dsh-remote-control-plugin 0.3.6 → 0.4.1

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
  }
@@ -103,6 +432,8 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
103
432
  approved: approvals.has(message.deviceId) || Boolean(existing?.approved),
104
433
  secret: approvals.get(message.deviceId)?.secret ?? existing?.secret,
105
434
  })
435
+ // Fresh discovery evidence: retry the direct lane on the next call.
436
+ routeBlocks.delete(message.deviceId)
106
437
  }
107
438
 
108
439
  function reap() {
@@ -150,13 +481,20 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
150
481
  if (loaded) return
151
482
  loaded = true
152
483
  try {
153
- const raw = JSON.parse(await readFile(stateFile, 'utf8'))
484
+ const raw = await approvalStore.load()
154
485
  for (const [deviceId, entry] of Object.entries(raw?.peers ?? {})) {
155
486
  if (entry && typeof entry.secret === 'string') {
156
487
  approvals.set(deviceId, {
157
488
  deviceName: entry.deviceName ?? 'DSH device',
158
489
  secret: entry.secret,
159
490
  approvedAt: entry.approvedAt ?? Date.now(),
491
+ relay: entry.relay ?? '',
492
+ publicKey: entry.publicKey ?? '',
493
+ fingerprint: entry.fingerprint ?? '',
494
+ agreementKey: entry.agreementKey ?? '',
495
+ peerEphemeral: entry.peerEphemeral ?? '',
496
+ sas: entry.sas ?? '',
497
+ scopes: Array.isArray(entry.scopes) ? entry.scopes : [...DEFAULT_SCOPES],
160
498
  })
161
499
  }
162
500
  }
@@ -169,10 +507,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
169
507
  const peersObject = {}
170
508
  for (const [deviceId, entry] of approvals) peersObject[deviceId] = entry
171
509
  try {
172
- await mkdir(path.dirname(stateFile), { recursive: true })
173
- await writeFile(stateFile, JSON.stringify({ version: 1, peers: peersObject }, null, 2), { mode: 0o600 })
510
+ await approvalStore.save({ version: 1, peers: peersObject })
174
511
  } catch (error) {
175
- log.warn(`[remote-control] could not persist ${stateFile}: ${error?.message || error}`)
512
+ log.warn(`[remote-control] could not persist approvals: ${error?.message || error}`)
176
513
  }
177
514
  }
178
515
 
@@ -184,11 +521,38 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
184
521
  if (!deviceId) return json(res, 400, { error: 'missing-device' })
185
522
  if (requests.size >= 20) return json(res, 429, { error: 'too-many-requests' })
186
523
  const requestId = randomBytes(16).toString('hex')
524
+ const claimedRelay = typeof body?.relay === 'string' ? body.relay : ''
525
+ const claimedPairCode = typeof body?.pairCode === 'string' ? body.pairCode : ''
526
+ const claimedFingerprint = typeof body?.fingerprint === 'string' ? body.fingerprint : ''
527
+ const claimedKey = typeof body?.publicKey === 'string' ? body.publicKey : ''
528
+ const known = approvals.get(deviceId)
529
+ const who = self()
530
+ const claimedEphemeral = body?.ephemeral
531
+ const ephemeralOk = verifyEphemeral(claimedKey, deviceId, claimedPairCode, claimedEphemeral)
532
+ const replyEphemeral = mintFor(deviceId, claimedPairCode)
187
533
  requests.set(requestId, {
188
534
  requestId,
189
535
  deviceId,
190
536
  deviceName: typeof body.deviceName === 'string' ? body.deviceName : 'DSH device',
191
537
  userId: typeof body.userId === 'string' ? body.userId : null,
538
+ relay: claimedRelay,
539
+ pairCode: claimedPairCode,
540
+ publicKey: claimedKey,
541
+ fingerprint: claimedFingerprint,
542
+ peerEphemeral: ephemeralOk ? claimedEphemeral.publicPem : '',
543
+ candidates: Array.isArray(body?.candidates) ? body.candidates.filter((entry) => typeof entry === 'string').slice(0, 16) : [],
544
+ punchPort: Number.isInteger(body?.punchPort) ? body.punchPort : 0,
545
+ publicEndpoint: body?.publicEndpoint && typeof body.publicEndpoint.address === 'string' && Number.isInteger(body.publicEndpoint.port)
546
+ ? { address: body.publicEndpoint.address, port: body.publicEndpoint.port }
547
+ : null,
548
+ replyEphemeral,
549
+ agreementKey: typeof body?.agreementKey === 'string' ? body.agreementKey : '',
550
+ // Both screens show these digits only when both keys and the invite
551
+ // nonce agree; a mismatch is a hard stop, not a warning.
552
+ sas: claimedFingerprint && who.fingerprint
553
+ ? sasCode(who.fingerprint, claimedFingerprint, claimedPairCode)
554
+ : '',
555
+ relayChanged: Boolean(known?.relay && claimedRelay && known.relay !== claimedRelay),
192
556
  address: req.socket?.remoteAddress ?? '',
193
557
  at: Date.now(),
194
558
  status: 'pending',
@@ -201,10 +565,26 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
201
565
  function helloStatus(res, requestId) {
202
566
  const request = requests.get(requestId)
203
567
  if (!request) return json(res, 404, { error: 'unknown-request' })
568
+ const who = self()
204
569
  if (request.status === 'approved') {
205
- return json(res, 200, { status: 'approved', secret: request.secret, deviceName: self().deviceName })
570
+ return json(res, 200, {
571
+ status: 'approved',
572
+ // A peer that pinned a device key never needs the shared secret, and
573
+ // the relay would see this response in plaintext. Only legacy peers
574
+ // (no claimed public key) still receive one.
575
+ ...(request.publicKey ? {} : { secret: request.secret }),
576
+ deviceName: who.deviceName,
577
+ publicKey: who.publicKey ?? '',
578
+ fingerprint: who.fingerprint ?? '',
579
+ agreementKey: who.agreementKey ?? '',
580
+ ephemeral: request.replyEphemeral,
581
+ candidates: localCandidates().map((entry) => entry.address),
582
+ punchPort: config.punchPort ?? 0,
583
+ publicEndpoint: publicEndpoint ?? null,
584
+ sas: request.sas ?? '',
585
+ })
206
586
  }
207
- return json(res, 200, { status: request.status })
587
+ return json(res, 200, { status: request.status, sas: request.sas ?? '' })
208
588
  }
209
589
 
210
590
  async function approveRequest(requestId) {
@@ -213,7 +593,22 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
213
593
  const secret = randomBytes(32).toString('hex')
214
594
  request.status = 'approved'
215
595
  request.secret = secret
216
- approvals.set(request.deviceId, { deviceName: request.deviceName, secret, approvedAt: Date.now() })
596
+ approvals.set(request.deviceId, {
597
+ deviceName: request.deviceName,
598
+ secret,
599
+ approvedAt: Date.now(),
600
+ relay: request.relay ?? '',
601
+ publicKey: request.publicKey ?? '',
602
+ fingerprint: request.fingerprint ?? '',
603
+ agreementKey: request.agreementKey ?? '',
604
+ peerEphemeral: request.peerEphemeral ?? '',
605
+ candidates: Array.isArray(request.candidates) ? request.candidates : [],
606
+ punchPort: Number.isInteger(request.punchPort) ? request.punchPort : 0,
607
+ publicEndpoint: request.publicEndpoint ?? null,
608
+ secret: request.publicKey ? '' : request.secret,
609
+ sas: request.sas ?? '',
610
+ scopes: [...DEFAULT_SCOPES],
611
+ })
217
612
  await saveApprovals()
218
613
  const peer = peers.get(request.deviceId)
219
614
  if (peer) { peer.approved = true; peer.secret = secret }
@@ -241,6 +636,15 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
241
636
  deviceId: who.deviceId,
242
637
  deviceName: who.deviceName,
243
638
  userId: who.userId,
639
+ relay: who.relayUrl ?? '',
640
+ pairCode: who.pairCode ?? '',
641
+ publicKey: who.publicKey ?? '',
642
+ fingerprint: who.fingerprint ?? '',
643
+ agreementKey: who.agreementKey ?? '',
644
+ ephemeral: mintFor(peer.deviceId, who.pairCode ?? ''),
645
+ candidates: localCandidates().map((entry) => entry.address),
646
+ punchPort: config.punchPort ?? 0,
647
+ publicEndpoint: publicEndpoint ?? null,
244
648
  })
245
649
  if (!reply.ok || typeof reply.json?.requestId !== 'string') {
246
650
  return { ok: false, error: reply.json?.error || reply.error || 'hello-failed' }
@@ -258,11 +662,29 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
258
662
  if (!outgoing.has(peer.deviceId)) return
259
663
  const reply = await peerCall(peer, 'GET', `/peer/hello/${requestId}`)
260
664
  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() })
665
+ if (status === 'approved') {
666
+ const replyEphemeral = reply.json.ephemeral
667
+ const ephemeralOk = verifyEphemeral(reply.json.publicKey ?? '', peer.deviceId, self().pairCode ?? '', replyEphemeral)
668
+ approvals.set(peer.deviceId, {
669
+ deviceName: peer.deviceName,
670
+ secret: reply.json.secret ?? '',
671
+ approvedAt: Date.now(),
672
+ relay: relay?.url() ?? '',
673
+ publicKey: reply.json.publicKey ?? '',
674
+ fingerprint: reply.json.fingerprint ?? '',
675
+ agreementKey: reply.json.agreementKey ?? '',
676
+ peerEphemeral: ephemeralOk ? replyEphemeral.publicPem : '',
677
+ candidates: Array.isArray(reply.json.candidates) ? reply.json.candidates : [],
678
+ punchPort: Number.isInteger(reply.json.punchPort) ? reply.json.punchPort : 0,
679
+ publicEndpoint: reply.json.publicEndpoint ?? null,
680
+ sas: reply.json.sas ?? '',
681
+ scopes: [...DEFAULT_SCOPES],
682
+ })
683
+ peer.publicKey = reply.json.publicKey ?? ''
684
+ peer.sas = reply.json.sas ?? ''
263
685
  await saveApprovals()
264
686
  peer.approved = true
265
- peer.secret = reply.json.secret
687
+ peer.secret = reply.json.secret ?? ''
266
688
  outgoing.delete(peer.deviceId)
267
689
  log.info(`[remote-control] ${peer.deviceName} approved this device`)
268
690
  return
@@ -285,42 +707,105 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
285
707
 
286
708
  const verdict = verifyPeer(req, url)
287
709
  if (!verdict.ok) return json(res, 403, { error: verdict.error })
710
+ if (!allowCall(verdict.deviceId)) return json(res, 429, { error: 'rate-limited' })
711
+ const requiredScope = scopeFor(req.method || 'GET', pathname)
712
+ if (requiredScope && Array.isArray(verdict.approval?.scopes) && !verdict.approval.scopes.includes(requiredScope)) {
713
+ return json(res, 403, { error: 'scope-denied', scope: requiredScope })
714
+ }
288
715
 
716
+ const viaHeader = String(req.headers['x-dsh-via'] || '')
717
+ const fromRelay = viaHeader === 'relay' || viaHeader === 'p2p'
718
+ // A pinned peer on the direct transport must use the sealed lane; relay
719
+ // traffic is already inside the relay AEAD envelope.
720
+ let lanePair
721
+ if (!fromRelay && verdict.approval?.publicKey && pathname !== '/peer/stream') {
722
+ if (String(req.headers['x-dsh-lane'] || '') !== 'sealed') {
723
+ return json(res, 403, { error: 'plaintext-not-allowed' })
724
+ }
725
+ lanePair = lanCipherFor(verdict.deviceId)
726
+ if (!lanePair) return json(res, 403, { error: 'no-channel' })
727
+ }
728
+ // One body read for every authenticated POST: the peer may have sealed it
729
+ // with the LAN channel, and every route needs the same decoded value.
730
+ let body = {}
731
+ if (req.method === 'POST') {
732
+ let incoming
733
+ try {
734
+ incoming = await readJson(req, UPLOAD_BODY_BYTES)
735
+ } catch {
736
+ return json(res, 400, { error: 'bad-body' })
737
+ }
738
+ if (incoming && typeof incoming === 'object' && typeof incoming.enc === 'string') {
739
+ if (!lanePair) return json(res, 403, { error: 'no-channel' })
740
+ try {
741
+ body = JSON.parse(lanePair.open(incoming.enc).toString('utf8'))
742
+ } catch (error) {
743
+ return json(res, 403, { error: 'bad-envelope' })
744
+ }
745
+ } else {
746
+ body = incoming ?? {}
747
+ }
748
+ }
749
+ const reply = lanePair
750
+ ? (status, value) => json(res, status, { enc: lanePair.seal(JSON.stringify(value)) })
751
+ : (status, value) => json(res, status, value)
752
+
753
+ if (pathname.startsWith('/peer/upload/') && uploads) {
754
+ const action = pathname.slice('/peer/upload/'.length)
755
+ const handler = uploads[action]
756
+ if (typeof handler !== 'function') return reply(404, { error: 'not-found' })
757
+ const payload = req.method === 'GET' ? Object.fromEntries(url.searchParams.entries()) : body
758
+ const result = await handler(payload)
759
+ return reply(result.status ?? 200, result)
760
+ }
761
+ if (pathname === '/peer/rehandshake' && req.method === 'POST') {
762
+ const peerMessage = body?.ephemeral
763
+ const mine = approvals.get(verdict.deviceId)
764
+ if (!peerMessage || !verifyEphemeral(mine?.publicKey ?? '', verdict.deviceId, self().pairCode ?? '', peerMessage)) {
765
+ return reply(400, { error: 'bad-handshake' })
766
+ }
767
+ const replyMessage = mintFor(verdict.deviceId, self().pairCode ?? '')
768
+ approvals.set(verdict.deviceId, { ...mine, peerEphemeral: peerMessage.publicPem })
769
+ ciphers.delete(verdict.deviceId)
770
+ for (const key of [...lanCiphers.keys()]) {
771
+ if (key.endsWith(`:${verdict.deviceId}`)) lanCiphers.delete(key)
772
+ }
773
+ await saveApprovals()
774
+ return reply(200, { ok: true, ephemeral: replyMessage })
775
+ }
289
776
  if (pathname === '/peer/session/config' && req.method === 'GET') {
290
777
  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))
778
+ if (!sessionId) return reply(400, { error: 'missing-session' })
779
+ if (typeof sessions.config !== 'function') return reply(503, { error: 'config-unavailable' })
780
+ return reply(200, await sessions.config(sessionId))
294
781
  }
295
782
  if (pathname === '/peer/session/config' && req.method === 'POST') {
296
- const body = await readJson(req)
297
783
  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))
784
+ if (!sessionId) return reply(400, { error: 'missing-session' })
785
+ if (typeof sessions.applyConfig !== 'function') return reply(503, { error: 'config-unavailable' })
786
+ return reply(200, await sessions.applyConfig(sessionId, body))
301
787
  }
302
788
  if (pathname === '/peer/sessions' && req.method === 'GET') {
303
789
  const value = await sessions.list()
304
- return json(res, 200, { items: value.map((item) => ({ ...item, title: item.title })) })
790
+ return reply(200, { items: value.map((item) => ({ ...item, title: item.title })) })
305
791
  }
306
792
  if (pathname === '/peer/stream' && req.method === 'GET') {
307
793
  return streamSession(req, res, url)
308
794
  }
309
795
  if (pathname === '/peer/prompt' && req.method === 'POST') {
310
- const body = await readJson(req)
311
796
  const sessionId = typeof body?.sessionId === 'string' ? body.sessionId : ''
312
797
  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)
798
+ const attachments = Array.isArray(body?.attachments) ? body.attachments : []
799
+ if (!sessionId || (!text.trim() && attachments.length === 0)) return reply(400, { error: 'bad-request' })
800
+ const value = await sessions.prompt(sessionId, { text, attachments })
801
+ return reply(200, value)
316
802
  }
317
803
  if (pathname === '/peer/cancel' && req.method === 'POST') {
318
- const body = await readJson(req)
319
804
  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))
805
+ if (!sessionId) return reply(400, { error: 'bad-request' })
806
+ return reply(200, sessions.cancel(sessionId))
322
807
  }
323
- return json(res, 404, { error: 'not-found' })
808
+ return reply(404, { error: 'not-found' })
324
809
  }
325
810
 
326
811
  function verifyPeer(req, url) {
@@ -333,16 +818,37 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
333
818
  if (!/^\d{1,16}$/.test(timestamp) || Math.abs(Date.now() - Number(timestamp)) > CLOCK_SKEW_MS) {
334
819
  return { ok: false, error: 'stale-signature' }
335
820
  }
821
+ // A pinned device key retires the shared secret: the approval response that
822
+ // carried that secret crossed the relay in plaintext, so accepting HMAC
823
+ // afterwards would let anyone who saw it forge calls.
824
+ if (approval.publicKey && String(req.headers['x-dsh-peer-alg'] || '') !== 'ed25519') {
825
+ return { ok: false, error: 'legacy-auth-disabled' }
826
+ }
827
+ if (String(req.headers['x-dsh-peer-alg'] || '') === 'ed25519') {
828
+ if (!approval.publicKey) return { ok: false, error: 'no-device-key' }
829
+ const nonce = String(req.headers['x-dsh-peer-nonce'] || '')
830
+ if (!nonce) return { ok: false, error: 'missing-nonce' }
831
+ const payload = `${deviceId}\n${req.method || 'GET'}\n${url.pathname + url.search}\n${timestamp}\n${nonce}`
832
+ if (!verifyPayload(approval.publicKey, payload, signature)) return { ok: false, error: 'bad-signature' }
833
+ if (!acceptNonce(deviceId, nonce)) return { ok: false, error: 'replayed' }
834
+ return { ok: true, deviceId, approval }
835
+ }
336
836
  const expected = sign(approval.secret, deviceId, req.method || 'GET', url.pathname + url.search, timestamp, '')
337
837
  const actual = Buffer.from(signature, 'hex')
338
838
  const wanted = Buffer.from(expected, 'hex')
339
839
  if (actual.length !== wanted.length || !timingSafeEqual(actual, wanted)) return { ok: false, error: 'bad-signature' }
340
- return { ok: true }
840
+ return { ok: true, deviceId, approval }
341
841
  }
342
842
 
343
843
  async function streamSession(req, res, url) {
344
844
  const sessionId = String(url.searchParams.get('sessionId') || '')
345
845
  if (!sessionId) return json(res, 400, { error: 'missing-session' })
846
+ // Frames use their own key space, so a stream can never desync the unary
847
+ // body counters.
848
+ const peerDeviceId = String(req.headers['x-dsh-peer-id'] || '')
849
+ const streamPair = String(req.headers['x-dsh-lane'] || '') === 'sealed' && peerDeviceId
850
+ ? lanCipherFor(peerDeviceId, 'lan-stream')
851
+ : undefined
346
852
  const controller = new AbortController()
347
853
  res.writeHead(200, {
348
854
  'content-type': 'text/event-stream; charset=utf-8',
@@ -366,7 +872,9 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
366
872
  try {
367
873
  for await (const frame of sessions.follow(sessionId, controller.signal)) {
368
874
  if (res.writableEnded || res.destroyed) break
369
- res.write(`data: ${JSON.stringify(frame)}\n\n`)
875
+ const payload = JSON.stringify(frame)
876
+ const wire = streamPair ? JSON.stringify({ enc: streamPair.seal(payload) }) : payload
877
+ res.write(`data: ${wire}\n\n`)
370
878
  }
371
879
  } catch (error) {
372
880
  if (!controller.signal.aborted) {
@@ -385,24 +893,194 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
385
893
  * One signed call to a peer over whichever transport reaches it: the relay
386
894
  * when the peer was discovered there, direct HTTP for a LAN peer.
387
895
  */
388
- async function peerCall(peer, method, pathname, body, secret) {
896
+ const routeBlocks = new Map() // deviceId -> { lanUntil, p2pUntil, lastError }
897
+
898
+ function laneBlocked(deviceId, lane) {
899
+ const entry = routeBlocks.get(deviceId)
900
+ if (!entry) return false
901
+ return (lane === 'lan' ? entry.lanUntil : entry.p2pUntil) > Date.now()
902
+ }
903
+
904
+ function markLaneBlocked(deviceId, lane, reason) {
905
+ let entry = routeBlocks.get(deviceId)
906
+ if (!entry) { entry = { lanUntil: 0, p2pUntil: 0 }; routeBlocks.set(deviceId, entry) }
907
+ const holdMs = config.routeBlockMs ?? 60_000
908
+ if (lane === 'lan') entry.lanUntil = Date.now() + holdMs
909
+ else entry.p2pUntil = Date.now() + holdMs
910
+ entry.lastError = String(reason ?? 'unreachable')
911
+ log.warn(`[remote-control] ${lane} route to ${deviceId} parked for ${holdMs}ms: ${entry.lastError}`)
912
+ }
913
+
914
+ function markLaneHealthy(deviceId, lane) {
915
+ const entry = routeBlocks.get(deviceId)
916
+ if (!entry) return
917
+ if (lane === 'lan') entry.lanUntil = 0
918
+ else entry.p2pUntil = 0
919
+ }
920
+
921
+ function withTimeout(promise, ms) {
922
+ return new Promise((resolve) => {
923
+ const timer = setTimeout(() => resolve(undefined), ms)
924
+ timer.unref?.()
925
+ promise.then(
926
+ (value) => { clearTimeout(timer); resolve(value) },
927
+ () => { clearTimeout(timer); resolve(undefined) },
928
+ )
929
+ })
930
+ }
931
+
932
+ /** Resolve with the first non-undefined value; undefined when all fail. */
933
+ function firstSuccess(promises) {
934
+ return new Promise((resolve) => {
935
+ let pending = promises.length
936
+ let settled = false
937
+ for (const promise of promises) {
938
+ promise.then(
939
+ (value) => {
940
+ if (settled) return
941
+ if (value) { settled = true; resolve(value); return }
942
+ pending -= 1
943
+ if (pending === 0) resolve(undefined)
944
+ },
945
+ () => {
946
+ if (settled) return
947
+ pending -= 1
948
+ if (pending === 0) resolve(undefined)
949
+ },
950
+ )
951
+ }
952
+ })
953
+ }
954
+
955
+ async function peerCall(peer, method, pathname, body, secret, options = {}) {
956
+ const deviceId = peer.deviceId
957
+ const approval = approvals.get(deviceId)
958
+ const bodySize = body === undefined ? 0 : JSON.stringify(body).length
959
+ const started = Date.now()
960
+ const relayAvailable = Boolean(relay)
961
+ const directParked = laneBlocked(deviceId, 'lan')
962
+ const p2pParked = laneBlocked(deviceId, 'p2p')
963
+
964
+ const finish = (result, lane) => {
965
+ const elapsed = Date.now() - started
966
+ peer.lastTransport = lane
967
+ peer.lastTiming = { lane, ms: elapsed, at: Date.now() }
968
+ log.info(`[remote-control] ${method} ${pathname} ${deviceId} via ${lane} in ${elapsed}ms`)
969
+ return result
970
+ }
971
+
972
+ const viaRelayLane = () => relayCall(peer, method, pathname, body, secret)
973
+
974
+ const viaP2p = async () => {
975
+ if (!p2pNode || !approval?.punch || bodySize > 64 * 1024 || laneBlocked(deviceId, 'p2p')) return undefined
976
+ const auth = authHeaders(deviceId, method, pathname) ?? {}
977
+ const result = await withTimeout(p2pNode.request(
978
+ { address: approval.punch.address, port: approval.punch.port },
979
+ deviceId,
980
+ {
981
+ method,
982
+ path: pathname,
983
+ headers: { ...auth, accept: 'application/json', 'content-type': 'application/json' },
984
+ bodyText: body === undefined ? '' : JSON.stringify(body),
985
+ },
986
+ ), config.p2pTimeoutMs ?? 800)
987
+ if (!result?.ok) {
988
+ markLaneBlocked(deviceId, 'p2p', result?.error ?? 'p2p-timeout')
989
+ return undefined
990
+ }
991
+ markLaneHealthy(deviceId, 'p2p')
992
+ let json = {}
993
+ try { json = result.message.bodyText ? JSON.parse(result.message.bodyText) : {} } catch { json = {} }
994
+ return { ok: result.message.status >= 200 && result.message.status < 300, status: result.message.status, json }
995
+ }
996
+
997
+ const viaDirect = async () => {
998
+ const auth = authHeaders(deviceId, method, pathname)
999
+ const pair = lanCipherFor(deviceId)
1000
+ const lane = pair
1001
+ ? { seal: (value) => pair.seal(value), open: (packet) => pair.open(packet).toString('utf8') }
1002
+ : undefined
1003
+ const result = await callRaw(
1004
+ peer, method, pathname, body, secret, self().deviceId, auth, lane,
1005
+ config.directTimeoutMs ?? 1000,
1006
+ )
1007
+ if (!result.error) {
1008
+ markLaneHealthy(deviceId, 'lan')
1009
+ return result
1010
+ }
1011
+ markLaneBlocked(deviceId, 'lan', result.error)
1012
+ return result
1013
+ }
1014
+
1015
+ // Happy-eyeballs for idempotent reads: the relay and the direct lane start
1016
+ // together and the first answer wins. Never used for writes, so a prompt
1017
+ // can still execute exactly once.
1018
+ if (options.race && relayAvailable && peer.proto !== 'relay' && !directParked && !p2pParked) {
1019
+ const auth = authHeaders(deviceId, method, pathname)
1020
+ const pair = lanCipherFor(deviceId)
1021
+ const lane = pair
1022
+ ? { seal: (value) => pair.seal(value), open: (packet) => pair.open(packet).toString('utf8') }
1023
+ : undefined
1024
+ const directPromise = callRaw(
1025
+ peer, method, pathname, body, secret, self().deviceId, auth, lane,
1026
+ config.directTimeoutMs ?? 1000,
1027
+ ).then((result) => {
1028
+ if (result.error) { markLaneBlocked(deviceId, 'lan', result.error); return undefined }
1029
+ markLaneHealthy(deviceId, 'lan')
1030
+ return { lane: 'lan', result }
1031
+ }, () => undefined)
1032
+ const relayPromise = relayCall(peer, method, pathname, body, secret)
1033
+ .then((result) => ((result.ok || result.status !== 503) ? { lane: 'relay', result } : undefined), () => undefined)
1034
+ const winner = await firstSuccess([directPromise, relayPromise])
1035
+ if (winner) return finish(winner.result, winner.lane)
1036
+ }
1037
+
1038
+ // Relay-first once a direct lane is known blocked: only the first call pays
1039
+ // the discovery penalty. A fresh announce clears the block again.
1040
+ if (relayAvailable && peer.proto !== 'relay' && (directParked || p2pParked)) {
1041
+ const relayed = await viaRelayLane()
1042
+ if (relayed.ok || relayed.status !== 503) return finish(relayed, 'relay')
1043
+ const fallback = await viaDirect()
1044
+ if (!fallback.error) return finish(fallback, 'lan')
1045
+ return finish(relayed, 'relay')
1046
+ }
1047
+
1048
+ const datagram = await viaP2p()
1049
+ if (datagram) return finish(datagram, 'p2p')
389
1050
  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
394
- const viaRelay = await relayCall(peer, method, pathname, body, secret)
395
- return viaRelay.ok || viaRelay.status !== 503 ? viaRelay : direct
1051
+ const direct = await viaDirect()
1052
+ if (!direct.error) return finish(direct, 'lan')
1053
+ if (relayAvailable) {
1054
+ const relayed = await viaRelayLane()
1055
+ if (relayed.ok || relayed.status !== 503) return finish(relayed, 'relay')
1056
+ }
1057
+ return finish(direct, 'lan')
396
1058
  }
397
- return relayCall(peer, method, pathname, body, secret)
1059
+ return finish(await viaRelayLane(), 'relay')
398
1060
  }
399
1061
 
400
1062
  async function relayCall(peer, method, pathname, body, secret) {
401
1063
  if (!relay) return { ok: false, status: 503, json: { error: 'relay-offline' } }
1064
+ // A peer that pinned an identity key but has no agreement key would send
1065
+ // plaintext through the relay: that is a silent downgrade, so refuse it
1066
+ // unless the operator opted in explicitly.
1067
+ const approvalForCrypto = approvals.get(peer.deviceId)
1068
+ if (approvalForCrypto?.publicKey && !approvalForCrypto?.agreementKey && config.allowPlaintextFallback !== true) {
1069
+ log.warn(`[remote-control] refusing plaintext relay call to ${peer.deviceId}: no agreement key`)
1070
+ return {
1071
+ ok: false,
1072
+ status: 403,
1073
+ json: { error: 'plaintext-refused', message: 'This peer pinned an identity but no encryption key; approve it again to enable encryption.' },
1074
+ }
1075
+ }
1076
+ peer.lastTransport = 'relay'
402
1077
  const callerId = self().deviceId
403
1078
  const timestamp = String(Date.now())
404
1079
  const headers = { accept: 'application/json' }
405
- if (secret) {
1080
+ const relayAuth = authHeaders(peer.deviceId, method, pathname)
1081
+ if (relayAuth) {
1082
+ Object.assign(headers, relayAuth)
1083
+ } else if (secret) {
406
1084
  headers['x-dsh-peer-id'] = callerId
407
1085
  headers['x-dsh-peer-ts'] = timestamp
408
1086
  headers['x-dsh-peer-sig'] = sign(secret, callerId, method, pathname, timestamp, '')
@@ -424,13 +1102,13 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
424
1102
  return { ok: reply.status >= 200 && reply.status < 300, status: reply.status, json: parsed }
425
1103
  }
426
1104
 
427
- async function callPeer(deviceId, method, pathname, body) {
1105
+ async function callPeer(deviceId, method, pathname, body, options) {
428
1106
  syncRelayPeers()
429
1107
  const peer = peers.get(deviceId)
430
1108
  if (!peer) return { ok: false, status: 404, json: { error: 'unknown-peer' } }
431
1109
  const approval = approvals.get(deviceId)
432
1110
  if (!approval) return { ok: false, status: 403, json: { error: 'not-approved' } }
433
- return peerCall(peer, method, pathname, body, approval.secret)
1111
+ return peerCall(peer, method, pathname, body, approval.secret, options)
434
1112
  }
435
1113
 
436
1114
  /** Relay one peer's SSE transcript to a local response (Devices panel). */
@@ -445,11 +1123,58 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
445
1123
  const target = `/peer/stream?sessionId=${encodeURIComponent(sessionId)}`
446
1124
  const timestamp = String(Date.now())
447
1125
  const callerId = self().deviceId
1126
+ const started = Date.now()
1127
+ // A parked direct route goes straight to the relay: the stream must never
1128
+ // hang waiting for headers a blocked LAN will not deliver.
1129
+ if (relay && peer.proto !== 'relay' && laneBlocked(deviceId, 'lan')) {
1130
+ relay.stream(peer.deviceId, {
1131
+ path: target,
1132
+ headers: {
1133
+ accept: 'text/event-stream',
1134
+ 'x-dsh-peer-id': callerId,
1135
+ 'x-dsh-peer-ts': timestamp,
1136
+ 'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
1137
+ ...(authHeaders(deviceId, 'GET', target) ?? {}),
1138
+ },
1139
+ }, res)
1140
+ peer.lastTiming = { lane: 'relay', ms: 0, at: Date.now(), reason: 'direct-parked' }
1141
+ return
1142
+ }
1143
+ if (config.p2pStreams !== false && p2pNode && approval.punch && !laneBlocked(deviceId, 'p2p')) {
1144
+ const auth = authHeaders(deviceId, 'GET', target) ?? {}
1145
+ res.writeHead(200, {
1146
+ 'content-type': 'text/event-stream; charset=utf-8',
1147
+ 'cache-control': 'no-store, no-transform',
1148
+ connection: 'keep-alive',
1149
+ 'x-accel-buffering': 'no',
1150
+ })
1151
+ const handle = p2pNode.openStream(
1152
+ { address: approval.punch.address, port: approval.punch.port },
1153
+ deviceId,
1154
+ { path: target, headers: { ...auth, accept: 'text/event-stream' } },
1155
+ {
1156
+ onChunk: (chunk) => { try { res.write(chunk) } catch { /* client gone */ } },
1157
+ onEnd: () => { try { res.end() } catch { /* ignore */ } },
1158
+ },
1159
+ )
1160
+ if (!handle.ok) {
1161
+ markLaneBlocked(deviceId, 'p2p', 'stream-open-failed')
1162
+ try { res.destroy() } catch { /* ignore */ }
1163
+ return
1164
+ }
1165
+ peer.lastTransport = 'p2p'
1166
+ return
1167
+ }
448
1168
  if (peer.proto === 'relay') {
449
1169
  if (!relay) {
450
1170
  json(res, 503, { error: 'relay-offline' })
451
1171
  return
452
1172
  }
1173
+ if (approval.publicKey && !approval.agreementKey && config.allowPlaintextFallback !== true) {
1174
+ log.warn(`[remote-control] refusing plaintext relay stream to ${deviceId}: no agreement key`)
1175
+ json(res, 403, { error: 'plaintext-refused' })
1176
+ return
1177
+ }
453
1178
  relay.stream(peer.deviceId, {
454
1179
  path: target,
455
1180
  headers: {
@@ -457,16 +1182,21 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
457
1182
  'x-dsh-peer-id': callerId,
458
1183
  'x-dsh-peer-ts': timestamp,
459
1184
  'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
1185
+ ...(authHeaders(peer.deviceId, 'GET', target) ?? {}),
460
1186
  },
461
1187
  }, res)
462
1188
  return
463
1189
  }
1190
+ const streamPair = lanCipherFor(peer.deviceId, 'lan-stream')
1191
+ const streamAuth = authHeaders(peer.deviceId, 'GET', target)
464
1192
  const streamHeaders = {
465
1193
  accept: 'text/event-stream',
466
1194
  'x-dsh-peer-id': callerId,
467
1195
  'x-dsh-peer-ts': timestamp,
468
1196
  'x-dsh-peer-sig': sign(approval.secret, callerId, 'GET', target, timestamp, ''),
1197
+ ...(streamAuth ?? {}),
469
1198
  }
1199
+ if (streamPair) streamHeaders['x-dsh-lane'] = 'sealed'
470
1200
  const upstream = http.request({
471
1201
  host: peer.address,
472
1202
  port: peer.port,
@@ -474,25 +1204,67 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
474
1204
  method: 'GET',
475
1205
  headers: streamHeaders,
476
1206
  }, (response) => {
1207
+ responded = true
1208
+ clearTimeout(connectTimer)
1209
+ markLaneHealthy(deviceId, 'lan')
1210
+ peer.lastTiming = { lane: 'lan', ms: Date.now() - started, at: Date.now() }
477
1211
  res.writeHead(response.statusCode ?? 502, {
478
1212
  'content-type': response.headers['content-type'] || 'text/event-stream; charset=utf-8',
479
1213
  'cache-control': 'no-store, no-transform',
480
1214
  connection: 'keep-alive',
481
1215
  'x-accel-buffering': 'no',
482
1216
  })
483
- response.pipe(res)
1217
+ if (!streamPair) {
1218
+ response.pipe(res)
1219
+ response.on('end', () => { try { res.end() } catch { /* ignore */ } })
1220
+ return
1221
+ }
1222
+ // Decrypt each sealed frame before handing it to the local client.
1223
+ response.setEncoding('utf8')
1224
+ let buffer = ''
1225
+ response.on('data', (chunk) => {
1226
+ buffer += chunk
1227
+ let index
1228
+ while ((index = buffer.indexOf('\n\n')) >= 0) {
1229
+ const event = buffer.slice(0, index)
1230
+ buffer = buffer.slice(index + 2)
1231
+ const line = event.split('\n').find((entry) => entry.startsWith('data: '))
1232
+ if (line) {
1233
+ try {
1234
+ const parsed = JSON.parse(line.slice(6))
1235
+ if (parsed && typeof parsed.enc === 'string') {
1236
+ res.write(`data: ${streamPair.open(parsed.enc).toString('utf8')}\n\n`)
1237
+ continue
1238
+ }
1239
+ } catch {
1240
+ continue
1241
+ }
1242
+ }
1243
+ res.write(`${event}\n\n`)
1244
+ }
1245
+ })
484
1246
  response.on('end', () => { try { res.end() } catch { /* ignore */ } })
485
1247
  })
486
- upstream.on('error', () => {
1248
+ let responded = false
1249
+ const connectTimer = setTimeout(() => {
1250
+ if (!responded) upstream.destroy(new Error('stream-connect-timeout'))
1251
+ }, config.streamConnectTimeoutMs ?? 2500)
1252
+ connectTimer.unref?.()
1253
+ upstream.on('error', (error) => {
1254
+ clearTimeout(connectTimer)
487
1255
  // Same fallback as peerCall: a blocked LAN route may still reach the
488
1256
  // peer through the outbound relay.
1257
+ if (!responded) markLaneBlocked(deviceId, 'lan', error?.message || 'stream-failed')
489
1258
  if (relay && !res.headersSent) {
490
1259
  relay.stream(peer.deviceId, { path: target, headers: streamHeaders }, res)
491
1260
  return
492
1261
  }
493
1262
  try { json(res, 502, { error: 'peer-unreachable' }) } catch { /* ignore */ }
494
1263
  })
495
- res.on('close', () => { try { upstream.destroy() } catch { /* ignore */ } })
1264
+ res.on('close', () => {
1265
+ clearTimeout(connectTimer)
1266
+ try { upstream.destroy() } catch { /* ignore */ }
1267
+ })
496
1268
  upstream.end()
497
1269
  }
498
1270
 
@@ -505,8 +1277,44 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
505
1277
  callPeer,
506
1278
  proxyStream,
507
1279
  handlePeer,
1280
+ rehandshake: rehandshakePeer,
1281
+ attemptPunch: punchPeer,
1282
+ // Diagnostics used by the latency tests and by the Devices panel tooling.
1283
+ simulateAnnounce(deviceId, address, port) {
1284
+ onAnnounce(
1285
+ { kind: ANNOUNCE_KIND, deviceId, deviceName: `simulated-${deviceId}`, userId: null, port, proto: 'http' },
1286
+ { address },
1287
+ )
1288
+ },
1289
+ setPeerAddress(deviceId, address, port) {
1290
+ const peer = peers.get(deviceId)
1291
+ if (!peer) return false
1292
+ peer.address = address
1293
+ if (Number.isInteger(port)) peer.port = port
1294
+ return true
1295
+ },
1296
+ routeState(deviceId) {
1297
+ const entry = routeBlocks.get(deviceId)
1298
+ return entry ? { ...entry } : null
1299
+ },
1300
+ debugSetApproval(deviceId, patch) {
1301
+ const approval = approvals.get(deviceId)
1302
+ if (!approval) return false
1303
+ approvals.set(deviceId, { ...approval, ...patch })
1304
+ return true
1305
+ },
1306
+ async clearPunch(peerDeviceId) {
1307
+ const approval = approvals.get(peerDeviceId)
1308
+ if (!approval) return { ok: false, error: 'unknown-peer' }
1309
+ const next = { ...approval }
1310
+ delete next.punch
1311
+ approvals.set(peerDeviceId, next)
1312
+ await saveApprovals()
1313
+ return { ok: true }
1314
+ },
508
1315
  setRelay(link) {
509
1316
  relay = link
1317
+ if (link && typeof link.setSecurity === 'function') link.setSecurity(security)
510
1318
  if (!link) {
511
1319
  // Without a relay every relay-only peer is unreachable; keeping it
512
1320
  // would make each call fail with a misleading 503.
@@ -515,6 +1323,20 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
515
1323
  }
516
1324
  }
517
1325
  },
1326
+ setScopes(deviceId, scopes) {
1327
+ const approval = approvals.get(deviceId)
1328
+ if (!approval) return { ok: false, error: 'unknown-device' }
1329
+ const next = Array.isArray(scopes) ? scopes.filter((scope) => DEFAULT_SCOPES.includes(scope)) : []
1330
+ approvals.set(deviceId, { ...approval, scopes: next })
1331
+ saveApprovals().catch(() => {})
1332
+ return { ok: true, scopes: next }
1333
+ },
1334
+ revoke(deviceId) {
1335
+ if (!approvals.delete(deviceId)) return { ok: false, error: 'unknown-device' }
1336
+ ciphers.delete(deviceId)
1337
+ saveApprovals().catch(() => {})
1338
+ return { ok: true }
1339
+ },
518
1340
 
519
1341
  snapshot() {
520
1342
  syncRelayPeers()
@@ -531,6 +1353,19 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
531
1353
  approved: approvals.has(peer.deviceId),
532
1354
  connecting: outgoing.has(peer.deviceId),
533
1355
  transport: peer.proto === 'relay' ? 'relay' : 'lan',
1356
+ relay: approvals.get(peer.deviceId)?.relay ?? '',
1357
+ fingerprint: approvals.get(peer.deviceId)?.fingerprint ?? '',
1358
+ forwardSecrecy: Boolean(approvals.get(peer.deviceId)?.peerEphemeral && ephemerals.has(peer.deviceId)),
1359
+ lane: selectLane({ sameLan: peer.proto !== 'relay', punched: Boolean(approvals.get(peer.deviceId)?.punch), relayAvailable: Boolean(relay) }),
1360
+ transport: peer.lastTransport ?? (peer.proto === 'relay' ? 'relay' : 'lan'),
1361
+ timing: peer.lastTiming ?? null,
1362
+ blocked: { lan: laneBlocked(peer.deviceId, 'lan'), p2p: laneBlocked(peer.deviceId, 'p2p') },
1363
+ needsReapproval: Boolean(approvals.get(peer.deviceId)?.publicKey && !approvals.get(peer.deviceId)?.agreementKey),
1364
+ punch: approvals.get(peer.deviceId)?.punch ?? null,
1365
+ candidates: Array.isArray(approvals.get(peer.deviceId)?.candidates) ? approvals.get(peer.deviceId).candidates.length : 0,
1366
+ punchPort: peer.punchPort ?? 0,
1367
+ sas: approvals.get(peer.deviceId)?.sas ?? '',
1368
+ scopes: approvals.get(peer.deviceId)?.scopes ?? [],
534
1369
  })),
535
1370
  requests: [...requests.values()]
536
1371
  .filter((request) => request.status === 'pending')
@@ -540,6 +1375,11 @@ export function createFabric({ log, config, self, sessions, stateFile }) {
540
1375
  deviceName: request.deviceName,
541
1376
  userId: request.userId,
542
1377
  at: request.at,
1378
+ relay: request.relay ?? '',
1379
+ pairCode: request.pairCode ?? '',
1380
+ sas: request.sas ?? '',
1381
+ fingerprint: request.fingerprint ?? '',
1382
+ relayChanged: Boolean(request.relayChanged),
543
1383
  })),
544
1384
  }
545
1385
  },
@@ -554,15 +1394,21 @@ function sign(secret, deviceId, method, pathname, timestamp, body) {
554
1394
  .digest('hex')
555
1395
  }
556
1396
 
557
- function callRaw(peer, method, pathname, body, secret, callerId) {
1397
+ function callRaw(peer, method, pathname, body, secret, callerId, extraHeaders, lane, timeoutMs = 15000) {
558
1398
  return new Promise((resolve) => {
559
- const text = body === undefined ? '' : JSON.stringify(body)
1399
+ let text = body === undefined ? '' : JSON.stringify(body)
1400
+ if (lane && body !== undefined) {
1401
+ try { text = JSON.stringify({ enc: lane.seal(text) }) } catch { /* keep plaintext */ }
1402
+ }
560
1403
  const headers = { accept: 'application/json' }
561
1404
  if (text) {
562
1405
  headers['content-type'] = 'application/json'
563
1406
  headers['content-length'] = Buffer.byteLength(text)
564
1407
  }
565
- if (secret && callerId) {
1408
+ if (extraHeaders) {
1409
+ Object.assign(headers, extraHeaders)
1410
+ if (lane) headers['x-dsh-lane'] = 'sealed'
1411
+ } else if (secret && callerId) {
566
1412
  const timestamp = String(Date.now())
567
1413
  headers['x-dsh-peer-id'] = callerId
568
1414
  headers['x-dsh-peer-ts'] = timestamp
@@ -576,13 +1422,16 @@ function callRaw(peer, method, pathname, body, secret, callerId) {
576
1422
  path: pathname,
577
1423
  method,
578
1424
  headers,
579
- timeout: 15000,
1425
+ timeout: timeoutMs,
580
1426
  }, (res) => {
581
1427
  let data = ''
582
1428
  res.on('data', (chunk) => { data += chunk })
583
1429
  res.on('end', () => {
584
1430
  let parsed
585
1431
  try { parsed = data ? JSON.parse(data) : {} } catch { parsed = {} }
1432
+ if (lane && parsed && typeof parsed.enc === 'string') {
1433
+ try { parsed = JSON.parse(lane.open(parsed.enc)) } catch { parsed = { error: 'bad-envelope' } }
1434
+ }
586
1435
  resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, json: parsed })
587
1436
  })
588
1437
  })
@@ -592,13 +1441,19 @@ function callRaw(peer, method, pathname, body, secret, callerId) {
592
1441
  })
593
1442
  }
594
1443
 
595
- function readJson(req) {
1444
+ function readJson(req, maxBytes = 1024 * 1024) {
596
1445
  return new Promise((resolve, reject) => {
1446
+ const declared = Number(req.headers?.['content-length'])
1447
+ if (Number.isFinite(declared) && declared > maxBytes) {
1448
+ reject(new Error('too large'))
1449
+ req.destroy()
1450
+ return
1451
+ }
597
1452
  const chunks = []
598
1453
  let size = 0
599
1454
  req.on('data', (chunk) => {
600
1455
  size += chunk.length
601
- if (size > 1024 * 1024) { reject(new Error('too large')); req.destroy(); return }
1456
+ if (size > maxBytes) { reject(new Error('too large')); req.destroy(); return }
602
1457
  chunks.push(chunk)
603
1458
  })
604
1459
  req.on('end', () => {
@@ -619,6 +1474,25 @@ function json(res, status, value) {
619
1474
  res.end(body)
620
1475
  }
621
1476
 
1477
+ function createFileStore(file, log) {
1478
+ return {
1479
+ async load() {
1480
+ try { return JSON.parse(await readFile(file, 'utf8')) } catch (error) {
1481
+ if (error?.code !== 'ENOENT') log.warn(`[remote-control] could not read ${file}: ${error?.message || error}`)
1482
+ return null
1483
+ }
1484
+ },
1485
+ async save(value) {
1486
+ try {
1487
+ await mkdir(path.dirname(file), { recursive: true })
1488
+ await writeFile(file, JSON.stringify(value, null, 2), { mode: 0o600 })
1489
+ } catch (error) {
1490
+ log.warn(`[remote-control] could not persist ${file}: ${error?.message || error}`)
1491
+ }
1492
+ },
1493
+ }
1494
+ }
1495
+
622
1496
  function delay(ms) {
623
1497
  return new Promise((resolve) => setTimeout(resolve, ms))
624
1498
  }