dsh-mobilecode 0.2.0 → 0.3.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.
@@ -0,0 +1,220 @@
1
+ /**
2
+ * dsh-mobilecode — capability tokens and the transport fence for the live
3
+ * stream routes.
4
+ *
5
+ * Ported from ZSeven-W/dsh-android (stream-access.ts, MIT), same security
6
+ * posture:
7
+ * - HMAC-SHA256 capabilities `base64url(payload).base64url(mac)`, signed with a
8
+ * 32-byte per-install key (`~/.dsh/mobilecode/stream-access.key`, created
9
+ * atomically); tokens expire within 10 minutes.
10
+ * - Every route applies the loopback / trusted-browser transport fence (peer
11
+ * address, loopback Host, Sec-Fetch-Site / Origin) BEFORE any capability is
12
+ * consulted — Host/Origin are caller-controlled, so a LAN client cannot spoof
13
+ * localhost and a DNS-rebinding Host is rejected.
14
+ *
15
+ * The screenshot-cache containment walk of the reference is not ported: this
16
+ * plugin serves only the live stream over its routes (device_screen writes PNGs
17
+ * to disk directly), so there is no arbitrary-path-serving surface to fence.
18
+ */
19
+
20
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
21
+ import { existsSync } from 'node:fs'
22
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
23
+ import path from 'node:path'
24
+ import { HOME } from './setup.js'
25
+
26
+ /** Hard capability lifetime (tokens expire within 10 minutes). */
27
+ export const TOKEN_TTL_MS = 10 * 60 * 1000
28
+
29
+ const KEY_BYTES = 32
30
+ const TOKEN_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/
31
+ const MAX_TOKEN_LENGTH = 16 * 1024
32
+ /** Signing may run ahead of verification by this much before the TTL cap trips. */
33
+ const CLOCK_SKEW_MS = 60 * 1000
34
+
35
+ /** adb device serials: `emulator-5554`, `RFCX123ABC`, or `host:port` for network adb. */
36
+ export const SERIAL_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/
37
+
38
+ function keyPath() {
39
+ return path.join(HOME, 'stream-access.key')
40
+ }
41
+
42
+ function mac(key, payload) {
43
+ return createHmac('sha256', key).update(payload).digest()
44
+ }
45
+
46
+ function safeEqual(left, right) {
47
+ return left.length === right.length && timingSafeEqual(left, right)
48
+ }
49
+
50
+ /** Load or atomically create the per-install 32-byte signing key. */
51
+ export async function prepareStreamAccessKey() {
52
+ await mkdir(HOME, { recursive: true })
53
+ const file = keyPath()
54
+ if (existsSync(file)) {
55
+ const key = await readFile(file)
56
+ if (key.length === KEY_BYTES) return key
57
+ throw new Error('dsh-mobilecode: stream access key has an invalid length')
58
+ }
59
+ const candidate = randomBytes(KEY_BYTES)
60
+ try {
61
+ await writeFile(file, candidate, { flag: 'wx', mode: 0o600 })
62
+ return candidate
63
+ } catch (error) {
64
+ if (error?.code !== 'EEXIST') throw error
65
+ const key = await readFile(file)
66
+ if (key.length !== KEY_BYTES) throw new Error('dsh-mobilecode: stream access key has an invalid length')
67
+ return key
68
+ }
69
+ }
70
+
71
+ function parseStreamPayload(value) {
72
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined
73
+ if (
74
+ value.v !== 1
75
+ || value.kind !== 'mobilecode-stream'
76
+ || typeof value.serial !== 'string'
77
+ || !SERIAL_PATTERN.test(value.serial)
78
+ || typeof value.exp !== 'number'
79
+ || !Number.isSafeInteger(value.exp)
80
+ ) return undefined
81
+ return { v: 1, kind: 'mobilecode-stream', serial: value.serial, exp: value.exp }
82
+ }
83
+
84
+ /** HMAC capability encoder/verifier for the live stream URL. */
85
+ export class StreamAccessController {
86
+ #routeCount = 0
87
+ #keyPromise
88
+
89
+ constructor(resolveKey = prepareStreamAccessKey) {
90
+ this.resolveKey = resolveKey
91
+ }
92
+
93
+ /** Whether at least one HTTP carrier currently owns the routes. */
94
+ get routeAvailable() {
95
+ return this.#routeCount > 0
96
+ }
97
+
98
+ /** Mark one route attachment; the returned disposer removes it. */
99
+ attachRoute() {
100
+ this.#routeCount += 1
101
+ let active = true
102
+ return () => {
103
+ if (!active) return
104
+ active = false
105
+ this.#routeCount -= 1
106
+ }
107
+ }
108
+
109
+ /** Mint a stream capability for one device serial. */
110
+ async signStreamToken(serial, options = {}) {
111
+ if (!SERIAL_PATTERN.test(serial)) throw new TypeError('dsh-mobilecode: signStreamToken requires a device serial')
112
+ const key = await this.#key()
113
+ const payload = { v: 1, kind: 'mobilecode-stream', serial, exp: Date.now() + this.#ttl(options.ttlMs) }
114
+ const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
115
+ return { token: `${encoded}.${mac(key, encoded).toString('base64url')}`, expiresAt: payload.exp }
116
+ }
117
+
118
+ async verifyStreamToken(token) {
119
+ if (token.length === 0 || token.length > MAX_TOKEN_LENGTH || !TOKEN_PATTERN.test(token)) return undefined
120
+ const [encoded, signature] = token.split('.')
121
+ if (encoded === undefined || signature === undefined) return undefined
122
+ const key = await this.#key().catch(() => undefined)
123
+ if (key === undefined) return undefined
124
+ let supplied
125
+ try {
126
+ supplied = Buffer.from(signature, 'base64url')
127
+ } catch {
128
+ return undefined
129
+ }
130
+ if (!safeEqual(mac(key, encoded), supplied)) return undefined
131
+ try {
132
+ const payload = parseStreamPayload(JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')))
133
+ if (payload === undefined) return undefined
134
+ const now = Date.now()
135
+ if (payload.exp <= now) return undefined
136
+ if (payload.exp - now > TOKEN_TTL_MS + CLOCK_SKEW_MS) return undefined
137
+ return payload
138
+ } catch {
139
+ return undefined
140
+ }
141
+ }
142
+
143
+ #ttl(ttlMs) {
144
+ if (ttlMs === undefined || !Number.isFinite(ttlMs)) return TOKEN_TTL_MS
145
+ return Math.min(TOKEN_TTL_MS, Math.max(1, Math.floor(ttlMs)))
146
+ }
147
+
148
+ #key() {
149
+ this.#keyPromise ??= this.resolveKey()
150
+ return this.#keyPromise
151
+ }
152
+ }
153
+
154
+ // ── loopback / trusted-browser transport fence ───────────────────────────────
155
+
156
+ function isIpv4LoopbackAddress(address) {
157
+ const parts = address.split('.')
158
+ return parts.length === 4 && parts[0] === '127' && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255)
159
+ }
160
+
161
+ /**
162
+ * Trust the transport peer, never forwarded or caller-controlled host data.
163
+ * Node may expose an IPv4 peer directly or as an IPv4-mapped IPv6 address,
164
+ * including the compact hexadecimal form used by some platforms.
165
+ */
166
+ export function isLoopbackRemoteAddress(address) {
167
+ if (address === undefined) return false
168
+ const normalized = address.toLowerCase().split('%', 1)[0]
169
+ if (normalized === '::1' || isIpv4LoopbackAddress(normalized)) return true
170
+ if (!normalized.startsWith('::ffff:')) return false
171
+ const mapped = normalized.slice('::ffff:'.length)
172
+ if (isIpv4LoopbackAddress(mapped)) return true
173
+ const hexadecimal = /^([a-f0-9]{1,4}):([a-f0-9]{1,4})$/.exec(mapped)
174
+ return hexadecimal !== null && (Number.parseInt(hexadecimal[1], 16) >>> 8) === 127
175
+ }
176
+
177
+ function isLoopbackHostname(hostname) {
178
+ if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
179
+ return isIpv4LoopbackAddress(hostname)
180
+ }
181
+
182
+ function requestAuthority(req) {
183
+ const host = req.headers.host
184
+ if (typeof host !== 'string') return undefined
185
+ try {
186
+ const parsed = new URL(`http://${host}`)
187
+ if (parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '' || parsed.username !== '' || parsed.password !== '') {
188
+ return undefined
189
+ }
190
+ return parsed
191
+ } catch {
192
+ return undefined
193
+ }
194
+ }
195
+
196
+ function isLoopbackRequest(req) {
197
+ if (!isLoopbackRemoteAddress(req.socket?.remoteAddress)) return false
198
+ const authority = requestAuthority(req)
199
+ return authority !== undefined && isLoopbackHostname(authority.hostname)
200
+ }
201
+
202
+ function isTrustedBrowserRequest(req, requireOrigin) {
203
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
204
+ const origin = req.headers.origin
205
+ if (origin === undefined) return !requireOrigin
206
+ if (typeof origin !== 'string') return false
207
+ const authority = requestAuthority(req)
208
+ if (authority === undefined) return false
209
+ try {
210
+ const parsed = new URL(origin)
211
+ return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === authority.host
212
+ } catch {
213
+ return false
214
+ }
215
+ }
216
+
217
+ /** The transport fence applied to every stream route. */
218
+ export function isTrustedRequest(req, requireOrigin = false) {
219
+ return isLoopbackRequest(req) && isTrustedBrowserRequest(req, requireOrigin)
220
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-mobilecode",
3
3
  "description": "MobileCode for the dsh web GUI: detect iOS/Android projects, run serve-sim / serve-avd preview servers, and build-install-launch the app on the simulator or emulator from the session — plus agent tools (device_run, device_detect). Hot-pluggable — mounted via the profile bundle list + cordis.patch.yml, no dsh source changes.",
4
- "version": "0.2.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.22.0",
7
7
  "engines": {