Watch
1
0
Fork
You've already forked RedFlag
0

docs: bind the supply-chain claims to the helper

This commit is contained in:
Fimeg 2026-08-25 08:26:05 -04:00
commit c3037655cd
10 changed files with 194 additions and 128 deletions

View file

@ -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 12 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*