Skip to content

syft-enclave: attestation token is a de-facto bearer credential — never refreshed, ~30-day expiry grace, freshness-nonce slot never checked #9489

Description

@khoaguin

While building a trust-minimized audit framework on syft-enclave, we found that the attestation token behaves as a de-facto bearer credential. The enclave publishes its token once at boot into SYFT_version.json — a file that syncs over a transport the threat model already declares untrusted (e.g. Google Drive). The token then verifies for ~30 days past its real expiry and is bound to nothing its holder must possess. So anything that can read the sync folder can present itself as a live, attested enclave to a data owner deciding whether to release secrets. Reading the file is not an attack; it is the normal operation of the sync folder.

Before / After

Before

  • Trigger: a data owner runs attest_peer() (or any consumer calls verify_attestation_token()) before releasing secrets to an enclave.
  • Today: the check accepts a token minted up to a month ago, presented by anyone, with the caller-freshness slot in the EAT nonce empty and unchecked.
  • Cost: a captured SYFT_version.json lets a non-enclave impersonate an attested enclave for ~30 days — the cheapest attack in the model, with total-compromise damage (secrets released to the impersonator).

After

  • Same trigger.
  • Now: a verifier can pose a per-request nonce; the enclave mints a fresh token embedding it; verification checks the nonce and honours the token's real (~30 min) expiry. Replayed tokens fail.
  • Win: the token becomes a challenge-response, worthless to anyone who merely overheard it.

Don't regress

  • The legacy unchallenged path (boot-published token, no nonce) keeps working — peers of already-deployed enclaves must not break.
  • The grace window is only tightened on the challenged path, not removed globally.

What happens today vs. with the fix

TODAY: attacker replays the boot-published token and the data owner releases secrets. FIX: the owner sends a per-request nonce, the enclave mints a fresh token embedding it, and a replayed token is rejected on nonce mismatch or real expiry.

The three compounding problems

All paths under packages/syft-enclave/.

1. The token never expires in practice. Google mints Confidential Space tokens with a ~30-minute lifetime, but EnclaveRunner._publish_attestation() runs once during init() (src/syft_enclaves/runner.py:132-134) and never again — tick() (runner.py:77) does not republish. The stopgap for that is JWT_EXPIRY_GRACE_SECONDS = 30 * 24 * 60 * 60 (src/syft_enclaves/attestation.py:33), passed to the verifier as clock_skew_in_seconds (attestation.py:146), so an expired token still verifies for a month. The comment above the constant already names the intended fix:

TODO: remove this once the enclave periodically refreshes its attestation token in SYFT_version.json — then the real (short) expiry can be honoured.

2. Nothing binds the token to its holder. verify_attestation_token() checks the JWT signature, secboot, dbgstat, the syft-client version nonce, and the image digest. None of these require the presenter to possess anything — there is no key the enclave must prove it holds and no challenge the verifier chose. A token that verifies for one party verifies for whoever is holding it.

3. The freshness slot exists and is never filled or checked. build_eat_nonce(caller_nonce=None) documents slot 1 as a "caller-supplied freshness nonce" (src/syft_enclaves/tee_token.py:28), but runner.py:134 calls it with no argument, and verify_attestation_token() reads only eat_nonce[0] (attestation.py:201-205) — slot 1 is never inspected. Notably, docker/attestation_server.py already validates and embeds a caller nonce on demand (attestation_server.py:155,174,199): the enclave can already answer a challenge — nobody ever poses or checks one.

Put together: the token is a bearer credential with a one-month effective life, published into a file whose exposure is by-design.

Proposed fix

Three small changes — happy to open the PR:

  1. Check the nonce. Add AppraisalPolicy.expected_nonce: Optional[str] = None and a sixth check, freshness_nonce: skipped when unset (legacy path unchanged), fail on missing/mismatched eat_nonce[1]. When a nonce is expected, verify with a tight clock skew (~60 s) instead of the month grace — a challenged token is fresh by construction.
  2. Refresh the published token. Republish in tick() on a ~15-minute timer (tokens live ~30 min), retiring the reason the grace window exists.
  3. Help verifiers generate nonces. A generate_caller_nonce() helper in tee_token.py using secrets.token_urlsafe, which fits the existing _NONCE_PATTERN / 74-char cap (tee_token.py:21-22).

Scope note

attest_peer() (src/syft_enclaves/client.py:85) reads the boot-published token from SYFT_version.json, so the nonce path cannot cover it until a challenge-file protocol exists over syftbox — that is a follow-up, not this fix. This fix fully closes the HTTP/attestation_server path and makes the published token short-lived instead of month-long. The grace window remains only for the legacy unchallenged path.


Same consumer-finds-the-gap pattern as the merged digest-pinning fix (#9454) — and the commit that introduced the grace window (c7e2edbd07) already names periodic refresh as the intended fix, so this just finishes that sentence.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions