From bf63c00983ce2f82898b98e1aafc1657792c6a96 Mon Sep 17 00:00:00 2001 From: Hein Dauven Date: Wed, 9 Sep 2026 15:18:00 +0200 Subject: [PATCH 1/2] Harden sponge lifecycle, encoding and secret-output storage Make operation errors and zeroization terminal, reject unsupported widths and 31-bit aggregate overflow, and redact secret-bearing Debug output. Preserve valid encodings and original per-call pattern validation. Allocate replacement output before copying, wiping and releasing old storage, including clone continuation. Guard AE temporary buffers through errors and unwinding; borrow the inner slice to retain older zeroize compatibility. Keep bounded-demand amortized growth and the measured hot-path optimizations. Add independent mechanics vectors, correct-domain rejection tests, allocator wiping/failure/unwind checks and a pinned zeroize 1.5.4 consumer in make test. Document backend obligations and resource/security limitations. Refs #37. --- .gitignore | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 10 + Makefile | 1 + README.md | 31 ++- examples/generate_encryption_fixtures.rs | 92 ++++++++ src/encryption.rs | 40 ++-- src/error.rs | 5 +- src/lib.rs | 122 ++++++---- src/sponge.rs | 273 ++++++++++++++++++++++- tests/encryption.rs | 208 +++++++---------- tests/fixtures/encryption.txt | 5 + tests/sponge.rs | 170 +++++++++----- tests/zeroize-compat/Cargo.toml | 12 + tests/zeroize-compat/src/lib.rs | 9 + 15 files changed, 718 insertions(+), 264 deletions(-) create mode 100644 examples/generate_encryption_fixtures.rs create mode 100644 tests/fixtures/encryption.txt create mode 100644 tests/zeroize-compat/Cargo.toml create mode 100644 tests/zeroize-compat/src/lib.rs diff --git a/.gitignore b/.gitignore index 6936990..4308d82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -/target +target/ **/*.rs.bk Cargo.lock diff --git a/AGENTS.md b/AGENTS.md index 7df42b6..352789f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ The core abstraction is the **SAFE (Sponge API for Field Elements)** framework: Behind the `encryption` feature: - **`Encryption` trait** — extends `Safe` with subtraction and equality (also circuit-compatible). -- **`encrypt` / `decrypt`** — authenticated encryption using the sponge. Absorbs a shared secret and nonce, then XORs the message with squeezed keystream. A final squeeze element serves as an authentication tag. +- **`encrypt` / `decrypt`** — authenticated encryption using the sponge. Absorbs a shared secret and nonce, then uses field addition/subtraction to combine the message and squeezed keystream. A final squeeze element serves as an authentication tag. ### Key Dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e89fa..8f2dd0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Wipe old output allocations before growth, including when continuing cloned sponges [#37] +- Return an error and invalidate the sponge when output allocation fails [#37] +- Wipe encryption/decryption temporary vectors on error and unwinding [#37] +- Permanently invalidate sponges after operation errors or explicit zeroization [#37] +- Reject IO patterns whose aggregated runs exceed the 31-bit encoding limit [#37] +- Reject sponge widths below two at construction [#37] +- Redact permutation state, buffered output and backend data from sponge debugging [#37] - Reject empty ciphertext consistently in `decrypt` [#35] ### Changed +- Encode checked IO-pattern runs directly without an intermediate word vector [#37] +- Specialize small squeeze calls while keeping allocation and wiping out of line [#37] - Simplify sponge finalization and IO-pattern validation - Raise the MSRV to Rust 1.96.1 [#32] @@ -58,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add documentation +[#37]: https://github.com/dusk-network/safe/issues/37 [#35]: https://github.com/dusk-network/safe/issues/35 [#32]: https://github.com/dusk-network/safe/issues/32 [#26]: https://github.com/dusk-network/safe/issues/26 diff --git a/Makefile b/Makefile index 829f757..74f2b5b 100644 --- a/Makefile +++ b/Makefile @@ -18,6 +18,7 @@ check: ## Type-check test: ## Run tests in debug and release profiles @cargo test --features=encryption @cargo test --release --features=encryption + @cargo check --manifest-path tests/zeroize-compat/Cargo.toml no-std: ## Verify no_std compatibility on bare-metal target @rustup target add thumbv6m-none-eabi 2>/dev/null || true diff --git a/README.md b/README.md index 6e594ca..f24308a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A generic API for sponge functions. This is a minimal, `no_std`, pure Rust implementation of a sponge function, based on [SAFE](https://eprint.iacr.org/2023/522.pdf) (Sponge API for Field Elements), to be used in permutation-based symmetric primitives' design, such as hash functions, MACs, authenticated encryption schemes, PRNGs, and other. -The sponge is designed to be usable in zero-knowledge proving systems (ZKPs) as well as natively, operating on any type implementing the `Default` and `Copy` trait with a size of minimal 32 bytes. +The sponge is designed to be usable in zero-knowledge proving systems (ZKPs) as well as natively, operating on types implementing `Default`, `Copy`, and `Zeroize`. The backend must provide a suitable field, permutation, tag hash, arithmetic and equality semantics; the Rust representation size alone does not establish cryptographic security. ## Introduction @@ -29,7 +29,7 @@ The sponge constructed in this library is defined by: - an input-output (IO) pattern that defines the sequence to ingest `len` items of input (`absorb(len)`) and pruduce output (`squeeze(len)`) (eg. `[absorb(4), absorb(1), squeeze(3)]`) - a domain separator to distinguish between equivalent sponges with different usecases. -Note: With the capacity beeing one element of type `T` we need to restrict `T` to be at least 256 bits. It is the responsibility of the user to properly serialize input of different sizes into a type with at least 256 bits. +With capacity fixed to one field element, security depends on the actual field cardinality and the chosen permutation and tag hash, not `size_of::()`. The backend must provide the additive identity and field arithmetic, use constant-time native operations on secrets, and properly constrain circuit operations. In a circuit, an equality callback may add a constraint and return `true`; authentication is not established until the resulting proof is successfully verified. Secrets retained in the backend require its own cleanup. ## Abstract API @@ -39,10 +39,11 @@ Note: With the capacity beeing one element of type `T` we need to restrict `T` t - IO pattern has at least two calls. - First call is to `absorb`. - Last call is to `squeeze`. - - No call has a `len == 0`. + - The width is at least two. + - Individual calls and aggregated consecutive runs have lengths from 1 through `2^31 - 1`. 2. Compute the tag using the IO pattern and a domain separator. - 1. Encode the IO pattern as a list of 32-bit words whose MSB is set to 1 for `absorb` and to 0 for `squeeze`, and the length is added to the lower bits. Any contiguous calls to `absorb` and `squeeze` will be aggregated, e.g. the above example of an IO pattern of `[absorb(4), absorb(1), squeeze(3)]` will have the same encoding as `[absorb(5), squeeze(3)]`: `[0x8000_0005, 0x0000_0001]`. - 2. Serialize the list of words into a byte string and append to it the domain separator: e.g. if the domain separator encoding is set to the two-byte sequence `0x4142`, then the example above would yield the string (with big-endian convention): `0x80000005000000014142`. + 1. Encode the IO pattern as a list of 32-bit words whose MSB is set to 1 for `absorb` and to 0 for `squeeze`, and the length is added to the lower bits. Any contiguous calls to `absorb` and `squeeze` will be aggregated, e.g. the above example of an IO pattern of `[absorb(4), absorb(1), squeeze(3)]` will have the same encoding as `[absorb(5), squeeze(3)]`: `[0x8000_0005, 0x0000_0003]`. + 2. Serialize the words in big-endian order and append the eight-byte big-endian `u64` domain separator. With separator `0x4142`, the example above yields `0x80000005000000030000000000004142`. 3. Hash the byte string into the tag, an element of type `T`. 3. Initialize first element of the state to the tag and set the remaining elements to the default value of `T`. 4. Set both absorb and squeeze positions to zero. @@ -50,8 +51,10 @@ Note: With the capacity beeing one element of type `T` we need to restrict `T` t ### `finish` -1. If the IO count is equal to the length of the IO pattern, return the output vector, if not return an error. -2. Erase the state and its variables +1. Return the output vector only if the IO count equals the pattern length and the instance has never failed or been explicitly zeroized; otherwise return an error. +2. Erase the state and its variables. + +Every operation error and explicit zeroization permanently invalidates that sponge. Subsequent absorb, squeeze and finish calls reject it; cloning a failed sponge does not restore validity. Debug formatting omits state, buffered output and the backend. ### `absorb(len, input)` @@ -73,6 +76,20 @@ Note: With the capacity beeing one element of type `T` we need to restrict `T` t *Note that we do not set the `pos_absorb` to the rate as we do with the `pos_squeeze` in the call to `absorb`, this is because we may want the state to absorb at the same positions that have been squeezed.* +### Output storage and resource limits + +Output storage grows only for actual squeeze requests, not for the entire declared +pattern at construction. Growth allocates a replacement, copies live output, and +zeroizes the old allocation before releasing it; the same rule protects continued +clones. Failed output-growth allocations return `InvalidIOPattern` and permanently +invalidate the sponge; other allocations retain the platform's allocation-failure +behavior. Callers must bound pattern sizes and output requests to their own +memory budget: allocator success does not guarantee that an operating system can +back an arbitrarily large allocation. Growth temporarily keeps old and new buffers +live. Encryption/decryption temporary vectors are zeroized on error or unwinding; +returned vectors are owned by the caller. This does not erase copies retained by a +backend, compiler, or a process that aborts. + ## Example ```rust diff --git a/examples/generate_encryption_fixtures.rs b/examples/generate_encryption_fixtures.rs new file mode 100644 index 0000000..a13469b --- /dev/null +++ b/examples/generate_encryption_fixtures.rs @@ -0,0 +1,92 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +//! Check: cargo run --example generate_encryption_fixtures +//! Regenerate explicitly: append -- --write. +//! +//! Original vectors used Python integer arithmetic and hashlib: +//! https://github.com/dusk-network/safe/blob/d4802e77f65e0163fcfdf7d54275e572b718724c/tests/fixtures/generate.py +//! This reference calls no dusk-safe code, but shares field arithmetic/hashing +//! with the Rust tests. The hash-expanded backend is NOT a cryptographic +//! permutation. Fixed parameters: width 7, key [7, 8], nonce 9, domain 2^31, +//! message [1, ..., n]; this is not a general-purpose encryption +//! implementation. + +use std::fmt::Write; +use std::path::Path; +use std::{env, fs}; + +use dusk_bls12_381::BlsScalar; + +const RATE: usize = 6; + +fn permute(state: &mut [BlsScalar; RATE + 1]) { + let mut bytes: Vec<_> = state.iter().flat_map(|s| s.to_bytes()).collect(); + for (i, value) in state.iter_mut().enumerate() { + bytes.push(i as u8); + *value = BlsScalar::hash_to_scalar(&bytes); + bytes.pop(); + } +} + +fn reference_cipher(n: usize) -> Vec { + // Canonical A(3), S(n), A(n), S(1), then the big-endian domain. + let mut tag: Vec<_> = [0x8000_0003, n as u32, 0x8000_0000 | n as u32, 1] + .into_iter() + .flat_map(u32::to_be_bytes) + .collect(); + tag.extend_from_slice(&(1u64 << 31).to_be_bytes()); + let mut state = [BlsScalar::zero(); RATE + 1]; + state[0] = BlsScalar::hash_to_scalar(&tag); + for (i, value) in [7u64, 8, 9].into_iter().enumerate() { + state[i + 1] = BlsScalar::from(value); + } + + let mut cipher = vec![BlsScalar::zero(); n]; + for block in cipher.chunks_mut(RATE) { + permute(&mut state); + block.copy_from_slice(&state[1..=block.len()]); + } + // Absorb plaintext from position zero, adding it to the saved masks too. + for (block_index, block) in cipher.chunks_mut(RATE).enumerate() { + if block_index != 0 { + permute(&mut state); + } + for (i, value) in block.iter_mut().enumerate() { + let message = BlsScalar::from((block_index * RATE + i + 1) as u64); + state[i + 1] += message; + *value += message; + } + } + permute(&mut state); + cipher.push(state[1]); + cipher +} + +fn main() -> Result<(), Box> { + let mut content = String::from( + "# Originally Python stdlib; see examples/generate_encryption_fixtures.rs for provenance.\n", + ); + for n in [1, 6, 7, 13] { + write!(content, "{n} ")?; + for byte in reference_cipher(n).iter().flat_map(|s| s.to_bytes()) { + write!(content, "{byte:02x}")?; + } + content.push('\n'); + } + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/encryption.txt"); + let args: Vec<_> = env::args_os().skip(1).collect(); + match args.as_slice() { + [] => { + assert_eq!(fs::read_to_string(path)?, content, "frozen vectors differ"); + println!("four frozen AE mechanics vectors match"); + } + [flag] if flag == "--write" => fs::write(path, content)?, + _ => return Err("usage: cargo run --example generate_encryption_fixtures [-- --write]".into()), + } + Ok(()) +} diff --git a/src/encryption.rs b/src/encryption.rs index 499fe5c..b1c6a5b 100644 --- a/src/encryption.rs +++ b/src/encryption.rs @@ -6,7 +6,7 @@ use alloc::vec::Vec; -use zeroize::Zeroize; +use zeroize::{Zeroize, Zeroizing}; use crate::{Call, Error, Safe, Sponge}; @@ -37,7 +37,10 @@ pub trait Encryption { /// /// # Returns /// - /// Returns `true` if `lhs` is equal to `rhs`, otherwise `false`. + /// Native implementations must compare in constant time and return `true` + /// exactly when `lhs` equals `rhs`. Circuit implementations may constrain + /// equality and return `true`; authentication then depends on successfully + /// proving and verifying those constraints, not on this boolean alone. fn is_equal(&mut self, lhs: &T, rhs: &T) -> bool; } @@ -112,7 +115,7 @@ where // encryption cipher is the sponge.output with the message elements added // to the first message_len elements - let mut cipher = Vec::from(&sponge.output[..]); + let mut cipher = Zeroizing::new(Vec::from(&sponge.output[..])); for i in 0..message_len { cipher[i] = sponge.safe.add(&cipher[i], &message[i]); } @@ -123,16 +126,9 @@ where } // finish the sponge, erase cipher upon error - match sponge.finish() { - Ok(mut output) => { - output.zeroize(); - Ok(cipher) - } - Err(e) => { - cipher.zeroize(); - Err(e) - } - } + let mut output = sponge.finish()?; + output.zeroize(); + Ok(core::mem::take(&mut *cipher)) } /// Decrypts a cipher-text using a shared secret and nonce, and returns the @@ -176,13 +172,13 @@ where )?; // construct the message by subtracting sponge.output from the cipher - let mut message = Vec::from(&sponge.output[..]); + let mut message = Zeroizing::new(Vec::from(&sponge.output[..])); for i in 0..message_len { message[i] = sponge.safe.subtract(&cipher[i], &message[i]); } // absorb the obtained message - sponge.absorb(message_len, &message)?; + sponge.absorb(message_len, message.as_slice())?; // squeeze 1 element sponge.squeeze(1)?; @@ -191,22 +187,14 @@ where // of the sponge output let s = sponge.output[message_len]; if !sponge.safe.is_equal(&s, &cipher[message_len]) { - message.zeroize(); sponge.zeroize(); return Err(Error::DecryptionFailed); }; // finish sponge, erase decrypted message upon error - match sponge.finish() { - Ok(mut output) => { - output.zeroize(); - Ok(message) - } - Err(e) => { - message.zeroize(); - Err(e) - } - } + let mut output = sponge.finish()?; + output.zeroize(); + Ok(core::mem::take(&mut *message)) } /// Defines the input-output pattern for the encryption and decryption. diff --git a/src/error.rs b/src/error.rs index 10d7838..fd671fa 100644 --- a/src/error.rs +++ b/src/error.rs @@ -17,7 +17,10 @@ pub enum Error { /// - It doesn't end with a call to absorb /// - Every call to absorb or squeeze has a length between 0 < len < 2^31 /// - /// Also returned when an empty ciphertext cannot form a valid IO-pattern. + /// Consecutive calls of the same kind must also aggregate to less than + /// 2^31 elements. Widths below two cannot form a usable sponge. + /// Also returned if requested output storage cannot be represented or + /// allocated, or an empty ciphertext cannot form a valid IO-pattern. InvalidIOPattern, /// This error occurs when the input elements provided to the diff --git a/src/lib.rs b/src/lib.rs index 566f996..1f4931b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -71,39 +71,30 @@ fn tag_input( // ABSORB_MASK = 0b10000000_00000000_00000000_00000000 const ABSORB_MASK: u32 = 0x8000_0000; - // we know that the first call needs to be to absorb so we can initialize - // the vec - let mut input_u32 = Vec::new(); - input_u32.push(ABSORB_MASK); - - // Aggregate and encode calls to absorb and squeeze - iopattern.as_ref().iter().for_each(|call| { - // get a mutable ref to the previously encoded call - // Note: This is safe since we initialized the vector with one element - let l = input_u32.len(); - let prev = &mut input_u32[l - 1]; - match call { - // if both this and the previous calls are to absorb, aggregate them - Call::Absorb(len) if *prev & ABSORB_MASK != 0 => { - *prev += *len as u32 - } - // else add an encoded call to absorb - Call::Absorb(len) => input_u32.push(ABSORB_MASK + *len as u32), - // if both this and the previous calls are to squeeze, aggregate - // them - Call::Squeeze(len) if *prev & ABSORB_MASK == 0 => { - *prev += *len as u32 - } - // else add an encoded call to squeeze - Call::Squeeze(len) => input_u32.push(*len as u32), + let mut input = Vec::new(); + // Validation guarantees that the first operation is absorb. + let mut kind = ABSORB_MASK; + let mut length = 0u32; + + // Aggregate lengths separately from the operation bit. Every encoded run, + // not just every individual call, must fit in the low 31 bits. Emit each + // completed run directly instead of updating an intermediate word vector. + for call in iopattern.as_ref() { + let (next_kind, len) = match call { + Call::Absorb(len) => (ABSORB_MASK, *len as u32), + Call::Squeeze(len) => (0, *len as u32), + }; + if next_kind != kind { + input.extend((kind | length).to_be_bytes()); + kind = next_kind; + length = 0; } - }); - - // Convert hash input to an array of u8, using big endian conversion - let mut input: Vec = input_u32 - .iter() - .flat_map(|u32_int| u32_int.to_be_bytes().into_iter()) - .collect(); + length = length + .checked_add(len) + .filter(|length| *length < ABSORB_MASK) + .ok_or(Error::InvalidIOPattern)?; + } + input.extend((kind | length).to_be_bytes()); // Add the domain separator to the hash input input.extend(domain_sep.to_be_bytes()); @@ -152,6 +143,66 @@ mod tests { use super::*; + #[test] + fn aggregate_lengths_fit_the_encoding() { + const MAX: usize = (1 << 31) - 1; + for pattern in [ + vec![Call::Absorb(MAX), Call::Absorb(1), Call::Squeeze(1)], + vec![Call::Absorb(1), Call::Squeeze(MAX), Call::Squeeze(1)], + vec![ + Call::Absorb(1), + Call::Squeeze(MAX), + Call::Squeeze(2), + Call::Absorb(1), + Call::Squeeze(1), + ], + vec![ + Call::Absorb(1), + Call::Squeeze(MAX), + Call::Squeeze(1), + Call::Absorb(2), + Call::Squeeze(1), + ], + ] { + assert_eq!(tag_input(pattern, 42), Err(Error::InvalidIOPattern)); + } + let pattern = [ + Call::Absorb(MAX - 1), + Call::Absorb(1), + Call::Squeeze(MAX - 1), + Call::Squeeze(1), + ]; + let mut expected = vec![0xff, 0xff, 0xff, 0xff, 0x7f, 0xff, 0xff, 0xff]; + expected.extend(42u64.to_be_bytes()); + assert_eq!(tag_input(pattern, 42).unwrap(), expected); + assert_eq!( + tag_input([Call::Absorb(4), Call::Absorb(1), Call::Squeeze(3)], 42) + .unwrap(), + [0x80, 0, 0, 5, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 42] + ); + // Switching back to absorb must flush the squeeze run and reset its + // length; this literal is independent of the encoder implementation. + assert_eq!( + tag_input( + [ + Call::Absorb(2), + Call::Absorb(3), + Call::Squeeze(4), + Call::Squeeze(5), + Call::Absorb(6), + Call::Squeeze(7), + Call::Squeeze(8), + ], + 42, + ) + .unwrap(), + [ + 0x80, 0, 0, 5, 0, 0, 0, 9, 0x80, 0, 0, 6, 0, 0, 0, 15, 0, 0, 0, + 0, 0, 0, 0, 42, + ] + ); + } + #[test] fn test_validate_io_pattern() { // test valid @@ -243,13 +294,6 @@ mod tests { ); // check patterns whose aggregate are equal - let pattern1 = vec![Call::Absorb(2), Call::Squeeze(1)]; - let pattern2 = vec![Call::Absorb(2), Call::Squeeze(1)]; - assert_eq!( - tag_input(&pattern1, domain_sep)?, - tag_input(&pattern2, domain_sep)? - ); - let pattern1 = vec![Call::Absorb(1), Call::Absorb(1), Call::Squeeze(1)]; let pattern2 = vec![Call::Absorb(2), Call::Squeeze(1)]; assert_eq!( diff --git a/src/sponge.rs b/src/sponge.rs index 6429b4f..f7d055e 100644 --- a/src/sponge.rs +++ b/src/sponge.rs @@ -5,6 +5,7 @@ // Copyright (c) DUSK NETWORK. All rights reserved. use alloc::vec::Vec; +use core::fmt; use zeroize::Zeroize; @@ -14,6 +15,15 @@ use crate::{Call, Error, tag_input}; /// /// Note: The trait's specific implementation of addition enables usage within /// zero-knowledge circuits. +/// +/// Implementers supply the security boundary: `T::default()` must represent +/// the additive identity (or override `initialized_state`), and `add` must be +/// field addition. The permutation and pattern-to-field hash must meet the +/// security requirements of the chosen SAFE instantiation. Field cardinality, +/// not the Rust representation size, determines capacity security. Native +/// secret-dependent operations must be constant-time; circuit implementations +/// must constrain their results. Any secrets retained by the backend require +/// its own cleanup: sponge zeroization does not erase the backend. pub trait Safe where T: Default + Copy + Zeroize, @@ -63,9 +73,11 @@ where /// Struct that implements the Sponge API over field elements. /// -/// The capacity is fixed to one field element and the rate are `W - 1` field -/// elements. -#[derive(Debug, Clone, PartialEq)] +/// The capacity is fixed to one field element and the rate is `W - 1` field +/// elements. Widths below two are rejected. Operation errors and explicit +/// zeroization permanently invalidate the instance, including subsequent +/// clones of that failed instance; construct a new sponge to start again. +#[derive(Clone, PartialEq)] pub struct Sponge where S: Safe, @@ -76,6 +88,7 @@ where pos_absorb: usize, pos_squeeze: usize, io_count: usize, + failed: bool, iopattern: Vec, domain_sep: u64, pub(crate) output: Vec, @@ -105,12 +118,15 @@ where /// # Returns /// /// A result containing the initialized Sponge on success, or an `Error` if - /// the IO-pattern is invalid. + /// the IO-pattern is invalid or the width is less than two. pub fn start( safe: S, iopattern: impl Into>, domain_sep: u64, ) -> Result { + if W < 2 { + return Err(Error::InvalidIOPattern); + } // Compute the tag and initialize the state. // Note: This will return an error if the IO-pattern is invalid. let iopattern: Vec = iopattern.into(); @@ -124,6 +140,7 @@ where pos_absorb: 0, pos_squeeze: 0, io_count: 0, + failed: false, iopattern, domain_sep, output: Vec::new(), @@ -138,7 +155,7 @@ where /// A result containing the output vector on success, or an `Error` if the /// IO-pattern wasn't followed. pub fn finish(mut self) -> Result, Error> { - let ret = if self.io_count == self.iopattern.len() { + let ret = if !self.failed && self.io_count == self.iopattern.len() { Ok(core::mem::take(&mut self.output)) } else { Err(Error::IOPatternViolation) @@ -161,11 +178,15 @@ where /// /// A result indicating success if the operation completes, or an `Error` /// if the IO-pattern wasn't followed. + #[inline] pub fn absorb( &mut self, len: usize, input: impl AsRef<[T]>, ) -> Result<(), Error> { + if self.failed { + return Err(Error::IOPatternViolation); + } // Check that input yields enough elements if input.as_ref().len() < len { self.zeroize(); @@ -219,8 +240,15 @@ where /// # Returns /// /// A result indicating success if the operation completes, or an `Error` - /// if the IO-pattern wasn't followed. + /// if the IO-pattern wasn't followed. Unrepresentable or unavailable + /// output storage returns [`Error::InvalidIOPattern`] and invalidates the + /// sponge. Callers must bound requested output to their memory budget. + // Specialize small, constant-length calls without inlining allocation. + #[inline(always)] pub fn squeeze(&mut self, len: usize) -> Result<(), Error> { + if self.failed { + return Err(Error::IOPatternViolation); + } // Check that the IO-pattern is followed match self.iopattern.get(self.io_count) { // only proceed if we expect a call to squeeze with the correct @@ -232,7 +260,14 @@ where } } - // Squeeze 'len` field elements from the state, calling [`permute`] when + if len > self.output.capacity() - self.output.len() + && self.grow_output(len).is_err() + { + self.zeroize(); + return Err(Error::InvalidIOPattern); + } + + // Squeeze `len` field elements from the state, calling [`permute`] when // the squeeze-position reached the rate. for _ in 0..len { if self.pos_squeeze == Self::RATE { @@ -251,6 +286,48 @@ where Ok(()) } + + // Never let Vec reallocate live secret output: allocator growth may free + // the old allocation without wiping it. Grow only for actual squeeze + // requests, not arbitrarily large future lengths in pattern metadata. + // Keep allocation and wiping out of the repeated-call fast path. + #[inline(never)] + fn grow_output(&mut self, len: usize) -> Result<(), Error> { + let required = self + .output + .len() + .checked_add(len) + .ok_or(Error::InvalidIOPattern)?; + // No spare capacity is useful after the final operation. Keep + // amortized growth for streaming, but not for a final AE tag. + let capacity = if self.io_count + 1 == self.iopattern.len() { + required + } else { + required.max(self.output.capacity().saturating_mul(2)) + }; + let mut replacement = Vec::new(); + replacement + .try_reserve(capacity) + .map_err(|_| Error::InvalidIOPattern)?; + replacement.extend_from_slice(&self.output); + self.output.zeroize(); + self.output = replacement; + Ok(()) + } +} + +impl fmt::Debug for Sponge +where + S: Safe, + T: Default + Copy + Zeroize, +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Sponge") + .field("width", &W) + .field("io_count", &self.io_count) + .field("failed", &self.failed) + .finish_non_exhaustive() + } } impl Drop for Sponge @@ -268,10 +345,192 @@ where S: Safe, T: Default + Copy + Zeroize, { + /// Erase the state and output, permanently invalidating this instance. fn zeroize(&mut self) { + self.failed = true; self.state.zeroize(); self.pos_absorb.zeroize(); self.pos_squeeze.zeroize(); self.output.zeroize(); } } + +#[cfg(test)] +mod storage_tests { + // Allocation-boundary tests; the integer/identity backend is NOT + // cryptographic. + extern crate std; + use alloc::vec; + use std::alloc::{GlobalAlloc, Layout, System}; + use std::cell::Cell; + use std::ptr; + + use super::*; + + std::thread_local! { + // Watch one initialized u64, never uninitialized capacity or freed memory. + static WATCH: Cell = const { Cell::new(0) }; + static WIPED: Cell = const { Cell::new(false) }; + static FAIL_NEXT: Cell = const { Cell::new(false) }; + static FAIL_AFTER_SUBTRACT: Cell = const { Cell::new(false) }; + static PANIC_ON_SUBTRACT: Cell = const { Cell::new(false) }; + } + struct Allocator; + fn observe(ptr: *mut u8) { + let _ = WATCH.try_with(|watch| { + let address = watch.get(); + if address != 0 && address == ptr as usize { + // SAFETY: watch() registers an initialized u64; this is the + // matching, still-live allocation. Clearing a Vec does + // not invalidate the initialized bytes in its retained storage. + WIPED.set(unsafe { *ptr.cast::() == 0 }); + watch.set(0); + } + }); + } + unsafe impl GlobalAlloc for Allocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + if FAIL_NEXT + .try_with(|flag| flag.replace(false)) + .unwrap_or(false) + { + return ptr::null_mut(); + } + unsafe { System.alloc(layout) } + } + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + observe(ptr); + unsafe { System.dealloc(ptr, layout) } + } + unsafe fn realloc( + &self, + ptr: *mut u8, + layout: Layout, + size: usize, + ) -> *mut u8 { + observe(ptr); + unsafe { System.realloc(ptr, layout, size) } + } + } + #[global_allocator] + static ALLOCATOR: Allocator = Allocator; + + #[derive(Clone)] + struct Identity; + impl Safe for Identity { + fn tag(&mut self, _: &[u8]) -> u64 { + 0 + } + fn permute(&mut self, _: &mut [u64; 4]) {} + fn add(&mut self, a: &u64, b: &u64) -> u64 { + a.wrapping_add(*b) + } + } + fn watch(value: &u64) { + assert_ne!(*value, 0); + WIPED.set(false); + WATCH.set(value as *const u64 as usize); + } + #[test] + fn growth_wipes_old_output_including_clones() { + let mut original = Sponge::start( + Identity, + vec![Call::Absorb(1), Call::Squeeze(1), Call::Squeeze(4)], + 0, + ) + .unwrap(); + original.absorb(1, [99]).unwrap(); + original.squeeze(1).unwrap(); + let cloned = original.clone(); + for mut sponge in [original, cloned] { + // Identity's tag ignores the pattern, so adapt the final call to + // force growth of this instance's actual buffer (already nonempty). + let len = sponge.output.capacity().max(4); + sponge.iopattern[2] = Call::Squeeze(len); + watch(&sponge.output[0]); + sponge.squeeze(len).unwrap(); + assert!( + WIPED.get(), + "old output must be wiped before allocator release" + ); + let expected: Vec<_> = + [99, 0, 0].into_iter().cycle().take(len + 1).collect(); + assert_eq!(sponge.finish().unwrap(), expected); + } + } + #[test] + fn allocation_failure_is_terminal() { + for buffered in [false, true] { + let mut sponge = Sponge::start( + Identity, + vec![Call::Absorb(1), Call::Squeeze(1), Call::Squeeze(1)], + 0, + ) + .unwrap(); + sponge.absorb(1, [99]).unwrap(); + if buffered { + sponge.squeeze(1).unwrap(); + watch(&sponge.output[0]); + } + // Identity's constant tag permits adapting the next call to force + // growth. + let len = sponge.output.capacity().max(1); + sponge.iopattern[sponge.io_count] = Call::Squeeze(len); + FAIL_NEXT.set(true); + assert_eq!(sponge.squeeze(len), Err(Error::InvalidIOPattern)); + assert!(!FAIL_NEXT.get()); + assert!(sponge.output.is_empty()); + if buffered { + // Inspect now, before finish/Drop could mask missed error + // cleanup. + observe(sponge.output.as_mut_ptr().cast()); + assert!(WIPED.get(), "buffered output must be wiped on error"); + } + assert_eq!(sponge.squeeze(len), Err(Error::IOPatternViolation)); + assert_eq!(sponge.absorb(1, [99]), Err(Error::IOPatternViolation)); + assert_eq!(sponge.finish(), Err(Error::IOPatternViolation)); + } + } + #[cfg(feature = "encryption")] + impl crate::Encryption for Identity { + fn subtract(&mut self, a: &u64, b: &u64) -> u64 { + if FAIL_AFTER_SUBTRACT.replace(false) { + watch(b); + FAIL_NEXT.set(true); + } + if PANIC_ON_SUBTRACT.replace(false) { + watch(b); + panic!("injected backend panic"); + } + a.wrapping_sub(*b) + } + fn is_equal(&mut self, a: &u64, b: &u64) -> bool { + a == b + } + } + #[cfg(feature = "encryption")] + #[test] + fn unwinding_wipes_message_buffer() { + PANIC_ON_SUBTRACT.set(true); + let result = std::panic::catch_unwind(|| { + crate::decrypt(Identity, 0u64, [77u64; 5], &[7, 8], &9) + }); + assert_eq!( + result.unwrap_err().downcast_ref::<&str>(), + Some(&"injected backend panic") + ); + assert!(WIPED.get(), "message buffer must be wiped on unwind"); + } + #[cfg(feature = "encryption")] + #[test] + fn late_allocation_failure_wipes_decrypted_message() { + FAIL_AFTER_SUBTRACT.set(true); + let result = crate::decrypt(Identity, 0u64, [77u64; 5], &[7, 8], &9); + assert!(!FAIL_NEXT.get()); + assert_eq!(result, Err(Error::InvalidIOPattern)); + assert!( + WIPED.get(), + "plaintext must be wiped on the late squeeze error" + ); + } +} diff --git a/tests/encryption.rs b/tests/encryption.rs index 21bf7d2..f3e2878 100644 --- a/tests/encryption.rs +++ b/tests/encryption.rs @@ -33,9 +33,7 @@ impl Safe for HashState { }); } - // Setting the tag to a constant zero here so that the sponge output - // is predictable, this should *not* be done in production as it makes the - // resulting hash vulnerable to collisions attacks. + // Bind the mechanics fixture to the encoded pattern and domain. fn tag(&mut self, input: &[u8]) -> BlsScalar { BlsScalar::hash_to_scalar(input) } @@ -126,148 +124,100 @@ fn empty_message_and_short_ciphers_fail() { } #[test] -fn incorrect_shared_secret_fails() -> Result<(), Error> { +fn incorrect_parameters_fail() -> Result<(), Error> { let mut rng = StdRng::seed_from_u64(0x42424242); - let message_len = 21usize; - - let (message, shared_secret, nonce) = - encryption_variables(&mut rng, message_len); - - let cipher = encrypt( - HashState::new(), - DOMAIN, - &message, - &shared_secret.to_hash_inputs(), - &nonce, - )?; + let (message, shared_secret, nonce) = encryption_variables(&mut rng, 21); + let key = shared_secret.to_hash_inputs(); + let cipher = encrypt(HashState::new(), DOMAIN, &message, &key, &nonce)?; + // Preserve each former test's random draw from the same post-setup state. + let wrong_nonce = BlsScalar::random(&mut rng.clone()); let wrong_shared_secret = GENERATOR_EXTENDED * &JubJubScalar::random(&mut rng); assert_ne!(shared_secret, wrong_shared_secret); - - assert_eq!( - decrypt( - HashState::new(), - DOMAIN, - &cipher, - &wrong_shared_secret.to_hash_inputs(), - &nonce, - ) - .unwrap_err(), - Error::DecryptionFailed - ); - - Ok(()) -} - -#[test] -fn incorrect_nonce_fails() -> Result<(), Error> { - let mut rng = StdRng::seed_from_u64(0x42424242); - let message_len = 21usize; - - let (message, shared_secret, nonce) = - encryption_variables(&mut rng, message_len); - - let cipher = encrypt( - HashState::new(), - DOMAIN, - &message, - &shared_secret.to_hash_inputs(), - &nonce, - )?; - - let wrong_nonce = BlsScalar::random(&mut rng); assert_ne!(nonce, wrong_nonce); - assert_eq!( - decrypt( - HashState::new(), + for (parameter, key, nonce, domain) in [ + ( + "shared secret", + wrong_shared_secret.to_hash_inputs(), + nonce, DOMAIN, - &cipher, - &shared_secret.to_hash_inputs(), - &wrong_nonce, - ) - .unwrap_err(), - Error::DecryptionFailed - ); - + ), + ("nonce", key, wrong_nonce, DOMAIN), + ("domain", key, nonce, 1), + ] { + assert_eq!( + decrypt(HashState::new(), domain, &cipher, &key, &nonce), + Err(Error::DecryptionFailed), + "incorrect {parameter} must reject" + ); + } Ok(()) } #[test] -fn incorrect_domain_fails() -> Result<(), Error> { - let mut rng = StdRng::seed_from_u64(0x42424242); - let message_len = 21usize; - - let (message, shared_secret, nonce) = - encryption_variables(&mut rng, message_len); - - let cipher = encrypt( - HashState::new(), - DOMAIN, - &message, - &shared_secret.to_hash_inputs(), - &nonce, - )?; - - assert_eq!( - decrypt( - HashState::new(), - 1u64, - &cipher, - &shared_secret.to_hash_inputs(), - &nonce, - ) - .unwrap_err(), - Error::DecryptionFailed - ); - - Ok(()) +fn independent_encryption_vectors() { + // Frozen Python integer/hashlib output; provenance and Rust regeneration: + // examples/generate_encryption_fixtures.rs. This tests SAFE mechanics, + // not a production backend. + for line in include_str!("fixtures/encryption.txt").lines().skip(1) { + let (length, hex) = line.split_once(' ').unwrap(); + let n = length.parse::().unwrap(); + let expected: Vec<_> = (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).unwrap()) + .collect(); + let message: Vec<_> = + (1..=n).map(|i| BlsScalar::from(i as u64)).collect(); + let key = [BlsScalar::from(7), BlsScalar::from(8)]; + let nonce = BlsScalar::from(9); + let cipher = + encrypt(HashState::new(), DOMAIN, &message, &key, &nonce).unwrap(); + assert_eq!( + cipher.iter().flat_map(|s| s.to_bytes()).collect::>(), + expected + ); + assert_eq!( + decrypt(HashState::new(), DOMAIN, &cipher, &key, &nonce).unwrap(), + message + ); + } } #[test] fn incorrect_cipher_fails() -> Result<(), Error> { let mut rng = StdRng::seed_from_u64(0x42424242); - let message_len = 21usize; - - let (message, shared_secret, nonce) = - encryption_variables(&mut rng, message_len); - - let cipher = encrypt( - HashState::new(), - DOMAIN, - &message, - &shared_secret.to_hash_inputs(), - &nonce, - )?; - - let mut wrong_cipher = cipher.clone(); - wrong_cipher[message_len] += BlsScalar::from(42); - assert_eq!( - decrypt( - HashState::new(), - 1u64, - &wrong_cipher, - &shared_secret.to_hash_inputs(), - &nonce, - ) - .unwrap_err(), - Error::DecryptionFailed - ); - - let mut wrong_cipher = cipher.clone(); - wrong_cipher[0] += BlsScalar::from(42); - assert_eq!( - decrypt( - HashState::new(), - 1u64, - &wrong_cipher, - &shared_secret.to_hash_inputs(), - &nonce, - ) - .unwrap_err(), - Error::DecryptionFailed - ); - + let rate = W - 1; + for message_len in [ + 1, + rate - 1, + rate, + rate + 1, + 2 * rate - 1, + 2 * rate, + 2 * rate + 1, + 21, + ] { + let (message, shared_secret, nonce) = + encryption_variables(&mut rng, message_len); + let key = shared_secret.to_hash_inputs(); + let cipher = encrypt(HashState::new(), DOMAIN, &message, &key, &nonce)?; + assert_eq!( + decrypt(HashState::new(), DOMAIN, &cipher, &key, &nonce)?, + message + ); + // Change only one ciphertext element, including every position and the + // tag. + for position in 0..cipher.len() { + let mut wrong_cipher = cipher.clone(); + wrong_cipher[position] += BlsScalar::from(42); + assert_eq!( + decrypt(HashState::new(), DOMAIN, &wrong_cipher, &key, &nonce), + Err(Error::DecryptionFailed), + "length {message_len}, position {position}" + ); + } + } Ok(()) } diff --git a/tests/fixtures/encryption.txt b/tests/fixtures/encryption.txt new file mode 100644 index 0000000..1caef62 --- /dev/null +++ b/tests/fixtures/encryption.txt @@ -0,0 +1,5 @@ +# Originally Python stdlib; see examples/generate_encryption_fixtures.rs for provenance. +1 46422b497601850e3979a69ef1b0cdeef452549d3de1922ee309a0b3588823419d96bb8d328275c47773c02e19bbf5e395109660f11bf953d3c985b8bf70c948 +6 9afc92af03fb83536f8ba119565847219030131b5c688f444c1399b897e6d60a088c99041a79191728f1b7819d0cb192102441256aaf7cda2beede7c7ec6d20ce78cee6fa60384044145d7037ecfb1d77706c3e751b9b0b8e66d170da7b741432cf069d6c42976352f1faafd7a08268767705df6b3275314a6dbd4280dcc602d7dd81c277d977f6d769bd9f8d00f10a8168488e4e5a5809e2c5a8b495e6ce74b013c9737267be7894659804f1435a41a00ed8a4cd6daeacac6c29a1da7ca514bbbbddc4047927072823d328e52467ca384b7998e769331e9e6e4a8272ff87020 +7 690577a36918210e3728866b64c845bf1722fac3e44222b658f4a43b05f4e466c6c887abca5f9fb4e75625f578d25d1c85b656a106bb5ae8f61b97767154b71cb7fb7223bf03074e0c0dd518cd37feb37380f8403794df54877038adc513f4393068e11c834a24a3b924110f63576fd92cb4909fff5b76b48609d83e6595b21b00537333c02369103cf44988717a13fba765c991c08f4530f95653ce7d9e3a3733e044454349e0dad96824f94fdfd5e398595f3988638272307558baa4067132ea92d8287f6d5dffa34e55f40e154c84692f990288349c1784c283ee2d11ec4c296109f0cc30a766dc109ade22b4d86f0a02999e5ecaa6c3b89be619c708311d +13 3ec74ae3f445767d972d24c3aeaa99629bef09dd707553ce7a1bcf027f772e26e01420261c7f1c99feccb7312a38587320ac84a8b5648517187d6d87d4092a5f0b6a3d428e635e414aadc6f06e57f65dbc4868db6c26a0067613e26843c2db58b819b9e3b670d39e0646362486dd1175e111964d0804630fba928a49c9819b4345ccf7faaf965c934b6e543113f845650b7486c7921145efa7e3f6b258ee46013730d924514682bdd15f98673c750d12ab0f7abc8d35e45fb8e5a9e790f7961e4ee2c4652d48765454ce5465a5021b4fd6c690a2b18c207933b28cc12f4e2d0fd769a997e2b857ff0b99ebb462c9ff48411a91e96b36f9a01b81d8ffdeef94446ccdf5daabe75eb9de9c2d1c6802e6f6ad569ee9021e07182e4d888e6cb64a01b2e1513496a113ad05e15d56e3823adb28c9fee35d762d9f469fffd14b3c065db6882076112ab8358c80bf1dd6942ce77d808d87e2701accc4295fe9be4e742e23cc241955b2cea6deaf04956b0552ae5a818d14f6f39cec14351704054f8e13da69e01fb9eac54177243073621c1a3bb1910d42715b280713977179809739255a7ec5354e2a4d4447d7b766d73c22041445cebaf887909aca6bf537bdfd671b diff --git a/tests/sponge.rs b/tests/sponge.rs index 5b9b379..9283570 100644 --- a/tests/sponge.rs +++ b/tests/sponge.rs @@ -6,6 +6,7 @@ use dusk_bls12_381::BlsScalar; use dusk_safe::{Call, Error, Safe, Sponge}; +use zeroize::Zeroize; const W: usize = 7; @@ -40,6 +41,122 @@ impl Rotate { } } +#[test] +fn failures_are_terminal() { + for case in 0..9 { + let mut sponge = Sponge::start( + Rotate::new(), + vec![Call::Absorb(2), Call::Squeeze(1)], + 0, + ) + .unwrap(); + if case >= 5 { + sponge.absorb(2, [BlsScalar::one(); 2]).unwrap(); + } + if case >= 7 { + sponge.squeeze(1).unwrap(); + } + let error = match case { + 0 => sponge.squeeze(1), + 1 => sponge.absorb(1, [BlsScalar::one()]), + 2 => sponge.absorb(3, [BlsScalar::one(); 3]), + 3 => sponge.absorb(2, []), + 4 => sponge.absorb(2, [BlsScalar::one()]), + 5 | 7 => sponge.absorb(2, [BlsScalar::one(); 2]), + _ => sponge.squeeze(2), + }; + assert_eq!( + error, + Err(if matches!(case, 3 | 4) { + Error::TooFewInputElements + } else { + Error::IOPatternViolation + }) + ); + assert_eq!( + sponge.absorb(2, [BlsScalar::one(); 2]), + Err(Error::IOPatternViolation) + ); + assert_eq!(sponge.squeeze(1), Err(Error::IOPatternViolation)); + assert_eq!(sponge.clone().finish(), Err(Error::IOPatternViolation)); + sponge.zeroize(); + assert_eq!(sponge.finish(), Err(Error::IOPatternViolation)); + } +} + +#[test] +fn explicit_zeroization_is_terminal() { + for progress in 0..3 { + let mut sponge = Sponge::start( + Rotate::new(), + vec![Call::Absorb(1), Call::Squeeze(1)], + 0, + ) + .unwrap(); + if progress >= 1 { + sponge.absorb(1, [BlsScalar::one()]).unwrap(); + } + if progress == 2 { + sponge.squeeze(1).unwrap(); + } + sponge.zeroize(); + assert_eq!( + sponge.absorb(1, [BlsScalar::one()]), + Err(Error::IOPatternViolation) + ); + assert_eq!(sponge.squeeze(1), Err(Error::IOPatternViolation)); + assert_eq!(sponge.finish(), Err(Error::IOPatternViolation)); + } +} + +#[test] +fn unsupported_widths_are_rejected() { + // Mechanics-only backend; invalid widths must reject before invoking it. + struct Minimal; + impl Safe for Minimal { + fn tag(&mut self, _: &[u8]) -> BlsScalar { + assert!(N >= 2); + BlsScalar::zero() + } + fn add(&mut self, a: &BlsScalar, b: &BlsScalar) -> BlsScalar { + a + b + } + fn permute(&mut self, _: &mut [BlsScalar; N]) {} + } + let pattern = vec![Call::Absorb(1), Call::Squeeze(1)]; + assert_eq!( + Sponge::<_, BlsScalar, 0>::start(Minimal, pattern.clone(), 0) + .unwrap_err(), + Error::InvalidIOPattern + ); + assert_eq!( + Sponge::<_, BlsScalar, 1>::start(Minimal, pattern.clone(), 0) + .unwrap_err(), + Error::InvalidIOPattern + ); + let mut sponge = + Sponge::<_, BlsScalar, 2>::start(Minimal, pattern, 0).unwrap(); + sponge.absorb(1, [BlsScalar::one()]).unwrap(); + sponge.squeeze(1).unwrap(); + assert_eq!(sponge.finish().unwrap(), [BlsScalar::one()]); +} + +#[test] +fn debug_is_redacted() { + let mut sponge = Sponge::start( + Rotate::new(), + vec![Call::Absorb(1), Call::Squeeze(1)], + 0, + ) + .unwrap(); + sponge.absorb(1, [BlsScalar::from(77665544)]).unwrap(); + sponge.squeeze(1).unwrap(); + assert_eq!( + format!("{sponge:?}"), + "Sponge { width: 7, io_count: 2, failed: false, .. }" + ); +} + #[test] fn sponge() -> Result<(), Error> { // pick a domain-separator @@ -126,59 +243,6 @@ fn sponge() -> Result<(), Error> { Ok(()) } -#[test] -fn absorb_fails() -> Result<(), Error> { - // pick a domain-separator - let domain_sep = 0; - - // build the io-pattern - let iopattern = vec![Call::Absorb(6), Call::Squeeze(1)]; - - // start the sponge - let input = [BlsScalar::one(); 10]; - let mut sponge = Sponge::start(Rotate::new(), iopattern, domain_sep)?; - - // input-slice smaller than len - let error = sponge.clone().absorb(6, &input[..4]).unwrap_err(); - assert_eq!(error, Error::TooFewInputElements); - - // absorb len is not as io-pattern specifies - let error = sponge.clone().absorb(4, &input[..4]).unwrap_err(); - assert_eq!(error, Error::IOPatternViolation); - - // unexpected call to squeeze - let error = sponge.squeeze(1).unwrap_err(); - assert_eq!(error, Error::IOPatternViolation); - - Ok(()) -} - -#[test] -fn squeeze_fails() -> Result<(), Error> { - // pick a domain-separator - let domain_sep = 0; - - // build the io-pattern - let iopattern = vec![Call::Absorb(6), Call::Squeeze(1)]; - - // start the sponge - let input = [BlsScalar::one(); 10]; - let mut sponge = Sponge::start(Rotate::new(), iopattern, domain_sep)?; - - // absorb 6 elements as specified by the io-pattern - sponge.absorb(6, &input[..6])?; - - // squeeze 4 elements when io-pattern expects 1 - let error = sponge.clone().squeeze(4).unwrap_err(); - assert_eq!(error, Error::IOPatternViolation); - - // unexpected call to absorb when io-pattern expects squeeze - let error = sponge.absorb(1, &input).unwrap_err(); - assert_eq!(error, Error::IOPatternViolation); - - Ok(()) -} - #[test] fn finish_fails() -> Result<(), Error> { // pick a domain-separator diff --git a/tests/zeroize-compat/Cargo.toml b/tests/zeroize-compat/Cargo.toml new file mode 100644 index 0000000..b487836 --- /dev/null +++ b/tests/zeroize-compat/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "safe-zeroize-compat" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +dusk-safe = { path = "../..", features = ["encryption"] } +zeroize = "=1.5.4" + +# Keep SAFE's dev-dependencies out of this consumer's resolution. +[workspace] diff --git a/tests/zeroize-compat/src/lib.rs b/tests/zeroize-compat/src/lib.rs new file mode 100644 index 0000000..626e047 --- /dev/null +++ b/tests/zeroize-compat/src/lib.rs @@ -0,0 +1,9 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) DUSK NETWORK. All rights reserved. + +#![no_std] + +pub use dusk_safe::{decrypt, encrypt}; From a00265eb75175aca9654c9bbc0d0064147ed35cc Mon Sep 17 00:00:00 2001 From: Hein Dauven Date: Fri, 11 Sep 2026 11:05:56 +0200 Subject: [PATCH 2/2] Address sponge hardening review notes Disarm allocation-failure injection before test assertions can panic. Retain the original fixture-generator provenance under an archival tag and record its source hash without shipping Python. Remove implementation-only changelog entries. Keep the runtime implementation and inlining unchanged. Track the pre-existing equality API separately in #39. Refs #38 and #37. --- CHANGELOG.md | 2 -- examples/generate_encryption_fixtures.rs | 4 ++++ src/sponge.rs | 7 ++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f2dd0f..a46acf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Encode checked IO-pattern runs directly without an intermediate word vector [#37] -- Specialize small squeeze calls while keeping allocation and wiping out of line [#37] - Simplify sponge finalization and IO-pattern validation - Raise the MSRV to Rust 1.96.1 [#32] diff --git a/examples/generate_encryption_fixtures.rs b/examples/generate_encryption_fixtures.rs index a13469b..4624bf2 100644 --- a/examples/generate_encryption_fixtures.rs +++ b/examples/generate_encryption_fixtures.rs @@ -9,6 +9,10 @@ //! //! Original vectors used Python integer arithmetic and hashlib: //! https://github.com/dusk-network/safe/blob/d4802e77f65e0163fcfdf7d54275e572b718724c/tests/fixtures/generate.py +//! Retained by the archival (non-release) tag +//! `provenance/encryption-fixtures-python`. +//! Source SHA-256: +//! f623540c62aa17997b53a8eaa82b19423075e1662ecfae3520c3989a8c9bd075 //! This reference calls no dusk-safe code, but shares field arithmetic/hashing //! with the Rust tests. The hash-expanded backend is NOT a cryptographic //! permutation. Fixed parameters: width 7, key [7, 8], nonce 9, domain 2^31, diff --git a/src/sponge.rs b/src/sponge.rs index f7d055e..50a5bde 100644 --- a/src/sponge.rs +++ b/src/sponge.rs @@ -477,8 +477,9 @@ mod storage_tests { let len = sponge.output.capacity().max(1); sponge.iopattern[sponge.io_count] = Call::Squeeze(len); FAIL_NEXT.set(true); - assert_eq!(sponge.squeeze(len), Err(Error::InvalidIOPattern)); - assert!(!FAIL_NEXT.get()); + let result = sponge.squeeze(len); + assert!(!FAIL_NEXT.replace(false)); + assert_eq!(result, Err(Error::InvalidIOPattern)); assert!(sponge.output.is_empty()); if buffered { // Inspect now, before finish/Drop could mask missed error @@ -526,7 +527,7 @@ mod storage_tests { fn late_allocation_failure_wipes_decrypted_message() { FAIL_AFTER_SUBTRACT.set(true); let result = crate::decrypt(Identity, 0u64, [77u64; 5], &[7, 8], &9); - assert!(!FAIL_NEXT.get()); + assert!(!FAIL_NEXT.replace(false)); assert_eq!(result, Err(Error::InvalidIOPattern)); assert!( WIPED.get(),