@forgezero/runtime 0.1.5 → 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/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/identity.d.ts +11 -3
- package/dist/identity.js +109 -14
- package/dist/passkey-hybrid.d.ts +37 -0
- package/dist/passkey-hybrid.js +111 -0
- package/dist/realtime.d.ts +68 -0
- package/dist/realtime.js +184 -0
- package/package.json +10 -6
- package/dist/ssh-agent.d.ts +0 -83
- package/dist/ssh-agent.js +0 -147
package/dist/custody-crypto.d.ts
CHANGED
|
@@ -28,13 +28,15 @@ export declare function openWithKey(key: Uint8Array, box: CipherBox, aad: string
|
|
|
28
28
|
* plaintext — so a captured response is ciphertext, and opening it requires the
|
|
29
29
|
* factor itself.
|
|
30
30
|
*
|
|
31
|
-
* X25519 + HKDF-SHA256 + AES-256-GCM
|
|
32
|
-
*
|
|
33
|
-
*
|
|
31
|
+
* ML-KEM-768 + X25519 + HKDF-SHA256 + AES-256-GCM. Both KEM halves are
|
|
32
|
+
* mandatory: a captured custody envelope remains confidential after a future
|
|
33
|
+
* break of X25519, while the classical half hedges a failure in the newer KEM.
|
|
34
|
+
* The version is explicit and the opener accepts no legacy X25519-only shape.
|
|
34
35
|
*/
|
|
35
36
|
export interface SealedToKey extends CipherBox {
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
version: 2;
|
|
38
|
+
/** base64 — the mandatory hybrid ML-KEM-768 + X25519 ciphertext. */
|
|
39
|
+
kemCiphertext: string;
|
|
38
40
|
}
|
|
39
41
|
/** Seal to a recipient's public key. */
|
|
40
42
|
export declare function sealToKey(recipientPublicKey: Uint8Array, plaintext: Uint8Array, aad: string): SealedToKey;
|
package/dist/custody-crypto.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
|
export {
|
|
83
105
|
wrappingKeyPair,
|
package/dist/custody-share.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type CipherBox, type SealedToKey } from './custody-crypto';
|
|
2
2
|
export interface SealedShare {
|
|
3
3
|
shareIndex: number;
|
|
4
|
+
/** Account credential whose PRF deterministically derives this passkey key. */
|
|
5
|
+
passkeyCredentialId?: string;
|
|
4
6
|
passkeyEnvelope: CipherBox;
|
|
5
7
|
phraseEnvelope: CipherBox;
|
|
6
8
|
/** base64 — the HKDF salt for the phrase key and the verifier. */
|
|
@@ -42,6 +44,8 @@ export declare function openShareWithPhrase(sealed: SealedShare, custodianKey: s
|
|
|
42
44
|
* ever sent.
|
|
43
45
|
*/
|
|
44
46
|
export interface WrappingKeys {
|
|
47
|
+
/** The verified, PRF-capable account credential selected by the browser. */
|
|
48
|
+
passkeyCredentialId: string;
|
|
45
49
|
passkeyPublicKey: string;
|
|
46
50
|
phrasePublicKey: string;
|
|
47
51
|
phraseSalt: string;
|
|
@@ -60,6 +64,7 @@ export declare function phraseWrappingKey(custodianKey: string, phraseWords: str
|
|
|
60
64
|
/** Everything the server needs, and nothing it must not have. */
|
|
61
65
|
export declare function wrappingKeysFor(args: {
|
|
62
66
|
custodianKey: string;
|
|
67
|
+
passkeyCredentialId: string;
|
|
63
68
|
passkeyPrfOutput: Uint8Array;
|
|
64
69
|
phraseWords: string[];
|
|
65
70
|
}): WrappingKeys;
|
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),
|
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
|
};
|
|
@@ -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;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
3
|
+
}) : x)(function(x) {
|
|
4
|
+
if (typeof require !== "undefined")
|
|
5
|
+
return require.apply(this, arguments);
|
|
6
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// src/passkey-hybrid.ts
|
|
10
|
+
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
11
|
+
import { hkdf } from "@noble/hashes/hkdf.js";
|
|
12
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
13
|
+
var PASSKEY_HYBRID_VERSION = 1;
|
|
14
|
+
var PASSKEY_HYBRID_SUITE = "webauthn+prf-ml-dsa-65";
|
|
15
|
+
var PASSKEY_PRF_SALT = new TextEncoder().encode("forgezero:passkey:hybrid-auth:prf:v1");
|
|
16
|
+
var utf8 = (value) => new TextEncoder().encode(value);
|
|
17
|
+
var b64 = (bytes) => {
|
|
18
|
+
let binary = "";
|
|
19
|
+
for (const byte of bytes)
|
|
20
|
+
binary += String.fromCharCode(byte);
|
|
21
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
22
|
+
};
|
|
23
|
+
var un64 = (value) => {
|
|
24
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value))
|
|
25
|
+
throw new Error("passkey-hybrid: non-canonical base64url");
|
|
26
|
+
const normal = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
27
|
+
const binary = atob(normal.padEnd(Math.ceil(normal.length / 4) * 4, "="));
|
|
28
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
29
|
+
};
|
|
30
|
+
function field(value) {
|
|
31
|
+
if (value.length > 2048 || /[\0\r\n]/.test(value)) {
|
|
32
|
+
throw new Error("passkey-hybrid: invalid binding field");
|
|
33
|
+
}
|
|
34
|
+
return `${utf8(value).length}:${value}`;
|
|
35
|
+
}
|
|
36
|
+
function passkeyHybridMessage(binding, credentialId) {
|
|
37
|
+
if (binding.version !== PASSKEY_HYBRID_VERSION || binding.suite !== PASSKEY_HYBRID_SUITE) {
|
|
38
|
+
throw new Error("passkey-hybrid: unsupported protocol");
|
|
39
|
+
}
|
|
40
|
+
if (!/^[A-Za-z0-9_-]{16,1024}$/.test(credentialId)) {
|
|
41
|
+
throw new Error("passkey-hybrid: invalid credential id");
|
|
42
|
+
}
|
|
43
|
+
return utf8([
|
|
44
|
+
"forgezero-passkey-hybrid-v1",
|
|
45
|
+
binding.suite,
|
|
46
|
+
binding.purpose,
|
|
47
|
+
binding.rpId,
|
|
48
|
+
binding.origin,
|
|
49
|
+
binding.challenge,
|
|
50
|
+
credentialId,
|
|
51
|
+
binding.userKey,
|
|
52
|
+
binding.sessionKey,
|
|
53
|
+
binding.actionRequestKey
|
|
54
|
+
].map(field).join(`
|
|
55
|
+
`));
|
|
56
|
+
}
|
|
57
|
+
function keys(prfOutput, credentialId) {
|
|
58
|
+
if (prfOutput.length !== 32)
|
|
59
|
+
throw new Error("passkey-hybrid: PRF output must be exactly 32 bytes");
|
|
60
|
+
const seed = hkdf(sha256, prfOutput, utf8("forgezero:passkey:hybrid-auth:ml-dsa-65:v1"), utf8(`credential:${credentialId.length}:${credentialId}`), 32);
|
|
61
|
+
try {
|
|
62
|
+
const pair = ml_dsa65.keygen(seed);
|
|
63
|
+
return {
|
|
64
|
+
publicKey: Uint8Array.from(pair.publicKey),
|
|
65
|
+
secretKey: Uint8Array.from(pair.secretKey)
|
|
66
|
+
};
|
|
67
|
+
} finally {
|
|
68
|
+
seed.fill(0);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function createPasskeyHybridProof(prfOutput, binding, credentialId) {
|
|
72
|
+
const pair = keys(prfOutput, credentialId);
|
|
73
|
+
try {
|
|
74
|
+
return {
|
|
75
|
+
version: PASSKEY_HYBRID_VERSION,
|
|
76
|
+
suite: PASSKEY_HYBRID_SUITE,
|
|
77
|
+
credentialId,
|
|
78
|
+
publicKey: b64(pair.publicKey),
|
|
79
|
+
signature: b64(ml_dsa65.sign(passkeyHybridMessage(binding, credentialId), pair.secretKey))
|
|
80
|
+
};
|
|
81
|
+
} finally {
|
|
82
|
+
pair.secretKey.fill(0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function verifyPasskeyHybridProof(args) {
|
|
86
|
+
try {
|
|
87
|
+
if (args.proof.version !== PASSKEY_HYBRID_VERSION || args.proof.suite !== PASSKEY_HYBRID_SUITE || !Object.keys(args.proof).every((key) => ["version", "suite", "credentialId", "signature"].includes(key)))
|
|
88
|
+
return false;
|
|
89
|
+
const publicKey = un64(args.publicKey);
|
|
90
|
+
const signature = un64(args.proof.signature);
|
|
91
|
+
if (publicKey.length !== ml_dsa65.lengths.publicKey || signature.length !== ml_dsa65.lengths.signature)
|
|
92
|
+
return false;
|
|
93
|
+
if (b64(publicKey) !== args.publicKey || b64(signature) !== args.proof.signature)
|
|
94
|
+
return false;
|
|
95
|
+
return ml_dsa65.verify(signature, passkeyHybridMessage(args.binding, args.proof.credentialId), publicKey);
|
|
96
|
+
} catch {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
var PASSKEY_ML_DSA_PUBLIC_KEY_BYTES = ml_dsa65.lengths.publicKey;
|
|
101
|
+
var PASSKEY_ML_DSA_SIGNATURE_BYTES = ml_dsa65.lengths.signature;
|
|
102
|
+
export {
|
|
103
|
+
verifyPasskeyHybridProof,
|
|
104
|
+
passkeyHybridMessage,
|
|
105
|
+
createPasskeyHybridProof,
|
|
106
|
+
PASSKEY_PRF_SALT,
|
|
107
|
+
PASSKEY_ML_DSA_SIGNATURE_BYTES,
|
|
108
|
+
PASSKEY_ML_DSA_PUBLIC_KEY_BYTES,
|
|
109
|
+
PASSKEY_HYBRID_VERSION,
|
|
110
|
+
PASSKEY_HYBRID_SUITE
|
|
111
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export declare const REALTIME_MAX_EVENTS = 100;
|
|
2
|
+
export declare const REALTIME_MAX_EVENT_BYTES: number;
|
|
3
|
+
export declare const REALTIME_MAX_BATCH_BYTES: number;
|
|
4
|
+
export declare const REALTIME_MAX_SOCKETS_PER_SHARD = 1000;
|
|
5
|
+
export declare const REALTIME_MAX_SHARDS_PER_TOPIC = 1024;
|
|
6
|
+
export interface RealtimeEvent {
|
|
7
|
+
id: string;
|
|
8
|
+
type: string;
|
|
9
|
+
payload: unknown;
|
|
10
|
+
}
|
|
11
|
+
export type RealtimePrincipalKind = 'user' | 'node' | 'api-key' | 'service' | 'header';
|
|
12
|
+
export type RealtimeAudience = Readonly<{
|
|
13
|
+
kind: 'public';
|
|
14
|
+
}> | Readonly<{
|
|
15
|
+
kind: 'principal';
|
|
16
|
+
realmId: string;
|
|
17
|
+
principalKind: RealtimePrincipalKind;
|
|
18
|
+
principalKey: string;
|
|
19
|
+
capability: string;
|
|
20
|
+
}> | Readonly<{
|
|
21
|
+
kind: 'project';
|
|
22
|
+
realmId: string;
|
|
23
|
+
projectKey: string;
|
|
24
|
+
capability: string;
|
|
25
|
+
}> | Readonly<{
|
|
26
|
+
kind: 'group';
|
|
27
|
+
realmId: string;
|
|
28
|
+
group: string;
|
|
29
|
+
capability: string;
|
|
30
|
+
}>;
|
|
31
|
+
export interface RealtimeBatch {
|
|
32
|
+
version: 2;
|
|
33
|
+
batchId: string;
|
|
34
|
+
topic: string;
|
|
35
|
+
audience: RealtimeAudience;
|
|
36
|
+
publishedAtMs: number;
|
|
37
|
+
events: RealtimeEvent[];
|
|
38
|
+
}
|
|
39
|
+
export declare function validateRealtimeAudience(input: unknown): RealtimeAudience;
|
|
40
|
+
export declare function validateRealtimeBatch(input: unknown): RealtimeBatch;
|
|
41
|
+
export declare const realtimeBatchBytes: (input: unknown) => string;
|
|
42
|
+
export interface RealtimeSubscriptionTicket {
|
|
43
|
+
version: 2;
|
|
44
|
+
topic: string;
|
|
45
|
+
principal: Readonly<{
|
|
46
|
+
kind: RealtimePrincipalKind;
|
|
47
|
+
key: string;
|
|
48
|
+
}>;
|
|
49
|
+
realmId: string;
|
|
50
|
+
projectKeys: readonly string[];
|
|
51
|
+
groups: readonly string[];
|
|
52
|
+
capabilities: readonly string[];
|
|
53
|
+
expiresAtSec: number;
|
|
54
|
+
nonce: string;
|
|
55
|
+
}
|
|
56
|
+
export declare function validateRealtimeSubscriptionTicket(input: unknown, nowSec?: number): RealtimeSubscriptionTicket;
|
|
57
|
+
/**
|
|
58
|
+
* The edge has no database authority. It may deliver only when the short-lived,
|
|
59
|
+
* API-issued connection capability contains every coordinate named by the
|
|
60
|
+
* event audience. Topic equality is handled by the shard; it is not an access
|
|
61
|
+
* decision.
|
|
62
|
+
*/
|
|
63
|
+
export declare function canReceiveRealtimeAudience(ticket: RealtimeSubscriptionTicket, audience: RealtimeAudience): boolean;
|
|
64
|
+
export declare const realtimeShardKey: (topic: string, shard: number) => string;
|
|
65
|
+
export declare function realtimeHmac(secret: string, message: string): Promise<string>;
|
|
66
|
+
export declare function verifyRealtimeHmac(secret: string, message: string, signature: string): Promise<boolean>;
|
|
67
|
+
export declare function issueRealtimeSubscriptionTicket(secret: string, ticket: RealtimeSubscriptionTicket, nowSec?: number): Promise<string>;
|
|
68
|
+
export declare function verifyRealtimeSubscriptionToken(secret: string, token: string, nowSec?: number): Promise<RealtimeSubscriptionTicket | null>;
|
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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/realtime.ts
|
|
10
|
+
var REALTIME_MAX_EVENTS = 100;
|
|
11
|
+
var REALTIME_MAX_EVENT_BYTES = 64 * 1024;
|
|
12
|
+
var REALTIME_MAX_BATCH_BYTES = 512 * 1024;
|
|
13
|
+
var REALTIME_MAX_SOCKETS_PER_SHARD = 1000;
|
|
14
|
+
var REALTIME_MAX_SHARDS_PER_TOPIC = 1024;
|
|
15
|
+
var ATOM = /^[A-Za-z0-9][A-Za-z0-9_.:@/-]{0,255}$/;
|
|
16
|
+
var PRINCIPAL_KINDS = new Set(["user", "node", "api-key", "service", "header"]);
|
|
17
|
+
var atom = (value) => typeof value === "string" && ATOM.test(value);
|
|
18
|
+
function validateRealtimeAudience(input) {
|
|
19
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
20
|
+
throw new Error("Realtime audience must be an object.");
|
|
21
|
+
const row = input;
|
|
22
|
+
if (row.kind === "public") {
|
|
23
|
+
if (Object.keys(row).length !== 1)
|
|
24
|
+
throw new Error("Public realtime audience has no additional coordinates.");
|
|
25
|
+
return { kind: "public" };
|
|
26
|
+
}
|
|
27
|
+
if (!atom(row.realmId) || !atom(row.capability))
|
|
28
|
+
throw new Error("Realtime audience scope is invalid.");
|
|
29
|
+
if (row.kind === "principal" && PRINCIPAL_KINDS.has(row.principalKind) && atom(row.principalKey)) {
|
|
30
|
+
return {
|
|
31
|
+
kind: "principal",
|
|
32
|
+
realmId: row.realmId,
|
|
33
|
+
principalKind: row.principalKind,
|
|
34
|
+
principalKey: row.principalKey,
|
|
35
|
+
capability: row.capability
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (row.kind === "project" && atom(row.projectKey)) {
|
|
39
|
+
return { kind: "project", realmId: row.realmId, projectKey: row.projectKey, capability: row.capability };
|
|
40
|
+
}
|
|
41
|
+
if (row.kind === "group" && atom(row.group)) {
|
|
42
|
+
return { kind: "group", realmId: row.realmId, group: row.group, capability: row.capability };
|
|
43
|
+
}
|
|
44
|
+
throw new Error("Realtime audience coordinates are invalid.");
|
|
45
|
+
}
|
|
46
|
+
function validateRealtimeBatch(input) {
|
|
47
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
48
|
+
throw new Error("Realtime batch must be an object.");
|
|
49
|
+
const row = input;
|
|
50
|
+
if (row.version !== 2 || !ATOM.test(row.batchId ?? "") || !ATOM.test(row.topic ?? "") || !Number.isSafeInteger(row.publishedAtMs) || row.publishedAtMs < 0 || !Array.isArray(row.events) || row.events.length < 1 || row.events.length > REALTIME_MAX_EVENTS)
|
|
51
|
+
throw new Error("Realtime batch coordinates are invalid.");
|
|
52
|
+
const audience = validateRealtimeAudience(row.audience);
|
|
53
|
+
const events = row.events.map((event) => {
|
|
54
|
+
if (!event || typeof event !== "object" || Array.isArray(event))
|
|
55
|
+
throw new Error("Realtime event must be an object.");
|
|
56
|
+
const value = event;
|
|
57
|
+
if (!ATOM.test(value.id ?? "") || !ATOM.test(value.type ?? ""))
|
|
58
|
+
throw new Error("Realtime event id/type is invalid.");
|
|
59
|
+
const encoded = JSON.stringify(value.payload);
|
|
60
|
+
if (encoded === undefined || new TextEncoder().encode(encoded).byteLength > REALTIME_MAX_EVENT_BYTES) {
|
|
61
|
+
throw new Error("Realtime event payload is too large or not JSON serializable.");
|
|
62
|
+
}
|
|
63
|
+
return { id: value.id, type: value.type, payload: value.payload };
|
|
64
|
+
});
|
|
65
|
+
if (new Set(events.map(({ id }) => id)).size !== events.length)
|
|
66
|
+
throw new Error("Realtime event ids must be unique in a batch.");
|
|
67
|
+
const batch = {
|
|
68
|
+
version: 2,
|
|
69
|
+
batchId: row.batchId,
|
|
70
|
+
topic: row.topic,
|
|
71
|
+
audience,
|
|
72
|
+
publishedAtMs: row.publishedAtMs,
|
|
73
|
+
events
|
|
74
|
+
};
|
|
75
|
+
if (new TextEncoder().encode(JSON.stringify(batch)).byteLength > REALTIME_MAX_BATCH_BYTES) {
|
|
76
|
+
throw new Error("Realtime batch is too large.");
|
|
77
|
+
}
|
|
78
|
+
return batch;
|
|
79
|
+
}
|
|
80
|
+
var realtimeBatchBytes = (input) => JSON.stringify(validateRealtimeBatch(input));
|
|
81
|
+
function boundedUniqueAtoms(value, maximum, label) {
|
|
82
|
+
if (!Array.isArray(value) || value.length > maximum || value.some((item) => !atom(item)) || new Set(value).size !== value.length)
|
|
83
|
+
throw new Error(`Realtime ticket ${label} are invalid.`);
|
|
84
|
+
return [...value];
|
|
85
|
+
}
|
|
86
|
+
function validateRealtimeSubscriptionTicket(input, nowSec = Math.floor(Date.now() / 1000)) {
|
|
87
|
+
if (!input || typeof input !== "object" || Array.isArray(input))
|
|
88
|
+
throw new Error("Realtime ticket must be an object.");
|
|
89
|
+
const row = input;
|
|
90
|
+
if (row.version !== 2 || !atom(row.topic) || !atom(row.realmId) || !atom(row.nonce) || !row.principal || !PRINCIPAL_KINDS.has(row.principal.kind) || !atom(row.principal.key) || !Number.isSafeInteger(row.expiresAtSec) || row.expiresAtSec <= nowSec || row.expiresAtSec > nowSec + 300)
|
|
91
|
+
throw new Error("Realtime ticket is invalid or expired.");
|
|
92
|
+
return {
|
|
93
|
+
version: 2,
|
|
94
|
+
topic: row.topic,
|
|
95
|
+
principal: { kind: row.principal.kind, key: row.principal.key },
|
|
96
|
+
realmId: row.realmId,
|
|
97
|
+
projectKeys: boundedUniqueAtoms(row.projectKeys, 32, "project keys"),
|
|
98
|
+
groups: boundedUniqueAtoms(row.groups, 16, "groups"),
|
|
99
|
+
capabilities: boundedUniqueAtoms(row.capabilities, 64, "capabilities"),
|
|
100
|
+
expiresAtSec: row.expiresAtSec,
|
|
101
|
+
nonce: row.nonce
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
function canReceiveRealtimeAudience(ticket, audience) {
|
|
105
|
+
if (audience.kind === "public")
|
|
106
|
+
return true;
|
|
107
|
+
if (ticket.realmId !== audience.realmId || !ticket.capabilities.includes(audience.capability))
|
|
108
|
+
return false;
|
|
109
|
+
if (audience.kind === "principal") {
|
|
110
|
+
return ticket.principal.kind === audience.principalKind && ticket.principal.key === audience.principalKey;
|
|
111
|
+
}
|
|
112
|
+
if (audience.kind === "project")
|
|
113
|
+
return ticket.projectKeys.includes(audience.projectKey);
|
|
114
|
+
return ticket.groups.includes(audience.group);
|
|
115
|
+
}
|
|
116
|
+
var realtimeShardKey = (topic, shard) => {
|
|
117
|
+
if (!ATOM.test(topic) || !Number.isSafeInteger(shard) || shard < 0 || shard >= REALTIME_MAX_SHARDS_PER_TOPIC) {
|
|
118
|
+
throw new Error("Realtime shard coordinates are invalid.");
|
|
119
|
+
}
|
|
120
|
+
return `rt:${topic}:${shard.toString().padStart(4, "0")}`;
|
|
121
|
+
};
|
|
122
|
+
var bytesToHex = (bytes) => [...bytes].map((value) => value.toString(16).padStart(2, "0")).join("");
|
|
123
|
+
var timingEqual = (left, right) => {
|
|
124
|
+
if (left.length !== right.length)
|
|
125
|
+
return false;
|
|
126
|
+
let difference = 0;
|
|
127
|
+
for (let index = 0;index < left.length; index += 1)
|
|
128
|
+
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
|
|
129
|
+
return difference === 0;
|
|
130
|
+
};
|
|
131
|
+
async function realtimeHmac(secret, message) {
|
|
132
|
+
if (new TextEncoder().encode(secret).byteLength < 32)
|
|
133
|
+
throw new Error("Realtime secret must contain at least 32 bytes.");
|
|
134
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
135
|
+
return bytesToHex(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message))));
|
|
136
|
+
}
|
|
137
|
+
async function verifyRealtimeHmac(secret, message, signature) {
|
|
138
|
+
return /^[a-f0-9]{64}$/.test(signature) && timingEqual(await realtimeHmac(secret, message), signature);
|
|
139
|
+
}
|
|
140
|
+
var base64url = (value) => {
|
|
141
|
+
const bytes = new TextEncoder().encode(value);
|
|
142
|
+
let binary = "";
|
|
143
|
+
for (const byte of bytes)
|
|
144
|
+
binary += String.fromCharCode(byte);
|
|
145
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
146
|
+
};
|
|
147
|
+
var fromBase64url = (value) => {
|
|
148
|
+
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
149
|
+
const binary = atob(normalized + "=".repeat((4 - normalized.length % 4) % 4));
|
|
150
|
+
return new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
|
|
151
|
+
};
|
|
152
|
+
async function issueRealtimeSubscriptionTicket(secret, ticket, nowSec = Math.floor(Date.now() / 1000)) {
|
|
153
|
+
const body = base64url(JSON.stringify(validateRealtimeSubscriptionTicket(ticket, nowSec)));
|
|
154
|
+
return `${body}.${await realtimeHmac(secret, `ticket
|
|
155
|
+
${body}`)}`;
|
|
156
|
+
}
|
|
157
|
+
async function verifyRealtimeSubscriptionToken(secret, token, nowSec = Math.floor(Date.now() / 1000)) {
|
|
158
|
+
const [body, signature, ...extra] = token.split(".");
|
|
159
|
+
if (!body || !signature || extra.length || !await verifyRealtimeHmac(secret, `ticket
|
|
160
|
+
${body}`, signature))
|
|
161
|
+
return null;
|
|
162
|
+
try {
|
|
163
|
+
return validateRealtimeSubscriptionTicket(JSON.parse(fromBase64url(body)), nowSec);
|
|
164
|
+
} catch {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
export {
|
|
169
|
+
verifyRealtimeSubscriptionToken,
|
|
170
|
+
verifyRealtimeHmac,
|
|
171
|
+
validateRealtimeSubscriptionTicket,
|
|
172
|
+
validateRealtimeBatch,
|
|
173
|
+
validateRealtimeAudience,
|
|
174
|
+
realtimeShardKey,
|
|
175
|
+
realtimeHmac,
|
|
176
|
+
realtimeBatchBytes,
|
|
177
|
+
issueRealtimeSubscriptionTicket,
|
|
178
|
+
canReceiveRealtimeAudience,
|
|
179
|
+
REALTIME_MAX_SOCKETS_PER_SHARD,
|
|
180
|
+
REALTIME_MAX_SHARDS_PER_TOPIC,
|
|
181
|
+
REALTIME_MAX_EVENT_BYTES,
|
|
182
|
+
REALTIME_MAX_EVENTS,
|
|
183
|
+
REALTIME_MAX_BATCH_BYTES
|
|
184
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgezero/runtime",
|
|
3
|
-
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public",
|
|
@@ -47,6 +47,10 @@
|
|
|
47
47
|
"types": "./dist/query.d.ts",
|
|
48
48
|
"default": "./dist/query.js"
|
|
49
49
|
},
|
|
50
|
+
"./realtime": {
|
|
51
|
+
"types": "./dist/realtime.d.ts",
|
|
52
|
+
"default": "./dist/realtime.js"
|
|
53
|
+
},
|
|
50
54
|
"./calendar": {
|
|
51
55
|
"types": "./dist/calendar.d.ts",
|
|
52
56
|
"default": "./dist/calendar.js"
|
|
@@ -55,6 +59,10 @@
|
|
|
55
59
|
"types": "./dist/identity.d.ts",
|
|
56
60
|
"default": "./dist/identity.js"
|
|
57
61
|
},
|
|
62
|
+
"./passkey-hybrid": {
|
|
63
|
+
"types": "./dist/passkey-hybrid.d.ts",
|
|
64
|
+
"default": "./dist/passkey-hybrid.js"
|
|
65
|
+
},
|
|
58
66
|
"./totp": {
|
|
59
67
|
"types": "./dist/totp.d.ts",
|
|
60
68
|
"default": "./dist/totp.js"
|
|
@@ -155,10 +163,6 @@
|
|
|
155
163
|
"types": "./dist/phrase.d.ts",
|
|
156
164
|
"default": "./dist/phrase.js"
|
|
157
165
|
},
|
|
158
|
-
"./ssh-agent": {
|
|
159
|
-
"types": "./dist/ssh-agent.d.ts",
|
|
160
|
-
"default": "./dist/ssh-agent.js"
|
|
161
|
-
},
|
|
162
166
|
"./slip10": {
|
|
163
167
|
"types": "./dist/slip10.d.ts",
|
|
164
168
|
"default": "./dist/slip10.js"
|
|
@@ -183,7 +187,7 @@
|
|
|
183
187
|
"scripts": {
|
|
184
188
|
"check": "tsc --noEmit",
|
|
185
189
|
"prebuild": "rm -rf dist",
|
|
186
|
-
"build": "bun build src/query.ts src/jobs.ts src/queue.ts src/outbox.ts src/audit.ts src/backup.ts src/notify.ts src/notify-templates.ts src/calendar.ts src/compliance.ts src/pipeline.ts src/totp.ts src/otpauth.ts src/identity.ts src/slip10.ts src/openssh.ts src/ssh-cert.ts src/importers.ts src/snp.ts src/passkey.ts src/
|
|
190
|
+
"build": "bun build src/query.ts src/realtime.ts src/jobs.ts src/queue.ts src/outbox.ts src/audit.ts src/backup.ts src/notify.ts src/notify-templates.ts src/calendar.ts src/compliance.ts src/pipeline.ts src/totp.ts src/otpauth.ts src/identity.ts src/slip10.ts src/openssh.ts src/ssh-cert.ts src/importers.ts src/snp.ts src/passkey.ts src/passkey-hybrid.ts src/custody-crypto.ts src/custody-share.ts src/phrase.ts src/schema.ts src/schema-typebox.ts src/finance/discounts.ts src/finance/money.ts src/finance/storage.ts src/finance/custody.ts src/finance/tax.ts src/finance/derive.ts src/finance/venues.ts src/finance/ledger.ts src/finance/rates.ts src/finance/transfers.ts src/finance/chain.ts src/finance/chain-addresses.ts src/finance/chain-deposits.ts src/finance/chain-withdrawals.ts src/finance/chain-reconcile.ts src/finance/market.ts src/finance/commission.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
|
|
187
191
|
"prepublishOnly": "bun run check && bun run build"
|
|
188
192
|
},
|
|
189
193
|
"dependencies": {
|
package/dist/ssh-agent.d.ts
DELETED
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* SSH agent as a custody factor.
|
|
3
|
-
*
|
|
4
|
-
* The ceremony seals every custodian share twice — once under a key derived from
|
|
5
|
-
* a WebAuthn PRF output, once under a key derived from a BIP-39 phrase — and
|
|
6
|
-
* either alone opens it. That works in a browser and not at all over SSH, which
|
|
7
|
-
* is where the platform is first brought up: there is no passkey before there is
|
|
8
|
-
* a platform to register one against.
|
|
9
|
-
*
|
|
10
|
-
* An SSH agent fills the same slot. Both are an agent-held key that produces a
|
|
11
|
-
* stable secret without ever exposing the key itself, so the SSH signature over
|
|
12
|
-
* a fixed challenge substitutes for the PRF output with no change to the sealing
|
|
13
|
-
* code. The custodian later registers a passkey through the UI and the same
|
|
14
|
-
* share gains a browser route.
|
|
15
|
-
*
|
|
16
|
-
* **Ed25519 only, and that is a correctness requirement rather than a
|
|
17
|
-
* preference.** The derived key must be identical on every enrolment and every
|
|
18
|
-
* unlock, so the signature has to be deterministic. Ed25519 is (RFC 8032). RSA
|
|
19
|
-
* with PKCS#1 v1.5 happens to be, but agents are free to offer RSA-PSS for the
|
|
20
|
-
* same key, and PSS is randomised — a share sealed under one PSS signature could
|
|
21
|
-
* never be opened again. Refusing everything except Ed25519 makes that
|
|
22
|
-
* impossible rather than rare.
|
|
23
|
-
*/
|
|
24
|
-
export declare class SshAgentError extends Error {
|
|
25
|
-
}
|
|
26
|
-
export interface AgentIdentity {
|
|
27
|
-
/** Raw SSH public key blob, as the agent returns it. */
|
|
28
|
-
blob: Buffer;
|
|
29
|
-
comment: string;
|
|
30
|
-
/** `ssh-ed25519`, etc. Anything else is refused — see the module note. */
|
|
31
|
-
type: string;
|
|
32
|
-
/** `SHA256:…`, matching `ssh-add -l`, so a human can confirm which key. */
|
|
33
|
-
fingerprint: string;
|
|
34
|
-
}
|
|
35
|
-
/**
|
|
36
|
-
* How long to wait on the agent before giving up.
|
|
37
|
-
*
|
|
38
|
-
* An agent holding a forwarded key whose upstream is gone, or a confirm-on-use
|
|
39
|
-
* key with nobody at the terminal, does not refuse — it simply never answers.
|
|
40
|
-
* Without a socket-level deadline those connections are never closed, and a
|
|
41
|
-
* caller that races them against its own timer leaks one socket per attempt
|
|
42
|
-
* until the agent stops accepting connections entirely.
|
|
43
|
-
*/
|
|
44
|
-
export declare const AGENT_TIMEOUT_MS = 3000;
|
|
45
|
-
/** The keys the agent is holding. Matches `ssh-add -l`. */
|
|
46
|
-
export declare function listIdentities(socketPath?: string): Promise<AgentIdentity[]>;
|
|
47
|
-
/** Ed25519 only. See the module note — determinism is the whole mechanism. */
|
|
48
|
-
export declare function listCustodyIdentities(socketPath?: string): Promise<AgentIdentity[]>;
|
|
49
|
-
/**
|
|
50
|
-
* Sign arbitrary bytes with an identity the agent holds.
|
|
51
|
-
*
|
|
52
|
-
* Exported because a fresh proof from a terminal needs it: the server issues a
|
|
53
|
-
* nonce and this is what turns it into something the server can verify against
|
|
54
|
-
* a registered public key. `deriveCustodyKey` signs a FIXED challenge and hashes
|
|
55
|
-
* the result — that is a key-derivation, not a proof, and using it as one would
|
|
56
|
-
* replay.
|
|
57
|
-
*
|
|
58
|
-
* Returns the raw ed25519 signature, unwrapped from the agent's blob, because
|
|
59
|
-
* that is what a verifier takes.
|
|
60
|
-
*/
|
|
61
|
-
export declare function signWithIdentity(identity: AgentIdentity, data: Uint8Array, socketPath?: string): Promise<Uint8Array>;
|
|
62
|
-
/**
|
|
63
|
-
* Derive the 32-byte custody key for an identity.
|
|
64
|
-
*
|
|
65
|
-
* Stable across processes and machines for the same key, which is what lets a
|
|
66
|
-
* custodian enrol today and unlock next month from a different laptop with the
|
|
67
|
-
* same key in their agent.
|
|
68
|
-
*
|
|
69
|
-
* HKDF over the signature rather than the signature itself: the signature is a
|
|
70
|
-
* value the agent will hand to anything that asks, so using it directly as a
|
|
71
|
-
* key would mean any process that can reach the socket holds the custody key.
|
|
72
|
-
* The salt and info bind it to this purpose.
|
|
73
|
-
*/
|
|
74
|
-
export declare function deriveCustodyKey(identity: AgentIdentity, socketPath?: string): Promise<Uint8Array>;
|
|
75
|
-
/**
|
|
76
|
-
* Prove the derivation reproduces before it is trusted with a share.
|
|
77
|
-
*
|
|
78
|
-
* Signs twice and compares. A non-deterministic agent — a smartcard doing PSS, a
|
|
79
|
-
* forwarded agent that swapped keys mid-ceremony — would otherwise seal a share
|
|
80
|
-
* under a key that can never be reproduced, and the failure would surface only
|
|
81
|
-
* at the worst possible moment: recovery.
|
|
82
|
-
*/
|
|
83
|
-
export declare function assertDeterministic(identity: AgentIdentity, socketPath?: string): Promise<Uint8Array>;
|
package/dist/ssh-agent.js
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
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/ssh-agent.ts
|
|
10
|
-
import { Socket } from "node:net";
|
|
11
|
-
import { createHash, hkdfSync } from "node:crypto";
|
|
12
|
-
|
|
13
|
-
class SshAgentError extends Error {
|
|
14
|
-
}
|
|
15
|
-
var SSH_AGENTC_REQUEST_IDENTITIES = 11;
|
|
16
|
-
var SSH_AGENT_IDENTITIES_ANSWER = 12;
|
|
17
|
-
var SSH_AGENTC_SIGN_REQUEST = 13;
|
|
18
|
-
var SSH_AGENT_SIGN_RESPONSE = 14;
|
|
19
|
-
var CUSTODY_CHALLENGE = Buffer.from("forgezero/custody/ssh-agent/v1", "utf8");
|
|
20
|
-
function readString(buffer, offset) {
|
|
21
|
-
const length = buffer.readUInt32BE(offset);
|
|
22
|
-
const start = offset + 4;
|
|
23
|
-
return [buffer.subarray(start, start + length), start + length];
|
|
24
|
-
}
|
|
25
|
-
function writeString(value) {
|
|
26
|
-
const length = Buffer.alloc(4);
|
|
27
|
-
length.writeUInt32BE(value.length);
|
|
28
|
-
return Buffer.concat([length, value]);
|
|
29
|
-
}
|
|
30
|
-
function frame(payload) {
|
|
31
|
-
const length = Buffer.alloc(4);
|
|
32
|
-
length.writeUInt32BE(payload.length);
|
|
33
|
-
return Buffer.concat([length, payload]);
|
|
34
|
-
}
|
|
35
|
-
var AGENT_TIMEOUT_MS = 3000;
|
|
36
|
-
async function request(socketPath, payload) {
|
|
37
|
-
return new Promise((resolve, reject) => {
|
|
38
|
-
const socket = new Socket;
|
|
39
|
-
const chunks = [];
|
|
40
|
-
let expected = null;
|
|
41
|
-
let settled = false;
|
|
42
|
-
const fail = (message) => {
|
|
43
|
-
if (settled)
|
|
44
|
-
return;
|
|
45
|
-
settled = true;
|
|
46
|
-
socket.destroy();
|
|
47
|
-
reject(new SshAgentError(message));
|
|
48
|
-
};
|
|
49
|
-
socket.setTimeout(AGENT_TIMEOUT_MS, () => fail("SSH_AGENT_TIMEOUT"));
|
|
50
|
-
socket.on("error", () => fail("SSH_AGENT_UNREACHABLE"));
|
|
51
|
-
socket.on("connect", () => socket.write(frame(payload)));
|
|
52
|
-
socket.on("data", (chunk) => {
|
|
53
|
-
chunks.push(chunk);
|
|
54
|
-
const all = Buffer.concat(chunks);
|
|
55
|
-
if (expected === null && all.length >= 4)
|
|
56
|
-
expected = all.readUInt32BE(0);
|
|
57
|
-
if (expected !== null && all.length >= expected + 4) {
|
|
58
|
-
if (settled)
|
|
59
|
-
return;
|
|
60
|
-
settled = true;
|
|
61
|
-
socket.end();
|
|
62
|
-
resolve(all.subarray(4, expected + 4));
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
socket.on("close", () => {
|
|
66
|
-
if (expected === null)
|
|
67
|
-
fail("SSH_AGENT_CLOSED_EARLY");
|
|
68
|
-
});
|
|
69
|
-
socket.connect(socketPath);
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
function agentSocket(explicit) {
|
|
73
|
-
const path = explicit ?? process.env.SSH_AUTH_SOCK;
|
|
74
|
-
if (!path)
|
|
75
|
-
throw new SshAgentError("SSH_AUTH_SOCK_NOT_SET");
|
|
76
|
-
return path;
|
|
77
|
-
}
|
|
78
|
-
async function listIdentities(socketPath) {
|
|
79
|
-
const response = await request(agentSocket(socketPath), Buffer.from([SSH_AGENTC_REQUEST_IDENTITIES]));
|
|
80
|
-
if (response[0] !== SSH_AGENT_IDENTITIES_ANSWER) {
|
|
81
|
-
throw new SshAgentError("SSH_AGENT_BAD_RESPONSE");
|
|
82
|
-
}
|
|
83
|
-
const count = response.readUInt32BE(1);
|
|
84
|
-
const identities = [];
|
|
85
|
-
let offset = 5;
|
|
86
|
-
for (let index = 0;index < count; index += 1) {
|
|
87
|
-
const [blob, afterBlob] = readString(response, offset);
|
|
88
|
-
const [comment, afterComment] = readString(response, afterBlob);
|
|
89
|
-
offset = afterComment;
|
|
90
|
-
const [type] = readString(blob, 0);
|
|
91
|
-
identities.push({
|
|
92
|
-
blob,
|
|
93
|
-
comment: comment.toString("utf8"),
|
|
94
|
-
type: type.toString("utf8"),
|
|
95
|
-
fingerprint: `SHA256:${createHash("sha256").update(blob).digest("base64").replace(/=+$/, "")}`
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
return identities;
|
|
99
|
-
}
|
|
100
|
-
async function listCustodyIdentities(socketPath) {
|
|
101
|
-
return (await listIdentities(socketPath)).filter((id) => id.type === "ssh-ed25519");
|
|
102
|
-
}
|
|
103
|
-
async function signWithIdentity(identity, data, socketPath) {
|
|
104
|
-
const wrapped = await sign(identity.blob, Buffer.from(data), socketPath);
|
|
105
|
-
const [raw] = readString(wrapped, readString(wrapped, 0)[1]);
|
|
106
|
-
return new Uint8Array(raw);
|
|
107
|
-
}
|
|
108
|
-
async function sign(blob, data, socketPath) {
|
|
109
|
-
const payload = Buffer.concat([
|
|
110
|
-
Buffer.from([SSH_AGENTC_SIGN_REQUEST]),
|
|
111
|
-
writeString(blob),
|
|
112
|
-
writeString(data),
|
|
113
|
-
Buffer.alloc(4)
|
|
114
|
-
]);
|
|
115
|
-
const response = await request(agentSocket(socketPath), payload);
|
|
116
|
-
if (response[0] !== SSH_AGENT_SIGN_RESPONSE) {
|
|
117
|
-
throw new SshAgentError("SSH_AGENT_SIGN_REFUSED");
|
|
118
|
-
}
|
|
119
|
-
const [signature] = readString(response, 1);
|
|
120
|
-
return signature;
|
|
121
|
-
}
|
|
122
|
-
async function deriveCustodyKey(identity, socketPath) {
|
|
123
|
-
if (identity.type !== "ssh-ed25519") {
|
|
124
|
-
throw new SshAgentError("SSH_KEY_TYPE_UNSUPPORTED");
|
|
125
|
-
}
|
|
126
|
-
const signature = await sign(identity.blob, CUSTODY_CHALLENGE, socketPath);
|
|
127
|
-
if (signature.length < 32)
|
|
128
|
-
throw new SshAgentError("SSH_AGENT_SIGNATURE_TOO_SHORT");
|
|
129
|
-
return new Uint8Array(hkdfSync("sha256", signature, identity.blob, Buffer.from("forgezero/custody/ssh-key/v1", "utf8"), 32));
|
|
130
|
-
}
|
|
131
|
-
async function assertDeterministic(identity, socketPath) {
|
|
132
|
-
const first = await deriveCustodyKey(identity, socketPath);
|
|
133
|
-
const second = await deriveCustodyKey(identity, socketPath);
|
|
134
|
-
if (Buffer.compare(Buffer.from(first), Buffer.from(second)) !== 0) {
|
|
135
|
-
throw new SshAgentError("SSH_AGENT_NOT_DETERMINISTIC");
|
|
136
|
-
}
|
|
137
|
-
return first;
|
|
138
|
-
}
|
|
139
|
-
export {
|
|
140
|
-
signWithIdentity,
|
|
141
|
-
listIdentities,
|
|
142
|
-
listCustodyIdentities,
|
|
143
|
-
deriveCustodyKey,
|
|
144
|
-
assertDeterministic,
|
|
145
|
-
SshAgentError,
|
|
146
|
-
AGENT_TIMEOUT_MS
|
|
147
|
-
};
|