dsh-relay-phone 0.16.0 → 0.18.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/package.json +1 -1
- package/src/boot-source.js +16 -1
- package/src/crypto.js +38 -0
- package/src/files.js +12 -0
- package/src/index.js +9 -2
- package/src/local-request.js +31 -0
- package/src/pairing.js +8 -1
- package/src/panel.js +11 -0
- package/src/pump.js +89 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-relay-phone",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Remote-control your local DeepSeek Harness from a phone through your own zero-knowledge relay: phone-first page, QR pairing, end-to-end encryption, approvals, full desktop UI fallback.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/boot-source.js
CHANGED
|
@@ -239,6 +239,12 @@ export function buildBootSource(id, gate) {
|
|
|
239
239
|
// because it never named where it was going. The plaintext is now
|
|
240
240
|
// 0x02 | u32 headerLen | JSON {m,t,c} | body
|
|
241
241
|
// and the host refuses a request whose outer method/target disagree.
|
|
242
|
+
function bytesToBase64(bytes) {
|
|
243
|
+
var text = ''
|
|
244
|
+
for (var i = 0; i < bytes.length; i++) text += String.fromCharCode(bytes[i])
|
|
245
|
+
return btoa(text)
|
|
246
|
+
}
|
|
247
|
+
|
|
242
248
|
var BOUND = 0x02
|
|
243
249
|
function sealBound(method, target, contentType, bytes) {
|
|
244
250
|
var enc = new TextEncoder()
|
|
@@ -373,7 +379,16 @@ export function buildBootSource(id, gate) {
|
|
|
373
379
|
return bodyPromise.then(function (body) {
|
|
374
380
|
var isRead = options.method === 'GET' || options.method === 'HEAD'
|
|
375
381
|
if (body === null && isRead) {
|
|
376
|
-
// GET/HEAD: there is no body, so only the response is sealed
|
|
382
|
+
// GET/HEAD: there is no body, so only the response is sealed - but the
|
|
383
|
+
// request still has to prove it came from this page. Without the proof
|
|
384
|
+
// header the relay could mint a GET of its own and make the host run it
|
|
385
|
+
// with the host cookie.
|
|
386
|
+
headers.set('x-relay-bind', bytesToBase64(sealBound(
|
|
387
|
+
options.method || 'GET',
|
|
388
|
+
absolute.pathname + absolute.search,
|
|
389
|
+
'',
|
|
390
|
+
new Uint8Array(8).fill(0),
|
|
391
|
+
)))
|
|
377
392
|
return origFetch(finalUrl, Object.assign({}, options, { headers: headers }))
|
|
378
393
|
.then(maybeUnwrap)
|
|
379
394
|
}
|
package/src/crypto.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import nacl from 'tweetnacl'
|
|
14
14
|
import crypto from 'node:crypto'
|
|
15
15
|
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
16
17
|
|
|
17
18
|
const sha256 = (...parts) => {
|
|
18
19
|
const hash = crypto.createHash('sha256')
|
|
@@ -50,9 +51,45 @@ export class CryptoBox {
|
|
|
50
51
|
// entry falls out only after REPLAY_MEMORY newer envelopes), which for a
|
|
51
52
|
// personal relay is days of traffic.
|
|
52
53
|
this.openedNonces = new Set()
|
|
54
|
+
// A ticketed session survives a restart, so the replay memory must too: a
|
|
55
|
+
// relay that recorded a write before the restart could otherwise replay it
|
|
56
|
+
// afterwards. Written atomically and only when something new was opened.
|
|
57
|
+
this.noncePath = options.noncePath ?? null
|
|
58
|
+
this.nonceDirty = false
|
|
59
|
+
this.#loadNonces()
|
|
60
|
+
if (this.noncePath) {
|
|
61
|
+
this.nonceTimer = setInterval(() => this.flushNonces(), 5000)
|
|
62
|
+
this.nonceTimer.unref?.()
|
|
63
|
+
}
|
|
53
64
|
this.#loadSessions()
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
#loadNonces() {
|
|
68
|
+
if (!this.noncePath) return
|
|
69
|
+
try {
|
|
70
|
+
const raw = JSON.parse(fs.readFileSync(this.noncePath, 'utf8'))
|
|
71
|
+
if (!Array.isArray(raw?.nonces)) return
|
|
72
|
+
for (const hex of raw.nonces.slice(-REPLAY_MEMORY)) {
|
|
73
|
+
if (typeof hex === 'string' && /^[0-9a-f]{48}$/.test(hex)) this.openedNonces.add(hex)
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
// Missing or corrupt file starts empty: the cost is the pre-restart window.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
flushNonces() {
|
|
81
|
+
if (!this.noncePath || !this.nonceDirty) return
|
|
82
|
+
this.nonceDirty = false
|
|
83
|
+
const tmp = `${this.noncePath}.tmp`
|
|
84
|
+
try {
|
|
85
|
+
fs.mkdirSync(path.dirname(this.noncePath), { recursive: true, mode: 0o700 })
|
|
86
|
+
fs.writeFileSync(tmp, JSON.stringify({ nonces: [...this.openedNonces] }), { mode: 0o600 })
|
|
87
|
+
fs.renameSync(tmp, this.noncePath)
|
|
88
|
+
} catch {
|
|
89
|
+
// Losing this write only widens the window by one flush interval.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
56
93
|
/**
|
|
57
94
|
* False when this nonce has been opened before, i.e. the ciphertext is a copy.
|
|
58
95
|
* Bounded memory: oldest entries are dropped first, so a long-running process
|
|
@@ -62,6 +99,7 @@ export class CryptoBox {
|
|
|
62
99
|
const id = nonce.toString('hex')
|
|
63
100
|
if (this.openedNonces.has(id)) return false
|
|
64
101
|
this.openedNonces.add(id)
|
|
102
|
+
this.nonceDirty = true
|
|
65
103
|
if (this.openedNonces.size > REPLAY_MEMORY) {
|
|
66
104
|
const excess = this.openedNonces.size - REPLAY_MEMORY
|
|
67
105
|
let dropped = 0
|
package/src/files.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import fs from 'node:fs'
|
|
9
9
|
import path from 'node:path'
|
|
10
10
|
import { auditLog } from './identity.js'
|
|
11
|
+
import { isLocalRequest } from './local-request.js'
|
|
11
12
|
|
|
12
13
|
const MIME = {
|
|
13
14
|
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
|
|
@@ -23,6 +24,17 @@ export function registerFiles(ctx) {
|
|
|
23
24
|
kind: 'exact',
|
|
24
25
|
path: '/relay-bridge/files',
|
|
25
26
|
handler: (req, res) => {
|
|
27
|
+
// This handler will read any absolute path the caller names, so who the
|
|
28
|
+
// caller is decides everything. Loopback peer plus loopback Host: a proxy
|
|
29
|
+
// forwarding to 127.0.0.1, or a rebound domain, is refused.
|
|
30
|
+
if (req.headers['x-dsh-relay'] || !isLocalRequest({
|
|
31
|
+
remoteAddress: req.socket?.remoteAddress,
|
|
32
|
+
host: req.headers.host,
|
|
33
|
+
})) {
|
|
34
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
35
|
+
res.end('dsh-relay: the file shuttle answers on localhost only.\n')
|
|
36
|
+
return
|
|
37
|
+
}
|
|
26
38
|
const url = new URL(req.url ?? '/', 'http://x')
|
|
27
39
|
const raw = url.searchParams.get('p') ?? ''
|
|
28
40
|
if (!raw || raw.includes('\0')) {
|
package/src/index.js
CHANGED
|
@@ -56,7 +56,11 @@ export default class RelayBridgeService extends Service {
|
|
|
56
56
|
this.config = this.mergeConfig(config, stored)
|
|
57
57
|
|
|
58
58
|
this.identity = loadOrCreateIdentity()
|
|
59
|
-
this.cryptoBox = new CryptoBox(this.identity, {
|
|
59
|
+
this.cryptoBox = new CryptoBox(this.identity, {
|
|
60
|
+
sessionsPath: path.join(identityDir(), 'sessions.json'),
|
|
61
|
+
// Replay memory has to outlive the process, because a ticket does.
|
|
62
|
+
noncePath: path.join(identityDir(), 'seen-nonces.json'),
|
|
63
|
+
})
|
|
60
64
|
this.pairing = new PairingService(this.cryptoBox, this.identity)
|
|
61
65
|
this.pump = null
|
|
62
66
|
this.client = null
|
|
@@ -193,7 +197,10 @@ export default class RelayBridgeService extends Service {
|
|
|
193
197
|
this.identity = createIdentity()
|
|
194
198
|
saveIdentity(this.identity)
|
|
195
199
|
auditLog(`identity rotated -> ${this.identity.id} (all paired devices revoked)`)
|
|
196
|
-
this.cryptoBox = new CryptoBox(this.identity, {
|
|
200
|
+
this.cryptoBox = new CryptoBox(this.identity, {
|
|
201
|
+
sessionsPath: path.join(identityDir(), 'sessions.json'),
|
|
202
|
+
noncePath: path.join(identityDir(), 'seen-nonces.json'),
|
|
203
|
+
})
|
|
197
204
|
this.cryptoBox.resetSessions() // rotation revokes every paired device
|
|
198
205
|
this.pairing = new PairingService(this.cryptoBox, this.identity)
|
|
199
206
|
this.mobile.cryptoBox = this.cryptoBox
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// "Is this request from the desktop itself?"
|
|
2
|
+
//
|
|
3
|
+
// The management panel and the local file shuttles are desktop tools: they hand
|
|
4
|
+
// out the pairing link (which embeds the device PSK) and let the caller read
|
|
5
|
+
// files. Both are registered on DSH's own web server, so whatever binds that
|
|
6
|
+
// server decides who can reach them - a LAN exposure, a reverse proxy, or a
|
|
7
|
+
// browser at another origin all end up looking like ordinary requests.
|
|
8
|
+
//
|
|
9
|
+
// Two independent facts must hold: the TCP peer must be loopback, and the Host
|
|
10
|
+
// header must be a loopback name. The second one is what defeats DNS rebinding -
|
|
11
|
+
// `evil.example` resolving to 127.0.0.1 arrives with `Host: evil.example`, which
|
|
12
|
+
// is not a name the desktop would ever use.
|
|
13
|
+
|
|
14
|
+
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
15
|
+
const LOOPBACK_HOST = /^(?:localhost|127\.0\.0\.1|\[::1\])(?::\d{1,5})?$/i
|
|
16
|
+
|
|
17
|
+
export function isLoopbackAddress(address) {
|
|
18
|
+
const value = String(address ?? '')
|
|
19
|
+
if (LOOPBACK_ADDRESSES.has(value)) return true
|
|
20
|
+
// 127.0.0.0/8 is all loopback; a resolved name may present any of it.
|
|
21
|
+
return /^127(?:\.\d{1,3}){3}$/.test(value)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function isLoopbackHost(host) {
|
|
25
|
+
return LOOPBACK_HOST.test(String(host ?? ''))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Loopback peer AND loopback Host: the desktop, not a proxy or a rebound name. */
|
|
29
|
+
export function isLocalRequest({ remoteAddress, host } = {}) {
|
|
30
|
+
return isLoopbackAddress(remoteAddress) && isLoopbackHost(host)
|
|
31
|
+
}
|
package/src/pairing.js
CHANGED
|
@@ -119,7 +119,14 @@ export class PairingService {
|
|
|
119
119
|
|
|
120
120
|
if (path === '/__relay/diag' && method === 'POST') {
|
|
121
121
|
const payload = parseJson(bodyBuffer) ?? {}
|
|
122
|
-
|
|
122
|
+
// Caller-supplied free text used to be written to the audit log verbatim:
|
|
123
|
+
// a newline in it forges log lines, and a 120-char UA string is enough to
|
|
124
|
+
// smuggle anything. Only shapes we expect survive.
|
|
125
|
+
const printable = (value, max) => String(value ?? '')
|
|
126
|
+
.replace(/[^\x20-\x7e]/g, '?')
|
|
127
|
+
.slice(0, max)
|
|
128
|
+
const note = /^[a-z0-9-]{0,32}$/.test(String(payload.note ?? '')) ? String(payload.note) : 'other'
|
|
129
|
+
auditLog(`diag ua=${printable(payload.ua, 60)} gate=${payload.gate === true} cookie=${payload.cookie === true} note=${note}`)
|
|
123
130
|
return json({ ok: true })
|
|
124
131
|
}
|
|
125
132
|
|
package/src/panel.js
CHANGED
|
@@ -7,6 +7,7 @@ import crypto from 'node:crypto'
|
|
|
7
7
|
import QRCode from 'qrcode'
|
|
8
8
|
import { identityDir } from './identity.js'
|
|
9
9
|
import { COOKIE_NAME, TICKET_TTL_SECONDS } from './pairing.js'
|
|
10
|
+
import { isLocalRequest } from './local-request.js'
|
|
10
11
|
import fs from 'node:fs'
|
|
11
12
|
import path from 'node:path'
|
|
12
13
|
|
|
@@ -98,6 +99,11 @@ export async function registerPanel(ctx, service) {
|
|
|
98
99
|
res.end('dsh-relay: the management panel is desktop-only. Open it on 127.0.0.1.\n')
|
|
99
100
|
return
|
|
100
101
|
}
|
|
102
|
+
if (!isLocalRequest({ remoteAddress: req.socket?.remoteAddress, host: req.headers.host })) {
|
|
103
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
104
|
+
res.end('dsh-relay: the management panel answers on localhost only.\n')
|
|
105
|
+
return
|
|
106
|
+
}
|
|
101
107
|
// Same ticket policy as /relay-bridge/api: a local page load mints one so
|
|
102
108
|
// its write actions authenticate.
|
|
103
109
|
if (!service.pairing.sessionForHeaders(req.headers)) {
|
|
@@ -131,6 +137,11 @@ export async function registerPanel(ctx, service) {
|
|
|
131
137
|
if (req.headers['x-dsh-relay']) {
|
|
132
138
|
return reply(403, { ok: false, error: 'desktop-only' })
|
|
133
139
|
}
|
|
140
|
+
// Loopback peer *and* loopback Host: a proxy that forwards to loopback, or
|
|
141
|
+
// a rebound domain pointing at 127.0.0.1, must not read the PSK link.
|
|
142
|
+
if (!isLocalRequest({ remoteAddress: req.socket?.remoteAddress, host: req.headers.host })) {
|
|
143
|
+
return reply(403, { ok: false, error: 'localhost only' })
|
|
144
|
+
}
|
|
134
145
|
|
|
135
146
|
// Pairing-ticket gate. Requests arriving through the relay already
|
|
136
147
|
// carry a valid ticket (the pump refuses ticketless traffic), so this
|
package/src/pump.js
CHANGED
|
@@ -342,6 +342,18 @@ export class PumpManager {
|
|
|
342
342
|
return
|
|
343
343
|
}
|
|
344
344
|
stream.info = info
|
|
345
|
+
// The path in the request line is raw, and the upstream may normalize it:
|
|
346
|
+
// `/assets/../api/private` matches the asset allowlist as a string but lands
|
|
347
|
+
// on a private API once DSH resolves the dots. Refuse anything whose
|
|
348
|
+
// canonical form differs from what was sent, so the string we allow is the
|
|
349
|
+
// string DSH routes.
|
|
350
|
+
const canonical = canonicalPath(info.path)
|
|
351
|
+
if (canonical === null || canonical !== info.path) {
|
|
352
|
+
auditLog(`relayed request with a non-canonical path refused (${info.path.slice(0, 120)})`)
|
|
353
|
+
this.respondInline(sid, 400, 'Bad Request')
|
|
354
|
+
this.closeStream(sid)
|
|
355
|
+
return
|
|
356
|
+
}
|
|
345
357
|
// The phone's own connection can carry further requests after this one:
|
|
346
358
|
// that is what saves a TLS handshake per asset on a mobile link.
|
|
347
359
|
// DSH_RELAY_NO_KEEPALIVE=1 restores the old one-request-per-connection
|
|
@@ -391,6 +403,10 @@ export class PumpManager {
|
|
|
391
403
|
stream.phase = 'local-body'
|
|
392
404
|
stream.reqLength = Number(info.headers.get('content-length') ?? '0')
|
|
393
405
|
stream.buffer = this.splitBody(stream, rest, stream.reqLength)
|
|
406
|
+
// A gate holder could otherwise open a pairing request, never send the body
|
|
407
|
+
// and sit on the device's slot forever: this is the same bounded wait the
|
|
408
|
+
// sealed path uses.
|
|
409
|
+
this.armIdle(sid, stream)
|
|
394
410
|
this.maybeFinishLocal(sid, stream)
|
|
395
411
|
return
|
|
396
412
|
}
|
|
@@ -737,9 +753,58 @@ export class PumpManager {
|
|
|
737
753
|
this.forwardSealed(sid, stream, plain)
|
|
738
754
|
}
|
|
739
755
|
|
|
756
|
+
/**
|
|
757
|
+
* A sealed read has no body to bind, so the client proves it instead: a small
|
|
758
|
+
* sealed `{m,t}` envelope in `x-relay-bind`. Without it the relay could mint a
|
|
759
|
+
* GET of its own and make the host run it with the host cookie. Opening the
|
|
760
|
+
* proof also puts its nonce in the replay cache, so a captured header cannot
|
|
761
|
+
* be reused.
|
|
762
|
+
*/
|
|
763
|
+
verifyReadProof(sid, stream) {
|
|
764
|
+
const info = stream.info
|
|
765
|
+
const header = info.headers.get('x-relay-bind')
|
|
766
|
+
if (!header) {
|
|
767
|
+
auditLog(`sealed read without a proof refused (${info.method} ${info.path})`)
|
|
768
|
+
this.respondInline(sid, 400, 'Bad Request', 'dsh-relay-phone: sealed reads need a proof header\n')
|
|
769
|
+
this.closeStream(sid)
|
|
770
|
+
return false
|
|
771
|
+
}
|
|
772
|
+
let sealed
|
|
773
|
+
try {
|
|
774
|
+
sealed = Buffer.from(String(header), 'base64')
|
|
775
|
+
} catch {
|
|
776
|
+
sealed = null
|
|
777
|
+
}
|
|
778
|
+
const proof = sealed && sealed.length >= SEALED_BODY_MIN
|
|
779
|
+
? this.cryptoBox.open(this.cryptoBox.envelopeKey, sealed)
|
|
780
|
+
: null
|
|
781
|
+
if (!proof) {
|
|
782
|
+
auditLog(`sealed read with an unopenable proof refused (${info.path})`)
|
|
783
|
+
this.respondInline(sid, 400, 'Bad Request')
|
|
784
|
+
this.closeStream(sid)
|
|
785
|
+
return false
|
|
786
|
+
}
|
|
787
|
+
const { bound } = this.unwrapBound(proof)
|
|
788
|
+
if (!bound) {
|
|
789
|
+
auditLog(`sealed read with an unbound proof refused (${info.path})`)
|
|
790
|
+
this.respondInline(sid, 400, 'Bad Request')
|
|
791
|
+
this.closeStream(sid)
|
|
792
|
+
return false
|
|
793
|
+
}
|
|
794
|
+
const mismatch = this.boundMismatch(bound, info)
|
|
795
|
+
if (mismatch) {
|
|
796
|
+
auditLog(`sealed read proof mismatch: ${mismatch}`)
|
|
797
|
+
this.respondInline(sid, 400, 'Bad Request', 'dsh-relay-phone: sealed request was re-routed\n')
|
|
798
|
+
this.closeStream(sid)
|
|
799
|
+
return false
|
|
800
|
+
}
|
|
801
|
+
return true
|
|
802
|
+
}
|
|
803
|
+
|
|
740
804
|
/** Send an opened (or bodyless) sealed request upstream and seal the response. */
|
|
741
805
|
forwardSealed(sid, stream, plain, { responseOnly = false } = {}) {
|
|
742
806
|
const info = stream.info
|
|
807
|
+
if (responseOnly && !this.verifyReadProof(sid, stream)) return
|
|
743
808
|
const { body, bound } = this.unwrapBound(plain)
|
|
744
809
|
if (bound) {
|
|
745
810
|
const mismatch = this.boundMismatch(bound, info)
|
|
@@ -1279,6 +1344,30 @@ function rewriteAssetUrls(html, relay) {
|
|
|
1279
1344
|
* @param query - raw query text, without the leading `?`.
|
|
1280
1345
|
* @returns the gate value ('' when absent) and the remaining raw query.
|
|
1281
1346
|
*/
|
|
1347
|
+
/**
|
|
1348
|
+
* Canonical form of a request path, or null when it cannot be one.
|
|
1349
|
+
*
|
|
1350
|
+
* Dot segments, encoded separators and backslashes are refused outright rather
|
|
1351
|
+
* than resolved: the plugin decides what may pass on this string, and DSH
|
|
1352
|
+
* decides what to route on its own normalization - those two must be the same.
|
|
1353
|
+
*/
|
|
1354
|
+
export function canonicalPath(rawPath) {
|
|
1355
|
+
if (typeof rawPath !== 'string' || rawPath === '') return null
|
|
1356
|
+
if (!rawPath.startsWith('/')) return null
|
|
1357
|
+
let decoded
|
|
1358
|
+
try { decoded = decodeURIComponent(rawPath) } catch { return null }
|
|
1359
|
+
if (decoded.includes('\\') || decoded.includes('\0')) return null
|
|
1360
|
+
if (decoded.includes('%')) return null // an encoded % is a second round of decoding
|
|
1361
|
+
const trailingSlash = decoded.endsWith('/') && decoded !== '/'
|
|
1362
|
+
const parts = []
|
|
1363
|
+
for (const segment of decoded.split('/')) {
|
|
1364
|
+
if (segment === '' || segment === '.') continue
|
|
1365
|
+
if (segment === '..') return null
|
|
1366
|
+
parts.push(segment)
|
|
1367
|
+
}
|
|
1368
|
+
return `/${parts.join('/')}${trailingSlash ? '/' : ''}`
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1282
1371
|
export function stripGateParam(query) {
|
|
1283
1372
|
const gate = new URLSearchParams(query).get('g') ?? ''
|
|
1284
1373
|
const cleanQuery = query
|