ocremote 1.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,165 @@
1
+ import crypto from 'node:crypto'
2
+ import fs from 'node:fs'
3
+ import path from 'node:path'
4
+ import { b64, deviceIDFromPublic, exportRawPublic, generateIdentityKeys, randomBytes, unb64 } from './crypto.mjs'
5
+
6
+ const PKCS8_PREFIX = Buffer.from('302e020100300506032b657004220420', 'hex')
7
+
8
+ export function privateKeyFromSeed(seed) {
9
+ return crypto.createPrivateKey({
10
+ key: Buffer.concat([PKCS8_PREFIX, Buffer.from(seed)]),
11
+ format: 'der',
12
+ type: 'pkcs8',
13
+ })
14
+ }
15
+
16
+ export class Identity {
17
+ constructor(dir) {
18
+ this.dir = dir
19
+ this.file = path.join(dir, 'identity.json')
20
+ this.pairsDir = path.join(dir, 'pairs')
21
+ this.identity = null
22
+ }
23
+
24
+ load() {
25
+ try {
26
+ const parsed = JSON.parse(fs.readFileSync(this.file, 'utf8'))
27
+ this.identity = {
28
+ id: parsed.id,
29
+ name: parsed.name,
30
+ publicRaw: unb64(parsed.publicKey),
31
+ privateKey: privateKeyFromSeed(unb64(parsed.privateKey)),
32
+ createdAt: parsed.createdAt,
33
+ }
34
+ return this
35
+ } catch {
36
+ this.identity = null
37
+ return null
38
+ }
39
+ }
40
+
41
+ ensure(name) {
42
+ this.load()
43
+ if (!this.identity) {
44
+ const keys = generateIdentityKeys()
45
+ this.identity = {
46
+ id: deviceIDFromPublic(keys.publicRaw),
47
+ name,
48
+ publicRaw: keys.publicRaw,
49
+ privateKey: keys.privateKey,
50
+ createdAt: new Date().toISOString(),
51
+ }
52
+ this.save()
53
+ return this
54
+ }
55
+ if (name && this.identity.name !== name) {
56
+ this.identity.name = name
57
+ this.save()
58
+ }
59
+ return this
60
+ }
61
+
62
+ save() {
63
+ fs.mkdirSync(this.dir, { recursive: true, mode: 0o700 })
64
+ const seed = this.identity.privateKey.export({ type: 'pkcs8', format: 'der' }).subarray(-32)
65
+ const payload = {
66
+ version: 2,
67
+ id: this.identity.id,
68
+ name: this.identity.name,
69
+ publicKey: b64(this.identity.publicRaw),
70
+ privateKey: b64(seed),
71
+ createdAt: this.identity.createdAt,
72
+ updatedAt: new Date().toISOString(),
73
+ }
74
+ const file = `${this.file}.tmp`
75
+ fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
76
+ fs.renameSync(file, this.file)
77
+ try {
78
+ fs.chmodSync(this.file, 0o600)
79
+ } catch {}
80
+ }
81
+
82
+ sign(message) {
83
+ return crypto.sign(null, Buffer.from(message, 'utf8'), this.identity.privateKey)
84
+ }
85
+
86
+ publicRaw() {
87
+ return this.identity.publicRaw
88
+ }
89
+
90
+ publicKeyB64() {
91
+ return b64(exportRawPublic(crypto.createPublicKey(this.identity.privateKey)))
92
+ }
93
+
94
+ deviceID() {
95
+ return this.identity.id
96
+ }
97
+
98
+ name() {
99
+ return this.identity.name
100
+ }
101
+
102
+ pairFile(pairID) {
103
+ return path.join(this.pairsDir, `${pairID}.json`)
104
+ }
105
+
106
+ savePairWithKey({ pairID, clientID, clientPK, name, pairKey }) {
107
+ fs.mkdirSync(this.pairsDir, { recursive: true, mode: 0o700 })
108
+ const payload = {
109
+ version: 2,
110
+ pairID,
111
+ clientID,
112
+ clientPK,
113
+ name: name || '',
114
+ pairKey: b64(pairKey),
115
+ createdAt: new Date().toISOString(),
116
+ }
117
+ const file = `${this.pairFile(pairID)}.tmp`
118
+ fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 })
119
+ fs.renameSync(file, this.pairFile(pairID))
120
+ }
121
+
122
+ pair(pairID) {
123
+ try {
124
+ const parsed = JSON.parse(fs.readFileSync(this.pairFile(pairID), 'utf8'))
125
+ return {
126
+ pairID: parsed.pairID,
127
+ clientID: parsed.clientID,
128
+ clientPK: parsed.clientPK,
129
+ name: parsed.name,
130
+ pairKey: unb64(parsed.pairKey),
131
+ createdAt: parsed.createdAt,
132
+ }
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+
138
+ pairs() {
139
+ try {
140
+ return fs
141
+ .readdirSync(this.pairsDir)
142
+ .filter((file) => file.endsWith('.json'))
143
+ .map((file) => this.pair(file.replace(/\.json$/, '')))
144
+ .filter(Boolean)
145
+ } catch {
146
+ return []
147
+ }
148
+ }
149
+
150
+ removePair(pairID) {
151
+ try {
152
+ fs.rmSync(this.pairFile(pairID))
153
+ } catch {}
154
+ }
155
+ }
156
+
157
+ export function newInvite() {
158
+ return {
159
+ pairID: `p_${randomBytes(6).toString('hex')}`,
160
+ secret: randomBytes(32),
161
+ createdAt: Date.now(),
162
+ expiresAt: Date.now() + 5 * 60_000,
163
+ used: false,
164
+ }
165
+ }
package/lib/relay.mjs ADDED
@@ -0,0 +1,495 @@
1
+ import {
2
+ CHANNEL_OPEN,
3
+ CHANNEL_SEALED,
4
+ DIR_CLIENT,
5
+ DIR_COMPANION,
6
+ PROTOCOL_VERSION,
7
+ b64,
8
+ control,
9
+ frame,
10
+ openWire,
11
+ sealWire,
12
+ inviteSessionKey,
13
+ open,
14
+ pairKeyFromInvite,
15
+ parseFrame,
16
+ randomBytes,
17
+ seal,
18
+ sessionKey,
19
+ sign,
20
+ unb64,
21
+ verify,
22
+ } from './crypto.mjs'
23
+
24
+ const RECONNECT_MIN = 1500
25
+ const RECONNECT_MAX = 30_000
26
+ const PING_INTERVAL = 25_000
27
+ const STALE_AFTER = 75_000
28
+
29
+ export class RelayClient {
30
+ constructor({ relayUrl, identity, adapter, logger, invites, onEvent }) {
31
+ this.relayUrl = relayUrl.replace(/\/+$/, '')
32
+ this.httpBase = this.relayUrl.replace(/^ws/, 'http')
33
+ this.identity = identity
34
+ this.adapter = adapter
35
+ this.logger = logger
36
+ this.invites = invites
37
+ this.onEvent = onEvent || (() => {})
38
+ this.ws = null
39
+ this.sessions = new Map()
40
+ this.stopped = false
41
+ this.attempt = 0
42
+ this.lastMessageAt = 0
43
+ this.pingTimer = null
44
+ this.reconnectTimer = null
45
+ }
46
+
47
+ log(message) {
48
+ this.logger?.(message)
49
+ }
50
+
51
+ async api(path, { method = 'POST', body, headers = {} } = {}) {
52
+ const res = await fetch(`${this.httpBase}${path}`, {
53
+ method,
54
+ headers: { 'content-type': 'application/json', ...headers },
55
+ body: body === undefined ? undefined : JSON.stringify(body),
56
+ signal: AbortSignal.timeout(10_000),
57
+ })
58
+ const text = await res.text()
59
+ let parsed = null
60
+ try {
61
+ parsed = text ? JSON.parse(text) : null
62
+ } catch {}
63
+ if (!res.ok) {
64
+ const error = new Error(parsed?.message || `HTTP ${res.status}`)
65
+ error.status = res.status
66
+ error.code = parsed?.error
67
+ throw error
68
+ }
69
+ return parsed
70
+ }
71
+
72
+ deviceAuth() {
73
+ const ts = Math.floor(Date.now() / 1000)
74
+ const name = this.identity.name()
75
+ const deviceID = this.identity.deviceID()
76
+ const message = `ocremote-register-v1|${deviceID}|${name}|${ts}`
77
+ return {
78
+ deviceID,
79
+ name,
80
+ pk: this.identity.publicKeyB64(),
81
+ ts,
82
+ sig: b64(this.identity.sign(message)),
83
+ }
84
+ }
85
+
86
+ async registerDevice(version) {
87
+ const body = { ...this.deviceAuth(), companionVersion: version }
88
+ return this.api('/v1/devices', { body })
89
+ }
90
+
91
+ async registerPair(pair) {
92
+ const ts = Math.floor(Date.now() / 1000)
93
+ const deviceID = this.identity.deviceID()
94
+ const message = `ocremote-pair-v1|${deviceID}|${pair.pairID}|${pair.clientID}|${ts}`
95
+ await this.api('/v1/pairs', {
96
+ body: {
97
+ deviceID,
98
+ pairID: pair.pairID,
99
+ clientID: pair.clientID,
100
+ clientPK: pair.clientPK,
101
+ name: pair.name,
102
+ ts,
103
+ sig: b64(this.identity.sign(message)),
104
+ },
105
+ })
106
+ }
107
+
108
+ async revokePair(pairID) {
109
+ const ts = Math.floor(Date.now() / 1000)
110
+ const deviceID = this.identity.deviceID()
111
+ const message = `ocremote-unpair-v1|${deviceID}|${pairID}|${ts}`
112
+ const query = new URLSearchParams({
113
+ ts: String(ts),
114
+ sig: b64(this.identity.sign(message)),
115
+ })
116
+ await this.api(`/v1/pairs/${pairID}?${query}`, { method: 'DELETE' })
117
+ }
118
+
119
+ async registerInvite(invite) {
120
+ const ts = Math.floor(Date.now() / 1000)
121
+ const exp = Math.floor(invite.expiresAt / 1000)
122
+ const deviceID = this.identity.deviceID()
123
+ const message = `ocremote-invite-v1|${deviceID}|${invite.pairID}|${exp}|${ts}`
124
+ await this.api('/v1/invites', {
125
+ body: {
126
+ deviceID,
127
+ pairID: invite.pairID,
128
+ exp,
129
+ ts,
130
+ sig: b64(this.identity.sign(message)),
131
+ },
132
+ })
133
+ }
134
+
135
+ async createInvite() {
136
+ const invite = {
137
+ pairID: `p_${randomBytes(6).toString('hex')}`,
138
+ secret: randomBytes(32),
139
+ createdAt: Date.now(),
140
+ expiresAt: Date.now() + 5 * 60_000,
141
+ }
142
+ this.invites.set(invite.pairID, invite)
143
+ if (this.ws && this.ws.readyState === 1) {
144
+ try {
145
+ await this.registerInvite(invite)
146
+ } catch (err) {
147
+ this.log(`could not register invite with the relay: ${err.message}`)
148
+ }
149
+ }
150
+ return invite
151
+ }
152
+
153
+ pruneInvites() {
154
+ const now = Date.now()
155
+ for (const [id, invite] of this.invites) {
156
+ if (invite.expiresAt < now) this.invites.delete(id)
157
+ }
158
+ }
159
+
160
+ inviteURL(invite) {
161
+ const payload = {
162
+ v: 2,
163
+ relay: this.relayUrl,
164
+ device: this.identity.deviceID(),
165
+ name: this.identity.name(),
166
+ pk: this.identity.publicKeyB64(),
167
+ pairID: invite.pairID,
168
+ secret: b64(invite.secret),
169
+ exp: Math.floor(invite.expiresAt / 1000),
170
+ }
171
+ return `ocremote://pair?v=2&d=${b64(Buffer.from(JSON.stringify(payload), 'utf8'))}`
172
+ }
173
+
174
+ start() {
175
+ this.stopped = false
176
+ this.connect()
177
+ }
178
+
179
+ stop() {
180
+ this.stopped = true
181
+ clearTimeout(this.reconnectTimer)
182
+ clearInterval(this.pingTimer)
183
+ for (const session of this.sessions.values()) session.close('relay_stopped')
184
+ this.sessions.clear()
185
+ if (this.ws) {
186
+ try {
187
+ this.ws.close()
188
+ } catch {}
189
+ this.ws = null
190
+ }
191
+ }
192
+
193
+ scheduleReconnect() {
194
+ if (this.stopped) return
195
+ this.attempt++
196
+ const base = Math.min(RECONNECT_MAX, RECONNECT_MIN * 2 ** Math.min(this.attempt - 1, 5))
197
+ const delay = Math.round(base * (0.7 + Math.random() * 0.6))
198
+ this.log(`relay reconnecting in ${delay}ms (attempt ${this.attempt})`)
199
+ clearTimeout(this.reconnectTimer)
200
+ this.reconnectTimer = setTimeout(() => this.connect(), delay)
201
+ }
202
+
203
+ async connect() {
204
+ if (this.stopped) return
205
+ try {
206
+ const challenge = await this.api('/v1/challenge', {
207
+ body: { deviceID: this.identity.deviceID() },
208
+ })
209
+ const role = 'companion'
210
+ const message = `ocremote-ws-v1|${role}|${this.identity.deviceID()}||${challenge.challenge}`
211
+ const url = `${this.relayUrl}/v1/ws?role=${role}&device=${this.identity.deviceID()}`
212
+ const ws = new WebSocket(url, {
213
+ headers: {
214
+ 'X-Challenge': challenge.challenge,
215
+ 'X-Signature': b64(this.identity.sign(message)),
216
+ },
217
+ })
218
+ ws.binaryType = 'arraybuffer'
219
+ this.ws = ws
220
+ ws.addEventListener('open', () => {
221
+ this.attempt = 0
222
+ this.lastMessageAt = Date.now()
223
+ this.log(`relay connected ${this.relayUrl} device=${this.identity.deviceID()}`)
224
+ this.onEvent({ type: 'relay_connected' })
225
+ clearInterval(this.pingTimer)
226
+ this.pingTimer = setInterval(() => {
227
+ if (Date.now() - this.lastMessageAt > STALE_AFTER) {
228
+ this.log('relay silent; reconnecting')
229
+ try {
230
+ ws.close()
231
+ } catch {}
232
+ return
233
+ }
234
+ this.send(control({ t: 'ping' }))
235
+ }, PING_INTERVAL)
236
+ })
237
+ ws.addEventListener('message', (event) => {
238
+ this.lastMessageAt = Date.now()
239
+ this.handleMessage(event.data)
240
+ })
241
+ ws.addEventListener('close', () => {
242
+ if (this.ws !== ws) return
243
+ clearInterval(this.pingTimer)
244
+ for (const session of this.sessions.values()) session.close('relay_disconnected')
245
+ this.sessions.clear()
246
+ this.ws = null
247
+ this.onEvent({ type: 'relay_disconnected' })
248
+ if (!this.stopped) this.scheduleReconnect()
249
+ })
250
+ ws.addEventListener('error', () => {
251
+ try {
252
+ ws.close()
253
+ } catch {}
254
+ })
255
+ } catch (err) {
256
+ this.log(`relay connect failed: ${err.message}`)
257
+ this.scheduleReconnect()
258
+ }
259
+ }
260
+
261
+ send(data) {
262
+ if (!this.ws || this.ws.readyState !== 1) return false
263
+ try {
264
+ this.ws.send(data)
265
+ return true
266
+ } catch {
267
+ return false
268
+ }
269
+ }
270
+
271
+ handleMessage(data) {
272
+ const buffer = Buffer.from(data)
273
+ const parsed = parseFrame(buffer)
274
+ if (!parsed) return
275
+ if (parsed.channel === 0x00) {
276
+ this.handleControl(parsed.payload)
277
+ return
278
+ }
279
+ const session = this.sessions.get(parsed.session)
280
+ if (!session) return
281
+ if (parsed.channel === CHANNEL_OPEN) {
282
+ session.handleOpen(parsed.payload)
283
+ } else if (parsed.channel === CHANNEL_SEALED) {
284
+ session.handleSealed(parsed.payload)
285
+ }
286
+ }
287
+
288
+ handleControl(payload) {
289
+ let msg = null
290
+ try {
291
+ msg = JSON.parse(payload.toString('utf8'))
292
+ } catch {
293
+ return
294
+ }
295
+ if (msg.t === 'session_open') {
296
+ const session = new RelaySession({
297
+ id: msg.session,
298
+ clientID: msg.clientID,
299
+ relay: this,
300
+ identity: this.identity,
301
+ adapter: this.adapter,
302
+ logger: this.logger,
303
+ })
304
+ this.sessions.set(msg.session, session)
305
+ this.log(`session ${msg.session} opened (${msg.clientID})`)
306
+ this.onEvent({ type: 'session_open', session: msg.session, clientID: msg.clientID })
307
+ return
308
+ }
309
+ if (msg.t === 'session_close') {
310
+ const session = this.sessions.get(msg.session)
311
+ if (session) {
312
+ session.close('client_disconnected')
313
+ this.sessions.delete(msg.session)
314
+ }
315
+ this.onEvent({ type: 'session_close', session: msg.session })
316
+ return
317
+ }
318
+ if (msg.t === 'pong') return
319
+ if (msg.t === 'hello') {
320
+ this.log(`relay hello: ${JSON.stringify(msg.device || {})}`)
321
+ return
322
+ }
323
+ if (msg.t === 'error') {
324
+ this.log(`relay error: ${msg.code}`)
325
+ }
326
+ }
327
+ }
328
+
329
+ export class RelaySession {
330
+ constructor({ id, clientID, relay, identity, adapter, logger }) {
331
+ this.id = id
332
+ this.clientID = clientID
333
+ this.relay = relay
334
+ this.identity = identity
335
+ this.adapter = adapter
336
+ this.logger = logger
337
+ this.mode = 'awaiting'
338
+ this.pairID = null
339
+ this.key = null
340
+ this.clientNonce = null
341
+ this.serverNonce = null
342
+ this.sendCounter = 0
343
+ this.recvCounter = -1
344
+ this.closed = false
345
+ this.failures = 0
346
+ }
347
+
348
+ log(message) {
349
+ this.logger?.(message)
350
+ }
351
+
352
+ close(reason) {
353
+ if (this.closed) return
354
+ this.closed = true
355
+ this.adapter?.cancelAll(this)
356
+ this.log(`session ${this.id} closed (${reason})`)
357
+ }
358
+
359
+ sendOpen(payload) {
360
+ this.relay.send(frame(CHANNEL_OPEN, this.id, Buffer.from(JSON.stringify(payload), 'utf8')))
361
+ }
362
+
363
+ sendSealed(payload) {
364
+ if (!this.key || this.closed) return
365
+ const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
366
+ const sealed = sealWire(this.key, DIR_COMPANION, this.sendCounter, bytes)
367
+ this.sendCounter++
368
+ this.relay.send(frame(CHANNEL_SEALED, this.id, sealed))
369
+ }
370
+
371
+ handleOpen(payload) {
372
+ let msg = null
373
+ try {
374
+ msg = JSON.parse(payload.toString('utf8'))
375
+ } catch {
376
+ return
377
+ }
378
+ if (msg.t !== 'hs1' || msg.v !== PROTOCOL_VERSION) return
379
+ this.clientNonce = unb64(msg.nonce)
380
+ if (this.clientNonce.length !== 32) {
381
+ this.close('bad_nonce')
382
+ return
383
+ }
384
+ this.serverNonce = randomBytes(32)
385
+ this.sendCounter = 0
386
+ this.recvCounter = -1
387
+ this.failures = 0
388
+ this.sendOpen({ t: 'hs2', v: PROTOCOL_VERSION, nonce: b64(this.serverNonce) })
389
+
390
+ const pair = this.identity.pair(msg.pair)
391
+ if (pair) {
392
+ this.mode = 'paired'
393
+ this.pairID = pair.pairID
394
+ this.clientID = pair.clientID
395
+ this.key = sessionKey(pair.pairKey, this.clientNonce, this.serverNonce, pair.pairID)
396
+ this.log(`session ${this.id} handshake ok (paired ${pair.clientID})`)
397
+ return
398
+ }
399
+
400
+ const invite = this.relay.invites.get(msg.pair)
401
+ if (!invite || Date.now() > invite.expiresAt) {
402
+ this.log(`session ${this.id} rejected: unknown or expired invite`)
403
+ this.close('invite_invalid')
404
+ return
405
+ }
406
+ this.mode = 'pairing'
407
+ this.pairID = invite.pairID
408
+ this.key = inviteSessionKey(invite.secret, this.clientNonce, this.serverNonce)
409
+ this.log(`session ${this.id} handshake ok (pairing invite ${invite.pairID})`)
410
+ }
411
+
412
+ handleSealed(payload) {
413
+ if (!this.key || this.closed) return
414
+ let opened = null
415
+ try {
416
+ opened = openWire(this.key, DIR_CLIENT, payload, BigInt(this.recvCounter))
417
+ } catch (err) {
418
+ this.failures++
419
+ this.log(`session ${this.id} sealed rejected: ${err.message} (${this.failures}/3)`)
420
+ if (this.failures >= 3) this.close('sealed_rejected')
421
+ return
422
+ }
423
+ this.recvCounter = opened.counter
424
+ this.failures = 0
425
+ let msg = null
426
+ try {
427
+ msg = JSON.parse(opened.plaintext.toString('utf8'))
428
+ } catch {
429
+ return
430
+ }
431
+ this.handleMessage(msg)
432
+ }
433
+
434
+ handleMessage(msg) {
435
+ if (msg.t === 'pair' && this.mode === 'pairing') {
436
+ this.completePairing(msg)
437
+ return
438
+ }
439
+ if (msg.t === 'ping') {
440
+ this.sendSealed({ t: 'pong' })
441
+ return
442
+ }
443
+ if (msg.t === 'req') {
444
+ this.adapter?.handle(this, msg)
445
+ return
446
+ }
447
+ if (msg.t === 'cancel') {
448
+ this.adapter?.cancel(this, msg.id)
449
+ }
450
+ }
451
+
452
+ async completePairing(msg) {
453
+ const invite = this.relay.invites.get(this.pairID)
454
+ if (!invite) {
455
+ this.close('invite_missing')
456
+ return
457
+ }
458
+ if (typeof msg.clientID !== 'string' || typeof msg.clientPK !== 'string') {
459
+ this.close('bad_pair_request')
460
+ return
461
+ }
462
+ const pairKey = pairKeyFromInvite(invite.secret, this.clientNonce, this.serverNonce)
463
+ this.relay.invites.delete(this.pairID)
464
+ this.identity.savePairWithKey({
465
+ pairID: this.pairID,
466
+ clientID: msg.clientID,
467
+ clientPK: msg.clientPK,
468
+ name: msg.name,
469
+ pairKey,
470
+ })
471
+ try {
472
+ await this.relay.registerPair({
473
+ pairID: this.pairID,
474
+ clientID: msg.clientID,
475
+ clientPK: msg.clientPK,
476
+ name: msg.name,
477
+ })
478
+ } catch (err) {
479
+ this.log(`pair registration failed: ${err.message}`)
480
+ }
481
+ this.mode = 'paired'
482
+ this.clientID = msg.clientID
483
+ this.sendSealed({
484
+ t: 'paired',
485
+ v: PROTOCOL_VERSION,
486
+ deviceID: this.identity.deviceID(),
487
+ pairID: this.pairID,
488
+ clientID: msg.clientID,
489
+ })
490
+ this.log(`paired with ${msg.clientID} (${msg.name || 'device'})`)
491
+ this.relay.onEvent({ type: 'paired', pairID: this.pairID, clientID: msg.clientID, name: msg.name })
492
+ }
493
+ }
494
+
495
+ export { verify }