dsh-remote-control-plugin 0.3.5 → 0.4.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/host/fabric.js CHANGED
@@ -3,12 +3,49 @@ import dgram from 'node:dgram'
3
3
  import http from 'node:http'
4
4
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
+ import { sasCode, signPayload, verifyPayload } from './identity.js'
7
+ import { createCipherPair, createBoxCipher, deriveEphemeralKey, deriveSessionKey, ephemeralPublicPem, generateEphemeralKeypair } from './crypto.js'
8
+ import { mintEphemeral, verifyEphemeral } from './handshake.js'
9
+ import { localCandidates } from './nat.js'
10
+ import { createPuncher } from './punch.js'
11
+ import { createP2pNode } from './p2p.js'
12
+ import { selectLane } from './lane.js'
6
13
 
7
14
  const ANNOUNCE_KIND = 'dsh-remote/announce'
8
15
  const ANNOUNCE_INTERVAL_MS = 4000
9
16
  const PEER_TTL_MS = 15000
10
17
  const REQUEST_TTL_MS = 10 * 60 * 1000
11
18
  const CLOCK_SKEW_MS = 5 * 60 * 1000
19
+ // Upload chunks carry up to 1 MiB of base64 (~1.4 MiB of JSON), above the
20
+ // default body cap; the relay's own per-message cap stays at 2 MiB.
21
+ const UPLOAD_BODY_BYTES = 4 * 1024 * 1024
22
+
23
+ /** Capability scopes; an approval without an explicit list keeps all of them. */
24
+ export const DEFAULT_SCOPES = [
25
+ 'sessions:list',
26
+ 'transcript:read',
27
+ 'prompt:send',
28
+ 'stop',
29
+ 'config:read',
30
+ 'config:write',
31
+ 'files:write',
32
+ ]
33
+
34
+ const SCOPE_BY_PATH = [
35
+ [/^\/peer\/sessions$/, () => 'sessions:list'],
36
+ [/^\/peer\/stream$/, () => 'transcript:read'],
37
+ [/^\/peer\/prompt$/, () => 'prompt:send'],
38
+ [/^\/peer\/cancel$/, () => 'stop'],
39
+ [/^\/peer\/session\/config$/, (method) => (method === 'GET' ? 'config:read' : 'config:write')],
40
+ [/^\/peer\/upload\//, () => 'files:write'],
41
+ ]
42
+
43
+ function scopeFor(method, pathname) {
44
+ for (const [pattern, pick] of SCOPE_BY_PATH) {
45
+ if (pattern.test(pathname)) return pick(method)
46
+ }
47
+ return undefined
48
+ }
12
49
 
13
50
  /**
14
51
  * Peer fabric: LAN discovery, one-time approval, and the authenticated
@@ -17,7 +54,23 @@ const CLOCK_SKEW_MS = 5 * 60 * 1000
17
54
  * Everything here is plain Node so it can be exercised without Cordis:
18
55
  * `sessions` is a small facade over the Host's session controller.
19
56
  */
20
- export function createFabric({ log, config, self, sessions, stateFile }) {
57
+ export function createFabric({ log, config, self, sessions, stateFile, store, uploads, identity, limits = {} }) {
58
+ const approvalStore = store ?? createFileStore(stateFile, log)
59
+ const rate = new Map() // deviceId -> { count, resetAt }
60
+ const rateLimit = {
61
+ windowMs: limits.rateWindowMs ?? 10_000,
62
+ max: limits.rateMax ?? 120,
63
+ }
64
+ function allowCall(deviceId) {
65
+ const now = Date.now()
66
+ const entry = rate.get(deviceId)
67
+ if (!entry || entry.resetAt <= now) {
68
+ rate.set(deviceId, { count: 1, resetAt: now + rateLimit.windowMs })
69
+ return true
70
+ }
71
+ entry.count += 1
72
+ return entry.count <= rateLimit.max
73
+ }
21
74
  const peers = new Map() // deviceId -> peer record
22
75
  const requests = new Map() // requestId -> inbound approval request
23
76
  const outgoing = new Map() // deviceId -> { requestId, at }
@@ -27,14 +80,287 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
27
80
  let reaper
28
81
  let loaded = false
29
82
  let relay
83
+ const ciphers = new Map()
84
+ const seenNonces = new Map()
85
+
86
+ /** Reject a nonce already spent by this peer inside the clock window. */
87
+ function acceptNonce(deviceId, nonce) {
88
+ const now = Date.now()
89
+ let bucket = seenNonces.get(deviceId)
90
+ if (!bucket) { bucket = new Map(); seenNonces.set(deviceId, bucket) }
91
+ for (const [key, expiry] of bucket) if (expiry <= now) bucket.delete(key)
92
+ if (bucket.has(nonce)) return false
93
+ bucket.set(nonce, now + CLOCK_SKEW_MS)
94
+ return true
95
+ }
96
+
97
+ /**
98
+ * Per-call authentication: the device's Ed25519 key when the peer's key is
99
+ * pinned, otherwise the legacy shared-secret HMAC so 0.3.x peers keep working.
100
+ */
101
+ function authHeaders(peerDeviceId, method, pathname) {
102
+ const approval = approvals.get(peerDeviceId)
103
+ const me = currentIdentity()
104
+ const timestamp = String(Date.now())
105
+ if (approval?.publicKey && me?.ed25519?.privateKey) {
106
+ const nonce = randomBytes(12).toString('hex')
107
+ const payload = `${self().deviceId}\n${method}\n${pathname}\n${timestamp}\n${nonce}`
108
+ return {
109
+ 'x-dsh-peer-id': self().deviceId,
110
+ 'x-dsh-peer-ts': timestamp,
111
+ 'x-dsh-peer-nonce': nonce,
112
+ 'x-dsh-peer-alg': 'ed25519',
113
+ 'x-dsh-peer-sig': signPayload(me.ed25519.privateKey, payload),
114
+ }
115
+ }
116
+ if (approval?.secret) {
117
+ return {
118
+ 'x-dsh-peer-id': self().deviceId,
119
+ 'x-dsh-peer-ts': timestamp,
120
+ 'x-dsh-peer-sig': sign(approval.secret, self().deviceId, method, pathname, timestamp, ''),
121
+ }
122
+ }
123
+ return undefined
124
+ }
125
+
126
+ const currentIdentity = () => (typeof identity === 'function' ? identity() : identity)
127
+
128
+ const ephemerals = new Map() // peerDeviceId -> local X25519 private key
129
+ const rotating = new Set()
130
+
131
+ /** Mint this side's ephemeral message for one pairing attempt. */
132
+ function mintFor(peerDeviceId, pairCode) {
133
+ const me = currentIdentity()
134
+ if (!me?.ed25519?.privateKey) return undefined
135
+ const keypair = generateEphemeralKeypair()
136
+ ephemerals.set(peerDeviceId, keypair.privateKey)
137
+ const message = mintEphemeral(me, self().deviceId, pairCode, keypair)
138
+ return message
139
+ }
140
+
141
+ /** Session key: ephemeral+static when both sides agreed, else static-static. */
142
+ function sessionKeyFor(peerDeviceId, label) {
143
+ const me = currentIdentity()
144
+ const approval = approvals.get(peerDeviceId)
145
+ if (!me?.x25519?.privateKey || !approval?.agreementKey) return undefined
146
+ const ephemeralPrivate = ephemerals.get(peerDeviceId)
147
+ if (ephemeralPrivate && approval.peerEphemeral) {
148
+ try {
149
+ const key = deriveEphemeralKey({
150
+ ephemeralPrivate,
151
+ peerEphemeralPublicPem: approval.peerEphemeral,
152
+ staticPrivate: me.x25519.privateKey,
153
+ peerStaticPublicPem: approval.agreementKey,
154
+ myFingerprint: me.fingerprint,
155
+ peerFingerprint: approval.fingerprint,
156
+ label,
157
+ })
158
+ return key
159
+ } catch { /* fall back to static-static */ }
160
+ }
161
+ const fallback = deriveSessionKey({
162
+ privateKey: me.x25519.privateKey,
163
+ peerPublicKeyPem: approval.agreementKey,
164
+ myFingerprint: me.fingerprint,
165
+ peerFingerprint: approval.fingerprint,
166
+ label,
167
+ })
168
+ return fallback
169
+ }
170
+
171
+ function cipherFor(peerDeviceId) {
172
+ const me = currentIdentity()
173
+ if (!me?.x25519?.privateKey) return undefined
174
+ const approval = approvals.get(peerDeviceId)
175
+ if (!approval?.agreementKey || !approval?.fingerprint) return undefined
176
+ const cached = ciphers.get(peerDeviceId)
177
+ if (cached) return cached
178
+ try {
179
+ const key = sessionKeyFor(peerDeviceId, 'channel')
180
+ if (!key) return undefined
181
+ const side = String(self().deviceId) < String(peerDeviceId) ? 'a' : 'b'
182
+ const pair = createCipherPair(key, side)
183
+ ciphers.set(peerDeviceId, pair)
184
+ return pair
185
+ } catch (error) {
186
+ log.warn(`[remote-control] channel setup failed for ${peerDeviceId}: ${error?.message || error}`)
187
+ return undefined
188
+ }
189
+ }
190
+
191
+ const lanCiphers = new Map()
192
+ const p2pBoxes = new Map()
193
+ let p2pNode
194
+ let publicEndpoint
195
+
196
+ /** Sealed box for the p2p datagram lane, keyed per peer. */
197
+ function p2pBoxFor(deviceId) {
198
+ const key = sessionKeyFor(deviceId, 'p2p')
199
+ if (!key) return undefined
200
+ let box = p2pBoxes.get(deviceId)
201
+ if (!box) {
202
+ box = createBoxCipher(key)
203
+ p2pBoxes.set(deviceId, box)
204
+ }
205
+ return box
206
+ }
207
+
208
+ /** Per-peer AEAD pair for one direct transport lane (separate nonce space). */
209
+ function lanCipherFor(peerDeviceId, label = 'lan') {
210
+ const me = currentIdentity()
211
+ if (!me?.x25519?.privateKey) return undefined
212
+ const approval = approvals.get(peerDeviceId)
213
+ if (!approval?.agreementKey || !approval?.fingerprint) return undefined
214
+ const cacheKey = `${label}:${peerDeviceId}`
215
+ const cached = lanCiphers.get(cacheKey)
216
+ if (cached) return cached
217
+ try {
218
+ const key = sessionKeyFor(peerDeviceId, label)
219
+ if (!key) return undefined
220
+ const side = String(self().deviceId) < String(peerDeviceId) ? 'a' : 'b'
221
+ const pair = createCipherPair(key, side)
222
+ lanCiphers.set(cacheKey, pair)
223
+ return pair
224
+ } catch {
225
+ return undefined
226
+ }
227
+ }
228
+
229
+ /** Mint an ephemeral message without installing it as the live key yet. */
230
+ function mintDetached(pairCode) {
231
+ const me = currentIdentity()
232
+ if (!me?.ed25519?.privateKey) return undefined
233
+ const keypair = generateEphemeralKeypair()
234
+ return { privateKey: keypair.privateKey, message: mintEphemeral(me, self().deviceId, pairCode, keypair) }
235
+ }
236
+
237
+ /** Try to open a direct UDP path to one peer (both sides must call this). */
238
+ async function punchPeer(peerDeviceId) {
239
+ const approval = approvals.get(peerDeviceId)
240
+ if (!approval) return { ok: false, error: 'unknown-peer' }
241
+ const candidates = approval.candidates ?? []
242
+ const punchPort = approval.punchPort ?? 0
243
+ if (candidates.length === 0 || !punchPort) return { ok: false, error: 'no-candidates' }
244
+ if (!p2pNode) return { ok: false, error: 'p2p-unavailable' }
245
+ // The public mapping (STUN) is the only endpoint a NATed peer can answer on;
246
+ // LAN candidates are tried with the local punch port as a same-network path.
247
+ const targets = []
248
+ if (approval.publicEndpoint?.address) {
249
+ targets.push({ address: approval.publicEndpoint.address, port: approval.publicEndpoint.port })
250
+ }
251
+ for (const address of candidates) targets.push({ address, port: punchPort })
252
+ for (const target of targets) {
253
+ const result = await p2pNode.punch(target, peerDeviceId)
254
+ if (!result.ok) continue
255
+ approvals.set(peerDeviceId, { ...approval, punch: target })
256
+ await saveApprovals()
257
+ return { ok: true, address: target.address, port: target.port }
258
+ }
259
+ return { ok: false, error: 'no-pong' }
260
+ }
261
+
262
+ /** Rotate the ephemeral channel with one approved peer. */
263
+ async function rehandshakePeer(peerDeviceId) {
264
+ const peer = peers.get(peerDeviceId)
265
+ if (!peer) return { ok: false, error: 'unknown-peer' }
266
+ // Two simultaneous initiators would each install a different ephemeral and
267
+ // desynchronize the channel, so only one rotation may be in flight.
268
+ if (rotating.has(peerDeviceId)) return { ok: false, error: 'in-progress' }
269
+ rotating.add(peerDeviceId)
270
+ try {
271
+ const pairCode = self().pairCode ?? ''
272
+ const minted = mintDetached(pairCode)
273
+ if (!minted) return { ok: false, error: 'no-identity' }
274
+ // The request still travels over the current channel; the new key is
275
+ // installed only once the peer's signed reply verifies.
276
+ const result = await peerCall(peer, 'POST', '/peer/rehandshake', { ephemeral: minted.message })
277
+ if (!result.ok) return { ok: false, error: result.json?.error || result.error || 'rehandshake-failed' }
278
+ const theirs = result.json?.ephemeral
279
+ const approval = approvals.get(peerDeviceId)
280
+ if (!verifyEphemeral(approval?.publicKey ?? '', peerDeviceId, pairCode, theirs)) {
281
+ return { ok: false, error: 'bad-handshake' }
282
+ }
283
+ ephemerals.set(peerDeviceId, minted.privateKey)
284
+ approvals.set(peerDeviceId, { ...approval, peerEphemeral: theirs.publicPem, rotatedAt: Date.now() })
285
+ ciphers.delete(peerDeviceId)
286
+ for (const key of [...lanCiphers.keys()]) {
287
+ if (key.endsWith(`:${peerDeviceId}`)) lanCiphers.delete(key)
288
+ }
289
+ await saveApprovals()
290
+ return { ok: true }
291
+ } finally {
292
+ rotating.delete(peerDeviceId)
293
+ }
294
+ }
295
+
296
+ const security = {
297
+ seal(to, text) {
298
+ const pair = cipherFor(to)
299
+ if (!pair) return undefined
300
+ try { return pair.seal(text) } catch { return undefined }
301
+ },
302
+ open(from, packet) {
303
+ const pair = cipherFor(from)
304
+ if (!pair) return undefined
305
+ try { return pair.open(packet).toString('utf8') } catch (error) {
306
+ log.warn(`[remote-control] dropped an unauthenticated relay message from ${from}: ${error?.message || error}`)
307
+ return undefined
308
+ }
309
+ },
310
+ }
30
311
 
31
312
  const discoveryPort = config.discoveryPort
32
313
  const enabled = config.discovery !== false
33
314
 
34
315
  async function start() {
35
316
  await loadApprovals()
317
+
318
+ // The p2p lane must exist even when LAN discovery is disabled.
319
+ if (config.punchPort) {
320
+ try {
321
+ p2pNode = await createP2pNode({
322
+ localPort: config.port,
323
+ bindPort: config.punchPort,
324
+ selfDeviceId: self().deviceId,
325
+ log,
326
+ keyFor: (deviceId) => p2pBoxFor(deviceId),
327
+ })
328
+ } catch (error) {
329
+ log.warn(`[remote-control] could not open the p2p lane on ${config.punchPort}: ${error?.message || error}`)
330
+ }
331
+ // Learn this socket's public mapping so a NATed peer advertises something
332
+ // reachable. Without this only LAN addresses would be offered.
333
+ if (p2pNode && config.stunHost !== false) {
334
+ try {
335
+ const mapped = await p2pNode.mapped({
336
+ host: config.stunHost || 'stun.l.google.com',
337
+ port: config.stunPort ?? 19302,
338
+ timeoutMs: config.stunTimeoutMs ?? 1500,
339
+ })
340
+ if (mapped?.address) publicEndpoint = { address: mapped.address, port: mapped.port }
341
+ } catch { /* no STUN reachable; LAN candidates still work */ }
342
+ }
343
+ }
36
344
  if (!enabled) return
37
345
 
346
+ // Ephemeral keys live only in memory, so after a restart each approved peer
347
+ // is re-handshaked once reachable; until then the channel uses static-static.
348
+ const refreshMs = Number.isInteger(config.channelRefreshMs) ? config.channelRefreshMs : 2500
349
+ const refreshTimer = refreshMs > 0 ? setTimeout(() => {
350
+ for (const deviceId of approvals.keys()) {
351
+ // Only peers without an in-memory ephemeral need a rotation; this is
352
+ // the restart case, not a periodic re-key.
353
+ if (ephemerals.has(deviceId)) continue
354
+ // Deterministic initiator: only the lower device id starts an automatic
355
+ // rotation, so the two sides never rotate over each other.
356
+ if (String(self().deviceId) >= String(deviceId)) continue
357
+ const rotatedAt = approvals.get(deviceId)?.rotatedAt
358
+ if (rotatedAt && Date.now() - rotatedAt < 60_000) continue
359
+ rehandshakePeer(deviceId).catch(() => { /* peer offline */ })
360
+ }
361
+ }, refreshMs) : undefined
362
+ refreshTimer?.unref?.()
363
+
38
364
  const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
39
365
  sockets.push(socket)
40
366
  socket.on('error', (error) => log.warn(`[remote-control] discovery socket: ${error?.message || error}`))
@@ -60,6 +386,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
60
386
  function stop() {
61
387
  clearInterval(announceTimer)
62
388
  clearInterval(reaper)
389
+ try { p2pNode?.close() } catch { /* already closed */ }
390
+ p2pNode = undefined
391
+ p2pBoxes.clear()
63
392
  for (const socket of sockets) {
64
393
  try { socket.close() } catch { /* ignore */ }
65
394
  }
@@ -150,13 +479,20 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
150
479
  if (loaded) return
151
480
  loaded = true
152
481
  try {
153
- const raw = JSON.parse(await readFile(stateFile, 'utf8'))
482
+ const raw = await approvalStore.load()
154
483
  for (const [deviceId, entry] of Object.entries(raw?.peers ?? {})) {
155
484
  if (entry && typeof entry.secret === 'string') {
156
485
  approvals.set(deviceId, {
157
486
  deviceName: entry.deviceName ?? 'DSH device',
158
487
  secret: entry.secret,
159
488
  approvedAt: entry.approvedAt ?? Date.now(),
489
+ relay: entry.relay ?? '',
490
+ publicKey: entry.publicKey ?? '',
491
+ fingerprint: entry.fingerprint ?? '',
492
+ agreementKey: entry.agreementKey ?? '',
493
+ peerEphemeral: entry.peerEphemeral ?? '',
494
+ sas: entry.sas ?? '',
495
+ scopes: Array.isArray(entry.scopes) ? entry.scopes : [...DEFAULT_SCOPES],
160
496
  })
161
497
  }
162
498
  }
@@ -169,10 +505,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
169
505
  const peersObject = {}
170
506
  for (const [deviceId, entry] of approvals) peersObject[deviceId] = entry
171
507
  try {
172
- await mkdir(path.dirname(stateFile), { recursive: true })
173
- await writeFile(stateFile, JSON.stringify({ version: 1, peers: peersObject }, null, 2), { mode: 0o600 })
508
+ await approvalStore.save({ version: 1, peers: peersObject })
174
509
  } catch (error) {
175
- log.warn(`[remote-control] could not persist ${stateFile}: ${error?.message || error}`)
510
+ log.warn(`[remote-control] could not persist approvals: ${error?.message || error}`)
176
511
  }
177
512
  }
178
513
 
@@ -184,11 +519,38 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
184
519
  if (!deviceId) return json(res, 400, { error: 'missing-device' })
185
520
  if (requests.size >= 20) return json(res, 429, { error: 'too-many-requests' })
186
521
  const requestId = randomBytes(16).toString('hex')
522
+ const claimedRelay = typeof body?.relay === 'string' ? body.relay : ''
523
+ const claimedPairCode = typeof body?.pairCode === 'string' ? body.pairCode : ''
524
+ const claimedFingerprint = typeof body?.fingerprint === 'string' ? body.fingerprint : ''
525
+ const claimedKey = typeof body?.publicKey === 'string' ? body.publicKey : ''
526
+ const known = approvals.get(deviceId)
527
+ const who = self()
528
+ const claimedEphemeral = body?.ephemeral
529
+ const ephemeralOk = verifyEphemeral(claimedKey, deviceId, claimedPairCode, claimedEphemeral)
530
+ const replyEphemeral = mintFor(deviceId, claimedPairCode)
187
531
  requests.set(requestId, {
188
532
  requestId,
189
533
  deviceId,
190
534
  deviceName: typeof body.deviceName === 'string' ? body.deviceName : 'DSH device',
191
535
  userId: typeof body.userId === 'string' ? body.userId : null,
536
+ relay: claimedRelay,
537
+ pairCode: claimedPairCode,
538
+ publicKey: claimedKey,
539
+ fingerprint: claimedFingerprint,
540
+ peerEphemeral: ephemeralOk ? claimedEphemeral.publicPem : '',
541
+ candidates: Array.isArray(body?.candidates) ? body.candidates.filter((entry) => typeof entry === 'string').slice(0, 16) : [],
542
+ punchPort: Number.isInteger(body?.punchPort) ? body.punchPort : 0,
543
+ publicEndpoint: body?.publicEndpoint && typeof body.publicEndpoint.address === 'string' && Number.isInteger(body.publicEndpoint.port)
544
+ ? { address: body.publicEndpoint.address, port: body.publicEndpoint.port }
545
+ : null,
546
+ replyEphemeral,
547
+ agreementKey: typeof body?.agreementKey === 'string' ? body.agreementKey : '',
548
+ // Both screens show these digits only when both keys and the invite
549
+ // nonce agree; a mismatch is a hard stop, not a warning.
550
+ sas: claimedFingerprint && who.fingerprint
551
+ ? sasCode(who.fingerprint, claimedFingerprint, claimedPairCode)
552
+ : '',
553
+ relayChanged: Boolean(known?.relay && claimedRelay && known.relay !== claimedRelay),
192
554
  address: req.socket?.remoteAddress ?? '',
193
555
  at: Date.now(),
194
556
  status: 'pending',
@@ -201,10 +563,26 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
201
563
  function helloStatus(res, requestId) {
202
564
  const request = requests.get(requestId)
203
565
  if (!request) return json(res, 404, { error: 'unknown-request' })
566
+ const who = self()
204
567
  if (request.status === 'approved') {
205
- return json(res, 200, { status: 'approved', secret: request.secret, deviceName: self().deviceName })
568
+ return json(res, 200, {
569
+ status: 'approved',
570
+ // A peer that pinned a device key never needs the shared secret, and
571
+ // the relay would see this response in plaintext. Only legacy peers
572
+ // (no claimed public key) still receive one.
573
+ ...(request.publicKey ? {} : { secret: request.secret }),
574
+ deviceName: who.deviceName,
575
+ publicKey: who.publicKey ?? '',
576
+ fingerprint: who.fingerprint ?? '',
577
+ agreementKey: who.agreementKey ?? '',
578
+ ephemeral: request.replyEphemeral,
579
+ candidates: localCandidates().map((entry) => entry.address),
580
+ punchPort: config.punchPort ?? 0,
581
+ publicEndpoint: publicEndpoint ?? null,
582
+ sas: request.sas ?? '',
583
+ })
206
584
  }
207
- return json(res, 200, { status: request.status })
585
+ return json(res, 200, { status: request.status, sas: request.sas ?? '' })
208
586
  }
209
587
 
210
588
  async function approveRequest(requestId) {
@@ -213,7 +591,22 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
213
591
  const secret = randomBytes(32).toString('hex')
214
592
  request.status = 'approved'
215
593
  request.secret = secret
216
- approvals.set(request.deviceId, { deviceName: request.deviceName, secret, approvedAt: Date.now() })
594
+ approvals.set(request.deviceId, {
595
+ deviceName: request.deviceName,
596
+ secret,
597
+ approvedAt: Date.now(),
598
+ relay: request.relay ?? '',
599
+ publicKey: request.publicKey ?? '',
600
+ fingerprint: request.fingerprint ?? '',
601
+ agreementKey: request.agreementKey ?? '',
602
+ peerEphemeral: request.peerEphemeral ?? '',
603
+ candidates: Array.isArray(request.candidates) ? request.candidates : [],
604
+ punchPort: Number.isInteger(request.punchPort) ? request.punchPort : 0,
605
+ publicEndpoint: request.publicEndpoint ?? null,
606
+ secret: request.publicKey ? '' : request.secret,
607
+ sas: request.sas ?? '',
608
+ scopes: [...DEFAULT_SCOPES],
609
+ })
217
610
  await saveApprovals()
218
611
  const peer = peers.get(request.deviceId)
219
612
  if (peer) { peer.approved = true; peer.secret = secret }
@@ -241,6 +634,15 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
241
634
  deviceId: who.deviceId,
242
635
  deviceName: who.deviceName,
243
636
  userId: who.userId,
637
+ relay: who.relayUrl ?? '',
638
+ pairCode: who.pairCode ?? '',
639
+ publicKey: who.publicKey ?? '',
640
+ fingerprint: who.fingerprint ?? '',
641
+ agreementKey: who.agreementKey ?? '',
642
+ ephemeral: mintFor(peer.deviceId, who.pairCode ?? ''),
643
+ candidates: localCandidates().map((entry) => entry.address),
644
+ punchPort: config.punchPort ?? 0,
645
+ publicEndpoint: publicEndpoint ?? null,
244
646
  })
245
647
  if (!reply.ok || typeof reply.json?.requestId !== 'string') {
246
648
  return { ok: false, error: reply.json?.error || reply.error || 'hello-failed' }
@@ -258,11 +660,29 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
258
660
  if (!outgoing.has(peer.deviceId)) return
259
661
  const reply = await peerCall(peer, 'GET', `/peer/hello/${requestId}`)
260
662
  const status = reply.json?.status
261
- if (status === 'approved' && typeof reply.json.secret === 'string') {
262
- approvals.set(peer.deviceId, { deviceName: peer.deviceName, secret: reply.json.secret, approvedAt: Date.now() })
663
+ if (status === 'approved') {
664
+ const replyEphemeral = reply.json.ephemeral
665
+ const ephemeralOk = verifyEphemeral(reply.json.publicKey ?? '', peer.deviceId, self().pairCode ?? '', replyEphemeral)
666
+ approvals.set(peer.deviceId, {
667
+ deviceName: peer.deviceName,
668
+ secret: reply.json.secret ?? '',
669
+ approvedAt: Date.now(),
670
+ relay: relay?.url() ?? '',
671
+ publicKey: reply.json.publicKey ?? '',
672
+ fingerprint: reply.json.fingerprint ?? '',
673
+ agreementKey: reply.json.agreementKey ?? '',
674
+ peerEphemeral: ephemeralOk ? replyEphemeral.publicPem : '',
675
+ candidates: Array.isArray(reply.json.candidates) ? reply.json.candidates : [],
676
+ punchPort: Number.isInteger(reply.json.punchPort) ? reply.json.punchPort : 0,
677
+ publicEndpoint: reply.json.publicEndpoint ?? null,
678
+ sas: reply.json.sas ?? '',
679
+ scopes: [...DEFAULT_SCOPES],
680
+ })
681
+ peer.publicKey = reply.json.publicKey ?? ''
682
+ peer.sas = reply.json.sas ?? ''
263
683
  await saveApprovals()
264
684
  peer.approved = true
265
- peer.secret = reply.json.secret
685
+ peer.secret = reply.json.secret ?? ''
266
686
  outgoing.delete(peer.deviceId)
267
687
  log.info(`[remote-control] ${peer.deviceName} approved this device`)
268
688
  return
@@ -285,42 +705,105 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
285
705
 
286
706
  const verdict = verifyPeer(req, url)
287
707
  if (!verdict.ok) return json(res, 403, { error: verdict.error })
708
+ if (!allowCall(verdict.deviceId)) return json(res, 429, { error: 'rate-limited' })
709
+ const requiredScope = scopeFor(req.method || 'GET', pathname)
710
+ if (requiredScope && Array.isArray(verdict.approval?.scopes) && !verdict.approval.scopes.includes(requiredScope)) {
711
+ return json(res, 403, { error: 'scope-denied', scope: requiredScope })
712
+ }
713
+
714
+ const viaHeader = String(req.headers['x-dsh-via'] || '')
715
+ const fromRelay = viaHeader === 'relay' || viaHeader === 'p2p'
716
+ // A pinned peer on the direct transport must use the sealed lane; relay
717
+ // traffic is already inside the relay AEAD envelope.
718
+ let lanePair
719
+ if (!fromRelay && verdict.approval?.publicKey && pathname !== '/peer/stream') {
720
+ if (String(req.headers['x-dsh-lane'] || '') !== 'sealed') {
721
+ return json(res, 403, { error: 'plaintext-not-allowed' })
722
+ }
723
+ lanePair = lanCipherFor(verdict.deviceId)
724
+ if (!lanePair) return json(res, 403, { error: 'no-channel' })
725
+ }
726
+ // One body read for every authenticated POST: the peer may have sealed it
727
+ // with the LAN channel, and every route needs the same decoded value.
728
+ let body = {}
729
+ if (req.method === 'POST') {
730
+ let incoming
731
+ try {
732
+ incoming = await readJson(req, UPLOAD_BODY_BYTES)
733
+ } catch {
734
+ return json(res, 400, { error: 'bad-body' })
735
+ }
736
+ if (incoming && typeof incoming === 'object' && typeof incoming.enc === 'string') {
737
+ if (!lanePair) return json(res, 403, { error: 'no-channel' })
738
+ try {
739
+ body = JSON.parse(lanePair.open(incoming.enc).toString('utf8'))
740
+ } catch (error) {
741
+ return json(res, 403, { error: 'bad-envelope' })
742
+ }
743
+ } else {
744
+ body = incoming ?? {}
745
+ }
746
+ }
747
+ const reply = lanePair
748
+ ? (status, value) => json(res, status, { enc: lanePair.seal(JSON.stringify(value)) })
749
+ : (status, value) => json(res, status, value)
288
750
 
751
+ if (pathname.startsWith('/peer/upload/') && uploads) {
752
+ const action = pathname.slice('/peer/upload/'.length)
753
+ const handler = uploads[action]
754
+ if (typeof handler !== 'function') return reply(404, { error: 'not-found' })
755
+ const payload = req.method === 'GET' ? Object.fromEntries(url.searchParams.entries()) : body
756
+ const result = await handler(payload)
757
+ return reply(result.status ?? 200, result)
758
+ }
759
+ if (pathname === '/peer/rehandshake' && req.method === 'POST') {
760
+ const peerMessage = body?.ephemeral
761
+ const mine = approvals.get(verdict.deviceId)
762
+ if (!peerMessage || !verifyEphemeral(mine?.publicKey ?? '', verdict.deviceId, self().pairCode ?? '', peerMessage)) {
763
+ return reply(400, { error: 'bad-handshake' })
764
+ }
765
+ const replyMessage = mintFor(verdict.deviceId, self().pairCode ?? '')
766
+ approvals.set(verdict.deviceId, { ...mine, peerEphemeral: peerMessage.publicPem })
767
+ ciphers.delete(verdict.deviceId)
768
+ for (const key of [...lanCiphers.keys()]) {
769
+ if (key.endsWith(`:${verdict.deviceId}`)) lanCiphers.delete(key)
770
+ }
771
+ await saveApprovals()
772
+ return reply(200, { ok: true, ephemeral: replyMessage })
773
+ }
289
774
  if (pathname === '/peer/session/config' && req.method === 'GET') {
290
775
  const sessionId = String(url.searchParams.get('sessionId') || '')
291
- if (!sessionId) return json(res, 400, { error: 'missing-session' })
292
- if (typeof sessions.config !== 'function') return json(res, 503, { error: 'config-unavailable' })
293
- return json(res, 200, await sessions.config(sessionId))
776
+ if (!sessionId) return reply(400, { error: 'missing-session' })
777
+ if (typeof sessions.config !== 'function') return reply(503, { error: 'config-unavailable' })
778
+ return reply(200, await sessions.config(sessionId))
294
779
  }
295
780
  if (pathname === '/peer/session/config' && req.method === 'POST') {
296
- const body = await readJson(req)
297
781
  const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
298
- if (!sessionId) return json(res, 400, { error: 'missing-session' })
299
- if (typeof sessions.applyConfig !== 'function') return json(res, 503, { error: 'config-unavailable' })
300
- return json(res, 200, await sessions.applyConfig(sessionId, body))
782
+ if (!sessionId) return reply(400, { error: 'missing-session' })
783
+ if (typeof sessions.applyConfig !== 'function') return reply(503, { error: 'config-unavailable' })
784
+ return reply(200, await sessions.applyConfig(sessionId, body))
301
785
  }
302
786
  if (pathname === '/peer/sessions' && req.method === 'GET') {
303
787
  const value = await sessions.list()
304
- return json(res, 200, { items: value.map((item) => ({ ...item, title: item.title })) })
788
+ return reply(200, { items: value.map((item) => ({ ...item, title: item.title })) })
305
789
  }
306
790
  if (pathname === '/peer/stream' && req.method === 'GET') {
307
791
  return streamSession(req, res, url)
308
792
  }
309
793
  if (pathname === '/peer/prompt' && req.method === 'POST') {
310
- const body = await readJson(req)
311
794
  const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
312
795
  const text = typeof body?.text === 'string' ? body.text : ''
313
- if (!sessionId || !text.trim()) return json(res, 400, { error: 'bad-request' })
314
- const value = await sessions.prompt(sessionId, text)
315
- return json(res, 200, value)
796
+ const attachments = Array.isArray(body?.attachments) ? body.attachments : []
797
+ if (!sessionId || (!text.trim() && attachments.length === 0)) return reply(400, { error: 'bad-request' })
798
+ const value = await sessions.prompt(sessionId, { text, attachments })
799
+ return reply(200, value)
316
800
  }
317
801
  if (pathname === '/peer/cancel' && req.method === 'POST') {
318
- const body = await readJson(req)
319
802
  const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
320
- if (!sessionId) return json(res, 400, { error: 'bad-request' })
321
- return json(res, 200, sessions.cancel(sessionId))
803
+ if (!sessionId) return reply(400, { error: 'bad-request' })
804
+ return reply(200, sessions.cancel(sessionId))
322
805
  }
323
- return json(res, 404, { error: 'not-found' })
806
+ return reply(404, { error: 'not-found' })
324
807
  }
325
808
 
326
809
  function verifyPeer(req, url) {
@@ -333,16 +816,37 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
333
816
  if (!/^\d{1,16}$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > CLOCK_SKEW_MS) {
334
817
  return { ok: false, error: 'stale-signature' }
335
818
  }
819
+ // A pinned device key retires the shared secret: the approval response that
820
+ // carried that secret crossed the relay in plaintext, so accepting HMAC
821
+ // afterwards would let anyone who saw it forge calls.
822
+ if (approval.publicKey && String(req.headers['x-dsh-peer-alg'] || '') !== 'ed25519') {
823
+ return { ok: false, error: 'legacy-auth-disabled' }
824
+ }
825
+ if (String(req.headers['x-dsh-peer-alg'] || '') === 'ed25519') {
826
+ if (!approval.publicKey) return { ok: false, error: 'no-device-key' }
827
+ const nonce = String(req.headers['x-dsh-peer-nonce'] || '')
828
+ if (!nonce) return { ok: false, error: 'missing-nonce' }
829
+ const payload = `${deviceId}\n${req.method || 'GET'}\n${url.pathname + url.search}\n${timestamp}\n${nonce}`
830
+ if (!verifyPayload(approval.publicKey, payload, signature)) return { ok: false, error: 'bad-signature' }
831
+ if (!acceptNonce(deviceId, nonce)) return { ok: false, error: 'replayed' }
832
+ return { ok: true, deviceId, approval }
833
+ }
336
834
  const expected = sign(approval.secret, deviceId, req.method || 'GET', url.pathname + url.search, timestamp, '')
337
835
  const actual = Buffer.from(signature, 'hex')
338
836
  const wanted = Buffer.from(expected, 'hex')
339
837
  if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) return { ok: false, error: 'bad-signature' }
340
- return { ok: true }
838
+ return { ok: true, deviceId, approval }
341
839
  }
342
840
 
343
841
  async function streamSession(req, res, url) {
344
842
  const sessionId = String(url.searchParams.get('sessionId') || '')
345
843
  if (!sessionId) return json(res, 400, { error: 'missing-session' })
844
+ // Frames use their own key space, so a stream can never desync the unary
845
+ // body counters.
846
+ const peerDeviceId = String(req.headers['x-dsh-peer-id'] || '')
847
+ const streamPair = String(req.headers['x-dsh-lane'] || '') === 'sealed' && peerDeviceId
848
+ ? lanCipherFor(peerDeviceId, 'lan-stream')
849
+ : undefined
346
850
  const controller = new AbortController()
347
851
  res.writeHead(200, {
348
852
  'content-type': 'text/event-stream; charset=utf-8',
@@ -366,7 +870,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
366
870
  try {
367
871
  for await (const frame of sessions.follow(sessionId, controller.signal)) {
368
872
  if (res.writableEnded || res.destroyed) break
369
- res.write(`data: ${JSON.stringify(frame)}\n\n`)
873
+ const payload = JSON.stringify(frame)
874
+ const wire = streamPair ? JSON.stringify({ enc: streamPair.seal(payload) }) : payload
875
+ res.write(`data: ${wire}\n\n`)
370
876
  }
371
877
  } catch (error) {
372
878
  if (!controller.signal.aborted) {
@@ -386,11 +892,40 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
386
892
  * when the peer was discovered there, direct HTTP for a LAN peer.
387
893
  */
388
894
  async function peerCall(peer, method, pathname, body, secret) {
895
+ const approval = approvals.get(peer.deviceId)
896
+ const datagramLimit = 64 * 1024
897
+ const bodySize = body === undefined ? 0 : JSON.stringify(body).length
898
+ if (p2pNode && approval?.punch && bodySize <= datagramLimit) {
899
+ const auth = authHeaders(peer.deviceId, method, pathname) ?? {}
900
+ const result = await p2pNode.request(
901
+ { address: approval.punch.address, port: approval.punch.port },
902
+ peer.deviceId,
903
+ {
904
+ method,
905
+ path: pathname,
906
+ headers: { ...auth, accept: 'application/json', 'content-type': 'application/json' },
907
+ bodyText: body === undefined ? '' : JSON.stringify(body),
908
+ },
909
+ )
910
+ if (result.ok) {
911
+ peer.lastTransport = 'p2p'
912
+ let json = {}
913
+ try { json = result.message.bodyText ? JSON.parse(result.message.bodyText) : {} } catch { json = {} }
914
+ return { ok: result.message.status >= 200 && result.message.status < 300, status: result.message.status, json }
915
+ }
916
+ // A failed datagram path falls through to LAN and then the relay.
917
+ }
389
918
  if (peer.proto !== 'relay') {
390
- const direct = await callRaw(peer, method, pathname, body, secret, self().deviceId)
391
- // A direct LAN route can be silently blocked (firewall profile, VPN
392
- // change). The relay is outbound-only, so fall back instead of failing.
393
- if (!direct.error || !relay) return direct
919
+ const auth = authHeaders(peer.deviceId, method, pathname)
920
+ const pair = lanCipherFor(peer.deviceId)
921
+ const lane = pair
922
+ ? { seal: (value) => pair.seal(value), open: (packet) => pair.open(packet).toString('utf8') }
923
+ : undefined
924
+ const direct = await callRaw(peer, method, pathname, body, secret, self().deviceId, auth, lane)
925
+ if (!direct.error) {
926
+ peer.lastTransport = 'lan'
927
+ return direct
928
+ }
394
929
  const viaRelay = await relayCall(peer, method, pathname, body, secret)
395
930
  return viaRelay.ok || viaRelay.status !== 503 ? viaRelay : direct
396
931
  }
@@ -399,10 +934,14 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
399
934
 
400
935
  async function relayCall(peer, method, pathname, body, secret) {
401
936
  if (!relay) return { ok: false, status: 503, json: { error: 'relay-offline' } }
937
+ peer.lastTransport = 'relay'
402
938
  const callerId = self().deviceId
403
939
  const timestamp = String(Date.now())
404
940
  const headers = { accept: 'application/json' }
405
- if (secret) {
941
+ const relayAuth = authHeaders(peer.deviceId, method, pathname)
942
+ if (relayAuth) {
943
+ Object.assign(headers, relayAuth)
944
+ } else if (secret) {
406
945
  headers['x-dsh-peer-id'] = callerId
407
946
  headers['x-dsh-peer-ts'] = timestamp
408
947
  headers['x-dsh-peer-sig'] = sign(secret, callerId, method, pathname, timestamp, '')
@@ -445,6 +984,30 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
445
984
  const target = `/peer/stream?sessionId=${encodeURIComponent(sessionId)}`
446
985
  const timestamp = String(Date.now())
447
986
  const callerId = self().deviceId
987
+ if (config.p2pStreams !== false && p2pNode && approval.punch) {
988
+ const auth = authHeaders(deviceId, 'GET', target) ?? {}
989
+ res.writeHead(200, {
990
+ 'content-type': 'text/event-stream; charset=utf-8',
991
+ 'cache-control': 'no-store, no-transform',
992
+ connection: 'keep-alive',
993
+ 'x-accel-buffering': 'no',
994
+ })
995
+ const handle = p2pNode.openStream(
996
+ { address: approval.punch.address, port: approval.punch.port },
997
+ deviceId,
998
+ { path: target, headers: { ...auth, accept: 'text/event-stream' } },
999
+ {
1000
+ onChunk: (chunk) => { try { res.write(chunk) } catch { /* client gone */ } },
1001
+ onEnd: () => { try { res.end() } catch { /* ignore */ } },
1002
+ },
1003
+ )
1004
+ if (!handle.ok) {
1005
+ try { res.destroy() } catch { /* ignore */ }
1006
+ return
1007
+ }
1008
+ peer.lastTransport = 'p2p'
1009
+ return
1010
+ }
448
1011
  if (peer.proto === 'relay') {
449
1012
  if (!relay) {
450
1013
  json(res, 503, { error: 'relay-offline' })
@@ -457,16 +1020,21 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
457
1020
  'x-dsh-peer-id': callerId,
458
1021
  'x-dsh-peer-ts': timestamp,
459
1022
  'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
1023
+ ...(authHeaders(peer.deviceId, 'GET', target) ?? {}),
460
1024
  },
461
1025
  }, res)
462
1026
  return
463
1027
  }
1028
+ const streamPair = lanCipherFor(peer.deviceId, 'lan-stream')
1029
+ const streamAuth = authHeaders(peer.deviceId, 'GET', target)
464
1030
  const streamHeaders = {
465
1031
  accept: 'text/event-stream',
466
1032
  'x-dsh-peer-id': callerId,
467
1033
  'x-dsh-peer-ts': timestamp,
468
1034
  'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
1035
+ ...(streamAuth ?? {}),
469
1036
  }
1037
+ if (streamPair) streamHeaders['x-dsh-lane'] = 'sealed'
470
1038
  const upstream = http.request({
471
1039
  host: peer.address,
472
1040
  port: peer.port,
@@ -480,7 +1048,35 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
480
1048
  connection: 'keep-alive',
481
1049
  'x-accel-buffering': 'no',
482
1050
  })
483
- response.pipe(res)
1051
+ if (!streamPair) {
1052
+ response.pipe(res)
1053
+ response.on('end', () => { try { res.end() } catch { /* ignore */ } })
1054
+ return
1055
+ }
1056
+ // Decrypt each sealed frame before handing it to the local client.
1057
+ response.setEncoding('utf8')
1058
+ let buffer = ''
1059
+ response.on('data', (chunk) => {
1060
+ buffer += chunk
1061
+ let index
1062
+ while ((index = buffer.indexOf('\n\n')) >= 0) {
1063
+ const event = buffer.slice(0, index)
1064
+ buffer = buffer.slice(index + 2)
1065
+ const line = event.split('\n').find((entry) => entry.startsWith('data: '))
1066
+ if (line) {
1067
+ try {
1068
+ const parsed = JSON.parse(line.slice(6))
1069
+ if (parsed && typeof parsed.enc === 'string') {
1070
+ res.write(`data: ${streamPair.open(parsed.enc).toString('utf8')}\n\n`)
1071
+ continue
1072
+ }
1073
+ } catch {
1074
+ continue
1075
+ }
1076
+ }
1077
+ res.write(`${event}\n\n`)
1078
+ }
1079
+ })
484
1080
  response.on('end', () => { try { res.end() } catch { /* ignore */ } })
485
1081
  })
486
1082
  upstream.on('error', () => {
@@ -505,8 +1101,20 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
505
1101
  callPeer,
506
1102
  proxyStream,
507
1103
  handlePeer,
1104
+ rehandshake: rehandshakePeer,
1105
+ attemptPunch: punchPeer,
1106
+ async clearPunch(peerDeviceId) {
1107
+ const approval = approvals.get(peerDeviceId)
1108
+ if (!approval) return { ok: false, error: 'unknown-peer' }
1109
+ const next = { ...approval }
1110
+ delete next.punch
1111
+ approvals.set(peerDeviceId, next)
1112
+ await saveApprovals()
1113
+ return { ok: true }
1114
+ },
508
1115
  setRelay(link) {
509
1116
  relay = link
1117
+ if (link && typeof link.setSecurity === 'function') link.setSecurity(security)
510
1118
  if (!link) {
511
1119
  // Without a relay every relay-only peer is unreachable; keeping it
512
1120
  // would make each call fail with a misleading 503.
@@ -515,6 +1123,20 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
515
1123
  }
516
1124
  }
517
1125
  },
1126
+ setScopes(deviceId, scopes) {
1127
+ const approval = approvals.get(deviceId)
1128
+ if (!approval) return { ok: false, error: 'unknown-device' }
1129
+ const next = Array.isArray(scopes) ? scopes.filter((scope) => DEFAULT_SCOPES.includes(scope)) : []
1130
+ approvals.set(deviceId, { ...approval, scopes: next })
1131
+ saveApprovals().catch(() => {})
1132
+ return { ok: true, scopes: next }
1133
+ },
1134
+ revoke(deviceId) {
1135
+ if (!approvals.delete(deviceId)) return { ok: false, error: 'unknown-device' }
1136
+ ciphers.delete(deviceId)
1137
+ saveApprovals().catch(() => {})
1138
+ return { ok: true }
1139
+ },
518
1140
 
519
1141
  snapshot() {
520
1142
  syncRelayPeers()
@@ -531,6 +1153,16 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
531
1153
  approved: approvals.has(peer.deviceId),
532
1154
  connecting: outgoing.has(peer.deviceId),
533
1155
  transport: peer.proto === 'relay' ? 'relay' : 'lan',
1156
+ relay: approvals.get(peer.deviceId)?.relay ?? '',
1157
+ fingerprint: approvals.get(peer.deviceId)?.fingerprint ?? '',
1158
+ forwardSecrecy: Boolean(approvals.get(peer.deviceId)?.peerEphemeral && ephemerals.has(peer.deviceId)),
1159
+ lane: selectLane({ sameLan: peer.proto !== 'relay', punched: Boolean(approvals.get(peer.deviceId)?.punch), relayAvailable: Boolean(relay) }),
1160
+ transport: peer.lastTransport ?? (peer.proto === 'relay' ? 'relay' : 'lan'),
1161
+ punch: approvals.get(peer.deviceId)?.punch ?? null,
1162
+ candidates: Array.isArray(approvals.get(peer.deviceId)?.candidates) ? approvals.get(peer.deviceId).candidates.length : 0,
1163
+ punchPort: peer.punchPort ?? 0,
1164
+ sas: approvals.get(peer.deviceId)?.sas ?? '',
1165
+ scopes: approvals.get(peer.deviceId)?.scopes ?? [],
534
1166
  })),
535
1167
  requests: [...requests.values()]
536
1168
  .filter((request) => request.status === 'pending')
@@ -540,6 +1172,11 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
540
1172
  deviceName: request.deviceName,
541
1173
  userId: request.userId,
542
1174
  at: request.at,
1175
+ relay: request.relay ?? '',
1176
+ pairCode: request.pairCode ?? '',
1177
+ sas: request.sas ?? '',
1178
+ fingerprint: request.fingerprint ?? '',
1179
+ relayChanged: Boolean(request.relayChanged),
543
1180
  })),
544
1181
  }
545
1182
  },
@@ -554,15 +1191,21 @@ function sign(secret, deviceId, method, pathname, timestamp, body) {
554
1191
  .digest('hex')
555
1192
  }
556
1193
 
557
- function callRaw(peer, method, pathname, body, secret, callerId) {
1194
+ function callRaw(peer, method, pathname, body, secret, callerId, extraHeaders, lane) {
558
1195
  return new Promise((resolve) => {
559
- const text = body === undefined ? '' : JSON.stringify(body)
1196
+ let text = body === undefined ? '' : JSON.stringify(body)
1197
+ if (lane && body !== undefined) {
1198
+ try { text = JSON.stringify({ enc: lane.seal(text) }) } catch { /* keep plaintext */ }
1199
+ }
560
1200
  const headers = { accept: 'application/json' }
561
1201
  if (text) {
562
1202
  headers['content-type'] = 'application/json'
563
1203
  headers['content-length'] = Buffer.byteLength(text)
564
1204
  }
565
- if (secret && callerId) {
1205
+ if (extraHeaders) {
1206
+ Object.assign(headers, extraHeaders)
1207
+ if (lane) headers['x-dsh-lane'] = 'sealed'
1208
+ } else if (secret && callerId) {
566
1209
  const timestamp = String(Date.now())
567
1210
  headers['x-dsh-peer-id'] = callerId
568
1211
  headers['x-dsh-peer-ts'] = timestamp
@@ -583,6 +1226,9 @@ function callRaw(peer, method, pathname, body, secret, callerId) {
583
1226
  res.on('end', () => {
584
1227
  let parsed
585
1228
  try { parsed = data ? JSON.parse(data) : {} } catch { parsed = {} }
1229
+ if (lane && parsed && typeof parsed.enc === 'string') {
1230
+ try { parsed = JSON.parse(lane.open(parsed.enc)) } catch { parsed = { error: 'bad-envelope' } }
1231
+ }
586
1232
  resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: parsed })
587
1233
  })
588
1234
  })
@@ -592,13 +1238,19 @@ function callRaw(peer, method, pathname, body, secret, callerId) {
592
1238
  })
593
1239
  }
594
1240
 
595
- function readJson(req) {
1241
+ function readJson(req, maxBytes = 1024 * 1024) {
596
1242
  return new Promise((resolve, reject) => {
1243
+ const declared = Number(req.headers?.['content-length'])
1244
+ if (Number.isFinite(declared) && declared > maxBytes) {
1245
+ reject(new Error('too large'))
1246
+ req.destroy()
1247
+ return
1248
+ }
597
1249
  const chunks = []
598
1250
  let size = 0
599
1251
  req.on('data', (chunk) => {
600
1252
  size += chunk.length
601
- if (size > 1024 * 1024) { reject(new Error('too large')); req.destroy(); return }
1253
+ if (size > maxBytes) { reject(new Error('too large')); req.destroy(); return }
602
1254
  chunks.push(chunk)
603
1255
  })
604
1256
  req.on('end', () => {
@@ -619,6 +1271,25 @@ function json(res, status, value) {
619
1271
  res.end(body)
620
1272
  }
621
1273
 
1274
+ function createFileStore(file, log) {
1275
+ return {
1276
+ async load() {
1277
+ try { return JSON.parse(await readFile(file, 'utf8')) } catch (error) {
1278
+ if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${file}: ${error?.message || error}`)
1279
+ return null
1280
+ }
1281
+ },
1282
+ async save(value) {
1283
+ try {
1284
+ await mkdir(path.dirname(file), { recursive: true })
1285
+ await writeFile(file, JSON.stringify(value, null, 2), { mode: 0o600 })
1286
+ } catch (error) {
1287
+ log.warn(`[remote-control] could not persist ${file}: ${error?.message || error}`)
1288
+ }
1289
+ },
1290
+ }
1291
+ }
1292
+
622
1293
  function delay(ms) {
623
1294
  return new Promise((resolve) => setTimeout(resolve, ms))
624
1295
  }