@dshn/agent 0.3.0 → 0.3.2
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/client.js +6 -236
- package/lib/index.js +275 -0
- 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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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,261 @@ 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
|
+
// Same host as the page, over http(s) OR ws(s). A wss: URL has the origin
|
|
4628
|
+
// "wss://host", which never equals the page's "https://host", so comparing
|
|
4629
|
+
// origins would leave every event socket undecrypted.
|
|
4630
|
+
const isApi = (url) => {
|
|
4631
|
+
try {
|
|
4632
|
+
const u = new URL(url, location.href)
|
|
4633
|
+
return u.host === location.host && /^(https?|wss?):$/.test(u.protocol) && u.pathname.startsWith('/api')
|
|
4634
|
+
} catch { return false }
|
|
4635
|
+
}
|
|
4636
|
+
|
|
4637
|
+
async function deriveKey(password, saltHex) {
|
|
4638
|
+
const base = await crypto.subtle.importKey('raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'])
|
|
4639
|
+
return crypto.subtle.deriveKey({ name: 'PBKDF2', salt: hexToBytes(saltHex), iterations: E2E_ITERS, hash: 'SHA-256' },
|
|
4640
|
+
base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'])
|
|
4641
|
+
}
|
|
4642
|
+
async function sealBytes(bytes) {
|
|
4643
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
4644
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, bytes))
|
|
4645
|
+
const out = new Uint8Array(iv.length + ct.length); out.set(iv); out.set(ct, iv.length); return out
|
|
4646
|
+
}
|
|
4647
|
+
async function openBytes(k, blob) {
|
|
4648
|
+
const iv = blob.subarray(0, 12)
|
|
4649
|
+
const pt = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, k, blob.subarray(12))
|
|
4650
|
+
return new Uint8Array(pt)
|
|
4651
|
+
}
|
|
4652
|
+
|
|
4653
|
+
// fetch: seal /api request bodies, decrypt marked responses. Non-/api and
|
|
4654
|
+
// (once ready) the E2E-off case pass straight through.
|
|
4655
|
+
window.fetch = async function (input, init) {
|
|
4656
|
+
const url = typeof input === 'string' ? input : (input && input.url) || String(input)
|
|
4657
|
+
if (!isApi(url)) return realFetch(input, init)
|
|
4658
|
+
await ready
|
|
4659
|
+
if (!key) return realFetch(input, init)
|
|
4660
|
+
const req = new Request(input, init)
|
|
4661
|
+
const headers = new Headers(req.headers)
|
|
4662
|
+
let body = null
|
|
4663
|
+
const buf = await req.clone().arrayBuffer()
|
|
4664
|
+
if (buf.byteLength > 0) { body = await sealBytes(new Uint8Array(buf)); headers.set(E2E_HEADER, '1') }
|
|
4665
|
+
else headers.set(E2E_HEADER, '1')
|
|
4666
|
+
const res = await realFetch(url, { method: req.method, headers, body, credentials: 'include', mode: req.mode, cache: req.cache })
|
|
4667
|
+
if (res.headers.get(E2E_HEADER) !== '1') return res
|
|
4668
|
+
const sealed = new Uint8Array(await res.arrayBuffer())
|
|
4669
|
+
let plain
|
|
4670
|
+
try { plain = await openBytes(key, sealed) } catch { return new Response(null, { status: 502, statusText: 'e2e decrypt failed' }) }
|
|
4671
|
+
const outH = new Headers(res.headers); outH.delete(E2E_HEADER); outH.delete('content-length')
|
|
4672
|
+
return new Response(plain, { status: res.status, statusText: res.statusText, headers: outH })
|
|
4673
|
+
}
|
|
4674
|
+
|
|
4675
|
+
// WebSocket: connect normally, but decrypt the sealed downlink messages in
|
|
4676
|
+
// arrival order once the key is ready. Everything else proxies through.
|
|
4677
|
+
const E2EWebSocket = class extends EventTarget {
|
|
4678
|
+
constructor(url, protocols) {
|
|
4679
|
+
super()
|
|
4680
|
+
this._ws = new RealWS(url, protocols)
|
|
4681
|
+
this._q = Promise.resolve()
|
|
4682
|
+
this._api = isApi(String(url))
|
|
4683
|
+
for (const t of ['open', 'error']) this._ws.addEventListener(t, (e) => this._emit(t, e))
|
|
4684
|
+
this._ws.addEventListener('close', (e) => this._emit('close', e))
|
|
4685
|
+
this._ws.addEventListener('message', (e) => { this._q = this._q.then(() => this._msg(e)) })
|
|
4686
|
+
}
|
|
4687
|
+
get url() { return this._ws.url }
|
|
4688
|
+
get readyState() { return this._ws.readyState }
|
|
4689
|
+
get bufferedAmount() { return this._ws.bufferedAmount }
|
|
4690
|
+
get protocol() { return this._ws.protocol }
|
|
4691
|
+
get extensions() { return this._ws.extensions }
|
|
4692
|
+
get binaryType() { return this._ws.binaryType }
|
|
4693
|
+
set binaryType(v) { this._ws.binaryType = v }
|
|
4694
|
+
set onopen(f) { this._onopen = f } get onopen() { return this._onopen }
|
|
4695
|
+
set onclose(f) { this._onclose = f } get onclose() { return this._onclose }
|
|
4696
|
+
set onerror(f) { this._onerror = f } get onerror() { return this._onerror }
|
|
4697
|
+
set onmessage(f) { this._onmessage = f } get onmessage() { return this._onmessage }
|
|
4698
|
+
send(d) { this._ws.send(d) }
|
|
4699
|
+
close(c, r) { this._ws.close(c, r) }
|
|
4700
|
+
_emit(type, orig) {
|
|
4701
|
+
const ev = type === 'close' ? new CloseEvent('close', { code: orig.code, reason: orig.reason, wasClean: orig.wasClean }) : new Event(type)
|
|
4702
|
+
const on = this['_on' + type]; if (on) try { on.call(this, ev) } catch {}
|
|
4703
|
+
this.dispatchEvent(ev)
|
|
4704
|
+
}
|
|
4705
|
+
async _msg(e) {
|
|
4706
|
+
let data = e.data
|
|
4707
|
+
if (this._api) {
|
|
4708
|
+
await ready
|
|
4709
|
+
if (active && key) {
|
|
4710
|
+
try {
|
|
4711
|
+
const raw = data instanceof ArrayBuffer ? new Uint8Array(data)
|
|
4712
|
+
: data instanceof Blob ? new Uint8Array(await data.arrayBuffer())
|
|
4713
|
+
: new Uint8Array(await new Blob([data]).arrayBuffer())
|
|
4714
|
+
const opened = await openBytes(key, raw)
|
|
4715
|
+
// slice, not subarray: subarray(1).buffer is the WHOLE decrypted
|
|
4716
|
+
// buffer, type byte included, and would hand the app one extra byte.
|
|
4717
|
+
data = opened[0] === 0 ? new TextDecoder().decode(opened.subarray(1)) : opened.slice(1).buffer
|
|
4718
|
+
} catch { return } // drop messages we can't decrypt
|
|
4719
|
+
}
|
|
4720
|
+
}
|
|
4721
|
+
const ev = new MessageEvent('message', { data })
|
|
4722
|
+
if (this._onmessage) try { this._onmessage.call(this, ev) } catch {}
|
|
4723
|
+
this.dispatchEvent(ev)
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
for (const [k, v] of [['CONNECTING', 0], ['OPEN', 1], ['CLOSING', 2], ['CLOSED', 3]]) {
|
|
4727
|
+
E2EWebSocket[k] = v; E2EWebSocket.prototype[k] = v
|
|
4728
|
+
}
|
|
4729
|
+
window.WebSocket = E2EWebSocket
|
|
4730
|
+
|
|
4731
|
+
// The host injected this script only because E2E is ON, with the public
|
|
4732
|
+
// salt and device id inline — so there is nothing to discover and no
|
|
4733
|
+
// window in which dsh's own traffic could slip past the gate: fetch and
|
|
4734
|
+
// WebSocket wait on the ready promise from the first byte of the page.
|
|
4735
|
+
;(async () => {
|
|
4736
|
+
try {
|
|
4737
|
+
active = true
|
|
4738
|
+
window.__dshnE2E.enabled = true
|
|
4739
|
+
window.__dshnE2E.stage = 'gating'
|
|
4740
|
+
await unlockGate(__dshnInfo.salt, __dshnInfo.device)
|
|
4741
|
+
window.__dshnE2E.stage = 'unlocked'
|
|
4742
|
+
} catch (e) { window.__dshnE2E.stage = 'error'; window.__dshnE2E.error = String(e && e.message || e) }
|
|
4743
|
+
resolveReady()
|
|
4744
|
+
})()
|
|
4745
|
+
|
|
4746
|
+
// A blocking DOM overlay (not React — must appear before the app mounts)
|
|
4747
|
+
// asking for the e2e password; verified by a sealed probe to /api.
|
|
4748
|
+
function unlockGate(salt, deviceKey) {
|
|
4749
|
+
return new Promise((resolve) => {
|
|
4750
|
+
const zh = String(document.documentElement.lang || navigator.language || 'en').toLowerCase().indexOf('zh') === 0
|
|
4751
|
+
const L = zh
|
|
4752
|
+
? { t: '端到端加密', s: '本页内容已端到端加密。输入端到端密码解锁——密码不会发送到云端。', p: '端到端密码', u: '解锁', bad: '密码错误,无法解密。',
|
|
4753
|
+
save: '在此设备记住密码', stale: '已保存的密码无法解锁(可能已被更改),请重新输入。' }
|
|
4754
|
+
: { 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.',
|
|
4755
|
+
save: 'Remember on this device', stale: 'The saved password no longer works (it may have been changed). Enter it again.' }
|
|
4756
|
+
// Remembered password lives in localStorage, per public host AND per
|
|
4757
|
+
// device, on THIS browser only — never transmitted (E2E is intact).
|
|
4758
|
+
// The device part matters on a multi-device subdomain: each machine
|
|
4759
|
+
// has its own e2e password, and one saved copy must not clobber (or be
|
|
4760
|
+
// probed against) another device's. Keyed by host+device (not salt) so
|
|
4761
|
+
// a changed e2e password is detected and re-prompted. The old
|
|
4762
|
+
// host-only key is read once as a fallback and migrated on success.
|
|
4763
|
+
const LEGACY_KEY = 'dshn:e2e:' + location.hostname
|
|
4764
|
+
const STORE_KEY = LEGACY_KEY + (deviceKey ? ':' + deviceKey : '')
|
|
4765
|
+
const readSaved = () => {
|
|
4766
|
+
try { return localStorage.getItem(STORE_KEY) || (STORE_KEY !== LEGACY_KEY ? localStorage.getItem(LEGACY_KEY) : null) } catch { return null }
|
|
4767
|
+
}
|
|
4768
|
+
const writeSaved = (v) => {
|
|
4769
|
+
try {
|
|
4770
|
+
if (v == null) localStorage.removeItem(STORE_KEY); else localStorage.setItem(STORE_KEY, v)
|
|
4771
|
+
if (STORE_KEY !== LEGACY_KEY) localStorage.removeItem(LEGACY_KEY)
|
|
4772
|
+
} catch { /* storage may be blocked */ }
|
|
4773
|
+
}
|
|
4774
|
+
|
|
4775
|
+
// Derive from a password string and probe /api with a sealed body; on a
|
|
4776
|
+
// correct key set the live key and return true. A wrong key → agent 400
|
|
4777
|
+
// (or the response fails to open), so return false.
|
|
4778
|
+
const attempt = async (pwStr) => {
|
|
4779
|
+
try {
|
|
4780
|
+
const cand = await deriveKey(pwStr, salt)
|
|
4781
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
4782
|
+
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, cand, enc.encode('{}')))
|
|
4783
|
+
const probeBody = new Uint8Array(iv.length + ct.length); probeBody.set(iv); probeBody.set(ct, iv.length)
|
|
4784
|
+
const r = await realFetch('/api/host.describe', { method: 'POST', headers: { [E2E_HEADER]: '1', 'content-type': 'application/json' }, body: probeBody, credentials: 'include' })
|
|
4785
|
+
if (r.status === 400) return false
|
|
4786
|
+
if (r.headers.get(E2E_HEADER) === '1') { await openBytes(cand, new Uint8Array(await r.arrayBuffer())) }
|
|
4787
|
+
key = cand
|
|
4788
|
+
return true
|
|
4789
|
+
} catch { return false }
|
|
4790
|
+
}
|
|
4791
|
+
|
|
4792
|
+
;(async () => {
|
|
4793
|
+
// 1. A remembered password unlocks silently — the gate never appears.
|
|
4794
|
+
let stale = false
|
|
4795
|
+
const saved = readSaved()
|
|
4796
|
+
if (saved) {
|
|
4797
|
+
if (await attempt(saved)) {
|
|
4798
|
+
writeSaved(saved) // re-write so a legacy host-only entry migrates to the per-device key
|
|
4799
|
+
window.__dshnE2E.autounlock = true; resolve(); return
|
|
4800
|
+
}
|
|
4801
|
+
writeSaved(null); stale = true // the saved one no longer works → drop it and tell the user
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
// 2. Otherwise show the unlock gate, themed with dsh's own tokens so it
|
|
4805
|
+
// matches the app (light/dark aware; dark fallbacks if vars are absent).
|
|
4806
|
+
const V = {
|
|
4807
|
+
mask: 'var(--dsw-alias-bg-mask-1, rgba(8,10,14,.55))', blur: 'var(--dsw-mask-blur, blur(4px))',
|
|
4808
|
+
card: 'var(--dsw-alias-bg-layer-2, #171a1f)', fg: 'var(--dsw-alias-label-primary, #e8eaed)',
|
|
4809
|
+
sub: 'var(--dsw-alias-label-tertiary, #9aa0aa)', bd: 'var(--dsw-alias-border-l1, rgba(128,134,142,.35))',
|
|
4810
|
+
shadow: 'var(--dsw-shadow-lv3, 0 24px 64px rgba(0,0,0,.5))',
|
|
4811
|
+
accent: 'var(--dsw-alias-button-primary-fill, #4176e6)', accentFg: 'var(--dsw-alias-label-primary-foreground, #fff)',
|
|
4812
|
+
err: 'var(--dsw-alias-state-error-primary, #e5484d)', warn: 'var(--dsw-alias-state-warn-primary, #d98324)',
|
|
4813
|
+
focus: 'var(--dsw-alias-label-primary-bluish, #4176e6)',
|
|
4814
|
+
}
|
|
4815
|
+
const ov = document.createElement('div')
|
|
4816
|
+
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)')
|
|
4817
|
+
ov.innerHTML =
|
|
4818
|
+
'<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 + '">'
|
|
4819
|
+
+ '<div style="font-size:15px;font-weight:600;margin-bottom:6px">🔒 ' + L.t + '</div>'
|
|
4820
|
+
+ '<div style="font-size:12.5px;color:' + V.sub + ';margin-bottom:16px;line-height:1.5">' + L.s + '</div>'
|
|
4821
|
+
+ '<div id="dshn-e2e-err" style="display:none;font-size:12px;margin-bottom:10px;line-height:1.5"></div>'
|
|
4822
|
+
+ '<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">'
|
|
4823
|
+
+ '<label style="display:flex;align-items:center;gap:8px;margin-top:12px;font-size:12.5px;color:' + V.sub + ';cursor:pointer;user-select:none">'
|
|
4824
|
+
+ '<input id="dshn-e2e-remember" type="checkbox" checked style="width:15px;height:15px;margin:0;accent-color:' + V.accent + ';cursor:pointer">' + L.save + '</label>'
|
|
4825
|
+
+ '<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>'
|
|
4826
|
+
const mount = () => document.body.appendChild(ov)
|
|
4827
|
+
if (document.body) mount(); else document.addEventListener('DOMContentLoaded', mount)
|
|
4828
|
+
const form = ov.querySelector('form'), pw = ov.querySelector('#dshn-e2e-pw'), err = ov.querySelector('#dshn-e2e-err'), remember = ov.querySelector('#dshn-e2e-remember')
|
|
4829
|
+
pw.addEventListener('focus', () => { pw.style.borderColor = V.focus })
|
|
4830
|
+
pw.addEventListener('blur', () => { pw.style.borderColor = V.bd })
|
|
4831
|
+
const showErr = (msg, color) => { err.textContent = msg; err.style.color = color; err.style.display = 'block' }
|
|
4832
|
+
if (stale) showErr(L.stale, V.warn) // the "saved password no longer works" notice
|
|
4833
|
+
form.addEventListener('submit', async (e) => {
|
|
4834
|
+
e.preventDefault()
|
|
4835
|
+
const btn = form.querySelector('button'); btn.disabled = true
|
|
4836
|
+
if (await attempt(pw.value)) {
|
|
4837
|
+
writeSaved(remember.checked ? pw.value : null)
|
|
4838
|
+
ov.remove()
|
|
4839
|
+
resolve()
|
|
4840
|
+
} else { showErr(L.bad, V.err); btn.disabled = false; pw.select() }
|
|
4841
|
+
})
|
|
4842
|
+
setTimeout(() => pw.focus(), 50)
|
|
4843
|
+
})()
|
|
4844
|
+
})
|
|
4845
|
+
}
|
|
4846
|
+
`;
|
|
4847
|
+
function e2eBootstrapTag(info) {
|
|
4848
|
+
const json = JSON.stringify({ salt: info.salt, device: info.device }).replace(/</g, "\\u003c");
|
|
4849
|
+
return `<script>(function (__dshnInfo) {${SHIM_BODY}})(${json})</script>`;
|
|
4850
|
+
}
|
|
4851
|
+
function injectE2EBootstrap(html, info) {
|
|
4852
|
+
const tag = e2eBootstrapTag(info);
|
|
4853
|
+
const head = /<head(\s[^>]*)?>/i.exec(html);
|
|
4854
|
+
if (head !== null)
|
|
4855
|
+
return html.slice(0, head.index + head[0].length) + tag + html.slice(head.index + head[0].length);
|
|
4856
|
+
const root = /<html(\s[^>]*)?>/i.exec(html);
|
|
4857
|
+
if (root !== null)
|
|
4858
|
+
return html.slice(0, root.index + root[0].length) + tag + html.slice(root.index + root[0].length);
|
|
4859
|
+
return tag + html;
|
|
4860
|
+
}
|
|
4861
|
+
|
|
4607
4862
|
// packages/agent/lib/index.js
|
|
4608
4863
|
var name = "@dshn/agent";
|
|
4609
4864
|
var TUNNEL_MARKER = "x-dshn-forwarded";
|
|
@@ -5369,7 +5624,27 @@ var AgentTunnel = class {
|
|
|
5369
5624
|
this.reqE2E.set(id, { method, path, headers: outHeaders, marked, chunks: [] });
|
|
5370
5625
|
return;
|
|
5371
5626
|
}
|
|
5627
|
+
const wantsDocument = method === "GET" && headers.some(([k, v]) => k.toLowerCase() === "accept" && v.includes("text/html"));
|
|
5628
|
+
const injectBootstrap = this.e2eKey !== null && wantsDocument;
|
|
5629
|
+
if (injectBootstrap)
|
|
5630
|
+
outHeaders["accept-encoding"] = "identity";
|
|
5372
5631
|
const req = http.request({ host: this.config.localHost, port: this.localPort(), method, path, headers: outHeaders }, (res) => {
|
|
5632
|
+
const contentType = String(res.headers["content-type"] ?? "");
|
|
5633
|
+
if (injectBootstrap && this.e2eKey !== null && res.statusCode === 200 && /^text\/html\b/i.test(contentType)) {
|
|
5634
|
+
const chunks = [];
|
|
5635
|
+
res.on("data", (c) => chunks.push(c));
|
|
5636
|
+
res.on("end", () => {
|
|
5637
|
+
const html = injectE2EBootstrap(Buffer.concat(chunks).toString("utf8"), { salt: this.e2eSalt, device: this.deviceId });
|
|
5638
|
+
const body = Buffer.from(html, "utf8");
|
|
5639
|
+
const resHeaders = filterHeaders(headerListFromRaw(res.rawHeaders), /* @__PURE__ */ new Set([...HOP_BY_HOP, "content-length", "content-encoding"]));
|
|
5640
|
+
resHeaders.push(["content-length", String(body.length)]);
|
|
5641
|
+
this.send({ t: "res_head", id, status: 200, headers: resHeaders });
|
|
5642
|
+
this.sendData(DATA_RES_BODY, id, body);
|
|
5643
|
+
this.send({ t: "res_end", id });
|
|
5644
|
+
});
|
|
5645
|
+
res.on("error", () => this.send({ t: "abort", id, reason: "response stream error" }));
|
|
5646
|
+
return;
|
|
5647
|
+
}
|
|
5373
5648
|
this.send({
|
|
5374
5649
|
t: "res_head",
|
|
5375
5650
|
id,
|