Skip to main content

mkit_server/upload/
token.rs

1//! Stateless upload ticket authentication (STC §7.6). Only the key id is
2//! inspected before authenticating the bytes; claims are decoded afterwards.
3
4use std::collections::BTreeSet;
5use std::fmt;
6
7use mkit_core::hash::{Hash, from_hex};
8use subtle::ConstantTimeEq;
9use zeroize::Zeroizing;
10
11use crate::error::{Code, ServerError};
12
13/// Domain separator for the ticket-token MAC key.
14pub const TICKET_TOKEN_CONTEXT: &str = "mkit-server ticket token v1";
15/// Reserved for WP-1.11: derive receipt keys from the same deployment secret.
16pub const PART_RECEIPT_CONTEXT: &str = "mkit-server part receipt v1";
17
18/// Authenticated, deployment-bound upload claims. Clients treat their encoding
19/// as opaque; no metadata read is needed to verify them.
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct TicketClaims {
22    /// Authority generation authorized when this ticket was created.
23    pub authority_generation: Option<u64>,
24    /// Reservation-derived ticket id.
25    pub ticket_id: Hash,
26    /// Canonical deployment audience.
27    pub audience: String,
28    /// Full repository identity, including namespace.
29    pub repository: String,
30    /// Signer permitted to upload and consume this ticket.
31    pub signer: Hash,
32    /// Expected pack commitment.
33    pub pack_id: Hash,
34    /// Declared pack byte count.
35    pub bytes: u64,
36    /// Server-selected part geometry.
37    pub part_size: u64,
38    /// Expiry on the business clock, Unix milliseconds.
39    pub expires_at_ms: u64,
40    /// Backend multipart identifier; empty before WP-1.11.
41    pub upload_session: Vec<u8>,
42}
43
44impl TicketClaims {
45    /// Bind the ticket to the caller before an RPC checks its own payload fields.
46    pub fn check_principal(
47        &self,
48        audience: &str,
49        repository: &str,
50        signer: &Hash,
51    ) -> Result<(), ServerError> {
52        if self.audience != audience || self.repository != repository || self.signer != *signer {
53            return Err(ServerError::new(
54                Code::PermissionDenied,
55                "upload ticket binding mismatch",
56            ));
57        }
58        Ok(())
59    }
60
61    /// Check the request binding after token verification. Ticket, part and
62    /// receipt-specific commitments are checked by their respective RPCs.
63    pub fn check_binding(
64        &self,
65        audience: &str,
66        repository: &str,
67        signer: &Hash,
68        pack_id: &Hash,
69        bytes: u64,
70    ) -> Result<(), ServerError> {
71        self.check_principal(audience, repository, signer)?;
72        if self.pack_id != *pack_id || self.bytes != bytes {
73            return Err(ServerError::new(
74                Code::PermissionDenied,
75                "upload ticket binding mismatch",
76            ));
77        }
78        Ok(())
79    }
80}
81
82#[derive(Clone)]
83struct TicketKey {
84    id: String,
85    secret: Zeroizing<Hash>,
86}
87
88impl PartialEq for TicketKey {
89    fn eq(&self, other: &Self) -> bool {
90        self.id == other.id && bool::from(self.secret.ct_eq(&*other.secret))
91    }
92}
93
94impl Eq for TicketKey {}
95
96impl TicketKey {
97    fn mac_key(&self) -> Zeroizing<Hash> {
98        Zeroizing::new(blake3::derive_key(TICKET_TOKEN_CONTEXT, &*self.secret))
99    }
100
101    fn receipt_mac_key(&self) -> Zeroizing<Hash> {
102        Zeroizing::new(blake3::derive_key(PART_RECEIPT_CONTEXT, &*self.secret))
103    }
104}
105
106/// Signing and accepted keys. The first key signs and every listed key verifies.
107/// Secrets, including derived MAC keys, are zeroed when dropped.
108#[derive(Clone, PartialEq, Eq)]
109pub struct TicketKeys {
110    keys: Vec<TicketKey>,
111}
112
113impl fmt::Debug for TicketKeys {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        f.debug_struct("TicketKeys")
116            .field(
117                "key_ids",
118                &self.keys.iter().map(|key| &key.id).collect::<Vec<_>>(),
119            )
120            .finish_non_exhaustive()
121    }
122}
123
124/// Invalid deployment key configuration. Never contains secret input.
125#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
126#[error("invalid ticket keys: expected unique key ids and 64 hex digits per key")]
127pub struct TicketKeyError;
128
129impl TicketKeys {
130    /// Dedicated listing MAC domain; never interchangeable with tickets or receipts.
131    pub(crate) fn listing_signing_key(&self) -> (&str, Zeroizing<Hash>) {
132        let key = &self.keys[0];
133        (
134            &key.id,
135            Zeroizing::new(blake3::derive_key(
136                "mkit ListRepos page token v1",
137                &*key.secret,
138            )),
139        )
140    }
141
142    pub(crate) fn listing_verification_key(&self, id: &[u8]) -> Option<Zeroizing<Hash>> {
143        self.keys
144            .iter()
145            .find(|key| key.id.as_bytes() == id)
146            .map(|key| {
147                Zeroizing::new(blake3::derive_key(
148                    "mkit ListRepos page token v1",
149                    &*key.secret,
150                ))
151            })
152    }
153
154    /// The active key id and a domain-separated receipt MAC key. The source
155    /// secret is never exposed; the derived key is wiped after use.
156    pub(crate) fn receipt_signing_key(&self) -> (&str, Zeroizing<Hash>) {
157        let key = &self.keys[0];
158        (&key.id, key.receipt_mac_key())
159    }
160
161    /// A domain-separated receipt MAC key for an accepted key id.
162    pub(crate) fn receipt_verification_key(&self, id: &[u8]) -> Option<Zeroizing<Hash>> {
163        self.keys
164            .iter()
165            .find(|key| key.id.as_bytes() == id)
166            .map(TicketKey::receipt_mac_key)
167    }
168
169    /// Validate an ordered key set. It must be nonempty; ids must be unique,
170    /// 1–32 ASCII bytes from `[A-Za-z0-9._-]`.
171    pub fn new(keys: Vec<(String, Hash)>) -> Result<Self, TicketKeyError> {
172        // Wrap before validation so even rejected configuration is wiped.
173        let keys: Vec<_> = keys
174            .into_iter()
175            .map(|(id, secret)| TicketKey {
176                id,
177                secret: Zeroizing::new(secret),
178            })
179            .collect();
180        let mut ids = BTreeSet::new();
181        if keys.is_empty()
182            || keys
183                .iter()
184                .any(|key| !valid_id(&key.id) || !ids.insert(&key.id))
185        {
186            return Err(TicketKeyError);
187        }
188        Ok(Self { keys })
189    }
190
191    /// Parse `<key-id> <64 hex>` lines, ignoring blank lines and `#` comments.
192    /// The first line signs; all lines verify. Errors never echo input.
193    pub fn parse(text: &str) -> Result<Self, TicketKeyError> {
194        let mut keys = Vec::new();
195        let mut ids = BTreeSet::new();
196        for line in text.lines().map(str::trim) {
197            if line.is_empty() || line.starts_with('#') {
198                continue;
199            }
200            let mut fields = line.split_whitespace();
201            let id = fields.next().ok_or(TicketKeyError)?;
202            let secret = fields.next().ok_or(TicketKeyError)?;
203            if fields.next().is_some() || !valid_id(id) || !ids.insert(id) {
204                return Err(TicketKeyError);
205            }
206            let secret = Zeroizing::new(from_hex(secret).map_err(|_| TicketKeyError)?);
207            keys.push(TicketKey {
208                id: id.into(),
209                secret,
210            });
211        }
212        if keys.is_empty() {
213            return Err(TicketKeyError);
214        }
215        Ok(Self { keys })
216    }
217
218    /// Parse an owned deployment secret, wiping its source text when parsing
219    /// completes, including on invalid configuration.
220    pub fn parse_secret(text: String) -> Result<Self, TicketKeyError> {
221        let text = Zeroizing::new(text);
222        Self::parse(&text)
223    }
224
225    /// Whether `secret` is any key's source secret, in constant time.
226    /// `Pipeline::new` refuses a URL-token key that repeats one
227    /// (SPEC-WRITE-GRANTS §9.4's dedicated-key rule), and the native adapter
228    /// refuses a hook seed that does (SPEC-SERVER §7.1).
229    #[must_use]
230    pub fn contains_secret(&self, secret: &[u8; 32]) -> bool {
231        self.keys
232            .iter()
233            .any(|key| bool::from(key.secret.ct_eq(secret)))
234    }
235
236    /// Whether a public role key equals a ticket secret or its derived public key.
237    /// Publishing raw ticket MAC material would disclose it; deriving the same
238    /// public key also identifies a shared signing seed. Both are forbidden.
239    #[must_use]
240    pub fn contains_ed25519_public(&self, public: &[u8; 32]) -> bool {
241        self.keys.iter().any(|key| {
242            bool::from(key.secret.ct_eq(public))
243                || ed25519_dalek::SigningKey::from_bytes(&key.secret)
244                    .verifying_key()
245                    .as_bytes()
246                    == public
247        })
248    }
249
250    /// Mint a token from trusted claims.
251    ///
252    /// # Panics
253    /// If audience, repository or session exceeds the encoding's u16 length.
254    /// RPC callers build claims from validated repository and ticket rows.
255    #[must_use]
256    pub fn mint(&self, claims: &TicketClaims) -> Vec<u8> {
257        let key = &self.keys[0]; // constructors enforce a nonempty key set
258        let id_len = u8::try_from(key.id.len()).expect("validated ticket key id");
259        let mut bytes = vec![
260            if claims.authority_generation.is_some() {
261                2
262            } else {
263                1
264            },
265            id_len,
266        ];
267        bytes.extend_from_slice(key.id.as_bytes());
268        bytes.extend_from_slice(&claims.ticket_id);
269        append_field(&mut bytes, claims.audience.as_bytes());
270        append_field(&mut bytes, claims.repository.as_bytes());
271        bytes.extend_from_slice(&claims.signer);
272        bytes.extend_from_slice(&claims.pack_id);
273        for number in [claims.bytes, claims.part_size, claims.expires_at_ms] {
274            bytes.extend_from_slice(&number.to_be_bytes());
275        }
276        append_field(&mut bytes, &claims.upload_session);
277        if let Some(generation) = claims.authority_generation {
278            bytes.extend_from_slice(&generation.to_be_bytes());
279        }
280        bytes.extend_from_slice(blake3::keyed_hash(&key.mac_key(), &bytes).as_bytes());
281        bytes
282    }
283
284    /// Authenticate first, decode next, then check expiry on the business clock.
285    /// Malformed, unknown, invalid and expired tokens are failed preconditions.
286    pub fn verify(&self, token: &[u8], now_ms: u64) -> Result<TicketClaims, ServerError> {
287        let tag_at = token.len().checked_sub(32).ok_or_else(invalid_token)?;
288        let (message, tag) = token.split_at(tag_at);
289        let id_len = usize::from(*message.get(1).ok_or_else(invalid_token)?);
290        let id = message.get(2..2 + id_len).ok_or_else(invalid_token)?;
291        let key = self
292            .keys
293            .iter()
294            .find(|key| key.id.as_bytes() == id)
295            .ok_or_else(invalid_token)?;
296        let expected = blake3::keyed_hash(&key.mac_key(), message);
297        if !bool::from(expected.as_bytes().as_slice().ct_eq(tag)) {
298            return Err(invalid_token());
299        }
300
301        let mut reader = Reader(message);
302        let version = reader.take(1)?[0];
303        if version != 1 && version != 2 {
304            return Err(invalid_token());
305        }
306        reader.take(1 + id_len)?;
307        let claims = TicketClaims {
308            ticket_id: reader.array()?,
309            audience: reader.text()?,
310            repository: reader.text()?,
311            signer: reader.array()?,
312            pack_id: reader.array()?,
313            bytes: u64::from_be_bytes(reader.array()?),
314            part_size: u64::from_be_bytes(reader.array()?),
315            expires_at_ms: u64::from_be_bytes(reader.array()?),
316            upload_session: reader.field()?.to_vec(),
317            authority_generation: if version == 2 {
318                Some(u64::from_be_bytes(reader.array()?))
319            } else {
320                None
321            },
322        };
323        if !reader.0.is_empty() || claims.expires_at_ms <= now_ms {
324            return Err(invalid_token());
325        }
326        Ok(claims)
327    }
328}
329
330impl std::str::FromStr for TicketKeys {
331    type Err = TicketKeyError;
332
333    fn from_str(text: &str) -> Result<Self, Self::Err> {
334        Self::parse(text)
335    }
336}
337
338fn valid_id(id: &str) -> bool {
339    (1..=32).contains(&id.len())
340        && id
341            .bytes()
342            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
343}
344
345fn append_field(bytes: &mut Vec<u8>, field: &[u8]) {
346    let length = u16::try_from(field.len()).expect("validated ticket claim length");
347    bytes.extend_from_slice(&length.to_be_bytes());
348    bytes.extend_from_slice(field);
349}
350
351fn invalid_token() -> ServerError {
352    ServerError::new(Code::FailedPrecondition, "invalid or expired upload ticket")
353}
354
355struct Reader<'a>(&'a [u8]);
356
357impl<'a> Reader<'a> {
358    fn take(&mut self, length: usize) -> Result<&'a [u8], ServerError> {
359        let bytes = self.0.get(..length).ok_or_else(invalid_token)?;
360        self.0 = &self.0[length..];
361        Ok(bytes)
362    }
363
364    fn array<const N: usize>(&mut self) -> Result<[u8; N], ServerError> {
365        self.take(N)?.try_into().map_err(|_| invalid_token())
366    }
367
368    fn field(&mut self) -> Result<&'a [u8], ServerError> {
369        let length = u16::from_be_bytes(self.array()?);
370        self.take(usize::from(length))
371    }
372
373    fn text(&mut self) -> Result<String, ServerError> {
374        std::str::from_utf8(self.field()?)
375            .map(str::to_owned)
376            .map_err(|_| invalid_token())
377    }
378}
379
380#[cfg(test)]
381#[path = "token_tests.rs"]
382mod tests;