@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.
- package/package.json +2 -2
- package/src/agents/agent-audit-store.js +16 -6
- package/src/agents/agent-permissions.js +9 -3
- package/src/agents/agent-tool-registry.js +19 -1
- package/src/captures/capture-store.js +50 -3
- package/src/filesystem/shared-folders.js +8 -0
- package/src/filesystem/transfer-jobs.js +36 -5
- package/src/live-desk-update.js +87 -19
- package/src/remote-hub.js +722 -173
- package/src/security/device-credential-authority.js +406 -0
- package/src/security/security-audit-store.js +260 -0
- package/src/server.js +1012 -277
- package/src/settings/settings-schema.js +19 -39
- package/src/transport/relay-hub-control.js +330 -3
- package/src/transport/secure-direct-acceptor.js +433 -0
- package/src/transport/udp-hub-transport.js +28 -5
- package/src/transport/udp-rendezvous.js +179 -13
|
@@ -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
|
+
}
|