Skip to content
Open
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
56 changes: 54 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,63 @@ and this library adheres to Rust's notion of
## [Unreleased]
### Added
- `fpe::ff1::{InvalidRadix, NumeralStringError}`
- `fpe::ff1::FF1NewError`: new error type returned by `FF1fr::new`, with
variants `InvalidRadix` and `InvalidKeyLength`.
- `fpe::ff1::NumeralStringError::TweakTooLong`: returned by `encrypt`/`decrypt`
when the tweak length exceeds `u32::MAX` bytes (NIST SP 800-38G §5.1).
- `fpe::ff1::NumeralStringError::NotByteAligned`: returned by
`BinaryNumeralString::to_bytes_le` when the length is not a multiple of 8.

### Security
- `fpe::ff1`: `Prf` now implements `Drop` and zeroes its CBC output block
buffer on drop, removing key-derived bytes from memory.
- `fpe::ff1::FF1fr`: the expanded cipher key schedule is zeroed on drop for
any `CIPH` that implements `ZeroizeOnDrop` (all `aes 0.8` types do so by
default).
- `fpe::ff1::FF1fr::new`: construction now panics if `FEISTEL_ROUNDS < 8`,
preventing the creation of instances that would perform identity encryption.
- `fpe::ff1::FF1fr::new`: construction now panics if the cipher's block size
is not 128 bits, enforcing the requirement of NIST SP 800-38G §4.3.

### Fixed
- `fpe::ff1::FF1fr::new`: now uses `KeyInit::new_from_slice` and returns
`FF1NewError::InvalidKeyLength` instead of panicking on wrong-length keys.
- `fpe::ff1::alloc::BinaryNumeralString::to_bytes_le`: no longer panics on
non-byte-aligned input; returns `Err(NumeralStringError::NotByteAligned)`.
- `fpe::ff1::alloc`: replaced O(n) manual exponentiation loop in `pow` with
`num_traits::pow::pow`, which uses O(log n) binary exponentiation, closing a
DoS vector for large numeral strings.
- `fpe::ff1::alloc`: `assert_eq!(radix, 2)` in `BinaryNumeralString::num_radix`
and `str_radix` replaced with `debug_assert_eq!`; these are internal
invariants always satisfied by the validated call path.
- `fpe::ff1::alloc`: removed redundant parentheses in `is_valid` closures for
both `FlexibleNumeralString` and `BinaryNumeralString` (Clippy lint
`clippy::unused_parens`).
- `fpe::ff1::test_vectors`: replaced deprecated `array::IntoIter::new([...])`
with `IntoIterator::into_iter([...])` and removed the associated
`#[allow(deprecated)]` attribute and unused `use core::array` import.

### Changed
- MSRV is now 1.56.0.
- Bumped dependencies to `cipher 0.4`, `cbc 0.1`.
- MSRV is now 1.70.0.
- Bumped dependencies to `cipher 0.4`, `cbc 0.1`; added `zeroize = "1"`.
- `aes 0.8` is now the minimum compatible crate version.
- `fpe::ff1::FF1fr::new` now returns `Result<Self, FF1NewError>` instead of
`Result<Self, InvalidRadix>`; `FF1NewError` also covers invalid key length.
- `fpe::ff1::encrypt` and `decrypt` now return
`Err(NumeralStringError::TweakTooLong)` when the tweak exceeds `u32::MAX`
bytes (previously the tweak length would silently truncate in the P-block).
- `fpe::ff1`: the 16-byte P-block construction is annotated with inline
comments mapping each field to NIST SP 800-38G §6.2 Step 5. The `u` field
now uses the explicit cast `(u % 256) as u8` instead of relying on implicit
truncation.
- `fpe::ff1::Radix::to_u32`: duplicate match arms consolidated using an OR
pattern (`Radix::Any { .. } | Radix::PowerTwo { .. } => radix`).
- `fpe::ff1`: documentation updated to reference the final NIST SP 800-38G
(removed the draft Revision 1 URL).
- `fpe::ff1::FF1h`: documented as a non-standard extension that breaks CAVP
compliance and interoperability with conforming FF1 implementations.
- `fpe::ff1`: module documentation notes that only FF1 is implemented; FF3
(also defined in NIST SP 800-38G) is not provided.
- `fpe::ff1`:
- `FF1::new` now returns `Result<_, InvalidRadix>`.
- `FF1::{encrypt, decrypt}` now return `Result<_, NumeralStringError>`.
Expand Down
10 changes: 3 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[package]
name = "fpe"
version = "0.5.1"
authors = ["Jack Grigg <thestr4d@gmail.com>"]
authors = ["Jack Grigg <thestr4d@gmail.com>", "Bruno Grieder <bruno.grieder@cosmian.com>", "Hatem Mnaouer <hatem.mnaouer@cosmian.net>"]
license = "MIT/Apache-2.0"
edition = "2018"
rust-version = "1.56"
rust-version = "1.70"
description = "Format-preserving encryption"
documentation = "https://docs.rs/fpe/"
homepage = "https://github.com/str4d/fpe"
Expand All @@ -17,17 +17,13 @@ cbc = { version = "0.1", default-features = false }
cipher = "0.4"
libm = "0.2"
static_assertions = "1.1"

zeroize = { version = "1.8", default-features = false }
num-bigint = { version = "0.4", optional = true, default-features = false }
num-integer = { version = "0.1", optional = true, default-features = false }
num-traits = { version = "0.2", optional = true, default-features = false }

[dev-dependencies]
aes = "0.8"

# Benchmarks
#aes-old = { package = "aes", version = "0.3" }
#binary-ff1 = "0.1"
criterion = "0.4"

[target.'cfg(any(target_arch = "x86", target_arch = "x86_64"))'.dev-dependencies]
Expand Down
123 changes: 102 additions & 21 deletions src/ff1.rs
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
//! A Rust implementation of the FF1 algorithm, specified in
//! [NIST Special Publication 800-38G](http://dx.doi.org/10.6028/NIST.SP.800-38G).
//! [NIST Special Publication 800-38G](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38G.pdf).
//!
//! This crate implements **FF1 only**. FF3 (also defined in NIST SP 800-38G) is
//! not provided.

use core::cmp;

use cipher::{
generic_array::GenericArray, Block, BlockCipher, BlockEncrypt, BlockEncryptMut, InnerIvInit,
KeyInit,
KeyInit, Unsigned,
};
use zeroize::Zeroize;

#[cfg(test)]
use static_assertions::const_assert;

mod error;
pub use error::{InvalidRadix, NumeralStringError};
pub use error::{FF1NewError, InvalidRadix, NumeralStringError};

#[cfg(feature = "alloc")]
mod alloc;
Expand All @@ -22,14 +26,17 @@ pub use self::alloc::{BinaryNumeralString, FlexibleNumeralString};
#[cfg(test)]
mod test_vectors;

#[cfg(test)]
mod ff1_18;

/// The minimum allowed numeral string length for any radix.
const MIN_NS_LEN: u32 = 2;
/// The maximum allowed numeral string length for any radix.
const MAX_NS_LEN: usize = u32::MAX as usize;

/// The minimum allowed value of radix^minlen.
///
/// Defined in [NIST SP 800-38G Revision 1](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38Gr1-draft.pdf).
/// Defined in [NIST SP 800-38G](https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38G.pdf).
#[cfg(test)]
const MIN_NS_DOMAIN_SIZE: u32 = 1_000_000;

Expand Down Expand Up @@ -121,8 +128,7 @@ impl Radix {

fn to_u32(&self) -> u32 {
match *self {
Radix::Any { radix, .. } => radix,
Radix::PowerTwo { radix, .. } => radix,
Radix::Any { radix, .. } | Radix::PowerTwo { radix, .. } => radix,
}
}
}
Expand Down Expand Up @@ -181,6 +187,14 @@ struct Prf<CIPH: BlockCipher + BlockEncrypt> {
offset: usize,
}

impl<CIPH: BlockCipher + BlockEncrypt> Drop for Prf<CIPH> {
fn drop(&mut self) {
// Zero the CBC output block to remove any key-derived bytes.
// Note: `Block<CIPH> = GenericArray<u8, _>` implements Zeroize because u8: Zeroize.
self.buf[0].zeroize();
}
}

impl<CIPH: BlockCipher + BlockEncrypt + Clone> Prf<CIPH> {
fn new(ciph: &CIPH) -> Self {
let ciph = ciph.clone();
Expand Down Expand Up @@ -232,24 +246,65 @@ fn generate_s<'a, CIPH: BlockEncrypt>(
.take(d)
}

/// A struct for performing FF1 encryption and decryption operations.
pub struct FF1<CIPH: BlockCipher> {
/// A struct for performing FF1 encryption and decryption operations
/// using the default 10 Feistel rounds
Comment on lines +249 to +250
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
/// A struct for performing FF1 encryption and decryption operations
/// using the default 10 Feistel rounds
/// A struct for performing FF1 encryption and decryption operations.
///
/// This implements FF1 as specified in [NIST SP 800-38G revision 1], with 10 Feistel
/// rounds and a minimum domain size of $\mathsf{radix}^\mathsf{minlen} \geq 1,000,000$.
///
/// [NIST Special Publication 800-38G]: https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-38Gr1-draft.pdf

pub type FF1<CIPH> = FF1fr<10, CIPH>;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
pub type FF1<CIPH> = FF1fr<10, CIPH>;
pub type FF1<CIPH> = FF1Core<10, CIPH>;


/// A struct for performing hardened FF1 encryption and decryption operations
/// using 18 Feistel rounds.
///
/// # ⚠ Non-standard
/// NIST SP 800-38G standardises exactly **10 rounds** for FF1. This 18-round
/// variant provides a higher security margin but is **not NIST-compliant** and
/// will fail CAVP certification. It is **not interoperable** with conforming
/// implementations.
pub type FF1h<CIPH> = FF1fr<18, CIPH>;

/// A struct for performing FF1 encryption and decryption operations
/// with an adjustable number of Feistel rounds.
///
/// # Key material
/// This struct holds the expanded cipher key schedule. When `FF1fr` is dropped
/// the key schedule is zeroed if `CIPH` implements [`ZeroizeOnDrop`] — all
/// `aes 0.8` types (`Aes128`, `Aes192`, `Aes256`) do so by default.
/// The internal CBC output buffer is always zeroed on drop.
///
/// [`ZeroizeOnDrop`]: zeroize::ZeroizeOnDrop
pub struct FF1fr<const FEISTEL_ROUNDS: u8, CIPH: BlockCipher> {
ciph: CIPH,
radix: Radix,
}

impl<CIPH: BlockCipher + KeyInit> FF1<CIPH> {
impl<const FEISTEL_ROUNDS: u8, CIPH: BlockCipher + KeyInit> FF1fr<FEISTEL_ROUNDS, CIPH> {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
impl<const FEISTEL_ROUNDS: u8, CIPH: BlockCipher + KeyInit> FF1fr<FEISTEL_ROUNDS, CIPH> {
impl<const FEISTEL_ROUNDS: u8, CIPH: BlockCipher + KeyInit> FF1Core<FEISTEL_ROUNDS, CIPH> {

/// Creates a new FF1 object for the given key and radix.
///
/// Returns an error if the given radix is not in [2..2^16].
pub fn new(key: &[u8], radix: u32) -> Result<Self, InvalidRadix> {
let ciph = CIPH::new(GenericArray::from_slice(key));
/// Returns an error if:
/// - the radix is not in `[2..2^16]`, or
/// - the key length does not match the cipher's requirement.
///
/// # Panics
/// Panics at construction time if `FEISTEL_ROUNDS < 8` or if the cipher's
/// block size is not 128 bits (16 bytes), as required by NIST SP 800-38G §4.3.
pub fn new(key: &[u8], radix: u32) -> Result<Self, FF1NewError> {
assert!(
FEISTEL_ROUNDS >= 8,
"FF1fr requires at least 8 Feistel rounds; got FEISTEL_ROUNDS = {}",
FEISTEL_ROUNDS
);
assert_eq!(
CIPH::BlockSize::USIZE,
16,
"FF1 requires a 128-bit (16-byte) block cipher (NIST SP 800-38G §4.3)"
);
let ciph = CIPH::new_from_slice(key).map_err(|_| FF1NewError::InvalidKeyLength)?;
let radix = Radix::from_u32(radix)?;
Ok(FF1 { ciph, radix })
Ok(FF1fr { ciph, radix })
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
Ok(FF1fr { ciph, radix })
Ok(Self { ciph, radix })

}
}

impl<CIPH: BlockCipher + BlockEncrypt + Clone> FF1<CIPH> {
impl<const FEISTEL_ROUNDS: u8, CIPH: BlockCipher + BlockEncrypt + Clone>
FF1fr<FEISTEL_ROUNDS, CIPH>
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Suggested change
FF1fr<FEISTEL_ROUNDS, CIPH>
FF1Core<FEISTEL_ROUNDS, CIPH>

{
/// Encrypts the given numeral string.
///
/// Returns an error if the numeral string is not in the required radix.
Expand All @@ -267,6 +322,11 @@ impl<CIPH: BlockCipher + BlockEncrypt + Clone> FF1<CIPH> {
let n = x.numeral_count();
let t = tweak.len();

// Enforce NIST SP 800-38G §5.1: t must fit in 4 bytes in the P-block.
if t > u32::MAX as usize {
return Err(NumeralStringError::TweakTooLong);
}

// 1. Let u = floor(n / 2); v = n - u
let u = n / 2;
let v = n - u;
Expand All @@ -280,8 +340,16 @@ impl<CIPH: BlockCipher + BlockEncrypt + Clone> FF1<CIPH> {
// 4. Let d = 4 * ceil(b / 4) + 4.
let d = 4 * ((b + 3) / 4) + 4;

// 5. Let P = [1, 2, 1] || [radix] || [10] || [u mod 256] || [n] || [t].
let mut p = [1, 2, 1, 0, 0, 0, 10, u as u8, 0, 0, 0, 0, 0, 0, 0, 0];
// 5. Build the 16-byte P-block (NIST SP 800-38G §6.2, Step 5):
// P = [1]₁ VERS: 1 (FF1)
// || [2]₁ ALGO: 2 (AES-CBCMAC)
// || [1]₁ constant
// || [radix]₃ radix in 3 big-endian bytes
// || [10]₁ constant
// || [u mod 256]₁
// || [n]₄ numeral string length
// || [t]₄ tweak length
let mut p = [1, 2, 1, 0, 0, 0, 10, (u % 256) as u8, 0, 0, 0, 0, 0, 0, 0, 0];
p[3..6].copy_from_slice(&self.radix.to_u32().to_be_bytes()[1..]);
p[8..12].copy_from_slice(&(n as u32).to_be_bytes());
p[12..16].copy_from_slice(&(t as u32).to_be_bytes());
Expand All @@ -294,7 +362,7 @@ impl<CIPH: BlockCipher + BlockEncrypt + Clone> FF1<CIPH> {
for _ in 0..((((-(t as i32) - (b as i32) - 1) % 16) + 16) % 16) {
prf.update(&[0]);
}
for i in 0..10 {
for i in 0..FEISTEL_ROUNDS {
let mut prf = prf.clone();
prf.update(&[i]);
prf.update(x_b.num_radix(self.radix.to_u32()).to_bytes(b).as_ref());
Expand Down Expand Up @@ -345,6 +413,11 @@ impl<CIPH: BlockCipher + BlockEncrypt + Clone> FF1<CIPH> {
let n = x.numeral_count();
let t = tweak.len();

// Enforce NIST SP 800-38G §5.1: t must fit in 4 bytes in the P-block.
if t > u32::MAX as usize {
return Err(NumeralStringError::TweakTooLong);
}

// 1. Let u = floor(n / 2); v = n - u
let u = n / 2;
let v = n - u;
Expand All @@ -358,8 +431,16 @@ impl<CIPH: BlockCipher + BlockEncrypt + Clone> FF1<CIPH> {
// 4. Let d = 4 * ceil(b / 4) + 4.
let d = 4 * ((b + 3) / 4) + 4;

// 5. Let P = [1, 2, 1] || [radix] || [10] || [u mod 256] || [n] || [t].
let mut p = [1, 2, 1, 0, 0, 0, 10, u as u8, 0, 0, 0, 0, 0, 0, 0, 0];
// 5. Build the 16-byte P-block (NIST SP 800-38G §6.2, Step 5):
// P = [1]₁ VERS: 1 (FF1)
// || [2]₁ ALGO: 2 (AES-CBCMAC)
// || [1]₁ constant
// || [radix]₃ radix in 3 big-endian bytes
// || [10]₁ constant
// || [u mod 256]₁
// || [n]₄ numeral string length
// || [t]₄ tweak length
let mut p = [1, 2, 1, 0, 0, 0, 10, (u % 256) as u8, 0, 0, 0, 0, 0, 0, 0, 0];
p[3..6].copy_from_slice(&self.radix.to_u32().to_be_bytes()[1..]);
p[8..12].copy_from_slice(&(n as u32).to_be_bytes());
p[12..16].copy_from_slice(&(t as u32).to_be_bytes());
Expand All @@ -372,8 +453,8 @@ impl<CIPH: BlockCipher + BlockEncrypt + Clone> FF1<CIPH> {
for _ in 0..((((-(t as i32) - (b as i32) - 1) % 16) + 16) % 16) {
prf.update(&[0]);
}
for i in 0..10 {
let i = 9 - i;
for i in 0..FEISTEL_ROUNDS {
let i = FEISTEL_ROUNDS - 1 - i;
let mut prf = prf.clone();
prf.update(&[i]);
prf.update(x_a.num_radix(self.radix.to_u32()).to_bytes(b).as_ref());
Expand Down
Loading