@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,260 @@
|
|
|
1
|
+
// ISO 18013-7 Annex C "org-iso-mdoc" over the W3C Digital Credentials API — the
|
|
2
|
+
// protocol iOS Safari/WebKit supports (Android Chrome uses OpenID4VP instead).
|
|
3
|
+
// Extracted FAITHFULLY from the demo's payment-gate/credential-gate/mdoc-iso.ts
|
|
4
|
+
// (which reverse-engineered the wire format from verifier.multipaz.org). The only
|
|
5
|
+
// adaptation: the doctype/namespace/elements arrive as a `MdocDocSpec` argument
|
|
6
|
+
// (rail-agnostic) instead of a hardcoded credential-kind table, so both rails reuse
|
|
7
|
+
// this one module.
|
|
8
|
+
//
|
|
9
|
+
// What is REAL here: the full ISO/IEC 18013-5 wire format — deterministic
|
|
10
|
+
// (canonical) CBOR, COSE_Sign1 ReaderAuthAll (ES256 over the SessionTranscript),
|
|
11
|
+
// the @peculiar/x509 reader-cert chain (CA + leaf with mDL reader-auth EKUs),
|
|
12
|
+
// EncryptionInfo / DeviceRequest / SessionTranscript, and HPKE (P-256 / HKDF-SHA256
|
|
13
|
+
// / AES-128-GCM) decryption of the wallet's DeviceResponse bound to the web origin.
|
|
14
|
+
// What is NOT yet real: the issuer TRUST ANCHOR — the reader cert is self-signed
|
|
15
|
+
// (no real CA), so origin/reader binding is enforced but cross-issuer trust is not.
|
|
16
|
+
import { Encoder, decode as cborDecode, Tag } from "cbor-x";
|
|
17
|
+
// ISO/IEC 18013-5 §9.1.1 mandates *deterministic* (canonical) CBOR. cbor-x's
|
|
18
|
+
// default `encode` writes maps with a fixed 2-byte length header (0xb9 …) for
|
|
19
|
+
// speed, which is non-minimal — Apple re-encodes deviceRequestInfo canonically
|
|
20
|
+
// when it reconstructs ReaderAuthenticationAll, so a non-minimal header makes the
|
|
21
|
+
// reader-auth signature fail to validate (idcsInvalidReaderAuthSignature).
|
|
22
|
+
// `useRecords:false` + `variableMapSize:true` emits minimal map headers (0xa2 …)
|
|
23
|
+
// matching verifier.multipaz.org's output exactly. `useTag259ForMaps:false` is
|
|
24
|
+
// CRITICAL: by default cbor-x wraps every JS Map in its non-standard tag 259
|
|
25
|
+
// (0xd90103), so the COSE protected header `{1:-7}` becomes `d90103 a10126`
|
|
26
|
+
// instead of `a10126`. The protected header is part of the signed Sig_structure,
|
|
27
|
+
// so the tag makes Apple's COSE parser reject the reader-auth signature
|
|
28
|
+
// (idcsInvalidReaderAuthSignature). It also corrupts the x5chain (unprotected
|
|
29
|
+
// header) and the recipientPublicKey COSE_Key.
|
|
30
|
+
const canonicalEncoder = new Encoder({
|
|
31
|
+
useRecords: false,
|
|
32
|
+
variableMapSize: true,
|
|
33
|
+
useTag259ForMaps: false,
|
|
34
|
+
});
|
|
35
|
+
function cborEncode(value) {
|
|
36
|
+
return canonicalEncoder.encode(value);
|
|
37
|
+
}
|
|
38
|
+
import { createHash, webcrypto, randomBytes } from "node:crypto";
|
|
39
|
+
import { CipherSuite, DhkemP256HkdfSha256, HkdfSha256, Aes128Gcm } from "@hpke/core";
|
|
40
|
+
import * as jose from "jose";
|
|
41
|
+
import * as x509 from "@peculiar/x509";
|
|
42
|
+
import { decodeVpToken } from "./mdoc.js";
|
|
43
|
+
x509.cryptoProvider.set(globalThis.crypto);
|
|
44
|
+
const READER_SIGN_ALG = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" };
|
|
45
|
+
// Reader-authentication certificate chain following the ISO/IEC 18013-5 reader
|
|
46
|
+
// profile. Apple validates the *whole chain* on a signed request, so a single
|
|
47
|
+
// self-signed cert is rejected (idcsInvalidReaderAuthSignature). We mint the
|
|
48
|
+
// exact structure verifier.multipaz.org uses: a self-signed Reader CA root
|
|
49
|
+
// (basicConstraints CA:true) and a leaf signed by it carrying the reader-auth
|
|
50
|
+
// EKUs and a DNS SAN matching the request origin. The reader auth is signed with
|
|
51
|
+
// the leaf key; x5chain = [leaf, ca].
|
|
52
|
+
async function makeMdocReaderCert(origin, host) {
|
|
53
|
+
const now = Date.now();
|
|
54
|
+
// ── Reader CA (self-signed root) ──
|
|
55
|
+
const caKeys = await webcrypto.subtle.generateKey(READER_SIGN_ALG, true, ["sign", "verify"]);
|
|
56
|
+
const ca = await x509.X509CertificateGenerator.createSelfSigned({
|
|
57
|
+
serialNumber: "01",
|
|
58
|
+
name: `CN=${host} Reader CA`,
|
|
59
|
+
notBefore: new Date(now - 60_000),
|
|
60
|
+
notAfter: new Date(now + 5 * 365 * 86_400_000),
|
|
61
|
+
signingAlgorithm: READER_SIGN_ALG,
|
|
62
|
+
keys: caKeys,
|
|
63
|
+
extensions: [
|
|
64
|
+
new x509.BasicConstraintsExtension(true, undefined, true), // CA:true, critical
|
|
65
|
+
new x509.KeyUsagesExtension(x509.KeyUsageFlags.keyCertSign | x509.KeyUsageFlags.cRLSign, true),
|
|
66
|
+
await x509.SubjectKeyIdentifierExtension.create(caKeys.publicKey),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
// ── Leaf (signed by the Reader CA) ──
|
|
70
|
+
const leafKeys = await webcrypto.subtle.generateKey(READER_SIGN_ALG, true, ["sign", "verify"]);
|
|
71
|
+
const leaf = await x509.X509CertificateGenerator.create({
|
|
72
|
+
serialNumber: "02",
|
|
73
|
+
subject: `CN=Verifier at ${origin}`,
|
|
74
|
+
issuer: ca.subject,
|
|
75
|
+
notBefore: new Date(now - 60_000),
|
|
76
|
+
notAfter: new Date(now + 365 * 86_400_000),
|
|
77
|
+
signingAlgorithm: READER_SIGN_ALG,
|
|
78
|
+
publicKey: leafKeys.publicKey,
|
|
79
|
+
signingKey: caKeys.privateKey,
|
|
80
|
+
extensions: [
|
|
81
|
+
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
|
|
82
|
+
// id-mdlReaderAuth + ISO 23220-4 reader-auth — both, as multipaz does.
|
|
83
|
+
new x509.ExtendedKeyUsageExtension(["1.0.18013.5.1.6", "1.0.23220.4.1.6"], true),
|
|
84
|
+
new x509.SubjectAlternativeNameExtension([{ type: "dns", value: host }]),
|
|
85
|
+
await x509.AuthorityKeyIdentifierExtension.create(ca),
|
|
86
|
+
await x509.SubjectKeyIdentifierExtension.create(leafKeys.publicKey),
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
chainDer: [new Uint8Array(leaf.rawData), new Uint8Array(ca.rawData)],
|
|
91
|
+
leafKey: leafKeys.privateKey,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function suite() {
|
|
95
|
+
return new CipherSuite({ kem: new DhkemP256HkdfSha256(), kdf: new HkdfSha256(), aead: new Aes128Gcm() });
|
|
96
|
+
}
|
|
97
|
+
function b64urlToBytes(s) {
|
|
98
|
+
return Buffer.from(s, "base64url");
|
|
99
|
+
}
|
|
100
|
+
function bytesToB64url(b) {
|
|
101
|
+
return Buffer.from(b).toString("base64url");
|
|
102
|
+
}
|
|
103
|
+
// @hpke/core wants ArrayBuffer for enc/info/ciphertext.
|
|
104
|
+
function toAB(b) {
|
|
105
|
+
return b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
|
|
106
|
+
}
|
|
107
|
+
// ── COSE_Key (EC2 / P-256), CBOR map with integer keys ────────────────────────
|
|
108
|
+
export function coseKeyFromJwk(jwk) {
|
|
109
|
+
return new Map([
|
|
110
|
+
[1, 2], // kty: EC2
|
|
111
|
+
[-1, 1], // crv: P-256
|
|
112
|
+
[-2, b64urlToBytes(jwk.x)], // x
|
|
113
|
+
[-3, b64urlToBytes(jwk.y)], // y
|
|
114
|
+
]);
|
|
115
|
+
}
|
|
116
|
+
export async function generateReaderKey() {
|
|
117
|
+
const kp = await webcrypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
118
|
+
const pubJwk = (await webcrypto.subtle.exportKey("jwk", kp.publicKey));
|
|
119
|
+
const privateJwk = (await webcrypto.subtle.exportKey("jwk", kp.privateKey));
|
|
120
|
+
return { coseKey: coseKeyFromJwk(pubJwk), privateJwk };
|
|
121
|
+
}
|
|
122
|
+
// ── EncryptionInfo = ["dcapi", { nonce, recipientPublicKey: COSE_Key }] ────────
|
|
123
|
+
export function buildEncryptionInfo(coseKey, nonce) {
|
|
124
|
+
const info = ["dcapi", { nonce: Buffer.from(nonce), recipientPublicKey: coseKey }];
|
|
125
|
+
const bytes = cborEncode(info);
|
|
126
|
+
return { bytes, base64: bytesToB64url(bytes) };
|
|
127
|
+
}
|
|
128
|
+
// CBOR canonical map-key order (RFC 8949 §4.2.1 / ISO 18013-5): shorter key
|
|
129
|
+
// first, then bytewise. The reader-auth signature is over the canonically-encoded
|
|
130
|
+
// ItemsRequest, so its element-map keys MUST be in this order or Apple's
|
|
131
|
+
// reconstruction won't match (idcsInvalidReaderAuthSignature).
|
|
132
|
+
function canonicalKeyOrder(a, b) {
|
|
133
|
+
const ab = Buffer.from(a), bb = Buffer.from(b);
|
|
134
|
+
return ab.length !== bb.length ? ab.length - bb.length : Buffer.compare(ab, bb);
|
|
135
|
+
}
|
|
136
|
+
export function buildItemsRequest(spec) {
|
|
137
|
+
const { docType, namespace, elements } = spec;
|
|
138
|
+
const sorted = [...elements].sort(canonicalKeyOrder);
|
|
139
|
+
return cborEncode({ docType, nameSpaces: { [namespace]: Object.fromEntries(sorted.map((e) => [e, false])) } });
|
|
140
|
+
}
|
|
141
|
+
// Unsigned DeviceRequest (used by the structure tests). The real request sent to
|
|
142
|
+
// iOS is reader-authenticated — see buildSignedDeviceRequest.
|
|
143
|
+
export function buildDeviceRequest(spec) {
|
|
144
|
+
return cborEncode({
|
|
145
|
+
version: "1.0",
|
|
146
|
+
docRequests: [{ itemsRequest: new Tag(Buffer.from(buildItemsRequest(spec)), 24) }],
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// ISO 18013-5 (mdoc v1.1) ReaderAuthAll — a single COSE_Sign1 (detached) at the
|
|
150
|
+
// DeviceRequest level covering ALL doc requests, signed over:
|
|
151
|
+
// #6.24(bstr .cbor ["ReaderAuthenticationAll", SessionTranscript,
|
|
152
|
+
// [ #6.24(bstr ItemsRequest), … ], deviceRequestInfo|null])
|
|
153
|
+
// This is exactly what verifier.multipaz.org sends and what Apple's WebKit
|
|
154
|
+
// validator authenticates ("ReaderAuthAll"). The reader cert rides in x5chain
|
|
155
|
+
// (label 33). NOTE: the DeviceRequest version MUST be "1.1" or readerAuthAll is
|
|
156
|
+
// ignored by conformant parsers.
|
|
157
|
+
// DeviceRequestInfo (mdoc v1.1): one mandatory use case covering our single doc
|
|
158
|
+
// request (documentSets indices). Matches what verifier.multipaz.org sends; it is
|
|
159
|
+
// part of the ReaderAuthAll signed payload, so it must be present AND identical.
|
|
160
|
+
function buildDeviceRequestInfo() {
|
|
161
|
+
return cborEncode({ useCases: [{ mandatory: true, documentSets: [[0]] }] });
|
|
162
|
+
}
|
|
163
|
+
async function buildReaderAuthAll(args) {
|
|
164
|
+
const transcriptItem = cborDecode(args.sessionTranscript);
|
|
165
|
+
// [ "ReaderAuthenticationAll", SessionTranscript, [ItemsRequestBytes…], deviceRequestInfoBytes ]
|
|
166
|
+
const readerAuthenticationAll = ["ReaderAuthenticationAll", transcriptItem, args.itemsRequestTags, args.deviceRequestInfoTag];
|
|
167
|
+
const raaBytes = cborEncode(new Tag(Buffer.from(cborEncode(readerAuthenticationAll)), 24));
|
|
168
|
+
const protectedHeader = cborEncode(new Map([[1, -7]])); // {alg: ES256}
|
|
169
|
+
const sigStructure = cborEncode(["Signature1", Buffer.from(protectedHeader), Buffer.alloc(0), Buffer.from(raaBytes)]);
|
|
170
|
+
const signature = new Uint8Array(await webcrypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, args.signingKey, sigStructure));
|
|
171
|
+
// x5chain (label 33): array of DER certs, leaf first — [leaf, ca].
|
|
172
|
+
const unprotected = new Map([[33, args.chainDer.map((d) => Buffer.from(d))]]);
|
|
173
|
+
// COSE_Sign1 = [protected, unprotected, payload(null = detached), signature]
|
|
174
|
+
return [Buffer.from(protectedHeader), unprotected, null, Buffer.from(signature)];
|
|
175
|
+
}
|
|
176
|
+
async function buildSignedDeviceRequest(spec, sessionTranscript, signingKey, chainDer) {
|
|
177
|
+
const itemsRequestTag = new Tag(Buffer.from(buildItemsRequest(spec)), 24);
|
|
178
|
+
const deviceRequestInfoTag = new Tag(Buffer.from(buildDeviceRequestInfo()), 24);
|
|
179
|
+
const readerAuthAll = await buildReaderAuthAll({ sessionTranscript, itemsRequestTags: [itemsRequestTag], deviceRequestInfoTag, signingKey, chainDer });
|
|
180
|
+
// Key order matches verifier.multipaz.org: version, docRequests, deviceRequestInfo, readerAuthAll.
|
|
181
|
+
// readerAuthAll is an *array* of COSE_Sign1s (one per signing key); we always send exactly one,
|
|
182
|
+
// so the value is [[protectedHdr, unprotected, null, sig]] — the outer array is not a mistake.
|
|
183
|
+
return cborEncode({ version: "1.1", docRequests: [{ itemsRequest: itemsRequestTag }], deviceRequestInfo: deviceRequestInfoTag, readerAuthAll: [readerAuthAll] });
|
|
184
|
+
}
|
|
185
|
+
// ── SessionTranscript = [null, null, ["dcapi", SHA256(CBOR([b64EncInfo, origin]))]] ──
|
|
186
|
+
export function buildSessionTranscript(base64EncryptionInfo, origin) {
|
|
187
|
+
const dcapiInfo = [base64EncryptionInfo, origin];
|
|
188
|
+
const digest = createHash("sha256").update(cborEncode(dcapiInfo)).digest();
|
|
189
|
+
const transcript = [null, null, ["dcapi", digest]];
|
|
190
|
+
return cborEncode(transcript);
|
|
191
|
+
}
|
|
192
|
+
export async function buildMdocRequestParts(spec, origin, signed = true) {
|
|
193
|
+
const { coseKey, privateJwk } = await generateReaderKey();
|
|
194
|
+
const nonce = randomBytes(16);
|
|
195
|
+
const { base64: base64EncryptionInfo } = buildEncryptionInfo(coseKey, nonce);
|
|
196
|
+
let deviceRequest;
|
|
197
|
+
if (signed) {
|
|
198
|
+
// The reader auth signs over the session transcript, which binds the request
|
|
199
|
+
// to this exact origin — so the device request must be built with it.
|
|
200
|
+
const sessionTranscript = buildSessionTranscript(base64EncryptionInfo, origin);
|
|
201
|
+
const host = new URL(origin).host.split(":")[0];
|
|
202
|
+
const { chainDer, leafKey } = await makeMdocReaderCert(origin, host);
|
|
203
|
+
deviceRequest = await buildSignedDeviceRequest(spec, sessionTranscript, leafKey, chainDer);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
deviceRequest = buildDeviceRequest(spec); // unsigned (diagnostic A/B)
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
data: { deviceRequest: bytesToB64url(deviceRequest), encryptionInfo: base64EncryptionInfo },
|
|
210
|
+
readerPrivateJwk: privateJwk,
|
|
211
|
+
base64EncryptionInfo,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
// ── HPKE-decrypt the wallet's response → DeviceResponse CBOR bytes ─────────────
|
|
215
|
+
function field(map, key) {
|
|
216
|
+
if (map instanceof Map)
|
|
217
|
+
return map.get(key);
|
|
218
|
+
if (map && typeof map === "object")
|
|
219
|
+
return map[key];
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
export async function decryptDeviceResponse(args) {
|
|
223
|
+
const { responseB64Url, readerPrivateJwk, sessionTranscript } = args;
|
|
224
|
+
const decoded = cborDecode(b64urlToBytes(responseB64Url));
|
|
225
|
+
// Accept either ["dcapi", {enc, cipherText}] or a bare {enc, cipherText} map.
|
|
226
|
+
const params = Array.isArray(decoded) && decoded[0] === "dcapi" ? decoded[1] : decoded;
|
|
227
|
+
const enc = field(params, "enc");
|
|
228
|
+
const cipherText = field(params, "cipherText");
|
|
229
|
+
if (!enc || !cipherText)
|
|
230
|
+
throw new Error("missing enc/cipherText in response");
|
|
231
|
+
const s = suite();
|
|
232
|
+
const recipientKey = await s.kem.importKey("jwk", readerPrivateJwk, false);
|
|
233
|
+
const recipient = await s.createRecipientContext({
|
|
234
|
+
recipientKey,
|
|
235
|
+
enc: toAB(enc),
|
|
236
|
+
info: toAB(sessionTranscript),
|
|
237
|
+
});
|
|
238
|
+
const pt = await recipient.open(toAB(cipherText));
|
|
239
|
+
return new Uint8Array(pt);
|
|
240
|
+
}
|
|
241
|
+
// ── Decrypted DeviceResponse → disclosed claims (reuses ./mdoc.ts) ────────────
|
|
242
|
+
export function disclosedFromDeviceResponse(deviceResponse) {
|
|
243
|
+
return decodeVpToken({ mdoc: bytesToB64url(deviceResponse) });
|
|
244
|
+
}
|
|
245
|
+
function keyFromSecret(secret) {
|
|
246
|
+
return new Uint8Array(createHash("sha256").update(secret).digest());
|
|
247
|
+
}
|
|
248
|
+
export async function sealMdocContext(ctx, secret, ttlMs = 180_000) {
|
|
249
|
+
const payload = { ...ctx, exp: Date.now() + ttlMs };
|
|
250
|
+
return await new jose.CompactEncrypt(new TextEncoder().encode(JSON.stringify(payload)))
|
|
251
|
+
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
|
252
|
+
.encrypt(keyFromSecret(secret));
|
|
253
|
+
}
|
|
254
|
+
export async function openMdocContext(token, secret) {
|
|
255
|
+
const { plaintext } = await jose.compactDecrypt(token, keyFromSecret(secret));
|
|
256
|
+
const payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
257
|
+
if (Date.now() > payload.exp)
|
|
258
|
+
throw new Error("mdoc reader context expired");
|
|
259
|
+
return { readerPrivateJwk: payload.readerPrivateJwk, base64EncryptionInfo: payload.base64EncryptionInfo };
|
|
260
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface DisclosedEntry {
|
|
2
|
+
id: string;
|
|
3
|
+
format: string;
|
|
4
|
+
type?: string;
|
|
5
|
+
claims: {
|
|
6
|
+
label: string;
|
|
7
|
+
value: any;
|
|
8
|
+
}[];
|
|
9
|
+
}
|
|
10
|
+
export declare function decodeVpToken(vpToken: unknown): DisclosedEntry[];
|
|
11
|
+
export declare function extractTransactionDataHash(vpStr: string | string[], namespace?: string, element?: string): string | null;
|
|
12
|
+
export interface AuthBlocks {
|
|
13
|
+
hasIssuerAuth: boolean;
|
|
14
|
+
hasDeviceAuth: boolean;
|
|
15
|
+
docType: string | null;
|
|
16
|
+
}
|
|
17
|
+
export declare function inspectAuthBlocks(vpStr: string | string[]): AuthBlocks;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Structural-only decode of a presented mdoc DeviceResponse (ISO 18013-5 CBOR).
|
|
2
|
+
// Extracted FAITHFULLY from the demo's payment-gate/dc-payment/mdoc.ts — the
|
|
3
|
+
// SAME real wire parser both rails (credential-gate + dc-payment) feed their
|
|
4
|
+
// decrypted DeviceResponse into.
|
|
5
|
+
//
|
|
6
|
+
// What is REAL here: the ISO 18013-5 CBOR wire format is fully parsed — the
|
|
7
|
+
// DeviceResponse → documents → issuerSigned.nameSpaces → IssuerSignedItemBytes
|
|
8
|
+
// (#6.24 tagged) → elementIdentifier / elementValue, and the deviceSigned
|
|
9
|
+
// transaction_data_hash. What is NOT verified here: the issuer/device COSE
|
|
10
|
+
// SIGNATURES and the value digests — that issuer-trust check is the acknowledged
|
|
11
|
+
// future work (main self-signs its mdoc certs, so there is no real trust anchor
|
|
12
|
+
// yet). The crypto that IS real — JWE/HPKE decryption + nonce binding — runs in
|
|
13
|
+
// verify before this parser ever sees the bytes.
|
|
14
|
+
import { decode, Tag } from "cbor-x";
|
|
15
|
+
function b64urlToBytes(s) {
|
|
16
|
+
return new Uint8Array(Buffer.from(String(s), "base64url"));
|
|
17
|
+
}
|
|
18
|
+
// IssuerSignedItemBytes = #6.24(bstr .cbor IssuerSignedItem). Depending on the
|
|
19
|
+
// cbor-x build, tag 24 arrives as a Tag wrapping bytes or already as bytes.
|
|
20
|
+
function decodeTagged(item) {
|
|
21
|
+
if (item instanceof Tag)
|
|
22
|
+
return decode(item.value);
|
|
23
|
+
if (item instanceof Uint8Array)
|
|
24
|
+
return decode(item);
|
|
25
|
+
return item;
|
|
26
|
+
}
|
|
27
|
+
function sanitize(v) {
|
|
28
|
+
if (v instanceof Uint8Array)
|
|
29
|
+
return { _bytes_b64url: Buffer.from(v).toString("base64url") };
|
|
30
|
+
if (v instanceof Tag)
|
|
31
|
+
return { _tag: v.tag, value: sanitize(v.value) };
|
|
32
|
+
if (v instanceof Date)
|
|
33
|
+
return v.toISOString();
|
|
34
|
+
if (typeof v === "bigint")
|
|
35
|
+
return v.toString();
|
|
36
|
+
if (Array.isArray(v))
|
|
37
|
+
return v.map(sanitize);
|
|
38
|
+
if (v && typeof v === "object") {
|
|
39
|
+
const o = {};
|
|
40
|
+
for (const [k, val] of Object.entries(v))
|
|
41
|
+
o[k] = sanitize(val);
|
|
42
|
+
return o;
|
|
43
|
+
}
|
|
44
|
+
return v;
|
|
45
|
+
}
|
|
46
|
+
// vp_token from OpenID4VP DC API: { "<dcql-id>": "<base64url DeviceResponse>" }
|
|
47
|
+
// (older shape: an array, or a wrapping array per id). Returns a flattened shape.
|
|
48
|
+
export function decodeVpToken(vpToken) {
|
|
49
|
+
const entries = Array.isArray(vpToken)
|
|
50
|
+
? vpToken.map((v, i) => [String(i), v])
|
|
51
|
+
: Object.entries(vpToken ?? {});
|
|
52
|
+
return entries.map(([id, token]) => {
|
|
53
|
+
const str = Array.isArray(token) ? token[0] : token;
|
|
54
|
+
const dr = decode(b64urlToBytes(str));
|
|
55
|
+
const flat = [];
|
|
56
|
+
let type;
|
|
57
|
+
for (const doc of dr.documents ?? []) {
|
|
58
|
+
type = doc.docType;
|
|
59
|
+
const nameSpaces = doc.issuerSigned?.nameSpaces ?? {};
|
|
60
|
+
for (const [ns, items] of Object.entries(nameSpaces)) {
|
|
61
|
+
for (const raw of items) {
|
|
62
|
+
const isi = decodeTagged(raw);
|
|
63
|
+
flat.push({ label: `${ns} / ${isi.elementIdentifier}`, value: sanitize(isi.elementValue) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { id, format: "mso_mdoc", type, claims: flat };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
// The payment binding lives in deviceSigned, not issuerSigned. Returns the
|
|
71
|
+
// transaction_data_hash bytes as base64url, or null.
|
|
72
|
+
export function extractTransactionDataHash(vpStr, namespace = "urn:eudi:sca:payment:1", element = "transaction_data_hash") {
|
|
73
|
+
const str = Array.isArray(vpStr) ? vpStr[0] : vpStr;
|
|
74
|
+
const dr = decode(b64urlToBytes(str));
|
|
75
|
+
for (const doc of dr.documents ?? []) {
|
|
76
|
+
const ns = decodeTagged(doc.deviceSigned?.nameSpaces);
|
|
77
|
+
const val = ns?.[namespace]?.[element];
|
|
78
|
+
if (val instanceof Uint8Array)
|
|
79
|
+
return Buffer.from(val).toString("base64url");
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
export function inspectAuthBlocks(vpStr) {
|
|
84
|
+
const str = Array.isArray(vpStr) ? vpStr[0] : vpStr;
|
|
85
|
+
const dr = decode(b64urlToBytes(str));
|
|
86
|
+
const doc = (dr.documents ?? [])[0] ?? {};
|
|
87
|
+
const issuerAuth = doc.issuerSigned?.issuerAuth;
|
|
88
|
+
const deviceAuth = doc.deviceSigned?.deviceAuth;
|
|
89
|
+
return {
|
|
90
|
+
hasIssuerAuth: Array.isArray(issuerAuth) && issuerAuth.length > 0,
|
|
91
|
+
hasDeviceAuth: !!(deviceAuth && (deviceAuth.deviceSignature || deviceAuth.deviceMac)),
|
|
92
|
+
docType: doc.docType ?? null,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as jose from "jose";
|
|
2
|
+
import type { webcrypto as NodeWebCrypto } from "node:crypto";
|
|
3
|
+
export declare function makeReaderCert(rpID: string): Promise<{
|
|
4
|
+
x5c: string;
|
|
5
|
+
privateKey: NodeWebCrypto.CryptoKey;
|
|
6
|
+
}>;
|
|
7
|
+
export declare function makeEncryptionKey(): Promise<{
|
|
8
|
+
encJwk: jose.JWK;
|
|
9
|
+
ecdhPrivateJwk: jose.JWK;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Reader-side key + cert material for the Android-Chrome OpenID4VP path, shared by
|
|
2
|
+
// both rails' REAL signed-request builders. Extracted FAITHFULLY from the demo's
|
|
3
|
+
// payment-gate/dc-payment/request.ts (makeReaderCert + makeEncryptionKey).
|
|
4
|
+
//
|
|
5
|
+
// REAL crypto: a P-256 reader certificate is minted with @peculiar/x509 (SAN-DNS =
|
|
6
|
+
// RP-ID, SubjectKeyIdentifier required or the wallet's TrustManagerUtil NPEs) and
|
|
7
|
+
// an ephemeral P-256 ECDH key the wallet encrypts its response to. The request is
|
|
8
|
+
// then ES256-signed (jose.SignJWT) over the verifier-bound request object. The one
|
|
9
|
+
// thing NOT yet real is the issuer TRUST ANCHOR — the reader cert is self-signed
|
|
10
|
+
// here (as the demo's is), so origin/RP binding is enforced but cross-issuer trust
|
|
11
|
+
// is not.
|
|
12
|
+
import * as jose from "jose";
|
|
13
|
+
import * as x509 from "@peculiar/x509";
|
|
14
|
+
const webcrypto = globalThis.crypto;
|
|
15
|
+
x509.cryptoProvider.set(webcrypto);
|
|
16
|
+
const SIGN_ALG = { name: "ECDSA", namedCurve: "P-256", hash: "SHA-256" };
|
|
17
|
+
export async function makeReaderCert(rpID) {
|
|
18
|
+
const keys = await webcrypto.subtle.generateKey(SIGN_ALG, true, ["sign", "verify"]);
|
|
19
|
+
const cert = await x509.X509CertificateGenerator.createSelfSigned({
|
|
20
|
+
serialNumber: "01",
|
|
21
|
+
name: `CN=${rpID}`,
|
|
22
|
+
notBefore: new Date(Date.now() - 60_000),
|
|
23
|
+
notAfter: new Date(Date.now() + 86_400_000),
|
|
24
|
+
signingAlgorithm: SIGN_ALG,
|
|
25
|
+
keys,
|
|
26
|
+
extensions: [
|
|
27
|
+
new x509.SubjectAlternativeNameExtension([{ type: "dns", value: rpID }]),
|
|
28
|
+
// The Subject Key Identifier extension is REQUIRED — without it the wallet's
|
|
29
|
+
// TrustManagerUtil does subjectKeyIdentifier!! → NPE.
|
|
30
|
+
await x509.SubjectKeyIdentifierExtension.create(keys.publicKey),
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
return { x5c: cert.toString("base64"), privateKey: keys.privateKey };
|
|
34
|
+
}
|
|
35
|
+
// Ephemeral P-256 key the wallet encrypts its response to. Shared by the payment
|
|
36
|
+
// and credential gates so both build the response-encryption JWK identically.
|
|
37
|
+
export async function makeEncryptionKey() {
|
|
38
|
+
const encKP = await webcrypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
|
|
39
|
+
const encPubJwk = await webcrypto.subtle.exportKey("jwk", encKP.publicKey);
|
|
40
|
+
const ecdhPrivateJwk = (await webcrypto.subtle.exportKey("jwk", encKP.privateKey));
|
|
41
|
+
const encJwk = { kty: "EC", crv: "P-256", x: encPubJwk.x, y: encPubJwk.y, use: "enc", alg: "ECDH-ES", kid: "response-encryption-key" };
|
|
42
|
+
return { encJwk, ecdhPrivateJwk };
|
|
43
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import * as jose from "jose";
|
|
2
|
+
export interface ReaderContext {
|
|
3
|
+
ecdhPrivateJwk: jose.JWK;
|
|
4
|
+
transactionDataB64: string;
|
|
5
|
+
nonce?: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function sealReaderContext(ctx: ReaderContext, secret: string, ttlMs?: number): Promise<string>;
|
|
8
|
+
export declare function openReaderContext(token: string, secret: string): Promise<ReaderContext>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Stateless carrier for the reader's ephemeral ECDH private key + the bound
|
|
2
|
+
// transaction_data (and the request nonce) between /request and /verify. Extracted
|
|
3
|
+
// FAITHFULLY from the demo's payment-gate/dc-payment/readerContext.ts.
|
|
4
|
+
//
|
|
5
|
+
// REAL crypto: sealed as a JWE (dir / A256GCM) under a key derived from the host's
|
|
6
|
+
// signingKey, with a short expiry. Confidentiality matters (it wraps a PRIVATE
|
|
7
|
+
// key), so we encrypt rather than just HMAC — `jose.CompactEncrypt` /
|
|
8
|
+
// `jose.compactDecrypt`. The nonce sealed here is what /verify requires the
|
|
9
|
+
// wallet's response to be bound to (apu/apv echo) — not merely "a token decrypted".
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import * as jose from "jose";
|
|
12
|
+
const DEFAULT_TTL_MS = 180_000;
|
|
13
|
+
function keyFromSecret(secret) {
|
|
14
|
+
return new Uint8Array(createHash("sha256").update(secret).digest());
|
|
15
|
+
}
|
|
16
|
+
export async function sealReaderContext(ctx, secret, ttlMs = DEFAULT_TTL_MS) {
|
|
17
|
+
const payload = { ...ctx, exp: Date.now() + ttlMs };
|
|
18
|
+
const plaintext = new TextEncoder().encode(JSON.stringify(payload));
|
|
19
|
+
return await new jose.CompactEncrypt(plaintext)
|
|
20
|
+
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
|
21
|
+
.encrypt(keyFromSecret(secret));
|
|
22
|
+
}
|
|
23
|
+
export async function openReaderContext(token, secret) {
|
|
24
|
+
const { plaintext } = await jose.compactDecrypt(token, keyFromSecret(secret));
|
|
25
|
+
const payload = JSON.parse(new TextDecoder().decode(plaintext));
|
|
26
|
+
if (Date.now() > payload.exp)
|
|
27
|
+
throw new Error("reader context expired");
|
|
28
|
+
return { ecdhPrivateJwk: payload.ecdhPrivateJwk, transactionDataB64: payload.transactionDataB64, nonce: payload.nonce };
|
|
29
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { VerificationStore } from "../types.js";
|
|
2
|
+
import { type Origin, type RequestLike } from "./origin.js";
|
|
3
|
+
import type { CeremonyCatalog, CeremonyOrder, CeremonyOrderStore, CompletionSeam, SettlementSeam } from "./types.js";
|
|
4
|
+
/** Minimal Express-app shape mount() needs (no `express` dependency). */
|
|
5
|
+
export interface CeremonyApp {
|
|
6
|
+
locals: Record<string, unknown>;
|
|
7
|
+
get?(path: string, ...handlers: unknown[]): unknown;
|
|
8
|
+
post?(path: string, ...handlers: unknown[]): unknown;
|
|
9
|
+
use?(path: string, ...handlers: unknown[]): unknown;
|
|
10
|
+
}
|
|
11
|
+
/** What the host injects. Required seams throw if missing (CT2); `origin`,
|
|
12
|
+
* `settlement`, and the signing-key escape hatch have safe behaviors. */
|
|
13
|
+
export interface CeremonySeams {
|
|
14
|
+
/** Per-order verification state (never process-global — invariant 4). */
|
|
15
|
+
verificationStore: VerificationStore;
|
|
16
|
+
/** Resolve a created order by id (totals are re-priced from `catalog`). */
|
|
17
|
+
orderStore: CeremonyOrderStore;
|
|
18
|
+
/** Server-side re-pricing — the amount source of truth (invariant 2). */
|
|
19
|
+
catalog: CeremonyCatalog;
|
|
20
|
+
/** Host-bound completion (idempotent record + cart/verification clear). */
|
|
21
|
+
completion: CompletionSeam;
|
|
22
|
+
/** Stable HMAC key for the challenge nonce. Required so options→verify survive
|
|
23
|
+
* an instance split (D6) UNLESS `allowEphemeralKey` is explicitly set. */
|
|
24
|
+
signingKey?: string;
|
|
25
|
+
/** RP-id / origin derivation; defaults to the built-in `deriveOrigin`. */
|
|
26
|
+
origin?: (req: RequestLike) => Origin;
|
|
27
|
+
/** Optional demo-mode settlement seam (absent ⇒ mock-complete). */
|
|
28
|
+
settlement?: SettlementSeam;
|
|
29
|
+
/** Dev-only: allow an ephemeral per-process signing key. NEVER inferred —
|
|
30
|
+
* mount() does not guess "serverless". */
|
|
31
|
+
allowEphemeralKey?: boolean;
|
|
32
|
+
}
|
|
33
|
+
/** The resolved context each rail receives (every required seam present). */
|
|
34
|
+
export interface CeremonyContext {
|
|
35
|
+
verificationStore: VerificationStore;
|
|
36
|
+
orderStore: CeremonyOrderStore;
|
|
37
|
+
catalog: CeremonyCatalog;
|
|
38
|
+
completion: CompletionSeam;
|
|
39
|
+
signingKey: string;
|
|
40
|
+
origin: (req: RequestLike) => Origin;
|
|
41
|
+
settlement?: SettlementSeam;
|
|
42
|
+
}
|
|
43
|
+
/** A rail attaches its routes to the host app given the resolved context. */
|
|
44
|
+
export type RailRegistrar = (app: CeremonyApp, ctx: CeremonyContext) => void;
|
|
45
|
+
/**
|
|
46
|
+
* Read + validate the injected seams, build the CeremonyContext, and register
|
|
47
|
+
* every rail's routes. Throws on a missing required seam (CT2). Seams may arrive
|
|
48
|
+
* via `options` OR `app.locals.attestomcp` — options win.
|
|
49
|
+
*/
|
|
50
|
+
export declare function mountCeremony(app: CeremonyApp, options?: Partial<CeremonySeams>): CeremonyContext;
|
|
51
|
+
/**
|
|
52
|
+
* Shared order resolution + re-pricing (T003a). Resolve a created order by id
|
|
53
|
+
* from the injected store, then RE-PRICE it from the catalog — the displayed and
|
|
54
|
+
* bound amounts come from the catalog, never the id/token (CT3, invariants 2/3).
|
|
55
|
+
* A tampered or unknown id resolves to `null` (the rail refuses).
|
|
56
|
+
*/
|
|
57
|
+
export declare function resolveOrder(ctx: CeremonyContext, orderId: string | undefined | null): Promise<CeremonyOrder | null>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The injected-seam contract for the ceremony (Context 2). `mountCeremony(app)`
|
|
2
|
+
// reads the seams the host provides (options + `app.locals.attestomcp`), FAILS FAST
|
|
3
|
+
// when a load-bearing one is missing (CT2 — never silently degrade), resolves a
|
|
4
|
+
// CeremonyContext, and registers each rail's routes onto the host app. With no
|
|
5
|
+
// rails extracted yet (Phase 2 — Foundational), it validates the seams + builds
|
|
6
|
+
// the context only; the passkey / dc-payment / credential-gate rails push their
|
|
7
|
+
// registrars here as they land (US1–US3).
|
|
8
|
+
//
|
|
9
|
+
// The package stays dependency-free: `CeremonyApp` is a minimal structural type
|
|
10
|
+
// (no `express` import) carrying just `locals` + the route methods a rail needs.
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { deriveOrigin } from "./origin.js";
|
|
13
|
+
import { registerCredentialGate } from "./credential-gate/routes.js";
|
|
14
|
+
import { registerPasskeyGate } from "./passkey/routes.js";
|
|
15
|
+
import { registerDcPaymentGate } from "./dc-payment/routes.js";
|
|
16
|
+
// Per-rail registration scaffold. Each rail (passkey / dc-payment /
|
|
17
|
+
// credential-gate) pushes its registrar here once extracted (US1–US3). US1 lands
|
|
18
|
+
// the credential gate (age + membership); passkey / dc-payment follow (US2/US3).
|
|
19
|
+
// Each registrar no-ops on a route-less app shape, so mount()'s fail-fast tests
|
|
20
|
+
// (which pass a `{ locals }`-only app) are unaffected.
|
|
21
|
+
const RAILS = [registerCredentialGate, registerPasskeyGate, registerDcPaymentGate];
|
|
22
|
+
/**
|
|
23
|
+
* Read + validate the injected seams, build the CeremonyContext, and register
|
|
24
|
+
* every rail's routes. Throws on a missing required seam (CT2). Seams may arrive
|
|
25
|
+
* via `options` OR `app.locals.attestomcp` — options win.
|
|
26
|
+
*/
|
|
27
|
+
export function mountCeremony(app, options = {}) {
|
|
28
|
+
const locals = (app.locals.attestomcp ?? {});
|
|
29
|
+
const verificationStore = options.verificationStore ?? locals.verificationStore ?? locals.store;
|
|
30
|
+
const orderStore = options.orderStore ?? locals.orderStore;
|
|
31
|
+
const catalog = options.catalog ?? locals.catalog;
|
|
32
|
+
const completion = options.completion ?? locals.completion;
|
|
33
|
+
const settlement = options.settlement ?? locals.settlement;
|
|
34
|
+
const origin = options.origin ?? locals.origin ?? deriveOrigin;
|
|
35
|
+
const allowEphemeralKey = options.allowEphemeralKey ?? locals.allowEphemeralKey ?? false;
|
|
36
|
+
let signingKey = options.signingKey ?? locals.signingKey;
|
|
37
|
+
// Fail fast (CT2) — a load-bearing seam must never silently default. (`origin`
|
|
38
|
+
// has a safe built-in default; `settlement` is genuinely optional.)
|
|
39
|
+
const missing = [];
|
|
40
|
+
if (!verificationStore)
|
|
41
|
+
missing.push("verificationStore");
|
|
42
|
+
if (!orderStore)
|
|
43
|
+
missing.push("orderStore");
|
|
44
|
+
if (!catalog)
|
|
45
|
+
missing.push("catalog");
|
|
46
|
+
if (!completion)
|
|
47
|
+
missing.push("completion");
|
|
48
|
+
if (missing.length > 0) {
|
|
49
|
+
throw new Error(`[attestomcp] mount(): missing required ceremony seam(s): ${missing.join(", ")}. ` +
|
|
50
|
+
`Provide them via attestomcp.mount(app, { ... }) or app.locals.attestomcp.`);
|
|
51
|
+
}
|
|
52
|
+
// The challenge HMAC must survive an instance split (options→verify may hit
|
|
53
|
+
// different serverless instances — D6). We do NOT infer "serverless"; an
|
|
54
|
+
// ephemeral per-process key is allowed ONLY when the host opts in explicitly.
|
|
55
|
+
if (!signingKey) {
|
|
56
|
+
if (!allowEphemeralKey) {
|
|
57
|
+
throw new Error(`[attestomcp] mount(): a stable 'signingKey' is required so the challenge HMAC survives an instance split. ` +
|
|
58
|
+
`Pass { signingKey } (e.g. process.env.GATE_SECRET), or { allowEphemeralKey: true } for a single-process dev server.`);
|
|
59
|
+
}
|
|
60
|
+
signingKey = randomBytes(32).toString("hex");
|
|
61
|
+
}
|
|
62
|
+
const ctx = {
|
|
63
|
+
verificationStore: verificationStore,
|
|
64
|
+
orderStore: orderStore,
|
|
65
|
+
catalog: catalog,
|
|
66
|
+
completion: completion,
|
|
67
|
+
signingKey,
|
|
68
|
+
origin,
|
|
69
|
+
...(settlement ? { settlement } : {}),
|
|
70
|
+
};
|
|
71
|
+
// Re-expose the resolved seams on app.locals so the storefront's gate routes
|
|
72
|
+
// resolve verification THROUGH AttestoMcp (and a re-mount is idempotent).
|
|
73
|
+
app.locals.attestomcp = { ...app.locals.attestomcp, store: ctx.verificationStore, ...ctx };
|
|
74
|
+
for (const register of RAILS)
|
|
75
|
+
register(app, ctx);
|
|
76
|
+
return ctx;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Shared order resolution + re-pricing (T003a). Resolve a created order by id
|
|
80
|
+
* from the injected store, then RE-PRICE it from the catalog — the displayed and
|
|
81
|
+
* bound amounts come from the catalog, never the id/token (CT3, invariants 2/3).
|
|
82
|
+
* A tampered or unknown id resolves to `null` (the rail refuses).
|
|
83
|
+
*/
|
|
84
|
+
export async function resolveOrder(ctx, orderId) {
|
|
85
|
+
if (!orderId)
|
|
86
|
+
return null;
|
|
87
|
+
const stored = await ctx.orderStore.read(orderId);
|
|
88
|
+
if (!stored || stored.id !== orderId || !Array.isArray(stored.lines))
|
|
89
|
+
return null;
|
|
90
|
+
// A loyalty discount is applied only when THIS order's verification opts in
|
|
91
|
+
// (invariant 3); the line items come from the store, every price from the
|
|
92
|
+
// catalog.
|
|
93
|
+
const verification = await ctx.verificationStore.read(orderId);
|
|
94
|
+
const loyaltyApplied = !!verification?.loyalty?.applied;
|
|
95
|
+
return ctx.catalog.createOrder(stored.lines.map((l) => ({ productId: l.id, quantity: l.quantity })), orderId, { loyaltyApplied });
|
|
96
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface RequestLike {
|
|
2
|
+
headers: Record<string, string | string[] | undefined>;
|
|
3
|
+
host: string;
|
|
4
|
+
protocol: string;
|
|
5
|
+
}
|
|
6
|
+
export interface Origin {
|
|
7
|
+
rpID: string;
|
|
8
|
+
origin: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function deriveOrigin(req: RequestLike): Origin;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
function first(value) {
|
|
2
|
+
return Array.isArray(value) ? value[0] : value;
|
|
3
|
+
}
|
|
4
|
+
export function deriveOrigin(req) {
|
|
5
|
+
const host = first(req.headers["x-forwarded-host"]) ?? req.host;
|
|
6
|
+
const proto = first(req.headers["x-forwarded-proto"]) ?? req.protocol;
|
|
7
|
+
const rpID = host.split(":")[0];
|
|
8
|
+
return { rpID, origin: `${proto}://${host}` };
|
|
9
|
+
}
|