1use 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
16const STATEMENT_FIELDS: usize = 8;
18
19pub(crate) const MAX_TOKEN_LEN: usize = 8192;
23
24#[derive(Clone, Debug, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum UrlTarget {
28 Object(Hash),
30 #[non_exhaustive]
34 Path {
35 reference: String,
37 path: String,
39 },
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
44#[error("invalid URL token target")]
45pub struct TargetError;
46
47impl UrlTarget {
48 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 #[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 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
99fn 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#[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 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 #[must_use]
148 pub fn audience(&self) -> &str {
149 &self.audience
150 }
151
152 #[must_use]
154 pub fn repository(&self) -> &str {
155 &self.repository
156 }
157
158 #[must_use]
160 pub fn target(&self) -> &UrlTarget {
161 &self.target
162 }
163
164 #[must_use]
166 pub fn epoch(&self) -> u64 {
167 self.epoch
168 }
169
170 #[must_use]
172 pub fn issued_ms(&self) -> i64 {
173 self.issued_ms
174 }
175
176 #[must_use]
178 pub fn expiry_ms(&self) -> i64 {
179 self.expiry_ms
180 }
181
182 #[must_use]
184 pub fn key_id(&self) -> [u8; 16] {
185 self.key_id
186 }
187
188 pub fn encode(&self) -> Result<Vec<u8>, UrlTokenError> {
194 validate_audience(&self.audience).map_err(|_| GrantError::Audience)?;
195 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 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
243fn key_id_hex(field: &str) -> Result<[u8; 16], UrlTokenError> {
245 if !is_hex(field, 16) {
246 return Err(GrantError::Hex.into());
247 }
248 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
256fn max_ttl_i64() -> i64 {
258 i64::try_from(MAX_TTL_MS).unwrap_or(i64::MAX)
259}
260
261pub(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
268pub(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
290pub(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}