@forgezero/runtime 0.1.1 → 0.1.3
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/identity.d.ts +25 -0
- package/dist/identity.js +99 -3
- package/dist/jobs.d.ts +3 -2
- package/dist/jobs.js +288 -13
- package/dist/pipeline.d.ts +11 -37
- package/dist/pipeline.js +0 -27
- package/dist/queue.d.ts +17 -3
- package/dist/queue.js +86 -19
- package/dist/snp.d.ts +7 -6
- package/dist/snp.js +6 -1
- package/package.json +6 -9
- package/dist/finance/binance.d.ts +0 -27
- package/dist/finance/binance.js +0 -452
- package/dist/serial.d.ts +0 -54
- package/dist/serial.js +0 -40
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) => {
|
package/dist/identity.d.ts
CHANGED
|
@@ -64,6 +64,28 @@ export interface SignedEnvelope {
|
|
|
64
64
|
edSignature: string;
|
|
65
65
|
mlDsaSignature: string;
|
|
66
66
|
}
|
|
67
|
+
export interface ResponseRecipient {
|
|
68
|
+
publicKey: string;
|
|
69
|
+
secretKey: string;
|
|
70
|
+
}
|
|
71
|
+
export interface SealedResponse {
|
|
72
|
+
version: 1;
|
|
73
|
+
kemCiphertext: string;
|
|
74
|
+
nonce: string;
|
|
75
|
+
ciphertext: string;
|
|
76
|
+
}
|
|
77
|
+
export declare const RESPONSE_KEY_HEADER = "x-fz-response-key";
|
|
78
|
+
export declare function validResponsePublicKey(value: string): boolean;
|
|
79
|
+
/** One ephemeral hybrid ML-KEM-768 + X25519 recipient per request. */
|
|
80
|
+
export declare function generateResponseRecipient(): ResponseRecipient;
|
|
81
|
+
/** Seal one JSON response so TLS termination never sees the secret payload. */
|
|
82
|
+
export declare function sealResponse<T>(recipientPublicKey: string, requestBinding: string, payload: T): Promise<SealedResponse>;
|
|
83
|
+
/** Open a response only with the request's ephemeral private half and exact signature binding. */
|
|
84
|
+
export declare function openResponse<T>(recipientSecretKey: string, requestBinding: string, envelope: SealedResponse): Promise<T>;
|
|
85
|
+
/** One wire encoding for Agent and external API-key hybrid signatures. */
|
|
86
|
+
export declare function encodeSignatureHeader(envelope: SignedEnvelope): string;
|
|
87
|
+
/** Parse the common wire encoding and bind its public key id from the companion header. */
|
|
88
|
+
export declare function decodeSignatureHeader(raw: string, nodeKey: string): SignedEnvelope | null;
|
|
67
89
|
/**
|
|
68
90
|
* The bytes that get signed.
|
|
69
91
|
*
|
|
@@ -79,12 +101,14 @@ export declare function canonicalString(args: {
|
|
|
79
101
|
timestamp: number;
|
|
80
102
|
nonce: string;
|
|
81
103
|
body: string | Uint8Array;
|
|
104
|
+
responseKey?: string;
|
|
82
105
|
}): string;
|
|
83
106
|
export declare function signRequest(keys: NodeKeyPair, nodeKey: string, args: {
|
|
84
107
|
method: string;
|
|
85
108
|
path: string;
|
|
86
109
|
query?: string;
|
|
87
110
|
body: string | Uint8Array;
|
|
111
|
+
responseKey?: string;
|
|
88
112
|
}): SignedEnvelope;
|
|
89
113
|
export type VerifyFailure = 'timestamp_out_of_window' | 'ed25519_invalid' | 'ml_dsa_invalid' | 'malformed';
|
|
90
114
|
/** Requests older or newer than this are refused before any signature work. */
|
|
@@ -106,6 +130,7 @@ export declare function verifyRequest(args: {
|
|
|
106
130
|
path: string;
|
|
107
131
|
query?: string;
|
|
108
132
|
body: string | Uint8Array;
|
|
133
|
+
responseKey?: string;
|
|
109
134
|
nowSeconds?: number;
|
|
110
135
|
}): {
|
|
111
136
|
verified: true;
|
package/dist/identity.js
CHANGED
|
@@ -10,6 +10,7 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
10
10
|
import { toBase64Url, fromBase64Url } from "@forgezero/access/security";
|
|
11
11
|
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
12
12
|
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
13
|
+
import { ml_kem768_x25519 } from "@noble/post-quantum/hybrid.js";
|
|
13
14
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
14
15
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
15
16
|
var ENCODER = new TextEncoder;
|
|
@@ -41,17 +42,104 @@ function generateNodeKeys() {
|
|
|
41
42
|
mlDsa: { publicKey: b64(mlKeys.publicKey), secretKey: b64(mlKeys.secretKey) }
|
|
42
43
|
};
|
|
43
44
|
}
|
|
45
|
+
var RESPONSE_KEY_HEADER = "x-fz-response-key";
|
|
46
|
+
function validResponsePublicKey(value) {
|
|
47
|
+
try {
|
|
48
|
+
return un64(value).length === ml_kem768_x25519.lengths.publicKey;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function generateResponseRecipient() {
|
|
54
|
+
const pair = ml_kem768_x25519.keygen();
|
|
55
|
+
return { publicKey: b64(pair.publicKey), secretKey: b64(pair.secretKey) };
|
|
56
|
+
}
|
|
57
|
+
var responseKey = (sharedSecret) => hkdf(sha256, sharedSecret, undefined, ENCODER.encode("forgezero/response/ml-kem-768+x25519/v1"), 32);
|
|
58
|
+
async function sealResponse(recipientPublicKey, requestBinding, payload) {
|
|
59
|
+
const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(un64(recipientPublicKey));
|
|
60
|
+
const rawKey = responseKey(sharedSecret);
|
|
61
|
+
sharedSecret.fill(0);
|
|
62
|
+
const key = await crypto.subtle.importKey("raw", new Uint8Array(rawKey), "AES-GCM", false, ["encrypt"]);
|
|
63
|
+
rawKey.fill(0);
|
|
64
|
+
const nonce = randomBytes(12);
|
|
65
|
+
const serialized = JSON.stringify(payload);
|
|
66
|
+
if (serialized === undefined)
|
|
67
|
+
throw new Error("response: payload is not JSON serializable");
|
|
68
|
+
const plaintext = ENCODER.encode(serialized);
|
|
69
|
+
const ciphertext = await crypto.subtle.encrypt({
|
|
70
|
+
name: "AES-GCM",
|
|
71
|
+
iv: new Uint8Array(nonce),
|
|
72
|
+
additionalData: new Uint8Array(ENCODER.encode(requestBinding)),
|
|
73
|
+
tagLength: 128
|
|
74
|
+
}, key, new Uint8Array(plaintext));
|
|
75
|
+
plaintext.fill(0);
|
|
76
|
+
return {
|
|
77
|
+
version: 1,
|
|
78
|
+
kemCiphertext: b64(cipherText),
|
|
79
|
+
nonce: b64(nonce),
|
|
80
|
+
ciphertext: b64(new Uint8Array(ciphertext))
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
async function openResponse(recipientSecretKey, requestBinding, envelope) {
|
|
84
|
+
if (envelope?.version !== 1)
|
|
85
|
+
throw new Error("response: unsupported sealed response");
|
|
86
|
+
const sharedSecret = ml_kem768_x25519.decapsulate(un64(envelope.kemCiphertext), un64(recipientSecretKey));
|
|
87
|
+
const rawKey = responseKey(sharedSecret);
|
|
88
|
+
sharedSecret.fill(0);
|
|
89
|
+
const key = await crypto.subtle.importKey("raw", new Uint8Array(rawKey), "AES-GCM", false, ["decrypt"]);
|
|
90
|
+
rawKey.fill(0);
|
|
91
|
+
const decrypted = new Uint8Array(await crypto.subtle.decrypt({
|
|
92
|
+
name: "AES-GCM",
|
|
93
|
+
iv: new Uint8Array(un64(envelope.nonce)),
|
|
94
|
+
additionalData: new Uint8Array(ENCODER.encode(requestBinding)),
|
|
95
|
+
tagLength: 128
|
|
96
|
+
}, key, new Uint8Array(un64(envelope.ciphertext))));
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
99
|
+
} finally {
|
|
100
|
+
decrypted.fill(0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
var SIGNATURE_FIELDS = (envelope) => ({
|
|
104
|
+
timestamp: envelope.timestamp,
|
|
105
|
+
nonce: envelope.nonce,
|
|
106
|
+
edSignature: envelope.edSignature,
|
|
107
|
+
mlDsaSignature: envelope.mlDsaSignature
|
|
108
|
+
});
|
|
109
|
+
function encodeSignatureHeader(envelope) {
|
|
110
|
+
return b64(ENCODER.encode(JSON.stringify(SIGNATURE_FIELDS(envelope))));
|
|
111
|
+
}
|
|
112
|
+
function decodeSignatureHeader(raw, nodeKey) {
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(new TextDecoder().decode(un64(raw)));
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (typeof parsed.timestamp !== "number" || !Number.isSafeInteger(parsed.timestamp) || typeof parsed.nonce !== "string" || typeof parsed.edSignature !== "string" || typeof parsed.mlDsaSignature !== "string")
|
|
120
|
+
return null;
|
|
121
|
+
return {
|
|
122
|
+
nodeKey,
|
|
123
|
+
timestamp: parsed.timestamp,
|
|
124
|
+
nonce: parsed.nonce,
|
|
125
|
+
edSignature: parsed.edSignature,
|
|
126
|
+
mlDsaSignature: parsed.mlDsaSignature
|
|
127
|
+
};
|
|
128
|
+
}
|
|
44
129
|
function canonicalString(args) {
|
|
45
130
|
const body = typeof args.body === "string" ? ENCODER.encode(args.body) : args.body;
|
|
46
131
|
const digest = Array.from(sha256(body), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
47
|
-
|
|
132
|
+
const fields = [
|
|
48
133
|
args.method.toUpperCase(),
|
|
49
134
|
args.path,
|
|
50
135
|
args.query ?? "",
|
|
51
136
|
String(args.timestamp),
|
|
52
137
|
args.nonce,
|
|
53
138
|
digest
|
|
54
|
-
]
|
|
139
|
+
];
|
|
140
|
+
if (args.responseKey)
|
|
141
|
+
fields.push(args.responseKey);
|
|
142
|
+
return fields.join(`
|
|
55
143
|
`);
|
|
56
144
|
}
|
|
57
145
|
function signRequest(keys, nodeKey, args) {
|
|
@@ -80,7 +168,8 @@ function verifyRequest(args) {
|
|
|
80
168
|
query: args.query ?? "",
|
|
81
169
|
timestamp: args.envelope.timestamp,
|
|
82
170
|
nonce: args.envelope.nonce,
|
|
83
|
-
body: args.body
|
|
171
|
+
body: args.body,
|
|
172
|
+
responseKey: args.responseKey
|
|
84
173
|
}));
|
|
85
174
|
} catch {
|
|
86
175
|
return { verified: false, reason: "malformed" };
|
|
@@ -103,9 +192,16 @@ function verifyRequest(args) {
|
|
|
103
192
|
}
|
|
104
193
|
export {
|
|
105
194
|
verifyRequest,
|
|
195
|
+
validResponsePublicKey,
|
|
106
196
|
signRequest,
|
|
197
|
+
sealResponse,
|
|
198
|
+
openResponse,
|
|
199
|
+
generateResponseRecipient,
|
|
107
200
|
generateNodeKeys,
|
|
201
|
+
encodeSignatureHeader,
|
|
108
202
|
deriveKeysFromSeed,
|
|
203
|
+
decodeSignatureHeader,
|
|
109
204
|
canonicalString,
|
|
205
|
+
RESPONSE_KEY_HEADER,
|
|
110
206
|
CLOCK_SKEW_SECONDS
|
|
111
207
|
};
|
package/dist/jobs.d.ts
CHANGED
|
@@ -15,11 +15,12 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Each has a specific answer here: reschedule after completion rather than on
|
|
17
17
|
* an interval, advance only on a fully successful batch, take a fenced lock,
|
|
18
|
-
* and
|
|
18
|
+
* and submit every run through the common awaited queue, which drains on stop.
|
|
19
19
|
*
|
|
20
20
|
* Zero dependencies. The lock and the cursor store are interfaces, so this runs
|
|
21
21
|
* against a database, Redis, or nothing at all in a test.
|
|
22
22
|
*/
|
|
23
|
+
import { type DrainReport } from './queue';
|
|
23
24
|
/** Injected so a test does not sleep and a resumed run is reproducible. */
|
|
24
25
|
export interface Clock {
|
|
25
26
|
now(): number;
|
|
@@ -122,7 +123,7 @@ export declare function createScheduler(options: SchedulerOptions): {
|
|
|
122
123
|
* leaves a record half-written — the process exits mid-transaction and the
|
|
123
124
|
* next boot finds state nothing can explain.
|
|
124
125
|
*/
|
|
125
|
-
stop(): Promise<
|
|
126
|
+
stop(deadlineMs?: number): Promise<DrainReport>;
|
|
126
127
|
/** Timers stop firing; in-flight work is left to finish. */
|
|
127
128
|
pause(): void;
|
|
128
129
|
resume(): void;
|