@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.
@@ -1,61 +1,57 @@
1
1
  import { ApprovedIssuerSet } from "@coordination-network/issuer-set"
2
2
  import { RevocationRegistry } from "@coordination-network/revocation-registry"
3
- import type { ChainStateLoader, WalletChainState } from "@coordination-network/wallet"
4
- import type { HttpClient } from "./types"
3
+ import type { WalletChainState } from "@coordination-network/wallet"
4
+ import type { ChainRoots, HttpClient } from "./types"
5
5
 
6
6
  interface IssuersResponseBody {
7
7
  leaves: string[]
8
- root: string
9
8
  }
10
9
 
11
10
  interface RevocationSnapshotResponseBody {
12
- root: string
13
11
  snapshot: string
14
12
  }
15
13
 
16
- /** The half of a chain state a member's approved-issuer set supplies. */
17
- export type IssuerSetState = Pick<WalletChainState, "approvedIssuers" | "approvedIssuerRoot">
14
+ /** The trees a server's two lists rebuild, with no root attached. */
15
+ export type ChainLists = Pick<WalletChainState, "approvedIssuers" | "revocations">
18
16
 
19
- /** The half its revocation snapshot supplies. */
20
- export type RevocationsState = Pick<WalletChainState, "revocations" | "revocationRoot">
21
-
22
- /**
23
- * `GET /v1/issuers` carries a `root` the server read from chain independently of the `leaves` it
24
- * also served, so `Wallet`'s comparison between the tree rebuilt from `leaves` and
25
- * `approvedIssuerRoot` catches a server that served a mismatched pair.
26
- */
27
- export async function readIssuerSet(http: HttpClient, issuersUrl: string): Promise<IssuerSetState> {
17
+ async function readIssuerSet(http: HttpClient, issuersUrl: string): Promise<ApprovedIssuerSet> {
28
18
  const response = await http.getJson(issuersUrl)
29
19
  if (response.status !== 200) {
30
20
  throw new Error(`GET ${issuersUrl} returned ${response.status}`)
31
21
  }
32
- const { leaves, root } = response.body as IssuersResponseBody
22
+ const { leaves } = response.body as IssuersResponseBody
33
23
 
34
- return {
35
- approvedIssuers: ApprovedIssuerSet.fromLeaves(leaves.map((leaf) => BigInt(leaf))),
36
- approvedIssuerRoot: BigInt(root)
37
- }
24
+ return ApprovedIssuerSet.fromLeaves(leaves.map((leaf) => BigInt(leaf)))
38
25
  }
39
26
 
40
- /** The snapshot's own `root` gives the revocation side the same cross-check. */
41
- export async function readRevocations(http: HttpClient, revocationsUrl: string): Promise<RevocationsState> {
27
+ async function readRevocations(http: HttpClient, revocationsUrl: string): Promise<RevocationRegistry> {
42
28
  const response = await http.getJson(revocationsUrl)
43
29
  if (response.status !== 200) {
44
30
  throw new Error(`GET ${revocationsUrl} returned ${response.status}`)
45
31
  }
46
- const { root, snapshot } = response.body as RevocationSnapshotResponseBody
32
+ const { snapshot } = response.body as RevocationSnapshotResponseBody
47
33
 
48
- return { revocations: RevocationRegistry.import(snapshot), revocationRoot: BigInt(root) }
34
+ return RevocationRegistry.import(snapshot)
49
35
  }
50
36
 
51
37
  /**
52
- * Both lists are named per request by `issuersUrl`/`revocationsUrl`, so this is built fresh per
53
- * request and never held onto. A handler with several credentials to check reads the two halves
54
- * itself instead, so one read of each serves them all (see `presentation-create.ts`).
38
+ * Bulk data only: the `root` each endpoint also serves is not read, because the server choosing
39
+ * both halves of the comparison makes it pass whatever it serves. The caller pairs these trees
40
+ * with roots read from chain, and `listsMatchChain` rejects a pair that disagrees.
55
41
  */
56
- export function buildChainStateLoader(http: HttpClient, issuersUrl: string, revocationsUrl: string): ChainStateLoader {
57
- return async () => ({
58
- ...(await readIssuerSet(http, issuersUrl)),
59
- ...(await readRevocations(http, revocationsUrl))
60
- })
42
+ export async function readLists(http: HttpClient, issuersUrl: string, revocationsUrl: string): Promise<ChainLists> {
43
+ const [approvedIssuers, revocations] = await Promise.all([
44
+ readIssuerSet(http, issuersUrl),
45
+ readRevocations(http, revocationsUrl)
46
+ ])
47
+ return { approvedIssuers, revocations }
48
+ }
49
+
50
+ /** Both roots, and the issuer set's leaf count, which with its root fixes every leaf. */
51
+ export function listsMatchChain(lists: ChainLists, chain: ChainRoots): boolean {
52
+ return (
53
+ lists.approvedIssuers.root === chain.approvedIssuerRoot &&
54
+ lists.approvedIssuers.leafCount === chain.approvedIssuerLeafCount &&
55
+ lists.revocations.root === chain.revocationRoot
56
+ )
61
57
  }
package/src/errors.ts CHANGED
@@ -1,6 +1,28 @@
1
1
  import { WalletError, type WalletErrorCode } from "@coordination-network/wallet"
2
2
  import { LinkError, type LinkErrorCode } from "@coordination-network/user-app-link"
3
3
 
4
+ /**
5
+ * A key use the person or the platform ended: cancelled at the prompt, the key invalidated, or the
6
+ * authentication itself failed (lockout, no foreground activity, another prompt already showing).
7
+ */
8
+ export class DeviceKeyError extends Error {
9
+ readonly code: "USER_CANCELED" | "KEY_INVALIDATED" | "AUTHENTICATION_FAILED"
10
+
11
+ constructor(code: DeviceKeyError["code"], message: string = code) {
12
+ super(message)
13
+ this.name = "DeviceKeyError"
14
+ this.code = code
15
+ }
16
+ }
17
+
18
+ /** A cancel is the person's refusal; anything else the key reports is this app's fault, not a request problem. */
19
+ export function mapDeviceKeyError(error: unknown): LinkError {
20
+ if (error instanceof DeviceKeyError && error.code === "USER_CANCELED") {
21
+ return new LinkError("CONSENT_DENIED", "the person cancelled the device prompt")
22
+ }
23
+ return new LinkError("INTERNAL", error instanceof Error ? error.message : String(error))
24
+ }
25
+
4
26
  /**
5
27
  * `WalletError` and `LinkError` speak different vocabularies for the same underlying failures.
6
28
  * Split along one line: a malformed or structurally-invalid credential is `INVALID_REQUEST`;
@@ -33,6 +55,8 @@ const WALLET_ERROR_TO_LINK_ERROR_CODE: Record<WalletErrorCode, LinkErrorCode> =
33
55
  * absent `code` would have produced a `LinkError` with no code at all.
34
56
  */
35
57
  export function mapWalletError(error: unknown, chainStateCode?: LinkErrorCode): LinkError {
58
+ // The wallet wraps whatever its signer or seed thunk threw; the key's own failures keep their meaning.
59
+ if (error instanceof WalletError && error.cause instanceof DeviceKeyError) return mapDeviceKeyError(error.cause)
36
60
  if (!(error instanceof WalletError)) {
37
61
  return new LinkError("INTERNAL", error instanceof Error ? error.message : String(error))
38
62
  }
@@ -1,29 +1,29 @@
1
+ import { buildDeviceBindingMessage, deriveKeys, signAccountBinding, type Hex } from "@coordination-network/identity"
2
+ import { Identity } from "@semaphore-protocol/identity"
1
3
  import {
2
- buildDeviceBindingMessage,
3
- deriveEddsaKeypair,
4
- deriveKeys,
5
- eddsaPeerPointBytes,
6
- signAccountBinding,
7
- type Hex
8
- } from "@coordination-network/identity"
9
- import {
4
+ countryCode,
10
5
  deriveRevocationCommitment,
11
6
  generateRevocationSecret,
7
+ issuanceRequestCommitment,
12
8
  jwkToPublicKeyBytes,
13
9
  parseZkCredential,
14
- signCommitmentOwnership
10
+ programmeHash,
11
+ signCommitmentOwnership,
12
+ signHolderKeyOwnership,
13
+ type ZkCredential
15
14
  } from "@coordination-network/credential"
15
+ import { issuerKeyLeaf } from "@coordination-network/issuer-set"
16
16
  import type { ChainStateLoader, Wallet, WalletChainState } from "@coordination-network/wallet"
17
17
  import { LinkError, type CredentialIssueParams, type CredentialIssueResult } from "@coordination-network/user-app-link"
18
- import { buildChainStateLoader } from "../chain-state"
19
- import { mapWalletError } from "../errors"
18
+ import { listsMatchChain, readLists } from "../chain-state"
19
+ import { DeviceKeyError, mapDeviceKeyError, mapWalletError } from "../errors"
20
20
  import { bigintToBytes32, bytesToHex, hexToBytes } from "../hex"
21
21
  import { challengeResponseSchema, issueCredentialResponseSchema, postToMember, type MemberRefusal } from "../member-api"
22
22
  import { readIssuerEntry, writeIssuerEntry } from "../issuer-index"
23
23
  import { nowFn } from "../now"
24
24
  import { CREDENTIAL_ISSUE_STEPS } from "../steps"
25
- import { buildWallet } from "../wallet-factory"
26
- import type { UserAppPorts } from "../types"
25
+ import { buildWallet, type IdentitySeedCache } from "../wallet-factory"
26
+ import type { KeyPrompt, UserAppPorts } from "../types"
27
27
 
28
28
  type Refusal = Extract<CredentialIssueResult, { status: "refused" }>
29
29
 
@@ -40,6 +40,18 @@ const refused = ({ code, message }: MemberRefusal): Refusal => ({
40
40
  message
41
41
  })
42
42
 
43
+ const isMemberKey = (memberKeyLeaves: ReadonlySet<bigint>, key: readonly [string, string]): boolean =>
44
+ memberKeyLeaves.has(issuerKeyLeaf([BigInt(key[0]), BigInt(key[1])]))
45
+
46
+ /** A cancel or an invalidated key reaches the person as the link error that says so, not as an internal failure. */
47
+ async function keyUse<T>(use: Promise<T>): Promise<T> {
48
+ try {
49
+ return await use
50
+ } catch (error) {
51
+ throw error instanceof DeviceKeyError ? mapDeviceKeyError(error) : error
52
+ }
53
+ }
54
+
43
55
  /**
44
56
  * Whether an indexed credential still stops this member issuing another for the same programme.
45
57
  * Only one that is valid right now does: a revoked or expired credential, or an entry whose
@@ -61,6 +73,7 @@ async function isStillValid(wallet: Wallet, credentialId: string, load: ChainSta
61
73
  */
62
74
  export async function credentialIssueHandler(
63
75
  ports: UserAppPorts,
76
+ seeds: IdentitySeedCache,
64
77
  deviceSecret: Hex,
65
78
  params: CredentialIssueParams
66
79
  ): Promise<CredentialIssueResult> {
@@ -71,22 +84,56 @@ export async function credentialIssueHandler(
71
84
 
72
85
  // Read before the issuer signs: the revocation secret below never leaves this call, so a
73
86
  // failure after the 201 would orphan the credential at the issuer with nothing able to open it.
87
+ // So is the chain read, and an empty key list refuses before any challenge. Whether the signing
88
+ // key is among the keys can only be known after the signature; that mismatch is refused below.
74
89
  step(CREDENTIAL_ISSUE_STEPS.lists)
75
- let chainState: WalletChainState
76
- try {
77
- chainState = await buildChainStateLoader(ports.http, params.issuersUrl, params.revocationsUrl)()
78
- } catch (error) {
90
+ const [lists, leafRead, rootsRead] = await Promise.allSettled([
91
+ readLists(ports.http, params.issuersUrl, params.revocationsUrl),
92
+ ports.directory.issuerKeyLeavesOf(params.issuerNetworkId),
93
+ ports.roots.currentRoots()
94
+ ])
95
+ if (lists.status === "rejected") {
79
96
  throw new LinkError(
80
97
  "ISSUER_UNREACHABLE",
81
- error instanceof Error
82
- ? error.message
98
+ lists.reason instanceof Error
99
+ ? lists.reason.message
83
100
  : "the issuer's approved-issuer set or revocation snapshot is unreadable"
84
101
  )
85
102
  }
103
+ if (leafRead.status === "rejected") {
104
+ // Fixed text: a viem message carries the RPC URL, which can hold a provider key.
105
+ throw new LinkError("CHAIN_UNREACHABLE", "the approved issuer keys could not be read from chain")
106
+ }
107
+ if (rootsRead.status === "rejected") {
108
+ throw new LinkError("CHAIN_UNREACHABLE", "the current roots could not be read from chain")
109
+ }
110
+ // The wallet's own check only runs once the issuer has signed, which would orphan the credential.
111
+ if (!listsMatchChain(lists.value, rootsRead.value)) {
112
+ throw new LinkError("ISSUER_UNREACHABLE", "the issuer's lists do not match the chain")
113
+ }
114
+ const chainState: WalletChainState = {
115
+ ...lists.value,
116
+ approvedIssuerRoot: rootsRead.value.approvedIssuerRoot,
117
+ revocationRoot: rootsRead.value.revocationRoot
118
+ }
119
+ const memberKeyLeaves = new Set(leafRead.value)
120
+ if (memberKeyLeaves.size === 0) {
121
+ throw new LinkError("ISSUER_NOT_APPROVED", `no approved issuer key is known for ${params.issuerNetworkId}`)
122
+ }
86
123
 
87
124
  const loadChainState: ChainStateLoader = async () => chainState
88
- const wallet = await buildWallet(ports, loadChainState)
89
- if (held !== null && (await isStillValid(wallet, held.credentialId, loadChainState))) {
125
+ // The hold check below reads and syncs only, so it asks nothing of the keys; the seed and the
126
+ // signature come after the challenge.
127
+ const prompt: KeyPrompt = {
128
+ title: "Bind a credential to this phone",
129
+ subtitle: `${params.approvalScope.programme} (${params.approvalScope.country})`
130
+ }
131
+ const wallet = buildWallet(ports, seeds, loadChainState, prompt)
132
+ if (
133
+ held !== null &&
134
+ isMemberKey(memberKeyLeaves, held.issuerPublicKey) &&
135
+ (await isStillValid(wallet, held.credentialId, loadChainState))
136
+ ) {
90
137
  return { status: "alreadyHeld", credentialId: held.credentialId }
91
138
  }
92
139
 
@@ -103,13 +150,20 @@ export async function credentialIssueHandler(
103
150
  const nonce = challenge.value.nonce
104
151
 
105
152
  step(CREDENTIAL_ISSUE_STEPS.binding)
153
+ const seed = await keyUse(seeds.resolve())
154
+ const identity = new Identity(seed)
106
155
  const confirmationKey = await ports.deviceKey.getPublicKeyJwk()
107
- const eddsa = deriveEddsaKeypair(await ports.deviceKey.ecdh(eddsaPeerPointBytes()))
108
156
  const revocationSecret = generateRevocationSecret()
109
157
  const ownership = signCommitmentOwnership(revocationSecret, hexToBytes(nonce))
158
+ const holderOwnership = signHolderKeyOwnership(seed, hexToBytes(nonce))
110
159
 
111
- const deviceBindingMessage = buildDeviceBindingMessage(nonce, keys.networkId, bigintToBytes32(eddsa.commitment))
112
- const devicePopSignature = bytesToHex(await ports.deviceKey.sign(deviceBindingMessage))
160
+ const deviceBindingMessage = buildDeviceBindingMessage(
161
+ nonce,
162
+ keys.networkId,
163
+ bigintToBytes32(identity.commitment),
164
+ issuanceRequestCommitment(params.issuerNetworkId, params.approvalScope)
165
+ )
166
+ const devicePopSignature = bytesToHex(await keyUse(ports.deviceKey.signerFor(prompt).sign(deviceBindingMessage)))
113
167
  const { signature: accountPopSignature } = signAccountBinding(
114
168
  keys.account.privateKey,
115
169
  nonce,
@@ -117,6 +171,7 @@ export async function credentialIssueHandler(
117
171
  )
118
172
 
119
173
  step(CREDENTIAL_ISSUE_STEPS.signing)
174
+ const deviceAttestation = await ports.deviceKey.getAttestationChain()
120
175
  const issued = await postToMember(
121
176
  ports.http,
122
177
  params.credentialsUrl,
@@ -128,7 +183,11 @@ export async function credentialIssueHandler(
128
183
  accountPublicKey: keys.account.uncompressedPublicKey,
129
184
  devicePopSignature,
130
185
  accountPopSignature,
131
- identityPublicKey: pointToStrings(eddsa.publicKey),
186
+ identityPublicKey: pointToStrings(identity.publicKey),
187
+ identityOwnershipSignature: {
188
+ R8: pointToStrings(holderOwnership.signature.R8),
189
+ S: String(holderOwnership.signature.S)
190
+ },
132
191
  revocation: {
133
192
  commitment: deriveRevocationCommitment(revocationSecret).toString(),
134
193
  publicKey: pointToStrings(ownership.publicKey),
@@ -136,19 +195,34 @@ export async function credentialIssueHandler(
136
195
  R8: pointToStrings(ownership.signature.R8),
137
196
  S: String(ownership.signature.S)
138
197
  }
139
- }
198
+ },
199
+ ...(deviceAttestation.length > 0 && { deviceAttestation })
140
200
  },
141
201
  { status: 201, schema: issueCredentialResponseSchema, unreachable: "ISSUER_UNREACHABLE" }
142
202
  )
143
203
  if (issued.refusal) return refused(issued.refusal)
144
204
 
145
- let issuerPublicKey: [string, string]
205
+ let parsed: ZkCredential
146
206
  try {
147
- const parsed = parseZkCredential(issued.value.credential)
148
- issuerPublicKey = [parsed.issuerPublicKey[0].toString(), parsed.issuerPublicKey[1].toString()]
207
+ parsed = parseZkCredential(issued.value.credential)
149
208
  } catch (error) {
150
209
  throw new LinkError("ISSUER_UNREACHABLE", error instanceof Error ? error.message : "malformed credential")
151
210
  }
211
+ // The issuer's own binding check enforces this; a dishonest issuer is not trusted to.
212
+ if (
213
+ parsed.body.programmeHash !== programmeHash(params.approvalScope.programme) ||
214
+ parsed.body.countryCode !== countryCode(params.approvalScope.country)
215
+ ) {
216
+ throw new LinkError("ISSUER_UNREACHABLE", "credential is not for the programme and country consented to")
217
+ }
218
+ const issuerPublicKey: [string, string] = [
219
+ parsed.issuerPublicKey[0].toString(),
220
+ parsed.issuerPublicKey[1].toString()
221
+ ]
222
+
223
+ if (!isMemberKey(memberKeyLeaves, issuerPublicKey)) {
224
+ throw new LinkError("ISSUER_NOT_APPROVED", `the credential is not signed by a key of ${params.issuerNetworkId}`)
225
+ }
152
226
 
153
227
  // Built above for the hold check; the lists are read before the signature, so this is the
154
228
  // only step left that can fail.
@@ -5,13 +5,13 @@ import {
5
5
  type PresentationCreateParams,
6
6
  type PresentationCreateResult
7
7
  } from "@coordination-network/user-app-link"
8
- import { readIssuerSet, readRevocations, type IssuerSetState, type RevocationsState } from "../chain-state"
8
+ import { listsMatchChain, readLists } from "../chain-state"
9
9
  import { mapWalletError } from "../errors"
10
10
  import { bytesToHex, hexToBytes } from "../hex"
11
11
  import { listIssuerEntries, type IssuerIndexEntry } from "../issuer-index"
12
12
  import { postToMember, verifyResponseSchema } from "../member-api"
13
13
  import { latestExpiring, matchingCredentials } from "../selection"
14
- import { buildWallet, neverLoadChainState } from "../wallet-factory"
14
+ import { buildWallet, neverLoadChainState, type IdentitySeedCache } from "../wallet-factory"
15
15
  import type { UserAppPorts } from "../types"
16
16
 
17
17
  /** A held credential together with the issuer entry naming its plaintext programme and issuer. */
@@ -27,39 +27,42 @@ interface SyncedCandidate extends Candidate {
27
27
 
28
28
  export async function presentationCreateHandler(
29
29
  ports: UserAppPorts,
30
+ seeds: IdentitySeedCache,
30
31
  params: PresentationCreateParams
31
32
  ): Promise<PresentationCreateResult> {
32
33
  const step = (name: string) => ports.progress("presentation.create", name)
33
34
 
34
35
  // The state is read below, once the request is known to have candidates, so the wallet is built
35
- // without an authority and handed one per call.
36
- const wallet = await buildWallet(ports, neverLoadChainState)
36
+ // without an authority and handed one per call. This one only lists and syncs: it holds no key.
37
+ const wallet = buildWallet(ports, seeds, neverLoadChainState, null)
37
38
 
38
39
  /**
39
40
  * The same for every candidate, so the request reads it once however many credentials it has
40
- * to check — and a verifier that cannot serve it fails the whole request, since no candidate
41
- * can be judged without it.
41
+ * to check — and a verifier that cannot serve the lists, or a chain that cannot be read, fails
42
+ * the whole request, since no candidate can be judged without them.
42
43
  */
43
- async function verifierIssuerSet(): Promise<IssuerSetState> {
44
- try {
45
- return await readIssuerSet(ports.http, params.issuersUrl)
46
- } catch (error) {
44
+ async function networkState(): Promise<WalletChainState> {
45
+ const [lists, roots] = await Promise.allSettled([
46
+ readLists(ports.http, params.issuersUrl, params.revocationsUrl),
47
+ ports.roots.currentRoots()
48
+ ])
49
+ if (lists.status === "rejected") {
47
50
  throw new LinkError(
48
51
  "VERIFIER_UNREACHABLE",
49
- error instanceof Error ? error.message : `GET ${params.issuersUrl} failed`
52
+ lists.reason instanceof Error ? lists.reason.message : "the verifier's lists are unreadable"
50
53
  )
51
54
  }
52
- }
53
-
54
- /** Same reasoning as the issuer set: the verifier is the party that must accept this root. */
55
- async function verifierRevocations(): Promise<RevocationsState> {
56
- try {
57
- return await readRevocations(ports.http, params.revocationsUrl)
58
- } catch (error) {
59
- throw new LinkError(
60
- "VERIFIER_UNREACHABLE",
61
- error instanceof Error ? error.message : `GET ${params.revocationsUrl} failed`
62
- )
55
+ if (roots.status === "rejected") {
56
+ // Fixed text: a viem message carries the RPC URL, which can hold a provider key.
57
+ throw new LinkError("CHAIN_UNREACHABLE", "the current roots could not be read from chain")
58
+ }
59
+ if (!listsMatchChain(lists.value, roots.value)) {
60
+ throw new LinkError("VERIFIER_UNREACHABLE", "the verifier's lists do not match the chain")
61
+ }
62
+ return {
63
+ ...lists.value,
64
+ approvedIssuerRoot: roots.value.approvedIssuerRoot,
65
+ revocationRoot: roots.value.revocationRoot
63
66
  }
64
67
  }
65
68
 
@@ -82,16 +85,10 @@ export async function presentationCreateHandler(
82
85
  // state is kept so the proof is built from the same read, not a second one that could disagree.
83
86
  let chainState: WalletChainState | undefined
84
87
  if (candidates.length > 0) {
85
- const state = { ...(await verifierIssuerSet()), ...(await verifierRevocations()) }
88
+ const state = await networkState()
86
89
  chainState = state
87
90
  for (const candidate of candidates) {
88
- try {
89
- await wallet.syncCredential(candidate.id, async () => state)
90
- } catch (error) {
91
- // A served root that does not match the tree served beside it is the verifier's
92
- // fault, not this wallet's; without mapping it the caller is told INTERNAL.
93
- throw mapWalletError(error, "VERIFIER_UNREACHABLE")
94
- }
91
+ await wallet.syncCredential(candidate.id, async () => state)
95
92
  }
96
93
  }
97
94
 
@@ -120,10 +117,23 @@ export async function presentationCreateHandler(
120
117
  )
121
118
  }
122
119
 
120
+ const approved = await ports.review({
121
+ kind: "proof",
122
+ verifier: params.audience,
123
+ programme: params.programme,
124
+ country: params.country,
125
+ issuerNetworkId: selected.issuerNetworkId
126
+ })
127
+ if (!approved) throw new LinkError("CONSENT_DENIED", "the proof was not shared")
128
+
123
129
  step("Proving")
130
+ const presenter = buildWallet(ports, seeds, neverLoadChainState, {
131
+ title: "Share a proof",
132
+ subtitle: `Proves your ${params.programme} credential (${params.country})`
133
+ })
124
134
  let proof
125
135
  try {
126
- proof = await wallet.present(
136
+ proof = await presenter.present(
127
137
  selected.id,
128
138
  {
129
139
  challenge: hexToBytes(params.challenge),
@@ -140,15 +150,6 @@ export async function presentationCreateHandler(
140
150
  throw new LinkError("PROOF_FAILED", error instanceof Error ? error.message : "proof generation failed")
141
151
  }
142
152
 
143
- const approved = await ports.review({
144
- kind: "proof",
145
- verifier: params.audience,
146
- programme: params.programme,
147
- country: params.country,
148
- issuerNetworkId: selected.issuerNetworkId
149
- })
150
- if (!approved) throw new LinkError("CONSENT_DENIED", "the proof was built but not shared")
151
-
152
153
  step("Sharing")
153
154
  // Only the id comes back: the verifier's own `ok` would be the verifier reporting on itself,
154
155
  // and its member app can read the recorded outcome from its own server under this id.
package/src/index.ts CHANGED
@@ -4,25 +4,30 @@
4
4
  */
5
5
 
6
6
  export { createUserAppService } from "./service"
7
- export { mapWalletError } from "./errors"
7
+ export { DeviceKeyError, mapWalletError } from "./errors"
8
8
  export { latestExpiring, matchingCredentials, type CredentialSelectionRequest } from "./selection"
9
9
  export { buildConsentSummary } from "./consent"
10
10
  export { DEVICE_SECRET_KEY } from "./device-secret"
11
11
  export { CREDENTIAL_ISSUE_STEPS } from "./steps"
12
12
  export { listIssuerEntries, readIssuerEntry, type IssuerIndexEntry } from "./issuer-index"
13
+ export { DEVICE_ATTESTATION_CHALLENGE } from "@coordination-network/credential"
13
14
  export type {
14
15
  ConsentDetail,
15
16
  ConsentScope,
16
17
  ConsentSummary,
17
18
  DeviceKey,
19
+ ChainRoots,
18
20
  HeldCredential,
19
21
  HeldCredentialStatus,
20
22
  HttpClient,
21
23
  HttpJsonResponse,
24
+ KeyPrompt,
25
+ MemberDirectory,
22
26
  PresentationReview,
23
27
  PresentationReviewPrompt,
24
28
  PublicIdentity,
25
29
  RevocationAuthority,
30
+ RootAuthority,
26
31
  StealthScanKeys,
27
32
  UserAppPorts,
28
33
  UserAppService,
@@ -3,9 +3,10 @@ import type { Store } from "@coordination-network/wallet"
3
3
  import type { ConsentScope } from "./types"
4
4
 
5
5
  /**
6
- * `issuerNetworkId` is asserted by the caller and cannot be verified here. An established mapping
7
- * still cannot be taken over while the credential under it is valid: `credential.issue` answers
8
- * `alreadyHeld` for such a key rather than issuing against it again.
6
+ * `issuerNetworkId` is asserted by the caller, so `credential.issue` checks it against the chain:
7
+ * an entry is written, or honoured as `alreadyHeld`, only for a key in the member's
8
+ * `issuerKeysOf` list. Listings and presentation read entries as written, including any from before
9
+ * this check.
9
10
  */
10
11
  const ISSUER_INDEX_PREFIX = "user-app-core:issuer-index:"
11
12
 
@@ -17,9 +18,8 @@ export interface IssuerIndexEntry {
17
18
  credentialId: string
18
19
  /** The Baby Jubjub key that signed the credential, as decimal field elements. */
19
20
  issuerPublicKey: [string, string]
20
- /** Where the issuer published its own approved-issuer set at issuance. Nothing reads it yet:
21
- * a presentation reads the verifier's, which is why a hostile verifier can still probe for the
22
- * issuer by serving a subset. Recorded so that check can be anchored here instead. */
21
+ /** Where the issuer published its approved-issuer set at issuance. Nothing reads it back: a
22
+ * presentation reads the verifier's lists. */
23
23
  issuersUrl: string
24
24
  programme: string
25
25
  country: string
@@ -62,3 +62,19 @@ export async function listIssuerEntries(store: Store): Promise<(IssuerIndexEntry
62
62
  export async function writeIssuerEntry(store: Store, issuerNetworkId: Hex32, entry: IssuerIndexEntry): Promise<void> {
63
63
  await store.set(keyFor(issuerNetworkId, entry), JSON.stringify(entry))
64
64
  }
65
+
66
+ /** Removes the entries naming any of `credentialIds`; every other entry stays. */
67
+ export async function deleteIssuerEntries(store: Store, credentialIds: ReadonlySet<string>): Promise<void> {
68
+ for (const key of await store.list()) {
69
+ if (!key.startsWith(ISSUER_INDEX_PREFIX)) continue
70
+ const raw = await store.get(key)
71
+ if (raw === null) continue
72
+ let credentialId: string
73
+ try {
74
+ credentialId = (JSON.parse(raw) as IssuerIndexEntry).credentialId
75
+ } catch {
76
+ continue
77
+ }
78
+ if (credentialIds.has(credentialId)) await store.delete(key)
79
+ }
80
+ }
@@ -0,0 +1,8 @@
1
+ import type { SecurityLevel } from "@coordination-network/user-app-link"
2
+
3
+ // UNKNOWN ranks with SOFTWARE: a keystore that cannot say is trusted no more than one that admits it.
4
+ const RANK: Record<SecurityLevel, number> = { STRONGBOX: 2, TRUSTED_ENVIRONMENT: 1, SOFTWARE: 0, UNKNOWN: 0 }
5
+
6
+ export function meetsSecurityLevel(level: SecurityLevel, minimum: SecurityLevel): boolean {
7
+ return RANK[level] >= RANK[minimum]
8
+ }
package/src/selection.ts CHANGED
@@ -9,8 +9,8 @@ export interface CredentialSelectionRequest {
9
9
  /**
10
10
  * `presentation.create` carries no credential id, so this narrows by what the credential body
11
11
  * says: the requested programme and country. Status is deliberately not a filter — each candidate
12
- * is then synced against its own issuer's snapshot, and a caller that finds none valid still
13
- * needs to know whether it held a revoked one.
12
+ * is then synced against the verifier's lists, checked against the chain's roots, and a caller
13
+ * that finds none valid still needs to know whether it held a revoked one.
14
14
  */
15
15
  export function matchingCredentials<T extends CredentialInfo>(
16
16
  credentials: T[],