Watch
1
0
Fork
You've already forked RedFlag
0

feat: pin mutation manifest authority bytes

Introduce a dormant MutationEnvelope beside the current closure token. Server, Agent, and helper now agree on length-prefixed manifest and authorization bytes, with one shared fixture pinning the hash, key identity, and Ed25519 signature.\n\nExecutor-affecting provenance, location, target, backend, and backend payload now have a signed home before any runtime path migrates. Exact duplicates stay visible in the hash; ordering does not. Current APT/DNF and Windows behavior remains untouched.
This commit is contained in:
Fimeg 2026-08-26 16:07:19 -04:00
commit f5eb83ca94
11 changed files with 1529 additions and 1 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,35 @@ 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.
The envelope carries two objects:
- `MutationManifest`: format, operation ID, target/body ID, backend, operation kind,
backend-owned resolved-action payloads, and provenance/evidence digests.
- `MutationAuthorization`: manifest hash, authority kind/identity, target/body ID, issue and
validity times, decision, key ID, and Ed25519 signature.
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,268 @@
package capability
import (
"bytes"
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
)
const (
// MutationProtocolVersion belongs to the manifest namespace, independently
// of the current closure-based Token format.
MutationProtocolVersion = 1
manifestDomain = "redflag.mutation-manifest"
actionDomain = "redflag.resolved-action"
evidenceDomain = "redflag.evidence"
authorizationDomain = "redflag.mutation-authorization"
)
type ResolvedAction struct {
Kind string `json:"kind"`
Identity string `json:"identity"`
Payload string `json:"payload"`
}
type Evidence struct {
Kind string `json:"kind"`
Digest string `json:"digest"`
}
type MutationManifest struct {
ProtocolVersion int `json:"protocol_version"`
OperationID string `json:"operation_id"`
TargetID string `json:"target_id"`
Backend string `json:"backend"`
Operation string `json:"operation"`
ResolvedActions []ResolvedAction `json:"resolved_actions"`
Evidence []Evidence `json:"evidence"`
}
type MutationAuthorization struct {
ProtocolVersion int `json:"protocol_version"`
AuthorizationID string `json:"authorization_id"`
ManifestHash string `json:"manifest_hash"`
AuthorityKind string `json:"authority_kind"`
AuthorityID string `json:"authority_id"`
TargetID string `json:"target_id"`
IssuedAt int64 `json:"issued_at"`
NotBefore int64 `json:"not_before"`
ExpiresAt int64 `json:"expires_at"`
Decision string `json:"decision"`
KeyID string `json:"key_id"`
Signature string `json:"signature"`
}
type MutationEnvelope struct {
Manifest MutationManifest `json:"manifest"`
Authorization MutationAuthorization `json:"authorization"`
}
func writeLP(buf *bytes.Buffer, value []byte) {
buf.WriteString(strconv.Itoa(len(value)))
buf.WriteByte(':')
buf.Write(value)
}
func canonicalRecord(domain string, values ...string) []byte {
var buf bytes.Buffer
writeLP(&buf, []byte(domain))
for _, value := range values {
writeLP(&buf, []byte(value))
}
return buf.Bytes()
}
func (a ResolvedAction) canonicalBytes() []byte {
return canonicalRecord(actionDomain, a.Kind, a.Identity, a.Payload)
}
func (e Evidence) canonicalBytes() []byte {
return canonicalRecord(evidenceDomain, e.Kind, e.Digest)
}
func sortedRecords[T any](values []T, encode func(T) []byte) [][]byte {
records := make([][]byte, 0, len(values))
for _, value := range values {
records = append(records, encode(value))
}
sort.Slice(records, func(i, j int) bool { return bytes.Compare(records[i], records[j]) < 0 })
return records
}
func (m MutationManifest) CanonicalBytes() []byte {
var buf bytes.Buffer
writeLP(&buf, []byte(manifestDomain))
for _, value := range []string{
strconv.Itoa(m.ProtocolVersion),
m.OperationID,
m.TargetID,
m.Backend,
m.Operation,
} {
writeLP(&buf, []byte(value))
}
actions := sortedRecords(m.ResolvedActions, func(a ResolvedAction) []byte { return a.canonicalBytes() })
writeLP(&buf, []byte(strconv.Itoa(len(actions))))
for _, action := range actions {
writeLP(&buf, action)
}
evidence := sortedRecords(m.Evidence, func(e Evidence) []byte { return e.canonicalBytes() })
writeLP(&buf, []byte(strconv.Itoa(len(evidence))))
for _, item := range evidence {
writeLP(&buf, item)
}
return buf.Bytes()
}
func (m MutationManifest) Hash() string {
digest := sha256.Sum256(m.CanonicalBytes())
return hex.EncodeToString(digest[:])
}
func (m MutationManifest) Validate() error {
if m.ProtocolVersion != MutationProtocolVersion {
return fmt.Errorf("mutation protocol: unsupported manifest version %d", m.ProtocolVersion)
}
for _, field := range [][2]string{
{"operation_id", m.OperationID},
{"target_id", m.TargetID},
{"backend", m.Backend},
{"operation", m.Operation},
} {
name, value := field[0], field[1]
if value == "" {
return fmt.Errorf("mutation protocol: manifest %s is empty", name)
}
}
if len(m.ResolvedActions) == 0 {
return fmt.Errorf("mutation protocol: manifest has no resolved actions")
}
for i, action := range m.ResolvedActions {
if action.Kind == "" || action.Identity == "" || action.Payload == "" {
return fmt.Errorf("mutation protocol: resolved action %d is incomplete", i)
}
if !json.Valid([]byte(action.Payload)) {
return fmt.Errorf("mutation protocol: resolved action %d payload is not JSON", i)
}
}
for i, evidence := range m.Evidence {
if evidence.Kind == "" {
return fmt.Errorf("mutation protocol: evidence %d kind is empty", i)
}
decoded, err := hex.DecodeString(evidence.Digest)
if err != nil || len(decoded) != sha256.Size {
return fmt.Errorf("mutation protocol: evidence %d digest is not SHA-256 hex", i)
}
}
return nil
}
func (a MutationAuthorization) CanonicalMessage() []byte {
return canonicalRecord(
authorizationDomain,
strconv.Itoa(a.ProtocolVersion),
a.AuthorizationID,
a.ManifestHash,
a.AuthorityKind,
a.AuthorityID,
a.TargetID,
strconv.FormatInt(a.IssuedAt, 10),
strconv.FormatInt(a.NotBefore, 10),
strconv.FormatInt(a.ExpiresAt, 10),
a.Decision,
a.KeyID,
)
}
func (a MutationAuthorization) validateShape() error {
if a.ProtocolVersion != MutationProtocolVersion {
return fmt.Errorf("mutation protocol: unsupported authorization version %d", a.ProtocolVersion)
}
for _, field := range [][2]string{
{"authorization_id", a.AuthorizationID},
{"authority_kind", a.AuthorityKind},
{"authority_id", a.AuthorityID},
{"target_id", a.TargetID},
{"decision", a.Decision},
} {
name, value := field[0], field[1]
if value == "" {
return fmt.Errorf("mutation protocol: authorization %s is empty", name)
}
}
if a.IssuedAt <= 0 || a.NotBefore < a.IssuedAt || a.ExpiresAt <= a.NotBefore {
return fmt.Errorf("mutation protocol: invalid authorization time window")
}
return nil
}
func (a *MutationAuthorization) Sign(priv ed25519.PrivateKey, manifest MutationManifest) error {
if len(priv) != ed25519.PrivateKeySize {
return fmt.Errorf("mutation protocol: invalid private key size %d", len(priv))
}
if err := manifest.Validate(); err != nil {
return err
}
if err := a.validateShape(); err != nil {
return err
}
if a.TargetID != manifest.TargetID {
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
}
a.ManifestHash = manifest.Hash()
a.KeyID = KeyIDFor(priv.Public().(ed25519.PublicKey))
a.Signature = hex.EncodeToString(ed25519.Sign(priv, a.CanonicalMessage()))
return nil
}
func (a MutationAuthorization) Verify(pub ed25519.PublicKey, manifest MutationManifest) error {
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("mutation protocol: invalid public key size %d", len(pub))
}
if err := manifest.Validate(); err != nil {
return err
}
if err := a.validateShape(); err != nil {
return err
}
if a.TargetID != manifest.TargetID {
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
}
if a.ManifestHash != manifest.Hash() {
return fmt.Errorf("mutation protocol: authorization manifest hash mismatch")
}
if a.KeyID != KeyIDFor(pub) {
return fmt.Errorf("mutation protocol: authorization key id mismatch")
}
signature, err := hex.DecodeString(a.Signature)
if err != nil || len(signature) != ed25519.SignatureSize {
return fmt.Errorf("mutation protocol: malformed signature")
}
if !ed25519.Verify(pub, a.CanonicalMessage(), signature) {
return fmt.Errorf("mutation protocol: signature verification failed")
}
return nil
}
func (a MutationAuthorization) VerifyForExecutionAt(pub ed25519.PublicKey, manifest MutationManifest, now int64) error {
if err := a.Verify(pub, manifest); err != nil {
return err
}
if a.Decision != "allow" {
return fmt.Errorf("mutation protocol: authorization decision is %q", a.Decision)
}
if now < a.NotBefore || now > a.ExpiresAt {
return fmt.Errorf("mutation protocol: authorization is outside its time window")
}
return nil
}
func (e MutationEnvelope) VerifyForExecutionAt(pub ed25519.PublicKey, now int64) error {
return e.Authorization.VerifyForExecutionAt(pub, e.Manifest, now)
}

View file

@ -0,0 +1,145 @@
package capability
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
)
type mutationProtocolFixture struct {
Manifest MutationManifest `json:"manifest"`
Authorization MutationAuthorization `json:"authorization"`
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"`
}
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",
hex.EncodeToString(manifest.CanonicalBytes()), manifest.Hash(),
hex.EncodeToString(authorization.CanonicalMessage()), authorization.KeyID, authorization.Signature,
)
}
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 || authorization.Signature != fixture.ExpectedSignature {
t.Fatal("authorization key/signature drifted from golden vector")
}
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() || authorization.Verify(publicKey, reordered) != nil {
t.Fatal("ordering changed the authorized manifest")
}
duplicate := cloneMutationManifest(t, manifest)
duplicate.ResolvedActions = append(duplicate.ResolvedActions, duplicate.ResolvedActions[0])
if duplicate.Hash() == manifest.Hash() || authorization.Verify(publicKey, duplicate) == nil {
t.Fatal("duplicate action was silently de-duplicated or remained authorized")
}
}
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 = "body-other" },
"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 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")
}
}

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,454 @@
//! 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;
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";
#[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,
}
#[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 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());
}
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)
}
}
fn key_id_for(public_key: &[u8; 32]) -> String {
let digest = Sha256::digest(public_key);
hex::encode(&digest[..16])
}
#[cfg(test)]
mod tests {
use ed25519_dalek::{Signer, SigningKey};
use serde::Deserialize;
use super::*;
#[derive(Deserialize)]
struct GoldenFixture {
manifest: MutationManifest,
authorization: MutationAuthorization,
test_seed: String,
expected_manifest_canonical_hex: String,
expected_manifest_hash: String,
expected_authorization_canonical_hex: String,
expected_key_id: String,
expected_signature: 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);
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());
}
}

126
protocol/README.md Normal file
View file

@ -0,0 +1,126 @@
# 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
```
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.
## 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.
## 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;
- 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`.

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

@ -0,0 +1,51 @@
{
"manifest": {
"protocol_version": 1,
"operation_id": "550e8400-e29b-41d4-a716-446655440010",
"target_id": "body-7c0f",
"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": "body-7c0f",
"issued_at": 1700000000,
"not_before": 1700000001,
"expires_at": 1700000600,
"decision": "allow",
"key_id": "",
"signature": ""
},
"test_seed": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
"expected_manifest_canonical_hex": "32353a726564666c61672e6d75746174696f6e2d6d616e6966657374313a3133363a35353065383430302d653239622d343164342d613731362d343436363535343430303130393a626f64792d37633066363a7061636d616e373a696e7374616c6c313a323234323a32333a726564666c61672e7265736f6c7665642d616374696f6e373a7061636b61676531313a61636c40322e332e322d313138393a7b2261727469666163745f736861323536223a2231313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131222c22657865637574696f6e5f6c6f636174696f6e223a7b226b696e64223a226361636865222c2276616c7565223a222f7661722f63616368652f726564666c61672f61636c2e706b672e7461722e7a7374227d2c227265706f7369746f7279223a22636f7265227d3233393a32333a726564666c61672e7265736f6c7665642d616374696f6e373a7061636b616765393a7a736840352e392d323138393a7b2261727469666163745f736861323536223a2232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232222c22657865637574696f6e5f6c6f636174696f6e223a7b226b696e64223a226361636865222c2276616c7565223a222f7661722f63616368652f726564666c61672f7a73682e706b672e7461722e7a7374227d2c227265706f7369746f7279223a22636f7265227d313a323130383a31363a726564666c61672e65766964656e636531393a7265706f7369746f72792d6d6574616461746136343a6262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626239363a31363a726564666c61672e65766964656e6365383a70726f647563657236343a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161",
"expected_manifest_hash": "240028bf48a3e8ffe33e115ffe5b85c4160bc8d69193d8bf7782b039a3c0e202",
"expected_authorization_canonical_hex": "33303a726564666c61672e6d75746174696f6e2d617574686f72697a6174696f6e313a3133363a35353065383430302d653239622d343164342d613731362d34343636353534343030313136343a3234303032386266343861336538666665333365313135666665356238356334313630626338643639313933643862663737383262303339613363306532303231323a666c6565742d73657276657231323a726564666c61672d70726f64393a626f64792d3763306631303a3137303030303030303031303a3137303030303030303131303a31373030303030363030353a616c6c6f7733323a3536343735616137353436333437346330323835646635646266326263616237",
"expected_key_id": "56475aa75463474c0285df5dbf2bcab7",
"expected_signature": "58ac223facbbc819f0854a134f97bb38cc8d54722ed6c02ec13f16485be3f1b8db4e400ae0644b2c96595c139a0ca745ee04087d7b87254b55eca9f5389f7401"
}

View file

@ -0,0 +1,288 @@
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
manifestDomain = "redflag.mutation-manifest"
actionDomain = "redflag.resolved-action"
evidenceDomain = "redflag.evidence"
authorizationDomain = "redflag.mutation-authorization"
)
// 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.
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,
)
}
func (a MutationAuthorization) validateShape() error {
if a.ProtocolVersion != MutationProtocolVersion {
return fmt.Errorf("mutation protocol: unsupported authorization version %d", a.ProtocolVersion)
}
for _, field := range [][2]string{
{"authorization_id", a.AuthorizationID},
{"authority_kind", a.AuthorityKind},
{"authority_id", a.AuthorityID},
{"target_id", a.TargetID},
{"decision", a.Decision},
} {
name, value := field[0], field[1]
if value == "" {
return fmt.Errorf("mutation protocol: authorization %s is empty", name)
}
}
if a.IssuedAt <= 0 || a.NotBefore < a.IssuedAt || a.ExpiresAt <= a.NotBefore {
return fmt.Errorf("mutation protocol: invalid authorization time window")
}
return nil
}
func (a *MutationAuthorization) Sign(priv ed25519.PrivateKey, manifest MutationManifest) error {
if len(priv) != ed25519.PrivateKeySize {
return fmt.Errorf("mutation protocol: invalid private key size %d", len(priv))
}
if err := manifest.Validate(); err != nil {
return err
}
if err := a.validateShape(); err != nil {
return err
}
if a.TargetID != manifest.TargetID {
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
}
a.ManifestHash = manifest.Hash()
a.KeyID = KeyIDFor(priv.Public().(ed25519.PublicKey))
a.Signature = hex.EncodeToString(ed25519.Sign(priv, a.CanonicalMessage()))
return nil
}
func (a MutationAuthorization) Verify(pub ed25519.PublicKey, manifest MutationManifest) error {
if len(pub) != ed25519.PublicKeySize {
return fmt.Errorf("mutation protocol: invalid public key size %d", len(pub))
}
if err := manifest.Validate(); err != nil {
return err
}
if err := a.validateShape(); err != nil {
return err
}
if a.TargetID != manifest.TargetID {
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
}
if a.ManifestHash != manifest.Hash() {
return fmt.Errorf("mutation protocol: authorization manifest hash mismatch")
}
if a.KeyID != KeyIDFor(pub) {
return fmt.Errorf("mutation protocol: authorization key id mismatch")
}
signature, err := hex.DecodeString(a.Signature)
if err != nil || len(signature) != ed25519.SignatureSize {
return fmt.Errorf("mutation protocol: malformed signature")
}
if !ed25519.Verify(pub, a.CanonicalMessage(), signature) {
return fmt.Errorf("mutation protocol: signature verification failed")
}
return nil
}
// 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)
}

View file

@ -0,0 +1,163 @@
package capability
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"os"
"strings"
"testing"
)
type mutationProtocolFixture struct {
Manifest MutationManifest `json:"manifest"`
Authorization MutationAuthorization `json:"authorization"`
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"`
}
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",
hex.EncodeToString(manifest.CanonicalBytes()),
manifest.Hash(),
hex.EncodeToString(authorization.CanonicalMessage()),
authorization.KeyID,
authorization.Signature,
)
}
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)
}
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 = "body-other" },
"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")
}
}