@blamejs/blamejs-shop 0.0.127 → 0.0.129
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/CHANGELOG.md +4 -0
- package/README.md +4 -0
- package/lib/storefront.js +235 -6
- package/lib/vendor/MANIFEST.json +3 -3
- package/lib/vendor/blamejs/CHANGELOG.md +14 -0
- package/lib/vendor/blamejs/README.md +7 -1
- package/lib/vendor/blamejs/SECURITY.md +3 -0
- package/lib/vendor/blamejs/api-snapshot.json +408 -2
- package/lib/vendor/blamejs/index.js +6 -0
- package/lib/vendor/blamejs/lib/cose.js +212 -4
- package/lib/vendor/blamejs/lib/cwt.js +244 -0
- package/lib/vendor/blamejs/lib/eat.js +240 -0
- package/lib/vendor/blamejs/lib/mdoc.js +305 -0
- package/lib/vendor/blamejs/lib/scitt.js +243 -0
- package/lib/vendor/blamejs/lib/tsa.js +688 -0
- package/lib/vendor/blamejs/lib/vc.js +328 -0
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.34.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.35.json +22 -0
- package/lib/vendor/blamejs/release-notes/v0.12.36.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.37.json +27 -0
- package/lib/vendor/blamejs/release-notes/v0.12.38.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.39.json +18 -0
- package/lib/vendor/blamejs/release-notes/v0.12.40.json +18 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/codebase-patterns.test.js +27 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/cose.test.js +84 -1
- package/lib/vendor/blamejs/test/layer-0-primitives/cwt.test.js +137 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/eat.test.js +111 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/mdoc.test.js +230 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/scitt.test.js +158 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/tsa.test.js +373 -0
- package/lib/vendor/blamejs/test/layer-0-primitives/vc.test.js +188 -0
- package/package.json +1 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.eat
|
|
4
|
+
* @nav Crypto
|
|
5
|
+
* @title Entity Attestation Token (EAT)
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* RFC 9711 Entity Attestation Token — a CWT (or JWT) profile that
|
|
9
|
+
* carries attestation claims describing the state of a device or
|
|
10
|
+
* software entity: a freshness nonce, a Universal Entity ID, OEM /
|
|
11
|
+
* hardware identifiers, debug status, software measurements, and
|
|
12
|
+
* nested submodule attestations. EAT is the token a Relying Party
|
|
13
|
+
* asks a device to produce to prove what it is and what state it is
|
|
14
|
+
* in. This module is the EAT profile over <code>b.cwt</code> — it
|
|
15
|
+
* maps the RFC 9711 claim names to their CWT claim-key integer
|
|
16
|
+
* labels and adds the attestation-specific verification.
|
|
17
|
+
*
|
|
18
|
+
* <code>b.eat.sign(claims, opts)</code> takes a friendly claims
|
|
19
|
+
* object (<code>nonce</code>, <code>ueid</code>, <code>oemid</code>,
|
|
20
|
+
* <code>dbgstat</code>, <code>eat_profile</code>,
|
|
21
|
+
* <code>measurements</code>, <code>submods</code>, … plus the
|
|
22
|
+
* standard CWT claims) and signs it as a CWT.
|
|
23
|
+
*
|
|
24
|
+
* <code>b.eat.verify(eat, opts)</code> verifies the CWT (signature +
|
|
25
|
+
* alg allowlist + time claims, via <code>b.cwt</code>) and then
|
|
26
|
+
* enforces the attestation contract:
|
|
27
|
+
*
|
|
28
|
+
* - <strong>Nonce binding</strong> — when the Relying Party supplied
|
|
29
|
+
* a fresh <code>expectedNonce</code>, the token's
|
|
30
|
+
* <code>eat_nonce</code> (claim 10) MUST match it (constant-time
|
|
31
|
+
* compare). This is the freshness / anti-replay defense: without
|
|
32
|
+
* it a captured attestation can be replayed indefinitely.
|
|
33
|
+
* - <strong>Debug status</strong> — <code>requireDebugDisabled</code>
|
|
34
|
+
* refuses a token whose <code>dbgstat</code> is
|
|
35
|
+
* <code>enabled</code> (0) or absent; only the disabled states
|
|
36
|
+
* (1–4) pass.
|
|
37
|
+
* - <strong>Profile</strong> — <code>expectedProfile</code> pins the
|
|
38
|
+
* <code>eat_profile</code> claim.
|
|
39
|
+
*
|
|
40
|
+
* Signing algorithms follow <code>b.cwt</code> / <code>b.cose</code>:
|
|
41
|
+
* ES256/384/512 + EdDSA (interoperable today) and ML-DSA-87.
|
|
42
|
+
*
|
|
43
|
+
* @card
|
|
44
|
+
* RFC 9711 Entity Attestation Token over b.cwt — sign / verify
|
|
45
|
+
* device + software attestation claims with verifier-nonce binding,
|
|
46
|
+
* debug-status policy, and profile pinning.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
var cwt = require("./cwt");
|
|
50
|
+
var bCrypto = require("./crypto");
|
|
51
|
+
var validateOpts = require("./validate-opts");
|
|
52
|
+
var { defineClass } = require("./framework-error");
|
|
53
|
+
|
|
54
|
+
var EatError = defineClass("EatError", { alwaysPermanent: true });
|
|
55
|
+
|
|
56
|
+
// RFC 9711 / IANA CWT Claims registry claim keys.
|
|
57
|
+
var EAT = { // allow:raw-byte-literal — RFC 9711 / IANA CWT claim-key labels, not byte sizes
|
|
58
|
+
nonce: 10, ueid: 256, sueids: 257, oemid: 258, hwmodel: 259, hwversion: 260, // allow:raw-byte-literal — CWT claim keys
|
|
59
|
+
uptime: 261, oemboot: 262, dbgstat: 263, location: 264, eat_profile: 265, // allow:raw-byte-literal — CWT claim keys
|
|
60
|
+
submods: 266, swname: 270, swversion: 271, manifests: 272, measurements: 273, // allow:raw-byte-literal — CWT claim keys
|
|
61
|
+
};
|
|
62
|
+
var EAT_BY_LABEL = {};
|
|
63
|
+
Object.keys(EAT).forEach(function (k) { EAT_BY_LABEL[EAT[k]] = k; });
|
|
64
|
+
|
|
65
|
+
// RFC 9711 §4.3.1 debug-status enumeration.
|
|
66
|
+
var DBGSTAT = {
|
|
67
|
+
"enabled": 0, "disabled": 1, "disabled-since-boot": 2,
|
|
68
|
+
"disabled-permanently": 3, "disabled-fully-and-permanently": 4,
|
|
69
|
+
};
|
|
70
|
+
var DBGSTAT_BY_VALUE = {};
|
|
71
|
+
Object.keys(DBGSTAT).forEach(function (k) { DBGSTAT_BY_VALUE[DBGSTAT[k]] = k; });
|
|
72
|
+
|
|
73
|
+
// Standard CWT claim labels → names (RFC 8392 §3.1.1) so EAT's
|
|
74
|
+
// friendly output names the standard claims alongside the EAT ones.
|
|
75
|
+
var STD_NAME = { 1: "iss", 2: "sub", 3: "aud", 4: "exp", 5: "nbf", 6: "iat", 7: "cti" };
|
|
76
|
+
|
|
77
|
+
function _toBuf(x) {
|
|
78
|
+
if (Buffer.isBuffer(x)) return x;
|
|
79
|
+
if (x instanceof Uint8Array) return Buffer.from(x);
|
|
80
|
+
if (typeof x === "string") return Buffer.from(x, "utf8");
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function _nonceMatches(claimValue, expected) {
|
|
85
|
+
var exp = _toBuf(expected);
|
|
86
|
+
if (!exp) return false;
|
|
87
|
+
// eat_nonce may be a single byte string or an array of them (one per
|
|
88
|
+
// verifier). Constant-time compare against each candidate.
|
|
89
|
+
var candidates = Array.isArray(claimValue) ? claimValue : [claimValue];
|
|
90
|
+
for (var i = 0; i < candidates.length; i++) {
|
|
91
|
+
var c = _toBuf(candidates[i]);
|
|
92
|
+
if (c && c.length === exp.length && bCrypto.timingSafeEqual(c, exp)) return true;
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @primitive b.eat.sign
|
|
99
|
+
* @signature b.eat.sign(claims, opts)
|
|
100
|
+
* @since 0.12.35
|
|
101
|
+
* @status stable
|
|
102
|
+
* @related b.eat.verify, b.cwt.sign
|
|
103
|
+
*
|
|
104
|
+
* Sign EAT attestation claims into a CWT. EAT claim names map to their
|
|
105
|
+
* RFC 9711 integer labels; <code>dbgstat</code> accepts the enum name
|
|
106
|
+
* (<code>"disabled-since-boot"</code>) or its integer. Standard CWT
|
|
107
|
+
* claims (<code>iss</code> / <code>exp</code> / …) pass through to
|
|
108
|
+
* <code>b.cwt.sign</code>.
|
|
109
|
+
*
|
|
110
|
+
* @opts
|
|
111
|
+
* {
|
|
112
|
+
* alg: string, // COSE signing alg (ES256 / EdDSA / ML-DSA-87 / …)
|
|
113
|
+
* privateKey: object, // signing key
|
|
114
|
+
* kid?: string,
|
|
115
|
+
* tagged?: boolean, // CWT tag 61
|
|
116
|
+
* }
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* var eat = await b.eat.sign(
|
|
120
|
+
* { nonce: rpNonce, ueid: deviceUeid, oemid: oem, dbgstat: "disabled-permanently",
|
|
121
|
+
* eat_profile: "https://example.com/eat/profile-1", iat: Math.floor(Date.now()/1000) },
|
|
122
|
+
* { alg: "ES256", privateKey: deviceKey });
|
|
123
|
+
*/
|
|
124
|
+
async function sign(claims, opts) {
|
|
125
|
+
if (!claims || typeof claims !== "object" || Array.isArray(claims)) {
|
|
126
|
+
throw new EatError("eat/bad-claims", "eat.sign: claims must be a plain object");
|
|
127
|
+
}
|
|
128
|
+
validateOpts.requireObject(opts, "eat.sign", EatError);
|
|
129
|
+
// Translate EAT claim names to their integer labels into a Map (so
|
|
130
|
+
// the integer keys survive — a plain object would stringify them).
|
|
131
|
+
// Standard CWT names (iss/sub/aud/exp/nbf/iat/cti) + custom keys are
|
|
132
|
+
// left for b.cwt.sign to handle.
|
|
133
|
+
var mapped = new Map();
|
|
134
|
+
var keys = Object.keys(claims);
|
|
135
|
+
for (var i = 0; i < keys.length; i++) {
|
|
136
|
+
var name = keys[i];
|
|
137
|
+
var value = claims[name];
|
|
138
|
+
if (name === "dbgstat" && typeof value === "string") {
|
|
139
|
+
if (!Object.prototype.hasOwnProperty.call(DBGSTAT, value)) {
|
|
140
|
+
throw new EatError("eat/bad-dbgstat",
|
|
141
|
+
"eat.sign: dbgstat must be one of " + Object.keys(DBGSTAT).join(" / ") + " (or an integer 0-4)");
|
|
142
|
+
}
|
|
143
|
+
value = DBGSTAT[value];
|
|
144
|
+
}
|
|
145
|
+
mapped.set(Object.prototype.hasOwnProperty.call(EAT, name) ? EAT[name] : name, value);
|
|
146
|
+
}
|
|
147
|
+
return cwt.sign(mapped, opts);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @primitive b.eat.verify
|
|
152
|
+
* @signature b.eat.verify(eat, opts)
|
|
153
|
+
* @since 0.12.35
|
|
154
|
+
* @status stable
|
|
155
|
+
* @related b.eat.sign, b.cwt.verify
|
|
156
|
+
*
|
|
157
|
+
* Verify an EAT and return its attestation claims. Delegates the CWT
|
|
158
|
+
* signature + algorithm-allowlist + time-claim checks to
|
|
159
|
+
* <code>b.cwt.verify</code>, then enforces the attestation contract:
|
|
160
|
+
* the <code>eat_nonce</code> must match <code>expectedNonce</code>
|
|
161
|
+
* (when supplied — the freshness/anti-replay binding),
|
|
162
|
+
* <code>requireDebugDisabled</code> refuses a non-disabled
|
|
163
|
+
* <code>dbgstat</code>, and <code>expectedProfile</code> pins
|
|
164
|
+
* <code>eat_profile</code>.
|
|
165
|
+
*
|
|
166
|
+
* @opts
|
|
167
|
+
* {
|
|
168
|
+
* algorithms: string[], // required — accepted COSE algs
|
|
169
|
+
* publicKey?: object,
|
|
170
|
+
* keyResolver?: function,
|
|
171
|
+
* expectedNonce?: Buffer, // require eat_nonce to match (freshness)
|
|
172
|
+
* requireDebugDisabled?: boolean, // refuse dbgstat enabled / absent
|
|
173
|
+
* expectedProfile?: string, // pin eat_profile
|
|
174
|
+
* expectedIssuer?: string, // forwarded to b.cwt.verify
|
|
175
|
+
* expectedAudience?: string,
|
|
176
|
+
* clockSkewSec?: number,
|
|
177
|
+
* now?: number,
|
|
178
|
+
* externalAad?: Buffer,
|
|
179
|
+
* }
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* var att = await b.eat.verify(eat, { algorithms: ["ES256"], publicKey: devicePub, expectedNonce: rpNonce, requireDebugDisabled: true });
|
|
183
|
+
* // → { claims: { nonce, ueid, dbgstat: "disabled-permanently", ... }, raw: Map, alg }
|
|
184
|
+
*/
|
|
185
|
+
async function verify(eat, opts) {
|
|
186
|
+
validateOpts.requireObject(opts, "eat.verify", EatError);
|
|
187
|
+
var out = await cwt.verify(eat, {
|
|
188
|
+
algorithms: opts.algorithms, publicKey: opts.publicKey, keyResolver: opts.keyResolver,
|
|
189
|
+
expectedIssuer: opts.expectedIssuer, expectedAudience: opts.expectedAudience,
|
|
190
|
+
clockSkewSec: opts.clockSkewSec, now: opts.now, externalAad: opts.externalAad,
|
|
191
|
+
});
|
|
192
|
+
var raw = out.raw;
|
|
193
|
+
|
|
194
|
+
// Nonce binding — the freshness / anti-replay defense. When the RP
|
|
195
|
+
// supplied a nonce, the token MUST carry a matching eat_nonce.
|
|
196
|
+
if (opts.expectedNonce != null) {
|
|
197
|
+
if (!raw.has(EAT.nonce)) {
|
|
198
|
+
throw new EatError("eat/nonce-missing", "eat.verify: expectedNonce supplied but token has no eat_nonce claim");
|
|
199
|
+
}
|
|
200
|
+
if (!_nonceMatches(raw.get(EAT.nonce), opts.expectedNonce)) {
|
|
201
|
+
throw new EatError("eat/nonce-mismatch", "eat.verify: eat_nonce does not match expectedNonce (stale / replayed attestation)");
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Debug status — refuse a token that can't prove debug is disabled.
|
|
206
|
+
if (opts.requireDebugDisabled === true) {
|
|
207
|
+
var ds = raw.get(EAT.dbgstat);
|
|
208
|
+
if (typeof ds !== "number" || ds < DBGSTAT.disabled) {
|
|
209
|
+
throw new EatError("eat/debug-not-disabled",
|
|
210
|
+
"eat.verify: requireDebugDisabled — dbgstat is " +
|
|
211
|
+
(ds === undefined ? "absent" : (DBGSTAT_BY_VALUE[ds] || ds)) + ", not a disabled state");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (opts.expectedProfile != null && raw.get(EAT.eat_profile) !== opts.expectedProfile) {
|
|
216
|
+
throw new EatError("eat/profile-mismatch", "eat.verify: eat_profile does not match expectedProfile");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Build friendly claims: EAT + standard labels → names; decode the
|
|
220
|
+
// dbgstat enum to its name.
|
|
221
|
+
var claims = {};
|
|
222
|
+
raw.forEach(function (v, k) {
|
|
223
|
+
var name = Object.prototype.hasOwnProperty.call(EAT_BY_LABEL, k) ? EAT_BY_LABEL[k]
|
|
224
|
+
: (Object.prototype.hasOwnProperty.call(STD_NAME, k) ? STD_NAME[k] : k);
|
|
225
|
+
if (name === "dbgstat" && typeof v === "number" && Object.prototype.hasOwnProperty.call(DBGSTAT_BY_VALUE, v)) {
|
|
226
|
+
v = DBGSTAT_BY_VALUE[v];
|
|
227
|
+
}
|
|
228
|
+
claims[name] = v;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
return { claims: claims, raw: raw, alg: out.alg, protectedHeaders: out.protectedHeaders };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
module.exports = {
|
|
235
|
+
sign: sign,
|
|
236
|
+
verify: verify,
|
|
237
|
+
CLAIM_LABELS: EAT,
|
|
238
|
+
DBGSTAT: DBGSTAT,
|
|
239
|
+
EatError: EatError,
|
|
240
|
+
};
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module b.mdoc
|
|
4
|
+
* @nav Crypto
|
|
5
|
+
* @title ISO mdoc / mDL (ISO 18013-5)
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Verify the issuer-signed data of an ISO/IEC 18013-5 mdoc — the
|
|
9
|
+
* credential format behind mobile driving licences (mDL) and the ISO
|
|
10
|
+
* track of the EU Digital Identity Wallet. This is the relying-party
|
|
11
|
+
* side: confirm that the data elements a holder presents were signed
|
|
12
|
+
* by the issuer and have not been altered.
|
|
13
|
+
*
|
|
14
|
+
* An mdoc's <code>IssuerSigned</code> structure carries the disclosed
|
|
15
|
+
* data elements (<code>nameSpaces</code>) and an <code>issuerAuth</code>
|
|
16
|
+
* that is a COSE_Sign1 (<code>b.cose</code>) over a Mobile Security
|
|
17
|
+
* Object (MSO). The MSO holds, per namespace, a SHA-256/384/512 digest
|
|
18
|
+
* of every issued element. <code>b.mdoc.verifyIssuerSigned</code>
|
|
19
|
+
* verifies the COSE signature with the issuer certificate carried in
|
|
20
|
+
* the COSE <code>x5chain</code> (label 33), parses the MSO, enforces
|
|
21
|
+
* its <code>validityInfo</code> window, and — the integrity check that
|
|
22
|
+
* makes selective disclosure trustworthy — recomputes the digest of
|
|
23
|
+
* every disclosed element (the full Tag-24 <code>IssuerSignedItemBytes</code>)
|
|
24
|
+
* and matches it against the MSO, constant-time. A disclosed element
|
|
25
|
+
* whose digest is absent or mismatched is refused.
|
|
26
|
+
*
|
|
27
|
+
* Signing algorithms follow <code>b.cose</code> verification: the
|
|
28
|
+
* classical ES256 / 384 / 512 and EdDSA that real mDL issuers use are
|
|
29
|
+
* accepted (consume-what-exists; the caller names the allowlist).
|
|
30
|
+
* <code>opts.trustAnchorsPem</code> additionally verifies the issuer
|
|
31
|
+
* certificate chain and its validity at the asserted time.
|
|
32
|
+
*
|
|
33
|
+
* <strong>Scope.</strong> This is issuer-data authentication
|
|
34
|
+
* (ISO 18013-5 §9.1.2.4) — the data is genuine and issuer-signed. The
|
|
35
|
+
* mdoc <em>device authentication</em> half (DeviceSigned / the
|
|
36
|
+
* SessionTranscript-bound holder-binding proof, §9.1.3) is deferred:
|
|
37
|
+
* it needs the live session transcript a verifier negotiates, so it is
|
|
38
|
+
* a presentation-protocol concern rather than a credential check.
|
|
39
|
+
* Composes <code>b.cose</code> + <code>b.cbor</code>; no new runtime
|
|
40
|
+
* dependency. Distinct from W3C VCDM (<code>b.vc</code>) and IETF
|
|
41
|
+
* SD-JWT VC (<code>b.auth.sdJwtVc</code>) — the three credential
|
|
42
|
+
* ecosystems.
|
|
43
|
+
*
|
|
44
|
+
* @card
|
|
45
|
+
* ISO 18013-5 mdoc / mDL issuer-data verification — checks the
|
|
46
|
+
* COSE_Sign1 IssuerAuth, the MSO validity window, and every disclosed
|
|
47
|
+
* element's digest against the Mobile Security Object. Composes
|
|
48
|
+
* b.cose + b.cbor; device-auth holder-binding deferred.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
var nodeCrypto = require("node:crypto");
|
|
52
|
+
var C = require("./constants");
|
|
53
|
+
var cbor = require("./cbor");
|
|
54
|
+
var cose = require("./cose");
|
|
55
|
+
var bCrypto = require("./crypto");
|
|
56
|
+
var validateOpts = require("./validate-opts");
|
|
57
|
+
var { defineClass } = require("./framework-error");
|
|
58
|
+
|
|
59
|
+
var MdocError = defineClass("MdocError", { alwaysPermanent: true });
|
|
60
|
+
|
|
61
|
+
var HDR_X5CHAIN = 33; // allow:raw-byte-literal allow:raw-time-literal — x5chain COSE header label (RFC 9360 is a spec number, not a size/duration)
|
|
62
|
+
var TAG_ENCODED_CBOR = 24; // allow:raw-byte-literal — RFC 8949 §3.4.5.1 embedded-CBOR tag
|
|
63
|
+
// Tags ISO 18013-5 uses in issuer data: tdate(0), epoch(1), embedded
|
|
64
|
+
// CBOR(24), full-date(1004, RFC 8943). Bounded — others are refused.
|
|
65
|
+
var ALLOWED_TAGS = [0, 1, TAG_ENCODED_CBOR, 1004];
|
|
66
|
+
var DIGEST_ALGS = { "SHA-256": "sha256", "SHA-384": "sha384", "SHA-512": "sha512" };
|
|
67
|
+
|
|
68
|
+
function _bytes(x, what) {
|
|
69
|
+
if (Buffer.isBuffer(x)) return x;
|
|
70
|
+
if (x instanceof Uint8Array) return Buffer.from(x);
|
|
71
|
+
throw new MdocError("mdoc/bad-input", "mdoc: " + what + " must be a Buffer / Uint8Array of CBOR");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// validityInfo dates are tdate (Tag 0, an RFC 3339 string) or epoch
|
|
75
|
+
// (Tag 1). Returns epoch-ms; fails closed on a malformed value.
|
|
76
|
+
function _validityMs(v, name) {
|
|
77
|
+
var raw = (v instanceof cbor.Tag) ? v.value : v;
|
|
78
|
+
if (typeof raw === "string") {
|
|
79
|
+
var ms = Date.parse(raw);
|
|
80
|
+
if (!isFinite(ms)) throw new MdocError("mdoc/bad-validity", "mdoc: validityInfo." + name + " is not a valid date: " + raw);
|
|
81
|
+
return ms;
|
|
82
|
+
}
|
|
83
|
+
if (typeof raw === "number" && isFinite(raw)) return raw * C.TIME.seconds(1); // epoch seconds → ms
|
|
84
|
+
throw new MdocError("mdoc/bad-validity", "mdoc: validityInfo." + name + " is missing or malformed");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _mapGet(m, k) { return m instanceof Map ? m.get(k) : (m ? m[k] : undefined); }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @primitive b.mdoc.verifyIssuerSigned
|
|
91
|
+
* @signature b.mdoc.verifyIssuerSigned(issuerSigned, opts)
|
|
92
|
+
* @since 0.12.40
|
|
93
|
+
* @status experimental
|
|
94
|
+
* @compliance gdpr, soc2
|
|
95
|
+
* @related b.cose.verify, b.vc.verify
|
|
96
|
+
*
|
|
97
|
+
* Verify the issuer-signed data of an ISO 18013-5 mdoc and return the
|
|
98
|
+
* disclosed elements. <code>issuerSigned</code> is the CBOR
|
|
99
|
+
* <code>IssuerSigned</code> map (the operator extracts it from the
|
|
100
|
+
* device response / QR). The COSE_Sign1 <code>issuerAuth</code> is
|
|
101
|
+
* verified with the issuer certificate from its <code>x5chain</code>
|
|
102
|
+
* header against the mandatory <code>opts.algorithms</code> allowlist;
|
|
103
|
+
* the MSO <code>validityInfo</code> window is enforced; and every
|
|
104
|
+
* disclosed element's digest is matched against the Mobile Security
|
|
105
|
+
* Object (a mismatch or absence is refused). Pass
|
|
106
|
+
* <code>opts.trustAnchorsPem</code> to also verify the issuer
|
|
107
|
+
* certificate chain.
|
|
108
|
+
*
|
|
109
|
+
* @opts
|
|
110
|
+
* {
|
|
111
|
+
* algorithms: string[], // required — accepted COSE alg names (ES256/384/512, EdDSA)
|
|
112
|
+
* trustAnchorsPem: string|string[], // optional issuer roots — enables chain + validity verification
|
|
113
|
+
* expectedDocType: string, // require the MSO docType to match (e.g. "org.iso.18013.5.1.mDL")
|
|
114
|
+
* at: Date, // validity instant (default now); must be a valid Date
|
|
115
|
+
* maxBytes: number, // forwarded to b.cbor.decode
|
|
116
|
+
* maxDepth: number,
|
|
117
|
+
* }
|
|
118
|
+
*
|
|
119
|
+
* @example
|
|
120
|
+
* var out = await b.mdoc.verifyIssuerSigned(issuerSignedBytes, {
|
|
121
|
+
* algorithms: ["ES256"], expectedDocType: "org.iso.18013.5.1.mDL",
|
|
122
|
+
* });
|
|
123
|
+
* // → { docType, validityInfo, namespaces: { "org.iso.18013.5.1": { family_name, age_over_18, … } }, signerCert, alg }
|
|
124
|
+
*/
|
|
125
|
+
async function verifyIssuerSigned(issuerSigned, opts) {
|
|
126
|
+
validateOpts.requireObject(opts, "mdoc.verifyIssuerSigned", MdocError);
|
|
127
|
+
validateOpts(opts, ["algorithms", "trustAnchorsPem", "expectedDocType", "at", "maxBytes", "maxDepth"], "mdoc.verifyIssuerSigned");
|
|
128
|
+
if (!Array.isArray(opts.algorithms) || opts.algorithms.length === 0) {
|
|
129
|
+
throw new MdocError("mdoc/algorithms-required", "mdoc.verifyIssuerSigned: opts.algorithms is required");
|
|
130
|
+
}
|
|
131
|
+
var at = new Date();
|
|
132
|
+
if (opts.at !== undefined && opts.at !== null) {
|
|
133
|
+
if (!(opts.at instanceof Date) || !isFinite(opts.at.getTime())) {
|
|
134
|
+
throw new MdocError("mdoc/bad-at", "mdoc.verifyIssuerSigned: opts.at must be a valid Date");
|
|
135
|
+
}
|
|
136
|
+
at = opts.at;
|
|
137
|
+
}
|
|
138
|
+
var decodeOpts = { allowedTags: ALLOWED_TAGS, maxBytes: opts.maxBytes, maxDepth: opts.maxDepth };
|
|
139
|
+
|
|
140
|
+
var top = cbor.decode(_bytes(issuerSigned, "issuerSigned"), decodeOpts);
|
|
141
|
+
var nameSpaces = _mapGet(top, "nameSpaces");
|
|
142
|
+
var issuerAuth = _mapGet(top, "issuerAuth");
|
|
143
|
+
if (!Array.isArray(issuerAuth) || issuerAuth.length !== 4) {
|
|
144
|
+
throw new MdocError("mdoc/malformed", "mdoc.verifyIssuerSigned: issuerAuth must be a COSE_Sign1 (4-element array)");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The signer certificate rides in the COSE x5chain (label 33): a
|
|
148
|
+
// single cert bstr or an array of bstrs, leaf first.
|
|
149
|
+
var unprotected = issuerAuth[1];
|
|
150
|
+
var x5 = _mapGet(unprotected, HDR_X5CHAIN);
|
|
151
|
+
var chain = Array.isArray(x5) ? x5 : (x5 != null ? [x5] : []);
|
|
152
|
+
if (!chain.length || !Buffer.isBuffer(chain[0])) {
|
|
153
|
+
throw new MdocError("mdoc/no-cert", "mdoc.verifyIssuerSigned: issuerAuth has no x5chain certificate (label 33)");
|
|
154
|
+
}
|
|
155
|
+
// The x5chain certificate is attacker-controlled — a malformed DER
|
|
156
|
+
// must surface as a clean error, not a raw OpenSSL throw.
|
|
157
|
+
var signerCert;
|
|
158
|
+
try { signerCert = new nodeCrypto.X509Certificate(chain[0]); }
|
|
159
|
+
catch (e) {
|
|
160
|
+
throw new MdocError("mdoc/bad-cert", "mdoc.verifyIssuerSigned: x5chain certificate is not valid DER: " + ((e && e.message) || e));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Verify the COSE_Sign1 signature with the embedded signer key.
|
|
164
|
+
var coseBytes = cbor.encode(issuerAuth);
|
|
165
|
+
var verified = await cose.verify(coseBytes, {
|
|
166
|
+
algorithms: opts.algorithms,
|
|
167
|
+
keyResolver: function () { return signerCert.publicKey; },
|
|
168
|
+
maxBytes: opts.maxBytes,
|
|
169
|
+
maxDepth: opts.maxDepth,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// payload = Tag 24 ( bstr .cbor MSO ).
|
|
173
|
+
var payloadTag = cbor.decode(verified.payload, decodeOpts);
|
|
174
|
+
var msoBytes = (payloadTag instanceof cbor.Tag && payloadTag.tag === TAG_ENCODED_CBOR) ? payloadTag.value : null;
|
|
175
|
+
if (!Buffer.isBuffer(msoBytes)) {
|
|
176
|
+
throw new MdocError("mdoc/malformed", "mdoc.verifyIssuerSigned: issuerAuth payload is not a Tag-24 MobileSecurityObject");
|
|
177
|
+
}
|
|
178
|
+
var mso = cbor.decode(msoBytes, decodeOpts);
|
|
179
|
+
|
|
180
|
+
var digestAlgName = _mapGet(mso, "digestAlgorithm");
|
|
181
|
+
var digestNode = DIGEST_ALGS[digestAlgName];
|
|
182
|
+
if (!digestNode) {
|
|
183
|
+
throw new MdocError("mdoc/bad-digest-alg", "mdoc.verifyIssuerSigned: unsupported MSO digestAlgorithm '" + digestAlgName + "'");
|
|
184
|
+
}
|
|
185
|
+
var docType = _mapGet(mso, "docType");
|
|
186
|
+
if (opts.expectedDocType !== undefined && docType !== opts.expectedDocType) {
|
|
187
|
+
throw new MdocError("mdoc/doctype-mismatch", "mdoc.verifyIssuerSigned: MSO docType '" + docType + "' does not match expectedDocType");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// validityInfo window (fail closed on malformed dates).
|
|
191
|
+
var vi = _mapGet(mso, "validityInfo");
|
|
192
|
+
if (!(vi instanceof Map) && (!vi || typeof vi !== "object")) {
|
|
193
|
+
throw new MdocError("mdoc/malformed", "mdoc.verifyIssuerSigned: MSO has no validityInfo");
|
|
194
|
+
}
|
|
195
|
+
var nowMs = at.getTime();
|
|
196
|
+
var validFromMs = _validityMs(_mapGet(vi, "validFrom"), "validFrom");
|
|
197
|
+
var validUntilMs = _validityMs(_mapGet(vi, "validUntil"), "validUntil");
|
|
198
|
+
if (nowMs < validFromMs) throw new MdocError("mdoc/not-yet-valid", "mdoc.verifyIssuerSigned: credential not yet valid");
|
|
199
|
+
if (nowMs > validUntilMs) throw new MdocError("mdoc/expired", "mdoc.verifyIssuerSigned: credential validity has passed");
|
|
200
|
+
|
|
201
|
+
// Match every disclosed element's digest against the MSO. The digest
|
|
202
|
+
// covers the full Tag-24 IssuerSignedItemBytes (ISO 18013-5 §9.1.2.5).
|
|
203
|
+
var valueDigests = _mapGet(mso, "valueDigests");
|
|
204
|
+
var out = {};
|
|
205
|
+
if (nameSpaces instanceof Map) {
|
|
206
|
+
var nsNames = Array.from(nameSpaces.keys());
|
|
207
|
+
for (var ni = 0; ni < nsNames.length; ni += 1) {
|
|
208
|
+
var ns = nsNames[ni];
|
|
209
|
+
var items = nameSpaces.get(ns);
|
|
210
|
+
var nsDigests = _mapGet(valueDigests, ns);
|
|
211
|
+
if (!Array.isArray(items) || !(nsDigests instanceof Map)) {
|
|
212
|
+
throw new MdocError("mdoc/malformed", "mdoc.verifyIssuerSigned: namespace '" + ns + "' has no matching valueDigests");
|
|
213
|
+
}
|
|
214
|
+
out[ns] = {};
|
|
215
|
+
var seen = Object.create(null); // dup-elementIdentifier guard (proto-safe)
|
|
216
|
+
for (var ii = 0; ii < items.length; ii += 1) {
|
|
217
|
+
var item = items[ii];
|
|
218
|
+
if (!(item instanceof cbor.Tag) || item.tag !== TAG_ENCODED_CBOR || !Buffer.isBuffer(item.value)) {
|
|
219
|
+
throw new MdocError("mdoc/malformed", "mdoc.verifyIssuerSigned: IssuerSignedItem is not a Tag-24 byte string");
|
|
220
|
+
}
|
|
221
|
+
var itemBytes = cbor.encode(new cbor.Tag(TAG_ENCODED_CBOR, item.value));
|
|
222
|
+
var digest = nodeCrypto.createHash(digestNode).update(itemBytes).digest();
|
|
223
|
+
var inner = cbor.decode(item.value, decodeOpts);
|
|
224
|
+
var digestID = _mapGet(inner, "digestID");
|
|
225
|
+
var expected = nsDigests.get(digestID);
|
|
226
|
+
if (!Buffer.isBuffer(expected) || !bCrypto.timingSafeEqual(digest, expected)) {
|
|
227
|
+
throw new MdocError("mdoc/digest-mismatch",
|
|
228
|
+
"mdoc.verifyIssuerSigned: disclosed element (digestID " + digestID + ", namespace " + ns + ") does not match the MSO");
|
|
229
|
+
}
|
|
230
|
+
// Refuse a duplicate elementIdentifier within a namespace — two
|
|
231
|
+
// signed values for one element is ambiguous; fail closed rather
|
|
232
|
+
// than silently keep the last.
|
|
233
|
+
var elementId = _mapGet(inner, "elementIdentifier");
|
|
234
|
+
if (seen[elementId]) {
|
|
235
|
+
throw new MdocError("mdoc/duplicate-element",
|
|
236
|
+
"mdoc.verifyIssuerSigned: namespace '" + ns + "' has duplicate elementIdentifier '" + elementId + "'");
|
|
237
|
+
}
|
|
238
|
+
seen[elementId] = true;
|
|
239
|
+
out[ns][elementId] = _mapGet(inner, "elementValue");
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Optional issuer chain + validity at the asserted time.
|
|
245
|
+
if (opts.trustAnchorsPem !== undefined && opts.trustAnchorsPem !== null) {
|
|
246
|
+
var anchors = typeof opts.trustAnchorsPem === "string" ? [opts.trustAnchorsPem] : opts.trustAnchorsPem;
|
|
247
|
+
if (!Array.isArray(anchors) || anchors.length === 0 ||
|
|
248
|
+
!anchors.every(function (a) { return typeof a === "string" && a.length > 0; })) {
|
|
249
|
+
throw new MdocError("mdoc/bad-trust-anchors", "mdoc.verifyIssuerSigned: trustAnchorsPem must be a non-empty PEM string or array");
|
|
250
|
+
}
|
|
251
|
+
_verifyChain(chain, anchors, at);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
docType: docType,
|
|
256
|
+
version: _mapGet(mso, "version"),
|
|
257
|
+
digestAlgorithm: digestAlgName,
|
|
258
|
+
validityInfo: { validFrom: new Date(validFromMs), validUntil: new Date(validUntilMs) },
|
|
259
|
+
namespaces: out,
|
|
260
|
+
signerCert: signerCert.toString(),
|
|
261
|
+
alg: verified.alg,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Verify the leaf (chain[0]) chains to a supplied anchor and every cert
|
|
266
|
+
// is valid at `at`. Intermediates in the x5chain are consulted.
|
|
267
|
+
function _verifyChain(chainDer, anchorsPem, at) {
|
|
268
|
+
var anchors = anchorsPem.map(function (p) { return new nodeCrypto.X509Certificate(p); });
|
|
269
|
+
var pool = chainDer.map(function (d) { return new nodeCrypto.X509Certificate(d); });
|
|
270
|
+
var current = pool[0];
|
|
271
|
+
var atMs = at.getTime();
|
|
272
|
+
var steps = 0;
|
|
273
|
+
while (steps <= pool.length + 1) {
|
|
274
|
+
_assertValidAt(current, atMs);
|
|
275
|
+
for (var a = 0; a < anchors.length; a += 1) {
|
|
276
|
+
if (_issued(anchors[a], current)) { _assertValidAt(anchors[a], atMs); return; }
|
|
277
|
+
if (current.fingerprint256 === anchors[a].fingerprint256) return;
|
|
278
|
+
}
|
|
279
|
+
var parent = null;
|
|
280
|
+
for (var p = 0; p < pool.length; p += 1) {
|
|
281
|
+
if (pool[p].fingerprint256 !== current.fingerprint256 && _issued(pool[p], current)) { parent = pool[p]; break; }
|
|
282
|
+
}
|
|
283
|
+
if (!parent) {
|
|
284
|
+
throw new MdocError("mdoc/untrusted-chain", "mdoc.verifyIssuerSigned: issuer certificate does not chain to a supplied trust anchor");
|
|
285
|
+
}
|
|
286
|
+
current = parent;
|
|
287
|
+
steps += 1;
|
|
288
|
+
}
|
|
289
|
+
throw new MdocError("mdoc/chain-loop", "mdoc.verifyIssuerSigned: certificate chain did not terminate");
|
|
290
|
+
}
|
|
291
|
+
function _issued(issuer, subject) {
|
|
292
|
+
try { return subject.checkIssued(issuer) && subject.verify(issuer.publicKey); }
|
|
293
|
+
catch (_e) { return false; }
|
|
294
|
+
}
|
|
295
|
+
function _assertValidAt(cert, atMs) {
|
|
296
|
+
if (atMs < cert.validFromDate.getTime() || atMs > cert.validToDate.getTime()) {
|
|
297
|
+
throw new MdocError("mdoc/cert-expired", "mdoc.verifyIssuerSigned: certificate '" + cert.subject + "' is not valid at the asserted time");
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
module.exports = {
|
|
302
|
+
verifyIssuerSigned: verifyIssuerSigned,
|
|
303
|
+
DIGEST_ALGS: DIGEST_ALGS,
|
|
304
|
+
MdocError: MdocError,
|
|
305
|
+
};
|