use crate::internal_alloc::Vec;
use noxtls_core::{Error, Result};
use noxtls_crypto::{aes_gcm_decrypt, aes_gcm_encrypt, sha256, AesCipher};
#[cfg(all(feature = "std", unix))]
use std::io::Write;
#[cfg(all(feature = "std", unix))]
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
#[cfg(feature = "std")]
use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
const TICKET_STORE_MAGIC: [u8; 4] = *b"NXTK";
const TICKET_STORE_VERSION: u8 = 2;
const TICKET_STORE_ENCRYPTED_MAGIC: [u8; 4] = *b"NXSE";
const TICKET_STORE_ENCRYPTED_VERSION: u8 = 1;
const TICKET_STORE_ENCRYPTED_NONCE_LEN: usize = 12;
const TICKET_STORE_ENCRYPTED_TAG_LEN: usize = 16;
const TICKET_STORE_MAX_DECODED_TICKETS: usize = 16_384;
const TICKET_STORE_MAX_DECODED_BYTES: usize = 8 * 1024 * 1024;
const TICKET_STORE_MAX_IDENTITY_LEN: usize = 4_096;
const TICKET_STORE_MAX_NONCE_LEN: usize = 4_096;
#[cfg(feature = "std")]
static TICKET_STORE_NONCE_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ResumptionTicket {
pub identity: Vec<u8>,
pub ticket_nonce: Vec<u8>,
pub obfuscated_ticket_age: u32,
pub age_add: u32,
pub issued_at_ms: u64,
pub lifetime_ms: u64,
pub max_early_data_size: u32,
pub consumed: bool,
}
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum TicketUsagePolicy {
Reusable,
SingleUse,
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct TicketStore {
tickets: Vec<ResumptionTicket>,
max_entries: usize,
}
impl TicketStore {
#[must_use]
pub fn new() -> Self {
Self::with_max_entries(256)
}
#[must_use]
pub fn with_max_entries(max_entries: usize) -> Self {
Self {
tickets: Vec::new(),
max_entries: max_entries.max(1),
}
}
pub fn insert(&mut self, ticket: ResumptionTicket) {
self.tickets.push(ticket);
if self.tickets.len() > self.max_entries {
let overflow = self.tickets.len().saturating_sub(self.max_entries);
self.tickets.drain(0..overflow);
}
}
#[must_use]
pub fn tickets(&self) -> &[ResumptionTicket] {
&self.tickets
}
pub(crate) fn tickets_mut(&mut self) -> &mut [ResumptionTicket] {
&mut self.tickets
}
#[must_use]
pub fn len(&self) -> usize {
self.tickets.len()
}
#[must_use]
pub fn max_entries(&self) -> usize {
self.max_entries
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.tickets.is_empty()
}
pub fn remove_consumed(&mut self) -> usize {
let original_len = self.tickets.len();
self.tickets.retain(|ticket| !ticket.consumed);
original_len.saturating_sub(self.tickets.len())
}
pub fn remove_expired(&mut self, current_time_ms: u64) -> usize {
let original_len = self.tickets.len();
self.tickets.retain(|ticket| {
current_time_ms.saturating_sub(ticket.issued_at_ms) <= ticket.lifetime_ms
});
original_len.saturating_sub(self.tickets.len())
}
pub fn invalidate_identity(&mut self, identity: &[u8]) -> usize {
let original_len = self.tickets.len();
self.tickets
.retain(|ticket| ticket.identity.as_slice() != identity);
original_len.saturating_sub(self.tickets.len())
}
pub fn rotate(&mut self, current_time_ms: u64) -> usize {
self.remove_consumed()
.saturating_add(self.remove_expired(current_time_ms))
}
pub fn to_bytes(&self) -> Result<Vec<u8>> {
let max_entries_u32 = u32::try_from(self.max_entries)
.map_err(|_| Error::InvalidLength("ticket store max_entries exceeds u32 range"))?;
let ticket_count_u32 = u32::try_from(self.tickets.len())
.map_err(|_| Error::InvalidLength("ticket store ticket count exceeds u32 range"))?;
let mut out = Vec::new();
out.extend_from_slice(&TICKET_STORE_MAGIC);
out.push(TICKET_STORE_VERSION);
out.extend_from_slice(&max_entries_u32.to_be_bytes());
out.extend_from_slice(&ticket_count_u32.to_be_bytes());
for ticket in &self.tickets {
let identity_len = u16::try_from(ticket.identity.len())
.map_err(|_| Error::InvalidLength("ticket identity exceeds u16 length"))?;
let nonce_len = u16::try_from(ticket.ticket_nonce.len())
.map_err(|_| Error::InvalidLength("ticket nonce exceeds u16 length"))?;
out.extend_from_slice(&identity_len.to_be_bytes());
out.extend_from_slice(&ticket.identity);
out.extend_from_slice(&nonce_len.to_be_bytes());
out.extend_from_slice(&ticket.ticket_nonce);
out.extend_from_slice(&ticket.obfuscated_ticket_age.to_be_bytes());
out.extend_from_slice(&ticket.age_add.to_be_bytes());
out.extend_from_slice(&ticket.issued_at_ms.to_be_bytes());
out.extend_from_slice(&ticket.lifetime_ms.to_be_bytes());
out.extend_from_slice(&ticket.max_early_data_size.to_be_bytes());
out.push(u8::from(ticket.consumed));
}
Ok(out)
}
pub fn from_bytes(input: &[u8]) -> Result<Self> {
if input.len() < 13 {
return Err(Error::ParseFailure("ticket store encoding too short"));
}
if input.len() > TICKET_STORE_MAX_DECODED_BYTES {
return Err(Error::InvalidLength(
"ticket store payload exceeds decoded byte budget",
));
}
if input[0..4] != TICKET_STORE_MAGIC {
return Err(Error::ParseFailure("invalid ticket store magic"));
}
let version = input[4];
if version != 1 && version != TICKET_STORE_VERSION {
return Err(Error::ParseFailure("unsupported ticket store version"));
}
let mut cursor = &input[5..];
let max_entries = usize::try_from(read_u32(&mut cursor)?)
.map_err(|_| Error::InvalidLength("ticket store max_entries is out of range"))?;
let ticket_count = usize::try_from(read_u32(&mut cursor)?)
.map_err(|_| Error::InvalidLength("ticket store ticket_count is out of range"))?;
if ticket_count > TICKET_STORE_MAX_DECODED_TICKETS {
return Err(Error::InvalidLength(
"ticket store ticket_count exceeds decode safety limit",
));
}
if max_entries > TICKET_STORE_MAX_DECODED_TICKETS {
return Err(Error::InvalidLength(
"ticket store max_entries exceeds decode safety limit",
));
}
let mut store = Self::with_max_entries(max_entries);
for _ in 0..ticket_count {
let identity_len = usize::from(read_u16(&mut cursor)?);
if identity_len > TICKET_STORE_MAX_IDENTITY_LEN {
return Err(Error::InvalidLength(
"ticket identity exceeds decode safety limit",
));
}
let identity = read_bytes(&mut cursor, identity_len)?.to_vec();
let nonce_len = usize::from(read_u16(&mut cursor)?);
if nonce_len > TICKET_STORE_MAX_NONCE_LEN {
return Err(Error::InvalidLength(
"ticket nonce exceeds decode safety limit",
));
}
let ticket_nonce = read_bytes(&mut cursor, nonce_len)?.to_vec();
let obfuscated_ticket_age = read_u32(&mut cursor)?;
let age_add = read_u32(&mut cursor)?;
let issued_at_ms = read_u64(&mut cursor)?;
let lifetime_ms = read_u64(&mut cursor)?;
let max_early_data_size = if version >= 2 {
read_u32(&mut cursor)?
} else {
0
};
let consumed_byte = read_u8(&mut cursor)?;
let consumed = match consumed_byte {
0 => false,
1 => true,
_ => return Err(Error::ParseFailure("invalid consumed flag in ticket store")),
};
store.insert(ResumptionTicket {
identity,
ticket_nonce,
obfuscated_ticket_age,
age_add,
issued_at_ms,
lifetime_ms,
max_early_data_size,
consumed,
});
}
if !cursor.is_empty() {
return Err(Error::ParseFailure("ticket store contains trailing bytes"));
}
Ok(store)
}
pub fn to_encrypted_bytes(&self, encryption_key: &[u8], nonce: &[u8; 12]) -> Result<Vec<u8>> {
let plaintext = self.to_bytes()?;
let key = derive_ticket_store_aead_key(encryption_key)?;
let cipher = AesCipher::new(&key)?;
let aad = ticket_store_encryption_aad(*nonce, plaintext.len())?;
let (ciphertext, tag) = aes_gcm_encrypt(&cipher, nonce, &aad, &plaintext)?;
let ciphertext_len = u32::try_from(ciphertext.len()).map_err(|_| {
Error::InvalidLength("ticket store ciphertext length exceeds u32 range")
})?;
let mut out = Vec::with_capacity(
4 + 1
+ TICKET_STORE_ENCRYPTED_NONCE_LEN
+ 4
+ ciphertext.len()
+ TICKET_STORE_ENCRYPTED_TAG_LEN,
);
out.extend_from_slice(&TICKET_STORE_ENCRYPTED_MAGIC);
out.push(TICKET_STORE_ENCRYPTED_VERSION);
out.extend_from_slice(nonce);
out.extend_from_slice(&ciphertext_len.to_be_bytes());
out.extend_from_slice(&ciphertext);
out.extend_from_slice(&tag);
Ok(out)
}
pub fn from_encrypted_bytes(input: &[u8], encryption_key: &[u8]) -> Result<Self> {
let min_len = 4 + 1 + TICKET_STORE_ENCRYPTED_NONCE_LEN + 4 + TICKET_STORE_ENCRYPTED_TAG_LEN;
if input.len() < min_len {
return Err(Error::ParseFailure(
"encrypted ticket store encoding too short",
));
}
if input[0..4] != TICKET_STORE_ENCRYPTED_MAGIC {
return Err(Error::ParseFailure("invalid encrypted ticket store magic"));
}
if input[4] != TICKET_STORE_ENCRYPTED_VERSION {
return Err(Error::ParseFailure(
"unsupported encrypted ticket store version",
));
}
let mut cursor = &input[5..];
let nonce_slice = read_bytes(&mut cursor, TICKET_STORE_ENCRYPTED_NONCE_LEN)?;
let nonce: [u8; 12] = nonce_slice
.try_into()
.map_err(|_| Error::ParseFailure("encrypted ticket store nonce length mismatch"))?;
let ciphertext_len = usize::try_from(read_u32(&mut cursor)?).map_err(|_| {
Error::InvalidLength("encrypted ticket store ciphertext length is out of range")
})?;
let ciphertext = read_bytes(&mut cursor, ciphertext_len)?;
let tag_slice = read_bytes(&mut cursor, TICKET_STORE_ENCRYPTED_TAG_LEN)?;
let tag: [u8; 16] = tag_slice
.try_into()
.map_err(|_| Error::ParseFailure("encrypted ticket store tag length mismatch"))?;
if !cursor.is_empty() {
return Err(Error::ParseFailure(
"encrypted ticket store contains trailing bytes",
));
}
let key = derive_ticket_store_aead_key(encryption_key)?;
let cipher = AesCipher::new(&key)?;
let aad = ticket_store_encryption_aad(nonce, ciphertext_len)?;
let plaintext = aes_gcm_decrypt(&cipher, &nonce, &aad, ciphertext, &tag)
.map_err(|_| Error::ParseFailure("ticket store at-rest authentication failed"))?;
Self::from_bytes(&plaintext)
}
#[cfg(feature = "std")]
pub fn save_to_file<P: AsRef<std::path::Path>>(
&self,
path: P,
encryption_key: &[u8],
) -> Result<()> {
let nonce = generate_ticket_store_persistence_nonce(path.as_ref());
let bytes = self.to_encrypted_bytes(encryption_key, &nonce)?;
#[cfg(unix)]
{
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(path.as_ref())
.map_err(|_| Error::ParseFailure("failed to open ticket store file"))?;
file.write_all(&bytes)
.map_err(|_| Error::ParseFailure("failed to write ticket store file"))?;
file.sync_all()
.map_err(|_| Error::ParseFailure("failed to sync ticket store file"))?;
}
#[cfg(not(unix))]
{
std::fs::write(path.as_ref(), bytes)
.map_err(|_| Error::ParseFailure("failed to write ticket store file"))?;
}
Ok(())
}
#[cfg(feature = "std")]
pub fn load_from_file<P: AsRef<std::path::Path>>(
path: P,
encryption_key: &[u8],
) -> Result<Self> {
#[cfg(unix)]
{
let metadata = std::fs::metadata(path.as_ref())
.map_err(|_| Error::ParseFailure("failed to stat ticket store file"))?;
let mode = metadata.mode() & 0o777;
if (mode & 0o077) != 0 {
return Err(Error::StateError(
"ticket store file permissions must not allow group/other access",
));
}
}
let bytes = std::fs::read(path)
.map_err(|_| Error::ParseFailure("failed to read ticket store file"))?;
Self::from_encrypted_bytes(&bytes, encryption_key)
}
}
impl Default for TicketStore {
fn default() -> Self {
Self::new()
}
}
pub(crate) fn ticket_age_matches_policy(
ticket: &ResumptionTicket,
offered_obfuscated_age: u32,
current_time_ms: u64,
max_skew_ms: u32,
) -> bool {
let ticket_age_ms = current_time_ms.saturating_sub(ticket.issued_at_ms);
if ticket_age_ms > ticket.lifetime_ms {
return false;
}
let ticket_age_u32 = ticket_age_ms.min(u64::from(u32::MAX)) as u32;
let expected_obfuscated_age = ticket.age_add.wrapping_add(ticket_age_u32);
wrapping_u32_distance(expected_obfuscated_age, offered_obfuscated_age) <= max_skew_ms
}
fn wrapping_u32_distance(left: u32, right: u32) -> u32 {
let lr = left.wrapping_sub(right);
let rl = right.wrapping_sub(left);
lr.min(rl)
}
fn read_u16(cursor: &mut &[u8]) -> Result<u16> {
if cursor.len() < 2 {
return Err(Error::ParseFailure("ticket store truncated u16"));
}
let value = u16::from_be_bytes([cursor[0], cursor[1]]);
*cursor = &cursor[2..];
Ok(value)
}
fn read_u32(cursor: &mut &[u8]) -> Result<u32> {
if cursor.len() < 4 {
return Err(Error::ParseFailure("ticket store truncated u32"));
}
let value = u32::from_be_bytes([cursor[0], cursor[1], cursor[2], cursor[3]]);
*cursor = &cursor[4..];
Ok(value)
}
fn read_u64(cursor: &mut &[u8]) -> Result<u64> {
if cursor.len() < 8 {
return Err(Error::ParseFailure("ticket store truncated u64"));
}
let value = u64::from_be_bytes([
cursor[0], cursor[1], cursor[2], cursor[3], cursor[4], cursor[5], cursor[6], cursor[7],
]);
*cursor = &cursor[8..];
Ok(value)
}
fn read_u8(cursor: &mut &[u8]) -> Result<u8> {
if cursor.is_empty() {
return Err(Error::ParseFailure("ticket store truncated u8"));
}
let value = cursor[0];
*cursor = &cursor[1..];
Ok(value)
}
fn read_bytes<'a>(cursor: &mut &'a [u8], len: usize) -> Result<&'a [u8]> {
if cursor.len() < len {
return Err(Error::ParseFailure("ticket store truncated bytes"));
}
let (head, tail) = cursor.split_at(len);
*cursor = tail;
Ok(head)
}
fn derive_ticket_store_aead_key(encryption_key: &[u8]) -> Result<[u8; 32]> {
if encryption_key.is_empty() {
return Err(Error::InvalidLength(
"ticket store encryption key must not be empty",
));
}
Ok(sha256(encryption_key))
}
fn ticket_store_encryption_aad(nonce: [u8; 12], ciphertext_len: usize) -> Result<Vec<u8>> {
let ciphertext_len_u32 = u32::try_from(ciphertext_len)
.map_err(|_| Error::InvalidLength("ticket store ciphertext length exceeds u32 range"))?;
let mut aad = Vec::with_capacity(4 + 1 + TICKET_STORE_ENCRYPTED_NONCE_LEN + 4);
aad.extend_from_slice(&TICKET_STORE_ENCRYPTED_MAGIC);
aad.push(TICKET_STORE_ENCRYPTED_VERSION);
aad.extend_from_slice(&nonce);
aad.extend_from_slice(&ciphertext_len_u32.to_be_bytes());
Ok(aad)
}
#[cfg(feature = "std")]
fn generate_ticket_store_persistence_nonce(path: &std::path::Path) -> [u8; 12] {
let counter = TICKET_STORE_NONCE_COUNTER.fetch_add(1, AtomicOrdering::Relaxed);
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0_u128, |d| d.as_nanos());
let mut material = Vec::new();
material.extend_from_slice(path.as_os_str().to_string_lossy().as_bytes());
material.extend_from_slice(&counter.to_be_bytes());
material.extend_from_slice(&nanos.to_be_bytes());
let digest = sha256(&material);
let mut nonce = [0_u8; 12];
nonce.copy_from_slice(&digest[..12]);
nonce
}