#[cfg(test)]
mod golden;
mod statement;
#[cfg(test)]
mod tests;
use std::collections::BTreeSet;
use std::fmt::{self, Write as _};
use std::future::Future;
use std::sync::Arc;
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use mkit_attest::grant::GrantError;
use mkit_core::hash::{hash, to_hex_bytes};
use zeroize::Zeroizing;
use crate::error::{Redacted, ServerError};
pub(crate) use statement::key_id;
pub use statement::{TargetError, UrlTarget, UrlTokenStatement};
pub const DOMAIN: &str = "mkit-url-token:v1";
pub const MAX_PATH_BYTES: usize = 1024;
pub const DEFAULT_TTL_MS: u64 = 15 * 60 * 1000;
pub const MAX_TTL_MS: u64 = 24 * 60 * 60 * 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum UrlTokenError {
#[error("token too long")]
Length,
#[error("token format")]
Format,
#[error("token encoding")]
Encoding,
#[error("signature length")]
SignatureLength,
#[error("statement: {0}")]
Statement(#[from] GrantError),
#[error("invalid target")]
Target,
#[error("unknown or retired key id")]
KeyId,
#[error("bad signature")]
Signature,
#[error("binding mismatch")]
Binding,
#[error("token expired")]
Expired,
#[error("lifetime too long")]
Lifetime,
#[error("epoch mismatch")]
Epoch,
}
impl UrlTokenError {
#[must_use]
pub fn reason(&self) -> &'static str {
match self {
Self::Length => "token too long",
Self::Format => "token format",
Self::Encoding => "token encoding",
Self::SignatureLength => "signature length",
Self::Statement(e) => e.reason(),
Self::Target => "invalid target",
Self::KeyId => "unknown or retired key id",
Self::Signature => "bad signature",
Self::Binding => "binding mismatch",
Self::Expired => "token expired",
Self::Lifetime => "lifetime too long",
Self::Epoch => "epoch mismatch",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RetiredKey {
pub public: [u8; 32],
pub retired_at_ms: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum UrlTokenConfigError {
#[error("invalid URL token key configuration")]
Keys,
#[error("invalid URL token lifetime")]
Ttl,
}
pub struct UrlTokenKeys {
active: SigningKey,
retired: Vec<RetiredKey>,
}
impl UrlTokenKeys {
#[must_use]
pub fn contains_secret(&self, material: &[u8; 32]) -> bool {
use subtle::ConstantTimeEq as _;
let seed = Zeroizing::new(self.active.to_bytes());
bool::from(seed.ct_eq(material))
}
#[allow(clippy::needless_pass_by_value)] pub fn new(
active_seed: Zeroizing<[u8; 32]>,
retired: Vec<RetiredKey>,
) -> Result<Self, UrlTokenConfigError> {
let active = SigningKey::from_bytes(&active_seed);
let active_public = active.verifying_key().to_bytes();
let mut ids = BTreeSet::from([key_id(&active_public)]);
for key in &retired {
let verifying =
VerifyingKey::from_bytes(&key.public).map_err(|_| UrlTokenConfigError::Keys)?;
if verifying.is_weak()
|| key.public == active_public
|| !ids.insert(key_id(&key.public))
{
return Err(UrlTokenConfigError::Keys);
}
}
Ok(Self { active, retired })
}
pub fn parse_key_file(text: &str) -> Result<Self, UrlTokenConfigError> {
let invalid = || UrlTokenConfigError::Keys;
let mut seed = None;
let mut retired = Vec::new();
for line in text.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut fields = line.split_whitespace();
match fields.next() {
Some("active") => {
let hex = fields.next().ok_or_else(invalid)?;
if seed.is_some() || fields.next().is_some() {
return Err(invalid());
}
seed = Some(Zeroizing::new(
mkit_attest::grant::text::hex32(hex).map_err(|_| invalid())?,
));
}
Some("retired") => {
let public = fields.next().ok_or_else(invalid)?;
let at = fields.next().ok_or_else(invalid)?;
let retired_at_ms = at
.parse::<u64>()
.ok()
.filter(|n| n.to_string() == at)
.ok_or_else(invalid)?;
if fields.next().is_some() {
return Err(invalid());
}
retired.push(RetiredKey {
public: mkit_attest::grant::text::hex32(public).map_err(|_| invalid())?,
retired_at_ms,
});
}
_ => return Err(invalid()),
}
}
Self::new(seed.ok_or_else(invalid)?, retired)
}
pub fn parse_key_file_secret(text: String) -> Result<Self, UrlTokenConfigError> {
let text = Zeroizing::new(text);
Self::parse_key_file(&text)
}
#[must_use]
pub fn active_key_id(&self) -> String {
to_hex_bytes(&self.active_id())
}
fn active_id(&self) -> [u8; 16] {
key_id(&self.active.verifying_key().to_bytes())
}
pub fn public_keys(&self) -> impl Iterator<Item = [u8; 32]> + '_ {
core::iter::once(self.active.verifying_key().to_bytes())
.chain(self.retired.iter().map(|key| key.public))
}
pub(crate) fn verifying_key(
&self,
id: &[u8; 16],
now_ms: i64,
ttl_ms: u64,
) -> Option<VerifyingKey> {
let active = self.active.verifying_key();
if key_id(active.as_bytes()) == *id {
return Some(active);
}
let key = self.retired.iter().find(|key| key_id(&key.public) == *id)?;
let after = key.retired_at_ms.saturating_add(ttl_ms);
if now_ms >= 0 && u64::try_from(now_ms).ok() < Some(after) {
VerifyingKey::from_bytes(&key.public).ok()
} else {
None
}
}
#[must_use]
pub fn key_set_json(&self, ttl_ms: u64) -> String {
let entry = |id: [u8; 16], public: &[u8; 32]| {
format!(
"\"keyId\":\"{}\",\"alg\":\"ed25519\",\"publicKey\":\"{}\"",
to_hex_bytes(&id),
to_hex_bytes(public)
)
};
let active = self.active.verifying_key().to_bytes();
let mut json = format!(
"{{\"version\":1,\"keys\":[{{{}",
entry(self.active_id(), &active)
);
for key in &self.retired {
let not_after = key.retired_at_ms.saturating_add(ttl_ms);
let _ = write!(
json,
"}},{{{},\"notAfterMs\":\"{not_after}\"",
entry(key_id(&key.public), &key.public)
);
}
json.push_str("}]}");
json
}
}
impl fmt::Debug for UrlTokenKeys {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("UrlTokenKeys")
.field(
"active_key_id",
&to_hex_bytes(&key_id(&self.active.verifying_key().to_bytes())),
)
.field(
"retired_key_ids",
&self
.retired
.iter()
.map(|key| to_hex_bytes(&key_id(&key.public)))
.collect::<Vec<_>>(),
)
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct UrlTokenConfig {
keys: Arc<UrlTokenKeys>,
ttl_ms: u64,
}
impl UrlTokenConfig {
#[must_use]
pub fn new(keys: UrlTokenKeys) -> Self {
Self {
keys: Arc::new(keys),
ttl_ms: DEFAULT_TTL_MS,
}
}
pub fn with_ttl_ms(keys: UrlTokenKeys, ttl_ms: u64) -> Result<Self, UrlTokenConfigError> {
if ttl_ms == 0 || ttl_ms > MAX_TTL_MS {
return Err(UrlTokenConfigError::Ttl);
}
Ok(Self {
keys: Arc::new(keys),
ttl_ms,
})
}
#[must_use]
pub fn ttl_ms(&self) -> u64 {
self.ttl_ms
}
#[must_use]
pub fn keys(&self) -> &UrlTokenKeys {
&self.keys
}
pub fn mint(
&self,
audience: &str,
repository: &str,
target: &UrlTarget,
epoch: u64,
now_ms: i64,
requested_ttl_s: u32,
) -> Result<MintedToken, ServerError> {
let ttl_ms = if requested_ttl_s == 0 {
self.ttl_ms
} else {
u64::from(requested_ttl_s)
.saturating_mul(1000)
.min(self.ttl_ms)
};
let expires_at_ms = now_ms.saturating_add(i64::try_from(ttl_ms).unwrap_or(i64::MAX));
let statement = UrlTokenStatement::new(
audience,
repository,
target.clone(),
epoch,
now_ms,
expires_at_ms,
self.keys.active_id(),
);
let bytes = statement.encode().map_err(|e| {
ServerError::internal(
"request failed",
format_args!("url token statement did not encode: {}", e.reason()),
)
})?;
let signature = self.keys.active.sign(&hash(&bytes));
Ok(MintedToken {
token: Redacted::new(statement::encode_token(&bytes, &signature.to_bytes())),
expires_at_ms,
})
}
pub fn precheck(&self, token: &str, now_ms: i64) -> Result<Prechecked, TokenRejected> {
let (bytes, signature) = statement::decode_token(token).map_err(|_| TokenRejected)?;
let statement = UrlTokenStatement::parse(&bytes).map_err(|_| TokenRejected)?;
let key = self
.keys
.verifying_key(&statement.key_id(), now_ms, self.ttl_ms)
.ok_or(TokenRejected)?;
key.verify_strict(&hash(&bytes), &Signature::from_bytes(&signature))
.map_err(|_| TokenRejected)?;
Ok(Prechecked { statement })
}
}
impl fmt::Debug for UrlTokenConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("UrlTokenConfig")
.field("keys", &self.keys)
.field("ttl_ms", &self.ttl_ms)
.finish()
}
}
#[derive(Clone)]
pub struct MintedToken {
token: Redacted,
pub expires_at_ms: i64,
}
impl MintedToken {
#[must_use]
pub fn expose(&self) -> &str {
self.token.expose()
}
}
impl fmt::Debug for MintedToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("MintedToken")
.field("expires_at_ms", &self.expires_at_ms)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid URL token")]
pub struct TokenRejected;
pub struct Prechecked {
statement: UrlTokenStatement,
}
impl fmt::Debug for Prechecked {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Prechecked").finish_non_exhaustive()
}
}
#[derive(Debug)]
pub struct Binding<'a> {
pub audience: &'a str,
pub repository: &'a str,
pub target: &'a UrlTarget,
}
#[derive(Clone, Copy)]
pub struct BoundToken {
epoch: u64,
issued_ms: i64,
expiry_ms: i64,
}
impl fmt::Debug for BoundToken {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BoundToken").finish_non_exhaustive()
}
}
impl BoundToken {
#[must_use]
pub fn expiry_ms(&self) -> i64 {
self.expiry_ms
}
#[must_use]
pub fn epoch(&self) -> u64 {
self.epoch
}
pub fn check_visibility_change(&self, changed_ms: u64) -> Result<(), TokenRejected> {
if u64::try_from(self.issued_ms).is_ok_and(|issued| issued > changed_ms) {
Ok(())
} else {
Err(TokenRejected)
}
}
pub fn check_epoch(&self, stored: u64) -> Result<(), TokenRejected> {
if self.epoch == stored {
Ok(())
} else {
Err(TokenRejected)
}
}
}
impl Prechecked {
pub fn check_binding(
self,
binding: &Binding<'_>,
now_ms: i64,
ttl_ms: u64,
) -> Result<BoundToken, TokenRejected> {
let statement = &self.statement;
if statement.audience() != binding.audience
|| statement.repository() != binding.repository
|| statement.target() != binding.target
{
return Err(TokenRejected);
}
if now_ms >= statement.expiry_ms() {
return Err(TokenRejected);
}
let lifetime = statement.expiry_ms().saturating_sub(statement.issued_ms());
if lifetime > i64::try_from(ttl_ms).unwrap_or(i64::MAX) {
return Err(TokenRejected);
}
Ok(BoundToken {
epoch: statement.epoch(),
issued_ms: statement.issued_ms(),
expiry_ms: statement.expiry_ms(),
})
}
}
pub async fn verify<F, Fut, E>(
cfg: &UrlTokenConfig,
token: &str,
binding: &Binding<'_>,
now_ms: i64,
read_epoch: F,
) -> Result<Result<(), TokenRejected>, E>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<u64, E>>,
{
let bound = match cfg
.precheck(token, now_ms)
.and_then(|p| p.check_binding(binding, now_ms, cfg.ttl_ms()))
{
Ok(bound) => bound,
Err(rejected) => return Ok(Err(rejected)),
};
Ok(bound.check_epoch(read_epoch().await?))
}