Skip to main content

enclavia_protocol/
chain.rs

1//! Per-enclave public upgrade chain: shared types + pure validation.
2//!
3//! The chain is the user-facing audit trail of every transition an
4//! enclave has been through, exposed by the backend as
5//! `GET /enclaves/{id}/upgrade-chain` (unauthenticated). The same
6//! validation that gates ingest server-side is what an SDK consumer
7//! needs to walk the chain and convince themselves it is consistent;
8//! both surfaces share this module so behaviour cannot drift.
9//!
10//! Three link kinds in v1:
11//!
12//! * [`ChainLinkKind::Boot`] — one per successful boot of a new image
13//!   digest. Payload binds `pcrs / image_digest / enclave_id /
14//!   booted_at / nonce`. The attestation's `user_data` is
15//!   `sha256(payload)` (checked by [`super::attestation::verify_chain_attestation`]).
16//! * [`ChainLinkKind::Upgrade`] — emitted by the OLD enclave after the
17//!   backend signs and ships a `PrepareUpgrade` control command.
18//!   Payload binds `from_pcrs / to_pcrs / image_digest / valid_from /
19//!   issued_at / nonce`. The link's `signature` is the backend's
20//!   ECDSA P-256 sig over the payload, verifiable against the enclave's
21//!   baked-in control pubkey.
22//! * [`ChainLinkKind::Revocation`] — emitted by the OLD enclave on a
23//!   pre-activation revoke. Payload binds the chain entry id being
24//!   cancelled + `issued_at / nonce`. Same signature treatment as
25//!   upgrade.
26//!
27//! Non-upgradable enclaves can only ever produce a single Boot entry
28//! (the genesis). [`validate_chain_link`] rejects upgrade / revocation
29//! outright on those (no control pubkey to verify against, no upgrade
30//! flow), and rejects a second boot that would change `image_digest`.
31//!
32//! The wire shape diverges slightly from the original issue body, which
33//! described the chain as carrying no signature. We include one on
34//! upgrade / revocation as defence-in-depth: a forged link injected by
35//! a tampered host-side daemon would carry no valid signature.
36
37use 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/// Kind of a chain entry.
46#[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/// One entry in an enclave's public chain. The opaque byte fields are
55/// the same shape on the wire (base64 in the API JSON) and at rest
56/// (raw bytes in the backend DB) — this struct is the canonical
57/// representation either way.
58///
59/// `id` and `sequence` are assigned by the backend at insert time;
60/// in-flight links being validated for the FIRST time will not have
61/// them populated. Use [`ChainLink::with_assignment`] to attach them
62/// after validation returns [`Outcome::Append`].
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ChainLink {
65    /// Backend-assigned UUID. Absent on inbound (pre-ingest) links;
66    /// populated on every link returned by the public read endpoint.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub id: Option<Uuid>,
69    /// Per-enclave monotonic ordering, starts at 0. Absent on inbound
70    /// links — the backend computes it at validation time.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub sequence: Option<u64>,
73    pub kind: ChainLinkKind,
74    /// CBOR-encoded payload. Decode against the kind-specific struct:
75    /// [`BootPayload`] / [`UpgradePayload`] / [`RevocationPayload`].
76    #[serde(with = "serde_bytes")]
77    pub payload: Vec<u8>,
78    /// COSE_Sign1 attestation document. `user_data == sha256(payload)`.
79    #[serde(with = "serde_bytes")]
80    pub attestation: Vec<u8>,
81    /// 64-byte raw `r || s` ECDSA P-256 signature over `payload` under
82    /// the enclave's control private key. Required for upgrade /
83    /// revocation, absent on boot.
84    #[serde(
85        default,
86        skip_serializing_if = "Option::is_none",
87        with = "serde_bytes_opt"
88    )]
89    pub signature: Option<Vec<u8>>,
90}
91
92/// `serde_bytes`-like adapter that handles `Option<Vec<u8>>` cleanly.
93/// (`serde_bytes` requires Vec<u8> directly; without this, an Option
94/// would round-trip through serde's default sequence representation
95/// and break interop with the backend's DB-side BYTEA column.)
96mod 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/// JSON wire shape of a chain link on the public `GET
112/// /enclaves/{id}/upgrade-chain` route. The opaque byte fields are
113/// carried as base64 strings here (vs. the raw-bytes [`ChainLink`] used
114/// at rest and inside the validator). Consumed by the CLI today and,
115/// eventually, by the backend + chain-host so all three surfaces share
116/// one definition.
117///
118/// `payload`, `attestation`, and `signature` are standard base64 with
119/// padding. Decode them before handing the bytes to [`validate_chain_link`]
120/// for re-verification.
121#[derive(Debug, Clone, Deserialize, Serialize)]
122pub struct ChainLinkJson {
123    /// Assigned by the backend on insert; absent on the wire shape
124    /// `chain-host` sends to the ingest route.
125    #[serde(default)]
126    pub id: Option<uuid::Uuid>,
127    pub kind: ChainLinkKind,
128    /// Monotonic per-enclave, starts at 0 for the boot link.
129    #[serde(default)]
130    pub sequence: Option<i64>,
131    /// Base64 of the CBOR-encoded kind-specific payload.
132    pub payload: String,
133    /// Base64 of the COSE_Sign1 NSM attestation document. `user_data`
134    /// is bound to `sha256(payload_bytes)`.
135    pub attestation: String,
136    /// Base64 of the raw 64-byte ECDSA P-256 r||s signature. Absent on
137    /// boot links (they're authenticated by the attestation alone),
138    /// required on upgrade/revocation links.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub signature: Option<String>,
141    /// Wall-clock time the backend appended this link. `None` on the
142    /// chain-host ingest direction.
143    #[serde(default)]
144    pub created_at: Option<chrono::DateTime<chrono::Utc>>,
145}
146
147/// Payload shape for a [`ChainLinkKind::Boot`] link.
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct BootPayload {
150    /// Enclave identifier. Must match the URL path on ingest.
151    pub enclave_id: Uuid,
152    /// Manifest digest of the Docker image this boot is bound to.
153    pub image_digest: String,
154    /// PCR0 / PCR1 / PCR2 in raw byte form (48 B each on Nitro).
155    pub pcrs: PcrsHex,
156    /// Wall-clock time the in-enclave boot path produced this
157    /// attestation.
158    pub booted_at: DateTime<Utc>,
159    /// 32-byte freshly-generated nonce.
160    #[serde(with = "serde_bytes")]
161    pub nonce: Vec<u8>,
162}
163
164/// Payload shape for a [`ChainLinkKind::Upgrade`] link.
165#[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/// Payload shape for a [`ChainLinkKind::Revocation`] link.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct RevocationPayload {
180    pub enclave_id: Uuid,
181    /// Chain entry id of the upgrade link this revocation cancels.
182    pub revokes: Uuid,
183    pub issued_at: DateTime<Utc>,
184    #[serde(with = "serde_bytes")]
185    pub nonce: Vec<u8>,
186}
187
188/// Failure decoding a [`ChainLinkJson`] wire link into a [`ChainLink`].
189#[derive(Debug, thiserror::Error)]
190pub enum ChainLinkDecodeError {
191    /// One of the base64 byte fields did not decode.
192    #[error("chain link `{field}` is not valid base64: {source}")]
193    Base64 {
194        field: &'static str,
195        #[source]
196        source: base64::DecodeError,
197    },
198    /// `sequence` was negative. The backend never persists a negative
199    /// value; surface it instead of silently coercing, so a misbehaving
200    /// source is visible rather than papered over.
201    #[error("chain link has a negative sequence {0}")]
202    NegativeSequence(i64),
203}
204
205impl ChainLinkJson {
206    /// Decode the base64 wire fields into the raw-bytes [`ChainLink`] the
207    /// validator consumes. `id` carries through; `sequence` narrows
208    /// `i64 -> u64` and errors on a negative value rather than coercing.
209    ///
210    /// This is the single decode path for every consumer of the public
211    /// `GET /enclaves/{id}/upgrade-chain` endpoint (the SDK's
212    /// trust-upgrades walk and the CLI's `upgrade chain`), so the
213    /// base64 handling is defined once in the audited crate rather than
214    /// re-implemented per caller.
215    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    /// Decode into a [`RecordedLink`], carrying `created_at` as the
244    /// ingest reference instant for time-dependent rules (revocation
245    /// `valid_from`).
246    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/// PCRs in hex-string form. Chosen over raw bytes for the wire/CBOR
255/// shape because hex strings interop trivially with the existing
256/// hex-PCR format the backend already stores; clients walking the
257/// chain don't have to deal with byte-vs-string conversion to match
258/// what the attestation document carries.
259#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
260pub struct PcrsHex {
261    // `alias` accepts the lowercase form too: the backend persists the
262    // builder's pcr.json verbatim (nitro-cli `PCR0` casing), but a
263    // future normalization on the row should not break a consumer
264    // deserializing it. Serialization is unchanged (always `PCR0`).
265    #[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    /// Decode to raw bytes for the attestation verifier.
275    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/// The `validate_chain` context fields carried on the public
287/// `GET /enclaves/{id}` response. Deserialized by every consumer that
288/// re-walks a chain (the SDK's `trust_upgrades`, the CLI's `upgrade
289/// chain`) so the tolerant parsing lives once here instead of being
290/// re-implemented per caller.
291///
292/// Tolerances, matching what the backend actually emits:
293/// - `pcrs`: nitro-cli `PCR0` casing or lowercase (see [`PcrsHex`]).
294/// - `control_public_key`: the BYTEA column serializes as a JSON array
295///   of byte values; a base64 string and `null` (a non-upgradable
296///   enclave) are also accepted.
297/// - `upgradable`: defaults to `false` when absent.
298///
299/// These values are corroborating, not load-bearing, in the
300/// `trust_upgrades` trust model (a wrong value can only make a genuine
301/// chain fail to verify); see [`verify_pcr_descent`].
302#[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
312/// Accept the `control_public_key` BYTEA in any of the shapes the row
313/// can carry: a JSON array of byte values, a base64 string, or
314/// null/absent (non-upgradable enclave).
315fn 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
335/// Context the validator needs that lives outside the link itself: the
336/// enclave's recorded metadata + the chain so far.
337///
338/// Backend usage: load from DB at ingest. SDK usage: fetch via
339/// `GET /enclaves/{id}` and iterate the chain returned from
340/// `GET /enclaves/{id}/upgrade-chain`, passing successively longer
341/// prefixes as `prior_chain`.
342pub struct ChainContext<'a> {
343    /// PCR0/1/2 recorded for this enclave at build time. Every link's
344    /// attestation document must carry these PCRs.
345    pub enclave_pcrs: &'a PcrsHex,
346    /// Manifest digest of the Docker image currently pinned to this
347    /// enclave row. Boot payloads must reference this digest.
348    pub enclave_image_digest: &'a str,
349    /// Enclave's 65-byte uncompressed SEC1 ECDSA P-256 control public
350    /// key, or None when the enclave was created non-upgradable.
351    pub control_public_key: Option<&'a [u8]>,
352    /// Whether the enclave was created with the upgradable flag.
353    /// Upgrade / revocation links are rejected outright when false.
354    pub upgradable: bool,
355    /// Existing chain entries, in `sequence` order. May be empty (the
356    /// link about to be validated would be genesis).
357    pub prior_chain: &'a [ChainLink],
358}
359
360/// Validator outcome on a successful check.
361#[derive(Debug, Clone, PartialEq, Eq)]
362pub enum Outcome {
363    /// The link is well-formed and consistent with the chain. Caller
364    /// should append it with this `sequence` number.
365    Append {
366        /// Sequence number to assign to the new link. The first
367        /// genesis boot is 0; subsequent links increment by 1.
368        sequence: u64,
369    },
370    /// The link is a duplicate of an already-active boot (same
371    /// `image_digest` as the chain's most recent boot). Caller should
372    /// NOT insert it; the existing matching link is still authoritative.
373    Dedup,
374}
375
376/// All ways a chain link can fail validation.
377#[derive(Debug, thiserror::Error)]
378pub enum ChainValidationError {
379    /// Bottom-line attestation verification (see
380    /// [`super::attestation::verify_chain_attestation`]).
381    #[error("{0}")]
382    Attestation(#[from] AttestationError),
383    /// `attestation` byte vec is empty.
384    #[error("attestation must be present")]
385    EmptyAttestation,
386    /// CBOR-decode of `payload` failed against the kind's struct.
387    #[error("{kind:?} payload not CBOR-decodable: {msg}")]
388    PayloadDecode { kind: ChainLinkKind, msg: String },
389    /// `payload.enclave_id` does not match the validator's context.
390    #[error("payload enclave_id does not match the URL")]
391    EnclaveIdMismatch,
392    /// Boot link claims PCRs that disagree with what the backend
393    /// recorded post-build.
394    #[error("boot payload PCRs do not match the enclave's recorded PCRs")]
395    PcrMismatch,
396    /// Boot link's `image_digest` disagrees with `enclaves.image_digest`.
397    #[error("boot payload image_digest does not match the enclave's pinned digest")]
398    ImageDigestMismatch,
399    /// Boot link presented a `signature`. Boot links carry no
400    /// signature.
401    #[error("boot link must not carry a signature")]
402    BootHasSignature,
403    /// Upgrade / revocation link is missing the `signature` field.
404    #[error("{0:?} link must carry a signature")]
405    SignatureMissing(ChainLinkKind),
406    /// The control_public_key on `enclaves` did not decode as
407    /// uncompressed SEC1 P-256. Indicates DB-side drift; user-facing
408    /// error class should map to 500.
409    #[error("stored control_public_key does not decode as SEC1 P-256: {0}")]
410    BadControlPubkey(String),
411    /// `signature` is not 64 bytes raw r||s.
412    #[error("signature is not 64 bytes raw r||s ECDSA P-256")]
413    SignatureShape,
414    /// `signature` does not verify against the enclave's control
415    /// pubkey.
416    #[error("signature does not verify under the enclave's control_public_key")]
417    SignatureInvalid,
418    /// Upgrade / revocation submitted on a non-upgradable enclave.
419    #[error("non-upgradable enclaves cannot record {0:?} links")]
420    NonUpgradableSigned(ChainLinkKind),
421    /// Boot of a fresh image digest on a non-upgradable enclave.
422    #[error("non-upgradable enclaves cannot record a second boot")]
423    NonUpgradableSecondBoot,
424    /// Upgrade or revocation submitted before any genesis boot exists.
425    #[error("first chain entry must be a boot — no upgrade or revocation can precede the genesis")]
426    NoGenesisYet,
427    /// Revocation's `revokes` does not resolve to any entry on the
428    /// chain context.
429    #[error("revocation `revokes` does not reference any chain entry on this enclave")]
430    RevokeTargetMissing,
431    /// Revocation's `revokes` points at a non-upgrade link.
432    #[error("revocation can only target an upgrade entry, not a {0:?}")]
433    RevokeTargetWrongKind(ChainLinkKind),
434    /// Revoked upgrade is past `valid_from`. Pre-activation revoke only.
435    #[error("revocation is past the upgrade's valid_from; pre-activation revoke only")]
436    RevokePastActivation,
437    /// Another revocation on this chain already targets the same
438    /// upgrade.
439    #[error("upgrade has already been revoked")]
440    AlreadyRevoked,
441    /// A stored chain entry's payload no longer CBOR-decodes (DB-side
442    /// drift). Maps to 500.
443    #[error("stored {0:?} payload corrupt: {1}")]
444    CorruptStoredPayload(ChainLinkKind, String),
445    /// A stored PCR string is not hex (DB-side drift). Maps to 500.
446    #[error("stored PCR {0} is not valid hex")]
447    CorruptStoredPcrHex(usize),
448}
449
450/// Pure validator. No DB access, no clock skew (uses the supplied
451/// `now`), no I/O. Backend ingest calls this with `now = Utc::now()`;
452/// SDK chain-walkers can pass any reference instant they want for
453/// consistency (e.g. the chain GET's response time).
454///
455/// On `Ok(Outcome::Append { sequence })` the caller should INSERT the
456/// link assigning that sequence number. On `Ok(Outcome::Dedup)` the
457/// caller should not insert. On `Err(_)` the caller should reject.
458pub 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    // Cross-link semantics: dedup against the most recent boot
496    // anywhere in the chain (not just the tail). The reboot-during-
497    // pending-upgrade case lands here with `upgrade` as the tail; the
498    // running image hasn't actually changed.
499    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            // Genesis. Sequence is `prior_chain.len()` so we pick up
507            // from whatever's at the tail even if (somehow) a non-boot
508            // entry preceded the genesis.
509            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    // Payload-shape sanity + per-kind cross-link checks.
556    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            // Target lookup, kind check, activation check, double-revoke.
573            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    // First chain entry must be a boot.
608    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// ---------------------------------------------------------------------------
617// Full-chain walker
618// ---------------------------------------------------------------------------
619
620/// One stored chain link plus its server-assigned ingest time: the
621/// input unit for [`validate_chain`].
622#[derive(Debug, Clone)]
623pub struct RecordedLink {
624    pub link: ChainLink,
625    /// `created_at` on the stored row. Used as the reference instant
626    /// for time-dependent rules: a revocation is judged against the
627    /// clock at its ingest, not the verifier's clock: by the time
628    /// anyone re-walks the chain, the revoked upgrade's `valid_from`
629    /// has usually passed, and judging it "now" would reject a link
630    /// that was perfectly valid when the backend recorded it. `None`
631    /// falls back to the walk's `now`.
632    pub recorded_at: Option<DateTime<Utc>>,
633}
634
635/// Result of [`validate_chain`].
636#[derive(Debug)]
637pub struct ChainWalk {
638    /// Per-link outcome, same order as the input links.
639    pub outcomes: Vec<Result<Outcome, ChainValidationError>>,
640    /// PCRs in force after the walk: the genesis boot's values,
641    /// advanced by every verified promotion boot. `None` when the
642    /// chain has no usable genesis.
643    pub final_pcrs: Option<PcrsHex>,
644    /// Image digest in force after the walk (same advancement rule).
645    pub final_image_digest: Option<String>,
646    /// Whether the walk's final in-force state equals the enclave row
647    /// state supplied to [`validate_chain`]. `false` means the chain
648    /// does not explain what the row currently records (stale chain,
649    /// missing links, or row drift): treat the chain as NOT verified
650    /// even if every individual link validated.
651    pub tip_matches_row: bool,
652}
653
654/// Re-validate a stored chain end-to-end, reconstructing the context
655/// each link saw at ingest time.
656///
657/// The backend validates links incrementally: each arrives while the
658/// enclave row still holds the state in force at that moment: the
659/// genesis build's PCRs for the genesis boot, the old version's PCRs
660/// for upgrade / revocation links (the running enclave attests them),
661/// and the new version's PCRs for a promotion boot (the cutover sweep
662/// promotes the row before the new enclave boots). A later verifier
663/// only has the FINAL row state, so validating every link against it
664/// rejects perfectly good history. This walker rebuilds the historical
665/// context from the chain itself:
666///
667/// - The genesis boot anchors the walk on its own attested payload.
668///   [`validate_chain_link`] then enforces payload <-> attestation
669///   agreement, and in production the AWS Nitro CA signature roots
670///   that payload in hardware.
671/// - Upgrade / revocation links validate against the in-force state:
672///   they are attested by the enclave version running at the time.
673/// - A boot whose PCRs match the `to_pcrs` of a prior unrevoked
674///   upgrade link (with the same target image digest) is a promotion:
675///   it validates against that upgrade's target state, and on success
676///   the in-force state advances to it.
677/// - Any other boot validates against the in-force state: a
678///   same-version reboot dedups, anything else fails the attestation
679///   PCR check. A transition no signed upgrade link explains is
680///   exactly what this rejects.
681///
682/// Callers MUST check [`ChainWalk::tip_matches_row`] in addition to
683/// the per-link outcomes: it ties the walk's final state to the row,
684/// proving the chain accounts for what is currently running.
685///
686/// `now` is the fallback reference instant for links with no
687/// `recorded_at` (e.g. not-yet-ingested candidates).
688pub 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    // (pcrs, image_digest) in force at the current walk position. Set
700    // by the genesis boot, advanced by each verified promotion boot.
701    // `None` until a genesis validates; the row state then stands in
702    // so later links still get individually validated and reported.
703    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        // Reconstruct the row state this link saw at ingest. `promotes`
710        // marks the contexts that advance the in-force state when the
711        // link validates (genesis anchor, promotion boot).
712        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                    // Undecodable genesis: hand the row state to the
717                    // validator so it reports the decode error.
718                    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                            // Same-version reboot.
729                            (pcrs.clone(), digest.clone(), false)
730                        } else if let Some(target) =
731                            promotion_target(&prior, &p.pcrs, &p.image_digest)
732                        {
733                            // Promotion boot: ingest saw the row
734                            // already promoted to the upgrade target.
735                            (target.to_pcrs, target.image_digest, true)
736                        } else {
737                            // No signed upgrade explains these PCRs;
738                            // validate against the in-force state and
739                            // fail loudly.
740                            (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
783/// Most recent prior unrevoked upgrade link whose `to_pcrs` and target
784/// image digest match the boot being explained. `None` when no signed
785/// upgrade accounts for a boot with these measurements.
786fn 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// ---------------------------------------------------------------------------
807// Pinned-PCR upgrade-descent check (SDK `trust_upgrades`)
808// ---------------------------------------------------------------------------
809
810/// Why a live enclave's PCRs could not be shown to descend from the
811/// caller's pinned PCRs.
812#[derive(Debug, thiserror::Error)]
813pub enum PcrDescentError {
814    /// The chain produced no usable genesis, so no in-force state could
815    /// be established and nothing descends from anything.
816    #[error("chain has no valid genesis boot")]
817    NoGenesis,
818    /// A chain link failed validation (attestation, signature, or
819    /// cross-link consistency). The whole chain is rejected: a single
820    /// unverifiable link means the history is not a trustworthy account
821    /// of how the enclave reached its current measurements.
822    #[error("chain link at position {position} failed validation: {source}")]
823    LinkInvalid {
824        position: usize,
825        #[source]
826        source: ChainValidationError,
827    },
828    /// Every link validated, but the pinned PCRs never appear as an
829    /// in-force boot state on this chain. The chain may be a perfectly
830    /// valid history of a DIFFERENT enclave; it does not start from (or
831    /// pass through) the version the caller pinned, so the live
832    /// measurements cannot be said to descend from the pinned ones.
833    #[error("pinned PCRs are not an in-force state anywhere on this chain")]
834    PinnedNotInLineage,
835    /// A boot link the walker accepted carries a payload that no longer
836    /// CBOR-decodes, or PCR hex that no longer parses. Indicates the
837    /// link bytes were mutated between validation and this read; treated
838    /// as fatal.
839    #[error("validated boot link at position {0} has an unreadable payload")]
840    BootPayloadUnreadable(usize),
841}
842
843/// Verify that an enclave currently measuring some live PCRs descends,
844/// through this enclave's signed upgrade chain, from the PCRs the caller
845/// pinned, and return the chain's final (tip) measurements.
846///
847/// This backs the SDK's `trust_upgrades` mode. When a client pinned a
848/// version's PCRs and the enclave has since upgraded, the live
849/// attestation no longer matches the pin; this function decides whether
850/// to extend trust to the new image by proving the new image is a
851/// descendant of the pinned one.
852///
853/// The caller passes the pinned PCRs plus the enclave's public chain
854/// (from `GET /enclaves/{id}/upgrade-chain`) and the validator context
855/// (`row_*`, `control_public_key`, `upgradable`) from
856/// `GET /enclaves/{id}`. On success it returns the chain's TIP PCRs; the
857/// caller MUST then verify the LIVE attestation against exactly those
858/// PCRs (e.g. [`crate::attestation::verify_against`]) to bind the
859/// verified descendant version to the running Noise session. This
860/// function does not see the live attestation and so cannot make that
861/// binding itself.
862///
863/// ## Trust model
864///
865/// Soundness rests entirely on the per-link AWS Nitro attestations,
866/// which `validate_chain` verifies (in production mode); the
867/// control-key signatures and the `row_*` / `control_public_key` /
868/// `upgradable` context are corroborating, never load-bearing. A
869/// dishonest source of any of those can only make a genuine chain FAIL
870/// here (a denial), never make a forged transition pass: forging an
871/// upgrade link would require a real Nitro document from an enclave
872/// measuring the `from` PCRs that voluntarily authorized the `to` PCRs,
873/// which is exactly the trust delegation `trust_upgrades` opts into.
874///
875/// Two independent gates make the result meaningful:
876///
877/// 1. EVERY link validates. One bad link rejects the whole chain.
878/// 2. The pinned PCRs appear as an in-force boot state on the chain.
879///    Without this a valid chain belonging to some OTHER enclave would
880///    pass; with it, the pinned version is provably part of THIS
881///    enclave's measured history. Per-enclave PCRs are unique (the
882///    enclave UUID is measured into them), so a single matching
883///    in-force state on a fully-validated linear chain places the tip
884///    downstream of the pin.
885///
886/// `debug_mode` must be the SDK's own mode: in production mode a chain
887/// of debug (non-CA-signed) links fails attestation and is rejected, so
888/// a production client never extends trust through unverifiable history.
889// One arg over the lint's threshold: this mirrors `validate_chain`'s
890// context (which sits exactly at the limit) plus the pinned anchor.
891// Bundling them into a struct would just move the same fields around.
892#[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    // Gate 1: every link must validate. Reject on the first failure so a
918    // tampered or unexplained transition can never be papered over by a
919    // later good link.
920    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    // Gate 2: the pinned PCRs must be one of the chain's in-force boot
930    // states. Every boot link here was accepted by the walk above (we
931    // returned on any failure), so its PCRs are a measured state the
932    // enclave genuinely ran; the genesis and each promotion boot are the
933    // points where the in-force version changed.
934    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        // boot(v1) -> upgrade(v1->v2) -> reboot(v1): dedup.
1145        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        // Sign with a different key.
1228        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    // -----------------------------------------------------------------------
1427    // Full-chain walker
1428    // -----------------------------------------------------------------------
1429
1430    /// Upgrade link describing a real version transition: attested by
1431    /// the OLD enclave (`from_seed`, the version running at confirm
1432    /// time) and targeting the NEW measurements (`to_seed`). The
1433    /// single-seed [`upgrade_link`] fixture above keeps from == to,
1434    /// which never promotes anything.
1435    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    /// The promoted-history shape a real upgrade leaves behind:
1474    /// boot(v1) -> upgrade(v1->v2) -> boot(v2), walked AFTER promotion
1475    /// with the row already holding the v2 state. Validating each link
1476    /// against the final row state would reject the first two; the
1477    /// walker must reconstruct the per-link historical context.
1478    #[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    /// A boot whose measurements no prior upgrade link explains must
1521    /// reject, and the in-force tip must NOT advance to it, even when
1522    /// the enclave row already claims the new state.
1523    #[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        // Tip stays at genesis, which the row no longer matches.
1556        assert!(!walk.tip_matches_row);
1557        assert_eq!(walk.final_pcrs, Some(pcrs_hex_from_seed(0x21)));
1558    }
1559
1560    /// A boot of a REVOKED upgrade's target must reject: the revocation
1561    /// strips the upgrade link of its power to explain the transition.
1562    #[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        // Confirmed for the future, then revoked before activation.
1572        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        // Still on v1, which the row agrees with.
1597        assert!(walk.tip_matches_row);
1598    }
1599
1600    /// Historical revocations validate against their INGEST clock, not
1601    /// the verifier's: by walk time the revoked upgrade's `valid_from`
1602    /// has passed, and judging the revocation "now" would reject a
1603    /// link the backend legitimately recorded.
1604    #[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        // valid_from is an hour in the PAST relative to the walk...
1614        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        // ...but the revocation was recorded 30 minutes BEFORE that.
1619        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        // Sanity: the same chain judged entirely at `now` (no recorded
1639        // ingest times) rejects the revocation as past activation.
1640        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    // -----------------------------------------------------------------------
1663    // verify_pcr_descent (SDK trust_upgrades)
1664    // -----------------------------------------------------------------------
1665
1666    /// boot(v1) -> upgrade(v1->v2) -> boot(v2) walked AFTER promotion.
1667    /// Returns the links plus the v1/v2 seeds and the control pubkey.
1668    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        // The tip is the v2 measurements the running enclave should show.
1711        assert_eq!(tip, pcrs_hex_from_seed(v2).to_pcrs().unwrap());
1712    }
1713
1714    #[test]
1715    fn descent_pinned_current_tip_returns_tip() {
1716        // Pinning the CURRENT (post-upgrade) version is also "in lineage":
1717        // the tip itself is an in-force boot state.
1718        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        // A fully-valid chain, but the pinned PCRs belong to neither the
1733        // genesis nor any promotion: this could be a real chain for a
1734        // different enclave. Must not extend trust.
1735        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        // Flip a byte in the upgrade payload: its attestation binding
1750        // (user_data == sha256(payload)) breaks, so the link fails and
1751        // the whole chain is rejected even though the pin matches genesis.
1752        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        // boot(v1) then a promotion boot(v2) with NO upgrade link
1771        // explaining the transition: the v2 boot fails the attestation
1772        // PCR check against the in-force v1 state. An attacker cannot
1773        // jump the measured version without a signed, attested upgrade.
1774        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        // into_chain_link is the SDK/CLI decode path: base64 wire fields
1829        // back to raw bytes, id/sequence carried, negative sequence
1830        // dropped.
1831        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}