1use 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
13pub const TICKET_TOKEN_CONTEXT: &str = "mkit-server ticket token v1";
15pub const PART_RECEIPT_CONTEXT: &str = "mkit-server part receipt v1";
17
18#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct TicketClaims {
22 pub authority_generation: Option<u64>,
24 pub ticket_id: Hash,
26 pub audience: String,
28 pub repository: String,
30 pub signer: Hash,
32 pub pack_id: Hash,
34 pub bytes: u64,
36 pub part_size: u64,
38 pub expires_at_ms: u64,
40 pub upload_session: Vec<u8>,
42}
43
44impl TicketClaims {
45 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 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#[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#[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 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 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 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 pub fn new(keys: Vec<(String, Hash)>) -> Result<Self, TicketKeyError> {
172 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 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 pub fn parse_secret(text: String) -> Result<Self, TicketKeyError> {
221 let text = Zeroizing::new(text);
222 Self::parse(&text)
223 }
224
225 #[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 #[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 #[must_use]
256 pub fn mint(&self, claims: &TicketClaims) -> Vec<u8> {
257 let key = &self.keys[0]; 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 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;