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 +5 -4
- package/README.md +67 -18
- package/client.js +163 -6
- package/host/crypto.js +145 -0
- package/host/fabric.js +923 -49
- package/host/handshake.js +39 -0
- package/host/identity.js +112 -0
- package/host/lane.js +10 -0
- package/host/nat.js +113 -0
- package/host/p2p.js +169 -0
- package/host/punch.js +69 -0
- package/host/qr.js +212 -0
- package/host/reachability.js +252 -0
- package/host/relay-core.js +2 -1
- package/host/relay-link.js +33 -10
- package/host/udp-channel.js +176 -0
- package/host/uploads.js +171 -0
- package/index.js +212 -30
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
nothing — the target still requires the one-time approval before any session call is
|
|
58
|
-
Treat the relay as private: whoever holds the group id can join the group and *request*
|
|
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
|
-
-
|
|
94
|
-
|
|
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
|
-
|
|
214
|
+
const pending = pendingFiles
|
|
215
|
+
if ((!text && pending.length === 0) || !peerId || !sessionId) return
|
|
203
216
|
setDraft('')
|
|
204
217
|
setError(null)
|
|
205
218
|
try {
|
|
206
|
-
|
|
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', {
|
|
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
|
+
}
|