dsh-remote-control-plugin 0.3.6 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL.md +5 -4
- package/client.js +125 -6
- package/host/crypto.js +145 -0
- package/host/fabric.js +711 -40
- 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 +170 -0
- package/index.js +205 -28
- 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.0`, 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.0` 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.0 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/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 {
|
|
@@ -477,7 +552,8 @@ window.__ModuleLoader__.load({
|
|
|
477
552
|
setBusy('relay')
|
|
478
553
|
setError(null)
|
|
479
554
|
try {
|
|
480
|
-
await api('/remote-control/api/relay/host', { enabled: true })
|
|
555
|
+
const value = await api('/remote-control/api/relay/host', { enabled: true })
|
|
556
|
+
if (value?.reachability) setReachability(value.reachability)
|
|
481
557
|
} catch (failure) {
|
|
482
558
|
setError(failure.message)
|
|
483
559
|
}
|
|
@@ -488,10 +564,23 @@ window.__ModuleLoader__.load({
|
|
|
488
564
|
const stopHosting = useCallback(async () => {
|
|
489
565
|
setBusy('relay')
|
|
490
566
|
try { await api('/remote-control/api/relay/host', { enabled: false }) } catch (failure) { setError(failure.message) }
|
|
567
|
+
setReachability(null)
|
|
491
568
|
setBusy(null)
|
|
492
569
|
refresh()
|
|
493
570
|
}, [refresh])
|
|
494
571
|
|
|
572
|
+
const checkReachability = useCallback(async () => {
|
|
573
|
+
setBusy('reach')
|
|
574
|
+
setError(null)
|
|
575
|
+
try {
|
|
576
|
+
const value = await api('/remote-control/api/relay/reachability')
|
|
577
|
+
setReachability(value)
|
|
578
|
+
} catch (failure) {
|
|
579
|
+
setError(failure.message)
|
|
580
|
+
}
|
|
581
|
+
setBusy(null)
|
|
582
|
+
}, [])
|
|
583
|
+
|
|
495
584
|
const clearRelay = useCallback(async () => {
|
|
496
585
|
setBusy('relay')
|
|
497
586
|
try { await api('/remote-control/api/relay/clear', {}) } catch (failure) { setError(failure.message) }
|
|
@@ -526,6 +615,13 @@ window.__ModuleLoader__.load({
|
|
|
526
615
|
? h('div', null,
|
|
527
616
|
h('div', { style: { ...S.meta, marginBottom: '6px' } },
|
|
528
617
|
'This device is hosting the relay. Paste this invite into the other device’s Relay panel.'),
|
|
618
|
+
relay.invite
|
|
619
|
+
? h('img', {
|
|
620
|
+
alt: 'Invite QR code',
|
|
621
|
+
src: `/remote-control/api/qr?text=${encodeURIComponent(relay.invite)}`,
|
|
622
|
+
style: { width: '184px', height: '184px', marginBottom: '10px', background: '#fff', borderRadius: '8px', padding: '6px' },
|
|
623
|
+
})
|
|
624
|
+
: null,
|
|
529
625
|
h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' } },
|
|
530
626
|
h('input', {
|
|
531
627
|
style: { ...S.field, flex: 1, minWidth: '260px' },
|
|
@@ -542,6 +638,20 @@ window.__ModuleLoader__.load({
|
|
|
542
638
|
),
|
|
543
639
|
h('div', { style: { ...S.meta, marginTop: '6px' } },
|
|
544
640
|
`Reachable at ${(relay.urls || []).join(', ') || 'this machine'} — forward that port (or set publicUrl) so other networks can reach it.`),
|
|
641
|
+
h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center', marginTop: '8px' } },
|
|
642
|
+
h('button', {
|
|
643
|
+
type: 'button',
|
|
644
|
+
style: S.button,
|
|
645
|
+
disabled: busy === 'reach',
|
|
646
|
+
onClick: checkReachability,
|
|
647
|
+
}, 'Check reachability'),
|
|
648
|
+
reachability
|
|
649
|
+
? h('span', { style: { ...S.meta, flex: 1 } },
|
|
650
|
+
reachability.ok
|
|
651
|
+
? `Reachable at ${reachability.url}${reachability.upnp?.mapped ? ' (UPnP mapping)' : ''}`
|
|
652
|
+
: `Not reachable${reachability.upnp?.available === false ? ' — no UPnP gateway on this network' : reachability.hint ? ` — ${reachability.hint}` : ''}`)
|
|
653
|
+
: null,
|
|
654
|
+
),
|
|
545
655
|
)
|
|
546
656
|
: h('div', null,
|
|
547
657
|
h('div', { style: { display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' } },
|
|
@@ -580,6 +690,13 @@ window.__ModuleLoader__.load({
|
|
|
580
690
|
h('div', { style: { marginBottom: '8px' } },
|
|
581
691
|
h('strong', null, request.deviceName),
|
|
582
692
|
` wants to control this computer (account ${shortAccount(request.userId)}).`),
|
|
693
|
+
h('div', { style: { ...S.meta, marginBottom: '8px' } },
|
|
694
|
+
[
|
|
695
|
+
request.sas ? `SAS ${request.sas}` : '',
|
|
696
|
+
request.fingerprint ? `key ${String(request.fingerprint).slice(0, 12)}` : '',
|
|
697
|
+
request.relay ? `via ${request.relay}` : '',
|
|
698
|
+
request.relayChanged ? 'relay changed since the last approval' : '',
|
|
699
|
+
].filter(Boolean).join(' · ')),
|
|
583
700
|
h('div', { style: { display: 'flex', gap: '8px' } },
|
|
584
701
|
h('button', {
|
|
585
702
|
type: 'button', style: { ...S.button, ...S.primary }, disabled: busy === request.requestId,
|
|
@@ -663,6 +780,8 @@ window.__ModuleLoader__.load({
|
|
|
663
780
|
|
|
664
781
|
return {
|
|
665
782
|
inject: ['slots'],
|
|
783
|
+
// Exposed for the headless render test (scripts/client-render-test.mjs).
|
|
784
|
+
__components: { Icon, DevicesPage, Chat },
|
|
666
785
|
apply(ctx) {
|
|
667
786
|
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
668
787
|
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
|
+
}
|