feat(runtime): correct enclave clock drift via hypervisor PTP clock#138
Open
aruokhai wants to merge 4 commits into
Open
feat(runtime): correct enclave clock drift via hypervisor PTP clock#138aruokhai wants to merge 4 commits into
aruokhai wants to merge 4 commits into
Conversation
…e genesis descriptor
Add an externally-verifiable record of every EIF version that has held the
deployment's key, plus a stable per-generation enclave identity, so any party
can audit the version chain and bind a live enclave to it.
PCR0 lineage (runtime/lineage.go):
- Append-only, Object-Lock-immutable S3 log. At adoption the migrating-in enclave
records its own Nitro self-attestation plus the predecessor's (which PCR31-commits
to it), so the genesis→current chain is permanent and Nitro-verifiable. Audit-only:
failures are logged, never gate boot.
Persistent identity (runtime/attestation.go):
- The response-signing attestation key is now a persistent, per-generation secp256k1
key: minted (attested) under the active KMS key on the first boot of a generation,
sealed in SSM by keyID, reloaded on reboot, rotated on migration. Its hash is the
attestation appKeyHash and is committed to a reserved PCR (30), binding every signed
response to the enclave's lineage entry. Secrets reserve PCRs strictly below it.
Pinnable genesis descriptor (runtime/descriptor.go, cli/descriptor.go):
- Genesis builds {genesis_pcr0, anchor/lineage bucket names, region}, writes it to the
lineage entry, and binds sha256(descriptor) into the genesis self-attestation's
user_data (isolated from the response-signing user_data format). `enclave descriptor`
publishes it out-of-band; enclave-info returns it.
External verification (cli/lineage.go):
- `enclave verify-lineage` walks genesis→head against the AWS Nitro root and binds the
live enclave to the head via the identity PCR. With --descriptor it runs
credential-less (anonymous reads of the public lineage bucket) and checks the pinned
descriptor against the genesis-attested one. The dev deployment skips the signature
check (mock NSM), mirroring the runtime's skipCOSEVerification.
Infra: dedicated Object-Lock lineage bucket (public read-only) + IAM + SSM params;
the anchor bucket stays private. The internal freshness anchor (boot gate + per-read
rollback protection) is unchanged.
Verified end-to-end on QEMU + LocalStack (genesis + v1→v2 migration), incl.
credential-less verify-lineage; runtime -race and cli unit tests green.
…ia PCR0 The per-generation identity key (sealed secp256k1, committed to PCR30, unified with the response-signing key) added no guarantee once the client-facing freshness check was removed: - verify-lineage's live-enclave-to-head binding only needs PCR0: a same-PCR0 enclave IS the head version, and a same-PCR0 twin unseals the same identity key, so PCR30 = hash(identity) gave no discrimination over PCR0. bindLiveEnclaveToHead now compares the live attestation's PCR0 to the head's. - The response-signing key reverts to ephemeral (per boot); clients still verify it via the attestation appKeyHash. No client-visible change. Removed: Attestation.Load/ExtendPCR + identityPCRIndex (PCR30 reservation), the IdentityKey SSM ciphertext param + its IAM grant + teardown, and the PCR30 / identity-rotation assertions in the test app and e2e scripts. The immutable PCR0 lineage and the pinnable genesis descriptor (credential-less verify-lineage --descriptor) are unchanged; the descriptor binding uses the Nitro user_data, not the signing key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Nitro enclave's kvm-clock is set once at boot and never re-synced, drifting ~1s/day — silently corrupting every wall-clock decision the runtime makes (freshness-anchor timestamps + S3 Object-Lock retain-until, PCR0 lineage, self-signed TLS validity, K/V TTLs, stream IDs, migration cooldown). There was no time synchronization at all. StartClockSync (runtime.go) opens /dev/ptp0 — the hypervisor's ptp_kvm clock, which the parent instance cannot forge or skew — hard-steps CLOCK_REALTIME to it at boot (before any outbound TLS or served traffic), then runClockSync (clocksync.go) slews every 5s when drift exceeds 5ms via ClockAdjtime (ADJ_OFFSET_SINGLESHOT — gradual, never a backward jump). No-op if /dev/ptp0 is absent (e.g. a TCG-only QEMU runner). Uses the already-vendored golang.org/x/sys/unix; no new dependency or vendorHash change. Verified end-to-end: the KVM test runner exposes /dev/ptp0, so the boot hard-step engages and the full suite stays green (35/35 integration, 76 run.sh PASS); run.sh surfaces the clock-sync status. Unit tests cover the offset math and PTP clock-id derivation.
This was referenced Jul 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #137.
Problem
The Nitro enclave's
kvm-clockis set once at boot from the hypervisor and never re-synchronized, drifting ~1s/day. With no time sync in the codebase, that drift silently corrupts every wall-clock decision the runtime makes — freshness-anchor timestamps + S3 Object-Lock retain-until, PCR0 lineage, self-signed TLS validity, K/V TTLs, stream IDs, migration cooldown.Fix
Sync
CLOCK_REALTIMEto the hypervisor's PTP hardware clock (/dev/ptp0, the built-inptp_kvmdriver) — which the parent EC2 instance cannot forge or skew, even across restarts.(*Runtime).StartClockSync()opens/dev/ptp0, hard-stepsCLOCK_REALTIMEto it at boot (wired inmain.gobefore any outbound TLS / served traffic), then spawns the slew loop.runClockSync()slews every 5s when drift exceeds 5ms viaunix.ClockAdjtime(ADJ_OFFSET_SINGLESHOT— gradual, never a backward jump), and releases the device on the lifecyclestopchannel.golang.org/x/sys/unix— no new dependency, no vendorHash change. Graceful no-op if/dev/ptp0is absent.Why it's host-safe
Time is sourced from the Nitro Hypervisor (
/dev/ptp0), outside the parent instance's trust boundary. A malicious host can't forge or rewind it — setting the parent OS clock has no effect, and it can't substitute a fake PTP device. Residual is delay/DoS only, already in the threat model.Feasibility (verified)
The pinned kernel (
aws-nitro-enclaves-cliv1.2.3) hasCONFIG_PTP_1588_CLOCK_KVM=y+CONFIG_DEVTMPFS_MOUNT=y, so/dev/ptp0is auto-created at boot on real Nitro — no rootfs/kernel work.Verification
/dev/ptp0, so the boot hard-step actually engaged (clock sync: initial hard-step to hypervisor PTP completed);run.shsurfaces this as an informational line. A TCG-only runner would hit the graceful "disabled" branch.Notes
add_migration_attestion(stacked on feat: PCR0 version lineage + pinnable genesis descriptor #136), since the clock-sync runs inside the same runtime boot path.tx.offchainTime).🤖 Generated with Claude Code