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:
parent
c3037655cd
commit
f5eb83ca94
11 changed files with 1529 additions and 1 deletions
|
|
@ -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. |
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
268
agent/internal/capability/mutation_manifest.go
Normal file
268
agent/internal/capability/mutation_manifest.go
Normal 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)
|
||||||
|
}
|
||||||
145
agent/internal/capability/mutation_manifest_test.go
Normal file
145
agent/internal/capability/mutation_manifest_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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!(
|
||||||
|
|
|
||||||
454
helper/src/mutation_protocol.rs
Normal file
454
helper/src/mutation_protocol.rs
Normal 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,
|
||||||
|
¬_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
126
protocol/README.md
Normal 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
51
protocol/testdata/mutation-golden.json
vendored
Normal 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"
|
||||||
|
}
|
||||||
288
server/internal/capability/mutation_manifest.go
Normal file
288
server/internal/capability/mutation_manifest.go
Normal 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)
|
||||||
|
}
|
||||||
163
server/internal/capability/mutation_manifest_test.go
Normal file
163
server/internal/capability/mutation_manifest_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue