413 lines
22 KiB
Markdown
413 lines
22 KiB
Markdown
# 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 1–2 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*
|