Skip to content

feat: two-phase verify with Policy trait#112

Open
kvinwang wants to merge 34 commits intomasterfrom
policy
Open

feat: two-phase verify with Policy trait#112
kvinwang wants to merge 34 commits intomasterfrom
policy

Conversation

@kvinwang
Copy link
Copy Markdown
Collaborator

@kvinwang kvinwang commented Feb 10, 2026

Summary

Introduces two-phase quote verification: crypto verification (signature chain, TCB matching) followed by policy validation (business rules).

Built-in policy options:

  • SimplePolicy — Rust-native builder API for straightforward use without external dependencies
  • RegoPolicy / RegoPolicySet — evaluates Intel's qal_script.rego with Intel QAL JSON policies for compatibility with Intel's Quote Appraisal framework

This PR also exposes RegoPolicy / RegoPolicySet to Python and WASM/JS bindings.

Examples

SimplePolicy (Rust-native, no Rego)

use dcap_qvl::{QuoteVerifier, SimplePolicy, TcbStatus};
use std::time::Duration;

let policy = SimplePolicy::strict(now)
    .allow_status(TcbStatus::SWHardeningNeeded)
    .allow_status(TcbStatus::OutOfDate)
    .collateral_grace_period(Duration::from_secs(90 * 24 * 3600))  // 90 days
    .reject_advisory("INTEL-SA-00334")
    .reject_advisories(&["INTEL-SA-00615", "INTEL-SA-00809"])
    .allow_smt(true)
    .accepted_sgx_types(&[0, 1]);

let report = QuoteVerifier::new_prod_default_crypto()
    .verify(&quote, collateral, now)?
    .validate(&policy)?;
println!("MRENCLAVE: {:?}", report.report);

RegoPolicy (Intel QAL JSON, feature rego)

use dcap_qvl::{QuoteVerifier, RegoPolicySet};

let platform_policy = r#"{
    "environment": {
        "class_id": "3123ec35-8d38-4ea5-87a5-d6c48b567570"
    },
    "reference": {
        "accepted_tcb_status": ["UpToDate", "SWHardeningNeeded", "OutOfDate"],
        "collateral_grace_period": 7776000,
        "rejected_advisory_ids": ["INTEL-SA-00999"],
        "allow_dynamic_platform": false,
        "allow_cached_keys": false,
        "allow_smt_enabled": true,
        "accepted_sgx_types": [0, 1]
    }
}"#;

let enclave_policy = r#"{
    "environment": {
        "class_id": "bef7cb8c-31aa-42c1-854c-10db005d5c41"
    },
    "reference": {
        "sgx_mrenclave": "a1b2c3d4...",
        "sgx_mrsigner": "e5f6a7b8..."
    }
}"#;

let policies = RegoPolicySet::new(&[platform_policy, enclave_policy])?;
let report = QuoteVerifier::new_prod_default_crypto()
    .verify(&quote, collateral, now)?
    .validate(&policies)?;

Core Architecture

  • Policy trait — pluggable validation interface for SupplementalData
  • SimplePolicy — built-in Rust-native policy covering the common appraisal checks without requiring a Rego engine: TCB status, advisory ID blacklist, collateral grace period, platform grace period, QE grace period, min eval number, dynamic platform, cached keys, SMT, and SGX types
  • Two-phase flow: QuoteVerifier::verify()QuoteVerificationResult (crypto only) → validate(&policy)VerifiedReport
  • QuoteVerificationResult::supplemental() exposes the computed supplemental data for inspection before committing to a policy

Rego Policy Support (rego feature)

  • RegoPolicy — evaluates Intel's qal_script.rego via the regorus Rego interpreter
  • RegoPolicySet — multi-measurement appraisal matching Intel's QAL structure
    • SGX quotes → 2 entries (platform TCB + enclave)
    • TDX quotes → 3 entries (platform TCB + QE identity + TD)
    • each entry matched to its policy by class_id
  • rand.intn extension — RNG + memoization matching OPA semantics
  • final_appraisal_result query — uses Intel's standard appraisal output format
  • Exposed in Rust, Python, and WASM/JS bindings

SupplementalData

  • 1:1 parity with Intel's sgx_ql_qv_supplemental_t fields, plus qe_report and qe_tcb_eval_data_number for multi-measurement support
  • QE Identity-specific time fields (qe_iden_earliest_issue_date, qe_iden_latest_issue_date, qe_iden_earliest_expiration_date) matching Intel's qve_get_collateral_dates() behavior
  • Separate measurement generators for unmerged status:
    • platform measurement uses unmerged platform_tcb_level
    • QE measurement uses qe_tcb_level + QE-specific dates
    • SGX enclave tenant measurement includes KSS field extraction
    • TDX TD10/TD15 tenant measurements supported

Other Changes

  • TcbStatus merge uses Intel's exact convergeTcbStatusWithQeTcbStatus logic
  • SupplementalData time window computed from 8 sources
  • root_key_id computed as SHA-384 of raw public key bytes
  • Verified PCK chain preserved for supplemental-data construction
  • Platform flags (dynamic_platform, cached_keys, smt_enabled) distinguish False from Undefined
  • JS/WASM API now follows the two-phase flow with QuoteVerifier, SimplePolicy, and QuoteVerificationResult
  • Extracted src/policy/ as dedicated module with mod.rs, simple.rs, rego.rs

Test plan

  • cargo test --all-features
  • cargo clippy --all-features -- -D warnings
  • cargo build --target wasm32-unknown-unknown --features js
  • Python bindings updated for two-phase API and Rego policy exposure
  • JS/WASM examples and tests updated for two-phase API and Rego policy exposure
  • Added coverage for RegoPolicy / RegoPolicySet bindings and SimplePolicy advisory blacklist behavior

@kvinwang kvinwang changed the title Add policy validation support feat: Add Policy trait, QuotePolicy, RegoPolicy, and multi-measurement RegoPolicySet Feb 10, 2026
kvinwang added 4 commits March 6, 2026 04:35
SupplementalData is no longer built during verify() — it's computed
on demand via result.supplemental() or internally by validate().
This keeps the verify() path minimal (crypto only), saving ~2 TGas
on NEAR (175 TGas, matching pre-policy baseline).

- QuoteVerificationResult stores verification intermediates privately
- supplemental() method builds the full struct lazily (root_key_id,
  CRL numbers, tcb_date_tag, earliest_expiration)
- into_report() works without ever constructing SupplementalData
- Rego path calls supplemental() internally when needed
Rego validation was parsing TcbInfo + QeIdentity JSON twice:
once in supplemental() → compute_earliest_expiration(), and
again in compute_time_window(). Now Rego path uses
build_supplemental(tw.earliest_expiration_date) to reuse
the value from compute_time_window() directly.
- Split monolithic src/policy.rs into policy/{mod,simple,rego}.rs
- Rename QuotePolicy to SimplePolicy for clarity
- Advisory skip only during platform grace for pure OutOfDate
  (collateral grace and OutOfDateConfigurationNeeded don't skip)
- Update all imports and re-exports across lib, cli, verify
- Create docs/policy.md with comprehensive policy validation guide
- Rename into_report() to into_report_unchecked() with warning docs
- Add doc comments to CollateralTimeWindow fields
- Update README with two-phase API example and policy section
Resolve conflicts:
- src/lib.rs: keep pub visibility for qe_identity and tcb_info modules
- src/verify.rs: keep QuoteVerificationResult return type, remove
  convenience verify() functions (policy branch design), retain
  danger-allow-tcb-override in verify_impl and QuoteVerifier
…C FFI

- Move CollateralTimeWindow fields into SupplementalData (8-source time window)
- Add Report to SupplementalData so Policy impls have full context
- impl Policy for RegoPolicy and RegoPolicySet (replaces separate eval methods)
- Add SimplePolicyConfig (JSON-deserializable) for building SimplePolicy
- Python bindings: two-phase API (verify → QVR → validate(policy) → VerifiedReport)
- C FFI: keep flat dcap_verify_cb/dcap_verify_with_root_ca_cb (no policy in ABI)
- Go bindings unchanged from master
…den_* dates

- Register rand.intn as regorus extension with real RNG (getrandom) and
  per-evaluation memoization matching OPA semantics (cache key = "{str}-{n}")
- Switch Rego query from final_ret to final_appraisal_result (the standard
  Intel QAL output format with nonce, timestamp, appraised_reports)
- Add qe_iden_earliest_issue_date, qe_iden_latest_issue_date,
  qe_iden_earliest_expiration_date to SupplementalData, computed from
  QE Identity issuer chain + JSON (sources [5]+[7]), matching Intel's
  qve_get_collateral_dates() lines 94-96
- QE measurement now uses QE-specific time window instead of global dates
@kvinwang kvinwang changed the title feat: Add Policy trait, QuotePolicy, RegoPolicy, and multi-measurement RegoPolicySet feat: Policy trait, SimplePolicy, RegoPolicy, and multi-measurement RegoPolicySet Mar 10, 2026
The two-phase API refactor removed these functions. Re-add them using
QuoteVerifier + into_report_unchecked(), matching the test harness
behavior. Add rustcrypto to the js feature since CryptoBackend is now
required.
@kvinwang kvinwang changed the title feat: Policy trait, SimplePolicy, RegoPolicy, and multi-measurement RegoPolicySet feat: two-phase verify with Policy trait Mar 11, 2026
…icationResult

Replace flat js_verify/js_verify_with_root_ca/js_get_collateral globals
with class-based API matching the Rust two-phase pattern:

  const verifier = new QuoteVerifier();       // or new QuoteVerifier(rootCaDer)
  const result = verifier.verify(quote, collateral, now);
  const report = result.validate(policy);     // or result.into_report_unchecked()

  const policy = new SimplePolicy(now)
      .allow_status("OutOfDate")
      .allow_smt(true);

  const collateral = await QuoteVerifier.get_collateral(pccsUrl, quote);

Add rustcrypto to js feature since CryptoBackend is now required.
…sgx_types

- Fix get_collateral_web.js, esbuild/main.ts, vite/main.ts to use
  QuoteVerifier class instead of deleted js_verify/js_get_collateral
- Add accepted_sgx_types() to JsSimplePolicy (parity with Python)
- Change example files to use validate(policy) as default path
  instead of into_report_unchecked()
- Fix rustfmt formatting in get_collateral and js_parse_tcb_status
…esult

Without js_class matching the js_name on the struct, wasm_bindgen
doesn't attach methods to the renamed JS class.
@kvinwang kvinwang marked this pull request as ready for review March 11, 2026 04:03
Move the collateral_grace_period vs platform/qe_grace_period mutual
exclusivity check to the top of SimplePolicy::validate(), before any
data-dependent checks run. Previously it was checked after the TCB
status check, meaning a misconfigured policy would run partial
validation before failing on the config error.
OPA's builtinRandIntn returns 0 for n==0 and uses abs(n) for negative
values. Our implementation was returning 0 for all n<=0, diverging
from OPA's behavior for negative n.
The collateral_grace_period, platform_grace_period, and qe_grace_period
are orthogonal checks (time-based vs version-based). Intel's Rego script
enforces mutual exclusivity as a simplification, but it's not a logical
necessity. Allow all three to be set independently.
Lightweight accessor that avoids cloning the entire QVR or calling
into_report_unchecked() just to read the PPID.
Comment on lines +438 to +440
accepted_tcb_level_date_tag_ok(bundle) if {
not bundle.policy.sgx_platform.reference.min_tcb_level_date
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The Rego rule accepted_tcb_level_date_tag_ok uses an incorrect path, causing the min_tcb_level_date policy validation to be bypassed entirely.
Severity: HIGH

Suggested Fix

In rego/qal_script.rego, change the path bundle.policy.sgx_platform.reference.min_tcb_level_date to bundle.policy.reference.min_tcb_level_date to match the correct policy data structure.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: rego/qal_script.rego#L438-L440

Potential issue: In the Rego script, the rule `accepted_tcb_level_date_tag_ok` uses an
incorrect object path `bundle.policy.sgx_platform.reference.min_tcb_level_date` to
access a policy setting. The correct path is
`bundle.policy.reference.min_tcb_level_date`. Because the incorrect path is always
undefined in Rego, the `not` expression always evaluates to true. This causes the rule
to always succeed, effectively disabling the policy constraint that is supposed to
enforce a minimum TCB level date. As a result, platform TCB measurements with outdated
dates will pass validation when they should be rejected.

Comment on lines +282 to +283
"appraisal_result": appraise_ret,
"report": {"environment": item.report.environment, "measurement": item.report.measurement},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The Rego script uses incorrect paths (item.report.*) to access fields for unmatched reports, leading to malformed diagnostic output.
Severity: MEDIUM

Suggested Fix

In the appraisal_result rule that handles report_not_in_policy, change the output mapping from {"environment": item.report.environment, "measurement": item.report.measurement} to {"environment": item.environment, "measurement": item.measurement}.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: rego/qal_script.rego#L282-L283

Potential issue: When generating an appraisal result for a report that does not match
any policy, the Rego script attempts to access `item.report.environment` and
`item.report.measurement`. However, the `item` in this context is a raw report entry
from `report_not_in_policy` and does not have a nested `report` field. The correct paths
are `item.environment` and `item.measurement`. This error causes the resulting
`appraisal_output` object to contain an incomplete or empty `report` field, breaking the
diagnostic reporting for unmatched reports.

Comment on lines +797 to +801
isvfamilyid_ok(bundle) if {
is_string(bundle.report.measurement.sgx_isvfamilyid)
is_string(bundle.policy.reference.sgx_isvfamilyid)
lower(bundle.report.measurement.sgx_isvfamilyid) == bundle.policy.reference.sgx_isvfamilyid
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The isvfamilyid_ok rule performs a case-sensitive comparison by failing to apply lower() to the policy value, unlike other similar rules.
Severity: MEDIUM

Suggested Fix

In the isvfamilyid_ok rule, apply the lower() function to the policy value to ensure a case-insensitive comparison. Change bundle.policy.reference.sgx_isvfamilyid to lower(bundle.policy.reference.sgx_isvfamilyid).

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: rego/qal_script.rego#L797-L801

Potential issue: The Rego rule `isvfamilyid_ok` performs a case-sensitive comparison
between the `sgx_isvfamilyid` from the report and the policy. It converts the report
value to lowercase using `lower()` but fails to do the same for the policy value
`bundle.policy.reference.sgx_isvfamilyid`. This is inconsistent with all other similar
hexadecimal field comparisons in the same file, which apply `lower()` to both sides.
This will cause validation to fail incorrectly if a user provides an uppercase or
mixed-case `sgx_isvfamilyid` in their policy, as the report value is always uppercase
before being lowercased for comparison.

src/verify.rs Outdated
Comment on lines +769 to +773
let pce_id = match pck_ext.pce_id.as_slice() {
[hi, lo] => u16::from_be_bytes([*hi, *lo]),
[lo] => u16::from(*lo),
_ => 0,
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The pce_id is parsed as big-endian using from_be_bytes, but the Intel specification defines it as little-endian, leading to incorrect ID values.
Severity: HIGH

Suggested Fix

In src/verify.rs, change the pce_id conversion from u16::from_be_bytes to u16::from_le_bytes to correctly parse the little-endian value as defined by the Intel specification.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/verify.rs#L769-L773

Potential issue: The `pce_id` field from the PCK certificate is parsed as a big-endian
value using `u16::from_be_bytes`. However, the Intel specification and comments
elsewhere in the codebase confirm that `pce_id` is encoded in little-endian format. This
discrepancy will cause an incorrect `pce_id` value to be calculated and stored in the
`QuoteVerificationResult` for any certificate where the ID is not symmetrical (e.g., a
little-endian value of 1, `[0x01, 0x00]`, would be incorrectly parsed as 256). This
incorrect data is then exposed to consumers and could cause failures in Rego policy
evaluations that rely on this value.

Comment on lines +743 to +745
let result = self
.inner
.take()

This comment was marked as outdated.

Comment on lines +560 to +567
fn validate(&self, data: &SupplementalData) -> Result<()> {
let measurement = build_merged_measurement(data);
let qvl_result = vec![json!({
"environment": { "class_id": &self.class_id },
"measurement": measurement,
})];
eval_rego_engine(&self.engine, &[&self.policy_json], qvl_result)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: RegoPolicy::validate() incorrectly uses a merged TCB status for evaluation, rather than selecting the appropriate measurement based on the policy's class_id, causing inconsistent validation results.
Severity: HIGH

Suggested Fix

Modify RegoPolicy::validate() to check the self.class_id and invoke the appropriate measurement builder function (build_platform_measurement, build_qe_measurement, or tenant_measurement), mirroring the logic currently used in to_rego_qvl_result.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: src/policy/rego.rs#L560-L567

Potential issue: The `RegoPolicy::validate()` function incorrectly uses a merged TCB
status for all policy evaluations by always calling `build_merged_measurement(data)`.
This function combines the platform and QE TCB statuses. However, the validation should
be specific to the policy's `class_id`, as is correctly handled in
`RegoPolicySet::validate()`, which uses different measurement builders
(`build_platform_measurement`, `build_qe_measurement`) accordingly. This discrepancy
leads to incorrect validation outcomes. For example, a platform policy requiring an
`UpToDate` status will be incorrectly rejected if the platform TCB is `UpToDate` but the
QE TCB is `OutOfDate`, because the merged status will be `OutOfDate`. This creates
inconsistent behavior between the `RegoPolicy` and `RegoPolicySet` APIs.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant