Watch
1
0
Fork
You've already forked RedFlag
0

feat: supply-chain capability-token gate (executor, signing, mint/deliver, agent consumer)

Server mints Ed25519-signed capability tokens authorizing one package
operation over a resolved closure; a privileged network-less Rust executor
verifies signature + artifact hashes and performs the op. Replaces the
rs-helper socket-decision daemon's role with signed tokens (trust root off-host).

- helper/: Rust executor keystone (stdin token -> window/bind/keyring/verify_strict
  -> artifact hash -> replay guard -> one op, no shell, env_clear, fail-closed)
- capability/ (mirrored in server + agent): token type, canonical encoder,
  sign/verify; cross-language byte-identity proven by contract tests
- server: SignCapabilityToken, CapabilityMinter (mint at approval after Layer-1
  hash), capability_tokens queries, migration 042, delivery + receipt endpoints
- agent: supplychain consumer (bind-check + allowlist + invoke executor + receipt),
  client methods, polled each check-in via loop.processCapabilityTokens
This commit is contained in:
Fimeg 2026-05-28 14:49:13 -04:00
commit ea068c2213
16 changed files with 1786 additions and 81 deletions

View file

@ -23,6 +23,7 @@ import (
"github.com/Fimeg/RedFlag/agent/internal/recovery"
"github.com/Fimeg/RedFlag/agent/internal/scanner"
"github.com/Fimeg/RedFlag/agent/internal/startup"
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
"github.com/Fimeg/RedFlag/agent/internal/system"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
@ -344,6 +345,12 @@ func RunPollingLoop(loopCtx *LoopContext) error {
processCommands(ctx, response.Commands)
}
// Supply Chain Gate — pull and execute any signed capability tokens the
// server minted for this host. Independent of the command path: tokens
// authorize package operations directly, verified by the privileged
// executor. A gate that is not enabled server-side returns no tokens.
processCapabilityTokens(ctx)
// Sleep until next poll (or stop signal)
if ctx.StopCh != nil {
select {
@ -358,6 +365,25 @@ func RunPollingLoop(loopCtx *LoopContext) error {
}
}
// processCapabilityTokens fetches and processes this host's capability tokens.
// Best-effort per poll: errors are logged and the loop continues. The executor
// binary path is taken from REDFLAG_HELPER_BIN, defaulting inside the consumer.
func processCapabilityTokens(ctx *LoopContext) {
tokens, err := ctx.APIClient.GetCapabilityTokens(ctx.Cfg.AgentID)
if err != nil {
log.Printf("[WARNING] [agent] [supplychain] token_fetch_failed error=%v", err)
return
}
if len(tokens) == 0 {
return
}
log.Printf("[INFO] [agent] [supplychain] tokens_received count=%d", len(tokens))
executor := supplychain.NewExecutor(os.Getenv("REDFLAG_HELPER_BIN"))
consumer := supplychain.NewConsumer(ctx.Cfg.AgentID, executor, ctx.APIClient)
consumer.ProcessTokens(ctx.Ctx, tokens)
}
// collectMetrics collects system metrics for the check-in
func collectMetrics(cfg *config.Config) *client.SystemMetrics {
sysMetrics, err := system.GetLightweightMetrics()
@ -384,8 +410,8 @@ func collectMetrics(cfg *config.Config) *client.SystemMetrics {
if cfg.RapidPollingEnabled && time.Now().Before(cfg.RapidPollingUntil) {
metrics.Metadata = map[string]interface{}{
"rapid_polling_enabled": true,
"rapid_polling_until": cfg.RapidPollingUntil.Format(time.RFC3339),
"rapid_polling_enabled": true,
"rapid_polling_until": cfg.RapidPollingUntil.Format(time.RFC3339),
"rapid_polling_duration_minutes": int(time.Until(cfg.RapidPollingUntil).Minutes()),
}
}

View file

@ -0,0 +1,117 @@
// Package capability defines the supply-chain capability token: an Ed25519-signed
// authorization for exactly one package operation over a fully-resolved dependency
// closure. The server (authority) mints and signs tokens; the agent passes them to
// the privileged Rust executor (helper/) which independently verifies them.
//
// The canonical signed message and closure hash MUST stay byte-identical across
// this package, the server's mirror of it, and helper/src/main.rs. See
// RAF/SUPPLY_CHAIN_GATE_PLAN.md for the contract.
package capability
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
)
// Version is the only token format this code understands. Forward-only doctrine:
// new versions add fields, never reinterpret existing ones.
const Version = 1
// ClosureEntry is one resolved artifact in the dependency closure.
type ClosureEntry struct {
Name string `json:"name"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
Source string `json:"source"` // "mirror" | "registry"
ArtifactPath string `json:"artifact_path,omitempty"` // local path or url, optional
}
// Token is the full capability token exchanged between server, agent, and executor.
type Token struct {
Version int `json:"version"`
TokenID string `json:"token_id"`
AgentID string `json:"agent_id"`
KeyID string `json:"key_id"`
PackageType string `json:"package_type"` // apt|dnf|npm|bun|pip|docker|winget
Operation string `json:"operation"` // install|upgrade (forward-only)
Closure []ClosureEntry `json:"closure"`
IssuedAt int64 `json:"issued_at"`
NotBefore int64 `json:"not_before"`
ExpiresAt int64 `json:"expires_at"`
Signature string `json:"signature"` // hex ed25519
}
// KeyIDFor returns the authority key fingerprint: hex(sha256(pubkey)[:16]).
// Matches SigningService.GetPublicKeyFingerprint and the executor's key_id_for.
func KeyIDFor(pub ed25519.PublicKey) string {
hash := sha256.Sum256(pub)
return hex.EncodeToString(hash[:16])
}
// ClosureHash computes hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) )).
// Lines are sorted and de-duplicated so neither array order nor exact duplicates
// can change the digest. This mirrors the Rust BTreeSet construction exactly.
func (t *Token) ClosureHash() string {
set := make(map[string]struct{}, len(t.Closure))
for _, e := range t.Closure {
set[fmt.Sprintf("%s@%s#%s", e.Name, e.Version, e.SHA256)] = struct{}{}
}
lines := make([]string, 0, len(set))
for line := range set {
lines = append(lines, line)
}
sort.Strings(lines)
h := sha256.New()
for i, line := range lines {
if i > 0 {
h.Write([]byte("\n"))
}
h.Write([]byte(line))
}
return hex.EncodeToString(h.Sum(nil))
}
// CanonicalMessage builds the deterministic message that is signed/verified:
// "{agent_id}:{token_id}:{operation}:{package_type}:{closure_hash}:{expires_at}".
func (t *Token) CanonicalMessage() string {
return fmt.Sprintf("%s:%s:%s:%s:%s:%d",
t.AgentID, t.TokenID, t.Operation, t.PackageType, t.ClosureHash(), t.ExpiresAt)
}
// Sign signs the canonical message with the authority private key, sets the
// token's KeyID and Signature, and returns the hex signature.
func (t *Token) Sign(priv ed25519.PrivateKey) (string, error) {
if len(priv) != ed25519.PrivateKeySize {
return "", fmt.Errorf("capability: invalid private key size %d", len(priv))
}
pub := priv.Public().(ed25519.PublicKey)
t.KeyID = KeyIDFor(pub)
sig := ed25519.Sign(priv, []byte(t.CanonicalMessage()))
t.Signature = hex.EncodeToString(sig)
return t.Signature, nil
}
// Verify checks the token's signature against the given public key. It does not
// check the validity window, agent binding, or artifact hashes — those are the
// executor's responsibility (and the agent's bind-check). It verifies only that
// this key signed this canonical message.
func (t *Token) Verify(pub ed25519.PublicKey) error {
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("capability: invalid public key size %d", len(pub))
}
sig, err := hex.DecodeString(t.Signature)
if err != nil {
return fmt.Errorf("capability: signature not hex: %w", err)
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("capability: invalid signature size %d", len(sig))
}
if !ed25519.Verify(pub, []byte(t.CanonicalMessage()), sig) {
return fmt.Errorf("capability: signature verification failed")
}
return nil
}

View file

@ -0,0 +1,63 @@
package capability
import (
"crypto/ed25519"
"testing"
)
// Cross-language contract vector. The Rust executor (helper/src/main.rs) asserts
// these same strings for the same input. If either side drifts, both break.
func TestCanonicalVector(t *testing.T) {
tok := &Token{
Version: 1, TokenID: "tok-1", AgentID: "agent-123",
PackageType: "npm", Operation: "install",
Closure: []ClosureEntry{
{Name: "left-pad", Version: "1.3.0", SHA256: "aaaa"},
{Name: "is-odd", Version: "2.0.0", SHA256: "bbbb"},
},
ExpiresAt: 1700000000,
}
const wantHash = "49a181cd7b6df83a6dc83c7f647f2d224effe90907dd5dcdfdaf4c8697af595f"
const wantMsg = "agent-123:tok-1:install:npm:" + wantHash + ":1700000000"
if got := tok.ClosureHash(); got != wantHash {
t.Fatalf("ClosureHash() = %q, want %q", got, wantHash)
}
if got := tok.CanonicalMessage(); got != wantMsg {
t.Fatalf("CanonicalMessage() = %q, want %q", got, wantMsg)
}
}
func TestClosureHashOrderIndependent(t *testing.T) {
a := &Token{Closure: []ClosureEntry{{Name: "a", Version: "1", SHA256: "x"}, {Name: "b", Version: "2", SHA256: "y"}}}
b := &Token{Closure: []ClosureEntry{{Name: "b", Version: "2", SHA256: "y"}, {Name: "a", Version: "1", SHA256: "x"}}}
if a.ClosureHash() != b.ClosureHash() {
t.Fatalf("closure hash depends on order: %q != %q", a.ClosureHash(), b.ClosureHash())
}
}
func TestSignVerifyRoundtrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
tok := &Token{
Version: 1, TokenID: "tok-2", AgentID: "agent-9",
PackageType: "dnf", Operation: "upgrade",
Closure: []ClosureEntry{{Name: "openssl", Version: "3.2.1", SHA256: "deadbeef"}},
ExpiresAt: 1700000000,
}
if _, err := tok.Sign(priv); err != nil {
t.Fatal(err)
}
if tok.KeyID != KeyIDFor(pub) {
t.Fatalf("KeyID = %q, want %q", tok.KeyID, KeyIDFor(pub))
}
if err := tok.Verify(pub); err != nil {
t.Fatalf("Verify after Sign failed: %v", err)
}
// Tamper detection: any change to the closure breaks verification.
tok.Closure[0].Version = "3.2.2"
if err := tok.Verify(pub); err == nil {
t.Fatal("Verify accepted a tampered closure")
}
}

View file

@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/event"
"github.com/Fimeg/RedFlag/agent/internal/models"
"github.com/Fimeg/RedFlag/agent/internal/system"
@ -448,10 +449,10 @@ type Command struct {
ID string `json:"id"`
Type string `json:"type"`
Params map[string]interface{} `json:"params"`
Signature string `json:"signature,omitempty"` // Ed25519 signature of the command
KeyID string `json:"key_id,omitempty"` // Fingerprint of the signing key used
SignedAt *time.Time `json:"signed_at,omitempty"` // Timestamp when command was signed
AgentID string `json:"agent_id,omitempty"` // Target agent ID (F-1 fix: included in signed payload)
Signature string `json:"signature,omitempty"` // Ed25519 signature of the command
KeyID string `json:"key_id,omitempty"` // Fingerprint of the signing key used
SignedAt *time.Time `json:"signed_at,omitempty"` // Timestamp when command was signed
AgentID string `json:"agent_id,omitempty"` // Target agent ID (F-1 fix: included in signed payload)
CreatedAt *time.Time `json:"created_at,omitempty"` // Server-side creation time (F-3 fix: old-format expiry)
}
@ -559,6 +560,83 @@ func (c *Client) GetCommands(agentID uuid.UUID, metrics *SystemMetrics) (*Comman
return &result, nil
}
// CapabilityTokensResponse is the server's reply for the capability-token feed.
type CapabilityTokensResponse struct {
Tokens []*capability.Token `json:"tokens"`
}
// GetCapabilityTokens fetches this agent's minted-but-undelivered capability
// tokens. The agent must bind-check each token's agent_id and hand it to the
// privileged executor, which verifies signature and artifact hashes before
// acting. The agent holds no signing key and makes no allow/deny decision.
func (c *Client) GetCapabilityTokens(agentID uuid.UUID) ([]*capability.Token, error) {
url := fmt.Sprintf("%s/api/v1/agents/%s/capability-tokens", c.baseURL, agentID)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+c.token)
c.addMachineIDHeader(req)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusServiceUnavailable {
// Gate not enabled server-side; not an error for the agent.
return nil, nil
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to get capability tokens: %s - %s", resp.Status, string(bodyBytes))
}
var result CapabilityTokensResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Tokens, nil
}
// ReportCapabilityResult posts an audit receipt for a processed capability token.
// Best-effort: the executor's local replay guard is authoritative on single use,
// so a failed receipt does not change install correctness.
func (c *Client) ReportCapabilityResult(agentID uuid.UUID, tokenID string, decision, reason string, exitCode int) error {
url := fmt.Sprintf("%s/api/v1/agents/%s/capability-tokens/%s/receipt", c.baseURL, agentID, tokenID)
body, err := json.Marshal(map[string]interface{}{
"decision": decision,
"reason": reason,
"exit_code": exitCode,
})
if err != nil {
return err
}
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.token)
c.addMachineIDHeader(req)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("failed to report capability result: %s - %s", resp.Status, string(bodyBytes))
}
return nil
}
// UpdateReport represents discovered updates
type UpdateReport struct {
CommandID string `json:"command_id"`
@ -916,8 +994,8 @@ func (c *Client) ReportSystemInfo(agentID uuid.UUID, report SystemInfoReport) er
// CircuitBreakerReport represents circuit breaker health status
// [ISSUE-004] Added for circuit breaker monitoring and alerting
type CircuitBreakerReport struct {
Timestamp time.Time `json:"timestamp"`
Subsystems []CircuitBreakerStatus `json:"subsystems"`
Timestamp time.Time `json:"timestamp"`
Subsystems []CircuitBreakerStatus `json:"subsystems"`
}
// CircuitBreakerStatus represents the state of a single circuit breaker

View file

@ -0,0 +1,178 @@
// Package supplychain is the unprivileged agent-side consumer of capability
// tokens. It fetches tokens minted by the server authority, confirms each is
// bound to this host, and hands it to the privileged executor (helper/) which
// independently verifies the signature and artifact hashes before performing the
// one authorized operation. The consumer holds no signing key and makes no
// allow/deny decision of its own — that authority lives in the token and the
// executor.
//
// This retires the rs-helper socket-decision daemon: its package-manager
// allowlist is salvaged here as defense-in-depth, but its runtime allow/deny RPC
// role is gone — authorization is the signed token.
package supplychain
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os/exec"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/google/uuid"
)
// DefaultExecutorPath is where the privileged executor binary is expected. The
// agent process need not be privileged; the executor is the privileged boundary.
const DefaultExecutorPath = "/usr/local/bin/redflag-helper"
// executorTimeout bounds a single executor invocation. Package operations can be
// slow; this is a guard against a hung child, not a tight latency target.
const executorTimeout = 30 * time.Minute
// AllowedPackageManagers is salvaged from rs-helper. Defense-in-depth only: the
// signed token is the authorization. A token for a package_type outside this set
// is refused before the executor is ever invoked.
var AllowedPackageManagers = map[string]bool{
"apt": true,
"dnf": true,
"npm": true,
"bun": true,
"pip": true,
"docker": true,
"winget": true,
}
// PolicyResult mirrors the executor's stdout contract (helper/src/main.rs). Field
// names must stay aligned with the Rust PolicyResult serialization.
type PolicyResult struct {
TokenID string `json:"token_id"`
AgentID string `json:"agent_id"`
PackageType string `json:"package_type"`
Operation string `json:"operation"`
Decision string `json:"decision"` // executed | denied | failed
Reason string `json:"reason"`
Executed bool `json:"executed"`
VerifiedArtifacts int `json:"verified_artifacts"`
ExitCode int `json:"exit_code"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// Executor invokes the privileged helper binary, piping a token to its stdin and
// parsing the structured result from stdout.
type Executor struct {
BinaryPath string
}
// NewExecutor returns an Executor for the given binary path, defaulting when empty.
func NewExecutor(binaryPath string) *Executor {
if binaryPath == "" {
binaryPath = DefaultExecutorPath
}
return &Executor{BinaryPath: binaryPath}
}
// Execute runs the executor for one token. It returns the parsed PolicyResult.
// A non-zero executor exit is reflected in result.ExitCode/Decision, not as a Go
// error; a Go error is returned only when the executor could not be run or its
// output could not be parsed.
func (e *Executor) Execute(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
payload, err := json.Marshal(token)
if err != nil {
return nil, fmt.Errorf("marshal token: %w", err)
}
runCtx, cancel := context.WithTimeout(ctx, executorTimeout)
defer cancel()
cmd := exec.CommandContext(runCtx, e.BinaryPath)
cmd.Stdin = bytes.NewReader(payload)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
// Forward the executor's structured stderr logs to the agent log stream.
if stderr.Len() > 0 {
for _, line := range bytes.Split(bytes.TrimRight(stderr.Bytes(), "\n"), []byte("\n")) {
log.Printf("[INFO] [agent] [supplychain] executor_log %s", string(line))
}
}
if stdout.Len() == 0 {
if runErr != nil {
return nil, fmt.Errorf("executor produced no result: %w", runErr)
}
return nil, fmt.Errorf("executor produced no result")
}
var result PolicyResult
if err := json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &result); err != nil {
return nil, fmt.Errorf("parse executor result: %w", err)
}
return &result, nil
}
// Reporter delivers a token result back to the server for audit (optional).
type Reporter interface {
ReportCapabilityResult(agentID uuid.UUID, tokenID string, decision, reason string, exitCode int) error
}
// Consumer ties together fetching, bind-checking, executing, and reporting.
type Consumer struct {
agentID uuid.UUID
executor *Executor
reporter Reporter // optional
}
// NewConsumer builds a consumer bound to this host's agent identity.
func NewConsumer(agentID uuid.UUID, executor *Executor, reporter Reporter) *Consumer {
return &Consumer{agentID: agentID, executor: executor, reporter: reporter}
}
// ProcessToken runs the full path for one token. It refuses, before invoking the
// executor, any token not bound to this host or for an unsupported package type;
// the executor enforces the same checks again as the privileged authority.
func (c *Consumer) ProcessToken(ctx context.Context, token *capability.Token) (*PolicyResult, error) {
if token.AgentID != c.agentID.String() {
log.Printf("[SECURITY] [agent] [supplychain] bind_check_failed token_id=%s token_agent_id=%s host_agent_id=%s",
token.TokenID, token.AgentID, c.agentID)
return nil, fmt.Errorf("token not bound to this host")
}
if !AllowedPackageManagers[token.PackageType] {
log.Printf("[SECURITY] [agent] [supplychain] package_type_refused token_id=%s package_type=%s",
token.TokenID, token.PackageType)
return nil, fmt.Errorf("package_type %q not in allowlist", token.PackageType)
}
result, err := c.executor.Execute(ctx, token)
if err != nil {
log.Printf("[ERROR] [agent] [supplychain] executor_invoke_failed token_id=%s error=%v", token.TokenID, err)
return nil, err
}
log.Printf("[SECURITY] [agent] [supplychain] token_processed token_id=%s decision=%s reason=%s exit=%d verified_artifacts=%d",
token.TokenID, result.Decision, result.Reason, result.ExitCode, result.VerifiedArtifacts)
if c.reporter != nil {
if err := c.reporter.ReportCapabilityResult(c.agentID, token.TokenID, result.Decision, result.Reason, result.ExitCode); err != nil {
log.Printf("[WARNING] [agent] [supplychain] result_report_failed token_id=%s error=%v", token.TokenID, err)
}
}
return result, nil
}
// ProcessTokens runs ProcessToken for each token, continuing past individual
// failures so one bad token does not strand the rest.
func (c *Consumer) ProcessTokens(ctx context.Context, tokens []*capability.Token) {
for _, token := range tokens {
if _, err := c.ProcessToken(ctx, token); err != nil {
log.Printf("[WARNING] [agent] [supplychain] token_skipped token_id=%s error=%v", token.TokenID, err)
}
}
}

21
helper/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "redflag-helper"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
ed25519-dalek = "2.1.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sha2 = "0.10"
hex = "0.4"
[[bin]]
name = "redflag-helper"
path = "src/main.rs"
[profile.release]
opt-level = 3
lto = true
strip = true

630
helper/src/main.rs Normal file
View file

@ -0,0 +1,630 @@
// redflag-helper — capability-token executor (keystone).
//
// Reads one Ed25519-signed capability token from stdin, verifies it against a
// locally pinned trusted keyring, verifies every artifact hash it can reach on
// disk, guards against replay, then performs exactly one package operation over
// the signed dependency closure. No shell, no inherited environment, fail-closed
// on every error path. The signing authority lives off this host; this process
// only verifies and executes.
//
// Contract: see RAF/SUPPLY_CHAIN_GATE_PLAN.md. The canonical signed message and
// closure hash here MUST stay byte-identical to the Go signer/verifier.
use std::collections::BTreeSet;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
const SUPPORTED_TOKEN_VERSION: u32 = 1;
// Exit codes double as deny taxonomy. 0 = the one operation ran and exited 0.
const EXIT_OK: i32 = 0;
const EXIT_BAD_TOKEN: i32 = 10;
const EXIT_VERSION: i32 = 11;
const EXIT_TIME_WINDOW: i32 = 12;
const EXIT_AGENT_MISMATCH: i32 = 13;
const EXIT_KEY_NOT_FOUND: i32 = 14;
const EXIT_SIGNATURE: i32 = 15;
const EXIT_ARTIFACT: i32 = 16;
const EXIT_REPLAY: i32 = 17;
const EXIT_UNSUPPORTED_OP: i32 = 18;
const EXIT_EXEC_FAILED: i32 = 19;
const EXIT_INTERNAL: i32 = 20;
// Default on-host locations. All overridable by env so packaging/tests can relocate.
const DEFAULT_KEYRING_DIR: &str = "/etc/redflag/trusted-keys";
const DEFAULT_STATE_FILE: &str = "/var/lib/redflag/helper/consumed-tokens";
const AGENT_ID_FILES: &[&str] = &["/etc/redflag/agent_id", "/var/lib/redflag/agent_id"];
#[derive(Debug, Deserialize)]
struct ClosureEntry {
name: String,
version: String,
sha256: String,
#[serde(default)]
source: String, // "mirror" | "registry"
#[serde(default)]
artifact_path: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CapabilityToken {
version: u32,
token_id: String,
agent_id: String,
key_id: String,
package_type: String,
operation: String,
closure: Vec<ClosureEntry>,
#[allow(dead_code)]
issued_at: i64,
not_before: i64,
expires_at: i64,
signature: String, // hex ed25519
}
#[derive(Debug, Serialize)]
struct PolicyResult {
token_id: String,
agent_id: String,
package_type: String,
operation: String,
decision: String, // "executed" | "denied" | "failed"
reason: String,
executed: bool,
verified_artifacts: usize,
exit_code: i32,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
timestamp: i64,
}
// A deny/fail with its taxonomy code. Carries enough to emit a structured result.
struct Denial {
code: i32,
reason: &'static str,
detail: String,
}
impl Denial {
fn new(code: i32, reason: &'static str, detail: impl Into<String>) -> Self {
Denial { code, reason, detail: detail.into() }
}
}
fn now_unix() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
// ETHOS structured logging: [TAG] [system] [component] message key=value.
fn log_security(msg: &str) {
eprintln!("[SECURITY] [helper] [executor] {}", msg);
}
fn log_info(msg: &str) {
eprintln!("[INFO] [helper] [executor] {}", msg);
}
fn log_error(msg: &str) {
eprintln!("[ERROR] [helper] [executor] {}", msg);
}
fn env_or(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
fn read_token_from_stdin() -> Result<CapabilityToken, Denial> {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| Denial::new(EXIT_BAD_TOKEN, "stdin_read_failed", e.to_string()))?;
let token: CapabilityToken = serde_json::from_str(&buf)
.map_err(|e| Denial::new(EXIT_BAD_TOKEN, "token_parse_failed", e.to_string()))?;
Ok(token)
}
// This host's identity, read independently of the token so a forged agent_id
// cannot bind a token to a host it was not minted for.
fn local_agent_id() -> Result<String, Denial> {
if let Ok(v) = std::env::var("REDFLAG_AGENT_ID") {
let v = v.trim().to_string();
if !v.is_empty() {
return Ok(v);
}
}
for path in AGENT_ID_FILES {
if let Ok(contents) = fs::read_to_string(path) {
let v = contents.trim().to_string();
if !v.is_empty() {
return Ok(v);
}
}
}
Err(Denial::new(
EXIT_AGENT_MISMATCH,
"local_agent_id_unavailable",
"no REDFLAG_AGENT_ID env and no provisioned agent_id file",
))
}
fn key_id_for(pubkey: &[u8]) -> String {
let digest = Sha256::digest(pubkey);
hex::encode(&digest[..16])
}
// Load *.pub hex files from the keyring dir, indexed by computed key_id.
fn load_keyring(dir: &Path) -> Result<Vec<(String, VerifyingKey)>, Denial> {
let entries = fs::read_dir(dir).map_err(|e| {
Denial::new(EXIT_KEY_NOT_FOUND, "keyring_unreadable", format!("{}: {}", dir.display(), e))
})?;
let mut keys = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("pub") {
continue;
}
let raw = match fs::read_to_string(&path) {
Ok(r) => r,
Err(e) => {
log_error(&format!("keyring_file_skipped path={} error={}", path.display(), e));
continue;
}
};
let bytes = match hex::decode(raw.trim()) {
Ok(b) => b,
Err(e) => {
log_error(&format!("keyring_file_bad_hex path={} error={}", path.display(), e));
continue;
}
};
let arr: [u8; 32] = match bytes.as_slice().try_into() {
Ok(a) => a,
Err(_) => {
log_error(&format!("keyring_file_bad_len path={} len={}", path.display(), bytes.len()));
continue;
}
};
match VerifyingKey::from_bytes(&arr) {
Ok(vk) => keys.push((key_id_for(&arr), vk)),
Err(e) => log_error(&format!("keyring_file_bad_key path={} error={}", path.display(), e)),
}
}
if keys.is_empty() {
return Err(Denial::new(
EXIT_KEY_NOT_FOUND,
"keyring_empty",
format!("no usable *.pub keys in {}", dir.display()),
));
}
Ok(keys)
}
// closure_hash = hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) ))
// Sorted independently of array order so ordering cannot change the digest.
fn closure_hash(closure: &[ClosureEntry]) -> String {
let mut lines: BTreeSet<String> = BTreeSet::new();
for e in closure {
lines.insert(format!("{}@{}#{}", e.name, e.version, e.sha256));
}
let joined = lines.into_iter().collect::<Vec<_>>().join("\n");
hex::encode(Sha256::digest(joined.as_bytes()))
}
// signed_message = "{agent_id}:{token_id}:{operation}:{package_type}:{closure_hash}:{expires_at}"
fn signed_message(token: &CapabilityToken, closure_hash: &str) -> String {
format!(
"{}:{}:{}:{}:{}:{}",
token.agent_id, token.token_id, token.operation, token.package_type, closure_hash, token.expires_at
)
}
fn verify_signature(token: &CapabilityToken, keyring: &[(String, VerifyingKey)]) -> Result<(), Denial> {
let vk = keyring
.iter()
.find(|(id, _)| id == &token.key_id)
.map(|(_, vk)| vk)
.ok_or_else(|| {
Denial::new(EXIT_KEY_NOT_FOUND, "key_id_not_trusted", format!("key_id={}", token.key_id))
})?;
let sig_bytes = hex::decode(token.signature.trim())
.map_err(|e| Denial::new(EXIT_SIGNATURE, "signature_bad_hex", e.to_string()))?;
let sig = Signature::from_slice(&sig_bytes)
.map_err(|e| Denial::new(EXIT_SIGNATURE, "signature_malformed", e.to_string()))?;
let ch = closure_hash(&token.closure);
let msg = signed_message(token, &ch);
vk.verify(msg.as_bytes(), &sig)
.map_err(|e| Denial::new(EXIT_SIGNATURE, "signature_invalid", e.to_string()))
}
fn compute_file_sha256(path: &Path) -> std::io::Result<String> {
let mut file = fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let n = file.read(&mut buffer)?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
// Verify every artifact the executor can actually reach on disk.
// mirror source: artifact_path is required and the file MUST exist and match.
// registry source: if a local artifact_path is present, verify it; otherwise the
// server already attested the hash (covered by the signature) and enforcement is
// the mirror's job at fetch time. Returns count of hashes verified on disk.
fn verify_artifacts(token: &CapabilityToken) -> Result<usize, Denial> {
let mut verified = 0usize;
for e in &token.closure {
let is_mirror = e.source == "mirror";
let local_path = e.artifact_path.as_ref().filter(|p| !p.is_empty());
match local_path {
Some(p) => {
let path = Path::new(p);
if !path.is_file() {
if is_mirror {
return Err(Denial::new(
EXIT_ARTIFACT,
"mirror_artifact_missing",
format!("{}@{} path={}", e.name, e.version, p),
));
}
log_info(&format!(
"registry_artifact_not_local name={} version={} deferred_to_mirror",
e.name, e.version
));
continue;
}
let actual = compute_file_sha256(path).map_err(|err| {
Denial::new(EXIT_ARTIFACT, "artifact_hash_read_failed", format!("{}: {}", p, err))
})?;
if !actual.eq_ignore_ascii_case(&e.sha256) {
return Err(Denial::new(
EXIT_ARTIFACT,
"artifact_hash_mismatch",
format!("{}@{} expected={} actual={}", e.name, e.version, e.sha256, actual),
));
}
verified += 1;
}
None => {
if is_mirror {
return Err(Denial::new(
EXIT_ARTIFACT,
"mirror_artifact_no_path",
format!("{}@{} source=mirror but no artifact_path", e.name, e.version),
));
}
log_info(&format!(
"registry_artifact_no_path name={} version={} deferred_to_mirror",
e.name, e.version
));
}
}
}
Ok(verified)
}
// Replay guard. token_id is recorded BEFORE execution so a token can never run
// twice even across a crash. A record-write failure is fail-closed (deny).
fn replay_check_and_record(token_id: &str, state_path: &Path) -> Result<(), Denial> {
if let Ok(contents) = fs::read_to_string(state_path) {
if contents.lines().any(|l| l.trim() == token_id) {
return Err(Denial::new(EXIT_REPLAY, "token_already_consumed", format!("token_id={}", token_id)));
}
}
if let Some(parent) = state_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Denial::new(EXIT_INTERNAL, "state_dir_create_failed", format!("{}: {}", parent.display(), e))
})?;
}
let mut existing = fs::read_to_string(state_path).unwrap_or_default();
existing.push_str(token_id);
existing.push('\n');
fs::write(state_path, existing).map_err(|e| {
Denial::new(EXIT_INTERNAL, "state_write_failed", format!("{}: {}", state_path.display(), e))
})?;
Ok(())
}
// Build the argv plan for the one authorized operation. Returns a list of
// (program, args) invocations — single-element for line managers, per-artifact
// for image/winget managers. Unsupported (type, operation) pairs are denied
// rather than faked. Forward-only: only install/upgrade exist.
fn build_plan(token: &CapabilityToken) -> Result<Vec<(String, Vec<String>)>, Denial> {
match token.operation.as_str() {
"install" | "upgrade" => {}
other => {
return Err(Denial::new(
EXIT_UNSUPPORTED_OP,
"operation_not_allowed",
format!("operation={} (forward-only: install|upgrade)", other),
))
}
}
let c = &token.closure;
let plan = match token.package_type.as_str() {
"apt" => {
let mut args = vec!["install".into(), "-y".into(), "--no-install-recommends".into()];
for e in c {
args.push(format!("{}={}", e.name, e.version));
}
vec![("apt-get".to_string(), args)]
}
"dnf" => {
let mut args = vec!["install".into(), "-y".into()];
for e in c {
args.push(format!("{}-{}", e.name, e.version));
}
vec![("dnf".to_string(), args)]
}
"npm" => {
let mut args = vec!["install".into()];
for e in c {
args.push(format!("{}@{}", e.name, e.version));
}
vec![("npm".to_string(), args)]
}
"bun" => {
let mut args = vec!["add".into()];
for e in c {
args.push(format!("{}@{}", e.name, e.version));
}
vec![("bun".to_string(), args)]
}
"pip" => {
let mut args = vec!["install".into()];
for e in c {
args.push(format!("{}=={}", e.name, e.version));
}
vec![("pip".to_string(), args)]
}
"docker" => c
.iter()
.map(|e| ("docker".to_string(), vec!["pull".to_string(), format!("{}:{}", e.name, e.version)]))
.collect(),
"winget" => c
.iter()
.map(|e| {
(
"winget".to_string(),
vec![
"install".into(),
"--id".into(),
e.name.clone(),
"--version".into(),
e.version.clone(),
"--exact".into(),
"--accept-package-agreements".into(),
"--accept-source-agreements".into(),
],
)
})
.collect(),
other => {
return Err(Denial::new(
EXIT_UNSUPPORTED_OP,
"package_type_not_supported",
format!("package_type={}", other),
))
}
};
Ok(plan)
}
// Execute the plan with no shell and a stripped environment. Any non-zero step
// aborts the rest and fails closed.
fn execute_plan(plan: &[(String, Vec<String>)]) -> Result<(), Denial> {
for (program, args) in plan {
log_info(&format!("exec program={} argc={}", program, args.len()));
let status = Command::new(program)
.args(args)
.env_clear()
.env("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
.status()
.map_err(|e| {
Denial::new(EXIT_EXEC_FAILED, "exec_spawn_failed", format!("{}: {}", program, e))
})?;
if !status.success() {
return Err(Denial::new(
EXIT_EXEC_FAILED,
"exec_nonzero_exit",
format!("{} exit={:?}", program, status.code()),
));
}
}
Ok(())
}
fn emit_result(result: &PolicyResult) {
match serde_json::to_string(result) {
Ok(s) => println!("{}", s),
Err(e) => log_error(&format!("result_serialize_failed error={}", e)),
}
}
fn run() -> Result<PolicyResult, (Option<CapabilityToken>, Denial)> {
let token = read_token_from_stdin().map_err(|d| (None, d))?;
if token.version != SUPPORTED_TOKEN_VERSION {
let d = Denial::new(
EXIT_VERSION,
"unsupported_token_version",
format!("version={} supported={}", token.version, SUPPORTED_TOKEN_VERSION),
);
return Err((Some(token), d));
}
// Validity window.
let now = now_unix();
if now < token.not_before {
let d = Denial::new(EXIT_TIME_WINDOW, "token_not_yet_valid", format!("now={} not_before={}", now, token.not_before));
return Err((Some(token), d));
}
if now > token.expires_at {
let d = Denial::new(EXIT_TIME_WINDOW, "token_expired", format!("now={} expires_at={}", now, token.expires_at));
return Err((Some(token), d));
}
// Bind-check against this host's independently-read identity.
let local = match local_agent_id() {
Ok(v) => v,
Err(d) => return Err((Some(token), d)),
};
if local != token.agent_id {
let d = Denial::new(
EXIT_AGENT_MISMATCH,
"agent_id_mismatch",
format!("token_agent_id={} host_agent_id={}", token.agent_id, local),
);
return Err((Some(token), d));
}
// Verify signature against the pinned keyring (verify keys, not servers).
let keyring_dir = PathBuf::from(env_or("REDFLAG_HELPER_KEYRING", DEFAULT_KEYRING_DIR));
let keyring = match load_keyring(&keyring_dir) {
Ok(k) => k,
Err(d) => return Err((Some(token), d)),
};
if let Err(d) = verify_signature(&token, &keyring) {
return Err((Some(token), d));
}
// Verify artifact hashes the executor can reach.
let verified = match verify_artifacts(&token) {
Ok(v) => v,
Err(d) => return Err((Some(token), d)),
};
// Build the one operation before consuming the replay slot, so an
// unsupported op does not burn the token_id.
let plan = match build_plan(&token) {
Ok(p) => p,
Err(d) => return Err((Some(token), d)),
};
// Replay guard records the token_id before execution.
let state_path = PathBuf::from(env_or("REDFLAG_HELPER_STATE", DEFAULT_STATE_FILE));
if let Err(d) = replay_check_and_record(&token.token_id, &state_path) {
return Err((Some(token), d));
}
log_security(&format!(
"authorized token_id={} agent_id={} package_type={} operation={} closure_size={} verified_artifacts={}",
token.token_id, token.agent_id, token.package_type, token.operation, token.closure.len(), verified
));
if let Err(d) = execute_plan(&plan) {
return Err((Some(token), d));
}
Ok(PolicyResult {
token_id: token.token_id.clone(),
agent_id: token.agent_id.clone(),
package_type: token.package_type.clone(),
operation: token.operation.clone(),
decision: "executed".to_string(),
reason: "operation_completed".to_string(),
executed: true,
verified_artifacts: verified,
exit_code: EXIT_OK,
error: None,
timestamp: now_unix(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(name: &str, version: &str, sha: &str) -> ClosureEntry {
ClosureEntry {
name: name.to_string(),
version: version.to_string(),
sha256: sha.to_string(),
source: String::new(),
artifact_path: None,
}
}
// Cross-language contract vector. These exact strings are produced by the Go
// capability package for the same input; if either side changes, this breaks.
#[test]
fn canonical_matches_go() {
let token = CapabilityToken {
version: 1,
token_id: "tok-1".into(),
agent_id: "agent-123".into(),
key_id: String::new(),
package_type: "npm".into(),
operation: "install".into(),
closure: vec![entry("left-pad", "1.3.0", "aaaa"), entry("is-odd", "2.0.0", "bbbb")],
issued_at: 0,
not_before: 0,
expires_at: 1700000000,
signature: String::new(),
};
let ch = closure_hash(&token.closure);
assert_eq!(ch, "49a181cd7b6df83a6dc83c7f647f2d224effe90907dd5dcdfdaf4c8697af595f");
assert_eq!(
signed_message(&token, &ch),
"agent-123:tok-1:install:npm:49a181cd7b6df83a6dc83c7f647f2d224effe90907dd5dcdfdaf4c8697af595f:1700000000"
);
}
// Closure ordering must not change the digest.
#[test]
fn closure_hash_order_independent() {
let a = vec![entry("a", "1", "x"), entry("b", "2", "y")];
let b = vec![entry("b", "2", "y"), entry("a", "1", "x")];
assert_eq!(closure_hash(&a), closure_hash(&b));
}
}
fn main() {
match run() {
Ok(result) => {
log_security(&format!("executed token_id={} package_type={}", result.token_id, result.package_type));
emit_result(&result);
std::process::exit(EXIT_OK);
}
Err((token, denial)) => {
// failed = the op was attempted and the process running it returned
// non-zero; everything earlier is a pre-execution deny.
let decision = if denial.code == EXIT_EXEC_FAILED { "failed" } else { "denied" };
log_security(&format!(
"{} reason={} detail={} exit={}",
decision, denial.reason, denial.detail, denial.code
));
let result = PolicyResult {
token_id: token.as_ref().map(|t| t.token_id.clone()).unwrap_or_default(),
agent_id: token.as_ref().map(|t| t.agent_id.clone()).unwrap_or_default(),
package_type: token.as_ref().map(|t| t.package_type.clone()).unwrap_or_default(),
operation: token.as_ref().map(|t| t.operation.clone()).unwrap_or_default(),
decision: decision.to_string(),
reason: denial.reason.to_string(),
executed: false,
verified_artifacts: 0,
exit_code: denial.code,
error: Some(denial.detail),
timestamp: now_unix(),
};
emit_result(&result);
std::process::exit(denial.code);
}
}
}

View file

@ -337,15 +337,15 @@ func main() {
// Initialize security logger
secConfig := logging.SecurityLogConfig{
Enabled: true, // Could be configurable in the future
Level: "warning",
LogSuccesses: false,
FilePath: "/var/log/redflag/security.json",
MaxSizeMB: 100,
MaxFiles: 10,
RetentionDays: 90,
LogToDatabase: true,
HashIPAddresses: true,
Enabled: true, // Could be configurable in the future
Level: "warning",
LogSuccesses: false,
FilePath: "/var/log/redflag/security.json",
MaxSizeMB: 100,
MaxFiles: 10,
RetentionDays: 90,
LogToDatabase: true,
HashIPAddresses: true,
}
securityLogger, err := logging.NewSecurityLogger(secConfig, db.DB)
if err != nil {
@ -552,6 +552,16 @@ func main() {
updateHandler.SetSecuritySettings(securitySettingsService)
}
// Supply Chain Gate — mint signed capability tokens at approval and deliver
// them to agents. Active only when signing is enabled; the authority role
// requires the signing key.
if signingService != nil && signingService.IsEnabled() {
capabilityTokenQueries := queries.NewCapabilityTokenQueries(db.DB)
capabilityMinter := services.NewCapabilityMinter(signingService, capabilityTokenQueries)
updateHandler.SetCapabilityMinter(capabilityMinter, capabilityTokenQueries)
log.Printf("[system] Capability token gate enabled (key_id=%s)", signingService.GetCurrentKeyID())
}
// Initialize events handler [TD-003]
eventsHandler := handlers.NewEventsHandler(agentQueries)
@ -565,6 +575,8 @@ func main() {
agents.Use(middleware.MachineBindingMiddleware(agentQueries, cfg.MinAgentVersion)) // v0.1.22: Prevent config copying
{
agents.GET("/:id/commands", rateLimiter.RateLimit("agent_checkin", middleware.KeyByAgentID), agentHandler.GetCommands)
agents.GET("/:id/capability-tokens", rateLimiter.RateLimit("agent_checkin", middleware.KeyByAgentID), updateHandler.GetCapabilityTokens)
agents.POST("/:id/capability-tokens/:token_id/receipt", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportCapabilityResult)
agents.GET("/:id/config", agentHandler.GetAgentConfig)
agents.POST("/:id/updates", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportUpdates)
agents.POST("/:id/logs", rateLimiter.RateLimit("agent_reports", middleware.KeyByAgentID), updateHandler.ReportLog)
@ -713,31 +725,31 @@ func main() {
admin.PUT("/scanner-timeouts/:scanner_name", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), scannerConfigHandler.UpdateScannerTimeout)
admin.POST("/scanner-timeouts/:scanner_name/reset", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), scannerConfigHandler.ResetScannerTimeout)
// Maintenance Windows (gating for install operations)
admin.GET("/maintenance-windows", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.ListWindows)
admin.GET("/maintenance-windows/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.GetWindow)
admin.POST("/maintenance-windows", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.CreateWindow)
admin.PUT("/maintenance-windows/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.UpdateWindow)
admin.DELETE("/maintenance-windows/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.DeleteWindow)
admin.GET("/maintenance-windows/check", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.CheckWindow)
// Maintenance Windows (gating for install operations)
admin.GET("/maintenance-windows", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.ListWindows)
admin.GET("/maintenance-windows/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.GetWindow)
admin.POST("/maintenance-windows", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.CreateWindow)
admin.PUT("/maintenance-windows/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.UpdateWindow)
admin.DELETE("/maintenance-windows/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.DeleteWindow)
admin.GET("/maintenance-windows/check", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), maintenanceWindowHandler.CheckWindow)
// Upstream version sync (Repology + endoflife.date adapters)
admin.GET("/upstream", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.List)
admin.GET("/upstream/drift", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.ListDrifted)
admin.GET("/upstream/drift/events", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.RecentDrift)
admin.POST("/upstream", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.Create)
admin.DELETE("/upstream/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.Delete)
admin.POST("/upstream/:id/sync", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.SyncNow)
admin.GET("/upstream/:id/installations", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.ListInstallations)
// Upstream version sync (Repology + endoflife.date adapters)
admin.GET("/upstream", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.List)
admin.GET("/upstream/drift", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.ListDrifted)
admin.GET("/upstream/drift/events", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.RecentDrift)
admin.POST("/upstream", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.Create)
admin.DELETE("/upstream/:id", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.Delete)
admin.POST("/upstream/:id/sync", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), upstreamHandler.SyncNow)
admin.GET("/upstream/:id/installations", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.ListInstallations)
// Agent <-> tracked_software bindings: per-host view of upstream drift.
// Routes live under the agent subtree so the binding's authorization
// boundary is the agent id (queries.Delete enforces the scope).
admin.GET("/agents/:id/tracked-software", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.ListByAgent)
admin.POST("/agents/:id/tracked-software", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.Upsert)
// Agent <-> tracked_software bindings: per-host view of upstream drift.
// Routes live under the agent subtree so the binding's authorization
// boundary is the agent id (queries.Delete enforces the scope).
admin.GET("/agents/:id/tracked-software", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.ListByAgent)
admin.POST("/agents/:id/tracked-software", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.Upsert)
admin.POST("/agents/:id/tracked-software/create-update", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.CreateUpdateFromDrift)
admin.GET("/agents/:id/tracked-software/:bindingID/install-script", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.GenerateInstallScript)
admin.DELETE("/agents/:id/tracked-software/:bindingID", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.Delete)
admin.DELETE("/agents/:id/tracked-software/:bindingID", rateLimiter.RateLimit("admin_operations", middleware.KeyByUserID), agentTrackedSoftwareHandler.Delete)
}
// Security Health Check endpoints

View file

@ -14,6 +14,7 @@ import (
"strings"
"time"
"github.com/Fimeg/RedFlag/server/internal/capability"
"github.com/Fimeg/RedFlag/server/internal/config"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
@ -42,6 +43,8 @@ type UpdateHandler struct {
maintenanceWindowQueries *queries.MaintenanceWindowQueries
securitySettings *services.SecuritySettingsService // optional; reads policy.allow_dry_runs
config *config.Config // optional; for self-referential URLs
minter *services.CapabilityMinter // optional; mints capability tokens at approval
tokenQueries *queries.CapabilityTokenQueries // optional; delivers minted tokens to agents
}
func NewUpdateHandler(uq *queries.UpdateQueries, aq *queries.AgentQueries, cq *queries.CommandQueries, ah *AgentHandler, mwq *queries.MaintenanceWindowQueries, cfg *config.Config) *UpdateHandler {
@ -62,6 +65,14 @@ func (h *UpdateHandler) SetSecuritySettings(s *services.SecuritySettingsService)
h.securitySettings = s
}
// SetCapabilityMinter wires the capability-token minter and token store. When
// both are set, approval mints a signed token over the package closure and the
// delivery endpoint serves it to the agent. Nil leaves the gate inactive.
func (h *UpdateHandler) SetCapabilityMinter(m *services.CapabilityMinter, tq *queries.CapabilityTokenQueries) {
h.minter = m
h.tokenQueries = tq
}
// shouldEnableHeartbeat checks if heartbeat is already active for an agent
// Returns true if heartbeat should be enabled (i.e., not already active or expired)
func (h *UpdateHandler) shouldEnableHeartbeat(agentID uuid.UUID, durationMinutes int) (bool, error) {
@ -128,8 +139,8 @@ func (h *UpdateHandler) ReportUpdates(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"message": "update events recorded",
"count": len(events),
"message": "update events recorded",
"count": len(events),
"command_id": req.CommandID,
})
}
@ -256,14 +267,14 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
log.Printf("[WARNING] [supply_chain] approval_blocked_by_age_gate id=%s pkg=%s age_hours=%.2f min=%.2f",
id, update.PackageName, ageDecision.AgeHours, ageDecision.MinAgeHours)
c.JSON(http.StatusConflict, gin.H{
"error": "approval blocked by supply chain age gate",
"reason": ageDecision.WarnMessage,
"package": update.PackageName,
"version": update.AvailableVersion,
"published_at": ageDecision.PublishedAt.UTC().Format(time.RFC3339),
"age_hours": ageDecision.AgeHours,
"min_age_hours": ageDecision.MinAgeHours,
"override_hint": "set REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT=warn (or off) and retry; or wait until threshold passes",
"error": "approval blocked by supply chain age gate",
"reason": ageDecision.WarnMessage,
"package": update.PackageName,
"version": update.AvailableVersion,
"published_at": ageDecision.PublishedAt.UTC().Format(time.RFC3339),
"age_hours": ageDecision.AgeHours,
"min_age_hours": ageDecision.MinAgeHours,
"override_hint": "set REDFLAG_SUPPLY_CHAIN_GATE_ENFORCEMENT=warn (or off) and retry; or wait until threshold passes",
})
return
}
@ -300,6 +311,29 @@ func (h *UpdateHandler) ApproveUpdate(c *gin.Context) {
id, update.PackageName, artifactHash[:16]+"...")
}
// Supply Chain Gate — mint a signed capability token over the approved
// closure. Single-entry closure today (top-level package + its expected
// hash); transitive resolution lands here later. Best-effort: a minting
// failure is logged at SECURITY but does not roll back an approval that has
// already cleared OSV/age/hash, mirroring the fail-open hash step above.
if h.minter.Enabled() {
if artifactHash == "" {
log.Printf("[SECURITY] [server] [capability] mint_skipped id=%s pkg=%s reason=no_artifact_hash",
id, update.PackageName)
} else {
closure := []capability.ClosureEntry{{
Name: update.PackageName,
Version: update.AvailableVersion,
SHA256: artifactHash,
Source: "registry",
}}
if _, err := h.minter.MintForUpdate(update, closure); err != nil {
log.Printf("[SECURITY] [server] [capability] mint_failed id=%s pkg=%s error=%v",
id, update.PackageName, err)
}
}
}
response := gin.H{"message": "update approved"}
if len(vulns) > 0 {
response["warnings"] = vulns
@ -397,8 +431,8 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
log.Printf("[INFO] [server] [updates] duplicate_log_rejected agent_id=%s command_id=%s status=%s",
agentID, commandID, command.Status)
c.JSON(http.StatusConflict, gin.H{
"error": "duplicate log submission",
"command_id": commandID.String(),
"error": "duplicate log submission",
"command_id": commandID.String(),
"current_status": command.Status,
})
return
@ -603,10 +637,10 @@ func (h *UpdateHandler) GetPackageHistory(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"history": history,
"history": history,
"package_type": packageType,
"package_name": packageName,
"count": len(history),
"count": len(history),
})
}
@ -629,7 +663,7 @@ func (h *UpdateHandler) GetBatchStatus(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"batches": batches,
"count": len(batches),
"count": len(batches),
})
}
@ -877,13 +911,13 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
AgentID: update.AgentID,
CommandType: models.CommandTypeDryRunUpdate,
Params: map[string]interface{}{
"update_id": id.String(),
"package_name": update.PackageName,
"package_type": update.PackageType,
"update_id": id.String(),
"package_name": update.PackageName,
"package_type": update.PackageType,
},
Status: models.CommandStatusPending,
Source: models.CommandSourceManual,
CreatedAt: time.Now().UTC(),
Status: models.CommandStatusPending,
Source: models.CommandSourceManual,
CreatedAt: time.Now().UTC(),
}
// Check if heartbeat should be enabled (avoid duplicates)
@ -922,7 +956,7 @@ func (h *UpdateHandler) InstallUpdate(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"message": "dry run command created for agent",
"message": "dry run command created for agent",
"command_id": command.ID.String(),
})
}
@ -983,10 +1017,10 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
AgentID: agentID,
CommandType: models.CommandTypeConfirmDependencies,
Params: map[string]interface{}{
"update_id": update.ID.String(),
"package_name": req.PackageName,
"package_type": req.PackageType,
"dependencies": []string{}, // Empty dependencies array
"update_id": update.ID.String(),
"package_name": req.PackageName,
"package_type": req.PackageType,
"dependencies": []string{}, // Empty dependencies array
},
Status: models.CommandStatusPending,
Source: models.CommandSourceManual,
@ -1075,14 +1109,14 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
AgentID: update.AgentID,
CommandType: models.CommandTypeConfirmDependencies,
Params: map[string]interface{}{
"update_id": id.String(),
"package_name": update.PackageName,
"package_type": update.PackageType,
"dependencies": update.Metadata["dependencies"], // Dependencies stored in metadata
"update_id": id.String(),
"package_name": update.PackageName,
"package_type": update.PackageType,
"dependencies": update.Metadata["dependencies"], // Dependencies stored in metadata
},
Status: models.CommandStatusPending,
Source: models.CommandSourceManual,
CreatedAt: time.Now().UTC(),
Status: models.CommandStatusPending,
Source: models.CommandSourceManual,
CreatedAt: time.Now().UTC(),
}
// Check if heartbeat should be enabled (avoid duplicates)
@ -1121,7 +1155,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"message": "dependency installation confirmed and command created",
"message": "dependency installation confirmed and command created",
"command_id": command.ID.String(),
})
}
@ -1130,8 +1164,8 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
// Now returns unified history of both commands and logs
func (h *UpdateHandler) GetAllLogs(c *gin.Context) {
filters := &models.LogFilters{
Action: c.Query("action"),
Result: c.Query("result"),
Action: c.Query("action"),
Result: c.Query("result"),
}
// Parse agent_id if provided
@ -1372,7 +1406,7 @@ func (h *UpdateHandler) ClearFailedCommands(c *gin.Context) {
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to clear failed commands",
"error": "failed to clear failed commands",
"details": err.Error(),
})
return
@ -1388,8 +1422,8 @@ func (h *UpdateHandler) ClearFailedCommands(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"message": message,
"count": count,
"message": message,
"count": count,
"cheeky_warning": "Consider this a developer experience enhancement - the system should clean up after itself automatically!",
})
}
@ -1434,9 +1468,96 @@ func (h *UpdateHandler) GetExpectedHash(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"package_type": packageType,
"package_name": packageName,
"package_type": packageType,
"package_name": packageName,
"expected_sha256": sha,
})
}
// GetCapabilityTokens delivers an agent's minted-but-undelivered capability
// tokens. The agent passes each to the privileged executor, which verifies the
// signature and artifact hashes independently before performing the operation.
// Tokens are marked delivered as they are served; the executor's local replay
// guard — not delivery state — is the authority on single use.
func (h *UpdateHandler) GetCapabilityTokens(c *gin.Context) {
if h.tokenQueries == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "capability gate not enabled"})
return
}
agentID := c.MustGet("agent_id").(uuid.UUID)
ids, err := h.tokenQueries.ListUndeliveredForAgent(agentID, time.Now().UTC().Unix())
if err != nil {
log.Printf("[ERROR] [server] [capability] list_undelivered_failed agent_id=%s error=%v", agentID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list capability tokens"})
return
}
tokens := make([]*capability.Token, 0, len(ids))
for _, id := range ids {
token, err := h.tokenQueries.GetByID(id)
if err != nil {
log.Printf("[ERROR] [server] [capability] token_load_failed token_id=%s error=%v", id, err)
continue
}
tokens = append(tokens, token)
if err := h.tokenQueries.MarkDelivered(id); err != nil {
log.Printf("[WARNING] [server] [capability] mark_delivered_failed token_id=%s error=%v", id, err)
}
}
c.JSON(http.StatusOK, gin.H{"tokens": tokens})
}
// ReportCapabilityResult records the executor's outcome for a delivered token.
// This is an audit receipt: the executor's local replay guard is authoritative on
// single use, so a receipt only marks the server-side consumed_at and logs the
// decision. Tokens are bound to the calling agent; an agent cannot post a receipt
// for a token that is not its own.
func (h *UpdateHandler) ReportCapabilityResult(c *gin.Context) {
if h.tokenQueries == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "capability gate not enabled"})
return
}
agentID := c.MustGet("agent_id").(uuid.UUID)
tokenID, err := uuid.Parse(c.Param("token_id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid token_id"})
return
}
var body struct {
Decision string `json:"decision"`
Reason string `json:"reason"`
ExitCode int `json:"exit_code"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid receipt body"})
return
}
token, err := h.tokenQueries.GetByID(tokenID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "token not found"})
return
}
if token.AgentID != agentID.String() {
log.Printf("[SECURITY] [server] [capability] receipt_agent_mismatch token_id=%s token_agent=%s caller_agent=%s",
tokenID, token.AgentID, agentID)
c.JSON(http.StatusForbidden, gin.H{"error": "token not bound to this agent"})
return
}
if _, err := h.tokenQueries.MarkConsumed(tokenID); err != nil {
log.Printf("[ERROR] [server] [capability] mark_consumed_failed token_id=%s error=%v", tokenID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record receipt"})
return
}
log.Printf("[SECURITY] [server] [capability] receipt token_id=%s agent_id=%s decision=%s reason=%s exit=%d",
tokenID, agentID, body.Decision, body.Reason, body.ExitCode)
c.JSON(http.StatusOK, gin.H{"message": "receipt recorded"})
}

View file

@ -0,0 +1,117 @@
// Package capability defines the supply-chain capability token: an Ed25519-signed
// authorization for exactly one package operation over a fully-resolved dependency
// closure. The server (authority) mints and signs tokens; the agent passes them to
// the privileged Rust executor (helper/) which independently verifies them.
//
// The canonical signed message and closure hash MUST stay byte-identical across
// this package, the agent's mirror of it, and helper/src/main.rs. See
// RAF/SUPPLY_CHAIN_GATE_PLAN.md for the contract.
package capability
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
)
// Version is the only token format this code understands. Forward-only doctrine:
// new versions add fields, never reinterpret existing ones.
const Version = 1
// ClosureEntry is one resolved artifact in the dependency closure.
type ClosureEntry struct {
Name string `json:"name"`
Version string `json:"version"`
SHA256 string `json:"sha256"`
Source string `json:"source"` // "mirror" | "registry"
ArtifactPath string `json:"artifact_path,omitempty"` // local path or url, optional
}
// Token is the full capability token exchanged between server, agent, and executor.
type Token struct {
Version int `json:"version"`
TokenID string `json:"token_id"`
AgentID string `json:"agent_id"`
KeyID string `json:"key_id"`
PackageType string `json:"package_type"` // apt|dnf|npm|bun|pip|docker|winget
Operation string `json:"operation"` // install|upgrade (forward-only)
Closure []ClosureEntry `json:"closure"`
IssuedAt int64 `json:"issued_at"`
NotBefore int64 `json:"not_before"`
ExpiresAt int64 `json:"expires_at"`
Signature string `json:"signature"` // hex ed25519
}
// KeyIDFor returns the authority key fingerprint: hex(sha256(pubkey)[:16]).
// Matches SigningService.GetPublicKeyFingerprint and the executor's key_id_for.
func KeyIDFor(pub ed25519.PublicKey) string {
hash := sha256.Sum256(pub)
return hex.EncodeToString(hash[:16])
}
// ClosureHash computes hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) )).
// Lines are sorted and de-duplicated so neither array order nor exact duplicates
// can change the digest. This mirrors the Rust BTreeSet construction exactly.
func (t *Token) ClosureHash() string {
set := make(map[string]struct{}, len(t.Closure))
for _, e := range t.Closure {
set[fmt.Sprintf("%s@%s#%s", e.Name, e.Version, e.SHA256)] = struct{}{}
}
lines := make([]string, 0, len(set))
for line := range set {
lines = append(lines, line)
}
sort.Strings(lines)
h := sha256.New()
for i, line := range lines {
if i > 0 {
h.Write([]byte("\n"))
}
h.Write([]byte(line))
}
return hex.EncodeToString(h.Sum(nil))
}
// CanonicalMessage builds the deterministic message that is signed/verified:
// "{agent_id}:{token_id}:{operation}:{package_type}:{closure_hash}:{expires_at}".
func (t *Token) CanonicalMessage() string {
return fmt.Sprintf("%s:%s:%s:%s:%s:%d",
t.AgentID, t.TokenID, t.Operation, t.PackageType, t.ClosureHash(), t.ExpiresAt)
}
// Sign signs the canonical message with the authority private key, sets the
// token's KeyID and Signature, and returns the hex signature.
func (t *Token) Sign(priv ed25519.PrivateKey) (string, error) {
if len(priv) != ed25519.PrivateKeySize {
return "", fmt.Errorf("capability: invalid private key size %d", len(priv))
}
pub := priv.Public().(ed25519.PublicKey)
t.KeyID = KeyIDFor(pub)
sig := ed25519.Sign(priv, []byte(t.CanonicalMessage()))
t.Signature = hex.EncodeToString(sig)
return t.Signature, nil
}
// Verify checks the token's signature against the given public key. It does not
// check the validity window, agent binding, or artifact hashes — those are the
// executor's responsibility (and the agent's bind-check). It verifies only that
// this key signed this canonical message.
func (t *Token) Verify(pub ed25519.PublicKey) error {
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("capability: invalid public key size %d", len(pub))
}
sig, err := hex.DecodeString(t.Signature)
if err != nil {
return fmt.Errorf("capability: signature not hex: %w", err)
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("capability: invalid signature size %d", len(sig))
}
if !ed25519.Verify(pub, []byte(t.CanonicalMessage()), sig) {
return fmt.Errorf("capability: signature verification failed")
}
return nil
}

View file

@ -0,0 +1,63 @@
package capability
import (
"crypto/ed25519"
"testing"
)
// Cross-language contract vector. The Rust executor (helper/src/main.rs) asserts
// these same strings for the same input. If either side drifts, both break.
func TestCanonicalVector(t *testing.T) {
tok := &Token{
Version: 1, TokenID: "tok-1", AgentID: "agent-123",
PackageType: "npm", Operation: "install",
Closure: []ClosureEntry{
{Name: "left-pad", Version: "1.3.0", SHA256: "aaaa"},
{Name: "is-odd", Version: "2.0.0", SHA256: "bbbb"},
},
ExpiresAt: 1700000000,
}
const wantHash = "49a181cd7b6df83a6dc83c7f647f2d224effe90907dd5dcdfdaf4c8697af595f"
const wantMsg = "agent-123:tok-1:install:npm:" + wantHash + ":1700000000"
if got := tok.ClosureHash(); got != wantHash {
t.Fatalf("ClosureHash() = %q, want %q", got, wantHash)
}
if got := tok.CanonicalMessage(); got != wantMsg {
t.Fatalf("CanonicalMessage() = %q, want %q", got, wantMsg)
}
}
func TestClosureHashOrderIndependent(t *testing.T) {
a := &Token{Closure: []ClosureEntry{{Name: "a", Version: "1", SHA256: "x"}, {Name: "b", Version: "2", SHA256: "y"}}}
b := &Token{Closure: []ClosureEntry{{Name: "b", Version: "2", SHA256: "y"}, {Name: "a", Version: "1", SHA256: "x"}}}
if a.ClosureHash() != b.ClosureHash() {
t.Fatalf("closure hash depends on order: %q != %q", a.ClosureHash(), b.ClosureHash())
}
}
func TestSignVerifyRoundtrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
tok := &Token{
Version: 1, TokenID: "tok-2", AgentID: "agent-9",
PackageType: "dnf", Operation: "upgrade",
Closure: []ClosureEntry{{Name: "openssl", Version: "3.2.1", SHA256: "deadbeef"}},
ExpiresAt: 1700000000,
}
if _, err := tok.Sign(priv); err != nil {
t.Fatal(err)
}
if tok.KeyID != KeyIDFor(pub) {
t.Fatalf("KeyID = %q, want %q", tok.KeyID, KeyIDFor(pub))
}
if err := tok.Verify(pub); err != nil {
t.Fatalf("Verify after Sign failed: %v", err)
}
// Tamper detection: any change to the closure breaks verification.
tok.Closure[0].Version = "3.2.2"
if err := tok.Verify(pub); err == nil {
t.Fatal("Verify accepted a tampered closure")
}
}

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS capability_tokens;

View file

@ -0,0 +1,25 @@
-- Supply Chain Gate — capability token persistence.
-- Stores each minted Ed25519 capability token together with the fully-resolved
-- dependency closure (per-artifact name/version/sha256/source) it authorizes.
-- The privileged executor verifies signature + hashes independently; this table
-- is the server-side audit/replay record and the delivery source for the agent.
CREATE TABLE IF NOT EXISTS capability_tokens (
token_id UUID PRIMARY KEY,
update_id UUID REFERENCES current_package_state(id) ON DELETE SET NULL,
agent_id UUID NOT NULL,
key_id VARCHAR(32) NOT NULL,
package_type VARCHAR(32) NOT NULL,
operation VARCHAR(16) NOT NULL,
closure JSONB NOT NULL,
issued_at BIGINT NOT NULL,
not_before BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
signature TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
delivered_at TIMESTAMPTZ,
consumed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_capability_tokens_agent ON capability_tokens(agent_id);
CREATE INDEX IF NOT EXISTS idx_capability_tokens_update ON capability_tokens(update_id);
CREATE INDEX IF NOT EXISTS idx_capability_tokens_expires ON capability_tokens(expires_at);

View file

@ -0,0 +1,143 @@
package queries
import (
"database/sql"
"encoding/json"
"time"
"github.com/Fimeg/RedFlag/server/internal/capability"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// CapabilityTokenQueries persists minted capability tokens and their resolved
// closures. The token is the audit/replay record; the executor verifies it
// independently of anything stored here.
type CapabilityTokenQueries struct {
db *sqlx.DB
}
func NewCapabilityTokenQueries(db *sqlx.DB) *CapabilityTokenQueries {
return &CapabilityTokenQueries{db: db}
}
// Insert stores a freshly minted, signed token. updateID may be uuid.Nil when
// the token covers an aggregate that is not tied to a single update row.
func (q *CapabilityTokenQueries) Insert(t *capability.Token, updateID uuid.UUID) error {
closureJSON, err := json.Marshal(t.Closure)
if err != nil {
return err
}
tokenID, err := uuid.Parse(t.TokenID)
if err != nil {
return err
}
agentID, err := uuid.Parse(t.AgentID)
if err != nil {
return err
}
var updatePtr interface{}
if updateID != uuid.Nil {
updatePtr = updateID
}
query := `
INSERT INTO capability_tokens (
token_id, update_id, agent_id, key_id, package_type, operation,
closure, issued_at, not_before, expires_at, signature
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
)
`
_, err = q.db.Exec(query,
tokenID, updatePtr, agentID, t.KeyID, t.PackageType, t.Operation,
closureJSON, t.IssuedAt, t.NotBefore, t.ExpiresAt, t.Signature)
return err
}
// GetByID returns the stored token reconstructed into a capability.Token.
func (q *CapabilityTokenQueries) GetByID(tokenID uuid.UUID) (*capability.Token, error) {
var row struct {
TokenID uuid.UUID `db:"token_id"`
AgentID uuid.UUID `db:"agent_id"`
KeyID string `db:"key_id"`
PackageType string `db:"package_type"`
Operation string `db:"operation"`
Closure []byte `db:"closure"`
IssuedAt int64 `db:"issued_at"`
NotBefore int64 `db:"not_before"`
ExpiresAt int64 `db:"expires_at"`
Signature string `db:"signature"`
}
query := `
SELECT token_id, agent_id, key_id, package_type, operation,
closure, issued_at, not_before, expires_at, signature
FROM capability_tokens WHERE token_id = $1`
if err := q.db.Get(&row, query, tokenID); err != nil {
return nil, err
}
var closure []capability.ClosureEntry
if err := json.Unmarshal(row.Closure, &closure); err != nil {
return nil, err
}
return &capability.Token{
Version: capability.Version,
TokenID: row.TokenID.String(),
AgentID: row.AgentID.String(),
KeyID: row.KeyID,
PackageType: row.PackageType,
Operation: row.Operation,
Closure: closure,
IssuedAt: row.IssuedAt,
NotBefore: row.NotBefore,
ExpiresAt: row.ExpiresAt,
Signature: row.Signature,
}, nil
}
// ListUndeliveredForAgent returns minted-but-not-yet-delivered, unexpired tokens
// for an agent, oldest first.
func (q *CapabilityTokenQueries) ListUndeliveredForAgent(agentID uuid.UUID, now int64) ([]uuid.UUID, error) {
var ids []uuid.UUID
query := `
SELECT token_id FROM capability_tokens
WHERE agent_id = $1 AND delivered_at IS NULL AND expires_at > $2
ORDER BY created_at ASC`
if err := q.db.Select(&ids, query, agentID, now); err != nil {
return nil, err
}
return ids, nil
}
// MarkDelivered records that a token was handed to its agent.
func (q *CapabilityTokenQueries) MarkDelivered(tokenID uuid.UUID) error {
_, err := q.db.Exec(
`UPDATE capability_tokens SET delivered_at = $1 WHERE token_id = $2 AND delivered_at IS NULL`,
time.Now().UTC(), tokenID)
return err
}
// MarkConsumed records the executor's confirmation that a token was used.
func (q *CapabilityTokenQueries) MarkConsumed(tokenID uuid.UUID) (bool, error) {
res, err := q.db.Exec(
`UPDATE capability_tokens SET consumed_at = $1 WHERE token_id = $2 AND consumed_at IS NULL`,
time.Now().UTC(), tokenID)
if err != nil {
return false, err
}
n, _ := res.RowsAffected()
return n > 0, nil
}
// IsConsumed reports whether a token has already been marked consumed (server-side
// replay view; the executor holds the authoritative local replay guard).
func (q *CapabilityTokenQueries) IsConsumed(tokenID uuid.UUID) (bool, error) {
var consumed sql.NullTime
err := q.db.Get(&consumed, `SELECT consumed_at FROM capability_tokens WHERE token_id = $1`, tokenID)
if err != nil {
return false, err
}
return consumed.Valid, nil
}

View file

@ -0,0 +1,97 @@
package services
import (
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/server/internal/capability"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/google/uuid"
)
// DefaultTokenTTL bounds how long a minted token stays valid. Short by design:
// a token authorizes one already-approved operation that the agent should pick
// up promptly. Long enough to survive normal poll/delivery latency, short enough
// that a leaked token is not a standing capability.
const DefaultTokenTTL = time.Hour
// CapabilityMinter builds, signs, and persists supply-chain capability tokens at
// approval time. It is the server's authority role: it only mints after the
// caller's policy checks (OSV, age, hash) have cleared.
//
// The signing key stays inside SigningService; the minter never touches key
// material directly. Full off-web-process signer isolation (plan constraint #2)
// is an infrastructure step layered on top of this seam — this type is the only
// place that asks for a signature, so relocating the signer means swapping the
// SigningService dependency here, not rewriting callers.
type CapabilityMinter struct {
signing *SigningService
tokens *queries.CapabilityTokenQueries
ttl time.Duration
}
func NewCapabilityMinter(signing *SigningService, tokens *queries.CapabilityTokenQueries) *CapabilityMinter {
return &CapabilityMinter{signing: signing, tokens: tokens, ttl: DefaultTokenTTL}
}
// SetTTL overrides the token validity window.
func (m *CapabilityMinter) SetTTL(d time.Duration) {
if d > 0 {
m.ttl = d
}
}
// Enabled reports whether minting can produce signed tokens. When signing is
// disabled the gate is simply not active; that is not an error condition.
func (m *CapabilityMinter) Enabled() bool {
return m != nil && m.signing != nil && m.signing.IsEnabled()
}
// MintForUpdate builds a token authorizing the approved update over the supplied
// resolved closure, signs it, and persists it for delivery. The caller assembles
// the closure (today: the top-level package plus its expected hash; later: the
// full transitive set) so this stays the single signing chokepoint.
//
// Returns (nil, nil) when signing is disabled — the gate is optional and its
// absence must not break the approval path it hangs off.
func (m *CapabilityMinter) MintForUpdate(update *models.UpdateState, closure []capability.ClosureEntry) (*capability.Token, error) {
if !m.Enabled() {
return nil, nil
}
if len(closure) == 0 {
return nil, fmt.Errorf("capability: refusing to mint a token over an empty closure")
}
operation := "upgrade"
if update.CurrentVersion == "" {
operation = "install"
}
now := time.Now().UTC()
token := &capability.Token{
Version: capability.Version,
TokenID: uuid.New().String(),
AgentID: update.AgentID.String(),
PackageType: update.PackageType,
Operation: operation,
Closure: closure,
IssuedAt: now.Unix(),
NotBefore: now.Unix(),
ExpiresAt: now.Add(m.ttl).Unix(),
}
if err := m.signing.SignCapabilityToken(token); err != nil {
return nil, fmt.Errorf("capability: sign failed: %w", err)
}
if err := m.tokens.Insert(token, update.ID); err != nil {
return nil, fmt.Errorf("capability: persist failed: %w", err)
}
log.Printf("[SECURITY] [server] [capability] token_minted token_id=%s agent_id=%s package_type=%s operation=%s closure_size=%d key_id=%s expires_at=%d",
token.TokenID, token.AgentID, token.PackageType, token.Operation, len(token.Closure), token.KeyID, token.ExpiresAt)
return token, nil
}

View file

@ -12,6 +12,7 @@ import (
"runtime"
"time"
"github.com/Fimeg/RedFlag/server/internal/capability"
"github.com/Fimeg/RedFlag/server/internal/database/queries"
"github.com/Fimeg/RedFlag/server/internal/models"
"github.com/google/uuid"
@ -393,6 +394,18 @@ func (s *SigningService) SignCommand(cmd *models.AgentCommand) (string, error) {
return hex.EncodeToString(signature), nil
}
// SignCapabilityToken signs a supply-chain capability token in place, setting its
// KeyID and Signature over the canonical message. The private key never leaves the
// signing service. The canonical encoding lives in the capability package and is
// byte-identical to the agent mirror and the Rust executor.
func (s *SigningService) SignCapabilityToken(t *capability.Token) error {
if !s.enabled || s.privateKey == nil {
return fmt.Errorf("signing service not initialized with private key")
}
_, err := t.Sign(s.privateKey)
return err
}
// Key rotation is now implemented via the signing_keys table (migration 020).
// Use InitializePrimaryKey() at startup to register the active key.
// Use GetAllActivePublicKeys() to enumerate all active keys for agents during rotation.