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, Deserialize, Eq, PartialEq, Serialize)]
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, Deserialize, Eq, PartialEq, Serialize)]
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, Deserialize, Serialize)]
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, Deserialize, Serialize)]
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, Deserialize, Serialize)]
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/// Provides I/O adapter to [`sha2::Sha512`] object.
167struct IoWrapper(sha2::Sha512);
168
169impl std::io::Write for IoWrapper {
170    /// Updates the inner hasher and returns the number of bytes written.
171    ///
172    /// # Errors
173    ///
174    /// This function never fails.
175    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
176        self.0.update(buf);
177        Ok(buf.len())
178    }
179
180    /// Does nothing.
181    ///
182    /// # Errors
183    ///
184    /// This function never fails.
185    fn flush(&mut self) -> std::io::Result<()> {
186        Ok(())
187    }
188}
189
190/// Signing request.
191#[derive(Debug, Deserialize, Serialize)]
192pub struct Request {
193    /// Version of this signing request.
194    pub version: Version,
195
196    /// Required parameters of the signing process.
197    ///
198    /// All required parameters must be understood by the signing
199    /// process or the entire request is to be rejected.
200    pub required: Required,
201
202    /// Optional parameters for the signing process.
203    ///
204    /// The server may ignore any or all parameters in this group. If
205    /// any parameter is not understood by the server it must be
206    /// ignored.
207    pub optional: HashMap<String, Value>,
208}
209
210impl Request {
211    /// Read the request from a JSON serialized bytes.
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if reading the file fails or the file contents
216    /// are not well-formed.
217    pub fn from_reader(reader: impl std::io::Read) -> Result<Self, Error> {
218        let req: Request = serde_json::from_reader(reader)?;
219        Ok(req)
220    }
221
222    /// Write the request as a JSON serialized form.
223    ///
224    /// # Errors
225    ///
226    /// Returns an error if serialization of the request fails or writing to
227    /// the `writer` encounters an error.
228    pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
229        serde_json::to_writer(writer, &self)?;
230        Ok(())
231    }
232
233    /// Prepares a signing request for a file.
234    ///
235    /// Given a file as an `input` this function creates a well-formed request.
236    /// That request is of latest known version and contains all necessary fields.
237    ///
238    /// # Errors
239    ///
240    /// Returns an error if reading the file fails or forming the request encounters
241    /// an error.
242    ///
243    /// # Examples
244    ///
245    /// The following example creates a signing request for `Cargo.toml`:
246    ///
247    /// ```
248    /// # fn main() -> testresult::TestResult {
249    /// use signstar_request_signature::Request;
250    ///
251    /// let signing_request = Request::for_file("Cargo.toml")?;
252    /// # Ok(()) }
253    /// ```
254    pub fn for_file(input: impl AsRef<Path>) -> Result<Self, Error> {
255        let input = input.as_ref();
256        let pack_err = |source| Error::Io {
257            file: input.into(),
258            source,
259        };
260        let hasher = {
261            let mut hasher = IoWrapper(sha2::Sha512::new());
262            std::io::copy(
263                &mut std::fs::File::open(input).map_err(pack_err)?,
264                &mut hasher,
265            )
266            .map_err(pack_err)?;
267            hasher.0
268        };
269        let required = Required {
270            input: hasher.into(),
271            output: SignatureRequestOutput::new_openpgp_v4(),
272        };
273
274        // Add "grease" so that the server can handle any optional data
275        // See: https://lobste.rs/s/utmsph/age_plugins#c_i76hkd
276        // See: https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417
277        let grease: String = rand::thread_rng()
278            .sample_iter(&rand::distributions::Alphanumeric)
279            .take(7)
280            .map(char::from)
281            .collect();
282
283        Ok(Self {
284            version: semver::Version::new(1, 0, 0),
285            required,
286            optional: vec![
287                (
288                    grease,
289                    Value::String(
290                        "https://gitlab.archlinux.org/archlinux/signstar/-/merge_requests/43"
291                            .to_string(),
292                    ),
293                ),
294                (
295                    "request-time".into(),
296                    Value::Number(
297                        SystemTime::now()
298                            .duration_since(SystemTime::UNIX_EPOCH)
299                            .map_err(|source| crate::Error::CurrentTimeBeforeReference {
300                                reference_time: SystemTime::UNIX_EPOCH,
301                                source,
302                            })?
303                            .as_secs()
304                            .into(),
305                    ),
306                ),
307                (
308                    "file-name".into(),
309                    input
310                        .file_name()
311                        .and_then(|s| s.to_str())
312                        .map(Into::into)
313                        .unwrap_or(Value::Null),
314                ),
315            ]
316            .into_iter()
317            .collect(),
318        })
319    }
320}
321
322/// The response to a signing request.
323///
324/// Tracks the `version` of the signing response and the signature as `signature`.
325///
326/// The details of the format are documented in the [response specification].
327///
328/// [response specification]: https://signstar.archlinux.page/signstar-request-signature/resources/docs/response.html
329#[derive(Debug, Deserialize, Serialize)]
330pub struct Response {
331    /// Version of this signing response.
332    pub version: Version,
333
334    /// Raw content of the signature.
335    signature: String,
336}
337
338impl Response {
339    /// Creates a version 1 compatible signature from raw signature content.
340    pub fn v1(signature: String) -> Self {
341        Self {
342            version: Version::new(1, 0, 0),
343            signature,
344        }
345    }
346
347    /// Creates a [`Response`] from a `reader` of JSON formatted bytes.
348    ///
349    /// # Errors
350    ///
351    /// Returns an error if deserialization from `reader` fails.
352    pub fn from_reader(reader: impl std::io::Read) -> Result<Self, Error> {
353        let resp: Self = serde_json::from_reader(reader)?;
354        Ok(resp)
355    }
356
357    /// Writes the [`Response`] to a `writer` in JSON serialized form.
358    ///
359    /// # Errors
360    ///
361    /// Returns an error if `self` can not be serialized or if writing to `writer` fails.
362    pub fn to_writer(&self, writer: impl std::io::Write) -> Result<(), Error> {
363        serde_json::to_writer(writer, &self)?;
364        Ok(())
365    }
366
367    /// Writes the raw signature of the [`Response`] to a `writer`.
368    ///
369    /// # Errors
370    ///
371    /// Returns an error if the signature can not be written to the `writer`.
372    pub fn signature_to_writer(&self, mut writer: impl std::io::Write) -> Result<(), Error> {
373        writer
374            .write_all(self.signature.as_bytes())
375            .map_err(|source| Error::Io {
376                file: PathBuf::new(),
377                source,
378            })?;
379        Ok(())
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use std::{fs::File, path::PathBuf};
386
387    use rstest::rstest;
388    use sha2::Digest;
389    use testresult::TestResult;
390
391    use super::*;
392
393    #[test]
394    fn hash_values_are_predictable() -> testresult::TestResult {
395        let mut hasher = IoWrapper(sha2::Sha512::new());
396        let mut bytes = std::io::Cursor::new(b"this is sample text");
397        std::io::copy(&mut bytes, &mut hasher)?;
398        let result: &[u8] = &hasher.0.serialize();
399
400        let expected_state = [
401            8, 201, 188, 243, 103, 230, 9, 106, 59, 167, 202, 132, 133, 174, 103, 187, 43, 248,
402            148, 254, 114, 243, 110, 60, 241, 54, 29, 95, 58, 245, 79, 165, 209, 130, 230, 173,
403            127, 82, 14, 81, 31, 108, 62, 43, 140, 104, 5, 155, 107, 189, 65, 251, 171, 217, 131,
404            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,
405            64, 19, 116, 104, 105, 115, 32, 105, 115, 32, 115, 97, 109, 112, 108, 101, 32, 116,
406            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,
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, 0, 0, 0, 0, 0,
409            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,
410        ];
411
412        assert_eq!(result, expected_state);
413
414        let expected_digest = [
415            20, 253, 69, 133, 146, 76, 11, 4, 191, 13, 150, 196, 9, 97, 21, 35, 186, 95, 254, 59,
416            148, 60, 88, 155, 127, 203, 151, 216, 11, 16, 228, 73, 113, 23, 115, 110, 198, 42, 109,
417            92, 23, 33, 70, 71, 136, 219, 73, 238, 135, 13, 223, 117, 215, 69, 243, 33, 125, 109,
418            95, 121, 213, 44, 212, 166,
419        ];
420
421        let hasher = sha2::Sha512::deserialize(&expected_state.into())?;
422        let hash = &hasher.finalize()[..];
423        assert_eq!(hash, expected_digest);
424
425        //let hasher = old_sha2::Sha512::deserialize(&expected_state.try_into()?)?;
426        //let hash = &hasher.finalize()[..];
427        //assert_eq!(hash, expected_digest);
428
429        Ok(())
430    }
431
432    #[test]
433    fn sample_request_is_ok() -> TestResult {
434        let reader = File::open("tests/sample-request.json")?;
435        let reader = Request::from_reader(reader)?;
436        let hasher: sha2::Sha512 = reader.required.input.try_into()?;
437        assert_eq!(
438            hasher.finalize(),
439            [
440                85, 185, 86, 249, 187, 64, 117, 47, 163, 40, 201, 53, 35, 169, 119, 90, 168, 78,
441                29, 32, 20, 55, 39, 121, 253, 203, 159, 82, 85, 40, 233, 26, 208, 13, 111, 61, 93,
442                100, 199, 31, 185, 140, 195, 114, 92, 118, 108, 237, 100, 152, 212, 177, 189, 56,
443                146, 204, 137, 76, 235, 31, 101, 1, 19, 55
444            ]
445        );
446        Ok(())
447    }
448
449    #[rstest]
450    fn sample_request_is_bad(#[files("tests/bad-*.json")] request_file: PathBuf) -> TestResult {
451        let reader = File::open(request_file)?;
452        assert!(
453            Request::from_reader(reader).is_err(),
454            "parsing of the request file should fail"
455        );
456        Ok(())
457    }
458}