From 3c7e8b9690f8d9873a431dacc05299ef8f4e1ffb Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Mon, 4 May 2026 16:43:08 -0400 Subject: [PATCH 1/2] Add tlog_mirror crate with wire format and default ticket authenticator Implements the wire-format pieces of c2sp.org/tlog-mirror: - `AddEntriesRequestHeader`: read/write framing for the add-entries request body header (u16 log_origin, u64 upload_start/end, u16 ticket). - `EntryPackage`: read/write framing for a single entry package (u16-prefixed entries + u8 num_hashes <= 63 + 32-byte hashes). - `package_ranges`: deterministic iterator over the spec's 256-aligned `[start, end)` intervals derived from upload_start and upload_end. Callers iterate this rather than recomputing the rounding math. - `MirrorInfo`: read/write framing for the `text/x.tlog.mirror-info` 409 Conflict response body (3 newline-terminated lines: tree size, next entry, base64-encoded ticket). - `TicketSealer`: default opaque-ticket authenticator using deterministic AES-256-GCM-SIV over the payload, with the log origin as associated data. Encryption enforces the spec's ticket opacity so clients cannot depend on the internal format; confidentiality is not required because pending checkpoints are public. The mirror's `add-checkpoint` endpoint reuses `tlog_witness::add_checkpoint` per spec, so this crate does not duplicate it. --- Cargo.lock | 96 +++++ Cargo.toml | 8 + crates/tlog_mirror/Cargo.toml | 28 ++ crates/tlog_mirror/LICENSE | 1 + crates/tlog_mirror/src/error.rs | 75 ++++ crates/tlog_mirror/src/lib.rs | 29 ++ crates/tlog_mirror/src/ticket.rs | 181 +++++++++ crates/tlog_mirror/src/wire.rs | 651 +++++++++++++++++++++++++++++++ 8 files changed, 1069 insertions(+) create mode 100644 crates/tlog_mirror/Cargo.toml create mode 120000 crates/tlog_mirror/LICENSE create mode 100644 crates/tlog_mirror/src/error.rs create mode 100644 crates/tlog_mirror/src/lib.rs create mode 100644 crates/tlog_mirror/src/ticket.rs create mode 100644 crates/tlog_mirror/src/wire.rs diff --git a/Cargo.lock b/Cargo.lock index 1eaace9..4a52502 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.12.0-rc.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d2f4cb729b8b58144551d208643a39915a039ca78659e7cec9c80789d1695b5" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", +] + [[package]] name = "ahash" version = "0.8.12" @@ -388,6 +423,17 @@ dependencies = [ "half", ] +[[package]] +name = "cipher" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea" +dependencies = [ + "block-buffer", + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.50" @@ -646,6 +692,15 @@ dependencies = [ "serde", ] +[[package]] +name = "ctr" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17469f8eb9bdbfad10f71f4cfddfd38b01143520c0e717d8796ccb4d44d44e42" +dependencies = [ + "cipher", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1526,6 +1581,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "integration_tests" version = "0.2.0" @@ -2131,6 +2195,17 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "polyval" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +dependencies = [ + "cpubits", + "cpufeatures", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -3129,6 +3204,17 @@ dependencies = [ "tlog_tiles", ] +[[package]] +name = "tlog_mirror" +version = "0.2.0" +dependencies = [ + "aes-gcm-siv", + "base64", + "byteorder", + "thiserror 2.0.17", + "tlog_core", +] + [[package]] name = "tlog_tiles" version = "0.2.0" @@ -3331,6 +3417,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 7a58883..3b87c9f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/tlog_core", "crates/tlog_cosignature", "crates/tlog_entry", + "crates/tlog_mirror", "crates/tlog_tiles", "crates/tlog_tiles_wasm", "crates/tlog_witness", @@ -49,6 +50,7 @@ default-members = [ "crates/tlog_core", "crates/tlog_cosignature", "crates/tlog_entry", + "crates/tlog_mirror", "crates/tlog_tiles", "crates/tlog_tiles_wasm", "crates/tlog_witness", @@ -80,6 +82,10 @@ opt-level = 3 debug = 1 [workspace.dependencies] +# aes-gcm-siv has no stable release on the current RustCrypto trait +# batch (aead 0.6), so it stays on a pre-release (as does rsa, below); +# the rc still resolves aead/cipher up to their stable releases. +aes-gcm-siv = { version = "0.12.0-rc.3", default-features = false, features = ["aes", "alloc"] } anyhow = "1.0" axum = { version = "0.8", default-features = false, features = ["json", "matched-path"] } base64 = "0.22" @@ -103,6 +109,7 @@ futures-executor = "0.3.31" futures-util = "0.3.31" getrandom = { version = "0.4", features = ["wasm_js"] } hex = "0.4" +hmac = "0.13" itertools = "0.14.0" jsonschema = "0.30" length_prefixed = { path = "crates/length_prefixed" } @@ -132,6 +139,7 @@ tlog_checkpoint = { path = "crates/tlog_checkpoint", version = "0.2.0" } tlog_core = { path = "crates/tlog_core", version = "0.2.0" } tlog_cosignature = { path = "crates/tlog_cosignature", version = "0.2.0" } tlog_entry = { path = "crates/tlog_entry", version = "0.2.0" } +tlog_mirror = { path = "crates/tlog_mirror", version = "0.2.0" } tlog_tiles = { path = "crates/tlog_tiles", version = "0.2.0" } tlog_witness = { path = "crates/tlog_witness", version = "0.2.0" } tokio = { version = "1", features = ["sync"] } diff --git a/crates/tlog_mirror/Cargo.toml b/crates/tlog_mirror/Cargo.toml new file mode 100644 index 0000000..1afb6da --- /dev/null +++ b/crates/tlog_mirror/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "tlog_mirror" +# Held back from crates.io while c2sp.org/tlog-mirror stabilizes. Lift +# the publish gate (and remove this comment) once the spec is final. +publish = false +version.workspace = true +authors.workspace = true +edition.workspace = true +license.workspace = true +readme.workspace = true +homepage.workspace = true +repository.workspace = true +description = "An implementation of c2sp.org/tlog-mirror" +categories = ["cryptography"] +keywords = ["transparency", "mirror", "checkpoint", "crypto", "pki"] + +[package.metadata.release] +release = false + +[lib] +crate-type = ["rlib"] + +[dependencies] +aes-gcm-siv.workspace = true +base64.workspace = true +byteorder.workspace = true +thiserror.workspace = true +tlog_core.workspace = true diff --git a/crates/tlog_mirror/LICENSE b/crates/tlog_mirror/LICENSE new file mode 120000 index 0000000..30cff74 --- /dev/null +++ b/crates/tlog_mirror/LICENSE @@ -0,0 +1 @@ +../../LICENSE \ No newline at end of file diff --git a/crates/tlog_mirror/src/error.rs b/crates/tlog_mirror/src/error.rs new file mode 100644 index 0000000..49f932e --- /dev/null +++ b/crates/tlog_mirror/src/error.rs @@ -0,0 +1,75 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! Error types for `tlog_mirror`. + +use thiserror::Error; + +/// Errors returned when parsing tlog-mirror wire-format messages. +#[derive(Debug, Error)] +pub enum ParseError { + /// An IO error occurred while reading from the input stream. + #[error("io: {0}")] + Io(#[from] std::io::Error), + + /// The `log_origin_size` u16 prefix advertised more bytes than were + /// available in the input. + #[error("log_origin truncated: advertised {advertised} bytes")] + LogOriginTruncated { + /// Size advertised by the wire `log_origin_size` u16. + advertised: u16, + }, + + /// The `log_origin` bytes were not valid UTF-8. + #[error("log_origin is not valid UTF-8")] + LogOriginNotUtf8, + + /// `upload_start` was greater than `upload_end`. + #[error("upload_start ({start}) > upload_end ({end})")] + UploadRangeInverted { + /// `upload_start` from the wire. + start: u64, + /// `upload_end` from the wire. + end: u64, + }, + + /// `num_hashes` for a subtree consistency proof exceeded the spec's + /// maximum of 63. + #[error("num_hashes {0} exceeds spec maximum of 63")] + TooManyHashes(u8), + + /// The `text/x.tlog.mirror-info` body did not have exactly three + /// newline-terminated lines. + #[error("mirror-info body is malformed: {0}")] + MalformedMirrorInfo(&'static str), + + /// A decimal field in the `text/x.tlog.mirror-info` body could not be + /// parsed as a `u64`. + #[error("mirror-info decimal field {field} could not be parsed: {value:?}")] + InvalidDecimal { + /// Field name (`tree_size` or `next_entry`). + field: &'static str, + /// Verbatim bytes from the wire. + value: String, + }, + + /// The base64-encoded ticket in a `text/x.tlog.mirror-info` body could + /// not be decoded. + #[error("mirror-info ticket is not valid base64")] + InvalidTicketBase64, +} + +/// Errors returned by [`TicketSealer`](crate::TicketSealer). +#[derive(Debug, Error)] +pub enum TicketError { + /// The sealed ticket was shorter than the AEAD tag length + /// ([`TAG_LEN`](crate::TAG_LEN), 16 bytes), so it cannot possibly be + /// a valid ticket. + #[error("sealed ticket too short: {0} bytes, need at least 16")] + TooShort(usize), + + /// AEAD decryption failed: the ticket, tag, key, or associated data + /// (`aad`) did not match. + #[error("ticket authentication failed")] + AuthenticationFailed, +} diff --git a/crates/tlog_mirror/src/lib.rs b/crates/tlog_mirror/src/lib.rs new file mode 100644 index 0000000..8b33e06 --- /dev/null +++ b/crates/tlog_mirror/src/lib.rs @@ -0,0 +1,29 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! An implementation of [c2sp.org/tlog-mirror](https://c2sp.org/tlog-mirror). +//! +//! Wire-format pieces for building a tlog mirror, scoped to spec-level +//! concerns: +//! +//! * [`wire::AddEntriesRequestHeader`] and [`wire::EntryPackage`] for the +//! `add-entries` request body. +//! * [`wire::MirrorInfo`] for the `text/x.tlog.mirror-info` 409 Conflict +//! response body. +//! * [`TicketSealer`] for the default opaque-ticket scheme. +//! +//! A mirror's `add-checkpoint` endpoint is expected to reuse the witness +//! wire format via the `tlog_witness` crate. Storage, retention, and +//! pruning policy are out of scope. + +pub mod ticket; +pub mod wire; + +mod error; + +pub use error::{ParseError, TicketError}; +pub use ticket::{TAG_LEN, TicketSealer}; +pub use wire::{ + AddEntriesRequestHeader, EntryPackage, MAX_HASHES_PER_PROOF, MIRROR_INFO_CONTENT_TYPE, + MirrorInfo, PACKAGE_ALIGNMENT, PackageRanges, package_ranges, +}; diff --git a/crates/tlog_mirror/src/ticket.rs b/crates/tlog_mirror/src/ticket.rs new file mode 100644 index 0000000..e0fad2d --- /dev/null +++ b/crates/tlog_mirror/src/ticket.rs @@ -0,0 +1,181 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! Default ticket authentication for tlog-mirror operators. +//! +//! The spec treats the ticket as opaque and only requires the mirror to +//! authenticate data derived from it; pending checkpoints are public, so +//! confidentiality is not needed. [`TicketSealer`] still encrypts with +//! AES-256-GCM-SIV to enforce that opacity, so clients cannot depend on +//! the ticket's internal format. +//! +//! Sealing is deterministic (fixed nonce): identical `(payload, aad)` +//! inputs yield identical tickets, which is safe because AES-GCM-SIV is +//! nonce-misuse resistant. `aad` is authenticated but not encrypted; +//! callers pass the log origin to bind a ticket to its log. + +use aes_gcm_siv::{ + Aes256GcmSiv, Nonce, + aead::{Aead, KeyInit, Payload}, +}; + +use crate::error::TicketError; + +/// Length of the AES-GCM-SIV authentication tag, in bytes. +pub const TAG_LEN: usize = 16; + +/// Fixed nonce; deterministic sealing is safe under AES-GCM-SIV's +/// nonce-misuse resistance (see module docs). +const NONCE: [u8; 12] = [0u8; 12]; + +/// AES-256-GCM-SIV ticket sealer, keyed with the operator's secret. +#[derive(Clone)] +pub struct TicketSealer { + cipher: Aes256GcmSiv, +} + +impl TicketSealer { + /// Construct a new sealer from a 32-byte AES-256 key. + #[must_use] + pub fn new(key: &[u8; 32]) -> Self { + // 32-byte key: conversion into the cipher key is infallible. + let cipher = Aes256GcmSiv::new(&(*key).into()); + Self { cipher } + } + + /// Seal a payload, returning `ciphertext || tag`. `aad` is + /// authenticated but not encrypted and must match on + /// [`open`](Self::open). + /// + /// # Panics + /// Only if the input exceeds AES-GCM-SIV's length limit (~64 GiB), + /// which ticket payloads never approach. + #[must_use] + pub fn seal(&self, payload: &[u8], aad: &[u8]) -> Vec { + self.cipher + .encrypt(&Nonce::from(NONCE), Payload { msg: payload, aad }) + .expect("AES-GCM-SIV encryption cannot fail for in-memory ticket payloads") + } + + /// Open a sealed ticket, returning the decrypted payload. `aad` must + /// match the value passed to [`seal`](Self::seal). + /// + /// # Errors + /// [`TicketError::TooShort`] if `sealed` cannot contain a tag, or + /// [`TicketError::AuthenticationFailed`] if the ticket, tag, key, or + /// `aad` do not match. Verification is constant-time. + pub fn open(&self, sealed: &[u8], aad: &[u8]) -> Result, TicketError> { + if sealed.len() < TAG_LEN { + return Err(TicketError::TooShort(sealed.len())); + } + self.cipher + .decrypt(&Nonce::from(NONCE), Payload { msg: sealed, aad }) + .map_err(|_| TicketError::AuthenticationFailed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const AAD: &[u8] = b"example.com/log"; + + fn sealer() -> TicketSealer { + TicketSealer::new(&[0x42; 32]) + } + + #[test] + fn seal_open_roundtrip() { + let s = sealer(); + let payload = b"signed-checkpoint-bytes-here"; + let sealed = s.seal(payload, AAD); + // Ciphertext is the payload length plus the appended tag. + assert_eq!(sealed.len(), payload.len() + TAG_LEN); + let opened = s.open(&sealed, AAD).unwrap(); + assert_eq!(opened, payload); + } + + #[test] + fn seal_is_deterministic() { + let s = sealer(); + let payload = b"same plaintext"; + // Identical (payload, aad) produce identical tickets: the fixed + // nonce makes the ticket a content-addressable handle on the + // pending checkpoint, which is safe under AES-GCM-SIV. + assert_eq!(s.seal(payload, AAD), s.seal(payload, AAD)); + } + + #[test] + fn seal_hides_plaintext() { + let s = sealer(); + let payload = b"pending-checkpoint-plaintext"; + let sealed = s.seal(payload, AAD); + // The payload must not appear verbatim in the sealed ticket: + // clients cannot parse the internal format. + assert!( + sealed.windows(payload.len()).all(|w| w != payload), + "plaintext leaked into sealed ticket", + ); + } + + #[test] + fn open_rejects_wrong_aad() { + let s = sealer(); + let sealed = s.seal(b"hello", AAD); + let err = s.open(&sealed, b"other.com/log").unwrap_err(); + assert!(matches!(err, TicketError::AuthenticationFailed)); + } + + #[test] + fn open_rejects_short_input() { + let s = sealer(); + let err = s.open(b"short", AAD).unwrap_err(); + assert!(matches!(err, TicketError::TooShort(5))); + } + + #[test] + fn open_rejects_empty_input() { + let s = sealer(); + let err = s.open(b"", AAD).unwrap_err(); + assert!(matches!(err, TicketError::TooShort(0))); + } + + #[test] + fn open_rejects_tampered_ciphertext() { + let s = sealer(); + let mut sealed = s.seal(b"hello", AAD); + // Flip a bit in the ciphertext body. + sealed[0] ^= 0x01; + let err = s.open(&sealed, AAD).unwrap_err(); + assert!(matches!(err, TicketError::AuthenticationFailed)); + } + + #[test] + fn open_rejects_tampered_tag() { + let s = sealer(); + let mut sealed = s.seal(b"hello", AAD); + // Flip a bit in the appended tag. + let last = sealed.len() - 1; + sealed[last] ^= 0x01; + let err = s.open(&sealed, AAD).unwrap_err(); + assert!(matches!(err, TicketError::AuthenticationFailed)); + } + + #[test] + fn open_rejects_wrong_key() { + let a = sealer(); + let b = TicketSealer::new(&[0x07; 32]); + let sealed = a.seal(b"hello", AAD); + let err = b.open(&sealed, AAD).unwrap_err(); + assert!(matches!(err, TicketError::AuthenticationFailed)); + } + + #[test] + fn seal_open_roundtrip_empty_payload() { + let s = sealer(); + let sealed = s.seal(b"", AAD); + // Empty payload: sealed ticket is exactly the tag. + assert_eq!(sealed.len(), TAG_LEN); + assert_eq!(s.open(&sealed, AAD).unwrap(), b""); + } +} diff --git a/crates/tlog_mirror/src/wire.rs b/crates/tlog_mirror/src/wire.rs new file mode 100644 index 0000000..33368c8 --- /dev/null +++ b/crates/tlog_mirror/src/wire.rs @@ -0,0 +1,651 @@ +// Copyright (c) 2025-2026 Cloudflare, Inc. All rights reserved. +// SPDX-License-Identifier: BSD-3-Clause + +//! Wire format for the [c2sp.org/tlog-mirror](https://c2sp.org/tlog-mirror) +//! HTTP API. Framing for two message bodies: +//! +//! * The `add-entries` request body: a header +//! ([`AddEntriesRequestHeader`]) followed by a sequence of entry +//! packages ([`EntryPackage`]), each covering a `[start, end)` range +//! from [`package_ranges`] with a subtree consistency proof. +//! * The `text/x.tlog.mirror-info` 409 response body ([`MirrorInfo`]): +//! pending tree size, next entry, and a base64 ticket. +//! +//! Request bodies are unbounded, so parsing works over `Read`/`Write` +//! streams: read the header, then iterate [`package_ranges`] and parse +//! each [`EntryPackage`]. + +use std::io::{self, Read, Write}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; +use tlog_core::{HASH_SIZE, Hash}; + +use crate::error::ParseError; + +/// `Content-Type` of the 409 Conflict response body. +pub const MIRROR_INFO_CONTENT_TYPE: &str = "text/x.tlog.mirror-info"; + +/// Spec maximum number of hashes in a subtree consistency proof. +pub const MAX_HASHES_PER_PROOF: u8 = 63; + +/// Entry-package alignment, matching the tlog-tiles entry-bundle +/// granularity. +pub const PACKAGE_ALIGNMENT: u64 = 256; + +/// Header parsed from an `add-entries` request body, followed on the wire +/// by the [`EntryPackage`]s covering `[upload_start, upload_end)` (see +/// [`package_ranges`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddEntriesRequestHeader { + /// Origin of the log being uploaded to. UTF-8, max 65535 bytes. + pub log_origin: String, + /// First log index in the upload, inclusive. Must be `<= upload_end`. + pub upload_start: u64, + /// First log index after the upload, exclusive. Per spec, must equal + /// a known pending checkpoint's tree size. + pub upload_end: u64, + /// Opaque ticket bytes, possibly empty (max 65535). The default + /// scheme is [`TicketSealer`](crate::TicketSealer); operators MAY use + /// any authenticated payload. + pub ticket: Vec, +} + +impl AddEntriesRequestHeader { + /// Read the header, leaving the cursor at the first entry package. + /// + /// # Errors + /// [`ParseError::Io`] on short reads, [`ParseError::LogOriginNotUtf8`] + /// if the origin is not valid UTF-8, or + /// [`ParseError::UploadRangeInverted`] if `upload_start > upload_end`. + pub fn read_from(mut reader: R) -> Result { + let log_origin_size = reader.read_u16::()?; + let mut log_origin_bytes = vec![0u8; usize::from(log_origin_size)]; + reader.read_exact(&mut log_origin_bytes).map_err(|e| { + if e.kind() == io::ErrorKind::UnexpectedEof { + ParseError::LogOriginTruncated { + advertised: log_origin_size, + } + } else { + ParseError::Io(e) + } + })?; + let log_origin = + String::from_utf8(log_origin_bytes).map_err(|_| ParseError::LogOriginNotUtf8)?; + + let upload_start = reader.read_u64::()?; + let upload_end = reader.read_u64::()?; + if upload_start > upload_end { + return Err(ParseError::UploadRangeInverted { + start: upload_start, + end: upload_end, + }); + } + + let ticket_size = reader.read_u16::()?; + let mut ticket = vec![0u8; usize::from(ticket_size)]; + reader.read_exact(&mut ticket)?; + + Ok(Self { + log_origin, + upload_start, + upload_end, + ticket, + }) + } + + /// Write the header. + /// + /// # Errors + /// [`io::ErrorKind::InvalidInput`] if `log_origin` or `ticket` exceeds + /// the u16 length-prefix limit (65535 bytes); otherwise the writer's + /// IO errors. + pub fn write_to(&self, mut writer: W) -> io::Result<()> { + let log_origin_size = + u16::try_from(self.log_origin.len()).map_err(|_| oversize("log_origin"))?; + let ticket_size = u16::try_from(self.ticket.len()).map_err(|_| oversize("ticket"))?; + writer.write_u16::(log_origin_size)?; + writer.write_all(self.log_origin.as_bytes())?; + writer.write_u64::(self.upload_start)?; + writer.write_u64::(self.upload_end)?; + writer.write_u16::(ticket_size)?; + writer.write_all(&self.ticket)?; + Ok(()) + } +} + +fn oversize(field: &'static str) -> io::Error { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("{field} exceeds u16 length prefix (max {})", u16::MAX), + ) +} + +/// Iterator over the `[start, end)` ranges of the entry packages in an +/// `add-entries` body for the given `[upload_start, upload_end)`. +/// +/// Packages are aligned at multiples of [`PACKAGE_ALIGNMENT`] (256), so +/// the first and last are typically partial. Empty when +/// `upload_start == upload_end`. Prefer this over recomputing the +/// rounding math by hand. +#[must_use] +pub fn package_ranges(upload_start: u64, upload_end: u64) -> PackageRanges { + PackageRanges { + upload_end, + next: upload_start, + } +} + +/// Iterator returned by [`package_ranges`]. +#[derive(Debug, Clone)] +pub struct PackageRanges { + upload_end: u64, + next: u64, +} + +impl Iterator for PackageRanges { + type Item = (u64, u64); + + fn next(&mut self) -> Option { + if self.next >= self.upload_end { + return None; + } + // Spec: package i covers [start, end) with + // start = max(upload_start, rounded_start + i*256) + // end = min(upload_end, rounded_start + (i+1)*256) + // `start` is already >= rounded_start + i*256, so the next end is + // `start` rounded up to the next 256 boundary. Saturate at + // upload_end if that overflows (start >= u64::MAX - 254, from + // hostile wire input); the min below would clamp there anyway. + let start = self.next; + let next_boundary = (start / PACKAGE_ALIGNMENT) + .checked_add(1) + .and_then(|q| q.checked_mul(PACKAGE_ALIGNMENT)) + .unwrap_or(self.upload_end); + let end = next_boundary.min(self.upload_end); + self.next = end; + Some((start, end)) + } +} + +/// One entry package: the log entries for a `[start, end)` range plus the +/// subtree consistency proof to the checkpoint at `upload_end`. +/// +/// Only [`PartialEq`] (not [`Eq`]) because [`tlog_core::Hash`] is +/// `PartialEq` only. +#[derive(Debug, Clone, PartialEq)] +pub struct EntryPackage { + /// Log entries in order, each u16-length-prefixed on the wire (max + /// 65535 bytes). Count equals `end - start` for the package range. + pub entries: Vec>, + /// Subtree consistency proof hashes, max [`MAX_HASHES_PER_PROOF`] + /// (empty is valid). + pub proof: Vec, +} + +impl EntryPackage { + /// Read one entry package. `num_entries` must be `end - start` for the + /// package's range from [`package_ranges`]. + /// + /// # Errors + /// [`ParseError::Io`] on short reads, or [`ParseError::TooManyHashes`] + /// if `num_hashes` exceeds 63. + pub fn read_from(mut reader: R, num_entries: u64) -> Result { + let num_entries = usize::try_from(num_entries) + .map_err(|_| io::Error::other("num_entries overflows usize"))?; + let mut entries = Vec::with_capacity(num_entries); + for _ in 0..num_entries { + let entry_size = reader.read_u16::()?; + let mut entry = vec![0u8; usize::from(entry_size)]; + reader.read_exact(&mut entry)?; + entries.push(entry); + } + + let num_hashes = reader.read_u8()?; + if num_hashes > MAX_HASHES_PER_PROOF { + return Err(ParseError::TooManyHashes(num_hashes)); + } + let mut proof = Vec::with_capacity(usize::from(num_hashes)); + for _ in 0..num_hashes { + let mut hash = [0u8; HASH_SIZE]; + reader.read_exact(&mut hash)?; + proof.push(Hash(hash)); + } + + Ok(Self { entries, proof }) + } + + /// Write one entry package. + /// + /// # Errors + /// [`io::ErrorKind::InvalidInput`] if the proof exceeds 63 hashes or + /// any entry exceeds 65535 bytes; otherwise the writer's IO errors. + pub fn write_to(&self, mut writer: W) -> io::Result<()> { + if self.proof.len() > usize::from(MAX_HASHES_PER_PROOF) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "proof has {} hashes, max is {MAX_HASHES_PER_PROOF}", + self.proof.len() + ), + )); + } + for entry in &self.entries { + let len = u16::try_from(entry.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + format!("entry of {} bytes exceeds u16 length prefix", entry.len()), + ) + })?; + writer.write_u16::(len)?; + writer.write_all(entry)?; + } + // Length already validated; cast is safe. + #[allow(clippy::cast_possible_truncation)] + writer.write_u8(self.proof.len() as u8)?; + for hash in &self.proof { + writer.write_all(&hash.0)?; + } + Ok(()) + } +} + +/// Body of a `text/x.tlog.mirror-info` 409 Conflict response, returned +/// when the client is out of sync. On the wire, three `\n`-terminated +/// lines: `tree_size` (decimal), `next_entry` (decimal), and a base64 +/// `ticket` (may be empty). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MirrorInfo { + /// Tree size of a valid pending checkpoint to retry against. + pub tree_size: u64, + /// Next entry index the mirror is expecting. + pub next_entry: u64, + /// Opaque ticket bytes (base64-decoded), possibly empty. + pub ticket: Vec, +} + +impl MirrorInfo { + /// Parse a `text/x.tlog.mirror-info` 409 response body. + /// + /// # Errors + /// [`ParseError::MalformedMirrorInfo`] if the body is not exactly + /// three `\n`-terminated lines, [`ParseError::InvalidDecimal`] if a + /// size line is not a `u64`, or [`ParseError::InvalidTicketBase64`] + /// if the ticket line is not valid base64. + pub fn parse(body: &[u8]) -> Result { + let s = std::str::from_utf8(body) + .map_err(|_| ParseError::MalformedMirrorInfo("not valid UTF-8"))?; + let mut lines = s.split_inclusive('\n'); + let tree_size_line = lines + .next() + .ok_or(ParseError::MalformedMirrorInfo("missing tree_size line"))?; + let next_entry_line = lines + .next() + .ok_or(ParseError::MalformedMirrorInfo("missing next_entry line"))?; + let ticket_line = lines + .next() + .ok_or(ParseError::MalformedMirrorInfo("missing ticket line"))?; + if lines.next().is_some() { + return Err(ParseError::MalformedMirrorInfo("unexpected trailing data")); + } + let tree_size_str = + tree_size_line + .strip_suffix('\n') + .ok_or(ParseError::MalformedMirrorInfo( + "tree_size line not newline-terminated", + ))?; + let next_entry_str = + next_entry_line + .strip_suffix('\n') + .ok_or(ParseError::MalformedMirrorInfo( + "next_entry line not newline-terminated", + ))?; + let ticket_str = ticket_line + .strip_suffix('\n') + .ok_or(ParseError::MalformedMirrorInfo( + "ticket line not newline-terminated", + ))?; + + let tree_size = parse_decimal_u64("tree_size", tree_size_str)?; + let next_entry = parse_decimal_u64("next_entry", next_entry_str)?; + let ticket = BASE64 + .decode(ticket_str) + .map_err(|_| ParseError::InvalidTicketBase64)?; + Ok(Self { + tree_size, + next_entry, + ticket, + }) + } + + /// Serialize this `MirrorInfo` as a `text/x.tlog.mirror-info` body. + #[must_use] + pub fn to_body(&self) -> Vec { + let mut out = Vec::with_capacity( + // Two 20-char decimals + 3 newlines + base64 (3:4 ratio, + // rounded up). + 20 + 1 + 20 + 1 + (self.ticket.len().div_ceil(3) * 4) + 1, + ); + // Writing into a Vec is infallible. + let _ = writeln!(out, "{}", self.tree_size); + let _ = writeln!(out, "{}", self.next_entry); + out.extend_from_slice(BASE64.encode(&self.ticket).as_bytes()); + out.push(b'\n'); + out + } +} + +/// Strict decimal-`u64` parser for the `text/x.tlog.mirror-info` body. +/// Rejects empty input, leading zeros (except the literal `"0"`), signs, +/// whitespace, and any non-ASCII-decimal byte, enforcing one canonical +/// encoding per integer. +fn parse_decimal_u64(field: &'static str, value: &str) -> Result { + if value.is_empty() { + return Err(ParseError::InvalidDecimal { + field, + value: value.to_owned(), + }); + } + if value.len() > 1 && value.starts_with('0') { + return Err(ParseError::InvalidDecimal { + field, + value: value.to_owned(), + }); + } + if !value.bytes().all(|b| b.is_ascii_digit()) { + return Err(ParseError::InvalidDecimal { + field, + value: value.to_owned(), + }); + } + value + .parse::() + .map_err(|_| ParseError::InvalidDecimal { + field, + value: value.to_owned(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + fn sample_header() -> AddEntriesRequestHeader { + AddEntriesRequestHeader { + log_origin: "log.example/m1".to_owned(), + upload_start: 12_345, + upload_end: 12_400, + ticket: vec![0xAB, 0xCD, 0xEF], + } + } + + #[test] + fn header_roundtrip() { + let header = sample_header(); + let mut buf = Vec::new(); + header.write_to(&mut buf).unwrap(); + let parsed = AddEntriesRequestHeader::read_from(Cursor::new(&buf)).unwrap(); + assert_eq!(parsed, header); + } + + #[test] + fn header_roundtrip_empty_origin_and_ticket() { + let header = AddEntriesRequestHeader { + log_origin: String::new(), + upload_start: 0, + upload_end: 0, + ticket: Vec::new(), + }; + let mut buf = Vec::new(); + header.write_to(&mut buf).unwrap(); + // Exact-bytes pin: u16(0) || u64(0) || u64(0) || u16(0) = 20 zero bytes. + assert_eq!(buf, vec![0u8; 20]); + let parsed = AddEntriesRequestHeader::read_from(Cursor::new(&buf)).unwrap(); + assert_eq!(parsed, header); + } + + #[test] + fn header_rejects_upload_range_inverted() { + let mut buf = Vec::new(); + // log_origin_size = 0 + buf.extend_from_slice(&0u16.to_be_bytes()); + // upload_start = 100, upload_end = 50 (inverted) + buf.extend_from_slice(&100u64.to_be_bytes()); + buf.extend_from_slice(&50u64.to_be_bytes()); + // ticket_size = 0 + buf.extend_from_slice(&0u16.to_be_bytes()); + let err = AddEntriesRequestHeader::read_from(Cursor::new(&buf)).unwrap_err(); + assert!(matches!( + err, + ParseError::UploadRangeInverted { + start: 100, + end: 50 + } + )); + } + + #[test] + fn header_rejects_non_utf8_origin() { + let mut buf = Vec::new(); + buf.extend_from_slice(&2u16.to_be_bytes()); // log_origin_size = 2 + buf.extend_from_slice(&[0xFF, 0xFE]); // not valid UTF-8 + buf.extend_from_slice(&0u64.to_be_bytes()); + buf.extend_from_slice(&0u64.to_be_bytes()); + buf.extend_from_slice(&0u16.to_be_bytes()); + let err = AddEntriesRequestHeader::read_from(Cursor::new(&buf)).unwrap_err(); + assert!(matches!(err, ParseError::LogOriginNotUtf8)); + } + + #[test] + fn header_rejects_truncated_origin() { + let mut buf = Vec::new(); + buf.extend_from_slice(&5u16.to_be_bytes()); // claims 5 origin bytes + buf.extend_from_slice(b"abc"); // but only 3 follow + let err = AddEntriesRequestHeader::read_from(Cursor::new(&buf)).unwrap_err(); + assert!(matches!( + err, + ParseError::LogOriginTruncated { advertised: 5 } + )); + } + + #[test] + fn package_ranges_empty_when_start_equals_end() { + let ranges: Vec<_> = package_ranges(1024, 1024).collect(); + assert!(ranges.is_empty()); + } + + #[test] + fn package_ranges_aligned() { + // Both endpoints aligned: exactly two full packages. + let ranges: Vec<_> = package_ranges(0, 512).collect(); + assert_eq!(ranges, vec![(0, 256), (256, 512)]); + } + + #[test] + fn package_ranges_partial_first_only() { + // Start mid-package, end aligned. + let ranges: Vec<_> = package_ranges(100, 256).collect(); + assert_eq!(ranges, vec![(100, 256)]); + } + + #[test] + fn package_ranges_partial_last_only() { + // Start aligned, end mid-package. + let ranges: Vec<_> = package_ranges(256, 300).collect(); + assert_eq!(ranges, vec![(256, 300)]); + } + + #[test] + fn package_ranges_partial_both() { + // Start and end both mid-package, spanning multiple packages. + let ranges: Vec<_> = package_ranges(100, 600).collect(); + assert_eq!(ranges, vec![(100, 256), (256, 512), (512, 600)]); + } + + #[test] + fn package_ranges_all_in_one_partial() { + // Both endpoints inside the same package boundary. + let ranges: Vec<_> = package_ranges(100, 200).collect(); + assert_eq!(ranges, vec![(100, 200)]); + } + + #[test] + fn package_ranges_no_overflow_at_u64_max() { + // Regression: `(start / 256 + 1) * 256` overflows near u64::MAX, + // reachable since `read_from` accepts any 8-byte upload_end. + let ranges: Vec<_> = package_ranges(u64::MAX - 1, u64::MAX).collect(); + assert_eq!(ranges, vec![(u64::MAX - 1, u64::MAX)]); + + let edge = u64::MAX - (u64::MAX % PACKAGE_ALIGNMENT); + let ranges: Vec<_> = package_ranges(edge, u64::MAX).collect(); + assert_eq!(ranges, vec![(edge, u64::MAX)]); + } + + #[test] + fn entry_package_roundtrip() { + let pkg = EntryPackage { + entries: vec![b"hello".to_vec(), b"world".to_vec(), Vec::new()], + proof: vec![Hash([0x11; HASH_SIZE]), Hash([0x22; HASH_SIZE])], + }; + let mut buf = Vec::new(); + pkg.write_to(&mut buf).unwrap(); + let parsed = EntryPackage::read_from(Cursor::new(&buf), 3).unwrap(); + assert_eq!(parsed, pkg); + } + + #[test] + fn entry_package_rejects_too_many_hashes_on_read() { + let mut buf = Vec::new(); + // Zero entries, num_hashes = 64 (exceeds 63). + buf.push(64u8); + buf.extend_from_slice(&[0u8; 64 * HASH_SIZE]); + let err = EntryPackage::read_from(Cursor::new(&buf), 0).unwrap_err(); + assert!(matches!(err, ParseError::TooManyHashes(64))); + } + + #[test] + fn entry_package_rejects_too_many_hashes_on_write() { + let pkg = EntryPackage { + entries: vec![], + proof: (0..64).map(|_| Hash([0u8; HASH_SIZE])).collect(), + }; + let mut buf = Vec::new(); + let err = pkg.write_to(&mut buf).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn entry_package_rejects_oversize_entry_on_write() { + let pkg = EntryPackage { + entries: vec![vec![0u8; usize::from(u16::MAX) + 1]], + proof: vec![], + }; + let mut buf = Vec::new(); + let err = pkg.write_to(&mut buf).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + fn sample_mirror_info() -> MirrorInfo { + MirrorInfo { + tree_size: 12_400, + next_entry: 12_345, + ticket: vec![0x01, 0x02, 0x03, 0x04, 0x05], + } + } + + #[test] + fn mirror_info_roundtrip() { + let info = sample_mirror_info(); + let body = info.to_body(); + let parsed = MirrorInfo::parse(&body).unwrap(); + assert_eq!(parsed, info); + } + + #[test] + fn mirror_info_pin_exact_body() { + // Pin the exact wire bytes for a known input. Base64("\x01\x02\x03\x04\x05") = "AQIDBAU=". + let info = sample_mirror_info(); + assert_eq!(info.to_body(), b"12400\n12345\nAQIDBAU=\n"); + } + + #[test] + fn mirror_info_roundtrip_empty_ticket() { + let info = MirrorInfo { + tree_size: 0, + next_entry: 0, + ticket: Vec::new(), + }; + assert_eq!(info.to_body(), b"0\n0\n\n"); + assert_eq!(MirrorInfo::parse(b"0\n0\n\n").unwrap(), info); + } + + #[test] + fn mirror_info_rejects_missing_newline_terminators() { + // Missing trailing newline on the ticket line. + let body = b"100\n50\nAQID"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!(err, ParseError::MalformedMirrorInfo(_))); + } + + #[test] + fn mirror_info_rejects_too_many_lines() { + let body = b"100\n50\nAQID\nextra\n"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!(err, ParseError::MalformedMirrorInfo(_))); + } + + #[test] + fn mirror_info_rejects_leading_zero() { + let body = b"0100\n50\nAQID\n"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!( + err, + ParseError::InvalidDecimal { + field: "tree_size", + .. + } + )); + } + + #[test] + fn mirror_info_rejects_leading_plus() { + let body = b"+100\n50\nAQID\n"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!( + err, + ParseError::InvalidDecimal { + field: "tree_size", + .. + } + )); + } + + #[test] + fn mirror_info_rejects_negative() { + let body = b"-100\n50\nAQID\n"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!( + err, + ParseError::InvalidDecimal { + field: "tree_size", + .. + } + )); + } + + #[test] + fn mirror_info_rejects_bad_base64_ticket() { + let body = b"100\n50\n!!!notbase64!!!\n"; + let err = MirrorInfo::parse(body).unwrap_err(); + assert!(matches!(err, ParseError::InvalidTicketBase64)); + } + + #[test] + fn mirror_info_accepts_zero_literal() { + let body = b"0\n0\nAQID\n"; + let info = MirrorInfo::parse(body).unwrap(); + assert_eq!(info.tree_size, 0); + assert_eq!(info.next_entry, 0); + } +} From 09bc4b55b84dced89b82949789fe05442ec9f80e Mon Sep 17 00:00:00 2001 From: Luke Valenta Date: Mon, 13 Jul 2026 12:55:49 -0400 Subject: [PATCH 2/2] Address Lina's comments --- Cargo.lock | 1 + Cargo.toml | 1 - crates/tlog_mirror/Cargo.toml | 1 + crates/tlog_mirror/src/error.rs | 14 +++- crates/tlog_mirror/src/lib.rs | 2 +- crates/tlog_mirror/src/ticket.rs | 86 +++++++++++++------- crates/tlog_mirror/src/wire.rs | 133 +++++++++++++++++++++++-------- 7 files changed, 167 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a52502..7a4be32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3211,6 +3211,7 @@ dependencies = [ "aes-gcm-siv", "base64", "byteorder", + "rand", "thiserror 2.0.17", "tlog_core", ] diff --git a/Cargo.toml b/Cargo.toml index 3b87c9f..f1e4cd7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,7 +109,6 @@ futures-executor = "0.3.31" futures-util = "0.3.31" getrandom = { version = "0.4", features = ["wasm_js"] } hex = "0.4" -hmac = "0.13" itertools = "0.14.0" jsonschema = "0.30" length_prefixed = { path = "crates/length_prefixed" } diff --git a/crates/tlog_mirror/Cargo.toml b/crates/tlog_mirror/Cargo.toml index 1afb6da..5352e14 100644 --- a/crates/tlog_mirror/Cargo.toml +++ b/crates/tlog_mirror/Cargo.toml @@ -24,5 +24,6 @@ crate-type = ["rlib"] aes-gcm-siv.workspace = true base64.workspace = true byteorder.workspace = true +rand.workspace = true thiserror.workspace = true tlog_core.workspace = true diff --git a/crates/tlog_mirror/src/error.rs b/crates/tlog_mirror/src/error.rs index 49f932e..7258fe9 100644 --- a/crates/tlog_mirror/src/error.rs +++ b/crates/tlog_mirror/src/error.rs @@ -38,6 +38,11 @@ pub enum ParseError { #[error("num_hashes {0} exceeds spec maximum of 63")] TooManyHashes(u8), + /// An entry package's entry count exceeded the spec's per-package + /// maximum of [`PACKAGE_ALIGNMENT`](crate::PACKAGE_ALIGNMENT) (256). + #[error("entry package has {0} entries, spec maximum is 256")] + TooManyEntries(u64), + /// The `text/x.tlog.mirror-info` body did not have exactly three /// newline-terminated lines. #[error("mirror-info body is malformed: {0}")] @@ -62,10 +67,11 @@ pub enum ParseError { /// Errors returned by [`TicketSealer`](crate::TicketSealer). #[derive(Debug, Error)] pub enum TicketError { - /// The sealed ticket was shorter than the AEAD tag length - /// ([`TAG_LEN`](crate::TAG_LEN), 16 bytes), so it cannot possibly be - /// a valid ticket. - #[error("sealed ticket too short: {0} bytes, need at least 16")] + /// The sealed ticket was shorter than the prepended nonce + /// ([`NONCE_LEN`](crate::NONCE_LEN), 12 bytes) plus the AEAD tag + /// ([`TAG_LEN`](crate::TAG_LEN), 16 bytes), so it cannot possibly be a + /// valid ticket. + #[error("sealed ticket too short: {0} bytes, need at least 28")] TooShort(usize), /// AEAD decryption failed: the ticket, tag, key, or associated data diff --git a/crates/tlog_mirror/src/lib.rs b/crates/tlog_mirror/src/lib.rs index 8b33e06..01d1a19 100644 --- a/crates/tlog_mirror/src/lib.rs +++ b/crates/tlog_mirror/src/lib.rs @@ -22,7 +22,7 @@ pub mod wire; mod error; pub use error::{ParseError, TicketError}; -pub use ticket::{TAG_LEN, TicketSealer}; +pub use ticket::{NONCE_LEN, TAG_LEN, TicketSealer}; pub use wire::{ AddEntriesRequestHeader, EntryPackage, MAX_HASHES_PER_PROOF, MIRROR_INFO_CONTENT_TYPE, MirrorInfo, PACKAGE_ALIGNMENT, PackageRanges, package_ranges, diff --git a/crates/tlog_mirror/src/ticket.rs b/crates/tlog_mirror/src/ticket.rs index e0fad2d..8f4a665 100644 --- a/crates/tlog_mirror/src/ticket.rs +++ b/crates/tlog_mirror/src/ticket.rs @@ -9,24 +9,28 @@ //! AES-256-GCM-SIV to enforce that opacity, so clients cannot depend on //! the ticket's internal format. //! -//! Sealing is deterministic (fixed nonce): identical `(payload, aad)` -//! inputs yield identical tickets, which is safe because AES-GCM-SIV is -//! nonce-misuse resistant. `aad` is authenticated but not encrypted; -//! callers pass the log origin to bind a ticket to its log. +//! A fresh random 96-bit nonce is generated per [`seal`](TicketSealer::seal) +//! and prepended to the ciphertext, so the sealed ticket is +//! `nonce || ciphertext || tag`. RFC 8452 recommends against fixing +//! the nonce, so we do not: a random nonce keeps the security margin even +//! under a long-lived key without bounding the number of tickets. `aad` is +//! authenticated but not encrypted; callers pass the log origin to bind a +//! ticket to its log. use aes_gcm_siv::{ Aes256GcmSiv, Nonce, aead::{Aead, KeyInit, Payload}, }; +use rand::RngExt; use crate::error::TicketError; /// Length of the AES-GCM-SIV authentication tag, in bytes. pub const TAG_LEN: usize = 16; -/// Fixed nonce; deterministic sealing is safe under AES-GCM-SIV's -/// nonce-misuse resistance (see module docs). -const NONCE: [u8; 12] = [0u8; 12]; +/// Length of the AES-GCM-SIV nonce, in bytes. Prepended to every sealed +/// ticket (see module docs). +pub const NONCE_LEN: usize = 12; /// AES-256-GCM-SIV ticket sealer, keyed with the operator's secret. #[derive(Clone)] @@ -43,33 +47,50 @@ impl TicketSealer { Self { cipher } } - /// Seal a payload, returning `ciphertext || tag`. `aad` is - /// authenticated but not encrypted and must match on - /// [`open`](Self::open). + /// Seal a payload, returning `nonce || ciphertext || tag` with a fresh + /// random nonce. `aad` is authenticated but not encrypted and must + /// match on [`open`](Self::open). /// /// # Panics /// Only if the input exceeds AES-GCM-SIV's length limit (~64 GiB), /// which ticket payloads never approach. #[must_use] pub fn seal(&self, payload: &[u8], aad: &[u8]) -> Vec { - self.cipher - .encrypt(&Nonce::from(NONCE), Payload { msg: payload, aad }) - .expect("AES-GCM-SIV encryption cannot fail for in-memory ticket payloads") + let mut nonce = [0u8; NONCE_LEN]; + rand::rng().fill(&mut nonce[..]); + let ciphertext = self + .cipher + .encrypt(&Nonce::from(nonce), Payload { msg: payload, aad }) + .expect("AES-GCM-SIV encryption cannot fail for in-memory ticket payloads"); + let mut sealed = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + sealed.extend_from_slice(&nonce); + sealed.extend_from_slice(&ciphertext); + sealed } - /// Open a sealed ticket, returning the decrypted payload. `aad` must - /// match the value passed to [`seal`](Self::seal). + /// Open a sealed ticket (`nonce || ciphertext || tag`), returning the + /// decrypted payload. `aad` must match the value passed to + /// [`seal`](Self::seal). /// /// # Errors - /// [`TicketError::TooShort`] if `sealed` cannot contain a tag, or - /// [`TicketError::AuthenticationFailed`] if the ticket, tag, key, or - /// `aad` do not match. Verification is constant-time. + /// [`TicketError::TooShort`] if `sealed` cannot contain a nonce and + /// tag, or [`TicketError::AuthenticationFailed`] if the ticket, tag, + /// key, or `aad` do not match. Verification is constant-time. pub fn open(&self, sealed: &[u8], aad: &[u8]) -> Result, TicketError> { - if sealed.len() < TAG_LEN { + if sealed.len() < NONCE_LEN + TAG_LEN { return Err(TicketError::TooShort(sealed.len())); } + let (nonce_bytes, ciphertext) = sealed.split_at(NONCE_LEN); + let mut nonce = [0u8; NONCE_LEN]; + nonce.copy_from_slice(nonce_bytes); self.cipher - .decrypt(&Nonce::from(NONCE), Payload { msg: sealed, aad }) + .decrypt( + &Nonce::from(nonce), + Payload { + msg: ciphertext, + aad, + }, + ) .map_err(|_| TicketError::AuthenticationFailed) } } @@ -89,20 +110,23 @@ mod tests { let s = sealer(); let payload = b"signed-checkpoint-bytes-here"; let sealed = s.seal(payload, AAD); - // Ciphertext is the payload length plus the appended tag. - assert_eq!(sealed.len(), payload.len() + TAG_LEN); + // Sealed ticket is the prepended nonce, the payload, and the tag. + assert_eq!(sealed.len(), NONCE_LEN + payload.len() + TAG_LEN); let opened = s.open(&sealed, AAD).unwrap(); assert_eq!(opened, payload); } #[test] - fn seal_is_deterministic() { + fn seal_is_randomized() { let s = sealer(); let payload = b"same plaintext"; - // Identical (payload, aad) produce identical tickets: the fixed - // nonce makes the ticket a content-addressable handle on the - // pending checkpoint, which is safe under AES-GCM-SIV. - assert_eq!(s.seal(payload, AAD), s.seal(payload, AAD)); + // A fresh random nonce per seal means identical (payload, aad) + // inputs produce distinct tickets (RFC 8452 ยง9), yet both open. + let a = s.seal(payload, AAD); + let b = s.seal(payload, AAD); + assert_ne!(a, b); + assert_eq!(s.open(&a, AAD).unwrap(), payload); + assert_eq!(s.open(&b, AAD).unwrap(), payload); } #[test] @@ -144,8 +168,8 @@ mod tests { fn open_rejects_tampered_ciphertext() { let s = sealer(); let mut sealed = s.seal(b"hello", AAD); - // Flip a bit in the ciphertext body. - sealed[0] ^= 0x01; + // Flip a bit in the ciphertext body (just past the nonce). + sealed[NONCE_LEN] ^= 0x01; let err = s.open(&sealed, AAD).unwrap_err(); assert!(matches!(err, TicketError::AuthenticationFailed)); } @@ -174,8 +198,8 @@ mod tests { fn seal_open_roundtrip_empty_payload() { let s = sealer(); let sealed = s.seal(b"", AAD); - // Empty payload: sealed ticket is exactly the tag. - assert_eq!(sealed.len(), TAG_LEN); + // Empty payload: sealed ticket is exactly the nonce plus the tag. + assert_eq!(sealed.len(), NONCE_LEN + TAG_LEN); assert_eq!(s.open(&sealed, AAD).unwrap(), b""); } } diff --git a/crates/tlog_mirror/src/wire.rs b/crates/tlog_mirror/src/wire.rs index 33368c8..c591733 100644 --- a/crates/tlog_mirror/src/wire.rs +++ b/crates/tlog_mirror/src/wire.rs @@ -97,10 +97,23 @@ impl AddEntriesRequestHeader { /// Write the header. /// /// # Errors - /// [`io::ErrorKind::InvalidInput`] if `log_origin` or `ticket` exceeds - /// the u16 length-prefix limit (65535 bytes); otherwise the writer's - /// IO errors. + /// [`io::ErrorKind::InvalidInput`] if `upload_start > upload_end` + /// (which [`read_from`](Self::read_from) rejects), or if `log_origin` + /// or `ticket` exceeds the u16 length-prefix limit (65535 bytes); + /// otherwise the writer's IO errors. pub fn write_to(&self, mut writer: W) -> io::Result<()> { + // Keep read/write symmetric: the spec requires + // upload_start <= upload_end, and read_from enforces it, so refuse + // to serialize an inverted range. + if self.upload_start > self.upload_end { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "upload_start ({}) > upload_end ({})", + self.upload_start, self.upload_end + ), + )); + } let log_origin_size = u16::try_from(self.log_origin.len()).map_err(|_| oversize("log_origin"))?; let ticket_size = u16::try_from(self.ticket.len()).map_err(|_| oversize("ticket"))?; @@ -188,9 +201,16 @@ impl EntryPackage { /// package's range from [`package_ranges`]. /// /// # Errors - /// [`ParseError::Io`] on short reads, or [`ParseError::TooManyHashes`] - /// if `num_hashes` exceeds 63. + /// [`ParseError::TooManyEntries`] if `num_entries` exceeds + /// [`PACKAGE_ALIGNMENT`] (256), [`ParseError::Io`] on short reads, or + /// [`ParseError::TooManyHashes`] if `num_hashes` exceeds 63. pub fn read_from(mut reader: R, num_entries: u64) -> Result { + // Reject oversized counts before allocating, so hostile wire input + // can't request a huge `Vec::with_capacity`. The spec caps each + // package at PACKAGE_ALIGNMENT (256) entries. + if num_entries > PACKAGE_ALIGNMENT { + return Err(ParseError::TooManyEntries(num_entries)); + } let num_entries = usize::try_from(num_entries) .map_err(|_| io::Error::other("num_entries overflows usize"))?; let mut entries = Vec::with_capacity(num_entries); @@ -218,9 +238,23 @@ impl EntryPackage { /// Write one entry package. /// /// # Errors - /// [`io::ErrorKind::InvalidInput`] if the proof exceeds 63 hashes or - /// any entry exceeds 65535 bytes; otherwise the writer's IO errors. + /// [`io::ErrorKind::InvalidInput`] if the package holds more than + /// [`PACKAGE_ALIGNMENT`] (256) entries, the proof exceeds 63 hashes, + /// or any entry exceeds 65535 bytes; otherwise the writer's IO errors. pub fn write_to(&self, mut writer: W) -> io::Result<()> { + // Keep read/write symmetric: never serialize a package larger than + // the spec's per-package maximum that `read_from` would reject. + // Compare as u64 so the check is target-pointer-width independent + // and cannot panic. + if u64::try_from(self.entries.len()).map_or(true, |n| n > PACKAGE_ALIGNMENT) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!( + "entry package has {} entries, max is {PACKAGE_ALIGNMENT}", + self.entries.len() + ), + )); + } if self.proof.len() > usize::from(MAX_HASHES_PER_PROOF) { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -335,24 +369,9 @@ impl MirrorInfo { } } -/// Strict decimal-`u64` parser for the `text/x.tlog.mirror-info` body. -/// Rejects empty input, leading zeros (except the literal `"0"`), signs, -/// whitespace, and any non-ASCII-decimal byte, enforcing one canonical -/// encoding per integer. +/// Decimal-`u64` parser for the `text/x.tlog.mirror-info` body. fn parse_decimal_u64(field: &'static str, value: &str) -> Result { - if value.is_empty() { - return Err(ParseError::InvalidDecimal { - field, - value: value.to_owned(), - }); - } - if value.len() > 1 && value.starts_with('0') { - return Err(ParseError::InvalidDecimal { - field, - value: value.to_owned(), - }); - } - if !value.bytes().all(|b| b.is_ascii_digit()) { + if value.is_empty() || !value.bytes().all(|b| b.is_ascii_digit()) { return Err(ParseError::InvalidDecimal { field, value: value.to_owned(), @@ -405,6 +424,23 @@ mod tests { assert_eq!(parsed, header); } + #[test] + fn header_write_rejects_upload_range_inverted() { + // write_to must refuse an inverted range, keeping it symmetric with + // read_from (which rejects upload_start > upload_end). + let header = AddEntriesRequestHeader { + log_origin: "log.example/m1".to_owned(), + upload_start: 100, + upload_end: 50, + ticket: Vec::new(), + }; + let mut buf = Vec::new(); + let err = header.write_to(&mut buf).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + // Nothing should have been written before the validation failure. + assert!(buf.is_empty()); + } + #[test] fn header_rejects_upload_range_inverted() { let mut buf = Vec::new(); @@ -514,6 +550,38 @@ mod tests { assert_eq!(parsed, pkg); } + #[test] + fn entry_package_rejects_too_many_entries_on_read() { + // num_entries above PACKAGE_ALIGNMENT (256) must be rejected before + // allocating, so hostile input can't force a huge allocation. + let err = EntryPackage::read_from(Cursor::new(&[]), PACKAGE_ALIGNMENT + 1).unwrap_err(); + assert!(matches!(err, ParseError::TooManyEntries(n) if n == PACKAGE_ALIGNMENT + 1)); + } + + #[test] + fn entry_package_accepts_max_entries_on_read() { + // Exactly PACKAGE_ALIGNMENT (256) entries is the spec maximum and + // must still parse. Each entry is a single u16 length prefix of 0. + let buf = vec![0u8; usize::try_from(PACKAGE_ALIGNMENT).unwrap() * 2 + 1]; + let pkg = EntryPackage::read_from(Cursor::new(&buf), PACKAGE_ALIGNMENT).unwrap(); + assert_eq!( + pkg.entries.len(), + usize::try_from(PACKAGE_ALIGNMENT).unwrap() + ); + assert!(pkg.proof.is_empty()); + } + + #[test] + fn entry_package_rejects_too_many_entries_on_write() { + let pkg = EntryPackage { + entries: vec![Vec::new(); usize::try_from(PACKAGE_ALIGNMENT).unwrap() + 1], + proof: vec![], + }; + let mut buf = Vec::new(); + let err = pkg.write_to(&mut buf).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + #[test] fn entry_package_rejects_too_many_hashes_on_read() { let mut buf = Vec::new(); @@ -596,16 +664,13 @@ mod tests { } #[test] - fn mirror_info_rejects_leading_zero() { - let body = b"0100\n50\nAQID\n"; - let err = MirrorInfo::parse(body).unwrap_err(); - assert!(matches!( - err, - ParseError::InvalidDecimal { - field: "tree_size", - .. - } - )); + fn mirror_info_accepts_leading_zero() { + // Allow leading zeros for compatibility with Go's strconv.ParseUint. + let info = MirrorInfo::parse(b"0100\n00042\nAQID\n").unwrap(); + assert_eq!(info.tree_size, 100); + assert_eq!(info.next_entry, 42); + // to_body remains canonical (no leading zeros) when serializing. + assert_eq!(info.to_body(), b"100\n42\nAQID\n"); } #[test]