@forgezero/runtime 0.1.4 → 0.1.6
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 +24 -2
- package/dist/backup.d.ts +19 -4
- package/dist/backup.js +252 -45
- package/dist/custody-crypto.d.ts +7 -5
- package/dist/custody-crypto.js +43 -21
- package/dist/custody-share.d.ts +5 -0
- package/dist/custody-share.js +46 -21
- package/dist/finance/chain-deposits.js +5 -1
- package/dist/finance/chain-withdrawals.js +5 -1
- package/dist/finance/commission.js +5 -1
- package/dist/finance/ledger.d.ts +8 -2
- package/dist/finance/ledger.js +6 -1
- package/dist/identity.d.ts +11 -3
- package/dist/identity.js +109 -14
- package/dist/jobs.d.ts +2 -0
- package/dist/jobs.js +9 -2
- package/dist/passkey-hybrid.d.ts +37 -0
- package/dist/passkey-hybrid.js +111 -0
- package/dist/query.d.ts +47 -0
- package/dist/query.js +51 -0
- package/dist/realtime.d.ts +68 -0
- package/dist/realtime.js +184 -0
- package/dist/schema-typebox.d.ts +3 -0
- package/dist/schema-typebox.js +32 -2
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +15 -2
- package/package.json +18 -10
- package/dist/ssh-agent.d.ts +0 -83
- package/dist/ssh-agent.js +0 -147
package/dist/custody-share.js
CHANGED
|
@@ -8,9 +8,9 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
|
|
|
8
8
|
|
|
9
9
|
// src/custody-crypto.ts
|
|
10
10
|
import { gcm } from "@noble/ciphers/aes.js";
|
|
11
|
-
import { x25519 } from "@noble/curves/ed25519.js";
|
|
12
11
|
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
13
12
|
import { sha256 } from "@noble/hashes/sha2.js";
|
|
13
|
+
import { ml_kem768_x25519 } from "@noble/post-quantum/hybrid.js";
|
|
14
14
|
var KEY_BYTES = 32;
|
|
15
15
|
var NONCE_BYTES = 12;
|
|
16
16
|
var toBase64 = (bytes) => {
|
|
@@ -44,40 +44,62 @@ function openWithKey(key, box, aad) {
|
|
|
44
44
|
throw new Error(`custody: unknown algorithm ${box.alg}`);
|
|
45
45
|
return gcm(key, fromBase64(box.nonce), utf8(aad)).decrypt(fromBase64(box.ciphertext));
|
|
46
46
|
}
|
|
47
|
-
var WRAP_INFO = "forgezero:custody:wrap:
|
|
48
|
-
var
|
|
49
|
-
|
|
50
|
-
const out = new Uint8Array(left.length + right.length);
|
|
51
|
-
out.set(left, 0);
|
|
52
|
-
out.set(right, left.length);
|
|
53
|
-
return out;
|
|
54
|
-
}
|
|
47
|
+
var WRAP_INFO = "forgezero:custody:wrap:ml-kem-768+x25519:v2";
|
|
48
|
+
var WRAP_SEED_SALT = utf8("forgezero:custody:wrapkey:ml-kem-768+x25519:v2");
|
|
49
|
+
var wrapKey = (shared) => hkdf(sha256, shared, undefined, utf8(WRAP_INFO), 32);
|
|
55
50
|
function sealToKey(recipientPublicKey, plaintext, aad) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
51
|
+
if (recipientPublicKey.length !== ml_kem768_x25519.lengths.publicKey) {
|
|
52
|
+
throw new Error("custody: invalid hybrid recipient public key");
|
|
53
|
+
}
|
|
54
|
+
const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(recipientPublicKey);
|
|
55
|
+
const shared = Uint8Array.from(sharedSecret);
|
|
56
|
+
const ciphertext = Uint8Array.from(cipherText);
|
|
57
|
+
const key = wrapKey(shared);
|
|
60
58
|
try {
|
|
61
|
-
return {
|
|
59
|
+
return {
|
|
60
|
+
version: 2,
|
|
61
|
+
...sealWithKey(key, plaintext, aad),
|
|
62
|
+
kemCiphertext: toBase64(ciphertext)
|
|
63
|
+
};
|
|
62
64
|
} finally {
|
|
63
65
|
key.fill(0);
|
|
64
|
-
|
|
66
|
+
shared.fill(0);
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
function openFromKey(recipientSecretKey, box, aad) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
if (box?.version !== 2 || typeof box.kemCiphertext !== "string") {
|
|
71
|
+
throw new Error("custody: unsupported sealed-to-key envelope");
|
|
72
|
+
}
|
|
73
|
+
if (recipientSecretKey.length !== ml_kem768_x25519.lengths.secretKey) {
|
|
74
|
+
throw new Error("custody: invalid hybrid recipient secret key");
|
|
75
|
+
}
|
|
76
|
+
const ciphertext = fromBase64(box.kemCiphertext);
|
|
77
|
+
if (ciphertext.length !== ml_kem768_x25519.lengths.cipherText) {
|
|
78
|
+
throw new Error("custody: invalid hybrid KEM ciphertext");
|
|
79
|
+
}
|
|
80
|
+
const shared = Uint8Array.from(ml_kem768_x25519.decapsulate(ciphertext, recipientSecretKey));
|
|
81
|
+
const key = wrapKey(shared);
|
|
72
82
|
try {
|
|
73
83
|
return openWithKey(key, box, aad);
|
|
74
84
|
} finally {
|
|
75
85
|
key.fill(0);
|
|
86
|
+
shared.fill(0);
|
|
76
87
|
}
|
|
77
88
|
}
|
|
78
89
|
function wrappingKeyPair(factorMaterial, info) {
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
if (factorMaterial.length < 32) {
|
|
91
|
+
throw new Error("custody: wrapping factor material must be at least 32 bytes");
|
|
92
|
+
}
|
|
93
|
+
const seed = hkdf(sha256, factorMaterial, WRAP_SEED_SALT, utf8(info), 32);
|
|
94
|
+
try {
|
|
95
|
+
const pair = ml_kem768_x25519.keygen(seed);
|
|
96
|
+
return {
|
|
97
|
+
secretKey: Uint8Array.from(pair.secretKey),
|
|
98
|
+
publicKey: Uint8Array.from(pair.publicKey)
|
|
99
|
+
};
|
|
100
|
+
} finally {
|
|
101
|
+
seed.fill(0);
|
|
102
|
+
}
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
// src/phrase.ts
|
|
@@ -247,11 +269,14 @@ function phraseWrappingKey(custodianKey, phraseWords, salt) {
|
|
|
247
269
|
return wrappingKeyPair(phraseToKey(phraseWords, salt), wrapInfo(custodianKey, "phrase"));
|
|
248
270
|
}
|
|
249
271
|
function wrappingKeysFor(args) {
|
|
272
|
+
if (!args.passkeyCredentialId)
|
|
273
|
+
throw new Error("custody-share: passkey credential id is required");
|
|
250
274
|
const salt = newSalt();
|
|
251
275
|
const passkey = passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput);
|
|
252
276
|
const phrase = phraseWrappingKey(args.custodianKey, args.phraseWords, salt);
|
|
253
277
|
try {
|
|
254
278
|
return {
|
|
279
|
+
passkeyCredentialId: args.passkeyCredentialId,
|
|
255
280
|
passkeyPublicKey: toBase642(passkey.publicKey),
|
|
256
281
|
phrasePublicKey: toBase642(phrase.publicKey),
|
|
257
282
|
phraseSalt: toBase642(salt),
|
|
@@ -168,10 +168,14 @@ function parseAccount(id) {
|
|
|
168
168
|
return { kind, owner, bucket };
|
|
169
169
|
}
|
|
170
170
|
var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
|
|
171
|
+
var MAX_LEDGER_ENTRIES = 64;
|
|
171
172
|
function assertBalanced(transaction) {
|
|
172
|
-
if (transaction.entries.length
|
|
173
|
+
if (transaction.entries.length < 2) {
|
|
173
174
|
throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
|
|
174
175
|
}
|
|
176
|
+
if (transaction.entries.length > MAX_LEDGER_ENTRIES) {
|
|
177
|
+
throw new LedgerError("TOO_MANY_ENTRIES", `A transaction may contain at most ${MAX_LEDGER_ENTRIES} entries; split this batch into separate transactions.`);
|
|
178
|
+
}
|
|
175
179
|
const totals = new Map;
|
|
176
180
|
for (const entry of transaction.entries) {
|
|
177
181
|
if (entry.amount.units === 0n) {
|
|
@@ -168,10 +168,14 @@ function parseAccount(id) {
|
|
|
168
168
|
return { kind, owner, bucket };
|
|
169
169
|
}
|
|
170
170
|
var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
|
|
171
|
+
var MAX_LEDGER_ENTRIES = 64;
|
|
171
172
|
function assertBalanced(transaction) {
|
|
172
|
-
if (transaction.entries.length
|
|
173
|
+
if (transaction.entries.length < 2) {
|
|
173
174
|
throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
|
|
174
175
|
}
|
|
176
|
+
if (transaction.entries.length > MAX_LEDGER_ENTRIES) {
|
|
177
|
+
throw new LedgerError("TOO_MANY_ENTRIES", `A transaction may contain at most ${MAX_LEDGER_ENTRIES} entries; split this batch into separate transactions.`);
|
|
178
|
+
}
|
|
175
179
|
const totals = new Map;
|
|
176
180
|
for (const entry of transaction.entries) {
|
|
177
181
|
if (entry.amount.units === 0n) {
|
|
@@ -168,10 +168,14 @@ function parseAccount(id) {
|
|
|
168
168
|
return { kind, owner, bucket };
|
|
169
169
|
}
|
|
170
170
|
var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
|
|
171
|
+
var MAX_LEDGER_ENTRIES = 64;
|
|
171
172
|
function assertBalanced(transaction) {
|
|
172
|
-
if (transaction.entries.length
|
|
173
|
+
if (transaction.entries.length < 2) {
|
|
173
174
|
throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
|
|
174
175
|
}
|
|
176
|
+
if (transaction.entries.length > MAX_LEDGER_ENTRIES) {
|
|
177
|
+
throw new LedgerError("TOO_MANY_ENTRIES", `A transaction may contain at most ${MAX_LEDGER_ENTRIES} entries; split this batch into separate transactions.`);
|
|
178
|
+
}
|
|
175
179
|
const totals = new Map;
|
|
176
180
|
for (const entry of transaction.entries) {
|
|
177
181
|
if (entry.amount.units === 0n) {
|
package/dist/finance/ledger.d.ts
CHANGED
|
@@ -33,8 +33,8 @@ import { type Money } from './money';
|
|
|
33
33
|
* must balance, so a split that loses a unit cannot be committed at all.
|
|
34
34
|
*/
|
|
35
35
|
export declare class LedgerError extends Error {
|
|
36
|
-
readonly code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE';
|
|
37
|
-
constructor(code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE', message: string);
|
|
36
|
+
readonly code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'TOO_MANY_ENTRIES' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE';
|
|
37
|
+
constructor(code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'TOO_MANY_ENTRIES' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE', message: string);
|
|
38
38
|
}
|
|
39
39
|
/**
|
|
40
40
|
* The sub-accounts every owner has.
|
|
@@ -96,6 +96,12 @@ export interface Transaction {
|
|
|
96
96
|
atMs: number;
|
|
97
97
|
memo?: string;
|
|
98
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* A ledger transaction is one atomic business movement, not a batch transport.
|
|
101
|
+
* Keeping the posting set small bounds both the durable document and the
|
|
102
|
+
* multikey `accountIds[*]` index entry fan-out derived from it by the API.
|
|
103
|
+
*/
|
|
104
|
+
export declare const MAX_LEDGER_ENTRIES = 64;
|
|
99
105
|
/**
|
|
100
106
|
* Every transaction sums to zero, per asset.
|
|
101
107
|
*
|
package/dist/finance/ledger.js
CHANGED
|
@@ -168,10 +168,14 @@ function parseAccount(id) {
|
|
|
168
168
|
return { kind, owner, bucket };
|
|
169
169
|
}
|
|
170
170
|
var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
|
|
171
|
+
var MAX_LEDGER_ENTRIES = 64;
|
|
171
172
|
function assertBalanced(transaction) {
|
|
172
|
-
if (transaction.entries.length
|
|
173
|
+
if (transaction.entries.length < 2) {
|
|
173
174
|
throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
|
|
174
175
|
}
|
|
176
|
+
if (transaction.entries.length > MAX_LEDGER_ENTRIES) {
|
|
177
|
+
throw new LedgerError("TOO_MANY_ENTRIES", `A transaction may contain at most ${MAX_LEDGER_ENTRIES} entries; split this batch into separate transactions.`);
|
|
178
|
+
}
|
|
175
179
|
const totals = new Map;
|
|
176
180
|
for (const entry of transaction.entries) {
|
|
177
181
|
if (entry.amount.units === 0n) {
|
|
@@ -302,6 +306,7 @@ export {
|
|
|
302
306
|
assertAvailable,
|
|
303
307
|
accountId,
|
|
304
308
|
VERSION2 as VERSION,
|
|
309
|
+
MAX_LEDGER_ENTRIES,
|
|
305
310
|
LedgerError,
|
|
306
311
|
BUCKETS,
|
|
307
312
|
ACCOUNT_KINDS
|
package/dist/identity.d.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* are young; Ed25519 is not. Requiring both means a break in either one leaves
|
|
10
10
|
* the other standing, and the cost is a few hundred bytes per request.
|
|
11
11
|
*
|
|
12
|
-
* This is the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* This is the machine-identity use of asymmetric post-quantum crypto. Browser
|
|
13
|
+
* passkeys have a separate PRF-derived ML-DSA companion in passkey-hybrid.ts,
|
|
14
|
+
* while persisted custody remains symmetric and Shamir-based.
|
|
15
15
|
*
|
|
16
16
|
* ## Where the primitives come from
|
|
17
17
|
*
|
|
@@ -55,9 +55,13 @@ export interface NodePublicKeys {
|
|
|
55
55
|
* break in one leaves the other genuinely independent.
|
|
56
56
|
*/
|
|
57
57
|
export declare function deriveKeysFromSeed(seed: Uint8Array): NodeKeyPair;
|
|
58
|
+
/** Derive only public halves for server-side credential issuance, then erase every private byte. */
|
|
59
|
+
export declare function derivePublicKeysFromSeed(seed: Uint8Array): NodePublicKeys;
|
|
58
60
|
/** Generate a node key pair. The secret halves never leave the caller. */
|
|
59
61
|
export declare function generateNodeKeys(): NodeKeyPair;
|
|
60
62
|
export interface SignedEnvelope {
|
|
63
|
+
version: 1;
|
|
64
|
+
suite: typeof REQUEST_SIGNATURE_SUITE;
|
|
61
65
|
nodeKey: string;
|
|
62
66
|
timestamp: number;
|
|
63
67
|
nonce: string;
|
|
@@ -70,11 +74,14 @@ export interface ResponseRecipient {
|
|
|
70
74
|
}
|
|
71
75
|
export interface SealedResponse {
|
|
72
76
|
version: 1;
|
|
77
|
+
suite: typeof RESPONSE_SEALING_SUITE;
|
|
73
78
|
kemCiphertext: string;
|
|
74
79
|
nonce: string;
|
|
75
80
|
ciphertext: string;
|
|
76
81
|
}
|
|
77
82
|
export declare const RESPONSE_KEY_HEADER = "x-fz-response-key";
|
|
83
|
+
export declare const REQUEST_SIGNATURE_SUITE: "ed25519+ml-dsa-65";
|
|
84
|
+
export declare const RESPONSE_SEALING_SUITE: "ml-kem-768+x25519/aes-256-gcm";
|
|
78
85
|
export declare function validResponsePublicKey(value: string): boolean;
|
|
79
86
|
/** One ephemeral hybrid ML-KEM-768 + X25519 recipient per request. */
|
|
80
87
|
export declare function generateResponseRecipient(): ResponseRecipient;
|
|
@@ -95,6 +102,7 @@ export declare function decodeSignatureHeader(raw: string, nodeKey: string): Sig
|
|
|
95
102
|
* large upload does not have to be buffered twice.
|
|
96
103
|
*/
|
|
97
104
|
export declare function canonicalString(args: {
|
|
105
|
+
identity: string;
|
|
98
106
|
method: string;
|
|
99
107
|
path: string;
|
|
100
108
|
query: string;
|
package/dist/identity.js
CHANGED
|
@@ -26,10 +26,26 @@ function deriveKeysFromSeed(seed) {
|
|
|
26
26
|
const edPublic = ed25519.getPublicKey(edSecret);
|
|
27
27
|
const mlKeys = ml_dsa65.keygen(mlSeed);
|
|
28
28
|
mlSeed.fill(0);
|
|
29
|
-
|
|
29
|
+
const result = {
|
|
30
30
|
ed25519: { publicKey: b64(edPublic), secretKey: b64(edSecret) },
|
|
31
31
|
mlDsa: { publicKey: b64(mlKeys.publicKey), secretKey: b64(mlKeys.secretKey) }
|
|
32
32
|
};
|
|
33
|
+
edSecret.fill(0);
|
|
34
|
+
mlKeys.secretKey.fill(0);
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
function derivePublicKeysFromSeed(seed) {
|
|
38
|
+
if (seed.length < 32)
|
|
39
|
+
throw new Error("identity: seed must be at least 32 bytes");
|
|
40
|
+
const edSecret = hkdf(sha256, seed, undefined, ENCODER.encode("forgezero/identity/ed25519/v1"), 32);
|
|
41
|
+
const mlSeed = hkdf(sha256, seed, undefined, ENCODER.encode("forgezero/identity/ml-dsa-65/v1"), 32);
|
|
42
|
+
const edPublic = ed25519.getPublicKey(edSecret);
|
|
43
|
+
const mlKeys = ml_dsa65.keygen(mlSeed);
|
|
44
|
+
const result = { ed25519: b64(edPublic), mlDsa: b64(mlKeys.publicKey) };
|
|
45
|
+
edSecret.fill(0);
|
|
46
|
+
mlSeed.fill(0);
|
|
47
|
+
mlKeys.secretKey.fill(0);
|
|
48
|
+
return result;
|
|
33
49
|
}
|
|
34
50
|
function generateNodeKeys() {
|
|
35
51
|
const edSecret = ed25519.utils.randomSecretKey();
|
|
@@ -37,26 +53,60 @@ function generateNodeKeys() {
|
|
|
37
53
|
const seed = randomBytes(32);
|
|
38
54
|
const mlKeys = ml_dsa65.keygen(seed);
|
|
39
55
|
seed.fill(0);
|
|
40
|
-
|
|
56
|
+
const result = {
|
|
41
57
|
ed25519: { publicKey: b64(edPublic), secretKey: b64(edSecret) },
|
|
42
58
|
mlDsa: { publicKey: b64(mlKeys.publicKey), secretKey: b64(mlKeys.secretKey) }
|
|
43
59
|
};
|
|
60
|
+
edSecret.fill(0);
|
|
61
|
+
mlKeys.secretKey.fill(0);
|
|
62
|
+
return result;
|
|
44
63
|
}
|
|
45
64
|
var RESPONSE_KEY_HEADER = "x-fz-response-key";
|
|
46
|
-
|
|
65
|
+
var REQUEST_SIGNATURE_SUITE = "ed25519+ml-dsa-65";
|
|
66
|
+
var RESPONSE_SEALING_SUITE = "ml-kem-768+x25519/aes-256-gcm";
|
|
67
|
+
var BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
68
|
+
var encodedLength = (bytes) => Math.ceil(bytes * 4 / 3);
|
|
69
|
+
var length = (value, name) => {
|
|
70
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
71
|
+
throw new Error(`identity: ${name} length unavailable`);
|
|
72
|
+
return value;
|
|
73
|
+
};
|
|
74
|
+
var ED_PUBLIC_BYTES = length(ed25519.lengths.publicKey, "Ed25519 public key");
|
|
75
|
+
var ED_SIGNATURE_BYTES = length(ed25519.lengths.signature, "Ed25519 signature");
|
|
76
|
+
var ML_PUBLIC_BYTES = length(ml_dsa65.lengths.publicKey, "ML-DSA-65 public key");
|
|
77
|
+
var ML_SIGNATURE_BYTES = length(ml_dsa65.lengths.signature, "ML-DSA-65 signature");
|
|
78
|
+
var RESPONSE_PUBLIC_BYTES = length(ml_kem768_x25519.lengths.publicKey, "hybrid response public key");
|
|
79
|
+
var RESPONSE_SECRET_BYTES = length(ml_kem768_x25519.lengths.secretKey, "hybrid response secret key");
|
|
80
|
+
var RESPONSE_CIPHERTEXT_BYTES = length(ml_kem768_x25519.lengths.cipherText, "hybrid response ciphertext");
|
|
81
|
+
function exactBytes(value, bytes) {
|
|
82
|
+
if (typeof value !== "string" || value.length !== encodedLength(bytes) || !BASE64URL.test(value))
|
|
83
|
+
return null;
|
|
47
84
|
try {
|
|
48
|
-
|
|
85
|
+
const decoded = un64(value);
|
|
86
|
+
return decoded.length === bytes && b64(decoded) === value ? decoded : null;
|
|
49
87
|
} catch {
|
|
50
|
-
return
|
|
88
|
+
return null;
|
|
51
89
|
}
|
|
52
90
|
}
|
|
91
|
+
function validIdentity(value) {
|
|
92
|
+
return typeof value === "string" && value.length >= 1 && value.length <= 128 && !/[^A-Za-z0-9_.:@/-]/.test(value) && !/[\0\r\n]/.test(value);
|
|
93
|
+
}
|
|
94
|
+
function validResponsePublicKey(value) {
|
|
95
|
+
return exactBytes(value, RESPONSE_PUBLIC_BYTES) !== null;
|
|
96
|
+
}
|
|
53
97
|
function generateResponseRecipient() {
|
|
54
98
|
const pair = ml_kem768_x25519.keygen();
|
|
55
99
|
return { publicKey: b64(pair.publicKey), secretKey: b64(pair.secretKey) };
|
|
56
100
|
}
|
|
57
101
|
var responseKey = (sharedSecret) => hkdf(sha256, sharedSecret, undefined, ENCODER.encode("forgezero/response/ml-kem-768+x25519/v1"), 32);
|
|
58
102
|
async function sealResponse(recipientPublicKey, requestBinding, payload) {
|
|
59
|
-
const
|
|
103
|
+
const recipient = exactBytes(recipientPublicKey, RESPONSE_PUBLIC_BYTES);
|
|
104
|
+
if (!recipient)
|
|
105
|
+
throw new Error("response: malformed recipient public key");
|
|
106
|
+
if (!requestBinding || requestBinding.length > 16384 || /[\0\r\n]/.test(requestBinding)) {
|
|
107
|
+
throw new Error("response: malformed request binding");
|
|
108
|
+
}
|
|
109
|
+
const { cipherText, sharedSecret } = ml_kem768_x25519.encapsulate(recipient);
|
|
60
110
|
const rawKey = responseKey(sharedSecret);
|
|
61
111
|
sharedSecret.fill(0);
|
|
62
112
|
const key = await crypto.subtle.importKey("raw", new Uint8Array(rawKey), "AES-GCM", false, ["encrypt"]);
|
|
@@ -66,6 +116,10 @@ async function sealResponse(recipientPublicKey, requestBinding, payload) {
|
|
|
66
116
|
if (serialized === undefined)
|
|
67
117
|
throw new Error("response: payload is not JSON serializable");
|
|
68
118
|
const plaintext = ENCODER.encode(serialized);
|
|
119
|
+
if (plaintext.length > 16 * 1024 * 1024 - 16) {
|
|
120
|
+
plaintext.fill(0);
|
|
121
|
+
throw new Error("response: payload exceeds sealed response limit");
|
|
122
|
+
}
|
|
69
123
|
const ciphertext = await crypto.subtle.encrypt({
|
|
70
124
|
name: "AES-GCM",
|
|
71
125
|
iv: new Uint8Array(nonce),
|
|
@@ -75,25 +129,46 @@ async function sealResponse(recipientPublicKey, requestBinding, payload) {
|
|
|
75
129
|
plaintext.fill(0);
|
|
76
130
|
return {
|
|
77
131
|
version: 1,
|
|
132
|
+
suite: RESPONSE_SEALING_SUITE,
|
|
78
133
|
kemCiphertext: b64(cipherText),
|
|
79
134
|
nonce: b64(nonce),
|
|
80
135
|
ciphertext: b64(new Uint8Array(ciphertext))
|
|
81
136
|
};
|
|
82
137
|
}
|
|
83
138
|
async function openResponse(recipientSecretKey, requestBinding, envelope) {
|
|
84
|
-
if (
|
|
139
|
+
if (!requestBinding || requestBinding.length > 16384 || /[\0\r\n]/.test(requestBinding)) {
|
|
140
|
+
throw new Error("response: malformed request binding");
|
|
141
|
+
}
|
|
142
|
+
if (envelope?.version !== 1 || envelope.suite !== RESPONSE_SEALING_SUITE) {
|
|
85
143
|
throw new Error("response: unsupported sealed response");
|
|
86
|
-
|
|
144
|
+
}
|
|
145
|
+
const kemCiphertext = exactBytes(envelope.kemCiphertext, RESPONSE_CIPHERTEXT_BYTES);
|
|
146
|
+
const nonce = exactBytes(envelope.nonce, 12);
|
|
147
|
+
if (!kemCiphertext || !nonce || typeof envelope.ciphertext !== "string" || envelope.ciphertext.length < encodedLength(16) || envelope.ciphertext.length > encodedLength(16 * 1024 * 1024) || !BASE64URL.test(envelope.ciphertext)) {
|
|
148
|
+
throw new Error("response: malformed sealed response");
|
|
149
|
+
}
|
|
150
|
+
let encodedCiphertext;
|
|
151
|
+
try {
|
|
152
|
+
encodedCiphertext = un64(envelope.ciphertext);
|
|
153
|
+
} catch {
|
|
154
|
+
throw new Error("response: malformed sealed response");
|
|
155
|
+
}
|
|
156
|
+
if (encodedCiphertext.length < 16 || encodedCiphertext.length > 16 * 1024 * 1024 || b64(encodedCiphertext) !== envelope.ciphertext) {
|
|
157
|
+
throw new Error("response: malformed sealed response");
|
|
158
|
+
}
|
|
159
|
+
const sharedSecret = ml_kem768_x25519.decapsulate(kemCiphertext, exactBytes(recipientSecretKey, RESPONSE_SECRET_BYTES) ?? (() => {
|
|
160
|
+
throw new Error("response: malformed recipient secret key");
|
|
161
|
+
})());
|
|
87
162
|
const rawKey = responseKey(sharedSecret);
|
|
88
163
|
sharedSecret.fill(0);
|
|
89
164
|
const key = await crypto.subtle.importKey("raw", new Uint8Array(rawKey), "AES-GCM", false, ["decrypt"]);
|
|
90
165
|
rawKey.fill(0);
|
|
91
166
|
const decrypted = new Uint8Array(await crypto.subtle.decrypt({
|
|
92
167
|
name: "AES-GCM",
|
|
93
|
-
iv: new Uint8Array(
|
|
168
|
+
iv: new Uint8Array(nonce),
|
|
94
169
|
additionalData: new Uint8Array(ENCODER.encode(requestBinding)),
|
|
95
170
|
tagLength: 128
|
|
96
|
-
}, key, new Uint8Array(
|
|
171
|
+
}, key, new Uint8Array(encodedCiphertext)));
|
|
97
172
|
try {
|
|
98
173
|
return JSON.parse(new TextDecoder().decode(decrypted));
|
|
99
174
|
} finally {
|
|
@@ -101,6 +176,8 @@ async function openResponse(recipientSecretKey, requestBinding, envelope) {
|
|
|
101
176
|
}
|
|
102
177
|
}
|
|
103
178
|
var SIGNATURE_FIELDS = (envelope) => ({
|
|
179
|
+
version: envelope.version,
|
|
180
|
+
suite: envelope.suite,
|
|
104
181
|
timestamp: envelope.timestamp,
|
|
105
182
|
nonce: envelope.nonce,
|
|
106
183
|
edSignature: envelope.edSignature,
|
|
@@ -110,15 +187,19 @@ function encodeSignatureHeader(envelope) {
|
|
|
110
187
|
return b64(ENCODER.encode(JSON.stringify(SIGNATURE_FIELDS(envelope))));
|
|
111
188
|
}
|
|
112
189
|
function decodeSignatureHeader(raw, nodeKey) {
|
|
190
|
+
if (typeof raw !== "string" || raw.length < 64 || raw.length > 8192 || !BASE64URL.test(raw))
|
|
191
|
+
return null;
|
|
113
192
|
let parsed;
|
|
114
193
|
try {
|
|
115
194
|
parsed = JSON.parse(new TextDecoder().decode(un64(raw)));
|
|
116
195
|
} catch {
|
|
117
196
|
return null;
|
|
118
197
|
}
|
|
119
|
-
if (typeof parsed.timestamp !== "number" || !Number.isSafeInteger(parsed.timestamp) ||
|
|
198
|
+
if (Object.keys(parsed).length !== 6 || parsed.version !== 1 || parsed.suite !== REQUEST_SIGNATURE_SUITE || typeof parsed.timestamp !== "number" || !Number.isSafeInteger(parsed.timestamp) || !exactBytes(parsed.nonce ?? "", 16) || !exactBytes(parsed.edSignature ?? "", ED_SIGNATURE_BYTES) || !exactBytes(parsed.mlDsaSignature ?? "", ML_SIGNATURE_BYTES) || !validIdentity(nodeKey))
|
|
120
199
|
return null;
|
|
121
200
|
return {
|
|
201
|
+
version: 1,
|
|
202
|
+
suite: REQUEST_SIGNATURE_SUITE,
|
|
122
203
|
nodeKey,
|
|
123
204
|
timestamp: parsed.timestamp,
|
|
124
205
|
nonce: parsed.nonce,
|
|
@@ -127,9 +208,15 @@ function decodeSignatureHeader(raw, nodeKey) {
|
|
|
127
208
|
};
|
|
128
209
|
}
|
|
129
210
|
function canonicalString(args) {
|
|
211
|
+
if (!validIdentity(args.identity) || !/^[A-Za-z][A-Za-z0-9-]{0,31}$/.test(args.method) || /[\0\r\n]/.test(args.path) || /[\0\r\n]/.test(args.query) || !exactBytes(args.nonce, 16) || args.responseKey !== undefined && !validResponsePublicKey(args.responseKey)) {
|
|
212
|
+
throw new Error("identity: malformed canonical request");
|
|
213
|
+
}
|
|
130
214
|
const body = typeof args.body === "string" ? ENCODER.encode(args.body) : args.body;
|
|
131
215
|
const digest = Array.from(sha256(body), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
132
216
|
const fields = [
|
|
217
|
+
"forgezero/request-signature/v1",
|
|
218
|
+
REQUEST_SIGNATURE_SUITE,
|
|
219
|
+
args.identity,
|
|
133
220
|
args.method.toUpperCase(),
|
|
134
221
|
args.path,
|
|
135
222
|
args.query ?? "",
|
|
@@ -137,16 +224,17 @@ function canonicalString(args) {
|
|
|
137
224
|
args.nonce,
|
|
138
225
|
digest
|
|
139
226
|
];
|
|
140
|
-
|
|
141
|
-
fields.push(args.responseKey);
|
|
227
|
+
fields.push(args.responseKey ?? "-");
|
|
142
228
|
return fields.join(`
|
|
143
229
|
`);
|
|
144
230
|
}
|
|
145
231
|
function signRequest(keys, nodeKey, args) {
|
|
146
232
|
const timestamp = Math.floor(Date.now() / 1000);
|
|
147
233
|
const nonce = b64(randomBytes(16));
|
|
148
|
-
const message = ENCODER.encode(canonicalString({ ...args, query: args.query ?? "", timestamp, nonce }));
|
|
234
|
+
const message = ENCODER.encode(canonicalString({ ...args, identity: nodeKey, query: args.query ?? "", timestamp, nonce }));
|
|
149
235
|
return {
|
|
236
|
+
version: 1,
|
|
237
|
+
suite: REQUEST_SIGNATURE_SUITE,
|
|
150
238
|
nodeKey,
|
|
151
239
|
timestamp,
|
|
152
240
|
nonce,
|
|
@@ -157,12 +245,16 @@ function signRequest(keys, nodeKey, args) {
|
|
|
157
245
|
var CLOCK_SKEW_SECONDS = 300;
|
|
158
246
|
function verifyRequest(args) {
|
|
159
247
|
const now = args.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
248
|
+
if (args.envelope.version !== 1 || args.envelope.suite !== REQUEST_SIGNATURE_SUITE || !validIdentity(args.envelope.nodeKey) || !exactBytes(args.envelope.nonce, 16) || !exactBytes(args.envelope.edSignature, ED_SIGNATURE_BYTES) || !exactBytes(args.envelope.mlDsaSignature, ML_SIGNATURE_BYTES) || !exactBytes(args.publicKeys.ed25519, ED_PUBLIC_BYTES) || !exactBytes(args.publicKeys.mlDsa, ML_PUBLIC_BYTES) || args.responseKey !== undefined && !validResponsePublicKey(args.responseKey)) {
|
|
249
|
+
return { verified: false, reason: "malformed" };
|
|
250
|
+
}
|
|
160
251
|
if (Math.abs(now - args.envelope.timestamp) > CLOCK_SKEW_SECONDS) {
|
|
161
252
|
return { verified: false, reason: "timestamp_out_of_window" };
|
|
162
253
|
}
|
|
163
254
|
let message;
|
|
164
255
|
try {
|
|
165
256
|
message = ENCODER.encode(canonicalString({
|
|
257
|
+
identity: args.envelope.nodeKey,
|
|
166
258
|
method: args.method,
|
|
167
259
|
path: args.path,
|
|
168
260
|
query: args.query ?? "",
|
|
@@ -199,9 +291,12 @@ export {
|
|
|
199
291
|
generateResponseRecipient,
|
|
200
292
|
generateNodeKeys,
|
|
201
293
|
encodeSignatureHeader,
|
|
294
|
+
derivePublicKeysFromSeed,
|
|
202
295
|
deriveKeysFromSeed,
|
|
203
296
|
decodeSignatureHeader,
|
|
204
297
|
canonicalString,
|
|
298
|
+
RESPONSE_SEALING_SUITE,
|
|
205
299
|
RESPONSE_KEY_HEADER,
|
|
300
|
+
REQUEST_SIGNATURE_SUITE,
|
|
206
301
|
CLOCK_SKEW_SECONDS
|
|
207
302
|
};
|
package/dist/jobs.d.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface JobContext {
|
|
|
86
86
|
log(message: string, detail?: Record<string, unknown>): void;
|
|
87
87
|
}
|
|
88
88
|
export interface JobResult {
|
|
89
|
+
/** A resolved run may still report an operational failure without throwing. */
|
|
90
|
+
ok?: boolean;
|
|
89
91
|
/** Anything worth showing on a status screen. Kept in the report verbatim. */
|
|
90
92
|
[key: string]: unknown;
|
|
91
93
|
}
|
package/dist/jobs.js
CHANGED
|
@@ -477,8 +477,15 @@ function createScheduler(options) {
|
|
|
477
477
|
log: (message, detail) => options.onLog?.(job.key, message, detail)
|
|
478
478
|
});
|
|
479
479
|
report.lastResult = result ?? undefined;
|
|
480
|
-
|
|
481
|
-
|
|
480
|
+
if (result?.ok === false) {
|
|
481
|
+
const error = new Error("job returned ok=false");
|
|
482
|
+
report.lastError = error.message;
|
|
483
|
+
report.consecutiveFailures += 1;
|
|
484
|
+
options.onError?.(job.key, error);
|
|
485
|
+
} else {
|
|
486
|
+
report.lastError = undefined;
|
|
487
|
+
report.consecutiveFailures = 0;
|
|
488
|
+
}
|
|
482
489
|
} catch (error) {
|
|
483
490
|
report.lastError = error instanceof Error ? error.message : String(error);
|
|
484
491
|
report.consecutiveFailures += 1;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare const PASSKEY_HYBRID_VERSION: 1;
|
|
2
|
+
export declare const PASSKEY_HYBRID_SUITE: "webauthn+prf-ml-dsa-65";
|
|
3
|
+
export declare const PASSKEY_PRF_SALT: Uint8Array<ArrayBuffer>;
|
|
4
|
+
export type PasskeyHybridPurpose = 'register' | 'signin' | 'session' | 'action';
|
|
5
|
+
export interface PasskeyHybridBinding {
|
|
6
|
+
version: typeof PASSKEY_HYBRID_VERSION;
|
|
7
|
+
suite: typeof PASSKEY_HYBRID_SUITE;
|
|
8
|
+
purpose: PasskeyHybridPurpose;
|
|
9
|
+
rpId: string;
|
|
10
|
+
origin: string;
|
|
11
|
+
challenge: string;
|
|
12
|
+
userKey: string;
|
|
13
|
+
sessionKey: string;
|
|
14
|
+
actionRequestKey: string;
|
|
15
|
+
}
|
|
16
|
+
export interface PasskeyHybridProof {
|
|
17
|
+
version: typeof PASSKEY_HYBRID_VERSION;
|
|
18
|
+
suite: typeof PASSKEY_HYBRID_SUITE;
|
|
19
|
+
credentialId: string;
|
|
20
|
+
signature: string;
|
|
21
|
+
}
|
|
22
|
+
export interface PasskeyHybridRegistrationProof extends PasskeyHybridProof {
|
|
23
|
+
publicKey: string;
|
|
24
|
+
/** A verified WebAuthn assertion which performed the PRF evaluation. */
|
|
25
|
+
assertion: unknown;
|
|
26
|
+
}
|
|
27
|
+
export declare function passkeyHybridMessage(binding: PasskeyHybridBinding, credentialId: string): Uint8Array;
|
|
28
|
+
export declare function createPasskeyHybridProof(prfOutput: Uint8Array, binding: PasskeyHybridBinding, credentialId: string): PasskeyHybridProof & {
|
|
29
|
+
publicKey: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function verifyPasskeyHybridProof(args: {
|
|
32
|
+
proof: PasskeyHybridProof;
|
|
33
|
+
publicKey: string;
|
|
34
|
+
binding: PasskeyHybridBinding;
|
|
35
|
+
}): boolean;
|
|
36
|
+
export declare const PASSKEY_ML_DSA_PUBLIC_KEY_BYTES: number;
|
|
37
|
+
export declare const PASSKEY_ML_DSA_SIGNATURE_BYTES: number;
|