1#![doc = include_str!("../README.md")]
2
3use std::path::Path;
4use std::time::SystemTime;
5use std::{collections::HashMap, path::PathBuf};
6
7use digest_io::IoWrapper;
8use rand::Rng;
9use semver::Version;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12use sha2::Digest;
13pub use sha2::Sha512;
14use sha2::digest::common::hazmat::{DeserializeStateError, SerializableState};
15
16use crate::ssh::client::ConnectConfig;
17
18#[cfg(feature = "cli")]
19pub mod cli;
20pub mod ssh;
21
22#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum Error {
26 #[error("Invalid content type. Found {actual:?} but expected {expected:?}.")]
28 InvalidContentType {
29 actual: HashType,
31
32 expected: HashType,
34 },
35
36 #[error("Malformed content size")]
38 InvalidContentSize,
39
40 #[error("Deserialization of the hasher's state failed: {0}")]
42 HasherDeserialization(#[from] DeserializeStateError),
43
44 #[error("Could not deserialize request: {0}")]
46 RequestDeserialization(#[from] serde_json::Error),
47
48 #[error("I/O error: {source} when processing {file}")]
50 Io {
51 file: PathBuf,
56
57 source: std::io::Error,
59 },
60
61 #[error("Current time is before reference time {reference_time:?}: {source}")]
63 CurrentTimeBeforeReference {
64 reference_time: SystemTime,
66 source: std::time::SystemTimeError,
68 },
69
70 #[error("SSH client error: {0}")]
72 SshClient(#[from] crate::ssh::client::Error),
73
74 #[error("TOML deserialization error: {0}")]
76 Toml(#[from] toml::de::Error),
77
78 #[error("No configuration files present in any of the default paths:\n {paths}", paths = ConnectConfig::CONFIG_ORDER.iter().map(|path| format!("- {path}")).collect::<Vec<_>>().join("\n"))]
80 ConfigMissing,
81
82 #[error("Validation error while {context}: {source}")]
84 Validation {
85 context: String,
89
90 source: garde::Report,
92 },
93}
94
95#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
97#[serde(rename_all = "lowercase")]
98pub enum HashType {
99 #[serde(rename = "sha2-0.11-SHA512-state")]
103 #[expect(
104 non_camel_case_types,
105 reason = "Rust suggested casing (Sha2_0_11Sha512State) is unreadable"
106 )]
107 Sha2_0_11_Sha512_State,
108}
109
110#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
112pub enum SignatureType {
113 #[serde(rename = "OpenPGPv4")]
115 OpenPgpV4,
116}
117
118#[derive(Debug, Deserialize, Serialize)]
120pub struct SignatureRequestInput {
121 #[serde(rename = "type")]
122 hash_type: HashType,
123 content: Vec<u8>,
124}
125
126#[derive(Debug, Deserialize, Serialize)]
128pub struct SignatureRequestOutput {
129 #[serde(rename = "type")]
131 sig_type: SignatureType,
132}
133
134impl SignatureRequestOutput {
135 pub fn new_openpgp_v4() -> Self {
138 Self {
139 sig_type: SignatureType::OpenPgpV4,
140 }
141 }
142
143 pub fn is_openpgp_v4(&self) -> bool {
146 self.sig_type == SignatureType::OpenPgpV4
147 }
148}
149
150impl From<sha2::Sha512> for SignatureRequestInput {
151 fn from(value: sha2::Sha512) -> Self {
152 Self {
153 hash_type: HashType::Sha2_0_11_Sha512_State,
154 content: value.serialize().to_vec(),
155 }
156 }
157}
158
159impl TryFrom<SignatureRequestInput> for sha2::Sha512 {
160 type Error = Error;
161 fn try_from(value: SignatureRequestInput) -> Result<Self, Self::Error> {
162 if value.hash_type != HashType::Sha2_0_11_Sha512_State {
163 return Err(Error::InvalidContentType {
164 actual: value.hash_type,
165 expected: HashType::Sha2_0_11_Sha512_State,
166 });
167 }
168
169 let hasher = sha2::Sha512::deserialize(
170 value.content[..]
171 .try_into()
172 .map_err(|_| Error::InvalidContentSize)?,
173 )?;
174
175 Ok(hasher)
176 }
177}
178
179#[derive(Debug, Deserialize, Serialize)]
181pub struct Required {
182 pub input: SignatureRequestInput,
184
185 pub output: SignatureRequestOutput,
187}
188
189#[derive(Debug, Deserialize, Serialize)]
191pub struct Request {
192 pub version: Version,
194
195 pub required: Required,
200
201 pub optional: HashMap<String, Value>,
207}
208
209impl Request {
210 pub fn from_reader(reader: impl std::io::Read) -> Result<Self, Error> {
217 let req: Request = serde_json::from_reader(reader)?;
218 Ok(req)
219 }
220
221 pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
228 serde_json::to_writer(writer, &self)?;
229 Ok(())
230 }
231
232 pub fn for_file(input: impl AsRef<Path>) -> Result<Self, Error> {
254 let input = input.as_ref();
255 let pack_err = |source| Error::Io {
256 file: input.into(),
257 source,
258 };
259 let hasher = {
260 let mut hasher = IoWrapper(sha2::Sha512::new());
261 std::io::copy(
262 &mut std::fs::File::open(input).map_err(pack_err)?,
263 &mut hasher,
264 )
265 .map_err(pack_err)?;
266 hasher.0
267 };
268 let required = Required {
269 input: hasher.into(),
270 output: SignatureRequestOutput::new_openpgp_v4(),
271 };
272
273 let grease: String = rand::thread_rng()
277 .sample_iter(&rand::distributions::Alphanumeric)
278 .take(7)
279 .map(char::from)
280 .collect();
281
282 Ok(Self {
283 version: semver::Version::new(1, 0, 0),
284 required,
285 optional: vec![
286 (
287 grease,
288 Value::String(
289 "https://gitlab.archlinux.org/archlinux/signstar/-/merge_requests/43"
290 .to_string(),
291 ),
292 ),
293 (
294 "request-time".into(),
295 Value::Number(
296 SystemTime::now()
297 .duration_since(SystemTime::UNIX_EPOCH)
298 .map_err(|source| crate::Error::CurrentTimeBeforeReference {
299 reference_time: SystemTime::UNIX_EPOCH,
300 source,
301 })?
302 .as_secs()
303 .into(),
304 ),
305 ),
306 (
307 "file-name".into(),
308 input
309 .file_name()
310 .and_then(|s| s.to_str())
311 .map(Into::into)
312 .unwrap_or(Value::Null),
313 ),
314 ]
315 .into_iter()
316 .collect(),
317 })
318 }
319}
320
321#[derive(Debug, Deserialize, Serialize)]
329pub struct Response {
330 pub version: Version,
332
333 signature: String,
335}
336
337impl Response {
338 pub fn v1(signature: String) -> Self {
340 Self {
341 version: Version::new(1, 0, 0),
342 signature,
343 }
344 }
345
346 pub fn from_reader(reader: impl std::io::Read) -> Result<Self, Error> {
352 let resp: Self = serde_json::from_reader(reader)?;
353 Ok(resp)
354 }
355
356 pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
362 serde_json::to_writer(writer, &self)?;
363 Ok(())
364 }
365
366 pub fn signature_to_writer(&self, mut writer: impl std::io::Write) -> Result<(), Error> {
372 writer
373 .write_all(self.signature.as_bytes())
374 .map_err(|source| Error::Io {
375 file: PathBuf::new(),
376 source,
377 })?;
378 Ok(())
379 }
380}
381
382#[cfg(test)]
383mod tests {
384 use std::{fs::File, path::PathBuf};
385
386 use rstest::rstest;
387 use sha2::Digest;
388 use testresult::TestResult;
389
390 use super::*;
391
392 #[test]
393 fn hash_values_are_predictable() -> testresult::TestResult {
394 let mut hasher = IoWrapper(sha2::Sha512::new());
395 let mut bytes = std::io::Cursor::new(b"this is sample text");
396 std::io::copy(&mut bytes, &mut hasher)?;
397 let result: &[u8] = &hasher.0.serialize();
398
399 let expected_state = [
400 8, 201, 188, 243, 103, 230, 9, 106, 59, 167, 202, 132, 133, 174, 103, 187, 43, 248,
401 148, 254, 114, 243, 110, 60, 241, 54, 29, 95, 58, 245, 79, 165, 209, 130, 230, 173,
402 127, 82, 14, 81, 31, 108, 62, 43, 140, 104, 5, 155, 107, 189, 65, 251, 171, 217, 131,
403 31, 121, 33, 126, 19, 25, 205, 224, 91, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
404 19, 116, 104, 105, 115, 32, 105, 115, 32, 115, 97, 109, 112, 108, 101, 32, 116, 101,
405 120, 116, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
406 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
407 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
408 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
409 ];
410
411 assert_eq!(result, expected_state);
412
413 let expected_digest = [
414 20, 253, 69, 133, 146, 76, 11, 4, 191, 13, 150, 196, 9, 97, 21, 35, 186, 95, 254, 59,
415 148, 60, 88, 155, 127, 203, 151, 216, 11, 16, 228, 73, 113, 23, 115, 110, 198, 42, 109,
416 92, 23, 33, 70, 71, 136, 219, 73, 238, 135, 13, 223, 117, 215, 69, 243, 33, 125, 109,
417 95, 121, 213, 44, 212, 166,
418 ];
419
420 let hasher = sha2::Sha512::deserialize(&expected_state.into())?;
421 let hash = &hasher.finalize()[..];
422 assert_eq!(hash, expected_digest);
423
424 Ok(())
429 }
430
431 #[test]
432 fn sample_request_is_ok() -> TestResult {
433 let reader = File::open("tests/sample-request.json")?;
434 let reader = Request::from_reader(reader)?;
435 let hasher: sha2::Sha512 = reader.required.input.try_into()?;
436 assert_eq!(
437 hasher.finalize(),
438 [
439 20, 253, 69, 133, 146, 76, 11, 4, 191, 13, 150, 196, 9, 97, 21, 35, 186, 95, 254,
440 59, 148, 60, 88, 155, 127, 203, 151, 216, 11, 16, 228, 73, 113, 23, 115, 110, 198,
441 42, 109, 92, 23, 33, 70, 71, 136, 219, 73, 238, 135, 13, 223, 117, 215, 69, 243,
442 33, 125, 109, 95, 121, 213, 44, 212, 166
443 ]
444 );
445 Ok(())
446 }
447
448 #[rstest]
449 fn sample_request_is_bad(#[files("tests/bad-*.json")] request_file: PathBuf) -> TestResult {
450 let reader = File::open(request_file)?;
451 assert!(
452 Request::from_reader(reader).is_err(),
453 "parsing of the request file should fail"
454 );
455 Ok(())
456 }
457}