@muhkoo/connect 0.14.0-alpha.9 → 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.
@@ -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
- const existing = this._keys.get(id);
1086
- if (!existing || !existing.privateKey) {
1087
- this._keys.set(id, { privateKey: ecdhPrivateKey, publicKey: ecdhPublicKey });
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
- if (this.sessionType === 'specific' && this.state.sendCount >= this.windowSize && this.state.sendCount > 0) {
1236
- newDhKey = true;
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
- appLogger.debug(`Generating new DH key pair for ${senderId}`);
1240
- const ecdhKeyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-384' }, true, ['deriveKey', 'deriveBits']);
1241
- this.state.clientDhPriv = ecdhKeyPair.privateKey;
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
- this.transport.on(EventCoreEvents.DISCONNECTED, () => {
2320
- this.emit(BroadcastChannelEvents.DISCONNECTED);
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