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:
parent
f0f18d7320
commit
ea068c2213
16 changed files with 1786 additions and 81 deletions
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
117
agent/internal/capability/token.go
Normal file
117
agent/internal/capability/token.go
Normal 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
|
||||
}
|
||||
63
agent/internal/capability/token_test.go
Normal file
63
agent/internal/capability/token_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
178
agent/internal/supplychain/consumer.go
Normal file
178
agent/internal/supplychain/consumer.go
Normal 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
21
helper/Cargo.toml
Normal 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
630
helper/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"})
|
||||
}
|
||||
|
|
|
|||
117
server/internal/capability/token.go
Normal file
117
server/internal/capability/token.go
Normal 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
|
||||
}
|
||||
63
server/internal/capability/token_test.go
Normal file
63
server/internal/capability/token_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
DROP TABLE IF EXISTS capability_tokens;
|
||||
|
|
@ -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);
|
||||
143
server/internal/database/queries/capability_tokens.go
Normal file
143
server/internal/database/queries/capability_tokens.go
Normal 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
|
||||
}
|
||||
97
server/internal/services/capability_minter.go
Normal file
97
server/internal/services/capability_minter.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue