@skrr-ai/cli 0.1.123 → 0.1.125
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 +26 -0
- package/dist/commands/agents/chat.js +48 -10
- 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/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/generated/agent-guides.json +19 -19
- package/dist/lib/agentic-stream.js +15 -1
- package/dist/lib/auth-core-init.d.ts +15 -2
- package/dist/lib/auth-core-init.js +47 -6
- package/dist/lib/command-hygiene.js +30 -1
- package/dist/lib/daemonBrokerRefusal.js +5 -1
- package/dist/lib/keychain.js +41 -4
- 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 +13 -28
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.d.ts +133 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/deviceIdentityBridge.js +387 -90
- 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 +102 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/index.js +6 -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 +8 -0
- 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 +11 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.d.ts +43 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/masterKekService.js +33 -3
- 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/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 +12 -10
- 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/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 +15 -37
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.d.ts +133 -4
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/deviceIdentityBridge.js +536 -101
- 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 +13 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/index.js +1 -1
- 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 +15 -0
- 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 +20 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.d.ts +43 -3
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/masterKekService.js +104 -8
- 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/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 +14 -10
- 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/package.json +11 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +1094 -1067
- package/oclif.manifest.json +16 -3
- 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';
|
|
@@ -85,12 +106,36 @@ function keyFilesStamp(profile) {
|
|
|
85
106
|
return `${part(publicKeyPath(profile))}|${part(privateKeyPath(profile))}`;
|
|
86
107
|
}
|
|
87
108
|
/**
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
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.
|
|
92
136
|
*/
|
|
93
137
|
function reloadIfRecovered() {
|
|
138
|
+
reloadIfActiveKeyChanged();
|
|
94
139
|
if (_state.kind !== 'disabled' || _state.reason !== 'unreadable_key')
|
|
95
140
|
return;
|
|
96
141
|
const seen = _unreadableSeen;
|
|
@@ -110,23 +155,69 @@ function reloadIfRecovered() {
|
|
|
110
155
|
if (stamp === seen.stamp)
|
|
111
156
|
return;
|
|
112
157
|
seen.stamp = stamp;
|
|
113
|
-
let
|
|
158
|
+
let key = null;
|
|
114
159
|
try {
|
|
115
|
-
|
|
160
|
+
key = loadStoredKey(seen.profile);
|
|
116
161
|
}
|
|
117
162
|
catch {
|
|
118
|
-
|
|
163
|
+
key = null;
|
|
119
164
|
}
|
|
120
|
-
if (!
|
|
165
|
+
if (!key)
|
|
121
166
|
return;
|
|
122
167
|
_state = {
|
|
123
168
|
kind: 'active',
|
|
124
|
-
|
|
169
|
+
key,
|
|
125
170
|
profile: seen.profile,
|
|
126
|
-
envelopeProtected: isCredEnvelopeActive(),
|
|
171
|
+
envelopeProtected: key.custody === 'software' && isCredEnvelopeActive(),
|
|
127
172
|
};
|
|
128
173
|
_unreadableSeen = null;
|
|
129
|
-
|
|
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
|
+
});
|
|
130
221
|
}
|
|
131
222
|
/**
|
|
132
223
|
* The CLI, daemon, and desktop's short-lived signer resolve the same canonical
|
|
@@ -141,7 +232,7 @@ function reloadIfRecovered() {
|
|
|
141
232
|
*/
|
|
142
233
|
async function withDeviceIdentityLock(profile, body) {
|
|
143
234
|
const dir = profileStateDir(profile);
|
|
144
|
-
|
|
235
|
+
ensureStateDir(dir, `take the device identity lock for profile ${profile}`);
|
|
145
236
|
const release = await lockfile.lock(dir, {
|
|
146
237
|
realpath: false,
|
|
147
238
|
lockfilePath: path.join(dir, '.device-identity.lock'),
|
|
@@ -210,33 +301,6 @@ function enrollmentMarkerPath(profile) {
|
|
|
210
301
|
function enrollmentBackoffPath(profile) {
|
|
211
302
|
return path.join(profileConfigDir(profile), ENROLLMENT_BACKOFF_FILENAME);
|
|
212
303
|
}
|
|
213
|
-
function atomicWrite0600(target, value) {
|
|
214
|
-
const dir = path.dirname(target);
|
|
215
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
216
|
-
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
217
|
-
const buf = typeof value === 'string' ? Buffer.from(value, 'utf-8') : value;
|
|
218
|
-
const fd = fs.openSync(tmp, 'w', 0o600);
|
|
219
|
-
try {
|
|
220
|
-
fs.writeSync(fd, buf);
|
|
221
|
-
fs.fsyncSync(fd);
|
|
222
|
-
}
|
|
223
|
-
finally {
|
|
224
|
-
fs.closeSync(fd);
|
|
225
|
-
}
|
|
226
|
-
fs.renameSync(tmp, target);
|
|
227
|
-
try {
|
|
228
|
-
const dirFd = fs.openSync(dir, 'r');
|
|
229
|
-
try {
|
|
230
|
-
fs.fsyncSync(dirFd);
|
|
231
|
-
}
|
|
232
|
-
finally {
|
|
233
|
-
fs.closeSync(dirFd);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
catch {
|
|
237
|
-
/* best-effort; some platforms reject dir fsync */
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
304
|
function emit(eventName, success, metadata, opts = {}) {
|
|
241
305
|
emitAuthTelemetry({
|
|
242
306
|
timestamp: new Date().toISOString(),
|
|
@@ -255,17 +319,49 @@ function emit(eventName, success, metadata, opts = {}) {
|
|
|
255
319
|
}
|
|
256
320
|
}
|
|
257
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
|
+
}
|
|
258
359
|
/**
|
|
259
360
|
* Persist a freshly-generated keypair. Public goes plaintext; private
|
|
260
361
|
* goes through `maybeEncryptForWrite` so L12 wraps it when active.
|
|
261
362
|
*/
|
|
262
363
|
function persistKeypair(profile, kp) {
|
|
263
|
-
|
|
264
|
-
publicJwk: kp.publicJwk,
|
|
265
|
-
thumbprint: kp.thumbprint,
|
|
266
|
-
createdAt: new Date().toISOString(),
|
|
267
|
-
});
|
|
268
|
-
atomicWrite0600(publicKeyPath(profile), publicPayload);
|
|
364
|
+
writePublicKeyRecord(profile, kp.publicJwk);
|
|
269
365
|
// Use the always-fail-open variant — device-identity wants the private
|
|
270
366
|
// key persisted even when the L12 envelope path is inactive (today's
|
|
271
367
|
// default on every platform). The daemon's `credEnvelopeFailClosed`
|
|
@@ -273,7 +369,7 @@ function persistKeypair(profile, kp) {
|
|
|
273
369
|
const privatePlaintext = JSON.stringify(kp.privateJwk);
|
|
274
370
|
const wireForm = wrapIfActiveOrPassthrough(privatePlaintext);
|
|
275
371
|
const envelopeProtected = wireForm !== privatePlaintext;
|
|
276
|
-
atomicWrite0600(privateKeyPath(profile), wireForm);
|
|
372
|
+
atomicWrite0600(privateKeyPath(profile), wireForm, `write the device private key for profile ${profile}`);
|
|
277
373
|
emit('keypair_persisted', true, {
|
|
278
374
|
profile,
|
|
279
375
|
thumbprint: kp.thumbprint,
|
|
@@ -326,11 +422,17 @@ function readIndependentIdentityFile(file, sealed) {
|
|
|
326
422
|
}
|
|
327
423
|
}
|
|
328
424
|
/**
|
|
329
|
-
* Load a previously-persisted
|
|
330
|
-
* 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
|
|
331
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.
|
|
332
434
|
*/
|
|
333
|
-
function
|
|
435
|
+
function loadStoredKey(profile) {
|
|
334
436
|
const pubPath = publicKeyPath(profile);
|
|
335
437
|
const privPath = privateKeyPath(profile);
|
|
336
438
|
const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
|
|
@@ -344,6 +446,8 @@ function loadKeypair(profile) {
|
|
|
344
446
|
let storedThumbprint;
|
|
345
447
|
try {
|
|
346
448
|
const parsed = JSON.parse(pubRaw);
|
|
449
|
+
if (!isDevicePublicJwk(parsed.publicJwk))
|
|
450
|
+
throw new Error('public key is not a device key');
|
|
347
451
|
publicJwk = parsed.publicJwk;
|
|
348
452
|
storedThumbprint = parsed.thumbprint;
|
|
349
453
|
}
|
|
@@ -354,7 +458,32 @@ function loadKeypair(profile) {
|
|
|
354
458
|
});
|
|
355
459
|
return null;
|
|
356
460
|
}
|
|
357
|
-
|
|
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
|
+
});
|
|
358
487
|
if (decryptResult.plaintext === null) {
|
|
359
488
|
emit('private_key_decrypt_failed', false, { profile });
|
|
360
489
|
return null;
|
|
@@ -372,8 +501,10 @@ function loadKeypair(profile) {
|
|
|
372
501
|
return null;
|
|
373
502
|
}
|
|
374
503
|
// public.x must match private.x — any mismatch indicates a partial
|
|
375
|
-
// write race, manual edit, or attacker swapping one of the files.
|
|
376
|
-
|
|
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) {
|
|
377
508
|
emit('keypair_inconsistent', false, { profile });
|
|
378
509
|
return null;
|
|
379
510
|
}
|
|
@@ -387,11 +518,16 @@ function loadKeypair(profile) {
|
|
|
387
518
|
recomputed,
|
|
388
519
|
});
|
|
389
520
|
}
|
|
390
|
-
|
|
391
|
-
publicJwk,
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
+
}
|
|
395
531
|
}
|
|
396
532
|
/**
|
|
397
533
|
* Identity of a device-identity directory: the thumbprint of the PLAINTEXT
|
|
@@ -405,7 +541,7 @@ function loadKeypair(profile) {
|
|
|
405
541
|
function deviceIdentityOf(dir) {
|
|
406
542
|
try {
|
|
407
543
|
const parsed = JSON.parse(fs.readFileSync(path.join(dir, PUBLIC_KEY_FILENAME), 'utf-8'));
|
|
408
|
-
if (!parsed.publicJwk
|
|
544
|
+
if (!isDevicePublicJwk(parsed.publicJwk))
|
|
409
545
|
return null;
|
|
410
546
|
return jwkThumbprint(parsed.publicJwk);
|
|
411
547
|
}
|
|
@@ -473,6 +609,7 @@ async function consolidateDeviceIdentity(profile) {
|
|
|
473
609
|
});
|
|
474
610
|
}
|
|
475
611
|
catch (err) {
|
|
612
|
+
rethrowIfLiveStateRefusal(err);
|
|
476
613
|
emit('path_consolidation.failed', false, {
|
|
477
614
|
profile,
|
|
478
615
|
message: err?.message ?? 'unknown',
|
|
@@ -515,6 +652,7 @@ export async function initDeviceIdentity(profile, options = {}) {
|
|
|
515
652
|
if (_state.kind === 'uninit')
|
|
516
653
|
await initDeviceIdentityLocked(profile, options);
|
|
517
654
|
}).catch((err) => {
|
|
655
|
+
rethrowIfLiveStateRefusal(err);
|
|
518
656
|
_state = { kind: 'disabled', reason: 'init_failed' };
|
|
519
657
|
emit('init_failed', false, {
|
|
520
658
|
profile,
|
|
@@ -539,23 +677,25 @@ async function initDeviceIdentityLocked(profile, options = {}) {
|
|
|
539
677
|
const independent = /^desktop-bg-[a-f0-9]{32}$/i.test(profile);
|
|
540
678
|
if (!independent)
|
|
541
679
|
await consolidateDeviceIdentity(profile);
|
|
542
|
-
let
|
|
680
|
+
let stored = null;
|
|
543
681
|
try {
|
|
544
|
-
|
|
682
|
+
stored = loadStoredKey(profile);
|
|
545
683
|
}
|
|
546
684
|
catch (err) {
|
|
547
685
|
emit('load_failed', false, {
|
|
548
686
|
profile,
|
|
549
687
|
message: err?.message ?? 'unknown',
|
|
550
688
|
});
|
|
551
|
-
|
|
689
|
+
stored = null;
|
|
552
690
|
}
|
|
553
|
-
if (
|
|
554
|
-
const envelopeProtected = isCredEnvelopeActive();
|
|
555
|
-
_state = { kind: 'active',
|
|
691
|
+
if (stored) {
|
|
692
|
+
const envelopeProtected = stored.custody === 'software' && isCredEnvelopeActive();
|
|
693
|
+
_state = { kind: 'active', key: stored, profile, envelopeProtected };
|
|
694
|
+
trackActiveKeyFiles(profile);
|
|
556
695
|
emit('active', true, {
|
|
557
696
|
profile,
|
|
558
|
-
thumbprint:
|
|
697
|
+
thumbprint: stored.thumbprint,
|
|
698
|
+
custody: stored.custody,
|
|
559
699
|
envelopeProtected,
|
|
560
700
|
regenerated: false,
|
|
561
701
|
});
|
|
@@ -599,6 +739,7 @@ async function initDeviceIdentityLocked(profile, options = {}) {
|
|
|
599
739
|
persistResult = persistKeypair(profile, fresh);
|
|
600
740
|
}
|
|
601
741
|
catch (err) {
|
|
742
|
+
rethrowIfLiveStateRefusal(err);
|
|
602
743
|
_state = { kind: 'disabled', reason: 'persist_failed' };
|
|
603
744
|
emit('persist_failed', false, {
|
|
604
745
|
profile,
|
|
@@ -606,15 +747,30 @@ async function initDeviceIdentityLocked(profile, options = {}) {
|
|
|
606
747
|
});
|
|
607
748
|
return;
|
|
608
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
|
+
}
|
|
609
763
|
_state = {
|
|
610
764
|
kind: 'active',
|
|
611
|
-
|
|
765
|
+
key: freshKey,
|
|
612
766
|
profile,
|
|
613
767
|
envelopeProtected: persistResult.envelopeProtected,
|
|
614
768
|
};
|
|
769
|
+
trackActiveKeyFiles(profile);
|
|
615
770
|
emit('active', true, {
|
|
616
771
|
profile,
|
|
617
772
|
thumbprint: fresh.thumbprint,
|
|
773
|
+
custody: 'software',
|
|
618
774
|
envelopeProtected: persistResult.envelopeProtected,
|
|
619
775
|
regenerated: true,
|
|
620
776
|
});
|
|
@@ -631,13 +787,22 @@ export async function resetDeviceIdentity(opts) {
|
|
|
631
787
|
// Every location, not just the resolved one. A reset that cleared only the
|
|
632
788
|
// canonical copy would leave the pre-consolidation copy for the next init to
|
|
633
789
|
// adopt — a deleted device key that reappears.
|
|
634
|
-
|
|
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
|
+
];
|
|
635
799
|
await withDeviceIdentityLock(opts.profile, async () => {
|
|
636
800
|
for (const p of targets) {
|
|
637
801
|
try {
|
|
638
|
-
|
|
802
|
+
removeStateFile(p, `delete the device key file ${path.basename(p)}`);
|
|
639
803
|
}
|
|
640
804
|
catch (err) {
|
|
805
|
+
rethrowIfLiveStateRefusal(err);
|
|
641
806
|
if (err.code !== 'ENOENT') {
|
|
642
807
|
emit('clear_failed', false, {
|
|
643
808
|
profile: opts.profile,
|
|
@@ -651,6 +816,212 @@ export async function resetDeviceIdentity(opts) {
|
|
|
651
816
|
}
|
|
652
817
|
_state = { kind: 'uninit' };
|
|
653
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
|
+
});
|
|
654
1025
|
}
|
|
655
1026
|
/** True iff init has completed and a keypair is in memory. */
|
|
656
1027
|
export function isDeviceIdentityActive() {
|
|
@@ -666,7 +1037,12 @@ export function getDevicePublicKey() {
|
|
|
666
1037
|
reloadIfRecovered();
|
|
667
1038
|
if (_state.kind !== 'active')
|
|
668
1039
|
return null;
|
|
669
|
-
return {
|
|
1040
|
+
return {
|
|
1041
|
+
jwk: _state.key.publicJwk,
|
|
1042
|
+
thumbprint: _state.key.thumbprint,
|
|
1043
|
+
alg: _state.key.alg,
|
|
1044
|
+
custody: _state.key.custody,
|
|
1045
|
+
};
|
|
670
1046
|
}
|
|
671
1047
|
/**
|
|
672
1048
|
* Operator-readable summary of device-identity state. Used by
|
|
@@ -680,28 +1056,63 @@ export function describeDeviceIdentityState() {
|
|
|
680
1056
|
if (_state.kind === 'active') {
|
|
681
1057
|
return {
|
|
682
1058
|
kind: 'active',
|
|
683
|
-
thumbprint: _state.
|
|
1059
|
+
thumbprint: _state.key.thumbprint,
|
|
684
1060
|
profile: _state.profile,
|
|
685
1061
|
envelopeProtected: _state.envelopeProtected,
|
|
1062
|
+
alg: _state.key.alg,
|
|
1063
|
+
custody: _state.key.custody,
|
|
1064
|
+
canSign: _state.key.custody === 'software' ? true : resolveHardwareDeviceKeyHelper() !== null,
|
|
686
1065
|
};
|
|
687
1066
|
}
|
|
688
1067
|
return { kind: 'uninit' };
|
|
689
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
|
+
}
|
|
690
1096
|
/**
|
|
691
1097
|
* Sign an outgoing request with the device key. Returns the JWS Compact
|
|
692
1098
|
* Serialization, or null when device identity is not active. NEVER
|
|
693
1099
|
* throws — a failed sign returns null and emits a telemetry event so
|
|
694
1100
|
* the caller can fall back to bearer-only auth without an outage.
|
|
695
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
|
+
*
|
|
696
1106
|
* Shape matches `DeviceProofSigner` from `./refresh.ts` so the function
|
|
697
1107
|
* passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
|
|
698
1108
|
*/
|
|
699
|
-
export function signServerRequest(opts) {
|
|
1109
|
+
export async function signServerRequest(opts) {
|
|
700
1110
|
reloadIfRecovered();
|
|
701
|
-
|
|
1111
|
+
const state = _state;
|
|
1112
|
+
if (state.kind !== 'active')
|
|
702
1113
|
return null;
|
|
703
1114
|
try {
|
|
704
|
-
return
|
|
1115
|
+
return await signProofWith(state.key, {
|
|
705
1116
|
method: opts.method,
|
|
706
1117
|
url: opts.url,
|
|
707
1118
|
refreshToken: opts.refreshToken,
|
|
@@ -710,9 +1121,13 @@ export function signServerRequest(opts) {
|
|
|
710
1121
|
}
|
|
711
1122
|
catch (err) {
|
|
712
1123
|
emit('sign_failed', false, {
|
|
713
|
-
profile:
|
|
714
|
-
thumbprint:
|
|
1124
|
+
profile: state.profile,
|
|
1125
|
+
thumbprint: state.key.thumbprint,
|
|
1126
|
+
custody: state.key.custody,
|
|
715
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 } : {}),
|
|
716
1131
|
message: err?.message ?? 'unknown',
|
|
717
1132
|
});
|
|
718
1133
|
return null;
|
|
@@ -735,9 +1150,10 @@ export function __enrollmentBackoffPathForTest(profile) {
|
|
|
735
1150
|
export function clearEnrollmentMarker(profile) {
|
|
736
1151
|
for (const markerPath of [enrollmentMarkerPath(profile), enrollmentBackoffPath(profile)]) {
|
|
737
1152
|
try {
|
|
738
|
-
|
|
1153
|
+
removeStateFile(markerPath, `delete the enrollment file ${path.basename(markerPath)}`);
|
|
739
1154
|
}
|
|
740
1155
|
catch (err) {
|
|
1156
|
+
rethrowIfLiveStateRefusal(err);
|
|
741
1157
|
if (err.code !== 'ENOENT') {
|
|
742
1158
|
emit('enrollment_marker_clear_failed', false, {
|
|
743
1159
|
profile,
|
|
@@ -769,7 +1185,7 @@ export async function enrollWithServer(opts) {
|
|
|
769
1185
|
return { enrolled: false, reason: 'not_active' };
|
|
770
1186
|
}
|
|
771
1187
|
const profile = _state.profile;
|
|
772
|
-
const
|
|
1188
|
+
const key = _state.key;
|
|
773
1189
|
const markerPath = enrollmentMarkerPath(profile);
|
|
774
1190
|
const backoffPath = enrollmentBackoffPath(profile);
|
|
775
1191
|
try {
|
|
@@ -785,33 +1201,48 @@ export async function enrollWithServer(opts) {
|
|
|
785
1201
|
const retryAfter = Date.parse(raw.retryAfter || '');
|
|
786
1202
|
if (raw.serverUrl === serverUrl &&
|
|
787
1203
|
raw.machineUuid === machineUuid &&
|
|
788
|
-
raw.thumbprint ===
|
|
1204
|
+
raw.thumbprint === key.thumbprint &&
|
|
789
1205
|
Number.isFinite(retryAfter) &&
|
|
790
1206
|
retryAfter > Date.now()) {
|
|
791
1207
|
return { enrolled: false, reason: 'backoff_active' };
|
|
792
1208
|
}
|
|
793
|
-
|
|
1209
|
+
removeStateFile(backoffPath, 'delete the stale enrollment backoff');
|
|
794
1210
|
}
|
|
795
1211
|
catch (err) {
|
|
796
1212
|
if (err.code !== 'ENOENT') {
|
|
797
|
-
// 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.
|
|
798
1216
|
try {
|
|
799
|
-
|
|
1217
|
+
removeStateFile(backoffPath, 'delete the corrupt enrollment backoff');
|
|
800
1218
|
}
|
|
801
|
-
catch {
|
|
1219
|
+
catch (clearErr) {
|
|
1220
|
+
rethrowIfLiveStateRefusal(clearErr);
|
|
802
1221
|
/* best-effort */
|
|
803
1222
|
}
|
|
804
1223
|
}
|
|
805
1224
|
}
|
|
806
1225
|
const base = serverUrl.replace(/\/+$/, '');
|
|
807
1226
|
const htu = `${base}/api/daemon-resources/enroll-device-key`;
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
url: htu
|
|
811
|
-
}
|
|
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.
|
|
812
1242
|
const body = JSON.stringify({
|
|
813
1243
|
machineUuid,
|
|
814
|
-
publicJwkX:
|
|
1244
|
+
...(key.publicJwk.kty === 'OKP' ? { publicJwkX: key.publicJwk.x } : {}),
|
|
1245
|
+
publicJwk: key.publicJwk,
|
|
815
1246
|
proofJws,
|
|
816
1247
|
});
|
|
817
1248
|
// 10-second bound — enrollment is best-effort at startup.
|
|
@@ -834,7 +1265,7 @@ export async function enrollWithServer(opts) {
|
|
|
834
1265
|
const message = err?.message ?? 'unknown';
|
|
835
1266
|
emit('enrollment_network_error', false, {
|
|
836
1267
|
profile,
|
|
837
|
-
thumbprint:
|
|
1268
|
+
thumbprint: key.thumbprint,
|
|
838
1269
|
serverUrl,
|
|
839
1270
|
message,
|
|
840
1271
|
});
|
|
@@ -885,7 +1316,7 @@ export async function enrollWithServer(opts) {
|
|
|
885
1316
|
}
|
|
886
1317
|
const pinnedToOtherKey = response.status === 409 &&
|
|
887
1318
|
enrolledThumbprint !== null &&
|
|
888
|
-
enrolledThumbprint !==
|
|
1319
|
+
enrolledThumbprint !== key.thumbprint;
|
|
889
1320
|
const keyPinnedElsewhere = response.status === 409 && conflictCode === 'ENROLLMENT_DUPLICATE_KEY';
|
|
890
1321
|
const isBenignRejection = (response.status === 404 && responseText.includes('DAEMON_NOT_FOUND')) ||
|
|
891
1322
|
(response.status === 403 && responseText.includes('OPAQUE_AUTH_REFUSED')) ||
|
|
@@ -893,7 +1324,7 @@ export async function enrollWithServer(opts) {
|
|
|
893
1324
|
if (pinnedToOtherKey) {
|
|
894
1325
|
emit('enrollment_key_mismatch', false, {
|
|
895
1326
|
profile,
|
|
896
|
-
thumbprint:
|
|
1327
|
+
thumbprint: key.thumbprint,
|
|
897
1328
|
enrolledThumbprint,
|
|
898
1329
|
serverUrl,
|
|
899
1330
|
message: 'this machine is registered with a different device key than this profile signs ' +
|
|
@@ -906,11 +1337,12 @@ export async function enrollWithServer(opts) {
|
|
|
906
1337
|
atomicWrite0600(backoffPath, JSON.stringify({
|
|
907
1338
|
serverUrl,
|
|
908
1339
|
machineUuid,
|
|
909
|
-
thumbprint:
|
|
1340
|
+
thumbprint: key.thumbprint,
|
|
910
1341
|
retryAfter: new Date(Date.now() + DAEMON_NOT_FOUND_BACKOFF_MS).toISOString(),
|
|
911
|
-
}));
|
|
1342
|
+
}), 'write the enrollment backoff');
|
|
912
1343
|
}
|
|
913
1344
|
catch (err) {
|
|
1345
|
+
rethrowIfLiveStateRefusal(err);
|
|
914
1346
|
emit('enrollment_backoff_write_failed', false, {
|
|
915
1347
|
profile,
|
|
916
1348
|
message: err?.message ?? 'unknown',
|
|
@@ -919,7 +1351,7 @@ export async function enrollWithServer(opts) {
|
|
|
919
1351
|
}
|
|
920
1352
|
emit('enrollment_rejected', false, {
|
|
921
1353
|
profile,
|
|
922
|
-
thumbprint:
|
|
1354
|
+
thumbprint: key.thumbprint,
|
|
923
1355
|
serverUrl,
|
|
924
1356
|
status: response.status,
|
|
925
1357
|
body: responseText,
|
|
@@ -934,25 +1366,28 @@ export async function enrollWithServer(opts) {
|
|
|
934
1366
|
const marker = JSON.stringify({
|
|
935
1367
|
enrolledAt: new Date().toISOString(),
|
|
936
1368
|
serverUrl,
|
|
937
|
-
thumbprint:
|
|
1369
|
+
thumbprint: key.thumbprint,
|
|
938
1370
|
machineUuid,
|
|
939
1371
|
});
|
|
940
1372
|
try {
|
|
941
|
-
atomicWrite0600(markerPath, marker);
|
|
1373
|
+
atomicWrite0600(markerPath, marker, 'write the enrollment marker');
|
|
942
1374
|
}
|
|
943
1375
|
catch (err) {
|
|
1376
|
+
rethrowIfLiveStateRefusal(err);
|
|
944
1377
|
// Non-fatal — server enrolled us; we may re-POST next run, which
|
|
945
1378
|
// the server handles idempotently.
|
|
946
1379
|
emit('enrollment_marker_write_failed', false, {
|
|
947
1380
|
profile,
|
|
948
|
-
thumbprint:
|
|
1381
|
+
thumbprint: key.thumbprint,
|
|
949
1382
|
message: err?.message ?? 'unknown',
|
|
950
1383
|
});
|
|
951
1384
|
}
|
|
952
1385
|
try {
|
|
953
|
-
|
|
1386
|
+
removeStateFile(backoffPath, 'delete the enrollment backoff');
|
|
954
1387
|
}
|
|
955
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.
|
|
956
1391
|
if (err.code !== 'ENOENT') {
|
|
957
1392
|
emit('enrollment_backoff_clear_failed', false, {
|
|
958
1393
|
profile,
|
|
@@ -962,8 +1397,8 @@ export async function enrollWithServer(opts) {
|
|
|
962
1397
|
}
|
|
963
1398
|
emit('enrolled_with_server', true, {
|
|
964
1399
|
profile,
|
|
965
|
-
thumbprint:
|
|
1400
|
+
thumbprint: key.thumbprint,
|
|
966
1401
|
serverUrl,
|
|
967
1402
|
});
|
|
968
|
-
return { enrolled: true, thumbprint:
|
|
1403
|
+
return { enrolled: true, thumbprint: key.thumbprint };
|
|
969
1404
|
}
|