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:
parent
2b55410323
commit
d74498a19d
3 changed files with 263 additions and 9 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
249
RAF/SUPPLY_CHAIN_GATE_PLAN.md
Normal file
249
RAF/SUPPLY_CHAIN_GATE_PLAN.md
Normal 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 1–2 are the contract. Everything else hangs off them.
|
||||
|
||||
## Build status — 2026-05-30
|
||||
|
||||
Steps 1–5 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 6–7
|
||||
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
|
||||
(10–20). 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 6–7)
|
||||
|
||||
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.
|
||||
18
README.md
18
README.md
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue