Skip to content

Separate appraisal from verification: verify_attestation always applies a policy, and appraisal can fetch #92

Open

Description

@samlaf

Another issue coming out of me thinking through the API boundaries after our discussion on #85 .
Looking a bit more into the RATS model https://www.rfc-editor.org/info/rfc9334/ I started thinking that we should aim for this library to follow its general nomenclature and roles/objects breakdown. Have you considered doing so and rejected it already for some reason?

If not, what this issue basically argues for is separation of the Verifier and Relying Party roles, and to make the RP role stateless and injectable.

Image

LLM SUMMARY

The coupling

AttestationVerifier::verify_attestation does two jobs in one call: it verifies
the evidence, then applies the measurement policy before returning
(crates/attestation/src/lib.rs:626):

let verified = match attestation_type { /* … verify … */ };

// Do a measurement / attestation type policy check
self.measurement_policy.check_measurement_with_gcp_cache(
    &verified.measurements,
    platform_metadata.as_ref(),
    Some(&self.known_gcp_firmware),
)?;

Ok(Some(verified))

RFC 9334 splits these across two roles: the Verifier consumes Evidence and
produces Attestation Results, and the Relying Party applies an Appraisal Policy
to those Results. The crate merges them, with two consequences:

  1. You cannot get a verified result without also passing a policy. For a
    relying party that wants to know "is this evidence genuinely signed and
    fresh?" and decide acceptance separately — or later, or against several
    policies — the only path also demands an accept/reject answer up front.
  2. You cannot re-appraise without re-verifying. Re-running a policy against
    an already-verified result means redoing signature verification, and on a
    cache miss an outbound collateral fetch.

MeasurementPolicy::check_measurement is already public, so a caller can
appraise on its own. What is missing is the ability to ask the verifier not to.

Appraisal is not a pure function today

Worth surfacing separately, because it surprised us. The policy step can make a
network call. On the ExpectedMeasurements::Image path:

check_measurement_with_gcp_cache
  → compare_portable_dcap_measurement       (measurements.rs:763)
    → GcpFirmwareCache::get_or_fetch        (gcp/firmware.rs:23)
      → fetch_firmware                      (gcp/firmware.rs:44)  ← HTTP

So "check these measurements against my policy" may reach the network to resolve
GCP firmware by MRTD. Also note the asymmetry: the fetching variant is
pub(crate), while the public check_measurement passes None for the cache. A
caller appraising standalone therefore gets different behaviour from the one the
verifier uses internally — it will miss the cache rather than share it.

Whatever happens to the split, it seems worth either documenting that appraisal
may fetch, or moving that resolution to the verification side where the other
network work already lives.

Rough shape: the policy as a per-call parameter, not a field

The change that follows from the role split is that measurement_policy stops
being a field on AttestationVerifier (crates/attestation/src/lib.rs:498) and
becomes an argument:

// verification: no policy involved
let result = verifier.verify_attestation(evidence, binding)?;
// appraisal: the relying party names its own policy
verifier.appraise(&result, &policy)?;

Why a parameter rather than moving the policy out of the crate's reach entirely:

  • The handshake path still works. verify_attestation_binding
    (crates/attested-tls/src/lib.rs:653) runs inside rustls's certificate
    verification callback and must return accept-or-reject, so something has to
    hold a policy there. That something is the certificate verifier struct — which
    is precisely the Relying Party in RFC 9334's terms. It holds the policy and
    passes it. The roles land where the RFC puts them without the handshake losing
    anything.
  • Verifier fetches collateral and verifies quotes the policy will always reject #87's early reject survives. The call has the policy in hand, so it can
    still refuse an attestation type the policy never accepts before doing any DCAP
    work.
  • The GCP firmware cache stays where it belongs. known_gcp_firmware is
    verifier state that the policy check needs. Keeping appraisal as a method on the
    verifier means the cache is still shared; a policy layer wholly outside the
    crate would strand it, and every caller would re-fetch.

The benefit that isn't about our use case: one verifier can serve several
relying parties with different policies
, sharing its PCCS cache, GCP firmware
cache and trusted-certificate cache. Today that needs one AttestationVerifier
per policy, and therefore one set of caches per policy.

Keep a convenience method that does both, if the common handshake path prefers one
call.

The type-level nicety is that a verified-but-unappraised result and an appraised
one could be distinct types, so a relying party cannot forget the second step.
That may be more ceremony than it's worth; mentioning it for completeness.

Not asking for a crate split

The end state of this direction is arguably a policy crate separate from a
verification crate — measurements.rs is 1938 lines and drags http,
attest_measure, attest_types and file/URL policy loading that a pure verifier
does not need. I'm deliberately not asking for that:

  • The dependency runs the wrong way. Policy consumes MultiMeasurements, which
    verification produces, so a policy crate would depend on the verifier crate or
    need a third shared-types crate.
  • Moving attestation generation and verification code to attest repo. #40 proposes moving generation and verification into the attest repo, so
    crate boundaries are already in flux and this would be the wrong moment.

Mentioning it only so the direction is legible. The ask here is the parameter.

Relationship to existing issues

Our motivation, for context

We use the crate as a relying party for a one-time event rather than a live
handshake (background in #84). Founding evidence is verified once and archived.
Being able to re-run a policy against an archived, already-verified result — with
no network and no re-verification — is the property we want. It is not a blocker;
we can call the policy ourselves. The coupling just means the verifier also
insists on appraising with the policy it was built with.

Activity

  1. ameba23 commented on Sep 1, 2026

    @ameba23
    Collaborator

    Yes! Agree its good to follow standards and it would be nice offer separate methods for verification and checking measurement policy.

    The thing i don't like so much about the RATS idea of separating verifier from relying party as that its a bit geared towards this model of outsourcing verification. Like Intel trust authority / Microsoft Azure Attestation (MAA), where a trusted authority does the actual verification and the relying party just has to check a signed token from them. This feels like handing over too much trust.

    But i think offering a flexible API is a good move.

  2. samlaf commented on Sep 1, 2026

    @samlaf
    ContributorAuthor

    @ameba23 haven't internalized the whole RATS model not even close, but my current understanding is that its verification model is flexible, and the MAA style verification is the passport check model, but RATS also supports the background check model which is what we use.

    Image

    In both cases though it's good to separate the verifier (your lib) from the RP.

  3. samlaf commented on Sep 1, 2026

    @samlaf
    ContributorAuthor

    And here's a concrete example from our use case of how this would make our code cleaner. In our case when a new validator asks to join the network, the policy lives onchain. so the verifier and relying party literally have different jobs: attested-tls needs to attest to the evidence's cryptography, but the ultimate appraisal policy (deciding whether or not to let that validator's measurements in) is done by a contract that lives on our chain.

    But to get verified measurements out of the verifier we have to build a policy that does nothing (https://github.com/SeismicSystems/enclave/blob/5d59e0d214328cb877fe66f6abcc96ce8d98fb41/crates/attestation/src/lib.rs#L127-L151):

    // Backend appraisal pinned to the evidence's own attestation type is
    // cryptographic verification only — admissibility (including which
    // attestation types are acceptable at all) is the predicate's job.
    let crypto_only =
        BackendMeasurementPolicy::single_attestation_type(evidence.attestation_type());
    let verified = verify_with_backend_policy(evidence, expected_binding, crypto_only, ..).await?;
    
    admission.admit(&verified).await.map_err(AttestationError::AdmissionDenied)?;

    It's vacuous by construction — pinned to the evidence's own attestation type, so it cannot reject anything. It exists only to satisfy the parameter. With verify() / appraise() separated, those two lines disappear and the code says what it means.

  4. ameba23 commented on Sep 2, 2026

    @ameba23
    Collaborator

    I see what you mean.

    Probably it makes sense to split AttestationVerifier into two structs, one for verifying and one for appraisal of policy.

    But for our use case i would still want to expose one high level struct which does both and has the API that AttestationVerifier has now. But probably call it something else.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions