1use base64::Engine as _;
38use chrono::{DateTime, Utc};
39use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
40use serde::{Deserialize, Serialize};
41use uuid::Uuid;
42
43use crate::attestation::{AttestationError, Pcrs, verify_chain_attestation};
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ChainLinkKind {
49 Boot,
50 Upgrade,
51 Revocation,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ChainLink {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub id: Option<Uuid>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub sequence: Option<u64>,
73 pub kind: ChainLinkKind,
74 #[serde(with = "serde_bytes")]
77 pub payload: Vec<u8>,
78 #[serde(with = "serde_bytes")]
80 pub attestation: Vec<u8>,
81 #[serde(
85 default,
86 skip_serializing_if = "Option::is_none",
87 with = "serde_bytes_opt"
88 )]
89 pub signature: Option<Vec<u8>>,
90}
91
92mod serde_bytes_opt {
97 use serde::{Deserialize, Deserializer, Serialize, Serializer};
98
99 pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, ser: S) -> Result<S::Ok, S::Error> {
100 match v {
101 Some(bytes) => serde_bytes::Bytes::new(bytes).serialize(ser),
102 None => ser.serialize_none(),
103 }
104 }
105
106 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Option<Vec<u8>>, D::Error> {
107 Option::<serde_bytes::ByteBuf>::deserialize(de).map(|o| o.map(|b| b.into_vec()))
108 }
109}
110
111#[derive(Debug, Clone, Deserialize, Serialize)]
122pub struct ChainLinkJson {
123 #[serde(default)]
126 pub id: Option<uuid::Uuid>,
127 pub kind: ChainLinkKind,
128 #[serde(default)]
130 pub sequence: Option<i64>,
131 pub payload: String,
133 pub attestation: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub signature: Option<String>,
141 #[serde(default)]
144 pub created_at: Option<chrono::DateTime<chrono::Utc>>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct BootPayload {
150 pub enclave_id: Uuid,
152 pub image_digest: String,
154 pub pcrs: PcrsHex,
156 pub booted_at: DateTime<Utc>,
159 #[serde(with = "serde_bytes")]
161 pub nonce: Vec<u8>,
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct UpgradePayload {
167 pub enclave_id: Uuid,
168 pub from_pcrs: PcrsHex,
169 pub to_pcrs: PcrsHex,
170 pub image_digest: String,
171 pub valid_from: DateTime<Utc>,
172 pub issued_at: DateTime<Utc>,
173 #[serde(with = "serde_bytes")]
174 pub nonce: Vec<u8>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct RevocationPayload {
180 pub enclave_id: Uuid,
181 pub revokes: Uuid,
183 pub issued_at: DateTime<Utc>,
184 #[serde(with = "serde_bytes")]
185 pub nonce: Vec<u8>,
186}
187
188#[derive(Debug, thiserror::Error)]
190pub enum ChainLinkDecodeError {
191 #[error("chain link `{field}` is not valid base64: {source}")]
193 Base64 {
194 field: &'static str,
195 #[source]
196 source: base64::DecodeError,
197 },
198 #[error("chain link has a negative sequence {0}")]
202 NegativeSequence(i64),
203}
204
205impl ChainLinkJson {
206 pub fn into_chain_link(&self) -> Result<ChainLink, ChainLinkDecodeError> {
216 let b64 = base64::engine::general_purpose::STANDARD;
217 let decode = |field: &'static str, s: &str| {
218 b64.decode(s.as_bytes())
219 .map_err(|source| ChainLinkDecodeError::Base64 { field, source })
220 };
221 let payload = decode("payload", &self.payload)?;
222 let attestation = decode("attestation", &self.attestation)?;
223 let signature = match self.signature.as_deref() {
224 Some(s) => Some(decode("signature", s)?),
225 None => None,
226 };
227 let sequence = match self.sequence {
228 Some(s) => {
229 Some(u64::try_from(s).map_err(|_| ChainLinkDecodeError::NegativeSequence(s))?)
230 }
231 None => None,
232 };
233 Ok(ChainLink {
234 id: self.id,
235 sequence,
236 kind: self.kind,
237 payload,
238 attestation,
239 signature,
240 })
241 }
242
243 pub fn into_recorded_link(&self) -> Result<RecordedLink, ChainLinkDecodeError> {
247 Ok(RecordedLink {
248 link: self.into_chain_link()?,
249 recorded_at: self.created_at,
250 })
251 }
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub struct PcrsHex {
261 #[serde(rename = "PCR0", alias = "pcr0")]
266 pub pcr0: String,
267 #[serde(rename = "PCR1", alias = "pcr1")]
268 pub pcr1: String,
269 #[serde(rename = "PCR2", alias = "pcr2")]
270 pub pcr2: String,
271}
272
273impl PcrsHex {
274 pub fn to_pcrs(&self) -> Result<Pcrs, ChainValidationError> {
276 let pcr0 =
277 hex::decode(&self.pcr0).map_err(|_| ChainValidationError::CorruptStoredPcrHex(0))?;
278 let pcr1 =
279 hex::decode(&self.pcr1).map_err(|_| ChainValidationError::CorruptStoredPcrHex(1))?;
280 let pcr2 =
281 hex::decode(&self.pcr2).map_err(|_| ChainValidationError::CorruptStoredPcrHex(2))?;
282 Ok(Pcrs { pcr0, pcr1, pcr2 })
283 }
284}
285
286#[derive(Debug, Clone, Deserialize)]
303pub struct EnclaveChainRow {
304 pub pcrs: PcrsHex,
305 pub image_digest: String,
306 #[serde(default, deserialize_with = "deserialize_control_key")]
307 pub control_public_key: Option<Vec<u8>>,
308 #[serde(default)]
309 pub upgradable: bool,
310}
311
312fn deserialize_control_key<'de, D>(de: D) -> Result<Option<Vec<u8>>, D::Error>
316where
317 D: serde::Deserializer<'de>,
318{
319 #[derive(Deserialize)]
320 #[serde(untagged)]
321 enum Raw {
322 Bytes(Vec<u8>),
323 Base64(String),
324 }
325 match Option::<Raw>::deserialize(de)? {
326 None => Ok(None),
327 Some(Raw::Bytes(bytes)) => Ok(Some(bytes)),
328 Some(Raw::Base64(s)) => base64::engine::general_purpose::STANDARD
329 .decode(s.as_bytes())
330 .map(Some)
331 .map_err(serde::de::Error::custom),
332 }
333}
334
335pub struct ChainContext<'a> {
343 pub enclave_pcrs: &'a PcrsHex,
346 pub enclave_image_digest: &'a str,
349 pub control_public_key: Option<&'a [u8]>,
352 pub upgradable: bool,
355 pub prior_chain: &'a [ChainLink],
358}
359
360#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum Outcome {
363 Append {
366 sequence: u64,
369 },
370 Dedup,
374}
375
376#[derive(Debug, thiserror::Error)]
378pub enum ChainValidationError {
379 #[error("{0}")]
382 Attestation(#[from] AttestationError),
383 #[error("attestation must be present")]
385 EmptyAttestation,
386 #[error("{kind:?} payload not CBOR-decodable: {msg}")]
388 PayloadDecode { kind: ChainLinkKind, msg: String },
389 #[error("payload enclave_id does not match the URL")]
391 EnclaveIdMismatch,
392 #[error("boot payload PCRs do not match the enclave's recorded PCRs")]
395 PcrMismatch,
396 #[error("boot payload image_digest does not match the enclave's pinned digest")]
398 ImageDigestMismatch,
399 #[error("boot link must not carry a signature")]
402 BootHasSignature,
403 #[error("{0:?} link must carry a signature")]
405 SignatureMissing(ChainLinkKind),
406 #[error("stored control_public_key does not decode as SEC1 P-256: {0}")]
410 BadControlPubkey(String),
411 #[error("signature is not 64 bytes raw r||s ECDSA P-256")]
413 SignatureShape,
414 #[error("signature does not verify under the enclave's control_public_key")]
417 SignatureInvalid,
418 #[error("non-upgradable enclaves cannot record {0:?} links")]
420 NonUpgradableSigned(ChainLinkKind),
421 #[error("non-upgradable enclaves cannot record a second boot")]
423 NonUpgradableSecondBoot,
424 #[error("first chain entry must be a boot — no upgrade or revocation can precede the genesis")]
426 NoGenesisYet,
427 #[error("revocation `revokes` does not reference any chain entry on this enclave")]
430 RevokeTargetMissing,
431 #[error("revocation can only target an upgrade entry, not a {0:?}")]
433 RevokeTargetWrongKind(ChainLinkKind),
434 #[error("revocation is past the upgrade's valid_from; pre-activation revoke only")]
436 RevokePastActivation,
437 #[error("upgrade has already been revoked")]
440 AlreadyRevoked,
441 #[error("stored {0:?} payload corrupt: {1}")]
444 CorruptStoredPayload(ChainLinkKind, String),
445 #[error("stored PCR {0} is not valid hex")]
447 CorruptStoredPcrHex(usize),
448}
449
450pub fn validate_chain_link(
459 link: &ChainLink,
460 ctx: &ChainContext<'_>,
461 now: DateTime<Utc>,
462 debug_mode: bool,
463) -> Result<Outcome, ChainValidationError> {
464 if link.attestation.is_empty() {
465 return Err(ChainValidationError::EmptyAttestation);
466 }
467 let recorded_pcrs = ctx.enclave_pcrs.to_pcrs()?;
468 verify_chain_attestation(&link.attestation, &link.payload, &recorded_pcrs, debug_mode)?;
469
470 match link.kind {
471 ChainLinkKind::Boot => validate_boot(link, ctx),
472 ChainLinkKind::Upgrade | ChainLinkKind::Revocation => validate_signed(link, ctx, now),
473 }
474}
475
476fn validate_boot(
477 link: &ChainLink,
478 ctx: &ChainContext<'_>,
479) -> Result<Outcome, ChainValidationError> {
480 if link.signature.is_some() {
481 return Err(ChainValidationError::BootHasSignature);
482 }
483 let parsed: BootPayload = ciborium::from_reader(link.payload.as_slice()).map_err(|e| {
484 ChainValidationError::PayloadDecode {
485 kind: ChainLinkKind::Boot,
486 msg: e.to_string(),
487 }
488 })?;
489 if parsed.pcrs != *ctx.enclave_pcrs {
490 return Err(ChainValidationError::PcrMismatch);
491 }
492 if parsed.image_digest != ctx.enclave_image_digest {
493 return Err(ChainValidationError::ImageDigestMismatch);
494 }
495 let last_boot = ctx
500 .prior_chain
501 .iter()
502 .rev()
503 .find(|l| l.kind == ChainLinkKind::Boot);
504 match last_boot {
505 None => {
506 Ok(Outcome::Append {
510 sequence: ctx.prior_chain.len() as u64,
511 })
512 }
513 Some(prev) => {
514 let prev_payload: BootPayload = ciborium::from_reader(prev.payload.as_slice())
515 .map_err(|e| {
516 ChainValidationError::CorruptStoredPayload(ChainLinkKind::Boot, e.to_string())
517 })?;
518 if prev_payload.image_digest == parsed.image_digest {
519 return Ok(Outcome::Dedup);
520 }
521 if !ctx.upgradable {
522 return Err(ChainValidationError::NonUpgradableSecondBoot);
523 }
524 Ok(Outcome::Append {
525 sequence: ctx.prior_chain.len() as u64,
526 })
527 }
528 }
529}
530
531fn validate_signed(
532 link: &ChainLink,
533 ctx: &ChainContext<'_>,
534 now: DateTime<Utc>,
535) -> Result<Outcome, ChainValidationError> {
536 if !ctx.upgradable {
537 return Err(ChainValidationError::NonUpgradableSigned(link.kind));
538 }
539 let sig_bytes = link
540 .signature
541 .as_deref()
542 .ok_or(ChainValidationError::SignatureMissing(link.kind))?;
543 let pubkey_bytes = ctx.control_public_key.ok_or_else(|| {
544 ChainValidationError::BadControlPubkey(
545 "upgradable enclave is missing control_public_key in context".into(),
546 )
547 })?;
548 let verifying = VerifyingKey::from_sec1_bytes(pubkey_bytes)
549 .map_err(|e| ChainValidationError::BadControlPubkey(e.to_string()))?;
550 let sig = Signature::from_slice(sig_bytes).map_err(|_| ChainValidationError::SignatureShape)?;
551 verifying
552 .verify(&link.payload, &sig)
553 .map_err(|_| ChainValidationError::SignatureInvalid)?;
554
555 match link.kind {
557 ChainLinkKind::Upgrade => {
558 let _: UpgradePayload =
559 ciborium::from_reader(link.payload.as_slice()).map_err(|e| {
560 ChainValidationError::PayloadDecode {
561 kind: ChainLinkKind::Upgrade,
562 msg: e.to_string(),
563 }
564 })?;
565 }
566 ChainLinkKind::Revocation => {
567 let revoke: RevocationPayload = ciborium::from_reader(link.payload.as_slice())
568 .map_err(|e| ChainValidationError::PayloadDecode {
569 kind: ChainLinkKind::Revocation,
570 msg: e.to_string(),
571 })?;
572 let target = ctx
574 .prior_chain
575 .iter()
576 .find(|l| l.id == Some(revoke.revokes))
577 .ok_or(ChainValidationError::RevokeTargetMissing)?;
578 if target.kind != ChainLinkKind::Upgrade {
579 return Err(ChainValidationError::RevokeTargetWrongKind(target.kind));
580 }
581 let target_upgrade: UpgradePayload = ciborium::from_reader(target.payload.as_slice())
582 .map_err(|e| {
583 ChainValidationError::CorruptStoredPayload(ChainLinkKind::Upgrade, e.to_string())
584 })?;
585 if target_upgrade.valid_from <= now {
586 return Err(ChainValidationError::RevokePastActivation);
587 }
588 for existing in ctx.prior_chain {
589 if existing.kind != ChainLinkKind::Revocation {
590 continue;
591 }
592 let existing_payload: RevocationPayload =
593 ciborium::from_reader(existing.payload.as_slice()).map_err(|e| {
594 ChainValidationError::CorruptStoredPayload(
595 ChainLinkKind::Revocation,
596 e.to_string(),
597 )
598 })?;
599 if existing_payload.revokes == revoke.revokes {
600 return Err(ChainValidationError::AlreadyRevoked);
601 }
602 }
603 }
604 ChainLinkKind::Boot => unreachable!("validate_signed not called for boot"),
605 };
606
607 if ctx.prior_chain.is_empty() {
609 return Err(ChainValidationError::NoGenesisYet);
610 }
611 Ok(Outcome::Append {
612 sequence: ctx.prior_chain.len() as u64,
613 })
614}
615
616#[derive(Debug, Clone)]
623pub struct RecordedLink {
624 pub link: ChainLink,
625 pub recorded_at: Option<DateTime<Utc>>,
633}
634
635#[derive(Debug)]
637pub struct ChainWalk {
638 pub outcomes: Vec<Result<Outcome, ChainValidationError>>,
640 pub final_pcrs: Option<PcrsHex>,
644 pub final_image_digest: Option<String>,
646 pub tip_matches_row: bool,
652}
653
654pub fn validate_chain(
689 links: &[RecordedLink],
690 row_pcrs: &PcrsHex,
691 row_image_digest: &str,
692 control_public_key: Option<&[u8]>,
693 upgradable: bool,
694 now: DateTime<Utc>,
695 debug_mode: bool,
696) -> ChainWalk {
697 let mut outcomes = Vec::with_capacity(links.len());
698 let mut prior: Vec<ChainLink> = Vec::with_capacity(links.len());
699 let mut in_force: Option<(PcrsHex, String)> = None;
704
705 for recorded in links {
706 let link = &recorded.link;
707 let reference = recorded.recorded_at.unwrap_or(now);
708
709 let (ctx_pcrs, ctx_digest, promotes): (PcrsHex, String, bool) = match link.kind {
713 ChainLinkKind::Boot if prior.is_empty() => {
714 match ciborium::from_reader::<BootPayload, _>(link.payload.as_slice()) {
715 Ok(p) => (p.pcrs, p.image_digest, true),
716 Err(_) => (row_pcrs.clone(), row_image_digest.to_owned(), false),
719 }
720 }
721 ChainLinkKind::Boot => {
722 match (
723 ciborium::from_reader::<BootPayload, _>(link.payload.as_slice()),
724 in_force.as_ref(),
725 ) {
726 (Ok(p), Some((pcrs, digest))) => {
727 if p.pcrs == *pcrs {
728 (pcrs.clone(), digest.clone(), false)
730 } else if let Some(target) =
731 promotion_target(&prior, &p.pcrs, &p.image_digest)
732 {
733 (target.to_pcrs, target.image_digest, true)
736 } else {
737 (pcrs.clone(), digest.clone(), false)
741 }
742 }
743 (_, Some((pcrs, digest))) => (pcrs.clone(), digest.clone(), false),
744 (_, None) => (row_pcrs.clone(), row_image_digest.to_owned(), false),
745 }
746 }
747 ChainLinkKind::Upgrade | ChainLinkKind::Revocation => match in_force.as_ref() {
748 Some((pcrs, digest)) => (pcrs.clone(), digest.clone(), false),
749 None => (row_pcrs.clone(), row_image_digest.to_owned(), false),
750 },
751 };
752
753 let ctx = ChainContext {
754 enclave_pcrs: &ctx_pcrs,
755 enclave_image_digest: &ctx_digest,
756 control_public_key,
757 upgradable,
758 prior_chain: &prior,
759 };
760 let outcome = validate_chain_link(link, &ctx, reference, debug_mode);
761 if promotes && matches!(outcome, Ok(Outcome::Append { .. })) {
762 in_force = Some((ctx_pcrs, ctx_digest));
763 }
764 outcomes.push(outcome);
765 prior.push(link.clone());
766 }
767
768 let tip_matches_row = in_force
769 .as_ref()
770 .is_some_and(|(p, d)| p == row_pcrs && d == row_image_digest);
771 let (final_pcrs, final_image_digest) = match in_force {
772 Some((p, d)) => (Some(p), Some(d)),
773 None => (None, None),
774 };
775 ChainWalk {
776 outcomes,
777 final_pcrs,
778 final_image_digest,
779 tip_matches_row,
780 }
781}
782
783fn promotion_target(
787 prior: &[ChainLink],
788 boot_pcrs: &PcrsHex,
789 boot_image_digest: &str,
790) -> Option<UpgradePayload> {
791 let revoked: Vec<Uuid> = prior
792 .iter()
793 .filter(|l| l.kind == ChainLinkKind::Revocation)
794 .filter_map(|l| ciborium::from_reader::<RevocationPayload, _>(l.payload.as_slice()).ok())
795 .map(|p| p.revokes)
796 .collect();
797 prior
798 .iter()
799 .rev()
800 .filter(|l| l.kind == ChainLinkKind::Upgrade)
801 .filter(|l| l.id.is_none_or(|id| !revoked.contains(&id)))
802 .filter_map(|l| ciborium::from_reader::<UpgradePayload, _>(l.payload.as_slice()).ok())
803 .find(|p| p.to_pcrs == *boot_pcrs && p.image_digest == boot_image_digest)
804}
805
806#[derive(Debug, thiserror::Error)]
813pub enum PcrDescentError {
814 #[error("chain has no valid genesis boot")]
817 NoGenesis,
818 #[error("chain link at position {position} failed validation: {source}")]
823 LinkInvalid {
824 position: usize,
825 #[source]
826 source: ChainValidationError,
827 },
828 #[error("pinned PCRs are not an in-force state anywhere on this chain")]
834 PinnedNotInLineage,
835 #[error("validated boot link at position {0} has an unreadable payload")]
840 BootPayloadUnreadable(usize),
841}
842
843#[allow(clippy::too_many_arguments)]
893pub fn verify_pcr_descent(
894 pinned: &Pcrs,
895 links: &[RecordedLink],
896 control_public_key: Option<&[u8]>,
897 row_pcrs: &PcrsHex,
898 row_image_digest: &str,
899 upgradable: bool,
900 now: DateTime<Utc>,
901 debug_mode: bool,
902) -> Result<Pcrs, PcrDescentError> {
903 let ChainWalk {
904 outcomes,
905 final_pcrs,
906 ..
907 } = validate_chain(
908 links,
909 row_pcrs,
910 row_image_digest,
911 control_public_key,
912 upgradable,
913 now,
914 debug_mode,
915 );
916
917 for (position, outcome) in outcomes.into_iter().enumerate() {
921 outcome.map_err(|source| PcrDescentError::LinkInvalid { position, source })?;
922 }
923
924 let tip = final_pcrs
925 .ok_or(PcrDescentError::NoGenesis)?
926 .to_pcrs()
927 .map_err(|_| PcrDescentError::BootPayloadUnreadable(0))?;
928
929 let mut pinned_in_lineage = false;
935 for (position, recorded) in links.iter().enumerate() {
936 if recorded.link.kind != ChainLinkKind::Boot {
937 continue;
938 }
939 let payload: BootPayload = ciborium::from_reader(recorded.link.payload.as_slice())
940 .map_err(|_| PcrDescentError::BootPayloadUnreadable(position))?;
941 let state = payload
942 .pcrs
943 .to_pcrs()
944 .map_err(|_| PcrDescentError::BootPayloadUnreadable(position))?;
945 if &state == pinned {
946 pinned_in_lineage = true;
947 }
948 }
949 if !pinned_in_lineage {
950 return Err(PcrDescentError::PinnedNotInLineage);
951 }
952
953 Ok(tip)
954}
955
956#[cfg(test)]
957mod tests {
958 use super::*;
959 use crate::attestation::test_utils::FakeChainAttestation;
960 use chrono::Duration;
961 use p256::ecdsa::{SigningKey, signature::Signer};
962
963 fn pcrs_hex_from_seed(seed: u8) -> PcrsHex {
964 PcrsHex {
965 pcr0: hex::encode(vec![seed; 48]),
966 pcr1: hex::encode(vec![seed.wrapping_add(1); 48]),
967 pcr2: hex::encode(vec![seed.wrapping_add(2); 48]),
968 }
969 }
970
971 fn keypair() -> (SigningKey, Vec<u8>) {
972 let seed: [u8; 32] = core::array::from_fn(|i| (i + 1) as u8);
973 let sk = SigningKey::from_slice(&seed).unwrap();
974 let pk = sk
975 .verifying_key()
976 .to_encoded_point(false)
977 .as_bytes()
978 .to_vec();
979 (sk, pk)
980 }
981
982 fn boot_link(enclave_id: Uuid, image_digest: &str, pcr_seed: u8) -> ChainLink {
983 let payload = BootPayload {
984 enclave_id,
985 image_digest: image_digest.into(),
986 pcrs: pcrs_hex_from_seed(pcr_seed),
987 booted_at: chrono::Utc::now(),
988 nonce: vec![0x42; 32],
989 };
990 let mut payload_bytes = Vec::new();
991 ciborium::into_writer(&payload, &mut payload_bytes).unwrap();
992 let attestation = FakeChainAttestation::for_payload(pcr_seed, &payload_bytes).encode();
993 ChainLink {
994 id: None,
995 sequence: None,
996 kind: ChainLinkKind::Boot,
997 payload: payload_bytes,
998 attestation,
999 signature: None,
1000 }
1001 }
1002
1003 fn upgrade_link(
1004 enclave_id: Uuid,
1005 image_digest: &str,
1006 pcr_seed: u8,
1007 signing: &SigningKey,
1008 valid_from: DateTime<Utc>,
1009 ) -> ChainLink {
1010 let pcrs = pcrs_hex_from_seed(pcr_seed);
1011 let payload = UpgradePayload {
1012 enclave_id,
1013 from_pcrs: pcrs.clone(),
1014 to_pcrs: pcrs,
1015 image_digest: image_digest.into(),
1016 valid_from,
1017 issued_at: chrono::Utc::now(),
1018 nonce: vec![0x43; 32],
1019 };
1020 let mut payload_bytes = Vec::new();
1021 ciborium::into_writer(&payload, &mut payload_bytes).unwrap();
1022 let attestation = FakeChainAttestation::for_payload(pcr_seed, &payload_bytes).encode();
1023 let sig: Signature = signing.sign(&payload_bytes);
1024 ChainLink {
1025 id: None,
1026 sequence: None,
1027 kind: ChainLinkKind::Upgrade,
1028 payload: payload_bytes,
1029 attestation,
1030 signature: Some(sig.to_bytes().to_vec()),
1031 }
1032 }
1033
1034 fn revocation_link(
1035 enclave_id: Uuid,
1036 revokes: Uuid,
1037 pcr_seed: u8,
1038 signing: &SigningKey,
1039 ) -> ChainLink {
1040 let payload = RevocationPayload {
1041 enclave_id,
1042 revokes,
1043 issued_at: chrono::Utc::now(),
1044 nonce: vec![0x44; 32],
1045 };
1046 let mut payload_bytes = Vec::new();
1047 ciborium::into_writer(&payload, &mut payload_bytes).unwrap();
1048 let attestation = FakeChainAttestation::for_payload(pcr_seed, &payload_bytes).encode();
1049 let sig: Signature = signing.sign(&payload_bytes);
1050 ChainLink {
1051 id: None,
1052 sequence: None,
1053 kind: ChainLinkKind::Revocation,
1054 payload: payload_bytes,
1055 attestation,
1056 signature: Some(sig.to_bytes().to_vec()),
1057 }
1058 }
1059
1060 fn ctx<'a>(
1061 pcrs: &'a PcrsHex,
1062 digest: &'a str,
1063 pubkey: Option<&'a [u8]>,
1064 upgradable: bool,
1065 chain: &'a [ChainLink],
1066 ) -> ChainContext<'a> {
1067 ChainContext {
1068 enclave_pcrs: pcrs,
1069 enclave_image_digest: digest,
1070 control_public_key: pubkey,
1071 upgradable,
1072 prior_chain: chain,
1073 }
1074 }
1075
1076 #[test]
1077 fn boot_genesis_appends_at_zero() {
1078 let pcrs = pcrs_hex_from_seed(0x10);
1079 let id = Uuid::new_v4();
1080 let link = boot_link(id, "sha256:aaa", 0x10);
1081 let outcome = validate_chain_link(
1082 &link,
1083 &ctx(&pcrs, "sha256:aaa", None, false, &[]),
1084 chrono::Utc::now(),
1085 true,
1086 )
1087 .unwrap();
1088 assert_eq!(outcome, Outcome::Append { sequence: 0 });
1089 }
1090
1091 #[test]
1092 fn boot_rejects_pcr_mismatch() {
1093 let pcrs = pcrs_hex_from_seed(0x11);
1094 let id = Uuid::new_v4();
1095 let link = boot_link(id, "sha256:aaa", 0x99);
1096 let err = validate_chain_link(
1097 &link,
1098 &ctx(&pcrs, "sha256:aaa", None, false, &[]),
1099 chrono::Utc::now(),
1100 true,
1101 )
1102 .unwrap_err();
1103 assert!(matches!(err, ChainValidationError::Attestation(_)));
1104 }
1105
1106 #[test]
1107 fn boot_rejects_image_digest_mismatch() {
1108 let pcrs = pcrs_hex_from_seed(0x12);
1109 let id = Uuid::new_v4();
1110 let link = boot_link(id, "sha256:DIFFERENT", 0x12);
1111 let err = validate_chain_link(
1112 &link,
1113 &ctx(&pcrs, "sha256:aaa", None, false, &[]),
1114 chrono::Utc::now(),
1115 true,
1116 )
1117 .unwrap_err();
1118 assert!(matches!(err, ChainValidationError::ImageDigestMismatch));
1119 }
1120
1121 #[test]
1122 fn boot_dedups_on_same_image_digest() {
1123 let pcrs = pcrs_hex_from_seed(0x13);
1124 let id = Uuid::new_v4();
1125 let first = boot_link(id, "sha256:bbb", 0x13);
1126 let outcome = validate_chain_link(
1127 &first,
1128 &ctx(
1129 &pcrs,
1130 "sha256:bbb",
1131 None,
1132 false,
1133 std::slice::from_ref(&first),
1134 ),
1135 chrono::Utc::now(),
1136 true,
1137 )
1138 .unwrap();
1139 assert_eq!(outcome, Outcome::Dedup);
1140 }
1141
1142 #[test]
1143 fn boot_after_pending_upgrade_dedups_against_last_boot() {
1144 let pcrs = pcrs_hex_from_seed(0x14);
1146 let (sk, pk) = keypair();
1147 let id = Uuid::new_v4();
1148 let mut chain = vec![boot_link(id, "sha256:v1", 0x14)];
1149 chain[0].id = Some(Uuid::new_v4());
1150 chain[0].sequence = Some(0);
1151 let mut upgrade = upgrade_link(
1152 id,
1153 "sha256:v2",
1154 0x14,
1155 &sk,
1156 chrono::Utc::now() + Duration::days(7),
1157 );
1158 upgrade.id = Some(Uuid::new_v4());
1159 upgrade.sequence = Some(1);
1160 chain.push(upgrade);
1161
1162 let reboot = boot_link(id, "sha256:v1", 0x14);
1163 let outcome = validate_chain_link(
1164 &reboot,
1165 &ctx(&pcrs, "sha256:v1", Some(&pk), true, &chain),
1166 chrono::Utc::now(),
1167 true,
1168 )
1169 .unwrap();
1170 assert_eq!(outcome, Outcome::Dedup);
1171 }
1172
1173 #[test]
1174 fn non_upgradable_rejects_second_boot_with_new_digest() {
1175 let pcrs = pcrs_hex_from_seed(0x15);
1176 let id = Uuid::new_v4();
1177 let mut genesis = boot_link(id, "sha256:old", 0x15);
1178 genesis.id = Some(Uuid::new_v4());
1179 genesis.sequence = Some(0);
1180
1181 let reboot = boot_link(id, "sha256:new", 0x15);
1182 let err = validate_chain_link(
1183 &reboot,
1184 &ctx(
1185 &pcrs,
1186 "sha256:new",
1187 None,
1188 false,
1189 std::slice::from_ref(&genesis),
1190 ),
1191 chrono::Utc::now(),
1192 true,
1193 )
1194 .unwrap_err();
1195 assert!(matches!(err, ChainValidationError::NonUpgradableSecondBoot));
1196 }
1197
1198 #[test]
1199 fn upgrade_rejects_on_non_upgradable() {
1200 let pcrs = pcrs_hex_from_seed(0x16);
1201 let (sk, _) = keypair();
1202 let id = Uuid::new_v4();
1203 let link = upgrade_link(
1204 id,
1205 "sha256:v2",
1206 0x16,
1207 &sk,
1208 chrono::Utc::now() + Duration::days(7),
1209 );
1210 let err = validate_chain_link(
1211 &link,
1212 &ctx(&pcrs, "sha256:v1", None, false, &[]),
1213 chrono::Utc::now(),
1214 true,
1215 )
1216 .unwrap_err();
1217 assert!(matches!(
1218 err,
1219 ChainValidationError::NonUpgradableSigned(ChainLinkKind::Upgrade)
1220 ));
1221 }
1222
1223 #[test]
1224 fn upgrade_rejects_bad_signature() {
1225 let pcrs = pcrs_hex_from_seed(0x17);
1226 let (_sk, pk) = keypair();
1227 let other_seed: [u8; 32] = core::array::from_fn(|i| (i + 99) as u8);
1229 let other_sk = SigningKey::from_slice(&other_seed).unwrap();
1230 let id = Uuid::new_v4();
1231 let mut genesis = boot_link(id, "sha256:v1", 0x17);
1232 genesis.id = Some(Uuid::new_v4());
1233 genesis.sequence = Some(0);
1234
1235 let link = upgrade_link(
1236 id,
1237 "sha256:v2",
1238 0x17,
1239 &other_sk,
1240 chrono::Utc::now() + Duration::days(7),
1241 );
1242 let err = validate_chain_link(
1243 &link,
1244 &ctx(
1245 &pcrs,
1246 "sha256:v1",
1247 Some(&pk),
1248 true,
1249 std::slice::from_ref(&genesis),
1250 ),
1251 chrono::Utc::now(),
1252 true,
1253 )
1254 .unwrap_err();
1255 assert!(matches!(err, ChainValidationError::SignatureInvalid));
1256 }
1257
1258 #[test]
1259 fn upgrade_rejects_without_genesis() {
1260 let pcrs = pcrs_hex_from_seed(0x18);
1261 let (sk, pk) = keypair();
1262 let id = Uuid::new_v4();
1263 let link = upgrade_link(
1264 id,
1265 "sha256:v2",
1266 0x18,
1267 &sk,
1268 chrono::Utc::now() + Duration::days(7),
1269 );
1270 let err = validate_chain_link(
1271 &link,
1272 &ctx(&pcrs, "sha256:v1", Some(&pk), true, &[]),
1273 chrono::Utc::now(),
1274 true,
1275 )
1276 .unwrap_err();
1277 assert!(matches!(err, ChainValidationError::NoGenesisYet));
1278 }
1279
1280 #[test]
1281 fn upgrade_appends_after_genesis() {
1282 let pcrs = pcrs_hex_from_seed(0x19);
1283 let (sk, pk) = keypair();
1284 let id = Uuid::new_v4();
1285 let mut genesis = boot_link(id, "sha256:v1", 0x19);
1286 genesis.id = Some(Uuid::new_v4());
1287 genesis.sequence = Some(0);
1288
1289 let link = upgrade_link(
1290 id,
1291 "sha256:v2",
1292 0x19,
1293 &sk,
1294 chrono::Utc::now() + Duration::days(7),
1295 );
1296 let outcome = validate_chain_link(
1297 &link,
1298 &ctx(
1299 &pcrs,
1300 "sha256:v1",
1301 Some(&pk),
1302 true,
1303 std::slice::from_ref(&genesis),
1304 ),
1305 chrono::Utc::now(),
1306 true,
1307 )
1308 .unwrap();
1309 assert_eq!(outcome, Outcome::Append { sequence: 1 });
1310 }
1311
1312 #[test]
1313 fn revocation_succeeds_against_pending_upgrade() {
1314 let pcrs = pcrs_hex_from_seed(0x1a);
1315 let (sk, pk) = keypair();
1316 let id = Uuid::new_v4();
1317 let mut genesis = boot_link(id, "sha256:v1", 0x1a);
1318 genesis.id = Some(Uuid::new_v4());
1319 genesis.sequence = Some(0);
1320 let mut upgrade = upgrade_link(
1321 id,
1322 "sha256:v2",
1323 0x1a,
1324 &sk,
1325 chrono::Utc::now() + Duration::days(7),
1326 );
1327 upgrade.id = Some(Uuid::new_v4());
1328 upgrade.sequence = Some(1);
1329 let chain = vec![genesis, upgrade.clone()];
1330
1331 let link = revocation_link(id, upgrade.id.unwrap(), 0x1a, &sk);
1332 let outcome = validate_chain_link(
1333 &link,
1334 &ctx(&pcrs, "sha256:v1", Some(&pk), true, &chain),
1335 chrono::Utc::now(),
1336 true,
1337 )
1338 .unwrap();
1339 assert_eq!(outcome, Outcome::Append { sequence: 2 });
1340 }
1341
1342 #[test]
1343 fn revocation_rejects_unknown_target() {
1344 let pcrs = pcrs_hex_from_seed(0x1b);
1345 let (sk, pk) = keypair();
1346 let id = Uuid::new_v4();
1347 let mut genesis = boot_link(id, "sha256:v1", 0x1b);
1348 genesis.id = Some(Uuid::new_v4());
1349 genesis.sequence = Some(0);
1350 let chain = vec![genesis];
1351
1352 let link = revocation_link(id, Uuid::new_v4(), 0x1b, &sk);
1353 let err = validate_chain_link(
1354 &link,
1355 &ctx(&pcrs, "sha256:v1", Some(&pk), true, &chain),
1356 chrono::Utc::now(),
1357 true,
1358 )
1359 .unwrap_err();
1360 assert!(matches!(err, ChainValidationError::RevokeTargetMissing));
1361 }
1362
1363 #[test]
1364 fn revocation_rejects_past_activation() {
1365 let pcrs = pcrs_hex_from_seed(0x1c);
1366 let (sk, pk) = keypair();
1367 let id = Uuid::new_v4();
1368 let mut genesis = boot_link(id, "sha256:v1", 0x1c);
1369 genesis.id = Some(Uuid::new_v4());
1370 genesis.sequence = Some(0);
1371 let mut upgrade = upgrade_link(
1372 id,
1373 "sha256:v2",
1374 0x1c,
1375 &sk,
1376 chrono::Utc::now() - Duration::seconds(1),
1377 );
1378 upgrade.id = Some(Uuid::new_v4());
1379 upgrade.sequence = Some(1);
1380 let chain = vec![genesis, upgrade.clone()];
1381
1382 let link = revocation_link(id, upgrade.id.unwrap(), 0x1c, &sk);
1383 let err = validate_chain_link(
1384 &link,
1385 &ctx(&pcrs, "sha256:v1", Some(&pk), true, &chain),
1386 chrono::Utc::now(),
1387 true,
1388 )
1389 .unwrap_err();
1390 assert!(matches!(err, ChainValidationError::RevokePastActivation));
1391 }
1392
1393 #[test]
1394 fn revocation_rejects_double_revoke() {
1395 let pcrs = pcrs_hex_from_seed(0x1d);
1396 let (sk, pk) = keypair();
1397 let id = Uuid::new_v4();
1398 let mut genesis = boot_link(id, "sha256:v1", 0x1d);
1399 genesis.id = Some(Uuid::new_v4());
1400 genesis.sequence = Some(0);
1401 let mut upgrade = upgrade_link(
1402 id,
1403 "sha256:v2",
1404 0x1d,
1405 &sk,
1406 chrono::Utc::now() + Duration::days(7),
1407 );
1408 upgrade.id = Some(Uuid::new_v4());
1409 upgrade.sequence = Some(1);
1410 let mut prior_revoke = revocation_link(id, upgrade.id.unwrap(), 0x1d, &sk);
1411 prior_revoke.id = Some(Uuid::new_v4());
1412 prior_revoke.sequence = Some(2);
1413 let chain = vec![genesis, upgrade.clone(), prior_revoke];
1414
1415 let link = revocation_link(id, upgrade.id.unwrap(), 0x1d, &sk);
1416 let err = validate_chain_link(
1417 &link,
1418 &ctx(&pcrs, "sha256:v1", Some(&pk), true, &chain),
1419 chrono::Utc::now(),
1420 true,
1421 )
1422 .unwrap_err();
1423 assert!(matches!(err, ChainValidationError::AlreadyRevoked));
1424 }
1425
1426 fn transition_upgrade_link(
1436 enclave_id: Uuid,
1437 target_digest: &str,
1438 from_seed: u8,
1439 to_seed: u8,
1440 signing: &SigningKey,
1441 valid_from: DateTime<Utc>,
1442 ) -> ChainLink {
1443 let payload = UpgradePayload {
1444 enclave_id,
1445 from_pcrs: pcrs_hex_from_seed(from_seed),
1446 to_pcrs: pcrs_hex_from_seed(to_seed),
1447 image_digest: target_digest.into(),
1448 valid_from,
1449 issued_at: chrono::Utc::now(),
1450 nonce: vec![0x45; 32],
1451 };
1452 let mut payload_bytes = Vec::new();
1453 ciborium::into_writer(&payload, &mut payload_bytes).unwrap();
1454 let attestation = FakeChainAttestation::for_payload(from_seed, &payload_bytes).encode();
1455 let sig: Signature = signing.sign(&payload_bytes);
1456 ChainLink {
1457 id: None,
1458 sequence: None,
1459 kind: ChainLinkKind::Upgrade,
1460 payload: payload_bytes,
1461 attestation,
1462 signature: Some(sig.to_bytes().to_vec()),
1463 }
1464 }
1465
1466 fn recorded(link: ChainLink, at: DateTime<Utc>) -> RecordedLink {
1467 RecordedLink {
1468 link,
1469 recorded_at: Some(at),
1470 }
1471 }
1472
1473 #[test]
1479 fn walk_validates_promoted_history() {
1480 let (sk, pk) = keypair();
1481 let id = Uuid::new_v4();
1482 let now = chrono::Utc::now();
1483
1484 let mut genesis = boot_link(id, "sha256:v1", 0x20);
1485 genesis.id = Some(Uuid::new_v4());
1486 genesis.sequence = Some(0);
1487 let mut upgrade = transition_upgrade_link(
1488 id,
1489 "sha256:v2",
1490 0x20,
1491 0x30,
1492 &sk,
1493 now - Duration::minutes(10),
1494 );
1495 upgrade.id = Some(Uuid::new_v4());
1496 upgrade.sequence = Some(1);
1497 let mut promo = boot_link(id, "sha256:v2", 0x30);
1498 promo.id = Some(Uuid::new_v4());
1499 promo.sequence = Some(2);
1500
1501 let links = vec![
1502 recorded(genesis, now - Duration::hours(2)),
1503 recorded(upgrade, now - Duration::minutes(11)),
1504 recorded(promo, now - Duration::minutes(9)),
1505 ];
1506 let row_pcrs = pcrs_hex_from_seed(0x30);
1507 let walk = validate_chain(&links, &row_pcrs, "sha256:v2", Some(&pk), true, now, true);
1508
1509 for (i, outcome) in walk.outcomes.iter().enumerate() {
1510 assert!(
1511 matches!(outcome, Ok(Outcome::Append { sequence }) if *sequence == i as u64),
1512 "link {i}: {outcome:?}"
1513 );
1514 }
1515 assert!(walk.tip_matches_row);
1516 assert_eq!(walk.final_pcrs, Some(row_pcrs));
1517 assert_eq!(walk.final_image_digest, Some("sha256:v2".into()));
1518 }
1519
1520 #[test]
1524 fn walk_rejects_unexplained_transition_boot() {
1525 let id = Uuid::new_v4();
1526 let now = chrono::Utc::now();
1527
1528 let mut genesis = boot_link(id, "sha256:v1", 0x21);
1529 genesis.id = Some(Uuid::new_v4());
1530 genesis.sequence = Some(0);
1531 let mut rogue = boot_link(id, "sha256:v2", 0x31);
1532 rogue.id = Some(Uuid::new_v4());
1533 rogue.sequence = Some(1);
1534
1535 let links = vec![
1536 recorded(genesis, now - Duration::hours(1)),
1537 recorded(rogue, now - Duration::minutes(5)),
1538 ];
1539 let row_pcrs = pcrs_hex_from_seed(0x31);
1540 let walk = validate_chain(
1541 &links,
1542 &row_pcrs,
1543 "sha256:v2",
1544 Some(&[4u8; 65]),
1545 true,
1546 now,
1547 true,
1548 );
1549
1550 assert!(matches!(
1551 walk.outcomes[0],
1552 Ok(Outcome::Append { sequence: 0 })
1553 ));
1554 assert!(walk.outcomes[1].is_err(), "{:?}", walk.outcomes[1]);
1555 assert!(!walk.tip_matches_row);
1557 assert_eq!(walk.final_pcrs, Some(pcrs_hex_from_seed(0x21)));
1558 }
1559
1560 #[test]
1563 fn walk_rejects_boot_of_revoked_upgrade() {
1564 let (sk, pk) = keypair();
1565 let id = Uuid::new_v4();
1566 let now = chrono::Utc::now();
1567
1568 let mut genesis = boot_link(id, "sha256:v1", 0x22);
1569 genesis.id = Some(Uuid::new_v4());
1570 genesis.sequence = Some(0);
1571 let mut upgrade =
1573 transition_upgrade_link(id, "sha256:v2", 0x22, 0x32, &sk, now + Duration::days(7));
1574 upgrade.id = Some(Uuid::new_v4());
1575 upgrade.sequence = Some(1);
1576 let mut revoke = revocation_link(id, upgrade.id.unwrap(), 0x22, &sk);
1577 revoke.id = Some(Uuid::new_v4());
1578 revoke.sequence = Some(2);
1579 let mut rogue = boot_link(id, "sha256:v2", 0x32);
1580 rogue.id = Some(Uuid::new_v4());
1581 rogue.sequence = Some(3);
1582
1583 let links = vec![
1584 recorded(genesis, now - Duration::hours(1)),
1585 recorded(upgrade, now - Duration::minutes(30)),
1586 recorded(revoke, now - Duration::minutes(20)),
1587 recorded(rogue, now - Duration::minutes(5)),
1588 ];
1589 let row_pcrs = pcrs_hex_from_seed(0x22);
1590 let walk = validate_chain(&links, &row_pcrs, "sha256:v1", Some(&pk), true, now, true);
1591
1592 assert!(walk.outcomes[0].is_ok());
1593 assert!(walk.outcomes[1].is_ok());
1594 assert!(walk.outcomes[2].is_ok());
1595 assert!(walk.outcomes[3].is_err(), "{:?}", walk.outcomes[3]);
1596 assert!(walk.tip_matches_row);
1598 }
1599
1600 #[test]
1605 fn walk_accepts_historical_revocation_after_target_activation() {
1606 let (sk, pk) = keypair();
1607 let id = Uuid::new_v4();
1608 let now = chrono::Utc::now();
1609
1610 let mut genesis = boot_link(id, "sha256:v1", 0x23);
1611 genesis.id = Some(Uuid::new_v4());
1612 genesis.sequence = Some(0);
1613 let mut upgrade =
1615 transition_upgrade_link(id, "sha256:v2", 0x23, 0x33, &sk, now - Duration::hours(1));
1616 upgrade.id = Some(Uuid::new_v4());
1617 upgrade.sequence = Some(1);
1618 let mut revoke = revocation_link(id, upgrade.id.unwrap(), 0x23, &sk);
1620 revoke.id = Some(Uuid::new_v4());
1621 revoke.sequence = Some(2);
1622
1623 let links = vec![
1624 recorded(genesis, now - Duration::hours(3)),
1625 recorded(upgrade, now - Duration::hours(2)),
1626 recorded(revoke, now - Duration::minutes(90)),
1627 ];
1628 let row_pcrs = pcrs_hex_from_seed(0x23);
1629 let walk = validate_chain(&links, &row_pcrs, "sha256:v1", Some(&pk), true, now, true);
1630
1631 assert!(
1632 walk.outcomes.iter().all(Result::is_ok),
1633 "{:?}",
1634 walk.outcomes
1635 );
1636 assert!(walk.tip_matches_row);
1637
1638 let unstamped: Vec<RecordedLink> = links
1641 .iter()
1642 .map(|r| RecordedLink {
1643 link: r.link.clone(),
1644 recorded_at: None,
1645 })
1646 .collect();
1647 let walk_now = validate_chain(
1648 &unstamped,
1649 &row_pcrs,
1650 "sha256:v1",
1651 Some(&pk),
1652 true,
1653 now,
1654 true,
1655 );
1656 assert!(matches!(
1657 walk_now.outcomes[2],
1658 Err(ChainValidationError::RevokePastActivation)
1659 ));
1660 }
1661
1662 fn two_version_chain(now: DateTime<Utc>) -> (Vec<RecordedLink>, Vec<u8>, u8, u8) {
1669 let (sk, pk) = keypair();
1670 let id = Uuid::new_v4();
1671 let (v1, v2) = (0x20u8, 0x30u8);
1672
1673 let mut genesis = boot_link(id, "sha256:v1", v1);
1674 genesis.id = Some(Uuid::new_v4());
1675 genesis.sequence = Some(0);
1676 let mut upgrade =
1677 transition_upgrade_link(id, "sha256:v2", v1, v2, &sk, now - Duration::minutes(10));
1678 upgrade.id = Some(Uuid::new_v4());
1679 upgrade.sequence = Some(1);
1680 let mut promo = boot_link(id, "sha256:v2", v2);
1681 promo.id = Some(Uuid::new_v4());
1682 promo.sequence = Some(2);
1683
1684 let links = vec![
1685 recorded(genesis, now - Duration::hours(2)),
1686 recorded(upgrade, now - Duration::minutes(11)),
1687 recorded(promo, now - Duration::minutes(9)),
1688 ];
1689 (links, pk, v1, v2)
1690 }
1691
1692 #[test]
1693 fn descent_pinned_genesis_returns_tip() {
1694 let now = chrono::Utc::now();
1695 let (links, pk, v1, v2) = two_version_chain(now);
1696 let row_pcrs = pcrs_hex_from_seed(v2);
1697 let pinned = pcrs_hex_from_seed(v1).to_pcrs().unwrap();
1698
1699 let tip = verify_pcr_descent(
1700 &pinned,
1701 &links,
1702 Some(&pk),
1703 &row_pcrs,
1704 "sha256:v2",
1705 true,
1706 now,
1707 true,
1708 )
1709 .unwrap();
1710 assert_eq!(tip, pcrs_hex_from_seed(v2).to_pcrs().unwrap());
1712 }
1713
1714 #[test]
1715 fn descent_pinned_current_tip_returns_tip() {
1716 let now = chrono::Utc::now();
1719 let (links, pk, _v1, v2) = two_version_chain(now);
1720 let row_pcrs = pcrs_hex_from_seed(v2);
1721 let pinned = pcrs_hex_from_seed(v2).to_pcrs().unwrap();
1722
1723 let tip = verify_pcr_descent(
1724 &pinned, &links, Some(&pk), &row_pcrs, "sha256:v2", true, now, true,
1725 )
1726 .unwrap();
1727 assert_eq!(tip, pinned);
1728 }
1729
1730 #[test]
1731 fn descent_rejects_pin_not_in_chain() {
1732 let now = chrono::Utc::now();
1736 let (links, pk, _v1, v2) = two_version_chain(now);
1737 let row_pcrs = pcrs_hex_from_seed(v2);
1738 let stranger = pcrs_hex_from_seed(0x77).to_pcrs().unwrap();
1739
1740 let err = verify_pcr_descent(
1741 &stranger, &links, Some(&pk), &row_pcrs, "sha256:v2", true, now, true,
1742 )
1743 .unwrap_err();
1744 assert!(matches!(err, PcrDescentError::PinnedNotInLineage));
1745 }
1746
1747 #[test]
1748 fn descent_rejects_tampered_link() {
1749 let now = chrono::Utc::now();
1753 let (mut links, pk, v1, v2) = two_version_chain(now);
1754 links[1].link.payload[0] ^= 0xff;
1755 let row_pcrs = pcrs_hex_from_seed(v2);
1756 let pinned = pcrs_hex_from_seed(v1).to_pcrs().unwrap();
1757
1758 let err = verify_pcr_descent(
1759 &pinned, &links, Some(&pk), &row_pcrs, "sha256:v2", true, now, true,
1760 )
1761 .unwrap_err();
1762 assert!(matches!(
1763 err,
1764 PcrDescentError::LinkInvalid { position: 1, .. }
1765 ));
1766 }
1767
1768 #[test]
1769 fn descent_rejects_unsigned_promotion() {
1770 let now = chrono::Utc::now();
1775 let (_sk, pk) = keypair();
1776 let id = Uuid::new_v4();
1777 let (v1, v2) = (0x20u8, 0x30u8);
1778
1779 let mut genesis = boot_link(id, "sha256:v1", v1);
1780 genesis.id = Some(Uuid::new_v4());
1781 genesis.sequence = Some(0);
1782 let mut rogue = boot_link(id, "sha256:v2", v2);
1783 rogue.id = Some(Uuid::new_v4());
1784 rogue.sequence = Some(1);
1785 let links = vec![
1786 recorded(genesis, now - Duration::hours(1)),
1787 recorded(rogue, now - Duration::minutes(1)),
1788 ];
1789 let pinned = pcrs_hex_from_seed(v1).to_pcrs().unwrap();
1790
1791 let err = verify_pcr_descent(
1792 &pinned,
1793 &links,
1794 Some(&pk),
1795 &pcrs_hex_from_seed(v1),
1796 "sha256:v1",
1797 true,
1798 now,
1799 true,
1800 )
1801 .unwrap_err();
1802 assert!(matches!(
1803 err,
1804 PcrDescentError::LinkInvalid { position: 1, .. }
1805 ));
1806 }
1807
1808 #[test]
1809 fn descent_empty_chain_has_no_genesis() {
1810 let now = chrono::Utc::now();
1811 let pinned = pcrs_hex_from_seed(0x20).to_pcrs().unwrap();
1812 let err = verify_pcr_descent(
1813 &pinned,
1814 &[],
1815 None,
1816 &pcrs_hex_from_seed(0x20),
1817 "sha256:v1",
1818 true,
1819 now,
1820 true,
1821 )
1822 .unwrap_err();
1823 assert!(matches!(err, PcrDescentError::NoGenesis));
1824 }
1825
1826 #[test]
1827 fn chain_link_json_round_trips_through_decode() {
1828 let id = Uuid::new_v4();
1832 let raw = boot_link(id, "sha256:v1", 0x20);
1833 let b64 = base64::engine::general_purpose::STANDARD;
1834 let wire = ChainLinkJson {
1835 id: Some(Uuid::new_v4()),
1836 kind: ChainLinkKind::Boot,
1837 sequence: Some(3),
1838 payload: b64.encode(&raw.payload),
1839 attestation: b64.encode(&raw.attestation),
1840 signature: None,
1841 created_at: None,
1842 };
1843 let decoded = wire.into_chain_link().unwrap();
1844 assert_eq!(decoded.payload, raw.payload);
1845 assert_eq!(decoded.attestation, raw.attestation);
1846 assert_eq!(decoded.sequence, Some(3));
1847
1848 let bad = ChainLinkJson {
1849 payload: "not base64!!!".into(),
1850 ..wire.clone()
1851 };
1852 assert!(matches!(
1853 bad.into_chain_link(),
1854 Err(ChainLinkDecodeError::Base64 { field: "payload", .. })
1855 ));
1856
1857 let neg = ChainLinkJson {
1858 sequence: Some(-1),
1859 ..wire
1860 };
1861 assert!(matches!(
1862 neg.into_chain_link(),
1863 Err(ChainLinkDecodeError::NegativeSequence(-1))
1864 ));
1865 }
1866
1867 #[test]
1868 fn enclave_row_parses_array_control_key_and_pcr_casing() {
1869 let row = serde_json::json!({
1870 "upgradable": true,
1871 "image_digest": "sha256:abc",
1872 "pcrs": { "PCR0": "00", "PCR1": "11", "PCR2": "22" },
1873 "control_public_key": [4, 255, 0, 7],
1874 });
1875 let parsed: EnclaveChainRow = serde_json::from_value(row).unwrap();
1876 assert!(parsed.upgradable);
1877 assert_eq!(parsed.image_digest, "sha256:abc");
1878 assert_eq!(parsed.pcrs.pcr0, "00");
1879 assert_eq!(parsed.control_public_key, Some(vec![4u8, 255, 0, 7]));
1880 }
1881
1882 #[test]
1883 fn enclave_row_accepts_base64_control_key_and_lowercase_pcrs() {
1884 let key = vec![4u8, 1, 2, 3];
1885 let row = serde_json::json!({
1886 "upgradable": true,
1887 "image_digest": "sha256:abc",
1888 "pcrs": { "pcr0": "aa", "pcr1": "bb", "pcr2": "cc" },
1889 "control_public_key": base64::engine::general_purpose::STANDARD.encode(&key),
1890 });
1891 let parsed: EnclaveChainRow = serde_json::from_value(row).unwrap();
1892 assert_eq!(parsed.pcrs.pcr1, "bb");
1893 assert_eq!(parsed.control_public_key, Some(key));
1894 }
1895
1896 #[test]
1897 fn enclave_row_null_control_key_is_non_upgradable_and_upgradable_defaults_false() {
1898 let row = serde_json::json!({
1899 "image_digest": "sha256:abc",
1900 "pcrs": { "PCR0": "00", "PCR1": "11", "PCR2": "22" },
1901 "control_public_key": null,
1902 });
1903 let parsed: EnclaveChainRow = serde_json::from_value(row).unwrap();
1904 assert_eq!(parsed.control_public_key, None);
1905 assert!(!parsed.upgradable);
1906 }
1907
1908 #[test]
1909 fn enclave_row_missing_image_digest_errors() {
1910 let row = serde_json::json!({
1911 "upgradable": true,
1912 "pcrs": { "PCR0": "00", "PCR1": "11", "PCR2": "22" },
1913 });
1914 assert!(serde_json::from_value::<EnclaveChainRow>(row).is_err());
1915 }
1916}