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/INSTALL.md CHANGED
@@ -4,12 +4,12 @@ Published as `dsh-remote-control-plugin`. Self-contained: no dependencies, no bu
4
4
 
5
5
  ## Install
6
6
 
7
- Web GUI: **Plugins → Add plugin**, enter `dsh-remote-control-plugin@0.3.6`, keep the official npm
7
+ Web GUI: **Plugins → Add plugin**, enter `dsh-remote-control-plugin@0.4.1`, keep the official npm
8
8
  registry selected, then **Install** and **Enable now**. Restart DSH.
9
9
 
10
10
  From a chat (Standard or Creator preset — PTC disables `tool-plugin-manager`):
11
11
 
12
- > Install the DSH plugin bundle `dsh-remote-control-plugin@0.3.6` with plugin_manager.
12
+ > Install the DSH plugin bundle `dsh-remote-control-plugin@0.4.1` with plugin_manager.
13
13
 
14
14
  ## Connect two machines
15
15
 
@@ -27,7 +27,8 @@ Same-LAN machines need no relay. No reachable machine? Run
27
27
  - Approvals live in `$DSH_HOME/remote-control-peers.json`; delete it to revoke.
28
28
  - Relay and control API share one port (default `57330`).
29
29
  - Peer traffic is signed but not encrypted; use a private network or TLS in front.
30
- - 0.3.6 fixes: ordered transcript chunks, hosted relay persistence, stale
30
+ - 0.4.1 fixes: ordered transcript chunks, hosted relay persistence, stale
31
31
  relay peer pruning, relay fallback when a direct LAN route is blocked, a client
32
32
  module id that matches the package name, and a Devices config row for the
33
- remote session's model, permission preset, and plan mode.
33
+ remote session's model, permission preset, and plan mode. It also adds peer
34
+ file uploads (chunked, resumable, stored through the Host attachment service).
package/README.md CHANGED
@@ -15,19 +15,29 @@ Both drive `ctx.sessionController` in-process, so neither depends on the GUI's b
15
15
 
16
16
  ## How device trust works
17
17
 
18
- 1. Each Host announces itself on UDP `discoveryPort` with its device id, display name, and
19
- account id (when signed in).
20
- 2. To control a peer, this Host sends `POST /peer/hello`. The **target** shows a pending
21
- request in its own Devices panel; nothing is granted until a person there presses **Allow**.
22
- 3. On approval the target issues a random per-peer secret. Every later call is signed
23
- `HMAC-SHA256(secret, deviceId + method + path + timestamp + body)`, valid for 5 minutes of
24
- clock skew.
25
- 4. Approvals persist in `$DSH_HOME/remote-control-peers.json`. Deleting that file revokes
26
- every device.
18
+ 1. Each Host generates a device identity once: an Ed25519 signing key and an X25519 agreement
19
+ key, stored as a credential-store record (a `0600` file only as a fallback). The fingerprint
20
+ is derived from the public key.
21
+ 2. Hosts announce themselves on UDP `discoveryPort` with device id, display name, and account id
22
+ (when signed in).
23
+ 3. To control a peer, this Host sends `POST /peer/hello` carrying its public key, fingerprint,
24
+ agreement key and a signed ephemeral key. The **target** shows a pending request in its own
25
+ Devices panel; nothing is granted until a person there presses **Allow** and the six-digit
26
+ short authentication string (SAS, derived from both fingerprints and the invite nonce) matches
27
+ on both screens.
28
+ 4. On approval the peer keys are pinned. Later calls carry an **Ed25519 signature** over method,
29
+ path, timestamp and a fresh nonce, with a five-minute skew window and a replay cache. The
30
+ legacy shared-secret HMAC is retired as soon as a key is pinned, because the approval response
31
+ that carried that secret crossed the relay in plaintext.
32
+ 5. Each approval records its scope list (`sessions:list`, `transcript:read`, `prompt:send`,
33
+ `stop`, `config:read`, `config:write`, `files:write`), the fingerprint, the agreement key and
34
+ the relay used. Scope edits and **Revoke** take effect immediately; revoking also drops the
35
+ cached keys.
36
+ 6. Approvals persist as credential-store grants; deleting the record revokes every device.
27
37
 
28
38
  There is deliberately no "same account implies trust" shortcut: two devices holding grants for
29
- the same account share no secret, so account id alone cannot authenticate a peer. The panel
30
- shows the account id of each candidate and flags a different account, but a person approves.
39
+ the same account share no secret, so account id alone cannot authenticate a peer. The panel shows
40
+ the account id of each candidate and flags a different account, but a person approves.
31
41
 
32
42
  ## Across networks (relay)
33
43
 
@@ -52,10 +62,32 @@ Host it on a VPS, NAS, or any always-on machine, put it behind TLS or a VPN, and
52
62
  `http://host:8787 <workspace key>` into every device.
53
63
 
54
64
  Either way the workspace key never leaves the devices: the relay only receives its SHA-256 as a
55
- group id. It forwards opaque `/peer/*` calls that stay signed end to end, so a compromised relay
56
- can drop or delay traffic but cannot forge a control command. Group membership alone grants
57
- nothing — the target still requires the one-time approval before any session call is accepted.
58
- Treat the relay as private: whoever holds the group id can join the group and *request* access.
65
+ group id. It forwards opaque `/peer/*` calls, and after pairing every message is sealed end to
66
+ end, so a compromised relay sees ciphertext and can only drop or delay traffic. Group membership
67
+ alone grants nothing — the target still requires the one-time approval before any session call is
68
+ accepted. Treat the relay as private: whoever holds the group id can join the group and *request*
69
+ access.
70
+
71
+ ### Encryption
72
+
73
+ Paired devices agree a session key with X25519 (ephemeral mixed with static when both sides
74
+ completed the pairing handshake) → HKDF-SHA256 → ChaCha20-Poly1305. The relay envelope seals every
75
+ message; on a direct LAN lane the request body and reply are sealed with a separate per-transport
76
+ key, and transcript frames with another. Plaintext exists only during bootstrap: the `hello`
77
+ request and the approval response that carries the peer's public keys.
78
+
79
+ A peer that pinned an identity but has no agreement key is **refused** over the relay
80
+ (`plaintext-refused`) rather than silently downgraded; `allowPlaintextFallback: true` opts out.
81
+ Peers approved before identity keys existed keep the legacy HMAC path until they are re-approved.
82
+
83
+ ### Lane ladder
84
+
85
+ Calls are attempted in order, each with its own budget: punched P2P (`p2pTimeoutMs`) → direct LAN
86
+ (`directTimeoutMs`) → relay. A blocked direct lane is parked per peer for `routeBlockMs` and then
87
+ retried as soon as fresh discovery evidence arrives. Latency-sensitive reads (`sessions`, session
88
+ config) race the relay against the direct lane so only the first call after a network change pays
89
+ a penalty; writes stay strictly ordered so a prompt can never run twice. `/state` reports `lane`,
90
+ `transport`, `timing` and `blocked` per peer.
59
91
 
60
92
  ## Configuration
61
93
 
@@ -78,6 +110,13 @@ Defaults work on a LAN. Override the row in `$DSH_HOME/profiles/<profile>/cordis
78
110
  cookieDays: 30
79
111
  defaultCwd: ''
80
112
  tls: { certFile: '', keyFile: '' }
113
+ directTimeoutMs: 1000 # budget for one direct LAN call before fallback
114
+ streamConnectTimeoutMs: 2500 # budget for direct stream headers before fallback
115
+ p2pTimeoutMs: 800 # budget for the punched lane per call
116
+ routeBlockMs: 60000 # how long a failed lane stays parked
117
+ punchPort: 57332 # UDP lane; also needs an inbound UDP firewall rule
118
+ stunHost: stun.l.google.com # or false to skip public mapping discovery
119
+ allowPlaintextFallback: false # refuse rather than downgrade a pinned peer
81
120
  ```
82
121
 
83
122
  The browser page and the peer API share the listener. When `host` is a wildcard, any IP literal
@@ -88,10 +127,20 @@ come from loopback.
88
127
  ## Threat model / limits
89
128
 
90
129
  - Approval is manual on purpose. A peer on the LAN can prompt for access; it cannot grant it.
130
+ Compare the six-digit SAS on both screens, and check the fingerprint when in doubt.
91
131
  - Prompts sent from a peer run with this Host's tools and permissions. Approve devices you
92
- control.
93
- - Peer transport is plain HTTP. The peer secret crosses the LAN once, at approval time. Use a
94
- private network or VPN, or set `tls` and front the port with a proxy.
132
+ control, and narrow scopes afterwards if a device only needs to read sessions.
133
+ - Transport is encrypted: sealed bodies/replies and transcript frames on a direct lane, an AEAD
134
+ envelope over the relay. The relay sees ids, timing and ciphertext.
135
+ - Legacy fallback: a peer approved before identity keys existed has no pinned key and still uses
136
+ the shared-secret HMAC in plaintext; a peer with a pinned identity but no agreement key is
137
+ refused over the relay unless `allowPlaintextFallback: true`. Re-approve to upgrade both.
138
+ - Forward secrecy is per session: ephemeral keys live in memory and are re-established by the
139
+ startup re-handshake, so a restart briefly degrades to static-static agreement.
140
+ - The construction is IK-shaped but **not Noise**: no protocol-name hash, no full handshake
141
+ transcript.
142
+ - Uploads are capped at 25 MB per file and 100 MB per session, with a MIME allowlist; bytes only
143
+ enter the Host attachment store, never a caller-supplied path.
95
144
  - The pairing code protects the browser page only; it is printed at activation and stored in
96
145
  `$DSH_HOME/remote-control.json`.
97
146
  - Approvals that arrive while no Devices panel is open stay pending for up to 10 minutes.
package/client.js CHANGED
@@ -126,6 +126,8 @@ window.__ModuleLoader__.load({
126
126
  const [live, setLive] = useState('')
127
127
  const [running, setRunning] = useState(false)
128
128
  const [draft, setDraft] = useState('')
129
+ const [pendingFiles, setPendingFiles] = useState([])
130
+ const [uploading, setUploading] = useState(null)
129
131
  const [error, setError] = useState(null)
130
132
  const [streamState, setStreamState] = useState('connecting')
131
133
  const [attempt, setAttempt] = useState(0)
@@ -197,18 +199,54 @@ window.__ModuleLoader__.load({
197
199
  if (node) node.scrollTop = node.scrollHeight
198
200
  }, [entries, live])
199
201
 
202
+ const addFiles = useCallback((list) => {
203
+ const files = Array.from(list ?? []).filter((file) => file && typeof file.size === 'number')
204
+ if (files.length > 0) setPendingFiles((current) => [...current, ...files])
205
+ }, [])
206
+
207
+ const dropFiles = useCallback((event) => {
208
+ event.preventDefault()
209
+ addFiles(event.dataTransfer?.files)
210
+ }, [addFiles])
211
+
200
212
  const send = useCallback(async () => {
201
213
  const text = draft.trim()
202
- if (!text || !peerId || !sessionId) return
214
+ const pending = pendingFiles
215
+ if ((!text && pending.length === 0) || !peerId || !sessionId) return
203
216
  setDraft('')
204
217
  setError(null)
205
218
  try {
206
- await api(`/remote-control/api/peer/${encodeURIComponent(peerId)}/prompt`, { sessionId, text })
219
+ const sent = []
220
+ for (const file of pending) {
221
+ setUploading({ name: file.name, progress: 0 })
222
+ const init = await api('/remote-control/api/upload/init', {
223
+ sessionId,
224
+ name: file.name || 'file',
225
+ size: file.size,
226
+ mediaType: file.type || 'application/octet-stream',
227
+ })
228
+ const uploadId = init?.uploadId
229
+ if (!uploadId) throw new Error('The device refused the upload.')
230
+ const chunkSize = 512 * 1024
231
+ for (let offset = 0, index = 0; offset < file.size; offset += chunkSize, index += 1) {
232
+ const bytes = new Uint8Array(await file.slice(offset, offset + chunkSize).arrayBuffer())
233
+ let binary = ''
234
+ for (let i = 0; i < bytes.length; i += 1) binary += String.fromCharCode(bytes[i])
235
+ await api('/remote-control/api/upload/chunk', { uploadId, index, data: btoa(binary) })
236
+ setUploading({ name: file.name, progress: Math.min(1, (offset + chunkSize) / Math.max(1, file.size)) })
237
+ }
238
+ const committed = await api('/remote-control/api/upload/commit', { uploadId })
239
+ if (committed?.attachment) sent.push(committed.attachment)
240
+ }
241
+ setUploading(null)
242
+ setPendingFiles([])
243
+ await api(`/remote-control/api/peer/${encodeURIComponent(peerId)}/prompt`, { sessionId, text, attachments: sent })
207
244
  } catch (failure) {
245
+ setUploading(null)
208
246
  setDraft(text)
209
247
  setError(failure.message)
210
248
  }
211
- }, [draft, peerId, sessionId])
249
+ }, [draft, pendingFiles, peerId, sessionId])
212
250
 
213
251
  const stop = useCallback(async () => {
214
252
  try {
@@ -330,7 +368,29 @@ window.__ModuleLoader__.load({
330
368
  live ? h('div', { style: { ...S.bubble, ...S.assistantBubble, opacity: 0.85 } }, live) : null,
331
369
  ),
332
370
  configBar,
333
- h('div', { style: S.composer },
371
+ h('div', { style: S.composer, onDragOver: (event) => event.preventDefault(), onDrop: dropFiles },
372
+ pendingFiles.length > 0 || uploading
373
+ ? h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center', marginBottom: 6 } },
374
+ pendingFiles.map((file, index) => h('span', {
375
+ key: `${file.name}-${index}`,
376
+ style: {
377
+ display: 'inline-flex', gap: 6, alignItems: 'center', padding: '2px 8px',
378
+ borderRadius: 999, background: 'rgba(127,127,127,0.18)', fontSize: 12,
379
+ },
380
+ },
381
+ `${file.name} · ${Math.max(1, Math.round(file.size / 1024))} KB`,
382
+ h('button', {
383
+ type: 'button',
384
+ title: 'Remove',
385
+ style: { border: 'none', background: 'transparent', cursor: 'pointer', color: 'inherit', padding: 0 },
386
+ onClick: () => setPendingFiles((current) => current.filter((_, position) => position !== index)),
387
+ }, '✕'),
388
+ )),
389
+ uploading
390
+ ? h('span', { style: { fontSize: 12, opacity: 0.8 } }, `Uploading ${uploading.name} ${Math.round(uploading.progress * 100)}%`)
391
+ : null,
392
+ )
393
+ : null,
334
394
  h('textarea', {
335
395
  style: S.input,
336
396
  rows: 1,
@@ -341,8 +401,22 @@ window.__ModuleLoader__.load({
341
401
  if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); send() }
342
402
  },
343
403
  }),
404
+ h('label', { style: { ...S.button, cursor: 'pointer' }, title: 'Attach files' },
405
+ 'Attach',
406
+ h('input', {
407
+ type: 'file',
408
+ multiple: true,
409
+ style: { display: 'none' },
410
+ onChange: (event) => { addFiles(event.target.files); event.target.value = '' },
411
+ }),
412
+ ),
344
413
  running ? h('button', { type: 'button', style: { ...S.button, ...S.danger }, onClick: stop }, 'Stop') : null,
345
- h('button', { type: 'button', style: { ...S.button, ...S.primary }, onClick: send, disabled: !draft.trim() }, 'Send'),
414
+ h('button', {
415
+ type: 'button',
416
+ style: { ...S.button, ...S.primary },
417
+ onClick: send,
418
+ disabled: uploading !== null || (!draft.trim() && pendingFiles.length === 0),
419
+ }, uploading ? 'Uploading…' : 'Send'),
346
420
  ),
347
421
  )
348
422
  }
@@ -401,6 +475,7 @@ window.__ModuleLoader__.load({
401
475
  const [relayOpen, setRelayOpen] = useState(false)
402
476
  const [relayUrl, setRelayUrl] = useState('')
403
477
  const [relayKey, setRelayKey] = useState('')
478
+ const [reachability, setReachability] = useState(null)
404
479
 
405
480
  const refresh = useCallback(async () => {
406
481
  try {
@@ -440,6 +515,18 @@ window.__ModuleLoader__.load({
440
515
  refresh()
441
516
  }, [refresh])
442
517
 
518
+ const revoke = useCallback(async (deviceId) => {
519
+ setBusy(deviceId)
520
+ try {
521
+ await api('/remote-control/api/revoke', { deviceId })
522
+ setOpenPeer(null)
523
+ } catch (failure) {
524
+ setError(failure.message)
525
+ }
526
+ setBusy(null)
527
+ refresh()
528
+ }, [refresh])
529
+
443
530
  const open = useCallback(async (peer) => {
444
531
  setOpenPeer(peer)
445
532
  setSessions(null)
@@ -477,7 +564,8 @@ window.__ModuleLoader__.load({
477
564
  setBusy('relay')
478
565
  setError(null)
479
566
  try {
480
- await api('/remote-control/api/relay/host', { enabled: true })
567
+ const value = await api('/remote-control/api/relay/host', { enabled: true })
568
+ if (value?.reachability) setReachability(value.reachability)
481
569
  } catch (failure) {
482
570
  setError(failure.message)
483
571
  }
@@ -488,10 +576,23 @@ window.__ModuleLoader__.load({
488
576
  const stopHosting = useCallback(async () => {
489
577
  setBusy('relay')
490
578
  try { await api('/remote-control/api/relay/host', { enabled: false }) } catch (failure) { setError(failure.message) }
579
+ setReachability(null)
491
580
  setBusy(null)
492
581
  refresh()
493
582
  }, [refresh])
494
583
 
584
+ const checkReachability = useCallback(async () => {
585
+ setBusy('reach')
586
+ setError(null)
587
+ try {
588
+ const value = await api('/remote-control/api/relay/reachability')
589
+ setReachability(value)
590
+ } catch (failure) {
591
+ setError(failure.message)
592
+ }
593
+ setBusy(null)
594
+ }, [])
595
+
495
596
  const clearRelay = useCallback(async () => {
496
597
  setBusy('relay')
497
598
  try { await api('/remote-control/api/relay/clear', {}) } catch (failure) { setError(failure.message) }
@@ -526,6 +627,13 @@ window.__ModuleLoader__.load({
526
627
  ? h('div', null,
527
628
  h('div', { style: { ...S.meta, marginBottom: '6px' } },
528
629
  'This device is hosting the relay. Paste this invite into the other device’s Relay panel.'),
630
+ relay.invite
631
+ ? h('img', {
632
+ alt: 'Invite QR code',
633
+ src: `/remote-control/api/qr?text=${encodeURIComponent(relay.invite)}`,
634
+ style: { width: '184px', height: '184px', marginBottom: '10px', background: '#fff', borderRadius: '8px', padding: '6px' },
635
+ })
636
+ : null,
529
637
  h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' } },
530
638
  h('input', {
531
639
  style: { ...S.field, flex: 1, minWidth: '260px' },
@@ -542,6 +650,20 @@ window.__ModuleLoader__.load({
542
650
  ),
543
651
  h('div', { style: { ...S.meta, marginTop: '6px' } },
544
652
  `Reachable at ${(relay.urls || []).join(', ') || 'this machine'} — forward that port (or set publicUrl) so other networks can reach it.`),
653
+ h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', marginTop: '8px' } },
654
+ h('button', {
655
+ type: 'button',
656
+ style: S.button,
657
+ disabled: busy === 'reach',
658
+ onClick: checkReachability,
659
+ }, 'Check reachability'),
660
+ reachability
661
+ ? h('span', { style: { ...S.meta, flex: 1 } },
662
+ reachability.ok
663
+ ? `Reachable at ${reachability.url}${reachability.upnp?.mapped ? ' (UPnP mapping)' : ''}`
664
+ : `Not reachable${reachability.upnp?.available === false ? ' — no UPnP gateway on this network' : reachability.hint ? ` — ${reachability.hint}` : ''}`)
665
+ : null,
666
+ ),
545
667
  )
546
668
  : h('div', null,
547
669
  h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' } },
@@ -580,6 +702,13 @@ window.__ModuleLoader__.load({
580
702
  h('div', { style: { marginBottom: '8px' } },
581
703
  h('strong', null, request.deviceName),
582
704
  ` wants to control this computer (account ${shortAccount(request.userId)}).`),
705
+ h('div', { style: { ...S.meta, marginBottom: '8px' } },
706
+ [
707
+ request.sas ? `SAS ${request.sas}` : '',
708
+ request.fingerprint ? `key ${String(request.fingerprint).slice(0, 12)}` : '',
709
+ request.relay ? `via ${request.relay}` : '',
710
+ request.relayChanged ? 'relay changed since the last approval' : '',
711
+ ].filter(Boolean).join(' · ')),
583
712
  h('div', { style: { display: 'flex', gap: '8px' } },
584
713
  h('button', {
585
714
  type: 'button', style: { ...S.button, ...S.primary }, disabled: busy === request.requestId,
@@ -613,6 +742,32 @@ window.__ModuleLoader__.load({
613
742
  ),
614
743
  peer.approved
615
744
  ? h('span', { style: { ...S.chip, ...S.chipOk } }, 'Trusted')
745
+ : null,
746
+ peer.approved
747
+ ? h('span', {
748
+ style: { ...S.chip, ...(peer.forwardSecrecy ? S.chipOk : S.chipWarn) },
749
+ title: peer.forwardSecrecy ? 'Ephemeral channel established' : 'Only static agreement (restart or re-approve to refresh)',
750
+ }, peer.forwardSecrecy ? 'E2E' : 'static')
751
+ : null,
752
+ peer.approved && peer.fingerprint
753
+ ? h('span', { style: S.meta, title: `fingerprint ${peer.fingerprint}` }, String(peer.fingerprint).slice(0, 8))
754
+ : null,
755
+ peer.approved && Array.isArray(peer.scopes)
756
+ ? h('span', { style: S.meta, title: peer.scopes.join(', ') }, `${peer.scopes.length} scopes`)
757
+ : null,
758
+ peer.approved && peer.needsReapproval
759
+ ? h('span', {
760
+ style: { ...S.chip, ...S.chipBad },
761
+ title: 'This approval has no agreement key; approve again to enable encryption',
762
+ }, 'Re-approve')
763
+ : null,
764
+ peer.approved
765
+ ? h('button', {
766
+ type: 'button',
767
+ style: S.button,
768
+ disabled: busy === peer.deviceId,
769
+ onClick: (event) => { event.stopPropagation(); revoke(peer.deviceId) },
770
+ }, 'Revoke')
616
771
  : peer.connecting
617
772
  ? h('span', { style: { ...S.chip, ...S.chipWarn } }, 'Waiting')
618
773
  : h('button', {
@@ -663,6 +818,8 @@ window.__ModuleLoader__.load({
663
818
 
664
819
  return {
665
820
  inject: ['slots'],
821
+ // Exposed for the headless render test (scripts/client-render-test.mjs).
822
+ __components: { Icon, DevicesPage, Chat },
666
823
  apply(ctx) {
667
824
  ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
668
825
  name: 'sidebar.panellist', id: 'devices', order: 20, label: () => 'Devices',
package/host/crypto.js ADDED
@@ -0,0 +1,145 @@
1
+ import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from 'node:crypto'
2
+
3
+ /**
4
+ * Random-nonce AEAD for unreliable transports: a duplicated or reordered
5
+ * datagram is harmless because each packet carries its own nonce, and plaintext
6
+ * replay is stopped at the request layer by the signed timestamp + nonce.
7
+ */
8
+ export function createBoxCipher(key) {
9
+ const TAG = 16
10
+ return {
11
+ seal(plaintext) {
12
+ const nonce = randomBytes(12)
13
+ const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: TAG })
14
+ const body = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()])
15
+ return Buffer.concat([nonce, body, cipher.getAuthTag()]).toString('base64')
16
+ },
17
+ open(packet) {
18
+ const raw = Buffer.from(String(packet), 'base64')
19
+ if (raw.length < 12 + TAG) throw new Error('box: short packet')
20
+ const nonce = raw.subarray(0, 12)
21
+ const tag = raw.subarray(raw.length - TAG)
22
+ const body = raw.subarray(12, raw.length - TAG)
23
+ const decipher = createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: TAG })
24
+ decipher.setAuthTag(tag)
25
+ return Buffer.concat([decipher.update(body), decipher.final()])
26
+ },
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Peer channel crypto (phase A2).
32
+ *
33
+ * Static-static X25519 agreement per device pair, HKDF-SHA256 to a 32-byte
34
+ * session key, then ChaCha20-Poly1305 with a per-direction counter nonce:
35
+ *
36
+ * nonce = 1 byte direction | 11 byte big-endian counter
37
+ *
38
+ * The receiver demands the exact next nonce, so a replayed, reordered or
39
+ * forked packet fails authentication instead of decrypting. AAD is empty
40
+ * because the nonce already binds direction and position; the ciphertext is
41
+ * authenticated over its full length.
42
+ *
43
+ * Known limitation: static keys give no forward secrecy. Replacing this with a
44
+ * Noise-IK-style ephemeral handshake is the next step; the wire format already
45
+ * carries a version tag so both can coexist.
46
+ */
47
+
48
+ export const CHANNEL_VERSION = 1
49
+
50
+ /**
51
+ * Ephemeral key agreement for forward secrecy.
52
+ *
53
+ * The session key mixes one ephemeral DH (fresh per pairing) with one static DH
54
+ * that authenticates the peer's long-term key. The ephemeral private key is
55
+ * dropped when the pairing context is disposed, so a later compromise of the
56
+ * long-term keys cannot reconstruct past session keys.
57
+ *
58
+ * This is IK-shaped but NOT a Noise implementation: no protocol name hash, no
59
+ * handshake transcript beyond the fingerprint salt. Do not describe it as Noise.
60
+ */
61
+ export function generateEphemeralKeypair() {
62
+ return generateKeyPairSync('x25519')
63
+ }
64
+
65
+ export function ephemeralPublicPem(pair) {
66
+ return pair.publicKey.export({ type: 'spki', format: 'pem' })
67
+ }
68
+
69
+ export function deriveEphemeralKey({
70
+ ephemeralPrivate,
71
+ peerEphemeralPublicPem,
72
+ staticPrivate,
73
+ peerStaticPublicPem,
74
+ myFingerprint,
75
+ peerFingerprint,
76
+ label = 'channel',
77
+ }) {
78
+ const ephemeralShared = diffieHellman({
79
+ privateKey: ephemeralPrivate,
80
+ publicKey: createPublicKey(peerEphemeralPublicPem),
81
+ })
82
+ const staticShared = diffieHellman({
83
+ privateKey: staticPrivate,
84
+ publicKey: createPublicKey(peerStaticPublicPem),
85
+ })
86
+ const salt = Buffer.from([myFingerprint, peerFingerprint].sort().join(':'), 'utf8')
87
+ const info = Buffer.from(`dsh-remote-control/${label}-fs/v${CHANNEL_VERSION}`, 'utf8')
88
+ return Buffer.from(hkdfSync('sha256', Buffer.concat([ephemeralShared, staticShared]), salt, info, 32))
89
+ }
90
+
91
+ export function deriveSessionKey({ privateKey, peerPublicKeyPem, myFingerprint, peerFingerprint, label = 'channel' }) {
92
+ const peerPublicKey = createPublicKey(peerPublicKeyPem)
93
+ const shared = diffieHellman({ privateKey, publicKey: peerPublicKey })
94
+ const salt = Buffer.from([myFingerprint, peerFingerprint].sort().join(':'), 'utf8')
95
+ const info = Buffer.from(`dsh-remote-control/${label}/v${CHANNEL_VERSION}`, 'utf8')
96
+ return Buffer.from(hkdfSync('sha256', shared, salt, info, 32))
97
+ }
98
+
99
+ function nonceFor(direction, counter) {
100
+ const nonce = Buffer.alloc(12)
101
+ nonce[0] = direction
102
+ nonce.writeBigUInt64BE(BigInt(counter), 4)
103
+ return nonce
104
+ }
105
+
106
+ /**
107
+ * One direction-aware AEAD pair for a peer session. `side` fixes which
108
+ * direction byte this endpoint sends with, so both peers agree without
109
+ * negotiating it on the wire.
110
+ */
111
+ export function createCipherPair(key, side) {
112
+ const sendDirection = side === 'a' ? 1 : 2
113
+ const receiveDirection = side === 'a' ? 2 : 1
114
+ let sendCounter = 0
115
+ let lastReceived = -1n
116
+
117
+ return {
118
+ seal(plaintext) {
119
+ const nonce = nonceFor(sendDirection, sendCounter)
120
+ const cipher = createCipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 })
121
+ const body = Buffer.concat([cipher.update(Buffer.from(plaintext)), cipher.final()])
122
+ sendCounter += 1
123
+ return Buffer.concat([nonce, cipher.getAuthTag(), body]).toString('base64')
124
+ },
125
+ open(packet) {
126
+ const raw = Buffer.from(String(packet), 'base64')
127
+ if (raw.length < 28) throw new Error('channel: short packet')
128
+ const nonce = raw.subarray(0, 12)
129
+ if (nonce[0] !== receiveDirection) throw new Error('channel: wrong direction')
130
+ const counter = nonce.readBigUInt64BE(4)
131
+ // Monotonic acceptance: a replayed or older packet is refused, while a
132
+ // gap left by a dropped message does not wedge the channel forever.
133
+ if (counter <= lastReceived) throw new Error('channel: replay')
134
+ const tag = raw.subarray(12, 28)
135
+ const body = raw.subarray(28)
136
+ const decipher = createDecipheriv('chacha20-poly1305', key, nonce, { authTagLength: 16 })
137
+ decipher.setAuthTag(tag)
138
+ const plain = Buffer.concat([decipher.update(body), decipher.final()])
139
+ lastReceived = counter
140
+ return plain
141
+ },
142
+ stats: () => ({ sent: sendCounter, received: lastReceived >= 0n ? Number(lastReceived) + 1 : 0 }),
143
+ reset() { sendCounter = 0; lastReceived = -1n },
144
+ }
145
+ }