use std::collections::BTreeSet;
use std::fmt;
use mkit_core::hash::{Hash, from_hex};
use subtle::ConstantTimeEq;
use zeroize::Zeroizing;
use crate::error::{Code, ServerError};
pub const TICKET_TOKEN_CONTEXT: &str = "mkit-server ticket token v1";
pub const PART_RECEIPT_CONTEXT: &str = "mkit-server part receipt v1";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TicketClaims {
pub authority_generation: Option<u64>,
pub ticket_id: Hash,
pub audience: String,
pub repository: String,
pub signer: Hash,
pub pack_id: Hash,
pub bytes: u64,
pub part_size: u64,
pub expires_at_ms: u64,
pub upload_session: Vec<u8>,
}
impl TicketClaims {
pub fn check_principal(
&self,
audience: &str,
repository: &str,
signer: &Hash,
) -> Result<(), ServerError> {
if self.audience != audience || self.repository != repository || self.signer != *signer {
return Err(ServerError::new(
Code::PermissionDenied,
"upload ticket binding mismatch",
));
}
Ok(())
}
pub fn check_binding(
&self,
audience: &str,
repository: &str,
signer: &Hash,
pack_id: &Hash,
bytes: u64,
) -> Result<(), ServerError> {
self.check_principal(audience, repository, signer)?;
if self.pack_id != *pack_id || self.bytes != bytes {
return Err(ServerError::new(
Code::PermissionDenied,
"upload ticket binding mismatch",
));
}
Ok(())
}
}
#[derive(Clone)]
struct TicketKey {
id: String,
secret: Zeroizing<Hash>,
}
impl PartialEq for TicketKey {
fn eq(&self, other: &Self) -> bool {
self.id == other.id && bool::from(self.secret.ct_eq(&*other.secret))
}
}
impl Eq for TicketKey {}
impl TicketKey {
fn mac_key(&self) -> Zeroizing<Hash> {
Zeroizing::new(blake3::derive_key(TICKET_TOKEN_CONTEXT, &*self.secret))
}
fn receipt_mac_key(&self) -> Zeroizing<Hash> {
Zeroizing::new(blake3::derive_key(PART_RECEIPT_CONTEXT, &*self.secret))
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct TicketKeys {
keys: Vec<TicketKey>,
}
impl fmt::Debug for TicketKeys {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TicketKeys")
.field(
"key_ids",
&self.keys.iter().map(|key| &key.id).collect::<Vec<_>>(),
)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("invalid ticket keys: expected unique key ids and 64 hex digits per key")]
pub struct TicketKeyError;
impl TicketKeys {
pub(crate) fn listing_signing_key(&self) -> (&str, Zeroizing<Hash>) {
let key = &self.keys[0];
(
&key.id,
Zeroizing::new(blake3::derive_key(
"mkit ListRepos page token v1",
&*key.secret,
)),
)
}
pub(crate) fn listing_verification_key(&self, id: &[u8]) -> Option<Zeroizing<Hash>> {
self.keys
.iter()
.find(|key| key.id.as_bytes() == id)
.map(|key| {
Zeroizing::new(blake3::derive_key(
"mkit ListRepos page token v1",
&*key.secret,
))
})
}
pub(crate) fn receipt_signing_key(&self) -> (&str, Zeroizing<Hash>) {
let key = &self.keys[0];
(&key.id, key.receipt_mac_key())
}
pub(crate) fn receipt_verification_key(&self, id: &[u8]) -> Option<Zeroizing<Hash>> {
self.keys
.iter()
.find(|key| key.id.as_bytes() == id)
.map(TicketKey::receipt_mac_key)
}
pub fn new(keys: Vec<(String, Hash)>) -> Result<Self, TicketKeyError> {
let keys: Vec<_> = keys
.into_iter()
.map(|(id, secret)| TicketKey {
id,
secret: Zeroizing::new(secret),
})
.collect();
let mut ids = BTreeSet::new();
if keys.is_empty()
|| keys
.iter()
.any(|key| !valid_id(&key.id) || !ids.insert(&key.id))
{
return Err(TicketKeyError);
}
Ok(Self { keys })
}
pub fn parse(text: &str) -> Result<Self, TicketKeyError> {
let mut keys = Vec::new();
let mut ids = BTreeSet::new();
for line in text.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut fields = line.split_whitespace();
let id = fields.next().ok_or(TicketKeyError)?;
let secret = fields.next().ok_or(TicketKeyError)?;
if fields.next().is_some() || !valid_id(id) || !ids.insert(id) {
return Err(TicketKeyError);
}
let secret = Zeroizing::new(from_hex(secret).map_err(|_| TicketKeyError)?);
keys.push(TicketKey {
id: id.into(),
secret,
});
}
if keys.is_empty() {
return Err(TicketKeyError);
}
Ok(Self { keys })
}
pub fn parse_secret(text: String) -> Result<Self, TicketKeyError> {
let text = Zeroizing::new(text);
Self::parse(&text)
}
#[must_use]
pub fn contains_secret(&self, secret: &[u8; 32]) -> bool {
self.keys
.iter()
.any(|key| bool::from(key.secret.ct_eq(secret)))
}
#[must_use]
pub fn contains_ed25519_public(&self, public: &[u8; 32]) -> bool {
self.keys.iter().any(|key| {
bool::from(key.secret.ct_eq(public))
|| ed25519_dalek::SigningKey::from_bytes(&key.secret)
.verifying_key()
.as_bytes()
== public
})
}
#[must_use]
pub fn mint(&self, claims: &TicketClaims) -> Vec<u8> {
let key = &self.keys[0]; let id_len = u8::try_from(key.id.len()).expect("validated ticket key id");
let mut bytes = vec![
if claims.authority_generation.is_some() {
2
} else {
1
},
id_len,
];
bytes.extend_from_slice(key.id.as_bytes());
bytes.extend_from_slice(&claims.ticket_id);
append_field(&mut bytes, claims.audience.as_bytes());
append_field(&mut bytes, claims.repository.as_bytes());
bytes.extend_from_slice(&claims.signer);
bytes.extend_from_slice(&claims.pack_id);
for number in [claims.bytes, claims.part_size, claims.expires_at_ms] {
bytes.extend_from_slice(&number.to_be_bytes());
}
append_field(&mut bytes, &claims.upload_session);
if let Some(generation) = claims.authority_generation {
bytes.extend_from_slice(&generation.to_be_bytes());
}
bytes.extend_from_slice(blake3::keyed_hash(&key.mac_key(), &bytes).as_bytes());
bytes
}
pub fn verify(&self, token: &[u8], now_ms: u64) -> Result<TicketClaims, ServerError> {
let tag_at = token.len().checked_sub(32).ok_or_else(invalid_token)?;
let (message, tag) = token.split_at(tag_at);
let id_len = usize::from(*message.get(1).ok_or_else(invalid_token)?);
let id = message.get(2..2 + id_len).ok_or_else(invalid_token)?;
let key = self
.keys
.iter()
.find(|key| key.id.as_bytes() == id)
.ok_or_else(invalid_token)?;
let expected = blake3::keyed_hash(&key.mac_key(), message);
if !bool::from(expected.as_bytes().as_slice().ct_eq(tag)) {
return Err(invalid_token());
}
let mut reader = Reader(message);
let version = reader.take(1)?[0];
if version != 1 && version != 2 {
return Err(invalid_token());
}
reader.take(1 + id_len)?;
let claims = TicketClaims {
ticket_id: reader.array()?,
audience: reader.text()?,
repository: reader.text()?,
signer: reader.array()?,
pack_id: reader.array()?,
bytes: u64::from_be_bytes(reader.array()?),
part_size: u64::from_be_bytes(reader.array()?),
expires_at_ms: u64::from_be_bytes(reader.array()?),
upload_session: reader.field()?.to_vec(),
authority_generation: if version == 2 {
Some(u64::from_be_bytes(reader.array()?))
} else {
None
},
};
if !reader.0.is_empty() || claims.expires_at_ms <= now_ms {
return Err(invalid_token());
}
Ok(claims)
}
}
impl std::str::FromStr for TicketKeys {
type Err = TicketKeyError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
Self::parse(text)
}
}
fn valid_id(id: &str) -> bool {
(1..=32).contains(&id.len())
&& id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
fn append_field(bytes: &mut Vec<u8>, field: &[u8]) {
let length = u16::try_from(field.len()).expect("validated ticket claim length");
bytes.extend_from_slice(&length.to_be_bytes());
bytes.extend_from_slice(field);
}
fn invalid_token() -> ServerError {
ServerError::new(Code::FailedPrecondition, "invalid or expired upload ticket")
}
struct Reader<'a>(&'a [u8]);
impl<'a> Reader<'a> {
fn take(&mut self, length: usize) -> Result<&'a [u8], ServerError> {
let bytes = self.0.get(..length).ok_or_else(invalid_token)?;
self.0 = &self.0[length..];
Ok(bytes)
}
fn array<const N: usize>(&mut self) -> Result<[u8; N], ServerError> {
self.take(N)?.try_into().map_err(|_| invalid_token())
}
fn field(&mut self) -> Result<&'a [u8], ServerError> {
let length = u16::from_be_bytes(self.array()?);
self.take(usize::from(length))
}
fn text(&mut self) -> Result<String, ServerError> {
std::str::from_utf8(self.field()?)
.map(str::to_owned)
.map_err(|_| invalid_token())
}
}
#[cfg(test)]
#[path = "token_tests.rs"]
mod tests;