@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.
@@ -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, registry);
189
+ function persistRegistry(nextRegistry = registry) {
190
+ atomicPrivateJson(registryPath, nextRegistry);
188
191
  }
189
192
 
190
- function issueCredential({ accountId, deviceId, devicePublicKey, replace = false }) {
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 && !replace) throw securityError('device-already-enrolled');
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
- registry.devices[id] = {
217
- serial: payload.serial,
218
- accountId: owner,
219
- hubId,
220
- devicePublicKey: deviceKey.text,
221
- credential,
222
- issuedAt,
223
- expiresAt: payload.expiresAt,
224
- revokedAt: '',
225
- lastConnectedAt: ''
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: 1,
290
- protocol: 'livedesk.udp.rendezvous-proof.v1',
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
- record.revokedAt = new Date(now()).toISOString();
309
- record.revokeReason = clean(reason, 160);
310
- persistRegistry();
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
- record.lastConnectedAt = new Date(now()).toISOString();
319
- persistRegistry();
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
- registry = { version: AUTHORITY_VERSION, devices: {} };
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 { appendFile, mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
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
- await appendFile(filePath, `${JSON.stringify(written)}\n`, { encoding: 'utf8', mode: 0o600 });
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;