@coordination-network/wallet 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 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, read from its own issuer.
54
- loadIssuerChainState
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 the authoritative
62
- root fetched from chain. The wallet rejects a mismatch before storing or
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
- Each issuer publishes its own approved-issuer-set and revocation endpoints, so
67
- a credential can only be judged against its own issuer's state.
68
- `syncCredential(id, load)` refreshes one credential against a `load` the
69
- caller points at that credential's own issuer, and `present(id, request, load)`
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 a set holding only the
80
- credential's own issuer, not the wallet's full approved set, so the proof's
81
- issuer root is that issuer's own leaf. This tells the verifier which issuer
82
- signed and, so, which registry the revocation root must be valid in.
83
- Membership of the full approved set is still checked beforehand, against the
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
- constructor(code: WalletErrorCode, message: string);
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 fetched independently of the reconstructed issuer tree. */
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 fetched independently of the reconstructed revocation tree. */
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
- /** Raw 32-byte Semaphore identity seed derived from the hardware P-256 ECDH output. */
45
- identitySeed: Uint8Array;
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 = options.identitySeed.slice();
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.identitySeed).commitment !== credential.body.holderKeyCommitment) {
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.identitySeed,
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.0.2",
3
+ "version": "0.1.0",
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.0.2",
35
+ "@coordination-network/zk-artifacts": "^0.1.0",
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.0.2",
46
- "@coordination-network/identity": "^0.0.2",
47
- "@coordination-network/issuer-set": "^0.0.2",
48
- "@coordination-network/revocation-registry": "^0.0.2",
45
+ "@coordination-network/credential": "^0.1.0",
46
+ "@coordination-network/identity": "^0.1.0",
47
+ "@coordination-network/issuer-set": "^0.1.0",
48
+ "@coordination-network/revocation-registry": "^0.1.0",
49
49
  "@noble/hashes": "^1.8.0",
50
- "@semaphore-protocol/identity": "4.14.3"
50
+ "@semaphore-protocol/identity": "4.14.4"
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 fetched independently of the reconstructed issuer tree. */
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 fetched independently of the reconstructed revocation tree. */
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
- /** Raw 32-byte Semaphore identity seed derived from the hardware P-256 ECDH output. */
79
- identitySeed: Uint8Array
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.length !== 32) throw new Error("identity seed must be exactly 32 bytes")
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 = options.identitySeed.slice()
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.identitySeed).commitment !== credential.body.holderKeyCommitment) {
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.identitySeed,
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))