Watch
1
0
Fork
You've already forked RedFlag
0

feat: standalone local authority — helper mint mode + local approval flow

FEAT-003 core (design: RAF/security/06-standalone-authority.md, approved
2026-06-10). On a host with no fleet server, the trust boundary preserved is
root-vs-unprivileged: a root-owned 0600 Ed25519 key signs capability tokens
via a new privileged helper invocation; redflag-local membership lets you
request a mint, never perform one.

Helper gains the mint subcommand: validates forward-only ops, mintable-type
allowlist (no agent-self), host agent-id bind, closure shape (64-hex sha256
fail-closed), hard-coded 15-minute gate-evidence freshness with future-dating
rejection, override-reason requirement for vulnerable/unreachable/overridden
verdicts, duplicate request_id dedupe, journal-before-emission. --init-key /
--retire-key manage the authority lifecycle (retire = the fleet-join swap).
Deny taxonomy 22-25. Round-trip test proves a minted token passes the execute
path's own verification and parses as the wire CapabilityToken.

Agent gains POST /v1/actions/approve-update (single-flight, 409/503 mapping):
fleet-mode refusal, dnf/apt dry-run closure resolve + hash pin (no pin, no
mint), best-effort OSV.dev closure check with honest verdicts (unreachable is
never silent-clear), mint via sudo systemd-run mirroring the execute grant,
then the unchanged verify+execute path. Provisioning script sets up the
journal dir (root:redflag-local 2750 setgid), mint request dir, key init, and
the pinned mint sudoers line.
This commit is contained in:
Fimeg 2026-06-10 09:04:06 -04:00
commit e8b7742422
10 changed files with 1442 additions and 22 deletions

View file

@ -2,6 +2,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
@ -202,10 +203,42 @@ func RunPollingLoop(loopCtx *LoopContext) error {
return nil
}
// FEAT-003 write path: standalone local approval. Single-flight; the
// callback decodes the body, runs gates → mint → execute, and translates
// typed failures into localapi sentinels for honest HTTP codes.
var approveInFlight atomic.Bool
approveUpdate := func(body []byte) (interface{}, error) {
if !approveInFlight.CompareAndSwap(false, true) {
return nil, fmt.Errorf("%w: approval already in flight", localapi.ErrApprovalConflict)
}
defer approveInFlight.Store(false)
var req handlers.LocalApproveRequest
if err := json.Unmarshal(body, &req); err != nil {
return nil, fmt.Errorf("invalid approval request: %w", err)
}
result, err := handlers.HandleLocalApprove(ctx.Ctx, ctx.Cfg, req)
switch {
case err == nil:
return result, nil
case errors.Is(err, handlers.ErrApprovalFleetMode),
errors.Is(err, handlers.ErrApprovalBlocked),
errors.Is(err, supplychain.ErrMintGateRefused),
errors.Is(err, supplychain.ErrMintStale),
errors.Is(err, supplychain.ErrMintDuplicate):
return nil, fmt.Errorf("%w: %v", localapi.ErrApprovalConflict, err)
case errors.Is(err, supplychain.ErrMintNoAuthority):
return nil, fmt.Errorf("%w: %v", localapi.ErrApprovalUnavailable, err)
default:
return nil, err
}
}
localAPIServer, err := localapi.Start(localapi.Options{
Config: ctx.Cfg,
DesktopProvider: ctx.DesktopManager,
TriggerScan: triggerScan,
ApproveUpdate: approveUpdate,
})
if err != nil {
log.Printf("[ERROR] [agent] [localapi] start_failed error=%v", err)

View file

@ -0,0 +1,160 @@
package handlers
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/installer"
"github.com/Fimeg/RedFlag/agent/internal/supplychain"
)
// Standalone local approval (FEAT-003). The full gate pipeline runs locally:
// dry-run closure resolve → hash pin → OSV best-effort → mint request → the
// privileged helper signs with the root-owned authority → the normal execute
// path verifies and installs. Fleet mode refuses: the server is the sole
// authority there, and this path never weakens that.
// Typed failures the local API maps to honest HTTP codes.
var (
// ErrApprovalFleetMode: this host is registered to a fleet server.
ErrApprovalFleetMode = errors.New("local approval refused: fleet mode — approve via the server")
// ErrApprovalBlocked: a gate refused and no override reason was supplied.
ErrApprovalBlocked = errors.New("approval blocked by supply-chain gate")
)
// LocalApproveRequest is the tray's approval submission.
type LocalApproveRequest struct {
PackageType string `json:"package_type"`
PackageName string `json:"package_name"`
AvailableVersion string `json:"available_version"`
Operator string `json:"operator"`
OverrideReason string `json:"override_reason"`
}
// LocalApproveResult carries the gate verdicts plus the execution outcome so
// the tray can render exactly what was checked and what happened.
type LocalApproveResult struct {
RequestID string `json:"request_id"`
OSVStatus string `json:"osv_status"`
OSVVulnCount int `json:"osv_vuln_count"`
ClosureSize int `json:"closure_size"`
Policy *supplychain.PolicyResult `json:"policy"`
}
// gatedLocalApproval limits local approval to the ecosystems whose closures
// the agent can resolve and pin from signed repo metadata. Mirrors the
// capability-gate set, not the full installer set.
func gatedLocalApproval(packageType string) bool {
return packageType == "dnf" || packageType == "apt"
}
// HandleLocalApprove runs the standalone approval flow end to end. Synchronous:
// the caller holds the local socket connection until the helper's verdict (and,
// on success, the install) completes.
func HandleLocalApprove(ctx context.Context, cfg *config.Config, req LocalApproveRequest) (*LocalApproveResult, error) {
if cfg.IsRegistered() {
return nil, ErrApprovalFleetMode
}
if req.PackageType == "" || req.PackageName == "" {
return nil, fmt.Errorf("package_type and package_name are required")
}
if !gatedLocalApproval(req.PackageType) {
return nil, fmt.Errorf("local approval not supported for package_type=%s (dnf|apt only)", req.PackageType)
}
if req.Operator == "" {
return nil, fmt.Errorf("operator is required")
}
log.Printf("[INFO] [agent] [localapprove] approval_started pkg=%s type=%s version=%s operator=%s",
req.PackageName, req.PackageType, req.AvailableVersion, req.Operator)
// Resolve the closure exactly as the fleet dry-run path does.
inst, err := installer.InstallerFactory(req.PackageType, cfg.ServerURL)
if err != nil {
return nil, fmt.Errorf("[ERROR] [agent] [installer] factory_failed type=%s error=%w", req.PackageType, err)
}
if !inst.IsAvailable() {
return nil, fmt.Errorf("[ERROR] [agent] [installer] not_available type=%s", req.PackageType)
}
dryRun, err := inst.DryRun(req.PackageName, req.AvailableVersion)
if err != nil {
return nil, fmt.Errorf("dry run failed: %w", err)
}
resolvedAt := time.Now().UTC()
closureItems := resolveClosureHashes(req.PackageType, req.PackageName, dryRun.Dependencies)
if len(closureItems) == 0 {
// No pin, no mint. The execute path could not verify anything.
return nil, fmt.Errorf("closure hash resolution failed for %s — refusing unpinned approval", req.PackageName)
}
closure := make([]capability.ClosureEntry, len(closureItems))
pkgs := make([]supplychain.PkgVersion, len(closureItems))
for i, c := range closureItems {
closure[i] = capability.ClosureEntry{
Name: c.Name,
Version: c.Version,
SHA256: c.SHA256,
Source: c.Source,
}
pkgs[i] = supplychain.PkgVersion{Name: c.Name, Version: c.Version}
}
// OSV best-effort with honest verdict: vulnerable or unreachable proceeds
// only over an explicit operator reason, refused here before the helper is
// ever invoked (the helper re-enforces — defense in depth).
osvStatus, vulnCount := supplychain.CheckClosureOSV(ctx, req.PackageType, pkgs)
osvCheckedAt := time.Now().UTC()
if osvStatus != supplychain.OSVStatusClear && req.OverrideReason == "" {
log.Printf("[SECURITY] [agent] [localapprove] approval_blocked pkg=%s osv_status=%s vulns=%d",
req.PackageName, osvStatus, vulnCount)
return nil, fmt.Errorf("%w: osv_status=%s vulns=%d — override requires an explicit reason",
ErrApprovalBlocked, osvStatus, vulnCount)
}
mintReq := &supplychain.MintRequest{
AgentID: cfg.AgentID.String(),
PackageType: req.PackageType,
Operation: "install",
Closure: closure,
GateEvidence: supplychain.GateEvidence{
ResolvedAt: resolvedAt.Unix(),
OSVCheckedAt: osvCheckedAt.Unix(),
OSVStatus: osvStatus,
OSVVulnCount: vulnCount,
// Standalone has no registry age data for dnf/apt (matches the
// fleet age gate's ecosystem scope) and no local version
// first-seen tracking yet — journaled honestly as not_applicable.
AgeGate: "not_applicable",
SoakGate: "not_applicable",
Operator: req.Operator,
OverrideReason: req.OverrideReason,
},
}
executor := supplychain.NewExecutor("")
token, err := executor.Mint(ctx, mintReq)
if err != nil {
return nil, err
}
policy, err := executor.Execute(ctx, token)
if err != nil {
return nil, fmt.Errorf("execute after mint failed (token_id=%s): %w", token.TokenID, err)
}
log.Printf("[INFO] [agent] [localapprove] approval_completed pkg=%s decision=%s exit=%d",
req.PackageName, policy.Decision, policy.ExitCode)
return &LocalApproveResult{
RequestID: mintReq.RequestID,
OSVStatus: osvStatus,
OSVVulnCount: vulnCount,
ClosureSize: len(closure),
Policy: policy,
}, nil
}

View file

@ -0,0 +1,100 @@
package localapi
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Fimeg/RedFlag/agent/internal/cache"
)
func approveHandler(t *testing.T, approve func(body []byte) (interface{}, error)) http.Handler {
t.Helper()
return newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
}, ApproveUpdate: approve})
}
func postApprove(t *testing.T, handler http.Handler, body string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/actions/approve-update", strings.NewReader(body)))
return rec
}
func TestApproveUpdateSuccess(t *testing.T) {
var gotBody string
handler := approveHandler(t, func(body []byte) (interface{}, error) {
gotBody = string(body)
return map[string]interface{}{"request_id": "req-9", "osv_status": "clear"}, nil
})
rec := postApprove(t, handler, `{"package_type":"dnf","package_name":"hyprutils"}`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(gotBody, "hyprutils") {
t.Fatalf("callback body = %q, want raw request body", gotBody)
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp["request_id"] != "req-9" {
t.Fatalf("request_id = %v, want req-9", resp["request_id"])
}
}
func TestApproveUpdateConflictIs409(t *testing.T) {
handler := approveHandler(t, func([]byte) (interface{}, error) {
return nil, fmt.Errorf("%w: gate refused", ErrApprovalConflict)
})
rec := postApprove(t, handler, `{}`)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, want 409", rec.Code)
}
if !strings.Contains(rec.Body.String(), "gate refused") {
t.Fatalf("body = %s, want gate refusal detail", rec.Body.String())
}
}
func TestApproveUpdateNoAuthorityIs503(t *testing.T) {
handler := approveHandler(t, func([]byte) (interface{}, error) {
return nil, fmt.Errorf("%w: no local authority", ErrApprovalUnavailable)
})
rec := postApprove(t, handler, `{}`)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
func TestApproveUpdateNilCallbackIs503(t *testing.T) {
handler := approveHandler(t, nil)
rec := postApprove(t, handler, `{}`)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
func TestApproveUpdateInternalErrorIs500(t *testing.T) {
handler := approveHandler(t, func([]byte) (interface{}, error) {
return nil, errors.New("dry run exploded")
})
rec := postApprove(t, handler, `{}`)
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want 500", rec.Code)
}
}
func TestApproveUpdateRejectsGet(t *testing.T) {
handler := approveHandler(t, func([]byte) (interface{}, error) { return nil, nil })
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/actions/approve-update", nil))
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", rec.Code)
}
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
@ -27,6 +28,16 @@ const (
// triggered scan is still running. The handler maps it to 409 Conflict.
var ErrScanInFlight = errors.New("localapi: scan already in flight")
// Approval callback sentinels. The loop wiring translates handler/supplychain
// errors into these so this package stays decoupled from the approval stack.
var (
// ErrApprovalConflict → 409: gate refused, fleet mode, duplicate, or an
// approval already in flight. The wrapped message carries the specifics.
ErrApprovalConflict = errors.New("localapi: approval conflict")
// ErrApprovalUnavailable → 503: no local authority on this host.
ErrApprovalUnavailable = errors.New("localapi: approval unavailable")
)
// Options configures the local read-only API. Group, socket, and pipe defaults
// are platform-specific and enforced by the listener implementation.
type Options struct {
@ -43,6 +54,11 @@ type Options struct {
// group boundary on the socket/pipe — anyone who can connect may trigger.
// Nil disables the endpoint (503). Return ErrScanInFlight to signal 409.
TriggerScan func(source string) error
// ApproveUpdate runs the standalone approval flow (FEAT-003): gates →
// mint → execute. Synchronous; the response carries the full verdict.
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
// ErrApprovalUnavailable to control the HTTP status.
ApproveUpdate func(body []byte) (interface{}, error)
}
// Server owns the local API listener and HTTP server.
@ -66,7 +82,7 @@ func Start(opts Options) (*Server, error) {
opts.RequestLog = log.Printf
}
handler := newHandler(opts.Config, opts.LoadCache, opts.DesktopProvider, opts.TriggerScan)
handler := newHandler(opts)
httpServer := &http.Server{Handler: handler}
listener := opts.ListenerOverride
@ -109,10 +125,11 @@ func (s *Server) Stop() {
}
type handler struct {
cfg *config.Config
loadCache func() (*cache.LocalCache, error)
desktop DesktopStatusProvider
triggerScan func(source string) error
cfg *config.Config
loadCache func() (*cache.LocalCache, error)
desktop DesktopStatusProvider
triggerScan func(source string) error
approveUpdate func(body []byte) (interface{}, error)
}
// DesktopStatusProvider allows the desktop manager to report its status.
@ -154,9 +171,9 @@ type DesktopStatus struct {
// DesktopHealthRequest is sent by the desktop app to report its health.
type DesktopHealthRequest struct {
Version string `json:"version"`
Uptime int64 `json:"uptime_seconds"`
WindowOpen bool `json:"window_open"`
Version string `json:"version"`
Uptime int64 `json:"uptime_seconds"`
WindowOpen bool `json:"window_open"`
}
type ScanResponse struct {
@ -172,8 +189,17 @@ type TokenResponse struct {
Capabilities cache.CapabilityTokenState `json:"capabilities"`
}
func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error), desktop DesktopStatusProvider, triggerScan func(source string) error) http.Handler {
h := &handler{cfg: cfg, loadCache: loadCache, desktop: desktop, triggerScan: triggerScan}
func newHandler(opts Options) http.Handler {
h := &handler{
cfg: opts.Config,
loadCache: opts.LoadCache,
desktop: opts.DesktopProvider,
triggerScan: opts.TriggerScan,
approveUpdate: opts.ApproveUpdate,
}
if h.loadCache == nil {
h.loadCache = cache.Load
}
mux := http.NewServeMux()
mux.HandleFunc("/v1/identity", h.identity)
mux.HandleFunc("/v1/status", h.status)
@ -182,6 +208,7 @@ func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error),
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
mux.HandleFunc("/v1/desktop", h.desktopHealth)
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
mux.HandleFunc("/v1/actions/approve-update", h.approveUpdateAction)
return mux
}
@ -341,6 +368,51 @@ func (h *handler) triggerScanAction(w http.ResponseWriter, r *http.Request) {
writeJSONBody(w, map[string]interface{}{"accepted": true})
}
// approveUpdateAction handles POST /v1/actions/approve-update — the standalone
// approval flow (FEAT-003). Authorization is the OS-local group ACL on the
// socket/pipe; the real judgment lives in the gates and the root-owned mint
// key. Synchronous: the connection is held until verdict + install complete.
func (h *handler) approveUpdateAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if h.approveUpdate == nil {
log.Printf("[WARNING] [agent] [localapi] approve_unavailable")
http.Error(w, "local approval unavailable", http.StatusServiceUnavailable)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "request read failed", http.StatusBadRequest)
return
}
result, err := h.approveUpdate(body)
switch {
case err == nil:
log.Printf("[INFO] [agent] [localapi] approve_completed")
writeJSON(w, result)
case errors.Is(err, ErrApprovalConflict):
log.Printf("[SECURITY] [agent] [localapi] approve_conflict error=%v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
case errors.Is(err, ErrApprovalUnavailable):
log.Printf("[WARNING] [agent] [localapi] approve_unavailable error=%v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
default:
log.Printf("[ERROR] [agent] [localapi] approve_failed error=%v", err)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
}
}
func (h *handler) load(w http.ResponseWriter) (*cache.LocalCache, bool) {
localCache, err := h.loadCache()
if err != nil {

View file

@ -17,9 +17,9 @@ import (
func TestIdentityRedactsSecrets(t *testing.T) {
cfg := testConfig(t)
handler := newHandler(cfg, func() (*cache.LocalCache, error) {
handler := newHandler(Options{Config: cfg, LoadCache: func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
}, nil, nil)
}})
req := httptest.NewRequest(http.MethodGet, "/v1/identity", nil)
rec := httptest.NewRecorder()
@ -86,9 +86,9 @@ func TestStatusAndPackagesReadLocalCache(t *testing.T) {
LastProcessedCount: 1,
},
}
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
return localCache, nil
}, nil, nil)
}})
var status StatusResponse
requestJSON(t, handler, http.MethodGet, "/v1/status", &status)
@ -116,9 +116,9 @@ func TestStatusAndPackagesReadLocalCache(t *testing.T) {
}
func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
return nil, errors.New("cannot read cache")
}, nil, nil)
}})
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
rec := httptest.NewRecorder()
@ -130,9 +130,9 @@ func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
}
func TestOnlyGetMethodsAllowed(t *testing.T) {
handler := newHandler(testConfig(t), func() (*cache.LocalCache, error) {
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
}, nil, nil)
}})
req := httptest.NewRequest(http.MethodPost, "/v1/status", nil)
rec := httptest.NewRecorder()

View file

@ -12,9 +12,9 @@ import (
func triggerHandler(t *testing.T, trigger func(source string) error) http.Handler {
t.Helper()
return newHandler(testConfig(t), func() (*cache.LocalCache, error) {
return newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
return &cache.LocalCache{}, nil
}, nil, trigger)
}, TriggerScan: trigger})
}
func postTrigger(t *testing.T, handler http.Handler) *httptest.ResponseRecorder {

View file

@ -0,0 +1,155 @@
package supplychain
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/Fimeg/RedFlag/agent/internal/capability"
"github.com/Fimeg/RedFlag/agent/internal/constants"
"github.com/gofrs/uuid/v5"
)
// Standalone local authority — mint invocation (FEAT-003, design of record
// RAF/security/06-standalone-authority.md). The unprivileged agent assembles a
// mint request (closure + gate evidence) and asks the privileged helper to
// sign a capability token with the root-owned local authority key. The helper
// re-validates everything; this file is transport, not judgment.
const (
mintRequestDir = "mint"
mintTimeout = 60 * time.Second
)
// Helper mint-mode exit codes (helper/src/main.rs deny taxonomy).
const (
mintExitGate = 22
mintExitStale = 23
mintExitKey = 24
mintExitDuplicate = 25
)
// Typed mint failures so the local API can map them to honest HTTP codes.
var (
ErrMintGateRefused = errors.New("mint refused by gate verdict")
ErrMintStale = errors.New("mint refused: gate evidence stale")
ErrMintNoAuthority = errors.New("mint refused: no local authority key (fleet mode or unprovisioned)")
ErrMintDuplicate = errors.New("mint refused: request already minted")
)
// GateEvidence is the agent's gate verdict bundle. JSON contract with the
// helper's MintRequest parsing — keep aligned with helper/src/main.rs.
type GateEvidence struct {
ResolvedAt int64 `json:"resolved_at"`
OSVCheckedAt int64 `json:"osv_checked_at"`
OSVStatus string `json:"osv_status"`
OSVVulnCount int `json:"osv_vuln_count"`
AgeGate string `json:"age_gate"`
SoakGate string `json:"soak_gate"`
Operator string `json:"operator"`
OverrideReason string `json:"override_reason"`
}
// MintRequest is the file handed to `redflag-helper mint --request-file`.
type MintRequest struct {
Version int `json:"version"`
RequestID string `json:"request_id"`
AgentID string `json:"agent_id"`
PackageType string `json:"package_type"`
Operation string `json:"operation"`
Closure []capability.ClosureEntry `json:"closure"`
GateEvidence GateEvidence `json:"gate_evidence"`
}
// Mint invokes the privileged helper's mint mode and returns the signed token.
// The request and token travel as files under the agent data dir, mirroring the
// execute path's --token-file pattern (and the same sudoers pinning style).
func (e *Executor) Mint(ctx context.Context, req *MintRequest) (*capability.Token, error) {
if req.RequestID == "" {
id, err := uuid.NewV4()
if err != nil {
return nil, fmt.Errorf("generate request id: %w", err)
}
req.RequestID = id.String()
}
req.Version = capability.Version
payload, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal mint request: %w", err)
}
agentDir := filepath.Join(constants.GetBaseDir(), constants.AgentDir)
reqDir := filepath.Join(agentDir, mintRequestDir)
tokDir := filepath.Join(agentDir, tokenDir)
if err := os.MkdirAll(reqDir, 0o750); err != nil {
return nil, fmt.Errorf("create mint request dir: %w", err)
}
if err := os.MkdirAll(tokDir, 0o700); err != nil {
return nil, fmt.Errorf("create token dir: %w", err)
}
fname, err := safeTokenFilename(req.RequestID)
if err != nil {
return nil, err
}
reqPath := filepath.Join(reqDir, fname+".json")
tokPath := filepath.Join(tokDir, fname+".minted")
if err := os.WriteFile(reqPath, payload, 0o640); err != nil {
return nil, fmt.Errorf("write mint request: %w", err)
}
defer os.Remove(reqPath)
defer os.Remove(tokPath)
runCtx, cancel := context.WithTimeout(ctx, mintTimeout)
defer cancel()
args := []string{"systemd-run", "--wait",
"--property=ProtectSystem=no",
"--", e.BinaryPath, "mint", "--request-file", reqPath, "--token-out", tokPath}
cmd := exec.CommandContext(runCtx, "sudo", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
runErr := cmd.Run()
if runErr != nil {
exitCode := -1
var exitErr *exec.ExitError
if errors.As(runErr, &exitErr) {
exitCode = exitErr.ExitCode()
}
log.Printf("[SECURITY] [agent] [supplychain] mint_denied request_id=%s exit=%d stderr=%s",
req.RequestID, exitCode, stderr.String())
switch exitCode {
case mintExitGate:
return nil, fmt.Errorf("%w (request_id=%s)", ErrMintGateRefused, req.RequestID)
case mintExitStale:
return nil, fmt.Errorf("%w (request_id=%s)", ErrMintStale, req.RequestID)
case mintExitKey:
return nil, fmt.Errorf("%w (request_id=%s)", ErrMintNoAuthority, req.RequestID)
case mintExitDuplicate:
return nil, fmt.Errorf("%w (request_id=%s)", ErrMintDuplicate, req.RequestID)
default:
return nil, fmt.Errorf("mint failed exit=%d request_id=%s: %w", exitCode, req.RequestID, runErr)
}
}
raw, err := os.ReadFile(tokPath)
if err != nil {
return nil, fmt.Errorf("read minted token: %w", err)
}
var token capability.Token
if err := json.Unmarshal(raw, &token); err != nil {
return nil, fmt.Errorf("parse minted token: %w", err)
}
log.Printf("[SECURITY] [agent] [supplychain] mint_succeeded request_id=%s token_id=%s package_type=%s closure_size=%d",
req.RequestID, token.TokenID, token.PackageType, len(token.Closure))
return &token, nil
}

View file

@ -0,0 +1,142 @@
package supplychain
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
)
// Standalone-mode OSV check (FEAT-003). In fleet mode OSV runs server-side at
// detection; a standalone host has no server, so the unprivileged agent queries
// OSV.dev directly before requesting a mint. Best-effort with an honest verdict
// (Casey, 2026-06-10): vulnerable is a full stop, unreachable is surfaced as
// "unverified" and mints only over an explicit operator override reason. The
// verdict strings below are the gate_evidence contract with the helper's mint
// mode — keep them aligned with helper/src/main.rs.
const (
OSVStatusClear = "clear"
OSVStatusVulnerable = "vulnerable"
OSVStatusUnreachable = "unreachable"
)
// osvBatchLimit mirrors the server's batch sizing for /v1/querybatch.
const osvBatchLimit = 100
var osvHTTPClient = &http.Client{Timeout: 15 * time.Second}
// PkgVersion is the minimal identity OSV needs.
type PkgVersion struct {
Name string
Version string
}
// OSVEcosystem maps RedFlag package types to OSV.dev ecosystem names. Mirrors
// services.EcosystemFromPackageType on the server — keep in sync.
func OSVEcosystem(pkgType string) string {
switch pkgType {
case "npm":
return "npm"
case "pypi", "pip":
return "PyPI"
case "apt":
return "Debian"
case "dnf":
return "AlmaLinux"
}
return pkgType
}
type osvQuery struct {
Package struct {
Name string `json:"name"`
Ecosystem string `json:"ecosystem"`
} `json:"package"`
Version string `json:"version"`
}
type osvBatchRequest struct {
Queries []osvQuery `json:"queries"`
}
type osvBatchResponse struct {
Results []struct {
Vulns []struct {
ID string `json:"id"`
} `json:"vulns"`
} `json:"results"`
}
// CheckClosureOSV queries OSV.dev for every package in the closure. Returns the
// gate-evidence verdict and the total vulnerability count. Any transport or
// decode failure returns OSVStatusUnreachable — never a silent clear.
func CheckClosureOSV(ctx context.Context, pkgType string, pkgs []PkgVersion) (string, int) {
ecosystem := OSVEcosystem(pkgType)
vulnCount := 0
for start := 0; start < len(pkgs); start += osvBatchLimit {
end := start + osvBatchLimit
if end > len(pkgs) {
end = len(pkgs)
}
batch := pkgs[start:end]
req := osvBatchRequest{Queries: make([]osvQuery, len(batch))}
for i, p := range batch {
req.Queries[i].Package.Name = p.Name
req.Queries[i].Package.Ecosystem = ecosystem
req.Queries[i].Version = p.Version
}
body, err := json.Marshal(req)
if err != nil {
log.Printf("[ERROR] [agent] [supplychain] osv_marshal_failed error=%v", err)
return OSVStatusUnreachable, 0
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://api.osv.dev/v1/querybatch", bytes.NewReader(body))
if err != nil {
log.Printf("[ERROR] [agent] [supplychain] osv_request_build_failed error=%v", err)
return OSVStatusUnreachable, 0
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := osvHTTPClient.Do(httpReq)
if err != nil {
log.Printf("[WARNING] [agent] [supplychain] osv_unreachable error=%v", err)
return OSVStatusUnreachable, 0
}
func() {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("osv status %d", resp.StatusCode)
return
}
var batchResp osvBatchResponse
if decodeErr := json.NewDecoder(resp.Body).Decode(&batchResp); decodeErr != nil {
err = decodeErr
return
}
for i, r := range batchResp.Results {
if len(r.Vulns) > 0 {
vulnCount += len(r.Vulns)
log.Printf("[SECURITY] [agent] [supplychain] osv_vulns_found pkg=%s version=%s ecosystem=%s count=%d",
batch[i].Name, batch[i].Version, ecosystem, len(r.Vulns))
}
}
}()
if err != nil {
log.Printf("[WARNING] [agent] [supplychain] osv_unreachable error=%v", err)
return OSVStatusUnreachable, 0
}
}
if vulnCount > 0 {
return OSVStatusVulnerable, vulnCount
}
log.Printf("[INFO] [agent] [supplychain] osv_closure_clear pkg_type=%s packages=%d", pkgType, len(pkgs))
return OSVStatusClear, 0
}

View file

@ -18,7 +18,7 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@ -38,6 +38,11 @@ const EXIT_UNSUPPORTED_OP: i32 = 18;
const EXIT_EXEC_FAILED: i32 = 19;
const EXIT_INTERNAL: i32 = 20;
const EXIT_INTEGRITY: i32 = 21; // agent-binary hash mismatch (watchdog mode)
// Mint-mode (standalone authority) deny taxonomy.
const EXIT_MINT_GATE: i32 = 22; // gate verdict refuses (vuln/unverified without reason)
const EXIT_MINT_STALE: i32 = 23; // gate evidence outside the freshness window
const EXIT_MINT_KEY: i32 = 24; // mint key missing/unsafe permissions
const EXIT_MINT_DUPLICATE: i32 = 25; // request_id already minted
// Default on-host locations. All overridable by env so packaging/tests can relocate.
const DEFAULT_KEYRING_DIR: &str = "/etc/redflag/trusted-keys";
@ -57,7 +62,7 @@ const DEFAULT_HELPER_STAGING: &str = "/var/lib/redflag/helper/upgrade-staging.bi
const DEFAULT_HELPER_BINARY: &str = "/usr/local/bin/redflag-helper";
const AGENT_SELF_PACKAGE_TYPE: &str = "agent-self";
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, Serialize, Clone)]
struct ClosureEntry {
name: String,
version: String,
@ -101,6 +106,7 @@ struct PolicyResult {
}
// A deny/fail with its taxonomy code. Carries enough to emit a structured result.
#[derive(Debug)]
struct Denial {
code: i32,
reason: &'static str,
@ -811,6 +817,514 @@ fn run(token_file: Option<&str>, helper_file: Option<&str>) -> Result<PolicyResu
})
}
// ============================================================================
// Mint mode — standalone local authority (RAF/security/06-standalone-authority.md)
//
// On a host with no fleet server, this privileged invocation mints the
// capability token after the unprivileged agent has run the gates (closure
// dry-run resolve, OSV best-effort, age/soak). The boundary enforced here is
// root-vs-unprivileged: the mint key is root-owned 0600, so membership in
// redflag-local lets you *request* a mint, never perform one. Doctrine is
// unchanged: forward-only, signing required, no skip-verification path.
//
// The freshness window is HARD-CODED (no doctrinal knobs): evidence older
// than 15 minutes means the agent re-resolves and re-checks. Vulnerable or
// OSV-unverified closures mint only with an explicit operator override
// reason, and every decision is journaled before the token is emitted.
// ============================================================================
const MINT_EVIDENCE_MAX_AGE_SECS: i64 = 900; // 15 min — fixed by design, not configurable
const MINT_CLOCK_SKEW_SECS: i64 = 60;
const MINT_TOKEN_TTL_SECS: i64 = 600; // matches the 10-minute command validity window
const DEFAULT_MINT_KEY: &str = "/etc/redflag/authority_local.key";
const DEFAULT_MINT_JOURNAL: &str = "/var/lib/redflag/journal/mint.log";
// Package types execute-mode can actually run, minus agent-self: standalone
// self-upgrade is its own future slice, not a side door of the first one.
const MINTABLE_PACKAGE_TYPES: &[&str] = &["apt", "dnf", "npm", "bun", "pip", "docker", "winget"];
#[derive(Debug, Deserialize)]
struct GateEvidence {
resolved_at: i64,
#[serde(default)]
osv_checked_at: i64,
osv_status: String, // "clear" | "vulnerable" | "unreachable"
#[serde(default)]
osv_vuln_count: u32,
age_gate: String, // "pass" | "overridden" | "not_applicable"
soak_gate: String, // "pass" | "overridden" | "not_applicable"
operator: String,
#[serde(default)]
override_reason: String,
}
#[derive(Debug, Deserialize)]
struct MintRequest {
version: u32,
request_id: String,
agent_id: String,
package_type: String,
operation: String,
closure: Vec<ClosureEntry>,
gate_evidence: GateEvidence,
}
// Serialized wire token — must match the CapabilityToken the execute path and
// the Go consumer parse. Field order is irrelevant (JSON), the signature is not.
#[derive(Debug, Serialize)]
struct MintedToken {
version: u32,
token_id: String,
agent_id: String,
key_id: String,
package_type: String,
operation: String,
closure: Vec<ClosureEntry>,
issued_at: i64,
not_before: i64,
expires_at: i64,
signature: String,
}
struct MintPaths {
key_path: PathBuf,
journal_path: PathBuf,
}
impl MintPaths {
fn from_env() -> Self {
MintPaths {
key_path: PathBuf::from(env_or("REDFLAG_MINT_KEY", DEFAULT_MINT_KEY)),
journal_path: PathBuf::from(env_or("REDFLAG_MINT_JOURNAL", DEFAULT_MINT_JOURNAL)),
}
}
}
fn log_mint(msg: &str) {
eprintln!("[SECURITY] [helper] [mint] {}", msg);
}
fn random_bytes(n: usize) -> Result<Vec<u8>, Denial> {
let mut buf = vec![0u8; n];
let mut f = fs::File::open("/dev/urandom")
.map_err(|e| Denial::new(EXIT_INTERNAL, "urandom_open_failed", e.to_string()))?;
f.read_exact(&mut buf)
.map_err(|e| Denial::new(EXIT_INTERNAL, "urandom_read_failed", e.to_string()))?;
Ok(buf)
}
fn new_uuid_v4() -> Result<String, Denial> {
let mut b = random_bytes(16)?;
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
let h = hex::encode(&b);
Ok(format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32]))
}
// Load the root-owned mint key (hex-encoded 32-byte Ed25519 seed). Refuses a
// key readable by group/other — a loose key is treated as no key at all.
fn load_mint_key(path: &Path) -> Result<SigningKey, Denial> {
let meta = fs::metadata(path).map_err(|e| {
Denial::new(EXIT_MINT_KEY, "mint_key_unavailable", format!("{}: {}", path.display(), e))
})?;
let mode = meta.permissions().mode();
if mode & 0o077 != 0 {
return Err(Denial::new(
EXIT_MINT_KEY,
"mint_key_permissions_unsafe",
format!("{} mode={:o} — must not be group/other accessible", path.display(), mode & 0o777),
));
}
let raw = fs::read_to_string(path)
.map_err(|e| Denial::new(EXIT_MINT_KEY, "mint_key_read_failed", format!("{}: {}", path.display(), e)))?;
let bytes = hex::decode(raw.trim())
.map_err(|e| Denial::new(EXIT_MINT_KEY, "mint_key_bad_hex", e.to_string()))?;
let seed: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| Denial::new(EXIT_MINT_KEY, "mint_key_bad_len", format!("len={}", bytes.len())))?;
Ok(SigningKey::from_bytes(&seed))
}
fn validate_mint_request(req: &MintRequest, now: i64) -> Result<(), Denial> {
if req.version != SUPPORTED_TOKEN_VERSION {
return Err(Denial::new(EXIT_VERSION, "mint_unsupported_version", format!("version={}", req.version)));
}
match req.operation.as_str() {
"install" | "upgrade" => {}
other => {
return Err(Denial::new(
EXIT_UNSUPPORTED_OP,
"mint_operation_not_allowed",
format!("operation={} (forward-only: install|upgrade)", other),
))
}
}
if !MINTABLE_PACKAGE_TYPES.contains(&req.package_type.as_str()) {
return Err(Denial::new(
EXIT_UNSUPPORTED_OP,
"mint_package_type_not_supported",
format!("package_type={}", req.package_type),
));
}
if req.request_id.trim().is_empty() {
return Err(Denial::new(EXIT_BAD_TOKEN, "mint_request_id_empty", ""));
}
// Bind to this host's independently-read identity, same as execute mode.
let local = local_agent_id()?;
if local != req.agent_id {
return Err(Denial::new(
EXIT_AGENT_MISMATCH,
"mint_agent_id_mismatch",
format!("request_agent_id={} host_agent_id={}", req.agent_id, local),
));
}
// Closure shape: fail-closed on anything the execute path could not verify.
if req.closure.is_empty() {
return Err(Denial::new(EXIT_ARTIFACT, "mint_closure_empty", ""));
}
for e in &req.closure {
if e.name.trim().is_empty() || e.version.trim().is_empty() {
return Err(Denial::new(EXIT_ARTIFACT, "mint_closure_entry_incomplete", format!("name={:?}", e.name)));
}
let sha = e.sha256.trim();
if sha.len() != 64 || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(Denial::new(
EXIT_ARTIFACT,
"mint_closure_hash_invalid",
format!("{}@{} sha256={:?}", e.name, e.version, e.sha256),
));
}
}
let ev = &req.gate_evidence;
if ev.operator.trim().is_empty() {
return Err(Denial::new(EXIT_MINT_GATE, "mint_operator_missing", ""));
}
// Evidence freshness — hard 15-minute window, future-dating rejected.
let age = now - ev.resolved_at;
if age > MINT_EVIDENCE_MAX_AGE_SECS || age < -MINT_CLOCK_SKEW_SECS {
return Err(Denial::new(
EXIT_MINT_STALE,
"mint_evidence_stale",
format!("resolved_at={} now={} window_secs={}", ev.resolved_at, now, MINT_EVIDENCE_MAX_AGE_SECS),
));
}
if ev.osv_status == "clear" {
let osv_age = now - ev.osv_checked_at;
if osv_age > MINT_EVIDENCE_MAX_AGE_SECS || osv_age < -MINT_CLOCK_SKEW_SECS {
return Err(Denial::new(
EXIT_MINT_STALE,
"mint_osv_evidence_stale",
format!("osv_checked_at={} now={}", ev.osv_checked_at, now),
));
}
}
// Gate verdicts. Vuln or unverified mints only over an explicit, journaled
// operator reason — the standalone mirror of the fleet 409 + override path.
let has_reason = !ev.override_reason.trim().is_empty();
match ev.osv_status.as_str() {
"clear" => {}
"vulnerable" | "unreachable" => {
if !has_reason {
return Err(Denial::new(
EXIT_MINT_GATE,
"mint_gate_requires_override_reason",
format!("osv_status={} vuln_count={}", ev.osv_status, ev.osv_vuln_count),
));
}
}
other => {
return Err(Denial::new(EXIT_MINT_GATE, "mint_osv_status_unknown", format!("osv_status={}", other)));
}
}
for (gate, verdict) in [("age_gate", ev.age_gate.as_str()), ("soak_gate", ev.soak_gate.as_str())] {
match verdict {
"pass" | "not_applicable" => {}
"overridden" => {
if !has_reason {
return Err(Denial::new(
EXIT_MINT_GATE,
"mint_gate_requires_override_reason",
format!("{}=overridden", gate),
));
}
}
other => {
return Err(Denial::new(EXIT_MINT_GATE, "mint_gate_verdict_unknown", format!("{}={}", gate, other)));
}
}
}
Ok(())
}
// Duplicate request_id guard: without it an agent retry would mint two live
// tokens for one approval — two executable authorizations. Journal lines carry
// request_id=<id>, so the journal doubles as the dedupe index.
fn mint_journal_has_request(journal: &Path, request_id: &str) -> bool {
let needle = format!("\"request_id\":\"{}\"", request_id);
match fs::read_to_string(journal) {
Ok(contents) => contents.lines().any(|l| l.contains(&needle)),
Err(_) => false,
}
}
// Journal before token emission: audit precedes authority. Append-only 0640 —
// install provisioning makes the journal dir setgid root:redflag-local, so the
// group inherits read access without the key dir ever loosening.
fn mint_journal_append(journal: &Path, entry: &serde_json::Value) -> Result<(), Denial> {
if let Some(parent) = journal.parent() {
fs::create_dir_all(parent).map_err(|e| {
Denial::new(EXIT_INTERNAL, "mint_journal_dir_failed", format!("{}: {}", parent.display(), e))
})?;
}
let line = format!("{}\n", entry);
let mut f = fs::OpenOptions::new()
.append(true)
.create(true)
.mode(0o640)
.open(journal)
.map_err(|e| Denial::new(EXIT_INTERNAL, "mint_journal_open_failed", format!("{}: {}", journal.display(), e)))?;
std::io::Write::write_all(&mut f, line.as_bytes())
.map_err(|e| Denial::new(EXIT_INTERNAL, "mint_journal_write_failed", format!("{}: {}", journal.display(), e)))
}
fn run_mint(req: &MintRequest, paths: &MintPaths, now: i64) -> Result<MintedToken, Denial> {
validate_mint_request(req, now)?;
if mint_journal_has_request(&paths.journal_path, &req.request_id) {
return Err(Denial::new(
EXIT_MINT_DUPLICATE,
"mint_request_already_minted",
format!("request_id={}", req.request_id),
));
}
let signing_key = load_mint_key(&paths.key_path)?;
let verifying_key = signing_key.verifying_key();
let key_id = key_id_for(verifying_key.as_bytes());
let token_id = new_uuid_v4()?;
let ch = closure_hash(&req.closure);
let mut token = MintedToken {
version: SUPPORTED_TOKEN_VERSION,
token_id: token_id.clone(),
agent_id: req.agent_id.clone(),
key_id: key_id.clone(),
package_type: req.package_type.clone(),
operation: req.operation.clone(),
closure: req.closure.clone(),
issued_at: now,
not_before: now - MINT_CLOCK_SKEW_SECS,
expires_at: now + MINT_TOKEN_TTL_SECS,
signature: String::new(),
};
let msg = format!(
"{}:{}:{}:{}:{}:{}",
token.agent_id, token.token_id, token.operation, token.package_type, ch, token.expires_at
);
token.signature = hex::encode(signing_key.sign(msg.as_bytes()).to_bytes());
let ev = &req.gate_evidence;
mint_journal_append(
&paths.journal_path,
&serde_json::json!({
"ts": now,
"event": "mint",
"request_id": req.request_id,
"token_id": token_id,
"agent_id": req.agent_id,
"package_type": req.package_type,
"operation": req.operation,
"closure_size": req.closure.len(),
"closure_hash": ch,
"osv_status": ev.osv_status,
"osv_vuln_count": ev.osv_vuln_count,
"age_gate": ev.age_gate,
"soak_gate": ev.soak_gate,
"operator": ev.operator,
"override_reason": ev.override_reason,
"key_id": key_id,
"expires_at": token.expires_at,
}),
)?;
log_mint(&format!(
"minted token_id={} request_id={} package_type={} operation={} closure_size={} osv_status={} operator={}",
token_id, req.request_id, req.package_type, req.operation, req.closure.len(), ev.osv_status, ev.operator
));
Ok(token)
}
// mint --init-key: generate the local authority keypair. Private seed (hex)
// 0600 at the key path; public half installed into the keyring dir as
// authority_local.pub so the execute path trusts what mint signs.
fn run_mint_init_key(paths: &MintPaths) -> Result<(), Denial> {
if paths.key_path.exists() {
return Err(Denial::new(
EXIT_MINT_KEY,
"mint_key_already_exists",
format!("{} — retire it first; refusing to overwrite an authority", paths.key_path.display()),
));
}
let seed_bytes = random_bytes(32)?;
let seed: [u8; 32] = seed_bytes.as_slice().try_into().unwrap();
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let key_id = key_id_for(verifying_key.as_bytes());
if let Some(parent) = paths.key_path.parent() {
fs::create_dir_all(parent).map_err(|e| {
Denial::new(EXIT_INTERNAL, "mint_key_dir_failed", format!("{}: {}", parent.display(), e))
})?;
}
let mut f = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&paths.key_path)
.map_err(|e| Denial::new(EXIT_MINT_KEY, "mint_key_create_failed", format!("{}: {}", paths.key_path.display(), e)))?;
std::io::Write::write_all(&mut f, hex::encode(seed).as_bytes())
.map_err(|e| Denial::new(EXIT_MINT_KEY, "mint_key_write_failed", e.to_string()))?;
let keyring_dir = PathBuf::from(env_or("REDFLAG_HELPER_KEYRING", DEFAULT_KEYRING_DIR));
fs::create_dir_all(&keyring_dir).map_err(|e| {
Denial::new(EXIT_INTERNAL, "keyring_dir_failed", format!("{}: {}", keyring_dir.display(), e))
})?;
let pub_path = keyring_dir.join("authority_local.pub");
fs::write(&pub_path, hex::encode(verifying_key.as_bytes())).map_err(|e| {
Denial::new(EXIT_INTERNAL, "keyring_pub_write_failed", format!("{}: {}", pub_path.display(), e))
})?;
mint_journal_append(
&paths.journal_path,
&serde_json::json!({ "ts": now_unix(), "event": "authority_created", "key_id": key_id }),
)?;
log_mint(&format!("authority_created key_id={} pub={}", key_id, pub_path.display()));
println!("{}", serde_json::json!({ "key_id": key_id, "public_key_file": pub_path.to_string_lossy() }));
Ok(())
}
// mint --retire-key: destroy the local authority (fleet join). Overwrite the
// seed before unlink so the hex isn't left on disk, remove the public half
// from the keyring, journal the retirement.
fn run_mint_retire_key(paths: &MintPaths) -> Result<(), Denial> {
let meta = fs::metadata(&paths.key_path).map_err(|e| {
Denial::new(EXIT_MINT_KEY, "mint_key_unavailable", format!("{}: {}", paths.key_path.display(), e))
})?;
let zeros = vec![b'0'; meta.len() as usize];
fs::write(&paths.key_path, &zeros)
.map_err(|e| Denial::new(EXIT_INTERNAL, "mint_key_scrub_failed", e.to_string()))?;
fs::remove_file(&paths.key_path)
.map_err(|e| Denial::new(EXIT_INTERNAL, "mint_key_remove_failed", e.to_string()))?;
let keyring_dir = PathBuf::from(env_or("REDFLAG_HELPER_KEYRING", DEFAULT_KEYRING_DIR));
let pub_path = keyring_dir.join("authority_local.pub");
if pub_path.exists() {
if let Err(e) = fs::remove_file(&pub_path) {
log_error(&format!("keyring_pub_remove_failed path={} error={}", pub_path.display(), e));
}
}
mint_journal_append(
&paths.journal_path,
&serde_json::json!({ "ts": now_unix(), "event": "authority_retired" }),
)?;
log_mint("authority_retired");
Ok(())
}
// CLI: redflag-helper mint --request-file <p> --token-out <p>
// redflag-helper mint --init-key
// redflag-helper mint --retire-key
fn run_mint_cli(args: &[String]) -> i32 {
let paths = MintPaths::from_env();
match args.first().map(|s| s.as_str()) {
Some("--init-key") => {
return match run_mint_init_key(&paths) {
Ok(()) => EXIT_OK,
Err(d) => {
log_mint(&format!("denied reason={} detail={} exit={}", d.reason, d.detail, d.code));
d.code
}
}
}
Some("--retire-key") => {
return match run_mint_retire_key(&paths) {
Ok(()) => EXIT_OK,
Err(d) => {
log_mint(&format!("denied reason={} detail={} exit={}", d.reason, d.detail, d.code));
d.code
}
}
}
_ => {}
}
let request_file = args
.iter()
.position(|a| a == "--request-file")
.and_then(|i| args.get(i + 1));
let token_out = args.iter().position(|a| a == "--token-out").and_then(|i| args.get(i + 1));
let (request_file, token_out) = match (request_file, token_out) {
(Some(r), Some(t)) => (r, t),
_ => {
log_error("mint usage: redflag-helper mint --request-file <path> --token-out <path> | --init-key | --retire-key");
return EXIT_BAD_TOKEN;
}
};
let req: MintRequest = match fs::read_to_string(request_file)
.map_err(|e| Denial::new(EXIT_BAD_TOKEN, "mint_request_read_failed", format!("{}: {}", request_file, e)))
.and_then(|buf| {
serde_json::from_str(&buf).map_err(|e| Denial::new(EXIT_BAD_TOKEN, "mint_request_parse_failed", e.to_string()))
}) {
Ok(r) => r,
Err(d) => {
log_mint(&format!("denied reason={} detail={} exit={}", d.reason, d.detail, d.code));
return d.code;
}
};
match run_mint(&req, &paths, now_unix()) {
Ok(token) => {
let json = match serde_json::to_string(&token) {
Ok(s) => s,
Err(e) => {
log_error(&format!("mint_token_serialize_failed error={}", e));
return EXIT_INTERNAL;
}
};
// 0644: the tokens dir is agent-owned 0700; the unprivileged agent
// must read the token back to feed the normal consumer path.
match fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o644).open(token_out) {
Ok(mut f) => {
if let Err(e) = std::io::Write::write_all(&mut f, json.as_bytes()) {
log_error(&format!("mint_token_write_failed path={} error={}", token_out, e));
return EXIT_INTERNAL;
}
}
Err(e) => {
log_error(&format!("mint_token_open_failed path={} error={}", token_out, e));
return EXIT_INTERNAL;
}
}
EXIT_OK
}
Err(d) => {
log_mint(&format!(
"denied request_id={} reason={} detail={} exit={}",
req.request_id, d.reason, d.detail, d.code
));
d.code
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -857,6 +1371,175 @@ mod tests {
let b = vec![entry("b", "2", "y"), entry("a", "1", "x")];
assert_eq!(closure_hash(&a), closure_hash(&b));
}
// ---- mint mode ----
const TEST_SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
struct MintFixture {
dir: PathBuf,
paths: MintPaths,
verifying_key: VerifyingKey,
}
impl Drop for MintFixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
fn mint_fixture(name: &str) -> MintFixture {
let dir = std::env::temp_dir().join(format!("redflag-mint-test-{}-{}", name, std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
let seed = [7u8; 32];
let signing_key = SigningKey::from_bytes(&seed);
let key_path = dir.join("authority_local.key");
{
let mut f = fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&key_path).unwrap();
std::io::Write::write_all(&mut f, hex::encode(seed).as_bytes()).unwrap();
}
// Host identity for the agent_id bind check.
std::env::set_var("REDFLAG_AGENT_ID", "agent-123");
MintFixture {
paths: MintPaths { key_path, journal_path: dir.join("mint.log") },
verifying_key: signing_key.verifying_key(),
dir,
}
}
fn good_request(request_id: &str, now: i64) -> MintRequest {
MintRequest {
version: 1,
request_id: request_id.to_string(),
agent_id: "agent-123".to_string(),
package_type: "dnf".to_string(),
operation: "install".to_string(),
closure: vec![ClosureEntry {
name: "hyprutils".into(),
version: "0.2.4-1.fc43".into(),
sha256: TEST_SHA.into(),
source: "registry".into(),
artifact_path: None,
}],
gate_evidence: GateEvidence {
resolved_at: now - 60,
osv_checked_at: now - 60,
osv_status: "clear".into(),
osv_vuln_count: 0,
age_gate: "pass".into(),
soak_gate: "pass".into(),
operator: "casey".into(),
override_reason: String::new(),
},
}
}
// The full loop: mint a token, then verify it with the exact same functions
// the execute path uses. If this passes, a minted token is executable.
#[test]
fn mint_round_trips_through_execute_verification() {
let fx = mint_fixture("roundtrip");
let now = now_unix();
let token = run_mint(&good_request("req-1", now), &fx.paths, now).expect("mint should succeed");
assert_eq!(token.version, SUPPORTED_TOKEN_VERSION);
assert_eq!(token.key_id, key_id_for(fx.verifying_key.as_bytes()));
assert!(token.expires_at > now && token.not_before < now);
let ch = closure_hash(&token.closure);
let msg = format!(
"{}:{}:{}:{}:{}:{}",
token.agent_id, token.token_id, token.operation, token.package_type, ch, token.expires_at
);
let sig = Signature::from_slice(&hex::decode(&token.signature).unwrap()).unwrap();
fx.verifying_key.verify(msg.as_bytes(), &sig).expect("execute-path verification must accept");
// And the wire shape parses as the execute path's CapabilityToken.
let json = serde_json::to_string(&token).unwrap();
let parsed: CapabilityToken = serde_json::from_str(&json).expect("wire-compatible");
assert_eq!(parsed.token_id, token.token_id);
// Journaled before emission.
let journal = fs::read_to_string(&fx.paths.journal_path).unwrap();
assert!(journal.contains("\"request_id\":\"req-1\""));
assert!(journal.contains(&token.token_id));
}
#[test]
fn mint_rejects_stale_evidence() {
let fx = mint_fixture("stale");
let now = now_unix();
let mut req = good_request("req-stale", now);
req.gate_evidence.resolved_at = now - MINT_EVIDENCE_MAX_AGE_SECS - 1;
let d = run_mint(&req, &fx.paths, now).unwrap_err();
assert_eq!(d.code, EXIT_MINT_STALE);
}
#[test]
fn mint_rejects_future_dated_evidence() {
let fx = mint_fixture("future");
let now = now_unix();
let mut req = good_request("req-future", now);
req.gate_evidence.resolved_at = now + MINT_CLOCK_SKEW_SECS + 30;
let d = run_mint(&req, &fx.paths, now).unwrap_err();
assert_eq!(d.code, EXIT_MINT_STALE);
}
#[test]
fn mint_vulnerable_requires_override_reason() {
let fx = mint_fixture("vuln");
let now = now_unix();
let mut req = good_request("req-vuln", now);
req.gate_evidence.osv_status = "vulnerable".into();
req.gate_evidence.osv_vuln_count = 2;
let d = run_mint(&req, &fx.paths, now).unwrap_err();
assert_eq!(d.code, EXIT_MINT_GATE);
req.gate_evidence.override_reason = "CVE-2026-0001 not reachable in our deployment".into();
let token = run_mint(&req, &fx.paths, now).expect("override with reason mints");
let journal = fs::read_to_string(&fx.paths.journal_path).unwrap();
assert!(journal.contains("not reachable in our deployment"));
assert!(journal.contains(&token.token_id));
}
#[test]
fn mint_duplicate_request_id_denied() {
let fx = mint_fixture("dup");
let now = now_unix();
run_mint(&good_request("req-dup", now), &fx.paths, now).expect("first mint");
let d = run_mint(&good_request("req-dup", now), &fx.paths, now).unwrap_err();
assert_eq!(d.code, EXIT_MINT_DUPLICATE);
}
#[test]
fn mint_loose_key_permissions_denied() {
let fx = mint_fixture("perms");
let now = now_unix();
fs::set_permissions(&fx.paths.key_path, fs::Permissions::from_mode(0o644)).unwrap();
let d = run_mint(&good_request("req-perm", now), &fx.paths, now).unwrap_err();
assert_eq!(d.code, EXIT_MINT_KEY);
}
#[test]
fn mint_rejects_agent_self_and_bad_hashes() {
let fx = mint_fixture("shape");
let now = now_unix();
let mut req = good_request("req-self", now);
req.package_type = AGENT_SELF_PACKAGE_TYPE.to_string();
assert_eq!(run_mint(&req, &fx.paths, now).unwrap_err().code, EXIT_UNSUPPORTED_OP);
let mut req = good_request("req-badsha", now);
req.closure[0].sha256 = "deadbeef".into();
assert_eq!(run_mint(&req, &fx.paths, now).unwrap_err().code, EXIT_ARTIFACT);
let mut req = good_request("req-remove", now);
req.operation = "remove".into();
assert_eq!(run_mint(&req, &fx.paths, now).unwrap_err().code, EXIT_UNSUPPORTED_OP);
}
}
#[derive(Debug, Serialize)]
@ -937,6 +1620,12 @@ fn main() {
std::process::exit(run_verify_binary(&args[2..]));
}
// "mint" is the standalone local authority (no fleet server on this host).
// See RAF/security/06-standalone-authority.md.
if args.get(1).map(|s| s.as_str()) == Some("mint") {
std::process::exit(run_mint_cli(&args[2..]));
}
// --token-file <path> reads the capability token from a file instead of stdin.
// This avoids SCM_RIGHTS fd-passing via systemd-run --pipe, which dbus-broker 37
// on Fedora 43 drops (MSG_CTRUNC) for new-connection handshake messages.

View file

@ -0,0 +1,69 @@
#!/bin/bash
# provision-standalone-authority.sh — set up the local mint authority on a
# STANDALONE host (no fleet server). Design: RAF/security/06-standalone-authority.md.
# Build tracking: docs/tasks/FEAT-003-standalone-local-authority.md.
#
# Run as root, after the base agent install (agent user, redflag-local group,
# helper binary, helper sudoers). The future native installers (.rpm/.deb/AUR)
# call this for standalone installs; fleet installs must NOT run it — fleet
# hosts have no local authority. Fleet join later runs:
# redflag-helper mint --retire-key
#
# Idempotent: safe to re-run. The key init refuses to overwrite an existing
# authority by design (retire first).
set -euo pipefail
AGENT_USER="redflag-agent"
LOCAL_GROUP="redflag-local"
HELPER_BIN="/usr/local/bin/redflag-helper"
JOURNAL_DIR="/var/lib/redflag/journal"
MINT_REQUEST_DIR="/var/lib/redflag/agent/mint"
TOKENS_DIR="/var/lib/redflag/agent/tokens"
SUDOERS_FILE="/etc/sudoers.d/redflag-agent-mint"
log() { echo "[INFO] [provision] [standalone-authority] $*"; }
fail() { echo "[ERROR] [provision] [standalone-authority] $*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || fail "must run as root"
[ -x "$HELPER_BIN" ] || fail "helper binary missing at $HELPER_BIN — run the base install first"
id "$AGENT_USER" &>/dev/null || fail "agent user $AGENT_USER missing — run the base install first"
getent group "$LOCAL_GROUP" &>/dev/null || fail "group $LOCAL_GROUP missing — run the base install first"
# Journal dir: root-owned, setgid redflag-local, group-readable. Files the
# helper writes 0640 inherit the group via setgid, so the unprivileged agent
# (a group member) can serve journal entries over the local API while the dir
# stays root-writable only.
install -d -m 2750 -o root -g "$LOCAL_GROUP" "$JOURNAL_DIR"
log "journal dir ready: $JOURNAL_DIR (root:$LOCAL_GROUP 2750)"
# Mint request dir: agent writes requests, root (helper) reads them.
install -d -m 0750 -o "$AGENT_USER" -g "$LOCAL_GROUP" "$MINT_REQUEST_DIR"
log "mint request dir ready: $MINT_REQUEST_DIR"
# Tokens dir should already exist from the base install; ensure it does.
[ -d "$TOKENS_DIR" ] || install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$TOKENS_DIR"
# Local authority keypair. --init-key writes the private seed 0600 root at
# /etc/redflag/authority_local.key and installs the public half into the
# helper keyring so the execute path trusts what mint signs. Refuses to
# overwrite an existing authority.
if [ -f /etc/redflag/authority_local.key ]; then
log "local authority already provisioned — leaving key untouched"
else
"$HELPER_BIN" mint --init-key
log "local authority created"
fi
# Sudoers: the agent user may invoke exactly the mint command shape, mirroring
# the execute-path grant. Request and token paths are pinned to their dirs.
cat > "$SUDOERS_FILE" <<EOF
# RedFlag standalone authority — mint invocation (FEAT-003).
# The agent user may request a mint; the gates + root-owned key decide.
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN mint --request-file $MINT_REQUEST_DIR/* --token-out $TOKENS_DIR/*
EOF
chmod 0440 "$SUDOERS_FILE"
visudo -c -f "$SUDOERS_FILE" >/dev/null || fail "sudoers validation failed for $SUDOERS_FILE"
log "sudoers installed: $SUDOERS_FILE"
log "standalone authority provisioned — fleet join later must run: $HELPER_BIN mint --retire-key"