@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
|
@@ -32,10 +32,31 @@
|
|
|
32
32
|
import fs from 'node:fs';
|
|
33
33
|
import path from 'node:path';
|
|
34
34
|
import lockfile from 'proper-lockfile';
|
|
35
|
-
import {
|
|
35
|
+
import { generateDeviceKeyPair, isDevicePublicJwk, jwkThumbprint, signProofWith, } from './deviceKey.js';
|
|
36
|
+
import { softwareEd25519Handle } from './deviceKeyHandle.js';
|
|
37
|
+
import { createHardwareDeviceKey, hardwareDeviceKeyHandle, parseHardwareKeyRecord, probeHardwareDeviceKey, resolveHardwareDeviceKeyHelper, serializeHardwareKeyRecord, HardwareDeviceKeyError, } from './deviceKeyHardware.js';
|
|
36
38
|
import { hasCredEnvelopePathConflict, isCredEnvelopeActive, maybeDecryptOnRead, splitKeyConvergencePolicy, wrapIfActiveOrPassthrough, } from './credentialEnvelopeBridge.js';
|
|
37
|
-
import { consolidateProfileState, profileStateFilePathsToClear, profileStateDir, resolveProfileStateDir, } from './profileStateDir.js';
|
|
39
|
+
import { consolidateProfileState, profileStateFilePathsToClear, profileStateDir, resolveProfileStateDir, RETIRED_COPY_SUFFIX, } from './profileStateDir.js';
|
|
40
|
+
import { rethrowIfLiveStateRefusal } from './liveState.js';
|
|
38
41
|
import { emitAuthTelemetry, getAuthLogger } from './runtime.js';
|
|
42
|
+
import { atomicWrite0600, ensureStateDir, removeStateFile } from './stateFiles.js';
|
|
43
|
+
/**
|
|
44
|
+
* The `cred_envelope.read.decrypt_failed` hint for the device PRIVATE key
|
|
45
|
+
* (OSK-20506).
|
|
46
|
+
*
|
|
47
|
+
* The envelope's default hint says "signing in again rebuilds it", which is true
|
|
48
|
+
* of a credential and false of this: `skrr login` never creates or replaces a
|
|
49
|
+
* device key, and only an interactive daemon sign-in does (it re-pins the new key
|
|
50
|
+
* on the server). `loadKeypair` hands this to `maybeDecryptOnRead` so the
|
|
51
|
+
* producer says WHAT failed; a presenter that already prints one device-key line
|
|
52
|
+
* (the CLI's `recovery_required` notice) recognises it by identity and prints
|
|
53
|
+
* nothing for the decrypt failure, instead of two lines with two remedies. A
|
|
54
|
+
* logger that prints every warning, the daemon's, shows this one as written.
|
|
55
|
+
*
|
|
56
|
+
* Compared by value by the CLI presenter: change the text and the CLI prints the
|
|
57
|
+
* credential line again, which `auth-core-init.device-key.spec.ts` catches.
|
|
58
|
+
*/
|
|
59
|
+
export const DEVICE_KEY_UNREADABLE_CREDENTIAL_HINT = 'stored device key is unreadable on this machine — only an interactive daemon sign-in replaces it';
|
|
39
60
|
const PUBLIC_KEY_FILENAME = 'device-public-key.jwk';
|
|
40
61
|
const PRIVATE_KEY_FILENAME = 'device-private-key';
|
|
41
62
|
const ENROLLMENT_MARKER_FILENAME = 'device-enrollment.json';
|
|
@@ -58,6 +79,146 @@ const DEVICE_IDENTITY_FILES = [
|
|
|
58
79
|
const DAEMON_NOT_FOUND_BACKOFF_MS = 15 * 60 * 1000;
|
|
59
80
|
let _state = { kind: 'uninit' };
|
|
60
81
|
let _initializing = null;
|
|
82
|
+
/**
|
|
83
|
+
* What this process saw when it gave up on a stored key it could not read: the profile, and
|
|
84
|
+
* the mtimes of the two key files. A process that is already running has no way to learn that
|
|
85
|
+
* someone else has since written a readable key (the desktop's recovery, an interactive
|
|
86
|
+
* login in another process), and used to sit disabled until it was restarted, signing
|
|
87
|
+
* nothing. `reloadIfRecovered` reads the key again, but only when one of those files has
|
|
88
|
+
* changed, so a key that stays unreadable costs one `stat` and no log line.
|
|
89
|
+
*/
|
|
90
|
+
let _unreadableSeen = null;
|
|
91
|
+
let _unreadableReloadIntervalMs = 5_000;
|
|
92
|
+
/** @internal Test seam — how often an unreadable key is looked at again. `null` restores 5 s. */
|
|
93
|
+
export function __setUnreadableReloadIntervalForTest(ms) {
|
|
94
|
+
_unreadableReloadIntervalMs = ms ?? 5_000;
|
|
95
|
+
}
|
|
96
|
+
function keyFilesStamp(profile) {
|
|
97
|
+
const part = (file) => {
|
|
98
|
+
try {
|
|
99
|
+
const s = fs.statSync(file);
|
|
100
|
+
return `${s.mtimeMs}:${s.size}`;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return 'absent';
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
return `${part(publicKeyPath(profile))}|${part(privateKeyPath(profile))}`;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* What this process last saw of the key files while it held an ACTIVE key. A running daemon
|
|
110
|
+
* signs with the key it loaded at boot; when an interactive login in another process upgrades
|
|
111
|
+
* the device key (software to hardware), the server re-pins the machine to the new key and the
|
|
112
|
+
* old one starts being refused. Without this the daemon would keep signing with it until
|
|
113
|
+
* someone restarted it, and every refresh in between would fail as a key mismatch.
|
|
114
|
+
*/
|
|
115
|
+
let _activeSeen = null;
|
|
116
|
+
function trackActiveKeyFiles(profile) {
|
|
117
|
+
try {
|
|
118
|
+
_activeSeen = { profile, stamp: keyFilesStamp(profile), checkedAt: Date.now() };
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
_activeSeen = null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Pick up a key another process has written since this one loaded its own. Two cases, one
|
|
126
|
+
* rule: this only ever READS what another process wrote under the same lock, and it never
|
|
127
|
+
* creates, replaces or deletes a key, which is what keeps it clear of the silent replacement
|
|
128
|
+
* the `recoverUnreadable` gate exists to prevent.
|
|
129
|
+
*
|
|
130
|
+
* - A key this process could not read, since replaced by a readable one (the desktop's
|
|
131
|
+
* recovery, an interactive login elsewhere).
|
|
132
|
+
* - An ACTIVE key, since replaced by a different one (an upgrade to hardware). A change that
|
|
133
|
+
* does not load is ignored and not retried until the files change again: the key in
|
|
134
|
+
* memory is the one this process was told is pinned, and a half-understood replacement is
|
|
135
|
+
* not evidence that it still is not.
|
|
136
|
+
*/
|
|
137
|
+
function reloadIfRecovered() {
|
|
138
|
+
reloadIfActiveKeyChanged();
|
|
139
|
+
if (_state.kind !== 'disabled' || _state.reason !== 'unreadable_key')
|
|
140
|
+
return;
|
|
141
|
+
const seen = _unreadableSeen;
|
|
142
|
+
if (!seen || _initializing)
|
|
143
|
+
return;
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
if (now - seen.checkedAt < _unreadableReloadIntervalMs)
|
|
146
|
+
return;
|
|
147
|
+
seen.checkedAt = now;
|
|
148
|
+
let stamp;
|
|
149
|
+
try {
|
|
150
|
+
stamp = keyFilesStamp(seen.profile);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (stamp === seen.stamp)
|
|
156
|
+
return;
|
|
157
|
+
seen.stamp = stamp;
|
|
158
|
+
let key = null;
|
|
159
|
+
try {
|
|
160
|
+
key = loadStoredKey(seen.profile);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
key = null;
|
|
164
|
+
}
|
|
165
|
+
if (!key)
|
|
166
|
+
return;
|
|
167
|
+
_state = {
|
|
168
|
+
kind: 'active',
|
|
169
|
+
key,
|
|
170
|
+
profile: seen.profile,
|
|
171
|
+
envelopeProtected: key.custody === 'software' && isCredEnvelopeActive(),
|
|
172
|
+
};
|
|
173
|
+
_unreadableSeen = null;
|
|
174
|
+
trackActiveKeyFiles(seen.profile);
|
|
175
|
+
emit('reloaded_after_recovery', true, {
|
|
176
|
+
profile: seen.profile,
|
|
177
|
+
thumbprint: key.thumbprint,
|
|
178
|
+
custody: key.custody,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
function reloadIfActiveKeyChanged() {
|
|
182
|
+
const state = _state;
|
|
183
|
+
const seen = _activeSeen;
|
|
184
|
+
if (state.kind !== 'active' || !seen || seen.profile !== state.profile || _initializing)
|
|
185
|
+
return;
|
|
186
|
+
const now = Date.now();
|
|
187
|
+
if (now - seen.checkedAt < _unreadableReloadIntervalMs)
|
|
188
|
+
return;
|
|
189
|
+
seen.checkedAt = now;
|
|
190
|
+
let stamp;
|
|
191
|
+
try {
|
|
192
|
+
stamp = keyFilesStamp(seen.profile);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (stamp === seen.stamp)
|
|
198
|
+
return;
|
|
199
|
+
seen.stamp = stamp;
|
|
200
|
+
let key = null;
|
|
201
|
+
try {
|
|
202
|
+
key = loadStoredKey(seen.profile);
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
key = null;
|
|
206
|
+
}
|
|
207
|
+
if (!key || key.thumbprint === state.key.thumbprint)
|
|
208
|
+
return;
|
|
209
|
+
_state = {
|
|
210
|
+
kind: 'active',
|
|
211
|
+
key,
|
|
212
|
+
profile: seen.profile,
|
|
213
|
+
envelopeProtected: key.custody === 'software' && isCredEnvelopeActive(),
|
|
214
|
+
};
|
|
215
|
+
emit('reloaded_after_key_change', true, {
|
|
216
|
+
profile: seen.profile,
|
|
217
|
+
from: state.key.thumbprint,
|
|
218
|
+
thumbprint: key.thumbprint,
|
|
219
|
+
custody: key.custody,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
61
222
|
/**
|
|
62
223
|
* The CLI, daemon, and desktop's short-lived signer resolve the same canonical
|
|
63
224
|
* profile directory even when an older private key still lives in the nested
|
|
@@ -71,7 +232,7 @@ let _initializing = null;
|
|
|
71
232
|
*/
|
|
72
233
|
async function withDeviceIdentityLock(profile, body) {
|
|
73
234
|
const dir = profileStateDir(profile);
|
|
74
|
-
|
|
235
|
+
ensureStateDir(dir, `take the device identity lock for profile ${profile}`);
|
|
75
236
|
const release = await lockfile.lock(dir, {
|
|
76
237
|
realpath: false,
|
|
77
238
|
lockfilePath: path.join(dir, '.device-identity.lock'),
|
|
@@ -140,33 +301,6 @@ function enrollmentMarkerPath(profile) {
|
|
|
140
301
|
function enrollmentBackoffPath(profile) {
|
|
141
302
|
return path.join(profileConfigDir(profile), ENROLLMENT_BACKOFF_FILENAME);
|
|
142
303
|
}
|
|
143
|
-
function atomicWrite0600(target, value) {
|
|
144
|
-
const dir = path.dirname(target);
|
|
145
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
146
|
-
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
147
|
-
const buf = typeof value === 'string' ? Buffer.from(value, 'utf-8') : value;
|
|
148
|
-
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
149
|
-
try {
|
|
150
|
-
fs.writeSync(fd, buf);
|
|
151
|
-
fs.fsyncSync(fd);
|
|
152
|
-
}
|
|
153
|
-
finally {
|
|
154
|
-
fs.closeSync(fd);
|
|
155
|
-
}
|
|
156
|
-
fs.renameSync(tmp, target);
|
|
157
|
-
try {
|
|
158
|
-
const dirFd = fs.openSync(dir, 'r');
|
|
159
|
-
try {
|
|
160
|
-
fs.fsyncSync(dirFd);
|
|
161
|
-
}
|
|
162
|
-
finally {
|
|
163
|
-
fs.closeSync(dirFd);
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
catch {
|
|
167
|
-
/* best-effort; some platforms reject dir fsync */
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
304
|
function emit(eventName, success, metadata, opts = {}) {
|
|
171
305
|
emitAuthTelemetry({
|
|
172
306
|
timestamp: new Date().toISOString(),
|
|
@@ -185,17 +319,49 @@ function emit(eventName, success, metadata, opts = {}) {
|
|
|
185
319
|
}
|
|
186
320
|
}
|
|
187
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* The plaintext public record, written for either kind of key. `alg` and
|
|
324
|
+
* `custody` are for readers that want to know what they are looking at without
|
|
325
|
+
* opening the private slot (status, support bundles); the loader never trusts
|
|
326
|
+
* them over the key itself.
|
|
327
|
+
*/
|
|
328
|
+
function writePublicKeyRecord(profile, publicJwk) {
|
|
329
|
+
atomicWrite0600(publicKeyPath(profile), JSON.stringify({
|
|
330
|
+
publicJwk,
|
|
331
|
+
thumbprint: jwkThumbprint(publicJwk),
|
|
332
|
+
alg: publicJwk.kty === 'EC' ? 'ES256' : 'EdDSA',
|
|
333
|
+
custody: publicJwk.kty === 'EC' ? 'hardware' : 'software',
|
|
334
|
+
createdAt: new Date().toISOString(),
|
|
335
|
+
}), `write the device public key for profile ${profile}`);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Persist a hardware key. The private slot is written FIRST and is
|
|
339
|
+
* authoritative: it carries its own public key and thumbprint, so a stop
|
|
340
|
+
* between the two writes leaves a state `loadStoredKey` repairs (public file
|
|
341
|
+
* stale, private slot complete) instead of one it must call corrupt (a new
|
|
342
|
+
* public file beside an old private key). Writing in the other order would turn
|
|
343
|
+
* a crash during a key upgrade into a lost identity, which is the exact outcome
|
|
344
|
+
* the upgrade exists to make less likely.
|
|
345
|
+
*
|
|
346
|
+
* Deliberately NOT sealed under the credential envelope; see the header of
|
|
347
|
+
* `deviceKeyHardware.ts`.
|
|
348
|
+
*/
|
|
349
|
+
function persistHardwareKey(profile, record) {
|
|
350
|
+
atomicWrite0600(privateKeyPath(profile), serializeHardwareKeyRecord(record), `write the hardware device key record for profile ${profile}`);
|
|
351
|
+
writePublicKeyRecord(profile, record.publicJwk);
|
|
352
|
+
emit('keypair_persisted', true, {
|
|
353
|
+
profile,
|
|
354
|
+
thumbprint: jwkThumbprint(record.publicJwk),
|
|
355
|
+
custody: 'hardware',
|
|
356
|
+
envelopeProtected: false,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
188
359
|
/**
|
|
189
360
|
* Persist a freshly-generated keypair. Public goes plaintext; private
|
|
190
361
|
* goes through `maybeEncryptForWrite` so L12 wraps it when active.
|
|
191
362
|
*/
|
|
192
363
|
function persistKeypair(profile, kp) {
|
|
193
|
-
|
|
194
|
-
publicJwk: kp.publicJwk,
|
|
195
|
-
thumbprint: kp.thumbprint,
|
|
196
|
-
createdAt: new Date().toISOString(),
|
|
197
|
-
});
|
|
198
|
-
atomicWrite0600(publicKeyPath(profile), publicPayload);
|
|
364
|
+
writePublicKeyRecord(profile, kp.publicJwk);
|
|
199
365
|
// Use the always-fail-open variant — device-identity wants the private
|
|
200
366
|
// key persisted even when the L12 envelope path is inactive (today's
|
|
201
367
|
// default on every platform). The daemon's `credEnvelopeFailClosed`
|
|
@@ -203,7 +369,7 @@ function persistKeypair(profile, kp) {
|
|
|
203
369
|
const privatePlaintext = JSON.stringify(kp.privateJwk);
|
|
204
370
|
const wireForm = wrapIfActiveOrPassthrough(privatePlaintext);
|
|
205
371
|
const envelopeProtected = wireForm !== privatePlaintext;
|
|
206
|
-
atomicWrite0600(privateKeyPath(profile), wireForm);
|
|
372
|
+
atomicWrite0600(privateKeyPath(profile), wireForm, `write the device private key for profile ${profile}`);
|
|
207
373
|
emit('keypair_persisted', true, {
|
|
208
374
|
profile,
|
|
209
375
|
thumbprint: kp.thumbprint,
|
|
@@ -256,11 +422,17 @@ function readIndependentIdentityFile(file, sealed) {
|
|
|
256
422
|
}
|
|
257
423
|
}
|
|
258
424
|
/**
|
|
259
|
-
* Load a previously-persisted
|
|
260
|
-
* decrypt failure, public/private mismatch (file-swap defense), or
|
|
425
|
+
* Load a previously-persisted device key as a handle. Returns null on missing
|
|
426
|
+
* files, decrypt failure, public/private mismatch (file-swap defense), or
|
|
261
427
|
* malformed JSON. Throws only on unexpected fs errors; init catches.
|
|
428
|
+
*
|
|
429
|
+
* The private-key slot holds one of two things, told apart by what they are
|
|
430
|
+
* rather than by a flag anyone could forget to set: a hardware record (plain
|
|
431
|
+
* JSON that names its own algorithm and custody), or a sealed software key.
|
|
432
|
+
* A build that predates hardware keys reads a hardware record as an inconsistent
|
|
433
|
+
* keypair, so it fails closed and never replaces a key it does not understand.
|
|
262
434
|
*/
|
|
263
|
-
function
|
|
435
|
+
function loadStoredKey(profile) {
|
|
264
436
|
const pubPath = publicKeyPath(profile);
|
|
265
437
|
const privPath = privateKeyPath(profile);
|
|
266
438
|
const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
|
|
@@ -274,6 +446,8 @@ function loadKeypair(profile) {
|
|
|
274
446
|
let storedThumbprint;
|
|
275
447
|
try {
|
|
276
448
|
const parsed = JSON.parse(pubRaw);
|
|
449
|
+
if (!isDevicePublicJwk(parsed.publicJwk))
|
|
450
|
+
throw new Error('public key is not a device key');
|
|
277
451
|
publicJwk = parsed.publicJwk;
|
|
278
452
|
storedThumbprint = parsed.thumbprint;
|
|
279
453
|
}
|
|
@@ -284,7 +458,32 @@ function loadKeypair(profile) {
|
|
|
284
458
|
});
|
|
285
459
|
return null;
|
|
286
460
|
}
|
|
287
|
-
|
|
461
|
+
// A reserved independent profile is software-only: its key has a server-pinned
|
|
462
|
+
// owner and is read through the strict sealed-file reader above.
|
|
463
|
+
const hardware = independent ? null : parseHardwareKeyRecord(privRaw);
|
|
464
|
+
if (hardware) {
|
|
465
|
+
// The private slot is authoritative for a hardware key: the record carries
|
|
466
|
+
// its own public key and proves its own thumbprint. A public file that
|
|
467
|
+
// disagrees is a commit that stopped between its two writes (see
|
|
468
|
+
// `persistHardwareKey`), not a swapped file, so repair it from the record.
|
|
469
|
+
if (jwkThumbprint(hardware.publicJwk) !== jwkThumbprint(publicJwk)) {
|
|
470
|
+
try {
|
|
471
|
+
writePublicKeyRecord(profile, hardware.publicJwk);
|
|
472
|
+
emit('public_key_repaired_from_hardware_record', true, { profile }, { logLevel: 'info' });
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
emit('public_key_unreadable', false, { profile, message: err?.message ?? 'unknown' });
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return hardwareDeviceKeyHandle(hardware);
|
|
480
|
+
}
|
|
481
|
+
// The device key's own hint, so a failure here is labelled as the DEVICE key and
|
|
482
|
+
// the CLI prints one device-key line instead of the generic credential one
|
|
483
|
+
// ("run `skrr login`", which cannot repair a device key).
|
|
484
|
+
const decryptResult = maybeDecryptOnRead(privRaw, {
|
|
485
|
+
unreadableHint: DEVICE_KEY_UNREADABLE_CREDENTIAL_HINT,
|
|
486
|
+
});
|
|
288
487
|
if (decryptResult.plaintext === null) {
|
|
289
488
|
emit('private_key_decrypt_failed', false, { profile });
|
|
290
489
|
return null;
|
|
@@ -302,8 +501,10 @@ function loadKeypair(profile) {
|
|
|
302
501
|
return null;
|
|
303
502
|
}
|
|
304
503
|
// public.x must match private.x — any mismatch indicates a partial
|
|
305
|
-
// write race, manual edit, or attacker swapping one of the files.
|
|
306
|
-
|
|
504
|
+
// write race, manual edit, or attacker swapping one of the files. (A software
|
|
505
|
+
// key is Ed25519 and so has no `y`; an EC public key beside an Ed25519
|
|
506
|
+
// private one fails the `kty` test below for the same reason.)
|
|
507
|
+
if (publicJwk.kty !== 'OKP' || publicJwk.x !== privateJwk.x) {
|
|
307
508
|
emit('keypair_inconsistent', false, { profile });
|
|
308
509
|
return null;
|
|
309
510
|
}
|
|
@@ -317,11 +518,16 @@ function loadKeypair(profile) {
|
|
|
317
518
|
recomputed,
|
|
318
519
|
});
|
|
319
520
|
}
|
|
320
|
-
|
|
321
|
-
publicJwk,
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
521
|
+
try {
|
|
522
|
+
return softwareEd25519Handle({ publicJwk, privateJwk, thumbprint: recomputed });
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
emit('private_key_malformed', false, {
|
|
526
|
+
profile,
|
|
527
|
+
message: err?.message ?? 'unknown',
|
|
528
|
+
});
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
325
531
|
}
|
|
326
532
|
/**
|
|
327
533
|
* Identity of a device-identity directory: the thumbprint of the PLAINTEXT
|
|
@@ -335,7 +541,7 @@ function loadKeypair(profile) {
|
|
|
335
541
|
function deviceIdentityOf(dir) {
|
|
336
542
|
try {
|
|
337
543
|
const parsed = JSON.parse(fs.readFileSync(path.join(dir, PUBLIC_KEY_FILENAME), 'utf-8'));
|
|
338
|
-
if (!parsed.publicJwk
|
|
544
|
+
if (!isDevicePublicJwk(parsed.publicJwk))
|
|
339
545
|
return null;
|
|
340
546
|
return jwkThumbprint(parsed.publicJwk);
|
|
341
547
|
}
|
|
@@ -403,6 +609,7 @@ async function consolidateDeviceIdentity(profile) {
|
|
|
403
609
|
});
|
|
404
610
|
}
|
|
405
611
|
catch (err) {
|
|
612
|
+
rethrowIfLiveStateRefusal(err);
|
|
406
613
|
emit('path_consolidation.failed', false, {
|
|
407
614
|
profile,
|
|
408
615
|
message: err?.message ?? 'unknown',
|
|
@@ -445,6 +652,7 @@ export async function initDeviceIdentity(profile, options = {}) {
|
|
|
445
652
|
if (_state.kind === 'uninit')
|
|
446
653
|
await initDeviceIdentityLocked(profile, options);
|
|
447
654
|
}).catch((err) => {
|
|
655
|
+
rethrowIfLiveStateRefusal(err);
|
|
448
656
|
_state = { kind: 'disabled', reason: 'init_failed' };
|
|
449
657
|
emit('init_failed', false, {
|
|
450
658
|
profile,
|
|
@@ -469,23 +677,25 @@ async function initDeviceIdentityLocked(profile, options = {}) {
|
|
|
469
677
|
const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
|
|
470
678
|
if (!independent)
|
|
471
679
|
await consolidateDeviceIdentity(profile);
|
|
472
|
-
let
|
|
680
|
+
let stored = null;
|
|
473
681
|
try {
|
|
474
|
-
|
|
682
|
+
stored = loadStoredKey(profile);
|
|
475
683
|
}
|
|
476
684
|
catch (err) {
|
|
477
685
|
emit('load_failed', false, {
|
|
478
686
|
profile,
|
|
479
687
|
message: err?.message ?? 'unknown',
|
|
480
688
|
});
|
|
481
|
-
|
|
689
|
+
stored = null;
|
|
482
690
|
}
|
|
483
|
-
if (
|
|
484
|
-
const envelopeProtected = isCredEnvelopeActive();
|
|
485
|
-
_state = { kind: 'active',
|
|
691
|
+
if (stored) {
|
|
692
|
+
const envelopeProtected = stored.custody === 'software' && isCredEnvelopeActive();
|
|
693
|
+
_state = { kind: 'active', key: stored, profile, envelopeProtected };
|
|
694
|
+
trackActiveKeyFiles(profile);
|
|
486
695
|
emit('active', true, {
|
|
487
696
|
profile,
|
|
488
|
-
thumbprint:
|
|
697
|
+
thumbprint: stored.thumbprint,
|
|
698
|
+
custody: stored.custody,
|
|
489
699
|
envelopeProtected,
|
|
490
700
|
regenerated: false,
|
|
491
701
|
});
|
|
@@ -500,6 +710,9 @@ async function initDeviceIdentityLocked(profile, options = {}) {
|
|
|
500
710
|
const hasStoredKey = storedKeyMaterialExists(profile);
|
|
501
711
|
if (hasStoredKey && (!options.recoverUnreadable || independent)) {
|
|
502
712
|
_state = { kind: 'disabled', reason: 'unreadable_key' };
|
|
713
|
+
_unreadableSeen = independent
|
|
714
|
+
? null
|
|
715
|
+
: { profile, stamp: keyFilesStamp(profile), checkedAt: Date.now() };
|
|
503
716
|
emit('recovery_required', false, { profile, reason: 'stored_key_unreadable' });
|
|
504
717
|
return;
|
|
505
718
|
}
|
|
@@ -526,6 +739,7 @@ async function initDeviceIdentityLocked(profile, options = {}) {
|
|
|
526
739
|
persistResult = persistKeypair(profile, fresh);
|
|
527
740
|
}
|
|
528
741
|
catch (err) {
|
|
742
|
+
rethrowIfLiveStateRefusal(err);
|
|
529
743
|
_state = { kind: 'disabled', reason: 'persist_failed' };
|
|
530
744
|
emit('persist_failed', false, {
|
|
531
745
|
profile,
|
|
@@ -533,15 +747,30 @@ async function initDeviceIdentityLocked(profile, options = {}) {
|
|
|
533
747
|
});
|
|
534
748
|
return;
|
|
535
749
|
}
|
|
750
|
+
let freshKey;
|
|
751
|
+
try {
|
|
752
|
+
freshKey = softwareEd25519Handle(fresh);
|
|
753
|
+
}
|
|
754
|
+
catch (err) {
|
|
755
|
+
_state = { kind: 'disabled', reason: 'init_failed' };
|
|
756
|
+
emit('init_failed', false, {
|
|
757
|
+
profile,
|
|
758
|
+
reason: 'keygen_threw',
|
|
759
|
+
message: err?.message ?? 'unknown',
|
|
760
|
+
});
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
536
763
|
_state = {
|
|
537
764
|
kind: 'active',
|
|
538
|
-
|
|
765
|
+
key: freshKey,
|
|
539
766
|
profile,
|
|
540
767
|
envelopeProtected: persistResult.envelopeProtected,
|
|
541
768
|
};
|
|
769
|
+
trackActiveKeyFiles(profile);
|
|
542
770
|
emit('active', true, {
|
|
543
771
|
profile,
|
|
544
772
|
thumbprint: fresh.thumbprint,
|
|
773
|
+
custody: 'software',
|
|
545
774
|
envelopeProtected: persistResult.envelopeProtected,
|
|
546
775
|
regenerated: true,
|
|
547
776
|
});
|
|
@@ -558,13 +787,22 @@ export async function resetDeviceIdentity(opts) {
|
|
|
558
787
|
// Every location, not just the resolved one. A reset that cleared only the
|
|
559
788
|
// canonical copy would leave the pre-consolidation copy for the next init to
|
|
560
789
|
// adopt — a deleted device key that reappears.
|
|
561
|
-
|
|
790
|
+
//
|
|
791
|
+
// The retired copy an upgrade keeps is part of the identity: a logout that left
|
|
792
|
+
// it would leave a sealed device key behind the person asked to be rid of.
|
|
793
|
+
const targets = [
|
|
794
|
+
...[PUBLIC_KEY_FILENAME, PRIVATE_KEY_FILENAME].flatMap((name) => profileStateFilePathsToClear(opts.profile, name)),
|
|
795
|
+
...retiredKeyCopies(opts.profile, PUBLIC_KEY_FILENAME),
|
|
796
|
+
...retiredKeyCopies(opts.profile, PRIVATE_KEY_FILENAME),
|
|
797
|
+
pendingHardwareKeyPath(opts.profile),
|
|
798
|
+
];
|
|
562
799
|
await withDeviceIdentityLock(opts.profile, async () => {
|
|
563
800
|
for (const p of targets) {
|
|
564
801
|
try {
|
|
565
|
-
|
|
802
|
+
removeStateFile(p, `delete the device key file ${path.basename(p)}`);
|
|
566
803
|
}
|
|
567
804
|
catch (err) {
|
|
805
|
+
rethrowIfLiveStateRefusal(err);
|
|
568
806
|
if (err.code !== 'ENOENT') {
|
|
569
807
|
emit('clear_failed', false, {
|
|
570
808
|
profile: opts.profile,
|
|
@@ -577,9 +815,217 @@ export async function resetDeviceIdentity(opts) {
|
|
|
577
815
|
});
|
|
578
816
|
}
|
|
579
817
|
_state = { kind: 'uninit' };
|
|
818
|
+
_unreadableSeen = null;
|
|
819
|
+
_activeSeen = null;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Create a hardware key candidate if this machine can hold one and does not
|
|
823
|
+
* already. Never throws and never touches disk, so an interactive sign-in can
|
|
824
|
+
* call it unconditionally and fall back to the key it has on any answer but ok.
|
|
825
|
+
*/
|
|
826
|
+
export async function prepareHardwareKeyCandidate(profile) {
|
|
827
|
+
if (/^desktop-bg-[a-f0-9]{32}$/i.test(profile))
|
|
828
|
+
return { ok: false, reason: 'independent_profile' };
|
|
829
|
+
if (_state.kind === 'active' && _state.key.custody === 'hardware') {
|
|
830
|
+
return { ok: false, reason: 'already_hardware' };
|
|
831
|
+
}
|
|
832
|
+
const capability = await probeHardwareDeviceKey();
|
|
833
|
+
if (!capability.available)
|
|
834
|
+
return { ok: false, reason: 'unavailable', capability };
|
|
835
|
+
try {
|
|
836
|
+
const { record, handle } = await createHardwareDeviceKey();
|
|
837
|
+
return { ok: true, candidate: { key: handle, record } };
|
|
838
|
+
}
|
|
839
|
+
catch (err) {
|
|
840
|
+
emit('hardware_key_create_failed', false, {
|
|
841
|
+
profile,
|
|
842
|
+
...(err instanceof HardwareDeviceKeyError ? { hardwareFailure: err.code } : {}),
|
|
843
|
+
message: err?.message ?? 'unknown',
|
|
844
|
+
});
|
|
845
|
+
return { ok: false, reason: 'create_failed', message: err?.message ?? 'unknown' };
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
/** Sign a request with a candidate (the upgrade's sign-in proof). Null on failure, like `signServerRequest`. */
|
|
849
|
+
export async function signWithCandidate(candidate, opts) {
|
|
850
|
+
try {
|
|
851
|
+
return await signProofWith(candidate.key, opts);
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
emit('sign_failed', false, {
|
|
855
|
+
thumbprint: candidate.key.thumbprint,
|
|
856
|
+
custody: 'hardware',
|
|
857
|
+
candidate: true,
|
|
858
|
+
method: opts.method,
|
|
859
|
+
...(err instanceof HardwareDeviceKeyError ? { hardwareFailure: err.code } : {}),
|
|
860
|
+
message: err?.message ?? 'unknown',
|
|
861
|
+
});
|
|
862
|
+
return null;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
/** The retired copies of a profile's keys: `<file>.retired.<stamp>`, newest last. */
|
|
866
|
+
function retiredKeyCopies(profile, filename) {
|
|
867
|
+
const dir = path.dirname(filename === PUBLIC_KEY_FILENAME ? publicKeyPath(profile) : privateKeyPath(profile));
|
|
868
|
+
const prefix = `${filename}${RETIRED_COPY_SUFFIX}.`;
|
|
869
|
+
try {
|
|
870
|
+
return fs
|
|
871
|
+
.readdirSync(dir)
|
|
872
|
+
.filter((name) => name.startsWith(prefix))
|
|
873
|
+
.sort()
|
|
874
|
+
.map((name) => path.join(dir, name));
|
|
875
|
+
}
|
|
876
|
+
catch {
|
|
877
|
+
return [];
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
/**
|
|
881
|
+
* Keep the software key an upgrade is about to replace, as a copy. The pin the
|
|
882
|
+
* server holds moves with the sign-in, but a bug in that move (or a server that
|
|
883
|
+
* accepted the sign-in and did not re-pin) would leave a daemon whose every
|
|
884
|
+
* refresh is refused as a key mismatch, with no way back. One retired pair is
|
|
885
|
+
* kept (the newest) and a logout deletes it with the rest of the profile's keys.
|
|
886
|
+
* Best effort: a copy that cannot be written must not block the upgrade of a
|
|
887
|
+
* key that is, at this point, already proven by the server.
|
|
888
|
+
*/
|
|
889
|
+
function retireSoftwareKey(profile) {
|
|
890
|
+
try {
|
|
891
|
+
const priv = privateKeyPath(profile);
|
|
892
|
+
const raw = fs.readFileSync(priv, 'utf-8');
|
|
893
|
+
if (parseHardwareKeyRecord(raw))
|
|
894
|
+
return; // already hardware: nothing software to keep
|
|
895
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
896
|
+
atomicWrite0600(`${priv}${RETIRED_COPY_SUFFIX}.${stamp}`, raw, `keep the replaced software device key for profile ${profile}`);
|
|
897
|
+
try {
|
|
898
|
+
atomicWrite0600(`${publicKeyPath(profile)}${RETIRED_COPY_SUFFIX}.${stamp}`, fs.readFileSync(publicKeyPath(profile), 'utf-8'), `keep the replaced software device public key for profile ${profile}`);
|
|
899
|
+
}
|
|
900
|
+
catch (err) {
|
|
901
|
+
rethrowIfLiveStateRefusal(err);
|
|
902
|
+
/* the private copy is the one that matters; the public key derives from it */
|
|
903
|
+
}
|
|
904
|
+
for (const filename of [PRIVATE_KEY_FILENAME, PUBLIC_KEY_FILENAME]) {
|
|
905
|
+
for (const stale of retiredKeyCopies(profile, filename).slice(0, -1)) {
|
|
906
|
+
try {
|
|
907
|
+
removeStateFile(stale, `prune a retired device key copy for profile ${profile}`);
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
rethrowIfLiveStateRefusal(err);
|
|
911
|
+
/* a stale copy that will not go costs a few hundred bytes */
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
catch (err) {
|
|
917
|
+
emit('retire_software_key_failed', false, { profile, message: err?.message ?? 'unknown' });
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Make a candidate this profile's device identity. Call it only after the
|
|
922
|
+
* server has accepted a sign-in proven with `candidate`: until then the software
|
|
923
|
+
* key is the identity the server pins, and replacing it early is the failure
|
|
924
|
+
* this API exists to prevent.
|
|
925
|
+
*
|
|
926
|
+
* Under the identity lock, in the order that survives a crash: retire the old
|
|
927
|
+
* key, write the hardware record (authoritative), then the public file, then
|
|
928
|
+
* clear the enrollment marker (it records the OLD thumbprint and would otherwise
|
|
929
|
+
* short-circuit every later enrollment as already done).
|
|
930
|
+
*/
|
|
931
|
+
export async function commitDeviceKeyCandidate(profile, candidate) {
|
|
932
|
+
if (_initializing)
|
|
933
|
+
await _initializing;
|
|
934
|
+
await withDeviceIdentityLock(profile, async () => {
|
|
935
|
+
commitRecordLocked(profile, candidate.record, candidate.key);
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
/** The commit itself; the caller holds the identity lock. */
|
|
939
|
+
function commitRecordLocked(profile, record, key) {
|
|
940
|
+
retireSoftwareKey(profile);
|
|
941
|
+
persistHardwareKey(profile, record);
|
|
942
|
+
clearEnrollmentMarker(profile);
|
|
943
|
+
removePendingHardwareKey(profile);
|
|
944
|
+
_state = { kind: 'active', key, profile, envelopeProtected: false };
|
|
945
|
+
_unreadableSeen = null;
|
|
946
|
+
trackActiveKeyFiles(profile);
|
|
947
|
+
emit('upgraded_to_hardware', true, {
|
|
948
|
+
profile,
|
|
949
|
+
thumbprint: key.thumbprint,
|
|
950
|
+
custody: 'hardware',
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
// ---------------------------------------------------------------------------
|
|
954
|
+
// A candidate that has to outlive its process
|
|
955
|
+
// ---------------------------------------------------------------------------
|
|
956
|
+
//
|
|
957
|
+
// The desktop signs the sign-in's proof in `skrrd device-proof` (one process),
|
|
958
|
+
// POSTs it from the app (another), and only then knows whether the server
|
|
959
|
+
// accepted it. The candidate therefore has to be settled by a LATER invocation,
|
|
960
|
+
// so it is parked in a pending file until then. That costs nothing in safety:
|
|
961
|
+
// the pending file holds a Secure Enclave blob, which is useless anywhere but
|
|
962
|
+
// this machine, and an abandoned one is never read as an identity (only the two
|
|
963
|
+
// anchor files are), expires on its own, and is deleted by the next attempt and
|
|
964
|
+
// by a logout.
|
|
965
|
+
const PENDING_HARDWARE_FILENAME = 'device-key.pending';
|
|
966
|
+
/** A sign-in that takes longer than this has been abandoned; its pending key is not committed. */
|
|
967
|
+
export const PENDING_HARDWARE_KEY_MAX_AGE_MS = 10 * 60 * 1000;
|
|
968
|
+
function pendingHardwareKeyPath(profile) {
|
|
969
|
+
return path.join(profileConfigDir(profile), PENDING_HARDWARE_FILENAME);
|
|
970
|
+
}
|
|
971
|
+
function removePendingHardwareKey(profile) {
|
|
972
|
+
try {
|
|
973
|
+
removeStateFile(pendingHardwareKeyPath(profile), `remove the pending hardware key for profile ${profile}`);
|
|
974
|
+
}
|
|
975
|
+
catch (err) {
|
|
976
|
+
rethrowIfLiveStateRefusal(err);
|
|
977
|
+
if (err.code !== 'ENOENT') {
|
|
978
|
+
emit('pending_hardware_key_clear_failed', false, { profile, message: err?.message ?? 'unknown' });
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
/** Park a candidate so a later process can commit or discard it. Replaces any earlier one. */
|
|
983
|
+
export async function persistPendingHardwareKey(profile, candidate) {
|
|
984
|
+
await withDeviceIdentityLock(profile, async () => {
|
|
985
|
+
atomicWrite0600(pendingHardwareKeyPath(profile), serializeHardwareKeyRecord(candidate.record), `park a pending hardware key for profile ${profile}`);
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Make the parked candidate this profile's identity, if it is the one the caller
|
|
990
|
+
* proved the sign-in with (`thumbprint`) and it is not stale. Anything else
|
|
991
|
+
* commits nothing: the software key stays the identity, and the answer says why.
|
|
992
|
+
*/
|
|
993
|
+
export async function commitPendingHardwareKey(profile, thumbprint) {
|
|
994
|
+
if (_initializing)
|
|
995
|
+
await _initializing;
|
|
996
|
+
return withDeviceIdentityLock(profile, async () => {
|
|
997
|
+
const file = pendingHardwareKeyPath(profile);
|
|
998
|
+
let raw;
|
|
999
|
+
let modifiedAt;
|
|
1000
|
+
try {
|
|
1001
|
+
raw = fs.readFileSync(file, 'utf-8');
|
|
1002
|
+
modifiedAt = fs.statSync(file).mtimeMs;
|
|
1003
|
+
}
|
|
1004
|
+
catch {
|
|
1005
|
+
return 'no_pending';
|
|
1006
|
+
}
|
|
1007
|
+
const record = parseHardwareKeyRecord(raw);
|
|
1008
|
+
if (!record)
|
|
1009
|
+
return 'unreadable';
|
|
1010
|
+
if (Date.now() - modifiedAt > PENDING_HARDWARE_KEY_MAX_AGE_MS) {
|
|
1011
|
+
removePendingHardwareKey(profile);
|
|
1012
|
+
return 'expired';
|
|
1013
|
+
}
|
|
1014
|
+
if (jwkThumbprint(record.publicJwk) !== thumbprint)
|
|
1015
|
+
return 'mismatch';
|
|
1016
|
+
commitRecordLocked(profile, record, hardwareDeviceKeyHandle(record));
|
|
1017
|
+
return 'committed';
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
/** Drop the parked candidate (the sign-in failed or was refused). Idempotent. */
|
|
1021
|
+
export async function discardPendingHardwareKey(profile) {
|
|
1022
|
+
await withDeviceIdentityLock(profile, async () => {
|
|
1023
|
+
removePendingHardwareKey(profile);
|
|
1024
|
+
});
|
|
580
1025
|
}
|
|
581
1026
|
/** True iff init has completed and a keypair is in memory. */
|
|
582
1027
|
export function isDeviceIdentityActive() {
|
|
1028
|
+
reloadIfRecovered();
|
|
583
1029
|
return _state.kind === 'active';
|
|
584
1030
|
}
|
|
585
1031
|
/**
|
|
@@ -588,42 +1034,85 @@ export function isDeviceIdentityActive() {
|
|
|
588
1034
|
* on the in-flight request.
|
|
589
1035
|
*/
|
|
590
1036
|
export function getDevicePublicKey() {
|
|
1037
|
+
reloadIfRecovered();
|
|
591
1038
|
if (_state.kind !== 'active')
|
|
592
1039
|
return null;
|
|
593
|
-
return {
|
|
1040
|
+
return {
|
|
1041
|
+
jwk: _state.key.publicJwk,
|
|
1042
|
+
thumbprint: _state.key.thumbprint,
|
|
1043
|
+
alg: _state.key.alg,
|
|
1044
|
+
custody: _state.key.custody,
|
|
1045
|
+
};
|
|
594
1046
|
}
|
|
595
1047
|
/**
|
|
596
1048
|
* Operator-readable summary of device-identity state. Used by
|
|
597
1049
|
* `oversky status` and CLI debug commands. Never includes the private key.
|
|
598
1050
|
*/
|
|
599
1051
|
export function describeDeviceIdentityState() {
|
|
1052
|
+
reloadIfRecovered();
|
|
600
1053
|
if (_state.kind === 'disabled') {
|
|
601
1054
|
return { kind: 'disabled', reason: _state.reason };
|
|
602
1055
|
}
|
|
603
1056
|
if (_state.kind === 'active') {
|
|
604
1057
|
return {
|
|
605
1058
|
kind: 'active',
|
|
606
|
-
thumbprint: _state.
|
|
1059
|
+
thumbprint: _state.key.thumbprint,
|
|
607
1060
|
profile: _state.profile,
|
|
608
1061
|
envelopeProtected: _state.envelopeProtected,
|
|
1062
|
+
alg: _state.key.alg,
|
|
1063
|
+
custody: _state.key.custody,
|
|
1064
|
+
canSign: _state.key.custody === 'software' ? true : resolveHardwareDeviceKeyHelper() !== null,
|
|
609
1065
|
};
|
|
610
1066
|
}
|
|
611
1067
|
return { kind: 'uninit' };
|
|
612
1068
|
}
|
|
1069
|
+
/**
|
|
1070
|
+
* What a profile's device key IS, read from its plaintext public record alone. For
|
|
1071
|
+
* `status`, which is a different process from the daemon and may run while the
|
|
1072
|
+
* daemon is stopped: it opens nothing, needs no envelope key and writes nothing, so
|
|
1073
|
+
* it answers even when the private half is unreadable. Not evidence the key can be
|
|
1074
|
+
* used (only `describeDeviceIdentityState` in the process that loaded it says that);
|
|
1075
|
+
* evidence of what kind of key the profile has, which nothing else showed.
|
|
1076
|
+
*/
|
|
1077
|
+
export function describeStoredDeviceKey(profile) {
|
|
1078
|
+
try {
|
|
1079
|
+
const parsed = JSON.parse(fs.readFileSync(publicKeyPath(profile), 'utf-8'));
|
|
1080
|
+
if (!isDevicePublicJwk(parsed.publicJwk))
|
|
1081
|
+
return { stored: false };
|
|
1082
|
+
const alg = parsed.publicJwk.kty === 'EC' ? 'ES256' : 'EdDSA';
|
|
1083
|
+
const custody = alg === 'ES256' ? 'hardware' : 'software';
|
|
1084
|
+
return {
|
|
1085
|
+
stored: true,
|
|
1086
|
+
alg,
|
|
1087
|
+
custody,
|
|
1088
|
+
thumbprint: jwkThumbprint(parsed.publicJwk),
|
|
1089
|
+
canSign: custody === 'software' ? true : resolveHardwareDeviceKeyHelper() !== null,
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
return { stored: false };
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
613
1096
|
/**
|
|
614
1097
|
* Sign an outgoing request with the device key. Returns the JWS Compact
|
|
615
1098
|
* Serialization, or null when device identity is not active. NEVER
|
|
616
1099
|
* throws — a failed sign returns null and emits a telemetry event so
|
|
617
1100
|
* the caller can fall back to bearer-only auth without an outage.
|
|
618
1101
|
*
|
|
1102
|
+
* Asynchronous because a hardware key signs in a helper process. A software key
|
|
1103
|
+
* resolves on the next microtask, so a caller that awaits pays nothing it can
|
|
1104
|
+
* measure; a caller that could not await could not have a hardware key.
|
|
1105
|
+
*
|
|
619
1106
|
* Shape matches `DeviceProofSigner` from `./refresh.ts` so the function
|
|
620
1107
|
* passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
|
|
621
1108
|
*/
|
|
622
|
-
export function signServerRequest(opts) {
|
|
623
|
-
|
|
1109
|
+
export async function signServerRequest(opts) {
|
|
1110
|
+
reloadIfRecovered();
|
|
1111
|
+
const state = _state;
|
|
1112
|
+
if (state.kind !== 'active')
|
|
624
1113
|
return null;
|
|
625
1114
|
try {
|
|
626
|
-
return
|
|
1115
|
+
return await signProofWith(state.key, {
|
|
627
1116
|
method: opts.method,
|
|
628
1117
|
url: opts.url,
|
|
629
1118
|
refreshToken: opts.refreshToken,
|
|
@@ -632,9 +1121,13 @@ export function signServerRequest(opts) {
|
|
|
632
1121
|
}
|
|
633
1122
|
catch (err) {
|
|
634
1123
|
emit('sign_failed', false, {
|
|
635
|
-
profile:
|
|
636
|
-
thumbprint:
|
|
1124
|
+
profile: state.profile,
|
|
1125
|
+
thumbprint: state.key.thumbprint,
|
|
1126
|
+
custody: state.key.custody,
|
|
637
1127
|
method: opts.method,
|
|
1128
|
+
// A hardware failure names its cause (helper missing, timed out, key
|
|
1129
|
+
// mismatch) so a status line can say what to do about it.
|
|
1130
|
+
...(err instanceof HardwareDeviceKeyError ? { hardwareFailure: err.code } : {}),
|
|
638
1131
|
message: err?.message ?? 'unknown',
|
|
639
1132
|
});
|
|
640
1133
|
return null;
|
|
@@ -657,9 +1150,10 @@ export function __enrollmentBackoffPathForTest(profile) {
|
|
|
657
1150
|
export function clearEnrollmentMarker(profile) {
|
|
658
1151
|
for (const markerPath of [enrollmentMarkerPath(profile), enrollmentBackoffPath(profile)]) {
|
|
659
1152
|
try {
|
|
660
|
-
|
|
1153
|
+
removeStateFile(markerPath, `delete the enrollment file ${path.basename(markerPath)}`);
|
|
661
1154
|
}
|
|
662
1155
|
catch (err) {
|
|
1156
|
+
rethrowIfLiveStateRefusal(err);
|
|
663
1157
|
if (err.code !== 'ENOENT') {
|
|
664
1158
|
emit('enrollment_marker_clear_failed', false, {
|
|
665
1159
|
profile,
|
|
@@ -691,7 +1185,7 @@ export async function enrollWithServer(opts) {
|
|
|
691
1185
|
return { enrolled: false, reason: 'not_active' };
|
|
692
1186
|
}
|
|
693
1187
|
const profile = _state.profile;
|
|
694
|
-
const
|
|
1188
|
+
const key = _state.key;
|
|
695
1189
|
const markerPath = enrollmentMarkerPath(profile);
|
|
696
1190
|
const backoffPath = enrollmentBackoffPath(profile);
|
|
697
1191
|
try {
|
|
@@ -707,33 +1201,48 @@ export async function enrollWithServer(opts) {
|
|
|
707
1201
|
const retryAfter = Date.parse(raw.retryAfter || '');
|
|
708
1202
|
if (raw.serverUrl === serverUrl &&
|
|
709
1203
|
raw.machineUuid === machineUuid &&
|
|
710
|
-
raw.thumbprint ===
|
|
1204
|
+
raw.thumbprint === key.thumbprint &&
|
|
711
1205
|
Number.isFinite(retryAfter) &&
|
|
712
1206
|
retryAfter > Date.now()) {
|
|
713
1207
|
return { enrolled: false, reason: 'backoff_active' };
|
|
714
1208
|
}
|
|
715
|
-
|
|
1209
|
+
removeStateFile(backoffPath, 'delete the stale enrollment backoff');
|
|
716
1210
|
}
|
|
717
1211
|
catch (err) {
|
|
718
1212
|
if (err.code !== 'ENOENT') {
|
|
719
|
-
// A corrupt/stale marker must never suppress enrollment indefinitely.
|
|
1213
|
+
// A corrupt/stale marker must never suppress enrollment indefinitely. A refusal from the
|
|
1214
|
+
// first delete above lands here too, and is asked again by this one, so this is the
|
|
1215
|
+
// single place it is rethrown.
|
|
720
1216
|
try {
|
|
721
|
-
|
|
1217
|
+
removeStateFile(backoffPath, 'delete the corrupt enrollment backoff');
|
|
722
1218
|
}
|
|
723
|
-
catch {
|
|
1219
|
+
catch (clearErr) {
|
|
1220
|
+
rethrowIfLiveStateRefusal(clearErr);
|
|
724
1221
|
/* best-effort */
|
|
725
1222
|
}
|
|
726
1223
|
}
|
|
727
1224
|
}
|
|
728
1225
|
const base = serverUrl.replace(/\/+$/, '');
|
|
729
1226
|
const htu = `${base}/api/daemon-resources/enroll-device-key`;
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
url: htu
|
|
733
|
-
}
|
|
1227
|
+
let proofJws;
|
|
1228
|
+
try {
|
|
1229
|
+
proofJws = await signProofWith(key, { method: 'POST', url: htu });
|
|
1230
|
+
}
|
|
1231
|
+
catch (err) {
|
|
1232
|
+
// A key that cannot sign (a hardware key whose helper is missing) is not a
|
|
1233
|
+
// network failure and not the server's to hear about.
|
|
1234
|
+
const message = err?.message ?? 'unknown';
|
|
1235
|
+
emit('enrollment_sign_failed', false, { profile, thumbprint: key.thumbprint, custody: key.custody, message });
|
|
1236
|
+
return { enrolled: false, reason: 'not_active', message };
|
|
1237
|
+
}
|
|
1238
|
+
// `publicJwkX` is the original wire field and only ever described an Ed25519
|
|
1239
|
+
// point, so an ES256 key leaves it out rather than send a coordinate an older
|
|
1240
|
+
// server would read as the wrong curve. `publicJwk` is the whole key; a server
|
|
1241
|
+
// that understands it prefers it, and the proof's own header carries it too.
|
|
734
1242
|
const body = JSON.stringify({
|
|
735
1243
|
machineUuid,
|
|
736
|
-
publicJwkX:
|
|
1244
|
+
...(key.publicJwk.kty === 'OKP' ? { publicJwkX: key.publicJwk.x } : {}),
|
|
1245
|
+
publicJwk: key.publicJwk,
|
|
737
1246
|
proofJws,
|
|
738
1247
|
});
|
|
739
1248
|
// 10-second bound — enrollment is best-effort at startup.
|
|
@@ -756,7 +1265,7 @@ export async function enrollWithServer(opts) {
|
|
|
756
1265
|
const message = err?.message ?? 'unknown';
|
|
757
1266
|
emit('enrollment_network_error', false, {
|
|
758
1267
|
profile,
|
|
759
|
-
thumbprint:
|
|
1268
|
+
thumbprint: key.thumbprint,
|
|
760
1269
|
serverUrl,
|
|
761
1270
|
message,
|
|
762
1271
|
});
|
|
@@ -807,7 +1316,7 @@ export async function enrollWithServer(opts) {
|
|
|
807
1316
|
}
|
|
808
1317
|
const pinnedToOtherKey = response.status === 409 &&
|
|
809
1318
|
enrolledThumbprint !== null &&
|
|
810
|
-
enrolledThumbprint !==
|
|
1319
|
+
enrolledThumbprint !== key.thumbprint;
|
|
811
1320
|
const keyPinnedElsewhere = response.status === 409 && conflictCode === 'ENROLLMENT_DUPLICATE_KEY';
|
|
812
1321
|
const isBenignRejection = (response.status === 404 && responseText.includes('DAEMON_NOT_FOUND')) ||
|
|
813
1322
|
(response.status === 403 && responseText.includes('OPAQUE_AUTH_REFUSED')) ||
|
|
@@ -815,7 +1324,7 @@ export async function enrollWithServer(opts) {
|
|
|
815
1324
|
if (pinnedToOtherKey) {
|
|
816
1325
|
emit('enrollment_key_mismatch', false, {
|
|
817
1326
|
profile,
|
|
818
|
-
thumbprint:
|
|
1327
|
+
thumbprint: key.thumbprint,
|
|
819
1328
|
enrolledThumbprint,
|
|
820
1329
|
serverUrl,
|
|
821
1330
|
message: 'this machine is registered with a different device key than this profile signs ' +
|
|
@@ -828,11 +1337,12 @@ export async function enrollWithServer(opts) {
|
|
|
828
1337
|
atomicWrite0600(backoffPath, JSON.stringify({
|
|
829
1338
|
serverUrl,
|
|
830
1339
|
machineUuid,
|
|
831
|
-
thumbprint:
|
|
1340
|
+
thumbprint: key.thumbprint,
|
|
832
1341
|
retryAfter: new Date(Date.now() + DAEMON_NOT_FOUND_BACKOFF_MS).toISOString(),
|
|
833
|
-
}));
|
|
1342
|
+
}), 'write the enrollment backoff');
|
|
834
1343
|
}
|
|
835
1344
|
catch (err) {
|
|
1345
|
+
rethrowIfLiveStateRefusal(err);
|
|
836
1346
|
emit('enrollment_backoff_write_failed', false, {
|
|
837
1347
|
profile,
|
|
838
1348
|
message: err?.message ?? 'unknown',
|
|
@@ -841,7 +1351,7 @@ export async function enrollWithServer(opts) {
|
|
|
841
1351
|
}
|
|
842
1352
|
emit('enrollment_rejected', false, {
|
|
843
1353
|
profile,
|
|
844
|
-
thumbprint:
|
|
1354
|
+
thumbprint: key.thumbprint,
|
|
845
1355
|
serverUrl,
|
|
846
1356
|
status: response.status,
|
|
847
1357
|
body: responseText,
|
|
@@ -856,25 +1366,28 @@ export async function enrollWithServer(opts) {
|
|
|
856
1366
|
const marker = JSON.stringify({
|
|
857
1367
|
enrolledAt: new Date().toISOString(),
|
|
858
1368
|
serverUrl,
|
|
859
|
-
thumbprint:
|
|
1369
|
+
thumbprint: key.thumbprint,
|
|
860
1370
|
machineUuid,
|
|
861
1371
|
});
|
|
862
1372
|
try {
|
|
863
|
-
atomicWrite0600(markerPath, marker);
|
|
1373
|
+
atomicWrite0600(markerPath, marker, 'write the enrollment marker');
|
|
864
1374
|
}
|
|
865
1375
|
catch (err) {
|
|
1376
|
+
rethrowIfLiveStateRefusal(err);
|
|
866
1377
|
// Non-fatal — server enrolled us; we may re-POST next run, which
|
|
867
1378
|
// the server handles idempotently.
|
|
868
1379
|
emit('enrollment_marker_write_failed', false, {
|
|
869
1380
|
profile,
|
|
870
|
-
thumbprint:
|
|
1381
|
+
thumbprint: key.thumbprint,
|
|
871
1382
|
message: err?.message ?? 'unknown',
|
|
872
1383
|
});
|
|
873
1384
|
}
|
|
874
1385
|
try {
|
|
875
|
-
|
|
1386
|
+
removeStateFile(backoffPath, 'delete the enrollment backoff');
|
|
876
1387
|
}
|
|
877
1388
|
catch (err) {
|
|
1389
|
+
// Not a place a refusal can arise: the marker written just above lives in the same
|
|
1390
|
+
// directory, and a refusal there has already ended this function.
|
|
878
1391
|
if (err.code !== 'ENOENT') {
|
|
879
1392
|
emit('enrollment_backoff_clear_failed', false, {
|
|
880
1393
|
profile,
|
|
@@ -884,8 +1397,8 @@ export async function enrollWithServer(opts) {
|
|
|
884
1397
|
}
|
|
885
1398
|
emit('enrolled_with_server', true, {
|
|
886
1399
|
profile,
|
|
887
|
-
thumbprint:
|
|
1400
|
+
thumbprint: key.thumbprint,
|
|
888
1401
|
serverUrl,
|
|
889
1402
|
});
|
|
890
|
-
return { enrolled: true, thumbprint:
|
|
1403
|
+
return { enrolled: true, thumbprint: key.thumbprint };
|
|
891
1404
|
}
|