Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/agent/internal/capability/mutation_manifest.go
Fimeg f5eb83ca94 feat: pin mutation manifest authority bytes
Introduce a dormant MutationEnvelope beside the current closure token. Server, Agent, and helper now agree on length-prefixed manifest and authorization bytes, with one shared fixture pinning the hash, key identity, and Ed25519 signature.\n\nExecutor-affecting provenance, location, target, backend, and backend payload now have a signed home before any runtime path migrates. Exact duplicates stay visible in the hash; ordering does not. Current APT/DNF and Windows behavior remains untouched.
2026-08-26 16:07:19 -04:00

268 lines
8.1 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.
MutationProtocolVersion = 1
manifestDomain = "redflag.mutation-manifest"
actionDomain = "redflag.resolved-action"
evidenceDomain = "redflag.evidence"
authorizationDomain = "redflag.mutation-authorization"
)
type ResolvedAction struct {
Kind string `json:"kind"`
Identity string `json:"identity"`
Payload string `json:"payload"`
}
type Evidence struct {
Kind string `json:"kind"`
Digest string `json:"digest"`
}
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"`
}
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"`
}
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
}
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,
)
}
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 a.IssuedAt <= 0 || a.NotBefore < a.IssuedAt || a.ExpiresAt <= a.NotBefore {
return fmt.Errorf("mutation protocol: invalid authorization time window")
}
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
}
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)
}