@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.
Files changed (111) hide show
  1. package/dist/commands/agents/chat.d.ts +7 -0
  2. package/dist/commands/agents/chat.js +32 -6
  3. package/dist/commands/agents/delete.d.ts +8 -0
  4. package/dist/commands/agents/delete.js +16 -0
  5. package/dist/commands/context.js +7 -0
  6. package/dist/commands/followups/act.js +3 -0
  7. package/dist/commands/followups/checkin.js +3 -0
  8. package/dist/commands/followups/remind.js +3 -0
  9. package/dist/commands/followups/show.js +1 -1
  10. package/dist/commands/followups/watch.js +3 -0
  11. package/dist/commands/search/workspace.js +1 -1
  12. package/dist/commands/store/gallery-admissions/approve.js +1 -1
  13. package/dist/commands/store/gallery-admissions/preview.js +1 -1
  14. package/dist/commands/store/gallery-admissions/refuse.js +1 -1
  15. package/dist/commands/store/gallery-admissions/show.js +1 -1
  16. package/dist/lib/agentic-stream.d.ts +46 -0
  17. package/dist/lib/agentic-stream.js +135 -14
  18. package/dist/lib/auth-core-init.d.ts +15 -2
  19. package/dist/lib/auth-core-init.js +47 -6
  20. package/dist/lib/auth-failure.js +12 -0
  21. package/dist/lib/command-hygiene.js +30 -1
  22. package/dist/lib/daemonBrokerRefusal.js +5 -1
  23. package/dist/lib/exec-runtime-binary.d.ts +9 -0
  24. package/dist/lib/exec-runtime-binary.js +15 -1
  25. package/dist/lib/followups.d.ts +13 -0
  26. package/dist/lib/followups.js +8 -0
  27. package/dist/lib/keychain.js +41 -4
  28. package/dist/lib/profile-machine-identity-guard.js +2 -1
  29. package/dist/lib/runtime-start-verification.js +6 -1
  30. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.d.ts +5 -1
  31. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/cliHandoffWire.js +1 -1
  32. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/credentialEnvelopeBridge.js +69 -27
  33. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +135 -4
  34. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +440 -83
  35. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.d.ts +106 -7
  36. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKey.js +77 -19
  37. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.d.ts +33 -0
  38. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHandle.js +77 -0
  39. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.d.ts +68 -0
  40. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyHardware.js +264 -0
  41. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.d.ts +31 -0
  42. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceKeyServerSupport.js +90 -0
  43. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/dpop.js +2 -0
  44. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/independentMachineReservation.js +2 -0
  45. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.d.ts +7 -3
  46. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +110 -0
  47. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -0
  48. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +13 -0
  49. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.d.ts +18 -0
  50. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +11 -2
  51. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.d.ts +22 -0
  52. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/macos.js +14 -2
  53. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +69 -0
  54. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +67 -0
  55. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.d.ts +154 -0
  56. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/liveState.js +281 -0
  57. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineId.js +6 -3
  58. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/machineUuid.js +8 -5
  59. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.d.ts +1 -1
  60. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/messages.js +1 -0
  61. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/profileStateDir.js +7 -27
  62. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.d.ts +1 -1
  63. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/refresh.js +24 -16
  64. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.d.ts +31 -0
  65. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/stateFiles.js +89 -0
  66. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/telemetryRedaction.d.ts +3 -0
  67. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/telemetryRedaction.js +41 -3
  68. package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/types.d.ts +7 -1
  69. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.d.ts +5 -1
  70. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/cliHandoffWire.js +4 -1
  71. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/credentialEnvelopeBridge.js +122 -38
  72. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +135 -4
  73. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +603 -90
  74. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.d.ts +106 -7
  75. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKey.js +131 -27
  76. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.d.ts +33 -0
  77. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHandle.js +81 -0
  78. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.d.ts +68 -0
  79. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyHardware.js +317 -0
  80. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.d.ts +31 -0
  81. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceKeyServerSupport.js +86 -0
  82. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/dpop.js +4 -0
  83. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/independentMachineReservation.js +4 -0
  84. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +7 -3
  85. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +14 -4
  86. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -0
  87. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -0
  88. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.d.ts +18 -0
  89. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +18 -2
  90. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.d.ts +22 -0
  91. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/macos.js +23 -2
  92. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +69 -0
  93. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +134 -0
  94. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.d.ts +154 -0
  95. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/liveState.js +386 -0
  96. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineId.js +6 -3
  97. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/machineUuid.js +11 -5
  98. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.d.ts +1 -1
  99. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/messages.js +1 -0
  100. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/profileStateDir.js +6 -35
  101. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.d.ts +1 -1
  102. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/refresh.js +45 -16
  103. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.d.ts +31 -0
  104. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/stateFiles.js +73 -0
  105. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/telemetryRedaction.d.ts +3 -0
  106. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/telemetryRedaction.js +40 -3
  107. package/dist/node_modules/@skrr-ai/auth-core/dist/esm/types.d.ts +7 -1
  108. package/dist/node_modules/@skrr-ai/auth-core/package.json +21 -1
  109. package/dist/node_modules/@skrr-ai/data-provider/index.js +7349 -7325
  110. package/oclif.manifest.json +11 -2
  111. 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
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
169
+ ensureStateDir(dir, `take the credential envelope lock for profile ${profile}`);
199
170
  if (!fs.existsSync(lockPath)) {
200
- fs.writeFileSync(lockPath, '', { mode: 0o600 });
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
- unwrapAttemptedAndFailed = true;
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
- fs.unlinkSync(target);
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 DeviceKeyPair, type Ed25519PublicJwk } from './deviceKey.js';
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
- keypair: DeviceKeyPair;
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: Ed25519PublicJwk;
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;