Skip to main content

signstar_request_signature/
lib.rs

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/// Signature request processing error.
23#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum Error {
26    /// Invalid content type.
27    #[error("Invalid content type. Found {actual:?} but expected {expected:?}.")]
28    InvalidContentType {
29        /// The content type that was found.
30        actual: HashType,
31
32        /// The content type that was expected.
33        expected: HashType,
34    },
35
36    /// Malformed content size.
37    #[error("Malformed content size")]
38    InvalidContentSize,
39
40    /// Deserialization of hasher's state failed.
41    #[error("Deserialization of the hasher's state failed: {0}")]
42    HasherDeserialization(#[from] DeserializeStateError),
43
44    /// Request deserialization failed.
45    #[error("Could not deserialize request: {0}")]
46    RequestDeserialization(#[from] serde_json::Error),
47
48    /// I/O error occurred.
49    #[error("I/O error: {source} when processing {file}")]
50    Io {
51        /// File being processed.
52        ///
53        /// This field will be empty ([`PathBuf::new`]) if the error
54        /// was encountered when processing generic I/O streams.
55        file: PathBuf,
56
57        /// Source error.
58        source: std::io::Error,
59    },
60
61    /// System time error that occurs when the current time is before the reference time.
62    #[error("Current time is before reference time {reference_time:?}: {source}")]
63    CurrentTimeBeforeReference {
64        /// The reference time.
65        reference_time: SystemTime,
66        /// The error source.
67        source: std::time::SystemTimeError,
68    },
69
70    /// Requesting signing via SSH failed.
71    #[error("SSH client error: {0}")]
72    SshClient(#[from] crate::ssh::client::Error),
73
74    /// TOML deserialization error.
75    #[error("TOML deserialization error: {0}")]
76    Toml(#[from] toml::de::Error),
77
78    /// No configuration files present.
79    #[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    /// A garde validation error occurred.
83    #[error("Validation error while {context}: {source}")]
84    Validation {
85        /// The context in which the error occurred.
86        ///
87        /// This is meant to complete the sentence "Validation error while ".
88        context: String,
89
90        /// The error source.
91        source: garde::Report,
92    },
93}
94
95/// Type of the input hash.
96#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
97#[serde(rename_all = "lowercase")]
98pub enum HashType {
99    /// State of the SHA-512 hasher, as understood by the [`sha2`
100    /// crate](https://crates.io/crates/sha2) in version `0.11` and
101    /// compatible.
102    #[serde(rename = "sha2-0.11-SHA512-state")]
103    #[allow(non_camel_case_types)]
104    Sha2_0_11_Sha512_State,
105}
106
107/// The requested signature type.
108#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
109pub enum SignatureType {
110    /// OpenPGP signature (version 4).
111    #[serde(rename = "OpenPGPv4")]
112    OpenPgpV4,
113}
114
115/// Input of the signing request process.
116#[derive(Debug, Deserialize, Serialize)]
117pub struct SignatureRequestInput {
118    #[serde(rename = "type")]
119    hash_type: HashType,
120    content: Vec<u8>,
121}
122
123/// Outputs of the signing process.
124#[derive(Debug, Deserialize, Serialize)]
125pub struct SignatureRequestOutput {
126    /// Type of the signature to be produced.
127    #[serde(rename = "type")]
128    sig_type: SignatureType,
129}
130
131impl SignatureRequestOutput {
132    /// Create a new signature output which asks for OpenPGP (version
133    /// 4) signature.
134    pub fn new_openpgp_v4() -> Self {
135        Self {
136            sig_type: SignatureType::OpenPgpV4,
137        }
138    }
139
140    /// Indicates if the signature output should be OpenPGP (version
141    /// 4).
142    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/// Required parameters for the signing request operation.
177#[derive(Debug, Deserialize, Serialize)]
178pub struct Required {
179    /// Inputs of the signing procedure.
180    pub input: SignatureRequestInput,
181
182    /// Outputs of the signing procedure.
183    pub output: SignatureRequestOutput,
184}
185
186/// Signing request.
187#[derive(Debug, Deserialize, Serialize)]
188pub struct Request {
189    /// Version of this signing request.
190    pub version: Version,
191
192    /// Required parameters of the signing process.
193    ///
194    /// All required parameters must be understood by the signing
195    /// process or the entire request is to be rejected.
196    pub required: Required,
197
198    /// Optional parameters for the signing process.
199    ///
200    /// The server may ignore any or all parameters in this group. If
201    /// any parameter is not understood by the server it must be
202    /// ignored.
203    pub optional: HashMap<String, Value>,
204}
205
206impl Request {
207    /// Read the request from a JSON serialized bytes.
208    ///
209    /// # Errors
210    ///
211    /// Returns an error if reading the file fails or the file contents
212    /// are not well-formed.
213    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    /// Write the request as a JSON serialized form.
219    ///
220    /// # Errors
221    ///
222    /// Returns an error if serialization of the request fails or writing to
223    /// the `writer` encounters an error.
224    pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
225        serde_json::to_writer(writer, &self)?;
226        Ok(())
227    }
228
229    /// Prepares a signing request for a file.
230    ///
231    /// Given a file as an `input` this function creates a well-formed request.
232    /// That request is of latest known version and contains all necessary fields.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if reading the file fails or forming the request encounters
237    /// an error.
238    ///
239    /// # Examples
240    ///
241    /// The following example creates a signing request for `Cargo.toml`:
242    ///
243    /// ```
244    /// # fn main() -> testresult::TestResult {
245    /// use signstar_request_signature::Request;
246    ///
247    /// let signing_request = Request::for_file("Cargo.toml")?;
248    /// # Ok(()) }
249    /// ```
250    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        // Add "grease" so that the server can handle any optional data
271        // See: https://lobste.rs/s/utmsph/age_plugins#c_i76hkd
272        // See: https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417
273        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/// The response to a signing request.
319///
320/// Tracks the `version` of the signing response and the signature as `signature`.
321///
322/// The details of the format are documented in the [response specification].
323///
324/// [response specification]: https://signstar.archlinux.page/signstar-request-signature/resources/docs/response.html
325#[derive(Debug, Deserialize, Serialize)]
326pub struct Response {
327    /// Version of this signing response.
328    pub version: Version,
329
330    /// Raw content of the signature.
331    signature: String,
332}
333
334impl Response {
335    /// Creates a version 1 compatible signature from raw signature content.
336    pub fn v1(signature: String) -> Self {
337        Self {
338            version: Version::new(1, 0, 0),
339            signature,
340        }
341    }
342
343    /// Creates a [`Response`] from a `reader` of JSON formatted bytes.
344    ///
345    /// # Errors
346    ///
347    /// Returns an error if deserialization from `reader` fails.
348    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    /// Writes the [`Response`] to a `writer` in JSON serialized form.
354    ///
355    /// # Errors
356    ///
357    /// Returns an error if `self` can not be serialized or if writing to `writer` fails.
358    pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
359        serde_json::to_writer(writer, &self)?;
360        Ok(())
361    }
362
363    /// Writes the raw signature of the [`Response`] to a `writer`.
364    ///
365    /// # Errors
366    ///
367    /// Returns an error if the signature can not be written to the `writer`.
368    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        //let hasher = old_sha2::Sha512::deserialize(&expected_state.try_into()?)?;
422        //let hash = &hasher.finalize()[..];
423        //assert_eq!(hash, expected_digest);
424
425        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}