diff --git a/fiftyone.pipeline.did/didClient.js b/fiftyone.pipeline.did/didClient.js index 66fec9e..4b8f9ae 100644 --- a/fiftyone.pipeline.did/didClient.js +++ b/fiftyone.pipeline.did/didClient.js @@ -125,6 +125,28 @@ const FactorResult = Object.freeze({ MISCONFIGURED: 'misconfigured' }); +/** + * The names of the creator context factors, as the cloud writes them as + * keys of `factors`, in the order the cloud lists them. The operating + * system and the browser each have a name and a version, so a version + * mismatch beside a verified name reads as an upgrade, and a mismatched + * name reads as a different operating system or browser. These four + * replaced the single `browser` factor from cloud release 4.4.38. + * {@link RedeemResult#factors} is not limited to these names, so a factor + * the cloud adds later still reaches the caller. + */ +const Factor = Object.freeze({ + TRANSPORT: 'transport', + DEVICE: 'device', + BROWSER_IP: 'browserip', + CONNECTION_IP: 'connectionip', + ASN: 'asn', + PLATFORM_NAME: 'platformname', + PLATFORM_VERSION: 'platformversion', + BROWSER_NAME: 'browsername', + BROWSER_VERSION: 'browserversion' +}); + /** * The reason a {@link DidClient#verifySignatureDetailed} answer was given. */ @@ -233,10 +255,14 @@ class RedeemResult { ? SignatureResult.INVALID : SignatureResult.UNKNOWN; /** - * @type {object | undefined} factor name to {@link FactorResult} value - * (or null where nothing was compared), present only when the cloud - * sent `factors`, which is the mismatch outcome. The names are - * transport, device, browserip, connectionip, asn and browser. + * @type {object | undefined} {@link Factor} name to + * {@link FactorResult} value (or null where nothing was compared), + * present only when the cloud sent `factors`, which it does where there + * is something to diagnose, being a mismatch or a misconfigured result + * that still compared some factors. Every name is kept exactly as the + * cloud sent it, including one this package does not list in + * {@link Factor}, so a factor the cloud adds later reaches the caller + * without a new release of this package. */ this.factors = parsed.factors && typeof parsed.factors === 'object' ? Object.freeze(Object.assign({}, parsed.factors)) @@ -861,6 +887,7 @@ module.exports = { ContextResult, SignatureResult, FactorResult, + Factor, SignatureReason, DidClientError, DidArgumentError, diff --git a/fiftyone.pipeline.did/examples/fodIdExample.js b/fiftyone.pipeline.did/examples/fodIdExample.js index 3a87b33..300bc90 100644 --- a/fiftyone.pipeline.did/examples/fodIdExample.js +++ b/fiftyone.pipeline.did/examples/fodIdExample.js @@ -113,7 +113,7 @@ async function run () { console.log(' Domain :', fodId.domain); console.log(' Type :', IdType.name(fodId.type)); console.log(' Usage :', Usage.name(fodId.usage)); - console.log(' From cons.:', fodId.usageFromConsent); + console.log(' Indirect :', fodId.usageIsIndirect); console.log(' LicenseId :', fodId.licenseId); console.log(' Match key :', Buffer.from(fodId.matchKey).toString('hex')); console.log(' Terms :', fodId.terms); diff --git a/fiftyone.pipeline.did/fodId.js b/fiftyone.pipeline.did/fodId.js index 7b7e795..b44271b 100644 --- a/fiftyone.pipeline.did/fodId.js +++ b/fiftyone.pipeline.did/fodId.js @@ -30,7 +30,7 @@ const FodIdParseError = require('./fodIdParseError'); /** * Why a read of a 51Did succeeded or failed. The OWID library's own * vocabulary is carried through unchanged, because a 51Did failing to be - * an OWID is reported exactly as the OWID library reported it, and two + * an OWID is reported exactly as the OWID library reported it, and four * members are added for the outcomes that belong to the 51Did payload * rather than to the envelope. Frozen, and compared by value rather than * by the text of any message. @@ -55,7 +55,13 @@ const ParseStatus = Object.freeze(Object.assign({}, owid.ParseStatus, { * layout this package knows would answer with values that are wrong * rather than absent. */ - UNSUPPORTED_PAYLOAD_VERSION: 'UnsupportedPayloadVersion' + UNSUPPORTED_PAYLOAD_VERSION: 'UnsupportedPayloadVersion', + /** + * Bits 0 to 2 of the flags byte are all clear, which is not a usage. The + * cloud writes no flags byte without bit 0, so such a payload is damaged + * or forged, and it is refused rather than offered as a fourth usage. + */ + NO_USAGE: 'NoUsage' })); /** @@ -106,7 +112,8 @@ const ParseStatus = Object.freeze(Object.assign({}, owid.ParseStatus, { class FodId { /** * Why a read succeeded or failed, being the OWID library's statuses plus - * `PAYLOAD_TOO_SHORT` and `INVALID_TYPE_PAYLOAD_LENGTH`. Frozen. + * `PAYLOAD_TOO_SHORT`, `INVALID_TYPE_PAYLOAD_LENGTH`, + * `UNSUPPORTED_PAYLOAD_VERSION` and `NO_USAGE`. Frozen. * @type {Readonly>} */ static ParseStatus = ParseStatus; @@ -306,13 +313,16 @@ class FodId { } /** - * Whether the usage was derived from an IAB consent string the caller - * sent, rather than stated by the caller directly. Bit 3 of the flags. - * Both are legitimate ways to arrive at a usage, and this says nothing - * about which usage it is. + * Whether the usage is indirect, being worked out by the issuer from a + * signal other than the caller stating it. Bit 3 of the flags. False + * means the caller stated the usage directly. A consent string is the + * only indirect signal today, so today this is true only where the usage + * was derived from one, but a later signal of another kind sets the same + * bit. Both are legitimate ways to arrive at a usage, and this says + * nothing about which usage it is. * @returns {boolean} */ - get usageFromConsent () { + get usageIsIndirect () { return (this._flags & 0b1000) !== 0; } @@ -462,10 +472,10 @@ class FodId { * @param {Uint8Array} payload the payload bytes * @returns {{status: string, flags?: number, licenseId?: number, * matchKey?: Uint8Array, termsIndex?: number, length: number, - * required: number, type?: number, payloadVersion?: number}} `status` - * PARSED with the fields, or a 51Did status with the length the type - * needed, and the version found where that is what the payload was refused - * for + * required: number, type?: number, payloadVersion?: number, + * usageBits?: number}} `status` PARSED with the fields, or a 51Did status + * with the length the type needed, and the version or the usage bits found + * where that is what the payload was refused for */ function unpack (payload) { const length = payload.length; @@ -491,6 +501,19 @@ function unpack (payload) { payloadVersion }; } + // Usage bits 000 are not a usage. The cloud writes no flags byte without + // bit 0, so a payload carrying them is damaged or forged, and there is + // nothing a caller could do with a fourth usage that a refusal does not + // already say, being that the identifier must not be passed on. + const usageBits = flags & 0b111; + if (usageBits === 0) { + return { + status: ParseStatus.NO_USAGE, + length, + required: layout.HEADER_LENGTH, + usageBits + }; + } // Little-endian unsigned 32-bit. `>>> 0` forces unsigned so the high bit // does not produce a negative number. const licenseId = ( @@ -616,7 +639,7 @@ function valueOrThrow (read) { } /** - * The exception for a failed read. The three 51Did payload statuses keep the + * The exception for a failed read. The four 51Did payload statuses keep the * RangeError this package has always thrown for them, and every OWID status * is a FodIdParseError carrying the status. Each error carries `status` so * the reason can be acted on without reading the message. @@ -638,6 +661,11 @@ function errorFor (read) { error = new RangeError( `51Did payload version ${read.detail.payloadVersion} is not one this ` + 'package can read.'); + } else if (read.status === ParseStatus.NO_USAGE) { + error = new RangeError( + '51Did payload carries usage bits ' + + read.detail.usageBits.toString(2).padStart(3, '0') + + ', which is not a usage.'); } else { return new FodIdParseError(read.status); } diff --git a/fiftyone.pipeline.did/fodIdParseError.js b/fiftyone.pipeline.did/fodIdParseError.js index 6747f7d..1727eaa 100644 --- a/fiftyone.pipeline.did/fodIdParseError.js +++ b/fiftyone.pipeline.did/fodIdParseError.js @@ -26,7 +26,7 @@ * constructor) when the OWID library refused the envelope. The status names * the reason in the same vocabulary the non-throwing surfaces report, so a * caller catching this can act on the reason without reading the message. - * The three 51Did payload statuses are thrown as RangeError instead, as this + * The four 51Did payload statuses are thrown as RangeError instead, as this * package has always thrown them, and that RangeError carries `status` too. */ class FodIdParseError extends Error { diff --git a/fiftyone.pipeline.did/index.js b/fiftyone.pipeline.did/index.js index 79c565b..d5af75b 100644 --- a/fiftyone.pipeline.did/index.js +++ b/fiftyone.pipeline.did/index.js @@ -30,6 +30,7 @@ const { ContextResult, SignatureResult, FactorResult, + Factor, SignatureReason, DidClientError, DidArgumentError, @@ -46,6 +47,7 @@ module.exports = { ContextResult, SignatureResult, FactorResult, + Factor, SignatureReason, DidClientError, DidArgumentError, diff --git a/fiftyone.pipeline.did/readme.md b/fiftyone.pipeline.did/readme.md index 8c60b8a..13f8b5d 100644 --- a/fiftyone.pipeline.did/readme.md +++ b/fiftyone.pipeline.did/readme.md @@ -88,7 +88,6 @@ read before the identifier is passed anywhere. | `Usage` | The cloud's `id.usage` | Meaning | | --- | --- | --- | -| `NONE` | none | No usage bit is set. The cloud never issues such an identifier, so treat it as one that may not be passed on | | `NON_MARKETING` | `non-marketing` | Created for use that is not marketing. Must never be passed to a demand source | | `STANDARD` | `standard` | Created for standard marketing, being targeting unrelated to browsing history | | `PERSONALIZED` | `personalized` | Created for personalized marketing, being targeting related to browsing history | @@ -101,14 +100,25 @@ non-marketing, which is the wrong way round for a rule that turns on it. `fodId.usage` answers with the highest usage granted, so that mistake cannot be made, and it is the only supported way to read the usage. -`fodId.usageFromConsent` says whether the usage was worked out from an IAB -consent string the caller sent rather than stated by the caller directly. Both -are legitimate ways to arrive at a usage, and it says nothing about which -usage it is. +There are exactly these three values. A payload whose usage bits are all +clear is not given a fourth value, and is refused with +`ParseStatus.NO_USAGE` instead, because the cloud never writes such a flags +byte and the only safe answer to one is not to pass the identifier on, which +the refusal already gives. + +`fodId.usageIsIndirect` says whether the usage is indirect, being worked out +by the issuer from a signal other than the caller stating it, or direct, +being stated by the caller. A consent string is the only indirect signal +today, so today it is true only where the usage was derived from an IAB +consent string the caller sent. Both are legitimate ways to arrive at a +usage, and it says nothing about which usage it is. It was called +`usageFromConsent` before the field was restated as direct against +indirect, and the old name has been removed. `Usage.name(usage)` gives the cross language name, for example `"NonMarketing"`, and `Usage.idUsage(usage)` gives the cloud's own `id.usage` -value, for example `"non-marketing"`, or `null` for `NONE`. +value, for example `"non-marketing"`. Both answer `null` for a value that +is not a `Usage`. ## The terms a 51Did was created under @@ -217,6 +227,7 @@ OWID library's status, so a specific reason is never reduced to a general one. | `PAYLOAD_TOO_SHORT` | 51Did | The payload is shorter than the 5 byte header (flags and licence id), so the type cannot be read | | `INVALID_TYPE_PAYLOAD_LENGTH` | 51Did | The header named a type and the payload is shorter than that type's match key needs, being 21 bytes for Random and 37 for Probabilistic and HashedEmail | | `UNSUPPORTED_PAYLOAD_VERSION` | 51Did | Bits 4 and 5 of the flags byte name a payload layout version this package does not know, so no field is read | +| `NO_USAGE` | 51Did | Bits 0 to 2 of the flags byte are all clear, which is not a usage. The cloud never writes such a flags byte, so the identifier is damaged or forged | A Reserved type is not yet assigned, so the reader accepts it at any length from the header up and exposes whatever follows the header as the match key. @@ -230,7 +241,7 @@ exception. They run the same checks, in the same order, and throw: | Thrown | When | | --- | --- | | `TypeError` | The argument is the wrong kind of thing, being `null`, `undefined`, a non-string to `fromBase64`, or a non-`Uint8Array` to `fromByteArray` | -| `RangeError` | The payload is `PAYLOAD_TOO_SHORT`, `INVALID_TYPE_PAYLOAD_LENGTH` or `UNSUPPORTED_PAYLOAD_VERSION`, being the three statuses the 51Did payload rules produce. The error carries `status` | +| `RangeError` | The payload is `PAYLOAD_TOO_SHORT`, `INVALID_TYPE_PAYLOAD_LENGTH`, `UNSUPPORTED_PAYLOAD_VERSION` or `NO_USAGE`, being the four statuses the 51Did payload rules produce. The error carries `status` | | `FodIdParseError` | The OWID library refused the envelope for any other status. The error carries `status` | A wrong argument type is a programming error and stays exceptional on every @@ -263,7 +274,7 @@ and `PAYLOAD_LENGTH` are no longer part of the package. | Before | After | | --- | --- | -| `fodId.flags` masked for a usage bit | `fodId.usage`, and `fodId.usageFromConsent` for bit 3 | +| `fodId.flags` masked for a usage bit | `fodId.usage`, and `fodId.usageIsIndirect` for bit 3 | | `fodId.flags` masked for the type bits | `fodId.type` | | `fodId.hash` | `fodId.matchKey`, the same bytes under the name the Model Terms for Marketing use | | `fodId.dateMinutes` | `fodId.date`, which reports the same unsigned value | @@ -310,14 +321,14 @@ npm test ## Usage ```js -const { FodId, IdType, Usage, Terms } = require('fiftyone.pipeline.did'); +const { FodId, IdType, Usage } = require('fiftyone.pipeline.did'); // Either base64 alphabet is accepted, the standard one the cloud issues and // the URL-safe one a page puts in a link, with or without padding. const fodId = FodId.fromBase64(base64FromCloudService); const usage = fodId.usage; // Usage.NON_MARKETING / STANDARD / PERSONALIZED -const fromConsent = fodId.usageFromConsent; +const indirect = fodId.usageIsIndirect; const type = fodId.type; // IdType.PROBABILISTIC / RANDOM / HASHED_EMAIL const licenseId = fodId.licenseId; const matchKey = fodId.matchKey; // Uint8Array: SHA-256 or GUID bytes, see type @@ -469,9 +480,12 @@ redeemed.context // ContextResult: 'verified', 'mismatch', 'nocontext', // 'notcheckable', 'expired', 'replayed', 'unreadable', // 'unconfirmed' redeemed.signature // SignatureResult: 'verified', 'invalid' or 'unknown' -redeemed.factors // only on a mismatch: { transport, device, browserip, - // connectionip, asn, browser } each 'verified', - // 'mismatch' or null where nothing was compared +redeemed.factors // where there is something to diagnose: + // { transport, device, browserip, connectionip, + // asn, platformname, platformversion, + // browsername, browserversion } each 'verified', + // 'mismatch', 'misconfigured', or null where + // nothing was compared redeemed.verifiedAt // Date, on the redeemed and expired outcomes redeemed.secondsSinceVerified redeemed.statusCode // 200, or 503 for 'unconfirmed', which may be retried @@ -479,6 +493,17 @@ redeemed.raw // the body as received redeemed.toJSON() // the cloud's own response shape, for relaying to a page ``` +The factor names are in `Factor`, in the order the cloud lists them. From +cloud release 4.4.38 the operating system and the browser each have a name +and a version, replacing the single `browser` factor, so a version mismatch +beside a verified name reads as an upgrade and a mismatched name reads as a +different operating system or browser. `factors` keeps every name exactly as +the cloud sent it, including a name that is not in `Factor`, so a factor the +cloud adds later reaches the caller without a new release of this package, +and an older service's `browser` key stays under its own name rather than +filling any of the four. A factor that is `misconfigured` was not checked by +the service, and must never be read as a mismatch. + A context string this package does not know maps to `unreadable`, so an unrecognised outcome is never mistaken for a good one, and `contextRaw` keeps the string as sent. Every cryptographic failure comes back from the cloud as diff --git a/fiftyone.pipeline.did/tests/didClient.integration.test.js b/fiftyone.pipeline.did/tests/didClient.integration.test.js index 65bc31b..1191e38 100644 --- a/fiftyone.pipeline.did/tests/didClient.integration.test.js +++ b/fiftyone.pipeline.did/tests/didClient.integration.test.js @@ -150,10 +150,10 @@ live('DidClient against the cloud', () => { // non-marketing bit reads every marketing identifier as non-marketing. // The label travels into the expectation so a failure names which // identifier it was. - const assertAligned = (label, id, usage, terms, fromConsent) => { + const assertAligned = (label, id, usage, terms, indirect) => { expect({ label, usage: id.usage }).toEqual({ label, usage }); - expect({ label, fromConsent: id.usageFromConsent }) - .toEqual({ label, fromConsent }); + expect({ label, indirect: id.usageIsIndirect }) + .toEqual({ label, indirect }); expect({ label, terms: id.terms }).toEqual({ label, terms }); expect({ label, type: id.type }) .toEqual({ label, type: IdType.PROBABILISTIC }); @@ -190,7 +190,7 @@ live('DidClient against the cloud', () => { } }); - test('a consent string sets the usage-from-consent bit', async () => { + test('a consent string sets the usage is indirect bit', async () => { let proven = 0; for (const [tcString, usage] of consentStrings) { // No id.usage is sent. A stated usage wins over a consent string, so @@ -212,10 +212,10 @@ live('DidClient against the cloud', () => { } if (proven === 0) { console.warn('NOTHING PROVEN: this resource key returned no ' + - 'identifier for either consent string, so the usage-from-consent ' + + 'identifier for either consent string, so the usage is indirect ' + 'bit was never read.'); } else { - console.log(`Usage-from-consent read on ${proven} identifier(s).`); + console.log(`Usage is indirect read on ${proven} identifier(s).`); } }); }); diff --git a/fiftyone.pipeline.did/tests/didClient.test.js b/fiftyone.pipeline.did/tests/didClient.test.js index 94d0fd3..308241f 100644 --- a/fiftyone.pipeline.did/tests/didClient.test.js +++ b/fiftyone.pipeline.did/tests/didClient.test.js @@ -26,6 +26,8 @@ const { RedeemResult, ContextResult, SignatureResult, + FactorResult, + Factor, SignatureReason, DidClientError, DidArgumentError, @@ -430,7 +432,7 @@ describe('DidClient verifySignature', () => { // A Reserved type parses at any length from the header up, so it is // the way to present a payload the cloud's length rule refuses. const short = new Uint8Array(20); - short[layout.FLAGS_OFFSET] = 0b11000000; + short[layout.FLAGS_OFFSET] = 0b11000001; const fod = await signedAt(pairs[1], new Date(START_2.getTime() + DAY), { payload: short }); await expect(client.verifySignatureDetailed(fod)).resolves.toEqual({ valid: false, reason: SignatureReason.LENGTH @@ -723,7 +725,10 @@ describe('DidClient redeem', () => { browserip: 'verified', connectionip: 'mismatch', asn: 'verified', - browser: null + platformname: 'verified', + platformversion: 'mismatch', + browsername: 'verified', + browserversion: null }, verifiedAt: '2026-08-07T09:15:32Z', secondsSinceVerified: 2 @@ -743,6 +748,88 @@ describe('DidClient redeem', () => { expect(result.toJSON()).toEqual(body); }); + test('the four platform and browser factors are read into their names', + async () => { + const body = { + signature: 'verified', + context: 'mismatch', + factors: { + transport: 'verified', + device: 'verified', + browserip: 'verified', + connectionip: 'verified', + asn: 'verified', + platformname: 'verified', + platformversion: 'mismatch', + browsername: 'mismatch', + browserversion: 'misconfigured' + } + }; + const { client } = redeemClient(200, body); + const result = await client.redeem(fod, RESULT, CHALLENGE); + expect(result.factors).toEqual(body.factors); + expect(result.factors[Factor.PLATFORM_NAME]) + .toBe(FactorResult.VERIFIED); + expect(result.factors[Factor.PLATFORM_VERSION]) + .toBe(FactorResult.MISMATCH); + expect(result.factors[Factor.BROWSER_NAME]) + .toBe(FactorResult.MISMATCH); + expect(result.factors[Factor.BROWSER_VERSION]) + .toBe(FactorResult.MISCONFIGURED); + }); + + test('the factor names are the nine the cloud lists, in its order', () => { + expect(Object.isFrozen(Factor)).toBe(true); + expect(Object.keys(Factor)).toEqual([ + 'TRANSPORT', 'DEVICE', 'BROWSER_IP', 'CONNECTION_IP', 'ASN', + 'PLATFORM_NAME', 'PLATFORM_VERSION', 'BROWSER_NAME', 'BROWSER_VERSION' + ]); + expect(Object.values(Factor)).toEqual([ + 'transport', 'device', 'browserip', 'connectionip', 'asn', + 'platformname', 'platformversion', 'browsername', 'browserversion' + ]); + }); + + test('an old browser factor populates none of the four, and is kept', + async () => { + const body = { + signature: 'verified', + context: 'mismatch', + factors: { + transport: 'verified', + device: 'verified', + browserip: 'verified', + connectionip: 'verified', + asn: 'verified', + browser: 'mismatch' + } + }; + const { client } = redeemClient(200, body); + const result = await client.redeem(fod, RESULT, CHALLENGE); + expect(result.factors).toBeDefined(); + for (const name of [Factor.PLATFORM_NAME, Factor.PLATFORM_VERSION, + Factor.BROWSER_NAME, Factor.BROWSER_VERSION]) { + expect(name in result.factors).toBe(false); + } + // Every name passes through as the cloud sent it, as in every other + // 51Did package. + expect(result.factors.browser).toBe('mismatch'); + expect(result.factors).toEqual(body.factors); + expect(result.toJSON().factors).toEqual(body.factors); + }); + + test('a factor name this package does not list is passed through', + async () => { + const body = { + context: 'mismatch', + factors: { transport: 'verified', laterfactor: 'mismatch' } + }; + const { client } = redeemClient(200, body); + const result = await client.redeem(fod, RESULT, CHALLENGE); + expect(result.factors).toEqual(body.factors); + expect(Object.isFrozen(result.factors)).toBe(true); + }); + test('redeemed without factors (verified)', async () => { const body = { signature: 'verified', diff --git a/fiftyone.pipeline.did/tests/fodId.test.js b/fiftyone.pipeline.did/tests/fodId.test.js index e6e21a2..c84998c 100644 --- a/fiftyone.pipeline.did/tests/fodId.test.js +++ b/fiftyone.pipeline.did/tests/fodId.test.js @@ -143,10 +143,12 @@ describe('FodId', () => { expect(FodId.fromBase64(envelopeBase64(p)).licenseId).toBe(0x80000000); }); - test('a flags byte of zero is read unchanged', () => { + test('the least flags byte a reader accepts is read unchanged', () => { + // Bit 0 is the least a flags byte can carry, because usage bits 000 + // are refused, which the usage tests cover. const p = canonicalPayload(); - p[layout.FLAGS_OFFSET] = 0x00; - expect(FodId.fromBase64(envelopeBase64(p))._flags).toBe(0); + p[layout.FLAGS_OFFSET] = 0x01; + expect(FodId.fromBase64(envelopeBase64(p))._flags).toBe(1); }); test('every flags bit outside the version is read unchanged', () => { @@ -279,7 +281,6 @@ describe('FodId', () => { // mask for the non-marketing bit alone would say yes for every marketing // identifier, which is the wrong answer for a data protection decision. test.each([ - [0b000, Usage.NONE, null], [0b001, Usage.NON_MARKETING, 'non-marketing'], [0b011, Usage.STANDARD, 'standard'], [0b111, Usage.PERSONALIZED, 'personalized'] @@ -290,17 +291,81 @@ describe('FodId', () => { expect(fod.usage).toBe(expected); expect(Usage.idUsage(fod.usage)).toBe(idUsage); expect(fod.type).toBe(IdType.RANDOM); - expect(fod.usageFromConsent).toBe(false); + expect(fod.usageIsIndirect).toBe(false); }); - test('usage from consent is bit three', () => { + test('usage is indirect is bit three', () => { const p = canonicalRandomPayload(); p[layout.FLAGS_OFFSET] = (1 << 6) | 0b1011; const fod = FodId.fromBase64(envelopeBase64(p)); - expect(fod.usageFromConsent).toBe(true); + expect(fod.usageIsIndirect).toBe(true); expect(fod.usage).toBe(Usage.STANDARD); }); + test.each([ + [0b0001, false], + [0b1001, true], + [0b0011, false], + [0b1011, true], + [0b0111, false], + [0b1111, true] + ])('flags %s answer usage is indirect %s, and only from bit three', + (bits, indirect) => { + const p = canonicalPayload(); + p[layout.FLAGS_OFFSET] = bits; + const fod = FodId.fromBase64(envelopeBase64(p)); + expect(fod.usageIsIndirect).toBe(indirect); + expect(fod.usage).toBe(Usage.fromFlags(bits & 0b111)); + }); + + test('the old usage from consent name is gone, with no alias', () => { + const fod = FodId.fromBase64(envelopeBase64(canonicalPayload())); + expect('usageFromConsent' in fod).toBe(false); + }); + + // Usage bits 000 are not a usage. The cloud never writes them, so the + // payload is refused the way an unknown payload version is, and never + // offered as a fourth usage. + test('Usage has exactly the three usages', () => { + expect(Object.keys(Usage).filter((k) => typeof Usage[k] === 'number')) + .toEqual(['NON_MARKETING', 'STANDARD', 'PERSONALIZED']); + expect('NONE' in Usage).toBe(false); + expect(() => Usage.fromFlags(0b1000)).toThrow(RangeError); + expect(Usage.name(0)).toBeNull(); + expect(Usage.idUsage(0)).toBeNull(); + }); + + test.each([ + ['Probabilistic', 0b00, () => canonicalPayload()], + ['Random', 0b01, () => canonicalRandomPayload()], + ['HashedEmail', 0b10, () => canonicalPayload()], + ['Reserved', 0b11, () => canonicalPayload()] + ])('usage bits 000 are refused for the %s type, and the refusal names them', + (name, type, payload) => { + for (const bit3 of [0, 0b1000]) { + const p = payload(); + p[layout.FLAGS_OFFSET] = (type << 6) | bit3; + const encoded = envelopeBase64(p); + + const read = FodId.tryParse(encoded); + expect(read.ok).toBe(false); + expect(read.value).toBeNull(); + expect(read.status).toBe(ParseStatus.NO_USAGE); + expect(FodId.tryFromByteArray(envelopeBytes(p)).status) + .toBe(ParseStatus.NO_USAGE); + + let thrown; + try { + FodId.fromBase64(encoded); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(RangeError); + expect(thrown.status).toBe(ParseStatus.NO_USAGE); + expect(thrown.message).toContain('usage bits 000'); + } + }); + test('type is Random when bits are 01', () => { const fod = FodId.fromBase64(envelopeBase64(canonicalRandomPayload())); expect(fod.type).toBe(IdType.RANDOM); @@ -336,7 +401,7 @@ describe('FodId', () => { test('Reserved header-only payload parses', () => { const p = new Uint8Array(layout.MATCH_KEY_OFFSET); - p[layout.FLAGS_OFFSET] = 0b1100_0000; + p[layout.FLAGS_OFFSET] = 0b1100_0001; const fod = FodId.fromBase64(envelopeBase64(p)); expect(fod.type).toBe(IdType.RESERVED); expect(fod.matchKey.length).toBe(0); @@ -456,7 +521,7 @@ describe('FodId', () => { // A Reserved type is not yet assigned, so everything after the header // is exposed as the match key and no byte is left to read as the terms. const p = new Uint8Array(layout.MATCH_KEY_OFFSET); - p[layout.FLAGS_OFFSET] = 0b1100_0000; + p[layout.FLAGS_OFFSET] = 0b1100_0001; const fod = FodId.fromBase64(envelopeBase64(p)); expect(fod.type).toBe(IdType.RESERVED); expect(fod.terms).toBeNull(); @@ -569,12 +634,19 @@ describe('FodId', () => { test('the version is read apart from the usage and type bits', () => { // A reader masking the wrong bits would refuse a version 0 identifier // or let a later version through, so every combination is tried. + // Usage bits 000 are refused as no usage under version 0, and the + // version is read first, so a later version is still reported as such. for (const usage of [0b000, 0b001, 0b011, 0b111]) { for (const type of [0b00, 0b10, 0b11]) { const p = payloadEndingAtMatchKey(); p[layout.FLAGS_OFFSET] = (type << 6) | usage; - expect(FodId.tryParse(envelopeBase64(p)).ok).toBe(true); + const read = FodId.tryParse(envelopeBase64(p)); + if (usage === 0b000) { + expect(read.status).toBe(ParseStatus.NO_USAGE); + } else { + expect(read.ok).toBe(true); + } for (const version of [1, 2, 3]) { const refused = FodId.tryParse( @@ -814,7 +886,7 @@ describe('FodId.tryParse and tryFromByteArray', () => { } } - test('the status vocabulary is the OWID one plus the three 51Did members', () => { + test('the status vocabulary is the OWID one plus the four 51Did members', () => { expect(Object.isFrozen(ParseStatus)).toBe(true); for (const [name, value] of Object.entries(owid.ParseStatus)) { expect(ParseStatus[name]).toBe(value); @@ -824,8 +896,9 @@ describe('FodId.tryParse and tryFromByteArray', () => { .toBe('InvalidTypePayloadLength'); expect(ParseStatus.UNSUPPORTED_PAYLOAD_VERSION) .toBe('UnsupportedPayloadVersion'); + expect(ParseStatus.NO_USAGE).toBe('NoUsage'); expect(Object.keys(ParseStatus)) - .toHaveLength(Object.keys(owid.ParseStatus).length + 3); + .toHaveLength(Object.keys(owid.ParseStatus).length + 4); expect(SignatureStatus).toBe(owid.SignatureStatus); }); @@ -915,7 +988,7 @@ describe('FodId.tryParse and tryFromByteArray', () => { test('a Reserved payload keeps the best-effort read at any length from the header up', () => { for (const length of [layout.HEADER_LENGTH, 12, layout.PAYLOAD_LENGTH + 100]) { const p = new Uint8Array(length); - p[layout.FLAGS_OFFSET] = 0b1100_0000; + p[layout.FLAGS_OFFSET] = 0b1100_0001; const fod = expectParsed(FodId.tryParse(envelopeBase64(p))); expect(fod.type).toBe(IdType.RESERVED); expect(fod.matchKey).toHaveLength(length - layout.HEADER_LENGTH); diff --git a/fiftyone.pipeline.did/types/didClient.d.ts b/fiftyone.pipeline.did/types/didClient.d.ts index 32db748..5cf0b09 100644 --- a/fiftyone.pipeline.did/types/didClient.d.ts +++ b/fiftyone.pipeline.did/types/didClient.d.ts @@ -305,10 +305,14 @@ export class RedeemResult { /** @type {string} one of {@link SignatureResult} */ signature: string; /** - * @type {object | undefined} factor name to {@link FactorResult} value - * (or null where nothing was compared), present only when the cloud - * sent `factors`, which is the mismatch outcome. The names are - * transport, device, browserip, connectionip, asn and browser. + * @type {object | undefined} {@link Factor} name to + * {@link FactorResult} value (or null where nothing was compared), + * present only when the cloud sent `factors`, which it does where there + * is something to diagnose, being a mismatch or a misconfigured result + * that still compared some factors. Every name is kept exactly as the + * cloud sent it, including one this package does not list in + * {@link Factor}, so a factor the cloud adds later reaches the caller + * without a new release of this package. */ factors: object | undefined; /** @@ -395,6 +399,27 @@ export const FactorResult: Readonly<{ */ MISCONFIGURED: "misconfigured"; }>; +/** + * The names of the creator context factors, as the cloud writes them as + * keys of `factors`, in the order the cloud lists them. The operating + * system and the browser each have a name and a version, so a version + * mismatch beside a verified name reads as an upgrade, and a mismatched + * name reads as a different operating system or browser. These four + * replaced the single `browser` factor from cloud release 4.4.38. + * {@link RedeemResult#factors} is not limited to these names, so a factor + * the cloud adds later still reaches the caller. + */ +export const Factor: Readonly<{ + TRANSPORT: "transport"; + DEVICE: "device"; + BROWSER_IP: "browserip"; + CONNECTION_IP: "connectionip"; + ASN: "asn"; + PLATFORM_NAME: "platformname"; + PLATFORM_VERSION: "platformversion"; + BROWSER_NAME: "browsername"; + BROWSER_VERSION: "browserversion"; +}>; /** * The reason a {@link DidClient#verifySignatureDetailed} answer was given. */ diff --git a/fiftyone.pipeline.did/types/fodId.d.ts b/fiftyone.pipeline.did/types/fodId.d.ts index da2810f..c9465a9 100644 --- a/fiftyone.pipeline.did/types/fodId.d.ts +++ b/fiftyone.pipeline.did/types/fodId.d.ts @@ -46,7 +46,8 @@ export = FodId; declare class FodId { /** * Why a read succeeded or failed, being the OWID library's statuses plus - * `PAYLOAD_TOO_SHORT` and `INVALID_TYPE_PAYLOAD_LENGTH`. Frozen. + * `PAYLOAD_TOO_SHORT`, `INVALID_TYPE_PAYLOAD_LENGTH`, + * `UNSUPPORTED_PAYLOAD_VERSION` and `NO_USAGE`. Frozen. * @type {Readonly>} */ static ParseStatus: Readonly>; @@ -183,13 +184,16 @@ declare class FodId { */ get usage(): number; /** - * Whether the usage was derived from an IAB consent string the caller - * sent, rather than stated by the caller directly. Bit 3 of the flags. - * Both are legitimate ways to arrive at a usage, and this says nothing - * about which usage it is. + * Whether the usage is indirect, being worked out by the issuer from a + * signal other than the caller stating it. Bit 3 of the flags. False + * means the caller stated the usage directly. A consent string is the + * only indirect signal today, so today this is true only where the usage + * was derived from one, but a later signal of another kind sets the same + * bit. Both are legitimate ways to arrive at a usage, and this says + * nothing about which usage it is. * @returns {boolean} */ - get usageFromConsent(): boolean; + get usageIsIndirect(): boolean; /** * The 4-byte little-endian field at offset 1 of the payload, as an * unsigned integer (0-4294967295). diff --git a/fiftyone.pipeline.did/types/fodIdParseError.d.ts b/fiftyone.pipeline.did/types/fodIdParseError.d.ts index 6e91b06..e54c1ab 100644 --- a/fiftyone.pipeline.did/types/fodIdParseError.d.ts +++ b/fiftyone.pipeline.did/types/fodIdParseError.d.ts @@ -5,7 +5,7 @@ export = FodIdParseError; * constructor) when the OWID library refused the envelope. The status names * the reason in the same vocabulary the non-throwing surfaces report, so a * caller catching this can act on the reason without reading the message. - * The three 51Did payload statuses are thrown as RangeError instead, as this + * The four 51Did payload statuses are thrown as RangeError instead, as this * package has always thrown them, and that RangeError carries `status` too. */ declare class FodIdParseError extends Error { diff --git a/fiftyone.pipeline.did/types/index.d.ts b/fiftyone.pipeline.did/types/index.d.ts index 4cd1b2f..566324f 100644 --- a/fiftyone.pipeline.did/types/index.d.ts +++ b/fiftyone.pipeline.did/types/index.d.ts @@ -7,8 +7,9 @@ import { RedeemResult } from "./didClient"; import { ContextResult } from "./didClient"; import { SignatureResult } from "./didClient"; import { FactorResult } from "./didClient"; +import { Factor } from "./didClient"; import { SignatureReason } from "./didClient"; import { DidClientError } from "./didClient"; import { DidArgumentError } from "./didClient"; import { DidNotSupportedError } from "./didClient"; -export { FodId, FodIdParseError, IdType, Usage, DidClient, RedeemResult, ContextResult, SignatureResult, FactorResult, SignatureReason, DidClientError, DidArgumentError, DidNotSupportedError }; +export { FodId, FodIdParseError, IdType, Usage, DidClient, RedeemResult, ContextResult, SignatureResult, FactorResult, Factor, SignatureReason, DidClientError, DidArgumentError, DidNotSupportedError }; diff --git a/fiftyone.pipeline.did/types/usage.d.ts b/fiftyone.pipeline.did/types/usage.d.ts index 1c1160d..47021fe 100644 --- a/fiftyone.pipeline.did/types/usage.d.ts +++ b/fiftyone.pipeline.did/types/usage.d.ts @@ -1,11 +1,5 @@ export = Usage; declare const Usage: Readonly<{ - /** - * No usage bit is set. The cloud never issues such an identifier, so - * this is an identifier from somewhere else or a damaged one, and it - * should be treated as though it may not be passed on. - */ - NONE: 0; /** Created for use that is not marketing. Must not be passed to a demand source. */ NON_MARKETING: 1; /** Created for standard marketing, being targeting unrelated to browsing history. */ @@ -14,21 +8,27 @@ declare const Usage: Readonly<{ PERSONALIZED: 3; /** * Decodes the usage from bits 0-2 of a flags byte, as the highest usage - * granted. + * granted. There is no Usage for bits 000, because the cloud never + * writes a flags byte without bit 0, so a payload carrying 000 is + * damaged or forged and FodId refuses it with + * `FodId.ParseStatus.NO_USAGE` before this is asked. * @param {number} flags the 1-byte flags value (0-255) * @returns {number} the Usage value + * @throws {RangeError} when bits 0-2 are all clear */ fromFlags(flags: number): number; /** * The cross language name of a Usage value. - * @param {number} usage a Usage value, 0 to 3 - * @returns {string} for example "NonMarketing" + * @param {number} usage a Usage value, 1 to 3 + * @returns {string|null} for example "NonMarketing", or null for a value + * that is not a Usage */ - name(usage: number): string; + name(usage: number): string | null; /** - * The cloud's id.usage value for a Usage value, or null for NONE. - * @param {number} usage a Usage value, 0 to 3 - * @returns {string|null} for example "non-marketing" + * The cloud's id.usage value for a Usage value. + * @param {number} usage a Usage value, 1 to 3 + * @returns {string|null} for example "non-marketing", or null for a value + * that is not a Usage */ idUsage(usage: number): string | null; }>; diff --git a/fiftyone.pipeline.did/usage.js b/fiftyone.pipeline.did/usage.js index fdd2331..4cbabff 100644 --- a/fiftyone.pipeline.did/usage.js +++ b/fiftyone.pipeline.did/usage.js @@ -42,15 +42,9 @@ * https://github.com/51Degrees/specifications/blob/main/did-specification/identifier-layout.md * which is the authority rather than this comment. */ -const NAMES = ['None', 'NonMarketing', 'Standard', 'Personalized']; +const NAMES = [null, 'NonMarketing', 'Standard', 'Personalized']; const ID_USAGE = [null, 'non-marketing', 'standard', 'personalized']; const Usage = Object.freeze({ - /** - * No usage bit is set. The cloud never issues such an identifier, so - * this is an identifier from somewhere else or a damaged one, and it - * should be treated as though it may not be passed on. - */ - NONE: 0, /** Created for use that is not marketing. Must not be passed to a demand source. */ NON_MARKETING: 1, /** Created for standard marketing, being targeting unrelated to browsing history. */ @@ -59,31 +53,38 @@ const Usage = Object.freeze({ PERSONALIZED: 3, /** * Decodes the usage from bits 0-2 of a flags byte, as the highest usage - * granted. + * granted. There is no Usage for bits 000, because the cloud never + * writes a flags byte without bit 0, so a payload carrying 000 is + * damaged or forged and FodId refuses it with + * `FodId.ParseStatus.NO_USAGE` before this is asked. * @param {number} flags the 1-byte flags value (0-255) * @returns {number} the Usage value + * @throws {RangeError} when bits 0-2 are all clear */ fromFlags (flags) { if (flags & 0b100) return 3; if (flags & 0b010) return 2; if (flags & 0b001) return 1; - return 0; + throw new RangeError( + 'Usage bits 000 are not a usage, so the flags byte cannot be read.'); }, /** * The cross language name of a Usage value. - * @param {number} usage a Usage value, 0 to 3 - * @returns {string} for example "NonMarketing" + * @param {number} usage a Usage value, 1 to 3 + * @returns {string|null} for example "NonMarketing", or null for a value + * that is not a Usage */ name (usage) { - return NAMES[usage]; + return NAMES[usage] || null; }, /** - * The cloud's id.usage value for a Usage value, or null for NONE. - * @param {number} usage a Usage value, 0 to 3 - * @returns {string|null} for example "non-marketing" + * The cloud's id.usage value for a Usage value. + * @param {number} usage a Usage value, 1 to 3 + * @returns {string|null} for example "non-marketing", or null for a value + * that is not a Usage */ idUsage (usage) { - return ID_USAGE[usage]; + return ID_USAGE[usage] || null; } }); module.exports = Usage;