Watch
1
0
Fork
You've already forked RedFlag
0

RAF: full docs pass — components, flows, security, scanners, reference, testing, verification, overview

This commit is contained in:
Fimeg 2026-06-11 11:32:21 -04:00
commit a19dcf4f14
34 changed files with 5085 additions and 28 deletions

View file

@ -0,0 +1,366 @@
# Supply Chain Gate
**Package-manager authorization with signed capability tokens, kernel-enforced where the platform allows.**
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**.
**Core principle:** Nothing installs, updates, or changes without a signed authorization token from the RedFlag server.
---
## 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.
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, 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 │
└─────────────────────────────────────────────────────────────┘
```
---
## 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": [ // FULL resolved set, sorted by (name,version)
{
"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).** 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.
- **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) → structured result + exit code. Fail-closed on every error
path. Auditable in one sitting.
- **Kernel layer (where applicable).** Linux eBPF / Windows WDAC / macOS ESF deny
package-manager execution except via the trusted executor. Defense-in-depth.
---
## Load-bearing constraints (do not regress these)
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:** the exact version is resolved and its SHA256 recorded at pin time; the
artifact that installs must match that hash.
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)
All artifacts are verified by SHA256 before installation.
**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
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 signing and hash verification, which are doctrine.
**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
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.
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.
### 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 — 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.
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
`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.
**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.
**Connection:** [[security/01-trust-boundaries]] (kernel enforcement as trust boundary)
**Connection:** [[security/04-machine-binding]] (agent identity verification)
---
*Last reviewed: 2026-06-11*