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 version 1 compatible signature from raw signature content.
316    pub fn v1(signature: String) -> Self {
317        Self {
318            version: Version::new(1, 0, 0),
319            signature,
320        }
321    }
322
323    /// Creates a [`Response`] from a `reader` of JSON formatted bytes.
324    ///
325    /// # Errors
326    ///
327    /// Returns an error if deserialization from `reader` fails.
328    pub fn from_reader(reader: impl std::io::Read) -> Result<Self, Error> {
329        let resp: Self = serde_json::from_reader(reader)?;
330        Ok(resp)
331    }
332
333    /// Writes the [`Response`] to a `writer` in JSON serialized form.
334    ///
335    /// # Errors
336    ///
337    /// Returns an error if `self` can not be serialized or if writing to `writer` fails.
338    pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
339        serde_json::to_writer(writer, &self)?;
340        Ok(())
341    }
342
343    /// Writes the raw signature of the [`Response`] to a `writer`.
344    ///
345    /// # Errors
346    ///
347    /// Returns an error if the signature can not be written to the `writer`.
348    pub fn signature_to_writer(&self, mut writer: impl std::io::Write) -> Result<(), Error> {
349        writer
350            .write_all(self.signature.as_bytes())
351            .map_err(|source| Error::Io {
352                file: PathBuf::new(),
353                source,
354            })?;
355        Ok(())
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use std::path::PathBuf;
362
363    use rstest::rstest;
364    use sha2::Digest;
365    use testresult::TestResult;
366
367    use super::*;
368
369    #[test]
370    fn hash_values_are_predictable() -> testresult::TestResult {
371        let mut hasher = sha2::Sha512::new();
372        let mut bytes = std::io::Cursor::new(b"this is sample text");
373        std::io::copy(&mut bytes, &mut hasher)?;
374        let result: &[u8] = &hasher.serialize();
375
376        let expected_state = [
377            8, 201, 188, 243, 103, 230, 9, 106, 59, 167, 202, 132, 133, 174, 103, 187, 43, 248,
378            148, 254, 114, 243, 110, 60, 241, 54, 29, 95, 58, 245, 79, 165, 209, 130, 230, 173,
379            127, 82, 14, 81, 31, 108, 62, 43, 140, 104, 5, 155, 107, 189, 65, 251, 171, 217, 131,
380            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,
381            64, 19, 116, 104, 105, 115, 32, 105, 115, 32, 115, 97, 109, 112, 108, 101, 32, 116,
382            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,
383            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,
384            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,
385            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,
386        ];
387
388        assert_eq!(result, expected_state);
389
390        let expected_digest = [
391            20, 253, 69, 133, 146, 76, 11, 4, 191, 13, 150, 196, 9, 97, 21, 35, 186, 95, 254, 59,
392            148, 60, 88, 155, 127, 203, 151, 216, 11, 16, 228, 73, 113, 23, 115, 110, 198, 42, 109,
393            92, 23, 33, 70, 71, 136, 219, 73, 238, 135, 13, 223, 117, 215, 69, 243, 33, 125, 109,
394            95, 121, 213, 44, 212, 166,
395        ];
396
397        let hasher = sha2::Sha512::deserialize(&expected_state.into())?;
398        let hash = &hasher.finalize()[..];
399        assert_eq!(hash, expected_digest);
400
401        //let hasher = old_sha2::Sha512::deserialize(&expected_state.try_into()?)?;
402        //let hash = &hasher.finalize()[..];
403        //assert_eq!(hash, expected_digest);
404
405        Ok(())
406    }
407
408    #[test]
409    fn sample_request_is_ok() -> TestResult {
410        let reader = std::fs::File::open("tests/sample-request.json")?;
411        let reader = Request::from_reader(reader)?;
412        let hasher: sha2::Sha512 = reader.required.input.try_into()?;
413        assert_eq!(
414            hasher.finalize(),
415            [
416                85, 185, 86, 249, 187, 64, 117, 47, 163, 40, 201, 53, 35, 169, 119, 90, 168, 78,
417                29, 32, 20, 55, 39, 121, 253, 203, 159, 82, 85, 40, 233, 26, 208, 13, 111, 61, 93,
418                100, 199, 31, 185, 140, 195, 114, 92, 118, 108, 237, 100, 152, 212, 177, 189, 56,
419                146, 204, 137, 76, 235, 31, 101, 1, 19, 55
420            ]
421        );
422        Ok(())
423    }
424
425    #[rstest]
426    fn sample_request_is_bad(#[files("tests/bad-*.json")] request_file: PathBuf) -> TestResult {
427        let reader = std::fs::File::open(request_file)?;
428        assert!(
429            Request::from_reader(reader).is_err(),
430            "parsing of the request file should fail"
431        );
432        Ok(())
433    }
434}