Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/agent/internal/capability/mutation_manifest.go
Fimeg 517f1aca20 feat: pin complete mutation authority contract
Cut 1 completes the dormant MutationEnvelope contract without changing any runtime path. Bind target_id to the provisioned agent identity, require canonical UUID v4 authorization IDs, cap authorization lifetime at the fleet TTL, and add an unsigned MutationReceipt carrying the audit join.

Server, Agent, and helper share the same canonical bytes, key identity, receipt digest, and Ed25519 golden vectors. Current APT/DNF and Windows execution remains untouched.
2026-08-26 18:39:29 -04:00

423 lines
14 KiB
Go

package capability
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
)
const (
// MutationProtocolVersion belongs to the manifest namespace, independently
// of the current closure-based Token format. The manifest path is dormant
// until a backend explicitly opts into it.
MutationProtocolVersion = 1
// MaxAuthorizationLifetimeSeconds is the ceiling on expires_at - not_before.
// Derived, not chosen: it is DefaultTokenTTL, the fleet minter's window
// (server/internal/services/capability_minter.go). Standalone mint is
// tighter still at 600s. Doctrine, not a knob — an authority that wants a
// standing capability has to say so by minting again.
MaxAuthorizationLifetimeSeconds = 3600
manifestDomain = "redflag.mutation-manifest"
actionDomain = "redflag.resolved-action"
evidenceDomain = "redflag.evidence"
authorizationDomain = "redflag.mutation-authorization"
receiptDomain = "redflag.mutation-receipt"
)
// ResolvedAction carries exact backend-owned UTF-8 JSON bytes. The common
// protocol signs those bytes but does not reinterpret pacman, WUA, Winget,
// Docker, or self-update semantics into a fictional universal artifact.
type ResolvedAction struct {
Kind string `json:"kind"`
Identity string `json:"identity"`
Payload string `json:"payload"`
}
// Evidence identifies provenance or policy evidence by digest. Execution
// location belongs in the resolved action payload, never in this trust class.
type Evidence struct {
Kind string `json:"kind"`
Digest string `json:"digest"`
}
// MutationManifest is the immutable description an authority approves and
// an executor later receives unchanged.
//
// TargetID MUST be the locally provisioned RedFlag agent identity. The generic
// name is deliberate: a later protocol may define another target namespace.
type MutationManifest struct {
ProtocolVersion int `json:"protocol_version"`
OperationID string `json:"operation_id"`
TargetID string `json:"target_id"`
Backend string `json:"backend"`
Operation string `json:"operation"`
ResolvedActions []ResolvedAction `json:"resolved_actions"`
Evidence []Evidence `json:"evidence"`
}
// MutationAuthorization binds an authority decision to one manifest and body.
// Every field except Signature is inside CanonicalMessage, including KeyID.
type MutationAuthorization struct {
ProtocolVersion int `json:"protocol_version"`
AuthorizationID string `json:"authorization_id"`
ManifestHash string `json:"manifest_hash"`
AuthorityKind string `json:"authority_kind"`
AuthorityID string `json:"authority_id"`
TargetID string `json:"target_id"`
IssuedAt int64 `json:"issued_at"`
NotBefore int64 `json:"not_before"`
ExpiresAt int64 `json:"expires_at"`
Decision string `json:"decision"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
// MutationEnvelope is the indivisible object handed across an authority or
// executor boundary. Verification always recomputes the manifest hash from the
// manifest carried beside its authorization.
type MutationEnvelope struct {
Manifest MutationManifest `json:"manifest"`
Authorization MutationAuthorization `json:"authorization"`
}
func writeLP(buf *bytes.Buffer, value []byte) {
buf.WriteString(strconv.Itoa(len(value)))
buf.WriteByte(':')
buf.Write(value)
}
func canonicalRecord(domain string, values ...string) []byte {
var buf bytes.Buffer
writeLP(&buf, []byte(domain))
for _, value := range values {
writeLP(&buf, []byte(value))
}
return buf.Bytes()
}
func (a ResolvedAction) canonicalBytes() []byte {
return canonicalRecord(actionDomain, a.Kind, a.Identity, a.Payload)
}
func (e Evidence) canonicalBytes() []byte {
return canonicalRecord(evidenceDomain, e.Kind, e.Digest)
}
func sortedRecords[T any](values []T, encode func(T) []byte) [][]byte {
records := make([][]byte, 0, len(values))
for _, value := range values {
records = append(records, encode(value))
}
sort.Slice(records, func(i, j int) bool { return bytes.Compare(records[i], records[j]) < 0 })
return records
}
// CanonicalBytes is domain-separated and length-prefixed. Action and evidence
// ordering is irrelevant, while exact duplicates remain present and therefore
// change the hash. No set conversion is permitted here.
func (m MutationManifest) CanonicalBytes() []byte {
var buf bytes.Buffer
writeLP(&buf, []byte(manifestDomain))
for _, value := range []string{
strconv.Itoa(m.ProtocolVersion),
m.OperationID,
m.TargetID,
m.Backend,
m.Operation,
} {
writeLP(&buf, []byte(value))
}
actions := sortedRecords(m.ResolvedActions, func(a ResolvedAction) []byte { return a.canonicalBytes() })
writeLP(&buf, []byte(strconv.Itoa(len(actions))))
for _, action := range actions {
writeLP(&buf, action)
}
evidence := sortedRecords(m.Evidence, func(e Evidence) []byte { return e.canonicalBytes() })
writeLP(&buf, []byte(strconv.Itoa(len(evidence))))
for _, item := range evidence {
writeLP(&buf, item)
}
return buf.Bytes()
}
func (m MutationManifest) Hash() string {
digest := sha256.Sum256(m.CanonicalBytes())
return hex.EncodeToString(digest[:])
}
func (m MutationManifest) Validate() error {
if m.ProtocolVersion != MutationProtocolVersion {
return fmt.Errorf("mutation protocol: unsupported manifest version %d", m.ProtocolVersion)
}
for _, field := range [][2]string{
{"operation_id", m.OperationID},
{"target_id", m.TargetID},
{"backend", m.Backend},
{"operation", m.Operation},
} {
name, value := field[0], field[1]
if value == "" {
return fmt.Errorf("mutation protocol: manifest %s is empty", name)
}
}
if len(m.ResolvedActions) == 0 {
return fmt.Errorf("mutation protocol: manifest has no resolved actions")
}
for i, action := range m.ResolvedActions {
if action.Kind == "" || action.Identity == "" || action.Payload == "" {
return fmt.Errorf("mutation protocol: resolved action %d is incomplete", i)
}
if !json.Valid([]byte(action.Payload)) {
return fmt.Errorf("mutation protocol: resolved action %d payload is not JSON", i)
}
}
for i, evidence := range m.Evidence {
if evidence.Kind == "" {
return fmt.Errorf("mutation protocol: evidence %d kind is empty", i)
}
decoded, err := hex.DecodeString(evidence.Digest)
if err != nil || len(decoded) != sha256.Size {
return fmt.Errorf("mutation protocol: evidence %d digest is not SHA-256 hex", i)
}
}
return nil
}
func (a MutationAuthorization) CanonicalMessage() []byte {
return canonicalRecord(
authorizationDomain,
strconv.Itoa(a.ProtocolVersion),
a.AuthorizationID,
a.ManifestHash,
a.AuthorityKind,
a.AuthorityID,
a.TargetID,
strconv.FormatInt(a.IssuedAt, 10),
strconv.FormatInt(a.NotBefore, 10),
strconv.FormatInt(a.ExpiresAt, 10),
a.Decision,
a.KeyID,
)
}
// IsCanonicalUUIDv4 reports whether s is 8-4-4-4-12 lowercase hex with the
// version (4) and variant (8/9/a/b) nibbles set. Same discipline the standalone
// mint already applies to request_id, applied here before authorization_id can
// become an executor replay key: a newline-delimited replay ledger matched by
// exact line has no defence against an identifier that contains a newline.
func IsCanonicalUUIDv4(s string) bool {
if len(s) != 36 {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if i == 8 || i == 13 || i == 18 || i == 23 {
if c != '-' {
return false
}
continue
}
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
if !isHex {
return false
}
}
if s[14] != '4' {
return false
}
switch s[19] {
case '8', '9', 'a', 'b':
return true
}
return false
}
func (a MutationAuthorization) validateShape() error {
if a.ProtocolVersion != MutationProtocolVersion {
return fmt.Errorf("mutation protocol: unsupported authorization version %d", a.ProtocolVersion)
}
for _, field := range [][2]string{
{"authorization_id", a.AuthorizationID},
{"authority_kind", a.AuthorityKind},
{"authority_id", a.AuthorityID},
{"target_id", a.TargetID},
{"decision", a.Decision},
} {
name, value := field[0], field[1]
if value == "" {
return fmt.Errorf("mutation protocol: authorization %s is empty", name)
}
}
if !IsCanonicalUUIDv4(a.AuthorizationID) {
return fmt.Errorf("mutation protocol: authorization_id is not a canonical UUID v4")
}
if a.IssuedAt <= 0 || a.NotBefore < a.IssuedAt || a.ExpiresAt <= a.NotBefore {
return fmt.Errorf("mutation protocol: invalid authorization time window")
}
if a.ExpiresAt-a.NotBefore > MaxAuthorizationLifetimeSeconds {
return fmt.Errorf("mutation protocol: authorization lifetime %ds exceeds the %ds ceiling",
a.ExpiresAt-a.NotBefore, MaxAuthorizationLifetimeSeconds)
}
return nil
}
func (a *MutationAuthorization) Sign(priv ed25519.PrivateKey, manifest MutationManifest) error {
if len(priv) != ed25519.PrivateKeySize {
return fmt.Errorf("mutation protocol: invalid private key size %d", len(priv))
}
if err := manifest.Validate(); err != nil {
return err
}
if err := a.validateShape(); err != nil {
return err
}
if a.TargetID != manifest.TargetID {
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
}
a.ManifestHash = manifest.Hash()
a.KeyID = KeyIDFor(priv.Public().(ed25519.PublicKey))
a.Signature = hex.EncodeToString(ed25519.Sign(priv, a.CanonicalMessage()))
return nil
}
func (a MutationAuthorization) Verify(pub ed25519.PublicKey, manifest MutationManifest) error {
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("mutation protocol: invalid public key size %d", len(pub))
}
if err := manifest.Validate(); err != nil {
return err
}
if err := a.validateShape(); err != nil {
return err
}
if a.TargetID != manifest.TargetID {
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
}
if a.ManifestHash != manifest.Hash() {
return fmt.Errorf("mutation protocol: authorization manifest hash mismatch")
}
if a.KeyID != KeyIDFor(pub) {
return fmt.Errorf("mutation protocol: authorization key id mismatch")
}
signature, err := hex.DecodeString(a.Signature)
if err != nil || len(signature) != ed25519.SignatureSize {
return fmt.Errorf("mutation protocol: malformed signature")
}
if !ed25519.Verify(pub, a.CanonicalMessage(), signature) {
return fmt.Errorf("mutation protocol: signature verification failed")
}
return nil
}
// VerifyForExecutionAt adds the executor's decision and time checks to the
// cryptographic envelope verification.
func (a MutationAuthorization) VerifyForExecutionAt(pub ed25519.PublicKey, manifest MutationManifest, now int64) error {
if err := a.Verify(pub, manifest); err != nil {
return err
}
if a.Decision != "allow" {
return fmt.Errorf("mutation protocol: authorization decision is %q", a.Decision)
}
if now < a.NotBefore || now > a.ExpiresAt {
return fmt.Errorf("mutation protocol: authorization is outside its time window")
}
return nil
}
func (e MutationEnvelope) VerifyForExecutionAt(pub ed25519.PublicKey, now int64) error {
return e.Authorization.VerifyForExecutionAt(pub, e.Manifest, now)
}
// MutationReceipt is the response half of the contract: what the privileged
// executor did with one envelope. It carries the audit join ARCH-002 names —
// operation ID, manifest hash, authorization ID — so a local receipt and a
// server history row can be joined without either guessing.
//
// It is not signed. The executor is not a second authority; this is a record
// produced inside the trust boundary that already ran the operation. Decision
// and Reason keep the PolicyResult taxonomy rather than inventing a new one.
type MutationReceipt struct {
ProtocolVersion int `json:"protocol_version"`
OperationID string `json:"operation_id"`
ManifestHash string `json:"manifest_hash"`
AuthorizationID string `json:"authorization_id"`
TargetID string `json:"target_id"`
Backend string `json:"backend"`
Operation string `json:"operation"`
Decision string `json:"decision"` // executed | denied | failed
Reason string `json:"reason"`
Executed bool `json:"executed"`
VerifiedActions int `json:"verified_actions"`
ExitCode int `json:"exit_code"`
Error string `json:"error,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// CanonicalBytes pins the receipt the same way the manifest is pinned, so a
// ledger can digest one without re-deriving field order from JSON. Every field
// is present even when empty — a refusal before parse still produces a receipt,
// and its emptiness is part of the record.
func (r MutationReceipt) CanonicalBytes() []byte {
return canonicalRecord(
receiptDomain,
strconv.Itoa(r.ProtocolVersion),
r.OperationID,
r.ManifestHash,
r.AuthorizationID,
r.TargetID,
r.Backend,
r.Operation,
r.Decision,
r.Reason,
strconv.FormatBool(r.Executed),
strconv.Itoa(r.VerifiedActions),
strconv.Itoa(r.ExitCode),
r.Error,
strconv.FormatInt(r.Timestamp, 10),
)
}
func (r MutationReceipt) Digest() string {
digest := sha256.Sum256(r.CanonicalBytes())
return hex.EncodeToString(digest[:])
}
// MutationOutcome is what the executor did, separate from which envelope it
// did it to.
type MutationOutcome struct {
Decision string
Reason string
Executed bool
VerifiedActions int
ExitCode int
Detail string
}
// ReceiptFor copies the audit join out of signed bytes rather than retyping it.
func (e MutationEnvelope) ReceiptFor(outcome MutationOutcome, now int64) MutationReceipt {
return MutationReceipt{
ProtocolVersion: MutationProtocolVersion,
OperationID: e.Manifest.OperationID,
ManifestHash: e.Manifest.Hash(),
AuthorizationID: e.Authorization.AuthorizationID,
TargetID: e.Manifest.TargetID,
Backend: e.Manifest.Backend,
Operation: e.Manifest.Operation,
Decision: outcome.Decision,
Reason: outcome.Reason,
Executed: outcome.Executed,
VerifiedActions: outcome.VerifiedActions,
ExitCode: outcome.ExitCode,
Error: outcome.Detail,
Timestamp: now,
}
}