Skip to main content

varve_core/
selfverify.rs

1//! Self-verification (REQ-SELF-001, DD-009) — the tool that gates the
2//! toolchain clears its own gate.
3//!
4//! varve releases carry a varve-native signature: a DSSE envelope over the
5//! release's SHA256SUMS.txt, signed with the same root key deposits use.
6//! `varve self-verify` checks a candidate archive against that envelope with
7//! the pinned trust root, fully offline, failing closed — including when the
8//! envelope is absent, because "unsigned" is a verdict, not a shrug.
9
10use crate::install::VerifyError;
11use crate::store::manifest_digest;
12use crate::verify::{dsse_sign_typed, dsse_verify_typed};
13
14/// The authenticated payload type for release checksum envelopes.
15pub const RELEASE_SUMS_PAYLOAD_TYPE: &str =
16    "application/vnd.pulseengine.varve.release-sums.v1+json";
17
18#[derive(Debug, thiserror::Error)]
19pub enum SelfVerifyError {
20    #[error(transparent)]
21    Verify(#[from] VerifyError),
22    #[error("SHA256SUMS payload is not UTF-8 text")]
23    NotText,
24    #[error("'{name}' has no entry in the signed SHA256SUMS — refusing to trust it")]
25    NoEntry { name: String },
26    #[error(
27        "'{name}' does not match its signed digest — expected {expected}, the file hashes to {actual}"
28    )]
29    DigestMismatch {
30        name: String,
31        expected: String,
32        actual: String,
33    },
34}
35
36/// Verify one release file against the signed sums envelope. Returns the
37/// verified digest on success.
38pub fn verify_release_file(
39    file_name: &str,
40    file_bytes: &[u8],
41    sums_envelope: &[u8],
42    root_public_key: &[u8],
43) -> Result<String, SelfVerifyError> {
44    let payload = dsse_verify_typed(sums_envelope, RELEASE_SUMS_PAYLOAD_TYPE, root_public_key)?;
45    let text = std::str::from_utf8(&payload).map_err(|_| SelfVerifyError::NotText)?;
46    // sha256sum format: "<hex>  <name>" (name possibly ./-prefixed).
47    let expected = text
48        .lines()
49        .filter_map(|line| {
50            let (hex, name) = line.split_once("  ")?;
51            let name = name.trim().trim_start_matches("./");
52            (name == file_name).then(|| hex.trim().to_string())
53        })
54        .next()
55        .ok_or_else(|| SelfVerifyError::NoEntry {
56            name: file_name.to_string(),
57        })?;
58    let actual = manifest_digest(file_bytes);
59    let actual_hex = actual
60        .strip_prefix("sha256:")
61        .expect("digest shape")
62        .to_string();
63    if actual_hex != expected {
64        return Err(SelfVerifyError::DigestMismatch {
65            name: file_name.to_string(),
66            expected,
67            actual: actual_hex,
68        });
69    }
70    Ok(actual)
71}
72
73/// Sign a SHA256SUMS.txt into the release envelope (the CI side).
74pub fn sign_release_sums(
75    sums: &[u8],
76    secret_key: &[u8],
77    key_id: &str,
78) -> Result<String, VerifyError> {
79    dsse_sign_typed(sums, RELEASE_SUMS_PAYLOAD_TYPE, secret_key, key_id)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::verify::generate_root_keypair;
86
87    fn fixture() -> (Vec<u8>, String, Vec<u8>, Vec<u8>) {
88        let (sk, pk) = generate_root_keypair();
89        let archive = b"pretend-tarball-bytes".to_vec();
90        let digest = manifest_digest(&archive);
91        let hex = digest.strip_prefix("sha256:").unwrap();
92        let sums = format!(
93            "{hex}  ./varve-v9.9.9-aarch64-apple-darwin.tar.gz\n\
94             0000000000000000000000000000000000000000000000000000000000000000  ./other.txt\n"
95        );
96        let envelope = sign_release_sums(sums.as_bytes(), &sk, "varve-root-1").unwrap();
97        (archive, envelope, pk, sk)
98    }
99
100    // rivet: verifies REQ-SELF-001
101    #[test]
102    fn a_release_archive_verifies_against_the_signed_sums() {
103        let (archive, envelope, pk, _) = fixture();
104        let digest = verify_release_file(
105            "varve-v9.9.9-aarch64-apple-darwin.tar.gz",
106            &archive,
107            envelope.as_bytes(),
108            &pk,
109        )
110        .unwrap();
111        assert!(digest.starts_with("sha256:"));
112    }
113
114    // rivet: verifies REQ-SELF-001
115    #[test]
116    fn a_tampered_archive_is_refused() {
117        let (mut archive, envelope, pk, _) = fixture();
118        archive.push(b'!');
119        let err = verify_release_file(
120            "varve-v9.9.9-aarch64-apple-darwin.tar.gz",
121            &archive,
122            envelope.as_bytes(),
123            &pk,
124        )
125        .unwrap_err();
126        assert!(
127            matches!(err, SelfVerifyError::DigestMismatch { .. }),
128            "got: {err}"
129        );
130    }
131
132    // rivet: verifies REQ-SELF-001
133    #[test]
134    fn a_file_outside_the_signed_sums_is_refused() {
135        let (archive, envelope, pk, _) = fixture();
136        let err =
137            verify_release_file("sneaky.tar.gz", &archive, envelope.as_bytes(), &pk).unwrap_err();
138        assert!(matches!(err, SelfVerifyError::NoEntry { .. }), "got: {err}");
139    }
140
141    // rivet: verifies REQ-SELF-001
142    #[test]
143    fn sums_signed_by_an_untrusted_key_are_refused() {
144        let (archive, _, _, _) = fixture();
145        let (other_sk, other_pk) = generate_root_keypair();
146        let _ = other_pk;
147        let digest = manifest_digest(&archive);
148        let hex = digest.strip_prefix("sha256:").unwrap();
149        let sums = format!("{hex}  ./x.tar.gz\n");
150        let impostor = sign_release_sums(sums.as_bytes(), &other_sk, "evil").unwrap();
151        let (_, _, pk, _) = fixture();
152        assert!(
153            verify_release_file("x.tar.gz", &archive, impostor.as_bytes(), &pk).is_err(),
154            "impostor sums must not verify"
155        );
156    }
157
158    // rivet: verifies REQ-SELF-001
159    #[test]
160    fn a_layer_manifest_envelope_cannot_pose_as_release_sums() {
161        let (archive, _, pk, sk) = fixture();
162        let not_sums = crate::verify::sign_layer_manifest(b"{}", &sk, "k");
163        // sign_layer_manifest refuses nothing here — it signs any payload —
164        // but its payload TYPE differs, so verification must refuse it.
165        let envelope = not_sums.unwrap();
166        let err = verify_release_file("x.tar.gz", &archive, envelope.as_bytes(), &pk).unwrap_err();
167        assert!(err.to_string().contains("payload type"), "got: {err}");
168    }
169}