1use core::time::Duration;
4
5use ed25519_dalek::{Signer, SigningKey};
6use mkit_core::hash::{hash, to_hex, to_hex_bytes};
7use zeroize::Zeroizing;
8
9pub const DOMAIN: &str = "mkit-hook:v1";
11pub const MAX_VALIDITY: Duration = Duration::from_mins(5);
13pub const DEFAULT_VALIDITY: Duration = Duration::from_mins(1);
15const DEFAULT_VALIDITY_MS: i64 = 60_000;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
19#[non_exhaustive]
20pub enum SignerError {
21 #[error("hook key id must be 1-64 bytes of [A-Za-z0-9._-]")]
23 KeyId,
24 #[error("hook signature validity must be 1 ms to 300 s")]
26 Validity,
27 #[error("hook clock is before the Unix epoch")]
29 Clock,
30}
31
32pub 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 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 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 #[must_use]
85 pub fn key_id(&self) -> &str {
86 &self.key_id
87 }
88
89 #[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 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}