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.
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +14 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/server.d.ts +38 -1
- package/dist/server/server.js +24 -0
- package/dist/server/server.js.map +1 -1
- package/dist/store.d.ts +11 -2
- package/dist/store.js +10 -2
- package/dist/store.js.map +1 -1
- package/dist/sync-engine.d.ts +92 -1
- package/dist/sync-engine.js +252 -30
- package/dist/sync-engine.js.map +1 -1
- package/package.json +1 -1
package/dist/sync-engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
947
|
-
if (!
|
|
948
|
-
this.obs.logger.warn({ subtreeId, peerId, identityId }, 'SyncStep2 rejected:
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
|
|
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
|
-
|
|
960
|
-
|
|
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
|
|
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
|
|
995
|
-
if (!
|
|
996
|
-
this.obs.logger.warn({ subtreeId, peerId, identityId }, 'update rejected:
|
|
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
|
-
|
|
1142
|
-
if (data.length >
|
|
1143
|
-
const idLen = data[
|
|
1144
|
-
if (data.length >=
|
|
1145
|
-
remoteIdentityPeerId = new TextDecoder().decode(data.subarray(
|
|
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
|
-
//
|
|
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
|
-
|
|
1244
|
-
|
|
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
|
-
|
|
1253
|
-
msg
|
|
1254
|
-
|
|
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);
|