From 6fa59d47278c498484478ef6dc12615f6a4225d7 Mon Sep 17 00:00:00 2001 From: Tip ten Brink <75669206+tiptenbrink@users.noreply.github.com> Date: Sun, 4 Oct 2026 16:18:39 +0200 Subject: [PATCH 01/14] Do less repeated work when signing an S3 request. These are the 19 changes of #50, which lists each. S3 signing hashes its canonical form once and keeps its key's HMAC states for the day. Request heads are written in fewer passes and copies, and response heads are read with fewer comparisons. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 11 + Cargo.lock | 28 -- crates/object-storage-crypto/Cargo.toml | 12 +- crates/object-storage-crypto/src/lib.rs | 54 ++- .../src/sha256_minimal.rs | 9 +- .../src/sha256_rustcrypto.rs | 94 ++++- .../object-storage-proto/src/azure/batch.rs | 2 +- crates/object-storage-proto/src/azure/mod.rs | 22 +- crates/object-storage-proto/src/common.rs | 45 +- crates/object-storage-proto/src/encoding.rs | 19 +- crates/object-storage-proto/src/head.rs | 149 ++++--- crates/object-storage-proto/src/lib.rs | 4 +- crates/object-storage-proto/src/request.rs | 155 ++++++- crates/object-storage-proto/src/s3/mod.rs | 399 ++++++++++++------ crates/object-storage-proto/src/sigv4.rs | 204 ++++++--- crates/object-storage-proto/src/time.rs | 16 +- crates/object-storage-proto/src/url.rs | 37 +- .../object-storage-proto/tests/s3_client.rs | 4 +- .../object-storage-proto/tests/s3_delete.rs | 3 +- crates/object-storage-proto/tests/s3_get.rs | 3 +- crates/object-storage-proto/tests/s3_list.rs | 3 +- crates/object-storage-proto/tests/s3_put.rs | 3 +- 22 files changed, 884 insertions(+), 392 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8f4b688..1e215e17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ This file lists the changes in each release of `borink-object-storage-proto` and `borink-object-storage-crypto`. Until 1.0, any release can break the API. Note: these changelogs are not human-written and only lightly reviewed before 1.0. +## Unreleased + +### Changed + +- `sigv4::Sha256Provider::new` takes three functions, `start`, `update` and `finish`, and no longer an `hmac`: a client builds HMAC-SHA256 from SHA-256 itself, and `Sha256Provider::hmac` does the same. A client copies a provider's state between calls, so the state must hold no pointer and nothing it must release; `Sha256State` states this. +- `Objects::with_signing_key` also hashes the padding blocks of the day's signing key once, so each signature hashes two fewer blocks. +- An S3 client sorts a request's signed headers once per request, and hashes its canonical request from the head it has written instead of writing each value a second time. It no longer signs a head that does not fit its buffers, which it refuses anyway. It also hashes the canonical request in blocks rather than in some 30 calls to the provider, copies short pieces without `memcpy`, checks a key for `.` and `..` segments in one pass, and percent-encodes a key with a table of 256 flags. An Azure client checks a key's rules in one pass instead of three. Both read a plain `Content-Range` in one pass, format numbers two digits at a time and hex-encode from a table of digit pairs, and an S3 client lists its own signed headers already in name order. +- An S3 request head holds each signed field as `name:value`, after a `\n` from the field before, so it needs two more bytes a signed field than before. The name and value spans leave the separators out. The client hashes the signed fields, which these lines make the canonical request's, in one piece. Other fields, and an Azure head, are laid out as before. +- `ResponseHead::insert` compares a header's name only with the known names of the same length, so a header that a head does not keep costs one comparison of lengths rather than up to 25 of names. +- In `borink-object-storage-crypto`, the trait `Sha256` requires `Copy` and no longer has `hmac`. `Sha256RustCrypto` keeps its own state over `sha2`'s compression function, and the crate no longer depends on `hmac`. The `zeroize` feature now changes only `wipe`: the signing key's hash states are copied with the client and never wiped. + ## 0.0.4 - 2026-10-03 ### Added diff --git a/Cargo.lock b/Cargo.lock index 3eeee979..72a378c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", - "zeroize", ] [[package]] @@ -36,7 +35,6 @@ name = "borink-object-storage-crypto" version = "0.0.4" dependencies = [ "borink-object-storage-proto", - "hmac", "hmac-sha256", "md-5", "sha1", @@ -98,12 +96,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cmov" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" - [[package]] name = "cpufeatures" version = "0.3.1" @@ -122,15 +114,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "ctutils" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] - [[package]] name = "digest" version = "0.11.3" @@ -139,8 +122,6 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer", "crypto-common", - "ctutils", - "zeroize", ] [[package]] @@ -160,15 +141,6 @@ dependencies = [ "wasi", ] -[[package]] -name = "hmac" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" -dependencies = [ - "digest", -] - [[package]] name = "hmac-sha256" version = "1.1.14" diff --git a/crates/object-storage-crypto/Cargo.toml b/crates/object-storage-crypto/Cargo.toml index 9f844d2f..e09b9225 100644 --- a/crates/object-storage-crypto/Cargo.toml +++ b/crates/object-storage-crypto/Cargo.toml @@ -16,13 +16,12 @@ borink-object-storage-proto = { path = "../object-storage-proto", version = "0.0 # because this crate needs the hasher alone: not `std`, `alloc` or the OID # tables. md-5 = { version = "0.11", optional = true, default-features = false } -# The SHA-256 and HMAC-SHA256 that SigV4 signs with, one provider per +# The SHA-256 that SigV4 signs with, one provider per # feature. Each links only its own crates. # # RustCrypto's, which uses the CPU's SHA extensions where it finds them and # needs no `std`. sha2 = { version = "0.11", optional = true, default-features = false } -hmac = { version = "0.13", optional = true, default-features = false } # One crate with no dependencies, in portable code. hmac-sha256 = { version = "1.1.14", optional = true, default-features = false } # Only the `sha1-rustcrypto` feature links this, with its default features @@ -44,14 +43,13 @@ crc32c = [] sha1-rustcrypto = ["dep:sha1"] # The `Md5RustCrypto` adapter over RustCrypto's `md-5`. md5-rustcrypto = ["dep:md-5"] -# `Sha256RustCrypto`, over RustCrypto's `sha2` and `hmac`. -sha256-rustcrypto = ["dep:sha2", "dep:hmac"] +# `Sha256RustCrypto`, over RustCrypto's `sha2`. +sha256-rustcrypto = ["dep:sha2"] # `Sha256Minimal`, over `hmac-sha256`. sha256-minimal = ["dep:hmac-sha256"] # `wipe` through the `zeroize` crate, in place of this crate's volatile -# writes. With `sha256-rustcrypto`, RustCrypto's SHA-256 and HMAC state is -# also wiped when it is dropped, which holds the key of each HMAC. -zeroize = ["dep:zeroize", "sha2?/zeroize", "hmac?/zeroize"] +# writes. +zeroize = ["dep:zeroize"] [lints.rust] missing_docs = "deny" diff --git a/crates/object-storage-crypto/src/lib.rs b/crates/object-storage-crypto/src/lib.rs index 0a3eeb85..ddd187ba 100644 --- a/crates/object-storage-crypto/src/lib.rs +++ b/crates/object-storage-crypto/src/lib.rs @@ -44,8 +44,8 @@ //! //! # SHA-256 //! -//! An S3 client signs each request with SHA-256 and HMAC-SHA256. Pass a -//! provider to +//! An S3 client signs each request with SHA-256, and with HMAC-SHA256, +//! which it builds from SHA-256. Pass a provider to //! [`s3::Objects::new`](borink_object_storage_proto::s3::Objects::new): //! //! ``` @@ -68,7 +68,7 @@ //! //! This crate has two providers, each behind a feature of its name: //! -//! - [`SHA256_RUSTCRYPTO`], over RustCrypto's `sha2` and `hmac`. It needs no +//! - [`SHA256_RUSTCRYPTO`], over RustCrypto's `sha2`. It needs no //! `std`, runs on every target, and uses the CPU's SHA extensions where it //! finds them. //! - [`SHA256_MINIMAL`], over `hmac-sha256`. That crate has no dependencies @@ -79,9 +79,10 @@ //! //! Pass [`wipe`] to `Credentials::new`, as above. A client calls it on the //! copy of the secret access key that it makes to derive its signing key. -//! The `zeroize` feature routes it through the `zeroize` crate. That -//! feature also has RustCrypto wipe its SHA-256 and HMAC state, which holds -//! the key of each HMAC. `hmac-sha256` leaves its state on the stack. +//! The `zeroize` feature routes it through the `zeroize` crate. A client +//! keeps its signing key as two SHA-256 states, which it copies with +//! itself and never wipes, and neither provider wipes the states of a +//! signature it leaves on the stack. //! //! [`borink-object-storage-proto`]: borink_object_storage_proto @@ -258,13 +259,17 @@ fn finish(mut state: ChecksumState) -> Digest { checksum.finish() } -/// SHA-256 computed one piece of the input at a time, and HMAC-SHA256 -/// computed in one call. +/// SHA-256 computed one piece of the input at a time. /// /// Implement this for a SHA-256 of your own, such as a library you already /// link, and pass the type to [`sha256_provider`]. The encoder creates the /// value with [`Default`], calls [`Self::update`] with each piece of the -/// input in order, and calls [`Self::finish`] once. +/// input in order, and calls [`Self::finish`] once. It builds HMAC-SHA256 +/// from these calls itself. +/// +/// The value is [`Copy`]: a client keeps the state of a hash after the +/// padding block of its signing key, and starts each signature from a copy +/// of it. /// /// # Size /// @@ -272,15 +277,12 @@ fn finish(mut state: ChecksumState) -> Digest { /// no larger than [`Sha256State::LEN`] bytes and aligned to no more than /// sixteen. [`sha256_provider`] fails to compile for a type that breaks /// either limit. -pub trait Sha256: Default { +pub trait Sha256: Default + Copy { /// Adds `bytes` to the input seen so far. fn update(&mut self, bytes: &[u8]); /// Returns the SHA-256 of everything added so far. fn finish(self) -> [u8; 32]; - - /// Returns the HMAC-SHA256 of `message` under `key`. - fn hmac(key: &[u8], message: &[u8]) -> [u8; 32]; } /// Sets every byte of `bytes` to zero, in writes that the compiler keeps. @@ -306,7 +308,7 @@ pub fn wipe(bytes: &mut [u8]) { } } -/// Returns a provider that computes SHA-256 and HMAC-SHA256 with `S`. +/// Returns a provider that computes SHA-256 with `S`. /// /// Pass it to /// [`s3::Objects::new`](borink_object_storage_proto::s3::Objects::new). @@ -321,7 +323,7 @@ pub fn wipe(bytes: &mut [u8]) { /// ```compile_fail /// use borink_object_storage_crypto::{Sha256, sha256_provider}; /// -/// #[derive(Default)] +/// #[derive(Clone, Copy, Default)] /// struct Wide([u64; 32]); /// /// impl Sha256 for Wide { @@ -329,20 +331,12 @@ pub fn wipe(bytes: &mut [u8]) { /// fn finish(self) -> [u8; 32] { /// [0; 32] /// } -/// fn hmac(_: &[u8], _: &[u8]) -> [u8; 32] { -/// [0; 32] -/// } /// } /// /// let too_wide = sha256_provider::(); /// ``` pub const fn sha256_provider() -> Sha256Provider { - Sha256Provider::new( - sha256_start::, - sha256_update::, - sha256_finish::, - S::hmac, - ) + Sha256Provider::new(sha256_start::, sha256_update::, sha256_finish::) } // An `S` lives in the state slot for the length of one hash. Each of the @@ -371,18 +365,18 @@ fn sha256_start() -> Sha256State { fn sha256_update(state: &mut Sha256State, bytes: &[u8]) { const { assert_sha256_fits::() }; // SAFETY: a client only ever calls this on a state that - // `sha256_start::` wrote an `S` into, and nothing between the two - // reads or writes the bytes, so the slot holds a live `S` that this - // borrow does not alias. + // `sha256_start::` wrote an `S` into, or on a copy of one, and only + // `update` writes the bytes in between. `S` is `Copy`, so a copy of + // the bytes is a valid `S` of its own, and this borrow of the slot + // aliases nothing. let sum = unsafe { &mut *state.as_mut_ptr().cast::() }; sum.update(bytes); } fn sha256_finish(mut state: Sha256State) -> [u8; 32] { const { assert_sha256_fits::() }; - // SAFETY: as in `sha256_update`, the slot holds a live `S`. The state is - // taken by value and is dropped here, so the `S` is moved out exactly - // once and the bytes are never read again. + // SAFETY: as in `sha256_update`, the slot holds a valid `S`, which is + // `Copy`, so reading it out leaves nothing to release. let sum = unsafe { state.as_mut_ptr().cast::().read() }; sum.finish() } diff --git a/crates/object-storage-crypto/src/sha256_minimal.rs b/crates/object-storage-crypto/src/sha256_minimal.rs index e5c09b75..72a4d19a 100644 --- a/crates/object-storage-crypto/src/sha256_minimal.rs +++ b/crates/object-storage-crypto/src/sha256_minimal.rs @@ -1,10 +1,10 @@ use crate::Sha256; -/// SHA-256 and HMAC-SHA256 from the `hmac-sha256` crate. +/// SHA-256 from the `hmac-sha256` crate. /// /// `hmac-sha256` has no dependencies and needs no `std`. It is portable /// code, and uses no SHA extensions of the CPU. -#[derive(Clone)] +#[derive(Clone, Copy)] pub struct Sha256Minimal(hmac_sha256::Hash); impl Default for Sha256Minimal { @@ -27,11 +27,6 @@ impl Sha256 for Sha256Minimal { fn finish(self) -> [u8; 32] { self.0.finalize() } - - fn hmac(key: &[u8], message: &[u8]) -> [u8; 32] { - // `hmac-sha256` takes the message first and the key second. - hmac_sha256::HMAC::mac(message, key) - } } // The same SHA-256 as the checksum of a write's content, which S3 checks as diff --git a/crates/object-storage-crypto/src/sha256_rustcrypto.rs b/crates/object-storage-crypto/src/sha256_rustcrypto.rs index 56854527..59b1291d 100644 --- a/crates/object-storage-crypto/src/sha256_rustcrypto.rs +++ b/crates/object-storage-crypto/src/sha256_rustcrypto.rs @@ -1,31 +1,89 @@ -use hmac::{KeyInit, Mac}; -use sha2::Digest as _; +use sha2::block_api::compress256; use crate::Sha256; -/// SHA-256 and HMAC-SHA256 from RustCrypto's `sha2` and `hmac` crates. +/// SHA-256 over the compression function of RustCrypto's `sha2` crate. /// /// `sha2` uses the SHA extensions of x86_64 and aarch64 when the CPU has /// them, and portable code elsewhere. It needs no `std`. -#[derive(Debug, Clone, Default)] -pub struct Sha256RustCrypto(sha2::Sha256); +/// +/// This type keeps the state, the partial block and the length itself, so +/// that it is [`Copy`], as a provider's SHA-256 must be. +#[derive(Debug, Clone, Copy)] +pub struct Sha256RustCrypto { + state: [u32; 8], + // The bytes of the block not yet compressed, `block[..filled]`. + block: [u8; 64], + filled: usize, + // The bytes added so far. + len: u64, +} + +// The initial hash value of FIPS 180-4, section 5.3.3. +const INITIAL: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, +]; + +impl Default for Sha256RustCrypto { + fn default() -> Self { + Self { + state: INITIAL, + block: [0; 64], + filled: 0, + len: 0, + } + } +} + +impl Sha256RustCrypto { + fn add(&mut self, mut bytes: &[u8]) { + self.len += bytes.len() as u64; + if self.filled > 0 { + let take = bytes.len().min(64 - self.filled); + self.block[self.filled..self.filled + take].copy_from_slice(&bytes[..take]); + self.filled += take; + bytes = &bytes[take..]; + if self.filled < 64 { + return; + } + compress256(&mut self.state, core::slice::from_ref(&self.block)); + self.filled = 0; + } + let (blocks, rest) = bytes.as_chunks::<64>(); + if !blocks.is_empty() { + compress256(&mut self.state, blocks); + } + self.block[..rest.len()].copy_from_slice(rest); + self.filled = rest.len(); + } + + // Pads the message as FIPS 180-4, section 5.1.1 says, and returns the + // state as bytes. + fn digest(mut self) -> [u8; 32] { + let bits = self.len.wrapping_mul(8); + self.block[self.filled] = 0x80; + self.block[self.filled + 1..].fill(0); + if self.filled >= 56 { + compress256(&mut self.state, core::slice::from_ref(&self.block)); + self.block = [0; 64]; + } + self.block[56..].copy_from_slice(&bits.to_be_bytes()); + compress256(&mut self.state, core::slice::from_ref(&self.block)); + let mut out = [0; 32]; + for (bytes, word) in out.chunks_exact_mut(4).zip(self.state) { + bytes.copy_from_slice(&word.to_be_bytes()); + } + out + } +} impl Sha256 for Sha256RustCrypto { fn update(&mut self, bytes: &[u8]) { - self.0.update(bytes); + self.add(bytes); } fn finish(self) -> [u8; 32] { - self.0.finalize().into() - } - - fn hmac(key: &[u8], message: &[u8]) -> [u8; 32] { - // HMAC takes a key of any length, so this never fails. - let Ok(mut mac) = as KeyInit>::new_from_slice(key) else { - unreachable!("HMAC accepts a key of any length"); - }; - mac.update(message); - mac.finalize().into_bytes().into() + self.digest() } } @@ -36,10 +94,10 @@ impl crate::Checksum for Sha256RustCrypto { borink_object_storage_proto::checksum::ChecksumKind::Sha256; fn update(&mut self, bytes: &[u8]) { - self.0.update(bytes); + self.add(bytes); } fn finish(self) -> borink_object_storage_proto::checksum::Digest { - borink_object_storage_proto::checksum::Digest::sha256(self.0.finalize().into()) + borink_object_storage_proto::checksum::Digest::sha256(self.digest()) } } diff --git a/crates/object-storage-proto/src/azure/batch.rs b/crates/object-storage-proto/src/azure/batch.rs index 8527cbc4..0eb0105b 100644 --- a/crates/object-storage-proto/src/azure/batch.rs +++ b/crates/object-storage-proto/src/azure/batch.rs @@ -141,7 +141,7 @@ impl<'a> Blobs<'a> { self.write_path(out, Some(object.key)); url::write_query_in_url(out, &[revision_parameter(object.revision)]); out.push(b" HTTP/1.1\r\nx-ms-date: "); - out.push(now.rfc1123().as_bytes()); + out.push(now.rfc1123_bytes()); out.push(b"\r\nAuthorization: Bearer "); out.push(self.token.as_bytes()); out.push(b"\r\nContent-Length: 0\r\n"); diff --git a/crates/object-storage-proto/src/azure/mod.rs b/crates/object-storage-proto/src/azure/mod.rs index 39531e68..69045a31 100644 --- a/crates/object-storage-proto/src/azure/mod.rs +++ b/crates/object-storage-proto/src/azure/mod.rs @@ -376,7 +376,7 @@ impl<'a> Blobs<'a> { } head.url(|out| self.write_url(out, key, query)); head.header("authorization", Bearer(self.token)); - head.header("x-ms-date", now.rfc1123().as_bytes()); + head.header("x-ms-date", now.rfc1123_bytes()); head.header("x-ms-version", VERSION.as_bytes()); if range != RequestedRange::Whole { head.header("range", range); @@ -1179,29 +1179,41 @@ pub(crate) fn validate_key(key: &str, namespace: AzureNamespace) -> Result<()> { if namespace == AzureNamespace::Flat && name_units(key) > MAX_BLOB_NAME_UNITS { return Err(InvalidPlan::KeyTooLong.into()); } + // One pass over the bytes finds what the three rules below look for. + let bytes = key.as_bytes(); + let (mut control, mut slashes, mut dot_ends) = (false, 0usize, false); + for (at, &byte) in bytes.iter().enumerate() { + control |= byte.is_ascii_control(); + if byte == b'/' { + slashes += 1; + dot_ends |= at > 0 && bytes[at - 1] == b'.'; + } + } + dot_ends |= bytes.last() == Some(&b'.'); // Azure refuses an ASCII control character in a name, with 400. Measured // for U+0001, U+000B, U+000C, U+000E and U+007F. Testing the bytes is // testing the characters: every byte of a character outside ASCII is 0x80 // or above, and none of those is an ASCII control. - if key.bytes().any(|byte| byte.is_ascii_control()) { + if control { return Err(InvalidPlan::KeyControlCharacter.into()); } // Azure takes a name of 255 `/`-delimited segments and refuses 256, // whatever the 254 in its documentation says. Measured by bisection // against the live service. // At most one segment per byte plus one; str lengths fit isize. - if key.matches('/').count() + 1 > MAX_BLOB_NAME_SEGMENTS { + if slashes + 1 > MAX_BLOB_NAME_SEGMENTS { return Err(InvalidPlan::KeyTooManySegments.into()); } // Azure drops a dot from the end of every segment of a name: `dot.` is // stored as `dot`, and `dotseg./x` as `dotseg/x`. Measured; see the live - // suite. + // suite. A segment ends in a dot where a dot comes before a `/` or ends + // the name. // // The same test covers a segment that is only dots. A host resolves `.` // and `..` out of the URL before it sends it, as the standard for URLs // requires, so those would name another object entirely; measured, as // `dots/../up` wrote `up`. - if key.split('/').any(|segment| segment.ends_with('.')) { + if dot_ends { return Err(InvalidPlan::KeyWouldBeNormalized.into()); } Ok(()) diff --git a/crates/object-storage-proto/src/common.rs b/crates/object-storage-proto/src/common.rs index 70e94e88..9e7f19bc 100644 --- a/crates/object-storage-proto/src/common.rs +++ b/crates/object-storage-proto/src/common.rs @@ -236,6 +236,12 @@ pub(crate) enum ContentRange { // This keeps inclusive lengths and exclusive ends representable even for /*. pub(crate) fn parse_content_range(value: &[u8]) -> Option { let rest = trim_ascii(value).strip_prefix(b"bytes ")?; + // `start-end/total` in digits, as both services write it, in one pass. + // Anything else takes the general reading below, which agrees with this + // one wherever this one reads a value. + if let Some(range) = digits_range(rest) { + return Some(range); + } let slash = rest.iter().rposition(|byte| *byte == b'/')?; let (spec, total) = (trim_ascii(&rest[..slash]), trim_ascii(&rest[slash + 1..])); let total = match total { @@ -254,6 +260,36 @@ pub(crate) fn parse_content_range(value: &[u8]) -> Option { Some(ContentRange::Satisfied { start, end, total }) } +// `start-end/total`, digits only, as a satisfied range. +fn digits_range(text: &[u8]) -> Option { + let (start, text) = leading_decimal(text)?; + let (end, text) = leading_decimal(text.strip_prefix(b"-")?)?; + let (total, text) = leading_decimal(text.strip_prefix(b"/")?)?; + (text.is_empty() && start <= end && end < total).then_some(ContentRange::Satisfied { + start, + end, + total: Some(total), + }) +} + +// The number that `text` starts with, of 1 to 19 digits, which cannot +// overflow, and the rest of `text`. One loop reads the digits and their +// value; a 20th digit is refused, so the wrapping arithmetic never wraps in a +// value that is returned. +fn leading_decimal(text: &[u8]) -> Option<(u64, &[u8])> { + let (mut value, mut len) = (0u64, 0); + while let Some(&byte) = text.get(len) { + let digit = byte.wrapping_sub(b'0'); + if digit > 9 || len == 19 { + break; + } + value = value.wrapping_mul(10).wrapping_add(u64::from(digit)); + len += 1; + } + let refused = len == 0 || text.get(len).is_some_and(u8::is_ascii_digit); + (!refused).then_some((value, &text[len..])) +} + // Reads a header value that carries text. Both services write // `Last-Modified` in ASCII, so a value that is not UTF-8 is a fault in the // head. @@ -276,6 +312,13 @@ pub(crate) fn decimal(bytes: &[u8]) -> Option { if bytes.is_empty() { return None; } + // Up to 19 digits cannot overflow, so they need no checked arithmetic. + if bytes.len() <= 19 { + return bytes.iter().try_fold(0u64, |value, byte| { + let digit = byte.checked_sub(b'0').filter(|digit| *digit <= 9)?; + Some(value * 10 + u64::from(digit)) + }); + } bytes.iter().try_fold(0u64, |value, byte| { let digit = byte.checked_sub(b'0').filter(|digit| *digit <= 9)?; value.checked_mul(10)?.checked_add(digit as u64) @@ -429,7 +472,7 @@ impl HeaderValue for ConditionValue<'_> { fn write_to(self, out: &mut dyn ByteSink) { match self { Self::ETag(tag) => out.push(tag), - Self::Time(seconds) => out.push(Timestamps::from_unix(seconds).rfc1123().as_bytes()), + Self::Time(seconds) => out.push(Timestamps::from_unix(seconds).rfc1123_bytes()), } } } diff --git a/crates/object-storage-proto/src/encoding.rs b/crates/object-storage-proto/src/encoding.rs index 8aaafc02..7fff6b23 100644 --- a/crates/object-storage-proto/src/encoding.rs +++ b/crates/object-storage-proto/src/encoding.rs @@ -9,15 +9,26 @@ const BASE64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz // Writes 32 bytes as lowercase hexadecimal, as SigV4 writes a digest. pub(crate) fn hex(bytes: &[u8; 32]) -> [u8; 64] { - const DIGITS: &[u8; 16] = b"0123456789abcdef"; let mut out = [0; 64]; - for (index, byte) in bytes.iter().enumerate() { - out[2 * index] = DIGITS[usize::from(byte >> 4)]; - out[2 * index + 1] = DIGITS[usize::from(byte & 0xF)]; + for (pair, byte) in out.chunks_exact_mut(2).zip(bytes) { + pair.copy_from_slice(&HEX_PAIRS[usize::from(*byte)]); } out } +// The two lowercase hexadecimal digits of each byte, so that a byte is one +// lookup rather than two. +static HEX_PAIRS: [[u8; 2]; 256] = { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut pairs = [[0; 2]; 256]; + let mut byte = 0; + while byte < 256 { + pairs[byte] = [DIGITS[byte >> 4], DIGITS[byte & 0xF]]; + byte += 1; + } + pairs +}; + // Reads one hexadecimal digit of either case. pub(crate) fn hex_digit(c: u8) -> Option { match c { diff --git a/crates/object-storage-proto/src/head.rs b/crates/object-storage-proto/src/head.rs index 3ef0e436..85802075 100644 --- a/crates/object-storage-proto/src/head.rs +++ b/crates/object-storage-proto/src/head.rs @@ -106,59 +106,110 @@ impl<'h> ResponseHead<'h> { /// Consumes one parsed header without copying its value. The first value wins. pub fn insert(&mut self, name: &str, value: &'h [u8]) { - let slot = if name.eq_ignore_ascii_case("content-length") { - &mut self.content_length - } else if name.eq_ignore_ascii_case("content-range") { - &mut self.content_range - } else if name.eq_ignore_ascii_case("content-encoding") { - &mut self.content_encoding - } else if name.eq_ignore_ascii_case("content-type") { - &mut self.content_type - } else if name.eq_ignore_ascii_case("content-md5") { - &mut self.content_md5 - } else if name.eq_ignore_ascii_case("content-language") { - &mut self.content_language - } else if name.eq_ignore_ascii_case("content-disposition") { - &mut self.content_disposition - } else if name.eq_ignore_ascii_case("cache-control") { - &mut self.cache_control - } else if name.eq_ignore_ascii_case("x-amz-storage-class") - || name.eq_ignore_ascii_case("x-ms-access-tier") + if let Some(slot) = self.slot(name) + && slot.is_none() { - &mut self.storage_class - } else if name.eq_ignore_ascii_case("etag") { - &mut self.e_tag - } else if name.eq_ignore_ascii_case("last-modified") { - &mut self.last_modified - } else if name.eq_ignore_ascii_case("x-ms-version-id") - || name.eq_ignore_ascii_case("x-amz-version-id") - { - &mut self.version - } else if name.eq_ignore_ascii_case("x-ms-snapshot") { - &mut self.snapshot - } else if name.eq_ignore_ascii_case("x-ms-copy-id") { - &mut self.copy_id - } else if name.eq_ignore_ascii_case("x-ms-copy-status") { - &mut self.copy_status - } else if name.eq_ignore_ascii_case("x-amz-restore") - || name.eq_ignore_ascii_case("x-ms-archive-status") - { - &mut self.restore_status - } else if name.eq_ignore_ascii_case("x-ms-error-code") { - &mut self.error_code - } else if name.eq_ignore_ascii_case("x-ms-request-id") - || name.eq_ignore_ascii_case("x-amz-request-id") - { - &mut self.request_id - } else if name.eq_ignore_ascii_case("x-amz-id-2") { - &mut self.extended_request_id - } else { - return; - }; - if slot.is_none() { *slot = Some(value); } } + + // The field that the header `name` fills, in any case, or `None` for a + // header that a head does not keep. A name is compared only with the + // known names of its length, so most headers that a service sends and a + // head does not keep cost one comparison of lengths. + fn slot(&mut self, name: &str) -> Option<&mut Option<&'h [u8]>> { + let name = name.as_bytes(); + let is = |known: &Known| known.is(name); + Some(match name.len() { + 4 if is(const { &Known::new("etag") }) => &mut self.e_tag, + 10 if is(const { &Known::new("x-amz-id-2") }) => &mut self.extended_request_id, + 11 if is(const { &Known::new("content-md5") }) => &mut self.content_md5, + 12 if is(const { &Known::new("content-type") }) => &mut self.content_type, + 12 if is(const { &Known::new("x-ms-copy-id") }) => &mut self.copy_id, + 13 if is(const { &Known::new("content-range") }) => &mut self.content_range, + 13 if is(const { &Known::new("cache-control") }) => &mut self.cache_control, + 13 if is(const { &Known::new("last-modified") }) => &mut self.last_modified, + 13 if is(const { &Known::new("x-ms-snapshot") }) => &mut self.snapshot, + 13 if is(const { &Known::new("x-amz-restore") }) => &mut self.restore_status, + 14 if is(const { &Known::new("content-length") }) => &mut self.content_length, + 15 if is(const { &Known::new("x-ms-version-id") }) => &mut self.version, + 15 if is(const { &Known::new("x-ms-error-code") }) => &mut self.error_code, + 15 if is(const { &Known::new("x-ms-request-id") }) => &mut self.request_id, + 16 if is(const { &Known::new("content-encoding") }) => &mut self.content_encoding, + 16 if is(const { &Known::new("content-language") }) => &mut self.content_language, + 16 if is(const { &Known::new("x-ms-access-tier") }) => &mut self.storage_class, + 16 if is(const { &Known::new("x-amz-version-id") }) => &mut self.version, + 16 if is(const { &Known::new("x-ms-copy-status") }) => &mut self.copy_status, + 16 if is(const { &Known::new("x-amz-request-id") }) => &mut self.request_id, + 19 if is(const { &Known::new("content-disposition") }) => &mut self.content_disposition, + 19 if is(const { &Known::new("x-amz-storage-class") }) => &mut self.storage_class, + 19 if is(const { &Known::new("x-ms-archive-status") }) => &mut self.restore_status, + _ => return None, + }) + } +} + +// A header name that a head keeps, ready to compare: its bytes as words, and +// the case bit `0x20` of each byte that is a letter. A byte matches if it +// equals the known byte once that bit is set, which is exact for each byte, +// and a name of 4 to 24 bytes is two or three overlapping words, so a +// comparison is a few loads whatever the length. +struct Known { + len: usize, + // The words at the start, in the middle (only past 16 bytes) and at the + // end, each as `(bytes, case bits)`. Below 8 bytes they are 4 wide. + words: [(u64, u64); 3], +} + +impl Known { + const fn new(name: &str) -> Self { + let name = name.as_bytes(); + let len = name.len(); + assert!(4 <= len && len <= 24, "a known name is 4 to 24 bytes"); + let width = if len < 8 { 4 } else { 8 }; + let middle = if len > 16 { 8 } else { 0 }; + Self { + len, + words: [ + Self::word(name, 0, width), + Self::word(name, middle, width), + Self::word(name, len - width, width), + ], + } + } + + const fn word(name: &[u8], at: usize, width: usize) -> (u64, u64) { + let (mut bytes, mut fold, mut i) = (0u64, 0u64, 0); + while i < width { + let byte = name[at + i]; + bytes |= (byte as u64) << (8 * i); + if byte.is_ascii_lowercase() { + fold |= 0x20 << (8 * i); + } + i += 1; + } + (bytes, fold) + } + + // Whether `name`, as long as this one, is this name in any case. Inlined + // with a constant name, the lengths fold away and each word is one load. + #[inline] + fn is(&self, name: &[u8]) -> bool { + let narrow = self.len < 8; + let width = if narrow { 4 } else { 8 }; + let middle = if self.len > 16 { 8 } else { 0 }; + let word = |at: usize| -> u64 { + if narrow { + u32::from_le_bytes(name[at..at + 4].try_into().expect("4 bytes")).into() + } else { + u64::from_le_bytes(name[at..at + 8].try_into().expect("8 bytes")) + } + }; + let [first, mid, last] = self.words; + word(0) | first.1 == first.0 + && word(middle) | mid.1 == mid.0 + && word(self.len - width) | last.1 == last.0 + } } #[cfg(test)] diff --git a/crates/object-storage-proto/src/lib.rs b/crates/object-storage-proto/src/lib.rs index ffebed79..9729b346 100644 --- a/crates/object-storage-proto/src/lib.rs +++ b/crates/object-storage-proto/src/lib.rs @@ -46,8 +46,8 @@ //! The same plans drive S3. Create an [`s3::Objects`] //! client and call its `encode_*`, `accept_*` and `fill_listing` methods, as //! the [`s3`] module describes. It signs each request with AWS Signature -//! Version 4, using the SHA-256 and HMAC-SHA256 of a -//! [`sigv4::Sha256Provider`] that you pass. +//! Version 4, using the SHA-256 of a [`sigv4::Sha256Provider`] that you +//! pass. //! //! # Example //! diff --git a/crates/object-storage-proto/src/request.rs b/crates/object-storage-proto/src/request.rs index 6e5dd705..b69e376a 100644 --- a/crates/object-storage-proto/src/request.rs +++ b/crates/object-storage-proto/src/request.rs @@ -35,9 +35,11 @@ pub struct Span { } impl Span { - fn of(self, bytes: &str) -> &str { + // The text of this part of a head. Only the part is checked as UTF-8, so + // a host that reads the head by range pays for no check. + fn of(self, bytes: &[u8]) -> &str { // HeadWriter bounds start + len by the finished buffer's length. - &bytes[self.start..self.start + self.len] + text(&bytes[self.start..self.start + self.len]) } } @@ -94,7 +96,7 @@ impl core::fmt::Display for Method { /// Drop the request before reusing either region. #[derive(Debug, Clone, Copy)] pub struct WireRequest<'r> { - bytes: &'r str, + bytes: &'r [u8], method: Method, url: Span, headers: &'r [HeaderSpan], @@ -183,12 +185,13 @@ impl<'a> Writer<'a> { Self { bytes, position: 0 } } + #[inline] pub(crate) fn push(&mut self, value: &[u8]) { // Counting continues past capacity; aliased inputs can exceed usize // in aggregate. Saturation preserves monotonicity and cannot fit a slice. let end = self.position.saturating_add(value.len()); if end <= self.bytes.len() { - self.bytes[self.position..end].copy_from_slice(value); + copy_short(&mut self.bytes[self.position..end], value); } self.position = end; } @@ -197,6 +200,22 @@ impl<'a> Writer<'a> { self.position } + // Writes with `write` and returns where its bytes are. + pub(crate) fn part(&mut self, write: impl FnOnce(&mut Self)) -> Span { + let start = self.position; + write(self); + // push only increases or saturates position, so subtraction cannot underflow. + Span { + start, + len: self.position - start, + } + } + + // Whether every byte pushed so far is in the buffer. + fn fits(&self) -> bool { + self.position <= self.bytes.len() + } + pub(crate) fn finish(self) -> Option<&'a [u8]> { (self.position <= self.bytes.len()).then(|| &self.bytes[..self.position]) } @@ -225,6 +244,34 @@ impl<'a> HeadWriter<'a> { self.out.position() } + // Whether every byte and every header so far is in the buffers, so that + // `Self::written` and `Self::header_spans` can read them. + pub(crate) fn fits(&self) -> bool { + self.out.fits() && self.count <= self.headers.len() + } + + // The bytes of a part already written, in a head that fits. + pub(crate) fn written(&self, span: Span) -> &[u8] { + &self.out.bytes[span.start..span.start + span.len] + } + + // Writes `bytes` over a part already written, as long, in a head that + // fits. + pub(crate) fn overwrite(&mut self, span: Span, bytes: &[u8]) { + self.out.bytes[span.start..span.start + span.len].copy_from_slice(bytes); + } + + // The number of headers written so far. + pub(crate) fn header_count(&self) -> usize { + self.count + } + + // The headers written so far, from the one at `first`, in a head that + // fits. + pub(crate) fn header_spans(&self, first: usize) -> &[HeaderSpan] { + &self.headers[first..self.count] + } + pub(crate) fn capacity(&self) -> crate::CapacityError { crate::CapacityError { required: self.position(), @@ -253,6 +300,31 @@ impl<'a> HeadWriter<'a> { ) { let name = self.part(name); let value = self.part(value); + self.record(name, value); + } + + // A field written as a line of a SigV4 canonical request: `name:value`, + // after a `\n` that ends the field before, which the spans leave out. A + // run of such fields is the signed headers' lines but for the last `\n`, + // so a signer hashes the run in one piece. It costs two bytes a field, so + // only a client that signs writes its fields so. + pub(crate) fn line_with(&mut self, name: &str, value: impl FnOnce(&mut Writer<'a>)) { + self.line_parts(|out| out.push(name.as_bytes()), value); + } + + pub(crate) fn line_parts( + &mut self, + name: impl FnOnce(&mut Writer<'a>), + value: impl FnOnce(&mut Writer<'a>), + ) { + self.out.push(b"\n"); + let name = self.part(name); + self.out.push(b":"); + let value = self.part(value); + self.record(name, value); + } + + fn record(&mut self, name: Span, value: Span) { if let Some(slot) = self.headers.get_mut(self.count) { *slot = HeaderSpan { name, value }; } @@ -286,7 +358,7 @@ impl<'a> HeadWriter<'a> { Payload::Slice(&bytes[span.start..span.start + span.len]) }); Some(WireRequest { - bytes: text(&bytes[..head_end]), + bytes: &bytes[..head_end], method, url, headers, @@ -296,13 +368,39 @@ impl<'a> HeadWriter<'a> { } fn part(&mut self, write: impl FnOnce(&mut Writer<'a>)) -> Span { - let start = self.out.position(); - write(&mut self.out); - // push only increases or saturates position, so subtraction cannot underflow. - Span { - start, - len: self.out.position() - start, + self.out.part(write) + } +} + +// Copies `from` into `to`, which is as long. A copy of up to 32 bytes is two +// overlapping copies of a fixed length, which compile to a few moves, where +// `copy_from_slice` of a length known only at run time calls the C library's +// `memcpy`, which costs more than the copy for so few bytes. Slicing `to` to +// that length first lets the compiler drop the bounds checks of each copy. +#[inline] +pub(crate) fn copy_short(to: &mut [u8], from: &[u8]) { + let len = from.len(); + let to = &mut to[..len]; + match len { + 0 => {} + 1..=3 => { + to[0] = from[0]; + to[len / 2] = from[len / 2]; + to[len - 1] = from[len - 1]; + } + 4..=7 => { + to[..4].copy_from_slice(&from[..4]); + to[len - 4..].copy_from_slice(&from[len - 4..]); + } + 8..=16 => { + to[..8].copy_from_slice(&from[..8]); + to[len - 8..].copy_from_slice(&from[len - 8..]); } + 17..=32 => { + to[..16].copy_from_slice(&from[..16]); + to[len - 16..].copy_from_slice(&from[len - 16..]); + } + _ => to.copy_from_slice(from), } } @@ -361,6 +459,17 @@ impl HeaderValue for U64Decimal { } } +// The two decimal digits of each number below 100. +static DIGIT_PAIRS: [[u8; 2]; 100] = { + let mut pairs = [[0; 2]; 100]; + let mut value = 0; + while value < 100 { + pairs[value] = [b'0' + (value / 10) as u8, b'0' + (value % 10) as u8]; + value += 1; + } + pairs +}; + // Unlike the fixed-width date fields in `time`, range offsets need the shortest // decimal representation. This buffer owns that representation without allocating. pub(crate) struct U64Decimal { @@ -369,17 +478,27 @@ pub(crate) struct U64Decimal { } impl U64Decimal { + // Kept out of line: the compiler unrolls the digit loop, and inlined at + // each of the 21 places that write a number it made one range header + // alone 2,400 instructions long. + #[inline(never)] pub(crate) fn new(mut value: u64) -> Self { let mut bytes = [0; 20]; let mut start = bytes.len(); - loop { - // A u64 has at most 20 decimal digits; each division consumes one. + // Two digits a division, from a table of them. A u64 has at most 20 + // decimal digits, so `start` stays in the array. + while value >= 100 { + let pair = DIGIT_PAIRS[(value % 100) as usize]; + value /= 100; + start -= 2; + bytes[start..start + 2].copy_from_slice(&pair); + } + if value >= 10 { + start -= 2; + bytes[start..start + 2].copy_from_slice(&DIGIT_PAIRS[value as usize]); + } else { start -= 1; - bytes[start] = b'0' + (value % 10) as u8; - value /= 10; - if value == 0 { - break; - } + bytes[start] = b'0' + value as u8; } Self { bytes, start } } diff --git a/crates/object-storage-proto/src/s3/mod.rs b/crates/object-storage-proto/src/s3/mod.rs index 71452dc6..99ef28a9 100644 --- a/crates/object-storage-proto/src/s3/mod.rs +++ b/crates/object-storage-proto/src/s3/mod.rs @@ -31,7 +31,7 @@ //! }; //! # use borink_object_storage_proto::sigv4::{Sha256Provider, Sha256State}; //! # const SHA256: Sha256Provider = -//! # Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32], |_, _| [0; 32]); +//! # Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32]); //! //! # fn main() -> Result<(), Box> { //! let bucket = Bucket::new( @@ -93,8 +93,9 @@ //! //! Every request is signed for the bucket's region. Signing takes five //! HMACs, and four of them depend only on the day. Call -//! [`Objects::with_signing_key`] to compute those four once. A client whose -//! key is for another day computes them again for each request. +//! [`Objects::with_signing_key`] to compute those four once, and to hash the +//! padding of the key of the fifth once. A client whose key is for another +//! day computes them again for each request. //! //! The signature covers the `host` header, which your HTTP client writes from //! the URL. Send the URL as the request holds it. An endpoint that names the @@ -125,7 +126,7 @@ //! # use borink_object_storage_proto::sigv4::{Credentials, wipe_best_effort}; //! # use borink_object_storage_proto::sigv4::{Sha256Provider, Sha256State}; //! # const SHA256: Sha256Provider = -//! # Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32], |_, _| [0; 32]); +//! # Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32]); //! use borink_object_storage_proto::{ //! EntryKind, HeaderSpan, ListEntry, PhysicalList, Timestamps, layered, //! }; @@ -198,7 +199,7 @@ //! # use borink_object_storage_proto::sigv4::{Credentials, wipe_best_effort}; //! # use borink_object_storage_proto::sigv4::{Sha256Provider, Sha256State}; //! # const SHA256: Sha256Provider = -//! # Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32], |_, _| [0; 32]); +//! # Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32]); //! use borink_object_storage_proto::s3::{PartRef, PhysicalCreateUpload, PhysicalStagePart}; //! use borink_object_storage_proto::{ //! CommitHeadOutcome, HeaderSpan, Payload, PhysicalCommit, ResponseHead, StageHeadOutcome, @@ -350,7 +351,7 @@ use crate::{ DeleteShape, Error, Failure, GetHeadOutcome, GetKind, GetShape, HeaderSpan, InvalidPlan, ListEntry, ListHeadOutcome, ListInclude, ListMarker, Listing, MetadataPair, Method, ObjectMeta, Payload, PhysicalDelete, PhysicalGet, PhysicalList, PhysicalPut, PutHeadOutcome, PutShape, - RequestedRange, ResponseFault, ResponseHead, Result, Revision, ServiceErrorKind, Tag, + RequestedRange, ResponseFault, ResponseHead, Result, Revision, ServiceErrorKind, Span, Tag, Timestamps, TransactionalChecksum, WireRequest, WriteOptions, }; @@ -1081,6 +1082,9 @@ pub(crate) struct SignedCopy<'p> { #[derive(Clone, Copy)] enum SignedValue<'a> { Bytes(&'a [u8]), + // Bytes that this crate formatted, such as a digest in hexadecimal, + // which hold no space for SigV4 to fold. + Formatted(&'a [u8]), Host, Range, Date, @@ -1098,7 +1102,7 @@ enum Header<'a> { impl<'a> Objects<'a> { /// Creates a client that signs requests to `bucket` with `credentials`, - /// computing SHA-256 and HMAC-SHA256 with `sha256`. + /// computing SHA-256 with `sha256`. pub const fn new( bucket: Bucket<'a>, credentials: Credentials<'a>, @@ -1449,8 +1453,9 @@ impl<'a> Objects<'a> { }) } - // Writes the URL and every signed header. A measuring pass writes a signature - // of zeros, which is as long as a real one. + // Writes the URL and every signed header, then signs them. The signature + // is written as zeros, which are as long, and a writing pass replaces + // them once the head is written. pub(crate) fn write_head( &self, head: &mut HeadWriter<'_>, @@ -1458,19 +1463,19 @@ impl<'a> Objects<'a> { pass: Pass, now: &Timestamps, ) { - let signature = if pass == Pass::Measure { - [b'0'; 64] - } else { - self.signature(signed, now) - }; + let token = self.token(); + let headers = SignedHeaders::of(signed, token); + let mut parts = SignedParts::default(); head.url(|out| { out.push(self.bucket.scheme.as_str().as_bytes()); out.push(b"://"); - self.bucket.write_host(out); - self.bucket.write_path(out, signed.key); - url::write_query_in_url(out, signed.query); + parts.host = out.part(|out| self.bucket.write_host(out)); + parts.path = out.part(|out| self.bucket.write_path(out, signed.key)); + if signed.query.iter().any(Option::is_some) { + out.push(b"?"); + parts.query = out.part(|out| url::write_query(out, signed.query)); + } }); - let token = self.token(); head.header_with("authorization", |out| { out.push(sigv4::ALGORITHM.as_bytes()); out.push(b" Credential="); @@ -1478,24 +1483,25 @@ impl<'a> Objects<'a> { out.push(b"/"); self.write_scope(out, now); out.push(b", SignedHeaders="); - write_signed_names(out, signed, token); + parts.names = out.part(|out| write_signed_names(out, &headers)); out.push(b", Signature="); - out.push(&signature); + parts.signature = out.part(|out| out.push(&[b'0'; 64])); }); - for header in ordered_headers(signed, token) { + parts.first_header = head.header_count(); + for header in headers.iter() { match header { Header::Fixed(_, SignedValue::Host) => {} - Header::Fixed(name, value) => head.header_with(name, |out| match value { - SignedValue::Bytes(bytes) => out.push(bytes), + Header::Fixed(name, value) => head.line_with(name, |out| match value { + SignedValue::Bytes(bytes) | SignedValue::Formatted(bytes) => out.push(bytes), SignedValue::Condition(value) => value.write_to(out), SignedValue::Range => write_range(out, signed.range), - SignedValue::Date => out.push(now.iso8601().as_bytes()), + SignedValue::Date => out.push(now.iso8601_bytes()), SignedValue::Tags => write_tags(out, signed.tags), SignedValue::CopySource => self.write_copy_source(out, signed), SignedValue::CopyRange => write_copy_range(out, signed), SignedValue::Host => {} }), - Header::Meta(pair) => head.header_parts( + Header::Meta(pair) => head.line_parts( |out| { out.push(METADATA_PREFIX.as_bytes()); for byte in pair.name.bytes() { @@ -1506,6 +1512,11 @@ impl<'a> Objects<'a> { ), } } + // A head that does not fit is refused, so it needs no signature. + if pass == Pass::Write && head.fits() { + let signature = self.signature(head, &parts, signed, &headers, now); + head.overwrite(parts.signature, &signature); + } } // The value of `x-amz-copy-source`: the source's bucket, or the client's @@ -1612,7 +1623,7 @@ impl<'a> Objects<'a> { } fn write_scope(&self, out: &mut dyn ByteSink, now: &Timestamps) { - out.push(now.date().as_bytes()); + out.push(now.date()); out.push(b"/"); out.push(self.bucket.region.as_bytes()); out.push(b"/"); @@ -1620,11 +1631,70 @@ impl<'a> Objects<'a> { out.push(b"/aws4_request"); } - // Returns the signature of the request, as lowercase hexadecimal. The - // canonical request is hashed as it is written, so it is never held. - fn signature(&self, signed: &Signed<'_>, now: &Timestamps) -> [u8; 64] { + // Returns the signature of the head, as lowercase hexadecimal. + // + // The canonical request of SigV4 is the method and the parts of the head + // that the signature covers: the path, the query, and each signed header + // with its value. Each is hashed from the bytes that the head holds, so + // nothing is written twice and the canonical request is never held. A + // value that the head sends with runs of spaces is signed with them + // folded, as SigV4 asks. + fn signature( + &self, + head: &HeadWriter<'_>, + parts: &SignedParts, + signed: &Signed<'_>, + headers: &SignedHeaders<'_>, + now: &Timestamps, + ) -> [u8; 64] { let mut sum = self.sha256.start(); - self.write_canonical_request(&mut sum, signed, now); + sum.update(signed.method.as_str().as_bytes()); + sum.update(b"\n"); + sum.update(head.written(parts.path)); + sum.update(b"\n"); + sum.update(head.written(parts.query)); + sum.update(b"\n"); + // The head writes every signed header but `host` in this order, right + // after `authorization`, as `name:value` lines: the canonical lines, + // unless SigV4 folds spaces in a value. The HTTP client writes `host` + // from the URL. + let sent = head.header_spans(parts.first_header); + let as_sent = headers + .iter() + .filter(|header| !matches!(header, Header::Fixed(_, SignedValue::Host))) + .zip(sent) + .all(|(header, span)| { + !folds_spaces(header, self.bucket.service) + || !needs_folding(head.written(span.value)) + }); + if as_sent { + // The fields before `host` in one piece, `host`, then the rest. + let before = headers + .iter() + .take_while(|header| !matches!(header, Header::Fixed(_, SignedValue::Host))) + .count(); + let lines = |sum: &mut crate::sigv4::Sum, spans: &[HeaderSpan]| { + if let (Some(first), Some(last)) = (spans.first(), spans.last()) { + let end = last.value.start + last.value.len; + sum.update(head.written(Span { + start: first.name.start, + len: end - first.name.start, + })); + sum.update(b"\n"); + } + }; + lines(&mut sum, &sent[..before]); + sum.update(b"host:"); + sum.update(head.written(parts.host)); + sum.update(b"\n"); + lines(&mut sum, &sent[before..]); + } else { + self.write_folded_headers(&mut sum, head, parts, headers); + } + sum.update(b"\n"); + sum.update(head.written(parts.names)); + sum.update(b"\n"); + sum.update(signed.content_sha256); let canonical = sum.finish(); let key = match self.signing_key { Some(key) if key.covers(now) => key, @@ -1639,50 +1709,33 @@ impl<'a> Objects<'a> { ) } - // The canonical request of SigV4. - fn write_canonical_request( + // The signed headers of the canonical request one at a time, with the + // spaces of each value that SigV4 folds folded. + fn write_folded_headers( &self, - out: &mut dyn ByteSink, - signed: &Signed<'_>, - now: &Timestamps, + sum: &mut crate::sigv4::Sum, + head: &HeadWriter<'_>, + parts: &SignedParts, + headers: &SignedHeaders<'_>, ) { - out.push(signed.method.as_str().as_bytes()); - out.push(b"\n"); - self.bucket.write_path(out, signed.key); - out.push(b"\n"); - // The URL carries the query in canonical form, so this is the same - // text. - url::write_query(out, signed.query); - out.push(b"\n"); - let token = self.token(); - for header in ordered_headers(signed, token) { - write_header_name(out, header); - out.push(b":"); - match header { - Header::Fixed(_, SignedValue::Bytes(bytes)) => write_canonical_value(out, bytes), - Header::Fixed(_, SignedValue::Condition(ConditionValue::ETag(tag))) => { - write_canonical_value(out, tag); + let mut sent = head.header_spans(parts.first_header).iter(); + for header in headers.iter() { + if let Header::Fixed(_, SignedValue::Host) = header { + sum.update(b"host:"); + sum.update(head.written(parts.host)); + } else { + let span = sent.next().expect("the head writes each signed header"); + sum.update(head.written(span.name)); + sum.update(b":"); + let value = head.written(span.value); + if folds_spaces(header, self.bucket.service) { + write_canonical_value(sum, value); + } else { + sum.update(value); } - // An HTTP date has no space at either end, nor a run of them. - Header::Fixed(_, SignedValue::Condition(value)) => value.write_to(out), - Header::Fixed(_, SignedValue::Host) => self.bucket.write_host(out), - Header::Fixed(_, SignedValue::Range) => write_range(out, signed.range), - Header::Fixed(_, SignedValue::Date) => out.push(now.iso8601().as_bytes()), - // The encoded form holds no space, so it is its canonical form. - Header::Fixed(_, SignedValue::Tags) => write_tags(out, signed.tags), - Header::Fixed(_, SignedValue::CopySource) => self.write_copy_source(out, signed), - Header::Fixed(_, SignedValue::CopyRange) => write_copy_range(out, signed), - Header::Meta(pair) if encodes(pair.value, self.bucket.service) => { - write_metadata_value(out, pair.value, self.bucket.service) - } - Header::Meta(pair) => write_canonical_value(out, pair.value.as_bytes()), } - out.push(b"\n"); + sum.update(b"\n"); } - out.push(b"\n"); - write_signed_names(out, signed, token); - out.push(b"\n"); - out.push(signed.content_sha256); } /// Reads the response head of a GET or a HEAD and reports what to do @@ -2122,81 +2175,150 @@ fn kind_for_code(code: &[u8]) -> Option { }) } -// Every signed header in the order of its name: the fixed set sorted, merged -// with the metadata sorted by lowercase name. -fn ordered_headers<'s>( +// Every signed header of one request in the order of its name: the fixed +// set, sorted once per encode, merged with the metadata sorted by lowercase +// name. The canonical request, the `authorization` header and the head each +// walk it. +struct SignedHeaders<'s> { + fixed: [Header<'s>; 24], + count: usize, + metadata: &'s [MetadataPair<'s>], +} + +impl<'s> SignedHeaders<'s> { + fn of(signed: &'s Signed<'s>, token: Option<(&'static str, &'s str)>) -> Self { + let mut fixed = [Header::Fixed("", SignedValue::Host); 24]; + let mut count = 0; + // The headers this crate names come in the order of their names, so + // they are appended; only the plan's own are sorted in among them. + for (name, value) in fixed_headers(signed, token) { + fixed[count] = Header::Fixed(name, value); + count += 1; + } + debug_assert!(fixed[..count].is_sorted_by_key(|header| match header { + Header::Fixed(name, _) => *name, + Header::Meta(_) => "", + })); + for (name, value) in signed + .headers + .iter() + .map(|(name, value)| (*name, SignedValue::Bytes(value))) + { + let mut at = count; + while at > 0 && matches!(fixed[at - 1], Header::Fixed(previous, _) if previous > name) { + fixed[at] = fixed[at - 1]; + at -= 1; + } + fixed[at] = Header::Fixed(name, value); + count += 1; + } + Self { + fixed, + count, + metadata: signed.metadata, + } + } + + fn iter(&self) -> impl Iterator> + '_ { + let plain = self.metadata.is_empty(); + let mut fixed = self.fixed[..self.count].iter().copied().peekable(); + let mut metadata = sorted_metadata(self.metadata).peekable(); + // Without metadata, which most requests carry none of, there is + // nothing to merge. + core::iter::from_fn(move || { + if plain { + return fixed.next(); + } + match (fixed.peek(), metadata.peek()) { + (None, None) => None, + (Some(_), None) => fixed.next(), + (Some(Header::Fixed(name, _)), Some(pair)) + if name.bytes().cmp(metadata_header_name(pair)) == Ordering::Less => + { + fixed.next() + } + _ => metadata.next().map(Header::Meta), + } + }) + } +} + +// Where the head holds what the signature covers, beside the headers. +#[derive(Default)] +struct SignedParts { + host: Span, + path: Span, + // The query without its `?`, empty if the URL has none. + query: Span, + // The names in `SignedHeaders=`. + names: Span, + signature: Span, + // The index of the first signed header after `authorization`. + first_header: usize, +} + +// Whether SigV4 signs the value of `header` with each run of spaces folded, +// rather than as the head sends it. The values that this crate formats hold +// no such run; a value from the plan may. +fn folds_spaces(header: Header<'_>, service: Service) -> bool { + match header { + Header::Fixed(_, SignedValue::Bytes(_)) + | Header::Fixed(_, SignedValue::Condition(ConditionValue::ETag(_))) => true, + Header::Fixed(..) => false, + Header::Meta(pair) => !encodes(pair.value, service), + } +} + +// Whether SigV4 would change `value` by folding its spaces: a space at +// either end, or two in a row. +fn needs_folding(value: &[u8]) -> bool { + value.first() == Some(&b' ') + || value.last() == Some(&b' ') + || value.windows(2).any(|pair| pair == b" ") +} + +// The signed headers that this crate names, other than metadata, in the +// order of their names: the four conditions all sort between `host` and +// `range`, the copy's headers between `x-amz-content-sha256` and +// `x-amz-date`, and either token between `x-amz-date` and `x-amz-tagging`. +fn fixed_headers<'s>( signed: &'s Signed<'s>, token: Option<(&'static str, &'s str)>, -) -> impl Iterator> { - let mut fixed = [Header::Fixed("", SignedValue::Host); 24]; - let mut count = 0; - let entries = [ +) -> impl Iterator)> { + let copy_condition = signed.copy.and_then(|copy| { + let name = match copy.source.condition { + ConditionKind::None => return None, + ConditionKind::IfMatch => "x-amz-copy-source-if-match", + ConditionKind::IfNoneMatch => "x-amz-copy-source-if-none-match", + ConditionKind::IfModifiedSince => "x-amz-copy-source-if-modified-since", + ConditionKind::IfUnmodifiedSince => "x-amz-copy-source-if-unmodified-since", + }; + Some((name, SignedValue::Condition(copy.source.condition_value?))) + }); + [ Some(("host", SignedValue::Host)), - Some(( - "x-amz-content-sha256", - SignedValue::Bytes(signed.content_sha256), - )), - Some(("x-amz-date", SignedValue::Date)), - token.map(|(name, token)| (name, SignedValue::Bytes(token.as_bytes()))), - (signed.range != RequestedRange::Whole).then_some(("range", SignedValue::Range)), condition_header(signed.condition) .zip(signed.condition_value) .map(|(name, value)| (name, SignedValue::Condition(value))), - (!signed.tags.is_empty()).then_some(("x-amz-tagging", SignedValue::Tags)), + (signed.range != RequestedRange::Whole).then_some(("range", SignedValue::Range)), + Some(( + "x-amz-content-sha256", + SignedValue::Formatted(signed.content_sha256), + )), signed .copy .map(|_| ("x-amz-copy-source", SignedValue::CopySource)), + copy_condition, signed .copy .filter(|copy| copy.range != RequestedRange::Whole) .map(|_| ("x-amz-copy-source-range", SignedValue::CopyRange)), - signed.copy.and_then(|copy| { - let name = match copy.source.condition { - ConditionKind::None => return None, - ConditionKind::IfMatch => "x-amz-copy-source-if-match", - ConditionKind::IfNoneMatch => "x-amz-copy-source-if-none-match", - ConditionKind::IfModifiedSince => "x-amz-copy-source-if-modified-since", - ConditionKind::IfUnmodifiedSince => "x-amz-copy-source-if-unmodified-since", - }; - Some((name, SignedValue::Condition(copy.source.condition_value?))) - }), + Some(("x-amz-date", SignedValue::Date)), + token.map(|(name, token)| (name, SignedValue::Bytes(token.as_bytes()))), + (!signed.tags.is_empty()).then_some(("x-amz-tagging", SignedValue::Tags)), ] .into_iter() .flatten() - .chain( - signed - .headers - .iter() - .map(|(name, value)| (*name, SignedValue::Bytes(value))), - ); - for (name, value) in entries { - let mut at = count; - while at > 0 && matches!(fixed[at - 1], Header::Fixed(previous, _) if previous > name) { - fixed[at] = fixed[at - 1]; - at -= 1; - } - fixed[at] = Header::Fixed(name, value); - count += 1; - } - let mut index = 0; - let mut metadata = sorted_metadata(signed.metadata).peekable(); - core::iter::from_fn(move || { - let next = (index < count).then(|| fixed[index]); - match (next, metadata.peek()) { - (None, None) => None, - (Some(header), None) => { - index += 1; - Some(header) - } - (Some(Header::Fixed(name, value)), Some(pair)) - if name.bytes().cmp(metadata_header_name(pair)) == Ordering::Less => - { - index += 1; - Some(Header::Fixed(name, value)) - } - _ => metadata.next().map(Header::Meta), - } - }) } fn metadata_header_name<'p>(pair: &'p MetadataPair<'p>) -> impl Iterator + 'p { @@ -2217,12 +2339,8 @@ fn write_header_name(out: &mut dyn ByteSink, header: Header<'_>) { } // The names of the signed headers, in order, separated by `;`. -fn write_signed_names( - out: &mut dyn ByteSink, - signed: &Signed<'_>, - token: Option<(&'static str, &str)>, -) { - for (index, header) in ordered_headers(signed, token).enumerate() { +fn write_signed_names(out: &mut dyn ByteSink, headers: &SignedHeaders<'_>) { + for (index, header) in headers.iter().enumerate() { if index != 0 { out.push(b";"); } @@ -2334,12 +2452,23 @@ pub(crate) fn validate_key(key: &str) -> Result<()> { } // A host resolves `.` and `..` segments out of the URL before it sends // it, which would address another object. - if key.split('/').any(|segment| matches!(segment, "." | "..")) { + if has_dot_segment(key.as_bytes()) { return Err(InvalidPlan::KeyWouldBeNormalized.into()); } Ok(()) } +// Whether `key` has a segment, between slashes or at either end, that is +// `.` or `..`. One pass, looking only at a dot that starts a segment. +fn has_dot_segment(key: &[u8]) -> bool { + let ends = |at: usize| key.get(at).is_none_or(|&byte| byte == b'/'); + key.iter().enumerate().any(|(at, &byte)| { + byte == b'.' + && (at == 0 || key[at - 1] == b'/') + && (ends(at + 1) || (key[at + 1] == b'.' && ends(at + 2))) + }) +} + fn validate_get(get: &PhysicalGet<'_>) -> Result<()> { validate_key(get.key)?; match get.range { diff --git a/crates/object-storage-proto/src/sigv4.rs b/crates/object-storage-proto/src/sigv4.rs index 8bf43697..81d15f83 100644 --- a/crates/object-storage-proto/src/sigv4.rs +++ b/crates/object-storage-proto/src/sigv4.rs @@ -1,10 +1,11 @@ //! AWS Signature Version 4, and the SHA-256 and HMAC-SHA256 it is built //! from. //! -//! This crate computes neither. An [`s3::Objects`](crate::s3::Objects) client -//! signs every request with the [`Sha256Provider`] you give it. The +//! This crate computes no SHA-256 itself. An +//! [`s3::Objects`](crate::s3::Objects) client signs every request with the +//! [`Sha256Provider`] you give it, and builds HMAC-SHA256 from it. The //! `borink-object-storage-crypto` crate has two, and a trait that turns any -//! other implementation into one. To build a provider by hand, pass four +//! other implementation into one. To build a provider by hand, pass three //! functions to [`Sha256Provider::new`]. //! //! A request is signed over its method, its path, the headers this crate @@ -23,6 +24,12 @@ use crate::{Error, Result, Timestamps}; /// provider's `start`, then to each `update`, then to `finish`, and drops /// it. Only the provider reads or writes the bytes. /// +/// The encoder also copies a slot between calls, and goes on from each copy +/// on its own. A client keeps the state of a hash after the padding block of +/// its signing key, and starts each signature from a copy. A provider's +/// state must therefore stay valid when its bytes are copied: it holds no +/// pointer and nothing that it must release. +/// /// The slot is [`Self::LEN`] bytes long and aligned to sixteen bytes. It is /// on the encoder's stack: nothing here is allocated. #[derive(Clone, Copy)] @@ -65,11 +72,11 @@ impl fmt::Debug for Sha256State { } } -/// An implementation of SHA-256 and HMAC-SHA256, which a client signs with. +/// An implementation of SHA-256, which a client signs with. /// -/// A provider holds four function pointers. The first three compute one -/// SHA-256 a piece at a time, keeping their state in a [`Sha256State`]. The -/// fourth computes one HMAC-SHA256 in one call. +/// A provider holds three function pointers, which compute one SHA-256 a +/// piece at a time, keeping their state in a [`Sha256State`]. A client +/// builds HMAC-SHA256 from them. /// /// A provider holds no borrows, so it can be a `const`. The providers of /// `borink-object-storage-crypto` are. @@ -78,27 +85,23 @@ pub struct Sha256Provider { start: fn() -> Sha256State, update: fn(&mut Sha256State, &[u8]), finish: fn(Sha256State) -> [u8; 32], - hmac: fn(&[u8], &[u8]) -> [u8; 32], } impl Sha256Provider { - /// Creates a provider from the calls that compute SHA-256 and - /// HMAC-SHA256. + /// Creates a provider from the calls that compute SHA-256. /// /// For one SHA-256, the encoder calls `start` once, `update` once for - /// each piece of the input in order, and `finish` once. `hmac` takes the - /// key first and the message second. + /// each piece of the input in order, and `finish` once, on the state + /// or on a copy of it: see [`Sha256State`]. pub const fn new( start: fn() -> Sha256State, update: fn(&mut Sha256State, &[u8]), finish: fn(Sha256State) -> [u8; 32], - hmac: fn(key: &[u8], message: &[u8]) -> [u8; 32], ) -> Self { Self { start, update, finish, - hmac, } } @@ -111,14 +114,11 @@ impl Sha256Provider { /// Returns the HMAC-SHA256 of `message` under `key`. pub fn hmac(&self, key: &[u8], message: &[u8]) -> [u8; 32] { - (self.hmac)(key, message) + HmacKey::new(key, self, wipe_best_effort).mac(self, |sum| sum.update(message)) } pub(crate) fn start(&self) -> Sum { - Sum { - provider: *self, - state: (self.start)(), - } + Sum::resume(self, (self.start)()) } } @@ -128,10 +128,14 @@ impl fmt::Debug for Sha256Provider { } } -// A SHA-256 in progress. +// A SHA-256 in progress. A canonical request arrives in some 30 pieces of a +// few bytes, so pieces are gathered into a block's worth before the provider +// sees them: a call through its function pointer costs more than the copy. pub(crate) struct Sum { provider: Sha256Provider, state: Sha256State, + pending: [u8; 64], + filled: usize, } impl crate::request::ByteSink for Sum { @@ -141,13 +145,49 @@ impl crate::request::ByteSink for Sum { } impl Sum { + // Goes on from `state`, a copy of the state of an earlier hash. + fn resume(provider: &Sha256Provider, state: Sha256State) -> Self { + Self { + provider: *provider, + state, + pending: [0; 64], + filled: 0, + } + } + pub(crate) fn update(&mut self, bytes: &[u8]) { - (self.provider.update)(&mut self.state, bytes); + if bytes.len() <= self.pending.len() - self.filled { + let end = self.filled + bytes.len(); + crate::request::copy_short(&mut self.pending[self.filled..end], bytes); + self.filled = end; + return; + } + self.flush(); + if bytes.len() >= self.pending.len() { + (self.provider.update)(&mut self.state, bytes); + } else { + crate::request::copy_short(&mut self.pending[..bytes.len()], bytes); + self.filled = bytes.len(); + } } - pub(crate) fn finish(self) -> [u8; 32] { + fn flush(&mut self) { + if self.filled > 0 { + (self.provider.update)(&mut self.state, &self.pending[..self.filled]); + self.filled = 0; + } + } + + pub(crate) fn finish(mut self) -> [u8; 32] { + self.flush(); (self.provider.finish)(self.state) } + + // The provider's state, with every piece given to it. + fn into_state(mut self) -> Sha256State { + self.flush(); + self.state + } } /// The most bytes of secret access key that [`Credentials::new`] takes. @@ -304,11 +344,56 @@ pub(crate) const ALGORITHM: &str = "AWS4-HMAC-SHA256"; pub(crate) const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; +// The key of an HMAC-SHA256, absorbed: the states of SHA-256 after its +// inner and after its outer padding block. Each message under the key goes +// on from copies of them, so the padding is hashed once per key rather than +// once per message. +#[derive(Clone, Copy)] +struct HmacKey { + inner: Sha256State, + outer: Sha256State, +} + +impl HmacKey { + // `wipe` clears the copies of the key that this makes. + fn new(key: &[u8], provider: &Sha256Provider, wipe: fn(&mut [u8])) -> Self { + // RFC 2104: a key longer than a block is hashed first. + let mut pad = [0u8; 64]; + if key.len() > pad.len() { + let mut digest = provider.hash(key); + pad[..digest.len()].copy_from_slice(&digest); + wipe(&mut digest); + } else { + pad[..key.len()].copy_from_slice(key); + } + pad.iter_mut().for_each(|byte| *byte ^= 0x36); + let mut inner = provider.start(); + inner.update(&pad); + pad.iter_mut().for_each(|byte| *byte ^= 0x36 ^ 0x5c); + let mut outer = provider.start(); + outer.update(&pad); + wipe(&mut pad); + Self { + inner: inner.into_state(), + outer: outer.into_state(), + } + } + + // The HMAC of the message that `write` adds. + fn mac(&self, provider: &Sha256Provider, write: impl FnOnce(&mut Sum)) -> [u8; 32] { + let mut inner = Sum::resume(provider, self.inner); + write(&mut inner); + let mut outer = Sum::resume(provider, self.outer); + outer.update(&inner.finish()); + outer.finish() + } +} + // The key that signs every request of one day, in one region, for one -// service. Deriving it takes four HMACs. -#[derive(Clone, Copy, PartialEq, Eq)] +// service, absorbed as an HMAC key. Deriving it takes four HMACs. +#[derive(Clone, Copy)] pub(crate) struct SigningKey { - key: [u8; 32], + key: HmacKey, date: [u8; 8], } @@ -321,25 +406,34 @@ impl SigningKey { provider: &Sha256Provider, ) -> Self { let date = now.date(); + let wipe = credentials.wipe; let mut seed = [0u8; 4 + MAX_SECRET_LEN]; let secret = credentials.secret.as_bytes(); // `Credentials::new` bounds the secret by MAX_SECRET_LEN. seed[..4].copy_from_slice(b"AWS4"); seed[4..4 + secret.len()].copy_from_slice(secret); - let mut key = provider.hmac(&seed[..4 + secret.len()], date.as_bytes()); - // The caller's `&str` and the provider's HMAC state still hold the - // secret. This crate wipes only the copy it made. - (credentials.wipe)(&mut seed); - key = provider.hmac(&key, region.as_bytes()); - key = provider.hmac(&key, service.as_bytes()); - key = provider.hmac(&key, b"aws4_request"); + let seed_key = HmacKey::new(&seed[..4 + secret.len()], provider, wipe); + // The caller's `&str` still holds the secret. This crate wipes only + // the copies it made. + wipe(&mut seed); + let mut key = seed_key.mac(provider, |sum| sum.update(date)); + for part in [region.as_bytes(), service.as_bytes(), b"aws4_request"] { + let mut next = HmacKey::new(&key, provider, wipe).mac(provider, |sum| sum.update(part)); + core::mem::swap(&mut key, &mut next); + wipe(&mut next); + } + let absorbed = HmacKey::new(&key, provider, wipe); + wipe(&mut key); let mut day = [0; 8]; - day.copy_from_slice(date.as_bytes()); - Self { key, date: day } + day.copy_from_slice(date); + Self { + key: absorbed, + date: day, + } } pub(crate) fn covers(&self, now: &Timestamps) -> bool { - self.date == *now.date().as_bytes() + self.date == *now.date() } // Signs a request whose canonical form hashes to `canonical`, and @@ -352,27 +446,23 @@ impl SigningKey { now: &Timestamps, provider: &Sha256Provider, ) -> [u8; 64] { - // The string to sign is 122 bytes plus the region and the service. A - // bucket bounds the region by MAX_REGION_LEN, and a service name is - // one of this crate's own. - let mut text = [0u8; 122 + MAX_REGION_LEN + 16]; - let mut at = 0; - for piece in [ - ALGORITHM.as_bytes(), - b"\n", - now.iso8601().as_bytes(), - b"\n", - now.date().as_bytes(), - b"/", - region.as_bytes(), - b"/", - service.as_bytes(), - b"/aws4_request\n", - &crate::encoding::hex(canonical), - ] { - text[at..at + piece.len()].copy_from_slice(piece); - at += piece.len(); - } - crate::encoding::hex(&provider.hmac(&self.key, &text[..at])) + let mac = self.key.mac(provider, |sum| { + for piece in [ + ALGORITHM.as_bytes(), + b"\n", + now.iso8601_bytes(), + b"\n", + now.date(), + b"/", + region.as_bytes(), + b"/", + service.as_bytes(), + b"/aws4_request\n", + &crate::encoding::hex(canonical), + ] { + sum.update(piece); + } + }); + crate::encoding::hex(&mac) } } diff --git a/crates/object-storage-proto/src/time.rs b/crates/object-storage-proto/src/time.rs index 89e0e6f1..b8a9811d 100644 --- a/crates/object-storage-proto/src/time.rs +++ b/crates/object-storage-proto/src/time.rs @@ -84,14 +84,24 @@ impl Timestamps { core::str::from_utf8(&self.rfc1123).expect("HTTP dates are ASCII") } + // The bytes of `Self::rfc1123`, for a writer that needs no `str`. + pub(crate) fn rfc1123_bytes(&self) -> &[u8] { + &self.rfc1123 + } + /// Returns the time as `YYYYMMDDTHHMMSSZ`, for `x-amz-date`. pub fn iso8601(&self) -> &str { core::str::from_utf8(&self.iso8601).expect("ISO 8601 basic dates are ASCII") } + // The bytes of `Self::iso8601`, for a writer that needs no `str`. + pub(crate) fn iso8601_bytes(&self) -> &[u8] { + &self.iso8601 + } + // The `YYYYMMDD` that a SigV4 credential scope names. - pub(crate) fn date(&self) -> &str { - &self.iso8601()[..8] + pub(crate) fn date(&self) -> &[u8] { + &self.iso8601[..8] } } @@ -131,7 +141,7 @@ mod tests { assert_eq!(&text[17..], b"00:00:00 GMT", "{year}-{month}-{day}"); let iso = actual.iso8601(); assert_eq!(iso, std::format!("{year:04}{month:02}{day:02}T000000Z")); - assert_eq!(actual.date(), &iso[..8]); + assert_eq!(actual.date(), &iso.as_bytes()[..8]); if (year, month, day) == (2400, 12, 31) { break; diff --git a/crates/object-storage-proto/src/url.rs b/crates/object-storage-proto/src/url.rs index 0ba79e64..f624258b 100644 --- a/crates/object-storage-proto/src/url.rs +++ b/crates/object-storage-proto/src/url.rs @@ -15,36 +15,41 @@ use crate::encoding::hex_digit; use crate::request::{ByteSink, U64Decimal}; // A set of ASCII bytes, as `percent-encoding`'s `AsciiSet`. `percent_encode` -// escapes the bytes in the set and every byte outside ASCII. +// escapes the bytes in the set and every byte outside ASCII. It is kept as +// one flag for each of the 256 bytes, the bytes outside ASCII set, so that +// asking about a byte is one load. pub(crate) struct AsciiSet { - mask: [u32; 4], + escape: [bool; 256], } impl AsciiSet { - const fn contains(&self, byte: u8) -> bool { - self.mask[byte as usize / 32] & (1 << (byte % 32)) != 0 - } - fn should_percent_encode(&self, byte: u8) -> bool { - !byte.is_ascii() || self.contains(byte) + self.escape[byte as usize] } const fn add(&self, byte: u8) -> Self { - let mut mask = self.mask; - mask[byte as usize / 32] |= 1 << (byte % 32); - Self { mask } + let mut escape = self.escape; + escape[byte as usize] = true; + Self { escape } } const fn remove(&self, byte: u8) -> Self { - let mut mask = self.mask; - mask[byte as usize / 32] &= !(1 << (byte % 32)); - Self { mask } + let mut escape = self.escape; + escape[byte as usize] = false; + Self { escape } } } -// The C0 controls, 0x00 to 0x1F, and DEL, 0x7F. -const CONTROLS: &AsciiSet = &AsciiSet { - mask: [!0, 0, 0, 1 << (0x7F % 32)], +// The C0 controls, 0x00 to 0x1F, and DEL, 0x7F, with every byte outside +// ASCII. +const CONTROLS: &AsciiSet = &{ + let mut escape = [false; 256]; + let mut byte = 0; + while byte < escape.len() { + escape[byte] = byte < 0x20 || byte >= 0x7F; + byte += 1; + } + AsciiSet { escape } }; // Every ASCII byte that is not a letter or a digit. diff --git a/crates/object-storage-proto/tests/s3_client.rs b/crates/object-storage-proto/tests/s3_client.rs index caa68799..c2c4e901 100644 --- a/crates/object-storage-proto/tests/s3_client.rs +++ b/crates/object-storage-proto/tests/s3_client.rs @@ -18,15 +18,13 @@ use borink_object_storage_proto::{ const SECRET: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; const TOKEN: &str = "session-token"; -const ZEROS: Sha256Provider = - Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32], |_, _| [0; 32]); +const ZEROS: Sha256Provider = Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32]); // A provider that fails the test if the encoder hashes anything. const REFUSES: Sha256Provider = Sha256Provider::new( Sha256State::uninit, |_, _| panic!("the encoder hashed content"), |_| panic!("the encoder finished a hash"), - |_, _| panic!("the encoder computed an HMAC"), ); fn bucket() -> Bucket<'static> { diff --git a/crates/object-storage-proto/tests/s3_delete.rs b/crates/object-storage-proto/tests/s3_delete.rs index 6bbb9be5..3e125472 100644 --- a/crates/object-storage-proto/tests/s3_delete.rs +++ b/crates/object-storage-proto/tests/s3_delete.rs @@ -12,8 +12,7 @@ use borink_object_storage_proto::{ const SECRET: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; const TOKEN: &str = "session-token"; -const ZEROS: Sha256Provider = - Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32], |_, _| [0; 32]); +const ZEROS: Sha256Provider = Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32]); fn bucket() -> Bucket<'static> { Bucket::new("https://s3.example.com", "bucket", "auto", Service::Aws).unwrap() diff --git a/crates/object-storage-proto/tests/s3_get.rs b/crates/object-storage-proto/tests/s3_get.rs index 77e4bccb..3635606f 100644 --- a/crates/object-storage-proto/tests/s3_get.rs +++ b/crates/object-storage-proto/tests/s3_get.rs @@ -14,8 +14,7 @@ use borink_object_storage_proto::{ const SECRET: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; const TOKEN: &str = "session-token"; -const ZEROS: Sha256Provider = - Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32], |_, _| [0; 32]); +const ZEROS: Sha256Provider = Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32]); fn bucket() -> Bucket<'static> { Bucket::new("https://s3.example.com", "bucket", "auto", Service::Aws).unwrap() diff --git a/crates/object-storage-proto/tests/s3_list.rs b/crates/object-storage-proto/tests/s3_list.rs index 2aa37ff8..54a70c6a 100644 --- a/crates/object-storage-proto/tests/s3_list.rs +++ b/crates/object-storage-proto/tests/s3_list.rs @@ -9,8 +9,7 @@ use borink_object_storage_proto::{ ResponseFault, Timestamps, layered, }; -const ZEROS: Sha256Provider = - Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32], |_, _| [0; 32]); +const ZEROS: Sha256Provider = Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32]); fn objects(addressing: Addressing) -> Objects<'static> { let bucket = Bucket::new("https://s3.example.com", "bucket", "auto", Service::Aws) diff --git a/crates/object-storage-proto/tests/s3_put.rs b/crates/object-storage-proto/tests/s3_put.rs index cada3316..40f4c31f 100644 --- a/crates/object-storage-proto/tests/s3_put.rs +++ b/crates/object-storage-proto/tests/s3_put.rs @@ -14,8 +14,7 @@ use borink_object_storage_proto::{ const SECRET: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; const TOKEN: &str = "session-token"; -const ZEROS: Sha256Provider = - Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32], |_, _| [0; 32]); +const ZEROS: Sha256Provider = Sha256Provider::new(Sha256State::uninit, |_, _| {}, |_| [0; 32]); const MD5: ChecksumProvider = ChecksumProvider::new( ChecksumKind::Md5, From 45e86897131d9c1e701e39fb5e9dcc00be0a4ae2 Mon Sep 17 00:00:00 2001 From: Tip ten Brink <75669206+tiptenbrink@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:02:42 +0200 Subject: [PATCH 02/14] Write reads, writes, removals and listings from templates. A template is the part of a request that is the same for each object, written once into a buffer the caller owns. Read tests that object-tests already grades are removed, and the rules each service repeats are shared. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 7 - Cargo.toml | 3 +- checks/no-allocator/src/lib.rs | 258 ++- crates/object-storage-c/src/step.rs | 4 +- crates/object-storage-crypto/src/lib.rs | 1 + crates/object-storage-crypto/src/sigv4.rs | 195 ++ crates/object-storage-crypto/tests/encoder.rs | 91 +- .../tests/s3_checksums.rs | 103 +- crates/object-storage-crypto/tests/sigv4.rs | 589 +++--- .../object-storage-proto/src/azure/batch.rs | 31 +- .../object-storage-proto/src/azure/blocks.rs | 76 +- crates/object-storage-proto/src/azure/copy.rs | 51 +- crates/object-storage-proto/src/azure/mod.rs | 1469 ++++++++------- .../object-storage-proto/src/azure/restore.rs | 26 +- crates/object-storage-proto/src/azure/tags.rs | 68 +- .../src/azure/template.rs | 251 +++ crates/object-storage-proto/src/checksum.rs | 16 +- crates/object-storage-proto/src/common.rs | 130 +- crates/object-storage-proto/src/error.rs | 24 +- crates/object-storage-proto/src/head.rs | 2 +- crates/object-storage-proto/src/layered.rs | 182 +- crates/object-storage-proto/src/lib.rs | 179 +- crates/object-storage-proto/src/outcome.rs | 30 +- crates/object-storage-proto/src/request.rs | 25 +- crates/object-storage-proto/src/s3/batch.rs | 29 +- crates/object-storage-proto/src/s3/copy.rs | 32 +- crates/object-storage-proto/src/s3/mod.rs | 1658 +++++++++-------- crates/object-storage-proto/src/s3/parts.rs | 95 +- crates/object-storage-proto/src/s3/restore.rs | 26 +- crates/object-storage-proto/src/s3/tags.rs | 36 +- .../object-storage-proto/src/s3/template.rs | 591 ++++++ crates/object-storage-proto/src/sigv4.rs | 4 + crates/object-storage-proto/src/template.rs | 689 +++++++ crates/object-storage-proto/src/time.rs | 2 +- crates/object-storage-proto/src/types.rs | 597 +++--- crates/object-storage-proto/src/url.rs | 36 +- .../tests/azure_delete.rs | 323 +--- .../object-storage-proto/tests/azure_get.rs | 437 ++--- .../tests/azure_head_offsets.rs | 252 ++- .../object-storage-proto/tests/azure_list.rs | 450 ++--- .../object-storage-proto/tests/azure_put.rs | 774 ++------ .../tests/azure_responses.rs | 259 +-- .../object-storage-proto/tests/s3_client.rs | 187 +- .../object-storage-proto/tests/s3_delete.rs | 106 -- crates/object-storage-proto/tests/s3_get.rs | 235 +-- crates/object-storage-proto/tests/s3_list.rs | 194 +- crates/object-storage-proto/tests/s3_put.rs | 300 ++- hosts/object-tests/src/azure.rs | 248 +-- hosts/object-tests/src/lib.rs | 205 +- hosts/object-tests/src/listing.rs | 45 +- hosts/object-tests/src/s3.rs | 491 +++-- hosts/ureq/examples/azure_get.rs | 6 +- hosts/ureq/examples/azure_list.rs | 36 +- hosts/ureq/examples/azure_put.rs | 128 +- hosts/ureq/examples/s3_get.rs | 9 +- hosts/ureq/examples/s3_list.rs | 43 +- hosts/ureq/src/azure.rs | 285 +-- hosts/ureq/src/lib.rs | 91 +- hosts/ureq/src/s3.rs | 303 ++- hosts/ureq/tests/azure_get.rs | 10 +- hosts/ureq/tests/azure_list.rs | 27 +- hosts/ureq/tests/s3.rs | 9 +- 62 files changed, 7029 insertions(+), 6030 deletions(-) create mode 100644 crates/object-storage-crypto/src/sigv4.rs create mode 100644 crates/object-storage-proto/src/azure/template.rs create mode 100644 crates/object-storage-proto/src/s3/template.rs create mode 100644 crates/object-storage-proto/src/template.rs delete mode 100644 crates/object-storage-proto/tests/s3_delete.rs diff --git a/Cargo.lock b/Cargo.lock index 72a378c8..acedb1e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,13 +23,6 @@ dependencies = [ "hybrid-array", ] -[[package]] -name = "borink-object-storage-c" -version = "0.0.0" -dependencies = [ - "borink-object-storage-proto", -] - [[package]] name = "borink-object-storage-crypto" version = "0.0.4" diff --git a/Cargo.toml b/Cargo.toml index 5fdf18fb..049da659 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,8 @@ # inferred from an edition. resolver = "3" members = [ - "crates/*", + "crates/object-storage-proto", + "crates/object-storage-crypto", "checks/no-allocator", "hosts/ureq", "hosts/object-tests", diff --git a/checks/no-allocator/src/lib.rs b/checks/no-allocator/src/lib.rs index b8303c6a..db747f75 100644 --- a/checks/no-allocator/src/lib.rs +++ b/checks/no-allocator/src/lib.rs @@ -2,12 +2,14 @@ #![cfg_attr(not(feature = "std"), no_std)] -use borink_object_storage_proto::s3::{Bucket, Objects, PayloadHash, Service}; +use borink_object_storage_crypto::sigv4; +use borink_object_storage_proto::azure; +use borink_object_storage_proto::s3::{self, Bucket, PayloadSigning, Service}; use borink_object_storage_proto::sigv4::Credentials; use borink_object_storage_proto::{ - BlobProperty, Blobs, ChecksumKind, Container, GetHeadOutcome, HeaderSpan, ListEntry, - ListHeadOutcome, ListMarker, MetadataPair, Payload, PhysicalGet, PhysicalList, PhysicalPut, - PropertySet, ResponseHead, Timestamps, TransactionalChecksum, WriteOptions, layered, + AzureNamespace, BlobProperty, ChecksumKind, ChecksumValue, Container, GetHeadOutcome, GetItem, + GetOptions, ListEntry, ListHeadOutcome, ListItem, ListMarker, ListOptions, MetadataPair, + Payload, PropertySet, PutItem, PutOptions, ResponseHead, Timestamps, layered, }; // Required to link this no_std artifact; the exported check does not panic. @@ -22,38 +24,43 @@ fn panic(_: &core::panic::PanicInfo<'_>) -> ! { /// Exercises request construction and response interpretation in a reachable symbol. #[unsafe(no_mangle)] pub extern "C" fn object_storage_without_an_allocator() -> usize { - let mut request_headers = [HeaderSpan::default(); 8]; let Ok(container) = Container::new("https://account", "container") else { return 1; }; - let Ok(blobs) = Blobs::new(container, "token") else { - return 2; - }; - let blobs = blobs - .with_checksum(borink_object_storage_crypto::CRC64) - .with_checksum(borink_object_storage_crypto::MD5_RUSTCRYPTO); - let mut buf = [0; 256]; let now = Timestamps::from_unix(1_787_400_000); - let get = PhysicalGet::new("object"); - let Ok(request) = blobs.encode_get(&mut buf, &mut request_headers, &get, &now) else { + let options = GetOptions::new(); + let mut bytes = [0; 256]; + let Ok(template) = azure::prepare_get( + &container, + "token", + AzureNamespace::Unknown, + &options, + &mut bytes, + ) else { + return 3; + }; + let get = GetItem::new("object"); + let mut buf = [0; 512]; + let Ok(request) = azure::encode_get(&template, &bytes, &get, &now, &mut buf) else { return 3; }; let headers = [("content-length", b"4".as_slice())]; - let Ok(GetHeadOutcome::Body { body, .. }) = - blobs.accept_get_head(get.shape(), ResponseHead::from_headers(200, headers)) - else { + let Ok(GetHeadOutcome::Body { body, .. }) = azure::accept_get_head( + get.shape(&options), + ResponseHead::from_headers(200, headers), + ) else { return 4; }; request.url().len() + body.expected_len.unwrap_or_default() as usize - + listing(&blobs, &now) - + computed_checksums(&blobs, &now) + + listing(&container, &now) + + computed_checksums(&container, &now) + signed_requests(&now) } -// A signed request hashes its canonical form as it is written, and derives -// its key from the secret in a fixed array on the stack. Neither provider -// asks for memory either. +// A signed request derives its key from the secret in a fixed array on the +// stack, and hashes its canonical request where it was written. Neither +// provider asks for memory either. fn signed_requests(now: &Timestamps) -> usize { let Ok(bucket) = Bucket::new("https://s3.example.com", "bucket", "auto", Service::Aws) else { return 10; @@ -65,81 +72,126 @@ fn signed_requests(now: &Timestamps) -> usize { ) else { return 11; }; - let mut written = 0; - for sha256 in [ - borink_object_storage_crypto::SHA256_RUSTCRYPTO, - borink_object_storage_crypto::SHA256_MINIMAL, - ] { - let objects = Objects::new(bucket, credentials, sha256) - .with_signing_key(now) - .with_checksum(borink_object_storage_crypto::MD5_RUSTCRYPTO); - let mut request_headers = [HeaderSpan::default(); 12]; - let mut buf = [0; 768]; - let get = PhysicalGet::new("object"); - let Ok(request) = objects.encode_get(&mut buf, &mut request_headers, &get, now) else { - return 12; - }; - written += request.url().len(); - let headers = [("content-length", b"4".as_slice())]; - let Ok(GetHeadOutcome::Body { .. }) = - objects.accept_get_head(get.shape(), ResponseHead::from_headers(200, headers)) - else { - return 13; - }; - let metadata = [MetadataPair { - name: "Colour", - value: "dark red", - }]; - let put = PhysicalPut { - metadata: &metadata, - options: WriteOptions { - checksum: Some(TransactionalChecksum::Compute(ChecksumKind::Md5)), - ..WriteOptions::default() - }, - ..PhysicalPut::new("object") - }; - let mut request_headers = [HeaderSpan::default(); 12]; - let mut buf = [0; 768]; - let Ok(request) = objects.encode_put( - &mut buf, - &mut request_headers, - &put, - Payload::Slice(b"0123456789"), - PayloadHash::Compute, - now, - ) else { - return 14; - }; - written += request.url().len(); - } + let headers = [("content-length", b"4".as_slice())]; + let options = GetOptions::new(); + let Ok(GetHeadOutcome::Body { .. }) = s3::accept_get_head( + Service::Aws, + GetItem::new("object").shape(&options), + ResponseHead::from_headers(200, headers), + ) else { + return 13; + }; + signed::(&bucket, &credentials, now) + + signed::(&bucket, &credentials, now) +} + +// A read and a write from templates, signed. The templates, the requests and +// every hash state are on this stack frame. +fn signed( + bucket: &Bucket<'_>, + credentials: &Credentials<'_>, + now: &Timestamps, +) -> usize { + let options = GetOptions::new(); + let mut bytes = [0; 768]; + let Ok(template) = s3::prepare_get(bucket, credentials, &options, &mut bytes) else { + return 15; + }; + let Ok(key) = sigv4::signing_key::(&template, &bytes, credentials, now) else { + return 16; + }; + let Ok(stamp) = sigv4::stamp(&key, &template, &bytes, now) else { + return 17; + }; + let mut buf = [0; 1024]; + let Ok(unsigned) = s3::encode_get(&template, &bytes, &GetItem::new("object"), now, &mut buf) + else { + return 12; + }; + let mut written = sigv4::sign(&stamp, &unsigned, &mut buf).url().len(); + + let metadata = [MetadataPair { + name: "Colour", + value: "dark red", + }]; + let options = PutOptions { + metadata: &metadata, + checksum: Some(ChecksumKind::Md5), + ..PutOptions::default() + }; + let mut bytes = [0; 1024]; + let Ok(template) = s3::prepare_put( + bucket, + credentials, + &options, + PayloadSigning::Signed, + &mut bytes, + ) else { + return 18; + }; + let content = b"0123456789"; + let item = PutItem { + checksum: Some(ChecksumValue::Digest(digest::< + borink_object_storage_crypto::Md5RustCrypto, + >(content))), + ..PutItem::new("object", Payload::Slice(content)) + }; + let sha256 = sigv4::sha256::(content); + let mut buf = [0; 1024]; + let Ok(unsigned) = s3::encode_put(&template, &bytes, &item, Some(&sha256), now, &mut buf) + else { + return 14; + }; + written += sigv4::sign(&stamp, &unsigned, &mut buf).url().len(); written } -// A computed checksum runs a provider over the content while the head is -// written. The state is a slot on this stack frame, so neither the encoder -// nor either implementation asks for memory. -fn computed_checksums(blobs: &Blobs<'_>, now: &Timestamps) -> usize { +// A checksum of content, computed into a value on this stack frame. +fn digest( + content: &[u8], +) -> borink_object_storage_proto::Digest { + let mut checksum = C::default(); + checksum.update(content); + checksum.finish() +} + +// A write sends the checksum that a provider computed over its content. +// Neither the encoder nor either implementation asks for memory. +fn computed_checksums(container: &Container<'_>, now: &Timestamps) -> usize { let mut written = 0; - for kind in [ChecksumKind::Crc64, ChecksumKind::Md5] { - let mut request_headers = [HeaderSpan::default(); 8]; - let mut buf = [0; 256]; - let options = WriteOptions { - checksum: Some(TransactionalChecksum::Compute(kind)), - ..WriteOptions::default() - }; - let put = PhysicalPut { - options, - ..PhysicalPut::new("object") + let content = b"0123456789"; + for (kind, value) in [ + ( + ChecksumKind::Crc64, + digest::(content), + ), + ( + ChecksumKind::Md5, + digest::(content), + ), + ] { + let options = PutOptions { + checksum: Some(kind), + ..PutOptions::default() }; - let Ok(request) = blobs.encode_put( - &mut buf, - &mut request_headers, - &put, - Payload::Slice(b"0123456789"), - now, + let mut bytes = [0; 256]; + let Ok(template) = azure::prepare_put( + container, + "token", + AzureNamespace::Unknown, + &options, + &mut bytes, ) else { return 9; }; + let item = PutItem { + checksum: Some(ChecksumValue::Digest(value)), + ..PutItem::new("object", Payload::Slice(content)) + }; + let mut buf = [0; 512]; + let Ok(request) = azure::encode_put(&template, &bytes, &item, now, &mut buf) else { + return 9; + }; written += request.url().len(); } written @@ -147,19 +199,29 @@ fn computed_checksums(blobs: &Blobs<'_>, now: &Timestamps) -> usize { // A listing reads a document out of a buffer and decodes the text in it where // it stands, so it is the one operation that could want scratch. It does not. -fn listing(blobs: &Blobs<'_>, now: &Timestamps) -> usize { - let mut request_headers = [HeaderSpan::default(); 8]; - let mut buf = [0; 256]; - let list = PhysicalList { +fn listing(container: &Container<'_>, now: &Timestamps) -> usize { + let options = ListOptions { delimiter: Some("/"), max_results: Some(2), - ..PhysicalList::new("directory/") + ..ListOptions::default() + }; + let mut bytes = [0; 256]; + let Ok(template) = azure::prepare_list( + container, + "token", + AzureNamespace::Unknown, + &options, + &mut bytes, + ) else { + return 5; }; - let Ok(request) = blobs.encode_list(&mut buf, &mut request_headers, &list, now) else { + let mut buf = [0; 512]; + let item = ListItem::new("directory/").after(ListMarker::Text("marker")); + let Ok(request) = azure::encode_list(&template, &bytes, &item, now, &mut buf) else { return 5; }; let url = request.url().len(); - let Ok(ListHeadOutcome::Page { .. }) = blobs.accept_list_head(ResponseHead::new(200)) else { + let Ok(ListHeadOutcome::Page { .. }) = azure::accept_list_head(ResponseHead::new(200)) else { return 6; }; let mut body = *b"a&b\ @@ -169,7 +231,7 @@ fn listing(blobs: &Blobs<'_>, now: &Timestamps) -> usize { // The entries borrow the body, so the array that holds them belongs to // the read. let mut first = [ListEntry::default(); 2]; - let Ok(page) = blobs.fill_listing(&mut body, &mut first) else { + let Ok(page) = azure::fill_listing(&mut body, &mut first) else { return 7; }; // A property that the entry does not carry is read out of its own bytes, @@ -189,7 +251,7 @@ fn listing(blobs: &Blobs<'_>, now: &Timestamps) -> usize { "; let wanted = PropertySet::of(&[BlobProperty::AccessTier]); let mut picked = [(0usize, None); 1]; - let Ok(again) = blobs.fill_listing_with(&mut body, &mut picked, wanted, |entry, values| { + let Ok(again) = azure::fill_listing_with(&mut body, &mut picked, wanted, |entry, values| { (entry.key.len(), values.get(BlobProperty::AccessTier)) }) else { return 8; diff --git a/crates/object-storage-c/src/step.rs b/crates/object-storage-c/src/step.rs index a0e258f9..50f9454d 100644 --- a/crates/object-storage-c/src/step.rs +++ b/crates/object-storage-c/src/step.rs @@ -15,7 +15,7 @@ use crate::{ use crate::ptr::SessionValues; use borink_object_storage_proto as proto; -use borink_object_storage_proto::{Blobs, Container, Error, ListMarker, ResponseHead, WireRequest}; +use borink_object_storage_proto::{Blobs, Container, Error, ListMarker, ResponseHead, LegacyRequest}; // What every call needs before the core crate sees it: a session that was // passed, whose three values name a container that can be addressed. @@ -136,7 +136,7 @@ pub(crate) fn head_of<'a>( // The written head, or the exact size that it needed, or why the plan was // refused. All three are one status and one `required`. -pub(crate) fn written(request: proto::Result>) -> RequestHead { +pub(crate) fn written(request: proto::Result>) -> RequestHead { let request = match request { Ok(request) => request, Err(error) => return refused(&error), diff --git a/crates/object-storage-crypto/src/lib.rs b/crates/object-storage-crypto/src/lib.rs index ddd187ba..cbaa9070 100644 --- a/crates/object-storage-crypto/src/lib.rs +++ b/crates/object-storage-crypto/src/lib.rs @@ -105,6 +105,7 @@ mod sha1_rustcrypto; mod sha256_minimal; #[cfg(feature = "sha256-rustcrypto")] mod sha256_rustcrypto; +pub mod sigv4; #[cfg(feature = "crc32")] pub use crc32::Crc32; diff --git a/crates/object-storage-crypto/src/sigv4.rs b/crates/object-storage-crypto/src/sigv4.rs new file mode 100644 index 00000000..8805ebff --- /dev/null +++ b/crates/object-storage-crypto/src/sigv4.rs @@ -0,0 +1,195 @@ +//! Signs S3 requests written from a template, with AWS Signature Version 4. +//! +//! `borink_object_storage_proto::s3` writes each request with a hole for its +//! signature and computes no hash. This module computes the hashes, with any +//! [`Sha256`] implementation, and writes the signature in. +//! +//! # Signing one request +//! +//! 1. Derive the day's [`SigningKey`] once a day with [`signing_key`]. +//! 2. Build a [`Stamp`] with [`stamp`] for the instant you sign at. It holds +//! the key and the start of the string to sign, which every request +//! signed at that instant shares. +//! 3. Encode the request with the operation's `encode_*`, and pass the +//! result to [`sign`], which returns the request ready to send. +//! +//! # Signing many requests +//! +//! [`sign_batch`] computes the signatures of many requests, one hash step +//! over all of them before the next. Pass each signature to +//! `borink_object_storage_proto::s3::finish`. + +use borink_object_storage_proto::s3::{self, STRING_TO_SIGN_PREFIX_LEN, Template, Unsigned}; +use borink_object_storage_proto::sigv4::Credentials; +use borink_object_storage_proto::{Result, Timestamps, WireRequest}; + +use crate::Sha256; + +/// A key of HMAC-SHA256, absorbed: the SHA-256 after its inner padding +/// block and the SHA-256 after its outer one. +/// +/// Each HMAC under the key starts from copies of the two, so the padding is +/// hashed once per key rather than once per message. +#[derive(Clone, Copy)] +pub struct HmacKey { + inner: S, + outer: S, +} + +impl core::fmt::Debug for HmacKey { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("HmacKey()") + } +} + +impl HmacKey { + /// Absorbs `key`. A key longer than a block is hashed first, as RFC + /// 2104 says. + pub fn new(key: &[u8]) -> Self { + let mut pad = [0u8; 64]; + if key.len() > 64 { + pad[..32].copy_from_slice(&sha256::(key)); + } else { + pad[..key.len()].copy_from_slice(key); + } + pad.iter_mut().for_each(|byte| *byte ^= 0x36); + let mut inner = S::default(); + inner.update(&pad); + pad.iter_mut().for_each(|byte| *byte ^= 0x36 ^ 0x5c); + let mut outer = S::default(); + outer.update(&pad); + crate::wipe(&mut pad); + Self { inner, outer } + } + + /// Returns the HMAC-SHA256 of `message` under this key. + pub fn mac(&self, message: &[u8]) -> [u8; 32] { + let mut inner = self.inner; + inner.update(message); + let mut outer = self.outer; + outer.update(&inner.finish()); + outer.finish() + } +} + +/// Returns the SHA-256 of `bytes`. +pub fn sha256(bytes: &[u8]) -> [u8; 32] { + let mut sum = S::default(); + sum.update(bytes); + sum.finish() +} + +/// Returns the HMAC-SHA256 of `message` under `key`. +pub fn hmac(key: &[u8], message: &[u8]) -> [u8; 32] { + HmacKey::::new(key).mac(message) +} + +/// The key that signs every request of one day, in one region, for one +/// service. +pub type SigningKey = HmacKey; + +/// Derives the key that signs every request from `template` on the day of +/// `now`, from the secret of `credentials`. +/// +/// It takes four HMACs. Derive it once a day and keep it. +/// +/// # Errors +/// +/// Returns what [`s3::signing_key`] returns. +pub fn signing_key( + template: &Template, + bytes: &[u8], + credentials: &Credentials<'_>, + now: &Timestamps, +) -> Result> { + let mut key = s3::signing_key(template, bytes, credentials, now, hmac::)?; + let absorbed = HmacKey::new(&key); + crate::wipe(&mut key); + Ok(absorbed) +} + +/// The signing key, gone on through the start of the string to sign of one +/// instant. +/// +/// Build one for each instant you sign at, and sign every request of that +/// instant from it. +#[derive(Clone, Copy)] +pub struct Stamp { + inner: S, + outer: S, +} + +impl core::fmt::Debug for Stamp { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("Stamp()") + } +} + +impl Stamp { + /// Returns the signature of a request whose canonical request hashes to + /// `canonical`, before it is written in hexadecimal. + pub fn sign(&self, canonical: &[u8; 32]) -> [u8; 32] { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut hex = [0u8; 64]; + for (pair, byte) in hex.chunks_exact_mut(2).zip(canonical) { + pair[0] = DIGITS[usize::from(byte >> 4)]; + pair[1] = DIGITS[usize::from(byte & 15)]; + } + let mut inner = self.inner; + inner.update(&hex); + let mut outer = self.outer; + outer.update(&inner.finish()); + outer.finish() + } +} + +/// Builds the stamp of `key` for requests from `template` signed at `now`. +/// +/// # Errors +/// +/// Returns what [`s3::string_to_sign_prefix`] returns. +pub fn stamp( + key: &SigningKey, + template: &Template, + bytes: &[u8], + now: &Timestamps, +) -> Result> { + let mut prefix = [0u8; STRING_TO_SIGN_PREFIX_LEN]; + let prefix = s3::string_to_sign_prefix(template, bytes, now, &mut prefix)?; + let mut inner = key.inner; + inner.update(prefix); + Ok(Stamp { + inner, + outer: key.outer, + }) +} + +/// Signs one request and returns it, ready to send. +/// +/// `buf` is the buffer that `unsigned` was written into. +/// +/// # Panics +/// +/// Panics if `buf` is shorter than `unsigned`. +pub fn sign<'r, S: Sha256>( + stamp: &Stamp, + unsigned: &Unsigned<'r>, + buf: &'r mut [u8], +) -> WireRequest<'r> { + let mac = stamp.sign(&sha256::(unsigned.canonical(buf))); + s3::finish(unsigned, buf, &mac) +} + +/// Computes the signatures of a batch of requests: the SHA-256 of every +/// canonical request, then the HMAC of every string to sign. +/// +/// `macs` receives one signature for each canonical request. Pass each to +/// `borink_object_storage_proto::s3::finish` with its request. +pub fn sign_batch(stamp: &Stamp, canonicals: &[&[u8]], macs: &mut [[u8; 32]]) { + for (canonical, mac) in canonicals.iter().zip(macs.iter_mut()) { + *mac = sha256::(canonical); + } + for mac in macs.iter_mut().take(canonicals.len()) { + *mac = stamp.sign(mac); + } +} diff --git a/crates/object-storage-crypto/tests/encoder.rs b/crates/object-storage-crypto/tests/encoder.rs index 0f7d786c..3f736b82 100644 --- a/crates/object-storage-crypto/tests/encoder.rs +++ b/crates/object-storage-crypto/tests/encoder.rs @@ -4,8 +4,8 @@ use borink_object_storage_proto::azure::{BlockRef, BlockSource, PhysicalStageBlock}; use borink_object_storage_proto::{ - Blobs, ChecksumKind, Container, Error, HeaderSpan, InvalidPlan, Payload, PhysicalCommit, - PhysicalPut, Timestamps, TransactionalChecksum, WriteOptions, layered, + Blobs, ChecksumKind, Container, HeaderSpan, Payload, PhysicalCommit, Timestamps, + TransactionalChecksum, WriteOptions, layered, }; fn blobs() -> Blobs<'static> { @@ -22,93 +22,6 @@ fn now() -> Timestamps { Timestamps::from_unix(1_787_400_000) } -#[test] -fn the_encoder_computes_a_checksum_of_content_it_holds() { - let blobs = blobs(); - for (kind, name, expected) in [ - (ChecksumKind::Md5, "content-md5", "eB5eJF1ptWaXm4bijSPyxw=="), - (ChecksumKind::Crc64, "x-ms-content-crc64", "HZz9TO6x+RU="), - ] { - let put = PhysicalPut { - options: WriteOptions { - checksum: Some(TransactionalChecksum::Compute(kind)), - ..Default::default() - }, - ..PhysicalPut::new("object.bin") - }; - let content = Payload::Slice(b"0123456789"); - let size = layered::put_requirements(&blobs, &put, content, &now()).unwrap(); - let mut buf = vec![0; size.bytes]; - let mut request_headers = vec![HeaderSpan::default(); size.headers]; - let request = blobs - .encode_put(&mut buf, &mut request_headers, &put, content, &now()) - .unwrap(); - let headers: Vec<_> = request.headers().collect(); - assert!(headers.contains(&(name, expected)), "{kind:?}: {headers:?}"); - // The bytes are not here to sum, so a streamed payload is refused. - assert_eq!( - blobs - .encode_put( - &mut buf, - &mut request_headers, - &put, - Payload::Streamed { len: 10 }, - &now() - ) - .map(drop), - Err(Error::InvalidPlan(InvalidPlan::Option)) - ); - } -} - -#[test] -fn a_client_registers_the_kinds_it_computes() { - let put = |blobs: &Blobs<'_>, kind| { - let options = WriteOptions { - checksum: Some(TransactionalChecksum::Compute(kind)), - ..Default::default() - }; - blobs - .encode_put( - &mut [0; 1024], - &mut [HeaderSpan::default(); 8], - &PhysicalPut { - options, - ..PhysicalPut::new("object.bin") - }, - Payload::Slice(b"0123456789"), - &now(), - ) - .map(|request| { - request - .headers() - .find(|(name, _)| *name == kind_header(kind)) - .map(|(_, value)| value.to_owned()) - .unwrap() - }) - }; - let one = Blobs::new( - Container::new("https://account.blob.core.windows.net", "container").unwrap(), - "token", - ) - .unwrap() - .with_checksum(borink_object_storage_crypto::CRC64); - // A client is the value that carries the providers, so registering one - // kind leaves the other refused. - assert_eq!(put(&one, ChecksumKind::Crc64).unwrap(), "HZz9TO6x+RU="); - assert_eq!( - put(&one, ChecksumKind::Md5), - Err(Error::InvalidPlan(InvalidPlan::Option)) - ); -} - -fn kind_header(kind: ChecksumKind) -> &'static str { - match kind { - ChecksumKind::Md5 => "content-md5", - _ => "x-ms-content-crc64", - } -} - #[test] fn a_stage_and_a_commit_compute_the_checksum_of_what_they_send() { let options = WriteOptions { diff --git a/crates/object-storage-crypto/tests/s3_checksums.rs b/crates/object-storage-crypto/tests/s3_checksums.rs index 12110200..0b4c63fc 100644 --- a/crates/object-storage-crypto/tests/s3_checksums.rs +++ b/crates/object-storage-crypto/tests/s3_checksums.rs @@ -11,15 +11,13 @@ ))] use borink_object_storage_crypto::{ - CRC32, CRC32C, CRC64, Checksum, Crc32, Crc32c, SHA1_RUSTCRYPTO, SHA256_CHECKSUM_RUSTCRYPTO, - SHA256_RUSTCRYPTO, Sha1RustCrypto, Sha256Minimal, Sha256RustCrypto, wipe, + Checksum, Crc32, Crc32c, Crc64, Sha1RustCrypto, Sha256Minimal, Sha256RustCrypto, wipe, }; use borink_object_storage_proto::checksum::BASE64_LEN; -use borink_object_storage_proto::s3::{Bucket, Objects, PayloadHash, Service}; +use borink_object_storage_proto::s3::{self, Bucket, PayloadSigning, Service}; use borink_object_storage_proto::sigv4::Credentials; use borink_object_storage_proto::{ - ChecksumKind, HeaderSpan, Payload, PhysicalPut, Timestamps, TransactionalChecksum, - WriteOptions, layered, + ChecksumKind, ChecksumValue, Digest, Payload, PutItem, PutOptions, Timestamps, }; fn base64(pieces: &[&[u8]]) -> String { @@ -56,51 +54,76 @@ fn the_hashes_are_the_reference_hashes() { } #[test] -fn an_s3_write_signs_the_checksum_it_computes() { +fn an_s3_write_signs_the_checksum_of_each_kind() { let bucket = Bucket::new("https://s3.example.com", "bucket", "auto", Service::Aws).unwrap(); let credentials = Credentials::new("AKIAIOSFODNN7EXAMPLE", "secret", wipe).unwrap(); - let objects = Objects::new(bucket, credentials, SHA256_RUSTCRYPTO) - .with_checksum(CRC32) - .with_checksum(CRC32C) - .with_checksum(CRC64) - .with_checksum(SHA1_RUSTCRYPTO) - .with_checksum(SHA256_CHECKSUM_RUSTCRYPTO); let now = Timestamps::from_unix(1_787_400_000); - for (kind, name) in [ - (ChecksumKind::Crc32, "x-amz-checksum-crc32"), - (ChecksumKind::Crc32c, "x-amz-checksum-crc32c"), - (ChecksumKind::Crc64, "x-amz-checksum-crc64nvme"), - (ChecksumKind::Sha1, "x-amz-checksum-sha1"), - (ChecksumKind::Sha256, "x-amz-checksum-sha256"), + let content = b"checksum\n"; + fn digest(content: &[u8]) -> Digest { + let mut sum = C::default(); + sum.update(content); + sum.finish() + } + for (kind, name, value) in [ + ( + ChecksumKind::Crc32, + "x-amz-checksum-crc32", + digest::(content), + ), + ( + ChecksumKind::Crc32c, + "x-amz-checksum-crc32c", + digest::(content), + ), + ( + ChecksumKind::Crc64, + "x-amz-checksum-crc64nvme", + digest::(content), + ), + ( + ChecksumKind::Sha1, + "x-amz-checksum-sha1", + digest::(content), + ), + ( + ChecksumKind::Sha256, + "x-amz-checksum-sha256", + digest::(content), + ), ] { - let put = PhysicalPut { - options: WriteOptions { - checksum: Some(TransactionalChecksum::Compute(kind)), - ..WriteOptions::new() - }, - ..PhysicalPut::new("object") + let options = PutOptions { + checksum: Some(kind), + ..PutOptions::default() + }; + let mut bytes = vec![0; 1024]; + let template = s3::prepare_put( + &bucket, + &credentials, + &options, + PayloadSigning::Unsigned, + &mut bytes, + ) + .unwrap(); + let item = PutItem { + checksum: Some(ChecksumValue::Digest(value)), + ..PutItem::new("object", Payload::Slice(content)) }; - let content = Payload::Slice(b"checksum\n"); - let hash = PayloadHash::Compute; - let size = layered::s3::put_requirements(&objects, &put, content, hash, &now).unwrap(); - let mut buf = vec![0; size.bytes]; - let mut headers = vec![HeaderSpan::default(); size.headers]; - let request = objects - .encode_put(&mut buf, &mut headers, &put, content, hash, &now) - .unwrap(); - let value = request + let mut buf = vec![0; 2048]; + let unsigned = s3::encode_put(&template, &bytes, &item, None, &now, &mut buf).unwrap(); + let request = s3::finish(&unsigned, &mut buf, &[0; 32]); + let found = request .headers() .find(|(found, _)| *found == name) - .map(|(_, value)| value); + .map(|(_, value)| value.to_owned()); let expected = match kind { - ChecksumKind::Crc32 => base64::(&[b"checksum\n"]), - ChecksumKind::Crc32c => base64::(&[b"checksum\n"]), - ChecksumKind::Sha1 => base64::(&[b"checksum\n"]), - ChecksumKind::Sha256 => base64::(&[b"checksum\n"]), + ChecksumKind::Crc32 => base64::(&[content]), + ChecksumKind::Crc32c => base64::(&[content]), + ChecksumKind::Sha1 => base64::(&[content]), + ChecksumKind::Sha256 => base64::(&[content]), // S3 reads a CRC-64 big-endian, the reverse of the digest's bytes. - _ => base64_of(&borink_object_storage_crypto::Crc64::of(b"checksum\n").to_be_bytes()), + _ => base64_of(&Crc64::of(content).to_be_bytes()), }; - assert_eq!(value, Some(expected.as_str()), "{kind:?}"); + assert_eq!(found, Some(expected), "{kind:?}"); let authorization = request .headers() .find(|(found, _)| *found == "authorization") diff --git a/crates/object-storage-crypto/tests/sigv4.rs b/crates/object-storage-crypto/tests/sigv4.rs index b321c242..2411a571 100644 --- a/crates/object-storage-crypto/tests/sigv4.rs +++ b/crates/object-storage-crypto/tests/sigv4.rs @@ -6,72 +6,102 @@ #![cfg(any(feature = "sha256-rustcrypto", feature = "sha256-minimal"))] -use borink_object_storage_proto::s3::{Addressing, Bucket, Objects, PayloadHash, Service}; -use borink_object_storage_proto::sigv4::{Credentials, Sha256Provider}; +use borink_object_storage_crypto::{Sha256, sigv4}; +use borink_object_storage_proto::s3::{ + self, Addressing, Bucket, PayloadSigning, Service, Template, Unsigned, +}; +use borink_object_storage_proto::sigv4::Credentials; use borink_object_storage_proto::{ - Condition, HeaderSpan, MetadataPair, Payload, PhysicalDelete, PhysicalGet, PhysicalPut, - RequestedRange, Timestamps, WireRequest, layered, + ChecksumKind, ChecksumValue, ConditionKind, ConditionValue, DeleteItem, DeleteOptions, Error, + GetItem, GetOptions, MetadataPair, Payload, PutItem, PutOptions, RequestedRange, Timestamps, }; const KEY_ID: &str = "AKIAIOSFODNN7EXAMPLE"; const SECRET: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; -#[allow(clippy::vec_init_then_push)] -fn providers() -> Vec { - let mut all = Vec::new(); - #[cfg(feature = "sha256-rustcrypto")] - all.push(borink_object_storage_crypto::SHA256_RUSTCRYPTO); - #[cfg(feature = "sha256-minimal")] - all.push(borink_object_storage_crypto::SHA256_MINIMAL); - all -} - -// Every provider, each as a client with no signing key, with the key for -// `now`, and with the key for the day before, which must not be used. -fn clients<'a>( - bucket: Bucket<'a>, - credentials: Credentials<'a>, - now: &Timestamps, -) -> Vec> { - let yesterday = Timestamps::from_unix(now.unix() - 86_400); - providers() - .into_iter() - .flat_map(|sha256| { - let objects = Objects::new(bucket, credentials, sha256); - [ - objects, - objects.with_signing_key(now), - objects.with_signing_key(&yesterday), - ] - }) - .collect() -} - fn credentials() -> Credentials<'static> { Credentials::new(KEY_ID, SECRET, borink_object_storage_crypto::wipe).unwrap() } -fn headers<'r>(request: &WireRequest<'r>) -> Vec<(&'r str, &'r str)> { - request.headers().collect() +// A request from the template that `prepare` writes, signed with each +// provider, as its URL, its sorted headers and its HTTP/1.1 head. The +// template and the request are written into buffers of exactly the size +// that an empty buffer reports. +type Signed = (String, Vec<(String, String)>, String); +type Prepare<'p> = dyn Fn(&mut [u8]) -> borink_object_storage_proto::Result