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:
parent
1241c1ef01
commit
e8b7742422
10 changed files with 1442 additions and 22 deletions
|
|
@ -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)
|
||||
|
|
|
|||
160
agent/internal/handlers/local_approve.go
Normal file
160
agent/internal/handlers/local_approve.go
Normal 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
|
||||
}
|
||||
100
agent/internal/localapi/approve_test.go
Normal file
100
agent/internal/localapi/approve_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
155
agent/internal/supplychain/mint.go
Normal file
155
agent/internal/supplychain/mint.go
Normal 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
|
||||
}
|
||||
142
agent/internal/supplychain/osv.go
Normal file
142
agent/internal/supplychain/osv.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
69
scripts/provision-standalone-authority.sh
Executable file
69
scripts/provision-standalone-authority.sh
Executable 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"
|
||||
Loading…
Reference in a new issue