diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index f181cf82..588b8c3a 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -114,13 +114,20 @@ jobs: GOTOOLCHAIN=go1.26.0+auto go test \ ./pkg/atls/... \ ./pkg/clients/... \ - ./pkg/agtp/... + ./pkg/agtp/... \ + ./pkg/production - 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/clients \ + ./pkg/production + + - name: Run protected-change consumer integration + run: | + GOTOOLCHAIN=go1.26.0+auto go test -v -race -count=1 \ + ./examples/protected-change-consumer - name: Run v1 and draft-06 v2 A2A negative integration run: | diff --git a/.github/workflows/security-red-team.yaml b/.github/workflows/security-red-team.yaml index f96a61a7..24f30646 100644 --- a/.github/workflows/security-red-team.yaml +++ b/.github/workflows/security-red-team.yaml @@ -14,8 +14,12 @@ on: - "pkg/agtp/**" - "pkg/atls/**" - "pkg/clients/**" + - "pkg/production/**" - "examples/a2a-multiprocess/**" + - "examples/protected-change-consumer/**" - "docs/draft06-a2a-profile.md" + - "docs/API_COMPATIBILITY.md" + - "docs/production-deployment-profile.md" - "docs/security-red-team-tests.md" - "docs/live-red-team-report.md" push: @@ -30,8 +34,12 @@ on: - "pkg/agtp/**" - "pkg/atls/**" - "pkg/clients/**" + - "pkg/production/**" - "examples/a2a-multiprocess/**" + - "examples/protected-change-consumer/**" - "docs/draft06-a2a-profile.md" + - "docs/API_COMPATIBILITY.md" + - "docs/production-deployment-profile.md" - "docs/security-red-team-tests.md" - "docs/live-red-team-report.md" @@ -59,4 +67,6 @@ jobs: ./pkg/atls/identitypolicy \ ./pkg/atls/sbaipv2 \ ./pkg/clients \ + ./pkg/production \ + ./examples/protected-change-consumer \ ./examples/a2a-multiprocess diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..d5ff05cc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,17 @@ +# Changelog + +## v1.0.0 + +- Add the supported Direct-Agent v1 production composition. +- Add role-separated trust and revocation snapshots that fail closed on source + errors. +- Add signed attestation-result appraisal with exact binder, policy, + measurement, freshness, and verifier-key checks. +- Add a TLS-only Redis/Valkey SETNX replay adapter with bounded operations. +- Add the protected-change HTTPS consumer and positive/negative E2E tests. +- Define the supported API and compatibility policy. + +The v1.0.0 release covers the verifier-side Direct-Agent v1 core and the +documented protected-change deployment profile. It does not make the +experimental draft-06 v2, gateway runtime, inherited Cocos runtime, or +hardware evidence acquisition part of the supported product API. diff --git a/Makefile b/Makefile index 140bb4e5..df8f1a6c 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ BUILD_DIR = build SERVICES = manager agent cli attestation-service log-forwarder computation-runner egress-proxy ingress-proxy -DIRECT_AGENT_CORE_PKGS = ./pkg/atls/... ./pkg/clients/... ./pkg/agtp/... +DIRECT_AGENT_CORE_PKGS = ./pkg/atls/... ./pkg/clients/... ./pkg/agtp/... ./pkg/production +PRODUCTION_CONSUMER_PKGS = ./examples/protected-change-consumer CGO_ENABLED ?= 0 GOARCH ?= amd64 VERSION ?= $(shell git describe --abbrev=0 --tags --always) @@ -70,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 + GOTOOLCHAIN=go1.26.0+auto go test -v -race -count=1 ./pkg/atls/identitypolicy ./pkg/clients ./pkg/production $(PRODUCTION_CONSUMER_PKGS) $(MAKE) fuzz-smoke $(GOVULNCHECK) ./... diff --git a/PUBLICATION_TODO.md b/PUBLICATION_TODO.md index 3571beb1..addd4210 100644 --- a/PUBLICATION_TODO.md +++ b/PUBLICATION_TODO.md @@ -88,6 +88,30 @@ Do not describe this repository as implementing every binding profile from the draft. Product claims should say that it implements an experimental Direct-Agent binding profile based on the core acceptance rules. +## Supported v1 Product Surface + +Release `v1.0.0` fixes the supported surface and compatibility rules in +`docs/API_COMPATIBILITY.md`. The supported product API is the Direct-Agent v1 +subset of `pkg/clients` and `pkg/atls/identitypolicy`, composed through +`pkg/production`. Draft-06-inspired v2, `pkg/agtp`, gateway runtime, inherited +Cocos runtime services, and hardware evidence acquisition remain experimental +or outside the supported API. + +The concrete `protected-change-v1` deployment profile is recorded in +`docs/production-deployment-profile.md`. It includes: + +- separate Manager, Agent, and attestation-verifier Ed25519 key roles; +- fresh trust and revocation snapshots with fail-closed source errors; +- a signed attestation result bound to the accepted TLS session, exact action, + verifier nonce, appraisal policy, and measurement; +- a TLS 1.3 Redis/Valkey `SET NX PX` replay adapter with bounded operations; +- a concrete non-Split-Knowledge HTTPS consumer; and +- positive and negative unit, replay-race, and E2E integration tests. + +The profile authenticates signed appraisal results. Hardware-specific evidence +acquisition and appraisal remain deployment responsibilities and are not +implied by the default GitHub-hosted test environment. + ## Inherited Runtime Risk Classification These items come from inherited Cocos runtime code paths. Profile text does not diff --git a/README.md b/README.md index d1670248..de05b261 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,13 @@ The primary failure class is context diversion: accepting cryptographically valid material for a different service, tenant, Agent, task, delegation, or authority boundary than the verifier intended. +Release `v1.0.0` defines a supported verifier product surface for the +Direct-Agent v1 profile. It adds one complete deployment composition covering +role-separated trust keys, revocation, signed attestation results, TLS-protected +distributed replay, and a concrete protected-change consumer. See +[`docs/API_COMPATIBILITY.md`](docs/API_COMPATIBILITY.md) and +[`docs/production-deployment-profile.md`](docs/production-deployment-profile.md). + ## Acceptance Contract The verifier evaluates one ordered contract: @@ -38,10 +45,17 @@ CWT/COSE, and gateway-route policy experiments. v2 profile for the multiprocess A2A demonstration. - `docs/live-red-team-report.md`: current live-style red-team evidence and evaluation boundaries. +- `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. - `formal/`: ProVerif and TLA+ models, recorded results, and model-to-implementation traceability. - `pkg/clients`, `pkg/atls`, and `pkg/atls/identitypolicy`: Direct-Agent acceptance implementation. +- `pkg/production`: supported fail-closed composition and Redis/Valkey replay + adapter. +- `examples/protected-change-consumer`: independent HTTPS application consumer + and E2E negative tests; it is not Split-Knowledge. - `PUBLICATION_TODO.md`: publication blockers, inherited runtime risk classification, module identity choice, and CI/red-team checkpoint status. @@ -87,7 +101,7 @@ acceptance path. ## Evaluation Evidence -The current v0.4 evidence covers: +The release evidence covers: - focused local checks and unit-level coverage; - positive and negative profile vectors; @@ -99,6 +113,12 @@ The current v0.4 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; +- 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. For accepted TLS sessions, the AGTP observed-identity path derives `tls_exporter_sha256` from the accepted `tls.ConnectionState`. Fixed exporter @@ -205,6 +225,14 @@ Product security gate: make product-security-gate ``` +Focused production profile and consumer integration: + +```sh +go test -race -count=1 \ + ./pkg/production \ + ./examples/protected-change-consumer +``` + ## Security Reporting Report suspected vulnerabilities through GitHub private vulnerability reporting @@ -240,16 +268,17 @@ upstream notices. See `ATTRIBUTION.md`. - Gateway-routed runtime wiring is outside the current Direct-Agent implementation. Wallets can provide presentation or signing functions, but are not trust roots or sources of verifier-local expected policy. -- The v0.4 evaluation is evidence for the tested fail-closed verifier behavior, +- The release evaluation is evidence for the tested fail-closed verifier behavior, not a formal proof or validation of every deployment. Broader application 0-RTT behavior, production gRPC pooling, runtime gateway wiring, longer fuzz/property campaigns, and hardware-backed confidential-VM attestation replay remain outside the recorded evaluation. - The ProVerif model uses symbolic cryptography and does not prove TLS, X.509, JWT parsing, certificate handling, or equivalence with compiled Go code. The - TLA+ result is bounded evidence for a generic target state machine; the - current Go tree does not implement its complete durable snapshot, revocation, - lease, audit-outbox, or logical-time contract. + TLA+ result is bounded evidence for a generic target state machine. The + production profile implements trust/revocation snapshots, signed attestation + results, and shared replay commits, but not the model's complete lease, + audit-outbox, application outcome, or logical-time contract. - `pkg/atls` and `pkg/agtp` are legacy compatibility names and do not define the protocol trust model. Cocos is implementation provenance rather than the normative scope of the profile. diff --git a/docs/API_COMPATIBILITY.md b/docs/API_COMPATIBILITY.md new file mode 100644 index 00000000..b22055d8 --- /dev/null +++ b/docs/API_COMPATIBILITY.md @@ -0,0 +1,66 @@ +# Supported API and compatibility policy + +This policy applies beginning with release `v1.0.0`. + +## Supported surface + +The supported Go API is: + +- `pkg/production`: production composition, signed attestation-result policy, + accepted TLS binding derivation, and TLS-protected Redis/Valkey SETNX replay + adapter; +- `pkg/clients`: Direct-Agent v1 JWT verification through + `JWTVerifyOptions`, `VerifyIdentityGrantJWT`, `VerifySessionBindingJWT`, and + `VerifySessionIdentityJWT`; and +- `pkg/atls/identitypolicy`: Direct-Agent v1 policy, binding, and replay + interfaces used by the supported production composition. + +The supported wire profile is Direct-Agent profile version `1` with +`sbaip.identity-grant` and `sbaip.session-binding` token types. The fixed +production exporter label is `Attestation`; application context and a +verifier-issued nonce are combined by `production.BindingFromTLS`. + +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 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. + +An exported Go identifier outside the supported list is not implicitly stable. + +## Compatibility rules + +Tags follow semantic versioning. + +- Patch releases preserve the supported source API and v1 wire shape. A patch + may reject input that was previously accepted when the input is invalid, + ambiguous, insecure, or outside the documented profile. +- Minor releases may add optional APIs or fields. Existing supported calls and + valid v1 messages continue to work without source changes. +- Breaking supported API or wire changes require a new major version and a new + versioned verification entrypoint. They are not introduced by silently + changing v1 parsing or binding behavior. +- Stable APIs deprecated during v1 remain available through the v1 major line + unless retaining them creates a concrete security vulnerability. Any + security exception is documented in the release notes and security advisory. +- The supported build baseline for v1.0 is Go 1.26.x. A later toolchain floor + is announced in release notes before it becomes the default-branch minimum. + +Only the latest v1 minor release receives routine fixes. The immediately prior +minor receives critical security fixes for 90 days after the newer minor is +released. Release artifacts and their source commit remain available after the +support window. + +## Deployment compatibility + +Production deployments must keep Manager, Agent, and attestation-verifier key +roles separate. Key rotation is compatible when old and new key IDs overlap in +the locally accepted trust snapshot for the intended migration window. +Disabling a key ID or revoking a token ID intentionally causes requests that +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. diff --git a/docs/SSOT.md b/docs/SSOT.md index 18642e7d..3c6a1922 100644 --- a/docs/SSOT.md +++ b/docs/SSOT.md @@ -1271,10 +1271,16 @@ The implemented production profile covers: - local QUIC/TLS early-data authentication gating coverage for pre-binding rejection. -Deployment still chooses trusted keys, expected policy values, revocation data, -and distributed replay storage. Those sources can be Manager configuration, -Agent metadata, computation state, an authorization policy engine, or a -fail-closed registry integration. They must not be raw peer-controlled metadata. +`pkg/production` composes the Direct-Agent v1 checks with role-separated trust +and revocation snapshots, signed attestation-result appraisal, and a +TLS-protected Redis/Valkey SETNX adapter. The concrete supported choices and +consumer boundary are fixed in `docs/production-deployment-profile.md`. + +Each deployment still supplies its own trusted keys, expected policy values, +revocation data, Redis/Valkey service, and attestation verifier output. Those +sources can be Manager configuration, Agent metadata, computation state, an +authorization policy engine, or a fail-closed registry integration. They must +not be raw peer-controlled metadata. ## 26. Evaluation boundary diff --git a/docs/live-red-team-report.md b/docs/live-red-team-report.md index 53242f03..dc8fbe40 100644 --- a/docs/live-red-team-report.md +++ b/docs/live-red-team-report.md @@ -11,12 +11,15 @@ session-bound agent identity profile. The normative profile source is ## Status -Current mainline coverage is synchronized with `docs/SSOT.md` draft v0.4 -as of 2026-06-21. The profile now includes direct-Agent JWT/JWS and CWT/COSE +The v1.0 production extension retains the Direct-Agent v1 behavior in +`docs/SSOT.md` and adds the fixed deployment composition in +`docs/production-deployment-profile.md`. Coverage includes Direct-Agent JWT/JWS and CWT/COSE verification, dependency-free live-style relay, HTTP/2 reuse, QUIC/TLS early-data, route-assertion HTTP harnesses, deterministic malformed-token corpus tests, a deterministic JWT acceptance invariant matrix, and local -Gateway Route Assertion validation. +Gateway Route Assertion validation. It now also includes a concrete HTTPS +consumer, signed attestation-result appraisal, current trust/revocation +snapshots, and a TLS Redis/Valkey SETNX adapter race harness. Earlier branch-scoped implementation and verification completed on 2026-06-13. Additional local regressions were run on 2026-06-16 for @@ -36,7 +39,7 @@ across a request boundary. Runtime client/server gateway wiring remains separate work. The current evaluation is not a formal proof and is not a broad deployment -security claim. It is v0.4 evidence for checked fail-closed verifier behavior, +security claim. It is release evidence for checked fail-closed verifier behavior, built from focused local checks, negative vectors, unit-level tests, dependency-free live-style harnesses, and deterministic invariant checks. Claims such as "this grant is accepted only for this session" still need real 0-RTT @@ -109,6 +112,7 @@ All checks passed. `docs/SSOT.pdf` rendered as a 24-page PDF. | HTTP/2 or gRPC connection reuse | The HTTP/2 and gRPC harnesses verify connection reuse, accepted same-context bindings, and rejected cross-context bindings. | Add deployment-specific gRPC pooling coverage when a product API is fixed. | | TLS resumption and 0-RTT | `TestVerifySessionIdentityJWTLiveRedTeamRejectsTLSResumptionReplayAndPreBinding` establishes an initial TLS 1.3 session and a resumed TLS 1.3 session, derives exporter hashes from each, accepts fresh per-session binding material, rejects the initial Session Binding Statement on the resumed session, and rejects a pre-binding statement without `tls_exporter_sha256`. `TestVerifySessionIdentityJWTLiveRedTeamRejectsQUICEarlyDataAuthentication` exercises Go's QUIC/TLS early-data secrets, rejects authentication before `tls_exporter_sha256` is available, and accepts only after the finished-handshake exporter is bound. | End-to-end application 0-RTT payload behavior remains future work if a QUIC application profile is introduced. | | Distributed replay race | Local goroutine race and local multi-process SETNX-style service are covered. | LRTT03b: repeat against real multi-node Redis or Valkey, including failover and timeout behavior. | +| Production consumer action binding | `examples/protected-change-consumer` uses mTLS 1.3 and the supported production verifier to accept the exact change and reject changed-action, wrong-session, replay, revoked-grant, attestation-mismatch, and replay-store-outage cases. | The reference store is in-memory; production outcome durability and reconciliation remain consumer responsibilities. | | Gateway route confusion | SSOT defines gateway route-assertion requirements, `docs/gateway-routed-profile.md` fixes the Gateway Route Assertion claim map and holder-of-key proof, `pkg/agtp/gatewayroute` rejects route, tenant, policy, task, target-Agent, nonce, audit-hash, replay, and missing-proof confusion, and `pkg/agtp` verifies JWT/JWS and CWT/COSE route-assertion wire tokens. `TestVerifyGatewayRouteJWTLiveRedTeamNetworkHarness` exercises route assertion verification across an HTTP request boundary and rejects route diversion and replay. | Add runtime gateway client/server wiring if gateway-routed mode becomes a product surface. | | JWT/JWS parser robustness | Deterministic negative tests cover supported claim and signature paths. `TestVerifySessionIdentityJWTRedTeamRejectsMalformedCorpus` rejects malformed compact JWS, duplicate protected-header or payload JSON members, and unsafe control-character claims. `FuzzVerifySessionIdentityJWTRejectsMalformedCompactTokens` provides bounded fuzz smoke for malformed compact JWT/JWS inputs and passed a 60-second local fuzz run with 1,252,457 executions on 2026-06-30. | Add longer corpus jobs for Unicode, duplicate JSON keys, malformed base64url, malformed protected headers, and malformed JWS structure if broad parser-hardening evidence is needed. | | Grant, binding, session, and expected-policy invariants | `TestVerifySessionIdentityJWTInvariantMatrix` enumerates grant hash, request context, TLS exporter, attestation binder, audience, role-separated context, task, replay, and local-policy invariants through the same JWT acceptance gate. | Add long-running property or fuzz generation if the project wants randomized invariant exploration. | @@ -157,6 +161,9 @@ All checks passed. `docs/SSOT.pdf` rendered as a 24-page PDF. | `TestVerifyGatewayRouteCWTRedTeamRejectsAttacks` | CWT/COSE Gateway Route Assertion adapter rejects route diversion, grant-hash substitution, gateway-session substitution, holder-proof hash substitution, unprotected COSE `kid`, and replay. | Passed locally | | `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 | +| `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 @@ -227,8 +234,10 @@ boundaries that need separate work if the project chooses to support them. here yet. - CWT/COSE verification exists in `pkg/agtp`, but client configuration is still wired for the JWT/JWS runtime path. -- Replay race coverage uses a local HTTP SETNX-style service, not a real - multi-node Redis or Valkey deployment. +- 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. - 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 new file mode 100644 index 00000000..afe561fd --- /dev/null +++ b/docs/production-deployment-profile.md @@ -0,0 +1,149 @@ +# Production deployment profile: protected-change-v1 + +Status: supported beginning with `v1.0.0`. + +This is one concrete Direct-Agent v1 deployment profile. Its reference +consumer is a tenant-configuration change service, not Split-Knowledge. The +service applies a change only when the grant, Agent proof, accepted TLS 1.3 +session, exact change, fresh signed attestation result, verifier nonce, local +policy, and shared replay state all agree. + +## Fixed choices + +| Item | protected-change-v1 choice | +| --- | --- | +| Transport | mutually authenticated TLS 1.3; no 0-RTT acceptance | +| Grant signing | Ed25519 / JWT `EdDSA`; locally configured Manager key IDs | +| Session proof signing | Ed25519 / JWT `EdDSA`; Agent key named by grant `cnf.kid` | +| Audience | exact protected-change endpoint configured by the verifier | +| Token profile | Direct-Agent v1: `sbaip.identity-grant` and `sbaip.session-binding` | +| Exporter label | fixed `Attestation` label | +| 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 | +| Outcome | consumer-owned durable, idempotent store keyed by `change_id` | + +Manager, Agent, and attestation-verifier keys are separate trust domains. A key +ID is never accepted in another role merely because its signature verifies. + +## Exact protected action + +The consumer accepts this request shape and rejects unknown JSON members: + +```json +{ + "change_id": "change-0001", + "tenant": "tenant-01", + "setting": "feature-x", + "enabled": true +} +``` + +The bound canonical action is the JSON encoding of these ordered fields: + +```json +{ + "profile": "asb.protected-change/v1", + "method": "POST", + "resource": "config://tenant-01/feature-x", + "change_id": "change-0001", + "enabled": true +} +``` + +Changing the Boolean value, tenant, setting, change identifier, method, or +resource changes the accepted context and causes the session proof to fail. + +## Trust and revocation + +`production.Profile` loads two snapshots for each request: + +1. the Manager snapshot verifies the grant issuer, audience, algorithm, `kid`, + signature, lifetime, and grant `jti` revocation state; and +2. the Agent snapshot verifies the session proof issuer, audience, algorithm, + `kid`, signature, lifetime, and proof `jti` revocation state. + +A trust-source error, unknown key, disabled key, revoked token, missing key, or +role collision rejects the action. A deployment may implement `TrustSource` +with an atomically replaced local snapshot or a remote registry, but it must +return an error rather than silently using unbounded stale data. + +## Attestation policy + +The profile requires a `production.AttestationResult`. The signed payload +contains: + +- result version and unique result ID; +- attestation-verifier key ID; +- exact appraisal policy ID and accepted measurement; +- the binder derived from the authenticated peer key, current TLS exporter, + canonical action, and verifier nonce; and +- explicit issue and expiry times. + +`production.SignedAttestationPolicy` verifies the Ed25519 signature, trusted +and enabled verifier key, policy ID, measurement allowlist, binder, maximum age, +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. + +## Distributed replay + +Configure the shared replay cache with a bounded, certificate-verified TLS +connection: + +```go +redisStore := production.RedisSetNXStore{ + Address: "replay.internal.example:6379", + KeyPrefix: "asb:protected-change:v1:", + TLSConfig: redisTLSConfig, + OperationTimeout: 2 * time.Second, +} +replay := identitypolicy.NewSetNXReplayCache(ctx, redisStore) +``` + +`redisTLSConfig` must use TLS 1.3 or newer, verify the server name and trust +chain, and must not set `InsecureSkipVerify`. Redis ACL credentials or a client +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. + +## Consumer transaction + +The acceptance order is: + +1. strictly parse and canonicalize the protected action; +2. derive the expected binding from the accepted mTLS session, peer + certificate, action, and verifier nonce; +3. load current role-specific trust and revocation snapshots; +4. authenticate grant and session proof and enforce confirmation-key binding; +5. compare D3-D6 application policy and every expected binding field; +6. authenticate and appraise the signed attestation result; +7. atomically commit shared replay state; and +8. pass the minimal accepted identity projection to the consumer's idempotent + outcome store. + +The ASB replay commit is an identity-acceptance boundary, not an application +database transaction. A production consumer must use an idempotent durable +outcome record and reconcile failures that occur after identity acceptance. + +## Verification + +Run the production unit, distributed replay, and concrete consumer tests: + +```sh +go test -race -count=1 ./pkg/production ./examples/protected-change-consumer +``` + +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. + +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. diff --git a/examples/protected-change-consumer/README.md b/examples/protected-change-consumer/README.md new file mode 100644 index 00000000..41d7a92d --- /dev/null +++ b/examples/protected-change-consumer/README.md @@ -0,0 +1,22 @@ +# Protected Change consumer + +This reference application is intentionally unrelated to Split-Knowledge. It +accepts one tenant-configuration change over mutually authenticated TLS 1.3 and +applies it only after the `pkg/production` verifier accepts: + +- separate Manager and Agent trust keys plus current revocation state; +- the exact action context and accepted TLS exporter binding; +- a signed, fresh, policy-approved attestation result; +- a verifier-issued one-shot nonce; and +- the shared replay-store commit. + +The integration test uses a real HTTPS request boundary and covers the positive +path plus changed-action, wrong-session, replay, revocation, and attestation +failures: + +```sh +go test -race -count=1 ./examples/protected-change-consumer +``` + +`MemoryChangeStore` is only the consumer's test/reference outcome store. A +deployment should replace it with its own durable idempotent application store. diff --git a/examples/protected-change-consumer/app.go b/examples/protected-change-consumer/app.go new file mode 100644 index 00000000..f9d33e64 --- /dev/null +++ b/examples/protected-change-consumer/app.go @@ -0,0 +1,289 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +// Package protectedchange is an HTTP consumer of the ASB production verifier. +// It applies a tenant configuration change only after the exact request is +// bound to the accepted TLS session and every production gate succeeds. +package protectedchange + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "strings" + "sync" + + "github.com/thinksyncs/agents-secure-binding/pkg/atls/identitypolicy" + "github.com/thinksyncs/agents-secure-binding/pkg/production" +) + +const ( + IdentityGrantHeader = "ASB-Identity-Grant" + SessionBindingHeader = "ASB-Session-Binding" + AttestationHeader = "ASB-Attestation-Result" + ChangePath = "/v1/changes" + maxRequestBytes = 32 << 10 +) + +var ( + ErrInvalidChange = errors.New("protected change: invalid request") + ErrMissingTLSIdentity = errors.New("protected change: missing authenticated TLS peer") + ErrMissingNonce = errors.New("protected change: missing verifier nonce") + ErrChangeConflict = errors.New("protected change: change identifier conflict") +) + +// ChangeRequest is the protected application action. +type ChangeRequest struct { + ChangeID string `json:"change_id"` + Tenant string `json:"tenant"` + Setting string `json:"setting"` + Enabled bool `json:"enabled"` +} + +type canonicalChange struct { + Profile string `json:"profile"` + Method string `json:"method"` + Resource string `json:"resource"` + ChangeID string `json:"change_id"` + Enabled bool `json:"enabled"` +} + +// NonceSource returns the verifier-issued nonce for the exact change. +type NonceSource interface { + ExpectedNonce(context.Context, ChangeRequest) (string, error) +} + +// ChangeStore durably applies an accepted change. Implementations must be +// idempotent for an identical ChangeID and reject conflicting reuse. +type ChangeStore interface { + Apply(context.Context, ChangeRequest, production.AcceptedIdentity) error +} + +// Application is the protected-change HTTP consumer. +type Application struct { + Profile production.Profile + Nonces NonceSource + Store ChangeStore + ExpectedAgent string + AuditFailure func(context.Context, error) +} + +// ServeHTTP accepts only the protected change endpoint. +func (a Application) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != ChangePath { + http.NotFound(w, r) + return + } + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + change, err := decodeChange(r.Body) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { + http.Error(w, "authentication failed", http.StatusUnauthorized) + return + } + if a.Nonces == nil || a.Store == nil { + http.Error(w, "service unavailable", http.StatusServiceUnavailable) + return + } + nonce, err := a.Nonces.ExpectedNonce(r.Context(), change) + if err != nil || strings.TrimSpace(nonce) == "" { + http.Error(w, "authentication failed", http.StatusUnauthorized) + return + } + actionContext, err := CanonicalActionContext(change) + if err != nil { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + expectedBinding, err := production.BindingFromTLS(r.TLS, r.TLS.PeerCertificates[0], actionContext, nonce) + if err != nil { + http.Error(w, "authentication failed", http.StatusUnauthorized) + return + } + attestation, err := decodeAttestation(r.Header.Get(AttestationHeader)) + if err != nil { + http.Error(w, "authentication failed", http.StatusUnauthorized) + return + } + + profile := a.Profile + if len(r.Header.Get(IdentityGrantHeader)) > maxRequestBytes || len(r.Header.Get(SessionBindingHeader)) > maxRequestBytes { + http.Error(w, "authentication failed", http.StatusUnauthorized) + return + } + if !usesProtectedChangeSigningProfile(profile) { + http.Error(w, "service unavailable", http.StatusServiceUnavailable) + return + } + profile.IdentityPolicy = ExpectedPolicy(change, a.ExpectedAgent) + accepted, err := profile.Verify(r.Context(), production.VerifyRequest{ + GrantJWT: r.Header.Get(IdentityGrantHeader), + SessionBindingJWT: r.Header.Get(SessionBindingHeader), + ExpectedBinding: expectedBinding, + Attestation: attestation, + }) + if err != nil { + if a.AuditFailure != nil { + a.AuditFailure(r.Context(), err) + } + http.Error(w, "authentication failed", http.StatusUnauthorized) + return + } + if err := a.Store.Apply(r.Context(), change, accepted); err != nil { + http.Error(w, "change not applied", http.StatusConflict) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// CanonicalActionContext returns the exact bytes bound into the TLS exporter +// and request-context digest for one protected change. +func CanonicalActionContext(change ChangeRequest) ([]byte, error) { + if err := validateChange(change); err != nil { + return nil, err + } + return json.Marshal(canonicalChange{ + Profile: "asb.protected-change/v1", + Method: http.MethodPost, + Resource: changeResource(change), + ChangeID: change.ChangeID, + Enabled: change.Enabled, + }) +} + +// ExpectedPolicy constructs verifier-local D3-D6 policy for one action. +func ExpectedPolicy(change ChangeRequest, agent string) identitypolicy.Policy { + return identitypolicy.Policy{ + Mode: identitypolicy.ModeRequired, + SetMode: identitypolicy.SetModeExact, + Require: identitypolicy.Requirements{L3: true, L4: true, L5: true, L6: true}, + Expected: identitypolicy.Values{ + Service: "protected-change", + Agent: agent, + TaskID: change.ChangeID, + IntentRef: "change:intent:apply", + CapabilityRef: "change:capability:write", + Scopes: []string{"change.write"}, + Resources: []string{changeResource(change)}, + AuthorizationDetails: []string{"change:set-enabled"}, + }, + } +} + +// MemoryChangeStore is a concurrency-safe idempotent store used by the +// reference consumer and its integration tests. +type MemoryChangeStore struct { + mu sync.Mutex + changes map[string]ChangeRequest +} + +// NewMemoryChangeStore returns an empty change store. +func NewMemoryChangeStore() *MemoryChangeStore { + return &MemoryChangeStore{changes: make(map[string]ChangeRequest)} +} + +// Apply records an accepted change exactly once. +func (s *MemoryChangeStore) Apply(_ context.Context, change ChangeRequest, _ production.AcceptedIdentity) error { + if s == nil { + return ErrChangeConflict + } + s.mu.Lock() + defer s.mu.Unlock() + if existing, ok := s.changes[change.ChangeID]; ok { + if existing == change { + return nil + } + return ErrChangeConflict + } + s.changes[change.ChangeID] = change + return nil +} + +// Lookup returns an applied change. +func (s *MemoryChangeStore) Lookup(changeID string) (ChangeRequest, bool) { + s.mu.Lock() + defer s.mu.Unlock() + change, ok := s.changes[changeID] + return change, ok +} + +func decodeChange(body io.ReadCloser) (ChangeRequest, error) { + defer body.Close() + raw, err := io.ReadAll(io.LimitReader(body, maxRequestBytes+1)) + if err != nil || len(raw) > maxRequestBytes { + return ChangeRequest{}, ErrInvalidChange + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + var change ChangeRequest + if err := decoder.Decode(&change); err != nil { + return ChangeRequest{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return ChangeRequest{}, ErrInvalidChange + } + if err := validateChange(change); err != nil { + return ChangeRequest{}, err + } + return change, nil +} + +func decodeAttestation(value string) (production.AttestationResult, error) { + if len(value) == 0 || len(value) > base64.RawURLEncoding.EncodedLen(maxRequestBytes) { + return production.AttestationResult{}, production.ErrInvalidAttestationResult + } + raw, err := base64.RawURLEncoding.DecodeString(value) + if err != nil || len(raw) == 0 || len(raw) > maxRequestBytes { + return production.AttestationResult{}, production.ErrInvalidAttestationResult + } + decoder := json.NewDecoder(strings.NewReader(string(raw))) + decoder.DisallowUnknownFields() + var result production.AttestationResult + if err := decoder.Decode(&result); err != nil { + return production.AttestationResult{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return production.AttestationResult{}, production.ErrInvalidAttestationResult + } + return result, nil +} + +func validateChange(change ChangeRequest) error { + for _, value := range []string{change.ChangeID, change.Tenant, change.Setting} { + if value == "" || value != strings.TrimSpace(value) || len(value) > 128 { + return ErrInvalidChange + } + for _, r := range value { + if !isIdentifierRune(r) { + return ErrInvalidChange + } + } + } + return nil +} + +func isIdentifierRune(r rune) bool { + return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '.' || r == '_' || r == '-' +} + +func usesProtectedChangeSigningProfile(profile production.Profile) bool { + return len(profile.GrantAuthority.ValidMethods) == 1 && profile.GrantAuthority.ValidMethods[0] == "EdDSA" && + len(profile.BindingAuthority.ValidMethods) == 1 && profile.BindingAuthority.ValidMethods[0] == "EdDSA" +} + +func changeResource(change ChangeRequest) string { + return fmt.Sprintf("config://%s/%s", change.Tenant, change.Setting) +} diff --git a/examples/protected-change-consumer/e2e_test.go b/examples/protected-change-consumer/e2e_test.go new file mode 100644 index 00000000..a6e65b5b --- /dev/null +++ b/examples/protected-change-consumer/e2e_test.go @@ -0,0 +1,528 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +package protectedchange + +import ( + "bufio" + "bytes" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "io" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/thinksyncs/agents-secure-binding/pkg/atls/identitypolicy" + "github.com/thinksyncs/agents-secure-binding/pkg/clients" + "github.com/thinksyncs/agents-secure-binding/pkg/production" +) + +const ( + e2eAudience = "https://protected-change.example.test/v1/changes" + e2eManagerIssuer = "https://manager.example.test" + e2eAgentIssuer = "https://agent.example.test" + e2eManagerKeyID = "manager-ed25519-2026-01" + e2eAgentKeyID = "agent-ed25519-2026-01" + e2eAttesterKeyID = "attester-ed25519-2026-01" + e2ePolicyID = "protected-change-attestation/v1" + e2eMeasurement = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + e2eExpectedAgent = "change-agent-01" + e2eExpectedNonce = "verifier-nonce-change-0001" +) + +type e2eFixture struct { + server *httptest.Server + clientTLS *tls.Config + clientLeaf *x509.Certificate + managerPrivate ed25519.PrivateKey + agentPrivate ed25519.PrivateKey + attesterPrivate ed25519.PrivateKey + managerTrust *mutableTrustSource + replayStore *sharedSetNXStore + store *MemoryChangeStore + audit *e2eAudit + now time.Time +} + +type e2eSession struct { + conn *tls.Conn + reader *bufio.Reader +} + +type e2eAudit struct { + mu sync.Mutex + err error +} + +func (a *e2eAudit) record(_ context.Context, err error) { + a.mu.Lock() + defer a.mu.Unlock() + a.err = err +} + +func (a *e2eAudit) last() error { + a.mu.Lock() + defer a.mu.Unlock() + return a.err +} + +type mutableTrustSource struct { + mu sync.Mutex + snapshot production.TrustSnapshot + err error +} + +func (s *mutableTrustSource) Snapshot(context.Context) (production.TrustSnapshot, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return production.TrustSnapshot{}, s.err + } + return s.snapshot, nil +} + +func (s *mutableTrustSource) revoke(tokenID string) { + s.mu.Lock() + defer s.mu.Unlock() + s.snapshot.RevokedTokenIDs = append(s.snapshot.RevokedTokenIDs, tokenID) +} + +type sharedSetNXStore struct { + mu sync.Mutex + seen map[string]struct{} + err error +} + +func (s *sharedSetNXStore) SetNX(_ context.Context, key string, _ time.Duration) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.err != nil { + return false, s.err + } + if _, ok := s.seen[key]; ok { + return false, nil + } + s.seen[key] = struct{}{} + return true, nil +} + +type fixedNonceSource map[string]string + +func (s fixedNonceSource) ExpectedNonce(_ context.Context, change ChangeRequest) (string, error) { + nonce, ok := s[change.ChangeID] + if !ok { + return "", ErrMissingNonce + } + return nonce, nil +} + +func TestProtectedChangeE2EAcceptsExactBoundAction(t *testing.T) { + t.Parallel() + fixture := newE2EFixture(t) + session := fixture.dial(t) + defer session.conn.Close() + change := e2eChange(true) + headers := fixture.headers(t, session, change, nil) + + if status := sendChange(t, session, change, headers); status != http.StatusNoContent { + t.Fatalf("status = %d, want %d, audit = %v", status, http.StatusNoContent, fixture.audit.last()) + } + stored, ok := fixture.store.Lookup(change.ChangeID) + if !ok || stored != change { + t.Fatalf("stored change = %+v, %v", stored, ok) + } +} + +func TestProtectedChangeE2ERejectsChangedAction(t *testing.T) { + t.Parallel() + fixture := newE2EFixture(t) + session := fixture.dial(t) + defer session.conn.Close() + signed := e2eChange(true) + sent := e2eChange(false) + headers := fixture.headers(t, session, signed, nil) + + if status := sendChange(t, session, sent, headers); status != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", status, http.StatusUnauthorized) + } + if _, ok := fixture.store.Lookup(sent.ChangeID); ok { + t.Fatal("changed action was applied") + } +} + +func TestProtectedChangeE2ERejectsWrongTLSSession(t *testing.T) { + t.Parallel() + fixture := newE2EFixture(t) + boundSession := fixture.dial(t) + headers := fixture.headers(t, boundSession, e2eChange(true), nil) + _ = boundSession.conn.Close() + + sentSession := fixture.dial(t) + defer sentSession.conn.Close() + if status := sendChange(t, sentSession, e2eChange(true), headers); status != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", status, http.StatusUnauthorized) + } + if _, ok := fixture.store.Lookup("change-0001"); ok { + t.Fatal("wrong-session action was applied") + } +} + +func TestProtectedChangeE2ERejectsReplay(t *testing.T) { + t.Parallel() + fixture := newE2EFixture(t) + session := fixture.dial(t) + defer session.conn.Close() + change := e2eChange(true) + headers := fixture.headers(t, session, change, nil) + + if status := sendChange(t, session, change, headers); status != http.StatusNoContent { + t.Fatalf("first status = %d, want %d, audit = %v", status, http.StatusNoContent, fixture.audit.last()) + } + if status := sendChange(t, session, change, headers); status != http.StatusUnauthorized { + t.Fatalf("second status = %d, want %d", status, http.StatusUnauthorized) + } +} + +func TestProtectedChangeE2ERejectsRevokedGrant(t *testing.T) { + t.Parallel() + fixture := newE2EFixture(t) + fixture.managerTrust.revoke("grant-change-0001") + session := fixture.dial(t) + defer session.conn.Close() + change := e2eChange(true) + headers := fixture.headers(t, session, change, nil) + + if status := sendChange(t, session, change, headers); status != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", status, http.StatusUnauthorized) + } + if _, ok := fixture.store.Lookup(change.ChangeID); ok { + t.Fatal("revoked-grant action was applied") + } +} + +func TestProtectedChangeE2ERejectsAttestationMismatch(t *testing.T) { + t.Parallel() + fixture := newE2EFixture(t) + session := fixture.dial(t) + defer session.conn.Close() + change := e2eChange(true) + headers := fixture.headers(t, session, change, func(result *production.AttestationResult) { + result.AttestationBinderSHA256 = strings.Repeat("0", 64) + }) + + if status := sendChange(t, session, change, headers); status != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", status, http.StatusUnauthorized) + } + if _, ok := fixture.store.Lookup(change.ChangeID); ok { + t.Fatal("attestation-mismatch action was applied") + } +} + +func TestProtectedChangeE2ERejectsReplayStoreOutage(t *testing.T) { + t.Parallel() + fixture := newE2EFixture(t) + fixture.replayStore.err = errors.New("shared replay unavailable") + session := fixture.dial(t) + defer session.conn.Close() + change := e2eChange(true) + headers := fixture.headers(t, session, change, nil) + + if status := sendChange(t, session, change, headers); status != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", status, http.StatusUnauthorized) + } + if _, ok := fixture.store.Lookup(change.ChangeID); ok { + t.Fatal("replay-outage action was applied") + } +} + +func newE2EFixture(t *testing.T) *e2eFixture { + t.Helper() + now := time.Date(2026, time.August, 3, 3, 0, 0, 0, time.UTC) + managerPublic, managerPrivate := generateEd25519(t) + agentPublic, agentPrivate := generateEd25519(t) + attesterPublic, attesterPrivate := generateEd25519(t) + managerTrust := &mutableTrustSource{snapshot: production.TrustSnapshot{Keys: []clients.LocalKey{{ + KeyID: e2eManagerKeyID, + Key: managerPublic, + }}}} + agentTrust := &mutableTrustSource{snapshot: production.TrustSnapshot{Keys: []clients.LocalKey{{ + KeyID: e2eAgentKeyID, + Key: agentPublic, + }}}} + replayStore := &sharedSetNXStore{seen: make(map[string]struct{})} + store := NewMemoryChangeStore() + audit := &e2eAudit{} + profile := production.Profile{ + GrantAuthority: production.AuthorityPolicy{ + ExpectedIssuer: e2eManagerIssuer, + ExpectedAudience: e2eAudience, + ValidMethods: []string{jwt.SigningMethodEdDSA.Alg()}, + TrustSource: managerTrust, + }, + BindingAuthority: production.AuthorityPolicy{ + ExpectedIssuer: e2eAgentIssuer, + ExpectedAudience: e2eAudience, + ValidMethods: []string{jwt.SigningMethodEdDSA.Alg()}, + TrustSource: agentTrust, + }, + Attestation: production.SignedAttestationPolicy{ + TrustedKeys: map[string]ed25519.PublicKey{e2eAttesterKeyID: attesterPublic}, + PolicyID: e2ePolicyID, + AllowedMeasurements: []string{e2eMeasurement}, + MaxAge: 2 * time.Minute, + ClockSkew: 5 * time.Second, + }, + ReplayCache: identitypolicy.NewSetNXReplayCacheWithClock(context.Background(), replayStore, func() time.Time { return now }), + Now: func() time.Time { return now }, + } + clientCertificate, clientLeaf, clientCAPool := e2eClientCertificate(t, now) + app := Application{ + Profile: profile, + Nonces: fixedNonceSource{"change-0001": e2eExpectedNonce}, + Store: store, + ExpectedAgent: e2eExpectedAgent, + AuditFailure: audit.record, + } + server := httptest.NewUnstartedServer(app) + server.TLS = &tls.Config{ + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAPool, + MinVersion: tls.VersionTLS13, + } + server.StartTLS() + t.Cleanup(server.Close) + serverRoots := x509.NewCertPool() + serverRoots.AddCert(server.Certificate()) + clientTLS := &tls.Config{ + Certificates: []tls.Certificate{clientCertificate}, + RootCAs: serverRoots, + ServerName: "example.com", + MinVersion: tls.VersionTLS13, + } + return &e2eFixture{ + server: server, + clientTLS: clientTLS, + clientLeaf: clientLeaf, + managerPrivate: managerPrivate, + agentPrivate: agentPrivate, + attesterPrivate: attesterPrivate, + managerTrust: managerTrust, + replayStore: replayStore, + store: store, + audit: audit, + now: now, + } +} + +func (f *e2eFixture) dial(t *testing.T) *e2eSession { + t.Helper() + address := strings.TrimPrefix(f.server.URL, "https://") + conn, err := tls.Dial("tcp", address, f.clientTLS.Clone()) + if err != nil { + t.Fatalf("TLS dial: %v", err) + } + return &e2eSession{conn: conn, reader: bufio.NewReader(conn)} +} + +func (f *e2eFixture) headers(t *testing.T, session *e2eSession, change ChangeRequest, mutateAttestation func(*production.AttestationResult)) http.Header { + t.Helper() + actionContext, err := CanonicalActionContext(change) + if err != nil { + t.Fatal(err) + } + state := session.conn.ConnectionState() + binding, err := production.BindingFromTLS(&state, f.clientLeaf, actionContext, e2eExpectedNonce) + if err != nil { + t.Fatalf("derive binding: %v", err) + } + binding.IssuedAt = f.now.Add(-30 * time.Second) + binding.ExpiresAt = f.now.Add(90 * time.Second) + resource := "config://" + change.Tenant + "/" + change.Setting + grant := signE2EJWT(t, f.managerPrivate, e2eManagerKeyID, jwt.MapClaims{ + "iss": e2eManagerIssuer, + "sub": e2eExpectedAgent, + "aud": e2eAudience, + "jti": "grant-change-0001", + "iat": f.now.Add(-time.Minute).Unix(), + "exp": f.now.Add(5 * time.Minute).Unix(), + "profile_type": clients.TokenTypeIdentityGrant, + "profile_version": clients.ProfileVersion, + "cnf": map[string]string{"kid": e2eAgentKeyID}, + "service": "protected-change", + "agent": e2eExpectedAgent, + "task_id": change.ChangeID, + "intent_ref": "change:intent:apply", + "capability_ref": "change:capability:write", + "scopes": []string{"change.write"}, + "resources": []string{resource}, + "authorization_details": []string{"change:set-enabled"}, + }) + sessionBinding := signE2EJWT(t, f.agentPrivate, e2eAgentKeyID, jwt.MapClaims{ + "iss": e2eAgentIssuer, + "aud": e2eAudience, + "jti": "binding-change-0001", + "iat": binding.IssuedAt.Unix(), + "exp": binding.ExpiresAt.Unix(), + "profile_type": clients.TokenTypeSessionBinding, + "profile_version": clients.ProfileVersion, + "grant_hash": clients.IdentityGrantHash(grant), + "leaf_public_key_sha256": binding.LeafPublicKeySHA256, + "tls_exporter_sha256": binding.TLSExporterSHA256, + "request_context_sha256": binding.RequestContextSHA256, + "attestation_binder_sha256": binding.AttestationBinderSHA256, + "nonce": binding.Nonce, + }) + attestation := production.AttestationResult{ + Version: production.AttestationResultVersion, + ResultID: "attestation-change-0001", + VerifierKeyID: e2eAttesterKeyID, + PolicyID: e2ePolicyID, + Measurement: e2eMeasurement, + AttestationBinderSHA256: binding.AttestationBinderSHA256, + IssuedAt: f.now.Add(-30 * time.Second), + ExpiresAt: f.now.Add(90 * time.Second), + } + if mutateAttestation != nil { + mutateAttestation(&attestation) + } + signE2EAttestation(t, &attestation, f.attesterPrivate) + attestationJSON, err := json.Marshal(attestation) + if err != nil { + t.Fatal(err) + } + headers := make(http.Header) + headers.Set(IdentityGrantHeader, grant) + headers.Set(SessionBindingHeader, sessionBinding) + headers.Set(AttestationHeader, base64.RawURLEncoding.EncodeToString(attestationJSON)) + return headers +} + +func sendChange(t *testing.T, session *e2eSession, change ChangeRequest, headers http.Header) int { + t.Helper() + body, err := json.Marshal(change) + if err != nil { + t.Fatal(err) + } + request, err := http.NewRequest(http.MethodPost, "https://example.com"+ChangePath, bytes.NewReader(body)) + if err != nil { + t.Fatal(err) + } + request.Header = headers.Clone() + request.Header.Set("Content-Type", "application/json") + if err := request.Write(session.conn); err != nil { + t.Fatalf("write request: %v", err) + } + response, err := http.ReadResponse(session.reader, request) + if err != nil { + t.Fatalf("read response: %v", err) + } + _, _ = io.Copy(io.Discard, response.Body) + _ = response.Body.Close() + return response.StatusCode +} + +func e2eChange(enabled bool) ChangeRequest { + return ChangeRequest{ + ChangeID: "change-0001", + Tenant: "tenant-01", + Setting: "feature-x", + Enabled: enabled, + } +} + +func signE2EJWT(t *testing.T, key ed25519.PrivateKey, keyID string, claims jwt.MapClaims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = keyID + value, err := token.SignedString(key) + if err != nil { + t.Fatal(err) + } + return value +} + +func signE2EAttestation(t *testing.T, result *production.AttestationResult, key ed25519.PrivateKey) { + t.Helper() + result.Signature = nil + payload, err := result.SigningBytes() + if err != nil { + t.Fatal(err) + } + result.Signature = ed25519.Sign(key, payload) +} + +func generateEd25519(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return public, private +} + +func e2eClientCertificate(t *testing.T, now time.Time) (tls.Certificate, *x509.Certificate, *x509.CertPool) { + t.Helper() + caPublic, caPrivate := generateEd25519(t) + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "ASB protected-change test CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caPublic, caPrivate) + if err != nil { + t.Fatal(err) + } + ca, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + clientPublic, clientPrivate := generateEd25519(t) + clientTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "change-agent-01"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + clientDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, ca, clientPublic, caPrivate) + if err != nil { + t.Fatal(err) + } + clientLeaf, err := x509.ParseCertificate(clientDER) + if err != nil { + t.Fatal(err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: clientDER}) + certPEM = append(certPEM, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER})...) + keyDER, err := x509.MarshalPKCS8PrivateKey(clientPrivate) + if err != nil { + t.Fatal(err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) + certificate, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + t.Fatal(err) + } + pool := x509.NewCertPool() + pool.AddCert(ca) + return certificate, clientLeaf, pool +} diff --git a/internal/proto/attestation-agent/attestation-agent.pb.go b/internal/proto/attestation-agent/attestation-agent.pb.go index 5d51dde0..6edad5cb 100644 --- a/internal/proto/attestation-agent/attestation-agent.pb.go +++ b/internal/proto/attestation-agent/attestation-agent.pb.go @@ -572,26 +572,44 @@ func (x *GetTeeTypeResponse) GetTee() string { var File_internal_proto_attestation_agent_attestation_agent_proto protoreflect.FileDescriptor const file_internal_proto_attestation_agent_attestation_agent_proto_rawDesc = "" + - "\n8internal/proto/attestation-agent/attestation-agent.proto\x12\x11attestation_agent\"6\n" + - "\x12GetEvidenceRequest\x12 \n\vRuntimeData\x18\x01 \x01(\fR\vRuntimeData\"@\n\x1cGetAdditionalEvidenceRe" + - "quest\x12 \n\vRuntimeData\x18\x01 \x01(\fR\vRuntimeData\"1\n\x13GetEvidenceResponse\x12\x1a\n\bEvidence\x18\x01 \x01(\f" + - "R\bEvidence\"/\n\x0fGetTokenRequest\x12\x1c\n\tTokenType\x18\x01 \x01(\tR\tTokenType\"(\n\x10GetTokenResponse\x12" + - "\x14\n\x05Token\x18\x01 \x01(\fR\x05Token\"\xae\x01\n\x1fExtendRuntimeMeasurementRequest\x12\x16\n\x06Domain\x18\x01 \x01(\tR\x06Domai" + - "n\x12\x1c\n\tOperation\x18\x02 \x01(\tR\tOperation\x12\x18\n\aContent\x18\x03 \x01(\tR\aContent\x12)\n\rRegisterIndex\x18\x04 \x01(\x04" + - "H\x00R\rRegisterIndex\x88\x01\x01B\x10\n\x0e_RegisterIndex\"g\n ExtendRuntimeMeasurementResponse\x12C\n\x06Re" + - "sult\x18\x01 \x01(\x0e2+.attestation_agent.RuntimeMeasurementResultR\x06Result\"-\n\x13BindInitDataR" + - "equest\x12\x16\n\x06Digest\x18\x01 \x01(\fR\x06Digest\"\x16\n\x14BindInitDataResponse\"\x13\n\x11GetTeeTypeRequest\"&\n\x12G" + - "etTeeTypeResponse\x12\x10\n\x03tee\x18\x01 \x01(\tR\x03tee*F\n\x18RuntimeMeasurementResult\x12\x06\n\x02OK\x10\x00\x12\x11\n\rNOT_S" + - "UPPORTED\x10\x01\x12\x0f\n\vNOT_ENABLED\x10\x022\x8c\x05\n\x17AttestationAgentService\x12^\n\vGetEvidence\x12%.attesta" + - "tion_agent.GetEvidenceRequest\x1a&.attestation_agent.GetEvidenceResponse\"\x00\x12r\n\x15GetAd" + - "ditionalEvidence\x12/.attestation_agent.GetAdditionalEvidenceRequest\x1a&.attestation_" + - "agent.GetEvidenceResponse\"\x00\x12U\n\bGetToken\x12\".attestation_agent.GetTokenRequest\x1a#.at" + - "testation_agent.GetTokenResponse\"\x00\x12\x85\x01\n\x18ExtendRuntimeMeasurement\x122.attestation_ag" + - "ent.ExtendRuntimeMeasurementRequest\x1a3.attestation_agent.ExtendRuntimeMeasurement" + - "Response\"\x00\x12a\n\fBindInitData\x12&.attestation_agent.BindInitDataRequest\x1a'.attestation" + - "_agent.BindInitDataResponse\"\x00\x12[\n\nGetTeeType\x12$.attestation_agent.GetTeeTypeReques" + - "t\x1a%.attestation_agent.GetTeeTypeResponse\"\x00B`Z^github.com/thinksyncs/agents-se" + - "cure-binding/internal/proto/attestation-agent;attestation_agentb\x06proto3" + "\n" + + "8internal/proto/attestation-agent/attestation-agent.proto\x12\x11attestation_agent\"6\n" + + "\x12GetEvidenceRequest\x12 \n" + + "\vRuntimeData\x18\x01 \x01(\fR\vRuntimeData\"@\n" + + "\x1cGetAdditionalEvidenceRequest\x12 \n" + + "\vRuntimeData\x18\x01 \x01(\fR\vRuntimeData\"1\n" + + "\x13GetEvidenceResponse\x12\x1a\n" + + "\bEvidence\x18\x01 \x01(\fR\bEvidence\"/\n" + + "\x0fGetTokenRequest\x12\x1c\n" + + "\tTokenType\x18\x01 \x01(\tR\tTokenType\"(\n" + + "\x10GetTokenResponse\x12\x14\n" + + "\x05Token\x18\x01 \x01(\fR\x05Token\"\xae\x01\n" + + "\x1fExtendRuntimeMeasurementRequest\x12\x16\n" + + "\x06Domain\x18\x01 \x01(\tR\x06Domain\x12\x1c\n" + + "\tOperation\x18\x02 \x01(\tR\tOperation\x12\x18\n" + + "\aContent\x18\x03 \x01(\tR\aContent\x12)\n" + + "\rRegisterIndex\x18\x04 \x01(\x04H\x00R\rRegisterIndex\x88\x01\x01B\x10\n" + + "\x0e_RegisterIndex\"g\n" + + " ExtendRuntimeMeasurementResponse\x12C\n" + + "\x06Result\x18\x01 \x01(\x0e2+.attestation_agent.RuntimeMeasurementResultR\x06Result\"-\n" + + "\x13BindInitDataRequest\x12\x16\n" + + "\x06Digest\x18\x01 \x01(\fR\x06Digest\"\x16\n" + + "\x14BindInitDataResponse\"\x13\n" + + "\x11GetTeeTypeRequest\"&\n" + + "\x12GetTeeTypeResponse\x12\x10\n" + + "\x03tee\x18\x01 \x01(\tR\x03tee*F\n" + + "\x18RuntimeMeasurementResult\x12\x06\n" + + "\x02OK\x10\x00\x12\x11\n" + + "\rNOT_SUPPORTED\x10\x01\x12\x0f\n" + + "\vNOT_ENABLED\x10\x022\x8c\x05\n" + + "\x17AttestationAgentService\x12^\n" + + "\vGetEvidence\x12%.attestation_agent.GetEvidenceRequest\x1a&.attestation_agent.GetEvidenceResponse\"\x00\x12r\n" + + "\x15GetAdditionalEvidence\x12/.attestation_agent.GetAdditionalEvidenceRequest\x1a&.attestation_agent.GetEvidenceResponse\"\x00\x12U\n" + + "\bGetToken\x12\".attestation_agent.GetTokenRequest\x1a#.attestation_agent.GetTokenResponse\"\x00\x12\x85\x01\n" + + "\x18ExtendRuntimeMeasurement\x122.attestation_agent.ExtendRuntimeMeasurementRequest\x1a3.attestation_agent.ExtendRuntimeMeasurementResponse\"\x00\x12a\n" + + "\fBindInitData\x12&.attestation_agent.BindInitDataRequest\x1a'.attestation_agent.BindInitDataResponse\"\x00\x12[\n" + + "\n" + + "GetTeeType\x12$.attestation_agent.GetTeeTypeRequest\x1a%.attestation_agent.GetTeeTypeResponse\"\x00B`Z^github.com/thinksyncs/agents-secure-binding/internal/proto/attestation-agent;attestation_agentb\x06proto3" var ( file_internal_proto_attestation_agent_attestation_agent_proto_rawDescOnce sync.Once diff --git a/internal/proto/attestation/v1/attestation.pb.go b/internal/proto/attestation/v1/attestation.pb.go index c510ed1c..284d7067 100644 --- a/internal/proto/attestation/v1/attestation.pb.go +++ b/internal/proto/attestation/v1/attestation.pb.go @@ -321,19 +321,33 @@ func (x *AzureTokenResponse) GetToken() []byte { var File_internal_proto_attestation_v1_attestation_proto protoreflect.FileDescriptor const file_internal_proto_attestation_v1_attestation_proto_rawDesc = "" + - "\n/internal/proto/attestation/v1/attestation.proto\x12\x0eattestation.v1\"\x8e\x01\n\x12Attestatio" + - "nRequest\x12\x1f\n\vreport_data\x18\x01 \x01(\fR\nreportData\x12\x14\n\x05nonce\x18\x02 \x01(\fR\x05nonce\x12A\n\rplatform_type" + - "\x18\x03 \x01(\x0e2\x1c.attestation.v1.PlatformTypeR\fplatformType\"2\n\x13AttestationResponse\x12\x1b\n\teat" + - "_token\x18\x01 \x01(\fR\beatToken\"1\n\x13RawEvidenceResponse\x12\x1a\n\bevidence\x18\x01 \x01(\fR\bevidence\")\n\x11Azu" + - "reTokenRequest\x12\x14\n\x05nonce\x18\x01 \x01(\fR\x05nonce\"*\n\x12AzureTokenResponse\x12\x14\n\x05token\x18\x01 \x01(\fR\x05token" + - "*\xc1\x01\n\fPlatformType\x12\x1d\n\x19PLATFORM_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11PLATFORM_TYPE_SNP\x10\x01\x12\x15\n\x11PLATF" + - "ORM_TYPE_TDX\x10\x02\x12\x16\n\x12PLATFORM_TYPE_VTPM\x10\x03\x12\x1a\n\x16PLATFORM_TYPE_SNP_VTPM\x10\x04\x12\x17\n\x13PLATFORM_T" + - "YPE_AZURE\x10\x05\x12\x17\n\x13PLATFORM_TYPE_NO_CC\x10\x062\xa8\x02\n\x12AttestationService\x12[\n\x10FetchAttestation\x12" + - "\".attestation.v1.AttestationRequest\x1a#.attestation.v1.AttestationResponse\x12[\n\x10Fetc" + - "hRawEvidence\x12\".attestation.v1.AttestationRequest\x1a#.attestation.v1.RawEvidenceRes" + - "ponse\x12X\n\x0fFetchAzureToken\x12!.attestation.v1.AzureTokenRequest\x1a\".attestation.v1.Azu" + - "reTokenResponseBWZUgithub.com/thinksyncs/agents-secure-binding/internal/proto/attest" + - "ation/v1;attestationb\x06proto3" + "\n" + + "/internal/proto/attestation/v1/attestation.proto\x12\x0eattestation.v1\"\x8e\x01\n" + + "\x12AttestationRequest\x12\x1f\n" + + "\vreport_data\x18\x01 \x01(\fR\n" + + "reportData\x12\x14\n" + + "\x05nonce\x18\x02 \x01(\fR\x05nonce\x12A\n" + + "\rplatform_type\x18\x03 \x01(\x0e2\x1c.attestation.v1.PlatformTypeR\fplatformType\"2\n" + + "\x13AttestationResponse\x12\x1b\n" + + "\teat_token\x18\x01 \x01(\fR\beatToken\"1\n" + + "\x13RawEvidenceResponse\x12\x1a\n" + + "\bevidence\x18\x01 \x01(\fR\bevidence\")\n" + + "\x11AzureTokenRequest\x12\x14\n" + + "\x05nonce\x18\x01 \x01(\fR\x05nonce\"*\n" + + "\x12AzureTokenResponse\x12\x14\n" + + "\x05token\x18\x01 \x01(\fR\x05token*\xc1\x01\n" + + "\fPlatformType\x12\x1d\n" + + "\x19PLATFORM_TYPE_UNSPECIFIED\x10\x00\x12\x15\n" + + "\x11PLATFORM_TYPE_SNP\x10\x01\x12\x15\n" + + "\x11PLATFORM_TYPE_TDX\x10\x02\x12\x16\n" + + "\x12PLATFORM_TYPE_VTPM\x10\x03\x12\x1a\n" + + "\x16PLATFORM_TYPE_SNP_VTPM\x10\x04\x12\x17\n" + + "\x13PLATFORM_TYPE_AZURE\x10\x05\x12\x17\n" + + "\x13PLATFORM_TYPE_NO_CC\x10\x062\xa8\x02\n" + + "\x12AttestationService\x12[\n" + + "\x10FetchAttestation\x12\".attestation.v1.AttestationRequest\x1a#.attestation.v1.AttestationResponse\x12[\n" + + "\x10FetchRawEvidence\x12\".attestation.v1.AttestationRequest\x1a#.attestation.v1.RawEvidenceResponse\x12X\n" + + "\x0fFetchAzureToken\x12!.attestation.v1.AzureTokenRequest\x1a\".attestation.v1.AzureTokenResponseBWZUgithub.com/thinksyncs/agents-secure-binding/internal/proto/attestation/v1;attestationb\x06proto3" var ( file_internal_proto_attestation_v1_attestation_proto_rawDescOnce sync.Once diff --git a/pkg/production/attestation.go b/pkg/production/attestation.go new file mode 100644 index 00000000..f156e276 --- /dev/null +++ b/pkg/production/attestation.go @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +package production + +import ( + "context" + "crypto/ed25519" + "encoding/json" + "errors" + "fmt" + "strings" + "time" +) + +var ( + ErrInvalidAttestationResult = errors.New("production: invalid attestation result") + ErrUnknownAttestationKey = errors.New("production: unknown attestation verifier key") + ErrDisabledAttestationKey = errors.New("production: disabled attestation verifier key") + ErrAttestationSignature = errors.New("production: invalid attestation result signature") + ErrAttestationPolicy = errors.New("production: attestation policy mismatch") + ErrAttestationBinding = errors.New("production: attestation binding mismatch") + ErrAttestationExpired = errors.New("production: expired attestation result") + ErrAttestationFuture = errors.New("production: attestation result issued in the future") + ErrAttestationStale = errors.New("production: stale attestation result") +) + +const AttestationResultVersion = "asb-attestation-result/v1" + +// AttestationResult is a signed appraisal result bound to one accepted TLS and +// application context. Signature contains an Ed25519 signature over +// SigningBytes. +type AttestationResult struct { + Version string `json:"version"` + ResultID string `json:"result_id"` + VerifierKeyID string `json:"verifier_key_id"` + PolicyID string `json:"policy_id"` + Measurement string `json:"measurement"` + AttestationBinderSHA256 string `json:"attestation_binder_sha256"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + Signature []byte `json:"signature"` +} + +type attestationSigningPayload struct { + Version string `json:"version"` + ResultID string `json:"result_id"` + VerifierKeyID string `json:"verifier_key_id"` + PolicyID string `json:"policy_id"` + Measurement string `json:"measurement"` + AttestationBinderSHA256 string `json:"attestation_binder_sha256"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` +} + +// SigningBytes returns the canonical bytes covered by the attestation-result +// signature. +func (r AttestationResult) SigningBytes() ([]byte, error) { + if err := r.validateShape(); err != nil { + return nil, err + } + payload := attestationSigningPayload{ + Version: r.Version, + ResultID: r.ResultID, + VerifierKeyID: r.VerifierKeyID, + PolicyID: r.PolicyID, + Measurement: r.Measurement, + AttestationBinderSHA256: r.AttestationBinderSHA256, + IssuedAt: r.IssuedAt.UTC().Format(time.RFC3339Nano), + ExpiresAt: r.ExpiresAt.UTC().Format(time.RFC3339Nano), + } + return json.Marshal(payload) +} + +// SignedAttestationPolicy verifies a signed result under a local verifier-key +// namespace and exact appraisal policy. +type SignedAttestationPolicy struct { + TrustedKeys map[string]ed25519.PublicKey + DisabledKeyIDs []string + PolicyID string + AllowedMeasurements []string + MaxAge time.Duration + ClockSkew time.Duration +} + +// Verify authenticates and appraises one result. +func (p SignedAttestationPolicy) Verify(_ context.Context, result AttestationResult, expectedBinder string, now time.Time) error { + if p.ClockSkew < 0 || p.MaxAge <= 0 || len(p.TrustedKeys) == 0 || len(p.AllowedMeasurements) == 0 { + return ErrAttestationPolicy + } + payload, err := result.SigningBytes() + if err != nil { + return err + } + if expectedBinder == "" || result.AttestationBinderSHA256 != expectedBinder { + return ErrAttestationBinding + } + if p.PolicyID == "" || result.PolicyID != p.PolicyID { + return ErrAttestationPolicy + } + if !contains(p.AllowedMeasurements, result.Measurement) { + return ErrAttestationPolicy + } + if contains(p.DisabledKeyIDs, result.VerifierKeyID) { + return ErrDisabledAttestationKey + } + key, ok := p.TrustedKeys[result.VerifierKeyID] + if !ok || len(key) != ed25519.PublicKeySize { + return ErrUnknownAttestationKey + } + if !ed25519.Verify(key, payload, result.Signature) { + return ErrAttestationSignature + } + if now.IsZero() { + now = time.Now() + } + if result.IssuedAt.After(now.Add(p.ClockSkew)) { + return ErrAttestationFuture + } + if now.After(result.ExpiresAt.Add(p.ClockSkew)) { + return ErrAttestationExpired + } + if now.Sub(result.IssuedAt) > p.MaxAge+p.ClockSkew { + return ErrAttestationStale + } + return nil +} + +func (r AttestationResult) validateShape() error { + if r.Version != AttestationResultVersion || + strings.TrimSpace(r.ResultID) == "" || + strings.TrimSpace(r.VerifierKeyID) == "" || + strings.TrimSpace(r.PolicyID) == "" || + strings.TrimSpace(r.Measurement) == "" || + strings.TrimSpace(r.AttestationBinderSHA256) == "" || + r.IssuedAt.IsZero() || r.ExpiresAt.IsZero() || !r.ExpiresAt.After(r.IssuedAt) { + return ErrInvalidAttestationResult + } + for _, value := range []string{ + r.ResultID, r.VerifierKeyID, r.PolicyID, r.Measurement, r.AttestationBinderSHA256, + } { + if len(value) > 1024 || strings.TrimSpace(value) != value { + return fmt.Errorf("%w: non-canonical field", ErrInvalidAttestationResult) + } + } + return nil +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/pkg/production/binding.go b/pkg/production/binding.go new file mode 100644 index 00000000..490b9428 --- /dev/null +++ b/pkg/production/binding.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +package production + +import ( + "bytes" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "errors" + "strings" + + eaattestation "github.com/thinksyncs/agents-secure-binding/pkg/atls/eaattestation" + "github.com/thinksyncs/agents-secure-binding/pkg/atls/identitypolicy" +) + +var ErrInvalidAcceptedBinding = errors.New("production: invalid accepted binding input") + +// DirectAgentV1ExporterLabel is fixed by the supported Direct-Agent v1 +// production profile. Peers cannot select or override it. +const DirectAgentV1ExporterLabel = eaattestation.ExporterLabelAttestation + +// BindingFromTLS derives the verifier-local expected binding from an accepted +// TLS 1.3 session, the authenticated peer certificate, exact canonical action +// bytes, and a verifier-issued nonce. +func BindingFromTLS(state *tls.ConnectionState, peerLeaf *x509.Certificate, actionContext []byte, nonce string) (identitypolicy.Binding, error) { + if state == nil || peerLeaf == nil || len(actionContext) == 0 || strings.TrimSpace(nonce) == "" { + return identitypolicy.Binding{}, ErrInvalidAcceptedBinding + } + exporterContext := bytes.Join([][]byte{ + []byte("asb.direct-agent.production.v1"), + []byte(nonce), + actionContext, + }, []byte{0}) + exported, _, attestationBinding, err := eaattestation.ComputeBinding( + state, + DirectAgentV1ExporterLabel, + exporterContext, + peerLeaf, + ) + if err != nil { + return identitypolicy.Binding{}, err + } + leafKey := sha256.Sum256(peerLeaf.RawSubjectPublicKeyInfo) + exporter := sha256.Sum256(exported) + requestContext := sha256.Sum256(actionContext) + attestationBinder := sha256.Sum256(attestationBinding) + return identitypolicy.Binding{ + LeafPublicKeySHA256: hex.EncodeToString(leafKey[:]), + TLSExporterSHA256: hex.EncodeToString(exporter[:]), + RequestContextSHA256: hex.EncodeToString(requestContext[:]), + AttestationBinderSHA256: hex.EncodeToString(attestationBinder[:]), + Nonce: nonce, + }, nil +} diff --git a/pkg/production/profile.go b/pkg/production/profile.go new file mode 100644 index 00000000..b717dacb --- /dev/null +++ b/pkg/production/profile.go @@ -0,0 +1,272 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +// Package production provides the supported production composition for the +// Direct-Agent v1 verifier. It keeps deployment-owned trust, revocation, +// attestation, and distributed replay policy outside the wire-token parser +// while enforcing them in one fail-closed acceptance transaction. +package production + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/thinksyncs/agents-secure-binding/pkg/atls/identitypolicy" + "github.com/thinksyncs/agents-secure-binding/pkg/clients" +) + +var ( + ErrMissingTrustSource = errors.New("production: missing trust source") + ErrTrustSourceUnavailable = errors.New("production: trust source unavailable") + ErrMissingAttestation = errors.New("production: missing attestation result") + ErrMissingAttestationPolicy = errors.New("production: missing attestation policy") + ErrMissingReplayCache = errors.New("production: missing distributed replay cache") + ErrMissingPolicy = errors.New("production: missing identity policy") + ErrInvalidAuthority = errors.New("production: invalid authority policy") + ErrMissingContext = errors.New("production: missing context") +) + +// TrustSnapshot is one fail-closed view of trusted keys and revocation state. +// Grant and binding authorities use separate snapshots so their key roles +// cannot be silently combined. +type TrustSnapshot struct { + Keys []clients.LocalKey + DisabledKeyIDs []string + RevokedTokenIDs []string +} + +// TrustSource returns the current trusted-key and revocation snapshot. A +// source error rejects the request; callers must not fall back to stale data. +type TrustSource interface { + Snapshot(context.Context) (TrustSnapshot, error) +} + +// StaticTrustSource is suitable for deployments whose immutable configuration +// is replaced atomically during key rotation or revocation updates. +type StaticTrustSource struct { + Trust TrustSnapshot + Err error +} + +// Snapshot returns a defensive copy of the configured trust snapshot. +func (s StaticTrustSource) Snapshot(context.Context) (TrustSnapshot, error) { + if s.Err != nil { + return TrustSnapshot{}, fmt.Errorf("%w: %v", ErrTrustSourceUnavailable, s.Err) + } + return cloneTrustSnapshot(s.Trust), nil +} + +// AuthorityPolicy fixes the issuer, audience, signing algorithms, and trust +// source used for one token role. +type AuthorityPolicy struct { + ExpectedIssuer string + ExpectedAudience string + ValidMethods []string + TrustSource TrustSource +} + +// AttestationPolicy authenticates and appraises an attestation result against +// the binding derived by the relying service from the accepted TLS session and +// exact application context. +type AttestationPolicy interface { + Verify(context.Context, AttestationResult, string, time.Time) error +} + +// Profile is the supported Direct-Agent v1 production composition. +type Profile struct { + GrantAuthority AuthorityPolicy + BindingAuthority AuthorityPolicy + IdentityPolicy identitypolicy.Policy + Attestation AttestationPolicy + ReplayCache identitypolicy.ReplayCache + Now func() time.Time +} + +// VerifyRequest contains untrusted wire tokens plus verifier-derived binding +// and authenticated attestation material for one protected action. +type VerifyRequest struct { + GrantJWT string + SessionBindingJWT string + ExpectedBinding identitypolicy.Binding + Attestation AttestationResult +} + +// AcceptedIdentity is the minimal application projection returned after every +// production gate succeeds and replay state is committed. +type AcceptedIdentity struct { + Issuer string + Agent string + TaskID string + DelegationID string + IntentRef string + CapabilityRef string + Scopes []string + Resources []string + AuthorizationDetails []string + GrantExpiresAt time.Time + SessionBindingExpiresAt time.Time + AttestationExpiresAt time.Time +} + +// Validate checks deployment-owned configuration without reading a token. +func (p Profile) Validate(ctx context.Context) error { + if ctx == nil { + return ErrMissingContext + } + if !p.IdentityPolicy.Enabled() { + return ErrMissingPolicy + } + if err := p.IdentityPolicy.ValidateMode(); err != nil { + return fmt.Errorf("%w: identity policy: %v", ErrInvalidAuthority, err) + } + if p.Attestation == nil { + return ErrMissingAttestationPolicy + } + if p.ReplayCache == nil { + return ErrMissingReplayCache + } + if _, err := p.jwtOptions(ctx, p.GrantAuthority, time.Time{}); err != nil { + return fmt.Errorf("grant authority: %w", err) + } + if _, err := p.jwtOptions(ctx, p.BindingAuthority, time.Time{}); err != nil { + return fmt.Errorf("binding authority: %w", err) + } + return nil +} + +// Verify authenticates the grant and session proof, evaluates local policy and +// attestation, then commits one distributed replay key before returning an +// accepted identity. +func (p Profile) Verify(ctx context.Context, req VerifyRequest) (AcceptedIdentity, error) { + if ctx == nil { + return AcceptedIdentity{}, ErrMissingContext + } + if !p.IdentityPolicy.Enabled() { + return AcceptedIdentity{}, ErrMissingPolicy + } + if p.Attestation == nil { + return AcceptedIdentity{}, ErrMissingAttestationPolicy + } + if p.ReplayCache == nil { + return AcceptedIdentity{}, ErrMissingReplayCache + } + if strings.TrimSpace(req.Attestation.ResultID) == "" { + return AcceptedIdentity{}, ErrMissingAttestation + } + + now := time.Now() + if p.Now != nil { + now = p.Now() + } + + grantOpts, err := p.jwtOptions(ctx, p.GrantAuthority, now) + if err != nil { + return AcceptedIdentity{}, fmt.Errorf("grant authority: %w", err) + } + grant, err := clients.VerifyIdentityGrantJWT(req.GrantJWT, grantOpts) + if err != nil { + return AcceptedIdentity{}, fmt.Errorf("verify grant: %w", err) + } + + bindingOpts, err := p.jwtOptions(ctx, p.BindingAuthority, now) + if err != nil { + return AcceptedIdentity{}, fmt.Errorf("binding authority: %w", err) + } + statement, err := clients.VerifySessionBindingJWT(req.SessionBindingJWT, bindingOpts) + if err != nil { + return AcceptedIdentity{}, fmt.Errorf("verify session binding: %w", err) + } + + assertion, err := identitypolicy.NewAssertionFromSessionBinding(grant, statement, now) + if err != nil { + return AcceptedIdentity{}, fmt.Errorf("bind grant to session: %w", err) + } + if err := p.IdentityPolicy.ValidateAssertion(assertion, req.ExpectedBinding, now); err != nil { + return AcceptedIdentity{}, fmt.Errorf("verify expected identity: %w", err) + } + if err := p.Attestation.Verify(ctx, req.Attestation, req.ExpectedBinding.AttestationBinderSHA256, now); err != nil { + return AcceptedIdentity{}, fmt.Errorf("verify attestation: %w", err) + } + + replayExpiry := earliestTime(statement.Binding.ExpiresAt, req.Attestation.ExpiresAt, grant.ExpiresAt) + replayKey := strings.Join([]string{ + "asb.production.v1", + grant.GrantHash, + grant.Audience, + statement.Binding.RequestContextSHA256, + statement.Binding.Nonce, + }, "\x00") + if err := p.ReplayCache.MarkUsed(replayKey, replayExpiry); err != nil { + return AcceptedIdentity{}, fmt.Errorf("commit replay state: %w", err) + } + + return AcceptedIdentity{ + Issuer: assertion.Issuer, + Agent: assertion.Values.Agent, + TaskID: assertion.Values.TaskID, + DelegationID: assertion.Values.DelegationID, + IntentRef: assertion.Values.IntentRef, + CapabilityRef: assertion.Values.CapabilityRef, + Scopes: append([]string(nil), assertion.Values.Scopes...), + Resources: append([]string(nil), assertion.Values.Resources...), + AuthorizationDetails: append([]string(nil), assertion.Values.AuthorizationDetails...), + GrantExpiresAt: grant.ExpiresAt, + SessionBindingExpiresAt: statement.Binding.ExpiresAt, + AttestationExpiresAt: req.Attestation.ExpiresAt, + }, nil +} + +func (p Profile) jwtOptions(ctx context.Context, authority AuthorityPolicy, now time.Time) (clients.JWTVerifyOptions, error) { + if authority.TrustSource == nil { + return clients.JWTVerifyOptions{}, ErrMissingTrustSource + } + if strings.TrimSpace(authority.ExpectedIssuer) == "" || + strings.TrimSpace(authority.ExpectedAudience) == "" || len(authority.ValidMethods) == 0 { + return clients.JWTVerifyOptions{}, ErrInvalidAuthority + } + snapshot, err := authority.TrustSource.Snapshot(ctx) + if err != nil { + if errors.Is(err, ErrTrustSourceUnavailable) { + return clients.JWTVerifyOptions{}, err + } + return clients.JWTVerifyOptions{}, fmt.Errorf("%w: %v", ErrTrustSourceUnavailable, err) + } + opts := clients.JWTVerifyOptions{ + ExpectedIssuer: authority.ExpectedIssuer, + ExpectedAudience: authority.ExpectedAudience, + ValidMethods: append([]string(nil), authority.ValidMethods...), + LocalKeys: append([]clients.LocalKey(nil), snapshot.Keys...), + DisabledKeyIDs: append([]string(nil), snapshot.DisabledKeyIDs...), + RevokedJWTIDs: append([]string(nil), snapshot.RevokedTokenIDs...), + Now: now, + } + if err := clients.ValidateJWTVerifyOptions(opts); err != nil { + return clients.JWTVerifyOptions{}, fmt.Errorf("%w: %v", ErrInvalidAuthority, err) + } + return opts, nil +} + +func cloneTrustSnapshot(in TrustSnapshot) TrustSnapshot { + out := TrustSnapshot{ + Keys: append([]clients.LocalKey(nil), in.Keys...), + DisabledKeyIDs: append([]string(nil), in.DisabledKeyIDs...), + RevokedTokenIDs: append([]string(nil), in.RevokedTokenIDs...), + } + return out +} + +func earliestTime(values ...time.Time) time.Time { + var earliest time.Time + for _, value := range values { + if value.IsZero() { + continue + } + if earliest.IsZero() || value.Before(earliest) { + earliest = value + } + } + return earliest +} diff --git a/pkg/production/profile_test.go b/pkg/production/profile_test.go new file mode 100644 index 00000000..f5c4e5f3 --- /dev/null +++ b/pkg/production/profile_test.go @@ -0,0 +1,397 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +package production + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "sync" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/thinksyncs/agents-secure-binding/pkg/atls/identitypolicy" + "github.com/thinksyncs/agents-secure-binding/pkg/clients" +) + +const ( + testAudience = "https://change.example.test/v1/changes" + testManagerIssuer = "https://manager.example.test" + testAgentIssuer = "https://agent.example.test" + testManagerKeyID = "manager-ed25519-2026-01" + testAgentKeyID = "agent-ed25519-2026-01" + testAttesterKeyID = "attester-ed25519-2026-01" + testAttestationID = "attestation-0001" + testAttestationPolicyID = "protected-change-attestation/v1" + testMeasurement = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +) + +type profileFixture struct { + profile Profile + request VerifyRequest + managerPrivate ed25519.PrivateKey + agentPrivate ed25519.PrivateKey + attesterPrivate ed25519.PrivateKey + replay *recordingReplayCache + now time.Time +} + +type trustSourceFunc func(context.Context) (TrustSnapshot, error) + +func (f trustSourceFunc) Snapshot(ctx context.Context) (TrustSnapshot, error) { + return f(ctx) +} + +type recordingReplayCache struct { + mu sync.Mutex + seen map[string]time.Time + err error +} + +func (c *recordingReplayCache) MarkUsed(key string, expiresAt time.Time) error { + if c.err != nil { + return c.err + } + c.mu.Lock() + defer c.mu.Unlock() + if _, ok := c.seen[key]; ok { + return identitypolicy.ErrReplayDetected + } + c.seen[key] = expiresAt + return nil +} + +func (c *recordingReplayCache) count() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.seen) +} + +func TestProfileVerifyAcceptsProductionComposition(t *testing.T) { + t.Parallel() + fixture := newProfileFixture(t) + + accepted, err := fixture.profile.Verify(context.Background(), fixture.request) + if err != nil { + t.Fatalf("Verify() error = %v", err) + } + if accepted.Agent != "change-agent-01" || accepted.TaskID != "change-0001" { + t.Fatalf("Verify() accepted = %+v", accepted) + } + if fixture.replay.count() != 1 { + t.Fatalf("replay commits = %d, want 1", fixture.replay.count()) + } +} + +func TestProfileVerifyRejectsReplay(t *testing.T) { + t.Parallel() + fixture := newProfileFixture(t) + + if _, err := fixture.profile.Verify(context.Background(), fixture.request); err != nil { + t.Fatalf("first Verify() error = %v", err) + } + _, err := fixture.profile.Verify(context.Background(), fixture.request) + if !errors.Is(err, identitypolicy.ErrReplayDetected) { + t.Fatalf("second Verify() error = %v, want %v", err, identitypolicy.ErrReplayDetected) + } +} + +func TestProfileVerifyNegativeGatesDoNotCommitReplay(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*testing.T, *profileFixture) + want error + }{ + { + name: "trust source outage", + mutate: func(_ *testing.T, f *profileFixture) { + f.profile.GrantAuthority.TrustSource = trustSourceFunc(func(context.Context) (TrustSnapshot, error) { + return TrustSnapshot{}, errors.New("registry unavailable") + }) + }, + want: ErrTrustSourceUnavailable, + }, + { + name: "disabled manager key", + mutate: func(_ *testing.T, f *profileFixture) { + f.profile.GrantAuthority.TrustSource = StaticTrustSource{Trust: TrustSnapshot{ + Keys: []clients.LocalKey{{KeyID: testManagerKeyID, Key: f.managerPrivate.Public()}}, + DisabledKeyIDs: []string{testManagerKeyID}, + }} + }, + want: clients.ErrDisabledKeyID, + }, + { + name: "unknown agent key", + mutate: func(_ *testing.T, f *profileFixture) { + f.profile.BindingAuthority.TrustSource = StaticTrustSource{Trust: TrustSnapshot{ + Keys: []clients.LocalKey{{KeyID: "different-agent-key", Key: f.agentPrivate.Public()}}, + }} + }, + want: clients.ErrUnknownKeyID, + }, + { + name: "revoked grant", + mutate: func(_ *testing.T, f *profileFixture) { + f.profile.GrantAuthority.TrustSource = StaticTrustSource{Trust: TrustSnapshot{ + Keys: []clients.LocalKey{{KeyID: testManagerKeyID, Key: f.managerPrivate.Public()}}, + RevokedTokenIDs: []string{"grant-0001"}, + }} + }, + want: clients.ErrRevokedJWTID, + }, + { + name: "wrong action binding", + mutate: func(_ *testing.T, f *profileFixture) { + f.request.ExpectedBinding.RequestContextSHA256 = testHash("different-action") + }, + want: identitypolicy.ErrMismatch, + }, + { + name: "wrong local task", + mutate: func(_ *testing.T, f *profileFixture) { + f.profile.IdentityPolicy.Expected.TaskID = "change-9999" + }, + want: identitypolicy.ErrMismatch, + }, + { + name: "attestation binder mismatch", + mutate: func(t *testing.T, f *profileFixture) { + f.request.Attestation.AttestationBinderSHA256 = testHash("other-attestation-binding") + signAttestation(t, &f.request.Attestation, f.attesterPrivate) + }, + want: ErrAttestationBinding, + }, + { + name: "stale attestation", + mutate: func(t *testing.T, f *profileFixture) { + f.request.Attestation.IssuedAt = f.now.Add(-10 * time.Minute) + f.request.Attestation.ExpiresAt = f.now.Add(time.Minute) + signAttestation(t, &f.request.Attestation, f.attesterPrivate) + }, + want: ErrAttestationStale, + }, + { + name: "unknown measurement", + mutate: func(t *testing.T, f *profileFixture) { + f.request.Attestation.Measurement = testHash("unapproved-workload") + signAttestation(t, &f.request.Attestation, f.attesterPrivate) + }, + want: ErrAttestationPolicy, + }, + { + name: "replay store outage", + mutate: func(_ *testing.T, f *profileFixture) { + f.replay.err = errors.New("shared store unavailable") + }, + want: errors.New("shared store unavailable"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + fixture := newProfileFixture(t) + tt.mutate(t, fixture) + _, err := fixture.profile.Verify(context.Background(), fixture.request) + if tt.name == "replay store outage" { + if err == nil || !errors.Is(err, fixture.replay.err) { + t.Fatalf("Verify() error = %v, want replay store error", err) + } + } else if !errors.Is(err, tt.want) { + t.Fatalf("Verify() error = %v, want %v", err, tt.want) + } + if fixture.replay.count() != 0 { + t.Fatalf("replay commits = %d, want 0", fixture.replay.count()) + } + }) + } +} + +func TestProfileValidateRejectsIncompleteDeployment(t *testing.T) { + t.Parallel() + fixture := newProfileFixture(t) + if err := fixture.profile.Validate(nil); !errors.Is(err, ErrMissingContext) { + t.Fatalf("Validate(nil) error = %v, want %v", err, ErrMissingContext) + } + if _, err := fixture.profile.Verify(nil, fixture.request); !errors.Is(err, ErrMissingContext) { + t.Fatalf("Verify(nil) error = %v, want %v", err, ErrMissingContext) + } + + tests := []struct { + name string + mutate func(*Profile) + want error + }{ + {"missing trust", func(p *Profile) { p.GrantAuthority.TrustSource = nil }, ErrMissingTrustSource}, + {"missing attestation", func(p *Profile) { p.Attestation = nil }, ErrMissingAttestationPolicy}, + {"missing replay", func(p *Profile) { p.ReplayCache = nil }, ErrMissingReplayCache}, + {"missing policy", func(p *Profile) { p.IdentityPolicy = identitypolicy.Policy{} }, ErrMissingPolicy}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + profile := fixture.profile + tt.mutate(&profile) + if err := profile.Validate(context.Background()); !errors.Is(err, tt.want) { + t.Fatalf("Validate() error = %v, want %v", err, tt.want) + } + }) + } +} + +func newProfileFixture(t *testing.T) *profileFixture { + t.Helper() + now := time.Date(2026, time.August, 3, 2, 0, 0, 0, time.UTC) + managerPublic, managerPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + agentPublic, agentPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + attesterPublic, attesterPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + expectedBinding := identitypolicy.Binding{ + LeafPublicKeySHA256: testHash("agent-leaf-key"), + TLSExporterSHA256: testHash("accepted-tls-exporter"), + RequestContextSHA256: testHash("protected-change-action"), + AttestationBinderSHA256: testHash("accepted-attestation-binding"), + Nonce: "binding-nonce-0001", + } + values := identitypolicy.Values{ + Service: "protected-change", + Agent: "change-agent-01", + TaskID: "change-0001", + IntentRef: "change:intent:apply", + CapabilityRef: "change:capability:write", + Scopes: []string{"change.write"}, + Resources: []string{"config://tenant-01/feature-x"}, + AuthorizationDetails: []string{"change:set-enabled"}, + } + grant := signJWT(t, managerPrivate, testManagerKeyID, jwt.MapClaims{ + "iss": testManagerIssuer, + "sub": values.Agent, + "aud": testAudience, + "jti": "grant-0001", + "iat": now.Add(-time.Minute).Unix(), + "exp": now.Add(5 * time.Minute).Unix(), + "profile_type": clients.TokenTypeIdentityGrant, + "profile_version": clients.ProfileVersion, + "cnf": map[string]string{"kid": testAgentKeyID}, + "service": values.Service, + "agent": values.Agent, + "task_id": values.TaskID, + "intent_ref": values.IntentRef, + "capability_ref": values.CapabilityRef, + "scopes": values.Scopes, + "resources": values.Resources, + "authorization_details": values.AuthorizationDetails, + }) + binding := signJWT(t, agentPrivate, testAgentKeyID, jwt.MapClaims{ + "iss": testAgentIssuer, + "aud": testAudience, + "jti": "binding-0001", + "iat": now.Add(-30 * time.Second).Unix(), + "exp": now.Add(2 * time.Minute).Unix(), + "profile_type": clients.TokenTypeSessionBinding, + "profile_version": clients.ProfileVersion, + "grant_hash": clients.IdentityGrantHash(grant), + "leaf_public_key_sha256": expectedBinding.LeafPublicKeySHA256, + "tls_exporter_sha256": expectedBinding.TLSExporterSHA256, + "request_context_sha256": expectedBinding.RequestContextSHA256, + "attestation_binder_sha256": expectedBinding.AttestationBinderSHA256, + "nonce": expectedBinding.Nonce, + }) + attestation := AttestationResult{ + Version: AttestationResultVersion, + ResultID: testAttestationID, + VerifierKeyID: testAttesterKeyID, + PolicyID: testAttestationPolicyID, + Measurement: testMeasurement, + AttestationBinderSHA256: expectedBinding.AttestationBinderSHA256, + IssuedAt: now.Add(-30 * time.Second), + ExpiresAt: now.Add(90 * time.Second), + } + signAttestation(t, &attestation, attesterPrivate) + + replay := &recordingReplayCache{seen: make(map[string]time.Time)} + profile := Profile{ + GrantAuthority: AuthorityPolicy{ + ExpectedIssuer: testManagerIssuer, + ExpectedAudience: testAudience, + ValidMethods: []string{jwt.SigningMethodEdDSA.Alg()}, + TrustSource: StaticTrustSource{Trust: TrustSnapshot{Keys: []clients.LocalKey{ + {KeyID: testManagerKeyID, Key: managerPublic}, + }}}, + }, + BindingAuthority: AuthorityPolicy{ + ExpectedIssuer: testAgentIssuer, + ExpectedAudience: testAudience, + ValidMethods: []string{jwt.SigningMethodEdDSA.Alg()}, + TrustSource: StaticTrustSource{Trust: TrustSnapshot{Keys: []clients.LocalKey{ + {KeyID: testAgentKeyID, Key: agentPublic}, + }}}, + }, + IdentityPolicy: identitypolicy.Policy{ + Mode: identitypolicy.ModeRequired, + SetMode: identitypolicy.SetModeExact, + Require: identitypolicy.Requirements{L3: true, L4: true, L5: true, L6: true}, + Expected: values, + }, + Attestation: SignedAttestationPolicy{ + TrustedKeys: map[string]ed25519.PublicKey{testAttesterKeyID: attesterPublic}, + PolicyID: testAttestationPolicyID, + AllowedMeasurements: []string{testMeasurement}, + MaxAge: 2 * time.Minute, + ClockSkew: 5 * time.Second, + }, + ReplayCache: replay, + Now: func() time.Time { return now }, + } + return &profileFixture{ + profile: profile, + request: VerifyRequest{GrantJWT: grant, SessionBindingJWT: binding, ExpectedBinding: expectedBinding, Attestation: attestation}, + managerPrivate: managerPrivate, + agentPrivate: agentPrivate, + attesterPrivate: attesterPrivate, + replay: replay, + now: now, + } +} + +func signJWT(t *testing.T, key ed25519.PrivateKey, keyID string, claims jwt.MapClaims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) + token.Header["kid"] = keyID + value, err := token.SignedString(key) + if err != nil { + t.Fatalf("sign JWT: %v", err) + } + return value +} + +func signAttestation(t *testing.T, result *AttestationResult, key ed25519.PrivateKey) { + t.Helper() + result.Signature = nil + payload, err := result.SigningBytes() + if err != nil { + t.Fatalf("attestation signing bytes: %v", err) + } + result.Signature = ed25519.Sign(key, payload) +} + +func testHash(value string) string { + digest := sha256.Sum256([]byte(value)) + return "sha256:" + hex.EncodeToString(digest[:]) +} diff --git a/pkg/production/redis.go b/pkg/production/redis.go new file mode 100644 index 00000000..cd1fcf88 --- /dev/null +++ b/pkg/production/redis.go @@ -0,0 +1,196 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +package production + +import ( + "bufio" + "context" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "time" +) + +var ( + ErrInvalidRedisConfig = errors.New("production: invalid redis replay configuration") + ErrRedisProtocol = errors.New("production: redis replay protocol error") +) + +// RedisSetNXStore implements identitypolicy.SetNXStore with Redis or Valkey +// SET key value NX PX ttl over TLS. It opens one bounded connection per replay +// commit so no failed connection or authentication state is reused. +type RedisSetNXStore struct { + Address string + Username string + Password string + KeyPrefix string + TLSConfig *tls.Config + Dialer *net.Dialer + OperationTimeout time.Duration +} + +// SetNX atomically records a SHA-256-derived replay key until its TTL expires. +func (s RedisSetNXStore) SetNX(ctx context.Context, key string, ttl time.Duration) (bool, error) { + if ctx == nil { + return false, ErrMissingContext + } + if err := s.validate(); err != nil { + return false, err + } + if key == "" || ttl <= 0 { + return false, ErrInvalidRedisConfig + } + operationCtx, cancel := context.WithTimeout(ctx, s.OperationTimeout) + defer cancel() + ctx = operationCtx + + dialer := s.Dialer + if dialer == nil { + dialer = &net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second} + } + raw, err := dialer.DialContext(ctx, "tcp", s.Address) + if err != nil { + return false, fmt.Errorf("redis replay dial: %w", err) + } + defer raw.Close() + + tlsConfig := s.TLSConfig.Clone() + conn := tls.Client(raw, tlsConfig) + if err := conn.HandshakeContext(ctx); err != nil { + return false, fmt.Errorf("redis replay TLS: %w", err) + } + if deadline, ok := ctx.Deadline(); ok { + if err := conn.SetDeadline(deadline); err != nil { + return false, fmt.Errorf("redis replay deadline: %w", err) + } + } + reader := bufio.NewReader(conn) + + if s.Password != "" { + auth := []string{"AUTH", s.Password} + if s.Username != "" { + auth = []string{"AUTH", s.Username, s.Password} + } + if err := writeRESPArray(conn, auth); err != nil { + return false, fmt.Errorf("redis replay AUTH: %w", err) + } + kind, value, err := readRESP(reader) + if err != nil { + return false, fmt.Errorf("redis replay AUTH: %w", err) + } + if kind != '+' || value != "OK" { + return false, fmt.Errorf("%w: AUTH rejected", ErrRedisProtocol) + } + } + + digest := sha256.Sum256([]byte(key)) + redisKey := s.KeyPrefix + hex.EncodeToString(digest[:]) + millis := ttl.Milliseconds() + if millis < 1 { + millis = 1 + } + command := []string{"SET", redisKey, "1", "NX", "PX", strconv.FormatInt(millis, 10)} + if err := writeRESPArray(conn, command); err != nil { + return false, fmt.Errorf("redis replay SET: %w", err) + } + kind, value, err := readRESP(reader) + if err != nil { + return false, fmt.Errorf("redis replay SET: %w", err) + } + switch { + case kind == '+' && value == "OK": + return true, nil + case kind == '$' && value == "": + return false, nil + default: + return false, fmt.Errorf("%w: unexpected SET response", ErrRedisProtocol) + } +} + +func (s RedisSetNXStore) validate() error { + if strings.TrimSpace(s.Address) == "" || strings.TrimSpace(s.KeyPrefix) == "" || s.OperationTimeout <= 0 { + return ErrInvalidRedisConfig + } + if s.Username != "" && s.Password == "" { + return ErrInvalidRedisConfig + } + if s.TLSConfig == nil || s.TLSConfig.InsecureSkipVerify || s.TLSConfig.ServerName == "" { + return ErrInvalidRedisConfig + } + if s.TLSConfig.MinVersion < tls.VersionTLS13 { + return ErrInvalidRedisConfig + } + return nil +} + +func writeRESPArray(w io.Writer, values []string) error { + if len(values) == 0 { + return ErrRedisProtocol + } + var builder strings.Builder + builder.WriteByte('*') + builder.WriteString(strconv.Itoa(len(values))) + builder.WriteString("\r\n") + for _, value := range values { + builder.WriteByte('$') + builder.WriteString(strconv.Itoa(len(value))) + builder.WriteString("\r\n") + builder.WriteString(value) + builder.WriteString("\r\n") + } + _, err := io.WriteString(w, builder.String()) + return err +} + +func readRESP(r *bufio.Reader) (byte, string, error) { + prefix, err := r.ReadByte() + if err != nil { + return 0, "", err + } + line, err := readRESPLine(r) + if err != nil { + return 0, "", err + } + switch prefix { + case '+': + return prefix, line, nil + case '-': + return 0, "", fmt.Errorf("%w: server error", ErrRedisProtocol) + case '$': + length, err := strconv.Atoi(line) + if err != nil || length < -1 || length > 4096 { + return 0, "", ErrRedisProtocol + } + if length == -1 { + return '$', "", nil + } + payload := make([]byte, length+2) + if _, err := io.ReadFull(r, payload); err != nil { + return 0, "", err + } + if payload[length] != '\r' || payload[length+1] != '\n' { + return 0, "", ErrRedisProtocol + } + return '$', string(payload[:length]), nil + default: + return 0, "", ErrRedisProtocol + } +} + +func readRESPLine(r *bufio.Reader) (string, error) { + line, err := r.ReadString('\n') + if err != nil { + return "", err + } + if len(line) < 2 || !strings.HasSuffix(line, "\r\n") || len(line) > 4096 { + return "", ErrRedisProtocol + } + return strings.TrimSuffix(line, "\r\n"), nil +} diff --git a/pkg/production/redis_test.go b/pkg/production/redis_test.go new file mode 100644 index 00000000..054d7bb5 --- /dev/null +++ b/pkg/production/redis_test.go @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ToppyMicroServices OÜ +// SPDX-License-Identifier: Apache-2.0 + +package production + +import ( + "bufio" + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestRedisSetNXStoreCommitsOneWinnerOverTLS(t *testing.T) { + t.Parallel() + address, clientTLS, stop := startTestRedisTLS(t) + t.Cleanup(stop) + + store := RedisSetNXStore{ + Address: address, + KeyPrefix: "asb:replay:v1:", + TLSConfig: clientTLS, + OperationTimeout: 5 * time.Second, + } + + const workers = 20 + var winners atomic.Int32 + var wg sync.WaitGroup + errCh := make(chan error, workers) + for range workers { + wg.Add(1) + go func() { + defer wg.Done() + ok, err := store.SetNX(context.Background(), "sensitive\x00replay\x00material", time.Minute) + if err != nil { + errCh <- err + return + } + if ok { + winners.Add(1) + } + }() + } + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("SetNX() error = %v", err) + } + if got := winners.Load(); got != 1 { + t.Fatalf("SetNX() winners = %d, want 1", got) + } +} + +func TestRedisSetNXStoreRejectsUnsafeConfiguration(t *testing.T) { + t.Parallel() + if _, err := (RedisSetNXStore{}).SetNX(nil, "key", time.Minute); !errors.Is(err, ErrMissingContext) { + t.Fatalf("SetNX(nil) error = %v, want %v", err, ErrMissingContext) + } + tests := []RedisSetNXStore{ + {}, + {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}}, + } + for i, store := range tests { + if _, err := store.SetNX(context.Background(), "key", time.Minute); !errors.Is(err, ErrInvalidRedisConfig) { + t.Fatalf("case %d: SetNX() error = %v, want %v", i, err, ErrInvalidRedisConfig) + } + } +} + +func startTestRedisTLS(t *testing.T) (string, *tls.Config, func()) { + t.Helper() + certificate, roots := testRedisCertificate(t) + listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS13, + }) + if err != nil { + t.Fatalf("listen: %v", err) + } + + server := &testRedisServer{ + listener: listener, + seen: make(map[string]struct{}), + done: make(chan struct{}), + } + go server.serve() + stop := func() { + _ = listener.Close() + <-server.done + server.mu.Lock() + defer server.mu.Unlock() + if server.err != nil { + t.Errorf("test Redis server: %v", server.err) + } + } + return listener.Addr().String(), &tls.Config{ + RootCAs: roots, + ServerName: "redis.test", + MinVersion: tls.VersionTLS13, + }, stop +} + +type testRedisServer struct { + listener net.Listener + done chan struct{} + mu sync.Mutex + seen map[string]struct{} + err error + wg sync.WaitGroup +} + +func (s *testRedisServer) serve() { + defer close(s.done) + for { + conn, err := s.listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + break + } + s.recordError(err) + break + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + defer conn.Close() + if err := s.handle(conn); err != nil { + s.recordError(err) + } + }() + } + s.wg.Wait() +} + +func (s *testRedisServer) handle(conn net.Conn) error { + command, err := readTestRESPArray(bufio.NewReader(conn)) + if err != nil { + return err + } + if len(command) != 6 || command[0] != "SET" || command[2] != "1" || command[3] != "NX" || command[4] != "PX" { + return fmt.Errorf("unexpected command: %q", command) + } + if !strings.HasPrefix(command[1], "asb:replay:v1:") || strings.Contains(command[1], "sensitive") { + return fmt.Errorf("unsafe Redis key: %q", command[1]) + } + if _, err := strconv.ParseInt(command[5], 10, 64); err != nil { + return fmt.Errorf("invalid TTL: %w", err) + } + + s.mu.Lock() + _, exists := s.seen[command[1]] + if !exists { + s.seen[command[1]] = struct{}{} + } + s.mu.Unlock() + if exists { + _, err = io.WriteString(conn, "$-1\r\n") + } else { + _, err = io.WriteString(conn, "+OK\r\n") + } + return err +} + +func (s *testRedisServer) recordError(err error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.err == nil { + s.err = err + } +} + +func readTestRESPArray(r *bufio.Reader) ([]string, error) { + line, err := r.ReadString('\n') + if err != nil { + return nil, err + } + if len(line) < 4 || line[0] != '*' || !strings.HasSuffix(line, "\r\n") { + return nil, errors.New("invalid array") + } + count, err := strconv.Atoi(strings.TrimSuffix(line[1:], "\r\n")) + if err != nil || count < 1 || count > 16 { + return nil, errors.New("invalid array count") + } + out := make([]string, count) + for i := range count { + lengthLine, err := r.ReadString('\n') + if err != nil { + return nil, err + } + if len(lengthLine) < 4 || lengthLine[0] != '$' || !strings.HasSuffix(lengthLine, "\r\n") { + return nil, errors.New("invalid bulk string") + } + length, err := strconv.Atoi(strings.TrimSuffix(lengthLine[1:], "\r\n")) + if err != nil || length < 0 || length > 4096 { + return nil, errors.New("invalid bulk string length") + } + value := make([]byte, length+2) + if _, err := io.ReadFull(r, value); err != nil { + return nil, err + } + if value[length] != '\r' || value[length+1] != '\n' { + return nil, errors.New("invalid bulk string terminator") + } + out[i] = string(value[:length]) + } + return out, nil +} + +func testRedisCertificate(t *testing.T) (tls.Certificate, *x509.CertPool) { + t.Helper() + now := time.Now() + caPublic, caPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + caTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "ASB test Redis CA"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, caPublic, caPrivate) + if err != nil { + t.Fatal(err) + } + ca, err := x509.ParseCertificate(caDER) + if err != nil { + t.Fatal(err) + } + + serverPublic, serverPrivate, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + serverTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "redis.test"}, + DNSNames: []string{"redis.test"}, + NotBefore: now.Add(-time.Minute), + NotAfter: now.Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + serverDER, err := x509.CreateCertificate(rand.Reader, serverTemplate, ca, serverPublic, caPrivate) + if err != nil { + t.Fatal(err) + } + + serverCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverDER}) + serverKeyDER, err := x509.MarshalPKCS8PrivateKey(serverPrivate) + if err != nil { + t.Fatal(err) + } + serverKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: serverKeyDER}) + certificate, err := tls.X509KeyPair(serverCertPEM, serverKeyPEM) + if err != nil { + t.Fatal(err) + } + roots := x509.NewCertPool() + roots.AddCert(ca) + return certificate, roots +}