Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/RAF/security/05-supply-chain-gate.md

413 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Supply Chain Gate
**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.
Build status and per-step implementation tracking live in `docs/tasks/GATE-000-supply-chain-gate-plan.md`.
---
## Overview
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**.
**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 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
the unprivileged agent-side consumer. Its *role* as a runtime allow/deny RPC is retired.
### Why this model (two tests it has to pass)
**Cross-platform.** Linux eBPF and macOS ESF can pause an exec and ask a daemon "may this
proceed?" Windows WDAC cannot — it is signature-based, with no runtime callback. A
decision-daemon model therefore has no Windows mapping. A capability token maps onto all
three identically: in every case the enforcement layer only needs to answer "is this
execution authorized," and a verified token + a privileged executor that the OS trusts is
platform-agnostic. We build for the platform with the tightest constraint.
**Protects people.** Protection comes down to where the trust root lives. The signing key
lives at the server (the human-approval authority), off the host. An attacker who fully owns
the agent process — a prompt-injected coding agent, the literal threat — can *request* a
token but cannot forge the server's signature, so the executor never runs. The trust root is
outside the blast radius. A socket-RPC daemon degrades to "can the attacker reach the socket
or influence what's pinned," which a compromised agent tier often can.
The two tests converge on the same answer, which is the signal it's right.
---
## Architecture
### Enforcement Layers (Platform-Specific)
| Platform | Enforcement Mechanism | What It Blocks |
|----------|----------------------|----------------|
| **Linux** | eBPF (syscalls/sys_enter_execve) or AppArmor | apt, dnf, yum, pip, npm, bun, docker (CLI) |
| **Windows** | WDAC (Windows Defender Application Control) | winget, npm.cmd, pip.exe, choco, scoop |
| **macOS** | Endpoint Security Framework (ESF) | brew, pip, npm, bun, cargo |
**Crucial distinction:** Enforcement sits **below** userspace wrappers. A prompt-injected Claude Code session cannot bypass it with absolute paths or environment manipulation.
### Trust Chain
```
┌─────────────────────────────────────────────────────────────┐
│ Human Operator │
│ - Reviews UI prompt with OSV findings, age checks, etc. │
│ - Clicks "Approve" or "Reject" │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────────┐
│ RedFlag Server (authority, unprivileged) │
│ - Resolves the closure, records/fetches per-artifact hash │
│ - Runs OSV.dev check + package-age gates │
│ - Mints an Ed25519-signed capability token (signer off the │
│ request path) │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────────┐
│ RedFlag Agent (consumer, unprivileged) │
│ - Polls for the token, confirms agent_id is this host │
│ - Holds no signing key; cannot run installs directly │
│ - Hands the token to the executor │
└─────────────────────┬───────────────────────────────────────┘
┌─────────────────────▼───────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────┘
```
---
## The token (the contract all three sides agree on)
This is the canonical wire contract. The Go (`server/`, `agent/internal/capability/token.go`)
and Rust (`helper/src/main.rs`) implementations reconstruct identical bytes from it.
```jsonc
{
"version": 1,
"token_id": "<uuid>", // unique; replay guard + receipt/audit
"agent_id": "<uuid>", // bound to exactly one host
"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": [ // signed reported set; target is the full closure
{
"name": "<pkg>",
"version": "<exact>",
"sha256": "<hex>", // expected artifact hash
"source": "mirror|registry",
"artifact_path": "<local path or url, optional>"
}
],
"issued_at": <unix>,
"not_before": <unix>,
"expires_at": <unix>, // short TTL
"signature": "<hex ed25519>" // over the canonical message below
}
```
**Canonical signed message** (deterministic, language-agnostic — mirrors the existing v3
command format so Go and Rust reconstruct identical bytes):
```
closure_hash = hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) ))
signed_message = "{agent_id}:{token_id}:{operation}:{package_type}:{closure_hash}:{expires_at}"
signature = ed25519_sign(authority_priv, signed_message)
```
The closure is sorted before hashing so ordering can't change the digest. Tampering with any
artifact, version, or hash changes `closure_hash` and breaks verification.
### Reuse, don't reinvent
The token extends the existing Ed25519 infrastructure rather than introducing new crypto:
- `server/internal/services/signing.go``SigningService` (Ed25519, `GetPublicKeyHex`,
`GetCurrentKeyID` = SHA-256(pubkey)[:16] hex). The token is a new payload it signs.
- `server/internal/database/queries/signing_keys.go` — key storage + rotation/version.
- `agent/internal/crypto/verification.go` — verifies against active keys; v3 message format
`{agent_id}:{id}:{type}:{sha256(params)}:{ts}`. The token mirrors this.
- `agent/internal/client/client.go::GetActivePublicKeys` — already "verify keys not servers."
---
## Component responsibilities
- **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 →
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. This is defense-in-depth design;
the present eBPF scaffold is not wired to the capability model.
---
## 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
its hash. Authorizing only the top-level reopens the gap where the modern attacks live.
Resolve-and-hash-the-closure is also the mirror's real security job; air-gapping from the
registry is the bonus.
2. **The signer lives off the web process.** Server-as-authority plus server-as-mirror
concentrates blast radius. The signing key must not be reachable from the request path
(separate signer service / key material not loaded in the API process), so a web
compromise cannot both mint tokens and serve artifacts.
3. **Verified-cache fallback, fail-closed only on change.** When installs route through the
mirror, an already-approved-and-hashed artifact must still install from local cache if the
server blinks. Fail closed on *new change*, not on a brief outage of an already-authorized
operation.
4. **Verify keys, not servers.** The agent and helper trust *a public key (set)* identified by
`key_id` fingerprint — never a server URL. Today the key lives on your server; nothing
changes operationally. But the contract ("trust this key") lets the authority later be
rotated, replicated, or held by a federation/guild node without touching the agent↔helper
interface. This is the long-term cross-platform answer hidden in a one-line design choice.
5. **Kernel stops are defense-in-depth, not a prerequisite.** The capability model protects on
a host where eBPF/WDAC/ESF cannot be deployed (locked-down managed box, constrained
container). Kernel enforcement raises the cost of bypass; it does not gate whether the model
means anything. Partial deployment still moves a host out of the soft-target category.
6. **No doctrinal knobs.** Signing required and forward-only (no downgrade) are ETHOS doctrine,
not configurable. The token has no "skip verification" path.
---
## Gate Features
### 1. Version Pinning (Security Primitive)
**Normal model:** `npm install express` → resolves to `latest` → fetches from registry → installs
**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:
- **npm / PyPI** — one canonical public registry exists, so the **server** fetches the
artifact and computes the hash directly at approval (`computeAndStorePackageHash`).
- **dnf / apt** — artifacts come from each agent's own GPG-signed repos, which the server
cannot reach. The **agent** resolves the canonical hash from its signed repo metadata at the
dry-run step and reports it (`installer.ResolveArtifactSHA256`: dnf via `dnf download`+SHA256,
apt via the `SHA256:` field of the signed index). The server pins what the agent reports.
Trust is anchored in the repo signature; the pin is set before the install-time compromise
the gate defends against.
> Historical note: an earlier draft of this doc specified a `ResolvePin` / `fetchAndHash`
> function and a `security_packages` table. Neither was built. The shipped registry is the two
> stores below. This section documents what exists.
### 2. Hash Registry (Layer 1)
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. 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
at approval, not in a dedicated table.
### 3. Local Mirror (Optional)
**Purpose:** Decouple fleet from upstream availability after approval.
**Flow:**
1. Operator approves update → server fetches artifact → stores in local mirror
2. Server issues approval token with artifact path in mirror
3. Agent fetches from mirror (not upstream) → verifies SHA256 → installs
**When to enable:**
- Large fleets (>100 agents) where redundant fetches are noisy
- Upstream availability is a concern
- Maximum isolation desired
**Configuration:** `security.package_mirror.enabled` (boolean)
### 4. Time Gates: Package Age + Version Soak (live policies, v0.2.6.2)
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 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:
| Setting | Default | Meaning |
|---------|---------|---------|
| `min_package_age_hours` | 24 | Minimum publish age before approval is clean |
| `gate_enforcement` | `warn` | `warn` or `block` |
| `block_unknown_age` | `false` | Opt-in: under `block`, registry-backed ecosystems (npm, PyPI) fail closed when the publish date can't be determined — a dark recency source is itself a Shai-Hulud-class signal. Default off, by sovereignty: the operator chooses to fail closed on unknowns. |
**Install-time version-soak gate** (`soak_gate.go` — this is GATE-005, *not* the age gate):
| Setting | Default | Meaning |
|---------|---------|---------|
| `soak_window_days` | 14 | A version must soak this long before the install path will take it |
| `soak_enforcement` | `block` | `warn` or `block` |
### 5. OSV.dev Integration
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
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`).
That client retries transient failures (transport error, 5xx, 429) with exponential
backoff and trips a process-wide circuit breaker after a run of failures, fast-failing
to `unreachable` instead of hammering the endpoint (ETHOS #3, SEC-029). The verdict
semantics are unchanged and remain fail-closed: an exhausted retry or an open breaker
surfaces as `unreachable` — never a silent clear — and `unreachable` still gates the
mint behind an explicit operator override. The resilience only avoids turning a transient
OSV blip into a forced operator action.
### 6. SLSA/Sigstore Attestation (Visibility Signal)
**Not a hard block** — surfaced as a visibility indicator.
**UI:** When unpinning a package without attestation:
```
This package lacks SLSA provenance or Sigstore signature.
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 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 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
drift on what counts as a clean closure.
---
## Build sequence (design intent)
The order the gate is built in. Per-step *status* is tracked in
`docs/tasks/GATE-000-supply-chain-gate-plan.md`; this records the intended dependency order.
1. `helper/` executor + token contract (the keystone; defines the schema in code).
2. Go `capability` token type + canonical encoder + Ed25519 sign/verify (server & agent share
the definition; mirrored in both modules — no shared module exists).
3. Server: closure resolver + mint/sign at approval; signer off web process; endpoint to
deliver tokens to the agent.
4. Migration: store resolved closure + per-artifact hashes alongside the pinned state.
5. Agent consumer: accept token, bind-check, pass to executor; fold in `rs-helper` parts.
6. Mirror tier (optional): pull+hash closure at approval; verified-cache fallback.
7. Kernel adapters wire the executor as the only permitted caller.
Steps 12 are the contract. Everything else hangs off them.
---
## Cross-References
- **Build status / implementation tracking** → `docs/tasks/GATE-000-supply-chain-gate-plan.md`
- **Command signing** → `verification/01-signing-pipeline.md`
- **Agent verification** → `verification/02-agent-verification.md`
- **Replay protection** → `verification/04-replay-protection.md`
- **Trust boundaries** → `security/01-trust-boundaries.md`
---
## Footer: Assumptions & Connections
**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.
**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)
**Connection:** [security/04-machine-binding](04-machine-binding.md) (agent identity verification)
---
*Last reviewed: 2026-08-25*