fiftyone.pipeline.did 4.5.37 → 4.5.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/didClient.js CHANGED
@@ -21,6 +21,7 @@
21
21
  * ********************************************************************* */
22
22
 
23
23
  const FodId = require('./fodId');
24
+ const layout = require('./internal/layout');
24
25
  const IdType = require('./idType');
25
26
  const packageVersion = require('./package.json').version;
26
27
 
@@ -777,7 +778,7 @@ function ensureEncodedLength (value) {
777
778
  * @returns {Date} the moment the envelope says it was created
778
779
  */
779
780
  function dateOf (fodId) {
780
- return new Date(OWID_EPOCH_MS + fodId.dateMinutes * MINUTE_MS);
781
+ return new Date(OWID_EPOCH_MS + fodId.date * MINUTE_MS);
781
782
  }
782
783
 
783
784
  /**
@@ -794,9 +795,9 @@ function dateOf (fodId) {
794
795
  */
795
796
  function payloadLengthValid (fodId) {
796
797
  const matchKeyLength = fodId.type === IdType.RANDOM
797
- ? FodId.GUID_LENGTH
798
- : FodId.MATCH_KEY_LENGTH;
799
- return fodId.payload.length >= FodId.HEADER_LENGTH + matchKeyLength;
798
+ ? layout.GUID_LENGTH
799
+ : layout.MATCH_KEY_LENGTH;
800
+ return fodId.payload.length >= layout.HEADER_LENGTH + matchKeyLength;
800
801
  }
801
802
 
802
803
  /**
@@ -33,7 +33,12 @@
33
33
  */
34
34
 
35
35
  const { webcrypto } = require('crypto');
36
- const { FodId, IdType } = require('../index');
36
+ const { FodId, IdType, Usage } = require('../index');
37
+ // The payload byte layout is internal to the package, so a consumer
38
+ // never reaches for it. This example builds a sample 51Did to work on,
39
+ // which the cloud would otherwise issue, so it reads the layout from
40
+ // inside the package.
41
+ const layout = require('../internal/layout');
37
42
 
38
43
  const subtle = webcrypto.subtle;
39
44
  const VERSION = 2;
@@ -45,13 +50,17 @@ function uint32LE (v) {
45
50
  }
46
51
 
47
52
  function samplePayload () {
48
- const p = new Uint8Array(FodId.PAYLOAD_LENGTH); // Probabilistic (flags 0x00)
49
- p[FodId.LICENSE_ID_OFFSET] = 0x78;
50
- p[FodId.LICENSE_ID_OFFSET + 1] = 0x56;
51
- p[FodId.LICENSE_ID_OFFSET + 2] = 0x34;
52
- p[FodId.LICENSE_ID_OFFSET + 3] = 0x12;
53
- for (let i = 0; i < FodId.MATCH_KEY_LENGTH; i++) {
54
- p[FodId.MATCH_KEY_OFFSET + i] = 0x20 + i;
53
+ const p = new Uint8Array(layout.PAYLOAD_LENGTH);
54
+ // Bits 6 and 7 are zero, so the type is Probabilistic. Bits 0 to 2 are
55
+ // the usage and they are cumulative, so 0b011 grants standard
56
+ // marketing and, with it, non-marketing use.
57
+ p[layout.FLAGS_OFFSET] = 0b0000_0011;
58
+ p[layout.LICENSE_ID_OFFSET] = 0x78;
59
+ p[layout.LICENSE_ID_OFFSET + 1] = 0x56;
60
+ p[layout.LICENSE_ID_OFFSET + 2] = 0x34;
61
+ p[layout.LICENSE_ID_OFFSET + 3] = 0x12;
62
+ for (let i = 0; i < layout.MATCH_KEY_LENGTH; i++) {
63
+ p[layout.MATCH_KEY_OFFSET + i] = 0x20 + i;
55
64
  }
56
65
  return p;
57
66
  }
@@ -94,7 +103,8 @@ async function run () {
94
103
  console.log('51Did parsed from base64:');
95
104
  console.log(' Domain :', fodId.domain);
96
105
  console.log(' Type :', IdType.name(fodId.type));
97
- console.log(' Flags : 0x' + fodId.flags.toString(16));
106
+ console.log(' Usage :', Usage.name(fodId.usage));
107
+ console.log(' From cons.:', fodId.usageFromConsent);
98
108
  console.log(' LicenseId :', fodId.licenseId);
99
109
  console.log(' Match key :', Buffer.from(fodId.matchKey).toString('hex'));
100
110
  console.log(' Verifies :', await fodId.verify(publicPem));
@@ -129,7 +139,7 @@ async function run () {
129
139
  ', status ' + result.status);
130
140
  }
131
141
  const shortPayload = await issue(
132
- keyPair.privateKey, payload.slice(0, FodId.PAYLOAD_LENGTH - 1), DATE);
142
+ keyPair.privateKey, payload.slice(0, layout.PAYLOAD_LENGTH - 1), DATE);
133
143
  const short = FodId.tryParse(shortPayload);
134
144
  console.log(' a payload one byte short -> ok ' + short.ok +
135
145
  ', status ' + short.status);
package/fodId.js CHANGED
@@ -21,7 +21,9 @@
21
21
  * ********************************************************************* */
22
22
 
23
23
  const owid = require('owid');
24
+ const layout = require('./internal/layout');
24
25
  const IdType = require('./idType');
26
+ const Usage = require('./usage');
25
27
  const FodIdParseError = require('./fodIdParseError');
26
28
 
27
29
  /**
@@ -82,42 +84,17 @@ const ParseStatus = Object.freeze(Object.assign({}, owid.ParseStatus, {
82
84
  * A FodId composes the OWID the OWID library read (holds it and delegates
83
85
  * the envelope fields to it). That OWID is frozen and hands out its byte
84
86
  * arrays as copies, so nothing a caller holds can change the identifier.
87
+ *
88
+ * Every field has a named accessor here, so nothing needs the payload
89
+ * bytes or their offsets. The byte layout itself is specified once for all
90
+ * languages, and that specification is the authority rather than this
91
+ * comment:
92
+ * https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md
93
+ *
94
+ * What each 51Did package offers on top of that layout is specified at:
95
+ * https://github.com/51Degrees/specifications/blob/main/did-specification/package-surface.md
85
96
  */
86
97
  class FodId {
87
- static FLAGS_OFFSET = 0;
88
- static LICENSE_ID_OFFSET = 1;
89
- static LICENSE_ID_LENGTH = 4;
90
- /** Byte offset of the match key field within the payload. */
91
- static MATCH_KEY_OFFSET = 5;
92
- /**
93
- * Byte length of the match key field for Probabilistic and HashedEmail
94
- * identifiers, being a SHA-256.
95
- */
96
- static MATCH_KEY_LENGTH = 32;
97
- /**
98
- * Deprecated alias for {@link FodId.MATCH_KEY_OFFSET}. The stable,
99
- * comparable part of a 51Did is now called the match key, mirroring the
100
- * Model Terms for Marketing vocabulary. This alias will be removed in a
101
- * future release.
102
- * @deprecated Renamed to MATCH_KEY_OFFSET. This alias will be removed in
103
- * a future release.
104
- */
105
- static HASH_OFFSET = FodId.MATCH_KEY_OFFSET;
106
- /**
107
- * Deprecated alias for {@link FodId.MATCH_KEY_LENGTH}. The stable,
108
- * comparable part of a 51Did is now called the match key, mirroring the
109
- * Model Terms for Marketing vocabulary. This alias will be removed in a
110
- * future release.
111
- * @deprecated Renamed to MATCH_KEY_LENGTH. This alias will be removed in
112
- * a future release.
113
- */
114
- static HASH_LENGTH = FodId.MATCH_KEY_LENGTH;
115
- static HEADER_LENGTH = FodId.MATCH_KEY_OFFSET;
116
- /** Byte length of the GUID match key carried by Random identifiers. */
117
- static GUID_LENGTH = 16;
118
- static RANDOM_PAYLOAD_LENGTH = 21;
119
- static PAYLOAD_LENGTH = FodId.MATCH_KEY_OFFSET + FodId.MATCH_KEY_LENGTH;
120
-
121
98
  /**
122
99
  * Why a read succeeded or failed, being the OWID library's statuses plus
123
100
  * `PAYLOAD_TOO_SHORT` and `INVALID_TYPE_PAYLOAD_LENGTH`. Frozen.
@@ -303,16 +280,31 @@ class FodId {
303
280
  return new FodId(owidInstance);
304
281
  }
305
282
 
306
- /** @returns {number} the 1-byte usage flags bit-mask (0-255). */
307
- get flags () {
308
- return this._flags;
309
- }
310
-
311
283
  /** @returns {number} the IdType carried in bits 6-7 of the flags. */
312
284
  get type () {
313
285
  return IdType.fromFlags(this._flags);
314
286
  }
315
287
 
288
+ /**
289
+ * The Usage carried in bits 0-2 of the flags, as the highest usage
290
+ * granted. See Usage for why it is read that way.
291
+ * @returns {number} a Usage value
292
+ */
293
+ get usage () {
294
+ return Usage.fromFlags(this._flags);
295
+ }
296
+
297
+ /**
298
+ * Whether the usage was derived from an IAB consent string the caller
299
+ * sent, rather than stated by the caller directly. Bit 3 of the flags.
300
+ * Both are legitimate ways to arrive at a usage, and this says nothing
301
+ * about which usage it is.
302
+ * @returns {boolean}
303
+ */
304
+ get usageFromConsent () {
305
+ return (this._flags & 0b1000) !== 0;
306
+ }
307
+
316
308
  /**
317
309
  * The 4-byte little-endian field at offset 1 of the payload, as an
318
310
  * unsigned integer (0-4294967295).
@@ -341,19 +333,6 @@ class FodId {
341
333
  return this._matchKey.slice();
342
334
  }
343
335
 
344
- /**
345
- * Deprecated alias for {@link FodId#matchKey}. The stable, comparable
346
- * part of a 51Did is now called the match key, mirroring the Model Terms
347
- * for Marketing vocabulary. This alias will be removed in a future
348
- * release.
349
- * @deprecated Renamed to matchKey. This alias will be removed in a future
350
- * release.
351
- * @returns {Uint8Array} the same bytes as {@link FodId#matchKey}
352
- */
353
- get hash () {
354
- return this.matchKey;
355
- }
356
-
357
336
  /** @returns {number} the OWID version. */
358
337
  get version () {
359
338
  return this._owid.version;
@@ -365,24 +344,13 @@ class FodId {
365
344
  }
366
345
 
367
346
  /**
368
- * @returns {number} the OWID date as minutes since 2020-01-01 UTC, as an
369
- * unsigned 32-bit number, the same value as {@link FodId#dateMinutes}.
370
- */
371
- get date () {
372
- return this._owid.date;
373
- }
374
-
375
- /**
376
- * The envelope's own date as the unsigned 32-bit count of minutes since
377
- * 2020-01-01T00:00:00Z, exactly as the wire carries it. This is the value
378
- * the OWID `public-key?date=` parameter takes, and the integer to use when
379
- * comparing creation times. The OWID library now reads the field unsigned
380
- * too, so {@link FodId#date} agrees with this getter. The getter is kept
381
- * because callers were told to use it, and it still forces the unsigned
382
- * reading should the field ever arrive signed.
347
+ * The envelope's own date, being the count of minutes since
348
+ * 2020-01-01T00:00:00Z exactly as the wire carries it, read as an
349
+ * unsigned 32-bit number. This is the integer to compare when asking
350
+ * which of two identifiers was issued first.
383
351
  * @returns {number} minutes since 2020-01-01T00:00:00Z
384
352
  */
385
- get dateMinutes () {
353
+ get date () {
386
354
  return this._owid.date >>> 0;
387
355
  }
388
356
 
@@ -464,34 +432,34 @@ class FodId {
464
432
  */
465
433
  function unpack (payload) {
466
434
  const length = payload.length;
467
- if (length < FodId.HEADER_LENGTH) {
435
+ if (length < layout.HEADER_LENGTH) {
468
436
  return {
469
437
  status: ParseStatus.PAYLOAD_TOO_SHORT,
470
438
  length,
471
- required: FodId.HEADER_LENGTH
439
+ required: layout.HEADER_LENGTH
472
440
  };
473
441
  }
474
- const flags = payload[FodId.FLAGS_OFFSET];
442
+ const flags = payload[layout.FLAGS_OFFSET];
475
443
  // Little-endian unsigned 32-bit. `>>> 0` forces unsigned so the high bit
476
444
  // does not produce a negative number.
477
445
  const licenseId = (
478
- payload[FodId.LICENSE_ID_OFFSET] |
479
- (payload[FodId.LICENSE_ID_OFFSET + 1] << 8) |
480
- (payload[FodId.LICENSE_ID_OFFSET + 2] << 16) |
481
- (payload[FodId.LICENSE_ID_OFFSET + 3] << 24)
446
+ payload[layout.LICENSE_ID_OFFSET] |
447
+ (payload[layout.LICENSE_ID_OFFSET + 1] << 8) |
448
+ (payload[layout.LICENSE_ID_OFFSET + 2] << 16) |
449
+ (payload[layout.LICENSE_ID_OFFSET + 3] << 24)
482
450
  ) >>> 0;
483
451
  const type = IdType.fromFlags(flags);
484
452
  let matchKeyLength;
485
453
  if (type === IdType.RANDOM) {
486
- matchKeyLength = FodId.GUID_LENGTH;
454
+ matchKeyLength = layout.GUID_LENGTH;
487
455
  } else if (type === IdType.RESERVED) {
488
456
  // Not yet assigned, so read best-effort, whatever follows the header
489
457
  // is the match key.
490
- matchKeyLength = length - FodId.HEADER_LENGTH;
458
+ matchKeyLength = length - layout.HEADER_LENGTH;
491
459
  } else {
492
- matchKeyLength = FodId.MATCH_KEY_LENGTH;
460
+ matchKeyLength = layout.MATCH_KEY_LENGTH;
493
461
  }
494
- const required = FodId.HEADER_LENGTH + matchKeyLength;
462
+ const required = layout.HEADER_LENGTH + matchKeyLength;
495
463
  if (length < required) {
496
464
  return {
497
465
  status: ParseStatus.INVALID_TYPE_PAYLOAD_LENGTH,
@@ -506,7 +474,7 @@ function unpack (payload) {
506
474
  licenseId,
507
475
  // slice() copies, so the stored match key is this identifier's own.
508
476
  matchKey: payload.slice(
509
- FodId.MATCH_KEY_OFFSET, FodId.MATCH_KEY_OFFSET + matchKeyLength),
477
+ layout.MATCH_KEY_OFFSET, layout.MATCH_KEY_OFFSET + matchKeyLength),
510
478
  length,
511
479
  required
512
480
  };
package/index.js CHANGED
@@ -23,6 +23,7 @@
23
23
  const FodId = require('./fodId');
24
24
  const FodIdParseError = require('./fodIdParseError');
25
25
  const IdType = require('./idType');
26
+ const Usage = require('./usage');
26
27
  const {
27
28
  DidClient,
28
29
  RedeemResult,
@@ -39,6 +40,7 @@ module.exports = {
39
40
  FodId,
40
41
  FodIdParseError,
41
42
  IdType,
43
+ Usage,
42
44
  DidClient,
43
45
  RedeemResult,
44
46
  ContextResult,
@@ -0,0 +1,62 @@
1
+ /* *********************************************************************
2
+ * This Original Work is copyright of 51 Degrees Mobile Experts Limited.
3
+ * Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
4
+ * Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
5
+ *
6
+ * This Original Work is licensed under the European Union Public Licence
7
+ * (EUPL) v.1.2 and is subject to its terms as set out below.
8
+ *
9
+ * If a copy of the EUPL was not distributed with this file, You can obtain
10
+ * one at https://opensource.org/licenses/EUPL-1.2.
11
+ *
12
+ * The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
13
+ * amended by the European Commission) shall be deemed incompatible for
14
+ * the purposes of the Work and the provisions of the compatibility
15
+ * clause in Article 5 of the EUPL shall not apply.
16
+ *
17
+ * If using the Work as, or as part of, a network application, by
18
+ * including the attribution notice(s) required under Article 5 of the EUPL
19
+ * in the end user terms of the application under an appropriate heading,
20
+ * such notice(s) shall fulfill the requirements of that article.
21
+ * ********************************************************************* */
22
+
23
+ /**
24
+ * The byte layout of a 51Did payload. Internal to this package, because the
25
+ * only reason to read the payload by hand is to read a field that already
26
+ * has a name on FodId, and reading the flags byte by hand is how the usage
27
+ * comes out backwards. The usage bits are cumulative, so a caller masking
28
+ * for the non-marketing bit alone reads every marketing identifier as
29
+ * non-marketing. Use the FodId accessors instead.
30
+ *
31
+ * The layout is described once for every language in the 51Did
32
+ * specification, which is the authority for these numbers:
33
+ * https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md
34
+ *
35
+ * What each package offers on top of that layout is described in:
36
+ * https://github.com/51Degrees/specifications/blob/main/did-specification/package-surface.md
37
+ */
38
+ module.exports = Object.freeze({
39
+ /** Byte offset of the flags byte within the payload. */
40
+ FLAGS_OFFSET: 0,
41
+ /** Byte offset of the licence id field within the payload. */
42
+ LICENSE_ID_OFFSET: 1,
43
+ /** Byte length of the licence id field. */
44
+ LICENSE_ID_LENGTH: 4,
45
+ /** Byte offset of the match key field within the payload. */
46
+ MATCH_KEY_OFFSET: 5,
47
+ /**
48
+ * Byte length of the match key field for Probabilistic and HashedEmail
49
+ * identifiers, being a SHA-256.
50
+ */
51
+ MATCH_KEY_LENGTH: 32,
52
+ /** Byte length of the flags and licence id fields together. */
53
+ HEADER_LENGTH: 5,
54
+ /** Byte length of the GUID match key carried by Random identifiers. */
55
+ GUID_LENGTH: 16,
56
+ /** Least payload length for a Random identifier. */
57
+ RANDOM_PAYLOAD_LENGTH: 21,
58
+ /**
59
+ * Least payload length for a Probabilistic or HashedEmail identifier.
60
+ */
61
+ PAYLOAD_LENGTH: 37
62
+ });
@@ -22,6 +22,12 @@ public key from their well known end point and verifies the ECDSA signature
22
22
  locally. When `crypto.subtle` is not available it falls back to the creator's
23
23
  remote verify end point.
24
24
 
25
+ Both end points are versioned, `/owid/api/v<version>/creator` and
26
+ `/owid/api/v<version>/verify`, and the version in the path is the version
27
+ byte of the OWID being verified rather than a fixed number, because a
28
+ creator serves each version of the format at its own path and returns 404
29
+ for the others.
30
+
25
31
  The public key is requested for the OWID's own creation date
26
32
  (`?date=<minutes>`), so OWIDs signed before a signing-key rotation still
27
33
  verify. A creator that does not support the `date` parameter ignores it and
@@ -38,6 +44,12 @@ The headers are sent to every creator domain that a verification touches,
38
44
  so only set a credential that all the creators in the tree are meant to
39
45
  see.
40
46
 
47
+ A custom header makes the key request a non simple cross origin request, so
48
+ the browser sends a `OPTIONS` preflight first and the creator has to answer
49
+ it with `Access-Control-Allow-Headers` naming that header. A creator that
50
+ does not answer the preflight cannot be given a credential this way, and
51
+ the request never leaves the browser.
52
+
41
53
  ## Reading answers instead of throwing
42
54
 
43
55
  An OWID is read from whatever a caller was handed, which on a public end
@@ -45,6 +45,25 @@ const otherKeyPair = nodeCrypto.generateKeyPairSync('ec', {
45
45
  // 2021-04-06 12:59 UTC expressed as minutes since 2020-01-01 00:00 UTC.
46
46
  const testDateInMinutes = 664619;
47
47
 
48
+ // The version byte every OWID built here is written with. The mocked
49
+ // creator only answers the path for this version, and the expected URL is
50
+ // built from the version the library read back out of the OWID, so the test
51
+ // and the library take the version from the same place and a wrong version
52
+ // in either one fails rather than passing quietly.
53
+ const testVersion = 3;
54
+
55
+ /**
56
+ * The creator key URL the library must request for an OWID. The version
57
+ * segment comes from the OWID's own version byte, which is the value the
58
+ * library uses, so this expectation cannot drift away from the code.
59
+ * @param {Object} o - the OWID that was read.
60
+ * @returns {string} the expected URL.
61
+ */
62
+ function expectedCreatorUrl(o) {
63
+ return "//" + o.domain + "/owid/api/v" + o.version +
64
+ "/creator?date=" + o.date;
65
+ }
66
+
48
67
  /**
49
68
  * Reads an OWID that the test expects to be valid, asserting the three facts
50
69
  * a read always reports before handing back the OWID itself.
@@ -64,8 +83,9 @@ const wrongKeyDomain = "wrong-key.swan-demo.uk";
64
83
  const emptyKeyDomain = "empty-key.swan-demo.uk";
65
84
 
66
85
  /**
67
- * Builds the unsigned portion of a version 3 OWID as a byte array in the
68
- * same form the library serializes for verification.
86
+ * Builds the unsigned portion of an OWID, at the version named by
87
+ * testVersion, as a byte array in the same form the library serializes for
88
+ * verification.
69
89
  * @param {string} domain - the creator domain.
70
90
  * @param {number} dateInMinutes - minutes since the OWID base date.
71
91
  * @param {Buffer} payload - the payload bytes.
@@ -77,7 +97,7 @@ function buildUnsignedOWID(domain, dateInMinutes, payload) {
77
97
  var length = Buffer.alloc(4);
78
98
  length.writeUInt32LE(payload.length);
79
99
  return Buffer.concat([
80
- Buffer.from([3]),
100
+ Buffer.from([testVersion]),
81
101
  Buffer.from(domain, 'ascii'),
82
102
  Buffer.from([0]),
83
103
  date,
@@ -132,7 +152,10 @@ beforeEach(() => {
132
152
  }
133
153
  var url = new URL(urlString);
134
154
 
135
- if (url.pathname.endsWith("/creator")) {
155
+ // A real creator serves each version of the format at its own
156
+ // path and returns 404 for the others, so answering any version
157
+ // here would hide a request sent to the wrong one.
158
+ if (url.pathname === "/owid/api/v" + testVersion + "/creator") {
136
159
  // This domain returns a header only PEM with no key body, which
137
160
  // exercises the empty public key guard in the library.
138
161
  if (url.hostname == emptyKeyDomain) {
@@ -169,8 +192,65 @@ test('crypto verify valid OWID passes', () => {
169
192
  // The library must have used the public key path, so the only
170
193
  // request is to the creator end point.
171
194
  expect(fetch.mock.calls.length).toBe(1);
195
+ expect(fetch.mock.calls[0][0]).toBe(expectedCreatorUrl(o));
196
+ });
197
+ });
198
+
199
+ test('crypto verify asks the end point for the version the OWID carries', () => {
200
+ // Every other OWID in this suite is version 3, the same value a constant
201
+ // in the path would give, so this is the test that catches a constant
202
+ // put back. A version 2 OWID must ask the version 2 end point, which
203
+ // the mocked creator does not serve, so the key is unavailable and the
204
+ // one request that was made names the right path.
205
+ var date = Buffer.alloc(4);
206
+ date.writeUInt32LE(testDateInMinutes);
207
+ var payload = Buffer.from("example");
208
+ var length = Buffer.alloc(4);
209
+ length.writeUInt32LE(payload.length);
210
+ var unsigned = Buffer.concat([
211
+ Buffer.from([2]),
212
+ Buffer.from(creatorDomain, 'ascii'),
213
+ Buffer.from([0]),
214
+ date,
215
+ length,
216
+ payload
217
+ ]);
218
+ var o = read(signOWID(unsigned, creatorKeyPair.privateKey));
219
+ expect(o.version).toBe(2);
220
+
221
+ return o.checkSignature().then(r => {
222
+ expect(r.status).toBe(owid.SignatureStatus.KEY_UNAVAILABLE);
223
+ expect(fetch.mock.calls.length).toBe(1);
172
224
  expect(fetch.mock.calls[0][0]).toBe(
173
- "//" + creatorDomain + "/owid/api/v1/creator?date=" + testDateInMinutes);
225
+ "//" + creatorDomain + "/owid/api/v2/creator?date=" +
226
+ testDateInMinutes);
227
+ });
228
+ });
229
+
230
+ // A creator whose domain answers with a redirect does not get the key at
231
+ // the other end trusted as its own. The library asks fetch not to follow,
232
+ // so the answer is the redirect itself, which is not ok and reads as the
233
+ // key being unavailable, and no second request is made. Without this a
234
+ // network attacker able to bend a creator's DNS, or a misconfigured
235
+ // creator, could substitute the key, and the publisher's fetchHeaders
236
+ // credential would travel to wherever the redirect pointed.
237
+ test('crypto verify does not follow a redirect from the creator', () => {
238
+ var redirectDomain = "redirecting.example";
239
+ fetchMock.mockResponse(req => {
240
+ return Promise.resolve({
241
+ status: 302,
242
+ headers: { Location: "https://elsewhere.example/key.pem" },
243
+ body: ""
244
+ });
245
+ });
246
+ var unsigned = buildUnsignedOWID(
247
+ redirectDomain, testDateInMinutes, Buffer.from("example"));
248
+ var o = read(signOWID(unsigned, creatorKeyPair.privateKey));
249
+
250
+ return o.checkSignature().then(r => {
251
+ expect(r.status).toBe(owid.SignatureStatus.KEY_UNAVAILABLE);
252
+ expect(fetch.mock.calls.length).toBe(1);
253
+ expect(fetch.mock.calls[0][1].redirect).toBe("manual");
174
254
  });
175
255
  });
176
256
 
@@ -139,6 +139,18 @@ fixtures.forEach(f => {
139
139
  publicKeys[f.domain] = f.publicKeySPKI;
140
140
  });
141
141
 
142
+ /**
143
+ * The creator key URL the library must request for an OWID. The version
144
+ * segment comes from the OWID's own version byte, which is the value the
145
+ * library uses, so this expectation cannot drift away from the code.
146
+ * @param {Object} o - the OWID that was read.
147
+ * @returns {string} the expected URL.
148
+ */
149
+ function expectedCreatorUrl(o) {
150
+ return "//" + o.domain + "/owid/api/v" + o.version +
151
+ "/creator?date=" + o.date;
152
+ }
153
+
142
154
  /**
143
155
  * Returns the base 64 string with a single byte changed at the given offset
144
156
  * of the decoded byte array.
@@ -164,7 +176,12 @@ beforeEach(() => {
164
176
  }
165
177
  var url = new URL(urlString);
166
178
 
167
- if (url.pathname.endsWith("/creator") && publicKeys[url.hostname]) {
179
+ // Every fixture is a version 3 OWID and a real creator serves each
180
+ // version of the format at its own path, returning 404 for the
181
+ // others, so the key is only handed back on the path that names the
182
+ // version the OWID was written in.
183
+ if (url.pathname === "/owid/api/v3/creator" &&
184
+ publicKeys[url.hostname]) {
168
185
  return Promise.resolve(JSON.stringify({
169
186
  publicKeySPKI: publicKeys[url.hostname]
170
187
  }));
@@ -186,8 +203,8 @@ fixtures.forEach(f => {
186
203
  // The library must have used the public key path, so the only
187
204
  // request is to the creator end point.
188
205
  expect(fetch.mock.calls.length).toBe(1);
189
- expect(fetch.mock.calls[0][0]).toBe(
190
- "//" + f.domain + "/owid/api/v1/creator?date=" + o.date);
206
+ expect(fetch.mock.calls[0][0]).toBe(expectedCreatorUrl(o));
207
+ expect(o.version).toBe(3);
191
208
  });
192
209
  });
193
210