signstar_request_signature/
lib.rs

1#![doc = include_str!("../README.md")]
2#![deny(missing_debug_implementations)]
3#![deny(missing_docs)]
4#![deny(clippy::unwrap_used)]
5#![deny(clippy::expect_used)]
6
7use std::path::Path;
8use std::time::SystemTime;
9use std::{collections::HashMap, path::PathBuf};
10
11use rand::Rng;
12use semver::Version;
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15use sha2::Digest;
16pub use sha2::Sha512;
17use sha2::digest::crypto_common::hazmat::SerializableState;
18
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] sha2::digest::crypto_common::hazmat::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
75/// Type of the input hash.
76#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
77#[serde(rename_all = "lowercase")]
78pub enum HashType {
79    /// State of the SHA-512 hasher, as understood by the [`sha2`
80    /// crate](https://crates.io/crates/sha2) in version `0.11` and
81    /// compatible.
82    #[serde(rename = "sha2-0.11-SHA512-state")]
83    #[allow(non_camel_case_types)]
84    Sha2_0_11_Sha512_State,
85}
86
87/// The requested signature type.
88#[derive(Debug, Serialize, PartialEq, Eq, Deserialize)]
89pub enum SignatureType {
90    /// OpenPGP signature (version 4).
91    #[serde(rename = "OpenPGPv4")]
92    OpenPgpV4,
93}
94
95/// Input of the signing request process.
96#[derive(Debug, Serialize, Deserialize)]
97pub struct SignatureRequestInput {
98    #[serde(rename = "type")]
99    hash_type: HashType,
100    content: Vec<u8>,
101}
102
103/// Outputs of the signing process.
104#[derive(Debug, Serialize, Deserialize)]
105pub struct SignatureRequestOutput {
106    /// Type of the signature to be produced.
107    #[serde(rename = "type")]
108    sig_type: SignatureType,
109}
110
111impl SignatureRequestOutput {
112    /// Create a new signature output which asks for OpenPGP (version
113    /// 4) signature.
114    pub fn new_openpgp_v4() -> Self {
115        Self {
116            sig_type: SignatureType::OpenPgpV4,
117        }
118    }
119
120    /// Indicates if the signature output should be OpenPGP (version
121    /// 4).
122    pub fn is_openpgp_v4(&self) -> bool {
123        self.sig_type == SignatureType::OpenPgpV4
124    }
125}
126
127impl From<sha2::Sha512> for SignatureRequestInput {
128    fn from(value: sha2::Sha512) -> Self {
129        Self {
130            hash_type: HashType::Sha2_0_11_Sha512_State,
131            content: value.serialize().to_vec(),
132        }
133    }
134}
135
136impl TryFrom<SignatureRequestInput> for sha2::Sha512 {
137    type Error = Error;
138    fn try_from(value: SignatureRequestInput) -> Result<Self, Self::Error> {
139        if value.hash_type != HashType::Sha2_0_11_Sha512_State {
140            return Err(Error::InvalidContentType {
141                actual: value.hash_type,
142                expected: HashType::Sha2_0_11_Sha512_State,
143            });
144        }
145
146        let hasher = sha2::Sha512::deserialize(
147            value.content[..]
148                .try_into()
149                .map_err(|_| Error::InvalidContentSize)?,
150        )?;
151
152        Ok(hasher)
153    }
154}
155
156/// Required parameters for the signing request operation.
157#[derive(Debug, Serialize, Deserialize)]
158pub struct Required {
159    /// Inputs of the signing procedure.
160    pub input: SignatureRequestInput,
161
162    /// Outputs of the signing procedure.
163    pub output: SignatureRequestOutput,
164}
165
166/// Signing request.
167#[derive(Debug, Serialize, Deserialize)]
168pub struct Request {
169    /// Version of this signing request.
170    pub version: Version,
171
172    /// Required parameters of the signing process.
173    ///
174    /// All required parameters must be understood by the signing
175    /// process or the entire request is to be rejected.
176    pub required: Required,
177
178    /// Optional parameters for the signing process.
179    ///
180    /// The server may ignore any or all parameters in this group. If
181    /// any parameter is not understood by the server it must be
182    /// ignored.
183    pub optional: HashMap<String, Value>,
184}
185
186impl Request {
187    /// Read the request from a JSON serialized bytes.
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if reading the file fails or the file contents
192    /// are not well-formed.
193    pub fn from_reader(reader: impl std::io::Read) -> Result<Self, Error> {
194        let req: Request = serde_json::from_reader(reader)?;
195        Ok(req)
196    }
197
198    /// Write the request as a JSON serialized form.
199    ///
200    /// # Errors
201    ///
202    /// Returns an error if serialization of the request fails or writing to
203    /// the `writer` encounters an error.
204    pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
205        serde_json::to_writer(writer, &self)?;
206        Ok(())
207    }
208
209    /// Prepares a signing request for a file.
210    ///
211    /// Given a file as an `input` this function creates a well-formed request.
212    /// That request is of latest known version and contains all necessary fields.
213    ///
214    /// # Errors
215    ///
216    /// Returns an error if reading the file fails or forming the request encounters
217    /// an error.
218    ///
219    /// # Examples
220    ///
221    /// The following example creates a signing request for `Cargo.toml`:
222    ///
223    /// ```
224    /// # fn main() -> testresult::TestResult {
225    /// use signstar_request_signature::Request;
226    ///
227    /// let signing_request = Request::for_file("Cargo.toml")?;
228    /// # Ok(()) }
229    /// ```
230    pub fn for_file(input: impl AsRef<Path>) -> Result<Self, Error> {
231        let input = input.as_ref();
232        let pack_err = |source| Error::Io {
233            file: input.into(),
234            source,
235        };
236        let hasher = {
237            let mut hasher = sha2::Sha512::new();
238            std::io::copy(
239                &mut std::fs::File::open(input).map_err(pack_err)?,
240                &mut hasher,
241            )
242            .map_err(pack_err)?;
243            hasher
244        };
245        let required = Required {
246            input: hasher.into(),
247            output: SignatureRequestOutput::new_openpgp_v4(),
248        };
249
250        // Add "grease" so that the server can handle any optional data
251        // See: https://lobste.rs/s/utmsph/age_plugins#c_i76hkd
252        // See: https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417
253        let grease: String = rand::thread_rng()
254            .sample_iter(&rand::distributions::Alphanumeric)
255            .take(7)
256            .map(char::from)
257            .collect();
258
259        Ok(Self {
260            version: semver::Version::new(1, 0, 0),
261            required,
262            optional: vec![
263                (
264                    grease,
265                    Value::String(
266                        "https://gitlab.archlinux.org/archlinux/signstar/-/merge_requests/43"
267                            .to_string(),
268                    ),
269                ),
270                (
271                    "request-time".into(),
272                    Value::Number(
273                        SystemTime::now()
274                            .duration_since(SystemTime::UNIX_EPOCH)
275                            .map_err(|source| crate::Error::CurrentTimeBeforeReference {
276                                reference_time: SystemTime::UNIX_EPOCH,
277                                source,
278                            })?
279                            .as_secs()
280                            .into(),
281                    ),
282                ),
283                (
284                    "file-name".into(),
285                    input
286                        .file_name()
287                        .and_then(|s| s.to_str())
288                        .map(Into::into)
289                        .unwrap_or(Value::Null),
290                ),
291            ]
292            .into_iter()
293            .collect(),
294        })
295    }
296}
297
298/// The response to a signing request.
299///
300/// Tracks the `version` of the signing response and the signature as `signature`.
301///
302/// The details of the format are documented in the [response specification].
303///
304/// [response specification]: https://signstar.archlinux.page/signstar-request-signature/resources/docs/response.html
305#[derive(Debug, Serialize, Deserialize)]
306pub struct Response {
307    /// Version of this signing response.
308    pub version: Version,
309
310    /// Raw content of the signature.
311    signature: String,
312}
313
314impl Response {
315    /// Creates a [`Response`] from a `reader` of JSON formatted bytes.
316    ///
317    /// # Errors
318    ///
319    /// Returns an error if deserialization from `reader` fails.
320    pub fn from_reader(reader: impl std::io::Read) -> Result<Self, Error> {
321        let resp: Self = serde_json::from_reader(reader)?;
322        Ok(resp)
323    }
324
325    /// Writes the [`Response`] to a `writer` in JSON serialized form.
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if `self` can not be serialized or if writing to `writer` fails.
330    pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
331        serde_json::to_writer(writer, &self)?;
332        Ok(())
333    }
334
335    /// Writes the raw signature of the [`Response`] to a `writer`.
336    ///
337    /// # Errors
338    ///
339    /// Returns an error if the signature can not be written to the `writer`.
340    pub fn signature_to_writer(&self, mut writer: impl std::io::Write) -> Result<(), Error> {
341        writer
342            .write_all(self.signature.as_bytes())
343            .map_err(|source| Error::Io {
344                file: PathBuf::new(),
345                source,
346            })?;
347        Ok(())
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use std::path::PathBuf;
354
355    use rstest::rstest;
356    use sha2::Digest;
357    use testresult::TestResult;
358
359    use super::*;
360
361    #[test]
362    fn hash_values_are_predictable() -> testresult::TestResult {
363        let mut hasher = sha2::Sha512::new();
364        let mut bytes = std::io::Cursor::new(b"this is sample text");
365        std::io::copy(&mut bytes, &mut hasher)?;
366        let result: &[u8] = &hasher.serialize();
367
368        let expected_state = [
369            8, 201, 188, 243, 103, 230, 9, 106, 59, 167, 202, 132, 133, 174, 103, 187, 43, 248,
370            148, 254, 114, 243, 110, 60, 241, 54, 29, 95, 58, 245, 79, 165, 209, 130, 230, 173,
371            127, 82, 14, 81, 31, 108, 62, 43, 140, 104, 5, 155, 107, 189, 65, 251, 171, 217, 131,
372            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,
373            64, 19, 116, 104, 105, 115, 32, 105, 115, 32, 115, 97, 109, 112, 108, 101, 32, 116,
374            101, 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,
375            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,
376            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,
377            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,
378        ];
379
380        assert_eq!(result, expected_state);
381
382        let expected_digest = [
383            20, 253, 69, 133, 146, 76, 11, 4, 191, 13, 150, 196, 9, 97, 21, 35, 186, 95, 254, 59,
384            148, 60, 88, 155, 127, 203, 151, 216, 11, 16, 228, 73, 113, 23, 115, 110, 198, 42, 109,
385            92, 23, 33, 70, 71, 136, 219, 73, 238, 135, 13, 223, 117, 215, 69, 243, 33, 125, 109,
386            95, 121, 213, 44, 212, 166,
387        ];
388
389        let hasher = sha2::Sha512::deserialize(&expected_state.into())?;
390        let hash = &hasher.finalize()[..];
391        assert_eq!(hash, expected_digest);
392
393        //let hasher = old_sha2::Sha512::deserialize(&expected_state.try_into()?)?;
394        //let hash = &hasher.finalize()[..];
395        //assert_eq!(hash, expected_digest);
396
397        Ok(())
398    }
399
400    #[test]
401    fn sample_request_is_ok() -> TestResult {
402        let reader = std::fs::File::open("tests/sample-request.json")?;
403        let reader = Request::from_reader(reader)?;
404        let hasher: sha2::Sha512 = reader.required.input.try_into()?;
405        assert_eq!(
406            hasher.finalize(),
407            [
408                85, 185, 86, 249, 187, 64, 117, 47, 163, 40, 201, 53, 35, 169, 119, 90, 168, 78,
409                29, 32, 20, 55, 39, 121, 253, 203, 159, 82, 85, 40, 233, 26, 208, 13, 111, 61, 93,
410                100, 199, 31, 185, 140, 195, 114, 92, 118, 108, 237, 100, 152, 212, 177, 189, 56,
411                146, 204, 137, 76, 235, 31, 101, 1, 19, 55
412            ]
413        );
414        Ok(())
415    }
416
417    #[rstest]
418    fn sample_request_is_bad(#[files("tests/bad-*.json")] request_file: PathBuf) -> TestResult {
419        let reader = std::fs::File::open(request_file)?;
420        assert!(
421            Request::from_reader(reader).is_err(),
422            "parsing of the request file should fail"
423        );
424        Ok(())
425    }
426}