nihilium-recovery-sdk 0.7.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.
Files changed (122) hide show
  1. package/.gitmodules +3 -0
  2. package/README.md +441 -0
  3. package/examples/quorum-recovery.mjs +187 -0
  4. package/onchain/README.md +18 -0
  5. package/onchain/evm/.env.example +67 -0
  6. package/onchain/evm/README.md +155 -0
  7. package/onchain/evm/bindings/abi.generated.ts +797 -0
  8. package/onchain/evm/bindings/generate-abi.mjs +49 -0
  9. package/onchain/evm/bindings/index.ts +83 -0
  10. package/onchain/evm/deployments/11155111.json +8 -0
  11. package/onchain/evm/deployments/42161.json +8 -0
  12. package/onchain/evm/deployments/README.md +46 -0
  13. package/onchain/evm/foundry.toml +61 -0
  14. package/onchain/evm/package-lock.json +7674 -0
  15. package/onchain/evm/package.json +53 -0
  16. package/onchain/evm/remappings.txt +13 -0
  17. package/onchain/evm/script/DeployRecoveryModule.s.sol +196 -0
  18. package/onchain/evm/src/GradualVeto.sol +232 -0
  19. package/onchain/evm/src/RecoveryModule.sol +381 -0
  20. package/onchain/evm/test/GradualVeto.t.sol +319 -0
  21. package/onchain/evm/test/GradualVetoHarness.sol +60 -0
  22. package/onchain/evm/test/MockERC7579Account.sol +78 -0
  23. package/onchain/evm/test/RecoveryModule.t.sol +457 -0
  24. package/onchain/evm/test/VetoDifferential.t.sol +113 -0
  25. package/onchain/evm/test/fixtures/veto-traces.json +3152 -0
  26. package/onchain/evm/tsconfig.json +28 -0
  27. package/package.json +36 -0
  28. package/packages/adapters/condition-quorum/README.md +43 -0
  29. package/packages/adapters/condition-quorum/package.json +25 -0
  30. package/packages/adapters/condition-quorum/src/adapter.ts +514 -0
  31. package/packages/adapters/condition-quorum/src/errors.ts +46 -0
  32. package/packages/adapters/condition-quorum/src/format.ts +207 -0
  33. package/packages/adapters/condition-quorum/src/index.ts +27 -0
  34. package/packages/adapters/condition-quorum/src/resolver.ts +48 -0
  35. package/packages/adapters/condition-quorum/test/adapter.test.ts +482 -0
  36. package/packages/adapters/condition-quorum/test/barrel.test.ts +33 -0
  37. package/packages/adapters/condition-quorum/test/fake-condition.ts +71 -0
  38. package/packages/adapters/condition-quorum/test/integration.test.ts +206 -0
  39. package/packages/adapters/condition-quorum/tsconfig.json +9 -0
  40. package/packages/adapters/condition-zkemail/.env.example +20 -0
  41. package/packages/adapters/condition-zkemail/package.json +17 -0
  42. package/packages/adapters/condition-zkemail/src/adapter.ts +275 -0
  43. package/packages/adapters/condition-zkemail/src/email-hash.ts +57 -0
  44. package/packages/adapters/condition-zkemail/src/index.ts +25 -0
  45. package/packages/adapters/condition-zkemail/src/single_email_collection.json +72 -0
  46. package/packages/adapters/condition-zkemail/src/unsealing.ts +307 -0
  47. package/packages/adapters/condition-zkemail/test/adapter.test.ts +159 -0
  48. package/packages/adapters/condition-zkemail/test/barrel.test.ts +36 -0
  49. package/packages/adapters/condition-zkemail/test/email-hash.test.ts +60 -0
  50. package/packages/adapters/condition-zkemail/test/live-config.ts +61 -0
  51. package/packages/adapters/condition-zkemail/test/live.test.ts +225 -0
  52. package/packages/adapters/condition-zkemail/tsconfig.json +22 -0
  53. package/packages/adapters/key-evm/package.json +25 -0
  54. package/packages/adapters/key-evm/src/index.ts +96 -0
  55. package/packages/adapters/key-evm/tsconfig.json +9 -0
  56. package/packages/adapters/key-solana/package.json +24 -0
  57. package/packages/adapters/key-solana/src/index.ts +74 -0
  58. package/packages/adapters/key-solana/tsconfig.json +9 -0
  59. package/packages/adapters/resolver-dkim/package.json +13 -0
  60. package/packages/adapters/resolver-dkim/src/index.ts +193 -0
  61. package/packages/adapters/resolver-dkim/test/resolver.test.ts +148 -0
  62. package/packages/adapters/resolver-dkim/tsconfig.json +9 -0
  63. package/packages/adapters/storage-local/package.json +23 -0
  64. package/packages/adapters/storage-local/src/index.ts +177 -0
  65. package/packages/adapters/storage-local/test/storage-local.test.ts +127 -0
  66. package/packages/adapters/storage-local/tsconfig.json +9 -0
  67. package/packages/core/package.json +28 -0
  68. package/packages/core/src/adapters/condition.ts +85 -0
  69. package/packages/core/src/adapters/index.ts +10 -0
  70. package/packages/core/src/adapters/key.ts +43 -0
  71. package/packages/core/src/adapters/settlement.ts +43 -0
  72. package/packages/core/src/adapters/storage.ts +85 -0
  73. package/packages/core/src/authority.ts +61 -0
  74. package/packages/core/src/envelope.ts +264 -0
  75. package/packages/core/src/errors.ts +50 -0
  76. package/packages/core/src/index.ts +46 -0
  77. package/packages/core/src/kdf.ts +176 -0
  78. package/packages/core/src/sdk.ts +230 -0
  79. package/packages/core/src/testing/index.ts +255 -0
  80. package/packages/core/src/types.ts +196 -0
  81. package/packages/core/src/veto-types.ts +105 -0
  82. package/packages/core/test/authority.test.ts +141 -0
  83. package/packages/core/test/boundaries.test.ts +177 -0
  84. package/packages/core/test/envelope-v2.test.ts +214 -0
  85. package/packages/core/test/envelope.test.ts +93 -0
  86. package/packages/core/test/kdf.test.ts +166 -0
  87. package/packages/core/test/sdk.test.ts +318 -0
  88. package/packages/core/tsconfig.json +8 -0
  89. package/packages/nihilium/package.json +27 -0
  90. package/packages/nihilium/src/index.ts +64 -0
  91. package/packages/nihilium/src/protocol.ts +69 -0
  92. package/packages/nihilium/src/scenario/sealing.ts +316 -0
  93. package/packages/nihilium/src/scenario/unsealing.ts +104 -0
  94. package/packages/nihilium/tsconfig.json +9 -0
  95. package/packages/shamir/README.md +30 -0
  96. package/packages/shamir/package.json +23 -0
  97. package/packages/shamir/src/derive.ts +78 -0
  98. package/packages/shamir/src/errors.ts +11 -0
  99. package/packages/shamir/src/gf256.ts +77 -0
  100. package/packages/shamir/src/index.ts +15 -0
  101. package/packages/shamir/src/shamir.ts +211 -0
  102. package/packages/shamir/test/gf256.test.ts +104 -0
  103. package/packages/shamir/test/shamir.test.ts +238 -0
  104. package/packages/shamir/test/vectors.test.ts +96 -0
  105. package/packages/shamir/tsconfig.json +8 -0
  106. package/packages/veto/package.json +23 -0
  107. package/packages/veto/scripts/generate-veto-traces.mjs +134 -0
  108. package/packages/veto/src/index.ts +18 -0
  109. package/packages/veto/src/machine.ts +174 -0
  110. package/packages/veto/src/validate.ts +190 -0
  111. package/packages/veto/test/machine.test.ts +257 -0
  112. package/packages/veto/test/traces.test.ts +96 -0
  113. package/packages/veto/test/validate.test.ts +183 -0
  114. package/packages/veto/tsconfig.json +9 -0
  115. package/spec/nihilium-recovery-sdk.md +438 -0
  116. package/spec/vectors/generate-kdf-vectors.mjs +72 -0
  117. package/spec/vectors/generate-shamir-vectors.mjs +110 -0
  118. package/spec/vectors/kdf.json +160 -0
  119. package/spec/vectors/shamir.json +150 -0
  120. package/tsconfig.base.json +25 -0
  121. package/tsconfig.json +35 -0
  122. package/vitest.config.ts +44 -0
package/.gitmodules ADDED
@@ -0,0 +1,3 @@
1
+ [submodule "onchain"]
2
+ path = onchain
3
+ url = git@github.com:nihilium/recovery-guardian-contracts.git
package/README.md ADDED
@@ -0,0 +1,441 @@
1
+ # Nihilium Recovery SDK
2
+
3
+ Identity-gated, condition-based key recovery for self-custodial wallets, built on
4
+ [Nihilium](https://github.com/nihilium). A wallet integrates two calls: `seal()` places a recovery
5
+ key under a private identity condition; `recover()` returns signing authority once that condition is
6
+ satisfied.
7
+
8
+ The security comes from the settlement construction the recovered authority operates within, not from
9
+ the SDK hiding a secret. On smart-account chains that construction is a **graduated veto** — three
10
+ separated capabilities (pause / resume / abort), each individually harmless, held by up to three
11
+ different parties. No veto key can move funds; completing a recovery needs the identity gate *plus* a
12
+ matured timelock.
13
+
14
+ The full specification is [`spec/nihilium-recovery-sdk.md`](spec/nihilium-recovery-sdk.md); section
15
+ references throughout the code point at it.
16
+
17
+ > **Scope: recovery covers loss, not theft.** It restores access to an owner who lost it. It does not
18
+ > defend a wallet whose seed an attacker already holds.
19
+
20
+ ## Layout
21
+
22
+ ```
23
+ packages/
24
+ core/ chain- and protocol-agnostic: types, adapter contracts, RRS + key derivation
25
+ veto/ graduated-veto state machine and config validation (the on-chain oracle)
26
+ shamir/ Shamir secret sharing over GF(256) (no internal dependencies)
27
+ nihilium/ protocol port — the ONLY package that imports @nihilium/client-sdk
28
+ adapters/
29
+ key-evm/ secp256k1
30
+ key-solana/ ed25519
31
+ condition-zkemail/ zkEmail identity gate (forked scenario, see below)
32
+ condition-quorum/ composite gate: any k of n member conditions
33
+ resolver-dkim/ TrustAnchorResolver over the DKIM registry
34
+ storage-local/ filesystem SealStore + add-only VaultStore
35
+ sdk/ default wiring; the package wallets install
36
+ onchain/
37
+ evm/ foundry + ModuleKit; ERC-7579 executor + graduated veto
38
+ solana/ anchor program
39
+ spec/
40
+ vectors/ frozen conformance vectors
41
+ ```
42
+
43
+ `core` has no internal dependencies, and `@nihilium/client-sdk` is reachable from exactly one
44
+ package. Both rules are enforced by `packages/core/test/boundaries.test.ts`, not just documented.
45
+
46
+ `onchain/*` is deliberately **not** an npm workspace member: it installs its own dependency tree, so
47
+ the contract toolchain stays independent of the SDK's and `forge test` never needs the TypeScript
48
+ build.
49
+
50
+ ## Getting started
51
+
52
+ ```bash
53
+ npm install # TypeScript workspaces
54
+ npm run build
55
+ npm test # 220 tests
56
+
57
+ npm --prefix onchain/evm install # ModuleKit's Solidity deps (separate tree)
58
+ npm run test:evm # 58 tests
59
+ ```
60
+
61
+ ## Using it
62
+
63
+ > `packages/sdk` — the preconfigured wiring a wallet would install — is **not built yet**. Today you
64
+ > assemble the adapters yourself, which is what the examples below do. The shapes will not change
65
+ > when that package lands; it will just pick the defaults for you.
66
+
67
+ A recovery has four moving parts, and they happen at two very different times:
68
+
69
+ | | At setup | At recovery |
70
+ |---|---|---|
71
+ | **Off-chain** (this SDK) | `seal()` puts a root secret behind the identity gate | `recover()` returns signing authority |
72
+ | **On-chain** (the module) | install `RecoveryModule` with the veto config | initiate → timelock → execute, under veto |
73
+
74
+ The off-chain half proves *who you are*. The on-chain half decides *what that lets you do, and how
75
+ slowly*. Neither is sufficient alone, and that separation is the design.
76
+
77
+ ### 1 · Seal (at wallet setup)
78
+
79
+ ```ts
80
+ import { RecoverySDK } from "@nihilium-recovery/core";
81
+ import { EvmKeyAdapter, toEvmAddress } from "@nihilium-recovery/key-evm";
82
+ import { ZKEmailConditionAdapter } from "@nihilium-recovery/condition-zkemail";
83
+ import { LocalSealStore } from "@nihilium-recovery/storage-local";
84
+ import {
85
+ NihiliumPaymentProviderClientAPIKEY_DO_NOT_USE,
86
+ setApiEndpoint,
87
+ } from "@nihilium-recovery/nihilium";
88
+
89
+ setApiEndpoint("https://api.nihilium.io");
90
+
91
+ // Keep a reference to the adapter: `buildCondition` / `buildProof` are the adapter's, and the SDK
92
+ // deliberately does not re-export them — the ceremony's parameters are adapter-private.
93
+ const condition = new ZKEmailConditionAdapter({
94
+ emailServiceUrl: "https://zkemail.nihilium.io",
95
+ network: 11155111,
96
+ threshold: 2, // k — processors that must cooperate to recover
97
+ processorCount: 3, // n — processors sealed with
98
+ payment: new NihiliumPaymentProviderClientAPIKEY_DO_NOT_USE(
99
+ "https://api.nihilium.io",
100
+ process.env.NIHILIUM_API_KEY!,
101
+ ),
102
+ });
103
+
104
+ const sdk = new RecoverySDK({
105
+ key: new EvmKeyAdapter(),
106
+ condition,
107
+ sealStore: new LocalSealStore({ directory: "./seals" }),
108
+ });
109
+
110
+ const chain = {
111
+ namespace: "eip155:11155111", // CAIP-2
112
+ tier: "smart-account" as const,
113
+ accountId: "0xYourSmartAccount",
114
+ vaultId: "wallet-1", // one seal + one processor cohort per vault
115
+ epoch: 0, // bumped by each completed recovery
116
+ };
117
+
118
+ const { recoveryPubKey, sealBlob } = await sdk.seal({
119
+ condition: await condition.buildCondition({ email: "you@example.com" }),
120
+ chain,
121
+ });
122
+
123
+ const recoveryOwner = toEvmAddress(recoveryPubKey); // → the module's `recoveryOwner`
124
+ ```
125
+
126
+ **Sealing is a paid operation** and runs a Groth16 proof per share, so it takes tens of seconds.
127
+ `sealBlob` is a **bearer artifact**: whoever holds it can attempt a recovery, subject to the gate and
128
+ the veto. Back it up; don't publish it.
129
+
130
+ Two deliberate constraints worth knowing before you design around them:
131
+
132
+ - **You cannot seal an existing private key.** `recoveryKey` accepts `{kind:"generate"}` or
133
+ `{kind:"rrs", rrs}` — a root secret, never a chain key. Every key is *derived* from that root plus
134
+ the epoch, which is what lets a completed recovery mint a fresh key without re-running (and
135
+ re-paying for) the ceremony. Accepting a chain key would silently turn epoch rotation back into a
136
+ full re-seal.
137
+ - **`epoch` is not stored in the seal.** It is a KDF input, so rotating it costs nothing.
138
+
139
+ ### 2 · Install the module (once per account)
140
+
141
+ `recoveryOwner` above is the address the module checks intent signatures against. It goes in
142
+ alongside the veto config, as `onInstall` data:
143
+
144
+ ```solidity
145
+ GradualVeto.Config memory veto = GradualVeto.Config({
146
+ pauseAuthority: 0x…, // may pause an in-flight recovery. That is all it can do.
147
+ abortAuthority: 0x…, // may kill one, irreversibly. Keep this key offline.
148
+ resumeMembers: [g1, g2, g3],
149
+ resumeThreshold: 2, // of resumeMembers, to lift a pause early
150
+ timelockSeconds: 7 days, // must elapse before the recovery is executable
151
+ pauseCeilingSeconds: 3 days // a pause auto-lifts after this, so nobody can stall forever
152
+ });
153
+
154
+ account.installModule(2 /* EXECUTOR */, RECOVERY_MODULE, abi.encode(recoveryOwner, veto));
155
+ ```
156
+
157
+ Hold the three veto roles separately — ideally by three different parties. Each is individually
158
+ harmless: **no veto key can move funds**, and none of them can complete a recovery either. The
159
+ protection comes from them being separate, so putting all three on one key throws it away while
160
+ leaving everything looking correctly configured.
161
+
162
+ `pauseCeilingSeconds` is the counterweight: a pause that never expires would be a denial-of-service on
163
+ the legitimate owner, so a pause buys investigation time rather than a veto.
164
+
165
+ ### 3 · Recover (when the key is lost)
166
+
167
+ ```ts
168
+ const authority = await sdk.recover({
169
+ proof: await condition.buildProof({ email: "you@example.com" }),
170
+ chain,
171
+ onProgress: (m) => console.log(m),
172
+ });
173
+ ```
174
+
175
+ This is **long-running and human-in-the-loop**: the service sends an email, a human must reply, and
176
+ the proof is built from that reply. Expect minutes, not seconds.
177
+
178
+ By default you get a scoped capability rather than raw bytes:
179
+
180
+ ```ts
181
+ import { withCapability } from "@nihilium-recovery/core";
182
+
183
+ if (authority.kind === "capability") {
184
+ await withCapability(authority.capability, async (cap) => {
185
+ const sig = await cap.sign(intentHash); // EVM: a 32-byte digest, already hashed
186
+ }); // key wiped in `finally`, even on throw
187
+ }
188
+ ```
189
+
190
+ `{ options: { mode: "rawKey" } }` returns the bytes instead. That is a supported, first-class opt-out
191
+ (§13), not a back door — the confinement that matters is on-chain, and holding the raw key still only
192
+ buys you the committed rotation described below.
193
+
194
+ ### 4 · Drive the on-chain recovery
195
+
196
+ The recovered key signs an EIP-712 `Intent` naming the account, epoch, nonce, replacement validator
197
+ and an expiry. Every field is bound, so an intent cannot be replayed onto another account, another
198
+ epoch, or after it goes stale.
199
+
200
+ ```
201
+ initiateRecovery(intent, signature) anyone may submit — authority is the signature, not the sender
202
+ │ (so a relayer can pay gas for a user with no funded account)
203
+ ├─ pause(account) pause authority only; INITIATED → PAUSED
204
+ │ └─ resume(account, …) a threshold of distinct quorum members, or the ceiling lapses
205
+ ├─ abort(account) abort authority only; irreversible, needs no cooperation
206
+
207
+ timelockSeconds elapse → EXECUTABLE
208
+ executeRecovery(intent) installs the validator, bumps the epoch
209
+ ```
210
+
211
+ Read the live state any time with `stateOf(account)` — it projects the clock forward, so a pause past
212
+ its ceiling reads as `INITIATED` whether or not anyone has poked the contract.
213
+
214
+ The only call this module can ever make is `installModule(TYPE_VALIDATOR, …)` on the recovering
215
+ account. The intent chooses *which* validator; it never chooses the call shape or the target. There
216
+ is no path through the module that moves a token. That is what makes "extracting the raw recovery key
217
+ still only yields the committed, vetoable rotation" a property rather than an aspiration.
218
+
219
+ It deliberately does **not** uninstall the superseded validator: removing a module from an ERC-7579
220
+ sentinel list needs a correct predecessor pointer and gets the list wrong if you miss. Recovery's
221
+ scope is loss, not theft — the old key is gone, not hostile — so that is a deliberate follow-up by
222
+ the recovered owner.
223
+
224
+ ## Recovering behind k of n identities
225
+
226
+ `@nihilium-recovery/condition-quorum` is a `ConditionAdapter` that wraps *other* condition adapters.
227
+ It Shamir-splits the secret over GF(256) and places one share behind each member, so any k of n
228
+ recover it and k-1 reveal nothing — information-theoretically, not merely computationally.
229
+
230
+ Because it satisfies the ordinary `ConditionAdapter` contract, nothing else changes: `RecoverySDK`,
231
+ the envelope, the KDF, zeroization and `SealStore` all work exactly as they do for a single member,
232
+ and the whole quorum is **one file, stored per `vaultId`**.
233
+
234
+ ```ts
235
+ import { QuorumConditionAdapter } from "@nihilium-recovery/condition-quorum";
236
+
237
+ const email = new ZKEmailConditionAdapter({ /* … as in step 1 … */ });
238
+
239
+ // One stateless zkEmail adapter can back every member.
240
+ const quorum = new QuorumConditionAdapter({ members: [email, email, email] });
241
+
242
+ const condition = await quorum.buildCondition({
243
+ threshold: 2, // any 2 of the 3
244
+ members: [ // positional: entry i goes to member i
245
+ { email: "alice@example.com" },
246
+ { email: "bob@example.net" },
247
+ { email: "carol@example.org" },
248
+ ],
249
+ });
250
+
251
+ const sdk = new RecoverySDK({ key: new EvmKeyAdapter(), condition: quorum, sealStore });
252
+ const { recoveryPubKey } = await sdk.seal({ condition, chain }); // 3 ceremonies, 1 file
253
+ ```
254
+
255
+ Recovery names **exactly** the members to use, so nobody is contacted who is not needed. The named
256
+ members run concurrently, since all of them are required:
257
+
258
+ ```ts
259
+ const proof = await quorum.buildProof({
260
+ members: [{ index: 1 }, { index: 3 }], // Shamir indices, 1..n
261
+ onProgress: (m) => console.log(m), // prefixed "[member #1] …"
262
+ });
263
+ const authority = await sdk.recover({ proof, chain });
264
+ ```
265
+
266
+ If a member fails, `QuorumIncompleteError` carries every member's outcome so you know who to retry
267
+ without. Shamir is unauthenticated, so the seal also records a commitment per share and one over the
268
+ secret: a corrupted share names its own member *before* anything is combined, rather than surfacing
269
+ much later as an unparseable envelope after everyone has already answered their email.
270
+
271
+ ### Sealing costs money, so it resumes
272
+
273
+ Each member is a separate paid ceremony. `onMemberSealed` checkpoints as it goes, and `resumeFrom`
274
+ skips members already bought:
275
+
276
+ ```ts
277
+ const checkpoint: QuorumMember[] = [];
278
+ const quorum = new QuorumConditionAdapter({ members, onMemberSealed: (m) => checkpoint.push(m) });
279
+ // … member 4 of 5 dies …
280
+
281
+ const resumed = new QuorumConditionAdapter({
282
+ members,
283
+ resumeFrom: { setId, members: checkpoint }, // setId from the interrupted run
284
+ });
285
+ await sdk.seal({ recoveryKey: { kind: "rrs", rrs }, condition, chain }); // buys only 4 and 5
286
+ ```
287
+
288
+ The split is *derived* from `(secret, setId, k, n)` rather than sampled, which is what lets a resume
289
+ reproduce byte-identical shares without a plaintext share ever being written to disk. Supply the same
290
+ root secret and set id; each reused member's commitment is re-checked, so resuming with the wrong
291
+ secret fails immediately instead of writing a seal that can never reconstruct.
292
+
293
+ ### Two thresholds, and they are not the same
294
+
295
+ `ZKEmailConditionOptions.threshold` is Nihilium's **processor** cohort — who runs one member's
296
+ ceremony. The quorum threshold is across **members** — which identities may recover. Keep them
297
+ distinct in anything user-facing.
298
+
299
+ ## One seal, several chains
300
+
301
+ The KDF already separates keys by `(namespace, accountId, vaultId, epoch)`, so one root secret can
302
+ serve many chains; a v1 envelope simply had nowhere to record more than one context. `sealMultiChain`
303
+ records a closed set:
304
+
305
+ ```ts
306
+ const { recoveryKeys } = await sdk.sealMultiChain({
307
+ condition,
308
+ contexts: [
309
+ { chain: evmChain, key: new EvmKeyAdapter() },
310
+ { chain: solanaChain, key: new SolanaKeyAdapter() },
311
+ ],
312
+ });
313
+ ```
314
+
315
+ This matters most with a quorum, because members are expensive and contexts are free: two chains
316
+ behind a 3-of-5 quorum costs **5** ceremonies, not 10. Adding a tenth chain still costs 5 — so seal a
317
+ generous context set up front, since the set is fixed at seal time and adding one later means
318
+ re-sealing.
319
+
320
+ `recover()` is unchanged and still returns authority for exactly one chain: `parseEnvelope` selects
321
+ the matching context and reports its algorithm, so recovering through the wrong `KeyAdapter` fails on
322
+ the curve check rather than deriving an unrelated key. Sealing every chain at once is a convenience;
323
+ assembling every chain's authority at once is not.
324
+
325
+ ## Deployments
326
+
327
+ The contracts (`RecoveryModule`, `GradualVeto`), their Foundry tests, deployment script, and
328
+ deployment records now live in
329
+ [nihilium/recovery-guardian-contracts](https://github.com/nihilium/recovery-guardian-contracts) —
330
+ pulled in here as a git submodule at `onchain/`, pinned to a specific reviewed commit — so they
331
+ can be publicly audited independently of the rest of the SDK. That repo is organised per chain
332
+ (`evm/`, with room for e.g. `solana/` later), which is why the EVM contracts land at `onchain/evm/`
333
+ here. See that repo's README for the current live addresses on Sepolia and Arbitrum One, the
334
+ veto-clock and settlement/proof-network
335
+ notes, and the deployment guide.
336
+
337
+ ## The forked zkEmail scenario
338
+
339
+ `@nihilium/client-sdk`'s `ZKEmailSealingClient` hardcodes its collection JSON, its datastream binding
340
+ and its email-hash user input inside `create()` and `buildTemplate()`, so the machinery cannot be
341
+ reused for a different condition graph. Rather than change nihilium-core — which backs the public
342
+ recovery demo — the pattern is **forked** into this repo with those three lifted into parameters:
343
+
344
+ | Here | Forked from | Change |
345
+ |---|---|---|
346
+ | `nihilium/ScenarioSealingClient` | `ZKEmailSealingClient` | collection, datastream binding and user inputs are parameters |
347
+ | `nihilium/ScenarioUnsealingClient` | `ZKEmailUnsealingClient` | abstract base; endpoint resolution + vault decryption pulled up |
348
+ | `condition-zkemail/ZKEmailUnsealingClient` | same | resolvers keyed per **node id**, so two-domain email (§12) works |
349
+ | `condition-zkemail/hashEmailAddress` | `hashEmailAddress` | verbatim; pinned against upstream by a test |
350
+
351
+ The upstream scenario is untouched. `condition-zkemail/test/email-hash.test.ts` compares the fork
352
+ against `@nihilium/client-sdk` directly on every call — a drifted hash does not fail at seal time, it
353
+ fails at recovery time as an unverifiable proof, so it is worth pinning hard.
354
+
355
+ ### Node compatibility
356
+
357
+ `@nihilium/client-sdk` used to ship a browser-only bundle that threw on import under Node, and this
358
+ repo carried a shim for it. As of client-sdk **0.7.1** the package ships a `node` export condition,
359
+ the shim is gone, and `packages/nihilium/` imports it like any other dependency.
360
+
361
+ Two things were wrong and both were fixed upstream rather than worked around here: the build
362
+ resolved `snarkjs` to its browser entry (whose prover builds a worker from a `blob:` URL Node cannot
363
+ load), and several dependencies had ESM/CJS interop that breaks under Node's resolver. Proving under
364
+ Node is now also ~3x faster than it was through the shim, because snarkjs runs its own native path.
365
+
366
+ ## Testing against the live deployment
367
+
368
+ Parameters mirror `../forgot-my-password-ui/.env`; see
369
+ [`packages/adapters/condition-zkemail/.env.example`](packages/adapters/condition-zkemail/.env.example).
370
+ Three opt-in tiers, because the costs differ sharply — `npm test` sets none of them, so the default
371
+ suite stays hermetic and offline.
372
+
373
+ | Tier | Gate | Cost |
374
+ |---|---|---|
375
+ | Registry + DKIM reads | `NIHILIUM_LIVE=1` | free |
376
+ | A real k-of-n seal | `+ NIHILIUM_API_KEY` | **paid**, ~10s of proving per share |
377
+ | A real recovery | `+ NIHILIUM_LIVE_EMAIL` | paid, and blocks on a human replying to an email |
378
+
379
+ ```bash
380
+ NIHILIUM_LIVE=1 npx vitest run packages/adapters/condition-zkemail/test/live.test.ts
381
+ ```
382
+
383
+ > **The public registry lists one processor.** A k-of-n seal against it exercises the threshold
384
+ > *mechanics* — each slot still gets its own reveal value and key — but carries none of the
385
+ > independence the threshold exists for, because one operator holds every share. The live registry
386
+ > test warns when this is the case, so the day a second processor appears, it says so.
387
+
388
+ ## Two things worth knowing before you rely on this
389
+
390
+ **`recover()` returns a scoped, zeroizing key — not never-assemble threshold signing.** Spec §3
391
+ describes `SigningCapability` as threshold-signing without ever assembling the key. Nihilium's
392
+ threshold protects a BabyJubJub *vault* scalar which is recovered whole, client-side; there is no
393
+ threshold signer over secp256k1 or ed25519 anywhere in the protocol. What is delivered is a real but
394
+ smaller guarantee: the key is assembled only during a recovery event, held in one closure, and wiped
395
+ by `zeroize()`. `KeyAdapter.thresholdSign()` exists as the reserved seam and throws, naming the
396
+ backend it needs. **User-facing copy must not claim the key is never assembled.**
397
+
398
+ **A quorum seal names every guardian.** It is a bearer artifact, and each member seal carries its
399
+ own address in the clear (the zkEmail adapter records it so a recovery needs nothing but the file).
400
+ One stolen quorum file therefore discloses the whole recovery social graph, not just one address.
401
+ The `Condition` summaries stay domain-only, but that is not the same as the file being private.
402
+
403
+ **A multi-chain seal widens the blast radius on purpose.** Under envelope v2 a completed recovery
404
+ yields the envelope and therefore *every* key in the context set, so §11's compartment moves from
405
+ `(chain, account)` out to `vaultId`. It is opt-in and the context set is closed — there is no
406
+ wildcard form — but reach for separate vaults when the chains should fail independently.
407
+
408
+ **The timelock is on-chain only.** The Nihilium condition is a pure identity gate. This keeps
409
+ pause/resume meaningful — an off-chain delay could be neither paused nor aborted — and keeps the
410
+ state machine single-sourced.
411
+
412
+ ## Conformance
413
+
414
+ Two frozen artefacts pin behaviour that must never drift:
415
+
416
+ - **`spec/vectors/kdf.json`** — byte-exact recovery-key derivation (§11) across both curves,
417
+ including the length-prefix collision case that naive concatenation would get wrong. Do not
418
+ regenerate; a changed derivation silently strands every seal already in the wild.
419
+ - **`spec/vectors/shamir.json`** — the GF(256) split, byte-exact, with fixed coefficients plus the
420
+ derived-coefficient path a resumed seal depends on. Do not regenerate; a changed split leaves the
421
+ shares already sitting behind each member unable to reconstruct, and nothing reveals it until
422
+ someone actually needs their recovery.
423
+ - **`onchain/evm/test/fixtures/veto-traces.json`** — 200 traces generated from the TypeScript veto
424
+ machine and replayed against the Solidity library. The TypeScript machine is the oracle; the
425
+ fixture is what makes that an enforced fact rather than a comment.
426
+
427
+ ## Status
428
+
429
+ | Phase | State |
430
+ |---|---|
431
+ | 0 · Scaffold + boundaries | done |
432
+ | 1 · Core types, KDF, capability | done |
433
+ | 2 · Storage contracts + local impl | done |
434
+ | 3 · Veto machine + validation | done |
435
+ | 4 · EVM contracts (ModuleKit) | done — deployed and verified on Sepolia |
436
+ | 5 · zkEmail condition adapter | done — verified by a real 2-of-2 seal on the live set |
437
+ | 6 · `seal()` / `recover()` over Nihilium | `seal()` verified live; `recover()` needs a human email reply to close out |
438
+ | 7 · EVM hardened mode | partly — intent-binding and epoch invalidation already in |
439
+ | 12 · k-of-n quorum condition | done — hermetic; not yet exercised against live ceremonies |
440
+ | 13 · Multi-chain envelope (v2) | done — `sealMultiChain()`, v1 seals unaffected |
441
+ | 8–11 · Solana, provider storage, demo, script tier | not started |
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Runnable tour of the k-of-n quorum and the multi-chain envelope.
3
+ *
4
+ * npm run build && node examples/quorum-recovery.mjs
5
+ *
6
+ * Everything here is the real SDK — real key adapters, real KDF, real filesystem SealStore. The one
7
+ * stand-in is `DemoCondition`, which replaces a live identity ceremony so this runs offline and for
8
+ * free. Swap it for `ZKEmailConditionAdapter` and the rest is unchanged; see `realWiring()` below.
9
+ */
10
+ import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { RecoverySDK, withCapability } from "@nihilium-recovery/core";
14
+ import { EvmKeyAdapter } from "@nihilium-recovery/key-evm";
15
+ import { SolanaKeyAdapter } from "@nihilium-recovery/key-solana";
16
+ import { LocalSealStore } from "@nihilium-recovery/storage-local";
17
+ import {
18
+ QuorumConditionAdapter,
19
+ QuorumIncompleteError,
20
+ parseQuorumSeal,
21
+ } from "@nihilium-recovery/condition-quorum";
22
+
23
+ const hex = (b) => Buffer.from(b).toString("hex");
24
+ const log = (...a) => console.log(...a);
25
+
26
+ /** Stands in for a real identity ceremony. Holds the share; hands it back on demand. */
27
+ class DemoCondition {
28
+ conditionType = "zkemail";
29
+ resolver = { rejectWeakSelectors: true, revocationFreshnessBlocks: 100,
30
+ async resolve() { return { status: "valid", reason: "demo", revoked: "no", strength: "ok" }; } };
31
+ unreachable = false;
32
+ async buildCondition({ email }) {
33
+ return { conditionType: "zkemail", descriptor: { email },
34
+ summary: `Control of an email address at ${email.split("@").pop()}` };
35
+ }
36
+ async buildProof(params = {}) { return { conditionType: "zkemail", descriptor: params }; }
37
+ async sealSecret({ secret }) { return { format: "demo-seal-v1", payload: hex(secret) }; }
38
+ async openSecret({ seal, onProgress }) {
39
+ if (this.unreachable) throw new Error("no reply from this mailbox");
40
+ onProgress?.("email answered, producing proof");
41
+ return Uint8Array.from(seal.payload.match(/../g).map((b) => parseInt(b, 16)));
42
+ }
43
+ }
44
+
45
+ const dir = mkdtempSync(join(tmpdir(), "quorum-example-"));
46
+ const sealStore = new LocalSealStore({ directory: dir });
47
+
48
+ const evmChain = {
49
+ namespace: "eip155:11155111", tier: "smart-account",
50
+ accountId: "0xAcc0", vaultId: "my-wallet", epoch: 0,
51
+ };
52
+ const solanaChain = {
53
+ namespace: "solana:mainnet", tier: "script",
54
+ accountId: "9xQeWvG…", vaultId: "my-wallet", epoch: 0,
55
+ };
56
+
57
+ const guardians = [
58
+ { email: "alice@example.com" },
59
+ { email: "bob@example.net" },
60
+ { email: "carol@example.org" },
61
+ ];
62
+
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+ // 1 · Seal 2-of-3
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+ const members = guardians.map(() => new DemoCondition());
67
+ const quorum = new QuorumConditionAdapter({ members });
68
+
69
+ const condition = await quorum.buildCondition({ threshold: 2, members: guardians });
70
+ log("condition:", condition.summary, "\n");
71
+
72
+ const key = new EvmKeyAdapter();
73
+ const sdk = new RecoverySDK({ key, condition: quorum, sealStore });
74
+
75
+ const { recoveryPubKey } = await sdk.seal({ condition, chain: evmChain });
76
+ log("1 · sealed. register this on-chain:", hex(recoveryPubKey.bytes).slice(0, 24) + "…");
77
+
78
+ const files = readdirSync(join(dir, "seals"));
79
+ const payload = parseQuorumSeal(JSON.parse(readFileSync(join(dir, "seals", files[0]), "utf8")).blob);
80
+ log(` ${files.length} file on disk, holding ${payload.members.length} member seals:`);
81
+ for (const m of payload.members) log(` #${m.index} ${m.summary}`);
82
+
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+ // 2 · Recover with any 2. Only the members you name are contacted.
85
+ // ─────────────────────────────────────────────────────────────────────────────
86
+ const proof = await quorum.buildProof({
87
+ members: [{ index: 1 }, { index: 3 }],
88
+ onProgress: (m) => log(" " + m),
89
+ });
90
+ log("\n2 · recovering via members #1 and #3:");
91
+
92
+ const authority = await sdk.recover({ proof, chain: evmChain });
93
+ await withCapability(authority.capability, async (cap) => {
94
+ const sig = await cap.sign(new Uint8Array(32).fill(1));
95
+ log(` recovered ${cap.algorithm}, signed ${sig.bytes.length} bytes`);
96
+ log(` matches what seal() registered:`,
97
+ hex(cap.publicKey.bytes) === hex(recoveryPubKey.bytes));
98
+ });
99
+ log(" capability zeroized:", authority.capability.zeroized);
100
+
101
+ // ─────────────────────────────────────────────────────────────────────────────
102
+ // 3 · A member that cannot be reached
103
+ // ─────────────────────────────────────────────────────────────────────────────
104
+ members[1].unreachable = true;
105
+ log("\n3 · trying members #1 and #2, where #2 is unreachable:");
106
+ try {
107
+ await sdk.recover({
108
+ proof: await quorum.buildProof({ members: [{ index: 1 }, { index: 2 }] }),
109
+ chain: evmChain,
110
+ });
111
+ } catch (error) {
112
+ if (!(error instanceof QuorumIncompleteError)) throw error;
113
+ for (const o of error.outcomes) log(` #${o.index} ${o.status}${o.reason ? ` — ${o.reason}` : ""}`);
114
+ log(" → retry with a different pair; #1's work is not wasted, unsealing is free");
115
+ }
116
+ members[1].unreachable = false;
117
+
118
+ // ─────────────────────────────────────────────────────────────────────────────
119
+ // 4 · One seal covering two chains (envelope v2)
120
+ // ─────────────────────────────────────────────────────────────────────────────
121
+ const multiDir = mkdtempSync(join(tmpdir(), "quorum-multi-"));
122
+ const multiStore = new LocalSealStore({ directory: multiDir });
123
+ const multiMembers = guardians.map(() => new DemoCondition());
124
+ const multiQuorum = new QuorumConditionAdapter({ members: multiMembers });
125
+ const multiCondition = await multiQuorum.buildCondition({ threshold: 2, members: guardians });
126
+
127
+ const evmKey = new EvmKeyAdapter();
128
+ const solanaKey = new SolanaKeyAdapter();
129
+ const evmSdk = new RecoverySDK({ key: evmKey, condition: multiQuorum, sealStore: multiStore });
130
+ const solanaSdk = new RecoverySDK({ key: solanaKey, condition: multiQuorum, sealStore: multiStore });
131
+
132
+ const { recoveryKeys } = await evmSdk.sealMultiChain({
133
+ condition: multiCondition,
134
+ contexts: [{ chain: evmChain, key: evmKey }, { chain: solanaChain, key: solanaKey }],
135
+ });
136
+ log("\n4 · one seal, two chains:");
137
+ for (const { chain, publicKey } of recoveryKeys) {
138
+ log(` ${chain.namespace.padEnd(18)} ${publicKey.algorithm.padEnd(10)} ${hex(publicKey.bytes).slice(0, 20)}…`);
139
+ }
140
+ log(` ${readdirSync(join(multiDir, "seals")).length} file, 3 ceremonies — not 6.`);
141
+
142
+ // Each chain recovers on its own curve, through whichever guardians are available.
143
+ const evmAuth = await evmSdk.recover({
144
+ proof: await multiQuorum.buildProof({ members: [{ index: 1 }, { index: 2 }] }),
145
+ chain: evmChain,
146
+ });
147
+ const solAuth = await solanaSdk.recover({
148
+ proof: await multiQuorum.buildProof({ members: [{ index: 2 }, { index: 3 }] }),
149
+ chain: solanaChain,
150
+ });
151
+ log(" EVM via #1,#2 →", evmAuth.capability.algorithm,
152
+ hex(evmAuth.capability.publicKey.bytes) === hex(recoveryKeys[0].publicKey.bytes) ? "✓" : "✗");
153
+ log(" Solana via #2,#3 →", solAuth.capability.algorithm,
154
+ hex(solAuth.capability.publicKey.bytes) === hex(recoveryKeys[1].publicKey.bytes) ? "✓" : "✗");
155
+ evmAuth.capability.zeroize();
156
+ solAuth.capability.zeroize();
157
+
158
+ // Recovering a chain the seal never listed fails loudly rather than deriving a stranger's key.
159
+ try {
160
+ await evmSdk.recover({
161
+ proof: await multiQuorum.buildProof({ members: [{ index: 1 }, { index: 2 }] }),
162
+ chain: { ...evmChain, namespace: "eip155:137" },
163
+ });
164
+ } catch (error) {
165
+ log(" polygon (unlisted) →", error.constructor.name);
166
+ }
167
+
168
+ rmSync(dir, { recursive: true, force: true });
169
+ rmSync(multiDir, { recursive: true, force: true });
170
+
171
+ /** The only difference in production: a real condition adapter, and it costs money. */
172
+ function realWiring() {
173
+ return `
174
+ import { ZKEmailConditionAdapter } from "@nihilium-recovery/condition-zkemail";
175
+
176
+ const email = new ZKEmailConditionAdapter({
177
+ emailServiceUrl: "https://zkemail.nihilium.io",
178
+ network: 11155111,
179
+ threshold: 2, // Nihilium PROCESSOR cohort — not the quorum threshold
180
+ processorCount: 3,
181
+ payment,
182
+ });
183
+
184
+ // One stateless adapter can back every member.
185
+ const quorum = new QuorumConditionAdapter({ members: [email, email, email] });
186
+ `;
187
+ }
@@ -0,0 +1,18 @@
1
+ # recovery-guardian-contracts
2
+
3
+ On-chain contracts for [nihilium-recovery-sdk](https://github.com/nihilium/recovery-sdk)'s
4
+ recovery guardian — identity-gated, condition-based key recovery for self-custodial wallets. Kept
5
+ in its own repo, independent of the SDK's TypeScript codebase, so the on-chain code can be
6
+ publicly audited on its own.
7
+
8
+ Recovery guardian is a per-chain concept: each chain gets its own recovery module and its own
9
+ implementation, one directory per chain.
10
+
11
+ | Directory | Chain | Status |
12
+ |---|---|---|
13
+ | [`evm/`](evm/) | Ethereum and other EVM chains (Foundry) | Live — deployed on Sepolia and Arbitrum One, see [`evm/README.md`](evm/README.md) |
14
+ | `solana/` | Solana (Anchor) | Not started |
15
+
16
+ `nihilium-recovery-sdk` pulls this whole repo in as a single git submodule mounted at `onchain/`,
17
+ pinned to a specific reviewed commit rather than tracking `main`, so `onchain/evm/` here lines up
18
+ with `onchain/evm/` there.