Skip to main content

mkit_server/admin/
auth.rs

1use std::collections::BTreeSet;
2
3use ed25519_dalek::{Signature, VerifyingKey};
4use mkit_core::hash::{hash, to_hex};
5use serde::Deserialize;
6use subtle::ConstantTimeEq;
7
8use crate::{ServerError, auth_v2};
9
10use super::{BodyCapture, Headers};
11
12/// Required admin envelope headers, in canonical envelope field order.
13pub const HEADER_NAMES: [&str; 8] = [
14    "x-mkit-admin-version",
15    "x-mkit-admin-key-id",
16    "x-mkit-admin-audience",
17    "x-mkit-admin-created-at",
18    "x-mkit-admin-expires-at",
19    "x-mkit-admin-nonce",
20    "x-mkit-admin-digest",
21    "x-mkit-admin-signature",
22];
23
24/// Public operator keys and the deployment's canonical signing origin.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct Config {
27    pub(crate) audience: String,
28    keys: Vec<Key>,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
32struct Key {
33    id: String,
34    public: [u8; 32],
35    before: Option<i64>,
36    after: Option<i64>,
37    roles: BTreeSet<String>,
38}
39
40impl Config {
41    /// Parse the ยง16.3 key list; empty lists disable the service.
42    ///
43    /// # Errors
44    /// Invalid origins, duplicate keys or roles, malformed keys and bounds.
45    pub fn parse(audience: &str, json: &str) -> Result<Self, ServerError> {
46        #[derive(Deserialize)]
47        #[serde(rename_all = "camelCase", deny_unknown_fields)]
48        struct Entry {
49            key_id: String,
50            alg: String,
51            public_key: String,
52            not_before_ms: Option<String>,
53            not_after_ms: Option<String>,
54            roles: Vec<String>,
55        }
56        #[derive(Deserialize)]
57        #[serde(deny_unknown_fields)]
58        struct List {
59            version: u32,
60            keys: Vec<Entry>,
61        }
62        if json.len() > 64 * 1024 {
63            return Err(invalid("admin key list too large"));
64        }
65        mkit_core::write_auth::validate_audience(audience)
66            .map_err(|_| invalid("invalid admin audience"))?;
67        let list: List =
68            serde_json::from_str(json).map_err(|_| invalid("invalid admin key list"))?;
69        if list.keys.len() > 128 {
70            return Err(invalid("too many admin keys"));
71        }
72        if list.version != 1 {
73            return Err(invalid("invalid admin key-list version"));
74        }
75        let mut keys: Vec<Key> = Vec::new();
76        for e in list.keys {
77            if !identifier(&e.key_id, 64, false)
78                || e.alg != "ed25519"
79                || keys.iter().any(|k| k.id == e.key_id)
80            {
81                return Err(invalid("invalid or duplicate admin key id"));
82            }
83            let public =
84                hex::<32>(&e.public_key).ok_or_else(|| invalid("invalid admin public key"))?;
85            VerifyingKey::from_bytes(&public).map_err(|_| invalid("invalid Ed25519 admin key"))?;
86            let bound = |s: Option<String>| {
87                s.map(|s| {
88                    decimal_i64(&s).ok_or_else(|| invalid("invalid admin key validity bound"))
89                })
90                .transpose()
91            };
92            let before = bound(e.not_before_ms)?;
93            let after = bound(e.not_after_ms)?;
94            if before.zip(after).is_some_and(|(b, a)| b > a) {
95                return Err(invalid("invalid admin key validity interval"));
96            }
97            let mut roles = BTreeSet::new();
98            for role in e.roles {
99                if !matches!(
100                    role.as_str(),
101                    "lease" | "moderation" | "grants" | "audit" | "all"
102                ) || !roles.insert(role)
103                {
104                    return Err(invalid("invalid or duplicate admin role"));
105                }
106            }
107            if roles.is_empty() {
108                return Err(invalid("admin key requires roles"));
109            }
110            keys.push(Key {
111                id: e.key_id,
112                public,
113                before,
114                after,
115                roles,
116            });
117        }
118        Ok(Self {
119            audience: audience.to_owned(),
120            keys,
121        })
122    }
123
124    /// Canonical signing origin used by authentication and operator intents.
125    #[must_use]
126    pub fn audience(&self) -> &str {
127        &self.audience
128    }
129
130    /// Whether a nonempty key list enables the admin service.
131    #[must_use]
132    pub fn enabled(&self) -> bool {
133        !self.keys.is_empty()
134    }
135
136    /// Configured public keys for checking other credential domains.
137    #[must_use]
138    pub fn public_keys(&self) -> Vec<[u8; 32]> {
139        self.keys.iter().map(|k| k.public).collect()
140    }
141
142    /// Reject sharing an admin key with another credential role.
143    ///
144    /// # Errors
145    /// A configured admin public key occurs in `other`.
146    pub fn check_separation(&self, other: &[[u8; 32]]) -> Result<(), ServerError> {
147        if self.keys.iter().any(|k| other.contains(&k.public)) {
148            return Err(invalid("admin credentials must use distinct keys"));
149        }
150        Ok(())
151    }
152
153    pub(crate) fn verify(
154        &self,
155        path: &str,
156        headers: &Headers,
157        body: &BodyCapture,
158        now: i64,
159    ) -> Result<Verified, ServerError> {
160        let verified = self.verify_envelope(path, headers, now)?;
161        let body_digest = body.digest();
162        if !bool::from(verified.digest.as_bytes().ct_eq(body_digest.as_bytes())) {
163            return Err(unauth("invalid admin envelope"));
164        }
165        Ok(verified)
166    }
167
168    pub(crate) fn verify_envelope(
169        &self,
170        path: &str,
171        headers: &Headers,
172        now: i64,
173    ) -> Result<Verified, ServerError> {
174        check_headers(headers)?;
175        if !self.enabled() {
176            return Err(ServerError::unauthenticated("admin service disabled"));
177        }
178        let h = HEADER_NAMES
179            .map(|name| one(headers, name).ok_or_else(|| unauth("missing admin header")));
180        let [
181            version,
182            id,
183            audience,
184            created,
185            expiry,
186            nonce,
187            digest,
188            signature,
189        ] = h;
190        if version? != "1" {
191            return Err(unauth("unsupported admin version"));
192        }
193        let id = id?;
194        let audience = audience?;
195        let created = created?;
196        let expiry = expiry?;
197        let nonce = nonce?;
198        let digest = digest?;
199        let signature = signature?;
200        if digest.len() != 69
201            || !digest.starts_with("body:")
202            || !digest[5..]
203                .bytes()
204                .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
205        {
206            return Err(unauth("invalid admin digest"));
207        }
208        let key = self
209            .keys
210            .iter()
211            .find(|k| k.id == id)
212            .ok_or_else(|| unauth("unknown admin key"))?;
213        let created_ms =
214            decimal_u64(created).ok_or_else(|| unauth("invalid admin creation time"))?;
215        let expiry_ms = decimal_u64(expiry).ok_or_else(|| unauth("invalid admin expiry time"))?;
216        let now_ms = u64::try_from(now).map_err(|_| unauth("invalid backend clock"))?;
217        let nonce_bytes = hex::<32>(nonce).ok_or_else(|| unauth("invalid admin nonce"))?;
218        let signature = hex::<64>(signature).ok_or_else(|| unauth("invalid admin signature"))?;
219        if audience != self.audience
220            || !path.starts_with(super::PREFIX)
221            || expiry_ms <= created_ms
222            || expiry_ms - created_ms > 300_000
223            || created_ms > now_ms.saturating_add(30_000)
224            || expiry_ms <= now_ms
225            || key
226                .before
227                .is_some_and(|b| i128::from(created_ms) < i128::from(b))
228            || key
229                .after
230                .is_some_and(|a| i128::from(created_ms) > i128::from(a))
231            || path == super::READ_PRESERVED_PATH
232                && (key.before.is_some_and(|b| now < b) || key.after.is_some_and(|a| now > a))
233        {
234            return Err(unauth("invalid admin envelope"));
235        }
236        let canonical = format!(
237            "mkit-admin:v1\n{id}\n{audience}\n{path}\n{digest}\n{created}\n{expiry}\n{nonce}"
238        );
239        let public =
240            VerifyingKey::from_bytes(&key.public).map_err(|_| unauth("invalid admin key"))?;
241        public
242            .verify_strict(
243                &hash(canonical.as_bytes()),
244                &Signature::from_bytes(&signature),
245            )
246            .map_err(|_| unauth("invalid admin signature"))?;
247        let replay_key = to_hex(&hash(
248            format!("{audience}\n{id}\n{}", to_hex(&nonce_bytes)).as_bytes(),
249        ));
250        Ok(Verified {
251            actor: id.to_owned(),
252            nonce: nonce.to_owned(),
253            digest: digest.to_owned(),
254            path: path.to_owned(),
255            replay_key,
256            expiry_ms,
257            roles: key.roles.clone(),
258        })
259    }
260}
261
262pub(crate) struct Verified {
263    pub actor: String,
264    pub nonce: String,
265    pub digest: String,
266    pub path: String,
267    pub replay_key: String,
268    pub expiry_ms: u64,
269    pub roles: BTreeSet<String>,
270}
271
272pub(crate) fn check_headers(headers: &Headers) -> Result<(), ServerError> {
273    let admin = headers
274        .iter()
275        .any(|(n, _)| n.to_ascii_lowercase().starts_with("x-mkit-admin-"));
276    if admin
277        && headers.iter().any(|(n, _)| {
278            auth_v2::HEADER_NAMES
279                .iter()
280                .any(|a| n.eq_ignore_ascii_case(a))
281                || n.eq_ignore_ascii_case("x-write-grant")
282        })
283    {
284        return Err(invalid("mixed admin and write credentials"));
285    }
286    if one(headers, HEADER_NAMES[0]) != Some("1") {
287        return Err(unauth("missing or unsupported admin version"));
288    }
289    for name in HEADER_NAMES {
290        if one(headers, name).is_none_or(|s| s.contains(',')) {
291            return Err(unauth("missing, repeated or joined admin header"));
292        }
293    }
294    Ok(())
295}
296
297fn one<'a>(headers: &'a Headers, name: &str) -> Option<&'a str> {
298    let mut values = headers.iter().filter(|(n, _)| n.eq_ignore_ascii_case(name));
299    let value = values.next()?.1.as_str();
300    values.next().is_none().then_some(value)
301}
302
303pub(crate) fn identifier(s: &str, max: usize, operation: bool) -> bool {
304    !s.is_empty()
305        && s.len() <= max
306        && s.bytes().all(|b| {
307            b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-') || operation && b == b':'
308        })
309}
310pub(crate) fn decimal_u64(s: &str) -> Option<u64> {
311    s.parse::<u64>().ok().filter(|n| n.to_string() == s)
312}
313fn decimal_i64(s: &str) -> Option<i64> {
314    s.parse::<i64>().ok().filter(|n| n.to_string() == s)
315}
316pub(crate) fn hex<const N: usize>(s: &str) -> Option<[u8; N]> {
317    if s.len() != 2 * N
318        || !s
319            .bytes()
320            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
321    {
322        return None;
323    }
324    let mut out = [0; N];
325    for (i, b) in out.iter_mut().enumerate() {
326        *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?;
327    }
328    Some(out)
329}
330pub(crate) fn invalid(message: &str) -> ServerError {
331    ServerError::invalid_argument(message.to_owned())
332}
333fn unauth(message: &str) -> ServerError {
334    ServerError::unauthenticated(message.to_owned())
335}