fpr-ff1 2.1.0-rc2

NIST SP 800-38G FF1 format-preserving encryption: exact integer arithmetic, conformance-tested against the published vectors.
Documentation
//! Conformance evidence for the crate (plan 00009 STEP-07).
//!
//! Every expected value comes from the project's shared fixtures in
//! `tests/vectors/`, the same files the Python suite reads: the NIST
//! SP 800-38G samples and their per-round intermediates, the FIPS 197
//! Appendix C AES vectors, and the frozen independent-oracle vectors. None
//! was generated by this implementation.
//!
//! The NIST samples and the frozen vectors run through the public API, so
//! they also cover validation and the alphabet mapping. The intermediates
//! and the AES vectors reach the core directly: those are the core's
//! evidence, as in the Python suite.

use num_bigint::BigUint;
use num_traits::One;
use serde_json::Value;

use crate::engine::{cipher_block_with_key, ff1_traced, TraceRecord};
use crate::test_fixtures;
use crate::FF1;

/// Decode hex the way Python's `bytes.fromhex` does, skipping ASCII
/// whitespace: the AES-192 key in the intermediates fixture carries a line
/// break from its transcription, and fixtures are never edited.
fn hex(s: &str) -> Vec<u8> {
    let digits: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
    assert!(digits.len().is_multiple_of(2), "odd-length hex {s:?}");
    digits
        .chunks(2)
        .map(|pair| {
            let pair = std::str::from_utf8(pair).expect("ASCII hex");
            u8::from_str_radix(pair, 16).expect("hex digit")
        })
        .collect()
}

fn str_field<'a>(v: &'a Value, key: &str) -> &'a str {
    v[key]
        .as_str()
        .unwrap_or_else(|| panic!("{key} is not a string"))
}

fn u64_field(v: &Value, key: &str) -> u64 {
    v[key]
        .as_u64()
        .unwrap_or_else(|| panic!("{key} is not an unsigned integer"))
}

fn bytes_field(v: &Value, key: &str) -> Vec<u8> {
    v[key]
        .as_array()
        .unwrap_or_else(|| panic!("{key} is not a list"))
        .iter()
        .map(|b| u8::try_from(b.as_u64().expect("byte")).expect("byte range"))
        .collect()
}

fn numerals_field(v: &Value, key: &str) -> Vec<u16> {
    v[key]
        .as_array()
        .unwrap_or_else(|| panic!("{key} is not a list"))
        .iter()
        .map(|n| u16::try_from(n.as_u64().expect("numeral")).expect("u16 range"))
        .collect()
}

/// The standard digits-then-lowercase alphabet the NIST radix-10 and
/// radix-36 samples use.
fn nist_alphabet(radix: u64) -> String {
    "0123456789abcdefghijklmnopqrstuvwxyz"[..usize::try_from(radix).expect("small")].to_string()
}

/// Map characters to numerals by character position -- not byte offset: the
/// frozen radix-62 vectors use a non-ASCII alphabet.
fn to_numerals(s: &str, alphabet: &str) -> Vec<u16> {
    let chars: Vec<char> = alphabet.chars().collect();
    s.chars()
        .map(|c| {
            let i = chars.iter().position(|&a| a == c).expect("in alphabet");
            u16::try_from(i).expect("u16")
        })
        .collect()
}

#[test]
fn nist_samples_encrypt_and_decrypt_through_the_public_api() {
    let Some(vectors) = test_fixtures::load("nist_ff1_samples.json") else {
        return;
    };
    let vectors = vectors["vectors"].as_array().expect("vectors");
    assert_eq!(vectors.len(), 9, "all nine published samples");
    for v in vectors {
        let key = hex(str_field(v, "key"));
        let tweak = hex(str_field(v, "tweak"));
        let ff1 = FF1::builder(&key, u32::try_from(u64_field(v, "radix")).expect("radix"))
            .alphabet(str_field(v, "alphabet"))
            .tweak(&tweak)
            .build()
            .expect("NIST configuration is valid");
        let name = str_field(v, "name");
        assert_eq!(
            ff1.encrypt(str_field(v, "plaintext"), None).expect("valid"),
            str_field(v, "ciphertext"),
            "encrypt {name}"
        );
        assert_eq!(
            ff1.decrypt(str_field(v, "ciphertext"), None)
                .expect("valid"),
            str_field(v, "plaintext"),
            "decrypt {name}"
        );
    }
}

/// Assert one round of the core's trace against the published values.
fn assert_round(rec: &TraceRecord, want: &Value, name: &str) {
    let round = u64_field(want, "i");
    let ctx = format!("{name} round {round}");
    assert_eq!(u64::from(rec.i), round, "{ctx}: i");
    assert_eq!(rec.p, bytes_field(want, "P"), "{ctx}: P");
    assert_eq!(rec.q, bytes_field(want, "Q"), "{ctx}: Q");
    assert_eq!(rec.r, bytes_field(want, "R"), "{ctx}: R");
    assert_eq!(rec.s, bytes_field(want, "S"), "{ctx}: S");
    // y and c can exceed u64: compare as exact decimal integers.
    assert_eq!(
        BigUint::from_bytes_be(&rec.y).to_string(),
        want["y"].to_string(),
        "{ctx}: y"
    );
    assert_eq!(rec.m as u64, u64_field(want, "m"), "{ctx}: m");
    assert_eq!(
        BigUint::from_bytes_be(&rec.c).to_string(),
        want["c"].to_string(),
        "{ctx}: c"
    );
    assert_eq!(rec.c_block, numerals_field(want, "C"), "{ctx}: C");
}

#[test]
fn nist_per_round_intermediates() {
    // Two compensating bugs can pass an output test; they cannot pass this
    // one (AGENTS.md tests section 2).
    let Some(vectors) = test_fixtures::load("nist_ff1_intermediates.json") else {
        return;
    };
    let vectors = vectors["vectors"].as_array().expect("vectors");
    assert_eq!(vectors.len(), 9);
    for v in vectors {
        let name = str_field(v, "name");
        let radix = u64_field(v, "radix");
        let alphabet = nist_alphabet(radix);
        let x = to_numerals(str_field(v, "plaintext"), &alphabet);
        let (out, trace) = ff1_traced(
            &hex(str_field(v, "key")),
            u32::try_from(radix).expect("radix"),
            &x,
            &hex(str_field(v, "tweak")),
        )
        .expect("valid NIST input");
        assert_eq!(
            out,
            to_numerals(str_field(v, "ciphertext"), &alphabet),
            "{name}: output"
        );
        let rounds = v["rounds"].as_array().expect("rounds");
        assert_eq!(trace.len(), 10, "{name}: exactly ten rounds");
        assert_eq!(rounds.len(), 10);
        for (rec, want) in trace.iter().zip(rounds) {
            assert_eq!(rec.u as u64, u64_field(v, "u"), "{name}: u");
            assert_eq!(rec.v as u64, u64_field(v, "v"), "{name}: v");
            assert_eq!(rec.b as u64, u64_field(v, "b"), "{name}: b");
            assert_eq!(rec.d as u64, u64_field(v, "d"), "{name}: d");
            assert_round(rec, want, name);
        }
    }
}

#[test]
fn fips197_aes_known_answer_vectors() {
    let Some(vectors) = test_fixtures::load("aes_kat_fips197.json") else {
        return;
    };
    let vectors = vectors["vectors"].as_array().expect("vectors");
    assert_eq!(vectors.len(), 3, "AES-128, -192 and -256");
    for v in vectors {
        let block: [u8; 16] = hex(str_field(v, "plaintext")).try_into().expect("16 bytes");
        let out = cipher_block_with_key(&hex(str_field(v, "key")), &block).expect("valid key");
        assert_eq!(
            out.to_vec(),
            hex(str_field(v, "ciphertext")),
            "{}",
            str_field(v, "name")
        );
    }
}

#[test]
fn frozen_oracle_vectors_through_the_public_api() {
    let Some(vectors) = test_fixtures::load("oracle_kat_frozen.json") else {
        return;
    };
    let vectors = vectors["vectors"].as_array().expect("vectors");
    assert!(
        vectors.len() >= 40,
        "the frozen set has {} vectors",
        vectors.len()
    );
    let mut max_d = 0usize;
    for v in vectors {
        let radix = u32::try_from(u64_field(v, "radix")).expect("radix");
        let key = hex(str_field(v, "key_hex"));
        let tweak = hex(str_field(v, "tweak_hex"));
        let alphabet = str_field(v, "alphabet");
        let ff1 = FF1::builder(&key, radix)
            .alphabet(alphabet)
            .tweak(&tweak)
            .build()
            .expect("frozen configuration is valid");
        let (pt, ct) = (str_field(v, "plaintext"), str_field(v, "ciphertext"));
        assert_eq!(
            ff1.encrypt(pt, None).expect("valid"),
            ct,
            "encrypt radix {radix}"
        );
        assert_eq!(
            ff1.decrypt(ct, None).expect("valid"),
            pt,
            "decrypt radix {radix}"
        );
        let x = to_numerals(pt, alphabet);
        let (_, trace) = ff1_traced(&key, radix, &x, &tweak).expect("valid");
        max_d = max_d.max(trace[0].d);
    }
    // The NIST samples never reach the step 6.iii S-expansion; these must.
    assert!(
        max_d > 16,
        "no frozen vector reaches d > 16 (max d = {max_d})"
    );
}

/// `b = ceil(bits(radix**v - 1) / 8)` computed independently of the core,
/// with exact integers.
fn expected_b(radix: u32, v: usize) -> usize {
    let bits = (BigUint::from(radix).pow(u32::try_from(v).expect("small")) - BigUint::one()).bits();
    usize::try_from(bits.div_ceil(8)).expect("small")
}

#[test]
fn power_of_two_radices_round_trip_with_exact_b() {
    // Radices where radix**v sits exactly on a power of two: precisely where
    // a floating-point bit length flips to the wrong integer.
    for (radix, n) in [
        (2u32, 20usize),
        (4, 10),
        (8, 7),
        (16, 5),
        (32, 4),
        (64, 4),
        (256, 3),
    ] {
        let key = [0x11u8; 16];
        let x: Vec<u16> = (0..n)
            .map(|i| u16::try_from(i as u32 % radix).expect("u16"))
            .collect();
        let ff1 = FF1::new(&key, radix).expect("valid");
        let ct = ff1.encrypt_numerals(&x, None).expect("valid");
        assert_eq!(ct.len(), n, "radix {radix}: length preserved");
        assert!(
            ct.iter().all(|&c| u32::from(c) < radix),
            "radix {radix}: range preserved"
        );
        assert_eq!(
            ff1.decrypt_numerals(&ct, None).expect("valid"),
            x,
            "radix {radix}"
        );
        let (_, trace) = ff1_traced(&key, radix, &x, b"").expect("valid");
        assert_eq!(
            trace[0].b,
            expected_b(radix, trace[0].v),
            "radix {radix}: b"
        );
    }
}

#[test]
fn b_is_derived_from_v_not_u_and_q_is_block_aligned() {
    // Radix 256, n = 5: u = 2, v = 3, so b from v is 3 and b from u would be 2.
    let tweak = [0x42u8; 12];
    let (_, trace) = ff1_traced(&[0u8; 16], 256, &[1, 2, 3, 4, 5], &tweak).expect("valid");
    let first = &trace[0];
    assert_eq!((first.u, first.v, first.b), (2, 3, 3), "b must come from v");
    // Q = T || [0]^pad || [i] || [NUM(B)]^b with pad = (-t - b - 1) mod 16.
    let pad = (16 - (tweak.len() + first.b + 1) % 16) % 16;
    assert_eq!(first.q.len(), tweak.len() + pad + 1 + first.b);
    assert_eq!(&first.q[..tweak.len()], &tweak, "the tweak leads Q");
    assert!(
        first.q[tweak.len()..tweak.len() + pad]
            .iter()
            .all(|&z| z == 0),
        "zero padding"
    );
    assert_eq!(
        (first.p.len() + first.q.len()) % 16,
        0,
        "PRF input is block aligned"
    );
}

/// Strip `//` comments and the contents of string and char literals, so the
/// float scan sees code only.
fn code_only(source: &str) -> String {
    let mut out = String::new();
    for line in source.lines() {
        let mut in_str = false;
        let mut prev = '\0';
        let mut chars = line.chars().peekable();
        while let Some(c) = chars.next() {
            if !in_str && c == '/' && chars.peek() == Some(&'/') {
                break;
            }
            if c == '"' && prev != '\\' {
                in_str = !in_str;
            } else if !in_str {
                out.push(c);
            }
            prev = c;
        }
        out.push('\n');
    }
    out
}

/// Every FF1 path in the crate, scanned for floating-point arithmetic: the
/// Bouncy Castle bug class, where a float bit length flips to the wrong
/// integer. The Rust counterpart of `tests/test_exact_arithmetic.py`.
#[test]
fn no_floating_point_in_any_ff1_path() {
    for (name, source) in [
        ("engine.rs", include_str!("engine.rs")),
        ("validate.rs", include_str!("validate.rs")),
        ("ff1.rs", include_str!("ff1.rs")),
        ("error.rs", include_str!("error.rs")),
        ("lib.rs", include_str!("lib.rs")),
    ] {
        assert_no_float(name, &code_only(source));
    }
}

fn assert_no_float(name: &str, code: &str) {
    for token in [
        "f32", "f64", "libm", "log2(", "log10(", ".ln(", "powf(", "powi(", "sqrt(", ".ceil(",
        ".floor(", ".round(",
    ] {
        assert!(
            !code.contains(token),
            "{name}: forbidden float token {token:?}"
        );
    }
    // A float literal: a digit, a dot, a digit (ranges like `0..10` have two dots).
    let bytes = code.as_bytes();
    for w in bytes.windows(3) {
        if w[0].is_ascii_digit() && w[1] == b'.' && w[2].is_ascii_digit() {
            panic!(
                "{name}: float literal near {:?}",
                String::from_utf8_lossy(w)
            );
        }
    }
}