diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 588b8c3a..36a55378 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -115,14 +115,16 @@ jobs: ./pkg/atls/... \ ./pkg/clients/... \ ./pkg/agtp/... \ - ./pkg/production + ./pkg/production \ + ./cmd/redis-failover-redteam - name: Run Direct-Agent security red-team tests run: | GOTOOLCHAIN=go1.26.0+auto go test -v -race -count=1 \ ./pkg/atls/identitypolicy \ ./pkg/clients \ - ./pkg/production + ./pkg/production \ + ./cmd/redis-failover-redteam - name: Run protected-change consumer integration run: | diff --git a/.github/workflows/security-red-team.yaml b/.github/workflows/security-red-team.yaml index 24f30646..13f4e413 100644 --- a/.github/workflows/security-red-team.yaml +++ b/.github/workflows/security-red-team.yaml @@ -15,11 +15,14 @@ on: - "pkg/atls/**" - "pkg/clients/**" - "pkg/production/**" + - "cmd/redis-failover-redteam/**" - "examples/a2a-multiprocess/**" - "examples/protected-change-consumer/**" - "docs/draft06-a2a-profile.md" - "docs/API_COMPATIBILITY.md" - "docs/production-deployment-profile.md" + - "docs/azure-sev-snp-attestation-bridge.md" + - "docs/redis-failover-runbook.md" - "docs/security-red-team-tests.md" - "docs/live-red-team-report.md" push: @@ -35,11 +38,14 @@ on: - "pkg/atls/**" - "pkg/clients/**" - "pkg/production/**" + - "cmd/redis-failover-redteam/**" - "examples/a2a-multiprocess/**" - "examples/protected-change-consumer/**" - "docs/draft06-a2a-profile.md" - "docs/API_COMPATIBILITY.md" - "docs/production-deployment-profile.md" + - "docs/azure-sev-snp-attestation-bridge.md" + - "docs/redis-failover-runbook.md" - "docs/security-red-team-tests.md" - "docs/live-red-team-report.md" @@ -68,5 +74,6 @@ jobs: ./pkg/atls/sbaipv2 \ ./pkg/clients \ ./pkg/production \ + ./cmd/redis-failover-redteam \ ./examples/protected-change-consumer \ ./examples/a2a-multiprocess diff --git a/CHANGELOG.md b/CHANGELOG.md index d5ff05cc..33b1a7b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- Add a pinned-issuer Azure SEV-SNP Attestation token bridge with exact ASB + binder challenge, measurement, policy, SVN, debug, migration, key, and + freshness checks. +- Add optional same-connection Redis/Valkey `WAIT` acknowledgement after a + successful replay insert. +- Add a two-phase real Redis/Valkey failover qualification command and + deployment runbooks for Azure hardware attestation and replay HA. + ## v1.0.0 - Add the supported Direct-Agent v1 production composition. diff --git a/Makefile b/Makefile index df8f1a6c..25bcb35d 100644 --- a/Makefile +++ b/Makefile @@ -71,7 +71,7 @@ build-igvm: product-security-gate: go mod verify GOTOOLCHAIN=go1.26.0+auto go test $(DIRECT_AGENT_CORE_PKGS) - GOTOOLCHAIN=go1.26.0+auto go test -v -race -count=1 ./pkg/atls/identitypolicy ./pkg/clients ./pkg/production $(PRODUCTION_CONSUMER_PKGS) + GOTOOLCHAIN=go1.26.0+auto go test -v -race -count=1 ./pkg/atls/identitypolicy ./pkg/clients ./pkg/production ./cmd/redis-failover-redteam $(PRODUCTION_CONSUMER_PKGS) $(MAKE) fuzz-smoke $(GOVULNCHECK) ./... diff --git a/README.md b/README.md index 0840a639..7d27c741 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ CWT/COSE, and gateway-route policy experiments. - `docs/API_COMPATIBILITY.md`: supported v1 API and compatibility policy. - `docs/production-deployment-profile.md`: fixed production choices for trust, revocation, attestation, distributed replay, and exact action binding. +- `docs/azure-sev-snp-attestation-bridge.md`: unreleased Azure Attestation + token-to-ASB bridge boundary and live confidential-VM qualification. +- `docs/redis-failover-runbook.md`: private multi-node replay topology, + replication acknowledgement, and real failover gate. - `formal/`: ProVerif and TLA+ models, recorded results, and model-to-implementation traceability. - `pkg/clients`, `pkg/atls`, and `pkg/atls/identitypolicy`: Direct-Agent @@ -113,12 +117,15 @@ The release evidence covers: for compact JWT/JWS parsing, and deterministic acceptance invariants; - route-assertion policy tests and a local HTTP route-assertion harness for the documented gateway boundary. -- a production composition with current trust/revocation snapshots, signed - attestation-result policy, and TLS-only Redis/Valkey SETNX replay; +- a production composition with current trust/revocation snapshots and + TLS-only Redis/Valkey SETNX replay, plus an unreleased Azure SEV-SNP + token-to-result bridge tested with signed synthetic tokens and optional + same-connection Redis replica acknowledgement; - an independent protected-change HTTPS consumer that rejects a changed action, wrong TLS session, replay, revoked grant, attestation mismatch, and replay-store outage; and -- a 20-client TLS replay-store race that requires exactly one SETNX winner. +- a 20-client TLS replay-store race that requires exactly one SETNX winner and + a replica acknowledgement for that accepted write. For accepted TLS sessions, the AGTP observed-identity path derives `tls_exporter_sha256` from the accepted `tls.ConnectionState`. Fixed exporter diff --git a/cmd/redis-failover-redteam/main.go b/cmd/redis-failover-redteam/main.go new file mode 100644 index 00000000..d38e7034 --- /dev/null +++ b/cmd/redis-failover-redteam/main.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/thinksyncs/agents-secure-binding/pkg/production" +) + +const ( + stateVersion = "asb.redis-failover-evidence/v1" + phaseSeed = "seed" + phaseVerify = "verify" +) + +var ( + errReplayAcceptedAfterFailover = errors.New("replay key was accepted after failover") + errEvidenceExpired = errors.New("failover evidence TTL expired before verification") +) + +type options struct { + Phase string + StateFile string + Address string + ServerName string + CAFile string + ClientCertificate string + ClientKey string + KeyPrefix string + RequiredReplicas int + ReplicationTimeout time.Duration + OperationTimeout time.Duration + TTL time.Duration +} + +type evidenceState struct { + Version string `json:"version"` + ReplayKey string `json:"replay_key"` + ReplayKeySHA256 string `json:"replay_key_sha256"` + SeededAt time.Time `json:"seeded_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +type setNXStore interface { + SetNX(context.Context, string, time.Duration) (bool, error) +} + +func main() { + opts := options{} + flag.StringVar(&opts.Phase, "phase", "", "test phase: seed or verify") + flag.StringVar(&opts.StateFile, "state-file", "", "private state file shared across failover") + flag.StringVar(&opts.Address, "address", "", "stable private Redis/Valkey endpoint") + flag.StringVar(&opts.ServerName, "server-name", "", "TLS server name") + flag.StringVar(&opts.CAFile, "ca-file", "", "PEM CA bundle for the replay service") + flag.StringVar(&opts.ClientCertificate, "client-certificate", "", "optional PEM client certificate") + flag.StringVar(&opts.ClientKey, "client-key", "", "optional PEM client private key") + flag.StringVar(&opts.KeyPrefix, "key-prefix", "asb:redis-failover:v1:", "isolated Redis key prefix") + flag.IntVar(&opts.RequiredReplicas, "required-replicas", 1, "replica acknowledgements required by WAIT") + flag.DurationVar(&opts.ReplicationTimeout, "replication-timeout", time.Second, "WAIT timeout") + flag.DurationVar(&opts.OperationTimeout, "operation-timeout", 5*time.Second, "total replay operation timeout") + flag.DurationVar(&opts.TTL, "ttl", 30*time.Minute, "failover evidence TTL") + flag.Parse() + + if err := execute(context.Background(), opts, time.Now(), rand.Reader); err != nil { + fmt.Fprintf(os.Stderr, "redis failover red-team failed: %v\n", err) + os.Exit(1) + } +} + +func execute(ctx context.Context, opts options, now time.Time, randomness io.Reader) error { + if strings.TrimSpace(opts.StateFile) == "" || strings.TrimSpace(opts.Address) == "" || + strings.TrimSpace(opts.ServerName) == "" || strings.TrimSpace(opts.CAFile) == "" { + return errors.New("state-file, address, server-name, and ca-file are required") + } + tlsConfig, err := loadTLSConfig(opts) + if err != nil { + return err + } + store := production.RedisSetNXStore{ + Address: opts.Address, + Username: os.Getenv("ASB_REDIS_USERNAME"), + Password: os.Getenv("ASB_REDIS_PASSWORD"), + KeyPrefix: opts.KeyPrefix, + TLSConfig: tlsConfig, + OperationTimeout: opts.OperationTimeout, + RequiredReplicaAcknowledgements: opts.RequiredReplicas, + ReplicationTimeout: opts.ReplicationTimeout, + } + return runPhase(ctx, opts, store, now, randomness) +} + +func runPhase(ctx context.Context, opts options, store setNXStore, now time.Time, randomness io.Reader) error { + if ctx == nil || store == nil || randomness == nil || opts.TTL <= 0 { + return errors.New("invalid failover test configuration") + } + switch opts.Phase { + case phaseSeed: + rawKey := make([]byte, 32) + if _, err := io.ReadFull(randomness, rawKey); err != nil { + return fmt.Errorf("generate replay key: %w", err) + } + replayKey := base64.RawURLEncoding.EncodeToString(rawKey) + accepted, err := store.SetNX(ctx, replayKey, opts.TTL) + if err != nil { + return fmt.Errorf("seed replicated replay state: %w", err) + } + if !accepted { + return errors.New("fresh replay key was already present") + } + digest := sha256.Sum256([]byte(replayKey)) + state := evidenceState{ + Version: stateVersion, + ReplayKey: replayKey, + ReplayKeySHA256: hex.EncodeToString(digest[:]), + SeededAt: now.UTC(), + ExpiresAt: now.Add(opts.TTL).UTC(), + } + if err := writeState(opts.StateFile, state); err != nil { + return err + } + fmt.Printf("seed passed: replay_key_sha256=%s expires_at=%s\n", state.ReplayKeySHA256, state.ExpiresAt.Format(time.RFC3339)) + return nil + + case phaseVerify: + state, err := readState(opts.StateFile) + if err != nil { + return err + } + if !now.Before(state.ExpiresAt) { + return errEvidenceExpired + } + remainingTTL := state.ExpiresAt.Sub(now) + accepted, err := store.SetNX(ctx, state.ReplayKey, remainingTTL) + if err != nil { + return fmt.Errorf("verify replay state after failover: %w", err) + } + if accepted { + return errReplayAcceptedAfterFailover + } + fmt.Printf("verify passed: replay_key_sha256=%s remained rejected after failover\n", state.ReplayKeySHA256) + return nil + + default: + return errors.New("phase must be seed or verify") + } +} + +func loadTLSConfig(opts options) (*tls.Config, error) { + rootPEM, err := os.ReadFile(opts.CAFile) + if err != nil { + return nil, fmt.Errorf("read CA file: %w", err) + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(rootPEM) { + return nil, errors.New("CA file contains no usable certificates") + } + config := &tls.Config{ + RootCAs: roots, + ServerName: opts.ServerName, + MinVersion: tls.VersionTLS13, + } + if (opts.ClientCertificate == "") != (opts.ClientKey == "") { + return nil, errors.New("client-certificate and client-key must be provided together") + } + if opts.ClientCertificate != "" { + certificate, err := tls.LoadX509KeyPair(opts.ClientCertificate, opts.ClientKey) + if err != nil { + return nil, fmt.Errorf("load Redis client certificate: %w", err) + } + config.Certificates = []tls.Certificate{certificate} + } + return config, nil +} + +func writeState(path string, state evidenceState) error { + payload, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("marshal failover state: %w", err) + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return fmt.Errorf("write failover state: %w", err) + } + if _, err := file.Write(append(payload, '\n')); err != nil { + _ = file.Close() + return fmt.Errorf("write failover state: %w", err) + } + if err := file.Close(); err != nil { + return fmt.Errorf("close failover state: %w", err) + } + return nil +} + +func readState(path string) (evidenceState, error) { + info, err := os.Stat(path) + if err != nil { + return evidenceState{}, fmt.Errorf("stat failover state: %w", err) + } + if info.Mode().Perm()&0o077 != 0 { + return evidenceState{}, errors.New("failover state permissions must be 0600 or stricter") + } + payload, err := os.ReadFile(path) + if err != nil { + return evidenceState{}, fmt.Errorf("read failover state: %w", err) + } + var state evidenceState + if err := json.Unmarshal(payload, &state); err != nil { + return evidenceState{}, fmt.Errorf("decode failover state: %w", err) + } + digest := sha256.Sum256([]byte(state.ReplayKey)) + if state.Version != stateVersion || state.ReplayKey == "" || + state.ReplayKeySHA256 != hex.EncodeToString(digest[:]) || + state.SeededAt.IsZero() || state.ExpiresAt.IsZero() || !state.ExpiresAt.After(state.SeededAt) { + return evidenceState{}, errors.New("invalid failover state") + } + return state, nil +} diff --git a/cmd/redis-failover-redteam/main_test.go b/cmd/redis-failover-redteam/main_test.go new file mode 100644 index 00000000..8f2f7651 --- /dev/null +++ b/cmd/redis-failover-redteam/main_test.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "errors" + "os" + "sync" + "testing" + "time" +) + +type memorySetNXStore struct { + mu sync.Mutex + seen map[string]struct{} + err error +} + +func (s *memorySetNXStore) SetNX(_ context.Context, key string, _ time.Duration) (bool, error) { + if s.err != nil { + return false, s.err + } + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.seen[key]; ok { + return false, nil + } + s.seen[key] = struct{}{} + return true, nil +} + +func TestRunPhaseRejectsReplayAfterFailover(t *testing.T) { + t.Parallel() + stateFile := t.TempDir() + "/state.json" + now := time.Date(2026, time.August, 3, 12, 0, 0, 0, time.UTC) + opts := options{Phase: phaseSeed, StateFile: stateFile, TTL: time.Hour} + store := &memorySetNXStore{seen: make(map[string]struct{})} + if err := runPhase(context.Background(), opts, store, now, bytes.NewReader(make([]byte, 32))); err != nil { + t.Fatalf("seed error = %v", err) + } + opts.Phase = phaseVerify + if err := runPhase(context.Background(), opts, store, now.Add(time.Minute), bytes.NewReader(make([]byte, 32))); err != nil { + t.Fatalf("verify error = %v", err) + } +} + +func TestRunPhaseDetectsLostReplayWrite(t *testing.T) { + t.Parallel() + stateFile := t.TempDir() + "/state.json" + now := time.Date(2026, time.August, 3, 12, 0, 0, 0, time.UTC) + opts := options{Phase: phaseSeed, StateFile: stateFile, TTL: time.Hour} + beforeFailover := &memorySetNXStore{seen: make(map[string]struct{})} + if err := runPhase(context.Background(), opts, beforeFailover, now, bytes.NewReader(make([]byte, 32))); err != nil { + t.Fatalf("seed error = %v", err) + } + + opts.Phase = phaseVerify + afterFailover := &memorySetNXStore{seen: make(map[string]struct{})} + err := runPhase(context.Background(), opts, afterFailover, now.Add(time.Minute), bytes.NewReader(make([]byte, 32))) + if !errors.Is(err, errReplayAcceptedAfterFailover) { + t.Fatalf("verify error = %v, want %v", err, errReplayAcceptedAfterFailover) + } +} + +func TestRunPhaseRejectsExpiredEvidenceAndStoreOutage(t *testing.T) { + t.Parallel() + stateFile := t.TempDir() + "/state.json" + now := time.Date(2026, time.August, 3, 12, 0, 0, 0, time.UTC) + opts := options{Phase: phaseSeed, StateFile: stateFile, TTL: time.Minute} + store := &memorySetNXStore{seen: make(map[string]struct{})} + if err := runPhase(context.Background(), opts, store, now, bytes.NewReader(make([]byte, 32))); err != nil { + t.Fatalf("seed error = %v", err) + } + opts.Phase = phaseVerify + if err := runPhase(context.Background(), opts, store, now.Add(time.Minute), bytes.NewReader(make([]byte, 32))); !errors.Is(err, errEvidenceExpired) { + t.Fatalf("expired verify error = %v, want %v", err, errEvidenceExpired) + } + + outage := errors.New("replay service unavailable") + opts = options{Phase: phaseSeed, StateFile: t.TempDir() + "/state.json", TTL: time.Hour} + store = &memorySetNXStore{seen: make(map[string]struct{}), err: outage} + if err := runPhase(context.Background(), opts, store, now, bytes.NewReader(make([]byte, 32))); !errors.Is(err, outage) { + t.Fatalf("outage error = %v, want %v", err, outage) + } +} + +func TestFailoverStateRejectsOverwriteAndBroadPermissions(t *testing.T) { + t.Parallel() + path := t.TempDir() + "/state.json" + state := evidenceState{ + Version: stateVersion, + ReplayKey: "test-replay-key", + ReplayKeySHA256: "ignored-until-read", + SeededAt: time.Date(2026, time.August, 3, 12, 0, 0, 0, time.UTC), + ExpiresAt: time.Date(2026, time.August, 3, 13, 0, 0, 0, time.UTC), + } + if err := writeState(path, state); err != nil { + t.Fatalf("writeState() error = %v", err) + } + if err := writeState(path, state); err == nil { + t.Fatal("writeState() overwrote existing evidence") + } + if err := os.Chmod(path, 0o644); err != nil { + t.Fatal(err) + } + if _, err := readState(path); err == nil { + t.Fatal("readState() accepted broadly readable evidence") + } +} diff --git a/docs/API_COMPATIBILITY.md b/docs/API_COMPATIBILITY.md index b22055d8..bf4848a4 100644 --- a/docs/API_COMPATIBILITY.md +++ b/docs/API_COMPATIBILITY.md @@ -24,6 +24,8 @@ The following remain experimental or outside the supported product API: - draft-06-inspired v2 entrypoints and types; - `pkg/agtp`, gateway-route, cache, and diversion-policy adapters; +- the unreleased Azure Attestation bridge and Redis failover command, until a + minor release explicitly adds them to the supported surface; - the inherited Manager, Agent, CVM, HAL, proxy, and CLI runtime surfaces; - examples, test harnesses, formal models, and document structure; and - hardware-specific evidence acquisition and appraisal implementations. @@ -63,4 +65,7 @@ depend on it to fail. Replay storage is compatible with Redis or Valkey servers that implement `SET key value NX PX ttl` over TLS. Store unavailability is an authentication -failure; there is no in-memory fallback in the production profile. +failure; there is no in-memory fallback in the production profile. The +unreleased replica-acknowledgement option additionally requires +`WAIT numreplicas timeout` on the same connection. `WAIT` compatibility does +not imply strong consistency or zero-loss failover behavior. diff --git a/docs/azure-sev-snp-attestation-bridge.md b/docs/azure-sev-snp-attestation-bridge.md new file mode 100644 index 00000000..c2ee5e54 --- /dev/null +++ b/docs/azure-sev-snp-attestation-bridge.md @@ -0,0 +1,109 @@ +# Azure SEV-SNP attestation bridge + +Status: unreleased candidate deployment profile for `protected-change-v1`; a +successful run on an Azure confidential VM and a minor release are required +before it joins the supported product surface. + +## Boundary + +`production.AzureSNPAttestationBridge` is the relying-party bridge between an +Azure Attestation JWT and `production.AttestationResult`. It does not collect a +quote itself and does not silently enable the inherited Azure runtime fetcher. +The confidential VM obtains the token with Microsoft's guest-attestation +library; the bridge authenticates and appraises that token and signs the +short-lived ASB result. + +The deployment has three separate trust domains: + +1. Azure Attestation signs the hardware appraisal JWT with RS256. +2. The bridge pins the expected Azure Attestation issuer and a reviewed key + snapshot from that provider's OpenID metadata. +3. The bridge signs `asb-attestation-result/v1` with its own Ed25519 key. The + ASB verifier trusts that key only in the attestation-verifier role. + +The bridge never follows a token-provided `jku` URL during acceptance. Key +refresh is an administrative operation and must atomically replace the trusted +snapshot after the provider issuer and OpenID metadata endpoint are checked. + +## Exact session and action binding + +For each accepted TLS session and canonical action: + +1. ASB derives `attestation_binder_sha256` from the peer key, TLS exporter, + canonical action, and verifier nonce. +2. The caller computes + `production.AzureSNPChallenge(attestation_binder_sha256)`. +3. The confidential workload supplies that ASCII value as the Azure guest + attestation nonce. +4. The bridge requires the signed token's `nonce` claim to match the challenge + exactly before it signs an ASB result. + +A token collected for another TLS session, action, or verifier nonce therefore +cannot be converted into a result for the current binder. A deployment must +confirm that its chosen Azure guest-attestation API includes the supplied +nonce in the verified token flow; merely copying an unverified request value +into application metadata is insufficient. + +## Fixed appraisal + +Configure the bridge with exact values, not prefixes or display names: + +- Azure Attestation issuer URI; +- enabled RS256 signing-key IDs and RSA public keys; +- accepted `x-ms-policy-hash` values; +- accepted SEV-SNP launch measurements; +- minimum guest SVN; +- debug disabled; +- migration disabled unless the deployment has a separate migration policy; +- maximum Azure token age; and +- a short ASB result TTL. + +The bridge accepts the documented nested guest-attestation claim layout and a +direct top-level SEV-SNP layout. It rejects missing or ambiguous security +claims. It does not infer defaults for debug, migration, SVN, measurement, or +policy hash. + +## Real-hardware qualification + +Run this qualification on an Azure AMD SEV-SNP confidential VM with vTPM and +Secure Boot enabled: + +1. Install Microsoft's supported guest-attestation package and workload + integration. +2. Generate an ASB binder for one real mTLS session and canonical protected + action, then compute its Azure challenge. +3. Request an Azure Attestation token with that challenge. +4. Verify it through `AzureMAATokenVerifier` and issue the ASB result through + `AzureSNPAttestationBridge`. +5. Complete the protected-change request and record only non-sensitive + fingerprints, measurement, policy ID, provider issuer, key ID, and time. +6. Repeat with a second session and require rejection of the first token under + the second binder. + +The negative run must also reject a wrong issuer, unknown or disabled MAA key, +expired or stale token, wrong policy hash, wrong launch measurement, low guest +SVN, debug enabled, migration enabled, and bridge-key revocation. + +Do not upload raw tokens, TPM keys, TLS private keys, Redis credentials, or +bridge private keys as CI artifacts. Azure resource creation and the hardware +run are deployment operations with account and cost implications; default +GitHub-hosted runners do not qualify as hardware evidence. + +## Operations + +Keep the bridge Ed25519 key in an HSM or managed KMS signer in production. +`AzureSNPAttestationBridge.Signer` accepts a standard `crypto.Signer`; local +tests use an ephemeral Ed25519 key, while a production service supplies its +KMS/HSM-backed adapter. A long-lived production service must not load the raw +bridge private key from application configuration. + +Rotate MAA and bridge keys with an overlap window: add the new key, verify live +traffic and negative tests, switch issuance, then disable the old key. A +provider-token verification outage or unavailable key snapshot fails closed; +there is no software-only attestation fallback. + +Primary references: + +- +- +- diff --git a/docs/live-red-team-report.md b/docs/live-red-team-report.md index 85e6352a..1d827943 100644 --- a/docs/live-red-team-report.md +++ b/docs/live-red-team-report.md @@ -163,7 +163,9 @@ All checks passed. `docs/SSOT.pdf` rendered as a 24-page PDF. | `TestVerifySessionIdentityJWTInvariantMatrix` | The JWT acceptance invariant rejects mismatched grant hash, request context, TLS exporter, attestation binder, audience, role-separated context, task, replay, and local policy. | Passed locally | | `TestSEVSNPAppraisalContractValidateAcceptsRequiredEvidence` and companion negative tests | SEV-SNP HostData and `kernel-hashes=on` appraisal contract accepts matching evidence and rejects missing expected HostData, mismatched HostData, or missing kernel-hash evidence. | Passed locally | | `TestProfileVerifyAcceptsProductionComposition` and negative gates | Role-separated trust, revocation, signed attestation, exact policy/binding, and shared replay commit are enforced in one acceptance path; failures do not commit replay state. | Passed locally | -| `TestRedisSetNXStoreCommitsOneWinnerOverTLS` | Twenty concurrent TLS clients race one hashed replay key against the Redis/Valkey wire adapter; exactly one SETNX succeeds. | Passed locally | +| `TestAzureSNPAttestationBridgeIssuesVerifiableResult` and companion negative tests | A pinned-issuer RS256 Azure Attestation claim set is converted into a short-lived Ed25519 ASB result only for the exact binder challenge, policy hash, launch measurement, SVN, debug, migration, key, and lifetime. | Passed locally; token is synthetic, not live hardware evidence | +| `TestRedisSetNXStoreCommitsOneWinnerOverTLS` | Twenty concurrent TLS clients race one hashed replay key against the Redis/Valkey wire adapter; exactly one SETNX succeeds after one replica acknowledgement. | Passed locally | +| `TestRunPhaseRejectsReplayAfterFailover` and companion negative tests | The two-phase failover gate rejects a replay retained across a modeled failover and detects a lost replay write, expired evidence, and store outage. | Passed locally; real multi-node failover is not recorded | | `TestProtectedChangeE2EAcceptsExactBoundAction` and companion negative tests | A concrete mTLS HTTPS consumer applies the exact protected change and rejects action mutation, wrong TLS session, replay, revoked grant, attestation mismatch, and replay-store outage. | Passed locally | ## LRTT Status @@ -172,7 +174,7 @@ All checks passed. `docs/SSOT.pdf` rendered as a 24-page PDF. | --- | --- | --- | --- | | LRTT01 | Completed for the dependency-free CI harness | `TestAGTPObservedIdentityRedTeamRealTLSAttestationBinding` covers real TLS 1.3 exporter binding, certificate material, accepted attestation payload, AGTP hook acceptance, and borrowed-session rejection | Hardware-generated confidential-VM evidence is not exercised in this local CI profile | | LRTT02 | Completed | `TestVerifySessionIdentityCWTAcceptsManagerGrantAndLocalPolicy` and `TestVerifySessionIdentityCWTRedTeamRejectsCOSEProfileAttacks` | Runtime client configuration remains JWT/JWS-wired unless callers use the CWT verifier directly | -| LRTT03 | Completed | `TestAGTPObservedIdentityRedTeamRejectsReplayRaceMultiProcess` | Real multi-node Redis / Valkey deployment is outside the local harness | +| LRTT03 | Gate implemented; real failover run not recorded | `TestAGTPObservedIdentityRedTeamRejectsReplayRaceMultiProcess`, replica-acknowledged `RedisSetNXStore`, and `cmd/redis-failover-redteam` cover local race and seed/verify behavior | Counts as deployment evidence only after the selected private multi-node service is failed over successfully | | LRTT04 | Completed | `TestAGTPObservedIdentityRedTeamRejectsKeyAndRevocationFailures` | None for the modeled HTTP key and revocation failure modes | | LRTT05 | Completed | `TestAGTPObservedIdentityRedTeamRejectsAttestationBinderMismatch` | None for binder mismatch comparison | | LRTT06 | Completed | `TestVerifySessionIdentityJWTEnvelopeRedTeamRejectsSubstitution` | Runtime client configuration remains two-token JWT/JWS-wired unless callers use the envelope verifier directly | @@ -180,7 +182,7 @@ All checks passed. `docs/SSOT.pdf` rendered as a 24-page PDF. | LRTT08 | Superseded by LRTT15 gateway work | SSOT separates gateway mode from the direct-Agent trust model; `docs/gateway-routed-profile.md` now defines the companion profile | Full gateway-routed runtime harness remains future work | | LRTT09 | Completed locally | `TestValidateResponseCachePolicyRedTeamRejectsCallerDependentPublicCache`; `TestValidateResponseCachePolicyRedTeamPartitionsPrivateCache` | This is a dependency-free policy and cache-key harness, not a live AGTP daemon response-cache implementation | | LRTT10 | Not implemented | Tracked from the evaluation matrix | Real network relay with live endpoints and an active relay | -| LRTT11 | Workflow added; hardware run not recorded | `Hardware Attestation Red Team` runs `cmd/hardware-attestation-redteam` on a confidential self-hosted runner and rejects stale evidence across two verifier challenges | Counts as completed only after a successful confidential-hardware run is recorded | +| LRTT11 | Bridge and workflow added; hardware run not recorded | `Hardware Attestation Red Team` exercises direct hardware challenge binding, while `AzureMAATokenVerifier` and `AzureSNPAttestationBridge` enforce the selected Azure token-to-ASB contract in local tests | Counts as completed only after a successful Azure confidential-VM token and protected-change run is recorded | | LRTT12 | Completed for the dependency-free loopback harness | `TestVerifySessionIdentityJWTLiveRedTeamRejectsNetworkRelayAcrossEndpoints` | Uses two live local TLS endpoints and relayed profile material; it is not a full malicious forwarding proxy | | LRTT13 | Completed for local HTTP/2 and gRPC reuse | `TestVerifySessionIdentityJWTLiveRedTeamHTTP2ConnectionReuse`; `TestVerifySessionIdentityJWTLiveRedTeamGRPCConnectionReuse` | Broader deployment gRPC pooling coverage remains future work | | LRTT14 | Completed for local TLS resumption, pre-binding rejection, and QUIC/TLS early-data authentication gating | `TestVerifySessionIdentityJWTLiveRedTeamRejectsTLSResumptionReplayAndPreBinding`; `TestVerifySessionIdentityJWTLiveRedTeamRejectsQUICEarlyDataAuthentication` | End-to-end application 0-RTT payload behavior remains future work if a QUIC application profile is introduced | @@ -223,6 +225,10 @@ All checks passed. `docs/SSOT.pdf` rendered as a 24-page PDF. workflow for confidential self-hosted runner attestation replay evidence. - Added a SEV-SNP HostData and `kernel-hashes=on` appraisal contract with fail-closed tests. +- Added a pinned-issuer Azure SEV-SNP Attestation token bridge and exact + binder-challenge negative tests. +- Added same-connection Redis/Valkey replica acknowledgement and a two-phase + real-service failover qualification command. ## Residual Boundaries @@ -230,15 +236,14 @@ These are not blockers for the completed branch; they are profile or deployment boundaries that need separate work if the project chooses to support them. - Hardware-generated confidential-VM attestation evidence is not produced by the - default GitHub-hosted CI runners. The manual hardware workflow requires a - confidential self-hosted runner, and no successful hardware run is recorded - here yet. + default GitHub-hosted CI runners. The Azure bridge tests use signed synthetic + tokens; no successful Azure confidential-VM token run is recorded here yet. - CWT/COSE verification exists in `pkg/agtp`, but client configuration is still wired for the JWT/JWS runtime path. -- Replay coverage includes the TLS Redis/Valkey `SET NX PX` wire adapter and a - 20-client one-winner race against a local protocol server. Real multi-node - failover, persistence, and operational timeout behavior remain deployment - validation. +- Replay coverage includes the TLS Redis/Valkey `SET NX PX` wire adapter, + same-connection `WAIT`, a 20-client one-winner race, and the failover + seed/verify command. No successful real multi-node failover, persistence, or + operational timeout run is recorded here yet. - Gateway-routed deployments now have a fixed route-assertion claim map, holder-of-key proof rules, a local policy gate, and JWT/CWT route-assertion adapters plus a local HTTP route-assertion harness. Runtime client/server diff --git a/docs/production-deployment-profile.md b/docs/production-deployment-profile.md index afe561fd..5f74bb21 100644 --- a/docs/production-deployment-profile.md +++ b/docs/production-deployment-profile.md @@ -1,6 +1,8 @@ # Production deployment profile: protected-change-v1 -Status: supported beginning with `v1.0.0`. +Status: the baseline is supported beginning with `v1.0.0`. The Azure SEV-SNP +bridge and Redis replica-acknowledgement additions documented below are +unreleased candidates pending live qualification and a minor release. This is one concrete Direct-Agent v1 deployment profile. Its reference consumer is a tenant-configuration change service, not Split-Knowledge. The @@ -21,8 +23,8 @@ policy, and shared replay state all agree. | Exporter context | `asb.direct-agent.production.v1 NUL nonce NUL canonical_action` | | Action digest | SHA-256 of canonical protected-change JSON | | Trust and revocation | fresh role-specific `production.TrustSource` snapshot on every acceptance | -| Attestation | Ed25519-signed `asb-attestation-result/v1` with exact policy, measurement, binder, issue time, and expiry | -| Replay | Redis/Valkey `SET NX PX` over certificate-verified TLS; fail closed on error | +| Attestation | v1.0 baseline: Ed25519-signed `asb-attestation-result/v1`; unreleased extension: pinned-issuer Azure SEV-SNP MAA bridge | +| Replay | v1.0 baseline: Redis/Valkey `SET NX PX`; unreleased extension: same-connection `WAIT`; certificate-verified TLS and fail closed | | Outcome | consumer-owned durable, idempotent store keyed by `change_id` | Manager, Agent, and attestation-verifier keys are separate trust domains. A key @@ -88,6 +90,15 @@ future skew, and expiry. Missing or stale results fail closed. This profile authenticates an appraisal result; evidence acquisition and hardware-specific appraisal remain deployment responsibilities. +For the selected Azure SEV-SNP deployment, +`production.AzureMAATokenVerifier` authenticates an RS256 Azure Attestation JWT +against a pinned issuer and deployment-managed key snapshot. It does not follow +token-provided key URLs during acceptance. `production.AzureSNPAttestationBridge` +then enforces exact policy hash, launch measurement, guest SVN, debug and +migration policy, and a signed nonce derived from the exact ASB binder before +issuing the short-lived result. See +[`azure-sev-snp-attestation-bridge.md`](azure-sev-snp-attestation-bridge.md). + ## Distributed replay Configure the shared replay cache with a bounded, certificate-verified TLS @@ -95,10 +106,12 @@ connection: ```go redisStore := production.RedisSetNXStore{ - Address: "replay.internal.example:6379", - KeyPrefix: "asb:protected-change:v1:", - TLSConfig: redisTLSConfig, - OperationTimeout: 2 * time.Second, + Address: "replay.internal.example:6379", + KeyPrefix: "asb:protected-change:v1:", + TLSConfig: redisTLSConfig, + OperationTimeout: 2 * time.Second, + RequiredReplicaAcknowledgements: 1, + ReplicationTimeout: 500 * time.Millisecond, } replay := identitypolicy.NewSetNXReplayCache(ctx, redisStore) ``` @@ -109,7 +122,15 @@ certificate may be used. Replay input is hashed before becoming a Redis key. The atomic key covers the grant hash, audience, exact action context, and verifier nonce; the TTL is the earliest grant, proof, or attestation expiry. Connection, TLS, authentication, protocol, timeout, and store errors all reject -the action. No local replay fallback is used. +the action. An insufficient `WAIT` acknowledgement also rejects the action. The +write may already exist after that rejection. No local replay fallback is used. + +`WAIT` reduces the acknowledged-write loss window but does not make Redis a +strongly consistent store. Real failover qualification is required for the +selected managed or self-operated topology; see +[`redis-failover-runbook.md`](redis-failover-runbook.md). A deployment that +requires zero replay after every possible failover must use a strongly +consistent conditional-insert backend instead of relying on Redis replication. ## Consumer transaction @@ -142,8 +163,15 @@ The negative suite covers trust-source outage, unknown or disabled keys, revoked grant, changed action, wrong local task, wrong TLS session, replay, attestation binder mismatch, stale attestation, unapproved measurement, and shared replay-store outage. The Redis/Valkey adapter test races 20 TLS clients -against one key and requires exactly one winner. +against one key, requires exactly one winner, and requires a replica +acknowledgement for the successful write. The failover command provides a +two-phase seed/verify gate for the selected real service: + +```sh +go test -race -count=1 ./cmd/redis-failover-redteam +``` -These tests are implementation evidence for the documented profile. They are -not evidence that a particular external key registry, Redis/Valkey cluster, or -hardware attestation service is correctly operated. +These tests are implementation evidence for the documented profile. A +successful Azure confidential-VM run and a successful multi-node Redis/Valkey +failover run must be recorded separately before claiming those deployment +properties. diff --git a/docs/redis-failover-runbook.md b/docs/redis-failover-runbook.md new file mode 100644 index 00000000..809784a6 --- /dev/null +++ b/docs/redis-failover-runbook.md @@ -0,0 +1,108 @@ +# Redis/Valkey replay failover runbook + +Status: deployment qualification for `protected-change-v1`; local protocol +tests do not replace a run against the selected multi-node service. + +## Topology + +All ASB verifier instances connect to one stable private Redis/Valkey primary +endpoint over certificate-verified TLS 1.3. The endpoint is external to the +ASB process but must not be exposed to the public Internet. A development +instance may run on the same host; that arrangement is not multi-node HA. + +Use either: + +- a managed single-region HA service that maintains a stable primary endpoint; + or +- a self-operated primary and replicas with Sentinel, plus a client/proxy that + implements Sentinel discovery and role verification. + +`production.RedisSetNXStore` uses one fixed address and does not implement +Sentinel discovery or Redis Cluster `MOVED`/`ASK` redirects. A deployment that +does not provide a stable endpoint must add and test that discovery layer +before using this adapter. + +## Replication acknowledgement + +Configure a same-connection `WAIT` after the successful replay `SET NX PX`: + +```go +redisStore := production.RedisSetNXStore{ + Address: "replay.internal.example:6379", + KeyPrefix: "asb:protected-change:v1:", + TLSConfig: redisTLSConfig, + OperationTimeout: 2 * time.Second, + RequiredReplicaAcknowledgements: 1, + ReplicationTimeout: 500 * time.Millisecond, +} +``` + +Insufficient acknowledgements, timeout, disconnect, TLS failure, authentication +failure, or protocol error rejects the ASB request. The initial `SET` may +already exist when `WAIT` fails, so a retry may also be rejected. This is a +deliberate fail-closed availability trade-off. + +Redis replication remains asynchronous. `WAIT` reduces the practical lost-write +window but does not make Redis a strongly consistent CP store and cannot prove +zero replay across every failover. If zero lost replay writes are mandatory, +replace the replay backend with a strongly consistent conditional-insert store. + +## Real failover gate + +Run both phases from a host that can reach the private endpoint. Supply ACL +credentials through `ASB_REDIS_USERNAME` and `ASB_REDIS_PASSWORD`, never as +command-line arguments. + +Before failover: + +```sh +go run ./cmd/redis-failover-redteam \ + --phase seed \ + --state-file /secure/asb-redis-failover.json \ + --address replay.internal.example:6379 \ + --server-name replay.internal.example \ + --ca-file /secure/redis-ca.pem \ + --required-replicas 1 \ + --replication-timeout 500ms \ + --ttl 30m +``` + +After the seed reports success, trigger a planned primary failover through the +selected service's control plane or Sentinel. Wait only until the stable +endpoint reports the promoted primary as writable, then run: + +```sh +go run ./cmd/redis-failover-redteam \ + --phase verify \ + --state-file /secure/asb-redis-failover.json \ + --address replay.internal.example:6379 \ + --server-name replay.internal.example \ + --ca-file /secure/redis-ca.pem \ + --required-replicas 1 \ + --replication-timeout 500ms \ + --ttl 30m +``` + +The verify phase passes only when the seeded key still exists and is rejected +as replay. It fails if the key was lost, the evidence TTL expired, replication +acknowledgement is insufficient, or the service is unavailable. + +Repeat the gate at these cut points: + +1. immediately after the primary returns `SET OK`; +2. while `WAIT` is outstanding; +3. during stable-endpoint/DNS convergence; +4. with an old-primary/new-primary network partition; +5. after an ASB process restart; and +6. during TLS certificate and ACL credential rotation. + +Record service/version, topology, persistence mode, replica count, requested +and observed acknowledgements, failover start/end time, ASB error class, and +the replay-key fingerprint. Do not record Redis passwords, client private keys, +or the raw replay state file. + +Primary references: + +- +- +- diff --git a/pkg/production/azure_snp_bridge.go b/pkg/production/azure_snp_bridge.go new file mode 100644 index 00000000..5d559e31 --- /dev/null +++ b/pkg/production/azure_snp_bridge.go @@ -0,0 +1,310 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +package production + +import ( + "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math" + "strconv" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +var ( + ErrAzureMAAToken = errors.New("production: invalid Azure Attestation token") + ErrAzureMAAKey = errors.New("production: invalid Azure Attestation signing key") + ErrAzureSNPClaims = errors.New("production: invalid Azure SEV-SNP claims") + ErrAzureSNPPolicy = errors.New("production: Azure SEV-SNP policy mismatch") + ErrAzureSNPBinding = errors.New("production: Azure SEV-SNP binding mismatch") + ErrAzureSNPBridge = errors.New("production: invalid Azure SEV-SNP bridge configuration") +) + +const ( + AzureSNPChallengeVersion = "asb.azure-sevsnp.challenge/v1" + AzureSNPAttestationType = "sevsnpvm" +) + +// AzureSNPTokenClaims is the normalized, signature-verified subset of an Azure +// Attestation token used by the production bridge. +type AzureSNPTokenClaims struct { + TokenID string + Nonce string + PolicyHash string + Measurement string + AttestationType string + GuestSVN uint64 + Debuggable bool + MigrationAllowed bool + IssuedAt time.Time + ExpiresAt time.Time +} + +// AzureSNPTokenVerifier authenticates an Azure Attestation token and returns +// normalized SEV-SNP claims. Implementations must verify the token signature, +// issuer, lifetime, and key ID before returning claims. +type AzureSNPTokenVerifier interface { + Verify(context.Context, string, time.Time) (AzureSNPTokenClaims, error) +} + +// AzureMAATokenVerifier verifies RS256 Azure Attestation JWTs against a pinned +// issuer and a deployment-managed signing-key snapshot. It deliberately does +// not follow token-provided jku URLs or fetch keys during request acceptance. +type AzureMAATokenVerifier struct { + Issuer string + TrustedKeys map[string]*rsa.PublicKey + DisabledKeyIDs []string + MaxAge time.Duration + ClockSkew time.Duration +} + +// Verify authenticates one Azure Attestation JWT and extracts its SEV-SNP +// appraisal claims. Both the current nested guest-attestation claim layout and +// direct top-level SEV-SNP claims are accepted. +func (v AzureMAATokenVerifier) Verify(ctx context.Context, token string, now time.Time) (AzureSNPTokenClaims, error) { + if ctx == nil { + return AzureSNPTokenClaims{}, ErrMissingContext + } + if err := ctx.Err(); err != nil { + return AzureSNPTokenClaims{}, err + } + if strings.TrimSpace(v.Issuer) == "" || len(v.TrustedKeys) == 0 || v.MaxAge <= 0 || v.ClockSkew < 0 { + return AzureSNPTokenClaims{}, ErrAzureMAAToken + } + if strings.TrimSpace(token) == "" { + return AzureSNPTokenClaims{}, ErrAzureMAAToken + } + if now.IsZero() { + now = time.Now() + } + + claims := jwt.MapClaims{} + parser := jwt.NewParser( + jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), + jwt.WithJSONNumber(), + jwt.WithIssuer(v.Issuer), + jwt.WithExpirationRequired(), + jwt.WithIssuedAt(), + jwt.WithLeeway(v.ClockSkew), + jwt.WithTimeFunc(func() time.Time { return now }), + ) + parsed, err := parser.ParseWithClaims(token, claims, func(token *jwt.Token) (any, error) { + keyID, ok := token.Header["kid"].(string) + if !ok || strings.TrimSpace(keyID) == "" { + return nil, ErrAzureMAAKey + } + if contains(v.DisabledKeyIDs, keyID) { + return nil, ErrAzureMAAKey + } + key, ok := v.TrustedKeys[keyID] + if !ok || key == nil || key.N == nil || key.E < 3 { + return nil, ErrAzureMAAKey + } + return key, nil + }) + if err != nil || !parsed.Valid { + return AzureSNPTokenClaims{}, fmt.Errorf("%w: signature, issuer, lifetime, or key validation failed", ErrAzureMAAToken) + } + + normalized, err := normalizeAzureSNPClaims(claims) + if err != nil { + return AzureSNPTokenClaims{}, err + } + if normalized.IssuedAt.After(now.Add(v.ClockSkew)) || now.Sub(normalized.IssuedAt) > v.MaxAge+v.ClockSkew { + return AzureSNPTokenClaims{}, ErrAzureMAAToken + } + return normalized, nil +} + +// AzureSNPAttestationBridge converts a verified Azure SEV-SNP appraisal into +// the role-separated ASB attestation-result format. +type AzureSNPAttestationBridge struct { + TokenVerifier AzureSNPTokenVerifier + VerifierKeyID string + Signer crypto.Signer + PolicyID string + AllowedPolicyHashes []string + AllowedMeasurements []string + MinimumGuestSVN uint64 + AllowDebug bool + AllowMigration bool + ResultTTL time.Duration +} + +// Issue authenticates an Azure token, enforces the deployment appraisal, and +// signs a short-lived ASB result bound to the exact expected ASB binder. +func (b AzureSNPAttestationBridge) Issue( + ctx context.Context, + token string, + expectedBinder string, + now time.Time, +) (AttestationResult, error) { + if ctx == nil { + return AttestationResult{}, ErrMissingContext + } + if b.TokenVerifier == nil || b.Signer == nil || + strings.TrimSpace(b.VerifierKeyID) == "" || + strings.TrimSpace(b.PolicyID) == "" || + len(b.AllowedPolicyHashes) == 0 || + len(b.AllowedMeasurements) == 0 || + b.ResultTTL <= 0 { + return AttestationResult{}, ErrAzureSNPBridge + } + publicKey, ok := b.Signer.Public().(ed25519.PublicKey) + if !ok || len(publicKey) != ed25519.PublicKeySize { + return AttestationResult{}, ErrAzureSNPBridge + } + if strings.TrimSpace(expectedBinder) == "" || strings.TrimSpace(expectedBinder) != expectedBinder { + return AttestationResult{}, ErrAzureSNPBinding + } + if now.IsZero() { + now = time.Now() + } + + claims, err := b.TokenVerifier.Verify(ctx, token, now) + if err != nil { + return AttestationResult{}, err + } + if claims.AttestationType != AzureSNPAttestationType || + !contains(b.AllowedPolicyHashes, claims.PolicyHash) || + !contains(b.AllowedMeasurements, claims.Measurement) || + claims.GuestSVN < b.MinimumGuestSVN || + (claims.Debuggable && !b.AllowDebug) || + (claims.MigrationAllowed && !b.AllowMigration) { + return AttestationResult{}, ErrAzureSNPPolicy + } + expectedChallenge := AzureSNPChallenge(expectedBinder) + if subtle.ConstantTimeCompare([]byte(claims.Nonce), []byte(expectedChallenge)) != 1 { + return AttestationResult{}, ErrAzureSNPBinding + } + if claims.IssuedAt.IsZero() || claims.ExpiresAt.IsZero() || !claims.ExpiresAt.After(now) { + return AttestationResult{}, ErrAzureMAAToken + } + + expiresAt := now.Add(b.ResultTTL) + if claims.ExpiresAt.Before(expiresAt) { + expiresAt = claims.ExpiresAt + } + result := AttestationResult{ + Version: AttestationResultVersion, + ResultID: claims.TokenID, + VerifierKeyID: b.VerifierKeyID, + PolicyID: b.PolicyID, + Measurement: claims.Measurement, + AttestationBinderSHA256: expectedBinder, + IssuedAt: now.UTC(), + ExpiresAt: expiresAt.UTC(), + } + payload, err := result.SigningBytes() + if err != nil { + return AttestationResult{}, err + } + result.Signature, err = b.Signer.Sign(rand.Reader, payload, crypto.Hash(0)) + if err != nil || len(result.Signature) != ed25519.SignatureSize { + return AttestationResult{}, ErrAzureSNPBridge + } + if !ed25519.Verify(publicKey, payload, result.Signature) { + return AttestationResult{}, ErrAzureSNPBridge + } + return result, nil +} + +// AzureSNPChallenge returns the nonce supplied to Azure guest attestation for +// one ASB binder. The returned ASCII value is safe for the MAA nonce claim. +func AzureSNPChallenge(expectedBinder string) string { + digest := sha256.Sum256([]byte(AzureSNPChallengeVersion + "\x00" + expectedBinder)) + return base64.RawURLEncoding.EncodeToString(digest[:]) +} + +func normalizeAzureSNPClaims(claims jwt.MapClaims) (AzureSNPTokenClaims, error) { + schemaVersion, _ := claims["x-ms-ver"].(string) + if schemaVersion != "1.0" { + return AzureSNPTokenClaims{}, ErrAzureSNPClaims + } + teeClaims := map[string]any(claims) + if nested, ok := claims["x-ms-isolation-tee"].(map[string]any); ok { + teeClaims = nested + } + + issuedAt, err := claims.GetIssuedAt() + if err != nil || issuedAt == nil { + return AzureSNPTokenClaims{}, ErrAzureSNPClaims + } + expiresAt, err := claims.GetExpirationTime() + if err != nil || expiresAt == nil { + return AzureSNPTokenClaims{}, ErrAzureSNPClaims + } + tokenID, _ := claims["jti"].(string) + nonce, _ := claims["nonce"].(string) + policyHash, _ := claims["x-ms-policy-hash"].(string) + measurement, _ := teeClaims["x-ms-sevsnpvm-launchmeasurement"].(string) + attestationType, _ := teeClaims["x-ms-attestation-type"].(string) + if attestationType == "" { + attestationType, _ = claims["x-ms-attestation-type"].(string) + } + guestSVN, ok := exactUint64(teeClaims["x-ms-sevsnpvm-guestsvn"]) + if !ok { + return AzureSNPTokenClaims{}, ErrAzureSNPClaims + } + debuggable, ok := teeClaims["x-ms-sevsnpvm-is-debuggable"].(bool) + if !ok { + return AzureSNPTokenClaims{}, ErrAzureSNPClaims + } + migrationAllowed, ok := teeClaims["x-ms-sevsnpvm-migration-allowed"].(bool) + if !ok { + return AzureSNPTokenClaims{}, ErrAzureSNPClaims + } + + for _, value := range []string{tokenID, nonce, policyHash, measurement, attestationType} { + if strings.TrimSpace(value) == "" || strings.TrimSpace(value) != value || len(value) > 4096 { + return AzureSNPTokenClaims{}, ErrAzureSNPClaims + } + } + return AzureSNPTokenClaims{ + TokenID: tokenID, + Nonce: nonce, + PolicyHash: policyHash, + Measurement: measurement, + AttestationType: attestationType, + GuestSVN: guestSVN, + Debuggable: debuggable, + MigrationAllowed: migrationAllowed, + IssuedAt: issuedAt.Time, + ExpiresAt: expiresAt.Time, + }, nil +} + +func exactUint64(value any) (uint64, bool) { + switch typed := value.(type) { + case json.Number: + parsed, err := strconv.ParseUint(typed.String(), 10, 64) + return parsed, err == nil + case float64: + const maxSafeJSONInteger = float64(1<<53 - 1) + if typed < 0 || typed > maxSafeJSONInteger || math.Trunc(typed) != typed { + return 0, false + } + return uint64(typed), true + case uint64: + return typed, true + case int: + if typed < 0 { + return 0, false + } + return uint64(typed), true + default: + return 0, false + } +} diff --git a/pkg/production/azure_snp_bridge_test.go b/pkg/production/azure_snp_bridge_test.go new file mode 100644 index 00000000..64f368a5 --- /dev/null +++ b/pkg/production/azure_snp_bridge_test.go @@ -0,0 +1,222 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +package production + +import ( + "context" + "crypto" + "crypto/ed25519" + "crypto/rand" + "crypto/rsa" + "encoding/json" + "errors" + "io" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type invalidAzureBridgeSigner struct { + public ed25519.PublicKey +} + +func (s invalidAzureBridgeSigner) Public() crypto.PublicKey { + return s.public +} + +func (s invalidAzureBridgeSigner) Sign(io.Reader, []byte, crypto.SignerOpts) ([]byte, error) { + return make([]byte, ed25519.SignatureSize), nil +} + +const ( + testMAAIssuer = "https://asb-prod.eus.attest.azure.net" + testMAAKeyID = "maa-rs256-2026-01" + testMAAPolicyHash = "maa-policy-sha256:test" + testAzureSNPMetric = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +type azureSNPBridgeFixture struct { + bridge AzureSNPAttestationBridge + maaPrivate *rsa.PrivateKey + attesterPub ed25519.PublicKey + now time.Time + binder string + claims jwt.MapClaims +} + +func TestAzureSNPAttestationBridgeIssuesVerifiableResult(t *testing.T) { + t.Parallel() + fixture := newAzureSNPBridgeFixture(t) + token := fixture.signToken(t, fixture.claims) + + result, err := fixture.bridge.Issue(context.Background(), token, fixture.binder, fixture.now) + if err != nil { + t.Fatalf("Issue() error = %v", err) + } + policy := SignedAttestationPolicy{ + TrustedKeys: map[string]ed25519.PublicKey{fixture.bridge.VerifierKeyID: fixture.attesterPub}, + PolicyID: fixture.bridge.PolicyID, + AllowedMeasurements: []string{testAzureSNPMetric}, + MaxAge: time.Minute, + ClockSkew: time.Second, + } + if err := policy.Verify(context.Background(), result, fixture.binder, fixture.now); err != nil { + t.Fatalf("SignedAttestationPolicy.Verify() error = %v", err) + } + if result.ExpiresAt.After(fixture.now.Add(fixture.bridge.ResultTTL)) { + t.Fatalf("result expiry = %v, exceeds bridge TTL", result.ExpiresAt) + } +} + +func TestAzureSNPAttestationBridgeRejectsPolicyAndBindingFailures(t *testing.T) { + t.Parallel() + tests := []struct { + name string + mutate func(*azureSNPBridgeFixture) + want error + }{ + {"wrong binder challenge", func(f *azureSNPBridgeFixture) { f.claims["nonce"] = AzureSNPChallenge(testHash("other-binder")) }, ErrAzureSNPBinding}, + {"debug enabled", func(f *azureSNPBridgeFixture) { f.tee()["x-ms-sevsnpvm-is-debuggable"] = true }, ErrAzureSNPPolicy}, + {"migration enabled", func(f *azureSNPBridgeFixture) { f.tee()["x-ms-sevsnpvm-migration-allowed"] = true }, ErrAzureSNPPolicy}, + {"measurement mismatch", func(f *azureSNPBridgeFixture) { f.tee()["x-ms-sevsnpvm-launchmeasurement"] = "different" }, ErrAzureSNPPolicy}, + {"policy hash mismatch", func(f *azureSNPBridgeFixture) { f.claims["x-ms-policy-hash"] = "different" }, ErrAzureSNPPolicy}, + {"guest svn too old", func(f *azureSNPBridgeFixture) { f.tee()["x-ms-sevsnpvm-guestsvn"] = float64(1) }, ErrAzureSNPPolicy}, + {"wrong issuer", func(f *azureSNPBridgeFixture) { f.claims["iss"] = "https://attacker.example" }, ErrAzureMAAToken}, + {"expired token", func(f *azureSNPBridgeFixture) { f.claims["exp"] = f.now.Add(-time.Minute).Unix() }, ErrAzureMAAToken}, + {"stale token", func(f *azureSNPBridgeFixture) { f.claims["iat"] = f.now.Add(-10 * time.Minute).Unix() }, ErrAzureMAAToken}, + {"disabled MAA key", func(f *azureSNPBridgeFixture) { + verifier := f.bridge.TokenVerifier.(AzureMAATokenVerifier) + verifier.DisabledKeyIDs = []string{testMAAKeyID} + f.bridge.TokenVerifier = verifier + }, ErrAzureMAAToken}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fixture := newAzureSNPBridgeFixture(t) + tt.mutate(fixture) + token := fixture.signToken(t, fixture.claims) + _, err := fixture.bridge.Issue(context.Background(), token, fixture.binder, fixture.now) + if !errors.Is(err, tt.want) { + t.Fatalf("Issue() error = %v, want %v", err, tt.want) + } + }) + } +} + +func TestExactUint64PreservesJSONIntegerSemantics(t *testing.T) { + t.Parallel() + if _, ok := exactUint64(float64(1 << 53)); ok { + t.Fatal("exactUint64() accepted a float64 outside the safe JSON integer range") + } + const maxUint64 = "18446744073709551615" + got, ok := exactUint64(json.Number(maxUint64)) + if !ok || got != ^uint64(0) { + t.Fatalf("exactUint64(%s) = (%d, %t)", maxUint64, got, ok) + } + if _, ok := exactUint64(json.Number("18446744073709551616")); ok { + t.Fatal("exactUint64() accepted a JSON integer larger than uint64") + } +} + +func TestAzureMAATokenVerifierRejectsUntrustedKey(t *testing.T) { + t.Parallel() + fixture := newAzureSNPBridgeFixture(t) + attacker, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, fixture.claims) + token.Header["kid"] = "attacker" + signed, err := token.SignedString(attacker) + if err != nil { + t.Fatal(err) + } + _, err = fixture.bridge.Issue(context.Background(), signed, fixture.binder, fixture.now) + if !errors.Is(err, ErrAzureMAAToken) { + t.Fatalf("Issue() error = %v, want %v", err, ErrAzureMAAToken) + } +} + +func TestAzureSNPAttestationBridgeRejectsInvalidSignerOutput(t *testing.T) { + t.Parallel() + fixture := newAzureSNPBridgeFixture(t) + fixture.bridge.Signer = invalidAzureBridgeSigner{public: fixture.attesterPub} + token := fixture.signToken(t, fixture.claims) + _, err := fixture.bridge.Issue(context.Background(), token, fixture.binder, fixture.now) + if !errors.Is(err, ErrAzureSNPBridge) { + t.Fatalf("Issue() error = %v, want %v", err, ErrAzureSNPBridge) + } +} + +func newAzureSNPBridgeFixture(t *testing.T) *azureSNPBridgeFixture { + t.Helper() + now := time.Date(2026, time.August, 3, 12, 0, 0, 0, time.UTC) + maaPrivate, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + attesterPub, attesterPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + binder := testHash("azure-snp-exact-session-action") + tee := map[string]any{ + "x-ms-attestation-type": AzureSNPAttestationType, + "x-ms-sevsnpvm-launchmeasurement": testAzureSNPMetric, + "x-ms-sevsnpvm-guestsvn": float64(3), + "x-ms-sevsnpvm-is-debuggable": false, + "x-ms-sevsnpvm-migration-allowed": false, + } + claims := jwt.MapClaims{ + "iss": testMAAIssuer, + "jti": "maa-attestation-0001", + "iat": now.Add(-10 * time.Second).Unix(), + "nbf": now.Add(-10 * time.Second).Unix(), + "exp": now.Add(5 * time.Minute).Unix(), + "nonce": AzureSNPChallenge(binder), + "x-ms-ver": "1.0", + "x-ms-policy-hash": testMAAPolicyHash, + "x-ms-isolation-tee": tee, + } + return &azureSNPBridgeFixture{ + bridge: AzureSNPAttestationBridge{ + TokenVerifier: AzureMAATokenVerifier{ + Issuer: testMAAIssuer, + TrustedKeys: map[string]*rsa.PublicKey{testMAAKeyID: &maaPrivate.PublicKey}, + MaxAge: 2 * time.Minute, + ClockSkew: time.Second, + }, + VerifierKeyID: "asb-azure-bridge-ed25519-2026-01", + Signer: attesterPrivate, + PolicyID: "protected-change-azure-sevsnp/v1", + AllowedPolicyHashes: []string{testMAAPolicyHash}, + AllowedMeasurements: []string{testAzureSNPMetric}, + MinimumGuestSVN: 2, + ResultTTL: 30 * time.Second, + }, + maaPrivate: maaPrivate, + attesterPub: attesterPub, + now: now, + binder: binder, + claims: claims, + } +} + +func (f *azureSNPBridgeFixture) tee() map[string]any { + return f.claims["x-ms-isolation-tee"].(map[string]any) +} + +func (f *azureSNPBridgeFixture) signToken(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + token.Header["kid"] = testMAAKeyID + signed, err := token.SignedString(f.maaPrivate) + if err != nil { + t.Fatal(err) + } + return signed +} diff --git a/pkg/production/redis.go b/pkg/production/redis.go index cd1fcf88..d04646e5 100644 --- a/pkg/production/redis.go +++ b/pkg/production/redis.go @@ -21,6 +21,7 @@ import ( var ( ErrInvalidRedisConfig = errors.New("production: invalid redis replay configuration") ErrRedisProtocol = errors.New("production: redis replay protocol error") + ErrRedisReplication = errors.New("production: redis replay replication acknowledgement failed") ) // RedisSetNXStore implements identitypolicy.SetNXStore with Redis or Valkey @@ -34,6 +35,13 @@ type RedisSetNXStore struct { TLSConfig *tls.Config Dialer *net.Dialer OperationTimeout time.Duration + + // RequiredReplicaAcknowledgements enables a same-connection WAIT after a + // successful SET. Zero preserves SET NX PX-only behavior. WAIT reduces the + // acknowledged-write loss window, but Redis replication is not strongly + // consistent and this is not a zero-loss failover guarantee. + RequiredReplicaAcknowledgements int + ReplicationTimeout time.Duration } // SetNX atomically records a SHA-256-derived replay key until its TTL expires. @@ -106,6 +114,40 @@ func (s RedisSetNXStore) SetNX(ctx context.Context, key string, ttl time.Duratio } switch { case kind == '+' && value == "OK": + if s.RequiredReplicaAcknowledgements == 0 { + return true, nil + } + waitMillis := s.ReplicationTimeout.Milliseconds() + if waitMillis < 1 { + waitMillis = 1 + } + wait := []string{ + "WAIT", + strconv.Itoa(s.RequiredReplicaAcknowledgements), + strconv.FormatInt(waitMillis, 10), + } + if err := writeRESPArray(conn, wait); err != nil { + return false, fmt.Errorf("redis replay WAIT: %w", err) + } + kind, value, err = readRESP(reader) + if err != nil { + return false, fmt.Errorf("redis replay WAIT: %w", err) + } + if kind != ':' { + return false, fmt.Errorf("%w: unexpected WAIT response", ErrRedisProtocol) + } + acknowledged, err := strconv.Atoi(value) + if err != nil || acknowledged < 0 { + return false, fmt.Errorf("%w: invalid WAIT acknowledgement", ErrRedisProtocol) + } + if acknowledged < s.RequiredReplicaAcknowledgements { + return false, fmt.Errorf( + "%w: got %d, require %d", + ErrRedisReplication, + acknowledged, + s.RequiredReplicaAcknowledgements, + ) + } return true, nil case kind == '$' && value == "": return false, nil @@ -127,6 +169,16 @@ func (s RedisSetNXStore) validate() error { if s.TLSConfig.MinVersion < tls.VersionTLS13 { return ErrInvalidRedisConfig } + if s.RequiredReplicaAcknowledgements < 0 || s.ReplicationTimeout < 0 { + return ErrInvalidRedisConfig + } + if s.RequiredReplicaAcknowledgements == 0 && s.ReplicationTimeout != 0 { + return ErrInvalidRedisConfig + } + if s.RequiredReplicaAcknowledgements > 0 && + (s.ReplicationTimeout <= 0 || s.ReplicationTimeout >= s.OperationTimeout) { + return ErrInvalidRedisConfig + } return nil } @@ -161,6 +213,11 @@ func readRESP(r *bufio.Reader) (byte, string, error) { switch prefix { case '+': return prefix, line, nil + case ':': + if _, err := strconv.ParseInt(line, 10, 64); err != nil { + return 0, "", ErrRedisProtocol + } + return prefix, line, nil case '-': return 0, "", fmt.Errorf("%w: server error", ErrRedisProtocol) case '$': diff --git a/pkg/production/redis_test.go b/pkg/production/redis_test.go index 054d7bb5..54bf45b6 100644 --- a/pkg/production/redis_test.go +++ b/pkg/production/redis_test.go @@ -31,10 +31,12 @@ func TestRedisSetNXStoreCommitsOneWinnerOverTLS(t *testing.T) { t.Cleanup(stop) store := RedisSetNXStore{ - Address: address, - KeyPrefix: "asb:replay:v1:", - TLSConfig: clientTLS, - OperationTimeout: 5 * time.Second, + Address: address, + KeyPrefix: "asb:replay:v1:", + TLSConfig: clientTLS, + OperationTimeout: 5 * time.Second, + RequiredReplicaAcknowledgements: 1, + ReplicationTimeout: time.Second, } const workers = 20 @@ -65,6 +67,25 @@ func TestRedisSetNXStoreCommitsOneWinnerOverTLS(t *testing.T) { } } +func TestRedisSetNXStoreRejectsInsufficientReplication(t *testing.T) { + t.Parallel() + address, clientTLS, stop := startTestRedisTLSWithAcknowledgements(t, 0) + t.Cleanup(stop) + + store := RedisSetNXStore{ + Address: address, + KeyPrefix: "asb:replay:v1:", + TLSConfig: clientTLS, + OperationTimeout: 5 * time.Second, + RequiredReplicaAcknowledgements: 1, + ReplicationTimeout: time.Second, + } + ok, err := store.SetNX(context.Background(), "replication-required", time.Minute) + if ok || !errors.Is(err, ErrRedisReplication) { + t.Fatalf("SetNX() = (%v, %v), want (false, %v)", ok, err, ErrRedisReplication) + } +} + func TestRedisSetNXStoreRejectsUnsafeConfiguration(t *testing.T) { t.Parallel() if _, err := (RedisSetNXStore{}).SetNX(nil, "key", time.Minute); !errors.Is(err, ErrMissingContext) { @@ -75,6 +96,10 @@ func TestRedisSetNXStoreRejectsUnsafeConfiguration(t *testing.T) { {Address: "redis.test:6379", KeyPrefix: "asb:", OperationTimeout: time.Second, TLSConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS13}}, //nolint:gosec // verifies rejection {Address: "redis.test:6379", KeyPrefix: "asb:", OperationTimeout: time.Second, TLSConfig: &tls.Config{ServerName: "redis.test", MinVersion: tls.VersionTLS12}}, {Address: "redis.test:6379", KeyPrefix: "asb:", OperationTimeout: time.Second, Username: "user", TLSConfig: &tls.Config{ServerName: "redis.test", MinVersion: tls.VersionTLS13}}, + {Address: "redis.test:6379", KeyPrefix: "asb:", OperationTimeout: time.Second, TLSConfig: &tls.Config{ServerName: "redis.test", MinVersion: tls.VersionTLS13}, RequiredReplicaAcknowledgements: -1}, + {Address: "redis.test:6379", KeyPrefix: "asb:", OperationTimeout: time.Second, TLSConfig: &tls.Config{ServerName: "redis.test", MinVersion: tls.VersionTLS13}, ReplicationTimeout: time.Millisecond}, + {Address: "redis.test:6379", KeyPrefix: "asb:", OperationTimeout: time.Second, TLSConfig: &tls.Config{ServerName: "redis.test", MinVersion: tls.VersionTLS13}, RequiredReplicaAcknowledgements: 1}, + {Address: "redis.test:6379", KeyPrefix: "asb:", OperationTimeout: time.Second, TLSConfig: &tls.Config{ServerName: "redis.test", MinVersion: tls.VersionTLS13}, RequiredReplicaAcknowledgements: 1, ReplicationTimeout: time.Second}, } for i, store := range tests { if _, err := store.SetNX(context.Background(), "key", time.Minute); !errors.Is(err, ErrInvalidRedisConfig) { @@ -84,6 +109,11 @@ func TestRedisSetNXStoreRejectsUnsafeConfiguration(t *testing.T) { } func startTestRedisTLS(t *testing.T) (string, *tls.Config, func()) { + t.Helper() + return startTestRedisTLSWithAcknowledgements(t, 1) +} + +func startTestRedisTLSWithAcknowledgements(t *testing.T, acknowledgements int) (string, *tls.Config, func()) { t.Helper() certificate, roots := testRedisCertificate(t) listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ @@ -95,9 +125,10 @@ func startTestRedisTLS(t *testing.T) (string, *tls.Config, func()) { } server := &testRedisServer{ - listener: listener, - seen: make(map[string]struct{}), - done: make(chan struct{}), + listener: listener, + seen: make(map[string]struct{}), + done: make(chan struct{}), + acknowledgements: acknowledgements, } go server.serve() stop := func() { @@ -117,12 +148,13 @@ func startTestRedisTLS(t *testing.T) (string, *tls.Config, func()) { } type testRedisServer struct { - listener net.Listener - done chan struct{} - mu sync.Mutex - seen map[string]struct{} - err error - wg sync.WaitGroup + listener net.Listener + done chan struct{} + mu sync.Mutex + seen map[string]struct{} + err error + wg sync.WaitGroup + acknowledgements int } func (s *testRedisServer) serve() { @@ -149,7 +181,8 @@ func (s *testRedisServer) serve() { } func (s *testRedisServer) handle(conn net.Conn) error { - command, err := readTestRESPArray(bufio.NewReader(conn)) + reader := bufio.NewReader(conn) + command, err := readTestRESPArray(reader) if err != nil { return err } @@ -171,9 +204,23 @@ func (s *testRedisServer) handle(conn net.Conn) error { s.mu.Unlock() if exists { _, err = io.WriteString(conn, "$-1\r\n") - } else { - _, err = io.WriteString(conn, "+OK\r\n") + return err + } + if _, err = io.WriteString(conn, "+OK\r\n"); err != nil { + return err + } + + wait, err := readTestRESPArray(reader) + if err != nil { + return err + } + if len(wait) != 3 || wait[0] != "WAIT" || wait[1] != "1" { + return fmt.Errorf("unexpected replication command: %q", wait) + } + if _, err := strconv.ParseInt(wait[2], 10, 64); err != nil { + return fmt.Errorf("invalid WAIT timeout: %w", err) } + _, err = fmt.Fprintf(conn, ":%d\r\n", s.acknowledgements) return err }