Skip to main content

enclavia_protocol/
attestation.rs

1//! Nitro NSM attestation verification.
2//!
3//! Two entry points share the same parse-and-verify core:
4//!
5//! - [`verify_against`] — "is this document from the enclave I expected?"
6//!   The SDK's call path: a client knows what PCRs the target enclave is
7//!   supposed to have and wants the document to confirm it.
8//!
9//! - [`verify_and_extract`] — "what enclave produced this document?" The
10//!   synchronizer's call path: it does not pre-commit to a specific
11//!   identity; the document's verified PCRs *are* the identity, and the
12//!   caller hashes them into its own key. The doc must also carry the
13//!   enclave's raw 32-byte Ed25519 control pubkey in `user_data` — the
14//!   synchronizer registers it alongside the key and uses it to verify
15//!   `Transition` signatures later.
16//!
17//! Both check that the doc's nonce equals `base64(handshake_hash)`,
18//! binding the document to the live Noise session. In `debug_mode` the
19//! COSE_Sign1 certificate chain is skipped (the in-enclave NSM
20//! self-signs when run under QEMU) — production validates the full
21//! chain.
22
23use attestation_doc_validation::{
24    PCRProvider, attestation_doc::decode_attestation_document,
25    attestation_doc::get_pcrs as att_get_pcrs, validate_and_parse_attestation_doc,
26    validate_expected_nonce, validate_expected_pcrs,
27};
28use aws_nitro_enclaves_nsm_api::api::AttestationDoc;
29use base64::Engine;
30use sha2::{Digest, Sha256};
31
32/// PCR (Platform Configuration Register) measurements that identify a
33/// specific enclave image and configuration:
34///
35/// - `pcr0` — Enclave Image File (EIF) measurement.
36/// - `pcr1` — Enclave OS measurement.
37/// - `pcr2` — Application configuration measurement.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct Pcrs {
40    /// EIF measurement.
41    pub pcr0: Vec<u8>,
42    /// Enclave OS measurement.
43    pub pcr1: Vec<u8>,
44    /// Application configuration measurement.
45    pub pcr2: Vec<u8>,
46}
47
48impl Pcrs {
49    /// Build [`Pcrs`] from the three hex-encoded measurements, exactly as
50    /// printed by `enclavia enclave status` / `enclavia reproduce` and
51    /// shown on the dashboard, so they can be copy/pasted verbatim.
52    /// Accepts upper- or lower-case hex and surrounding whitespace. Each
53    /// value must decode to 32, 48, or 64 bytes (real Nitro PCRs are
54    /// 48-byte SHA-384, 96 hex characters).
55    ///
56    /// ```
57    /// let pcrs = enclavia_protocol::attestation::Pcrs::from_hex(
58    ///     &"ab".repeat(48),
59    ///     &"cd".repeat(48),
60    ///     &"ef".repeat(48),
61    /// )
62    /// .unwrap();
63    /// assert_eq!(pcrs.pcr0.len(), 48);
64    /// ```
65    pub fn from_hex(pcr0: &str, pcr1: &str, pcr2: &str) -> Result<Self, AttestationError> {
66        fn decode(idx: usize, s: &str) -> Result<Vec<u8>, AttestationError> {
67            let bytes =
68                hex::decode(s.trim()).map_err(|_| AttestationError::InvalidPcrHex(idx))?;
69            if !matches!(bytes.len(), 32 | 48 | 64) {
70                return Err(AttestationError::InvalidPcrLength { idx, len: bytes.len() });
71            }
72            Ok(bytes)
73        }
74        Ok(Pcrs {
75            pcr0: decode(0, pcr0)?,
76            pcr1: decode(1, pcr1)?,
77            pcr2: decode(2, pcr2)?,
78        })
79    }
80
81    /// SHA-256 over `PCR0 || PCR1 || PCR2`. The synchronizer uses this
82    /// 32-byte digest as the per-enclave session key.
83    pub fn digest(&self) -> [u8; 32] {
84        let mut hasher = Sha256::new();
85        hasher.update(&self.pcr0);
86        hasher.update(&self.pcr1);
87        hasher.update(&self.pcr2);
88        hasher.finalize().into()
89    }
90}
91
92/// Errors from attestation verification.
93#[derive(Debug, thiserror::Error)]
94pub enum AttestationError {
95    /// Parse/structure/signature/PCR/nonce validation failed in the
96    /// upstream `attestation-doc-validation` crate. Carries the original
97    /// error rendered to a string — the upstream type is non-exhaustive
98    /// and not worth re-exporting.
99    #[error("attestation document validation failed: {0}")]
100    Validation(String),
101    /// A PCR value coming out of the validated document hex-decoded to
102    /// something other than 32/48/64 bytes, which would break PcrKey
103    /// derivation. Should be unreachable for real Nitro docs.
104    #[error("attestation document PCR {idx} has unexpected length {len}")]
105    InvalidPcrLength {
106        /// The PCR index (0, 1, or 2).
107        idx: usize,
108        /// The decoded length in bytes.
109        len: usize,
110    },
111    /// A PCR slot was not hex-encoded. Should be unreachable: the
112    /// upstream crate is the one that hex-encodes them on the way out.
113    #[error("attestation document PCR {0} is not valid hex")]
114    InvalidPcrHex(usize),
115    /// The doc's `user_data` field is missing or not a 65-byte
116    /// uncompressed SEC1 ECDSA P-256 verifying key. Required by
117    /// [`verify_and_extract`] — the synchronizer needs the control
118    /// pubkey to verify `Transition` signatures later in the session.
119    #[error(
120        "attestation document user_data is missing or not a 65-byte uncompressed SEC1 P-256 pubkey"
121    )]
122    InvalidControlPubkey,
123    /// The doc's `user_data` field is missing or not the 32-byte
124    /// SHA-256 hash of the chain link's `payload`. Required by
125    /// [`verify_chain_attestation`] — every chain link binds its
126    /// `attestation.user_data` to `sha256(payload)`, so any mismatch
127    /// means either the payload or the attestation has been swapped.
128    #[error("attestation document user_data does not match sha256(payload)")]
129    PayloadBindingMismatch,
130    /// The doc's `user_data` field is missing or not exactly 32 bytes
131    /// where a control nonce was expected. Returned by
132    /// [`verify_control_nonce_attestation`]: the in-enclave server's
133    /// `RequestAttestation` reply always embeds the current 32-byte
134    /// control nonce as `user_data`, so any other shape means the
135    /// document was produced for a different purpose (or tampered with).
136    #[error("attestation document user_data is not a 32-byte control nonce")]
137    InvalidControlNonce,
138    /// The document verified (structure, signature, nonce binding) but
139    /// its PCR0/1/2 equal NONE of the caller's expected triples.
140    /// Returned by [`verify_and_extract_pcrs`]: the presenting enclave
141    /// is genuine but is not the identity the caller trusts
142    /// (server authentication).
143    #[error("attestation document PCRs match none of the expected values")]
144    PcrsNotExpected,
145}
146
147/// Length of an ECDSA P-256 verifying key in uncompressed SEC1 form
148/// (`0x04 || X(32) || Y(32)`). Locked at the protocol layer because
149/// every caller — synchronizer node, in-enclave server, attestation
150/// emitter — needs to agree on the shape carried in
151/// `AttestationDoc::user_data`.
152pub const CONTROL_PUBKEY_LEN: usize = 65;
153
154/// Domain-separation string the canonical non-upgradable control key is
155/// derived from. Public and fixed: it is the audit anchor that lets
156/// anyone reproduce [`NON_UPGRADABLE_CONTROL_KEY`] and confirm the
157/// construction.
158pub const NON_UPGRADABLE_CONTROL_KEY_DST: &[u8] =
159    b"enclavia/synchronizer/non-upgradable-control-key/v1";
160
161/// The canonical "provably un-signable" control key for enclaves that
162/// have no upgrade path at all (non-upgradable enclaves).
163///
164/// ## What it is for
165///
166/// The synchronizer freezes a key's control pubkey at first pin and uses
167/// it for exactly one thing: verifying the ECDSA signature on a future
168/// `Transition` (the PCR re-key that an upgrade performs). An enclave
169/// with no upgrade chain has no control key, so the storage-pinning
170/// client registers with THIS value instead. Because no private key for
171/// it is known to anyone, no `Transition` signature can ever verify, so
172/// the pinned storage history is permanently bound to that one image,
173/// which is exactly the correct semantic for a non-upgradable enclave.
174/// It only disables `Transition`; `Pin`/`Get` are gated by the attested
175/// PCR key, not by this pubkey, so storage pinning works normally.
176///
177/// ## Why it is provably un-signable (nothing-up-my-sleeve)
178///
179/// The point's x-coordinate is a SHA-256 hash output over the public
180/// [`NON_UPGRADABLE_CONTROL_KEY_DST`] (try-and-increment to the first
181/// valid curve point). Recovering a private key would mean solving the
182/// discrete log for a point whose x nobody chose, so by construction no
183/// party knows (or could have arranged to know) the scalar. This is
184/// strictly safer than minting a throwaway real key and trusting that
185/// its private half was destroyed: here no usable private half ever
186/// existed.
187///
188/// Baked as a compile-time constant (uncompressed SEC1, `0x04 || X || Y`)
189/// so it costs nothing at runtime and is usable in const contexts. The
190/// bytes are the output of try-and-increment over
191/// [`NON_UPGRADABLE_CONTROL_KEY_DST`] (hash the DST with a 1-byte
192/// counter to a candidate x-coordinate, take the first that decompresses
193/// to a valid P-256 point). `derive_non_upgradable_control_key` in the
194/// tests re-runs that derivation and asserts it equals this constant, so
195/// the literal can never silently drift from its construction.
196pub const NON_UPGRADABLE_CONTROL_KEY: [u8; CONTROL_PUBKEY_LEN] = [
197    0x04, 0x22, 0x18, 0xad, 0x29, 0x17, 0x7d, 0x9a, 0x5c, 0xb3, 0x52, 0xc4, 0x78, 0x64, 0x06, 0xfa,
198    0x76, 0x57, 0xaa, 0xc1, 0x6c, 0xe4, 0xb2, 0xe8, 0x19, 0xcd, 0xbd, 0x7f, 0x6e, 0xbd, 0xfa, 0x5a,
199    0x8e, 0xb1, 0x1a, 0xf7, 0x68, 0x69, 0x3a, 0xd6, 0x5f, 0xc5, 0xb2, 0x21, 0x10, 0x3f, 0x10, 0x8a,
200    0xe9, 0x50, 0x87, 0xb3, 0x1d, 0x68, 0x54, 0xe8, 0x13, 0x51, 0x60, 0x6d, 0xc4, 0xe2, 0xd4, 0xf7,
201    0xda,
202];
203
204/// Verified enclave identity extracted from an NSM attestation document.
205///
206/// Returned by [`verify_and_extract`] when the document validates and the
207/// caller wants both the PCRs (for deriving a session key) and the
208/// enclave's ECDSA P-256 control pubkey (for verifying future
209/// `Transition` signatures from this key).
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct AttestedIdentity {
212    /// PCR0/1/2 from the validated document.
213    pub pcrs: Pcrs,
214    /// 65-byte uncompressed SEC1 ECDSA P-256 verifying key extracted
215    /// from the doc's `user_data` field. The synchronizer registers
216    /// this alongside the [`Pcrs::digest`]-derived key on first
217    /// attestation, and uses it to verify raw r||s signatures on
218    /// subsequent `Transition` RPCs.
219    pub control_pubkey: [u8; CONTROL_PUBKEY_LEN],
220}
221
222/// Verify an attestation document against expected PCRs.
223///
224/// SDK entry point. The caller has pinned the enclave's identity at
225/// configure-time and wants `Ok(())` on a match or an error otherwise.
226///
227/// Checks performed (in order, in both `debug_mode` and production):
228///
229/// 1. Parse + structural validation of the COSE_Sign1 wrapper.
230/// 2. Nonce equals `base64(handshake_hash)`.
231/// 3. PCR0/1/2 in the doc equal the caller-supplied `expected_pcrs`.
232///
233/// Additionally, in production mode (`debug_mode = false`), the AWS
234/// Nitro CA chain is validated and the COSE signature is verified.
235pub fn verify_against(
236    attestation_data: &[u8],
237    handshake_hash: &[u8],
238    expected_pcrs: &Pcrs,
239    debug_mode: bool,
240) -> Result<(), AttestationError> {
241    let pcrs_hex = PcrsHex::from_pcrs(expected_pcrs);
242    let doc = parse_and_validate(attestation_data, debug_mode)?;
243
244    check_nonce(&doc, handshake_hash)?;
245
246    validate_expected_pcrs(&doc, &pcrs_hex)
247        .map_err(|e| AttestationError::Validation(e.to_string()))?;
248
249    Ok(())
250}
251
252/// Verify a control-nonce attestation and return the attested nonce.
253///
254/// Backend control-dispatch entry point (upgrade-chain hardening). Before signing
255/// and sending a control command, the dispatcher requests an attestation
256/// over the control channel; the in-enclave server's reply binds the
257/// live Noise session (doc `nonce` = `base64(handshake_hash)`) and
258/// carries the current 32-byte control nonce in `user_data`. Verifying
259/// the document before dispatch gives the caller two guarantees a bare
260/// `GetControlNonce` round-trip cannot:
261///
262/// 1. The Noise session terminates inside the enclave whose PCRs the
263///    caller expected, with no host in the middle, so the eventual
264///    `ControlResult` is authentic rather than the relay's word.
265/// 2. The nonce embedded in the signed command was minted by that
266///    enclave, not substituted on the way through the host.
267///
268/// Verification is [`verify_against`] (COSE chain in production mode,
269/// session-nonce binding, PCR equality) plus a requirement that
270/// `user_data` is exactly 32 bytes, returned as the attested control
271/// nonce.
272pub fn verify_control_nonce_attestation(
273    attestation_data: &[u8],
274    handshake_hash: &[u8],
275    expected_pcrs: &Pcrs,
276    debug_mode: bool,
277) -> Result<[u8; 32], AttestationError> {
278    let pcrs_hex = PcrsHex::from_pcrs(expected_pcrs);
279    let doc = parse_and_validate(attestation_data, debug_mode)?;
280
281    check_nonce(&doc, handshake_hash)?;
282
283    validate_expected_pcrs(&doc, &pcrs_hex)
284        .map_err(|e| AttestationError::Validation(e.to_string()))?;
285
286    let user_data = doc
287        .user_data
288        .as_ref()
289        .ok_or(AttestationError::InvalidControlNonce)?;
290    user_data
291        .as_slice()
292        .try_into()
293        .map_err(|_| AttestationError::InvalidControlNonce)
294}
295
296/// Verify an attestation document and return the enclave identity it
297/// embeds.
298///
299/// Synchronizer entry point. The caller does not know in advance which
300/// enclave is connecting — the verified document's PCRs *are* the
301/// identity, and the doc's `user_data` carries the enclave's Ed25519
302/// control pubkey. The caller typically passes the returned
303/// [`AttestedIdentity::pcrs`] through [`Pcrs::digest`] to derive a stable
304/// session key, and registers
305/// [`AttestedIdentity::control_pubkey`] for verifying future
306/// `Transition` RPCs from this key.
307///
308/// Verification is identical to [`verify_against`] minus the
309/// `expected_pcrs` equality check (there are no expected PCRs at this
310/// layer — the doc's nonce binding to the handshake hash is what
311/// authenticates the document's origin to the live session), plus a
312/// requirement that `user_data` is exactly [`CONTROL_PUBKEY_LEN`]
313/// bytes — the uncompressed SEC1 ECDSA P-256 verifying key.
314pub fn verify_and_extract(
315    attestation_data: &[u8],
316    handshake_hash: &[u8],
317    debug_mode: bool,
318) -> Result<AttestedIdentity, AttestationError> {
319    let doc = parse_and_validate(attestation_data, debug_mode)?;
320
321    check_nonce(&doc, handshake_hash)?;
322
323    let hex_pcrs = att_get_pcrs(&doc).map_err(|e| AttestationError::Validation(e.to_string()))?;
324
325    let pcrs = Pcrs {
326        pcr0: decode_pcr(&hex_pcrs.pcr_0, 0)?,
327        pcr1: decode_pcr(&hex_pcrs.pcr_1, 1)?,
328        pcr2: decode_pcr(&hex_pcrs.pcr_2, 2)?,
329    };
330
331    let user_data = doc
332        .user_data
333        .as_ref()
334        .ok_or(AttestationError::InvalidControlPubkey)?;
335    let control_pubkey: [u8; CONTROL_PUBKEY_LEN] = user_data
336        .as_slice()
337        .try_into()
338        .map_err(|_| AttestationError::InvalidControlPubkey)?;
339    // SEC1 uncompressed-form prefix must be 0x04. Anything else (0x02 /
340    // 0x03 compressed, or random bytes that happen to fit) is rejected
341    // here so the in-enclave verifier doesn't have to handle the
342    // compressed-form decompression path.
343    if control_pubkey[0] != 0x04 {
344        return Err(AttestationError::InvalidControlPubkey);
345    }
346
347    Ok(AttestedIdentity {
348        pcrs,
349        control_pubkey,
350    })
351}
352
353/// Verify an attestation document's session binding AND that its PCRs
354/// equal one of the caller's `expected` triples, with no `user_data`
355/// requirement. Returns the verified PCRs (which of the expected set
356/// matched).
357///
358/// Server-authentication entry point.
359/// The synchronizer's CUSTOMER client uses this to authenticate the
360/// ORACLE back to itself: the synchronizer sends its own NSM document
361/// bound to the live Noise session and the client validates it here
362/// against the synchronizer measurements it trusts.
363///
364/// Checks performed:
365///
366/// 1. Parse + structural validation of the COSE_Sign1 wrapper; in
367///    production mode (`debug_mode = false`) the AWS Nitro CA chain is
368///    validated and the COSE signature verified, exactly like
369///    [`verify_against`] / [`verify_and_extract`].
370/// 2. Nonce equals `base64(handshake_hash)`: the document is bound to
371///    *this* Noise session, so a document captured from any other
372///    session (including the mesh and other customers' sessions) is
373///    rejected.
374/// 3. The document's PCR0/1/2 equal one of `expected` EXACTLY. An empty
375///    `expected` admits nothing. The comparison is mandatory and lives
376///    here (not at the caller) so no public API exists that verifies a
377///    document without committing to an identity; a bare
378///    verify-and-return-PCRs form would be passable by ANY enclave,
379///    including a reflection of the caller's own document.
380///
381/// Differs from [`verify_against`] in accepting a SET of valid triples
382/// (a deployment may roll between two cluster images), and from
383/// [`verify_and_extract`] in not requiring (or reading) `user_data`:
384/// the server side of the customer protocol carries no control pubkey,
385/// so demanding one would force the server to stuff a meaningless value
386/// into the document.
387pub fn verify_and_extract_pcrs(
388    attestation_data: &[u8],
389    handshake_hash: &[u8],
390    expected: &[Pcrs],
391    debug_mode: bool,
392) -> Result<Pcrs, AttestationError> {
393    let doc = parse_and_validate(attestation_data, debug_mode)?;
394
395    check_nonce(&doc, handshake_hash)?;
396
397    let hex_pcrs = att_get_pcrs(&doc).map_err(|e| AttestationError::Validation(e.to_string()))?;
398
399    let pcrs = Pcrs {
400        pcr0: decode_pcr(&hex_pcrs.pcr_0, 0)?,
401        pcr1: decode_pcr(&hex_pcrs.pcr_1, 1)?,
402        pcr2: decode_pcr(&hex_pcrs.pcr_2, 2)?,
403    };
404    if !expected.iter().any(|e| e == &pcrs) {
405        return Err(AttestationError::PcrsNotExpected);
406    }
407    Ok(pcrs)
408}
409
410/// Extract PCR0/1/2 from an attestation document the caller JUST obtained from
411/// its OWN `/dev/nsm`, WITHOUT verifying the certificate chain or the nonce.
412///
413/// # This is NOT a verification function. Read before using.
414///
415/// Every other entry point in this module (`verify_against`,
416/// `verify_and_extract`, `verify_control_nonce_attestation`,
417/// `verify_chain_attestation`) authenticates a document that came from SOMEONE
418/// ELSE: in production it validates the AWS Nitro CA chain and the COSE
419/// signature, and it binds the document to a live Noise session via the nonce.
420/// This function does NONE of that. It only structurally decodes the COSE_Sign1
421/// envelope and pulls out the PCRs. A document fed to it could be a forgery and
422/// it would happily return whatever PCRs the forgery claims.
423///
424/// That is acceptable for, and ONLY for, one caller: a node deriving its OWN
425/// self-PCR digest from a document it just requested from its OWN local
426/// `/dev/nsm`. The local NSM device is inside the node's trusted computing base
427/// (on real Nitro it is the hardware module measuring this very VM; under
428/// QEMU's nitro-enclave machine it is the emulated module measuring the same),
429/// so there is no cert chain to trust (the node is reading its own hardware
430/// measurements, not authenticating a remote party) and there is no Noise
431/// session to bind to (the node generated the request itself, with an arbitrary
432/// nonce). This replaces a host-supplied PCR allowlist, which the host (the
433/// adversary) could otherwise choose to admit a rogue image into the mesh.
434///
435/// Do NOT use this on a document received over the network, ever: use
436/// [`verify_and_extract`] (peer attestation) or [`verify_against`] (pinned
437/// identity) for that.
438pub fn extract_own_pcrs(attestation_data: &[u8]) -> Result<Pcrs, AttestationError> {
439    // Structural decode only: no cert chain, no signature, no nonce. The
440    // `debug_mode = true` arm of `parse_and_validate` is exactly this
441    // (decode_attestation_document), and it is correct here on BOTH QEMU and
442    // real Nitro because the caller is reading its own local device, not
443    // authenticating a remote party.
444    let doc = parse_and_validate(attestation_data, true)?;
445    let hex_pcrs = att_get_pcrs(&doc).map_err(|e| AttestationError::Validation(e.to_string()))?;
446    Ok(Pcrs {
447        pcr0: decode_pcr(&hex_pcrs.pcr_0, 0)?,
448        pcr1: decode_pcr(&hex_pcrs.pcr_1, 1)?,
449        pcr2: decode_pcr(&hex_pcrs.pcr_2, 2)?,
450    })
451}
452
453/// Verify a chain-link attestation document.
454///
455/// Used by the backend's `POST /enclaves/{id}/chain-links` ingest
456/// path: each chain link (`boot`, `upgrade`, `revocation`) carries a
457/// hardware-signed `attestation` whose `user_data` field commits to the
458/// link's `payload` via `sha256(payload)`. This function performs the
459/// minimum-trust check required at ingest:
460///
461/// 1. Parse + structural validation of the COSE_Sign1 wrapper (same as
462///    [`verify_against`] / [`verify_and_extract`]).
463/// 2. `attestation.user_data == sha256(payload)` — the binding that
464///    makes the chain entry tamper-evident.
465/// 3. PCR0/1/2 in the doc equal `expected_pcrs` (the backend's recorded
466///    PCRs for this enclave, post-build).
467///
468/// In production mode (`debug_mode = false`), the AWS Nitro CA chain is
469/// validated and the COSE signature is verified by the upstream
470/// `attestation-doc-validation` crate, same as the existing entry
471/// points. In `debug_mode`, only structural validity is required —
472/// matching QEMU's emulated NSM device, which signs documents with its
473/// own key instead of the AWS CA (and the `test-utils` doc builders,
474/// which carry placeholder signatures).
475///
476/// The doc's `nonce` field is **not** checked here. The chain-link
477/// attestations are not produced in the context of a Noise session, so
478/// there is no handshake hash to bind against; the binding lives in
479/// `user_data` instead. Any value in `nonce` is accepted.
480pub fn verify_chain_attestation(
481    attestation_data: &[u8],
482    payload: &[u8],
483    expected_pcrs: &Pcrs,
484    debug_mode: bool,
485) -> Result<(), AttestationError> {
486    let pcrs_hex = PcrsHex::from_pcrs(expected_pcrs);
487    let doc = parse_and_validate(attestation_data, debug_mode)?;
488
489    let user_data = doc
490        .user_data
491        .as_ref()
492        .ok_or(AttestationError::PayloadBindingMismatch)?;
493    let expected: [u8; 32] = {
494        let mut hasher = Sha256::new();
495        hasher.update(payload);
496        hasher.finalize().into()
497    };
498    if user_data.as_slice() != expected {
499        return Err(AttestationError::PayloadBindingMismatch);
500    }
501
502    validate_expected_pcrs(&doc, &pcrs_hex)
503        .map_err(|e| AttestationError::Validation(e.to_string()))?;
504
505    Ok(())
506}
507
508fn parse_and_validate(
509    attestation_data: &[u8],
510    debug_mode: bool,
511) -> Result<AttestationDoc, AttestationError> {
512    if debug_mode {
513        let (_, doc) = decode_attestation_document(attestation_data)
514            .map_err(|e| AttestationError::Validation(e.to_string()))?;
515        Ok(doc)
516    } else {
517        validate_and_parse_attestation_doc(attestation_data)
518            .map_err(|e| AttestationError::Validation(e.to_string()))
519    }
520}
521
522fn check_nonce(doc: &AttestationDoc, handshake_hash: &[u8]) -> Result<(), AttestationError> {
523    let nonce_b64 = base64::engine::general_purpose::STANDARD.encode(handshake_hash);
524    validate_expected_nonce(doc, &nonce_b64)
525        .map_err(|e| AttestationError::Validation(e.to_string()))
526}
527
528fn decode_pcr(hex_str: &str, idx: usize) -> Result<Vec<u8>, AttestationError> {
529    let bytes = hex::decode(hex_str).map_err(|_| AttestationError::InvalidPcrHex(idx))?;
530    if ![32usize, 48, 64].contains(&bytes.len()) {
531        return Err(AttestationError::InvalidPcrLength {
532            idx,
533            len: bytes.len(),
534        });
535    }
536    Ok(bytes)
537}
538
539/// Internal hex-encoded view of a [`Pcrs`] for the `PCRProvider` trait.
540/// The upstream crate compares PCRs by string equality on hex
541/// representations, so we encode once at the entry point.
542struct PcrsHex {
543    pcr0: String,
544    pcr1: String,
545    pcr2: String,
546}
547
548impl PcrsHex {
549    fn from_pcrs(pcrs: &Pcrs) -> Self {
550        Self {
551            pcr0: hex::encode(&pcrs.pcr0),
552            pcr1: hex::encode(&pcrs.pcr1),
553            pcr2: hex::encode(&pcrs.pcr2),
554        }
555    }
556}
557
558impl PCRProvider for PcrsHex {
559    fn pcr_0(&self) -> Option<&str> {
560        Some(&self.pcr0)
561    }
562    fn pcr_1(&self) -> Option<&str> {
563        Some(&self.pcr1)
564    }
565    fn pcr_2(&self) -> Option<&str> {
566        Some(&self.pcr2)
567    }
568    fn pcr_8(&self) -> Option<&str> {
569        None
570    }
571}
572
573/// Test-only helpers for constructing attestation documents with known
574/// PCRs and nonces. Behind the `test-utils` feature so downstream test
575/// suites can build doc fixtures without spinning up real Nitro
576/// hardware. Production builds cannot reach this module.
577#[cfg(any(test, feature = "test-utils"))]
578pub mod test_utils {
579    use std::collections::BTreeMap;
580
581    use aws_nitro_enclaves_nsm_api::api::{AttestationDoc, Digest};
582    use ciborium::value::Value as CborValue;
583
584    /// Builder for synthetic attestation documents accepted by
585    /// [`verify_against`](super::verify_against) /
586    /// [`verify_and_extract`](super::verify_and_extract) in debug mode.
587    ///
588    /// In debug mode the COSE signature is not validated, so any
589    /// well-formed COSE_Sign1 envelope around a well-formed
590    /// [`AttestationDoc`] is accepted. PCR0/1/2 are 48-byte SHA-384
591    /// values (matches what real Nitro hardware emits).
592    pub struct FakeAttestation {
593        pub pcr0: Vec<u8>,
594        pub pcr1: Vec<u8>,
595        pub pcr2: Vec<u8>,
596        /// Raw Noise handshake hash. The encoded doc's `nonce` field is
597        /// set to these bytes verbatim — the verifier base64-encodes
598        /// before comparing, so it works out.
599        pub handshake_hash: Vec<u8>,
600        /// 65-byte uncompressed SEC1 ECDSA P-256 verifying key. Encoded
601        /// into the doc's `user_data` field — [`super::verify_and_extract`]
602        /// requires this to be a 65-byte pubkey with the SEC1 prefix
603        /// `0x04`.
604        pub control_pubkey: [u8; super::CONTROL_PUBKEY_LEN],
605    }
606
607    impl FakeAttestation {
608        /// Build a fixture with all three PCRs derived from `seed` and a
609        /// synthetic but structurally-valid SEC1 control pubkey (prefix
610        /// `0x04`, the remaining 64 bytes filled with `seed | 0x80`).
611        /// The synthetic pubkey will NOT decode as a valid P-256 point,
612        /// so tests that only need to exercise the verifier's
613        /// length-and-prefix check can use this directly; tests that
614        /// need a *real* P-256 keypair (to actually sign) should use
615        /// [`Self::with_seed_and_pubkey`] with bytes from a
616        /// `p256::ecdsa::SigningKey`.
617        pub fn with_seed(seed: u8, handshake_hash: Vec<u8>) -> Self {
618            let mut control_pubkey = [seed.wrapping_add(0x80); super::CONTROL_PUBKEY_LEN];
619            control_pubkey[0] = 0x04;
620            Self {
621                pcr0: vec![seed; 48],
622                pcr1: vec![seed.wrapping_add(1); 48],
623                pcr2: vec![seed.wrapping_add(2); 48],
624                handshake_hash,
625                control_pubkey,
626            }
627        }
628
629        /// Like [`Self::with_seed`] but with a caller-supplied control
630        /// pubkey (typically `VerifyingKey::to_encoded_point(false)` from
631        /// a real `p256::ecdsa::SigningKey` the test holds for signing).
632        pub fn with_seed_and_pubkey(
633            seed: u8,
634            handshake_hash: Vec<u8>,
635            control_pubkey: [u8; super::CONTROL_PUBKEY_LEN],
636        ) -> Self {
637            let mut fake = Self::with_seed(seed, handshake_hash);
638            fake.control_pubkey = control_pubkey;
639            fake
640        }
641
642        /// CBOR-encoded COSE_Sign1 bytes ready to pass through the
643        /// `debug_mode` verify path.
644        pub fn encode(&self) -> Vec<u8> {
645            assert_eq!(self.pcr0.len(), 48, "test PCRs must be 48 bytes (SHA-384)");
646            assert_eq!(self.pcr1.len(), 48, "test PCRs must be 48 bytes (SHA-384)");
647            assert_eq!(self.pcr2.len(), 48, "test PCRs must be 48 bytes (SHA-384)");
648
649            let mut pcrs = BTreeMap::new();
650            pcrs.insert(0usize, self.pcr0.clone());
651            pcrs.insert(1usize, self.pcr1.clone());
652            pcrs.insert(2usize, self.pcr2.clone());
653            // The upstream `get_pcrs` is hard-coded to require PCR8
654            // (signing-cert measurement). Synchronizer doesn't use it,
655            // but the doc has to include it to deserialize.
656            pcrs.insert(8usize, vec![0u8; 48]);
657
658            let doc = AttestationDoc::new(
659                "test-module".to_string(),
660                Digest::SHA384,
661                0,
662                pcrs,
663                // certificate / cabundle: not validated in debug mode,
664                // but `validate_attestation_document_structure` does
665                // require each cert byte slice to be 1..=1024 bytes.
666                vec![0u8; 64],
667                vec![vec![0u8; 64]],
668                Some(self.control_pubkey.to_vec()),
669                Some(self.handshake_hash.clone()),
670                None,
671            );
672
673            let mut payload = Vec::new();
674            ciborium::into_writer(&doc, &mut payload).expect("ciborium encode AttestationDoc");
675
676            // COSE_Sign1, untagged: [protected: bstr, unprotected: map, payload: bstr, signature: bstr].
677            // - protected is a *byte string* whose contents are a serialized HeaderMap.
678            //   An empty CBOR map is one byte: 0xa0.
679            let cose = CborValue::Array(vec![
680                CborValue::Bytes(vec![0xa0]),
681                CborValue::Map(Vec::new()),
682                CborValue::Bytes(payload),
683                // Signature: junk. The debug-mode verify path does not
684                // touch it (and even production verify only fails if the
685                // cert chain is wrong, which it always will be for
686                // synthetic docs).
687                CborValue::Bytes(vec![0u8; 96]),
688            ]);
689
690            let mut out = Vec::new();
691            ciborium::into_writer(&cose, &mut out).expect("ciborium encode COSE_Sign1");
692            out
693        }
694    }
695
696    /// Builder for synthetic control-nonce attestation documents
697    /// accepted by
698    /// [`verify_control_nonce_attestation`](super::verify_control_nonce_attestation)
699    /// in debug mode. Mirrors the in-enclave server's
700    /// `RequestAttestation` reply shape: `nonce` carries the Noise
701    /// handshake hash, `user_data` carries the 32-byte control nonce.
702    pub struct FakeControlNonceAttestation {
703        pub pcr0: Vec<u8>,
704        pub pcr1: Vec<u8>,
705        pub pcr2: Vec<u8>,
706        /// Raw Noise handshake hash, encoded verbatim into the doc's
707        /// `nonce` field (the verifier base64-encodes before comparing).
708        pub handshake_hash: Vec<u8>,
709        /// Encoded into the doc's `user_data` field. 32 bytes on the
710        /// happy path; tests exercising the length check can override.
711        pub control_nonce: Vec<u8>,
712    }
713
714    impl FakeControlNonceAttestation {
715        /// Build a fixture with all three PCRs derived from `seed`.
716        pub fn with_seed(seed: u8, handshake_hash: Vec<u8>, control_nonce: [u8; 32]) -> Self {
717            Self {
718                pcr0: vec![seed; 48],
719                pcr1: vec![seed.wrapping_add(1); 48],
720                pcr2: vec![seed.wrapping_add(2); 48],
721                handshake_hash,
722                control_nonce: control_nonce.to_vec(),
723            }
724        }
725
726        /// CBOR-encoded COSE_Sign1 bytes ready to pass through the
727        /// `debug_mode` verify path.
728        pub fn encode(&self) -> Vec<u8> {
729            assert_eq!(self.pcr0.len(), 48, "test PCRs must be 48 bytes (SHA-384)");
730            assert_eq!(self.pcr1.len(), 48, "test PCRs must be 48 bytes (SHA-384)");
731            assert_eq!(self.pcr2.len(), 48, "test PCRs must be 48 bytes (SHA-384)");
732
733            let mut pcrs = BTreeMap::new();
734            pcrs.insert(0usize, self.pcr0.clone());
735            pcrs.insert(1usize, self.pcr1.clone());
736            pcrs.insert(2usize, self.pcr2.clone());
737            pcrs.insert(8usize, vec![0u8; 48]);
738
739            let doc = AttestationDoc::new(
740                "test-module".to_string(),
741                Digest::SHA384,
742                0,
743                pcrs,
744                vec![0u8; 64],
745                vec![vec![0u8; 64]],
746                Some(self.control_nonce.clone()),
747                Some(self.handshake_hash.clone()),
748                None,
749            );
750
751            let mut payload = Vec::new();
752            ciborium::into_writer(&doc, &mut payload).expect("ciborium encode AttestationDoc");
753
754            let cose = CborValue::Array(vec![
755                CborValue::Bytes(vec![0xa0]),
756                CborValue::Map(Vec::new()),
757                CborValue::Bytes(payload),
758                CborValue::Bytes(vec![0u8; 96]),
759            ]);
760
761            let mut out = Vec::new();
762            ciborium::into_writer(&cose, &mut out).expect("ciborium encode COSE_Sign1");
763            out
764        }
765    }
766
767    /// Builder for synthetic chain-link attestation documents accepted
768    /// by [`verify_chain_attestation`](super::verify_chain_attestation)
769    /// in debug mode. Differs from [`FakeAttestation`] in two ways:
770    ///   * `user_data` carries the SHA-256 of a caller-supplied
771    ///     `payload` (not the control pubkey, which the chain ingest
772    ///     path doesn't read).
773    ///   * `nonce` is irrelevant to the chain ingest verifier and is
774    ///     populated with a fixed zero-padded value so the doc still
775    ///     serialises.
776    pub struct FakeChainAttestation {
777        pub pcr0: Vec<u8>,
778        pub pcr1: Vec<u8>,
779        pub pcr2: Vec<u8>,
780        /// 32-byte SHA-256 of the chain link's payload. Set by
781        /// [`Self::for_payload`]; tests that want to exercise a
782        /// `user_data` mismatch can override after construction.
783        pub user_data: Vec<u8>,
784    }
785
786    impl FakeChainAttestation {
787        /// Build a fixture with all three PCRs derived from `seed` and
788        /// `user_data` set to `sha256(payload)`. Drop-in for the chain
789        /// ingest verifier's happy path.
790        pub fn for_payload(seed: u8, payload: &[u8]) -> Self {
791            use sha2::Digest as _;
792            let mut hasher = sha2::Sha256::new();
793            hasher.update(payload);
794            let user_data: Vec<u8> = hasher.finalize().to_vec();
795            Self {
796                pcr0: vec![seed; 48],
797                pcr1: vec![seed.wrapping_add(1); 48],
798                pcr2: vec![seed.wrapping_add(2); 48],
799                user_data,
800            }
801        }
802
803        /// CBOR-encoded COSE_Sign1 bytes ready to pass through the
804        /// `debug_mode` chain-attestation verify path.
805        pub fn encode(&self) -> Vec<u8> {
806            assert_eq!(self.pcr0.len(), 48, "test PCRs must be 48 bytes (SHA-384)");
807            assert_eq!(self.pcr1.len(), 48, "test PCRs must be 48 bytes (SHA-384)");
808            assert_eq!(self.pcr2.len(), 48, "test PCRs must be 48 bytes (SHA-384)");
809
810            let mut pcrs = BTreeMap::new();
811            pcrs.insert(0usize, self.pcr0.clone());
812            pcrs.insert(1usize, self.pcr1.clone());
813            pcrs.insert(2usize, self.pcr2.clone());
814            pcrs.insert(8usize, vec![0u8; 48]);
815
816            let doc = AttestationDoc::new(
817                "test-module".to_string(),
818                Digest::SHA384,
819                0,
820                pcrs,
821                vec![0u8; 64],
822                vec![vec![0u8; 64]],
823                Some(self.user_data.clone()),
824                // Nonce is not consulted by `verify_chain_attestation`,
825                // but the doc has to carry one to serialise. Zero-padded
826                // to a length the structure-validator accepts.
827                Some(vec![0u8; 32]),
828                None,
829            );
830
831            let mut payload = Vec::new();
832            ciborium::into_writer(&doc, &mut payload).expect("ciborium encode AttestationDoc");
833
834            let cose = CborValue::Array(vec![
835                CborValue::Bytes(vec![0xa0]),
836                CborValue::Map(Vec::new()),
837                CborValue::Bytes(payload),
838                CborValue::Bytes(vec![0u8; 96]),
839            ]);
840
841            let mut out = Vec::new();
842            ciborium::into_writer(&cose, &mut out).expect("ciborium encode COSE_Sign1");
843            out
844        }
845    }
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851
852    fn hh() -> Vec<u8> {
853        // 32-byte BLAKE2s-shaped handshake hash for tests.
854        (0u8..32).collect()
855    }
856
857    #[test]
858    fn verify_and_extract_returns_doc_identity_in_debug_mode() {
859        let fake = test_utils::FakeAttestation::with_seed(0x11, hh());
860        let bytes = fake.encode();
861
862        let identity = verify_and_extract(&bytes, &hh(), true).expect("verify");
863        assert_eq!(identity.pcrs.pcr0, fake.pcr0);
864        assert_eq!(identity.pcrs.pcr1, fake.pcr1);
865        assert_eq!(identity.pcrs.pcr2, fake.pcr2);
866        assert_eq!(identity.control_pubkey, fake.control_pubkey);
867    }
868
869    #[test]
870    fn extract_own_pcrs_returns_doc_pcrs_without_nonce_or_chain() {
871        // A document the node "just got from its own /dev/nsm" (here a
872        // FakeAttestation fixture). extract_own_pcrs must return its PCR0/1/2
873        // verbatim with no nonce/cert-chain check, so the node can derive its
874        // own self-PCR digest regardless of the throwaway/self-signed key.
875        let fake = test_utils::FakeAttestation::with_seed(0x5a, hh());
876        let bytes = fake.encode();
877
878        let pcrs = extract_own_pcrs(&bytes).expect("extract own pcrs");
879        assert_eq!(pcrs.pcr0, fake.pcr0);
880        assert_eq!(pcrs.pcr1, fake.pcr1);
881        assert_eq!(pcrs.pcr2, fake.pcr2);
882
883        // The digest matches what verify_and_extract derives for the same doc,
884        // i.e. it is the SAME identity a peer would compute, just without the
885        // verification a peer document requires.
886        let verified = verify_and_extract(&bytes, &hh(), true).expect("verify");
887        assert_eq!(pcrs.digest(), verified.pcrs.digest());
888    }
889
890    #[test]
891    fn extract_own_pcrs_ignores_the_nonce_entirely() {
892        // Unlike verify_and_extract, extract_own_pcrs takes no handshake hash
893        // and never inspects the nonce: a doc minted with one nonce still
894        // yields its PCRs. (The node mints the request itself with an arbitrary
895        // nonce; there is no session to bind to.)
896        let fake = test_utils::FakeAttestation::with_seed(0x77, vec![0xde; 32]);
897        let pcrs = extract_own_pcrs(&fake.encode()).expect("extract own pcrs");
898        assert_eq!(pcrs.pcr0, fake.pcr0);
899    }
900
901    #[test]
902    fn extract_own_pcrs_rejects_garbage_bytes() {
903        let err = extract_own_pcrs(b"not a cose document").unwrap_err();
904        assert!(
905            matches!(err, AttestationError::Validation(_)),
906            "expected Validation, got {err:?}"
907        );
908    }
909
910    #[test]
911    fn verify_control_nonce_attestation_returns_attested_nonce() {
912        let nonce = [0xab; 32];
913        let fake = test_utils::FakeControlNonceAttestation::with_seed(0x21, hh(), nonce);
914        let expected = Pcrs {
915            pcr0: fake.pcr0.clone(),
916            pcr1: fake.pcr1.clone(),
917            pcr2: fake.pcr2.clone(),
918        };
919
920        let got = verify_control_nonce_attestation(&fake.encode(), &hh(), &expected, true)
921            .expect("verify");
922        assert_eq!(got, nonce);
923    }
924
925    #[test]
926    fn verify_control_nonce_attestation_rejects_wrong_pcrs() {
927        let fake = test_utils::FakeControlNonceAttestation::with_seed(0x21, hh(), [0xab; 32]);
928        let wrong = Pcrs {
929            pcr0: vec![0xff; 48],
930            pcr1: fake.pcr1.clone(),
931            pcr2: fake.pcr2.clone(),
932        };
933
934        let err =
935            verify_control_nonce_attestation(&fake.encode(), &hh(), &wrong, true).unwrap_err();
936        assert!(matches!(err, AttestationError::Validation(_)), "{err}");
937    }
938
939    #[test]
940    fn verify_control_nonce_attestation_rejects_wrong_handshake_hash() {
941        let fake = test_utils::FakeControlNonceAttestation::with_seed(0x21, hh(), [0xab; 32]);
942        let expected = Pcrs {
943            pcr0: fake.pcr0.clone(),
944            pcr1: fake.pcr1.clone(),
945            pcr2: fake.pcr2.clone(),
946        };
947        let other_hh: Vec<u8> = (100u8..132).collect();
948
949        let err = verify_control_nonce_attestation(&fake.encode(), &other_hh, &expected, true)
950            .unwrap_err();
951        assert!(matches!(err, AttestationError::Validation(_)), "{err}");
952    }
953
954    #[test]
955    fn verify_control_nonce_attestation_rejects_non_32_byte_user_data() {
956        let mut fake = test_utils::FakeControlNonceAttestation::with_seed(0x21, hh(), [0xab; 32]);
957        fake.control_nonce = vec![0xab; 16]; // wrong length
958        let expected = Pcrs {
959            pcr0: fake.pcr0.clone(),
960            pcr1: fake.pcr1.clone(),
961            pcr2: fake.pcr2.clone(),
962        };
963
964        let err =
965            verify_control_nonce_attestation(&fake.encode(), &hh(), &expected, true).unwrap_err();
966        assert!(
967            matches!(err, AttestationError::InvalidControlNonce),
968            "{err}"
969        );
970    }
971
972    #[test]
973    fn verify_and_extract_rejects_doc_without_user_data() {
974        // Build a doc with `user_data: None` by constructing it directly,
975        // since `FakeAttestation::encode` always populates user_data.
976        use aws_nitro_enclaves_nsm_api::api::{AttestationDoc, Digest};
977        use ciborium::value::Value as CborValue;
978        use std::collections::BTreeMap;
979
980        let mut pcrs = BTreeMap::new();
981        pcrs.insert(0usize, vec![0x11u8; 48]);
982        pcrs.insert(1usize, vec![0x12u8; 48]);
983        pcrs.insert(2usize, vec![0x13u8; 48]);
984        pcrs.insert(8usize, vec![0u8; 48]);
985
986        let doc = AttestationDoc::new(
987            "test-module".to_string(),
988            Digest::SHA384,
989            0,
990            pcrs,
991            vec![0u8; 64],
992            vec![vec![0u8; 64]],
993            None, // user_data missing — the case under test.
994            Some(hh()),
995            None,
996        );
997
998        let mut payload = Vec::new();
999        ciborium::into_writer(&doc, &mut payload).unwrap();
1000        let cose = CborValue::Array(vec![
1001            CborValue::Bytes(vec![0xa0]),
1002            CborValue::Map(Vec::new()),
1003            CborValue::Bytes(payload),
1004            CborValue::Bytes(vec![0u8; 96]),
1005        ]);
1006        let mut bytes = Vec::new();
1007        ciborium::into_writer(&cose, &mut bytes).unwrap();
1008
1009        let err = verify_and_extract(&bytes, &hh(), true).unwrap_err();
1010        assert!(
1011            matches!(err, AttestationError::InvalidControlPubkey),
1012            "expected InvalidControlPubkey, got {err:?}"
1013        );
1014    }
1015
1016    #[test]
1017    fn verify_and_extract_rejects_doc_with_wrong_size_user_data() {
1018        let mut fake = test_utils::FakeAttestation::with_seed(0x22, hh());
1019        // Override user_data via the `control_pubkey` field by encoding
1020        // a longer payload — done by reaching directly into the struct
1021        // and re-encoding manually. Easier: build the doc inline with a
1022        // 16-byte user_data.
1023        use aws_nitro_enclaves_nsm_api::api::{AttestationDoc, Digest};
1024        use ciborium::value::Value as CborValue;
1025        use std::collections::BTreeMap;
1026        let _ = &mut fake;
1027
1028        let mut pcrs = BTreeMap::new();
1029        pcrs.insert(0usize, vec![0x22u8; 48]);
1030        pcrs.insert(1usize, vec![0x23u8; 48]);
1031        pcrs.insert(2usize, vec![0x24u8; 48]);
1032        pcrs.insert(8usize, vec![0u8; 48]);
1033
1034        let doc = AttestationDoc::new(
1035            "test-module".to_string(),
1036            Digest::SHA384,
1037            0,
1038            pcrs,
1039            vec![0u8; 64],
1040            vec![vec![0u8; 64]],
1041            Some(vec![0u8; 16]), // 16 bytes is the wrong size.
1042            Some(hh()),
1043            None,
1044        );
1045
1046        let mut payload = Vec::new();
1047        ciborium::into_writer(&doc, &mut payload).unwrap();
1048        let cose = CborValue::Array(vec![
1049            CborValue::Bytes(vec![0xa0]),
1050            CborValue::Map(Vec::new()),
1051            CborValue::Bytes(payload),
1052            CborValue::Bytes(vec![0u8; 96]),
1053        ]);
1054        let mut bytes = Vec::new();
1055        ciborium::into_writer(&cose, &mut bytes).unwrap();
1056
1057        let err = verify_and_extract(&bytes, &hh(), true).unwrap_err();
1058        assert!(
1059            matches!(err, AttestationError::InvalidControlPubkey),
1060            "expected InvalidControlPubkey, got {err:?}"
1061        );
1062    }
1063
1064    /// The expected-PCR triple matching `FakeAttestation::with_seed(seed)`.
1065    fn seed_pcrs(seed: u8) -> Pcrs {
1066        Pcrs {
1067            pcr0: vec![seed; 48],
1068            pcr1: vec![seed.wrapping_add(1); 48],
1069            pcr2: vec![seed.wrapping_add(2); 48],
1070        }
1071    }
1072
1073    #[test]
1074    fn verify_and_extract_pcrs_returns_doc_pcrs_in_debug_mode() {
1075        let fake = test_utils::FakeAttestation::with_seed(0x66, hh());
1076        let pcrs = verify_and_extract_pcrs(&fake.encode(), &hh(), &[seed_pcrs(0x66)], true)
1077            .expect("verify");
1078        assert_eq!(pcrs.pcr0, fake.pcr0);
1079        assert_eq!(pcrs.pcr1, fake.pcr1);
1080        assert_eq!(pcrs.pcr2, fake.pcr2);
1081    }
1082
1083    #[test]
1084    fn verify_and_extract_pcrs_rejects_unexpected_pcrs() {
1085        let fake = test_utils::FakeAttestation::with_seed(0x66, hh());
1086        let err =
1087            verify_and_extract_pcrs(&fake.encode(), &hh(), &[seed_pcrs(0x99)], true).unwrap_err();
1088        assert!(
1089            matches!(err, AttestationError::PcrsNotExpected),
1090            "expected PcrsNotExpected, got {err:?}"
1091        );
1092        // An empty expected set admits nothing.
1093        let err = verify_and_extract_pcrs(&fake.encode(), &hh(), &[], true).unwrap_err();
1094        assert!(
1095            matches!(err, AttestationError::PcrsNotExpected),
1096            "expected PcrsNotExpected, got {err:?}"
1097        );
1098    }
1099
1100    #[test]
1101    fn verify_and_extract_pcrs_rejects_wrong_handshake_hash() {
1102        let fake = test_utils::FakeAttestation::with_seed(0x67, hh());
1103        let wrong: Vec<u8> = vec![0xab; 32];
1104        let err =
1105            verify_and_extract_pcrs(&fake.encode(), &wrong, &[seed_pcrs(0x67)], true).unwrap_err();
1106        assert!(
1107            matches!(err, AttestationError::Validation(_)),
1108            "expected Validation, got {err:?}"
1109        );
1110    }
1111
1112    #[test]
1113    fn verify_and_extract_pcrs_rejects_garbage_bytes() {
1114        let err = verify_and_extract_pcrs(b"not a cose document", &hh(), &[seed_pcrs(0x66)], true)
1115            .unwrap_err();
1116        assert!(
1117            matches!(err, AttestationError::Validation(_)),
1118            "expected Validation, got {err:?}"
1119        );
1120    }
1121
1122    #[test]
1123    fn verify_and_extract_pcrs_accepts_doc_without_user_data() {
1124        // The server side of the customer protocol carries no control
1125        // pubkey, so the doc may legitimately omit user_data (a real
1126        // /dev/nsm request with user_data = None). Build one inline,
1127        // since FakeAttestation always populates user_data.
1128        use aws_nitro_enclaves_nsm_api::api::{AttestationDoc, Digest};
1129        use ciborium::value::Value as CborValue;
1130        use std::collections::BTreeMap;
1131
1132        let mut pcrs = BTreeMap::new();
1133        pcrs.insert(0usize, vec![0x68u8; 48]);
1134        pcrs.insert(1usize, vec![0x69u8; 48]);
1135        pcrs.insert(2usize, vec![0x6au8; 48]);
1136        pcrs.insert(8usize, vec![0u8; 48]);
1137
1138        let doc = AttestationDoc::new(
1139            "test-module".to_string(),
1140            Digest::SHA384,
1141            0,
1142            pcrs,
1143            vec![0u8; 64],
1144            vec![vec![0u8; 64]],
1145            None, // no user_data: must still verify.
1146            Some(hh()),
1147            None,
1148        );
1149
1150        let mut payload = Vec::new();
1151        ciborium::into_writer(&doc, &mut payload).unwrap();
1152        let cose = CborValue::Array(vec![
1153            CborValue::Bytes(vec![0xa0]),
1154            CborValue::Map(Vec::new()),
1155            CborValue::Bytes(payload),
1156            CborValue::Bytes(vec![0u8; 96]),
1157        ]);
1158        let mut bytes = Vec::new();
1159        ciborium::into_writer(&cose, &mut bytes).unwrap();
1160
1161        let pcrs =
1162            verify_and_extract_pcrs(&bytes, &hh(), &[seed_pcrs(0x68)], true).expect("verify");
1163        assert_eq!(pcrs.pcr0, vec![0x68u8; 48]);
1164    }
1165
1166    #[test]
1167    fn verify_against_accepts_matching_pcrs_in_debug_mode() {
1168        let fake = test_utils::FakeAttestation::with_seed(0x22, hh());
1169        let bytes = fake.encode();
1170        let expected = Pcrs {
1171            pcr0: fake.pcr0.clone(),
1172            pcr1: fake.pcr1.clone(),
1173            pcr2: fake.pcr2.clone(),
1174        };
1175        verify_against(&bytes, &hh(), &expected, true).expect("verify");
1176    }
1177
1178    #[test]
1179    fn verify_against_rejects_mismatched_pcrs() {
1180        let fake = test_utils::FakeAttestation::with_seed(0x33, hh());
1181        let bytes = fake.encode();
1182        let expected = Pcrs {
1183            pcr0: vec![0xff; 48],
1184            pcr1: fake.pcr1.clone(),
1185            pcr2: fake.pcr2.clone(),
1186        };
1187        let err = verify_against(&bytes, &hh(), &expected, true).unwrap_err();
1188        assert!(
1189            matches!(err, AttestationError::Validation(_)),
1190            "expected Validation, got {err:?}"
1191        );
1192    }
1193
1194    #[test]
1195    fn verify_rejects_wrong_handshake_hash() {
1196        let fake = test_utils::FakeAttestation::with_seed(0x44, hh());
1197        let bytes = fake.encode();
1198        let wrong: Vec<u8> = vec![0xab; 32];
1199        let err = verify_and_extract(&bytes, &wrong, true).unwrap_err();
1200        assert!(
1201            matches!(err, AttestationError::Validation(_)),
1202            "expected Validation, got {err:?}"
1203        );
1204    }
1205
1206    #[test]
1207    fn digest_is_sha256_of_concatenated_pcrs() {
1208        let pcrs = Pcrs {
1209            pcr0: vec![0x01; 48],
1210            pcr1: vec![0x02; 48],
1211            pcr2: vec![0x03; 48],
1212        };
1213        let mut hasher = Sha256::new();
1214        hasher.update(&pcrs.pcr0);
1215        hasher.update(&pcrs.pcr1);
1216        hasher.update(&pcrs.pcr2);
1217        let expected: [u8; 32] = hasher.finalize().into();
1218        assert_eq!(pcrs.digest(), expected);
1219    }
1220
1221    fn pcrs_from_seed(seed: u8) -> Pcrs {
1222        Pcrs {
1223            pcr0: vec![seed; 48],
1224            pcr1: vec![seed.wrapping_add(1); 48],
1225            pcr2: vec![seed.wrapping_add(2); 48],
1226        }
1227    }
1228
1229    #[test]
1230    fn verify_chain_attestation_accepts_well_formed_link_in_debug_mode() {
1231        let payload = b"chain-link-payload-canary".to_vec();
1232        let fake = test_utils::FakeChainAttestation::for_payload(0x33, &payload);
1233        let bytes = fake.encode();
1234        let expected_pcrs = pcrs_from_seed(0x33);
1235
1236        verify_chain_attestation(&bytes, &payload, &expected_pcrs, true)
1237            .expect("valid chain attestation must pass");
1238    }
1239
1240    #[test]
1241    fn verify_chain_attestation_rejects_mismatched_payload_binding() {
1242        let payload = b"chain-link-payload-canary".to_vec();
1243        let fake = test_utils::FakeChainAttestation::for_payload(0x44, &payload);
1244        let bytes = fake.encode();
1245        let expected_pcrs = pcrs_from_seed(0x44);
1246
1247        // Same attestation, different payload — user_data binds to the
1248        // original, so the verifier must reject the substitution.
1249        let err = verify_chain_attestation(&bytes, b"DIFFERENT", &expected_pcrs, true)
1250            .expect_err("payload swap must fail the binding check");
1251        assert!(
1252            matches!(err, AttestationError::PayloadBindingMismatch),
1253            "expected PayloadBindingMismatch, got {err:?}"
1254        );
1255    }
1256
1257    #[test]
1258    fn verify_chain_attestation_rejects_pcr_mismatch() {
1259        let payload = b"chain-link-payload-canary".to_vec();
1260        let fake = test_utils::FakeChainAttestation::for_payload(0x55, &payload);
1261        let bytes = fake.encode();
1262        // Wrong expected PCRs — the caller's recorded PCRs disagree with
1263        // what the doc carries. Verifier must reject.
1264        let mismatched_pcrs = pcrs_from_seed(0x99);
1265
1266        let err = verify_chain_attestation(&bytes, &payload, &mismatched_pcrs, true)
1267            .expect_err("PCR mismatch must fail");
1268        assert!(
1269            matches!(err, AttestationError::Validation(_)),
1270            "expected Validation error, got {err:?}"
1271        );
1272    }
1273
1274    #[test]
1275    fn verify_chain_attestation_rejects_doc_without_user_data() {
1276        use aws_nitro_enclaves_nsm_api::api::{AttestationDoc, Digest};
1277        use ciborium::value::Value as CborValue;
1278        use std::collections::BTreeMap;
1279
1280        let payload = b"any-payload".to_vec();
1281        let pcrs = pcrs_from_seed(0x77);
1282
1283        let mut pcr_map = BTreeMap::new();
1284        pcr_map.insert(0usize, pcrs.pcr0.clone());
1285        pcr_map.insert(1usize, pcrs.pcr1.clone());
1286        pcr_map.insert(2usize, pcrs.pcr2.clone());
1287        pcr_map.insert(8usize, vec![0u8; 48]);
1288
1289        let doc = AttestationDoc::new(
1290            "test-module".to_string(),
1291            Digest::SHA384,
1292            0,
1293            pcr_map,
1294            vec![0u8; 64],
1295            vec![vec![0u8; 64]],
1296            None, // user_data missing — the case under test.
1297            Some(vec![0u8; 32]),
1298            None,
1299        );
1300
1301        let mut doc_bytes = Vec::new();
1302        ciborium::into_writer(&doc, &mut doc_bytes).unwrap();
1303        let cose = CborValue::Array(vec![
1304            CborValue::Bytes(vec![0xa0]),
1305            CborValue::Map(Vec::new()),
1306            CborValue::Bytes(doc_bytes),
1307            CborValue::Bytes(vec![0u8; 96]),
1308        ]);
1309        let mut bytes = Vec::new();
1310        ciborium::into_writer(&cose, &mut bytes).unwrap();
1311
1312        let err = verify_chain_attestation(&bytes, &payload, &pcrs, true)
1313            .expect_err("missing user_data must be rejected");
1314        assert!(
1315            matches!(err, AttestationError::PayloadBindingMismatch),
1316            "expected PayloadBindingMismatch, got {err:?}"
1317        );
1318    }
1319}
1320
1321#[cfg(test)]
1322mod non_upgradable_control_key_tests {
1323    use super::{CONTROL_PUBKEY_LEN, NON_UPGRADABLE_CONTROL_KEY, NON_UPGRADABLE_CONTROL_KEY_DST};
1324    use sha2::{Digest, Sha256};
1325
1326    /// Re-run the try-and-increment derivation the baked constant came
1327    /// from: hash the DST with a 1-byte counter to a candidate
1328    /// x-coordinate and take the first that decompresses to a valid
1329    /// P-256 point. Test-only; the production value is the
1330    /// [`NON_UPGRADABLE_CONTROL_KEY`] constant, this just proves the
1331    /// constant equals its construction so the literal cannot drift.
1332    fn derive_non_upgradable_control_key() -> [u8; CONTROL_PUBKEY_LEN] {
1333        use p256::EncodedPoint;
1334        use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint};
1335
1336        for counter in 0u16..=255 {
1337            let mut hasher = Sha256::new();
1338            hasher.update(NON_UPGRADABLE_CONTROL_KEY_DST);
1339            hasher.update([counter as u8]);
1340            let x = hasher.finalize();
1341            let mut compressed = [0u8; 33];
1342            compressed[0] = 0x02; // even-y compressed SEC1
1343            compressed[1..].copy_from_slice(&x);
1344            let Ok(encoded) = EncodedPoint::from_bytes(compressed) else {
1345                continue;
1346            };
1347            let maybe_point = p256::AffinePoint::from_encoded_point(&encoded);
1348            if maybe_point.is_some().into() {
1349                let uncompressed = maybe_point.unwrap().to_encoded_point(false);
1350                let mut out = [0u8; CONTROL_PUBKEY_LEN];
1351                out.copy_from_slice(uncompressed.as_bytes());
1352                return out;
1353            }
1354        }
1355        panic!("no valid P-256 point found deriving the non-upgradable control key");
1356    }
1357
1358    /// The baked constant must equal the live derivation. This is the
1359    /// audit anchor: a change to the DST or the derivation that is not
1360    /// mirrored into the constant trips here, forcing a deliberate
1361    /// review (changing the value would orphan every already-pinned
1362    /// non-upgradable enclave).
1363    #[test]
1364    fn constant_matches_derivation() {
1365        assert_eq!(
1366            NON_UPGRADABLE_CONTROL_KEY,
1367            derive_non_upgradable_control_key(),
1368            "baked non-upgradable control key drifted from its DST derivation"
1369        );
1370    }
1371
1372    #[test]
1373    fn is_uncompressed_sec1_shape() {
1374        assert_eq!(NON_UPGRADABLE_CONTROL_KEY.len(), CONTROL_PUBKEY_LEN);
1375        assert_eq!(NON_UPGRADABLE_CONTROL_KEY[0], 0x04);
1376    }
1377
1378    /// It must parse as a real P-256 verifying key, so `Register` and the
1379    /// `verify_transition_link` decode step accept it (the un-signability
1380    /// bites at the signature check, not at decode: a Transition cannot
1381    /// be rejected merely because the key looks malformed, it must be a
1382    /// well-formed key that simply no signature verifies against).
1383    #[test]
1384    fn parses_as_a_valid_verifying_key() {
1385        p256::ecdsa::VerifyingKey::from_sec1_bytes(NON_UPGRADABLE_CONTROL_KEY.as_slice())
1386            .expect("canonical non-upgradable control key must be a valid P-256 point");
1387    }
1388}