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    #[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/// The requested signature type.
111#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
112pub enum SignatureType {
113    /// OpenPGP signature (version 4).
114    #[serde(rename = "OpenPGPv4")]
115    OpenPgpV4,
116}
117
118/// Input of the signing request process.
119#[derive(Debug, Deserialize, Serialize)]
120pub struct SignatureRequestInput {
121    #[serde(rename = "type")]
122    hash_type: HashType,
123    content: Vec<u8>,
124}
125
126/// Outputs of the signing process.
127#[derive(Debug, Deserialize, Serialize)]
128pub struct SignatureRequestOutput {
129    /// Type of the signature to be produced.
130    #[serde(rename = "type")]
131    sig_type: SignatureType,
132}
133
134impl SignatureRequestOutput {
135    /// Create a new signature output which asks for OpenPGP (version
136    /// 4) signature.
137    pub fn new_openpgp_v4() -> Self {
138        Self {
139            sig_type: SignatureType::OpenPgpV4,
140        }
141    }
142
143    /// Indicates if the signature output should be OpenPGP (version
144    /// 4).
145    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/// Required parameters for the signing request operation.
180#[derive(Debug, Deserialize, Serialize)]
181pub struct Required {
182    /// Inputs of the signing procedure.
183    pub input: SignatureRequestInput,
184
185    /// Outputs of the signing procedure.
186    pub output: SignatureRequestOutput,
187}
188
189/// Signing request.
190#[derive(Debug, Deserialize, Serialize)]
191pub struct Request {
192    /// Version of this signing request.
193    pub version: Version,
194
195    /// Required parameters of the signing process.
196    ///
197    /// All required parameters must be understood by the signing
198    /// process or the entire request is to be rejected.
199    pub required: Required,
200
201    /// Optional parameters for the signing process.
202    ///
203    /// The server may ignore any or all parameters in this group. If
204    /// any parameter is not understood by the server it must be
205    /// ignored.
206    pub optional: HashMap<String, Value>,
207}
208
209impl Request {
210    /// Read the request from a JSON serialized bytes.
211    ///
212    /// # Errors
213    ///
214    /// Returns an error if reading the file fails or the file contents
215    /// are not well-formed.
216    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    /// Write the request as a JSON serialized form.
222    ///
223    /// # Errors
224    ///
225    /// Returns an error if serialization of the request fails or writing to
226    /// the `writer` encounters an error.
227    pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
228        serde_json::to_writer(writer, &self)?;
229        Ok(())
230    }
231
232    /// Prepares a signing request for a file.
233    ///
234    /// Given a file as an `input` this function creates a well-formed request.
235    /// That request is of latest known version and contains all necessary fields.
236    ///
237    /// # Errors
238    ///
239    /// Returns an error if reading the file fails or forming the request encounters
240    /// an error.
241    ///
242    /// # Examples
243    ///
244    /// The following example creates a signing request for `Cargo.toml`:
245    ///
246    /// ```
247    /// # fn main() -> testresult::TestResult {
248    /// use signstar_request_signature::Request;
249    ///
250    /// let signing_request = Request::for_file("Cargo.toml")?;
251    /// # Ok(()) }
252    /// ```
253    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        // Add "grease" so that the server can handle any optional data
274        // See: https://lobste.rs/s/utmsph/age_plugins#c_i76hkd
275        // See: https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417
276        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/// The response to a signing request.
322///
323/// Tracks the `version` of the signing response and the signature as `signature`.
324///
325/// The details of the format are documented in the [response specification].
326///
327/// [response specification]: https://signstar.archlinux.page/signstar-request-signature/resources/docs/response.html
328#[derive(Debug, Deserialize, Serialize)]
329pub struct Response {
330    /// Version of this signing response.
331    pub version: Version,
332
333    /// Raw content of the signature.
334    signature: String,
335}
336
337impl Response {
338    /// Creates a version 1 compatible signature from raw signature content.
339    pub fn v1(signature: String) -> Self {
340        Self {
341            version: Version::new(1, 0, 0),
342            signature,
343        }
344    }
345
346    /// Creates a [`Response`] from a `reader` of JSON formatted bytes.
347    ///
348    /// # Errors
349    ///
350    /// Returns an error if deserialization from `reader` fails.
351    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    /// Writes the [`Response`] to a `writer` in JSON serialized form.
357    ///
358    /// # Errors
359    ///
360    /// Returns an error if `self` can not be serialized or if writing to `writer` fails.
361    pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
362        serde_json::to_writer(writer, &self)?;
363        Ok(())
364    }
365
366    /// Writes the raw signature of the [`Response`] to a `writer`.
367    ///
368    /// # Errors
369    ///
370    /// Returns an error if the signature can not be written to the `writer`.
371    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        //let hasher = old_sha2::Sha512::deserialize(&expected_state.try_into()?)?;
425        //let hash = &hasher.finalize()[..];
426        //assert_eq!(hash, expected_digest);
427
428        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}