@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/src/service.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { deriveKeys, generateDeviceSecret, type Hex } from "@coordination-network/identity"
2
- import { parseZkCredential } from "@coordination-network/credential"
2
+ import { deriveDeviceKeyCommitment, parseZkCredential } from "@coordination-network/credential"
3
+ import { CREDENTIAL_PREFIX } from "@coordination-network/wallet"
3
4
  import { LinkError, type DecodedLinkRequest, type LinkHandlers } from "@coordination-network/user-app-link"
4
5
  import { buildConsentSummary } from "./consent"
5
6
  import { readDeviceSecret, writeDeviceSecret } from "./device-secret"
@@ -7,9 +8,10 @@ import { credentialIssueHandler } from "./handlers/credential-issue"
7
8
  import { presentationCreateHandler } from "./handlers/presentation-create"
8
9
  import { registrationSignHandler } from "./handlers/registration-sign"
9
10
  import { assembleIdentity } from "./identity"
10
- import { listIssuerEntries } from "./issuer-index"
11
+ import { deleteIssuerEntries, listIssuerEntries } from "./issuer-index"
11
12
  import { nowFn } from "./now"
12
- import { buildWallet, neverLoadChainState } from "./wallet-factory"
13
+ import { meetsSecurityLevel } from "./security-level"
14
+ import { buildWallet, createIdentitySeedCache, neverLoadChainState } from "./wallet-factory"
13
15
  import type {
14
16
  HeldCredential,
15
17
  HeldCredentialStatus,
@@ -87,6 +89,8 @@ export function createUserAppService(ports: UserAppPorts): UserAppService {
87
89
  return secret
88
90
  }
89
91
 
92
+ const seeds = createIdentitySeedCache(ports.deviceKey)
93
+
90
94
  // One slot deep: a handler body reads and writes the same store a concurrent one might be
91
95
  // mid-sync against, so two deep links run their handler bodies one at a time, never interleaved.
92
96
  let queue: Promise<unknown> = Promise.resolve()
@@ -104,12 +108,12 @@ export function createUserAppService(ports: UserAppPorts): UserAppService {
104
108
  serialize(async () => registrationSignHandler(ports, await requireOnboarded(), params)),
105
109
 
106
110
  "credential.issue": (params) =>
107
- serialize(async () => credentialIssueHandler(ports, await requireOnboarded(), params)),
111
+ serialize(async () => credentialIssueHandler(ports, seeds, await requireOnboarded(), params)),
108
112
 
109
113
  "presentation.create": (params) =>
110
114
  serialize(async () => {
111
115
  await requireOnboarded()
112
- return presentationCreateHandler(ports, params)
116
+ return presentationCreateHandler(ports, seeds, params)
113
117
  })
114
118
  }
115
119
 
@@ -129,6 +133,15 @@ export function createUserAppService(ports: UserAppPorts): UserAppService {
129
133
  if ((await resolveDeviceSecret()) !== null) {
130
134
  throw new Error("createIdentity: an identity already exists — erase() before creating another")
131
135
  }
136
+ const minimum = ports.minimumDeviceSecurityLevel
137
+ if (minimum !== undefined) {
138
+ const level = await ports.deviceKey.getSecurityLevel()
139
+ if (!meetsSecurityLevel(level, minimum)) {
140
+ throw new Error(
141
+ `This phone's key protection is ${level}; creating an identity needs a hardware-backed key (${minimum} or better).`
142
+ )
143
+ }
144
+ }
132
145
  const secret = generateDeviceSecret()
133
146
  const identity = await assembleIdentity(secret, ports.deviceKey)
134
147
  await writeDeviceSecret(ports.store, secret)
@@ -155,7 +168,7 @@ export function createUserAppService(ports: UserAppPorts): UserAppService {
155
168
  * name and the plaintext programme survive — the credential body carries a hash.
156
169
  */
157
170
  async listCredentials(): Promise<HeldCredential[]> {
158
- const wallet = await buildWallet(ports, neverLoadChainState)
171
+ const wallet = buildWallet(ports, seeds, neverLoadChainState, null)
159
172
  const held = await wallet.listCredentials()
160
173
  const entryByCredentialId = new Map(
161
174
  (await listIssuerEntries(ports.store)).map((entry) => [entry.credentialId, entry])
@@ -191,6 +204,36 @@ export function createUserAppService(ports: UserAppPorts): UserAppService {
191
204
  )
192
205
  },
193
206
 
207
+ removeUnboundCredentials(): Promise<number> {
208
+ return serialize(async () => {
209
+ const commitment = deriveDeviceKeyCommitment(await ports.deviceKey.getPublicKeyJwk())
210
+ const wallet = buildWallet(ports, seeds, neverLoadChainState, null)
211
+ const unbound: string[] = []
212
+ for (const key of await ports.store.list()) {
213
+ if (!key.startsWith(CREDENTIAL_PREFIX)) continue
214
+ const id = key.slice(CREDENTIAL_PREFIX.length)
215
+ try {
216
+ const info = await wallet.getCredential(id)
217
+ if (
218
+ info !== null &&
219
+ parseZkCredential(info.credential).body.deviceKeyCommitment !== commitment
220
+ ) {
221
+ unbound.push(id)
222
+ }
223
+ } catch {
224
+ // Unreadable, so not proven unbound: left in place.
225
+ }
226
+ }
227
+ if (unbound.length === 0) return 0
228
+ // Index entries first: a run killed before the credentials go leaves them still
229
+ // unbound, so the next run removes them.
230
+ await deleteIssuerEntries(ports.store, new Set(unbound))
231
+ for (const id of unbound) await wallet.deleteCredential(id)
232
+ seeds.clear()
233
+ return unbound.length
234
+ })
235
+ },
236
+
194
237
  /**
195
238
  * Clears the entire store, not only this package's own keys — a host app shares it, and an
196
239
  * "erase everything" that left its configuration behind would be a half-erase. A caller
@@ -200,6 +243,7 @@ export function createUserAppService(ports: UserAppPorts): UserAppService {
200
243
  return serialize(async () => {
201
244
  for (const key of await ports.store.list()) await ports.store.delete(key)
202
245
  deviceSecretCache = null
246
+ seeds.clear()
203
247
  })
204
248
  }
205
249
  }
package/src/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Hex } from "@coordination-network/identity"
2
- import type { DeviceSigner } from "@coordination-network/credential"
3
- import type { ProofGenerator, Store } from "@coordination-network/wallet"
2
+ import type { DeviceSigner, Jwk } from "@coordination-network/credential"
3
+ import type { ProofGenerator, Store, WalletChainState } from "@coordination-network/wallet"
4
4
  import type {
5
5
  Caller,
6
6
  DecodedLinkRequest,
@@ -12,15 +12,26 @@ import type {
12
12
  SecurityLevel
13
13
  } from "@coordination-network/user-app-link"
14
14
 
15
+ /** The text of the system dialog a key use raises. The caller supplies it; the key only shows it. */
16
+ export interface KeyPrompt {
17
+ title: string
18
+ subtitle?: string
19
+ }
20
+
15
21
  /**
16
- * The holder P-256 device key. Extends `DeviceSigner` (rather than replacing it) so a `DeviceKey`
17
- * is accepted as-is anywhere a `DeviceSigner` is expected — `Wallet`, `buildZkPresentationInputs`,
18
- * `deriveDeviceKeyCommitment`.
22
+ * The holder's two P-256 hardware keys behind one port: a signing key, used per operation, and an
23
+ * agreement key for ECDH. Every use that unlocks a key takes the prompt the person will see; a
24
+ * cancelled or invalidated key throws `DeviceKeyError`.
19
25
  */
20
- export interface DeviceKey extends DeviceSigner {
21
- /** X coordinate of hwPriv · peerPublicKey, 32 raw bytes. Feeds `deriveEddsaKeypair`. */
22
- ecdh(peerPublicKey: Uint8Array): Promise<Uint8Array>
26
+ export interface DeviceKey {
27
+ getPublicKeyJwk(): Promise<Jwk>
28
+ /** A signer whose `sign` and `signDigest` each raise `prompt`, so it fits `Wallet` and `buildZkPresentationInputs`. */
29
+ signerFor(prompt: KeyPrompt): DeviceSigner
30
+ /** X coordinate of agreementPriv · peerPublicKey, 32 raw bytes. Feeds `deriveEddsaKeypair`. */
31
+ ecdh(peerPublicKey: Uint8Array, prompt: KeyPrompt): Promise<Uint8Array>
23
32
  getSecurityLevel(): Promise<SecurityLevel>
33
+ /** Base64 DER certificates attesting the signing key to its hardware root of trust, leaf first; empty when it has none. */
34
+ getAttestationChain(): Promise<string[]>
24
35
  }
25
36
 
26
37
  /** A minimal HTTP JSON port: no DOM or RN fetch types needed, and a test fake is three lines. */
@@ -100,13 +111,49 @@ export interface RevocationAuthority {
100
111
  isRevoked(commitment: bigint): Promise<boolean | null>
101
112
  }
102
113
 
114
+ /**
115
+ * What the chain says about a member. A rejection means the chain could not be read, never "no
116
+ * keys": an unknown member answers with an empty list. Unknown includes a removed member and one
117
+ * the wallet's own member list does not have. An implementation must not reveal to the chain node
118
+ * which member is asked about.
119
+ */
120
+ export interface MemberDirectory {
121
+ /** Leaves of the member's live issuer keys, as `ApprovedIssuerSet.issuerKeysOf` returns them. */
122
+ issuerKeyLeavesOf(networkId: Hex32): Promise<bigint[]>
123
+ }
124
+
125
+ /**
126
+ * What a served list is checked against. The leaf count pins the issuer set's leaves, not only its
127
+ * root: an internal node can stand in for the leaves under it and keep the root.
128
+ */
129
+ export type ChainRoots = Pick<WalletChainState, "approvedIssuerRoot" | "revocationRoot"> & {
130
+ approvedIssuerLeafCount: number
131
+ }
132
+
133
+ /**
134
+ * What the chain holds now: the network's one approved-issuer set and one revocation registry. A
135
+ * rejection means the chain could not be read. The lists a verifier or issuer serves are only
136
+ * bulk data that must hash to exactly these roots, so an implementation must read the chain, never
137
+ * a server, and all three values must come from one chain state (one block). It must not reveal to the chain node which credential or member prompted the read.
138
+ */
139
+ export interface RootAuthority {
140
+ currentRoots(): Promise<ChainRoots>
141
+ }
142
+
103
143
  export interface UserAppPorts {
104
144
  store: Store
105
145
  deviceKey: DeviceKey
146
+ /**
147
+ * The lowest level `createIdentity` accepts. Absent means no floor. UNKNOWN ranks with SOFTWARE.
148
+ * The issuer enforces its own minimum at issuance.
149
+ */
150
+ minimumDeviceSecurityLevel?: SecurityLevel
106
151
  proofGenerator: ProofGenerator
107
152
  http: HttpClient
108
153
  consent: UserConsentPrompt
109
154
  review: PresentationReviewPrompt
155
+ directory: MemberDirectory
156
+ roots: RootAuthority
110
157
  /** Absent leaves every held credential's revocation unchecked; see {@link RevocationAuthority}. */
111
158
  revocations?: RevocationAuthority
112
159
  /** Named steps inside a long handler, for a screen that shows what the wallet is doing. */
@@ -171,5 +218,11 @@ export interface UserAppService {
171
218
  listCredentials(): Promise<HeldCredential[]>
172
219
  /** See {@link StealthScanKeys}. Throws if no identity has been created. */
173
220
  readStealthScanKeys(): Promise<StealthScanKeys>
221
+ /**
222
+ * Drops every held credential bound to a device key other than the current one, with its
223
+ * revocation secret and issuer-index entry, and resolves to how many. Reads only the public key,
224
+ * so it raises no prompt, and keeps the device secret, so the person stays registered.
225
+ */
226
+ removeUnboundCredentials(): Promise<number>
174
227
  erase(): Promise<void>
175
228
  }
@@ -1,8 +1,15 @@
1
1
  import { deriveEddsaKeypair, eddsaPeerPointBytes } from "@coordination-network/identity"
2
+ import type { DeviceSigner } from "@coordination-network/credential"
2
3
  import { ChainStateLoader, Wallet } from "@coordination-network/wallet"
3
4
  import { hexToBytes } from "./hex"
4
5
  import { nowFn } from "./now"
5
- import type { DeviceKey, UserAppPorts } from "./types"
6
+ import type { DeviceKey, KeyPrompt, UserAppPorts } from "./types"
7
+
8
+ /** Its own text, so the person never sees two identical dialogs back to back. */
9
+ const ECDH_PROMPT: KeyPrompt = {
10
+ title: "Unlock your credentials",
11
+ subtitle: "Needed once each time the app starts"
12
+ }
6
13
 
7
14
  /**
8
15
  * The Baby Jubjub identity seed, rooted in the hardware ECDH rather than the device secret.
@@ -11,10 +18,36 @@ import type { DeviceKey, UserAppPorts } from "./types"
11
18
  * the 32 raw bytes decoded from it.
12
19
  */
13
20
  export async function resolveIdentitySeed(deviceKey: DeviceKey): Promise<Uint8Array> {
14
- const sharedSecret = await deviceKey.ecdh(eddsaPeerPointBytes())
21
+ const sharedSecret = await deviceKey.ecdh(eddsaPeerPointBytes(), ECDH_PROMPT)
15
22
  return hexToBytes(deriveEddsaKeypair(sharedSecret).seed)
16
23
  }
17
24
 
25
+ /** The seed is the same for the life of the process, so the person is asked for it once. */
26
+ export interface IdentitySeedCache {
27
+ resolve(): Promise<Uint8Array>
28
+ clear(): void
29
+ }
30
+
31
+ export function createIdentitySeedCache(deviceKey: DeviceKey): IdentitySeedCache {
32
+ let pending: Promise<Uint8Array> | null = null
33
+ return {
34
+ resolve() {
35
+ if (pending === null) {
36
+ const attempt = resolveIdentitySeed(deviceKey)
37
+ pending = attempt
38
+ // A cancelled prompt must not be cached: the next operation asks again.
39
+ attempt.catch(() => {
40
+ if (pending === attempt) pending = null
41
+ })
42
+ }
43
+ return pending
44
+ },
45
+ clear() {
46
+ pending = null
47
+ }
48
+ }
49
+ }
50
+
18
51
  /**
19
52
  * `listCredentials` never syncs — there is no single authority to sync a mixed-issuer listing
20
53
  * against — so this sentinel documents that, and would surface loudly if a future bug called it.
@@ -23,16 +56,39 @@ export const neverLoadChainState: ChainStateLoader = async () => {
23
56
  throw new Error("chain state must not be requested outside a per-request authority")
24
57
  }
25
58
 
59
+ /** A listing wallet's identity seed: like `neverLoadChainState`, loud if a future bug reaches for it. */
60
+ export const neverSeed = async (): Promise<Uint8Array> => {
61
+ throw new Error("a listing must not ask the device key for the identity seed")
62
+ }
63
+
64
+ const neverSign = async (): Promise<Uint8Array> => {
65
+ throw new Error("a listing must not ask the device key to sign")
66
+ }
67
+
68
+ /** A listing wallet's signer: it reads the public key, which raises no prompt, and never signs. */
69
+ export const listingSigner = (deviceKey: DeviceKey): DeviceSigner => ({
70
+ getPublicKeyJwk: () => deviceKey.getPublicKeyJwk(),
71
+ sign: neverSign,
72
+ signDigest: neverSign
73
+ })
74
+
26
75
  /**
27
76
  * A fresh `Wallet` per request, scoped to one member's authority. The private `Wallet` gets a
28
77
  * constant-`true` consent prompt: the person has already been asked once, at the link boundary,
29
78
  * and prompting again here would show a second screen for one `presentation.create`.
79
+ *
80
+ * `prompt` is what a signature raises. `null` builds a wallet for listing and syncing, which must
81
+ * never touch a key: its seed and signer throw.
30
82
  */
31
- export async function buildWallet(ports: UserAppPorts, loadChainState: ChainStateLoader): Promise<Wallet> {
32
- const identitySeed = await resolveIdentitySeed(ports.deviceKey)
83
+ export function buildWallet(
84
+ ports: UserAppPorts,
85
+ seeds: IdentitySeedCache,
86
+ loadChainState: ChainStateLoader,
87
+ prompt: KeyPrompt | null
88
+ ): Wallet {
33
89
  return new Wallet({
34
- identitySeed,
35
- deviceSigner: ports.deviceKey,
90
+ identitySeed: prompt === null ? neverSeed : () => seeds.resolve(),
91
+ deviceSigner: prompt === null ? listingSigner(ports.deviceKey) : ports.deviceKey.signerFor(prompt),
36
92
  consent: async () => true,
37
93
  proofGenerator: ports.proofGenerator,
38
94
  store: ports.store,
package/types/index.d.ts CHANGED
@@ -1,17 +1,28 @@
1
1
  import { Hex } from '@coordination-network/identity';
2
- import { DeviceSigner } from '@coordination-network/credential';
3
- import { Store, ProofGenerator, CredentialInfo } from '@coordination-network/wallet';
2
+ import { Jwk, DeviceSigner } from '@coordination-network/credential';
3
+ export { DEVICE_ATTESTATION_CHALLENGE } from '@coordination-network/credential';
4
+ import { WalletChainState, Store, ProofGenerator, CredentialInfo } from '@coordination-network/wallet';
4
5
  import { Caller, SecurityLevel, Hex32, Hex66, Hex65, LinkOp, LinkHandlers, DecodedLinkRequest, LinkErrorCode, LinkError } from '@coordination-network/user-app-link';
5
6
 
7
+ /** The text of the system dialog a key use raises. The caller supplies it; the key only shows it. */
8
+ interface KeyPrompt {
9
+ title: string;
10
+ subtitle?: string;
11
+ }
6
12
  /**
7
- * The holder P-256 device key. Extends `DeviceSigner` (rather than replacing it) so a `DeviceKey`
8
- * is accepted as-is anywhere a `DeviceSigner` is expected — `Wallet`, `buildZkPresentationInputs`,
9
- * `deriveDeviceKeyCommitment`.
13
+ * The holder's two P-256 hardware keys behind one port: a signing key, used per operation, and an
14
+ * agreement key for ECDH. Every use that unlocks a key takes the prompt the person will see; a
15
+ * cancelled or invalidated key throws `DeviceKeyError`.
10
16
  */
11
- interface DeviceKey extends DeviceSigner {
12
- /** X coordinate of hwPriv · peerPublicKey, 32 raw bytes. Feeds `deriveEddsaKeypair`. */
13
- ecdh(peerPublicKey: Uint8Array): Promise<Uint8Array>;
17
+ interface DeviceKey {
18
+ getPublicKeyJwk(): Promise<Jwk>;
19
+ /** A signer whose `sign` and `signDigest` each raise `prompt`, so it fits `Wallet` and `buildZkPresentationInputs`. */
20
+ signerFor(prompt: KeyPrompt): DeviceSigner;
21
+ /** X coordinate of agreementPriv · peerPublicKey, 32 raw bytes. Feeds `deriveEddsaKeypair`. */
22
+ ecdh(peerPublicKey: Uint8Array, prompt: KeyPrompt): Promise<Uint8Array>;
14
23
  getSecurityLevel(): Promise<SecurityLevel>;
24
+ /** Base64 DER certificates attesting the signing key to its hardware root of trust, leaf first; empty when it has none. */
25
+ getAttestationChain(): Promise<string[]>;
15
26
  }
16
27
  /** A minimal HTTP JSON port: no DOM or RN fetch types needed, and a test fake is three lines. */
17
28
  interface HttpJsonResponse {
@@ -87,13 +98,46 @@ type PresentationReviewPrompt = (summary: PresentationReview) => Promise<boolean
87
98
  interface RevocationAuthority {
88
99
  isRevoked(commitment: bigint): Promise<boolean | null>;
89
100
  }
101
+ /**
102
+ * What the chain says about a member. A rejection means the chain could not be read, never "no
103
+ * keys": an unknown member answers with an empty list. Unknown includes a removed member and one
104
+ * the wallet's own member list does not have. An implementation must not reveal to the chain node
105
+ * which member is asked about.
106
+ */
107
+ interface MemberDirectory {
108
+ /** Leaves of the member's live issuer keys, as `ApprovedIssuerSet.issuerKeysOf` returns them. */
109
+ issuerKeyLeavesOf(networkId: Hex32): Promise<bigint[]>;
110
+ }
111
+ /**
112
+ * What a served list is checked against. The leaf count pins the issuer set's leaves, not only its
113
+ * root: an internal node can stand in for the leaves under it and keep the root.
114
+ */
115
+ type ChainRoots = Pick<WalletChainState, "approvedIssuerRoot" | "revocationRoot"> & {
116
+ approvedIssuerLeafCount: number;
117
+ };
118
+ /**
119
+ * What the chain holds now: the network's one approved-issuer set and one revocation registry. A
120
+ * rejection means the chain could not be read. The lists a verifier or issuer serves are only
121
+ * bulk data that must hash to exactly these roots, so an implementation must read the chain, never
122
+ * a server, and all three values must come from one chain state (one block). It must not reveal to the chain node which credential or member prompted the read.
123
+ */
124
+ interface RootAuthority {
125
+ currentRoots(): Promise<ChainRoots>;
126
+ }
90
127
  interface UserAppPorts {
91
128
  store: Store;
92
129
  deviceKey: DeviceKey;
130
+ /**
131
+ * The lowest level `createIdentity` accepts. Absent means no floor. UNKNOWN ranks with SOFTWARE.
132
+ * The issuer enforces its own minimum at issuance.
133
+ */
134
+ minimumDeviceSecurityLevel?: SecurityLevel;
93
135
  proofGenerator: ProofGenerator;
94
136
  http: HttpClient;
95
137
  consent: UserConsentPrompt;
96
138
  review: PresentationReviewPrompt;
139
+ directory: MemberDirectory;
140
+ roots: RootAuthority;
97
141
  /** Absent leaves every held credential's revocation unchecked; see {@link RevocationAuthority}. */
98
142
  revocations?: RevocationAuthority;
99
143
  /** Named steps inside a long handler, for a screen that shows what the wallet is doing. */
@@ -153,11 +197,25 @@ interface UserAppService {
153
197
  listCredentials(): Promise<HeldCredential[]>;
154
198
  /** See {@link StealthScanKeys}. Throws if no identity has been created. */
155
199
  readStealthScanKeys(): Promise<StealthScanKeys>;
200
+ /**
201
+ * Drops every held credential bound to a device key other than the current one, with its
202
+ * revocation secret and issuer-index entry, and resolves to how many. Reads only the public key,
203
+ * so it raises no prompt, and keeps the device secret, so the person stays registered.
204
+ */
205
+ removeUnboundCredentials(): Promise<number>;
156
206
  erase(): Promise<void>;
157
207
  }
158
208
 
159
209
  declare function createUserAppService(ports: UserAppPorts): UserAppService;
160
210
 
211
+ /**
212
+ * A key use the person or the platform ended: cancelled at the prompt, the key invalidated, or the
213
+ * authentication itself failed (lockout, no foreground activity, another prompt already showing).
214
+ */
215
+ declare class DeviceKeyError extends Error {
216
+ readonly code: "USER_CANCELED" | "KEY_INVALIDATED" | "AUTHENTICATION_FAILED";
217
+ constructor(code: DeviceKeyError["code"], message?: string);
218
+ }
161
219
  /**
162
220
  * Preserves the original message on the thrown `LinkError`. `chainStateCode` names the side whose
163
221
  * lists were being read: during issuance that is the issuer, not a verifier.
@@ -176,8 +234,8 @@ interface CredentialSelectionRequest {
176
234
  /**
177
235
  * `presentation.create` carries no credential id, so this narrows by what the credential body
178
236
  * says: the requested programme and country. Status is deliberately not a filter — each candidate
179
- * is then synced against its own issuer's snapshot, and a caller that finds none valid still
180
- * needs to know whether it held a revoked one.
237
+ * is then synced against the verifier's lists, checked against the chain's roots, and a caller
238
+ * that finds none valid still needs to know whether it held a revoked one.
181
239
  */
182
240
  declare function matchingCredentials<T extends CredentialInfo>(credentials: T[], request: CredentialSelectionRequest): T[];
183
241
  /** Of the credentials still usable, the one that stays usable longest, tie-broken by id for determinism. */
@@ -220,9 +278,8 @@ interface IssuerIndexEntry {
220
278
  credentialId: string;
221
279
  /** The Baby Jubjub key that signed the credential, as decimal field elements. */
222
280
  issuerPublicKey: [string, string];
223
- /** Where the issuer published its own approved-issuer set at issuance. Nothing reads it yet:
224
- * a presentation reads the verifier's, which is why a hostile verifier can still probe for the
225
- * issuer by serving a subset. Recorded so that check can be anchored here instead. */
281
+ /** Where the issuer published its approved-issuer set at issuance. Nothing reads it back: a
282
+ * presentation reads the verifier's lists. */
226
283
  issuersUrl: string;
227
284
  programme: string;
228
285
  country: string;
@@ -234,5 +291,5 @@ declare function listIssuerEntries(store: Store): Promise<(IssuerIndexEntry & {
234
291
  issuerNetworkId: Hex32;
235
292
  })[]>;
236
293
 
237
- export { CREDENTIAL_ISSUE_STEPS, DEVICE_SECRET_KEY, buildConsentSummary, createUserAppService, latestExpiring, listIssuerEntries, mapWalletError, matchingCredentials, readIssuerEntry };
238
- export type { ConsentDetail, ConsentScope, ConsentSummary, CredentialSelectionRequest, DeviceKey, HeldCredential, HeldCredentialStatus, HttpClient, HttpJsonResponse, IssuerIndexEntry, PresentationReview, PresentationReviewPrompt, PublicIdentity, RevocationAuthority, StealthScanKeys, UserAppPorts, UserAppService, UserConsentPrompt };
294
+ export { CREDENTIAL_ISSUE_STEPS, DEVICE_SECRET_KEY, DeviceKeyError, buildConsentSummary, createUserAppService, latestExpiring, listIssuerEntries, mapWalletError, matchingCredentials, readIssuerEntry };
295
+ export type { ChainRoots, ConsentDetail, ConsentScope, ConsentSummary, CredentialSelectionRequest, DeviceKey, HeldCredential, HeldCredentialStatus, HttpClient, HttpJsonResponse, IssuerIndexEntry, KeyPrompt, MemberDirectory, PresentationReview, PresentationReviewPrompt, PublicIdentity, RevocationAuthority, RootAuthority, StealthScanKeys, UserAppPorts, UserAppService, UserConsentPrompt };