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 +1 -2
- package/package.json +1 -1
- package/src/boot-source.js +19 -1
- package/src/crypto.js +37 -1
- package/src/mobile/assets/app.js +20 -2
- package/src/pump.js +2 -0
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.
|
|
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.
|
|
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",
|
package/src/boot-source.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) --------------------------------
|
package/src/mobile/assets/app.js
CHANGED
|
@@ -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
|
|
122
|
-
|
|
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
|
)
|