dsh-remote-control-plugin 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,39 @@
1
+ import { signPayload, verifyPayload } from './identity.js'
2
+
3
+ /**
4
+ * Pairing handshake messages for forward secrecy.
5
+ *
6
+ * Each side mints a fresh X25519 ephemeral key for one pairing attempt and
7
+ * signs the public half together with the device id and the invite nonce:
8
+ *
9
+ * payload = `${deviceId}\n${pairCode}\n${ephemeralPublicPem}`
10
+ *
11
+ * The signature proves the ephemeral belongs to the identity that was approved,
12
+ * so a relay cannot substitute its own ephemeral key. The private half never
13
+ * leaves the process and is dropped once both lanes are derived.
14
+ *
15
+ * This is a message format, not a protocol: ordering and retry live in hello.
16
+ */
17
+
18
+ export function ephemeralPayload(deviceId, pairCode, publicPem) {
19
+ return `${deviceId}\n${pairCode}\n${publicPem}`
20
+ }
21
+
22
+ export function mintEphemeral(identity, deviceId, pairCode, keypair) {
23
+ const publicPem = keypair.publicKey.export({ type: 'spki', format: 'pem' })
24
+ return {
25
+ publicPem,
26
+ signature: signPayload(identity.ed25519.privateKey, ephemeralPayload(deviceId, pairCode, publicPem)),
27
+ }
28
+ }
29
+
30
+ export function verifyEphemeral(identityPublicKeyPem, deviceId, pairCode, message) {
31
+ if (!identityPublicKeyPem || !message || typeof message.publicPem !== 'string' || typeof message.signature !== 'string') {
32
+ return false
33
+ }
34
+ return verifyPayload(
35
+ identityPublicKeyPem,
36
+ ephemeralPayload(deviceId, pairCode, message.publicPem),
37
+ message.signature,
38
+ )
39
+ }
@@ -0,0 +1,112 @@
1
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto'
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
+ import path from 'node:path'
4
+
5
+ /**
6
+ * Device identity: one Ed25519 signing key and one X25519 key-agreement key per
7
+ * install, generated once and persisted owner-only.
8
+ *
9
+ * The Ed25519 key is the device's long-term identity: its fingerprint goes into
10
+ * invites and approval records, and peer calls are signed with it. The X25519
11
+ * key is reserved for the encrypted channel (phase A2); it is stored now so a
12
+ * device never has to rotate identity to gain encryption.
13
+ *
14
+ * The short authentication string (SAS) binds two fingerprints and the invite
15
+ * nonce, so both screens show the same six digits only when both devices hold
16
+ * the keys they claim.
17
+ */
18
+
19
+ export function fingerprintOf(publicKeyPem) {
20
+ const key = createPublicKey(publicKeyPem)
21
+ const der = key.export({ type: 'spki', format: 'der' })
22
+ return createHash('sha256').update(der).digest('hex').slice(0, 32)
23
+ }
24
+
25
+ export function sasCode(fingerprintA, fingerprintB, nonce) {
26
+ const ordered = [fingerprintA, fingerprintB].sort().join(':')
27
+ const digest = createHash('sha256').update(`${ordered}:${nonce}`).digest()
28
+ const value = digest.readUInt32BE(0) % 1_000_000
29
+ return String(value).padStart(6, '0')
30
+ }
31
+
32
+ export function signPayload(privateKey, payload) {
33
+ return sign(null, Buffer.from(payload), privateKey).toString('base64url')
34
+ }
35
+
36
+ export function verifyPayload(publicKeyPem, payload, signature) {
37
+ try {
38
+ return verify(null, Buffer.from(payload), createPublicKey(publicKeyPem), Buffer.from(signature, 'base64url'))
39
+ } catch {
40
+ return false
41
+ }
42
+ }
43
+
44
+ export async function loadOrCreateIdentity(file, log = console, store) {
45
+ let stored
46
+ if (store) {
47
+ try {
48
+ stored = await store.load()
49
+ } catch (error) {
50
+ log.warn?.(`[remote-control] could not read the identity record: ${error?.message || error}`)
51
+ }
52
+ }
53
+ if (!stored) {
54
+ try {
55
+ stored = JSON.parse(await readFile(file, 'utf8'))
56
+ } catch (error) {
57
+ if (error?.code !== 'ENOENT') log.warn?.(`[remote-control] could not read identity: ${error?.message || error}`)
58
+ }
59
+ }
60
+
61
+ if (stored?.ed25519?.privateKey && stored?.ed25519?.publicKey) {
62
+ try {
63
+ const ed25519 = {
64
+ publicKey: createPublicKey(stored.ed25519.publicKey),
65
+ privateKey: createPrivateKey(stored.ed25519.privateKey),
66
+ publicKeyPem: stored.ed25519.publicKey,
67
+ }
68
+ let x25519 = null
69
+ if (stored?.x25519?.privateKey) {
70
+ x25519 = {
71
+ publicKey: createPublicKey(stored.x25519.publicKey),
72
+ privateKey: createPrivateKey(stored.x25519.privateKey),
73
+ publicKeyPem: stored.x25519.publicKey,
74
+ }
75
+ }
76
+ return { ed25519, x25519, fingerprint: fingerprintOf(ed25519.publicKeyPem), createdAt: stored.createdAt ?? Date.now(), fresh: false }
77
+ } catch {
78
+ log.warn?.('[remote-control] stored identity is unusable; generating a new one')
79
+ }
80
+ }
81
+
82
+ const ed = generateKeyPairSync('ed25519')
83
+ const x = generateKeyPairSync('x25519')
84
+ const record = {
85
+ createdAt: Date.now(),
86
+ ed25519: {
87
+ publicKey: ed.publicKey.export({ type: 'spki', format: 'pem' }),
88
+ privateKey: ed.privateKey.export({ type: 'pkcs8', format: 'pem' }),
89
+ },
90
+ x25519: {
91
+ publicKey: x.publicKey.export({ type: 'spki', format: 'pem' }),
92
+ privateKey: x.privateKey.export({ type: 'pkcs8', format: 'pem' }),
93
+ },
94
+ }
95
+ try {
96
+ if (store) {
97
+ await store.save(record)
98
+ } else {
99
+ await mkdir(path.dirname(file), { recursive: true })
100
+ await writeFile(file, JSON.stringify(record, null, 2), { mode: 0o600 })
101
+ }
102
+ } catch (error) {
103
+ log.warn?.(`[remote-control] could not persist identity: ${error?.message || error}`)
104
+ }
105
+ return {
106
+ ed25519: { publicKey: ed.publicKey, privateKey: ed.privateKey, publicKeyPem: record.ed25519.publicKey },
107
+ x25519: { publicKey: x.publicKey, privateKey: x.privateKey, publicKeyPem: record.x25519.publicKey },
108
+ fingerprint: fingerprintOf(record.ed25519.publicKey),
109
+ createdAt: record.createdAt,
110
+ fresh: true,
111
+ }
112
+ }
package/host/lane.js ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Lane preference: a same-LAN peer always wins, then a punched P2P path, then
3
+ * the relay. Kept pure so the ordering is testable without any network.
4
+ */
5
+ export function selectLane({ sameLan = false, punched = false, relayAvailable = false } = {}) {
6
+ if (sameLan) return 'lan'
7
+ if (punched) return 'p2p'
8
+ if (relayAvailable) return 'relay'
9
+ return 'none'
10
+ }
package/host/nat.js ADDED
@@ -0,0 +1,113 @@
1
+ import dgram from 'node:dgram'
2
+ import { randomBytes } from 'node:crypto'
3
+ import os from 'node:os'
4
+
5
+ /**
6
+ * Phase B building blocks: IPv6-first candidate discovery and a minimal STUN
7
+ * binding client.
8
+ *
9
+ * Only this half is verifiable on the current network (which has no IPv6 and no
10
+ * second NAT): the binding request/response codec and candidate filtering are
11
+ * exercised against an in-process STUN responder. Hole punching itself needs
12
+ * two real NATs and is not attempted here.
13
+ */
14
+
15
+ const MAGIC = 0x2112a442
16
+ const BINDING_REQUEST = 0x0001
17
+ const BINDING_SUCCESS = 0x0101
18
+ const XOR_MAPPED_ADDRESS = 0x0020
19
+
20
+ export function buildBindingRequest(transactionId = randomBytes(12)) {
21
+ const buffer = Buffer.alloc(20)
22
+ buffer.writeUInt16BE(BINDING_REQUEST, 0)
23
+ buffer.writeUInt16BE(0, 2)
24
+ buffer.writeUInt32BE(MAGIC, 4)
25
+ transactionId.copy(buffer, 8, 0, 12)
26
+ return buffer
27
+ }
28
+
29
+ export function parseBindingResponse(buffer) {
30
+ if (!Buffer.isBuffer(buffer) || buffer.length < 20) return undefined
31
+ if (buffer.readUInt16BE(0) !== BINDING_SUCCESS) return undefined
32
+ if (buffer.readUInt32BE(4) !== MAGIC) return undefined
33
+ const length = buffer.readUInt16BE(2)
34
+ if (20 + length > buffer.length) return undefined
35
+ let offset = 20
36
+ const end = 20 + length
37
+ while (offset + 4 <= end) {
38
+ const type = buffer.readUInt16BE(offset)
39
+ const size = buffer.readUInt16BE(offset + 2)
40
+ const valueStart = offset + 4
41
+ if (valueStart + size > end) return undefined
42
+ if (type === XOR_MAPPED_ADDRESS && size >= 8) {
43
+ const family = buffer[valueStart + 1]
44
+ const port = buffer.readUInt16BE(valueStart + 2) ^ (MAGIC >>> 16)
45
+ if (family === 0x01) {
46
+ const raw = buffer.readUInt32BE(valueStart + 4)
47
+ const address = (raw ^ MAGIC) >>> 0
48
+ return {
49
+ family: 'IPv4',
50
+ address: `${(address >>> 24) & 0xff}.${(address >>> 16) & 0xff}.${(address >>> 8) & 0xff}.${address & 0xff}`,
51
+ port,
52
+ }
53
+ }
54
+ if (family === 0x02 && size >= 20) {
55
+ const raw = buffer.subarray(valueStart + 4, valueStart + 20)
56
+ const mask = Buffer.alloc(16)
57
+ mask.writeUInt32BE(MAGIC, 0)
58
+ buffer.copy(mask, 4, 4, 20) // transaction id
59
+ const address = Buffer.from(raw.map((byte, index) => byte ^ mask[index]))
60
+ const groups = []
61
+ for (let i = 0; i < 16; i += 2) groups.push(address.readUInt16BE(i).toString(16))
62
+ return { family: 'IPv6', address: groups.join(':'), port }
63
+ }
64
+ }
65
+ offset = valueStart + size + ((4 - (size % 4)) % 4)
66
+ }
67
+ return undefined
68
+ }
69
+
70
+ export function stunBinding({ host = 'stun.l.google.com', port = 19302, timeoutMs = 2000 } = {}) {
71
+ return new Promise((resolve) => {
72
+ const transactionId = randomBytes(12)
73
+ const request = buildBindingRequest(transactionId)
74
+ const socket = dgram.createSocket('udp4')
75
+ let settled = false
76
+ const finish = (value) => {
77
+ if (settled) return
78
+ settled = true
79
+ clearTimeout(timer)
80
+ try { socket.close() } catch { /* ignore */ }
81
+ resolve(value)
82
+ }
83
+ const timer = setTimeout(() => finish(undefined), timeoutMs)
84
+ socket.on('error', () => finish(undefined))
85
+ socket.on('message', (message) => {
86
+ if (message.length >= 20 && message.readUInt32BE(4) === MAGIC && message.subarray(8, 20).equals(transactionId)) {
87
+ finish(parseBindingResponse(message))
88
+ }
89
+ })
90
+ socket.send(request, 0, request.length, port, host, (error) => {
91
+ if (error) finish(undefined)
92
+ })
93
+ })
94
+ }
95
+
96
+ /** IPv6 candidates first, then IPv4; loopback and link-local are excluded. */
97
+ export function localCandidates(interfaces = os.networkInterfaces()) {
98
+ const ipv6 = []
99
+ const ipv4 = []
100
+ for (const entries of Object.values(interfaces ?? {})) {
101
+ for (const entry of entries ?? []) {
102
+ if (!entry || entry.internal) continue
103
+ if (entry.family === 'IPv6' || entry.family === 6) {
104
+ if (entry.address.startsWith('fe80') || entry.address === '::1') continue
105
+ ipv6.push({ family: 'IPv6', address: entry.address })
106
+ } else if (entry.family === 'IPv4' || entry.family === 4) {
107
+ if (entry.address.startsWith('127.')) continue
108
+ ipv4.push({ family: 'IPv4', address: entry.address })
109
+ }
110
+ }
111
+ }
112
+ return [...ipv6, ...ipv4]
113
+ }
package/host/p2p.js ADDED
@@ -0,0 +1,169 @@
1
+ import http from 'node:http'
2
+ import { createBoxCipher } from './crypto.js'
3
+ import { createUdpChannel } from './udp-channel.js'
4
+
5
+ /**
6
+ * P2P proxy for the punched lane: carries HTTP-shaped peer calls and SSE frames
7
+ * over the UDP channel between two DSH instances and replays them into the
8
+ * local listener, so authentication, sealing and scope checks stay in one place.
9
+ *
10
+ * Unary calls use request/response (ACKed). Stream frames are one-way datagrams:
11
+ * a lost frame is acceptable because the transcript resynchronizes from the next
12
+ * snapshot, and retransmitting ordered frames over an unreliable transport would
13
+ * need a much larger protocol than this lane is worth.
14
+ */
15
+
16
+ export async function createP2pNode({ localPort, bindPort = 0, keyFor, selfDeviceId, log = console }) {
17
+ const streams = new Map() // streamId -> { onChunk, onEnd }
18
+ let nextStreamId = 1
19
+
20
+ function forward(inner) {
21
+ return new Promise((resolve) => {
22
+ const text = typeof inner?.bodyText === 'string' ? inner.bodyText : ''
23
+ const request = http.request({
24
+ host: '127.0.0.1',
25
+ port: localPort,
26
+ path: typeof inner?.path === 'string' ? inner.path : '/',
27
+ method: inner?.method || 'GET',
28
+ headers: {
29
+ ...(inner?.headers ?? {}),
30
+ host: `127.0.0.1:${localPort}`,
31
+ 'x-dsh-via': 'p2p',
32
+ 'content-length': Buffer.byteLength(text),
33
+ },
34
+ timeout: 10000,
35
+ }, (response) => {
36
+ let data = ''
37
+ response.on('data', (chunk) => { data += chunk })
38
+ response.on('end', () => resolve({ status: response.statusCode ?? 502, bodyText: data }))
39
+ })
40
+ request.on('timeout', () => request.destroy(new Error('timeout')))
41
+ request.on('error', () => resolve({ status: 502, bodyText: '' }))
42
+ request.end(text || undefined)
43
+ })
44
+ }
45
+
46
+ /** Forward one SSE stream to a peer as datagrams. */
47
+ function relayStream(remote, box, inner, streamId) {
48
+ const request = http.request({
49
+ host: '127.0.0.1',
50
+ port: localPort,
51
+ path: typeof inner?.path === 'string' ? inner.path : '/',
52
+ method: 'GET',
53
+ headers: {
54
+ ...(inner?.headers ?? {}),
55
+ host: `127.0.0.1:${localPort}`,
56
+ 'x-dsh-via': 'p2p',
57
+ accept: 'text/event-stream',
58
+ },
59
+ timeout: 15000,
60
+ }, (response) => {
61
+ response.setEncoding('utf8')
62
+ response.on('data', (chunk) => {
63
+ channel.notify(remote, { from: selfDeviceId, box: box.seal(JSON.stringify({ streamChunk: streamId, chunk })) })
64
+ })
65
+ response.on('end', () => {
66
+ channel.notify(remote, { from: selfDeviceId, box: box.seal(JSON.stringify({ streamEnd: streamId })) })
67
+ })
68
+ })
69
+ request.on('timeout', () => request.destroy(new Error('timeout')))
70
+ request.on('error', () => { /* the peer sees the stream stall and retries */ })
71
+ request.end()
72
+ }
73
+
74
+ async function handleDatagram(message, rinfo) {
75
+ // The punch probe is authenticated too: an open responder would answer any
76
+ // datagram and hand out a reflection surface.
77
+ if (typeof message?.box !== 'string') return { error: 'bad-envelope' }
78
+ const deviceId = String(message.from || '')
79
+ const box = keyFor(deviceId)
80
+ if (!box || typeof box.open !== 'function') return { error: 'no-channel' }
81
+ let inner
82
+ try {
83
+ inner = JSON.parse(box.open(message.box).toString('utf8'))
84
+ } catch {
85
+ return { error: 'bad-envelope' }
86
+ }
87
+
88
+ if (inner?.punch === true) return { box: box.seal(JSON.stringify({ pong: true })) }
89
+
90
+ if (inner?.streamOpen) {
91
+ relayStream(rinfo, box, inner.streamOpen, inner.streamId)
92
+ return { ok: true }
93
+ }
94
+ if (typeof inner?.streamChunk === 'number') {
95
+ streams.get(inner.streamChunk)?.onChunk?.(String(inner.chunk ?? ''))
96
+ return { ok: true }
97
+ }
98
+ if (typeof inner?.streamEnd === 'number') {
99
+ const entry = streams.get(inner.streamEnd)
100
+ streams.delete(inner.streamEnd)
101
+ entry?.onEnd?.()
102
+ return { ok: true }
103
+ }
104
+
105
+ const result = await forward(inner)
106
+ return { box: box.seal(JSON.stringify(result)) }
107
+ }
108
+
109
+ const channel = await createUdpChannel({ bindPort, handler: handleDatagram })
110
+
111
+ async function request(remote, deviceId, inner) {
112
+ const box = keyFor(deviceId)
113
+ if (!box) return { ok: false, error: 'no-channel' }
114
+ const result = await channel.request(remote, { from: selfDeviceId, box: box.seal(JSON.stringify(inner)) })
115
+ if (!result.ok) return result
116
+ const reply = result.message
117
+ if (typeof reply?.box !== 'string') return { ok: false, error: reply?.error || 'bad-reply' }
118
+ try {
119
+ return { ok: true, message: JSON.parse(box.open(reply.box).toString('utf8')) }
120
+ } catch {
121
+ return { ok: false, error: 'bad-reply' }
122
+ }
123
+ }
124
+
125
+ function openStream(remote, deviceId, inner, handlers = {}) {
126
+ const box = keyFor(deviceId)
127
+ if (!box) return { ok: false, error: 'no-channel' }
128
+ const streamId = nextStreamId++
129
+ streams.set(streamId, handlers)
130
+ channel.notify(remote, {
131
+ from: selfDeviceId,
132
+ box: box.seal(JSON.stringify({ streamOpen: inner, streamId })),
133
+ })
134
+ return {
135
+ ok: true,
136
+ streamId,
137
+ close: () => {
138
+ streams.delete(streamId)
139
+ const current = keyFor(deviceId)
140
+ if (current) {
141
+ channel.notify(remote, { from: selfDeviceId, box: current.seal(JSON.stringify({ streamEnd: streamId })) })
142
+ }
143
+ },
144
+ }
145
+ }
146
+
147
+ return {
148
+ port: channel.port,
149
+ request,
150
+ openStream,
151
+ mapped: (options) => channel.stun(options),
152
+ async punch(remote, deviceId) {
153
+ const box = keyFor(deviceId)
154
+ if (!box) return { ok: false, error: 'no-channel' }
155
+ const result = await channel.request(remote, { from: selfDeviceId, box: box.seal(JSON.stringify({ punch: true })) })
156
+ if (!result.ok) return { ok: false, error: result.error || 'no-pong' }
157
+ if (typeof result.message?.box !== 'string') return { ok: false, error: result.message?.error || 'no-pong' }
158
+ try {
159
+ const reply = JSON.parse(box.open(result.message.box).toString('utf8'))
160
+ return reply?.pong === true ? { ok: true } : { ok: false, error: 'no-pong' }
161
+ } catch {
162
+ return { ok: false, error: 'bad-reply' }
163
+ }
164
+ },
165
+ close: () => channel.close(),
166
+ }
167
+ }
168
+
169
+ export { createBoxCipher }
package/host/punch.js ADDED
@@ -0,0 +1,69 @@
1
+ import dgram from 'node:dgram'
2
+ import { randomBytes } from 'node:crypto'
3
+
4
+ /**
5
+ * UDP hole-punch exchange (Phase B).
6
+ *
7
+ * Both sides send a small HELLO packet to each of the peer's advertised
8
+ * candidates on an interval; the first packet to arrive proves the NAT mapping
9
+ * and the socket becomes the direct lane. Retries continue until the timeout
10
+ * because the two sides' first sends rarely cross.
11
+ *
12
+ * This is the exchange only. On a network without NAT the same code path runs
13
+ * between two bound sockets, which is exactly how scripts/punch-test.mjs
14
+ * verifies it; on two real NATs it still needs a live trial.
15
+ */
16
+
17
+ const MAGIC = Buffer.from('DSHPUNCH1', 'utf8')
18
+
19
+ export async function createPuncher({ intervalMs = 200, bindPort = 0 } = {}) {
20
+ const socket = dgram.createSocket('udp4')
21
+ let settled
22
+ let timer
23
+ let resolveDone
24
+ const done = new Promise((resolve) => { resolveDone = resolve })
25
+
26
+ function finish(value) {
27
+ if (settled) return
28
+ settled = value
29
+ clearInterval(timer)
30
+ try { socket.close() } catch { /* already closed */ }
31
+ resolveDone(value)
32
+ }
33
+
34
+ socket.on('message', (message, rinfo) => {
35
+ if (message.length >= MAGIC.length && message.subarray(0, MAGIC.length).equals(MAGIC)) {
36
+ finish({ ok: true, address: rinfo.address, port: rinfo.port })
37
+ }
38
+ })
39
+ socket.on('error', (error) => finish({ ok: false, error: error?.message || 'socket-error' }))
40
+
41
+ await new Promise((resolve, reject) => {
42
+ socket.once('error', reject)
43
+ socket.bind(bindPort, resolve)
44
+ })
45
+ const port = socket.address().port
46
+
47
+ function start(candidates, timeoutMs = 3000) {
48
+ if (settled) return Promise.resolve(settled)
49
+ const targets = (candidates ?? []).filter((entry) => entry && entry.address && entry.port)
50
+ const packet = Buffer.concat([MAGIC, randomBytes(4)])
51
+ const send = () => {
52
+ for (const target of targets) {
53
+ try { socket.send(packet, 0, packet.length, target.port, target.address) } catch { /* candidate unreachable */ }
54
+ }
55
+ }
56
+ send()
57
+ timer = setInterval(send, intervalMs)
58
+ timer.unref?.()
59
+ const deadline = setTimeout(() => finish({ ok: false, error: 'timeout' }), timeoutMs)
60
+ deadline.unref?.()
61
+ return done
62
+ }
63
+
64
+ return {
65
+ port: () => port,
66
+ start,
67
+ close: () => finish({ ok: false, error: 'closed' }),
68
+ }
69
+ }