@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,195 @@
|
|
|
1
|
+
// Server-rendered dc-payment gate page. It now drives the REAL OpenID4VP wallet path:
|
|
2
|
+
// the PRIMARY button calls navigator.credentials.get({digital}) synchronously inside
|
|
3
|
+
// the tap (the signed request is PRE-FETCHED from /attestomcp/dc-payment/request so no
|
|
4
|
+
// await sits between the click and get() — iOS WebKit drops the transient user
|
|
5
|
+
// activation across an await; Chrome 141+ renders the cross-device caBLE QR), then POSTs
|
|
6
|
+
// the wallet's encrypted vp_token to /attestomcp/dc-payment/verify in the shape the route's
|
|
7
|
+
// real path reads ({ order, readerContextToken, result:{protocol,data} }) — the route
|
|
8
|
+
// decrypts the JWE, re-checks the device-signed transaction_data_hash against the
|
|
9
|
+
// amount we sealed, runs the four gates, and completes through the shared completeOrder
|
|
10
|
+
// seam. The SECONDARY "instant demo" button is kept as a fallback: it POSTs the
|
|
11
|
+
// canonical disclosed instrument + the catalog-bound amount ({ order, amount, claims })
|
|
12
|
+
// (no wallet round-trip; the tested default). On a browser without the Digital
|
|
13
|
+
// Credentials API the page points the buyer at the instant-demo button. Every surface
|
|
14
|
+
// states trust_level "presence-only-demo" (CT11 / Principle VII / FR-011): the wire
|
|
15
|
+
// crypto is real; the wallet's device/issuer trust anchor is not — never a real safety
|
|
16
|
+
// control. Self-contained: takes the re-priced amount + lines, not a demo Order type.
|
|
17
|
+
import { pageHead, brandHeader, progressRail, orderSummaryCard, trustFooter, settlingBar, completionHandoffBanner } from "../theme.js";
|
|
18
|
+
// The canonical disclosed instrument the instant-demo button presents — it goes
|
|
19
|
+
// through the SAME server-side amount-binding gates as a real wallet presentation.
|
|
20
|
+
const DEMO_CLAIMS = {
|
|
21
|
+
issuer_name: "Demo Bank",
|
|
22
|
+
payment_instrument_id: "pi-77AABBCC",
|
|
23
|
+
masked_account_reference: "•••• 4242",
|
|
24
|
+
holder_name: "Demo Buyer",
|
|
25
|
+
expiry_date: "2032-09-01",
|
|
26
|
+
};
|
|
27
|
+
function money(amount, currency) {
|
|
28
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(amount);
|
|
29
|
+
}
|
|
30
|
+
export function renderDcPaymentPage(args) {
|
|
31
|
+
const { order, total, currency, lines } = args;
|
|
32
|
+
const returnUrl = args.returnUrl ?? `/checkout?order=${encodeURIComponent(order)}`;
|
|
33
|
+
// The shared order summary card (line items + bold Total) — same chrome as the hub.
|
|
34
|
+
const summary = orderSummaryCard({
|
|
35
|
+
lines: lines.map((l) => ({ name: l.name, quantity: l.quantity, lineTotal: l.lineTotal, currency: l.currency })),
|
|
36
|
+
total,
|
|
37
|
+
currency,
|
|
38
|
+
caption: `Order ${order}`,
|
|
39
|
+
});
|
|
40
|
+
// The progress rail with Pay as the current (final) step; the upstream gates are done.
|
|
41
|
+
const rail = progressRail([{ label: "Age", done: true }, { label: "Membership", done: true }, { label: "Pay" }], 2);
|
|
42
|
+
// Page-local chrome layered over the shared design system: the verify-progress rows
|
|
43
|
+
// reuse `.step`; the receipt gate rows + the success card are page-specific.
|
|
44
|
+
const extraCss = `
|
|
45
|
+
#receipt { display: none; margin-top: 16px; }
|
|
46
|
+
.gate { font-size: .82rem; padding: 3px 0; }
|
|
47
|
+
.gate.pass { color: var(--success); } .gate.fail { color: var(--danger); }`;
|
|
48
|
+
return `<!doctype html>
|
|
49
|
+
<html lang="en">
|
|
50
|
+
${pageHead(`Authorize payment (cross-device) · ${order}`, extraCss)}
|
|
51
|
+
<body>
|
|
52
|
+
<div class="wrap">
|
|
53
|
+
${brandHeader({ h1: "Authorize payment", tagline: "Authorize from your wallet" })}
|
|
54
|
+
${rail}
|
|
55
|
+
${summary}
|
|
56
|
+
<div class="card">
|
|
57
|
+
<p class="lede">Present a payment credential from your phone wallet. Chrome shows a QR; scanning it uses the cross-device channel (FIDO caBLE). Your wallet signs over this exact amount, then payment settles on-chain via the <strong>x402</strong> protocol — on a <strong>test network</strong>, no real money, a tiny token amount (a fixed demo rate, not the dollar total).</p>
|
|
58
|
+
<button id="go-dc" class="btn btn-primary">Authorize ${money(total, currency)} with my wallet</button>
|
|
59
|
+
<button id="go" class="btn btn-secondary">Authorize ${money(total, currency)} (instant demo)</button>
|
|
60
|
+
<div id="log"></div>
|
|
61
|
+
${settlingBar()}
|
|
62
|
+
<div id="receipt"></div>
|
|
63
|
+
</div>
|
|
64
|
+
${trustFooter()}
|
|
65
|
+
<script type="module">
|
|
66
|
+
const ORDER = ${JSON.stringify(order)};
|
|
67
|
+
const AMOUNT = ${JSON.stringify(total)};
|
|
68
|
+
const DEMO_CLAIMS = ${JSON.stringify(DEMO_CLAIMS)};
|
|
69
|
+
const RETURN_URL = ${JSON.stringify(returnUrl)};
|
|
70
|
+
const DONE_BANNER = ${JSON.stringify(completionHandoffBanner(returnUrl))};
|
|
71
|
+
const log = document.getElementById("log");
|
|
72
|
+
const goDc = document.getElementById("go-dc");
|
|
73
|
+
const btn = document.getElementById("go");
|
|
74
|
+
const settling = document.getElementById("settling");
|
|
75
|
+
const step = (t, c = "") => { const d = document.createElement("div"); d.className = "step " + c; d.textContent = t; log.appendChild(d); };
|
|
76
|
+
function notice(html) { const d = document.createElement("div"); d.className = "notice"; d.innerHTML = html; log.appendChild(d); }
|
|
77
|
+
// Escape any server-returned value before it goes into innerHTML (txId, accountId,
|
|
78
|
+
// the settlementError message): they're built server-side but never trusted raw.
|
|
79
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => "&#" + c.charCodeAt(0) + ";");
|
|
80
|
+
|
|
81
|
+
// Pre-fetch the REAL signed OpenID4VP request so navigator.credentials.get() can be
|
|
82
|
+
// called SYNCHRONOUSLY inside the tap. iOS WebKit drops the transient user
|
|
83
|
+
// activation across an await, so we must not fetch between the click and get(). We
|
|
84
|
+
// keep a fresh pre-fetched request ready at all times. location.search carries the
|
|
85
|
+
// order this gate is scoped to, so /request re-prices THIS order (amount-bound).
|
|
86
|
+
let reqData = null;
|
|
87
|
+
function prefetch() {
|
|
88
|
+
reqData = null;
|
|
89
|
+
fetch("/attestomcp/dc-payment/request" + location.search).then((r) => r.json()).then((d) => { reqData = d; }).catch(() => {});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!("credentials" in navigator) || !window.DigitalCredential) {
|
|
93
|
+
goDc.disabled = true;
|
|
94
|
+
notice('This browser does not support <code>navigator.credentials.get({digital})</code> (needs <strong>Chrome 141+</strong>/Android or iOS 18+). Use the <strong>instant demo</strong> button.');
|
|
95
|
+
} else {
|
|
96
|
+
prefetch();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
goDc.addEventListener("click", () => {
|
|
100
|
+
if (!("credentials" in navigator) || !window.DigitalCredential) {
|
|
101
|
+
notice('This browser does not support <code>navigator.credentials.get({digital})</code>. Use the instant-demo button.');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (!reqData || !reqData.request) { notice("Preparing the request — tap again in a second."); prefetch(); return; }
|
|
105
|
+
goDc.disabled = true;
|
|
106
|
+
const rd = reqData;
|
|
107
|
+
step("→ navigator.credentials.get({digital}) — Chrome should show a QR…");
|
|
108
|
+
// Called synchronously (no await before it) to keep the user activation.
|
|
109
|
+
navigator.credentials.get({ digital: { requests: [{ protocol: "openid4vp-v1-signed", data: { request: rd.request } }] }, mediation: "required" })
|
|
110
|
+
.then(async (result) => {
|
|
111
|
+
let data = result && result.data != null ? result.data : null;
|
|
112
|
+
if (typeof data === "string") { try { data = JSON.parse(data); } catch (e) {} }
|
|
113
|
+
step("→ verify · Settling via x402 on Hedera testnet (if configured)… can take ~10s");
|
|
114
|
+
settling.classList.add("on");
|
|
115
|
+
const out = await fetch("/attestomcp/dc-payment/verify", {
|
|
116
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
117
|
+
body: JSON.stringify({ order: ORDER, readerContextToken: rd.readerContextToken, result: { protocol: (result && result.protocol) || null, data } }),
|
|
118
|
+
}).then((r) => r.json()).finally(() => settling.classList.remove("on"));
|
|
119
|
+
if (!out.mandate) throw new Error(out.error || "authorization failed");
|
|
120
|
+
step("✓ presentation verified · mandate built (" + out.mandate.trust_level + ")", "ok");
|
|
121
|
+
renderReceipt(out);
|
|
122
|
+
// Configured-but-failed settle: authorized, not settled — let the buyer retry.
|
|
123
|
+
if (out.settlementError) { step("✗ settlement failed — authorized, not settled (retry below)", "err"); goDc.disabled = false; prefetch(); }
|
|
124
|
+
})
|
|
125
|
+
.catch((err) => {
|
|
126
|
+
step("✗ " + ((err && err.message) || String(err)), "err");
|
|
127
|
+
goDc.disabled = false;
|
|
128
|
+
prefetch(); // fresh request for the next attempt
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
btn.addEventListener("click", async () => {
|
|
133
|
+
btn.disabled = true;
|
|
134
|
+
try {
|
|
135
|
+
step("→ verify (presence-only, amount-bound) · Settling via x402 on Hedera testnet (if configured)… can take ~10s");
|
|
136
|
+
settling.classList.add("on");
|
|
137
|
+
const out = await fetch("/attestomcp/dc-payment/verify", {
|
|
138
|
+
method: "POST", headers: { "Content-Type": "application/json" },
|
|
139
|
+
body: JSON.stringify({ order: ORDER, amount: AMOUNT, claims: DEMO_CLAIMS }),
|
|
140
|
+
}).then((r) => r.json()).finally(() => settling.classList.remove("on"));
|
|
141
|
+
if (!out.mandate) throw new Error(out.error || "authorization failed");
|
|
142
|
+
step("✓ presentation verified · mandate built (" + out.mandate.trust_level + ")", "ok");
|
|
143
|
+
renderReceipt(out);
|
|
144
|
+
// Configured-but-failed settle: authorized, not settled — let the buyer retry.
|
|
145
|
+
if (out.settlementError) { step("✗ settlement failed — authorized, not settled (retry below)", "err"); btn.disabled = false; }
|
|
146
|
+
} catch (err) {
|
|
147
|
+
step("✗ " + (err?.message ?? String(err)), "err");
|
|
148
|
+
btn.disabled = false;
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
function renderReceipt(out) {
|
|
153
|
+
const el = document.getElementById("receipt");
|
|
154
|
+
const gates = out.gates.map((g) => '<div class="gate ' + (g.pass ? "pass" : "fail") + '">' + (g.pass ? "✓" : "✗") + " " + g.gate + " — " + g.detail + "</div>").join("");
|
|
155
|
+
// The x402 on-chain settlement receipt. When the host settled (Hedera/blocky402),
|
|
156
|
+
// show the actual tinybar amount, payer/merchant, speed, tx, and a PROMINENT
|
|
157
|
+
// tappable HashScan link — the third-party proof the buyer (on their phone) taps
|
|
158
|
+
// straight into the live explorer. A configured-but-failed settle is the calm
|
|
159
|
+
// "authorized, not settled" line (FR-013) — never an alarming wall.
|
|
160
|
+
const s = out.settlement;
|
|
161
|
+
const settlement = s
|
|
162
|
+
? '<div class="settle"><div class="settle-head">✓ Settled via x402 on Hedera testnet</div>' +
|
|
163
|
+
'<dl class="kv">' +
|
|
164
|
+
"<dt>Amount</dt><dd>" + (s.amountTinybar / 1e8) + ' ℏ <span class="dim">(' + esc(s.fxRate) + ")</span></dd>" +
|
|
165
|
+
"<dt>From</dt><dd>" + esc(s.payer.accountId) + ' <span class="dim">' +
|
|
166
|
+
(s.payer.kind === "session-wallet"
|
|
167
|
+
? "wallet created for this order, " + (s.walletAgeMs / 1000).toFixed(1) + "s old when it paid"
|
|
168
|
+
: "demo customer") + "</span></dd>" +
|
|
169
|
+
"<dt>To</dt><dd>" + esc(s.payTo) + ' <span class="dim">merchant</span></dd>' +
|
|
170
|
+
"<dt>Speed</dt><dd>settled in " + (s.settledInMs / 1000).toFixed(1) + "s</dd>" +
|
|
171
|
+
'<dt>Tx</dt><dd><span class="mono">' + esc(s.txId) + "</span></dd>" +
|
|
172
|
+
"</dl>" +
|
|
173
|
+
'<a class="hashscan" href="' + esc(s.hashscanUrl) + '" target="_blank" rel="noopener">View on HashScan ›</a>' +
|
|
174
|
+
"</div>"
|
|
175
|
+
: out.settlementError
|
|
176
|
+
? '<div class="settle-failed">✗ Settlement failed — authorized, not settled: ' + esc(out.settlementError) + "</div>"
|
|
177
|
+
: "";
|
|
178
|
+
// Every gate + payment is done ⇒ the order is COMPLETE. Lead with the prominent
|
|
179
|
+
// handoff: close this window and continue in the agent (the MCP host polls
|
|
180
|
+
// order-status and resumes). No auto-redirect — we don't yank the buyer off the
|
|
181
|
+
// "you're done" message; the on-chain proof + a secondary return link stay below.
|
|
182
|
+
const done = out.completed ? DONE_BANNER : "";
|
|
183
|
+
el.innerHTML = done + '<div class="row-ok">✓ Payment Mandate authorized (amount-bound)</div>' +
|
|
184
|
+
'<div class="small" style="margin:4px 0 8px;">' + out.mandate.id + "</div>" + gates + settlement;
|
|
185
|
+
el.style.display = "block";
|
|
186
|
+
if (out.completed) {
|
|
187
|
+
goDc.disabled = true;
|
|
188
|
+
btn.textContent = "Authorized ✓";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
</script>
|
|
192
|
+
</div>
|
|
193
|
+
</body>
|
|
194
|
+
</html>`;
|
|
195
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { CeremonyOrder } from "../types.js";
|
|
2
|
+
import type { Origin } from "../origin.js";
|
|
3
|
+
import type { DcqlQuery } from "../../types.js";
|
|
4
|
+
export interface SignedDcPaymentRequest {
|
|
5
|
+
protocol: "openid4vp-v1-signed";
|
|
6
|
+
/** The ES256-signed OpenID4VP request JWT (real). */
|
|
7
|
+
request: string;
|
|
8
|
+
/** The DCQL embedded in the signed request (echoed for callers/tests). */
|
|
9
|
+
dcql_query: DcqlQuery;
|
|
10
|
+
/** The amount-bound transaction_data entries (base64url) — REAL binding. */
|
|
11
|
+
transaction_data: string[];
|
|
12
|
+
/** Sealed reader context (ECDH key + bound transaction_data) carried to /verify. */
|
|
13
|
+
readerContextToken: string;
|
|
14
|
+
trust_level: "presence-only-demo";
|
|
15
|
+
}
|
|
16
|
+
/** Build the REAL signed OpenID4VP request for the payment credential. */
|
|
17
|
+
export declare function buildDcPaymentRequest(order: CeremonyOrder, origin: Origin, secret: string): Promise<SignedDcPaymentRequest>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// REAL signed OpenID4VP request for the dc-payment rail. Faithfully ported from the
|
|
2
|
+
// demo's payment-gate/dc-payment/request.ts. It mints a reader cert
|
|
3
|
+
// (@peculiar/x509), an ephemeral ECDH response-encryption key, a fresh nonce, embeds
|
|
4
|
+
// the amount-bound `transaction_data` (txData.ts), and ES256-signs the verifier-bound
|
|
5
|
+
// request object (jose.SignJWT). The transaction_data — and the ECDH key — are sealed
|
|
6
|
+
// into a reader context (a JWE) so /verify re-checks the wallet's device-signed
|
|
7
|
+
// transaction_data_hash against SHA-256 of exactly what we sent.
|
|
8
|
+
//
|
|
9
|
+
// The crypto here is REAL (signed request, amount binding, origin/RP binding, sealed
|
|
10
|
+
// key); the issuer TRUST ANCHOR is not (the reader cert is self-signed) — trust_level
|
|
11
|
+
// stays presence-only-demo.
|
|
12
|
+
import * as jose from "jose";
|
|
13
|
+
import { buildDcPaymentDcql } from "./dcql.js";
|
|
14
|
+
import { buildTransactionData, encodeTransactionData } from "./txData.js";
|
|
15
|
+
import { makeReaderCert, makeEncryptionKey } from "../mdoc/reader.js";
|
|
16
|
+
import { sealReaderContext } from "../mdoc/readerContext.js";
|
|
17
|
+
/** Build the REAL signed OpenID4VP request for the payment credential. */
|
|
18
|
+
export async function buildDcPaymentRequest(order, origin, secret) {
|
|
19
|
+
const { x5c, privateKey } = await makeReaderCert(origin.rpID);
|
|
20
|
+
const { encJwk, ecdhPrivateJwk } = await makeEncryptionKey();
|
|
21
|
+
const dcql = buildDcPaymentDcql();
|
|
22
|
+
const txDataB64 = encodeTransactionData(buildTransactionData(order, origin));
|
|
23
|
+
const nonce = jose.base64url.encode(crypto.getRandomValues(new Uint8Array(16)));
|
|
24
|
+
const requestObject = {
|
|
25
|
+
response_type: "vp_token",
|
|
26
|
+
response_mode: "dc_api.jwt",
|
|
27
|
+
client_id: `x509_san_dns:${origin.rpID}`,
|
|
28
|
+
expected_origins: [origin.origin],
|
|
29
|
+
nonce,
|
|
30
|
+
dcql_query: dcql,
|
|
31
|
+
client_metadata: {
|
|
32
|
+
vp_formats_supported: { mso_mdoc: { issuerauth_alg_values: [-7], deviceauth_alg_values: [-7] } },
|
|
33
|
+
jwks: { keys: [encJwk] },
|
|
34
|
+
},
|
|
35
|
+
transaction_data: [txDataB64],
|
|
36
|
+
};
|
|
37
|
+
const request = await new jose.SignJWT(requestObject)
|
|
38
|
+
.setProtectedHeader({ alg: "ES256", typ: "oauth-authz-req+jwt", x5c: [x5c] })
|
|
39
|
+
.setIssuedAt()
|
|
40
|
+
.sign(privateKey);
|
|
41
|
+
const readerContextToken = await sealReaderContext({ ecdhPrivateJwk, transactionDataB64: txDataB64, nonce }, secret);
|
|
42
|
+
return {
|
|
43
|
+
protocol: "openid4vp-v1-signed",
|
|
44
|
+
request,
|
|
45
|
+
dcql_query: dcql,
|
|
46
|
+
transaction_data: [txDataB64],
|
|
47
|
+
readerContextToken,
|
|
48
|
+
trust_level: "presence-only-demo",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The dc-payment rail (Digital Credentials API + OpenID4VP, amount-bound) — US3.
|
|
2
|
+
// Registers its routes onto the host app through the Foundational mount() seam:
|
|
3
|
+
// GET /attestomcp/dc-payment?order=<id> → the payment gate page
|
|
4
|
+
// GET /attestomcp/dc-payment/request?order=<id> → REAL signed OpenID4VP request (+ amount-bound transaction_data)
|
|
5
|
+
// POST /attestomcp/dc-payment/verify → instant-demo claims OR a real presentation → SHARED completeOrder
|
|
6
|
+
//
|
|
7
|
+
// Dependency-free (no `express` import — invariant from mount.ts): handlers register
|
|
8
|
+
// against the structural CeremonyApp.get/post, and the verify body is read either
|
|
9
|
+
// from a host-installed body parser (`req.body`) or straight off the request stream.
|
|
10
|
+
//
|
|
11
|
+
// EVERY route resolves the order by id THROUGH `resolveOrder` (catalog re-pricing; a
|
|
12
|
+
// tampered/unknown id is refused — CT3, invariant 2). Completion goes through the
|
|
13
|
+
// injected `ctx.completion` seam — the SAME shared `completeOrder` the passkey rail
|
|
14
|
+
// uses (no second completion path — FR-008, CT8): it re-prices, enforces the age
|
|
15
|
+
// gate, settles (when configured), records idempotently, and clears the cart +
|
|
16
|
+
// per-order verification.
|
|
17
|
+
//
|
|
18
|
+
// Verify has TWO paths feeding ONE set of gates + the SAME completion seam:
|
|
19
|
+
// • instant-demo — body carries `claims` directly (the tested default; CT6–CT8).
|
|
20
|
+
// • real presentation — body carries `result` from navigator.credentials.get; the
|
|
21
|
+
// wallet's JWE response is decrypted, the device-signed transaction_data_hash is
|
|
22
|
+
// re-checked against what we sealed, and the parsed mdoc drives the gates. The
|
|
23
|
+
// wire crypto is REAL; the issuer trust anchor is not (presence-only-demo).
|
|
24
|
+
import { resolveOrder } from "../mount.js";
|
|
25
|
+
import { buildDcPaymentRequest } from "./request.js";
|
|
26
|
+
import { buildDcMandate, runDcGates, verifyDcPresentation } from "./verify.js";
|
|
27
|
+
import { renderDcPaymentPage } from "./page.js";
|
|
28
|
+
function firstHeader(value) {
|
|
29
|
+
return Array.isArray(value) ? value[0] : value;
|
|
30
|
+
}
|
|
31
|
+
function originOf(ctx, req) {
|
|
32
|
+
const reqLike = { headers: req.headers, host: firstHeader(req.headers.host) ?? "localhost", protocol: req.protocol };
|
|
33
|
+
return ctx.origin(reqLike);
|
|
34
|
+
}
|
|
35
|
+
// Read the JSON body from a host-installed parser, or straight off the stream when
|
|
36
|
+
// no parser ran (so the rail is self-contained — it doesn't require the host to
|
|
37
|
+
// mount express.json()).
|
|
38
|
+
async function readJsonBody(req) {
|
|
39
|
+
if (req.body && typeof req.body === "object")
|
|
40
|
+
return req.body;
|
|
41
|
+
try {
|
|
42
|
+
const chunks = [];
|
|
43
|
+
for await (const chunk of req) {
|
|
44
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
45
|
+
}
|
|
46
|
+
if (chunks.length === 0)
|
|
47
|
+
return {};
|
|
48
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export const registerDcPaymentGate = (app, ctx) => {
|
|
55
|
+
// The Foundational fail-fast tests mount() with a route-less app shape; only
|
|
56
|
+
// attach when the host app can actually route (CeremonyApp.get/post are optional).
|
|
57
|
+
const get = app.get?.bind(app);
|
|
58
|
+
const post = app.post?.bind(app);
|
|
59
|
+
if (!get || !post)
|
|
60
|
+
return;
|
|
61
|
+
// GET the gate page — re-priced order, presence-only honesty banner.
|
|
62
|
+
get("/attestomcp/dc-payment", async (req, res) => {
|
|
63
|
+
const order = await resolveOrder(ctx, typeof req.query.order === "string" ? req.query.order : undefined);
|
|
64
|
+
if (!order) {
|
|
65
|
+
res.status(404).type("html").send("<!doctype html><h1>Order not found</h1>");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
res.status(200).type("html").send(renderDcPaymentPage({
|
|
69
|
+
order: order.id,
|
|
70
|
+
total: order.total,
|
|
71
|
+
currency: order.currency,
|
|
72
|
+
lines: order.lines.map((l) => ({ name: l.name ?? l.id, quantity: l.quantity, lineTotal: l.lineTotal, currency: l.currency ?? order.currency })),
|
|
73
|
+
}));
|
|
74
|
+
});
|
|
75
|
+
// GET the REAL signed OpenID4VP request for this order — ES256-signed, carrying the
|
|
76
|
+
// amount-bound transaction_data, with the reader context (ECDH key + bound
|
|
77
|
+
// transaction_data) sealed for /verify.
|
|
78
|
+
get("/attestomcp/dc-payment/request", async (req, res) => {
|
|
79
|
+
const order = await resolveOrder(ctx, typeof req.query.order === "string" ? req.query.order : undefined);
|
|
80
|
+
if (!order) {
|
|
81
|
+
res.status(404).json({ error: "order not found" });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
res.json(await buildDcPaymentRequest(order, originOf(ctx, req), ctx.signingKey));
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
res.status(500).json({ error: err.message });
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
// POST verify — instant-demo claims OR a real wallet presentation. Resolve +
|
|
92
|
+
// re-price the order (CT3), build the amount-bound mandate, run the four
|
|
93
|
+
// deterministic gates, and complete THROUGH the shared completeOrder seam
|
|
94
|
+
// (FR-008, CT8).
|
|
95
|
+
post("/attestomcp/dc-payment/verify", async (req, res) => {
|
|
96
|
+
const body = await readJsonBody(req);
|
|
97
|
+
const order = await resolveOrder(ctx, typeof body.order === "string" ? body.order : undefined);
|
|
98
|
+
if (!order) {
|
|
99
|
+
res.status(400).json({ completed: false, error: "missing or invalid order" });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const origin = originOf(ctx, req);
|
|
103
|
+
let mandate;
|
|
104
|
+
let gates;
|
|
105
|
+
try {
|
|
106
|
+
const result = body.result;
|
|
107
|
+
if (result && typeof result === "object") {
|
|
108
|
+
// REAL OpenID4VP presentation — decrypt the wallet's response, re-check the
|
|
109
|
+
// device-signed transaction_data_hash, and run the gates.
|
|
110
|
+
if (typeof body.readerContextToken !== "string") {
|
|
111
|
+
res.status(400).json({ completed: false, error: "missing readerContextToken for openid4vp presentation" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const out = await verifyDcPresentation({ order, origin, result, readerContextToken: body.readerContextToken, secret: ctx.signingKey });
|
|
115
|
+
mandate = out.mandate;
|
|
116
|
+
gates = out.gates;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// Instant-demo claims path (the tested default).
|
|
120
|
+
const claims = (body.claims && typeof body.claims === "object" ? body.claims : {});
|
|
121
|
+
const presentedAmount = typeof body.amount === "number" ? body.amount : order.total;
|
|
122
|
+
mandate = buildDcMandate({ order, origin, claims, presentedAmount });
|
|
123
|
+
gates = runDcGates(mandate, origin);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (err) {
|
|
127
|
+
res.status(400).json({ completed: false, error: err.message, trust_level: "presence-only-demo" });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Complete through the SHARED seam (idempotent record + re-price + age gate +
|
|
131
|
+
// optional settle + clear cart & per-order verification). No second path.
|
|
132
|
+
const input = {
|
|
133
|
+
order,
|
|
134
|
+
mandateId: mandate.id,
|
|
135
|
+
amount: mandate.payment.amount,
|
|
136
|
+
currency: mandate.payment.currency,
|
|
137
|
+
method: "dc-payment",
|
|
138
|
+
instrument: mandate.payment.instrument,
|
|
139
|
+
gates,
|
|
140
|
+
};
|
|
141
|
+
const result = await ctx.completion(input);
|
|
142
|
+
// Forward the on-chain settlement (when configured + succeeded) AND the
|
|
143
|
+
// settlementError (a configured-but-failed settle → authorized-but-not-settled,
|
|
144
|
+
// FR-013) so the page can render the x402 receipt or the calm refusal line.
|
|
145
|
+
res.json({ mandate, gates, completed: result.completed, ...(result.reason ? { reason: result.reason } : {}), ...(result.settlement ? { settlement: result.settlement } : {}), ...(result.settlementError ? { settlementError: result.settlementError } : {}) });
|
|
146
|
+
});
|
|
147
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { CeremonyOrder } from "../types.js";
|
|
2
|
+
import type { Origin } from "../origin.js";
|
|
3
|
+
export interface TransactionData {
|
|
4
|
+
type: "urn:eudi:sca:payment:1";
|
|
5
|
+
credential_ids: string[];
|
|
6
|
+
payload: {
|
|
7
|
+
transaction_id: string;
|
|
8
|
+
amount: number;
|
|
9
|
+
currency: string;
|
|
10
|
+
payee: {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export declare function buildTransactionData(order: CeremonyOrder, origin: Origin): TransactionData;
|
|
17
|
+
export declare function encodeTransactionData(txData: TransactionData): string;
|
|
18
|
+
export declare function hashTransactionData(txDataB64: string): string;
|
|
19
|
+
export declare function decodeTransactionData(txDataB64: string): TransactionData;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Single source of truth for the OpenID4VP transaction_data entry (amount binding).
|
|
2
|
+
// Extracted from the demo's payment-gate/dc-payment/txData.ts, but DEPENDENCY-FREE:
|
|
3
|
+
// `jose.base64url.encode` becomes a Buffer base64url and the SHA-256 stays on
|
|
4
|
+
// node:crypto. Amount + payee come from the order + origin via the shared
|
|
5
|
+
// `buildBindingFields`, so the hash the wallet would sign is derived from the SAME
|
|
6
|
+
// fields Gate 1 (verify.ts#runDcGates) re-checks. The wallet's SIGNATURE over this
|
|
7
|
+
// hash is the PR-in-flight crypto (request.ts scaffolds the signed request); the
|
|
8
|
+
// binding itself is real here.
|
|
9
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
10
|
+
import { buildBindingFields } from "../mandate.js";
|
|
11
|
+
export function buildTransactionData(order, origin) {
|
|
12
|
+
const b = buildBindingFields(order, origin);
|
|
13
|
+
return {
|
|
14
|
+
type: "urn:eudi:sca:payment:1",
|
|
15
|
+
credential_ids: ["dpc"],
|
|
16
|
+
payload: {
|
|
17
|
+
transaction_id: randomUUID(),
|
|
18
|
+
amount: b.amount,
|
|
19
|
+
currency: b.currency,
|
|
20
|
+
payee: b.payee,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function encodeTransactionData(txData) {
|
|
25
|
+
return Buffer.from(JSON.stringify(txData), "utf8").toString("base64url");
|
|
26
|
+
}
|
|
27
|
+
// SHA-256 of the base64url transaction_data string, itself base64url. This is the
|
|
28
|
+
// value the wallet signs over (transaction_data_hash) and Gate 1 re-derives.
|
|
29
|
+
export function hashTransactionData(txDataB64) {
|
|
30
|
+
return createHash("sha256").update(txDataB64).digest("base64url");
|
|
31
|
+
}
|
|
32
|
+
export function decodeTransactionData(txDataB64) {
|
|
33
|
+
return JSON.parse(Buffer.from(txDataB64, "base64url").toString("utf8"));
|
|
34
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { CeremonyOrder } from "../types.js";
|
|
2
|
+
import type { Origin } from "../origin.js";
|
|
3
|
+
export interface DcInstrument {
|
|
4
|
+
issuer: string | null;
|
|
5
|
+
instrumentId: string | null;
|
|
6
|
+
maskedAccount: string | null;
|
|
7
|
+
holder: string | null;
|
|
8
|
+
expiry: string | null;
|
|
9
|
+
}
|
|
10
|
+
export interface DcMandate {
|
|
11
|
+
type: "ap2.PaymentMandate";
|
|
12
|
+
version: "0.1-dc-demo";
|
|
13
|
+
id: string;
|
|
14
|
+
issuedAt: string;
|
|
15
|
+
expiresAt: string;
|
|
16
|
+
issuer: string;
|
|
17
|
+
subject: {
|
|
18
|
+
credentialId: string | null;
|
|
19
|
+
};
|
|
20
|
+
cart: CeremonyOrder;
|
|
21
|
+
payment: {
|
|
22
|
+
instrument: DcInstrument;
|
|
23
|
+
amount: number;
|
|
24
|
+
currency: string;
|
|
25
|
+
};
|
|
26
|
+
userAuthorization: {
|
|
27
|
+
type: "openid4vp-dc-api";
|
|
28
|
+
/** The amount-bound transaction_data the wallet would sign over (base64url). */
|
|
29
|
+
transactionData: string;
|
|
30
|
+
/**
|
|
31
|
+
* The transaction_data_hash Gate 1 re-checks against SHA-256(transactionData).
|
|
32
|
+
* Instant-demo: the server's own hash (always matches). REAL path: the value
|
|
33
|
+
* EXTRACTED from the wallet's device-signed DeviceResponse — so Gate 1 verifies
|
|
34
|
+
* the wallet actually authorized THIS amount/payee.
|
|
35
|
+
*/
|
|
36
|
+
transactionDataHash: string | null;
|
|
37
|
+
/** Presence-only: the instrument was disclosed but not cryptographically verified. */
|
|
38
|
+
presented: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* REAL path only — the wallet's raw mdoc vp_token (base64url DeviceResponse) and
|
|
41
|
+
* the structural issuerAuth/deviceAuth presence Gate 2 reads. Absent on the
|
|
42
|
+
* instant-demo path (Gate 2 falls back to instrument presence there).
|
|
43
|
+
*/
|
|
44
|
+
vpToken?: string;
|
|
45
|
+
authBlocks?: {
|
|
46
|
+
hasIssuerAuth: boolean;
|
|
47
|
+
hasDeviceAuth: boolean;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
trust_level: "presence-only-demo";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Build the presence-only DC payment mandate. The transaction_data is derived from
|
|
54
|
+
* the (catalog-re-priced) order + this RP's origin, so its amount/payee are the
|
|
55
|
+
* server's truth; `presentedAmount` is what the caller asserts authorizing (Gate 1
|
|
56
|
+
* re-checks it equals the re-derived payable — a tampered value is refused).
|
|
57
|
+
*/
|
|
58
|
+
export declare function buildDcMandate(args: {
|
|
59
|
+
order: CeremonyOrder;
|
|
60
|
+
origin: Origin;
|
|
61
|
+
claims: Record<string, unknown>;
|
|
62
|
+
presentedAmount?: number;
|
|
63
|
+
issuer?: string;
|
|
64
|
+
}): DcMandate;
|
|
65
|
+
export interface GateResult {
|
|
66
|
+
gate: string;
|
|
67
|
+
pass: boolean;
|
|
68
|
+
detail: string;
|
|
69
|
+
}
|
|
70
|
+
export declare function runDcGates(mandate: DcMandate, origin: Origin, opts?: {
|
|
71
|
+
loyaltyDiscountPct?: number;
|
|
72
|
+
}): GateResult[];
|
|
73
|
+
/**
|
|
74
|
+
* Build the DC mandate from a REAL wallet DeviceResponse. Unlike the instant-demo
|
|
75
|
+
* builder, `transactionDataHash` is the value EXTRACTED from the wallet's
|
|
76
|
+
* device-signed mdoc (deviceSigned/transaction_data_hash) — Gate 1 then re-checks it
|
|
77
|
+
* equals SHA-256 of the transaction_data WE sealed (transactionDataB64). The vpToken
|
|
78
|
+
* + parsed issuerAuth/deviceAuth presence drive Gate 2. The instrument fields come
|
|
79
|
+
* from the issuer-signed namespaces of the SAME DeviceResponse.
|
|
80
|
+
*/
|
|
81
|
+
export declare function buildDcMandateFromPresentation(args: {
|
|
82
|
+
order: CeremonyOrder;
|
|
83
|
+
vpStr: string;
|
|
84
|
+
transactionDataB64: string;
|
|
85
|
+
issuer?: string;
|
|
86
|
+
}): DcMandate;
|
|
87
|
+
export interface DcVerification {
|
|
88
|
+
mandate: DcMandate;
|
|
89
|
+
gates: GateResult[];
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Verify the wallet's REAL OpenID4VP presentation: open the sealed reader context,
|
|
93
|
+
* JWE-decrypt the response (jose ECDH-ES compactDecrypt), pull the mdoc vp_token,
|
|
94
|
+
* build the amount-bound mandate from the device-signed DeviceResponse, and run the
|
|
95
|
+
* four gates. Faithfully ported from the demo's payment-gate/dc-payment/verify.ts.
|
|
96
|
+
* The crypto (decryption + the transaction_data hash binding) is REAL; the issuer
|
|
97
|
+
* trust anchor is not (trust_level presence-only-demo).
|
|
98
|
+
*/
|
|
99
|
+
export declare function verifyDcPresentation(args: {
|
|
100
|
+
order: CeremonyOrder;
|
|
101
|
+
origin: Origin;
|
|
102
|
+
result: {
|
|
103
|
+
protocol?: string;
|
|
104
|
+
data?: unknown;
|
|
105
|
+
};
|
|
106
|
+
readerContextToken: string;
|
|
107
|
+
secret: string;
|
|
108
|
+
}): Promise<DcVerification>;
|