@coordination-network/wallet 0.0.2 → 0.1.1
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 +19 -16
- package/dist/errors.d.ts +2 -1
- package/dist/errors.js +2 -1
- package/dist/wallet.d.ts +8 -4
- package/dist/wallet.js +23 -6
- package/package.json +7 -7
- package/src/errors.ts +3 -1
- package/src/wallet.ts +37 -11
package/README.md
CHANGED
|
@@ -7,6 +7,9 @@ The wallet verifies a credential's issuer signature and its holder, device, and
|
|
|
7
7
|
revocation-secret bindings before storing it. Before presenting, it requires
|
|
8
8
|
fresh approved-issuer and revocation trees, asks for consent, builds the private
|
|
9
9
|
circuit inputs, and delegates proof generation to the configured proof system.
|
|
10
|
+
It refuses a verifier-supplied `currentTime` more than 300 seconds from the
|
|
11
|
+
holder's clock before consent, signing, or proving, so a verifier cannot search
|
|
12
|
+
the credential's validity window by choosing arbitrary times.
|
|
10
13
|
|
|
11
14
|
## Use
|
|
12
15
|
|
|
@@ -50,25 +53,26 @@ const proof = await wallet.present(
|
|
|
50
53
|
programme: "demo-programme",
|
|
51
54
|
country: "ZZZ"
|
|
52
55
|
},
|
|
53
|
-
// The state this credential is judged and proved against
|
|
54
|
-
|
|
56
|
+
// The state this credential is judged and proved against: lists from the server the caller
|
|
57
|
+
// names, roots from chain.
|
|
58
|
+
loadChainStateForPresentation
|
|
55
59
|
)
|
|
56
60
|
|
|
57
61
|
stopSync()
|
|
58
62
|
await proofGenerator.destroy()
|
|
59
63
|
```
|
|
60
64
|
|
|
61
|
-
`loadChainState` returns each reconstructed tree alongside
|
|
62
|
-
root
|
|
65
|
+
`loadChainState` returns each reconstructed tree alongside its authoritative
|
|
66
|
+
root. The caller must read the roots from chain, not from the server that
|
|
67
|
+
served the lists: a root served beside its own list always matches it. The
|
|
68
|
+
wallet rejects a tree that does not hash to its root before storing or
|
|
63
69
|
presenting a credential. It uses the validated local trees to check every held
|
|
64
70
|
credential without revealing to an RPC provider which credential it owns.
|
|
65
71
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
`
|
|
69
|
-
|
|
70
|
-
takes the same kind of `load` so that the issuer the proof names and the
|
|
71
|
-
revocation tree it is checked against come from one read.
|
|
72
|
+
The lists come from whichever server the caller names and the roots from chain.
|
|
73
|
+
`syncCredential(id, load)` refreshes one credential against a `load`, and
|
|
74
|
+
`present(id, request, load)` takes the same kind of `load` so that the proof
|
|
75
|
+
and the checks before it come from one read.
|
|
72
76
|
`syncRevocations`/`startRevocationSync` refresh every held credential against
|
|
73
77
|
the single `loadChainState` configured on the wallet, for background use.
|
|
74
78
|
|
|
@@ -76,12 +80,11 @@ the single `loadChainState` configured on the wallet, for background use.
|
|
|
76
80
|
|
|
77
81
|
- Invalid, unapproved, revoked, not-yet-valid, or expired credentials are not
|
|
78
82
|
stored.
|
|
79
|
-
- A presentation proves membership against
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
state the presentation itself loaded.
|
|
83
|
+
- A presentation proves membership against the whole approved set, so the
|
|
84
|
+
proof's issuer root is the set's root and names no issuer. One network-wide
|
|
85
|
+
revocation registry lets the verifier check the revocation root without
|
|
86
|
+
picking a registry. Membership is checked beforehand, against the state the
|
|
87
|
+
presentation itself loaded.
|
|
85
88
|
- A presentation restamps the credential from its own `load` before judging it,
|
|
86
89
|
so it never proves against state an earlier sync left behind. Background sync
|
|
87
90
|
runs every 30 seconds by default.
|
package/dist/errors.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type WalletErrorCode = "NOT_FOUND" | "ALREADY_STORED" | "CREDENTIAL_INVALID" | "CONSENT_DENIED" | "REVOKED" | "CHAIN_STATE_UNAVAILABLE" | "CHAIN_STATE_INVALID" | "NOT_YET_VALID" | "EXPIRED" | "ISSUER_NOT_APPROVED" | "PRESENTATION_FAILED";
|
|
2
2
|
export declare class WalletError extends Error {
|
|
3
3
|
readonly code: WalletErrorCode;
|
|
4
|
-
|
|
4
|
+
readonly cause?: unknown;
|
|
5
|
+
constructor(code: WalletErrorCode, message: string, cause?: unknown);
|
|
5
6
|
}
|
package/dist/errors.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.WalletError = void 0;
|
|
4
4
|
class WalletError extends Error {
|
|
5
|
-
constructor(code, message) {
|
|
5
|
+
constructor(code, message, cause) {
|
|
6
6
|
super(message);
|
|
7
7
|
this.name = "WalletError";
|
|
8
8
|
this.code = code;
|
|
9
|
+
this.cause = cause;
|
|
9
10
|
}
|
|
10
11
|
}
|
|
11
12
|
exports.WalletError = WalletError;
|
package/dist/wallet.d.ts
CHANGED
|
@@ -21,10 +21,10 @@ export interface ConsentRequest {
|
|
|
21
21
|
export type ConsentPrompt = (request: ConsentRequest) => Promise<boolean>;
|
|
22
22
|
export interface WalletChainState {
|
|
23
23
|
approvedIssuers: ApprovedIssuerSet;
|
|
24
|
-
/** Authoritative root
|
|
24
|
+
/** Authoritative root, read from chain by the caller, never from the server that served the leaves. */
|
|
25
25
|
approvedIssuerRoot: bigint;
|
|
26
26
|
revocations: RevocationRegistry;
|
|
27
|
-
/** Authoritative root
|
|
27
|
+
/** Authoritative root, read from chain by the caller, never from the server that served the snapshot. */
|
|
28
28
|
revocationRoot: bigint;
|
|
29
29
|
}
|
|
30
30
|
/** Fetches and validates the latest public issuer and revocation trees. */
|
|
@@ -41,8 +41,11 @@ export interface CredentialInfo {
|
|
|
41
41
|
checkedAt: number;
|
|
42
42
|
}
|
|
43
43
|
export interface WalletOptions {
|
|
44
|
-
/**
|
|
45
|
-
|
|
44
|
+
/**
|
|
45
|
+
* Raw 32-byte Semaphore identity seed derived from the hardware P-256 ECDH output. A thunk is
|
|
46
|
+
* called once per `addCredential` and `present`, so a device key can prompt only when needed.
|
|
47
|
+
*/
|
|
48
|
+
identitySeed: Uint8Array | (() => Promise<Uint8Array>);
|
|
46
49
|
deviceSigner: DeviceSigner;
|
|
47
50
|
consent: ConsentPrompt;
|
|
48
51
|
loadChainState: ChainStateLoader;
|
|
@@ -100,6 +103,7 @@ export declare class Wallet {
|
|
|
100
103
|
private stamp;
|
|
101
104
|
private statusOf;
|
|
102
105
|
private loadAndValidateChainState;
|
|
106
|
+
private resolveIdentitySeed;
|
|
103
107
|
private listCredentialIds;
|
|
104
108
|
private readRecord;
|
|
105
109
|
private writeRecord;
|
package/dist/wallet.js
CHANGED
|
@@ -14,13 +14,17 @@ exports.CREDENTIAL_PREFIX = "vc:";
|
|
|
14
14
|
exports.REVOCATION_SECRET_PREFIX = "revsec:";
|
|
15
15
|
/** How often background sync restamps held credentials (issue #47 asks for 60s or better). */
|
|
16
16
|
exports.SYNC_INTERVAL_MS = 30000;
|
|
17
|
+
/** Match the verifier's default freshness window before a caller-chosen time reaches the prover. */
|
|
18
|
+
const MAX_PRESENTATION_TIME_SKEW_SECONDS = 300;
|
|
17
19
|
/** Device keys and held ZK credentials, with consent and fresh public tree state around presentation. */
|
|
18
20
|
class Wallet {
|
|
19
21
|
constructor(options) {
|
|
20
|
-
if (options.identitySeed.length !== 32)
|
|
22
|
+
if (typeof options.identitySeed !== "function" && options.identitySeed.length !== 32) {
|
|
21
23
|
throw new Error("identity seed must be exactly 32 bytes");
|
|
24
|
+
}
|
|
22
25
|
this.store = options.store ?? new store_1.MemoryStore();
|
|
23
|
-
this.identitySeed =
|
|
26
|
+
this.identitySeed =
|
|
27
|
+
typeof options.identitySeed === "function" ? options.identitySeed : options.identitySeed.slice();
|
|
24
28
|
this.deviceSigner = options.deviceSigner;
|
|
25
29
|
this.consent = options.consent;
|
|
26
30
|
this.loadChainState = options.loadChainState;
|
|
@@ -64,7 +68,7 @@ class Wallet {
|
|
|
64
68
|
const credential = (0, credential_1.parseZkCredential)(options.credential);
|
|
65
69
|
if (!(0, credential_1.verifyZkCredential)(credential))
|
|
66
70
|
throw new Error("credential issuer signature is invalid");
|
|
67
|
-
if (new identity_1.Identity(this.
|
|
71
|
+
if (new identity_1.Identity(await this.resolveIdentitySeed()).commitment !== credential.body.holderKeyCommitment) {
|
|
68
72
|
throw new Error("identity seed does not open the credential holder commitment");
|
|
69
73
|
}
|
|
70
74
|
if ((0, credential_1.deriveRevocationCommitment)(options.revocationSecret) !== credential.body.revocationCommitment) {
|
|
@@ -103,7 +107,7 @@ class Wallet {
|
|
|
103
107
|
catch (error) {
|
|
104
108
|
if (error instanceof errors_1.WalletError)
|
|
105
109
|
throw error;
|
|
106
|
-
throw new errors_1.WalletError("CREDENTIAL_INVALID", error instanceof Error ? error.message : "credential verification failed");
|
|
110
|
+
throw new errors_1.WalletError("CREDENTIAL_INVALID", error instanceof Error ? error.message : "credential verification failed", error);
|
|
107
111
|
}
|
|
108
112
|
}
|
|
109
113
|
async listCredentials() {
|
|
@@ -149,6 +153,11 @@ class Wallet {
|
|
|
149
153
|
if (status === "issuer-not-approved") {
|
|
150
154
|
throw new errors_1.WalletError("ISSUER_NOT_APPROVED", `credential ${id}'s issuer is no longer approved`);
|
|
151
155
|
}
|
|
156
|
+
const nowSeconds = Math.floor(this.now() / 1000);
|
|
157
|
+
if (!Number.isSafeInteger(request.currentTime) ||
|
|
158
|
+
Math.abs(request.currentTime - nowSeconds) > MAX_PRESENTATION_TIME_SKEW_SECONDS) {
|
|
159
|
+
throw new errors_1.WalletError("PRESENTATION_FAILED", "presentation request time is not current");
|
|
160
|
+
}
|
|
152
161
|
const allowed = await this.consent({
|
|
153
162
|
kind,
|
|
154
163
|
credentialId: id,
|
|
@@ -161,7 +170,7 @@ class Wallet {
|
|
|
161
170
|
let inputs;
|
|
162
171
|
try {
|
|
163
172
|
inputs = await (0, credential_1.buildZkPresentationInputs)(record.credential, {
|
|
164
|
-
identitySeed: this.
|
|
173
|
+
identitySeed: await this.resolveIdentitySeed(),
|
|
165
174
|
revocationSecret: (0, utils_1.hexToBytes)(revocationSecret.slice(2)),
|
|
166
175
|
deviceSigner: this.deviceSigner
|
|
167
176
|
}, {
|
|
@@ -176,7 +185,7 @@ class Wallet {
|
|
|
176
185
|
catch (error) {
|
|
177
186
|
if (error instanceof errors_1.WalletError)
|
|
178
187
|
throw error;
|
|
179
|
-
throw new errors_1.WalletError("PRESENTATION_FAILED", error instanceof Error ? error.message : "failed to build presentation inputs");
|
|
188
|
+
throw new errors_1.WalletError("PRESENTATION_FAILED", error instanceof Error ? error.message : "failed to build presentation inputs", error);
|
|
180
189
|
}
|
|
181
190
|
return this.proofGenerator.generateProof(inputs);
|
|
182
191
|
}
|
|
@@ -247,6 +256,14 @@ class Wallet {
|
|
|
247
256
|
}
|
|
248
257
|
return chainState;
|
|
249
258
|
}
|
|
259
|
+
async resolveIdentitySeed() {
|
|
260
|
+
if (typeof this.identitySeed !== "function")
|
|
261
|
+
return this.identitySeed;
|
|
262
|
+
const seed = await this.identitySeed();
|
|
263
|
+
if (seed.length !== 32)
|
|
264
|
+
throw new Error("identity seed must be exactly 32 bytes");
|
|
265
|
+
return seed;
|
|
266
|
+
}
|
|
250
267
|
async listCredentialIds() {
|
|
251
268
|
return (await this.store.list())
|
|
252
269
|
.filter((key) => key.startsWith(exports.CREDENTIAL_PREFIX))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@coordination-network/wallet",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Wallet SDK for the coordination network: device key management, credential storage, consent-gated presentation and revocation sync.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"test:e2e": "tsx --test tests/wallet.e2e.ts"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@coordination-network/zk-artifacts": "^0.
|
|
35
|
+
"@coordination-network/zk-artifacts": "^0.1.1",
|
|
36
36
|
"@noble/curves": "^1.9.7",
|
|
37
37
|
"@types/jest": "^30.0.0",
|
|
38
38
|
"@types/node": "^26.1.0",
|
|
@@ -42,11 +42,11 @@
|
|
|
42
42
|
"typescript": "^6.0.3"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@coordination-network/credential": "^0.
|
|
46
|
-
"@coordination-network/identity": "^0.
|
|
47
|
-
"@coordination-network/issuer-set": "^0.
|
|
48
|
-
"@coordination-network/revocation-registry": "^0.
|
|
45
|
+
"@coordination-network/credential": "^0.1.1",
|
|
46
|
+
"@coordination-network/identity": "^0.1.1",
|
|
47
|
+
"@coordination-network/issuer-set": "^0.1.1",
|
|
48
|
+
"@coordination-network/revocation-registry": "^0.1.1",
|
|
49
49
|
"@noble/hashes": "^1.8.0",
|
|
50
|
-
"@semaphore-protocol/identity": "4.14.
|
|
50
|
+
"@semaphore-protocol/identity": "4.14.5"
|
|
51
51
|
}
|
|
52
52
|
}
|
package/src/errors.ts
CHANGED
|
@@ -13,10 +13,12 @@ export type WalletErrorCode =
|
|
|
13
13
|
|
|
14
14
|
export class WalletError extends Error {
|
|
15
15
|
readonly code: WalletErrorCode
|
|
16
|
+
readonly cause?: unknown
|
|
16
17
|
|
|
17
|
-
constructor(code: WalletErrorCode, message: string) {
|
|
18
|
+
constructor(code: WalletErrorCode, message: string, cause?: unknown) {
|
|
18
19
|
super(message)
|
|
19
20
|
this.name = "WalletError"
|
|
20
21
|
this.code = code
|
|
22
|
+
this.cause = cause
|
|
21
23
|
}
|
|
22
24
|
}
|
package/src/wallet.ts
CHANGED
|
@@ -44,10 +44,10 @@ export type ConsentPrompt = (request: ConsentRequest) => Promise<boolean>
|
|
|
44
44
|
|
|
45
45
|
export interface WalletChainState {
|
|
46
46
|
approvedIssuers: ApprovedIssuerSet
|
|
47
|
-
/** Authoritative root
|
|
47
|
+
/** Authoritative root, read from chain by the caller, never from the server that served the leaves. */
|
|
48
48
|
approvedIssuerRoot: bigint
|
|
49
49
|
revocations: RevocationRegistry
|
|
50
|
-
/** Authoritative root
|
|
50
|
+
/** Authoritative root, read from chain by the caller, never from the server that served the snapshot. */
|
|
51
51
|
revocationRoot: bigint
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -75,8 +75,11 @@ interface CredentialRecord {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export interface WalletOptions {
|
|
78
|
-
/**
|
|
79
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Raw 32-byte Semaphore identity seed derived from the hardware P-256 ECDH output. A thunk is
|
|
80
|
+
* called once per `addCredential` and `present`, so a device key can prompt only when needed.
|
|
81
|
+
*/
|
|
82
|
+
identitySeed: Uint8Array | (() => Promise<Uint8Array>)
|
|
80
83
|
deviceSigner: DeviceSigner
|
|
81
84
|
consent: ConsentPrompt
|
|
82
85
|
loadChainState: ChainStateLoader
|
|
@@ -89,6 +92,9 @@ export interface WalletOptions {
|
|
|
89
92
|
onSyncError?: (error: unknown) => void
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
/** Match the verifier's default freshness window before a caller-chosen time reaches the prover. */
|
|
96
|
+
const MAX_PRESENTATION_TIME_SKEW_SECONDS = 300
|
|
97
|
+
|
|
92
98
|
export interface AddCredentialOptions {
|
|
93
99
|
id: string
|
|
94
100
|
/** Whatever the issuer sent: `addCredential` parses and verifies it, and stores that. */
|
|
@@ -100,7 +106,7 @@ export interface AddCredentialOptions {
|
|
|
100
106
|
/** Device keys and held ZK credentials, with consent and fresh public tree state around presentation. */
|
|
101
107
|
export class Wallet {
|
|
102
108
|
private readonly store: Store
|
|
103
|
-
private readonly identitySeed: Uint8Array
|
|
109
|
+
private readonly identitySeed: Uint8Array | (() => Promise<Uint8Array>)
|
|
104
110
|
private readonly deviceSigner: DeviceSigner
|
|
105
111
|
private readonly consent: ConsentPrompt
|
|
106
112
|
private readonly loadChainState: ChainStateLoader
|
|
@@ -109,9 +115,12 @@ export class Wallet {
|
|
|
109
115
|
private readonly onSyncError: (error: unknown) => void
|
|
110
116
|
|
|
111
117
|
constructor(options: WalletOptions) {
|
|
112
|
-
if (options.identitySeed
|
|
118
|
+
if (typeof options.identitySeed !== "function" && options.identitySeed.length !== 32) {
|
|
119
|
+
throw new Error("identity seed must be exactly 32 bytes")
|
|
120
|
+
}
|
|
113
121
|
this.store = options.store ?? new MemoryStore()
|
|
114
|
-
this.identitySeed =
|
|
122
|
+
this.identitySeed =
|
|
123
|
+
typeof options.identitySeed === "function" ? options.identitySeed : options.identitySeed.slice()
|
|
115
124
|
this.deviceSigner = options.deviceSigner
|
|
116
125
|
this.consent = options.consent
|
|
117
126
|
this.loadChainState = options.loadChainState
|
|
@@ -159,7 +168,7 @@ export class Wallet {
|
|
|
159
168
|
try {
|
|
160
169
|
const credential = parseZkCredential(options.credential)
|
|
161
170
|
if (!verifyZkCredential(credential)) throw new Error("credential issuer signature is invalid")
|
|
162
|
-
if (new Identity(this.
|
|
171
|
+
if (new Identity(await this.resolveIdentitySeed()).commitment !== credential.body.holderKeyCommitment) {
|
|
163
172
|
throw new Error("identity seed does not open the credential holder commitment")
|
|
164
173
|
}
|
|
165
174
|
if (deriveRevocationCommitment(options.revocationSecret) !== credential.body.revocationCommitment) {
|
|
@@ -206,7 +215,8 @@ export class Wallet {
|
|
|
206
215
|
if (error instanceof WalletError) throw error
|
|
207
216
|
throw new WalletError(
|
|
208
217
|
"CREDENTIAL_INVALID",
|
|
209
|
-
error instanceof Error ? error.message : "credential verification failed"
|
|
218
|
+
error instanceof Error ? error.message : "credential verification failed",
|
|
219
|
+
error
|
|
210
220
|
)
|
|
211
221
|
}
|
|
212
222
|
}
|
|
@@ -267,6 +277,14 @@ export class Wallet {
|
|
|
267
277
|
throw new WalletError("ISSUER_NOT_APPROVED", `credential ${id}'s issuer is no longer approved`)
|
|
268
278
|
}
|
|
269
279
|
|
|
280
|
+
const nowSeconds = Math.floor(this.now() / 1000)
|
|
281
|
+
if (
|
|
282
|
+
!Number.isSafeInteger(request.currentTime) ||
|
|
283
|
+
Math.abs(request.currentTime - nowSeconds) > MAX_PRESENTATION_TIME_SKEW_SECONDS
|
|
284
|
+
) {
|
|
285
|
+
throw new WalletError("PRESENTATION_FAILED", "presentation request time is not current")
|
|
286
|
+
}
|
|
287
|
+
|
|
270
288
|
const allowed = await this.consent({
|
|
271
289
|
kind,
|
|
272
290
|
credentialId: id,
|
|
@@ -281,7 +299,7 @@ export class Wallet {
|
|
|
281
299
|
inputs = await buildZkPresentationInputs(
|
|
282
300
|
record.credential,
|
|
283
301
|
{
|
|
284
|
-
identitySeed: this.
|
|
302
|
+
identitySeed: await this.resolveIdentitySeed(),
|
|
285
303
|
revocationSecret: hexToBytes(revocationSecret.slice(2)),
|
|
286
304
|
deviceSigner: this.deviceSigner
|
|
287
305
|
},
|
|
@@ -299,7 +317,8 @@ export class Wallet {
|
|
|
299
317
|
if (error instanceof WalletError) throw error
|
|
300
318
|
throw new WalletError(
|
|
301
319
|
"PRESENTATION_FAILED",
|
|
302
|
-
error instanceof Error ? error.message : "failed to build presentation inputs"
|
|
320
|
+
error instanceof Error ? error.message : "failed to build presentation inputs",
|
|
321
|
+
error
|
|
303
322
|
)
|
|
304
323
|
}
|
|
305
324
|
return this.proofGenerator.generateProof(inputs)
|
|
@@ -376,6 +395,13 @@ export class Wallet {
|
|
|
376
395
|
return chainState
|
|
377
396
|
}
|
|
378
397
|
|
|
398
|
+
private async resolveIdentitySeed(): Promise<Uint8Array> {
|
|
399
|
+
if (typeof this.identitySeed !== "function") return this.identitySeed
|
|
400
|
+
const seed = await this.identitySeed()
|
|
401
|
+
if (seed.length !== 32) throw new Error("identity seed must be exactly 32 bytes")
|
|
402
|
+
return seed
|
|
403
|
+
}
|
|
404
|
+
|
|
379
405
|
private async listCredentialIds(): Promise<string[]> {
|
|
380
406
|
return (await this.store.list())
|
|
381
407
|
.filter((key) => key.startsWith(CREDENTIAL_PREFIX))
|