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}