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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-relay-phone",
3
- "version": "0.16.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",
@@ -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, { sessionsPath: path.join(identityDir(), 'sessions.json') })
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, { sessionsPath: path.join(identityDir(), 'sessions.json') })
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
- auditLog(`diag ua=${String(payload.ua ?? '').slice(0, 120)} gate=${payload.gate} cookie=${payload.cookie} note=${String(payload.note ?? '').slice(0, 80)}`)
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