Skip to content

feat(runtime): correct enclave clock drift via hypervisor PTP clock#138

Open
aruokhai wants to merge 4 commits into
masterfrom
fix-clock_drift
Open

feat(runtime): correct enclave clock drift via hypervisor PTP clock#138
aruokhai wants to merge 4 commits into
masterfrom
fix-clock_drift

Conversation

@aruokhai

Copy link
Copy Markdown
Collaborator

Fixes #137.

Problem

The Nitro enclave's kvm-clock is 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_REALTIME to the hypervisor's PTP hardware clock (/dev/ptp0, the built-in ptp_kvm driver) — which the parent EC2 instance cannot forge or skew, even across restarts.

  • (*Runtime).StartClockSync() opens /dev/ptp0, hard-steps CLOCK_REALTIME to it at boot (wired in main.go before any outbound TLS / served traffic), then spawns the slew loop.
  • runClockSync() slews every 5s when drift exceeds 5ms via unix.ClockAdjtime (ADJ_OFFSET_SINGLESHOT — gradual, never a backward jump), and releases the device on the lifecycle stop channel.
  • Uses already-vendored golang.org/x/sys/unixno new dependency, no vendorHash change. Graceful no-op if /dev/ptp0 is 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-cli v1.2.3) has CONFIG_PTP_1588_CLOCK_KVM=y + CONFIG_DEVTMPFS_MOUNT=y, so /dev/ptp0 is auto-created at boot on real Nitro — no rootfs/kernel work.

Verification

  • Full QEMU + LocalStack e2e: 35/35 integration, 76 run.sh PASS, 0 fail — no regression.
  • The KVM-accelerated runner exposes /dev/ptp0, so the boot hard-step actually engaged (clock sync: initial hard-step to hypervisor PTP completed); run.sh surfaces this as an informational line. A TCG-only runner would hit the graceful "disabled" branch.
  • Unit tests for the offset math (overflow-safe across the 1e9 boundary) and the PTP clock-id derivation.

Notes

🤖 Generated with Claude Code

aruokhai and others added 3 commits June 25, 2026 20:35
…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.
@aruokhai
aruokhai changed the base branch from add_migration_attestion to master July 14, 2026 13:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant