@muhkoo/connect 0.14.0-alpha.8 → 0.15.0-alpha.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/README.md +53 -4
- package/dist/browser/index.js +4631 -990
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +1165 -55
- package/dist/connect.workers.d.ts +38 -1
- package/dist/server/index.js +4631 -990
- package/dist/server/index.js.map +1 -1
- package/dist/workers/index.js +111 -22
- package/dist/workers/index.js.map +1 -1
- package/package.json +1 -1
package/dist/workers/index.js
CHANGED
|
@@ -1030,6 +1030,8 @@ class KeyStore {
|
|
|
1030
1030
|
static instance;
|
|
1031
1031
|
_keys = new Map();
|
|
1032
1032
|
_authKeys = new Map();
|
|
1033
|
+
/** The exact pairs marked vault-backed, so a pair written over them later is never taken for them. */
|
|
1034
|
+
_vaultBacked = new Map();
|
|
1033
1035
|
constructor() { }
|
|
1034
1036
|
static getInstance() {
|
|
1035
1037
|
if (!KeyStore.instance) {
|
|
@@ -1067,7 +1069,28 @@ class KeyStore {
|
|
|
1067
1069
|
const dehydrated = await this.dehydrateKeyPair(id);
|
|
1068
1070
|
appLogger.debug(`Stored remote public keys for ${id}: ecdhPub=${dehydrated.ecdhPub.slice(0, 16)}...`);
|
|
1069
1071
|
}
|
|
1072
|
+
/** Generate an own pair without touching the store; {@link replaceOwnKeyPair} puts it in place. */
|
|
1073
|
+
async generateKeyPair() {
|
|
1074
|
+
const ecdh = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, true, ['deriveKey', 'deriveBits']);
|
|
1075
|
+
const ecdsa = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign', 'verify']);
|
|
1076
|
+
return {
|
|
1077
|
+
ecdh: { privateKey: ecdh.privateKey, publicKey: ecdh.publicKey },
|
|
1078
|
+
ecdsa: { privateKey: ecdsa.privateKey, publicKey: ecdsa.publicKey },
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1070
1081
|
async hydrateKeyPair(id, dehydrated) {
|
|
1082
|
+
const { ecdh, ecdsa } = await this.importKeyPair(dehydrated);
|
|
1083
|
+
const existing = this._keys.get(id);
|
|
1084
|
+
if (!existing || !existing.privateKey) {
|
|
1085
|
+
this._keys.set(id, ecdh);
|
|
1086
|
+
}
|
|
1087
|
+
const existingAuth = this._authKeys.get(id);
|
|
1088
|
+
if (!existingAuth || !existingAuth.privateKey) {
|
|
1089
|
+
this._authKeys.set(id, ecdsa);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
/** Import a dehydrated pair without touching the store; {@link replaceOwnKeyPair} puts it in place. */
|
|
1093
|
+
async importKeyPair(dehydrated) {
|
|
1071
1094
|
const ecdhPubJwk = JSON.parse(deserialize(dehydrated.ecdhPub));
|
|
1072
1095
|
const ecdhPublicKey = await crypto.subtle.importKey('jwk', ecdhPubJwk, { name: 'ECDH', namedCurve: 'P-384' }, true, []);
|
|
1073
1096
|
const ecdsaPubJwk = JSON.parse(deserialize(dehydrated.ecdsaPub));
|
|
@@ -1082,14 +1105,10 @@ class KeyStore {
|
|
|
1082
1105
|
const ecdsaPrivJwk = JSON.parse(deserialize(dehydrated.ecdsaPriv));
|
|
1083
1106
|
ecdsaPrivateKey = await crypto.subtle.importKey('jwk', ecdsaPrivJwk, { name: 'ECDSA', namedCurve: 'P-384' }, true, ['sign']);
|
|
1084
1107
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
}
|
|
1089
|
-
const existingAuth = this._authKeys.get(id);
|
|
1090
|
-
if (!existingAuth || !existingAuth.privateKey) {
|
|
1091
|
-
this._authKeys.set(id, { privateKey: ecdsaPrivateKey, publicKey: ecdsaPublicKey });
|
|
1092
|
-
}
|
|
1108
|
+
return {
|
|
1109
|
+
ecdh: { privateKey: ecdhPrivateKey, publicKey: ecdhPublicKey },
|
|
1110
|
+
ecdsa: { privateKey: ecdsaPrivateKey, publicKey: ecdsaPublicKey },
|
|
1111
|
+
};
|
|
1093
1112
|
}
|
|
1094
1113
|
getKeyPair(id) {
|
|
1095
1114
|
return this._keys.get(id) || null;
|
|
@@ -1097,12 +1116,54 @@ class KeyStore {
|
|
|
1097
1116
|
getAuthKeyPair(id) {
|
|
1098
1117
|
return this._authKeys.get(id) || null;
|
|
1099
1118
|
}
|
|
1119
|
+
/** Record that the pair `id` holds now came from the seed-wrapped vault (ChatKeyVault). */
|
|
1120
|
+
markVaultBacked(id) {
|
|
1121
|
+
const ecdh = this._keys.get(id);
|
|
1122
|
+
const ecdsa = this._authKeys.get(id);
|
|
1123
|
+
if (ecdh && ecdsa)
|
|
1124
|
+
this._vaultBacked.set(id, { ecdh, ecdsa });
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Put `pair` in place as `id`'s own pair and mark it vault-backed, in one
|
|
1128
|
+
* synchronous step: whatever `id` held before is dropped, so a pair minted
|
|
1129
|
+
* while the vault pair was being fetched or generated is never kept and
|
|
1130
|
+
* marked in its place (design §3.3).
|
|
1131
|
+
*/
|
|
1132
|
+
replaceOwnKeyPair(id, pair) {
|
|
1133
|
+
this.forget(id);
|
|
1134
|
+
this._keys.set(id, pair.ecdh);
|
|
1135
|
+
this._authKeys.set(id, pair.ecdsa);
|
|
1136
|
+
this._vaultBacked.set(id, { ecdh: pair.ecdh, ecdsa: pair.ecdsa });
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Whether `id`'s own pair was loaded from, or persisted to, the vault in this
|
|
1140
|
+
* tab, and is still the very pair that was marked. A Space identity must be
|
|
1141
|
+
* vault-backed (design §3.3): any other pair, such as one minted by a locked
|
|
1142
|
+
* DM subscription, never takes part, even one that lands over the marked pair.
|
|
1143
|
+
*/
|
|
1144
|
+
isVaultBacked(id) {
|
|
1145
|
+
const marked = this._vaultBacked.get(id);
|
|
1146
|
+
return !!marked && marked.ecdh.privateKey != null
|
|
1147
|
+
&& this._keys.get(id) === marked.ecdh && this._authKeys.get(id) === marked.ecdsa;
|
|
1148
|
+
}
|
|
1149
|
+
/** Drop every key held for `id` (SpecA §6.4 `KeyStore.forget`). */
|
|
1150
|
+
forget(id) {
|
|
1151
|
+
this._keys.delete(id);
|
|
1152
|
+
this._authKeys.delete(id);
|
|
1153
|
+
this._vaultBacked.delete(id);
|
|
1154
|
+
}
|
|
1100
1155
|
async dehydrateKeyPair(id) {
|
|
1101
1156
|
const keyPair = this._keys.get(id);
|
|
1102
1157
|
const authKeyPair = this._authKeys.get(id);
|
|
1103
1158
|
if (!keyPair || !authKeyPair) {
|
|
1104
1159
|
throw new Error(`No key pair for ${id}`);
|
|
1105
1160
|
}
|
|
1161
|
+
return this.exportKeyPair({ ecdh: keyPair, ecdsa: authKeyPair });
|
|
1162
|
+
}
|
|
1163
|
+
/** Dehydrate a pair that is not (yet) in the store, in the same format as {@link dehydrateKeyPair}. */
|
|
1164
|
+
async exportKeyPair(pair) {
|
|
1165
|
+
const keyPair = pair.ecdh;
|
|
1166
|
+
const authKeyPair = pair.ecdsa;
|
|
1106
1167
|
const ecdhPubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
|
|
1107
1168
|
const ecdhPrivJwk = keyPair.privateKey ? await crypto.subtle.exportKey('jwk', keyPair.privateKey) : null;
|
|
1108
1169
|
const ecdsaPubJwk = await crypto.subtle.exportKey('jwk', authKeyPair.publicKey);
|
|
@@ -1158,7 +1219,6 @@ class DoubleRatchet {
|
|
|
1158
1219
|
sessionType;
|
|
1159
1220
|
maxSkip = 3000;
|
|
1160
1221
|
overlapPeriod = 30000; // ms
|
|
1161
|
-
windowSize = 100; // messages per DH ratchet
|
|
1162
1222
|
constructor(senderId, recipientId, sessionType, isClient = true) {
|
|
1163
1223
|
this.sessionType = sessionType;
|
|
1164
1224
|
const keyStore = KeyStore.getInstance();
|
|
@@ -1232,16 +1292,24 @@ class DoubleRatchet {
|
|
|
1232
1292
|
throw new Error(`Session type mismatch: expected ${this.sessionType}, got ${sessionType}`);
|
|
1233
1293
|
}
|
|
1234
1294
|
const keyStore = KeyStore.getInstance();
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1295
|
+
// The DH-ratchet path is disabled, deliberately. It used to fire by
|
|
1296
|
+
// itself once `sendCount` passed a 100-message window, and it was wrong
|
|
1297
|
+
// in three ways at once: it wrote the fresh pair into the PROCESS-GLOBAL
|
|
1298
|
+
// `KeyStore` under our own id (clobbering the long-term ECDH key that
|
|
1299
|
+
// every other ratchet and the Space key-unwrap path read back from the
|
|
1300
|
+
// same entry), it stepped our sending chain without telling the peer (so
|
|
1301
|
+
// message 101 of any conversation stopped decrypting), and it hardcoded
|
|
1302
|
+
// the client role regardless of which half of the session we are.
|
|
1303
|
+
//
|
|
1304
|
+
// No caller in the SDK asks for a rotation — every one passes `false` —
|
|
1305
|
+
// so failing loudly here is strictly better than silently desynchronising
|
|
1306
|
+
// a session. Rotation returns on the standard trigger (receipt of a new
|
|
1307
|
+
// peer `dhPub`) with the key schedule rebuilt; see
|
|
1308
|
+
// docs/superpowers/specs/2026-09-24-dm-protocol-design.md §0 and §3.6.
|
|
1238
1309
|
if (newDhKey && this.sessionType === 'specific') {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
this.state.clientDhPub = ecdhKeyPair.publicKey;
|
|
1243
|
-
keyStore.keys.set(senderId, { privateKey: ecdhKeyPair.privateKey, publicKey: ecdhKeyPair.publicKey });
|
|
1244
|
-
await this.dhRatchet(this.state.serverDhPub, true);
|
|
1310
|
+
throw new Error('DoubleRatchet: DH key rotation is disabled — the previous implementation ' +
|
|
1311
|
+
'corrupted the process-global KeyStore and desynchronised the peer. ' +
|
|
1312
|
+
'Rotation is reinstated with the key-schedule rewrite (audit H1).');
|
|
1245
1313
|
}
|
|
1246
1314
|
const [messageKey, newChainKey] = await this.symmetricRatchet(this.state.sendChainKey);
|
|
1247
1315
|
this.state.sendChainKey = newChainKey;
|
|
@@ -1996,6 +2064,7 @@ class WSTransport extends EventCore {
|
|
|
1996
2064
|
pongFrame;
|
|
1997
2065
|
heartbeatTimer = null;
|
|
1998
2066
|
pongTimer = null;
|
|
2067
|
+
terminalCloseCodes;
|
|
1999
2068
|
constructor(opts) {
|
|
2000
2069
|
super();
|
|
2001
2070
|
if (!opts || !opts.url) {
|
|
@@ -2012,6 +2081,7 @@ class WSTransport extends EventCore {
|
|
|
2012
2081
|
this.heartbeatTimeout = opts.heartbeatTimeout ?? 10000;
|
|
2013
2082
|
this.pingFrame = opts.pingFrame ?? "ping";
|
|
2014
2083
|
this.pongFrame = opts.pongFrame ?? "pong";
|
|
2084
|
+
this.terminalCloseCodes = opts.terminalCloseCodes ?? [];
|
|
2015
2085
|
}
|
|
2016
2086
|
/** Open the socket. Resolves when CONNECTED fires. Rejects on first error. */
|
|
2017
2087
|
async connect() {
|
|
@@ -2064,12 +2134,17 @@ class WSTransport extends EventCore {
|
|
|
2064
2134
|
reject(err);
|
|
2065
2135
|
}
|
|
2066
2136
|
};
|
|
2067
|
-
socket.onclose = () => {
|
|
2137
|
+
socket.onclose = (ev) => {
|
|
2138
|
+
const code = typeof ev?.code === "number" ? ev.code : undefined;
|
|
2068
2139
|
const wasConnected = this._isConnected;
|
|
2069
2140
|
this._isConnected = false;
|
|
2070
2141
|
this._isConnecting = false;
|
|
2071
2142
|
this.stopHeartbeat();
|
|
2072
|
-
this.emit(EventCoreEvents.DISCONNECTED, undefined);
|
|
2143
|
+
this.emit(EventCoreEvents.DISCONNECTED, code !== undefined ? { code, reason: ev?.reason ?? "" } : undefined);
|
|
2144
|
+
if (code !== undefined && this.terminalCloseCodes.includes(code)) {
|
|
2145
|
+
this.autoReconnect = false;
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2073
2148
|
if (this.autoReconnect && wasConnected) {
|
|
2074
2149
|
this.scheduleReconnect();
|
|
2075
2150
|
}
|
|
@@ -2099,6 +2174,14 @@ class WSTransport extends EventCore {
|
|
|
2099
2174
|
this._isConnected = false;
|
|
2100
2175
|
this._isConnecting = false;
|
|
2101
2176
|
}
|
|
2177
|
+
/** Restart reconnecting after a terminal close or `disconnect()`; the next URL comes from `urlProvider`. */
|
|
2178
|
+
resume() {
|
|
2179
|
+
// A reconnect already pending is left to run: a second timer would dial on top of it.
|
|
2180
|
+
if (this._isConnected || this._isConnecting || this.reconnectTimeout)
|
|
2181
|
+
return;
|
|
2182
|
+
this.autoReconnect = true;
|
|
2183
|
+
this.scheduleReconnect();
|
|
2184
|
+
}
|
|
2102
2185
|
/**
|
|
2103
2186
|
* Send a raw frame. If currently disconnected, the frame is queued and sent
|
|
2104
2187
|
* after the next successful reconnect (up to `maxQueueSize`).
|
|
@@ -2199,6 +2282,7 @@ class WSTransport extends EventCore {
|
|
|
2199
2282
|
this.reconnectAttempts++;
|
|
2200
2283
|
this.emit(EventCoreEvents.RECONNECTING, { attempt: this.reconnectAttempts });
|
|
2201
2284
|
this.reconnectTimeout = setTimeout(async () => {
|
|
2285
|
+
this.reconnectTimeout = null;
|
|
2202
2286
|
try {
|
|
2203
2287
|
await this.connect();
|
|
2204
2288
|
}
|
|
@@ -2316,8 +2400,9 @@ class BroadcastChannel {
|
|
|
2316
2400
|
void this.announce();
|
|
2317
2401
|
}
|
|
2318
2402
|
});
|
|
2319
|
-
|
|
2320
|
-
|
|
2403
|
+
// `detail` is the close `{code, reason}` when the socket reported one.
|
|
2404
|
+
this.transport.on(EventCoreEvents.DISCONNECTED, (e) => {
|
|
2405
|
+
this.emit(BroadcastChannelEvents.DISCONNECTED, e.detail);
|
|
2321
2406
|
});
|
|
2322
2407
|
this.transport.on(EventCoreEvents.RECONNECTING, (e) => {
|
|
2323
2408
|
this.emit(BroadcastChannelEvents.RECONNECTING, e.detail);
|
|
@@ -2352,6 +2437,10 @@ class BroadcastChannel {
|
|
|
2352
2437
|
disconnect() {
|
|
2353
2438
|
this.transport.disconnect();
|
|
2354
2439
|
}
|
|
2440
|
+
/** Reconnect after a terminal close (see `terminalCloseCodes`) or `disconnect()`, on a fresh `urlProvider` URL. */
|
|
2441
|
+
resume() {
|
|
2442
|
+
this.transport.resume();
|
|
2443
|
+
}
|
|
2355
2444
|
/**
|
|
2356
2445
|
* Send our `keyExchange` frame to the room. Idempotent — only sends on the
|
|
2357
2446
|
* first call. Call this once your higher-level handshake (e.g. sending a
|