Skip to main content

mkit_rpc/hooks/
sign.rs

1//! Request signing (SPEC-SERVER §7.1).
2
3use core::time::Duration;
4
5use ed25519_dalek::{Signer, SigningKey};
6use mkit_core::hash::{hash, to_hex, to_hex_bytes};
7use zeroize::Zeroizing;
8
9/// The literal domain separator of the hook key use.
10pub const DOMAIN: &str = "mkit-hook:v1";
11/// The longest permitted validity interval.
12pub const MAX_VALIDITY: Duration = Duration::from_mins(5);
13/// The validity interval used unless configured otherwise.
14pub const DEFAULT_VALIDITY: Duration = Duration::from_mins(1);
15const DEFAULT_VALIDITY_MS: i64 = 60_000;
16
17/// A signer setting the spec refuses.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
19#[non_exhaustive]
20pub enum SignerError {
21    /// A key id must be 1-64 bytes of `[A-Za-z0-9._-]`.
22    #[error("hook key id must be 1-64 bytes of [A-Za-z0-9._-]")]
23    KeyId,
24    /// The validity interval must be positive and at most 300,000 ms.
25    #[error("hook signature validity must be 1 ms to 300 s")]
26    Validity,
27    /// The clock reads before the epoch, which no header can express.
28    #[error("hook clock is before the Unix epoch")]
29    Clock,
30}
31
32/// The dedicated hook signing key. It must not be a key used for `mkit-write:v2`,
33/// grants, receipts or administration.
34pub struct HookSigner {
35    key_id: String,
36    seed: Zeroizing<[u8; 32]>,
37    validity_ms: i64,
38}
39
40impl core::fmt::Debug for HookSigner {
41    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
42        f.debug_struct("HookSigner")
43            .field("key_id", &self.key_id)
44            .finish_non_exhaustive()
45    }
46}
47
48impl HookSigner {
49    /// A signer for `key_id` over the Ed25519 `seed`, valid for
50    /// [`DEFAULT_VALIDITY`].
51    ///
52    /// # Errors
53    /// [`SignerError::KeyId`] for a key id outside the spec's grammar.
54    pub fn new(key_id: impl Into<String>, seed: Zeroizing<[u8; 32]>) -> Result<Self, SignerError> {
55        let key_id = key_id.into();
56        let valid = (1..=64).contains(&key_id.len())
57            && key_id
58                .bytes()
59                .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
60        if !valid {
61            return Err(SignerError::KeyId);
62        }
63        Ok(Self {
64            key_id,
65            seed,
66            validity_ms: DEFAULT_VALIDITY_MS,
67        })
68    }
69
70    /// Set the validity interval each signature carries.
71    ///
72    /// # Errors
73    /// [`SignerError::Validity`] outside 1 ms to [`MAX_VALIDITY`].
74    pub fn with_validity(mut self, validity: Duration) -> Result<Self, SignerError> {
75        if validity < Duration::from_millis(1) || validity > MAX_VALIDITY {
76            return Err(SignerError::Validity);
77        }
78        self.validity_ms =
79            i64::try_from(validity.as_millis()).map_err(|_| SignerError::Validity)?;
80        Ok(self)
81    }
82
83    /// The key id this signer's signatures name.
84    #[must_use]
85    pub fn key_id(&self) -> &str {
86        &self.key_id
87    }
88
89    /// The Ed25519 public key of this signer, for the §7.2 key list.
90    #[must_use]
91    pub fn public_key(&self) -> [u8; 32] {
92        SigningKey::from_bytes(&self.seed)
93            .verifying_key()
94            .to_bytes()
95    }
96
97    /// The eight §7.1 headers for one attempt: `created_ms` and the fresh
98    /// `nonce` are supplied so a test can reproduce a vector.
99    ///
100    /// # Errors
101    /// [`SignerError::Clock`] for a negative `created_ms`.
102    pub fn headers(
103        &self,
104        audience: &str,
105        procedure: &str,
106        body: &[u8],
107        created_ms: i64,
108        nonce: &[u8; 32],
109    ) -> Result<Vec<(&'static str, String)>, SignerError> {
110        if created_ms < 0 {
111            return Err(SignerError::Clock);
112        }
113        let expires_ms = created_ms.saturating_add(self.validity_ms);
114        let digest = format!("body:{}", to_hex(&hash(body)));
115        let nonce = to_hex_bytes(nonce);
116        let canonical = [
117            DOMAIN,
118            &self.key_id,
119            audience,
120            procedure,
121            &digest,
122            &created_ms.to_string(),
123            &expires_ms.to_string(),
124            &nonce,
125        ]
126        .join("\n");
127        let key = SigningKey::from_bytes(&self.seed);
128        let signature = key.sign(&hash(canonical.as_bytes()));
129        Ok(vec![
130            ("X-Mkit-Hook-Version", "1".to_owned()),
131            ("X-Mkit-Hook-Key-Id", self.key_id.clone()),
132            ("X-Mkit-Hook-Audience", audience.to_owned()),
133            ("X-Mkit-Hook-Created-At", created_ms.to_string()),
134            ("X-Mkit-Hook-Expires-At", expires_ms.to_string()),
135            ("X-Mkit-Hook-Nonce", nonce),
136            ("X-Mkit-Hook-Digest", digest),
137            ("X-Mkit-Hook-Signature", to_hex_bytes(&signature.to_bytes())),
138        ])
139    }
140}