@skrr-ai/cli 0.1.120 → 0.1.122
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/dist/base-command.js +11 -2
- package/dist/commands/actions/publish.js +4 -0
- package/dist/commands/agent/message/context.d.ts +1 -0
- package/dist/commands/agent/message/context.js +3 -2
- package/dist/commands/agent/message/send.js +6 -1
- package/dist/commands/search/agents.js +5 -1
- package/dist/commands/search/all.js +74 -22
- package/dist/commands/search/skills.d.ts +1 -0
- package/dist/commands/search/skills.js +12 -1
- package/dist/commands/search/spaces.d.ts +8 -1
- package/dist/commands/search/spaces.js +50 -12
- package/dist/commands/search/tasks.d.ts +13 -2
- package/dist/commands/search/tasks.js +78 -14
- package/dist/commands/search/teams.js +5 -1
- package/dist/commands/search/workspace.d.ts +19 -0
- package/dist/commands/search/workspace.js +126 -0
- package/dist/commands/skills/browse.d.ts +1 -0
- package/dist/commands/skills/browse.js +8 -1
- package/dist/commands/skills/search.d.ts +1 -0
- package/dist/commands/skills/search.js +12 -1
- package/dist/commands/spaces/list.d.ts +1 -0
- package/dist/commands/spaces/list.js +12 -3
- package/dist/commands/spaces/search.d.ts +1 -0
- package/dist/commands/spaces/search.js +9 -0
- package/dist/commands/store/gallery-admissions/approve.d.ts +16 -0
- package/dist/commands/store/gallery-admissions/approve.js +36 -0
- package/dist/commands/store/gallery-admissions/list.d.ts +9 -0
- package/dist/commands/store/gallery-admissions/list.js +49 -0
- package/dist/commands/store/gallery-admissions/preview.d.ts +15 -0
- package/dist/commands/store/gallery-admissions/preview.js +48 -0
- package/dist/commands/store/gallery-admissions/refuse.d.ts +15 -0
- package/dist/commands/store/gallery-admissions/refuse.js +35 -0
- package/dist/commands/store/gallery-admissions/show.d.ts +12 -0
- package/dist/commands/store/gallery-admissions/show.js +55 -0
- package/dist/commands/tasks/list.js +8 -0
- package/dist/commands/tasks/search.d.ts +1 -0
- package/dist/commands/tasks/search.js +9 -0
- package/dist/generated/agent-guides.json +11 -11
- package/dist/lib/api-error-reason.d.ts +2 -0
- package/dist/lib/api-error-reason.js +14 -0
- package/dist/lib/api-fetch.js +3 -0
- package/dist/lib/command-miss.js +1 -0
- package/dist/lib/search-display.d.ts +7 -0
- package/dist/lib/search-display.js +31 -0
- package/dist/lib/skill-store-pagination.d.ts +8 -0
- package/dist/lib/skill-store-pagination.js +35 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +10 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +147 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonConsentProof.d.ts +20 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/daemonConsentProof.js +72 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/desktopDaemonProof.d.ts +21 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/desktopDaemonProof.js +96 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +2 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +102 -20
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.d.ts +27 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +161 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineScopes.d.ts +26 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineScopes.js +41 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +15 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +20 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +10 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +172 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonConsentProof.d.ts +20 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/daemonConsentProof.js +43 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/desktopDaemonProof.d.ts +21 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/desktopDaemonProof.js +62 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +2 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +133 -25
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.d.ts +27 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +132 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineScopes.d.ts +26 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineScopes.js +15 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +28 -15
- package/dist/node_modules/@skrr-ai/auth-core/package.json +31 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +7728 -7666
- package/oclif.manifest.json +1180 -101
- package/package.json +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export declare const DESKTOP_DAEMON_PROOF_NAMESPACE = "/desktop-proof-v1";
|
|
2
|
+
export declare const DESKTOP_DAEMON_RENEWAL_NAMESPACE = "/desktop-renewal-v1";
|
|
3
|
+
export interface DesktopDaemonOwner {
|
|
4
|
+
userId: string;
|
|
5
|
+
serverUrl: string;
|
|
6
|
+
}
|
|
7
|
+
export interface DesktopDaemonChallenge {
|
|
8
|
+
owner: DesktopDaemonOwner;
|
|
9
|
+
port: number;
|
|
10
|
+
clientNonce: string;
|
|
11
|
+
serverNonce: string;
|
|
12
|
+
}
|
|
13
|
+
/** The same canonical owner is used by the daemon's owner boundary and MAC. */
|
|
14
|
+
export declare function normalizeDesktopDaemonOwner(input: unknown): DesktopDaemonOwner | null;
|
|
15
|
+
/** Role separation prevents reflecting the daemon's proof as client proof. */
|
|
16
|
+
export declare function desktopDaemonProof(secret: string, role: 'daemon' | 'desktop', challenge: DesktopDaemonChallenge): string;
|
|
17
|
+
/** Renewal uses a distinct domain so a proof for this narrow channel cannot
|
|
18
|
+
* be reflected or replayed as a normal desktop attachment. */
|
|
19
|
+
export declare function desktopRenewalProof(secret: string, role: 'daemon' | 'desktop', challenge: DesktopDaemonChallenge): string;
|
|
20
|
+
export declare function verifyDesktopDaemonProof(secret: string, role: 'daemon' | 'desktop', challenge: DesktopDaemonChallenge, proof: unknown): boolean;
|
|
21
|
+
export declare function verifyDesktopRenewalProof(secret: string, role: 'daemon' | 'desktop', challenge: DesktopDaemonChallenge, proof: unknown): boolean;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export const DESKTOP_DAEMON_PROOF_NAMESPACE = '/desktop-proof-v1';
|
|
3
|
+
export const DESKTOP_DAEMON_RENEWAL_NAMESPACE = '/desktop-renewal-v1';
|
|
4
|
+
/** The same canonical owner is used by the daemon's owner boundary and MAC. */
|
|
5
|
+
export function normalizeDesktopDaemonOwner(input) {
|
|
6
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
7
|
+
return null;
|
|
8
|
+
const value = input;
|
|
9
|
+
if (typeof value.userId !== 'string' || !value.userId || value.userId.length > 128 ||
|
|
10
|
+
typeof value.serverUrl !== 'string' || value.serverUrl.length > 2048)
|
|
11
|
+
return null;
|
|
12
|
+
try {
|
|
13
|
+
const url = new URL(value.serverUrl);
|
|
14
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
15
|
+
return null;
|
|
16
|
+
return { userId: value.userId, serverUrl: url.origin };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Role separation prevents reflecting the daemon's proof as client proof. */
|
|
23
|
+
export function desktopDaemonProof(secret, role, challenge) {
|
|
24
|
+
return proofForPurpose(secret, role, challenge, 'skrr-desktop-daemon-proof:v1\0');
|
|
25
|
+
}
|
|
26
|
+
/** Renewal uses a distinct domain so a proof for this narrow channel cannot
|
|
27
|
+
* be reflected or replayed as a normal desktop attachment. */
|
|
28
|
+
export function desktopRenewalProof(secret, role, challenge) {
|
|
29
|
+
return proofForPurpose(secret, role, challenge, 'skrr-desktop-renewal-proof:v1\0');
|
|
30
|
+
}
|
|
31
|
+
function proofForPurpose(secret, role, challenge, domain) {
|
|
32
|
+
const key = Buffer.from(secret, 'base64');
|
|
33
|
+
const owner = normalizeDesktopDaemonOwner(challenge.owner);
|
|
34
|
+
if (key.length !== 32 || key.toString('base64') !== secret || !owner ||
|
|
35
|
+
!Number.isInteger(challenge.port) || challenge.port < 1 || challenge.port > 65535 ||
|
|
36
|
+
!/^[a-f0-9]{64}$/.test(challenge.clientNonce) || !/^[a-f0-9]{64}$/.test(challenge.serverNonce)) {
|
|
37
|
+
throw new Error('Invalid desktop daemon challenge');
|
|
38
|
+
}
|
|
39
|
+
return createHmac('sha256', key).update(domain).update(JSON.stringify([
|
|
40
|
+
role, challenge.port, owner.userId, owner.serverUrl, challenge.clientNonce, challenge.serverNonce,
|
|
41
|
+
])).digest('hex');
|
|
42
|
+
}
|
|
43
|
+
export function verifyDesktopDaemonProof(secret, role, challenge, proof) {
|
|
44
|
+
if (typeof proof !== 'string' || !/^[a-f0-9]{64}$/.test(proof))
|
|
45
|
+
return false;
|
|
46
|
+
try {
|
|
47
|
+
return timingSafeEqual(Buffer.from(proof, 'hex'), Buffer.from(desktopDaemonProof(secret, role, challenge), 'hex'));
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function verifyDesktopRenewalProof(secret, role, challenge, proof) {
|
|
54
|
+
if (typeof proof !== 'string' || !/^[a-f0-9]{64}$/.test(proof))
|
|
55
|
+
return false;
|
|
56
|
+
try {
|
|
57
|
+
return timingSafeEqual(Buffer.from(proof, 'hex'), Buffer.from(desktopRenewalProof(secret, role, challenge), 'hex'));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -51,9 +51,8 @@ export declare function describeDeviceKeySplit(profile: string): {
|
|
|
51
51
|
* `createIfMissing: false` stops before step 2 (disabled+no_stored_key) and
|
|
52
52
|
* never replaces anything: for a short-lived process that must only READ the
|
|
53
53
|
* daemon's key while the daemon itself may be creating it (OSK-15990's
|
|
54
|
-
* `skrrd device-proof`).
|
|
55
|
-
*
|
|
56
|
-
* memory and the disk another.
|
|
54
|
+
* `skrrd device-proof`). Readers and writers both take the profile lock so
|
|
55
|
+
* they cannot observe an incomplete write from another updated process.
|
|
57
56
|
*/
|
|
58
57
|
export declare function initDeviceIdentity(profile: string, options?: {
|
|
59
58
|
recoverUnreadable?: boolean;
|
|
@@ -31,9 +31,10 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import fs from 'node:fs';
|
|
33
33
|
import path from 'node:path';
|
|
34
|
+
import lockfile from 'proper-lockfile';
|
|
34
35
|
import { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, } from './deviceKey.js';
|
|
35
36
|
import { hasCredEnvelopePathConflict, isCredEnvelopeActive, maybeDecryptOnRead, splitKeyConvergencePolicy, wrapIfActiveOrPassthrough, } from './credentialEnvelopeBridge.js';
|
|
36
|
-
import { consolidateProfileState, profileStateFilePathsToClear, resolveProfileStateDir, } from './profileStateDir.js';
|
|
37
|
+
import { consolidateProfileState, profileStateFilePathsToClear, profileStateDir, resolveProfileStateDir, } from './profileStateDir.js';
|
|
37
38
|
import { emitAuthTelemetry, getAuthLogger } from './runtime.js';
|
|
38
39
|
const PUBLIC_KEY_FILENAME = 'device-public-key.jwk';
|
|
39
40
|
const PRIVATE_KEY_FILENAME = 'device-private-key';
|
|
@@ -56,6 +57,34 @@ const DEVICE_IDENTITY_FILES = [
|
|
|
56
57
|
// so every CLI command does not repeat the same guaranteed 404.
|
|
57
58
|
const DAEMON_NOT_FOUND_BACKOFF_MS = 15 * 60 * 1000;
|
|
58
59
|
let _state = { kind: 'uninit' };
|
|
60
|
+
let _initializing = null;
|
|
61
|
+
/**
|
|
62
|
+
* The CLI, daemon, and desktop's short-lived signer resolve the same canonical
|
|
63
|
+
* profile directory even when an older private key still lives in the nested
|
|
64
|
+
* legacy directory. Serialize the entire read/consolidate/create transaction;
|
|
65
|
+
* otherwise two first starts can each retain a different key in memory.
|
|
66
|
+
*
|
|
67
|
+
* Never proceed unlocked. The long stale interval favors preserving a pinned
|
|
68
|
+
* identity if a process is paused; a contended caller fails and can retry.
|
|
69
|
+
* Versions that predate this lock do not participate and still require an
|
|
70
|
+
* orderly upgrade before using the new-profile authorization path.
|
|
71
|
+
*/
|
|
72
|
+
async function withDeviceIdentityLock(profile, body) {
|
|
73
|
+
const dir = profileStateDir(profile);
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
75
|
+
const release = await lockfile.lock(dir, {
|
|
76
|
+
realpath: false,
|
|
77
|
+
lockfilePath: path.join(dir, '.device-identity.lock'),
|
|
78
|
+
stale: 10 * 60 * 1000,
|
|
79
|
+
retries: { retries: 20, minTimeout: 100, maxTimeout: 500, factor: 1.25 },
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
return await body();
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
await release();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
59
88
|
/** @internal Test seam — force-set state. */
|
|
60
89
|
export function __setStateForTest(next) {
|
|
61
90
|
const prev = _state;
|
|
@@ -72,6 +101,11 @@ export function __getStateForTest() {
|
|
|
72
101
|
* that decides which copy the group is read from.
|
|
73
102
|
*/
|
|
74
103
|
function profileConfigDir(profile) {
|
|
104
|
+
// A reserved independent profile has a server-pinned owner and device key.
|
|
105
|
+
// An older nested copy is not authority to replace or shadow its canonical
|
|
106
|
+
// key, even when generic profile-convergence policy favors that copy.
|
|
107
|
+
if (/^desktop-bg-[a-f0-9]{32}$/i.test(profile))
|
|
108
|
+
return profileStateDir(profile);
|
|
75
109
|
return resolveProfileStateDir(profile, PRIVATE_KEY_FILENAME).dir;
|
|
76
110
|
}
|
|
77
111
|
function publicKeyPath(profile) {
|
|
@@ -177,6 +211,50 @@ function persistKeypair(profile, kp) {
|
|
|
177
211
|
});
|
|
178
212
|
return { envelopeProtected };
|
|
179
213
|
}
|
|
214
|
+
/** Reserved identities are read without following links or accepting an
|
|
215
|
+
* unbounded, shared, unsealed or concurrently replaced key file. */
|
|
216
|
+
function readIndependentIdentityFile(file, sealed) {
|
|
217
|
+
if (typeof fs.constants.O_NOFOLLOW !== 'number' || typeof fs.constants.O_NONBLOCK !== 'number')
|
|
218
|
+
return null;
|
|
219
|
+
let fd;
|
|
220
|
+
try {
|
|
221
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const before = fs.fstatSync(fd);
|
|
228
|
+
if (!before.isFile() || before.nlink !== 1 || before.size > 16_384 || (before.mode & 0o077) !== 0 ||
|
|
229
|
+
(typeof process.getuid === 'function' && before.uid !== process.getuid()))
|
|
230
|
+
return null;
|
|
231
|
+
const bytes = Buffer.alloc(before.size);
|
|
232
|
+
let offset = 0;
|
|
233
|
+
while (offset < bytes.length) {
|
|
234
|
+
const count = fs.readSync(fd, bytes, offset, bytes.length - offset, offset);
|
|
235
|
+
if (count <= 0)
|
|
236
|
+
return null;
|
|
237
|
+
offset += count;
|
|
238
|
+
}
|
|
239
|
+
const after = fs.fstatSync(fd);
|
|
240
|
+
const current = fs.lstatSync(file);
|
|
241
|
+
if (!current.isFile() || current.isSymbolicLink() || current.dev !== before.dev || current.ino !== before.ino ||
|
|
242
|
+
current.size !== before.size || current.nlink !== before.nlink || current.mode !== before.mode ||
|
|
243
|
+
current.uid !== before.uid || current.mtimeMs !== before.mtimeMs || current.ctimeMs !== before.ctimeMs ||
|
|
244
|
+
before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.nlink !== after.nlink ||
|
|
245
|
+
before.mode !== after.mode || before.uid !== after.uid || before.mtimeMs !== after.mtimeMs ||
|
|
246
|
+
before.ctimeMs !== after.ctimeMs)
|
|
247
|
+
return null;
|
|
248
|
+
const value = bytes.toString('utf8');
|
|
249
|
+
return sealed && !value.startsWith('osk-env1:') ? null : value;
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
finally {
|
|
255
|
+
fs.closeSync(fd);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
180
258
|
/**
|
|
181
259
|
* Load a previously-persisted keypair. Returns null on missing files,
|
|
182
260
|
* decrypt failure, public/private mismatch (file-swap defense), or
|
|
@@ -185,11 +263,13 @@ function persistKeypair(profile, kp) {
|
|
|
185
263
|
function loadKeypair(profile) {
|
|
186
264
|
const pubPath = publicKeyPath(profile);
|
|
187
265
|
const privPath = privateKeyPath(profile);
|
|
188
|
-
|
|
266
|
+
const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
|
|
267
|
+
if (!independent && (!fs.existsSync(pubPath) || !fs.existsSync(privPath)))
|
|
268
|
+
return null;
|
|
269
|
+
const pubRaw = independent ? readIndependentIdentityFile(pubPath, false) : fs.readFileSync(pubPath, 'utf-8');
|
|
270
|
+
const privRaw = independent ? readIndependentIdentityFile(privPath, true) : fs.readFileSync(privPath, 'utf-8');
|
|
271
|
+
if (pubRaw === null || privRaw === null)
|
|
189
272
|
return null;
|
|
190
|
-
}
|
|
191
|
-
const pubRaw = fs.readFileSync(pubPath, 'utf-8');
|
|
192
|
-
const privRaw = fs.readFileSync(privPath, 'utf-8');
|
|
193
273
|
let publicJwk;
|
|
194
274
|
let storedThumbprint;
|
|
195
275
|
try {
|
|
@@ -348,23 +428,47 @@ async function consolidateDeviceIdentity(profile) {
|
|
|
348
428
|
* `createIfMissing: false` stops before step 2 (disabled+no_stored_key) and
|
|
349
429
|
* never replaces anything: for a short-lived process that must only READ the
|
|
350
430
|
* daemon's key while the daemon itself may be creating it (OSK-15990's
|
|
351
|
-
* `skrrd device-proof`).
|
|
352
|
-
*
|
|
353
|
-
* memory and the disk another.
|
|
431
|
+
* `skrrd device-proof`). Readers and writers both take the profile lock so
|
|
432
|
+
* they cannot observe an incomplete write from another updated process.
|
|
354
433
|
*/
|
|
355
434
|
export async function initDeviceIdentity(profile, options = {}) {
|
|
435
|
+
if (_initializing)
|
|
436
|
+
return _initializing;
|
|
356
437
|
if (_state.kind !== 'uninit')
|
|
357
438
|
return;
|
|
358
|
-
if (typeof profile !== 'string' ||
|
|
439
|
+
if (typeof profile !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(profile)) {
|
|
359
440
|
_state = { kind: 'disabled', reason: 'init_failed' };
|
|
360
|
-
emit('init_failed', false, { reason: '
|
|
441
|
+
emit('init_failed', false, { reason: 'invalid_profile' });
|
|
361
442
|
return;
|
|
362
443
|
}
|
|
444
|
+
const job = withDeviceIdentityLock(profile, async () => {
|
|
445
|
+
if (_state.kind === 'uninit')
|
|
446
|
+
await initDeviceIdentityLocked(profile, options);
|
|
447
|
+
}).catch((err) => {
|
|
448
|
+
_state = { kind: 'disabled', reason: 'init_failed' };
|
|
449
|
+
emit('init_failed', false, {
|
|
450
|
+
profile,
|
|
451
|
+
reason: 'identity_lock_unavailable',
|
|
452
|
+
message: err instanceof Error ? err.message : 'unknown',
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
_initializing = job;
|
|
456
|
+
try {
|
|
457
|
+
await job;
|
|
458
|
+
}
|
|
459
|
+
finally {
|
|
460
|
+
if (_initializing === job)
|
|
461
|
+
_initializing = null;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function initDeviceIdentityLocked(profile, options = {}) {
|
|
363
465
|
// OSK-10314 — converge the two directories this profile may have before
|
|
364
466
|
// resolving any of the four files. Must run after `initCredEnvelope`, which
|
|
365
467
|
// both consumers already guarantee, because the private key's readability
|
|
366
468
|
// depends on which DEK ended up active.
|
|
367
|
-
|
|
469
|
+
const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
|
|
470
|
+
if (!independent)
|
|
471
|
+
await consolidateDeviceIdentity(profile);
|
|
368
472
|
let keypair = null;
|
|
369
473
|
try {
|
|
370
474
|
keypair = loadKeypair(profile);
|
|
@@ -394,12 +498,12 @@ export async function initDeviceIdentity(profile, options = {}) {
|
|
|
394
498
|
// DEVICE_KEY_MISMATCH. Only an explicit interactive login may replace it;
|
|
395
499
|
// that login proves the new key and re-pins it before issuing credentials.
|
|
396
500
|
const hasStoredKey = storedKeyMaterialExists(profile);
|
|
397
|
-
if (hasStoredKey && !options.recoverUnreadable) {
|
|
501
|
+
if (hasStoredKey && (!options.recoverUnreadable || independent)) {
|
|
398
502
|
_state = { kind: 'disabled', reason: 'unreadable_key' };
|
|
399
503
|
emit('recovery_required', false, { profile, reason: 'stored_key_unreadable' });
|
|
400
504
|
return;
|
|
401
505
|
}
|
|
402
|
-
if (options.createIfMissing === false) {
|
|
506
|
+
if (options.createIfMissing === false || (independent && options.createIfMissing !== true)) {
|
|
403
507
|
_state = { kind: 'disabled', reason: 'no_stored_key' };
|
|
404
508
|
emit('no_stored_key', false, { profile });
|
|
405
509
|
return;
|
|
@@ -448,25 +552,29 @@ export async function initDeviceIdentity(profile, options = {}) {
|
|
|
448
552
|
* the daemon's pinned identity.
|
|
449
553
|
*/
|
|
450
554
|
export async function resetDeviceIdentity(opts) {
|
|
555
|
+
if (_initializing)
|
|
556
|
+
await _initializing;
|
|
451
557
|
if (opts?.clearOnDisk && opts.profile) {
|
|
452
558
|
// Every location, not just the resolved one. A reset that cleared only the
|
|
453
559
|
// canonical copy would leave the pre-consolidation copy for the next init to
|
|
454
560
|
// adopt — a deleted device key that reappears.
|
|
455
561
|
const targets = [PUBLIC_KEY_FILENAME, PRIVATE_KEY_FILENAME].flatMap((name) => profileStateFilePathsToClear(opts.profile, name));
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
562
|
+
await withDeviceIdentityLock(opts.profile, async () => {
|
|
563
|
+
for (const p of targets) {
|
|
564
|
+
try {
|
|
565
|
+
fs.unlinkSync(p);
|
|
566
|
+
}
|
|
567
|
+
catch (err) {
|
|
568
|
+
if (err.code !== 'ENOENT') {
|
|
569
|
+
emit('clear_failed', false, {
|
|
570
|
+
profile: opts.profile,
|
|
571
|
+
file: path.basename(p),
|
|
572
|
+
message: err?.message ?? 'unknown',
|
|
573
|
+
});
|
|
574
|
+
}
|
|
467
575
|
}
|
|
468
576
|
}
|
|
469
|
-
}
|
|
577
|
+
});
|
|
470
578
|
}
|
|
471
579
|
_state = { kind: 'uninit' };
|
|
472
580
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The desktop reserves a new profile for every background authorization. */
|
|
2
|
+
export declare const INDEPENDENT_MACHINE_PROFILE_RE: RegExp;
|
|
3
|
+
export declare const INDEPENDENT_MACHINE_RESERVATION_FILE = "independent-machine-attempt.json";
|
|
4
|
+
export interface IndependentMachineReservation {
|
|
5
|
+
version: 2;
|
|
6
|
+
profile: string;
|
|
7
|
+
ownerId: string;
|
|
8
|
+
origin: string;
|
|
9
|
+
secretHash: string;
|
|
10
|
+
encryptedSecret: string;
|
|
11
|
+
createdAt: string;
|
|
12
|
+
}
|
|
13
|
+
/** Reserve a previously absent profile and persist the encrypted attempt secret.
|
|
14
|
+
* `encrypt` must use the platform secure store and throw if it is unavailable. */
|
|
15
|
+
export declare function reserveIndependentMachineProfile(options: {
|
|
16
|
+
root: string;
|
|
17
|
+
ownerId: string;
|
|
18
|
+
origin: string;
|
|
19
|
+
encrypt: (secret: string) => Buffer;
|
|
20
|
+
}): {
|
|
21
|
+
reservation: IndependentMachineReservation;
|
|
22
|
+
secret: string;
|
|
23
|
+
};
|
|
24
|
+
/** A saved reservation is a selection hint, never proof of machine ownership. */
|
|
25
|
+
export declare function readIndependentMachineReservation(root: string, profile: string): IndependentMachineReservation | null;
|
|
26
|
+
/** Confirm a one-shot signer is acting on the desktop's reserved attempt. */
|
|
27
|
+
export declare function verifyIndependentMachineReservation(root: string, profile: string, secret: unknown): boolean;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
/** The desktop reserves a new profile for every background authorization. */
|
|
5
|
+
export const INDEPENDENT_MACHINE_PROFILE_RE = /^desktop-bg-[a-f0-9]{32}$/;
|
|
6
|
+
export const INDEPENDENT_MACHINE_RESERVATION_FILE = 'independent-machine-attempt.json';
|
|
7
|
+
function canonicalOrigin(input) {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(input);
|
|
10
|
+
const local = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
|
|
11
|
+
return !url.username && !url.password && url.origin === input &&
|
|
12
|
+
(url.protocol === 'https:' || (url.protocol === 'http:' && local));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function validSecret(secret) {
|
|
19
|
+
return typeof secret === 'string' && /^[A-Za-z0-9_-]{43}$/.test(secret) &&
|
|
20
|
+
Buffer.from(secret, 'base64url').length === 32 &&
|
|
21
|
+
Buffer.from(secret, 'base64url').toString('base64url') === secret;
|
|
22
|
+
}
|
|
23
|
+
function hashSecret(profile, ownerId, origin, secret) {
|
|
24
|
+
return crypto.createHash('sha256').update('independent-machine-reservation:v2\0')
|
|
25
|
+
.update(profile).update('\0').update(ownerId).update('\0').update(origin).update('\0').update(secret).digest();
|
|
26
|
+
}
|
|
27
|
+
function reservationPath(root, profile) {
|
|
28
|
+
if (!path.isAbsolute(root) || !INDEPENDENT_MACHINE_PROFILE_RE.test(profile)) {
|
|
29
|
+
throw new Error('Invalid independent machine profile');
|
|
30
|
+
}
|
|
31
|
+
return path.join(root, 'profiles', profile, INDEPENDENT_MACHINE_RESERVATION_FILE);
|
|
32
|
+
}
|
|
33
|
+
/** Reserve a previously absent profile and persist the encrypted attempt secret.
|
|
34
|
+
* `encrypt` must use the platform secure store and throw if it is unavailable. */
|
|
35
|
+
export function reserveIndependentMachineProfile(options) {
|
|
36
|
+
if (!path.isAbsolute(options.root) || !/^[a-fA-F0-9]{24}$/.test(options.ownerId) ||
|
|
37
|
+
!canonicalOrigin(options.origin))
|
|
38
|
+
throw new Error('Invalid background authorization context');
|
|
39
|
+
const secret = crypto.randomBytes(32).toString('base64url');
|
|
40
|
+
const encrypted = options.encrypt(secret);
|
|
41
|
+
if (!Buffer.isBuffer(encrypted) || encrypted.length === 0 || encrypted.length > 4096) {
|
|
42
|
+
throw new Error('Background authorization secure storage is unavailable');
|
|
43
|
+
}
|
|
44
|
+
const profiles = path.join(options.root, 'profiles');
|
|
45
|
+
const rootExisted = fs.existsSync(options.root);
|
|
46
|
+
const profilesExisted = fs.existsSync(profiles);
|
|
47
|
+
fs.mkdirSync(profiles, { recursive: true, mode: 0o700 });
|
|
48
|
+
for (let i = 0; i < 5; i += 1) {
|
|
49
|
+
const profile = `desktop-bg-${crypto.randomBytes(16).toString('hex')}`;
|
|
50
|
+
const dir = path.join(profiles, profile);
|
|
51
|
+
try {
|
|
52
|
+
fs.mkdirSync(dir, { mode: 0o700 });
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code === 'EEXIST')
|
|
56
|
+
continue;
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
const reservation = {
|
|
60
|
+
version: 2, profile, ownerId: options.ownerId.toLowerCase(), origin: options.origin,
|
|
61
|
+
secretHash: hashSecret(profile, options.ownerId.toLowerCase(), options.origin, secret).toString('hex'),
|
|
62
|
+
encryptedSecret: encrypted.toString('base64'),
|
|
63
|
+
createdAt: new Date().toISOString(),
|
|
64
|
+
};
|
|
65
|
+
const file = reservationPath(options.root, profile);
|
|
66
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL |
|
|
67
|
+
(fs.constants.O_NOFOLLOW ?? 0), 0o600);
|
|
68
|
+
try {
|
|
69
|
+
fs.writeFileSync(fd, `${JSON.stringify(reservation)}\n`, 'utf8');
|
|
70
|
+
fs.fsyncSync(fd);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
fs.closeSync(fd);
|
|
74
|
+
}
|
|
75
|
+
const directories = [dir, profiles, ...(!profilesExisted ? [options.root] : []),
|
|
76
|
+
...(!rootExisted ? [path.dirname(options.root)] : [])];
|
|
77
|
+
for (const directory of directories) {
|
|
78
|
+
const directoryFd = fs.openSync(directory, 'r');
|
|
79
|
+
try {
|
|
80
|
+
fs.fsyncSync(directoryFd);
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
fs.closeSync(directoryFd);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// A profile whose reservation cannot be written is never used. Keep any
|
|
87
|
+
// uncertain directory for recovery rather than deleting other state.
|
|
88
|
+
return { reservation, secret };
|
|
89
|
+
}
|
|
90
|
+
throw new Error('Could not reserve a new background machine profile');
|
|
91
|
+
}
|
|
92
|
+
/** A saved reservation is a selection hint, never proof of machine ownership. */
|
|
93
|
+
export function readIndependentMachineReservation(root, profile) {
|
|
94
|
+
try {
|
|
95
|
+
const file = reservationPath(root, profile);
|
|
96
|
+
const directory = fs.lstatSync(path.dirname(file));
|
|
97
|
+
if (!directory.isDirectory() || directory.isSymbolicLink())
|
|
98
|
+
return null;
|
|
99
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
100
|
+
let raw;
|
|
101
|
+
try {
|
|
102
|
+
const stat = fs.fstatSync(fd);
|
|
103
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > 8192 || (stat.mode & 0o077) !== 0)
|
|
104
|
+
return null;
|
|
105
|
+
raw = fs.readFileSync(fd, 'utf8');
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
fs.closeSync(fd);
|
|
109
|
+
}
|
|
110
|
+
const value = JSON.parse(raw);
|
|
111
|
+
if (value.version !== 2 || value.profile !== profile ||
|
|
112
|
+
typeof value.ownerId !== 'string' || !/^[a-f0-9]{24}$/.test(value.ownerId) ||
|
|
113
|
+
typeof value.origin !== 'string' || !canonicalOrigin(value.origin) ||
|
|
114
|
+
typeof value.secretHash !== 'string' || !/^[a-f0-9]{64}$/.test(value.secretHash) ||
|
|
115
|
+
typeof value.encryptedSecret !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value.encryptedSecret) ||
|
|
116
|
+
typeof value.createdAt !== 'string' || !Number.isFinite(Date.parse(value.createdAt)))
|
|
117
|
+
return null;
|
|
118
|
+
return value;
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** Confirm a one-shot signer is acting on the desktop's reserved attempt. */
|
|
125
|
+
export function verifyIndependentMachineReservation(root, profile, secret) {
|
|
126
|
+
if (!validSecret(secret))
|
|
127
|
+
return false;
|
|
128
|
+
const saved = readIndependentMachineReservation(root, profile);
|
|
129
|
+
if (!saved)
|
|
130
|
+
return false;
|
|
131
|
+
return crypto.timingSafeEqual(Buffer.from(saved.secretHash, 'hex'), hashSecret(profile, saved.ownerId, saved.origin, secret));
|
|
132
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capabilities explained to a person before a separate background
|
|
3
|
+
* machine is authorized. This version is an immutable wire contract: adding
|
|
4
|
+
* access needs a new contract and a new authorization, not an edit to v1.
|
|
5
|
+
*/
|
|
6
|
+
export declare const INDEPENDENT_MACHINE_SCOPE_CONTRACT_V1: "desktop-background-v1";
|
|
7
|
+
export declare const INDEPENDENT_MACHINE_PERMISSIONS_V1: readonly [Readonly<{
|
|
8
|
+
scope: "daemon:tools:bash";
|
|
9
|
+
description: "Run terminal commands and local agents";
|
|
10
|
+
}>, Readonly<{
|
|
11
|
+
scope: "daemon:tools:fs:read";
|
|
12
|
+
description: "Read files in allowed folders";
|
|
13
|
+
}>, Readonly<{
|
|
14
|
+
scope: "daemon:tools:fs:write";
|
|
15
|
+
description: "Create and edit files in allowed folders";
|
|
16
|
+
}>, Readonly<{
|
|
17
|
+
scope: "daemon:browser:cdp";
|
|
18
|
+
description: "Control connected browser tabs";
|
|
19
|
+
}>, Readonly<{
|
|
20
|
+
scope: "daemon:session:replay";
|
|
21
|
+
description: "Replay results recorded while offline";
|
|
22
|
+
}>, Readonly<{
|
|
23
|
+
scope: "daemon:offline-queue";
|
|
24
|
+
description: "Send queued results after reconnecting";
|
|
25
|
+
}>];
|
|
26
|
+
export declare const INDEPENDENT_MACHINE_SCOPES_V1: readonly string[];
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The capabilities explained to a person before a separate background
|
|
3
|
+
* machine is authorized. This version is an immutable wire contract: adding
|
|
4
|
+
* access needs a new contract and a new authorization, not an edit to v1.
|
|
5
|
+
*/
|
|
6
|
+
export const INDEPENDENT_MACHINE_SCOPE_CONTRACT_V1 = 'desktop-background-v1';
|
|
7
|
+
export const INDEPENDENT_MACHINE_PERMISSIONS_V1 = Object.freeze([
|
|
8
|
+
Object.freeze({ scope: 'daemon:tools:bash', description: 'Run terminal commands and local agents' }),
|
|
9
|
+
Object.freeze({ scope: 'daemon:tools:fs:read', description: 'Read files in allowed folders' }),
|
|
10
|
+
Object.freeze({ scope: 'daemon:tools:fs:write', description: 'Create and edit files in allowed folders' }),
|
|
11
|
+
Object.freeze({ scope: 'daemon:browser:cdp', description: 'Control connected browser tabs' }),
|
|
12
|
+
Object.freeze({ scope: 'daemon:session:replay', description: 'Replay results recorded while offline' }),
|
|
13
|
+
Object.freeze({ scope: 'daemon:offline-queue', description: 'Send queued results after reconnecting' }),
|
|
14
|
+
]);
|
|
15
|
+
export const INDEPENDENT_MACHINE_SCOPES_V1 = Object.freeze(INDEPENDENT_MACHINE_PERMISSIONS_V1.map((permission) => permission.scope));
|
|
@@ -28,7 +28,7 @@ export { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as cred
|
|
|
28
28
|
export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, LinuxKek, MacosKek, registerZeroizeHook, WindowsKek, zeroizeKekCaches, type KekStrategy, } from './kek/index.js';
|
|
29
29
|
export { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, normalizeHtu, publicJwkFromX, tokenHash, verifyProof as verifyDeviceProof, type DeviceKeyPair, type DeviceProofClaims, type DeviceProofHeader, type Ed25519PrivateJwk, type Ed25519PublicJwk, type VerifyFailureReason as DeviceProofVerifyFailureReason, type VerifyFailure as DeviceProofVerifyFailure, type VerifyOptions as DeviceProofVerifyOptions, type VerifySuccess as DeviceProofVerifySuccess, } from './deviceKey.js';
|
|
30
30
|
export { DPOP_HEADER, DPOP_MAX_AGE_SECONDS, DPOP_PROOF_INVALID, DPOP_PROOF_REQUIRED, DPOP_PROOF_TYP, DPOP_PROOF_VERIFY_UNAVAILABLE, accessTokenHash, createDpopKey, isAth, isJwkThumbprint, normalizeDpopHtu, verifyDpopProof, type DpopKey, type DpopProofClaims, type DpopProofRequest, type DpopVerifyFailureReason, type DpopVerifyOptions, type DpopVerifyResult, } from './dpop.js';
|
|
31
|
-
export { initCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT, type DecryptOnReadOptions, __wrappedDekFilePathForTest, __setStateForTest as __setCredEnvelopeStateForTest, __getStateForTest as __getCredEnvelopeStateForTest, __resetShutdownGuardForTest, type ReadTransformResult, } from './credentialEnvelopeBridge.js';
|
|
31
|
+
export { initCredEnvelope, initExistingCredEnvelope, initNewIndependentCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT, type DecryptOnReadOptions, __wrappedDekFilePathForTest, __setStateForTest as __setCredEnvelopeStateForTest, __getStateForTest as __getCredEnvelopeStateForTest, __resetShutdownGuardForTest, type ReadTransformResult, } from './credentialEnvelopeBridge.js';
|
|
32
32
|
export { initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit, __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, type EnrollmentResult, } from './deviceIdentityBridge.js';
|
|
33
33
|
export { getMachineUuid, getMachineUuidForProfile, describeMachineIdentity, type MachineIdentityDescription, type MachineIdentityReason, getHardwareMachineUuid, setMachineUuidProfile, getMachineUuidProfile, qualifyMachineUuid, MACHINE_IDENTITY_FILENAME, hostedRuntimeIdOverride, HOSTED_RUNTIME_MACHINE_SOURCES, __resetMachineUuidCacheForTest, } from './machineUuid.js';
|
|
34
34
|
export { ensureMachineDaemonId, legacyDaemonUuids, __resetCachedDaemonIdForTest, } from './machineId.js';
|
|
@@ -50,3 +50,4 @@ export * from './harnessPermissionControls.js';
|
|
|
50
50
|
export { compareReleaseVersions } from './releaseVersions.js';
|
|
51
51
|
export * from './computerUse.js';
|
|
52
52
|
export * from './desktopWire.js';
|
|
53
|
+
export { INDEPENDENT_MACHINE_PROFILE_RE, INDEPENDENT_MACHINE_RESERVATION_FILE, readIndependentMachineReservation, reserveIndependentMachineProfile, verifyIndependentMachineReservation, type IndependentMachineReservation, } from './independentMachineReservation.js';
|
|
@@ -112,7 +112,7 @@ export { DPOP_HEADER, DPOP_MAX_AGE_SECONDS, DPOP_PROOF_INVALID, DPOP_PROOF_REQUI
|
|
|
112
112
|
// Daemon and CLI consume these; the underlying primitives live above.
|
|
113
113
|
export {
|
|
114
114
|
// credEnvelope bridge
|
|
115
|
-
initCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT,
|
|
115
|
+
initCredEnvelope, initExistingCredEnvelope, initNewIndependentCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT,
|
|
116
116
|
// Test seams — `__`-prefixed so consumers don't import them in
|
|
117
117
|
// production code paths. Re-exported here so CLI/daemon spec files
|
|
118
118
|
// can resolve the wrapped-DEK path against the same module instance.
|
|
@@ -186,3 +186,4 @@ export * from './harnessPermissionControls.js';
|
|
|
186
186
|
export { compareReleaseVersions } from './releaseVersions.js';
|
|
187
187
|
export * from './computerUse.js';
|
|
188
188
|
export * from './desktopWire.js';
|
|
189
|
+
export { INDEPENDENT_MACHINE_PROFILE_RE, INDEPENDENT_MACHINE_RESERVATION_FILE, readIndependentMachineReservation, reserveIndependentMachineProfile, verifyIndependentMachineReservation, } from './independentMachineReservation.js';
|
|
@@ -272,17 +272,9 @@ async function readKekFromKeychain(service, account) {
|
|
|
272
272
|
}
|
|
273
273
|
}
|
|
274
274
|
/**
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* wins outcome because (a) the daemon is single-tenant per profile and
|
|
279
|
-
* (b) all writers are generating the same shape of secret.
|
|
280
|
-
*
|
|
281
|
-
* Race note: if two daemon processes both miss and both store within the
|
|
282
|
-
* same TOCTOU window, the second overwrite wins and any DEKs already
|
|
283
|
-
* wrapped under the first KEK become permanently unrecoverable. The
|
|
284
|
-
* envelope path's caller (`credEnvelope.ts:initCredEnvelope`) handles
|
|
285
|
-
* this gracefully — `unwrap` failure → log + treat as first enrollment.
|
|
275
|
+
* Create a master KEK without replacing an existing item. This key is shared
|
|
276
|
+
* by profiles, so even a first-use race in a different process must not
|
|
277
|
+
* overwrite the key protecting already wrapped credentials.
|
|
286
278
|
*
|
|
287
279
|
* The `service` / `account` parameters are threaded from the calling
|
|
288
280
|
* `MacosKek` instance so each instance keeps its own keychain entry.
|
|
@@ -312,7 +304,6 @@ async function writeKekToKeychain(kek, service, account) {
|
|
|
312
304
|
account,
|
|
313
305
|
'-w',
|
|
314
306
|
b64,
|
|
315
|
-
'-U',
|
|
316
307
|
keychainPath,
|
|
317
308
|
]);
|
|
318
309
|
}
|
|
@@ -410,9 +401,31 @@ async function resolveOrCreateKek(service, account) {
|
|
|
410
401
|
return existing;
|
|
411
402
|
}
|
|
412
403
|
const fresh = crypto.randomBytes(KEY_LEN);
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
404
|
+
try {
|
|
405
|
+
try {
|
|
406
|
+
await writeKekToKeychain(fresh, service, account);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
// A concurrent creator may have won. Only a successful read of its
|
|
410
|
+
// existing item makes the failed create recoverable; all other errors
|
|
411
|
+
// remain failures and must not cause an update or a second write.
|
|
412
|
+
const winner = await readKekFromKeychain(service, account).catch(() => null);
|
|
413
|
+
if (!winner)
|
|
414
|
+
throw error;
|
|
415
|
+
_kekCacheBySvc.set(cacheKey, winner);
|
|
416
|
+
return winner;
|
|
417
|
+
}
|
|
418
|
+
const persisted = await readKekFromKeychain(service, account);
|
|
419
|
+
if (!persisted || !persisted.equals(fresh)) {
|
|
420
|
+
persisted?.fill(0);
|
|
421
|
+
throw new KekUnavailableError(KIND_SHELLOUT, 'created master KEK could not be verified');
|
|
422
|
+
}
|
|
423
|
+
_kekCacheBySvc.set(cacheKey, persisted);
|
|
424
|
+
return persisted;
|
|
425
|
+
}
|
|
426
|
+
finally {
|
|
427
|
+
fresh.fill(0);
|
|
428
|
+
}
|
|
416
429
|
}
|
|
417
430
|
/** Resolve an already-persisted master KEK without creating one. */
|
|
418
431
|
async function resolveExistingKek(service, account) {
|