@forgezero/runtime 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -0
  3. package/contracts/foundry.toml +9 -0
  4. package/contracts/src/ColdVault.sol +206 -0
  5. package/contracts/src/DepositFactory.sol +202 -0
  6. package/contracts/src/DepositProxy.sol +72 -0
  7. package/contracts/src/IERC20.sol +7 -0
  8. package/contracts/src/MockTokens.sol +32 -0
  9. package/contracts/src/SafeTransferLib.sol +31 -0
  10. package/contracts/test/Custody.t.sol +361 -0
  11. package/contracts/test/Vectors.t.sol +45 -0
  12. package/dist/audit.d.ts +265 -0
  13. package/dist/audit.js +291 -0
  14. package/dist/backup.d.ts +243 -0
  15. package/dist/backup.js +302 -0
  16. package/dist/calendar.d.ts +136 -0
  17. package/dist/calendar.js +129 -0
  18. package/dist/compliance.d.ts +172 -0
  19. package/dist/compliance.js +168 -0
  20. package/dist/finance/binance.d.ts +27 -0
  21. package/dist/finance/binance.js +452 -0
  22. package/dist/finance/chain-addresses.d.ts +130 -0
  23. package/dist/finance/chain-addresses.js +462 -0
  24. package/dist/finance/chain-deposits.d.ts +193 -0
  25. package/dist/finance/chain-deposits.js +596 -0
  26. package/dist/finance/chain-reconcile.d.ts +112 -0
  27. package/dist/finance/chain-reconcile.js +76 -0
  28. package/dist/finance/chain-withdrawals.d.ts +223 -0
  29. package/dist/finance/chain-withdrawals.js +631 -0
  30. package/dist/finance/chain.d.ts +116 -0
  31. package/dist/finance/chain.js +316 -0
  32. package/dist/finance/commission.d.ts +155 -0
  33. package/dist/finance/commission.js +419 -0
  34. package/dist/finance/custody.d.ts +68 -0
  35. package/dist/finance/custody.js +107 -0
  36. package/dist/finance/derive.d.ts +115 -0
  37. package/dist/finance/derive.js +116 -0
  38. package/dist/finance/discounts.d.ts +98 -0
  39. package/dist/finance/discounts.js +90 -0
  40. package/dist/finance/ledger.d.ts +221 -0
  41. package/dist/finance/ledger.js +308 -0
  42. package/dist/finance/market.d.ts +209 -0
  43. package/dist/finance/market.js +112 -0
  44. package/dist/finance/money.d.ts +118 -0
  45. package/dist/finance/money.js +176 -0
  46. package/dist/finance/rates.d.ts +178 -0
  47. package/dist/finance/rates.js +292 -0
  48. package/dist/finance/storage.d.ts +113 -0
  49. package/dist/finance/storage.js +226 -0
  50. package/dist/finance/tax.d.ts +132 -0
  51. package/dist/finance/tax.js +291 -0
  52. package/dist/finance/transfers.d.ts +153 -0
  53. package/dist/finance/transfers.js +292 -0
  54. package/dist/finance/venues.d.ts +190 -0
  55. package/dist/finance/venues.js +251 -0
  56. package/dist/identity.d.ts +115 -0
  57. package/dist/identity.js +111 -0
  58. package/dist/importers.d.ts +87 -0
  59. package/dist/importers.js +250 -0
  60. package/dist/jobs.d.ts +171 -0
  61. package/dist/jobs.js +250 -0
  62. package/dist/notify-templates.d.ts +11 -0
  63. package/dist/notify-templates.js +254 -0
  64. package/dist/notify.d.ts +172 -0
  65. package/dist/notify.js +122 -0
  66. package/dist/openssh.d.ts +36 -0
  67. package/dist/openssh.js +106 -0
  68. package/dist/otpauth.d.ts +57 -0
  69. package/dist/otpauth.js +223 -0
  70. package/dist/outbox.d.ts +234 -0
  71. package/dist/outbox.js +236 -0
  72. package/dist/passkey.d.ts +120 -0
  73. package/dist/passkey.js +105 -0
  74. package/dist/phrase.d.ts +87 -0
  75. package/dist/phrase.js +87 -0
  76. package/dist/pipeline.d.ts +137 -0
  77. package/dist/pipeline.js +121 -0
  78. package/dist/queue.d.ts +243 -0
  79. package/dist/queue.js +246 -0
  80. package/dist/schema-typebox.d.ts +24 -0
  81. package/dist/schema-typebox.js +201 -0
  82. package/dist/schema.d.ts +134 -0
  83. package/dist/schema.js +169 -0
  84. package/dist/serial.d.ts +54 -0
  85. package/dist/serial.js +40 -0
  86. package/dist/slip10.d.ts +37 -0
  87. package/dist/slip10.js +74 -0
  88. package/dist/snp.d.ts +115 -0
  89. package/dist/snp.js +109 -0
  90. package/dist/ssh-agent.d.ts +70 -0
  91. package/dist/ssh-agent.js +141 -0
  92. package/dist/ssh-cert.d.ts +73 -0
  93. package/dist/ssh-cert.js +111 -0
  94. package/dist/totp.d.ts +104 -0
  95. package/dist/totp.js +143 -0
  96. package/package.json +248 -0
package/dist/outbox.js ADDED
@@ -0,0 +1,236 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/outbox.ts
10
+ class OutboxError extends Error {
11
+ code;
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.code = code;
15
+ this.name = "OutboxError";
16
+ }
17
+ }
18
+ var EVENT_STATES = ["pending", "delivering", "delivered", "dead"];
19
+ var DEFAULT_POLICY = {
20
+ maxAttempts: 8,
21
+ baseDelayMs: 1000,
22
+ maxDelayMs: 300000,
23
+ jitter: 0.3,
24
+ claimTtlMs: 60000
25
+ };
26
+ function backoffMs(attempts, policy = DEFAULT_POLICY, random = Math.random) {
27
+ const exponential = Math.min(policy.baseDelayMs * 2 ** Math.max(0, attempts - 1), policy.maxDelayMs);
28
+ return Math.round(exponential * (1 + policy.jitter * random()));
29
+ }
30
+ function createOutbox(options) {
31
+ const policy = { ...DEFAULT_POLICY, ...options.policy };
32
+ const now = options.now ?? Date.now;
33
+ const random = options.random ?? Math.random;
34
+ const drainerId = options.drainerId ?? `drainer-${Math.floor(Math.random() * 1e9).toString(36)}`;
35
+ let sequence = 0;
36
+ const nextId = () => `evt_${now().toString(36)}_${(sequence += 1).toString(36)}`;
37
+ async function settleOne(event) {
38
+ let result;
39
+ try {
40
+ result = await options.transport.deliver(event);
41
+ } catch (cause) {
42
+ result = { ok: false, error: cause instanceof Error ? cause.message : String(cause) };
43
+ }
44
+ if (result.ok) {
45
+ await options.store.settle(event.id, {
46
+ state: "delivered",
47
+ deliveredAtMs: now(),
48
+ claimedBy: undefined,
49
+ claimedUntilMs: undefined
50
+ });
51
+ return "delivered";
52
+ }
53
+ const attempts = event.attempts + 1;
54
+ const exhausted = result.terminal || attempts >= policy.maxAttempts;
55
+ if (exhausted) {
56
+ const dead = {
57
+ ...event,
58
+ state: "dead",
59
+ attempts,
60
+ lastError: result.error,
61
+ claimedBy: undefined,
62
+ claimedUntilMs: undefined
63
+ };
64
+ await options.store.settle(event.id, {
65
+ state: "dead",
66
+ attempts,
67
+ lastError: result.error,
68
+ claimedBy: undefined,
69
+ claimedUntilMs: undefined
70
+ });
71
+ options.onDeadLetter?.(dead);
72
+ return "dead";
73
+ }
74
+ await options.store.settle(event.id, {
75
+ state: "pending",
76
+ attempts,
77
+ lastError: result.error,
78
+ nextAttemptAtMs: now() + backoffMs(attempts, policy, random),
79
+ claimedBy: undefined,
80
+ claimedUntilMs: undefined
81
+ });
82
+ return "failed";
83
+ }
84
+ return {
85
+ async publish(input) {
86
+ const event = {
87
+ id: input.id ?? nextId(),
88
+ type: input.type,
89
+ key: input.key,
90
+ route: input.route,
91
+ realm: input.realm,
92
+ payload: input.payload,
93
+ state: "pending",
94
+ attempts: 0,
95
+ nextAttemptAtMs: input.notBeforeMs ?? now(),
96
+ createdAtMs: now()
97
+ };
98
+ const inserted = await options.store.insert(event);
99
+ if (!inserted) {
100
+ return await options.store.get(event.id) ?? event;
101
+ }
102
+ return event;
103
+ },
104
+ async drain(limit = 32) {
105
+ const events = await options.store.claim({
106
+ drainerId,
107
+ limit,
108
+ nowMs: now(),
109
+ claimTtlMs: policy.claimTtlMs
110
+ });
111
+ const outcomes = await Promise.all(events.map(settleOne));
112
+ return {
113
+ claimed: events.length,
114
+ delivered: outcomes.filter((outcome) => outcome === "delivered").length,
115
+ failed: outcomes.filter((outcome) => outcome === "failed").length,
116
+ dead: outcomes.filter((outcome) => outcome === "dead").length
117
+ };
118
+ },
119
+ deadLetter: (limit = 100) => options.store.byState("dead", limit),
120
+ async retry(id) {
121
+ const event = await options.store.get(id);
122
+ if (!event)
123
+ throw new OutboxError("UNKNOWN_EVENT", `No event ${id}.`);
124
+ await options.store.settle(id, {
125
+ state: "pending",
126
+ attempts: 0,
127
+ nextAttemptAtMs: now(),
128
+ lastError: undefined
129
+ });
130
+ },
131
+ pending: (limit = 100) => options.store.byState("pending", limit),
132
+ drainerId
133
+ };
134
+ }
135
+ function outboxJob(outbox, options = {}) {
136
+ return {
137
+ key: options.key ?? "outbox.drain",
138
+ everyMs: options.everyMs ?? 5000,
139
+ run: async () => {
140
+ let total = { claimed: 0, delivered: 0, failed: 0, dead: 0 };
141
+ const batch = options.batch ?? 32;
142
+ for (let round = 0;round < 20; round += 1) {
143
+ const report = await outbox.drain(batch);
144
+ total = {
145
+ claimed: total.claimed + report.claimed,
146
+ delivered: total.delivered + report.delivered,
147
+ failed: total.failed + report.failed,
148
+ dead: total.dead + report.dead
149
+ };
150
+ if (report.claimed < batch)
151
+ break;
152
+ }
153
+ return { ok: total.dead === 0, detail: total };
154
+ }
155
+ };
156
+ }
157
+ function memoryStore() {
158
+ const events = new Map;
159
+ return {
160
+ async insert(event) {
161
+ if (events.has(event.id))
162
+ return false;
163
+ events.set(event.id, { ...event });
164
+ return true;
165
+ },
166
+ async claim({ drainerId, limit, nowMs, claimTtlMs }) {
167
+ const claimed = [];
168
+ const keysInBatch = new Set;
169
+ const candidates = [...events.values()].filter((event) => {
170
+ if (event.state === "delivered" || event.state === "dead")
171
+ return false;
172
+ if (event.nextAttemptAtMs > nowMs)
173
+ return false;
174
+ if (event.state === "delivering" && (event.claimedUntilMs ?? 0) > nowMs)
175
+ return false;
176
+ return true;
177
+ }).sort((a, b) => a.createdAtMs - b.createdAtMs || a.id.localeCompare(b.id));
178
+ const blocked = new Set;
179
+ for (const event of candidates) {
180
+ if (!event.key) {
181
+ if (claimed.length >= limit)
182
+ break;
183
+ claimed.push(event);
184
+ continue;
185
+ }
186
+ if (blocked.has(event.key) || keysInBatch.has(event.key))
187
+ continue;
188
+ if (claimed.length >= limit)
189
+ break;
190
+ keysInBatch.add(event.key);
191
+ blocked.add(event.key);
192
+ claimed.push(event);
193
+ }
194
+ for (const event of claimed) {
195
+ events.set(event.id, {
196
+ ...event,
197
+ state: "delivering",
198
+ claimedBy: drainerId,
199
+ claimedUntilMs: nowMs + claimTtlMs
200
+ });
201
+ }
202
+ return claimed.map((event) => ({ ...events.get(event.id) }));
203
+ },
204
+ async settle(id, patch) {
205
+ const event = events.get(id);
206
+ if (!event)
207
+ return;
208
+ const merged = { ...event, ...patch };
209
+ for (const [key, value] of Object.entries(patch)) {
210
+ if (value === undefined)
211
+ delete merged[key];
212
+ }
213
+ events.set(id, merged);
214
+ },
215
+ async byState(state, limit = 100) {
216
+ return [...events.values()].filter((event) => event.state === state).sort((a, b) => a.createdAtMs - b.createdAtMs).slice(0, limit);
217
+ },
218
+ async get(id) {
219
+ const event = events.get(id);
220
+ return event ? { ...event } : null;
221
+ },
222
+ all: () => [...events.values()],
223
+ clear: () => events.clear()
224
+ };
225
+ }
226
+ var VERSION = "0.1.0";
227
+ export {
228
+ outboxJob,
229
+ memoryStore,
230
+ createOutbox,
231
+ backoffMs,
232
+ VERSION,
233
+ OutboxError,
234
+ EVENT_STATES,
235
+ DEFAULT_POLICY
236
+ };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Passkeys held in the vault, for sites that are not us.
3
+ *
4
+ * `security/webauthn.ts` is ForgeZero authenticating a person. This is the
5
+ * opposite direction: ForgeZero acting as the AUTHENTICATOR for somebody else's
6
+ * site, so a credential survives a lost laptop.
7
+ *
8
+ * ## Phishing resistance is the RP ID, not the hardware
9
+ *
10
+ * The reason a passkey cannot be phished is that the credential signs a
11
+ * structure containing a hash of the relying party it belongs to, and the
12
+ * browser supplies that from the origin it is actually on. A key living in a
13
+ * vault is exactly as unphishable as one in a security chip, PROVIDED the RP ID
14
+ * check is never skipped — which is why it is the first thing this module does
15
+ * and why it refuses rather than warns.
16
+ *
17
+ * Synced passkeys are standardised, not a workaround: WebAuthn calls them
18
+ * multi-device credentials, and every major manager ships them.
19
+ *
20
+ * ## Derived, never stored
21
+ *
22
+ * A credential's private half comes from the realm seed and the site it belongs
23
+ * to. Nothing is kept that could be read: the key is reconstructed inside a
24
+ * signing call and the same inputs always produce the same credential, so a
25
+ * restored vault still holds every passkey it ever created.
26
+ */
27
+ export declare class PasskeyError extends Error {
28
+ readonly code: 'RP_MISMATCH' | 'BAD_ORIGIN' | 'BAD_CREDENTIAL' | 'BAD_CHALLENGE';
29
+ constructor(code: 'RP_MISMATCH' | 'BAD_ORIGIN' | 'BAD_CREDENTIAL' | 'BAD_CHALLENGE', message: string);
30
+ }
31
+ /**
32
+ * Does this origin belong to this relying party?
33
+ *
34
+ * The WebAuthn rule: the RP ID must equal the origin's effective domain or be a
35
+ * registrable suffix of it. `login.example.com` may use a credential for
36
+ * `example.com`; `example.com.evil.net` may not, and neither may
37
+ * `notexample.com`.
38
+ *
39
+ * The suffix test is on LABELS, not characters. `evil-example.com` ends with
40
+ * `example.com` as a string and is a different site — that single mistake is
41
+ * the whole attack.
42
+ */
43
+ export declare function rpIdMatches(origin: string, rpId: string): boolean;
44
+ export interface StoredPasskey {
45
+ /** The site. `github.com`, never an origin. */
46
+ rpId: string;
47
+ /** Opaque bytes the site gave for the account. */
48
+ userHandle: string;
49
+ /** What a person sees in a list. */
50
+ userName: string;
51
+ /** Base64url. Derived, so it is reproducible rather than remembered. */
52
+ credentialId: string;
53
+ publicKey: string;
54
+ /** Incremented per assertion. A site may use it to spot a cloned key. */
55
+ counter: number;
56
+ createdAtTs: number;
57
+ }
58
+ /**
59
+ * A credential id that is a function of what it is for.
60
+ *
61
+ * Derived rather than random so a restored vault produces the same ids: a
62
+ * random id would have to be stored, and a credential whose id was lost is one
63
+ * the site will never recognise again no matter what key you hold.
64
+ */
65
+ export declare const credentialIdFor: (rpId: string, userHandle: string) => string;
66
+ /** WebAuthn flags. UP is presence, UV is verification, BE/BS are backup state. */
67
+ export declare const FLAG_UP = 1;
68
+ export declare const FLAG_UV = 4;
69
+ export declare const FLAG_BE = 8;
70
+ export declare const FLAG_BS = 16;
71
+ /**
72
+ * `authenticatorData`, which is the structure the signature actually covers.
73
+ *
74
+ * 32 sha256(rpId) ← the binding, and the whole security argument
75
+ * 1 flags
76
+ * 4 signature counter, big-endian
77
+ *
78
+ * BE and BS are set because a vault-held credential IS backed up and IS
79
+ * currently available on more than one device. Claiming otherwise would tell a
80
+ * relying party this key cannot be recovered, and some of them use that to
81
+ * decide whether to keep a fallback.
82
+ */
83
+ export declare function authenticatorData(rpId: string, counter: number, userVerified: boolean): Uint8Array;
84
+ export interface AssertionRequest {
85
+ passkey: StoredPasskey;
86
+ /** The origin the browser is actually on. Checked, never trusted. */
87
+ origin: string;
88
+ /** The site's challenge, base64url. */
89
+ challenge: string;
90
+ userVerified?: boolean;
91
+ /** Signs 32 bytes with the credential's private half. Never returns it. */
92
+ sign: (message: Uint8Array) => Uint8Array;
93
+ }
94
+ export interface Assertion {
95
+ credentialId: string;
96
+ authenticatorData: string;
97
+ clientDataJSON: string;
98
+ signature: string;
99
+ userHandle: string;
100
+ counter: number;
101
+ }
102
+ /**
103
+ * Produce one assertion.
104
+ *
105
+ * The origin is checked FIRST, before anything is built and long before
106
+ * anything is signed. A signature produced and then discarded because the check
107
+ * failed is a signature that existed, and the only safe place for that check is
108
+ * ahead of it.
109
+ */
110
+ export declare function assertPasskey(request: AssertionRequest): Assertion;
111
+ /**
112
+ * Which stored passkeys may be offered on this page.
113
+ *
114
+ * The same shape as the extension's credential matcher and for the same reason:
115
+ * offering a credential on the wrong site is the failure, and it happens before
116
+ * anybody clicks anything.
117
+ */
118
+ export declare const passkeysFor: (passkeys: readonly StoredPasskey[], origin: string) => StoredPasskey[];
119
+ /** Verify one of our own assertions. For tests, and for a caller that wants proof. */
120
+ export declare function verifyAssertion(assertion: Assertion, publicKey: string): boolean;
@@ -0,0 +1,105 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/passkey.ts
10
+ import { sha256 } from "@noble/hashes/sha2.js";
11
+ import { ed25519 } from "@noble/curves/ed25519.js";
12
+ import { toBase64Url, fromBase64Url } from "@forgezero/access/security";
13
+
14
+ class PasskeyError extends Error {
15
+ code;
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.code = code;
19
+ this.name = "PasskeyError";
20
+ }
21
+ }
22
+ function rpIdMatches(origin, rpId) {
23
+ let host;
24
+ try {
25
+ const url = new URL(origin);
26
+ if (url.protocol !== "https:" && url.hostname !== "localhost")
27
+ return false;
28
+ host = url.hostname.toLowerCase();
29
+ } catch {
30
+ return false;
31
+ }
32
+ const wanted = rpId.trim().toLowerCase();
33
+ if (!wanted || wanted !== wanted.replace(/[^a-z0-9.-]/g, ""))
34
+ return false;
35
+ if (host === wanted)
36
+ return true;
37
+ return host.endsWith(`.${wanted}`);
38
+ }
39
+ var credentialIdFor = (rpId, userHandle) => toBase64Url(sha256(new TextEncoder().encode(`fz:passkey:${rpId}:${userHandle}`)));
40
+ var FLAG_UP = 1;
41
+ var FLAG_UV = 4;
42
+ var FLAG_BE = 8;
43
+ var FLAG_BS = 16;
44
+ function authenticatorData(rpId, counter, userVerified) {
45
+ const rpIdHash = sha256(new TextEncoder().encode(rpId));
46
+ const out = new Uint8Array(37);
47
+ out.set(rpIdHash, 0);
48
+ out[32] = FLAG_UP | FLAG_BE | FLAG_BS | (userVerified ? FLAG_UV : 0);
49
+ new DataView(out.buffer).setUint32(33, counter, false);
50
+ return out;
51
+ }
52
+ function assertPasskey(request) {
53
+ if (!rpIdMatches(request.origin, request.passkey.rpId)) {
54
+ throw new PasskeyError("RP_MISMATCH", `A credential for ${request.passkey.rpId} will not sign for ${request.origin}. This is what makes a passkey unphishable.`);
55
+ }
56
+ if (!request.challenge) {
57
+ throw new PasskeyError("BAD_CHALLENGE", "The site issued no challenge.");
58
+ }
59
+ const counter = request.passkey.counter + 1;
60
+ const data = authenticatorData(request.passkey.rpId, counter, request.userVerified ?? true);
61
+ const clientData = JSON.stringify({
62
+ type: "webauthn.get",
63
+ challenge: request.challenge,
64
+ origin: new URL(request.origin).origin,
65
+ crossOrigin: false
66
+ });
67
+ const clientDataHash = sha256(new TextEncoder().encode(clientData));
68
+ const signed = new Uint8Array(data.length + clientDataHash.length);
69
+ signed.set(data);
70
+ signed.set(clientDataHash, data.length);
71
+ return {
72
+ credentialId: request.passkey.credentialId,
73
+ authenticatorData: toBase64Url(data),
74
+ clientDataJSON: toBase64Url(new TextEncoder().encode(clientData)),
75
+ signature: toBase64Url(request.sign(signed)),
76
+ userHandle: request.passkey.userHandle,
77
+ counter
78
+ };
79
+ }
80
+ var passkeysFor = (passkeys, origin) => passkeys.filter((passkey) => rpIdMatches(origin, passkey.rpId));
81
+ function verifyAssertion(assertion, publicKey) {
82
+ const data = fromBase64Url(assertion.authenticatorData);
83
+ const clientDataHash = sha256(fromBase64Url(assertion.clientDataJSON));
84
+ const signed = new Uint8Array(data.length + clientDataHash.length);
85
+ signed.set(data);
86
+ signed.set(clientDataHash, data.length);
87
+ try {
88
+ return ed25519.verify(fromBase64Url(assertion.signature), signed, fromBase64Url(publicKey));
89
+ } catch {
90
+ return false;
91
+ }
92
+ }
93
+ export {
94
+ verifyAssertion,
95
+ rpIdMatches,
96
+ passkeysFor,
97
+ credentialIdFor,
98
+ authenticatorData,
99
+ assertPasskey,
100
+ PasskeyError,
101
+ FLAG_UV,
102
+ FLAG_UP,
103
+ FLAG_BS,
104
+ FLAG_BE
105
+ };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * BIP-39 recovery phrases — the custodian's second, offline key.
3
+ *
4
+ * Every custodian gets a passkey AND a 24-word phrase. The passkey is the day
5
+ * to day factor; the phrase is what still works when the laptop holding the
6
+ * passkey is lost, wiped or dead. See share-envelopes.ts for how the two are
7
+ * combined (they are alternatives, not a chain).
8
+ *
9
+ * ── Why BIP-39, and why 24 words ─────────────────────────────────────────────
10
+ * We need a key a human can write on paper, read back a year later, and type
11
+ * without ambiguity. BIP-39's English wordlist is engineered for exactly that:
12
+ * 2048 words, each identified by its first four letters, no confusable pairs,
13
+ * and a checksum so a mistyped or transposed word is caught before it becomes a
14
+ * silent "wrong key" failure hours into a recovery.
15
+ *
16
+ * 24 words carries 256 bits of entropy (24 * 11 = 264 bits, of which 8 are the
17
+ * checksum). That matches the strength of the keys it protects. A 12-word
18
+ * phrase would be 128 bits — still strong, but it would become the weakest link
19
+ * in a chain that is otherwise 256-bit throughout, so this module mints and
20
+ * accepts 24 words only.
21
+ *
22
+ * The full 2048-word standard list is used, always. A shortened or custom
23
+ * wordlist would silently cut the entropy per word and break interoperability
24
+ * with every offline BIP-39 tool a custodian might use to check their backup.
25
+ *
26
+ * ── Key derivation ───────────────────────────────────────────────────────────
27
+ * `phraseToKey` runs the standard BIP-39 seed derivation (PBKDF2-HMAC-SHA512,
28
+ * 2048 iterations) and then HKDF-SHA256 with a per-record salt. The PBKDF2 work
29
+ * factor is a BIP-39 constant and is NOT what provides security here — 2048
30
+ * iterations would be feeble against a guessable input. It does not need to be
31
+ * strong: the input is 256 bits of machine-generated entropy, which is not
32
+ * guessable at any work factor. The per-record salt means the same phrase
33
+ * yields a different key for every share it protects, so two records never
34
+ * share a key and one leaked key cannot be tried against another record.
35
+ *
36
+ * `phraseVerifier` expands the SAME extracted PRK under a different HKDF info
37
+ * label. Different info means the two outputs are computationally independent
38
+ * PRF evaluations: publishing the verifier reveals nothing about the key, and
39
+ * neither reveals the phrase, since HKDF is one-way. The verifier exists so a
40
+ * recovery flow can say "that is not the right phrase" immediately instead of
41
+ * returning an opaque AEAD failure.
42
+ *
43
+ * ── Post-quantum ─────────────────────────────────────────────────────────────
44
+ * Symmetric throughout: PBKDF2-HMAC-SHA512, HKDF-SHA256. Grover's algorithm
45
+ * takes the 256-bit phrase to a ~128-bit effective search, which is fine. No
46
+ * public-key primitive is involved, so there is nothing for Shor's algorithm.
47
+ *
48
+ * ── Handling ─────────────────────────────────────────────────────────────────
49
+ * The words are shown to the custodian once, at issue time, and are never
50
+ * stored by the platform in any form from which they can be recovered. Only the
51
+ * salt and the verifier are persisted.
52
+ */
53
+ /** 24 words. 256 bits of entropy plus an 8-bit checksum. */
54
+ export declare const PHRASE_WORDS = 24;
55
+ /** Per-record HKDF salt. 128 bits is ample for uniqueness. */
56
+ export declare const PHRASE_SALT_BYTES = 16;
57
+ /**
58
+ * A fresh 24-word phrase.
59
+ *
60
+ * The entropy comes from `crypto.getRandomValues` and is passed to BIP-39
61
+ * explicitly rather than letting the library pick, so the 256-bit claim is
62
+ * visible in this file rather than inferred from a strength parameter.
63
+ */
64
+ export declare function generatePhrase(): string[];
65
+ /**
66
+ * Real BIP-39 validation: every word must be in the standard list AND the
67
+ * trailing checksum bits must match the entropy. This is what catches a
68
+ * transposed or misread word at input time.
69
+ *
70
+ * Deliberately narrower than BIP-39: a valid 12-word phrase returns false here,
71
+ * because ForgeZero issues 24-word phrases and a 12-word one can only have come
72
+ * from somewhere else.
73
+ */
74
+ export declare function validatePhrase(words: string[]): boolean;
75
+ /** A fresh per-record salt for `phraseToKey` / `phraseVerifier`. */
76
+ export declare function newSalt(): Uint8Array;
77
+ /**
78
+ * Derive the 32-byte key this phrase protects a record with. Deterministic:
79
+ * same words and same salt always give the same key.
80
+ */
81
+ export declare function phraseToKey(words: string[], salt: Uint8Array): Uint8Array;
82
+ /**
83
+ * A salted hex digest that proves a phrase is the right one without being able
84
+ * to reconstruct it — and without being the key, so storing it next to the
85
+ * ciphertext gives an attacker nothing to decrypt with.
86
+ */
87
+ export declare function phraseVerifier(words: string[], salt: Uint8Array): string;
package/dist/phrase.js ADDED
@@ -0,0 +1,87 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/phrase.ts
10
+ import { entropyToMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
11
+ import { wordlist } from "@scure/bip39/wordlists/english.js";
12
+ import { hkdf } from "@noble/hashes/hkdf.js";
13
+ import { sha256 } from "@noble/hashes/sha2.js";
14
+ import { toHex } from "@forgezero/access/security";
15
+ var ENCODER = new TextEncoder;
16
+ var randomBytes = (length) => crypto.getRandomValues(new Uint8Array(length));
17
+ var PHRASE_WORDS = 24;
18
+ var ENTROPY_BYTES = 32;
19
+ var PHRASE_SALT_BYTES = 16;
20
+ var KEY_BYTES = 32;
21
+ var KEY_INFO = "forgezero:custodian:phrase-key:v1";
22
+ var VERIFIER_INFO = "forgezero:custodian:phrase-verifier:v1";
23
+ function canonical(words) {
24
+ return words.map((word) => word.normalize("NFKD").trim().toLowerCase()).join(" ");
25
+ }
26
+ function shapeIsValid(words) {
27
+ if (!Array.isArray(words) || words.length !== PHRASE_WORDS)
28
+ return false;
29
+ return words.every((word) => typeof word === "string" && word.trim().length > 0);
30
+ }
31
+ function generatePhrase() {
32
+ const entropy = randomBytes(ENTROPY_BYTES);
33
+ try {
34
+ const words = entropyToMnemonic(entropy, wordlist).split(" ");
35
+ if (words.length !== PHRASE_WORDS) {
36
+ throw new Error("phrase: wordlist produced an unexpected phrase length");
37
+ }
38
+ return words;
39
+ } finally {
40
+ entropy.fill(0);
41
+ }
42
+ }
43
+ function validatePhrase(words) {
44
+ if (!shapeIsValid(words))
45
+ return false;
46
+ return validateMnemonic(canonical(words), wordlist);
47
+ }
48
+ function newSalt() {
49
+ return Uint8Array.from(randomBytes(PHRASE_SALT_BYTES));
50
+ }
51
+ function assertSalt(salt) {
52
+ if (!(salt instanceof Uint8Array) || salt.length < PHRASE_SALT_BYTES) {
53
+ throw new Error(`phrase: salt must be at least ${PHRASE_SALT_BYTES} bytes`);
54
+ }
55
+ }
56
+ function bip39Seed(words) {
57
+ if (!validatePhrase(words))
58
+ throw new Error("INVALID_PHRASE");
59
+ return mnemonicToSeedSync(canonical(words));
60
+ }
61
+ function phraseToKey(words, salt) {
62
+ assertSalt(salt);
63
+ const seed = bip39Seed(words);
64
+ try {
65
+ return hkdf(sha256, seed, salt, ENCODER.encode(KEY_INFO), KEY_BYTES);
66
+ } finally {
67
+ seed.fill(0);
68
+ }
69
+ }
70
+ function phraseVerifier(words, salt) {
71
+ assertSalt(salt);
72
+ const seed = bip39Seed(words);
73
+ try {
74
+ return toHex(hkdf(sha256, seed, salt, ENCODER.encode(VERIFIER_INFO), KEY_BYTES));
75
+ } finally {
76
+ seed.fill(0);
77
+ }
78
+ }
79
+ export {
80
+ validatePhrase,
81
+ phraseVerifier,
82
+ phraseToKey,
83
+ newSalt,
84
+ generatePhrase,
85
+ PHRASE_WORDS,
86
+ PHRASE_SALT_BYTES
87
+ };