@skrr-ai/cli 0.1.122 → 0.1.124
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/commands/agents/chat.d.ts +7 -0
- package/dist/commands/agents/chat.js +32 -6
- package/dist/commands/agents/delete.d.ts +8 -0
- package/dist/commands/agents/delete.js +16 -0
- package/dist/commands/context.js +7 -0
- package/dist/commands/followups/act.js +3 -0
- package/dist/commands/followups/checkin.js +3 -0
- package/dist/commands/followups/remind.js +3 -0
- package/dist/commands/followups/show.js +1 -1
- package/dist/commands/followups/watch.js +3 -0
- package/dist/commands/search/workspace.js +1 -1
- package/dist/commands/store/gallery-admissions/approve.js +1 -1
- package/dist/commands/store/gallery-admissions/preview.js +1 -1
- package/dist/commands/store/gallery-admissions/refuse.js +1 -1
- package/dist/commands/store/gallery-admissions/show.js +1 -1
- package/dist/lib/agentic-stream.d.ts +46 -0
- package/dist/lib/agentic-stream.js +135 -14
- package/dist/lib/auth-core-init.d.ts +15 -2
- package/dist/lib/auth-core-init.js +47 -6
- package/dist/lib/auth-failure.js +12 -0
- package/dist/lib/command-hygiene.js +30 -1
- package/dist/lib/daemonBrokerRefusal.js +5 -1
- package/dist/lib/exec-runtime-binary.d.ts +9 -0
- package/dist/lib/exec-runtime-binary.js +15 -1
- package/dist/lib/followups.d.ts +13 -0
- package/dist/lib/followups.js +8 -0
- package/dist/lib/keychain.js +41 -4
- package/dist/lib/profile-machine-identity-guard.js +2 -1
- package/dist/lib/runtime-start-verification.js +6 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +5 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +69 -27
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +135 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +440 -83
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +106 -7
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +77 -19
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.d.ts +33 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.js +77 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.d.ts +68 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.js +264 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.d.ts +31 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.js +90 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +2 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +2 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +7 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +110 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +13 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.d.ts +18 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +11 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.d.ts +22 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +14 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +69 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +67 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.d.ts +154 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.js +281 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineId.js +6 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineUuid.js +8 -5
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.js +1 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +7 -27
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +24 -16
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.d.ts +31 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.js +89 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/telemetryRedaction.d.ts +3 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/telemetryRedaction.js +41 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/types.d.ts +7 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +5 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +4 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +122 -38
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +135 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +603 -90
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +106 -7
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +131 -27
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.d.ts +33 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.js +81 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.d.ts +68 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.js +317 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.d.ts +31 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.js +86 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +4 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +4 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +7 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +14 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.d.ts +18 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +18 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.d.ts +22 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +23 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +69 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +134 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.d.ts +154 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.js +386 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineId.js +6 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineUuid.js +11 -5
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.js +1 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +6 -35
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +45 -16
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.d.ts +31 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.js +73 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/telemetryRedaction.d.ts +3 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/telemetryRedaction.js +40 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/types.d.ts +7 -1
- package/dist/node_modules/@skrr-ai/auth-core/package.json +21 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +7349 -7325
- package/oclif.manifest.json +11 -2
- package/package.json +4 -1
|
@@ -45,10 +45,12 @@ import fs from 'node:fs';
|
|
|
45
45
|
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
|
-
import { getKekStrategy, zeroizeKekCaches } from './kek/index.js';
|
|
48
|
+
import { getKekStrategy, KekUnavailableError, zeroizeKekCaches, } from './kek/index.js';
|
|
49
49
|
import { consolidateProfileState, profileStateDir, profileStateFilePathsToClear, resolveProfileStateFile, RETIRED_COPY_SUFFIX, } from './profileStateDir.js';
|
|
50
|
+
import { assertMayMutateLiveState, rethrowIfLiveStateRefusal } from './liveState.js';
|
|
50
51
|
import { needsReauthState } from './refresh.js';
|
|
51
52
|
import { emitAuthTelemetry, getAuthLogger, getCredEnvelopeFailClosed } from './runtime.js';
|
|
53
|
+
import { atomicWrite0600, ensureStateDir, removeStateFile, writeStateFile } from './stateFiles.js';
|
|
52
54
|
/**
|
|
53
55
|
* Account name the wrapped DEK lives under (file fallback). Keeping the
|
|
54
56
|
* literal here so a future rename surfaces as a single grep target.
|
|
@@ -84,37 +86,6 @@ export function __getStateForTest() {
|
|
|
84
86
|
function wrappedDekFilePath(profile) {
|
|
85
87
|
return resolveProfileStateFile(profile, ACCOUNT_CRED_DEK_WRAPPED).path;
|
|
86
88
|
}
|
|
87
|
-
/**
|
|
88
|
-
* Atomic 0600 write — tmp+fsync+rename+dir-fsync. Same shape as the
|
|
89
|
-
* original daemon/CLI implementations so partial-write races between
|
|
90
|
-
* `sky` and `oversky` produce the same on-disk transitions.
|
|
91
|
-
*/
|
|
92
|
-
function atomicWrite0600(target, value) {
|
|
93
|
-
const dir = path.dirname(target);
|
|
94
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
95
|
-
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
96
|
-
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
97
|
-
try {
|
|
98
|
-
fs.writeSync(fd, value);
|
|
99
|
-
fs.fsyncSync(fd);
|
|
100
|
-
}
|
|
101
|
-
finally {
|
|
102
|
-
fs.closeSync(fd);
|
|
103
|
-
}
|
|
104
|
-
fs.renameSync(tmp, target);
|
|
105
|
-
try {
|
|
106
|
-
const dirFd = fs.openSync(dir, 'r');
|
|
107
|
-
try {
|
|
108
|
-
fs.fsyncSync(dirFd);
|
|
109
|
-
}
|
|
110
|
-
finally {
|
|
111
|
-
fs.closeSync(dirFd);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
/* best-effort — Windows / some BSDs reject dir fsync */
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
89
|
/**
|
|
119
90
|
* Telemetry shim. Routes through the consumer-supplied logEvent sink
|
|
120
91
|
* (daemon: structured event log; CLI: typically no-op or stderr at DEBUG).
|
|
@@ -195,9 +166,9 @@ function credEnvelopeLockPath(profile) {
|
|
|
195
166
|
async function withCredEnvelopeLock(profile, body) {
|
|
196
167
|
const lockPath = credEnvelopeLockPath(profile);
|
|
197
168
|
const dir = path.dirname(lockPath);
|
|
198
|
-
|
|
169
|
+
ensureStateDir(dir, `take the credential envelope lock for profile ${profile}`);
|
|
199
170
|
if (!fs.existsSync(lockPath)) {
|
|
200
|
-
|
|
171
|
+
writeStateFile(lockPath, '', { mode: 0o600 }, 'create the credential envelope lock');
|
|
201
172
|
}
|
|
202
173
|
const acquireStart = Date.now();
|
|
203
174
|
const release = await lockfile.lock(lockPath, {
|
|
@@ -338,6 +309,7 @@ async function consolidateWrappedDek(profile, kek, aad) {
|
|
|
338
309
|
}));
|
|
339
310
|
}
|
|
340
311
|
catch (err) {
|
|
312
|
+
rethrowIfLiveStateRefusal(err);
|
|
341
313
|
emit('cred_envelope.path_consolidation.failed', false, {
|
|
342
314
|
profile,
|
|
343
315
|
message: err?.message ?? 'unknown',
|
|
@@ -407,6 +379,12 @@ async function loadRetiredDeks(profile, kek, aad, active) {
|
|
|
407
379
|
}
|
|
408
380
|
}
|
|
409
381
|
catch {
|
|
382
|
+
// A copy kept because it did not open (`keepUnreadableWrappedDek`) is expected not to open
|
|
383
|
+
// until its KEK comes back; warning on every process start for each of up to eight files
|
|
384
|
+
// would print on every `skrr` command for as long as it lasts. A retired DEK a
|
|
385
|
+
// consolidation set aside is different: it opened once, so it failing is news.
|
|
386
|
+
if (path.basename(target).startsWith(UNREADABLE_DEK_COPY_PREFIX))
|
|
387
|
+
continue;
|
|
410
388
|
emit('cred_envelope.retired_dek.unreadable', false, {
|
|
411
389
|
profile,
|
|
412
390
|
file: path.basename(target),
|
|
@@ -414,6 +392,80 @@ async function loadRetiredDeks(profile, kek, aad, active) {
|
|
|
414
392
|
}
|
|
415
393
|
}
|
|
416
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Prefix of the copy kept when a wrapped DEK could not be opened and is about to be
|
|
397
|
+
* replaced. It sits in the retired keyring (`<file>.retired.*`), so a key that opens
|
|
398
|
+
* again later (a restored Keychain item, a transient fault misread as a rotation) brings
|
|
399
|
+
* back everything sealed under it, and a logout deletes it with the rest.
|
|
400
|
+
*/
|
|
401
|
+
const UNREADABLE_DEK_COPY_PREFIX = `${ACCOUNT_CRED_DEK_WRAPPED}${RETIRED_COPY_SUFFIX}.unreadable-`;
|
|
402
|
+
/**
|
|
403
|
+
* A copy is 92 bytes and only the OLDEST can be the one that matters: every replacement after
|
|
404
|
+
* the first is usually a copy of a DEK this process itself just enrolled. The first cut kept
|
|
405
|
+
* two, and three rotations in thirteen seconds (a test pointed at a real home) pushed the
|
|
406
|
+
* original DEK out, which is exactly the copy that would have restored the profile.
|
|
407
|
+
*/
|
|
408
|
+
const MAX_UNREADABLE_DEK_COPIES = 8;
|
|
409
|
+
/**
|
|
410
|
+
* Whether a failed unwrap means the KEK could not be READ right now, as opposed to the KEK
|
|
411
|
+
* being a different key. A locked Keychain, a non-interactive context or a failing helper
|
|
412
|
+
* say nothing about the key; only an authentication-tag failure under a KEK that was read
|
|
413
|
+
* does. Matched by name as well as by class, since two copies of this package in one
|
|
414
|
+
* process (a farmed worktree) have two classes.
|
|
415
|
+
*/
|
|
416
|
+
function isKekUnavailableError(err) {
|
|
417
|
+
return (err instanceof KekUnavailableError ||
|
|
418
|
+
err?.name === 'KekUnavailableError');
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Keep the wrapped DEK this process could not open before it is replaced. Replacing it
|
|
422
|
+
* orphans everything sealed under the old DEK (the device private key, stored tokens), and
|
|
423
|
+
* the one thing that can still undo that is the old wrapped DEK meeting its KEK again.
|
|
424
|
+
* Best effort and bounded: a copy that cannot be written must not stop the daemon coming
|
|
425
|
+
* up, and the oldest copies are pruned.
|
|
426
|
+
*/
|
|
427
|
+
function keepUnreadableWrappedDek(profile, wrapped) {
|
|
428
|
+
try {
|
|
429
|
+
const dir = profileStateDir(profile);
|
|
430
|
+
const existing = fs.existsSync(dir)
|
|
431
|
+
? fs
|
|
432
|
+
.readdirSync(dir)
|
|
433
|
+
.filter((entry) => entry.startsWith(UNREADABLE_DEK_COPY_PREFIX))
|
|
434
|
+
.sort()
|
|
435
|
+
: [];
|
|
436
|
+
// The same bytes are already kept (a KEK that flaps between two values re-seals nothing
|
|
437
|
+
// new): do not let a duplicate take a slot from a different DEK.
|
|
438
|
+
for (const entry of existing) {
|
|
439
|
+
try {
|
|
440
|
+
if (fs.readFileSync(path.join(dir, entry)).equals(wrapped))
|
|
441
|
+
return entry;
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
/* an unreadable copy is not a match */
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const name = `${UNREADABLE_DEK_COPY_PREFIX}${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
448
|
+
atomicWrite0600(path.join(dir, name), wrapped, 'keep the unreadable wrapped DEK');
|
|
449
|
+
const kept = [...existing, name].sort();
|
|
450
|
+
for (const stale of kept.slice(0, Math.max(0, kept.length - MAX_UNREADABLE_DEK_COPIES))) {
|
|
451
|
+
try {
|
|
452
|
+
removeStateFile(path.join(dir, stale), 'prune a kept wrapped DEK');
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
/* a copy that will not go costs 92 bytes */
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return name;
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
rethrowIfLiveStateRefusal(err);
|
|
462
|
+
emit('cred_envelope.unreadable_dek_copy_failed', false, {
|
|
463
|
+
profile,
|
|
464
|
+
message: err?.message ?? 'unknown',
|
|
465
|
+
});
|
|
466
|
+
return null;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
417
469
|
/** Retired wrapped DEKs for `profile`, oldest first. */
|
|
418
470
|
function retiredDekFiles(profile) {
|
|
419
471
|
const dir = profileStateDir(profile);
|
|
@@ -547,6 +599,9 @@ export async function initNewIndependentCredEnvelope(profile) {
|
|
|
547
599
|
const dek = generateDek();
|
|
548
600
|
let temp;
|
|
549
601
|
try {
|
|
602
|
+
// Everything below creates, links and deletes inside `dir`, with raw flags
|
|
603
|
+
// (O_EXCL, link) the shared writers do not offer, so the refusal is asked once here.
|
|
604
|
+
assertMayMutateLiveState(dir, `enroll the wrapped DEK for background profile ${profile}`);
|
|
550
605
|
const wrapped = await kek.wrap(dek, buildCredentialAad(profile));
|
|
551
606
|
// Another enrollment may have won while the platform store was busy.
|
|
552
607
|
// Its file is opened read-only; this process never overwrites it.
|
|
@@ -699,6 +754,8 @@ export async function initCredEnvelope(profile) {
|
|
|
699
754
|
}
|
|
700
755
|
let dek = null;
|
|
701
756
|
let unwrapAttemptedAndFailed = false;
|
|
757
|
+
let kekUnreadable = null;
|
|
758
|
+
let unreadableWrapped = null;
|
|
702
759
|
await withCredEnvelopeLock(profile, async () => {
|
|
703
760
|
// Re-stat after acquiring the lock — a sibling sky/oversky may have
|
|
704
761
|
// enrolled while we were waiting. Without this, both processes would
|
|
@@ -728,6 +785,7 @@ export async function initCredEnvelope(profile) {
|
|
|
728
785
|
kekKind: captureKind(),
|
|
729
786
|
});
|
|
730
787
|
unwrapAttemptedAndFailed = true;
|
|
788
|
+
unreadableWrapped = wrappedBytes;
|
|
731
789
|
}
|
|
732
790
|
else {
|
|
733
791
|
dek = candidate;
|
|
@@ -740,21 +798,45 @@ export async function initCredEnvelope(profile) {
|
|
|
740
798
|
kekKind: captureKind(),
|
|
741
799
|
message: err?.message ?? 'unknown',
|
|
742
800
|
});
|
|
743
|
-
|
|
801
|
+
if (isKekUnavailableError(err)) {
|
|
802
|
+
// The KEK could not be read; that is not evidence it changed. Enrolling here would
|
|
803
|
+
// replace a DEK that is still good with one the next start cannot tell apart from
|
|
804
|
+
// a rotation, and orphan everything sealed under it.
|
|
805
|
+
kekUnreadable = err;
|
|
806
|
+
}
|
|
807
|
+
else {
|
|
808
|
+
unwrapAttemptedAndFailed = true;
|
|
809
|
+
unreadableWrapped = wrappedBytes;
|
|
810
|
+
}
|
|
744
811
|
}
|
|
745
812
|
}
|
|
813
|
+
if (kekUnreadable !== null) {
|
|
814
|
+
_state = { kind: 'disabled', reason: 'kek_unavailable' };
|
|
815
|
+
emit('cred_envelope.kek_unavailable', false, {
|
|
816
|
+
kekId: kek.id,
|
|
817
|
+
profile,
|
|
818
|
+
phase: 'unwrap',
|
|
819
|
+
describe: kek.describe(),
|
|
820
|
+
message: kekUnreadable?.message ?? 'unknown',
|
|
821
|
+
});
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
746
824
|
// KEK rotation detection (gap G4). If the wrapped file was present,
|
|
747
825
|
// KEK reported `available=true`, but unwrap threw — the most likely
|
|
748
826
|
// explanation is the KEK was rotated underneath us. Surface this as
|
|
749
827
|
// a distinct event so dashboards can count rotations separately
|
|
750
828
|
// from genuinely-corrupt blobs, while still falling through to a
|
|
751
|
-
// fresh enrollment so the daemon stays operational.
|
|
829
|
+
// fresh enrollment so the daemon stays operational. The old wrapped DEK is
|
|
830
|
+
// kept first: enrolling replaces it, and it is the only way back to what
|
|
831
|
+
// was sealed under it.
|
|
752
832
|
if (unwrapAttemptedAndFailed && dek === null) {
|
|
753
833
|
_kekRotationDetected = true;
|
|
834
|
+
const keptAs = unreadableWrapped ? keepUnreadableWrappedDek(profile, unreadableWrapped) : null;
|
|
754
835
|
emit('cred_envelope.kek_rotated', false, {
|
|
755
836
|
kekId: kek.id,
|
|
756
837
|
kekKind: captureKind(),
|
|
757
838
|
profile,
|
|
839
|
+
...(keptAs ? { previousWrappedDekKeptAs: keptAs } : {}),
|
|
758
840
|
message: 'wrapped DEK present but unwrap failed under available KEK; treating as rotation and re-enrolling',
|
|
759
841
|
});
|
|
760
842
|
}
|
|
@@ -763,7 +845,7 @@ export async function initCredEnvelope(profile) {
|
|
|
763
845
|
try {
|
|
764
846
|
dek = generateDek();
|
|
765
847
|
const wrapped = await kek.wrap(dek, aad);
|
|
766
|
-
atomicWrite0600(targetPath, wrapped);
|
|
848
|
+
atomicWrite0600(targetPath, wrapped, `write the wrapped DEK for profile ${profile}`);
|
|
767
849
|
emit('cred_envelope.dek_enrolled', true, {
|
|
768
850
|
kekId: kek.id,
|
|
769
851
|
kekKind: captureKind(),
|
|
@@ -772,6 +854,7 @@ export async function initCredEnvelope(profile) {
|
|
|
772
854
|
});
|
|
773
855
|
}
|
|
774
856
|
catch (err) {
|
|
857
|
+
rethrowIfLiveStateRefusal(err);
|
|
775
858
|
_state = { kind: 'disabled', reason: 'init_failed' };
|
|
776
859
|
emit('cred_envelope.init_failed', false, {
|
|
777
860
|
reason: 'enrollment_failed',
|
|
@@ -820,9 +903,10 @@ export async function resetCredEnvelope(opts) {
|
|
|
820
903
|
...retiredDekFiles(opts.profile),
|
|
821
904
|
]) {
|
|
822
905
|
try {
|
|
823
|
-
|
|
906
|
+
removeStateFile(target, `delete the wrapped DEK file ${path.basename(target)}`);
|
|
824
907
|
}
|
|
825
908
|
catch (err) {
|
|
909
|
+
rethrowIfLiveStateRefusal(err);
|
|
826
910
|
if (err.code !== 'ENOENT') {
|
|
827
911
|
emit('cred_envelope.clear_failed', false, {
|
|
828
912
|
profile: opts.profile,
|
|
@@ -1,4 +1,34 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type DeviceKeyAlg, type DevicePublicJwk } from './deviceKey.js';
|
|
2
|
+
import { type DeviceKeyCustody, type DeviceKeyHandle } from './deviceKeyHandle.js';
|
|
3
|
+
import { type HardwareKeyCapability, type HardwareKeyRecord } from './deviceKeyHardware.js';
|
|
4
|
+
/**
|
|
5
|
+
* The `cred_envelope.read.decrypt_failed` hint for the device PRIVATE key
|
|
6
|
+
* (OSK-20506).
|
|
7
|
+
*
|
|
8
|
+
* The envelope's default hint says "signing in again rebuilds it", which is true
|
|
9
|
+
* of a credential and false of this: `skrr login` never creates or replaces a
|
|
10
|
+
* device key, and only an interactive daemon sign-in does (it re-pins the new key
|
|
11
|
+
* on the server). `loadKeypair` hands this to `maybeDecryptOnRead` so the
|
|
12
|
+
* producer says WHAT failed; a presenter that already prints one device-key line
|
|
13
|
+
* (the CLI's `recovery_required` notice) recognises it by identity and prints
|
|
14
|
+
* nothing for the decrypt failure, instead of two lines with two remedies. A
|
|
15
|
+
* logger that prints every warning, the daemon's, shows this one as written.
|
|
16
|
+
*
|
|
17
|
+
* Compared by value by the CLI presenter: change the text and the CLI prints the
|
|
18
|
+
* credential line again, which `auth-core-init.device-key.spec.ts` catches.
|
|
19
|
+
*/
|
|
20
|
+
export declare const DEVICE_KEY_UNREADABLE_CREDENTIAL_HINT = "stored device key is unreadable on this machine \u2014 only an interactive daemon sign-in replaces it";
|
|
21
|
+
/**
|
|
22
|
+
* `key` is a handle, never a keypair: nothing in this module (or any caller of
|
|
23
|
+
* it) can read a private scalar once a key is active, which is what lets a
|
|
24
|
+
* hardware key stand in for a software one without a second code path.
|
|
25
|
+
*
|
|
26
|
+
* `envelopeProtected` is about the PRIVATE KEY FILE being sealed under the
|
|
27
|
+
* credential envelope. A hardware record is deliberately not sealed (it is
|
|
28
|
+
* useless off this machine, and sealing it would tie the device identity's life
|
|
29
|
+
* to the envelope key again), so it is always `false` for hardware and says
|
|
30
|
+
* nothing about how well that key is protected: read `key.custody` for that.
|
|
31
|
+
*/
|
|
2
32
|
type DeviceIdentityState = {
|
|
3
33
|
kind: 'uninit';
|
|
4
34
|
} | {
|
|
@@ -6,11 +36,13 @@ type DeviceIdentityState = {
|
|
|
6
36
|
reason: DisabledReason;
|
|
7
37
|
} | {
|
|
8
38
|
kind: 'active';
|
|
9
|
-
|
|
39
|
+
key: DeviceKeyHandle;
|
|
10
40
|
profile: string;
|
|
11
41
|
envelopeProtected: boolean;
|
|
12
42
|
};
|
|
13
43
|
type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed' | 'unreadable_key' | 'no_stored_key';
|
|
44
|
+
/** @internal Test seam — how often an unreadable key is looked at again. `null` restores 5 s. */
|
|
45
|
+
export declare function __setUnreadableReloadIntervalForTest(ms: number | null): void;
|
|
14
46
|
/** @internal Test seam — force-set state. */
|
|
15
47
|
export declare function __setStateForTest(next: DeviceIdentityState): DeviceIdentityState;
|
|
16
48
|
/** @internal Test seam — read state. */
|
|
@@ -67,6 +99,69 @@ export declare function resetDeviceIdentity(opts?: {
|
|
|
67
99
|
profile?: string;
|
|
68
100
|
clearOnDisk?: boolean;
|
|
69
101
|
}): Promise<void>;
|
|
102
|
+
/**
|
|
103
|
+
* A hardware key that exists only in memory. It is not this machine's identity
|
|
104
|
+
* until `commitDeviceKeyCandidate`, and it leaves nothing behind if it is
|
|
105
|
+
* dropped: a Secure Enclave key lives in the blob and nowhere else.
|
|
106
|
+
*
|
|
107
|
+
* That is the whole safety argument for an upgrade. The server learns the new key
|
|
108
|
+
* from a proof signed with it (an interactive sign-in re-pins the row), so the
|
|
109
|
+
* order has to be: create the candidate, sign the sign-in with it, and only after
|
|
110
|
+
* the server has accepted that sign-in make it the identity. The software key
|
|
111
|
+
* stays on disk, untouched and active, through every step before the last one.
|
|
112
|
+
*/
|
|
113
|
+
export interface DeviceKeyCandidate {
|
|
114
|
+
readonly key: DeviceKeyHandle;
|
|
115
|
+
/** @internal what `commitDeviceKeyCandidate` persists. */
|
|
116
|
+
readonly record: HardwareKeyRecord;
|
|
117
|
+
}
|
|
118
|
+
export type DeviceKeyCandidateResult = {
|
|
119
|
+
ok: true;
|
|
120
|
+
candidate: DeviceKeyCandidate;
|
|
121
|
+
} | {
|
|
122
|
+
ok: false;
|
|
123
|
+
reason: 'already_hardware' | 'independent_profile' | 'unavailable' | 'create_failed';
|
|
124
|
+
capability?: HardwareKeyCapability;
|
|
125
|
+
message?: string;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* Create a hardware key candidate if this machine can hold one and does not
|
|
129
|
+
* already. Never throws and never touches disk, so an interactive sign-in can
|
|
130
|
+
* call it unconditionally and fall back to the key it has on any answer but ok.
|
|
131
|
+
*/
|
|
132
|
+
export declare function prepareHardwareKeyCandidate(profile: string): Promise<DeviceKeyCandidateResult>;
|
|
133
|
+
/** Sign a request with a candidate (the upgrade's sign-in proof). Null on failure, like `signServerRequest`. */
|
|
134
|
+
export declare function signWithCandidate(candidate: DeviceKeyCandidate, opts: {
|
|
135
|
+
method: string;
|
|
136
|
+
url: string;
|
|
137
|
+
refreshToken?: string;
|
|
138
|
+
accessToken?: string;
|
|
139
|
+
}): Promise<string | null>;
|
|
140
|
+
/**
|
|
141
|
+
* Make a candidate this profile's device identity. Call it only after the
|
|
142
|
+
* server has accepted a sign-in proven with `candidate`: until then the software
|
|
143
|
+
* key is the identity the server pins, and replacing it early is the failure
|
|
144
|
+
* this API exists to prevent.
|
|
145
|
+
*
|
|
146
|
+
* Under the identity lock, in the order that survives a crash: retire the old
|
|
147
|
+
* key, write the hardware record (authoritative), then the public file, then
|
|
148
|
+
* clear the enrollment marker (it records the OLD thumbprint and would otherwise
|
|
149
|
+
* short-circuit every later enrollment as already done).
|
|
150
|
+
*/
|
|
151
|
+
export declare function commitDeviceKeyCandidate(profile: string, candidate: DeviceKeyCandidate): Promise<void>;
|
|
152
|
+
/** A sign-in that takes longer than this has been abandoned; its pending key is not committed. */
|
|
153
|
+
export declare const PENDING_HARDWARE_KEY_MAX_AGE_MS: number;
|
|
154
|
+
/** Park a candidate so a later process can commit or discard it. Replaces any earlier one. */
|
|
155
|
+
export declare function persistPendingHardwareKey(profile: string, candidate: DeviceKeyCandidate): Promise<void>;
|
|
156
|
+
export type PendingHardwareKeyOutcome = 'committed' | 'no_pending' | 'mismatch' | 'expired' | 'unreadable';
|
|
157
|
+
/**
|
|
158
|
+
* Make the parked candidate this profile's identity, if it is the one the caller
|
|
159
|
+
* proved the sign-in with (`thumbprint`) and it is not stale. Anything else
|
|
160
|
+
* commits nothing: the software key stays the identity, and the answer says why.
|
|
161
|
+
*/
|
|
162
|
+
export declare function commitPendingHardwareKey(profile: string, thumbprint: string): Promise<PendingHardwareKeyOutcome>;
|
|
163
|
+
/** Drop the parked candidate (the sign-in failed or was refused). Idempotent. */
|
|
164
|
+
export declare function discardPendingHardwareKey(profile: string): Promise<void>;
|
|
70
165
|
/** True iff init has completed and a keypair is in memory. */
|
|
71
166
|
export declare function isDeviceIdentityActive(): boolean;
|
|
72
167
|
/**
|
|
@@ -75,8 +170,10 @@ export declare function isDeviceIdentityActive(): boolean;
|
|
|
75
170
|
* on the in-flight request.
|
|
76
171
|
*/
|
|
77
172
|
export declare function getDevicePublicKey(): {
|
|
78
|
-
jwk:
|
|
173
|
+
jwk: DevicePublicJwk;
|
|
79
174
|
thumbprint: string;
|
|
175
|
+
alg: DeviceKeyAlg;
|
|
176
|
+
custody: DeviceKeyCustody;
|
|
80
177
|
} | null;
|
|
81
178
|
/**
|
|
82
179
|
* Operator-readable summary of device-identity state. Used by
|
|
@@ -88,13 +185,47 @@ export declare function describeDeviceIdentityState(): {
|
|
|
88
185
|
thumbprint?: string;
|
|
89
186
|
profile?: string;
|
|
90
187
|
envelopeProtected?: boolean;
|
|
188
|
+
alg?: DeviceKeyAlg;
|
|
189
|
+
/** Whether the private half can be copied off this machine. Absent unless active. */
|
|
190
|
+
custody?: DeviceKeyCustody;
|
|
191
|
+
/**
|
|
192
|
+
* For a hardware key: can THIS process sign with it right now? False means the
|
|
193
|
+
* key exists and is intact but the helper that signs with it is not installed
|
|
194
|
+
* where this process can find it (a CLI without the desktop app's helper, an
|
|
195
|
+
* app update that has not landed). It is a different fact from an unreadable
|
|
196
|
+
* key, and it must never lead to replacing the key.
|
|
197
|
+
*/
|
|
198
|
+
canSign?: boolean;
|
|
199
|
+
};
|
|
200
|
+
export type StoredDeviceKeyDescription = {
|
|
201
|
+
stored: false;
|
|
202
|
+
} | {
|
|
203
|
+
stored: true;
|
|
204
|
+
alg: DeviceKeyAlg;
|
|
205
|
+
custody: DeviceKeyCustody;
|
|
206
|
+
thumbprint: string;
|
|
207
|
+
/** A hardware key needs the helper to sign; a software key never does. */
|
|
208
|
+
canSign: boolean;
|
|
91
209
|
};
|
|
210
|
+
/**
|
|
211
|
+
* What a profile's device key IS, read from its plaintext public record alone. For
|
|
212
|
+
* `status`, which is a different process from the daemon and may run while the
|
|
213
|
+
* daemon is stopped: it opens nothing, needs no envelope key and writes nothing, so
|
|
214
|
+
* it answers even when the private half is unreadable. Not evidence the key can be
|
|
215
|
+
* used (only `describeDeviceIdentityState` in the process that loaded it says that);
|
|
216
|
+
* evidence of what kind of key the profile has, which nothing else showed.
|
|
217
|
+
*/
|
|
218
|
+
export declare function describeStoredDeviceKey(profile: string): StoredDeviceKeyDescription;
|
|
92
219
|
/**
|
|
93
220
|
* Sign an outgoing request with the device key. Returns the JWS Compact
|
|
94
221
|
* Serialization, or null when device identity is not active. NEVER
|
|
95
222
|
* throws — a failed sign returns null and emits a telemetry event so
|
|
96
223
|
* the caller can fall back to bearer-only auth without an outage.
|
|
97
224
|
*
|
|
225
|
+
* Asynchronous because a hardware key signs in a helper process. A software key
|
|
226
|
+
* resolves on the next microtask, so a caller that awaits pays nothing it can
|
|
227
|
+
* measure; a caller that could not await could not have a hardware key.
|
|
228
|
+
*
|
|
98
229
|
* Shape matches `DeviceProofSigner` from `./refresh.ts` so the function
|
|
99
230
|
* passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
|
|
100
231
|
*/
|
|
@@ -103,7 +234,7 @@ export declare function signServerRequest(opts: {
|
|
|
103
234
|
url: string;
|
|
104
235
|
refreshToken?: string;
|
|
105
236
|
accessToken?: string;
|
|
106
|
-
}): string | null
|
|
237
|
+
}): Promise<string | null>;
|
|
107
238
|
/** @internal Test seams — file path resolvers. */
|
|
108
239
|
export declare function __publicKeyPathForTest(profile: string): string;
|
|
109
240
|
export declare function __privateKeyPathForTest(profile: string): string;
|