diff --git a/pkg/tbtc/signer/README.md b/pkg/tbtc/signer/README.md index 49e6450953..989da8baa8 100644 --- a/pkg/tbtc/signer/README.md +++ b/pkg/tbtc/signer/README.md @@ -106,6 +106,211 @@ Sample input schemas are provided in: - `pkg/tbtc/signer/scripts/admission-override.sample.json` - `pkg/tbtc/signer/scripts/admission-override-registry.sample.json` +## TEE Governance Registry Checker (Phase A) + +Run the governance registry validator for TEE-required signer policy artifacts: + +```bash +cd pkg/tbtc/signer +cargo run --bin tee_registry_checker -- \ + --registry scripts/tee-governance-registry-v1.sample.json \ + --events scripts/tee-governance-audit-events-v1.sample.json \ + --now-unix 1700100000 +``` + +Flags: + +- `--registry ` (required): path to governance registry JSON +- `--events ` (optional): path to governance audit events JSON +- `--now-unix ` (optional): override current Unix timestamp for + deterministic validation (useful for CI and testing) + +Exit codes: + +- `0`: registry/events satisfy Phase A schema and workflow checks +- `1`: policy or workflow violations were detected (see JSON reason codes) +- `2`: checker input/config error + +Output schema (exit codes 0 and 1): + +```json +{ + "decision": "allow | reject", + "reasons": [{ "code": "reason_code", "detail": "human-readable detail" }], + "validated_at_unix": 1700100000 +} +``` + +Sample input schemas are provided in: + +- `pkg/tbtc/signer/scripts/tee-governance-registry-v1.sample.json` +- `pkg/tbtc/signer/scripts/tee-governance-audit-events-v1.sample.json` + +## TEE Admission Token Checker (Phase B) + +Run the verifier/token checker for threshold-signed admission tokens: + +```bash +cd pkg/tbtc/signer +cargo run --bin tee_token_checker -- \ + --registry scripts/tee-governance-registry-v1.sample.json \ + --keyset scripts/tee-verifier-keyset-v1.sample.json \ + --token scripts/tee-admission-token.sample.json \ + --revocation-registry scripts/tee-token-revocation-registry-v1.sample.json \ + --now-unix 1700100000 +``` + +Flags: + +- `--registry ` (required): governance registry JSON +- `--keyset ` (required): verifier keyset JSON (`m-of-n` threshold config) +- `--token ` (required): admission token artifact JSON (`payload_json` + signatures) +- `--revocation-registry ` (required): token/key revocation registry JSON +- `--now-unix ` (optional): deterministic time override for CI/testing + +The checker requires `profile_status = mandatory` in the governance registry. +Token signatures cover the exact `payload_json` byte string in the token +artifact. Do not deserialize and reserialize that value before signature +verification. The token checker enforces a two-trust-root and +two-verifier-instance floor for quorum diversity; broader vendor concentration +limits are enforced by the runtime checker. + +Exit codes: + +- `0`: token satisfies Phase B checks +- `1`: token/keyset/revocation violations detected (see JSON reason codes) +- `2`: checker input/config error + +Output schema (exit codes 0 and 1): + +```json +{ + "decision": "allow | reject", + "reasons": [{ "code": "reason_code", "detail": "human-readable detail" }], + "validated_at_unix": 1700100000 +} +``` + +Sample input schemas are provided in: + +- `pkg/tbtc/signer/scripts/tee-verifier-keyset-v1.sample.json` +- `pkg/tbtc/signer/scripts/tee-admission-token.sample.json` +- `pkg/tbtc/signer/scripts/tee-token-revocation-registry-v1.sample.json` + +`tee-admission-token.sample.json` and `tee-verifier-keyset-v1.sample.json` are +schema-only examples and require real verifier keys/signatures to pass. + +## TEE Runtime Enforcement Checker (Phase C) + +Run the runtime selection/session checker for attestation-token and denylist +enforcement: + +```bash +cd pkg/tbtc/signer +cargo run --bin tee_runtime_checker -- \ + --registry scripts/tee-runtime-governance-registry-v1.sample.json \ + --session scripts/tee-runtime-session-start-v1.sample.json \ + --now-unix 1700100000 +``` + +Flags: + +- `--registry ` (required): governance registry JSON for runtime checks +- `--session ` (required): runtime session/cohort snapshot JSON +- `--now-unix ` (optional): deterministic time override for CI/testing + +Runtime session phase values: + +- `session_start`: strict token validity required +- `mid_session`: expired tokens tolerated only within `grace_period_seconds` + +Hard safety ceilings enforced by the checker: + +- `grace_period_seconds <= 3600` +- `attestation_max_age_seconds <= 86400` +- `denylist_max_staleness_seconds` in `1..=300` +- `max_single_vendor_share_percent` in `1..=60` + +Exit codes: + +- `0`: runtime session satisfies Phase C checks +- `1`: runtime policy violations detected (see JSON reason codes) +- `2`: checker input/config error + +Output schema (exit codes 0 and 1): + +```json +{ + "decision": "allow | reject", + "reasons": [{ "code": "reason_code", "detail": "human-readable detail" }], + "validated_at_unix": 1700100000 +} +``` + +Sample input schemas are provided in: + +- `pkg/tbtc/signer/scripts/tee-runtime-governance-registry-v1.sample.json` +- `pkg/tbtc/signer/scripts/tee-runtime-session-start-v1.sample.json` +- `pkg/tbtc/signer/scripts/tee-runtime-session-mid-session-grace-v1.sample.json` +- `pkg/tbtc/signer/scripts/tee-runtime-session-vendor-outage-v1.sample.json` + +## TEE Phase D Enforcement Checker (Phase D) + +Run the Phase D mode/waiver checker that applies canary and full-enforcement +policy over a Phase C runtime decision: + +```bash +cd pkg/tbtc/signer +cargo run --bin tee_enforcement_checker -- \ + --registry scripts/tee-governance-registry-v1.sample.json \ + --context scripts/tee-enforcement-context-monitor-v1.sample.json \ + --now-unix 1700100000 +``` + +Flags: + +- `--registry ` (required): governance registry JSON with break-glass policy +- `--context ` (required): Phase D session context JSON +- `--now-unix ` (optional): deterministic time override for CI/testing + +Supported `enforcement_mode` values: + +- `monitor_only`: record runtime violations without blocking +- `soft_enforcement`: warnings + exclusion preference without blocking +- `hard_enforcement_canary`: blocking applies to canary sessions only +- `full_enforcement`: blocking applies to all sessions + +Break-glass enforcement controls: + +- scope coverage for selected operators +- required quorum (`break_glass_quorum_bps`) +- waiver TTL bounded by policy (`break_glass_ttl_seconds`) and hard maximum (7 days) +- activation cap per 7 days (`break_glass_max_activations_per_7d`) for new activations +- minimum cooldown (`break_glass_cooldown_seconds`) for new activations +- reused incident tickets bounded by their history-derived activation time and TTL + +Exit codes: + +- `0`: `allow` or `allow_with_warnings` +- `1`: `reject` +- `2`: checker input/config error + +Output schema (exit codes 0 and 1): + +```json +{ + "decision": "allow | allow_with_warnings | reject", + "reasons": [{ "code": "reason_code", "detail": "human-readable detail" }], + "validated_at_unix": 1700100000 +} +``` + +Sample input schemas are provided in: + +- `pkg/tbtc/signer/scripts/tee-enforcement-context-monitor-v1.sample.json` +- `pkg/tbtc/signer/scripts/tee-enforcement-context-hard-canary-v1.sample.json` +- `pkg/tbtc/signer/scripts/tee-enforcement-context-full-break-glass-v1.sample.json` + ## Encrypted State Key Providers Signer state persistence is encrypted at rest. Key-provider behavior is controlled diff --git a/pkg/tbtc/signer/docs/tee-whitelisted-signer-activation-gate-record.md b/pkg/tbtc/signer/docs/tee-whitelisted-signer-activation-gate-record.md new file mode 100644 index 0000000000..56dc46bf4a --- /dev/null +++ b/pkg/tbtc/signer/docs/tee-whitelisted-signer-activation-gate-record.md @@ -0,0 +1,65 @@ +# TEE Hardening Activation Gate Record + +Date: `TBD` +Status: `PENDING` +Owner: `Threshold Labs + DAO Governance` +Related plan: +`docs/frost-migration/tee-whitelisted-signer-enforcement-plan.md` + +## 1. Gate Statement + +This record approves (or rejects) transitioning the TEE hardening profile +status from `draft` to `mandatory`. + +## 2. Governance Decision Metadata + +- Governance proposal/decision ID: `TBD` +- Effective timestamp (UTC): `TBD` +- Quorum denominator: `TBD` +- Achieved quorum: `TBD` +- Required quorum: `>= 67.00%` (`activation_gate_required_quorum_bps=6700`) + +## 3. Preconditions + +- [ ] Validation scenarios in Section 11 of the TEE plan are complete. +- [ ] No unresolved CRITICAL/HIGH findings remain in attestation path. +- [ ] Incident runbook simulation is complete. +- [ ] Policy and measurements are approved by DAO governance process. + +## 3.1 Readiness Review Summary + +The integrated TEE readiness stack completed a final merge-readiness review on +2026-03-03 with recommendation `READY` for the scaffold branch. The review +recorded: + +- all eight prior Phase D enforcement-mode blockers resolved, +- no remaining merge blockers, +- Phase D unit coverage passing (`24/24`), +- Phase A/B/C regression suites passing (`29/29`, `24/24`, `23/23`), +- sample enforcement commands runnable for monitor, hard-canary, and + full-enforcement break-glass contexts. + +Remaining review notes are non-blocking future hardening items for production +activation, including additional break-glass edge-case tests, structural input +validation cases, and stricter duplicate-history semantics. These do not replace +the governance preconditions above. + +## 4. Approval Record + +| Reviewer | Role | Decision | Date (UTC) | Notes | +| --- | --- | --- | --- | --- | +| `UNASSIGNED` | security owner | `PENDING` | `TBD` | | +| `UNASSIGNED` | signer/runtime owner | `PENDING` | `TBD` | | +| `UNASSIGNED` | governance delegate | `PENDING` | `TBD` | | + +## 5. Transition Decision + +- `profile_status_transition`: `draft -> mandatory` / `REJECTED` +- Scope: `TBD` +- Activation start (UTC): `TBD` + +## 6. Rollback Controls + +- Rollback authority: `TBD` +- Rollback trigger conditions: `TBD` +- Rollback execution SLA: `TBD` diff --git a/pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md b/pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md index f5df4379f8..0a5cbdc7d9 100644 --- a/pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md +++ b/pkg/tbtc/signer/docs/tee-whitelisted-signer-enforcement-plan.md @@ -72,7 +72,8 @@ Each signer admission record should include: | `break_glass_max_activations_per_7d` | `2` | prevent break-glass chaining abuse | | `break_glass_cooldown_seconds` | `86400` | 24-hour cooldown between activations | | `break_glass_scope` | `named_operator_ids_only` | no global suspension in default policy | -| `break_glass_quorum_bps` | `6700` | supermajority quorum for activation | +| `break_glass_quorum_bps` | `6700` | supermajority quorum for emergency break-glass actions | +| `activation_gate_required_quorum_bps` | `6700` | independent quorum threshold for `draft -> mandatory` activation gate; hard floor of 6700 bps enforced by checker | | `re_attestation_poll_interval_seconds` | `300` | signer refresh cadence | Values should be tuned with canary data and incident drills. @@ -239,6 +240,56 @@ Requirements: 4. full enforcement after gate pass 5. enforce break-glass abuse controls (activation caps + cooldown + scope) +### 10.2 Phase A Scaffold Artifacts + +Initial Phase A schema/workflow checks are implemented in: + +1. `tools/tbtc-signer/src/bin/tee_registry_checker.rs` +2. `tools/tbtc-signer/scripts/tee-governance-registry-v1.sample.json` +3. `tools/tbtc-signer/scripts/tee-governance-audit-events-v1.sample.json` + +### 10.3 Phase B Scaffold Artifacts + +Initial Phase B verifier/token checks are implemented in: + +1. `tools/tbtc-signer/src/bin/tee_token_checker.rs` +2. `tools/tbtc-signer/scripts/tee-verifier-keyset-v1.sample.json` +3. `tools/tbtc-signer/scripts/tee-admission-token.sample.json` +4. `tools/tbtc-signer/scripts/tee-token-revocation-registry-v1.sample.json` + +### 10.4 Phase C Scaffold Artifacts + +Initial Phase C runtime selection/session checks are implemented in: + +1. `tools/tbtc-signer/src/bin/tee_runtime_checker.rs` +2. `tools/tbtc-signer/scripts/tee-runtime-governance-registry-v1.sample.json` +3. `tools/tbtc-signer/scripts/tee-runtime-session-start-v1.sample.json` +4. `tools/tbtc-signer/scripts/tee-runtime-session-mid-session-grace-v1.sample.json` +5. `tools/tbtc-signer/scripts/tee-runtime-session-vendor-outage-v1.sample.json` + +### 10.5 Phase D Scaffold Artifacts + +Initial Phase D canary/hard-enforcement checks are implemented in: + +1. `tools/tbtc-signer/src/bin/tee_enforcement_checker.rs` +2. `tools/tbtc-signer/scripts/tee-enforcement-context-monitor-v1.sample.json` +3. `tools/tbtc-signer/scripts/tee-enforcement-context-hard-canary-v1.sample.json` +4. `tools/tbtc-signer/scripts/tee-enforcement-context-full-break-glass-v1.sample.json` + +Phase D final readiness outcome: + +- final review recommendation: `READY` for the scaffold branch, +- prior Phase D enforcement-mode blockers resolved: `8/8`, +- merge blockers remaining: `0`, +- Phase D unit tests passing: `24/24`, +- Phase A/B/C regression tests passing: `29/29`, `24/24`, `23/23`, +- sample enforcement commands verified for monitor-only, hard-canary, and + full-enforcement break-glass contexts. + +Non-blocking future hardening items remain for additional break-glass edge +cases, structural input validation, duplicate-history behavior, and +`serde(deny_unknown_fields)` policy consistency. + ### 10.1 Mapping To ROAST Phase 5 Stages 1. ROAST Stage 1 (5% canary) requires TEE Phase C completed and TEE Phase D in @@ -273,6 +324,8 @@ Before hard enforcement in production: 4. policy and measurements approved by DAO governance process 5. activation gate approved in governance record: - profile status transitions from `draft` to `mandatory` + - approval artifact: + `docs/frost-migration/tee-whitelisted-signer-activation-gate-record.md` ### 12.1 Activation Gate Record Requirements @@ -295,6 +348,7 @@ This plan is linked from: 1. `docs/frost-migration/roast-phase-5-security-rollout-gates.md` 2. `docs/frost-migration/roast-phase-5-rollout-runbook.md` +3. `docs/frost-migration/roast-phase-5-human-signoff-packet.md` as a future mandatory TEE hardening profile for permissioned operator deployments once Section 12 activation gate is approved. diff --git a/pkg/tbtc/signer/scripts/tee-admission-token.sample.json b/pkg/tbtc/signer/scripts/tee-admission-token.sample.json new file mode 100644 index 0000000000..f223a4cf6b --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-admission-token.sample.json @@ -0,0 +1,13 @@ +{ + "payload_json": "{\"token_id\":\"token-operator-1-0001\",\"operator_id\":\"operator-1\",\"signer_identifier\":\"signer-1\",\"tee_type\":\"sgx\",\"measurement_digest\":\"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"issued_at_unix\":1700100000,\"expires_at_unix\":1700100300,\"registry_snapshot_version\":1,\"verifier_key_ids\":[\"verifier-key-1\",\"verifier-key-2\"],\"token_revocation_epoch\":5}", + "signatures": [ + { + "verifier_key_id": "verifier-key-1", + "signature_hex": "REPLACE_WITH_SCHNORR_SIGNATURE_HEX_OVER_SHA256_OF_PAYLOAD_JSON" + }, + { + "verifier_key_id": "verifier-key-2", + "signature_hex": "REPLACE_WITH_SCHNORR_SIGNATURE_HEX_OVER_SHA256_OF_PAYLOAD_JSON" + } + ] +} diff --git a/pkg/tbtc/signer/scripts/tee-enforcement-context-full-break-glass-v1.sample.json b/pkg/tbtc/signer/scripts/tee-enforcement-context-full-break-glass-v1.sample.json new file mode 100644 index 0000000000..544369047f --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-enforcement-context-full-break-glass-v1.sample.json @@ -0,0 +1,30 @@ +{ + "session_id": "phase-d-full-enforcement-session-1", + "canary_session": true, + "selected_operator_ids": ["operator-aws-us-east-1-1", "operator-gcp-europe-west1-1"], + "runtime_decision": { + "decision": "reject", + "reasons": [ + { + "code": "selected_token_expired_for_session_start", + "detail": "selected token expired before session start" + } + ], + "validated_at_unix": 1700100000 + }, + "enforcement_mode": "full_enforcement", + "break_glass": { + "incident_ticket": "INC-2026-03-03-001", + "declared_at_unix": 1700099000, + "expires_at_unix": 1700107200, + "approver_quorum_bps": 7100, + "scope_operator_ids": ["operator-aws-us-east-1-1", "operator-gcp-europe-west1-1"], + "is_new_activation": true + }, + "break_glass_history": [ + { + "incident_ticket": "INC-2026-02-20-001", + "activated_at_unix": 1699200000 + } + ] +} diff --git a/pkg/tbtc/signer/scripts/tee-enforcement-context-hard-canary-v1.sample.json b/pkg/tbtc/signer/scripts/tee-enforcement-context-hard-canary-v1.sample.json new file mode 100644 index 0000000000..b8c78f6b62 --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-enforcement-context-hard-canary-v1.sample.json @@ -0,0 +1,12 @@ +{ + "session_id": "phase-d-hard-canary-session-1", + "canary_session": true, + "selected_operator_ids": ["operator-aws-us-east-1-1", "operator-gcp-europe-west1-1"], + "runtime_decision": { + "decision": "allow", + "reasons": [], + "validated_at_unix": 1700100000 + }, + "enforcement_mode": "hard_enforcement_canary", + "break_glass_history": [] +} diff --git a/pkg/tbtc/signer/scripts/tee-enforcement-context-monitor-v1.sample.json b/pkg/tbtc/signer/scripts/tee-enforcement-context-monitor-v1.sample.json new file mode 100644 index 0000000000..e9a122d79d --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-enforcement-context-monitor-v1.sample.json @@ -0,0 +1,17 @@ +{ + "session_id": "phase-d-monitor-session-1", + "canary_session": true, + "selected_operator_ids": ["operator-aws-us-east-1-1", "operator-gcp-europe-west1-1"], + "runtime_decision": { + "decision": "reject", + "reasons": [ + { + "code": "vendor_diversity_cap_exceeded", + "detail": "vendor [vendor-a] share [50%] exceeds cap [40%]" + } + ], + "validated_at_unix": 1700100000 + }, + "enforcement_mode": "monitor_only", + "break_glass_history": [] +} diff --git a/pkg/tbtc/signer/scripts/tee-governance-audit-events-v1.sample.json b/pkg/tbtc/signer/scripts/tee-governance-audit-events-v1.sample.json new file mode 100644 index 0000000000..489a2964a6 --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-governance-audit-events-v1.sample.json @@ -0,0 +1,66 @@ +{ + "events": [ + { + "event_id": "evt-001", + "event_type": "add", + "operator_id": "operator-aws-us-east-1-1", + "signer_identifier": "signer-aws-us-east-1-1", + "governance_decision_id": "dao-proposal-tee-add-001", + "effective_at_unix": 1700000010 + }, + { + "event_id": "evt-002", + "event_type": "add", + "operator_id": "operator-gcp-europe-west1-1", + "signer_identifier": "signer-gcp-europe-west1-1", + "governance_decision_id": "dao-proposal-tee-add-002", + "effective_at_unix": 1700000020 + }, + { + "event_id": "evt-003", + "event_type": "add", + "operator_id": "operator-azure-eastus-1", + "signer_identifier": "signer-azure-eastus-1", + "governance_decision_id": "dao-proposal-tee-add-003", + "effective_at_unix": 1700000030 + }, + { + "event_id": "evt-004", + "event_type": "suspend", + "operator_id": "operator-gcp-europe-west1-1", + "governance_decision_id": "dao-proposal-tee-suspend-001", + "effective_at_unix": 1700000100 + }, + { + "event_id": "evt-005", + "event_type": "revoke", + "operator_id": "operator-azure-eastus-1", + "governance_decision_id": "dao-proposal-tee-revoke-001", + "effective_at_unix": 1700000200 + }, + { + "event_id": "evt-006", + "event_type": "measurement_update", + "operator_id": "operator-aws-us-east-1-1", + "measurement_digest": "sha256:aaaaaaaabbbbbbbbccccccccddddddddeeeeeeeeffffffff0000000011111111", + "governance_decision_id": "dao-proposal-tee-measurement-001", + "effective_at_unix": 1700000300 + }, + { + "event_id": "evt-007", + "event_type": "break_glass_activate", + "incident_ticket": "INC-TEE-100", + "scope_operator_ids": ["operator-gcp-europe-west1-1"], + "expires_at_unix": 1700004000, + "governance_decision_id": "dao-proposal-tee-break-glass-activate-001", + "effective_at_unix": 1700000400 + }, + { + "event_id": "evt-008", + "event_type": "break_glass_expire", + "incident_ticket": "INC-TEE-100", + "governance_decision_id": "dao-proposal-tee-break-glass-expire-001", + "effective_at_unix": 1700001000 + } + ] +} diff --git a/pkg/tbtc/signer/scripts/tee-governance-registry-v1.sample.json b/pkg/tbtc/signer/scripts/tee-governance-registry-v1.sample.json new file mode 100644 index 0000000000..cd5ada2db9 --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-governance-registry-v1.sample.json @@ -0,0 +1,85 @@ +{ + "profile_status": "mandatory", + "enforcement": { + "attestation_max_age_seconds": 3600, + "grace_period_seconds": 900, + "min_attested_signers_per_cohort": 4, + "max_single_vendor_share_percent": 40, + "denylist_max_staleness_seconds": 60, + "break_glass_ttl_seconds": 21600, + "break_glass_max_activations_per_7d": 2, + "break_glass_cooldown_seconds": 86400, + "break_glass_scope": "named_operator_ids_only", + "break_glass_quorum_bps": 6700, + "activation_gate_required_quorum_bps": 6700, + "re_attestation_poll_interval_seconds": 300 + }, + "operators": [ + { + "operator_id": "operator-aws-us-east-1-1", + "signer_identifier": "signer-aws-us-east-1-1", + "status": "active", + "allowed_tee_types": ["sgx", "tdx"], + "allowed_measurements": [ + "sha256:1111111111111111111111111111111111111111111111111111111111111111" + ], + "attestation_max_age_seconds": 3600, + "grace_period_seconds": 900, + "effective_from": 1700000010 + }, + { + "operator_id": "operator-gcp-europe-west1-1", + "signer_identifier": "signer-gcp-europe-west1-1", + "status": "suspended", + "allowed_tee_types": ["sev-snp"], + "allowed_measurements": [ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + ], + "attestation_max_age_seconds": 3600, + "grace_period_seconds": 900, + "effective_from": 1700000020 + }, + { + "operator_id": "operator-azure-eastus-1", + "signer_identifier": "signer-azure-eastus-1", + "status": "revoked", + "allowed_tee_types": ["sgx"], + "allowed_measurements": [ + "sha256:3333333333333333333333333333333333333333333333333333333333333333" + ], + "attestation_max_age_seconds": 3600, + "grace_period_seconds": 900, + "effective_from": 1700000030, + "effective_until": 1700000500 + } + ], + "activation_gate": { + "governance_decision_id": "dao-proposal-tee-activation-001", + "effective_at_unix": 1700001000, + "quorum_denominator": 100000, + "achieved_quorum_bps": 7400, + "approvers": [ + { + "approver_id": "security-owner-1", + "role": "security_owner", + "decision": "approved", + "decided_at_unix": 1700000950 + }, + { + "approver_id": "runtime-owner-1", + "role": "signer_runtime_owner", + "decision": "approved", + "decided_at_unix": 1700000960 + }, + { + "approver_id": "delegate-1", + "role": "governance_delegate", + "decision": "approved", + "decided_at_unix": 1700000970 + } + ], + "profile_status_transition": "draft -> mandatory", + "rollback_condition": "critical verifier compromise", + "rollback_authority": "security-council-multisig" + } +} diff --git a/pkg/tbtc/signer/scripts/tee-runtime-governance-registry-v1.sample.json b/pkg/tbtc/signer/scripts/tee-runtime-governance-registry-v1.sample.json new file mode 100644 index 0000000000..0df88f9ebe --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-runtime-governance-registry-v1.sample.json @@ -0,0 +1,36 @@ +{ + "profile_status": "mandatory", + "enforcement": { + "attestation_max_age_seconds": 3600, + "grace_period_seconds": 900, + "min_attested_signers_per_cohort": 4, + "max_single_vendor_share_percent": 40, + "denylist_max_staleness_seconds": 60 + }, + "operators": [ + { + "operator_id": "operator-a", + "signer_identifier": "signer-a", + "status": "active", + "effective_from": 1700000000 + }, + { + "operator_id": "operator-b", + "signer_identifier": "signer-b", + "status": "active", + "effective_from": 1700000000 + }, + { + "operator_id": "operator-c", + "signer_identifier": "signer-c", + "status": "active", + "effective_from": 1700000000 + }, + { + "operator_id": "operator-d", + "signer_identifier": "signer-d", + "status": "active", + "effective_from": 1700000000 + } + ] +} diff --git a/pkg/tbtc/signer/scripts/tee-runtime-session-mid-session-grace-v1.sample.json b/pkg/tbtc/signer/scripts/tee-runtime-session-mid-session-grace-v1.sample.json new file mode 100644 index 0000000000..c914cf2108 --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-runtime-session-mid-session-grace-v1.sample.json @@ -0,0 +1,58 @@ +{ + "session_id": "session-runtime-mid-grace-1", + "phase": "mid_session", + "threshold": 3, + "selected_signers": [ + { + "operator_id": "operator-a", + "signer_identifier": "signer-a", + "vendor_id": "vendor-1", + "token": { + "token_id": "token-a-002", + "issued_at_unix": 1700098900, + "expires_at_unix": 1700099500, + "token_revocation_epoch": 7 + } + }, + { + "operator_id": "operator-b", + "signer_identifier": "signer-b", + "vendor_id": "vendor-2", + "token": { + "token_id": "token-b-002", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + }, + { + "operator_id": "operator-c", + "signer_identifier": "signer-c", + "vendor_id": "vendor-3", + "token": { + "token_id": "token-c-002", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + }, + { + "operator_id": "operator-d", + "signer_identifier": "signer-d", + "vendor_id": "vendor-4", + "token": { + "token_id": "token-d-002", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + } + ], + "denylist": { + "refreshed_at_unix": 1700099970, + "revoked_operator_ids": [], + "revoked_signer_identifiers": [], + "revoked_token_ids": [], + "min_token_revocation_epoch": 7 + } +} diff --git a/pkg/tbtc/signer/scripts/tee-runtime-session-start-v1.sample.json b/pkg/tbtc/signer/scripts/tee-runtime-session-start-v1.sample.json new file mode 100644 index 0000000000..24dff8216c --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-runtime-session-start-v1.sample.json @@ -0,0 +1,58 @@ +{ + "session_id": "session-runtime-start-1", + "phase": "session_start", + "threshold": 3, + "selected_signers": [ + { + "operator_id": "operator-a", + "signer_identifier": "signer-a", + "vendor_id": "vendor-1", + "token": { + "token_id": "token-a-001", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + }, + { + "operator_id": "operator-b", + "signer_identifier": "signer-b", + "vendor_id": "vendor-2", + "token": { + "token_id": "token-b-001", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + }, + { + "operator_id": "operator-c", + "signer_identifier": "signer-c", + "vendor_id": "vendor-3", + "token": { + "token_id": "token-c-001", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + }, + { + "operator_id": "operator-d", + "signer_identifier": "signer-d", + "vendor_id": "vendor-4", + "token": { + "token_id": "token-d-001", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + } + ], + "denylist": { + "refreshed_at_unix": 1700099970, + "revoked_operator_ids": [], + "revoked_signer_identifiers": [], + "revoked_token_ids": [], + "min_token_revocation_epoch": 7 + } +} diff --git a/pkg/tbtc/signer/scripts/tee-runtime-session-vendor-outage-v1.sample.json b/pkg/tbtc/signer/scripts/tee-runtime-session-vendor-outage-v1.sample.json new file mode 100644 index 0000000000..96d9ae89f3 --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-runtime-session-vendor-outage-v1.sample.json @@ -0,0 +1,64 @@ +{ + "session_id": "session-runtime-outage-1", + "phase": "session_start", + "threshold": 3, + "selected_signers": [ + { + "operator_id": "operator-a", + "signer_identifier": "signer-a", + "vendor_id": "vendor-1", + "token": { + "token_id": "token-a-003", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + }, + { + "operator_id": "operator-b", + "signer_identifier": "signer-b", + "vendor_id": "vendor-1", + "token": { + "token_id": "token-b-003", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + }, + { + "operator_id": "operator-c", + "signer_identifier": "signer-c", + "vendor_id": "vendor-2", + "token": { + "token_id": "token-c-003", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + }, + { + "operator_id": "operator-d", + "signer_identifier": "signer-d", + "vendor_id": "vendor-3", + "token": { + "token_id": "token-d-003", + "issued_at_unix": 1700099700, + "expires_at_unix": 1700100300, + "token_revocation_epoch": 7 + } + } + ], + "denylist": { + "refreshed_at_unix": 1700099970, + "revoked_operator_ids": [], + "revoked_signer_identifiers": [], + "revoked_token_ids": [], + "min_token_revocation_epoch": 7 + }, + "vendor_outage": { + "declared": true, + "declared_at_unix": 1700099500, + "relaxed_max_single_vendor_share_percent": 50, + "expires_at_unix": 1700115000 + } +} diff --git a/pkg/tbtc/signer/scripts/tee-token-revocation-registry-v1.sample.json b/pkg/tbtc/signer/scripts/tee-token-revocation-registry-v1.sample.json new file mode 100644 index 0000000000..2f9296c720 --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-token-revocation-registry-v1.sample.json @@ -0,0 +1,6 @@ +{ + "denylist_refreshed_at_unix": 1700100000, + "min_token_revocation_epoch": 0, + "revoked_token_ids": {}, + "revoked_verifier_key_ids": {} +} diff --git a/pkg/tbtc/signer/scripts/tee-verifier-keyset-v1.sample.json b/pkg/tbtc/signer/scripts/tee-verifier-keyset-v1.sample.json new file mode 100644 index 0000000000..1c0ea676d8 --- /dev/null +++ b/pkg/tbtc/signer/scripts/tee-verifier-keyset-v1.sample.json @@ -0,0 +1,31 @@ +{ + "keyset_version": 1, + "threshold_m": 2, + "max_key_age_seconds": 2592000, + "keys": [ + { + "key_id": "verifier-key-1", + "verifier_instance_id": "verifier-a", + "trust_root_id": "trust-root-a", + "pubkey_hex": "REPLACE_WITH_XONLY_PUBKEY_HEX", + "valid_from_unix": 1700000000, + "valid_until_unix": 1702592000 + }, + { + "key_id": "verifier-key-2", + "verifier_instance_id": "verifier-b", + "trust_root_id": "trust-root-b", + "pubkey_hex": "REPLACE_WITH_XONLY_PUBKEY_HEX", + "valid_from_unix": 1700000000, + "valid_until_unix": 1702592000 + }, + { + "key_id": "verifier-key-3", + "verifier_instance_id": "verifier-c", + "trust_root_id": "trust-root-c", + "pubkey_hex": "REPLACE_WITH_XONLY_PUBKEY_HEX", + "valid_from_unix": 1700000000, + "valid_until_unix": 1702592000 + } + ] +} diff --git a/pkg/tbtc/signer/src/bin/tee_enforcement_checker.rs b/pkg/tbtc/signer/src/bin/tee_enforcement_checker.rs new file mode 100644 index 0000000000..07bae09fc1 --- /dev/null +++ b/pkg/tbtc/signer/src/bin/tee_enforcement_checker.rs @@ -0,0 +1,1364 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SECONDS_PER_7_DAYS: u64 = 7 * 24 * 60 * 60; +const MAX_BREAK_GLASS_TTL_SECONDS: u64 = SECONDS_PER_7_DAYS; + +#[derive(Clone, Debug, Deserialize)] +struct TeeGovernanceRegistryV1 { + profile_status: String, + enforcement: TeeEnforcementParameters, +} + +#[derive(Clone, Debug, Deserialize)] +struct TeeEnforcementParameters { + break_glass_ttl_seconds: u64, + break_glass_max_activations_per_7d: u64, + break_glass_cooldown_seconds: u64, + break_glass_scope: String, + break_glass_quorum_bps: u64, +} + +#[derive(Clone, Debug, Deserialize)] +struct PhaseDEnforcementContextV1 { + session_id: String, + canary_session: bool, + selected_operator_ids: Vec, + runtime_decision: RuntimeDecisionSnapshot, + enforcement_mode: String, + #[serde(default)] + break_glass: Option, + #[serde(default)] + break_glass_history: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct RuntimeDecisionSnapshot { + decision: String, + #[serde(default)] + reasons: Vec, + validated_at_unix: u64, +} + +#[derive(Clone, Debug, Deserialize)] +struct BreakGlassActivation { + incident_ticket: String, + declared_at_unix: u64, + expires_at_unix: u64, + approver_quorum_bps: u64, + scope_operator_ids: Vec, + #[serde(default)] + is_new_activation: bool, +} + +#[derive(Clone, Debug, Deserialize)] +struct BreakGlassActivationHistoryRecord { + incident_ticket: String, + activated_at_unix: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct ValidationReason { + code: String, + detail: String, +} + +#[derive(Clone, Debug, Serialize)] +struct ValidationDecision { + decision: String, + reasons: Vec, + validated_at_unix: u64, +} + +#[derive(Debug)] +struct CliArgs { + registry_path: PathBuf, + context_path: PathBuf, + now_unix_override: Option, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum EnforcementMode { + MonitorOnly, + SoftEnforcement, + HardEnforcementCanary, + FullEnforcement, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum RuntimeDecisionState { + Allow, + AllowWithWarnings, + Reject, +} + +#[derive(Clone, Debug)] +struct BreakGlassEvaluation { + provided: bool, + valid: bool, + reasons: Vec, +} + +fn usage() -> String { + "Usage: tee_enforcement_checker --registry --context [--now-unix ]" + .to_string() +} + +fn parse_args(args: &[String]) -> Result { + let mut registry_path: Option = None; + let mut context_path: Option = None; + let mut now_unix_override: Option = None; + + let mut i = 0usize; + while i < args.len() { + match args[i].as_str() { + "--registry" => { + i += 1; + if i >= args.len() { + return Err("missing value for --registry".to_string()); + } + registry_path = Some(PathBuf::from(&args[i])); + } + "--context" => { + i += 1; + if i >= args.len() { + return Err("missing value for --context".to_string()); + } + context_path = Some(PathBuf::from(&args[i])); + } + "--now-unix" => { + i += 1; + if i >= args.len() { + return Err("missing value for --now-unix".to_string()); + } + now_unix_override = Some( + args[i] + .parse::() + .map_err(|_| "invalid value for --now-unix".to_string())?, + ); + } + "--help" | "-h" => { + return Err(usage()); + } + unknown => { + return Err(format!("unknown argument [{unknown}]")); + } + } + i += 1; + } + + let registry_path = registry_path.ok_or_else(|| "missing required --registry".to_string())?; + let context_path = context_path.ok_or_else(|| "missing required --context".to_string())?; + + Ok(CliArgs { + registry_path, + context_path, + now_unix_override, + }) +} + +fn now_unix() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|error| format!("system clock must be after UNIX epoch: {error}")) +} + +fn load_json_file Deserialize<'de>>(path: &PathBuf) -> Result { + let bytes = fs::read(path) + .map_err(|error| format!("failed to read file [{}]: {error}", path.display()))?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse JSON file [{}]: {error}", path.display())) +} + +fn trimmed_lowercase(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn parse_enforcement_mode(mode: &str) -> Option { + match trimmed_lowercase(mode).as_str() { + "monitor_only" | "monitor-only" => Some(EnforcementMode::MonitorOnly), + "soft_enforcement" | "soft-enforcement" => Some(EnforcementMode::SoftEnforcement), + "hard_enforcement_canary" | "hard-enforcement-canary" => { + Some(EnforcementMode::HardEnforcementCanary) + } + "full_enforcement" | "full-enforcement" => Some(EnforcementMode::FullEnforcement), + _ => None, + } +} + +fn parse_runtime_decision_state(decision: &str) -> Option { + match trimmed_lowercase(decision).as_str() { + "allow" => Some(RuntimeDecisionState::Allow), + "allow_with_warnings" | "allow-with-warnings" => { + Some(RuntimeDecisionState::AllowWithWarnings) + } + "reject" => Some(RuntimeDecisionState::Reject), + _ => None, + } +} + +fn push_reason(reasons: &mut Vec, code: &str, detail: String) { + reasons.push(ValidationReason { + code: code.to_string(), + detail, + }); +} + +fn append_runtime_reasons(target: &mut Vec, runtime: &RuntimeDecisionSnapshot) { + if runtime.reasons.is_empty() { + push_reason( + target, + "runtime_decision_reject_without_reasons", + "runtime_decision is reject but reasons are empty".to_string(), + ); + return; + } + + for reason in &runtime.reasons { + target.push(ValidationReason { + code: format!("runtime_{}", reason.code), + detail: reason.detail.clone(), + }); + } +} + +fn validate_context_structure( + context: &PhaseDEnforcementContextV1, + blocking_reasons: &mut Vec, +) { + if context.session_id.trim().is_empty() { + push_reason( + blocking_reasons, + "session_id_missing", + "session_id must be non-empty".to_string(), + ); + } + + if context.selected_operator_ids.is_empty() { + push_reason( + blocking_reasons, + "selected_operator_ids_empty", + "selected_operator_ids must contain at least one operator_id".to_string(), + ); + } + + let mut seen = HashSet::new(); + for operator_id in &context.selected_operator_ids { + let normalized = trimmed_lowercase(operator_id); + if normalized.is_empty() { + push_reason( + blocking_reasons, + "selected_operator_id_missing", + "selected_operator_ids contains an empty operator_id".to_string(), + ); + continue; + } + + if !seen.insert(normalized) { + push_reason( + blocking_reasons, + "selected_operator_id_duplicate", + format!( + "selected_operator_ids contains duplicate operator_id [{}]", + operator_id + ), + ); + } + } + + if context.runtime_decision.validated_at_unix == 0 { + push_reason( + blocking_reasons, + "runtime_decision_validated_at_invalid", + "runtime_decision.validated_at_unix must be > 0".to_string(), + ); + } +} + +fn evaluate_break_glass( + registry: &TeeGovernanceRegistryV1, + context: &PhaseDEnforcementContextV1, + now_unix_seconds: u64, +) -> BreakGlassEvaluation { + let Some(break_glass) = context.break_glass.as_ref() else { + return BreakGlassEvaluation { + provided: false, + valid: false, + reasons: Vec::new(), + }; + }; + + let mut reasons = Vec::new(); + let mut valid = true; + + if trimmed_lowercase(®istry.enforcement.break_glass_scope) != "named_operator_ids_only" { + push_reason( + &mut reasons, + "break_glass_scope_not_supported", + format!( + "registry enforcement break_glass_scope [{}] must be [named_operator_ids_only]", + registry.enforcement.break_glass_scope + ), + ); + valid = false; + } + + if break_glass.incident_ticket.trim().is_empty() { + push_reason( + &mut reasons, + "break_glass_incident_ticket_missing", + "break_glass.incident_ticket must be non-empty".to_string(), + ); + valid = false; + } + let current_incident_ticket_normalized = trimmed_lowercase(&break_glass.incident_ticket); + + if break_glass.declared_at_unix == 0 { + push_reason( + &mut reasons, + "break_glass_declared_at_invalid", + "break_glass.declared_at_unix must be > 0".to_string(), + ); + valid = false; + } + + if break_glass.declared_at_unix > now_unix_seconds { + push_reason( + &mut reasons, + "break_glass_declared_in_future", + format!( + "break_glass.declared_at_unix [{}] is in the future relative to now [{}]", + break_glass.declared_at_unix, now_unix_seconds + ), + ); + valid = false; + } + + if break_glass.expires_at_unix <= break_glass.declared_at_unix { + push_reason( + &mut reasons, + "break_glass_expiry_window_invalid", + format!( + "break_glass.expires_at_unix [{}] must be greater than declared_at_unix [{}]", + break_glass.expires_at_unix, break_glass.declared_at_unix + ), + ); + valid = false; + } + + if break_glass.expires_at_unix <= now_unix_seconds { + push_reason( + &mut reasons, + "break_glass_expired", + format!( + "break_glass expires_at_unix [{}] is not after now [{}]", + break_glass.expires_at_unix, now_unix_seconds + ), + ); + valid = false; + } + + let ttl_policy_valid = registry.enforcement.break_glass_ttl_seconds > 0 + && registry.enforcement.break_glass_ttl_seconds <= MAX_BREAK_GLASS_TTL_SECONDS; + if !ttl_policy_valid { + push_reason( + &mut reasons, + "break_glass_ttl_policy_invalid", + format!( + "registry break_glass_ttl_seconds [{}] must be within [1, {}]", + registry.enforcement.break_glass_ttl_seconds, MAX_BREAK_GLASS_TTL_SECONDS + ), + ); + valid = false; + } + + if break_glass.expires_at_unix > break_glass.declared_at_unix { + let ttl_seconds = break_glass + .expires_at_unix + .saturating_sub(break_glass.declared_at_unix); + if ttl_seconds > MAX_BREAK_GLASS_TTL_SECONDS { + push_reason( + &mut reasons, + "break_glass_ttl_exceeds_hard_max", + format!( + "break_glass ttl [{}] exceeds hard maximum [{}]", + ttl_seconds, MAX_BREAK_GLASS_TTL_SECONDS + ), + ); + valid = false; + } + + if ttl_policy_valid && ttl_seconds > registry.enforcement.break_glass_ttl_seconds { + push_reason( + &mut reasons, + "break_glass_ttl_exceeds_policy", + format!( + "break_glass ttl [{}] exceeds policy break_glass_ttl_seconds [{}]", + ttl_seconds, registry.enforcement.break_glass_ttl_seconds + ), + ); + valid = false; + } + } + + if break_glass.approver_quorum_bps > 10_000 { + push_reason( + &mut reasons, + "break_glass_quorum_out_of_range", + format!( + "break_glass.approver_quorum_bps [{}] must be <= 10000", + break_glass.approver_quorum_bps + ), + ); + valid = false; + } + + if break_glass.approver_quorum_bps < registry.enforcement.break_glass_quorum_bps { + push_reason( + &mut reasons, + "break_glass_quorum_below_required", + format!( + "break_glass.approver_quorum_bps [{}] below required [{}]", + break_glass.approver_quorum_bps, registry.enforcement.break_glass_quorum_bps + ), + ); + valid = false; + } + + if break_glass.scope_operator_ids.is_empty() { + push_reason( + &mut reasons, + "break_glass_scope_empty", + "break_glass.scope_operator_ids must be non-empty".to_string(), + ); + valid = false; + } + + let mut normalized_scope = HashSet::new(); + for scoped_operator_id in &break_glass.scope_operator_ids { + let normalized = trimmed_lowercase(scoped_operator_id); + if normalized.is_empty() { + push_reason( + &mut reasons, + "break_glass_scope_operator_missing", + "break_glass.scope_operator_ids contains an empty operator_id".to_string(), + ); + valid = false; + continue; + } + + let _ = normalized_scope.insert(normalized); + } + + for selected_operator_id in &context.selected_operator_ids { + let normalized = trimmed_lowercase(selected_operator_id); + if normalized.is_empty() { + continue; + } + + if !normalized_scope.contains(&normalized) { + push_reason( + &mut reasons, + "break_glass_scope_operator_not_covered", + format!( + "selected operator_id [{}] is not covered by break_glass scope_operator_ids", + selected_operator_id + ), + ); + valid = false; + } + } + + let mut history_incident_tickets = HashSet::new(); + let mut recent_activation_incident_tickets = HashSet::new(); + let mut latest_activation_unix: Option = None; + let mut reused_incident_activated_at_unix: Option = None; + let recent_window_start = now_unix_seconds.saturating_sub(SECONDS_PER_7_DAYS); + for history_record in &context.break_glass_history { + let history_incident_ticket_normalized = trimmed_lowercase(&history_record.incident_ticket); + if history_incident_ticket_normalized.is_empty() { + push_reason( + &mut reasons, + "break_glass_history_incident_ticket_missing", + "break_glass_history contains an empty incident_ticket".to_string(), + ); + valid = false; + continue; + } + + if !history_incident_tickets.insert(history_incident_ticket_normalized.clone()) { + push_reason( + &mut reasons, + "break_glass_history_duplicate_incident_ticket", + format!( + "break_glass_history contains duplicate incident_ticket [{}]", + history_record.incident_ticket + ), + ); + valid = false; + } + + if history_incident_ticket_normalized == current_incident_ticket_normalized + && reused_incident_activated_at_unix.is_none() + { + reused_incident_activated_at_unix = Some(history_record.activated_at_unix); + } + + if history_record.activated_at_unix > now_unix_seconds { + push_reason( + &mut reasons, + "break_glass_history_activation_in_future", + format!( + "break_glass_history activated_at_unix [{}] is in the future relative to now [{}]", + history_record.activated_at_unix, now_unix_seconds + ), + ); + valid = false; + continue; + } + + if history_record.activated_at_unix >= recent_window_start { + let _ = recent_activation_incident_tickets.insert(history_incident_ticket_normalized); + } + + latest_activation_unix = Some( + latest_activation_unix + .map(|current| current.max(history_record.activated_at_unix)) + .unwrap_or(history_record.activated_at_unix), + ); + } + + let inferred_new_activation = !current_incident_ticket_normalized.is_empty() + && !history_incident_tickets.contains(¤t_incident_ticket_normalized); + + if break_glass.is_new_activation != inferred_new_activation { + push_reason( + &mut reasons, + "break_glass_activation_hint_mismatch", + format!( + "break_glass.is_new_activation [{}] does not match inferred value [{}] from incident history", + break_glass.is_new_activation, inferred_new_activation + ), + ); + } + + if let Some(activated_at_unix) = + reused_incident_activated_at_unix.filter(|_| !inferred_new_activation) + { + if break_glass.declared_at_unix != activated_at_unix { + push_reason( + &mut reasons, + "break_glass_reused_incident_declared_at_mismatch", + format!( + "reused incident_ticket [{}] declared_at_unix [{}] must match history activated_at_unix [{}]", + break_glass.incident_ticket, break_glass.declared_at_unix, activated_at_unix + ), + ); + valid = false; + } + + if ttl_policy_valid { + let historical_expires_at_unix = + activated_at_unix.saturating_add(registry.enforcement.break_glass_ttl_seconds); + if historical_expires_at_unix <= now_unix_seconds { + push_reason( + &mut reasons, + "break_glass_reused_incident_expired", + format!( + "reused incident_ticket [{}] expired at [{}] based on history activated_at_unix [{}] and policy ttl [{}]", + break_glass.incident_ticket, + historical_expires_at_unix, + activated_at_unix, + registry.enforcement.break_glass_ttl_seconds + ), + ); + valid = false; + } + + if break_glass.expires_at_unix > historical_expires_at_unix { + push_reason( + &mut reasons, + "break_glass_reused_incident_extends_ttl", + format!( + "reused incident_ticket [{}] expires_at_unix [{}] exceeds history-derived expiry [{}]", + break_glass.incident_ticket, + break_glass.expires_at_unix, + historical_expires_at_unix + ), + ); + valid = false; + } + } + } + + if inferred_new_activation { + let recent_activations = recent_activation_incident_tickets.len(); + let projected_activations = recent_activations.saturating_add(1); + if projected_activations > registry.enforcement.break_glass_max_activations_per_7d as usize + { + push_reason( + &mut reasons, + "break_glass_activation_limit_exceeded", + format!( + "projected break-glass activations in 7d [{}] exceed max [{}]", + projected_activations, registry.enforcement.break_glass_max_activations_per_7d + ), + ); + valid = false; + } + + if let Some(latest_activation_unix) = latest_activation_unix { + let elapsed = now_unix_seconds.saturating_sub(latest_activation_unix); + if elapsed < registry.enforcement.break_glass_cooldown_seconds { + push_reason( + &mut reasons, + "break_glass_cooldown_violation", + format!( + "break-glass activation cooldown violated: elapsed [{}] < required [{}]", + elapsed, registry.enforcement.break_glass_cooldown_seconds + ), + ); + valid = false; + } + } + } + + BreakGlassEvaluation { + provided: true, + valid, + reasons, + } +} + +fn validate_enforcement( + registry: &TeeGovernanceRegistryV1, + context: &PhaseDEnforcementContextV1, + now_unix_seconds: u64, +) -> ValidationDecision { + let mut blocking_reasons = Vec::new(); + let mut warning_reasons = Vec::new(); + + validate_context_structure(context, &mut blocking_reasons); + + let runtime_state = match parse_runtime_decision_state(&context.runtime_decision.decision) { + Some(runtime_state) => runtime_state, + None => { + push_reason( + &mut blocking_reasons, + "runtime_decision_invalid", + format!( + "runtime_decision.decision [{}] must be one of [allow, allow_with_warnings, allow-with-warnings, reject]", + context.runtime_decision.decision + ), + ); + RuntimeDecisionState::Reject + } + }; + + let enforcement_mode = match parse_enforcement_mode(&context.enforcement_mode) { + Some(enforcement_mode) => enforcement_mode, + None => { + push_reason( + &mut blocking_reasons, + "enforcement_mode_invalid", + format!( + "enforcement_mode [{}] must be one of [monitor_only, soft_enforcement, hard_enforcement_canary, full_enforcement]", + context.enforcement_mode + ), + ); + EnforcementMode::FullEnforcement + } + }; + + if trimmed_lowercase(®istry.profile_status) != "mandatory" + && matches!( + enforcement_mode, + EnforcementMode::HardEnforcementCanary | EnforcementMode::FullEnforcement + ) + { + push_reason( + &mut blocking_reasons, + "governance_profile_not_mandatory_for_strict_mode", + format!( + "registry profile_status [{}] must be mandatory for strict enforcement modes", + registry.profile_status + ), + ); + } + + let break_glass_evaluation = evaluate_break_glass(registry, context, now_unix_seconds); + let runtime_violation = runtime_state != RuntimeDecisionState::Allow; + + if !blocking_reasons.is_empty() { + return ValidationDecision { + decision: "reject".to_string(), + reasons: blocking_reasons, + validated_at_unix: now_unix_seconds, + }; + } + + match enforcement_mode { + EnforcementMode::MonitorOnly => { + if runtime_violation { + push_reason( + &mut warning_reasons, + "monitor_only_runtime_violation_observed", + "runtime decision indicates policy violations; monitor_only mode does not block" + .to_string(), + ); + append_runtime_reasons(&mut warning_reasons, &context.runtime_decision); + } + + if break_glass_evaluation.provided && !break_glass_evaluation.valid { + warning_reasons.extend(break_glass_evaluation.reasons); + } + } + EnforcementMode::SoftEnforcement => { + if runtime_violation { + push_reason( + &mut warning_reasons, + "soft_enforcement_violation_exclusion_preferred", + "runtime decision indicates policy violations; soft_enforcement mode prefers exclusion but does not block" + .to_string(), + ); + append_runtime_reasons(&mut warning_reasons, &context.runtime_decision); + } + + if break_glass_evaluation.provided && !break_glass_evaluation.valid { + warning_reasons.extend(break_glass_evaluation.reasons); + } + } + EnforcementMode::HardEnforcementCanary => { + if runtime_violation { + if context.canary_session { + if break_glass_evaluation.provided && break_glass_evaluation.valid { + push_reason( + &mut warning_reasons, + "hard_enforcement_canary_break_glass_applied", + "canary runtime violation allowed due to valid break-glass activation" + .to_string(), + ); + append_runtime_reasons(&mut warning_reasons, &context.runtime_decision); + } else { + push_reason( + &mut blocking_reasons, + "hard_enforcement_canary_violation_blocked", + "canary runtime violation blocked in hard_enforcement_canary mode" + .to_string(), + ); + append_runtime_reasons(&mut blocking_reasons, &context.runtime_decision); + if break_glass_evaluation.provided { + blocking_reasons.extend(break_glass_evaluation.reasons); + } + } + } else { + push_reason( + &mut warning_reasons, + "hard_enforcement_canary_non_canary_soft_fallback", + "non-canary runtime violation observed; hard_enforcement_canary mode does not block non-canary sessions" + .to_string(), + ); + append_runtime_reasons(&mut warning_reasons, &context.runtime_decision); + + if break_glass_evaluation.provided && !break_glass_evaluation.valid { + warning_reasons.extend(break_glass_evaluation.reasons); + } + } + } else if break_glass_evaluation.provided && !break_glass_evaluation.valid { + warning_reasons.extend(break_glass_evaluation.reasons); + } + } + EnforcementMode::FullEnforcement => { + if runtime_violation { + if break_glass_evaluation.provided && break_glass_evaluation.valid { + push_reason( + &mut warning_reasons, + "full_enforcement_break_glass_applied", + "runtime violation allowed due to valid break-glass activation".to_string(), + ); + append_runtime_reasons(&mut warning_reasons, &context.runtime_decision); + } else { + push_reason( + &mut blocking_reasons, + "full_enforcement_violation_blocked", + "runtime violation blocked in full_enforcement mode".to_string(), + ); + append_runtime_reasons(&mut blocking_reasons, &context.runtime_decision); + if break_glass_evaluation.provided { + blocking_reasons.extend(break_glass_evaluation.reasons); + } + } + } else if break_glass_evaluation.provided && !break_glass_evaluation.valid { + warning_reasons.extend(break_glass_evaluation.reasons); + } + } + } + + if !blocking_reasons.is_empty() { + return ValidationDecision { + decision: "reject".to_string(), + reasons: blocking_reasons, + validated_at_unix: now_unix_seconds, + }; + } + + if warning_reasons.is_empty() { + ValidationDecision { + decision: "allow".to_string(), + reasons: warning_reasons, + validated_at_unix: now_unix_seconds, + } + } else { + ValidationDecision { + decision: "allow_with_warnings".to_string(), + reasons: warning_reasons, + validated_at_unix: now_unix_seconds, + } + } +} + +fn run() -> Result { + let args = env::args().skip(1).collect::>(); + let cli = parse_args(&args)?; + + let registry: TeeGovernanceRegistryV1 = load_json_file(&cli.registry_path)?; + let context: PhaseDEnforcementContextV1 = load_json_file(&cli.context_path)?; + let now_unix_seconds = match cli.now_unix_override { + Some(now_unix_override) => now_unix_override, + None => now_unix()?, + }; + + Ok(validate_enforcement(®istry, &context, now_unix_seconds)) +} + +fn main() { + match run() { + Ok(decision) => { + let json = serde_json::to_string_pretty(&decision).unwrap_or_else(|_| { + "{\"decision\":\"reject\",\"reasons\":[{\"code\":\"serialization_error\",\"detail\":\"failed to encode output\"}],\"validated_at_unix\":0}".to_string() + }); + println!("{json}"); + if decision.decision == "reject" { + std::process::exit(1); + } + std::process::exit(0); + } + Err(error) => { + eprintln!("{error}"); + eprintln!("{}", usage()); + std::process::exit(2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn baseline_registry() -> TeeGovernanceRegistryV1 { + TeeGovernanceRegistryV1 { + profile_status: "mandatory".to_string(), + enforcement: TeeEnforcementParameters { + break_glass_ttl_seconds: 21_600, + break_glass_max_activations_per_7d: 2, + break_glass_cooldown_seconds: 86_400, + break_glass_scope: "named_operator_ids_only".to_string(), + break_glass_quorum_bps: 6_700, + }, + } + } + + fn runtime_allow() -> RuntimeDecisionSnapshot { + RuntimeDecisionSnapshot { + decision: "allow".to_string(), + reasons: vec![], + validated_at_unix: 1_700_100_000, + } + } + + fn runtime_reject() -> RuntimeDecisionSnapshot { + RuntimeDecisionSnapshot { + decision: "reject".to_string(), + reasons: vec![ValidationReason { + code: "vendor_diversity_cap_exceeded".to_string(), + detail: "vendor-a share exceeds cap".to_string(), + }], + validated_at_unix: 1_700_100_000, + } + } + + fn baseline_context() -> PhaseDEnforcementContextV1 { + PhaseDEnforcementContextV1 { + session_id: "session-1".to_string(), + canary_session: true, + selected_operator_ids: vec!["operator-1".to_string(), "operator-2".to_string()], + runtime_decision: runtime_allow(), + enforcement_mode: "full_enforcement".to_string(), + break_glass: None, + break_glass_history: vec![], + } + } + + fn valid_break_glass() -> BreakGlassActivation { + BreakGlassActivation { + incident_ticket: "INC-123".to_string(), + declared_at_unix: 1_700_099_000, + expires_at_unix: 1_700_103_600, + approver_quorum_bps: 7_100, + scope_operator_ids: vec!["operator-1".to_string(), "operator-2".to_string()], + is_new_activation: true, + } + } + + #[test] + fn validate_enforcement_allows_full_enforcement_when_runtime_allows() { + let registry = baseline_registry(); + let context = baseline_context(); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "allow"); + assert!(decision.reasons.is_empty()); + } + + #[test] + fn validate_enforcement_rejects_full_enforcement_runtime_violation_without_break_glass() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "full_enforcement_violation_blocked")); + } + + #[test] + fn validate_enforcement_allows_monitor_only_with_warning_on_runtime_violation() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.enforcement_mode = "monitor_only".to_string(); + context.runtime_decision = runtime_reject(); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "allow_with_warnings"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "monitor_only_runtime_violation_observed")); + } + + #[test] + fn validate_enforcement_allows_soft_enforcement_with_warning_on_runtime_violation() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.enforcement_mode = "soft_enforcement".to_string(); + context.runtime_decision = runtime_reject(); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "allow_with_warnings"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "soft_enforcement_violation_exclusion_preferred" })); + } + + #[test] + fn validate_enforcement_rejects_hard_canary_runtime_violation_for_canary_session() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.enforcement_mode = "hard_enforcement_canary".to_string(); + context.runtime_decision = runtime_reject(); + context.canary_session = true; + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "hard_enforcement_canary_violation_blocked" })); + } + + #[test] + fn validate_enforcement_allows_hard_canary_runtime_violation_for_non_canary_session() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.enforcement_mode = "hard_enforcement_canary".to_string(); + context.runtime_decision = runtime_reject(); + context.canary_session = false; + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "allow_with_warnings"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "hard_enforcement_canary_non_canary_soft_fallback" })); + } + + #[test] + fn validate_enforcement_allows_hard_canary_runtime_violation_with_valid_break_glass() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.enforcement_mode = "hard_enforcement_canary".to_string(); + context.runtime_decision = runtime_reject(); + context.canary_session = true; + context.break_glass = Some(valid_break_glass()); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "allow_with_warnings"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "hard_enforcement_canary_break_glass_applied" })); + } + + #[test] + fn validate_enforcement_allows_full_enforcement_runtime_violation_with_valid_break_glass() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + context.break_glass = Some(valid_break_glass()); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "allow_with_warnings"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "full_enforcement_break_glass_applied")); + } + + #[test] + fn validate_enforcement_rejects_break_glass_when_scope_does_not_cover_selected_operator() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + context.break_glass = Some(BreakGlassActivation { + scope_operator_ids: vec!["operator-1".to_string()], + ..valid_break_glass() + }); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "break_glass_scope_operator_not_covered" })); + } + + #[test] + fn validate_enforcement_rejects_break_glass_with_empty_scope_operator_id() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + context.selected_operator_ids = vec!["operator-1".to_string()]; + context.break_glass = Some(BreakGlassActivation { + scope_operator_ids: vec!["operator-1".to_string(), " ".to_string()], + ..valid_break_glass() + }); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_scope_operator_missing")); + } + + #[test] + fn validate_enforcement_rejects_break_glass_when_activation_limit_exceeded() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + context.break_glass = Some(valid_break_glass()); + context.break_glass_history = vec![ + BreakGlassActivationHistoryRecord { + incident_ticket: "INC-100".to_string(), + activated_at_unix: 1_700_090_000, + }, + BreakGlassActivationHistoryRecord { + incident_ticket: "INC-101".to_string(), + activated_at_unix: 1_700_095_000, + }, + ]; + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_activation_limit_exceeded")); + } + + #[test] + fn validate_enforcement_rejects_break_glass_when_cooldown_violated() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + context.break_glass = Some(valid_break_glass()); + context.break_glass_history = vec![BreakGlassActivationHistoryRecord { + incident_ticket: "INC-100".to_string(), + activated_at_unix: 1_700_099_999, + }]; + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_cooldown_violation")); + } + + #[test] + fn validate_enforcement_rejects_break_glass_when_quorum_below_required() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + context.break_glass = Some(BreakGlassActivation { + approver_quorum_bps: 6_600, + ..valid_break_glass() + }); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_quorum_below_required")); + } + + #[test] + fn validate_enforcement_rejects_break_glass_when_ttl_exceeds_policy() { + let mut registry = baseline_registry(); + registry.enforcement.break_glass_ttl_seconds = 1_800; + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + context.break_glass = Some(valid_break_glass()); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_ttl_exceeds_policy")); + } + + #[test] + fn validate_enforcement_infers_new_activation_from_history_when_hint_false() { + let mut registry = baseline_registry(); + registry.enforcement.break_glass_max_activations_per_7d = 1; + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + context.break_glass = Some(BreakGlassActivation { + is_new_activation: false, + ..valid_break_glass() + }); + context.break_glass_history = vec![BreakGlassActivationHistoryRecord { + incident_ticket: "INC-100".to_string(), + activated_at_unix: 1_700_099_000, + }]; + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_activation_limit_exceeded")); + } + + #[test] + fn validate_enforcement_treats_existing_incident_as_reuse_even_when_hint_true() { + let mut registry = baseline_registry(); + registry.enforcement.break_glass_max_activations_per_7d = 1; + registry.enforcement.break_glass_cooldown_seconds = 86_400; + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + context.break_glass = Some(BreakGlassActivation { + incident_ticket: "INC-123".to_string(), + is_new_activation: true, + ..valid_break_glass() + }); + context.break_glass_history = vec![BreakGlassActivationHistoryRecord { + incident_ticket: "INC-123".to_string(), + activated_at_unix: 1_700_099_000, + }]; + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "allow_with_warnings"); + assert!(!decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_activation_limit_exceeded")); + assert!(!decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_cooldown_violation")); + } + + #[test] + fn validate_enforcement_rejects_reused_incident_with_refreshed_window() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.runtime_decision = runtime_reject(); + context.break_glass = Some(BreakGlassActivation { + incident_ticket: "INC-123".to_string(), + declared_at_unix: 1_700_199_000, + expires_at_unix: 1_700_203_600, + is_new_activation: false, + ..valid_break_glass() + }); + context.break_glass_history = vec![BreakGlassActivationHistoryRecord { + incident_ticket: "INC-123".to_string(), + activated_at_unix: 1_700_000_000, + }]; + + let decision = validate_enforcement(®istry, &context, 1_700_200_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_reused_incident_declared_at_mismatch")); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_reused_incident_expired")); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_reused_incident_extends_ttl")); + } + + #[test] + fn validate_enforcement_accepts_hyphenated_allow_with_warnings_runtime_decision() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.enforcement_mode = "monitor_only".to_string(); + context.runtime_decision.decision = "allow-with-warnings".to_string(); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "allow_with_warnings"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "monitor_only_runtime_violation_observed")); + } + + #[test] + fn validate_enforcement_rejects_invalid_enforcement_mode() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.enforcement_mode = "invalid_mode".to_string(); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "enforcement_mode_invalid")); + } + + #[test] + fn validate_enforcement_rejects_invalid_runtime_decision_state() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.runtime_decision.decision = "unknown".to_string(); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "runtime_decision_invalid")); + } + + #[test] + fn validate_enforcement_rejects_non_mandatory_profile_in_hard_enforcement_canary_mode() { + let mut registry = baseline_registry(); + registry.profile_status = "draft".to_string(); + let mut context = baseline_context(); + context.enforcement_mode = "hard_enforcement_canary".to_string(); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "governance_profile_not_mandatory_for_strict_mode" })); + } + + #[test] + fn validate_enforcement_rejects_non_mandatory_profile_in_full_enforcement_mode() { + let mut registry = baseline_registry(); + registry.profile_status = "draft".to_string(); + let context = baseline_context(); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "governance_profile_not_mandatory_for_strict_mode" })); + } + + #[test] + fn validate_enforcement_allows_soft_mode_even_with_invalid_break_glass() { + let registry = baseline_registry(); + let mut context = baseline_context(); + context.enforcement_mode = "soft_enforcement".to_string(); + context.runtime_decision = runtime_reject(); + context.break_glass = Some(BreakGlassActivation { + scope_operator_ids: vec![], + ..valid_break_glass() + }); + + let decision = validate_enforcement(®istry, &context, 1_700_100_000); + assert_eq!(decision.decision, "allow_with_warnings"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_scope_empty")); + } + + #[test] + fn parse_args_accepts_required_flags() { + let args = vec![ + "--registry".to_string(), + "registry.json".to_string(), + "--context".to_string(), + "context.json".to_string(), + ]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!(parsed.registry_path, PathBuf::from("registry.json")); + assert_eq!(parsed.context_path, PathBuf::from("context.json")); + assert!(parsed.now_unix_override.is_none()); + } + + #[test] + fn parse_args_accepts_now_unix() { + let args = vec![ + "--registry".to_string(), + "registry.json".to_string(), + "--context".to_string(), + "context.json".to_string(), + "--now-unix".to_string(), + "1700100000".to_string(), + ]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!(parsed.now_unix_override, Some(1_700_100_000)); + } + + #[test] + fn parse_args_rejects_missing_context_flag() { + let args = vec!["--registry".to_string(), "registry.json".to_string()]; + + let error = parse_args(&args).expect_err("expected parse failure"); + assert_eq!(error, "missing required --context"); + } +} diff --git a/pkg/tbtc/signer/src/bin/tee_registry_checker.rs b/pkg/tbtc/signer/src/bin/tee_registry_checker.rs new file mode 100644 index 0000000000..c0fe320fb7 --- /dev/null +++ b/pkg/tbtc/signer/src/bin/tee_registry_checker.rs @@ -0,0 +1,2105 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SECONDS_PER_DAY: u64 = 86_400; +const SECONDS_PER_7_DAYS: u64 = 7 * SECONDS_PER_DAY; +const MAX_ATTESTATION_MAX_AGE_SECONDS: u64 = SECONDS_PER_DAY; +const MAX_DENYLIST_STALENESS_SECONDS: u64 = 300; + +#[derive(Clone, Debug, Deserialize)] +struct TeeGovernanceRegistryV1 { + profile_status: String, + enforcement: TeeEnforcementParameters, + operators: Vec, + #[serde(default)] + activation_gate: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct TeeOperatorAdmissionRecord { + operator_id: String, + signer_identifier: String, + status: String, + allowed_tee_types: Vec, + allowed_measurements: Vec, + attestation_max_age_seconds: u64, + grace_period_seconds: u64, + effective_from: u64, + #[serde(default)] + effective_until: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct TeeEnforcementParameters { + attestation_max_age_seconds: u64, + grace_period_seconds: u64, + min_attested_signers_per_cohort: u64, + max_single_vendor_share_percent: u64, + denylist_max_staleness_seconds: u64, + break_glass_ttl_seconds: u64, + break_glass_max_activations_per_7d: u64, + break_glass_cooldown_seconds: u64, + break_glass_scope: String, + break_glass_quorum_bps: u64, + activation_gate_required_quorum_bps: u64, + re_attestation_poll_interval_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize)] +struct TeeActivationGateRecord { + governance_decision_id: String, + effective_at_unix: u64, + quorum_denominator: u64, + achieved_quorum_bps: u64, + approvers: Vec, + profile_status_transition: String, + rollback_condition: String, + rollback_authority: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct TeeActivationApprover { + approver_id: String, + role: String, + decision: String, + decided_at_unix: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum GovernanceEventType { + Add, + Suspend, + Revoke, + MeasurementUpdate, + BreakGlassActivate, + BreakGlassExpire, +} + +#[derive(Clone, Debug, Deserialize)] +struct GovernanceAuditEvent { + event_id: String, + event_type: GovernanceEventType, + #[serde(default)] + operator_id: Option, + #[serde(default)] + signer_identifier: Option, + #[serde(default)] + measurement_digest: Option, + governance_decision_id: String, + effective_at_unix: u64, + #[serde(default)] + incident_ticket: Option, + #[serde(default)] + scope_operator_ids: Option>, + #[serde(default)] + expires_at_unix: Option, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(untagged)] +enum GovernanceAuditInput { + Events(Vec), + Envelope { events: Vec }, +} + +impl GovernanceAuditInput { + fn into_events(self) -> Vec { + match self { + GovernanceAuditInput::Events(events) => events, + GovernanceAuditInput::Envelope { events } => events, + } + } +} + +#[derive(Clone, Debug, Serialize)] +struct ValidationReason { + code: String, + detail: String, +} + +#[derive(Clone, Debug, Serialize)] +struct ValidationDecision { + decision: String, + reasons: Vec, + validated_at_unix: u64, +} + +#[derive(Debug)] +struct CliArgs { + registry_path: PathBuf, + events_path: Option, + now_unix_override: Option, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum ProfileStatus { + Draft, + Mandatory, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum OperatorStatus { + Active, + Suspended, + Revoked, +} + +fn usage() -> String { + "Usage: tee_registry_checker --registry [--events ] [--now-unix ]" + .to_string() +} + +fn parse_args(args: &[String]) -> Result { + let mut registry_path: Option = None; + let mut events_path: Option = None; + let mut now_unix_override: Option = None; + + let mut i = 0usize; + while i < args.len() { + match args[i].as_str() { + "--registry" => { + i += 1; + if i >= args.len() { + return Err("missing value for --registry".to_string()); + } + registry_path = Some(PathBuf::from(&args[i])); + } + "--events" => { + i += 1; + if i >= args.len() { + return Err("missing value for --events".to_string()); + } + events_path = Some(PathBuf::from(&args[i])); + } + "--now-unix" => { + i += 1; + if i >= args.len() { + return Err("missing value for --now-unix".to_string()); + } + let parsed = args[i] + .parse::() + .map_err(|_| "invalid value for --now-unix".to_string())?; + now_unix_override = Some(parsed); + } + "--help" | "-h" => { + return Err(usage()); + } + unknown => { + return Err(format!("unknown argument [{unknown}]")); + } + } + i += 1; + } + + let registry_path = registry_path.ok_or_else(|| "missing required --registry".to_string())?; + + Ok(CliArgs { + registry_path, + events_path, + now_unix_override, + }) +} + +fn now_unix() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|error| format!("system clock must be after UNIX epoch: {error}")) +} + +fn load_json_file Deserialize<'de>>(path: &PathBuf) -> Result { + let bytes = fs::read(path) + .map_err(|error| format!("failed to read file [{}]: {error}", path.display()))?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse JSON file [{}]: {error}", path.display())) +} + +fn trimmed_lowercase(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn push_rejection_reason(reasons: &mut Vec, code: &str, detail: String) { + reasons.push(ValidationReason { + code: code.to_string(), + detail, + }); +} + +fn parse_profile_status(status: &str) -> Option { + match trimmed_lowercase(status).as_str() { + "draft" => Some(ProfileStatus::Draft), + "mandatory" => Some(ProfileStatus::Mandatory), + _ => None, + } +} + +fn parse_operator_status(status: &str) -> Option { + match trimmed_lowercase(status).as_str() { + "active" => Some(OperatorStatus::Active), + "suspended" => Some(OperatorStatus::Suspended), + "revoked" => Some(OperatorStatus::Revoked), + _ => None, + } +} + +fn is_sha256_digest(value: &str) -> bool { + let normalized = value.trim(); + if normalized.len() != 71 { + return false; + } + if !normalized + .get(0..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("sha256:")) + { + return false; + } + normalized.get(7..).is_some_and(|digest| { + digest + .chars() + .all(|character| character.is_ascii_hexdigit()) + }) +} + +fn required_non_empty( + field_name: &str, + value: &str, + reasons: &mut Vec, + code: &str, +) -> Option { + let normalized = value.trim().to_string(); + if normalized.is_empty() { + push_rejection_reason( + reasons, + code, + format!("field [{field_name}] must be non-empty"), + ); + return None; + } + Some(normalized) +} + +fn validate_non_empty_string_list( + field_name: &str, + values: &[String], + reasons: &mut Vec, + empty_code: &str, + item_code: &str, +) { + if values.is_empty() { + push_rejection_reason( + reasons, + empty_code, + format!("field [{field_name}] must include at least one value"), + ); + return; + } + + for value in values { + if value.trim().is_empty() { + push_rejection_reason( + reasons, + item_code, + format!("field [{field_name}] contains an empty item"), + ); + return; + } + } +} + +fn validate_enforcement( + enforcement: &TeeEnforcementParameters, + reasons: &mut Vec, +) { + if enforcement.attestation_max_age_seconds == 0 { + push_rejection_reason( + reasons, + "attestation_max_age_invalid", + "enforcement.attestation_max_age_seconds must be > 0".to_string(), + ); + } else if enforcement.attestation_max_age_seconds > MAX_ATTESTATION_MAX_AGE_SECONDS { + push_rejection_reason( + reasons, + "attestation_max_age_exceeds_hard_ceiling", + format!( + "enforcement.attestation_max_age_seconds [{}] exceeds hard ceiling [{}]", + enforcement.attestation_max_age_seconds, MAX_ATTESTATION_MAX_AGE_SECONDS + ), + ); + } + + if enforcement.grace_period_seconds > enforcement.attestation_max_age_seconds { + push_rejection_reason( + reasons, + "grace_period_exceeds_attestation_max_age", + format!( + "enforcement.grace_period_seconds [{}] exceeds attestation_max_age_seconds [{}]", + enforcement.grace_period_seconds, enforcement.attestation_max_age_seconds + ), + ); + } + + if enforcement.min_attested_signers_per_cohort == 0 { + push_rejection_reason( + reasons, + "min_attested_signers_invalid", + "enforcement.min_attested_signers_per_cohort must be > 0".to_string(), + ); + } + + if !(1..=100).contains(&enforcement.max_single_vendor_share_percent) { + push_rejection_reason( + reasons, + "max_single_vendor_share_percent_invalid", + format!( + "enforcement.max_single_vendor_share_percent [{}] must be within [1, 100]", + enforcement.max_single_vendor_share_percent + ), + ); + } + + if enforcement.denylist_max_staleness_seconds == 0 + || enforcement.denylist_max_staleness_seconds > MAX_DENYLIST_STALENESS_SECONDS + { + push_rejection_reason( + reasons, + "denylist_max_staleness_out_of_bounds", + format!( + "enforcement.denylist_max_staleness_seconds [{}] must be within [1, {}]", + enforcement.denylist_max_staleness_seconds, MAX_DENYLIST_STALENESS_SECONDS + ), + ); + } + + if enforcement.break_glass_ttl_seconds == 0 + || enforcement.break_glass_ttl_seconds > SECONDS_PER_7_DAYS + { + push_rejection_reason( + reasons, + "break_glass_ttl_invalid", + format!( + "enforcement.break_glass_ttl_seconds [{}] must be within [1, {}]", + enforcement.break_glass_ttl_seconds, SECONDS_PER_7_DAYS + ), + ); + } + + if enforcement.break_glass_max_activations_per_7d == 0 { + push_rejection_reason( + reasons, + "break_glass_max_activations_invalid", + "enforcement.break_glass_max_activations_per_7d must be > 0".to_string(), + ); + } + + if enforcement.break_glass_cooldown_seconds == 0 + || enforcement.break_glass_cooldown_seconds > SECONDS_PER_7_DAYS + { + push_rejection_reason( + reasons, + "break_glass_cooldown_invalid", + format!( + "enforcement.break_glass_cooldown_seconds [{}] must be within [1, {}]", + enforcement.break_glass_cooldown_seconds, SECONDS_PER_7_DAYS + ), + ); + } + + if trimmed_lowercase(&enforcement.break_glass_scope) != "named_operator_ids_only" { + push_rejection_reason( + reasons, + "break_glass_scope_not_supported", + format!( + "enforcement.break_glass_scope must be [named_operator_ids_only], got [{}]", + enforcement.break_glass_scope + ), + ); + } + + if !(1..=10_000).contains(&enforcement.break_glass_quorum_bps) { + push_rejection_reason( + reasons, + "break_glass_quorum_invalid", + format!( + "enforcement.break_glass_quorum_bps [{}] must be within [1, 10000]", + enforcement.break_glass_quorum_bps + ), + ); + } + + if !(6_700..=10_000).contains(&enforcement.activation_gate_required_quorum_bps) { + push_rejection_reason( + reasons, + "activation_gate_required_quorum_invalid", + format!( + "enforcement.activation_gate_required_quorum_bps [{}] must be within [6700, 10000]", + enforcement.activation_gate_required_quorum_bps + ), + ); + } + + if enforcement.re_attestation_poll_interval_seconds == 0 { + push_rejection_reason( + reasons, + "re_attestation_poll_interval_invalid", + "enforcement.re_attestation_poll_interval_seconds must be > 0".to_string(), + ); + } else if enforcement.re_attestation_poll_interval_seconds + > enforcement.attestation_max_age_seconds + { + push_rejection_reason( + reasons, + "re_attestation_poll_interval_exceeds_attestation_max_age", + format!( + "enforcement.re_attestation_poll_interval_seconds [{}] exceeds attestation_max_age_seconds [{}]", + enforcement.re_attestation_poll_interval_seconds, + enforcement.attestation_max_age_seconds + ), + ); + } +} + +fn validate_operator_records( + operators: &[TeeOperatorAdmissionRecord], + now_unix_seconds: u64, + reasons: &mut Vec, +) { + let mut operator_ids = HashSet::new(); + let mut signer_identifiers = HashSet::new(); + + for operator in operators { + let Some(operator_id) = required_non_empty( + "operator_id", + &operator.operator_id, + reasons, + "operator_id_missing", + ) else { + continue; + }; + + let operator_id_normalized = trimmed_lowercase(&operator_id); + if !operator_ids.insert(operator_id_normalized.clone()) { + push_rejection_reason( + reasons, + "operator_id_duplicate", + format!( + "operator_id [{}] is duplicated in registry", + operator.operator_id + ), + ); + } + + let Some(signer_identifier) = required_non_empty( + "signer_identifier", + &operator.signer_identifier, + reasons, + "signer_identifier_missing", + ) else { + continue; + }; + + let signer_identifier_normalized = trimmed_lowercase(&signer_identifier); + if !signer_identifiers.insert(signer_identifier_normalized) { + push_rejection_reason( + reasons, + "signer_identifier_duplicate", + format!( + "signer_identifier [{}] is duplicated in registry", + operator.signer_identifier + ), + ); + } + + if parse_operator_status(&operator.status).is_none() { + push_rejection_reason( + reasons, + "operator_status_invalid", + format!( + "operator [{}] has invalid status [{}]; expected one of [active, suspended, revoked]", + operator.operator_id, operator.status + ), + ); + } + + validate_non_empty_string_list( + "allowed_tee_types", + &operator.allowed_tee_types, + reasons, + "allowed_tee_types_missing", + "allowed_tee_types_contains_empty", + ); + + validate_non_empty_string_list( + "allowed_measurements", + &operator.allowed_measurements, + reasons, + "allowed_measurements_missing", + "allowed_measurements_contains_empty", + ); + for measurement in &operator.allowed_measurements { + if !is_sha256_digest(measurement) { + push_rejection_reason( + reasons, + "allowed_measurement_digest_invalid", + format!( + "operator [{}] allowed_measurements entry [{}] must match sha256:<64 hex chars>", + operator.operator_id, measurement + ), + ); + } + } + + if operator.attestation_max_age_seconds == 0 { + push_rejection_reason( + reasons, + "operator_attestation_max_age_invalid", + format!( + "operator [{}] attestation_max_age_seconds must be > 0", + operator.operator_id + ), + ); + } + + if operator.grace_period_seconds > operator.attestation_max_age_seconds { + push_rejection_reason( + reasons, + "operator_grace_period_exceeds_attestation_max_age", + format!( + "operator [{}] grace_period_seconds [{}] exceeds attestation_max_age_seconds [{}]", + operator.operator_id, + operator.grace_period_seconds, + operator.attestation_max_age_seconds + ), + ); + } + + if operator.effective_from == 0 { + push_rejection_reason( + reasons, + "operator_effective_from_invalid", + format!( + "operator [{}] effective_from must be > 0", + operator.operator_id + ), + ); + } + + if let Some(effective_until) = operator.effective_until { + if effective_until < operator.effective_from { + push_rejection_reason( + reasons, + "operator_effective_window_invalid", + format!( + "operator [{}] effective_until [{}] is before effective_from [{}]", + operator.operator_id, effective_until, operator.effective_from + ), + ); + } + + if effective_until < now_unix_seconds + && parse_operator_status(&operator.status) == Some(OperatorStatus::Active) + { + push_rejection_reason( + reasons, + "active_operator_window_expired", + format!( + "operator [{}] is active but effective_until [{}] is in the past relative to now [{}]", + operator.operator_id, effective_until, now_unix_seconds + ), + ); + } + } + } +} + +fn validate_activation_gate( + profile_status: ProfileStatus, + activation_gate: Option<&TeeActivationGateRecord>, + enforcement: &TeeEnforcementParameters, + now_unix_seconds: u64, + reasons: &mut Vec, +) { + if profile_status == ProfileStatus::Mandatory && activation_gate.is_none() { + push_rejection_reason( + reasons, + "mandatory_profile_missing_activation_gate", + "profile_status [mandatory] requires activation_gate record".to_string(), + ); + return; + } + + let Some(activation_gate) = activation_gate else { + return; + }; + + if activation_gate.governance_decision_id.trim().is_empty() { + push_rejection_reason( + reasons, + "activation_gate_decision_id_missing", + "activation_gate.governance_decision_id must be non-empty".to_string(), + ); + } + + if activation_gate.effective_at_unix == 0 { + push_rejection_reason( + reasons, + "activation_gate_effective_time_invalid", + "activation_gate.effective_at_unix must be > 0".to_string(), + ); + } + + if profile_status == ProfileStatus::Mandatory + && activation_gate.effective_at_unix > now_unix_seconds + { + push_rejection_reason( + reasons, + "activation_gate_not_yet_effective", + format!( + "mandatory profile requires activation_gate.effective_at_unix [{}] <= now [{}]", + activation_gate.effective_at_unix, now_unix_seconds + ), + ); + } + + if activation_gate.quorum_denominator == 0 { + push_rejection_reason( + reasons, + "activation_gate_quorum_denominator_invalid", + "activation_gate.quorum_denominator must be > 0".to_string(), + ); + } + + if activation_gate.achieved_quorum_bps > 10_000 { + push_rejection_reason( + reasons, + "activation_gate_achieved_quorum_invalid", + format!( + "activation_gate.achieved_quorum_bps [{}] must be <= 10000", + activation_gate.achieved_quorum_bps + ), + ); + } + + if activation_gate.achieved_quorum_bps < enforcement.activation_gate_required_quorum_bps { + push_rejection_reason( + reasons, + "activation_gate_quorum_below_required", + format!( + "activation_gate.achieved_quorum_bps [{}] is below required [{}]", + activation_gate.achieved_quorum_bps, + enforcement.activation_gate_required_quorum_bps + ), + ); + } + + let transition = trimmed_lowercase(&activation_gate.profile_status_transition).replace(' ', ""); + if transition != "draft->mandatory" { + push_rejection_reason( + reasons, + "activation_gate_transition_invalid", + format!( + "activation_gate.profile_status_transition must be [draft -> mandatory], got [{}]", + activation_gate.profile_status_transition + ), + ); + } + + if activation_gate.rollback_condition.trim().is_empty() { + push_rejection_reason( + reasons, + "activation_gate_rollback_condition_missing", + "activation_gate.rollback_condition must be non-empty".to_string(), + ); + } + + if activation_gate.rollback_authority.trim().is_empty() { + push_rejection_reason( + reasons, + "activation_gate_rollback_authority_missing", + "activation_gate.rollback_authority must be non-empty".to_string(), + ); + } + + if activation_gate.approvers.is_empty() { + push_rejection_reason( + reasons, + "activation_gate_approvers_missing", + "activation_gate.approvers must include required roles".to_string(), + ); + return; + } + + let mut role_decisions: HashMap = HashMap::new(); + let mut seen_approver_ids: HashSet = HashSet::new(); + for approver in &activation_gate.approvers { + if approver.approver_id.trim().is_empty() { + push_rejection_reason( + reasons, + "activation_gate_approver_id_missing", + "activation_gate approver_id must be non-empty".to_string(), + ); + } + + let approver_id_normalized = trimmed_lowercase(&approver.approver_id); + if !approver_id_normalized.is_empty() && !seen_approver_ids.insert(approver_id_normalized) { + push_rejection_reason( + reasons, + "activation_gate_approver_id_duplicate", + format!( + "activation_gate approver_id [{}] appears more than once", + approver.approver_id + ), + ); + } + + if approver.decided_at_unix == 0 { + push_rejection_reason( + reasons, + "activation_gate_approver_timestamp_invalid", + format!( + "activation_gate approver [{}] decided_at_unix must be > 0", + approver.approver_id + ), + ); + } + + let role_normalized = trimmed_lowercase(&approver.role); + let decision_normalized = trimmed_lowercase(&approver.decision); + let approved = decision_normalized == "approved"; + + if !matches!( + role_normalized.as_str(), + "security_owner" | "signer_runtime_owner" | "governance_delegate" + ) { + push_rejection_reason( + reasons, + "activation_gate_role_invalid", + format!( + "activation_gate approver role [{}] is unsupported", + approver.role + ), + ); + continue; + } + + if role_decisions.contains_key(&role_normalized) { + push_rejection_reason( + reasons, + "activation_gate_role_duplicate", + format!( + "activation_gate approver role [{}] appears more than once", + approver.role + ), + ); + continue; + } + + role_decisions.insert(role_normalized, approved); + } + + for required_role in [ + "security_owner", + "signer_runtime_owner", + "governance_delegate", + ] { + match role_decisions.get(required_role) { + Some(true) => {} + Some(false) => push_rejection_reason( + reasons, + "activation_gate_role_not_approved", + format!( + "activation_gate role [{}] is present but not approved", + required_role + ), + ), + None => push_rejection_reason( + reasons, + "activation_gate_role_missing", + format!("activation_gate missing required role [{}]", required_role), + ), + } + } +} + +fn validate_governance_events( + registry: &TeeGovernanceRegistryV1, + events: &[GovernanceAuditEvent], + now_unix_seconds: u64, + reasons: &mut Vec, +) { + if events.is_empty() { + push_rejection_reason( + reasons, + "audit_events_missing", + "events file was provided but contains no governance events".to_string(), + ); + return; + } + + for window in events.windows(2) { + if window[0].effective_at_unix > window[1].effective_at_unix { + push_rejection_reason( + reasons, + "audit_events_not_chronological", + format!( + "event [{}] has effective_at_unix [{}] later than following event [{}] at [{}]", + window[0].event_id, + window[0].effective_at_unix, + window[1].event_id, + window[1].effective_at_unix + ), + ); + break; + } + } + + let mut seen_event_ids: HashSet = HashSet::new(); + let mut status_by_operator: HashMap = HashMap::new(); + let mut signer_by_operator: HashMap = HashMap::new(); + let mut active_break_glass_incidents: HashMap = HashMap::new(); + let mut recent_break_glass_activations: VecDeque = VecDeque::new(); + let mut last_break_glass_activation_unix: Option = None; + + for event in events { + let event_id = match required_non_empty( + "event_id", + &event.event_id, + reasons, + "audit_event_id_missing", + ) { + Some(event_id) => event_id, + None => continue, + }; + + let event_id_normalized = trimmed_lowercase(&event_id); + if !seen_event_ids.insert(event_id_normalized) { + push_rejection_reason( + reasons, + "audit_event_id_duplicate", + format!("duplicate governance event_id [{}]", event.event_id), + ); + } + + if event.effective_at_unix == 0 { + push_rejection_reason( + reasons, + "audit_event_effective_time_invalid", + format!("event [{}] effective_at_unix must be > 0", event.event_id), + ); + } + + if event.governance_decision_id.trim().is_empty() { + push_rejection_reason( + reasons, + "audit_event_decision_id_missing", + format!( + "event [{}] governance_decision_id must be non-empty", + event.event_id + ), + ); + } + + match event.event_type { + GovernanceEventType::Add => { + let Some(operator_id) = event.operator_id.as_ref().and_then(|value| { + required_non_empty( + "operator_id", + value, + reasons, + "audit_add_operator_id_missing", + ) + }) else { + continue; + }; + + let Some(signer_identifier) = event.signer_identifier.as_ref().and_then(|value| { + required_non_empty( + "signer_identifier", + value, + reasons, + "audit_add_signer_identifier_missing", + ) + }) else { + continue; + }; + + let operator_id_normalized = trimmed_lowercase(&operator_id); + if status_by_operator.contains_key(&operator_id_normalized) { + push_rejection_reason( + reasons, + "audit_add_duplicate_operator", + format!( + "event [{}] adds operator [{}] more than once", + event.event_id, operator_id + ), + ); + continue; + } + + status_by_operator.insert(operator_id_normalized.clone(), OperatorStatus::Active); + signer_by_operator.insert(operator_id_normalized, signer_identifier); + } + GovernanceEventType::Suspend => { + let Some(operator_id) = event.operator_id.as_ref().and_then(|value| { + required_non_empty( + "operator_id", + value, + reasons, + "audit_suspend_operator_id_missing", + ) + }) else { + continue; + }; + + let operator_id_normalized = trimmed_lowercase(&operator_id); + let Some(status) = status_by_operator.get_mut(&operator_id_normalized) else { + push_rejection_reason( + reasons, + "audit_suspend_unknown_operator", + format!( + "event [{}] suspends unknown operator [{}]", + event.event_id, operator_id + ), + ); + continue; + }; + + if *status == OperatorStatus::Revoked { + push_rejection_reason( + reasons, + "audit_suspend_after_revoke", + format!( + "event [{}] cannot suspend revoked operator [{}]", + event.event_id, operator_id + ), + ); + continue; + } + + *status = OperatorStatus::Suspended; + } + GovernanceEventType::Revoke => { + let Some(operator_id) = event.operator_id.as_ref().and_then(|value| { + required_non_empty( + "operator_id", + value, + reasons, + "audit_revoke_operator_id_missing", + ) + }) else { + continue; + }; + + let operator_id_normalized = trimmed_lowercase(&operator_id); + let Some(status) = status_by_operator.get_mut(&operator_id_normalized) else { + push_rejection_reason( + reasons, + "audit_revoke_unknown_operator", + format!( + "event [{}] revokes unknown operator [{}]", + event.event_id, operator_id + ), + ); + continue; + }; + + *status = OperatorStatus::Revoked; + } + GovernanceEventType::MeasurementUpdate => { + let Some(operator_id) = event.operator_id.as_ref().and_then(|value| { + required_non_empty( + "operator_id", + value, + reasons, + "audit_measurement_update_operator_id_missing", + ) + }) else { + continue; + }; + + let Some(measurement_digest) = + event.measurement_digest.as_ref().and_then(|value| { + required_non_empty( + "measurement_digest", + value, + reasons, + "audit_measurement_digest_missing", + ) + }) + else { + continue; + }; + + let operator_id_normalized = trimmed_lowercase(&operator_id); + if !status_by_operator.contains_key(&operator_id_normalized) { + push_rejection_reason( + reasons, + "audit_measurement_update_unknown_operator", + format!( + "event [{}] updates measurement for unknown operator [{}]", + event.event_id, operator_id + ), + ); + continue; + } + + if !is_sha256_digest(&measurement_digest) { + push_rejection_reason( + reasons, + "audit_measurement_digest_invalid_format", + format!( + "event [{}] measurement_digest [{}] must match sha256:<64 hex chars>", + event.event_id, measurement_digest + ), + ); + } + } + GovernanceEventType::BreakGlassActivate => { + let Some(incident_ticket) = event.incident_ticket.as_ref().and_then(|value| { + required_non_empty( + "incident_ticket", + value, + reasons, + "audit_break_glass_ticket_missing", + ) + }) else { + continue; + }; + + let Some(expires_at_unix) = event.expires_at_unix else { + push_rejection_reason( + reasons, + "audit_break_glass_expiry_missing", + format!( + "event [{}] break_glass_activate requires expires_at_unix", + event.event_id + ), + ); + continue; + }; + + if expires_at_unix <= event.effective_at_unix { + push_rejection_reason( + reasons, + "audit_break_glass_expiry_invalid", + format!( + "event [{}] expires_at_unix [{}] must be greater than effective_at_unix [{}]", + event.event_id, expires_at_unix, event.effective_at_unix + ), + ); + } + + let ttl_seconds = expires_at_unix.saturating_sub(event.effective_at_unix); + if ttl_seconds > registry.enforcement.break_glass_ttl_seconds { + push_rejection_reason( + reasons, + "audit_break_glass_ttl_exceeds_policy", + format!( + "event [{}] break-glass ttl [{}] exceeds policy max [{}]", + event.event_id, + ttl_seconds, + registry.enforcement.break_glass_ttl_seconds + ), + ); + } + + let scope_operator_ids = event.scope_operator_ids.clone().unwrap_or_default(); + if scope_operator_ids.is_empty() { + push_rejection_reason( + reasons, + "audit_break_glass_scope_missing", + format!( + "event [{}] break_glass_activate requires non-empty scope_operator_ids", + event.event_id + ), + ); + } + + for scoped_operator_id in scope_operator_ids { + if scoped_operator_id.trim().is_empty() { + push_rejection_reason( + reasons, + "audit_break_glass_scope_contains_empty", + format!( + "event [{}] scope_operator_ids contains an empty operator_id", + event.event_id + ), + ); + continue; + } + + let scoped_operator_id_normalized = trimmed_lowercase(&scoped_operator_id); + match status_by_operator.get(&scoped_operator_id_normalized) { + None => { + push_rejection_reason( + reasons, + "audit_break_glass_scope_unknown_operator", + format!( + "event [{}] scope operator [{}] has no prior add event", + event.event_id, scoped_operator_id + ), + ); + } + Some(OperatorStatus::Revoked) => { + push_rejection_reason( + reasons, + "audit_break_glass_scope_revoked_operator", + format!( + "event [{}] scope operator [{}] is revoked; break-glass scope must target non-revoked operators", + event.event_id, scoped_operator_id + ), + ); + } + Some(_) => {} + } + } + + while let Some(front) = recent_break_glass_activations.front() { + if event.effective_at_unix.saturating_sub(*front) > SECONDS_PER_7_DAYS { + let _ = recent_break_glass_activations.pop_front(); + } else { + break; + } + } + recent_break_glass_activations.push_back(event.effective_at_unix); + + if recent_break_glass_activations.len() + > registry.enforcement.break_glass_max_activations_per_7d as usize + { + push_rejection_reason( + reasons, + "audit_break_glass_activation_limit_exceeded", + format!( + "event [{}] exceeds break_glass_max_activations_per_7d [{}]", + event.event_id, registry.enforcement.break_glass_max_activations_per_7d + ), + ); + } + + if let Some(last_activation) = last_break_glass_activation_unix { + let elapsed = event.effective_at_unix.saturating_sub(last_activation); + if elapsed < registry.enforcement.break_glass_cooldown_seconds { + push_rejection_reason( + reasons, + "audit_break_glass_cooldown_violation", + format!( + "event [{}] violates break-glass cooldown: elapsed [{}] < required [{}]", + event.event_id, elapsed, registry.enforcement.break_glass_cooldown_seconds + ), + ); + } + } + last_break_glass_activation_unix = Some(event.effective_at_unix); + + let incident_ticket_normalized = trimmed_lowercase(&incident_ticket); + if active_break_glass_incidents + .insert(incident_ticket_normalized.clone(), expires_at_unix) + .is_some() + { + push_rejection_reason( + reasons, + "audit_break_glass_duplicate_incident", + format!( + "event [{}] activates already-active break-glass incident [{}]", + event.event_id, incident_ticket + ), + ); + } + } + GovernanceEventType::BreakGlassExpire => { + let Some(incident_ticket) = event.incident_ticket.as_ref().and_then(|value| { + required_non_empty( + "incident_ticket", + value, + reasons, + "audit_break_glass_expire_ticket_missing", + ) + }) else { + continue; + }; + + let incident_ticket_normalized = trimmed_lowercase(&incident_ticket); + let Some(activated_expires_at_unix) = + active_break_glass_incidents.remove(&incident_ticket_normalized) + else { + push_rejection_reason( + reasons, + "audit_break_glass_expire_without_activation", + format!( + "event [{}] expires unknown break-glass incident [{}]", + event.event_id, incident_ticket + ), + ); + continue; + }; + + if event.effective_at_unix > activated_expires_at_unix { + push_rejection_reason( + reasons, + "audit_break_glass_expire_after_ttl", + format!( + "event [{}] expires incident [{}] after ttl deadline [{}]", + event.event_id, incident_ticket, activated_expires_at_unix + ), + ); + } + } + } + } + + for (incident_ticket, expires_at_unix) in active_break_glass_incidents { + if expires_at_unix <= now_unix_seconds { + push_rejection_reason( + reasons, + "audit_break_glass_missing_expire_event", + format!( + "break-glass incident [{}] expired at [{}] without break_glass_expire event", + incident_ticket, expires_at_unix + ), + ); + } + } + + let registry_operator_ids: HashSet = registry + .operators + .iter() + .map(|operator| trimmed_lowercase(&operator.operator_id)) + .collect(); + + for operator in ®istry.operators { + let operator_id = trimmed_lowercase(&operator.operator_id); + let Some(expected_status) = parse_operator_status(&operator.status) else { + continue; + }; + + match status_by_operator.get(&operator_id) { + Some(actual_status) => { + if actual_status != &expected_status { + push_rejection_reason( + reasons, + "operator_status_mismatch_with_events", + format!( + "operator [{}] registry status [{:?}] does not match event-derived status [{:?}]", + operator.operator_id, expected_status, actual_status + ), + ); + } + } + None => { + push_rejection_reason( + reasons, + "operator_missing_add_event", + format!( + "operator [{}] exists in registry but has no corresponding add event", + operator.operator_id + ), + ); + } + } + + if let Some(event_signer_identifier) = signer_by_operator.get(&operator_id) { + if trimmed_lowercase(event_signer_identifier) + != trimmed_lowercase(&operator.signer_identifier) + { + push_rejection_reason( + reasons, + "operator_signer_identifier_mismatch_with_events", + format!( + "operator [{}] signer_identifier [{}] does not match add-event signer_identifier [{}]", + operator.operator_id, operator.signer_identifier, event_signer_identifier + ), + ); + } + } + } + + for operator_id in status_by_operator.keys() { + if !registry_operator_ids.contains(operator_id) { + push_rejection_reason( + reasons, + "operator_present_in_events_missing_from_registry", + format!( + "operator [{}] appears in events but is missing from registry", + operator_id + ), + ); + } + } +} + +fn validate_registry( + registry: &TeeGovernanceRegistryV1, + events: Option<&[GovernanceAuditEvent]>, + now_unix_seconds: u64, +) -> ValidationDecision { + let mut reasons: Vec = Vec::new(); + + let profile_status = match parse_profile_status(®istry.profile_status) { + Some(profile_status) => profile_status, + None => { + push_rejection_reason( + &mut reasons, + "profile_status_invalid", + format!( + "profile_status [{}] must be one of [draft, mandatory]", + registry.profile_status + ), + ); + ProfileStatus::Draft + } + }; + + if registry.operators.is_empty() { + push_rejection_reason( + &mut reasons, + "operators_empty", + "registry must contain at least one operator record".to_string(), + ); + } + + validate_enforcement(®istry.enforcement, &mut reasons); + validate_operator_records(®istry.operators, now_unix_seconds, &mut reasons); + validate_activation_gate( + profile_status, + registry.activation_gate.as_ref(), + ®istry.enforcement, + now_unix_seconds, + &mut reasons, + ); + + if let Some(events) = events { + validate_governance_events(registry, events, now_unix_seconds, &mut reasons); + } + + ValidationDecision { + decision: if reasons.is_empty() { + "allow".to_string() + } else { + "reject".to_string() + }, + reasons, + validated_at_unix: now_unix_seconds, + } +} + +fn run() -> Result { + let args = env::args().skip(1).collect::>(); + let cli = parse_args(&args)?; + let registry: TeeGovernanceRegistryV1 = load_json_file(&cli.registry_path)?; + let events: Option> = match cli.events_path.as_ref() { + Some(path) => { + let input: GovernanceAuditInput = load_json_file(path)?; + Some(input.into_events()) + } + None => None, + }; + let now_unix_seconds = match cli.now_unix_override { + Some(now_unix_override) => now_unix_override, + None => now_unix()?, + }; + + Ok(validate_registry( + ®istry, + events.as_deref(), + now_unix_seconds, + )) +} + +fn main() { + match run() { + Ok(decision) => { + let json = serde_json::to_string_pretty(&decision).unwrap_or_else(|_| { + "{\"decision\":\"reject\",\"reasons\":[{\"code\":\"serialization_error\",\"detail\":\"failed to encode output\"}],\"validated_at_unix\":0}".to_string() + }); + println!("{json}"); + if decision.decision == "allow" { + std::process::exit(0); + } + std::process::exit(1); + } + Err(error) => { + eprintln!("{error}"); + eprintln!("{}", usage()); + std::process::exit(2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn baseline_registry() -> TeeGovernanceRegistryV1 { + TeeGovernanceRegistryV1 { + profile_status: "mandatory".to_string(), + enforcement: TeeEnforcementParameters { + attestation_max_age_seconds: 3_600, + grace_period_seconds: 900, + min_attested_signers_per_cohort: 4, + max_single_vendor_share_percent: 40, + denylist_max_staleness_seconds: 60, + break_glass_ttl_seconds: 21_600, + break_glass_max_activations_per_7d: 2, + break_glass_cooldown_seconds: 86_400, + break_glass_scope: "named_operator_ids_only".to_string(), + break_glass_quorum_bps: 6_700, + activation_gate_required_quorum_bps: 6_700, + re_attestation_poll_interval_seconds: 300, + }, + operators: vec![ + TeeOperatorAdmissionRecord { + operator_id: "operator-1".to_string(), + signer_identifier: "signer-1".to_string(), + status: "active".to_string(), + allowed_tee_types: vec!["sgx".to_string(), "sev-snp".to_string()], + allowed_measurements: vec![ + "sha256:1111111111111111111111111111111111111111111111111111111111111111" + .to_string(), + ], + attestation_max_age_seconds: 3_600, + grace_period_seconds: 900, + effective_from: 1_700_000_000, + effective_until: None, + }, + TeeOperatorAdmissionRecord { + operator_id: "operator-2".to_string(), + signer_identifier: "signer-2".to_string(), + status: "suspended".to_string(), + allowed_tee_types: vec!["tdx".to_string()], + allowed_measurements: vec![ + "sha256:2222222222222222222222222222222222222222222222222222222222222222" + .to_string(), + ], + attestation_max_age_seconds: 3_600, + grace_period_seconds: 900, + effective_from: 1_700_000_100, + effective_until: None, + }, + TeeOperatorAdmissionRecord { + operator_id: "operator-3".to_string(), + signer_identifier: "signer-3".to_string(), + status: "revoked".to_string(), + allowed_tee_types: vec!["sgx".to_string()], + allowed_measurements: vec![ + "sha256:3333333333333333333333333333333333333333333333333333333333333333" + .to_string(), + ], + attestation_max_age_seconds: 3_600, + grace_period_seconds: 900, + effective_from: 1_700_000_200, + effective_until: Some(1_700_000_900), + }, + ], + activation_gate: Some(TeeActivationGateRecord { + governance_decision_id: "proposal-42".to_string(), + effective_at_unix: 1_700_001_000, + quorum_denominator: 100_000, + achieved_quorum_bps: 7_400, + approvers: vec![ + TeeActivationApprover { + approver_id: "security-owner-1".to_string(), + role: "security_owner".to_string(), + decision: "approved".to_string(), + decided_at_unix: 1_700_000_950, + }, + TeeActivationApprover { + approver_id: "runtime-owner-1".to_string(), + role: "signer_runtime_owner".to_string(), + decision: "approved".to_string(), + decided_at_unix: 1_700_000_960, + }, + TeeActivationApprover { + approver_id: "delegate-1".to_string(), + role: "governance_delegate".to_string(), + decision: "approved".to_string(), + decided_at_unix: 1_700_000_970, + }, + ], + profile_status_transition: "draft -> mandatory".to_string(), + rollback_condition: "critical verifier compromise".to_string(), + rollback_authority: "security council multisig".to_string(), + }), + } + } + + fn baseline_events() -> Vec { + vec![ + GovernanceAuditEvent { + event_id: "evt-1".to_string(), + event_type: GovernanceEventType::Add, + operator_id: Some("operator-1".to_string()), + signer_identifier: Some("signer-1".to_string()), + measurement_digest: None, + governance_decision_id: "proposal-10".to_string(), + effective_at_unix: 1_700_000_010, + incident_ticket: None, + scope_operator_ids: None, + expires_at_unix: None, + }, + GovernanceAuditEvent { + event_id: "evt-2".to_string(), + event_type: GovernanceEventType::Add, + operator_id: Some("operator-2".to_string()), + signer_identifier: Some("signer-2".to_string()), + measurement_digest: None, + governance_decision_id: "proposal-11".to_string(), + effective_at_unix: 1_700_000_020, + incident_ticket: None, + scope_operator_ids: None, + expires_at_unix: None, + }, + GovernanceAuditEvent { + event_id: "evt-3".to_string(), + event_type: GovernanceEventType::Add, + operator_id: Some("operator-3".to_string()), + signer_identifier: Some("signer-3".to_string()), + measurement_digest: None, + governance_decision_id: "proposal-12".to_string(), + effective_at_unix: 1_700_000_030, + incident_ticket: None, + scope_operator_ids: None, + expires_at_unix: None, + }, + GovernanceAuditEvent { + event_id: "evt-4".to_string(), + event_type: GovernanceEventType::Suspend, + operator_id: Some("operator-2".to_string()), + signer_identifier: None, + measurement_digest: None, + governance_decision_id: "proposal-20".to_string(), + effective_at_unix: 1_700_000_100, + incident_ticket: None, + scope_operator_ids: None, + expires_at_unix: None, + }, + GovernanceAuditEvent { + event_id: "evt-5".to_string(), + event_type: GovernanceEventType::Revoke, + operator_id: Some("operator-3".to_string()), + signer_identifier: None, + measurement_digest: None, + governance_decision_id: "proposal-21".to_string(), + effective_at_unix: 1_700_000_200, + incident_ticket: None, + scope_operator_ids: None, + expires_at_unix: None, + }, + GovernanceAuditEvent { + event_id: "evt-6".to_string(), + event_type: GovernanceEventType::MeasurementUpdate, + operator_id: Some("operator-1".to_string()), + signer_identifier: None, + measurement_digest: Some( + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + ), + governance_decision_id: "proposal-22".to_string(), + effective_at_unix: 1_700_000_300, + incident_ticket: None, + scope_operator_ids: None, + expires_at_unix: None, + }, + GovernanceAuditEvent { + event_id: "evt-7".to_string(), + event_type: GovernanceEventType::BreakGlassActivate, + operator_id: None, + signer_identifier: None, + measurement_digest: None, + governance_decision_id: "proposal-30".to_string(), + effective_at_unix: 1_700_000_400, + incident_ticket: Some("INC-123".to_string()), + scope_operator_ids: Some(vec!["operator-2".to_string()]), + expires_at_unix: Some(1_700_004_000), + }, + GovernanceAuditEvent { + event_id: "evt-8".to_string(), + event_type: GovernanceEventType::BreakGlassExpire, + operator_id: None, + signer_identifier: None, + measurement_digest: None, + governance_decision_id: "proposal-31".to_string(), + effective_at_unix: 1_700_001_000, + incident_ticket: Some("INC-123".to_string()), + scope_operator_ids: None, + expires_at_unix: None, + }, + ] + } + + #[test] + fn validate_registry_allows_compliant_registry_and_events() { + let registry = baseline_registry(); + let events = baseline_events(); + + let decision = validate_registry(®istry, Some(events.as_slice()), 1_700_100_000); + assert_eq!(decision.decision, "allow"); + assert!(decision.reasons.is_empty()); + } + + #[test] + fn validate_registry_rejects_mandatory_profile_without_activation_gate() { + let mut registry = baseline_registry(); + registry.activation_gate = None; + let events = baseline_events(); + + let decision = validate_registry(®istry, Some(events.as_slice()), 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "mandatory_profile_missing_activation_gate")); + } + + #[test] + fn validate_registry_rejects_invalid_break_glass_cooldown() { + let mut registry = baseline_registry(); + let mut events = baseline_events(); + + events.push(GovernanceAuditEvent { + event_id: "evt-9".to_string(), + event_type: GovernanceEventType::BreakGlassActivate, + operator_id: None, + signer_identifier: None, + measurement_digest: None, + governance_decision_id: "proposal-32".to_string(), + effective_at_unix: 1_700_001_100, + incident_ticket: Some("INC-456".to_string()), + scope_operator_ids: Some(vec!["operator-1".to_string()]), + expires_at_unix: Some(1_700_005_000), + }); + + registry.enforcement.break_glass_cooldown_seconds = 86_400; + let decision = validate_registry(®istry, Some(events.as_slice()), 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "audit_break_glass_cooldown_violation")); + } + + #[test] + fn validate_registry_rejects_break_glass_activation_limit_violation() { + let mut registry = baseline_registry(); + let mut events = baseline_events(); + + events.push(GovernanceAuditEvent { + event_id: "evt-9".to_string(), + event_type: GovernanceEventType::BreakGlassActivate, + operator_id: None, + signer_identifier: None, + measurement_digest: None, + governance_decision_id: "proposal-32".to_string(), + effective_at_unix: 1_700_090_000, + incident_ticket: Some("INC-456".to_string()), + scope_operator_ids: Some(vec!["operator-1".to_string()]), + expires_at_unix: Some(1_700_093_000), + }); + events.push(GovernanceAuditEvent { + event_id: "evt-10".to_string(), + event_type: GovernanceEventType::BreakGlassActivate, + operator_id: None, + signer_identifier: None, + measurement_digest: None, + governance_decision_id: "proposal-33".to_string(), + effective_at_unix: 1_700_180_000, + incident_ticket: Some("INC-789".to_string()), + scope_operator_ids: Some(vec!["operator-2".to_string()]), + expires_at_unix: Some(1_700_183_000), + }); + + registry.enforcement.break_glass_max_activations_per_7d = 2; + registry.enforcement.break_glass_cooldown_seconds = 10; + + let decision = validate_registry(®istry, Some(events.as_slice()), 1_700_200_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "audit_break_glass_activation_limit_exceeded")); + } + + #[test] + fn validate_registry_rejects_status_mismatch_with_events() { + let mut registry = baseline_registry(); + let events = baseline_events(); + registry.operators[0].status = "suspended".to_string(); + + let decision = validate_registry(®istry, Some(events.as_slice()), 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "operator_status_mismatch_with_events")); + } + + #[test] + fn validate_registry_rejects_operators_without_add_events() { + let registry = baseline_registry(); + let events = vec![]; + + let decision = validate_registry(®istry, Some(events.as_slice()), 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "audit_events_missing")); + } + + #[test] + fn validate_registry_rejects_invalid_enforcement_bounds() { + let mut registry = baseline_registry(); + registry.enforcement.denylist_max_staleness_seconds = MAX_DENYLIST_STALENESS_SECONDS + 1; + registry.enforcement.break_glass_scope = "global".to_string(); + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "denylist_max_staleness_out_of_bounds")); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_scope_not_supported")); + } + + #[test] + fn validate_registry_rejects_attestation_max_age_above_hard_ceiling() { + let mut registry = baseline_registry(); + registry.enforcement.attestation_max_age_seconds = MAX_ATTESTATION_MAX_AGE_SECONDS + 1; + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "attestation_max_age_exceeds_hard_ceiling")); + } + + #[test] + fn validate_registry_rejects_zero_break_glass_cooldown() { + let mut registry = baseline_registry(); + registry.enforcement.break_glass_cooldown_seconds = 0; + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_cooldown_invalid")); + } + + #[test] + fn validate_registry_rejects_break_glass_ttl_exceeding_7_day_max() { + let mut registry = baseline_registry(); + registry.enforcement.break_glass_ttl_seconds = SECONDS_PER_7_DAYS + 1; + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "break_glass_ttl_invalid")); + } + + #[test] + fn validate_registry_rejects_activation_gate_quorum_below_dedicated_minimum() { + let mut registry = baseline_registry(); + registry.enforcement.break_glass_quorum_bps = 5_000; + registry.enforcement.activation_gate_required_quorum_bps = 6_700; + if let Some(activation_gate) = registry.activation_gate.as_mut() { + activation_gate.achieved_quorum_bps = 6_600; + } + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "activation_gate_quorum_below_required")); + } + + #[test] + fn validate_registry_rejects_duplicate_activation_gate_approver_ids() { + let mut registry = baseline_registry(); + if let Some(activation_gate) = registry.activation_gate.as_mut() { + activation_gate.approvers[1].approver_id = + activation_gate.approvers[0].approver_id.clone(); + } + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "activation_gate_approver_id_duplicate")); + } + + #[test] + fn validate_registry_rejects_break_glass_scope_for_revoked_operator() { + let registry = baseline_registry(); + let mut events = baseline_events(); + events.push(GovernanceAuditEvent { + event_id: "evt-9".to_string(), + event_type: GovernanceEventType::BreakGlassActivate, + operator_id: None, + signer_identifier: None, + measurement_digest: None, + governance_decision_id: "proposal-40".to_string(), + effective_at_unix: 1_700_090_000, + incident_ticket: Some("INC-999".to_string()), + scope_operator_ids: Some(vec!["operator-3".to_string()]), + expires_at_unix: Some(1_700_093_000), + }); + + let decision = validate_registry(®istry, Some(events.as_slice()), 1_700_200_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "audit_break_glass_scope_revoked_operator")); + } + + #[test] + fn validate_registry_rejects_invalid_measurement_digest_format() { + let mut registry = baseline_registry(); + registry.operators[0].allowed_measurements = vec!["sha256:nothex".to_string()]; + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "allowed_measurement_digest_invalid")); + } + + #[test] + fn validate_registry_rejects_duplicate_operator_ids() { + let mut registry = baseline_registry(); + registry.operators[1].operator_id = "operator-1".to_string(); + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "operator_id_duplicate")); + } + + #[test] + fn validate_registry_rejects_duplicate_signer_identifiers() { + let mut registry = baseline_registry(); + registry.operators[1].signer_identifier = "signer-1".to_string(); + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "signer_identifier_duplicate")); + } + + #[test] + fn validate_registry_rejects_invalid_operator_status() { + let mut registry = baseline_registry(); + registry.operators[0].status = "unknown".to_string(); + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "operator_status_invalid")); + } + + #[test] + fn validate_registry_rejects_empty_allowed_tee_types() { + let mut registry = baseline_registry(); + registry.operators[0].allowed_tee_types.clear(); + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "allowed_tee_types_missing")); + } + + #[test] + fn validate_registry_rejects_active_operator_with_expired_window() { + let mut registry = baseline_registry(); + registry.operators[0].effective_until = Some(1_700_000_500); + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "active_operator_window_expired")); + } + + #[test] + fn validate_registry_rejects_missing_activation_gate_rollback_condition() { + let mut registry = baseline_registry(); + if let Some(activation_gate) = registry.activation_gate.as_mut() { + activation_gate.rollback_condition.clear(); + } + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "activation_gate_rollback_condition_missing" })); + } + + #[test] + fn validate_registry_rejects_activation_gate_role_not_approved() { + let mut registry = baseline_registry(); + if let Some(activation_gate) = registry.activation_gate.as_mut() { + activation_gate.approvers[0].decision = "rejected".to_string(); + } + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "activation_gate_role_not_approved")); + } + + #[test] + fn validate_registry_rejects_activation_gate_missing_required_role() { + let mut registry = baseline_registry(); + if let Some(activation_gate) = registry.activation_gate.as_mut() { + activation_gate + .approvers + .retain(|approver| approver.role != "governance_delegate"); + } + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "activation_gate_role_missing")); + } + + #[test] + fn validate_registry_rejects_non_chronological_events() { + let registry = baseline_registry(); + let mut events = baseline_events(); + events[1].effective_at_unix = events[0].effective_at_unix.saturating_sub(1); + + let decision = validate_registry(®istry, Some(events.as_slice()), 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "audit_events_not_chronological")); + } + + #[test] + fn validate_registry_rejects_duplicate_event_ids() { + let registry = baseline_registry(); + let mut events = baseline_events(); + events[1].event_id = events[0].event_id.clone(); + + let decision = validate_registry(®istry, Some(events.as_slice()), 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "audit_event_id_duplicate")); + } + + #[test] + fn validate_registry_rejects_suspend_after_revoke() { + let registry = baseline_registry(); + let mut events = baseline_events(); + events.push(GovernanceAuditEvent { + event_id: "evt-extra".to_string(), + event_type: GovernanceEventType::Suspend, + operator_id: Some("operator-3".to_string()), + signer_identifier: None, + measurement_digest: None, + governance_decision_id: "proposal-99".to_string(), + effective_at_unix: 1_700_001_100, + incident_ticket: None, + scope_operator_ids: None, + expires_at_unix: None, + }); + + let decision = validate_registry(®istry, Some(events.as_slice()), 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "audit_suspend_after_revoke")); + } + + #[test] + fn validate_registry_rejects_invalid_profile_status() { + let mut registry = baseline_registry(); + registry.profile_status = "unknown".to_string(); + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "profile_status_invalid")); + } + + #[test] + fn validate_registry_rejects_empty_operators_list() { + let mut registry = baseline_registry(); + registry.operators.clear(); + + let decision = validate_registry(®istry, None, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "operators_empty")); + } + + #[test] + fn parse_args_accepts_required_flags() { + let args = vec!["--registry".to_string(), "registry.json".to_string()]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!(parsed.registry_path, PathBuf::from("registry.json")); + assert!(parsed.events_path.is_none()); + assert!(parsed.now_unix_override.is_none()); + } + + #[test] + fn parse_args_accepts_optional_flags() { + let args = vec![ + "--registry".to_string(), + "registry.json".to_string(), + "--events".to_string(), + "events.json".to_string(), + "--now-unix".to_string(), + "1700100000".to_string(), + ]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!(parsed.registry_path, PathBuf::from("registry.json")); + assert_eq!(parsed.events_path, Some(PathBuf::from("events.json"))); + assert_eq!(parsed.now_unix_override, Some(1_700_100_000)); + } + + #[test] + fn parse_args_rejects_missing_registry() { + let args = vec!["--events".to_string(), "events.json".to_string()]; + + let error = parse_args(&args).expect_err("expected parse failure"); + assert_eq!(error, "missing required --registry"); + } +} diff --git a/pkg/tbtc/signer/src/bin/tee_runtime_checker.rs b/pkg/tbtc/signer/src/bin/tee_runtime_checker.rs new file mode 100644 index 0000000000..17e46b4994 --- /dev/null +++ b/pkg/tbtc/signer/src/bin/tee_runtime_checker.rs @@ -0,0 +1,1333 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MAX_RELAXED_VENDOR_SHARE_PERCENT: u64 = 60; +const RELAXATION_TTL_SECONDS: u64 = 21_600; +const MAX_GRACE_PERIOD_SECONDS: u64 = 3_600; +const MAX_ATTESTATION_MAX_AGE_SECONDS: u64 = 86_400; +const MAX_DENYLIST_STALENESS_SECONDS: u64 = 300; +const MIN_ATTESTED_SIGNERS_PER_COHORT_FLOOR: u64 = 2; + +#[derive(Clone, Debug, Deserialize)] +struct TeeGovernanceRegistryV1 { + profile_status: String, + enforcement: TeeEnforcementParameters, + operators: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct TeeEnforcementParameters { + attestation_max_age_seconds: u64, + grace_period_seconds: u64, + min_attested_signers_per_cohort: u64, + max_single_vendor_share_percent: u64, + denylist_max_staleness_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize)] +struct TeeOperatorAdmissionRecord { + operator_id: String, + signer_identifier: String, + status: String, + effective_from: u64, + #[serde(default)] + effective_until: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct RuntimeSessionInputV1 { + session_id: String, + phase: String, + threshold: u64, + selected_signers: Vec, + denylist: RuntimeDenylistSnapshot, + #[serde(default)] + vendor_outage: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct RuntimeSelectedSigner { + operator_id: String, + signer_identifier: String, + vendor_id: String, + token: RuntimeTokenSnapshot, +} + +#[derive(Clone, Debug, Deserialize)] +struct RuntimeTokenSnapshot { + token_id: String, + issued_at_unix: u64, + expires_at_unix: u64, + token_revocation_epoch: u64, +} + +#[derive(Clone, Debug, Deserialize)] +struct RuntimeDenylistSnapshot { + refreshed_at_unix: u64, + #[serde(default)] + revoked_operator_ids: Vec, + #[serde(default)] + revoked_signer_identifiers: Vec, + #[serde(default)] + revoked_token_ids: Vec, + #[serde(default)] + min_token_revocation_epoch: u64, +} + +#[derive(Clone, Debug, Deserialize)] +struct VendorOutageRelaxation { + declared: bool, + declared_at_unix: u64, + relaxed_max_single_vendor_share_percent: u64, + expires_at_unix: u64, +} + +#[derive(Clone, Debug, Serialize)] +struct ValidationReason { + code: String, + detail: String, +} + +#[derive(Clone, Debug, Serialize)] +struct ValidationDecision { + decision: String, + reasons: Vec, + validated_at_unix: u64, +} + +#[derive(Debug)] +struct CliArgs { + registry_path: PathBuf, + session_path: PathBuf, + now_unix_override: Option, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum OperatorStatus { + Active, + Suspended, + Revoked, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum RuntimePhase { + SessionStart, + MidSession, +} + +fn usage() -> String { + "Usage: tee_runtime_checker --registry --session [--now-unix ]" + .to_string() +} + +fn parse_args(args: &[String]) -> Result { + let mut registry_path: Option = None; + let mut session_path: Option = None; + let mut now_unix_override: Option = None; + + let mut i = 0usize; + while i < args.len() { + match args[i].as_str() { + "--registry" => { + i += 1; + if i >= args.len() { + return Err("missing value for --registry".to_string()); + } + registry_path = Some(PathBuf::from(&args[i])); + } + "--session" => { + i += 1; + if i >= args.len() { + return Err("missing value for --session".to_string()); + } + session_path = Some(PathBuf::from(&args[i])); + } + "--now-unix" => { + i += 1; + if i >= args.len() { + return Err("missing value for --now-unix".to_string()); + } + now_unix_override = Some( + args[i] + .parse::() + .map_err(|_| "invalid value for --now-unix".to_string())?, + ); + } + "--help" | "-h" => { + return Err(usage()); + } + unknown => { + return Err(format!("unknown argument [{unknown}]")); + } + } + i += 1; + } + + let registry_path = registry_path.ok_or_else(|| "missing required --registry".to_string())?; + let session_path = session_path.ok_or_else(|| "missing required --session".to_string())?; + + Ok(CliArgs { + registry_path, + session_path, + now_unix_override, + }) +} + +fn now_unix() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|error| format!("system clock must be after UNIX epoch: {error}")) +} + +fn load_json_file Deserialize<'de>>(path: &PathBuf) -> Result { + let bytes = fs::read(path) + .map_err(|error| format!("failed to read file [{}]: {error}", path.display()))?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse JSON file [{}]: {error}", path.display())) +} + +fn trimmed_lowercase(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn parse_operator_status(status: &str) -> Option { + match trimmed_lowercase(status).as_str() { + "active" => Some(OperatorStatus::Active), + "suspended" => Some(OperatorStatus::Suspended), + "revoked" => Some(OperatorStatus::Revoked), + _ => None, + } +} + +fn parse_runtime_phase(phase: &str) -> Option { + match trimmed_lowercase(phase).as_str() { + "session_start" => Some(RuntimePhase::SessionStart), + "mid_session" => Some(RuntimePhase::MidSession), + _ => None, + } +} + +fn push_rejection_reason(reasons: &mut Vec, code: &str, detail: String) { + reasons.push(ValidationReason { + code: code.to_string(), + detail, + }); +} + +fn normalize_denylist( + snapshot: &RuntimeDenylistSnapshot, +) -> (HashSet, HashSet, HashSet) { + let revoked_operator_ids = snapshot + .revoked_operator_ids + .iter() + .map(|operator_id| trimmed_lowercase(operator_id)) + .collect::>(); + let revoked_signer_identifiers = snapshot + .revoked_signer_identifiers + .iter() + .map(|signer| trimmed_lowercase(signer)) + .collect::>(); + let revoked_token_ids = snapshot + .revoked_token_ids + .iter() + .map(|token_id| trimmed_lowercase(token_id)) + .collect::>(); + + ( + revoked_operator_ids, + revoked_signer_identifiers, + revoked_token_ids, + ) +} + +fn find_operator<'a>( + registry: &'a TeeGovernanceRegistryV1, + operator_id: &str, +) -> Option<&'a TeeOperatorAdmissionRecord> { + let operator_id = trimmed_lowercase(operator_id); + registry + .operators + .iter() + .find(|operator| trimmed_lowercase(&operator.operator_id) == operator_id) +} + +fn effective_vendor_cap_percent( + policy_cap_percent: u64, + vendor_outage: Option<&VendorOutageRelaxation>, + now_unix_seconds: u64, + reasons: &mut Vec, +) -> u64 { + let Some(vendor_outage) = vendor_outage else { + return policy_cap_percent; + }; + + if !vendor_outage.declared { + push_rejection_reason( + reasons, + "vendor_outage_not_declared", + "vendor_outage object provided but declared=false".to_string(), + ); + return policy_cap_percent; + } + + if vendor_outage.declared_at_unix == 0 { + push_rejection_reason( + reasons, + "vendor_outage_declared_at_invalid", + "vendor_outage.declared_at_unix must be > 0".to_string(), + ); + return policy_cap_percent; + } + + if vendor_outage.declared_at_unix > now_unix_seconds { + push_rejection_reason( + reasons, + "vendor_outage_declared_in_future", + format!( + "vendor_outage.declared_at_unix [{}] is in the future relative to now [{}]", + vendor_outage.declared_at_unix, now_unix_seconds + ), + ); + return policy_cap_percent; + } + + if vendor_outage.relaxed_max_single_vendor_share_percent < policy_cap_percent { + push_rejection_reason( + reasons, + "vendor_outage_relaxation_below_policy", + format!( + "vendor_outage relaxed cap [{}] cannot be below policy cap [{}]", + vendor_outage.relaxed_max_single_vendor_share_percent, policy_cap_percent + ), + ); + return policy_cap_percent; + } + + if vendor_outage.relaxed_max_single_vendor_share_percent > MAX_RELAXED_VENDOR_SHARE_PERCENT { + push_rejection_reason( + reasons, + "vendor_outage_relaxation_exceeds_maximum", + format!( + "vendor_outage relaxed cap [{}] exceeds maximum [{}]", + vendor_outage.relaxed_max_single_vendor_share_percent, + MAX_RELAXED_VENDOR_SHARE_PERCENT + ), + ); + return policy_cap_percent; + } + + if ![40u64, 50u64, 60u64].contains(&vendor_outage.relaxed_max_single_vendor_share_percent) { + push_rejection_reason( + reasons, + "vendor_outage_relaxation_step_invalid", + format!( + "vendor_outage relaxed cap [{}] must be one of [40, 50, 60]", + vendor_outage.relaxed_max_single_vendor_share_percent + ), + ); + return policy_cap_percent; + } + + if vendor_outage.expires_at_unix <= vendor_outage.declared_at_unix { + push_rejection_reason( + reasons, + "vendor_outage_expiry_window_invalid", + format!( + "vendor_outage.expires_at_unix [{}] must be greater than declared_at_unix [{}]", + vendor_outage.expires_at_unix, vendor_outage.declared_at_unix + ), + ); + return policy_cap_percent; + } + + if vendor_outage.expires_at_unix <= now_unix_seconds { + push_rejection_reason( + reasons, + "vendor_outage_relaxation_expired", + format!( + "vendor_outage relaxation expired at [{}], now [{}]", + vendor_outage.expires_at_unix, now_unix_seconds + ), + ); + return policy_cap_percent; + } + + let ttl_seconds = vendor_outage + .expires_at_unix + .saturating_sub(vendor_outage.declared_at_unix); + if ttl_seconds > RELAXATION_TTL_SECONDS { + push_rejection_reason( + reasons, + "vendor_outage_relaxation_ttl_exceeds_maximum", + format!( + "vendor_outage relaxation ttl [{}] exceeds maximum [{}]", + ttl_seconds, RELAXATION_TTL_SECONDS + ), + ); + return policy_cap_percent; + } + + vendor_outage.relaxed_max_single_vendor_share_percent +} + +fn validate_runtime( + registry: &TeeGovernanceRegistryV1, + session: &RuntimeSessionInputV1, + now_unix_seconds: u64, +) -> ValidationDecision { + let mut reasons = Vec::new(); + + if trimmed_lowercase(®istry.profile_status) != "mandatory" { + push_rejection_reason( + &mut reasons, + "governance_profile_not_mandatory", + format!( + "governance registry profile_status [{}] is not mandatory", + registry.profile_status + ), + ); + } + + if registry.enforcement.grace_period_seconds > MAX_GRACE_PERIOD_SECONDS { + push_rejection_reason( + &mut reasons, + "grace_period_exceeds_hard_ceiling", + format!( + "grace_period_seconds [{}] exceeds hard ceiling [{}]", + registry.enforcement.grace_period_seconds, MAX_GRACE_PERIOD_SECONDS + ), + ); + } + + if registry.enforcement.attestation_max_age_seconds > MAX_ATTESTATION_MAX_AGE_SECONDS { + push_rejection_reason( + &mut reasons, + "attestation_max_age_exceeds_hard_ceiling", + format!( + "attestation_max_age_seconds [{}] exceeds hard ceiling [{}]", + registry.enforcement.attestation_max_age_seconds, MAX_ATTESTATION_MAX_AGE_SECONDS + ), + ); + } + + if registry.enforcement.denylist_max_staleness_seconds == 0 { + push_rejection_reason( + &mut reasons, + "denylist_max_staleness_invalid_zero", + "denylist_max_staleness_seconds must be > 0".to_string(), + ); + } else if registry.enforcement.denylist_max_staleness_seconds > MAX_DENYLIST_STALENESS_SECONDS { + push_rejection_reason( + &mut reasons, + "denylist_max_staleness_exceeds_hard_ceiling", + format!( + "denylist_max_staleness_seconds [{}] exceeds hard ceiling [{}]", + registry.enforcement.denylist_max_staleness_seconds, MAX_DENYLIST_STALENESS_SECONDS + ), + ); + } + + if registry.enforcement.min_attested_signers_per_cohort < MIN_ATTESTED_SIGNERS_PER_COHORT_FLOOR + { + push_rejection_reason( + &mut reasons, + "min_attested_signers_below_absolute_floor", + format!( + "min_attested_signers_per_cohort [{}] below absolute floor [{}]", + registry.enforcement.min_attested_signers_per_cohort, + MIN_ATTESTED_SIGNERS_PER_COHORT_FLOOR + ), + ); + } + + if registry.enforcement.max_single_vendor_share_percent == 0 + || registry.enforcement.max_single_vendor_share_percent > MAX_RELAXED_VENDOR_SHARE_PERCENT + { + push_rejection_reason( + &mut reasons, + "max_single_vendor_share_percent_out_of_bounds", + format!( + "max_single_vendor_share_percent [{}] must be in range [1, {}]", + registry.enforcement.max_single_vendor_share_percent, + MAX_RELAXED_VENDOR_SHARE_PERCENT + ), + ); + } + + let runtime_phase = match parse_runtime_phase(&session.phase) { + Some(runtime_phase) => runtime_phase, + None => { + push_rejection_reason( + &mut reasons, + "runtime_phase_invalid", + format!( + "session phase [{}] must be one of [session_start, mid_session]", + session.phase + ), + ); + RuntimePhase::SessionStart + } + }; + + if session.session_id.trim().is_empty() { + push_rejection_reason( + &mut reasons, + "session_id_missing", + "session_id must be non-empty".to_string(), + ); + } + + if session.threshold == 0 { + push_rejection_reason( + &mut reasons, + "threshold_invalid", + "threshold must be > 0".to_string(), + ); + } + + let required_min_attested = session.threshold.saturating_add(1); + if registry.enforcement.min_attested_signers_per_cohort < required_min_attested { + push_rejection_reason( + &mut reasons, + "min_attested_signers_below_threshold_plus_one", + format!( + "policy min_attested_signers_per_cohort [{}] below required threshold+1 [{}]", + registry.enforcement.min_attested_signers_per_cohort, required_min_attested + ), + ); + } + + if session.selected_signers.len() + < registry.enforcement.min_attested_signers_per_cohort as usize + { + push_rejection_reason( + &mut reasons, + "selected_signers_below_policy_minimum", + format!( + "selected_signers [{}] below min_attested_signers_per_cohort [{}]", + session.selected_signers.len(), + registry.enforcement.min_attested_signers_per_cohort + ), + ); + } + + if session.selected_signers.is_empty() { + push_rejection_reason( + &mut reasons, + "selected_signers_empty", + "selected_signers must contain at least one signer".to_string(), + ); + } + + if session.denylist.refreshed_at_unix == 0 { + push_rejection_reason( + &mut reasons, + "denylist_refreshed_at_invalid", + "denylist.refreshed_at_unix must be > 0".to_string(), + ); + } else if session.denylist.refreshed_at_unix > now_unix_seconds { + push_rejection_reason( + &mut reasons, + "denylist_refreshed_at_in_future", + format!( + "denylist.refreshed_at_unix [{}] is in the future relative to now [{}]", + session.denylist.refreshed_at_unix, now_unix_seconds + ), + ); + } else { + let denylist_age_seconds = + now_unix_seconds.saturating_sub(session.denylist.refreshed_at_unix); + if denylist_age_seconds > registry.enforcement.denylist_max_staleness_seconds { + push_rejection_reason( + &mut reasons, + "denylist_stale", + format!( + "denylist age [{}] exceeds max staleness [{}]", + denylist_age_seconds, registry.enforcement.denylist_max_staleness_seconds + ), + ); + } + } + + let (revoked_operator_ids, revoked_signer_identifiers, revoked_token_ids) = + normalize_denylist(&session.denylist); + + let vendor_cap_percent = effective_vendor_cap_percent( + registry.enforcement.max_single_vendor_share_percent, + session.vendor_outage.as_ref(), + now_unix_seconds, + &mut reasons, + ); + + let mut vendor_counts: HashMap = HashMap::new(); + let selected_signer_count = session.selected_signers.len(); + + let mut seen_operators = HashSet::new(); + let mut seen_signer_identifiers = HashSet::new(); + let mut seen_token_ids = HashSet::new(); + + for selected_signer in &session.selected_signers { + let operator_id = trimmed_lowercase(&selected_signer.operator_id); + let signer_identifier = trimmed_lowercase(&selected_signer.signer_identifier); + let vendor_id = trimmed_lowercase(&selected_signer.vendor_id); + let token_id = trimmed_lowercase(&selected_signer.token.token_id); + + if operator_id.is_empty() { + push_rejection_reason( + &mut reasons, + "selected_operator_id_missing", + "selected signer operator_id must be non-empty".to_string(), + ); + continue; + } + + if signer_identifier.is_empty() { + push_rejection_reason( + &mut reasons, + "selected_signer_identifier_missing", + "selected signer signer_identifier must be non-empty".to_string(), + ); + continue; + } + + if vendor_id.is_empty() { + push_rejection_reason( + &mut reasons, + "selected_vendor_id_missing", + format!( + "selected signer [{}] vendor_id must be non-empty", + selected_signer.operator_id + ), + ); + continue; + } + + if token_id.is_empty() { + push_rejection_reason( + &mut reasons, + "selected_token_id_missing", + format!( + "selected signer [{}] token.token_id must be non-empty", + selected_signer.operator_id + ), + ); + continue; + } + + if !seen_operators.insert(operator_id.clone()) { + push_rejection_reason( + &mut reasons, + "selected_operator_id_duplicate", + format!( + "operator_id [{}] appears more than once in selected_signers", + selected_signer.operator_id + ), + ); + } + + if !seen_signer_identifiers.insert(signer_identifier.clone()) { + push_rejection_reason( + &mut reasons, + "selected_signer_identifier_duplicate", + format!( + "signer_identifier [{}] appears more than once in selected_signers", + selected_signer.signer_identifier + ), + ); + } + + if !seen_token_ids.insert(token_id.clone()) { + push_rejection_reason( + &mut reasons, + "selected_token_id_duplicate", + format!( + "token_id [{}] appears more than once in selected_signers", + selected_signer.token.token_id + ), + ); + } + + let Some(operator) = find_operator(registry, &operator_id) else { + push_rejection_reason( + &mut reasons, + "selected_operator_not_in_registry", + format!( + "selected operator_id [{}] not found in governance registry", + selected_signer.operator_id + ), + ); + continue; + }; + + if parse_operator_status(&operator.status) != Some(OperatorStatus::Active) { + push_rejection_reason( + &mut reasons, + "selected_operator_not_active", + format!( + "selected operator_id [{}] has non-active status [{}]", + operator.operator_id, operator.status + ), + ); + } + + if trimmed_lowercase(&operator.signer_identifier) != signer_identifier { + push_rejection_reason( + &mut reasons, + "selected_signer_identifier_registry_mismatch", + format!( + "selected signer_identifier [{}] does not match registry signer_identifier [{}]", + selected_signer.signer_identifier, operator.signer_identifier + ), + ); + } + + if selected_signer.token.issued_at_unix < operator.effective_from { + push_rejection_reason( + &mut reasons, + "selected_token_before_operator_effective_from", + format!( + "selected token for operator_id [{}] issued_at_unix [{}] before effective_from [{}]", + selected_signer.operator_id, + selected_signer.token.issued_at_unix, + operator.effective_from + ), + ); + } + + if let Some(effective_until) = operator.effective_until { + if selected_signer.token.issued_at_unix > effective_until { + push_rejection_reason( + &mut reasons, + "selected_token_after_operator_effective_until", + format!( + "selected token for operator_id [{}] issued_at_unix [{}] after effective_until [{}]", + selected_signer.operator_id, + selected_signer.token.issued_at_unix, + effective_until + ), + ); + } + } + + if selected_signer.token.issued_at_unix > now_unix_seconds { + push_rejection_reason( + &mut reasons, + "selected_token_not_yet_valid", + format!( + "selected token [{}] issued_at_unix [{}] is in the future relative to now [{}]", + selected_signer.token.token_id, + selected_signer.token.issued_at_unix, + now_unix_seconds + ), + ); + } + + if selected_signer.token.expires_at_unix <= selected_signer.token.issued_at_unix { + push_rejection_reason( + &mut reasons, + "selected_token_expiry_invalid", + format!( + "selected token [{}] expires_at_unix [{}] must be greater than issued_at_unix [{}]", + selected_signer.token.token_id, + selected_signer.token.expires_at_unix, + selected_signer.token.issued_at_unix + ), + ); + } + + let token_ttl_seconds = selected_signer + .token + .expires_at_unix + .saturating_sub(selected_signer.token.issued_at_unix); + if token_ttl_seconds > registry.enforcement.attestation_max_age_seconds { + push_rejection_reason( + &mut reasons, + "selected_token_ttl_exceeds_attestation_max_age", + format!( + "selected token [{}] ttl [{}] exceeds attestation_max_age_seconds [{}]", + selected_signer.token.token_id, + token_ttl_seconds, + registry.enforcement.attestation_max_age_seconds + ), + ); + } + + if selected_signer.token.token_revocation_epoch + < session.denylist.min_token_revocation_epoch + { + push_rejection_reason( + &mut reasons, + "selected_token_revocation_epoch_below_minimum", + format!( + "selected token [{}] token_revocation_epoch [{}] below minimum [{}]", + selected_signer.token.token_id, + selected_signer.token.token_revocation_epoch, + session.denylist.min_token_revocation_epoch + ), + ); + } + + if revoked_operator_ids.contains(&operator_id) { + push_rejection_reason( + &mut reasons, + "selected_operator_revoked", + format!( + "selected operator_id [{}] is present in denylist revoked_operator_ids", + selected_signer.operator_id + ), + ); + } + + if revoked_signer_identifiers.contains(&signer_identifier) { + push_rejection_reason( + &mut reasons, + "selected_signer_revoked", + format!( + "selected signer_identifier [{}] is present in denylist revoked_signer_identifiers", + selected_signer.signer_identifier + ), + ); + } + + if revoked_token_ids.contains(&token_id) { + push_rejection_reason( + &mut reasons, + "selected_token_revoked", + format!( + "selected token_id [{}] is present in denylist revoked_token_ids", + selected_signer.token.token_id + ), + ); + } + + if now_unix_seconds > selected_signer.token.expires_at_unix { + let elapsed_since_expiry = + now_unix_seconds.saturating_sub(selected_signer.token.expires_at_unix); + match runtime_phase { + RuntimePhase::SessionStart => { + push_rejection_reason( + &mut reasons, + "selected_token_expired_for_session_start", + format!( + "selected token [{}] expired at [{}] before session start now [{}]", + selected_signer.token.token_id, + selected_signer.token.expires_at_unix, + now_unix_seconds + ), + ); + } + RuntimePhase::MidSession => { + if elapsed_since_expiry > registry.enforcement.grace_period_seconds { + push_rejection_reason( + &mut reasons, + "selected_token_expired_beyond_grace", + format!( + "selected token [{}] expired [{}] seconds ago, exceeding grace_period_seconds [{}]", + selected_signer.token.token_id, + elapsed_since_expiry, + registry.enforcement.grace_period_seconds + ), + ); + } + } + } + } + + *vendor_counts.entry(vendor_id).or_insert(0usize) += 1; + } + + if selected_signer_count > 0 { + for (vendor_id, vendor_count) in vendor_counts { + let vendor_share_percent = + (vendor_count as u64).saturating_mul(100) / (selected_signer_count as u64); + if vendor_share_percent > vendor_cap_percent { + push_rejection_reason( + &mut reasons, + "vendor_diversity_cap_exceeded", + format!( + "vendor [{}] share [{}%] exceeds cap [{}%]", + vendor_id, vendor_share_percent, vendor_cap_percent + ), + ); + } + } + } + + ValidationDecision { + decision: if reasons.is_empty() { + "allow".to_string() + } else { + "reject".to_string() + }, + reasons, + validated_at_unix: now_unix_seconds, + } +} + +fn run() -> Result { + let args = env::args().skip(1).collect::>(); + let cli = parse_args(&args)?; + let registry: TeeGovernanceRegistryV1 = load_json_file(&cli.registry_path)?; + let session: RuntimeSessionInputV1 = load_json_file(&cli.session_path)?; + let now_unix_seconds = match cli.now_unix_override { + Some(now_unix_override) => now_unix_override, + None => now_unix()?, + }; + + Ok(validate_runtime(®istry, &session, now_unix_seconds)) +} + +fn main() { + match run() { + Ok(decision) => { + let json = serde_json::to_string_pretty(&decision).unwrap_or_else(|_| { + "{\"decision\":\"reject\",\"reasons\":[{\"code\":\"serialization_error\",\"detail\":\"failed to encode output\"}],\"validated_at_unix\":0}".to_string() + }); + println!("{json}"); + if decision.decision == "allow" { + std::process::exit(0); + } + std::process::exit(1); + } + Err(error) => { + eprintln!("{error}"); + eprintln!("{}", usage()); + std::process::exit(2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn baseline_registry() -> TeeGovernanceRegistryV1 { + TeeGovernanceRegistryV1 { + profile_status: "mandatory".to_string(), + enforcement: TeeEnforcementParameters { + attestation_max_age_seconds: 3_600, + grace_period_seconds: 900, + min_attested_signers_per_cohort: 4, + max_single_vendor_share_percent: 40, + denylist_max_staleness_seconds: 60, + }, + operators: vec![ + TeeOperatorAdmissionRecord { + operator_id: "operator-1".to_string(), + signer_identifier: "signer-1".to_string(), + status: "active".to_string(), + effective_from: 1_700_000_000, + effective_until: None, + }, + TeeOperatorAdmissionRecord { + operator_id: "operator-2".to_string(), + signer_identifier: "signer-2".to_string(), + status: "active".to_string(), + effective_from: 1_700_000_000, + effective_until: None, + }, + TeeOperatorAdmissionRecord { + operator_id: "operator-3".to_string(), + signer_identifier: "signer-3".to_string(), + status: "active".to_string(), + effective_from: 1_700_000_000, + effective_until: None, + }, + TeeOperatorAdmissionRecord { + operator_id: "operator-4".to_string(), + signer_identifier: "signer-4".to_string(), + status: "active".to_string(), + effective_from: 1_700_000_000, + effective_until: None, + }, + ], + } + } + + fn signer( + operator_id: &str, + signer_identifier: &str, + vendor_id: &str, + token_id: &str, + ) -> RuntimeSelectedSigner { + RuntimeSelectedSigner { + operator_id: operator_id.to_string(), + signer_identifier: signer_identifier.to_string(), + vendor_id: vendor_id.to_string(), + token: RuntimeTokenSnapshot { + token_id: token_id.to_string(), + issued_at_unix: 1_700_099_900, + expires_at_unix: 1_700_100_300, + token_revocation_epoch: 5, + }, + } + } + + fn baseline_session() -> RuntimeSessionInputV1 { + RuntimeSessionInputV1 { + session_id: "session-1".to_string(), + phase: "session_start".to_string(), + threshold: 3, + selected_signers: vec![ + signer("operator-1", "signer-1", "vendor-a", "token-1"), + signer("operator-2", "signer-2", "vendor-b", "token-2"), + signer("operator-3", "signer-3", "vendor-c", "token-3"), + signer("operator-4", "signer-4", "vendor-d", "token-4"), + ], + denylist: RuntimeDenylistSnapshot { + refreshed_at_unix: 1_700_100_000, + revoked_operator_ids: vec![], + revoked_signer_identifiers: vec![], + revoked_token_ids: vec![], + min_token_revocation_epoch: 5, + }, + vendor_outage: None, + } + } + + #[test] + fn validate_runtime_allows_valid_session_start() { + let registry = baseline_registry(); + let session = baseline_session(); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "allow"); + assert!(decision.reasons.is_empty()); + } + + #[test] + fn validate_runtime_rejects_stale_denylist() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.denylist.refreshed_at_unix = 1_700_099_900; + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "denylist_stale")); + } + + #[test] + fn validate_runtime_rejects_vendor_diversity_cap_violation() { + let registry = baseline_registry(); + let mut session = baseline_session(); + for selected_signer in &mut session.selected_signers { + selected_signer.vendor_id = "vendor-a".to_string(); + } + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "vendor_diversity_cap_exceeded")); + } + + #[test] + fn validate_runtime_allows_relaxed_vendor_cap_during_declared_outage() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.selected_signers[2].vendor_id = "vendor-a".to_string(); + session.vendor_outage = Some(VendorOutageRelaxation { + declared: true, + declared_at_unix: 1_700_099_500, + relaxed_max_single_vendor_share_percent: 60, + expires_at_unix: 1_700_101_000, + }); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "allow"); + } + + #[test] + fn validate_runtime_rejects_relaxed_vendor_cap_without_declared_outage() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.selected_signers[2].vendor_id = "vendor-a".to_string(); + session.vendor_outage = Some(VendorOutageRelaxation { + declared: false, + declared_at_unix: 1_700_099_500, + relaxed_max_single_vendor_share_percent: 60, + expires_at_unix: 1_700_101_000, + }); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "vendor_outage_not_declared")); + } + + #[test] + fn validate_runtime_rejects_relaxed_vendor_cap_with_invalid_step() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.selected_signers[2].vendor_id = "vendor-a".to_string(); + session.vendor_outage = Some(VendorOutageRelaxation { + declared: true, + declared_at_unix: 1_700_099_500, + relaxed_max_single_vendor_share_percent: 55, + expires_at_unix: 1_700_101_000, + }); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "vendor_outage_relaxation_step_invalid")); + } + + #[test] + fn validate_runtime_rejects_relaxed_vendor_cap_with_ttl_above_maximum() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.selected_signers[2].vendor_id = "vendor-a".to_string(); + session.vendor_outage = Some(VendorOutageRelaxation { + declared: true, + declared_at_unix: 1_700_078_000, + relaxed_max_single_vendor_share_percent: 60, + expires_at_unix: 1_700_100_001, + }); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "vendor_outage_relaxation_ttl_exceeds_maximum" })); + } + + #[test] + fn validate_runtime_rejects_expired_token_for_session_start() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.selected_signers[0].token.expires_at_unix = 1_700_099_999; + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "selected_token_expired_for_session_start" })); + } + + #[test] + fn validate_runtime_allows_mid_session_expiry_within_grace_window() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.phase = "mid_session".to_string(); + session.selected_signers[0].token.issued_at_unix = 1_700_099_300; + session.selected_signers[0].token.expires_at_unix = 1_700_099_700; + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "allow"); + } + + #[test] + fn validate_runtime_rejects_mid_session_expiry_beyond_grace_window() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.phase = "mid_session".to_string(); + session.selected_signers[0].token.expires_at_unix = 1_700_098_000; + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "selected_token_expired_beyond_grace")); + } + + #[test] + fn validate_runtime_rejects_revoked_token() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.denylist.revoked_token_ids = vec!["token-2".to_string()]; + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "selected_token_revoked")); + } + + #[test] + fn validate_runtime_rejects_revoked_operator() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.denylist.revoked_operator_ids = vec!["operator-2".to_string()]; + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "selected_operator_revoked")); + } + + #[test] + fn validate_runtime_rejects_revoked_signer_identifier() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.denylist.revoked_signer_identifiers = vec!["signer-3".to_string()]; + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "selected_signer_revoked")); + } + + #[test] + fn validate_runtime_rejects_token_revocation_epoch_below_minimum() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.denylist.min_token_revocation_epoch = 7; + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "selected_token_revocation_epoch_below_minimum" })); + } + + #[test] + fn validate_runtime_rejects_non_active_operator() { + let mut registry = baseline_registry(); + registry.operators[0].status = "revoked".to_string(); + let session = baseline_session(); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "selected_operator_not_active")); + } + + #[test] + fn validate_runtime_rejects_when_profile_not_mandatory() { + let mut registry = baseline_registry(); + registry.profile_status = "draft".to_string(); + let session = baseline_session(); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "governance_profile_not_mandatory")); + } + + #[test] + fn validate_runtime_rejects_grace_period_above_hard_ceiling() { + let mut registry = baseline_registry(); + registry.enforcement.grace_period_seconds = MAX_GRACE_PERIOD_SECONDS + 1; + let session = baseline_session(); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "grace_period_exceeds_hard_ceiling")); + } + + #[test] + fn validate_runtime_rejects_attestation_max_age_above_hard_ceiling() { + let mut registry = baseline_registry(); + registry.enforcement.attestation_max_age_seconds = MAX_ATTESTATION_MAX_AGE_SECONDS + 1; + let session = baseline_session(); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "attestation_max_age_exceeds_hard_ceiling")); + } + + #[test] + fn validate_runtime_rejects_denylist_max_staleness_above_hard_ceiling() { + let mut registry = baseline_registry(); + registry.enforcement.denylist_max_staleness_seconds = MAX_DENYLIST_STALENESS_SECONDS + 1; + let session = baseline_session(); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| { reason.code == "denylist_max_staleness_exceeds_hard_ceiling" })); + } + + #[test] + fn validate_runtime_rejects_vendor_outage_expiry_not_after_declared() { + let registry = baseline_registry(); + let mut session = baseline_session(); + session.selected_signers[2].vendor_id = "vendor-a".to_string(); + session.vendor_outage = Some(VendorOutageRelaxation { + declared: true, + declared_at_unix: 1_700_099_500, + relaxed_max_single_vendor_share_percent: 50, + expires_at_unix: 1_700_099_500, + }); + + let decision = validate_runtime(®istry, &session, 1_700_100_000); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "vendor_outage_expiry_window_invalid")); + } + + #[test] + fn parse_args_accepts_required_flags() { + let args = vec![ + "--registry".to_string(), + "registry.json".to_string(), + "--session".to_string(), + "session.json".to_string(), + ]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!(parsed.registry_path, PathBuf::from("registry.json")); + assert_eq!(parsed.session_path, PathBuf::from("session.json")); + assert!(parsed.now_unix_override.is_none()); + } + + #[test] + fn parse_args_accepts_now_unix() { + let args = vec![ + "--registry".to_string(), + "registry.json".to_string(), + "--session".to_string(), + "session.json".to_string(), + "--now-unix".to_string(), + "1700100000".to_string(), + ]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!(parsed.now_unix_override, Some(1_700_100_000)); + } + + #[test] + fn parse_args_rejects_missing_session_flag() { + let args = vec!["--registry".to_string(), "registry.json".to_string()]; + + let error = parse_args(&args).expect_err("expected parse failure"); + assert_eq!(error, "missing required --session"); + } +} diff --git a/pkg/tbtc/signer/src/bin/tee_token_checker.rs b/pkg/tbtc/signer/src/bin/tee_token_checker.rs new file mode 100644 index 0000000000..81d21df0f0 --- /dev/null +++ b/pkg/tbtc/signer/src/bin/tee_token_checker.rs @@ -0,0 +1,2136 @@ +use bitcoin::secp256k1::{ + schnorr::Signature as SchnorrSignature, Message as SecpMessage, Secp256k1, XOnlyPublicKey, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::env; +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SECONDS_PER_DAY: u64 = 86_400; +const MAX_VERIFIER_KEY_ROTATION_SECONDS: u64 = 30 * SECONDS_PER_DAY; +const MAX_ATTESTATION_MAX_AGE_SECONDS: u64 = SECONDS_PER_DAY; +const MAX_DENYLIST_STALENESS_SECONDS: u64 = 300; + +#[derive(Clone, Debug, Deserialize)] +struct TeeGovernanceRegistryV1 { + profile_status: String, + enforcement: TeeEnforcementParameters, + operators: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct TeeEnforcementParameters { + attestation_max_age_seconds: u64, + denylist_max_staleness_seconds: u64, +} + +#[derive(Clone, Debug, Deserialize)] +struct TeeOperatorAdmissionRecord { + operator_id: String, + signer_identifier: String, + status: String, + allowed_tee_types: Vec, + allowed_measurements: Vec, + attestation_max_age_seconds: u64, + effective_from: u64, + #[serde(default)] + effective_until: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct VerifierKeySetV1 { + keyset_version: u64, + threshold_m: usize, + max_key_age_seconds: u64, + keys: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct VerifierKeyRecord { + key_id: String, + verifier_instance_id: String, + trust_root_id: String, + pubkey_hex: String, + valid_from_unix: u64, + valid_until_unix: u64, + #[serde(default)] + revoked_at_unix: Option, +} + +#[derive(Clone, Debug, Deserialize)] +struct AdmissionTokenArtifact { + payload_json: String, + signatures: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct TokenSignature { + verifier_key_id: String, + signature_hex: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct AdmissionTokenPayload { + token_id: String, + operator_id: String, + signer_identifier: String, + tee_type: String, + measurement_digest: String, + issued_at_unix: u64, + expires_at_unix: u64, + registry_snapshot_version: u64, + verifier_key_ids: Vec, + token_revocation_epoch: u64, +} + +#[derive(Clone, Debug, Deserialize)] +struct TokenRevocationRegistryV1 { + denylist_refreshed_at_unix: u64, + #[serde(default)] + min_token_revocation_epoch: u64, + #[serde(default)] + revoked_token_ids: HashMap, + #[serde(default)] + revoked_verifier_key_ids: HashMap, +} + +#[derive(Clone, Debug, Deserialize)] +struct RevokedTokenRecord { + revoked_at_unix: u64, + #[serde(default)] + reason: String, +} + +#[derive(Clone, Debug, Deserialize)] +struct RevokedVerifierKeyRecord { + revoked_at_unix: u64, + #[serde(default)] + reason: String, +} + +#[derive(Clone, Debug, Serialize)] +struct ValidationReason { + code: String, + detail: String, +} + +#[derive(Clone, Debug, Serialize)] +struct ValidationDecision { + decision: String, + reasons: Vec, + validated_at_unix: u64, +} + +#[derive(Debug)] +struct CliArgs { + registry_path: PathBuf, + keyset_path: PathBuf, + token_path: PathBuf, + revocation_registry_path: PathBuf, + now_unix_override: Option, +} + +#[derive(Clone, Debug)] +struct ResolvedVerifierKey { + verifier_instance_id: String, + trust_root_id: String, + pubkey: XOnlyPublicKey, + valid_from_unix: u64, + valid_until_unix: u64, + revoked_at_unix: Option, +} + +#[derive(Clone, Debug)] +struct ResolvedVerifierKeySet { + threshold_m: usize, + keys: HashMap, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum OperatorStatus { + Active, + Suspended, + Revoked, +} + +fn usage() -> String { + "Usage: tee_token_checker --registry --keyset --token --revocation-registry [--now-unix ]" + .to_string() +} + +fn parse_args(args: &[String]) -> Result { + let mut registry_path: Option = None; + let mut keyset_path: Option = None; + let mut token_path: Option = None; + let mut revocation_registry_path: Option = None; + let mut now_unix_override: Option = None; + + let mut i = 0usize; + while i < args.len() { + match args[i].as_str() { + "--registry" => { + i += 1; + if i >= args.len() { + return Err("missing value for --registry".to_string()); + } + registry_path = Some(PathBuf::from(&args[i])); + } + "--keyset" => { + i += 1; + if i >= args.len() { + return Err("missing value for --keyset".to_string()); + } + keyset_path = Some(PathBuf::from(&args[i])); + } + "--token" => { + i += 1; + if i >= args.len() { + return Err("missing value for --token".to_string()); + } + token_path = Some(PathBuf::from(&args[i])); + } + "--revocation-registry" => { + i += 1; + if i >= args.len() { + return Err("missing value for --revocation-registry".to_string()); + } + revocation_registry_path = Some(PathBuf::from(&args[i])); + } + "--now-unix" => { + i += 1; + if i >= args.len() { + return Err("missing value for --now-unix".to_string()); + } + let parsed = args[i] + .parse::() + .map_err(|_| "invalid value for --now-unix".to_string())?; + now_unix_override = Some(parsed); + } + "--help" | "-h" => { + return Err(usage()); + } + unknown => { + return Err(format!("unknown argument [{unknown}]")); + } + } + i += 1; + } + + let registry_path = registry_path.ok_or_else(|| "missing required --registry".to_string())?; + let keyset_path = keyset_path.ok_or_else(|| "missing required --keyset".to_string())?; + let token_path = token_path.ok_or_else(|| "missing required --token".to_string())?; + let revocation_registry_path = revocation_registry_path + .ok_or_else(|| "missing required --revocation-registry".to_string())?; + + Ok(CliArgs { + registry_path, + keyset_path, + token_path, + revocation_registry_path, + now_unix_override, + }) +} + +fn now_unix() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .map_err(|error| format!("system clock must be after UNIX epoch: {error}")) +} + +fn load_json_file Deserialize<'de>>(path: &PathBuf) -> Result { + let bytes = fs::read(path) + .map_err(|error| format!("failed to read file [{}]: {error}", path.display()))?; + serde_json::from_slice(&bytes) + .map_err(|error| format!("failed to parse JSON file [{}]: {error}", path.display())) +} + +fn trimmed_lowercase(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn normalize_revocation_registry( + mut registry: TokenRevocationRegistryV1, +) -> TokenRevocationRegistryV1 { + registry.revoked_token_ids = registry + .revoked_token_ids + .into_iter() + .map(|(key, value)| (trimmed_lowercase(&key), value)) + .collect(); + registry.revoked_verifier_key_ids = registry + .revoked_verifier_key_ids + .into_iter() + .map(|(key, value)| (trimmed_lowercase(&key), value)) + .collect(); + registry +} + +fn push_rejection_reason(reasons: &mut Vec, code: &str, detail: String) { + reasons.push(ValidationReason { + code: code.to_string(), + detail, + }); +} + +fn required_non_empty( + field_name: &str, + value: &str, + reasons: &mut Vec, + code: &str, +) -> Option { + let normalized = value.trim().to_string(); + if normalized.is_empty() { + push_rejection_reason( + reasons, + code, + format!("field [{field_name}] must be non-empty"), + ); + return None; + } + Some(normalized) +} + +fn parse_operator_status(status: &str) -> Option { + match trimmed_lowercase(status).as_str() { + "active" => Some(OperatorStatus::Active), + "suspended" => Some(OperatorStatus::Suspended), + "revoked" => Some(OperatorStatus::Revoked), + _ => None, + } +} + +fn is_sha256_digest(value: &str) -> bool { + let normalized = value.trim(); + if normalized.len() != 71 { + return false; + } + if !normalized + .get(0..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("sha256:")) + { + return false; + } + + normalized.get(7..).is_some_and(|digest| { + digest + .chars() + .all(|character| character.is_ascii_hexdigit()) + }) +} + +fn parse_xonly_pubkey_hex(pubkey_hex: &str) -> Result { + let pubkey_hex = pubkey_hex.trim(); + if pubkey_hex.is_empty() { + return Err("verifier pubkey hex must be non-empty".to_string()); + } + + let pubkey_bytes = + hex::decode(pubkey_hex).map_err(|_| "verifier pubkey must be valid hex".to_string())?; + if pubkey_bytes.len() != 32 { + return Err("verifier pubkey must decode to 32 bytes".to_string()); + } + + XOnlyPublicKey::from_slice(&pubkey_bytes) + .map_err(|_| "verifier pubkey must be valid x-only secp256k1 key".to_string()) +} + +fn verify_schnorr_signature( + payload_json: &str, + signature_hex: &str, + pubkey: &XOnlyPublicKey, +) -> Result<(), String> { + let signature_bytes = hex::decode(signature_hex.trim()) + .map_err(|_| "token signature must be valid hex".to_string())?; + let signature = SchnorrSignature::from_slice(&signature_bytes) + .map_err(|_| "token signature must be valid schnorr bytes".to_string())?; + // Admission tokens commit to the exact payload_json bytes supplied by the issuer. + let payload_digest = Sha256::digest(payload_json.as_bytes()); + let message = SecpMessage::from_digest_slice(&payload_digest) + .map_err(|_| "failed to construct token signature digest".to_string())?; + + Secp256k1::verification_only() + .verify_schnorr(&signature, &message, pubkey) + .map_err(|_| "token signature verification failed".to_string()) +} + +fn validate_verifier_keyset( + keyset: &VerifierKeySetV1, + now_unix_seconds: u64, + reasons: &mut Vec, +) -> ResolvedVerifierKeySet { + if keyset.keyset_version == 0 { + push_rejection_reason( + reasons, + "keyset_version_invalid", + "keyset_version must be > 0".to_string(), + ); + } + + if keyset.threshold_m < 2 { + push_rejection_reason( + reasons, + "keyset_threshold_invalid", + format!( + "threshold_m [{}] must be >= 2 for multi-verifier quorum", + keyset.threshold_m + ), + ); + } + + if keyset.max_key_age_seconds == 0 + || keyset.max_key_age_seconds > MAX_VERIFIER_KEY_ROTATION_SECONDS + { + push_rejection_reason( + reasons, + "keyset_max_key_age_invalid", + format!( + "max_key_age_seconds [{}] must be within [1, {}]", + keyset.max_key_age_seconds, MAX_VERIFIER_KEY_ROTATION_SECONDS + ), + ); + } + + if keyset.keys.len() < keyset.threshold_m { + push_rejection_reason( + reasons, + "keyset_insufficient_keys", + format!( + "keyset has [{}] keys but threshold_m is [{}]", + keyset.keys.len(), + keyset.threshold_m + ), + ); + } + + let mut seen_key_ids: HashSet = HashSet::new(); + let mut seen_pubkeys: HashSet = HashSet::new(); + let mut resolved_keys = HashMap::new(); + let mut active_key_count = 0usize; + let mut active_trust_roots: HashSet = HashSet::new(); + let mut active_instances: HashSet = HashSet::new(); + + for key in &keyset.keys { + let Some(key_id) = + required_non_empty("key_id", &key.key_id, reasons, "verifier_key_id_missing") + else { + continue; + }; + let key_id_normalized = trimmed_lowercase(&key_id); + if !seen_key_ids.insert(key_id_normalized.clone()) { + push_rejection_reason( + reasons, + "verifier_key_id_duplicate", + format!("verifier key_id [{}] is duplicated", key.key_id), + ); + continue; + } + + let Some(verifier_instance_id) = required_non_empty( + "verifier_instance_id", + &key.verifier_instance_id, + reasons, + "verifier_instance_id_missing", + ) else { + continue; + }; + let Some(trust_root_id) = required_non_empty( + "trust_root_id", + &key.trust_root_id, + reasons, + "verifier_trust_root_id_missing", + ) else { + continue; + }; + + if key.valid_from_unix == 0 { + push_rejection_reason( + reasons, + "verifier_valid_from_invalid", + format!("verifier key [{}] valid_from_unix must be > 0", key.key_id), + ); + continue; + } + + if key.valid_until_unix <= key.valid_from_unix { + push_rejection_reason( + reasons, + "verifier_validity_window_invalid", + format!( + "verifier key [{}] valid_until_unix [{}] must be greater than valid_from_unix [{}]", + key.key_id, key.valid_until_unix, key.valid_from_unix + ), + ); + continue; + } + + let key_age_seconds = key.valid_until_unix.saturating_sub(key.valid_from_unix); + if keyset.max_key_age_seconds > 0 && key_age_seconds > keyset.max_key_age_seconds { + push_rejection_reason( + reasons, + "verifier_key_age_exceeds_policy", + format!( + "verifier key [{}] lifetime [{}] exceeds keyset max_key_age_seconds [{}]", + key.key_id, key_age_seconds, keyset.max_key_age_seconds + ), + ); + } + + if let Some(revoked_at_unix) = key.revoked_at_unix { + if revoked_at_unix < key.valid_from_unix { + push_rejection_reason( + reasons, + "verifier_key_revoked_before_valid_from", + format!( + "verifier key [{}] revoked_at_unix [{}] is before valid_from_unix [{}]", + key.key_id, revoked_at_unix, key.valid_from_unix + ), + ); + } + } + + let parsed_pubkey = match parse_xonly_pubkey_hex(&key.pubkey_hex) { + Ok(parsed_pubkey) => parsed_pubkey, + Err(detail) => { + push_rejection_reason( + reasons, + "verifier_pubkey_invalid", + format!("verifier key [{}]: {detail}", key.key_id), + ); + continue; + } + }; + let pubkey_normalized = parsed_pubkey.to_string(); + if !seen_pubkeys.insert(pubkey_normalized.clone()) { + push_rejection_reason( + reasons, + "verifier_pubkey_duplicate", + format!( + "verifier key [{}] reuses pubkey [{}] already present in keyset", + key.key_id, pubkey_normalized + ), + ); + continue; + } + + let active_now = key.valid_from_unix <= now_unix_seconds + && now_unix_seconds <= key.valid_until_unix + && key + .revoked_at_unix + .is_none_or(|revoked_at_unix| now_unix_seconds < revoked_at_unix); + if active_now { + active_key_count = active_key_count.saturating_add(1); + active_trust_roots.insert(trimmed_lowercase(&trust_root_id)); + active_instances.insert(trimmed_lowercase(&verifier_instance_id)); + } + + resolved_keys.insert( + key_id_normalized, + ResolvedVerifierKey { + verifier_instance_id: verifier_instance_id.trim().to_string(), + trust_root_id: trust_root_id.trim().to_string(), + pubkey: parsed_pubkey, + valid_from_unix: key.valid_from_unix, + valid_until_unix: key.valid_until_unix, + revoked_at_unix: key.revoked_at_unix, + }, + ); + } + + if active_key_count < keyset.threshold_m { + push_rejection_reason( + reasons, + "keyset_active_keys_below_threshold", + format!( + "active verifier keys [{}] below threshold_m [{}] at now [{}]", + active_key_count, keyset.threshold_m, now_unix_seconds + ), + ); + } + + if active_key_count > 0 && active_trust_roots.len() < 2 { + push_rejection_reason( + reasons, + "keyset_active_trust_roots_below_minimum", + format!( + "active verifier keys expose [{}] trust roots; require >= 2", + active_trust_roots.len() + ), + ); + } + + if active_key_count > 0 && active_instances.len() < 2 { + push_rejection_reason( + reasons, + "keyset_active_instances_below_minimum", + format!( + "active verifier keys expose [{}] verifier instances; require >= 2", + active_instances.len() + ), + ); + } + + ResolvedVerifierKeySet { + threshold_m: keyset.threshold_m, + keys: resolved_keys, + } +} + +fn find_operator<'a>( + registry: &'a TeeGovernanceRegistryV1, + operator_id: &str, +) -> Option<&'a TeeOperatorAdmissionRecord> { + let operator_id = trimmed_lowercase(operator_id); + registry + .operators + .iter() + .find(|operator| trimmed_lowercase(&operator.operator_id) == operator_id) +} + +fn validate_token( + registry: &TeeGovernanceRegistryV1, + keyset: &VerifierKeySetV1, + token_artifact: &AdmissionTokenArtifact, + revocation_registry: &TokenRevocationRegistryV1, + now_unix_seconds: u64, +) -> ValidationDecision { + let mut reasons: Vec = Vec::new(); + + if trimmed_lowercase(®istry.profile_status) != "mandatory" { + push_rejection_reason( + &mut reasons, + "governance_profile_not_mandatory", + format!( + "governance registry profile_status [{}] is not mandatory", + registry.profile_status + ), + ); + } + + if registry.enforcement.attestation_max_age_seconds > MAX_ATTESTATION_MAX_AGE_SECONDS { + push_rejection_reason( + &mut reasons, + "attestation_max_age_exceeds_hard_ceiling", + format!( + "attestation_max_age_seconds [{}] exceeds hard ceiling [{}]", + registry.enforcement.attestation_max_age_seconds, MAX_ATTESTATION_MAX_AGE_SECONDS + ), + ); + } + + if registry.enforcement.denylist_max_staleness_seconds == 0 { + push_rejection_reason( + &mut reasons, + "denylist_max_staleness_invalid_zero", + "denylist_max_staleness_seconds must be > 0".to_string(), + ); + } else if registry.enforcement.denylist_max_staleness_seconds > MAX_DENYLIST_STALENESS_SECONDS { + push_rejection_reason( + &mut reasons, + "denylist_max_staleness_exceeds_hard_ceiling", + format!( + "denylist_max_staleness_seconds [{}] exceeds hard ceiling [{}]", + registry.enforcement.denylist_max_staleness_seconds, MAX_DENYLIST_STALENESS_SECONDS + ), + ); + } + + let resolved_keyset = validate_verifier_keyset(keyset, now_unix_seconds, &mut reasons); + + if revocation_registry.denylist_refreshed_at_unix == 0 { + push_rejection_reason( + &mut reasons, + "denylist_refreshed_at_invalid", + "revocation registry denylist_refreshed_at_unix must be > 0".to_string(), + ); + } else if revocation_registry.denylist_refreshed_at_unix > now_unix_seconds { + push_rejection_reason( + &mut reasons, + "denylist_refreshed_at_in_future", + format!( + "denylist_refreshed_at_unix [{}] is in the future relative to now [{}]", + revocation_registry.denylist_refreshed_at_unix, now_unix_seconds + ), + ); + } else { + let denylist_age_seconds = + now_unix_seconds.saturating_sub(revocation_registry.denylist_refreshed_at_unix); + if denylist_age_seconds > registry.enforcement.denylist_max_staleness_seconds { + push_rejection_reason( + &mut reasons, + "denylist_stale", + format!( + "denylist age [{}] exceeds policy max staleness [{}]", + denylist_age_seconds, registry.enforcement.denylist_max_staleness_seconds + ), + ); + } + } + + let payload_json = token_artifact.payload_json.trim(); + if payload_json.is_empty() { + push_rejection_reason( + &mut reasons, + "token_payload_missing", + "token artifact payload_json must be non-empty".to_string(), + ); + return ValidationDecision { + decision: "reject".to_string(), + reasons, + validated_at_unix: now_unix_seconds, + }; + } + + let token_payload = match serde_json::from_str::(payload_json) { + Ok(token_payload) => token_payload, + Err(error) => { + push_rejection_reason( + &mut reasons, + "token_payload_invalid", + format!("failed to parse token payload_json: {error}"), + ); + return ValidationDecision { + decision: "reject".to_string(), + reasons, + validated_at_unix: now_unix_seconds, + }; + } + }; + + let Some(token_id) = required_non_empty( + "token_id", + &token_payload.token_id, + &mut reasons, + "token_id_missing", + ) else { + return ValidationDecision { + decision: "reject".to_string(), + reasons, + validated_at_unix: now_unix_seconds, + }; + }; + let token_id_normalized = trimmed_lowercase(&token_id); + + let Some(operator_id) = required_non_empty( + "operator_id", + &token_payload.operator_id, + &mut reasons, + "operator_id_missing", + ) else { + return ValidationDecision { + decision: "reject".to_string(), + reasons, + validated_at_unix: now_unix_seconds, + }; + }; + + let Some(signer_identifier) = required_non_empty( + "signer_identifier", + &token_payload.signer_identifier, + &mut reasons, + "signer_identifier_missing", + ) else { + return ValidationDecision { + decision: "reject".to_string(), + reasons, + validated_at_unix: now_unix_seconds, + }; + }; + + let Some(tee_type) = required_non_empty( + "tee_type", + &token_payload.tee_type, + &mut reasons, + "tee_type_missing", + ) else { + return ValidationDecision { + decision: "reject".to_string(), + reasons, + validated_at_unix: now_unix_seconds, + }; + }; + + if !is_sha256_digest(&token_payload.measurement_digest) { + push_rejection_reason( + &mut reasons, + "measurement_digest_invalid", + format!( + "measurement_digest [{}] must match sha256:<64 hex chars>", + token_payload.measurement_digest + ), + ); + } + + if token_payload.issued_at_unix == 0 { + push_rejection_reason( + &mut reasons, + "token_issued_at_invalid", + "issued_at_unix must be > 0".to_string(), + ); + } + + if token_payload.expires_at_unix <= token_payload.issued_at_unix { + push_rejection_reason( + &mut reasons, + "token_expiry_invalid", + format!( + "expires_at_unix [{}] must be greater than issued_at_unix [{}]", + token_payload.expires_at_unix, token_payload.issued_at_unix + ), + ); + } + + if token_payload.issued_at_unix > now_unix_seconds { + push_rejection_reason( + &mut reasons, + "token_not_yet_valid", + format!( + "issued_at_unix [{}] is in the future relative to now [{}]", + token_payload.issued_at_unix, now_unix_seconds + ), + ); + } + + if token_payload.expires_at_unix < now_unix_seconds { + push_rejection_reason( + &mut reasons, + "token_expired", + format!( + "token expired at [{}], now [{}]", + token_payload.expires_at_unix, now_unix_seconds + ), + ); + } + + if token_payload.registry_snapshot_version == 0 { + push_rejection_reason( + &mut reasons, + "registry_snapshot_version_invalid", + "registry_snapshot_version must be > 0".to_string(), + ); + } + + if token_payload.verifier_key_ids.is_empty() { + push_rejection_reason( + &mut reasons, + "token_verifier_key_ids_missing", + "verifier_key_ids must contain at least one key_id".to_string(), + ); + } + + let mut declared_verifier_key_ids: HashSet = HashSet::new(); + for key_id in &token_payload.verifier_key_ids { + let key_id_normalized = trimmed_lowercase(key_id); + if key_id_normalized.is_empty() { + push_rejection_reason( + &mut reasons, + "token_verifier_key_id_missing", + "verifier_key_ids contains an empty key_id".to_string(), + ); + continue; + } + + if !declared_verifier_key_ids.insert(key_id_normalized.clone()) { + push_rejection_reason( + &mut reasons, + "token_verifier_key_id_duplicate", + format!("verifier_key_ids contains duplicate key_id [{}]", key_id), + ); + } + } + + if let Some(revoked_token_record) = revocation_registry + .revoked_token_ids + .get(&token_id_normalized) + { + if revoked_token_record.revoked_at_unix <= now_unix_seconds { + let reason_suffix = if revoked_token_record.reason.trim().is_empty() { + String::new() + } else { + format!(" (reason: {})", revoked_token_record.reason.trim()) + }; + push_rejection_reason( + &mut reasons, + "token_id_revoked", + format!( + "token_id [{}] revoked at [{}]{}", + token_payload.token_id, revoked_token_record.revoked_at_unix, reason_suffix + ), + ); + } + } + + if token_payload.token_revocation_epoch < revocation_registry.min_token_revocation_epoch { + push_rejection_reason( + &mut reasons, + "token_revocation_epoch_below_minimum", + format!( + "token_revocation_epoch [{}] is below minimum [{}]", + token_payload.token_revocation_epoch, + revocation_registry.min_token_revocation_epoch + ), + ); + } + + let Some(operator_record) = find_operator(registry, &operator_id) else { + push_rejection_reason( + &mut reasons, + "operator_not_found", + format!( + "operator_id [{}] not found in governance registry", + operator_id + ), + ); + return ValidationDecision { + decision: "reject".to_string(), + reasons, + validated_at_unix: now_unix_seconds, + }; + }; + + if parse_operator_status(&operator_record.status) != Some(OperatorStatus::Active) { + push_rejection_reason( + &mut reasons, + "operator_not_active", + format!( + "operator_id [{}] status [{}] is not active", + operator_record.operator_id, operator_record.status + ), + ); + } + + if trimmed_lowercase(&operator_record.signer_identifier) + != trimmed_lowercase(&signer_identifier) + { + push_rejection_reason( + &mut reasons, + "signer_identifier_mismatch", + format!( + "token signer_identifier [{}] does not match registry signer_identifier [{}]", + token_payload.signer_identifier, operator_record.signer_identifier + ), + ); + } + + let tee_type_allowed = operator_record + .allowed_tee_types + .iter() + .any(|allowed| trimmed_lowercase(allowed) == trimmed_lowercase(&tee_type)); + if !tee_type_allowed { + push_rejection_reason( + &mut reasons, + "tee_type_not_allowed", + format!( + "tee_type [{}] not present in operator allowlist {:?}", + token_payload.tee_type, operator_record.allowed_tee_types + ), + ); + } + + let measurement_allowed = operator_record.allowed_measurements.iter().any(|allowed| { + trimmed_lowercase(allowed) == trimmed_lowercase(&token_payload.measurement_digest) + }); + if !measurement_allowed { + push_rejection_reason( + &mut reasons, + "measurement_not_allowlisted", + format!( + "measurement_digest [{}] not present in operator allowlist", + token_payload.measurement_digest + ), + ); + } + + if token_payload.issued_at_unix < operator_record.effective_from { + push_rejection_reason( + &mut reasons, + "operator_not_yet_effective", + format!( + "token issued_at_unix [{}] is before operator effective_from [{}]", + token_payload.issued_at_unix, operator_record.effective_from + ), + ); + } + + if let Some(effective_until) = operator_record.effective_until { + if token_payload.issued_at_unix > effective_until { + push_rejection_reason( + &mut reasons, + "operator_effective_window_expired", + format!( + "token issued_at_unix [{}] exceeds operator effective_until [{}]", + token_payload.issued_at_unix, effective_until + ), + ); + } + } + + let max_token_ttl_seconds = std::cmp::min( + registry.enforcement.attestation_max_age_seconds, + operator_record.attestation_max_age_seconds, + ); + let token_ttl_seconds = token_payload + .expires_at_unix + .saturating_sub(token_payload.issued_at_unix); + if token_ttl_seconds > max_token_ttl_seconds { + push_rejection_reason( + &mut reasons, + "token_ttl_exceeds_attestation_max_age", + format!( + "token ttl [{}] exceeds max allowed [{}]", + token_ttl_seconds, max_token_ttl_seconds + ), + ); + } + + if token_artifact.signatures.is_empty() { + push_rejection_reason( + &mut reasons, + "token_signatures_missing", + "token signatures must contain at least one signature".to_string(), + ); + } + + let mut seen_signature_key_ids = HashSet::new(); + let mut valid_signature_count = 0usize; + let mut valid_signature_trust_roots: HashSet = HashSet::new(); + let mut valid_signature_instances: HashSet = HashSet::new(); + + for signature in &token_artifact.signatures { + let Some(signature_key_id) = required_non_empty( + "verifier_key_id", + &signature.verifier_key_id, + &mut reasons, + "token_signature_key_id_missing", + ) else { + continue; + }; + let signature_key_id_normalized = trimmed_lowercase(&signature_key_id); + + if !seen_signature_key_ids.insert(signature_key_id_normalized.clone()) { + push_rejection_reason( + &mut reasons, + "token_signature_key_id_duplicate", + format!( + "token signatures contain duplicate verifier_key_id [{}]", + signature.verifier_key_id + ), + ); + continue; + } + + if !declared_verifier_key_ids.contains(&signature_key_id_normalized) { + push_rejection_reason( + &mut reasons, + "token_signature_key_not_declared", + format!( + "signature key_id [{}] not present in payload verifier_key_ids", + signature.verifier_key_id + ), + ); + continue; + } + + let Some(resolved_key) = resolved_keyset.keys.get(&signature_key_id_normalized) else { + push_rejection_reason( + &mut reasons, + "token_signature_key_unknown", + format!( + "signature key_id [{}] not found in verifier keyset", + signature.verifier_key_id + ), + ); + continue; + }; + + if token_payload.issued_at_unix < resolved_key.valid_from_unix + || token_payload.issued_at_unix > resolved_key.valid_until_unix + { + push_rejection_reason( + &mut reasons, + "token_signature_key_not_valid_at_issue_time", + format!( + "signature key_id [{}] is not valid at issued_at_unix [{}]", + signature.verifier_key_id, token_payload.issued_at_unix + ), + ); + continue; + } + + if resolved_key + .revoked_at_unix + .is_some_and(|revoked_at_unix| revoked_at_unix <= now_unix_seconds) + { + push_rejection_reason( + &mut reasons, + "token_signature_key_revoked", + format!( + "signature key_id [{}] was revoked at or before now [{}]", + signature.verifier_key_id, now_unix_seconds + ), + ); + continue; + } + + if let Some(revoked_key_record) = revocation_registry + .revoked_verifier_key_ids + .get(&signature_key_id_normalized) + { + if revoked_key_record.revoked_at_unix <= now_unix_seconds { + let reason_suffix = if revoked_key_record.reason.trim().is_empty() { + String::new() + } else { + format!(" (reason: {})", revoked_key_record.reason.trim()) + }; + push_rejection_reason( + &mut reasons, + "token_signature_key_revoked", + format!( + "signature key_id [{}] revoked in revocation registry at [{}]{}", + signature.verifier_key_id, + revoked_key_record.revoked_at_unix, + reason_suffix + ), + ); + continue; + } + } + + if let Err(detail) = + verify_schnorr_signature(payload_json, &signature.signature_hex, &resolved_key.pubkey) + { + push_rejection_reason( + &mut reasons, + "token_signature_verification_failed", + format!("signature key_id [{}]: {detail}", signature.verifier_key_id), + ); + continue; + } + + valid_signature_count = valid_signature_count.saturating_add(1); + valid_signature_trust_roots.insert(trimmed_lowercase(&resolved_key.trust_root_id)); + valid_signature_instances.insert(trimmed_lowercase(&resolved_key.verifier_instance_id)); + } + + if valid_signature_count < resolved_keyset.threshold_m { + push_rejection_reason( + &mut reasons, + "token_signature_quorum_not_met", + format!( + "valid token signatures [{}] below threshold_m [{}]", + valid_signature_count, resolved_keyset.threshold_m + ), + ); + } + + if valid_signature_count > 0 && valid_signature_trust_roots.len() < 2 { + push_rejection_reason( + &mut reasons, + "token_signature_trust_root_diversity_violation", + format!( + "valid token signatures cover [{}] trust roots; require >= 2", + valid_signature_trust_roots.len() + ), + ); + } + + if valid_signature_count > 0 && valid_signature_instances.len() < 2 { + push_rejection_reason( + &mut reasons, + "token_signature_instance_diversity_violation", + format!( + "valid token signatures cover [{}] verifier instances; require >= 2", + valid_signature_instances.len() + ), + ); + } + + ValidationDecision { + decision: if reasons.is_empty() { + "allow".to_string() + } else { + "reject".to_string() + }, + reasons, + validated_at_unix: now_unix_seconds, + } +} + +fn run() -> Result { + let args = env::args().skip(1).collect::>(); + let cli = parse_args(&args)?; + let registry: TeeGovernanceRegistryV1 = load_json_file(&cli.registry_path)?; + let keyset: VerifierKeySetV1 = load_json_file(&cli.keyset_path)?; + let token_artifact: AdmissionTokenArtifact = load_json_file(&cli.token_path)?; + let revocation_registry: TokenRevocationRegistryV1 = + normalize_revocation_registry(load_json_file(&cli.revocation_registry_path)?); + let now_unix_seconds = match cli.now_unix_override { + Some(now_unix_override) => now_unix_override, + None => now_unix()?, + }; + + Ok(validate_token( + ®istry, + &keyset, + &token_artifact, + &revocation_registry, + now_unix_seconds, + )) +} + +fn main() { + match run() { + Ok(decision) => { + let json = serde_json::to_string_pretty(&decision).unwrap_or_else(|_| { + "{\"decision\":\"reject\",\"reasons\":[{\"code\":\"serialization_error\",\"detail\":\"failed to encode output\"}],\"validated_at_unix\":0}".to_string() + }); + println!("{json}"); + if decision.decision == "allow" { + std::process::exit(0); + } + std::process::exit(1); + } + Err(error) => { + eprintln!("{error}"); + eprintln!("{}", usage()); + std::process::exit(2); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct SigningKeyFixture { + key_id: String, + keypair: bitcoin::secp256k1::Keypair, + verifier_instance_id: String, + trust_root_id: String, + pubkey_hex: String, + } + + fn signing_key( + seed: u8, + key_id: &str, + verifier_instance_id: &str, + trust_root_id: &str, + ) -> SigningKeyFixture { + let secp = Secp256k1::new(); + let secret_key = + bitcoin::secp256k1::SecretKey::from_slice(&[seed; 32]).expect("secret key"); + let keypair = bitcoin::secp256k1::Keypair::from_secret_key(&secp, &secret_key); + let (pubkey, _) = XOnlyPublicKey::from_keypair(&keypair); + + SigningKeyFixture { + key_id: key_id.to_string(), + keypair, + verifier_instance_id: verifier_instance_id.to_string(), + trust_root_id: trust_root_id.to_string(), + pubkey_hex: pubkey.to_string(), + } + } + + fn sign_payload(payload_json: &str, keypair: &bitcoin::secp256k1::Keypair) -> String { + let secp = Secp256k1::new(); + let digest = Sha256::digest(payload_json.as_bytes()); + let message = SecpMessage::from_digest_slice(&digest).expect("digest message"); + secp.sign_schnorr_no_aux_rand(&message, keypair).to_string() + } + + fn baseline_signing_keys() -> Vec { + vec![ + signing_key(0x11, "verifier-key-1", "verifier-a", "trust-root-a"), + signing_key(0x22, "verifier-key-2", "verifier-b", "trust-root-b"), + signing_key(0x33, "verifier-key-3", "verifier-c", "trust-root-c"), + ] + } + + fn baseline_registry() -> TeeGovernanceRegistryV1 { + TeeGovernanceRegistryV1 { + profile_status: "mandatory".to_string(), + enforcement: TeeEnforcementParameters { + attestation_max_age_seconds: 3_600, + denylist_max_staleness_seconds: 60, + }, + operators: vec![TeeOperatorAdmissionRecord { + operator_id: "operator-1".to_string(), + signer_identifier: "signer-1".to_string(), + status: "active".to_string(), + allowed_tee_types: vec!["sgx".to_string(), "sev-snp".to_string()], + allowed_measurements: vec![ + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + ], + attestation_max_age_seconds: 3_600, + effective_from: 1_700_000_000, + effective_until: None, + }], + } + } + + fn baseline_keyset(signing_keys: &[SigningKeyFixture]) -> VerifierKeySetV1 { + VerifierKeySetV1 { + keyset_version: 1, + threshold_m: 2, + max_key_age_seconds: MAX_VERIFIER_KEY_ROTATION_SECONDS, + keys: signing_keys + .iter() + .map(|key| VerifierKeyRecord { + key_id: key.key_id.clone(), + verifier_instance_id: key.verifier_instance_id.clone(), + trust_root_id: key.trust_root_id.clone(), + pubkey_hex: key.pubkey_hex.clone(), + valid_from_unix: 1_700_000_000, + valid_until_unix: 1_700_259_200, + revoked_at_unix: None, + }) + .collect(), + } + } + + fn baseline_revocation_registry(now_unix_seconds: u64) -> TokenRevocationRegistryV1 { + TokenRevocationRegistryV1 { + denylist_refreshed_at_unix: now_unix_seconds, + min_token_revocation_epoch: 5, + revoked_token_ids: HashMap::new(), + revoked_verifier_key_ids: HashMap::new(), + } + } + + fn baseline_token_payload(now_unix_seconds: u64) -> AdmissionTokenPayload { + AdmissionTokenPayload { + token_id: "token-operator-1-0001".to_string(), + operator_id: "operator-1".to_string(), + signer_identifier: "signer-1".to_string(), + tee_type: "sgx".to_string(), + measurement_digest: + "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .to_string(), + issued_at_unix: now_unix_seconds.saturating_sub(120), + expires_at_unix: now_unix_seconds.saturating_add(300), + registry_snapshot_version: 1, + verifier_key_ids: vec!["verifier-key-1".to_string(), "verifier-key-2".to_string()], + token_revocation_epoch: 5, + } + } + + fn build_signed_artifact( + payload: &AdmissionTokenPayload, + signing_keys: &[SigningKeyFixture], + signing_key_ids: &[&str], + ) -> AdmissionTokenArtifact { + let payload_json = serde_json::to_string(payload).expect("payload json"); + let signatures = signing_key_ids + .iter() + .map(|key_id| { + let signing_key = signing_keys + .iter() + .find(|key| key.key_id == *key_id) + .expect("signing key fixture"); + TokenSignature { + verifier_key_id: signing_key.key_id.clone(), + signature_hex: sign_payload(&payload_json, &signing_key.keypair), + } + }) + .collect(); + + AdmissionTokenArtifact { + payload_json, + signatures, + } + } + + #[test] + fn validate_token_allows_valid_threshold_signed_token() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "allow"); + assert!(decision.reasons.is_empty()); + } + + #[test] + fn validate_token_rejects_stale_denylist() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let mut revocation_registry = baseline_revocation_registry(now_unix_seconds); + revocation_registry.denylist_refreshed_at_unix = now_unix_seconds.saturating_sub(61); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "denylist_stale")); + } + + #[test] + fn validate_token_rejects_denylist_max_staleness_above_hard_ceiling() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let mut registry = baseline_registry(); + registry.enforcement.denylist_max_staleness_seconds = MAX_DENYLIST_STALENESS_SECONDS + 1; + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "denylist_max_staleness_exceeds_hard_ceiling")); + } + + #[test] + fn validate_token_rejects_attestation_max_age_above_hard_ceiling() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let mut registry = baseline_registry(); + registry.enforcement.attestation_max_age_seconds = MAX_ATTESTATION_MAX_AGE_SECONDS + 1; + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "attestation_max_age_exceeds_hard_ceiling")); + } + + #[test] + fn validate_token_rejects_revoked_token_id() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let mut revocation_registry = baseline_revocation_registry(now_unix_seconds); + revocation_registry.revoked_token_ids.insert( + "token-operator-1-0001".to_string(), + RevokedTokenRecord { + revoked_at_unix: now_unix_seconds.saturating_sub(10), + reason: "manual revoke".to_string(), + }, + ); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_id_revoked")); + } + + #[test] + fn validate_token_rejects_token_revocation_epoch_below_minimum() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let mut revocation_registry = baseline_revocation_registry(now_unix_seconds); + revocation_registry.min_token_revocation_epoch = 7; + let mut payload = baseline_token_payload(now_unix_seconds); + payload.token_revocation_epoch = 6; + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_revocation_epoch_below_minimum")); + } + + #[test] + fn validate_token_rejects_insufficient_signature_quorum() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact(&payload, &signing_keys, &["verifier-key-1"]); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_signature_quorum_not_met")); + } + + #[test] + fn validate_token_rejects_duplicate_verifier_pubkeys_under_distinct_key_ids() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let mut keyset = baseline_keyset(&signing_keys); + keyset.keys[1].pubkey_hex = keyset.keys[0].pubkey_hex.clone(); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let payload = baseline_token_payload(now_unix_seconds); + let payload_json = serde_json::to_string(&payload).expect("payload json"); + let duplicated_key_signature = sign_payload(&payload_json, &signing_keys[0].keypair); + let artifact = AdmissionTokenArtifact { + payload_json, + signatures: vec![ + TokenSignature { + verifier_key_id: "verifier-key-1".to_string(), + signature_hex: duplicated_key_signature.clone(), + }, + TokenSignature { + verifier_key_id: "verifier-key-2".to_string(), + signature_hex: duplicated_key_signature, + }, + ], + }; + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "verifier_pubkey_duplicate")); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_signature_quorum_not_met")); + } + + #[test] + fn validate_token_rejects_invalid_signature() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let payload = baseline_token_payload(now_unix_seconds); + let mut artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + artifact.signatures[0].signature_hex = "00".repeat(64); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_signature_verification_failed")); + } + + #[test] + fn validate_token_rejects_signature_key_not_declared_in_payload() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let mut payload = baseline_token_payload(now_unix_seconds); + payload.verifier_key_ids = vec!["verifier-key-1".to_string()]; + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_signature_key_not_declared")); + } + + #[test] + fn validate_token_rejects_when_operator_not_active() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let mut registry = baseline_registry(); + registry.operators[0].status = "suspended".to_string(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "operator_not_active")); + } + + #[test] + fn validate_token_rejects_measurement_not_allowlisted() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let mut payload = baseline_token_payload(now_unix_seconds); + payload.measurement_digest = + "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "measurement_not_allowlisted")); + } + + #[test] + fn validate_token_rejects_tee_type_not_allowed() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let mut payload = baseline_token_payload(now_unix_seconds); + payload.tee_type = "tdx".to_string(); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "tee_type_not_allowed")); + } + + #[test] + fn validate_token_rejects_keyset_max_key_age_exceeding_30_days() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let mut keyset = baseline_keyset(&signing_keys); + keyset.max_key_age_seconds = MAX_VERIFIER_KEY_ROTATION_SECONDS + 1; + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "keyset_max_key_age_invalid")); + } + + #[test] + fn validate_token_rejects_trust_root_diversity_violation() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let mut keyset = baseline_keyset(&signing_keys); + keyset.keys[1].trust_root_id = keyset.keys[0].trust_root_id.clone(); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision.reasons.iter().any(|reason| { + reason.code == "token_signature_trust_root_diversity_violation" + || reason.code == "keyset_active_trust_roots_below_minimum" + })); + } + + #[test] + fn validate_token_rejects_compromised_signature_key() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let mut revocation_registry = baseline_revocation_registry(now_unix_seconds); + revocation_registry.revoked_verifier_key_ids.insert( + "verifier-key-1".to_string(), + RevokedVerifierKeyRecord { + revoked_at_unix: now_unix_seconds.saturating_sub(100), + reason: "compromised".to_string(), + }, + ); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_signature_key_revoked")); + } + + #[test] + fn validate_token_rejects_revoked_token_id_case_insensitive() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let mut revocation_registry = baseline_revocation_registry(now_unix_seconds); + revocation_registry.revoked_token_ids.insert( + "TOKEN-OPERATOR-1-0001".to_string(), + RevokedTokenRecord { + revoked_at_unix: now_unix_seconds.saturating_sub(10), + reason: "case-mismatch regression test".to_string(), + }, + ); + revocation_registry = normalize_revocation_registry(revocation_registry); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_id_revoked")); + } + + #[test] + fn validate_token_rejects_revoked_verifier_key_case_insensitive() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let mut revocation_registry = baseline_revocation_registry(now_unix_seconds); + revocation_registry.revoked_verifier_key_ids.insert( + "VERIFIER-KEY-1".to_string(), + RevokedVerifierKeyRecord { + revoked_at_unix: now_unix_seconds.saturating_sub(100), + reason: "case-mismatch regression test".to_string(), + }, + ); + revocation_registry = normalize_revocation_registry(revocation_registry); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_signature_key_revoked")); + } + + #[test] + fn validate_token_rejects_when_governance_profile_not_mandatory() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let mut registry = baseline_registry(); + registry.profile_status = "draft".to_string(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let payload = baseline_token_payload(now_unix_seconds); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "governance_profile_not_mandatory")); + } + + #[test] + fn validate_token_rejects_expired_token() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let mut payload = baseline_token_payload(now_unix_seconds); + payload.issued_at_unix = now_unix_seconds.saturating_sub(500); + payload.expires_at_unix = now_unix_seconds.saturating_sub(1); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_expired")); + } + + #[test] + fn validate_token_rejects_future_issued_at() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let mut payload = baseline_token_payload(now_unix_seconds); + payload.issued_at_unix = now_unix_seconds.saturating_add(600); + payload.expires_at_unix = now_unix_seconds.saturating_add(900); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_not_yet_valid")); + } + + #[test] + fn validate_token_rejects_operator_not_found() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let mut payload = baseline_token_payload(now_unix_seconds); + payload.operator_id = "operator-unknown".to_string(); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "operator_not_found")); + } + + #[test] + fn validate_token_rejects_signer_identifier_mismatch() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let mut payload = baseline_token_payload(now_unix_seconds); + payload.signer_identifier = "signer-wrong".to_string(); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "signer_identifier_mismatch")); + } + + #[test] + fn validate_token_rejects_ttl_exceeding_attestation_max_age() { + let now_unix_seconds = 1_700_100_000u64; + let signing_keys = baseline_signing_keys(); + let registry = baseline_registry(); + let keyset = baseline_keyset(&signing_keys); + let revocation_registry = baseline_revocation_registry(now_unix_seconds); + let mut payload = baseline_token_payload(now_unix_seconds); + payload.issued_at_unix = now_unix_seconds.saturating_sub(100); + payload.expires_at_unix = payload.issued_at_unix.saturating_add(7_200); + let artifact = build_signed_artifact( + &payload, + &signing_keys, + &["verifier-key-1", "verifier-key-2"], + ); + + let decision = validate_token( + ®istry, + &keyset, + &artifact, + &revocation_registry, + now_unix_seconds, + ); + assert_eq!(decision.decision, "reject"); + assert!(decision + .reasons + .iter() + .any(|reason| reason.code == "token_ttl_exceeds_attestation_max_age")); + } + + #[test] + fn parse_args_accepts_required_flags() { + let args = vec![ + "--registry".to_string(), + "registry.json".to_string(), + "--keyset".to_string(), + "keyset.json".to_string(), + "--token".to_string(), + "token.json".to_string(), + "--revocation-registry".to_string(), + "revocations.json".to_string(), + ]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!(parsed.registry_path, PathBuf::from("registry.json")); + assert_eq!(parsed.keyset_path, PathBuf::from("keyset.json")); + assert_eq!(parsed.token_path, PathBuf::from("token.json")); + assert_eq!( + parsed.revocation_registry_path, + PathBuf::from("revocations.json") + ); + assert!(parsed.now_unix_override.is_none()); + } + + #[test] + fn parse_args_accepts_now_unix() { + let args = vec![ + "--registry".to_string(), + "registry.json".to_string(), + "--keyset".to_string(), + "keyset.json".to_string(), + "--token".to_string(), + "token.json".to_string(), + "--revocation-registry".to_string(), + "revocations.json".to_string(), + "--now-unix".to_string(), + "1700100000".to_string(), + ]; + + let parsed = parse_args(&args).expect("parse args"); + assert_eq!(parsed.now_unix_override, Some(1_700_100_000)); + } + + #[test] + fn parse_args_rejects_missing_required_flags() { + let args = vec![ + "--registry".to_string(), + "registry.json".to_string(), + "--keyset".to_string(), + "keyset.json".to_string(), + "--token".to_string(), + "token.json".to_string(), + ]; + + let error = parse_args(&args).expect_err("expected parse failure"); + assert_eq!(error, "missing required --revocation-registry"); + } +}