1use crate::install::VerifyError;
11use crate::store::manifest_digest;
12use crate::verify::{dsse_sign_typed, dsse_verify_typed};
13
14pub 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
36pub 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 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
73pub 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 #[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 #[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 #[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 #[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 #[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 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}