Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/RAF/security/05-supply-chain-gate.md
Fimeg 0b1b8124b0 crypto: forward-only key-path ceiling + OSV resilience + token serialization
SEC-028 -- a rotated-out server signing key must stop being trusted even when the agent cannot phone home. pubkey.go: bounded stale-cache window on public-key fetch failure; past the window (or when cache age is unknown) it fails closed instead of trusting the cached key indefinitely. Window length is operator policy (command_signing.stale_key_max_age_hours, default 168h/7d) delivered fleet-wide via GET /agents/:id/config; the [1h, 30d] clamp and the existence of the ceiling are doctrine, not knobs. verification.go: CheckKeyRotation refuses when the named key_id is not in the server active set (no primary fallback), and applies the same bounded-stale ceiling to the active-set fetch-failure path so key_id'd commands are no weaker than keyless ones. Server carries the default + 1-720h validation; web surfaces it in Security Settings.

SEC-029 -- the standalone OSV.dev client retries transient transport/5xx/429 with exponential backoff and trips a process-wide circuit breaker after a run of failures, fast-failing to 'unreachable'. Verdict semantics unchanged and still fail-closed; the resilience only stops a transient scanner blip from forcing an operator override.

GATE-004 #4 -- Consumer.ProcessToken holds a mutex so the replay-state guards are never raced by a concurrent caller. Today's single caller (the poll loop) never overlaps; this enforces the one-token-at-a-time invariant for future callers (local-API trigger, retry worker).

RAF/verification/03 and RAF/security/05 document the key-path and OSV changes. ETHOS #3, #4; forward-only doctrine.
2026-06-14 12:57:04 -04:00

20 KiB
Raw Blame History

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.

{
  "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.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). 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.

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 — 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 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.

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