Skip to main content

mkit_server/
authority.rs

1//! Deployment-authority generation statements (SPEC-SERVER §6.2.1).
2//! The outer RPC is unsigned; this bounded statement is its authorization.
3
4use crate::{
5    ServerError,
6    store::{Key, keys},
7};
8use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
9use ed25519_dalek::{Signature, VerifyingKey};
10use mkit_core::{hash::hash, repo_identity::Namespace, write_auth::validate_audience};
11use std::collections::BTreeSet;
12
13/// Dedicated statement domain, independent of grants and hook requests.
14pub const DOMAIN: &str = "mkit-authority-generation:v1";
15/// Maximum signed statement wire size, before decoding or signature work.
16pub const MAX_STATEMENT_BYTES: usize = 2048;
17/// Maximum generation increase, computed without overflow.
18pub const MAX_STEP: u64 = 1024;
19
20/// A deployment-authority verification key with explicit namespace permission.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct AuthorityKey {
23    /// Hook-style key identifier, 1–64 ASCII token bytes.
24    pub key_id: String,
25    /// Strict Ed25519 public key; never a namespace-owner trust fallback.
26    pub public_key: [u8; 32],
27    /// Exact self-certifying namespaces this key may fence.
28    pub namespaces: BTreeSet<Namespace>,
29}
30
31/// Optional deployment fencing configuration. Construction validates all keys.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct AuthorityFence {
34    keys: Vec<AuthorityKey>,
35}
36
37fn rejected() -> ServerError {
38    ServerError::permission_denied("authority generation statement rejected")
39}
40fn valid_id(id: &str) -> bool {
41    (1..=64).contains(&id.len())
42        && id
43            .bytes()
44            .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b))
45}
46fn decimal(text: &str) -> Result<u64, ServerError> {
47    text.parse::<u64>()
48        .ok()
49        .filter(|n| n.to_string() == text)
50        .ok_or_else(rejected)
51}
52
53impl AuthorityFence {
54    /// Validate a bounded set of dedicated keys, each with namespace permissions.
55    ///
56    /// # Errors
57    /// Empty/oversized lists, malformed, weak or duplicate keys, and owner keys.
58    pub fn new(keys: Vec<AuthorityKey>) -> Result<Self, ServerError> {
59        if keys.is_empty() || keys.len() > 16 {
60            return Err(rejected());
61        }
62        let mut ids = BTreeSet::new();
63        let mut publics = BTreeSet::new();
64        for key in &keys {
65            let public = VerifyingKey::from_bytes(&key.public_key).map_err(|_| rejected())?;
66            if !valid_id(&key.key_id)
67                || !ids.insert(&key.key_id)
68                || !publics.insert(key.public_key)
69                || public.is_weak()
70                || key.namespaces.is_empty()
71                || key.namespaces.len() > 1024
72                || key
73                    .namespaces
74                    .iter()
75                    .any(|ns| matches!(ns,Namespace::Ed25519(owner) if owner == &key.public_key))
76            {
77                return Err(rejected());
78            }
79        }
80        Ok(Self { keys })
81    }
82
83    /// Parse one `<key-id> <64 lowercase hex public key> <namespace[,namespace...]>`
84    /// per line. Keys authorize exact namespaces; the list is bounded before decoding.
85    /// # Errors
86    /// Any malformed or unauthorized key configuration.
87    pub fn parse(text: &str) -> Result<Self, ServerError> {
88        if text.len() > 256_000 {
89            return Err(rejected());
90        }
91        let mut keys = Vec::new();
92        for line in text.lines() {
93            if keys.len() >= 16 {
94                return Err(rejected());
95            }
96            let fields = line.split_whitespace().collect::<Vec<_>>();
97            let [id, public, namespaces] = fields.as_slice() else {
98                return Err(rejected());
99            };
100            if public.len() != 64 || !public.bytes().all(|b| matches!(b,b'0'..=b'9'|b'a'..=b'f')) {
101                return Err(rejected());
102            }
103            let public_key = mkit_core::hash::from_hex(public).map_err(|_| rejected())?;
104            let namespaces = namespaces
105                .split(',')
106                .map(|ns| Namespace::parse(ns).map_err(|_| rejected()))
107                .collect::<Result<BTreeSet<_>, _>>()?;
108            keys.push(AuthorityKey {
109                key_id: (*id).to_owned(),
110                public_key,
111                namespaces,
112            });
113        }
114        Self::new(keys)
115    }
116
117    /// All configured role keys, for adapter key separation.
118    pub fn public_keys(&self) -> impl Iterator<Item = [u8; 32]> + '_ {
119        self.keys.iter().map(|k| k.public_key)
120    }
121
122    /// Verify `<unpadded-base64url statement>.<unpadded-base64url signature>`.
123    /// Fields: domain, key id, namespace, generation, audience, created, expiry,
124    /// nonce; UTF-8, LF separated, no final LF. Signature covers BLAKE3(bytes).
125    ///
126    /// # Errors
127    /// Every malformed, wrongly bound, expired or unauthorized statement.
128    pub fn verify(
129        &self,
130        wire: &str,
131        audience: &str,
132        now_ms: i64,
133    ) -> Result<AuthorityStatement, ServerError> {
134        if wire.len() > MAX_STATEMENT_BYTES {
135            return Err(rejected());
136        }
137        let (text, sig) = wire.split_once('.').ok_or_else(rejected)?;
138        let decode = |s: &str| {
139            let bytes = URL_SAFE_NO_PAD.decode(s).map_err(|_| rejected())?;
140            if URL_SAFE_NO_PAD.encode(&bytes) != s {
141                return Err(rejected());
142            }
143            Ok(bytes)
144        };
145        let bytes = decode(text)?;
146        let sig = decode(sig)?;
147        let text = std::str::from_utf8(&bytes).map_err(|_| rejected())?;
148        let fields = text.split('\n').collect::<Vec<_>>();
149        let [
150            domain,
151            id,
152            namespace,
153            generation,
154            origin,
155            created,
156            expiry,
157            nonce,
158        ] = fields.as_slice()
159        else {
160            return Err(rejected());
161        };
162        let raw_namespace = *namespace;
163        let namespace = Namespace::parse(raw_namespace).map_err(|_| rejected())?;
164        if namespace.to_string() != raw_namespace {
165            return Err(rejected());
166        }
167        let key = self
168            .keys
169            .iter()
170            .find(|key| key.key_id == *id && key.namespaces.contains(&namespace))
171            .ok_or_else(rejected)?;
172        let created = decimal(created)?;
173        let expiry = decimal(expiry)?;
174        let now = u64::try_from(now_ms).map_err(|_| rejected())?;
175        if *domain != DOMAIN
176            || *origin != audience
177            || validate_audience(origin).is_err()
178            || expiry <= created
179            || expiry - created > 300_000
180            || now >= expiry
181            || created > now.saturating_add(30_000)
182            || nonce.len() != 64
183            || !nonce.bytes().all(|b| matches!(b,b'0'..=b'9'|b'a'..=b'f'))
184        {
185            return Err(rejected());
186        }
187        let signature = Signature::from_slice(&sig).map_err(|_| rejected())?;
188        VerifyingKey::from_bytes(&key.public_key)
189            .map_err(|_| rejected())?
190            .verify_strict(&hash(&bytes), &signature)
191            .map_err(|_| rejected())?;
192        Ok(AuthorityStatement {
193            namespace,
194            generation: decimal(generation)?,
195        })
196    }
197}
198
199/// Verified target of one idempotent generation transition.
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub struct AuthorityStatement {
202    /// Namespace authorized by the configured deployment key.
203    pub namespace: Namespace,
204    /// Target generation, bounded relative to stored state at the CAS.
205    pub generation: u64,
206}
207
208/// Which independent integer a shared lease completion scan fences.
209#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
210pub(crate) enum FenceKind {
211    Grant,
212    Authority,
213}
214impl FenceKind {
215    pub(crate) fn key(self) -> Key {
216        match self {
217            Self::Grant => keys::grant_epoch(),
218            Self::Authority => keys::authority_generation(),
219        }
220    }
221}
222
223pub(crate) fn moved() -> ServerError {
224    ServerError::permission_denied("namespace authority generation changed")
225        .with_abort_cause(crate::error::AbortCause::EpochMismatch)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use ed25519_dalek::{Signer, SigningKey};
232    fn ns() -> String {
233        format!(
234            "ed25519-{}",
235            mkit_core::hash::to_hex(SigningKey::from_bytes(&[1; 32]).verifying_key().as_bytes())
236        )
237    }
238    fn fence() -> AuthorityFence {
239        AuthorityFence::parse(&format!(
240            "deployment {} {}",
241            mkit_core::hash::to_hex(SigningKey::from_bytes(&[7; 32]).verifying_key().as_bytes()),
242            ns()
243        ))
244        .unwrap()
245    }
246    fn wire(fields: &[String], seed: [u8; 32]) -> String {
247        let bytes = fields.join("\n");
248        let signature = SigningKey::from_bytes(&seed).sign(&hash(bytes.as_bytes()));
249        format!(
250            "{}.{}",
251            URL_SAFE_NO_PAD.encode(bytes),
252            URL_SAFE_NO_PAD.encode(signature.to_bytes())
253        )
254    }
255    fn fields() -> Vec<String> {
256        vec![
257            DOMAIN.into(),
258            "deployment".into(),
259            ns(),
260            "0".into(),
261            "https://vcs.example".into(),
262            "0".into(),
263            "60000".into(),
264            "a".repeat(64),
265        ]
266    }
267    #[test]
268    fn statement_binds_every_field_and_strict_signature() {
269        let f = fence();
270        let fields = fields();
271        assert_eq!(
272            f.verify(&wire(&fields, [7; 32]), "https://vcs.example", 1)
273                .unwrap()
274                .generation,
275            0
276        );
277        for (index, value) in [
278            (0, "mkit-hook:v1"),
279            (1, "owner"),
280            (2, "bad"),
281            (3, "01"),
282            (4, "https://other.example"),
283            (5, "40000"),
284            (6, "300001"),
285            (7, "aa"),
286        ] {
287            let mut changed = fields.clone();
288            changed[index] = value.into();
289            assert!(
290                f.verify(&wire(&changed, [7; 32]), "https://vcs.example", 1)
291                    .is_err(),
292                "field {index}"
293            );
294        }
295        assert!(
296            f.verify(&wire(&fields, [1; 32]), "https://vcs.example", 1)
297                .is_err()
298        );
299        assert!(
300            f.verify(&wire(&fields, [7; 32]), "https://vcs.example", 60000)
301                .is_err()
302        );
303        assert!(
304            f.verify(
305                &"a".repeat(MAX_STATEMENT_BYTES + 1),
306                "https://vcs.example",
307                1
308            )
309            .is_err()
310        );
311        let mut extended = fields.clone();
312        extended.push(String::new());
313        assert!(
314            f.verify(&wire(&extended, [7; 32]), "https://vcs.example", 1)
315                .is_err()
316        );
317    }
318    #[test]
319    fn deployment_keys_are_bounded_dedicated_and_permissioned() {
320        let public =
321            mkit_core::hash::to_hex(SigningKey::from_bytes(&[1; 32]).verifying_key().as_bytes());
322        assert!(AuthorityFence::parse(&format!("owner {public} {}", ns())).is_err());
323        assert!(AuthorityFence::parse("").is_err());
324        let key = format!(
325            "deployment {} {}",
326            mkit_core::hash::to_hex(SigningKey::from_bytes(&[7; 32]).verifying_key().as_bytes()),
327            ns()
328        );
329        assert!(AuthorityFence::parse(&format!("{key}\n{key}")).is_err());
330        let mut fields = fields();
331        fields[2] = format!(
332            "ed25519-{}",
333            mkit_core::hash::to_hex(SigningKey::from_bytes(&[2; 32]).verifying_key().as_bytes())
334        );
335        assert!(
336            fence()
337                .verify(&wire(&fields, [7; 32]), "https://vcs.example", 1)
338                .is_err()
339        );
340    }
341}