-
Notifications
You must be signed in to change notification settings - Fork 36
fix(tee): fail closed on empty TCB-status allowlist in TEE admission #3023
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,57 @@ | ||
| /// Secure fail-closed default enforced when a policy's `allowed_tcb_statuses` | ||
| /// is empty. An empty allowlist must NOT skip the TCB-status check (that was a | ||
| /// fail-open hole — audit findings #356 / #17): instead it enforces against | ||
| /// this single status. Must be the exact PascalCase value dcap-qvl emits | ||
| /// (`crates/tee-attestation`) and matches the mero-tee KMS key-delivery gate, | ||
| /// keeping "admitted ⟹ can get key" consistent. Do not broaden. | ||
| pub const DEFAULT_ALLOWED_TCB_STATUS: &str = "UpToDate"; | ||
|
|
||
| /// dcap-qvl TCB status that is rejected unconditionally, regardless of policy | ||
| /// (defense-in-depth). Real verification already bails on `Revoked`, but the | ||
| /// subgroup-reuse admission path reuses a STORED tcb_status string without | ||
| /// re-running verify — this guards that path. | ||
| pub const TCB_STATUS_REVOKED: &str = "Revoked"; | ||
|
|
||
| /// Mock-attestation TCB status (set by `calimero_tee_attestation` mock verify). | ||
| /// Real dcap-qvl never emits this value, so it uniquely identifies the mock | ||
| /// path. Mock admission is gated upstream by `accept_mock`, so the TCB | ||
| /// allowlist must not apply to it. | ||
| pub const TCB_STATUS_MOCK: &str = "Mock"; | ||
|
|
||
| /// Shared TCB-status gate used by every `allowed_tcb_statuses` enforcement site. | ||
| /// | ||
| /// Rules (in order): | ||
| /// 1. `Revoked` (case-insensitive) → always rejected, even for the stored-status | ||
| /// subgroup-reuse path that does not re-run dcap-qvl verify. | ||
| /// 2. Mock path → allowed **only when the group policy sets `accept_mock`**. The | ||
| /// mock signal is either the explicit runtime `is_mock` flag (admit_tee_node) | ||
| /// or the reserved `"Mock"` status that carries it on the op-apply / | ||
| /// subgroup-reuse path (which has `is_mock = false`). Gating both on | ||
| /// `accept_mock` means a stored `"Mock"` status replayed onto a real fleet | ||
| /// (`accept_mock = false`) is rejected instead of being a permanent bypass | ||
| /// token — it no longer relies solely on the upstream admission gate | ||
| /// (audit follow-up to #356 / #17). | ||
| /// 3. Empty allowlist → fail-closed: enforce against the secure default | ||
| /// [`DEFAULT_ALLOWED_TCB_STATUS`] instead of skipping the check. | ||
| /// 4. Non-empty allowlist → honored exactly (case-sensitive, as today). | ||
| pub fn tcb_status_allowed( | ||
| allowed_tcb_statuses: &[String], | ||
| tcb_status: &str, | ||
| is_mock: bool, | ||
| accept_mock: bool, | ||
| ) -> bool { | ||
| if tcb_status.eq_ignore_ascii_case(TCB_STATUS_REVOKED) { | ||
| return false; | ||
| } | ||
| if (is_mock || tcb_status == TCB_STATUS_MOCK) && accept_mock { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Mock bypass activates on The condition Suggested fix: |
||
| return true; | ||
| } | ||
| if allowed_tcb_statuses.is_empty() { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Empty allowlist secure-default does not apply when If Suggested fix:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added in 4efed9f. // The is_mock flag bypasses the TCB check entirely — even a real-looking
// non-"Mock" status like OutOfDate is admitted when is_mock=true on a
// mock-accepting policy (the TCB allowlist does not apply to the mock path).
assert!(tcb_status_allowed(&[], "OutOfDate", true, true));Note the gate gained an |
||
| return tcb_status == DEFAULT_ALLOWED_TCB_STATUS; | ||
| } | ||
| allowed_tcb_statuses.iter().any(|a| a == tcb_status) | ||
| } | ||
|
|
||
| pub const TEE_REJECT_MRTD: &str = "mrtd_not_allowed"; | ||
| pub const TEE_REJECT_TCB_STATUS: &str = "tcb_status_not_allowed"; | ||
| pub const TEE_REJECT_RTMR0: &str = "rtmr0_not_allowed"; | ||
|
|
@@ -67,6 +121,10 @@ pub struct TeeAllowlistPolicy { | |
| pub allowed_rtmr2: Vec<String>, | ||
| pub allowed_rtmr3: Vec<String>, | ||
| pub allowed_tcb_statuses: Vec<String>, | ||
| /// Whether this group accepts mock attestations. Gates the mock TCB bypass | ||
| /// on the op-apply path so a stored `"Mock"` status only passes on a fleet | ||
| /// that actually opted into mock. See [`tcb_status_allowed`]. | ||
| pub accept_mock: bool, | ||
| } | ||
|
|
||
| pub struct TeeAttestationClaims<'a> { | ||
|
|
@@ -88,12 +146,18 @@ pub fn validate_tee_attestation_allowlists( | |
| }); | ||
| } | ||
|
|
||
| if !policy.allowed_tcb_statuses.is_empty() | ||
| && !policy | ||
| .allowed_tcb_statuses | ||
| .iter() | ||
| .any(|a| a == fields.tcb_status) | ||
| { | ||
| // Fail-closed TCB-status gate (shared with `admit_tee_node`). This is the | ||
| // op-apply path: it runs on every node replicating the op and has no | ||
| // explicit `is_mock` flag, so mock is detected via the reserved "Mock" | ||
| // status inside `tcb_status_allowed`. That mock bypass is in turn gated on | ||
| // the group's stored `accept_mock`, so a replayed `"Mock"` status cannot | ||
| // bypass the gate on a fleet that did not opt into mock. | ||
| if !tcb_status_allowed( | ||
| &policy.allowed_tcb_statuses, | ||
| fields.tcb_status, | ||
| false, | ||
| policy.accept_mock, | ||
| ) { | ||
| return Err(MembershipPolicyValidationError { | ||
| reason: MembershipPolicyRejection::TcbStatusNotAllowed, | ||
| }); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Mock bypass applies even when
is_mock=falseand status is"Mock"on the op-apply pathThe
tcb_status_allowedfunction allows any node to pass the TCB gate if its storedtcb_statusstring equals"Mock"— regardless ofis_mock. On the op-apply path (validate_tee_attestation_allowlists),is_mockis alwaysfalse, so the only guard is the string comparison. This means a malicious or buggy node that somehow getstcb_status = "Mock"stored in aMemberJoinedViaTeeAttestationop (e.g. via a crafted op replayed from a mock fleet onto a real fleet, or a compromised verifier) will bypass the TCB allowlist on every replicating node. The PR description acknowledges this as intentional for the subgroup-reuse path, but the trust boundary relies entirely onaccept_mockbeing enforced upstream at admission time — if that gate is ever bypassed or a stored op is replayed cross-fleet, the"Mock"string becomes a permanent bypass token. Consider documenting this invariant more explicitly as a// SAFETY:comment, or adding a check that"Mock"is only accepted when the group policy hasaccept_mock = true(readable from the store on the op-apply path).Suggested fix:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Addressed in 4efed9f by implementing your primary suggestion:
accept_mockis now threaded intotcb_status_allowedinstead of relying solely on the"Mock"string.accept_mockis readable from the storedTeeAdmissionPolicyat both call sites:validate_tee_attestation_allowlists): addedaccept_mocktoTeeAllowlistPolicy, populated from the policy read viaread_required_tee_admission_policy.admit_tee_node: passespolicy.accept_mock.The mock branch is now
if (is_mock || tcb_status == "Mock") && accept_mock. So a stored"Mock"status replayed onto a real fleet (accept_mock = false) no longer bypasses the gate — it falls through to the empty-allowlist secure default (UpToDate) and is rejected. The"Mock"string is no longer a permanent bypass token; it only passes on a fleet that actually opted into mock. Onadmit_tee_nodethis is also defense-in-depth behind the existing upstreamis_mock && !accept_mockrejection.New test
tcb_status_gate_mock_requires_accept_mockproves a stored"Mock"is rejected whenaccept_mock = false(both empty and non-empty allowlists), andvalidate_allowlists_empty_tcb_enforces_secure_defaultnow asserts the same through the op-apply entry point.