@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.
- package/LICENSE +201 -0
- package/README.md +172 -0
- package/dist/ceremony/cartMandate.d.ts +61 -0
- package/dist/ceremony/cartMandate.js +76 -0
- package/dist/ceremony/challengeToken.d.ts +5 -0
- package/dist/ceremony/challengeToken.js +43 -0
- package/dist/ceremony/checkout-page.d.ts +85 -0
- package/dist/ceremony/checkout-page.js +269 -0
- package/dist/ceremony/completion.d.ts +41 -0
- package/dist/ceremony/completion.js +90 -0
- package/dist/ceremony/credential-gate/dcql.d.ts +10 -0
- package/dist/ceremony/credential-gate/dcql.js +12 -0
- package/dist/ceremony/credential-gate/doc-spec.d.ts +3 -0
- package/dist/ceremony/credential-gate/doc-spec.js +16 -0
- package/dist/ceremony/credential-gate/mdoc-verify.d.ts +15 -0
- package/dist/ceremony/credential-gate/mdoc-verify.js +29 -0
- package/dist/ceremony/credential-gate/page.d.ts +20 -0
- package/dist/ceremony/credential-gate/page.js +136 -0
- package/dist/ceremony/credential-gate/request.d.ts +15 -0
- package/dist/ceremony/credential-gate/request.js +43 -0
- package/dist/ceremony/credential-gate/routes.d.ts +2 -0
- package/dist/ceremony/credential-gate/routes.js +200 -0
- package/dist/ceremony/credential-gate/verify.d.ts +51 -0
- package/dist/ceremony/credential-gate/verify.js +146 -0
- package/dist/ceremony/dc-payment/dcql.d.ts +5 -0
- package/dist/ceremony/dc-payment/dcql.js +23 -0
- package/dist/ceremony/dc-payment/page.d.ts +18 -0
- package/dist/ceremony/dc-payment/page.js +195 -0
- package/dist/ceremony/dc-payment/request.d.ts +17 -0
- package/dist/ceremony/dc-payment/request.js +50 -0
- package/dist/ceremony/dc-payment/routes.d.ts +2 -0
- package/dist/ceremony/dc-payment/routes.js +147 -0
- package/dist/ceremony/dc-payment/txData.d.ts +19 -0
- package/dist/ceremony/dc-payment/txData.js +34 -0
- package/dist/ceremony/dc-payment/verify.d.ts +108 -0
- package/dist/ceremony/dc-payment/verify.js +208 -0
- package/dist/ceremony/mandate.d.ts +71 -0
- package/dist/ceremony/mandate.js +116 -0
- package/dist/ceremony/mdoc/mdoc-iso.d.ts +44 -0
- package/dist/ceremony/mdoc/mdoc-iso.js +260 -0
- package/dist/ceremony/mdoc/mdoc.d.ts +17 -0
- package/dist/ceremony/mdoc/mdoc.js +94 -0
- package/dist/ceremony/mdoc/reader.d.ts +10 -0
- package/dist/ceremony/mdoc/reader.js +43 -0
- package/dist/ceremony/mdoc/readerContext.d.ts +8 -0
- package/dist/ceremony/mdoc/readerContext.js +29 -0
- package/dist/ceremony/mount.d.ts +57 -0
- package/dist/ceremony/mount.js +96 -0
- package/dist/ceremony/origin.d.ts +10 -0
- package/dist/ceremony/origin.js +9 -0
- package/dist/ceremony/passkey/page.d.ts +6 -0
- package/dist/ceremony/passkey/page.js +136 -0
- package/dist/ceremony/passkey/routes.d.ts +2 -0
- package/dist/ceremony/passkey/routes.js +170 -0
- package/dist/ceremony/passkey/verify.d.ts +15 -0
- package/dist/ceremony/passkey/verify.js +56 -0
- package/dist/ceremony/reconciliation.d.ts +34 -0
- package/dist/ceremony/reconciliation.js +21 -0
- package/dist/ceremony/theme.d.ts +63 -0
- package/dist/ceremony/theme.js +285 -0
- package/dist/ceremony/types.d.ts +95 -0
- package/dist/ceremony/types.js +1 -0
- package/dist/client.d.ts +39 -0
- package/dist/client.js +84 -0
- package/dist/credentials.d.ts +48 -0
- package/dist/credentials.js +127 -0
- package/dist/envelope.d.ts +62 -0
- package/dist/envelope.js +72 -0
- package/dist/gated.d.ts +39 -0
- package/dist/gated.js +41 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +49 -0
- package/dist/manifest.d.ts +28 -0
- package/dist/manifest.js +76 -0
- package/dist/store.d.ts +7 -0
- package/dist/store.js +16 -0
- package/dist/types.d.ts +146 -0
- package/dist/types.js +7 -0
- 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>;
|