@livedesk/hub 0.1.30 → 0.1.32

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,406 @@
1
+ import crypto from 'node:crypto';
2
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { createOsSecretStore, OS_SECRET_REFERENCE } from '@livedesk/runtime-core/os-secret-store';
6
+
7
+ const AUTHORITY_VERSION = 1;
8
+ const CREDENTIAL_VERSION = 1;
9
+ const DEFAULT_CREDENTIAL_TTL_MS = 90 * 24 * 60 * 60 * 1000;
10
+ const MAX_CREDENTIAL_TTL_MS = 366 * 24 * 60 * 60 * 1000;
11
+ const MAX_DEVICES = 4096;
12
+
13
+ function securityError(code) {
14
+ const error = new Error(code);
15
+ error.code = code;
16
+ return error;
17
+ }
18
+
19
+ function clean(value, maximum = 256) {
20
+ return String(value || '').replace(/[\0\r\n]/g, '').trim().slice(0, maximum);
21
+ }
22
+
23
+ function exactBase64Url(value, minimumBytes, maximumBytes = minimumBytes) {
24
+ const text = String(value || '');
25
+ if (!/^[A-Za-z0-9_-]+$/.test(text)) throw securityError('device-credential-base64-invalid');
26
+ const bytes = Buffer.from(text, 'base64url');
27
+ if (bytes.length < minimumBytes || bytes.length > maximumBytes || bytes.toString('base64url') !== text) {
28
+ throw securityError('device-credential-base64-invalid');
29
+ }
30
+ return bytes;
31
+ }
32
+
33
+ function atomicPrivateJson(filePath, value) {
34
+ mkdirSync(path.dirname(filePath), { recursive: true });
35
+ const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
36
+ writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
37
+ renameSync(temporary, filePath);
38
+ }
39
+
40
+ function parseJsonFile(filePath) {
41
+ try {
42
+ const value = JSON.parse(readFileSync(filePath, 'utf8'));
43
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ function requireP256PublicKey(publicKeyBase64Url) {
50
+ const der = exactBase64Url(publicKeyBase64Url, 80, 160);
51
+ let key;
52
+ try {
53
+ key = crypto.createPublicKey({ key: der, format: 'der', type: 'spki' });
54
+ } catch {
55
+ throw securityError('device-public-key-invalid');
56
+ }
57
+ if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
58
+ throw securityError('device-public-key-invalid');
59
+ }
60
+ return { key, der, text: der.toString('base64url') };
61
+ }
62
+
63
+ function requireP256PrivateKey(privateKeyBase64Url) {
64
+ const der = exactBase64Url(privateKeyBase64Url, 100, 512);
65
+ let key;
66
+ try {
67
+ key = crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' });
68
+ } catch {
69
+ throw securityError('hub-private-key-invalid');
70
+ }
71
+ if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') {
72
+ throw securityError('hub-private-key-invalid');
73
+ }
74
+ return key;
75
+ }
76
+
77
+ function encodeCredentialPayload(payload) {
78
+ return Buffer.from(JSON.stringify({
79
+ version: CREDENTIAL_VERSION,
80
+ serial: payload.serial,
81
+ accountId: payload.accountId,
82
+ hubId: payload.hubId,
83
+ deviceId: payload.deviceId,
84
+ devicePublicKey: payload.devicePublicKey,
85
+ hubPublicKey: payload.hubPublicKey,
86
+ issuedAt: payload.issuedAt,
87
+ expiresAt: payload.expiresAt
88
+ }), 'utf8').toString('base64url');
89
+ }
90
+
91
+ function parseCredential(credential) {
92
+ const text = String(credential || '');
93
+ if (text.length < 80 || text.length > 8192) throw securityError('device-credential-invalid');
94
+ const parts = text.split('.');
95
+ if (parts.length !== 2) throw securityError('device-credential-invalid');
96
+ const payloadBytes = exactBase64Url(parts[0], 32, 4096);
97
+ const signature = exactBase64Url(parts[1], 64, 64);
98
+ let payload;
99
+ try {
100
+ payload = JSON.parse(payloadBytes.toString('utf8'));
101
+ } catch {
102
+ throw securityError('device-credential-invalid');
103
+ }
104
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) throw securityError('device-credential-invalid');
105
+ const normalized = {
106
+ version: Number(payload.version),
107
+ serial: clean(payload.serial, 128),
108
+ accountId: clean(payload.accountId, 128),
109
+ hubId: clean(payload.hubId, 128),
110
+ deviceId: clean(payload.deviceId, 128),
111
+ devicePublicKey: clean(payload.devicePublicKey, 512),
112
+ hubPublicKey: clean(payload.hubPublicKey, 512),
113
+ issuedAt: Number(payload.issuedAt),
114
+ expiresAt: Number(payload.expiresAt)
115
+ };
116
+ if (normalized.version !== CREDENTIAL_VERSION
117
+ || !normalized.serial
118
+ || !normalized.accountId
119
+ || !normalized.hubId
120
+ || !normalized.deviceId
121
+ || !Number.isSafeInteger(normalized.issuedAt)
122
+ || !Number.isSafeInteger(normalized.expiresAt)
123
+ || normalized.expiresAt <= normalized.issuedAt
124
+ || normalized.expiresAt - normalized.issuedAt > MAX_CREDENTIAL_TTL_MS
125
+ || encodeCredentialPayload(normalized) !== parts[0]) {
126
+ throw securityError('device-credential-invalid');
127
+ }
128
+ requireP256PublicKey(normalized.devicePublicKey);
129
+ requireP256PublicKey(normalized.hubPublicKey);
130
+ return { text, payloadText: parts[0], payloadBytes, payload: normalized, signature };
131
+ }
132
+
133
+ export function createDeviceCredentialAuthority({
134
+ dataDir = path.join(os.homedir(), '.livedesk'),
135
+ now = () => Date.now(),
136
+ credentialTtlMs = DEFAULT_CREDENTIAL_TTL_MS
137
+ } = {}) {
138
+ const securityDir = path.join(dataDir, 'security');
139
+ const authorityPath = path.join(securityDir, 'hub-device-authority.json');
140
+ const registryPath = path.join(securityDir, 'device-credentials.json');
141
+ const authoritySecretStore = createOsSecretStore({
142
+ service: 'LiveDesk',
143
+ account: `hub-device-authority:${crypto.createHash('sha256').update(path.resolve(authorityPath)).digest('hex').slice(0, 24)}`,
144
+ dataDir
145
+ });
146
+ const boundedTtlMs = Math.max(60_000, Math.min(MAX_CREDENTIAL_TTL_MS, Number(credentialTtlMs) || DEFAULT_CREDENTIAL_TTL_MS));
147
+
148
+ let authority = parseJsonFile(authorityPath);
149
+ if (!authority) {
150
+ const { privateKey, publicKey } = crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
151
+ const privateKeyText = privateKey.export({ format: 'der', type: 'pkcs8' }).toString('base64url');
152
+ if (!authoritySecretStore.write(privateKeyText)) throw securityError('hub-private-key-secure-store-unavailable');
153
+ authority = {
154
+ version: AUTHORITY_VERSION,
155
+ hubId: crypto.randomUUID(),
156
+ publicKey: publicKey.export({ format: 'der', type: 'spki' }).toString('base64url'),
157
+ privateKeyRef: OS_SECRET_REFERENCE,
158
+ createdAt: new Date(now()).toISOString()
159
+ };
160
+ atomicPrivateJson(authorityPath, authority);
161
+ }
162
+ if (Number(authority.version) !== AUTHORITY_VERSION || !clean(authority.hubId, 128)) {
163
+ throw securityError('hub-device-authority-invalid');
164
+ }
165
+ const plaintextPrivateKey = clean(authority.privateKey, 1024);
166
+ if (plaintextPrivateKey) {
167
+ if (!authoritySecretStore.write(plaintextPrivateKey)) throw securityError('hub-private-key-migration-failed');
168
+ authority = { ...authority, privateKeyRef: OS_SECRET_REFERENCE };
169
+ delete authority.privateKey;
170
+ atomicPrivateJson(authorityPath, authority);
171
+ }
172
+ if (authority.privateKeyRef !== OS_SECRET_REFERENCE) throw securityError('hub-private-key-reference-invalid');
173
+ const privateKeyText = authoritySecretStore.read();
174
+ if (!privateKeyText) throw securityError('hub-private-key-secure-store-unavailable');
175
+ const hubPrivateKey = requireP256PrivateKey(privateKeyText);
176
+ const hubPublic = requireP256PublicKey(authority.publicKey);
177
+ const hubIssuerKeyId = crypto.createHash('sha256')
178
+ .update(Buffer.from(hubPublic.text, 'base64url'))
179
+ .digest('base64url');
180
+ const derivedPublic = crypto.createPublicKey(hubPrivateKey).export({ format: 'der', type: 'spki' }).toString('base64url');
181
+ if (derivedPublic !== hubPublic.text) throw securityError('hub-device-authority-key-mismatch');
182
+ const hubId = clean(authority.hubId, 128);
183
+
184
+ let registry = parseJsonFile(registryPath);
185
+ if (!registry || Number(registry.version) !== AUTHORITY_VERSION || !registry.devices || typeof registry.devices !== 'object') {
186
+ registry = { version: AUTHORITY_VERSION, devices: {} };
187
+ }
188
+
189
+ function persistRegistry(nextRegistry = registry) {
190
+ atomicPrivateJson(registryPath, nextRegistry);
191
+ }
192
+
193
+ function normalizeCredentialRequest({ accountId, deviceId, devicePublicKey } = {}) {
194
+ const owner = clean(accountId, 128);
195
+ const id = clean(deviceId, 128);
196
+ if (!owner) throw securityError('device-account-required');
197
+ if (!id) throw securityError('device-id-required');
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 });
214
+ const existing = registry.devices[id];
215
+ if (existing && !existing.revokedAt) throw securityError('device-already-enrolled');
216
+ if (!existing && Object.keys(registry.devices).length >= MAX_DEVICES) throw securityError('device-registry-capacity');
217
+ const issuedAt = Math.floor(now());
218
+ const payload = {
219
+ serial: crypto.randomBytes(16).toString('base64url'),
220
+ accountId: owner,
221
+ hubId,
222
+ deviceId: id,
223
+ devicePublicKey: deviceKey.text,
224
+ hubPublicKey: hubPublic.text,
225
+ issuedAt,
226
+ expiresAt: issuedAt + boundedTtlMs
227
+ };
228
+ const payloadText = encodeCredentialPayload(payload);
229
+ const signature = crypto.sign('sha256', Buffer.from(payloadText, 'utf8'), {
230
+ key: hubPrivateKey,
231
+ dsaEncoding: 'ieee-p1363'
232
+ }).toString('base64url');
233
+ const credential = `${payloadText}.${signature}`;
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
+ }
250
+ };
251
+ persistRegistry(nextRegistry);
252
+ registry = nextRegistry;
253
+ return { credential, payload: { ...payload }, hubPublicKey: hubPublic.text };
254
+ }
255
+
256
+ function verifyCredential(credential, { accountId, deviceId } = {}) {
257
+ const parsed = parseCredential(credential);
258
+ const signatureValid = crypto.verify('sha256', Buffer.from(parsed.payloadText, 'utf8'), {
259
+ key: hubPublic.key,
260
+ dsaEncoding: 'ieee-p1363'
261
+ }, parsed.signature);
262
+ if (!signatureValid) throw securityError('device-credential-signature-invalid');
263
+ const expectedAccountId = clean(accountId, 128);
264
+ const expectedDeviceId = clean(deviceId, 128);
265
+ if (parsed.payload.hubId !== hubId
266
+ || parsed.payload.hubPublicKey !== hubPublic.text
267
+ || expectedAccountId && parsed.payload.accountId !== expectedAccountId
268
+ || expectedDeviceId && parsed.payload.deviceId !== expectedDeviceId) {
269
+ throw securityError('device-credential-binding-invalid');
270
+ }
271
+ const record = registry.devices[parsed.payload.deviceId];
272
+ if (!record
273
+ || record.revokedAt
274
+ || record.serial !== parsed.payload.serial
275
+ || record.accountId !== parsed.payload.accountId
276
+ || record.hubId !== parsed.payload.hubId
277
+ || record.devicePublicKey !== parsed.payload.devicePublicKey
278
+ || record.credential !== parsed.text) {
279
+ throw securityError('device-credential-revoked');
280
+ }
281
+ const current = Math.floor(now());
282
+ if (parsed.payload.issuedAt > current + 30_000 || parsed.payload.expiresAt <= current) {
283
+ throw securityError('device-credential-expired');
284
+ }
285
+ return { ...parsed, record: { ...record } };
286
+ }
287
+
288
+ function verifyDeviceSignature(verifiedCredential, message, signature) {
289
+ const deviceKey = requireP256PublicKey(verifiedCredential?.payload?.devicePublicKey);
290
+ const signatureBytes = exactBase64Url(signature, 64, 64);
291
+ if (!crypto.verify('sha256', Buffer.from(String(message || ''), 'utf8'), {
292
+ key: deviceKey.key,
293
+ dsaEncoding: 'ieee-p1363'
294
+ }, signatureBytes)) {
295
+ throw securityError('device-signature-invalid');
296
+ }
297
+ return true;
298
+ }
299
+
300
+ function signHubMessage(message) {
301
+ return crypto.sign('sha256', Buffer.from(String(message || ''), 'utf8'), {
302
+ key: hubPrivateKey,
303
+ dsaEncoding: 'ieee-p1363'
304
+ }).toString('base64url');
305
+ }
306
+
307
+ function issueRendezvousProof({ roomId, accountId, deviceId, role, ttlMs = 60_000 } = {}) {
308
+ const room = clean(roomId, 160);
309
+ const owner = clean(accountId, 128);
310
+ const device = clean(deviceId, 128);
311
+ const normalizedRole = clean(role, 16).toLowerCase();
312
+ if (!room || !owner || !device) throw securityError('rendezvous-proof-binding-required');
313
+ if (!['hub', 'client'].includes(normalizedRole)) throw securityError('rendezvous-proof-role-required');
314
+ const issuedAt = Math.floor(now());
315
+ const payload = {
316
+ version: 2,
317
+ protocol: 'livedesk.udp.rendezvous-proof.v2',
318
+ issuerKeyId: hubIssuerKeyId,
319
+ roomId: room,
320
+ role: normalizedRole,
321
+ accountId: owner,
322
+ hubId,
323
+ deviceId: device,
324
+ issuedAt,
325
+ expiresAt: issuedAt + Math.max(10_000, Math.min(120_000, Number(ttlMs) || 60_000)),
326
+ nonce: crypto.randomBytes(16).toString('base64url')
327
+ };
328
+ const payloadText = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
329
+ return { proof: `${payloadText}.${signHubMessage(payloadText)}`, payload };
330
+ }
331
+
332
+ function revokeDevice(deviceId, reason = 'operator-revoked') {
333
+ const id = clean(deviceId, 128);
334
+ const record = registry.devices[id];
335
+ if (!record || record.revokedAt) return false;
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;
349
+ return true;
350
+ }
351
+
352
+ function markConnected(deviceId) {
353
+ const id = clean(deviceId, 128);
354
+ const record = registry.devices[id];
355
+ if (!record || record.revokedAt) return false;
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;
365
+ return true;
366
+ }
367
+
368
+ function listDevices() {
369
+ return Object.entries(registry.devices).map(([deviceId, record]) => ({
370
+ deviceId,
371
+ serial: record.serial,
372
+ accountId: record.accountId,
373
+ hubId: record.hubId,
374
+ issuedAt: record.issuedAt,
375
+ expiresAt: record.expiresAt,
376
+ revokedAt: record.revokedAt || '',
377
+ lastConnectedAt: record.lastConnectedAt || ''
378
+ }));
379
+ }
380
+
381
+ function clearDevices() {
382
+ const removed = Object.keys(registry.devices).length;
383
+ const nextRegistry = { version: AUTHORITY_VERSION, devices: {} };
384
+ persistRegistry(nextRegistry);
385
+ registry = nextRegistry;
386
+ return removed;
387
+ }
388
+
389
+ return Object.freeze({
390
+ hubId,
391
+ hubPublicKey: hubPublic.text,
392
+ hubIssuerKeyId,
393
+ assertEnrollmentAllowed,
394
+ issueCredential,
395
+ verifyCredential,
396
+ verifyDeviceSignature,
397
+ signHubMessage,
398
+ issueRendezvousProof,
399
+ revokeDevice,
400
+ markConnected,
401
+ listDevices,
402
+ clearDevices
403
+ });
404
+ }
405
+
406
+ export const DEVICE_CREDENTIAL_PROTOCOL = 'livedesk.device-credential.v1';
@@ -0,0 +1,260 @@
1
+ import crypto from 'node:crypto';
2
+ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createOsSecretStore } from '@livedesk/runtime-core/os-secret-store';
7
+
8
+ const AUDIT_VERSION = 1;
9
+ const MAX_RECORDS = 50_000;
10
+ const MAX_FILE_BYTES = 128 * 1024 * 1024;
11
+ const MAX_FIELD = 2_000;
12
+
13
+ function auditError(code) {
14
+ const error = new Error(code);
15
+ error.code = code;
16
+ return error;
17
+ }
18
+
19
+ function text(value, maximum = MAX_FIELD) {
20
+ return String(value ?? '').replace(/[\0\r\n]/g, ' ').trim().slice(0, maximum);
21
+ }
22
+
23
+ function redact(value, depth = 0) {
24
+ if (depth > 4) return '[truncated]';
25
+ if (Array.isArray(value)) return value.slice(0, 100).map(item => redact(item, depth + 1));
26
+ if (!value || typeof value !== 'object') return typeof value === 'string' ? text(value) : value;
27
+ return Object.fromEntries(Object.entries(value).slice(0, 100).map(([key, child]) => [
28
+ text(key, 120),
29
+ /content|payload|data(base64)?|command|script|token|secret|password|credential|api[-_]?key|authorization|private[-_]?key/i.test(key)
30
+ ? '[redacted]'
31
+ : redact(child, depth + 1)
32
+ ]));
33
+ }
34
+
35
+ function canonicalPayload(event = {}) {
36
+ return {
37
+ version: AUDIT_VERSION,
38
+ auditId: text(event.auditId || crypto.randomUUID(), 100),
39
+ timestamp: text(event.timestamp || new Date().toISOString(), 64),
40
+ actorAccountId: text(event.actorAccountId, 128),
41
+ actorUserId: text(event.actorUserId, 128),
42
+ hubId: text(event.hubId, 128),
43
+ deviceId: text(event.deviceId, 128),
44
+ deviceIds: Array.isArray(event.deviceIds)
45
+ ? [...new Set(event.deviceIds.map(item => text(item, 128)).filter(Boolean))].slice(0, 500)
46
+ : [],
47
+ sessionId: text(event.sessionId, 160),
48
+ action: text(event.action, 160),
49
+ phase: text(event.phase, 40),
50
+ result: text(event.result, 80),
51
+ reason: text(event.reason, 240),
52
+ authMethod: text(event.authMethod, 80),
53
+ requestHash: text(event.requestHash, 100),
54
+ startedAt: text(event.startedAt, 64),
55
+ completedAt: text(event.completedAt, 64),
56
+ details: redact(event.details || {})
57
+ };
58
+ }
59
+
60
+ function hashPayload(payload) {
61
+ return crypto.createHash('sha256').update(JSON.stringify(payload), 'utf8').digest('base64url');
62
+ }
63
+
64
+ function signRecord(key, previousHash, payloadHash) {
65
+ return crypto.createHmac('sha256', key).update(`${previousHash}.${payloadHash}`, 'utf8').digest('base64url');
66
+ }
67
+
68
+ function hashRecord(previousHash, payloadHash, mac) {
69
+ return crypto.createHash('sha256').update(`${previousHash}.${payloadHash}.${mac}`, 'utf8').digest('base64url');
70
+ }
71
+
72
+ function parseSeal(raw) {
73
+ try {
74
+ const parsed = JSON.parse(raw);
75
+ const key = Buffer.from(String(parsed?.key || ''), 'base64url');
76
+ if (Number(parsed?.version) !== AUDIT_VERSION || key.length !== 32) return null;
77
+ return {
78
+ version: AUDIT_VERSION,
79
+ key,
80
+ headHash: text(parsed.headHash, 100),
81
+ recordCount: Math.max(0, Number(parsed.recordCount) || 0)
82
+ };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
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
+
107
+ export function createSecurityAuditStore({ dataDir = path.join(os.homedir(), '.livedesk') } = {}) {
108
+ const filePath = path.join(dataDir, 'security', 'security-audit-v1.jsonl');
109
+ const sealStore = createOsSecretStore({
110
+ service: 'LiveDesk',
111
+ account: `security-audit-seal:${crypto.createHash('sha256').update(path.resolve(filePath)).digest('hex').slice(0, 24)}`,
112
+ dataDir
113
+ });
114
+ const existingSealText = sealStore.read();
115
+ let seal = parseSeal(existingSealText);
116
+ if (!seal) {
117
+ if (existingSealText || existsSync(filePath)) throw auditError('security-audit-seal-unavailable');
118
+ seal = { version: AUDIT_VERSION, key: crypto.randomBytes(32), headHash: '', recordCount: 0 };
119
+ if (!persistSeal()) throw auditError('security-audit-secure-store-unavailable');
120
+ }
121
+
122
+ let records = null;
123
+ let writeQueue = Promise.resolve();
124
+
125
+ function persistSeal() {
126
+ return sealStore.write(JSON.stringify({
127
+ version: AUDIT_VERSION,
128
+ key: seal.key.toString('base64url'),
129
+ headHash: seal.headHash,
130
+ recordCount: seal.recordCount
131
+ }));
132
+ }
133
+
134
+ function verifyRecords(candidateRecords) {
135
+ let previousHash = '';
136
+ let sealedHeadObserved = seal.headHash === '';
137
+ for (const record of candidateRecords) {
138
+ const payload = canonicalPayload(record?.payload || {});
139
+ const payloadHash = hashPayload(payload);
140
+ const expectedMac = signRecord(seal.key, previousHash, payloadHash);
141
+ const expectedRecordHash = hashRecord(previousHash, payloadHash, expectedMac);
142
+ if (record?.version !== AUDIT_VERSION
143
+ || record.previousHash !== previousHash
144
+ || record.payloadHash !== payloadHash
145
+ || record.mac !== expectedMac
146
+ || record.recordHash !== expectedRecordHash) {
147
+ throw auditError('security-audit-integrity-failed');
148
+ }
149
+ previousHash = expectedRecordHash;
150
+ if (previousHash === seal.headHash) sealedHeadObserved = true;
151
+ }
152
+ if (seal.headHash && !sealedHeadObserved) throw auditError('security-audit-truncation-detected');
153
+ return previousHash;
154
+ }
155
+
156
+ async function load() {
157
+ if (records) return records;
158
+ try {
159
+ const metadata = await stat(filePath);
160
+ if (metadata.size > MAX_FILE_BYTES) throw auditError('security-audit-file-too-large');
161
+ const lines = (await readFile(filePath, 'utf8')).split(/\r?\n/).filter(Boolean);
162
+ records = lines.map(line => JSON.parse(line));
163
+ } catch (error) {
164
+ if (error?.code !== 'ENOENT') throw error;
165
+ records = [];
166
+ }
167
+ const headHash = verifyRecords(records);
168
+ if (headHash !== seal.headHash || records.length !== seal.recordCount) {
169
+ seal.headHash = headHash;
170
+ seal.recordCount = records.length;
171
+ if (!persistSeal()) throw auditError('security-audit-secure-store-unavailable');
172
+ }
173
+ return records;
174
+ }
175
+
176
+ async function rewriteRetained(current) {
177
+ if (current.length <= MAX_RECORDS) return;
178
+ const retainedPayloads = current.slice(-(MAX_RECORDS - 1)).map(record => record.payload);
179
+ const pruned = current.length - retainedPayloads.length;
180
+ const payloads = [canonicalPayload({
181
+ action: 'security.audit.retention-pruned',
182
+ phase: 'complete',
183
+ result: 'success',
184
+ details: { prunedRecords: pruned, retentionLimit: MAX_RECORDS }
185
+ }), ...retainedPayloads];
186
+ const rebuilt = [];
187
+ let previousHash = '';
188
+ for (const payload of payloads) {
189
+ const payloadHash = hashPayload(payload);
190
+ const mac = signRecord(seal.key, previousHash, payloadHash);
191
+ const recordHash = hashRecord(previousHash, payloadHash, mac);
192
+ rebuilt.push({ version: AUDIT_VERSION, previousHash, payloadHash, mac, recordHash, payload });
193
+ previousHash = recordHash;
194
+ }
195
+ const temporary = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
196
+ await writeFile(temporary, `${rebuilt.map(record => JSON.stringify(record)).join('\n')}\n`, { encoding: 'utf8', mode: 0o600 });
197
+ await syncFile(temporary);
198
+ await rename(temporary, filePath);
199
+ records = rebuilt;
200
+ seal.headHash = previousHash;
201
+ seal.recordCount = rebuilt.length;
202
+ if (!persistSeal()) throw auditError('security-audit-secure-store-unavailable');
203
+ }
204
+
205
+ return Object.freeze({
206
+ filePath,
207
+ async record(event) {
208
+ let written;
209
+ writeQueue = writeQueue.then(async () => {
210
+ const current = await load();
211
+ const payload = canonicalPayload(event);
212
+ const previousHash = current.at(-1)?.recordHash || '';
213
+ const payloadHash = hashPayload(payload);
214
+ const mac = signRecord(seal.key, previousHash, payloadHash);
215
+ const recordHash = hashRecord(previousHash, payloadHash, mac);
216
+ written = { version: AUDIT_VERSION, previousHash, payloadHash, mac, recordHash, payload };
217
+ await mkdir(path.dirname(filePath), { recursive: true });
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`);
221
+ current.push(written);
222
+ seal.headHash = recordHash;
223
+ seal.recordCount = current.length;
224
+ if (!persistSeal()) throw auditError('security-audit-secure-store-unavailable');
225
+ await rewriteRetained(current);
226
+ });
227
+ await writeQueue;
228
+ return written;
229
+ },
230
+ async list({ limit = 500, action = '' } = {}) {
231
+ const current = await load();
232
+ const bounded = Math.max(1, Math.min(2_000, Number(limit) || 500));
233
+ return current
234
+ .filter(record => !action || record.payload?.action === String(action))
235
+ .slice(-bounded)
236
+ .reverse()
237
+ .map(record => ({ ...record.payload, recordHash: record.recordHash }));
238
+ },
239
+ async verify() {
240
+ const current = await load();
241
+ const headHash = verifyRecords(current);
242
+ return { ok: true, records: current.length, headHash, sealed: headHash === seal.headHash };
243
+ },
244
+ async reset() {
245
+ writeQueue = writeQueue.then(async () => {
246
+ await rm(filePath, { force: true });
247
+ records = [];
248
+ seal = {
249
+ version: AUDIT_VERSION,
250
+ key: crypto.randomBytes(32),
251
+ headHash: '',
252
+ recordCount: 0
253
+ };
254
+ if (!persistSeal()) throw auditError('security-audit-secure-store-unavailable');
255
+ });
256
+ await writeQueue;
257
+ return { ok: true };
258
+ }
259
+ });
260
+ }