@skrr-ai/auth-core 0.1.4 → 0.1.6
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/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +15 -1
- package/dist/cjs/kek/linux.js +71 -21
- package/dist/cjs/refreshClassification.js +9 -2
- package/dist/cjs/releaseKeys.d.ts +87 -0
- package/dist/cjs/releaseKeys.js +94 -0
- package/dist/cjs/releaseManifest.d.ts +161 -0
- package/dist/cjs/releaseManifest.js +235 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/kek/linux.js +71 -21
- package/dist/esm/refreshClassification.js +9 -2
- package/dist/esm/releaseKeys.d.ts +87 -0
- package/dist/esm/releaseKeys.js +91 -0
- package/dist/esm/releaseManifest.d.ts +161 -0
- package/dist/esm/releaseManifest.js +227 -0
- package/package.json +2 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_SIGS = void 0;
|
|
4
|
+
exports.buildArtifactMessage = buildArtifactMessage;
|
|
5
|
+
exports.buildManifestMessage = buildManifestMessage;
|
|
6
|
+
exports.verifyArtifact = verifyArtifact;
|
|
7
|
+
exports.verifyManifest = verifyManifest;
|
|
8
|
+
exports.getDeltaFor = getDeltaFor;
|
|
9
|
+
/**
|
|
10
|
+
* release-manifest.ts — signed daemon release manifest: types, canonical
|
|
11
|
+
* signature messages, and offline ed25519 verification.
|
|
12
|
+
*
|
|
13
|
+
* PURE: depends only on `node:crypto` (plus the sibling `release-keys.ts` for
|
|
14
|
+
* the pinned trust anchors). No Electron, no logger, no fs — so it is
|
|
15
|
+
* unit-testable in isolation and safe to import from the CI release signer.
|
|
16
|
+
*
|
|
17
|
+
* SINGLE SOURCE OF TRUTH: `buildArtifactMessage` and `buildManifestMessage`
|
|
18
|
+
* define the exact bytes that get signed. The CI signer MUST import these
|
|
19
|
+
* functions rather than reimplement them — that is what eliminates
|
|
20
|
+
* signer/verifier drift. Any change here is a wire-format change and must bump
|
|
21
|
+
* the embedded `v1` tag.
|
|
22
|
+
*
|
|
23
|
+
* Mechanics mirror `desktop/src/main/updater/policySignature.ts`: the public
|
|
24
|
+
* key is base64 DER-SPKI, verified with `crypto.verify(null, ...)` (ed25519
|
|
25
|
+
* takes no digest algorithm).
|
|
26
|
+
*
|
|
27
|
+
* WHY IT LIVES IN auth-core NOW
|
|
28
|
+
*
|
|
29
|
+
* It lived in `daemon/src/release-manifest.ts`, and `skyCodeChannels.ts` beside
|
|
30
|
+
* it recorded the reason for stopping there: "the installer, the signature
|
|
31
|
+
* verification and the promotion machinery stay in the daemon, which is the
|
|
32
|
+
* only process that performs them." That premise held exactly until the CLI
|
|
33
|
+
* had to install the daemon — `skrr daemon install` on a machine with no
|
|
34
|
+
* `skrrd` cannot ask the daemon to verify the daemon.
|
|
35
|
+
*
|
|
36
|
+
* So this is the same road that move was on, not a reversal of it. A MIRROR in
|
|
37
|
+
* the CLI is the alternative and is the wrong one, with precedent: OSK-3894
|
|
38
|
+
* shipped a mirrored harness-trust table and OSK-3897 removed it, because a
|
|
39
|
+
* mirror proves two copies agree rather than proving there is one copy. For a
|
|
40
|
+
* TRUST ROOT that argument is not stylistic — a second copy of the pinned keys
|
|
41
|
+
* is a second thing that can be edited, and the edit that matters (emptying the
|
|
42
|
+
* array) disables verification silently.
|
|
43
|
+
*
|
|
44
|
+
* `SignedReleasePolicy` did NOT move. It is the daemon's own decision envelope,
|
|
45
|
+
* so the manifest is generic over it and the daemon narrows it back.
|
|
46
|
+
*/
|
|
47
|
+
const node_crypto_1 = require("node:crypto");
|
|
48
|
+
const releaseKeys_js_1 = require("./releaseKeys.js");
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Canonical signature messages — THE contract. Do not reformat casually.
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/**
|
|
53
|
+
* Canonical bytes signed for a single platform artifact. Newline-joined, fixed
|
|
54
|
+
* field order, `v1` tag. `sha256` is lowercased so the message is stable
|
|
55
|
+
* regardless of the hex casing the caller supplies.
|
|
56
|
+
*/
|
|
57
|
+
function buildArtifactMessage(a) {
|
|
58
|
+
return [
|
|
59
|
+
'oversky-daemon-artifact',
|
|
60
|
+
'v1',
|
|
61
|
+
a.platform,
|
|
62
|
+
a.version,
|
|
63
|
+
a.sha256.toLowerCase(),
|
|
64
|
+
a.url,
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Canonical bytes signed for the whole manifest (excluding the top-level
|
|
69
|
+
* `sig`). Platform keys are sorted so the message is independent of JSON key
|
|
70
|
+
* order; each contributes `<platform>=<sha256-lower>`. `forceUpdate` is
|
|
71
|
+
* serialized strictly (`String(forceUpdate === true)` → `"true"`/`"false"`).
|
|
72
|
+
*/
|
|
73
|
+
function buildManifestMessage(m) {
|
|
74
|
+
return [
|
|
75
|
+
'oversky-daemon-manifest',
|
|
76
|
+
'v1',
|
|
77
|
+
m.version,
|
|
78
|
+
m.minimum,
|
|
79
|
+
String(m.forceUpdate === true),
|
|
80
|
+
...Object.keys(m.platforms)
|
|
81
|
+
.sort()
|
|
82
|
+
.map((p) => p + '=' + m.platforms[p].sha256.toLowerCase()),
|
|
83
|
+
].join('\n');
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Try `sigB64` against every pinned key (supports key rotation: current +
|
|
87
|
+
* next). Never throws — a malformed key or signature is just a failed attempt.
|
|
88
|
+
* Empty key set ⇒ `{ disabled: true }` so the caller can warn-and-proceed
|
|
89
|
+
* (mirrors desktop `MANIFEST_PUBLIC_KEY=''`).
|
|
90
|
+
*/
|
|
91
|
+
function verifyWithKeys(message, sigB64, keys) {
|
|
92
|
+
if (keys.length === 0)
|
|
93
|
+
return { disabled: true };
|
|
94
|
+
if (!sigB64)
|
|
95
|
+
return { ok: false, reason: 'missing signature' };
|
|
96
|
+
const msg = Buffer.from(message, 'utf8');
|
|
97
|
+
const sig = Buffer.from(sigB64, 'base64');
|
|
98
|
+
for (const k of keys) {
|
|
99
|
+
try {
|
|
100
|
+
const key = (0, node_crypto_1.createPublicKey)({
|
|
101
|
+
key: Buffer.from(k.publicKeySpkiB64, 'base64'),
|
|
102
|
+
format: 'der',
|
|
103
|
+
type: 'spki',
|
|
104
|
+
});
|
|
105
|
+
if ((0, node_crypto_1.verify)(null, msg, key, sig)) {
|
|
106
|
+
return { ok: true };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Malformed key material or verify error → try the next pinned key.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { ok: false, reason: 'signature did not verify against any pinned key' };
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Upper bound on signatures we will trial-verify per artifact/manifest. A
|
|
117
|
+
* rotation window needs at most 2–3 (current + next). `sigs[]` is NOT part of
|
|
118
|
+
* the signed bytes, so an attacker can pad it; this cap bounds the verify work
|
|
119
|
+
* they can force to O(MAX_SIGS × pinnedKeys) — a few hundred microseconds even
|
|
120
|
+
* at the cap. A valid signature placed beyond the cap is treated as absent.
|
|
121
|
+
*/
|
|
122
|
+
exports.MAX_SIGS = 8;
|
|
123
|
+
/**
|
|
124
|
+
* v2 acceptance: `{ ok: true }` if ANY provided signature verifies against ANY
|
|
125
|
+
* pinned key. This composes the two rotation axes — verifyWithKeys already
|
|
126
|
+
* loops every pinned KEY (key rotation); this adds the every-SIGNATURE loop —
|
|
127
|
+
* so a release dual-signed by [old, new] verifies for a daemon pinning only
|
|
128
|
+
* [old], only [new], or both. That is what makes rotation zero-downtime.
|
|
129
|
+
*
|
|
130
|
+
* Posture is identical to v1:
|
|
131
|
+
* - empty pinned-key set ⇒ `{ disabled: true }` (fail-OPEN, unconfigured);
|
|
132
|
+
* - keys present but nothing verifies ⇒ `{ ok: false }` (fail-CLOSED);
|
|
133
|
+
* and it never throws (per-key errors are swallowed inside verifyWithKeys).
|
|
134
|
+
*
|
|
135
|
+
* Security: `sigs[]` is attacker-malleable (not in the signed bytes), so trust
|
|
136
|
+
* comes SOLELY from a real ed25519 verify against a pinned key. Padding with
|
|
137
|
+
* garbage is inert; stripping every entry is at worst a denial-of-update
|
|
138
|
+
* (liveness), never a forged accept. keyId is never consulted → no keyId
|
|
139
|
+
* substitution. Each sig independently binds version/minimum/forceUpdate/
|
|
140
|
+
* platform-shas, so cross-manifest splicing is blocked exactly as in v1.
|
|
141
|
+
*/
|
|
142
|
+
function verifyWithKeysMulti(message, sigs, keys) {
|
|
143
|
+
if (keys.length === 0)
|
|
144
|
+
return { disabled: true };
|
|
145
|
+
let sawSignature = false;
|
|
146
|
+
// Bound attacker-supplied work; a real rotation never needs more than a few.
|
|
147
|
+
for (const entry of sigs.slice(0, exports.MAX_SIGS)) {
|
|
148
|
+
const sigB64 = typeof entry?.sig === 'string' ? entry.sig : '';
|
|
149
|
+
if (!sigB64)
|
|
150
|
+
continue;
|
|
151
|
+
sawSignature = true;
|
|
152
|
+
const r = verifyWithKeys(message, sigB64, keys); // keys non-empty ⇒ never {disabled}
|
|
153
|
+
if ('ok' in r && r.ok)
|
|
154
|
+
return { ok: true };
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
reason: sawSignature
|
|
159
|
+
? 'no provided signature verified against any pinned key'
|
|
160
|
+
: 'missing signature',
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Verify a single platform artifact's `sig` over buildArtifactMessage(...).
|
|
165
|
+
* `platform` is the `<platform>-<arch>` key; `version` is the manifest version
|
|
166
|
+
* the artifact belongs to. Defaults to the baked-in pinned keys.
|
|
167
|
+
*/
|
|
168
|
+
function verifyArtifact(platform, version, artifact, keys = releaseKeys_js_1.OVERSKY_RELEASE_PUBLIC_KEYS) {
|
|
169
|
+
let message;
|
|
170
|
+
try {
|
|
171
|
+
message = buildArtifactMessage({
|
|
172
|
+
platform,
|
|
173
|
+
version,
|
|
174
|
+
sha256: artifact.sha256,
|
|
175
|
+
url: artifact.url,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
// A malformed artifact (missing string fields) must fail-closed as a clean
|
|
180
|
+
// verification failure, never an uncaught throw.
|
|
181
|
+
return { ok: false, reason: `malformed artifact: ${err.message}` };
|
|
182
|
+
}
|
|
183
|
+
// v2: a present `sigs[]` is authoritative (any-of-N). Otherwise the v1 scalar
|
|
184
|
+
// path — byte-for-byte the prior behavior.
|
|
185
|
+
if (Array.isArray(artifact.sigs) && artifact.sigs.length > 0) {
|
|
186
|
+
return verifyWithKeysMulti(message, artifact.sigs, keys);
|
|
187
|
+
}
|
|
188
|
+
return verifyWithKeys(message, artifact.sig, keys);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Verify the manifest's top-level `sig` over buildManifestMessage(this).
|
|
192
|
+
* Defaults to the baked-in pinned keys.
|
|
193
|
+
*/
|
|
194
|
+
function verifyManifest(manifest, keys = releaseKeys_js_1.OVERSKY_RELEASE_PUBLIC_KEYS) {
|
|
195
|
+
let message;
|
|
196
|
+
try {
|
|
197
|
+
message = buildManifestMessage(manifest);
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
// A malformed manifest (e.g. a platform entry missing sha256) must
|
|
201
|
+
// fail-closed as a clean verification failure, never an uncaught throw.
|
|
202
|
+
return { ok: false, reason: `malformed manifest: ${err.message}` };
|
|
203
|
+
}
|
|
204
|
+
// v2: a present `sigs[]` is authoritative (any-of-N). Otherwise the v1 scalar
|
|
205
|
+
// path — byte-for-byte the prior behavior.
|
|
206
|
+
if (Array.isArray(manifest.sigs) && manifest.sigs.length > 0) {
|
|
207
|
+
return verifyWithKeysMulti(message, manifest.sigs, keys);
|
|
208
|
+
}
|
|
209
|
+
return verifyWithKeys(message, manifest.sig, keys);
|
|
210
|
+
}
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// Differential updates (delta metadata lookup) — UNSIGNED, optimization only.
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
/**
|
|
215
|
+
* Look up a delta patch that transforms `fromVersion`'s binary into this
|
|
216
|
+
* artifact's binary. Returns null when none is advertised or the descriptor is
|
|
217
|
+
* malformed — the caller then does a full download. PURE; validates shape only
|
|
218
|
+
* (the delta is UNSIGNED, so trust comes from verifying the RECONSTRUCTED binary
|
|
219
|
+
* against the signed `artifact.sha256`, not from this metadata).
|
|
220
|
+
*/
|
|
221
|
+
function getDeltaFor(artifact, fromVersion) {
|
|
222
|
+
const deltas = artifact.deltas;
|
|
223
|
+
if (!deltas || typeof deltas !== 'object')
|
|
224
|
+
return null;
|
|
225
|
+
const d = deltas[fromVersion];
|
|
226
|
+
if (!d ||
|
|
227
|
+
typeof d.url !== 'string' ||
|
|
228
|
+
typeof d.sha256 !== 'string' ||
|
|
229
|
+
typeof d.size !== 'number' ||
|
|
230
|
+
!Number.isFinite(d.size) ||
|
|
231
|
+
d.size <= 0) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
return d;
|
|
235
|
+
}
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -36,4 +36,6 @@ export { sanitizeSpawnEnv, SENSITIVE_ENV_VARS, SENSITIVE_ENV_PREFIXES, SENSITIVE
|
|
|
36
36
|
export { CONFIG_DIR_NAME, LEGACY_CONFIG_DIR_NAMES, NATIVE_ID_PREFIX, LEGACY_NATIVE_ID_PREFIXES, DEFAULT_BINARY_NAME, } from './localIdentity.js';
|
|
37
37
|
export { findLegacyLocalState, describeLegacyState, type LegacyStateFinding, type LegacyStateReport, } from './legacyStatePreflight.js';
|
|
38
38
|
export { DEFAULT_SKY_CODE_CHANNEL, DEFAULT_SKY_CODE_FEED_BASE, SKY_CODE_CHANNELS, SKY_CODE_CHANNEL_ENV, SKY_CODE_MANIFEST_FILE, isSkyCodeChannel, resolveChannelManifestUrl, resolveSkyCodeChannel, skyCodeChannelPrefix, skyCodeFeedBase, SKY_CODE_FEED_BASE_ENV, type SkyCodeChannel, } from './skyCodeChannels.js';
|
|
39
|
+
export { OVERSKY_RELEASE_PUBLIC_KEYS, type ReleaseKey } from './releaseKeys.js';
|
|
40
|
+
export { MAX_SIGS, buildArtifactMessage, buildManifestMessage, getDeltaFor, verifyArtifact, verifyManifest, type DaemonVersionManifest, type DeltaDescriptor, type ManifestSignature, type PlatformArtifact, type VerifyResult, } from './releaseManifest.js';
|
|
39
41
|
export { resolveConfigRoot, configRootPath, isConfigRootOverridden } from './configRoot.js';
|
package/dist/esm/index.js
CHANGED
|
@@ -143,6 +143,13 @@ export { CONFIG_DIR_NAME, LEGACY_CONFIG_DIR_NAMES, NATIVE_ID_PREFIX, LEGACY_NATI
|
|
|
143
143
|
reads, copies or removes what it finds (§7.2, §8.4). */
|
|
144
144
|
export { findLegacyLocalState, describeLegacyState, } from './legacyStatePreflight.js';
|
|
145
145
|
export { DEFAULT_SKY_CODE_CHANNEL, DEFAULT_SKY_CODE_FEED_BASE, SKY_CODE_CHANNELS, SKY_CODE_CHANNEL_ENV, SKY_CODE_MANIFEST_FILE, isSkyCodeChannel, resolveChannelManifestUrl, resolveSkyCodeChannel, skyCodeChannelPrefix, skyCodeFeedBase, SKY_CODE_FEED_BASE_ENV, } from './skyCodeChannels.js';
|
|
146
|
+
// Daemon release trust: the pinned ed25519 keys and the canonical signature
|
|
147
|
+
// messages. Shared because BOTH the daemon (self-update) and the CLI
|
|
148
|
+
// (`skrr daemon install`, which runs when there is no daemon to ask) verify the
|
|
149
|
+
// same manifest against the same root. See releaseManifest.ts for why a mirror
|
|
150
|
+
// would be the wrong shape here.
|
|
151
|
+
export { OVERSKY_RELEASE_PUBLIC_KEYS } from './releaseKeys.js';
|
|
152
|
+
export { MAX_SIGS, buildArtifactMessage, buildManifestMessage, getDeltaFor, verifyArtifact, verifyManifest, } from './releaseManifest.js';
|
|
146
153
|
// The one definition of the skrr root, shared by `skrr` and `skrrd`. See the
|
|
147
154
|
// module for why re-deriving it kept producing divergences.
|
|
148
155
|
export { resolveConfigRoot, configRootPath, isConfigRootOverridden } from './configRoot.js';
|
package/dist/esm/kek/linux.js
CHANGED
|
@@ -54,7 +54,6 @@
|
|
|
54
54
|
* cleaning up the corresponding Secret Service entry when finished.
|
|
55
55
|
*/
|
|
56
56
|
import { execFile } from 'node:child_process';
|
|
57
|
-
import { promisify } from 'node:util';
|
|
58
57
|
import crypto from 'node:crypto';
|
|
59
58
|
import os from 'node:os';
|
|
60
59
|
import fs from 'node:fs';
|
|
@@ -94,7 +93,6 @@ const HKDF_INFO = Buffer.from('oversky-cred-kek-v1');
|
|
|
94
93
|
/** Filesystem paths for Tier-3 IKM components. */
|
|
95
94
|
const PATH_LOGINUID = '/proc/self/loginuid';
|
|
96
95
|
const PATH_MACHINE_ID = '/etc/machine-id';
|
|
97
|
-
const execFileAsync = promisify(execFile);
|
|
98
96
|
let _execFileForTest = null;
|
|
99
97
|
/**
|
|
100
98
|
* @internal Test seam — inject a mock execFile for hermetic tests so the
|
|
@@ -108,23 +106,38 @@ export function __setExecFileForTest(fn) {
|
|
|
108
106
|
_secretToolProbeCache = undefined;
|
|
109
107
|
_tierSelection = null;
|
|
110
108
|
}
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Default execFile wrapper used when no test override is installed.
|
|
111
|
+
*
|
|
112
|
+
* `input` is WRITTEN to the child's stdin here, and stdin is always closed. The
|
|
113
|
+
* previous version forwarded `input` as an execFile option — but only the SYNC
|
|
114
|
+
* child_process functions honour `input`; async `execFile` ignores it. So
|
|
115
|
+
* `secret-tool store`, which reads the secret from stdin, got neither the secret
|
|
116
|
+
* nor EOF and waited forever: Tier-2 enrollment could not complete on any Linux
|
|
117
|
+
* desktop with a working Secret Service. Closing stdin also means no other call
|
|
118
|
+
* can block on a tool that unexpectedly reads it.
|
|
119
|
+
*/
|
|
120
|
+
const defaultExec = (file, args, options) => new Promise((resolve, reject) => {
|
|
121
|
+
const { input, ...execOptions } = options ?? {};
|
|
122
|
+
const child = execFile(file, args, { encoding: 'utf-8', ...execOptions }, (err, stdout, stderr) => {
|
|
123
|
+
// Defensive coercion — when callers override the encoding, output may
|
|
124
|
+
// surface as Buffer; normalize both shapes.
|
|
125
|
+
const out = typeof stdout === 'string' ? stdout : Buffer.from(stdout).toString('utf-8');
|
|
126
|
+
const errOut = typeof stderr === 'string' ? stderr : Buffer.from(stderr).toString('utf-8');
|
|
127
|
+
if (err) {
|
|
128
|
+
// Same shape promisify(execFile) produced, which the probe reads.
|
|
129
|
+
reject(Object.assign(err, { stdout: out, stderr: errOut }));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
resolve({ stdout: out, stderr: errOut });
|
|
116
133
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
? result.stderr
|
|
125
|
-
: Buffer.from(result.stderr).toString('utf-8'),
|
|
126
|
-
};
|
|
127
|
-
};
|
|
134
|
+
if (child.stdin) {
|
|
135
|
+
// A child that exits without reading stdin makes the write fail with EPIPE;
|
|
136
|
+
// the exit status is what reports that, not this stream.
|
|
137
|
+
child.stdin.on('error', () => undefined);
|
|
138
|
+
child.stdin.end(input ?? '');
|
|
139
|
+
}
|
|
140
|
+
});
|
|
128
141
|
function exec(file, args, options) {
|
|
129
142
|
const impl = _execFileForTest ?? defaultExec;
|
|
130
143
|
return impl(file, args, { encoding: 'utf-8', ...options });
|
|
@@ -156,6 +169,41 @@ function readFileTrimmed(path) {
|
|
|
156
169
|
// ---------------------------------------------------------------------------
|
|
157
170
|
/** `undefined` = not yet probed; `boolean` = cached probe outcome. */
|
|
158
171
|
let _secretToolProbeCache;
|
|
172
|
+
/**
|
|
173
|
+
* True when `secret-tool` never started, as opposed to starting and exiting
|
|
174
|
+
* non-zero.
|
|
175
|
+
*
|
|
176
|
+
* `execFile` reports the two differently: a process that ran and failed carries
|
|
177
|
+
* its numeric exit code, and a spawn that failed carries a STRING errno. Only
|
|
178
|
+
* `ENOENT` used to count as "missing", so every other spawn failure read as a
|
|
179
|
+
* working binary — and the probe then picked the libsecret tier, whose `store`
|
|
180
|
+
* cannot succeed either, which disables the credential envelope and makes the CLI
|
|
181
|
+
* refuse to persist any credential at all.
|
|
182
|
+
*
|
|
183
|
+
* The case that found it: a PATH entry the process may not search. execvp
|
|
184
|
+
* reports EACCES rather than ENOENT once it has met one, and a systemd unit that
|
|
185
|
+
* makes /snap inaccessible while PATH still lists /snap/bin (the Dedicated Runtime
|
|
186
|
+
* daemon sandbox did) turns every missing binary into EACCES.
|
|
187
|
+
*/
|
|
188
|
+
function secretToolDidNotRun(err) {
|
|
189
|
+
const { code, status } = err;
|
|
190
|
+
return (typeof code === 'string' && SECRET_TOOL_UNRUNNABLE.has(code)) || status === 127;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Spawn errnos that mean the binary cannot be executed from here, permanently
|
|
194
|
+
* for this process. Deliberately NOT every string errno: EAGAIN, EMFILE or ENOMEM
|
|
195
|
+
* are transient, and caching "absent" on one of those would move a machine whose
|
|
196
|
+
* libsecret works onto the machine-id tier for the rest of the process — where the
|
|
197
|
+
* existing wrapped key cannot be opened and would be re-enrolled over.
|
|
198
|
+
*/
|
|
199
|
+
const SECRET_TOOL_UNRUNNABLE = new Set([
|
|
200
|
+
'ENOENT',
|
|
201
|
+
'EACCES',
|
|
202
|
+
'ENOTDIR',
|
|
203
|
+
'ELOOP',
|
|
204
|
+
'ENOEXEC',
|
|
205
|
+
'EPERM',
|
|
206
|
+
]);
|
|
159
207
|
/**
|
|
160
208
|
* Probe secret-tool availability. Two checks:
|
|
161
209
|
* 1. `secret-tool --version` — ENOENT or exit 127 means binary missing.
|
|
@@ -173,9 +221,7 @@ async function probeSecretTool() {
|
|
|
173
221
|
await exec('secret-tool', ['--version']);
|
|
174
222
|
}
|
|
175
223
|
catch (err) {
|
|
176
|
-
|
|
177
|
-
const status = err.status;
|
|
178
|
-
if (code === 'ENOENT' || status === 127) {
|
|
224
|
+
if (secretToolDidNotRun(err)) {
|
|
179
225
|
_secretToolProbeCache = false;
|
|
180
226
|
return false;
|
|
181
227
|
}
|
|
@@ -189,6 +235,10 @@ async function probeSecretTool() {
|
|
|
189
235
|
_secretToolProbeCache = true;
|
|
190
236
|
}
|
|
191
237
|
catch (err) {
|
|
238
|
+
if (secretToolDidNotRun(err)) {
|
|
239
|
+
_secretToolProbeCache = false;
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
192
242
|
const stderr = err.stderr ?? '';
|
|
193
243
|
const isTransportError = DBUS_TRANSPORT_ERRORS.some((s) => stderr.includes(s));
|
|
194
244
|
if (isTransportError) {
|
|
@@ -23,8 +23,9 @@ import { PermanentAuthFailure, TransientAuthFailure } from './types.js';
|
|
|
23
23
|
export { PermanentAuthFailure, TransientAuthFailure };
|
|
24
24
|
/**
|
|
25
25
|
* Maps server-supplied error codes to the canonical PermanentAuthReason values
|
|
26
|
-
* exported from types.ts.
|
|
27
|
-
*
|
|
26
|
+
* exported from types.ts. `REFRESH_MISSING` is a server protocol code rather
|
|
27
|
+
* than a user-facing reason; it maps to `REFRESH_INVALID` because an identical
|
|
28
|
+
* retry cannot gain the credential the server says was absent.
|
|
28
29
|
*/
|
|
29
30
|
const PERMANENT_CODE_SET = new Set([
|
|
30
31
|
'REFRESH_INVALID',
|
|
@@ -64,6 +65,12 @@ export function classifyRefreshResponse(status, body) {
|
|
|
64
65
|
}
|
|
65
66
|
// 401 — auth failure; check for known permanent code
|
|
66
67
|
if (status === 401) {
|
|
68
|
+
if (body?.code === 'REFRESH_MISSING') {
|
|
69
|
+
return {
|
|
70
|
+
kind: 'failure',
|
|
71
|
+
failure: new PermanentAuthFailure('REFRESH_INVALID', body.message ?? 'Refresh token missing'),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
67
74
|
const code = body?.code;
|
|
68
75
|
if (code && PERMANENT_CODE_SET.has(code)) {
|
|
69
76
|
return {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* releaseKeys.ts — ed25519 public keys pinned into the daemon binary AND the
|
|
3
|
+
* CLI that installs it.
|
|
4
|
+
*
|
|
5
|
+
* These are the trust anchors the self-updater (`update.ts`) uses to verify
|
|
6
|
+
* the signed release manifest (`daemon/latest/daemon-version.json`) and the
|
|
7
|
+
* downloaded binary artifact OFFLINE, before swapping its own binary. The
|
|
8
|
+
* matching PRIVATE half lives ONLY in CI as the Secrets Manager secret
|
|
9
|
+
* `OVERSKY_RELEASE_SIGNING_KEY` (ed25519, base64 PKCS8) — it is never checked
|
|
10
|
+
* into the repo and never shipped in the binary.
|
|
11
|
+
*
|
|
12
|
+
* CURRENT STATE: a key is pinned (`daemon-2026-07`, provisioned 2026-07-13), so
|
|
13
|
+
* self-update signature verification is FAIL-CLOSED — an unsigned or
|
|
14
|
+
* badly-signed manifest or binary aborts the update.
|
|
15
|
+
*
|
|
16
|
+
* READ THE NEXT PARAGRAPH BEFORE EMPTYING THIS ARRAY.
|
|
17
|
+
*
|
|
18
|
+
* The empty array is not a safe default; it is a DISABLED SECURITY CONTROL.
|
|
19
|
+
* With no key pinned, `update.ts` logs a warning and proceeds on TLS + SHA256
|
|
20
|
+
* only — and the SHA256 comes from the same manifest fetched over the same
|
|
21
|
+
* channel, so it defends against corruption in transit, not against a
|
|
22
|
+
* compromised or spoofed update feed. That posture (mirrored from the desktop
|
|
23
|
+
* `MANIFEST_PUBLIC_KEY=''` default) existed because the keypair had not been
|
|
24
|
+
* minted yet. It has been minted. Removing the last entry below silently
|
|
25
|
+
* re-disables offline verification on every daemon that ships the change, and
|
|
26
|
+
* nothing in the build fails to tell you.
|
|
27
|
+
*
|
|
28
|
+
* To add a key (rotation, or re-provisioning after a compromise) run
|
|
29
|
+
* `node scripts/generate-release-keypair.mjs`. It mints the ed25519 keypair,
|
|
30
|
+
* prints the ready-to-paste `{ keyId, publicKeySpkiB64 }` line for the array
|
|
31
|
+
* below, and prints the commands to store the PRIVATE half in the GitHub
|
|
32
|
+
* secret `OVERSKY_RELEASE_SIGNING_KEY` + AWS Secrets Manager. Never commit the
|
|
33
|
+
* private half; the CI signer reads `OVERSKY_RELEASE_SIGNING_KEY` and MUST
|
|
34
|
+
* import `buildArtifactMessage` / `buildManifestMessage` from
|
|
35
|
+
* `release-manifest.ts` rather than reimplementing them, so signer and verifier
|
|
36
|
+
* can never drift. Add the *next* key alongside the current one BEFORE
|
|
37
|
+
* rotating, so an in-flight rotation verifies against either — the full
|
|
38
|
+
* procedure is the rotation runbook on the array below.
|
|
39
|
+
*/
|
|
40
|
+
export interface ReleaseKey {
|
|
41
|
+
/** Human-readable key identifier, e.g. `daemon-2026-07`. Mirrors manifest `keyId`. */
|
|
42
|
+
keyId: string;
|
|
43
|
+
/** base64-encoded DER-SPKI ed25519 public key. */
|
|
44
|
+
publicKeySpkiB64: string;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Pinned release-signing public keys, newest first. Verification tries each
|
|
48
|
+
* key in turn (key rotation: keep current + next together during a rotation
|
|
49
|
+
* window). Non-empty → fail-closed, which is the state today. Empty →
|
|
50
|
+
* verification DISABLED with only a logged warning; see the file header before
|
|
51
|
+
* ever letting this array become empty.
|
|
52
|
+
*
|
|
53
|
+
* ZERO-DOWNTIME KEY-ROTATION RUNBOOK (config-only, no code change):
|
|
54
|
+
* 1. Mint the next keypair: `node scripts/generate-release-keypair.mjs --force`.
|
|
55
|
+
* 2. Pin its public half ABOVE the current key in the array below, and ship
|
|
56
|
+
* that daemon build so the fleet trusts BOTH keys.
|
|
57
|
+
* 3. Store the new private half as the GH Actions secret
|
|
58
|
+
* OVERSKY_RELEASE_SIGNING_KEY_NEXT (+ optional OVERSKY_RELEASE_KEY_ID_NEXT
|
|
59
|
+
* label) — the signer then DUAL-SIGNS every release, emitting the additive
|
|
60
|
+
* v2 `sigs[]` envelope (scalar `sig` stays = the current/primary key so
|
|
61
|
+
* daemons that haven't picked up step 2 yet still verify).
|
|
62
|
+
* 4. Cut one release during the overlap window; it verifies for daemons
|
|
63
|
+
* pinning the old key, the new key, or both (see release-manifest.test.ts
|
|
64
|
+
* "ROTATION headroom").
|
|
65
|
+
* 5. Once the new-key build has saturated the fleet: promote NEXT→primary
|
|
66
|
+
* (rename the secret to OVERSKY_RELEASE_SIGNING_KEY), drop the old key from
|
|
67
|
+
* the array, and remove OVERSKY_RELEASE_SIGNING_KEY_NEXT. No downtime, and
|
|
68
|
+
* no further code change — the multi-sig machinery is already shipped.
|
|
69
|
+
*/
|
|
70
|
+
/**
|
|
71
|
+
* NOT ROTATED FOR THE skrr RENAME. Owner decision, 2026-08-31.
|
|
72
|
+
*
|
|
73
|
+
* A rename is not a compromise. Custody is already established (GH Actions
|
|
74
|
+
* secret `OVERSKY_RELEASE_SIGNING_KEY` + AWS Secrets Manager), the keyId
|
|
75
|
+
* `daemon-2026-07` is a date, not a brand, and no user ever sees any of it.
|
|
76
|
+
*
|
|
77
|
+
* The rotation procedure documented above is shipped and correct — it just has
|
|
78
|
+
* nothing to do here. Running it would cost a fleet-saturation window (step 5)
|
|
79
|
+
* and buy nothing, and the window is exactly when a self-update path is most
|
|
80
|
+
* fragile. Rotate on a compromise or an expiry, not on a rename.
|
|
81
|
+
*
|
|
82
|
+
* The signed message prefix (`oversky-daemon-manifest`) stays for the same
|
|
83
|
+
* reason: it lives inside the signed bytes, so changing it breaks verification
|
|
84
|
+
* between old and new peers with zero user-visible benefit — the same call made
|
|
85
|
+
* for `X-Oversky-Origin`.
|
|
86
|
+
*/
|
|
87
|
+
export declare const OVERSKY_RELEASE_PUBLIC_KEYS: ReleaseKey[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* releaseKeys.ts — ed25519 public keys pinned into the daemon binary AND the
|
|
3
|
+
* CLI that installs it.
|
|
4
|
+
*
|
|
5
|
+
* These are the trust anchors the self-updater (`update.ts`) uses to verify
|
|
6
|
+
* the signed release manifest (`daemon/latest/daemon-version.json`) and the
|
|
7
|
+
* downloaded binary artifact OFFLINE, before swapping its own binary. The
|
|
8
|
+
* matching PRIVATE half lives ONLY in CI as the Secrets Manager secret
|
|
9
|
+
* `OVERSKY_RELEASE_SIGNING_KEY` (ed25519, base64 PKCS8) — it is never checked
|
|
10
|
+
* into the repo and never shipped in the binary.
|
|
11
|
+
*
|
|
12
|
+
* CURRENT STATE: a key is pinned (`daemon-2026-07`, provisioned 2026-07-13), so
|
|
13
|
+
* self-update signature verification is FAIL-CLOSED — an unsigned or
|
|
14
|
+
* badly-signed manifest or binary aborts the update.
|
|
15
|
+
*
|
|
16
|
+
* READ THE NEXT PARAGRAPH BEFORE EMPTYING THIS ARRAY.
|
|
17
|
+
*
|
|
18
|
+
* The empty array is not a safe default; it is a DISABLED SECURITY CONTROL.
|
|
19
|
+
* With no key pinned, `update.ts` logs a warning and proceeds on TLS + SHA256
|
|
20
|
+
* only — and the SHA256 comes from the same manifest fetched over the same
|
|
21
|
+
* channel, so it defends against corruption in transit, not against a
|
|
22
|
+
* compromised or spoofed update feed. That posture (mirrored from the desktop
|
|
23
|
+
* `MANIFEST_PUBLIC_KEY=''` default) existed because the keypair had not been
|
|
24
|
+
* minted yet. It has been minted. Removing the last entry below silently
|
|
25
|
+
* re-disables offline verification on every daemon that ships the change, and
|
|
26
|
+
* nothing in the build fails to tell you.
|
|
27
|
+
*
|
|
28
|
+
* To add a key (rotation, or re-provisioning after a compromise) run
|
|
29
|
+
* `node scripts/generate-release-keypair.mjs`. It mints the ed25519 keypair,
|
|
30
|
+
* prints the ready-to-paste `{ keyId, publicKeySpkiB64 }` line for the array
|
|
31
|
+
* below, and prints the commands to store the PRIVATE half in the GitHub
|
|
32
|
+
* secret `OVERSKY_RELEASE_SIGNING_KEY` + AWS Secrets Manager. Never commit the
|
|
33
|
+
* private half; the CI signer reads `OVERSKY_RELEASE_SIGNING_KEY` and MUST
|
|
34
|
+
* import `buildArtifactMessage` / `buildManifestMessage` from
|
|
35
|
+
* `release-manifest.ts` rather than reimplementing them, so signer and verifier
|
|
36
|
+
* can never drift. Add the *next* key alongside the current one BEFORE
|
|
37
|
+
* rotating, so an in-flight rotation verifies against either — the full
|
|
38
|
+
* procedure is the rotation runbook on the array below.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Pinned release-signing public keys, newest first. Verification tries each
|
|
42
|
+
* key in turn (key rotation: keep current + next together during a rotation
|
|
43
|
+
* window). Non-empty → fail-closed, which is the state today. Empty →
|
|
44
|
+
* verification DISABLED with only a logged warning; see the file header before
|
|
45
|
+
* ever letting this array become empty.
|
|
46
|
+
*
|
|
47
|
+
* ZERO-DOWNTIME KEY-ROTATION RUNBOOK (config-only, no code change):
|
|
48
|
+
* 1. Mint the next keypair: `node scripts/generate-release-keypair.mjs --force`.
|
|
49
|
+
* 2. Pin its public half ABOVE the current key in the array below, and ship
|
|
50
|
+
* that daemon build so the fleet trusts BOTH keys.
|
|
51
|
+
* 3. Store the new private half as the GH Actions secret
|
|
52
|
+
* OVERSKY_RELEASE_SIGNING_KEY_NEXT (+ optional OVERSKY_RELEASE_KEY_ID_NEXT
|
|
53
|
+
* label) — the signer then DUAL-SIGNS every release, emitting the additive
|
|
54
|
+
* v2 `sigs[]` envelope (scalar `sig` stays = the current/primary key so
|
|
55
|
+
* daemons that haven't picked up step 2 yet still verify).
|
|
56
|
+
* 4. Cut one release during the overlap window; it verifies for daemons
|
|
57
|
+
* pinning the old key, the new key, or both (see release-manifest.test.ts
|
|
58
|
+
* "ROTATION headroom").
|
|
59
|
+
* 5. Once the new-key build has saturated the fleet: promote NEXT→primary
|
|
60
|
+
* (rename the secret to OVERSKY_RELEASE_SIGNING_KEY), drop the old key from
|
|
61
|
+
* the array, and remove OVERSKY_RELEASE_SIGNING_KEY_NEXT. No downtime, and
|
|
62
|
+
* no further code change — the multi-sig machinery is already shipped.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* NOT ROTATED FOR THE skrr RENAME. Owner decision, 2026-08-31.
|
|
66
|
+
*
|
|
67
|
+
* A rename is not a compromise. Custody is already established (GH Actions
|
|
68
|
+
* secret `OVERSKY_RELEASE_SIGNING_KEY` + AWS Secrets Manager), the keyId
|
|
69
|
+
* `daemon-2026-07` is a date, not a brand, and no user ever sees any of it.
|
|
70
|
+
*
|
|
71
|
+
* The rotation procedure documented above is shipped and correct — it just has
|
|
72
|
+
* nothing to do here. Running it would cost a fleet-saturation window (step 5)
|
|
73
|
+
* and buy nothing, and the window is exactly when a self-update path is most
|
|
74
|
+
* fragile. Rotate on a compromise or an expiry, not on a rename.
|
|
75
|
+
*
|
|
76
|
+
* The signed message prefix (`oversky-daemon-manifest`) stays for the same
|
|
77
|
+
* reason: it lives inside the signed bytes, so changing it breaks verification
|
|
78
|
+
* between old and new peers with zero user-visible benefit — the same call made
|
|
79
|
+
* for `X-Oversky-Origin`.
|
|
80
|
+
*/
|
|
81
|
+
export const OVERSKY_RELEASE_PUBLIC_KEYS = [
|
|
82
|
+
// ── NEXT key goes ABOVE this line during a rotation (newest first). ──
|
|
83
|
+
// Provisioned 2026-07-13. Private half: GH Actions secret
|
|
84
|
+
// OVERSKY_RELEASE_SIGNING_KEY + AWS Secrets Manager
|
|
85
|
+
// (us-east-1: oversky/daemon/release-signing-key). Self-update signature
|
|
86
|
+
// verification is now FAIL-CLOSED.
|
|
87
|
+
{
|
|
88
|
+
keyId: 'daemon-2026-07',
|
|
89
|
+
publicKeySpkiB64: 'MCowBQYDK2VwAyEA7ArbRnkbRW354XZ4MNS1MRLCwrTFauQzsw4GP27mMfI=',
|
|
90
|
+
},
|
|
91
|
+
];
|