use base64::Engine as _;
use chrono::{DateTime, Utc};
use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::attestation::{AttestationError, Pcrs, verify_chain_attestation};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChainLinkKind {
Boot,
Upgrade,
Revocation,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChainLink {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<Uuid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sequence: Option<u64>,
pub kind: ChainLinkKind,
#[serde(with = "serde_bytes")]
pub payload: Vec<u8>,
#[serde(with = "serde_bytes")]
pub attestation: Vec<u8>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "serde_bytes_opt"
)]
pub signature: Option<Vec<u8>>,
}
mod serde_bytes_opt {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, ser: S) -> Result<S::Ok, S::Error> {
match v {
Some(bytes) => serde_bytes::Bytes::new(bytes).serialize(ser),
None => ser.serialize_none(),
}
}
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Option<Vec<u8>>, D::Error> {
Option::<serde_bytes::ByteBuf>::deserialize(de).map(|o| o.map(|b| b.into_vec()))
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ChainLinkJson {
#[serde(default)]
pub id: Option<uuid::Uuid>,
pub kind: ChainLinkKind,
#[serde(default)]
pub sequence: Option<i64>,
pub payload: String,
pub attestation: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<String>,
#[serde(default)]
pub created_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootPayload {
pub enclave_id: Uuid,
pub image_digest: String,
pub pcrs: PcrsHex,
pub booted_at: DateTime<Utc>,
#[serde(with = "serde_bytes")]
pub nonce: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UpgradePayload {
pub enclave_id: Uuid,
pub from_pcrs: PcrsHex,
pub to_pcrs: PcrsHex,
pub image_digest: String,
pub valid_from: DateTime<Utc>,
pub issued_at: DateTime<Utc>,
#[serde(with = "serde_bytes")]
pub nonce: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevocationPayload {
pub enclave_id: Uuid,
pub revokes: Uuid,
pub issued_at: DateTime<Utc>,
#[serde(with = "serde_bytes")]
pub nonce: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum ChainLinkDecodeError {
#[error("chain link `{field}` is not valid base64: {source}")]
Base64 {
field: &'static str,
#[source]
source: base64::DecodeError,
},
#[error("chain link has a negative sequence {0}")]
NegativeSequence(i64),
}
impl ChainLinkJson {
pub fn into_chain_link(&self) -> Result<ChainLink, ChainLinkDecodeError> {
let b64 = base64::engine::general_purpose::STANDARD;
let decode = |field: &'static str, s: &str| {
b64.decode(s.as_bytes())
.map_err(|source| ChainLinkDecodeError::Base64 { field, source })
};
let payload = decode("payload", &self.payload)?;
let attestation = decode("attestation", &self.attestation)?;
let signature = match self.signature.as_deref() {
Some(s) => Some(decode("signature", s)?),
None => None,
};
let sequence = match self.sequence {
Some(s) => {
Some(u64::try_from(s).map_err(|_| ChainLinkDecodeError::NegativeSequence(s))?)
}
None => None,
};
Ok(ChainLink {
id: self.id,
sequence,
kind: self.kind,
payload,
attestation,
signature,
})
}
pub fn into_recorded_link(&self) -> Result<RecordedLink, ChainLinkDecodeError> {
Ok(RecordedLink {
link: self.into_chain_link()?,
recorded_at: self.created_at,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PcrsHex {
#[serde(rename = "PCR0", alias = "pcr0")]
pub pcr0: String,
#[serde(rename = "PCR1", alias = "pcr1")]
pub pcr1: String,
#[serde(rename = "PCR2", alias = "pcr2")]
pub pcr2: String,
}
impl PcrsHex {
pub fn to_pcrs(&self) -> Result<Pcrs, ChainValidationError> {
let pcr0 =
hex::decode(&self.pcr0).map_err(|_| ChainValidationError::CorruptStoredPcrHex(0))?;
let pcr1 =
hex::decode(&self.pcr1).map_err(|_| ChainValidationError::CorruptStoredPcrHex(1))?;
let pcr2 =
hex::decode(&self.pcr2).map_err(|_| ChainValidationError::CorruptStoredPcrHex(2))?;
Ok(Pcrs { pcr0, pcr1, pcr2 })
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct EnclaveChainRow {
pub pcrs: PcrsHex,
pub image_digest: String,
#[serde(default, deserialize_with = "deserialize_control_key")]
pub control_public_key: Option<Vec<u8>>,
#[serde(default)]
pub upgradable: bool,
}
fn deserialize_control_key<'de, D>(de: D) -> Result<Option<Vec<u8>>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Bytes(Vec<u8>),
Base64(String),
}
match Option::<Raw>::deserialize(de)? {
None => Ok(None),
Some(Raw::Bytes(bytes)) => Ok(Some(bytes)),
Some(Raw::Base64(s)) => base64::engine::general_purpose::STANDARD
.decode(s.as_bytes())
.map(Some)
.map_err(serde::de::Error::custom),
}
}
pub struct ChainContext<'a> {
pub enclave_pcrs: &'a PcrsHex,
pub enclave_image_digest: &'a str,
pub control_public_key: Option<&'a [u8]>,
pub upgradable: bool,
pub prior_chain: &'a [ChainLink],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Append {
sequence: u64,
},
Dedup,
}
#[derive(Debug, thiserror::Error)]
pub enum ChainValidationError {
#[error("{0}")]
Attestation(#[from] AttestationError),
#[error("attestation must be present")]
EmptyAttestation,
#[error("{kind:?} payload not CBOR-decodable: {msg}")]
PayloadDecode { kind: ChainLinkKind, msg: String },
#[error("payload enclave_id does not match the URL")]
EnclaveIdMismatch,
#[error("boot payload PCRs do not match the enclave's recorded PCRs")]
PcrMismatch,
#[error("boot payload image_digest does not match the enclave's pinned digest")]
ImageDigestMismatch,
#[error("boot link must not carry a signature")]
BootHasSignature,
#[error("{0:?} link must carry a signature")]
SignatureMissing(ChainLinkKind),
#[error("stored control_public_key does not decode as SEC1 P-256: {0}")]
BadControlPubkey(String),
#[error("signature is not 64 bytes raw r||s ECDSA P-256")]
SignatureShape,
#[error("signature does not verify under the enclave's control_public_key")]
SignatureInvalid,
#[error("non-upgradable enclaves cannot record {0:?} links")]
NonUpgradableSigned(ChainLinkKind),
#[error("non-upgradable enclaves cannot record a second boot")]
NonUpgradableSecondBoot,
#[error("first chain entry must be a boot — no upgrade or revocation can precede the genesis")]
NoGenesisYet,
#[error("revocation `revokes` does not reference any chain entry on this enclave")]
RevokeTargetMissing,
#[error("revocation can only target an upgrade entry, not a {0:?}")]
RevokeTargetWrongKind(ChainLinkKind),
#[error("revocation is past the upgrade's valid_from; pre-activation revoke only")]
RevokePastActivation,
#[error("upgrade has already been revoked")]
AlreadyRevoked,
#[error("stored {0:?} payload corrupt: {1}")]
CorruptStoredPayload(ChainLinkKind, String),
#[error("stored PCR {0} is not valid hex")]
CorruptStoredPcrHex(usize),
}
pub fn validate_chain_link(
link: &ChainLink,
ctx: &ChainContext<'_>,
now: DateTime<Utc>,
debug_mode: bool,
) -> Result<Outcome, ChainValidationError> {
if link.attestation.is_empty() {
return Err(ChainValidationError::EmptyAttestation);
}
let recorded_pcrs = ctx.enclave_pcrs.to_pcrs()?;
verify_chain_attestation(&link.attestation, &link.payload, &recorded_pcrs, debug_mode)?;
match link.kind {
ChainLinkKind::Boot => validate_boot(link, ctx),
ChainLinkKind::Upgrade | ChainLinkKind::Revocation => validate_signed(link, ctx, now),
}
}
fn validate_boot(
link: &ChainLink,
ctx: &ChainContext<'_>,
) -> Result<Outcome, ChainValidationError> {
if link.signature.is_some() {
return Err(ChainValidationError::BootHasSignature);
}
let parsed: BootPayload = ciborium::from_reader(link.payload.as_slice()).map_err(|e| {
ChainValidationError::PayloadDecode {
kind: ChainLinkKind::Boot,
msg: e.to_string(),
}
})?;
if parsed.pcrs != *ctx.enclave_pcrs {
return Err(ChainValidationError::PcrMismatch);
}
if parsed.image_digest != ctx.enclave_image_digest {
return Err(ChainValidationError::ImageDigestMismatch);
}
let last_boot = ctx
.prior_chain
.iter()
.rev()
.find(|l| l.kind == ChainLinkKind::Boot);
match last_boot {
None => {
Ok(Outcome::Append {
sequence: ctx.prior_chain.len() as u64,
})
}
Some(prev) => {
let prev_payload: BootPayload = ciborium::from_reader(prev.payload.as_slice())
.map_err(|e| {
ChainValidationError::CorruptStoredPayload(ChainLinkKind::Boot, e.to_string())
})?;
if prev_payload.image_digest == parsed.image_digest {
return Ok(Outcome::Dedup);
}
if !ctx.upgradable {
return Err(ChainValidationError::NonUpgradableSecondBoot);
}
Ok(Outcome::Append {
sequence: ctx.prior_chain.len() as u64,
})
}
}
}
fn validate_signed(
link: &ChainLink,
ctx: &ChainContext<'_>,
now: DateTime<Utc>,
) -> Result<Outcome, ChainValidationError> {
if !ctx.upgradable {
return Err(ChainValidationError::NonUpgradableSigned(link.kind));
}
let sig_bytes = link
.signature
.as_deref()
.ok_or(ChainValidationError::SignatureMissing(link.kind))?;
let pubkey_bytes = ctx.control_public_key.ok_or_else(|| {
ChainValidationError::BadControlPubkey(
"upgradable enclave is missing control_public_key in context".into(),
)
})?;
let verifying = VerifyingKey::from_sec1_bytes(pubkey_bytes)
.map_err(|e| ChainValidationError::BadControlPubkey(e.to_string()))?;
let sig = Signature::from_slice(sig_bytes).map_err(|_| ChainValidationError::SignatureShape)?;
verifying
.verify(&link.payload, &sig)
.map_err(|_| ChainValidationError::SignatureInvalid)?;
match link.kind {
ChainLinkKind::Upgrade => {
let _: UpgradePayload =
ciborium::from_reader(link.payload.as_slice()).map_err(|e| {
ChainValidationError::PayloadDecode {
kind: ChainLinkKind::Upgrade,
msg: e.to_string(),
}
})?;
}
ChainLinkKind::Revocation => {
let revoke: RevocationPayload = ciborium::from_reader(link.payload.as_slice())
.map_err(|e| ChainValidationError::PayloadDecode {
kind: ChainLinkKind::Revocation,
msg: e.to_string(),
})?;
let target = ctx
.prior_chain
.iter()
.find(|l| l.id == Some(revoke.revokes))
.ok_or(ChainValidationError::RevokeTargetMissing)?;
if target.kind != ChainLinkKind::Upgrade {
return Err(ChainValidationError::RevokeTargetWrongKind(target.kind));
}
let target_upgrade: UpgradePayload = ciborium::from_reader(target.payload.as_slice())
.map_err(|e| {
ChainValidationError::CorruptStoredPayload(ChainLinkKind::Upgrade, e.to_string())
})?;
if target_upgrade.valid_from <= now {
return Err(ChainValidationError::RevokePastActivation);
}
for existing in ctx.prior_chain {
if existing.kind != ChainLinkKind::Revocation {
continue;
}
let existing_payload: RevocationPayload =
ciborium::from_reader(existing.payload.as_slice()).map_err(|e| {
ChainValidationError::CorruptStoredPayload(
ChainLinkKind::Revocation,
e.to_string(),
)
})?;
if existing_payload.revokes == revoke.revokes {
return Err(ChainValidationError::AlreadyRevoked);
}
}
}
ChainLinkKind::Boot => unreachable!("validate_signed not called for boot"),
};
if ctx.prior_chain.is_empty() {
return Err(ChainValidationError::NoGenesisYet);
}
Ok(Outcome::Append {
sequence: ctx.prior_chain.len() as u64,
})
}
#[derive(Debug, Clone)]
pub struct RecordedLink {
pub link: ChainLink,
pub recorded_at: Option<DateTime<Utc>>,
}
#[derive(Debug)]
pub struct ChainWalk {
pub outcomes: Vec<Result<Outcome, ChainValidationError>>,
pub final_pcrs: Option<PcrsHex>,
pub final_image_digest: Option<String>,
pub tip_matches_row: bool,
}
pub fn validate_chain(
links: &[RecordedLink],
row_pcrs: &PcrsHex,
row_image_digest: &str,
control_public_key: Option<&[u8]>,
upgradable: bool,
now: DateTime<Utc>,
debug_mode: bool,
) -> ChainWalk {
let mut outcomes = Vec::with_capacity(links.len());
let mut prior: Vec<ChainLink> = Vec::with_capacity(links.len());
let mut in_force: Option<(PcrsHex, String)> = None;
for recorded in links {
let link = &recorded.link;
let reference = recorded.recorded_at.unwrap_or(now);
let (ctx_pcrs, ctx_digest, promotes): (PcrsHex, String, bool) = match link.kind {
ChainLinkKind::Boot if prior.is_empty() => {
match ciborium::from_reader::<BootPayload, _>(link.payload.as_slice()) {
Ok(p) => (p.pcrs, p.image_digest, true),
Err(_) => (row_pcrs.clone(), row_image_digest.to_owned(), false),
}
}
ChainLinkKind::Boot => {
match (
ciborium::from_reader::<BootPayload, _>(link.payload.as_slice()),
in_force.as_ref(),
) {
(Ok(p), Some((pcrs, digest))) => {
if p.pcrs == *pcrs {
(pcrs.clone(), digest.clone(), false)
} else if let Some(target) =
promotion_target(&prior, &p.pcrs, &p.image_digest)
{
(target.to_pcrs, target.image_digest, true)
} else {
(pcrs.clone(), digest.clone(), false)
}
}
(_, Some((pcrs, digest))) => (pcrs.clone(), digest.clone(), false),
(_, None) => (row_pcrs.clone(), row_image_digest.to_owned(), false),
}
}
ChainLinkKind::Upgrade | ChainLinkKind::Revocation => match in_force.as_ref() {
Some((pcrs, digest)) => (pcrs.clone(), digest.clone(), false),
None => (row_pcrs.clone(), row_image_digest.to_owned(), false),
},
};
let ctx = ChainContext {
enclave_pcrs: &ctx_pcrs,
enclave_image_digest: &ctx_digest,
control_public_key,
upgradable,
prior_chain: &prior,
};
let outcome = validate_chain_link(link, &ctx, reference, debug_mode);
if promotes && matches!(outcome, Ok(Outcome::Append { .. })) {
in_force = Some((ctx_pcrs, ctx_digest));
}
outcomes.push(outcome);
prior.push(link.clone());
}
let tip_matches_row = in_force
.as_ref()
.is_some_and(|(p, d)| p == row_pcrs && d == row_image_digest);
let (final_pcrs, final_image_digest) = match in_force {
Some((p, d)) => (Some(p), Some(d)),
None => (None, None),
};
ChainWalk {
outcomes,
final_pcrs,
final_image_digest,
tip_matches_row,
}
}
fn promotion_target(
prior: &[ChainLink],
boot_pcrs: &PcrsHex,
boot_image_digest: &str,
) -> Option<UpgradePayload> {
let revoked: Vec<Uuid> = prior
.iter()
.filter(|l| l.kind == ChainLinkKind::Revocation)
.filter_map(|l| ciborium::from_reader::<RevocationPayload, _>(l.payload.as_slice()).ok())
.map(|p| p.revokes)
.collect();
prior
.iter()
.rev()
.filter(|l| l.kind == ChainLinkKind::Upgrade)
.filter(|l| l.id.is_none_or(|id| !revoked.contains(&id)))
.filter_map(|l| ciborium::from_reader::<UpgradePayload, _>(l.payload.as_slice()).ok())
.find(|p| p.to_pcrs == *boot_pcrs && p.image_digest == boot_image_digest)
}
#[derive(Debug, thiserror::Error)]
pub enum PcrDescentError {
#[error("chain has no valid genesis boot")]
NoGenesis,
#[error("chain link at position {position} failed validation: {source}")]
LinkInvalid {
position: usize,
#[source]
source: ChainValidationError,
},
#[error("pinned PCRs are not an in-force state anywhere on this chain")]
PinnedNotInLineage,
#[error("validated boot link at position {0} has an unreadable payload")]
BootPayloadUnreadable(usize),
}
#[allow(clippy::too_many_arguments)]
pub fn verify_pcr_descent(
pinned: &Pcrs,
links: &[RecordedLink],
control_public_key: Option<&[u8]>,
row_pcrs: &PcrsHex,
row_image_digest: &str,
upgradable: bool,
now: DateTime<Utc>,
debug_mode: bool,
) -> Result<Pcrs, PcrDescentError> {
let ChainWalk {
outcomes,
final_pcrs,
..
} = validate_chain(
links,
row_pcrs,
row_image_digest,
control_public_key,
upgradable,
now,
debug_mode,
);
for (position, outcome) in outcomes.into_iter().enumerate() {
outcome.map_err(|source| PcrDescentError::LinkInvalid { position, source })?;
}
let tip = final_pcrs
.ok_or(PcrDescentError::NoGenesis)?
.to_pcrs()
.map_err(|_| PcrDescentError::BootPayloadUnreadable(0))?;
let mut pinned_in_lineage = false;
for (position, recorded) in links.iter().enumerate() {
if recorded.link.kind != ChainLinkKind::Boot {
continue;
}
let payload: BootPayload = ciborium::from_reader(recorded.link.payload.as_slice())
.map_err(|_| PcrDescentError::BootPayloadUnreadable(position))?;
let state = payload
.pcrs
.to_pcrs()
.map_err(|_| PcrDescentError::BootPayloadUnreadable(position))?;
if &state == pinned {
pinned_in_lineage = true;
}
}
if !pinned_in_lineage {
return Err(PcrDescentError::PinnedNotInLineage);
}
Ok(tip)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::attestation::test_utils::FakeChainAttestation;
use chrono::Duration;
use p256::ecdsa::{SigningKey, signature::Signer};
fn pcrs_hex_from_seed(seed: u8) -> PcrsHex {
PcrsHex {
pcr0: hex::encode(vec![seed; 48]),
pcr1: hex::encode(vec![seed.wrapping_add(1); 48]),
pcr2: hex::encode(vec![seed.wrapping_add(2); 48]),
}
}
fn keypair() -> (SigningKey, Vec<u8>) {
let seed: [u8; 32] = core::array::from_fn(|i| (i + 1) as u8);
let sk = SigningKey::from_slice(&seed).unwrap();
let pk = sk
.verifying_key()
.to_encoded_point(false)
.as_bytes()
.to_vec();
(sk, pk)
}
fn boot_link(enclave_id: Uuid, image_digest: &str, pcr_seed: u8) -> ChainLink {
let payload = BootPayload {
enclave_id,
image_digest: image_digest.into(),
pcrs: pcrs_hex_from_seed(pcr_seed),
booted_at: chrono::Utc::now(),
nonce: vec![0x42; 32],
};
let mut payload_bytes = Vec::new();
ciborium::into_writer(&payload, &mut payload_bytes).unwrap();
let attestation = FakeChainAttestation::for_payload(pcr_seed, &payload_bytes).encode();
ChainLink {
id: None,
sequence: None,
kind: ChainLinkKind::Boot,
payload: payload_bytes,
attestation,
signature: None,
}
}
fn upgrade_link(
enclave_id: Uuid,
image_digest: &str,
pcr_seed: u8,
signing: &SigningKey,
valid_from: DateTime<Utc>,
) -> ChainLink {
let pcrs = pcrs_hex_from_seed(pcr_seed);
let payload = UpgradePayload {
enclave_id,
from_pcrs: pcrs.clone(),
to_pcrs: pcrs,
image_digest: image_digest.into(),
valid_from,
issued_at: chrono::Utc::now(),
nonce: vec![0x43; 32],
};
let mut payload_bytes = Vec::new();
ciborium::into_writer(&payload, &mut payload_bytes).unwrap();
let attestation = FakeChainAttestation::for_payload(pcr_seed, &payload_bytes).encode();
let sig: Signature = signing.sign(&payload_bytes);
ChainLink {
id: None,
sequence: None,
kind: ChainLinkKind::Upgrade,
payload: payload_bytes,
attestation,
signature: Some(sig.to_bytes().to_vec()),
}
}
fn revocation_link(
enclave_id: Uuid,
revokes: Uuid,
pcr_seed: u8,
signing: &SigningKey,
) -> ChainLink {
let payload = RevocationPayload {
enclave_id,
revokes,
issued_at: chrono::Utc::now(),
nonce: vec![0x44; 32],
};
let mut payload_bytes = Vec::new();
ciborium::into_writer(&payload, &mut payload_bytes).unwrap();
let attestation = FakeChainAttestation::for_payload(pcr_seed, &payload_bytes).encode();
let sig: Signature = signing.sign(&payload_bytes);
ChainLink {
id: None,
sequence: None,
kind: ChainLinkKind::Revocation,
payload: payload_bytes,
attestation,
signature: Some(sig.to_bytes().to_vec()),
}
}
fn ctx<'a>(
pcrs: &'a PcrsHex,
digest: &'a str,
pubkey: Option<&'a [u8]>,
upgradable: bool,
chain: &'a [ChainLink],
) -> ChainContext<'a> {
ChainContext {
enclave_pcrs: pcrs,
enclave_image_digest: digest,
control_public_key: pubkey,
upgradable,
prior_chain: chain,
}
}
#[test]
fn boot_genesis_appends_at_zero() {
let pcrs = pcrs_hex_from_seed(0x10);
let id = Uuid::new_v4();
let link = boot_link(id, "sha256:aaa", 0x10);
let outcome = validate_chain_link(
&link,
&ctx(&pcrs, "sha256:aaa", None, false, &[]),
chrono::Utc::now(),
true,
)
.unwrap();
assert_eq!(outcome, Outcome::Append { sequence: 0 });
}
#[test]
fn boot_rejects_pcr_mismatch() {
let pcrs = pcrs_hex_from_seed(0x11);
let id = Uuid::new_v4();
let link = boot_link(id, "sha256:aaa", 0x99);
let err = validate_chain_link(
&link,
&ctx(&pcrs, "sha256:aaa", None, false, &[]),
chrono::Utc::now(),
true,
)
.unwrap_err();
assert!(matches!(err, ChainValidationError::Attestation(_)));
}
#[test]
fn boot_rejects_image_digest_mismatch() {
let pcrs = pcrs_hex_from_seed(0x12);
let id = Uuid::new_v4();
let link = boot_link(id, "sha256:DIFFERENT", 0x12);
let err = validate_chain_link(
&link,
&ctx(&pcrs, "sha256:aaa", None, false, &[]),
chrono::Utc::now(),
true,
)
.unwrap_err();
assert!(matches!(err, ChainValidationError::ImageDigestMismatch));
}
#[test]
fn boot_dedups_on_same_image_digest() {
let pcrs = pcrs_hex_from_seed(0x13);
let id = Uuid::new_v4();
let first = boot_link(id, "sha256:bbb", 0x13);
let outcome = validate_chain_link(
&first,
&ctx(
&pcrs,
"sha256:bbb",
None,
false,
std::slice::from_ref(&first),
),
chrono::Utc::now(),
true,
)
.unwrap();
assert_eq!(outcome, Outcome::Dedup);
}
#[test]
fn boot_after_pending_upgrade_dedups_against_last_boot() {
let pcrs = pcrs_hex_from_seed(0x14);
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let mut chain = vec![boot_link(id, "sha256:v1", 0x14)];
chain[0].id = Some(Uuid::new_v4());
chain[0].sequence = Some(0);
let mut upgrade = upgrade_link(
id,
"sha256:v2",
0x14,
&sk,
chrono::Utc::now() + Duration::days(7),
);
upgrade.id = Some(Uuid::new_v4());
upgrade.sequence = Some(1);
chain.push(upgrade);
let reboot = boot_link(id, "sha256:v1", 0x14);
let outcome = validate_chain_link(
&reboot,
&ctx(&pcrs, "sha256:v1", Some(&pk), true, &chain),
chrono::Utc::now(),
true,
)
.unwrap();
assert_eq!(outcome, Outcome::Dedup);
}
#[test]
fn non_upgradable_rejects_second_boot_with_new_digest() {
let pcrs = pcrs_hex_from_seed(0x15);
let id = Uuid::new_v4();
let mut genesis = boot_link(id, "sha256:old", 0x15);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let reboot = boot_link(id, "sha256:new", 0x15);
let err = validate_chain_link(
&reboot,
&ctx(
&pcrs,
"sha256:new",
None,
false,
std::slice::from_ref(&genesis),
),
chrono::Utc::now(),
true,
)
.unwrap_err();
assert!(matches!(err, ChainValidationError::NonUpgradableSecondBoot));
}
#[test]
fn upgrade_rejects_on_non_upgradable() {
let pcrs = pcrs_hex_from_seed(0x16);
let (sk, _) = keypair();
let id = Uuid::new_v4();
let link = upgrade_link(
id,
"sha256:v2",
0x16,
&sk,
chrono::Utc::now() + Duration::days(7),
);
let err = validate_chain_link(
&link,
&ctx(&pcrs, "sha256:v1", None, false, &[]),
chrono::Utc::now(),
true,
)
.unwrap_err();
assert!(matches!(
err,
ChainValidationError::NonUpgradableSigned(ChainLinkKind::Upgrade)
));
}
#[test]
fn upgrade_rejects_bad_signature() {
let pcrs = pcrs_hex_from_seed(0x17);
let (_sk, pk) = keypair();
let other_seed: [u8; 32] = core::array::from_fn(|i| (i + 99) as u8);
let other_sk = SigningKey::from_slice(&other_seed).unwrap();
let id = Uuid::new_v4();
let mut genesis = boot_link(id, "sha256:v1", 0x17);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let link = upgrade_link(
id,
"sha256:v2",
0x17,
&other_sk,
chrono::Utc::now() + Duration::days(7),
);
let err = validate_chain_link(
&link,
&ctx(
&pcrs,
"sha256:v1",
Some(&pk),
true,
std::slice::from_ref(&genesis),
),
chrono::Utc::now(),
true,
)
.unwrap_err();
assert!(matches!(err, ChainValidationError::SignatureInvalid));
}
#[test]
fn upgrade_rejects_without_genesis() {
let pcrs = pcrs_hex_from_seed(0x18);
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let link = upgrade_link(
id,
"sha256:v2",
0x18,
&sk,
chrono::Utc::now() + Duration::days(7),
);
let err = validate_chain_link(
&link,
&ctx(&pcrs, "sha256:v1", Some(&pk), true, &[]),
chrono::Utc::now(),
true,
)
.unwrap_err();
assert!(matches!(err, ChainValidationError::NoGenesisYet));
}
#[test]
fn upgrade_appends_after_genesis() {
let pcrs = pcrs_hex_from_seed(0x19);
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let mut genesis = boot_link(id, "sha256:v1", 0x19);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let link = upgrade_link(
id,
"sha256:v2",
0x19,
&sk,
chrono::Utc::now() + Duration::days(7),
);
let outcome = validate_chain_link(
&link,
&ctx(
&pcrs,
"sha256:v1",
Some(&pk),
true,
std::slice::from_ref(&genesis),
),
chrono::Utc::now(),
true,
)
.unwrap();
assert_eq!(outcome, Outcome::Append { sequence: 1 });
}
#[test]
fn revocation_succeeds_against_pending_upgrade() {
let pcrs = pcrs_hex_from_seed(0x1a);
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let mut genesis = boot_link(id, "sha256:v1", 0x1a);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let mut upgrade = upgrade_link(
id,
"sha256:v2",
0x1a,
&sk,
chrono::Utc::now() + Duration::days(7),
);
upgrade.id = Some(Uuid::new_v4());
upgrade.sequence = Some(1);
let chain = vec![genesis, upgrade.clone()];
let link = revocation_link(id, upgrade.id.unwrap(), 0x1a, &sk);
let outcome = validate_chain_link(
&link,
&ctx(&pcrs, "sha256:v1", Some(&pk), true, &chain),
chrono::Utc::now(),
true,
)
.unwrap();
assert_eq!(outcome, Outcome::Append { sequence: 2 });
}
#[test]
fn revocation_rejects_unknown_target() {
let pcrs = pcrs_hex_from_seed(0x1b);
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let mut genesis = boot_link(id, "sha256:v1", 0x1b);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let chain = vec![genesis];
let link = revocation_link(id, Uuid::new_v4(), 0x1b, &sk);
let err = validate_chain_link(
&link,
&ctx(&pcrs, "sha256:v1", Some(&pk), true, &chain),
chrono::Utc::now(),
true,
)
.unwrap_err();
assert!(matches!(err, ChainValidationError::RevokeTargetMissing));
}
#[test]
fn revocation_rejects_past_activation() {
let pcrs = pcrs_hex_from_seed(0x1c);
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let mut genesis = boot_link(id, "sha256:v1", 0x1c);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let mut upgrade = upgrade_link(
id,
"sha256:v2",
0x1c,
&sk,
chrono::Utc::now() - Duration::seconds(1),
);
upgrade.id = Some(Uuid::new_v4());
upgrade.sequence = Some(1);
let chain = vec![genesis, upgrade.clone()];
let link = revocation_link(id, upgrade.id.unwrap(), 0x1c, &sk);
let err = validate_chain_link(
&link,
&ctx(&pcrs, "sha256:v1", Some(&pk), true, &chain),
chrono::Utc::now(),
true,
)
.unwrap_err();
assert!(matches!(err, ChainValidationError::RevokePastActivation));
}
#[test]
fn revocation_rejects_double_revoke() {
let pcrs = pcrs_hex_from_seed(0x1d);
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let mut genesis = boot_link(id, "sha256:v1", 0x1d);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let mut upgrade = upgrade_link(
id,
"sha256:v2",
0x1d,
&sk,
chrono::Utc::now() + Duration::days(7),
);
upgrade.id = Some(Uuid::new_v4());
upgrade.sequence = Some(1);
let mut prior_revoke = revocation_link(id, upgrade.id.unwrap(), 0x1d, &sk);
prior_revoke.id = Some(Uuid::new_v4());
prior_revoke.sequence = Some(2);
let chain = vec![genesis, upgrade.clone(), prior_revoke];
let link = revocation_link(id, upgrade.id.unwrap(), 0x1d, &sk);
let err = validate_chain_link(
&link,
&ctx(&pcrs, "sha256:v1", Some(&pk), true, &chain),
chrono::Utc::now(),
true,
)
.unwrap_err();
assert!(matches!(err, ChainValidationError::AlreadyRevoked));
}
fn transition_upgrade_link(
enclave_id: Uuid,
target_digest: &str,
from_seed: u8,
to_seed: u8,
signing: &SigningKey,
valid_from: DateTime<Utc>,
) -> ChainLink {
let payload = UpgradePayload {
enclave_id,
from_pcrs: pcrs_hex_from_seed(from_seed),
to_pcrs: pcrs_hex_from_seed(to_seed),
image_digest: target_digest.into(),
valid_from,
issued_at: chrono::Utc::now(),
nonce: vec![0x45; 32],
};
let mut payload_bytes = Vec::new();
ciborium::into_writer(&payload, &mut payload_bytes).unwrap();
let attestation = FakeChainAttestation::for_payload(from_seed, &payload_bytes).encode();
let sig: Signature = signing.sign(&payload_bytes);
ChainLink {
id: None,
sequence: None,
kind: ChainLinkKind::Upgrade,
payload: payload_bytes,
attestation,
signature: Some(sig.to_bytes().to_vec()),
}
}
fn recorded(link: ChainLink, at: DateTime<Utc>) -> RecordedLink {
RecordedLink {
link,
recorded_at: Some(at),
}
}
#[test]
fn walk_validates_promoted_history() {
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let now = chrono::Utc::now();
let mut genesis = boot_link(id, "sha256:v1", 0x20);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let mut upgrade = transition_upgrade_link(
id,
"sha256:v2",
0x20,
0x30,
&sk,
now - Duration::minutes(10),
);
upgrade.id = Some(Uuid::new_v4());
upgrade.sequence = Some(1);
let mut promo = boot_link(id, "sha256:v2", 0x30);
promo.id = Some(Uuid::new_v4());
promo.sequence = Some(2);
let links = vec![
recorded(genesis, now - Duration::hours(2)),
recorded(upgrade, now - Duration::minutes(11)),
recorded(promo, now - Duration::minutes(9)),
];
let row_pcrs = pcrs_hex_from_seed(0x30);
let walk = validate_chain(&links, &row_pcrs, "sha256:v2", Some(&pk), true, now, true);
for (i, outcome) in walk.outcomes.iter().enumerate() {
assert!(
matches!(outcome, Ok(Outcome::Append { sequence }) if *sequence == i as u64),
"link {i}: {outcome:?}"
);
}
assert!(walk.tip_matches_row);
assert_eq!(walk.final_pcrs, Some(row_pcrs));
assert_eq!(walk.final_image_digest, Some("sha256:v2".into()));
}
#[test]
fn walk_rejects_unexplained_transition_boot() {
let id = Uuid::new_v4();
let now = chrono::Utc::now();
let mut genesis = boot_link(id, "sha256:v1", 0x21);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let mut rogue = boot_link(id, "sha256:v2", 0x31);
rogue.id = Some(Uuid::new_v4());
rogue.sequence = Some(1);
let links = vec![
recorded(genesis, now - Duration::hours(1)),
recorded(rogue, now - Duration::minutes(5)),
];
let row_pcrs = pcrs_hex_from_seed(0x31);
let walk = validate_chain(
&links,
&row_pcrs,
"sha256:v2",
Some(&[4u8; 65]),
true,
now,
true,
);
assert!(matches!(
walk.outcomes[0],
Ok(Outcome::Append { sequence: 0 })
));
assert!(walk.outcomes[1].is_err(), "{:?}", walk.outcomes[1]);
assert!(!walk.tip_matches_row);
assert_eq!(walk.final_pcrs, Some(pcrs_hex_from_seed(0x21)));
}
#[test]
fn walk_rejects_boot_of_revoked_upgrade() {
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let now = chrono::Utc::now();
let mut genesis = boot_link(id, "sha256:v1", 0x22);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let mut upgrade =
transition_upgrade_link(id, "sha256:v2", 0x22, 0x32, &sk, now + Duration::days(7));
upgrade.id = Some(Uuid::new_v4());
upgrade.sequence = Some(1);
let mut revoke = revocation_link(id, upgrade.id.unwrap(), 0x22, &sk);
revoke.id = Some(Uuid::new_v4());
revoke.sequence = Some(2);
let mut rogue = boot_link(id, "sha256:v2", 0x32);
rogue.id = Some(Uuid::new_v4());
rogue.sequence = Some(3);
let links = vec![
recorded(genesis, now - Duration::hours(1)),
recorded(upgrade, now - Duration::minutes(30)),
recorded(revoke, now - Duration::minutes(20)),
recorded(rogue, now - Duration::minutes(5)),
];
let row_pcrs = pcrs_hex_from_seed(0x22);
let walk = validate_chain(&links, &row_pcrs, "sha256:v1", Some(&pk), true, now, true);
assert!(walk.outcomes[0].is_ok());
assert!(walk.outcomes[1].is_ok());
assert!(walk.outcomes[2].is_ok());
assert!(walk.outcomes[3].is_err(), "{:?}", walk.outcomes[3]);
assert!(walk.tip_matches_row);
}
#[test]
fn walk_accepts_historical_revocation_after_target_activation() {
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let now = chrono::Utc::now();
let mut genesis = boot_link(id, "sha256:v1", 0x23);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let mut upgrade =
transition_upgrade_link(id, "sha256:v2", 0x23, 0x33, &sk, now - Duration::hours(1));
upgrade.id = Some(Uuid::new_v4());
upgrade.sequence = Some(1);
let mut revoke = revocation_link(id, upgrade.id.unwrap(), 0x23, &sk);
revoke.id = Some(Uuid::new_v4());
revoke.sequence = Some(2);
let links = vec![
recorded(genesis, now - Duration::hours(3)),
recorded(upgrade, now - Duration::hours(2)),
recorded(revoke, now - Duration::minutes(90)),
];
let row_pcrs = pcrs_hex_from_seed(0x23);
let walk = validate_chain(&links, &row_pcrs, "sha256:v1", Some(&pk), true, now, true);
assert!(
walk.outcomes.iter().all(Result::is_ok),
"{:?}",
walk.outcomes
);
assert!(walk.tip_matches_row);
let unstamped: Vec<RecordedLink> = links
.iter()
.map(|r| RecordedLink {
link: r.link.clone(),
recorded_at: None,
})
.collect();
let walk_now = validate_chain(
&unstamped,
&row_pcrs,
"sha256:v1",
Some(&pk),
true,
now,
true,
);
assert!(matches!(
walk_now.outcomes[2],
Err(ChainValidationError::RevokePastActivation)
));
}
fn two_version_chain(now: DateTime<Utc>) -> (Vec<RecordedLink>, Vec<u8>, u8, u8) {
let (sk, pk) = keypair();
let id = Uuid::new_v4();
let (v1, v2) = (0x20u8, 0x30u8);
let mut genesis = boot_link(id, "sha256:v1", v1);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let mut upgrade =
transition_upgrade_link(id, "sha256:v2", v1, v2, &sk, now - Duration::minutes(10));
upgrade.id = Some(Uuid::new_v4());
upgrade.sequence = Some(1);
let mut promo = boot_link(id, "sha256:v2", v2);
promo.id = Some(Uuid::new_v4());
promo.sequence = Some(2);
let links = vec![
recorded(genesis, now - Duration::hours(2)),
recorded(upgrade, now - Duration::minutes(11)),
recorded(promo, now - Duration::minutes(9)),
];
(links, pk, v1, v2)
}
#[test]
fn descent_pinned_genesis_returns_tip() {
let now = chrono::Utc::now();
let (links, pk, v1, v2) = two_version_chain(now);
let row_pcrs = pcrs_hex_from_seed(v2);
let pinned = pcrs_hex_from_seed(v1).to_pcrs().unwrap();
let tip = verify_pcr_descent(
&pinned,
&links,
Some(&pk),
&row_pcrs,
"sha256:v2",
true,
now,
true,
)
.unwrap();
assert_eq!(tip, pcrs_hex_from_seed(v2).to_pcrs().unwrap());
}
#[test]
fn descent_pinned_current_tip_returns_tip() {
let now = chrono::Utc::now();
let (links, pk, _v1, v2) = two_version_chain(now);
let row_pcrs = pcrs_hex_from_seed(v2);
let pinned = pcrs_hex_from_seed(v2).to_pcrs().unwrap();
let tip = verify_pcr_descent(
&pinned, &links, Some(&pk), &row_pcrs, "sha256:v2", true, now, true,
)
.unwrap();
assert_eq!(tip, pinned);
}
#[test]
fn descent_rejects_pin_not_in_chain() {
let now = chrono::Utc::now();
let (links, pk, _v1, v2) = two_version_chain(now);
let row_pcrs = pcrs_hex_from_seed(v2);
let stranger = pcrs_hex_from_seed(0x77).to_pcrs().unwrap();
let err = verify_pcr_descent(
&stranger, &links, Some(&pk), &row_pcrs, "sha256:v2", true, now, true,
)
.unwrap_err();
assert!(matches!(err, PcrDescentError::PinnedNotInLineage));
}
#[test]
fn descent_rejects_tampered_link() {
let now = chrono::Utc::now();
let (mut links, pk, v1, v2) = two_version_chain(now);
links[1].link.payload[0] ^= 0xff;
let row_pcrs = pcrs_hex_from_seed(v2);
let pinned = pcrs_hex_from_seed(v1).to_pcrs().unwrap();
let err = verify_pcr_descent(
&pinned, &links, Some(&pk), &row_pcrs, "sha256:v2", true, now, true,
)
.unwrap_err();
assert!(matches!(
err,
PcrDescentError::LinkInvalid { position: 1, .. }
));
}
#[test]
fn descent_rejects_unsigned_promotion() {
let now = chrono::Utc::now();
let (_sk, pk) = keypair();
let id = Uuid::new_v4();
let (v1, v2) = (0x20u8, 0x30u8);
let mut genesis = boot_link(id, "sha256:v1", v1);
genesis.id = Some(Uuid::new_v4());
genesis.sequence = Some(0);
let mut rogue = boot_link(id, "sha256:v2", v2);
rogue.id = Some(Uuid::new_v4());
rogue.sequence = Some(1);
let links = vec![
recorded(genesis, now - Duration::hours(1)),
recorded(rogue, now - Duration::minutes(1)),
];
let pinned = pcrs_hex_from_seed(v1).to_pcrs().unwrap();
let err = verify_pcr_descent(
&pinned,
&links,
Some(&pk),
&pcrs_hex_from_seed(v1),
"sha256:v1",
true,
now,
true,
)
.unwrap_err();
assert!(matches!(
err,
PcrDescentError::LinkInvalid { position: 1, .. }
));
}
#[test]
fn descent_empty_chain_has_no_genesis() {
let now = chrono::Utc::now();
let pinned = pcrs_hex_from_seed(0x20).to_pcrs().unwrap();
let err = verify_pcr_descent(
&pinned,
&[],
None,
&pcrs_hex_from_seed(0x20),
"sha256:v1",
true,
now,
true,
)
.unwrap_err();
assert!(matches!(err, PcrDescentError::NoGenesis));
}
#[test]
fn chain_link_json_round_trips_through_decode() {
let id = Uuid::new_v4();
let raw = boot_link(id, "sha256:v1", 0x20);
let b64 = base64::engine::general_purpose::STANDARD;
let wire = ChainLinkJson {
id: Some(Uuid::new_v4()),
kind: ChainLinkKind::Boot,
sequence: Some(3),
payload: b64.encode(&raw.payload),
attestation: b64.encode(&raw.attestation),
signature: None,
created_at: None,
};
let decoded = wire.into_chain_link().unwrap();
assert_eq!(decoded.payload, raw.payload);
assert_eq!(decoded.attestation, raw.attestation);
assert_eq!(decoded.sequence, Some(3));
let bad = ChainLinkJson {
payload: "not base64!!!".into(),
..wire.clone()
};
assert!(matches!(
bad.into_chain_link(),
Err(ChainLinkDecodeError::Base64 { field: "payload", .. })
));
let neg = ChainLinkJson {
sequence: Some(-1),
..wire
};
assert!(matches!(
neg.into_chain_link(),
Err(ChainLinkDecodeError::NegativeSequence(-1))
));
}
#[test]
fn enclave_row_parses_array_control_key_and_pcr_casing() {
let row = serde_json::json!({
"upgradable": true,
"image_digest": "sha256:abc",
"pcrs": { "PCR0": "00", "PCR1": "11", "PCR2": "22" },
"control_public_key": [4, 255, 0, 7],
});
let parsed: EnclaveChainRow = serde_json::from_value(row).unwrap();
assert!(parsed.upgradable);
assert_eq!(parsed.image_digest, "sha256:abc");
assert_eq!(parsed.pcrs.pcr0, "00");
assert_eq!(parsed.control_public_key, Some(vec![4u8, 255, 0, 7]));
}
#[test]
fn enclave_row_accepts_base64_control_key_and_lowercase_pcrs() {
let key = vec![4u8, 1, 2, 3];
let row = serde_json::json!({
"upgradable": true,
"image_digest": "sha256:abc",
"pcrs": { "pcr0": "aa", "pcr1": "bb", "pcr2": "cc" },
"control_public_key": base64::engine::general_purpose::STANDARD.encode(&key),
});
let parsed: EnclaveChainRow = serde_json::from_value(row).unwrap();
assert_eq!(parsed.pcrs.pcr1, "bb");
assert_eq!(parsed.control_public_key, Some(key));
}
#[test]
fn enclave_row_null_control_key_is_non_upgradable_and_upgradable_defaults_false() {
let row = serde_json::json!({
"image_digest": "sha256:abc",
"pcrs": { "PCR0": "00", "PCR1": "11", "PCR2": "22" },
"control_public_key": null,
});
let parsed: EnclaveChainRow = serde_json::from_value(row).unwrap();
assert_eq!(parsed.control_public_key, None);
assert!(!parsed.upgradable);
}
#[test]
fn enclave_row_missing_image_digest_errors() {
let row = serde_json::json!({
"upgradable": true,
"pcrs": { "PCR0": "00", "PCR1": "11", "PCR2": "22" },
});
assert!(serde_json::from_value::<EnclaveChainRow>(row).is_err());
}
}