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.
- package/README.md +43 -0
- package/lib/android-stream.js +378 -0
- package/lib/client.js +133 -0
- package/lib/device-build.js +51 -0
- package/lib/frame-source.js +274 -0
- package/lib/index.js +549 -7
- package/lib/stream-access.js +220 -0
- package/package.json +1 -1
|
@@ -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.
|
|
4
|
+
"version": "0.3.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "pnpm@11.22.0",
|
|
7
7
|
"engines": {
|