@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/README.md +53 -29
- package/dist/index.cjs +282 -90
- package/dist/index.js +281 -93
- package/package.json +9 -9
- package/src/chain-state.ts +28 -32
- package/src/errors.ts +24 -0
- package/src/handlers/credential-issue.ts +103 -29
- package/src/handlers/presentation-create.ts +41 -40
- package/src/index.ts +6 -1
- package/src/issuer-index.ts +22 -6
- package/src/security-level.ts +8 -0
- package/src/selection.ts +2 -2
- package/src/service.ts +50 -6
- package/src/types.ts +61 -8
- package/src/wallet-factory.ts +62 -6
- package/types/index.d.ts +72 -15
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 {
|
|
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 =
|
|
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
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
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
|
}
|
package/src/wallet-factory.ts
CHANGED
|
@@ -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
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
224
|
-
*
|
|
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 };
|