Skip to main content

mkit_server/url_token/
mod.rs

1//! Signed URL tokens (SPEC-WRITE-GRANTS §9.4): the `mkit-url-token:v1`
2//! statement, its `<statement>.<signature>` encoding, the deployment's
3//! Ed25519 key set and `IssueObjectUrl`'s mint.
4//!
5//! The token key is dedicated: it MUST NOT equal the receipt key, the
6//! hook key, the admin key, or any key that signs auth v2
7//! (SPEC-WRITE-GRANTS §9.4). [`crate::pipeline::Pipeline::new`] refuses a
8//! URL-token seed equal to an upload-ticket secret; the receipt-key check
9//! lands with WP-5.8. The token string and the seeds never appear in
10//! `Debug` output or tracing.
11
12#[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
33/// The statement domain separator (SPEC-WRITE-GRANTS §9.4).
34pub const DOMAIN: &str = "mkit-url-token:v1";
35/// The longest target path, in bytes.
36pub const MAX_PATH_BYTES: usize = 1024;
37/// The default token lifetime (§1.1 `url_token_ttl`).
38pub const DEFAULT_TTL_MS: u64 = 15 * 60 * 1000;
39/// The longest lifetime a statement may encode and a configuration may
40/// set (executor bound; §9.4 bounds by `url_token_ttl` at issue).
41pub const MAX_TTL_MS: u64 = 24 * 60 * 60 * 1000;
42
43/// Why a statement, token or key configuration is rejected. Every variant
44/// maps to one stable [`UrlTokenError::reason`] for the golden vectors.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
46#[non_exhaustive]
47pub enum UrlTokenError {
48    /// The token is longer than any valid token can be.
49    #[error("token too long")]
50    Length,
51    /// Not exactly two nonempty base64url segments joined by `.`.
52    #[error("token format")]
53    Format,
54    /// A segment is not strict unpadded base64url.
55    #[error("token encoding")]
56    Encoding,
57    /// The signature segment is not 64 bytes.
58    #[error("signature length")]
59    SignatureLength,
60    /// A §3.1 statement rule failed.
61    #[error("statement: {0}")]
62    Statement(#[from] GrantError),
63    /// The `target` field fails its grammar.
64    #[error("invalid target")]
65    Target,
66    /// The key id is not in the verification set, or its key is retired.
67    #[error("unknown or retired key id")]
68    KeyId,
69    /// The Ed25519 signature does not verify strictly.
70    #[error("bad signature")]
71    Signature,
72    /// The audience, repository or target does not match the request.
73    #[error("binding mismatch")]
74    Binding,
75    /// The token's `now` is at or past `expiry`.
76    #[error("token expired")]
77    Expired,
78    /// `expiry - issued` exceeds the configured lifetime.
79    #[error("lifetime too long")]
80    Lifetime,
81    /// The token's epoch differs from the stored epoch.
82    #[error("epoch mismatch")]
83    Epoch,
84}
85
86impl UrlTokenError {
87    /// The stable rejection reason, for tests and golden vectors.
88    #[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/// A retired verification key: it verifies until `retired_at_ms + ttl`.
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub struct RetiredKey {
110    /// The 32-byte Ed25519 public key.
111    pub public: [u8; 32],
112    /// Retirement time, Unix epoch milliseconds.
113    pub retired_at_ms: u64,
114}
115
116/// Invalid key-set or lifetime configuration. Never contains secret input.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
118pub enum UrlTokenConfigError {
119    /// Malformed seed or public key, a weak or duplicated public key, a
120    /// retired key equal to the active key, a missing or repeated `active`
121    /// line, or an unknown key-file directive.
122    #[error("invalid URL token key configuration")]
123    Keys,
124    /// A token lifetime outside `1..=MAX_TTL_MS`.
125    #[error("invalid URL token lifetime")]
126    Ttl,
127}
128
129/// The deployment's URL-token signing key and its retired verification
130/// keys (§9.4). Only key ids appear in `Debug`; seeds never do.
131pub struct UrlTokenKeys {
132    active: SigningKey,
133    retired: Vec<RetiredKey>,
134}
135
136impl UrlTokenKeys {
137    /// Check candidate role material without exposing the active signing seed.
138    #[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    /// Signing `active` seed plus the retired verification set.
146    ///
147    /// # Errors
148    /// [`UrlTokenConfigError::Keys`] for a malformed or weak retired public
149    /// key, a retired key equal to the active key, or a duplicate key id.
150    #[allow(clippy::needless_pass_by_value)] // The zeroizing seed moves with the key.
151    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    /// Parse a key file: blank lines and `#` comments are ignored; exactly
172    /// one `active <64 hex seed>` line and zero or more
173    /// `retired <64 hex public key> <retired_at_ms>` lines follow the §3.1
174    /// decimal and hex rules. Errors never echo input.
175    ///
176    /// # Errors
177    /// As [`UrlTokenKeys::new`], plus malformed lines.
178    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    /// [`Self::parse_key_file`] over an owned secret, wiping the source text.
220    ///
221    /// # Errors
222    /// As [`UrlTokenKeys::parse_key_file`].
223    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    /// The active key's id: `hex(blake3(public)[..16])`.
229    #[must_use]
230    pub fn active_key_id(&self) -> String {
231        to_hex_bytes(&self.active_id())
232    }
233
234    /// The active key's 16-byte id.
235    fn active_id(&self) -> [u8; 16] {
236        key_id(&self.active.verifying_key().to_bytes())
237    }
238
239    /// Active and retained public keys for deployment role-separation checks.
240    /// This never exposes seeds; adapters compare hook/enc/receipt/admin keys.
241    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    /// The verification key for a statement's key id: the active key, or a
247    /// retired key before `retired_at_ms + ttl_ms`.
248    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    /// The published key list (SPEC-SERVER §7.2): `version` 1, the active
268    /// key unbounded, each retired key bounded by `notAfterMs`
269    /// (`retired_at_ms + ttl_ms`). Mounting it at
270    /// `/.well-known/mkit-url-token-keys.json` is WP-4.16.
271    #[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    /// Key ids only; never a seed.
300    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/// The deployment's URL-token configuration: keys and the longest
319/// lifetime it issues (`url_token_ttl`, §1.1).
320#[derive(Clone)]
321pub struct UrlTokenConfig {
322    keys: Arc<UrlTokenKeys>,
323    ttl_ms: u64,
324}
325
326impl UrlTokenConfig {
327    /// `keys` with the default lifetime cap, [`DEFAULT_TTL_MS`] (15
328    /// minutes).
329    #[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    /// `keys` with an explicit issued-token lifetime cap.
338    ///
339    /// # Errors
340    /// [`UrlTokenConfigError::Ttl`] for `ttl_ms` outside `1..=MAX_TTL_MS`.
341    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    /// The issued-token lifetime cap, in milliseconds.
352    #[must_use]
353    pub fn ttl_ms(&self) -> u64 {
354        self.ttl_ms
355    }
356
357    /// The signing and verification key set.
358    #[must_use]
359    pub fn keys(&self) -> &UrlTokenKeys {
360        &self.keys
361    }
362
363    /// Mint a token binding `audience`, `repository` and `target` at
364    /// `epoch`, issued at `now_ms`. A `requested_ttl_s` of 0 asks for the
365    /// configured lifetime; any request is clamped to it, never refused.
366    ///
367    /// # Errors
368    /// `internal` when the statement cannot encode (a caller's clock far
369    /// outside its range).
370    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    /// Verification phase 1, before any repository lookup
410    /// (SPEC-HTTP-OBJECTS §6): strict token decode, the statement rules, a
411    /// key id in the verification set (the active key, or a retired key
412    /// before `retired_at_ms + ttl_ms`) and the strict Ed25519 signature
413    /// over `blake3(statement)`.
414    ///
415    /// # Errors
416    /// [`TokenRejected`] for any failure; the reason never escapes.
417    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/// A freshly minted token and its expiry. The token string is a
440/// credential: [`MintedToken::expose`] reads it for the response; `Debug`
441/// never shows it.
442#[derive(Clone)]
443pub struct MintedToken {
444    token: Redacted,
445    /// Expiry on the business clock, Unix epoch milliseconds.
446    pub expires_at_ms: i64,
447}
448
449impl MintedToken {
450    /// The token string, for the `IssueObjectUrl` response.
451    #[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/// The one rejection every URL-token verification failure maps to: the
466/// reason never survives to the client, so a private repository's
467/// uniform `not_found` cannot identify which check failed
468/// (SPEC-HTTP-OBJECTS §3 step 5).
469#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
470#[error("invalid URL token")]
471pub struct TokenRejected;
472
473/// A statement that passed [`UrlTokenConfig::precheck`]: syntax, key id
474/// and signature verified, still unbound to the request. `Debug` shows
475/// neither the token nor its claims.
476pub 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/// What verification phase 2 binds a token to: the request's audience,
487/// repository and target, each compared byte for byte.
488#[derive(Debug)]
489pub struct Binding<'a> {
490    /// The deployment's auth v2 audience.
491    pub audience: &'a str,
492    /// The request's repository identity (STC §7.4).
493    pub repository: &'a str,
494    /// The target the request asks for.
495    pub target: &'a UrlTarget,
496}
497
498/// A token bound to the request; the epoch comparison is all that is
499/// left (§9.4).
500#[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    /// Expiry retained for HTTP cache lifetime bounds.
514    #[must_use]
515    pub fn expiry_ms(&self) -> i64 {
516        self.expiry_ms
517    }
518
519    /// The epoch the token was minted at.
520    #[must_use]
521    pub fn epoch(&self) -> u64 {
522        self.epoch
523    }
524
525    /// The visibility rule (SPEC-WRITE-GRANTS §9.4): a token issued at or
526    /// before the repository's last visibility change (`changed_ms`, the
527    /// server's clock, 0 when it never changed) no longer serves.
528    ///
529    /// # Errors
530    /// [`TokenRejected`] when the token predates the change.
531    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    /// The last check: the token serves only while the stored epoch
540    /// still equals the epoch it was minted at.
541    ///
542    /// # Errors
543    /// [`TokenRejected`] on a mismatch.
544    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    /// Verification phase 2, before any stored-epoch read
555    /// (SPEC-HTTP-OBJECTS §6): `audience`, `repository` and `target`
556    /// equal the request's byte for byte, `now < expiry`, and
557    /// `expiry - issued <= ttl_ms` — the configured lifetime, which may
558    /// be shorter than the statement grammar's `MAX_TTL_MS` bound.
559    ///
560    /// # Errors
561    /// [`TokenRejected`] for any failure.
562    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
590/// §9.4 verification for a serving path: [`UrlTokenConfig::precheck`],
591/// then [`Prechecked::check_binding`], then exactly one `read_epoch`
592/// call and the epoch comparison. `read_epoch` never runs when an
593/// earlier phase fails — the stored-epoch read is the last step
594/// (SPEC-HTTP-OBJECTS §6). A public repository ignores the result; that
595/// choice belongs to the serving caller (SPEC-HTTP-OBJECTS §3 step 5).
596///
597/// # Errors
598/// The outer `Err` is `read_epoch`'s own error — an infrastructure
599/// failure, never confused with a rejection (SPEC-HTTP-OBJECTS §3: a
600/// store failure is a 503, not a fabricated `not_found`). The inner
601/// `Err` is the uniform [`TokenRejected`].
602pub 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}