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

22 KiB
Raw Blame History

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.

{
  "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.goSigningService (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 trackingdocs/tasks/GATE-000-supply-chain-gate-plan.md
  • Command signingverification/01-signing-pipeline.md
  • Agent verificationverification/02-agent-verification.md
  • Replay protectionverification/04-replay-protection.md
  • Trust boundariessecurity/01-trust-boundaries.md

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 (kernel enforcement as trust boundary)

Connection: security/04-machine-binding (agent identity verification)


Last reviewed: 2026-08-25