Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
/target
target/
**/*.rs.bk
Cargo.lock
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ The core abstraction is the **SAFE (Sponge API for Field Elements)** framework:
Behind the `encryption` feature:

- **`Encryption<T, W>` 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

Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ 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
Expand Down Expand Up @@ -58,6 +65,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add documentation

<!-- ISSUES -->
[#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
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 24 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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::<T>()`. 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

Expand All @@ -39,19 +39,22 @@ 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.
5. Set the IO count to zero.

### `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)`

Expand All @@ -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
Expand Down
96 changes: 96 additions & 0 deletions examples/generate_encryption_fixtures.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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
Comment on lines +10 to +11

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NOTE: The provenance link points at a commit that no branch or tag contains after the squash; GitHub keeps it only as a dangling object.

Context: Commit d4802e77 exists on the remote today but is not reachable from main or from the head of this pull request. Dangling objects are not guaranteed to survive garbage collection. The fixture header in tests/fixtures/encryption.txt delegates its provenance to this comment, so both lose their reference together.

Fix: Keep the Python generator in the tree under tests/fixtures/, or record its SHA-256 next to the link so the claim stays checkable without the commit. If neither is wanted, state that the Rust example is the sole reference and drop the link.

//! 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,
//! 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<BlsScalar> {
// 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<dyn std::error::Error>> {
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(())
}
40 changes: 14 additions & 26 deletions src/encryption.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use alloc::vec::Vec;

use zeroize::Zeroize;
use zeroize::{Zeroize, Zeroizing};

use crate::{Call, Error, Safe, Sponge};

Expand Down Expand Up @@ -37,7 +37,10 @@ pub trait Encryption<T, const W: usize> {
///
/// # 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;
}

Expand Down Expand Up @@ -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]);
}
Expand All @@ -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
Expand Down Expand Up @@ -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)?;
Expand All @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading