@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.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/contracts/foundry.toml +9 -0
- package/contracts/src/ColdVault.sol +206 -0
- package/contracts/src/DepositFactory.sol +202 -0
- package/contracts/src/DepositProxy.sol +72 -0
- package/contracts/src/IERC20.sol +7 -0
- package/contracts/src/MockTokens.sol +32 -0
- package/contracts/src/SafeTransferLib.sol +31 -0
- package/contracts/test/Custody.t.sol +361 -0
- package/contracts/test/Vectors.t.sol +45 -0
- package/dist/audit.d.ts +265 -0
- package/dist/audit.js +291 -0
- package/dist/backup.d.ts +243 -0
- package/dist/backup.js +302 -0
- package/dist/calendar.d.ts +136 -0
- package/dist/calendar.js +129 -0
- package/dist/compliance.d.ts +172 -0
- package/dist/compliance.js +168 -0
- package/dist/finance/binance.d.ts +27 -0
- package/dist/finance/binance.js +452 -0
- package/dist/finance/chain-addresses.d.ts +130 -0
- package/dist/finance/chain-addresses.js +462 -0
- package/dist/finance/chain-deposits.d.ts +193 -0
- package/dist/finance/chain-deposits.js +596 -0
- package/dist/finance/chain-reconcile.d.ts +112 -0
- package/dist/finance/chain-reconcile.js +76 -0
- package/dist/finance/chain-withdrawals.d.ts +223 -0
- package/dist/finance/chain-withdrawals.js +631 -0
- package/dist/finance/chain.d.ts +116 -0
- package/dist/finance/chain.js +316 -0
- package/dist/finance/commission.d.ts +155 -0
- package/dist/finance/commission.js +419 -0
- package/dist/finance/custody.d.ts +68 -0
- package/dist/finance/custody.js +107 -0
- package/dist/finance/derive.d.ts +115 -0
- package/dist/finance/derive.js +116 -0
- package/dist/finance/discounts.d.ts +98 -0
- package/dist/finance/discounts.js +90 -0
- package/dist/finance/ledger.d.ts +221 -0
- package/dist/finance/ledger.js +308 -0
- package/dist/finance/market.d.ts +209 -0
- package/dist/finance/market.js +112 -0
- package/dist/finance/money.d.ts +118 -0
- package/dist/finance/money.js +176 -0
- package/dist/finance/rates.d.ts +178 -0
- package/dist/finance/rates.js +292 -0
- package/dist/finance/storage.d.ts +113 -0
- package/dist/finance/storage.js +226 -0
- package/dist/finance/tax.d.ts +132 -0
- package/dist/finance/tax.js +291 -0
- package/dist/finance/transfers.d.ts +153 -0
- package/dist/finance/transfers.js +292 -0
- package/dist/finance/venues.d.ts +190 -0
- package/dist/finance/venues.js +251 -0
- package/dist/identity.d.ts +115 -0
- package/dist/identity.js +111 -0
- package/dist/importers.d.ts +87 -0
- package/dist/importers.js +250 -0
- package/dist/jobs.d.ts +171 -0
- package/dist/jobs.js +250 -0
- package/dist/notify-templates.d.ts +11 -0
- package/dist/notify-templates.js +254 -0
- package/dist/notify.d.ts +172 -0
- package/dist/notify.js +122 -0
- package/dist/openssh.d.ts +36 -0
- package/dist/openssh.js +106 -0
- package/dist/otpauth.d.ts +57 -0
- package/dist/otpauth.js +223 -0
- package/dist/outbox.d.ts +234 -0
- package/dist/outbox.js +236 -0
- package/dist/passkey.d.ts +120 -0
- package/dist/passkey.js +105 -0
- package/dist/phrase.d.ts +87 -0
- package/dist/phrase.js +87 -0
- package/dist/pipeline.d.ts +137 -0
- package/dist/pipeline.js +121 -0
- package/dist/queue.d.ts +243 -0
- package/dist/queue.js +246 -0
- package/dist/schema-typebox.d.ts +24 -0
- package/dist/schema-typebox.js +201 -0
- package/dist/schema.d.ts +134 -0
- package/dist/schema.js +169 -0
- package/dist/serial.d.ts +54 -0
- package/dist/serial.js +40 -0
- package/dist/slip10.d.ts +37 -0
- package/dist/slip10.js +74 -0
- package/dist/snp.d.ts +115 -0
- package/dist/snp.js +109 -0
- package/dist/ssh-agent.d.ts +70 -0
- package/dist/ssh-agent.js +141 -0
- package/dist/ssh-cert.d.ts +73 -0
- package/dist/ssh-cert.js +111 -0
- package/dist/totp.d.ts +104 -0
- package/dist/totp.js +143 -0
- 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;
|
package/dist/passkey.js
ADDED
|
@@ -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
|
+
};
|
package/dist/phrase.d.ts
ADDED
|
@@ -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
|
+
};
|