@forgezero/runtime 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -4
- package/dist/audit.js +262 -23
- package/dist/custody-crypto.d.ts +53 -0
- package/dist/custody-crypto.js +89 -0
- package/dist/custody-share.d.ts +117 -0
- package/dist/custody-share.js +313 -0
- package/dist/jobs.d.ts +3 -2
- package/dist/jobs.js +288 -13
- package/dist/outbox.d.ts +10 -1
- package/dist/outbox.js +8 -3
- package/dist/pipeline.d.ts +11 -37
- package/dist/pipeline.js +0 -27
- package/dist/queue.d.ts +101 -228
- package/dist/queue.js +246 -210
- package/dist/snp.d.ts +7 -6
- package/dist/snp.js +6 -1
- package/dist/ssh-agent.d.ts +13 -0
- package/dist/ssh-agent.js +6 -0
- package/package.json +254 -246
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ themselves, keyed queues, a transactional outbox, a hash-chained audit trail,
|
|
|
5
5
|
templated mail, encrypted backups, schema validation, and money that is never a
|
|
6
6
|
floating-point number.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Thirty-three public modules. Each is its own entry point, so you install one package and
|
|
9
9
|
your bundler includes only what you imported.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
@@ -24,7 +24,7 @@ import { createScheduler } from '@forgezero/runtime/jobs';
|
|
|
24
24
|
|
|
25
25
|
| | |
|
|
26
26
|
|---|---|
|
|
27
|
-
| **Spine** | `jobs` `queue` `
|
|
27
|
+
| **Spine** | `jobs` `queue` `outbox` |
|
|
28
28
|
| **Record** | `audit` `backup` `compliance` `calendar` |
|
|
29
29
|
| **Identity** | `identity` `totp` `notify` `notify/templates` `schema` `schema/typebox` |
|
|
30
30
|
| **Finance** | `finance/money` `finance/storage` `finance/ledger` `finance/commission` `finance/rates` `finance/transfers` `finance/tax` `finance/chain` `finance/custody` `finance/derive` `finance/venues` `finance/binance` |
|
|
@@ -32,6 +32,29 @@ import { createScheduler } from '@forgezero/runtime/jobs';
|
|
|
32
32
|
None of it knows what your product does. You bind your own rules to these; you
|
|
33
33
|
do not fork them.
|
|
34
34
|
|
|
35
|
+
## One reusable async queue
|
|
36
|
+
|
|
37
|
+
`queue` is deliberately memory-only: it accepts a function, returns that
|
|
38
|
+
function's value through a Promise, runs one key sequentially, and runs
|
|
39
|
+
different keys in parallel. Persistence and cluster ownership remain in the
|
|
40
|
+
business layer that knows what a pending request means; ForgeZero uses an
|
|
41
|
+
ArangoDB unique claim, while another caller may use any database or no database.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
import { createQueue } from '@forgezero/runtime/queue';
|
|
45
|
+
|
|
46
|
+
const queue = createQueue({ width: 8 });
|
|
47
|
+
const task = queue.run('tenant-a:wallet-7', transfer, amount, destination);
|
|
48
|
+
const receipt = await task.result;
|
|
49
|
+
|
|
50
|
+
queue.pauseKey('tenant-a:wallet-7');
|
|
51
|
+
queue.resumeKey('tenant-a:wallet-7');
|
|
52
|
+
queue.stopKey('tenant-a:wallet-7'); // running finishes; pending is rejected
|
|
53
|
+
queue.startKey('tenant-a:wallet-7');
|
|
54
|
+
queue.cancel(task.id); // pending task only
|
|
55
|
+
await queue.stop(30_000); // close intake and drain all work
|
|
56
|
+
```
|
|
57
|
+
|
|
35
58
|
## Three things worth knowing before you use it
|
|
36
59
|
|
|
37
60
|
**Money is never a number.** An amount is minor units as a `bigint` with its
|
|
@@ -81,9 +104,9 @@ node_modules/@forgezero/runtime/contracts/src/
|
|
|
81
104
|
ColdVault.sol M-of-N EIP-712, signatures ordered by ascending signer
|
|
82
105
|
```
|
|
83
106
|
|
|
84
|
-
Full documentation: **https://forgezero.net/docs/runtime**
|
|
107
|
+
Full documentation: **https://www.forgezero.net/docs/runtime**
|
|
85
108
|
|
|
86
109
|
## Licence
|
|
87
110
|
|
|
88
|
-
MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
|
|
111
|
+
MIT. Part of [ForgeZero](https://www.forgezero.net) — secrets, attested compute and
|
|
89
112
|
deploys — and usable entirely on its own, with no ForgeZero account.
|
package/dist/audit.js
CHANGED
|
@@ -6,32 +6,271 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
6
6
|
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
7
|
});
|
|
8
8
|
|
|
9
|
-
// src/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
// src/queue.ts
|
|
10
|
+
class QueueStoppedError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super("queue: stopped before this task could run");
|
|
13
|
+
this.name = "QueueStoppedError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class QueueKeyStoppedError extends Error {
|
|
18
|
+
key;
|
|
19
|
+
constructor(key) {
|
|
20
|
+
super(`queue: key ${key} is stopped`);
|
|
21
|
+
this.key = key;
|
|
22
|
+
this.name = "QueueKeyStoppedError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class TaskCancelledError extends Error {
|
|
27
|
+
constructor() {
|
|
28
|
+
super("queue: task cancelled");
|
|
29
|
+
this.name = "TaskCancelledError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
var DEFAULT_RETRY = {
|
|
33
|
+
attempts: 1,
|
|
34
|
+
backoffMs: (attempt) => Math.min(30000, 2 ** attempt * 100)
|
|
35
|
+
};
|
|
36
|
+
function createQueue(options = {}) {
|
|
37
|
+
const width = options.width ?? 8;
|
|
38
|
+
const retry = { ...DEFAULT_RETRY, ...options.retry };
|
|
39
|
+
if (!Number.isSafeInteger(width) || width < 1) {
|
|
40
|
+
throw new RangeError("queue: width must be a positive integer");
|
|
41
|
+
}
|
|
42
|
+
if (!Number.isSafeInteger(retry.attempts) || retry.attempts < 1) {
|
|
43
|
+
throw new RangeError("queue: retry attempts must be a positive integer");
|
|
44
|
+
}
|
|
45
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
46
|
+
const lanes = new Map;
|
|
47
|
+
const running = new Set;
|
|
48
|
+
const paused = new Set;
|
|
49
|
+
const stoppedKeys = new Set;
|
|
50
|
+
let sequence = 0;
|
|
51
|
+
let globallyPaused = false;
|
|
52
|
+
let accepting = true;
|
|
53
|
+
let aborted = false;
|
|
54
|
+
let completed = 0;
|
|
55
|
+
let failed = 0;
|
|
56
|
+
const idle = [];
|
|
57
|
+
const announceIdle = () => {
|
|
58
|
+
if (running.size > 0)
|
|
59
|
+
return;
|
|
60
|
+
for (const lane of lanes.values())
|
|
61
|
+
if (lane.length > 0)
|
|
18
62
|
return;
|
|
19
|
-
|
|
63
|
+
while (idle.length > 0)
|
|
64
|
+
idle.shift()();
|
|
65
|
+
};
|
|
66
|
+
function pump() {
|
|
67
|
+
if (globallyPaused || aborted) {
|
|
68
|
+
announceIdle();
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const [key, lane] of lanes) {
|
|
72
|
+
if (running.size >= width)
|
|
73
|
+
break;
|
|
74
|
+
if (running.has(key) || paused.has(key) || lane.length === 0)
|
|
75
|
+
continue;
|
|
76
|
+
execute(key);
|
|
77
|
+
}
|
|
78
|
+
announceIdle();
|
|
79
|
+
}
|
|
80
|
+
async function execute(key) {
|
|
81
|
+
running.add(key);
|
|
82
|
+
try {
|
|
83
|
+
const lane = lanes.get(key);
|
|
84
|
+
const entry = lane?.[0];
|
|
85
|
+
if (entry && !globallyPaused && !paused.has(key) && !aborted) {
|
|
86
|
+
lane.shift();
|
|
87
|
+
await attempt(entry);
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
running.delete(key);
|
|
91
|
+
const remaining = lanes.get(key);
|
|
92
|
+
if (remaining?.length === 0)
|
|
93
|
+
lanes.delete(key);
|
|
94
|
+
else if (remaining) {
|
|
95
|
+
lanes.delete(key);
|
|
96
|
+
lanes.set(key, remaining);
|
|
97
|
+
}
|
|
98
|
+
pump();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function attempt(entry) {
|
|
102
|
+
if (entry.cancelled) {
|
|
103
|
+
entry.reject(new TaskCancelledError);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
for (;; ) {
|
|
107
|
+
entry.attempt += 1;
|
|
108
|
+
try {
|
|
109
|
+
const value = await entry.run();
|
|
110
|
+
completed += 1;
|
|
111
|
+
entry.resolve(value);
|
|
20
112
|
return;
|
|
113
|
+
} catch (cause) {
|
|
114
|
+
if (entry.attempt >= retry.attempts || entry.cancelled || aborted) {
|
|
115
|
+
failed += 1;
|
|
116
|
+
entry.reject(cause);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const delay = retry.backoffMs(entry.attempt);
|
|
120
|
+
if (!Number.isFinite(delay) || delay < 0) {
|
|
121
|
+
failed += 1;
|
|
122
|
+
entry.reject(new RangeError("queue: retry backoff must be a non-negative finite number"));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await sleep(delay);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
run(key, handler, ...args) {
|
|
131
|
+
const id = `q_${++sequence}`;
|
|
132
|
+
if (!accepting || stoppedKeys.has(key)) {
|
|
133
|
+
const refused = Promise.reject(accepting ? new QueueKeyStoppedError(key) : new QueueStoppedError);
|
|
134
|
+
refused.catch(() => {
|
|
135
|
+
return;
|
|
136
|
+
});
|
|
137
|
+
return { id, key, result: refused };
|
|
138
|
+
}
|
|
139
|
+
let resolve;
|
|
140
|
+
let reject;
|
|
141
|
+
const result = new Promise((ok, no) => {
|
|
142
|
+
resolve = ok;
|
|
143
|
+
reject = no;
|
|
21
144
|
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
145
|
+
const entry = {
|
|
146
|
+
id,
|
|
147
|
+
key,
|
|
148
|
+
run: async () => handler(...args),
|
|
149
|
+
resolve,
|
|
150
|
+
reject,
|
|
151
|
+
attempt: 0,
|
|
152
|
+
cancelled: false
|
|
153
|
+
};
|
|
154
|
+
const lane = lanes.get(key);
|
|
155
|
+
if (lane)
|
|
156
|
+
lane.push(entry);
|
|
157
|
+
else
|
|
158
|
+
lanes.set(key, [entry]);
|
|
159
|
+
pump();
|
|
160
|
+
return { id, key, result };
|
|
161
|
+
},
|
|
162
|
+
cancel(id) {
|
|
163
|
+
for (const [key, lane] of lanes) {
|
|
164
|
+
const index = lane.findIndex((entry2) => entry2.id === id);
|
|
165
|
+
if (index === -1)
|
|
166
|
+
continue;
|
|
167
|
+
const [entry] = lane.splice(index, 1);
|
|
168
|
+
entry.cancelled = true;
|
|
169
|
+
entry.reject(new TaskCancelledError);
|
|
170
|
+
if (lane.length === 0 && !running.has(key))
|
|
171
|
+
lanes.delete(key);
|
|
172
|
+
announceIdle();
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
},
|
|
177
|
+
pauseKey(key) {
|
|
178
|
+
paused.add(key);
|
|
179
|
+
},
|
|
180
|
+
resumeKey(key) {
|
|
181
|
+
paused.delete(key);
|
|
182
|
+
pump();
|
|
183
|
+
},
|
|
184
|
+
stopKey(key) {
|
|
185
|
+
stoppedKeys.add(key);
|
|
186
|
+
paused.delete(key);
|
|
187
|
+
const lane = lanes.get(key);
|
|
188
|
+
if (!lane)
|
|
189
|
+
return 0;
|
|
190
|
+
let removed = 0;
|
|
191
|
+
for (const entry of lane.splice(0)) {
|
|
192
|
+
removed += 1;
|
|
193
|
+
entry.cancelled = true;
|
|
194
|
+
entry.reject(new QueueKeyStoppedError(key));
|
|
195
|
+
}
|
|
196
|
+
if (!running.has(key))
|
|
197
|
+
lanes.delete(key);
|
|
198
|
+
announceIdle();
|
|
199
|
+
return removed;
|
|
200
|
+
},
|
|
201
|
+
startKey(key) {
|
|
202
|
+
const changed = stoppedKeys.delete(key);
|
|
203
|
+
pump();
|
|
204
|
+
return changed;
|
|
205
|
+
},
|
|
206
|
+
pause() {
|
|
207
|
+
globallyPaused = true;
|
|
208
|
+
},
|
|
209
|
+
resume() {
|
|
210
|
+
globallyPaused = false;
|
|
211
|
+
pump();
|
|
212
|
+
},
|
|
213
|
+
snapshot() {
|
|
214
|
+
let queued = 0;
|
|
215
|
+
for (const lane of lanes.values())
|
|
216
|
+
queued += lane.length;
|
|
217
|
+
return {
|
|
218
|
+
running: running.size,
|
|
219
|
+
queued,
|
|
220
|
+
keys: lanes.size,
|
|
221
|
+
paused: globallyPaused,
|
|
222
|
+
pausedKeys: [...paused],
|
|
223
|
+
stoppedKeys: [...stoppedKeys],
|
|
224
|
+
completed,
|
|
225
|
+
failed
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
whenIdle() {
|
|
229
|
+
if (running.size === 0 && [...lanes.values()].every((lane) => lane.length === 0)) {
|
|
230
|
+
return Promise.resolve();
|
|
29
231
|
}
|
|
30
|
-
return
|
|
232
|
+
return new Promise((resolve) => idle.push(resolve));
|
|
31
233
|
},
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
234
|
+
async stop(deadlineMs = 30000) {
|
|
235
|
+
if (!Number.isSafeInteger(deadlineMs) || deadlineMs < 0) {
|
|
236
|
+
throw new RangeError("queue: stop deadline must be a non-negative integer");
|
|
237
|
+
}
|
|
238
|
+
accepting = false;
|
|
239
|
+
const before = { completed, failed };
|
|
240
|
+
globallyPaused = false;
|
|
241
|
+
paused.clear();
|
|
242
|
+
pump();
|
|
243
|
+
let timedOut = false;
|
|
244
|
+
let deadlineHandle;
|
|
245
|
+
const deadline = new Promise((resolve) => {
|
|
246
|
+
deadlineHandle = setTimeout(() => {
|
|
247
|
+
timedOut = true;
|
|
248
|
+
resolve();
|
|
249
|
+
}, deadlineMs);
|
|
250
|
+
});
|
|
251
|
+
await Promise.race([this.whenIdle(), deadline]);
|
|
252
|
+
if (!timedOut && deadlineHandle !== undefined)
|
|
253
|
+
clearTimeout(deadlineHandle);
|
|
254
|
+
if (timedOut)
|
|
255
|
+
aborted = true;
|
|
256
|
+
let abandoned = 0;
|
|
257
|
+
for (const lane of lanes.values()) {
|
|
258
|
+
abandoned += lane.length;
|
|
259
|
+
for (const entry of lane.splice(0))
|
|
260
|
+
entry.reject(new QueueStoppedError);
|
|
261
|
+
}
|
|
262
|
+
abandoned += running.size;
|
|
263
|
+
for (const [key, lane] of lanes) {
|
|
264
|
+
if (lane.length === 0 && !running.has(key))
|
|
265
|
+
lanes.delete(key);
|
|
266
|
+
}
|
|
267
|
+
announceIdle();
|
|
268
|
+
return {
|
|
269
|
+
completed: completed - before.completed,
|
|
270
|
+
failed: failed - before.failed,
|
|
271
|
+
abandoned,
|
|
272
|
+
timedOut
|
|
273
|
+
};
|
|
35
274
|
}
|
|
36
275
|
};
|
|
37
276
|
}
|
|
@@ -201,8 +440,8 @@ async function verifyExport(slice, options = {}) {
|
|
|
201
440
|
}
|
|
202
441
|
function createAuditChain(options) {
|
|
203
442
|
const digester = options.digester ?? hashDigester;
|
|
204
|
-
const
|
|
205
|
-
const enqueue = (realm, run) =>
|
|
443
|
+
const queue = createQueue();
|
|
444
|
+
const enqueue = (realm, run) => queue.run(realm ?? "\x00platform", run).result;
|
|
206
445
|
return {
|
|
207
446
|
append: (entry) => enqueue(entry.realm, () => appendRecord(options.store, entry, { ...options, digester })),
|
|
208
447
|
verify: async (realm, fromSequence = 1, toSequence = Number.MAX_SAFE_INTEGER) => {
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The wire shape, identical to the server's `CipherBox`. */
|
|
2
|
+
export interface CipherBox {
|
|
3
|
+
alg: 'aes-256-gcm';
|
|
4
|
+
nonce: string;
|
|
5
|
+
ciphertext: string;
|
|
6
|
+
}
|
|
7
|
+
/** HKDF-SHA256, the same construction and parameter order as `hkdfSync`. */
|
|
8
|
+
export declare function deriveKey(secret: Uint8Array, salt: Uint8Array, info: string, length?: number): Uint8Array;
|
|
9
|
+
/**
|
|
10
|
+
* Seal under a key, with AAD bound but not encrypted.
|
|
11
|
+
*
|
|
12
|
+
* `randomBytes` comes from the platform: `crypto.getRandomValues` exists in
|
|
13
|
+
* every browser and in Bun, and a nonce from anywhere else is not a nonce.
|
|
14
|
+
*/
|
|
15
|
+
export declare function sealWithKey(key: Uint8Array, plaintext: Uint8Array, aad: string): CipherBox;
|
|
16
|
+
/** Open a box. Throws on a wrong key, a wrong AAD, or any modification. */
|
|
17
|
+
export declare function openWithKey(key: Uint8Array, box: CipherBox, aad: string): Uint8Array;
|
|
18
|
+
/**
|
|
19
|
+
* Sealing to a PUBLIC key, so the holder of the private half is the only reader.
|
|
20
|
+
*
|
|
21
|
+
* This exists because the server was handing custodians their raw Shamir share
|
|
22
|
+
* and trusting them to report that both envelopes opened. Capturing one
|
|
23
|
+
* enrolment response therefore bypassed both recovery factors permanently, and
|
|
24
|
+
* the two factor "tests" were attestations by the client rather than evidence.
|
|
25
|
+
*
|
|
26
|
+
* With this, the client derives a wrapping key pair from each factor and sends
|
|
27
|
+
* only the public halves. The server seals the share to those and never emits
|
|
28
|
+
* plaintext — so a captured response is ciphertext, and opening it requires the
|
|
29
|
+
* factor itself.
|
|
30
|
+
*
|
|
31
|
+
* X25519 + HKDF-SHA256 + AES-256-GCM, with the ephemeral public key carried in
|
|
32
|
+
* the box. Nothing bespoke: the ephemeral-static shape is what every sealed-box
|
|
33
|
+
* construction uses, and doing it by hand is how people lose the AAD.
|
|
34
|
+
*/
|
|
35
|
+
export interface SealedToKey extends CipherBox {
|
|
36
|
+
/** base64 — the ephemeral public key this box was sealed with. */
|
|
37
|
+
ephemeral: string;
|
|
38
|
+
}
|
|
39
|
+
/** Seal to a recipient's public key. */
|
|
40
|
+
export declare function sealToKey(recipientPublicKey: Uint8Array, plaintext: Uint8Array, aad: string): SealedToKey;
|
|
41
|
+
/** Open a box sealed to this private key. */
|
|
42
|
+
export declare function openFromKey(recipientSecretKey: Uint8Array, box: SealedToKey, aad: string): Uint8Array;
|
|
43
|
+
/**
|
|
44
|
+
* A wrapping key pair derived from factor material.
|
|
45
|
+
*
|
|
46
|
+
* Deterministic, because a custodian must reproduce the same private key on a
|
|
47
|
+
* different machine months later from the same passkey or the same words. The
|
|
48
|
+
* factor never leaves the caller — only the public half is ever sent.
|
|
49
|
+
*/
|
|
50
|
+
export declare function wrappingKeyPair(factorMaterial: Uint8Array, info: string): {
|
|
51
|
+
secretKey: Uint8Array;
|
|
52
|
+
publicKey: Uint8Array;
|
|
53
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
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/custody-crypto.ts
|
|
10
|
+
import { gcm } from "@noble/ciphers/aes.js";
|
|
11
|
+
import { x25519 } from "@noble/curves/ed25519.js";
|
|
12
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
13
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
14
|
+
var KEY_BYTES = 32;
|
|
15
|
+
var NONCE_BYTES = 12;
|
|
16
|
+
var toBase64 = (bytes) => {
|
|
17
|
+
let binary = "";
|
|
18
|
+
for (const byte of bytes)
|
|
19
|
+
binary += String.fromCharCode(byte);
|
|
20
|
+
return btoa(binary);
|
|
21
|
+
};
|
|
22
|
+
var fromBase64 = (value) => {
|
|
23
|
+
const binary = atob(value);
|
|
24
|
+
const bytes = new Uint8Array(binary.length);
|
|
25
|
+
for (let index = 0;index < binary.length; index += 1)
|
|
26
|
+
bytes[index] = binary.charCodeAt(index);
|
|
27
|
+
return bytes;
|
|
28
|
+
};
|
|
29
|
+
var utf8 = (value) => new TextEncoder().encode(value);
|
|
30
|
+
function deriveKey(secret, salt, info, length = KEY_BYTES) {
|
|
31
|
+
return hkdf(sha256, secret, salt, utf8(info), length);
|
|
32
|
+
}
|
|
33
|
+
function sealWithKey(key, plaintext, aad) {
|
|
34
|
+
if (key.length !== KEY_BYTES)
|
|
35
|
+
throw new Error("custody: key must be 32 bytes");
|
|
36
|
+
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
|
|
37
|
+
const sealed = gcm(key, nonce, utf8(aad)).encrypt(plaintext);
|
|
38
|
+
return { alg: "aes-256-gcm", nonce: toBase64(nonce), ciphertext: toBase64(sealed) };
|
|
39
|
+
}
|
|
40
|
+
function openWithKey(key, box, aad) {
|
|
41
|
+
if (key.length !== KEY_BYTES)
|
|
42
|
+
throw new Error("custody: key must be 32 bytes");
|
|
43
|
+
if (box.alg !== "aes-256-gcm")
|
|
44
|
+
throw new Error(`custody: unknown algorithm ${box.alg}`);
|
|
45
|
+
return gcm(key, fromBase64(box.nonce), utf8(aad)).decrypt(fromBase64(box.ciphertext));
|
|
46
|
+
}
|
|
47
|
+
var WRAP_INFO = "forgezero:custody:wrap:v1";
|
|
48
|
+
var wrapKey = (shared, ephemeral, recipient) => hkdf(sha256, shared, concatBytes(ephemeral, recipient), utf8(WRAP_INFO), 32);
|
|
49
|
+
function concatBytes(left, right) {
|
|
50
|
+
const out = new Uint8Array(left.length + right.length);
|
|
51
|
+
out.set(left, 0);
|
|
52
|
+
out.set(right, left.length);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
function sealToKey(recipientPublicKey, plaintext, aad) {
|
|
56
|
+
const ephemeralSecret = x25519.utils.randomSecretKey();
|
|
57
|
+
const ephemeralPublic = x25519.getPublicKey(ephemeralSecret);
|
|
58
|
+
const shared = x25519.getSharedSecret(ephemeralSecret, recipientPublicKey);
|
|
59
|
+
const key = wrapKey(shared, ephemeralPublic, recipientPublicKey);
|
|
60
|
+
try {
|
|
61
|
+
return { ...sealWithKey(key, plaintext, aad), ephemeral: toBase64(ephemeralPublic) };
|
|
62
|
+
} finally {
|
|
63
|
+
key.fill(0);
|
|
64
|
+
ephemeralSecret.fill(0);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function openFromKey(recipientSecretKey, box, aad) {
|
|
68
|
+
const ephemeralPublic = fromBase64(box.ephemeral);
|
|
69
|
+
const recipientPublic = x25519.getPublicKey(recipientSecretKey);
|
|
70
|
+
const shared = x25519.getSharedSecret(recipientSecretKey, ephemeralPublic);
|
|
71
|
+
const key = wrapKey(shared, ephemeralPublic, recipientPublic);
|
|
72
|
+
try {
|
|
73
|
+
return openWithKey(key, box, aad);
|
|
74
|
+
} finally {
|
|
75
|
+
key.fill(0);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function wrappingKeyPair(factorMaterial, info) {
|
|
79
|
+
const secretKey = hkdf(sha256, factorMaterial, utf8("forgezero:custody:wrapkey:v1"), utf8(info), 32);
|
|
80
|
+
return { secretKey, publicKey: x25519.getPublicKey(secretKey) };
|
|
81
|
+
}
|
|
82
|
+
export {
|
|
83
|
+
wrappingKeyPair,
|
|
84
|
+
sealWithKey,
|
|
85
|
+
sealToKey,
|
|
86
|
+
openWithKey,
|
|
87
|
+
openFromKey,
|
|
88
|
+
deriveKey
|
|
89
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type CipherBox, type SealedToKey } from './custody-crypto';
|
|
2
|
+
export interface SealedShare {
|
|
3
|
+
shareIndex: number;
|
|
4
|
+
passkeyEnvelope: CipherBox;
|
|
5
|
+
phraseEnvelope: CipherBox;
|
|
6
|
+
/** base64 — the HKDF salt for the phrase key and the verifier. */
|
|
7
|
+
phraseSalt: string;
|
|
8
|
+
/** hex — proves the right phrase without being able to rebuild it. */
|
|
9
|
+
phraseVerifier: string;
|
|
10
|
+
}
|
|
11
|
+
type EnvelopeKind = 'passkey' | 'phrase';
|
|
12
|
+
/** The x-coordinate a share was cut at — its custodian-facing share number. */
|
|
13
|
+
export declare function shareIndexOf(share: Uint8Array): number;
|
|
14
|
+
/**
|
|
15
|
+
* Seal one share under BOTH factors.
|
|
16
|
+
*
|
|
17
|
+
* The two `sealWithKey` calls are siblings: the same `share` goes into each and
|
|
18
|
+
* neither output is an input to the other. That is what makes recovery an OR —
|
|
19
|
+
* lose the passkey, open with the phrase.
|
|
20
|
+
*/
|
|
21
|
+
export declare function sealShare(args: {
|
|
22
|
+
share: Uint8Array;
|
|
23
|
+
custodianKey: string;
|
|
24
|
+
passkeyPrfOutput: Uint8Array;
|
|
25
|
+
phraseWords: string[];
|
|
26
|
+
}): SealedShare;
|
|
27
|
+
/** Open the share with the passkey alone. The phrase is not consulted. */
|
|
28
|
+
export declare function openShareWithPasskey(sealed: SealedShare, custodianKey: string, passkeyPrfOutput: Uint8Array): Uint8Array;
|
|
29
|
+
/** Open the share with the phrase alone. The passkey is not consulted. */
|
|
30
|
+
export declare function openShareWithPhrase(sealed: SealedShare, custodianKey: string, phraseWords: string[]): Uint8Array;
|
|
31
|
+
/**
|
|
32
|
+
* The public halves a custodian offers so the server can seal to them.
|
|
33
|
+
*
|
|
34
|
+
* The server used to hand over the raw Shamir share and trust the client to
|
|
35
|
+
* report that both envelopes opened. Capturing one enrolment response therefore
|
|
36
|
+
* bypassed both recovery factors permanently, and the factor "tests" were
|
|
37
|
+
* attestations rather than evidence.
|
|
38
|
+
*
|
|
39
|
+
* These are derived from the factors themselves and are deterministic, so a
|
|
40
|
+
* custodian reproduces the same private halves months later on a different
|
|
41
|
+
* machine from the same passkey or the same words. Only the public halves are
|
|
42
|
+
* ever sent.
|
|
43
|
+
*/
|
|
44
|
+
export interface WrappingKeys {
|
|
45
|
+
passkeyPublicKey: string;
|
|
46
|
+
phrasePublicKey: string;
|
|
47
|
+
phraseSalt: string;
|
|
48
|
+
phraseVerifier: string;
|
|
49
|
+
}
|
|
50
|
+
/** The private half for one factor. Never leaves the machine that made it. */
|
|
51
|
+
export declare function passkeyWrappingKey(custodianKey: string, passkeyPrfOutput: Uint8Array): {
|
|
52
|
+
secretKey: Uint8Array;
|
|
53
|
+
publicKey: Uint8Array;
|
|
54
|
+
};
|
|
55
|
+
/** The same for the phrase, over the salt that will be stored beside it. */
|
|
56
|
+
export declare function phraseWrappingKey(custodianKey: string, phraseWords: string[], salt: Uint8Array): {
|
|
57
|
+
secretKey: Uint8Array;
|
|
58
|
+
publicKey: Uint8Array;
|
|
59
|
+
};
|
|
60
|
+
/** Everything the server needs, and nothing it must not have. */
|
|
61
|
+
export declare function wrappingKeysFor(args: {
|
|
62
|
+
custodianKey: string;
|
|
63
|
+
passkeyPrfOutput: Uint8Array;
|
|
64
|
+
phraseWords: string[];
|
|
65
|
+
}): WrappingKeys;
|
|
66
|
+
/** Open something the server sealed to one of those public keys. */
|
|
67
|
+
export declare function openSealedToFactor(args: {
|
|
68
|
+
custodianKey: string;
|
|
69
|
+
factor: EnvelopeKind;
|
|
70
|
+
box: SealedToKey;
|
|
71
|
+
passkeyPrfOutput?: Uint8Array;
|
|
72
|
+
phraseWords?: string[];
|
|
73
|
+
phraseSalt?: string;
|
|
74
|
+
}): Uint8Array;
|
|
75
|
+
/** The aad the server must use when sealing to a custodian's factor key. */
|
|
76
|
+
export declare const factorWrapAad: (custodianKey: string, factor: EnvelopeKind) => string;
|
|
77
|
+
/**
|
|
78
|
+
* A share plus a probe, sealed together.
|
|
79
|
+
*
|
|
80
|
+
* The probe is DIFFERENT in each envelope, and that difference is what makes
|
|
81
|
+
* the two factor tests independent. Sealing only the share would let a client
|
|
82
|
+
* that opened the passkey envelope answer the phrase test with the same bytes —
|
|
83
|
+
* proving one factor and being credited with both, which is the attestation
|
|
84
|
+
* problem this whole change exists to remove.
|
|
85
|
+
*
|
|
86
|
+
* Fixed 32-byte probe at the front, so splitting needs no length prefix and a
|
|
87
|
+
* truncated envelope fails the GCM tag rather than being parsed.
|
|
88
|
+
*/
|
|
89
|
+
export declare const PROBE_BYTES = 32;
|
|
90
|
+
export declare function joinProbeAndShare(probe: Uint8Array, share: Uint8Array): Uint8Array;
|
|
91
|
+
export declare function splitProbeAndShare(opened: Uint8Array): {
|
|
92
|
+
probe: Uint8Array;
|
|
93
|
+
share: Uint8Array;
|
|
94
|
+
};
|
|
95
|
+
/**
|
|
96
|
+
* Open one factor's envelope from the sealed record.
|
|
97
|
+
*
|
|
98
|
+
* Takes the whole record rather than a single box, because the phrase salt
|
|
99
|
+
* lives IN that record and is public — asking every caller to carry it
|
|
100
|
+
* separately meant each one had somewhere to lose it, and losing it surfaces as
|
|
101
|
+
* `atob` complaining about invalid characters rather than as anything about
|
|
102
|
+
* custody.
|
|
103
|
+
*/
|
|
104
|
+
export declare function openFactorEnvelope(args: {
|
|
105
|
+
sealed: SealedShare & {
|
|
106
|
+
passkeyEnvelope: SealedToKey;
|
|
107
|
+
phraseEnvelope: SealedToKey;
|
|
108
|
+
};
|
|
109
|
+
custodianKey: string;
|
|
110
|
+
factor: EnvelopeKind;
|
|
111
|
+
passkeyPrfOutput?: Uint8Array;
|
|
112
|
+
phraseWords?: string[];
|
|
113
|
+
}): {
|
|
114
|
+
probe: Uint8Array;
|
|
115
|
+
share: Uint8Array;
|
|
116
|
+
};
|
|
117
|
+
export {};
|