@coordination-network/wallet 0.0.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/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +11 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +20 -0
- package/dist/store.d.ts +16 -0
- package/dist/store.js +22 -0
- package/dist/wallet.d.ts +106 -0
- package/dist/wallet.js +263 -0
- package/package.json +52 -0
- package/src/errors.ts +22 -0
- package/src/index.ts +5 -0
- package/src/store.ts +29 -0
- package/src/wallet.ts +393 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ethereum Foundation
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# `@coordination-network/wallet`
|
|
2
|
+
|
|
3
|
+
The holder-side SDK for device keys, ZK credentials, consent-gated presentations,
|
|
4
|
+
and local issuer/revocation state.
|
|
5
|
+
|
|
6
|
+
The wallet verifies a credential's issuer signature and its holder, device, and
|
|
7
|
+
revocation-secret bindings before storing it. Before presenting, it requires
|
|
8
|
+
fresh approved-issuer and revocation trees, asks for consent, builds the private
|
|
9
|
+
circuit inputs, and delegates proof generation to the configured proof system.
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { ZkCredentialProofSystem } from "@coordination-network/credential"
|
|
15
|
+
import { Wallet } from "@coordination-network/wallet"
|
|
16
|
+
|
|
17
|
+
const proofGenerator = await ZkCredentialProofSystem.create(circuit)
|
|
18
|
+
|
|
19
|
+
const wallet = new Wallet({
|
|
20
|
+
identitySeed, // raw 32-byte Semaphore identity seed
|
|
21
|
+
deviceSigner, // hardware-backed P-256 signer on device
|
|
22
|
+
consent: async (request) => showConsentSheet(request),
|
|
23
|
+
loadChainState: async () => {
|
|
24
|
+
// Rebuild both public trees and pin their roots to authoritative chain state.
|
|
25
|
+
return {
|
|
26
|
+
approvedIssuers,
|
|
27
|
+
approvedIssuerRoot,
|
|
28
|
+
revocations,
|
|
29
|
+
revocationRoot
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
proofGenerator,
|
|
33
|
+
store // use a keystore-backed Store on device; MemoryStore is the default
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
await wallet.addCredential({
|
|
37
|
+
id: credentialId,
|
|
38
|
+
credential, // the issuer's response, unparsed; addCredential parses and verifies it
|
|
39
|
+
revocationSecret
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const stopSync = wallet.startRevocationSync()
|
|
43
|
+
|
|
44
|
+
const proof = await wallet.present(
|
|
45
|
+
credentialId,
|
|
46
|
+
{
|
|
47
|
+
challenge,
|
|
48
|
+
audience: verifierUrn,
|
|
49
|
+
currentTime,
|
|
50
|
+
programme: "demo-programme",
|
|
51
|
+
country: "ZZZ"
|
|
52
|
+
},
|
|
53
|
+
// The state this credential is judged and proved against, read from its own issuer.
|
|
54
|
+
loadIssuerChainState
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
stopSync()
|
|
58
|
+
await proofGenerator.destroy()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`loadChainState` returns each reconstructed tree alongside the authoritative
|
|
62
|
+
root fetched from chain. The wallet rejects a mismatch before storing or
|
|
63
|
+
presenting a credential. It uses the validated local trees to check every held
|
|
64
|
+
credential without revealing to an RPC provider which credential it owns.
|
|
65
|
+
|
|
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
|
+
`syncRevocations`/`startRevocationSync` refresh every held credential against
|
|
73
|
+
the single `loadChainState` configured on the wallet, for background use.
|
|
74
|
+
|
|
75
|
+
## Rules enforced
|
|
76
|
+
|
|
77
|
+
- Invalid, unapproved, revoked, not-yet-valid, or expired credentials are not
|
|
78
|
+
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.
|
|
85
|
+
- A presentation restamps the credential from its own `load` before judging it,
|
|
86
|
+
so it never proves against state an earlier sync left behind. Background sync
|
|
87
|
+
runs every 30 seconds by default.
|
|
88
|
+
- Consent is requested only after all local eligibility checks pass. Holder and
|
|
89
|
+
device signatures, and proof generation, happen only after consent succeeds.
|
|
90
|
+
- A `Store` receives the credential under `vc:<id>`, its revocation secret
|
|
91
|
+
under `revsec:<id>`, and account keys under `key:<networkId>`. Secrets must
|
|
92
|
+
go to secure storage; a caller can route by the exported `KEY_PREFIX`,
|
|
93
|
+
`CREDENTIAL_PREFIX`, and `REVOCATION_SECRET_PREFIX`.
|
|
94
|
+
- `createFromSeed` accepts high-entropy bytes, not a human-memorable password.
|
|
95
|
+
|
|
96
|
+
Expected failures throw `WalletError` with a stable `code`, such as `REVOKED`,
|
|
97
|
+
`EXPIRED`, or `CONSENT_DENIED`. A failure to fetch public state uses
|
|
98
|
+
`CHAIN_STATE_UNAVAILABLE`, allowing callers to retry without treating the
|
|
99
|
+
credential as invalid. Failures while constructing presentation inputs use
|
|
100
|
+
`PRESENTATION_FAILED`.
|
|
101
|
+
|
|
102
|
+
## Tests
|
|
103
|
+
|
|
104
|
+
The suite proves against the circuit published by
|
|
105
|
+
`@coordination-network/zk-artifacts`, so it needs no local Noir toolchain:
|
|
106
|
+
|
|
107
|
+
```sh
|
|
108
|
+
yarn workspace @coordination-network/wallet test --runInBand --watchman=false
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The end-to-end test uses the real Noir circuit and Barretenberg backend to
|
|
112
|
+
issue, store, present, and verify a credential, then refreshes the revocation
|
|
113
|
+
tree and confirms the wallet blocks another presentation.
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
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
|
+
export declare class WalletError extends Error {
|
|
3
|
+
readonly code: WalletErrorCode;
|
|
4
|
+
constructor(code: WalletErrorCode, message: string);
|
|
5
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.WalletError = void 0;
|
|
4
|
+
class WalletError extends Error {
|
|
5
|
+
constructor(code, message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "WalletError";
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
exports.WalletError = WalletError;
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** `@coordination-network/wallet` — device keys, held credentials, consent, revocation. */
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
15
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
16
|
+
};
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
__exportStar(require("./errors"), exports);
|
|
19
|
+
__exportStar(require("./store"), exports);
|
|
20
|
+
__exportStar(require("./wallet"), exports);
|
package/dist/store.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Device secrets and credentials. String values, so SecureStore fits it as-is. */
|
|
2
|
+
export interface Store {
|
|
3
|
+
get(key: string): Promise<string | null>;
|
|
4
|
+
set(key: string, value: string): Promise<void>;
|
|
5
|
+
delete(key: string): Promise<void>;
|
|
6
|
+
/** SecureStore cannot enumerate itself, so that adapter keeps its own index. */
|
|
7
|
+
list(): Promise<string[]>;
|
|
8
|
+
}
|
|
9
|
+
/** Nothing survives a restart, so device builds pass their own. */
|
|
10
|
+
export declare class MemoryStore implements Store {
|
|
11
|
+
private readonly entries;
|
|
12
|
+
get(key: string): Promise<string | null>;
|
|
13
|
+
set(key: string, value: string): Promise<void>;
|
|
14
|
+
delete(key: string): Promise<void>;
|
|
15
|
+
list(): Promise<string[]>;
|
|
16
|
+
}
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MemoryStore = void 0;
|
|
4
|
+
/** Nothing survives a restart, so device builds pass their own. */
|
|
5
|
+
class MemoryStore {
|
|
6
|
+
constructor() {
|
|
7
|
+
this.entries = new Map();
|
|
8
|
+
}
|
|
9
|
+
async get(key) {
|
|
10
|
+
return this.entries.get(key) ?? null;
|
|
11
|
+
}
|
|
12
|
+
async set(key, value) {
|
|
13
|
+
this.entries.set(key, value);
|
|
14
|
+
}
|
|
15
|
+
async delete(key) {
|
|
16
|
+
this.entries.delete(key);
|
|
17
|
+
}
|
|
18
|
+
async list() {
|
|
19
|
+
return [...this.entries.keys()];
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
exports.MemoryStore = MemoryStore;
|
package/dist/wallet.d.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type DerivedKeys, type Hex } from "@coordination-network/identity";
|
|
2
|
+
import { type DeviceSigner, type SerializedZkCredential, type ZkPresentationInputs, type ZkPresentationProof, type ZkPresentationRequest } from "@coordination-network/credential";
|
|
3
|
+
import { ApprovedIssuerSet } from "@coordination-network/issuer-set";
|
|
4
|
+
import { RevocationRegistry } from "@coordination-network/revocation-registry";
|
|
5
|
+
import { type Store } from "./store";
|
|
6
|
+
export declare const KEY_PREFIX = "key:";
|
|
7
|
+
export declare const CREDENTIAL_PREFIX = "vc:";
|
|
8
|
+
/** Stored apart from its credential, under its own prefix, so a `Store` can route it differently. */
|
|
9
|
+
export declare const REVOCATION_SECRET_PREFIX = "revsec:";
|
|
10
|
+
/** How often background sync restamps held credentials (issue #47 asks for 60s or better). */
|
|
11
|
+
export declare const SYNC_INTERVAL_MS = 30000;
|
|
12
|
+
export type CredentialStatus = "valid" | "revoked" | "not-yet-valid" | "expired" | "issuer-not-approved";
|
|
13
|
+
export interface ConsentRequest {
|
|
14
|
+
kind: "present" | "prove-ownership";
|
|
15
|
+
credentialId: string;
|
|
16
|
+
audience: string;
|
|
17
|
+
programme: string;
|
|
18
|
+
country: string;
|
|
19
|
+
}
|
|
20
|
+
/** True allows the proof to be built; anything else stops it before any presentation signature. */
|
|
21
|
+
export type ConsentPrompt = (request: ConsentRequest) => Promise<boolean>;
|
|
22
|
+
export interface WalletChainState {
|
|
23
|
+
approvedIssuers: ApprovedIssuerSet;
|
|
24
|
+
/** Authoritative root fetched independently of the reconstructed issuer tree. */
|
|
25
|
+
approvedIssuerRoot: bigint;
|
|
26
|
+
revocations: RevocationRegistry;
|
|
27
|
+
/** Authoritative root fetched independently of the reconstructed revocation tree. */
|
|
28
|
+
revocationRoot: bigint;
|
|
29
|
+
}
|
|
30
|
+
/** Fetches and validates the latest public issuer and revocation trees. */
|
|
31
|
+
export type ChainStateLoader = () => Promise<WalletChainState>;
|
|
32
|
+
/** ZkCredentialProofSystem satisfies this without making the wallet own its lifecycle. */
|
|
33
|
+
export interface ProofGenerator {
|
|
34
|
+
generateProof(inputs: ZkPresentationInputs): Promise<ZkPresentationProof>;
|
|
35
|
+
}
|
|
36
|
+
export interface CredentialInfo {
|
|
37
|
+
id: string;
|
|
38
|
+
credential: SerializedZkCredential;
|
|
39
|
+
status: CredentialStatus;
|
|
40
|
+
/** When the public issuer and revocation trees were last refreshed, in milliseconds since epoch. */
|
|
41
|
+
checkedAt: number;
|
|
42
|
+
}
|
|
43
|
+
export interface WalletOptions {
|
|
44
|
+
/** Raw 32-byte Semaphore identity seed derived from the hardware P-256 ECDH output. */
|
|
45
|
+
identitySeed: Uint8Array;
|
|
46
|
+
deviceSigner: DeviceSigner;
|
|
47
|
+
consent: ConsentPrompt;
|
|
48
|
+
loadChainState: ChainStateLoader;
|
|
49
|
+
proofGenerator: ProofGenerator;
|
|
50
|
+
/** Defaults to memory; on device, use a keystore-backed implementation. */
|
|
51
|
+
store?: Store;
|
|
52
|
+
/** Injectable clock for deterministic tests. Returns milliseconds since epoch. */
|
|
53
|
+
now?: () => number;
|
|
54
|
+
/** Receives background-sync failures instead of creating unhandled promise rejections. */
|
|
55
|
+
onSyncError?: (error: unknown) => void;
|
|
56
|
+
}
|
|
57
|
+
export interface AddCredentialOptions {
|
|
58
|
+
id: string;
|
|
59
|
+
/** Whatever the issuer sent: `addCredential` parses and verifies it, and stores that. */
|
|
60
|
+
credential: unknown;
|
|
61
|
+
/** Raw 32-byte secret whose Semaphore commitment is signed into this credential. */
|
|
62
|
+
revocationSecret: Uint8Array;
|
|
63
|
+
}
|
|
64
|
+
/** Device keys and held ZK credentials, with consent and fresh public tree state around presentation. */
|
|
65
|
+
export declare class Wallet {
|
|
66
|
+
private readonly store;
|
|
67
|
+
private readonly identitySeed;
|
|
68
|
+
private readonly deviceSigner;
|
|
69
|
+
private readonly consent;
|
|
70
|
+
private readonly loadChainState;
|
|
71
|
+
private readonly proofGenerator;
|
|
72
|
+
private readonly now;
|
|
73
|
+
private readonly onSyncError;
|
|
74
|
+
constructor(options: WalletOptions);
|
|
75
|
+
/** Derives a device secret from high-entropy seed material. Do not pass a human-memorable password. */
|
|
76
|
+
createFromSeed(seed: Uint8Array): Promise<Hex>;
|
|
77
|
+
/** The networkId falls out of the secret, so re-importing overwrites rather than duplicates. */
|
|
78
|
+
importKey(secret: Uint8Array | Hex): Promise<Hex>;
|
|
79
|
+
listKeys(): Promise<Hex[]>;
|
|
80
|
+
getKey(networkId: Hex): Promise<DerivedKeys | null>;
|
|
81
|
+
deleteKey(networkId: Hex): Promise<void>;
|
|
82
|
+
/** Verifies the signed body, all three holder bindings, issuer membership and revocation before storing. */
|
|
83
|
+
addCredential(options: AddCredentialOptions): Promise<string>;
|
|
84
|
+
listCredentials(): Promise<CredentialInfo[]>;
|
|
85
|
+
getCredential(id: string): Promise<CredentialInfo | null>;
|
|
86
|
+
deleteCredential(id: string): Promise<void>;
|
|
87
|
+
present(id: string, request: ZkPresentationRequest, load: ChainStateLoader): Promise<ZkPresentationProof>;
|
|
88
|
+
proveOwnership(id: string, request: ZkPresentationRequest, load: ChainStateLoader): Promise<ZkPresentationProof>;
|
|
89
|
+
private buildPresentation;
|
|
90
|
+
/** Refreshes both public trees once, then updates every held credential without a per-credential RPC lookup. */
|
|
91
|
+
syncRevocations(): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Refreshes one credential against the state `load` reads. The caller supplies that state
|
|
94
|
+
* rather than the wallet fetching it, so a request checking several credentials reads once and
|
|
95
|
+
* every one of them is judged against the same network state.
|
|
96
|
+
*/
|
|
97
|
+
syncCredential(id: string, load: ChainStateLoader): Promise<void>;
|
|
98
|
+
/** Starts background status refresh and returns its stop function. */
|
|
99
|
+
startRevocationSync(intervalMs?: number): () => void;
|
|
100
|
+
private stamp;
|
|
101
|
+
private statusOf;
|
|
102
|
+
private loadAndValidateChainState;
|
|
103
|
+
private listCredentialIds;
|
|
104
|
+
private readRecord;
|
|
105
|
+
private writeRecord;
|
|
106
|
+
}
|
package/dist/wallet.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Wallet = exports.SYNC_INTERVAL_MS = exports.REVOCATION_SECRET_PREFIX = exports.CREDENTIAL_PREFIX = exports.KEY_PREFIX = void 0;
|
|
4
|
+
const identity_1 = require("@semaphore-protocol/identity");
|
|
5
|
+
const sha2_1 = require("@noble/hashes/sha2");
|
|
6
|
+
const utils_1 = require("@noble/hashes/utils");
|
|
7
|
+
const identity_2 = require("@coordination-network/identity");
|
|
8
|
+
const credential_1 = require("@coordination-network/credential");
|
|
9
|
+
const errors_1 = require("./errors");
|
|
10
|
+
const store_1 = require("./store");
|
|
11
|
+
exports.KEY_PREFIX = "key:";
|
|
12
|
+
exports.CREDENTIAL_PREFIX = "vc:";
|
|
13
|
+
/** Stored apart from its credential, under its own prefix, so a `Store` can route it differently. */
|
|
14
|
+
exports.REVOCATION_SECRET_PREFIX = "revsec:";
|
|
15
|
+
/** How often background sync restamps held credentials (issue #47 asks for 60s or better). */
|
|
16
|
+
exports.SYNC_INTERVAL_MS = 30000;
|
|
17
|
+
/** Device keys and held ZK credentials, with consent and fresh public tree state around presentation. */
|
|
18
|
+
class Wallet {
|
|
19
|
+
constructor(options) {
|
|
20
|
+
if (options.identitySeed.length !== 32)
|
|
21
|
+
throw new Error("identity seed must be exactly 32 bytes");
|
|
22
|
+
this.store = options.store ?? new store_1.MemoryStore();
|
|
23
|
+
this.identitySeed = options.identitySeed.slice();
|
|
24
|
+
this.deviceSigner = options.deviceSigner;
|
|
25
|
+
this.consent = options.consent;
|
|
26
|
+
this.loadChainState = options.loadChainState;
|
|
27
|
+
this.proofGenerator = options.proofGenerator;
|
|
28
|
+
this.now = options.now ?? Date.now;
|
|
29
|
+
this.onSyncError = options.onSyncError ?? (() => { });
|
|
30
|
+
}
|
|
31
|
+
/** Derives a device secret from high-entropy seed material. Do not pass a human-memorable password. */
|
|
32
|
+
async createFromSeed(seed) {
|
|
33
|
+
if (seed.length === 0)
|
|
34
|
+
throw new Error("seed must not be empty");
|
|
35
|
+
return this.importKey((0, sha2_1.sha256)(seed));
|
|
36
|
+
}
|
|
37
|
+
/** The networkId falls out of the secret, so re-importing overwrites rather than duplicates. */
|
|
38
|
+
async importKey(secret) {
|
|
39
|
+
const bytes = (0, identity_2.normalizeDeviceSecret)(secret);
|
|
40
|
+
const { networkId } = (0, identity_2.deriveKeys)(bytes);
|
|
41
|
+
await this.store.set(`${exports.KEY_PREFIX}${networkId}`, `0x${(0, utils_1.bytesToHex)(bytes)}`);
|
|
42
|
+
return networkId;
|
|
43
|
+
}
|
|
44
|
+
async listKeys() {
|
|
45
|
+
return (await this.store.list())
|
|
46
|
+
.filter((key) => key.startsWith(exports.KEY_PREFIX))
|
|
47
|
+
.map((key) => key.slice(exports.KEY_PREFIX.length));
|
|
48
|
+
}
|
|
49
|
+
async getKey(networkId) {
|
|
50
|
+
const secret = await this.store.get(`${exports.KEY_PREFIX}${networkId}`);
|
|
51
|
+
return secret === null ? null : (0, identity_2.deriveKeys)(secret);
|
|
52
|
+
}
|
|
53
|
+
async deleteKey(networkId) {
|
|
54
|
+
await this.store.delete(`${exports.KEY_PREFIX}${networkId}`);
|
|
55
|
+
}
|
|
56
|
+
/** Verifies the signed body, all three holder bindings, issuer membership and revocation before storing. */
|
|
57
|
+
async addCredential(options) {
|
|
58
|
+
if (options.id.trim().length === 0)
|
|
59
|
+
throw new errors_1.WalletError("CREDENTIAL_INVALID", "credential id is empty");
|
|
60
|
+
if (options.revocationSecret.length !== 32) {
|
|
61
|
+
throw new errors_1.WalletError("CREDENTIAL_INVALID", "revocation secret must be exactly 32 bytes");
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const credential = (0, credential_1.parseZkCredential)(options.credential);
|
|
65
|
+
if (!(0, credential_1.verifyZkCredential)(credential))
|
|
66
|
+
throw new Error("credential issuer signature is invalid");
|
|
67
|
+
if (new identity_1.Identity(this.identitySeed).commitment !== credential.body.holderKeyCommitment) {
|
|
68
|
+
throw new Error("identity seed does not open the credential holder commitment");
|
|
69
|
+
}
|
|
70
|
+
if ((0, credential_1.deriveRevocationCommitment)(options.revocationSecret) !== credential.body.revocationCommitment) {
|
|
71
|
+
throw new Error("revocation secret does not open the credential revocation commitment");
|
|
72
|
+
}
|
|
73
|
+
if ((0, credential_1.deriveDeviceKeyCommitment)(await this.deviceSigner.getPublicKeyJwk()) !==
|
|
74
|
+
credential.body.deviceKeyCommitment) {
|
|
75
|
+
throw new Error("device signer does not open the credential device commitment");
|
|
76
|
+
}
|
|
77
|
+
const chainState = await this.loadAndValidateChainState();
|
|
78
|
+
if (!chainState.approvedIssuers.isApproved(credential.issuerPublicKey)) {
|
|
79
|
+
throw new Error("credential issuer is not in the approved set");
|
|
80
|
+
}
|
|
81
|
+
if (chainState.revocations.isRevoked(credential.body.revocationCommitment)) {
|
|
82
|
+
throw new errors_1.WalletError("REVOKED", `credential ${options.id} has been revoked`);
|
|
83
|
+
}
|
|
84
|
+
const nowSeconds = Math.floor(this.now() / 1000);
|
|
85
|
+
if (nowSeconds < credential.body.notBefore) {
|
|
86
|
+
throw new errors_1.WalletError("NOT_YET_VALID", `credential ${options.id} is not yet valid`);
|
|
87
|
+
}
|
|
88
|
+
if (nowSeconds > credential.body.expiresAt) {
|
|
89
|
+
throw new errors_1.WalletError("EXPIRED", `credential ${options.id} has expired`);
|
|
90
|
+
}
|
|
91
|
+
if ((await this.readRecord(options.id)) !== null) {
|
|
92
|
+
throw new errors_1.WalletError("ALREADY_STORED", `credential ${options.id} is already stored`);
|
|
93
|
+
}
|
|
94
|
+
await this.store.set(`${exports.REVOCATION_SECRET_PREFIX}${options.id}`, `0x${(0, utils_1.bytesToHex)(options.revocationSecret)}`);
|
|
95
|
+
await this.writeRecord(options.id, {
|
|
96
|
+
credential: (0, credential_1.serializeZkCredential)(credential),
|
|
97
|
+
revoked: false,
|
|
98
|
+
issuerApproved: true,
|
|
99
|
+
checkedAt: this.now()
|
|
100
|
+
});
|
|
101
|
+
return options.id;
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof errors_1.WalletError)
|
|
105
|
+
throw error;
|
|
106
|
+
throw new errors_1.WalletError("CREDENTIAL_INVALID", error instanceof Error ? error.message : "credential verification failed");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async listCredentials() {
|
|
110
|
+
const infos = await Promise.all((await this.listCredentialIds()).map((id) => this.getCredential(id)));
|
|
111
|
+
return infos.filter((info) => info !== null);
|
|
112
|
+
}
|
|
113
|
+
async getCredential(id) {
|
|
114
|
+
const record = await this.readRecord(id);
|
|
115
|
+
if (record === null)
|
|
116
|
+
return null;
|
|
117
|
+
return { id, credential: record.credential, status: this.statusOf(record), checkedAt: record.checkedAt };
|
|
118
|
+
}
|
|
119
|
+
async deleteCredential(id) {
|
|
120
|
+
await this.store.delete(`${exports.CREDENTIAL_PREFIX}${id}`);
|
|
121
|
+
await this.store.delete(`${exports.REVOCATION_SECRET_PREFIX}${id}`);
|
|
122
|
+
}
|
|
123
|
+
async present(id, request, load) {
|
|
124
|
+
return this.buildPresentation(id, "present", request, load);
|
|
125
|
+
}
|
|
126
|
+
async proveOwnership(id, request, load) {
|
|
127
|
+
return this.buildPresentation(id, "prove-ownership", request, load);
|
|
128
|
+
}
|
|
129
|
+
async buildPresentation(id, kind, request, load) {
|
|
130
|
+
const record = await this.readRecord(id);
|
|
131
|
+
if (record === null)
|
|
132
|
+
throw new errors_1.WalletError("NOT_FOUND", `no credential stored under ${id}`);
|
|
133
|
+
const revocationSecret = await this.store.get(`${exports.REVOCATION_SECRET_PREFIX}${id}`);
|
|
134
|
+
if (revocationSecret === null) {
|
|
135
|
+
throw new errors_1.WalletError("NOT_FOUND", `no revocation secret stored for credential ${id}`);
|
|
136
|
+
}
|
|
137
|
+
// One read decides both what the proof names as issuer and which tree it is checked
|
|
138
|
+
// against. Reading them apart was only ever correct by call order.
|
|
139
|
+
const chainState = await this.loadAndValidateChainState(load);
|
|
140
|
+
this.stamp(record, chainState, this.now());
|
|
141
|
+
await this.writeRecord(id, record);
|
|
142
|
+
const status = this.statusOf(record);
|
|
143
|
+
if (status === "revoked")
|
|
144
|
+
throw new errors_1.WalletError("REVOKED", `credential ${id} has been revoked`);
|
|
145
|
+
if (status === "not-yet-valid")
|
|
146
|
+
throw new errors_1.WalletError("NOT_YET_VALID", `credential ${id} is not yet valid`);
|
|
147
|
+
if (status === "expired")
|
|
148
|
+
throw new errors_1.WalletError("EXPIRED", `credential ${id} has expired`);
|
|
149
|
+
if (status === "issuer-not-approved") {
|
|
150
|
+
throw new errors_1.WalletError("ISSUER_NOT_APPROVED", `credential ${id}'s issuer is no longer approved`);
|
|
151
|
+
}
|
|
152
|
+
const allowed = await this.consent({
|
|
153
|
+
kind,
|
|
154
|
+
credentialId: id,
|
|
155
|
+
audience: request.audience,
|
|
156
|
+
programme: request.programme,
|
|
157
|
+
country: request.country
|
|
158
|
+
});
|
|
159
|
+
if (!allowed)
|
|
160
|
+
throw new errors_1.WalletError("CONSENT_DENIED", "the user declined this presentation");
|
|
161
|
+
let inputs;
|
|
162
|
+
try {
|
|
163
|
+
inputs = await (0, credential_1.buildZkPresentationInputs)(record.credential, {
|
|
164
|
+
identitySeed: this.identitySeed,
|
|
165
|
+
revocationSecret: (0, utils_1.hexToBytes)(revocationSecret.slice(2)),
|
|
166
|
+
deviceSigner: this.deviceSigner
|
|
167
|
+
}, {
|
|
168
|
+
// The whole approved set, so the proof's issuer root is the set's root and
|
|
169
|
+
// names no issuer. One network-wide revocation registry is what allows this:
|
|
170
|
+
// the verifier checks the revocation root without having to pick a registry,
|
|
171
|
+
// and picking one was what gave the issuer away.
|
|
172
|
+
approvedIssuers: chainState.approvedIssuers,
|
|
173
|
+
revocations: chainState.revocations
|
|
174
|
+
}, request);
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error instanceof errors_1.WalletError)
|
|
178
|
+
throw error;
|
|
179
|
+
throw new errors_1.WalletError("PRESENTATION_FAILED", error instanceof Error ? error.message : "failed to build presentation inputs");
|
|
180
|
+
}
|
|
181
|
+
return this.proofGenerator.generateProof(inputs);
|
|
182
|
+
}
|
|
183
|
+
/** Refreshes both public trees once, then updates every held credential without a per-credential RPC lookup. */
|
|
184
|
+
async syncRevocations() {
|
|
185
|
+
const chainState = await this.loadAndValidateChainState();
|
|
186
|
+
const checkedAt = this.now();
|
|
187
|
+
for (const id of await this.listCredentialIds()) {
|
|
188
|
+
const record = await this.readRecord(id);
|
|
189
|
+
if (record === null)
|
|
190
|
+
continue;
|
|
191
|
+
this.stamp(record, chainState, checkedAt);
|
|
192
|
+
await this.writeRecord(id, record);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Refreshes one credential against the state `load` reads. The caller supplies that state
|
|
197
|
+
* rather than the wallet fetching it, so a request checking several credentials reads once and
|
|
198
|
+
* every one of them is judged against the same network state.
|
|
199
|
+
*/
|
|
200
|
+
async syncCredential(id, load) {
|
|
201
|
+
const record = await this.readRecord(id);
|
|
202
|
+
if (record === null)
|
|
203
|
+
throw new errors_1.WalletError("NOT_FOUND", `no credential stored under ${id}`);
|
|
204
|
+
const chainState = await this.loadAndValidateChainState(load);
|
|
205
|
+
this.stamp(record, chainState, this.now());
|
|
206
|
+
await this.writeRecord(id, record);
|
|
207
|
+
}
|
|
208
|
+
/** Starts background status refresh and returns its stop function. */
|
|
209
|
+
startRevocationSync(intervalMs = exports.SYNC_INTERVAL_MS) {
|
|
210
|
+
const timer = setInterval(() => {
|
|
211
|
+
void this.syncRevocations().catch(this.onSyncError);
|
|
212
|
+
}, intervalMs);
|
|
213
|
+
return () => clearInterval(timer);
|
|
214
|
+
}
|
|
215
|
+
stamp(record, chainState, checkedAt) {
|
|
216
|
+
const credential = (0, credential_1.parseZkCredential)(record.credential);
|
|
217
|
+
record.revoked = chainState.revocations.isRevoked(credential.body.revocationCommitment);
|
|
218
|
+
record.issuerApproved = chainState.approvedIssuers.isApproved(credential.issuerPublicKey);
|
|
219
|
+
record.checkedAt = checkedAt;
|
|
220
|
+
}
|
|
221
|
+
statusOf(record) {
|
|
222
|
+
if (record.revoked)
|
|
223
|
+
return "revoked";
|
|
224
|
+
if (!record.issuerApproved)
|
|
225
|
+
return "issuer-not-approved";
|
|
226
|
+
const nowSeconds = Math.floor(this.now() / 1000);
|
|
227
|
+
const { body } = (0, credential_1.parseZkCredential)(record.credential);
|
|
228
|
+
if (nowSeconds < body.notBefore)
|
|
229
|
+
return "not-yet-valid";
|
|
230
|
+
if (nowSeconds > body.expiresAt)
|
|
231
|
+
return "expired";
|
|
232
|
+
return "valid";
|
|
233
|
+
}
|
|
234
|
+
async loadAndValidateChainState(load = this.loadChainState) {
|
|
235
|
+
let chainState;
|
|
236
|
+
try {
|
|
237
|
+
chainState = await load();
|
|
238
|
+
}
|
|
239
|
+
catch (error) {
|
|
240
|
+
throw new errors_1.WalletError("CHAIN_STATE_UNAVAILABLE", error instanceof Error ? error.message : "failed to load chain state");
|
|
241
|
+
}
|
|
242
|
+
if (chainState.approvedIssuers.root !== chainState.approvedIssuerRoot) {
|
|
243
|
+
throw new errors_1.WalletError("CHAIN_STATE_INVALID", "approved issuer tree does not match its authoritative root");
|
|
244
|
+
}
|
|
245
|
+
if (chainState.revocations.root !== chainState.revocationRoot) {
|
|
246
|
+
throw new errors_1.WalletError("CHAIN_STATE_INVALID", "revocation tree does not match its authoritative root");
|
|
247
|
+
}
|
|
248
|
+
return chainState;
|
|
249
|
+
}
|
|
250
|
+
async listCredentialIds() {
|
|
251
|
+
return (await this.store.list())
|
|
252
|
+
.filter((key) => key.startsWith(exports.CREDENTIAL_PREFIX))
|
|
253
|
+
.map((key) => key.slice(exports.CREDENTIAL_PREFIX.length));
|
|
254
|
+
}
|
|
255
|
+
async readRecord(id) {
|
|
256
|
+
const raw = await this.store.get(`${exports.CREDENTIAL_PREFIX}${id}`);
|
|
257
|
+
return raw === null ? null : JSON.parse(raw);
|
|
258
|
+
}
|
|
259
|
+
async writeRecord(id, record) {
|
|
260
|
+
await this.store.set(`${exports.CREDENTIAL_PREFIX}${id}`, JSON.stringify(record));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
exports.Wallet = Wallet;
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@coordination-network/wallet",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Wallet SDK for the coordination network: device key management, credential storage, consent-gated presentation and revocation sync.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/zk-kit/coordination-network.git",
|
|
9
|
+
"directory": "packages/wallet"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/zk-kit/coordination-network/tree/main/packages/wallet",
|
|
12
|
+
"bugs": "https://github.com/zk-kit/coordination-network/issues",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"main": "dist/index.js",
|
|
15
|
+
"types": "dist/index.d.ts",
|
|
16
|
+
"files": [
|
|
17
|
+
"dist/",
|
|
18
|
+
"src/",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"installConfig": {
|
|
26
|
+
"hoistingLimits": "workspaces"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc -p tsconfig.build.json",
|
|
30
|
+
"test": "yarn test:unit && yarn test:e2e",
|
|
31
|
+
"test:unit": "cd ../.. && jest --selectProjects wallet",
|
|
32
|
+
"test:e2e": "tsx --test tests/wallet.e2e.ts"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@coordination-network/zk-artifacts": "^0.0.1",
|
|
36
|
+
"@noble/curves": "^1.9.7",
|
|
37
|
+
"@types/jest": "^30.0.0",
|
|
38
|
+
"@types/node": "^26.1.0",
|
|
39
|
+
"jest": "^30.4.2",
|
|
40
|
+
"ts-jest": "^29.4.11",
|
|
41
|
+
"tsx": "^4.23.0",
|
|
42
|
+
"typescript": "^6.0.3"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@coordination-network/credential": "^0.0.1",
|
|
46
|
+
"@coordination-network/identity": "^0.0.1",
|
|
47
|
+
"@coordination-network/issuer-set": "^0.0.1",
|
|
48
|
+
"@coordination-network/revocation-registry": "^0.0.1",
|
|
49
|
+
"@noble/hashes": "^1.8.0",
|
|
50
|
+
"@semaphore-protocol/identity": "4.14.3"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type WalletErrorCode =
|
|
2
|
+
| "NOT_FOUND"
|
|
3
|
+
| "ALREADY_STORED"
|
|
4
|
+
| "CREDENTIAL_INVALID"
|
|
5
|
+
| "CONSENT_DENIED"
|
|
6
|
+
| "REVOKED"
|
|
7
|
+
| "CHAIN_STATE_UNAVAILABLE"
|
|
8
|
+
| "CHAIN_STATE_INVALID"
|
|
9
|
+
| "NOT_YET_VALID"
|
|
10
|
+
| "EXPIRED"
|
|
11
|
+
| "ISSUER_NOT_APPROVED"
|
|
12
|
+
| "PRESENTATION_FAILED"
|
|
13
|
+
|
|
14
|
+
export class WalletError extends Error {
|
|
15
|
+
readonly code: WalletErrorCode
|
|
16
|
+
|
|
17
|
+
constructor(code: WalletErrorCode, message: string) {
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = "WalletError"
|
|
20
|
+
this.code = code
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/index.ts
ADDED
package/src/store.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Device secrets and credentials. String values, so SecureStore fits it as-is. */
|
|
2
|
+
export interface Store {
|
|
3
|
+
get(key: string): Promise<string | null>
|
|
4
|
+
set(key: string, value: string): Promise<void>
|
|
5
|
+
delete(key: string): Promise<void>
|
|
6
|
+
/** SecureStore cannot enumerate itself, so that adapter keeps its own index. */
|
|
7
|
+
list(): Promise<string[]>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Nothing survives a restart, so device builds pass their own. */
|
|
11
|
+
export class MemoryStore implements Store {
|
|
12
|
+
private readonly entries = new Map<string, string>()
|
|
13
|
+
|
|
14
|
+
async get(key: string): Promise<string | null> {
|
|
15
|
+
return this.entries.get(key) ?? null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async set(key: string, value: string): Promise<void> {
|
|
19
|
+
this.entries.set(key, value)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async delete(key: string): Promise<void> {
|
|
23
|
+
this.entries.delete(key)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async list(): Promise<string[]> {
|
|
27
|
+
return [...this.entries.keys()]
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/wallet.ts
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import { Identity } from "@semaphore-protocol/identity"
|
|
2
|
+
import { sha256 } from "@noble/hashes/sha2"
|
|
3
|
+
import { bytesToHex, hexToBytes } from "@noble/hashes/utils"
|
|
4
|
+
import { deriveKeys, normalizeDeviceSecret, type DerivedKeys, type Hex } from "@coordination-network/identity"
|
|
5
|
+
import {
|
|
6
|
+
buildZkPresentationInputs,
|
|
7
|
+
deriveDeviceKeyCommitment,
|
|
8
|
+
deriveRevocationCommitment,
|
|
9
|
+
parseZkCredential,
|
|
10
|
+
serializeZkCredential,
|
|
11
|
+
verifyZkCredential,
|
|
12
|
+
type DeviceSigner,
|
|
13
|
+
type SerializedZkCredential,
|
|
14
|
+
type ZkPresentationInputs,
|
|
15
|
+
type ZkPresentationProof,
|
|
16
|
+
type ZkPresentationRequest
|
|
17
|
+
} from "@coordination-network/credential"
|
|
18
|
+
import { ApprovedIssuerSet } from "@coordination-network/issuer-set"
|
|
19
|
+
import { RevocationRegistry } from "@coordination-network/revocation-registry"
|
|
20
|
+
import { WalletError } from "./errors"
|
|
21
|
+
import { MemoryStore, type Store } from "./store"
|
|
22
|
+
|
|
23
|
+
export const KEY_PREFIX = "key:"
|
|
24
|
+
export const CREDENTIAL_PREFIX = "vc:"
|
|
25
|
+
|
|
26
|
+
/** Stored apart from its credential, under its own prefix, so a `Store` can route it differently. */
|
|
27
|
+
export const REVOCATION_SECRET_PREFIX = "revsec:"
|
|
28
|
+
|
|
29
|
+
/** How often background sync restamps held credentials (issue #47 asks for 60s or better). */
|
|
30
|
+
export const SYNC_INTERVAL_MS = 30_000
|
|
31
|
+
|
|
32
|
+
export type CredentialStatus = "valid" | "revoked" | "not-yet-valid" | "expired" | "issuer-not-approved"
|
|
33
|
+
|
|
34
|
+
export interface ConsentRequest {
|
|
35
|
+
kind: "present" | "prove-ownership"
|
|
36
|
+
credentialId: string
|
|
37
|
+
audience: string
|
|
38
|
+
programme: string
|
|
39
|
+
country: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True allows the proof to be built; anything else stops it before any presentation signature. */
|
|
43
|
+
export type ConsentPrompt = (request: ConsentRequest) => Promise<boolean>
|
|
44
|
+
|
|
45
|
+
export interface WalletChainState {
|
|
46
|
+
approvedIssuers: ApprovedIssuerSet
|
|
47
|
+
/** Authoritative root fetched independently of the reconstructed issuer tree. */
|
|
48
|
+
approvedIssuerRoot: bigint
|
|
49
|
+
revocations: RevocationRegistry
|
|
50
|
+
/** Authoritative root fetched independently of the reconstructed revocation tree. */
|
|
51
|
+
revocationRoot: bigint
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Fetches and validates the latest public issuer and revocation trees. */
|
|
55
|
+
export type ChainStateLoader = () => Promise<WalletChainState>
|
|
56
|
+
|
|
57
|
+
/** ZkCredentialProofSystem satisfies this without making the wallet own its lifecycle. */
|
|
58
|
+
export interface ProofGenerator {
|
|
59
|
+
generateProof(inputs: ZkPresentationInputs): Promise<ZkPresentationProof>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface CredentialInfo {
|
|
63
|
+
id: string
|
|
64
|
+
credential: SerializedZkCredential
|
|
65
|
+
status: CredentialStatus
|
|
66
|
+
/** When the public issuer and revocation trees were last refreshed, in milliseconds since epoch. */
|
|
67
|
+
checkedAt: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface CredentialRecord {
|
|
71
|
+
credential: SerializedZkCredential
|
|
72
|
+
revoked: boolean
|
|
73
|
+
issuerApproved: boolean
|
|
74
|
+
checkedAt: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface WalletOptions {
|
|
78
|
+
/** Raw 32-byte Semaphore identity seed derived from the hardware P-256 ECDH output. */
|
|
79
|
+
identitySeed: Uint8Array
|
|
80
|
+
deviceSigner: DeviceSigner
|
|
81
|
+
consent: ConsentPrompt
|
|
82
|
+
loadChainState: ChainStateLoader
|
|
83
|
+
proofGenerator: ProofGenerator
|
|
84
|
+
/** Defaults to memory; on device, use a keystore-backed implementation. */
|
|
85
|
+
store?: Store
|
|
86
|
+
/** Injectable clock for deterministic tests. Returns milliseconds since epoch. */
|
|
87
|
+
now?: () => number
|
|
88
|
+
/** Receives background-sync failures instead of creating unhandled promise rejections. */
|
|
89
|
+
onSyncError?: (error: unknown) => void
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface AddCredentialOptions {
|
|
93
|
+
id: string
|
|
94
|
+
/** Whatever the issuer sent: `addCredential` parses and verifies it, and stores that. */
|
|
95
|
+
credential: unknown
|
|
96
|
+
/** Raw 32-byte secret whose Semaphore commitment is signed into this credential. */
|
|
97
|
+
revocationSecret: Uint8Array
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Device keys and held ZK credentials, with consent and fresh public tree state around presentation. */
|
|
101
|
+
export class Wallet {
|
|
102
|
+
private readonly store: Store
|
|
103
|
+
private readonly identitySeed: Uint8Array
|
|
104
|
+
private readonly deviceSigner: DeviceSigner
|
|
105
|
+
private readonly consent: ConsentPrompt
|
|
106
|
+
private readonly loadChainState: ChainStateLoader
|
|
107
|
+
private readonly proofGenerator: ProofGenerator
|
|
108
|
+
private readonly now: () => number
|
|
109
|
+
private readonly onSyncError: (error: unknown) => void
|
|
110
|
+
|
|
111
|
+
constructor(options: WalletOptions) {
|
|
112
|
+
if (options.identitySeed.length !== 32) throw new Error("identity seed must be exactly 32 bytes")
|
|
113
|
+
this.store = options.store ?? new MemoryStore()
|
|
114
|
+
this.identitySeed = options.identitySeed.slice()
|
|
115
|
+
this.deviceSigner = options.deviceSigner
|
|
116
|
+
this.consent = options.consent
|
|
117
|
+
this.loadChainState = options.loadChainState
|
|
118
|
+
this.proofGenerator = options.proofGenerator
|
|
119
|
+
this.now = options.now ?? Date.now
|
|
120
|
+
this.onSyncError = options.onSyncError ?? (() => {})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Derives a device secret from high-entropy seed material. Do not pass a human-memorable password. */
|
|
124
|
+
async createFromSeed(seed: Uint8Array): Promise<Hex> {
|
|
125
|
+
if (seed.length === 0) throw new Error("seed must not be empty")
|
|
126
|
+
return this.importKey(sha256(seed))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The networkId falls out of the secret, so re-importing overwrites rather than duplicates. */
|
|
130
|
+
async importKey(secret: Uint8Array | Hex): Promise<Hex> {
|
|
131
|
+
const bytes = normalizeDeviceSecret(secret)
|
|
132
|
+
const { networkId } = deriveKeys(bytes)
|
|
133
|
+
await this.store.set(`${KEY_PREFIX}${networkId}`, `0x${bytesToHex(bytes)}`)
|
|
134
|
+
return networkId
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async listKeys(): Promise<Hex[]> {
|
|
138
|
+
return (await this.store.list())
|
|
139
|
+
.filter((key) => key.startsWith(KEY_PREFIX))
|
|
140
|
+
.map((key) => key.slice(KEY_PREFIX.length) as Hex)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async getKey(networkId: Hex): Promise<DerivedKeys | null> {
|
|
144
|
+
const secret = await this.store.get(`${KEY_PREFIX}${networkId}`)
|
|
145
|
+
return secret === null ? null : deriveKeys(secret as Hex)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async deleteKey(networkId: Hex): Promise<void> {
|
|
149
|
+
await this.store.delete(`${KEY_PREFIX}${networkId}`)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Verifies the signed body, all three holder bindings, issuer membership and revocation before storing. */
|
|
153
|
+
async addCredential(options: AddCredentialOptions): Promise<string> {
|
|
154
|
+
if (options.id.trim().length === 0) throw new WalletError("CREDENTIAL_INVALID", "credential id is empty")
|
|
155
|
+
if (options.revocationSecret.length !== 32) {
|
|
156
|
+
throw new WalletError("CREDENTIAL_INVALID", "revocation secret must be exactly 32 bytes")
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const credential = parseZkCredential(options.credential)
|
|
161
|
+
if (!verifyZkCredential(credential)) throw new Error("credential issuer signature is invalid")
|
|
162
|
+
if (new Identity(this.identitySeed).commitment !== credential.body.holderKeyCommitment) {
|
|
163
|
+
throw new Error("identity seed does not open the credential holder commitment")
|
|
164
|
+
}
|
|
165
|
+
if (deriveRevocationCommitment(options.revocationSecret) !== credential.body.revocationCommitment) {
|
|
166
|
+
throw new Error("revocation secret does not open the credential revocation commitment")
|
|
167
|
+
}
|
|
168
|
+
if (
|
|
169
|
+
deriveDeviceKeyCommitment(await this.deviceSigner.getPublicKeyJwk()) !==
|
|
170
|
+
credential.body.deviceKeyCommitment
|
|
171
|
+
) {
|
|
172
|
+
throw new Error("device signer does not open the credential device commitment")
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const chainState = await this.loadAndValidateChainState()
|
|
176
|
+
if (!chainState.approvedIssuers.isApproved(credential.issuerPublicKey)) {
|
|
177
|
+
throw new Error("credential issuer is not in the approved set")
|
|
178
|
+
}
|
|
179
|
+
if (chainState.revocations.isRevoked(credential.body.revocationCommitment)) {
|
|
180
|
+
throw new WalletError("REVOKED", `credential ${options.id} has been revoked`)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const nowSeconds = Math.floor(this.now() / 1000)
|
|
184
|
+
if (nowSeconds < credential.body.notBefore) {
|
|
185
|
+
throw new WalletError("NOT_YET_VALID", `credential ${options.id} is not yet valid`)
|
|
186
|
+
}
|
|
187
|
+
if (nowSeconds > credential.body.expiresAt) {
|
|
188
|
+
throw new WalletError("EXPIRED", `credential ${options.id} has expired`)
|
|
189
|
+
}
|
|
190
|
+
if ((await this.readRecord(options.id)) !== null) {
|
|
191
|
+
throw new WalletError("ALREADY_STORED", `credential ${options.id} is already stored`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await this.store.set(
|
|
195
|
+
`${REVOCATION_SECRET_PREFIX}${options.id}`,
|
|
196
|
+
`0x${bytesToHex(options.revocationSecret)}`
|
|
197
|
+
)
|
|
198
|
+
await this.writeRecord(options.id, {
|
|
199
|
+
credential: serializeZkCredential(credential),
|
|
200
|
+
revoked: false,
|
|
201
|
+
issuerApproved: true,
|
|
202
|
+
checkedAt: this.now()
|
|
203
|
+
})
|
|
204
|
+
return options.id
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (error instanceof WalletError) throw error
|
|
207
|
+
throw new WalletError(
|
|
208
|
+
"CREDENTIAL_INVALID",
|
|
209
|
+
error instanceof Error ? error.message : "credential verification failed"
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async listCredentials(): Promise<CredentialInfo[]> {
|
|
215
|
+
const infos = await Promise.all((await this.listCredentialIds()).map((id) => this.getCredential(id)))
|
|
216
|
+
return infos.filter((info): info is CredentialInfo => info !== null)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async getCredential(id: string): Promise<CredentialInfo | null> {
|
|
220
|
+
const record = await this.readRecord(id)
|
|
221
|
+
if (record === null) return null
|
|
222
|
+
return { id, credential: record.credential, status: this.statusOf(record), checkedAt: record.checkedAt }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async deleteCredential(id: string): Promise<void> {
|
|
226
|
+
await this.store.delete(`${CREDENTIAL_PREFIX}${id}`)
|
|
227
|
+
await this.store.delete(`${REVOCATION_SECRET_PREFIX}${id}`)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async present(id: string, request: ZkPresentationRequest, load: ChainStateLoader): Promise<ZkPresentationProof> {
|
|
231
|
+
return this.buildPresentation(id, "present", request, load)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async proveOwnership(
|
|
235
|
+
id: string,
|
|
236
|
+
request: ZkPresentationRequest,
|
|
237
|
+
load: ChainStateLoader
|
|
238
|
+
): Promise<ZkPresentationProof> {
|
|
239
|
+
return this.buildPresentation(id, "prove-ownership", request, load)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async buildPresentation(
|
|
243
|
+
id: string,
|
|
244
|
+
kind: ConsentRequest["kind"],
|
|
245
|
+
request: ZkPresentationRequest,
|
|
246
|
+
load: ChainStateLoader
|
|
247
|
+
): Promise<ZkPresentationProof> {
|
|
248
|
+
const record = await this.readRecord(id)
|
|
249
|
+
if (record === null) throw new WalletError("NOT_FOUND", `no credential stored under ${id}`)
|
|
250
|
+
|
|
251
|
+
const revocationSecret = await this.store.get(`${REVOCATION_SECRET_PREFIX}${id}`)
|
|
252
|
+
if (revocationSecret === null) {
|
|
253
|
+
throw new WalletError("NOT_FOUND", `no revocation secret stored for credential ${id}`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// One read decides both what the proof names as issuer and which tree it is checked
|
|
257
|
+
// against. Reading them apart was only ever correct by call order.
|
|
258
|
+
const chainState = await this.loadAndValidateChainState(load)
|
|
259
|
+
this.stamp(record, chainState, this.now())
|
|
260
|
+
await this.writeRecord(id, record)
|
|
261
|
+
|
|
262
|
+
const status = this.statusOf(record)
|
|
263
|
+
if (status === "revoked") throw new WalletError("REVOKED", `credential ${id} has been revoked`)
|
|
264
|
+
if (status === "not-yet-valid") throw new WalletError("NOT_YET_VALID", `credential ${id} is not yet valid`)
|
|
265
|
+
if (status === "expired") throw new WalletError("EXPIRED", `credential ${id} has expired`)
|
|
266
|
+
if (status === "issuer-not-approved") {
|
|
267
|
+
throw new WalletError("ISSUER_NOT_APPROVED", `credential ${id}'s issuer is no longer approved`)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const allowed = await this.consent({
|
|
271
|
+
kind,
|
|
272
|
+
credentialId: id,
|
|
273
|
+
audience: request.audience,
|
|
274
|
+
programme: request.programme,
|
|
275
|
+
country: request.country
|
|
276
|
+
})
|
|
277
|
+
if (!allowed) throw new WalletError("CONSENT_DENIED", "the user declined this presentation")
|
|
278
|
+
|
|
279
|
+
let inputs: ZkPresentationInputs
|
|
280
|
+
try {
|
|
281
|
+
inputs = await buildZkPresentationInputs(
|
|
282
|
+
record.credential,
|
|
283
|
+
{
|
|
284
|
+
identitySeed: this.identitySeed,
|
|
285
|
+
revocationSecret: hexToBytes(revocationSecret.slice(2)),
|
|
286
|
+
deviceSigner: this.deviceSigner
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
// The whole approved set, so the proof's issuer root is the set's root and
|
|
290
|
+
// names no issuer. One network-wide revocation registry is what allows this:
|
|
291
|
+
// the verifier checks the revocation root without having to pick a registry,
|
|
292
|
+
// and picking one was what gave the issuer away.
|
|
293
|
+
approvedIssuers: chainState.approvedIssuers,
|
|
294
|
+
revocations: chainState.revocations
|
|
295
|
+
},
|
|
296
|
+
request
|
|
297
|
+
)
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (error instanceof WalletError) throw error
|
|
300
|
+
throw new WalletError(
|
|
301
|
+
"PRESENTATION_FAILED",
|
|
302
|
+
error instanceof Error ? error.message : "failed to build presentation inputs"
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
return this.proofGenerator.generateProof(inputs)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Refreshes both public trees once, then updates every held credential without a per-credential RPC lookup. */
|
|
309
|
+
async syncRevocations(): Promise<void> {
|
|
310
|
+
const chainState = await this.loadAndValidateChainState()
|
|
311
|
+
const checkedAt = this.now()
|
|
312
|
+
|
|
313
|
+
for (const id of await this.listCredentialIds()) {
|
|
314
|
+
const record = await this.readRecord(id)
|
|
315
|
+
if (record === null) continue
|
|
316
|
+
this.stamp(record, chainState, checkedAt)
|
|
317
|
+
await this.writeRecord(id, record)
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Refreshes one credential against the state `load` reads. The caller supplies that state
|
|
323
|
+
* rather than the wallet fetching it, so a request checking several credentials reads once and
|
|
324
|
+
* every one of them is judged against the same network state.
|
|
325
|
+
*/
|
|
326
|
+
async syncCredential(id: string, load: ChainStateLoader): Promise<void> {
|
|
327
|
+
const record = await this.readRecord(id)
|
|
328
|
+
if (record === null) throw new WalletError("NOT_FOUND", `no credential stored under ${id}`)
|
|
329
|
+
|
|
330
|
+
const chainState = await this.loadAndValidateChainState(load)
|
|
331
|
+
this.stamp(record, chainState, this.now())
|
|
332
|
+
await this.writeRecord(id, record)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Starts background status refresh and returns its stop function. */
|
|
336
|
+
startRevocationSync(intervalMs: number = SYNC_INTERVAL_MS): () => void {
|
|
337
|
+
const timer = setInterval(() => {
|
|
338
|
+
void this.syncRevocations().catch(this.onSyncError)
|
|
339
|
+
}, intervalMs)
|
|
340
|
+
return () => clearInterval(timer)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
private stamp(record: CredentialRecord, chainState: WalletChainState, checkedAt: number): void {
|
|
344
|
+
const credential = parseZkCredential(record.credential)
|
|
345
|
+
record.revoked = chainState.revocations.isRevoked(credential.body.revocationCommitment)
|
|
346
|
+
record.issuerApproved = chainState.approvedIssuers.isApproved(credential.issuerPublicKey)
|
|
347
|
+
record.checkedAt = checkedAt
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private statusOf(record: CredentialRecord): CredentialStatus {
|
|
351
|
+
if (record.revoked) return "revoked"
|
|
352
|
+
if (!record.issuerApproved) return "issuer-not-approved"
|
|
353
|
+
const nowSeconds = Math.floor(this.now() / 1000)
|
|
354
|
+
const { body } = parseZkCredential(record.credential)
|
|
355
|
+
if (nowSeconds < body.notBefore) return "not-yet-valid"
|
|
356
|
+
if (nowSeconds > body.expiresAt) return "expired"
|
|
357
|
+
return "valid"
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async loadAndValidateChainState(load: ChainStateLoader = this.loadChainState): Promise<WalletChainState> {
|
|
361
|
+
let chainState: WalletChainState
|
|
362
|
+
try {
|
|
363
|
+
chainState = await load()
|
|
364
|
+
} catch (error) {
|
|
365
|
+
throw new WalletError(
|
|
366
|
+
"CHAIN_STATE_UNAVAILABLE",
|
|
367
|
+
error instanceof Error ? error.message : "failed to load chain state"
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
if (chainState.approvedIssuers.root !== chainState.approvedIssuerRoot) {
|
|
371
|
+
throw new WalletError("CHAIN_STATE_INVALID", "approved issuer tree does not match its authoritative root")
|
|
372
|
+
}
|
|
373
|
+
if (chainState.revocations.root !== chainState.revocationRoot) {
|
|
374
|
+
throw new WalletError("CHAIN_STATE_INVALID", "revocation tree does not match its authoritative root")
|
|
375
|
+
}
|
|
376
|
+
return chainState
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private async listCredentialIds(): Promise<string[]> {
|
|
380
|
+
return (await this.store.list())
|
|
381
|
+
.filter((key) => key.startsWith(CREDENTIAL_PREFIX))
|
|
382
|
+
.map((key) => key.slice(CREDENTIAL_PREFIX.length))
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private async readRecord(id: string): Promise<CredentialRecord | null> {
|
|
386
|
+
const raw = await this.store.get(`${CREDENTIAL_PREFIX}${id}`)
|
|
387
|
+
return raw === null ? null : (JSON.parse(raw) as CredentialRecord)
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
private async writeRecord(id: string, record: CredentialRecord): Promise<void> {
|
|
391
|
+
await this.store.set(`${CREDENTIAL_PREFIX}${id}`, JSON.stringify(record))
|
|
392
|
+
}
|
|
393
|
+
}
|