diff --git a/.github/workflows/ssh-relay-runtime-artifacts.yml b/.github/workflows/ssh-relay-runtime-artifacts.yml index 86b521deead..cff5861094d 100644 --- a/.github/workflows/ssh-relay-runtime-artifacts.yml +++ b/.github/workflows/ssh-relay-runtime-artifacts.yml @@ -268,6 +268,7 @@ jobs: src/main/ssh/ssh-relay-artifact-cache-entry.test.ts \ src/main/ssh/ssh-relay-artifact-cache-in-use-lease.test.ts \ src/main/ssh/ssh-relay-artifact-cache-eviction.test.ts \ + src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts \ src/main/ssh/ssh-relay-packaged-manifest.test.ts \ src/main/ssh/ssh-relay-runtime-identity.test.ts \ config/scripts/ssh-relay-runtime-compatibility.test.mjs \ @@ -803,6 +804,7 @@ jobs: src/main/ssh/ssh-relay-artifact-cache-entry.test.ts ` src/main/ssh/ssh-relay-artifact-cache-in-use-lease.test.ts ` src/main/ssh/ssh-relay-artifact-cache-eviction.test.ts ` + src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts ` src/main/ssh/ssh-relay-packaged-manifest.test.ts ` src/main/ssh/ssh-relay-runtime-identity.test.ts ` config/scripts/ssh-relay-runtime-compatibility.test.mjs ` diff --git a/config/scripts/ssh-relay-runtime-workflow.test.mjs b/config/scripts/ssh-relay-runtime-workflow.test.mjs index a23b0ae2a07..5807d274477 100644 --- a/config/scripts/ssh-relay-runtime-workflow.test.mjs +++ b/config/scripts/ssh-relay-runtime-workflow.test.mjs @@ -126,6 +126,7 @@ describe('SSH relay runtime artifact workflow', () => { 'ssh-relay-artifact-cache-entry.test.ts', 'ssh-relay-artifact-cache-in-use-lease.test.ts', 'ssh-relay-artifact-cache-eviction.test.ts', + 'ssh-relay-manifest-accepted-keys.test.ts', 'ssh-relay-packaged-manifest.test.ts', 'ssh-relay-runtime-identity.test.ts' ]) { diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md index a43ea2973c0..ad6c2ad4e1e 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist-summary.md @@ -9,9 +9,12 @@ keeps commands, hashes, runner identities, timings, and failure details. A checked box means the work has evidence in the detailed ledger. Design approval alone does not complete a box. -Active checkpoint: **Milestone 5 / Work Package 4 desktop resolver/cache — disconnected -official-build manifest loader proven on all six native clients; accepted-key/resource-input audit -next, 2026-07-15, Codex implementation owner.** `E-M5-PACKAGED-MANIFEST-LOCAL-001` passes 6/6 +Active checkpoint: **Milestone 5 / Work Package 4 desktop resolver/cache — disconnected desktop +accepted-public-key parser locally proven and wired; isolated commit/all-six native proof next, +2026-07-15, Codex implementation owner.** `E-M5-ACCEPTED-KEYS-AUDIT-001` and its RED precede +`E-M5-ACCEPTED-KEYS-LOCAL-001`: 4/4 focused, 19/19 trust, 304/304 non-full-size SSH relay, 12/12 +workflow/aggregate, typecheck, and lint gates pass. No production public key file or secret exists, +and no resource or consumer is connected. The prior `E-M5-PACKAGED-MANIFEST-LOCAL-001` passes 6/6 purpose-named tests, 300/300 non-full-size SSH relay tests, 279/279 release-script tests, typecheck, lint, format, max-lines, and diff gates. `E-M5-PACKAGED-MANIFEST-CI-WIRING-LOCAL-001` pins the suite into both native job families; exact-head run diff --git a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md index 5b551679a0b..4fd3f1e74fc 100644 --- a/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md +++ b/docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md @@ -8,7 +8,7 @@ work; keep exact commands, runner identities, hashes, metrics, and residual gaps Date created: 2026-07-14
Last updated: 2026-07-15
-Current phase: Milestone 5 / Work Package 4 desktop resolver/cache — **In progress — 2026-07-15, Codex implementation owner: all-six native packaged-manifest source gate closed; audit the next disconnected accepted-key/resource-embedding prerequisite**. `E-M5-PACKAGED-MANIFEST-CI-001` proves the fixed-resource loader on Linux, macOS, and Windows, x64 and arm64, under Node 24.18.0. Keep production keys/secrets, signed embedded bytes, Electron/cache-root/downloader/SSH/settings/tuples/publication/default behavior disconnected while selecting the next purpose-named RED.
+Current phase: Milestone 5 / Work Package 4 desktop resolver/cache — **In progress — 2026-07-15, Codex implementation owner: checkpoint and obtain all-six native evidence for the locally proven disconnected accepted-public-key parser**. `E-M5-ACCEPTED-KEYS-LOCAL-001` proves strict protected-aggregate document parity and both native workflow command lists locally. Keep the production key file/seed/environment, signed embedded bytes, builder/resource/consumer/cache/downloader/SSH/settings/tuples/publication/default behavior absent until exact-head native proof passes.
Session checkpoint: **In progress — 2026-07-15, Codex implementation owner** — user authority covers end-to-end PR-contained implementation, commits, pushes, CI, rehearsals, and release writes; merging to `main` remains explicitly prohibited. Commit `b79bcbd04` passes all six native source/full-size cache jobs, PR Checks, and Golden E2E. The artifact workflow is red only for the retained hosted Windows arm64 build-26200 versus required-26100 floor rejection after its primary proof. No Electron, downloader, SSH, setting, tuple, publication, or default-path consumer exists. Legacy remains the production default.
Primary design: [SSH relay GitHub Release plan](./2026-07-14-ssh-relay-github-release-plan.html)
Motivating issues: [#8450](https://github.com/stablyai/orca/issues/8450), [#1693](https://github.com/stablyai/orca/issues/1693) @@ -13933,6 +13933,81 @@ config/scripts/ssh-relay-runtime-workflow.test.mjs`. and immutable resource-input contract. External protected-environment/secret provisioning remains a separate repository-administrator gate and must not be inferred from source tests. +### E-M5-ACCEPTED-KEYS-AUDIT-001 — Desktop accepted-public-key document parity + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: exact pushed evidence head `6358fb466b2b2099f4c9add9b8e2f772664ec3ea`. +- Audit: complete reads of the desktop signature/loader modules and tests, protected manifest + workflow, aggregate command/signing handoff and tests, Electron builder config/tests, package + release scripts, and Linux/Windows/macOS release workflow build/package boundaries; targeted `rg` + over accepted keys, public-key encoding, `extraResources`, manifest signing, desktop build inputs, + and release packaging. +- Findings: + - the protected aggregate already defines the authoritative source format: exact + `{schemaVersion, keys}` fields, schema version 1, one to four keys, exact + `{keyId, publicKeyBase64}` entries, canonical base64 for exactly 32 Ed25519 public-key bytes, + derived SHA-256 key IDs, uniqueness, key-ID sorting, and a SHA-256 fingerprint over canonical + JSON; + - the protected workflow deliberately references an absent + `config/ssh-relay-runtime-manifest-accepted-keys.json` and tests ENOENT so no placeholder can be + mistaken for a reviewed trust root. Release-cut and macOS release workflows remain disconnected; + - the desktop signature verifier accepts decoded key bytes but has no strict parser for the + aggregate document. Adding that parser with deterministic test keys is the smallest useful + PR-contained parity boundary before production key review or immutable resource embedding; + - accepted keys must ultimately be compile-time desktop inputs. Loading a mutable adjacent key + file beside the manifest would let the trust root move with untrusted bytes and is not allowed; + - builder mapping, aggregate-to-desktop artifact download, tag checkout dependencies, byte-identical + packaged extraction, real public key review, and protected seed/environment provisioning remain + separate gates. +- Selected package: pure desktop parser returning cloned/sorted accepted key bytes plus the canonical + accepted-key document fingerprint; strict hostile-input tests and release-format parity; no file + I/O, Electron, resource mapping, manifest, signer, secret, cache, network, SSH, mode, or consumer. + +### E-M5-ACCEPTED-KEYS-LOCAL-RED-001 — Desktop accepted-key parser is absent + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: purpose-named `src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts` atop exact pushed + evidence head `6358fb466b2b2099f4c9add9b8e2f772664ec3ea`; production parser deliberately absent. +- Exact command: `/usr/bin/time -l pnpm exec vitest run --config config/vitest.config.ts +--maxWorkers=1 src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts`. +- Environment: macOS 26.2 arm64 build 25C56, Node v26.0.0, pnpm 10.24.0, Vitest 4.1.5. +- Result: required RED; one failed suite / zero collected tests in 174ms Vitest / 1.16s wall, + 131,989,504-byte maximum RSS, 96,179,600-byte peak memory footprint, and zero swaps. Import fails + because `./ssh-relay-manifest-accepted-keys` does not exist. +- Contracts encoded before implementation: canonical sort/fingerprint, cloned key bytes, frozen + container/records, one-to-four bound, exact fields/schema, canonical base64/32-byte keys, derived + identity, duplicate rejection, and input-order independence. +- Consumer-disconnection oracle: only the purpose-named test imports the absent module; fixtures use + deterministic test seeds. No production key file/seed/environment, resource mapping, Electron, + cache, network, SSH, setting, tuple, publication, or default call site is added. + +### E-M5-ACCEPTED-KEYS-LOCAL-001 — Desktop accepted-key parser and native wiring + +- Date: 2026-07-15 +- Owner: Codex implementation owner +- Source: working package atop exact pushed evidence head + `6358fb466b2b2099f4c9add9b8e2f772664ec3ea`; implementation/test are + `src/main/ssh/ssh-relay-manifest-accepted-keys.ts` and its purpose-named test. +- Focused command/result: `/usr/bin/time -l pnpm exec vitest run --config +config/vitest.config.ts --maxWorkers=1 src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts` passes + 1 file / 4 tests in 301ms Vitest / 1.23s wall, 131,874,816-byte maximum RSS, + 96,048,504-byte peak memory footprint, and zero swaps. +- Proven: exact schema/fields; one-to-four bound; canonical 32-byte base64; derived SHA-256 IDs; + duplicate rejection; input-order-independent sorting; cloned bytes; frozen container/records; and + the exact canonical JSON fingerprint used by the protected aggregate. +- Regression commands/results: accepted-keys/signature/packaged-loader trust suites pass 19/19; + complete `src/main/ssh/ssh-relay-*.test.ts` passes 23 files / 304 tests with three declared + full-size skips; workflow plus aggregate-command suites pass 12/12; full `pnpm typecheck` and + `pnpm lint` pass, including 41 reliability gates and no new max-lines bypass. +- Native wiring: the purpose-named suite is present once in each POSIX/PowerShell command list; the + workflow oracle requires exactly two occurrences. Exact-head all-six Node 24 execution remains + required before this source gate closes. +- Consumer-disconnection oracle: repository consumers remain test-only. The protected workflow still + requires the production accepted-key file to be absent; no key/seed/environment, resource bytes, + builder mapping, Electron/cache/network/SSH/mode/tuple/publication/default caller is added. + ## Accepted Gaps No product gap is accepted merely because it appears in this list. Each entry requires explicit diff --git a/src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts b/src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts new file mode 100644 index 00000000000..49f69eaf442 --- /dev/null +++ b/src/main/ssh/ssh-relay-manifest-accepted-keys.test.ts @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto' + +import nacl from 'tweetnacl' +import { describe, expect, it } from 'vitest' + +import { parseSshRelayManifestAcceptedKeyDocument } from './ssh-relay-manifest-accepted-keys' +import { sshRelayManifestKeyId } from './ssh-relay-manifest-signature' + +const first = nacl.sign.keyPair.fromSeed(Uint8Array.from({ length: 32 }, (_, index) => index)) +const second = nacl.sign.keyPair.fromSeed(Uint8Array.from({ length: 32 }, (_, index) => 31 - index)) + +function record(publicKey: Uint8Array) { + return { + keyId: sshRelayManifestKeyId(publicKey), + publicKeyBase64: Buffer.from(publicKey).toString('base64') + } +} + +function document(keys = [record(first.publicKey)]) { + return { schemaVersion: 1, keys } +} + +describe('SSH relay manifest accepted keys', () => { + it('parses, clones, sorts, freezes, and fingerprints the canonical release document', () => { + const input = document([record(second.publicKey), record(first.publicKey)]) + const parsed = parseSshRelayManifestAcceptedKeyDocument(input) + const sortedRecords = [...input.keys].sort((left, right) => + left.keyId < right.keyId ? -1 : left.keyId > right.keyId ? 1 : 0 + ) + const canonicalBytes = Buffer.from( + JSON.stringify({ schemaVersion: 1, keys: sortedRecords }), + 'utf8' + ) + + expect(parsed.acceptedKeys.map(({ keyId }) => keyId)).toEqual( + sortedRecords.map(({ keyId }) => keyId) + ) + expect(parsed.acceptedKeys[0].publicKey).not.toBe(first.publicKey) + expect(parsed.sha256).toBe( + `sha256:${createHash('sha256').update(canonicalBytes).digest('hex')}` + ) + expect(Object.isFrozen(parsed)).toBe(true) + expect(Object.isFrozen(parsed.acceptedKeys)).toBe(true) + expect(parsed.acceptedKeys.every(Object.isFrozen)).toBe(true) + }) + + it('accepts one to four unique keys independent of input order', () => { + const records = [record(first.publicKey), record(second.publicKey)] + const forward = parseSshRelayManifestAcceptedKeyDocument(document(records)) + const reverse = parseSshRelayManifestAcceptedKeyDocument(document(records.toReversed())) + + expect(forward.sha256).toBe(reverse.sha256) + expect(forward.acceptedKeys.map(({ keyId }) => keyId)).toEqual( + reverse.acceptedKeys.map(({ keyId }) => keyId) + ) + }) + + it('rejects malformed document shape and key counts', () => { + for (const input of [ + null, + [], + {}, + { schemaVersion: 2, keys: [record(first.publicKey)] }, + { schemaVersion: 1, keys: [], extra: true }, + { schemaVersion: 1, keys: [] }, + { schemaVersion: 1, keys: Array.from({ length: 5 }, () => record(first.publicKey)) } + ]) { + expect(() => parseSshRelayManifestAcceptedKeyDocument(input)).toThrow(/accepted|key|field/i) + } + }) + + it('rejects extra fields, non-canonical base64, wrong sizes, identity drift, and duplicates', () => { + const valid = record(first.publicKey) + for (const key of [ + { ...valid, extra: true }, + { ...valid, publicKeyBase64: `${valid.publicKeyBase64}\n` }, + { ...valid, publicKeyBase64: Buffer.alloc(31).toString('base64') }, + { ...valid, keyId: `sha256:${'0'.repeat(64)}` } + ]) { + expect(() => + parseSshRelayManifestAcceptedKeyDocument({ schemaVersion: 1, keys: [key] }) + ).toThrow(/accepted|key|base64|field|32/i) + } + expect(() => + parseSshRelayManifestAcceptedKeyDocument(document([valid, structuredClone(valid)])) + ).toThrow(/duplicate/i) + }) +}) diff --git a/src/main/ssh/ssh-relay-manifest-accepted-keys.ts b/src/main/ssh/ssh-relay-manifest-accepted-keys.ts new file mode 100644 index 00000000000..f6e5b82ae0c --- /dev/null +++ b/src/main/ssh/ssh-relay-manifest-accepted-keys.ts @@ -0,0 +1,99 @@ +import { createHash } from 'node:crypto' + +import { + sshRelayManifestKeyId, + type SshRelayManifestAcceptedKey +} from './ssh-relay-manifest-signature' +import type { SshRelayDigest } from './ssh-relay-runtime-identity' + +const BASE64 = /^[A-Za-z0-9+/]+={0,2}$/u +const MAXIMUM_KEYS = 4 + +type AcceptedKeyRecord = { + keyId: string + publicKeyBase64: string +} + +function assertObject(value: unknown, label: string): asserts value is Record { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`SSH relay manifest ${label} must be an object`) + } +} + +function assertExactFields(value: Record, fields: string[], label: string): void { + const actual = Object.keys(value).sort() + const expected = [...fields].sort() + if ( + actual.length !== expected.length || + actual.some((field, index) => field !== expected[index]) + ) { + throw new Error(`SSH relay manifest ${label} has unexpected or missing fields`) + } +} + +function decodePublicKey(value: unknown): { bytes: Buffer; base64: string } { + if (typeof value !== 'string' || !BASE64.test(value)) { + throw new Error('SSH relay manifest accepted public key is not canonical base64') + } + const bytes = Buffer.from(value, 'base64') + if (bytes.byteLength !== 32 || bytes.toString('base64') !== value) { + throw new Error('SSH relay manifest accepted public key must contain exactly 32 bytes') + } + return { bytes, base64: value } +} + +function sha256(bytes: Uint8Array): SshRelayDigest { + return `sha256:${createHash('sha256').update(bytes).digest('hex')}` +} + +export function parseSshRelayManifestAcceptedKeyDocument(input: unknown): Readonly<{ + acceptedKeys: readonly Readonly[] + sha256: SshRelayDigest +}> { + assertObject(input, 'accepted-key document') + assertExactFields(input, ['keys', 'schemaVersion'], 'accepted-key document') + if ( + input.schemaVersion !== 1 || + !Array.isArray(input.keys) || + input.keys.length === 0 || + input.keys.length > MAXIMUM_KEYS + ) { + throw new Error('SSH relay manifest accepted keys must be a bounded schema-version-1 array') + } + + const seen = new Set() + const parsed = input.keys + .map((value, index) => { + assertObject(value, `accepted key ${index}`) + assertExactFields(value, ['keyId', 'publicKeyBase64'], `accepted key ${index}`) + const decoded = decodePublicKey(value.publicKeyBase64) + const publicKey = decoded.bytes + const keyId = sshRelayManifestKeyId(publicKey) + if (value.keyId !== keyId) { + throw new Error('SSH relay manifest accepted key ID disagrees with its public key bytes') + } + if (seen.has(keyId)) { + throw new Error(`Duplicate accepted SSH relay manifest key: ${keyId}`) + } + seen.add(keyId) + return { keyId, publicKeyBase64: decoded.base64, publicKey } + }) + .sort((left, right) => (left.keyId < right.keyId ? -1 : left.keyId > right.keyId ? 1 : 0)) + + // Why: the protected aggregate fingerprints this exact sorted projection, so desktop build + // evidence can prove it embedded the reviewed public-key set without trusting input order. + const canonicalRecords: AcceptedKeyRecord[] = parsed.map(({ keyId, publicKeyBase64 }) => ({ + keyId, + publicKeyBase64 + })) + const canonicalBytes = Buffer.from( + JSON.stringify({ schemaVersion: 1, keys: canonicalRecords }), + 'utf8' + ) + const acceptedKeys = Object.freeze( + parsed.map(({ keyId, publicKey }) => Object.freeze({ keyId, publicKey })) + ) + return Object.freeze({ acceptedKeys, sha256: sha256(canonicalBytes) }) +} + +export const SSH_RELAY_MANIFEST_ACCEPTED_KEY_LIMITS = Object.freeze({ maximumKeys: MAXIMUM_KEYS })