@forestrie/receipt-verify 0.10.1 → 0.12.0
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/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/resolve-delegated-verify-key.d.ts +6 -3
- package/dist/resolve-delegated-verify-key.d.ts.map +1 -1
- package/dist/resolve-delegated-verify-key.js +6 -3
- package/dist/session-key-endorsement.d.ts +103 -0
- package/dist/session-key-endorsement.d.ts.map +1 -0
- package/dist/session-key-endorsement.js +188 -0
- package/package.json +4 -4
- package/src/index.ts +17 -0
- package/src/resolve-delegated-verify-key.ts +6 -3
- package/src/session-key-endorsement.ts +281 -0
package/dist/index.d.ts
CHANGED
|
@@ -28,6 +28,13 @@ export { checkDelegationConstraints, idtimestampToUnixSeconds, resolveDelegatedV
|
|
|
28
28
|
export type { DelegatedResolution, DelegationConstraints, } from "./resolve-delegated-verify-key.js";
|
|
29
29
|
/** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
|
|
30
30
|
export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
|
|
31
|
+
/**
|
|
32
|
+
* Passkey session-key endorsement (ADR-0064): build, assemble, and verify
|
|
33
|
+
* the one-gesture artifact chaining a passkey log root to the plain-ES256
|
|
34
|
+
* per-turn session key — the offline rung root → endorsement → leaves.
|
|
35
|
+
*/
|
|
36
|
+
export { assembleSessionKeyEndorsement, buildSessionKeyEndorsementTbs, SESSION_KEY_ENDORSEMENT_CONTENT_TYPE, SESSION_KEY_PAYLOAD_KEY, verifySessionKeyEndorsement, } from "./session-key-endorsement.js";
|
|
37
|
+
export type { SessionKeyEndorsementTbs, SessionKeyEndorsementVerifyResult, VerifySessionKeyEndorsementOptions, } from "./session-key-endorsement.js";
|
|
31
38
|
export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
|
|
32
39
|
export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
|
|
33
40
|
/** Deterministic receipt construction (plan-2607-12 Phase 2, FOR-353). */
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,mBAAmB,EACnB,kBAAkB,GACnB,MAAM,4BAA4B,CAAC;AACpC,oFAAoF;AACpF,YAAY,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AACjD,YAAY,EAAE,8BAA8B,EAAE,MAAM,mCAAmC,CAAC;AACxF,YAAY,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AACnF,OAAO,EAAE,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAClD,YAAY,EACV,wBAAwB,EACxB,uBAAuB,EACvB,eAAe,EACf,gBAAgB,GACjB,MAAM,4BAA4B,CAAC;AACpC,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,sBAAsB,EACtB,mBAAmB,EACnB,eAAe,GAChB,MAAM,4BAA4B,CAAC;AACpC,+EAA+E;AAC/E,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,YAAY,EACV,mBAAmB,EACnB,oBAAoB,GACrB,MAAM,sBAAsB,CAAC;AAC9B;;;GAGG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AACtD,OAAO,EAAE,0BAA0B,EAAE,MAAM,qCAAqC,CAAC;AACjF,OAAO,EAAE,yBAAyB,EAAE,MAAM,mCAAmC,CAAC;AAC9E,OAAO,EAAE,oBAAoB,EAAE,MAAM,mCAAmC,CAAC;AACzE;;;;GAIG;AACH,OAAO,EACL,iCAAiC,EACjC,4BAA4B,GAC7B,MAAM,mCAAmC,CAAC;AAC3C,YAAY,EACV,sCAAsC,EACtC,iCAAiC,GAClC,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EACL,0BAA0B,EAC1B,wBAAwB,EACxB,yBAAyB,GAC1B,MAAM,mCAAmC,CAAC;AAC3C,YAAY,EACV,mBAAmB,EACnB,qBAAqB,GACtB,MAAM,mCAAmC,CAAC;AAC3C,yEAAyE;AACzE,OAAO,EAAE,qCAAqC,EAAE,MAAM,6BAA6B,CAAC;AACpF,OAAO,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAC5E,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAE3E,0EAA0E;AAC1E,OAAO,EACL,cAAc,EACd,iDAAiD,GAClD,MAAM,2CAA2C,CAAC;AACnD,OAAO,EAAE,gBAAgB,EAAE,0BAA0B,EAAE,MAAM,eAAe,CAAC;AAC7E,YAAY,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD,6EAA6E;AAC7E,OAAO,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AACrE,oEAAoE;AACpE,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAC7D,YAAY,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AACxD,8EAA8E;AAC9E,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,EACL,kBAAkB,EAClB,0BAA0B,EAC1B,4BAA4B,EAC5B,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,GAChC,MAAM,uBAAuB,CAAC;AAC/B;;;;;GAKG;AACH,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD;;;;;;GAMG;AACH,OAAO,EACL,qBAAqB,EACrB,sBAAsB,EACtB,sBAAsB,EACtB,2CAA2C,EAC3C,KAAK,gBAAgB,GACtB,MAAM,wBAAwB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,12 @@ export { verifyGrantReceiptOfflineWithKeys, verifyReceiptOfflineWithKeys, } from
|
|
|
19
19
|
export { checkDelegationConstraints, idtimestampToUnixSeconds, resolveDelegatedVerifyKey, } from "./resolve-delegated-verify-key.js";
|
|
20
20
|
/** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
|
|
21
21
|
export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
|
|
22
|
+
/**
|
|
23
|
+
* Passkey session-key endorsement (ADR-0064): build, assemble, and verify
|
|
24
|
+
* the one-gesture artifact chaining a passkey log root to the plain-ES256
|
|
25
|
+
* per-turn session key — the offline rung root → endorsement → leaves.
|
|
26
|
+
*/
|
|
27
|
+
export { assembleSessionKeyEndorsement, buildSessionKeyEndorsementTbs, SESSION_KEY_ENDORSEMENT_CONTENT_TYPE, SESSION_KEY_PAYLOAD_KEY, verifySessionKeyEndorsement, } from "./session-key-endorsement.js";
|
|
22
28
|
export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
|
|
23
29
|
export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
|
|
24
30
|
/** Deterministic receipt construction (plan-2607-12 Phase 2, FOR-353). */
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* trust root and returns the delegated public key as an additional verify
|
|
7
7
|
* candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* P-256 roots only: the genesis alg is ES256 and sealer delegate keys are
|
|
10
|
+
* always secp256r1; KS256-rooted delegation is a server-only concern. A
|
|
11
|
+
* passkey (WebAuthn) root signs the certificate with alg -65800 and the
|
|
12
|
+
* ADR-0063 unprotected-header envelope — `verifyCoseSign1WithParsedKey`
|
|
13
|
+
* verifies that envelope against the same P-256 root keys, so the chain walk
|
|
14
|
+
* here is alg-agnostic (plan-2608-13 1.4).
|
|
12
15
|
*
|
|
13
16
|
* Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
|
|
14
17
|
* — a caller-known log OWNER key works identically (see
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"resolve-delegated-verify-key.d.ts","sourceRoot":"","sources":["../src/resolve-delegated-verify-key.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"resolve-delegated-verify-key.d.ts","sourceRoot":"","sources":["../src/resolve-delegated-verify-key.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAqCH,wBAAgB,wBAAwB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAKpE;AAED;;;;GAIG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF;;;;;;;GAOG;AACH,MAAM,MAAM,mBAAmB,GAC3B;IAAE,IAAI,EAAE,WAAW,CAAA;CAAE,GACrB;IACE,IAAI,EAAE,UAAU,CAAC;IACjB,YAAY,EAAE,SAAS,CAAC;IACxB,WAAW,EAAE,qBAAqB,GAAG,IAAI,CAAC;CAC3C,GACD;IAAE,IAAI,EAAE,QAAQ,CAAA;CAAE,CAAC;AAsGvB;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,0BAA0B,CACxC,WAAW,EAAE,qBAAqB,EAClC,YAAY,EAAE,MAAM,EACpB,eAAe,EAAE,MAAM,GACtB;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAS9C;AAED;;;;;GAKG;AACH,wBAAsB,yBAAyB,CAC7C,WAAW,EAAE,UAAU,EACvB,QAAQ,EAAE,SAAS,EAAE,GACpB,OAAO,CAAC,mBAAmB,CAAC,CAgC9B"}
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* trust root and returns the delegated public key as an additional verify
|
|
7
7
|
* candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* P-256 roots only: the genesis alg is ES256 and sealer delegate keys are
|
|
10
|
+
* always secp256r1; KS256-rooted delegation is a server-only concern. A
|
|
11
|
+
* passkey (WebAuthn) root signs the certificate with alg -65800 and the
|
|
12
|
+
* ADR-0063 unprotected-header envelope — `verifyCoseSign1WithParsedKey`
|
|
13
|
+
* verifies that envelope against the same P-256 root keys, so the chain walk
|
|
14
|
+
* here is alg-agnostic (plan-2608-13 1.4).
|
|
12
15
|
*
|
|
13
16
|
* Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
|
|
14
17
|
* — a caller-known log OWNER key works identically (see
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey session-key endorsement (devdocs ADR-0064, plan-2608-13 Q5).
|
|
3
|
+
*
|
|
4
|
+
* A passkey log root signs ceremonies only; the per-turn user-envelope
|
|
5
|
+
* signer is a separate session key, endorsed ONCE by the root via a COSE
|
|
6
|
+
* Sign1 in the ADR-0063 WebAuthn envelope. This module owns both sides of
|
|
7
|
+
* that artifact's byte-exact shape:
|
|
8
|
+
*
|
|
9
|
+
* - build/assemble: the client constructs the TBS, hashes its Sig_structure
|
|
10
|
+
* into the WebAuthn challenge, and attaches the assertion + signature;
|
|
11
|
+
* - verify: given the passkey root as trust anchor, validate the artifact
|
|
12
|
+
* (typed, challenge-bound, fail-closed) and yield the session public key.
|
|
13
|
+
*
|
|
14
|
+
* The offline trust chain is then:
|
|
15
|
+
* grant (root = passkey x‖y) → endorsement → session key → plain-ES256
|
|
16
|
+
* per-turn leaves — zero per-leaf format change (ADR-0064 §4).
|
|
17
|
+
*
|
|
18
|
+
* Domain separation (ADR-0064 §2): the protected header carries the content
|
|
19
|
+
* type below, and the payload is EXACTLY `{"sessionKey": bstr .size 64}`.
|
|
20
|
+
* Anything else is a verification failure, never a fallback.
|
|
21
|
+
*/
|
|
22
|
+
import { type ParsedVerifyKey } from "@forestrie/encoding";
|
|
23
|
+
/**
|
|
24
|
+
* Protected-header content type (label 3) naming the endorsement artifact.
|
|
25
|
+
* Signed via Sig_structure, so the artifact's type is non-malleable.
|
|
26
|
+
*/
|
|
27
|
+
export declare const SESSION_KEY_ENDORSEMENT_CONTENT_TYPE = "application/vnd.forestrie.session-key-endorsement+cbor";
|
|
28
|
+
/** Payload map key holding the endorsed session public key (x‖y, 64 bytes). */
|
|
29
|
+
export declare const SESSION_KEY_PAYLOAD_KEY = "sessionKey";
|
|
30
|
+
/** To-be-signed endorsement halves plus the challenge preimage. */
|
|
31
|
+
export interface SessionKeyEndorsementTbs {
|
|
32
|
+
/** Protected header map bytes `{1: -65800, 3: cty, 4: root x}`. */
|
|
33
|
+
protectedBstr: Uint8Array;
|
|
34
|
+
/** Payload bytes: deterministic CBOR `{"sessionKey": x‖y}`. */
|
|
35
|
+
payloadBstr: Uint8Array;
|
|
36
|
+
/**
|
|
37
|
+
* `Sig_structure` over the halves. The WebAuthn challenge MUST be
|
|
38
|
+
* `base64url(sha256(sigStructureBytes))` (ADR-0063 §3).
|
|
39
|
+
*/
|
|
40
|
+
sigStructureBytes: Uint8Array;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Build the endorsement TBS. The caller hashes `sigStructureBytes` into the
|
|
44
|
+
* `navigator.credentials.get` challenge, then assembles with the assertion.
|
|
45
|
+
*
|
|
46
|
+
* @param input.rootPublicKeyX - Passkey root x coordinate (kid, 32 bytes)
|
|
47
|
+
* @param input.sessionPublicKeyXY - Endorsed session public key x‖y (64 bytes)
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildSessionKeyEndorsementTbs(input: {
|
|
50
|
+
rootPublicKeyX: Uint8Array;
|
|
51
|
+
sessionPublicKeyXY: Uint8Array;
|
|
52
|
+
}): SessionKeyEndorsementTbs;
|
|
53
|
+
/**
|
|
54
|
+
* Assemble the final endorsement COSE Sign1: the assertion rides in the
|
|
55
|
+
* unprotected header at label -65800 (ADR-0063 §2), the signature is the
|
|
56
|
+
* assertion's, converted to low-s P1363 r‖s by the caller (see
|
|
57
|
+
* delegation-cose `derSignatureToP1363` / low-s normalisation).
|
|
58
|
+
*/
|
|
59
|
+
export declare function assembleSessionKeyEndorsement(input: {
|
|
60
|
+
tbs: SessionKeyEndorsementTbs;
|
|
61
|
+
authenticatorData: Uint8Array;
|
|
62
|
+
clientDataJSON: Uint8Array;
|
|
63
|
+
/** Low-s P1363 r‖s (64 bytes). */
|
|
64
|
+
signature: Uint8Array;
|
|
65
|
+
}): Uint8Array;
|
|
66
|
+
/** Verification outcome; failures name the first check that broke. */
|
|
67
|
+
export type SessionKeyEndorsementVerifyResult = {
|
|
68
|
+
ok: true;
|
|
69
|
+
/** Endorsed session public key, raw x‖y (64 bytes). */
|
|
70
|
+
sessionPublicKeyXY: Uint8Array;
|
|
71
|
+
/** The same key imported for ES256 verify of per-turn leaves. */
|
|
72
|
+
sessionKey: CryptoKey;
|
|
73
|
+
} | {
|
|
74
|
+
ok: false;
|
|
75
|
+
reason: "endorsement_malformed" | "wrong_alg" | "wrong_content_type" | "kid_invalid" | "kid_mismatch" | "payload_invalid" | "signature_invalid" | "session_key_import_failed";
|
|
76
|
+
};
|
|
77
|
+
/** Options for {@link verifySessionKeyEndorsement}. */
|
|
78
|
+
export interface VerifySessionKeyEndorsementOptions {
|
|
79
|
+
/**
|
|
80
|
+
* Require the assertion's UV flag. Onboarding UV is DEPLOYMENT config,
|
|
81
|
+
* not grant policy — the endorsement precedes the grant (ADR-0064 §3).
|
|
82
|
+
* User presence is always required regardless.
|
|
83
|
+
*/
|
|
84
|
+
requireUserVerification?: boolean;
|
|
85
|
+
/** Emit JSON warning lines on failure paths (no secrets). */
|
|
86
|
+
logFailures?: boolean;
|
|
87
|
+
/** Included in JSON log lines. */
|
|
88
|
+
logPrefix?: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Verify a session-key endorsement under the passkey root and yield the
|
|
92
|
+
* endorsed session key. All the ADR-0063 envelope checks (challenge binding
|
|
93
|
+
* to this artifact's `Sig_structure`, UP flag, ceremony type, low-s, both
|
|
94
|
+
* fail-closed directions) run inside the shared `-65800` verify branch;
|
|
95
|
+
* this function adds the ADR-0064 typing rules on top.
|
|
96
|
+
*
|
|
97
|
+
* @param endorsementCbor - The endorsement COSE Sign1 bytes
|
|
98
|
+
* @param rootKeys - Passkey root trust anchor(s), tried in order. Raw
|
|
99
|
+
* coordinate anchors additionally pin `kid == root x`.
|
|
100
|
+
* @param opts - UV requirement (deployment config) and failure logging
|
|
101
|
+
*/
|
|
102
|
+
export declare function verifySessionKeyEndorsement(endorsementCbor: Uint8Array, rootKeys: ParsedVerifyKey | ParsedVerifyKey[], opts?: VerifySessionKeyEndorsementOptions): Promise<SessionKeyEndorsementVerifyResult>;
|
|
103
|
+
//# sourceMappingURL=session-key-endorsement.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-key-endorsement.d.ts","sourceRoot":"","sources":["../src/session-key-endorsement.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,EASL,KAAK,eAAe,EACrB,MAAM,qBAAqB,CAAC;AAG7B;;;GAGG;AACH,eAAO,MAAM,oCAAoC,2DACS,CAAC;AAE3D,+EAA+E;AAC/E,eAAO,MAAM,uBAAuB,eAAe,CAAC;AAEpD,mEAAmE;AACnE,MAAM,WAAW,wBAAwB;IACvC,mEAAmE;IACnE,aAAa,EAAE,UAAU,CAAC;IAC1B,+DAA+D;IAC/D,WAAW,EAAE,UAAU,CAAC;IACxB;;;OAGG;IACH,iBAAiB,EAAE,UAAU,CAAC;CAC/B;AAED;;;;;;GAMG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE;IACnD,cAAc,EAAE,UAAU,CAAC;IAC3B,kBAAkB,EAAE,UAAU,CAAC;CAChC,GAAG,wBAAwB,CAyB3B;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,EAAE;IACnD,GAAG,EAAE,wBAAwB,CAAC;IAC9B,iBAAiB,EAAE,UAAU,CAAC;IAC9B,cAAc,EAAE,UAAU,CAAC;IAC3B,kCAAkC;IAClC,SAAS,EAAE,UAAU,CAAC;CACvB,GAAG,UAAU,CAab;AAED,sEAAsE;AACtE,MAAM,MAAM,iCAAiC,GACzC;IACE,EAAE,EAAE,IAAI,CAAC;IACT,uDAAuD;IACvD,kBAAkB,EAAE,UAAU,CAAC;IAC/B,iEAAiE;IACjE,UAAU,EAAE,SAAS,CAAC;CACvB,GACD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EACF,uBAAuB,GACvB,WAAW,GACX,oBAAoB,GACpB,aAAa,GACb,cAAc,GACd,iBAAiB,GACjB,mBAAmB,GACnB,2BAA2B,CAAC;CACjC,CAAC;AAEN,uDAAuD;AACvD,MAAM,WAAW,kCAAkC;IACjD;;;;OAIG;IACH,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,6DAA6D;IAC7D,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,kCAAkC;IAClC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAiDD;;;;;;;;;;;GAWG;AACH,wBAAsB,2BAA2B,CAC/C,eAAe,EAAE,UAAU,EAC3B,QAAQ,EAAE,eAAe,GAAG,eAAe,EAAE,EAC7C,IAAI,CAAC,EAAE,kCAAkC,GACxC,OAAO,CAAC,iCAAiC,CAAC,CA2D5C"}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey session-key endorsement (devdocs ADR-0064, plan-2608-13 Q5).
|
|
3
|
+
*
|
|
4
|
+
* A passkey log root signs ceremonies only; the per-turn user-envelope
|
|
5
|
+
* signer is a separate session key, endorsed ONCE by the root via a COSE
|
|
6
|
+
* Sign1 in the ADR-0063 WebAuthn envelope. This module owns both sides of
|
|
7
|
+
* that artifact's byte-exact shape:
|
|
8
|
+
*
|
|
9
|
+
* - build/assemble: the client constructs the TBS, hashes its Sig_structure
|
|
10
|
+
* into the WebAuthn challenge, and attaches the assertion + signature;
|
|
11
|
+
* - verify: given the passkey root as trust anchor, validate the artifact
|
|
12
|
+
* (typed, challenge-bound, fail-closed) and yield the session public key.
|
|
13
|
+
*
|
|
14
|
+
* The offline trust chain is then:
|
|
15
|
+
* grant (root = passkey x‖y) → endorsement → session key → plain-ES256
|
|
16
|
+
* per-turn leaves — zero per-leaf format change (ADR-0064 §4).
|
|
17
|
+
*
|
|
18
|
+
* Domain separation (ADR-0064 §2): the protected header carries the content
|
|
19
|
+
* type below, and the payload is EXACTLY `{"sessionKey": bstr .size 64}`.
|
|
20
|
+
* Anything else is a verification failure, never a fallback.
|
|
21
|
+
*/
|
|
22
|
+
import { COSE_ALG_ES256_WEBAUTHN, decodeCborDeterministic, decodeCoseSign1, encodeCborDeterministic, encodeCoseSign1Raw, encodeSigStructure, verifyCoseSign1WithParsedKey, WEBAUTHN_ENVELOPE_LABEL, } from "@forestrie/encoding";
|
|
23
|
+
import { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
|
|
24
|
+
/**
|
|
25
|
+
* Protected-header content type (label 3) naming the endorsement artifact.
|
|
26
|
+
* Signed via Sig_structure, so the artifact's type is non-malleable.
|
|
27
|
+
*/
|
|
28
|
+
export const SESSION_KEY_ENDORSEMENT_CONTENT_TYPE = "application/vnd.forestrie.session-key-endorsement+cbor";
|
|
29
|
+
/** Payload map key holding the endorsed session public key (x‖y, 64 bytes). */
|
|
30
|
+
export const SESSION_KEY_PAYLOAD_KEY = "sessionKey";
|
|
31
|
+
/**
|
|
32
|
+
* Build the endorsement TBS. The caller hashes `sigStructureBytes` into the
|
|
33
|
+
* `navigator.credentials.get` challenge, then assembles with the assertion.
|
|
34
|
+
*
|
|
35
|
+
* @param input.rootPublicKeyX - Passkey root x coordinate (kid, 32 bytes)
|
|
36
|
+
* @param input.sessionPublicKeyXY - Endorsed session public key x‖y (64 bytes)
|
|
37
|
+
*/
|
|
38
|
+
export function buildSessionKeyEndorsementTbs(input) {
|
|
39
|
+
if (input.rootPublicKeyX.length !== 32) {
|
|
40
|
+
throw new Error("endorsement kid must be the 32-byte root x coordinate");
|
|
41
|
+
}
|
|
42
|
+
if (input.sessionPublicKeyXY.length !== 64) {
|
|
43
|
+
throw new Error("endorsed session key must be 64 bytes (x||y)");
|
|
44
|
+
}
|
|
45
|
+
const protectedBstr = encodeCborDeterministic(new Map([
|
|
46
|
+
[1, COSE_ALG_ES256_WEBAUTHN],
|
|
47
|
+
[3, SESSION_KEY_ENDORSEMENT_CONTENT_TYPE],
|
|
48
|
+
[4, input.rootPublicKeyX],
|
|
49
|
+
]));
|
|
50
|
+
const payloadBstr = encodeCborDeterministic(new Map([
|
|
51
|
+
[SESSION_KEY_PAYLOAD_KEY, input.sessionPublicKeyXY],
|
|
52
|
+
]));
|
|
53
|
+
const sigStructureBytes = encodeSigStructure(protectedBstr, new Uint8Array(0), payloadBstr);
|
|
54
|
+
return { protectedBstr, payloadBstr, sigStructureBytes };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Assemble the final endorsement COSE Sign1: the assertion rides in the
|
|
58
|
+
* unprotected header at label -65800 (ADR-0063 §2), the signature is the
|
|
59
|
+
* assertion's, converted to low-s P1363 r‖s by the caller (see
|
|
60
|
+
* delegation-cose `derSignatureToP1363` / low-s normalisation).
|
|
61
|
+
*/
|
|
62
|
+
export function assembleSessionKeyEndorsement(input) {
|
|
63
|
+
if (input.signature.length !== 64) {
|
|
64
|
+
throw new Error("endorsement signature must be 64-byte P1363 r||s");
|
|
65
|
+
}
|
|
66
|
+
const unprotected = new Map([
|
|
67
|
+
[WEBAUTHN_ENVELOPE_LABEL, [input.authenticatorData, input.clientDataJSON]],
|
|
68
|
+
]);
|
|
69
|
+
return encodeCoseSign1Raw(input.tbs.protectedBstr, unprotected, input.tbs.payloadBstr, input.signature);
|
|
70
|
+
}
|
|
71
|
+
function protectedHeaderEntries(protectedBstr) {
|
|
72
|
+
let decoded;
|
|
73
|
+
try {
|
|
74
|
+
decoded = decodeCborDeterministic(protectedBstr);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
if (decoded instanceof Map) {
|
|
80
|
+
return { alg: decoded.get(1), cty: decoded.get(3), kid: decoded.get(4) };
|
|
81
|
+
}
|
|
82
|
+
if (typeof decoded === "object" && decoded !== null) {
|
|
83
|
+
const obj = decoded;
|
|
84
|
+
return {
|
|
85
|
+
alg: obj[1] ?? obj["1"],
|
|
86
|
+
cty: obj[3] ?? obj["3"],
|
|
87
|
+
kid: obj[4] ?? obj["4"],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function decodePayloadSessionKeyXy(payloadBstr) {
|
|
93
|
+
let decoded;
|
|
94
|
+
try {
|
|
95
|
+
decoded = decodeCborDeterministic(payloadBstr);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
// Exactly one entry, exactly the sessionKey label, exactly 64 bytes
|
|
101
|
+
// (ADR-0064 §2) — anything else is a failure, never a partial read.
|
|
102
|
+
let entries;
|
|
103
|
+
if (decoded instanceof Map) {
|
|
104
|
+
entries = [...decoded.entries()];
|
|
105
|
+
}
|
|
106
|
+
else if (typeof decoded === "object" && decoded !== null) {
|
|
107
|
+
entries = Object.entries(decoded);
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (entries.length !== 1)
|
|
113
|
+
return null;
|
|
114
|
+
const [key, value] = entries[0];
|
|
115
|
+
if (key !== SESSION_KEY_PAYLOAD_KEY)
|
|
116
|
+
return null;
|
|
117
|
+
if (!(value instanceof Uint8Array) || value.length !== 64)
|
|
118
|
+
return null;
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Verify a session-key endorsement under the passkey root and yield the
|
|
123
|
+
* endorsed session key. All the ADR-0063 envelope checks (challenge binding
|
|
124
|
+
* to this artifact's `Sig_structure`, UP flag, ceremony type, low-s, both
|
|
125
|
+
* fail-closed directions) run inside the shared `-65800` verify branch;
|
|
126
|
+
* this function adds the ADR-0064 typing rules on top.
|
|
127
|
+
*
|
|
128
|
+
* @param endorsementCbor - The endorsement COSE Sign1 bytes
|
|
129
|
+
* @param rootKeys - Passkey root trust anchor(s), tried in order. Raw
|
|
130
|
+
* coordinate anchors additionally pin `kid == root x`.
|
|
131
|
+
* @param opts - UV requirement (deployment config) and failure logging
|
|
132
|
+
*/
|
|
133
|
+
export async function verifySessionKeyEndorsement(endorsementCbor, rootKeys, opts) {
|
|
134
|
+
const anchors = Array.isArray(rootKeys) ? rootKeys : [rootKeys];
|
|
135
|
+
const decoded = decodeCoseSign1(endorsementCbor);
|
|
136
|
+
if (!decoded)
|
|
137
|
+
return { ok: false, reason: "endorsement_malformed" };
|
|
138
|
+
const header = protectedHeaderEntries(decoded.protectedBstr);
|
|
139
|
+
if (!header)
|
|
140
|
+
return { ok: false, reason: "endorsement_malformed" };
|
|
141
|
+
const alg = typeof header.alg === "bigint" ? Number(header.alg) : header.alg;
|
|
142
|
+
if (alg !== COSE_ALG_ES256_WEBAUTHN) {
|
|
143
|
+
return { ok: false, reason: "wrong_alg" };
|
|
144
|
+
}
|
|
145
|
+
if (header.cty !== SESSION_KEY_ENDORSEMENT_CONTENT_TYPE) {
|
|
146
|
+
return { ok: false, reason: "wrong_content_type" };
|
|
147
|
+
}
|
|
148
|
+
if (!(header.kid instanceof Uint8Array) || header.kid.length !== 32) {
|
|
149
|
+
return { ok: false, reason: "kid_invalid" };
|
|
150
|
+
}
|
|
151
|
+
const sessionPublicKeyXY = decodePayloadSessionKeyXy(decoded.payloadBstr);
|
|
152
|
+
if (!sessionPublicKeyXY)
|
|
153
|
+
return { ok: false, reason: "payload_invalid" };
|
|
154
|
+
let kidMismatch = false;
|
|
155
|
+
for (const anchor of anchors) {
|
|
156
|
+
// When the anchor exposes raw coordinates, the signed kid must name it —
|
|
157
|
+
// a valid signature under a root whose x is not the kid is evidence of
|
|
158
|
+
// artifact confusion, not authorization (ADR-0064 §2 kid = root x).
|
|
159
|
+
if (!(anchor instanceof CryptoKey)) {
|
|
160
|
+
const x = anchor.x;
|
|
161
|
+
if (x.length !== header.kid.length ||
|
|
162
|
+
!x.every((b, i) => b === header.kid[i])) {
|
|
163
|
+
kidMismatch = true;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const sigOk = await verifyCoseSign1WithParsedKey(endorsementCbor, anchor, {
|
|
168
|
+
requireUserVerification: opts?.requireUserVerification,
|
|
169
|
+
logFailures: opts?.logFailures,
|
|
170
|
+
logPrefix: opts?.logPrefix ?? "session-key-endorsement",
|
|
171
|
+
});
|
|
172
|
+
if (!sigOk)
|
|
173
|
+
continue;
|
|
174
|
+
let sessionKey;
|
|
175
|
+
try {
|
|
176
|
+
sessionKey =
|
|
177
|
+
await importEs256PublicKeyFromGrantDataXy64(sessionPublicKeyXY);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return { ok: false, reason: "session_key_import_failed" };
|
|
181
|
+
}
|
|
182
|
+
return { ok: true, sessionPublicKeyXY, sessionKey };
|
|
183
|
+
}
|
|
184
|
+
return {
|
|
185
|
+
ok: false,
|
|
186
|
+
reason: kidMismatch ? "kid_mismatch" : "signature_invalid",
|
|
187
|
+
};
|
|
188
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forestrie/receipt-verify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Offline SCITT grant receipt verification (ADR-0045)",
|
|
@@ -28,9 +28,9 @@
|
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@noble/hashes": "^1.7.1",
|
|
31
|
-
"@forestrie/chain-rpc": "0.
|
|
32
|
-
"@forestrie/
|
|
33
|
-
"@forestrie/
|
|
31
|
+
"@forestrie/chain-rpc": "0.2.0",
|
|
32
|
+
"@forestrie/encoding": "0.6.0",
|
|
33
|
+
"@forestrie/merklelog": "0.3.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"esbuild": "^0.24.0",
|
package/src/index.ts
CHANGED
|
@@ -58,6 +58,23 @@ export type {
|
|
|
58
58
|
} from "./resolve-delegated-verify-key.js";
|
|
59
59
|
/** Import a raw 64-byte x||y P-256 public key as an ES256 verify key. */
|
|
60
60
|
export { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
|
|
61
|
+
/**
|
|
62
|
+
* Passkey session-key endorsement (ADR-0064): build, assemble, and verify
|
|
63
|
+
* the one-gesture artifact chaining a passkey log root to the plain-ES256
|
|
64
|
+
* per-turn session key — the offline rung root → endorsement → leaves.
|
|
65
|
+
*/
|
|
66
|
+
export {
|
|
67
|
+
assembleSessionKeyEndorsement,
|
|
68
|
+
buildSessionKeyEndorsementTbs,
|
|
69
|
+
SESSION_KEY_ENDORSEMENT_CONTENT_TYPE,
|
|
70
|
+
SESSION_KEY_PAYLOAD_KEY,
|
|
71
|
+
verifySessionKeyEndorsement,
|
|
72
|
+
} from "./session-key-endorsement.js";
|
|
73
|
+
export type {
|
|
74
|
+
SessionKeyEndorsementTbs,
|
|
75
|
+
SessionKeyEndorsementVerifyResult,
|
|
76
|
+
VerifySessionKeyEndorsementOptions,
|
|
77
|
+
} from "./session-key-endorsement.js";
|
|
61
78
|
export { decodeForestrieGrantCose } from "./decode-forestrie-grant-cose.js";
|
|
62
79
|
export { decodeGrantPayload, decodeGrantResponse } from "./grant-codec.js";
|
|
63
80
|
|
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* trust root and returns the delegated public key as an additional verify
|
|
7
7
|
* candidate — the offline port of canopy-api's `resolveReceiptVerifyKey`.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* P-256 roots only: the genesis alg is ES256 and sealer delegate keys are
|
|
10
|
+
* always secp256r1; KS256-rooted delegation is a server-only concern. A
|
|
11
|
+
* passkey (WebAuthn) root signs the certificate with alg -65800 and the
|
|
12
|
+
* ADR-0063 unprotected-header envelope — `verifyCoseSign1WithParsedKey`
|
|
13
|
+
* verifies that envelope against the same P-256 root keys, so the chain walk
|
|
14
|
+
* here is alg-agnostic (plan-2608-13 1.4).
|
|
12
15
|
*
|
|
13
16
|
* Trust anchors (FOR-297 trust ladder): `rootKeys` need not be genesis-derived
|
|
14
17
|
* — a caller-known log OWNER key works identically (see
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey session-key endorsement (devdocs ADR-0064, plan-2608-13 Q5).
|
|
3
|
+
*
|
|
4
|
+
* A passkey log root signs ceremonies only; the per-turn user-envelope
|
|
5
|
+
* signer is a separate session key, endorsed ONCE by the root via a COSE
|
|
6
|
+
* Sign1 in the ADR-0063 WebAuthn envelope. This module owns both sides of
|
|
7
|
+
* that artifact's byte-exact shape:
|
|
8
|
+
*
|
|
9
|
+
* - build/assemble: the client constructs the TBS, hashes its Sig_structure
|
|
10
|
+
* into the WebAuthn challenge, and attaches the assertion + signature;
|
|
11
|
+
* - verify: given the passkey root as trust anchor, validate the artifact
|
|
12
|
+
* (typed, challenge-bound, fail-closed) and yield the session public key.
|
|
13
|
+
*
|
|
14
|
+
* The offline trust chain is then:
|
|
15
|
+
* grant (root = passkey x‖y) → endorsement → session key → plain-ES256
|
|
16
|
+
* per-turn leaves — zero per-leaf format change (ADR-0064 §4).
|
|
17
|
+
*
|
|
18
|
+
* Domain separation (ADR-0064 §2): the protected header carries the content
|
|
19
|
+
* type below, and the payload is EXACTLY `{"sessionKey": bstr .size 64}`.
|
|
20
|
+
* Anything else is a verification failure, never a fallback.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
COSE_ALG_ES256_WEBAUTHN,
|
|
25
|
+
decodeCborDeterministic,
|
|
26
|
+
decodeCoseSign1,
|
|
27
|
+
encodeCborDeterministic,
|
|
28
|
+
encodeCoseSign1Raw,
|
|
29
|
+
encodeSigStructure,
|
|
30
|
+
verifyCoseSign1WithParsedKey,
|
|
31
|
+
WEBAUTHN_ENVELOPE_LABEL,
|
|
32
|
+
type ParsedVerifyKey,
|
|
33
|
+
} from "@forestrie/encoding";
|
|
34
|
+
import { importEs256PublicKeyFromGrantDataXy64 } from "./decode-trust-root-cbor.js";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Protected-header content type (label 3) naming the endorsement artifact.
|
|
38
|
+
* Signed via Sig_structure, so the artifact's type is non-malleable.
|
|
39
|
+
*/
|
|
40
|
+
export const SESSION_KEY_ENDORSEMENT_CONTENT_TYPE =
|
|
41
|
+
"application/vnd.forestrie.session-key-endorsement+cbor";
|
|
42
|
+
|
|
43
|
+
/** Payload map key holding the endorsed session public key (x‖y, 64 bytes). */
|
|
44
|
+
export const SESSION_KEY_PAYLOAD_KEY = "sessionKey";
|
|
45
|
+
|
|
46
|
+
/** To-be-signed endorsement halves plus the challenge preimage. */
|
|
47
|
+
export interface SessionKeyEndorsementTbs {
|
|
48
|
+
/** Protected header map bytes `{1: -65800, 3: cty, 4: root x}`. */
|
|
49
|
+
protectedBstr: Uint8Array;
|
|
50
|
+
/** Payload bytes: deterministic CBOR `{"sessionKey": x‖y}`. */
|
|
51
|
+
payloadBstr: Uint8Array;
|
|
52
|
+
/**
|
|
53
|
+
* `Sig_structure` over the halves. The WebAuthn challenge MUST be
|
|
54
|
+
* `base64url(sha256(sigStructureBytes))` (ADR-0063 §3).
|
|
55
|
+
*/
|
|
56
|
+
sigStructureBytes: Uint8Array;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build the endorsement TBS. The caller hashes `sigStructureBytes` into the
|
|
61
|
+
* `navigator.credentials.get` challenge, then assembles with the assertion.
|
|
62
|
+
*
|
|
63
|
+
* @param input.rootPublicKeyX - Passkey root x coordinate (kid, 32 bytes)
|
|
64
|
+
* @param input.sessionPublicKeyXY - Endorsed session public key x‖y (64 bytes)
|
|
65
|
+
*/
|
|
66
|
+
export function buildSessionKeyEndorsementTbs(input: {
|
|
67
|
+
rootPublicKeyX: Uint8Array;
|
|
68
|
+
sessionPublicKeyXY: Uint8Array;
|
|
69
|
+
}): SessionKeyEndorsementTbs {
|
|
70
|
+
if (input.rootPublicKeyX.length !== 32) {
|
|
71
|
+
throw new Error("endorsement kid must be the 32-byte root x coordinate");
|
|
72
|
+
}
|
|
73
|
+
if (input.sessionPublicKeyXY.length !== 64) {
|
|
74
|
+
throw new Error("endorsed session key must be 64 bytes (x||y)");
|
|
75
|
+
}
|
|
76
|
+
const protectedBstr = encodeCborDeterministic(
|
|
77
|
+
new Map<number, unknown>([
|
|
78
|
+
[1, COSE_ALG_ES256_WEBAUTHN],
|
|
79
|
+
[3, SESSION_KEY_ENDORSEMENT_CONTENT_TYPE],
|
|
80
|
+
[4, input.rootPublicKeyX],
|
|
81
|
+
]),
|
|
82
|
+
);
|
|
83
|
+
const payloadBstr = encodeCborDeterministic(
|
|
84
|
+
new Map<string, unknown>([
|
|
85
|
+
[SESSION_KEY_PAYLOAD_KEY, input.sessionPublicKeyXY],
|
|
86
|
+
]),
|
|
87
|
+
);
|
|
88
|
+
const sigStructureBytes = encodeSigStructure(
|
|
89
|
+
protectedBstr,
|
|
90
|
+
new Uint8Array(0),
|
|
91
|
+
payloadBstr,
|
|
92
|
+
);
|
|
93
|
+
return { protectedBstr, payloadBstr, sigStructureBytes };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Assemble the final endorsement COSE Sign1: the assertion rides in the
|
|
98
|
+
* unprotected header at label -65800 (ADR-0063 §2), the signature is the
|
|
99
|
+
* assertion's, converted to low-s P1363 r‖s by the caller (see
|
|
100
|
+
* delegation-cose `derSignatureToP1363` / low-s normalisation).
|
|
101
|
+
*/
|
|
102
|
+
export function assembleSessionKeyEndorsement(input: {
|
|
103
|
+
tbs: SessionKeyEndorsementTbs;
|
|
104
|
+
authenticatorData: Uint8Array;
|
|
105
|
+
clientDataJSON: Uint8Array;
|
|
106
|
+
/** Low-s P1363 r‖s (64 bytes). */
|
|
107
|
+
signature: Uint8Array;
|
|
108
|
+
}): Uint8Array {
|
|
109
|
+
if (input.signature.length !== 64) {
|
|
110
|
+
throw new Error("endorsement signature must be 64-byte P1363 r||s");
|
|
111
|
+
}
|
|
112
|
+
const unprotected = new Map<number, unknown>([
|
|
113
|
+
[WEBAUTHN_ENVELOPE_LABEL, [input.authenticatorData, input.clientDataJSON]],
|
|
114
|
+
]);
|
|
115
|
+
return encodeCoseSign1Raw(
|
|
116
|
+
input.tbs.protectedBstr,
|
|
117
|
+
unprotected,
|
|
118
|
+
input.tbs.payloadBstr,
|
|
119
|
+
input.signature,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Verification outcome; failures name the first check that broke. */
|
|
124
|
+
export type SessionKeyEndorsementVerifyResult =
|
|
125
|
+
| {
|
|
126
|
+
ok: true;
|
|
127
|
+
/** Endorsed session public key, raw x‖y (64 bytes). */
|
|
128
|
+
sessionPublicKeyXY: Uint8Array;
|
|
129
|
+
/** The same key imported for ES256 verify of per-turn leaves. */
|
|
130
|
+
sessionKey: CryptoKey;
|
|
131
|
+
}
|
|
132
|
+
| {
|
|
133
|
+
ok: false;
|
|
134
|
+
reason:
|
|
135
|
+
| "endorsement_malformed"
|
|
136
|
+
| "wrong_alg"
|
|
137
|
+
| "wrong_content_type"
|
|
138
|
+
| "kid_invalid"
|
|
139
|
+
| "kid_mismatch"
|
|
140
|
+
| "payload_invalid"
|
|
141
|
+
| "signature_invalid"
|
|
142
|
+
| "session_key_import_failed";
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Options for {@link verifySessionKeyEndorsement}. */
|
|
146
|
+
export interface VerifySessionKeyEndorsementOptions {
|
|
147
|
+
/**
|
|
148
|
+
* Require the assertion's UV flag. Onboarding UV is DEPLOYMENT config,
|
|
149
|
+
* not grant policy — the endorsement precedes the grant (ADR-0064 §3).
|
|
150
|
+
* User presence is always required regardless.
|
|
151
|
+
*/
|
|
152
|
+
requireUserVerification?: boolean;
|
|
153
|
+
/** Emit JSON warning lines on failure paths (no secrets). */
|
|
154
|
+
logFailures?: boolean;
|
|
155
|
+
/** Included in JSON log lines. */
|
|
156
|
+
logPrefix?: string;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function protectedHeaderEntries(
|
|
160
|
+
protectedBstr: Uint8Array,
|
|
161
|
+
): { alg: unknown; cty: unknown; kid: unknown } | null {
|
|
162
|
+
let decoded: unknown;
|
|
163
|
+
try {
|
|
164
|
+
decoded = decodeCborDeterministic(protectedBstr);
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
if (decoded instanceof Map) {
|
|
169
|
+
return { alg: decoded.get(1), cty: decoded.get(3), kid: decoded.get(4) };
|
|
170
|
+
}
|
|
171
|
+
if (typeof decoded === "object" && decoded !== null) {
|
|
172
|
+
const obj = decoded as Record<string | number, unknown>;
|
|
173
|
+
return {
|
|
174
|
+
alg: obj[1] ?? obj["1"],
|
|
175
|
+
cty: obj[3] ?? obj["3"],
|
|
176
|
+
kid: obj[4] ?? obj["4"],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function decodePayloadSessionKeyXy(payloadBstr: Uint8Array): Uint8Array | null {
|
|
183
|
+
let decoded: unknown;
|
|
184
|
+
try {
|
|
185
|
+
decoded = decodeCborDeterministic(payloadBstr);
|
|
186
|
+
} catch {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
// Exactly one entry, exactly the sessionKey label, exactly 64 bytes
|
|
190
|
+
// (ADR-0064 §2) — anything else is a failure, never a partial read.
|
|
191
|
+
let entries: [unknown, unknown][];
|
|
192
|
+
if (decoded instanceof Map) {
|
|
193
|
+
entries = [...decoded.entries()];
|
|
194
|
+
} else if (typeof decoded === "object" && decoded !== null) {
|
|
195
|
+
entries = Object.entries(decoded);
|
|
196
|
+
} else {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (entries.length !== 1) return null;
|
|
200
|
+
const [key, value] = entries[0]!;
|
|
201
|
+
if (key !== SESSION_KEY_PAYLOAD_KEY) return null;
|
|
202
|
+
if (!(value instanceof Uint8Array) || value.length !== 64) return null;
|
|
203
|
+
return value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Verify a session-key endorsement under the passkey root and yield the
|
|
208
|
+
* endorsed session key. All the ADR-0063 envelope checks (challenge binding
|
|
209
|
+
* to this artifact's `Sig_structure`, UP flag, ceremony type, low-s, both
|
|
210
|
+
* fail-closed directions) run inside the shared `-65800` verify branch;
|
|
211
|
+
* this function adds the ADR-0064 typing rules on top.
|
|
212
|
+
*
|
|
213
|
+
* @param endorsementCbor - The endorsement COSE Sign1 bytes
|
|
214
|
+
* @param rootKeys - Passkey root trust anchor(s), tried in order. Raw
|
|
215
|
+
* coordinate anchors additionally pin `kid == root x`.
|
|
216
|
+
* @param opts - UV requirement (deployment config) and failure logging
|
|
217
|
+
*/
|
|
218
|
+
export async function verifySessionKeyEndorsement(
|
|
219
|
+
endorsementCbor: Uint8Array,
|
|
220
|
+
rootKeys: ParsedVerifyKey | ParsedVerifyKey[],
|
|
221
|
+
opts?: VerifySessionKeyEndorsementOptions,
|
|
222
|
+
): Promise<SessionKeyEndorsementVerifyResult> {
|
|
223
|
+
const anchors = Array.isArray(rootKeys) ? rootKeys : [rootKeys];
|
|
224
|
+
|
|
225
|
+
const decoded = decodeCoseSign1(endorsementCbor);
|
|
226
|
+
if (!decoded) return { ok: false, reason: "endorsement_malformed" };
|
|
227
|
+
|
|
228
|
+
const header = protectedHeaderEntries(decoded.protectedBstr);
|
|
229
|
+
if (!header) return { ok: false, reason: "endorsement_malformed" };
|
|
230
|
+
|
|
231
|
+
const alg = typeof header.alg === "bigint" ? Number(header.alg) : header.alg;
|
|
232
|
+
if (alg !== COSE_ALG_ES256_WEBAUTHN) {
|
|
233
|
+
return { ok: false, reason: "wrong_alg" };
|
|
234
|
+
}
|
|
235
|
+
if (header.cty !== SESSION_KEY_ENDORSEMENT_CONTENT_TYPE) {
|
|
236
|
+
return { ok: false, reason: "wrong_content_type" };
|
|
237
|
+
}
|
|
238
|
+
if (!(header.kid instanceof Uint8Array) || header.kid.length !== 32) {
|
|
239
|
+
return { ok: false, reason: "kid_invalid" };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const sessionPublicKeyXY = decodePayloadSessionKeyXy(decoded.payloadBstr);
|
|
243
|
+
if (!sessionPublicKeyXY) return { ok: false, reason: "payload_invalid" };
|
|
244
|
+
|
|
245
|
+
let kidMismatch = false;
|
|
246
|
+
for (const anchor of anchors) {
|
|
247
|
+
// When the anchor exposes raw coordinates, the signed kid must name it —
|
|
248
|
+
// a valid signature under a root whose x is not the kid is evidence of
|
|
249
|
+
// artifact confusion, not authorization (ADR-0064 §2 kid = root x).
|
|
250
|
+
if (!(anchor instanceof CryptoKey)) {
|
|
251
|
+
const x = anchor.x;
|
|
252
|
+
if (
|
|
253
|
+
x.length !== header.kid.length ||
|
|
254
|
+
!x.every((b, i) => b === (header.kid as Uint8Array)[i])
|
|
255
|
+
) {
|
|
256
|
+
kidMismatch = true;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
const sigOk = await verifyCoseSign1WithParsedKey(endorsementCbor, anchor, {
|
|
261
|
+
requireUserVerification: opts?.requireUserVerification,
|
|
262
|
+
logFailures: opts?.logFailures,
|
|
263
|
+
logPrefix: opts?.logPrefix ?? "session-key-endorsement",
|
|
264
|
+
});
|
|
265
|
+
if (!sigOk) continue;
|
|
266
|
+
|
|
267
|
+
let sessionKey: CryptoKey;
|
|
268
|
+
try {
|
|
269
|
+
sessionKey =
|
|
270
|
+
await importEs256PublicKeyFromGrantDataXy64(sessionPublicKeyXY);
|
|
271
|
+
} catch {
|
|
272
|
+
return { ok: false, reason: "session_key_import_failed" };
|
|
273
|
+
}
|
|
274
|
+
return { ok: true, sessionPublicKeyXY, sessionKey };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
reason: kidMismatch ? "kid_mismatch" : "signature_invalid",
|
|
280
|
+
};
|
|
281
|
+
}
|