use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use mkit_attest::grant::GrantError;
use mkit_attest::grant::text::{
check_lifetime, decimal_millis, decimal_u64, encode_millis, join_fields, split_fields,
};
use mkit_core::hash::{Hash, from_hex, hash, to_hex, to_hex_bytes};
use mkit_core::repo_identity::RepositoryIdentity;
use mkit_core::write_auth::{is_hex, validate_audience};
use super::{DOMAIN, MAX_PATH_BYTES, MAX_TTL_MS, UrlTokenError};
const STATEMENT_FIELDS: usize = 8;
pub(crate) const MAX_TOKEN_LEN: usize = 8192;
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum UrlTarget {
Object(Hash),
#[non_exhaustive]
Path {
reference: String,
path: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid URL token target")]
pub struct TargetError;
impl UrlTarget {
pub fn path(
reference: impl Into<String>,
path: impl Into<String>,
) -> Result<Self, TargetError> {
let (reference, path) = (reference.into(), path.into());
if !crate::refs::validate_ref_name(&reference) || !valid_path(&path) {
return Err(TargetError);
}
Ok(Self::Path { reference, path })
}
#[must_use]
pub fn field(&self) -> String {
match self {
Self::Object(id) => format!("object:{}", to_hex(id)),
Self::Path { reference, path } => {
format!(
"path:{reference}:{}",
URL_SAFE_NO_PAD.encode(path.as_bytes())
)
}
}
}
pub fn parse_field(field: &str) -> Result<Self, TargetError> {
if let Some(id) = field.strip_prefix("object:") {
if !is_hex(id, 32) {
return Err(TargetError);
}
return from_hex(id).map(Self::Object).map_err(|_| TargetError);
}
let rest = field.strip_prefix("path:").ok_or(TargetError)?;
let (reference, encoded) = rest.split_once(':').ok_or(TargetError)?;
let bytes = URL_SAFE_NO_PAD.decode(encoded).map_err(|_| TargetError)?;
let path = String::from_utf8(bytes).map_err(|_| TargetError)?;
Self::path(reference, path)
}
}
fn valid_path(path: &str) -> bool {
path.len() <= MAX_PATH_BYTES
&& !path.chars().any(char::is_control)
&& (path.is_empty()
|| path
.split('/')
.all(|entry| !entry.is_empty() && entry != "." && entry != ".."))
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct UrlTokenStatement {
audience: String,
repository: String,
target: UrlTarget,
epoch: u64,
issued_ms: i64,
expiry_ms: i64,
key_id: [u8; 16],
}
impl UrlTokenStatement {
pub(crate) fn new(
audience: impl Into<String>,
repository: impl Into<String>,
target: UrlTarget,
epoch: u64,
issued_ms: i64,
expiry_ms: i64,
key_id: [u8; 16],
) -> Self {
Self {
audience: audience.into(),
repository: repository.into(),
target,
epoch,
issued_ms,
expiry_ms,
key_id,
}
}
#[must_use]
pub fn audience(&self) -> &str {
&self.audience
}
#[must_use]
pub fn repository(&self) -> &str {
&self.repository
}
#[must_use]
pub fn target(&self) -> &UrlTarget {
&self.target
}
#[must_use]
pub fn epoch(&self) -> u64 {
self.epoch
}
#[must_use]
pub fn issued_ms(&self) -> i64 {
self.issued_ms
}
#[must_use]
pub fn expiry_ms(&self) -> i64 {
self.expiry_ms
}
#[must_use]
pub fn key_id(&self) -> [u8; 16] {
self.key_id
}
pub fn encode(&self) -> Result<Vec<u8>, UrlTokenError> {
validate_audience(&self.audience).map_err(|_| GrantError::Audience)?;
RepositoryIdentity::parse_bare_allowed(&self.repository)
.map_err(|_| GrantError::Repository)?;
check_lifetime(self.issued_ms, self.expiry_ms, max_ttl_i64())?;
join_fields(&[
DOMAIN,
&self.audience,
&self.repository,
&self.target.field(),
&self.epoch.to_string(),
&encode_millis(self.issued_ms)?,
&encode_millis(self.expiry_ms)?,
&to_hex_bytes(&self.key_id),
])
.map_err(Into::into)
}
pub fn parse(bytes: &[u8]) -> Result<Self, UrlTokenError> {
let f = split_fields(bytes, STATEMENT_FIELDS)?;
if f[0] != DOMAIN {
return Err(GrantError::Domain.into());
}
validate_audience(f[1]).map_err(|_| GrantError::Audience)?;
RepositoryIdentity::parse_bare_allowed(f[2]).map_err(|_| GrantError::Repository)?;
let target = UrlTarget::parse_field(f[3]).map_err(|_| UrlTokenError::Target)?;
let epoch = decimal_u64(f[4])?;
let issued_ms = decimal_millis(f[5])?;
let expiry_ms = decimal_millis(f[6])?;
check_lifetime(issued_ms, expiry_ms, max_ttl_i64())?;
let key_id = key_id_hex(f[7])?;
Ok(Self {
audience: f[1].to_owned(),
repository: f[2].to_owned(),
target,
epoch,
issued_ms,
expiry_ms,
key_id,
})
}
}
fn key_id_hex(field: &str) -> Result<[u8; 16], UrlTokenError> {
if !is_hex(field, 16) {
return Err(GrantError::Hex.into());
}
let mut id = [0; 16];
for (i, byte) in id.iter_mut().enumerate() {
*byte = u8::from_str_radix(&field[2 * i..2 * i + 2], 16).map_err(|_| GrantError::Hex)?;
}
Ok(id)
}
fn max_ttl_i64() -> i64 {
i64::try_from(MAX_TTL_MS).unwrap_or(i64::MAX)
}
pub(crate) fn key_id(public: &[u8; 32]) -> [u8; 16] {
let mut id = [0; 16];
id.copy_from_slice(&hash(public)[..16]);
id
}
pub(crate) fn decode_token(token: &str) -> Result<(Vec<u8>, [u8; 64]), UrlTokenError> {
if token.len() > MAX_TOKEN_LEN {
return Err(UrlTokenError::Length);
}
let (statement, signature) = token.split_once('.').ok_or(UrlTokenError::Format)?;
if statement.is_empty() || signature.is_empty() || signature.contains('.') {
return Err(UrlTokenError::Format);
}
let statement = URL_SAFE_NO_PAD
.decode(statement)
.map_err(|_| UrlTokenError::Encoding)?;
let signature: [u8; 64] = URL_SAFE_NO_PAD
.decode(signature)
.map_err(|_| UrlTokenError::Encoding)?
.try_into()
.map_err(|_| UrlTokenError::SignatureLength)?;
Ok((statement, signature))
}
pub(crate) fn encode_token(statement: &[u8], signature: &[u8; 64]) -> String {
format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(statement),
URL_SAFE_NO_PAD.encode(signature)
)
}