1#[cfg(test)]
13mod golden;
14mod statement;
15#[cfg(test)]
16mod tests;
17
18use std::collections::BTreeSet;
19use std::fmt::{self, Write as _};
20use std::future::Future;
21use std::sync::Arc;
22
23use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
24use mkit_attest::grant::GrantError;
25use mkit_core::hash::{hash, to_hex_bytes};
26use zeroize::Zeroizing;
27
28use crate::error::{Redacted, ServerError};
29
30pub(crate) use statement::key_id;
31pub use statement::{TargetError, UrlTarget, UrlTokenStatement};
32
33pub const DOMAIN: &str = "mkit-url-token:v1";
35pub const MAX_PATH_BYTES: usize = 1024;
37pub const DEFAULT_TTL_MS: u64 = 15 * 60 * 1000;
39pub const MAX_TTL_MS: u64 = 24 * 60 * 60 * 1000;
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
46#[non_exhaustive]
47pub enum UrlTokenError {
48 #[error("token too long")]
50 Length,
51 #[error("token format")]
53 Format,
54 #[error("token encoding")]
56 Encoding,
57 #[error("signature length")]
59 SignatureLength,
60 #[error("statement: {0}")]
62 Statement(#[from] GrantError),
63 #[error("invalid target")]
65 Target,
66 #[error("unknown or retired key id")]
68 KeyId,
69 #[error("bad signature")]
71 Signature,
72 #[error("binding mismatch")]
74 Binding,
75 #[error("token expired")]
77 Expired,
78 #[error("lifetime too long")]
80 Lifetime,
81 #[error("epoch mismatch")]
83 Epoch,
84}
85
86impl UrlTokenError {
87 #[must_use]
89 pub fn reason(&self) -> &'static str {
90 match self {
91 Self::Length => "token too long",
92 Self::Format => "token format",
93 Self::Encoding => "token encoding",
94 Self::SignatureLength => "signature length",
95 Self::Statement(e) => e.reason(),
96 Self::Target => "invalid target",
97 Self::KeyId => "unknown or retired key id",
98 Self::Signature => "bad signature",
99 Self::Binding => "binding mismatch",
100 Self::Expired => "token expired",
101 Self::Lifetime => "lifetime too long",
102 Self::Epoch => "epoch mismatch",
103 }
104 }
105}
106
107#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub struct RetiredKey {
110 pub public: [u8; 32],
112 pub retired_at_ms: u64,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
118pub enum UrlTokenConfigError {
119 #[error("invalid URL token key configuration")]
123 Keys,
124 #[error("invalid URL token lifetime")]
126 Ttl,
127}
128
129pub struct UrlTokenKeys {
132 active: SigningKey,
133 retired: Vec<RetiredKey>,
134}
135
136impl UrlTokenKeys {
137 #[must_use]
139 pub fn contains_secret(&self, material: &[u8; 32]) -> bool {
140 use subtle::ConstantTimeEq as _;
141 let seed = Zeroizing::new(self.active.to_bytes());
142 bool::from(seed.ct_eq(material))
143 }
144
145 #[allow(clippy::needless_pass_by_value)] pub fn new(
152 active_seed: Zeroizing<[u8; 32]>,
153 retired: Vec<RetiredKey>,
154 ) -> Result<Self, UrlTokenConfigError> {
155 let active = SigningKey::from_bytes(&active_seed);
156 let active_public = active.verifying_key().to_bytes();
157 let mut ids = BTreeSet::from([key_id(&active_public)]);
158 for key in &retired {
159 let verifying =
160 VerifyingKey::from_bytes(&key.public).map_err(|_| UrlTokenConfigError::Keys)?;
161 if verifying.is_weak()
162 || key.public == active_public
163 || !ids.insert(key_id(&key.public))
164 {
165 return Err(UrlTokenConfigError::Keys);
166 }
167 }
168 Ok(Self { active, retired })
169 }
170
171 pub fn parse_key_file(text: &str) -> Result<Self, UrlTokenConfigError> {
179 let invalid = || UrlTokenConfigError::Keys;
180 let mut seed = None;
181 let mut retired = Vec::new();
182 for line in text.lines().map(str::trim) {
183 if line.is_empty() || line.starts_with('#') {
184 continue;
185 }
186 let mut fields = line.split_whitespace();
187 match fields.next() {
188 Some("active") => {
189 let hex = fields.next().ok_or_else(invalid)?;
190 if seed.is_some() || fields.next().is_some() {
191 return Err(invalid());
192 }
193 seed = Some(Zeroizing::new(
194 mkit_attest::grant::text::hex32(hex).map_err(|_| invalid())?,
195 ));
196 }
197 Some("retired") => {
198 let public = fields.next().ok_or_else(invalid)?;
199 let at = fields.next().ok_or_else(invalid)?;
200 let retired_at_ms = at
201 .parse::<u64>()
202 .ok()
203 .filter(|n| n.to_string() == at)
204 .ok_or_else(invalid)?;
205 if fields.next().is_some() {
206 return Err(invalid());
207 }
208 retired.push(RetiredKey {
209 public: mkit_attest::grant::text::hex32(public).map_err(|_| invalid())?,
210 retired_at_ms,
211 });
212 }
213 _ => return Err(invalid()),
214 }
215 }
216 Self::new(seed.ok_or_else(invalid)?, retired)
217 }
218
219 pub fn parse_key_file_secret(text: String) -> Result<Self, UrlTokenConfigError> {
224 let text = Zeroizing::new(text);
225 Self::parse_key_file(&text)
226 }
227
228 #[must_use]
230 pub fn active_key_id(&self) -> String {
231 to_hex_bytes(&self.active_id())
232 }
233
234 fn active_id(&self) -> [u8; 16] {
236 key_id(&self.active.verifying_key().to_bytes())
237 }
238
239 pub fn public_keys(&self) -> impl Iterator<Item = [u8; 32]> + '_ {
242 core::iter::once(self.active.verifying_key().to_bytes())
243 .chain(self.retired.iter().map(|key| key.public))
244 }
245
246 pub(crate) fn verifying_key(
249 &self,
250 id: &[u8; 16],
251 now_ms: i64,
252 ttl_ms: u64,
253 ) -> Option<VerifyingKey> {
254 let active = self.active.verifying_key();
255 if key_id(active.as_bytes()) == *id {
256 return Some(active);
257 }
258 let key = self.retired.iter().find(|key| key_id(&key.public) == *id)?;
259 let after = key.retired_at_ms.saturating_add(ttl_ms);
260 if now_ms >= 0 && u64::try_from(now_ms).ok() < Some(after) {
261 VerifyingKey::from_bytes(&key.public).ok()
262 } else {
263 None
264 }
265 }
266
267 #[must_use]
272 pub fn key_set_json(&self, ttl_ms: u64) -> String {
273 let entry = |id: [u8; 16], public: &[u8; 32]| {
274 format!(
275 "\"keyId\":\"{}\",\"alg\":\"ed25519\",\"publicKey\":\"{}\"",
276 to_hex_bytes(&id),
277 to_hex_bytes(public)
278 )
279 };
280 let active = self.active.verifying_key().to_bytes();
281 let mut json = format!(
282 "{{\"version\":1,\"keys\":[{{{}",
283 entry(self.active_id(), &active)
284 );
285 for key in &self.retired {
286 let not_after = key.retired_at_ms.saturating_add(ttl_ms);
287 let _ = write!(
288 json,
289 "}},{{{},\"notAfterMs\":\"{not_after}\"",
290 entry(key_id(&key.public), &key.public)
291 );
292 }
293 json.push_str("}]}");
294 json
295 }
296}
297
298impl fmt::Debug for UrlTokenKeys {
299 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301 f.debug_struct("UrlTokenKeys")
302 .field(
303 "active_key_id",
304 &to_hex_bytes(&key_id(&self.active.verifying_key().to_bytes())),
305 )
306 .field(
307 "retired_key_ids",
308 &self
309 .retired
310 .iter()
311 .map(|key| to_hex_bytes(&key_id(&key.public)))
312 .collect::<Vec<_>>(),
313 )
314 .finish_non_exhaustive()
315 }
316}
317
318#[derive(Clone)]
321pub struct UrlTokenConfig {
322 keys: Arc<UrlTokenKeys>,
323 ttl_ms: u64,
324}
325
326impl UrlTokenConfig {
327 #[must_use]
330 pub fn new(keys: UrlTokenKeys) -> Self {
331 Self {
332 keys: Arc::new(keys),
333 ttl_ms: DEFAULT_TTL_MS,
334 }
335 }
336
337 pub fn with_ttl_ms(keys: UrlTokenKeys, ttl_ms: u64) -> Result<Self, UrlTokenConfigError> {
342 if ttl_ms == 0 || ttl_ms > MAX_TTL_MS {
343 return Err(UrlTokenConfigError::Ttl);
344 }
345 Ok(Self {
346 keys: Arc::new(keys),
347 ttl_ms,
348 })
349 }
350
351 #[must_use]
353 pub fn ttl_ms(&self) -> u64 {
354 self.ttl_ms
355 }
356
357 #[must_use]
359 pub fn keys(&self) -> &UrlTokenKeys {
360 &self.keys
361 }
362
363 pub fn mint(
371 &self,
372 audience: &str,
373 repository: &str,
374 target: &UrlTarget,
375 epoch: u64,
376 now_ms: i64,
377 requested_ttl_s: u32,
378 ) -> Result<MintedToken, ServerError> {
379 let ttl_ms = if requested_ttl_s == 0 {
380 self.ttl_ms
381 } else {
382 u64::from(requested_ttl_s)
383 .saturating_mul(1000)
384 .min(self.ttl_ms)
385 };
386 let expires_at_ms = now_ms.saturating_add(i64::try_from(ttl_ms).unwrap_or(i64::MAX));
387 let statement = UrlTokenStatement::new(
388 audience,
389 repository,
390 target.clone(),
391 epoch,
392 now_ms,
393 expires_at_ms,
394 self.keys.active_id(),
395 );
396 let bytes = statement.encode().map_err(|e| {
397 ServerError::internal(
398 "request failed",
399 format_args!("url token statement did not encode: {}", e.reason()),
400 )
401 })?;
402 let signature = self.keys.active.sign(&hash(&bytes));
403 Ok(MintedToken {
404 token: Redacted::new(statement::encode_token(&bytes, &signature.to_bytes())),
405 expires_at_ms,
406 })
407 }
408
409 pub fn precheck(&self, token: &str, now_ms: i64) -> Result<Prechecked, TokenRejected> {
418 let (bytes, signature) = statement::decode_token(token).map_err(|_| TokenRejected)?;
419 let statement = UrlTokenStatement::parse(&bytes).map_err(|_| TokenRejected)?;
420 let key = self
421 .keys
422 .verifying_key(&statement.key_id(), now_ms, self.ttl_ms)
423 .ok_or(TokenRejected)?;
424 key.verify_strict(&hash(&bytes), &Signature::from_bytes(&signature))
425 .map_err(|_| TokenRejected)?;
426 Ok(Prechecked { statement })
427 }
428}
429
430impl fmt::Debug for UrlTokenConfig {
431 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
432 f.debug_struct("UrlTokenConfig")
433 .field("keys", &self.keys)
434 .field("ttl_ms", &self.ttl_ms)
435 .finish()
436 }
437}
438
439#[derive(Clone)]
443pub struct MintedToken {
444 token: Redacted,
445 pub expires_at_ms: i64,
447}
448
449impl MintedToken {
450 #[must_use]
452 pub fn expose(&self) -> &str {
453 self.token.expose()
454 }
455}
456
457impl fmt::Debug for MintedToken {
458 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459 f.debug_struct("MintedToken")
460 .field("expires_at_ms", &self.expires_at_ms)
461 .finish_non_exhaustive()
462 }
463}
464
465#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
470#[error("invalid URL token")]
471pub struct TokenRejected;
472
473pub struct Prechecked {
477 statement: UrlTokenStatement,
478}
479
480impl fmt::Debug for Prechecked {
481 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
482 f.debug_struct("Prechecked").finish_non_exhaustive()
483 }
484}
485
486#[derive(Debug)]
489pub struct Binding<'a> {
490 pub audience: &'a str,
492 pub repository: &'a str,
494 pub target: &'a UrlTarget,
496}
497
498#[derive(Clone, Copy)]
501pub struct BoundToken {
502 epoch: u64,
503 issued_ms: i64,
504 expiry_ms: i64,
505}
506
507impl fmt::Debug for BoundToken {
508 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
509 f.debug_struct("BoundToken").finish_non_exhaustive()
510 }
511}
512impl BoundToken {
513 #[must_use]
515 pub fn expiry_ms(&self) -> i64 {
516 self.expiry_ms
517 }
518
519 #[must_use]
521 pub fn epoch(&self) -> u64 {
522 self.epoch
523 }
524
525 pub fn check_visibility_change(&self, changed_ms: u64) -> Result<(), TokenRejected> {
532 if u64::try_from(self.issued_ms).is_ok_and(|issued| issued > changed_ms) {
533 Ok(())
534 } else {
535 Err(TokenRejected)
536 }
537 }
538
539 pub fn check_epoch(&self, stored: u64) -> Result<(), TokenRejected> {
545 if self.epoch == stored {
546 Ok(())
547 } else {
548 Err(TokenRejected)
549 }
550 }
551}
552
553impl Prechecked {
554 pub fn check_binding(
563 self,
564 binding: &Binding<'_>,
565 now_ms: i64,
566 ttl_ms: u64,
567 ) -> Result<BoundToken, TokenRejected> {
568 let statement = &self.statement;
569 if statement.audience() != binding.audience
570 || statement.repository() != binding.repository
571 || statement.target() != binding.target
572 {
573 return Err(TokenRejected);
574 }
575 if now_ms >= statement.expiry_ms() {
576 return Err(TokenRejected);
577 }
578 let lifetime = statement.expiry_ms().saturating_sub(statement.issued_ms());
579 if lifetime > i64::try_from(ttl_ms).unwrap_or(i64::MAX) {
580 return Err(TokenRejected);
581 }
582 Ok(BoundToken {
583 epoch: statement.epoch(),
584 issued_ms: statement.issued_ms(),
585 expiry_ms: statement.expiry_ms(),
586 })
587 }
588}
589
590pub async fn verify<F, Fut, E>(
603 cfg: &UrlTokenConfig,
604 token: &str,
605 binding: &Binding<'_>,
606 now_ms: i64,
607 read_epoch: F,
608) -> Result<Result<(), TokenRejected>, E>
609where
610 F: FnOnce() -> Fut,
611 Fut: Future<Output = Result<u64, E>>,
612{
613 let bound = match cfg
614 .precheck(token, now_ms)
615 .and_then(|p| p.check_binding(binding, now_ms, cfg.ttl_ms()))
616 {
617 Ok(bound) => bound,
618 Err(rejected) => return Ok(Err(rejected)),
619 };
620 Ok(bound.check_epoch(read_epoch().await?))
621}