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.
This commit is contained in:
parent
c3037655cd
commit
517f1aca20
15 changed files with 2511 additions and 15 deletions
15
agent/internal/capability/keyid.go
Normal file
15
agent/internal/capability/keyid.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package capability
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// KeyIDFor returns the authority key fingerprint: hex(sha256(pubkey)[:16]).
|
||||
// Matches SigningService.GetPublicKeyFingerprint and the executor's key_id_for.
|
||||
// Split out of token.go so retiring Token does not strand the mutation protocol.
|
||||
func KeyIDFor(pub ed25519.PublicKey) string {
|
||||
hash := sha256.Sum256(pub)
|
||||
return hex.EncodeToString(hash[:16])
|
||||
}
|
||||
423
agent/internal/capability/mutation_manifest.go
Normal file
423
agent/internal/capability/mutation_manifest.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
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,
|
||||
}
|
||||
}
|
||||
302
agent/internal/capability/mutation_manifest_test.go
Normal file
302
agent/internal/capability/mutation_manifest_test.go
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
package capability
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mutationProtocolFixture struct {
|
||||
Manifest MutationManifest `json:"manifest"`
|
||||
Authorization MutationAuthorization `json:"authorization"`
|
||||
Receipt MutationReceipt `json:"receipt"`
|
||||
TestSeed string `json:"test_seed"`
|
||||
ExpectedManifestCanonicalHex string `json:"expected_manifest_canonical_hex"`
|
||||
ExpectedManifestHash string `json:"expected_manifest_hash"`
|
||||
ExpectedAuthorizationCanonical string `json:"expected_authorization_canonical_hex"`
|
||||
ExpectedKeyID string `json:"expected_key_id"`
|
||||
ExpectedSignature string `json:"expected_signature"`
|
||||
ExpectedReceiptCanonicalHex string `json:"expected_receipt_canonical_hex"`
|
||||
ExpectedReceiptDigest string `json:"expected_receipt_digest"`
|
||||
}
|
||||
|
||||
func loadMutationProtocolFixture(t *testing.T) mutationProtocolFixture {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../../protocol/testdata/mutation-golden.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture mutationProtocolFixture
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
func signedMutationProtocolFixture(t *testing.T) (MutationManifest, MutationAuthorization, ed25519.PublicKey) {
|
||||
t.Helper()
|
||||
fixture := loadMutationProtocolFixture(t)
|
||||
seed, err := hex.DecodeString(fixture.TestSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKey := ed25519.NewKeyFromSeed(seed)
|
||||
authorization := fixture.Authorization
|
||||
if err := authorization.Sign(privateKey, fixture.Manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fixture.Manifest, authorization, privateKey.Public().(ed25519.PublicKey)
|
||||
}
|
||||
|
||||
func cloneMutationManifest(t *testing.T, manifest MutationManifest) MutationManifest {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var clone MutationManifest
|
||||
if err := json.Unmarshal(raw, &clone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func TestMutationProtocolGoldenVector(t *testing.T) {
|
||||
fixture := loadMutationProtocolFixture(t)
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
if fixture.ExpectedManifestHash == "" {
|
||||
t.Fatalf(
|
||||
"fill fixture: manifest_canonical=%s\nmanifest_hash=%s\nauthorization_canonical=%s\nkey_id=%s\nsignature=%s\nreceipt_canonical=%s\nreceipt_digest=%s",
|
||||
hex.EncodeToString(manifest.CanonicalBytes()),
|
||||
manifest.Hash(),
|
||||
hex.EncodeToString(authorization.CanonicalMessage()),
|
||||
authorization.KeyID,
|
||||
authorization.Signature,
|
||||
hex.EncodeToString(fixture.Receipt.CanonicalBytes()),
|
||||
fixture.Receipt.Digest(),
|
||||
)
|
||||
}
|
||||
if got := hex.EncodeToString(manifest.CanonicalBytes()); got != fixture.ExpectedManifestCanonicalHex {
|
||||
t.Fatalf("manifest canonical bytes = %q, want %q", got, fixture.ExpectedManifestCanonicalHex)
|
||||
}
|
||||
if got := manifest.Hash(); got != fixture.ExpectedManifestHash {
|
||||
t.Fatalf("manifest hash = %q, want %q", got, fixture.ExpectedManifestHash)
|
||||
}
|
||||
if got := hex.EncodeToString(authorization.CanonicalMessage()); got != fixture.ExpectedAuthorizationCanonical {
|
||||
t.Fatalf("authorization canonical bytes = %q, want %q", got, fixture.ExpectedAuthorizationCanonical)
|
||||
}
|
||||
if authorization.KeyID != fixture.ExpectedKeyID {
|
||||
t.Fatalf("key id = %q, want %q", authorization.KeyID, fixture.ExpectedKeyID)
|
||||
}
|
||||
if authorization.Signature != fixture.ExpectedSignature {
|
||||
t.Fatalf("signature = %q, want %q", authorization.Signature, fixture.ExpectedSignature)
|
||||
}
|
||||
if got := hex.EncodeToString(fixture.Receipt.CanonicalBytes()); got != fixture.ExpectedReceiptCanonicalHex {
|
||||
t.Fatalf("receipt canonical bytes = %q, want %q", got, fixture.ExpectedReceiptCanonicalHex)
|
||||
}
|
||||
if got := fixture.Receipt.Digest(); got != fixture.ExpectedReceiptDigest {
|
||||
t.Fatalf("receipt digest = %q, want %q", got, fixture.ExpectedReceiptDigest)
|
||||
}
|
||||
envelope := MutationEnvelope{Manifest: manifest, Authorization: authorization}
|
||||
if err := envelope.VerifyForExecutionAt(publicKey, 1_700_000_100); err != nil {
|
||||
t.Fatalf("golden authorization did not verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationProtocolOrderingAndDuplicateSemantics(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
reordered := cloneMutationManifest(t, manifest)
|
||||
reordered.ResolvedActions[0], reordered.ResolvedActions[1] = reordered.ResolvedActions[1], reordered.ResolvedActions[0]
|
||||
reordered.Evidence[0], reordered.Evidence[1] = reordered.Evidence[1], reordered.Evidence[0]
|
||||
if reordered.Hash() != manifest.Hash() {
|
||||
t.Fatal("manifest hash changed when action/evidence order changed")
|
||||
}
|
||||
if err := authorization.Verify(publicKey, reordered); err != nil {
|
||||
t.Fatalf("authorization rejected reordered manifest: %v", err)
|
||||
}
|
||||
|
||||
duplicate := cloneMutationManifest(t, manifest)
|
||||
duplicate.ResolvedActions = append(duplicate.ResolvedActions, duplicate.ResolvedActions[0])
|
||||
if duplicate.Hash() == manifest.Hash() {
|
||||
t.Fatal("exact duplicate action was silently de-duplicated")
|
||||
}
|
||||
if err := authorization.Verify(publicKey, duplicate); err == nil {
|
||||
t.Fatal("authorization accepted a duplicate resolved action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationProtocolExecutorAffectingTamperFails(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
tests := map[string]func(*MutationManifest){
|
||||
"provenance": func(m *MutationManifest) { m.Evidence[0].Digest = strings.Repeat("c", 64) },
|
||||
"execution location": func(m *MutationManifest) {
|
||||
m.ResolvedActions[0].Payload = strings.Replace(m.ResolvedActions[0].Payload, "/var/cache/redflag", "/tmp", 1)
|
||||
},
|
||||
"target": func(m *MutationManifest) { m.TargetID = "6f1e2d3c-4b5a-4998-8877-665544332211" },
|
||||
"backend": func(m *MutationManifest) { m.Backend = "wua" },
|
||||
"resolved action": func(m *MutationManifest) { m.ResolvedActions[0].Identity = "zsh@6.0-1" },
|
||||
}
|
||||
for name, tamper := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
changed := cloneMutationManifest(t, manifest)
|
||||
tamper(&changed)
|
||||
if err := authorization.Verify(publicKey, changed); err == nil {
|
||||
t.Fatal("authorization accepted tampered manifest")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
changedAuthorization := authorization
|
||||
changedAuthorization.IssuedAt++
|
||||
if err := changedAuthorization.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("authorization accepted tampered authorization metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationProtocolUnknownVersionsFailClosed(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
manifest.ProtocolVersion++
|
||||
if err := manifest.Validate(); err == nil {
|
||||
t.Fatal("unknown manifest version passed validation")
|
||||
}
|
||||
|
||||
manifest.ProtocolVersion = MutationProtocolVersion
|
||||
authorization.ProtocolVersion++
|
||||
if err := authorization.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("unknown authorization version passed verification")
|
||||
}
|
||||
}
|
||||
|
||||
// The target fields carry the RedFlag agent identity. Both are signed and the
|
||||
// verifier requires them equal, so an executor that binds either one to the
|
||||
// host it read for itself has bound the whole envelope.
|
||||
func TestMutationProtocolTargetBindsManifestAndAuthorization(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
if manifest.TargetID != authorization.TargetID {
|
||||
t.Fatal("golden fixture disagrees with itself about the target")
|
||||
}
|
||||
|
||||
split := authorization
|
||||
split.TargetID = "6f1e2d3c-4b5a-4998-8877-665544332211"
|
||||
if err := split.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("authorization for another target verified against this manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationAuthorizationIDIsCanonicalUUIDv4(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
// A replay ledger matched line-by-line has no defence against an embedded
|
||||
// newline; the shape check is what makes the identifier safe to record.
|
||||
for _, bad := range []string{
|
||||
"",
|
||||
"not-a-uuid",
|
||||
"550e8400-e29b-41d4-a716-44665544001",
|
||||
"550e8400-e29b-11d4-a716-446655440011",
|
||||
"550e8400-e29b-41d4-c716-446655440011",
|
||||
"550E8400-E29B-41D4-A716-446655440011",
|
||||
"550e8400-e29b-41d4-a716-4466554400\n1",
|
||||
} {
|
||||
if IsCanonicalUUIDv4(bad) {
|
||||
t.Fatalf("accepted %q as a canonical UUID v4", bad)
|
||||
}
|
||||
changed := authorization
|
||||
changed.AuthorizationID = bad
|
||||
if err := changed.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatalf("authorization with id %q verified", bad)
|
||||
}
|
||||
}
|
||||
|
||||
if !IsCanonicalUUIDv4(authorization.AuthorizationID) {
|
||||
t.Fatalf("golden authorization_id %q is not a canonical UUID v4", authorization.AuthorizationID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationAuthorizationLifetimeCeiling(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
seed, err := hex.DecodeString(loadMutationProtocolFixture(t).TestSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKey := ed25519.NewKeyFromSeed(seed)
|
||||
|
||||
atCeiling := authorization
|
||||
atCeiling.ExpiresAt = atCeiling.NotBefore + MaxAuthorizationLifetimeSeconds
|
||||
if err := atCeiling.Sign(privateKey, manifest); err != nil {
|
||||
t.Fatalf("an authorization exactly at the ceiling must sign: %v", err)
|
||||
}
|
||||
if err := atCeiling.Verify(publicKey, manifest); err != nil {
|
||||
t.Fatalf("an authorization exactly at the ceiling must verify: %v", err)
|
||||
}
|
||||
|
||||
overCeiling := authorization
|
||||
overCeiling.ExpiresAt = overCeiling.NotBefore + MaxAuthorizationLifetimeSeconds + 1
|
||||
if err := overCeiling.Sign(privateKey, manifest); err == nil {
|
||||
t.Fatal("minted an authorization past the lifetime ceiling")
|
||||
}
|
||||
if err := overCeiling.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("verified an authorization past the lifetime ceiling")
|
||||
}
|
||||
}
|
||||
|
||||
// Evidence carries digests. Operator reason prose and policy text stay in the
|
||||
// authority's journal, and the shape check is what keeps them out.
|
||||
func TestMutationEvidenceCarriesDigestsNotProse(t *testing.T) {
|
||||
manifest, _, _ := signedMutationProtocolFixture(t)
|
||||
|
||||
for _, bad := range []string{"", "operator accepted the CVE risk", strings.Repeat("a", 63), strings.Repeat("z", 64)} {
|
||||
changed := cloneMutationManifest(t, manifest)
|
||||
changed.Evidence[0].Digest = bad
|
||||
if err := changed.Validate(); err == nil {
|
||||
t.Fatalf("manifest validated with evidence digest %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationReceiptCarriesAuditJoin(t *testing.T) {
|
||||
manifest, authorization, _ := signedMutationProtocolFixture(t)
|
||||
envelope := MutationEnvelope{Manifest: manifest, Authorization: authorization}
|
||||
|
||||
receipt := envelope.ReceiptFor(MutationOutcome{
|
||||
Decision: "denied",
|
||||
Reason: "backend_not_migrated",
|
||||
ExitCode: 18,
|
||||
Detail: "backend=pacman",
|
||||
}, 1_700_000_100)
|
||||
if receipt.OperationID != manifest.OperationID ||
|
||||
receipt.ManifestHash != manifest.Hash() ||
|
||||
receipt.AuthorizationID != authorization.AuthorizationID ||
|
||||
receipt.TargetID != manifest.TargetID {
|
||||
t.Fatal("receipt lost the operation/manifest/authorization/target join")
|
||||
}
|
||||
if receipt.Backend != manifest.Backend || receipt.Operation != manifest.Operation {
|
||||
t.Fatal("receipt lost the backend/operation it answers")
|
||||
}
|
||||
|
||||
// Every recorded field is in the canonical bytes, including the ones a
|
||||
// lossy report would drop first.
|
||||
for name, mutate := range map[string]func(*MutationReceipt){
|
||||
"decision": func(r *MutationReceipt) { r.Decision = "executed" },
|
||||
"reason": func(r *MutationReceipt) { r.Reason = "operation_completed" },
|
||||
"executed": func(r *MutationReceipt) { r.Executed = true },
|
||||
"verified actions": func(r *MutationReceipt) { r.VerifiedActions = 1 },
|
||||
"exit code": func(r *MutationReceipt) { r.ExitCode = 0 },
|
||||
"error": func(r *MutationReceipt) { r.Error = "" },
|
||||
"timestamp": func(r *MutationReceipt) { r.Timestamp++ },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
changed := receipt
|
||||
mutate(&changed)
|
||||
if changed.Digest() == receipt.Digest() {
|
||||
t.Fatal("receipt digest ignored a recorded field")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -44,13 +44,6 @@ type Token struct {
|
|||
Signature string `json:"signature"` // hex ed25519
|
||||
}
|
||||
|
||||
// KeyIDFor returns the authority key fingerprint: hex(sha256(pubkey)[:16]).
|
||||
// Matches SigningService.GetPublicKeyFingerprint and the executor's key_id_for.
|
||||
func KeyIDFor(pub ed25519.PublicKey) string {
|
||||
hash := sha256.Sum256(pub)
|
||||
return hex.EncodeToString(hash[:16])
|
||||
}
|
||||
|
||||
// ClosureHash computes hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) )).
|
||||
// Lines are sorted and de-duplicated so neither array order nor exact duplicates
|
||||
// can change the digest. This mirrors the Rust BTreeSet construction exactly.
|
||||
|
|
|
|||
Loading…
Reference in a new issue