@coordination-network/user-app-core 0.0.2 → 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/dist/index.cjs CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  var identity = require('@coordination-network/identity');
4
4
  var credential = require('@coordination-network/credential');
5
+ var wallet = require('@coordination-network/wallet');
5
6
  var userAppLink = require('@coordination-network/user-app-link');
7
+ var identity$1 = require('@semaphore-protocol/identity');
6
8
  var issuerSet = require('@coordination-network/issuer-set');
7
9
  var revocationRegistry = require('@coordination-network/revocation-registry');
8
- var wallet = require('@coordination-network/wallet');
9
10
  var zod = require('zod');
10
11
  var userClient = require('@coordination-network/user-client');
11
12
 
@@ -85,43 +86,59 @@ async function writeDeviceSecret(store, secret) {
85
86
  await store.set(DEVICE_SECRET_KEY, secret);
86
87
  }
87
88
 
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
89
  async function readIssuerSet(http, issuersUrl) {
94
90
  const response = await http.getJson(issuersUrl);
95
91
  if (response.status !== 200) {
96
92
  throw new Error(`GET ${issuersUrl} returned ${response.status}`);
97
93
  }
98
- const { leaves, root } = response.body;
99
- return {
100
- approvedIssuers: issuerSet.ApprovedIssuerSet.fromLeaves(leaves.map((leaf) => BigInt(leaf))),
101
- approvedIssuerRoot: BigInt(root)
102
- };
94
+ const { leaves } = response.body;
95
+ return issuerSet.ApprovedIssuerSet.fromLeaves(leaves.map((leaf) => BigInt(leaf)));
103
96
  }
104
- /** The snapshot's own `root` gives the revocation side the same cross-check. */
105
97
  async function readRevocations(http, revocationsUrl) {
106
98
  const response = await http.getJson(revocationsUrl);
107
99
  if (response.status !== 200) {
108
100
  throw new Error(`GET ${revocationsUrl} returned ${response.status}`);
109
101
  }
110
- const { root, snapshot } = response.body;
111
- return { revocations: revocationRegistry.RevocationRegistry.import(snapshot), revocationRoot: BigInt(root) };
102
+ const { snapshot } = response.body;
103
+ return revocationRegistry.RevocationRegistry.import(snapshot);
112
104
  }
113
105
  /**
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`).
106
+ * Bulk data only: the `root` each endpoint also serves is not read, because the server choosing
107
+ * both halves of the comparison makes it pass whatever it serves. The caller pairs these trees
108
+ * with roots read from chain, and `listsMatchChain` rejects a pair that disagrees.
117
109
  */
118
- function buildChainStateLoader(http, issuersUrl, revocationsUrl) {
119
- return async () => ({
120
- ...(await readIssuerSet(http, issuersUrl)),
121
- ...(await readRevocations(http, revocationsUrl))
122
- });
110
+ async function readLists(http, issuersUrl, revocationsUrl) {
111
+ const [approvedIssuers, revocations] = await Promise.all([
112
+ readIssuerSet(http, issuersUrl),
113
+ readRevocations(http, revocationsUrl)
114
+ ]);
115
+ return { approvedIssuers, revocations };
116
+ }
117
+ /** Both roots, and the issuer set's leaf count, which with its root fixes every leaf. */
118
+ function listsMatchChain(lists, chain) {
119
+ return (lists.approvedIssuers.root === chain.approvedIssuerRoot &&
120
+ lists.approvedIssuers.leafCount === chain.approvedIssuerLeafCount &&
121
+ lists.revocations.root === chain.revocationRoot);
123
122
  }
124
123
 
124
+ /**
125
+ * A key use the person or the platform ended: cancelled at the prompt, the key invalidated, or the
126
+ * authentication itself failed (lockout, no foreground activity, another prompt already showing).
127
+ */
128
+ class DeviceKeyError extends Error {
129
+ constructor(code, message = code) {
130
+ super(message);
131
+ this.name = "DeviceKeyError";
132
+ this.code = code;
133
+ }
134
+ }
135
+ /** A cancel is the person's refusal; anything else the key reports is this app's fault, not a request problem. */
136
+ function mapDeviceKeyError(error) {
137
+ if (error instanceof DeviceKeyError && error.code === "USER_CANCELED") {
138
+ return new userAppLink.LinkError("CONSENT_DENIED", "the person cancelled the device prompt");
139
+ }
140
+ return new userAppLink.LinkError("INTERNAL", error instanceof Error ? error.message : String(error));
141
+ }
125
142
  /**
126
143
  * `WalletError` and `LinkError` speak different vocabularies for the same underlying failures.
127
144
  * Split along one line: a malformed or structurally-invalid credential is `INVALID_REQUEST`;
@@ -153,6 +170,9 @@ const WALLET_ERROR_TO_LINK_ERROR_CODE = {
153
170
  * absent `code` would have produced a `LinkError` with no code at all.
154
171
  */
155
172
  function mapWalletError(error, chainStateCode) {
173
+ // The wallet wraps whatever its signer or seed thunk threw; the key's own failures keep their meaning.
174
+ if (error instanceof wallet.WalletError && error.cause instanceof DeviceKeyError)
175
+ return mapDeviceKeyError(error.cause);
156
176
  if (!(error instanceof wallet.WalletError)) {
157
177
  return new userAppLink.LinkError("INTERNAL", error instanceof Error ? error.message : String(error));
158
178
  }
@@ -217,9 +237,10 @@ async function postToMember(http, url, body, expected) {
217
237
  }
218
238
 
219
239
  /**
220
- * `issuerNetworkId` is asserted by the caller and cannot be verified here. An established mapping
221
- * still cannot be taken over while the credential under it is valid: `credential.issue` answers
222
- * `alreadyHeld` for such a key rather than issuing against it again.
240
+ * `issuerNetworkId` is asserted by the caller, so `credential.issue` checks it against the chain:
241
+ * an entry is written, or honoured as `alreadyHeld`, only for a key in the member's
242
+ * `issuerKeysOf` list. Listings and presentation read entries as written, including any from before
243
+ * this check.
223
244
  */
224
245
  const ISSUER_INDEX_PREFIX = "user-app-core:issuer-index:";
225
246
  /**
@@ -252,6 +273,25 @@ async function listIssuerEntries(store) {
252
273
  async function writeIssuerEntry(store, issuerNetworkId, entry) {
253
274
  await store.set(keyFor(issuerNetworkId, entry), JSON.stringify(entry));
254
275
  }
276
+ /** Removes the entries naming any of `credentialIds`; every other entry stays. */
277
+ async function deleteIssuerEntries(store, credentialIds) {
278
+ for (const key of await store.list()) {
279
+ if (!key.startsWith(ISSUER_INDEX_PREFIX))
280
+ continue;
281
+ const raw = await store.get(key);
282
+ if (raw === null)
283
+ continue;
284
+ let credentialId;
285
+ try {
286
+ credentialId = JSON.parse(raw).credentialId;
287
+ }
288
+ catch {
289
+ continue;
290
+ }
291
+ if (credentialIds.has(credentialId))
292
+ await store.delete(key);
293
+ }
294
+ }
255
295
 
256
296
  /** `ports.now` is optional (a test harness injects a deterministic clock); real ports omit it. */
257
297
  function nowFn(ports) {
@@ -272,6 +312,11 @@ const CREDENTIAL_ISSUE_STEPS = {
272
312
  storing: "Saving to your wallet"
273
313
  };
274
314
 
315
+ /** Its own text, so the person never sees two identical dialogs back to back. */
316
+ const ECDH_PROMPT = {
317
+ title: "Unlock your credentials",
318
+ subtitle: "Needed once each time the app starts"
319
+ };
275
320
  /**
276
321
  * The Baby Jubjub identity seed, rooted in the hardware ECDH rather than the device secret.
277
322
  * `deriveEddsaKeypair` returns `seed` as a `0x`-prefixed hex string — handing that string to
@@ -279,9 +324,29 @@ const CREDENTIAL_ISSUE_STEPS = {
279
324
  * the 32 raw bytes decoded from it.
280
325
  */
281
326
  async function resolveIdentitySeed(deviceKey) {
282
- const sharedSecret = await deviceKey.ecdh(identity.eddsaPeerPointBytes());
327
+ const sharedSecret = await deviceKey.ecdh(identity.eddsaPeerPointBytes(), ECDH_PROMPT);
283
328
  return identity.hexToBytes(identity.deriveEddsaKeypair(sharedSecret).seed);
284
329
  }
330
+ function createIdentitySeedCache(deviceKey) {
331
+ let pending = null;
332
+ return {
333
+ resolve() {
334
+ if (pending === null) {
335
+ const attempt = resolveIdentitySeed(deviceKey);
336
+ pending = attempt;
337
+ // A cancelled prompt must not be cached: the next operation asks again.
338
+ attempt.catch(() => {
339
+ if (pending === attempt)
340
+ pending = null;
341
+ });
342
+ }
343
+ return pending;
344
+ },
345
+ clear() {
346
+ pending = null;
347
+ }
348
+ };
349
+ }
285
350
  /**
286
351
  * `listCredentials` never syncs — there is no single authority to sync a mixed-issuer listing
287
352
  * against — so this sentinel documents that, and would surface loudly if a future bug called it.
@@ -289,16 +354,31 @@ async function resolveIdentitySeed(deviceKey) {
289
354
  const neverLoadChainState = async () => {
290
355
  throw new Error("chain state must not be requested outside a per-request authority");
291
356
  };
357
+ /** A listing wallet's identity seed: like `neverLoadChainState`, loud if a future bug reaches for it. */
358
+ const neverSeed = async () => {
359
+ throw new Error("a listing must not ask the device key for the identity seed");
360
+ };
361
+ const neverSign = async () => {
362
+ throw new Error("a listing must not ask the device key to sign");
363
+ };
364
+ /** A listing wallet's signer: it reads the public key, which raises no prompt, and never signs. */
365
+ const listingSigner = (deviceKey) => ({
366
+ getPublicKeyJwk: () => deviceKey.getPublicKeyJwk(),
367
+ sign: neverSign,
368
+ signDigest: neverSign
369
+ });
292
370
  /**
293
371
  * A fresh `Wallet` per request, scoped to one member's authority. The private `Wallet` gets a
294
372
  * constant-`true` consent prompt: the person has already been asked once, at the link boundary,
295
373
  * and prompting again here would show a second screen for one `presentation.create`.
374
+ *
375
+ * `prompt` is what a signature raises. `null` builds a wallet for listing and syncing, which must
376
+ * never touch a key: its seed and signer throw.
296
377
  */
297
- async function buildWallet(ports, loadChainState) {
298
- const identitySeed = await resolveIdentitySeed(ports.deviceKey);
378
+ function buildWallet(ports, seeds, loadChainState, prompt) {
299
379
  return new wallet.Wallet({
300
- identitySeed,
301
- deviceSigner: ports.deviceKey,
380
+ identitySeed: prompt === null ? neverSeed : () => seeds.resolve(),
381
+ deviceSigner: prompt === null ? listingSigner(ports.deviceKey) : ports.deviceKey.signerFor(prompt),
302
382
  consent: async () => true,
303
383
  proofGenerator: ports.proofGenerator,
304
384
  store: ports.store,
@@ -317,6 +397,16 @@ const refused = ({ code, message }) => ({
317
397
  code: code ?? UNNAMED_REFUSAL,
318
398
  message
319
399
  });
400
+ const isMemberKey = (memberKeyLeaves, key) => memberKeyLeaves.has(issuerSet.issuerKeyLeaf([BigInt(key[0]), BigInt(key[1])]));
401
+ /** A cancel or an invalidated key reaches the person as the link error that says so, not as an internal failure. */
402
+ async function keyUse(use) {
403
+ try {
404
+ return await use;
405
+ }
406
+ catch (error) {
407
+ throw error instanceof DeviceKeyError ? mapDeviceKeyError(error) : error;
408
+ }
409
+ }
320
410
  /**
321
411
  * Whether an indexed credential still stops this member issuing another for the same programme.
322
412
  * Only one that is valid right now does: a revoked or expired credential, or an entry whose
@@ -336,25 +426,56 @@ async function isStillValid(wallet, credentialId, load) {
336
426
  * The revocation secret is generated and opened inside this one call, so it never leaves the
337
427
  * function and nothing has to persist it between two deep links.
338
428
  */
339
- async function credentialIssueHandler(ports, deviceSecret, params) {
429
+ async function credentialIssueHandler(ports, seeds, deviceSecret, params) {
340
430
  const step = (name) => ports.progress("credential.issue", name);
341
431
  step(CREDENTIAL_ISSUE_STEPS.held);
342
432
  const held = await readIssuerEntry(ports.store, params.issuerNetworkId, params.approvalScope);
343
433
  // Read before the issuer signs: the revocation secret below never leaves this call, so a
344
434
  // failure after the 201 would orphan the credential at the issuer with nothing able to open it.
435
+ // So is the chain read, and an empty key list refuses before any challenge. Whether the signing
436
+ // key is among the keys can only be known after the signature; that mismatch is refused below.
345
437
  step(CREDENTIAL_ISSUE_STEPS.lists);
346
- let chainState;
347
- try {
348
- chainState = await buildChainStateLoader(ports.http, params.issuersUrl, params.revocationsUrl)();
349
- }
350
- catch (error) {
351
- throw new userAppLink.LinkError("ISSUER_UNREACHABLE", error instanceof Error
352
- ? error.message
438
+ const [lists, leafRead, rootsRead] = await Promise.allSettled([
439
+ readLists(ports.http, params.issuersUrl, params.revocationsUrl),
440
+ ports.directory.issuerKeyLeavesOf(params.issuerNetworkId),
441
+ ports.roots.currentRoots()
442
+ ]);
443
+ if (lists.status === "rejected") {
444
+ throw new userAppLink.LinkError("ISSUER_UNREACHABLE", lists.reason instanceof Error
445
+ ? lists.reason.message
353
446
  : "the issuer's approved-issuer set or revocation snapshot is unreadable");
354
447
  }
448
+ if (leafRead.status === "rejected") {
449
+ // Fixed text: a viem message carries the RPC URL, which can hold a provider key.
450
+ throw new userAppLink.LinkError("CHAIN_UNREACHABLE", "the approved issuer keys could not be read from chain");
451
+ }
452
+ if (rootsRead.status === "rejected") {
453
+ throw new userAppLink.LinkError("CHAIN_UNREACHABLE", "the current roots could not be read from chain");
454
+ }
455
+ // The wallet's own check only runs once the issuer has signed, which would orphan the credential.
456
+ if (!listsMatchChain(lists.value, rootsRead.value)) {
457
+ throw new userAppLink.LinkError("ISSUER_UNREACHABLE", "the issuer's lists do not match the chain");
458
+ }
459
+ const chainState = {
460
+ ...lists.value,
461
+ approvedIssuerRoot: rootsRead.value.approvedIssuerRoot,
462
+ revocationRoot: rootsRead.value.revocationRoot
463
+ };
464
+ const memberKeyLeaves = new Set(leafRead.value);
465
+ if (memberKeyLeaves.size === 0) {
466
+ throw new userAppLink.LinkError("ISSUER_NOT_APPROVED", `no approved issuer key is known for ${params.issuerNetworkId}`);
467
+ }
355
468
  const loadChainState = async () => chainState;
356
- const wallet = await buildWallet(ports, loadChainState);
357
- if (held !== null && (await isStillValid(wallet, held.credentialId, loadChainState))) {
469
+ // The hold check below reads and syncs only, so it asks nothing of the keys; the seed and the
470
+ // signature come after the challenge.
471
+ const prompt = {
472
+ title: "Bind a credential to this phone",
473
+ subtitle: `${params.approvalScope.programme} (${params.approvalScope.country})`
474
+ };
475
+ const wallet = buildWallet(ports, seeds, loadChainState, prompt);
476
+ if (held !== null &&
477
+ isMemberKey(memberKeyLeaves, held.issuerPublicKey) &&
478
+ (await isStillValid(wallet, held.credentialId, loadChainState))) {
358
479
  return { status: "alreadyHeld", credentialId: held.credentialId };
359
480
  }
360
481
  const keys = identity.deriveKeys(deviceSecret);
@@ -364,14 +485,17 @@ async function credentialIssueHandler(ports, deviceSecret, params) {
364
485
  return refused(challenge.refusal);
365
486
  const nonce = challenge.value.nonce;
366
487
  step(CREDENTIAL_ISSUE_STEPS.binding);
488
+ const seed = await keyUse(seeds.resolve());
489
+ const identity$2 = new identity$1.Identity(seed);
367
490
  const confirmationKey = await ports.deviceKey.getPublicKeyJwk();
368
- const eddsa = identity.deriveEddsaKeypair(await ports.deviceKey.ecdh(identity.eddsaPeerPointBytes()));
369
491
  const revocationSecret = credential.generateRevocationSecret();
370
492
  const ownership = credential.signCommitmentOwnership(revocationSecret, identity.hexToBytes(nonce));
371
- const deviceBindingMessage = identity.buildDeviceBindingMessage(nonce, keys.networkId, bigintToBytes32(eddsa.commitment));
372
- const devicePopSignature = identity.bytesToHex(await ports.deviceKey.sign(deviceBindingMessage));
493
+ const holderOwnership = credential.signHolderKeyOwnership(seed, identity.hexToBytes(nonce));
494
+ const deviceBindingMessage = identity.buildDeviceBindingMessage(nonce, keys.networkId, bigintToBytes32(identity$2.commitment), credential.issuanceRequestCommitment(params.issuerNetworkId, params.approvalScope));
495
+ const devicePopSignature = identity.bytesToHex(await keyUse(ports.deviceKey.signerFor(prompt).sign(deviceBindingMessage)));
373
496
  const { signature: accountPopSignature } = identity.signAccountBinding(keys.account.privateKey, nonce, credential.jwkToPublicKeyBytes(confirmationKey));
374
497
  step(CREDENTIAL_ISSUE_STEPS.signing);
498
+ const deviceAttestation = await ports.deviceKey.getAttestationChain();
375
499
  const issued = await postToMember(ports.http, params.credentialsUrl, {
376
500
  challengeId: challenge.value.challengeId,
377
501
  subjectNetworkId: keys.networkId,
@@ -380,7 +504,11 @@ async function credentialIssueHandler(ports, deviceSecret, params) {
380
504
  accountPublicKey: keys.account.uncompressedPublicKey,
381
505
  devicePopSignature,
382
506
  accountPopSignature,
383
- identityPublicKey: pointToStrings(eddsa.publicKey),
507
+ identityPublicKey: pointToStrings(identity$2.publicKey),
508
+ identityOwnershipSignature: {
509
+ R8: pointToStrings(holderOwnership.signature.R8),
510
+ S: String(holderOwnership.signature.S)
511
+ },
384
512
  revocation: {
385
513
  commitment: credential.deriveRevocationCommitment(revocationSecret).toString(),
386
514
  publicKey: pointToStrings(ownership.publicKey),
@@ -388,18 +516,30 @@ async function credentialIssueHandler(ports, deviceSecret, params) {
388
516
  R8: pointToStrings(ownership.signature.R8),
389
517
  S: String(ownership.signature.S)
390
518
  }
391
- }
519
+ },
520
+ ...(deviceAttestation.length > 0 && { deviceAttestation })
392
521
  }, { status: 201, schema: issueCredentialResponseSchema, unreachable: "ISSUER_UNREACHABLE" });
393
522
  if (issued.refusal)
394
523
  return refused(issued.refusal);
395
- let issuerPublicKey;
524
+ let parsed;
396
525
  try {
397
- const parsed = credential.parseZkCredential(issued.value.credential);
398
- issuerPublicKey = [parsed.issuerPublicKey[0].toString(), parsed.issuerPublicKey[1].toString()];
526
+ parsed = credential.parseZkCredential(issued.value.credential);
399
527
  }
400
528
  catch (error) {
401
529
  throw new userAppLink.LinkError("ISSUER_UNREACHABLE", error instanceof Error ? error.message : "malformed credential");
402
530
  }
531
+ // The issuer's own binding check enforces this; a dishonest issuer is not trusted to.
532
+ if (parsed.body.programmeHash !== credential.programmeHash(params.approvalScope.programme) ||
533
+ parsed.body.countryCode !== credential.countryCode(params.approvalScope.country)) {
534
+ throw new userAppLink.LinkError("ISSUER_UNREACHABLE", "credential is not for the programme and country consented to");
535
+ }
536
+ const issuerPublicKey = [
537
+ parsed.issuerPublicKey[0].toString(),
538
+ parsed.issuerPublicKey[1].toString()
539
+ ];
540
+ if (!isMemberKey(memberKeyLeaves, issuerPublicKey)) {
541
+ throw new userAppLink.LinkError("ISSUER_NOT_APPROVED", `the credential is not signed by a key of ${params.issuerNetworkId}`);
542
+ }
403
543
  // Built above for the hold check; the lists are read before the signature, so this is the
404
544
  // only step left that can fail.
405
545
  step(CREDENTIAL_ISSUE_STEPS.storing);
@@ -428,8 +568,8 @@ async function credentialIssueHandler(ports, deviceSecret, params) {
428
568
  /**
429
569
  * `presentation.create` carries no credential id, so this narrows by what the credential body
430
570
  * says: the requested programme and country. Status is deliberately not a filter — each candidate
431
- * is then synced against its own issuer's snapshot, and a caller that finds none valid still
432
- * needs to know whether it held a revoked one.
571
+ * is then synced against the verifier's lists, checked against the chain's roots, and a caller
572
+ * that finds none valid still needs to know whether it held a revoked one.
433
573
  */
434
574
  function matchingCredentials(credentials, request) {
435
575
  const wantedProgramme = credential.programmeHash(request.programme);
@@ -452,32 +592,36 @@ function latestExpiring(credentials) {
452
592
  });
453
593
  }
454
594
 
455
- async function presentationCreateHandler(ports, params) {
595
+ async function presentationCreateHandler(ports, seeds, params) {
456
596
  const step = (name) => ports.progress("presentation.create", name);
457
597
  // The state is read below, once the request is known to have candidates, so the wallet is built
458
- // without an authority and handed one per call.
459
- const wallet$1 = await buildWallet(ports, neverLoadChainState);
598
+ // without an authority and handed one per call. This one only lists and syncs: it holds no key.
599
+ const wallet$1 = buildWallet(ports, seeds, neverLoadChainState, null);
460
600
  /**
461
601
  * The same for every candidate, so the request reads it once however many credentials it has
462
- * to check — and a verifier that cannot serve it fails the whole request, since no candidate
463
- * can be judged without it.
602
+ * to check — and a verifier that cannot serve the lists, or a chain that cannot be read, fails
603
+ * the whole request, since no candidate can be judged without them.
464
604
  */
465
- async function verifierIssuerSet() {
466
- try {
467
- return await readIssuerSet(ports.http, params.issuersUrl);
605
+ async function networkState() {
606
+ const [lists, roots] = await Promise.allSettled([
607
+ readLists(ports.http, params.issuersUrl, params.revocationsUrl),
608
+ ports.roots.currentRoots()
609
+ ]);
610
+ if (lists.status === "rejected") {
611
+ throw new userAppLink.LinkError("VERIFIER_UNREACHABLE", lists.reason instanceof Error ? lists.reason.message : "the verifier's lists are unreadable");
468
612
  }
469
- catch (error) {
470
- throw new userAppLink.LinkError("VERIFIER_UNREACHABLE", error instanceof Error ? error.message : `GET ${params.issuersUrl} failed`);
471
- }
472
- }
473
- /** Same reasoning as the issuer set: the verifier is the party that must accept this root. */
474
- async function verifierRevocations() {
475
- try {
476
- return await readRevocations(ports.http, params.revocationsUrl);
613
+ if (roots.status === "rejected") {
614
+ // Fixed text: a viem message carries the RPC URL, which can hold a provider key.
615
+ throw new userAppLink.LinkError("CHAIN_UNREACHABLE", "the current roots could not be read from chain");
477
616
  }
478
- catch (error) {
479
- throw new userAppLink.LinkError("VERIFIER_UNREACHABLE", error instanceof Error ? error.message : `GET ${params.revocationsUrl} failed`);
617
+ if (!listsMatchChain(lists.value, roots.value)) {
618
+ throw new userAppLink.LinkError("VERIFIER_UNREACHABLE", "the verifier's lists do not match the chain");
480
619
  }
620
+ return {
621
+ ...lists.value,
622
+ approvedIssuerRoot: roots.value.approvedIssuerRoot,
623
+ revocationRoot: roots.value.revocationRoot
624
+ };
481
625
  }
482
626
  async function readCandidates() {
483
627
  const infoById = new Map((await wallet$1.listCredentials()).map((info) => [info.id, info]));
@@ -496,17 +640,10 @@ async function presentationCreateHandler(ports, params) {
496
640
  // state is kept so the proof is built from the same read, not a second one that could disagree.
497
641
  let chainState;
498
642
  if (candidates.length > 0) {
499
- const state = { ...(await verifierIssuerSet()), ...(await verifierRevocations()) };
643
+ const state = await networkState();
500
644
  chainState = state;
501
645
  for (const candidate of candidates) {
502
- try {
503
- await wallet$1.syncCredential(candidate.id, async () => state);
504
- }
505
- catch (error) {
506
- // A served root that does not match the tree served beside it is the verifier's
507
- // fault, not this wallet's; without mapping it the caller is told INTERNAL.
508
- throw mapWalletError(error, "VERIFIER_UNREACHABLE");
509
- }
646
+ await wallet$1.syncCredential(candidate.id, async () => state);
510
647
  }
511
648
  }
512
649
  const synced = (await readCandidates()).flatMap((candidate) => chainState === undefined ? [] : [{ ...candidate, chainState }]);
@@ -529,10 +666,23 @@ async function presentationCreateHandler(ports, params) {
529
666
  }
530
667
  throw new userAppLink.LinkError("NO_CREDENTIAL", `no held credential matches programme "${params.programme}"/"${params.country}"`);
531
668
  }
669
+ const approved = await ports.review({
670
+ kind: "proof",
671
+ verifier: params.audience,
672
+ programme: params.programme,
673
+ country: params.country,
674
+ issuerNetworkId: selected.issuerNetworkId
675
+ });
676
+ if (!approved)
677
+ throw new userAppLink.LinkError("CONSENT_DENIED", "the proof was not shared");
532
678
  step("Proving");
679
+ const presenter = buildWallet(ports, seeds, neverLoadChainState, {
680
+ title: "Share a proof",
681
+ subtitle: `Proves your ${params.programme} credential (${params.country})`
682
+ });
533
683
  let proof;
534
684
  try {
535
- proof = await wallet$1.present(selected.id, {
685
+ proof = await presenter.present(selected.id, {
536
686
  challenge: identity.hexToBytes(params.challenge),
537
687
  audience: params.audience,
538
688
  currentTime: params.currentTime,
@@ -546,15 +696,6 @@ async function presentationCreateHandler(ports, params) {
546
696
  // A raw prover failure, not a WalletError - keep it distinguishable from NO_CREDENTIAL above.
547
697
  throw new userAppLink.LinkError("PROOF_FAILED", error instanceof Error ? error.message : "proof generation failed");
548
698
  }
549
- const approved = await ports.review({
550
- kind: "proof",
551
- verifier: params.audience,
552
- programme: params.programme,
553
- country: params.country,
554
- issuerNetworkId: selected.issuerNetworkId
555
- });
556
- if (!approved)
557
- throw new userAppLink.LinkError("CONSENT_DENIED", "the proof was built but not shared");
558
699
  step("Sharing");
559
700
  // Only the id comes back: the verifier's own `ok` would be the verifier reporting on itself,
560
701
  // and its member app can read the recorded outcome from its own server under this id.
@@ -590,6 +731,12 @@ async function assembleIdentity(deviceSecret, deviceKey) {
590
731
  };
591
732
  }
592
733
 
734
+ // UNKNOWN ranks with SOFTWARE: a keystore that cannot say is trusted no more than one that admits it.
735
+ const RANK = { STRONGBOX: 2, TRUSTED_ENVIRONMENT: 1, SOFTWARE: 0, UNKNOWN: 0 };
736
+ function meetsSecurityLevel(level, minimum) {
737
+ return RANK[level] >= RANK[minimum];
738
+ }
739
+
593
740
  /**
594
741
  * Still not `Wallet`'s own status: that also carries `issuer-not-approved`, which `syncRevocations`
595
742
  * stamps against whichever single member last asked for a presentation, and reporting one member's
@@ -651,6 +798,7 @@ function createUserAppService(ports) {
651
798
  throw new userAppLink.LinkError("NOT_ONBOARDED", "no identity has been created yet");
652
799
  return secret;
653
800
  }
801
+ const seeds = createIdentitySeedCache(ports.deviceKey);
654
802
  // One slot deep: a handler body reads and writes the same store a concurrent one might be
655
803
  // mid-sync against, so two deep links run their handler bodies one at a time, never interleaved.
656
804
  let queue = Promise.resolve();
@@ -661,10 +809,10 @@ function createUserAppService(ports) {
661
809
  }
662
810
  const handlers = {
663
811
  "registration.sign": (params) => serialize(async () => registrationSignHandler(ports, await requireOnboarded(), params)),
664
- "credential.issue": (params) => serialize(async () => credentialIssueHandler(ports, await requireOnboarded(), params)),
812
+ "credential.issue": (params) => serialize(async () => credentialIssueHandler(ports, seeds, await requireOnboarded(), params)),
665
813
  "presentation.create": (params) => serialize(async () => {
666
814
  await requireOnboarded();
667
- return presentationCreateHandler(ports, params);
815
+ return presentationCreateHandler(ports, seeds, params);
668
816
  })
669
817
  };
670
818
  return {
@@ -680,6 +828,13 @@ function createUserAppService(ports) {
680
828
  if ((await resolveDeviceSecret()) !== null) {
681
829
  throw new Error("createIdentity: an identity already exists — erase() before creating another");
682
830
  }
831
+ const minimum = ports.minimumDeviceSecurityLevel;
832
+ if (minimum !== undefined) {
833
+ const level = await ports.deviceKey.getSecurityLevel();
834
+ if (!meetsSecurityLevel(level, minimum)) {
835
+ throw new Error(`This phone's key protection is ${level}; creating an identity needs a hardware-backed key (${minimum} or better).`);
836
+ }
837
+ }
683
838
  const secret = identity.generateDeviceSecret();
684
839
  const identity$1 = await assembleIdentity(secret, ports.deviceKey);
685
840
  await writeDeviceSecret(ports.store, secret);
@@ -705,7 +860,7 @@ function createUserAppService(ports) {
705
860
  * name and the plaintext programme survive — the credential body carries a hash.
706
861
  */
707
862
  async listCredentials() {
708
- const wallet = await buildWallet(ports, neverLoadChainState);
863
+ const wallet = buildWallet(ports, seeds, neverLoadChainState, null);
709
864
  const held = await wallet.listCredentials();
710
865
  const entryByCredentialId = new Map((await listIssuerEntries(ports.store)).map((entry) => [entry.credentialId, entry]));
711
866
  const nowSeconds = Math.floor(nowFn(ports) / 1000);
@@ -731,6 +886,37 @@ function createUserAppService(ports) {
731
886
  expiresAt: body.expiresAt
732
887
  })));
733
888
  },
889
+ removeUnboundCredentials() {
890
+ return serialize(async () => {
891
+ const commitment = credential.deriveDeviceKeyCommitment(await ports.deviceKey.getPublicKeyJwk());
892
+ const wallet$1 = buildWallet(ports, seeds, neverLoadChainState, null);
893
+ const unbound = [];
894
+ for (const key of await ports.store.list()) {
895
+ if (!key.startsWith(wallet.CREDENTIAL_PREFIX))
896
+ continue;
897
+ const id = key.slice(wallet.CREDENTIAL_PREFIX.length);
898
+ try {
899
+ const info = await wallet$1.getCredential(id);
900
+ if (info !== null &&
901
+ credential.parseZkCredential(info.credential).body.deviceKeyCommitment !== commitment) {
902
+ unbound.push(id);
903
+ }
904
+ }
905
+ catch {
906
+ // Unreadable, so not proven unbound: left in place.
907
+ }
908
+ }
909
+ if (unbound.length === 0)
910
+ return 0;
911
+ // Index entries first: a run killed before the credentials go leaves them still
912
+ // unbound, so the next run removes them.
913
+ await deleteIssuerEntries(ports.store, new Set(unbound));
914
+ for (const id of unbound)
915
+ await wallet$1.deleteCredential(id);
916
+ seeds.clear();
917
+ return unbound.length;
918
+ });
919
+ },
734
920
  /**
735
921
  * Clears the entire store, not only this package's own keys — a host app shares it, and an
736
922
  * "erase everything" that left its configuration behind would be a half-erase. A caller
@@ -741,13 +927,19 @@ function createUserAppService(ports) {
741
927
  for (const key of await ports.store.list())
742
928
  await ports.store.delete(key);
743
929
  deviceSecretCache = null;
930
+ seeds.clear();
744
931
  });
745
932
  }
746
933
  };
747
934
  }
748
935
 
936
+ Object.defineProperty(exports, "DEVICE_ATTESTATION_CHALLENGE", {
937
+ enumerable: true,
938
+ get: function () { return credential.DEVICE_ATTESTATION_CHALLENGE; }
939
+ });
749
940
  exports.CREDENTIAL_ISSUE_STEPS = CREDENTIAL_ISSUE_STEPS;
750
941
  exports.DEVICE_SECRET_KEY = DEVICE_SECRET_KEY;
942
+ exports.DeviceKeyError = DeviceKeyError;
751
943
  exports.buildConsentSummary = buildConsentSummary;
752
944
  exports.createUserAppService = createUserAppService;
753
945
  exports.latestExpiring = latestExpiring;