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