sietch 0.8.0 → 0.9.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.
@@ -27,7 +27,7 @@ import * as encoding from 'lib0/encoding';
27
27
  import * as decoding from 'lib0/decoding';
28
28
  import { messageYjsSyncStep1, messageYjsSyncStep2, messageYjsUpdate, writeSyncStep1, } from 'y-protocols/sync';
29
29
  import { keyExchangeInitiate, keyExchangeRespond, } from './crypto.js';
30
- import { SyncError, PeerConnectionError } from './errors.js';
30
+ import { SyncError, PeerConnectionError, HandshakeRejectedError } from './errors.js';
31
31
  // ── Protocol Constants ─────────────────────────────────────
32
32
  const SYNC_PROTOCOL = '/sietch/sync/1.0.0';
33
33
  const AUTH_PROTOCOL = '/sietch/auth/1.0.0';
@@ -251,6 +251,18 @@ export class SyncEngine {
251
251
  denialTrackers = new Map();
252
252
  /** Per-peer escalating ban state: tracks disconnect count and cooldown expiry. */
253
253
  aclBanState = new Map();
254
+ /**
255
+ * Optional server-side application authorization hook, invoked during
256
+ * PQ auth handshake after key exchange completes and before the peer
257
+ * is admitted to sync. See AMD-002 / FR-67.
258
+ */
259
+ onPeerHandshake = undefined;
260
+ /**
261
+ * Optional client-side factory that produces the `handshakeMetadata`
262
+ * trailer for outgoing auth frames. Called once per handshake
263
+ * (including reconnects). See AMD-002 / FR-67.
264
+ */
265
+ handshakeMetadataProvider = undefined;
254
266
  constructor(yjsEngine, identity, acl, obs) {
255
267
  this.yjsEngine = yjsEngine;
256
268
  this.identity = identity;
@@ -269,6 +281,27 @@ export class SyncEngine {
269
281
  registerQueryHandler(handler) {
270
282
  this.queryHandler = handler;
271
283
  }
284
+ /**
285
+ * Install a server-side application authorization hook. Called during
286
+ * PQ auth handshake; see {@link HandshakeHook}. Typically set by
287
+ * `SietchServer` from the `onPeerHandshake` option.
288
+ *
289
+ * @req FR-67
290
+ */
291
+ setOnPeerHandshake(hook) {
292
+ this.onPeerHandshake = hook;
293
+ }
294
+ /**
295
+ * Install a client-side factory that produces opaque application
296
+ * metadata for the auth handshake trailer. Called once per handshake,
297
+ * including on reconnect (so the factory may refresh short-lived
298
+ * tokens). See AMD-002.
299
+ *
300
+ * @req FR-67
301
+ */
302
+ setHandshakeMetadataProvider(provider) {
303
+ this.handshakeMetadataProvider = provider;
304
+ }
272
305
  /**
273
306
  * Initialize the libp2p node.
274
307
  */
@@ -406,12 +439,21 @@ export class SyncEngine {
406
439
  }
407
440
  /**
408
441
  * Connect to a server peer (WebSocket or TCP).
442
+ *
443
+ * If `opts.handshakeMetadata` is provided, it is installed as the
444
+ * per-handshake factory for the client-side auth trailer. Subsequent
445
+ * reconnects also invoke the factory (so it may refresh short-lived
446
+ * tokens). See AMD-002 / FR-67.
409
447
  */
410
448
  // @req FR-40
411
449
  // @req FR-33
412
- async connectServer(url) {
450
+ // @req FR-67
451
+ async connectServer(url, opts) {
413
452
  if (!this.node)
414
453
  throw new SyncError('Sync engine not initialized', 'SYNC_NOT_INIT');
454
+ if (opts?.handshakeMetadata) {
455
+ this.setHandshakeMetadataProvider(opts.handshakeMetadata);
456
+ }
415
457
  try {
416
458
  const ma = multiaddr(url);
417
459
  const conn = await this.node.dial(ma);
@@ -432,6 +474,11 @@ export class SyncEngine {
432
474
  this.obs.events.emit('sync', this.getStatus());
433
475
  }
434
476
  catch (err) {
477
+ // Preserve HandshakeRejectedError identity so callers can distinguish
478
+ // app-level rejection from generic connection failure.
479
+ if (err instanceof HandshakeRejectedError) {
480
+ throw err;
481
+ }
435
482
  throw new PeerConnectionError(`Failed to connect to server: ${err instanceof Error ? err.message : String(err)}`);
436
483
  }
437
484
  }
@@ -933,8 +980,14 @@ export class SyncEngine {
933
980
  case messageYjsSyncStep2:
934
981
  case messageYjsSyncStep2V2: {
935
982
  const update = decoding.readVarUint8Array(decoder);
936
- // ACL write gate
983
+ // ACL read gate for SyncStep2 (protocol handshake).
984
+ // SyncStep2 is a mandatory Yjs sync response, not an application
985
+ // write. Gate on read permission so read-only peers complete the
986
+ // handshake. Update content is only applied when the peer also
987
+ // has write permission — read-only peers' payloads are silently
988
+ // dropped to prevent state contamination.
937
989
  // @req FR-36
990
+ let applyUpdate = true;
938
991
  if (this.acl.hasAcl(subtreeId)) {
939
992
  const identityId = peer.identityPeerId;
940
993
  if (!identityId) {
@@ -943,27 +996,36 @@ export class SyncEngine {
943
996
  return;
944
997
  break;
945
998
  }
946
- const check = this.acl.checkWrite(subtreeId, identityId);
947
- if (!check.allowed) {
948
- this.obs.logger.warn({ subtreeId, peerId, identityId }, 'SyncStep2 rejected: write denied');
999
+ const readCheck = this.acl.checkRead(subtreeId, identityId);
1000
+ if (!readCheck.allowed) {
1001
+ this.obs.logger.warn({ subtreeId, peerId, identityId }, 'SyncStep2 rejected: read denied');
949
1002
  if (await this.handleAclDenial(peerId, subtreeId, framedStream))
950
1003
  return;
951
1004
  break;
952
1005
  }
1006
+ const writeCheck = this.acl.checkWrite(subtreeId, identityId);
1007
+ if (!writeCheck.allowed) {
1008
+ // Read-only peer: accept the handshake message but do not
1009
+ // apply its update content to the server's Y.Doc.
1010
+ applyUpdate = false;
1011
+ this.obs.logger.debug({ subtreeId, peerId, identityId }, 'SyncStep2 accepted from read-only peer (update content dropped)');
1012
+ }
953
1013
  }
954
1014
  peer.syncedSubtrees.add(subtreeId);
955
- try {
956
- if (msgType === messageYjsSyncStep2V2) {
957
- await this.yjsEngine.applyUpdateV2(subtreeId, update, peerId);
1015
+ if (applyUpdate) {
1016
+ try {
1017
+ if (msgType === messageYjsSyncStep2V2) {
1018
+ await this.yjsEngine.applyUpdateV2(subtreeId, update, peerId);
1019
+ }
1020
+ else {
1021
+ await this.yjsEngine.applyUpdate(subtreeId, update, peerId);
1022
+ }
958
1023
  }
959
- else {
960
- await this.yjsEngine.applyUpdate(subtreeId, update, peerId);
1024
+ catch (updateErr) {
1025
+ this.obs.logger.warn({ subtreeId, peerId, size: update.byteLength, err: updateErr }, 'failed to apply SyncStep2 (malformed Yjs data)');
1026
+ break;
961
1027
  }
962
1028
  }
963
- catch (updateErr) {
964
- this.obs.logger.warn({ subtreeId, peerId, size: update.byteLength, err: updateErr }, 'failed to apply SyncStep2 (malformed Yjs data)');
965
- break;
966
- }
967
1029
  // Emit per-subtree sync status: SyncStep2 received → 'synced'
968
1030
  this.emitSubtreeStatus({
969
1031
  subtreeId,
@@ -981,8 +1043,15 @@ export class SyncEngine {
981
1043
  case messageYjsUpdate:
982
1044
  case messageYjsUpdateV2: {
983
1045
  const update = decoding.readVarUint8Array(decoder);
984
- // ACL write gate
1046
+ // ACL gate (symmetric with SyncStep2 — see AMD-001).
1047
+ // Read-capable peers without write permission complete the
1048
+ // frame exchange with their payload silently dropped. They
1049
+ // do NOT count toward the ACL denial threshold. This matches
1050
+ // the SyncStep2 rule and closes the first-connect race where
1051
+ // a peer's pendingOps flush would otherwise trip the ban
1052
+ // counter before an out-of-band grant lands.
985
1053
  // @req FR-36
1054
+ let applyUpdate = true;
986
1055
  if (this.acl.hasAcl(subtreeId)) {
987
1056
  const identityId = peer.identityPeerId;
988
1057
  if (!identityId) {
@@ -991,15 +1060,27 @@ export class SyncEngine {
991
1060
  return;
992
1061
  break;
993
1062
  }
994
- const check = this.acl.checkWrite(subtreeId, identityId);
995
- if (!check.allowed) {
996
- this.obs.logger.warn({ subtreeId, peerId, identityId }, 'update rejected: write denied');
1063
+ const readCheck = this.acl.checkRead(subtreeId, identityId);
1064
+ if (!readCheck.allowed) {
1065
+ this.obs.logger.warn({ subtreeId, peerId, identityId }, 'update rejected: read denied');
997
1066
  if (await this.handleAclDenial(peerId, subtreeId, framedStream))
998
1067
  return;
999
1068
  break;
1000
1069
  }
1070
+ const writeCheck = this.acl.checkWrite(subtreeId, identityId);
1071
+ if (!writeCheck.allowed) {
1072
+ // Read-only peer: accept the frame (mark synced) but drop
1073
+ // the payload without applying or relaying.
1074
+ applyUpdate = false;
1075
+ this.obs.logger.info({ subtreeId, peerId, identityId, size: update.byteLength }, 'update dropped from read-only peer (no write permission)');
1076
+ }
1001
1077
  }
1002
1078
  peer.syncedSubtrees.add(subtreeId);
1079
+ if (!applyUpdate) {
1080
+ // Read-only peer: do not apply, do not relay. Fall through
1081
+ // to next frame.
1082
+ break;
1083
+ }
1003
1084
  try {
1004
1085
  if (msgType === messageYjsUpdateV2) {
1005
1086
  await this.yjsEngine.applyUpdateV2(subtreeId, update, peerId);
@@ -1138,11 +1219,22 @@ export class SyncEngine {
1138
1219
  const remoteMlKem = new Uint8Array(data.subarray(mlKemStart, mlKemStart + ML_KEM_768_PUB_SIZE));
1139
1220
  // Parse optional identity peerId (backward-compatible extension)
1140
1221
  let remoteIdentityPeerId;
1141
- const idOffset = mlKemStart + ML_KEM_768_PUB_SIZE;
1142
- if (data.length > idOffset) {
1143
- const idLen = data[idOffset];
1144
- if (data.length >= idOffset + 1 + idLen) {
1145
- remoteIdentityPeerId = new TextDecoder().decode(data.subarray(idOffset + 1, idOffset + 1 + idLen));
1222
+ let cursor = mlKemStart + ML_KEM_768_PUB_SIZE;
1223
+ if (data.length > cursor) {
1224
+ const idLen = data[cursor];
1225
+ if (data.length >= cursor + 1 + idLen) {
1226
+ remoteIdentityPeerId = new TextDecoder().decode(data.subarray(cursor + 1, cursor + 1 + idLen));
1227
+ cursor = cursor + 1 + idLen;
1228
+ }
1229
+ }
1230
+ // Parse optional handshakeMetadata trailer (AMD-002 / FR-67).
1231
+ // Backward-compatible: older clients omit this; parser stops at
1232
+ // end-of-frame. Format: [handshakeMetaLen(2 BE)][handshakeMeta].
1233
+ let remoteHandshakeMeta = new Uint8Array(0);
1234
+ if (data.length >= cursor + 2) {
1235
+ const metaLen = (data[cursor] << 8) | data[cursor + 1];
1236
+ if (data.length >= cursor + 2 + metaLen) {
1237
+ remoteHandshakeMeta = new Uint8Array(data.subarray(cursor + 2, cursor + 2 + metaLen));
1146
1238
  }
1147
1239
  }
1148
1240
  // Store identity BEFORE PQ exchange — survives even if PQ throws
@@ -1159,7 +1251,34 @@ export class SyncEngine {
1159
1251
  };
1160
1252
  const keypair = this.identity.getKeypair();
1161
1253
  const { shared, encapsulated } = keyExchangeInitiate(keypair.kem, remotePublicKey);
1162
- // PQ exchange succeeded store shared secret
1254
+ // Application authorization hook (AMD-002 / FR-67). Invoked after
1255
+ // key exchange succeeds and BEFORE the peer is marked authenticated.
1256
+ // Rejection closes the connection with a type=3 AppRejected frame
1257
+ // and leaves `authenticated = false`. `aclBanState` is NOT touched —
1258
+ // handshake rejection is distinct from per-peer ACL denial escalation.
1259
+ if (this.onPeerHandshake && senderPeerId) {
1260
+ try {
1261
+ const decision = await this.onPeerHandshake({
1262
+ peerId: senderPeerId,
1263
+ identityPeerId: remoteIdentityPeerId,
1264
+ handshakeMetadata: remoteHandshakeMeta,
1265
+ });
1266
+ if (decision && decision.accept === false) {
1267
+ const reason = decision.reason ?? 'application rejected handshake';
1268
+ this.obs.logger.warn({ peerId: senderPeerId, identityPeerId: remoteIdentityPeerId, reason }, 'PQ auth rejected by onPeerHandshake');
1269
+ this.sendAuthRejection(s, reason);
1270
+ return;
1271
+ }
1272
+ }
1273
+ catch (hookErr) {
1274
+ const reason = hookErr instanceof Error ? hookErr.message : String(hookErr);
1275
+ this.obs.logger.warn({ peerId: senderPeerId, identityPeerId: remoteIdentityPeerId, err: hookErr }, 'PQ auth rejected: onPeerHandshake threw');
1276
+ this.sendAuthRejection(s, reason);
1277
+ return;
1278
+ }
1279
+ }
1280
+ // PQ exchange succeeded and hook (if any) admitted the peer —
1281
+ // store shared secret and mark authenticated.
1163
1282
  if (senderPeerId) {
1164
1283
  const peer = this.peers.get(senderPeerId);
1165
1284
  if (peer) {
@@ -1208,6 +1327,33 @@ export class SyncEngine {
1208
1327
  }
1209
1328
  }
1210
1329
  }
1330
+ /**
1331
+ * Send a type=3 AppRejected auth response and close the stream.
1332
+ *
1333
+ * Wire format: `[type=3] [reasonLen(2 BE)] [reason_utf8]`.
1334
+ *
1335
+ * Distinct from type=2 (success) and from PQ key-exchange failure
1336
+ * (which uses the error path of `handleAuthStream`). Only `onPeerHandshake`
1337
+ * rejection or throw reaches this path. Does not touch `aclBanState`.
1338
+ *
1339
+ * @req FR-67
1340
+ */
1341
+ sendAuthRejection(s, reason) {
1342
+ try {
1343
+ const reasonBytes = new TextEncoder().encode(reason);
1344
+ const reasonLen = Math.min(reasonBytes.length, 0xffff);
1345
+ const frame = new Uint8Array(1 + 2 + reasonLen);
1346
+ frame[0] = 3; // type: application rejected
1347
+ frame[1] = (reasonLen >> 8) & 0xff;
1348
+ frame[2] = reasonLen & 0xff;
1349
+ frame.set(reasonBytes.subarray(0, reasonLen), 3);
1350
+ s.sendData(new Uint8ArrayList(frame));
1351
+ s.sendCloseWrite();
1352
+ }
1353
+ catch {
1354
+ /* stream may already be broken */
1355
+ }
1356
+ }
1211
1357
  // ── Sync Operations ───────────────────────────────────────
1212
1358
  /**
1213
1359
  * Perform PQ auth handshake with a peer.
@@ -1240,18 +1386,47 @@ export class SyncEngine {
1240
1386
  ownPeerIdBytes = new TextEncoder().encode(this.identity.getPublicIdentity().peerId);
1241
1387
  }
1242
1388
  catch { /* identity not initialized — skip */ }
1243
- const msgLen = 1 + 2 + x25519Pub.length + mlKemPub.length
1244
- + (ownPeerIdBytes.length > 0 ? 1 + ownPeerIdBytes.length : 0);
1389
+ // Optional handshakeMetadata trailer (AMD-002 / FR-67). Called per
1390
+ // handshake so short-lived tokens can be refreshed on reconnect.
1391
+ // Copy into a fresh ArrayBuffer-backed Uint8Array so downstream
1392
+ // wire-format code sees a stable, non-shared buffer type.
1393
+ let handshakeMeta = new Uint8Array(0);
1394
+ if (this.handshakeMetadataProvider) {
1395
+ try {
1396
+ const result = await this.handshakeMetadataProvider();
1397
+ if (result instanceof Uint8Array) {
1398
+ const len = Math.min(result.byteLength, 0xffff);
1399
+ const copy = new Uint8Array(len);
1400
+ copy.set(result.subarray(0, len));
1401
+ handshakeMeta = copy;
1402
+ }
1403
+ }
1404
+ catch (metaErr) {
1405
+ this.obs.logger.warn({ peerId, err: metaErr }, 'handshakeMetadataProvider threw — proceeding with empty metadata');
1406
+ }
1407
+ }
1408
+ const idTrailerLen = ownPeerIdBytes.length > 0 ? 1 + ownPeerIdBytes.length : 0;
1409
+ const metaTrailerLen = handshakeMeta.length > 0 ? 2 + handshakeMeta.length : 0;
1410
+ const msgLen = 1 + 2 + x25519Pub.length + mlKemPub.length + idTrailerLen + metaTrailerLen;
1245
1411
  const msg = new Uint8Array(msgLen);
1246
1412
  msg[0] = 1; // type: key exchange request
1247
1413
  msg[1] = (x25519Pub.length >> 8) & 0xff;
1248
1414
  msg[2] = x25519Pub.length & 0xff;
1249
1415
  msg.set(x25519Pub, 3);
1250
1416
  msg.set(mlKemPub, 3 + x25519Pub.length);
1417
+ let writeCursor = 3 + x25519Pub.length + mlKemPub.length;
1251
1418
  if (ownPeerIdBytes.length > 0) {
1252
- const idOffset = 3 + x25519Pub.length + mlKemPub.length;
1253
- msg[idOffset] = ownPeerIdBytes.length;
1254
- msg.set(ownPeerIdBytes, idOffset + 1);
1419
+ msg[writeCursor] = ownPeerIdBytes.length;
1420
+ msg.set(ownPeerIdBytes, writeCursor + 1);
1421
+ writeCursor += 1 + ownPeerIdBytes.length;
1422
+ }
1423
+ if (handshakeMeta.length > 0) {
1424
+ // handshakeMeta trailer is only included when identity trailer is
1425
+ // present, to preserve parser positional assumptions. In practice
1426
+ // identity is always set during server connection.
1427
+ msg[writeCursor] = (handshakeMeta.length >> 8) & 0xff;
1428
+ msg[writeCursor + 1] = handshakeMeta.length & 0xff;
1429
+ msg.set(handshakeMeta, writeCursor + 2);
1255
1430
  }
1256
1431
  s.sendData(new Uint8ArrayList(msg));
1257
1432
  s.sendCloseWrite();
@@ -1299,6 +1474,22 @@ export class SyncEngine {
1299
1474
  this.obs.logger.warn({ peerId, identityPeerId: serverIdentityPeerId }, 'PQ key exchange failed, trusting Noise channel');
1300
1475
  }
1301
1476
  }
1477
+ else if (respType === 3) {
1478
+ // [type=3] [reasonLen(2 BE)] [reason_utf8]
1479
+ // Application-layer rejection from server.onPeerHandshake.
1480
+ // Distinct from PQ key-exchange failure: this is terminal, we do
1481
+ // NOT fall back to Noise. Leaves peer.authenticated = false.
1482
+ // @req FR-67
1483
+ let reason = 'application rejected handshake';
1484
+ if (data.length >= 3) {
1485
+ const reasonLen = (data[1] << 8) | data[2];
1486
+ if (data.length >= 3 + reasonLen) {
1487
+ reason = new TextDecoder().decode(data.subarray(3, 3 + reasonLen));
1488
+ }
1489
+ }
1490
+ this.obs.logger.warn({ peerId, reason }, 'auth handshake rejected by server application hook');
1491
+ throw new HandshakeRejectedError(`Server rejected handshake: ${reason}`, reason);
1492
+ }
1302
1493
  else if (respType === 0) {
1303
1494
  this.obs.logger.warn({ peerId }, 'auth handshake rejected by server');
1304
1495
  // Still trust Noise channel for sync
@@ -1307,6 +1498,11 @@ export class SyncEngine {
1307
1498
  }
1308
1499
  }
1309
1500
  catch (err) {
1501
+ // HandshakeRejectedError is terminal — do NOT fall back to Noise.
1502
+ // @req FR-67
1503
+ if (err instanceof HandshakeRejectedError) {
1504
+ throw err;
1505
+ }
1310
1506
  this.obs.logger.debug({ peerId, err }, 'auth handshake failed, trusting Noise channel');
1311
1507
  // Fallback: trust the Noise channel encryption
1312
1508
  peer.authenticated = true;
@@ -1427,6 +1623,32 @@ export class SyncEngine {
1427
1623
  await this.sendAuthoritativeReset(subtreeId, stream);
1428
1624
  return false;
1429
1625
  }
1626
+ /**
1627
+ * Reset per-peer ACL ban state for the named peer.
1628
+ *
1629
+ * Supports applications that perform out-of-band authorization (e.g.,
1630
+ * validating a JWT and granting ACL via a separate HTTP route) and need
1631
+ * to resume sync without waiting for the 5-minute clean-session window.
1632
+ *
1633
+ * Clears both the escalating ban state and the in-window denial tracker.
1634
+ * Returns `{ cleared, priorDisconnects }` for observability; the caller
1635
+ * can tell whether a ban was actually in effect.
1636
+ *
1637
+ * Exposed publicly via `SietchServer.clearAclBan(peerId)`.
1638
+ *
1639
+ * @req FR-36
1640
+ */
1641
+ clearAclBan(peerId) {
1642
+ const prior = this.aclBanState.get(peerId);
1643
+ const hadTracker = this.denialTrackers.has(peerId);
1644
+ this.aclBanState.delete(peerId);
1645
+ this.denialTrackers.delete(peerId);
1646
+ const cleared = prior !== undefined || hadTracker;
1647
+ if (cleared) {
1648
+ this.obs.logger.info({ peerId, priorDisconnects: prior?.disconnects ?? 0 }, 'ACL ban state cleared by application');
1649
+ }
1650
+ return { cleared, priorDisconnects: prior?.disconnects ?? 0 };
1651
+ }
1430
1652
  async sendAuthoritativeReset(subtreeId, stream) {
1431
1653
  const doc = await this.yjsEngine.getSubdoc(subtreeId);
1432
1654
  const fullState = Y.encodeStateAsUpdateV2(doc);