Skip to content

Commit b4a73be

Browse files
committed
feat(selections): Add the ability to read integrity tag from selections
The `dub.selections.json` file can now contains integrity tags matching the SRI specifications, allowing dub (and other tools, e.g. Nix) to better validate that the downloaded archive matches the expected version. However, Dub will not yet write the integrity tag, as it would result in a bad user experience. Since `dub` tries hard to reuse packages present on the filesystem, doing a `dub upgrade` could wipe the integrity tag (or not populate it) if the package is already present on the system, an issue which would manifest itself quite often for popular packages. In order to solve this issue, we could store the integrity tag on disk, however this can be done in another PR as such package metadata would be useful for other purposes as well.
1 parent 73659f8 commit b4a73be

File tree

4 files changed

+231
-14
lines changed

4 files changed

+231
-14
lines changed

source/dub/dub.d

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import dub.package_;
2020
import dub.packagemanager;
2121
import dub.packagesuppliers;
2222
import dub.project;
23+
import dub.recipe.selection : IntegrityTag;
2324
import dub.generators.generator;
2425
import dub.init;
2526

@@ -726,8 +727,14 @@ class Dub {
726727

727728
FetchOptions fetchOpts;
728729
fetchOpts |= (options & UpgradeOptions.preRelease) != 0 ? FetchOptions.usePrerelease : FetchOptions.none;
729-
if (!pack) this.fetch(name, ver.version_, fetchOpts, defaultPlacementLocation, "getting selected version");
730-
if ((options & UpgradeOptions.select) && name.toString() != m_project.rootPackage.name) {
730+
if (!pack) {
731+
auto tag = this.m_project.selections.getIntegrityTag(name);
732+
const isInitTag = tag is typeof(tag).init;
733+
this.fetch(name, ver.version_, fetchOpts, defaultPlacementLocation, tag);
734+
if (isInitTag)
735+
this.m_project.selections.selectVersion(name, ver.version_, tag);
736+
}
737+
else if ((options & UpgradeOptions.select) && name.toString() != m_project.rootPackage.name) {
731738
if (!ver.repository.empty) {
732739
m_project.selections.selectVersion(name, ver.repository);
733740
} else if (ver.path.empty) {
@@ -940,6 +947,10 @@ class Dub {
940947
* options = A set of options used for fetching / matching versions.
941948
* location = Where to store the retrieved package. Default to the
942949
* configured `defaultPlacementLocation`.
950+
* tag = Dual-purpose `IntegrityTag` parameter. If it is specified
951+
* (in its non-`init` state), then it will be used to verify
952+
* the content of the download. If it is left in its init state,
953+
* it will be populated with a sha512 checksum post-download.
943954
* reason = Optionally, the reason for retriving this package.
944955
* This is used only for logging.
945956
*
@@ -952,28 +963,48 @@ class Dub {
952963
Package fetch(in PackageName name, in Version vers,
953964
FetchOptions options = FetchOptions.none, string reason = "")
954965
{
966+
IntegrityTag empty;
955967
return this.fetch(name, VersionRange(vers, vers), options,
956-
this.defaultPlacementLocation, reason);
968+
this.defaultPlacementLocation, empty, reason);
957969
}
958970

959971
/// Ditto
960972
Package fetch(in PackageName name, in Version vers, FetchOptions options,
961973
PlacementLocation location, string reason = "")
974+
{
975+
IntegrityTag empty;
976+
return this.fetch(name, VersionRange(vers, vers), options,
977+
this.defaultPlacementLocation, empty, reason);
978+
}
979+
980+
/// Ditto
981+
Package fetch(in PackageName name, in Version vers, FetchOptions options,
982+
PlacementLocation location, ref IntegrityTag integrity)
962983
{
963984
return this.fetch(name, VersionRange(vers, vers), options,
964-
this.defaultPlacementLocation, reason);
985+
this.defaultPlacementLocation, integrity, "getting selected version");
965986
}
966987

967988
/// Ditto
968989
Package fetch(in PackageName name, in VersionRange range = VersionRange.Any,
969990
FetchOptions options = FetchOptions.none, string reason = "")
970991
{
971-
return this.fetch(name, range, options, this.defaultPlacementLocation, reason);
992+
IntegrityTag empty;
993+
return this.fetch(name, range, options, this.defaultPlacementLocation,
994+
empty, reason);
972995
}
973996

974997
/// Ditto
975998
Package fetch(in PackageName name, in VersionRange range, FetchOptions options,
976999
PlacementLocation location, string reason = "")
1000+
{
1001+
IntegrityTag empty;
1002+
return this.fetch(name, range, options, location, empty, reason);
1003+
}
1004+
1005+
/// Ditto
1006+
Package fetch(in PackageName name, in VersionRange range, FetchOptions options,
1007+
PlacementLocation location, ref IntegrityTag tag, string reason = "")
9771008
{
9781009
Json pinfo;
9791010
PackageSupplier supplier;
@@ -1030,6 +1061,12 @@ class Dub {
10301061
import std.zip : ZipException;
10311062

10321063
auto data = supplier.fetchPackage(name.main, range, (options & FetchOptions.usePrerelease) != 0); // Q: continue on fail?
1064+
if (tag !is IntegrityTag.init)
1065+
enforce(tag.matches(data), ("Hash of downloaded package does " ~
1066+
"not match integrity tag for %s@%s - This can happen if " ~
1067+
"the version has been re-tagged").format(name.main, range));
1068+
else
1069+
tag = IntegrityTag.make(data);
10331070
logDiagnostic("Placing to %s...", location.toString());
10341071

10351072
try {

source/dub/packagemanager.d

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1291,7 +1291,7 @@ symlink_exit:
12911291
serialized["inheritable"] = true;
12921292
serialized["versions"] = Json.emptyObject;
12931293
foreach (p, dep; s.versions)
1294-
serialized["versions"][p] = dep.toJson(true);
1294+
serialized["versions"][p] = dep.toJsonDep();
12951295
return serialized;
12961296
}
12971297

source/dub/project.d

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1944,14 +1944,19 @@ public class SelectedVersions {
19441944
void selectVersion(string package_id, Version version_)
19451945
{
19461946
const name = PackageName(package_id);
1947-
return this.selectVersion(name, version_);
1947+
return this.selectVersionInternal(name, Dependency(version_));
19481948
}
19491949

19501950
/// Ditto
1951-
void selectVersion(in PackageName name, Version version_)
1951+
void selectVersion(in PackageName name, Version version_, in IntegrityTag tag = IntegrityTag.init)
19521952
{
1953-
const dep = Dependency(version_);
1954-
this.selectVersionInternal(name, dep);
1953+
auto dep = SelectedDependency(Dependency(version_), tag);
1954+
if (auto pdep = name.main.toString() in this.m_selections.versions) {
1955+
if (*pdep == dep)
1956+
return;
1957+
}
1958+
this.m_selections.versions[name.main.toString()] = dep;
1959+
this.m_dirty = true;
19551960
}
19561961

19571962
/// Selects a certain path for a specific package.
@@ -2050,6 +2055,14 @@ public class SelectedVersions {
20502055
return m_selections.versions[name.main.toString()];
20512056
}
20522057

2058+
/// Returns: The `IntegrityTag` associated to the version, or `.init` if none
2059+
IntegrityTag getIntegrityTag(in PackageName name) const
2060+
{
2061+
if (auto ptr = name.main.toString() in this.m_selections.versions)
2062+
return (*ptr).integrity;
2063+
return typeof(return).init;
2064+
}
2065+
20532066
/** Stores the selections to disk.
20542067
20552068
The target file will be written in JSON format. Usually, `defaultFile`

source/dub/recipe/selection.d

Lines changed: 171 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@
99
module dub.recipe.selection;
1010

1111
import dub.dependency;
12+
import dub.internal.vibecompat.data.json : Json;
1213
import dub.internal.vibecompat.inet.path : NativePath;
1314

1415
import dub.internal.configy.attributes;
1516
import dub.internal.dyaml.stdsumtype;
1617

18+
import std.algorithm.iteration : each;
19+
import std.algorithm.searching : canFind;
1720
import std.exception;
21+
import std.format : format;
22+
import std.range : enumerate;
23+
import std.string : indexOf;
1824

1925
deprecated("Use either `Selections!1` or `SelectionsFile` instead")
2026
public alias Selected = Selections!1;
@@ -125,21 +131,25 @@ public struct Selections (ushort Version)
125131

126132
/// Wrapper around `SelectedDependency` to do deserialization but still provide
127133
/// a `Dependency` object to client code.
128-
private struct SelectedDependency
134+
package(dub) struct SelectedDependency
129135
{
130136
public Dependency actual;
131137
alias actual this;
138+
public IntegrityTag integrity;
132139

133140
/// Constructor, used in `fromConfig`
134-
public this (inout(Dependency) dep) inout @safe pure nothrow @nogc
141+
public this (inout(Dependency) dep, const IntegrityTag tag = IntegrityTag.init)
142+
inout @safe pure nothrow @nogc
135143
{
136144
this.actual = dep;
145+
this.integrity = tag;
137146
}
138147

139148
/// Allow external code to assign to this object as if it was a `Dependency`
140149
public ref SelectedDependency opAssign (Dependency dep) return pure nothrow @nogc
141150
{
142151
this.actual = dep;
152+
this.integrity = IntegrityTag.init;
143153
return this;
144154
}
145155

@@ -157,16 +167,41 @@ private struct SelectedDependency
157167
assert(d.version_.length);
158168
if (d.repository.length)
159169
return SelectedDependency(Dependency(Repository(d.repository, d.version_)));
160-
return SelectedDependency(Dependency(Version(d.version_)));
170+
return SelectedDependency(Dependency(Version(d.version_)), d.integrity);
161171
}
162172
}
163173

174+
/// Serializes a selected version to JSON for `dub.selections.json`
175+
public Json toJsonDep () const {
176+
version (none) {
177+
// The following is not yet enabled, because we're currently only
178+
// able to get an integrity tag value when the package is first
179+
// downloaded. This is problematic as most of the time, we try
180+
// to reuse packages, and most common use of `dub upgrade` would
181+
// make the integrity tag flip between empty or not.
182+
// However, with this code enabled, one may get an integrity tag
183+
// written to their `dub.selections.json` under two conditions:
184+
// 1) The package is not present on the file system;
185+
// 2) The package is upgraded (e.g. `dub upgrade` would normally trigger);
186+
if (this.integrity.value.length && this.actual.isExactVersion()) {
187+
const vers = this.actual.version_();
188+
Json result = Json.emptyObject;
189+
result["version"] = Json(vers.toString());
190+
result["integrity"] = Json(
191+
"%s-%s".format(this.integrity.algorithm, this.integrity.value));
192+
return result;
193+
}
194+
}
195+
return this.actual.toJson(true);
196+
}
197+
164198
/// In-file representation of a dependency as permitted in `dub.selections.json`
165199
private struct YAMLFormat
166200
{
167201
@Optional @Name("version") string version_;
168202
@Optional string path;
169203
@Optional string repository;
204+
@Optional IntegrityTag integrity;
170205

171206
public void validate () const scope @safe pure
172207
{
@@ -178,10 +213,134 @@ private struct SelectedDependency
178213
"Cannot provide a `path` dependency if a `version` dependency is used");
179214
enforce(!this.repository.length || this.version_.length,
180215
"Cannot provide a `repository` dependency without a `version`");
216+
enforce(!this.integrity.algorithm.length || (!this.path.length && !this.repository.length),
217+
"`integrity` property is only supported for `version` dependencies");
181218
}
182219
}
183220
}
184221

222+
/**
223+
* A subresource integrity declaration
224+
*
225+
* Implement the SRI (Subresource Integrity) standard, used to validate that
226+
* a given dependency is of the expected version.
227+
*
228+
* One may get an integrity tag in base64 using openssl:
229+
* ```
230+
* $ cat vibe.d-0.10.1.zip | openssl dgst -binary -sha512 | base64
231+
* vwQ9tYTjLb981j41+3GZZUgKXm/5PlKpmY2bplRSUM8ajL03++LGm/TcfFFarJrHex8CTb5ZLWdi
232+
* Y1fFAOSkSw==
233+
* ```
234+
*
235+
* See_Also:
236+
* https://w3c.github.io/webappsec-subresource-integrity/#the-integrity-attribute
237+
*/
238+
public struct IntegrityTag
239+
{
240+
/// The hash function to use
241+
public string algorithm;
242+
/// The value of the digest computed with `algorithm`, base64-encoded
243+
public string value;
244+
245+
/// Parses a string representation as an `IntegrityTag`
246+
public this (string value)
247+
{
248+
auto sep = indexOf(value, '-');
249+
enforce(sep > 0, `Expected a string in the form 'hash-algorithm "-" base64-value', e.g. 'sha512-...'`);
250+
this.algorithm = value[0 .. sep];
251+
this.value = value[sep + 1 .. $];
252+
switch (this.algorithm) {
253+
case "sha512":
254+
enforce(this.value.length == 88,
255+
"Excepted a base64-encoded sha512 digest of 88 characters, not %s"
256+
.format(this.value.length));
257+
break;
258+
case "sha384":
259+
enforce(this.value.length == 64,
260+
"Excepted a base64-encoded sha384 digest of 64 characters, not %s"
261+
.format(this.value.length));
262+
break;
263+
case "sha256":
264+
enforce(this.value.length == 40,
265+
"Excepted a base64-encoded sha256 digest of 40 characters, not %s"
266+
.format(this.value.length));
267+
break;
268+
default:
269+
throw new Exception("Algorithm '" ~ this.algorithm ~
270+
"' is not supported, expected one of: 'sha512', 'sha384', 'sha256'");
271+
}
272+
this.value.enumerate.each!((size_t idx, dchar c) {
273+
enforce("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=".canFind(c),
274+
"Expected digest to be base64 encoded, found non-base64 character '%s' at index '%s'"
275+
.format(c, idx));
276+
});
277+
}
278+
279+
/// Internal constructor for `IntegrityTag.make`
280+
public this (string algorithm, string value) inout @safe pure nothrow @nogc {
281+
this.algorithm = algorithm;
282+
this.value = value;
283+
}
284+
285+
/**
286+
* Verify if the data passed in parameter matches this `IntegrityTag`
287+
*
288+
* Params:
289+
* data = The content of the archive to check for a match
290+
*
291+
* Returns:
292+
* Whether the hash of `data` (using `this.algorithm)` matches
293+
* the value that is `base64` encoded.
294+
*/
295+
public bool matches (in ubyte[] data) const @safe pure {
296+
import std.base64;
297+
import std.digest.sha;
298+
299+
ubyte[64] buffer; // 32, 48, or 64 bytes used
300+
auto decoded = Base64.decode(this.value, buffer[]);
301+
switch (this.algorithm) {
302+
case "sha512":
303+
return sha512Of(data) == decoded;
304+
case "sha384":
305+
return sha384Of(data) == decoded;
306+
case "sha256":
307+
return sha256Of(data) == decoded;
308+
default:
309+
assert(0, "An `IntegrityTag` with non-supported algorithm was created: " ~ this.algorithm);
310+
}
311+
}
312+
313+
/**
314+
* Build and returns an `IntegrityTag`
315+
*
316+
* This is a convenience function to build an `IntegrityTag` from the
317+
* archive data. Use sha512 by default.
318+
*
319+
* Params:
320+
* data = The content of the archive to check hash into a digest
321+
* algorithm = One of `sha256`, `sha384`, `sha512`. Default to the latter.
322+
*
323+
* Returns:
324+
* A populated `IntegrityTag`.
325+
*/
326+
public static IntegrityTag make (in ubyte[] data, string algorithm = "sha512")
327+
@safe pure {
328+
import std.base64;
329+
import std.digest.sha;
330+
331+
switch (algorithm) {
332+
case "sha512":
333+
return IntegrityTag(algorithm, Base64.encode(sha512Of(data)));
334+
case "sha384":
335+
return IntegrityTag(algorithm, Base64.encode(sha384Of(data)));
336+
case "sha256":
337+
return IntegrityTag(algorithm, Base64.encode(sha256Of(data)));
338+
default:
339+
assert(0, "`IntegrityTag.make` was called with non-supported algorithm: " ~ algorithm);
340+
}
341+
}
342+
}
343+
185344
// Ensure we can read all type of dependencies
186345
unittest
187346
{
@@ -191,6 +350,10 @@ unittest
191350
"fileVersion": 1,
192351
"versions": {
193352
"simple": "1.5.6",
353+
"complex": { "version": "1.2.3" },
354+
"digest": { "version": "1.2.3", "integrity": "sha256-abcdefghijklmnopqrstuvwxyz0123456789+/==" },
355+
"digest1": { "version": "1.2.3", "integrity": "sha384-Li9vy3DqF8tnTXuiaAJuML3ky+er10rcgNR/VqsVpcw+ThHmYcwiB1pbOxEbzJr7" },
356+
"digest2": { "version": "1.2.3", "integrity": "sha512-Q2bFTOhEALkN8hOms2FKTDLy7eugP2zFZ1T8LCvX42Fp3WoNr3bjZSAHeOsHrbV1Fu9/A0EzCinRE7Af1ofPrw==" },
194357
"branch": "~master",
195358
"branch2": "~main",
196359
"path": { "path": "../some/where" },
@@ -205,8 +368,12 @@ unittest
205368
(s) { assert(0); return Selections!(1).init; },
206369
);
207370
assert(!s.inheritable);
208-
assert(s.versions.length == 5);
371+
assert(s.versions.length == 9);
209372
assert(s.versions["simple"] == Dependency(Version("1.5.6")));
373+
assert(s.versions["complex"] == Dependency(Version("1.2.3")));
374+
assert(s.versions["digest"] == Dependency(Version("1.2.3")));
375+
assert(s.versions["digest1"] == Dependency(Version("1.2.3")));
376+
assert(s.versions["digest2"] == Dependency(Version("1.2.3")));
210377
assert(s.versions["branch"] == Dependency(Version("~master")));
211378
assert(s.versions["branch2"] == Dependency(Version("~main")));
212379
assert(s.versions["path"] == Dependency(NativePath("../some/where")));

0 commit comments

Comments
 (0)