@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,136 @@
1
+ import { pageHead, brandHeader, progressRail, trustFooter } from "../theme.js";
2
+ function escapeHtml(s) {
3
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4
+ }
5
+ export function renderCredentialPage(args) {
6
+ const minimumAge = args.minimumAge ?? 21;
7
+ const percent = args.percent ?? 10;
8
+ const isAge = args.kind === "age";
9
+ const title = isAge ? `Verify your age (${minimumAge}+)` : "Apply membership discount";
10
+ const lede = isAge
11
+ ? `Your cart contains age-restricted items. Present a digital ID so we can confirm you are ${minimumAge} or older. Nothing is stored — only an over-${minimumAge} check.`
12
+ : `Present your membership credential to take ${percent}% off your cart. Optional — your purchase works without it.`;
13
+ const cta = isAge ? `Verify with my digital ID` : `Present membership credential`;
14
+ const demoCta = isAge ? `Verify age (instant demo)` : `Apply membership (instant demo)`;
15
+ // The canonical positive claim the instant-demo button presents — it goes
16
+ // through the SAME server-side explicit-positive-claim check as a real wallet.
17
+ const demoClaims = isAge ? { [`age_over_${minimumAge}`]: true } : { membership_number: "DEMO-MEMBER-0001" };
18
+ const totalLine = args.total != null ? `<p class="small amount">Order ${escapeHtml(args.order)} · ${escapeHtml(args.currency ?? "USD")} ${args.total}</p>` : "";
19
+ const returnUrl = args.returnUrl ?? `/checkout?order=${encodeURIComponent(args.order)}`;
20
+ // Identity-first tagline + the progress rail with THIS gate marked current. The age
21
+ // gate is step 0 (Age) of Age · Membership · Pay; membership is the middle step.
22
+ const tagline = isAge ? "Present a digital ID" : "Present a membership credential";
23
+ const rail = isAge
24
+ ? progressRail([{ label: "Age" }, { label: "Membership" }, { label: "Pay" }], 0)
25
+ : progressRail([{ label: "Age", done: true }, { label: "Membership" }, { label: "Pay" }], 1);
26
+ // The PAGE-LOCAL extra styles: the calm gate-page chrome (verify log + the success
27
+ // banner) layered over the shared design system. The verify-progress rows reuse the
28
+ // shared `.step` styling; only the `#done` banner is page-specific.
29
+ const extraCss = `
30
+ .amount { font-variant-numeric: tabular-nums; }
31
+ #done { display:none; margin-top:16px; background:var(--accent); color:#fff; font-weight:700; padding:16px; border-radius:12px; text-align:center; }
32
+ #done a { color:#fff; text-decoration:underline; }`;
33
+ return `<!doctype html>
34
+ <html lang="en">
35
+ ${pageHead(title, extraCss)}
36
+ <body>
37
+ <div class="wrap">
38
+ ${brandHeader({ h1: title, tagline })}
39
+ ${rail}
40
+ <div class="card">
41
+ <p class="lede">${escapeHtml(lede)}</p>
42
+ ${totalLine}
43
+ <button id="go-dc" class="btn btn-primary">${escapeHtml(cta)}</button>
44
+ <button id="go" class="btn btn-secondary">${escapeHtml(demoCta)}</button>
45
+ <div id="log"></div>
46
+ </div>
47
+ <div id="done">✓ Done — returning to checkout… <a id="back" href="${escapeHtml(returnUrl)}">continue now ›</a></div>
48
+ ${trustFooter()}
49
+ <script type="module">
50
+ const ORDER = ${JSON.stringify(args.order)};
51
+ const CRED = ${JSON.stringify(args.kind)};
52
+ const DEMO_CLAIMS = ${JSON.stringify(demoClaims)};
53
+ const RETURN_URL = ${JSON.stringify(returnUrl)};
54
+ const log = document.getElementById("log");
55
+ const goDc = document.getElementById("go-dc");
56
+ const go = document.getElementById("go");
57
+ const doneEl = document.getElementById("done");
58
+ const step = (t, c = "") => { const d = document.createElement("div"); d.className = "step " + c; d.textContent = t; log.appendChild(d); };
59
+ function notice(html) { const d = document.createElement("div"); d.className = "notice"; d.innerHTML = html; log.appendChild(d); }
60
+ function done() {
61
+ goDc.disabled = true; go.disabled = true;
62
+ doneEl.style.display = "block";
63
+ // Return to the checkout hub so the next gate is one tap away (no manual
64
+ // browser-back). The hub re-reads verification state and shows this gate ✓.
65
+ setTimeout(() => { window.location.assign(RETURN_URL); }, 650);
66
+ }
67
+
68
+ // Pre-fetch the REAL OpenID4VP + org-iso-mdoc request so navigator.credentials.get()
69
+ // can be called SYNCHRONOUSLY inside the tap. iOS WebKit drops the transient user
70
+ // activation across an await, so we must not fetch between the click and get(). We
71
+ // keep a fresh pre-fetched request ready at all times. location.search carries the
72
+ // order + cred this gate is scoped to, so /request re-prices THIS order.
73
+ let reqData = null;
74
+ function prefetch() {
75
+ reqData = null;
76
+ fetch("/attestomcp/credential/request" + location.search).then((r) => r.json()).then((d) => { reqData = d; }).catch(() => {});
77
+ }
78
+
79
+ if (!navigator.credentials || !navigator.credentials.get) {
80
+ goDc.disabled = true;
81
+ notice("This browser doesn't support the Digital Credentials API (needs Chrome 141+/Android or iOS 18+). Use the <strong>instant demo</strong> button.");
82
+ } else {
83
+ prefetch();
84
+ }
85
+
86
+ goDc.addEventListener("click", () => {
87
+ if (!navigator.credentials || !navigator.credentials.get) {
88
+ notice("This browser doesn't support the Digital Credentials API. Use the instant-demo button.");
89
+ return;
90
+ }
91
+ if (!reqData || !reqData.requests) { notice("Preparing the request — tap again in a second."); prefetch(); return; }
92
+ goDc.disabled = true;
93
+ const rd = reqData;
94
+ step("→ navigator.credentials.get({digital}) — choose your wallet…");
95
+ // Called synchronously (no await before it) to keep the user activation.
96
+ navigator.credentials.get({ digital: { requests: rd.requests }, mediation: "required" })
97
+ .then(async (result) => {
98
+ let data = result && result.data != null ? result.data : null;
99
+ if (typeof data === "string") { try { data = JSON.parse(data); } catch (e) {} }
100
+ step("→ verify (" + ((result && result.protocol) || "?") + ")");
101
+ const out = await fetch("/attestomcp/credential/verify", {
102
+ method: "POST", headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify({ order: ORDER, cred: CRED, readerContextToken: rd.readerContextToken, mdocContextToken: rd.mdocContextToken, result: { protocol: (result && result.protocol) || null, data } }),
104
+ }).then((r) => r.json());
105
+ if (!out.verified) throw new Error(out.error || "not verified");
106
+ step("✓ verified (" + out.trust_level + ")", "ok");
107
+ done();
108
+ })
109
+ .catch((err) => {
110
+ step("✗ " + ((err && err.message) || String(err)), "err");
111
+ goDc.disabled = false;
112
+ prefetch(); // fresh request for the next attempt
113
+ });
114
+ });
115
+
116
+ go.addEventListener("click", async () => {
117
+ go.disabled = true;
118
+ try {
119
+ step("→ verify (presence-only)");
120
+ const out = await fetch("/attestomcp/credential/verify", {
121
+ method: "POST", headers: { "Content-Type": "application/json" },
122
+ body: JSON.stringify({ order: ORDER, cred: CRED, claims: DEMO_CLAIMS }),
123
+ }).then((r) => r.json());
124
+ if (!out.verified) throw new Error(out.error || "not verified");
125
+ step("✓ verified (" + out.trust_level + ")", "ok");
126
+ done();
127
+ } catch (err) {
128
+ step("✗ " + (err?.message ?? String(err)), "err");
129
+ go.disabled = false;
130
+ }
131
+ });
132
+ </script>
133
+ </div>
134
+ </body>
135
+ </html>`;
136
+ }
@@ -0,0 +1,15 @@
1
+ import type { Origin } from "../origin.js";
2
+ import { type CredentialDcqlOpts, type CredentialKind } from "./dcql.js";
3
+ import type { DcqlQuery } from "../../types.js";
4
+ export interface SignedCredentialRequest {
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
+ /** Sealed reader context (ECDH key + nonce) carried to /verify. */
11
+ readerContextToken: string;
12
+ trust_level: "presence-only-demo";
13
+ }
14
+ /** Build the REAL signed OpenID4VP request descriptor for one credential kind. */
15
+ export declare function buildCredentialRequest(kind: CredentialKind, origin: Origin, secret: string, opts?: CredentialDcqlOpts): Promise<SignedCredentialRequest>;
@@ -0,0 +1,43 @@
1
+ // REAL signed OpenID4VP request for a credential (age / membership) gate. Faithfully
2
+ // ported from the demo's payment-gate/credential-gate/request.ts — like the
3
+ // dc-payment request but with NO transaction_data (age/membership is not a payment,
4
+ // so there is no amount to bind). It mints a reader cert (@peculiar/x509), an
5
+ // ephemeral ECDH response-encryption key, a fresh nonce, and ES256-signs the
6
+ // verifier-bound request object (jose.SignJWT). The nonce is sealed alongside the
7
+ // decryption key (sealReaderContext, a JWE) so /verify can require the wallet's
8
+ // response to be bound to THIS request — not merely decryptable.
9
+ //
10
+ // The crypto here is REAL (signed request, origin/RP binding, sealed nonce + key);
11
+ // the issuer TRUST ANCHOR is not (the reader cert is self-signed) — trust_level
12
+ // stays presence-only-demo.
13
+ import * as jose from "jose";
14
+ import { makeReaderCert, makeEncryptionKey } from "../mdoc/reader.js";
15
+ import { sealReaderContext } from "../mdoc/readerContext.js";
16
+ import { buildCredentialDcql } from "./dcql.js";
17
+ /** Build the REAL signed OpenID4VP request descriptor for one credential kind. */
18
+ export async function buildCredentialRequest(kind, origin, secret, opts = {}) {
19
+ const { x5c, privateKey } = await makeReaderCert(origin.rpID);
20
+ const { encJwk, ecdhPrivateJwk } = await makeEncryptionKey();
21
+ const nonce = jose.base64url.encode(crypto.getRandomValues(new Uint8Array(16)));
22
+ const dcql = buildCredentialDcql(kind, opts);
23
+ const requestObject = {
24
+ response_type: "vp_token",
25
+ response_mode: "dc_api.jwt",
26
+ client_id: `x509_san_dns:${origin.rpID}`,
27
+ expected_origins: [origin.origin],
28
+ nonce,
29
+ dcql_query: dcql,
30
+ client_metadata: {
31
+ vp_formats_supported: { mso_mdoc: { issuerauth_alg_values: [-7], deviceauth_alg_values: [-7] } },
32
+ jwks: { keys: [encJwk] },
33
+ },
34
+ };
35
+ const request = await new jose.SignJWT(requestObject)
36
+ .setProtectedHeader({ alg: "ES256", typ: "oauth-authz-req+jwt", x5c: [x5c] })
37
+ .setIssuedAt()
38
+ .sign(privateKey);
39
+ // Seal the nonce alongside the decryption key so /verify can require the wallet's
40
+ // response to be bound to THIS request (apu/apv check), not just decrypt.
41
+ const readerContextToken = await sealReaderContext({ ecdhPrivateJwk, transactionDataB64: "", nonce }, secret);
42
+ return { protocol: "openid4vp-v1-signed", request, dcql_query: dcql, readerContextToken, trust_level: "presence-only-demo" };
43
+ }
@@ -0,0 +1,2 @@
1
+ import { type RailRegistrar } from "../mount.js";
2
+ export declare const registerCredentialGate: RailRegistrar;
@@ -0,0 +1,200 @@
1
+ // The credential-gate rail (age + membership) — the GDC-hero MVP. Registers its
2
+ // routes onto the host app through the Foundational mount() seam:
3
+ // GET /attestomcp/credential?order=<id>&cred=<age|membership> → the gate page
4
+ // GET /attestomcp/credential/request?order=<id>&cred=<…> → REAL OpenID4VP + org-iso-mdoc requests
5
+ // POST /attestomcp/credential/verify → instant-demo claims OR a real presentation
6
+ //
7
+ // Dependency-free (no `express` import — invariant from mount.ts): the handlers are
8
+ // registered against the structural CeremonyApp.get/post, and the verify body is
9
+ // read either from a host-installed body parser (`req.body`) or straight off the
10
+ // request stream — so the rail works whether or not the host mounts express.json().
11
+ //
12
+ // EVERY route resolves the order by id THROUGH `resolveOrder` (catalog re-pricing;
13
+ // a tampered/unknown id is refused — CT3, invariant 2), and the age threshold is
14
+ // re-derived from the catalog-priced lines, never the token (T013, invariant 5).
15
+ //
16
+ // Verification has TWO paths feeding ONE policy (evaluateCredential):
17
+ // • instant-demo — body carries `claims` directly (no wallet round-trip; the
18
+ // tested default for the e2e + bypass suite).
19
+ // • real presentation — body carries `result` from navigator.credentials.get; the
20
+ // wallet's response is JWE/HPKE-decrypted, nonce/origin-bound,
21
+ // and the ISO-mdoc DeviceResponse parsed before the same policy
22
+ // runs. Dispatched by the wallet's `result.protocol`
23
+ // (openid4vp → verifyCredentialPresentation; org-iso-mdoc →
24
+ // verifyMdocPresentation). The wire crypto is REAL; the issuer
25
+ // trust anchor is not (trust_level presence-only-demo).
26
+ //
27
+ // Enforcement (CT9 / invariant 1): the verify handler grants age ONLY on the
28
+ // explicit positive claim at the order's threshold; the OTHER half — refusing an
29
+ // unverified age-restricted order — lives in the shared `completeOrder` seam
30
+ // (completion.ts), so every payment rail honors it.
31
+ import { resolveOrder } from "../mount.js";
32
+ import { buildCredentialRequest } from "./request.js";
33
+ import { evaluateCredential, requiredAgeForOrder, verifyCredentialPresentation } from "./verify.js";
34
+ import { verifyMdocPresentation } from "./mdoc-verify.js";
35
+ import { buildMdocRequestParts, sealMdocContext } from "../mdoc/mdoc-iso.js";
36
+ import { mdocDocSpec } from "./doc-spec.js";
37
+ import { renderCredentialPage } from "./page.js";
38
+ function parseKind(raw) {
39
+ const value = Array.isArray(raw) ? raw[0] : raw;
40
+ return value === "age" || value === "membership" ? value : null;
41
+ }
42
+ function firstHeader(value) {
43
+ return Array.isArray(value) ? value[0] : value;
44
+ }
45
+ function originOf(ctx, req) {
46
+ const reqLike = { headers: req.headers, host: firstHeader(req.headers.host) ?? "localhost", protocol: req.protocol };
47
+ return ctx.origin(reqLike);
48
+ }
49
+ // Read the JSON body from a host-installed parser, or straight off the stream when
50
+ // no parser ran (so the rail is self-contained — it doesn't require the host to
51
+ // mount express.json()).
52
+ async function readJsonBody(req) {
53
+ if (req.body && typeof req.body === "object")
54
+ return req.body;
55
+ try {
56
+ const chunks = [];
57
+ for await (const chunk of req) {
58
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
59
+ }
60
+ if (chunks.length === 0)
61
+ return {};
62
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
63
+ }
64
+ catch {
65
+ return {};
66
+ }
67
+ }
68
+ // Persist a successful verification, scoped to THIS order (never process-global —
69
+ // invariant 4). Age writes the positive over-threshold claim; membership marks the
70
+ // loyalty discount, which resolveOrder/completeOrder then re-derive exactly once.
71
+ async function recordVerified(ctx, orderId, kind, membershipNumber) {
72
+ const prev = (await ctx.verificationStore.read(orderId)) ?? {};
73
+ if (kind === "age") {
74
+ await ctx.verificationStore.write(orderId, { ...prev, ageVerified: true });
75
+ }
76
+ else {
77
+ await ctx.verificationStore.write(orderId, { ...prev, loyalty: { applied: true, membershipNumber } });
78
+ }
79
+ }
80
+ // The membership discount percent the order applies, re-derived from the re-priced
81
+ // order (never the token) — used to surface the membership detail.
82
+ function percentFor(order) {
83
+ return order.discount > 0 && order.subtotal > 0 ? Math.round((order.discount / order.subtotal) * 100) : undefined;
84
+ }
85
+ export const registerCredentialGate = (app, ctx) => {
86
+ // The Foundational fail-fast tests mount() with a route-less app shape; only
87
+ // attach when the host app can actually route (CeremonyApp.get/post are optional).
88
+ const get = app.get?.bind(app);
89
+ const post = app.post?.bind(app);
90
+ if (!get || !post)
91
+ return;
92
+ // GET the gate page — re-priced order, presence-only honesty banner.
93
+ get("/attestomcp/credential", async (req, res) => {
94
+ const kind = parseKind(req.query.cred);
95
+ if (!kind) {
96
+ res.status(404).type("html").send("<!doctype html><h1>Unknown credential</h1>");
97
+ return;
98
+ }
99
+ const order = await resolveOrder(ctx, typeof req.query.order === "string" ? req.query.order : undefined);
100
+ if (!order) {
101
+ res.status(404).type("html").send("<!doctype html><h1>Order not found</h1>");
102
+ return;
103
+ }
104
+ res.status(200).type("html").send(renderCredentialPage({
105
+ kind,
106
+ order: order.id,
107
+ minimumAge: requiredAgeForOrder(order) ?? undefined,
108
+ total: order.total,
109
+ currency: order.currency,
110
+ percent: percentFor(order),
111
+ }));
112
+ });
113
+ // GET the REAL request. Offer BOTH protocols; the platform's DC API self-selects
114
+ // the one it supports (Android Chrome → openid4vp, iOS WebKit → org-iso-mdoc).
115
+ get("/attestomcp/credential/request", async (req, res) => {
116
+ const kind = parseKind(req.query.cred);
117
+ if (!kind) {
118
+ res.status(404).json({ error: "unknown credential" });
119
+ return;
120
+ }
121
+ const order = await resolveOrder(ctx, typeof req.query.order === "string" ? req.query.order : undefined);
122
+ if (!order) {
123
+ res.status(404).json({ error: "order not found" });
124
+ return;
125
+ }
126
+ try {
127
+ const minimumAge = kind === "age" ? requiredAgeForOrder(order) ?? 21 : undefined;
128
+ const reqOrigin = originOf(ctx, req);
129
+ const oid = await buildCredentialRequest(kind, reqOrigin, ctx.signingKey, { minimumAge });
130
+ // Signed (reader-authenticated) by default — required by iOS. ?signed=0 forces
131
+ // the unsigned path for diagnostics.
132
+ const signed = req.query.signed !== "0";
133
+ const mdoc = await buildMdocRequestParts(mdocDocSpec(kind, minimumAge ?? 21), reqOrigin.origin, signed);
134
+ const mdocContextToken = await sealMdocContext({ readerPrivateJwk: mdoc.readerPrivateJwk, base64EncryptionInfo: mdoc.base64EncryptionInfo }, ctx.signingKey);
135
+ res.json({
136
+ requests: [
137
+ { protocol: "openid4vp-v1-signed", data: { request: oid.request } },
138
+ { protocol: "org-iso-mdoc", data: mdoc.data },
139
+ ],
140
+ dcql_query: oid.dcql_query,
141
+ readerContextToken: oid.readerContextToken,
142
+ mdocContextToken,
143
+ trust_level: oid.trust_level,
144
+ });
145
+ }
146
+ catch (err) {
147
+ res.status(500).json({ error: err.message });
148
+ }
149
+ });
150
+ // POST verify — instant-demo claims OR a real wallet presentation. Resolve +
151
+ // re-price the order (CT3), evaluate the disclosed claims (explicit positive claim
152
+ // — CT4/CT5), write the per-order record on success.
153
+ post("/attestomcp/credential/verify", async (req, res) => {
154
+ const body = await readJsonBody(req);
155
+ const kind = parseKind(body.cred);
156
+ if (!kind) {
157
+ res.status(404).json({ verified: false, error: "unknown credential" });
158
+ return;
159
+ }
160
+ const order = await resolveOrder(ctx, typeof body.order === "string" ? body.order : undefined);
161
+ if (!order) {
162
+ res.status(400).json({ verified: false, error: "missing or invalid order" });
163
+ return;
164
+ }
165
+ const minimumAge = kind === "age" ? requiredAgeForOrder(order) ?? 21 : undefined;
166
+ const percent = kind === "membership" ? percentFor(order) : undefined;
167
+ try {
168
+ let out;
169
+ const result = body.result;
170
+ if (result && typeof result === "object") {
171
+ // REAL wallet presentation — dispatch by the protocol the wallet used.
172
+ if (result.protocol === "org-iso-mdoc") {
173
+ if (typeof body.mdocContextToken !== "string") {
174
+ res.status(400).json({ verified: false, error: "missing mdocContextToken for org-iso-mdoc" });
175
+ return;
176
+ }
177
+ out = await verifyMdocPresentation({ kind, result, mdocContextToken: body.mdocContextToken, origin: originOf(ctx, req), secret: ctx.signingKey, minimumAge, percent });
178
+ }
179
+ else {
180
+ if (typeof body.readerContextToken !== "string") {
181
+ res.status(400).json({ verified: false, error: "missing readerContextToken for openid4vp presentation" });
182
+ return;
183
+ }
184
+ out = await verifyCredentialPresentation({ kind, result, readerContextToken: body.readerContextToken, secret: ctx.signingKey, minimumAge, percent });
185
+ }
186
+ }
187
+ else {
188
+ // Instant-demo claims path (the tested default).
189
+ const claims = (body.claims && typeof body.claims === "object" ? body.claims : {});
190
+ out = evaluateCredential(kind, claims, { minimumAge, percent });
191
+ }
192
+ if (out.verified)
193
+ await recordVerified(ctx, order.id, kind, out.membershipNumber);
194
+ res.json(out);
195
+ }
196
+ catch (err) {
197
+ res.status(400).json({ verified: false, error: err.message, trust_level: "presence-only-demo" });
198
+ }
199
+ });
200
+ };
@@ -0,0 +1,51 @@
1
+ import type { CeremonyOrder } from "../types.js";
2
+ import type { CredentialKind } from "./dcql.js";
3
+ import { type DisclosedEntry } from "../mdoc/mdoc.js";
4
+ export type { CredentialKind } from "./dcql.js";
5
+ export interface GateResult {
6
+ gate: string;
7
+ pass: boolean;
8
+ detail: string;
9
+ }
10
+ export interface CredGateResult {
11
+ verified: boolean;
12
+ /** Membership id when a membership gate verified; null otherwise. */
13
+ membershipNumber: string | null;
14
+ gates: GateResult[];
15
+ /** Honesty axis — stated in the receipt, not buried in prose. */
16
+ trust_level: "presence-only-demo";
17
+ }
18
+ export interface EvaluateOpts {
19
+ /** The minimum age the order's products demand (age gate only; default 21). */
20
+ minimumAge?: number;
21
+ /** The membership discount percent surfaced in the gate detail (default 10). */
22
+ percent?: number;
23
+ }
24
+ /**
25
+ * Re-derive the age threshold this order requires from its catalog-priced lines —
26
+ * the strictest `minimumAge` present, or `null` when nothing is age-restricted.
27
+ * Always read from the (re-priced) lines, never the order token (invariant 2).
28
+ */
29
+ export declare function requiredAgeForOrder(order: CeremonyOrder): number | null;
30
+ /** True iff this order is age-restricted but carries no positive per-order age claim. */
31
+ export declare function isAgeUnsatisfied(order: CeremonyOrder, verification: {
32
+ ageVerified?: boolean;
33
+ } | undefined | null): boolean;
34
+ /**
35
+ * Evaluate disclosed claims (PRESENCE-ONLY) for one credential kind. Reuses the
36
+ * package's `age.over(N)` / `membership.discount()` `verify()` so the positive-claim
37
+ * rule has a single definition.
38
+ */
39
+ export declare function evaluateCredential(kind: CredentialKind, claims: Record<string, unknown>, opts?: EvaluateOpts): CredGateResult;
40
+ export declare function verifyCredentialPresentation(args: {
41
+ kind: CredentialKind;
42
+ result: {
43
+ protocol?: string;
44
+ data?: unknown;
45
+ };
46
+ readerContextToken: string;
47
+ secret: string;
48
+ minimumAge?: number;
49
+ percent?: number;
50
+ }): Promise<CredGateResult>;
51
+ export declare function evaluateDisclosed(kind: CredentialKind, disclosed: DisclosedEntry[], opts?: EvaluateOpts): CredGateResult;
@@ -0,0 +1,146 @@
1
+ // Map a wallet's disclosed mdoc claims to a verified boolean for the age /
2
+ // membership gate, and re-derive an order's age restriction from its catalog-priced
3
+ // lines.
4
+ //
5
+ // TWO verification entry points, ONE policy:
6
+ // • evaluateCredential(kind, claims, …) — the instant-demo path. Disclosed claims
7
+ // are passed in directly (no wallet round-trip). The explicit-positive-claim
8
+ // control (Security invariant 5) still runs.
9
+ // • verifyCredentialPresentation(…) — the REAL OpenID4VP path. The wallet's
10
+ // JWE-encrypted response is decrypted (jose ECDH-ES compactDecrypt), nonce-bound
11
+ // to THIS request (apu/apv echo check), the ISO 18013-5 mdoc DeviceResponse is
12
+ // parsed, and the disclosed claims are flattened into the SAME policy check.
13
+ // • verifyMdocPresentation(…) (mdoc-verify.ts) — the REAL iOS org-iso-mdoc path:
14
+ // HPKE-decrypt the DeviceResponse bound to the web origin, then the same policy.
15
+ //
16
+ // TRUST_LEVEL stays "presence-only-demo" (Principle VII / FR-011). The wire crypto
17
+ // (JWE/HPKE decryption, ECDH-ES, nonce binding, ISO-mdoc CBOR parsing) is REAL and
18
+ // verified; what is NOT yet real is the issuer TRUST ANCHOR — the mdoc's issuer /
19
+ // device COSE signatures are not checked against a real CA (main self-signs its
20
+ // mdoc certs), so a self-crafted mdoc would still parse. That hardening is the
21
+ // acknowledged future work, shared with dc-payment.
22
+ //
23
+ // The policy itself (what claims pass) reuses the package's own age.over(N) /
24
+ // membership.discount() builders so the threshold + membership rules have a single
25
+ // definition:
26
+ // • age — requires the positive over-age claim AT THE ORDER'S THRESHOLD
27
+ // (age_over_21 === true for a 21+ gate; an age_over_18 proof is refused).
28
+ // • membership — requires a real, non-empty membership id. A bare token or an
29
+ // unrelated claim must NOT grant the discount (it lowers the bound
30
+ // amount, so a forged loyalty state would reduce the charge).
31
+ import * as jose from "jose";
32
+ import { age, membership } from "../../credentials.js";
33
+ import { DEFAULT_LOYALTY_DISCOUNT_PCT } from "../mandate.js";
34
+ import { openReaderContext } from "../mdoc/readerContext.js";
35
+ import { decodeVpToken } from "../mdoc/mdoc.js";
36
+ /**
37
+ * Re-derive the age threshold this order requires from its catalog-priced lines —
38
+ * the strictest `minimumAge` present, or `null` when nothing is age-restricted.
39
+ * Always read from the (re-priced) lines, never the order token (invariant 2).
40
+ */
41
+ export function requiredAgeForOrder(order) {
42
+ const ages = order.lines
43
+ .map((l) => l.minimumAge)
44
+ .filter((a) => typeof a === "number" && a > 0);
45
+ return ages.length ? Math.max(...ages) : null;
46
+ }
47
+ /** True iff this order is age-restricted but carries no positive per-order age claim. */
48
+ export function isAgeUnsatisfied(order, verification) {
49
+ return requiredAgeForOrder(order) != null && verification?.ageVerified !== true;
50
+ }
51
+ /**
52
+ * Evaluate disclosed claims (PRESENCE-ONLY) for one credential kind. Reuses the
53
+ * package's `age.over(N)` / `membership.discount()` `verify()` so the positive-claim
54
+ * rule has a single definition.
55
+ */
56
+ export function evaluateCredential(kind, claims, opts = {}) {
57
+ if (kind === "age") {
58
+ const minimumAge = opts.minimumAge ?? 21;
59
+ // age.over(N).verify checks claims[`age_over_${N}`] === true — an explicit
60
+ // positive at THIS threshold (a lower-threshold proof does not satisfy it).
61
+ const verified = age.over(minimumAge).verify(claims);
62
+ return {
63
+ verified,
64
+ membershipNumber: null,
65
+ gates: [{ gate: `Age over ${minimumAge}`, pass: verified, detail: verified ? `age_over_${minimumAge} disclosed true` : `age_over_${minimumAge} not disclosed as true` }],
66
+ trust_level: "presence-only-demo",
67
+ };
68
+ }
69
+ const percent = opts.percent ?? DEFAULT_LOYALTY_DISCOUNT_PCT;
70
+ const verified = membership.discount(percent).verify(claims);
71
+ const membershipNumber = verified ? String(claims.membership_number) : null;
72
+ return {
73
+ verified,
74
+ membershipNumber,
75
+ gates: [{ gate: "Membership", pass: verified, detail: verified ? `member ${membershipNumber}` : "no membership id disclosed" }],
76
+ trust_level: "presence-only-demo",
77
+ };
78
+ }
79
+ // ── Disclosed mdoc DeviceResponse → the flat `claims` record `evaluateCredential`
80
+ // reads. mdoc.ts labels each claim "<namespace> / <elementId>"; we key by the
81
+ // bare elementId. Values can be raw or {_tag, value} (sanitized dates etc.) — a
82
+ // boolean (e.g. age_over_21) is preserved as a boolean so the strict
83
+ // `=== true` check holds; everything else is surfaced as-is. The SAME
84
+ // evaluateCredential policy then runs — no second source of truth for "verified".
85
+ function flattenDisclosed(disclosed) {
86
+ const claims = {};
87
+ for (const entry of disclosed) {
88
+ for (const c of entry.claims) {
89
+ const elementId = c.label.split(" / ").pop();
90
+ if (!elementId)
91
+ continue;
92
+ const v = c.value;
93
+ claims[elementId] = v && typeof v === "object" && "value" in v
94
+ ? v.value
95
+ : v;
96
+ }
97
+ }
98
+ return claims;
99
+ }
100
+ // ── REAL OpenID4VP path (Android Chrome). Open the sealed reader context, decrypt
101
+ // the wallet's JWE response (jose ECDH-ES compactDecrypt), enforce nonce binding,
102
+ // parse the ISO 18013-5 mdoc vp_token, and run the SAME evaluateCredential policy.
103
+ // Faithfully ported from the demo's payment-gate/credential-gate/verify.ts. The
104
+ // issuer/device signature (trust anchor) is the acknowledged future work —
105
+ // trust_level stays presence-only-demo. ──────────────────────────────────────
106
+ export async function verifyCredentialPresentation(args) {
107
+ const { kind, result, readerContextToken, secret, minimumAge, percent } = args;
108
+ const ctx = await openReaderContext(readerContextToken, secret);
109
+ let data = result?.data;
110
+ if (typeof data === "string") {
111
+ try {
112
+ data = JSON.parse(data);
113
+ }
114
+ catch { /* leave as string */ }
115
+ }
116
+ const jwe = data?.response;
117
+ if (!jwe)
118
+ throw new Error("no .response (JWE) in result.data");
119
+ // Nonce binding — reject on contradiction, accept on absence. OpenID4VP 1.0
120
+ // makes the apu/apv key-agreement parameters optional (the Multipaz test app
121
+ // sends them empty cross-device; some same-device paths echo the request nonce
122
+ // in apu; pre-1.0 drafts used apv), so their absence proves nothing — but a
123
+ // NON-EMPTY value bound to a DIFFERENT nonce is a response produced for another
124
+ // request, and is refused. Request-binding doesn't rest on this echo: every
125
+ // /request seals a fresh ephemeral decryption key with a short TTL, so a captured
126
+ // response only ever decrypts under the request that produced it.
127
+ if (!ctx.nonce)
128
+ throw new Error("reader context has no nonce to check");
129
+ const { apu, apv } = jose.decodeProtectedHeader(jwe);
130
+ const nonceForms = [jose.base64url.encode(ctx.nonce), ctx.nonce];
131
+ const echoed = [apu, apv].filter((p) => typeof p === "string" && p.length > 0);
132
+ if (echoed.length > 0 && !echoed.some((p) => nonceForms.includes(p))) {
133
+ throw new Error("nonce mismatch: response is not bound to this request");
134
+ }
135
+ const encPrivKey = await jose.importJWK(ctx.ecdhPrivateJwk, "ECDH-ES");
136
+ const { plaintext } = await jose.compactDecrypt(jwe, encPrivKey);
137
+ const openid4vpResponse = JSON.parse(new TextDecoder().decode(plaintext));
138
+ const vpToken = openid4vpResponse.vp_token;
139
+ const disclosed = vpToken ? decodeVpToken(vpToken) : [];
140
+ return evaluateCredential(kind, flattenDisclosed(disclosed), { minimumAge, percent });
141
+ }
142
+ // Shared by mdoc-verify.ts: a decoded DeviceResponse → the evaluateCredential
143
+ // policy, flattening the disclosed claims into the common record shape.
144
+ export function evaluateDisclosed(kind, disclosed, opts = {}) {
145
+ return evaluateCredential(kind, flattenDisclosed(disclosed), opts);
146
+ }
@@ -0,0 +1,5 @@
1
+ import type { DcqlQuery } from "../../types.js";
2
+ export declare const PAYMENT_DOCTYPE = "org.multipaz.payment.sca.1";
3
+ export declare const PAYMENT_CLAIM_LEAVES: readonly ["issuer_name", "payment_instrument_id", "masked_account_reference", "holder_name", "issue_date", "expiry_date"];
4
+ /** The DCQL the signed request embeds for the payment credential. */
5
+ export declare function buildDcPaymentDcql(): DcqlQuery;
@@ -0,0 +1,23 @@
1
+ export const PAYMENT_DOCTYPE = "org.multipaz.payment.sca.1";
2
+ // The disclosed instrument leaves verify.ts reads back into the DC mandate.
3
+ export const PAYMENT_CLAIM_LEAVES = [
4
+ "issuer_name",
5
+ "payment_instrument_id",
6
+ "masked_account_reference",
7
+ "holder_name",
8
+ "issue_date",
9
+ "expiry_date",
10
+ ];
11
+ /** The DCQL the signed request embeds for the payment credential. */
12
+ export function buildDcPaymentDcql() {
13
+ return {
14
+ credentials: [
15
+ {
16
+ id: "dpc",
17
+ format: "mso_mdoc",
18
+ meta: { doctype_value: PAYMENT_DOCTYPE },
19
+ claims: PAYMENT_CLAIM_LEAVES.map((leaf) => ({ path: [PAYMENT_DOCTYPE, leaf], intent_to_retain: false })),
20
+ },
21
+ ],
22
+ };
23
+ }
@@ -0,0 +1,18 @@
1
+ export interface DcPaymentLine {
2
+ name: string;
3
+ quantity: number;
4
+ lineTotal: number;
5
+ currency: string;
6
+ }
7
+ export interface DcPaymentPageArgs {
8
+ /** Order id, echoed back so verify is scoped to one order. */
9
+ order: string;
10
+ /** Catalog-priced total (never the token's). */
11
+ total: number;
12
+ currency: string;
13
+ lines: DcPaymentLine[];
14
+ /** Where to send the buyer after payment — the checkout hub, which then shows the
15
+ * paid confirmation. Defaults to this server's `/checkout?order=<id>`. */
16
+ returnUrl?: string;
17
+ }
18
+ export declare function renderDcPaymentPage(args: DcPaymentPageArgs): string;