fiftyone.pipeline.did 4.5.25

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.
@@ -0,0 +1,116 @@
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
+ * Offline example for the 51Did (FodId) reader.
25
+ *
26
+ * The 51Degrees Cloud service issues real 51Dids. To keep this example
27
+ * self-contained and offline, it builds a sample 51Did in process - generate
28
+ * an ECDSA P-256 key pair, sign a canonical 37-byte payload - then parses it
29
+ * back and prints the three payload fields. It also shows the headline use
30
+ * case: a 51Did is re-issued fresh on every call (the envelope, hence the
31
+ * base64, changes), but the value (the Hash) is stable. Compare values, never
32
+ * envelopes.
33
+ */
34
+
35
+ const { webcrypto } = require('crypto');
36
+ const { FodId, IdType } = require('../index');
37
+
38
+ const subtle = webcrypto.subtle;
39
+ const VERSION = 2;
40
+ const DOMAIN = '51degrees.com';
41
+ const DATE = 2900000; // minutes since 2020-01-01
42
+
43
+ function uint32LE (v) {
44
+ return [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
45
+ }
46
+
47
+ 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.HASH_LENGTH; i++) { p[FodId.HASH_OFFSET + i] = 0x20 + i; }
54
+ return p;
55
+ }
56
+
57
+ function noSigBytes (payload, date) {
58
+ const out = [VERSION];
59
+ for (let i = 0; i < DOMAIN.length; i++) { out.push(DOMAIN.charCodeAt(i)); }
60
+ out.push(0);
61
+ out.push(...uint32LE(date));
62
+ out.push(...uint32LE(payload.length));
63
+ for (const b of payload) { out.push(b); }
64
+ return Uint8Array.from(out);
65
+ }
66
+
67
+ async function issue (privateKey, payload, date) {
68
+ const noSig = noSigBytes(payload, date);
69
+ const sig = new Uint8Array(await subtle.sign(
70
+ { name: 'ECDSA', hash: 'SHA-256' }, privateKey, noSig));
71
+ const full = new Uint8Array(noSig.length + sig.length);
72
+ full.set(noSig);
73
+ full.set(sig, noSig.length);
74
+ return Buffer.from(full).toString('base64');
75
+ }
76
+
77
+ function toPem (label, der) {
78
+ const b64 = Buffer.from(der).toString('base64');
79
+ return `-----BEGIN ${label}-----\n${b64.match(/.{1,64}/g).join('\n')}\n` +
80
+ `-----END ${label}-----\n`;
81
+ }
82
+
83
+ async function run () {
84
+ const keyPair = await subtle.generateKey(
85
+ { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
86
+ const publicPem = toPem('PUBLIC KEY',
87
+ new Uint8Array(await subtle.exportKey('spki', keyPair.publicKey)));
88
+ const payload = samplePayload();
89
+
90
+ const fodId = FodId.fromBase64(await issue(keyPair.privateKey, payload, DATE));
91
+
92
+ console.log('51Did parsed from base64:');
93
+ console.log(' Domain :', fodId.domain);
94
+ console.log(' Type :', IdType.name(fodId.type));
95
+ console.log(' Flags : 0x' + fodId.flags.toString(16));
96
+ console.log(' LicenseId :', fodId.licenseId);
97
+ console.log(' Hash :', Buffer.from(fodId.hash).toString('hex'));
98
+ console.log(' Verifies :', await fodId.verify(publicPem));
99
+
100
+ // Re-issue the same payload at a later time: a separate envelope, same value.
101
+ const reissued = FodId.fromBase64(
102
+ await issue(keyPair.privateKey, payload, DATE + 5));
103
+ const sameEnvelope = fodId.asBase64() === reissued.asBase64();
104
+ const sameValue = Buffer.from(fodId.hash).equals(Buffer.from(reissued.hash));
105
+
106
+ console.log('\nSame payload, re-issued:');
107
+ console.log(' Same envelope (base64) :', sameEnvelope);
108
+ console.log(' Same value (Hash) :', sameValue);
109
+
110
+ if (sameEnvelope || !sameValue) {
111
+ throw new Error(
112
+ 'Expected a different envelope but the same value across reissues.');
113
+ }
114
+ }
115
+
116
+ run().catch((e) => { console.error(e); process.exit(1); });
package/fodId.js ADDED
@@ -0,0 +1,208 @@
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
+ const owid = require('owid');
24
+ const IdType = require('./idType');
25
+
26
+ /**
27
+ * A strongly typed reader for the 51Did (51Degrees Identifier) value returned
28
+ * by the 51Degrees Cloud service.
29
+ *
30
+ * A 51Did is described at three levels. The 51Did is the identifier as a
31
+ * whole. The envelope is the signed OWID that carries it (version, domain,
32
+ * date, payload, signature), re-issued fresh on every call. The value is the
33
+ * stable, comparable part of the payload after the Flags and License Id,
34
+ * exposed via {@link FodId#hash}. Two 51Dids for the same inputs share the
35
+ * same value even though their envelopes differ. Compare values, never
36
+ * envelopes.
37
+ *
38
+ * The owid-js library is verify-only and exposes no instance asBase64, so this
39
+ * type composes an owid instance (holds it and delegates) and keeps the
40
+ * original base64 for {@link FodId#asBase64}. Construction does NOT verify the
41
+ * signature; call {@link FodId#verify} (async) explicitly.
42
+ */
43
+ class FodId {
44
+ static FLAGS_OFFSET = 0;
45
+ static LICENSE_ID_OFFSET = 1;
46
+ static LICENSE_ID_LENGTH = 4;
47
+ static HASH_OFFSET = 5;
48
+ static HASH_LENGTH = 32;
49
+ static HEADER_LENGTH = 5;
50
+ static GUID_LENGTH = 16;
51
+ static RANDOM_PAYLOAD_LENGTH = 21;
52
+ static PAYLOAD_LENGTH = 37;
53
+
54
+ /**
55
+ * Promotes an already-parsed owid instance into a 51Did by unpacking its
56
+ * payload.
57
+ * @param {object} owidInstance an owid instance (from `new owid(base64)`)
58
+ */
59
+ constructor (owidInstance) {
60
+ if (owidInstance === null || owidInstance === undefined) {
61
+ throw new TypeError('owid must not be null or undefined');
62
+ }
63
+ this._owid = owidInstance;
64
+ const payload = owidInstance.owid.payload;
65
+ const length = payload ? payload.length : 0;
66
+ if (!payload || length < FodId.HEADER_LENGTH) {
67
+ throw new RangeError(
68
+ `51Did payload must be at least ${FodId.HEADER_LENGTH} bytes; ` +
69
+ `got ${length}.`);
70
+ }
71
+ this._flags = payload[FodId.FLAGS_OFFSET];
72
+ // Little-endian unsigned 32-bit. `>>> 0` forces unsigned so the high bit
73
+ // does not produce a negative number.
74
+ this._licenseId = (
75
+ payload[FodId.LICENSE_ID_OFFSET] |
76
+ (payload[FodId.LICENSE_ID_OFFSET + 1] << 8) |
77
+ (payload[FodId.LICENSE_ID_OFFSET + 2] << 16) |
78
+ (payload[FodId.LICENSE_ID_OFFSET + 3] << 24)
79
+ ) >>> 0;
80
+ const type = IdType.fromFlags(this._flags);
81
+ let valueLength;
82
+ if (type === IdType.RANDOM) {
83
+ valueLength = FodId.GUID_LENGTH;
84
+ } else if (type === IdType.RESERVED) {
85
+ valueLength = length - FodId.HEADER_LENGTH;
86
+ } else {
87
+ valueLength = FodId.HASH_LENGTH;
88
+ }
89
+ if (length < FodId.HEADER_LENGTH + valueLength) {
90
+ throw new RangeError(
91
+ `51Did payload for the ${IdType.name(type)} type must be at least ` +
92
+ `${FodId.HEADER_LENGTH + valueLength} bytes; got ${length}.`);
93
+ }
94
+ // slice() copies, so the stored value cannot mutate the OWID payload.
95
+ this._hash = payload.slice(
96
+ FodId.HASH_OFFSET, FodId.HASH_OFFSET + valueLength);
97
+ }
98
+
99
+ /**
100
+ * Parses a 51Did from its base64-encoded OWID string.
101
+ * @param {string} base64
102
+ * @returns {FodId}
103
+ */
104
+ static fromBase64 (base64) {
105
+ if (typeof base64 !== 'string') {
106
+ throw new TypeError('base64 must be a string');
107
+ }
108
+ return new FodId(new owid(base64));
109
+ }
110
+
111
+ /**
112
+ * Parses a 51Did from the raw bytes of an OWID envelope.
113
+ * @param {Uint8Array} buffer
114
+ * @returns {FodId}
115
+ */
116
+ static fromByteArray (buffer) {
117
+ if (!(buffer instanceof Uint8Array)) {
118
+ throw new TypeError('buffer must be a Uint8Array');
119
+ }
120
+ return new FodId(new owid(Buffer.from(buffer).toString('base64')));
121
+ }
122
+
123
+ /**
124
+ * Promotes an already-parsed owid instance into a 51Did. The owid is
125
+ * **copied** (re-parsed from its base64), not aliased, so a FodId can never
126
+ * desync from its envelope if the caller later mutates the owid it passed
127
+ * in.
128
+ * @param {object} owidInstance
129
+ * @returns {FodId}
130
+ */
131
+ static fromOwid (owidInstance) {
132
+ if (owidInstance === null || owidInstance === undefined) {
133
+ throw new TypeError('owid must not be null or undefined');
134
+ }
135
+ return new FodId(new owid(owidInstance.data));
136
+ }
137
+
138
+ /** @returns {number} the 1-byte usage flags bit-mask (0-255). */
139
+ get flags () {
140
+ return this._flags;
141
+ }
142
+
143
+ /** @returns {number} the IdType carried in bits 6-7 of the flags. */
144
+ get type () {
145
+ return IdType.fromFlags(this._flags);
146
+ }
147
+
148
+ /** @returns {number} the 4-byte little-endian License Id (0-4294967295). */
149
+ get licenseId () {
150
+ return this._licenseId;
151
+ }
152
+
153
+ /**
154
+ * @returns {Uint8Array} a defensive copy of the value bytes (a 32-byte
155
+ * SHA-256, or 16 GUID bytes for Random) - the stable cache / dedup key.
156
+ */
157
+ get hash () {
158
+ return this._hash.slice();
159
+ }
160
+
161
+ /** @returns {number} the OWID version. */
162
+ get version () {
163
+ return this._owid.owid.version;
164
+ }
165
+
166
+ /** @returns {string} the domain of the OWID creator. */
167
+ get domain () {
168
+ return this._owid.domain;
169
+ }
170
+
171
+ /** @returns {number} the OWID date as minutes since 2020-01-01 UTC. */
172
+ get date () {
173
+ return this._owid.date;
174
+ }
175
+
176
+ /** @returns {Uint8Array} the OWID payload bytes. */
177
+ get payload () {
178
+ return this._owid.owid.payload;
179
+ }
180
+
181
+ /** @returns {Uint8Array} the 64-byte OWID signature. */
182
+ get signature () {
183
+ return this._owid.signature;
184
+ }
185
+
186
+ /** @returns {string} the OWID as a base64 string (the original envelope). */
187
+ asBase64 () {
188
+ return this._owid.data;
189
+ }
190
+
191
+ /** @returns {Uint8Array} the OWID envelope as raw bytes. */
192
+ asByteArray () {
193
+ return Uint8Array.from(atob(this._owid.data), (c) => c.charCodeAt(0));
194
+ }
195
+
196
+ /**
197
+ * Verifies the OWID signature against the supplied SPKI public key PEM. This
198
+ * is an explicit, separate step - construction never verifies. Asynchronous
199
+ * because it uses Web Crypto.
200
+ * @param {string} publicPem the creator public key in SPKI PEM form
201
+ * @returns {Promise<boolean>}
202
+ */
203
+ verify (publicPem) {
204
+ return this._owid.verifyWithPublicKey(publicPem, []);
205
+ }
206
+ }
207
+
208
+ module.exports = FodId;
package/idType.js ADDED
@@ -0,0 +1,60 @@
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
+ const NAMES = ['Probabilistic', 'Random', 'HashedEmail', 'Reserved'];
24
+
25
+ /**
26
+ * The identifier type carried in bits 6-7 of the 51Did flags byte. Existing
27
+ * identifiers were issued with these bits zeroed, so they decode as
28
+ * PROBABILISTIC. The type selects the length of the value that follows the
29
+ * header in the payload.
30
+ */
31
+ const IdType = Object.freeze({
32
+ /** Device fingerprint + IP. Payload carries a 32-byte SHA-256 value. */
33
+ PROBABILISTIC: 0,
34
+ /** Server-generated random GUID. Payload carries 16 GUID bytes. */
35
+ RANDOM: 1,
36
+ /** Caller email + salt. Payload carries a 32-byte SHA-256 value. */
37
+ HASHED_EMAIL: 2,
38
+ /** Not yet assigned. Parsed best-effort; remaining bytes exposed as-is. */
39
+ RESERVED: 3,
40
+
41
+ /**
42
+ * Decodes the identifier type from the top two bits (6-7) of a flags byte.
43
+ * @param {number} flags the 1-byte flags value (0-255)
44
+ * @returns {number} the IdType value
45
+ */
46
+ fromFlags (flags) {
47
+ return (flags >> 6) & 0b11;
48
+ },
49
+
50
+ /**
51
+ * The human-readable name of an IdType value.
52
+ * @param {number} type an IdType value
53
+ * @returns {string}
54
+ */
55
+ name (type) {
56
+ return NAMES[type];
57
+ }
58
+ });
59
+
60
+ module.exports = IdType;
package/index.js ADDED
@@ -0,0 +1,26 @@
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
+ const FodId = require('./fodId');
24
+ const IdType = require('./idType');
25
+
26
+ module.exports = { FodId, IdType };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "fiftyone.pipeline.did",
3
+ "version": "4.5.25",
4
+ "description": "Strongly typed reader for the 51Did (51Degrees Identifier) value returned by the 51Degrees Cloud service. Parses the OWID envelope and exposes the Flags, License Id and value (Hash) plus the identifier type. Compare values, never envelopes.",
5
+ "keywords": [
6
+ "51degrees",
7
+ "51did",
8
+ "fodid",
9
+ "owid",
10
+ "pipeline"
11
+ ],
12
+ "main": "index.js",
13
+ "scripts": {
14
+ "test": "jest"
15
+ },
16
+ "author": "51Degrees Engineering <engineering@51degrees.com>",
17
+ "license": "EUPL-1.2",
18
+ "dependencies": {
19
+ "owid": "github:51Degrees/owid-js#main"
20
+ },
21
+ "devDependencies": {
22
+ "jest": "^29.7.0"
23
+ },
24
+ "jest": {
25
+ "setupFiles": [
26
+ "./tests/setup.js"
27
+ ]
28
+ },
29
+ "bugs": {
30
+ "url": "https://github.com/51Degrees/pipeline-node/issues"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/51Degrees/pipeline-node"
35
+ }
36
+ }
package/readme.md ADDED
@@ -0,0 +1,90 @@
1
+ # fiftyone.pipeline.did
2
+
3
+ Strongly typed Node.js reader for the 51Did (51Degrees Identifier) returned by
4
+ the 51Degrees Cloud service. Mirrors the .NET `FiftyOne.Did` package.
5
+
6
+ ## Terminology
7
+
8
+ - The **51Did** (51Degrees Identifier) is the identifier as a whole.
9
+ - The **envelope** is the data model that carries it: a signed OWID holding the
10
+ version, domain, date, payload and signature. It changes byte-for-byte every
11
+ time the cloud issues one.
12
+ - The **value** is the stable, comparable part of the payload after the Flags
13
+ and License Id: a 32-byte SHA-256 for Probabilistic and HashedEmail
14
+ identifiers, or 16 GUID bytes for Random.
15
+
16
+ **Comparing two 51Dids means comparing their values, never their envelopes.**
17
+
18
+ ## Payload layout
19
+
20
+ | Offset | Length | Field | Type |
21
+ |-------:|-------:|------------|-------------------------------------------------|
22
+ | 0 | 1 | Flags | uint8: bits 0-2 usage, bits 6-7 identifier type |
23
+ | 1 | 4 | LicenseId | uint32 (little-endian) |
24
+ | 5 | 16/32 | Value | SHA-256 (Probabilistic, HashedEmail) or GUID (Random) |
25
+
26
+ | Bits 7-6 | `IdType` | Value length | Minimum payload |
27
+ |---------:|-----------------|-------------:|----------------:|
28
+ | `00` | `PROBABILISTIC` | 32 | 37 |
29
+ | `01` | `RANDOM` | 16 | 21 |
30
+ | `10` | `HASHED_EMAIL` | 32 | 37 |
31
+ | `11` | `RESERVED` | remainder | 5 |
32
+
33
+ Identifiers issued before the type tag existed have bits 6-7 zeroed and decode
34
+ as `PROBABILISTIC`.
35
+
36
+ ## OWID dependency
37
+
38
+ `FodId` builds on the OWID envelope library
39
+ ([SWAN-community/owid-js](https://github.com/SWAN-community/owid-js)), consumed
40
+ via the `51Degrees/owid-js` fork as a git submodule and a `file:` dependency
41
+ (switch to the npm registry once published). owid-js is parse + verify only and
42
+ exposes no instance `asBase64`, so `FodId` **composes** an owid instance, keeps
43
+ the original base64 for `asBase64()`, and delegates the rest.
44
+
45
+ The fork was extended with an offline `verifyWithPublicKey(pem, others)` that
46
+ works in Node and the browser (Web Crypto), so `FodId.verify()` runs without
47
+ contacting a network endpoint.
48
+
49
+ ## Install / build
50
+
51
+ ```bash
52
+ git submodule update --init # fetches owid-js into ../owid-js
53
+ npm install
54
+ npm test
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ ```js
60
+ const { FodId, IdType } = require('fiftyone.pipeline.did');
61
+
62
+ const fodId = FodId.fromBase64(base64FromCloudService);
63
+
64
+ const flags = fodId.flags;
65
+ const type = fodId.type; // IdType.PROBABILISTIC / RANDOM / HASHED_EMAIL
66
+ const licenseId = fodId.licenseId;
67
+ const value = fodId.hash; // Uint8Array: SHA-256 or GUID bytes, see type
68
+
69
+ const domain = fodId.domain;
70
+ const verified = await fodId.verify(publicKeyPem); // async (Web Crypto)
71
+ const base64 = fodId.asBase64();
72
+ ```
73
+
74
+ ## Comparing two 51Dids
75
+
76
+ ```js
77
+ const a = FodId.fromBase64(idprobglobalA);
78
+ const b = FodId.fromBase64(idprobglobalB);
79
+
80
+ // The envelope (date, signature, base64) differs across reissues.
81
+ // The value inside the payload is stable - this is what you compare:
82
+ const sameValue = Buffer.from(a.hash).equals(Buffer.from(b.hash));
83
+ ```
84
+
85
+ ## Non-goals
86
+
87
+ - **No signature verification on construction.** Call `verify(publicKeyPem)`
88
+ when needed (it is asynchronous).
89
+ - **No creation of new 51Dids.** This is a parser; new 51Dids are issued by the
90
+ 51Degrees cloud / on-premise hashing engines.
@@ -0,0 +1,372 @@
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
+ const owid = require('owid');
24
+ const { FodId, IdType } = require('../index');
25
+
26
+ const VERSION = 2;
27
+ const DOMAIN = '51degrees.com';
28
+ const DATE = 2900000; // minutes since 2020-01-01
29
+ const CANONICAL_FLAGS = 0xA5; // HashedEmail type tag + usage bits
30
+ const CANONICAL_LICENSE_ID = 0x12345678;
31
+
32
+ function canonicalHash () {
33
+ const h = new Uint8Array(FodId.HASH_LENGTH);
34
+ for (let i = 0; i < h.length; i++) { h[i] = 0x20 + i; }
35
+ return h;
36
+ }
37
+
38
+ function writeLicenseId (payload) {
39
+ // Little-endian 0x12345678 -> 78 56 34 12.
40
+ payload[FodId.LICENSE_ID_OFFSET] = 0x78;
41
+ payload[FodId.LICENSE_ID_OFFSET + 1] = 0x56;
42
+ payload[FodId.LICENSE_ID_OFFSET + 2] = 0x34;
43
+ payload[FodId.LICENSE_ID_OFFSET + 3] = 0x12;
44
+ }
45
+
46
+ function canonicalPayload () {
47
+ const p = new Uint8Array(FodId.PAYLOAD_LENGTH);
48
+ p[FodId.FLAGS_OFFSET] = CANONICAL_FLAGS;
49
+ writeLicenseId(p);
50
+ p.set(canonicalHash(), FodId.HASH_OFFSET);
51
+ return p;
52
+ }
53
+
54
+ function canonicalRandomPayload () {
55
+ const p = new Uint8Array(FodId.RANDOM_PAYLOAD_LENGTH);
56
+ p[FodId.FLAGS_OFFSET] = (1 << 6) | 0b001; // Random tag + usage bits
57
+ writeLicenseId(p);
58
+ for (let i = 0; i < FodId.GUID_LENGTH; i++) {
59
+ p[FodId.HASH_OFFSET + i] = 0x40 + i;
60
+ }
61
+ return p;
62
+ }
63
+
64
+ function uint32LE (v) {
65
+ return [v & 0xFF, (v >>> 8) & 0xFF, (v >>> 16) & 0xFF, (v >>> 24) & 0xFF];
66
+ }
67
+
68
+ // Builds OWID envelope bytes (version 2 wire format) with the given payload and
69
+ // an arbitrary signature, matching owid-js getByteArray + a 64-byte signature.
70
+ function noSigBytes (payload, date) {
71
+ const out = [VERSION];
72
+ for (let i = 0; i < DOMAIN.length; i++) { out.push(DOMAIN.charCodeAt(i)); }
73
+ out.push(0);
74
+ out.push(...uint32LE(date));
75
+ out.push(...uint32LE(payload.length));
76
+ for (const b of payload) { out.push(b); }
77
+ return Uint8Array.from(out);
78
+ }
79
+
80
+ const DUMMY_SIG = (() => {
81
+ const s = new Uint8Array(64);
82
+ for (let i = 0; i < 64; i++) { s[i] = i + 1; }
83
+ return s;
84
+ })();
85
+
86
+ function envelopeBytes (payload, { date = DATE, signature = DUMMY_SIG } = {}) {
87
+ const noSig = noSigBytes(payload, date);
88
+ const full = new Uint8Array(noSig.length + signature.length);
89
+ full.set(noSig);
90
+ full.set(signature, noSig.length);
91
+ return full;
92
+ }
93
+
94
+ function envelopeBase64 (payload, opts) {
95
+ return Buffer.from(envelopeBytes(payload, opts)).toString('base64');
96
+ }
97
+
98
+ // Real ECDSA P-256 signing via Web Crypto, for the verify tests.
99
+ async function signedVerifiable (payload, date = DATE) {
100
+ const keyPair = await crypto.subtle.generateKey(
101
+ { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
102
+ const noSig = noSigBytes(payload, date);
103
+ const sig = new Uint8Array(await crypto.subtle.sign(
104
+ { name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, noSig));
105
+ const full = new Uint8Array(noSig.length + sig.length);
106
+ full.set(noSig);
107
+ full.set(sig, noSig.length);
108
+ const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
109
+ return {
110
+ base64: Buffer.from(full).toString('base64'),
111
+ publicPem: toPem('PUBLIC KEY', new Uint8Array(spki))
112
+ };
113
+ }
114
+
115
+ async function randomPublicPem () {
116
+ const keyPair = await crypto.subtle.generateKey(
117
+ { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']);
118
+ const spki = await crypto.subtle.exportKey('spki', keyPair.publicKey);
119
+ return toPem('PUBLIC KEY', new Uint8Array(spki));
120
+ }
121
+
122
+ function toPem (label, der) {
123
+ const b64 = Buffer.from(der).toString('base64');
124
+ return `-----BEGIN ${label}-----\n${b64.match(/.{1,64}/g).join('\n')}\n` +
125
+ `-----END ${label}-----\n`;
126
+ }
127
+
128
+ describe('FodId', () => {
129
+ // ----- Current .NET coverage -----
130
+
131
+ test('constants are internally consistent', () => {
132
+ expect(FodId.HASH_OFFSET + FodId.HASH_LENGTH).toBe(FodId.PAYLOAD_LENGTH);
133
+ expect(FodId.LICENSE_ID_OFFSET + FodId.LICENSE_ID_LENGTH)
134
+ .toBe(FodId.HASH_OFFSET);
135
+ expect(FodId.HASH_OFFSET + FodId.GUID_LENGTH)
136
+ .toBe(FodId.RANDOM_PAYLOAD_LENGTH);
137
+ });
138
+
139
+ test('exposes OWID-level fields', () => {
140
+ const fod = FodId.fromBase64(envelopeBase64(canonicalPayload()));
141
+ // OWID-level concerns are delegated to the wrapped envelope.
142
+ expect(fod.domain).toBe(DOMAIN);
143
+ expect(fod.version).toBeDefined();
144
+ });
145
+
146
+ test('fromBase64 unpacks all three fields', () => {
147
+ const fod = FodId.fromBase64(envelopeBase64(canonicalPayload()));
148
+ expect(fod.flags).toBe(CANONICAL_FLAGS);
149
+ expect(fod.licenseId).toBe(CANONICAL_LICENSE_ID);
150
+ expect(fod.hash).toEqual(canonicalHash());
151
+ expect(fod.domain).toBe(DOMAIN);
152
+ });
153
+
154
+ test('fromByteArray unpacks all three fields', () => {
155
+ const fod = FodId.fromByteArray(envelopeBytes(canonicalPayload()));
156
+ expect(fod.flags).toBe(CANONICAL_FLAGS);
157
+ expect(fod.licenseId).toBe(CANONICAL_LICENSE_ID);
158
+ expect(fod.hash).toEqual(canonicalHash());
159
+ expect(fod.domain).toBe(DOMAIN);
160
+ });
161
+
162
+ test('fromOwid unpacks all three fields', () => {
163
+ const o = new owid(envelopeBase64(canonicalPayload()));
164
+ const fod = FodId.fromOwid(o);
165
+ expect(fod.flags).toBe(CANONICAL_FLAGS);
166
+ expect(fod.licenseId).toBe(CANONICAL_LICENSE_ID);
167
+ expect(fod.hash).toEqual(canonicalHash());
168
+ expect(fod.domain).toBe(o.domain);
169
+ expect(fod.date).toBe(o.date);
170
+ expect(fod.version).toBe(o.owid.version);
171
+ expect(fod.payload).toEqual(o.owid.payload);
172
+ expect(fod.signature).toEqual(o.signature);
173
+ });
174
+
175
+ test('null owid throws', () => {
176
+ expect(() => FodId.fromOwid(null)).toThrow(TypeError);
177
+ });
178
+
179
+ test('licenseId is little-endian', () => {
180
+ const p = canonicalPayload();
181
+ p[1] = 0x01; p[2] = 0x00; p[3] = 0x00; p[4] = 0x00;
182
+ expect(FodId.fromBase64(envelopeBase64(p)).licenseId).toBe(1);
183
+ });
184
+
185
+ test('licenseId max value', () => {
186
+ const p = canonicalPayload();
187
+ p[1] = 0xFF; p[2] = 0xFF; p[3] = 0xFF; p[4] = 0xFF;
188
+ expect(FodId.fromBase64(envelopeBase64(p)).licenseId).toBe(4294967295);
189
+ });
190
+
191
+ test('licenseId high bit stays unsigned', () => {
192
+ const p = canonicalPayload();
193
+ p[1] = 0x00; p[2] = 0x00; p[3] = 0x00; p[4] = 0x80;
194
+ expect(FodId.fromBase64(envelopeBase64(p)).licenseId).toBe(0x80000000);
195
+ });
196
+
197
+ test('flags zero value exposed', () => {
198
+ const p = canonicalPayload();
199
+ p[FodId.FLAGS_OFFSET] = 0x00;
200
+ expect(FodId.fromBase64(envelopeBase64(p)).flags).toBe(0);
201
+ });
202
+
203
+ test('flags all bits set exposed', () => {
204
+ const p = canonicalPayload();
205
+ p[FodId.FLAGS_OFFSET] = 0xFF;
206
+ expect(FodId.fromBase64(envelopeBase64(p)).flags).toBe(255);
207
+ });
208
+
209
+ test('hash is a defensive copy', () => {
210
+ const fod = FodId.fromBase64(envelopeBase64(canonicalPayload()));
211
+ const h = fod.hash;
212
+ h[0] = 0x00;
213
+ h[FodId.HASH_LENGTH - 1] = 0x00;
214
+ expect(fod.hash).toEqual(canonicalHash());
215
+ expect(fod.payload[FodId.HASH_OFFSET]).toBe(0x20);
216
+ });
217
+
218
+ test('payload one byte short throws', () => {
219
+ expect(() => FodId.fromBase64(envelopeBase64(new Uint8Array(FodId.PAYLOAD_LENGTH - 1))))
220
+ .toThrow(RangeError);
221
+ });
222
+
223
+ test('empty payload throws', () => {
224
+ expect(() => FodId.fromBase64(envelopeBase64(new Uint8Array(0))))
225
+ .toThrow(RangeError);
226
+ });
227
+
228
+ test('null base64 throws', () => {
229
+ expect(() => FodId.fromBase64(null)).toThrow(TypeError);
230
+ });
231
+
232
+ test('null buffer throws', () => {
233
+ expect(() => FodId.fromByteArray(null)).toThrow(TypeError);
234
+ });
235
+
236
+ test('invalid base64 throws', () => {
237
+ expect(() => FodId.fromBase64('This is not valid Base64!@#$')).toThrow();
238
+ });
239
+
240
+ test('payload larger than spec uses first 37 bytes', () => {
241
+ const p = new Uint8Array(64);
242
+ p.set(canonicalPayload());
243
+ p.fill(0xCC, FodId.PAYLOAD_LENGTH);
244
+ const fod = FodId.fromBase64(envelopeBase64(p));
245
+ expect(fod.flags).toBe(CANONICAL_FLAGS);
246
+ expect(fod.licenseId).toBe(CANONICAL_LICENSE_ID);
247
+ expect(fod.hash).toEqual(canonicalHash());
248
+ expect(fod.hash.length).toBe(FodId.HASH_LENGTH);
249
+ });
250
+
251
+ test('is cryptographically verifiable', async () => {
252
+ const { base64, publicPem } = await signedVerifiable(canonicalPayload());
253
+ const fod = FodId.fromBase64(base64);
254
+ await expect(fod.verify(publicPem)).resolves.toBe(true);
255
+ });
256
+
257
+ test('base64 round-trip preserves all fields', () => {
258
+ const fod1 = FodId.fromBase64(envelopeBase64(canonicalPayload()));
259
+ const fod2 = FodId.fromBase64(fod1.asBase64());
260
+ expect(fod2.flags).toBe(fod1.flags);
261
+ expect(fod2.licenseId).toBe(fod1.licenseId);
262
+ expect(fod2.hash).toEqual(fod1.hash);
263
+ expect(fod2.domain).toBe(fod1.domain);
264
+ });
265
+
266
+ // ----- Type model -----
267
+
268
+ test('type decoded from top two flag bits', () => {
269
+ expect(typeFor(0b0000_0101)).toBe(IdType.PROBABILISTIC);
270
+ expect(typeFor(0b1000_0101)).toBe(IdType.HASHED_EMAIL);
271
+ expect(typeFor(0b1100_0101)).toBe(IdType.RESERVED);
272
+ });
273
+
274
+ function typeFor (flags) {
275
+ const p = canonicalPayload();
276
+ p[FodId.FLAGS_OFFSET] = flags;
277
+ return FodId.fromBase64(envelopeBase64(p)).type;
278
+ }
279
+
280
+ test('type is Random when bits are 01', () => {
281
+ const fod = FodId.fromBase64(envelopeBase64(canonicalRandomPayload()));
282
+ expect(fod.type).toBe(IdType.RANDOM);
283
+ });
284
+
285
+ test('Random 21-byte payload parses', () => {
286
+ const fod = FodId.fromBase64(envelopeBase64(canonicalRandomPayload()));
287
+ expect(fod.licenseId).toBe(CANONICAL_LICENSE_ID);
288
+ expect(fod.hash.length).toBe(FodId.GUID_LENGTH);
289
+ const guid = new Uint8Array(FodId.GUID_LENGTH);
290
+ for (let i = 0; i < guid.length; i++) { guid[i] = 0x40 + i; }
291
+ expect(fod.hash).toEqual(guid);
292
+ });
293
+
294
+ test('Random payload one byte short throws', () => {
295
+ const p = canonicalRandomPayload().slice(0, FodId.RANDOM_PAYLOAD_LENGTH - 1);
296
+ expect(() => FodId.fromBase64(envelopeBase64(p))).toThrow(RangeError);
297
+ });
298
+
299
+ test('Random payload larger than spec uses first 16 value bytes', () => {
300
+ const p = new Uint8Array(FodId.PAYLOAD_LENGTH);
301
+ p.set(canonicalRandomPayload());
302
+ p.fill(0xCC, FodId.RANDOM_PAYLOAD_LENGTH);
303
+ const fod = FodId.fromBase64(envelopeBase64(p));
304
+ expect(fod.type).toBe(IdType.RANDOM);
305
+ expect(fod.hash.length).toBe(FodId.GUID_LENGTH);
306
+ });
307
+
308
+ test('HashedEmail payload one byte short throws', () => {
309
+ const p = canonicalPayload().slice(0, FodId.PAYLOAD_LENGTH - 1);
310
+ expect(() => FodId.fromBase64(envelopeBase64(p))).toThrow(RangeError);
311
+ });
312
+
313
+ test('Reserved header-only payload parses', () => {
314
+ const p = new Uint8Array(FodId.HASH_OFFSET);
315
+ p[FodId.FLAGS_OFFSET] = 0b1100_0000;
316
+ const fod = FodId.fromBase64(envelopeBase64(p));
317
+ expect(fod.type).toBe(IdType.RESERVED);
318
+ expect(fod.hash.length).toBe(0);
319
+ });
320
+
321
+ // ----- Gap tests (runbook section 6b) -----
322
+
323
+ test('compare two 51Dids over the same payload', () => {
324
+ const payload = canonicalPayload();
325
+ const sigB = DUMMY_SIG.map((b) => b ^ 0xFF);
326
+ const a = envelopeBase64(payload, { date: DATE, signature: DUMMY_SIG });
327
+ const b = envelopeBase64(payload, { date: DATE + 5, signature: sigB });
328
+ const fa = FodId.fromBase64(a);
329
+ const fb = FodId.fromBase64(b);
330
+
331
+ expect(fa.hash).toEqual(fb.hash); // value is stable
332
+ expect(fa.date).not.toBe(fb.date); // envelope differs
333
+ expect(fa.signature).not.toEqual(fb.signature);
334
+ expect(a).not.toBe(b);
335
+ });
336
+
337
+ test('construction does not verify', () => {
338
+ // An envelope with a bogus signature still constructs and exposes all
339
+ // three fields - construction must not verify.
340
+ const fod = FodId.fromBase64(envelopeBase64(canonicalPayload()));
341
+ expect(fod.flags).toBe(CANONICAL_FLAGS);
342
+ expect(fod.licenseId).toBe(CANONICAL_LICENSE_ID);
343
+ expect(fod.hash).toEqual(canonicalHash());
344
+ });
345
+
346
+ test('fromOwid is decoupled from the source owid', () => {
347
+ // Mutating the source owid after construction must not affect the FodId
348
+ // (it holds an independent copy).
349
+ const o = new owid(envelopeBase64(canonicalPayload()));
350
+ const fod = FodId.fromOwid(o);
351
+ o.owid.payload = new Uint8Array(FodId.PAYLOAD_LENGTH); // mutate the source
352
+ expect(fod.hash).toEqual(canonicalHash());
353
+ expect(fod.flags).toBe(CANONICAL_FLAGS);
354
+ expect(fod.payload[FodId.HASH_OFFSET]).toBe(0x20);
355
+ });
356
+
357
+ test('verify with the wrong key returns false', async () => {
358
+ const { base64 } = await signedVerifiable(canonicalPayload());
359
+ const otherPublicPem = await randomPublicPem();
360
+ const fod = FodId.fromBase64(base64);
361
+ await expect(fod.verify(otherPublicPem)).resolves.toBe(false);
362
+ });
363
+
364
+ test('round-trip through the bytes constructor preserves all fields', () => {
365
+ const fod1 = FodId.fromBase64(envelopeBase64(canonicalPayload()));
366
+ const fod2 = FodId.fromByteArray(fod1.asByteArray());
367
+ expect(fod2.flags).toBe(fod1.flags);
368
+ expect(fod2.licenseId).toBe(fod1.licenseId);
369
+ expect(fod2.hash).toEqual(fod1.hash);
370
+ expect(fod2.domain).toBe(fod1.domain);
371
+ });
372
+ });
package/tests/setup.js ADDED
@@ -0,0 +1,35 @@
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
+ // Ensure the Web Crypto and base64 globals owid-js relies on are present in
24
+ // the Jest sandbox. In a normal Node 19+ runtime these already exist.
25
+ const { webcrypto } = require('crypto');
26
+
27
+ if (!globalThis.crypto) {
28
+ globalThis.crypto = webcrypto;
29
+ }
30
+ if (typeof globalThis.atob !== 'function') {
31
+ globalThis.atob = (b64) => Buffer.from(b64, 'base64').toString('binary');
32
+ }
33
+ if (typeof globalThis.btoa !== 'function') {
34
+ globalThis.btoa = (bin) => Buffer.from(bin, 'binary').toString('base64');
35
+ }