Watch
1
0
Fork
You've already forked RedFlag
0

publish supply-chain gate plan, honest status section in README

RAF/SUPPLY_CHAIN_GATE_PLAN.md unblocked — the architectural thesis for the
capability-token model. Updated to reflect agent-self upgrade path, OSV
expansion, and current verification state.

README Status section rewritten: "implemented and locally exercised, not
production-proven" replaces the misleading "working in production" header.
Honest gaps listed (GATE-002, CRITICAL-004).
This commit is contained in:
Fimeg 2026-05-30 13:25:12 -04:00
commit d74498a19d
3 changed files with 263 additions and 9 deletions

3
.gitignore vendored
View file

@ -20,6 +20,7 @@ TEST-CLONE.md
!OPERATIONS.md
!AUDIT_TASKS.md
!THIRD_PARTY_LICENSES.md
!RAF/SUPPLY_CHAIN_GATE_PLAN.md
!LICENSE
!NOTICE
!.env.example
@ -454,7 +455,9 @@ TEST-CLONE.md
!README.md
!CHANGELOG.md
!OPERATIONS.md
!AUDIT_TASKS.md
!THIRD_PARTY_LICENSES.md
!RAF/SUPPLY_CHAIN_GATE_PLAN.md
!LICENSE
!.env.example
!docs/API.md

View file

@ -0,0 +1,249 @@
# Supply Chain Gate — Capability Model (Plan & Framing)
Status: active design, supersedes the helper/enforcement portions of `PRIORITY_START_HERE.md` P0.
Decision date: 2026-05-28.
## The decision
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 replaces the `rs-helper` socket-decision daemon. `rs-helper`'s reusable parts
(the eBPF `InterceptEvent` struct, the package-manager allowlist, the hash cache) move
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.
## 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.
## Reuse, don't reinvent
The Ed25519 infrastructure already exists and the token extends it:
- `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."
## The token (the contract all three sides agree on)
```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.
## 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.
## Build sequence
1. `helper/` executor + token contract (this is the keystone; defines the schema in code).
2. Go `capability` token type + canonical encoder + Ed25519 sign/verify (server & agent
share the definition; mirror it 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.
## Build status — 2026-05-30
Steps 15 are implemented and compile clean across all three components (Rust
executor, Go server module, Go agent module). The cross-language contract is
proven, not assumed: a fixed vector produces byte-identical `closure_hash` and
canonical message in both the Rust unit test and the Go test
(`49a181cd7b6df83a6dc83c7f647f2d224effe90907dd5dcdfdaf4c8697af595f`). Steps 67
remain, plus two honest gaps in the implemented slice (below).
Since the initial implementation (2026-05-28), the executor also handles
`agent-self` package type (agent binary upgrade): the server mints an
`agent-self` token over the new binary's hash, the agent stages the binary, and
the helper verifies the hash, backs up the live binary, installs, and restarts
the agent via `systemctl restart --no-block`. The agent holds zero sudo for
self-upgrade. OSV.dev checks expanded to cover apt and dnf ecosystems (mapped to
Debian and AlmaLinux), queried async at discovery time with a startup backfill
for unchecked packages.
### Implemented
**Step 1 — executor (keystone).** `helper/src/main.rs` (+ `helper/Cargo.toml`,
deps: ed25519-dalek, serde, serde_json, sha2, hex — no thiserror, hand-rolled
`Denial`). Reads token from stdin → version check → validity window →
independent host `agent_id` (env `REDFLAG_AGENT_ID` or `/etc/redflag/agent_id` /
`/var/lib/redflag/agent_id`) bind-check → keyring load (`REDFLAG_HELPER_KEYRING`
or `/etc/redflag/trusted-keys`, `*.pub` hex files, resolved by `key_id`) →
reconstruct canonical message → `verify_strict` (uses `Verifier::verify`) →
artifact hash verification → replay guard recorded **before** exec
(`REDFLAG_HELPER_STATE` or `/var/lib/redflag/helper/consumed-tokens`) → exec one
op, no shell, `env_clear` + fixed PATH, forward-only. Structured `PolicyResult`
to stdout, ETHOS `[TAG] [helper] [executor]` to stderr, exit-code deny taxonomy
(1020). Every error path fails closed.
**Step 2 — Go capability package.** `server/internal/capability/token.go` and
`agent/internal/capability/token.go` (identical content, separate modules).
`Token`/`ClosureEntry` with serde-matching JSON tags, `ClosureHash()`
(sorted+deduped BTreeSet-equivalent), `CanonicalMessage()`, `Sign`/`Verify`,
`KeyIDFor`. Contract test `token_test.go` in both modules (vector + order
independence + sign/verify/tamper).
**Step 3 — server minting + delivery.**
- `services/signing.go`: `SignCapabilityToken` (key stays inside SigningService).
- `services/capability_minter.go`: `CapabilityMinter` — builds, signs, persists;
`Enabled()` gate; default 1h TTL; mints `(nil,nil)` when signing disabled.
- `database/queries/capability_tokens.go`: insert / get / list-undelivered /
mark-delivered / mark-consumed / is-consumed.
- `api/handlers/updates.go`: mints in `ApproveUpdate` after the Layer-1 hash step
(best-effort, logs `[SECURITY]` on failure, does not roll back a cleared
approval); `GetCapabilityTokens` delivery endpoint; `ReportCapabilityResult`
receipt endpoint (agent-bound).
- `cmd/server/main.go`: minter wired when signing enabled; routes
`GET /agents/:id/capability-tokens` and
`POST /agents/:id/capability-tokens/:token_id/receipt`.
**Step 4 — migration.** `migrations/042_create_capability_tokens.{up,down}.sql`
`capability_tokens` table, closure as JSONB, idempotent `IF NOT EXISTS`,
auto-discovered by the ReadDir+sort runner.
**Step 5 — agent consumer.** `agent/internal/supplychain/consumer.go`:
`Executor` (pipes token to helper stdin, parses `PolicyResult`, forwards stderr
to agent log), `Consumer` (bind-check + salvaged `AllowedPackageManagers`
allowlist + invoke + receipt), `PolicyResult` mirror of the Rust output.
`client.GetCapabilityTokens` / `client.ReportCapabilityResult`. Wired into
`agent/internal/agent/loop.go::processCapabilityTokens`, polled each check-in;
executor path from `REDFLAG_HELPER_BIN` (default `/usr/local/bin/redflag-helper`).
### Honest gaps in the implemented slice
1. **Closure transitivity is split by platform.**
- **npm/PyPI (server-fetched):** still single-entry (top-level + Layer-1 hash,
`source:"registry"`), minted at `ApproveUpdate`. The resolver that expands
transitive deps server-side is not built; closure assembly is isolated to one
place in `ApproveUpdate` so expansion lands there.
- **dnf/apt (agent-reported, 2026-05-28):** the dry-run resolves the dependency
closure and `ReportDependencies` mints over the **full reported set** (top-level
+ each resolvable dependency, per-artifact hash from signed metadata). Transitive
for the Linux path. A dependency whose hash cannot be resolved is logged and
omitted (no partial-guess pin) — so a closure can still under-cover if a dep is
unresolvable; that is the remaining edge, not the common case.
2. **Signer runs in-process, not off-web-process (constraint #2).** The key stays
encapsulated in `SigningService` and the minter is the only caller, so this is
the documented seam — but true process isolation is not yet built.
### Remaining (steps 67)
6. Mirror tier: pull+hash the full closure at approval (this is what makes the
closure transitive and flips entries to `source:"mirror"` with real
`artifact_path`s the executor verifies); verified-cache fail-closed-on-change.
7. Kernel adapters (eBPF / WDAC / ESF) wire the executor as the only permitted
package-manager caller. Defense-in-depth, not a prerequisite.
### Verification done / not done
Done: all three components compile; `go vet` clean on new packages; Rust + Go
contract tests pass proving byte-identical canonical encoding; gofmt-clean on new
files; agent dry-run exercised on live Fedora agent (dnf closure resolved,
1-entry closure minted); helper replay protection observed firing on the live
stack. **Not done:** no full end-to-end install through the privileged executor
against live infrastructure (GATE-002). The runtime path is wired and partially
exercised but the final step — helper actually running `dnf install` with a real
token on a real host — has not happened.

View file

@ -141,7 +141,9 @@ Before a package is installed: the agent fetches the expected SHA-256 from the s
## Status
**Working in production:**
**Compiles, runs on the maintainer's stack, not yet battle-tested.** No live deployment outside the dev environment. The gate (supply-chain verification) has not completed a full end-to-end run against real infrastructure. Treat everything below as "implemented and locally exercised, not production-proven."
**Implemented:**
- Linux and Windows agent registration and update management
- APT, DNF, Winget, Windows Update, Docker image scanning
- Dry-run dependency checking
@ -150,14 +152,14 @@ Before a package is installed: the agent fetches the expected SHA-256 from the s
- Supply chain hash verification and OSV.dev checks
- Maintenance windows
- Upstream version tracking (GitHub, Gitea, GitLab, Bitbucket, Repology, endoflife.date)
- Agent self-update with rollback
- Agent self-update via privileged helper (zero agent sudo)
**Known issues:**
- Winget detection occasionally misses packages (Windows API limitation)
- Some Windows Updates reappear after installation (Windows Update quirk, not ours)
- No AUR, Snap, Flatpak, or Homebrew support yet
- macOS agent binaries not yet signed
- Mobile dashboard is usable, not optimized
**Not yet done:**
- Live end-to-end gate test (GATE-002)
- Windows installer stubs report fake success (CRITICAL-004)
- No AUR, Snap, Flatpak, or Homebrew support
- macOS agent binaries not signed
- Mobile dashboard usable, not optimized
---