Skip to content

Pin the live TLS leaf cert#135

Merged
aruokhai merged 36 commits into
masterfrom
tls_key
Jul 13, 2026
Merged

Pin the live TLS leaf cert#135
aruokhai merged 36 commits into
masterfrom
tls_key

Conversation

@aruokhai

Copy link
Copy Markdown
Collaborator

Pin the live TLS leaf cert to the fingerprint signed into the enclave's NSM
attestation (tlsKeyHash in user_data), closing a MITM gap: the HTTP client,
enclave verify, and enclave curl previously skipped TLS verification, so a
host could terminate TLS with its own cert, relay the genuine enclave's
attestation, and read/tamper the channel while the client reported "verified".
gRPC already pinned; this brings the other three paths to parity.

Client:

  • Split into an unpinned bootstrap client (only GET /enclave/attestation) and a
    pinned client for everything else. verify() fetches the attestation, activates
    the pin, then runs PCR + key-binding checks over the pinned client, so a cert
    mismatch fails the handshake before any app request is sent.
  • Extract a shared verifyLeafCertPin used by both the HTTP and gRPC transports;
    reject empty/all-zero hashes (fail closed).
  • Add Options.StrictTLS (public CA/hostname validation on top of the pin) and
    Options.SkipKeyBinding (PCR0 + pin only); export PinnedHTTPClient.

CLI:

  • enclave curl: attestation-verified by default (--expected-pcr0), routing the
    user's request over the pinned connection. --insecure opts out (warns on
    stderr); --strict-tls adds PKI validation; the two are mutually exclusive.
  • enclave verify: pins the live cert to the attested fingerprint after the
    unpinned bootstrap and runs remaining checks over it.

Runtime:

  • setCertFingerprint fails closed when the chain has no non-CA leaf, instead of
    publishing an all-zero binding clients would reject.
  • Drop the unused MockCertFp escape hatch.

Refactor:

  • Rename appKeyHash → signingKeyHash (field + SetSigningKeyHash setter) to
    parallel its sibling tlsKeyHash.
  • Move tests to their source-matching files: pin_test.go → client_test.go +
    verify_test.go, cert_fingerprint_test.go → runtime_test.go.

aruokhai and others added 2 commits June 9, 2026 17:50
The runtime proved current code identity (PCR0) but not the origin of the
persisted state it booted on, so a host with AWS-account access could pre-seed
or swap the SSM artifacts the runtime owns (KMSKeyID, secret ciphertexts,
storage DEK) and the genuine enclave would decrypt and run on attacker-known
state.

Introduce state-origin receipts: at genesis/migration the enclave emits an
NSM-signed attestation whose user_data commits to a state_root over every
artifact it owns. Every later boot recomputes the state_root and requires a
valid receipt (AWS-root signature, PCR0 == self, user_data covers the
recomputed root) before decrypting — otherwise it fails closed. An attacker
cannot forge a receipt for the genuine PCR0: only the genuine EIF emits them,
and only over state it generated itself. The commitment covers ciphertext
hashes, not just KMSKeyID, so swapping a ciphertext under the accepted key
(the EC2 role holds un-gated Encrypt/GenerateDataKey) changes the state_root
and is rejected.

- StateOrigin subsystem (state_origin.go): state_root over runtime-owned SSM
  artifacts (deterministic CBOR), receipt write/verify, and Establish() —
  classify -> ensureKey -> verify -> load -> write, owned in one place so
  "verify before decrypt, commit after load" is structural, not a convention
  Init must remember.
- Migration: the predecessor writes a transition receipt over the successor's
  state_root (PCR31 -> successor) before flipping KMSKeyID; the successor
  verifies and adopts it, then writes its own receipt. Rollback-onto-self
  (predecessor == us) skips the PCR31 check, matching VerifyPredecessorCommitment.
- Harden VerifyKeyAuthorization: reject un-gated kms:Decrypt and kms:PutKeyPolicy
  granted to anyone when locked / a non-root principal when unlocked.
- Expose kms_key_locked in /v1/enclave-info.
- Tests: state_root determinism, receipt accept/reject, the classification
  table, the ciphertext-swap fail-closed property, KMS policy posture, and
  rollback-onto-self; plus an integration tamper -> fail-closed step and a
  kms_key_locked assertion
Pin the live TLS leaf cert to the fingerprint signed into the enclave's NSM
attestation (tlsKeyHash in user_data), closing a MITM gap: the HTTP client,
`enclave verify`, and `enclave curl` previously skipped TLS verification, so a
host could terminate TLS with its own cert, relay the genuine enclave's
attestation, and read/tamper the channel while the client reported "verified".
gRPC already pinned; this brings the other three paths to parity.

Client:
- Split into an unpinned bootstrap client (only GET /enclave/attestation) and a
  pinned client for everything else. verify() fetches the attestation, activates
  the pin, then runs PCR + key-binding checks over the pinned client, so a cert
  mismatch fails the handshake before any app request is sent.
- Extract a shared verifyLeafCertPin used by both the HTTP and gRPC transports;
  reject empty/all-zero hashes (fail closed).
- Add Options.StrictTLS (public CA/hostname validation on top of the pin) and
  Options.SkipKeyBinding (PCR0 + pin only); export PinnedHTTPClient.

CLI:
- `enclave curl`: attestation-verified by default (--expected-pcr0), routing the
  user's request over the pinned connection. --insecure opts out (warns on
  stderr); --strict-tls adds PKI validation; the two are mutually exclusive.
- `enclave verify`: pins the live cert to the attested fingerprint after the
  unpinned bootstrap and runs remaining checks over it.

Runtime:
- setCertFingerprint fails closed when the chain has no non-CA leaf, instead of
  publishing an all-zero binding clients would reject.
- Drop the unused MockCertFp escape hatch.

Refactor:
- Rename appKeyHash → signingKeyHash (field + SetSigningKeyHash setter) to
  parallel its sibling tlsKeyHash.
- Move tests to their source-matching files: pin_test.go → client_test.go +
  verify_test.go, cert_fingerprint_test.go → runtime_test.go.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aruokhai aruokhai changed the base branch from master to enclave_restart June 10, 2026 16:32

@arkanaai arkanaai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Arkana Code Review — PR #135: Pin the live TLS leaf cert

Verdict: APPROVE — this is a high-quality security hardening PR that closes a real MITM gap and adds significant defense-in-depth. Thorough, well-tested, correct fail-closed semantics throughout.


What this PR does (verified against the diff)

TLS cert pinning (#129): Splits the HTTP client into an unpinned bootstrap client (only GET /enclave/attestation) and a pinned client for everything else. The pin is activated after attestation verification, before any app request is sent. A shared verifyLeafCertPin() function covers HTTP, gRPC, and CLI paths — reject empty/all-zero hashes, reject missing peer certs, SHA-256 compare the DER leaf.

State-origin receipts (#131): NSM-signed receipts bind the SSM state (KMSKeyID + all secret ciphertexts + storage DEK) to the enclave's identity. Boot classification (genesis/resume/migration) fails closed on partial or ambiguous state. The canonical CBOR + domain-separated SHA-256 state_root is deterministic and sensitive to any ciphertext swap.

KMS policy posture verification (#131): verifyKeyPolicyPosture goes beyond policyAdmitsPCR0 — it now rejects un-gated kms:Decrypt (no PCR0 condition), rejects kms:PutKeyPolicy to non-root principals (unlocked) or anyone (locked), and treats kms:* / * wildcards as granting both.

SSM overlay hardening: nonOverridableEnv prevents the SSM env overlay from setting ENCLAVE_DEPLOYMENT or ENCLAVE_APP_NAME, closing the attack where an SSM writer sets ENCLAVE_DEPLOYMENT=dev to flip skipCOSEVerification().

Migration ordering fix: storePCR0WithAttestation now writes the attestation doc before the PCR0 commit point, and VerifyPredecessorCommitment fails closed on a recorded PCR0 with a missing attestation (was: return nil).


Security analysis — no issues found

  1. Bootstrap/pin split is correct. The unpinned client only fetches /enclave/attestation (public, no secrets). The pin is set in verify() via c.setPinHash(tlsKeyHash) before any call to verifyKeyBinding or verifyPCR0Chain runs on the pinned c.httpClient. Cache hits in ensureVerified skip re-verify but the pinHash persists from the first verification. Pre-pin requests fail closed (verifyLeafCertPin rejects empty hash).

  2. setCertFingerprint fail-closed (runtime/runtime.go:978). Previously returned nil on a CA-only chain, leaving an all-zero tlsKeyHash that clients would accept. Now returns an error. Tested in TestSetCertFingerprint.

  3. MockCertFp removed. The test-only escape hatch that let a config value override the real cert fingerprint is gone. Good.

  4. State-origin classify() is presence-only, verification is deferred. This is the right design — classification reads SSM cheaply, then the caller runs the expensive NSM-verified check the mode selects before decrypting anything.

  5. Migration transition receipt ordering (runtime/migrate.go:261). Written before the KMSKeyID flip (the atomic commit point), so the successor can verify the handoff on first boot. PCR31 already committed at this point.

  6. Rollback-onto-self (state_origin.go:~line 145). When predecessorPCR0Hex == ownPCR0Hex, PCR31 check is skipped (it commits to the failed forward target, not us). PCR0 + state_root coverage is the proof. Well-tested in TestVerifyMigrationTransitionReceipt_RollbackOntoSelf.

  7. verifyAttestationDoc signature change — removed the roots parameter, reads from the package-level attestationRoots var (nil = AWS Nitro root in production, test CA in tests). Fine for single-process runtime use. Tests use t.Cleanup to restore.


Test coverage — excellent

  • verifyLeafCertPin: match, mismatch, no cert, empty hash, all-zero hash
  • PinnedHTTPClient: real TLS handshake (httptest.NewTLSServer) with matching/mismatching fingerprints
  • setCertFingerprint: CA-only chain errors, leaf sets fingerprint
  • verifyKeyPolicyPosture: 25+ table-driven cases covering un-gated decrypt, wildcards, locked/unlocked postures, mixed principals
  • State origin: determinism, sensitivity, missing ciphertext errors
  • Receipt verification: genuine, forged, wrong PCR0/purpose/state_root
  • Migration transition receipt: genuine, wrong predecessor, wrong PCR31, rollback-onto-self
  • Startup classification: genesis, resume, migration, 7 failure modes
  • nonOverridableEnv: overlay can't flip ENCLAVE_DEPLOYMENT
  • Attestation verification: signed valid, forged rejected, untrusted signer, wrong PCR0, expired cert valid at timestamp
  • Integration test extended with kms_key_locked check

Items to address (non-blocking)

1. Downstream prefix:deployment: breakage (3 repos)
The config loader defaults Deployment to "dev" when the field is empty. Old enclave.yaml files with prefix: prod will silently ignore the value and use "dev" — a silent misconfiguration. Found in:

  • ee2e-kv/enclave/enclave.yaml
  • ee2e-kv/infra/enclave.yaml
  • introspector-review/enclave/enclave.yaml

Consider adding a backward-compat shim in loadConfigAt():

if cfg.Deployment == "" && cfg.Prefix != "" {
    cfg.Deployment = cfg.Prefix
    slog.Warn("enclave.yaml: 'prefix' is deprecated, rename to 'deployment'")
}

2. InsecureTLS default semantic change
The old default for client.Options.InsecureTLS was effectively true (skip TLS verification). The new default is nil (pin to attested TLS hash). This is the correct direction, but any consumer calling client.New() without explicitly setting InsecureTLS will get different behavior. No external Go consumers were found in the monorepo, but this should be called out in release notes.

3. attestationRoots global var thread safety (runtime/attestation.go)
The package-level var attestationRoots *x509.CertPool is written by tests via useAttestationRoots() and read by verifyAttestationDoc(). Tests use t.Cleanup to restore, but parallel test execution could race. Consider adding t.Parallel() guards or documenting that attestation tests must not run in parallel. Non-blocking since the runtime is single-process and tests currently pass.


Cross-repo impact

  • No external Go consumers of client.New(), client.Options, SetAttestationKeyHash, or MockCertFp found across all repos in /root/arkana/repos/.
  • PR #132 (verify_attestation) and PR #133 (enclave_restart) overlap on runtime/runtime.go, runtime/attestation.go, and cli/curl.go — expect merge conflicts.
  • No open PRs on downstream consumer repos.

Not protocol-critical

This PR does not touch VTXOs, transaction signing, forfeit paths, round lifecycle, or exit paths. It is enclave framework security — important, but not protocol-level. No protocol-critical human review flag needed.

🤖 Reviewed by Arkana

@aruokhai aruokhai requested a review from chris-ricketts June 10, 2026 16:38
The attested tlsKeyHash was sampled once, right after the first ACME cert
was issued, then frozen for the life of the enclave. autocert silently
renews Let's Encrypt leaves (~30 days before the 90-day expiry) and
rewrites the cache, so after a renewal the live cert no longer matched the
fingerprint in the attestation document — pinning clients would fail the
TLS handshake with a fingerprint mismatch.

Replace the one-shot goroutine in configureACME with watchACMECert, which
polls the cert cache (fast until the first bind, hourly thereafter) and
re-binds e.hashes.tlsKeyHash whenever autocert rotates the leaf. The
watcher now also exits cleanly on Stop() instead of leaking.

Make AttestationHashes thread-safe with a sync.RWMutex and SetTLSKeyHash/
SetSigningKeyHash setters, since tlsKeyHash is now written concurrently
with the per-request reads in Serialize(). This also closes a pre-existing
race on signingKeyHash, which the /enclave/hash handler could write while
attestation requests read it.

Extract the PEM-chain walk into a pure leafFingerprint helper shared by
the self-signed and ACME paths, and add TestRebindCertFingerprint covering
first issuance, no-op polls, and renewal re-binding.
@aruokhai

Copy link
Copy Markdown
Collaborator Author

closes #129

Comment thread cli/verify.go

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than duplicate the sensitive attestation verification and TLS pinning logic in both this file and the client package, we should remove the helpers in this file and delegate to the client package.

e.g.:

c, err := client.New(baseURL, client.Options{
	ExpectedPCR0:   expectedPCR0,
	StrictTLS:      strictTLS,
	SkipKeyBinding: !doVerifyKey,
})
if err != nil {
	return err
}

// runAllChecks is reduced to:
runCheck := verifyPCR0Chain(c)

The curl command already does this.

Comment thread client/client.go Outdated
// InsecureTLS skips TLS certificate verification. Default: true.
// Nitriding uses self-signed certificates, so this is typically needed.
// InsecureTLS, when explicitly true, disables TLS cert pinning (raw,
// unverified TLS). Default pins to the attested tlsKeyHash (#129).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Downstream consumers of the client package don't really need to know the issue number (#129) or see it in the doc comments.

Similar with Set by `enclave curl` (#129) for SkipKeyBinding

Comment thread cli/verify.go Outdated
Comment on lines 90 to 104

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the actual waitSec is always a multiple of the hardcoded interval 5s

e.g.: waitSec = 6 and it doesn't connet/verify, the error will be: verification failed after 6s: ...

but it really waits for 10s

Comment thread runtime/runtime.go Outdated
Comment on lines +939 to +944
e.tlsGetCert = mgr.GetCertificate

// Block on the cert appearing in the cache before computing its
// fingerprint. autocert populates the cache asynchronously after the
// first TLS handshake.
go func() {
var raw []byte
for {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
got, err := cache.Get(ctx, e.cfg.FQDN)
cancel()
if err == nil {
raw = got
break
}
time.Sleep(5 * time.Second)
// autocert rewrites the cache on every renewal, so the attested fingerprint
// must track the cache rather than sample it once.
go e.watchACMECert(cache)
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of watching the cache, we could wrap mgr.GetCertificates which writes to the cache:

e.tlsGetCert = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
	cert, err := mgr.GetCertificate(hello)
	if err == nil && cert != nil && len(cert.Certificate) > 0 {
		// update hash if leaf cert changed
	}
	return cert, err
}

https://cs.opensource.google/go/x/crypto/+/refs/tags/v0.48.0:acme/autocert/autocert.go;l=250

aruokhai and others added 6 commits June 21, 2026 17:04
…r 0)

The S3-backed K/V store sealed every object with nil AES-GCM AAD and a
bare nonce||ct blob, so a host that can write S3 could relabel/swap
ciphertext between keys or replay it across deployments and it still
decrypted cleanly.

Bind each object's AAD to "deployment/app/data/<key>" and frame a
versioned envelope (0x01 || nonce || ct+tag). A relabeled or
cross-deployment blob now fails the GCM tag check on Load. The version
byte lets Load reject legacy unversioned objects and leaves room for a
future Tier 1 format.

Extract the seal/open into pure helpers (sealStorage / openStorage /
storageAAD) as a single, S3-free chokepoint, and add the first unit
coverage for storage crypto: round-trip, relabel / cross-deployment /
cross-app rejection, tamper, short-blob, legacy-format, and wrong-DEK.

Dynamic secrets and the ACME cert cache inherit the binding for free
since they flow through Store/Load.
…ect-Lock rollback anchor

Replace the S3 `/v1/storage` HTTP API and dynamic secrets with a confidential,
rollback-resistant key/value store: a RESP (Redis-protocol) front-end over
DynamoDB, with values AES-256-GCM-sealed under the in-enclave DEK and AAD-bound
to {deployment, app, key, version, chunk}.

Rollback resistance (issue #134): every committed write is recorded as a
compliance-locked, DEK-sealed S3 object (anchor.go). A boot gate fails closed if
the live store is already rolled back, and a lazy per-read version-floor check
sets a halt flag (/health → 503, RESP refused) on regression. ENCLAVE_ANCHOR_WINDOW
is non-overridable so the operator can't wait out the Object Lock.

Redis surface (redcon, auth = runtime token, attestation-bound TLS):
- strings/keys, hashes, lists, sets, sorted sets, streams (sealed-CBOR blob
  per collection)
- incremental SCAN (cursor + MATCH/COUNT/TYPE), INFO, CONFIG
- MULTI/EXEC/DISCARD/WATCH/UNWATCH (optimistic version-WATCH)
- SUBSCRIBE/PSUBSCRIBE/PUBLISH, HELLO (+AUTH) handshake

Infra: tofu provisions the KV table, Object-Lock anchor bucket, IAM, and SSM
params; the RESP listener stays enclave-internal. Storage is demoted to backing
only the ACME cert cache; its per-op metrics are dropped.

Integration: a real go-redis client drives every data type, transactions, SCAN,
and pub/sub end to end across a v1→v2 migration on QEMU + LocalStack.
…ocused files

Replace the monolithic Migrator with methods on *Runtime and introduce an
Nsm session type that owns a single NSM session for batched PCR ops, so
PCR0 is read once in Init and threaded into KMS instead of each subsystem
opening its own session. Deduplicate the SSM read helpers into stateless
readSSMParam/readSSMParamOptional and drop the redundant *Runtime wrappers.

Carve the HTTP plumbing out of runtime.go into cohesive files:
  - middleware.go: signing/logging middleware, gatedMux, init-gating helpers,
    isGRPCRequest (restored to package scope to fix the broken middleware_test)
  - transport.go: protocol-switch reverse-proxy transport + CORS wrapper
  - nsm.go: Nsm session type and PCR operations
  - acme.go: merge of the former acme_cache.go + ACME directory client

Bind the ACME-served leaf via SetTLSKeyHashIfChanged so the attestation
re-binds (and logs) only on cert rotation rather than on every handshake.

Slim cli/verify.go by delegating attestation verification, TLS pinning, and
key binding to the client package, leaving only the PCR0 upgrade-chain check.

Move verifyPCR31Commitment to attestation.go and reorganize tests
(attestation_test.go, acme_test.go, runtime_test.go) to follow the new layout;
coverage is preserved across the renamed files.
@aruokhai aruokhai changed the base branch from enclave_restart to master July 13, 2026 12:56
@aruokhai aruokhai merged commit 6b5add7 into master Jul 13, 2026
4 checks passed
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.

2 participants