External parties (bridge contract operator, auditors, downstream services) can prove that the EVM address signing bridge transactions was produced by code running inside an AWS Nitro Enclave with a specific measurement (PCR0/1/2), without trusting the parent host process.
After successful verification, the verifier knows:
"AWS Nitro hardware (which I trust like a TLS root CA) certifies that, at time T (within nonce-freshness), an enclave running code with PCR0=X, PCR1=Y, PCR2=Z produced public key K, and the key bundle B plus the enclave's committed security policy P commit to user_data."
The security policy P describes the enclave's committed posture —
plain-BTC enablement, chain/contract/asset pins, attestation mode, gas
rules and selected data sources — resolved once at boot. Committing it into user_data
lets a verifier check the committed policy as one attested value instead of
inferring it from build flags or configuration guesses.
The chain of trust is:
AWS Nitro Root CA (public, hardcoded)
│ signs
AWS region intermediate(s)
│ signs
This EC2 instance's NSM signing cert
│ signs (P-384 ECDSA, COSE_Sign1)
Attestation document { pcrs, public_key, user_data, nonce, timestamp, ... }
verifier parent gRPC enclave (Nitro)
│ │ │
│ 1. nonce ← rand(32) │ │
│ 2. AttestedPublicKey(nonce) ────────────▶│ │
│ │ 3. GetAttestedPublicKey(nonce) ▶│
│ │ │ 4. NSM produces COSE_Sign1 doc
│ │ │ binding {nonce,
│ │ │ public_key=evm_uncompressed_pub,
│ │ │ user_data=sha256(bundle || policy)}
│ │ │ to PCR0/1/2
│ │ ◀── (public_keys, doc) ─────────│
│ ◀── AttestedPublicKeyResponse ───────────│ │
│ │
│ 5. Verify chain → AWS root, COSE sig, validity, PCRs, nonce equal,
│ public_key == evm_uncompressed_pub,
│ user_data == sha256(bundle || expected_policy). │
The NSM attestation document carries three caller-supplied fields. The enclave populates them as:
| NSM field | Bound value |
|---|---|
public_key |
evm_uncompressed_pub — the bridge's primary signing key (64 bytes, X||Y) |
user_data |
sha256(canonical_bundle || policy_commitment) — 32-byte commitment over the key bundle and the resolved security policy |
nonce |
the 32-byte nonce supplied by the verifier |
A lightweight verifier can stop at public_key (e.g. an EVM contract that
only cares about the signing address). A thorough verifier rebuilds the
canonical bundle and the expected security policy and checks user_data to
confirm the BTC keys, xpubs, and fingerprint were not swapped by the parent host
process and that the enclave's posture matches what was expected.
Length-prefixed (u32 big-endian) concatenation of every field of
PublicKeysResponse, in proto field order. Strings encoded as UTF-8 bytes.
chain_id is encoded as 8-byte big-endian (length prefix is the constant 8).
canonical_bundle =
u32_be(len(evm_address)) || evm_address
u32_be(len(btc_compressed_pub)) || btc_compressed_pub
u32_be(len(btc_xpub)) || btc_xpub_utf8
u32_be(len(master_fingerprint)) || master_fingerprint
u32_be(len(account_xpub_vanilla)) || account_xpub_vanilla_utf8
u32_be(len(account_xpub_colored)) || account_xpub_colored_utf8
u32_be(len(evm_uncompressed_pub)) || evm_uncompressed_pub
u32_be(8) || chain_id_be8
u32_be(len(bridge_contract)) || bridge_contract // 20 bytes (zeros = unset)
u32_be(len(rgb_asset_id)) || rgb_asset_id_utf8
u32_be(len(evm_gas_tx_uncompressed_pub)) || evm_gas_tx_uncompressed_pub // 64 bytes
u32_be(len(evm_gas_tx_address)) || evm_gas_tx_address // 20 bytes
u32_be(len(ccd_ed25519_pub)) || ccd_ed25519_pub // 32 bytes
Thirteen fields. chain_id, bridge_contract and rgb_asset_id are bridge
config pinned at enclave boot from env (EVM_CHAIN_ID,
EVM_PROXY_CONTRACT_ADDRESS, RGB_ASSET_ID). They commit the enclave to a
specific chain / contract / asset triple — a misconfigured or
maliciously-redirected enclave is observable through this commitment. (The
attestation-bundle/proto field keeps the legacy name bridge_contract; its
value is the MultisigProxy from EVM_PROXY_CONTRACT_ADDRESS.)
Production deployments MUST set all three; the commitment for a dev /
mock build with no env is chain_id=0, bridge_contract=20 zero bytes,
rgb_asset_id="". The gas-tx key and the Concordium key are derived in every
build, so the bundle has the same shape regardless of features.
The CLI reconstructs policy using the chain/contract/asset pins from the response. It authenticates these values but does not compare them to independent expected pins. Callers must compare them with their intended deployment.
The verifier MUST use the same field set, the same order, and the same
length-prefix encoding. The reference encoder is canonical_pubkey_bundle
in enclave/src/server.rs and the reference
decoder/checker is canonical_bundle in
parent/src/attest_verify.rs.
The canonical bundle above is followed by the enclave's resolved security
policy, and user_data = sha256(canonical_bundle || policy_commitment). The
policy is the single source of truth for the enclave's posture — resolved once
at boot in enclave/src/policy.rs and serialized by
attestation-verify/src/policy.rs, which
both the enclave and every verifier share so the bytes are identical.
policy_commitment =
u8(POLICY_COMMITMENT_V2 = 2) // version tag
// Production (release, fully-pinned bridge signer):
u8(0x01) // production discriminant
u8(allow_vanilla_psbt) // plain-BTC path enabled?
u8(attestation_mode) // 1 = real NSM (0 = mock)
u8(evm_source) // 0 disabled | 1 raw-rpc | 2 Helios-verified
u8(btc_source) // 1 = SPV-verified
chain_id_be8 || bridge_contract(20)
u32_be(len(rgb_asset_id)) || rgb_asset_id_utf8
u8(checkpoint_present) // 0 absent; 1 followed by 32-byte beacon root
// Gas-tx (SignRawDigest) rule:
gas_tx_allowed_to(20) // all-zero = gas path unpinned
gas_tx_max_gas_limit_be8 // gasLimit ceiling (0 = unset)
gas_tx_max_fee_per_gas_be16 // per-gas fee ceiling, wei (0 = unset)
gas_tx_max_value_wei_be16 // native-value ceiling, wei (0 = unset)
u32_be(len(selectors)) || selector(4)... // sorted + deduped 4-byte selectors
// Development (debug/test/dev-feature/non-bridge/unpinned build):
u8(0x00) // development discriminant
The tuple omits the deposit emitter, EVM confirmation depth, Bitcoin network, concrete sats budgets, resolver URLs and strict Helios checkpoint-age setting. Image-baked values remain measured in the EIF.
A production enclave commits the production tuple; a dev/mock enclave
commits just [version, 0x00]. Because the posture flags (allow_vanilla_psbt,
evm_source, …) and the gas-tx rule are not on the wire, a verifier reconstructs
the expected policy and requires the commitment to match — so an enclave that
shipped with a downgraded posture (vanilla signing on, a different EVM
source, an unpinned or wrong gas-tx rule, a dev build) fails
verification rather than being silently trusted.
The gas-tx rule is the SignRawDigest allowlist: the pinned
destination, the gasLimit/fee ceilings that bound fee-griefing, the
native-value ceiling that bounds the payable lzFundsOutCall carve-out, and the
4-byte calldata selectors the gas EOA may invoke. Committing it makes the
enclave's gas-signing policy externally verifiable instead of a self-protection
pin the operator has to trust; attest-verify declares the expected rule via
--expect-gas-tx-to / --expect-gas-max-gas-limit / --expect-gas-max-fee-per-gas
/ --expect-gas-max-value-wei / --expect-gas-selectors.
An unset GAS_TX_MAX_VALUE_WEI commits as 0, which is exactly the posture it
enforces (no non-zero value is signable) — so "unpinned" is itself attested, the
same way an unset destination commits as all-zero. None and Some(0) therefore
produce identical bytes; one enforced rule cannot yield two attestations.
PCRs are not self-attested: the verifier needs to know them out of band. PCR0 = enclave image hash, PCR1 = kernel + boot, PCR2 = app. Changing one byte of the enclave binary changes PCR0 deterministically.
Sourcing options, in increasing order of rigor:
- Release artifact / Git tag. Print PCRs from
nitro-cli build-enclaveinto the release notes. Operators paste into--pcr0/1/2. - Config file loaded by the verifier. Same trust as #1, fewer typos.
- On-chain registry. Bridge contract stores accepted PCRs; governance/multisig updates them. Verifiers pull from chain. Most rigorous, hardest to upgrade.
This repo currently relies on (1).
Given (public_keys_bundle, attestation_doc, nonce_sent, expected_pcrs):
- Parse
attestation_docasCOSE_Sign1(CBOR array of length 4). - Parse the inner CBOR payload as
AttestationDocument. - Verify the certificate chain in
cabundle:cabundle[0]must equal the AWS Nitro root CA bytewise (DER).- For each
i,cabundle[i]must signcabundle[i+1](DER ECDSA). cabundle[last]must signsigning_cert(the cert in the doc).- Every cert must be inside its validity window.
- Verify the COSE signature: P-384 ECDSA over
Sig_structure1 = ["Signature1", protected, h"", payload]. Per RFC 8152 §8.1 the COSE signature is rawr||s(96 bytes for P-384), not DER. - PCR check:
doc.pcrs[0/1/2]must each equalexpected_pcrs.{pcr0,pcr1,pcr2}bytewise. - Nonce check:
doc.nonce == nonce_sent. - Pubkey check:
doc.public_key == public_keys_bundle.evm_uncompressed_pub. - Commitment check: build the expected policy (from the expected posture +
the wire pins) and confirm
doc.user_data == sha256(canonical_bundle(public_keys_bundle) || expected_policy).
If all eight checks pass, the bridge's EVM address (keccak256(evm_uncompressed_pub)[12..])
is bound to the running TEE measurement and the enclave's attested posture
equals the expected production policy.
The attest-verify CLI in this repo runs the full recipe.
# Production verification (against a real Nitro enclave). By default it expects a
# production policy with plain-BTC signing DISABLED and the raw-RPC EVM data
# source (`--expect-evm-source raw`, what the shipped image uses).
attest-verify \
--endpoint http://parent.example:50051 \
--pcr0 <96-hex-chars> \
--pcr1 <96-hex-chars> \
--pcr2 <96-hex-chars>
# Gas signing: also supply the image's exact expected rule when configured:
# --expect-gas-tx-to <hex20> --expect-gas-max-gas-limit <units>
# --expect-gas-max-fee-per-gas <wei> --expect-gas-max-value-wei <wei>
# --expect-gas-selectors <comma-separated-hex4>
# Omitted flags expect an unpinned gas rule, not values discovered from the enclave.
# Expect the plain-BTC path enabled:
attest-verify --endpoint http://parent.example:50051 \
--pcr0 <..> --pcr1 <..> --pcr2 <..> \
--expect-vanilla-psbt
# Optional Helios build (not enabled in the supplied Dockerfiles):
attest-verify --endpoint http://parent.example:50051 \
--pcr0 <..> --pcr1 <..> --pcr2 <..> \
--expect-evm-source helios --expect-helios-checkpoint <hex32>
# Dev / CI verification (against an enclave built with --features mock-attestation).
# --mock implies the expected policy is Development.
attest-verify --endpoint http://127.0.0.1:50051 --mockExit codes:
| Code | Meaning |
|---|---|
| 0 | All eight checks passed |
| 1 | Verification failed, or the endpoint could not be reached (stderr explains why) |
| 2 | Command-line usage error |
Trusted: AWS Nitro root CA private key (off-machine), the running enclave image (PCR-pinned), the verifier's own machine.
NOT trusted: the parent host process, the network between parent and verifier, any TLS-terminating proxy, any operator with shell on the EC2 instance. None of them can forge an attestation document because none holds the AWS Nitro per-instance signing key.
Defended:
- Replay — the verifier-supplied nonce is signed into the doc and checked for equality on response. An old doc is rejected.
- Pubkey swap by parent —
public_keyis inside the signed payload. - BTC key / xpub swap —
user_datacommits to the full bundle. A parent cannot change one field ofPublicKeysResponsewithout breaking the commitment match. - Posture downgrade —
user_dataalso commits to the resolved security policy (signing modes, pins, attestation mode, data sources). An enclave that shipped with a weaker posture than expected — plain-BTC signing enabled, a different EVM source, or a dev build — fails the commitment match against the verifier's expected policy. - Fork to a different enclave image — PCR mismatch on verify.
- Stale code (vulnerable image) — operator publishes accepted PCRs; outdated images won't match.
NOT defended (out of scope for attestation):
- AWS hardware key compromise (same trust assumption as TLS roots).
- Bugs in the enclave code after measurement (PCRs only attest the binary; runtime correctness is a separate problem solved by code review, fuzzing, audits).
- Enclave-side handler:
enclave/src/server.rs(handle_get_attested_public_key). - Parent gRPC handler:
parent/src/grpc_server.rs(attested_public_key). - Verifier crate:
attestation-verify/src/lib.rs. - Verifier library (
verify_attested_pubkey,ExpectedPolicy):parent/src/attest_verify.rs. - CLI binary:
parent/src/bin/attest_verify.rs. - Security policy: resolved in
enclave/src/policy.rs; shared canonical encoding inattestation-verify/src/policy.rs. - Wire definitions:
- Enclave wire:
enclave-proto/proto/enclave.proto(GetAttestedPublicKeyRequest/Response). - Parent gRPC:
proto/enclave/parent.protoin the upstreamfederated-signer-protorepo (ParentService.AttestedPublicKey).
- Enclave wire:
- Tests:
- Enclave handler:
enclave/tests/test_attested_pubkey.rs. - End-to-end gRPC:
parent/tests/test_grpc_bridge.rs(grpc_attested_public_key_*).
- Enclave handler: