dsh-relay-phone 0.12.2 → 0.13.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/README.md CHANGED
@@ -30,8 +30,7 @@ dsh plugin --profile web add link:./packages/plugin
30
30
  Restart `dsh web` afterwards (the plugin is host-side code). Configure it from the sidebar's **Phone Entry** card → **Open console**; in the desktop app that navigates inside the window, so the port does not matter. Through the relay only the phone page and the documents/assets it loads are plaintext; every
31
31
  other request must carry a sealed envelope or it is refused with 403. A relay that could forward
32
32
  plaintext `/api/*` with the host cookie attached would be able to act as you, which would make the
33
- end-to-end encryption decorative. Replaying the full desktop UI over the relay is opt-in:
34
- `{ "allowDesktopPlaintext": true }` in the plugin config.
33
+ end-to-end encryption decorative. The desktop UI works under that policy, because the injected boot patch seals its `fetch`/XHR/WebSocket traffic (a WebSocket handshake carries no data; its frames must open). `{ "allowDesktopPlaintext": true }` remains for clients that bypass the patch.
35
34
 
36
35
  The panel is deliberately **desktop-only**: it prints the pairing link, which carries the device key, so requests that arrive through the relay are refused.
37
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-relay-phone",
3
- "version": "0.12.2",
3
+ "version": "0.13.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",
@@ -197,13 +197,31 @@ export function buildBootSource(id, gate) {
197
197
  var envKey = nacl.hash(new Uint8Array([].concat(Array.prototype.slice.call(psk),
198
198
  Array.prototype.slice.call(new TextEncoder().encode('envelope'))))).slice(0, 32)
199
199
 
200
+ // Replay defence on this side too: the relay could re-submit an old *response*
201
+ // (a stale approval result, a stale session list). Remember the nonces we have
202
+ // opened and refuse a repeat; bounded so a long session cannot grow forever.
203
+ var seenNonces = typeof Set === 'function' ? new Set() : null
204
+ var SEEN_LIMIT = 20000
205
+
200
206
  function openEnvelope(bytes) {
201
207
  try {
202
208
  if (bytes.length < 24 + 16) return null
203
209
  var nonce = bytes.slice(0, 24)
204
210
  var box = bytes.slice(24)
205
211
  var opened = nacl.secretbox.open(box, nonce, envKey)
206
- return opened ? new Uint8Array(opened) : null
212
+ if (!opened) return null
213
+ if (seenNonces) {
214
+ var id = ''
215
+ for (var i = 0; i < 24; i++) id += (nonce[i] < 16 ? '0' : '') + nonce[i].toString(16)
216
+ if (seenNonces.has(id)) return null
217
+ seenNonces.add(id)
218
+ if (seenNonces.size > SEEN_LIMIT) {
219
+ var excess = seenNonces.size - SEEN_LIMIT
220
+ var it = seenNonces.values()
221
+ for (var d = 0; d < excess; d++) seenNonces.delete(it.next().value)
222
+ }
223
+ }
224
+ return new Uint8Array(opened)
207
225
  } catch (e) { return null }
208
226
  }
209
227
  function sealEnvelope(bytes) {
package/src/crypto.js CHANGED
@@ -20,6 +20,10 @@ const sha256 = (...parts) => {
20
20
  return new Uint8Array(hash.digest())
21
21
  }
22
22
 
23
+ // How many opened envelopes to remember for replay detection. 50k * ~70 B
24
+ // is a few megabytes, and on a personal relay it covers weeks of traffic.
25
+ const REPLAY_MEMORY = 50_000
26
+
23
27
  export class CryptoBox {
24
28
  constructor(identity, options = {}) {
25
29
  this.psk = Buffer.from(identity.psk, 'hex') // 32 bytes
@@ -39,9 +43,36 @@ export class CryptoBox {
39
43
  // page was reloaded). Persist the table beside identity.json so a restart
40
44
  // keeps phones paired.
41
45
  this.sessionsPath = options.sessionsPath ?? null
46
+ // Replay defence. Every envelope carries a fresh random nonce, so seeing the
47
+ // same nonce twice means the ciphertext was captured and re-submitted by
48
+ // someone who cannot forge one — the relay is exactly that someone. Remember
49
+ // what has been opened and refuse a repeat; the set is bounded by count (an
50
+ // entry falls out only after REPLAY_MEMORY newer envelopes), which for a
51
+ // personal relay is days of traffic.
52
+ this.openedNonces = new Set()
42
53
  this.#loadSessions()
43
54
  }
44
55
 
56
+ /**
57
+ * False when this nonce has been opened before, i.e. the ciphertext is a copy.
58
+ * Bounded memory: oldest entries are dropped first, so a long-running process
59
+ * cannot grow without limit.
60
+ */
61
+ acceptNonce(nonce) {
62
+ const id = nonce.toString('hex')
63
+ if (this.openedNonces.has(id)) return false
64
+ this.openedNonces.add(id)
65
+ if (this.openedNonces.size > REPLAY_MEMORY) {
66
+ const excess = this.openedNonces.size - REPLAY_MEMORY
67
+ let dropped = 0
68
+ for (const old of this.openedNonces) {
69
+ this.openedNonces.delete(old)
70
+ if (++dropped >= excess) break
71
+ }
72
+ }
73
+ return true
74
+ }
75
+
45
76
  #loadSessions() {
46
77
  if (!this.sessionsPath) return
47
78
  try {
@@ -160,7 +191,12 @@ export class CryptoBox {
160
191
  const nonce = sealed.subarray(0, nacl.secretbox.nonceLength)
161
192
  const box = sealed.subarray(nacl.secretbox.nonceLength)
162
193
  const opened = nacl.secretbox.open(box, nonce, key)
163
- return opened ? Buffer.from(opened) : null
194
+ if (!opened) return null
195
+ // A valid envelope that has been seen before is a replay: the relay recorded
196
+ // it and sent it again. Refusing it here covers every unseal path — HTTP
197
+ // bodies, the mobile RPC and websocket frames all come through `open`.
198
+ if (!this.acceptNonce(nonce)) return null
199
+ return Buffer.from(opened)
164
200
  }
165
201
 
166
202
  // -- shell signatures (Ed25519 over boot.js) --------------------------------
@@ -115,11 +115,29 @@
115
115
  return out
116
116
  }
117
117
 
118
+ // The relay could re-submit an old response (stale approval result, stale
119
+ // session list). Refuse a nonce we have already opened; bounded for a phone.
120
+ var seenNonces = typeof Set === 'function' ? new Set() : null
121
+ var SEEN_LIMIT = 20000
122
+
118
123
  function openEnvelope(bytes) {
119
124
  try {
120
125
  if (bytes.length < 24 + 16) return null
121
- var opened = nacl.secretbox.open(bytes.slice(24), bytes.slice(0, 24), envKey)
122
- return opened ? new Uint8Array(opened) : null
126
+ var nonce = bytes.slice(0, 24)
127
+ var opened = nacl.secretbox.open(bytes.slice(24), nonce, envKey)
128
+ if (!opened) return null
129
+ if (seenNonces) {
130
+ var id = ''
131
+ for (var i = 0; i < 24; i++) id += (nonce[i] < 16 ? '0' : '') + nonce[i].toString(16)
132
+ if (seenNonces.has(id)) return null
133
+ seenNonces.add(id)
134
+ if (seenNonces.size > SEEN_LIMIT) {
135
+ var excess = seenNonces.size - SEEN_LIMIT
136
+ var it = seenNonces.values()
137
+ for (var d = 0; d < excess; d++) seenNonces.delete(it.next().value)
138
+ }
139
+ }
140
+ return new Uint8Array(opened)
123
141
  } catch (error) {
124
142
  return null
125
143
  }
package/src/pump.js CHANGED
@@ -56,6 +56,8 @@ const PLAINTEXT_DOCUMENT = new RegExp(
56
56
  '/favicon\\.ico|' +
57
57
  '/assets/[A-Za-z0-9._@/-]+|' + // built UI bundles
58
58
  '/plugins/[A-Za-z0-9._@/%-]*|' + // client bundle entry (/plugins/??pkg/file.js)
59
+ // "open in app" menu icons: plain <img> loads, no extension, no session data
60
+ '/open-in-app/icon/[A-Za-z0-9._-]+|' +
59
61
  '/[^?]*\\.(?:js|mjs|css|map|json|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|otf)' +
60
62
  ')$',
61
63
  )