@openmobilehub/attestomcp-gate 0.1.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.
Files changed (79) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +172 -0
  3. package/dist/ceremony/cartMandate.d.ts +61 -0
  4. package/dist/ceremony/cartMandate.js +76 -0
  5. package/dist/ceremony/challengeToken.d.ts +5 -0
  6. package/dist/ceremony/challengeToken.js +43 -0
  7. package/dist/ceremony/checkout-page.d.ts +85 -0
  8. package/dist/ceremony/checkout-page.js +269 -0
  9. package/dist/ceremony/completion.d.ts +41 -0
  10. package/dist/ceremony/completion.js +90 -0
  11. package/dist/ceremony/credential-gate/dcql.d.ts +10 -0
  12. package/dist/ceremony/credential-gate/dcql.js +12 -0
  13. package/dist/ceremony/credential-gate/doc-spec.d.ts +3 -0
  14. package/dist/ceremony/credential-gate/doc-spec.js +16 -0
  15. package/dist/ceremony/credential-gate/mdoc-verify.d.ts +15 -0
  16. package/dist/ceremony/credential-gate/mdoc-verify.js +29 -0
  17. package/dist/ceremony/credential-gate/page.d.ts +20 -0
  18. package/dist/ceremony/credential-gate/page.js +136 -0
  19. package/dist/ceremony/credential-gate/request.d.ts +15 -0
  20. package/dist/ceremony/credential-gate/request.js +43 -0
  21. package/dist/ceremony/credential-gate/routes.d.ts +2 -0
  22. package/dist/ceremony/credential-gate/routes.js +200 -0
  23. package/dist/ceremony/credential-gate/verify.d.ts +51 -0
  24. package/dist/ceremony/credential-gate/verify.js +146 -0
  25. package/dist/ceremony/dc-payment/dcql.d.ts +5 -0
  26. package/dist/ceremony/dc-payment/dcql.js +23 -0
  27. package/dist/ceremony/dc-payment/page.d.ts +18 -0
  28. package/dist/ceremony/dc-payment/page.js +195 -0
  29. package/dist/ceremony/dc-payment/request.d.ts +17 -0
  30. package/dist/ceremony/dc-payment/request.js +50 -0
  31. package/dist/ceremony/dc-payment/routes.d.ts +2 -0
  32. package/dist/ceremony/dc-payment/routes.js +147 -0
  33. package/dist/ceremony/dc-payment/txData.d.ts +19 -0
  34. package/dist/ceremony/dc-payment/txData.js +34 -0
  35. package/dist/ceremony/dc-payment/verify.d.ts +108 -0
  36. package/dist/ceremony/dc-payment/verify.js +208 -0
  37. package/dist/ceremony/mandate.d.ts +71 -0
  38. package/dist/ceremony/mandate.js +116 -0
  39. package/dist/ceremony/mdoc/mdoc-iso.d.ts +44 -0
  40. package/dist/ceremony/mdoc/mdoc-iso.js +260 -0
  41. package/dist/ceremony/mdoc/mdoc.d.ts +17 -0
  42. package/dist/ceremony/mdoc/mdoc.js +94 -0
  43. package/dist/ceremony/mdoc/reader.d.ts +10 -0
  44. package/dist/ceremony/mdoc/reader.js +43 -0
  45. package/dist/ceremony/mdoc/readerContext.d.ts +8 -0
  46. package/dist/ceremony/mdoc/readerContext.js +29 -0
  47. package/dist/ceremony/mount.d.ts +57 -0
  48. package/dist/ceremony/mount.js +96 -0
  49. package/dist/ceremony/origin.d.ts +10 -0
  50. package/dist/ceremony/origin.js +9 -0
  51. package/dist/ceremony/passkey/page.d.ts +6 -0
  52. package/dist/ceremony/passkey/page.js +136 -0
  53. package/dist/ceremony/passkey/routes.d.ts +2 -0
  54. package/dist/ceremony/passkey/routes.js +170 -0
  55. package/dist/ceremony/passkey/verify.d.ts +15 -0
  56. package/dist/ceremony/passkey/verify.js +56 -0
  57. package/dist/ceremony/reconciliation.d.ts +34 -0
  58. package/dist/ceremony/reconciliation.js +21 -0
  59. package/dist/ceremony/theme.d.ts +63 -0
  60. package/dist/ceremony/theme.js +285 -0
  61. package/dist/ceremony/types.d.ts +95 -0
  62. package/dist/ceremony/types.js +1 -0
  63. package/dist/client.d.ts +39 -0
  64. package/dist/client.js +84 -0
  65. package/dist/credentials.d.ts +48 -0
  66. package/dist/credentials.js +127 -0
  67. package/dist/envelope.d.ts +62 -0
  68. package/dist/envelope.js +72 -0
  69. package/dist/gated.d.ts +39 -0
  70. package/dist/gated.js +41 -0
  71. package/dist/index.d.ts +18 -0
  72. package/dist/index.js +49 -0
  73. package/dist/manifest.d.ts +28 -0
  74. package/dist/manifest.js +76 -0
  75. package/dist/store.d.ts +7 -0
  76. package/dist/store.js +16 -0
  77. package/dist/types.d.ts +146 -0
  78. package/dist/types.js +7 -0
  79. package/package.json +62 -0
@@ -0,0 +1,208 @@
1
+ // AP2-shaped DC payment mandate + four deterministic gates. TWO verify paths feed
2
+ // ONE set of gates:
3
+ // • the instant-demo path (buildDcMandate + runDcGates) — disclosed instrument
4
+ // claims passed in directly; the amount-bound transaction_data is re-derived +
5
+ // re-checked, so a tampered amount is refused. The tested default (CT6–CT8).
6
+ // • the REAL OpenID4VP path (verifyDcPresentation, below) — the wallet's
7
+ // JWE-encrypted response is decrypted (jose ECDH-ES compactDecrypt), the ISO
8
+ // 18013-5 mdoc DeviceResponse is parsed, and the wallet's device-SIGNED
9
+ // transaction_data_hash (from deviceSigned) is extracted and re-checked against
10
+ // SHA-256 of the transaction_data we sent — proving the wallet authorized THIS
11
+ // amount/payee, not merely that a token decrypted.
12
+ //
13
+ // TRUST_LEVEL stays "presence-only-demo" (Principle VII / FR-011): the wire crypto
14
+ // — JWE decryption, the ISO-mdoc CBOR parse, the transaction_data hash binding — is
15
+ // REAL; what is NOT yet verified is the issuer/device COSE SIGNATURE against a real
16
+ // trust anchor (main self-signs its mdoc certs). Faithfully ported from the demo's
17
+ // payment-gate/dc-payment/{mandate,verify}.ts. No gate trusts a `verified` flag —
18
+ // AMOUNT BINDING (Security invariants 2/3) is always re-derived from the
19
+ // catalog-priced lines.
20
+ import { randomUUID } from "node:crypto";
21
+ import * as jose from "jose";
22
+ import { buildBindingFields, DEFAULT_LOYALTY_DISCOUNT_PCT } from "../mandate.js";
23
+ import { buildTransactionData, decodeTransactionData, encodeTransactionData, hashTransactionData } from "./txData.js";
24
+ import { openReaderContext } from "../mdoc/readerContext.js";
25
+ import { decodeVpToken, extractTransactionDataHash, inspectAuthBlocks } from "../mdoc/mdoc.js";
26
+ function round2(n) {
27
+ return Math.round(n * 100) / 100;
28
+ }
29
+ function claimText(v) {
30
+ if (v == null)
31
+ return null;
32
+ if (typeof v === "object" && "value" in v)
33
+ return String(v.value);
34
+ return String(v);
35
+ }
36
+ const DEFAULT_ISSUER = "did:web:attestomcp.local";
37
+ /**
38
+ * Build the presence-only DC payment mandate. The transaction_data is derived from
39
+ * the (catalog-re-priced) order + this RP's origin, so its amount/payee are the
40
+ * server's truth; `presentedAmount` is what the caller asserts authorizing (Gate 1
41
+ * re-checks it equals the re-derived payable — a tampered value is refused).
42
+ */
43
+ export function buildDcMandate(args) {
44
+ const { order, origin, claims } = args;
45
+ const now = new Date();
46
+ const expires = new Date(now.getTime() + 5 * 60_000);
47
+ const txDataB64 = encodeTransactionData(buildTransactionData(order, origin));
48
+ const instrument = {
49
+ issuer: claimText(claims["issuer_name"]),
50
+ instrumentId: claimText(claims["payment_instrument_id"]),
51
+ maskedAccount: claimText(claims["masked_account_reference"]),
52
+ holder: claimText(claims["holder_name"]),
53
+ expiry: claimText(claims["expiry_date"]),
54
+ };
55
+ return {
56
+ type: "ap2.PaymentMandate",
57
+ version: "0.1-dc-demo",
58
+ id: "mandate_pm_" + randomUUID(),
59
+ issuedAt: now.toISOString(),
60
+ expiresAt: expires.toISOString(),
61
+ issuer: args.issuer ?? DEFAULT_ISSUER,
62
+ subject: { credentialId: instrument.instrumentId },
63
+ cart: order,
64
+ payment: { instrument, amount: args.presentedAmount ?? order.total, currency: order.currency },
65
+ userAuthorization: {
66
+ type: "openid4vp-dc-api",
67
+ transactionData: txDataB64,
68
+ transactionDataHash: hashTransactionData(txDataB64),
69
+ presented: true,
70
+ },
71
+ trust_level: "presence-only-demo",
72
+ };
73
+ }
74
+ export function runDcGates(mandate, origin, opts = {}) {
75
+ const pct = opts.loyaltyDiscountPct ?? DEFAULT_LOYALTY_DISCOUNT_PCT;
76
+ const ua = mandate.userAuthorization;
77
+ const cart = mandate.cart;
78
+ const results = [];
79
+ // Gate 1 — amount binding. Re-sum the (undiscounted) cart lines, re-derive the
80
+ // payable, and re-check it against (a) the transaction_data we'd send, (b) the
81
+ // presented payment.amount. A loyalty discount, if present, must be either zero
82
+ // or EXACTLY the configured percentage of the line sum (this lets a legitimately
83
+ // discounted order pass and rejects an arbitrary discount). The stored amount is
84
+ // NOT trusted; everything is re-derived here.
85
+ const lineSum = round2(cart.lines.reduce((sum, l) => sum + l.lineTotal, 0));
86
+ const discount = cart.discount ?? 0;
87
+ const discountOk = discount === 0 || discount === round2(lineSum * (pct / 100));
88
+ const payable = round2(lineSum - discount);
89
+ const recomputed = hashTransactionData(ua.transactionData);
90
+ const hashOk = ua.transactionDataHash === recomputed;
91
+ const txd = decodeTransactionData(ua.transactionData);
92
+ const amountOk = discountOk && payable === cart.total && payable === mandate.payment.amount && Number(txd.payload.amount) === payable;
93
+ const currencyOk = txd.payload.currency === cart.currency;
94
+ // Payee must be THIS RP — re-derived from the request origin, not the token. An
95
+ // attacker re-pointing the request to their own origin fails here (invariant 6).
96
+ const expectedPayee = buildBindingFields(cart, origin).payee.id;
97
+ const payeeOk = !!txd.payload.payee?.id && txd.payload.payee.id === expectedPayee;
98
+ results.push({
99
+ gate: "Amount binding",
100
+ pass: hashOk && amountOk && currencyOk && payeeOk,
101
+ detail: `hash ${hashOk ? "✓" : "✗"} · amount ${amountOk ? "✓" : "✗"} (${txd.payload.amount}/${mandate.payment.amount} vs ${payable}) · currency ${currencyOk ? "✓" : "✗"} · payee ${payeeOk ? "✓" : "✗"} (${txd.payload.payee?.id} vs ${expectedPayee})`,
102
+ });
103
+ // Gate 2 — authorization present. On the REAL path, the wallet's mdoc carries
104
+ // structural issuerAuth + deviceAuth blocks (inspected from the parsed
105
+ // DeviceResponse); on the instant-demo path a disclosed instrument id stands in
106
+ // for them. Either way this is presence-only — the COSE signatures themselves are
107
+ // NOT verified against a trust anchor (acknowledged future work).
108
+ const instrumentId = mandate.payment.instrument.instrumentId;
109
+ const auth = ua.authBlocks;
110
+ const authPass = auth ? auth.hasIssuerAuth && auth.hasDeviceAuth : !!instrumentId;
111
+ results.push({
112
+ gate: "Authorization present",
113
+ pass: authPass,
114
+ detail: auth
115
+ ? `issuerAuth ${auth.hasIssuerAuth ? "✓" : "✗"} · deviceAuth ${auth.hasDeviceAuth ? "✓" : "✗"} (presence-only — COSE signatures not verified)`
116
+ : `instrument=${instrumentId ?? "∅"} (presence-only — device/issuer signatures not verified)`,
117
+ });
118
+ // Gate 3 — credential not expired (disclosed expiry_date in the future).
119
+ const expStr = mandate.payment.instrument.expiry;
120
+ const notExpired = !!expStr && new Date(expStr).getTime() > Date.now();
121
+ results.push({ gate: "Credential not expired", pass: notExpired, detail: `expiry_date=${expStr}` });
122
+ // Gate 4 — subject binding: mandate.subject re-checked against the disclosed
123
+ // instrument id.
124
+ const subjectOk = !!instrumentId && mandate.subject.credentialId === instrumentId;
125
+ results.push({ gate: "Subject binding", pass: subjectOk, detail: `subject=${mandate.subject.credentialId} · instrument=${instrumentId}` });
126
+ return results;
127
+ }
128
+ // ── REAL OpenID4VP path ───────────────────────────────────────────────────────
129
+ // Disclosed mdoc claim values can be {_tag, value} (e.g. tag-1004 dates) or raw.
130
+ function disclosedClaims(vpStr) {
131
+ const disclosed = decodeVpToken({ dpc: vpStr });
132
+ return Object.fromEntries((disclosed[0]?.claims ?? []).map((c) => [c.label.split(" / ").pop(), c.value]));
133
+ }
134
+ /**
135
+ * Build the DC mandate from a REAL wallet DeviceResponse. Unlike the instant-demo
136
+ * builder, `transactionDataHash` is the value EXTRACTED from the wallet's
137
+ * device-signed mdoc (deviceSigned/transaction_data_hash) — Gate 1 then re-checks it
138
+ * equals SHA-256 of the transaction_data WE sealed (transactionDataB64). The vpToken
139
+ * + parsed issuerAuth/deviceAuth presence drive Gate 2. The instrument fields come
140
+ * from the issuer-signed namespaces of the SAME DeviceResponse.
141
+ */
142
+ export function buildDcMandateFromPresentation(args) {
143
+ const { order, vpStr, transactionDataB64 } = args;
144
+ const now = new Date();
145
+ const expires = new Date(now.getTime() + 5 * 60_000);
146
+ const claims = disclosedClaims(vpStr);
147
+ const instrument = {
148
+ issuer: claimText(claims["issuer_name"]),
149
+ instrumentId: claimText(claims["payment_instrument_id"]),
150
+ maskedAccount: claimText(claims["masked_account_reference"]),
151
+ holder: claimText(claims["holder_name"]),
152
+ expiry: claimText(claims["expiry_date"]),
153
+ };
154
+ const blocks = inspectAuthBlocks(vpStr);
155
+ return {
156
+ type: "ap2.PaymentMandate",
157
+ version: "0.1-dc-demo",
158
+ id: "mandate_pm_" + randomUUID(),
159
+ issuedAt: now.toISOString(),
160
+ expiresAt: expires.toISOString(),
161
+ issuer: args.issuer ?? DEFAULT_ISSUER,
162
+ subject: { credentialId: instrument.instrumentId },
163
+ cart: order,
164
+ payment: { instrument, amount: order.total, currency: order.currency },
165
+ userAuthorization: {
166
+ type: "openid4vp-dc-api",
167
+ transactionData: transactionDataB64,
168
+ // The wallet's signed hash — re-checked in Gate 1 against our recomputed hash.
169
+ transactionDataHash: extractTransactionDataHash(vpStr),
170
+ presented: true,
171
+ vpToken: vpStr,
172
+ authBlocks: { hasIssuerAuth: blocks.hasIssuerAuth, hasDeviceAuth: blocks.hasDeviceAuth },
173
+ },
174
+ trust_level: "presence-only-demo",
175
+ };
176
+ }
177
+ /**
178
+ * Verify the wallet's REAL OpenID4VP presentation: open the sealed reader context,
179
+ * JWE-decrypt the response (jose ECDH-ES compactDecrypt), pull the mdoc vp_token,
180
+ * build the amount-bound mandate from the device-signed DeviceResponse, and run the
181
+ * four gates. Faithfully ported from the demo's payment-gate/dc-payment/verify.ts.
182
+ * The crypto (decryption + the transaction_data hash binding) is REAL; the issuer
183
+ * trust anchor is not (trust_level presence-only-demo).
184
+ */
185
+ export async function verifyDcPresentation(args) {
186
+ const { order, origin, result, readerContextToken, secret } = args;
187
+ const ctx = await openReaderContext(readerContextToken, secret);
188
+ let data = result?.data;
189
+ if (typeof data === "string") {
190
+ try {
191
+ data = JSON.parse(data);
192
+ }
193
+ catch { /* leave as string */ }
194
+ }
195
+ const jwe = data?.response;
196
+ if (!jwe)
197
+ throw new Error("no .response (JWE) in result.data");
198
+ const encPrivKey = await jose.importJWK(ctx.ecdhPrivateJwk, "ECDH-ES");
199
+ const { plaintext } = await jose.compactDecrypt(jwe, encPrivKey);
200
+ const openid4vpResponse = JSON.parse(new TextDecoder().decode(plaintext));
201
+ const vpToken = openid4vpResponse.vp_token; // { dpc: [ "<DeviceResponse b64url>" ] }
202
+ const vpStr = Array.isArray(vpToken?.dpc) ? vpToken.dpc[0] : vpToken?.dpc;
203
+ if (!vpStr)
204
+ throw new Error("no vp_token.dpc in decrypted response");
205
+ const mandate = buildDcMandateFromPresentation({ order, vpStr, transactionDataB64: ctx.transactionDataB64 });
206
+ const gates = runDcGates(mandate, origin);
207
+ return { mandate, gates };
208
+ }
@@ -0,0 +1,71 @@
1
+ import type { CeremonyOrder } from "./types.js";
2
+ import type { Origin } from "./origin.js";
3
+ export declare const DEFAULT_LOYALTY_DISCOUNT_PCT = 10;
4
+ export interface BindingFields {
5
+ amount: number;
6
+ currency: string;
7
+ payee: {
8
+ id: string;
9
+ name: string;
10
+ };
11
+ orderId: string;
12
+ }
13
+ export declare function buildBindingFields(order: CeremonyOrder, origin: Origin, payeeName?: string): BindingFields;
14
+ export interface VerifiedAuthenticator {
15
+ credentialID: string;
16
+ userVerified: boolean;
17
+ credentialDeviceType: "singleDevice" | "multiDevice";
18
+ credentialBackedUp: boolean;
19
+ }
20
+ export interface PasskeyMandate {
21
+ type: "ap2.PaymentMandate";
22
+ version: "0.1-mock";
23
+ id: string;
24
+ issuedAt: string;
25
+ expiresAt: string;
26
+ issuer: string;
27
+ subject: {
28
+ credentialID: string;
29
+ };
30
+ cart: CeremonyOrder;
31
+ payment: {
32
+ instrument: string;
33
+ instrumentReference: string;
34
+ network: string;
35
+ amount: number;
36
+ currency: string;
37
+ };
38
+ userAuthorization: {
39
+ type: "webauthn.assertion";
40
+ credentialID: string;
41
+ userVerified: boolean;
42
+ hardwareBacked: boolean;
43
+ deviceType: string;
44
+ backedUp: boolean;
45
+ rpID: string;
46
+ origin: string;
47
+ ceremonyTimestamp: string;
48
+ };
49
+ payeeId: string;
50
+ trust_level: "presence-only-demo";
51
+ signature: {
52
+ alg: "MOCK-DEV-SIGNER";
53
+ value: string;
54
+ note: string;
55
+ };
56
+ }
57
+ export declare function buildPasskeyMandate(args: {
58
+ order: CeremonyOrder;
59
+ authenticator: VerifiedAuthenticator;
60
+ origin: Origin;
61
+ issuer?: string;
62
+ payeeName?: string;
63
+ }): PasskeyMandate;
64
+ export interface GateResult {
65
+ gate: string;
66
+ pass: boolean;
67
+ detail: string;
68
+ }
69
+ export declare function runGates(mandate: PasskeyMandate, opts?: {
70
+ loyaltyDiscountPct?: number;
71
+ }): GateResult[];
@@ -0,0 +1,116 @@
1
+ // Binding fields + the AP2-shaped passkey mandate + the four deterministic gates.
2
+ // Extracted from the demo's payment-gate/mandate.ts; the demo's `Order` and the
3
+ // hardcoded `LOYALTY_DISCOUNT_PCT` become an injected `CeremonyOrder` + an opt so
4
+ // the package stays dependency-free. No gate trusts a `verified` boolean — each is
5
+ // re-derived from the mandate's own fields.
6
+ //
7
+ // Trust is PRESENCE-ONLY (Principle VII): the signature is a dev-mock SHA-256
8
+ // digest and the mandate carries `trust_level: "presence-only-demo"`. Real
9
+ // KB-JWT / key-bound signing is deferred (v0.2); this is a flow demo, not a real
10
+ // safety control.
11
+ import { createHash, randomUUID } from "node:crypto";
12
+ function round2(n) {
13
+ return Math.round(n * 100) / 100;
14
+ }
15
+ // The demo's whole-cart loyalty discount; the host can override via runGates opts.
16
+ export const DEFAULT_LOYALTY_DISCOUNT_PCT = 10;
17
+ const DEFAULT_ISSUER = "did:web:attestomcp.local";
18
+ const DEFAULT_PAYEE_NAME = "AttestoMcp Gate Demo";
19
+ export function buildBindingFields(order, origin, payeeName = DEFAULT_PAYEE_NAME) {
20
+ return {
21
+ amount: order.total,
22
+ currency: order.currency,
23
+ payee: { id: origin.rpID, name: payeeName },
24
+ orderId: order.id,
25
+ };
26
+ }
27
+ export function buildPasskeyMandate(args) {
28
+ const { order, authenticator, origin } = args;
29
+ const now = new Date();
30
+ const expires = new Date(now.getTime() + 5 * 60_000);
31
+ const binding = buildBindingFields(order, origin, args.payeeName);
32
+ const body = {
33
+ type: "ap2.PaymentMandate",
34
+ version: "0.1-mock",
35
+ id: "mandate_pm_" + randomUUID(),
36
+ issuedAt: now.toISOString(),
37
+ expiresAt: expires.toISOString(),
38
+ issuer: args.issuer ?? DEFAULT_ISSUER,
39
+ subject: { credentialID: authenticator.credentialID },
40
+ cart: order,
41
+ payment: {
42
+ instrument: "stripe_test",
43
+ instrumentReference: "pi_3Mock" + Math.random().toString(36).slice(2, 10).toUpperCase(),
44
+ network: "card",
45
+ amount: binding.amount,
46
+ currency: binding.currency,
47
+ },
48
+ userAuthorization: {
49
+ type: "webauthn.assertion",
50
+ credentialID: authenticator.credentialID,
51
+ userVerified: authenticator.userVerified,
52
+ // A single-device credential is bound to this authenticator's hardware; a
53
+ // multi-device (syncable) passkey is not strictly hardware-bound.
54
+ hardwareBacked: authenticator.credentialDeviceType === "singleDevice",
55
+ deviceType: authenticator.credentialDeviceType,
56
+ backedUp: authenticator.credentialBackedUp,
57
+ rpID: origin.rpID,
58
+ origin: origin.origin,
59
+ ceremonyTimestamp: now.toISOString(),
60
+ },
61
+ payeeId: binding.payee.id,
62
+ trust_level: "presence-only-demo",
63
+ };
64
+ const digest = createHash("sha256").update(JSON.stringify(body)).digest("base64");
65
+ return {
66
+ ...body,
67
+ signature: {
68
+ alg: "MOCK-DEV-SIGNER",
69
+ value: "mock-sig:" + digest,
70
+ note: "Mock dev signer (presence-only-demo). Production replaces with AP2-conformant key-bound signing.",
71
+ },
72
+ };
73
+ }
74
+ export function runGates(mandate, opts = {}) {
75
+ const pct = opts.loyaltyDiscountPct ?? DEFAULT_LOYALTY_DISCOUNT_PCT;
76
+ const ua = mandate.userAuthorization;
77
+ const cart = mandate.cart;
78
+ const lineSum = round2(cart.lines.reduce((sum, l) => sum + l.lineTotal, 0));
79
+ const results = [];
80
+ // Gate 1 — amount integrity. Re-sum the (undiscounted) cart lines and re-derive
81
+ // the payable total; payment.amount is NOT trusted. A loyalty discount, if
82
+ // present, must be either zero or EXACTLY the configured percentage of the line
83
+ // sum — this lets a legitimately discounted order pass and rejects a token
84
+ // tampered with an arbitrary discount. Payable must equal cart.total AND the
85
+ // authorized payment.amount.
86
+ const discount = cart.discount ?? 0;
87
+ const discountOk = discount === 0 || discount === round2(lineSum * (pct / 100));
88
+ const payable = round2(lineSum - discount);
89
+ const amountOk = discountOk && payable === cart.total && payable === mandate.payment.amount;
90
+ results.push({
91
+ gate: "Amount integrity",
92
+ pass: amountOk,
93
+ detail: `lines=${lineSum} · discount=${discount} · payable=${payable} · payment=${mandate.payment.amount} · cart.total=${cart.total}`,
94
+ });
95
+ // Gate 2 — authorization present & structurally a webauthn assertion.
96
+ const authPresent = ua.type === "webauthn.assertion" && !!ua.credentialID;
97
+ results.push({
98
+ gate: "Authorization present",
99
+ pass: authPresent,
100
+ detail: `type=${ua.type} · credentialID=${ua.credentialID || "∅"}`,
101
+ });
102
+ // Gate 3 — user verification asserted by the authenticator.
103
+ results.push({
104
+ gate: "User verification",
105
+ pass: ua.userVerified === true,
106
+ detail: `userVerified=${ua.userVerified} · hardwareBacked=${ua.hardwareBacked}`,
107
+ });
108
+ // Gate 4 — subject binding: re-check subject == authorization credentialID.
109
+ const subjectOk = !!mandate.subject.credentialID && mandate.subject.credentialID === ua.credentialID;
110
+ results.push({
111
+ gate: "Subject binding",
112
+ pass: subjectOk,
113
+ detail: `subject=${mandate.subject.credentialID} · auth=${ua.credentialID}`,
114
+ });
115
+ return results;
116
+ }
@@ -0,0 +1,44 @@
1
+ import * as jose from "jose";
2
+ import { type DisclosedEntry } from "./mdoc.js";
3
+ export interface MdocDocSpec {
4
+ docType: string;
5
+ namespace: string;
6
+ elements: string[];
7
+ }
8
+ export declare function coseKeyFromJwk(jwk: {
9
+ x: string;
10
+ y: string;
11
+ }): Map<number, unknown>;
12
+ export interface ReaderKey {
13
+ coseKey: Map<number, unknown>;
14
+ privateJwk: jose.JWK;
15
+ }
16
+ export declare function generateReaderKey(): Promise<ReaderKey>;
17
+ export declare function buildEncryptionInfo(coseKey: Map<number, unknown>, nonce: Uint8Array): {
18
+ bytes: Uint8Array;
19
+ base64: string;
20
+ };
21
+ export declare function buildItemsRequest(spec: MdocDocSpec): Uint8Array;
22
+ export declare function buildDeviceRequest(spec: MdocDocSpec): Uint8Array;
23
+ export declare function buildSessionTranscript(base64EncryptionInfo: string, origin: string): Uint8Array;
24
+ export interface MdocRequestParts {
25
+ data: {
26
+ deviceRequest: string;
27
+ encryptionInfo: string;
28
+ };
29
+ readerPrivateJwk: jose.JWK;
30
+ base64EncryptionInfo: string;
31
+ }
32
+ export declare function buildMdocRequestParts(spec: MdocDocSpec, origin: string, signed?: boolean): Promise<MdocRequestParts>;
33
+ export declare function decryptDeviceResponse(args: {
34
+ responseB64Url: string;
35
+ readerPrivateJwk: jose.JWK;
36
+ sessionTranscript: Uint8Array;
37
+ }): Promise<Uint8Array>;
38
+ export declare function disclosedFromDeviceResponse(deviceResponse: Uint8Array): DisclosedEntry[];
39
+ export interface MdocReaderContext {
40
+ readerPrivateJwk: jose.JWK;
41
+ base64EncryptionInfo: string;
42
+ }
43
+ export declare function sealMdocContext(ctx: MdocReaderContext, secret: string, ttlMs?: number): Promise<string>;
44
+ export declare function openMdocContext(token: string, secret: string): Promise<MdocReaderContext>;