@dshn/agent 0.3.0 → 0.3.1

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.
Files changed (3) hide show
  1. package/client.js +6 -236
  2. package/lib/index.js +265 -0
  3. package/package.json +1 -1
package/client.js CHANGED
@@ -22,244 +22,14 @@ window.__ModuleLoader__.load({
22
22
  const ID = 'dshn'
23
23
  const POLL_MS = 2500
24
24
  const MIN_PW = 8
25
- const E2E_HEADER = 'x-dshn-e2e'
26
25
  const E2E_PUB_PATH = '/dshn-e2e'
27
- const E2E_ITERS = 210000
28
-
29
- // ── end-to-end decryption shim ────────────────────────────────────────────
30
- // Runs only when the page is opened THROUGH the tunnel (a public host, not
31
- // loopback) and the agent reports E2E on. It patches fetch + WebSocket so
32
- // /api request bodies are sealed and responses / event messages are decrypted
33
- // with a key derived from an e2e password the visitor types — a password that
34
- // never reaches the relay. dsh's own traffic is gated until that key is ready.
35
- ;(function installE2E() {
36
- window.__dshnE2E = { stage: 'entered', host: (typeof location !== 'undefined' ? location.hostname : '?') }
37
- if (typeof window === 'undefined' || !window.crypto || !window.crypto.subtle) { window.__dshnE2E.stage = 'no-subtle'; return }
38
- const host = location.hostname
39
- const loopback = host === 'localhost' || host === '::1' || /^127\./.test(host)
40
- window.__dshnE2E.remote = !loopback
41
- if (loopback) { window.__dshnE2E.stage = 'loopback-skip'; return } // local access talks straight to dsh; nothing is encrypted
42
-
43
- const realFetch = window.fetch.bind(window)
44
- const RealWS = window.WebSocket
45
- const enc = new TextEncoder()
46
- let key = null // CryptoKey once the visitor unlocks; null = pass-through
47
- let active = false // agent reports E2E on
48
- let resolveReady
49
- const ready = new Promise((r) => { resolveReady = r })
50
- const hexToBytes = (hx) => { const a = new Uint8Array(hx.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(hx.substr(i * 2, 2), 16); return a }
51
- const isApi = (url) => { try { const u = new URL(url, location.href); return u.origin === location.origin && u.pathname.startsWith('/api') } catch { return false } }
52
-
53
- async function deriveKey(password, saltHex) {
54
- const base = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
55
- return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: hexToBytes(saltHex), iterations: E2E_ITERS, hash: 'SHA-256' },
56
- base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
57
- }
58
- async function sealBytes(bytes) {
59
- const iv = crypto.getRandomValues(new Uint8Array(12))
60
- const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes))
61
- const out = new Uint8Array(iv.length + ct.length); out.set(iv); out.set(ct, iv.length); return out
62
- }
63
- async function openBytes(k, blob) {
64
- const iv = blob.subarray(0, 12)
65
- const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, k, blob.subarray(12))
66
- return new Uint8Array(pt)
67
- }
68
-
69
- // fetch: seal /api request bodies, decrypt marked responses. Non-/api and
70
- // (once ready) the E2E-off case pass straight through.
71
- window.fetch = async function (input, init) {
72
- const url = typeof input === 'string' ? input : (input && input.url) || String(input)
73
- if (!isApi(url)) return realFetch(input, init)
74
- await ready
75
- if (!key) return realFetch(input, init)
76
- const req = new Request(input, init)
77
- const headers = new Headers(req.headers)
78
- let body = null
79
- const buf = await req.clone().arrayBuffer()
80
- if (buf.byteLength > 0) { body = await sealBytes(new Uint8Array(buf)); headers.set(E2E_HEADER, '1') }
81
- else headers.set(E2E_HEADER, '1')
82
- const res = await realFetch(url, { method: req.method, headers, body, credentials: 'include', mode: req.mode, cache: req.cache })
83
- if (res.headers.get(E2E_HEADER) !== '1') return res
84
- const sealed = new Uint8Array(await res.arrayBuffer())
85
- let plain
86
- try { plain = await openBytes(key, sealed) } catch { return new Response(null, { status: 502, statusText: 'e2e decrypt failed' }) }
87
- const outH = new Headers(res.headers); outH.delete(E2E_HEADER); outH.delete('content-length')
88
- return new Response(plain, { status: res.status, statusText: res.statusText, headers: outH })
89
- }
90
-
91
- // WebSocket: connect normally, but decrypt the sealed downlink messages in
92
- // arrival order once the key is ready. Everything else proxies through.
93
- const E2EWebSocket = class extends EventTarget {
94
- constructor(url, protocols) {
95
- super()
96
- this._ws = new RealWS(url, protocols)
97
- this._q = Promise.resolve()
98
- this._seal = active && isApi(String(url))
99
- for (const t of ['open', 'error']) this._ws.addEventListener(t, (e) => this._emit(t, e))
100
- this._ws.addEventListener('close', (e) => this._emit('close', e))
101
- this._ws.addEventListener('message', (e) => { this._q = this._q.then(() => this._msg(e)) })
102
- }
103
- get url() { return this._ws.url }
104
- get readyState() { return this._ws.readyState }
105
- get bufferedAmount() { return this._ws.bufferedAmount }
106
- get protocol() { return this._ws.protocol }
107
- get extensions() { return this._ws.extensions }
108
- get binaryType() { return this._ws.binaryType }
109
- set binaryType(v) { this._ws.binaryType = v }
110
- set onopen(f) { this._onopen = f } get onopen() { return this._onopen }
111
- set onclose(f) { this._onclose = f } get onclose() { return this._onclose }
112
- set onerror(f) { this._onerror = f } get onerror() { return this._onerror }
113
- set onmessage(f) { this._onmessage = f } get onmessage() { return this._onmessage }
114
- send(d) { this._ws.send(d) }
115
- close(c, r) { this._ws.close(c, r) }
116
- _emit(type, orig) {
117
- const ev = type === 'close' ? new CloseEvent('close', { code: orig.code, reason: orig.reason, wasClean: orig.wasClean }) : new Event(type)
118
- const on = this['_on' + type]; if (on) try { on.call(this, ev) } catch {}
119
- this.dispatchEvent(ev)
120
- }
121
- async _msg(e) {
122
- let data = e.data
123
- if (this._seal) {
124
- await ready
125
- if (key) {
126
- try {
127
- const raw = data instanceof ArrayBuffer ? new Uint8Array(data)
128
- : data instanceof Blob ? new Uint8Array(await data.arrayBuffer())
129
- : new Uint8Array(await new Blob([data]).arrayBuffer())
130
- const opened = await openBytes(key, raw)
131
- data = opened[0] === 0 ? new TextDecoder().decode(opened.subarray(1)) : opened.subarray(1).buffer
132
- } catch { return } // drop messages we can't decrypt
133
- }
134
- }
135
- const ev = new MessageEvent('message', { data })
136
- if (this._onmessage) try { this._onmessage.call(this, ev) } catch {}
137
- this.dispatchEvent(ev)
138
- }
139
- }
140
- for (const [k, v] of [['CONNECTING', 0], ['OPEN', 1], ['CLOSING', 2], ['CLOSED', 3]]) {
141
- E2EWebSocket[k] = v; E2EWebSocket.prototype[k] = v
142
- }
143
- window.WebSocket = E2EWebSocket
144
-
145
- // Discover E2E state, then (if on) show the unlock gate and derive the key.
146
- // If E2E is OFF (the default), restore the native fetch/WebSocket entirely
147
- // so nothing here sits in the normal path — the feature is truly opt-in.
148
- ;(async () => {
149
- try {
150
- window.__dshnE2E.stage = 'checking'
151
- const info = await realFetch(E2E_PUB_PATH, { cache: 'no-store' }).then((r) => r.json())
152
- window.__dshnE2E.stage = 'checked'; window.__dshnE2E.enabled = info && info.enabled
153
- if (!info || !info.enabled || !info.salt) { window.fetch = realFetch; window.WebSocket = RealWS; window.__dshnE2E.stage = 'off-restored'; resolveReady(); return }
154
- active = true
155
- window.__dshnE2E.stage = 'gating'
156
- await unlockGate(info.salt, info.device)
157
- window.__dshnE2E.stage = 'unlocked'
158
- } catch (e) { window.fetch = realFetch; window.WebSocket = RealWS; window.__dshnE2E.stage = 'error'; window.__dshnE2E.error = String(e && e.message || e) }
159
- resolveReady()
160
- })()
161
26
 
162
- // A blocking DOM overlay (not React — must appear before the app mounts)
163
- // asking for the e2e password; verified by a sealed probe to /api.
164
- function unlockGate(salt, deviceKey) {
165
- return new Promise((resolve) => {
166
- const zh = String(document.documentElement.lang || navigator.language || 'en').toLowerCase().indexOf('zh') === 0
167
- const L = zh
168
- ? { t: '端到端加密', s: '本页内容已端到端加密。输入端到端密码解锁——密码不会发送到云端。', p: '端到端密码', u: '解锁', bad: '密码错误,无法解密。',
169
- save: '在此设备记住密码', stale: '已保存的密码无法解锁(可能已被更改),请重新输入。' }
170
- : { t: 'End-to-end encrypted', s: 'This session is end-to-end encrypted. Enter the e2e password to unlock — it is never sent to the cloud.', p: 'E2E password', u: 'Unlock', bad: 'Wrong password — cannot decrypt.',
171
- save: 'Remember on this device', stale: 'The saved password no longer works (it may have been changed). Enter it again.' }
172
- // Remembered password lives in localStorage, per public host AND per
173
- // device, on THIS browser only — never transmitted (E2E is intact).
174
- // The device part matters on a multi-device subdomain: each machine
175
- // has its own e2e password, and one saved copy must not clobber (or be
176
- // probed against) another device's. Keyed by host+device (not salt) so
177
- // a changed e2e password is detected and re-prompted. The old
178
- // host-only key is read once as a fallback and migrated on success.
179
- const LEGACY_KEY = 'dshn:e2e:' + location.hostname
180
- const STORE_KEY = LEGACY_KEY + (deviceKey ? ':' + deviceKey : '')
181
- const readSaved = () => {
182
- try { return localStorage.getItem(STORE_KEY) || (STORE_KEY !== LEGACY_KEY ? localStorage.getItem(LEGACY_KEY) : null) } catch { return null }
183
- }
184
- const writeSaved = (v) => {
185
- try {
186
- if (v == null) localStorage.removeItem(STORE_KEY); else localStorage.setItem(STORE_KEY, v)
187
- if (STORE_KEY !== LEGACY_KEY) localStorage.removeItem(LEGACY_KEY)
188
- } catch { /* storage may be blocked */ }
189
- }
190
-
191
- // Derive from a password string and probe /api with a sealed body; on a
192
- // correct key set the live key and return true. A wrong key → agent 400
193
- // (or the response fails to open), so return false.
194
- const attempt = async (pwStr) => {
195
- try {
196
- const cand = await deriveKey(pwStr, salt)
197
- const iv = crypto.getRandomValues(new Uint8Array(12))
198
- const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cand, enc.encode('{}')))
199
- const probeBody = new Uint8Array(iv.length + ct.length); probeBody.set(iv); probeBody.set(ct, iv.length)
200
- const r = await realFetch('/api/host.describe', { method: 'POST', headers: { [E2E_HEADER]: '1', 'content-type': 'application/json' }, body: probeBody, credentials: 'include' })
201
- if (r.status === 400) return false
202
- if (r.headers.get(E2E_HEADER) === '1') { await openBytes(cand, new Uint8Array(await r.arrayBuffer())) }
203
- key = cand
204
- return true
205
- } catch { return false }
206
- }
207
-
208
- ;(async () => {
209
- // 1. A remembered password unlocks silently — the gate never appears.
210
- let stale = false
211
- const saved = readSaved()
212
- if (saved) {
213
- if (await attempt(saved)) {
214
- writeSaved(saved) // re-write so a legacy host-only entry migrates to the per-device key
215
- window.__dshnE2E.autounlock = true; resolve(); return
216
- }
217
- writeSaved(null); stale = true // the saved one no longer works → drop it and tell the user
218
- }
219
-
220
- // 2. Otherwise show the unlock gate, themed with dsh's own tokens so it
221
- // matches the app (light/dark aware; dark fallbacks if vars are absent).
222
- const V = {
223
- mask: 'var(--dsw-alias-bg-mask-1, rgba(8,10,14,.55))', blur: 'var(--dsw-mask-blur, blur(4px))',
224
- card: 'var(--dsw-alias-bg-layer-2, #171a1f)', fg: 'var(--dsw-alias-label-primary, #e8eaed)',
225
- sub: 'var(--dsw-alias-label-tertiary, #9aa0aa)', bd: 'var(--dsw-alias-border-l1, rgba(128,134,142,.35))',
226
- shadow: 'var(--dsw-shadow-lv3, 0 24px 64px rgba(0,0,0,.5))',
227
- accent: 'var(--dsw-alias-button-primary-fill, #4176e6)', accentFg: 'var(--dsw-alias-label-primary-foreground, #fff)',
228
- err: 'var(--dsw-alias-state-error-primary, #e5484d)', warn: 'var(--dsw-alias-state-warn-primary, #d98324)',
229
- focus: 'var(--dsw-alias-label-primary-bluish, #4176e6)',
230
- }
231
- const ov = document.createElement('div')
232
- ov.setAttribute('style', 'position:fixed;inset:0;z-index:2147483000;display:grid;place-items:center;background:' + V.mask + ';backdrop-filter:' + V.blur + ';font-family:var(--dsw-font-family, system-ui, -apple-system, sans-serif)')
233
- ov.innerHTML =
234
- '<form style="width:min(360px,92vw);box-sizing:border-box;padding:22px;border-radius:16px;background:' + V.card + ';color:' + V.fg + ';border:1px solid ' + V.bd + ';box-shadow:' + V.shadow + '">'
235
- + '<div style="font-size:15px;font-weight:600;margin-bottom:6px">🔒 ' + L.t + '</div>'
236
- + '<div style="font-size:12.5px;color:' + V.sub + ';margin-bottom:16px;line-height:1.5">' + L.s + '</div>'
237
- + '<div id="dshn-e2e-err" style="display:none;font-size:12px;margin-bottom:10px;line-height:1.5"></div>'
238
- + '<input id="dshn-e2e-pw" type="password" placeholder="' + L.p + '" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;border:1px solid ' + V.bd + ';background:transparent;color:inherit;font-size:14.5px;outline:none">'
239
- + '<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-size:12.5px;color:' + V.sub + ';cursor:pointer;user-select:none">'
240
- + '<input id="dshn-e2e-remember" type="checkbox" checked style="width:15px;height:15px;margin:0;accent-color:' + V.accent + ';cursor:pointer">' + L.save + '</label>'
241
- + '<button type="submit" style="width:100%;margin-top:16px;padding:10px;border:0;border-radius:10px;background:' + V.accent + ';color:' + V.accentFg + ';font-size:14.5px;font-weight:500;cursor:pointer">' + L.u + '</button></form>'
242
- const mount = () => document.body.appendChild(ov)
243
- if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount)
244
- const form = ov.querySelector('form'), pw = ov.querySelector('#dshn-e2e-pw'), err = ov.querySelector('#dshn-e2e-err'), remember = ov.querySelector('#dshn-e2e-remember')
245
- pw.addEventListener('focus', () => { pw.style.borderColor = V.focus })
246
- pw.addEventListener('blur', () => { pw.style.borderColor = V.bd })
247
- const showErr = (msg, color) => { err.textContent = msg; err.style.color = color; err.style.display = 'block' }
248
- if (stale) showErr(L.stale, V.warn) // the "saved password no longer works" notice
249
- form.addEventListener('submit', async (e) => {
250
- e.preventDefault()
251
- const btn = form.querySelector('button'); btn.disabled = true
252
- if (await attempt(pw.value)) {
253
- writeSaved(remember.checked ? pw.value : null)
254
- ov.remove()
255
- resolve()
256
- } else { showErr(L.bad, V.err); btn.disabled = false; pw.select() }
257
- })
258
- setTimeout(() => pw.focus(), 50)
259
- })()
260
- })
261
- }
262
- })()
27
+ // ── end-to-end decryption ─────────────────────────────────────────────────
28
+ // The browser half of E2E (fetch/WebSocket patching + unlock gate) is NOT
29
+ // here any more: the host injects it into the app shell's <head> (see
30
+ // src/e2e-shim.ts) so it runs before any dsh code. As a module it arrived
31
+ // after dsh had already opened its event socket and issued its first /api
32
+ // calls, which then saw ciphertext. Nothing to install from this side.
263
33
 
264
34
  const CSS = `
265
35
  .dshn-root.dshn-root { position: fixed; left: 12px; bottom: 12px; z-index: 40;
package/lib/index.js CHANGED
@@ -4604,6 +4604,251 @@ function open(key, blob) {
4604
4604
  return Buffer.concat([decipher.update(ct), decipher.final()]);
4605
4605
  }
4606
4606
 
4607
+ // packages/agent/lib/e2e-shim.js
4608
+ var SHIM_BODY = String.raw`
4609
+ if (window.__dshnE2E) return // already installed (double injection / legacy module)
4610
+ const E2E_HEADER = 'x-dshn-e2e'
4611
+ const E2E_ITERS = 210000
4612
+ window.__dshnE2E = { stage: 'entered', host: (typeof location !== 'undefined' ? location.hostname : '?') }
4613
+ if (typeof window === 'undefined' || !window.crypto || !window.crypto.subtle) { window.__dshnE2E.stage = 'no-subtle'; return }
4614
+ const host = location.hostname
4615
+ const loopback = host === 'localhost' || host === '::1' || /^127\./.test(host)
4616
+ window.__dshnE2E.remote = !loopback
4617
+ if (loopback) { window.__dshnE2E.stage = 'loopback-skip'; return } // local access talks straight to dsh; nothing is encrypted
4618
+
4619
+ const realFetch = window.fetch.bind(window)
4620
+ const RealWS = window.WebSocket
4621
+ const enc = new TextEncoder()
4622
+ let key = null // CryptoKey once the visitor unlocks; null = pass-through
4623
+ let active = false // agent reports E2E on
4624
+ let resolveReady
4625
+ const ready = new Promise((r) => { resolveReady = r })
4626
+ const hexToBytes = (hx) => { const a = new Uint8Array(hx.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(hx.substr(i * 2, 2), 16); return a }
4627
+ const isApi = (url) => { try { const u = new URL(url, location.href); return u.origin === location.origin && u.pathname.startsWith('/api') } catch { return false } }
4628
+
4629
+ async function deriveKey(password, saltHex) {
4630
+ const base = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
4631
+ return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: hexToBytes(saltHex), iterations: E2E_ITERS, hash: 'SHA-256' },
4632
+ base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
4633
+ }
4634
+ async function sealBytes(bytes) {
4635
+ const iv = crypto.getRandomValues(new Uint8Array(12))
4636
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes))
4637
+ const out = new Uint8Array(iv.length + ct.length); out.set(iv); out.set(ct, iv.length); return out
4638
+ }
4639
+ async function openBytes(k, blob) {
4640
+ const iv = blob.subarray(0, 12)
4641
+ const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, k, blob.subarray(12))
4642
+ return new Uint8Array(pt)
4643
+ }
4644
+
4645
+ // fetch: seal /api request bodies, decrypt marked responses. Non-/api and
4646
+ // (once ready) the E2E-off case pass straight through.
4647
+ window.fetch = async function (input, init) {
4648
+ const url = typeof input === 'string' ? input : (input && input.url) || String(input)
4649
+ if (!isApi(url)) return realFetch(input, init)
4650
+ await ready
4651
+ if (!key) return realFetch(input, init)
4652
+ const req = new Request(input, init)
4653
+ const headers = new Headers(req.headers)
4654
+ let body = null
4655
+ const buf = await req.clone().arrayBuffer()
4656
+ if (buf.byteLength > 0) { body = await sealBytes(new Uint8Array(buf)); headers.set(E2E_HEADER, '1') }
4657
+ else headers.set(E2E_HEADER, '1')
4658
+ const res = await realFetch(url, { method: req.method, headers, body, credentials: 'include', mode: req.mode, cache: req.cache })
4659
+ if (res.headers.get(E2E_HEADER) !== '1') return res
4660
+ const sealed = new Uint8Array(await res.arrayBuffer())
4661
+ let plain
4662
+ try { plain = await openBytes(key, sealed) } catch { return new Response(null, { status: 502, statusText: 'e2e decrypt failed' }) }
4663
+ const outH = new Headers(res.headers); outH.delete(E2E_HEADER); outH.delete('content-length')
4664
+ return new Response(plain, { status: res.status, statusText: res.statusText, headers: outH })
4665
+ }
4666
+
4667
+ // WebSocket: connect normally, but decrypt the sealed downlink messages in
4668
+ // arrival order once the key is ready. Everything else proxies through.
4669
+ const E2EWebSocket = class extends EventTarget {
4670
+ constructor(url, protocols) {
4671
+ super()
4672
+ this._ws = new RealWS(url, protocols)
4673
+ this._q = Promise.resolve()
4674
+ this._api = isApi(String(url))
4675
+ for (const t of ['open', 'error']) this._ws.addEventListener(t, (e) => this._emit(t, e))
4676
+ this._ws.addEventListener('close', (e) => this._emit('close', e))
4677
+ this._ws.addEventListener('message', (e) => { this._q = this._q.then(() => this._msg(e)) })
4678
+ }
4679
+ get url() { return this._ws.url }
4680
+ get readyState() { return this._ws.readyState }
4681
+ get bufferedAmount() { return this._ws.bufferedAmount }
4682
+ get protocol() { return this._ws.protocol }
4683
+ get extensions() { return this._ws.extensions }
4684
+ get binaryType() { return this._ws.binaryType }
4685
+ set binaryType(v) { this._ws.binaryType = v }
4686
+ set onopen(f) { this._onopen = f } get onopen() { return this._onopen }
4687
+ set onclose(f) { this._onclose = f } get onclose() { return this._onclose }
4688
+ set onerror(f) { this._onerror = f } get onerror() { return this._onerror }
4689
+ set onmessage(f) { this._onmessage = f } get onmessage() { return this._onmessage }
4690
+ send(d) { this._ws.send(d) }
4691
+ close(c, r) { this._ws.close(c, r) }
4692
+ _emit(type, orig) {
4693
+ const ev = type === 'close' ? new CloseEvent('close', { code: orig.code, reason: orig.reason, wasClean: orig.wasClean }) : new Event(type)
4694
+ const on = this['_on' + type]; if (on) try { on.call(this, ev) } catch {}
4695
+ this.dispatchEvent(ev)
4696
+ }
4697
+ async _msg(e) {
4698
+ let data = e.data
4699
+ if (this._api) {
4700
+ await ready
4701
+ if (active && key) {
4702
+ try {
4703
+ const raw = data instanceof ArrayBuffer ? new Uint8Array(data)
4704
+ : data instanceof Blob ? new Uint8Array(await data.arrayBuffer())
4705
+ : new Uint8Array(await new Blob([data]).arrayBuffer())
4706
+ const opened = await openBytes(key, raw)
4707
+ data = opened[0] === 0 ? new TextDecoder().decode(opened.subarray(1)) : opened.subarray(1).buffer
4708
+ } catch { return } // drop messages we can't decrypt
4709
+ }
4710
+ }
4711
+ const ev = new MessageEvent('message', { data })
4712
+ if (this._onmessage) try { this._onmessage.call(this, ev) } catch {}
4713
+ this.dispatchEvent(ev)
4714
+ }
4715
+ }
4716
+ for (const [k, v] of [['CONNECTING', 0], ['OPEN', 1], ['CLOSING', 2], ['CLOSED', 3]]) {
4717
+ E2EWebSocket[k] = v; E2EWebSocket.prototype[k] = v
4718
+ }
4719
+ window.WebSocket = E2EWebSocket
4720
+
4721
+ // The host injected this script only because E2E is ON, with the public
4722
+ // salt and device id inline — so there is nothing to discover and no
4723
+ // window in which dsh's own traffic could slip past the gate: fetch and
4724
+ // WebSocket wait on the ready promise from the first byte of the page.
4725
+ ;(async () => {
4726
+ try {
4727
+ active = true
4728
+ window.__dshnE2E.enabled = true
4729
+ window.__dshnE2E.stage = 'gating'
4730
+ await unlockGate(__dshnInfo.salt, __dshnInfo.device)
4731
+ window.__dshnE2E.stage = 'unlocked'
4732
+ } catch (e) { window.__dshnE2E.stage = 'error'; window.__dshnE2E.error = String(e && e.message || e) }
4733
+ resolveReady()
4734
+ })()
4735
+
4736
+ // A blocking DOM overlay (not React — must appear before the app mounts)
4737
+ // asking for the e2e password; verified by a sealed probe to /api.
4738
+ function unlockGate(salt, deviceKey) {
4739
+ return new Promise((resolve) => {
4740
+ const zh = String(document.documentElement.lang || navigator.language || 'en').toLowerCase().indexOf('zh') === 0
4741
+ const L = zh
4742
+ ? { t: '端到端加密', s: '本页内容已端到端加密。输入端到端密码解锁——密码不会发送到云端。', p: '端到端密码', u: '解锁', bad: '密码错误,无法解密。',
4743
+ save: '在此设备记住密码', stale: '已保存的密码无法解锁(可能已被更改),请重新输入。' }
4744
+ : { t: 'End-to-end encrypted', s: 'This session is end-to-end encrypted. Enter the e2e password to unlock — it is never sent to the cloud.', p: 'E2E password', u: 'Unlock', bad: 'Wrong password — cannot decrypt.',
4745
+ save: 'Remember on this device', stale: 'The saved password no longer works (it may have been changed). Enter it again.' }
4746
+ // Remembered password lives in localStorage, per public host AND per
4747
+ // device, on THIS browser only — never transmitted (E2E is intact).
4748
+ // The device part matters on a multi-device subdomain: each machine
4749
+ // has its own e2e password, and one saved copy must not clobber (or be
4750
+ // probed against) another device's. Keyed by host+device (not salt) so
4751
+ // a changed e2e password is detected and re-prompted. The old
4752
+ // host-only key is read once as a fallback and migrated on success.
4753
+ const LEGACY_KEY = 'dshn:e2e:' + location.hostname
4754
+ const STORE_KEY = LEGACY_KEY + (deviceKey ? ':' + deviceKey : '')
4755
+ const readSaved = () => {
4756
+ try { return localStorage.getItem(STORE_KEY) || (STORE_KEY !== LEGACY_KEY ? localStorage.getItem(LEGACY_KEY) : null) } catch { return null }
4757
+ }
4758
+ const writeSaved = (v) => {
4759
+ try {
4760
+ if (v == null) localStorage.removeItem(STORE_KEY); else localStorage.setItem(STORE_KEY, v)
4761
+ if (STORE_KEY !== LEGACY_KEY) localStorage.removeItem(LEGACY_KEY)
4762
+ } catch { /* storage may be blocked */ }
4763
+ }
4764
+
4765
+ // Derive from a password string and probe /api with a sealed body; on a
4766
+ // correct key set the live key and return true. A wrong key → agent 400
4767
+ // (or the response fails to open), so return false.
4768
+ const attempt = async (pwStr) => {
4769
+ try {
4770
+ const cand = await deriveKey(pwStr, salt)
4771
+ const iv = crypto.getRandomValues(new Uint8Array(12))
4772
+ const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cand, enc.encode('{}')))
4773
+ const probeBody = new Uint8Array(iv.length + ct.length); probeBody.set(iv); probeBody.set(ct, iv.length)
4774
+ const r = await realFetch('/api/host.describe', { method: 'POST', headers: { [E2E_HEADER]: '1', 'content-type': 'application/json' }, body: probeBody, credentials: 'include' })
4775
+ if (r.status === 400) return false
4776
+ if (r.headers.get(E2E_HEADER) === '1') { await openBytes(cand, new Uint8Array(await r.arrayBuffer())) }
4777
+ key = cand
4778
+ return true
4779
+ } catch { return false }
4780
+ }
4781
+
4782
+ ;(async () => {
4783
+ // 1. A remembered password unlocks silently — the gate never appears.
4784
+ let stale = false
4785
+ const saved = readSaved()
4786
+ if (saved) {
4787
+ if (await attempt(saved)) {
4788
+ writeSaved(saved) // re-write so a legacy host-only entry migrates to the per-device key
4789
+ window.__dshnE2E.autounlock = true; resolve(); return
4790
+ }
4791
+ writeSaved(null); stale = true // the saved one no longer works → drop it and tell the user
4792
+ }
4793
+
4794
+ // 2. Otherwise show the unlock gate, themed with dsh's own tokens so it
4795
+ // matches the app (light/dark aware; dark fallbacks if vars are absent).
4796
+ const V = {
4797
+ mask: 'var(--dsw-alias-bg-mask-1, rgba(8,10,14,.55))', blur: 'var(--dsw-mask-blur, blur(4px))',
4798
+ card: 'var(--dsw-alias-bg-layer-2, #171a1f)', fg: 'var(--dsw-alias-label-primary, #e8eaed)',
4799
+ sub: 'var(--dsw-alias-label-tertiary, #9aa0aa)', bd: 'var(--dsw-alias-border-l1, rgba(128,134,142,.35))',
4800
+ shadow: 'var(--dsw-shadow-lv3, 0 24px 64px rgba(0,0,0,.5))',
4801
+ accent: 'var(--dsw-alias-button-primary-fill, #4176e6)', accentFg: 'var(--dsw-alias-label-primary-foreground, #fff)',
4802
+ err: 'var(--dsw-alias-state-error-primary, #e5484d)', warn: 'var(--dsw-alias-state-warn-primary, #d98324)',
4803
+ focus: 'var(--dsw-alias-label-primary-bluish, #4176e6)',
4804
+ }
4805
+ const ov = document.createElement('div')
4806
+ ov.setAttribute('style', 'position:fixed;inset:0;z-index:2147483000;display:grid;place-items:center;background:' + V.mask + ';backdrop-filter:' + V.blur + ';font-family:var(--dsw-font-family, system-ui, -apple-system, sans-serif)')
4807
+ ov.innerHTML =
4808
+ '<form style="width:min(360px,92vw);box-sizing:border-box;padding:22px;border-radius:16px;background:' + V.card + ';color:' + V.fg + ';border:1px solid ' + V.bd + ';box-shadow:' + V.shadow + '">'
4809
+ + '<div style="font-size:15px;font-weight:600;margin-bottom:6px">🔒 ' + L.t + '</div>'
4810
+ + '<div style="font-size:12.5px;color:' + V.sub + ';margin-bottom:16px;line-height:1.5">' + L.s + '</div>'
4811
+ + '<div id="dshn-e2e-err" style="display:none;font-size:12px;margin-bottom:10px;line-height:1.5"></div>'
4812
+ + '<input id="dshn-e2e-pw" type="password" placeholder="' + L.p + '" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:10px;border:1px solid ' + V.bd + ';background:transparent;color:inherit;font-size:14.5px;outline:none">'
4813
+ + '<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-size:12.5px;color:' + V.sub + ';cursor:pointer;user-select:none">'
4814
+ + '<input id="dshn-e2e-remember" type="checkbox" checked style="width:15px;height:15px;margin:0;accent-color:' + V.accent + ';cursor:pointer">' + L.save + '</label>'
4815
+ + '<button type="submit" style="width:100%;margin-top:16px;padding:10px;border:0;border-radius:10px;background:' + V.accent + ';color:' + V.accentFg + ';font-size:14.5px;font-weight:500;cursor:pointer">' + L.u + '</button></form>'
4816
+ const mount = () => document.body.appendChild(ov)
4817
+ if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount)
4818
+ const form = ov.querySelector('form'), pw = ov.querySelector('#dshn-e2e-pw'), err = ov.querySelector('#dshn-e2e-err'), remember = ov.querySelector('#dshn-e2e-remember')
4819
+ pw.addEventListener('focus', () => { pw.style.borderColor = V.focus })
4820
+ pw.addEventListener('blur', () => { pw.style.borderColor = V.bd })
4821
+ const showErr = (msg, color) => { err.textContent = msg; err.style.color = color; err.style.display = 'block' }
4822
+ if (stale) showErr(L.stale, V.warn) // the "saved password no longer works" notice
4823
+ form.addEventListener('submit', async (e) => {
4824
+ e.preventDefault()
4825
+ const btn = form.querySelector('button'); btn.disabled = true
4826
+ if (await attempt(pw.value)) {
4827
+ writeSaved(remember.checked ? pw.value : null)
4828
+ ov.remove()
4829
+ resolve()
4830
+ } else { showErr(L.bad, V.err); btn.disabled = false; pw.select() }
4831
+ })
4832
+ setTimeout(() => pw.focus(), 50)
4833
+ })()
4834
+ })
4835
+ }
4836
+ `;
4837
+ function e2eBootstrapTag(info) {
4838
+ const json = JSON.stringify({ salt: info.salt, device: info.device }).replace(/</g, "\\u003c");
4839
+ return `<script>(function (__dshnInfo) {${SHIM_BODY}})(${json})</script>`;
4840
+ }
4841
+ function injectE2EBootstrap(html, info) {
4842
+ const tag = e2eBootstrapTag(info);
4843
+ const head = /<head(\s[^>]*)?>/i.exec(html);
4844
+ if (head !== null)
4845
+ return html.slice(0, head.index + head[0].length) + tag + html.slice(head.index + head[0].length);
4846
+ const root = /<html(\s[^>]*)?>/i.exec(html);
4847
+ if (root !== null)
4848
+ return html.slice(0, root.index + root[0].length) + tag + html.slice(root.index + root[0].length);
4849
+ return tag + html;
4850
+ }
4851
+
4607
4852
  // packages/agent/lib/index.js
4608
4853
  var name = "@dshn/agent";
4609
4854
  var TUNNEL_MARKER = "x-dshn-forwarded";
@@ -5369,7 +5614,27 @@ var AgentTunnel = class {
5369
5614
  this.reqE2E.set(id, { method, path, headers: outHeaders, marked, chunks: [] });
5370
5615
  return;
5371
5616
  }
5617
+ const wantsDocument = method === "GET" && headers.some(([k, v]) => k.toLowerCase() === "accept" && v.includes("text/html"));
5618
+ const injectBootstrap = this.e2eKey !== null && wantsDocument;
5619
+ if (injectBootstrap)
5620
+ outHeaders["accept-encoding"] = "identity";
5372
5621
  const req = http.request({ host: this.config.localHost, port: this.localPort(), method, path, headers: outHeaders }, (res) => {
5622
+ const contentType = String(res.headers["content-type"] ?? "");
5623
+ if (injectBootstrap && this.e2eKey !== null && res.statusCode === 200 && /^text\/html\b/i.test(contentType)) {
5624
+ const chunks = [];
5625
+ res.on("data", (c) => chunks.push(c));
5626
+ res.on("end", () => {
5627
+ const html = injectE2EBootstrap(Buffer.concat(chunks).toString("utf8"), { salt: this.e2eSalt, device: this.deviceId });
5628
+ const body = Buffer.from(html, "utf8");
5629
+ const resHeaders = filterHeaders(headerListFromRaw(res.rawHeaders), /* @__PURE__ */ new Set([...HOP_BY_HOP, "content-length", "content-encoding"]));
5630
+ resHeaders.push(["content-length", String(body.length)]);
5631
+ this.send({ t: "res_head", id, status: 200, headers: resHeaders });
5632
+ this.sendData(DATA_RES_BODY, id, body);
5633
+ this.send({ t: "res_end", id });
5634
+ });
5635
+ res.on("error", () => this.send({ t: "abort", id, reason: "response stream error" }));
5636
+ return;
5637
+ }
5373
5638
  this.send({
5374
5639
  t: "res_head",
5375
5640
  id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dshn/agent",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Forward a local dsh web service to the public internet over ds.hn (bundled).",
5
5
  "keywords": [
6
6
  "dsh",