@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/chain-state.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
17
|
-
export type
|
|
14
|
+
/** The trees a server's two lists rebuild, with no root attached. */
|
|
15
|
+
export type ChainLists = Pick<WalletChainState, "approvedIssuers" | "revocations">
|
|
18
16
|
|
|
19
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
32
|
+
const { snapshot } = response.body as RevocationSnapshotResponseBody
|
|
47
33
|
|
|
48
|
-
return
|
|
34
|
+
return RevocationRegistry.import(snapshot)
|
|
49
35
|
}
|
|
50
36
|
|
|
51
37
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
82
|
-
?
|
|
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
|
-
|
|
89
|
-
|
|
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(
|
|
112
|
-
|
|
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(
|
|
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
|
|
205
|
+
let parsed: ZkCredential
|
|
146
206
|
try {
|
|
147
|
-
|
|
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 {
|
|
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 =
|
|
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
|
|
41
|
-
* can be judged without
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
52
|
+
lists.reason instanceof Error ? lists.reason.message : "the verifier's lists are unreadable"
|
|
50
53
|
)
|
|
51
54
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 =
|
|
88
|
+
const state = await networkState()
|
|
86
89
|
chainState = state
|
|
87
90
|
for (const candidate of candidates) {
|
|
88
|
-
|
|
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
|
|
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,
|
package/src/issuer-index.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
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
|
|
21
|
-
*
|
|
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
|
|
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[],
|