@skrr-ai/cli 0.1.121 → 0.1.123
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/actions/publish.js +4 -0
- package/dist/commands/agent/message/send.js +6 -1
- package/dist/commands/agents/chat.d.ts +7 -0
- package/dist/commands/agents/chat.js +32 -6
- 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/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/generated/agent-guides.json +11 -11
- package/dist/lib/agentic-stream.d.ts +46 -0
- package/dist/lib/agentic-stream.js +135 -14
- package/dist/lib/auth-failure.js +12 -0
- package/dist/lib/command-miss.js +1 -0
- 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/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/credentialEnvelopeBridge.js +58 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +2 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +60 -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 +8 -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 +7 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +3 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +3 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +29 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +37 -0
- 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/refresh.js +12 -6
- 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/credentialEnvelopeBridge.js +109 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +2 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +78 -0
- 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/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.js +3 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +3 -2
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +29 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +38 -0
- 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/refresh.js +31 -6
- 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 +10 -0
- package/dist/node_modules/@skrr-ai/data-provider/index.js +7359 -7344
- package/oclif.manifest.json +478 -3
- package/package.json +1 -1
|
@@ -45,7 +45,7 @@ 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
50
|
import { needsReauthState } from './refresh.js';
|
|
51
51
|
import { emitAuthTelemetry, getAuthLogger, getCredEnvelopeFailClosed } from './runtime.js';
|
|
@@ -407,6 +407,12 @@ async function loadRetiredDeks(profile, kek, aad, active) {
|
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
409
|
catch {
|
|
410
|
+
// A copy kept because it did not open (`keepUnreadableWrappedDek`) is expected not to open
|
|
411
|
+
// until its KEK comes back; warning on every process start for each of up to eight files
|
|
412
|
+
// would print on every `skrr` command for as long as it lasts. A retired DEK a
|
|
413
|
+
// consolidation set aside is different: it opened once, so it failing is news.
|
|
414
|
+
if (path.basename(target).startsWith(UNREADABLE_DEK_COPY_PREFIX))
|
|
415
|
+
continue;
|
|
410
416
|
emit('cred_envelope.retired_dek.unreadable', false, {
|
|
411
417
|
profile,
|
|
412
418
|
file: path.basename(target),
|
|
@@ -414,6 +420,79 @@ async function loadRetiredDeks(profile, kek, aad, active) {
|
|
|
414
420
|
}
|
|
415
421
|
}
|
|
416
422
|
}
|
|
423
|
+
/**
|
|
424
|
+
* Prefix of the copy kept when a wrapped DEK could not be opened and is about to be
|
|
425
|
+
* replaced. It sits in the retired keyring (`<file>.retired.*`), so a key that opens
|
|
426
|
+
* again later (a restored Keychain item, a transient fault misread as a rotation) brings
|
|
427
|
+
* back everything sealed under it, and a logout deletes it with the rest.
|
|
428
|
+
*/
|
|
429
|
+
const UNREADABLE_DEK_COPY_PREFIX = `${ACCOUNT_CRED_DEK_WRAPPED}${RETIRED_COPY_SUFFIX}.unreadable-`;
|
|
430
|
+
/**
|
|
431
|
+
* A copy is 92 bytes and only the OLDEST can be the one that matters: every replacement after
|
|
432
|
+
* the first is usually a copy of a DEK this process itself just enrolled. The first cut kept
|
|
433
|
+
* two, and three rotations in thirteen seconds (a test pointed at a real home) pushed the
|
|
434
|
+
* original DEK out, which is exactly the copy that would have restored the profile.
|
|
435
|
+
*/
|
|
436
|
+
const MAX_UNREADABLE_DEK_COPIES = 8;
|
|
437
|
+
/**
|
|
438
|
+
* Whether a failed unwrap means the KEK could not be READ right now, as opposed to the KEK
|
|
439
|
+
* being a different key. A locked Keychain, a non-interactive context or a failing helper
|
|
440
|
+
* say nothing about the key; only an authentication-tag failure under a KEK that was read
|
|
441
|
+
* does. Matched by name as well as by class, since two copies of this package in one
|
|
442
|
+
* process (a farmed worktree) have two classes.
|
|
443
|
+
*/
|
|
444
|
+
function isKekUnavailableError(err) {
|
|
445
|
+
return (err instanceof KekUnavailableError ||
|
|
446
|
+
err?.name === 'KekUnavailableError');
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Keep the wrapped DEK this process could not open before it is replaced. Replacing it
|
|
450
|
+
* orphans everything sealed under the old DEK (the device private key, stored tokens), and
|
|
451
|
+
* the one thing that can still undo that is the old wrapped DEK meeting its KEK again.
|
|
452
|
+
* Best effort and bounded: a copy that cannot be written must not stop the daemon coming
|
|
453
|
+
* up, and the oldest copies are pruned.
|
|
454
|
+
*/
|
|
455
|
+
function keepUnreadableWrappedDek(profile, wrapped) {
|
|
456
|
+
try {
|
|
457
|
+
const dir = profileStateDir(profile);
|
|
458
|
+
const existing = fs.existsSync(dir)
|
|
459
|
+
? fs
|
|
460
|
+
.readdirSync(dir)
|
|
461
|
+
.filter((entry) => entry.startsWith(UNREADABLE_DEK_COPY_PREFIX))
|
|
462
|
+
.sort()
|
|
463
|
+
: [];
|
|
464
|
+
// The same bytes are already kept (a KEK that flaps between two values re-seals nothing
|
|
465
|
+
// new): do not let a duplicate take a slot from a different DEK.
|
|
466
|
+
for (const entry of existing) {
|
|
467
|
+
try {
|
|
468
|
+
if (fs.readFileSync(path.join(dir, entry)).equals(wrapped))
|
|
469
|
+
return entry;
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
/* an unreadable copy is not a match */
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const name = `${UNREADABLE_DEK_COPY_PREFIX}${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
476
|
+
atomicWrite0600(path.join(dir, name), wrapped);
|
|
477
|
+
const kept = [...existing, name].sort();
|
|
478
|
+
for (const stale of kept.slice(0, Math.max(0, kept.length - MAX_UNREADABLE_DEK_COPIES))) {
|
|
479
|
+
try {
|
|
480
|
+
fs.unlinkSync(path.join(dir, stale));
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
/* a copy that will not go costs 92 bytes */
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return name;
|
|
487
|
+
}
|
|
488
|
+
catch (err) {
|
|
489
|
+
emit('cred_envelope.unreadable_dek_copy_failed', false, {
|
|
490
|
+
profile,
|
|
491
|
+
message: err?.message ?? 'unknown',
|
|
492
|
+
});
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
417
496
|
/** Retired wrapped DEKs for `profile`, oldest first. */
|
|
418
497
|
function retiredDekFiles(profile) {
|
|
419
498
|
const dir = profileStateDir(profile);
|
|
@@ -699,6 +778,8 @@ export async function initCredEnvelope(profile) {
|
|
|
699
778
|
}
|
|
700
779
|
let dek = null;
|
|
701
780
|
let unwrapAttemptedAndFailed = false;
|
|
781
|
+
let kekUnreadable = null;
|
|
782
|
+
let unreadableWrapped = null;
|
|
702
783
|
await withCredEnvelopeLock(profile, async () => {
|
|
703
784
|
// Re-stat after acquiring the lock — a sibling sky/oversky may have
|
|
704
785
|
// enrolled while we were waiting. Without this, both processes would
|
|
@@ -728,6 +809,7 @@ export async function initCredEnvelope(profile) {
|
|
|
728
809
|
kekKind: captureKind(),
|
|
729
810
|
});
|
|
730
811
|
unwrapAttemptedAndFailed = true;
|
|
812
|
+
unreadableWrapped = wrappedBytes;
|
|
731
813
|
}
|
|
732
814
|
else {
|
|
733
815
|
dek = candidate;
|
|
@@ -740,21 +822,45 @@ export async function initCredEnvelope(profile) {
|
|
|
740
822
|
kekKind: captureKind(),
|
|
741
823
|
message: err?.message ?? 'unknown',
|
|
742
824
|
});
|
|
743
|
-
|
|
825
|
+
if (isKekUnavailableError(err)) {
|
|
826
|
+
// The KEK could not be read; that is not evidence it changed. Enrolling here would
|
|
827
|
+
// replace a DEK that is still good with one the next start cannot tell apart from
|
|
828
|
+
// a rotation, and orphan everything sealed under it.
|
|
829
|
+
kekUnreadable = err;
|
|
830
|
+
}
|
|
831
|
+
else {
|
|
832
|
+
unwrapAttemptedAndFailed = true;
|
|
833
|
+
unreadableWrapped = wrappedBytes;
|
|
834
|
+
}
|
|
744
835
|
}
|
|
745
836
|
}
|
|
837
|
+
if (kekUnreadable !== null) {
|
|
838
|
+
_state = { kind: 'disabled', reason: 'kek_unavailable' };
|
|
839
|
+
emit('cred_envelope.kek_unavailable', false, {
|
|
840
|
+
kekId: kek.id,
|
|
841
|
+
profile,
|
|
842
|
+
phase: 'unwrap',
|
|
843
|
+
describe: kek.describe(),
|
|
844
|
+
message: kekUnreadable?.message ?? 'unknown',
|
|
845
|
+
});
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
746
848
|
// KEK rotation detection (gap G4). If the wrapped file was present,
|
|
747
849
|
// KEK reported `available=true`, but unwrap threw — the most likely
|
|
748
850
|
// explanation is the KEK was rotated underneath us. Surface this as
|
|
749
851
|
// a distinct event so dashboards can count rotations separately
|
|
750
852
|
// from genuinely-corrupt blobs, while still falling through to a
|
|
751
|
-
// fresh enrollment so the daemon stays operational.
|
|
853
|
+
// fresh enrollment so the daemon stays operational. The old wrapped DEK is
|
|
854
|
+
// kept first: enrolling replaces it, and it is the only way back to what
|
|
855
|
+
// was sealed under it.
|
|
752
856
|
if (unwrapAttemptedAndFailed && dek === null) {
|
|
753
857
|
_kekRotationDetected = true;
|
|
858
|
+
const keptAs = unreadableWrapped ? keepUnreadableWrappedDek(profile, unreadableWrapped) : null;
|
|
754
859
|
emit('cred_envelope.kek_rotated', false, {
|
|
755
860
|
kekId: kek.id,
|
|
756
861
|
kekKind: captureKind(),
|
|
757
862
|
profile,
|
|
863
|
+
...(keptAs ? { previousWrappedDekKeptAs: keptAs } : {}),
|
|
758
864
|
message: 'wrapped DEK present but unwrap failed under available KEK; treating as rotation and re-enrolling',
|
|
759
865
|
});
|
|
760
866
|
}
|
|
@@ -11,6 +11,8 @@ type DeviceIdentityState = {
|
|
|
11
11
|
envelopeProtected: boolean;
|
|
12
12
|
};
|
|
13
13
|
type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed' | 'unreadable_key' | 'no_stored_key';
|
|
14
|
+
/** @internal Test seam — how often an unreadable key is looked at again. `null` restores 5 s. */
|
|
15
|
+
export declare function __setUnreadableReloadIntervalForTest(ms: number | null): void;
|
|
14
16
|
/** @internal Test seam — force-set state. */
|
|
15
17
|
export declare function __setStateForTest(next: DeviceIdentityState): DeviceIdentityState;
|
|
16
18
|
/** @internal Test seam — read state. */
|
|
@@ -58,6 +58,76 @@ const DEVICE_IDENTITY_FILES = [
|
|
|
58
58
|
const DAEMON_NOT_FOUND_BACKOFF_MS = 15 * 60 * 1000;
|
|
59
59
|
let _state = { kind: 'uninit' };
|
|
60
60
|
let _initializing = null;
|
|
61
|
+
/**
|
|
62
|
+
* What this process saw when it gave up on a stored key it could not read: the profile, and
|
|
63
|
+
* the mtimes of the two key files. A process that is already running has no way to learn that
|
|
64
|
+
* someone else has since written a readable key (the desktop's recovery, an interactive
|
|
65
|
+
* login in another process), and used to sit disabled until it was restarted, signing
|
|
66
|
+
* nothing. `reloadIfRecovered` reads the key again, but only when one of those files has
|
|
67
|
+
* changed, so a key that stays unreadable costs one `stat` and no log line.
|
|
68
|
+
*/
|
|
69
|
+
let _unreadableSeen = null;
|
|
70
|
+
let _unreadableReloadIntervalMs = 5_000;
|
|
71
|
+
/** @internal Test seam — how often an unreadable key is looked at again. `null` restores 5 s. */
|
|
72
|
+
export function __setUnreadableReloadIntervalForTest(ms) {
|
|
73
|
+
_unreadableReloadIntervalMs = ms ?? 5_000;
|
|
74
|
+
}
|
|
75
|
+
function keyFilesStamp(profile) {
|
|
76
|
+
const part = (file) => {
|
|
77
|
+
try {
|
|
78
|
+
const s = fs.statSync(file);
|
|
79
|
+
return `${s.mtimeMs}:${s.size}`;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return 'absent';
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
return `${part(publicKeyPath(profile))}|${part(privateKeyPath(profile))}`;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* If a key this process could not read has been replaced on disk since, load it. Never
|
|
89
|
+
* creates, replaces or deletes anything: it only picks up what another process wrote
|
|
90
|
+
* under the same lock, which is not the silent replacement the `recoverUnreadable` gate
|
|
91
|
+
* exists to prevent.
|
|
92
|
+
*/
|
|
93
|
+
function reloadIfRecovered() {
|
|
94
|
+
if (_state.kind !== 'disabled' || _state.reason !== 'unreadable_key')
|
|
95
|
+
return;
|
|
96
|
+
const seen = _unreadableSeen;
|
|
97
|
+
if (!seen || _initializing)
|
|
98
|
+
return;
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
if (now - seen.checkedAt < _unreadableReloadIntervalMs)
|
|
101
|
+
return;
|
|
102
|
+
seen.checkedAt = now;
|
|
103
|
+
let stamp;
|
|
104
|
+
try {
|
|
105
|
+
stamp = keyFilesStamp(seen.profile);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (stamp === seen.stamp)
|
|
111
|
+
return;
|
|
112
|
+
seen.stamp = stamp;
|
|
113
|
+
let keypair = null;
|
|
114
|
+
try {
|
|
115
|
+
keypair = loadKeypair(seen.profile);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
keypair = null;
|
|
119
|
+
}
|
|
120
|
+
if (!keypair)
|
|
121
|
+
return;
|
|
122
|
+
_state = {
|
|
123
|
+
kind: 'active',
|
|
124
|
+
keypair,
|
|
125
|
+
profile: seen.profile,
|
|
126
|
+
envelopeProtected: isCredEnvelopeActive(),
|
|
127
|
+
};
|
|
128
|
+
_unreadableSeen = null;
|
|
129
|
+
emit('reloaded_after_recovery', true, { profile: seen.profile, thumbprint: keypair.thumbprint });
|
|
130
|
+
}
|
|
61
131
|
/**
|
|
62
132
|
* The CLI, daemon, and desktop's short-lived signer resolve the same canonical
|
|
63
133
|
* profile directory even when an older private key still lives in the nested
|
|
@@ -500,6 +570,9 @@ async function initDeviceIdentityLocked(profile, options = {}) {
|
|
|
500
570
|
const hasStoredKey = storedKeyMaterialExists(profile);
|
|
501
571
|
if (hasStoredKey && (!options.recoverUnreadable || independent)) {
|
|
502
572
|
_state = { kind: 'disabled', reason: 'unreadable_key' };
|
|
573
|
+
_unreadableSeen = independent
|
|
574
|
+
? null
|
|
575
|
+
: { profile, stamp: keyFilesStamp(profile), checkedAt: Date.now() };
|
|
503
576
|
emit('recovery_required', false, { profile, reason: 'stored_key_unreadable' });
|
|
504
577
|
return;
|
|
505
578
|
}
|
|
@@ -577,9 +650,11 @@ export async function resetDeviceIdentity(opts) {
|
|
|
577
650
|
});
|
|
578
651
|
}
|
|
579
652
|
_state = { kind: 'uninit' };
|
|
653
|
+
_unreadableSeen = null;
|
|
580
654
|
}
|
|
581
655
|
/** True iff init has completed and a keypair is in memory. */
|
|
582
656
|
export function isDeviceIdentityActive() {
|
|
657
|
+
reloadIfRecovered();
|
|
583
658
|
return _state.kind === 'active';
|
|
584
659
|
}
|
|
585
660
|
/**
|
|
@@ -588,6 +663,7 @@ export function isDeviceIdentityActive() {
|
|
|
588
663
|
* on the in-flight request.
|
|
589
664
|
*/
|
|
590
665
|
export function getDevicePublicKey() {
|
|
666
|
+
reloadIfRecovered();
|
|
591
667
|
if (_state.kind !== 'active')
|
|
592
668
|
return null;
|
|
593
669
|
return { jwk: _state.keypair.publicJwk, thumbprint: _state.keypair.thumbprint };
|
|
@@ -597,6 +673,7 @@ export function getDevicePublicKey() {
|
|
|
597
673
|
* `oversky status` and CLI debug commands. Never includes the private key.
|
|
598
674
|
*/
|
|
599
675
|
export function describeDeviceIdentityState() {
|
|
676
|
+
reloadIfRecovered();
|
|
600
677
|
if (_state.kind === 'disabled') {
|
|
601
678
|
return { kind: 'disabled', reason: _state.reason };
|
|
602
679
|
}
|
|
@@ -620,6 +697,7 @@ export function describeDeviceIdentityState() {
|
|
|
620
697
|
* passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
|
|
621
698
|
*/
|
|
622
699
|
export function signServerRequest(opts) {
|
|
700
|
+
reloadIfRecovered();
|
|
623
701
|
if (_state.kind !== 'active')
|
|
624
702
|
return null;
|
|
625
703
|
try {
|
|
@@ -25,11 +25,11 @@ export { loginWithLocalhost, LoginInitError, LoginExchangeError, type BrowserOpe
|
|
|
25
25
|
export { parseTtlDays, MIN_TTL_DAYS, MAX_TTL_DAYS, type ParseTtlResult, type ParseTtlSuccess, type ParseTtlError, } from './ttlParser.js';
|
|
26
26
|
export { formatRecoveryCode, parseRecoveryCode, normalizeRecoveryCode, isValidRecoveryCode, } from './recoveryCode.js';
|
|
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
|
-
export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, LinuxKek, MacosKek, registerZeroizeHook, WindowsKek, zeroizeKekCaches, type KekStrategy, } from './kek/index.js';
|
|
28
|
+
export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, isMasterKekKeychainService, LinuxKek, MacosKek, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, 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
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
|
-
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
|
+
export { initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit, __publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setUnreadableReloadIntervalForTest, __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';
|
|
35
35
|
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';
|
|
@@ -98,7 +98,7 @@ export { buildCredentialAad, decrypt as credEnvelopeDecrypt, deserialize as cred
|
|
|
98
98
|
// Tests that need the unavailable branch must inject `UnavailableKek`;
|
|
99
99
|
// `__setKekStrategyForTest(null)` restores the PLATFORM default, which is not
|
|
100
100
|
// the same thing and differs per OS.
|
|
101
|
-
export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, LinuxKek, MacosKek, registerZeroizeHook, WindowsKek, zeroizeKekCaches, } from './kek/index.js';
|
|
101
|
+
export { __setKekStrategyForTest, getKekStrategy, InMemoryKek, UnavailableKek, KekUnavailableError, isMasterKekKeychainService, LinuxKek, MacosKek, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, registerZeroizeHook, WindowsKek, zeroizeKekCaches, } from './kek/index.js';
|
|
102
102
|
// L11 Phase 1 — Device identity (Ed25519) + DPoP-shaped proof JWS.
|
|
103
103
|
// The crypto half of the two-axis identity story; daemon-side
|
|
104
104
|
// persistence + signing lives in `daemon/src/deviceIdentity.ts`.
|
|
@@ -121,7 +121,7 @@ export {
|
|
|
121
121
|
// deviceIdentity bridge
|
|
122
122
|
initDeviceIdentity, resetDeviceIdentity, signServerRequest, enrollWithServer, clearEnrollmentMarker, isDeviceIdentityActive, getDevicePublicKey, describeDeviceIdentityState, describeDeviceKeySplit,
|
|
123
123
|
// Test seams — file path resolvers + state setters for spec files.
|
|
124
|
-
__publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, } from './deviceIdentityBridge.js';
|
|
124
|
+
__publicKeyPathForTest, __privateKeyPathForTest, __enrollmentMarkerPathForTest, __enrollmentBackoffPathForTest, __setUnreadableReloadIntervalForTest, __setStateForTest as __setDeviceIdentityStateForTest, __getStateForTest as __getDeviceIdentityStateForTest, } from './deviceIdentityBridge.js';
|
|
125
125
|
// Hardware machine identity sent as `machineUuid` (IOPlatformUUID /
|
|
126
126
|
// machine-id / MachineGuid). ONE implementation for daemon and CLI —
|
|
127
127
|
// OSK-12063. Also exposed dependency-free as `@skrr-ai/auth-core/machine-uuid`.
|
|
@@ -19,6 +19,7 @@ export { MacosKek } from './macos.js';
|
|
|
19
19
|
export { LinuxKek } from './linux.js';
|
|
20
20
|
export { WindowsKek } from './windows.js';
|
|
21
21
|
export { zeroizeKekCaches, registerZeroizeHook } from './zeroize-registry.js';
|
|
22
|
+
export { isMasterKekKeychainService, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, } from './masterKekService.js';
|
|
22
23
|
/**
|
|
23
24
|
* @internal Test-only seam — inject a synthetic KEK strategy that wraps
|
|
24
25
|
* with a fixed in-memory key. Resets to platform default when called
|
|
@@ -7,6 +7,7 @@ export { MacosKek } from './macos.js';
|
|
|
7
7
|
export { LinuxKek } from './linux.js';
|
|
8
8
|
export { WindowsKek } from './windows.js';
|
|
9
9
|
export { zeroizeKekCaches, registerZeroizeHook } from './zeroize-registry.js';
|
|
10
|
+
export { isMasterKekKeychainService, MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE, } from './masterKekService.js';
|
|
10
11
|
/**
|
|
11
12
|
* Strategy used when the current platform has no implementation. Always
|
|
12
13
|
* unavailable; never throws on probe. Provides an honest `id` so fleet
|
|
@@ -60,12 +60,13 @@ import fs from 'node:fs';
|
|
|
60
60
|
import { openBuffer, sealBuffer, deserialize as envelopeDeserialize, serialize as envelopeSerialize, KEY_LEN, } from '../credentialEnvelope.js';
|
|
61
61
|
import { KekUnavailableError } from './types.js';
|
|
62
62
|
import { registerZeroizeHook } from './zeroize-registry.js';
|
|
63
|
+
import { MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE } from './masterKekService.js';
|
|
63
64
|
/** Stable telemetry kind strings — one per backend tier. */
|
|
64
65
|
const KIND_LIBSECRET = 'linux-libsecret-shellout';
|
|
65
66
|
const KIND_HKDF = 'linux-machineid-hkdf';
|
|
66
67
|
/** Secret Service item attributes for the master KEK (Tier-2). */
|
|
67
|
-
const SS_SERVICE =
|
|
68
|
-
const SS_ACCOUNT =
|
|
68
|
+
const SS_SERVICE = MASTER_KEK_KEYCHAIN_SERVICE;
|
|
69
|
+
const SS_ACCOUNT = MASTER_KEK_KEYCHAIN_ACCOUNT;
|
|
69
70
|
/** D-Bus transport error substrings — any of these in stderr means no session. */
|
|
70
71
|
const DBUS_TRANSPORT_ERRORS = [
|
|
71
72
|
'D-Bus',
|
|
@@ -57,6 +57,7 @@ import fs from 'node:fs';
|
|
|
57
57
|
import { openBuffer, sealBuffer, deserialize as envelopeDeserialize, serialize as envelopeSerialize, KEY_LEN, } from '../credentialEnvelope.js';
|
|
58
58
|
import { KekUnavailableError } from './types.js';
|
|
59
59
|
import { registerZeroizeHook } from './zeroize-registry.js';
|
|
60
|
+
import { MASTER_KEK_KEYCHAIN_ACCOUNT, MASTER_KEK_KEYCHAIN_SERVICE } from './masterKekService.js';
|
|
60
61
|
/**
|
|
61
62
|
* Stable telemetry kind string — fleet dashboards size the macOS cohort
|
|
62
63
|
* by this value. Only one backend ships in this module today.
|
|
@@ -68,8 +69,8 @@ const KIND_SHELLOUT = 'macos-keychain-shellout';
|
|
|
68
69
|
* Test seams and smoke tests can override either field via the
|
|
69
70
|
* `MacosKek` constructor without touching this canonical pair.
|
|
70
71
|
*/
|
|
71
|
-
const KEYCHAIN_SERVICE =
|
|
72
|
-
const KEYCHAIN_ACCOUNT =
|
|
72
|
+
const KEYCHAIN_SERVICE = MASTER_KEK_KEYCHAIN_SERVICE;
|
|
73
|
+
const KEYCHAIN_ACCOUNT = MASTER_KEK_KEYCHAIN_ACCOUNT;
|
|
73
74
|
/** `security` exit code when the requested item does not exist. */
|
|
74
75
|
const SECURITY_ITEM_NOT_FOUND = 44;
|
|
75
76
|
const execFileAsync = promisify(execFile);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kek/masterKekService.ts — the one place that names the machine-wide master KEK.
|
|
3
|
+
*
|
|
4
|
+
* The master KEK is ONE keychain item shared by every profile on the machine
|
|
5
|
+
* (`service=ai.skrr.daemon.kek`, `account=master-kek-v1`). Each profile's
|
|
6
|
+
* `cred-dek-wrapped` is sealed under it, and everything a profile stores — its
|
|
7
|
+
* tokens, its BYOK keys, its device private key — is sealed under that DEK.
|
|
8
|
+
* Deleting this one item therefore orphans every credential envelope on the
|
|
9
|
+
* machine at once, and nothing can bring it back: the key is random.
|
|
10
|
+
*
|
|
11
|
+
* It was deleted by accident on 2026-10-29. The desktop app's sign-out purge
|
|
12
|
+
* enumerates every service matching `ai.(skrr|oversky).daemon*` and drains each
|
|
13
|
+
* one, because that prefix used to hold only per-profile credential slots. The
|
|
14
|
+
* KEK moved under the same prefix without the purge being told, so a routine
|
|
15
|
+
* "your saved session was rejected" at app launch deleted it. The next daemon
|
|
16
|
+
* start could not open the DEK, treated that as a rotation, enrolled a new one,
|
|
17
|
+
* and the device private key sealed under the old DEK was gone for good — which
|
|
18
|
+
* the server then saw as `DEVICE_PROOF_MISSING` on every CLI hand-off.
|
|
19
|
+
*
|
|
20
|
+
* So anything that deletes keychain items by PREFIX or PATTERN asks
|
|
21
|
+
* `isMasterKekKeychainService` first. Deleting the KEK is a decision a caller
|
|
22
|
+
* makes by name, never a side effect of a sweep.
|
|
23
|
+
*/
|
|
24
|
+
/** Keychain `service` of the master KEK. */
|
|
25
|
+
export declare const MASTER_KEK_KEYCHAIN_SERVICE = "ai.skrr.daemon.kek";
|
|
26
|
+
/** Keychain `account` of the master KEK. */
|
|
27
|
+
export declare const MASTER_KEK_KEYCHAIN_ACCOUNT = "master-kek-v1";
|
|
28
|
+
/** Whether a keychain service name holds master-KEK material. */
|
|
29
|
+
export declare function isMasterKekKeychainService(service: string): boolean;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kek/masterKekService.ts — the one place that names the machine-wide master KEK.
|
|
3
|
+
*
|
|
4
|
+
* The master KEK is ONE keychain item shared by every profile on the machine
|
|
5
|
+
* (`service=ai.skrr.daemon.kek`, `account=master-kek-v1`). Each profile's
|
|
6
|
+
* `cred-dek-wrapped` is sealed under it, and everything a profile stores — its
|
|
7
|
+
* tokens, its BYOK keys, its device private key — is sealed under that DEK.
|
|
8
|
+
* Deleting this one item therefore orphans every credential envelope on the
|
|
9
|
+
* machine at once, and nothing can bring it back: the key is random.
|
|
10
|
+
*
|
|
11
|
+
* It was deleted by accident on 2026-10-29. The desktop app's sign-out purge
|
|
12
|
+
* enumerates every service matching `ai.(skrr|oversky).daemon*` and drains each
|
|
13
|
+
* one, because that prefix used to hold only per-profile credential slots. The
|
|
14
|
+
* KEK moved under the same prefix without the purge being told, so a routine
|
|
15
|
+
* "your saved session was rejected" at app launch deleted it. The next daemon
|
|
16
|
+
* start could not open the DEK, treated that as a rotation, enrolled a new one,
|
|
17
|
+
* and the device private key sealed under the old DEK was gone for good — which
|
|
18
|
+
* the server then saw as `DEVICE_PROOF_MISSING` on every CLI hand-off.
|
|
19
|
+
*
|
|
20
|
+
* So anything that deletes keychain items by PREFIX or PATTERN asks
|
|
21
|
+
* `isMasterKekKeychainService` first. Deleting the KEK is a decision a caller
|
|
22
|
+
* makes by name, never a side effect of a sweep.
|
|
23
|
+
*/
|
|
24
|
+
/** Keychain `service` of the master KEK. */
|
|
25
|
+
export const MASTER_KEK_KEYCHAIN_SERVICE = 'ai.skrr.daemon.kek';
|
|
26
|
+
/** Keychain `account` of the master KEK. */
|
|
27
|
+
export const MASTER_KEK_KEYCHAIN_ACCOUNT = 'master-kek-v1';
|
|
28
|
+
/**
|
|
29
|
+
* The master KEK under the current brand and the one before the rename, plus
|
|
30
|
+
* anything namespaced beneath either (`…kek.probe`, the Linux Secret Service
|
|
31
|
+
* probe). The pre-rename item is protected too: leaving a dead item behind costs
|
|
32
|
+
* bytes, and deleting key material is the one step that cannot be taken back.
|
|
33
|
+
*/
|
|
34
|
+
const MASTER_KEK_SERVICE_PATTERN = /^ai\.(?:skrr|oversky)\.daemon\.kek(?:\..*)?$/;
|
|
35
|
+
/** Whether a keychain service name holds master-KEK material. */
|
|
36
|
+
export function isMasterKekKeychainService(service) {
|
|
37
|
+
return typeof service === 'string' && MASTER_KEK_SERVICE_PATTERN.test(service);
|
|
38
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type AuthFailureReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_EXPIRED' | 'SESSION_REVOKED' | 'DEVICE_KEY_MISMATCH' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'UNKNOWN_401' | 'UNKNOWN';
|
|
1
|
+
export type AuthFailureReason = 'REFRESH_INVALID' | 'REFRESH_REUSED' | 'REFRESH_EXPIRED' | 'SESSION_REVOKED' | 'DEVICE_KEY_MISMATCH' | 'DEVICE_KEY_UNAVAILABLE' | 'TOKEN_EXPIRED' | 'TOKEN_INVALID' | 'UNKNOWN_401' | 'UNKNOWN';
|
|
2
2
|
/**
|
|
3
3
|
* The command that recovers this process from a re-auth latch.
|
|
4
4
|
*
|
|
@@ -13,6 +13,7 @@ const REASON_HUMAN = {
|
|
|
13
13
|
REFRESH_EXPIRED: 'Your session has expired.',
|
|
14
14
|
SESSION_REVOKED: 'Your session was ended (you signed out elsewhere or your password changed).',
|
|
15
15
|
DEVICE_KEY_MISMATCH: "This computer's device key no longer matches the one skrr registered for it, so its session cannot be renewed.",
|
|
16
|
+
DEVICE_KEY_UNAVAILABLE: "This computer's device key could not be read, so skrr cannot prove which device is asking and its session cannot be renewed. Signing in again creates a new one.",
|
|
16
17
|
TOKEN_EXPIRED: 'Your access token has expired.',
|
|
17
18
|
TOKEN_INVALID: 'Your saved credentials are no longer valid.',
|
|
18
19
|
UNKNOWN_401: 'skrr needs you to sign in again.',
|
|
@@ -611,7 +611,12 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
|
|
|
611
611
|
// "proof-per-attempt invariant: a retry loop must call buildRequestInit()
|
|
612
612
|
// per attempt" in `refresh.deviceProof.test.ts` asserts the function
|
|
613
613
|
// is defined inside `doRefresh` and will fail if it is moved.
|
|
614
|
+
// Whether the request the server just answered carried a device proof. A
|
|
615
|
+
// `DEVICE_PROOF_MISSING` is only a LOCAL fact (no key to sign with) when we
|
|
616
|
+
// know none went out; if one did, something between us stripped it.
|
|
617
|
+
let proofAttached = false;
|
|
614
618
|
function buildRequestInit() {
|
|
619
|
+
proofAttached = false;
|
|
615
620
|
// Phase 3-R5: the stable `X-Oversky-Origin` header is the server's
|
|
616
621
|
// preferred identity signal on the benign-grace rotation match. Newer
|
|
617
622
|
// servers prefer it over the raw User-Agent; older servers ignore
|
|
@@ -646,6 +651,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
|
|
|
646
651
|
__refreshDiagnostics.proofAttemptCount += 1;
|
|
647
652
|
if (typeof proof === 'string' && proof.length > 0) {
|
|
648
653
|
headers['OverSky-DPoP'] = proof;
|
|
654
|
+
proofAttached = true;
|
|
649
655
|
}
|
|
650
656
|
}
|
|
651
657
|
catch (err) {
|
|
@@ -867,10 +873,29 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
|
|
|
867
873
|
// the FINAL fallback. The distinct `UNKNOWN_401` telemetry tag is kept
|
|
868
874
|
// on BOTH branches so monitoring can still see a server-side bug
|
|
869
875
|
// separately from a real expiration (and watch the retry-vs-latch split).
|
|
876
|
+
//
|
|
877
|
+
// One unknown-shape 401 IS diagnosable: the server demanded a device proof
|
|
878
|
+
// (`DEVICE_PROOF_MISSING`) and this request carried none. That is a fact
|
|
879
|
+
// about this machine — an unreadable, never-created or unwired device key —
|
|
880
|
+
// and it never self-heals, but it stays on the same budget (a proxy that
|
|
881
|
+
// strips the header, or an identity still initialising, reads the same on
|
|
882
|
+
// one attempt). Only what the exhausted latch SAYS differs, so a person is
|
|
883
|
+
// sent to the sign-in that mints a key rather than told "sign in again".
|
|
884
|
+
// If a proof DID go out, the header was lost on the way: that stays generic.
|
|
885
|
+
const noLocalProof = parsedBody.code === 'DEVICE_PROOF_MISSING' && !proofAttached;
|
|
886
|
+
const latchReason = noLocalProof
|
|
887
|
+
? 'DEVICE_KEY_UNAVAILABLE'
|
|
888
|
+
: 'UNKNOWN_401';
|
|
870
889
|
const message = parsedBody.message || 'Unknown auth failure';
|
|
890
|
+
const latchMessage = noLocalProof
|
|
891
|
+
? `This computer has no usable device key, so the refresh carried no device proof (server: ${message})`
|
|
892
|
+
: message;
|
|
871
893
|
const { exhausted, count } = recordUnknown401(daemonId, Date.now());
|
|
872
894
|
if (!exhausted) {
|
|
873
|
-
log.warn(`[auth/refresh] unknown-shape 401 (${count}/${UNKNOWN_401_BUDGET}) — treating as transient, will retry: ${message}`
|
|
895
|
+
log.warn(`[auth/refresh] unknown-shape 401 (${count}/${UNKNOWN_401_BUDGET}) — treating as transient, will retry: ${message}` +
|
|
896
|
+
(noLocalProof
|
|
897
|
+
? ' (no device proof was sent: this machine has no usable device key)'
|
|
898
|
+
: ''));
|
|
874
899
|
emitAuthTelemetry({
|
|
875
900
|
timestamp: new Date().toISOString(),
|
|
876
901
|
eventType: 'auth',
|
|
@@ -893,7 +918,7 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
|
|
|
893
918
|
// permanent behavior: latch + persist the reauth flag so the daemon
|
|
894
919
|
// surfaces the banner and the interactive modal becomes the final
|
|
895
920
|
// recovery path.
|
|
896
|
-
log.warn(`[auth/refresh] unknown-shape 401 budget exhausted (${count}/${UNKNOWN_401_BUDGET}) — latching permanent: ${message}`);
|
|
921
|
+
log.warn(`[auth/refresh] unknown-shape 401 budget exhausted (${count}/${UNKNOWN_401_BUDGET}) — latching permanent ${latchReason}: ${message}`);
|
|
897
922
|
emitAuthTelemetry({
|
|
898
923
|
timestamp: new Date().toISOString(),
|
|
899
924
|
eventType: 'auth',
|
|
@@ -902,18 +927,18 @@ async function doRefresh(serverUrl, currentRefreshToken, daemonId, options = {})
|
|
|
902
927
|
event: 'refresh_failure',
|
|
903
928
|
daemonId,
|
|
904
929
|
durationMs: Date.now() - startMs,
|
|
905
|
-
errorCode:
|
|
930
|
+
errorCode: latchReason,
|
|
906
931
|
statusCode: 401,
|
|
907
932
|
transient: false,
|
|
908
933
|
unknown401Streak: count,
|
|
909
934
|
},
|
|
910
935
|
});
|
|
911
936
|
persistReauthState({
|
|
912
|
-
code:
|
|
913
|
-
message,
|
|
937
|
+
code: latchReason,
|
|
938
|
+
message: latchMessage,
|
|
914
939
|
at: Date.now(),
|
|
915
940
|
});
|
|
916
|
-
throw new PermanentAuthFailure(
|
|
941
|
+
throw new PermanentAuthFailure(latchReason, latchMessage);
|
|
917
942
|
}
|
|
918
943
|
// 5xx / unexpected — transient. Caller will back off.
|
|
919
944
|
// RESET SEMANTICS (Finding 4 follow-up): a 5xx is transient and does NOT
|
|
@@ -61,6 +61,9 @@ export declare const PREVIEW_TOKEN_REDACTION: "pvw_[redacted]";
|
|
|
61
61
|
export declare const QR_SIGN_IN_REDACTION: "[redacted]";
|
|
62
62
|
/** Fields are protocol-specific; preserve ordinary request ids, error codes and metrics. */
|
|
63
63
|
export declare function isQrSignInSecretField(key: string): boolean;
|
|
64
|
+
export declare function isOAuthQuerySecretField(key: string): boolean;
|
|
65
|
+
export declare function isOAuthQueryContainerField(key: string): boolean;
|
|
66
|
+
export declare function isGraphHandoffCookieField(key: string): boolean;
|
|
64
67
|
/**
|
|
65
68
|
* Replace every credential that travels in a URL with a marker: a Hosted Machine preview token with
|
|
66
69
|
* {@link PREVIEW_TOKEN_REDACTION}, and a workspace invitation, platform invitation, member link or operator code with
|
|
@@ -107,6 +107,21 @@ export function isQrSignInSecretField(key) {
|
|
|
107
107
|
return QR_SECRET_FIELDS.has(leaf.replace(/[_-]/g, '').toLowerCase());
|
|
108
108
|
}
|
|
109
109
|
const QR_SECRET_ASSIGNMENT = /((?:["']?)(?:statusToken|claimToken|codeVerifier|codeChallenge|comparisonCode|status_token|claim_token|code_verifier|code_challenge|comparison_code)(?:["']?)\s*[:=]\s*["']?)([A-Za-z0-9._~%+-]+)/gi;
|
|
110
|
+
// OAuth callback queries may be exported detached from their URL. Redact
|
|
111
|
+
// sensitive parameter values even without path context, including when the
|
|
112
|
+
// query separators were percent-encoded by an enclosing URL.
|
|
113
|
+
const OAUTH_QUERY_SECRET = /(^|[?&]|%(?:25)*26)(code|state|error_description|handoff)(=|%(?:25)*3[dD])([^&#\s"'<>]*)/gi;
|
|
114
|
+
export function isOAuthQuerySecretField(key) {
|
|
115
|
+
return ['code', 'state', 'error_description', 'handoff'].includes(key.toLowerCase());
|
|
116
|
+
}
|
|
117
|
+
export function isOAuthQueryContainerField(key) {
|
|
118
|
+
const leaf = (key.split('.').pop() || '').replace(/[_-]/g, '').toLowerCase();
|
|
119
|
+
return leaf === 'query' || leaf === 'querystring';
|
|
120
|
+
}
|
|
121
|
+
export function isGraphHandoffCookieField(key) {
|
|
122
|
+
return key.toLowerCase() === '__secure-pc-graph-handoff';
|
|
123
|
+
}
|
|
124
|
+
const GRAPH_HANDOFF_COOKIE = /((?:_|%(?:25)*5[fF]){2}secure(?:-|%(?:25)*2[dD])pc(?:-|%(?:25)*2[dD])graph(?:-|%(?:25)*2[dD])handoff(?:=|%(?:25)*3[dD]))([^;,&\s"'<>]+)/gi;
|
|
110
125
|
function redactQrSignInText(value) {
|
|
111
126
|
// Undici/Sentry may split url.query/query_string from its path. The QR's
|
|
112
127
|
// direction vocabulary identifies that standalone query without treating an
|
|
@@ -124,7 +139,8 @@ const credentialMarker = (_match, prefix) => `${prefix}_[redacted]`;
|
|
|
124
139
|
export function redactPreviewTokens(value) {
|
|
125
140
|
if (typeof value !== 'string')
|
|
126
141
|
return value;
|
|
127
|
-
const qrSafe = redactQrSignInText(value)
|
|
142
|
+
const qrSafe = redactQrSignInText(value).replace(OAUTH_QUERY_SECRET, '$1$2$3[redacted]')
|
|
143
|
+
.replace(GRAPH_HANDOFF_COOKIE, '$1[redacted]');
|
|
128
144
|
if (!CREDENTIAL_HINTS.some((hint) => qrSafe.indexOf(hint) !== -1))
|
|
129
145
|
return qrSafe;
|
|
130
146
|
return qrSafe
|
|
@@ -153,7 +169,7 @@ export function redactPreviewTokensDeep(root, limits = {}) {
|
|
|
153
169
|
let visited = 0;
|
|
154
170
|
let complete = true;
|
|
155
171
|
const rewrite = (holder, key, current) => {
|
|
156
|
-
const next = isQrSignInSecretField(key) ? QR_SIGN_IN_REDACTION : redactPreviewTokens(current);
|
|
172
|
+
const next = isQrSignInSecretField(key) || isGraphHandoffCookieField(key) ? QR_SIGN_IN_REDACTION : redactPreviewTokens(current);
|
|
157
173
|
if (next === current) {
|
|
158
174
|
return;
|
|
159
175
|
}
|
|
@@ -195,7 +211,28 @@ export function redactPreviewTokensDeep(root, limits = {}) {
|
|
|
195
211
|
complete = false;
|
|
196
212
|
continue;
|
|
197
213
|
}
|
|
198
|
-
if (
|
|
214
|
+
if (isOAuthQueryContainerField(key) && Array.isArray(child)) {
|
|
215
|
+
try {
|
|
216
|
+
holder[key] = '[redacted]';
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
complete = false;
|
|
220
|
+
}
|
|
221
|
+
if (holder[key] !== '[redacted]')
|
|
222
|
+
complete = false;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (isOAuthQueryContainerField(key) && child && typeof child === 'object' && !Array.isArray(child)) {
|
|
226
|
+
const query = child;
|
|
227
|
+
for (const queryKey of Object.keys(query)) {
|
|
228
|
+
if (isOAuthQuerySecretField(queryKey)) {
|
|
229
|
+
query[queryKey] = '[redacted]';
|
|
230
|
+
if (query[queryKey] !== '[redacted]')
|
|
231
|
+
complete = false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (isQrSignInSecretField(key) || isGraphHandoffCookieField(key) || typeof child === 'string') {
|
|
199
236
|
rewrite(holder, key, child);
|
|
200
237
|
}
|
|
201
238
|
else if (child !== null && typeof child === 'object') {
|