Skip to main content

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