RAF: full docs pass — components, flows, security, scanners, reference, testing, verification, overview
This commit is contained in:
parent
565baa0819
commit
a19dcf4f14
34 changed files with 5085 additions and 28 deletions
366
RAF/security/05-supply-chain-gate.md
Normal file
366
RAF/security/05-supply-chain-gate.md
Normal 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 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.
|
||||
|
||||
**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*
|
||||
Loading…
Reference in a new issue