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 #[allow(non_camel_case_types)]
104 Sha2_0_11_Sha512_State,
105}
106
107#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
109pub enum SignatureType {
110 #[serde(rename = "OpenPGPv4")]
112 OpenPgpV4,
113}
114
115#[derive(Debug, Deserialize, Serialize)]
117pub struct SignatureRequestInput {
118 #[serde(rename = "type")]
119 hash_type: HashType,
120 content: Vec<u8>,
121}
122
123#[derive(Debug, Deserialize, Serialize)]
125pub struct SignatureRequestOutput {
126 #[serde(rename = "type")]
128 sig_type: SignatureType,
129}
130
131impl SignatureRequestOutput {
132 pub fn new_openpgp_v4() -> Self {
135 Self {
136 sig_type: SignatureType::OpenPgpV4,
137 }
138 }
139
140 pub fn is_openpgp_v4(&self) -> bool {
143 self.sig_type == SignatureType::OpenPgpV4
144 }
145}
146
147impl From<sha2::Sha512> for SignatureRequestInput {
148 fn from(value: sha2::Sha512) -> Self {
149 Self {
150 hash_type: HashType::Sha2_0_11_Sha512_State,
151 content: value.serialize().to_vec(),
152 }
153 }
154}
155
156impl TryFrom<SignatureRequestInput> for sha2::Sha512 {
157 type Error = Error;
158 fn try_from(value: SignatureRequestInput) -> Result<Self, Self::Error> {
159 if value.hash_type != HashType::Sha2_0_11_Sha512_State {
160 return Err(Error::InvalidContentType {
161 actual: value.hash_type,
162 expected: HashType::Sha2_0_11_Sha512_State,
163 });
164 }
165
166 let hasher = sha2::Sha512::deserialize(
167 value.content[..]
168 .try_into()
169 .map_err(|_| Error::InvalidContentSize)?,
170 )?;
171
172 Ok(hasher)
173 }
174}
175
176#[derive(Debug, Deserialize, Serialize)]
178pub struct Required {
179 pub input: SignatureRequestInput,
181
182 pub output: SignatureRequestOutput,
184}
185
186#[derive(Debug, Deserialize, Serialize)]
188pub struct Request {
189 pub version: Version,
191
192 pub required: Required,
197
198 pub optional: HashMap<String, Value>,
204}
205
206impl Request {
207 pub fn from_reader(reader: impl std::io::Read) -> Result<Self, Error> {
214 let req: Request = serde_json::from_reader(reader)?;
215 Ok(req)
216 }
217
218 pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
225 serde_json::to_writer(writer, &self)?;
226 Ok(())
227 }
228
229 pub fn for_file(input: impl AsRef<Path>) -> Result<Self, Error> {
251 let input = input.as_ref();
252 let pack_err = |source| Error::Io {
253 file: input.into(),
254 source,
255 };
256 let hasher = {
257 let mut hasher = IoWrapper(sha2::Sha512::new());
258 std::io::copy(
259 &mut std::fs::File::open(input).map_err(pack_err)?,
260 &mut hasher,
261 )
262 .map_err(pack_err)?;
263 hasher.0
264 };
265 let required = Required {
266 input: hasher.into(),
267 output: SignatureRequestOutput::new_openpgp_v4(),
268 };
269
270 let grease: String = rand::thread_rng()
274 .sample_iter(&rand::distributions::Alphanumeric)
275 .take(7)
276 .map(char::from)
277 .collect();
278
279 Ok(Self {
280 version: semver::Version::new(1, 0, 0),
281 required,
282 optional: vec![
283 (
284 grease,
285 Value::String(
286 "https://gitlab.archlinux.org/archlinux/signstar/-/merge_requests/43"
287 .to_string(),
288 ),
289 ),
290 (
291 "request-time".into(),
292 Value::Number(
293 SystemTime::now()
294 .duration_since(SystemTime::UNIX_EPOCH)
295 .map_err(|source| crate::Error::CurrentTimeBeforeReference {
296 reference_time: SystemTime::UNIX_EPOCH,
297 source,
298 })?
299 .as_secs()
300 .into(),
301 ),
302 ),
303 (
304 "file-name".into(),
305 input
306 .file_name()
307 .and_then(|s| s.to_str())
308 .map(Into::into)
309 .unwrap_or(Value::Null),
310 ),
311 ]
312 .into_iter()
313 .collect(),
314 })
315 }
316}
317
318#[derive(Debug, Deserialize, Serialize)]
326pub struct Response {
327 pub version: Version,
329
330 signature: String,
332}
333
334impl Response {
335 pub fn v1(signature: String) -> Self {
337 Self {
338 version: Version::new(1, 0, 0),
339 signature,
340 }
341 }
342
343 pub fn from_reader(reader: impl std::io::Read) -> Result<Self, Error> {
349 let resp: Self = serde_json::from_reader(reader)?;
350 Ok(resp)
351 }
352
353 pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
359 serde_json::to_writer(writer, &self)?;
360 Ok(())
361 }
362
363 pub fn signature_to_writer(&self, mut writer: impl std::io::Write) -> Result<(), Error> {
369 writer
370 .write_all(self.signature.as_bytes())
371 .map_err(|source| Error::Io {
372 file: PathBuf::new(),
373 source,
374 })?;
375 Ok(())
376 }
377}
378
379#[cfg(test)]
380mod tests {
381 use std::{fs::File, path::PathBuf};
382
383 use rstest::rstest;
384 use sha2::Digest;
385 use testresult::TestResult;
386
387 use super::*;
388
389 #[test]
390 fn hash_values_are_predictable() -> testresult::TestResult {
391 let mut hasher = IoWrapper(sha2::Sha512::new());
392 let mut bytes = std::io::Cursor::new(b"this is sample text");
393 std::io::copy(&mut bytes, &mut hasher)?;
394 let result: &[u8] = &hasher.0.serialize();
395
396 let expected_state = [
397 8, 201, 188, 243, 103, 230, 9, 106, 59, 167, 202, 132, 133, 174, 103, 187, 43, 248,
398 148, 254, 114, 243, 110, 60, 241, 54, 29, 95, 58, 245, 79, 165, 209, 130, 230, 173,
399 127, 82, 14, 81, 31, 108, 62, 43, 140, 104, 5, 155, 107, 189, 65, 251, 171, 217, 131,
400 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,
401 19, 116, 104, 105, 115, 32, 105, 115, 32, 115, 97, 109, 112, 108, 101, 32, 116, 101,
402 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,
403 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,
404 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,
405 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 ];
407
408 assert_eq!(result, expected_state);
409
410 let expected_digest = [
411 20, 253, 69, 133, 146, 76, 11, 4, 191, 13, 150, 196, 9, 97, 21, 35, 186, 95, 254, 59,
412 148, 60, 88, 155, 127, 203, 151, 216, 11, 16, 228, 73, 113, 23, 115, 110, 198, 42, 109,
413 92, 23, 33, 70, 71, 136, 219, 73, 238, 135, 13, 223, 117, 215, 69, 243, 33, 125, 109,
414 95, 121, 213, 44, 212, 166,
415 ];
416
417 let hasher = sha2::Sha512::deserialize(&expected_state.into())?;
418 let hash = &hasher.finalize()[..];
419 assert_eq!(hash, expected_digest);
420
421 Ok(())
426 }
427
428 #[test]
429 fn sample_request_is_ok() -> TestResult {
430 let reader = File::open("tests/sample-request.json")?;
431 let reader = Request::from_reader(reader)?;
432 let hasher: sha2::Sha512 = reader.required.input.try_into()?;
433 assert_eq!(
434 hasher.finalize(),
435 [
436 20, 253, 69, 133, 146, 76, 11, 4, 191, 13, 150, 196, 9, 97, 21, 35, 186, 95, 254,
437 59, 148, 60, 88, 155, 127, 203, 151, 216, 11, 16, 228, 73, 113, 23, 115, 110, 198,
438 42, 109, 92, 23, 33, 70, 71, 136, 219, 73, 238, 135, 13, 223, 117, 215, 69, 243,
439 33, 125, 109, 95, 121, 213, 44, 212, 166
440 ]
441 );
442 Ok(())
443 }
444
445 #[rstest]
446 fn sample_request_is_bad(#[files("tests/bad-*.json")] request_file: PathBuf) -> TestResult {
447 let reader = File::open(request_file)?;
448 assert!(
449 Request::from_reader(reader).is_err(),
450 "parsing of the request file should fail"
451 );
452 Ok(())
453 }
454}