@kinocode/dsh-mobile-bridge 0.0.0-stage → 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.
@@ -0,0 +1,113 @@
1
+ // WebView sessions for the gateway: a signed cookie value bound to one registered device.
2
+ // The value is base64url(`deviceId.expiresAt`) followed by a 43-character HMAC-SHA256 (key derived
3
+ // from the host secret), only [A-Za-z0-9_-] as the app expects,
4
+ // so sessions survive a DSH restart; a removed device's sessions stop working at once because
5
+ // `isActive` consults the device registry and `revokeDevice` remembers it for this process.
6
+
7
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
8
+
9
+ export const SESSION_TTL_MS = 12 * 60 * 60 * 1000
10
+ export const SESSION_COOKIE = 'dshmb_session'
11
+ const VALUE = /^([A-Za-z0-9_-]{4,120})([A-Za-z0-9_-]{43})$/
12
+ const PAYLOAD = /^([A-Za-z0-9_-]{1,64})\.(\d{1,15})$/
13
+
14
+ export class WebSessions {
15
+ /**
16
+ * @param {{now?: () => number, secret?: () => string, isActive?: (deviceId: string) => boolean}} [options]
17
+ * secret - host secret the signing key is derived from (default: a random per-process key)
18
+ * isActive - whether a device is still registered (default: always)
19
+ */
20
+ constructor(options = {}) {
21
+ this.now = options.now ?? Date.now
22
+ const fallback = randomBytes(32).toString('base64url')
23
+ this.secret = options.secret ?? (() => fallback)
24
+ this.isActive = options.isActive ?? (() => true)
25
+ /** devices removed while this process runs */
26
+ this.revoked = new Set()
27
+ /** deviceId -> sockets to close when the device is removed */
28
+ this.sockets = new Map()
29
+ }
30
+
31
+ /** @param {string} payload */
32
+ mac(payload) {
33
+ const key = createHash('sha256').update(`dshmb-web-session\n${this.secret()}`).digest()
34
+ return createHmac('sha256', key).update(payload).digest('base64url')
35
+ }
36
+
37
+ /**
38
+ * @param {string} deviceId
39
+ * @returns {{cookieName: string, value: string, expiresAt: number}}
40
+ */
41
+ create(deviceId) {
42
+ this.revoked.delete(deviceId)
43
+ const expiresAt = this.now() + SESSION_TTL_MS
44
+ const payload = `${deviceId}.${expiresAt}`
45
+ return { cookieName: SESSION_COOKIE, value: `${Buffer.from(payload).toString('base64url')}${this.mac(payload)}`, expiresAt }
46
+ }
47
+
48
+ /**
49
+ * Device id for a Cookie header, or undefined.
50
+ * @param {string|undefined} cookieHeader
51
+ */
52
+ check(cookieHeader) {
53
+ const value = readCookie(cookieHeader, SESSION_COOKIE)
54
+ const match = value === undefined ? null : VALUE.exec(value)
55
+ if (match === null) return undefined
56
+ const payload = Buffer.from(match[1], 'base64url').toString()
57
+ const parts = PAYLOAD.exec(payload)
58
+ if (parts === null) return undefined
59
+ const [, deviceId, expires] = parts
60
+ const mac = match[2]
61
+ const expected = Buffer.from(this.mac(payload))
62
+ const given = Buffer.from(mac)
63
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) return undefined
64
+ if (Number(expires) <= this.now()) return undefined
65
+ if (this.revoked.has(deviceId) || !this.isActive(deviceId)) return undefined
66
+ return deviceId
67
+ }
68
+
69
+ /**
70
+ * Track a proxied WebSocket so removing the device closes it.
71
+ * @param {string} deviceId
72
+ * @param {import('node:net').Socket} socket
73
+ */
74
+ track(deviceId, socket) {
75
+ let set = this.sockets.get(deviceId)
76
+ if (set === undefined) {
77
+ set = new Set()
78
+ this.sockets.set(deviceId, set)
79
+ }
80
+ set.add(socket)
81
+ socket.once('close', () => set.delete(socket))
82
+ }
83
+
84
+ /** End every session and socket of a device. @param {string} deviceId */
85
+ revokeDevice(deviceId) {
86
+ this.revoked.add(deviceId)
87
+ for (const socket of this.sockets.get(deviceId) ?? []) socket.destroy()
88
+ this.sockets.delete(deviceId)
89
+ }
90
+ }
91
+
92
+ /**
93
+ * @param {string|undefined} header
94
+ * @param {string} name
95
+ */
96
+ export function readCookie(header, name) {
97
+ if (typeof header !== 'string') return undefined
98
+ for (const part of header.split(';')) {
99
+ const eq = part.indexOf('=')
100
+ if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim()
101
+ }
102
+ return undefined
103
+ }
104
+
105
+ /**
106
+ * Cookie header without one cookie.
107
+ * @param {string|undefined} header
108
+ * @param {string} name
109
+ */
110
+ export function withoutCookie(header, name) {
111
+ if (typeof header !== 'string') return ''
112
+ return header.split(';').map(p => p.trim()).filter(p => p !== '' && !p.startsWith(`${name}=`)).join('; ')
113
+ }
package/lib/x509.mjs ADDED
@@ -0,0 +1,228 @@
1
+ // Minimal X.509 v3 certificate builder for the gateway's private CA and server certificate.
2
+ // Only what the gateway needs: EC P-256 keys, ECDSA-SHA256 signatures, basicConstraints,
3
+ // keyUsage, extKeyUsage(serverAuth), subjectAltName (IP + DNS), subject/authority key ids.
4
+ // Verified with node:crypto X509Certificate after creation.
5
+
6
+ import { createHash, generateKeyPairSync, randomBytes, sign, X509Certificate } from 'node:crypto'
7
+ import { isIP, isIPv4 } from 'node:net'
8
+
9
+ // ---- DER primitives ---------------------------------------------------------------
10
+
11
+ function length(n) {
12
+ if (n < 0x80) return Buffer.from([n])
13
+ const bytes = []
14
+ while (n > 0) {
15
+ bytes.unshift(n & 0xff)
16
+ n >>>= 8
17
+ }
18
+ return Buffer.from([0x80 | bytes.length, ...bytes])
19
+ }
20
+
21
+ function tlv(tag, content) {
22
+ const body = Buffer.isBuffer(content) ? content : Buffer.concat(content)
23
+ return Buffer.concat([Buffer.from([tag]), length(body.length), body])
24
+ }
25
+
26
+ const seq = (...items) => tlv(0x30, items)
27
+ const set = (...items) => tlv(0x31, items)
28
+ const octets = (buf) => tlv(0x04, buf)
29
+ const bool = (value) => tlv(0x01, Buffer.from([value ? 0xff : 0x00]))
30
+ const utf8 = (text) => tlv(0x0c, Buffer.from(text, 'utf8'))
31
+ const ia5 = (text, tag = 0x16) => tlv(tag, Buffer.from(text, 'ascii'))
32
+ const bitString = (buf, unusedBits = 0) => tlv(0x03, Buffer.concat([Buffer.from([unusedBits]), buf]))
33
+ const explicit = (n, inner) => tlv(0xa0 | n, inner)
34
+
35
+ function integer(buf) {
36
+ let bytes = Buffer.from(buf)
37
+ while (bytes.length > 1 && bytes[0] === 0 && (bytes[1] & 0x80) === 0) bytes = bytes.subarray(1)
38
+ if (bytes[0] & 0x80) bytes = Buffer.concat([Buffer.from([0]), bytes])
39
+ return tlv(0x02, bytes)
40
+ }
41
+
42
+ function oid(dotted) {
43
+ const parts = dotted.split('.').map(Number)
44
+ const out = [40 * parts[0] + parts[1]]
45
+ for (const part of parts.slice(2)) {
46
+ const stack = [part & 0x7f]
47
+ let value = part >>> 7
48
+ while (value > 0) {
49
+ stack.unshift((value & 0x7f) | 0x80)
50
+ value >>>= 7
51
+ }
52
+ out.push(...stack)
53
+ }
54
+ return tlv(0x06, Buffer.from(out))
55
+ }
56
+
57
+ function time(date) {
58
+ const iso = date.toISOString().replace(/[-:T]/g, '').slice(0, 14) // YYYYMMDDHHMMSS
59
+ return date.getUTCFullYear() < 2050 ? ia5(`${iso.slice(2)}Z`, 0x17) : ia5(`${iso}Z`, 0x18)
60
+ }
61
+
62
+ // ---- OIDs ---------------------------------------------------------------------------
63
+
64
+ const OID = {
65
+ ecdsaWithSHA256: '1.2.840.10045.4.3.2',
66
+ commonName: '2.5.4.3',
67
+ organization: '2.5.4.10',
68
+ subjectKeyIdentifier: '2.5.29.14',
69
+ keyUsage: '2.5.29.15',
70
+ subjectAltName: '2.5.29.17',
71
+ basicConstraints: '2.5.29.19',
72
+ authorityKeyIdentifier: '2.5.29.35',
73
+ extKeyUsage: '2.5.29.37',
74
+ serverAuth: '1.3.6.1.5.5.7.3.1',
75
+ }
76
+
77
+ const signatureAlgorithm = () => seq(oid(OID.ecdsaWithSHA256))
78
+
79
+ function name(commonName) {
80
+ return seq(
81
+ set(seq(oid(OID.organization), utf8('DSH Mobile'))),
82
+ set(seq(oid(OID.commonName), utf8(commonName))),
83
+ )
84
+ }
85
+
86
+ function extension(id, critical, valueDer) {
87
+ return critical ? seq(oid(id), bool(true), octets(valueDer)) : seq(oid(id), octets(valueDer))
88
+ }
89
+
90
+ /** keyUsage bits: digitalSignature=0, keyCertSign=5, cRLSign=6. */
91
+ function keyUsage(bits) {
92
+ let byte = 0
93
+ for (const bit of bits) byte |= 0x80 >> bit
94
+ let unused = 0
95
+ while (unused < 7 && ((byte >> unused) & 1) === 0) unused += 1
96
+ return bitString(Buffer.from([byte]), unused)
97
+ }
98
+
99
+ function keyIdentifier(spkiDer) {
100
+ // RFC 5280 method 1: SHA-1 of the subjectPublicKey BIT STRING contents. For P-256 SPKI the
101
+ // uncompressed point is the last 65 bytes.
102
+ return createHash('sha1').update(spkiDer.subarray(spkiDer.length - 65)).digest()
103
+ }
104
+
105
+ function ipBytes(ip) {
106
+ if (isIPv4(ip)) return Buffer.from(ip.split('.').map(Number))
107
+ throw new Error(`unsupported IP SAN ${ip}`)
108
+ }
109
+
110
+ function serial() {
111
+ const bytes = randomBytes(16)
112
+ bytes[0] &= 0x7f
113
+ if (bytes[0] === 0) bytes[0] = 1
114
+ return bytes
115
+ }
116
+
117
+ /**
118
+ * @param {object} o
119
+ * @param {string} o.subjectCN
120
+ * @param {string} o.issuerCN
121
+ * @param {Buffer} o.subjectSpki - SPKI DER of the subject key
122
+ * @param {Buffer} o.issuerSpki - SPKI DER of the issuer key (for the authority key id)
123
+ * @param {import('node:crypto').KeyObject} o.issuerKey - private key that signs
124
+ * @param {Date} o.notBefore
125
+ * @param {Date} o.notAfter
126
+ * @param {Buffer[]} o.extensions
127
+ * @returns {Buffer} certificate DER
128
+ */
129
+ function build(o) {
130
+ const tbs = seq(
131
+ explicit(0, integer(Buffer.from([2]))),
132
+ integer(serial()),
133
+ signatureAlgorithm(),
134
+ name(o.issuerCN),
135
+ seq(time(o.notBefore), time(o.notAfter)),
136
+ name(o.subjectCN),
137
+ o.subjectSpki,
138
+ explicit(3, seq(...o.extensions)),
139
+ )
140
+ const signature = sign('sha256', tbs, { key: o.issuerKey, dsaEncoding: 'der' })
141
+ return seq(tbs, signatureAlgorithm(), bitString(signature))
142
+ }
143
+
144
+ const DAY = 24 * 60 * 60 * 1000
145
+
146
+ /**
147
+ * Create a self-signed EC P-256 CA.
148
+ * @param {{commonName?: string, days?: number, now?: Date}} [options]
149
+ * @returns {{certDer: Buffer, certPem: string, keyPem: string, fingerprint: string}}
150
+ */
151
+ export function createCa(options = {}) {
152
+ const now = options.now ?? new Date()
153
+ const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
154
+ const spki = publicKey.export({ format: 'der', type: 'spki' })
155
+ const cn = options.commonName ?? 'DSH Mobile Gateway CA'
156
+ const certDer = build({
157
+ subjectCN: cn,
158
+ issuerCN: cn,
159
+ subjectSpki: spki,
160
+ issuerSpki: spki,
161
+ issuerKey: privateKey,
162
+ notBefore: new Date(now.getTime() - DAY),
163
+ notAfter: new Date(now.getTime() + (options.days ?? 3650) * DAY),
164
+ extensions: [
165
+ extension(OID.basicConstraints, true, seq(bool(true), integer(Buffer.from([0])))),
166
+ extension(OID.keyUsage, true, keyUsage([5, 6])),
167
+ extension(OID.subjectKeyIdentifier, false, octets(keyIdentifier(spki))),
168
+ ],
169
+ })
170
+ const cert = new X509Certificate(certDer)
171
+ if (!cert.ca || !cert.verify(publicKey)) throw new Error('generated CA failed verification')
172
+ return { certDer, certPem: toPem(certDer), keyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }), fingerprint: fingerprintOf(certDer) }
173
+ }
174
+
175
+ /**
176
+ * Issue a server certificate signed by the CA.
177
+ * @param {object} o
178
+ * @param {string} o.caCertPem
179
+ * @param {import('node:crypto').KeyObject} o.caKey
180
+ * @param {string[]} o.ips - IPv4 addresses for IP SANs
181
+ * @param {string[]} o.dnsNames
182
+ * @param {number} [o.days]
183
+ * @param {Date} [o.now]
184
+ * @returns {{certPem: string, keyPem: string}}
185
+ */
186
+ export function issueServerCertificate(o) {
187
+ const now = o.now ?? new Date()
188
+ const ca = new X509Certificate(o.caCertPem)
189
+ const caSpki = ca.publicKey.export({ format: 'der', type: 'spki' })
190
+ const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' })
191
+ const spki = publicKey.export({ format: 'der', type: 'spki' })
192
+ const ips = [...new Set(o.ips.filter(ip => isIP(ip) === 4))]
193
+ const dnsNames = [...new Set(o.dnsNames.filter(n => /^[A-Za-z0-9.-]{1,253}$/.test(n)))]
194
+ if (ips.length + dnsNames.length === 0) throw new Error('server certificate needs at least one SAN')
195
+ const sans = [...ips.map(ip => tlv(0x87, ipBytes(ip))), ...dnsNames.map(n => ia5(n, 0x82))]
196
+ const certDer = build({
197
+ subjectCN: ips[0] ?? dnsNames[0],
198
+ issuerCN: ca.subject.split('\n').find(l => l.startsWith('CN='))?.slice(3) ?? 'DSH Mobile Gateway CA',
199
+ subjectSpki: spki,
200
+ issuerSpki: caSpki,
201
+ issuerKey: o.caKey,
202
+ notBefore: new Date(now.getTime() - DAY),
203
+ notAfter: new Date(now.getTime() + (o.days ?? 397) * DAY),
204
+ extensions: [
205
+ extension(OID.basicConstraints, true, seq()),
206
+ extension(OID.keyUsage, true, keyUsage([0])),
207
+ extension(OID.extKeyUsage, false, seq(oid(OID.serverAuth))),
208
+ extension(OID.subjectAltName, false, seq(...sans)),
209
+ extension(OID.subjectKeyIdentifier, false, octets(keyIdentifier(spki))),
210
+ extension(OID.authorityKeyIdentifier, false, seq(tlv(0x80, keyIdentifier(caSpki)))),
211
+ ],
212
+ })
213
+ const cert = new X509Certificate(certDer)
214
+ if (!cert.verify(ca.publicKey) || cert.ca) throw new Error('generated server certificate failed verification')
215
+ for (const ip of ips) if (cert.checkIP(ip) === undefined) throw new Error(`server certificate missing IP SAN ${ip}`)
216
+ return { certPem: toPem(certDer), keyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }) }
217
+ }
218
+
219
+ /** @param {Buffer} der */
220
+ export function fingerprintOf(der) {
221
+ return createHash('sha256').update(der).digest('hex')
222
+ }
223
+
224
+ /** @param {Buffer} der */
225
+ export function toPem(der) {
226
+ const b64 = der.toString('base64').match(/.{1,64}/g).join('\n')
227
+ return `-----BEGIN CERTIFICATE-----\n${b64}\n-----END CERTIFICATE-----\n`
228
+ }
package/package.json CHANGED
@@ -1,6 +1,65 @@
1
1
  {
2
2
  "name": "@kinocode/dsh-mobile-bridge",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.3.2",
4
+ "description": "DeepSeek Harness host plugin for the DSH Mobile Android app: pair with a QR, use DSH on the phone through its own HTTPS gateway, answer approvals and questions from notifications, push turn completion.",
5
+ "keywords": [
6
+ "deepseek-harness",
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "plugin",
10
+ "mobile",
11
+ "android",
12
+ "push-notifications",
13
+ "approval",
14
+ "relay"
15
+ ],
16
+ "author": "kinocode",
17
+ "homepage": "https://www.npmjs.com/package/@kinocode/dsh-mobile-bridge",
18
+ "type": "module",
19
+ "main": "lib/index.mjs",
20
+ "exports": {
21
+ ".": "./lib/index.mjs",
22
+ "./client": "./lib/client.js",
23
+ "./cordis.patch.yml": "./cordis.patch.yml",
24
+ "./package.json": "./package.json"
25
+ },
26
+ "files": [
27
+ "LICENSE",
28
+ "README.md",
29
+ "cordis.patch.yml",
30
+ "lib/**/*.mjs",
31
+ "lib/client.js",
32
+ "lib/vendor/*.mjs"
33
+ ],
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ },
38
+ "client": {
39
+ "inject": [
40
+ "@deepseek-ai/dsh-client-ui-workspace",
41
+ "@deepseek-ai/dsh-client-ui-sidebar"
42
+ ],
43
+ "platform": "web"
44
+ }
45
+ },
46
+ "peerDependencies": {
47
+ "@deepseek-ai/cordis": "~4.0.4"
48
+ },
49
+ "peerDependenciesMeta": {
50
+ "@deepseek-ai/cordis": {
51
+ "optional": true
52
+ }
53
+ },
54
+ "engines": {
55
+ "node": ">=22.4",
56
+ "dsh": ">=0.2.1-rc.2 <0.3.0"
57
+ },
58
+ "scripts": {
59
+ "test": "node --test test/*.test.mjs"
60
+ },
61
+ "license": "MIT",
62
+ "publishConfig": {
63
+ "access": "public"
64
+ }
65
+ }