dsh-device-fleet 0.0.0-stage → 0.1.0-dev.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/CONTEXT.md +123 -0
- package/LICENSE +21 -0
- package/README.md +133 -2
- package/cordis.patch.yml +26 -0
- package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
- package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
- package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
- package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
- package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
- package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
- package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
- package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
- package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
- package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
- package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
- package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
- package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
- package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
- package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
- package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
- package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
- package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
- package/docs/adr/0019-execution-context-configured-locally.md +24 -0
- package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
- package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
- package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
- package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
- package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
- package/docs/notes/design-decisions-summary.md +49 -0
- package/docs/notes/dev-release.md +28 -0
- package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
- package/docs/notes/network-v1-v2-implementation.md +71 -0
- package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
- package/package.json +42 -3
- package/src/host/direct-call.js +190 -0
- package/src/host/execution-slot.js +82 -0
- package/src/host/index.js +6 -0
- package/src/host/store.js +128 -0
- package/src/host/tool-policy.js +150 -0
- package/src/host/tools.js +215 -0
- package/src/index.js +3 -0
- package/src/net/direct.js +69 -0
- package/src/net/discovery.js +105 -0
- package/src/net/fleet-node.js +414 -0
- package/src/net/identity.js +156 -0
- package/src/net/index.js +9 -0
- package/src/net/protocol.js +73 -0
- package/src/net/relay-client.js +258 -0
- package/src/net/relay-server.js +366 -0
- package/src/net/secure-channel.js +538 -0
- package/src/plugin/index.js +285 -0
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
// End-to-end secure channel between two fleet devices.
|
|
2
|
+
//
|
|
3
|
+
// Handshake (newline-delimited JSON):
|
|
4
|
+
// initiator -> responder : HELLO (static keys + fleet proof + ephemeral X25519 + signature)
|
|
5
|
+
// responder -> initiator : HELLO_ACK
|
|
6
|
+
// both : encrypted READY frame
|
|
7
|
+
// Then every frame is AES-256-GCM with per-direction keys derived from
|
|
8
|
+
// X25519(ephemeral, ephemeral) + HKDF over the handshake transcript. The relay,
|
|
9
|
+
// if used, only splices the underlying bytes and cannot read frames.
|
|
10
|
+
import {
|
|
11
|
+
createCipheriv,
|
|
12
|
+
createDecipheriv,
|
|
13
|
+
createHash,
|
|
14
|
+
diffieHellman,
|
|
15
|
+
hkdfSync,
|
|
16
|
+
randomBytes,
|
|
17
|
+
sign,
|
|
18
|
+
verify,
|
|
19
|
+
} from 'node:crypto'
|
|
20
|
+
import { EventEmitter } from 'node:events'
|
|
21
|
+
import {
|
|
22
|
+
FRAME,
|
|
23
|
+
HANDSHAKE_TIMEOUT_MS,
|
|
24
|
+
MAX_FRAME_BYTES,
|
|
25
|
+
PROTOCOL_VERSION,
|
|
26
|
+
stableStringify,
|
|
27
|
+
} from './protocol.js'
|
|
28
|
+
import {
|
|
29
|
+
generateEphemeral,
|
|
30
|
+
importPrivateKey,
|
|
31
|
+
importPublicKey,
|
|
32
|
+
membershipProof,
|
|
33
|
+
verifyMembershipProof,
|
|
34
|
+
} from './identity.js'
|
|
35
|
+
|
|
36
|
+
const AAD = Buffer.from('dsh-device-fleet/frame/v1')
|
|
37
|
+
const MAX_CLOCK_SKEW_MS = 120_000
|
|
38
|
+
|
|
39
|
+
function sha256(buffer) {
|
|
40
|
+
return createHash('sha256').update(buffer).digest()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function b64(buffer) {
|
|
44
|
+
return Buffer.from(buffer).toString('base64url')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isNonEmptyString(value) {
|
|
48
|
+
return typeof value === 'string' && value.length > 0
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class SecureChannel extends EventEmitter {
|
|
52
|
+
/**
|
|
53
|
+
* @param {object} options
|
|
54
|
+
* @param {import('node:net').Socket} options.socket connected socket
|
|
55
|
+
* @param {object} options.identity local identity ({deviceId, edPrivate, edPublic, xPublic, name})
|
|
56
|
+
* @param {{fleetId: string, fleetKey: string}} options.fleet
|
|
57
|
+
* @param {'initiator'|'responder'} options.role
|
|
58
|
+
* @param {object|null} [options.expectedPeer] pinned peer ({deviceId, edPublic, xPublic})
|
|
59
|
+
* @param {string|null} [options.accountFingerprint] expected account fingerprint
|
|
60
|
+
* @param {(peer: object) => boolean|Promise<boolean>} [options.verifyPeer] trust decision after protocol checks
|
|
61
|
+
* @param {string} [options.pluginVersion] strict plugin version (ADR-0024)
|
|
62
|
+
* @param {string} [options.dshVersion] strict harness version line (ADR-0024)
|
|
63
|
+
* @param {string[]} [options.capabilities] reserved capability declarations
|
|
64
|
+
*/
|
|
65
|
+
constructor({
|
|
66
|
+
socket,
|
|
67
|
+
identity,
|
|
68
|
+
fleet,
|
|
69
|
+
role,
|
|
70
|
+
expectedPeer = null,
|
|
71
|
+
accountFingerprint = null,
|
|
72
|
+
verifyPeer = null,
|
|
73
|
+
pluginVersion = '0.0.0',
|
|
74
|
+
dshVersion = 'unknown',
|
|
75
|
+
capabilities = [],
|
|
76
|
+
}) {
|
|
77
|
+
super()
|
|
78
|
+
if (role !== 'initiator' && role !== 'responder') throw new Error('invalid channel role')
|
|
79
|
+
this.socket = socket
|
|
80
|
+
this.identity = identity
|
|
81
|
+
this.fleet = fleet
|
|
82
|
+
this.role = role
|
|
83
|
+
this.expectedPeer = expectedPeer
|
|
84
|
+
this.accountFingerprint = accountFingerprint
|
|
85
|
+
this.verifyPeer = verifyPeer
|
|
86
|
+
this.pluginVersion = pluginVersion
|
|
87
|
+
this.dshVersion = dshVersion
|
|
88
|
+
this.capabilities = [...capabilities].sort()
|
|
89
|
+
|
|
90
|
+
this.phase = 'handshake'
|
|
91
|
+
this.recvBuffer = Buffer.alloc(0)
|
|
92
|
+
this.readySent = false
|
|
93
|
+
this.readyReceived = false
|
|
94
|
+
this.authenticated = false
|
|
95
|
+
this.closed = false
|
|
96
|
+
this.peer = null
|
|
97
|
+
this.failure = null
|
|
98
|
+
|
|
99
|
+
this.sendKey = null
|
|
100
|
+
this.recvKey = null
|
|
101
|
+
this.sendPrefix = null
|
|
102
|
+
this.recvPrefix = null
|
|
103
|
+
this.sendCounter = 0n
|
|
104
|
+
this.recvCounter = 0n
|
|
105
|
+
|
|
106
|
+
this.handshakeTimer = setTimeout(() => {
|
|
107
|
+
this.fail('handshake-timeout', 'secure channel handshake timed out')
|
|
108
|
+
}, HANDSHAKE_TIMEOUT_MS)
|
|
109
|
+
this.handshakeTimer.unref?.()
|
|
110
|
+
|
|
111
|
+
socket.setNoDelay?.(true)
|
|
112
|
+
socket.on('data', (chunk) => this.onData(chunk))
|
|
113
|
+
socket.on('error', (error) => this.onSocketError(error))
|
|
114
|
+
socket.on('close', () => this.onSocketClose())
|
|
115
|
+
|
|
116
|
+
if (socket.connecting) socket.once('connect', () => this.start())
|
|
117
|
+
else this.start()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
start() {
|
|
121
|
+
if (this.phase !== 'handshake' || this.closed) return
|
|
122
|
+
if (this.role === 'initiator') this.sendHello()
|
|
123
|
+
// responder waits for HELLO
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------- handshake
|
|
127
|
+
|
|
128
|
+
sendHello() {
|
|
129
|
+
this.ephemeral = generateEphemeral()
|
|
130
|
+
const nonce = randomBytes(32)
|
|
131
|
+
const fields = {
|
|
132
|
+
t: 'hello',
|
|
133
|
+
v: PROTOCOL_VERSION,
|
|
134
|
+
fleetId: this.fleet.fleetId,
|
|
135
|
+
deviceId: this.identity.deviceId,
|
|
136
|
+
name: this.identity.name ?? 'device',
|
|
137
|
+
edPublic: this.identity.edPublic,
|
|
138
|
+
xPublic: this.identity.xPublic,
|
|
139
|
+
ephX: this.ephemeral.publicKey,
|
|
140
|
+
nonce: b64(nonce),
|
|
141
|
+
ts: Date.now(),
|
|
142
|
+
accountFingerprint: this.accountFingerprint ?? null,
|
|
143
|
+
pluginVersion: this.pluginVersion,
|
|
144
|
+
dshVersion: this.dshVersion,
|
|
145
|
+
capabilities: this.capabilities,
|
|
146
|
+
}
|
|
147
|
+
const proof = membershipProof({
|
|
148
|
+
fleetKey: this.fleet.fleetKey,
|
|
149
|
+
fleetId: this.fleet.fleetId,
|
|
150
|
+
deviceId: this.identity.deviceId,
|
|
151
|
+
edPublic: this.identity.edPublic,
|
|
152
|
+
xPublic: this.identity.xPublic,
|
|
153
|
+
accountFingerprint: this.accountFingerprint ?? null,
|
|
154
|
+
})
|
|
155
|
+
const signed = { ...fields, proof }
|
|
156
|
+
const sig = sign(null, Buffer.from(stableStringify(signed)), importPrivateKey(this.identity.edPrivate)).toString('base64url')
|
|
157
|
+
this.localFields = fields
|
|
158
|
+
this.writeLine({ ...signed, sig })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async onHello(message) {
|
|
162
|
+
const fields = this.validateIdentityMessage(message, 'hello')
|
|
163
|
+
if (fields === null) return
|
|
164
|
+
this.peer = this.peerFrom(fields)
|
|
165
|
+
if (!(await this.authorizePeer())) return
|
|
166
|
+
|
|
167
|
+
this.ephemeral = generateEphemeral()
|
|
168
|
+
const shared = diffieHellman({
|
|
169
|
+
privateKey: importPrivateKey(this.ephemeral.privateKeyPem),
|
|
170
|
+
publicKey: importPublicKey(fields.ephX),
|
|
171
|
+
})
|
|
172
|
+
const nonce = randomBytes(32)
|
|
173
|
+
const ackFields = {
|
|
174
|
+
t: 'hello.ack',
|
|
175
|
+
v: PROTOCOL_VERSION,
|
|
176
|
+
fleetId: this.fleet.fleetId,
|
|
177
|
+
deviceId: this.identity.deviceId,
|
|
178
|
+
name: this.identity.name ?? 'device',
|
|
179
|
+
edPublic: this.identity.edPublic,
|
|
180
|
+
xPublic: this.identity.xPublic,
|
|
181
|
+
ephX: this.ephemeral.publicKey,
|
|
182
|
+
nonce: b64(nonce),
|
|
183
|
+
ts: Date.now(),
|
|
184
|
+
accountFingerprint: this.accountFingerprint ?? null,
|
|
185
|
+
pluginVersion: this.pluginVersion,
|
|
186
|
+
dshVersion: this.dshVersion,
|
|
187
|
+
capabilities: this.capabilities,
|
|
188
|
+
}
|
|
189
|
+
const proof = membershipProof({
|
|
190
|
+
fleetKey: this.fleet.fleetKey,
|
|
191
|
+
fleetId: this.fleet.fleetId,
|
|
192
|
+
deviceId: this.identity.deviceId,
|
|
193
|
+
edPublic: this.identity.edPublic,
|
|
194
|
+
xPublic: this.identity.xPublic,
|
|
195
|
+
accountFingerprint: this.accountFingerprint ?? null,
|
|
196
|
+
})
|
|
197
|
+
const signed = { ...ackFields, proof }
|
|
198
|
+
const sig = sign(null, Buffer.from(stableStringify(signed)), importPrivateKey(this.identity.edPrivate)).toString('base64url')
|
|
199
|
+
this.localFields = ackFields
|
|
200
|
+
this.writeLine({ ...signed, sig })
|
|
201
|
+
|
|
202
|
+
const transcript = sha256(stableStringify({ hello: fields, ack: ackFields }))
|
|
203
|
+
this.deriveKeys(shared, transcript, 'responder')
|
|
204
|
+
this.enterFrames()
|
|
205
|
+
this.sendReady()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async onHelloAck(message) {
|
|
209
|
+
const fields = this.validateIdentityMessage(message, 'hello.ack')
|
|
210
|
+
if (fields === null) return
|
|
211
|
+
this.peer = this.peerFrom(fields)
|
|
212
|
+
if (!(await this.authorizePeer())) return
|
|
213
|
+
|
|
214
|
+
const shared = diffieHellman({
|
|
215
|
+
privateKey: importPrivateKey(this.ephemeral.privateKeyPem),
|
|
216
|
+
publicKey: importPublicKey(fields.ephX),
|
|
217
|
+
})
|
|
218
|
+
const transcript = sha256(stableStringify({ hello: this.localFields, ack: fields }))
|
|
219
|
+
this.deriveKeys(shared, transcript, 'initiator')
|
|
220
|
+
this.enterFrames()
|
|
221
|
+
this.sendReady()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
validateIdentityMessage(message, kind) {
|
|
225
|
+
const fail = (reason) => {
|
|
226
|
+
this.fail('handshake-rejected', reason)
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
if (!message || typeof message !== 'object') return fail(`${kind} is not an object`)
|
|
230
|
+
if (message.t !== kind) return fail(`expected ${kind}`)
|
|
231
|
+
if (message.v !== PROTOCOL_VERSION) return fail('protocol version mismatch')
|
|
232
|
+
if (message.pluginVersion !== this.pluginVersion) {
|
|
233
|
+
return fail(`plugin version mismatch: peer ${String(message.pluginVersion)} vs local ${this.pluginVersion}`)
|
|
234
|
+
}
|
|
235
|
+
if (message.dshVersion !== this.dshVersion) {
|
|
236
|
+
return fail(`harness version mismatch: peer ${String(message.dshVersion)} vs local ${this.dshVersion}`)
|
|
237
|
+
}
|
|
238
|
+
if (message.fleetId !== this.fleet.fleetId) return fail('fleet mismatch')
|
|
239
|
+
if (!isNonEmptyString(message.deviceId)) return fail('missing device id')
|
|
240
|
+
if (message.deviceId === this.identity.deviceId) return fail('peer is this device')
|
|
241
|
+
for (const key of ['edPublic', 'xPublic', 'ephX', 'nonce', 'proof', 'sig']) {
|
|
242
|
+
if (!isNonEmptyString(message[key])) return fail(`missing ${key}`)
|
|
243
|
+
}
|
|
244
|
+
if (typeof message.ts !== 'number' || Math.abs(Date.now() - message.ts) > MAX_CLOCK_SKEW_MS) {
|
|
245
|
+
return fail('clock skew too large')
|
|
246
|
+
}
|
|
247
|
+
if (this.expectedPeer?.deviceId !== undefined && message.deviceId !== this.expectedPeer.deviceId) {
|
|
248
|
+
return fail('unexpected device id')
|
|
249
|
+
}
|
|
250
|
+
if (this.expectedPeer?.edPublic !== undefined && message.edPublic !== this.expectedPeer.edPublic) {
|
|
251
|
+
return fail('device key changed')
|
|
252
|
+
}
|
|
253
|
+
if (this.expectedPeer?.xPublic !== undefined && message.xPublic !== this.expectedPeer.xPublic) {
|
|
254
|
+
return fail('device agreement key changed')
|
|
255
|
+
}
|
|
256
|
+
if (this.accountFingerprint !== null && this.accountFingerprint !== undefined) {
|
|
257
|
+
if ((message.accountFingerprint ?? null) !== this.accountFingerprint) return fail('account mismatch')
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const { sig, ...signed } = message
|
|
261
|
+
let signatureOk = false
|
|
262
|
+
try {
|
|
263
|
+
signatureOk = verify(null, Buffer.from(stableStringify(signed)), importPublicKey(message.edPublic), Buffer.from(sig, 'base64url'))
|
|
264
|
+
} catch {
|
|
265
|
+
signatureOk = false
|
|
266
|
+
}
|
|
267
|
+
if (!signatureOk) return fail('invalid signature')
|
|
268
|
+
|
|
269
|
+
const proofOk = verifyMembershipProof(message.proof, {
|
|
270
|
+
fleetKey: this.fleet.fleetKey,
|
|
271
|
+
fleetId: this.fleet.fleetId,
|
|
272
|
+
deviceId: message.deviceId,
|
|
273
|
+
edPublic: message.edPublic,
|
|
274
|
+
xPublic: message.xPublic,
|
|
275
|
+
accountFingerprint: message.accountFingerprint ?? null,
|
|
276
|
+
})
|
|
277
|
+
if (!proofOk) return fail('invalid fleet membership proof')
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
t: kind,
|
|
281
|
+
v: message.v,
|
|
282
|
+
fleetId: message.fleetId,
|
|
283
|
+
deviceId: message.deviceId,
|
|
284
|
+
name: message.name ?? 'device',
|
|
285
|
+
edPublic: message.edPublic,
|
|
286
|
+
xPublic: message.xPublic,
|
|
287
|
+
ephX: message.ephX,
|
|
288
|
+
nonce: message.nonce,
|
|
289
|
+
ts: message.ts,
|
|
290
|
+
accountFingerprint: message.accountFingerprint ?? null,
|
|
291
|
+
pluginVersion: message.pluginVersion,
|
|
292
|
+
dshVersion: message.dshVersion,
|
|
293
|
+
capabilities: Array.isArray(message.capabilities) ? message.capabilities : [],
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
peerFrom(fields) {
|
|
298
|
+
return {
|
|
299
|
+
deviceId: fields.deviceId,
|
|
300
|
+
name: fields.name,
|
|
301
|
+
edPublic: fields.edPublic,
|
|
302
|
+
xPublic: fields.xPublic,
|
|
303
|
+
accountFingerprint: fields.accountFingerprint ?? null,
|
|
304
|
+
pluginVersion: fields.pluginVersion,
|
|
305
|
+
dshVersion: fields.dshVersion,
|
|
306
|
+
capabilities: fields.capabilities ?? [],
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async authorizePeer() {
|
|
311
|
+
if (typeof this.verifyPeer !== 'function') return true
|
|
312
|
+
try {
|
|
313
|
+
const allowed = await this.verifyPeer(this.peer)
|
|
314
|
+
if (!allowed) {
|
|
315
|
+
this.fail('peer-rejected', 'peer rejected by local policy')
|
|
316
|
+
return false
|
|
317
|
+
}
|
|
318
|
+
return true
|
|
319
|
+
} catch (error) {
|
|
320
|
+
this.fail('peer-rejected', `peer verification failed: ${String(error?.message ?? error)}`)
|
|
321
|
+
return false
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
deriveKeys(shared, transcript, role) {
|
|
326
|
+
const okm = Buffer.from(
|
|
327
|
+
hkdfSync('sha256', shared, transcript, Buffer.from('dsh-device-fleet/channel/v1'), 72),
|
|
328
|
+
)
|
|
329
|
+
const initiatorToResponderKey = okm.subarray(0, 32)
|
|
330
|
+
const initiatorToResponderPrefix = okm.subarray(32, 36)
|
|
331
|
+
const responderToInitiatorKey = okm.subarray(36, 68)
|
|
332
|
+
const responderToInitiatorPrefix = okm.subarray(68, 72)
|
|
333
|
+
if (role === 'initiator') {
|
|
334
|
+
this.sendKey = initiatorToResponderKey
|
|
335
|
+
this.sendPrefix = initiatorToResponderPrefix
|
|
336
|
+
this.recvKey = responderToInitiatorKey
|
|
337
|
+
this.recvPrefix = responderToInitiatorPrefix
|
|
338
|
+
} else {
|
|
339
|
+
this.sendKey = responderToInitiatorKey
|
|
340
|
+
this.sendPrefix = responderToInitiatorPrefix
|
|
341
|
+
this.recvKey = initiatorToResponderKey
|
|
342
|
+
this.recvPrefix = initiatorToResponderPrefix
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
enterFrames() {
|
|
347
|
+
this.phase = 'frames'
|
|
348
|
+
if (this.recvBuffer.length > 0) this.processFrames()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
sendReady() {
|
|
352
|
+
if (this.readySent || this.closed) return
|
|
353
|
+
this.readySent = true
|
|
354
|
+
this.sendFrame({ t: FRAME.READY, v: PROTOCOL_VERSION, deviceId: this.identity.deviceId })
|
|
355
|
+
this.maybeAuthenticate()
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
onReadyFrame() {
|
|
359
|
+
this.readyReceived = true
|
|
360
|
+
this.maybeAuthenticate()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
maybeAuthenticate() {
|
|
364
|
+
if (this.authenticated || !this.readySent || !this.readyReceived) return
|
|
365
|
+
this.authenticated = true
|
|
366
|
+
clearTimeout(this.handshakeTimer)
|
|
367
|
+
this.emit('ready', this.peer)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ------------------------------------------------------------------- frames
|
|
371
|
+
|
|
372
|
+
writeLine(message) {
|
|
373
|
+
if (this.closed) return
|
|
374
|
+
this.socket.write(JSON.stringify(message) + '\n')
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
sendFrame(message) {
|
|
378
|
+
if (this.closed) throw new Error('secure channel is closed')
|
|
379
|
+
if (this.phase !== 'frames' || !this.sendKey) throw new Error('secure channel is not ready')
|
|
380
|
+
const plaintext = Buffer.from(JSON.stringify(message))
|
|
381
|
+
this.sendCounter += 1n
|
|
382
|
+
const nonce = Buffer.alloc(12)
|
|
383
|
+
this.sendPrefix.copy(nonce, 0)
|
|
384
|
+
nonce.writeBigUInt64BE(this.sendCounter, 4)
|
|
385
|
+
const cipher = createCipheriv('aes-256-gcm', this.sendKey, nonce)
|
|
386
|
+
cipher.setAAD(AAD)
|
|
387
|
+
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()])
|
|
388
|
+
const tag = cipher.getAuthTag()
|
|
389
|
+
const payload = Buffer.concat([ciphertext, tag])
|
|
390
|
+
const header = Buffer.alloc(4)
|
|
391
|
+
header.writeUInt32BE(payload.length)
|
|
392
|
+
this.socket.write(Buffer.concat([header, payload]))
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
onData(chunk) {
|
|
396
|
+
if (this.closed) return
|
|
397
|
+
this.recvBuffer = Buffer.concat([this.recvBuffer, chunk])
|
|
398
|
+
if (this.phase === 'handshake') this.processHandshake()
|
|
399
|
+
if (this.phase === 'frames') this.processFrames()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
processHandshake() {
|
|
403
|
+
if (this.handshakeBusy) return
|
|
404
|
+
this.handshakeBusy = true
|
|
405
|
+
this.drainHandshake().finally(() => {
|
|
406
|
+
this.handshakeBusy = false
|
|
407
|
+
if (this.phase === 'frames' && !this.closed) this.processFrames()
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async drainHandshake() {
|
|
412
|
+
while (this.phase === 'handshake' && !this.closed) {
|
|
413
|
+
const newline = this.recvBuffer.indexOf(0x0a)
|
|
414
|
+
if (newline < 0) return
|
|
415
|
+
const line = this.recvBuffer.subarray(0, newline).toString('utf8')
|
|
416
|
+
this.recvBuffer = this.recvBuffer.subarray(newline + 1)
|
|
417
|
+
let message
|
|
418
|
+
try {
|
|
419
|
+
message = JSON.parse(line)
|
|
420
|
+
} catch {
|
|
421
|
+
this.fail('handshake-rejected', 'invalid handshake JSON')
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
if (message.t === 'hello' && this.role === 'responder') {
|
|
425
|
+
try {
|
|
426
|
+
await this.onHello(message)
|
|
427
|
+
} catch (error) {
|
|
428
|
+
this.fail('handshake-error', `hello handling failed: ${String(error?.stack ?? error)}`)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
} else if (message.t === 'hello.ack' && this.role === 'initiator') {
|
|
432
|
+
try {
|
|
433
|
+
await this.onHelloAck(message)
|
|
434
|
+
} catch (error) {
|
|
435
|
+
this.fail('handshake-error', `ack handling failed: ${String(error?.stack ?? error)}`)
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
this.fail('handshake-rejected', `unexpected handshake message ${String(message.t)}`)
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
processFrames() {
|
|
446
|
+
while (this.phase === 'frames' && !this.closed) {
|
|
447
|
+
if (this.recvBuffer.length < 4) return
|
|
448
|
+
const length = this.recvBuffer.readUInt32BE(0)
|
|
449
|
+
if (length <= 16 || length > MAX_FRAME_BYTES) {
|
|
450
|
+
this.fail('frame-invalid', `invalid frame length ${String(length)}`)
|
|
451
|
+
return
|
|
452
|
+
}
|
|
453
|
+
if (this.recvBuffer.length < 4 + length) return
|
|
454
|
+
const payload = this.recvBuffer.subarray(4, 4 + length)
|
|
455
|
+
this.recvBuffer = this.recvBuffer.subarray(4 + length)
|
|
456
|
+
let message
|
|
457
|
+
try {
|
|
458
|
+
message = this.openFrame(payload)
|
|
459
|
+
} catch {
|
|
460
|
+
this.fail('frame-invalid', 'frame authentication failed')
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
this.dispatch(message)
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
openFrame(payload) {
|
|
468
|
+
const tag = payload.subarray(payload.length - 16)
|
|
469
|
+
const ciphertext = payload.subarray(0, payload.length - 16)
|
|
470
|
+
this.recvCounter += 1n
|
|
471
|
+
const nonce = Buffer.alloc(12)
|
|
472
|
+
this.recvPrefix.copy(nonce, 0)
|
|
473
|
+
nonce.writeBigUInt64BE(this.recvCounter, 4)
|
|
474
|
+
const decipher = createDecipheriv('aes-256-gcm', this.recvKey, nonce)
|
|
475
|
+
decipher.setAAD(AAD)
|
|
476
|
+
decipher.setAuthTag(tag)
|
|
477
|
+
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
478
|
+
return JSON.parse(plaintext.toString('utf8'))
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
dispatch(message) {
|
|
482
|
+
if (!message || typeof message !== 'object') return
|
|
483
|
+
if (message.t === FRAME.READY) {
|
|
484
|
+
this.onReadyFrame()
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
if (message.t === FRAME.PING) {
|
|
488
|
+
try {
|
|
489
|
+
this.sendFrame({ t: FRAME.PONG, v: PROTOCOL_VERSION })
|
|
490
|
+
} catch {
|
|
491
|
+
/* closing */
|
|
492
|
+
}
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
if (message.t === FRAME.PONG) return
|
|
496
|
+
if (!this.authenticated) {
|
|
497
|
+
this.fail('frame-before-ready', 'frame received before channel was ready')
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
this.emit('frame', message)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// ----------------------------------------------------------------- lifecycle
|
|
504
|
+
|
|
505
|
+
onSocketError(error) {
|
|
506
|
+
if (this.closed) return
|
|
507
|
+
this.emit('error', error)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
onSocketClose() {
|
|
511
|
+
if (this.closed) return
|
|
512
|
+
this.closed = true
|
|
513
|
+
clearTimeout(this.handshakeTimer)
|
|
514
|
+
this.emit('close', this.failure)
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
fail(code, message) {
|
|
518
|
+
if (this.closed) return
|
|
519
|
+
const error = new Error(message)
|
|
520
|
+
error.code = code
|
|
521
|
+
this.failure = error
|
|
522
|
+
this.emit('error', error)
|
|
523
|
+
this.destroy()
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
destroy() {
|
|
527
|
+
if (this.closed) return
|
|
528
|
+
try {
|
|
529
|
+
this.socket.destroy()
|
|
530
|
+
} catch {
|
|
531
|
+
/* ignore */
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
close() {
|
|
536
|
+
this.destroy()
|
|
537
|
+
}
|
|
538
|
+
}
|