#9489·PySyft

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

Author: khoaguinCreated Aug 23, 2026Updated Sep 18, 2026
LabelsEnclavepkg:syft-enclave

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.