@johnhenry/wsh 0.7.0 → 0.13.0
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/package.json +4 -1
- package/src/auth.mjs +106 -0
- package/src/client.mjs +491 -174
- package/src/file-transfer.mjs +39 -166
- package/src/index.d.ts +110 -0
- package/src/index.mjs +13 -0
- package/src/messages.gen.mjs +66 -10
- package/src/messages.mjs +1 -1
- package/src/mlkem.mjs +144 -0
- package/src/qmux-connection.mjs +698 -0
- package/src/qmux.mjs +620 -0
- package/src/session.mjs +62 -0
- package/src/transport-ws.mjs +145 -352
package/src/mlkem.mjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ML-KEM-768 (FIPS 203) for the hybrid X25519+ML-KEM-768 E2E key exchange
|
|
3
|
+
* (see `client.mjs`'s `initiateE2E` "hybrid" mode).
|
|
4
|
+
*
|
|
5
|
+
* Prefers the native WebCrypto `ML-KEM-768` algorithm (Node 24.7+; an
|
|
6
|
+
* experimental W3C draft also implemented by some browsers) so this
|
|
7
|
+
* library adds no *required* runtime dependency, matching its existing
|
|
8
|
+
* zero-runtime-dependency design (see `EncryptedFrame`'s spec note on why
|
|
9
|
+
* AES-GCM was picked over ChaCha20-Poly1305 for the same reason). Falls
|
|
10
|
+
* back to the optional `@noble/post-quantum` package's pure-JS
|
|
11
|
+
* implementation only when native support is absent -- most browsers
|
|
12
|
+
* today -- via a dynamic import, so it's never loaded unless hybrid mode
|
|
13
|
+
* is actually used AND native support is missing.
|
|
14
|
+
*
|
|
15
|
+
* Public keys are always raw ML-KEM-768 bytes (1184 bytes); private keys
|
|
16
|
+
* are always represented as a 64-byte keygen seed -- the one format both
|
|
17
|
+
* backends already agree on (native WebCrypto's `raw-seed` import/export,
|
|
18
|
+
* and noble's `keygen(seed)`) -- never a `CryptoKey` or an expanded
|
|
19
|
+
* secret key, so callers never need to know which backend is active.
|
|
20
|
+
*
|
|
21
|
+
* Native support is unreliable to assume from feature presence alone: a
|
|
22
|
+
* runtime can expose `encapsulateBits` and friends while not actually
|
|
23
|
+
* implementing them correctly, so support is probed with a full
|
|
24
|
+
* generate/encapsulate/decapsulate round trip on first use, not just a
|
|
25
|
+
* `typeof` check.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const ALG = { name: 'ML-KEM-768' };
|
|
29
|
+
export const MLKEM768_PUBLIC_KEY_LENGTH = 1184;
|
|
30
|
+
export const MLKEM768_CIPHERTEXT_LENGTH = 1088;
|
|
31
|
+
export const MLKEM768_SHARED_SECRET_LENGTH = 32;
|
|
32
|
+
const MLKEM768_SEED_LENGTH = 64;
|
|
33
|
+
|
|
34
|
+
let backendPromise = null; // Promise<'native' | 'noble'>, memoized after first probe
|
|
35
|
+
let noblePromise = null; // Promise<typeof import('@noble/post-quantum/ml-kem.js')>, memoized
|
|
36
|
+
|
|
37
|
+
async function loadNoble() {
|
|
38
|
+
if (!noblePromise) {
|
|
39
|
+
noblePromise = import('@noble/post-quantum/ml-kem.js').catch((err) => {
|
|
40
|
+
noblePromise = null;
|
|
41
|
+
throw new Error(
|
|
42
|
+
'Hybrid PQ key exchange requires either native WebCrypto ML-KEM-768 support ' +
|
|
43
|
+
'(Node 24.7+, or a browser implementing the experimental draft) or the optional ' +
|
|
44
|
+
`"@noble/post-quantum" package installed as a fallback. (${err.message})`
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return noblePromise;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function probeNativeSupport() {
|
|
52
|
+
try {
|
|
53
|
+
const { publicKey, privateKey } = await crypto.subtle.generateKey(ALG, true, ['encapsulateBits', 'decapsulateBits']);
|
|
54
|
+
const { ciphertext, sharedKey } = await crypto.subtle.encapsulateBits(ALG, publicKey);
|
|
55
|
+
const decapsulated = await crypto.subtle.decapsulateBits(ALG, privateKey, ciphertext);
|
|
56
|
+
return bytesEqual(new Uint8Array(sharedKey), new Uint8Array(decapsulated));
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function bytesEqual(a, b) {
|
|
63
|
+
if (a.length !== b.length) return false;
|
|
64
|
+
let diff = 0;
|
|
65
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
66
|
+
return diff === 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function getBackend() {
|
|
70
|
+
if (!backendPromise) {
|
|
71
|
+
backendPromise = probeNativeSupport().then((supported) => (supported ? 'native' : 'noble'));
|
|
72
|
+
}
|
|
73
|
+
return backendPromise;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Import a 64-byte seed as a native ML-KEM-768 private key, usable for decapsulation. */
|
|
77
|
+
async function importNativePrivateKey(seed) {
|
|
78
|
+
return crypto.subtle.importKey('raw-seed', seed, ALG, false, ['decapsulateBits']);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Derive the raw public key bytes for a 64-byte seed, via whichever backend is active. */
|
|
82
|
+
async function publicKeyFromSeed(seed, backend) {
|
|
83
|
+
if (backend === 'native') {
|
|
84
|
+
const privateKey = await importNativePrivateKey(seed);
|
|
85
|
+
const publicKey = await crypto.subtle.getPublicKey(privateKey, ['encapsulateBits']);
|
|
86
|
+
return new Uint8Array(await crypto.subtle.exportKey('raw-public', publicKey));
|
|
87
|
+
}
|
|
88
|
+
const { ml_kem768 } = await loadNoble();
|
|
89
|
+
return ml_kem768.keygen(seed).publicKey;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Generate a fresh ML-KEM-768 key pair.
|
|
94
|
+
* @returns {Promise<{ publicKey: Uint8Array, secretKeySeed: Uint8Array }>}
|
|
95
|
+
*/
|
|
96
|
+
export async function generateMlKemKeyPair() {
|
|
97
|
+
const seed = new Uint8Array(MLKEM768_SEED_LENGTH);
|
|
98
|
+
crypto.getRandomValues(seed);
|
|
99
|
+
const backend = await getBackend();
|
|
100
|
+
const publicKey = await publicKeyFromSeed(seed, backend);
|
|
101
|
+
return { publicKey, secretKeySeed: seed };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Encapsulate against a peer's raw ML-KEM-768 public key.
|
|
106
|
+
* @param {Uint8Array} publicKey - 1184-byte raw public key
|
|
107
|
+
* @returns {Promise<{ ciphertext: Uint8Array, sharedSecret: Uint8Array }>}
|
|
108
|
+
*/
|
|
109
|
+
export async function mlKemEncapsulate(publicKey) {
|
|
110
|
+
if (publicKey.length !== MLKEM768_PUBLIC_KEY_LENGTH) {
|
|
111
|
+
throw new Error(`ML-KEM-768 public key must be ${MLKEM768_PUBLIC_KEY_LENGTH} bytes, got ${publicKey.length}`);
|
|
112
|
+
}
|
|
113
|
+
const backend = await getBackend();
|
|
114
|
+
if (backend === 'native') {
|
|
115
|
+
const importedPublicKey = await crypto.subtle.importKey('raw-public', publicKey, ALG, false, ['encapsulateBits']);
|
|
116
|
+
const { ciphertext, sharedKey } = await crypto.subtle.encapsulateBits(ALG, importedPublicKey);
|
|
117
|
+
return { ciphertext: new Uint8Array(ciphertext), sharedSecret: new Uint8Array(sharedKey) };
|
|
118
|
+
}
|
|
119
|
+
const { ml_kem768 } = await loadNoble();
|
|
120
|
+
const randomness = new Uint8Array(32);
|
|
121
|
+
crypto.getRandomValues(randomness);
|
|
122
|
+
const { cipherText, sharedSecret } = ml_kem768.encapsulate(publicKey, randomness);
|
|
123
|
+
return { ciphertext: cipherText, sharedSecret };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Decapsulate a ciphertext with a previously-generated seed's private key.
|
|
128
|
+
* @param {Uint8Array} secretKeySeed - 64-byte seed from `generateMlKemKeyPair`
|
|
129
|
+
* @param {Uint8Array} ciphertext - 1088-byte ciphertext from `mlKemEncapsulate`
|
|
130
|
+
* @returns {Promise<Uint8Array>} 32-byte shared secret
|
|
131
|
+
*/
|
|
132
|
+
export async function mlKemDecapsulate(secretKeySeed, ciphertext) {
|
|
133
|
+
if (ciphertext.length !== MLKEM768_CIPHERTEXT_LENGTH) {
|
|
134
|
+
throw new Error(`ML-KEM-768 ciphertext must be ${MLKEM768_CIPHERTEXT_LENGTH} bytes, got ${ciphertext.length}`);
|
|
135
|
+
}
|
|
136
|
+
const backend = await getBackend();
|
|
137
|
+
if (backend === 'native') {
|
|
138
|
+
const privateKey = await importNativePrivateKey(secretKeySeed);
|
|
139
|
+
const sharedKey = await crypto.subtle.decapsulateBits(ALG, privateKey, ciphertext);
|
|
140
|
+
return new Uint8Array(sharedKey);
|
|
141
|
+
}
|
|
142
|
+
const { ml_kem768 } = await loadNoble();
|
|
143
|
+
return ml_kem768.decapsulate(ciphertext, ml_kem768.keygen(secretKeySeed).secretKey);
|
|
144
|
+
}
|