@modularizer/plat 0.9.0 → 0.10.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.
Files changed (42) hide show
  1. package/dist/client-entry.d.ts +1 -0
  2. package/dist/client-entry.d.ts.map +1 -1
  3. package/dist/client-entry.js +1 -0
  4. package/dist/client-entry.js.map +1 -1
  5. package/dist/client-server-entry.d.ts +1 -0
  6. package/dist/client-server-entry.d.ts.map +1 -1
  7. package/dist/client-server-entry.js +1 -0
  8. package/dist/client-server-entry.js.map +1 -1
  9. package/dist/client-side-python/runtime.d.ts.map +1 -1
  10. package/dist/client-side-python/runtime.js +8 -0
  11. package/dist/client-side-python/runtime.js.map +1 -1
  12. package/dist/client-side-server/identity.d.ts +80 -17
  13. package/dist/client-side-server/identity.d.ts.map +1 -1
  14. package/dist/client-side-server/identity.js +212 -31
  15. package/dist/client-side-server/identity.js.map +1 -1
  16. package/dist/client-side-server/mqtt-webrtc.d.ts +38 -6
  17. package/dist/client-side-server/mqtt-webrtc.d.ts.map +1 -1
  18. package/dist/client-side-server/mqtt-webrtc.js +839 -68
  19. package/dist/client-side-server/mqtt-webrtc.js.map +1 -1
  20. package/dist/client-side-server/protocol.d.ts +68 -3
  21. package/dist/client-side-server/protocol.d.ts.map +1 -1
  22. package/dist/client-side-server/protocol.js +110 -0
  23. package/dist/client-side-server/protocol.js.map +1 -1
  24. package/dist/client-side-server/runtime.d.ts.map +1 -1
  25. package/dist/client-side-server/runtime.js +7 -0
  26. package/dist/client-side-server/runtime.js.map +1 -1
  27. package/dist/client-side-server/secure-crypto.d.ts +35 -0
  28. package/dist/client-side-server/secure-crypto.d.ts.map +1 -0
  29. package/dist/client-side-server/secure-crypto.js +171 -0
  30. package/dist/client-side-server/secure-crypto.js.map +1 -0
  31. package/dist/client-side-server/server.js +1 -1
  32. package/dist/client-side-server/server.js.map +1 -1
  33. package/dist/client-side-server/signaling.d.ts +1 -0
  34. package/dist/client-side-server/signaling.d.ts.map +1 -1
  35. package/dist/client-side-server/signaling.js.map +1 -1
  36. package/dist/server/authority-server.d.ts +4 -4
  37. package/dist/server/authority-server.d.ts.map +1 -1
  38. package/dist/server/authority-server.js +23 -21
  39. package/dist/server/authority-server.js.map +1 -1
  40. package/dist/server/server.js +1 -1
  41. package/dist/server/server.js.map +1 -1
  42. package/package.json +1 -1
@@ -1,11 +1,16 @@
1
1
  import mqtt from 'mqtt';
2
2
  import { createClientSideServerTransportPlugin } from '../client/css-transport-plugin.js';
3
3
  import { createRTCDataChannelAdapter } from './channel.js';
4
- import { buildClientSideServerIdentityChallenge, clientSideServerPublicKeysEqual, getOrCreateClientSideServerIdentityKeyPair, loadTrustedClientSideServerRecordFromMap, resolveTrustedClientSideServerFromAuthorities, loadTrustedClientSideServerRecord, trustClientSideServerFromAuthorityRecord, saveTrustedClientSideServerRecordToMap, signClientSideServerChallenge, toClientSideServerPublicIdentity, trustClientSideServerOnFirstUse, verifyClientSideServerChallenge, verifySignedClientSideServerAuthorityRecord, } from './identity.js';
5
- import { isClientSideServerPeerMessage, } from './protocol.js';
4
+ import { buildClientSideServerIdentityChallenge, clientSideServerPublicKeysEqual, generateClientSideServerEncryptionKeyPair, getOrCreateClientSideServerIdentityKeyPair, isClientSideServerSignedAuthorityRecordV2, isClientSideServerTrustedServerRecordV2, loadTrustedClientSideServerRecordFromMap, resolveTrustedClientSideServerFromAuthorities, loadTrustedClientSideServerRecord, trustClientSideServerFromAuthorityRecord, saveTrustedClientSideServerRecordToMap, signClientSideServerChallenge, toClientSideServerEncryptionPublicIdentity, toClientSideServerPublicIdentity, trustClientSideServerOnFirstUse, verifyClientSideServerChallenge, verifyAnySignedClientSideServerAuthorityRecord, } from './identity.js';
5
+ import { isClientSideServerSealedEnvelope, isClientSideServerSealedPayload, isClientSideServerPeerMessage, } from './protocol.js';
6
+ import { choosePaddingBucket, computeSessionId, decodeBase64Url, decryptJsonAead, deriveAeadKeyFromX25519, encodeBase64Url, encryptJsonAead, generateEphemeralX25519KeyPair, importX25519PrivateKeyJwk, importX25519PublicKeyJwk, padCiphertext, randomNonce12, stableJson, unpadCiphertext, utf8, } from './secure-crypto.js';
6
7
  import { parseClientSideServerAddress, } from './signaling.js';
7
8
  export const DEFAULT_CLIENT_SIDE_SERVER_MQTT_BROKER = 'wss://broker.emqx.io:8084/mqtt';
8
9
  export const DEFAULT_CLIENT_SIDE_SERVER_MQTT_TOPIC = 'mrtchat/plat-css';
10
+ export const DEFAULT_CLIENT_SIDE_SERVER_SEALED_TOPIC = 'plat';
11
+ export const DEFAULT_CLIENT_SIDE_SERVER_MAX_SEALED_MESSAGE_BYTES = 65536;
12
+ export const DEFAULT_CLIENT_SIDE_SERVER_REPLAY_WINDOW_MS = 5 * 60_000;
13
+ export const DEFAULT_CLIENT_SIDE_SERVER_CLOCK_SKEW_TOLERANCE_MS = 30_000;
9
14
  export const DEFAULT_CLIENT_SIDE_SERVER_ICE_SERVERS = [
10
15
  { urls: 'stun:stun.l.google.com:19302' },
11
16
  { urls: 'stun:stun1.l.google.com:19302' },
@@ -26,8 +31,11 @@ export class ClientSideServerMQTTWebRTCServer {
26
31
  announceTimer;
27
32
  identityKeyPair;
28
33
  publicIdentity;
34
+ encryptionKeyPair;
35
+ encryptionPublicIdentity;
29
36
  clientCount = 0;
30
37
  resolvedInstanceInfo;
38
+ seenSealedNonces = new Map();
31
39
  constructor(options) {
32
40
  this.options = options;
33
41
  this.serverInstanceId = `${options.serverName}:${randomId('server')}`;
@@ -39,11 +47,18 @@ export class ClientSideServerMQTTWebRTCServer {
39
47
  if (this.mqtt)
40
48
  return;
41
49
  await this.ensureIdentity();
50
+ if (isSecureSignalingEnabled(this.options)) {
51
+ await this.ensureEncryptionIdentity();
52
+ }
42
53
  this.mqtt = await connectToBroker(this.options);
43
54
  this.mqtt.on('message', (_topic, payload) => {
44
55
  void this.onMessage(payload);
45
56
  });
46
- await subscribe(this.mqtt, this.options.mqttTopic ?? DEFAULT_CLIENT_SIDE_SERVER_MQTT_TOPIC);
57
+ await subscribe(this.mqtt, resolvePlaintextTopic(this.options));
58
+ const sealedTopic = resolveSealedTopic(this.options);
59
+ if (sealedTopic !== resolvePlaintextTopic(this.options)) {
60
+ await subscribe(this.mqtt, sealedTopic);
61
+ }
47
62
  // Compute and cache instance info (includes openapi hash) before first announce
48
63
  this.resolvedInstanceInfo = await this.buildResolvedInstanceInfo();
49
64
  await this.announce();
@@ -73,7 +88,7 @@ export class ClientSideServerMQTTWebRTCServer {
73
88
  async announce() {
74
89
  if (!this.mqtt)
75
90
  return;
76
- await publish(this.mqtt, this.options.mqttTopic ?? DEFAULT_CLIENT_SIDE_SERVER_MQTT_TOPIC, {
91
+ await publishJson(this.mqtt, resolvePlaintextTopic(this.options), {
77
92
  protocol: 'plat-css-v1',
78
93
  kind: 'announce',
79
94
  senderId: this.serverInstanceId,
@@ -86,33 +101,80 @@ export class ClientSideServerMQTTWebRTCServer {
86
101
  }
87
102
  async onMessage(payload) {
88
103
  const message = parseSignalingMessage(payload);
89
- if (!message || message.senderId === this.serverInstanceId)
104
+ if (message) {
105
+ if (message.senderId === this.serverInstanceId)
106
+ return;
107
+ if (message.targetId && message.targetId !== this.options.serverName && message.targetId !== this.serverInstanceId)
108
+ return;
109
+ if (message.kind === 'discover' && message.targetId === this.options.serverName && message.challengeNonce) {
110
+ await this.respondToDiscover(message);
111
+ return;
112
+ }
113
+ if (!isSecureSignalingEnabled(this.options)) {
114
+ if (message.kind === 'offer'
115
+ && (message.targetId === this.options.serverName || message.targetId === this.serverInstanceId)
116
+ && message.connectionId
117
+ && message.description) {
118
+ await this.acceptOffer(message);
119
+ return;
120
+ }
121
+ if (message.kind === 'ice'
122
+ && (message.targetId === this.options.serverName || message.targetId === this.serverInstanceId)
123
+ && message.connectionId
124
+ && message.candidate) {
125
+ const peer = this.peers.get(message.connectionId);
126
+ if (peer?.remoteDescription) {
127
+ await peer.addIceCandidate(new RTCIceCandidate(message.candidate));
128
+ }
129
+ else {
130
+ const pending = this.pendingCandidates.get(message.connectionId) ?? [];
131
+ pending.push(message.candidate);
132
+ this.pendingCandidates.set(message.connectionId, pending);
133
+ }
134
+ }
135
+ }
90
136
  return;
91
- if (message.targetId && message.targetId !== this.options.serverName && message.targetId !== this.serverInstanceId)
137
+ }
138
+ if (!isSecureSignalingEnabled(this.options))
92
139
  return;
93
- if (message.kind === 'discover' && message.targetId === this.options.serverName && message.challengeNonce) {
94
- await this.respondToDiscover(message);
140
+ const opened = await openClientSideServerSealedEnvelopeForServer({
141
+ envelopePayload: payload,
142
+ serverEncryptionKeyPair: this.encryptionKeyPair,
143
+ });
144
+ if (!opened || envelopeIsReplayedOrStale(opened.payload, this.options))
145
+ return;
146
+ if (this.isSealedReplay(opened.envelope.senderId, opened.envelope.nonce)) {
147
+ debugSecure('sealed payload replayed', {
148
+ serverName: this.options.serverName,
149
+ senderId: opened.envelope.senderId,
150
+ nonce: opened.envelope.nonce,
151
+ });
95
152
  return;
96
153
  }
97
- if (message.kind === 'offer'
98
- && (message.targetId === this.options.serverName || message.targetId === this.serverInstanceId)
99
- && message.connectionId
100
- && message.description) {
101
- await this.acceptOffer(message);
154
+ this.rememberSealedNonce(opened.envelope.senderId, opened.envelope.nonce);
155
+ debugSecure('sealed payload accepted', {
156
+ serverName: this.options.serverName,
157
+ senderId: opened.envelope.senderId,
158
+ type: opened.payload.type,
159
+ connectionId: opened.payload.connectionId,
160
+ });
161
+ if (opened.payload.type === 'offer') {
162
+ await this.acceptSealedOffer(opened.payload, {
163
+ senderId: opened.envelope.senderId,
164
+ clientEphemeralPublicKeyJwk: opened.envelope.clientEphemeralPublicKeyJwk,
165
+ connectionId: opened.payload.connectionId,
166
+ });
102
167
  return;
103
168
  }
104
- if (message.kind === 'ice'
105
- && (message.targetId === this.options.serverName || message.targetId === this.serverInstanceId)
106
- && message.connectionId
107
- && message.candidate) {
108
- const peer = this.peers.get(message.connectionId);
169
+ if (opened.payload.type === 'ice') {
170
+ const peer = this.peers.get(opened.payload.connectionId);
109
171
  if (peer?.remoteDescription) {
110
- await peer.addIceCandidate(new RTCIceCandidate(message.candidate));
172
+ await peer.addIceCandidate(new RTCIceCandidate(opened.payload.candidate));
111
173
  }
112
174
  else {
113
- const pending = this.pendingCandidates.get(message.connectionId) ?? [];
114
- pending.push(message.candidate);
115
- this.pendingCandidates.set(message.connectionId, pending);
175
+ const pending = this.pendingCandidates.get(opened.payload.connectionId) ?? [];
176
+ pending.push(opened.payload.candidate);
177
+ this.pendingCandidates.set(opened.payload.connectionId, pending);
116
178
  }
117
179
  }
118
180
  }
@@ -120,19 +182,32 @@ export class ClientSideServerMQTTWebRTCServer {
120
182
  if (!this.mqtt || !message.challengeNonce)
121
183
  return;
122
184
  await this.ensureIdentity();
185
+ let encryptionBootstrap = {};
186
+ if (message.requestEncryptionIdentity && message.bootstrapClientEphemeralPublicKeyJwk) {
187
+ await this.ensureEncryptionIdentity();
188
+ encryptionBootstrap = await createPublicEncryptionBootstrapResponse({
189
+ serverInstanceId: this.serverInstanceId,
190
+ serverName: this.options.serverName,
191
+ serverEncryptionKeyPair: this.encryptionKeyPair,
192
+ serverEncryptionIdentity: this.encryptionPublicIdentity,
193
+ challengeNonce: message.challengeNonce,
194
+ clientEphemeralPublicKeyJwk: message.bootstrapClientEphemeralPublicKeyJwk,
195
+ });
196
+ }
123
197
  const challengeString = buildClientSideServerIdentityChallenge({
124
198
  serverName: this.options.serverName,
125
199
  connectionId: this.serverInstanceId,
126
200
  challengeNonce: message.challengeNonce,
127
201
  });
128
202
  const challengeSignature = await signClientSideServerChallenge(this.identityKeyPair, challengeString);
129
- await publish(this.mqtt, this.options.mqttTopic ?? DEFAULT_CLIENT_SIDE_SERVER_MQTT_TOPIC, {
203
+ await publishJson(this.mqtt, resolvePlaintextTopic(this.options), {
130
204
  protocol: 'plat-css-v1',
131
205
  kind: 'announce',
132
206
  senderId: this.serverInstanceId,
133
207
  serverName: this.options.serverName,
134
208
  identity: this.publicIdentity,
135
209
  authorityRecord: this.options.identity?.authorityRecord,
210
+ ...encryptionBootstrap,
136
211
  challengeNonce: message.challengeNonce,
137
212
  challengeSignature,
138
213
  workerInfo: this.buildWorkerInfo(),
@@ -195,7 +270,7 @@ export class ClientSideServerMQTTWebRTCServer {
195
270
  this.options.workerInfo.acceptingNewClients = false;
196
271
  }
197
272
  if (this.mqtt) {
198
- await publish(this.mqtt, this.options.mqttTopic ?? DEFAULT_CLIENT_SIDE_SERVER_MQTT_TOPIC, {
273
+ await publishJson(this.mqtt, resolvePlaintextTopic(this.options), {
199
274
  protocol: 'plat-css-v1',
200
275
  kind: 'announce',
201
276
  senderId: this.serverInstanceId,
@@ -221,7 +296,7 @@ export class ClientSideServerMQTTWebRTCServer {
221
296
  peer.onicecandidate = (event) => {
222
297
  if (!event.candidate || !this.mqtt)
223
298
  return;
224
- void publish(this.mqtt, this.options.mqttTopic ?? DEFAULT_CLIENT_SIDE_SERVER_MQTT_TOPIC, {
299
+ void publishJson(this.mqtt, resolvePlaintextTopic(this.options), {
225
300
  protocol: 'plat-css-v1',
226
301
  kind: 'ice',
227
302
  senderId: this.serverInstanceId,
@@ -265,7 +340,7 @@ export class ClientSideServerMQTTWebRTCServer {
265
340
  this.pendingCandidates.delete(message.connectionId);
266
341
  const answer = await peer.createAnswer();
267
342
  await peer.setLocalDescription(answer);
268
- await publish(this.mqtt, this.options.mqttTopic ?? DEFAULT_CLIENT_SIDE_SERVER_MQTT_TOPIC, {
343
+ await publishJson(this.mqtt, resolvePlaintextTopic(this.options), {
269
344
  protocol: 'plat-css-v1',
270
345
  kind: 'answer',
271
346
  senderId: this.serverInstanceId,
@@ -286,6 +361,113 @@ export class ClientSideServerMQTTWebRTCServer {
286
361
  at: Date.now(),
287
362
  });
288
363
  }
364
+ async acceptSealedOffer(payload, response) {
365
+ if (!this.mqtt)
366
+ return;
367
+ await this.ensureIdentity();
368
+ await this.ensureEncryptionIdentity();
369
+ const authOk = await this.authorizeSealedOffer(payload, response);
370
+ if (!authOk)
371
+ return;
372
+ if (this.options.workerInfo?.acceptingNewClients === false) {
373
+ await this.publishSealedResponse(response, {
374
+ type: 'reject',
375
+ connectionId: payload.connectionId,
376
+ serverName: this.options.serverName,
377
+ reason: 'server-not-accepting',
378
+ at: Date.now(),
379
+ });
380
+ return;
381
+ }
382
+ const peer = new RTCPeerConnection({
383
+ iceServers: this.options.iceServers ?? DEFAULT_CLIENT_SIDE_SERVER_ICE_SERVERS,
384
+ });
385
+ this.peers.set(payload.connectionId, peer);
386
+ peer.onicecandidate = (event) => {
387
+ if (!event.candidate || !this.mqtt)
388
+ return;
389
+ void this.publishSealedResponse(response, {
390
+ type: 'ice',
391
+ connectionId: payload.connectionId,
392
+ serverName: this.options.serverName,
393
+ candidate: event.candidate.toJSON(),
394
+ at: Date.now(),
395
+ });
396
+ };
397
+ peer.onconnectionstatechange = () => {
398
+ if (peer.connectionState === 'failed' || peer.connectionState === 'closed' || peer.connectionState === 'disconnected') {
399
+ this.unsubscribeByConnection.get(payload.connectionId)?.();
400
+ this.unsubscribeByConnection.delete(payload.connectionId);
401
+ this.peers.delete(payload.connectionId);
402
+ }
403
+ };
404
+ peer.ondatachannel = (event) => {
405
+ const channel = createRTCDataChannelAdapter(event.channel);
406
+ const connId = payload.connectionId;
407
+ const controlUnsubscribe = channel.subscribe(async (msg) => {
408
+ await this.handleControlMessage(msg, channel, connId);
409
+ });
410
+ const serveUnsubscribe = this.options.server.serveChannel(channel);
411
+ this.channels.set(connId, channel);
412
+ this.clientCount++;
413
+ const cleanup = () => {
414
+ controlUnsubscribe();
415
+ serveUnsubscribe();
416
+ this.channels.delete(connId);
417
+ this.clientCount--;
418
+ this.unsubscribeByConnection.delete(connId);
419
+ };
420
+ this.unsubscribeByConnection.set(connId, cleanup);
421
+ event.channel.addEventListener('close', cleanup, { once: true });
422
+ };
423
+ try {
424
+ await peer.setRemoteDescription(new RTCSessionDescription(payload.description));
425
+ for (const candidate of this.pendingCandidates.get(payload.connectionId) ?? []) {
426
+ await peer.addIceCandidate(new RTCIceCandidate(candidate));
427
+ }
428
+ this.pendingCandidates.delete(payload.connectionId);
429
+ const answer = await peer.createAnswer();
430
+ await peer.setLocalDescription(answer);
431
+ await this.publishSealedResponse(response, {
432
+ type: 'answer',
433
+ connectionId: payload.connectionId,
434
+ serverName: this.options.serverName,
435
+ description: answer,
436
+ identity: this.publicIdentity,
437
+ authorityRecord: this.options.identity?.authorityRecord,
438
+ challengeNonce: payload.challengeNonce,
439
+ challengeSignature: payload.challengeNonce
440
+ ? await signClientSideServerChallenge(this.identityKeyPair, buildClientSideServerIdentityChallenge({
441
+ serverName: this.options.serverName,
442
+ connectionId: payload.connectionId,
443
+ challengeNonce: payload.challengeNonce,
444
+ }))
445
+ : undefined,
446
+ at: Date.now(),
447
+ });
448
+ debugSecure('secure answer sent', {
449
+ serverName: this.options.serverName,
450
+ connectionId: payload.connectionId,
451
+ });
452
+ }
453
+ catch (error) {
454
+ debugSecure('sealed offer processing failed', {
455
+ serverName: this.options.serverName,
456
+ connectionId: payload.connectionId,
457
+ error: error instanceof Error ? error.message : String(error),
458
+ });
459
+ await this.publishSealedResponse(response, {
460
+ type: 'reject',
461
+ connectionId: payload.connectionId,
462
+ serverName: this.options.serverName,
463
+ reason: 'bad-message',
464
+ at: Date.now(),
465
+ });
466
+ peer.close();
467
+ this.peers.delete(payload.connectionId);
468
+ this.pendingCandidates.delete(payload.connectionId);
469
+ }
470
+ }
289
471
  async ensureIdentity() {
290
472
  if (this.identityKeyPair && this.publicIdentity)
291
473
  return;
@@ -297,6 +479,105 @@ export class ClientSideServerMQTTWebRTCServer {
297
479
  });
298
480
  this.publicIdentity = await toClientSideServerPublicIdentity(this.identityKeyPair);
299
481
  }
482
+ async ensureEncryptionIdentity() {
483
+ if (this.encryptionKeyPair && this.encryptionPublicIdentity)
484
+ return;
485
+ if (this.options.serverEncryptionKeyPair) {
486
+ this.encryptionKeyPair = this.options.serverEncryptionKeyPair;
487
+ }
488
+ else {
489
+ this.encryptionKeyPair = await generateClientSideServerEncryptionKeyPair();
490
+ }
491
+ this.encryptionPublicIdentity = await toClientSideServerEncryptionPublicIdentity(this.encryptionKeyPair);
492
+ }
493
+ isSealedReplay(senderId, nonce) {
494
+ this.pruneSeenSealedNonces();
495
+ return this.seenSealedNonces.has(`${senderId}:${nonce}`);
496
+ }
497
+ rememberSealedNonce(senderId, nonce) {
498
+ this.pruneSeenSealedNonces();
499
+ this.seenSealedNonces.set(`${senderId}:${nonce}`, Date.now());
500
+ }
501
+ pruneSeenSealedNonces() {
502
+ const cutoff = Date.now() - (this.options.replayWindowMs ?? DEFAULT_CLIENT_SIDE_SERVER_REPLAY_WINDOW_MS);
503
+ for (const [key, value] of this.seenSealedNonces.entries()) {
504
+ if (value < cutoff) {
505
+ this.seenSealedNonces.delete(key);
506
+ }
507
+ }
508
+ }
509
+ async publishSealedResponse(response, payload) {
510
+ if (!this.mqtt)
511
+ return;
512
+ const sealed = await sealClientSideServerPayloadForServer({
513
+ senderId: this.serverInstanceId,
514
+ payload,
515
+ serverEncryptionKeyPair: this.encryptionKeyPair,
516
+ clientEphemeralPublicKeyJwk: response.clientEphemeralPublicKeyJwk,
517
+ maxSealedMessageBytes: this.options.maxSealedMessageBytes,
518
+ });
519
+ if (payload.type === 'reject') {
520
+ debugSecure('sealed reject sent', {
521
+ serverName: this.options.serverName,
522
+ connectionId: payload.connectionId,
523
+ reason: payload.reason,
524
+ });
525
+ }
526
+ await publishRaw(this.mqtt, resolveSealedTopic(this.options), serializeSealedEnvelope(sealed.envelope));
527
+ }
528
+ async authorizeSealedOffer(payload, response) {
529
+ const signalingAuth = this.options.signalingAuth;
530
+ if (!signalingAuth)
531
+ return true;
532
+ if (!payload.auth) {
533
+ if (signalingAuth.required !== false) {
534
+ await this.publishSealedResponse(response, {
535
+ type: 'reject',
536
+ connectionId: payload.connectionId,
537
+ serverName: this.options.serverName,
538
+ reason: 'auth-required',
539
+ at: Date.now(),
540
+ });
541
+ return false;
542
+ }
543
+ return true;
544
+ }
545
+ if (typeof signalingAuth.verify !== 'function') {
546
+ if (signalingAuth.required !== false) {
547
+ await this.publishSealedResponse(response, {
548
+ type: 'reject',
549
+ connectionId: payload.connectionId,
550
+ serverName: this.options.serverName,
551
+ reason: 'auth-required',
552
+ at: Date.now(),
553
+ });
554
+ return false;
555
+ }
556
+ return true;
557
+ }
558
+ let verified = false;
559
+ try {
560
+ verified = await signalingAuth.verify(payload.auth, {
561
+ serverName: this.options.serverName,
562
+ connectionId: payload.connectionId,
563
+ senderId: response.senderId,
564
+ });
565
+ }
566
+ catch {
567
+ verified = false;
568
+ }
569
+ if (!verified) {
570
+ await this.publishSealedResponse(response, {
571
+ type: 'reject',
572
+ connectionId: payload.connectionId,
573
+ serverName: this.options.serverName,
574
+ reason: 'auth-failed',
575
+ at: Date.now(),
576
+ });
577
+ return false;
578
+ }
579
+ return true;
580
+ }
300
581
  }
301
582
  export function createClientSideServerMQTTWebRTCTransportPlugin(options = {}) {
302
583
  if (isDefaultMQTTWebRTCOptions(options)) {
@@ -390,7 +671,7 @@ function createClientSideServerMQTTWebRTCPeerPoolInternal(options) {
390
671
  }
391
672
  export async function discoverClientSideServers(serverName, options = {}) {
392
673
  const mqttClient = await connectToBroker(options);
393
- const topic = options.mqttTopic ?? DEFAULT_CLIENT_SIDE_SERVER_MQTT_TOPIC;
674
+ const topic = resolvePlaintextTopic(options);
394
675
  const peerId = `${options.clientIdPrefix ?? 'client'}:${randomId('discover')}`;
395
676
  const challengeNonce = randomId('challenge') + '-mqtt';
396
677
  const timeoutMs = options.workerPool?.discoveryTimeoutMs ?? 3000;
@@ -416,7 +697,7 @@ export async function discoverClientSideServers(serverName, options = {}) {
416
697
  mqttChallengeVerified = await verifyClientSideServerChallenge(message.identity.publicKeyJwk, challengeString, message.challengeSignature);
417
698
  }
418
699
  if (message.authorityRecord && options.identity?.authority?.publicKeyJwk) {
419
- const authorityOk = await verifySignedClientSideServerAuthorityRecord(message.authorityRecord, options.identity.authority.publicKeyJwk);
700
+ const authorityOk = await verifyAnySignedClientSideServerAuthorityRecord(message.authorityRecord, options.identity.authority.publicKeyJwk);
420
701
  if (!authorityOk)
421
702
  return;
422
703
  }
@@ -502,7 +783,8 @@ export async function connectFirstDiscovery(address, options) {
502
783
  export async function createClientSideServerMQTTWebRTCPeerSession(address, options, targetInstanceId) {
503
784
  const webrtc = await resolveClientWebRTCImplementation();
504
785
  const mqtt = await connectToBroker(options);
505
- const topic = options.mqttTopic ?? DEFAULT_CLIENT_SIDE_SERVER_MQTT_TOPIC;
786
+ const plaintextTopic = resolvePlaintextTopic(options);
787
+ const sealedTopic = resolveSealedTopic(options);
506
788
  const peerId = `${options.clientIdPrefix ?? 'client'}:${randomId('peer')}`;
507
789
  const connectionId = randomId('conn');
508
790
  const challengeNonce = randomId('challenge');
@@ -515,11 +797,69 @@ export async function createClientSideServerMQTTWebRTCPeerSession(address, optio
515
797
  const cleanupCallbacks = [];
516
798
  const pendingCandidates = [];
517
799
  const expectedIdentity = await resolveExpectedServerIdentity(address.serverName, options.identity);
800
+ let secureState;
518
801
  let open = true;
519
802
  let answerApplied = false;
520
803
  let serverIdentity;
521
- await subscribe(mqtt, topic);
804
+ await subscribe(mqtt, plaintextTopic);
805
+ if (sealedTopic !== plaintextTopic) {
806
+ await subscribe(mqtt, sealedTopic);
807
+ }
808
+ if (isSecureSignalingEnabled(options)) {
809
+ secureState = await createSecureClientSessionState({
810
+ mqtt,
811
+ address,
812
+ options,
813
+ peerId,
814
+ expectedIdentity,
815
+ });
816
+ }
522
817
  const onMessage = async (_topic, payload) => {
818
+ if (secureState) {
819
+ const opened = await openClientSideServerSealedEnvelopeForClient({
820
+ envelopePayload: payload,
821
+ secureState,
822
+ });
823
+ if (!opened || opened.envelope.senderId === peerId || opened.payload.connectionId !== connectionId)
824
+ return;
825
+ if (opened.payload.type === 'reject') {
826
+ ready.reject(new Error(`Client-side server ${address.serverName} rejected connection: ${opened.payload.reason}`));
827
+ return;
828
+ }
829
+ if (opened.payload.type === 'answer' && opened.payload.description) {
830
+ if (answerApplied)
831
+ return;
832
+ answerApplied = true;
833
+ await verifyServerIdentityForSealedAnswer({
834
+ serverName: address.serverName,
835
+ connectionId,
836
+ challengeNonce,
837
+ message: opened.payload,
838
+ optionsIdentity: options.identity,
839
+ expectedIdentity,
840
+ });
841
+ serverIdentity = opened.payload.identity;
842
+ await peer.setRemoteDescription(webrtc.createSessionDescription(opened.payload.description));
843
+ await maybeTrustServerAfterAnswer(address.serverName, expectedIdentity, opened.payload, options.identity);
844
+ for (const candidate of pendingCandidates.splice(0, pendingCandidates.length)) {
845
+ await peer.addIceCandidate(webrtc.createIceCandidate(candidate));
846
+ }
847
+ return;
848
+ }
849
+ if (opened.payload.type === 'ice') {
850
+ debugSecure('secure ICE received', {
851
+ serverName: address.serverName,
852
+ connectionId,
853
+ });
854
+ if (peer.remoteDescription) {
855
+ await peer.addIceCandidate(webrtc.createIceCandidate(opened.payload.candidate));
856
+ }
857
+ else {
858
+ pendingCandidates.push(opened.payload.candidate);
859
+ }
860
+ }
861
+ return;
862
+ }
523
863
  const message = parseSignalingMessage(payload);
524
864
  if (!message || message.senderId === peerId || message.targetId !== peerId || message.connectionId !== connectionId) {
525
865
  return;
@@ -538,24 +878,7 @@ export async function createClientSideServerMQTTWebRTCPeerSession(address, optio
538
878
  });
539
879
  serverIdentity = message.identity;
540
880
  await peer.setRemoteDescription(webrtc.createSessionDescription(message.description));
541
- if (!expectedIdentity && message.authorityRecord && options.identity?.authority?.publicKeyJwk) {
542
- const trusted = await trustClientSideServerFromAuthorityRecord(message.authorityRecord, options.identity.authority.publicKeyJwk, {
543
- storage: options.identity?.knownHostsStorage,
544
- storageKey: options.identity?.knownHostsStorageKey,
545
- });
546
- if (options.identity?.knownHosts) {
547
- saveTrustedClientSideServerRecordToMap(options.identity.knownHosts, trusted);
548
- }
549
- }
550
- else if (!expectedIdentity && options.identity?.trustOnFirstUse !== false && message.identity) {
551
- const trusted = await trustClientSideServerOnFirstUse(address.serverName, message.identity, {
552
- storage: options.identity?.knownHostsStorage,
553
- storageKey: options.identity?.knownHostsStorageKey,
554
- });
555
- if (options.identity?.knownHosts) {
556
- saveTrustedClientSideServerRecordToMap(options.identity.knownHosts, trusted);
557
- }
558
- }
881
+ await maybeTrustServerAfterAnswer(address.serverName, expectedIdentity, message, options.identity);
559
882
  for (const candidate of pendingCandidates.splice(0, pendingCandidates.length)) {
560
883
  await peer.addIceCandidate(webrtc.createIceCandidate(candidate));
561
884
  }
@@ -573,10 +896,29 @@ export async function createClientSideServerMQTTWebRTCPeerSession(address, optio
573
896
  mqtt.on('message', onMessage);
574
897
  cleanupCallbacks.push(() => mqtt.off('message', onMessage));
575
898
  const offerTarget = targetInstanceId ?? address.serverName;
899
+ const addressAuth = resolveAddressAuth(address);
576
900
  peer.onicecandidate = (event) => {
577
901
  if (!event.candidate)
578
902
  return;
579
- void publish(mqtt, topic, {
903
+ if (secureState) {
904
+ void publishSealedClientPayload({
905
+ mqtt,
906
+ options,
907
+ senderId: peerId,
908
+ secureState,
909
+ payload: {
910
+ type: 'ice',
911
+ connectionId,
912
+ serverName: address.serverName,
913
+ candidate: webrtc.serializeIceCandidate(event.candidate),
914
+ at: Date.now(),
915
+ },
916
+ }).then(() => {
917
+ debugSecure('secure ICE sent', { serverName: address.serverName, connectionId });
918
+ });
919
+ return;
920
+ }
921
+ void publish(mqtt, plaintextTopic, {
580
922
  protocol: 'plat-css-v1',
581
923
  kind: 'ice',
582
924
  senderId: peerId,
@@ -602,22 +944,52 @@ export async function createClientSideServerMQTTWebRTCPeerSession(address, optio
602
944
  }, { once: true });
603
945
  const offer = await peer.createOffer();
604
946
  await peer.setLocalDescription(offer);
605
- await publish(mqtt, topic, {
606
- protocol: 'plat-css-v1',
607
- kind: 'offer',
608
- senderId: peerId,
609
- targetId: offerTarget,
610
- serverName: address.serverName,
611
- connectionId,
612
- description: offer,
613
- challengeNonce,
614
- at: Date.now(),
615
- });
947
+ if (secureState) {
948
+ await publishSealedClientPayload({
949
+ mqtt,
950
+ options,
951
+ senderId: peerId,
952
+ secureState,
953
+ payload: {
954
+ type: 'offer',
955
+ connectionId,
956
+ serverName: address.serverName,
957
+ description: offer,
958
+ challengeNonce,
959
+ requirePrivateChallenge: options.requirePrivateChallenge,
960
+ auth: addressAuth,
961
+ at: Date.now(),
962
+ },
963
+ });
964
+ }
965
+ else {
966
+ await publish(mqtt, plaintextTopic, {
967
+ protocol: 'plat-css-v1',
968
+ kind: 'offer',
969
+ senderId: peerId,
970
+ targetId: offerTarget,
971
+ serverName: address.serverName,
972
+ connectionId,
973
+ description: offer,
974
+ challengeNonce,
975
+ at: Date.now(),
976
+ });
977
+ }
616
978
  const timer = setTimeout(() => {
617
979
  ready.reject(new Error(`Timed out connecting to client-side server ${address.serverName}`));
618
980
  }, timeoutMs);
619
981
  cleanupCallbacks.push(() => clearTimeout(timer));
620
- await ready.promise;
982
+ try {
983
+ await ready.promise;
984
+ }
985
+ catch (error) {
986
+ open = false;
987
+ for (const cleanup of cleanupCallbacks)
988
+ cleanup();
989
+ peer.close();
990
+ await endClient(mqtt);
991
+ throw error;
992
+ }
621
993
  const channel = createRTCDataChannelAdapter(dataChannel);
622
994
  const originalClose = channel.close?.bind(channel);
623
995
  return {
@@ -659,7 +1031,7 @@ export async function createClientSideServerMQTTWebRTCPeerSession(address, optio
659
1031
  async function resolveExpectedServerIdentity(serverName, options) {
660
1032
  const mapped = loadTrustedClientSideServerRecordFromMap(options?.knownHosts, serverName);
661
1033
  if (mapped)
662
- return mapped;
1034
+ return normalizeExpectedIdentity(mapped);
663
1035
  if (options?.authorityServers?.length) {
664
1036
  const trusted = await resolveTrustedClientSideServerFromAuthorities(serverName, options.authorityServers, {
665
1037
  storage: options?.knownHostsStorage,
@@ -669,12 +1041,12 @@ async function resolveExpectedServerIdentity(serverName, options) {
669
1041
  if (options.knownHosts) {
670
1042
  saveTrustedClientSideServerRecordToMap(options.knownHosts, trusted);
671
1043
  }
672
- return trusted;
1044
+ return normalizeExpectedIdentity(trusted);
673
1045
  }
674
1046
  }
675
1047
  const authorityResolved = options?.authorityResolver ? await options.authorityResolver(serverName) : null;
676
1048
  if (authorityResolved)
677
- return authorityResolved;
1049
+ return normalizeExpectedIdentity(authorityResolved);
678
1050
  const stored = loadTrustedClientSideServerRecord(serverName, {
679
1051
  storage: options?.knownHostsStorage,
680
1052
  storageKey: options?.knownHostsStorageKey,
@@ -682,7 +1054,7 @@ async function resolveExpectedServerIdentity(serverName, options) {
682
1054
  if (stored && options?.knownHosts) {
683
1055
  saveTrustedClientSideServerRecordToMap(options.knownHosts, stored);
684
1056
  }
685
- return stored;
1057
+ return stored ? normalizeExpectedIdentity(stored) : null;
686
1058
  }
687
1059
  async function verifyServerIdentityForAnswer(input) {
688
1060
  const { message, serverName, connectionId, challengeNonce, expectedIdentity } = input;
@@ -693,11 +1065,11 @@ async function verifyServerIdentityForAnswer(input) {
693
1065
  return;
694
1066
  }
695
1067
  if (message.authorityRecord && input.optionsIdentity?.authority?.publicKeyJwk) {
696
- const authorityOk = await verifySignedClientSideServerAuthorityRecord(message.authorityRecord, input.optionsIdentity.authority.publicKeyJwk);
1068
+ const authorityOk = await verifyAnySignedClientSideServerAuthorityRecord(message.authorityRecord, input.optionsIdentity.authority.publicKeyJwk);
697
1069
  if (!authorityOk) {
698
1070
  throw new Error(`Server ${serverName} provided an invalid authority record`);
699
1071
  }
700
- if (!clientSideServerPublicKeysEqual(message.authorityRecord.publicKeyJwk, message.identity.publicKeyJwk)) {
1072
+ if (!clientSideServerPublicKeysEqual(getAuthoritySigningPublicKey(message.authorityRecord), message.identity.publicKeyJwk)) {
701
1073
  throw new Error(`Server ${serverName} authority record does not match presented identity`);
702
1074
  }
703
1075
  }
@@ -709,10 +1081,52 @@ async function verifyServerIdentityForAnswer(input) {
709
1081
  if (!verified) {
710
1082
  throw new Error(`Server ${serverName} failed identity challenge verification`);
711
1083
  }
712
- if (expectedIdentity && !clientSideServerPublicKeysEqual(expectedIdentity.publicKeyJwk, message.identity.publicKeyJwk)) {
1084
+ if (expectedIdentity && !clientSideServerPublicKeysEqual(expectedIdentity.signing.publicKeyJwk, message.identity.publicKeyJwk)) {
713
1085
  throw new Error(`Server ${serverName} presented an unexpected public key`);
714
1086
  }
715
1087
  }
1088
+ async function verifyServerIdentityForSealedAnswer(input) {
1089
+ const message = {
1090
+ protocol: 'plat-css-v1',
1091
+ kind: 'answer',
1092
+ senderId: 'sealed',
1093
+ connectionId: input.connectionId,
1094
+ serverName: input.serverName,
1095
+ description: input.message.description,
1096
+ identity: input.message.identity,
1097
+ authorityRecord: input.message.authorityRecord,
1098
+ challengeNonce: input.message.challengeNonce,
1099
+ challengeSignature: input.message.challengeSignature,
1100
+ at: input.message.at,
1101
+ };
1102
+ await verifyServerIdentityForAnswer({
1103
+ ...input,
1104
+ message,
1105
+ });
1106
+ }
1107
+ async function maybeTrustServerAfterAnswer(serverName, expectedIdentity, message, identityOptions) {
1108
+ if (expectedIdentity)
1109
+ return;
1110
+ if (message.authorityRecord && identityOptions?.authority?.publicKeyJwk) {
1111
+ const trusted = await trustClientSideServerFromAuthorityRecord(message.authorityRecord, identityOptions.authority.publicKeyJwk, {
1112
+ storage: identityOptions?.knownHostsStorage,
1113
+ storageKey: identityOptions?.knownHostsStorageKey,
1114
+ });
1115
+ if (identityOptions?.knownHosts) {
1116
+ saveTrustedClientSideServerRecordToMap(identityOptions.knownHosts, trusted);
1117
+ }
1118
+ return;
1119
+ }
1120
+ if (identityOptions?.trustOnFirstUse !== false && message.identity) {
1121
+ const trusted = await trustClientSideServerOnFirstUse(serverName, message.identity, {
1122
+ storage: identityOptions?.knownHostsStorage,
1123
+ storageKey: identityOptions?.knownHostsStorageKey,
1124
+ });
1125
+ if (identityOptions?.knownHosts) {
1126
+ saveTrustedClientSideServerRecordToMap(identityOptions.knownHosts, trusted);
1127
+ }
1128
+ }
1129
+ }
716
1130
  export function createClientSideServerMQTTWebRTCServer(options) {
717
1131
  return new ClientSideServerMQTTWebRTCServer(options);
718
1132
  }
@@ -763,6 +1177,19 @@ function publish(client, topic, message) {
763
1177
  });
764
1178
  });
765
1179
  }
1180
+ function publishJson(client, topic, message) {
1181
+ return publish(client, topic, message);
1182
+ }
1183
+ function publishRaw(client, topic, message) {
1184
+ return new Promise((resolve, reject) => {
1185
+ client.publish(topic, message, (error) => {
1186
+ if (error)
1187
+ reject(error);
1188
+ else
1189
+ resolve();
1190
+ });
1191
+ });
1192
+ }
766
1193
  function endClient(client) {
767
1194
  return new Promise((resolve) => {
768
1195
  client.end(false, {}, () => resolve());
@@ -790,6 +1217,350 @@ function randomId(prefix) {
790
1217
  function isDefaultMQTTWebRTCOptions(options) {
791
1218
  return Object.keys(options).length === 0;
792
1219
  }
1220
+ function isSecureSignalingEnabled(options) {
1221
+ return options.secureSignaling !== false;
1222
+ }
1223
+ function resolvePlaintextTopic(options) {
1224
+ return options.mqttTopic ?? DEFAULT_CLIENT_SIDE_SERVER_MQTT_TOPIC;
1225
+ }
1226
+ function resolveSealedTopic(options) {
1227
+ if (isSecureSignalingEnabled(options) && options.anonymousRouting !== false) {
1228
+ return options.sealedTopic ?? DEFAULT_CLIENT_SIDE_SERVER_SEALED_TOPIC;
1229
+ }
1230
+ return resolvePlaintextTopic(options);
1231
+ }
1232
+ function envelopeIsReplayedOrStale(payload, options) {
1233
+ const replayWindowMs = options.replayWindowMs ?? DEFAULT_CLIENT_SIDE_SERVER_REPLAY_WINDOW_MS;
1234
+ const clockSkewToleranceMs = options.clockSkewToleranceMs ?? DEFAULT_CLIENT_SIDE_SERVER_CLOCK_SKEW_TOLERANCE_MS;
1235
+ const stale = Math.abs(Date.now() - payload.at) > (clockSkewToleranceMs + replayWindowMs);
1236
+ if (stale) {
1237
+ debugSecure('sealed payload stale', { type: payload.type, connectionId: payload.connectionId, at: payload.at });
1238
+ }
1239
+ return stale;
1240
+ }
1241
+ async function openClientSideServerSealedEnvelopeForServer(input) {
1242
+ if (!input.serverEncryptionKeyPair)
1243
+ return null;
1244
+ const envelope = parseSealedEnvelope(input.envelopePayload);
1245
+ if (!envelope)
1246
+ return null;
1247
+ try {
1248
+ const privateKey = await importX25519PrivateKeyJwk(input.serverEncryptionKeyPair.privateKeyJwk);
1249
+ const publicKey = await importX25519PublicKeyJwk(envelope.clientEphemeralPublicKeyJwk);
1250
+ const aeadKey = await deriveAeadKeyFromX25519(privateKey, publicKey, utf8('plat-css-sealed-signaling-v1'));
1251
+ const ciphertext = unpadCiphertext(decodeBase64Url(envelope.ciphertext));
1252
+ const payload = await decryptJsonAead(aeadKey, ciphertext, buildSealedEnvelopeAad(envelope), decodeBase64Url(envelope.nonce));
1253
+ if (!isClientSideServerSealedPayload(payload)) {
1254
+ debugSecure('sealed decrypt failed', { reason: 'payload-validator' });
1255
+ return null;
1256
+ }
1257
+ return { envelope, payload };
1258
+ }
1259
+ catch (error) {
1260
+ debugSecure('sealed decrypt failed', { error: error instanceof Error ? error.message : String(error) });
1261
+ return null;
1262
+ }
1263
+ }
1264
+ async function openClientSideServerSealedEnvelopeForClient(input) {
1265
+ const envelope = parseSealedEnvelope(input.envelopePayload);
1266
+ if (!envelope)
1267
+ return null;
1268
+ try {
1269
+ const aeadKey = await deriveAeadKeyFromX25519(input.secureState.keyPair.privateKey, input.secureState.serverEncryptionPublicKey, utf8('plat-css-sealed-signaling-v1'));
1270
+ const payload = await decryptJsonAead(aeadKey, unpadCiphertext(decodeBase64Url(envelope.ciphertext)), buildSealedEnvelopeAad(envelope), decodeBase64Url(envelope.nonce));
1271
+ if (!isClientSideServerSealedPayload(payload))
1272
+ return null;
1273
+ return { envelope, payload };
1274
+ }
1275
+ catch {
1276
+ return null;
1277
+ }
1278
+ }
1279
+ async function createSecureClientSessionState(input) {
1280
+ const keyPair = await generateEphemeralX25519KeyPair();
1281
+ const serverEncryptionPublicKeyJwk = input.address.encryptionPublicKeyJwk
1282
+ ?? input.expectedIdentity?.encryption?.publicKeyJwk;
1283
+ if (serverEncryptionPublicKeyJwk) {
1284
+ return {
1285
+ keyPair,
1286
+ serverEncryptionPublicKeyJwk,
1287
+ serverEncryptionPublicKey: await importX25519PublicKeyJwk(serverEncryptionPublicKeyJwk),
1288
+ };
1289
+ }
1290
+ return bootstrapServerEncryptionIdentity({
1291
+ ...input,
1292
+ keyPair,
1293
+ });
1294
+ }
1295
+ async function bootstrapServerEncryptionIdentity(input) {
1296
+ const challengeNonce = randomId('enc-bootstrap');
1297
+ const timeoutMs = Math.min(input.options.connectionTimeoutMs ?? 10_000, input.options.workerPool?.discoveryTimeoutMs ?? 3_000);
1298
+ const ready = deferred();
1299
+ const onMessage = async (_topic, payload) => {
1300
+ const message = parseSignalingMessage(payload);
1301
+ if (!message || message.senderId === input.peerId)
1302
+ return;
1303
+ if (message.kind !== 'announce')
1304
+ return;
1305
+ if (message.serverName !== input.address.serverName)
1306
+ return;
1307
+ if (message.challengeNonce !== challengeNonce)
1308
+ return;
1309
+ if (!message.identity || !message.challengeSignature || !message.encryptionIdentity)
1310
+ return;
1311
+ if (!message.encryptionChallengeCiphertext || !message.encryptionChallengeNonce)
1312
+ return;
1313
+ if (input.expectedIdentity
1314
+ && !clientSideServerPublicKeysEqual(input.expectedIdentity.signing.publicKeyJwk, message.identity.publicKeyJwk)) {
1315
+ return;
1316
+ }
1317
+ if (message.authorityRecord && input.options.identity?.authority?.publicKeyJwk) {
1318
+ const authorityOk = await verifyAnySignedClientSideServerAuthorityRecord(message.authorityRecord, input.options.identity.authority.publicKeyJwk);
1319
+ if (!authorityOk)
1320
+ return;
1321
+ if (isClientSideServerSignedAuthorityRecordV2(message.authorityRecord)
1322
+ && !clientSideServerPublicKeysEqual(message.authorityRecord.encryptionPublicKeyJwk, message.encryptionIdentity.publicKeyJwk)) {
1323
+ return;
1324
+ }
1325
+ }
1326
+ const signedChallengeOk = await verifyClientSideServerChallenge(message.identity.publicKeyJwk, buildClientSideServerIdentityChallenge({
1327
+ serverName: input.address.serverName,
1328
+ connectionId: message.senderId,
1329
+ challengeNonce,
1330
+ }), message.challengeSignature);
1331
+ if (!signedChallengeOk)
1332
+ return;
1333
+ try {
1334
+ const serverEncryptionPublicKey = await importX25519PublicKeyJwk(message.encryptionIdentity.publicKeyJwk);
1335
+ const aeadKey = await deriveAeadKeyFromX25519(input.keyPair.privateKey, serverEncryptionPublicKey, utf8('plat-css-sealed-signaling-v1'));
1336
+ const proof = await decryptJsonAead(aeadKey, decodeBase64Url(message.encryptionChallengeCiphertext), buildBootstrapEncryptionAad({
1337
+ senderId: message.senderId,
1338
+ serverName: input.address.serverName,
1339
+ challengeNonce,
1340
+ clientEphemeralPublicKeyJwk: input.keyPair.publicKeyJwk,
1341
+ serverEncryptionPublicKeyJwk: message.encryptionIdentity.publicKeyJwk,
1342
+ }), decodeBase64Url(message.encryptionChallengeNonce));
1343
+ if (proof.challengeNonce !== challengeNonce)
1344
+ return;
1345
+ if (proof.senderId !== message.senderId)
1346
+ return;
1347
+ if (proof.serverName !== input.address.serverName)
1348
+ return;
1349
+ ready.resolve({
1350
+ keyPair: input.keyPair,
1351
+ serverEncryptionPublicKeyJwk: message.encryptionIdentity.publicKeyJwk,
1352
+ serverEncryptionPublicKey,
1353
+ });
1354
+ }
1355
+ catch {
1356
+ return;
1357
+ }
1358
+ };
1359
+ input.mqtt.on('message', onMessage);
1360
+ const timer = setTimeout(() => {
1361
+ ready.reject(new Error(`Timed out bootstrapping encryption public key for ${input.address.serverName}`));
1362
+ }, timeoutMs);
1363
+ try {
1364
+ await publish(input.mqtt, resolvePlaintextTopic(input.options), {
1365
+ protocol: 'plat-css-v1',
1366
+ kind: 'discover',
1367
+ senderId: input.peerId,
1368
+ targetId: input.address.serverName,
1369
+ challengeNonce,
1370
+ requestEncryptionIdentity: true,
1371
+ bootstrapClientEphemeralPublicKeyJwk: input.keyPair.publicKeyJwk,
1372
+ at: Date.now(),
1373
+ });
1374
+ const secureState = await ready.promise;
1375
+ input.address.encryptionPublicKeyJwk = secureState.serverEncryptionPublicKeyJwk;
1376
+ return secureState;
1377
+ }
1378
+ finally {
1379
+ clearTimeout(timer);
1380
+ input.mqtt.off('message', onMessage);
1381
+ }
1382
+ }
1383
+ async function createPublicEncryptionBootstrapResponse(input) {
1384
+ if (!input.serverEncryptionKeyPair || !input.serverEncryptionIdentity || !input.challengeNonce) {
1385
+ return {};
1386
+ }
1387
+ const privateKey = await importX25519PrivateKeyJwk(input.serverEncryptionKeyPair.privateKeyJwk);
1388
+ const publicKey = await importX25519PublicKeyJwk(input.clientEphemeralPublicKeyJwk);
1389
+ const aeadKey = await deriveAeadKeyFromX25519(privateKey, publicKey, utf8('plat-css-sealed-signaling-v1'));
1390
+ const nonce = randomNonce12();
1391
+ const ciphertext = await encryptJsonAead(aeadKey, {
1392
+ challengeNonce: input.challengeNonce,
1393
+ senderId: input.serverInstanceId,
1394
+ serverName: input.serverName,
1395
+ }, buildBootstrapEncryptionAad({
1396
+ senderId: input.serverInstanceId,
1397
+ serverName: input.serverName,
1398
+ challengeNonce: input.challengeNonce,
1399
+ clientEphemeralPublicKeyJwk: input.clientEphemeralPublicKeyJwk,
1400
+ serverEncryptionPublicKeyJwk: input.serverEncryptionIdentity.publicKeyJwk,
1401
+ }), nonce);
1402
+ return {
1403
+ encryptionIdentity: input.serverEncryptionIdentity,
1404
+ encryptionChallengeCiphertext: encodeBase64Url(ciphertext),
1405
+ encryptionChallengeNonce: encodeBase64Url(nonce),
1406
+ };
1407
+ }
1408
+ async function publishSealedClientPayload(input) {
1409
+ const nonce = randomNonce12();
1410
+ const envelope = await sealClientSideServerPayload({
1411
+ senderId: input.senderId,
1412
+ payload: input.payload,
1413
+ privateKey: input.secureState.keyPair.privateKey,
1414
+ peerPublicKey: input.secureState.serverEncryptionPublicKey,
1415
+ clientEphemeralPublicKeyJwk: input.secureState.keyPair.publicKeyJwk,
1416
+ serverEncryptionPublicKeyJwk: input.secureState.serverEncryptionPublicKeyJwk,
1417
+ maxSealedMessageBytes: input.options.maxSealedMessageBytes,
1418
+ nonce,
1419
+ });
1420
+ await publishRaw(input.mqtt, resolveSealedTopic(input.options), serializeSealedEnvelope(envelope.envelope));
1421
+ }
1422
+ async function sealClientSideServerPayloadForServer(input) {
1423
+ if (!input.serverEncryptionKeyPair) {
1424
+ throw new Error('Server encryption key pair is required for sealed signaling');
1425
+ }
1426
+ const privateKey = await importX25519PrivateKeyJwk(input.serverEncryptionKeyPair.privateKeyJwk);
1427
+ const publicKey = await importX25519PublicKeyJwk(input.clientEphemeralPublicKeyJwk);
1428
+ return {
1429
+ ...(await sealClientSideServerPayload({
1430
+ senderId: input.senderId,
1431
+ payload: input.payload,
1432
+ privateKey,
1433
+ peerPublicKey: publicKey,
1434
+ clientEphemeralPublicKeyJwk: input.clientEphemeralPublicKeyJwk,
1435
+ serverEncryptionPublicKeyJwk: input.serverEncryptionKeyPair.publicKeyJwk,
1436
+ maxSealedMessageBytes: input.maxSealedMessageBytes,
1437
+ })),
1438
+ };
1439
+ }
1440
+ async function sealClientSideServerPayload(input) {
1441
+ const nonceBytes = input.nonce ?? randomNonce12();
1442
+ const nonce = encodeBase64Url(nonceBytes);
1443
+ const at = Date.now();
1444
+ const sessionId = await computeSessionId({
1445
+ clientEphemeralPublicKeyJwk: input.clientEphemeralPublicKeyJwk,
1446
+ serverEncryptionPublicKeyJwk: input.serverEncryptionPublicKeyJwk,
1447
+ nonceB64u: nonce,
1448
+ }).catch(() => '');
1449
+ const aeadKey = await deriveAeadKeyFromX25519(input.privateKey, input.peerPublicKey, utf8('plat-css-sealed-signaling-v1'));
1450
+ const aadEnvelope = {
1451
+ platcss: 'sealed',
1452
+ version: 1,
1453
+ senderId: input.senderId,
1454
+ at,
1455
+ nonce,
1456
+ clientEphemeralPublicKeyJwk: input.clientEphemeralPublicKeyJwk,
1457
+ };
1458
+ const ciphertext = await encryptJsonAead(aeadKey, input.payload, utf8(stableJson(aadEnvelope)), nonceBytes);
1459
+ const bucket = choosePaddingBucket(ciphertext.byteLength);
1460
+ const padded = padCiphertext(ciphertext, bucket);
1461
+ const maxBytes = input.maxSealedMessageBytes ?? DEFAULT_CLIENT_SIDE_SERVER_MAX_SEALED_MESSAGE_BYTES;
1462
+ if (padded.byteLength > maxBytes) {
1463
+ throw new Error(`Sealed signaling message exceeds maximum size of ${maxBytes} bytes`);
1464
+ }
1465
+ return {
1466
+ sessionId,
1467
+ envelope: {
1468
+ ...aadEnvelope,
1469
+ ciphertext: encodeBase64Url(padded),
1470
+ },
1471
+ };
1472
+ }
1473
+ function parseSealedEnvelope(payload) {
1474
+ try {
1475
+ const text = payload instanceof Uint8Array ? new TextDecoder().decode(payload) : String(payload);
1476
+ const parsed = JSON.parse(text);
1477
+ if (!isClientSideServerSealedEnvelope(parsed)) {
1478
+ debugSecure('sealed envelope parse failed', { reason: 'validator' });
1479
+ return null;
1480
+ }
1481
+ return parsed;
1482
+ }
1483
+ catch {
1484
+ return null;
1485
+ }
1486
+ }
1487
+ function buildSealedEnvelopeAad(envelope) {
1488
+ return utf8(stableJson({
1489
+ platcss: 'sealed',
1490
+ version: 1,
1491
+ senderId: envelope.senderId,
1492
+ at: envelope.at,
1493
+ nonce: envelope.nonce,
1494
+ clientEphemeralPublicKeyJwk: envelope.clientEphemeralPublicKeyJwk,
1495
+ }));
1496
+ }
1497
+ function buildBootstrapEncryptionAad(input) {
1498
+ return utf8(stableJson({
1499
+ protocol: 'plat-css-v1',
1500
+ kind: 'announce',
1501
+ senderId: input.senderId,
1502
+ serverName: input.serverName,
1503
+ challengeNonce: input.challengeNonce,
1504
+ bootstrapClientEphemeralPublicKeyJwk: input.clientEphemeralPublicKeyJwk,
1505
+ encryptionPublicKeyJwk: input.serverEncryptionPublicKeyJwk,
1506
+ }));
1507
+ }
1508
+ function serializeSealedEnvelope(envelope) {
1509
+ return JSON.stringify({
1510
+ platcss: envelope.platcss,
1511
+ version: envelope.version,
1512
+ senderId: envelope.senderId,
1513
+ at: envelope.at,
1514
+ nonce: envelope.nonce,
1515
+ clientEphemeralPublicKeyJwk: envelope.clientEphemeralPublicKeyJwk,
1516
+ ciphertext: envelope.ciphertext,
1517
+ });
1518
+ }
1519
+ function resolveAddressAuth(address) {
1520
+ const username = address.params.username;
1521
+ const password = address.params.password;
1522
+ if (!username || !password)
1523
+ return undefined;
1524
+ return { username, password };
1525
+ }
1526
+ function getAuthoritySigningPublicKey(record) {
1527
+ return isClientSideServerSignedAuthorityRecordV2(record)
1528
+ ? record.signingPublicKeyJwk
1529
+ : record.publicKeyJwk;
1530
+ }
1531
+ function normalizeExpectedIdentity(record) {
1532
+ if (isClientSideServerTrustedServerRecordV2(record)) {
1533
+ return {
1534
+ trustedRecord: record,
1535
+ signing: {
1536
+ algorithm: 'ECDSA-P256',
1537
+ publicKeyJwk: record.signingPublicKeyJwk,
1538
+ keyId: record.signingKeyId ?? 'signing-key',
1539
+ fingerprint: record.signingFingerprint,
1540
+ },
1541
+ encryption: {
1542
+ algorithm: 'X25519',
1543
+ publicKeyJwk: record.encryptionPublicKeyJwk,
1544
+ keyId: record.encryptionKeyId ?? 'encryption-key',
1545
+ fingerprint: record.encryptionFingerprint,
1546
+ },
1547
+ };
1548
+ }
1549
+ return {
1550
+ trustedRecord: record,
1551
+ signing: {
1552
+ algorithm: 'ECDSA-P256',
1553
+ publicKeyJwk: record.publicKeyJwk,
1554
+ keyId: record.keyId ?? 'signing-key',
1555
+ fingerprint: record.fingerprint,
1556
+ },
1557
+ };
1558
+ }
1559
+ function debugSecure(event, details) {
1560
+ if (!globalThis.__PLAT_CSS_DEBUG_SECURE)
1561
+ return;
1562
+ console.debug(`[plat-css secure] ${event}`, details ?? {});
1563
+ }
793
1564
  function deferred() {
794
1565
  let resolve;
795
1566
  let reject;