mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
feat(ssh): define relay runtime artifact contracts
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
+172
-58
@@ -52,22 +52,27 @@ same change as the work it records.
|
||||
E-M0-STATIC-002, E-M0-LIVE-002, E-M0-CI-001). Draft PR
|
||||
[#8724](https://github.com/stablyai/orca/pull/8724) is open and CI-green at implementation head
|
||||
`94e58d83e`.
|
||||
- Active package: Milestone 1 decisions and Work Package 1 contracts are isolated in stacked draft PR
|
||||
[#8728](https://github.com/stablyai/orca/pull/8728); decision commit `a84d52dc6` changes no runtime
|
||||
behavior and enables no tuple.
|
||||
- Active package: Work Package 1's disconnected manifest, content-identity, signature, release-asset,
|
||||
and conservative selector contracts are locally implemented and green under E-M2-RED-001 and
|
||||
E-M2-CONTRACT-001. They remain isolated in stacked draft PR
|
||||
[#8728](https://github.com/stablyai/orca/pull/8728); no deploy/resolver call site is connected and
|
||||
no tuple is enabled.
|
||||
- Production behavior: unchanged; Orca embeds relay JavaScript and installs `node-pty` plus
|
||||
`@parcel/watcher` with remote npm.
|
||||
- New runtime assets published: none.
|
||||
- Bundled runtime enabled: no.
|
||||
- Declared supported bundled tuples: none until the baseline and runner decisions below are closed.
|
||||
- Declared supported bundled tuples: none until the required target-native and two-layer live
|
||||
evidence cells are complete.
|
||||
- Validation orchestration: GitHub Actions is the primary runner and evidence surface under
|
||||
E-M1-RUNNER-DECISION-001; exact native labels and representative remote targets remain open.
|
||||
E-M1-RUNNER-DECISION-001; exact native labels are recorded, while representative cross-family
|
||||
remote targets remain open.
|
||||
- Rollout control: existing per-SSH-target configuration; legacy is the default and the bundled
|
||||
runtime is an explicit per-target Beta opt-in under E-M1-ROLLOUT-DECISION-001.
|
||||
- Legacy fallback removal: not authorized.
|
||||
- Next required action: implement the versioned manifest/identity/selector contracts and hostile-input
|
||||
tests in draft PR #8728. Keep cross-family remote infrastructure and measured legacy baseline
|
||||
gates open and do not introduce any resolver, transfer, rollout, or default behavior.
|
||||
- Next required action: commit and run draft PR #8728 CI for the locally green Work Package 1
|
||||
contract package, record the exact CI evidence, then begin artifact-only target-native runtime
|
||||
assembly. Keep cross-family remote infrastructure and measured legacy baseline gates open and do
|
||||
not introduce resolver, transfer, rollout, or default behavior.
|
||||
|
||||
## Non-Negotiable Invariants
|
||||
|
||||
@@ -467,38 +472,44 @@ Suggested modules are provisional; update this file before choosing different na
|
||||
**In progress — 2026-07-14, Codex implementation owner:** implement the pure manifest schema,
|
||||
canonical unsigned bytes/signature verification, content identity, and fail-conservative
|
||||
platform/libc selector with hostile-input tests only. No deploy/resolver call site or bundled tuple is
|
||||
enabled in this package.
|
||||
enabled in this package. Session checkpoint: implementation and local gates are complete under
|
||||
E-M2-RED-001 and E-M2-CONTRACT-001; draft PR #8728 CI and exact implementation-commit evidence are
|
||||
the remaining package gates.
|
||||
|
||||
### Manifest schema
|
||||
|
||||
- [ ] Add a versioned relay artifact schema in a concrete domain module such as
|
||||
`src/main/ssh/ssh-relay-artifact-schema.ts`.
|
||||
- [ ] Include schema version, exact Orca build tag, relay protocol version, runtime content ID,
|
||||
- [x] Add a versioned relay artifact schema in `src/main/ssh/ssh-relay-artifact-schema.ts`.
|
||||
(E-M2-CONTRACT-001)
|
||||
- [x] Include schema version, exact Orca build tag, relay protocol version, runtime content ID,
|
||||
OS, architecture, libc family/version requirements, minimum OS/kernel requirements, Node
|
||||
version, dependency versions, archive name, archive size, expanded size, file count, and
|
||||
archive SHA-256.
|
||||
- [ ] Include every runtime file’s relative path, type, size, SHA-256, and required executable mode.
|
||||
- [ ] Include signing key ID, signature algorithm/version, creation timestamp, and provenance/SBOM
|
||||
references without making wall-clock time part of runtime identity.
|
||||
- [ ] Include target-native code-signing verification attestation, policy/tool identity, and the
|
||||
exact attested byte hashes inside the signed manifest.
|
||||
- [ ] Reject duplicate tuple entries, duplicate/case-colliding paths, unsafe Windows names,
|
||||
archive SHA-256. (E-M2-CONTRACT-001)
|
||||
- [x] Include every runtime file’s relative path, type, size, SHA-256, and required executable mode.
|
||||
(E-M2-CONTRACT-001)
|
||||
- [x] Include signing key ID, signature algorithm/version, creation timestamp, and provenance/SBOM
|
||||
references without making wall-clock time part of runtime identity. (E-M2-CONTRACT-001)
|
||||
- [x] Include target-native code-signing verification attestation, policy/tool identity, and the
|
||||
exact attested byte hashes inside the signed manifest. (E-M2-CONTRACT-001)
|
||||
- [x] Reject duplicate tuple entries, duplicate/case-colliding paths, unsafe Windows names,
|
||||
unsupported schema versions, missing required files, extra native platform packages, and
|
||||
inconsistent aggregate sizes.
|
||||
- [ ] Define exact archive naming for stable, RC, and perf builds.
|
||||
- [ ] Define exact direct release URL construction. Runtime code must not call the GitHub API or use
|
||||
a mutable `latest` URL.
|
||||
inconsistent aggregate sizes. (E-M2-CONTRACT-001)
|
||||
- [x] Define exact archive naming for stable, RC, and perf builds. (E-M2-CONTRACT-001)
|
||||
- [x] Define exact direct release URL construction. Runtime code must not call the GitHub API or use
|
||||
a mutable `latest` URL. (E-M2-CONTRACT-001)
|
||||
|
||||
### Content identity
|
||||
|
||||
- [ ] Compute the content identity from canonical full-runtime metadata and file digests, including
|
||||
- [x] Compute the content identity from canonical full-runtime metadata and file digests, including
|
||||
Node, relay entries, native modules, helper executables, and runtime JavaScript.
|
||||
- [ ] Prove changing only Node changes the identity.
|
||||
- [ ] Prove changing only `node-pty` changes the identity.
|
||||
- [ ] Prove changing only `@parcel/watcher` changes the identity.
|
||||
- [ ] Prove changing only `relay-watcher.js` changes the identity.
|
||||
- [ ] Prove mode changes for executable files change the identity or are otherwise authenticated.
|
||||
- [ ] Prove metadata that should not affect execution does not create nondeterministic identities.
|
||||
(E-M2-CONTRACT-001)
|
||||
- [x] Prove changing only Node changes the identity. (E-M2-CONTRACT-001)
|
||||
- [x] Prove changing only `node-pty` changes the identity. (E-M2-CONTRACT-001)
|
||||
- [x] Prove changing only `@parcel/watcher` changes the identity. (E-M2-CONTRACT-001)
|
||||
- [x] Prove changing only `relay-watcher.js` changes the identity. (E-M2-CONTRACT-001)
|
||||
- [x] Prove mode changes for executable files change the identity or are otherwise authenticated.
|
||||
(E-M2-CONTRACT-001)
|
||||
- [x] Prove metadata that should not affect execution does not create nondeterministic identities.
|
||||
(E-M2-CONTRACT-001)
|
||||
- [ ] Update remote directory parsing and GC rules for the new mode-qualified identity without
|
||||
breaking legacy directory recognition.
|
||||
|
||||
@@ -1101,16 +1112,16 @@ Baseline measurements must be captured before product behavior changes.
|
||||
|
||||
Update status and evidence as work begins. Do not combine these into one large behavior switch.
|
||||
|
||||
| Work package | Scope | Default behavior change | Status | PR/evidence |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------ | --------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------- |
|
||||
| 0. #8450 legacy fix | Coherent Node/npm selection and live repro | Fixes legacy selection only | Complete and CI-green in draft PR #8724 | E-M0-UNIT-002, E-M0-LIVE-002, E-M0-STATIC-002, E-M0-PR-001, E-M0-CI-001 |
|
||||
| 1. Contract and selectors | Manifest schema, identity, platform/libc selection, hostile inputs | None | Safety decisions recorded; contracts in progress | Draft PR #8728; a84d52dc6, 5c37e8efe |
|
||||
| 2. Runtime builds | Per-tuple assembly, native smoke, SBOM/provenance/signing | None | Not started | — |
|
||||
| 3. Release publication | Prerequisite DAG, embedded manifest, draft upload/read-back gates | Asset-only | Not started | — |
|
||||
| 4. Desktop resolver/cache | Verified download, extraction, cache, offline behavior | None/forced mode only | Not started | — |
|
||||
| 5. Transfer/install | Bounded transports, structured sentinel, bundled launch behind per-target Beta/forced mode | Per-target opt-in only | Not started | — |
|
||||
| 6. Fallback/diagnostics | Abort-and-join state machine, mode isolation, reason codes, target-mode configuration/UI | Per-target Beta only | Not started | — |
|
||||
| 7. Live gates/rollout | Matrix, security, performance, release promotion | Per-tuple staged | Not started | — |
|
||||
| Work package | Scope | Default behavior change | Status | PR/evidence |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------ | --------------------------- | --------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| 0. #8450 legacy fix | Coherent Node/npm selection and live repro | Fixes legacy selection only | Complete and CI-green in draft PR #8724 | E-M0-UNIT-002, E-M0-LIVE-002, E-M0-STATIC-002, E-M0-PR-001, E-M0-CI-001 |
|
||||
| 1. Contract and selectors | Manifest schema, identity, platform/libc selection, hostile inputs | None | Locally green; draft PR CI pending | Draft PR #8728; E-M2-RED-001, E-M2-CONTRACT-001 |
|
||||
| 2. Runtime builds | Per-tuple assembly, native smoke, SBOM/provenance/signing | None | Not started | — |
|
||||
| 3. Release publication | Prerequisite DAG, embedded manifest, draft upload/read-back gates | Asset-only | Not started | — |
|
||||
| 4. Desktop resolver/cache | Verified download, extraction, cache, offline behavior | None/forced mode only | Not started | — |
|
||||
| 5. Transfer/install | Bounded transports, structured sentinel, bundled launch behind per-target Beta/forced mode | Per-target opt-in only | Not started | — |
|
||||
| 6. Fallback/diagnostics | Abort-and-join state machine, mode isolation, reason codes, target-mode configuration/UI | Per-target Beta only | Not started | — |
|
||||
| 7. Live gates/rollout | Matrix, security, performance, release promotion | Per-tuple staged | Not started | — |
|
||||
|
||||
Every PR must document:
|
||||
|
||||
@@ -1154,7 +1165,7 @@ focused commands as their scripts/tests are introduced.
|
||||
|
||||
### Commands/scripts that must be added or formally identified
|
||||
|
||||
- [ ] Manifest/schema/identity unit-test command.
|
||||
- [x] Manifest/schema/identity/signature/selector unit-test command. (E-M2-CONTRACT-001)
|
||||
- [ ] Per-tuple runtime assembly command.
|
||||
- [ ] Per-tuple archive inspection and native smoke command.
|
||||
- [ ] Embedded-manifest extraction/comparison command for every packaged app.
|
||||
@@ -2007,23 +2018,125 @@ fragmentLinks=9`.
|
||||
- Follow-up: keep unresolved remote-pool and measured-baseline gates open while contract-only work
|
||||
proceeds without secrets or a runtime behavior switch.
|
||||
|
||||
### E-M2-RED-001 — Missing contract modules and canonical-vector red baseline
|
||||
|
||||
- Date: 2026-07-14
|
||||
- Commit SHA / PR: `959bc05caca7e5ccb4c69b47c1bf6f5b47671c57` plus uncommitted Work
|
||||
Package 1 tests; stacked draft PR [#8728](https://github.com/stablyai/orca/pull/8728)
|
||||
- Runner: macOS 26.2 arm64 native; Node v26.0.0 and pnpm 10.24.0
|
||||
- Remote: not applicable; pure local contract tests
|
||||
- Transport/network: local Vitest only; no SSH or GitHub request
|
||||
- Exact command:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/ssh/ssh-relay-artifact-schema.test.ts \
|
||||
src/main/ssh/ssh-relay-runtime-identity.test.ts \
|
||||
src/main/ssh/ssh-relay-manifest-signature.test.ts \
|
||||
src/main/ssh/ssh-relay-release-asset.test.ts \
|
||||
src/main/ssh/ssh-relay-artifact-selector.test.ts
|
||||
```
|
||||
|
||||
- Result: FAIL as the intended executable red baseline: signature and selector suites failed to
|
||||
import because their production modules did not exist; the identity vector still contained its
|
||||
explicit replacement marker; and one native-package fixture reached the undeclared-parent guard
|
||||
before the intended native-package guard. Four files failed, one passed, with 35 tests executed
|
||||
(33 passed and 2 failed) plus two import-failed suites.
|
||||
- Duration and resource metrics: 292 ms Vitest duration; no runtime-transfer resources apply.
|
||||
- Artifact/log/trace link: local command output retained in the implementation session; test paths
|
||||
above are the durable repro.
|
||||
- Oracle proved: the new tests were discriminating before the missing signature/selector modules and
|
||||
canonical vector were implemented, and the native-package fixture needed to preserve tree
|
||||
consistency to reach its intended oracle.
|
||||
- Does not prove: behavior before all schema/identity files existed, live SSH, a real archive,
|
||||
cryptographic key custody, target-native execution, or any production-path failure.
|
||||
- Checklist items satisfied: red half of the Work Package 1 contract-test gate only.
|
||||
- Follow-up: implemented and superseded for green behavior by E-M2-CONTRACT-001; retained as red
|
||||
history.
|
||||
|
||||
### E-M2-CONTRACT-001 — Manifest, identity, signature, URL, and selector contracts
|
||||
|
||||
- Date: 2026-07-14
|
||||
- Commit SHA / PR: current Work Package 1 implementation worktree based on
|
||||
`959bc05caca7e5ccb4c69b47c1bf6f5b47671c57`; stacked draft PR
|
||||
[#8728](https://github.com/stablyai/orca/pull/8728); exact implementation SHA and PR CI evidence
|
||||
pending
|
||||
- Runner: macOS 26.2 arm64 native; Node v26.0.0 and pnpm 10.24.0 (repository requests Node 24)
|
||||
- Remote: not applicable; pure local schema, cryptography, identity, release-name, and selection tests
|
||||
- Transport/network: local Vitest/static tools only; no SSH, release download, or GitHub API call
|
||||
- Exact command:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run --config config/vitest.config.ts \
|
||||
src/main/ssh/ssh-relay-artifact-schema.test.ts \
|
||||
src/main/ssh/ssh-relay-runtime-identity.test.ts \
|
||||
src/main/ssh/ssh-relay-manifest-signature.test.ts \
|
||||
src/main/ssh/ssh-relay-release-asset.test.ts \
|
||||
src/main/ssh/ssh-relay-artifact-selector.test.ts
|
||||
pnpm run typecheck
|
||||
pnpm run lint
|
||||
pnpm run check:max-lines-ratchet
|
||||
pnpm exec oxfmt --check \
|
||||
src/main/ssh/ssh-relay-artifact-consistency.ts \
|
||||
src/main/ssh/ssh-relay-artifact-path-policy.ts \
|
||||
src/main/ssh/ssh-relay-artifact-schema.ts \
|
||||
src/main/ssh/ssh-relay-artifact-selector.ts \
|
||||
src/main/ssh/ssh-relay-manifest-signature.ts \
|
||||
src/main/ssh/ssh-relay-release-asset.ts \
|
||||
src/main/ssh/ssh-relay-runtime-identity.ts \
|
||||
src/main/ssh/ssh-relay-artifact-schema.test.ts \
|
||||
src/main/ssh/ssh-relay-artifact-selector.test.ts \
|
||||
src/main/ssh/ssh-relay-manifest-signature.test.ts \
|
||||
src/main/ssh/ssh-relay-release-asset.test.ts \
|
||||
src/main/ssh/ssh-relay-runtime-identity.test.ts \
|
||||
src/main/ssh/ssh-relay-artifact-test-manifest.ts \
|
||||
docs/reference/plans/2026-07-14-ssh-relay-github-release-implementation-checklist.md
|
||||
git diff --cached --check
|
||||
```
|
||||
|
||||
- Result: PASS locally. Five suites passed with 68/68 tests; Node/CLI/web typecheck passed; the full
|
||||
repository lint/reliability/localization chain exited zero with pre-existing warnings only; the
|
||||
355-entry max-lines ratchet reported no new bypass; touched-file formatting and tracked diff checks
|
||||
passed.
|
||||
- Duration and resource metrics: final Vitest duration 540 ms (1.33 s wall); typecheck 3.91 s; full
|
||||
lint 23.62 s; formatting check 874 ms; max-lines and diff checks under 1 s. Runtime memory,
|
||||
channels, handles, files, and cancellation do not apply to this disconnected pure contract layer
|
||||
and were not instrumented.
|
||||
- Artifact/log/trace link: local command output; canonical runtime identity vector
|
||||
`sha256:5afe9c8094ec61a5eec6f7be6d1035faacee7362871985c74cc6ee6aceea8677`; canonical
|
||||
unsigned-manifest byte hash `e78bf4416628a91055035dc7926035cbf633f29d3618be34e041c6dc5e0794fb`;
|
||||
draft PR #8728
|
||||
- Oracle proved: strict versioned parsing; bounded sizes/counts; portable path and special-entry
|
||||
rejection; required runtime closure and executable Node mode; tuple/archive/attestation
|
||||
cross-consistency; stable content identity; exact stable/RC/perf tag and direct-URL rules; initial
|
||||
and dual Ed25519 signing with canonical unsigned bytes; unknown/malformed/duplicate/mismatched
|
||||
key failure; conservative Linux glibc/musl, macOS, Windows-bootstrap, translated-process, unknown,
|
||||
old, unavailable, and ambiguous selection.
|
||||
- Does not prove: Node 24 execution, real archive parsing/extraction, local cache/download, target
|
||||
probes, SSH/SFTP/system-SSH transfer, runtime build/native loading, signing environment custody,
|
||||
release publication, fallback state, UI rollout, performance, or any live matrix cell. No module
|
||||
is connected to a production call site and no tuple is enabled.
|
||||
- Checklist items satisfied: Milestone 2 manifest-schema, signed-content, content-identity,
|
||||
release-name/URL, schema-level hostile path/type, and pure selector contract items explicitly
|
||||
marked above; verification command inventory entry.
|
||||
- Follow-up: record exact implementation SHA and GitHub Actions result, then begin Work Package 2
|
||||
target-native runtime assembly without production consumption.
|
||||
|
||||
## Accepted Gaps
|
||||
|
||||
No product gap is accepted merely because it appears in this list. Each entry requires explicit
|
||||
owner and promotion condition.
|
||||
|
||||
| Gap | Current behavior | Risk | Owner | Promotion/removal condition | Status |
|
||||
| ---------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------- | ---------------------------------- | -------------------------------------------------------------------- | ------ |
|
||||
| Bundled runtime not implemented | Remote Node/npm installer | #8450/#1693 failure classes remain | Unassigned | Milestones 1–14 | Open |
|
||||
| Bundled support matrix undeclared | Legacy support only where proven | Cannot claim tuple coverage | Unassigned | Baselines and real runners decided | Open |
|
||||
| musl Node provenance undecided | Legacy eligibility only | Untrusted/incompatible binary risk | Unassigned | Reviewed runtime source/build | Open |
|
||||
| Windows arm64 real runner undecided | Legacy eligibility only | Cross-build may hide runtime failure | Unassigned | Native runner and full proof | Open |
|
||||
| Linux arm64 real runner undecided | Existing Docker evidence is not full runtime matrix | Native regression may escape | Unassigned | Native full-matrix proof | Open |
|
||||
| No numeric perf budgets yet | Current legacy behavior | “No regression” is unmeasurable | Unassigned | Baselines and thresholds recorded | Open |
|
||||
| Signing/key lifecycle undecided | GitHub app-release trust only | Relay-specific supply-chain gap | Unassigned | Milestone 1 trust decisions | Open |
|
||||
| Remote bootstrap primitive baselines undecided | Existing transport-specific shell assumptions | Hidden tool dependency or corrupt transfer | Unassigned | Milestone 1 POSIX/Windows decisions and live tests | Open |
|
||||
| WP0 commit/PR boundary absent | Uncommitted, independently scoped worktree diff | Starting WP1 now would mix review packages | User authorization required | Separately authorized WP0 commit/PR or equivalent reviewed boundary | Open |
|
||||
| Repository-wide lint failure outside WP0 | Touched-file oxlint passes; full lint stops on untouched renderer exhaustiveness error | Required handoff gate cannot be claimed green | Existing renderer owner unassigned | Fix or explicitly baseline E-M0-LINT-001, then rerun `pnpm run lint` | Open |
|
||||
| Gap | Current behavior | Risk | Owner | Promotion/removal condition | Status |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------- | ------------ |
|
||||
| Bundled runtime not implemented | Proven legacy path only; WP1 contracts have no production consumer | #8450/#1693 environment failures remain | Codex implementation owner | Work Packages 2–7 plus Milestones 3–14 | Open |
|
||||
| No bundled tuple enabled | Every target's default and effective mode remains legacy | No bundled support claim can be made | Codex implementation owner | Complete target-native build/trust and both required live-evidence layers | Open |
|
||||
| Cross-family Layer B remotes unavailable | GitHub native runner labels exist; no approved reachable target pool | Client/remote integration gaps may escape | Repository release administrator + implementation owner | Approve provider/snapshots/credentials/egress/teardown/cost owner | BLOCKED |
|
||||
| Musl has no accepted official Node binary | Musl is deliberately legacy-only | Unofficial binary would break provenance trust | Codex implementation owner | Orca-owned target-native source build, signing, provenance, and live gates | ACCEPTED GAP |
|
||||
| Native arm64 live matrices incomplete | Hosted Linux/Windows arm64 labels exist; full SSH/runtime cells do not | Cross-build or unit tests may hide native bugs | Codex implementation owner | Full native archive, trust, SFTP/system-SSH, RPC, and baseline evidence | Open |
|
||||
| Legacy performance baseline unmeasured | Numeric budgets exist; paired cold/warm measurements do not | Regression thresholds lack a measured baseline | Codex implementation owner | Purpose-built paired harness with ten samples on pinned runner classes | Open |
|
||||
| Manifest signing environment unprovisioned | Ed25519/key-rotation policy exists; no protected runtime signing secret | Runtime assets cannot be safely published | Repository release administrator | Protected environment, reviewers, two test keys, rehearsals, and access audit | BLOCKED |
|
||||
| Bootstrap primitives lack full live proof | POSIX/Windows contracts exist; bounded SSH implementations do not | Hidden dependency or transfer corruption | Codex implementation owner | Purpose-named full-size SFTP/POSIX/Windows system-SSH live suites | Open |
|
||||
|
||||
## Final Definition of Done
|
||||
|
||||
@@ -2064,8 +2177,9 @@ The project is not complete until every applicable item below is checked with ev
|
||||
|
||||
## Next Required Action
|
||||
|
||||
Place the implementation/live-proven Milestone 0 diff behind its separately authorized commit/PR
|
||||
boundary and resolve or explicitly baseline E-M0-LINT-001. Then assign owners and close Milestone
|
||||
1’s support baseline, remote bootstrap primitives, Node provenance/refresh policy, signing
|
||||
lifecycle, transfer/resource budgets, and rollout-policy decisions. Do not start a production
|
||||
default-path change before those decisions are reflected here.
|
||||
Commit and push the locally green Work Package 1 contract layer to stacked draft PR #8728, run its
|
||||
GitHub Actions checks on the exact implementation SHA, and append that evidence without enabling a
|
||||
tuple. If CI remains green, begin Work Package 2 with target-native artifact assembly and executable
|
||||
smoke only. The cross-family Layer B target pool, protected manifest-signing environment, and paired
|
||||
legacy performance baseline remain release/default-path blockers; no resolver, transfer, per-target
|
||||
Beta, fallback, or default behavior may be connected by the next package.
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { SshRelayRuntimeTuple } from './ssh-relay-artifact-schema'
|
||||
import {
|
||||
assertSafeSshRelayArtifactPath,
|
||||
foldSshRelayArtifactPath
|
||||
} from './ssh-relay-artifact-path-policy'
|
||||
import { sshRelayRuntimeArchiveName } from './ssh-relay-release-asset'
|
||||
import { computeSshRelayRuntimeContentId, type SshRelayDigest } from './ssh-relay-runtime-identity'
|
||||
|
||||
function expectedTupleId(tuple: SshRelayRuntimeTuple): string {
|
||||
if (tuple.os === 'linux' && tuple.compatibility.kind === 'linux') {
|
||||
return `linux-${tuple.architecture}-${tuple.compatibility.libc.family}`
|
||||
}
|
||||
return `${tuple.os}-${tuple.architecture}`
|
||||
}
|
||||
|
||||
function assertRequiredRuntimeEntries(tuple: SshRelayRuntimeTuple): void {
|
||||
const files = tuple.entries.filter((entry) => entry.type === 'file')
|
||||
const expectedPaths = new Map([
|
||||
['node', tuple.os === 'win32' ? 'node.exe' : 'bin/node'],
|
||||
['relay', 'relay.js'],
|
||||
['relay-watcher', 'relay-watcher.js']
|
||||
])
|
||||
for (const role of [
|
||||
'node',
|
||||
'relay',
|
||||
'relay-watcher',
|
||||
'node-pty-native',
|
||||
'parcel-watcher-native'
|
||||
]) {
|
||||
const matching = files.filter((file) => file.role === role)
|
||||
if (matching.length !== 1) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} requires exactly one ${role} entry`)
|
||||
}
|
||||
const expectedPath = expectedPaths.get(role)
|
||||
if (expectedPath && matching[0].path !== expectedPath) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} has invalid ${role} path`)
|
||||
}
|
||||
}
|
||||
for (const requiredPath of [
|
||||
'node_modules/node-pty/package.json',
|
||||
'node_modules/@parcel/watcher/package.json'
|
||||
]) {
|
||||
if (!files.some((file) => file.path === requiredPath)) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} is missing ${requiredPath}`)
|
||||
}
|
||||
}
|
||||
if (!files.some((file) => file.role === 'license')) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} requires bundled license metadata`)
|
||||
}
|
||||
const node = files.find((file) => file.role === 'node')
|
||||
if (node?.mode !== 0o755) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} requires executable bundled Node mode`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertEntryTree(tuple: SshRelayRuntimeTuple): void {
|
||||
const paths = new Set<string>()
|
||||
const foldedPaths = new Set<string>()
|
||||
const directories = new Set(
|
||||
tuple.entries.filter((entry) => entry.type === 'directory').map((entry) => entry.path)
|
||||
)
|
||||
for (const entry of tuple.entries) {
|
||||
assertSafeSshRelayArtifactPath(entry.path)
|
||||
const folded = foldSshRelayArtifactPath(entry.path)
|
||||
if (paths.has(entry.path) || foldedPaths.has(folded)) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} has colliding path: ${entry.path}`)
|
||||
}
|
||||
paths.add(entry.path)
|
||||
foldedPaths.add(folded)
|
||||
const separator = entry.path.lastIndexOf('/')
|
||||
if (separator > 0 && !directories.has(entry.path.slice(0, separator))) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} has undeclared parent for ${entry.path}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function expectedWatcherPackage(tuple: SshRelayRuntimeTuple): string {
|
||||
if (tuple.os === 'linux' && tuple.compatibility.kind === 'linux') {
|
||||
return `node_modules/@parcel/watcher-linux-${tuple.architecture}-${tuple.compatibility.libc.family}`
|
||||
}
|
||||
return `node_modules/@parcel/watcher-${tuple.os}-${tuple.architecture}`
|
||||
}
|
||||
|
||||
function assertNativePackages(tuple: SshRelayRuntimeTuple): void {
|
||||
const watcherPackage = expectedWatcherPackage(tuple)
|
||||
for (const entry of tuple.entries) {
|
||||
if (
|
||||
entry.path.startsWith('node_modules/@parcel/watcher-') &&
|
||||
entry.path !== watcherPackage &&
|
||||
!entry.path.startsWith(`${watcherPackage}/`)
|
||||
) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} contains an extra native watcher package`)
|
||||
}
|
||||
}
|
||||
const expectedPolicy =
|
||||
tuple.os === 'linux'
|
||||
? 'linux-hash-only-v1'
|
||||
: tuple.os === 'darwin'
|
||||
? 'apple-developer-id-v1'
|
||||
: 'signpath-authenticode-v1'
|
||||
if (tuple.nativeVerification.policy !== expectedPolicy) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} has the wrong native verification policy`)
|
||||
}
|
||||
}
|
||||
|
||||
function assertNativeAttestation(tuple: SshRelayRuntimeTuple): void {
|
||||
const attestations = new Map<string, SshRelayDigest>()
|
||||
for (const attestation of tuple.nativeVerification.files) {
|
||||
if (attestations.has(attestation.path)) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} has duplicate native attestation path`)
|
||||
}
|
||||
attestations.set(attestation.path, attestation.sha256)
|
||||
}
|
||||
const requiredRoles = new Set([
|
||||
'node',
|
||||
'node-pty-native',
|
||||
'parcel-watcher-native',
|
||||
'native-runtime'
|
||||
])
|
||||
for (const entry of tuple.entries) {
|
||||
if (entry.type !== 'file' || !requiredRoles.has(entry.role)) {
|
||||
continue
|
||||
}
|
||||
if (attestations.get(entry.path) !== entry.sha256) {
|
||||
throw new Error(
|
||||
`Runtime tuple ${tuple.tupleId} has an invalid attested hash for ${entry.path}`
|
||||
)
|
||||
}
|
||||
}
|
||||
for (const [path, hash] of attestations) {
|
||||
const entry = tuple.entries.find((candidate) => candidate.path === path)
|
||||
if (!entry || entry.type !== 'file' || entry.sha256 !== hash) {
|
||||
throw new Error(
|
||||
`Runtime tuple ${tuple.tupleId} attests a missing or mismatched file: ${path}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSshRelayRuntimeTupleConsistency(tuple: SshRelayRuntimeTuple): void {
|
||||
const expectedCompatibilityKind = tuple.os === 'win32' ? 'windows' : tuple.os
|
||||
if (
|
||||
expectedTupleId(tuple) !== tuple.tupleId ||
|
||||
tuple.compatibility.kind !== expectedCompatibilityKind
|
||||
) {
|
||||
throw new Error(`Runtime tuple identity does not match its platform fields: ${tuple.tupleId}`)
|
||||
}
|
||||
assertEntryTree(tuple)
|
||||
assertRequiredRuntimeEntries(tuple)
|
||||
assertNativePackages(tuple)
|
||||
assertNativeAttestation(tuple)
|
||||
|
||||
const files = tuple.entries.filter((entry) => entry.type === 'file')
|
||||
const expandedSize = files.reduce((total, file) => total + file.size, 0)
|
||||
if (tuple.archive.fileCount !== files.length) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} archive file count is inconsistent`)
|
||||
}
|
||||
if (tuple.archive.expandedSize !== expandedSize) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} archive expanded size is inconsistent`)
|
||||
}
|
||||
const contentId = computeSshRelayRuntimeContentId(tuple)
|
||||
if (tuple.contentId !== contentId) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} content identity is inconsistent`)
|
||||
}
|
||||
if (tuple.archive.name !== sshRelayRuntimeArchiveName(tuple.tupleId, tuple.contentId)) {
|
||||
throw new Error(`Runtime tuple ${tuple.tupleId} archive name is inconsistent`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export const SSH_RELAY_MAX_RELATIVE_PATH_BYTES = 240
|
||||
export const SSH_RELAY_MAX_PATH_DEPTH = 32
|
||||
|
||||
const PORTABLE_PATH = /^[A-Za-z0-9._@+/-]+$/
|
||||
const WINDOWS_DEVICE_NAME = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i
|
||||
|
||||
export function assertSafeSshRelayArtifactPath(relativePath: string): void {
|
||||
const fail = (reason: string): never => {
|
||||
throw new Error(`Unsafe artifact path "${relativePath}": ${reason}`)
|
||||
}
|
||||
|
||||
if (
|
||||
!relativePath ||
|
||||
Buffer.byteLength(relativePath, 'utf8') > SSH_RELAY_MAX_RELATIVE_PATH_BYTES
|
||||
) {
|
||||
fail('empty or over the byte limit')
|
||||
}
|
||||
if (!PORTABLE_PATH.test(relativePath)) {
|
||||
fail('contains non-portable characters or separators')
|
||||
}
|
||||
if (relativePath.startsWith('/') || relativePath.startsWith('//')) {
|
||||
fail('absolute and UNC paths are forbidden')
|
||||
}
|
||||
|
||||
const segments = relativePath.split('/')
|
||||
if (segments.length > SSH_RELAY_MAX_PATH_DEPTH) {
|
||||
fail('nesting depth exceeds the limit')
|
||||
}
|
||||
for (const segment of segments) {
|
||||
if (!segment || segment === '.' || segment === '..') {
|
||||
fail('empty and dot segments are forbidden')
|
||||
}
|
||||
if (segment.endsWith('.') || segment.endsWith(' ')) {
|
||||
fail('Windows-trimmed suffix is forbidden')
|
||||
}
|
||||
if (WINDOWS_DEVICE_NAME.test(segment)) {
|
||||
fail('Windows device name is forbidden')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function foldSshRelayArtifactPath(relativePath: string): string {
|
||||
return relativePath.toLowerCase()
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest'
|
||||
import { parseSshRelayArtifactManifest } from './ssh-relay-artifact-schema'
|
||||
|
||||
describe('SSH relay artifact manifest schema', () => {
|
||||
it('accepts a complete internally consistent manifest', () => {
|
||||
const parsed = parseSshRelayArtifactManifest(createSshRelayArtifactTestManifest())
|
||||
|
||||
expect(parsed.schemaVersion).toBe(1)
|
||||
expect(parsed.tuples[0].tupleId).toBe('linux-x64-glibc')
|
||||
})
|
||||
|
||||
it('rejects unsupported schema versions, extra fields, and non-canonical timestamps', () => {
|
||||
const unsupported = createSshRelayArtifactTestManifest() as unknown as Record<string, unknown>
|
||||
unsupported.schemaVersion = 2
|
||||
expect(() => parseSshRelayArtifactManifest(unsupported)).toThrow()
|
||||
|
||||
const extra = createSshRelayArtifactTestManifest() as unknown as Record<string, unknown>
|
||||
extra.latest = true
|
||||
expect(() => parseSshRelayArtifactManifest(extra)).toThrow()
|
||||
|
||||
const invalidDate = createSshRelayArtifactTestManifest()
|
||||
invalidDate.createdAt = '2026-02-31T00:00:00.000Z'
|
||||
expect(() => parseSshRelayArtifactManifest(invalidDate)).toThrow(/timestamp/i)
|
||||
})
|
||||
|
||||
it('rejects build identity that disagrees with the exact tag', () => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
manifest.build.channel = 'stable'
|
||||
|
||||
expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/build identity/i)
|
||||
})
|
||||
|
||||
it('rejects duplicate tuple identities', () => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
manifest.tuples.push(structuredClone(manifest.tuples[0]))
|
||||
|
||||
expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/duplicate tuple/i)
|
||||
})
|
||||
|
||||
it('rejects exact and case-folded entry collisions', () => {
|
||||
for (const path of ['relay.js', 'RELAY.JS']) {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
manifest.tuples[0].entries.push({
|
||||
path,
|
||||
type: 'file',
|
||||
role: 'runtime-javascript',
|
||||
size: 1,
|
||||
mode: 0o644,
|
||||
sha256: `sha256:${'e'.repeat(64)}`
|
||||
})
|
||||
expect(() => parseSshRelayArtifactManifest(manifest), path).toThrow(/colliding path/i)
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/absolute',
|
||||
'../escape',
|
||||
'nested/../escape',
|
||||
'C:/drive',
|
||||
'//server/share',
|
||||
'node_modules\\escape',
|
||||
'relay.js:stream',
|
||||
'CON',
|
||||
'aux.txt',
|
||||
'trailing.',
|
||||
'white space',
|
||||
`${'a/'.repeat(32)}file`,
|
||||
'a'.repeat(241)
|
||||
])('rejects unsafe portable path %s', (path) => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
manifest.tuples[0].entries[1].path = path
|
||||
|
||||
expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/unsafe artifact path/i)
|
||||
})
|
||||
|
||||
it('rejects inconsistent aggregate counts and sizes', () => {
|
||||
const countMismatch = createSshRelayArtifactTestManifest()
|
||||
countMismatch.tuples[0].archive.fileCount += 1
|
||||
expect(() => parseSshRelayArtifactManifest(countMismatch)).toThrow(/file count/i)
|
||||
|
||||
const sizeMismatch = createSshRelayArtifactTestManifest()
|
||||
sizeMismatch.tuples[0].archive.expandedSize += 1
|
||||
expect(() => parseSshRelayArtifactManifest(sizeMismatch)).toThrow(/expanded size/i)
|
||||
})
|
||||
|
||||
it('rejects archive, expanded-tree, per-file, and entry-count limit violations', () => {
|
||||
const archive = createSshRelayArtifactTestManifest()
|
||||
archive.tuples[0].archive.size = 100 * 1024 * 1024 + 1
|
||||
expect(() => parseSshRelayArtifactManifest(archive)).toThrow()
|
||||
|
||||
const expanded = createSshRelayArtifactTestManifest()
|
||||
expanded.tuples[0].archive.expandedSize = 350 * 1024 * 1024 + 1
|
||||
expect(() => parseSshRelayArtifactManifest(expanded)).toThrow()
|
||||
|
||||
const file = createSshRelayArtifactTestManifest()
|
||||
const relay = file.tuples[0].entries.find(
|
||||
(entry) => entry.type === 'file' && entry.role === 'relay'
|
||||
)
|
||||
if (!relay || relay.type !== 'file') {
|
||||
throw new Error('test fixture missing relay file')
|
||||
}
|
||||
relay.size = 250 * 1024 * 1024 + 1
|
||||
expect(() => parseSshRelayArtifactManifest(file)).toThrow()
|
||||
|
||||
const entries = createSshRelayArtifactTestManifest()
|
||||
entries.tuples[0].entries = Array.from({ length: 5_001 }, (_, index) => ({
|
||||
path: `entry-${index}`,
|
||||
type: 'directory' as const,
|
||||
mode: 0o755 as const
|
||||
}))
|
||||
expect(() => parseSshRelayArtifactManifest(entries)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects undeclared parents and non-file archive entry types', () => {
|
||||
const missingParent = createSshRelayArtifactTestManifest()
|
||||
missingParent.tuples[0].entries[1].path = 'missing/node'
|
||||
expect(() => parseSshRelayArtifactManifest(missingParent)).toThrow(/undeclared parent/i)
|
||||
|
||||
const symlink = createSshRelayArtifactTestManifest()
|
||||
symlink.tuples[0].entries.push({
|
||||
path: 'node-link',
|
||||
type: 'symlink',
|
||||
target: 'bin/node'
|
||||
} as never)
|
||||
expect(() => parseSshRelayArtifactManifest(symlink)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a missing required executable role', () => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
manifest.tuples[0].entries = manifest.tuples[0].entries.filter(
|
||||
(entry) => entry.type !== 'file' || entry.role !== 'node-pty-native'
|
||||
)
|
||||
|
||||
expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/node-pty-native/i)
|
||||
})
|
||||
|
||||
it('requires bundled license metadata and executable bundled Node mode', () => {
|
||||
const missingLicense = createSshRelayArtifactTestManifest()
|
||||
missingLicense.tuples[0].entries = missingLicense.tuples[0].entries.filter(
|
||||
(entry) => entry.type !== 'file' || entry.role !== 'license'
|
||||
)
|
||||
expect(() => parseSshRelayArtifactManifest(missingLicense)).toThrow(/license/i)
|
||||
|
||||
const nonExecutableNode = createSshRelayArtifactTestManifest()
|
||||
const node = nonExecutableNode.tuples[0].entries.find(
|
||||
(entry) => entry.type === 'file' && entry.role === 'node'
|
||||
)
|
||||
if (!node || node.type !== 'file') {
|
||||
throw new Error('test fixture missing Node')
|
||||
}
|
||||
node.mode = 0o644
|
||||
expect(() => parseSshRelayArtifactManifest(nonExecutableNode)).toThrow(/executable.*Node/i)
|
||||
})
|
||||
|
||||
it('rejects a native optional package for another tuple', () => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
for (const entry of manifest.tuples[0].entries) {
|
||||
entry.path = entry.path.replace('watcher-linux-x64-glibc', 'watcher-linux-arm64-glibc')
|
||||
}
|
||||
|
||||
expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/native watcher package/i)
|
||||
})
|
||||
|
||||
it('rejects native attestation hashes that do not match runtime bytes', () => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
manifest.tuples[0].nativeVerification.files[0].sha256 = `sha256:${'f'.repeat(64)}`
|
||||
|
||||
expect(() => parseSshRelayArtifactManifest(manifest)).toThrow(/attested hash/i)
|
||||
})
|
||||
|
||||
it('rejects duplicate native attestation paths and the wrong platform policy', () => {
|
||||
const duplicate = createSshRelayArtifactTestManifest()
|
||||
duplicate.tuples[0].nativeVerification.files.push(
|
||||
structuredClone(duplicate.tuples[0].nativeVerification.files[0])
|
||||
)
|
||||
expect(() => parseSshRelayArtifactManifest(duplicate)).toThrow(/duplicate native attestation/i)
|
||||
|
||||
const wrongPolicy = createSshRelayArtifactTestManifest()
|
||||
wrongPolicy.tuples[0].nativeVerification.policy = 'apple-developer-id-v1'
|
||||
expect(() => parseSshRelayArtifactManifest(wrongPolicy)).toThrow(/verification policy/i)
|
||||
})
|
||||
|
||||
it('rejects duplicate signature keys and malformed signature bytes', () => {
|
||||
const duplicate = createSshRelayArtifactTestManifest()
|
||||
duplicate.signatures.push(structuredClone(duplicate.signatures[0]))
|
||||
expect(() => parseSshRelayArtifactManifest(duplicate)).toThrow(/duplicate signature/i)
|
||||
|
||||
const malformed = createSshRelayArtifactTestManifest()
|
||||
malformed.signatures[0].signature = Buffer.alloc(63).toString('base64')
|
||||
expect(() => parseSshRelayArtifactManifest(malformed)).toThrow(/64 bytes/i)
|
||||
|
||||
const algorithm = createSshRelayArtifactTestManifest()
|
||||
algorithm.signatures[0].algorithm = 'rsa-v1' as never
|
||||
expect(() => parseSshRelayArtifactManifest(algorithm)).toThrow()
|
||||
})
|
||||
|
||||
it('rejects a content identity or archive name that is not derived from the runtime', () => {
|
||||
const identityMismatch = createSshRelayArtifactTestManifest()
|
||||
identityMismatch.tuples[0].contentId = `sha256:${'f'.repeat(64)}`
|
||||
expect(() => parseSshRelayArtifactManifest(identityMismatch)).toThrow(/content identity/i)
|
||||
|
||||
const nameMismatch = createSshRelayArtifactTestManifest()
|
||||
nameMismatch.tuples[0].archive.name = 'latest.tar.xz'
|
||||
expect(() => parseSshRelayArtifactManifest(nameMismatch)).toThrow(/archive name/i)
|
||||
})
|
||||
|
||||
it('rejects platform-field conflicts and non-canonical digests', () => {
|
||||
const conflict = createSshRelayArtifactTestManifest()
|
||||
conflict.tuples[0].architecture = 'arm64'
|
||||
expect(() => parseSshRelayArtifactManifest(conflict)).toThrow(/platform fields/i)
|
||||
|
||||
const digest = createSshRelayArtifactTestManifest()
|
||||
digest.tuples[0].archive.sha256 = `sha256:${'A'.repeat(64)}`
|
||||
expect(() => parseSshRelayArtifactManifest(digest)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,268 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { assertSshRelayRuntimeTupleConsistency } from './ssh-relay-artifact-consistency'
|
||||
import { parseSshRelayReleaseTag } from './ssh-relay-release-asset'
|
||||
import type { SshRelayDigest, SshRelayRuntimeIdentityInput } from './ssh-relay-runtime-identity'
|
||||
|
||||
const MAX_ARCHIVE_SIZE = 100 * 1024 * 1024
|
||||
const MAX_EXPANDED_SIZE = 350 * 1024 * 1024
|
||||
const MAX_FILE_SIZE = 250 * 1024 * 1024
|
||||
const MAX_ENTRIES = 5_000
|
||||
const VERSION = /^\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$/
|
||||
const NUMERIC_VERSION = /^\d+\.\d+(?:\.\d+)?$/
|
||||
const OPENSSH_VERSION = /^\d+\.\d+p\d+$/
|
||||
const ASSET_NAME = /^[A-Za-z0-9._-]+$/
|
||||
const TIMESTAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/
|
||||
|
||||
const digestSchema = z.string().regex(/^sha256:[0-9a-f]{64}$/)
|
||||
const safeSizeSchema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER)
|
||||
const versionSchema = z.string().regex(VERSION).max(64)
|
||||
const numericVersionSchema = z.string().regex(NUMERIC_VERSION).max(64)
|
||||
const timestampSchema = z
|
||||
.string()
|
||||
.regex(TIMESTAMP)
|
||||
.refine(
|
||||
(value) => {
|
||||
const milliseconds = Date.parse(value)
|
||||
return !Number.isNaN(milliseconds) && new Date(milliseconds).toISOString() === value
|
||||
},
|
||||
{ message: 'invalid UTC timestamp' }
|
||||
)
|
||||
|
||||
const directoryEntrySchema = z
|
||||
.object({ path: z.string(), type: z.literal('directory'), mode: z.literal(0o755) })
|
||||
.strict()
|
||||
const fileEntrySchema = z
|
||||
.object({
|
||||
path: z.string(),
|
||||
type: z.literal('file'),
|
||||
role: z.enum([
|
||||
'node',
|
||||
'relay',
|
||||
'relay-watcher',
|
||||
'node-pty-native',
|
||||
'parcel-watcher-native',
|
||||
'native-runtime',
|
||||
'runtime-javascript',
|
||||
'license'
|
||||
]),
|
||||
size: safeSizeSchema.max(MAX_FILE_SIZE),
|
||||
mode: z.union([z.literal(0o644), z.literal(0o755)]),
|
||||
sha256: digestSchema
|
||||
})
|
||||
.strict()
|
||||
const entrySchema = z.discriminatedUnion('type', [directoryEntrySchema, fileEntrySchema])
|
||||
|
||||
const glibcSchema = z
|
||||
.object({
|
||||
family: z.literal('glibc'),
|
||||
minimumVersion: numericVersionSchema,
|
||||
minimumLibstdcxxVersion: numericVersionSchema,
|
||||
minimumGlibcxxVersion: numericVersionSchema
|
||||
})
|
||||
.strict()
|
||||
const muslSchema = z
|
||||
.object({
|
||||
family: z.literal('musl'),
|
||||
minimumVersion: numericVersionSchema,
|
||||
minimumLibstdcxxVersion: z.null(),
|
||||
minimumGlibcxxVersion: z.null()
|
||||
})
|
||||
.strict()
|
||||
const compatibilitySchema = z.discriminatedUnion('kind', [
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('linux'),
|
||||
minimumKernelVersion: numericVersionSchema,
|
||||
libc: z.discriminatedUnion('family', [glibcSchema, muslSchema])
|
||||
})
|
||||
.strict(),
|
||||
z.object({ kind: z.literal('darwin'), minimumVersion: numericVersionSchema }).strict(),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('windows'),
|
||||
minimumBuild: safeSizeSchema,
|
||||
minimumOpenSshVersion: z.string().regex(OPENSSH_VERSION).max(64),
|
||||
minimumPowerShellVersion: numericVersionSchema,
|
||||
minimumDotNetFrameworkRelease: safeSizeSchema
|
||||
})
|
||||
.strict()
|
||||
])
|
||||
|
||||
const metadataAssetSchema = z
|
||||
.object({ name: z.string().regex(ASSET_NAME), size: safeSizeSchema, sha256: digestSchema })
|
||||
.strict()
|
||||
const nativeVerificationSchema = z
|
||||
.object({
|
||||
policy: z.enum(['linux-hash-only-v1', 'apple-developer-id-v1', 'signpath-authenticode-v1']),
|
||||
tool: z
|
||||
.object({ name: z.string().regex(ASSET_NAME), version: z.string().min(1).max(64) })
|
||||
.strict(),
|
||||
verifiedAt: timestampSchema,
|
||||
files: z
|
||||
.array(z.object({ path: z.string(), sha256: digestSchema }).strict())
|
||||
.min(1)
|
||||
.max(MAX_ENTRIES)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const tupleSchema = z
|
||||
.object({
|
||||
tupleId: z.enum([
|
||||
'linux-x64-glibc',
|
||||
'linux-arm64-glibc',
|
||||
'linux-x64-musl',
|
||||
'linux-arm64-musl',
|
||||
'darwin-x64',
|
||||
'darwin-arm64',
|
||||
'win32-x64',
|
||||
'win32-arm64'
|
||||
]),
|
||||
os: z.enum(['linux', 'darwin', 'win32']),
|
||||
architecture: z.enum(['x64', 'arm64']),
|
||||
compatibility: compatibilitySchema,
|
||||
nodeVersion: versionSchema,
|
||||
dependencies: z
|
||||
.object({ nodePtyVersion: versionSchema, parcelWatcherVersion: versionSchema })
|
||||
.strict(),
|
||||
entries: z.array(entrySchema).min(1).max(MAX_ENTRIES),
|
||||
contentId: digestSchema,
|
||||
archive: z
|
||||
.object({
|
||||
name: z.string().regex(ASSET_NAME),
|
||||
size: safeSizeSchema.max(MAX_ARCHIVE_SIZE),
|
||||
expandedSize: safeSizeSchema.max(MAX_EXPANDED_SIZE),
|
||||
fileCount: safeSizeSchema.max(MAX_ENTRIES),
|
||||
sha256: digestSchema
|
||||
})
|
||||
.strict(),
|
||||
metadataAssets: z
|
||||
.object({ sbom: metadataAssetSchema, provenance: metadataAssetSchema })
|
||||
.strict(),
|
||||
nativeVerification: nativeVerificationSchema
|
||||
})
|
||||
.strict()
|
||||
|
||||
const signatureSchema = z
|
||||
.object({
|
||||
algorithm: z.literal('ed25519-v1'),
|
||||
keyId: digestSchema,
|
||||
signature: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z0-9+/]+={0,2}$/)
|
||||
.refine(
|
||||
(value) => {
|
||||
const decoded = Buffer.from(value, 'base64')
|
||||
return decoded.length === 64 && decoded.toString('base64') === value
|
||||
},
|
||||
{ message: 'Ed25519 signature must be canonical base64 encoding of 64 bytes' }
|
||||
)
|
||||
})
|
||||
.strict()
|
||||
|
||||
const unsignedManifestSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
build: z
|
||||
.object({
|
||||
tag: z.string(),
|
||||
channel: z.enum(['stable', 'rc', 'perf']),
|
||||
version: z.string(),
|
||||
relayProtocolVersion: z.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
|
||||
})
|
||||
.strict(),
|
||||
createdAt: timestampSchema,
|
||||
tuples: z.array(tupleSchema).min(1).max(8)
|
||||
})
|
||||
.strict()
|
||||
const manifestSchema = unsignedManifestSchema
|
||||
.extend({ signatures: z.array(signatureSchema).min(1).max(4) })
|
||||
.strict()
|
||||
|
||||
export type SshRelayManifestSignature = {
|
||||
algorithm: 'ed25519-v1'
|
||||
keyId: SshRelayDigest
|
||||
signature: string
|
||||
}
|
||||
export type SshRelayRuntimeTuple = SshRelayRuntimeIdentityInput & {
|
||||
contentId: SshRelayDigest
|
||||
archive: {
|
||||
name: string
|
||||
size: number
|
||||
expandedSize: number
|
||||
fileCount: number
|
||||
sha256: SshRelayDigest
|
||||
}
|
||||
metadataAssets: {
|
||||
sbom: { name: string; size: number; sha256: SshRelayDigest }
|
||||
provenance: { name: string; size: number; sha256: SshRelayDigest }
|
||||
}
|
||||
nativeVerification: {
|
||||
policy: 'linux-hash-only-v1' | 'apple-developer-id-v1' | 'signpath-authenticode-v1'
|
||||
tool: { name: string; version: string }
|
||||
verifiedAt: string
|
||||
files: { path: string; sha256: SshRelayDigest }[]
|
||||
}
|
||||
}
|
||||
export type SshRelayUnsignedArtifactManifest = {
|
||||
schemaVersion: 1
|
||||
build: {
|
||||
tag: string
|
||||
channel: 'stable' | 'rc' | 'perf'
|
||||
version: string
|
||||
relayProtocolVersion: number
|
||||
}
|
||||
createdAt: string
|
||||
tuples: SshRelayRuntimeTuple[]
|
||||
}
|
||||
export type SshRelayArtifactManifest = SshRelayUnsignedArtifactManifest & {
|
||||
signatures: SshRelayManifestSignature[]
|
||||
}
|
||||
|
||||
function validateUnsignedManifest(
|
||||
parsed: SshRelayUnsignedArtifactManifest
|
||||
): SshRelayUnsignedArtifactManifest {
|
||||
const release = parseSshRelayReleaseTag(parsed.build.tag)
|
||||
if (release.channel !== parsed.build.channel || release.version !== parsed.build.version) {
|
||||
throw new Error('SSH relay manifest build identity does not match its exact release tag')
|
||||
}
|
||||
const tupleIds = new Set<string>()
|
||||
for (const tuple of parsed.tuples) {
|
||||
if (tupleIds.has(tuple.tupleId)) {
|
||||
throw new Error(`SSH relay manifest has duplicate tuple: ${tuple.tupleId}`)
|
||||
}
|
||||
tupleIds.add(tuple.tupleId)
|
||||
assertSshRelayRuntimeTupleConsistency(tuple)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function withoutSignatures(input: unknown): unknown {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return input
|
||||
}
|
||||
const { signatures: _signatures, ...unsigned } = input as Record<string, unknown>
|
||||
return unsigned
|
||||
}
|
||||
|
||||
export function parseSshRelayUnsignedArtifactManifest(
|
||||
input: unknown
|
||||
): SshRelayUnsignedArtifactManifest {
|
||||
const parsed = unsignedManifestSchema.parse(
|
||||
withoutSignatures(input)
|
||||
) as unknown as SshRelayUnsignedArtifactManifest
|
||||
return validateUnsignedManifest(parsed)
|
||||
}
|
||||
|
||||
export function parseSshRelayArtifactManifest(input: unknown): SshRelayArtifactManifest {
|
||||
const parsed = manifestSchema.parse(input) as unknown as SshRelayArtifactManifest
|
||||
validateUnsignedManifest(parsed)
|
||||
const signatureKeys = new Set<string>()
|
||||
for (const signature of parsed.signatures) {
|
||||
if (signatureKeys.has(signature.keyId)) {
|
||||
throw new Error(`SSH relay manifest has duplicate signature key: ${signature.keyId}`)
|
||||
}
|
||||
signatureKeys.add(signature.keyId)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest'
|
||||
import { parseSshRelayArtifactManifest } from './ssh-relay-artifact-schema'
|
||||
import { selectSshRelayArtifact } from './ssh-relay-artifact-selector'
|
||||
import { computeSshRelayRuntimeContentId } from './ssh-relay-runtime-identity'
|
||||
import { sshRelayRuntimeArchiveName } from './ssh-relay-release-asset'
|
||||
|
||||
const compatibleLinuxHost = {
|
||||
os: 'linux' as const,
|
||||
architecture: 'x64' as const,
|
||||
processTranslated: false,
|
||||
kernelVersion: '6.8.0',
|
||||
libc: { family: 'glibc' as const, version: '2.39' },
|
||||
libstdcxxVersion: '6.0.33',
|
||||
glibcxxVersion: '3.4.33'
|
||||
}
|
||||
|
||||
function windowsManifest() {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
const tuple = structuredClone(manifest.tuples[0]) as unknown as Record<string, unknown>
|
||||
Object.assign(tuple, {
|
||||
tupleId: 'win32-x64',
|
||||
os: 'win32',
|
||||
architecture: 'x64',
|
||||
compatibility: {
|
||||
kind: 'windows',
|
||||
minimumBuild: 20348,
|
||||
minimumOpenSshVersion: '8.1p1',
|
||||
minimumPowerShellVersion: '5.1',
|
||||
minimumDotNetFrameworkRelease: 528040
|
||||
}
|
||||
})
|
||||
manifest.tuples = [tuple as never]
|
||||
return manifest
|
||||
}
|
||||
|
||||
const compatibleWindowsHost = {
|
||||
os: 'win32' as const,
|
||||
architecture: 'x64' as const,
|
||||
processTranslated: false,
|
||||
build: 20348,
|
||||
openSshVersion: '8.1p1',
|
||||
powerShellVersion: '5.1',
|
||||
dotNetFrameworkRelease: 528040
|
||||
}
|
||||
|
||||
describe('SSH relay artifact selector', () => {
|
||||
it('selects the single compatible glibc tuple', () => {
|
||||
const result = selectSshRelayArtifact(createSshRelayArtifactTestManifest(), compatibleLinuxHost)
|
||||
|
||||
expect(result).toMatchObject({ kind: 'selected', tupleId: 'linux-x64-glibc' })
|
||||
})
|
||||
|
||||
it('accepts every Linux compatibility value at its exact minimum', () => {
|
||||
expect(
|
||||
selectSshRelayArtifact(createSshRelayArtifactTestManifest(), {
|
||||
...compatibleLinuxHost,
|
||||
kernelVersion: '4.18',
|
||||
libc: { family: 'glibc', version: '2.28' },
|
||||
libstdcxxVersion: '6.0.25',
|
||||
glibcxxVersion: '3.4.25'
|
||||
}).kind
|
||||
).toBe('selected')
|
||||
})
|
||||
|
||||
it('selects the detected libc family when both Linux variants exist', () => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
const musl = structuredClone(manifest.tuples[0])
|
||||
musl.tupleId = 'linux-x64-musl'
|
||||
musl.compatibility = {
|
||||
kind: 'linux',
|
||||
minimumKernelVersion: '4.18',
|
||||
libc: {
|
||||
family: 'musl',
|
||||
minimumVersion: '1.2.5',
|
||||
minimumLibstdcxxVersion: null,
|
||||
minimumGlibcxxVersion: null
|
||||
}
|
||||
}
|
||||
for (const entry of musl.entries) {
|
||||
entry.path = entry.path.replace('watcher-linux-x64-glibc', 'watcher-linux-x64-musl')
|
||||
}
|
||||
for (const attestation of musl.nativeVerification.files) {
|
||||
attestation.path = attestation.path.replace(
|
||||
'watcher-linux-x64-glibc',
|
||||
'watcher-linux-x64-musl'
|
||||
)
|
||||
}
|
||||
musl.contentId = computeSshRelayRuntimeContentId(musl)
|
||||
musl.archive.name = sshRelayRuntimeArchiveName(musl.tupleId, musl.contentId)
|
||||
manifest.tuples.push(musl)
|
||||
const parsed = parseSshRelayArtifactManifest(manifest)
|
||||
|
||||
expect(selectSshRelayArtifact(parsed, compatibleLinuxHost)).toMatchObject({
|
||||
kind: 'selected',
|
||||
tupleId: 'linux-x64-glibc'
|
||||
})
|
||||
expect(
|
||||
selectSshRelayArtifact(parsed, {
|
||||
...compatibleLinuxHost,
|
||||
libc: { family: 'musl', version: '1.2.5' }
|
||||
})
|
||||
).toMatchObject({ kind: 'selected', tupleId: 'linux-x64-musl' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ ...compatibleLinuxHost, kernelVersion: '4.17' }, 'kernel-too-old'],
|
||||
[
|
||||
{ ...compatibleLinuxHost, libc: { family: 'glibc' as const, version: '2.27' } },
|
||||
'libc-too-old'
|
||||
],
|
||||
[{ ...compatibleLinuxHost, glibcxxVersion: '3.4.24' }, 'libstdcxx-too-old'],
|
||||
[{ ...compatibleLinuxHost, libstdcxxVersion: undefined }, 'unknown-libstdcxx'],
|
||||
[{ ...compatibleLinuxHost, kernelVersion: 'not-a-version' }, 'unknown-kernel'],
|
||||
[{ ...compatibleLinuxHost, libc: { family: 'unknown' as const } }, 'unknown-libc'],
|
||||
[
|
||||
{ ...compatibleLinuxHost, libc: { family: 'musl' as const, version: '1.2.5' } },
|
||||
'tuple-unavailable'
|
||||
],
|
||||
[{ ...compatibleLinuxHost, processTranslated: true }, 'translated-process'],
|
||||
[{ ...compatibleLinuxHost, architecture: 'arm64' as const }, 'tuple-unavailable']
|
||||
])('selects legacy for incompatible or unknown host evidence', (host, reason) => {
|
||||
expect(selectSshRelayArtifact(createSshRelayArtifactTestManifest(), host)).toEqual({
|
||||
kind: 'legacy',
|
||||
reason
|
||||
})
|
||||
})
|
||||
|
||||
it('checks Windows bootstrap versions before selection', () => {
|
||||
const manifest = windowsManifest()
|
||||
expect(selectSshRelayArtifact(manifest, compatibleWindowsHost).kind).toBe('selected')
|
||||
expect(selectSshRelayArtifact(manifest, { ...compatibleWindowsHost, build: 20347 })).toEqual({
|
||||
kind: 'legacy',
|
||||
reason: 'os-too-old'
|
||||
})
|
||||
expect(
|
||||
selectSshRelayArtifact(manifest, {
|
||||
...compatibleWindowsHost,
|
||||
openSshVersion: '8.0p1'
|
||||
})
|
||||
).toEqual({ kind: 'legacy', reason: 'openssh-too-old' })
|
||||
expect(
|
||||
selectSshRelayArtifact(manifest, {
|
||||
...compatibleWindowsHost,
|
||||
powerShellVersion: '5.0'
|
||||
})
|
||||
).toEqual({ kind: 'legacy', reason: 'powershell-too-old' })
|
||||
expect(
|
||||
selectSshRelayArtifact(manifest, {
|
||||
...compatibleWindowsHost,
|
||||
dotNetFrameworkRelease: 528039
|
||||
})
|
||||
).toEqual({ kind: 'legacy', reason: 'dotnet-too-old' })
|
||||
})
|
||||
|
||||
it('selects legacy when Windows bootstrap evidence is absent or malformed', () => {
|
||||
const manifest = windowsManifest()
|
||||
expect(
|
||||
selectSshRelayArtifact(manifest, { ...compatibleWindowsHost, openSshVersion: '8.1.0' })
|
||||
).toEqual({ kind: 'legacy', reason: 'unknown-openssh' })
|
||||
expect(
|
||||
selectSshRelayArtifact(manifest, { ...compatibleWindowsHost, powerShellVersion: undefined })
|
||||
).toEqual({ kind: 'legacy', reason: 'unknown-powershell' })
|
||||
expect(
|
||||
selectSshRelayArtifact(manifest, {
|
||||
...compatibleWindowsHost,
|
||||
dotNetFrameworkRelease: undefined
|
||||
})
|
||||
).toEqual({ kind: 'legacy', reason: 'unknown-dotnet' })
|
||||
})
|
||||
|
||||
it('checks the minimum macOS version before selection', () => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
const tuple = structuredClone(manifest.tuples[0]) as unknown as Record<string, unknown>
|
||||
Object.assign(tuple, {
|
||||
tupleId: 'darwin-arm64',
|
||||
os: 'darwin',
|
||||
architecture: 'arm64',
|
||||
compatibility: { kind: 'darwin', minimumVersion: '13.5' }
|
||||
})
|
||||
manifest.tuples = [tuple as never]
|
||||
|
||||
expect(
|
||||
selectSshRelayArtifact(manifest, {
|
||||
os: 'darwin',
|
||||
architecture: 'arm64',
|
||||
processTranslated: false,
|
||||
version: '13.4'
|
||||
})
|
||||
).toEqual({ kind: 'legacy', reason: 'os-too-old' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { SshRelayArtifactManifest, SshRelayRuntimeTuple } from './ssh-relay-artifact-schema'
|
||||
|
||||
type SshRelayHostBase = {
|
||||
architecture: 'x64' | 'arm64'
|
||||
processTranslated: boolean
|
||||
}
|
||||
|
||||
export type SshRelayLinuxHostEvidence = SshRelayHostBase & {
|
||||
os: 'linux'
|
||||
kernelVersion?: string
|
||||
libc: { family: 'glibc' | 'musl'; version?: string } | { family: 'unknown' }
|
||||
libstdcxxVersion?: string
|
||||
glibcxxVersion?: string
|
||||
}
|
||||
|
||||
export type SshRelayDarwinHostEvidence = SshRelayHostBase & {
|
||||
os: 'darwin'
|
||||
version?: string
|
||||
}
|
||||
|
||||
export type SshRelayWindowsHostEvidence = SshRelayHostBase & {
|
||||
os: 'win32'
|
||||
build?: number
|
||||
openSshVersion?: string
|
||||
powerShellVersion?: string
|
||||
dotNetFrameworkRelease?: number
|
||||
}
|
||||
|
||||
export type SshRelayHostEvidence =
|
||||
| SshRelayLinuxHostEvidence
|
||||
| SshRelayDarwinHostEvidence
|
||||
| SshRelayWindowsHostEvidence
|
||||
|
||||
export type SshRelayArtifactLegacyReason =
|
||||
| 'tuple-inconsistent'
|
||||
| 'tuple-unavailable'
|
||||
| 'tuple-ambiguous'
|
||||
| 'translated-process'
|
||||
| 'unknown-kernel'
|
||||
| 'kernel-too-old'
|
||||
| 'unknown-libc'
|
||||
| 'libc-too-old'
|
||||
| 'unknown-libstdcxx'
|
||||
| 'libstdcxx-too-old'
|
||||
| 'unknown-os-version'
|
||||
| 'os-too-old'
|
||||
| 'unknown-openssh'
|
||||
| 'openssh-too-old'
|
||||
| 'unknown-powershell'
|
||||
| 'powershell-too-old'
|
||||
| 'unknown-dotnet'
|
||||
| 'dotnet-too-old'
|
||||
|
||||
export type SshRelayArtifactSelection =
|
||||
| { kind: 'selected'; tupleId: SshRelayRuntimeTuple['tupleId']; tuple: SshRelayRuntimeTuple }
|
||||
| { kind: 'legacy'; reason: SshRelayArtifactLegacyReason }
|
||||
|
||||
function parseNumericVersion(value: string | undefined): number[] | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const match = /^(\d+(?:\.\d+){1,3})(?:-[0-9A-Za-z.-]+)?$/.exec(value)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const components = match[1].split('.').map(Number)
|
||||
return components.every(Number.isSafeInteger) ? components : null
|
||||
}
|
||||
|
||||
function parseOpenSshVersion(value: string | undefined): number[] | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
const match = /^(\d+)\.(\d+)p(\d+)$/.exec(value)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const components = match.slice(1).map(Number)
|
||||
return components.every(Number.isSafeInteger) ? components : null
|
||||
}
|
||||
|
||||
function compareComponents(left: number[], right: number[]): number {
|
||||
const length = Math.max(left.length, right.length)
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const difference = (left[index] ?? 0) - (right[index] ?? 0)
|
||||
if (difference !== 0) {
|
||||
return difference
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function meetsVersion(
|
||||
actual: string | undefined,
|
||||
minimum: string,
|
||||
parser = parseNumericVersion
|
||||
): boolean | null {
|
||||
const actualParts = parser(actual)
|
||||
const minimumParts = parser(minimum)
|
||||
if (!actualParts || !minimumParts) {
|
||||
return null
|
||||
}
|
||||
return compareComponents(actualParts, minimumParts) >= 0
|
||||
}
|
||||
|
||||
function selectLinux(
|
||||
tuple: SshRelayRuntimeTuple,
|
||||
host: SshRelayLinuxHostEvidence
|
||||
): SshRelayArtifactSelection {
|
||||
if (tuple.compatibility.kind !== 'linux') {
|
||||
return { kind: 'legacy', reason: 'tuple-inconsistent' }
|
||||
}
|
||||
const kernel = meetsVersion(host.kernelVersion, tuple.compatibility.minimumKernelVersion)
|
||||
if (kernel === null) {
|
||||
return { kind: 'legacy', reason: 'unknown-kernel' }
|
||||
}
|
||||
if (!kernel) {
|
||||
return { kind: 'legacy', reason: 'kernel-too-old' }
|
||||
}
|
||||
if (host.libc.family === 'unknown') {
|
||||
return { kind: 'legacy', reason: 'unknown-libc' }
|
||||
}
|
||||
if (host.libc.family !== tuple.compatibility.libc.family) {
|
||||
return { kind: 'legacy', reason: 'tuple-unavailable' }
|
||||
}
|
||||
const libc = meetsVersion(host.libc.version, tuple.compatibility.libc.minimumVersion)
|
||||
if (libc === null) {
|
||||
return { kind: 'legacy', reason: 'unknown-libc' }
|
||||
}
|
||||
if (!libc) {
|
||||
return { kind: 'legacy', reason: 'libc-too-old' }
|
||||
}
|
||||
|
||||
if (tuple.compatibility.libc.family === 'glibc') {
|
||||
const libstdcxx = meetsVersion(
|
||||
host.libstdcxxVersion,
|
||||
tuple.compatibility.libc.minimumLibstdcxxVersion
|
||||
)
|
||||
const glibcxx = meetsVersion(
|
||||
host.glibcxxVersion,
|
||||
tuple.compatibility.libc.minimumGlibcxxVersion
|
||||
)
|
||||
if (libstdcxx === null || glibcxx === null) {
|
||||
return { kind: 'legacy', reason: 'unknown-libstdcxx' }
|
||||
}
|
||||
if (!libstdcxx || !glibcxx) {
|
||||
return { kind: 'legacy', reason: 'libstdcxx-too-old' }
|
||||
}
|
||||
}
|
||||
return { kind: 'selected', tupleId: tuple.tupleId, tuple }
|
||||
}
|
||||
|
||||
function selectDarwin(
|
||||
tuple: SshRelayRuntimeTuple,
|
||||
host: SshRelayDarwinHostEvidence
|
||||
): SshRelayArtifactSelection {
|
||||
if (tuple.compatibility.kind !== 'darwin') {
|
||||
return { kind: 'legacy', reason: 'tuple-inconsistent' }
|
||||
}
|
||||
const compatible = meetsVersion(host.version, tuple.compatibility.minimumVersion)
|
||||
if (compatible === null) {
|
||||
return { kind: 'legacy', reason: 'unknown-os-version' }
|
||||
}
|
||||
return compatible
|
||||
? { kind: 'selected', tupleId: tuple.tupleId, tuple }
|
||||
: { kind: 'legacy', reason: 'os-too-old' }
|
||||
}
|
||||
|
||||
function selectWindows(
|
||||
tuple: SshRelayRuntimeTuple,
|
||||
host: SshRelayWindowsHostEvidence
|
||||
): SshRelayArtifactSelection {
|
||||
if (tuple.compatibility.kind !== 'windows') {
|
||||
return { kind: 'legacy', reason: 'tuple-inconsistent' }
|
||||
}
|
||||
if (typeof host.build !== 'number' || !Number.isSafeInteger(host.build)) {
|
||||
return { kind: 'legacy', reason: 'unknown-os-version' }
|
||||
}
|
||||
if (host.build < tuple.compatibility.minimumBuild) {
|
||||
return { kind: 'legacy', reason: 'os-too-old' }
|
||||
}
|
||||
const openSsh = meetsVersion(
|
||||
host.openSshVersion,
|
||||
tuple.compatibility.minimumOpenSshVersion,
|
||||
parseOpenSshVersion
|
||||
)
|
||||
if (openSsh === null) {
|
||||
return { kind: 'legacy', reason: 'unknown-openssh' }
|
||||
}
|
||||
if (!openSsh) {
|
||||
return { kind: 'legacy', reason: 'openssh-too-old' }
|
||||
}
|
||||
const powerShell = meetsVersion(
|
||||
host.powerShellVersion,
|
||||
tuple.compatibility.minimumPowerShellVersion
|
||||
)
|
||||
if (powerShell === null) {
|
||||
return { kind: 'legacy', reason: 'unknown-powershell' }
|
||||
}
|
||||
if (!powerShell) {
|
||||
return { kind: 'legacy', reason: 'powershell-too-old' }
|
||||
}
|
||||
if (
|
||||
typeof host.dotNetFrameworkRelease !== 'number' ||
|
||||
!Number.isSafeInteger(host.dotNetFrameworkRelease)
|
||||
) {
|
||||
return { kind: 'legacy', reason: 'unknown-dotnet' }
|
||||
}
|
||||
if (host.dotNetFrameworkRelease < tuple.compatibility.minimumDotNetFrameworkRelease) {
|
||||
return { kind: 'legacy', reason: 'dotnet-too-old' }
|
||||
}
|
||||
return { kind: 'selected', tupleId: tuple.tupleId, tuple }
|
||||
}
|
||||
|
||||
export function selectSshRelayArtifact(
|
||||
manifest: SshRelayArtifactManifest,
|
||||
host: SshRelayHostEvidence
|
||||
): SshRelayArtifactSelection {
|
||||
// Why: translated and ambiguous process boundaries stay on the proven legacy path until their
|
||||
// own live SSH evidence exists; selecting a native artifact here would be an unsafe guess.
|
||||
if (host.processTranslated) {
|
||||
return { kind: 'legacy', reason: 'translated-process' }
|
||||
}
|
||||
const platformCandidates = manifest.tuples.filter(
|
||||
(tuple) => tuple.os === host.os && tuple.architecture === host.architecture
|
||||
)
|
||||
if (host.os === 'linux' && host.libc.family === 'unknown') {
|
||||
return { kind: 'legacy', reason: 'unknown-libc' }
|
||||
}
|
||||
const candidates =
|
||||
host.os === 'linux'
|
||||
? platformCandidates.filter(
|
||||
(tuple) =>
|
||||
tuple.compatibility.kind === 'linux' &&
|
||||
tuple.compatibility.libc.family === host.libc.family
|
||||
)
|
||||
: platformCandidates
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
kind: 'legacy',
|
||||
reason: platformCandidates.some((tuple) => tuple.compatibility.kind !== host.os)
|
||||
? 'tuple-inconsistent'
|
||||
: 'tuple-unavailable'
|
||||
}
|
||||
}
|
||||
if (candidates.length > 1) {
|
||||
return { kind: 'legacy', reason: 'tuple-ambiguous' }
|
||||
}
|
||||
|
||||
const tuple = candidates[0]
|
||||
if (host.os === 'linux') {
|
||||
return selectLinux(tuple, host)
|
||||
}
|
||||
if (host.os === 'darwin') {
|
||||
return selectDarwin(tuple, host)
|
||||
}
|
||||
return selectWindows(tuple, host)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { SshRelayArtifactManifest } from './ssh-relay-artifact-schema'
|
||||
import {
|
||||
computeSshRelayRuntimeContentId,
|
||||
type SshRelayRuntimeIdentityInput
|
||||
} from './ssh-relay-runtime-identity'
|
||||
import { sshRelayRuntimeArchiveName } from './ssh-relay-release-asset'
|
||||
|
||||
const digest = (character: string): `sha256:${string}` => `sha256:${character.repeat(64)}`
|
||||
|
||||
export function createSshRelayArtifactTestManifest(): SshRelayArtifactManifest {
|
||||
const runtime: SshRelayRuntimeIdentityInput = {
|
||||
tupleId: 'linux-x64-glibc',
|
||||
os: 'linux',
|
||||
architecture: 'x64',
|
||||
compatibility: {
|
||||
kind: 'linux',
|
||||
minimumKernelVersion: '4.18',
|
||||
libc: {
|
||||
family: 'glibc',
|
||||
minimumVersion: '2.28',
|
||||
minimumLibstdcxxVersion: '6.0.25',
|
||||
minimumGlibcxxVersion: '3.4.25'
|
||||
}
|
||||
},
|
||||
nodeVersion: '24.18.0',
|
||||
dependencies: {
|
||||
nodePtyVersion: '1.1.0',
|
||||
parcelWatcherVersion: '2.5.6'
|
||||
},
|
||||
entries: [
|
||||
{ path: 'bin', type: 'directory', mode: 0o755 },
|
||||
{
|
||||
path: 'bin/node',
|
||||
type: 'file',
|
||||
role: 'node',
|
||||
size: 40,
|
||||
mode: 0o755,
|
||||
sha256: digest('1')
|
||||
},
|
||||
{
|
||||
path: 'relay.js',
|
||||
type: 'file',
|
||||
role: 'relay',
|
||||
size: 20,
|
||||
mode: 0o644,
|
||||
sha256: digest('2')
|
||||
},
|
||||
{
|
||||
path: 'relay-watcher.js',
|
||||
type: 'file',
|
||||
role: 'relay-watcher',
|
||||
size: 15,
|
||||
mode: 0o644,
|
||||
sha256: digest('3')
|
||||
},
|
||||
{ path: 'node_modules', type: 'directory', mode: 0o755 },
|
||||
{ path: 'node_modules/node-pty', type: 'directory', mode: 0o755 },
|
||||
{
|
||||
path: 'node_modules/node-pty/package.json',
|
||||
type: 'file',
|
||||
role: 'runtime-javascript',
|
||||
size: 10,
|
||||
mode: 0o644,
|
||||
sha256: digest('4')
|
||||
},
|
||||
{ path: 'node_modules/node-pty/build', type: 'directory', mode: 0o755 },
|
||||
{ path: 'node_modules/node-pty/build/Release', type: 'directory', mode: 0o755 },
|
||||
{
|
||||
path: 'node_modules/node-pty/build/Release/pty.node',
|
||||
type: 'file',
|
||||
role: 'node-pty-native',
|
||||
size: 30,
|
||||
mode: 0o755,
|
||||
sha256: digest('5')
|
||||
},
|
||||
{ path: 'node_modules/@parcel', type: 'directory', mode: 0o755 },
|
||||
{ path: 'node_modules/@parcel/watcher', type: 'directory', mode: 0o755 },
|
||||
{
|
||||
path: 'node_modules/@parcel/watcher/package.json',
|
||||
type: 'file',
|
||||
role: 'runtime-javascript',
|
||||
size: 10,
|
||||
mode: 0o644,
|
||||
sha256: digest('6')
|
||||
},
|
||||
{
|
||||
path: 'node_modules/@parcel/watcher-linux-x64-glibc',
|
||||
type: 'directory',
|
||||
mode: 0o755
|
||||
},
|
||||
{
|
||||
path: 'node_modules/@parcel/watcher-linux-x64-glibc/watcher.node',
|
||||
type: 'file',
|
||||
role: 'parcel-watcher-native',
|
||||
size: 25,
|
||||
mode: 0o755,
|
||||
sha256: digest('7')
|
||||
},
|
||||
{
|
||||
path: 'THIRD_PARTY_LICENSES.txt',
|
||||
type: 'file',
|
||||
role: 'license',
|
||||
size: 10,
|
||||
mode: 0o644,
|
||||
sha256: digest('8')
|
||||
}
|
||||
]
|
||||
}
|
||||
const contentId = computeSshRelayRuntimeContentId(runtime)
|
||||
const fileEntries = runtime.entries.filter((entry) => entry.type === 'file')
|
||||
const expandedSize = fileEntries.reduce((total, entry) => total + entry.size, 0)
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
build: {
|
||||
tag: 'v1.4.140-rc.1',
|
||||
channel: 'rc',
|
||||
version: '1.4.140-rc.1',
|
||||
relayProtocolVersion: 1
|
||||
},
|
||||
createdAt: '2026-07-14T00:00:00.000Z',
|
||||
tuples: [
|
||||
{
|
||||
...runtime,
|
||||
contentId,
|
||||
archive: {
|
||||
name: sshRelayRuntimeArchiveName(runtime.tupleId, contentId),
|
||||
size: 100,
|
||||
expandedSize,
|
||||
fileCount: fileEntries.length,
|
||||
sha256: digest('a')
|
||||
},
|
||||
metadataAssets: {
|
||||
sbom: {
|
||||
name: 'orca-ssh-relay-runtime-linux-x64-glibc.spdx.json',
|
||||
size: 50,
|
||||
sha256: digest('b')
|
||||
},
|
||||
provenance: {
|
||||
name: 'orca-ssh-relay-runtime-linux-x64-glibc.provenance.json',
|
||||
size: 50,
|
||||
sha256: digest('c')
|
||||
}
|
||||
},
|
||||
nativeVerification: {
|
||||
policy: 'linux-hash-only-v1',
|
||||
tool: { name: 'sha256sum', version: '9.4' },
|
||||
verifiedAt: '2026-07-14T00:00:00.000Z',
|
||||
files: [
|
||||
{ path: 'bin/node', sha256: digest('1') },
|
||||
{ path: 'node_modules/node-pty/build/Release/pty.node', sha256: digest('5') },
|
||||
{
|
||||
path: 'node_modules/@parcel/watcher-linux-x64-glibc/watcher.node',
|
||||
sha256: digest('7')
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
signatures: [
|
||||
{
|
||||
algorithm: 'ed25519-v1',
|
||||
keyId: digest('d'),
|
||||
signature: Buffer.alloc(64).toString('base64')
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import nacl from 'tweetnacl'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest'
|
||||
import {
|
||||
canonicalUnsignedSshRelayManifestBytes,
|
||||
signSshRelayArtifactManifest,
|
||||
sshRelayManifestKeyId,
|
||||
verifySshRelayArtifactManifest
|
||||
} from './ssh-relay-manifest-signature'
|
||||
|
||||
const keyPair = nacl.sign.keyPair.fromSeed(Uint8Array.from({ length: 32 }, (_, index) => index))
|
||||
|
||||
function signedManifest() {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
manifest.signatures = [signSshRelayArtifactManifest(manifest, keyPair.secretKey)]
|
||||
return manifest
|
||||
}
|
||||
|
||||
function unsignedManifest() {
|
||||
const { signatures: _signatures, ...unsigned } = createSshRelayArtifactTestManifest()
|
||||
return unsigned
|
||||
}
|
||||
|
||||
describe('SSH relay manifest signatures', () => {
|
||||
it('canonicalizes object keys independently of insertion order', () => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
const reordered = {
|
||||
signatures: manifest.signatures,
|
||||
tuples: manifest.tuples,
|
||||
createdAt: manifest.createdAt,
|
||||
build: manifest.build,
|
||||
schemaVersion: manifest.schemaVersion
|
||||
}
|
||||
|
||||
expect(canonicalUnsignedSshRelayManifestBytes(reordered)).toEqual(
|
||||
canonicalUnsignedSshRelayManifestBytes(manifest)
|
||||
)
|
||||
})
|
||||
|
||||
it('has a fixed canonical unsigned test vector and ignores array insertion order', () => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
const reordered = structuredClone(manifest)
|
||||
reordered.tuples[0].entries.reverse()
|
||||
reordered.tuples[0].nativeVerification.files.reverse()
|
||||
reordered.signatures[0].signature = Buffer.alloc(64, 1).toString('base64')
|
||||
|
||||
const canonical = canonicalUnsignedSshRelayManifestBytes(manifest)
|
||||
expect(canonicalUnsignedSshRelayManifestBytes(reordered)).toEqual(canonical)
|
||||
expect(createHash('sha256').update(canonical).digest('hex')).toBe(
|
||||
'e78bf4416628a91055035dc7926035cbf633f29d3618be34e041c6dc5e0794fb'
|
||||
)
|
||||
})
|
||||
|
||||
it('creates the first signature from validated unsigned content', () => {
|
||||
const unsigned = unsignedManifest()
|
||||
const signature = signSshRelayArtifactManifest(unsigned, keyPair.secretKey)
|
||||
const manifest = { ...unsigned, signatures: [signature] }
|
||||
|
||||
expect(
|
||||
verifySshRelayArtifactManifest(manifest, [
|
||||
{ keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey }
|
||||
])
|
||||
).toEqual(manifest)
|
||||
})
|
||||
|
||||
it('rejects invalid key sizes and inconsistent unsigned content before signing', () => {
|
||||
expect(() => sshRelayManifestKeyId(new Uint8Array(31))).toThrow(/32 bytes/i)
|
||||
expect(() => signSshRelayArtifactManifest(unsignedManifest(), new Uint8Array(63))).toThrow(
|
||||
/64 bytes/i
|
||||
)
|
||||
|
||||
const inconsistent = unsignedManifest()
|
||||
inconsistent.tuples[0].contentId = `sha256:${'f'.repeat(64)}`
|
||||
expect(() => signSshRelayArtifactManifest(inconsistent, keyPair.secretKey)).toThrow(
|
||||
/content identity/i
|
||||
)
|
||||
})
|
||||
|
||||
it('verifies a signed manifest with the embedded accepted key', () => {
|
||||
const manifest = signedManifest()
|
||||
|
||||
const verified = verifySshRelayArtifactManifest(manifest, [
|
||||
{ keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey }
|
||||
])
|
||||
|
||||
expect(verified.tuples[0].contentId).toBe(manifest.tuples[0].contentId)
|
||||
})
|
||||
|
||||
it('rejects signed content mutation', () => {
|
||||
const manifest = signedManifest()
|
||||
manifest.build.relayProtocolVersion += 1
|
||||
|
||||
expect(() =>
|
||||
verifySshRelayArtifactManifest(manifest, [
|
||||
{ keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey }
|
||||
])
|
||||
).toThrow(/signature/i)
|
||||
})
|
||||
|
||||
it('fails closed for unknown, duplicate, malformed, or mismatched keys', () => {
|
||||
const manifest = signedManifest()
|
||||
expect(() => verifySshRelayArtifactManifest(manifest, [])).toThrow(/unknown signing key/i)
|
||||
|
||||
const duplicate = signedManifest()
|
||||
duplicate.signatures.push(structuredClone(duplicate.signatures[0]))
|
||||
expect(() => verifySshRelayArtifactManifest(duplicate, [])).toThrow(/duplicate signature/i)
|
||||
|
||||
const malformed = signedManifest()
|
||||
malformed.signatures[0].signature = Buffer.alloc(63).toString('base64')
|
||||
expect(() => verifySshRelayArtifactManifest(malformed, [])).toThrow(/64 bytes/i)
|
||||
|
||||
const otherPair = nacl.sign.keyPair()
|
||||
const mismatch = signedManifest()
|
||||
expect(() =>
|
||||
verifySshRelayArtifactManifest(mismatch, [
|
||||
{ keyId: mismatch.signatures[0].keyId, publicKey: otherPair.publicKey }
|
||||
])
|
||||
).toThrow(/public key id/i)
|
||||
})
|
||||
|
||||
it('accepts dual signatures only when every signing key is accepted', () => {
|
||||
const manifest = signedManifest()
|
||||
const nextKeyPair = nacl.sign.keyPair.fromSeed(
|
||||
Uint8Array.from({ length: 32 }, (_, index) => 31 - index)
|
||||
)
|
||||
manifest.signatures.push(signSshRelayArtifactManifest(manifest, nextKeyPair.secretKey))
|
||||
|
||||
expect(() =>
|
||||
verifySshRelayArtifactManifest(manifest, [
|
||||
{ keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey }
|
||||
])
|
||||
).toThrow(/unknown signing key/i)
|
||||
expect(
|
||||
verifySshRelayArtifactManifest(manifest, [
|
||||
{ keyId: sshRelayManifestKeyId(keyPair.publicKey), publicKey: keyPair.publicKey },
|
||||
{ keyId: sshRelayManifestKeyId(nextKeyPair.publicKey), publicKey: nextKeyPair.publicKey }
|
||||
])
|
||||
).toEqual(manifest)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,198 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
import nacl from 'tweetnacl'
|
||||
|
||||
import {
|
||||
parseSshRelayArtifactManifest,
|
||||
parseSshRelayUnsignedArtifactManifest,
|
||||
type SshRelayArtifactManifest,
|
||||
type SshRelayManifestSignature,
|
||||
type SshRelayRuntimeTuple,
|
||||
type SshRelayUnsignedArtifactManifest
|
||||
} from './ssh-relay-artifact-schema'
|
||||
import type {
|
||||
SshRelayDigest,
|
||||
SshRelayRuntimeCompatibility,
|
||||
SshRelayRuntimeEntry
|
||||
} from './ssh-relay-runtime-identity'
|
||||
|
||||
export type SshRelayManifestAcceptedKey = {
|
||||
keyId: SshRelayDigest
|
||||
publicKey: Uint8Array
|
||||
}
|
||||
|
||||
function compareAscii(left: string, right: string): number {
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}
|
||||
|
||||
function canonicalCompatibility(compatibility: SshRelayRuntimeCompatibility): object {
|
||||
if (compatibility.kind === 'linux') {
|
||||
return {
|
||||
kind: compatibility.kind,
|
||||
minimumKernelVersion: compatibility.minimumKernelVersion,
|
||||
libc: {
|
||||
family: compatibility.libc.family,
|
||||
minimumVersion: compatibility.libc.minimumVersion,
|
||||
minimumLibstdcxxVersion: compatibility.libc.minimumLibstdcxxVersion,
|
||||
minimumGlibcxxVersion: compatibility.libc.minimumGlibcxxVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
if (compatibility.kind === 'darwin') {
|
||||
return { kind: compatibility.kind, minimumVersion: compatibility.minimumVersion }
|
||||
}
|
||||
return {
|
||||
kind: compatibility.kind,
|
||||
minimumBuild: compatibility.minimumBuild,
|
||||
minimumOpenSshVersion: compatibility.minimumOpenSshVersion,
|
||||
minimumPowerShellVersion: compatibility.minimumPowerShellVersion,
|
||||
minimumDotNetFrameworkRelease: compatibility.minimumDotNetFrameworkRelease
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalEntry(entry: SshRelayRuntimeEntry): object {
|
||||
if (entry.type === 'directory') {
|
||||
return { path: entry.path, type: entry.type, mode: entry.mode }
|
||||
}
|
||||
return {
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
role: entry.role,
|
||||
size: entry.size,
|
||||
mode: entry.mode,
|
||||
sha256: entry.sha256
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalTuple(tuple: SshRelayRuntimeTuple): object {
|
||||
return {
|
||||
tupleId: tuple.tupleId,
|
||||
os: tuple.os,
|
||||
architecture: tuple.architecture,
|
||||
compatibility: canonicalCompatibility(tuple.compatibility),
|
||||
nodeVersion: tuple.nodeVersion,
|
||||
dependencies: {
|
||||
nodePtyVersion: tuple.dependencies.nodePtyVersion,
|
||||
parcelWatcherVersion: tuple.dependencies.parcelWatcherVersion
|
||||
},
|
||||
entries: [...tuple.entries]
|
||||
.sort((left, right) => compareAscii(left.path, right.path))
|
||||
.map(canonicalEntry),
|
||||
contentId: tuple.contentId,
|
||||
archive: {
|
||||
name: tuple.archive.name,
|
||||
size: tuple.archive.size,
|
||||
expandedSize: tuple.archive.expandedSize,
|
||||
fileCount: tuple.archive.fileCount,
|
||||
sha256: tuple.archive.sha256
|
||||
},
|
||||
metadataAssets: {
|
||||
sbom: {
|
||||
name: tuple.metadataAssets.sbom.name,
|
||||
size: tuple.metadataAssets.sbom.size,
|
||||
sha256: tuple.metadataAssets.sbom.sha256
|
||||
},
|
||||
provenance: {
|
||||
name: tuple.metadataAssets.provenance.name,
|
||||
size: tuple.metadataAssets.provenance.size,
|
||||
sha256: tuple.metadataAssets.provenance.sha256
|
||||
}
|
||||
},
|
||||
nativeVerification: {
|
||||
policy: tuple.nativeVerification.policy,
|
||||
tool: {
|
||||
name: tuple.nativeVerification.tool.name,
|
||||
version: tuple.nativeVerification.tool.version
|
||||
},
|
||||
verifiedAt: tuple.nativeVerification.verifiedAt,
|
||||
files: [...tuple.nativeVerification.files]
|
||||
.sort((left, right) => compareAscii(left.path, right.path))
|
||||
.map((file) => ({ path: file.path, sha256: file.sha256 }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalUnsignedSshRelayManifestBytes(input: unknown): Buffer {
|
||||
const manifest = parseSshRelayUnsignedArtifactManifest(input)
|
||||
// Why: signatures authenticate a fixed validated projection, never caller insertion order or
|
||||
// signature-array contents that could vary during key rotation.
|
||||
const unsignedProjection = {
|
||||
schemaVersion: manifest.schemaVersion,
|
||||
build: {
|
||||
tag: manifest.build.tag,
|
||||
channel: manifest.build.channel,
|
||||
version: manifest.build.version,
|
||||
relayProtocolVersion: manifest.build.relayProtocolVersion
|
||||
},
|
||||
createdAt: manifest.createdAt,
|
||||
tuples: [...manifest.tuples]
|
||||
.sort((left, right) => compareAscii(left.tupleId, right.tupleId))
|
||||
.map(canonicalTuple)
|
||||
}
|
||||
return Buffer.from(JSON.stringify(unsignedProjection), 'utf8')
|
||||
}
|
||||
|
||||
export function sshRelayManifestKeyId(publicKey: Uint8Array): SshRelayDigest {
|
||||
if (publicKey.byteLength !== nacl.sign.publicKeyLength) {
|
||||
throw new Error('SSH relay manifest Ed25519 public key must be 32 bytes')
|
||||
}
|
||||
return `sha256:${createHash('sha256').update(publicKey).digest('hex')}`
|
||||
}
|
||||
|
||||
export function signSshRelayArtifactManifest(
|
||||
manifest: SshRelayArtifactManifest | SshRelayUnsignedArtifactManifest,
|
||||
secretKey: Uint8Array
|
||||
): SshRelayManifestSignature {
|
||||
if (secretKey.byteLength !== nacl.sign.secretKeyLength) {
|
||||
throw new Error('SSH relay manifest Ed25519 secret key must be 64 bytes')
|
||||
}
|
||||
const publicKey = nacl.sign.keyPair.fromSecretKey(secretKey).publicKey
|
||||
const signature = nacl.sign.detached(canonicalUnsignedSshRelayManifestBytes(manifest), secretKey)
|
||||
return {
|
||||
algorithm: 'ed25519-v1',
|
||||
keyId: sshRelayManifestKeyId(publicKey),
|
||||
signature: Buffer.from(signature).toString('base64')
|
||||
}
|
||||
}
|
||||
|
||||
function acceptedKeyMap(keys: readonly SshRelayManifestAcceptedKey[]): Map<string, Uint8Array> {
|
||||
const accepted = new Map<string, Uint8Array>()
|
||||
for (const key of keys) {
|
||||
const derivedKeyId = sshRelayManifestKeyId(key.publicKey)
|
||||
if (key.keyId !== derivedKeyId) {
|
||||
throw new Error(`SSH relay manifest public key ID does not match key bytes: ${key.keyId}`)
|
||||
}
|
||||
if (accepted.has(key.keyId)) {
|
||||
throw new Error(`Duplicate accepted SSH relay manifest key: ${key.keyId}`)
|
||||
}
|
||||
accepted.set(key.keyId, key.publicKey)
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
export function verifySshRelayArtifactManifest(
|
||||
input: unknown,
|
||||
acceptedKeys: readonly SshRelayManifestAcceptedKey[]
|
||||
): SshRelayArtifactManifest {
|
||||
const manifest = parseSshRelayArtifactManifest(input)
|
||||
const unsignedBytes = canonicalUnsignedSshRelayManifestBytes(manifest)
|
||||
const keys = acceptedKeyMap(acceptedKeys)
|
||||
let validAcceptedSignatures = 0
|
||||
|
||||
for (const signature of manifest.signatures) {
|
||||
const publicKey = keys.get(signature.keyId)
|
||||
if (!publicKey) {
|
||||
throw new Error(`Unknown signing key in SSH relay manifest: ${signature.keyId}`)
|
||||
}
|
||||
const signatureBytes = Buffer.from(signature.signature, 'base64')
|
||||
if (!nacl.sign.detached.verify(unsignedBytes, signatureBytes, publicKey)) {
|
||||
throw new Error(`Invalid SSH relay manifest signature from key: ${signature.keyId}`)
|
||||
}
|
||||
validAcceptedSignatures += 1
|
||||
}
|
||||
|
||||
if (validAcceptedSignatures === 0) {
|
||||
throw new Error('SSH relay manifest requires at least one valid accepted signature')
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
parseSshRelayReleaseTag,
|
||||
sshRelayRuntimeArchiveName,
|
||||
sshRelayRuntimeDownloadUrl
|
||||
} from './ssh-relay-release-asset'
|
||||
|
||||
const contentId = `sha256:${'a'.repeat(64)}` as const
|
||||
|
||||
describe('SSH relay release asset identity', () => {
|
||||
it.each([
|
||||
['v1.2.3', 'stable'],
|
||||
['v1.2.3-rc.4', 'rc'],
|
||||
['v1.2.3-rc.4.perf', 'perf']
|
||||
] as const)('accepts exact %s tags', (tag, channel) => {
|
||||
expect(parseSshRelayReleaseTag(tag).channel).toBe(channel)
|
||||
})
|
||||
|
||||
it.each(['latest', 'v1.2', '1.2.3', 'v1.2.3-beta.1', 'v1.2.3-rc.1.other'])(
|
||||
'rejects mutable or unsupported tag %s',
|
||||
(tag) => expect(() => parseSshRelayReleaseTag(tag)).toThrow(/release tag/i)
|
||||
)
|
||||
|
||||
it('derives a content-qualified archive name and exact direct URL', () => {
|
||||
const name = sshRelayRuntimeArchiveName('linux-x64-glibc', contentId)
|
||||
|
||||
expect(name).toBe(`orca-ssh-relay-runtime-v1-linux-x64-glibc-${'a'.repeat(64)}.tar.xz`)
|
||||
expect(sshRelayRuntimeDownloadUrl('v1.2.3-rc.4', name)).toBe(
|
||||
`https://github.com/stablyai/orca/releases/download/v1.2.3-rc.4/${name}`
|
||||
)
|
||||
expect(sshRelayRuntimeDownloadUrl('v1.2.3-rc.4', name)).not.toContain('/latest/')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { SshRelayDigest, SshRelayRuntimeTupleId } from './ssh-relay-runtime-identity'
|
||||
|
||||
const STABLE_TAG = /^v(\d+\.\d+\.\d+)$/
|
||||
const RC_TAG = /^v(\d+\.\d+\.\d+-rc\.\d+)$/
|
||||
const PERF_TAG = /^v(\d+\.\d+\.\d+-rc\.\d+\.perf)$/
|
||||
const CONTENT_ID = /^sha256:([0-9a-f]{64})$/
|
||||
|
||||
export type SshRelayReleaseIdentity = {
|
||||
tag: string
|
||||
version: string
|
||||
channel: 'stable' | 'rc' | 'perf'
|
||||
}
|
||||
|
||||
export function parseSshRelayReleaseTag(tag: string): SshRelayReleaseIdentity {
|
||||
for (const [pattern, channel] of [
|
||||
[STABLE_TAG, 'stable'],
|
||||
[RC_TAG, 'rc'],
|
||||
[PERF_TAG, 'perf']
|
||||
] as const) {
|
||||
const match = pattern.exec(tag)
|
||||
if (match) {
|
||||
return { tag, version: match[1], channel }
|
||||
}
|
||||
}
|
||||
throw new Error(`Unsupported SSH relay release tag: ${tag}`)
|
||||
}
|
||||
|
||||
export function sshRelayRuntimeArchiveName(
|
||||
tupleId: SshRelayRuntimeTupleId,
|
||||
contentId: SshRelayDigest
|
||||
): string {
|
||||
const match = CONTENT_ID.exec(contentId)
|
||||
if (!match) {
|
||||
throw new Error(`Invalid SSH relay runtime content identity: ${contentId}`)
|
||||
}
|
||||
const extension = tupleId.startsWith('win32-') ? 'zip' : 'tar.xz'
|
||||
return `orca-ssh-relay-runtime-v1-${tupleId}-${match[1]}.${extension}`
|
||||
}
|
||||
|
||||
export function sshRelayRuntimeDownloadUrl(tag: string, archiveName: string): string {
|
||||
parseSshRelayReleaseTag(tag)
|
||||
if (!/^orca-ssh-relay-runtime-v1-[A-Za-z0-9.-]+\.(?:tar\.xz|zip)$/.test(archiveName)) {
|
||||
throw new Error(`Invalid SSH relay runtime archive name: ${archiveName}`)
|
||||
}
|
||||
return `https://github.com/stablyai/orca/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(archiveName)}`
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { createSshRelayArtifactTestManifest } from './ssh-relay-artifact-test-manifest'
|
||||
import { computeSshRelayRuntimeContentId } from './ssh-relay-runtime-identity'
|
||||
|
||||
function changedEntryContentId(role: string): string {
|
||||
const tuple = structuredClone(createSshRelayArtifactTestManifest().tuples[0])
|
||||
const entry = tuple.entries.find(
|
||||
(candidate) => candidate.type === 'file' && candidate.role === role
|
||||
)
|
||||
if (!entry || entry.type !== 'file') {
|
||||
throw new Error(`test fixture missing ${role}`)
|
||||
}
|
||||
entry.sha256 = `sha256:${'f'.repeat(64)}`
|
||||
return computeSshRelayRuntimeContentId(tuple)
|
||||
}
|
||||
|
||||
describe('SSH relay runtime content identity', () => {
|
||||
it('matches a fixed canonical test vector', () => {
|
||||
const tuple = createSshRelayArtifactTestManifest().tuples[0]
|
||||
|
||||
expect(computeSshRelayRuntimeContentId(tuple)).toBe(
|
||||
'sha256:5afe9c8094ec61a5eec6f7be6d1035faacee7362871985c74cc6ee6aceea8677'
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['node', 'node-pty-native', 'parcel-watcher-native', 'relay-watcher'])(
|
||||
'changes when only %s bytes change',
|
||||
(role) => {
|
||||
const tuple = createSshRelayArtifactTestManifest().tuples[0]
|
||||
expect(changedEntryContentId(role)).not.toBe(computeSshRelayRuntimeContentId(tuple))
|
||||
}
|
||||
)
|
||||
|
||||
it('changes when an executable mode changes', () => {
|
||||
const tuple = structuredClone(createSshRelayArtifactTestManifest().tuples[0])
|
||||
const node = tuple.entries.find((entry) => entry.type === 'file' && entry.role === 'node')
|
||||
if (!node || node.type !== 'file') {
|
||||
throw new Error('test fixture missing node')
|
||||
}
|
||||
node.mode = 0o644
|
||||
|
||||
expect(computeSshRelayRuntimeContentId(tuple)).not.toBe(
|
||||
createSshRelayArtifactTestManifest().tuples[0].contentId
|
||||
)
|
||||
})
|
||||
|
||||
it('is independent of entry ordering and release metadata', () => {
|
||||
const manifest = createSshRelayArtifactTestManifest()
|
||||
const tuple = structuredClone(manifest.tuples[0])
|
||||
tuple.entries.reverse()
|
||||
tuple.archive.name = 'ignored-by-runtime-identity.tar.xz'
|
||||
tuple.archive.sha256 = `sha256:${'f'.repeat(64)}`
|
||||
tuple.metadataAssets.sbom.sha256 = `sha256:${'e'.repeat(64)}`
|
||||
tuple.nativeVerification.verifiedAt = '2030-01-01T00:00:00.000Z'
|
||||
|
||||
expect(computeSshRelayRuntimeContentId(tuple)).toBe(manifest.tuples[0].contentId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export type SshRelayDigest = `sha256:${string}`
|
||||
export type SshRelayRuntimeTupleId =
|
||||
| 'linux-x64-glibc'
|
||||
| 'linux-arm64-glibc'
|
||||
| 'linux-x64-musl'
|
||||
| 'linux-arm64-musl'
|
||||
| 'darwin-x64'
|
||||
| 'darwin-arm64'
|
||||
| 'win32-x64'
|
||||
| 'win32-arm64'
|
||||
|
||||
export type SshRelayRuntimeFileRole =
|
||||
| 'node'
|
||||
| 'relay'
|
||||
| 'relay-watcher'
|
||||
| 'node-pty-native'
|
||||
| 'parcel-watcher-native'
|
||||
| 'native-runtime'
|
||||
| 'runtime-javascript'
|
||||
| 'license'
|
||||
|
||||
export type SshRelayRuntimeEntry =
|
||||
| { path: string; type: 'directory'; mode: 0o755 }
|
||||
| {
|
||||
path: string
|
||||
type: 'file'
|
||||
role: SshRelayRuntimeFileRole
|
||||
size: number
|
||||
mode: 0o644 | 0o755
|
||||
sha256: SshRelayDigest
|
||||
}
|
||||
|
||||
export type SshRelayLinuxCompatibility = {
|
||||
kind: 'linux'
|
||||
minimumKernelVersion: string
|
||||
libc:
|
||||
| {
|
||||
family: 'glibc'
|
||||
minimumVersion: string
|
||||
minimumLibstdcxxVersion: string
|
||||
minimumGlibcxxVersion: string
|
||||
}
|
||||
| {
|
||||
family: 'musl'
|
||||
minimumVersion: string
|
||||
minimumLibstdcxxVersion: null
|
||||
minimumGlibcxxVersion: null
|
||||
}
|
||||
}
|
||||
|
||||
export type SshRelayRuntimeCompatibility =
|
||||
| SshRelayLinuxCompatibility
|
||||
| { kind: 'darwin'; minimumVersion: string }
|
||||
| {
|
||||
kind: 'windows'
|
||||
minimumBuild: number
|
||||
minimumOpenSshVersion: string
|
||||
minimumPowerShellVersion: string
|
||||
minimumDotNetFrameworkRelease: number
|
||||
}
|
||||
|
||||
export type SshRelayRuntimeIdentityInput = {
|
||||
tupleId: SshRelayRuntimeTupleId
|
||||
os: 'linux' | 'darwin' | 'win32'
|
||||
architecture: 'x64' | 'arm64'
|
||||
compatibility: SshRelayRuntimeCompatibility
|
||||
nodeVersion: string
|
||||
dependencies: {
|
||||
nodePtyVersion: string
|
||||
parcelWatcherVersion: string
|
||||
}
|
||||
entries: SshRelayRuntimeEntry[]
|
||||
}
|
||||
|
||||
function canonicalCompatibility(compatibility: SshRelayRuntimeCompatibility): object {
|
||||
if (compatibility.kind === 'linux') {
|
||||
return {
|
||||
kind: compatibility.kind,
|
||||
minimumKernelVersion: compatibility.minimumKernelVersion,
|
||||
libc: {
|
||||
family: compatibility.libc.family,
|
||||
minimumVersion: compatibility.libc.minimumVersion,
|
||||
minimumLibstdcxxVersion: compatibility.libc.minimumLibstdcxxVersion,
|
||||
minimumGlibcxxVersion: compatibility.libc.minimumGlibcxxVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
if (compatibility.kind === 'darwin') {
|
||||
return { kind: compatibility.kind, minimumVersion: compatibility.minimumVersion }
|
||||
}
|
||||
return {
|
||||
kind: compatibility.kind,
|
||||
minimumBuild: compatibility.minimumBuild,
|
||||
minimumOpenSshVersion: compatibility.minimumOpenSshVersion,
|
||||
minimumPowerShellVersion: compatibility.minimumPowerShellVersion,
|
||||
minimumDotNetFrameworkRelease: compatibility.minimumDotNetFrameworkRelease
|
||||
}
|
||||
}
|
||||
|
||||
export function canonicalSshRelayRuntimeIdentityBytes(
|
||||
runtime: SshRelayRuntimeIdentityInput
|
||||
): Buffer {
|
||||
const entries = [...runtime.entries]
|
||||
.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
|
||||
.map((entry) =>
|
||||
entry.type === 'directory'
|
||||
? { path: entry.path, type: entry.type, mode: entry.mode }
|
||||
: {
|
||||
path: entry.path,
|
||||
type: entry.type,
|
||||
role: entry.role,
|
||||
size: entry.size,
|
||||
mode: entry.mode,
|
||||
sha256: entry.sha256
|
||||
}
|
||||
)
|
||||
|
||||
// Why: runtime identity excludes archive packing, timestamps, SBOM, and signing metadata so the
|
||||
// same executable tree has one stable content address across reproducible release rebuilds.
|
||||
const projection = {
|
||||
identitySchemaVersion: 1,
|
||||
tupleId: runtime.tupleId,
|
||||
os: runtime.os,
|
||||
architecture: runtime.architecture,
|
||||
compatibility: canonicalCompatibility(runtime.compatibility),
|
||||
nodeVersion: runtime.nodeVersion,
|
||||
dependencies: {
|
||||
nodePtyVersion: runtime.dependencies.nodePtyVersion,
|
||||
parcelWatcherVersion: runtime.dependencies.parcelWatcherVersion
|
||||
},
|
||||
entries
|
||||
}
|
||||
return Buffer.from(JSON.stringify(projection), 'utf8')
|
||||
}
|
||||
|
||||
export function computeSshRelayRuntimeContentId(
|
||||
runtime: SshRelayRuntimeIdentityInput
|
||||
): SshRelayDigest {
|
||||
const hex = createHash('sha256')
|
||||
.update(canonicalSshRelayRuntimeIdentityBytes(runtime))
|
||||
.digest('hex')
|
||||
return `sha256:${hex}`
|
||||
}
|
||||
Reference in New Issue
Block a user