Skip to main content

mkit_server/scanner_retrieval/
config.rs

1use core::fmt;
2use ed25519_dalek::{SigningKey, VerifyingKey};
3use mkit_core::hash::{Hash, from_hex};
4use std::collections::BTreeSet;
5use subtle::ConstantTimeEq;
6use zeroize::Zeroizing;
7
8/// Invalid configuration. Never contains supplied keys or credentials.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
10#[error("invalid or conflicting scanner retrieval configuration")]
11pub struct ConfigError;
12
13#[derive(Clone, PartialEq, Eq)]
14pub(super) struct Key {
15    pub id: String,
16    pub secret: Zeroizing<Hash>,
17    pub retired_at_ms: Option<u64>,
18}
19
20/// Dedicated active/retained MAC keys and the incoming scanner allowlist.
21#[derive(Clone, PartialEq, Eq)]
22pub struct RetrievalConfig {
23    pub(super) keys: Vec<Key>,
24    scanners: Vec<Hash>,
25}
26
27impl fmt::Debug for RetrievalConfig {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        f.debug_struct("RetrievalConfig")
30            .field(
31                "key_ids",
32                &self.keys.iter().map(|k| &k.id).collect::<Vec<_>>(),
33            )
34            .field("scanner_count", &self.scanners.len())
35            .finish_non_exhaustive()
36    }
37}
38
39fn lines(text: &str) -> impl Iterator<Item = &str> {
40    text.lines()
41        .map(str::trim)
42        .filter(|s| !s.is_empty() && !s.starts_with('#'))
43}
44
45impl RetrievalConfig {
46    /// Parse exactly one `active <id> <64 hex>` line, followed by up to 15
47    /// `retained <id> <64 hex> <retired_at_ms>` lines. Scanner keys are
48    /// 1–32 distinct, non-weak Ed25519 public keys, one per line.
49    /// Blank lines and whole-line comments are ignored.
50    ///
51    /// # Errors
52    /// Invalid grammar, duplicate/weak keys or cross-role reuse.
53    pub fn parse(keys: &str, scanners: &str) -> Result<Self, ConfigError> {
54        let mut parsed: Vec<Key> = Vec::new();
55        let mut ids = BTreeSet::new();
56        for line in lines(keys) {
57            let fields: Vec<_> = line.split_whitespace().collect();
58            let (id, secret, retired_at_ms) = match fields.as_slice() {
59                ["active", id, secret] if parsed.is_empty() => (*id, *secret, None),
60                ["retained", id, secret, at] if !parsed.is_empty() => {
61                    let at_ms = at.parse::<u64>().map_err(|_| ConfigError)?;
62                    if at_ms.to_string() != *at {
63                        return Err(ConfigError);
64                    }
65                    (*id, *secret, Some(at_ms))
66                }
67                _ => return Err(ConfigError),
68            };
69            if id.is_empty()
70                || id.len() > 32
71                || !id
72                    .bytes()
73                    .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
74                || !ids.insert(id.to_owned())
75                || parsed.len() >= 16
76            {
77                return Err(ConfigError);
78            }
79            let secret = Zeroizing::new(from_hex(secret).map_err(|_| ConfigError)?);
80            if parsed.iter().any(|k| bool::from(k.secret.ct_eq(&*secret))) {
81                return Err(ConfigError);
82            }
83            parsed.push(Key {
84                id: id.to_owned(),
85                secret,
86                retired_at_ms,
87            });
88        }
89        let mut public = Vec::new();
90        for line in lines(scanners) {
91            let key = from_hex(line).map_err(|_| ConfigError)?;
92            let verifying = VerifyingKey::from_bytes(&key).map_err(|_| ConfigError)?;
93            if public.len() >= 32 || verifying.is_weak() || public.contains(&key) {
94                return Err(ConfigError);
95            }
96            public.push(key);
97        }
98        if parsed.is_empty() || public.is_empty() {
99            return Err(ConfigError);
100        }
101        let config = Self {
102            keys: parsed,
103            scanners: public,
104        };
105        config.check_role_keys(&[], &[])?;
106        Ok(config)
107    }
108
109    /// Incoming scanner keys, for authentication and deployment separation.
110    pub fn scanner_keys(&self) -> impl Iterator<Item = Hash> + '_ {
111        self.scanners.iter().copied()
112    }
113
114    /// Refuse reuse of any raw secret in another deployment role.
115    ///
116    /// # Errors
117    /// Any active/retained MAC secret or scanner key equals the supplied key.
118    pub fn check_secret(&self, secret: &Hash) -> Result<(), ConfigError> {
119        self.check_role_keys(&[], &[*secret])
120    }
121
122    /// Compare all active/retained retrieval and scanner keys with role keys.
123    /// Public comparisons include Ed25519 public keys derived from MAC secrets,
124    /// so a signing seed reused as a MAC secret cannot evade startup checks.
125    ///
126    /// # Errors
127    /// A cross-role collision, including between scanners and retrieval keys.
128    pub fn check_role_keys(&self, public: &[Hash], seeds: &[Hash]) -> Result<(), ConfigError> {
129        let derived: Vec<_> = self
130            .keys
131            .iter()
132            .map(|k| SigningKey::from_bytes(&k.secret).verifying_key().to_bytes())
133            .collect();
134        for key in &self.keys {
135            if seeds.iter().any(|s| bool::from(key.secret.ct_eq(s)))
136                || public.contains(&*key.secret)
137                || self.scanners.contains(&*key.secret)
138            {
139                return Err(ConfigError);
140            }
141        }
142        if derived
143            .iter()
144            .any(|k| public.contains(k) || seeds.contains(k) || self.scanners.contains(k))
145            || self.scanners.iter().any(|k| {
146                public.contains(k)
147                    || seeds.contains(k)
148                    || seeds
149                        .iter()
150                        .any(|s| SigningKey::from_bytes(s).verifying_key().as_bytes() == k)
151            })
152        {
153            return Err(ConfigError);
154        }
155        Ok(())
156    }
157
158    #[cfg(feature = "remote-hooks")]
159    pub(crate) fn accepts(&self, public: &Hash) -> bool {
160        self.scanners.contains(public)
161    }
162}