@forgezero/runtime 0.1.0 → 0.1.1
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/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/outbox.d.ts +10 -1
- package/dist/outbox.js +8 -3
- package/dist/queue.d.ts +88 -229
- package/dist/queue.js +180 -211
- package/dist/ssh-agent.d.ts +13 -0
- package/dist/ssh-agent.js +6 -0
- package/package.json +258 -246
|
@@ -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 {};
|
|
@@ -0,0 +1,313 @@
|
|
|
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
|
+
|
|
83
|
+
// src/phrase.ts
|
|
84
|
+
import { entropyToMnemonic, mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
|
|
85
|
+
import { wordlist } from "@scure/bip39/wordlists/english.js";
|
|
86
|
+
import { hkdf as hkdf2 } from "@noble/hashes/hkdf.js";
|
|
87
|
+
import { sha256 as sha2562 } from "@noble/hashes/sha2.js";
|
|
88
|
+
import { toHex } from "@forgezero/access/security";
|
|
89
|
+
var ENCODER = new TextEncoder;
|
|
90
|
+
var randomBytes = (length) => crypto.getRandomValues(new Uint8Array(length));
|
|
91
|
+
var PHRASE_WORDS = 24;
|
|
92
|
+
var ENTROPY_BYTES = 32;
|
|
93
|
+
var PHRASE_SALT_BYTES = 16;
|
|
94
|
+
var KEY_BYTES2 = 32;
|
|
95
|
+
var KEY_INFO = "forgezero:custodian:phrase-key:v1";
|
|
96
|
+
var VERIFIER_INFO = "forgezero:custodian:phrase-verifier:v1";
|
|
97
|
+
function canonical(words) {
|
|
98
|
+
return words.map((word) => word.normalize("NFKD").trim().toLowerCase()).join(" ");
|
|
99
|
+
}
|
|
100
|
+
function shapeIsValid(words) {
|
|
101
|
+
if (!Array.isArray(words) || words.length !== PHRASE_WORDS)
|
|
102
|
+
return false;
|
|
103
|
+
return words.every((word) => typeof word === "string" && word.trim().length > 0);
|
|
104
|
+
}
|
|
105
|
+
function generatePhrase() {
|
|
106
|
+
const entropy = randomBytes(ENTROPY_BYTES);
|
|
107
|
+
try {
|
|
108
|
+
const words = entropyToMnemonic(entropy, wordlist).split(" ");
|
|
109
|
+
if (words.length !== PHRASE_WORDS) {
|
|
110
|
+
throw new Error("phrase: wordlist produced an unexpected phrase length");
|
|
111
|
+
}
|
|
112
|
+
return words;
|
|
113
|
+
} finally {
|
|
114
|
+
entropy.fill(0);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function validatePhrase(words) {
|
|
118
|
+
if (!shapeIsValid(words))
|
|
119
|
+
return false;
|
|
120
|
+
return validateMnemonic(canonical(words), wordlist);
|
|
121
|
+
}
|
|
122
|
+
function newSalt() {
|
|
123
|
+
return Uint8Array.from(randomBytes(PHRASE_SALT_BYTES));
|
|
124
|
+
}
|
|
125
|
+
function assertSalt(salt) {
|
|
126
|
+
if (!(salt instanceof Uint8Array) || salt.length < PHRASE_SALT_BYTES) {
|
|
127
|
+
throw new Error(`phrase: salt must be at least ${PHRASE_SALT_BYTES} bytes`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function bip39Seed(words) {
|
|
131
|
+
if (!validatePhrase(words))
|
|
132
|
+
throw new Error("INVALID_PHRASE");
|
|
133
|
+
return mnemonicToSeedSync(canonical(words));
|
|
134
|
+
}
|
|
135
|
+
function phraseToKey(words, salt) {
|
|
136
|
+
assertSalt(salt);
|
|
137
|
+
const seed = bip39Seed(words);
|
|
138
|
+
try {
|
|
139
|
+
return hkdf2(sha2562, seed, salt, ENCODER.encode(KEY_INFO), KEY_BYTES2);
|
|
140
|
+
} finally {
|
|
141
|
+
seed.fill(0);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function phraseVerifier(words, salt) {
|
|
145
|
+
assertSalt(salt);
|
|
146
|
+
const seed = bip39Seed(words);
|
|
147
|
+
try {
|
|
148
|
+
return toHex(hkdf2(sha2562, seed, salt, ENCODER.encode(VERIFIER_INFO), KEY_BYTES2));
|
|
149
|
+
} finally {
|
|
150
|
+
seed.fill(0);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/custody-share.ts
|
|
155
|
+
var KEY_BYTES3 = 32;
|
|
156
|
+
var MIN_PRF_BYTES = 32;
|
|
157
|
+
var PASSKEY_KEY_SALT = "forgezero:custodian:passkey:v1";
|
|
158
|
+
var utf82 = (value) => new TextEncoder().encode(value);
|
|
159
|
+
var toBase642 = (bytes) => {
|
|
160
|
+
let binary = "";
|
|
161
|
+
for (const byte of bytes)
|
|
162
|
+
binary += String.fromCharCode(byte);
|
|
163
|
+
return btoa(binary);
|
|
164
|
+
};
|
|
165
|
+
var fromBase642 = (value) => {
|
|
166
|
+
const binary = atob(value);
|
|
167
|
+
const bytes = new Uint8Array(binary.length);
|
|
168
|
+
for (let index = 0;index < binary.length; index += 1)
|
|
169
|
+
bytes[index] = binary.charCodeAt(index);
|
|
170
|
+
return bytes;
|
|
171
|
+
};
|
|
172
|
+
function passkeyKey(custodianKey, prfOutput) {
|
|
173
|
+
if (!(prfOutput instanceof Uint8Array) || prfOutput.length < MIN_PRF_BYTES) {
|
|
174
|
+
throw new Error(`custody-share: PRF output must be at least ${MIN_PRF_BYTES} bytes`);
|
|
175
|
+
}
|
|
176
|
+
const info = `forgezero:custodian:passkey-key:v1:${custodianKey.length}:${custodianKey}`;
|
|
177
|
+
return deriveKey(prfOutput, utf82(PASSKEY_KEY_SALT), info, KEY_BYTES3);
|
|
178
|
+
}
|
|
179
|
+
function shareAad(custodianKey, kind, shareIndex) {
|
|
180
|
+
return `forgezero:share:v1:${kind}:${shareIndex}:${custodianKey.length}:${custodianKey}`;
|
|
181
|
+
}
|
|
182
|
+
function assertCustodianKey(custodianKey) {
|
|
183
|
+
if (typeof custodianKey !== "string" || custodianKey.length === 0) {
|
|
184
|
+
throw new Error("custody-share: custodianKey must be a non-empty string");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function shareIndexOf(share) {
|
|
188
|
+
if (share.length < 2)
|
|
189
|
+
throw new Error("custody-share: malformed share");
|
|
190
|
+
const x = share[0];
|
|
191
|
+
if (x < 1)
|
|
192
|
+
throw new Error("custody-share: share index 0 is reserved");
|
|
193
|
+
return x;
|
|
194
|
+
}
|
|
195
|
+
function sealShare(args) {
|
|
196
|
+
const { share, custodianKey, passkeyPrfOutput, phraseWords } = args;
|
|
197
|
+
assertCustodianKey(custodianKey);
|
|
198
|
+
const shareIndex = shareIndexOf(share);
|
|
199
|
+
if (!validatePhrase(phraseWords))
|
|
200
|
+
throw new Error("INVALID_PHRASE");
|
|
201
|
+
const salt = newSalt();
|
|
202
|
+
const keyFromPasskey = passkeyKey(custodianKey, passkeyPrfOutput);
|
|
203
|
+
const keyFromPhrase = phraseToKey(phraseWords, salt);
|
|
204
|
+
try {
|
|
205
|
+
return {
|
|
206
|
+
shareIndex,
|
|
207
|
+
passkeyEnvelope: sealWithKey(keyFromPasskey, share, shareAad(custodianKey, "passkey", shareIndex)),
|
|
208
|
+
phraseEnvelope: sealWithKey(keyFromPhrase, share, shareAad(custodianKey, "phrase", shareIndex)),
|
|
209
|
+
phraseSalt: toBase642(salt),
|
|
210
|
+
phraseVerifier: phraseVerifier(phraseWords, salt)
|
|
211
|
+
};
|
|
212
|
+
} finally {
|
|
213
|
+
keyFromPasskey.fill(0);
|
|
214
|
+
keyFromPhrase.fill(0);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
function openShareWithPasskey(sealed, custodianKey, passkeyPrfOutput) {
|
|
218
|
+
assertCustodianKey(custodianKey);
|
|
219
|
+
const key = passkeyKey(custodianKey, passkeyPrfOutput);
|
|
220
|
+
try {
|
|
221
|
+
return openWithKey(key, sealed.passkeyEnvelope, shareAad(custodianKey, "passkey", sealed.shareIndex));
|
|
222
|
+
} finally {
|
|
223
|
+
key.fill(0);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function openShareWithPhrase(sealed, custodianKey, phraseWords) {
|
|
227
|
+
assertCustodianKey(custodianKey);
|
|
228
|
+
if (!validatePhrase(phraseWords))
|
|
229
|
+
throw new Error("INVALID_PHRASE");
|
|
230
|
+
const salt = fromBase642(sealed.phraseSalt);
|
|
231
|
+
const key = phraseToKey(phraseWords, salt);
|
|
232
|
+
try {
|
|
233
|
+
return openWithKey(key, sealed.phraseEnvelope, shareAad(custodianKey, "phrase", sealed.shareIndex));
|
|
234
|
+
} finally {
|
|
235
|
+
key.fill(0);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
var wrapInfo = (custodianKey, kind) => `forgezero:custody:wrap:v1:${kind}:${custodianKey.length}:${custodianKey}`;
|
|
239
|
+
function passkeyWrappingKey(custodianKey, passkeyPrfOutput) {
|
|
240
|
+
assertCustodianKey(custodianKey);
|
|
241
|
+
return wrappingKeyPair(passkeyKey(custodianKey, passkeyPrfOutput), wrapInfo(custodianKey, "passkey"));
|
|
242
|
+
}
|
|
243
|
+
function phraseWrappingKey(custodianKey, phraseWords, salt) {
|
|
244
|
+
assertCustodianKey(custodianKey);
|
|
245
|
+
if (!validatePhrase(phraseWords))
|
|
246
|
+
throw new Error("INVALID_PHRASE");
|
|
247
|
+
return wrappingKeyPair(phraseToKey(phraseWords, salt), wrapInfo(custodianKey, "phrase"));
|
|
248
|
+
}
|
|
249
|
+
function wrappingKeysFor(args) {
|
|
250
|
+
const salt = newSalt();
|
|
251
|
+
const passkey = passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput);
|
|
252
|
+
const phrase = phraseWrappingKey(args.custodianKey, args.phraseWords, salt);
|
|
253
|
+
try {
|
|
254
|
+
return {
|
|
255
|
+
passkeyPublicKey: toBase642(passkey.publicKey),
|
|
256
|
+
phrasePublicKey: toBase642(phrase.publicKey),
|
|
257
|
+
phraseSalt: toBase642(salt),
|
|
258
|
+
phraseVerifier: phraseVerifier(args.phraseWords, salt)
|
|
259
|
+
};
|
|
260
|
+
} finally {
|
|
261
|
+
passkey.secretKey.fill(0);
|
|
262
|
+
phrase.secretKey.fill(0);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
function openSealedToFactor(args) {
|
|
266
|
+
const pair = args.factor === "passkey" ? passkeyWrappingKey(args.custodianKey, args.passkeyPrfOutput) : phraseWrappingKey(args.custodianKey, args.phraseWords, fromBase642(args.phraseSalt));
|
|
267
|
+
try {
|
|
268
|
+
return openFromKey(pair.secretKey, args.box, wrapInfo(args.custodianKey, args.factor));
|
|
269
|
+
} finally {
|
|
270
|
+
pair.secretKey.fill(0);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
var factorWrapAad = (custodianKey, factor) => wrapInfo(custodianKey, factor);
|
|
274
|
+
var PROBE_BYTES = 32;
|
|
275
|
+
function joinProbeAndShare(probe, share) {
|
|
276
|
+
if (probe.length !== PROBE_BYTES)
|
|
277
|
+
throw new Error("custody-share: probe must be 32 bytes");
|
|
278
|
+
const out = new Uint8Array(probe.length + share.length);
|
|
279
|
+
out.set(probe, 0);
|
|
280
|
+
out.set(share, probe.length);
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
283
|
+
function splitProbeAndShare(opened) {
|
|
284
|
+
if (opened.length <= PROBE_BYTES)
|
|
285
|
+
throw new Error("custody-share: envelope is too short");
|
|
286
|
+
return { probe: opened.slice(0, PROBE_BYTES), share: opened.slice(PROBE_BYTES) };
|
|
287
|
+
}
|
|
288
|
+
function openFactorEnvelope(args) {
|
|
289
|
+
const box = args.factor === "passkey" ? args.sealed.passkeyEnvelope : args.sealed.phraseEnvelope;
|
|
290
|
+
return splitProbeAndShare(openSealedToFactor({
|
|
291
|
+
custodianKey: args.custodianKey,
|
|
292
|
+
factor: args.factor,
|
|
293
|
+
box,
|
|
294
|
+
passkeyPrfOutput: args.passkeyPrfOutput,
|
|
295
|
+
phraseWords: args.phraseWords,
|
|
296
|
+
phraseSalt: args.sealed.phraseSalt
|
|
297
|
+
}));
|
|
298
|
+
}
|
|
299
|
+
export {
|
|
300
|
+
wrappingKeysFor,
|
|
301
|
+
splitProbeAndShare,
|
|
302
|
+
shareIndexOf,
|
|
303
|
+
sealShare,
|
|
304
|
+
phraseWrappingKey,
|
|
305
|
+
passkeyWrappingKey,
|
|
306
|
+
openShareWithPhrase,
|
|
307
|
+
openShareWithPasskey,
|
|
308
|
+
openSealedToFactor,
|
|
309
|
+
openFactorEnvelope,
|
|
310
|
+
joinProbeAndShare,
|
|
311
|
+
factorWrapAad,
|
|
312
|
+
PROBE_BYTES
|
|
313
|
+
};
|
package/dist/outbox.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export declare class OutboxError extends Error {
|
|
|
47
47
|
export declare const EVENT_STATES: readonly ["pending", "delivering", "delivered", "dead"];
|
|
48
48
|
export type EventState = (typeof EVENT_STATES)[number];
|
|
49
49
|
export interface OutboxEvent {
|
|
50
|
+
/** Set when a drainer claims it; proves ownership at settle time. */
|
|
51
|
+
claimToken?: string;
|
|
50
52
|
id: string;
|
|
51
53
|
type: string;
|
|
52
54
|
/** The aggregate this concerns. Events sharing one are delivered in order. */
|
|
@@ -134,7 +136,14 @@ export interface OutboxStore {
|
|
|
134
136
|
nowMs: number;
|
|
135
137
|
claimTtlMs: number;
|
|
136
138
|
}): Promise<OutboxEvent[]>;
|
|
137
|
-
|
|
139
|
+
/**
|
|
140
|
+
* `claimToken` proves the caller still owns this event.
|
|
141
|
+
*
|
|
142
|
+
* Without it a drainer whose lease had expired could overwrite the result of
|
|
143
|
+
* the drainer that took over, landing an old outcome on top of a newer one.
|
|
144
|
+
* Optional so an in-memory store that cannot lose ownership need not carry it.
|
|
145
|
+
*/
|
|
146
|
+
settle(id: string, patch: Partial<OutboxEvent>, claimToken?: string): Promise<void>;
|
|
138
147
|
byState(state: EventState, limit?: number): Promise<OutboxEvent[]>;
|
|
139
148
|
get(id: string): Promise<OutboxEvent | null>;
|
|
140
149
|
}
|
package/dist/outbox.js
CHANGED
|
@@ -27,13 +27,18 @@ function backoffMs(attempts, policy = DEFAULT_POLICY, random = Math.random) {
|
|
|
27
27
|
const exponential = Math.min(policy.baseDelayMs * 2 ** Math.max(0, attempts - 1), policy.maxDelayMs);
|
|
28
28
|
return Math.round(exponential * (1 + policy.jitter * random()));
|
|
29
29
|
}
|
|
30
|
+
function randomSuffix() {
|
|
31
|
+
const bytes = new Uint8Array(4);
|
|
32
|
+
crypto.getRandomValues(bytes);
|
|
33
|
+
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
34
|
+
}
|
|
30
35
|
function createOutbox(options) {
|
|
31
36
|
const policy = { ...DEFAULT_POLICY, ...options.policy };
|
|
32
37
|
const now = options.now ?? Date.now;
|
|
33
38
|
const random = options.random ?? Math.random;
|
|
34
39
|
const drainerId = options.drainerId ?? `drainer-${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
35
40
|
let sequence = 0;
|
|
36
|
-
const nextId = () => `evt_${now().toString(36)}_${(sequence += 1).toString(36)}`;
|
|
41
|
+
const nextId = () => `evt_${now().toString(36)}_${(sequence += 1).toString(36)}_${randomSuffix()}`;
|
|
37
42
|
async function settleOne(event) {
|
|
38
43
|
let result;
|
|
39
44
|
try {
|
|
@@ -47,7 +52,7 @@ function createOutbox(options) {
|
|
|
47
52
|
deliveredAtMs: now(),
|
|
48
53
|
claimedBy: undefined,
|
|
49
54
|
claimedUntilMs: undefined
|
|
50
|
-
});
|
|
55
|
+
}, event.claimToken);
|
|
51
56
|
return "delivered";
|
|
52
57
|
}
|
|
53
58
|
const attempts = event.attempts + 1;
|
|
@@ -67,7 +72,7 @@ function createOutbox(options) {
|
|
|
67
72
|
lastError: result.error,
|
|
68
73
|
claimedBy: undefined,
|
|
69
74
|
claimedUntilMs: undefined
|
|
70
|
-
});
|
|
75
|
+
}, event.claimToken);
|
|
71
76
|
options.onDeadLetter?.(dead);
|
|
72
77
|
return "dead";
|
|
73
78
|
}
|