Skip to main content

mkit_server/url_token/
statement.rs

1//! The `mkit-url-token:v1` statement: the target grammar, the §3.1 field
2//! codec and the `<statement>.<signature>` token encoding
3//! (SPEC-WRITE-GRANTS §9.4).
4
5use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
6use mkit_attest::grant::GrantError;
7use mkit_attest::grant::text::{
8    check_lifetime, decimal_millis, decimal_u64, encode_millis, join_fields, split_fields,
9};
10use mkit_core::hash::{Hash, from_hex, hash, to_hex, to_hex_bytes};
11use mkit_core::repo_identity::RepositoryIdentity;
12use mkit_core::write_auth::{is_hex, validate_audience};
13
14use super::{DOMAIN, MAX_PATH_BYTES, MAX_TTL_MS, UrlTokenError};
15
16/// The eight statement fields of §9.4.
17const STATEMENT_FIELDS: usize = 8;
18
19/// The longest token string: a statement of
20/// `mkit_attest::grant::MAX_STATEMENT_BYTES` and a 64-byte signature
21/// encode well under it, so a longer input can never be a valid token.
22pub(crate) const MAX_TOKEN_LEN: usize = 8192;
23
24/// What `IssueObjectUrl` binds a token to (§9.4 `target`).
25#[derive(Clone, Debug, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum UrlTarget {
28    /// `object:<64 lowercase hex object id>`.
29    Object(Hash),
30    /// `path:<full ref name>:<unpadded base64url of the UTF-8 path>`; the
31    /// decoded path names a tree entry, empty the root tree. Built only
32    /// through [`UrlTarget::path`], which enforces the §9.4 grammar.
33    #[non_exhaustive]
34    Path {
35        /// The full ref name (SPEC-REFS §3).
36        reference: String,
37        /// The UTF-8 path, `0..MAX_PATH_BYTES` bytes.
38        path: String,
39    },
40}
41
42/// An invalid `target` field: bad ref, path or encoding.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
44#[error("invalid URL token target")]
45pub struct TargetError;
46
47impl UrlTarget {
48    /// A path target after checking the ref name and the §9.4 path rules.
49    ///
50    /// # Errors
51    /// [`TargetError`] for an invalid ref name, a path over
52    /// [`MAX_PATH_BYTES`] bytes, or a nonempty path with a leading,
53    /// trailing or repeated `/` or a `.`/`..` entry.
54    pub fn path(
55        reference: impl Into<String>,
56        path: impl Into<String>,
57    ) -> Result<Self, TargetError> {
58        let (reference, path) = (reference.into(), path.into());
59        if !crate::refs::validate_ref_name(&reference) || !valid_path(&path) {
60            return Err(TargetError);
61        }
62        Ok(Self::Path { reference, path })
63    }
64
65    /// The canonical `target` field text.
66    #[must_use]
67    pub fn field(&self) -> String {
68        match self {
69            Self::Object(id) => format!("object:{}", to_hex(id)),
70            Self::Path { reference, path } => {
71                format!(
72                    "path:{reference}:{}",
73                    URL_SAFE_NO_PAD.encode(path.as_bytes())
74                )
75            }
76        }
77    }
78
79    /// Parse a `target` field. Ref names contain no `:`, so the field
80    /// splits at its first two.
81    ///
82    /// # Errors
83    /// [`TargetError`] for anything [`Self::field`] does not produce.
84    pub fn parse_field(field: &str) -> Result<Self, TargetError> {
85        if let Some(id) = field.strip_prefix("object:") {
86            if !is_hex(id, 32) {
87                return Err(TargetError);
88            }
89            return from_hex(id).map(Self::Object).map_err(|_| TargetError);
90        }
91        let rest = field.strip_prefix("path:").ok_or(TargetError)?;
92        let (reference, encoded) = rest.split_once(':').ok_or(TargetError)?;
93        let bytes = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| TargetError)?;
94        let path = String::from_utf8(bytes).map_err(|_| TargetError)?;
95        Self::path(reference, path)
96    }
97}
98
99/// The §9.4 path grammar: at most [`MAX_PATH_BYTES`] bytes; empty names the
100/// root tree; a nonempty path is `/`-joined entry names with no empty,
101/// `.` or `..` entry, and no control character (Unicode category Cc).
102fn valid_path(path: &str) -> bool {
103    path.len() <= MAX_PATH_BYTES
104        && !path.chars().any(char::is_control)
105        && (path.is_empty()
106            || path
107                .split('/')
108                .all(|entry| !entry.is_empty() && entry != "." && entry != ".."))
109}
110
111/// A verified `mkit-url-token:v1` statement (§9.4).
112#[derive(Clone, Debug, PartialEq, Eq)]
113#[non_exhaustive]
114pub struct UrlTokenStatement {
115    audience: String,
116    repository: String,
117    target: UrlTarget,
118    epoch: u64,
119    issued_ms: i64,
120    expiry_ms: i64,
121    key_id: [u8; 16],
122}
123
124impl UrlTokenStatement {
125    /// Build from already-validated parts.
126    pub(crate) fn new(
127        audience: impl Into<String>,
128        repository: impl Into<String>,
129        target: UrlTarget,
130        epoch: u64,
131        issued_ms: i64,
132        expiry_ms: i64,
133        key_id: [u8; 16],
134    ) -> Self {
135        Self {
136            audience: audience.into(),
137            repository: repository.into(),
138            target,
139            epoch,
140            issued_ms,
141            expiry_ms,
142            key_id,
143        }
144    }
145
146    /// The issuing deployment's auth v2 audience.
147    #[must_use]
148    pub fn audience(&self) -> &str {
149        &self.audience
150    }
151
152    /// The full repository identity.
153    #[must_use]
154    pub fn repository(&self) -> &str {
155        &self.repository
156    }
157
158    /// The bound target.
159    #[must_use]
160    pub fn target(&self) -> &UrlTarget {
161        &self.target
162    }
163
164    /// The namespace's stored epoch at issue time.
165    #[must_use]
166    pub fn epoch(&self) -> u64 {
167        self.epoch
168    }
169
170    /// Issue time, Unix epoch milliseconds.
171    #[must_use]
172    pub fn issued_ms(&self) -> i64 {
173        self.issued_ms
174    }
175
176    /// Expiry, Unix epoch milliseconds.
177    #[must_use]
178    pub fn expiry_ms(&self) -> i64 {
179        self.expiry_ms
180    }
181
182    /// The signing key's id: `blake3(public key)[..16]`.
183    #[must_use]
184    pub fn key_id(&self) -> [u8; 16] {
185        self.key_id
186    }
187
188    /// Encode the canonical statement. Validates every rule
189    /// [`UrlTokenStatement::parse`] enforces.
190    ///
191    /// # Errors
192    /// The [`UrlTokenError`] of the first failed rule.
193    pub fn encode(&self) -> Result<Vec<u8>, UrlTokenError> {
194        validate_audience(&self.audience).map_err(|_| GrantError::Audience)?;
195        // The request's §7.4 identity, byte for byte: a single deployment's
196        // may be a bare name (SPEC-WRITE-GRANTS §9.4, SPEC-HTTP-OBJECTS §2).
197        RepositoryIdentity::parse_bare_allowed(&self.repository)
198            .map_err(|_| GrantError::Repository)?;
199        check_lifetime(self.issued_ms, self.expiry_ms, max_ttl_i64())?;
200        join_fields(&[
201            DOMAIN,
202            &self.audience,
203            &self.repository,
204            &self.target.field(),
205            &self.epoch.to_string(),
206            &encode_millis(self.issued_ms)?,
207            &encode_millis(self.expiry_ms)?,
208            &to_hex_bytes(&self.key_id),
209        ])
210        .map_err(Into::into)
211    }
212
213    /// Parse a canonical statement: the §3.1 text rules, then each §9.4
214    /// field rule.
215    ///
216    /// # Errors
217    /// The [`UrlTokenError`] of the first failed rule.
218    pub fn parse(bytes: &[u8]) -> Result<Self, UrlTokenError> {
219        let f = split_fields(bytes, STATEMENT_FIELDS)?;
220        if f[0] != DOMAIN {
221            return Err(GrantError::Domain.into());
222        }
223        validate_audience(f[1]).map_err(|_| GrantError::Audience)?;
224        RepositoryIdentity::parse_bare_allowed(f[2]).map_err(|_| GrantError::Repository)?;
225        let target = UrlTarget::parse_field(f[3]).map_err(|_| UrlTokenError::Target)?;
226        let epoch = decimal_u64(f[4])?;
227        let issued_ms = decimal_millis(f[5])?;
228        let expiry_ms = decimal_millis(f[6])?;
229        check_lifetime(issued_ms, expiry_ms, max_ttl_i64())?;
230        let key_id = key_id_hex(f[7])?;
231        Ok(Self {
232            audience: f[1].to_owned(),
233            repository: f[2].to_owned(),
234            target,
235            epoch,
236            issued_ms,
237            expiry_ms,
238            key_id,
239        })
240    }
241}
242
243/// The key-id field: 32 lowercase hex digits for the 16-byte id.
244fn key_id_hex(field: &str) -> Result<[u8; 16], UrlTokenError> {
245    if !is_hex(field, 16) {
246        return Err(GrantError::Hex.into());
247    }
248    // `is_hex` accepted, so every pair is a hex byte.
249    let mut id = [0; 16];
250    for (i, byte) in id.iter_mut().enumerate() {
251        *byte = u8::from_str_radix(&field[2 * i..2 * i + 2], 16).map_err(|_| GrantError::Hex)?;
252    }
253    Ok(id)
254}
255
256/// `MAX_TTL_MS` in `check_lifetime`'s `i64` units.
257fn max_ttl_i64() -> i64 {
258    i64::try_from(MAX_TTL_MS).unwrap_or(i64::MAX)
259}
260
261/// The token's key id: `hex(blake3(public key)[..16])`.
262pub(crate) fn key_id(public: &[u8; 32]) -> [u8; 16] {
263    let mut id = [0; 16];
264    id.copy_from_slice(&hash(public)[..16]);
265    id
266}
267
268/// Split a token into its statement and signature bytes (§9.4):
269/// `<b64url statement>.<b64url 64-byte signature>`, both unpadded and
270/// strictly encoded.
271pub(crate) fn decode_token(token: &str) -> Result<(Vec<u8>, [u8; 64]), UrlTokenError> {
272    if token.len() > MAX_TOKEN_LEN {
273        return Err(UrlTokenError::Length);
274    }
275    let (statement, signature) = token.split_once('.').ok_or(UrlTokenError::Format)?;
276    if statement.is_empty() || signature.is_empty() || signature.contains('.') {
277        return Err(UrlTokenError::Format);
278    }
279    let statement = URL_SAFE_NO_PAD
280        .decode(statement)
281        .map_err(|_| UrlTokenError::Encoding)?;
282    let signature: [u8; 64] = URL_SAFE_NO_PAD
283        .decode(signature)
284        .map_err(|_| UrlTokenError::Encoding)?
285        .try_into()
286        .map_err(|_| UrlTokenError::SignatureLength)?;
287    Ok((statement, signature))
288}
289
290/// Assemble `<b64url statement>.<b64url signature>`.
291pub(crate) fn encode_token(statement: &[u8], signature: &[u8; 64]) -> String {
292    format!(
293        "{}.{}",
294        URL_SAFE_NO_PAD.encode(statement),
295        URL_SAFE_NO_PAD.encode(signature)
296    )
297}