@coordination-network/user-app-core 0.0.1

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/dist/index.cjs ADDED
@@ -0,0 +1,754 @@
1
+ 'use strict';
2
+
3
+ var identity = require('@coordination-network/identity');
4
+ var credential = require('@coordination-network/credential');
5
+ var userAppLink = require('@coordination-network/user-app-link');
6
+ var issuerSet = require('@coordination-network/issuer-set');
7
+ var revocationRegistry = require('@coordination-network/revocation-registry');
8
+ var wallet = require('@coordination-network/wallet');
9
+ var zod = require('zod');
10
+ var userClient = require('@coordination-network/user-client');
11
+
12
+ /**
13
+ * `createRequestHandler` calls this after confirming `request.op` is a known op and validating
14
+ * `params` against its schema, so the casts below are re-attaching types the caller already
15
+ * proved, not asserting past them — an exhaustive switch covers every case with no defensive
16
+ * default branch needed. Every value here ends up behind the sheet's "Technical details" toggle;
17
+ * the sentence above it is the app's, not this package's.
18
+ */
19
+ function buildConsentSummary(request) {
20
+ const caller = request.caller;
21
+ // The callback is the only signal against a spoofed caller, so it is never shortened.
22
+ const callback = { label: "Callback", value: request.callback, truncate: false };
23
+ switch (request.op) {
24
+ case "registration.sign": {
25
+ const p = request.params;
26
+ return {
27
+ op: "registration.sign",
28
+ caller,
29
+ details: [
30
+ callback,
31
+ // The signature is bound to this contract, so it names what approving commits to.
32
+ { label: "Registry contract", value: p.verifyingContract },
33
+ { label: "Chain ID", value: String(p.chainId) }
34
+ ]
35
+ };
36
+ }
37
+ case "credential.issue": {
38
+ const p = request.params;
39
+ return {
40
+ op: "credential.issue",
41
+ caller,
42
+ scope: { programme: p.approvalScope.programme, country: p.approvalScope.country },
43
+ details: [
44
+ { label: "Issuer", value: p.issuerNetworkId },
45
+ // This app posts the binding signatures there itself, so this row is the one
46
+ // that says where they go. The caller picks the URL, and the schema holds all
47
+ // four issuance URLs to that one origin, so this row speaks for the rest.
48
+ { label: "Issuer server", value: p.credentialsUrl },
49
+ { label: "Programme", value: p.approvalScope.programme },
50
+ { label: "Country", value: p.approvalScope.country },
51
+ callback
52
+ ]
53
+ };
54
+ }
55
+ case "presentation.create": {
56
+ const p = request.params;
57
+ return {
58
+ op: "presentation.create",
59
+ caller,
60
+ scope: { programme: p.programme, country: p.country },
61
+ details: [
62
+ { label: "Verifier", value: p.audience },
63
+ // The proof is posted by this app, not handed back to the caller, so this row
64
+ // is the one that says where it actually goes. Unlike the callback, this URL
65
+ // is not scheme-restricted, and a caller picks it.
66
+ { label: "Proof is sent to", value: p.responseUrl },
67
+ callback,
68
+ { label: "Programme", value: p.programme },
69
+ { label: "Country", value: p.country }
70
+ ]
71
+ };
72
+ }
73
+ }
74
+ }
75
+
76
+ /**
77
+ * This package holds exactly one identity, so unlike `Wallet`'s `key:`-prefixed store (built for
78
+ * several imported keys) the device secret lives under one fixed key.
79
+ */
80
+ const DEVICE_SECRET_KEY = "user-app-core:device-secret";
81
+ async function readDeviceSecret(store) {
82
+ return (await store.get(DEVICE_SECRET_KEY));
83
+ }
84
+ async function writeDeviceSecret(store, secret) {
85
+ await store.set(DEVICE_SECRET_KEY, secret);
86
+ }
87
+
88
+ /**
89
+ * `GET /v1/issuers` carries a `root` the server read from chain independently of the `leaves` it
90
+ * also served, so `Wallet`'s comparison between the tree rebuilt from `leaves` and
91
+ * `approvedIssuerRoot` catches a server that served a mismatched pair.
92
+ */
93
+ async function readIssuerSet(http, issuersUrl) {
94
+ const response = await http.getJson(issuersUrl);
95
+ if (response.status !== 200) {
96
+ throw new Error(`GET ${issuersUrl} returned ${response.status}`);
97
+ }
98
+ const { leaves, root } = response.body;
99
+ return {
100
+ approvedIssuers: issuerSet.ApprovedIssuerSet.fromLeaves(leaves.map((leaf) => BigInt(leaf))),
101
+ approvedIssuerRoot: BigInt(root)
102
+ };
103
+ }
104
+ /** The snapshot's own `root` gives the revocation side the same cross-check. */
105
+ async function readRevocations(http, revocationsUrl) {
106
+ const response = await http.getJson(revocationsUrl);
107
+ if (response.status !== 200) {
108
+ throw new Error(`GET ${revocationsUrl} returned ${response.status}`);
109
+ }
110
+ const { root, snapshot } = response.body;
111
+ return { revocations: revocationRegistry.RevocationRegistry.import(snapshot), revocationRoot: BigInt(root) };
112
+ }
113
+ /**
114
+ * Both lists are named per request by `issuersUrl`/`revocationsUrl`, so this is built fresh per
115
+ * request and never held onto. A handler with several credentials to check reads the two halves
116
+ * itself instead, so one read of each serves them all (see `presentation-create.ts`).
117
+ */
118
+ function buildChainStateLoader(http, issuersUrl, revocationsUrl) {
119
+ return async () => ({
120
+ ...(await readIssuerSet(http, issuersUrl)),
121
+ ...(await readRevocations(http, revocationsUrl))
122
+ });
123
+ }
124
+
125
+ /**
126
+ * `WalletError` and `LinkError` speak different vocabularies for the same underlying failures.
127
+ * Split along one line: a malformed or structurally-invalid credential is `INVALID_REQUEST`;
128
+ * a well-formed credential that is simply not usable right now (not yet valid, expired, its
129
+ * issuer retired) is `NO_CREDENTIAL`, while a revoked one says so outright. A chain-state failure
130
+ * means whichever server published those lists is unreachable or inconsistent, not a fault in
131
+ * this app, hence an unreachable code rather than `INTERNAL`.
132
+ */
133
+ const WALLET_ERROR_TO_LINK_ERROR_CODE = {
134
+ NOT_FOUND: "NO_CREDENTIAL",
135
+ ALREADY_STORED: "INVALID_REQUEST",
136
+ CREDENTIAL_INVALID: "INVALID_REQUEST",
137
+ CONSENT_DENIED: "CONSENT_DENIED",
138
+ REVOKED: "CREDENTIAL_REVOKED",
139
+ CHAIN_STATE_UNAVAILABLE: "VERIFIER_UNREACHABLE",
140
+ CHAIN_STATE_INVALID: "VERIFIER_UNREACHABLE",
141
+ NOT_YET_VALID: "NO_CREDENTIAL",
142
+ EXPIRED: "NO_CREDENTIAL",
143
+ ISSUER_NOT_APPROVED: "NO_CREDENTIAL",
144
+ PRESENTATION_FAILED: "PROOF_FAILED"
145
+ };
146
+ /**
147
+ * Preserves the original message on the thrown `LinkError`. `chainStateCode` names the side whose
148
+ * lists were being read: during issuance that is the issuer, not a verifier.
149
+ *
150
+ * Takes `unknown` rather than `WalletError` because the wallet's sync methods are not total over
151
+ * `WalletError`: they parse each stored credential and write each record back, so a corrupt record
152
+ * or a failing device store throws something else entirely. Indexing the table with that error's
153
+ * absent `code` would have produced a `LinkError` with no code at all.
154
+ */
155
+ function mapWalletError(error, chainStateCode) {
156
+ if (!(error instanceof wallet.WalletError)) {
157
+ return new userAppLink.LinkError("INTERNAL", error instanceof Error ? error.message : String(error));
158
+ }
159
+ const isChainState = error.code === "CHAIN_STATE_UNAVAILABLE" || error.code === "CHAIN_STATE_INVALID";
160
+ const code = isChainState && chainStateCode ? chainStateCode : WALLET_ERROR_TO_LINK_ERROR_CODE[error.code];
161
+ return new userAppLink.LinkError(code, error.message);
162
+ }
163
+
164
+ /** Encodes a field element as the 32-byte big-endian value the binding and commitment builders expect. */
165
+ function bigintToBytes32(value) {
166
+ return identity.hexToBytes(`0x${value.toString(16).padStart(64, "0")}`);
167
+ }
168
+
169
+ /**
170
+ * The member-server responses the wallet reads for itself: the two issuance routes it now drives
171
+ * alone, and the verify route it posts a proof to. `@coordination-network/network-member-app-core`
172
+ * keeps its own schemas for the routes the member app still calls; the two packages do not depend on
173
+ * each other, and both are fixed by `apps/issuer-service`.
174
+ */
175
+ /**
176
+ * `POST /v1/challenge` writes its nonce with `@noble/hashes`' `bytesToHex`, which never adds the
177
+ * `0x` prefix every other hex field in this protocol carries - accept it either way and normalize
178
+ * to the `0x`-prefixed, lowercase form the binding builders require.
179
+ */
180
+ const challengeNonceSchema = zod.z
181
+ .string()
182
+ .regex(/^(0x)?[0-9a-fA-F]{64}$/i, "must be 32-byte hex")
183
+ .transform((value) => `0x${value.replace(/^0x/i, "")}`.toLowerCase());
184
+ const challengeResponseSchema = zod.z.object({ nonce: challengeNonceSchema, expiresAt: zod.z.number() }).strict();
185
+ const issueCredentialResponseSchema = zod.z.object({ id: zod.z.string().min(1), credential: zod.z.unknown() }).strict();
186
+ /**
187
+ * Not `.strict()`: `POST /v1/verify` also answers with its own `ok`/`code`, which the wallet
188
+ * deliberately does not relay — the member app reads the outcome from its own server instead.
189
+ */
190
+ const verifyResponseSchema = zod.z.object({ verificationId: zod.z.string().min(1) });
191
+ /** Not `.strict()`: an error body is read for `code`/`error`, whichever route sent it. */
192
+ const errorBodySchema = zod.z.object({ error: zod.z.string().optional(), code: zod.z.string().optional() });
193
+ /**
194
+ * One POST to a member's own server. An answer the route is specified to give is a value; any
195
+ * other answer carrying an error is handed back as the member's own words, for the caller to say
196
+ * what they amount to; anything else means the server is not usefully there, under whichever
197
+ * unreachable code names the side being talked to.
198
+ */
199
+ async function postToMember(http, url, body, expected) {
200
+ let response;
201
+ try {
202
+ response = await http.postJson(url, body);
203
+ }
204
+ catch (error) {
205
+ throw new userAppLink.LinkError(expected.unreachable, error instanceof Error ? error.message : `POST ${url} failed`);
206
+ }
207
+ const parsed = expected.schema.safeParse(response.body);
208
+ if (response.status === expected.status && parsed.success)
209
+ return { value: parsed.data };
210
+ const failure = errorBodySchema.safeParse(response.body);
211
+ if (failure.success && failure.data.error !== undefined) {
212
+ return { refusal: { code: failure.data.code, message: failure.data.error } };
213
+ }
214
+ throw new userAppLink.LinkError(expected.unreachable, `${url} returned an unusable response`);
215
+ }
216
+
217
+ /**
218
+ * `issuerNetworkId` is asserted by the caller and cannot be verified here. An established mapping
219
+ * still cannot be taken over while the credential under it is valid: `credential.issue` answers
220
+ * `alreadyHeld` for such a key rather than issuing against it again.
221
+ */
222
+ const ISSUER_INDEX_PREFIX = "user-app-core:issuer-index:";
223
+ /**
224
+ * One key per issuer *and* approval scope, so a member running two programmes can issue for both.
225
+ * The two scope parts are percent-encoded, which escapes the separator, so the issuer id is always
226
+ * the first of exactly three segments.
227
+ */
228
+ const keyFor = (issuerNetworkId, scope) => `${ISSUER_INDEX_PREFIX}${issuerNetworkId.toLowerCase()}:${encodeURIComponent(scope.programme)}:${encodeURIComponent(scope.country)}`;
229
+ async function readIssuerEntry(store, issuerNetworkId, scope) {
230
+ const raw = await store.get(keyFor(issuerNetworkId, scope));
231
+ return raw === null ? null : JSON.parse(raw);
232
+ }
233
+ /** Every entry the wallet holds, for the joins that start from a credential rather than an issuer. */
234
+ async function listIssuerEntries(store) {
235
+ const entries = [];
236
+ for (const key of await store.list()) {
237
+ if (!key.startsWith(ISSUER_INDEX_PREFIX))
238
+ continue;
239
+ const raw = await store.get(key);
240
+ if (raw === null)
241
+ continue;
242
+ entries.push({
243
+ ...JSON.parse(raw),
244
+ issuerNetworkId: key.slice(ISSUER_INDEX_PREFIX.length).split(":")[0]
245
+ });
246
+ }
247
+ return entries;
248
+ }
249
+ /** The entry carries its own scope, so it is what the key is built from. */
250
+ async function writeIssuerEntry(store, issuerNetworkId, entry) {
251
+ await store.set(keyFor(issuerNetworkId, entry), JSON.stringify(entry));
252
+ }
253
+
254
+ /** `ports.now` is optional (a test harness injects a deterministic clock); real ports omit it. */
255
+ function nowFn(ports) {
256
+ return (ports.now ?? Date.now)();
257
+ }
258
+
259
+ /**
260
+ * The steps `credential.issue` reports, in the order it reports them. Named here rather than
261
+ * spelled out twice, so the screen that groups the six into the three a person approved cannot
262
+ * drift from the handler reporting them.
263
+ */
264
+ const CREDENTIAL_ISSUE_STEPS = {
265
+ held: "Checking what you hold",
266
+ lists: "Checking the lists",
267
+ challenge: "Getting a challenge",
268
+ binding: "Binding to this phone",
269
+ signing: "Asking the issuer to sign",
270
+ storing: "Saving to your wallet"
271
+ };
272
+
273
+ /**
274
+ * The Baby Jubjub identity seed, rooted in the hardware ECDH rather than the device secret.
275
+ * `deriveEddsaKeypair` returns `seed` as a `0x`-prefixed hex string — handing that string to
276
+ * Semaphore derives a different identity (`DERIVATION.md` §12.4); `Wallet.identitySeed` must be
277
+ * the 32 raw bytes decoded from it.
278
+ */
279
+ async function resolveIdentitySeed(deviceKey) {
280
+ const sharedSecret = await deviceKey.ecdh(identity.eddsaPeerPointBytes());
281
+ return identity.hexToBytes(identity.deriveEddsaKeypair(sharedSecret).seed);
282
+ }
283
+ /**
284
+ * `listCredentials` never syncs — there is no single authority to sync a mixed-issuer listing
285
+ * against — so this sentinel documents that, and would surface loudly if a future bug called it.
286
+ */
287
+ const neverLoadChainState = async () => {
288
+ throw new Error("chain state must not be requested outside a per-request authority");
289
+ };
290
+ /**
291
+ * A fresh `Wallet` per request, scoped to one member's authority. The private `Wallet` gets a
292
+ * constant-`true` consent prompt: the person has already been asked once, at the link boundary,
293
+ * and prompting again here would show a second screen for one `presentation.create`.
294
+ */
295
+ async function buildWallet(ports, loadChainState) {
296
+ const identitySeed = await resolveIdentitySeed(ports.deviceKey);
297
+ return new wallet.Wallet({
298
+ identitySeed,
299
+ deviceSigner: ports.deviceKey,
300
+ consent: async () => true,
301
+ proofGenerator: ports.proofGenerator,
302
+ store: ports.store,
303
+ now: () => nowFn(ports),
304
+ loadChainState
305
+ });
306
+ }
307
+
308
+ /** The issuer answered, but named no reason of its own. */
309
+ const UNNAMED_REFUSAL = "ISSUANCE_REFUSED";
310
+ /** A Baby Jubjub point as the decimal-string pair the wire protocol carries. */
311
+ const pointToStrings = (point) => [String(point[0]), String(point[1])];
312
+ /** Relayed in the issuer's own words, under a stand-in code when it named none of its own. */
313
+ const refused = ({ code, message }) => ({
314
+ status: "refused",
315
+ code: code ?? UNNAMED_REFUSAL,
316
+ message
317
+ });
318
+ /**
319
+ * Whether an indexed credential still stops this member issuing another for the same programme.
320
+ * Only one that is valid right now does: a revoked or expired credential, or an entry whose
321
+ * credential is gone, would otherwise leave the person with no way back but erasing everything.
322
+ */
323
+ async function isStillValid(wallet, credentialId, load) {
324
+ if ((await wallet.getCredential(credentialId)) === null)
325
+ return false;
326
+ await wallet.syncCredential(credentialId, load);
327
+ return (await wallet.getCredential(credentialId))?.status === "valid";
328
+ }
329
+ /**
330
+ * The whole issuance, behind the one consent the link boundary already took: hold check, nonce,
331
+ * binding, the issuer's signature, the two authority checks, storage. Assembly over existing
332
+ * functions, no new cryptography.
333
+ *
334
+ * The revocation secret is generated and opened inside this one call, so it never leaves the
335
+ * function and nothing has to persist it between two deep links.
336
+ */
337
+ async function credentialIssueHandler(ports, deviceSecret, params) {
338
+ const step = (name) => ports.progress("credential.issue", name);
339
+ step(CREDENTIAL_ISSUE_STEPS.held);
340
+ const held = await readIssuerEntry(ports.store, params.issuerNetworkId, params.approvalScope);
341
+ // Read before the issuer signs: the revocation secret below never leaves this call, so a
342
+ // failure after the 201 would orphan the credential at the issuer with nothing able to open it.
343
+ step(CREDENTIAL_ISSUE_STEPS.lists);
344
+ let chainState;
345
+ try {
346
+ chainState = await buildChainStateLoader(ports.http, params.issuersUrl, params.revocationsUrl)();
347
+ }
348
+ catch (error) {
349
+ throw new userAppLink.LinkError("ISSUER_UNREACHABLE", error instanceof Error
350
+ ? error.message
351
+ : "the issuer's approved-issuer set or revocation snapshot is unreadable");
352
+ }
353
+ const loadChainState = async () => chainState;
354
+ const wallet = await buildWallet(ports, loadChainState);
355
+ if (held !== null && (await isStillValid(wallet, held.credentialId, loadChainState))) {
356
+ return { status: "alreadyHeld", credentialId: held.credentialId };
357
+ }
358
+ const keys = identity.deriveKeys(deviceSecret);
359
+ step(CREDENTIAL_ISSUE_STEPS.challenge);
360
+ const challenge = await postToMember(ports.http, params.challengeUrl, { subjectNetworkId: keys.networkId }, { status: 200, schema: challengeResponseSchema, unreachable: "ISSUER_UNREACHABLE" });
361
+ if (challenge.refusal)
362
+ return refused(challenge.refusal);
363
+ const nonce = challenge.value.nonce;
364
+ step(CREDENTIAL_ISSUE_STEPS.binding);
365
+ const confirmationKey = await ports.deviceKey.getPublicKeyJwk();
366
+ const eddsa = identity.deriveEddsaKeypair(await ports.deviceKey.ecdh(identity.eddsaPeerPointBytes()));
367
+ const revocationSecret = credential.generateRevocationSecret();
368
+ const ownership = credential.signCommitmentOwnership(revocationSecret, identity.hexToBytes(nonce));
369
+ const deviceBindingMessage = identity.buildDeviceBindingMessage(nonce, keys.networkId, bigintToBytes32(eddsa.commitment));
370
+ const devicePopSignature = identity.bytesToHex(await ports.deviceKey.sign(deviceBindingMessage));
371
+ const { signature: accountPopSignature } = identity.signAccountBinding(keys.account.privateKey, nonce, credential.jwkToPublicKeyBytes(confirmationKey));
372
+ step(CREDENTIAL_ISSUE_STEPS.signing);
373
+ const issued = await postToMember(ports.http, params.credentialsUrl, {
374
+ subjectNetworkId: keys.networkId,
375
+ approvalScope: params.approvalScope,
376
+ confirmationKey,
377
+ accountPublicKey: keys.account.uncompressedPublicKey,
378
+ devicePopSignature,
379
+ accountPopSignature,
380
+ identityPublicKey: pointToStrings(eddsa.publicKey),
381
+ revocation: {
382
+ commitment: credential.deriveRevocationCommitment(revocationSecret).toString(),
383
+ publicKey: pointToStrings(ownership.publicKey),
384
+ ownershipSignature: {
385
+ R8: pointToStrings(ownership.signature.R8),
386
+ S: String(ownership.signature.S)
387
+ }
388
+ }
389
+ }, { status: 201, schema: issueCredentialResponseSchema, unreachable: "ISSUER_UNREACHABLE" });
390
+ if (issued.refusal)
391
+ return refused(issued.refusal);
392
+ let issuerPublicKey;
393
+ try {
394
+ const parsed = credential.parseZkCredential(issued.value.credential);
395
+ issuerPublicKey = [parsed.issuerPublicKey[0].toString(), parsed.issuerPublicKey[1].toString()];
396
+ }
397
+ catch (error) {
398
+ throw new userAppLink.LinkError("ISSUER_UNREACHABLE", error instanceof Error ? error.message : "malformed credential");
399
+ }
400
+ // Built above for the hold check; the lists are read before the signature, so this is the
401
+ // only step left that can fail.
402
+ step(CREDENTIAL_ISSUE_STEPS.storing);
403
+ let credentialId;
404
+ try {
405
+ credentialId = await wallet.addCredential({
406
+ id: issued.value.id,
407
+ credential: issued.value.credential,
408
+ revocationSecret
409
+ });
410
+ }
411
+ catch (error) {
412
+ throw mapWalletError(error, "ISSUER_UNREACHABLE");
413
+ }
414
+ await writeIssuerEntry(ports.store, params.issuerNetworkId, {
415
+ credentialId,
416
+ issuerPublicKey,
417
+ issuersUrl: params.issuersUrl,
418
+ programme: params.approvalScope.programme,
419
+ country: params.approvalScope.country,
420
+ issuedAt: Math.floor(nowFn(ports) / 1000)
421
+ });
422
+ return { status: "stored", credentialId };
423
+ }
424
+
425
+ /**
426
+ * `presentation.create` carries no credential id, so this narrows by what the credential body
427
+ * says: the requested programme and country. Status is deliberately not a filter — each candidate
428
+ * is then synced against its own issuer's snapshot, and a caller that finds none valid still
429
+ * needs to know whether it held a revoked one.
430
+ */
431
+ function matchingCredentials(credentials, request) {
432
+ const wantedProgramme = credential.programmeHash(request.programme);
433
+ const wantedCountry = credential.countryCode(request.country);
434
+ return credentials.filter((info) => {
435
+ const { body } = credential.parseZkCredential(info.credential);
436
+ return body.programmeHash === wantedProgramme && body.countryCode === wantedCountry;
437
+ });
438
+ }
439
+ /** Of the credentials still usable, the one that stays usable longest, tie-broken by id for determinism. */
440
+ function latestExpiring(credentials) {
441
+ if (credentials.length === 0)
442
+ return null;
443
+ return credentials.reduce((best, candidate) => {
444
+ const bestExpiry = credential.parseZkCredential(best.credential).body.expiresAt;
445
+ const candidateExpiry = credential.parseZkCredential(candidate.credential).body.expiresAt;
446
+ if (candidateExpiry !== bestExpiry)
447
+ return candidateExpiry > bestExpiry ? candidate : best;
448
+ return candidate.id < best.id ? candidate : best;
449
+ });
450
+ }
451
+
452
+ async function presentationCreateHandler(ports, params) {
453
+ const step = (name) => ports.progress("presentation.create", name);
454
+ // The state is read below, once the request is known to have candidates, so the wallet is built
455
+ // without an authority and handed one per call.
456
+ const wallet$1 = await buildWallet(ports, neverLoadChainState);
457
+ /**
458
+ * The same for every candidate, so the request reads it once however many credentials it has
459
+ * to check — and a verifier that cannot serve it fails the whole request, since no candidate
460
+ * can be judged without it.
461
+ */
462
+ async function verifierIssuerSet() {
463
+ try {
464
+ return await readIssuerSet(ports.http, params.issuersUrl);
465
+ }
466
+ catch (error) {
467
+ throw new userAppLink.LinkError("VERIFIER_UNREACHABLE", error instanceof Error ? error.message : `GET ${params.issuersUrl} failed`);
468
+ }
469
+ }
470
+ /** Same reasoning as the issuer set: the verifier is the party that must accept this root. */
471
+ async function verifierRevocations() {
472
+ try {
473
+ return await readRevocations(ports.http, params.revocationsUrl);
474
+ }
475
+ catch (error) {
476
+ throw new userAppLink.LinkError("VERIFIER_UNREACHABLE", error instanceof Error ? error.message : `GET ${params.revocationsUrl} failed`);
477
+ }
478
+ }
479
+ async function readCandidates() {
480
+ const infoById = new Map((await wallet$1.listCredentials()).map((info) => [info.id, info]));
481
+ const held = (await listIssuerEntries(ports.store)).flatMap(({ issuerNetworkId, ...entry }) => {
482
+ const info = infoById.get(entry.credentialId);
483
+ // An entry whose credential is gone names a snapshot for nothing; a credential with
484
+ // no entry has no snapshot to be checked against. Neither can be proved.
485
+ return info === undefined ? [] : [{ ...info, issuerNetworkId, entry }];
486
+ });
487
+ return matchingCredentials(held, params);
488
+ }
489
+ step("Checking your credential");
490
+ const candidates = await readCandidates();
491
+ // One read for the whole request: every member serves the same network tree, and reading it
492
+ // per issuer was itself what told whoever served it which issuer a credential came from. The
493
+ // state is kept so the proof is built from the same read, not a second one that could disagree.
494
+ let chainState;
495
+ if (candidates.length > 0) {
496
+ const state = { ...(await verifierIssuerSet()), ...(await verifierRevocations()) };
497
+ chainState = state;
498
+ for (const candidate of candidates) {
499
+ try {
500
+ await wallet$1.syncCredential(candidate.id, async () => state);
501
+ }
502
+ catch (error) {
503
+ // A served root that does not match the tree served beside it is the verifier's
504
+ // fault, not this wallet's; without mapping it the caller is told INTERNAL.
505
+ throw mapWalletError(error, "VERIFIER_UNREACHABLE");
506
+ }
507
+ }
508
+ }
509
+ const synced = (await readCandidates()).flatMap((candidate) => chainState === undefined ? [] : [{ ...candidate, chainState }]);
510
+ const selected = latestExpiring(synced.filter((candidate) => candidate.status === "valid"));
511
+ if (selected === null) {
512
+ const revoked = latestExpiring(synced.filter((candidate) => candidate.status === "revoked"));
513
+ if (revoked !== null) {
514
+ // The refusal itself says the person holds a revoked credential, which a proof never
515
+ // would, so it goes through the same gate the proof does.
516
+ const told = await ports.review({
517
+ kind: "revoked",
518
+ verifier: params.audience,
519
+ programme: params.programme,
520
+ country: params.country,
521
+ issuerNetworkId: revoked.issuerNetworkId
522
+ });
523
+ if (!told)
524
+ throw new userAppLink.LinkError("CONSENT_DENIED", "the refusal was not shared");
525
+ throw new userAppLink.LinkError("CREDENTIAL_REVOKED", `the ${params.programme} credential you hold has been revoked`);
526
+ }
527
+ throw new userAppLink.LinkError("NO_CREDENTIAL", `no held credential matches programme "${params.programme}"/"${params.country}"`);
528
+ }
529
+ step("Proving");
530
+ let proof;
531
+ try {
532
+ proof = await wallet$1.present(selected.id, {
533
+ challenge: identity.hexToBytes(params.challenge),
534
+ audience: params.audience,
535
+ currentTime: params.currentTime,
536
+ programme: params.programme,
537
+ country: params.country
538
+ }, async () => selected.chainState);
539
+ }
540
+ catch (error) {
541
+ if (error instanceof wallet.WalletError)
542
+ throw mapWalletError(error);
543
+ // A raw prover failure, not a WalletError - keep it distinguishable from NO_CREDENTIAL above.
544
+ throw new userAppLink.LinkError("PROOF_FAILED", error instanceof Error ? error.message : "proof generation failed");
545
+ }
546
+ const approved = await ports.review({
547
+ kind: "proof",
548
+ verifier: params.audience,
549
+ programme: params.programme,
550
+ country: params.country,
551
+ issuerNetworkId: selected.issuerNetworkId
552
+ });
553
+ if (!approved)
554
+ throw new userAppLink.LinkError("CONSENT_DENIED", "the proof was built but not shared");
555
+ step("Sharing");
556
+ // Only the id comes back: the verifier's own `ok` would be the verifier reporting on itself,
557
+ // and its member app can read the recorded outcome from its own server under this id.
558
+ const posted = await postToMember(ports.http, params.responseUrl, { requestId: params.requestId, proof: identity.bytesToHex(proof.proof), publicInputs: proof.publicInputs }, { status: 200, schema: verifyResponseSchema, unreachable: "VERIFIER_UNREACHABLE" });
559
+ // An issuer's error body is the issuer refusing this person, and the wallet relays it. A
560
+ // verifier has nothing to refuse — it is answering a request the wallet made — so an error
561
+ // body from it is just an answer the wallet cannot use.
562
+ if (posted.refusal) {
563
+ throw new userAppLink.LinkError("VERIFIER_UNREACHABLE", `${params.responseUrl} returned an unusable response`);
564
+ }
565
+ return { verificationId: posted.value.verificationId };
566
+ }
567
+
568
+ async function registrationSignHandler(ports, deviceSecret, params) {
569
+ const keys = identity.deriveKeys(deviceSecret);
570
+ return {
571
+ request: userClient.buildRegistrationRequest(keys, {
572
+ chainId: params.chainId,
573
+ verifyingContract: params.verifyingContract
574
+ }),
575
+ securityLevel: await ports.deviceKey.getSecurityLevel()
576
+ };
577
+ }
578
+
579
+ /** Assembles the four values the Identity screen shows — `Wallet` exposes none of them directly. */
580
+ async function assembleIdentity(deviceSecret, deviceKey) {
581
+ const keys = identity.deriveKeys(deviceSecret);
582
+ return {
583
+ networkId: keys.networkId,
584
+ metaStealthAddress: userClient.metaStealthAddressFromKeys(keys),
585
+ accountPublicKey: keys.account.uncompressedPublicKey,
586
+ securityLevel: await deviceKey.getSecurityLevel()
587
+ };
588
+ }
589
+
590
+ /**
591
+ * Still not `Wallet`'s own status: that also carries `issuer-not-approved`, which `syncRevocations`
592
+ * stamps against whichever single member last asked for a presentation, and reporting one member's
593
+ * verdict on another member's credential would be worse than reporting nothing.
594
+ *
595
+ * Revocation is no longer in that category. It used to be - one registry per issuer left a
596
+ * mixed-issuer listing with no single authority to sync against - but the whole network now revokes
597
+ * into one registry, so there is exactly one thing to ask and asking it names no issuer.
598
+ */
599
+ function windowStatus(notBefore, expiresAt, nowSeconds) {
600
+ if (nowSeconds < notBefore)
601
+ return "not-yet-valid";
602
+ if (nowSeconds > expiresAt)
603
+ return "expired";
604
+ return "valid";
605
+ }
606
+ /**
607
+ * The window first, since it is signed into the credential and needs no one to confirm: a
608
+ * credential already outside its dates is reported as such without disclosing anything to a node.
609
+ * Only an otherwise usable credential is worth asking the registry about.
610
+ */
611
+ async function statusOf(notBefore, expiresAt, nowSeconds, revocationCommitment, authority) {
612
+ const window = windowStatus(notBefore, expiresAt, nowSeconds);
613
+ if (window !== "valid")
614
+ return window;
615
+ if (authority === undefined)
616
+ return "unchecked";
617
+ // A listing has to render whatever the network is doing, so an unreachable authority is an
618
+ // answer here - "not checked" - rather than a failure that blanks the screen.
619
+ let revoked;
620
+ try {
621
+ revoked = await authority.isRevoked(revocationCommitment);
622
+ }
623
+ catch {
624
+ revoked = null;
625
+ }
626
+ if (revoked === null)
627
+ return "unchecked";
628
+ return revoked ? "revoked" : "valid";
629
+ }
630
+ /** Inverse of `packages/credential`'s `countryCode`: three ASCII bytes packed into one integer. */
631
+ function decodeCountryCode(code) {
632
+ return String.fromCharCode((code >> 16) & 0xff, (code >> 8) & 0xff, code & 0xff);
633
+ }
634
+ function createUserAppService(ports) {
635
+ // `undefined` means "not yet read from the store"; `null` means "confirmed not onboarded".
636
+ // Distinguishing the two means onboarding status is read from the store at most once.
637
+ let deviceSecretCache;
638
+ async function resolveDeviceSecret() {
639
+ if (deviceSecretCache === undefined) {
640
+ deviceSecretCache = await readDeviceSecret(ports.store);
641
+ }
642
+ return deviceSecretCache;
643
+ }
644
+ /** The gate every handler shares: onboarding is an explicit app action, never implicit. */
645
+ async function requireOnboarded() {
646
+ const secret = await resolveDeviceSecret();
647
+ if (secret === null)
648
+ throw new userAppLink.LinkError("NOT_ONBOARDED", "no identity has been created yet");
649
+ return secret;
650
+ }
651
+ // One slot deep: a handler body reads and writes the same store a concurrent one might be
652
+ // mid-sync against, so two deep links run their handler bodies one at a time, never interleaved.
653
+ let queue = Promise.resolve();
654
+ function serialize(run) {
655
+ const result = queue.then(run);
656
+ queue = result.then(() => undefined, () => undefined);
657
+ return result;
658
+ }
659
+ const handlers = {
660
+ "registration.sign": (params) => serialize(async () => registrationSignHandler(ports, await requireOnboarded(), params)),
661
+ "credential.issue": (params) => serialize(async () => credentialIssueHandler(ports, await requireOnboarded(), params)),
662
+ "presentation.create": (params) => serialize(async () => {
663
+ await requireOnboarded();
664
+ return presentationCreateHandler(ports, params);
665
+ })
666
+ };
667
+ return {
668
+ handlers,
669
+ async consent(request) {
670
+ return ports.consent(buildConsentSummary(request));
671
+ },
672
+ async isOnboarded() {
673
+ return (await resolveDeviceSecret()) !== null;
674
+ },
675
+ createIdentity() {
676
+ return serialize(async () => {
677
+ if ((await resolveDeviceSecret()) !== null) {
678
+ throw new Error("createIdentity: an identity already exists — erase() before creating another");
679
+ }
680
+ const secret = identity.generateDeviceSecret();
681
+ const identity$1 = await assembleIdentity(secret, ports.deviceKey);
682
+ await writeDeviceSecret(ports.store, secret);
683
+ deviceSecretCache = secret;
684
+ return identity$1;
685
+ });
686
+ },
687
+ async readIdentity() {
688
+ const secret = await resolveDeviceSecret();
689
+ if (secret === null)
690
+ throw new Error("readIdentity: no identity has been created yet");
691
+ return assembleIdentity(secret, ports.deviceKey);
692
+ },
693
+ async readStealthScanKeys() {
694
+ const secret = await resolveDeviceSecret();
695
+ if (secret === null)
696
+ throw new Error("readStealthScanKeys: no identity has been created yet");
697
+ const keys = identity.deriveKeys(secret);
698
+ return { spendingPublicKey: keys.spending.publicKey, viewingPrivateKey: keys.viewing.privateKey };
699
+ },
700
+ /**
701
+ * Joined by credential id with the issuer index, which is the only place the issuer's
702
+ * name and the plaintext programme survive — the credential body carries a hash.
703
+ */
704
+ async listCredentials() {
705
+ const wallet = await buildWallet(ports, neverLoadChainState);
706
+ const held = await wallet.listCredentials();
707
+ const entryByCredentialId = new Map((await listIssuerEntries(ports.store)).map((entry) => [entry.credentialId, entry]));
708
+ const nowSeconds = Math.floor(nowFn(ports) / 1000);
709
+ const named = held.flatMap((info) => {
710
+ const entry = entryByCredentialId.get(info.id);
711
+ // A credential with no entry predates the index and cannot be named or proved;
712
+ // the demo erases and reinstalls rather than migrating.
713
+ if (entry === undefined)
714
+ return [];
715
+ return [{ info, entry, body: credential.parseZkCredential(info.credential).body }];
716
+ });
717
+ // One registry read per usable credential, all in flight together: a listing of two
718
+ // credentials should not cost two round trips in sequence.
719
+ return Promise.all(named.map(async ({ info, entry, body }) => ({
720
+ id: info.id,
721
+ status: await statusOf(body.notBefore, body.expiresAt, nowSeconds, body.revocationCommitment, ports.revocations),
722
+ issuerNetworkId: entry.issuerNetworkId,
723
+ programme: entry.programme,
724
+ issuedAt: entry.issuedAt,
725
+ programmeHash: body.programmeHash.toString(),
726
+ country: decodeCountryCode(body.countryCode),
727
+ notBefore: body.notBefore,
728
+ expiresAt: body.expiresAt
729
+ })));
730
+ },
731
+ /**
732
+ * Clears the entire store, not only this package's own keys — a host app shares it, and an
733
+ * "erase everything" that left its configuration behind would be a half-erase. A caller
734
+ * must therefore drop whatever it keeps in there too.
735
+ */
736
+ erase() {
737
+ return serialize(async () => {
738
+ for (const key of await ports.store.list())
739
+ await ports.store.delete(key);
740
+ deviceSecretCache = null;
741
+ });
742
+ }
743
+ };
744
+ }
745
+
746
+ exports.CREDENTIAL_ISSUE_STEPS = CREDENTIAL_ISSUE_STEPS;
747
+ exports.DEVICE_SECRET_KEY = DEVICE_SECRET_KEY;
748
+ exports.buildConsentSummary = buildConsentSummary;
749
+ exports.createUserAppService = createUserAppService;
750
+ exports.latestExpiring = latestExpiring;
751
+ exports.listIssuerEntries = listIssuerEntries;
752
+ exports.mapWalletError = mapWalletError;
753
+ exports.matchingCredentials = matchingCredentials;
754
+ exports.readIssuerEntry = readIssuerEntry;