docs: bind the supply-chain claims to the helper
This commit is contained in:
parent
14d4730c76
commit
c3037655cd
10 changed files with 194 additions and 128 deletions
|
|
@ -1,6 +1,6 @@
|
|||
# Supply Chain Gate
|
||||
|
||||
**Package-manager authorization with signed capability tokens, kernel-enforced where the platform allows.**
|
||||
**Package-manager authorization with signed capability tokens; full closure custody, network isolation, and kernel enforcement remain design work.**
|
||||
|
||||
This is the design of record for the gate — the capability model, the wire contract all
|
||||
three components agree on, the load-bearing constraints, and component responsibilities.
|
||||
|
|
@ -12,18 +12,29 @@ Build status and per-step implementation tracking live in `docs/tasks/GATE-000-s
|
|||
|
||||
RedFlag's supply chain gate inverts the traditional defense model: instead of **allow all installs and detect bad ones**, it **denies all state changes and requires explicit human authorization**.
|
||||
|
||||
**Core principle:** Nothing installs, updates, or changes without a signed authorization token from the RedFlag server.
|
||||
**Current enforced scope:** APT and DNF mutation through RedFlag requires a signed
|
||||
authorization token. Docker, Winget, and Windows Update still use the default-strict
|
||||
signed-command path, and kernel enforcement against out-of-band root mutation is not wired.
|
||||
|
||||
---
|
||||
|
||||
## The decision: capability tokens, not a decision daemon
|
||||
|
||||
The gate authorizes package installs with **signed capability tokens**, not with a runtime
|
||||
decision daemon. The server is the authority: it evaluates policy (OSV, age, pinning, human
|
||||
approval) and mints an Ed25519-signed token describing exactly one operation over a
|
||||
fully-resolved dependency closure. A small, privileged, network-less **executor**
|
||||
(`helper/`, Rust) verifies the signature against a trusted authority key, verifies every
|
||||
artifact hash, and performs that one operation. Nothing else can change package state.
|
||||
The gate authorizes APT/DNF installs with **signed capability tokens**, not with a runtime
|
||||
decision daemon. The server is the authority: it evaluates policy and mints an
|
||||
Ed25519-signed token describing exactly one operation over the artifact entries the agent
|
||||
resolved and reported. The top-level hash is required. Dependency hashes are best-effort;
|
||||
an unresolved dependency is logged and omitted today rather than making the report fail.
|
||||
|
||||
A small, privileged **executor** (`helper/`, Rust) validates token version and time, host
|
||||
binding, signature, and replay state, then runs a fixed argv plan without a shell or inherited
|
||||
environment. It rehashes local artifact files and requires a readable matching file for a
|
||||
mirror entry. A normal registry entry without a local path is not rehashed helper-side, and
|
||||
the current transient unit retains host network access.
|
||||
|
||||
The intended boundary is stronger: resolve and stage the complete transitive closure,
|
||||
rehash every byte at the privileged edge, then run the executor in a network-isolated unit.
|
||||
That remains explicit design work, not a present guarantee.
|
||||
|
||||
This replaced the earlier `rs-helper` socket-decision daemon. `rs-helper`'s reusable parts
|
||||
(the eBPF `InterceptEvent` struct, the package-manager allowlist, the hash cache) moved into
|
||||
|
|
@ -86,14 +97,13 @@ The two tests converge on the same answer, which is the signal it's right.
|
|||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼───────────────────────────────────────┐
|
||||
│ Helper / Executor (privileged, network-less, Rust) │
|
||||
│ - Invoked via `sudo systemd-run --pipe` as its own │
|
||||
│ transient service (escapes the agent's ProtectSystem │
|
||||
│ sandbox); not setuid │
|
||||
│ - Verifies token signature (ed25519) + every artifact hash │
|
||||
│ - Replay-guards on token_id, then execs exactly one op │
|
||||
│ via argv: no shell, env stripped, forward-only │
|
||||
│ - Entire codebase auditable in one sitting │
|
||||
│ Helper / Executor (privileged, short-lived, Rust) │
|
||||
│ - Invoked via `sudo systemd-run --wait` with token/result │
|
||||
│ files; escapes the agent's ProtectSystem sandbox │
|
||||
│ - Verifies version/time/host/signature/replay state │
|
||||
│ - Rehashes local files; registry entries may have no path │
|
||||
│ - Executes fixed argv: no shell, env stripped │
|
||||
│ - Current unit retains host network access │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
|
|
@ -112,7 +122,7 @@ and Rust (`helper/src/main.rs`) implementations reconstruct identical bytes from
|
|||
"key_id": "<hex, 32 chars>", // authority key fingerprint (rotation)
|
||||
"package_type": "apt|dnf|npm|bun|pip|docker|winget|agent-self",
|
||||
"operation": "install|upgrade", // forward-only; no downgrade
|
||||
"closure": [ // FULL resolved set, sorted by (name,version)
|
||||
"closure": [ // signed reported set; target is the full closure
|
||||
{
|
||||
"name": "<pkg>",
|
||||
"version": "<exact>",
|
||||
|
|
@ -154,26 +164,30 @@ The token extends the existing Ed25519 infrastructure rather than introducing ne
|
|||
|
||||
## Component responsibilities
|
||||
|
||||
- **Server (authority).** At approval/unpin: run OSV + age + attestation checks, resolve the
|
||||
full closure, pull+hash artifacts (mirror tier) or record expected hashes (no-mirror tier),
|
||||
mint and sign the token. Refuse to sign until every check clears. Signer runs off the web
|
||||
process.
|
||||
- **Agent consumer (unprivileged).** Receives the token, confirms `agent_id` is this host,
|
||||
hands it to the executor. Holds no signing key. On Linux/macOS, the eBPF/ESF event path
|
||||
feeds the *request* for a token; the consumer never decides allow/deny itself. Salvages
|
||||
`rs-helper`'s `InterceptEvent`, allowlist, and cache.
|
||||
- **Server (authority).** For dnf/apt, persist the agent-reported resolved entries, run OSV
|
||||
over that set, and mint a host-bound token after the approval boundary. The top-level hash
|
||||
is required, but the current agent may omit a dependency whose hash did not resolve. Full
|
||||
transitive resolution, the mirror tier, and signer process isolation remain target work.
|
||||
- **Agent consumer (unprivileged).** Run discovery and best-effort hash resolution, receive
|
||||
the token, confirm `agent_id` is this host, and hand it to the executor. It holds no signing
|
||||
key and has no direct APT/DNF mutation method.
|
||||
- **Executor (`helper/`, privileged, Rust).** Verify validity window → resolve trusted key by
|
||||
`key_id` from a local pinned keyring → reconstruct `signed_message` → Ed25519 verify →
|
||||
verify each artifact's sha256 → replay-guard on `token_id` → exec exactly one operation via
|
||||
argv (no shell, env stripped, POSIX `--` separator before user values) → structured result
|
||||
+ exit code. Fail-closed on every error path. `request_id` validated as canonical UUID v4
|
||||
at intake (GATE-005). Auditable in one sitting.
|
||||
rehash each locally available artifact (and require mirror paths) → build a fixed argv plan
|
||||
→ replay-guard on `token_id` → exec without a shell or inherited environment → structured
|
||||
result + exit code. Registry entries without local paths are not rehashed. The current unit
|
||||
is not network-isolated.
|
||||
- **Kernel layer (where applicable).** Linux eBPF / Windows WDAC / macOS ESF deny
|
||||
package-manager execution except via the trusted executor. Defense-in-depth.
|
||||
package-manager execution except via the trusted executor. This is defense-in-depth design;
|
||||
the present eBPF scaffold is not wired to the capability model.
|
||||
|
||||
---
|
||||
|
||||
## Load-bearing constraints (do not regress these)
|
||||
## Load-bearing constraints (target invariants)
|
||||
|
||||
Current deviations are named above and below. These constraints describe the boundary the
|
||||
system is meant to reach; they must not be presented as deployed enforcement until code and
|
||||
runtime evidence support them.
|
||||
|
||||
1. **Sign the resolved closure, not the top-level package.** Aggregate updates and the
|
||||
scheduler chaining dependencies mean the token must cover every transitive artifact and
|
||||
|
|
@ -208,8 +222,12 @@ The token extends the existing Ed25519 infrastructure rather than introducing ne
|
|||
|
||||
**Normal model:** `npm install express` → resolves to `latest` → fetches from registry → installs
|
||||
|
||||
**RedFlag model:** the exact version is resolved and its SHA256 recorded at pin time; the
|
||||
artifact that installs must match that hash.
|
||||
**RedFlag model today:** the exact version is resolved and its expected SHA256 is signed into
|
||||
the capability. The helper enforces that hash when it receives a local artifact path. For a
|
||||
normal registry entry without a local path, it fixes the package name/version in argv but does
|
||||
not compare the fetched bytes with the signed hash; APT/DNF still relies on its signed
|
||||
repository metadata. Making the installed artifact itself match the capability hash in every
|
||||
case is the mirror-backed target.
|
||||
|
||||
The pin's hash source depends on who can reach the artifact:
|
||||
|
||||
|
|
@ -228,13 +246,24 @@ The pin's hash source depends on who can reach the artifact:
|
|||
|
||||
### 2. Hash Registry (Layer 1)
|
||||
|
||||
All artifacts are verified by SHA256 before installation.
|
||||
Every name, version, and hash carried in a token is covered by its Ed25519 signature. That is
|
||||
not the same as rehashing every installed byte. The helper's current verification boundary is:
|
||||
|
||||
- `source=mirror`: `artifact_path` is required; missing or mismatched bytes deny.
|
||||
- Any entry with an existing local `artifact_path`: the helper rehashes it; mismatch denies.
|
||||
- Normal `source=registry` with no local file: the hash remains signed into the token, but the
|
||||
helper does not rehash the bytes APT/DNF later fetches.
|
||||
|
||||
The top-level APT/DNF hash is mandatory before the server stores a closure. Dependency hash
|
||||
resolution is best-effort and unresolved entries can be omitted. Complete closure staging and
|
||||
helper-side verification of every byte remain the intended mirror-backed end state.
|
||||
|
||||
**Storage (as built):**
|
||||
- `current_package_state.expected_sha256` (migration 040) — the pinned top-level hash per
|
||||
update, consumed by the agent's install-time `VerifyHash`.
|
||||
- `capability_tokens` (migration 042) — the minted, signed token carries the full resolved
|
||||
closure (per-artifact name/version/sha256/source) as JSONB. The token row *is* the closure
|
||||
update. On normal registry-backed dnf/apt helper execution it is signed into authority but
|
||||
not rehashed against fetched bytes.
|
||||
- `capability_tokens` (migration 042) — the minted, signed token carries the reported resolved
|
||||
set (per-artifact name/version/sha256/source) as JSONB. The token row *is* the closure
|
||||
record; there is no separate `security_packages` table.
|
||||
|
||||
OSV findings, package age, and published-at are recorded on the update's own `metadata` JSONB
|
||||
|
|
@ -260,7 +289,8 @@ at approval, not in a dedicated table.
|
|||
|
||||
Two distinct time-based gates, both under the `supply_chain.*` settings category, both
|
||||
resolving env → config → DB → default. These are *policies* (configurable, with enforcement
|
||||
modes) — unlike signing and hash verification, which are doctrine.
|
||||
modes) — unlike capability-signature validation and required local-artifact checks, which
|
||||
have no skip setting.
|
||||
|
||||
**Approval-time age gate** (`package_age.go`) — the Shai-Hulud defense. Packages younger
|
||||
than the threshold draw a warning or a block at approval:
|
||||
|
|
@ -280,15 +310,15 @@ than the threshold draw a warning or a block at approval:
|
|||
|
||||
### 5. OSV.dev Integration
|
||||
|
||||
Vulnerability scanning runs at **detection time**, not approval time (moved in v0.2.6.2).
|
||||
The scan-report path enqueues OSV checks (batch endpoint, 100 per POST, 4 concurrent)
|
||||
across the full dependency closure; verdicts persist to package metadata
|
||||
(`supply_chain_vulns`, `supply_chain_checked_at`) and are visible in the dashboard before
|
||||
anyone approves anything.
|
||||
Top-level vulnerability scanning begins at **detection time** (moved in v0.2.6.2). After
|
||||
the dnf/apt dry-run report, `checkClosureAndAdvance` queries OSV for the resolved entries the
|
||||
agent reported; verdicts persist to package metadata (`supply_chain_vulns`,
|
||||
`supply_chain_checked_at`) and gate auto-confirm/minting. An unresolved dependency omitted
|
||||
from the report is not checked by this path.
|
||||
|
||||
Approval *reads* the persisted verdict — it does not re-scan. Any known vulnerability
|
||||
anywhere in the closure is a full stop (see Enforcement Posture below); there is no
|
||||
severity threshold below which approval proceeds quietly.
|
||||
among the reported entries checked is a full stop (see Enforcement Posture below); there is
|
||||
no severity threshold below which approval proceeds quietly.
|
||||
|
||||
**Standalone path resilience.** In standalone mode there is no server, so the agent
|
||||
queries OSV.dev directly before requesting a mint (`agent/internal/supplychain/osv.go`).
|
||||
|
|
@ -314,15 +344,17 @@ Consider waiting for an attested release before proceeding.
|
|||
|
||||
## Enforcement Posture (v0.2.3.1, extended v0.2.6.2)
|
||||
|
||||
Approval is an enforcement point, not advisory. A known vulnerability — top-level or anywhere
|
||||
in the resolved dependency closure — is a hard stop: `ApproveUpdate` returns `409` and mints
|
||||
nothing. For capability-gated ecosystems, a closure that OSV could not check (service
|
||||
unreachable) is also a stop.
|
||||
Approval is an enforcement point, not advisory. A known vulnerability in any reported entry
|
||||
that was checked — top-level or transitive — is a hard stop: `ApproveUpdate` returns `409` and
|
||||
mints nothing. For capability-gated ecosystems, a reported closure that OSV could not check
|
||||
(service unreachable) is also a stop. This does not claim coverage for a dependency omitted
|
||||
because its artifact hash did not resolve.
|
||||
|
||||
The only path through is an explicit operator override with a documented reason. The override
|
||||
waives the vulnerability judgment only — the signed token still binds real artifact hashes and
|
||||
the executor still verifies signature + hash. Every override writes a `supply_chain_override`
|
||||
system event. Bulk approve carries no blanket override: flagged updates come back in
|
||||
waives the vulnerability judgment only — the signed token still binds the reported artifact
|
||||
entries, and the executor still validates authority plus any local artifact paths. Every
|
||||
override writes a `supply_chain_override` system event. Bulk approve carries no blanket
|
||||
override: flagged updates come back in
|
||||
`blocked[]` and must be approved individually.
|
||||
|
||||
Auto-confirm shares the `ClosureCleared` predicate with manual approval — the two paths cannot
|
||||
|
|
@ -364,9 +396,13 @@ Steps 1–2 are the contract. Everything else hangs off them.
|
|||
**Assumption:** The capability model is the floor; kernel-level primitives (eBPF, WDAC, ESF)
|
||||
are defense-in-depth on top, not a prerequisite. Userspace wrappers alone are bypassable.
|
||||
|
||||
**Assumption:** The privileged executor runs network-less, with a narrow argv-only API, no
|
||||
shell, and a stripped environment; the agent that hands it tokens is unprivileged and holds no
|
||||
signing key.
|
||||
**Current:** The privileged executor has a narrow argv-only API, no shell, and a stripped
|
||||
environment; the agent that hands it tokens is unprivileged and holds no signing key. The
|
||||
transient unit still has host network access.
|
||||
|
||||
**Target:** Stage and re-verify the full artifact closure, then enforce network isolation on
|
||||
the helper unit. Neither property should be claimed until the invocation and artifact path
|
||||
make it true.
|
||||
|
||||
**Connection:** [security/01-trust-boundaries](01-trust-boundaries.md) (kernel enforcement as trust boundary)
|
||||
|
||||
|
|
@ -374,4 +410,4 @@ signing key.
|
|||
|
||||
---
|
||||
|
||||
*Last reviewed: 2026-06-14*
|
||||
*Last reviewed: 2026-08-25*
|
||||
|
|
|
|||
Loading…
Reference in a new issue