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

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:
- 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.
- Refresh the published token. Republish in
tick() on a ~15-minute timer (tokens live ~30 min), retiring the reason the grace window exists.
- 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.
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 intoSYFT_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
attest_peer()(or any consumer callsverify_attestation_token()) before releasing secrets to an enclave.SYFT_version.jsonlets 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
Don't regress
What happens today vs. with the fix
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 duringinit()(src/syft_enclaves/runner.py:132-134) and never again —tick()(runner.py:77) does not republish. The stopgap for that isJWT_EXPIRY_GRACE_SECONDS = 30 * 24 * 60 * 60(src/syft_enclaves/attestation.py:33), passed to the verifier asclock_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: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), butrunner.py:134calls it with no argument, andverify_attestation_token()reads onlyeat_nonce[0](attestation.py:201-205) — slot 1 is never inspected. Notably,docker/attestation_server.pyalready 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:
AppraisalPolicy.expected_nonce: Optional[str] = Noneand a sixth check,freshness_nonce: skipped when unset (legacy path unchanged), fail on missing/mismatchedeat_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.tick()on a ~15-minute timer (tokens live ~30 min), retiring the reason the grace window exists.generate_caller_nonce()helper intee_token.pyusingsecrets.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 fromSYFT_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_serverpath 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.