@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.
@@ -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/cjs/index.js CHANGED
@@ -11,7 +11,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
11
11
  exports.challenge = exports.generateVerifier = exports.invalidateHeadersHelperCache = exports.runHeadersHelper = exports.SENTINEL_TRIGGERING_FAILURES = exports.__resetAuthHelperFailureReasonForTest = exports.getLastAuthHelperFailureReason = exports.getDefaultHelperTtlMs = exports.invokeAuthHelper = exports.AuthHelperUnavailableError = exports.AuthHelperUntrustedError = exports.CI_TOKEN_PREFIX = exports.classifyTokenKind = exports.invalidateAuthHelperCache = exports.__resetCredentialResolverForTest = exports.CREDENTIAL_PRECEDENCE = exports.CredentialResolver = exports.__setFdReaderForTest = exports.readAuthFromFd = exports.createRefreshScheduler = exports.UNKNOWN_401_BUDGET = exports.__resetUnknown401StreaksForTest = exports.__refreshDiagnostics = exports.installLockExitHandlers = exports.clearReauthState = exports.needsReauthState = exports.persistReauthState = exports.withAuthLock = exports.revokeDaemonRefreshSession = exports.refreshDaemonToken = exports.classifyRefreshResponse = exports.EXIT_NEEDS_REAUTH = exports.formatReauthMessage = exports.handleMidStreamAuthExpiry = exports.isAuthExpiredSseEvent = exports.decodeJwtPayload = exports.decodeJwtExpiry = exports.TransientAuthFailure = exports.PermanentAuthFailure = exports.emitAuthTelemetry = exports.getAuthBinaryName = exports.getAuthKillSwitches = exports.getAuthConfigDir = exports.getAuthLogger = exports.configureAuthCore = exports.OAUTH_CLI_SCOPE = exports.isDaemonScope = exports.VALID_DAEMON_SCOPE_SET = exports.ALL_DAEMON_SCOPES = exports.SCOPES = void 0;
12
12
  exports.tokenHash = exports.publicJwkFromX = exports.normalizeHtu = exports.jwkThumbprint = exports.generateDeviceKeyPair = exports.buildDeviceProof = exports.zeroizeKekCaches = exports.WindowsKek = exports.registerZeroizeHook = exports.MacosKek = exports.LinuxKek = exports.KekUnavailableError = exports.UnavailableKek = exports.InMemoryKek = exports.getKekStrategy = exports.__setKekStrategyForTest = exports.credEnvelopeWrap = exports.credEnvelopeUnwrap = exports.credEnvelopeSerialize = exports.credSealBuffer = exports.credOpenBuffer = exports.CRED_KEY_LEN = exports.isCredEnvelopeString = exports.generateDek = exports.CRED_ENVELOPE_MAGIC = exports.credEnvelopeEncrypt = exports.credEnvelopeDeserialize = exports.credEnvelopeDecrypt = exports.buildCredentialAad = exports.isValidRecoveryCode = exports.normalizeRecoveryCode = exports.parseRecoveryCode = exports.formatRecoveryCode = exports.MAX_TTL_DAYS = exports.MIN_TTL_DAYS = exports.parseTtlDays = exports.LoginExchangeError = exports.LoginInitError = exports.loginWithLocalhost = exports.LocalCallbackMalformedError = exports.LocalCallbackDeniedError = exports.LocalCallbackStateMismatchError = exports.LocalCallbackTimeoutError = exports.LocalCallbackBindError = exports.LocalCallbackError = exports.DEFAULT_LOGIN_TIMEOUT_MS = exports.startLocalCallback = exports.verifyChallenge = exports.safeEqual = exports.generateState = void 0;
13
13
  exports.getHarnessTier = exports.HARNESS_TIERS = exports.pairUint8ArrayToBase64Url = exports.pairUint8ArrayToBase64 = exports.parsePairUrl = exports.parsePairPlaintext = exports.parsePairBundle = exports.openPairBundleBase64 = exports.generatePairKeyPair = exports.buildPairUrl = exports.buildPairAad = exports.pairBase64UrlToUint8Array = exports.pairBase64ToUint8Array = exports.assemblePairBundleBase64 = exports.PairBundleError = exports.PAIR_SECRET_LEN = exports.PAIR_PUBKEY_LEN = exports.PAIR_NONCE_LEN = exports.PAIR_BUNDLE_MAX_BASE64_LEN = exports.PAIR_AAD_MAX_LEN = exports.PAIR_AAD_PREFIX = exports.__resetCachedDaemonIdForTest = exports.legacyDaemonUuids = exports.ensureMachineDaemonId = exports.__getDeviceIdentityStateForTest = exports.__setDeviceIdentityStateForTest = exports.__enrollmentBackoffPathForTest = exports.__enrollmentMarkerPathForTest = exports.__privateKeyPathForTest = exports.__publicKeyPathForTest = exports.describeDeviceIdentityState = exports.getDevicePublicKey = exports.isDeviceIdentityActive = exports.clearEnrollmentMarker = exports.enrollWithServer = exports.signServerRequest = exports.resetDeviceIdentity = exports.initDeviceIdentity = exports.__resetShutdownGuardForTest = exports.__getCredEnvelopeStateForTest = exports.__setCredEnvelopeStateForTest = exports.__wrappedDekFilePathForTest = exports.maybeDecryptOnRead = exports.maybeEncryptForWrite = exports.describeCredEnvelopeState = exports.isCredEnvelopeActive = exports.shutdownCredEnvelope = exports.resetCredEnvelope = exports.initCredEnvelope = exports.verifyDeviceProof = void 0;
14
- exports.isConfigRootOverridden = exports.configRootPath = exports.resolveConfigRoot = exports.SKY_CODE_FEED_BASE_ENV = exports.skyCodeFeedBase = exports.skyCodeChannelPrefix = exports.resolveSkyCodeChannel = exports.resolveChannelManifestUrl = exports.isSkyCodeChannel = exports.SKY_CODE_MANIFEST_FILE = exports.SKY_CODE_CHANNEL_ENV = exports.SKY_CODE_CHANNELS = exports.DEFAULT_SKY_CODE_FEED_BASE = exports.DEFAULT_SKY_CODE_CHANNEL = exports.describeLegacyState = exports.findLegacyLocalState = exports.DEFAULT_BINARY_NAME = exports.LEGACY_NATIVE_ID_PREFIXES = exports.NATIVE_ID_PREFIX = exports.LEGACY_CONFIG_DIR_NAMES = exports.CONFIG_DIR_NAME = exports.SENSITIVE_ENV_PREFIX_EXCEPTIONS = exports.SENSITIVE_ENV_SUFFIXES = exports.SENSITIVE_ENV_PREFIXES = exports.SENSITIVE_ENV_VARS = exports.sanitizeSpawnEnv = exports.tierPermitsConfiguredAutoMode = exports.credentialPolicyForTier = void 0;
14
+ exports.isConfigRootOverridden = exports.configRootPath = exports.resolveConfigRoot = exports.verifyManifest = exports.verifyArtifact = exports.getDeltaFor = exports.buildManifestMessage = exports.buildArtifactMessage = exports.MAX_SIGS = exports.OVERSKY_RELEASE_PUBLIC_KEYS = exports.SKY_CODE_FEED_BASE_ENV = exports.skyCodeFeedBase = exports.skyCodeChannelPrefix = exports.resolveSkyCodeChannel = exports.resolveChannelManifestUrl = exports.isSkyCodeChannel = exports.SKY_CODE_MANIFEST_FILE = exports.SKY_CODE_CHANNEL_ENV = exports.SKY_CODE_CHANNELS = exports.DEFAULT_SKY_CODE_FEED_BASE = exports.DEFAULT_SKY_CODE_CHANNEL = exports.describeLegacyState = exports.findLegacyLocalState = exports.DEFAULT_BINARY_NAME = exports.LEGACY_NATIVE_ID_PREFIXES = exports.NATIVE_ID_PREFIX = exports.LEGACY_CONFIG_DIR_NAMES = exports.CONFIG_DIR_NAME = exports.SENSITIVE_ENV_PREFIX_EXCEPTIONS = exports.SENSITIVE_ENV_SUFFIXES = exports.SENSITIVE_ENV_PREFIXES = exports.SENSITIVE_ENV_VARS = exports.sanitizeSpawnEnv = exports.tierPermitsConfiguredAutoMode = exports.credentialPolicyForTier = void 0;
15
15
  // Daemon scope vocabulary — single source of truth for non-Mongoose tiers
16
16
  // (daemon, desktop, cli). Schema-bound canonical lives in
17
17
  // `@skrr-ai/data-schemas/common/daemonScopes`; the two are kept byte-equal
@@ -315,6 +315,20 @@ Object.defineProperty(exports, "resolveSkyCodeChannel", { enumerable: true, get:
315
315
  Object.defineProperty(exports, "skyCodeChannelPrefix", { enumerable: true, get: function () { return skyCodeChannels_js_1.skyCodeChannelPrefix; } });
316
316
  Object.defineProperty(exports, "skyCodeFeedBase", { enumerable: true, get: function () { return skyCodeChannels_js_1.skyCodeFeedBase; } });
317
317
  Object.defineProperty(exports, "SKY_CODE_FEED_BASE_ENV", { enumerable: true, get: function () { return skyCodeChannels_js_1.SKY_CODE_FEED_BASE_ENV; } });
318
+ // Daemon release trust: the pinned ed25519 keys and the canonical signature
319
+ // messages. Shared because BOTH the daemon (self-update) and the CLI
320
+ // (`skrr daemon install`, which runs when there is no daemon to ask) verify the
321
+ // same manifest against the same root. See releaseManifest.ts for why a mirror
322
+ // would be the wrong shape here.
323
+ var releaseKeys_js_1 = require("./releaseKeys.js");
324
+ Object.defineProperty(exports, "OVERSKY_RELEASE_PUBLIC_KEYS", { enumerable: true, get: function () { return releaseKeys_js_1.OVERSKY_RELEASE_PUBLIC_KEYS; } });
325
+ var releaseManifest_js_1 = require("./releaseManifest.js");
326
+ Object.defineProperty(exports, "MAX_SIGS", { enumerable: true, get: function () { return releaseManifest_js_1.MAX_SIGS; } });
327
+ Object.defineProperty(exports, "buildArtifactMessage", { enumerable: true, get: function () { return releaseManifest_js_1.buildArtifactMessage; } });
328
+ Object.defineProperty(exports, "buildManifestMessage", { enumerable: true, get: function () { return releaseManifest_js_1.buildManifestMessage; } });
329
+ Object.defineProperty(exports, "getDeltaFor", { enumerable: true, get: function () { return releaseManifest_js_1.getDeltaFor; } });
330
+ Object.defineProperty(exports, "verifyArtifact", { enumerable: true, get: function () { return releaseManifest_js_1.verifyArtifact; } });
331
+ Object.defineProperty(exports, "verifyManifest", { enumerable: true, get: function () { return releaseManifest_js_1.verifyManifest; } });
318
332
  // The one definition of the skrr root, shared by `skrr` and `skrrd`. See the
319
333
  // module for why re-deriving it kept producing divergences.
320
334
  var configRoot_js_1 = require("./configRoot.js");
@@ -64,7 +64,6 @@ exports.__resetKekCacheForTest = __resetKekCacheForTest;
64
64
  * cleaning up the corresponding Secret Service entry when finished.
65
65
  */
66
66
  const node_child_process_1 = require("node:child_process");
67
- const node_util_1 = require("node:util");
68
67
  const node_crypto_1 = __importDefault(require("node:crypto"));
69
68
  const node_os_1 = __importDefault(require("node:os"));
70
69
  const node_fs_1 = __importDefault(require("node:fs"));
@@ -104,7 +103,6 @@ const HKDF_INFO = Buffer.from('oversky-cred-kek-v1');
104
103
  /** Filesystem paths for Tier-3 IKM components. */
105
104
  const PATH_LOGINUID = '/proc/self/loginuid';
106
105
  const PATH_MACHINE_ID = '/etc/machine-id';
107
- const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
108
106
  let _execFileForTest = null;
109
107
  /**
110
108
  * @internal Test seam — inject a mock execFile for hermetic tests so the
@@ -118,23 +116,38 @@ function __setExecFileForTest(fn) {
118
116
  _secretToolProbeCache = undefined;
119
117
  _tierSelection = null;
120
118
  }
121
- /** Default execFile wrapper used when no test override is installed. */
122
- const defaultExec = async (file, args, options) => {
123
- const result = await execFileAsync(file, args, {
124
- encoding: 'utf-8',
125
- ...(options ?? {}),
119
+ /**
120
+ * Default execFile wrapper used when no test override is installed.
121
+ *
122
+ * `input` is WRITTEN to the child's stdin here, and stdin is always closed. The
123
+ * previous version forwarded `input` as an execFile option — but only the SYNC
124
+ * child_process functions honour `input`; async `execFile` ignores it. So
125
+ * `secret-tool store`, which reads the secret from stdin, got neither the secret
126
+ * nor EOF and waited forever: Tier-2 enrollment could not complete on any Linux
127
+ * desktop with a working Secret Service. Closing stdin also means no other call
128
+ * can block on a tool that unexpectedly reads it.
129
+ */
130
+ const defaultExec = (file, args, options) => new Promise((resolve, reject) => {
131
+ const { input, ...execOptions } = options ?? {};
132
+ const child = (0, node_child_process_1.execFile)(file, args, { encoding: 'utf-8', ...execOptions }, (err, stdout, stderr) => {
133
+ // Defensive coercion — when callers override the encoding, output may
134
+ // surface as Buffer; normalize both shapes.
135
+ const out = typeof stdout === 'string' ? stdout : Buffer.from(stdout).toString('utf-8');
136
+ const errOut = typeof stderr === 'string' ? stderr : Buffer.from(stderr).toString('utf-8');
137
+ if (err) {
138
+ // Same shape promisify(execFile) produced, which the probe reads.
139
+ reject(Object.assign(err, { stdout: out, stderr: errOut }));
140
+ return;
141
+ }
142
+ resolve({ stdout: out, stderr: errOut });
126
143
  });
127
- // Defensive coercion — when callers override the encoding, stdout may
128
- // surface as Buffer/Uint8Array; the typeof guard keeps both shapes safe.
129
- return {
130
- stdout: typeof result.stdout === 'string'
131
- ? result.stdout
132
- : Buffer.from(result.stdout).toString('utf-8'),
133
- stderr: typeof result.stderr === 'string'
134
- ? result.stderr
135
- : Buffer.from(result.stderr).toString('utf-8'),
136
- };
137
- };
144
+ if (child.stdin) {
145
+ // A child that exits without reading stdin makes the write fail with EPIPE;
146
+ // the exit status is what reports that, not this stream.
147
+ child.stdin.on('error', () => undefined);
148
+ child.stdin.end(input ?? '');
149
+ }
150
+ });
138
151
  function exec(file, args, options) {
139
152
  const impl = _execFileForTest ?? defaultExec;
140
153
  return impl(file, args, { encoding: 'utf-8', ...options });
@@ -166,6 +179,41 @@ function readFileTrimmed(path) {
166
179
  // ---------------------------------------------------------------------------
167
180
  /** `undefined` = not yet probed; `boolean` = cached probe outcome. */
168
181
  let _secretToolProbeCache;
182
+ /**
183
+ * True when `secret-tool` never started, as opposed to starting and exiting
184
+ * non-zero.
185
+ *
186
+ * `execFile` reports the two differently: a process that ran and failed carries
187
+ * its numeric exit code, and a spawn that failed carries a STRING errno. Only
188
+ * `ENOENT` used to count as "missing", so every other spawn failure read as a
189
+ * working binary — and the probe then picked the libsecret tier, whose `store`
190
+ * cannot succeed either, which disables the credential envelope and makes the CLI
191
+ * refuse to persist any credential at all.
192
+ *
193
+ * The case that found it: a PATH entry the process may not search. execvp
194
+ * reports EACCES rather than ENOENT once it has met one, and a systemd unit that
195
+ * makes /snap inaccessible while PATH still lists /snap/bin (the Dedicated Runtime
196
+ * daemon sandbox did) turns every missing binary into EACCES.
197
+ */
198
+ function secretToolDidNotRun(err) {
199
+ const { code, status } = err;
200
+ return (typeof code === 'string' && SECRET_TOOL_UNRUNNABLE.has(code)) || status === 127;
201
+ }
202
+ /**
203
+ * Spawn errnos that mean the binary cannot be executed from here, permanently
204
+ * for this process. Deliberately NOT every string errno: EAGAIN, EMFILE or ENOMEM
205
+ * are transient, and caching "absent" on one of those would move a machine whose
206
+ * libsecret works onto the machine-id tier for the rest of the process — where the
207
+ * existing wrapped key cannot be opened and would be re-enrolled over.
208
+ */
209
+ const SECRET_TOOL_UNRUNNABLE = new Set([
210
+ 'ENOENT',
211
+ 'EACCES',
212
+ 'ENOTDIR',
213
+ 'ELOOP',
214
+ 'ENOEXEC',
215
+ 'EPERM',
216
+ ]);
169
217
  /**
170
218
  * Probe secret-tool availability. Two checks:
171
219
  * 1. `secret-tool --version` — ENOENT or exit 127 means binary missing.
@@ -183,9 +231,7 @@ async function probeSecretTool() {
183
231
  await exec('secret-tool', ['--version']);
184
232
  }
185
233
  catch (err) {
186
- const code = err.code;
187
- const status = err.status;
188
- if (code === 'ENOENT' || status === 127) {
234
+ if (secretToolDidNotRun(err)) {
189
235
  _secretToolProbeCache = false;
190
236
  return false;
191
237
  }
@@ -199,6 +245,10 @@ async function probeSecretTool() {
199
245
  _secretToolProbeCache = true;
200
246
  }
201
247
  catch (err) {
248
+ if (secretToolDidNotRun(err)) {
249
+ _secretToolProbeCache = false;
250
+ return false;
251
+ }
202
252
  const stderr = err.stderr ?? '';
203
253
  const isTransportError = DBUS_TRANSPORT_ERRORS.some((s) => stderr.includes(s));
204
254
  if (isTransportError) {
@@ -22,8 +22,9 @@ Object.defineProperty(exports, "PermanentAuthFailure", { enumerable: true, get:
22
22
  Object.defineProperty(exports, "TransientAuthFailure", { enumerable: true, get: function () { return types_js_1.TransientAuthFailure; } });
23
23
  /**
24
24
  * Maps server-supplied error codes to the canonical PermanentAuthReason values
25
- * exported from types.ts. Only the four values present in PermanentAuthReason
26
- * are listed; anything else falls through to 'REFRESH_INVALID'.
25
+ * exported from types.ts. `REFRESH_MISSING` is a server protocol code rather
26
+ * than a user-facing reason; it maps to `REFRESH_INVALID` because an identical
27
+ * retry cannot gain the credential the server says was absent.
27
28
  */
28
29
  const PERMANENT_CODE_SET = new Set([
29
30
  'REFRESH_INVALID',
@@ -63,6 +64,12 @@ function classifyRefreshResponse(status, body) {
63
64
  }
64
65
  // 401 — auth failure; check for known permanent code
65
66
  if (status === 401) {
67
+ if (body?.code === 'REFRESH_MISSING') {
68
+ return {
69
+ kind: 'failure',
70
+ failure: new types_js_1.PermanentAuthFailure('REFRESH_INVALID', body.message ?? 'Refresh token missing'),
71
+ };
72
+ }
66
73
  const code = body?.code;
67
74
  if (code && PERMANENT_CODE_SET.has(code)) {
68
75
  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,94 @@
1
+ "use strict";
2
+ /**
3
+ * releaseKeys.ts — ed25519 public keys pinned into the daemon binary AND the
4
+ * CLI that installs it.
5
+ *
6
+ * These are the trust anchors the self-updater (`update.ts`) uses to verify
7
+ * the signed release manifest (`daemon/latest/daemon-version.json`) and the
8
+ * downloaded binary artifact OFFLINE, before swapping its own binary. The
9
+ * matching PRIVATE half lives ONLY in CI as the Secrets Manager secret
10
+ * `OVERSKY_RELEASE_SIGNING_KEY` (ed25519, base64 PKCS8) — it is never checked
11
+ * into the repo and never shipped in the binary.
12
+ *
13
+ * CURRENT STATE: a key is pinned (`daemon-2026-07`, provisioned 2026-07-13), so
14
+ * self-update signature verification is FAIL-CLOSED — an unsigned or
15
+ * badly-signed manifest or binary aborts the update.
16
+ *
17
+ * READ THE NEXT PARAGRAPH BEFORE EMPTYING THIS ARRAY.
18
+ *
19
+ * The empty array is not a safe default; it is a DISABLED SECURITY CONTROL.
20
+ * With no key pinned, `update.ts` logs a warning and proceeds on TLS + SHA256
21
+ * only — and the SHA256 comes from the same manifest fetched over the same
22
+ * channel, so it defends against corruption in transit, not against a
23
+ * compromised or spoofed update feed. That posture (mirrored from the desktop
24
+ * `MANIFEST_PUBLIC_KEY=''` default) existed because the keypair had not been
25
+ * minted yet. It has been minted. Removing the last entry below silently
26
+ * re-disables offline verification on every daemon that ships the change, and
27
+ * nothing in the build fails to tell you.
28
+ *
29
+ * To add a key (rotation, or re-provisioning after a compromise) run
30
+ * `node scripts/generate-release-keypair.mjs`. It mints the ed25519 keypair,
31
+ * prints the ready-to-paste `{ keyId, publicKeySpkiB64 }` line for the array
32
+ * below, and prints the commands to store the PRIVATE half in the GitHub
33
+ * secret `OVERSKY_RELEASE_SIGNING_KEY` + AWS Secrets Manager. Never commit the
34
+ * private half; the CI signer reads `OVERSKY_RELEASE_SIGNING_KEY` and MUST
35
+ * import `buildArtifactMessage` / `buildManifestMessage` from
36
+ * `release-manifest.ts` rather than reimplementing them, so signer and verifier
37
+ * can never drift. Add the *next* key alongside the current one BEFORE
38
+ * rotating, so an in-flight rotation verifies against either — the full
39
+ * procedure is the rotation runbook on the array below.
40
+ */
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.OVERSKY_RELEASE_PUBLIC_KEYS = void 0;
43
+ /**
44
+ * Pinned release-signing public keys, newest first. Verification tries each
45
+ * key in turn (key rotation: keep current + next together during a rotation
46
+ * window). Non-empty → fail-closed, which is the state today. Empty →
47
+ * verification DISABLED with only a logged warning; see the file header before
48
+ * ever letting this array become empty.
49
+ *
50
+ * ZERO-DOWNTIME KEY-ROTATION RUNBOOK (config-only, no code change):
51
+ * 1. Mint the next keypair: `node scripts/generate-release-keypair.mjs --force`.
52
+ * 2. Pin its public half ABOVE the current key in the array below, and ship
53
+ * that daemon build so the fleet trusts BOTH keys.
54
+ * 3. Store the new private half as the GH Actions secret
55
+ * OVERSKY_RELEASE_SIGNING_KEY_NEXT (+ optional OVERSKY_RELEASE_KEY_ID_NEXT
56
+ * label) — the signer then DUAL-SIGNS every release, emitting the additive
57
+ * v2 `sigs[]` envelope (scalar `sig` stays = the current/primary key so
58
+ * daemons that haven't picked up step 2 yet still verify).
59
+ * 4. Cut one release during the overlap window; it verifies for daemons
60
+ * pinning the old key, the new key, or both (see release-manifest.test.ts
61
+ * "ROTATION headroom").
62
+ * 5. Once the new-key build has saturated the fleet: promote NEXT→primary
63
+ * (rename the secret to OVERSKY_RELEASE_SIGNING_KEY), drop the old key from
64
+ * the array, and remove OVERSKY_RELEASE_SIGNING_KEY_NEXT. No downtime, and
65
+ * no further code change — the multi-sig machinery is already shipped.
66
+ */
67
+ /**
68
+ * NOT ROTATED FOR THE skrr RENAME. Owner decision, 2026-08-31.
69
+ *
70
+ * A rename is not a compromise. Custody is already established (GH Actions
71
+ * secret `OVERSKY_RELEASE_SIGNING_KEY` + AWS Secrets Manager), the keyId
72
+ * `daemon-2026-07` is a date, not a brand, and no user ever sees any of it.
73
+ *
74
+ * The rotation procedure documented above is shipped and correct — it just has
75
+ * nothing to do here. Running it would cost a fleet-saturation window (step 5)
76
+ * and buy nothing, and the window is exactly when a self-update path is most
77
+ * fragile. Rotate on a compromise or an expiry, not on a rename.
78
+ *
79
+ * The signed message prefix (`oversky-daemon-manifest`) stays for the same
80
+ * reason: it lives inside the signed bytes, so changing it breaks verification
81
+ * between old and new peers with zero user-visible benefit — the same call made
82
+ * for `X-Oversky-Origin`.
83
+ */
84
+ exports.OVERSKY_RELEASE_PUBLIC_KEYS = [
85
+ // ── NEXT key goes ABOVE this line during a rotation (newest first). ──
86
+ // Provisioned 2026-07-13. Private half: GH Actions secret
87
+ // OVERSKY_RELEASE_SIGNING_KEY + AWS Secrets Manager
88
+ // (us-east-1: oversky/daemon/release-signing-key). Self-update signature
89
+ // verification is now FAIL-CLOSED.
90
+ {
91
+ keyId: 'daemon-2026-07',
92
+ publicKeySpkiB64: 'MCowBQYDK2VwAyEA7ArbRnkbRW354XZ4MNS1MRLCwrTFauQzsw4GP27mMfI=',
93
+ },
94
+ ];
@@ -0,0 +1,161 @@
1
+ import { type ReleaseKey } from './releaseKeys.js';
2
+ export type { ReleaseKey } from './releaseKeys.js';
3
+ /**
4
+ * One entry in a v2 multi-signature envelope. `sig` is a base64 ed25519
5
+ * signature over the EXACT SAME bytes as the v1 scalar `sig` (buildArtifact/
6
+ * ManifestMessage) — v2 is an envelope change, not a wire-format change. The
7
+ * `keyId` is an ADVISORY label only: verification trial-verifies every sig
8
+ * against every pinned key and NEVER consults keyId, so a mislabeled keyId can
9
+ * neither grant nor withhold trust (this structurally defeats keyId
10
+ * substitution). It exists for operator diagnostics ("which key signed this?").
11
+ */
12
+ export interface ManifestSignature {
13
+ keyId: string;
14
+ /** base64 ed25519 signature over the same bytes as the v1 `sig`. */
15
+ sig: string;
16
+ }
17
+ /**
18
+ * v-delta (optional): a differential-update patch that transforms a specific
19
+ * PRIOR version's binary into this release's binary — a brotli-compressed
20
+ * fossil delta. UNSIGNED, exactly like `keyId`/`size`/`sigs` (NOT part of
21
+ * buildArtifactMessage/buildManifestMessage), so it never changes the signed
22
+ * bytes and #782's envelope invariant holds. Trust comes SOLELY from verifying
23
+ * the RECONSTRUCTED binary against the signed `sha256` + ed25519 gates: a
24
+ * tampered/garbage delta yields a non-matching binary and the daemon falls back
25
+ * to the full signed download. `sha256` here is TRANSPORT integrity of the patch
26
+ * file only — NEVER an install gate.
27
+ */
28
+ export interface DeltaDescriptor {
29
+ /** Absolute public URL of the brotli-compressed fossil delta patch. */
30
+ url: string;
31
+ /** Lowercase hex sha256 of the patch file — transport integrity, not trust. */
32
+ sha256: string;
33
+ /** Size of the patch file in bytes. */
34
+ size: number;
35
+ }
36
+ /** One downloadable, immutable binary for a single `<platform>-<arch>` target. */
37
+ export interface PlatformArtifact {
38
+ /** Bare filename, e.g. `oversky-darwin-arm64`. */
39
+ file: string;
40
+ /** Absolute public URL under the CloudFront feed. */
41
+ url: string;
42
+ /** Lowercase hex sha256 of the binary bytes. */
43
+ sha256: string;
44
+ /** Size in bytes. */
45
+ size: number;
46
+ /** base64 ed25519 signature over buildArtifactMessage(...) — the PRIMARY key. */
47
+ sig: string;
48
+ /**
49
+ * v2 (optional): EVERY signature (primary + rotation key) over the same
50
+ * bytes as `sig`. Emitted ONLY during a key-rotation window (>1 signing key);
51
+ * absent in steady single-key state, so a normal manifest is byte-identical
52
+ * to v1. When present it is the authoritative set — see verifyWithKeysMulti.
53
+ */
54
+ sigs?: ManifestSignature[];
55
+ /**
56
+ * v-delta (optional, UNSIGNED): differential-update patches keyed by the
57
+ * from-version they apply to (e.g. `"0.8.0"`). Absent in steady state. See
58
+ * DeltaDescriptor — the reconstructed binary is verified against `sha256`
59
+ * above, so deltas add NO trust surface.
60
+ */
61
+ deltas?: Record<string, DeltaDescriptor>;
62
+ }
63
+ /**
64
+ * The signed release manifest. `platforms` is keyed by `<platform>-<arch>`
65
+ * (e.g. `darwin-arm64`, `linux-x64`). The top-level `sig` covers
66
+ * buildManifestMessage(manifest-without-sig).
67
+ */
68
+ export interface DaemonVersionManifest<TPolicy = unknown> {
69
+ schemaVersion: number;
70
+ version: string;
71
+ minimum: string;
72
+ forceUpdate: boolean;
73
+ keyId: string;
74
+ platforms: Record<string, PlatformArtifact>;
75
+ /**
76
+ * base64 ed25519 signature over buildManifestMessage(this-without-sig) — the
77
+ * PRIMARY key. Kept populated even in v2 so an OLD (v1-only) daemon still
78
+ * verifies against the key the lagging fleet trusts.
79
+ */
80
+ sig: string;
81
+ /**
82
+ * v2 (optional): EVERY manifest signature (primary + rotation key). Emitted
83
+ * ONLY during a rotation window; when present it is authoritative — see
84
+ * verifyWithKeysMulti. `schemaVersion` stays a reader hint (unsigned, inert);
85
+ * v2 is detected by `Array.isArray(sigs)`, not by the version number.
86
+ */
87
+ sigs?: ManifestSignature[];
88
+ /**
89
+ * Independently signed release decision envelope. It is intentionally not
90
+ * part of the v1 manifest message so existing daemons can ignore it; newer
91
+ * daemons verify this nested signature before honouring compatibility or
92
+ * rollout controls.
93
+ */
94
+ policy?: TPolicy;
95
+ }
96
+ /**
97
+ * Canonical bytes signed for a single platform artifact. Newline-joined, fixed
98
+ * field order, `v1` tag. `sha256` is lowercased so the message is stable
99
+ * regardless of the hex casing the caller supplies.
100
+ */
101
+ export declare function buildArtifactMessage(a: {
102
+ platform: string;
103
+ version: string;
104
+ sha256: string;
105
+ url: string;
106
+ }): string;
107
+ /**
108
+ * Canonical bytes signed for the whole manifest (excluding the top-level
109
+ * `sig`). Platform keys are sorted so the message is independent of JSON key
110
+ * order; each contributes `<platform>=<sha256-lower>`. `forceUpdate` is
111
+ * serialized strictly (`String(forceUpdate === true)` → `"true"`/`"false"`).
112
+ */
113
+ export declare function buildManifestMessage(m: {
114
+ version: string;
115
+ minimum: string;
116
+ forceUpdate: boolean;
117
+ platforms: Record<string, {
118
+ sha256: string;
119
+ }>;
120
+ }): string;
121
+ /**
122
+ * Discriminated verification result:
123
+ * - `{ ok: true }` — signature verified against a pinned key.
124
+ * - `{ ok: false, reason }` — keys are present but nothing verified (FAIL-CLOSED).
125
+ * - `{ disabled: true }` — no keys pinned; verification is intentionally off.
126
+ */
127
+ export type VerifyResult = {
128
+ ok: true;
129
+ } | {
130
+ ok: false;
131
+ reason: string;
132
+ } | {
133
+ disabled: true;
134
+ };
135
+ /**
136
+ * Upper bound on signatures we will trial-verify per artifact/manifest. A
137
+ * rotation window needs at most 2–3 (current + next). `sigs[]` is NOT part of
138
+ * the signed bytes, so an attacker can pad it; this cap bounds the verify work
139
+ * they can force to O(MAX_SIGS × pinnedKeys) — a few hundred microseconds even
140
+ * at the cap. A valid signature placed beyond the cap is treated as absent.
141
+ */
142
+ export declare const MAX_SIGS = 8;
143
+ /**
144
+ * Verify a single platform artifact's `sig` over buildArtifactMessage(...).
145
+ * `platform` is the `<platform>-<arch>` key; `version` is the manifest version
146
+ * the artifact belongs to. Defaults to the baked-in pinned keys.
147
+ */
148
+ export declare function verifyArtifact(platform: string, version: string, artifact: PlatformArtifact, keys?: readonly ReleaseKey[]): VerifyResult;
149
+ /**
150
+ * Verify the manifest's top-level `sig` over buildManifestMessage(this).
151
+ * Defaults to the baked-in pinned keys.
152
+ */
153
+ export declare function verifyManifest(manifest: DaemonVersionManifest, keys?: readonly ReleaseKey[]): VerifyResult;
154
+ /**
155
+ * Look up a delta patch that transforms `fromVersion`'s binary into this
156
+ * artifact's binary. Returns null when none is advertised or the descriptor is
157
+ * malformed — the caller then does a full download. PURE; validates shape only
158
+ * (the delta is UNSIGNED, so trust comes from verifying the RECONSTRUCTED binary
159
+ * against the signed `artifact.sha256`, not from this metadata).
160
+ */
161
+ export declare function getDeltaFor(artifact: PlatformArtifact, fromVersion: string): DeltaDescriptor | null;