@livedesk/hub 0.1.31 → 0.1.33
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/package.json +32 -32
- package/src/filesystem/transfer-jobs.js +5 -1
- package/src/remote-hub.js +721 -658
- package/src/security/device-credential-authority.js +75 -27
- package/src/security/security-audit-store.js +24 -2
- package/src/server.js +978 -780
- package/src/settings/settings-schema.js +251 -239
- package/src/settings/settings-store.js +82 -77
- package/src/transport/relay-hub-control.js +2 -1
- package/src/transport/secure-direct-acceptor.js +440 -432
- package/src/transport/udp-hub-transport.js +19 -8
- package/src/transport/udp-rendezvous.js +143 -29
|
@@ -174,6 +174,9 @@ export function createDeviceCredentialAuthority({
|
|
|
174
174
|
if (!privateKeyText) throw securityError('hub-private-key-secure-store-unavailable');
|
|
175
175
|
const hubPrivateKey = requireP256PrivateKey(privateKeyText);
|
|
176
176
|
const hubPublic = requireP256PublicKey(authority.publicKey);
|
|
177
|
+
const hubIssuerKeyId = crypto.createHash('sha256')
|
|
178
|
+
.update(Buffer.from(hubPublic.text, 'base64url'))
|
|
179
|
+
.digest('base64url');
|
|
177
180
|
const derivedPublic = crypto.createPublicKey(hubPrivateKey).export({ format: 'der', type: 'spki' }).toString('base64url');
|
|
178
181
|
if (derivedPublic !== hubPublic.text) throw securityError('hub-device-authority-key-mismatch');
|
|
179
182
|
const hubId = clean(authority.hubId, 128);
|
|
@@ -183,18 +186,33 @@ export function createDeviceCredentialAuthority({
|
|
|
183
186
|
registry = { version: AUTHORITY_VERSION, devices: {} };
|
|
184
187
|
}
|
|
185
188
|
|
|
186
|
-
function persistRegistry() {
|
|
187
|
-
atomicPrivateJson(registryPath,
|
|
189
|
+
function persistRegistry(nextRegistry = registry) {
|
|
190
|
+
atomicPrivateJson(registryPath, nextRegistry);
|
|
188
191
|
}
|
|
189
192
|
|
|
190
|
-
function
|
|
193
|
+
function normalizeCredentialRequest({ accountId, deviceId, devicePublicKey } = {}) {
|
|
191
194
|
const owner = clean(accountId, 128);
|
|
192
195
|
const id = clean(deviceId, 128);
|
|
193
196
|
if (!owner) throw securityError('device-account-required');
|
|
194
197
|
if (!id) throw securityError('device-id-required');
|
|
195
198
|
const deviceKey = requireP256PublicKey(devicePublicKey);
|
|
199
|
+
return { owner, id, deviceKey };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function assertEnrollmentAllowed(request) {
|
|
203
|
+
const normalized = normalizeCredentialRequest(request);
|
|
204
|
+
const existing = registry.devices[normalized.id];
|
|
205
|
+
if (existing && !existing.revokedAt) throw securityError('device-already-enrolled');
|
|
206
|
+
if (!existing && Object.keys(registry.devices).length >= MAX_DEVICES) {
|
|
207
|
+
throw securityError('device-registry-capacity');
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function issueCredential({ accountId, deviceId, devicePublicKey }) {
|
|
213
|
+
const { owner, id, deviceKey } = normalizeCredentialRequest({ accountId, deviceId, devicePublicKey });
|
|
196
214
|
const existing = registry.devices[id];
|
|
197
|
-
if (existing && !existing.revokedAt
|
|
215
|
+
if (existing && !existing.revokedAt) throw securityError('device-already-enrolled');
|
|
198
216
|
if (!existing && Object.keys(registry.devices).length >= MAX_DEVICES) throw securityError('device-registry-capacity');
|
|
199
217
|
const issuedAt = Math.floor(now());
|
|
200
218
|
const payload = {
|
|
@@ -213,18 +231,25 @@ export function createDeviceCredentialAuthority({
|
|
|
213
231
|
dsaEncoding: 'ieee-p1363'
|
|
214
232
|
}).toString('base64url');
|
|
215
233
|
const credential = `${payloadText}.${signature}`;
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
234
|
+
const nextRegistry = {
|
|
235
|
+
...registry,
|
|
236
|
+
devices: {
|
|
237
|
+
...registry.devices,
|
|
238
|
+
[id]: {
|
|
239
|
+
serial: payload.serial,
|
|
240
|
+
accountId: owner,
|
|
241
|
+
hubId,
|
|
242
|
+
devicePublicKey: deviceKey.text,
|
|
243
|
+
credential,
|
|
244
|
+
issuedAt,
|
|
245
|
+
expiresAt: payload.expiresAt,
|
|
246
|
+
revokedAt: '',
|
|
247
|
+
lastConnectedAt: ''
|
|
248
|
+
}
|
|
249
|
+
}
|
|
226
250
|
};
|
|
227
|
-
persistRegistry();
|
|
251
|
+
persistRegistry(nextRegistry);
|
|
252
|
+
registry = nextRegistry;
|
|
228
253
|
return { credential, payload: { ...payload }, hubPublicKey: hubPublic.text };
|
|
229
254
|
}
|
|
230
255
|
|
|
@@ -279,23 +304,26 @@ export function createDeviceCredentialAuthority({
|
|
|
279
304
|
}).toString('base64url');
|
|
280
305
|
}
|
|
281
306
|
|
|
282
|
-
function issueRendezvousProof({ roomId, accountId, deviceId, ttlMs = 60_000 } = {}) {
|
|
307
|
+
function issueRendezvousProof({ roomId, accountId, deviceId, role, ttlMs = 60_000 } = {}) {
|
|
283
308
|
const room = clean(roomId, 160);
|
|
284
309
|
const owner = clean(accountId, 128);
|
|
285
310
|
const device = clean(deviceId, 128);
|
|
311
|
+
const normalizedRole = clean(role, 16).toLowerCase();
|
|
286
312
|
if (!room || !owner || !device) throw securityError('rendezvous-proof-binding-required');
|
|
313
|
+
if (!['hub', 'client'].includes(normalizedRole)) throw securityError('rendezvous-proof-role-required');
|
|
287
314
|
const issuedAt = Math.floor(now());
|
|
288
315
|
const payload = {
|
|
289
|
-
version:
|
|
290
|
-
protocol: 'livedesk.udp.rendezvous-proof.
|
|
316
|
+
version: 2,
|
|
317
|
+
protocol: 'livedesk.udp.rendezvous-proof.v2',
|
|
318
|
+
issuerKeyId: hubIssuerKeyId,
|
|
291
319
|
roomId: room,
|
|
320
|
+
role: normalizedRole,
|
|
292
321
|
accountId: owner,
|
|
293
322
|
hubId,
|
|
294
323
|
deviceId: device,
|
|
295
324
|
issuedAt,
|
|
296
325
|
expiresAt: issuedAt + Math.max(10_000, Math.min(120_000, Number(ttlMs) || 60_000)),
|
|
297
|
-
nonce: crypto.randomBytes(16).toString('base64url')
|
|
298
|
-
hubPublicKey: hubPublic.text
|
|
326
|
+
nonce: crypto.randomBytes(16).toString('base64url')
|
|
299
327
|
};
|
|
300
328
|
const payloadText = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
301
329
|
return { proof: `${payloadText}.${signHubMessage(payloadText)}`, payload };
|
|
@@ -305,9 +333,19 @@ export function createDeviceCredentialAuthority({
|
|
|
305
333
|
const id = clean(deviceId, 128);
|
|
306
334
|
const record = registry.devices[id];
|
|
307
335
|
if (!record || record.revokedAt) return false;
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
336
|
+
const nextRegistry = {
|
|
337
|
+
...registry,
|
|
338
|
+
devices: {
|
|
339
|
+
...registry.devices,
|
|
340
|
+
[id]: {
|
|
341
|
+
...record,
|
|
342
|
+
revokedAt: new Date(now()).toISOString(),
|
|
343
|
+
revokeReason: clean(reason, 160)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
persistRegistry(nextRegistry);
|
|
348
|
+
registry = nextRegistry;
|
|
311
349
|
return true;
|
|
312
350
|
}
|
|
313
351
|
|
|
@@ -315,8 +353,15 @@ export function createDeviceCredentialAuthority({
|
|
|
315
353
|
const id = clean(deviceId, 128);
|
|
316
354
|
const record = registry.devices[id];
|
|
317
355
|
if (!record || record.revokedAt) return false;
|
|
318
|
-
|
|
319
|
-
|
|
356
|
+
const nextRegistry = {
|
|
357
|
+
...registry,
|
|
358
|
+
devices: {
|
|
359
|
+
...registry.devices,
|
|
360
|
+
[id]: { ...record, lastConnectedAt: new Date(now()).toISOString() }
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
persistRegistry(nextRegistry);
|
|
364
|
+
registry = nextRegistry;
|
|
320
365
|
return true;
|
|
321
366
|
}
|
|
322
367
|
|
|
@@ -335,14 +380,17 @@ export function createDeviceCredentialAuthority({
|
|
|
335
380
|
|
|
336
381
|
function clearDevices() {
|
|
337
382
|
const removed = Object.keys(registry.devices).length;
|
|
338
|
-
|
|
339
|
-
persistRegistry();
|
|
383
|
+
const nextRegistry = { version: AUTHORITY_VERSION, devices: {} };
|
|
384
|
+
persistRegistry(nextRegistry);
|
|
385
|
+
registry = nextRegistry;
|
|
340
386
|
return removed;
|
|
341
387
|
}
|
|
342
388
|
|
|
343
389
|
return Object.freeze({
|
|
344
390
|
hubId,
|
|
345
391
|
hubPublicKey: hubPublic.text,
|
|
392
|
+
hubIssuerKeyId,
|
|
393
|
+
assertEnrollmentAllowed,
|
|
346
394
|
issueCredential,
|
|
347
395
|
verifyCredential,
|
|
348
396
|
verifyDeviceSignature,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -85,6 +85,25 @@ function parseSeal(raw) {
|
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
async function syncFile(filePath, flags = 'r') {
|
|
89
|
+
const handle = await open(filePath, flags, 0o600);
|
|
90
|
+
try {
|
|
91
|
+
await handle.sync();
|
|
92
|
+
} finally {
|
|
93
|
+
await handle.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function appendDurably(filePath, value) {
|
|
98
|
+
const handle = await open(filePath, 'a', 0o600);
|
|
99
|
+
try {
|
|
100
|
+
await handle.writeFile(value, { encoding: 'utf8' });
|
|
101
|
+
await handle.sync();
|
|
102
|
+
} finally {
|
|
103
|
+
await handle.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.livedesk') } = {}) {
|
|
89
108
|
const filePath = path.join(dataDir, 'security', 'security-audit-v1.jsonl');
|
|
90
109
|
const sealStore = createOsSecretStore({
|
|
@@ -175,6 +194,7 @@ export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.l
|
|
|
175
194
|
}
|
|
176
195
|
const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
177
196
|
await writeFile(temporary, `${rebuilt.map(record => JSON.stringify(record)).join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
197
|
+
await syncFile(temporary);
|
|
178
198
|
await rename(temporary, filePath);
|
|
179
199
|
records = rebuilt;
|
|
180
200
|
seal.headHash = previousHash;
|
|
@@ -195,7 +215,9 @@ export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.l
|
|
|
195
215
|
const recordHash = hashRecord(previousHash, payloadHash, mac);
|
|
196
216
|
written = { version: AUDIT_VERSION, previousHash, payloadHash, mac, recordHash, payload };
|
|
197
217
|
await mkdir(path.dirname(filePath), { recursive: true });
|
|
198
|
-
|
|
218
|
+
// A successful record() is the mutation gate for security-sensitive
|
|
219
|
+
// operations. Resolve only after the JSONL record is on stable storage.
|
|
220
|
+
await appendDurably(filePath, `${JSON.stringify(written)}\n`);
|
|
199
221
|
current.push(written);
|
|
200
222
|
seal.headHash = recordHash;
|
|
201
223
|
seal.recordCount = current.length;
|