Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-08-26 16:07:19 -04:00
commit 517f1aca20
15 changed files with 2511 additions and 15 deletions

View file

@ -20,6 +20,7 @@
| **Helper** | The privileged, short-lived Rust executor — the only RedFlag mutation path on gated ecosystems. It uses fixed argv and a cleared environment; its current transient unit is not network-isolated. [components/04-helper](components/04-helper.md) | | **Helper** | The privileged, short-lived Rust executor — the only RedFlag mutation path on gated ecosystems. It uses fixed argv and a cleared environment; its current transient unit is not network-isolated. [components/04-helper](components/04-helper.md) |
| **Legacy command path** | Direct signed-command execution for docker / winget / windows_update — ecosystems the capability gate doesn't cover yet. A documented gap, not a feature. [OVERVIEW](OVERVIEW.md) | | **Legacy command path** | Direct signed-command execution for docker / winget / windows_update — ecosystems the capability gate doesn't cover yet. A documented gap, not a feature. [OVERVIEW](OVERVIEW.md) |
| **Machine binding** | Hardware fingerprint registered at enrollment and checked on every authenticated request, including token renewal. A stolen `config.json` is inert elsewhere. [security/04-machine-binding](security/04-machine-binding.md) | | **Machine binding** | Hardware fingerprint registered at enrollment and checked on every authenticated request, including token renewal. A stolen `config.json` is inert elsewhere. [security/04-machine-binding](security/04-machine-binding.md) |
| **Mutation manifest** | Dormant, backend-neutral description of one resolved state change: target, backend, operation, exact backend payloads, and provenance evidence. It is pinned cross-language but not yet used by runtime execution. [security/05-supply-chain-gate](security/05-supply-chain-gate.md) |
| **Nonce** | Per-command signed value with a 10-minute window; agents track executed nonces and reject replays. [verification/04-replay-protection](verification/04-replay-protection.md) | | **Nonce** | Per-command signed value with a 10-minute window; agents track executed nonces and reject replays. [verification/04-replay-protection](verification/04-replay-protection.md) |
| **OSV** | OSV.dev, the open vulnerability database. Queried for discovered packages and for the resolved entries reported after dry-run; verdicts persist and gate approval. An unresolved dependency omitted from that report is not checked by this path. | | **OSV** | OSV.dev, the open vulnerability database. Queried for discovered packages and for the resolved entries reported after dry-run; verdicts persist and gate approval. An unresolved dependency omitted from that report is not checked by this path. |
| **RAF** | This document tree — the RedFlag Architecture Framework, the design of record. What the system is, not what's currently on the task list. | | **RAF** | This document tree — the RedFlag Architecture Framework, the design of record. What the system is, not what's currently on the task list. |

View file

@ -150,6 +150,58 @@ signature = ed25519_sign(authority_priv, signed_message)
The closure is sorted before hashing so ordering can't change the digest. Tampering with any The closure is sorted before hashing so ordering can't change the digest. Tampering with any
artifact, version, or hash changes `closure_hash` and breaks verification. artifact, version, or hash changes `closure_hash` and breaks verification.
### Mutation manifest contract (pinned, not wired)
The closure token above remains the only format the runtime mints and executes. RedFlag also
pins a dormant `MutationEnvelope` contract in Go and Rust so new backends can converge without
extending the closure metaphor or preserving its unsigned `source`/`artifact_path` ambiguity.
No current operation uses this envelope yet.
Those two unsigned fields steer helper *verification* today, not helper *execution*:
`build_plan` reads only name, version, package type, and operation, and the self-update
branches take their source path from a helper constant. The server never populates
`artifact_path` and the agent reports `source=registry`, so the mirror branch is currently
unreached. The defect becomes executable the first time a backend puts a real cached path
in front of the executor — which is why the manifest lands before pacman, not after.
The envelope carries two objects, and the executor answers with a third:
- `MutationManifest`: format, operation ID, target ID, backend, operation kind,
backend-owned resolved-action payloads, and provenance/evidence digests.
- `MutationAuthorization`: manifest hash, authority kind/identity, target ID, issue and
validity times, decision, key ID, and Ed25519 signature.
- `MutationReceipt`: the response half — the operation/manifest/authorization join, the
decision and typed reason, exit code, verified-action count, and timestamp. It is
**not signed**: the executor records what it did inside a boundary that already trusts
it, and is not made a second authority by writing a receipt.
**`target_id` is the RedFlag `agent_id`.** Both copies are signed and a verifier requires
them equal; the executor compares them with the identity it reads for itself from a
root-owned SEC-021-validated file, exactly as it does for a closure token today. The field
name is generic so a later protocol may define another target namespace deliberately —
there is no second namespace today and no `body_id`.
Two shape rules bind the authority rather than the executor. `authorization_id` must be a
canonical UUID v4, the discipline standalone mint already applies to `request_id`, fixed
before that identifier becomes a replay key. And `expires_at - not_before` may not exceed
3600s — `DefaultTokenTTL`, the fleet minter's own window, enforced at signing as well as
at verification.
The manifest hash covers the exact UTF-8 JSON bytes of every backend payload. Fetch/cache
location therefore cannot change outside the signed object. Provenance evidence is a
separate field: a producer or signed-repository claim is not an execution location.
Canonical records are domain-separated and length-prefixed. Resolved actions and evidence
are sorted by canonical bytes, while exact duplicates are retained and change the hash. The
outer action collection is therefore an unordered multiset; a backend that needs ordered
steps must encode their order inside one backend payload. Unknown format values fail closed.
The canonical encoding is specified in `protocol/README.md`. One shared fixture under
`protocol/testdata/` pins canonical manifest bytes, manifest hash,
authorization bytes, key ID, and signature across Server, Agent, and helper. Tamper tests
cover provenance, execution location, target, backend, resolved action, duplicates, ordering,
authorization metadata, validity, decision, and unknown formats.
### Reuse, don't reinvent ### Reuse, don't reinvent
The token extends the existing Ed25519 infrastructure rather than introducing new crypto: The token extends the existing Ed25519 infrastructure rather than introducing new crypto:

View file

@ -12,7 +12,7 @@
|-------|----------------|----------| |-------|----------------|----------|
| Unit (Go) | Crypto verification, replay protection, backoff, machine-id derivation, scanner parsers | `agent/internal/crypto/*_test.go`, `winget_parser_test.go`, `windows_ghost_test.go` | | Unit (Go) | Crypto verification, replay protection, backoff, machine-id derivation, scanner parsers | `agent/internal/crypto/*_test.go`, `winget_parser_test.go`, `windows_ghost_test.go` |
| Unit (Rust) | Helper token verification, hash checks | `helper/` cargo tests | | Unit (Rust) | Helper token verification, hash checks | `helper/` cargo tests |
| Cross-language contract | The capability-token wire contract — Rust and Go must produce **byte-identical** `closure_hash` for the same closure | helper + server test pairs | | Cross-language contract | Current closure-token vectors plus the dormant mutation manifest/authorization envelope — Rust and Go must produce byte-identical canonical bytes, hashes, and signatures | shared `protocol/testdata/` fixture + helper/server/agent tests |
| Structural | Tests that assert properties of the *source*, not behavior — e.g. `token_renewal_transaction_test.go` asserts the renewal handler's transactional shape; `ethos_exempt_test.go` polices logging discipline | server + agent | | Structural | Tests that assert properties of the *source*, not behavior — e.g. `token_renewal_transaction_test.go` asserts the renewal handler's transactional shape; `ethos_exempt_test.go` polices logging discipline | server + agent |
| Migration | Idempotency and schema invariants | `server/internal/database/queries/*_test.go` | | Migration | Idempotency and schema invariants | `server/internal/database/queries/*_test.go` |
| CI | `go vet`, `go test -race`, `cargo test` + clippy, `tsc --noEmit` on every push | `.gitea/workflows/ci.yml` | | CI | `go vet`, `go test -race`, `cargo test` + clippy, `tsc --noEmit` on every push | `.gitea/workflows/ci.yml` |

View 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])
}

View 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,
}
}

View 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")
}
})
}
}

View file

@ -44,13 +44,6 @@ type Token struct {
Signature string `json:"signature"` // hex ed25519 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}") ) )). // ClosureHash computes hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) )).
// Lines are sorted and de-duplicated so neither array order nor exact duplicates // Lines are sorted and de-duplicated so neither array order nor exact duplicates
// can change the digest. This mirrors the Rust BTreeSet construction exactly. // can change the digest. This mirrors the Rust BTreeSet construction exactly.

View file

@ -26,6 +26,9 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq; use subtle::ConstantTimeEq;
#[allow(dead_code)]
mod mutation_protocol;
#[cfg(windows)] #[cfg(windows)]
fn main() { fn main() {
eprintln!( eprintln!(

View file

@ -0,0 +1,694 @@
//! Dormant mutation manifest and authorization contract.
//!
//! Current closure-token execution remains unchanged. This module pins the
//! backend-neutral bytes that Server, Agent, and helper will adopt together.
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
pub const MUTATION_PROTOCOL_VERSION: u32 = 1;
/// Ceiling on `expires_at - not_before`. Derived, not chosen: it is the fleet
/// minter's DefaultTokenTTL. Standalone mint is tighter still at 600s.
pub const MAX_AUTHORIZATION_LIFETIME_SECS: i64 = 3600;
const MANIFEST_DOMAIN: &str = "redflag.mutation-manifest";
const ACTION_DOMAIN: &str = "redflag.resolved-action";
const EVIDENCE_DOMAIN: &str = "redflag.evidence";
const AUTHORIZATION_DOMAIN: &str = "redflag.mutation-authorization";
const RECEIPT_DOMAIN: &str = "redflag.mutation-receipt";
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct ResolvedAction {
pub kind: String,
pub identity: String,
pub payload: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct Evidence {
pub kind: String,
pub digest: String,
}
/// `target_id` MUST be the locally provisioned RedFlag agent identity. The
/// generic name is deliberate: a later protocol may define another namespace.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct MutationManifest {
pub protocol_version: u32,
pub operation_id: String,
pub target_id: String,
pub backend: String,
pub operation: String,
pub resolved_actions: Vec<ResolvedAction>,
pub evidence: Vec<Evidence>,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct MutationAuthorization {
pub protocol_version: u32,
pub authorization_id: String,
pub manifest_hash: String,
pub authority_kind: String,
pub authority_id: String,
pub target_id: String,
pub issued_at: i64,
pub not_before: i64,
pub expires_at: i64,
pub decision: String,
pub key_id: String,
pub signature: String,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct MutationEnvelope {
pub manifest: MutationManifest,
pub authorization: MutationAuthorization,
}
fn write_lp(out: &mut Vec<u8>, value: &[u8]) {
out.extend_from_slice(value.len().to_string().as_bytes());
out.push(b':');
out.extend_from_slice(value);
}
fn canonical_record(domain: &str, values: &[&str]) -> Vec<u8> {
let mut out = Vec::new();
write_lp(&mut out, domain.as_bytes());
for value in values {
write_lp(&mut out, value.as_bytes());
}
out
}
impl ResolvedAction {
fn canonical_bytes(&self) -> Vec<u8> {
canonical_record(ACTION_DOMAIN, &[&self.kind, &self.identity, &self.payload])
}
}
impl Evidence {
fn canonical_bytes(&self) -> Vec<u8> {
canonical_record(EVIDENCE_DOMAIN, &[&self.kind, &self.digest])
}
}
impl MutationManifest {
pub fn canonical_bytes(&self) -> Vec<u8> {
let version = self.protocol_version.to_string();
let mut out = Vec::new();
write_lp(&mut out, MANIFEST_DOMAIN.as_bytes());
for value in [
version.as_str(),
self.operation_id.as_str(),
self.target_id.as_str(),
self.backend.as_str(),
self.operation.as_str(),
] {
write_lp(&mut out, value.as_bytes());
}
let mut actions: Vec<Vec<u8>> = self
.resolved_actions
.iter()
.map(ResolvedAction::canonical_bytes)
.collect();
actions.sort();
write_lp(&mut out, actions.len().to_string().as_bytes());
for action in actions {
write_lp(&mut out, &action);
}
let mut evidence: Vec<Vec<u8>> = self
.evidence
.iter()
.map(Evidence::canonical_bytes)
.collect();
evidence.sort();
write_lp(&mut out, evidence.len().to_string().as_bytes());
for item in evidence {
write_lp(&mut out, &item);
}
out
}
pub fn hash(&self) -> String {
hex::encode(Sha256::digest(self.canonical_bytes()))
}
pub fn validate(&self) -> Result<(), String> {
if self.protocol_version != MUTATION_PROTOCOL_VERSION {
return Err(format!(
"mutation protocol: unsupported manifest version {}",
self.protocol_version
));
}
for (name, value) in [
("operation_id", self.operation_id.as_str()),
("target_id", self.target_id.as_str()),
("backend", self.backend.as_str()),
("operation", self.operation.as_str()),
] {
if value.is_empty() {
return Err(format!("mutation protocol: manifest {name} is empty"));
}
}
if self.resolved_actions.is_empty() {
return Err("mutation protocol: manifest has no resolved actions".into());
}
for (index, action) in self.resolved_actions.iter().enumerate() {
if action.kind.is_empty() || action.identity.is_empty() || action.payload.is_empty() {
return Err(format!(
"mutation protocol: resolved action {index} is incomplete"
));
}
if serde_json::from_str::<serde_json::Value>(&action.payload).is_err() {
return Err(format!(
"mutation protocol: resolved action {index} payload is not JSON"
));
}
}
for (index, evidence) in self.evidence.iter().enumerate() {
if evidence.kind.is_empty() {
return Err(format!("mutation protocol: evidence {index} kind is empty"));
}
let digest = hex::decode(&evidence.digest).map_err(|_| {
format!("mutation protocol: evidence {index} digest is not SHA-256 hex")
})?;
if digest.len() != 32 {
return Err(format!(
"mutation protocol: evidence {index} digest is not SHA-256 hex"
));
}
}
Ok(())
}
}
impl MutationAuthorization {
pub fn canonical_message(&self) -> Vec<u8> {
let version = self.protocol_version.to_string();
let issued_at = self.issued_at.to_string();
let not_before = self.not_before.to_string();
let expires_at = self.expires_at.to_string();
canonical_record(
AUTHORIZATION_DOMAIN,
&[
&version,
&self.authorization_id,
&self.manifest_hash,
&self.authority_kind,
&self.authority_id,
&self.target_id,
&issued_at,
&not_before,
&expires_at,
&self.decision,
&self.key_id,
],
)
}
pub fn verify(
&self,
public_key: &VerifyingKey,
manifest: &MutationManifest,
) -> Result<(), String> {
manifest.validate()?;
self.validate_shape()?;
if self.target_id != manifest.target_id {
return Err(
"mutation protocol: authorization target does not match manifest target".into(),
);
}
if self.manifest_hash != manifest.hash() {
return Err("mutation protocol: authorization manifest hash mismatch".into());
}
let expected_key_id = key_id_for(public_key.as_bytes());
if self.key_id != expected_key_id {
return Err("mutation protocol: authorization key id mismatch".into());
}
let signature_bytes = hex::decode(&self.signature)
.map_err(|_| "mutation protocol: malformed signature".to_string())?;
let signature = Signature::from_slice(&signature_bytes)
.map_err(|_| "mutation protocol: malformed signature".to_string())?;
public_key
.verify(&self.canonical_message(), &signature)
.map_err(|_| "mutation protocol: signature verification failed".to_string())
}
pub fn verify_for_execution_at(
&self,
public_key: &VerifyingKey,
manifest: &MutationManifest,
now: i64,
) -> Result<(), String> {
self.verify(public_key, manifest)?;
if self.decision != "allow" {
return Err(format!(
"mutation protocol: authorization decision is {:?}",
self.decision
));
}
if now < self.not_before || now > self.expires_at {
return Err("mutation protocol: authorization is outside its time window".into());
}
Ok(())
}
fn validate_shape(&self) -> Result<(), String> {
if self.protocol_version != MUTATION_PROTOCOL_VERSION {
return Err(format!(
"mutation protocol: unsupported authorization version {}",
self.protocol_version
));
}
for (name, value) in [
("authorization_id", self.authorization_id.as_str()),
("authority_kind", self.authority_kind.as_str()),
("authority_id", self.authority_id.as_str()),
("target_id", self.target_id.as_str()),
("decision", self.decision.as_str()),
] {
if value.is_empty() {
return Err(format!("mutation protocol: authorization {name} is empty"));
}
}
if !is_canonical_uuid_v4(&self.authorization_id) {
return Err("mutation protocol: authorization_id is not a canonical UUID v4".into());
}
if self.issued_at <= 0
|| self.not_before < self.issued_at
|| self.expires_at <= self.not_before
{
return Err("mutation protocol: invalid authorization time window".into());
}
if self.expires_at - self.not_before > MAX_AUTHORIZATION_LIFETIME_SECS {
return Err(format!(
"mutation protocol: authorization lifetime {}s exceeds the {}s ceiling",
self.expires_at - self.not_before,
MAX_AUTHORIZATION_LIFETIME_SECS
));
}
Ok(())
}
}
impl MutationEnvelope {
pub fn verify_for_execution_at(
&self,
public_key: &VerifyingKey,
now: i64,
) -> Result<(), String> {
self.authorization
.verify_for_execution_at(public_key, &self.manifest, now)
}
/// Copies the audit join out of signed bytes rather than retyping it.
pub fn receipt_for(&self, outcome: MutationOutcome, now: i64) -> MutationReceipt {
MutationReceipt {
protocol_version: MUTATION_PROTOCOL_VERSION,
operation_id: self.manifest.operation_id.clone(),
manifest_hash: self.manifest.hash(),
authorization_id: self.authorization.authorization_id.clone(),
target_id: self.manifest.target_id.clone(),
backend: self.manifest.backend.clone(),
operation: self.manifest.operation.clone(),
decision: outcome.decision,
reason: outcome.reason,
executed: outcome.executed,
verified_actions: outcome.verified_actions,
exit_code: outcome.exit_code,
error: outcome.detail,
timestamp: now,
}
}
}
/// What the executor did, separate from which envelope it did it to.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MutationOutcome {
pub decision: String,
pub reason: String,
pub executed: bool,
pub verified_actions: u32,
pub exit_code: i32,
pub detail: String,
}
/// The response half of the contract: what the privileged executor did with one
/// envelope. Carries the operation/manifest/authorization join so a local
/// receipt and a server history row join without either guessing.
///
/// Not signed. The executor is not a second authority.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct MutationReceipt {
pub protocol_version: u32,
pub operation_id: String,
pub manifest_hash: String,
pub authorization_id: String,
pub target_id: String,
pub backend: String,
pub operation: String,
pub decision: String,
pub reason: String,
pub executed: bool,
pub verified_actions: u32,
pub exit_code: i32,
#[serde(default)]
pub error: String,
pub timestamp: i64,
}
impl MutationReceipt {
pub fn canonical_bytes(&self) -> Vec<u8> {
let version = self.protocol_version.to_string();
let executed = if self.executed { "true" } else { "false" };
let verified_actions = self.verified_actions.to_string();
let exit_code = self.exit_code.to_string();
let timestamp = self.timestamp.to_string();
canonical_record(
RECEIPT_DOMAIN,
&[
&version,
&self.operation_id,
&self.manifest_hash,
&self.authorization_id,
&self.target_id,
&self.backend,
&self.operation,
&self.decision,
&self.reason,
executed,
&verified_actions,
&exit_code,
&self.error,
&timestamp,
],
)
}
pub fn digest(&self) -> String {
hex::encode(Sha256::digest(self.canonical_bytes()))
}
}
pub fn key_id_for(public_key: &[u8; 32]) -> String {
let digest = Sha256::digest(public_key);
hex::encode(&digest[..16])
}
/// 8-4-4-4-12 lowercase hex with the version (4) and variant (8/9/a/b) nibbles
/// set. Same discipline the standalone mint applies to request_id, applied here
/// before authorization_id can become an executor replay key: a newline-delimited
/// ledger matched by exact line has no defence against an embedded newline.
pub fn is_canonical_uuid_v4(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() != 36 {
return false;
}
for (i, &b) in bytes.iter().enumerate() {
let is_separator = matches!(i, 8 | 13 | 18 | 23);
if is_separator {
if b != b'-' {
return false;
}
} else if !b.is_ascii_hexdigit() || b.is_ascii_uppercase() {
return false;
}
}
bytes[14] == b'4' && matches!(bytes[19], b'8' | b'9' | b'a' | b'b')
}
#[cfg(test)]
mod tests {
use ed25519_dalek::{Signer, SigningKey};
use serde::Deserialize;
use super::*;
#[derive(Deserialize)]
struct GoldenFixture {
manifest: MutationManifest,
authorization: MutationAuthorization,
receipt: MutationReceipt,
test_seed: String,
expected_manifest_canonical_hex: String,
expected_manifest_hash: String,
expected_authorization_canonical_hex: String,
expected_key_id: String,
expected_signature: String,
expected_receipt_canonical_hex: String,
expected_receipt_digest: String,
}
fn fixture() -> GoldenFixture {
serde_json::from_str(include_str!("../../protocol/testdata/mutation-golden.json"))
.expect("shared mutation fixture must parse")
}
fn signed_fixture() -> (GoldenFixture, MutationAuthorization, VerifyingKey) {
let fixture = fixture();
let seed: [u8; 32] = hex::decode(&fixture.test_seed)
.expect("seed hex")
.try_into()
.expect("32-byte seed");
let signing_key = SigningKey::from_bytes(&seed);
let verifying_key = signing_key.verifying_key();
let mut authorization = fixture.authorization.clone();
authorization.manifest_hash = fixture.manifest.hash();
authorization.key_id = key_id_for(verifying_key.as_bytes());
authorization.signature = hex::encode(
signing_key
.sign(&authorization.canonical_message())
.to_bytes(),
);
(fixture, authorization, verifying_key)
}
#[test]
fn golden_vector_matches_go() {
let (fixture, authorization, verifying_key) = signed_fixture();
if fixture.expected_manifest_hash.is_empty() {
panic!(
"fill fixture: manifest_canonical={}\nmanifest_hash={}\nauthorization_canonical={}\nkey_id={}\nsignature={}",
hex::encode(fixture.manifest.canonical_bytes()),
fixture.manifest.hash(),
hex::encode(authorization.canonical_message()),
authorization.key_id,
authorization.signature,
);
}
assert_eq!(
hex::encode(fixture.manifest.canonical_bytes()),
fixture.expected_manifest_canonical_hex
);
assert_eq!(fixture.manifest.hash(), fixture.expected_manifest_hash);
assert_eq!(
hex::encode(authorization.canonical_message()),
fixture.expected_authorization_canonical_hex
);
assert_eq!(authorization.key_id, fixture.expected_key_id);
assert_eq!(authorization.signature, fixture.expected_signature);
assert_eq!(
hex::encode(fixture.receipt.canonical_bytes()),
fixture.expected_receipt_canonical_hex
);
assert_eq!(fixture.receipt.digest(), fixture.expected_receipt_digest);
MutationEnvelope {
manifest: fixture.manifest.clone(),
authorization,
}
.verify_for_execution_at(&verifying_key, 1_700_000_100)
.expect("golden authorization must verify");
}
#[test]
fn ordering_is_irrelevant_but_duplicates_are_not_erased() {
let (fixture, authorization, verifying_key) = signed_fixture();
let mut reordered = fixture.manifest.clone();
reordered.resolved_actions.reverse();
reordered.evidence.reverse();
assert_eq!(reordered.hash(), fixture.manifest.hash());
authorization
.verify(&verifying_key, &reordered)
.expect("ordering must not change authority");
let mut duplicate = fixture.manifest.clone();
duplicate
.resolved_actions
.push(duplicate.resolved_actions[0].clone());
assert_ne!(duplicate.hash(), fixture.manifest.hash());
assert!(authorization.verify(&verifying_key, &duplicate).is_err());
}
#[test]
fn executor_affecting_tamper_is_refused() {
let (fixture, authorization, verifying_key) = signed_fixture();
let mut changed = fixture.manifest.clone();
changed.evidence[0].digest = "cc".repeat(32);
assert!(authorization.verify(&verifying_key, &changed).is_err());
let mut changed = fixture.manifest.clone();
changed.resolved_actions[0].payload = changed.resolved_actions[0]
.payload
.replace("/var/cache/redflag", "/tmp");
assert!(authorization.verify(&verifying_key, &changed).is_err());
let mut changed = fixture.manifest.clone();
changed.target_id = "body-other".into();
assert!(authorization.verify(&verifying_key, &changed).is_err());
let mut changed = fixture.manifest.clone();
changed.backend = "wua".into();
assert!(authorization.verify(&verifying_key, &changed).is_err());
let mut changed = fixture.manifest.clone();
changed.resolved_actions[0].identity = "zsh@6.0-1".into();
assert!(authorization.verify(&verifying_key, &changed).is_err());
let mut changed = authorization.clone();
changed.issued_at += 1;
assert!(changed.verify(&verifying_key, &fixture.manifest).is_err());
}
#[test]
fn unknown_formats_and_non_executable_authority_fail_closed() {
let (fixture, authorization, verifying_key) = signed_fixture();
let mut changed = fixture.manifest.clone();
changed.protocol_version += 1;
assert!(changed.validate().is_err());
let mut changed = authorization.clone();
changed.protocol_version += 1;
assert!(changed.verify(&verifying_key, &fixture.manifest).is_err());
let mut denied = authorization.clone();
denied.decision = "deny".into();
let seed: [u8; 32] = hex::decode(&fixture.test_seed)
.expect("seed hex")
.try_into()
.expect("32-byte seed");
denied.signature = hex::encode(
SigningKey::from_bytes(&seed)
.sign(&denied.canonical_message())
.to_bytes(),
);
assert!(denied
.verify_for_execution_at(&verifying_key, &fixture.manifest, 1_700_000_100)
.is_err());
assert!(authorization
.verify_for_execution_at(
&verifying_key,
&fixture.manifest,
authorization.expires_at + 1
)
.is_err());
}
#[test]
fn authorization_id_must_be_canonical_uuid_v4() {
let (fixture, authorization, verifying_key) = signed_fixture();
for bad in [
"",
"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",
] {
assert!(!is_canonical_uuid_v4(bad), "accepted {bad:?}");
let mut changed = authorization.clone();
changed.authorization_id = bad.into();
assert!(changed.verify(&verifying_key, &fixture.manifest).is_err());
}
assert!(is_canonical_uuid_v4(&authorization.authorization_id));
}
#[test]
fn authorization_lifetime_ceiling_is_enforced() {
let (fixture, authorization, verifying_key) = signed_fixture();
let seed: [u8; 32] = hex::decode(&fixture.test_seed)
.expect("seed hex")
.try_into()
.expect("32-byte seed");
let signing_key = SigningKey::from_bytes(&seed);
let mut at_ceiling = authorization.clone();
at_ceiling.expires_at = at_ceiling.not_before + MAX_AUTHORIZATION_LIFETIME_SECS;
at_ceiling.signature =
hex::encode(signing_key.sign(&at_ceiling.canonical_message()).to_bytes());
at_ceiling
.verify(&verifying_key, &fixture.manifest)
.expect("an authorization exactly at the ceiling must verify");
let mut over = authorization.clone();
over.expires_at = over.not_before + MAX_AUTHORIZATION_LIFETIME_SECS + 1;
over.signature = hex::encode(signing_key.sign(&over.canonical_message()).to_bytes());
assert!(over.verify(&verifying_key, &fixture.manifest).is_err());
}
// Evidence carries digests. Operator reason prose stays in the journal, and
// the shape check is what keeps it out.
#[test]
fn evidence_carries_digests_not_prose() {
let fixture = fixture();
for bad in ["", "operator accepted the CVE risk", &"a".repeat(63), &"z".repeat(64)] {
let mut changed = fixture.manifest.clone();
changed.evidence[0].digest = bad.into();
assert!(changed.validate().is_err(), "validated digest {bad:?}");
}
}
#[test]
fn target_binds_manifest_and_authorization() {
let (fixture, authorization, verifying_key) = signed_fixture();
assert_eq!(fixture.manifest.target_id, authorization.target_id);
let mut split = authorization.clone();
split.target_id = "6f1e2d3c-4b5a-4998-8877-665544332211".into();
assert!(split.verify(&verifying_key, &fixture.manifest).is_err());
}
#[test]
fn receipt_carries_the_audit_join() {
let (fixture, authorization, _) = signed_fixture();
let envelope = MutationEnvelope {
manifest: fixture.manifest.clone(),
authorization: authorization.clone(),
};
let receipt = envelope.receipt_for(
MutationOutcome {
decision: "denied".into(),
reason: "backend_not_migrated".into(),
executed: false,
verified_actions: 0,
exit_code: 18,
detail: "backend=pacman".into(),
},
1_700_000_100,
);
assert_eq!(receipt.operation_id, fixture.manifest.operation_id);
assert_eq!(receipt.manifest_hash, fixture.manifest.hash());
assert_eq!(receipt.authorization_id, authorization.authorization_id);
assert_eq!(receipt.target_id, fixture.manifest.target_id);
assert_eq!(receipt.backend, fixture.manifest.backend);
// The fields a lossy report drops first are still in the digest.
for mutate in [
(|r: &mut MutationReceipt| r.decision = "executed".into()) as fn(&mut MutationReceipt),
|r: &mut MutationReceipt| r.reason = "operation_completed".into(),
|r: &mut MutationReceipt| r.executed = true,
|r: &mut MutationReceipt| r.verified_actions = 1,
|r: &mut MutationReceipt| r.exit_code = 0,
|r: &mut MutationReceipt| r.error = String::new(),
|r: &mut MutationReceipt| r.timestamp += 1,
] {
let mut changed = receipt.clone();
mutate(&mut changed);
assert_ne!(changed.digest(), receipt.digest());
}
}
}

211
protocol/README.md Normal file
View file

@ -0,0 +1,211 @@
# Mutation manifest wire contract
This directory owns the language-independent bytes shared by RedFlag Server, Agent, and
helper. The current closure-based capability token remains the only runtime execution path;
the mutation envelope is pinned and tested but not wired to a backend yet.
## Envelope
```text
MutationEnvelope
├── MutationManifest
│ ├── protocol_version
│ ├── operation_id
│ ├── target_id
│ ├── backend
│ ├── operation
│ ├── resolved_actions[]
│ │ ├── kind
│ │ ├── identity
│ │ └── payload exact backend-owned UTF-8 JSON bytes
│ └── evidence[]
│ ├── kind
│ └── digest SHA-256 hex
└── MutationAuthorization
├── protocol_version
├── authorization_id
├── manifest_hash
├── authority_kind
├── authority_id
├── target_id
├── issued_at / not_before / expires_at
├── decision
├── key_id
└── signature
MutationReceipt the response half; unsigned, produced by the executor
├── protocol_version
├── operation_id / manifest_hash / authorization_id the audit join
├── target_id
├── backend / operation
├── decision / reason
├── executed
├── verified_actions
├── exit_code
├── error
└── timestamp
```
The protocol envelope begins at format `1` in its own namespace. It is not a new numbered
generation of `CapabilityToken`; the two named contracts coexist until a backend migrates.
Unknown format values fail closed.
## Target identity
`target_id` names the body being mutated. For RedFlag today the mapping is normative and
narrow:
> **`target_id` MUST equal the locally provisioned RedFlag `agent_id`** — the identity the
> executor reads for itself from a root-owned, SEC-021-validated file, never from the
> envelope.
The manifest and the authorization each carry it, both inside signed bytes, and a verifier
requires them equal. An executor binds the envelope by comparing them with the host
identity it read independently.
The field keeps a generic name so a later protocol may define another target namespace
deliberately. Until one exists, there is no second namespace and no `body_id` — inventing
vocabulary ahead of the thing it names would weaken a binding that already works.
## Authorization discipline
`authorization_id` MUST be a canonical UUID v4 (8-4-4-4-12 lowercase hex, version nibble
`4`, variant nibble `8`/`9`/`a`/`b`) — the same discipline the standalone mint already
applies to `request_id`. It is the identifier an executor will eventually record as a
replay key, and a newline-delimited ledger matched line-by-line has no defence against an
identifier that contains a newline. The shape is checked before that day arrives.
`expires_at - not_before` MUST NOT exceed **3600 seconds**. That ceiling is derived, not
chosen: it is `DefaultTokenTTL`, the fleet minter's own window. Standalone mint is tighter
still at 600s. Both the signer and the verifier enforce it, so an over-long authorization
cannot be minted, not merely refused at the end.
## Canonical records
Every value is encoded as UTF-8 `decimal_byte_length:value`, with no separator between
fields. Each record begins with a length-prefixed domain string.
Manifest field order:
```text
redflag.mutation-manifest
protocol_version
operation_id
target_id
backend
operation
resolved_action_count
sorted(length-prefixed resolved-action records)
evidence_count
sorted(length-prefixed evidence records)
```
Resolved action field order:
```text
redflag.resolved-action
kind
identity
payload
```
Evidence field order:
```text
redflag.evidence
kind
digest
```
Authorization field order:
```text
redflag.mutation-authorization
protocol_version
authorization_id
manifest_hash
authority_kind
authority_id
target_id
issued_at
not_before
expires_at
decision
key_id
```
`manifest_hash` is lowercase hex SHA-256 over the manifest canonical bytes. The Ed25519
signature is over the authorization canonical bytes. The signature itself is not included
in those bytes; every other authorization field, including `key_id`, is.
## Collection semantics
Resolved actions and evidence are unordered multisets. Their canonical records are sorted
bytewise for hashing. Exact duplicates remain present, increment the count, and change the
hash. No implementation may convert either collection to a set.
A backend that needs ordered execution encodes the sequence inside one signed payload. The
common layer never infers package, WUA, Winget, Docker, or self-update semantics from that
payload.
Provenance is evidence. Cache paths, URLs, repository selectors, WUA identities, and other
execution inputs belong in the exact signed backend payload or must be derived
deterministically from it.
Evidence carries **digests, not prose**. A gate verdict, an operator identity, and an
override reason are the authority's own record and stay in its journal; what travels in
the manifest is a digest binding the decision to the evidence it was made over. The
SHA-256 shape check is what enforces that — reason text cannot be smuggled into a signed
manifest through an evidence value.
## Receipt
The receipt is the response half of the contract. It carries the audit join — operation
ID, manifest hash, authorization ID — so a local enforcement record and a Server history
row join on a tuple neither side had to guess. `decision` and `reason` keep the existing
`PolicyResult` taxonomy rather than inventing a second one.
**The receipt is not signed.** The executor is not a second cryptographic authority; this
is a record produced inside the trust boundary that already ran, or refused, the
operation. Its canonical bytes exist so a ledger can digest one without re-deriving field
order from JSON, not so anyone can verify it.
Receipt field order:
```text
redflag.mutation-receipt
protocol_version
operation_id
manifest_hash
authorization_id
target_id
backend
operation
decision
reason
executed "true" | "false"
verified_actions
exit_code
error
timestamp
```
Every field is present even when empty: a refusal that happens before the envelope parses
still produces a receipt, and its emptiness is part of the record.
## Golden fixture
`testdata/mutation-golden.json` pins:
- manifest canonical bytes and hash;
- authorization canonical bytes;
- authority key ID;
- deterministic Ed25519 signature;
- action/evidence ordering behavior;
- duplicate retention;
- receipt canonical bytes and digest;
- authorization_id shape and the authorization lifetime ceiling;
- tamper refusal for provenance, execution location, target, backend, resolved action,
authorization metadata, decision, time window, and unknown formats.
Go tests live in both capability packages. Rust tests live in `helper/src/mutation_protocol.rs`.

69
protocol/testdata/mutation-golden.json vendored Normal file
View file

@ -0,0 +1,69 @@
{
"manifest": {
"protocol_version": 1,
"operation_id": "550e8400-e29b-41d4-a716-446655440010",
"target_id": "7c0f4e2a-9b31-4d55-8a6e-1f2c3d4e5f60",
"backend": "pacman",
"operation": "install",
"resolved_actions": [
{
"kind": "package",
"identity": "zsh@5.9-2",
"payload": "{\"artifact_sha256\":\"2222222222222222222222222222222222222222222222222222222222222222\",\"execution_location\":{\"kind\":\"cache\",\"value\":\"/var/cache/redflag/zsh.pkg.tar.zst\"},\"repository\":\"core\"}"
},
{
"kind": "package",
"identity": "acl@2.3.2-1",
"payload": "{\"artifact_sha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"execution_location\":{\"kind\":\"cache\",\"value\":\"/var/cache/redflag/acl.pkg.tar.zst\"},\"repository\":\"core\"}"
}
],
"evidence": [
{
"kind": "repository-metadata",
"digest": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
},
{
"kind": "producer",
"digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
]
},
"authorization": {
"protocol_version": 1,
"authorization_id": "550e8400-e29b-41d4-a716-446655440011",
"manifest_hash": "",
"authority_kind": "fleet-server",
"authority_id": "redflag-prod",
"target_id": "7c0f4e2a-9b31-4d55-8a6e-1f2c3d4e5f60",
"issued_at": 1700000000,
"not_before": 1700000001,
"expires_at": 1700000600,
"decision": "allow",
"key_id": "",
"signature": ""
},
"receipt": {
"protocol_version": 1,
"operation_id": "550e8400-e29b-41d4-a716-446655440010",
"manifest_hash": "67138d0644874a3feb53f9b7777df1af01d8e8ed43678755aaef9b447b38bbf0",
"authorization_id": "550e8400-e29b-41d4-a716-446655440011",
"target_id": "7c0f4e2a-9b31-4d55-8a6e-1f2c3d4e5f60",
"backend": "pacman",
"operation": "install",
"decision": "denied",
"reason": "backend_not_migrated",
"executed": false,
"verified_actions": 0,
"exit_code": 18,
"error": "backend=pacman",
"timestamp": 1700000100
},
"test_seed": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"expected_manifest_canonical_hex": "32353a726564666c61672e6d75746174696f6e2d6d616e6966657374313a3133363a35353065383430302d653239622d343164342d613731362d34343636353534343030313033363a37633066346532612d396233312d346435352d386136652d316632633364346535663630363a7061636d616e373a696e7374616c6c313a323234323a32333a726564666c61672e7265736f6c7665642d616374696f6e373a7061636b61676531313a61636c40322e332e322d313138393a7b2261727469666163745f736861323536223a2231313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131222c22657865637574696f6e5f6c6f636174696f6e223a7b226b696e64223a226361636865222c2276616c7565223a222f7661722f63616368652f726564666c61672f61636c2e706b672e7461722e7a7374227d2c227265706f7369746f7279223a22636f7265227d3233393a32333a726564666c61672e7265736f6c7665642d616374696f6e373a7061636b616765393a7a736840352e392d323138393a7b2261727469666163745f736861323536223a2232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232222c22657865637574696f6e5f6c6f636174696f6e223a7b226b696e64223a226361636865222c2276616c7565223a222f7661722f63616368652f726564666c61672f7a73682e706b672e7461722e7a7374227d2c227265706f7369746f7279223a22636f7265227d313a323130383a31363a726564666c61672e65766964656e636531393a7265706f7369746f72792d6d6574616461746136343a6262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626239363a31363a726564666c61672e65766964656e6365383a70726f647563657236343a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161",
"expected_manifest_hash": "67138d0644874a3feb53f9b7777df1af01d8e8ed43678755aaef9b447b38bbf0",
"expected_authorization_canonical_hex": "33303a726564666c61672e6d75746174696f6e2d617574686f72697a6174696f6e313a3133363a35353065383430302d653239622d343164342d613731362d34343636353534343030313136343a3637313338643036343438373461336665623533663962373737376466316166303164386538656434333637383735356161656639623434376233386262663031323a666c6565742d73657276657231323a726564666c61672d70726f6433363a37633066346532612d396233312d346435352d386136652d31663263336434653566363031303a3137303030303030303031303a3137303030303030303131303a31373030303030363030353a616c6c6f7733323a3536343735616137353436333437346330323835646635646266326263616237",
"expected_key_id": "56475aa75463474c0285df5dbf2bcab7",
"expected_signature": "0e00c4b6e552af2e99794a5ad86827e25d2a9046822e0f287e4f231e907b6d914b6e7b09d12523378458a22388f4206d313b140d6aebdfbd26dd3db5a620c600",
"expected_receipt_canonical_hex": "32343a726564666c61672e6d75746174696f6e2d72656365697074313a3133363a35353065383430302d653239622d343164342d613731362d34343636353534343030313036343a3637313338643036343438373461336665623533663962373737376466316166303164386538656434333637383735356161656639623434376233386262663033363a35353065383430302d653239622d343164342d613731362d34343636353534343030313133363a37633066346532612d396233312d346435352d386136652d316632633364346535663630363a7061636d616e373a696e7374616c6c363a64656e69656432303a6261636b656e645f6e6f745f6d69677261746564353a66616c7365313a30323a313831343a6261636b656e643d7061636d616e31303a31373030303030313030",
"expected_receipt_digest": "ae3278be71bb142b5cb0dde16b6b84516cec514ebc247a012312ffd18dccade0"
}

View 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])
}

View 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,
}
}

View 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")
}
})
}
}

View file

@ -44,13 +44,6 @@ type Token struct {
Signature string `json:"signature"` // hex ed25519 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}") ) )). // ClosureHash computes hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) )).
// Lines are sorted and de-duplicated so neither array order nor exact duplicates // Lines are sorted and de-duplicated so neither array order nor exact duplicates
// can change the digest. This mirrors the Rust BTreeSet construction exactly. // can change the digest. This mirrors the Rust BTreeSet construction exactly.