projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
15
server/internal/capability/keyid.go
Normal file
15
server/internal/capability/keyid.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
package capability
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// KeyIDFor returns the authority key fingerprint: hex(sha256(pubkey)[:16]).
|
||||
// Matches SigningService.GetPublicKeyFingerprint and the executor's key_id_for.
|
||||
// Split out of token.go so retiring Token does not strand the mutation protocol.
|
||||
func KeyIDFor(pub ed25519.PublicKey) string {
|
||||
hash := sha256.Sum256(pub)
|
||||
return hex.EncodeToString(hash[:16])
|
||||
}
|
||||
423
server/internal/capability/mutation_manifest.go
Normal file
423
server/internal/capability/mutation_manifest.go
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
package capability
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const (
|
||||
// MutationProtocolVersion belongs to the manifest namespace, independently
|
||||
// of the current closure-based Token format. Backends opt into the envelope
|
||||
// path explicitly; pacman begins at the helper boundary.
|
||||
MutationProtocolVersion = 1
|
||||
|
||||
// MaxAuthorizationLifetimeSeconds is the ceiling on expires_at - not_before.
|
||||
// Derived, not chosen: it is DefaultTokenTTL, the fleet minter's window
|
||||
// (server/internal/services/capability_minter.go). Standalone mint is
|
||||
// tighter still at 600s. Doctrine, not a knob — an authority that wants a
|
||||
// standing capability has to say so by minting again.
|
||||
MaxAuthorizationLifetimeSeconds = 3600
|
||||
|
||||
manifestDomain = "redflag.mutation-manifest"
|
||||
actionDomain = "redflag.resolved-action"
|
||||
evidenceDomain = "redflag.evidence"
|
||||
authorizationDomain = "redflag.mutation-authorization"
|
||||
receiptDomain = "redflag.mutation-receipt"
|
||||
)
|
||||
|
||||
// ResolvedAction carries exact backend-owned UTF-8 JSON bytes. The common
|
||||
// protocol signs those bytes but does not reinterpret pacman, WUA, Winget,
|
||||
// Docker, or self-update semantics into a fictional universal artifact.
|
||||
type ResolvedAction struct {
|
||||
Kind string `json:"kind"`
|
||||
Identity string `json:"identity"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
|
||||
// Evidence identifies provenance or policy evidence by digest. Execution
|
||||
// location belongs in the resolved action payload, never in this trust class.
|
||||
type Evidence struct {
|
||||
Kind string `json:"kind"`
|
||||
Digest string `json:"digest"`
|
||||
}
|
||||
|
||||
// MutationManifest is the immutable description an authority approves and
|
||||
// an executor later receives unchanged.
|
||||
//
|
||||
// TargetID MUST be the locally provisioned RedFlag agent identity. The generic
|
||||
// name is deliberate: a later protocol may define another target namespace.
|
||||
type MutationManifest struct {
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
OperationID string `json:"operation_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
Backend string `json:"backend"`
|
||||
Operation string `json:"operation"`
|
||||
ResolvedActions []ResolvedAction `json:"resolved_actions"`
|
||||
Evidence []Evidence `json:"evidence"`
|
||||
}
|
||||
|
||||
// MutationAuthorization binds an authority decision to one manifest and body.
|
||||
// Every field except Signature is inside CanonicalMessage, including KeyID.
|
||||
type MutationAuthorization struct {
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
AuthorizationID string `json:"authorization_id"`
|
||||
ManifestHash string `json:"manifest_hash"`
|
||||
AuthorityKind string `json:"authority_kind"`
|
||||
AuthorityID string `json:"authority_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
NotBefore int64 `json:"not_before"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Decision string `json:"decision"`
|
||||
KeyID string `json:"key_id"`
|
||||
Signature string `json:"signature"`
|
||||
}
|
||||
|
||||
// MutationEnvelope is the indivisible object handed across an authority or
|
||||
// executor boundary. Verification always recomputes the manifest hash from the
|
||||
// manifest carried beside its authorization.
|
||||
type MutationEnvelope struct {
|
||||
Manifest MutationManifest `json:"manifest"`
|
||||
Authorization MutationAuthorization `json:"authorization"`
|
||||
}
|
||||
|
||||
func writeLP(buf *bytes.Buffer, value []byte) {
|
||||
buf.WriteString(strconv.Itoa(len(value)))
|
||||
buf.WriteByte(':')
|
||||
buf.Write(value)
|
||||
}
|
||||
|
||||
func canonicalRecord(domain string, values ...string) []byte {
|
||||
var buf bytes.Buffer
|
||||
writeLP(&buf, []byte(domain))
|
||||
for _, value := range values {
|
||||
writeLP(&buf, []byte(value))
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func (a ResolvedAction) canonicalBytes() []byte {
|
||||
return canonicalRecord(actionDomain, a.Kind, a.Identity, a.Payload)
|
||||
}
|
||||
|
||||
func (e Evidence) canonicalBytes() []byte {
|
||||
return canonicalRecord(evidenceDomain, e.Kind, e.Digest)
|
||||
}
|
||||
|
||||
func sortedRecords[T any](values []T, encode func(T) []byte) [][]byte {
|
||||
records := make([][]byte, 0, len(values))
|
||||
for _, value := range values {
|
||||
records = append(records, encode(value))
|
||||
}
|
||||
sort.Slice(records, func(i, j int) bool { return bytes.Compare(records[i], records[j]) < 0 })
|
||||
return records
|
||||
}
|
||||
|
||||
// CanonicalBytes is domain-separated and length-prefixed. Action and evidence
|
||||
// ordering is irrelevant, while exact duplicates remain present and therefore
|
||||
// change the hash. No set conversion is permitted here.
|
||||
func (m MutationManifest) CanonicalBytes() []byte {
|
||||
var buf bytes.Buffer
|
||||
writeLP(&buf, []byte(manifestDomain))
|
||||
for _, value := range []string{
|
||||
strconv.Itoa(m.ProtocolVersion),
|
||||
m.OperationID,
|
||||
m.TargetID,
|
||||
m.Backend,
|
||||
m.Operation,
|
||||
} {
|
||||
writeLP(&buf, []byte(value))
|
||||
}
|
||||
|
||||
actions := sortedRecords(m.ResolvedActions, func(a ResolvedAction) []byte { return a.canonicalBytes() })
|
||||
writeLP(&buf, []byte(strconv.Itoa(len(actions))))
|
||||
for _, action := range actions {
|
||||
writeLP(&buf, action)
|
||||
}
|
||||
|
||||
evidence := sortedRecords(m.Evidence, func(e Evidence) []byte { return e.canonicalBytes() })
|
||||
writeLP(&buf, []byte(strconv.Itoa(len(evidence))))
|
||||
for _, item := range evidence {
|
||||
writeLP(&buf, item)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func (m MutationManifest) Hash() string {
|
||||
digest := sha256.Sum256(m.CanonicalBytes())
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func (m MutationManifest) Validate() error {
|
||||
if m.ProtocolVersion != MutationProtocolVersion {
|
||||
return fmt.Errorf("mutation protocol: unsupported manifest version %d", m.ProtocolVersion)
|
||||
}
|
||||
for _, field := range [][2]string{
|
||||
{"operation_id", m.OperationID},
|
||||
{"target_id", m.TargetID},
|
||||
{"backend", m.Backend},
|
||||
{"operation", m.Operation},
|
||||
} {
|
||||
name, value := field[0], field[1]
|
||||
if value == "" {
|
||||
return fmt.Errorf("mutation protocol: manifest %s is empty", name)
|
||||
}
|
||||
}
|
||||
if len(m.ResolvedActions) == 0 {
|
||||
return fmt.Errorf("mutation protocol: manifest has no resolved actions")
|
||||
}
|
||||
for i, action := range m.ResolvedActions {
|
||||
if action.Kind == "" || action.Identity == "" || action.Payload == "" {
|
||||
return fmt.Errorf("mutation protocol: resolved action %d is incomplete", i)
|
||||
}
|
||||
if !json.Valid([]byte(action.Payload)) {
|
||||
return fmt.Errorf("mutation protocol: resolved action %d payload is not JSON", i)
|
||||
}
|
||||
}
|
||||
for i, evidence := range m.Evidence {
|
||||
if evidence.Kind == "" {
|
||||
return fmt.Errorf("mutation protocol: evidence %d kind is empty", i)
|
||||
}
|
||||
decoded, err := hex.DecodeString(evidence.Digest)
|
||||
if err != nil || len(decoded) != sha256.Size {
|
||||
return fmt.Errorf("mutation protocol: evidence %d digest is not SHA-256 hex", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MutationAuthorization) CanonicalMessage() []byte {
|
||||
return canonicalRecord(
|
||||
authorizationDomain,
|
||||
strconv.Itoa(a.ProtocolVersion),
|
||||
a.AuthorizationID,
|
||||
a.ManifestHash,
|
||||
a.AuthorityKind,
|
||||
a.AuthorityID,
|
||||
a.TargetID,
|
||||
strconv.FormatInt(a.IssuedAt, 10),
|
||||
strconv.FormatInt(a.NotBefore, 10),
|
||||
strconv.FormatInt(a.ExpiresAt, 10),
|
||||
a.Decision,
|
||||
a.KeyID,
|
||||
)
|
||||
}
|
||||
|
||||
// IsCanonicalUUIDv4 reports whether s is 8-4-4-4-12 lowercase hex with the
|
||||
// version (4) and variant (8/9/a/b) nibbles set. Same discipline the standalone
|
||||
// mint already applies to request_id, applied here before authorization_id can
|
||||
// become an executor replay key: a newline-delimited replay ledger matched by
|
||||
// exact line has no defence against an identifier that contains a newline.
|
||||
func IsCanonicalUUIDv4(s string) bool {
|
||||
if len(s) != 36 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if i == 8 || i == 13 || i == 18 || i == 23 {
|
||||
if c != '-' {
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')
|
||||
if !isHex {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if s[14] != '4' {
|
||||
return false
|
||||
}
|
||||
switch s[19] {
|
||||
case '8', '9', 'a', 'b':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a MutationAuthorization) validateShape() error {
|
||||
if a.ProtocolVersion != MutationProtocolVersion {
|
||||
return fmt.Errorf("mutation protocol: unsupported authorization version %d", a.ProtocolVersion)
|
||||
}
|
||||
for _, field := range [][2]string{
|
||||
{"authorization_id", a.AuthorizationID},
|
||||
{"authority_kind", a.AuthorityKind},
|
||||
{"authority_id", a.AuthorityID},
|
||||
{"target_id", a.TargetID},
|
||||
{"decision", a.Decision},
|
||||
} {
|
||||
name, value := field[0], field[1]
|
||||
if value == "" {
|
||||
return fmt.Errorf("mutation protocol: authorization %s is empty", name)
|
||||
}
|
||||
}
|
||||
if !IsCanonicalUUIDv4(a.AuthorizationID) {
|
||||
return fmt.Errorf("mutation protocol: authorization_id is not a canonical UUID v4")
|
||||
}
|
||||
if a.IssuedAt <= 0 || a.NotBefore < a.IssuedAt || a.ExpiresAt <= a.NotBefore {
|
||||
return fmt.Errorf("mutation protocol: invalid authorization time window")
|
||||
}
|
||||
if a.ExpiresAt-a.NotBefore > MaxAuthorizationLifetimeSeconds {
|
||||
return fmt.Errorf("mutation protocol: authorization lifetime %ds exceeds the %ds ceiling",
|
||||
a.ExpiresAt-a.NotBefore, MaxAuthorizationLifetimeSeconds)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *MutationAuthorization) Sign(priv ed25519.PrivateKey, manifest MutationManifest) error {
|
||||
if len(priv) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("mutation protocol: invalid private key size %d", len(priv))
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.validateShape(); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.TargetID != manifest.TargetID {
|
||||
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
|
||||
}
|
||||
a.ManifestHash = manifest.Hash()
|
||||
a.KeyID = KeyIDFor(priv.Public().(ed25519.PublicKey))
|
||||
a.Signature = hex.EncodeToString(ed25519.Sign(priv, a.CanonicalMessage()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a MutationAuthorization) Verify(pub ed25519.PublicKey, manifest MutationManifest) error {
|
||||
if len(pub) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("mutation protocol: invalid public key size %d", len(pub))
|
||||
}
|
||||
if err := manifest.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.validateShape(); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.TargetID != manifest.TargetID {
|
||||
return fmt.Errorf("mutation protocol: authorization target does not match manifest target")
|
||||
}
|
||||
if a.ManifestHash != manifest.Hash() {
|
||||
return fmt.Errorf("mutation protocol: authorization manifest hash mismatch")
|
||||
}
|
||||
if a.KeyID != KeyIDFor(pub) {
|
||||
return fmt.Errorf("mutation protocol: authorization key id mismatch")
|
||||
}
|
||||
signature, err := hex.DecodeString(a.Signature)
|
||||
if err != nil || len(signature) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("mutation protocol: malformed signature")
|
||||
}
|
||||
if !ed25519.Verify(pub, a.CanonicalMessage(), signature) {
|
||||
return fmt.Errorf("mutation protocol: signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// VerifyForExecutionAt adds the executor's decision and time checks to the
|
||||
// cryptographic envelope verification.
|
||||
func (a MutationAuthorization) VerifyForExecutionAt(pub ed25519.PublicKey, manifest MutationManifest, now int64) error {
|
||||
if err := a.Verify(pub, manifest); err != nil {
|
||||
return err
|
||||
}
|
||||
if a.Decision != "allow" {
|
||||
return fmt.Errorf("mutation protocol: authorization decision is %q", a.Decision)
|
||||
}
|
||||
if now < a.NotBefore || now > a.ExpiresAt {
|
||||
return fmt.Errorf("mutation protocol: authorization is outside its time window")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e MutationEnvelope) VerifyForExecutionAt(pub ed25519.PublicKey, now int64) error {
|
||||
return e.Authorization.VerifyForExecutionAt(pub, e.Manifest, now)
|
||||
}
|
||||
|
||||
// MutationReceipt is the response half of the contract: what the privileged
|
||||
// executor did with one envelope. It carries the audit join ARCH-002 names —
|
||||
// operation ID, manifest hash, authorization ID — so a local receipt and a
|
||||
// server history row can be joined without either guessing.
|
||||
//
|
||||
// It is not signed. The executor is not a second authority; this is a record
|
||||
// produced inside the trust boundary that already ran the operation. Decision
|
||||
// and Reason keep the PolicyResult taxonomy rather than inventing a new one.
|
||||
type MutationReceipt struct {
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
OperationID string `json:"operation_id"`
|
||||
ManifestHash string `json:"manifest_hash"`
|
||||
AuthorizationID string `json:"authorization_id"`
|
||||
TargetID string `json:"target_id"`
|
||||
Backend string `json:"backend"`
|
||||
Operation string `json:"operation"`
|
||||
Decision string `json:"decision"` // executed | denied | failed
|
||||
Reason string `json:"reason"`
|
||||
Executed bool `json:"executed"`
|
||||
VerifiedActions int `json:"verified_actions"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
// CanonicalBytes pins the receipt the same way the manifest is pinned, so a
|
||||
// ledger can digest one without re-deriving field order from JSON. Every field
|
||||
// is present even when empty — a refusal before parse still produces a receipt,
|
||||
// and its emptiness is part of the record.
|
||||
func (r MutationReceipt) CanonicalBytes() []byte {
|
||||
return canonicalRecord(
|
||||
receiptDomain,
|
||||
strconv.Itoa(r.ProtocolVersion),
|
||||
r.OperationID,
|
||||
r.ManifestHash,
|
||||
r.AuthorizationID,
|
||||
r.TargetID,
|
||||
r.Backend,
|
||||
r.Operation,
|
||||
r.Decision,
|
||||
r.Reason,
|
||||
strconv.FormatBool(r.Executed),
|
||||
strconv.Itoa(r.VerifiedActions),
|
||||
strconv.Itoa(r.ExitCode),
|
||||
r.Error,
|
||||
strconv.FormatInt(r.Timestamp, 10),
|
||||
)
|
||||
}
|
||||
|
||||
func (r MutationReceipt) Digest() string {
|
||||
digest := sha256.Sum256(r.CanonicalBytes())
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
// MutationOutcome is what the executor did, separate from which envelope it
|
||||
// did it to.
|
||||
type MutationOutcome struct {
|
||||
Decision string
|
||||
Reason string
|
||||
Executed bool
|
||||
VerifiedActions int
|
||||
ExitCode int
|
||||
Detail string
|
||||
}
|
||||
|
||||
// ReceiptFor copies the audit join out of signed bytes rather than retyping it.
|
||||
func (e MutationEnvelope) ReceiptFor(outcome MutationOutcome, now int64) MutationReceipt {
|
||||
return MutationReceipt{
|
||||
ProtocolVersion: MutationProtocolVersion,
|
||||
OperationID: e.Manifest.OperationID,
|
||||
ManifestHash: e.Manifest.Hash(),
|
||||
AuthorizationID: e.Authorization.AuthorizationID,
|
||||
TargetID: e.Manifest.TargetID,
|
||||
Backend: e.Manifest.Backend,
|
||||
Operation: e.Manifest.Operation,
|
||||
Decision: outcome.Decision,
|
||||
Reason: outcome.Reason,
|
||||
Executed: outcome.Executed,
|
||||
VerifiedActions: outcome.VerifiedActions,
|
||||
ExitCode: outcome.ExitCode,
|
||||
Error: outcome.Detail,
|
||||
Timestamp: now,
|
||||
}
|
||||
}
|
||||
302
server/internal/capability/mutation_manifest_test.go
Normal file
302
server/internal/capability/mutation_manifest_test.go
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
package capability
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type mutationProtocolFixture struct {
|
||||
Manifest MutationManifest `json:"manifest"`
|
||||
Authorization MutationAuthorization `json:"authorization"`
|
||||
Receipt MutationReceipt `json:"receipt"`
|
||||
TestSeed string `json:"test_seed"`
|
||||
ExpectedManifestCanonicalHex string `json:"expected_manifest_canonical_hex"`
|
||||
ExpectedManifestHash string `json:"expected_manifest_hash"`
|
||||
ExpectedAuthorizationCanonical string `json:"expected_authorization_canonical_hex"`
|
||||
ExpectedKeyID string `json:"expected_key_id"`
|
||||
ExpectedSignature string `json:"expected_signature"`
|
||||
ExpectedReceiptCanonicalHex string `json:"expected_receipt_canonical_hex"`
|
||||
ExpectedReceiptDigest string `json:"expected_receipt_digest"`
|
||||
}
|
||||
|
||||
func loadMutationProtocolFixture(t *testing.T) mutationProtocolFixture {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../../protocol/testdata/mutation-golden.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fixture mutationProtocolFixture
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fixture
|
||||
}
|
||||
|
||||
func signedMutationProtocolFixture(t *testing.T) (MutationManifest, MutationAuthorization, ed25519.PublicKey) {
|
||||
t.Helper()
|
||||
fixture := loadMutationProtocolFixture(t)
|
||||
seed, err := hex.DecodeString(fixture.TestSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKey := ed25519.NewKeyFromSeed(seed)
|
||||
authorization := fixture.Authorization
|
||||
if err := authorization.Sign(privateKey, fixture.Manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fixture.Manifest, authorization, privateKey.Public().(ed25519.PublicKey)
|
||||
}
|
||||
|
||||
func cloneMutationManifest(t *testing.T, manifest MutationManifest) MutationManifest {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var clone MutationManifest
|
||||
if err := json.Unmarshal(raw, &clone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func TestMutationProtocolGoldenVector(t *testing.T) {
|
||||
fixture := loadMutationProtocolFixture(t)
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
if fixture.ExpectedManifestHash == "" {
|
||||
t.Fatalf(
|
||||
"fill fixture: manifest_canonical=%s\nmanifest_hash=%s\nauthorization_canonical=%s\nkey_id=%s\nsignature=%s\nreceipt_canonical=%s\nreceipt_digest=%s",
|
||||
hex.EncodeToString(manifest.CanonicalBytes()),
|
||||
manifest.Hash(),
|
||||
hex.EncodeToString(authorization.CanonicalMessage()),
|
||||
authorization.KeyID,
|
||||
authorization.Signature,
|
||||
hex.EncodeToString(fixture.Receipt.CanonicalBytes()),
|
||||
fixture.Receipt.Digest(),
|
||||
)
|
||||
}
|
||||
if got := hex.EncodeToString(manifest.CanonicalBytes()); got != fixture.ExpectedManifestCanonicalHex {
|
||||
t.Fatalf("manifest canonical bytes = %q, want %q", got, fixture.ExpectedManifestCanonicalHex)
|
||||
}
|
||||
if got := manifest.Hash(); got != fixture.ExpectedManifestHash {
|
||||
t.Fatalf("manifest hash = %q, want %q", got, fixture.ExpectedManifestHash)
|
||||
}
|
||||
if got := hex.EncodeToString(authorization.CanonicalMessage()); got != fixture.ExpectedAuthorizationCanonical {
|
||||
t.Fatalf("authorization canonical bytes = %q, want %q", got, fixture.ExpectedAuthorizationCanonical)
|
||||
}
|
||||
if authorization.KeyID != fixture.ExpectedKeyID {
|
||||
t.Fatalf("key id = %q, want %q", authorization.KeyID, fixture.ExpectedKeyID)
|
||||
}
|
||||
if authorization.Signature != fixture.ExpectedSignature {
|
||||
t.Fatalf("signature = %q, want %q", authorization.Signature, fixture.ExpectedSignature)
|
||||
}
|
||||
if got := hex.EncodeToString(fixture.Receipt.CanonicalBytes()); got != fixture.ExpectedReceiptCanonicalHex {
|
||||
t.Fatalf("receipt canonical bytes = %q, want %q", got, fixture.ExpectedReceiptCanonicalHex)
|
||||
}
|
||||
if got := fixture.Receipt.Digest(); got != fixture.ExpectedReceiptDigest {
|
||||
t.Fatalf("receipt digest = %q, want %q", got, fixture.ExpectedReceiptDigest)
|
||||
}
|
||||
envelope := MutationEnvelope{Manifest: manifest, Authorization: authorization}
|
||||
if err := envelope.VerifyForExecutionAt(publicKey, 1_700_000_100); err != nil {
|
||||
t.Fatalf("golden authorization did not verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationProtocolOrderingAndDuplicateSemantics(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
reordered := cloneMutationManifest(t, manifest)
|
||||
reordered.ResolvedActions[0], reordered.ResolvedActions[1] = reordered.ResolvedActions[1], reordered.ResolvedActions[0]
|
||||
reordered.Evidence[0], reordered.Evidence[1] = reordered.Evidence[1], reordered.Evidence[0]
|
||||
if reordered.Hash() != manifest.Hash() {
|
||||
t.Fatal("manifest hash changed when action/evidence order changed")
|
||||
}
|
||||
if err := authorization.Verify(publicKey, reordered); err != nil {
|
||||
t.Fatalf("authorization rejected reordered manifest: %v", err)
|
||||
}
|
||||
|
||||
duplicate := cloneMutationManifest(t, manifest)
|
||||
duplicate.ResolvedActions = append(duplicate.ResolvedActions, duplicate.ResolvedActions[0])
|
||||
if duplicate.Hash() == manifest.Hash() {
|
||||
t.Fatal("exact duplicate action was silently de-duplicated")
|
||||
}
|
||||
if err := authorization.Verify(publicKey, duplicate); err == nil {
|
||||
t.Fatal("authorization accepted a duplicate resolved action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationProtocolExecutorAffectingTamperFails(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
tests := map[string]func(*MutationManifest){
|
||||
"provenance": func(m *MutationManifest) { m.Evidence[0].Digest = strings.Repeat("c", 64) },
|
||||
"execution location": func(m *MutationManifest) {
|
||||
m.ResolvedActions[0].Payload = strings.Replace(m.ResolvedActions[0].Payload, "/var/cache/redflag", "/tmp", 1)
|
||||
},
|
||||
"target": func(m *MutationManifest) { m.TargetID = "6f1e2d3c-4b5a-4998-8877-665544332211" },
|
||||
"backend": func(m *MutationManifest) { m.Backend = "wua" },
|
||||
"resolved action": func(m *MutationManifest) { m.ResolvedActions[0].Identity = "zsh@6.0-1" },
|
||||
}
|
||||
for name, tamper := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
changed := cloneMutationManifest(t, manifest)
|
||||
tamper(&changed)
|
||||
if err := authorization.Verify(publicKey, changed); err == nil {
|
||||
t.Fatal("authorization accepted tampered manifest")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
changedAuthorization := authorization
|
||||
changedAuthorization.IssuedAt++
|
||||
if err := changedAuthorization.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("authorization accepted tampered authorization metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationProtocolUnknownVersionsFailClosed(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
manifest.ProtocolVersion++
|
||||
if err := manifest.Validate(); err == nil {
|
||||
t.Fatal("unknown manifest version passed validation")
|
||||
}
|
||||
|
||||
manifest.ProtocolVersion = MutationProtocolVersion
|
||||
authorization.ProtocolVersion++
|
||||
if err := authorization.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("unknown authorization version passed verification")
|
||||
}
|
||||
}
|
||||
|
||||
// The target fields carry the RedFlag agent identity. Both are signed and the
|
||||
// verifier requires them equal, so an executor that binds either one to the
|
||||
// host it read for itself has bound the whole envelope.
|
||||
func TestMutationProtocolTargetBindsManifestAndAuthorization(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
if manifest.TargetID != authorization.TargetID {
|
||||
t.Fatal("golden fixture disagrees with itself about the target")
|
||||
}
|
||||
|
||||
split := authorization
|
||||
split.TargetID = "6f1e2d3c-4b5a-4998-8877-665544332211"
|
||||
if err := split.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("authorization for another target verified against this manifest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationAuthorizationIDIsCanonicalUUIDv4(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
// A replay ledger matched line-by-line has no defence against an embedded
|
||||
// newline; the shape check is what makes the identifier safe to record.
|
||||
for _, bad := range []string{
|
||||
"",
|
||||
"not-a-uuid",
|
||||
"550e8400-e29b-41d4-a716-44665544001",
|
||||
"550e8400-e29b-11d4-a716-446655440011",
|
||||
"550e8400-e29b-41d4-c716-446655440011",
|
||||
"550E8400-E29B-41D4-A716-446655440011",
|
||||
"550e8400-e29b-41d4-a716-4466554400\n1",
|
||||
} {
|
||||
if IsCanonicalUUIDv4(bad) {
|
||||
t.Fatalf("accepted %q as a canonical UUID v4", bad)
|
||||
}
|
||||
changed := authorization
|
||||
changed.AuthorizationID = bad
|
||||
if err := changed.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatalf("authorization with id %q verified", bad)
|
||||
}
|
||||
}
|
||||
|
||||
if !IsCanonicalUUIDv4(authorization.AuthorizationID) {
|
||||
t.Fatalf("golden authorization_id %q is not a canonical UUID v4", authorization.AuthorizationID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationAuthorizationLifetimeCeiling(t *testing.T) {
|
||||
manifest, authorization, publicKey := signedMutationProtocolFixture(t)
|
||||
|
||||
seed, err := hex.DecodeString(loadMutationProtocolFixture(t).TestSeed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
privateKey := ed25519.NewKeyFromSeed(seed)
|
||||
|
||||
atCeiling := authorization
|
||||
atCeiling.ExpiresAt = atCeiling.NotBefore + MaxAuthorizationLifetimeSeconds
|
||||
if err := atCeiling.Sign(privateKey, manifest); err != nil {
|
||||
t.Fatalf("an authorization exactly at the ceiling must sign: %v", err)
|
||||
}
|
||||
if err := atCeiling.Verify(publicKey, manifest); err != nil {
|
||||
t.Fatalf("an authorization exactly at the ceiling must verify: %v", err)
|
||||
}
|
||||
|
||||
overCeiling := authorization
|
||||
overCeiling.ExpiresAt = overCeiling.NotBefore + MaxAuthorizationLifetimeSeconds + 1
|
||||
if err := overCeiling.Sign(privateKey, manifest); err == nil {
|
||||
t.Fatal("minted an authorization past the lifetime ceiling")
|
||||
}
|
||||
if err := overCeiling.Verify(publicKey, manifest); err == nil {
|
||||
t.Fatal("verified an authorization past the lifetime ceiling")
|
||||
}
|
||||
}
|
||||
|
||||
// Evidence carries digests. Operator reason prose and policy text stay in the
|
||||
// authority's journal, and the shape check is what keeps them out.
|
||||
func TestMutationEvidenceCarriesDigestsNotProse(t *testing.T) {
|
||||
manifest, _, _ := signedMutationProtocolFixture(t)
|
||||
|
||||
for _, bad := range []string{"", "operator accepted the CVE risk", strings.Repeat("a", 63), strings.Repeat("z", 64)} {
|
||||
changed := cloneMutationManifest(t, manifest)
|
||||
changed.Evidence[0].Digest = bad
|
||||
if err := changed.Validate(); err == nil {
|
||||
t.Fatalf("manifest validated with evidence digest %q", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationReceiptCarriesAuditJoin(t *testing.T) {
|
||||
manifest, authorization, _ := signedMutationProtocolFixture(t)
|
||||
envelope := MutationEnvelope{Manifest: manifest, Authorization: authorization}
|
||||
|
||||
receipt := envelope.ReceiptFor(MutationOutcome{
|
||||
Decision: "denied",
|
||||
Reason: "backend_not_migrated",
|
||||
ExitCode: 18,
|
||||
Detail: "backend=pacman",
|
||||
}, 1_700_000_100)
|
||||
if receipt.OperationID != manifest.OperationID ||
|
||||
receipt.ManifestHash != manifest.Hash() ||
|
||||
receipt.AuthorizationID != authorization.AuthorizationID ||
|
||||
receipt.TargetID != manifest.TargetID {
|
||||
t.Fatal("receipt lost the operation/manifest/authorization/target join")
|
||||
}
|
||||
if receipt.Backend != manifest.Backend || receipt.Operation != manifest.Operation {
|
||||
t.Fatal("receipt lost the backend/operation it answers")
|
||||
}
|
||||
|
||||
// Every recorded field is in the canonical bytes, including the ones a
|
||||
// lossy report would drop first.
|
||||
for name, mutate := range map[string]func(*MutationReceipt){
|
||||
"decision": func(r *MutationReceipt) { r.Decision = "executed" },
|
||||
"reason": func(r *MutationReceipt) { r.Reason = "operation_completed" },
|
||||
"executed": func(r *MutationReceipt) { r.Executed = true },
|
||||
"verified actions": func(r *MutationReceipt) { r.VerifiedActions = 1 },
|
||||
"exit code": func(r *MutationReceipt) { r.ExitCode = 0 },
|
||||
"error": func(r *MutationReceipt) { r.Error = "" },
|
||||
"timestamp": func(r *MutationReceipt) { r.Timestamp++ },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
changed := receipt
|
||||
mutate(&changed)
|
||||
if changed.Digest() == receipt.Digest() {
|
||||
t.Fatal("receipt digest ignored a recorded field")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
110
server/internal/capability/token.go
Normal file
110
server/internal/capability/token.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
// Package capability defines the supply-chain capability token: an Ed25519-signed
|
||||
// authorization for exactly one package operation over a fully-resolved dependency
|
||||
// closure. The server (authority) mints and signs tokens; the agent passes them to
|
||||
// the privileged Rust executor (helper/) which independently verifies them.
|
||||
//
|
||||
// The canonical signed message and closure hash MUST stay byte-identical across
|
||||
// this package, the agent's mirror of it, and helper/src/main.rs. See
|
||||
// RAF/security/05-supply-chain-gate.md for the contract.
|
||||
package capability
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Version is the only token format this code understands. Forward-only doctrine:
|
||||
// new versions add fields, never reinterpret existing ones.
|
||||
const Version = 1
|
||||
|
||||
// ClosureEntry is one resolved artifact in the dependency closure.
|
||||
type ClosureEntry struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Source string `json:"source"` // "mirror" | "registry"
|
||||
ArtifactPath string `json:"artifact_path,omitempty"` // local path or url, optional
|
||||
}
|
||||
|
||||
// Token is the full capability token exchanged between server, agent, and executor.
|
||||
type Token struct {
|
||||
Version int `json:"version"`
|
||||
TokenID string `json:"token_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
KeyID string `json:"key_id"`
|
||||
PackageType string `json:"package_type"` // apt|dnf|npm|bun|pip|docker|winget
|
||||
Operation string `json:"operation"` // install|upgrade (forward-only)
|
||||
Closure []ClosureEntry `json:"closure"`
|
||||
IssuedAt int64 `json:"issued_at"`
|
||||
NotBefore int64 `json:"not_before"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Signature string `json:"signature"` // hex ed25519
|
||||
}
|
||||
|
||||
// ClosureHash computes hex(sha256( "\n".join( sorted("{name}@{version}#{sha256}") ) )).
|
||||
// Lines are sorted and de-duplicated so neither array order nor exact duplicates
|
||||
// can change the digest. This mirrors the Rust BTreeSet construction exactly.
|
||||
func (t *Token) ClosureHash() string {
|
||||
set := make(map[string]struct{}, len(t.Closure))
|
||||
for _, e := range t.Closure {
|
||||
set[fmt.Sprintf("%s@%s#%s", e.Name, e.Version, e.SHA256)] = struct{}{}
|
||||
}
|
||||
lines := make([]string, 0, len(set))
|
||||
for line := range set {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
sort.Strings(lines)
|
||||
|
||||
h := sha256.New()
|
||||
for i, line := range lines {
|
||||
if i > 0 {
|
||||
h.Write([]byte("\n"))
|
||||
}
|
||||
h.Write([]byte(line))
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// CanonicalMessage builds the deterministic message that is signed/verified:
|
||||
// "{agent_id}:{token_id}:{operation}:{package_type}:{closure_hash}:{expires_at}".
|
||||
func (t *Token) CanonicalMessage() string {
|
||||
return fmt.Sprintf("%s:%s:%s:%s:%s:%d",
|
||||
t.AgentID, t.TokenID, t.Operation, t.PackageType, t.ClosureHash(), t.ExpiresAt)
|
||||
}
|
||||
|
||||
// Sign signs the canonical message with the authority private key, sets the
|
||||
// token's KeyID and Signature, and returns the hex signature.
|
||||
func (t *Token) Sign(priv ed25519.PrivateKey) (string, error) {
|
||||
if len(priv) != ed25519.PrivateKeySize {
|
||||
return "", fmt.Errorf("capability: invalid private key size %d", len(priv))
|
||||
}
|
||||
pub := priv.Public().(ed25519.PublicKey)
|
||||
t.KeyID = KeyIDFor(pub)
|
||||
sig := ed25519.Sign(priv, []byte(t.CanonicalMessage()))
|
||||
t.Signature = hex.EncodeToString(sig)
|
||||
return t.Signature, nil
|
||||
}
|
||||
|
||||
// Verify checks the token's signature against the given public key. It does not
|
||||
// check the validity window, agent binding, or artifact hashes — those are the
|
||||
// executor's responsibility (and the agent's bind-check). It verifies only that
|
||||
// this key signed this canonical message.
|
||||
func (t *Token) Verify(pub ed25519.PublicKey) error {
|
||||
if len(pub) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("capability: invalid public key size %d", len(pub))
|
||||
}
|
||||
sig, err := hex.DecodeString(t.Signature)
|
||||
if err != nil {
|
||||
return fmt.Errorf("capability: signature not hex: %w", err)
|
||||
}
|
||||
if len(sig) != ed25519.SignatureSize {
|
||||
return fmt.Errorf("capability: invalid signature size %d", len(sig))
|
||||
}
|
||||
if !ed25519.Verify(pub, []byte(t.CanonicalMessage()), sig) {
|
||||
return fmt.Errorf("capability: signature verification failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
63
server/internal/capability/token_test.go
Normal file
63
server/internal/capability/token_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package capability
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cross-language contract vector. The Rust executor (helper/src/main.rs) asserts
|
||||
// these same strings for the same input. If either side drifts, both break.
|
||||
func TestCanonicalVector(t *testing.T) {
|
||||
tok := &Token{
|
||||
Version: 1, TokenID: "tok-1", AgentID: "agent-123",
|
||||
PackageType: "npm", Operation: "install",
|
||||
Closure: []ClosureEntry{
|
||||
{Name: "left-pad", Version: "1.3.0", SHA256: "aaaa"},
|
||||
{Name: "is-odd", Version: "2.0.0", SHA256: "bbbb"},
|
||||
},
|
||||
ExpiresAt: 1700000000,
|
||||
}
|
||||
const wantHash = "49a181cd7b6df83a6dc83c7f647f2d224effe90907dd5dcdfdaf4c8697af595f"
|
||||
const wantMsg = "agent-123:tok-1:install:npm:" + wantHash + ":1700000000"
|
||||
if got := tok.ClosureHash(); got != wantHash {
|
||||
t.Fatalf("ClosureHash() = %q, want %q", got, wantHash)
|
||||
}
|
||||
if got := tok.CanonicalMessage(); got != wantMsg {
|
||||
t.Fatalf("CanonicalMessage() = %q, want %q", got, wantMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClosureHashOrderIndependent(t *testing.T) {
|
||||
a := &Token{Closure: []ClosureEntry{{Name: "a", Version: "1", SHA256: "x"}, {Name: "b", Version: "2", SHA256: "y"}}}
|
||||
b := &Token{Closure: []ClosureEntry{{Name: "b", Version: "2", SHA256: "y"}, {Name: "a", Version: "1", SHA256: "x"}}}
|
||||
if a.ClosureHash() != b.ClosureHash() {
|
||||
t.Fatalf("closure hash depends on order: %q != %q", a.ClosureHash(), b.ClosureHash())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignVerifyRoundtrip(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tok := &Token{
|
||||
Version: 1, TokenID: "tok-2", AgentID: "agent-9",
|
||||
PackageType: "dnf", Operation: "upgrade",
|
||||
Closure: []ClosureEntry{{Name: "openssl", Version: "3.2.1", SHA256: "deadbeef"}},
|
||||
ExpiresAt: 1700000000,
|
||||
}
|
||||
if _, err := tok.Sign(priv); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tok.KeyID != KeyIDFor(pub) {
|
||||
t.Fatalf("KeyID = %q, want %q", tok.KeyID, KeyIDFor(pub))
|
||||
}
|
||||
if err := tok.Verify(pub); err != nil {
|
||||
t.Fatalf("Verify after Sign failed: %v", err)
|
||||
}
|
||||
// Tamper detection: any change to the closure breaks verification.
|
||||
tok.Closure[0].Version = "3.2.2"
|
||||
if err := tok.Verify(pub); err == nil {
|
||||
t.Fatal("Verify accepted a tampered closure")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue