Skip to main content

clerk_report/
versions.rs

1use ed25519_dalek::{Signature, Verifier, VerifyingKey};
2
3#[cfg(any(test, feature = "signing"))]
4use ed25519_dalek::{Signer, SigningKey};
5
6use sha2::{Digest, Sha384};
7
8#[cfg(any(test, feature = "signing"))]
9use sha2::{digest::Output, Sha256};
10
11use serde::{Deserialize, Serialize};
12
13#[derive(thiserror::Error, Debug)]
14pub enum SignatureVerificationError {
15    #[error("Failed to verify signature: {0}")]
16    SignatureError(#[from] ed25519_dalek::SignatureError),
17    #[error("The specified key is unknown")]
18    UnknownKeyId,
19}
20
21const VMSA_PAGE_SIZE: usize = 4096;
22
23#[derive(thiserror::Error, Debug)]
24pub enum LaunchMeasurementError {
25    #[error("Launch measurement must be 48 bytes, got {0} bytes")]
26    InvalidMeasurementLength(usize),
27    #[error("VMSA must be at most {VMSA_PAGE_SIZE} bytes, got {0} bytes")]
28    InvalidVmsaLength(usize),
29}
30
31/// Launch measurement carried in a [`PublishedVersionEntry`].
32#[derive(Clone, Debug, Deserialize, Serialize)]
33#[serde(
34    untagged,
35    expecting = "a base64-encoded string or a launch measurement object"
36)]
37pub enum LaunchMeasurement {
38    V1(LaunchMeasurementV1),
39    V2(LaunchMeasurementV2),
40}
41
42/// Generation 1 launch measurement: flat SHA-384 digest including VMSA pages,
43/// serialized as a base64 string.
44#[derive(Clone, Debug, Deserialize, Serialize)]
45pub struct LaunchMeasurementV1(#[serde(with = "base64_compat")] pub Vec<u8>);
46
47/// Generation 2 launch measurement: pre-VMSA digest and raw VMSA bytes stored
48/// separately so the vCPU count can change without invalidating the signed
49/// system version.
50#[derive(Clone, Debug, Deserialize, Serialize)]
51pub struct LaunchMeasurementV2 {
52    /// Raw `oak_sev_guest::vmsa::Vmsa` bytes shared by all vCPUs.
53    #[serde(with = "base64_compat")]
54    pub vmsa: Vec<u8>,
55    /// SHA-384 digest of all measured pages except VMSA pages.
56    #[serde(with = "base64_compat")]
57    pub value: Vec<u8>,
58}
59
60impl LaunchMeasurement {
61    /// Resolves the full 48-byte launch measurement for comparison with an
62    /// attestation report.
63    ///
64    /// For V1, returns the stored digest. For V2, reconstructs the full
65    /// measurement by applying the VMSA page update once per vCPU.
66    ///
67    /// # Panics
68    ///
69    /// Panics if the measurement or VMSA has an invalid length. Use
70    /// [`Self::try_resolve`] for measurements from unvalidated input.
71    #[must_use]
72    pub fn resolve(&self, vcpus: usize) -> [u8; 48] {
73        self.try_resolve(vcpus).expect("Invalid launch measurement")
74    }
75
76    /// Like [`Self::resolve`], but returns an error instead of panicking if
77    /// the measurement or VMSA has an invalid length.
78    ///
79    /// # Errors
80    ///
81    /// Returns an error if the measurement is not 48 bytes or the VMSA is
82    /// larger than 4096 bytes.
83    pub fn try_resolve(&self, vcpus: usize) -> Result<[u8; 48], LaunchMeasurementError> {
84        match self {
85            Self::V1(measurement) => measurement.try_resolve(),
86            Self::V2(measurement) => measurement.try_resolve(vcpus),
87        }
88    }
89
90    /// Appends this measurement's bytes to a [`PublishedVersionEntry`] signing input.
91    fn extend_signing_input(&self, input: &mut Vec<u8>) {
92        match self {
93            Self::V1(measurement) => input.extend_from_slice(&measurement.0),
94            Self::V2(measurement) => {
95                input.extend_from_slice(&measurement.vmsa);
96                input.extend_from_slice(&measurement.value);
97            }
98        }
99    }
100}
101
102impl LaunchMeasurementV1 {
103    /// Returns the stored 48-byte digest.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error if the measurement is not 48 bytes.
108    pub fn try_resolve(&self) -> Result<[u8; 48], LaunchMeasurementError> {
109        self.0
110            .as_slice()
111            .try_into()
112            .map_err(|_| LaunchMeasurementError::InvalidMeasurementLength(self.0.len()))
113    }
114}
115
116impl LaunchMeasurementV2 {
117    /// Resolves the full 48-byte launch measurement by applying the VMSA page
118    /// update once per vCPU.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if the measurement is not 48 bytes or the VMSA is
123    /// larger than 4096 bytes.
124    pub fn try_resolve(&self, vcpus: usize) -> Result<[u8; 48], LaunchMeasurementError> {
125        if self.vmsa.len() > VMSA_PAGE_SIZE {
126            return Err(LaunchMeasurementError::InvalidVmsaLength(self.vmsa.len()));
127        }
128        let pre_vmsa_digest = self
129            .value
130            .as_slice()
131            .try_into()
132            .map_err(|_| LaunchMeasurementError::InvalidMeasurementLength(self.value.len()))?;
133        Ok(resolve_v2(&pre_vmsa_digest, &self.vmsa, vcpus))
134    }
135}
136
137/// Reconstructs the full launch measurement from a V2 pre-VMSA digest by
138/// applying the VMSA page update for each vCPU.
139///
140/// This reimplements the `PAGE_INFO` digest chain (AMD SEV-SNP Firmware ABI
141/// Specification, publication #56860, revision 1.58, May 2025, table 70) from
142/// the `snp_measurement` crate because that crate would unnecessarily bloat
143/// `clerk-report`.
144fn resolve_v2(pre_vmsa_digest: &[u8; 48], vmsa: &[u8], vcpus: usize) -> [u8; 48] {
145    const PAGE_INFO_SIZE: u16 = 112;
146    const VMSA_GPA: u64 = 0xffff_ffff_f000;
147
148    // SHA-384 of VMSA bytes zero-padded to 4 KiB (same for every vCPU)
149    let mut padded = [0u8; VMSA_PAGE_SIZE];
150    padded[..vmsa.len()].copy_from_slice(vmsa);
151    let contents: [u8; 48] = Sha384::digest(padded).into();
152
153    let mut digest: [u8; 48] = *pre_vmsa_digest;
154
155    for _ in 0..vcpus {
156        let mut page_info = [0u8; PAGE_INFO_SIZE as usize];
157        page_info[0..48].copy_from_slice(&digest); // DIGEST_CUR
158        page_info[48..96].copy_from_slice(&contents); // CONTENTS
159        page_info[96..98].copy_from_slice(&PAGE_INFO_SIZE.to_le_bytes()); // LENGTH
160        page_info[98] = 2; // PAGE_TYPE (VMSA)
161        page_info[104..112].copy_from_slice(&VMSA_GPA.to_le_bytes()); // GPA
162        digest = Sha384::digest(page_info).into();
163    }
164
165    digest
166}
167
168#[derive(Clone, Debug, Deserialize, Serialize)]
169#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
170#[serde(rename_all = "camelCase")]
171pub struct PublishedVersionEntry {
172    /// The kind of entry, in case we want to include other types in the future (e.g., key revocations, ...)
173    pub kind: String,
174    /// How [`Self::launch_measurement`] was calculated (breaking changes increase the version)
175    pub generation: usize,
176    #[serde(serialize_with = "rfc_3339_no_z")]
177    pub created_at: chrono::DateTime<chrono::offset::FixedOffset>,
178    /// Reference to the source repository as Nix flake URL
179    pub ref_: String,
180    /// Launch measurement for the system version. Generation 1 stores a flat
181    /// SHA-384 digest; generation 2 stores the pre-VMSA digest and VMSA bytes
182    /// separately to decouple the measurement from the vCPU count.
183    #[cfg_attr(feature = "utoipa", schema(value_type = serde_json::Value))]
184    pub launch_measurement: LaunchMeasurement,
185    /// YAXI signature
186    pub signature: PublishedVersionEntrySignature,
187}
188
189impl PublishedVersionEntry {
190    /// Verifies whether this entry is signed with a well-known key.
191    ///
192    /// # Errors
193    ///
194    /// Returns an error if the key id is unknown. Also forwards errors from
195    /// [`ed25519_dalek`](ed25519_dalek::SignatureError)
196    pub fn verify_signature(&self) -> Result<(), SignatureVerificationError> {
197        let Some(verifying_key) = verifying_key_by_id(&self.signature.key_id) else {
198            return Err(SignatureVerificationError::UnknownKeyId);
199        };
200
201        self.verify_signature_with_key(&verifying_key)
202    }
203
204    fn verify_signature_with_key(
205        &self,
206        verifying_key: &VerifyingKey,
207    ) -> Result<(), SignatureVerificationError> {
208        let mut input = Vec::new();
209        input.extend_from_slice(self.kind.as_bytes());
210        input.extend_from_slice(self.generation.to_string().as_bytes());
211        input.extend_from_slice(self.created_at.to_rfc3339().as_bytes());
212        input.extend_from_slice(self.ref_.as_bytes());
213        self.launch_measurement.extend_signing_input(&mut input);
214
215        let signature = Signature::from_slice(&self.signature.value)
216            .map_err(SignatureVerificationError::SignatureError)?;
217
218        verifying_key
219            .verify(&input, &signature)
220            .map_err(SignatureVerificationError::SignatureError)
221    }
222}
223
224#[serde_with::serde_as]
225#[derive(Clone, Debug, Deserialize, Serialize)]
226#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
227#[serde(rename_all = "camelCase")]
228pub struct PublishedVersionEntrySignature {
229    /// SHA-256 hash of Ed25519 public key
230    #[serde(with = "base64_compat")]
231    #[cfg_attr(feature = "utoipa", schema(value_type = String, format = "byte", example = "dYa685dhHap8RSUtB4DDy1l4UcycsGhklBnV5a/4HSg="))]
232    pub key_id: Vec<u8>,
233    /// Signing input: [`PublishedVersionEntry::kind`] || `[PublishedVersionEntry::generation` || [`PublishedVersionEntry::created_at`] || [`PublishedVersionEntry::ref_`] || [`PublishedVersionEntry::launch_measurement`]
234    #[serde(with = "base64_compat")]
235    #[cfg_attr(feature = "utoipa", schema(value_type = String, format = "byte", example = "V89vbJZCP3ZNskERuNN1MkzaL5of68dPZ36DigmXmgBUn2fthx6M57Iyh5USOIR5GZyJi119yf3erp7Zi1mZCQ=="))]
236    pub value: Vec<u8>,
237}
238
239#[cfg(not(any(test, feature = "test-signing-keys")))]
240fn verifying_key_by_id(key_id: &[u8]) -> Option<VerifyingKey> {
241    if key_id == b"\x02\x1a\xd4^\xc5\x7f\\\x0b\xc8\x13n\x11C\xf5m\xff5\xe8.\x87o\x8dz\x16\x0fg\xe1,k\x913\xb5" {
242        // In veehaitch's possession
243        Some(VerifyingKey::from_bytes(b"e\xbcL!\xf5\x9b*O\xd6\xb5}1D\x85W\xe8{[\xd1\x15\xef$\xc3\xd7R\x8c\xa0\xc3\xeco\xb4\xe2").unwrap())
244    } else if key_id == b"\x0f}3E\x87\xbcR\x0f{\xdff\xf8=\xed\x81\x03\x05\x80^\x7f\xd3\xe4\xdb,\xd8rN\xa7y\x0b\x0b\\" {
245        // In trundle's possession
246        Some(VerifyingKey::from_bytes(b"\x08\xcbka\x11\x8c\x10\xf0\x1d\xc5h\x1a\xb3sV\x02\xd2Ik\xd3.\"Y\xaeu\xc1~\xd7\x0c\x85j%").unwrap())
247    } else {
248        None
249    }
250}
251
252#[cfg(any(test, feature = "test-signing-keys"))]
253fn verifying_key_by_id(key_id: &[u8]) -> Option<VerifyingKey> {
254    // qzLgDnRegbiQzY416i9/MClrmMp24jcHzaWCWSWSutA=
255    if key_id == b"u\x86\xba\xf3\x97a\x1d\xaa|E%-\x07\x80\xc3\xcbYxQ\xcc\x9c\xb0hd\x94\x19\xd5\xe5\xaf\xf8\x1d(" {
256        // Signing key: KWd9W5LR/7jNTS26CvuCRcJ/rgXD/tS9S6KqA/pDczY=
257        Some(VerifyingKey::from_bytes(b"\xab2\xe0\x0et^\x81\xb8\x90\xcd\x8e5\xea/\x7f0)k\x98\xcav\xe27\x07\xcd\xa5\x82Y%\x92\xba\xd0").unwrap())
258    } else {
259        None
260    }
261}
262
263#[cfg(any(test, feature = "signing"))]
264#[must_use]
265pub fn key_id(verifying_key: &VerifyingKey) -> Output<Sha256> {
266    let mut hasher = Sha256::new();
267    hasher.update(verifying_key.to_bytes());
268    hasher.finalize()
269}
270
271/// Creates a signed system version entry for the given launch measurement.
272///
273/// The generation is derived from the [`LaunchMeasurement`] variant.
274#[cfg(any(test, feature = "signing"))]
275#[must_use]
276pub fn signed_version_entry(
277    launch_measurement: LaunchMeasurement,
278    flake_url: &str,
279    signing_key: &SigningKey,
280) -> PublishedVersionEntry {
281    let kind = "release".to_string();
282    let generation = match &launch_measurement {
283        LaunchMeasurement::V1(_) => 1,
284        LaunchMeasurement::V2(_) => 2,
285    };
286    let created_at: chrono::DateTime<chrono::offset::FixedOffset> =
287        chrono::offset::Utc::now().into();
288
289    let mut input = Vec::new();
290    input.extend_from_slice(kind.as_bytes());
291    input.extend_from_slice(generation.to_string().as_bytes());
292    input.extend_from_slice(created_at.to_rfc3339().as_bytes());
293    input.extend_from_slice(flake_url.as_bytes());
294    launch_measurement.extend_signing_input(&mut input);
295
296    let signature = {
297        let signature = signing_key.sign(&input);
298        PublishedVersionEntrySignature {
299            key_id: key_id(&signing_key.verifying_key()).to_vec(),
300            value: signature.to_vec(),
301        }
302    };
303
304    PublishedVersionEntry {
305        kind,
306        generation,
307        created_at,
308        ref_: flake_url.into(),
309        launch_measurement,
310        signature,
311    }
312}
313
314/// Used as serialize function to make sure the string value of `PublishedVersionEntry`'s
315/// `created_at` field matches the value used to build the signature.
316fn rfc_3339_no_z<S: serde::Serializer>(
317    source: &chrono::DateTime<chrono::offset::FixedOffset>,
318    serializer: S,
319) -> Result<S::Ok, S::Error> {
320    source.to_rfc3339().serialize(serializer)
321}
322
323mod base64_compat {
324    use base64::prelude::{Engine, BASE64_STANDARD, BASE64_URL_SAFE_NO_PAD};
325    use serde::{de, Deserialize, Serialize, Serializer};
326
327    pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
328    where
329        D: serde::Deserializer<'de>,
330    {
331        let value: String = Deserialize::deserialize(deserializer)?;
332        let url_safe = value.replace('+', "-").replace('/', "_");
333        let unpadded = url_safe.trim_end_matches('=');
334        BASE64_URL_SAFE_NO_PAD
335            .decode(unpadded)
336            .map_err(de::Error::custom)
337    }
338
339    pub fn serialize<S: Serializer, T: AsRef<[u8]>>(
340        source: &T,
341        serializer: S,
342    ) -> Result<S::Ok, S::Error> {
343        // Serialization still happens with base64 standard because existing consumers don't expect
344        // base64url yet
345        BASE64_STANDARD.encode(source).serialize(serializer)
346    }
347}
348
349#[cfg(test)]
350mod tests {
351    use base64::Engine;
352    use ed25519_dalek::Signature;
353    use rand::{rand_core::UnwrapErr, rngs::SysRng};
354
355    use crate::versions::verifying_key_by_id;
356
357    use super::{
358        signed_version_entry, LaunchMeasurement, LaunchMeasurementError, LaunchMeasurementV1,
359        LaunchMeasurementV2, PublishedVersionEntry,
360    };
361
362    #[test]
363    fn deserialize_published_version_entry() {
364        let entry: PublishedVersionEntry =
365            serde_json::from_str(published_version_entry_json()).unwrap();
366        assert_published_version_entry_fields(&entry);
367    }
368
369    #[test]
370    fn serialization_round_trip() {
371        let entry: PublishedVersionEntry =
372            serde_json::from_str(published_version_entry_json()).unwrap();
373        let serialized = serde_json::to_string_pretty(&entry).unwrap();
374        let entry: PublishedVersionEntry = serde_json::from_str(&serialized).unwrap();
375        assert_published_version_entry_fields(&entry);
376    }
377
378    #[test]
379    fn test_signature() {
380        let entry = published_version_entry();
381        assert!(entry.verify_signature().is_ok());
382    }
383
384    #[test]
385    fn unknown_key_id_fails() {
386        let mut entry = published_version_entry();
387        entry.signature.key_id[0] = entry.signature.key_id[0].wrapping_add(1);
388        assert!(entry.verify_signature().is_err());
389    }
390
391    #[test]
392    fn wrong_signature_fails() {
393        let mut entry = published_version_entry();
394        entry.signature.value[5] = entry.signature.value[5].wrapping_add(1);
395        assert!(entry.verify_signature().is_err());
396    }
397
398    #[test]
399    fn deserializes_standard_base64_for_compatibility() {
400        let entry: PublishedVersionEntry =
401            serde_json::from_str(published_version_entry_standard_base64_json()).unwrap();
402        assert_published_version_entry_fields(&entry);
403    }
404
405    #[test]
406    fn serializes_to_standard_base64_for_compatibility() {
407        use base64::prelude::BASE64_STANDARD;
408
409        let serialized = serde_json::to_string(&published_version_entry()).unwrap();
410        let deserialized: serde_json::Value = serde_json::from_str(&serialized).unwrap();
411
412        let launch_measurement = deserialized
413            .as_object()
414            .and_then(|obj| {
415                obj.get("launchMeasurement")
416                    .and_then(|value| value.as_str())
417            })
418            .unwrap();
419        match BASE64_STANDARD.decode(launch_measurement) {
420            Ok(measurement) => assert_launch_measurement(&measurement),
421            Err(err) => panic!("Could not base64 deserialize launch measurement: {err:?}"),
422        }
423    }
424
425    #[test]
426    fn test_signed_version_entry() {
427        use ed25519_dalek::SigningKey;
428
429        let signing_key = SigningKey::generate(&mut UnwrapErr(SysRng));
430
431        let entry = signed_version_entry(
432            published_version_entry().launch_measurement,
433            "github:yaxitech/nixos/a24091a8d5f8dc74f64435c029816c7c2c0c7a70",
434            &signing_key,
435        );
436
437        assert_eq!(entry.kind, "release");
438        assert_eq!(entry.generation, 1);
439        assert_eq!(
440            entry.ref_,
441            "github:yaxitech/nixos/a24091a8d5f8dc74f64435c029816c7c2c0c7a70".to_string()
442        );
443        assert_v1_launch_measurement(&entry);
444        assert!(entry
445            .verify_signature_with_key(&signing_key.verifying_key())
446            .is_ok());
447    }
448
449    #[test]
450    fn test_deserializes_entry_with_z_offset() {
451        let entry: Result<PublishedVersionEntry, _> =
452            serde_json::from_str(published_version_entry_json_z_offset());
453        assert!(entry.is_ok());
454    }
455
456    #[test]
457    fn test_signature_uses_json_time_value() {
458        // Like `PublishedVersionEntry`, but with unparsed string values
459        #[derive(serde::Deserialize)]
460        #[serde(rename_all = "camelCase")]
461        #[allow(unused)]
462        struct RawVersionEntry {
463            pub kind: String,
464            pub generation: usize,
465            pub created_at: String,
466            pub ref_: String,
467            #[serde(with = "super::base64_compat")]
468            pub launch_measurement: Vec<u8>,
469            pub signature: super::PublishedVersionEntrySignature,
470        }
471        let version_entry: RawVersionEntry =
472            serde_json::from_str(published_version_entry_json()).unwrap();
473
474        let mut input = Vec::new();
475        input.extend_from_slice(version_entry.kind.as_bytes());
476        input.extend_from_slice(version_entry.generation.to_string().as_bytes());
477        input.extend_from_slice(version_entry.created_at.as_bytes());
478        input.extend_from_slice(version_entry.ref_.as_bytes());
479        input.extend_from_slice(&version_entry.launch_measurement);
480
481        let signature = Signature::from_slice(&version_entry.signature.value).unwrap();
482        let key = verifying_key_by_id(&version_entry.signature.key_id).unwrap();
483        key.verify_strict(&input, &signature).unwrap();
484    }
485
486    #[test]
487    fn v2_signed_entry_is_valid() {
488        use ed25519_dalek::SigningKey;
489
490        let signing_key = SigningKey::generate(&mut UnwrapErr(SysRng));
491
492        let vmsa = vec![0xabu8; 1992];
493        let measurement = vec![0x42u8; 48];
494
495        let entry = signed_version_entry(
496            LaunchMeasurement::V2(LaunchMeasurementV2 {
497                vmsa: vmsa.clone(),
498                value: measurement.clone(),
499            }),
500            "github:yaxitech/cvms/v0.1.0",
501            &signing_key,
502        );
503
504        assert_eq!(entry.kind, "release");
505        assert_eq!(entry.generation, 2);
506        assert_eq!(entry.ref_, "github:yaxitech/cvms/v0.1.0".to_string());
507
508        match &entry.launch_measurement {
509            LaunchMeasurement::V1(_) => panic!("Expected V2 launch measurement"),
510            LaunchMeasurement::V2(v2) => {
511                assert_eq!(v2.vmsa, vmsa);
512                assert_eq!(v2.value, measurement);
513            }
514        }
515
516        assert!(entry
517            .verify_signature_with_key(&signing_key.verifying_key())
518            .is_ok());
519    }
520
521    #[test]
522    fn v2_serialization_round_trip() {
523        use ed25519_dalek::SigningKey;
524
525        let signing_key = SigningKey::generate(&mut UnwrapErr(SysRng));
526        let entry = signed_version_entry(
527            LaunchMeasurement::V2(LaunchMeasurementV2 {
528                vmsa: vec![0x01u8; 1992],
529                value: vec![0x02u8; 48],
530            }),
531            "github:yaxitech/cvms/v1.0.0",
532            &signing_key,
533        );
534
535        let serialized = serde_json::to_string_pretty(&entry).unwrap();
536        let deserialized: serde_json::Value = serde_json::from_str(&serialized).unwrap();
537
538        // Verify launchMeasurement is an object (not a string) in gen 2
539        assert!(
540            deserialized["launchMeasurement"].is_object(),
541            "gen 2 launchMeasurement should serialize as object"
542        );
543        assert!(deserialized["launchMeasurement"]["vmsa"].is_string());
544        assert!(deserialized["launchMeasurement"]["value"].is_string());
545
546        // Round-trip
547        let restored: PublishedVersionEntry = serde_json::from_str(&serialized).unwrap();
548        assert!(restored
549            .verify_signature_with_key(&signing_key.verifying_key())
550            .is_ok());
551    }
552
553    #[test]
554    fn v2_wrong_signature_fails() {
555        use ed25519_dalek::SigningKey;
556
557        let signing_key = SigningKey::generate(&mut UnwrapErr(SysRng));
558        let mut entry = signed_version_entry(
559            LaunchMeasurement::V2(LaunchMeasurementV2 {
560                vmsa: vec![0xabu8; 1992],
561                value: vec![0x42u8; 48],
562            }),
563            "github:yaxitech/cvms/v0.1.0",
564            &signing_key,
565        );
566
567        entry.signature.value[5] = entry.signature.value[5].wrapping_add(1);
568        assert!(entry
569            .verify_signature_with_key(&signing_key.verifying_key())
570            .is_err());
571    }
572
573    #[test]
574    fn launch_measurement_deserializes_both_variants() {
575        // V1: plain base64 string
576        let v1: LaunchMeasurement = serde_json::from_str(r#""WUFYSQ==""#).unwrap();
577        assert!(matches!(v1, LaunchMeasurement::V1(ref m) if m.0 == b"YAXI"));
578
579        // V2: object with vmsa and value (url-safe base64)
580        let v2: LaunchMeasurement =
581            serde_json::from_str(r#"{"vmsa": "_wD_", "value": "AP__"}"#).unwrap();
582        assert!(matches!(v2, LaunchMeasurement::V2(ref m)
583                if m.vmsa == [0xff, 0x00, 0xff] && m.value == [0x00, 0xff, 0xff]));
584    }
585
586    fn published_version_entry() -> PublishedVersionEntry {
587        serde_json::from_str(published_version_entry_json()).unwrap()
588    }
589
590    fn published_version_entry_standard_base64_json() -> &'static str {
591        r#"
592            {
593              "kind": "release",
594              "generation": 1,
595              "createdAt": "2025-03-03T21:53:07.650800415+00:00",
596              "ref": "github:yaxitech/nixos/a24091a8d5f8dc74f64435c029816c7c2c0c7a70",
597              "launchMeasurement": "QZPtHQhKa5bVGeNnPOBwzH1ycRDNYFy1/yyQl2Tq+T+VOHelRODRYI5kPd6u5dgV",
598              "signature": {
599                "keyId": "dYa685dhHap8RSUtB4DDy1l4UcycsGhklBnV5a/4HSg=",
600                "value": "NJpXrwzxi570HwBMGrlDndesoFiOPyaMXzAYjwzr+LGHnlr+gGrnX6pIOAXKdH9XVOwziAX7hYs44Ii9Gxc2CA=="
601              }
602            }
603        "#
604    }
605
606    fn published_version_entry_json() -> &'static str {
607        r#"
608            {
609              "kind": "release",
610              "generation": 1,
611              "createdAt": "2025-03-03T21:53:07.650800415+00:00",
612              "ref": "github:yaxitech/nixos/a24091a8d5f8dc74f64435c029816c7c2c0c7a70",
613              "launchMeasurement": "QZPtHQhKa5bVGeNnPOBwzH1ycRDNYFy1_yyQl2Tq-T-VOHelRODRYI5kPd6u5dgV",
614              "signature": {
615                "keyId": "dYa685dhHap8RSUtB4DDy1l4UcycsGhklBnV5a_4HSg=",
616                "value": "NJpXrwzxi570HwBMGrlDndesoFiOPyaMXzAYjwzr-LGHnlr-gGrnX6pIOAXKdH9XVOwziAX7hYs44Ii9Gxc2CA=="
617              }
618            }
619        "#
620    }
621
622    fn published_version_entry_json_z_offset() -> &'static str {
623        r#"
624            {
625              "kind": "routex-release",
626              "generation": 1,
627              "createdAt": "2024-03-26T19:16:40.269247621Z",
628              "ref": "github:yaxitech/nixos/a24091a8d5f8dc74f64435c029816c7c2c0c7a70",
629              "launchMeasurement": "QZPtHQhKa5bVGeNnPOBwzH1ycRDNYFy1_yyQl2Tq-T-VOHelRODRYI5kPd6u5dgV",
630              "signature": {
631                "keyId": "dYa685dhHap8RSUtB4DDy1l4UcycsGhklBnV5a_4HSg=",
632                "value": "w9kKh0xKFNKm5Mcn1-VSiMgCWs_jAZIAkJfGjiTkFQnj4CYD5qskb2LnQeE4CJa9CrgmT21l5pkIJR7gUP6UDA=="
633              }
634            }
635        "#
636    }
637
638    fn assert_published_version_entry_fields(entry: &PublishedVersionEntry) {
639        assert_eq!(entry.kind, "release");
640        assert_eq!(entry.generation, 1);
641        assert_eq!(
642            entry.ref_,
643            "github:yaxitech/nixos/a24091a8d5f8dc74f64435c029816c7c2c0c7a70".to_string()
644        );
645        assert_v1_launch_measurement(entry);
646        assert_eq!(
647            entry.signature.key_id,
648            &[
649                117, 134, 186, 243, 151, 97, 29, 170, 124, 69, 37, 45, 7, 128, 195, 203, 89, 120,
650                81, 204, 156, 176, 104, 100, 148, 25, 213, 229, 175, 248, 29, 40
651            ],
652        );
653    }
654
655    fn assert_v1_launch_measurement(entry: &PublishedVersionEntry) {
656        match &entry.launch_measurement {
657            LaunchMeasurement::V1(measurement) => assert_launch_measurement(&measurement.0),
658            LaunchMeasurement::V2(_) => panic!("Expected V1 launch measurement"),
659        }
660    }
661
662    fn assert_launch_measurement(actual: &[u8]) {
663        assert_eq!(
664            actual,
665            &[
666                65, 147, 237, 29, 8, 74, 107, 150, 213, 25, 227, 103, 60, 224, 112, 204, 125, 114,
667                113, 16, 205, 96, 92, 181, 255, 44, 144, 151, 100, 234, 249, 63, 149, 56, 119, 165,
668                68, 224, 209, 96, 142, 100, 61, 222, 174, 229, 216, 21
669            ]
670        );
671    }
672
673    #[test]
674    fn v1_resolve_returns_stored_digest() {
675        let bytes = [0x42u8; 48];
676        let measurement = LaunchMeasurement::V1(LaunchMeasurementV1(bytes.to_vec()));
677        assert_eq!(measurement.resolve(4), bytes);
678    }
679
680    #[test]
681    fn v2_resolve_with_zero_vcpus_returns_pre_vmsa_digest() {
682        let digest = [0x42u8; 48];
683        let measurement = LaunchMeasurement::V2(LaunchMeasurementV2 {
684            vmsa: vec![0xab; 1992],
685            value: digest.to_vec(),
686        });
687        assert_eq!(measurement.resolve(0), digest);
688    }
689
690    #[test]
691    fn v2_resolve_changes_with_vcpu_count() {
692        let measurement = LaunchMeasurement::V2(LaunchMeasurementV2 {
693            vmsa: vec![0xab; 1992],
694            value: vec![0x42; 48],
695        });
696        assert_ne!(measurement.resolve(1), measurement.resolve(2));
697    }
698
699    #[test]
700    fn v1_try_resolve_rejects_invalid_measurement_length() {
701        let measurement = LaunchMeasurementV1(vec![0x42; 47]);
702        assert!(matches!(
703            measurement.try_resolve(),
704            Err(LaunchMeasurementError::InvalidMeasurementLength(47))
705        ));
706    }
707
708    #[test]
709    fn v2_try_resolve_rejects_invalid_measurement_length() {
710        let measurement = LaunchMeasurementV2 {
711            vmsa: vec![0xab; 1992],
712            value: vec![0x42; 49],
713        };
714        assert!(matches!(
715            measurement.try_resolve(1),
716            Err(LaunchMeasurementError::InvalidMeasurementLength(49))
717        ));
718    }
719
720    #[test]
721    fn v2_try_resolve_rejects_invalid_vmsa_length() {
722        let measurement = LaunchMeasurementV2 {
723            vmsa: vec![0xab; 4097],
724            value: vec![0x42; 48],
725        };
726        assert!(matches!(
727            measurement.try_resolve(1),
728            Err(LaunchMeasurementError::InvalidVmsaLength(4097))
729        ));
730    }
731}