@skrr-ai/cli 0.1.57 → 0.1.59
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.d.ts +0 -16
- package/dist/base-command.js +65 -11
- package/dist/commands/agents/show.js +13 -2
- package/dist/commands/harnesses/ping.d.ts +2 -0
- package/dist/commands/harnesses/ping.js +10 -1
- package/dist/commands/login.js +6 -1
- package/dist/commands/logout.d.ts +0 -46
- package/dist/commands/logout.js +28 -2
- package/dist/commands/pair.js +1 -1
- package/dist/lib/agentic-stream.d.ts +54 -0
- package/dist/lib/agentic-stream.js +153 -15
- package/dist/lib/api-fetch.d.ts +11 -0
- package/dist/lib/api-fetch.js +98 -1
- package/dist/lib/auth-storage.d.ts +27 -0
- package/dist/lib/auth-storage.js +42 -0
- package/dist/lib/commitments.js +1 -0
- package/dist/lib/daemonBroker.d.ts +5 -1
- package/dist/lib/daemonBroker.js +13 -2
- package/dist/lib/daemonBrokerRefusal.d.ts +21 -0
- package/dist/lib/daemonBrokerRefusal.js +87 -2
- package/dist/lib/daemonHandoff.d.ts +1 -1
- package/dist/lib/daemonHandoff.js +18 -0
- package/dist/lib/dedicated-guest.d.ts +13 -0
- package/dist/lib/dedicated-guest.js +22 -0
- package/dist/lib/dedicated-machines.js +17 -1
- package/dist/lib/dedicated-service.d.ts +1 -13
- package/dist/lib/dedicated-service.js +7 -19
- package/dist/lib/login.js +12 -3
- package/dist/lib/oauthLogin.js +3 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.d.ts +41 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +147 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +17 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +38 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +5 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.d.ts +21 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +40 -7
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.d.ts +41 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +147 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +17 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +38 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +2 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.d.ts +21 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +39 -6
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +4388 -4349
- package/oclif.manifest.json +26425 -26425
- package/package.json +1 -1
|
@@ -46,7 +46,8 @@ import path from 'node:path';
|
|
|
46
46
|
import lockfile from 'proper-lockfile';
|
|
47
47
|
import { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as credEnvelopeDeserialize, encrypt as credEnvelopeEncrypt, isEnvelopeString as isCredEnvelopeString, generateDek, serialize as credEnvelopeSerialize, } from './credentialEnvelope.js';
|
|
48
48
|
import { getKekStrategy, zeroizeKekCaches } from './kek/index.js';
|
|
49
|
-
import { consolidateProfileState, profileStateDir, profileStateFilePathsToClear, resolveProfileStateFile, } from './profileStateDir.js';
|
|
49
|
+
import { consolidateProfileState, profileStateDir, profileStateFilePathsToClear, resolveProfileStateFile, RETIRED_COPY_SUFFIX, } from './profileStateDir.js';
|
|
50
|
+
import { needsReauthState } from './refresh.js';
|
|
50
51
|
import { emitAuthTelemetry, getAuthLogger, getCredEnvelopeFailClosed } from './runtime.js';
|
|
51
52
|
/**
|
|
52
53
|
* Account name the wrapped DEK lives under (file fallback). Keeping the
|
|
@@ -239,6 +240,20 @@ async function withCredEnvelopeLock(profile, body) {
|
|
|
239
240
|
* auth-core's to do), so being loud about it IS the remedy.
|
|
240
241
|
*/
|
|
241
242
|
let _pathConflict = null;
|
|
243
|
+
/**
|
|
244
|
+
* DEKs this profile used before a converging consolidation retired them
|
|
245
|
+
* (OSK-12462). Read-only: nothing is ever sealed under one again. A value that
|
|
246
|
+
* only a retired DEK opens is returned with `needsMigration: true`, the same
|
|
247
|
+
* signal a legacy plaintext value carries, so every consumer that already
|
|
248
|
+
* upgrades plaintext in place re-seals it under the active DEK on its own
|
|
249
|
+
* schedule. auth-core does not need to know where the consumer keeps it.
|
|
250
|
+
*/
|
|
251
|
+
let _retiredDeks = [];
|
|
252
|
+
/**
|
|
253
|
+
* Set when THIS process converged a split profile onto the legacy key. For the
|
|
254
|
+
* status surface and tests; the on-disk marker is the durable record.
|
|
255
|
+
*/
|
|
256
|
+
let _convergedFrom = null;
|
|
242
257
|
/**
|
|
243
258
|
* Identity of a wrapped DEK: the SHA-256 of the key it unwraps to.
|
|
244
259
|
*
|
|
@@ -277,18 +292,48 @@ async function wrappedDekIdentity(target, kek, aad) {
|
|
|
277
292
|
* Never fatal. A migration that cannot run leaves the process exactly where it
|
|
278
293
|
* was, which is the state it has been operating in all along.
|
|
279
294
|
*/
|
|
295
|
+
/**
|
|
296
|
+
* Whether a split profile may be converged onto the legacy (daemon) key.
|
|
297
|
+
*
|
|
298
|
+
* Only the daemon ever has a legacy location, so only the daemon runs this, and
|
|
299
|
+
* the legacy key is the one it signs with. Since OSK-12063 the CLI enrolls no
|
|
300
|
+
* device key and only an interactive daemon login re-pins, so the key the server
|
|
301
|
+
* pins for this machine is the daemon's, UNLESS the daemon has itself been
|
|
302
|
+
* refused over its device key. That is the one fact that proves otherwise, and
|
|
303
|
+
* then nothing here knows which key the server holds, so it refuses as before.
|
|
304
|
+
*
|
|
305
|
+
* Why converging matters (OSK-12462): a CLI credential brokered by the daemon
|
|
306
|
+
* is tied to the daemon's machine row, and the server checks its refresh proof
|
|
307
|
+
* against the key pinned there. A CLI signing with a different key has that
|
|
308
|
+
* refresh refused as DEVICE_PROOF_THUMBPRINT_MISMATCH at its first renewal.
|
|
309
|
+
*/
|
|
310
|
+
export function splitKeyConvergencePolicy() {
|
|
311
|
+
let reauthReason;
|
|
312
|
+
try {
|
|
313
|
+
const state = needsReauthState();
|
|
314
|
+
reauthReason = state.needed ? state.reason : undefined;
|
|
315
|
+
}
|
|
316
|
+
catch {
|
|
317
|
+
reauthReason = undefined;
|
|
318
|
+
}
|
|
319
|
+
return reauthReason === 'DEVICE_KEY_MISMATCH'
|
|
320
|
+
? { converge: false, reason: 'daemon_key_refused' }
|
|
321
|
+
: { converge: true, reason: 'daemon_key_pinned' };
|
|
322
|
+
}
|
|
280
323
|
async function consolidateWrappedDek(profile, kek, aad) {
|
|
281
324
|
const resolved = resolveProfileStateFile(profile, ACCOUNT_CRED_DEK_WRAPPED);
|
|
282
325
|
if (!resolved.usingLegacy) {
|
|
283
326
|
_pathConflict = null;
|
|
284
327
|
return;
|
|
285
328
|
}
|
|
329
|
+
const policy = splitKeyConvergencePolicy();
|
|
286
330
|
let outcome;
|
|
287
331
|
try {
|
|
288
332
|
outcome = await withCredEnvelopeLock(profile, () => consolidateProfileState(profile, {
|
|
289
333
|
files: [ACCOUNT_CRED_DEK_WRAPPED],
|
|
290
334
|
primary: ACCOUNT_CRED_DEK_WRAPPED,
|
|
291
335
|
identify: (dir) => wrappedDekIdentity(path.join(dir, ACCOUNT_CRED_DEK_WRAPPED), kek, aad),
|
|
336
|
+
onConflict: policy.converge ? 'adopt_legacy' : 'refuse',
|
|
292
337
|
onEvent: (name, success, meta) => emit(`cred_envelope.${name}`, success, meta),
|
|
293
338
|
}));
|
|
294
339
|
}
|
|
@@ -299,6 +344,19 @@ async function consolidateWrappedDek(profile, kek, aad) {
|
|
|
299
344
|
});
|
|
300
345
|
return;
|
|
301
346
|
}
|
|
347
|
+
if (outcome.status === 'converged') {
|
|
348
|
+
_pathConflict = null;
|
|
349
|
+
_convergedFrom = {
|
|
350
|
+
canonicalPath: path.join(outcome.canonicalDir, ACCOUNT_CRED_DEK_WRAPPED),
|
|
351
|
+
legacyPath: path.join(outcome.legacyDir ?? '', ACCOUNT_CRED_DEK_WRAPPED),
|
|
352
|
+
};
|
|
353
|
+
getAuthLogger().info(`[credEnvelope] profile "${profile}" had two different usable wrapped DEKs; ` +
|
|
354
|
+
`converged on the daemon's (${_convergedFrom.legacyPath}), which is the key the ` +
|
|
355
|
+
"server pins for this machine. The CLI's former key is kept beside it as " +
|
|
356
|
+
`${ACCOUNT_CRED_DEK_WRAPPED}${RETIRED_COPY_SUFFIX}.* and still opens what was ` +
|
|
357
|
+
'sealed under it; each value is re-sealed under the shared key on its next write.');
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
302
360
|
if (outcome.status !== 'conflict') {
|
|
303
361
|
_pathConflict = null;
|
|
304
362
|
return;
|
|
@@ -315,13 +373,62 @@ async function consolidateWrappedDek(profile, kek, aad) {
|
|
|
315
373
|
// BINARY restarts and adopts its legacy key into the empty canonical slot,
|
|
316
374
|
// and the CLI logs back in on it. Not `skrr daemon restart`: any `skrr`
|
|
317
375
|
// command mints a fresh key into an empty slot before doing anything else.
|
|
318
|
-
|
|
376
|
+
//
|
|
377
|
+
// Reached only when the daemon itself was refused over its device key
|
|
378
|
+
// (`splitKeyConvergencePolicy`); an ordinary split converges above.
|
|
379
|
+
getAuthLogger().warn(`[credEnvelope] profile "${profile}" has two different usable wrapped DEKs, and ` +
|
|
380
|
+
"the server refused the daemon's key, so neither is known to be the pinned one; " +
|
|
319
381
|
`refusing to choose. Keeping ${_pathConflict.legacyPath} and leaving ` +
|
|
320
382
|
`${_pathConflict.canonicalPath} untouched. Each side can still read its own ` +
|
|
321
383
|
"credentials, and neither can read the other's. Do not delete " +
|
|
322
384
|
`${_pathConflict.legacyPath}: it is the key the daemon reads. To converge on it, ` +
|
|
323
385
|
'run `skrr logout && skrrd restart && skrr login`.');
|
|
324
386
|
}
|
|
387
|
+
/**
|
|
388
|
+
* Load every retired DEK beside the canonical one (OSK-12462). Only files a
|
|
389
|
+
* converging consolidation set aside are read; the `.superseded.*` copy an
|
|
390
|
+
* UNUSABLE key leaves is not a key anything can open. Read-only unwrap, never
|
|
391
|
+
* fatal: a retired key that no longer opens is simply not in the ring.
|
|
392
|
+
*/
|
|
393
|
+
async function loadRetiredDeks(profile, kek, aad, active) {
|
|
394
|
+
for (const old of _retiredDeks)
|
|
395
|
+
old.fill(0);
|
|
396
|
+
_retiredDeks = [];
|
|
397
|
+
for (const target of retiredDekFiles(profile)) {
|
|
398
|
+
try {
|
|
399
|
+
if (!kek.unwrapExisting)
|
|
400
|
+
return;
|
|
401
|
+
const dek = await kek.unwrapExisting(fs.readFileSync(target), aad);
|
|
402
|
+
if (dek.length === 32 && !dek.equals(active)) {
|
|
403
|
+
_retiredDeks.push(dek);
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
dek.fill(0);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
emit('cred_envelope.retired_dek.unreadable', false, {
|
|
411
|
+
profile,
|
|
412
|
+
file: path.basename(target),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** Retired wrapped DEKs for `profile`, oldest first. */
|
|
418
|
+
function retiredDekFiles(profile) {
|
|
419
|
+
const dir = profileStateDir(profile);
|
|
420
|
+
const prefix = `${ACCOUNT_CRED_DEK_WRAPPED}${RETIRED_COPY_SUFFIX}.`;
|
|
421
|
+
try {
|
|
422
|
+
return fs
|
|
423
|
+
.readdirSync(dir)
|
|
424
|
+
.filter((name) => name.startsWith(prefix))
|
|
425
|
+
.sort()
|
|
426
|
+
.map((name) => path.join(dir, name));
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
325
432
|
/**
|
|
326
433
|
* Initialize the envelope path. Idempotent — second call when state is
|
|
327
434
|
* non-uninit returns immediately.
|
|
@@ -396,6 +503,7 @@ export async function initCredEnvelope(profile) {
|
|
|
396
503
|
const wrappedBytes = fs.readFileSync(targetPath);
|
|
397
504
|
const candidate = await kek.unwrapExisting(wrappedBytes, aad);
|
|
398
505
|
if (candidate.length === 32) {
|
|
506
|
+
await loadRetiredDeks(profile, kek, aad, candidate);
|
|
399
507
|
_state = {
|
|
400
508
|
kind: 'active',
|
|
401
509
|
dek: candidate,
|
|
@@ -503,6 +611,7 @@ export async function initCredEnvelope(profile) {
|
|
|
503
611
|
return;
|
|
504
612
|
}
|
|
505
613
|
}
|
|
614
|
+
await loadRetiredDeks(profile, kek, aad, dek);
|
|
506
615
|
_state = {
|
|
507
616
|
kind: 'active',
|
|
508
617
|
dek: dek,
|
|
@@ -533,7 +642,12 @@ export async function initCredEnvelope(profile) {
|
|
|
533
642
|
*/
|
|
534
643
|
export async function resetCredEnvelope(opts) {
|
|
535
644
|
if (opts?.clearOnDisk && opts.profile) {
|
|
536
|
-
|
|
645
|
+
// A logout ends every key this profile held, the retired ones included: a
|
|
646
|
+
// retired DEK exists only to open values the logout is deleting anyway.
|
|
647
|
+
for (const target of [
|
|
648
|
+
...profileStateFilePathsToClear(opts.profile, ACCOUNT_CRED_DEK_WRAPPED),
|
|
649
|
+
...retiredDekFiles(opts.profile),
|
|
650
|
+
]) {
|
|
537
651
|
try {
|
|
538
652
|
fs.unlinkSync(target);
|
|
539
653
|
}
|
|
@@ -554,6 +668,10 @@ export async function resetCredEnvelope(opts) {
|
|
|
554
668
|
_state = { kind: 'uninit' };
|
|
555
669
|
_kekRotationDetected = false;
|
|
556
670
|
_pathConflict = null;
|
|
671
|
+
_convergedFrom = null;
|
|
672
|
+
for (const old of _retiredDeks)
|
|
673
|
+
old.fill(0);
|
|
674
|
+
_retiredDeks = [];
|
|
557
675
|
}
|
|
558
676
|
/**
|
|
559
677
|
* Idempotent shutdown — must be called from the daemon's graceful-exit
|
|
@@ -592,6 +710,10 @@ export function shutdownCredEnvelope() {
|
|
|
592
710
|
_state = { kind: 'uninit' };
|
|
593
711
|
_kekRotationDetected = false;
|
|
594
712
|
_pathConflict = null;
|
|
713
|
+
_convergedFrom = null;
|
|
714
|
+
for (const old of _retiredDeks)
|
|
715
|
+
old.fill(0);
|
|
716
|
+
_retiredDeks = [];
|
|
595
717
|
}
|
|
596
718
|
/** @internal Test seam — re-arm `shutdownCredEnvelope` so it can run again. */
|
|
597
719
|
export function __resetShutdownGuardForTest() {
|
|
@@ -627,6 +749,10 @@ export function hasCredEnvelopePathConflict() {
|
|
|
627
749
|
* `oversky status`; clears on the next clean `resetCredEnvelope`.
|
|
628
750
|
*/
|
|
629
751
|
export function describeCredEnvelopeState() {
|
|
752
|
+
const extras = {
|
|
753
|
+
...(_convergedFrom ? { convergedFrom: _convergedFrom } : {}),
|
|
754
|
+
...(_retiredDeks.length > 0 ? { retiredKeys: _retiredDeks.length } : {}),
|
|
755
|
+
};
|
|
630
756
|
if (_state.kind === 'disabled') {
|
|
631
757
|
return {
|
|
632
758
|
kind: 'disabled',
|
|
@@ -643,6 +769,7 @@ export function describeCredEnvelopeState() {
|
|
|
643
769
|
profile: _state.profile,
|
|
644
770
|
...(_kekRotationDetected ? { kekRotated: true } : {}),
|
|
645
771
|
...(_pathConflict ? { pathConflict: _pathConflict } : {}),
|
|
772
|
+
...extras,
|
|
646
773
|
};
|
|
647
774
|
}
|
|
648
775
|
return { kind: 'uninit', ...(_pathConflict ? { pathConflict: _pathConflict } : {}) };
|
|
@@ -764,12 +891,28 @@ export function maybeDecryptOnRead(stored, options = {}) {
|
|
|
764
891
|
});
|
|
765
892
|
return { plaintext: null, needsMigration: false };
|
|
766
893
|
}
|
|
894
|
+
const aad = buildCredentialAad(_state.profile);
|
|
767
895
|
try {
|
|
768
|
-
const aad = buildCredentialAad(_state.profile);
|
|
769
896
|
const pt = credEnvelopeDecrypt(sealed, _state.dek, aad).toString('utf-8');
|
|
770
897
|
return { plaintext: pt, needsMigration: false };
|
|
771
898
|
}
|
|
772
899
|
catch (err) {
|
|
900
|
+
// Sealed before a converging consolidation retired its key (OSK-12462).
|
|
901
|
+
// Readable, and flagged for the in-place upgrade every consumer already
|
|
902
|
+
// performs for legacy plaintext, which re-seals it under the active DEK.
|
|
903
|
+
for (const retired of _retiredDeks) {
|
|
904
|
+
try {
|
|
905
|
+
const pt = credEnvelopeDecrypt(sealed, retired, aad).toString('utf-8');
|
|
906
|
+
emit('cred_envelope.read.retired_key', true, {
|
|
907
|
+
kekId: _state.kekId,
|
|
908
|
+
kekKind: _state.kekKind,
|
|
909
|
+
});
|
|
910
|
+
return { plaintext: pt, needsMigration: true };
|
|
911
|
+
}
|
|
912
|
+
catch {
|
|
913
|
+
/* not this one */
|
|
914
|
+
}
|
|
915
|
+
}
|
|
773
916
|
emit('cred_envelope.read.decrypt_failed', false, {
|
|
774
917
|
kekId: _state.kekId,
|
|
775
918
|
kekKind: _state.kekKind,
|
|
@@ -15,6 +15,23 @@ type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed';
|
|
|
15
15
|
export declare function __setStateForTest(next: DeviceIdentityState): DeviceIdentityState;
|
|
16
16
|
/** @internal Test seam — read state. */
|
|
17
17
|
export declare function __getStateForTest(): DeviceIdentityState;
|
|
18
|
+
/**
|
|
19
|
+
* The device-key split, read from disk: the daemon (legacy directory, not yet
|
|
20
|
+
* consolidated) and the CLI (canonical directory) holding DIFFERENT keypairs
|
|
21
|
+
* for one profile. `null` when this process has no legacy location, when the
|
|
22
|
+
* legacy copy is consolidated, or when either side has no readable public key.
|
|
23
|
+
*
|
|
24
|
+
* This is the fact the server acts on (OSK-12462): a CLI credential brokered by
|
|
25
|
+
* the daemon is checked against the key pinned on the daemon's machine row, so
|
|
26
|
+
* while the thumbprints differ the CLI's refresh is refused. Reading it from
|
|
27
|
+
* the public JWKs, which are plaintext, needs no DEK and changes nothing.
|
|
28
|
+
*/
|
|
29
|
+
export declare function describeDeviceKeySplit(profile: string): {
|
|
30
|
+
daemonThumbprint: string;
|
|
31
|
+
cliThumbprint: string;
|
|
32
|
+
daemonDir: string;
|
|
33
|
+
cliDir: string;
|
|
34
|
+
} | null;
|
|
18
35
|
/**
|
|
19
36
|
* Initialize device identity. Idempotent.
|
|
20
37
|
*
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import fs from 'node:fs';
|
|
33
33
|
import path from 'node:path';
|
|
34
34
|
import { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, } from './deviceKey.js';
|
|
35
|
-
import { hasCredEnvelopePathConflict, isCredEnvelopeActive, maybeDecryptOnRead, wrapIfActiveOrPassthrough, } from './credentialEnvelopeBridge.js';
|
|
35
|
+
import { hasCredEnvelopePathConflict, isCredEnvelopeActive, maybeDecryptOnRead, splitKeyConvergencePolicy, wrapIfActiveOrPassthrough, } from './credentialEnvelopeBridge.js';
|
|
36
36
|
import { consolidateProfileState, profileStateFilePathsToClear, resolveProfileStateDir, } from './profileStateDir.js';
|
|
37
37
|
import { emitAuthTelemetry, getAuthLogger } from './runtime.js';
|
|
38
38
|
const PUBLIC_KEY_FILENAME = 'device-public-key.jwk';
|
|
@@ -243,6 +243,37 @@ function deviceIdentityOf(dir) {
|
|
|
243
243
|
return null;
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* The device-key split, read from disk: the daemon (legacy directory, not yet
|
|
248
|
+
* consolidated) and the CLI (canonical directory) holding DIFFERENT keypairs
|
|
249
|
+
* for one profile. `null` when this process has no legacy location, when the
|
|
250
|
+
* legacy copy is consolidated, or when either side has no readable public key.
|
|
251
|
+
*
|
|
252
|
+
* This is the fact the server acts on (OSK-12462): a CLI credential brokered by
|
|
253
|
+
* the daemon is checked against the key pinned on the daemon's machine row, so
|
|
254
|
+
* while the thumbprints differ the CLI's refresh is refused. Reading it from
|
|
255
|
+
* the public JWKs, which are plaintext, needs no DEK and changes nothing.
|
|
256
|
+
*/
|
|
257
|
+
export function describeDeviceKeySplit(profile) {
|
|
258
|
+
try {
|
|
259
|
+
const resolved = resolveProfileStateDir(profile, PRIVATE_KEY_FILENAME);
|
|
260
|
+
if (!resolved.usingLegacy || resolved.legacyDir === null)
|
|
261
|
+
return null;
|
|
262
|
+
const daemonThumbprint = deviceIdentityOf(resolved.legacyDir);
|
|
263
|
+
const cliThumbprint = deviceIdentityOf(resolved.canonicalDir);
|
|
264
|
+
if (!daemonThumbprint || !cliThumbprint || daemonThumbprint === cliThumbprint)
|
|
265
|
+
return null;
|
|
266
|
+
return {
|
|
267
|
+
daemonThumbprint,
|
|
268
|
+
cliThumbprint,
|
|
269
|
+
daemonDir: resolved.legacyDir,
|
|
270
|
+
cliDir: resolved.canonicalDir,
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
246
277
|
/**
|
|
247
278
|
* Run the OSK-10314 consolidation for this profile's device identity.
|
|
248
279
|
*
|
|
@@ -257,11 +288,17 @@ async function consolidateDeviceIdentity(profile) {
|
|
|
257
288
|
emit('path_consolidation.deferred', true, { profile, reason: 'cred_envelope_path_conflict' }, { logLevel: 'info' });
|
|
258
289
|
return;
|
|
259
290
|
}
|
|
291
|
+
// Two different keypairs under one DEK are converged the way two DEKs are:
|
|
292
|
+
// onto the daemon's, unless the daemon itself was refused over it. The CLI's
|
|
293
|
+
// keypair is set aside as `.retired`, not deleted (OSK-12462). Leaving them
|
|
294
|
+
// split is what had the CLI refresh refused as a device-key mismatch.
|
|
295
|
+
const policy = splitKeyConvergencePolicy();
|
|
260
296
|
try {
|
|
261
297
|
await consolidateProfileState(profile, {
|
|
262
298
|
files: DEVICE_IDENTITY_FILES,
|
|
263
299
|
primary: PRIVATE_KEY_FILENAME,
|
|
264
300
|
identify: deviceIdentityOf,
|
|
301
|
+
onConflict: policy.converge ? 'adopt_legacy' : 'refuse',
|
|
265
302
|
onEvent: (name, success, meta) => emit(name, success, meta),
|
|
266
303
|
});
|
|
267
304
|
}
|
|
@@ -27,8 +27,8 @@ export { formatRecoveryCode, parseRecoveryCode, normalizeRecoveryCode, isValidRe
|
|
|
27
27
|
export { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as credEnvelopeDeserialize, encrypt as credEnvelopeEncrypt, ENVELOPE_MAGIC as CRED_ENVELOPE_MAGIC, generateDek, isEnvelopeString as isCredEnvelopeString, KEY_LEN as CRED_KEY_LEN, openBuffer as credOpenBuffer, sealBuffer as credSealBuffer, serialize as credEnvelopeSerialize, unwrap as credEnvelopeUnwrap, wrap as credEnvelopeWrap, type SealedBlob as CredSealedBlob, } from './credentialEnvelope.js';
|
|
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
|
-
export { initCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT, type DecryptOnReadOptions, __wrappedDekFilePathForTest, __setStateForTest as __setCredEnvelopeStateForTest, __getStateForTest as __getCredEnvelopeStateForTest, __resetShutdownGuardForTest, type ReadTransformResult, } from './credentialEnvelopeBridge.js';
|
|
31
|
-
export { initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, type EnrollmentResult, } from './deviceIdentityBridge.js';
|
|
30
|
+
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 { initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit, __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, type EnrollmentResult, } from './deviceIdentityBridge.js';
|
|
32
32
|
export { getMachineUuid, hostedRuntimeIdOverride, HOSTED_RUNTIME_MACHINE_SOURCES, __resetMachineUuidCacheForTest, } from './machineUuid.js';
|
|
33
33
|
export { ensureMachineDaemonId, legacyDaemonUuids, __resetCachedDaemonIdForTest, } from './machineId.js';
|
|
34
34
|
export { PAIR_AAD_PREFIX, PAIR_AAD_MAX_LEN, PAIR_BUNDLE_MAX_BASE64_LEN, PAIR_NONCE_LEN, PAIR_PUBKEY_LEN, PAIR_SECRET_LEN, PairBundleError, assemblePairBundleBase64, base64ToUint8Array as pairBase64ToUint8Array, base64UrlToUint8Array as pairBase64UrlToUint8Array, buildPairAad, buildPairUrl, generatePairKeyPair, openPairBundleBase64, parsePairBundle, parsePairPlaintext, parsePairUrl, uint8ArrayToBase64 as pairUint8ArrayToBase64, uint8ArrayToBase64Url as pairUint8ArrayToBase64Url, type AssembleBundleOptions as PairAssembleBundleOptions, type OpenBundleOptions as PairOpenBundleOptions, type PairBundle, type PairBundleErrorCode, type PairKeyPair, type PairPlaintextV1, type PairUrlFields, } from './daemonPair.js';
|
|
@@ -108,14 +108,14 @@ export { buildProof as buildDeviceProof, generateDeviceKeyPair, jwkThumbprint, n
|
|
|
108
108
|
// Daemon and CLI consume these; the underlying primitives live above.
|
|
109
109
|
export {
|
|
110
110
|
// credEnvelope bridge
|
|
111
|
-
initCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT,
|
|
111
|
+
initCredEnvelope, resetCredEnvelope, shutdownCredEnvelope, isCredEnvelopeActive, hasCredEnvelopePathConflict, describeCredEnvelopeState, splitKeyConvergencePolicy, maybeEncryptForWrite, maybeDecryptOnRead, DEFAULT_UNREADABLE_CREDENTIAL_HINT,
|
|
112
112
|
// Test seams — `__`-prefixed so consumers don't import them in
|
|
113
113
|
// production code paths. Re-exported here so CLI/daemon spec files
|
|
114
114
|
// can resolve the wrapped-DEK path against the same module instance.
|
|
115
115
|
__wrappedDekFilePathForTest, __setStateForTest as __setCredEnvelopeStateForTest, __getStateForTest as __getCredEnvelopeStateForTest, __resetShutdownGuardForTest, } from './credentialEnvelopeBridge.js';
|
|
116
116
|
export {
|
|
117
117
|
// deviceIdentity bridge
|
|
118
|
-
initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState,
|
|
118
|
+
initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit,
|
|
119
119
|
// Test seams — file path resolvers + state setters for spec files.
|
|
120
120
|
__publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, } from './deviceIdentityBridge.js';
|
|
121
121
|
// Hardware machine identity sent as `machineUuid` (IOPlatformUUID /
|
|
@@ -11,6 +11,13 @@ export declare const SUPERSEDED_MARKER_SUFFIX = ".superseded.json";
|
|
|
11
11
|
* canonical copy existed and was NOT usable — a usable one is never replaced.
|
|
12
12
|
*/
|
|
13
13
|
export declare const SUPERSEDED_COPY_SUFFIX = ".superseded";
|
|
14
|
+
/**
|
|
15
|
+
* Suffix for a USABLE canonical file set aside by a converging consolidation
|
|
16
|
+
* (`onConflict: 'adopt_legacy'`). Distinct from `SUPERSEDED_COPY_SUFFIX`, which
|
|
17
|
+
* names an unusable copy: a retired key still opens what was sealed under it,
|
|
18
|
+
* and the credential envelope reads these as its keyring (OSK-12462).
|
|
19
|
+
*/
|
|
20
|
+
export declare const RETIRED_COPY_SUFFIX = ".retired";
|
|
14
21
|
/** The canonical directory for a profile's auth state. */
|
|
15
22
|
export declare function profileStateDir(profile: string): string;
|
|
16
23
|
/**
|
|
@@ -59,7 +66,12 @@ export type ConsolidationStatus =
|
|
|
59
66
|
/** Canonical was present but unusable; replaced, and kept under `.superseded`. */
|
|
60
67
|
| 'superseded'
|
|
61
68
|
/** Both copies are usable and DIFFERENT. Refused — a human decides. */
|
|
62
|
-
| 'conflict'
|
|
69
|
+
| 'conflict'
|
|
70
|
+
/**
|
|
71
|
+
* Both copies were usable and DIFFERENT, and the caller chose the legacy one
|
|
72
|
+
* (`onConflict: 'adopt_legacy'`). The canonical copy is kept as `.retired`.
|
|
73
|
+
*/
|
|
74
|
+
| 'converged';
|
|
63
75
|
export interface ConsolidationOutcome {
|
|
64
76
|
status: ConsolidationStatus;
|
|
65
77
|
profile: string;
|
|
@@ -98,6 +110,14 @@ export interface ConsolidationSpec {
|
|
|
98
110
|
identify?: (dir: string) => Promise<string | null> | string | null;
|
|
99
111
|
/** Report what would happen and write nothing. */
|
|
100
112
|
dryRun?: boolean;
|
|
113
|
+
/**
|
|
114
|
+
* What to do when both copies are usable and DIFFERENT. `refuse` (default)
|
|
115
|
+
* leaves both untouched and reports `conflict`. `adopt_legacy` sets the
|
|
116
|
+
* canonical copy aside as `<file>.retired.<stamp>` and adopts the legacy one,
|
|
117
|
+
* reporting `converged`. Only a caller that has decided which key must win
|
|
118
|
+
* may pass it — the files are kept, but nothing here can re-seal content.
|
|
119
|
+
*/
|
|
120
|
+
onConflict?: 'refuse' | 'adopt_legacy';
|
|
101
121
|
onEvent?: (name: string, success: boolean, meta: Record<string, unknown>) => void;
|
|
102
122
|
}
|
|
103
123
|
/**
|
|
@@ -56,10 +56,14 @@
|
|
|
56
56
|
* 4. no canonical copy of the file → legacy (not yet consolidated)
|
|
57
57
|
* 5. both copies, no marker → legacy (unresolved: stay put)
|
|
58
58
|
*
|
|
59
|
-
* The migration writes the marker
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
59
|
+
* The migration writes the marker and never deletes. Case 5 with a usable and
|
|
60
|
+
* DIFFERENT key on each side is refused by default. A caller that knows which
|
|
61
|
+
* key must win can pass `onConflict: 'adopt_legacy'`, which moves the canonical
|
|
62
|
+
* copy aside as `<file>.retired.<stamp>` (kept, never deleted) and adopts the
|
|
63
|
+
* legacy one. The credential envelope does that for the daemon's key, and keeps
|
|
64
|
+
* the retired DEK readable so nothing sealed under it is lost (OSK-12462): the
|
|
65
|
+
* refusal alone left the CLI signing with a key the server does not pin, which
|
|
66
|
+
* gets its refresh refused as a device-key mismatch.
|
|
63
67
|
*
|
|
64
68
|
* ROLLBACK. The legacy file is left exactly where it was, under its own name.
|
|
65
69
|
* Older code looks there, finds it, and works — no operator step. The marker is
|
|
@@ -82,6 +86,13 @@ export const SUPERSEDED_MARKER_SUFFIX = '.superseded.json';
|
|
|
82
86
|
* canonical copy existed and was NOT usable — a usable one is never replaced.
|
|
83
87
|
*/
|
|
84
88
|
export const SUPERSEDED_COPY_SUFFIX = '.superseded';
|
|
89
|
+
/**
|
|
90
|
+
* Suffix for a USABLE canonical file set aside by a converging consolidation
|
|
91
|
+
* (`onConflict: 'adopt_legacy'`). Distinct from `SUPERSEDED_COPY_SUFFIX`, which
|
|
92
|
+
* names an unusable copy: a retired key still opens what was sealed under it,
|
|
93
|
+
* and the credential envelope reads these as its keyring (OSK-12462).
|
|
94
|
+
*/
|
|
95
|
+
export const RETIRED_COPY_SUFFIX = '.retired';
|
|
85
96
|
/** The canonical directory for a profile's auth state. */
|
|
86
97
|
export function profileStateDir(profile) {
|
|
87
98
|
assertProfile(profile);
|
|
@@ -213,6 +224,28 @@ export async function consolidateProfileState(profile, spec) {
|
|
|
213
224
|
identities,
|
|
214
225
|
};
|
|
215
226
|
}
|
|
227
|
+
if (spec.onConflict === 'adopt_legacy') {
|
|
228
|
+
const aside = dryRun
|
|
229
|
+
? plannedAside(canonicalDir, spec.files)
|
|
230
|
+
: moveAside(canonicalDir, spec.files, RETIRED_COPY_SUFFIX);
|
|
231
|
+
const moved = adopt(legacyDir, canonicalDir, spec.files, dryRun);
|
|
232
|
+
if (!dryRun)
|
|
233
|
+
writeMarker(markerPath, canonicalPrimary, 'converged');
|
|
234
|
+
emitOutcome(spec, 'converged', {
|
|
235
|
+
profile,
|
|
236
|
+
identities,
|
|
237
|
+
from: legacyDir,
|
|
238
|
+
to: canonicalDir,
|
|
239
|
+
retired: aside,
|
|
240
|
+
});
|
|
241
|
+
return {
|
|
242
|
+
...base,
|
|
243
|
+
status: 'converged',
|
|
244
|
+
written: moved.written,
|
|
245
|
+
supersededAside: aside,
|
|
246
|
+
identities,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
216
249
|
emitOutcome(spec, 'conflict', { profile, identities, canonicalDir, legacyDir });
|
|
217
250
|
return { ...base, status: 'conflict', identities };
|
|
218
251
|
}
|
|
@@ -297,14 +330,14 @@ function adopt(fromDir, toDir, files, dryRun) {
|
|
|
297
330
|
}
|
|
298
331
|
return { written, supersededAside: [] };
|
|
299
332
|
}
|
|
300
|
-
function moveAside(dir, files) {
|
|
333
|
+
function moveAside(dir, files, suffix = SUPERSEDED_COPY_SUFFIX) {
|
|
301
334
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
302
335
|
const aside = [];
|
|
303
336
|
for (const name of files) {
|
|
304
337
|
const target = path.join(dir, name);
|
|
305
338
|
if (!existsFile(target))
|
|
306
339
|
continue;
|
|
307
|
-
fs.renameSync(target, `${target}${
|
|
340
|
+
fs.renameSync(target, `${target}${suffix}.${stamp}`);
|
|
308
341
|
aside.push(target);
|
|
309
342
|
}
|
|
310
343
|
return aside;
|