@blamejs/pki 0.2.4 → 0.2.6

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,176 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The documented surface is the
6
+ // verifiers whose credential-key handling composes this validator (pki.webauthn).
7
+ //
8
+ // validator-cose -- the SINGLE home for "is this a conformant WebAuthn credential
9
+ // COSE_Key" (RFC 9052 sec. 7 structure + RFC 9053 EC2/OKP/RSA key parameters + the
10
+ // CTAP2 canonical-CBOR profile WebAuthn sec. 6.5.1 imposes). Sibling to the guard
11
+ // family: where a guard owns a CVE-class fail-closed defence once, a validator owns a
12
+ // decoded TYPE's COMPLETE conformance rule set once, so a format module composes the
13
+ // family rather than re-deriving a partial subset inline (the drift that leaks MUSTs
14
+ // out one review round at a time). Enforced by the validator-shape-reinlined
15
+ // codebase-patterns detector: a lib function that re-inlines COSE-key validation fires.
16
+ //
17
+ // Interface mirrors the guard family: (subject, E, code) where E is the caller's typed
18
+ // error CONSTRUCTOR and code its domain code, so every boundary keeps its own
19
+ // domain/reason (a future COSE consumer passes its own E + code).
20
+ //
21
+ // Rule set (gap-checked verbatim against RFC 9052 sec. 7 + RFC 9053 sec. 2/6 +
22
+ // WebAuthn sec. 6.5.1 + the IANA COSE Key Type / Key Type Parameters registries):
23
+ // - kty (label 1) REQUIRED; value an integer (CTAP2 canonical -- a tstr kty/alg is
24
+ // rejected as non-canonical for the WebAuthn profile).
25
+ // - alg (label 3) REQUIRED (the RP needs it to verify the later assertion).
26
+ // - EC2 (kty 2): crv (-1), x (-2), y (-3) all present; x/y length == the curve field
27
+ // size. OKP (kty 1): crv (-1) == Ed25519(6)/Ed448(7), x (-2) length == the key size.
28
+ // RSA (kty 3): n (-1), e (-2) present + non-empty.
29
+ // - CANONICAL: EXACTLY the type's parameters, nothing more (EC2 = 5, OKP/RSA = 4) --
30
+ // rejects a padded key / a private "d" component / kid / key_ops (WebAuthn 6.5.1
31
+ // CTAP2 canonical, stricter than open COSE `* label => values`).
32
+ // - PROFILE: alg <-> kty (and, for EC2, alg <-> crv) consistent; -8 (EdDSA) is Ed25519
33
+ // ONLY; the RFC 9864 fully-specified ids (-9/-51/-52/-19/-53) are accepted.
34
+ // - COMPRESSED: an EC2 credential key MUST use the uncompressed point form (y is a full
35
+ // coordinate byte string, never a CBOR bool sign bit) -- WebAuthn sec. alg identifier.
36
+ // - ON-CURVE: the public key point MUST be valid for its curve. For EC2 the SPKI is
37
+ // imported via node:crypto so OpenSSL validates the point (an off-curve or identity
38
+ // point fails to parse). For OKP, OpenSSL does NOT validate the Edwards point on
39
+ // import (an all-zeroes key parses, and even verifies a trivial signature), so the
40
+ // point is checked explicitly via edwards-point (RFC 8032 decode + cofactor low-order
41
+ // rejection) -- an off-curve, non-canonical, or low-order OKP key fails closed.
42
+
43
+ var cbor = require("./cbor-det");
44
+ var asn1 = require("./asn1-der");
45
+ var oid = require("./oid");
46
+ var edwardsPoint = require("./edwards-point");
47
+ var nodeCrypto = require("crypto");
48
+
49
+ // COSE EC2 curve (label -1) -> the fixed field-element byte length x/y carry, and the
50
+ // named-curve OID a certificate on that curve declares.
51
+ var EC2_CRV_LEN = { 1: 32, 2: 48, 3: 66 }; // P-256 / P-384 / P-521
52
+ var EC2_CRV_OID = { 1: "prime256v1", 2: "secp384r1", 3: "secp521r1" };
53
+ // COSE OKP curve (label -1) -> its RFC 8410 named-key OID + fixed public-key length.
54
+ var OKP_CRV = { 6: { oid: "Ed25519", len: 32 }, 7: { oid: "Ed448", len: 57 } };
55
+ // alg (label 3) -> the key type (and, for EC2, curve) it pins. WebAuthn (sec. alg
56
+ // identifier) adds guarantees over the open COSE registry: an ECDSA alg fixes its curve,
57
+ // -8 (EdDSA) is Ed25519 ONLY, and the RFC 9864 fully-specified ids (-9 ESP256, -51 ESP384,
58
+ // -52 ESP512, -19 Ed25519, -53 Ed448) each pin key type + curve. A verifier accepts the
59
+ // fully-specified ids even though WebAuthn recommends against them for credential creation.
60
+ var ALG_PROFILE = {
61
+ "-7": { kty: 2, crv: 1 }, "-35": { kty: 2, crv: 2 }, "-36": { kty: 2, crv: 3 },
62
+ "-9": { kty: 2, crv: 1 }, "-51": { kty: 2, crv: 2 }, "-52": { kty: 2, crv: 3 },
63
+ "-8": { kty: 1, crv: 6 }, "-19": { kty: 1, crv: 6 }, "-53": { kty: 1, crv: 7 },
64
+ "-257": { kty: 3 }, "-258": { kty: 3 }, "-259": { kty: 3 }, "-37": { kty: 3 }, "-65535": { kty: 3 },
65
+ };
66
+
67
+ // The value node of an integer-labelled COSE_Key parameter (labels are ints), or null.
68
+ function _mapGetInt(node, key) {
69
+ if (!node || node.majorType !== 5 || !node.children) return null;
70
+ for (var i = 0; i < node.children.length; i++) {
71
+ var k = node.children[i][0];
72
+ if ((k.majorType === 0 || k.majorType === 1) && cbor.read.int(k) === BigInt(key)) return node.children[i][1];
73
+ }
74
+ return null;
75
+ }
76
+
77
+ // credentialKey(node, E, code) -> the decoded + validated credential public key
78
+ // { kty, alg, crv?, x?, y?, n?, e? }, or throws new E(code, ...). The complete COSE_Key
79
+ // conformance gate for a WebAuthn credential key; a format module MUST route a credential
80
+ // key through here, never re-inline the kty/alg/crv/length/canonical/profile/on-curve
81
+ // checks.
82
+ // @enforced-by validator-shape-reinlined
83
+ // @validator-shape kty\s*===\s*2n
84
+ // @validator-shape EC2_CRV_LEN|ALG_PROFILE
85
+ function credentialKey(node, E, code) {
86
+ function bad(msg, cause) { return new E(code, msg, cause); }
87
+ if (!node || node.majorType !== 5) throw bad("a COSE_Key must be a CBOR map (RFC 9052 sec. 7)");
88
+ // Every parameter read maps a wrong-type cbor/* fault to the caller's domain -- a
89
+ // wrong-typed COSE label (x as an integer, kty as a string) is bad input, not a leak.
90
+ function ib(label) { var n = _mapGetInt(node, label); if (!n) return null; try { return cbor.read.byteString(n); } catch (e) { throw bad("COSE_Key parameter " + label + " must be a byte string", e); } }
91
+ function ii(label) { var n = _mapGetInt(node, label); if (!n) return null; try { return cbor.read.int(n); } catch (e) { throw bad("COSE_Key parameter " + label + " must be an integer", e); } }
92
+ var ktyN = _mapGetInt(node, 1), algN = _mapGetInt(node, 3);
93
+ if (!ktyN) throw bad("a COSE_Key is missing the kty (label 1) parameter");
94
+ if (!algN) throw bad("a COSE_Key is missing the alg (label 3) parameter");
95
+ var kty, algv;
96
+ try { kty = cbor.read.int(ktyN); algv = cbor.read.int(algN); }
97
+ catch (e) { throw bad("COSE_Key kty (label 1) and alg (label 3) must be integers", e); }
98
+ var key = { kty: Number(kty), alg: Number(algv) };
99
+ if (kty === 2n) {
100
+ key.crv = ii(-1) != null ? Number(ii(-1)) : null;
101
+ // WebAuthn EC2 credential keys MUST use the uncompressed point form: y (-3) is the full
102
+ // y-coordinate byte string, never a CBOR bool sign bit (WebAuthn sec. alg identifier).
103
+ var yNode = _mapGetInt(node, -3);
104
+ if (yNode && yNode.majorType === 7) throw bad("an EC2 credential key must use the uncompressed point form (a compressed y sign-bit is not permitted for WebAuthn)");
105
+ key.x = ib(-2); key.y = ib(-3);
106
+ if (key.crv == null || !key.x || !key.y) throw bad("an EC2 COSE_Key must carry crv (-1), x (-2), and y (-3)");
107
+ var el = EC2_CRV_LEN[key.crv];
108
+ if (!el || key.x.length !== el || key.y.length !== el) throw bad("an EC2 COSE_Key x/y length is inconsistent with its curve");
109
+ } else if (kty === 1n) {
110
+ key.crv = ii(-1) != null ? Number(ii(-1)) : null; key.x = ib(-2);
111
+ var okp = OKP_CRV[key.crv];
112
+ if (!okp || !key.x || key.x.length !== okp.len) throw bad("an OKP COSE_Key must be Ed25519 (crv 6) or Ed448 (crv 7) with a matching-length x (-2)");
113
+ } else if (kty === 3n) {
114
+ key.n = ib(-1); key.e = ib(-2);
115
+ if (!key.n || !key.n.length || !key.e || !key.e.length) throw bad("an RSA COSE_Key must carry n (-1) and e (-2)");
116
+ } else {
117
+ throw bad("unsupported COSE_Key kty " + Number(kty));
118
+ }
119
+ // CANONICAL CTAP2 COSE_Key: exactly the type's parameters, nothing more.
120
+ var expectedParams = kty === 2n ? 5 : 4;
121
+ if (node.children.length !== expectedParams) throw bad("the COSE_Key carries parameters beyond the canonical set for its key type (WebAuthn sec. 6.5.1)");
122
+ // PROFILE: the declared alg must match the key type (and, for EC2, the curve).
123
+ var prof = ALG_PROFILE[String(key.alg)];
124
+ if (!prof) throw bad("unsupported credential key algorithm " + key.alg);
125
+ if (prof.kty !== key.kty) throw bad("credential key algorithm " + key.alg + " is inconsistent with key type " + key.kty);
126
+ if (prof.crv != null && prof.crv !== key.crv) throw bad("credential key algorithm " + key.alg + " requires a different curve");
127
+ // ON-CURVE: import the SPKI so OpenSSL validates the EC point on its curve. An off-curve
128
+ // x/y or the identity point fails to parse here.
129
+ try { nodeCrypto.createPublicKey({ key: toSpki(key, E, code), format: "der", type: "spki" }); }
130
+ catch (e) { throw bad("the credential public key point is not valid for its curve", e); }
131
+ // OpenSSL does NOT validate an OKP (Ed25519/Ed448) point on import -- an all-zeroes key
132
+ // parses, and even verifies a trivial signature -- so an OKP point needs an explicit
133
+ // on-curve + full-order (non-low-order) check (RFC 8032 decode + the cofactor check).
134
+ if (kty === 1n && !edwardsPoint.validate(key.x, key.crv)) throw bad("the OKP credential public key is not a valid, full-order Edwards point");
135
+ return key;
136
+ }
137
+
138
+ // toSpki(key, E, code) -> a self-contained SubjectPublicKeyInfo DER for a validated COSE
139
+ // key, so a credential key and a certificate key import/compare by one path.
140
+ // @enforced-by behavioral -- an SPKI encoding of a validated COSE key has no rename-proof
141
+ // code shape distinct from the ecPublicKey/rsaEncryption OID-name tokens that legitimately
142
+ // recur in the oid registry + inspect renderer; its consumers route through
143
+ // validatorCose.toSpki and credentialKey calls it on the on-curve path, so the webauthn
144
+ // KAT round-trip (a real SPKI imports + verifies) is the behavioral guard.
145
+ function toSpki(key, E, code) {
146
+ function bad(msg) { return new E(code, msg); }
147
+ if (key.kty === 2 && key.x && key.y) {
148
+ var curveOid = EC2_CRV_OID[key.crv];
149
+ if (!curveOid) throw bad("unsupported EC2 curve " + key.crv);
150
+ var b = asn1.build;
151
+ return b.sequence([
152
+ b.sequence([b.oid(oid.byName("ecPublicKey")), b.oid(oid.byName(curveOid))]),
153
+ b.bitString(Buffer.concat([Buffer.from([0x04]), key.x, key.y])),
154
+ ]);
155
+ }
156
+ if (key.kty === 3 && key.n && key.n.length && key.e && key.e.length) {
157
+ var bb = asn1.build;
158
+ return bb.sequence([
159
+ bb.sequence([bb.oid(oid.byName("rsaEncryption")), bb.nullValue()]),
160
+ bb.bitString(bb.sequence([bb.integer(BigInt("0x" + key.n.toString("hex"))), bb.integer(BigInt("0x" + key.e.toString("hex")))])),
161
+ ]);
162
+ }
163
+ if (key.kty === 1 && OKP_CRV[key.crv] && key.x && key.x.length === OKP_CRV[key.crv].len) {
164
+ var eb = asn1.build;
165
+ return eb.sequence([eb.sequence([eb.oid(oid.byName(OKP_CRV[key.crv].oid))]), eb.bitString(key.x)]);
166
+ }
167
+ throw bad("cannot build an SPKI for this COSE key type");
168
+ }
169
+
170
+ module.exports = {
171
+ credentialKey: credentialKey,
172
+ toSpki: toSpki,
173
+ EC2_CRV_LEN: EC2_CRV_LEN,
174
+ EC2_CRV_OID: EC2_CRV_OID,
175
+ OKP_CRV: OKP_CRV,
176
+ };
@@ -0,0 +1,89 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The documented surface is the verifier whose
6
+ // android-key handling composes this validator (pki.webauthn).
7
+ //
8
+ // validator-keydesc -- the SINGLE home for the Android Key Attestation KeyDescription
9
+ // conformance (the AOSP key-attestation schema + WebAuthn sec. 8.4.1 authorization-list
10
+ // checks). Sibling to the guard family: a validator owns a decoded TYPE's COMPLETE
11
+ // conformance rule set once, so the four 8.4.1 checks cannot lose a gate to drift.
12
+ //
13
+ // The caller supplies its parsed certificate + an extension-accessor `exts`
14
+ // { find(cert, oidName) -> rawExt|null } + its typed error CONSTRUCTOR E with two codes: a
15
+ // STRUCTURAL `code` (a malformed KeyDescription is bad input) and a semantic `failCode` (a
16
+ // well-formed description that fails an 8.4.1 MUST is a failed verification).
17
+ //
18
+ // Rule set (gap-checked verbatim against WebAuthn sec. 8.4.1 + the AOSP KeyDescription
19
+ // schema):
20
+ // - KeyDescription is a positional SEQUENCE; attestationChallenge (position 4) MUST equal
21
+ // clientDataHash.
22
+ // - allApplications (tag 600) MUST be absent from BOTH the softwareEnforced (position 6)
23
+ // and teeEnforced (position 7) AuthorizationLists (the key MUST be scoped to the RP).
24
+ // - origin (tag 702) MUST equal KM_ORIGIN_GENERATED (0): at least one list declares it,
25
+ // and every list that declares one says GENERATED (a mixed IMPORTED/GENERATED key is
26
+ // contradictory and rejected).
27
+ // - purpose (tag 1) MUST be EXACTLY { KM_PURPOSE_SIGN (2) } over the union of the lists.
28
+
29
+ var asn1 = require("./asn1-der");
30
+
31
+ // The EXPLICIT-unwrapped value node for a [tag] field of an AuthorizationList (a SEQUENCE
32
+ // of context-tagged OPTIONAL fields with non-contiguous tag numbers), or null when absent.
33
+ function _alGet(seqNode, tag) {
34
+ if (!seqNode || !seqNode.children) return null;
35
+ for (var i = 0; i < seqNode.children.length; i++) {
36
+ var c = seqNode.children[i];
37
+ if (c.tagClass === "context" && c.tagNumber === tag) return c.children && c.children[0] ? c.children[0] : c;
38
+ }
39
+ return null;
40
+ }
41
+
42
+ function _alInt(node, E, code) {
43
+ try { return asn1.read.integer(node); }
44
+ catch (e) { throw new E(code, "an android KeyDescription authorization value is not an INTEGER", e); }
45
+ }
46
+
47
+ function _purposeUnion(a, b, E, code) {
48
+ var out = [];
49
+ [a, b].forEach(function (list) {
50
+ var p = _alGet(list, 1);
51
+ if (p && p.children) p.children.forEach(function (c) { var v = _alInt(c, E, code); if (out.indexOf(v) === -1) out.push(v); });
52
+ });
53
+ return out;
54
+ }
55
+
56
+ // androidKeyDescription(cert, clientDataHash, exts, E, code, failCode) -- the complete
57
+ // WebAuthn 8.4.1 KeyDescription gate. A verifier MUST route the android-key description
58
+ // through here, never re-derive a partial subset of the four checks inline.
59
+ // @enforced-by behavioral -- the challenge / allApplications / origin / purpose checks are
60
+ // WebAuthn 8.4.1 business rules with no rename-proof code shape; the RED vectors (challenge
61
+ // mismatch, allApplications present, origin != GENERATED, purpose != SIGN, and the empty-
62
+ // authorization-list spec vector) are the guard.
63
+ function androidKeyDescription(cert, clientDataHash, exts, E, code, failCode) {
64
+ var ext = exts.find(cert, "keyDescription");
65
+ if (!ext) throw new E(code, "the android-key attestation certificate is missing the key-description extension (WebAuthn 8.4.1)");
66
+ var kd;
67
+ try { kd = asn1.decode(ext.value); } catch (e) { throw new E(code, "the android KeyDescription is not decodable", e); }
68
+ if (!kd.children || kd.children.length < 8) throw new E(code, "the android KeyDescription is not a positional 8-field SEQUENCE");
69
+ // (1) attestationChallenge (position 4) == clientDataHash.
70
+ var challenge;
71
+ try { challenge = asn1.read.octetString(kd.children[4]); } catch (e) { throw new E(code, "the android attestationChallenge is not an OCTET STRING", e); }
72
+ if (!challenge.equals(clientDataHash)) throw new E(failCode, "the android attestationChallenge does not equal clientDataHash");
73
+ var softwareEnforced = kd.children[6], hardwareEnforced = kd.children[7];
74
+ // (2) [600] allApplications MUST be ABSENT in both lists.
75
+ if (_alGet(softwareEnforced, 600) || _alGet(hardwareEnforced, 600)) throw new E(failCode, "android allApplications MUST be absent (WebAuthn 8.4.1)");
76
+ // (3) origin == KM_ORIGIN_GENERATED (0) over the union: at least one list declares it,
77
+ // and every list that declares one says GENERATED.
78
+ var origins = [_alGet(softwareEnforced, 702), _alGet(hardwareEnforced, 702)].filter(Boolean);
79
+ if (!origins.length || !origins.every(function (o) { return _alInt(o, E, code) === 0n; })) {
80
+ throw new E(failCode, "android key origin is not KM_ORIGIN_GENERATED in every authorization list that declares it (WebAuthn 8.4.1)");
81
+ }
82
+ // (4) purpose == exactly { KM_PURPOSE_SIGN (2) } over the union.
83
+ var purposes = _purposeUnion(softwareEnforced, hardwareEnforced, E, code);
84
+ if (purposes.length !== 1 || purposes[0] !== 2n) {
85
+ throw new E(failCode, "android key purpose is not exactly KM_PURPOSE_SIGN (WebAuthn 8.4.1)");
86
+ }
87
+ }
88
+
89
+ module.exports = { androidKeyDescription: androidKeyDescription };
@@ -0,0 +1,57 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The documented surface is the verifiers
6
+ // whose ECDSA signature handling composes this validator (pki.webauthn).
7
+ //
8
+ // validator-sig -- the SINGLE home for "is this a conformant DER ECDSA-Sig-Value", and
9
+ // its conversion to the raw r||s (IEEE P1363) form a WebCrypto verify expects. Sibling to
10
+ // the guard family: a validator owns a decoded TYPE's COMPLETE conformance rule set once,
11
+ // so a format module composes it rather than hand-decoding a 2-INTEGER SEQUENCE into r/s
12
+ // and forgetting a strict-DER check (the drift a validator exists to prevent).
13
+ //
14
+ // Interface mirrors the guard family: (subject, ..., E, code) where E is the caller's
15
+ // typed error CONSTRUCTOR and code its domain code.
16
+ //
17
+ // Rule set (gap-checked verbatim against RFC 3279 sec. 2.2.3 + X.690 sec. 8.3 + SEC1):
18
+ // - ECDSA-Sig-Value ::= SEQUENCE { r INTEGER, s INTEGER } -- a universal SEQUENCE with
19
+ // EXACTLY two children.
20
+ // - r and s are each a PRIMITIVE, MINIMALLY-ENCODED (X.690 sec. 8.3.2 -- no redundant
21
+ // leading 0x00/0xFF), POSITIVE integer. Reading through the strict DER integer reader
22
+ // enforces the primitive + minimal-encoding rules; the value is then range-checked
23
+ // positive (r,s >= 1) and bounded by the curve field size. A non-minimal, zero,
24
+ // negative, or over-size coordinate fails closed -- never normalized-and-accepted.
25
+
26
+ var asn1 = require("./asn1-der");
27
+
28
+ // ecdsaSigToRaw(der, coordLen, E, code) -> the validated signature as raw r||s, each
29
+ // coordinate left-padded to coordLen bytes, or throws new E(code, ...). The complete DER
30
+ // ECDSA-Sig-Value conformance gate; a verifier MUST route an ECDSA signature through here,
31
+ // never hand-decode the SEQUENCE and read r/s content raw (which skips minimality).
32
+ // @enforced-by behavioral -- a DER ECDSA-Sig-Value decode has no rename-proof code shape
33
+ // distinct from generic 2-child-SEQUENCE content access; the RED conformance vectors
34
+ // (non-minimal / negative / zero / over-size r or s rejected) and the webauthn ECDSA KATs
35
+ // are the guard.
36
+ function ecdsaSigToRaw(der, coordLen, E, code) {
37
+ var node;
38
+ try { node = asn1.decode(der); } catch (e) { throw new E(code, "ECDSA signature is not a DER SEQUENCE", e); }
39
+ if (node.tagClass !== "universal" || node.tagNumber !== asn1.TAGS.SEQUENCE || !node.children || node.children.length !== 2) {
40
+ throw new E(code, "ECDSA signature must be a DER SEQUENCE { r, s }");
41
+ }
42
+ function coord(c, label) {
43
+ // The strict DER integer reader enforces PRIMITIVE + MINIMAL encoding (a constructed
44
+ // child, an empty INTEGER, or a redundant 0x00/0xFF sign octet all throw). The value
45
+ // is then range-checked: r and s MUST be positive (>= 1) and fit the curve field size.
46
+ var v;
47
+ try { v = asn1.read.integer(c); } catch (e) { throw new E(code, "ECDSA signature " + label + " is not a minimally-encoded DER INTEGER", e); }
48
+ if (v <= 0n) throw new E(code, "ECDSA signature " + label + " must be a positive integer");
49
+ var hex = v.toString(16); if (hex.length % 2) hex = "0" + hex;
50
+ var b = Buffer.from(hex, "hex");
51
+ if (b.length > coordLen) throw new E(code, "ECDSA signature " + label + " exceeds the curve field size");
52
+ var out = Buffer.alloc(coordLen); b.copy(out, coordLen - b.length); return out;
53
+ }
54
+ return Buffer.concat([coord(node.children[0], "r"), coord(node.children[1], "s")]);
55
+ }
56
+
57
+ module.exports = { ecdsaSigToRaw: ecdsaSigToRaw };
@@ -0,0 +1,135 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ //
5
+ // @internal -- no operator-facing namespace. The documented surface is the verifier whose
6
+ // tpm handling composes this validator (pki.webauthn).
7
+ //
8
+ // validator-tpm -- the SINGLE home for the TPM 2.0 structure conformance a WebAuthn tpm
9
+ // attestation carries: the TPMT_PUBLIC (pubArea) and TPMS_ATTEST (certInfo) decode, plus the
10
+ // pubArea-key == credential-key binding (WebAuthn sec. 8.3 + TCG TPM 2.0 Part 2). Sibling to
11
+ // the guard family: a validator owns a decoded TYPE's COMPLETE conformance rule set once, so
12
+ // the strict TPM2B sizing / algorithm-union walking / trailing-byte rejection cannot drift.
13
+ //
14
+ // Every field is unsigned big-endian, packed with no padding; every TPM2B_* is a UINT16 size
15
+ // followed by exactly `size` bytes. The bare certInfo and pubArea carry NO outer TPM2B size
16
+ // prefix. The caller supplies its typed error CONSTRUCTOR E + a structural `code` (a
17
+ // malformed structure is bad input) and, for the key binding, a `mismatchCode`.
18
+ //
19
+ // Rule set (gap-checked verbatim against WebAuthn sec. 8.3 + TCG TPM 2.0 Part 2 sec. 10.12 /
20
+ // 12.2):
21
+ // - parsePubArea: decode type/nameAlg, walk parameters per the algorithm union, read the
22
+ // public key in `unique`; reject trailing bytes (they perturb the TPM Name hash).
23
+ // - parseCertInfo: magic == TPM_GENERATED_VALUE, type == TPM_ST_ATTEST_CERTIFY; read
24
+ // extraData + attested.name; reject trailing bytes past the attested structure.
25
+ // - pubKeyEqualsCose: the pubArea key (EC curve+x+y, or RSA modulus+exponent) equals the
26
+ // credential COSE key, compared as unsigned magnitudes (a TPM2B may differ from a COSE
27
+ // coordinate by a leading 0x00), the RSA exponent over its full UINT32 width.
28
+
29
+ var ByteReader = require("./byte-reader");
30
+
31
+ var TPM_GENERATED_VALUE = 0xff544347;
32
+ var TPM_ST_ATTEST_CERTIFY = 0x8017;
33
+ var TPM_ALG = { RSA: 0x0001, SHA1: 0x0004, SHA256: 0x000b, SHA384: 0x000c, SHA512: 0x000d, NULL: 0x0010, ECC: 0x0023 };
34
+ var TPM_ALG_HASH = {}; TPM_ALG_HASH[TPM_ALG.SHA1] = "sha1"; TPM_ALG_HASH[TPM_ALG.SHA256] = "sha256"; TPM_ALG_HASH[TPM_ALG.SHA384] = "sha384"; TPM_ALG_HASH[TPM_ALG.SHA512] = "sha512";
35
+ // TPM_ECC_CURVE -> COSE crv (the codepoints differ; this is a mapping, not equality).
36
+ var TPM_CURVE_TO_COSE = {}; TPM_CURVE_TO_COSE[0x0003] = 1; TPM_CURVE_TO_COSE[0x0004] = 2; TPM_CURVE_TO_COSE[0x0005] = 3;
37
+
38
+ // Unsigned big-endian magnitude compare: strip leading zero octets, then byte-equal. TPM2B
39
+ // buffers and COSE fixed-length coordinates can differ by a leading 0x00.
40
+ function _ucmp(a, b) {
41
+ function strip(x) { var i = 0; while (i < x.length - 1 && x[i] === 0) i++; return x.subarray(i); }
42
+ return Buffer.compare(strip(a), strip(b)) === 0;
43
+ }
44
+ function _uintBytes(n) {
45
+ var hex = n.toString(16); if (hex.length % 2) hex = "0" + hex;
46
+ var b = Buffer.from(hex, "hex"); var i = 0; while (i < b.length - 1 && b[i] === 0) i++; return b.subarray(i);
47
+ }
48
+ // TPMT_SYM_DEF_OBJECT: algorithm UINT16; when not NULL, keyBits+mode follow. An attestation
49
+ // (restricted signing) key is NULL, so the non-NULL arm is defensive.
50
+ function _symDef(r) { if (r.u16() !== TPM_ALG.NULL) { r.u16(); r.u16(); } }
51
+ // TPMT_*_SCHEME / TPMT_KDF_SCHEME: scheme UINT16; when not NULL, a TPMS_SCHEME_HASH (a single
52
+ // UINT16 hashAlg) follows for the signing/kdf schemes in scope.
53
+ function _scheme(r) { if (r.u16() !== TPM_ALG.NULL) { r.u16(); } }
54
+
55
+ // parsePubArea(buf, E, code) -> the decoded TPMT_PUBLIC (WebAuthn 8.3 item 17-20).
56
+ // @enforced-by behavioral -- a packed TPM structure decode has no rename-proof code shape;
57
+ // the RED vectors (trailing bytes, an unsupported type, a truncated TPM2B) are the guard.
58
+ function parsePubArea(buf, E, code) {
59
+ var r = new ByteReader(buf, 0, buf.length, E, code);
60
+ var type = r.u16(), nameAlg = r.u16();
61
+ r.u32(); // objectAttributes (full policy validation deferred)
62
+ r.vector(2, 0, null); // authPolicy TPM2B_DIGEST
63
+ var pub = { type: type, nameAlg: nameAlg, nameAlgBytes: buf.subarray(2, 4) };
64
+ if (type === TPM_ALG.RSA) {
65
+ _symDef(r); // symmetric TPMT_SYM_DEF_OBJECT
66
+ _scheme(r); // scheme TPMT_RSA_SCHEME
67
+ r.u16(); // keyBits
68
+ var exp = r.u32(); // exponent (0 => default 65537)
69
+ pub.exponent = exp === 0 ? 65537 : exp;
70
+ pub.rsa = r.vector(2, 0, null); // unique TPM2B_PUBLIC_KEY_RSA (the modulus)
71
+ } else if (type === TPM_ALG.ECC) {
72
+ _symDef(r); // symmetric
73
+ _scheme(r); // scheme TPMT_ECC_SCHEME
74
+ pub.curveId = r.u16(); // curveID TPMI_ECC_CURVE
75
+ _scheme(r); // kdf TPMT_KDF_SCHEME
76
+ pub.x = r.vector(2, 0, null); // unique.ecc.x TPM2B_ECC_PARAMETER
77
+ pub.y = r.vector(2, 0, null); // unique.ecc.y TPM2B_ECC_PARAMETER
78
+ } else {
79
+ throw new E(code, "unsupported TPMT_PUBLIC type 0x" + type.toString(16));
80
+ }
81
+ // TPMT_PUBLIC ends with `unique`; trailing bytes mean a malformed pubArea (and would
82
+ // perturb the TPM Name hash), so fail closed rather than silently ignore them.
83
+ if (!r.atEnd()) throw new E(code, "pubArea has trailing bytes after the unique field (WebAuthn 8.3)");
84
+ return pub;
85
+ }
86
+
87
+ // parseCertInfo(buf, E, code) -> the decoded + magic/type-validated TPMS_ATTEST
88
+ // { extraData, attestedName } (WebAuthn 8.3 item 13-15).
89
+ // @enforced-by behavioral -- a packed TPM structure decode has no rename-proof code shape;
90
+ // the RED vectors (wrong magic, wrong type, trailing bytes) are the guard.
91
+ function parseCertInfo(buf, E, code) {
92
+ var r = new ByteReader(buf, 0, buf.length, E, code);
93
+ var magic = r.u32(), type = r.u16();
94
+ if (magic !== TPM_GENERATED_VALUE) throw new E(code, "certInfo magic is not TPM_GENERATED_VALUE (WebAuthn 8.3)");
95
+ if (type !== TPM_ST_ATTEST_CERTIFY) throw new E(code, "certInfo type is not TPM_ST_ATTEST_CERTIFY (WebAuthn 8.3)");
96
+ r.vector(2, 0, null); // qualifiedSigner TPM2B_NAME
97
+ var extraData = r.vector(2, 0, null); // extraData TPM2B_DATA
98
+ r.u64(); r.u32(); r.u32(); r.u8(); // clockInfo TPMS_CLOCK_INFO (17 bytes)
99
+ r.u64(); // firmwareVersion
100
+ var name = r.vector(2, 0, null); // attested.name TPM2B_NAME (nameAlg||H)
101
+ r.vector(2, 0, null); // attested.qualifiedName TPM2B_NAME
102
+ if (!r.atEnd()) throw new E(code, "certInfo has trailing bytes after the attested structure (WebAuthn 8.3)");
103
+ return { extraData: extraData, attestedName: name };
104
+ }
105
+
106
+ // pubKeyEqualsCose(pub, cose, E, mismatchCode, code) -- the pubArea public key MUST equal the
107
+ // credential COSE key (WebAuthn 8.3 item 22).
108
+ // @enforced-by behavioral -- a decoded-key equality check has no rename-proof code shape; the
109
+ // RED vectors (a mismatched EC curve/coordinate, a mismatched RSA modulus/exponent) are the guard.
110
+ function pubKeyEqualsCose(pub, cose, E, mismatchCode, code) {
111
+ if (pub.type === TPM_ALG.ECC) {
112
+ if (cose.kty !== 2 || TPM_CURVE_TO_COSE[pub.curveId] !== cose.crv || !_ucmp(pub.x, cose.x || Buffer.alloc(0)) || !_ucmp(pub.y, cose.y || Buffer.alloc(0))) {
113
+ throw new E(mismatchCode, "the TPM pubArea EC key does not equal the credential public key");
114
+ }
115
+ return;
116
+ }
117
+ if (pub.type === TPM_ALG.RSA) {
118
+ // Compare the exponent as an unsigned integer over its FULL width (a UINT32 up to
119
+ // 0xFFFFFFFF); a fixed 3-byte re-encode would silently truncate an exponent > 0xFFFFFF
120
+ // and let a mismatched key pass (WebAuthn 8.3 item 22).
121
+ var e = _uintBytes(pub.exponent >>> 0);
122
+ if (cose.kty !== 3 || !_ucmp(pub.rsa, cose.n || Buffer.alloc(0)) || !_ucmp(e, cose.e || Buffer.alloc(0))) {
123
+ throw new E(mismatchCode, "the TPM pubArea RSA key does not equal the credential public key");
124
+ }
125
+ return;
126
+ }
127
+ throw new E(code, "unsupported TPM pubArea key type");
128
+ }
129
+
130
+ module.exports = {
131
+ parsePubArea: parsePubArea,
132
+ parseCertInfo: parseCertInfo,
133
+ pubKeyEqualsCose: pubKeyEqualsCose,
134
+ TPM_ALG_HASH: TPM_ALG_HASH,
135
+ };