1use std::{
10 collections::{BTreeMap, BTreeSet},
11 io::{Cursor, Read, Seek, Write},
12};
13
14use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
15use rill_runtime_protocol::ReleaseIndexPayload;
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19use thiserror::Error;
20use zip::{ZipArchive, ZipWriter, write::SimpleFileOptions};
21
22const MANIFEST_PATH: &str = "manifest.json";
23const CHECKSUMS_PATH: &str = "checksums.json";
24const SIGNATURE_PATH: &str = "META-INF/signature.ed25519";
25
26#[derive(Debug, Default, Clone)]
27pub struct TrustStore(pub BTreeMap<String, VerifyingKey>);
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub(crate) struct Checksums {
32 schema_version: u32,
33 files: BTreeMap<String, String>,
34}
35
36#[derive(Debug, Error)]
37pub enum ArchiveError {
38 #[error("zip error: {0}")]
39 Zip(#[from] zip::result::ZipError),
40 #[error("I/O error: {0}")]
41 Io(#[from] std::io::Error),
42 #[error("JSON error: {0}")]
43 Json(#[from] serde_json::Error),
44 #[error("unsafe package path {0}")]
45 UnsafePath(String),
46 #[error("forbidden package file {0}")]
47 Forbidden(String),
48 #[error("duplicate package file {0}")]
49 Duplicate(String),
50 #[error("package exceeded {0} limit")]
51 Limit(&'static str),
52 #[error("missing package file {0}")]
53 Missing(&'static str),
54 #[error("missing package file {0}")]
55 MissingOwned(String),
56 #[error("checksum coverage does not exactly match the payload")]
57 ChecksumCoverage,
58 #[error("checksum mismatch for {0}")]
59 Digest(String),
60 #[error("unknown publisher key")]
61 UnknownKey,
62 #[error("signature verification failed")]
63 Signature,
64}
65
66#[derive(Debug, Error)]
67pub enum ReleaseIndexError {
68 #[error("JSON error: {0}")]
69 Json(#[from] serde_json::Error),
70 #[error("invalid release index: {0}")]
71 Manifest(String),
72 #[error("unknown release-index publisher key")]
73 UnknownKey,
74 #[error("release-index signature verification failed")]
75 Signature,
76 #[error("canonical JSON error: {0}")]
77 Canonical(ArchiveError),
78}
79
80#[derive(Debug, Clone, Copy)]
82pub(crate) struct ArchiveLimits {
83 pub max_files: usize,
84 pub max_file_bytes: u64,
85 pub max_total_bytes: u64,
86 pub max_compressed_total_bytes: u64,
87 pub max_compression_ratio: u64,
88}
89
90pub(crate) struct PackPaths {
92 pub manifest: &'static str,
93 pub checksums: &'static str,
94 pub signature: &'static str,
95}
96
97pub(crate) const DEFAULT_PATHS: PackPaths = PackPaths {
98 manifest: MANIFEST_PATH,
99 checksums: CHECKSUMS_PATH,
100 signature: SIGNATURE_PATH,
101};
102
103pub fn canonical_json(bytes: &[u8]) -> Result<Vec<u8>, ArchiveError> {
104 fn canonical(value: Value) -> Value {
105 match value {
106 Value::Object(map) => {
107 let sorted: BTreeMap<String, Value> = map
110 .into_iter()
111 .map(|(key, value)| (key, canonical(value)))
112 .collect();
113 Value::Object(sorted.into_iter().collect())
114 }
115 Value::Array(items) => Value::Array(items.into_iter().map(canonical).collect()),
116 other => other,
117 }
118 }
119 let value: Value = serde_json::from_slice(bytes)?;
120 Ok(serde_json::to_vec(&canonical(value))?)
121}
122
123pub fn sign_release_index(
124 payload: ReleaseIndexPayload,
125 signing_key: &SigningKey,
126) -> Result<rill_runtime_protocol::SignedReleaseIndex, ReleaseIndexError> {
127 validate_release_payload(&payload)?;
128 let serialized = serde_json::to_vec(&payload)?;
129 let canonical = canonical_json(&serialized).map_err(ReleaseIndexError::Canonical)?;
130 let signature = hex::encode(signing_key.sign(&canonical).to_bytes());
131 Ok(rill_runtime_protocol::SignedReleaseIndex { payload, signature })
132}
133
134pub fn verify_release_index(
135 index: &rill_runtime_protocol::SignedReleaseIndex,
136 trust: &TrustStore,
137) -> Result<(), ReleaseIndexError> {
138 validate_release_payload(&index.payload)?;
139 let signature_bytes =
140 hex::decode(&index.signature).map_err(|_| ReleaseIndexError::Signature)?;
141 let signature =
142 Signature::from_slice(&signature_bytes).map_err(|_| ReleaseIndexError::Signature)?;
143 let key = trust
144 .0
145 .get(&index.payload.publisher_key_id)
146 .ok_or(ReleaseIndexError::UnknownKey)?;
147 let serialized = serde_json::to_vec(&index.payload)?;
148 let canonical = canonical_json(&serialized).map_err(ReleaseIndexError::Canonical)?;
149 key.verify(&canonical, &signature)
150 .map_err(|_| ReleaseIndexError::Signature)
151}
152
153fn validate_release_payload(payload: &ReleaseIndexPayload) -> Result<(), ReleaseIndexError> {
154 payload
155 .validate_shape()
156 .map_err(|message| ReleaseIndexError::Manifest(message.into()))?;
157 let mut identities = BTreeSet::new();
158 for artifact in &payload.artifacts {
159 semver::Version::parse(&artifact.version).map_err(|error| {
160 ReleaseIndexError::Manifest(format!("invalid artifact version: {error}"))
161 })?;
162 let identity = (
163 artifact.kind.clone(),
164 artifact.id.clone(),
165 artifact.target_os.clone(),
166 artifact.target_arch.clone(),
167 artifact.handler_api_version,
168 );
169 if !identities.insert(identity) {
170 return Err(ReleaseIndexError::Manifest(
171 "duplicate release artifact identity".into(),
172 ));
173 }
174 }
175 Ok(())
176}
177
178pub(crate) fn read_archive<R: Read + Seek>(
181 reader: R,
182 allowed: &[&str],
183 limits: ArchiveLimits,
184) -> Result<BTreeMap<String, Vec<u8>>, ArchiveError> {
185 let mut archive = ZipArchive::new(reader)?;
186 if archive.len() > limits.max_files {
187 return Err(ArchiveError::Limit("file count"));
188 }
189 let mut total = 0u64;
190 let mut compressed_total = 0u64;
191 let mut files = BTreeMap::new();
192 for index in 0..archive.len() {
193 let mut entry = archive.by_index(index)?;
194 if entry.is_dir() {
195 continue;
196 }
197 let name = entry.name().to_string();
198 validate_path(&name)?;
199 if !allowed.iter().any(|allowed| *allowed == name) {
200 return Err(ArchiveError::Forbidden(name));
201 }
202 if entry.size() > limits.max_file_bytes {
203 return Err(ArchiveError::Limit("file size"));
204 }
205 let compressed = entry.compressed_size();
206 if compressed > 0 && entry.size() / compressed > limits.max_compression_ratio {
207 return Err(ArchiveError::Limit("compression ratio"));
208 }
209 total = total
210 .checked_add(entry.size())
211 .ok_or(ArchiveError::Limit("total size"))?;
212 if total > limits.max_total_bytes {
213 return Err(ArchiveError::Limit("total size"));
214 }
215 compressed_total = compressed_total
216 .checked_add(compressed)
217 .ok_or(ArchiveError::Limit("compressed total size"))?;
218 if compressed_total > limits.max_compressed_total_bytes {
219 return Err(ArchiveError::Limit("compressed total size"));
220 }
221 let mut bytes = Vec::with_capacity(entry.size() as usize);
222 entry.read_to_end(&mut bytes)?;
223 if files.insert(name.clone(), bytes).is_some() {
224 return Err(ArchiveError::Duplicate(name));
225 }
226 }
227 Ok(files)
228}
229
230pub(crate) fn verify_checksums_and_signature(
235 files: &BTreeMap<String, Vec<u8>>,
236 paths: &PackPaths,
237 checksum_payload_names: &[&str],
238 publisher_key_id: &str,
239 trust: &TrustStore,
240) -> Result<(), ArchiveError> {
241 let checksum_bytes = files
242 .get(paths.checksums)
243 .ok_or(ArchiveError::Missing(paths.checksums))?;
244 let checksums: Checksums = serde_json::from_slice(checksum_bytes)?;
245 if checksums.schema_version != 1 {
246 return Err(ArchiveError::Missing("checksum schema version"));
247 }
248 let mut expected_names: Vec<String> = checksum_payload_names
249 .iter()
250 .map(|s| s.to_string())
251 .collect();
252 expected_names.sort();
253 let actual_names: Vec<String> = checksums.files.keys().cloned().collect();
254 if actual_names != expected_names {
255 return Err(ArchiveError::ChecksumCoverage);
256 }
257 for (name, expected) in &checksums.files {
258 let bytes = files
259 .get(name)
260 .ok_or_else(|| ArchiveError::MissingOwned(name.clone()))?;
261 let actual = hex::encode(Sha256::digest(bytes));
262 if &actual != expected {
263 return Err(ArchiveError::Digest(name.clone()));
264 }
265 }
266 let raw_signature = files
267 .get(paths.signature)
268 .ok_or(ArchiveError::Missing(paths.signature))?;
269 let signature = Signature::from_slice(raw_signature).map_err(|_| ArchiveError::Signature)?;
270 let key = trust
271 .0
272 .get(publisher_key_id)
273 .ok_or(ArchiveError::UnknownKey)?;
274 let manifest_bytes = files
275 .get(paths.manifest)
276 .ok_or(ArchiveError::Missing(paths.manifest))?;
277 let mut message = canonical_json(manifest_bytes)?;
278 message.push(b'\n');
279 message.extend(canonical_json(checksum_bytes)?);
280 key.verify(&message, &signature)
281 .map_err(|_| ArchiveError::Signature)
282}
283
284pub(crate) fn build_signed_archive(
287 manifest_bytes: &[u8],
288 payload_name: &str,
289 payload_bytes: &[u8],
290 signing_key: &SigningKey,
291) -> Result<Vec<u8>, ArchiveError> {
292 let checksums = Checksums {
293 schema_version: 1,
294 files: BTreeMap::from([
295 (
296 MANIFEST_PATH.into(),
297 hex::encode(Sha256::digest(manifest_bytes)),
298 ),
299 (
300 payload_name.into(),
301 hex::encode(Sha256::digest(payload_bytes)),
302 ),
303 ]),
304 };
305 let checksum_bytes = serde_json::to_vec_pretty(&checksums)?;
306 let mut message = canonical_json(manifest_bytes)?;
307 message.push(b'\n');
308 message.extend(canonical_json(&checksum_bytes)?);
309 let signature = signing_key.sign(&message).to_bytes();
310
311 let mut output = Cursor::new(Vec::new());
312 {
313 let mut archive = ZipWriter::new(&mut output);
314 let options = SimpleFileOptions::default()
315 .compression_method(zip::CompressionMethod::Deflated)
316 .unix_permissions(0o644);
317 for (name, bytes) in [
318 (MANIFEST_PATH, manifest_bytes),
319 (payload_name, payload_bytes),
320 (CHECKSUMS_PATH, checksum_bytes.as_slice()),
321 (SIGNATURE_PATH, signature.as_slice()),
322 ] {
323 archive.start_file(name, options)?;
324 archive.write_all(bytes)?;
325 }
326 archive.finish()?;
327 }
328 Ok(output.into_inner())
329}
330
331fn validate_path(name: &str) -> Result<(), ArchiveError> {
332 if name.starts_with('/')
333 || name.contains('\\')
334 || name
335 .split('/')
336 .any(|part| part.is_empty() || part == "." || part == "..")
337 {
338 return Err(ArchiveError::UnsafePath(name.into()));
339 }
340 Ok(())
341}