From f4e2481dba6b9fdfbd26aa01c7f6fae5d76cae57 Mon Sep 17 00:00:00 2001 From: LoadingALIAS Date: Tue, 25 Aug 2026 17:08:20 -0400 Subject: [PATCH] aead: implement nonce-based AES-SIV-CMAC-256 --- .changes/add-aes-siv-cmac-256.md | 5 + .config/benchmark-matrix.json | 5 + Cargo.lock | 36 + Cargo.toml | 5 +- THREAT_MODEL.md | 8 +- benches/aead.rs | 129 ++++ ct.toml | 136 +++- docs/constant-time.md | 1 + docs/features.md | 3 +- docs/secret-lifecycle.md | 7 + docs/secret-ownership.md | 1 + docs/types.md | 10 + fuzz-packages/aead-aes-siv-cmac256/Cargo.lock | 269 ++++++++ fuzz-packages/aead-aes-siv-cmac256/Cargo.toml | 24 + .../corpus/aead_aes_siv_cmac256/seed-basic | 1 + .../fuzz_targets/aead_aes_siv_cmac256.rs | 8 + .../tests/corpus_replay.rs | 20 + fuzz/Cargo.lock | 36 + fuzz/Cargo.toml | 6 + fuzz/corpus/aead_aes_siv_cmac256/seed-basic | 1 + fuzz/fuzz_targets/aead_aes_siv_cmac256.rs | 8 + fuzz/target_impls/aead_aes_siv_cmac256.rs | 54 ++ fuzz/tests/corpus_replay.rs | 13 + scripts/check/zeroize-evidence.sh | 6 +- scripts/ci/check-ci-ownership.sh | 4 +- scripts/ci/nostd-wasm-suite.sh | 1 + scripts/lib/feature-profiles.sh | 1 + scripts/test/test-fuzz.sh | 2 +- src/aead/aes.rs | 147 ++++- src/aead/aes/aarch64_ce.rs | 19 + src/aead/aes/s390x_km.rs | 7 +- src/aead/aes/x86_64_ni.rs | 79 ++- src/aead/aes_siv_cmac256.rs | 617 ++++++++++++++++++ src/aead/mod.rs | 34 +- src/hex.rs | 19 + src/lib.rs | 43 +- src/macros.rs | 2 + src/secret.rs | 3 + src/traits/ct.rs | 3 + src/traits/mod.rs | 2 + tests/aes_siv_cmac256.rs | 162 +++++ tests/fast_hash_allocations.rs | 54 +- tests/secret_redaction.rs | 10 + tests/serde_roundtrip.rs | 11 + tools/ct-dudect/src/main.rs | 204 +++++- tools/ct-harness/src/lib.rs | 98 ++- tools/wasm-runtime-vectors/Cargo.toml | 2 +- tools/wasm-runtime-vectors/src/main.rs | 37 +- 48 files changed, 2292 insertions(+), 61 deletions(-) create mode 100644 .changes/add-aes-siv-cmac-256.md create mode 100644 fuzz-packages/aead-aes-siv-cmac256/Cargo.lock create mode 100644 fuzz-packages/aead-aes-siv-cmac256/Cargo.toml create mode 100644 fuzz-packages/aead-aes-siv-cmac256/corpus/aead_aes_siv_cmac256/seed-basic create mode 100644 fuzz-packages/aead-aes-siv-cmac256/fuzz_targets/aead_aes_siv_cmac256.rs create mode 100644 fuzz-packages/aead-aes-siv-cmac256/tests/corpus_replay.rs create mode 100644 fuzz/corpus/aead_aes_siv_cmac256/seed-basic create mode 100644 fuzz/fuzz_targets/aead_aes_siv_cmac256.rs create mode 100644 fuzz/target_impls/aead_aes_siv_cmac256.rs create mode 100644 src/aead/aes_siv_cmac256.rs create mode 100644 tests/aes_siv_cmac256.rs diff --git a/.changes/add-aes-siv-cmac-256.md b/.changes/add-aes-siv-cmac-256.md new file mode 100644 index 00000000..59def71a --- /dev/null +++ b/.changes/add-aes-siv-cmac-256.md @@ -0,0 +1,5 @@ +--- +"rscrypto" = "minor" +--- + +Add a misuse-resistant AES-SIV-CMAC-256 nonce-based AEAD profile with allocation-free in-place seal/open operations, typed keys, non-empty borrowed nonces, opaque failed-open errors, and complete rejected-plaintext cleanup. diff --git a/.config/benchmark-matrix.json b/.config/benchmark-matrix.json index b38a33a1..e3f724d2 100644 --- a/.config/benchmark-matrix.json +++ b/.config/benchmark-matrix.json @@ -122,6 +122,7 @@ "sha2", "aes-gcm", "aes-gcm-siv", + "aes-siv", "chacha20poly1305", "xchacha20poly1305", "aegis256", @@ -137,6 +138,7 @@ "parallel", "aes-gcm", "aes-gcm-siv", + "aes-siv", "chacha20poly1305", "xchacha20poly1305", "aegis256", @@ -212,6 +214,7 @@ "aes-128-gcm-siv": { "crate": "aead", "bench": "aead", "filter": "aes-128-gcm-siv" }, "aes-256-gcm": { "crate": "aead", "bench": "aead", "filter": "^aes-256-gcm/" }, "aes-128-gcm": { "crate": "aead", "bench": "aead", "filter": "^aes-128-gcm/" }, + "aes-siv-cmac-256": { "crate": "aead", "bench": "aead", "filter": "aes-siv-cmac-256" }, "aegis-256": { "crate": "aead", "bench": "aead", "filter": "aegis-256" }, "ascon-aead128": { "crate": "aead", "bench": "aead", "filter": "ascon-aead128" }, "header-protection": { "crate": "aead", "bench": "aead", "filter": "header-protection" }, @@ -272,6 +275,7 @@ "aes-128-gcm-siv", "aes-256-gcm", "aes-128-gcm", + "aes-siv-cmac-256", "aegis-256", "ascon-aead128", "header-protection" @@ -306,6 +310,7 @@ "aes-128-gcm-siv", "aes-256-gcm", "aes-128-gcm", + "aes-siv-cmac-256", "aegis-256", "ascon-aead128", "header-protection" diff --git a/Cargo.lock b/Cargo.lock index 73c595cb..e833b1ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,6 +61,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "aes-siv" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f60ec3717e40f849d065676ef76a0fbd76ed6c300b18cc70a9a3bde80d7c8e8" +dependencies = [ + "aead", + "aes", + "cipher", + "cmac", + "ctr", + "dbl", + "digest 0.11.3", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -426,6 +441,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac78aa94ce13e432b332a4d1bf2eff167d3a2520188ee05b337180a42fd2e62e" +dependencies = [ + "cipher", + "dbl", + "digest 0.11.3", +] + [[package]] name = "cmake" version = "0.1.58" @@ -728,6 +754,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dbl" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d7a944e61df464668c5f51f56cc667396a8821434273112948ea0b66e405d7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "der" version = "0.7.10" @@ -1987,6 +2022,7 @@ dependencies = [ "aes", "aes-gcm", "aes-gcm-siv", + "aes-siv", "argon2", "ascon-aead", "ascon-hash", diff --git a/Cargo.toml b/Cargo.toml index 51d6b455..46efb1fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -185,6 +185,7 @@ scrypt = ["pbkdf2", "alloc"] aes-gcm = [] aes-gcm-siv = [] +aes-siv = [] chacha20poly1305 = [] xchacha20poly1305 = [] aegis256 = [] @@ -200,7 +201,7 @@ password-hashing = ["argon2", "scrypt", "phc-strings"] signatures = ["ecdsa", "ed25519", "rsa"] key-exchange = ["x25519", "ml-kem"] auth = ["macs", "kdfs", "password-hashing", "signatures", "key-exchange"] -aead = ["aes-gcm", "aes-gcm-siv", "chacha20poly1305", "xchacha20poly1305", "aegis256", "ascon-aead"] +aead = ["aes-gcm", "aes-gcm-siv", "aes-siv", "chacha20poly1305", "xchacha20poly1305", "aegis256", "ascon-aead"] full = ["checksums", "hashes", "auth", "aead"] getrandom = ["dep:getrandom"] serde = ["dep:serde"] @@ -228,6 +229,7 @@ proptest = "^1.11.0" # Oracles aes = "0.9.2" +aes-siv = { version = "0.8.0", default-features = false, features = ["alloc"] } cshake = { version = "0.2.1", default-features = false } crc = "3.4.0" crc-fast = { version = "1.10.0", default-features = false, features = ["std"] } @@ -342,6 +344,7 @@ harness = false required-features = [ "aes-gcm", "aes-gcm-siv", + "aes-siv", "chacha20poly1305", "xchacha20poly1305", "aegis256", diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index d4954976..1085cddf 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -22,7 +22,7 @@ Review the `ct_intended` candidate core before the rest of the repository: 4. RSA private sign/decrypt leaves. 5. ML-KEM secret-noise key generation, encapsulation coins, decapsulation secret-key material, and implicit rejection. -6. AEAD authentication and failed-open cleanup. +6. AEAD authentication, including AES-SIV synthetic-IV derivation, and failed-open cleanup. 7. Header-protection mask generation. 8. MAC/tag verification, fixed-size owner comparison/declassification, and selected password-verification comparisons. @@ -95,6 +95,12 @@ claims remain limited to the release-evidenced configurations. parameters. The API uses typed keys and nonces, `#[must_use]` verification results, `NonceCounter` invocation budgets, opaque errors, and explicit drop cleanup for the named secret owners. + + AES-SIV-CMAC-256 preserves authenticity when a nonce repeats, but it reveals + equality when the complete key/nonce/AAD/plaintext tuple repeats. The nonce-based + profile therefore still treats nonce uniqueness as the normal caller contract; + misuse resistance is a containment property, not permission to omit nonce + management. 4. **Supply-chain attacker.** Targets the path between this repository and the artifact a downstream build consumes. diff --git a/benches/aead.rs b/benches/aead.rs index 8640cf56..c91f6eb4 100644 --- a/benches/aead.rs +++ b/benches/aead.rs @@ -1467,6 +1467,134 @@ fn header_protection(c: &mut Criterion) { mask.finish(); } +// AES-SIV-CMAC-256 (RFC 5297 nonce-based profile) + +fn aes_siv_cmac256(c: &mut Criterion) { + use aes_siv::{KeyInit as _, siv::Aes128Siv}; + use rscrypto::{AesSivCmac256, AesSivCmac256Key, AesSivCmac256Nonce}; + + let nonce_rs = AesSivCmac256Nonce::try_from(NONCE_16.as_slice()).expect("benchmark nonce is non-empty"); + let key_rs = AesSivCmac256Key::from_bytes(KEY_32); + let key_rc: aes_siv::Key = KEY_32.into(); + + let mut construction = c.benchmark_group("aes-siv-cmac-256/construct"); + construction.bench_function("rscrypto", |b| { + b.iter(|| black_box(AesSivCmac256::new(black_box(&key_rs)))) + }); + construction.bench_function("rustcrypto", |b| { + b.iter(|| black_box(Aes128Siv::new(black_box(&key_rc)))) + }); + construction.finish(); + + // NTS packet shapes: empty authenticator payload, short field, extension-field, + // cookie-shaped, and near-MTU protected bodies. + let inputs = [0usize, 16, 64, 256, 1232] + .into_iter() + .map(|len| (len, common::random_bytes(len))) + .collect::>(); + let cipher_rs = AesSivCmac256::new(&key_rs); + let mut cipher_rc = Aes128Siv::new(&key_rc); + let headers: [&[u8]; 2] = [AAD, &NONCE_16]; + + // Construction prepares different amounts of reusable work in the two libraries. Measure the + // complete one-context/one-seal lifecycle separately so setup deferral cannot skew the result. + let mut construct_and_seal = c.benchmark_group("aes-siv-cmac-256/construct-and-seal"); + for (len, data) in &inputs { + common::set_throughput(&mut construct_and_seal, *len); + let mut buffer_rs = data.clone(); + construct_and_seal.bench_with_input(BenchmarkId::new("rscrypto", len), data, |b, input| { + b.iter(|| { + buffer_rs.copy_from_slice(input); + let cipher = AesSivCmac256::new(black_box(&key_rs)); + black_box(cipher.seal_in_place(black_box(nonce_rs), black_box(AAD), black_box(&mut buffer_rs))) + }) + }); + + let mut buffer_rc = data.clone(); + construct_and_seal.bench_with_input(BenchmarkId::new("rustcrypto", len), data, |b, input| { + b.iter(|| { + buffer_rc.copy_from_slice(input); + let mut cipher = Aes128Siv::new(black_box(&key_rc)); + black_box( + cipher + .encrypt_inout_detached(black_box(headers), black_box(buffer_rc.as_mut_slice().into())) + .expect("valid benchmark input must seal"), + ) + }) + }); + } + construct_and_seal.finish(); + + let mut seal = c.benchmark_group("aes-siv-cmac-256/seal"); + + for (len, data) in &inputs { + common::set_throughput(&mut seal, *len); + let mut buffer_rs = data.clone(); + seal.bench_with_input(BenchmarkId::new("rscrypto", len), data, |b, input| { + b.iter(|| { + buffer_rs.copy_from_slice(input); + black_box(cipher_rs.seal_in_place(black_box(nonce_rs), black_box(AAD), black_box(&mut buffer_rs))) + }) + }); + + let mut buffer_rc = data.clone(); + seal.bench_with_input(BenchmarkId::new("rustcrypto", len), data, |b, input| { + b.iter(|| { + buffer_rc.copy_from_slice(input); + black_box( + cipher_rc + .encrypt_inout_detached(black_box(headers), black_box(buffer_rc.as_mut_slice().into())) + .expect("valid benchmark input must seal"), + ) + }) + }); + } + seal.finish(); + + let mut open = c.benchmark_group("aes-siv-cmac-256/open"); + for (len, data) in &inputs { + common::set_throughput(&mut open, *len); + + let mut ciphertext_rs = data.clone(); + let tag_rs = cipher_rs.seal_in_place(nonce_rs, AAD, &mut ciphertext_rs); + let mut buffer_rs = ciphertext_rs.clone(); + open.bench_with_input(BenchmarkId::new("rscrypto", len), &ciphertext_rs, |b, ciphertext| { + b.iter(|| { + buffer_rs.copy_from_slice(ciphertext); + cipher_rs + .open_in_place( + black_box(nonce_rs), + black_box(AAD), + black_box(&mut buffer_rs), + black_box(&tag_rs), + ) + .expect("valid benchmark ciphertext must open"); + black_box(&buffer_rs); + }) + }); + + let mut ciphertext_rc = data.clone(); + let tag_rc = cipher_rc + .encrypt_inout_detached(headers, ciphertext_rc.as_mut_slice().into()) + .expect("valid benchmark input must seal"); + let mut buffer_rc = ciphertext_rc.clone(); + open.bench_with_input(BenchmarkId::new("rustcrypto", len), &ciphertext_rc, |b, ciphertext| { + b.iter(|| { + buffer_rc.copy_from_slice(ciphertext); + cipher_rc + .decrypt_inout_detached( + black_box(headers), + black_box(buffer_rc.as_mut_slice().into()), + black_box(&tag_rc), + ) + .expect("valid benchmark ciphertext must open"); + black_box(&buffer_rc); + }) + }); + } + open.finish(); +} + // Criterion harness criterion_group!( @@ -1488,5 +1616,6 @@ criterion_group!( ascon_aead128_encrypt, ascon_aead128_decrypt, header_protection, + aes_siv_cmac256, ); criterion_main!(benches); diff --git a/ct.toml b/ct.toml index 905091ce..e15d70ef 100644 --- a/ct.toml +++ b/ct.toml @@ -317,6 +317,8 @@ symbols = [ "ct_entry_aes256gcm_open", "ct_entry_aes128gcmsiv_open", "ct_entry_aes256gcmsiv_open", + "ct_entry_aes_siv_cmac256_open", + "ct_entry_aes_siv_cmac256_open_portable", "ct_entry_xchacha20poly1305_open", "ct_entry_aegis256_open", "ct_entry_ascon_aead128_open", @@ -1556,6 +1558,69 @@ right_class = "random secret key" samples = 20000 smoke_samples = 2000 +[[dudect_case]] +name = "aes_siv_cmac256_fixed_vs_random_key_seal" +primitive = "aead.symmetric_transform" +filter = "aes_siv_cmac256_fixed_vs_random_key_seal" +left_class = "complete context construction and seal with fixed secret key" +right_class = "complete context construction and seal with random secret key" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "aes_siv_cmac256_fixed_vs_random_key_open" +primitive = "aead.open_authentication" +filter = "aes_siv_cmac256_fixed_vs_random_key_open" +left_class = "complete context construction and valid open with fixed secret key" +right_class = "complete context construction and valid open with random secret key" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "aes_siv_cmac256_first_vs_last_tag_mismatch" +primitive = "aead.open_authentication" +filter = "aes_siv_cmac256_first_vs_last_tag_mismatch" +left_class = "failed open with first synthetic-IV byte corrupted" +right_class = "failed open with last synthetic-IV byte corrupted" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "aes_siv_cmac256_portable_s2v_fixed_vs_random_key" +primitive = "aead.open_authentication" +filter = "aes_siv_cmac256_portable_s2v_fixed_vs_random_key" +left_class = "forced-portable complete CMAC/S2V state with fixed secret key" +right_class = "forced-portable complete CMAC/S2V state with random secret key" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "aes_siv_cmac256_portable_s2v_seal_fixed_vs_random_key" +primitive = "aead.symmetric_transform" +filter = "aes_siv_cmac256_portable_s2v_seal_fixed_vs_random_key" +left_class = "forced-portable complete CMAC/S2V state with fixed secret key" +right_class = "forced-portable complete CMAC/S2V state with random secret key" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "aes_siv_cmac256_portable_open_fixed_vs_random_key" +primitive = "aead.open_authentication" +filter = "aes_siv_cmac256_portable_open_fixed_vs_random_key" +left_class = "forced-portable complete valid open with fixed secret key" +right_class = "forced-portable complete valid open with random secret key" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "aes_siv_cmac256_portable_open_first_vs_last_tag_mismatch" +primitive = "aead.open_authentication" +filter = "aes_siv_cmac256_portable_open_first_vs_last_tag_mismatch" +left_class = "forced-portable failed open with first synthetic-IV byte corrupted" +right_class = "forced-portable failed open with last synthetic-IV byte corrupted" +samples = 20000 +smoke_samples = 2000 + [[dudect_case]] name = "blake2b256_keyed_fixed_vs_random_key" primitive = "keyed_hash.blake2_blake3" @@ -1814,6 +1879,19 @@ variant = "AsconAead128" dudect = ["ascon_aead128_fixed_vs_random_key_open"] binsec = ["aead.open_authentication.ascon_aead128_tag_portable.all"] +[[evidence_unit]] +id = "aead.open_authentication.aes_siv_cmac256" +primitive = "aead.open_authentication" +variant = "AesSivCmac256" +dudect = [ + "aes_siv_cmac256_fixed_vs_random_key_open", + "aes_siv_cmac256_first_vs_last_tag_mismatch", + "aes_siv_cmac256_portable_s2v_fixed_vs_random_key", + "aes_siv_cmac256_portable_open_fixed_vs_random_key", + "aes_siv_cmac256_portable_open_first_vs_last_tag_mismatch", +] +binsec = ["aead.open_authentication.aes_round_portable.all"] + [[evidence_unit]] id = "aead.symmetric_transform.aes128gcm" primitive = "aead.symmetric_transform" @@ -1903,6 +1981,16 @@ variant = "ChaCha20HeaderProtection" dudect = ["chacha20_header_protection_fixed_vs_random_key"] binsec = ["aead.symmetric_transform.chacha20poly1305_seal.portable.all"] +[[evidence_unit]] +id = "aead.symmetric_transform.aes_siv_cmac256" +primitive = "aead.symmetric_transform" +variant = "AesSivCmac256" +dudect = [ + "aes_siv_cmac256_fixed_vs_random_key_seal", + "aes_siv_cmac256_portable_s2v_seal_fixed_vs_random_key", +] +binsec = ["aead.symmetric_transform.aes_round_portable.all"] + [[evidence_unit]] id = "rsa.private_ops.pkcs1v15_sign" primitive = "rsa.private_ops" @@ -2835,8 +2923,8 @@ name = "x86_64-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2268 -compiler_api_sha256 = "6e3f13819536b857ab38b6b77280db1ca7da49ca9ddc91bf964e4656f0ecea19" +compiler_api_item_count = 2319 +compiler_api_sha256 = "8193076ea61bc84482b3993e2e02e77da9b372629bccb356994bf245158a36b4" claim = "ct-intended" physical_timing = "required" binsec = "required" @@ -2847,8 +2935,8 @@ name = "aarch64-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2262 -compiler_api_sha256 = "8e85d195e87964207eaf4a142b157eef0d8f8aea85b838ee6c172279b1fe23df" +compiler_api_item_count = 2313 +compiler_api_sha256 = "e40ba4d720685019669fdad3c452842fb50eaaba00d1159fa0ab1e4e7b4d6a0a" claim = "ct-intended" physical_timing = "required" binsec = "required" @@ -2907,8 +2995,8 @@ name = "aarch64-apple-darwin" group = "macos" backend = "llvm" linker = "apple-ld-unpinned" -compiler_api_item_count = 2262 -compiler_api_sha256 = "8e85d195e87964207eaf4a142b157eef0d8f8aea85b838ee6c172279b1fe23df" +compiler_api_item_count = 2313 +compiler_api_sha256 = "e40ba4d720685019669fdad3c452842fb50eaaba00d1159fa0ab1e4e7b4d6a0a" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2932,8 +3020,8 @@ name = "s390x-unknown-linux-gnu" group = "ibm" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2261 -compiler_api_sha256 = "daa3642fedbb5532ec987e192ef9f3f8e522237df89779dca933ea17920a3911" +compiler_api_item_count = 2312 +compiler_api_sha256 = "3c444abfafa99a01c573fc1a32c965317659e15610798b6388c068de10005313" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2945,8 +3033,8 @@ name = "powerpc64le-unknown-linux-gnu" group = "ibm" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2261 -compiler_api_sha256 = "9ea6ddaa217b1181d383fc4dd51690439005044199ae5af472d55a54d7775ea9" +compiler_api_item_count = 2312 +compiler_api_sha256 = "6578bc51a11fe4b1f11b4634f1bcb87dcdc455243c8314d6c050433ee54b6ce1" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2958,8 +3046,8 @@ name = "riscv64gc-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2261 -compiler_api_sha256 = "cc0faa52fd40a16280c020505643c008b1bfbc0e6f7c3b9d05e333bbb7b87eb2" +compiler_api_item_count = 2312 +compiler_api_sha256 = "46a3f1c7f46e5cb4e6dde1ccd51864bcfba16ef5659ac2765434d7b260a0e194" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -3122,6 +3210,7 @@ id = "aead.construct_and_seal" api = [ "rscrypto::Aead::{new,encrypt_in_place,encrypt,seal,seal_with_random_nonce}", "rscrypto::{Aes128Gcm,Aes256Gcm,Aes128GcmSiv,Aes256GcmSiv,ChaCha20Poly1305,XChaCha20Poly1305,Aegis256,AsconAead128}::{new,encrypt_in_place,encrypt}", + "rscrypto::AesSivCmac256::{new,seal_in_place,seal}", "rscrypto::NonceCounter::*::encrypt*", ] features = [ @@ -3144,6 +3233,7 @@ evidence = [ "primitive:aead.symmetric_transform", "unit:aead.symmetric_transform.aes128gcm", "unit:aead.symmetric_transform.chacha20poly1305", + "unit:aead.symmetric_transform.aes_siv_cmac256", ] limitation = "Release claims require exact target artifacts. Random-nonce helper timing includes the platform entropy source." @@ -3172,6 +3262,7 @@ id = "aead.open_and_authenticate" api = [ "rscrypto::Aead::{decrypt_in_place,decrypt,open}", "rscrypto::{Aes128Gcm,Aes256Gcm,Aes128GcmSiv,Aes256GcmSiv,ChaCha20Poly1305,XChaCha20Poly1305,Aegis256,AsconAead128}::{decrypt_in_place,decrypt}", + "rscrypto::AesSivCmac256::{open_in_place,open}", ] features = ["aead algorithm feature", "alloc/std for allocating helpers"] targets = ["all-supported", "backend selected by public target capabilities"] @@ -3190,6 +3281,8 @@ evidence = [ "primitive:aead.symmetric_transform", "unit:aead.open_authentication.aes128gcm", "unit:aead.open_authentication.ascon_aead128", + "unit:aead.open_authentication.aes_siv_cmac256", + "unit:aead.symmetric_transform.aes_siv_cmac256", ] limitation = "The bool-like authentication decision is surfaced only as an opaque Result, but exact release-machine-code evidence remains T3.3 work." @@ -3197,8 +3290,9 @@ limitation = "The bool-like authentication decision is surfaced only as an opaqu id = "aead.public_nonce_tag_and_error_values" api = [ "rscrypto::aead::Nonce*::{from_bytes,to_bytes,as_bytes,generate,try_random}", + "rscrypto::AesSivCmac256Nonce::{try_from,as_bytes}", "rscrypto::*Tag::{from_bytes,try_from,to_bytes,as_bytes,as_slice}", - "rscrypto::aead::{AeadBufferError,SealError,OpenError,RandomSealError,NonceCounter*}::*", + "rscrypto::aead::{AeadBufferError,SealError,OpenError,RandomSealError,NonceCounter*,AesSivCmac256NonceError}::*", ] features = ["aead algorithm feature", "serde"] targets = ["all-supported"] @@ -4042,12 +4136,20 @@ variants = [ "Aes256Gcm", "Aes128GcmSiv", "Aes256GcmSiv", + "AesSivCmac256", "ChaCha20Poly1305", "XChaCha20Poly1305", "Aegis256", "AsconAead128", ] -entrypoints = ["Aead::decrypt_in_place", "Aead::decrypt", "*::decrypt_in_place", "*::decrypt"] +entrypoints = [ + "Aead::decrypt_in_place", + "Aead::decrypt", + "*::decrypt_in_place", + "*::decrypt", + "AesSivCmac256::open_in_place", + "AesSivCmac256::open", +] secrets = ["key", "computed_tag", "expected_tag", "opened_plaintext"] public = ["nonce", "aad", "ciphertext", "ciphertext_length", "tag_length"] may_leak = ["input_length", "opaque_success_or_failure", "public_buffer_size_error"] @@ -4061,6 +4163,8 @@ symbols = [ "ct_entry_aes256gcm_open", "ct_entry_aes128gcmsiv_open", "ct_entry_aes256gcmsiv_open", + "ct_entry_aes_siv_cmac256_open", + "ct_entry_aes_siv_cmac256_open_portable", "ct_entry_chacha20poly1305_open", "ct_entry_xchacha20poly1305_open", "ct_entry_aegis256_open", @@ -4495,6 +4599,7 @@ variants = [ "Aes256Gcm", "Aes128GcmSiv", "Aes256GcmSiv", + "AesSivCmac256", "ChaCha20Poly1305", "XChaCha20Poly1305", "Aegis256", @@ -4516,6 +4621,7 @@ entrypoints = [ "GHASH/POLYVAL helpers", "ChaCha20 keystream helpers", "Poly1305 block helpers", + "AES-SIV-CMAC-256 private CMAC/S2V and CTR helpers", ] secrets = ["key", "plaintext", "opened_plaintext", "poly_key", "hash_subkey"] public = ["nonce", "aad", "input_length", "backend_caps"] @@ -4529,6 +4635,8 @@ symbols = [ "ct_entry_aes256gcm_open", "ct_entry_aes128gcmsiv_open", "ct_entry_aes256gcmsiv_open", + "ct_entry_aes_siv_cmac256_open", + "ct_entry_aes_siv_cmac256_open_portable", "ct_entry_chacha20poly1305_open", "ct_entry_xchacha20poly1305_open", "ct_entry_aegis256_open", diff --git a/docs/constant-time.md b/docs/constant-time.md index abb83caa..3f72bf47 100644 --- a/docs/constant-time.md +++ b/docs/constant-time.md @@ -49,6 +49,7 @@ evidence gate. This is intent, not a standalone public claim: tag, and keyed-output types. - AEAD authentication and failed-open cleanup. - AES and ChaCha20 header-protection mask generation with the derived key as secret and the fixed-size sample as public. +- AES-SIV-CMAC-256 S2V/CMAC derivation, CTR transform, tag comparison, and failed-open cleanup. - X25519 scalar multiplication. - ML-KEM-512/768/1024 key generation secret noise, encapsulation coins, decapsulation secret-key material, implicit-rejection seed, and listed diff --git a/docs/features.md b/docs/features.md index 4dd6c7b1..47328d47 100644 --- a/docs/features.md +++ b/docs/features.md @@ -63,7 +63,7 @@ rscrypto = { version = "0.8.1", features = ["full", "portable-only"] } | `password-hashing` | `argon2`, `scrypt`, `phc-strings` | | `signatures` | `ecdsa`, `ed25519`, `rsa` | | `key-exchange` | `x25519`, `ml-kem` | -| `aead` | `aes-gcm`, `aes-gcm-siv`, `chacha20poly1305`, `xchacha20poly1305`, `aegis256`, `ascon-aead` | +| `aead` | `aes-gcm`, `aes-gcm-siv`, `aes-siv`, `chacha20poly1305`, `xchacha20poly1305`, `aegis256`, `ascon-aead` | ### Algorithm leaf features @@ -99,6 +99,7 @@ rscrypto = { version = "0.8.1", features = ["full", "portable-only"] } | `ml-kem` | `sha3` | ML-KEM-512, ML-KEM-768, and ML-KEM-1024 key encapsulation | | `aes-gcm` | -- | AES-128-GCM, AES-256-GCM, and expert AES header-protection mask generation | | `aes-gcm-siv` | -- | AES-128-GCM-SIV and AES-256-GCM-SIV | +| `aes-siv` | -- | AES-SIV-CMAC-256 nonce-based profile (RFC 5297) | | `chacha20poly1305` | -- | ChaCha20-Poly1305 and expert ChaCha20 header-protection mask generation | | `xchacha20poly1305` | -- | XChaCha20-Poly1305 | | `aegis256` | -- | AEGIS-256 | diff --git a/docs/secret-lifecycle.md b/docs/secret-lifecycle.md index 52be32a8..6d9dc0d4 100644 --- a/docs/secret-lifecycle.md +++ b/docs/secret-lifecycle.md @@ -27,6 +27,7 @@ owners merely because they are produced by secret-bearing operations. | `SecretBytes`, `SecretVec`, typed keys, private keys, and shared secrets | Fixed or variable-length key material | Concrete `Drop`; consuming export either clears the source allocation or explicitly transfers responsibility to the returned ordinary bytes | | AEAD contexts and AES backend schedules | Expanded encryption keys and authentication subkeys | Context and nested schedule `Drop`; operation-local subkeys and authentication state are cleared after use; failed open and private-output paths clear rejected output | | Header-protection keys, contexts, and masks | Raw key bytes, expanded AES schedules or retained ChaCha20 keys, and backend-local cipher output | Key/context `Drop`; AES-NI/AES-CE expose only the five-byte mask from vector state, while other AES paths and ChaCha20 clear each materialized output block immediately after copying the mask | +| AES-SIV construction and seal/open | Borrowed typed-key halves during expansion; cached CMAC subkeys, S2V state, synthetic IV, CTR counter, keystream, and unauthenticated plaintext | Key halves are not copied into raw construction scratch; context and AES schedule `Drop` clear retained state; operation-local state is cleared after use; every authentication failure clears the complete caller output before returning one opaque verification error | | HMAC-SHA-2 | Live SHA state, keyed inner/outer prefixes, oversized-key digests, and inner-digest finalization snapshots | Secret-specific SHA finalization clears copied state and padding blocks; reset clears the replaced live state; `Drop` clears the live state and both saved prefixes | | HKDF and PBKDF2 | PRK or password-derived HMAC prefix words and derivation scratch | Prefix-owner `Drop`; oversized-key/password digests and per-block working values are cleared on every return path | | Ed25519 signing | Expanded scalar, nonce prefix, nonce hash state, digest, and scalar intermediates | Expanded-secret `Drop`; secret-specific SHA-512 digest/finalization clears hash state and padding snapshots; signing clears scalar and digest temporaries before return | @@ -82,6 +83,7 @@ The gate maps evidence to production behavior as follows: | `diag_poly1305_block_portable_digest`, `diag_ascon_aead128_tag_portable`, `diag_aegis256_update_portable` | Portable Poly1305, Ascon-AEAD, and AEGIS-256 authentication-state cleanup | | `diag_aes128gcm_ghash`, `diag_aes256gcm_ghash` | AES-GCM authentication-accumulator cleanup | | `diag_zeroize_{aes128,aes256,chacha20}_header_protection` | Header-protection key/context cleanup and cleanup of any materialized full output block | +| `diag_zeroize_aes_siv_cmac256` | AES-SIV typed key, retained context, S2V/CMAC state, CTR keystream, and successful-open cleanup | | `diag_zeroize_mlkem_sha3_512`, `diag_zeroize_mlkem_shake256_{scalar,pair,quad}` | ML-KEM secret SHA3-512 and scalar, paired, or quad SHAKE256 owner and seeded-state cleanup | | `diag_rsa_caller_random_signing_success`, `diag_rsa_caller_random_signing_error` | Shared complete private-scratch cleanup after successful signing and a partially filled entropy-error path | | `diag_rsa_validate_pkcs8_private_key_der_stage` | RSA private-component validation success, staged exits, and errors through heap-owner drop before deallocation | @@ -105,6 +107,11 @@ RSA caller-random signing discards the callback's error value and returns only `RsaPrivateOpError::EntropyUnavailable`; neither `Debug`, `Display`, nor an error source can expose a caller payload through this boundary. +AES-SIV-CMAC-256 additionally pins its non-empty public nonce error text and +verifies that every tag-byte corruption plus representative ciphertext, AAD, +and nonce corruption returns the same opaque error after clearing all +unauthenticated plaintext. + Secret-key hex errors retain the public offending-byte field for programmatic inspection but omit that byte from both `Debug` and `Display`. Generic ECDSA and password-record entropy errors omit the caller's payload from `Debug`, diff --git a/docs/secret-ownership.md b/docs/secret-ownership.md index 6759871a..e639350c 100644 --- a/docs/secret-ownership.md +++ b/docs/secret-ownership.md @@ -82,6 +82,7 @@ key. Generated-code timing claims remain limited by | AES expanded schedules | Neither | Neither | Inline; boxed only for the large RISC-V fixslice schedule | Retained by an AEAD context and borrowed by block operations; unused private `Clone` derives were removed during this inventory | | AEAD authentication working state | Private copies only where a backend finalizer consumes a value | Neither | Inline | Bound, intra-operation snapshot needed by consuming backend finalization | | Header-protection AES and ChaCha20 cipher output | Neither | Neither | Registers or inline | AES-NI/AES-CE do not materialize unused output bytes; other AES paths and ChaCha20 clear the operation-local block after copying the five-byte mask | +| AES-SIV CMAC/S2V and CTR working state | Neither | Neither | Inline | Construction borrows the typed key's two halves directly into expansion; authentication state, synthetic IV, counter, and length-shaped keystream batches are operation-local and cleared after their last secret use | | HMAC-SHA-3 and KMAC Keccak/cSHAKE snapshots | Private use of `Clone` | Neither | Inline | Implement non-consuming finalization and reset inside one public keyed owner | | Ed25519 `ExpandedSecret` | Private `Clone` | Masked; no serialization | Inline | Implements the public keypair's explicit `duplicate_secret()` operation | | X25519 clamped scalar and ECDSA secret scalar/word wrappers | No generic duplication on the owning wrappers | Neither | Inline | Bound one-operation arithmetic ownership | diff --git a/docs/types.md b/docs/types.md index 8f4dbdab..8911dc12 100644 --- a/docs/types.md +++ b/docs/types.md @@ -203,6 +203,7 @@ Feature: `aead` or individual leaves. | `Aes256Gcm` | `Aes256GcmKey` 32B | `Nonce96` 12B | `Aes256GcmTag` 16B | SP 800-38D | | `Aes128GcmSiv` | `Aes128GcmSivKey` 16B | `Nonce96` 12B | `Aes128GcmSivTag` 16B | RFC 8452 | | `Aes256GcmSiv` | `Aes256GcmSivKey` 32B | `Nonce96` 12B | `Aes256GcmSivTag` 16B | RFC 8452 | +| `AesSivCmac256` | `AesSivCmac256Key` 32B | borrowed 1+B | `AesSivCmac256Tag` 16B | RFC 5297 / RFC 5116 | | `ChaCha20Poly1305` | `ChaCha20Poly1305Key` 32B | `Nonce96` 12B | `ChaCha20Poly1305Tag` 16B | RFC 8439 | | `XChaCha20Poly1305` | `XChaCha20Poly1305Key` 32B | `Nonce192` 24B | `XChaCha20Poly1305Tag` 16B | draft-irtf-cfrg-xchacha | | `AsconAead128` | `AsconAead128Key` 16B | `Nonce128` 16B | `AsconAead128Tag` 16B | NIST SP 800-232 | @@ -227,6 +228,14 @@ Mask generation is allocation-free. Alloc-enabled RISC-V portable AES context construction retains the existing boxed fixslice schedule; no-alloc builds store that schedule inline. +`AesSivCmac256` implements only the registered 32-byte-key nonce-based profile. +`AesSivCmac256Nonce` validates a borrowed, non-empty variable-length nonce; +the API keeps nonce and one RFC 5116 AAD string as distinct S2V components. +Combined output is `synthetic_iv || ciphertext`. It deliberately does not +expose deterministic SIV, CMAC, S2V, raw AES, or a vector-of-AAD interface. +Construction and detached operations are allocation-free except that the +alloc-enabled RISC-V AES backend retains its existing boxed fixslice schedules. + ## Error types | Error | When | Recovery | @@ -235,6 +244,7 @@ store that schedule inline. | `PasswordHashError` | Caller entropy or password-record hashing fails | Match the variant; repair the entropy source or handle the algorithm error | | `EcdsaKeyGenerationError` | ECDSA random source failure or bounded scalar rejection exhaustion | Fix entropy source; investigate deterministic fillers | | `AeadBufferError` | Output buffer wrong size | Fix buffer length | +| `AesSivCmac256NonceError` | Empty AES-SIV-CMAC-256 nonce | Supply a non-empty nonce | | `SealError` | Combined AEAD buffer length is wrong or input exceeds the algorithm limit | Correct the public buffer/input length | | `OpenError` | Combined AEAD buffer length is wrong, input is too large, or authentication fails | Correct public lengths; reject opaque verification failures | | `NonceCounterSealError` | AES-GCM nonce counter is exhausted or sealing fails | Rotate the key before counter reuse, or correct the sealing input | diff --git a/fuzz-packages/aead-aes-siv-cmac256/Cargo.lock b/fuzz-packages/aead-aes-siv-cmac256/Cargo.lock new file mode 100644 index 00000000..ebc87efe --- /dev/null +++ b/fuzz-packages/aead-aes-siv-cmac256/Cargo.lock @@ -0,0 +1,269 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures", +] + +[[package]] +name = "aes-siv" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f60ec3717e40f849d065676ef76a0fbd76ed6c300b18cc70a9a3bde80d7c8e8" +dependencies = [ + "aead", + "aes", + "cipher", + "cmac", + "ctr", + "dbl", + "digest", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer", + "crypto-common", + "inout", +] + +[[package]] +name = "cmac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac78aa94ce13e432b332a4d1bf2eff167d3a2520188ee05b337180a42fd2e62e" +dependencies = [ + "cipher", + "dbl", + "digest", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", + "rand_core", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "dbl" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d7a944e61df464668c5f51f56cc667396a8821434273112948ea0b66e405d7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", + "ctutils", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rscrypto" +version = "0.8.1" + +[[package]] +name = "rscrypto-fuzz-aead-aes-siv-cmac256" +version = "0.0.0" +dependencies = [ + "aes-siv", + "libfuzzer-sys", + "rscrypto", + "rscrypto-fuzz-support", +] + +[[package]] +name = "rscrypto-fuzz-support" +version = "0.0.0" +dependencies = [ + "rscrypto", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" diff --git a/fuzz-packages/aead-aes-siv-cmac256/Cargo.toml b/fuzz-packages/aead-aes-siv-cmac256/Cargo.toml new file mode 100644 index 00000000..9f896f03 --- /dev/null +++ b/fuzz-packages/aead-aes-siv-cmac256/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "rscrypto-fuzz-aead-aes-siv-cmac256" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +rscrypto = { path = "../..", features = ["std", "aes-siv"] } +rscrypto_fuzz = { package = "rscrypto-fuzz-support", path = "../../fuzz/support", features = [ + "aead", +] } +aes-siv = { version = "0.8.0", default-features = false, features = ["alloc"] } + +[workspace] +members = ["."] + +[[bin]] +name = "aead_aes_siv_cmac256" +path = "fuzz_targets/aead_aes_siv_cmac256.rs" +doc = false diff --git a/fuzz-packages/aead-aes-siv-cmac256/corpus/aead_aes_siv_cmac256/seed-basic b/fuzz-packages/aead-aes-siv-cmac256/corpus/aead_aes_siv_cmac256/seed-basic new file mode 100644 index 00000000..dbcc66bd --- /dev/null +++ b/fuzz-packages/aead-aes-siv-cmac256/corpus/aead_aes_siv_cmac256/seed-basic @@ -0,0 +1 @@ +0123456789abcdef0123456789abcdef\x10\x80\x00nonce-associated-data-plaintext diff --git a/fuzz-packages/aead-aes-siv-cmac256/fuzz_targets/aead_aes_siv_cmac256.rs b/fuzz-packages/aead-aes-siv-cmac256/fuzz_targets/aead_aes_siv_cmac256.rs new file mode 100644 index 00000000..8df02cc6 --- /dev/null +++ b/fuzz-packages/aead-aes-siv-cmac256/fuzz_targets/aead_aes_siv_cmac256.rs @@ -0,0 +1,8 @@ +#![no_main] + +#[path = "../../../fuzz/target_impls/aead_aes_siv_cmac256.rs"] +mod target_impl; + +libfuzzer_sys::fuzz_target!(|data: &[u8]| { + target_impl::run(data); +}); diff --git a/fuzz-packages/aead-aes-siv-cmac256/tests/corpus_replay.rs b/fuzz-packages/aead-aes-siv-cmac256/tests/corpus_replay.rs new file mode 100644 index 00000000..5f0a9764 --- /dev/null +++ b/fuzz-packages/aead-aes-siv-cmac256/tests/corpus_replay.rs @@ -0,0 +1,20 @@ +use std::path::PathBuf; + +use rscrypto_fuzz::replay_corpus_dir; + +fn corpus_dir(target: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("corpus").join(target) +} + +#[path = "../../../fuzz/target_impls/aead_aes_siv_cmac256.rs"] +mod aead_aes_siv_cmac256; + +#[test] +fn replay_aead_aes_siv_cmac256_corpus() { + let replayed = replay_corpus_dir( + "aead_aes_siv_cmac256", + corpus_dir("aead_aes_siv_cmac256"), + aead_aes_siv_cmac256::run, + ); + assert_ne!(replayed, 0, "aead_aes_siv_cmac256 corpus should not be empty"); +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index a41d18a7..793509e8 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -61,6 +61,21 @@ dependencies = [ "subtle", ] +[[package]] +name = "aes-siv" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f60ec3717e40f849d065676ef76a0fbd76ed6c300b18cc70a9a3bde80d7c8e8" +dependencies = [ + "aead", + "aes", + "cipher", + "cmac", + "ctr", + "dbl", + "digest 0.11.3", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -230,6 +245,17 @@ dependencies = [ "inout", ] +[[package]] +name = "cmac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac78aa94ce13e432b332a4d1bf2eff167d3a2520188ee05b337180a42fd2e62e" +dependencies = [ + "cipher", + "dbl", + "digest 0.11.3", +] + [[package]] name = "cmov" version = "0.5.4" @@ -387,6 +413,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "dbl" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d7a944e61df464668c5f51f56cc667396a8821434273112948ea0b66e405d7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "der" version = "0.8.1" @@ -803,6 +838,7 @@ dependencies = [ "aes", "aes-gcm", "aes-gcm-siv", + "aes-siv", "argon2", "ascon-aead", "ascon-hash", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 051020f6..1b681e73 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -40,6 +40,7 @@ aes = "0.9.2" ascon-aead = { version = "0.6", default-features = false, features = ["alloc"] } aes-gcm = { version = "0.11", default-features = false, features = ["alloc", "aes"] } aes-gcm-siv = { version = "0.12", default-features = false, features = ["alloc", "aes"] } +aes-siv = { version = "0.8.0", default-features = false, features = ["alloc"] } aegis = "0.9" chacha20poly1305 = { version = "0.11", default-features = false, features = ["alloc"] } chacha20 = "0.10.1" @@ -128,6 +129,11 @@ name = "aead_header_protection" path = "fuzz_targets/aead_header_protection.rs" doc = false +[[bin]] +name = "aead_aes_siv_cmac256" +path = "fuzz_targets/aead_aes_siv_cmac256.rs" +doc = false + # ── Auth targets ────────────────────────────────────────────────────────────── [[bin]] name = "auth_ed25519" diff --git a/fuzz/corpus/aead_aes_siv_cmac256/seed-basic b/fuzz/corpus/aead_aes_siv_cmac256/seed-basic new file mode 100644 index 00000000..dbcc66bd --- /dev/null +++ b/fuzz/corpus/aead_aes_siv_cmac256/seed-basic @@ -0,0 +1 @@ +0123456789abcdef0123456789abcdef\x10\x80\x00nonce-associated-data-plaintext diff --git a/fuzz/fuzz_targets/aead_aes_siv_cmac256.rs b/fuzz/fuzz_targets/aead_aes_siv_cmac256.rs new file mode 100644 index 00000000..e90f0760 --- /dev/null +++ b/fuzz/fuzz_targets/aead_aes_siv_cmac256.rs @@ -0,0 +1,8 @@ +#![no_main] + +#[path = "../target_impls/aead_aes_siv_cmac256.rs"] +mod target_impl; + +libfuzzer_sys::fuzz_target!(|data: &[u8]| { + target_impl::run(data); +}); diff --git a/fuzz/target_impls/aead_aes_siv_cmac256.rs b/fuzz/target_impls/aead_aes_siv_cmac256.rs new file mode 100644 index 00000000..947d0638 --- /dev/null +++ b/fuzz/target_impls/aead_aes_siv_cmac256.rs @@ -0,0 +1,54 @@ +use aes_siv::{KeyInit as _, siv::Aes128Siv}; +use rscrypto::{AesSivCmac256, AesSivCmac256Key, AesSivCmac256Nonce, aead::OpenError}; +use rscrypto_fuzz::{FuzzInput, some_or_return, split_at_ratio}; + +pub(super) fn run(data: &[u8]) { + let mut input = FuzzInput::new(data); + let key_bytes: [u8; 32] = some_or_return!(input.bytes()); + let nonce_control = some_or_return!(input.byte()); + let aad_control = some_or_return!(input.byte()); + let corruption_control = some_or_return!(input.byte()); + let rest = input.rest(); + if rest.is_empty() { + return; + } + + let nonce_bound = rest.len().min(64); + let nonce_len = usize::from(nonce_control).rem_euclid(nonce_bound).strict_add(1); + let (nonce_bytes, payload) = rest.split_at(nonce_len); + let (aad, plaintext) = split_at_ratio(payload, aad_control); + + let key = AesSivCmac256Key::from_bytes(key_bytes); + let cipher = AesSivCmac256::new(&key); + let nonce = AesSivCmac256Nonce::try_from(nonce_bytes).expect("constructed nonce is non-empty"); + + let mut combined = vec![0u8; plaintext.len().strict_add(AesSivCmac256::TAG_SIZE)]; + cipher + .seal(nonce, aad, plaintext, &mut combined) + .expect("fuzz output has the exact required length"); + + let mut opened = vec![0xA5; plaintext.len()]; + cipher + .open(nonce, aad, &combined, &mut opened) + .expect("fresh ciphertext must authenticate"); + assert_eq!(opened, plaintext, "roundtrip plaintext mismatch"); + + let mut oracle = Aes128Siv::new((&key_bytes).into()); + let expected = oracle + .encrypt([aad, nonce_bytes], plaintext) + .expect("two-header oracle input is within its component bound"); + assert_eq!(combined, expected, "rscrypto diverged from the RustCrypto oracle"); + + let index = usize::from(corruption_control).rem_euclid(combined.len()); + combined[index] ^= 1; + opened.fill(0xA5); + assert_eq!( + cipher.open(nonce, aad, &combined, &mut opened), + Err(OpenError::verification()), + "corrupted ciphertext must be rejected opaquely" + ); + assert!( + opened.iter().all(|&byte| byte == 0), + "failed open must clear the complete output" + ); +} diff --git a/fuzz/tests/corpus_replay.rs b/fuzz/tests/corpus_replay.rs index 1e79da08..83608d5a 100644 --- a/fuzz/tests/corpus_replay.rs +++ b/fuzz/tests/corpus_replay.rs @@ -24,6 +24,9 @@ mod aead_chacha20poly1305; #[path = "../target_impls/aead_header_protection.rs"] mod aead_header_protection; +#[path = "../target_impls/aead_aes_siv_cmac256.rs"] +mod aead_aes_siv_cmac256; + #[path = "../target_impls/aead_nonce_counter.rs"] mod aead_nonce_counter; @@ -201,6 +204,16 @@ fn replay_aead_header_protection_corpus() { assert_ne!(replayed, 0, "aead_header_protection corpus should not be empty"); } +#[test] +fn replay_aead_aes_siv_cmac256_corpus() { + let replayed = replay_corpus_dir( + "aead_aes_siv_cmac256", + corpus_dir("aead_aes_siv_cmac256"), + aead_aes_siv_cmac256::run, + ); + assert_ne!(replayed, 0, "aead_aes_siv_cmac256 corpus should not be empty"); +} + #[test] fn replay_aead_nonce_counter_corpus() { let replayed = replay_corpus_dir( diff --git a/scripts/check/zeroize-evidence.sh b/scripts/check/zeroize-evidence.sh index 688ab758..20c68c16 100755 --- a/scripts/check/zeroize-evidence.sh +++ b/scripts/check/zeroize-evidence.sh @@ -13,7 +13,7 @@ CARGO_RAIL_CACHE=off CARGO_TARGET_DIR="$TARGET_DIR" cargo rustc \ --release \ --lib \ --no-default-features \ - --features alloc,aegis256,aes-gcm,ascon-aead,blake3,chacha20poly1305,ecdsa-p256,ecdsa-p384,hkdf,hmac,hmac-sha3,ml-kem,parallel,rsa,diag \ + --features alloc,aegis256,aes-gcm,aes-siv,ascon-aead,blake3,chacha20poly1305,ecdsa-p256,ecdsa-p384,hkdf,hmac,hmac-sha3,ml-kem,parallel,rsa,diag \ -- \ -Ccodegen-units=1 \ --emit=mir,llvm-ir,asm @@ -69,6 +69,7 @@ for symbol in \ diag_zeroize_aes128_header_protection \ diag_zeroize_aes256_header_protection \ diag_zeroize_chacha20_header_protection \ + diag_zeroize_aes_siv_cmac256 \ diag_zeroize_mlkem_sha3_512 \ diag_zeroize_mlkem_shake256_scalar \ diag_zeroize_mlkem_shake256_pair \ @@ -115,6 +116,7 @@ for symbol in \ diag_zeroize_aes128_header_protection \ diag_zeroize_aes256_header_protection \ diag_zeroize_chacha20_header_protection \ + diag_zeroize_aes_siv_cmac256 \ diag_zeroize_mlkem_sha3_512 \ diag_zeroize_mlkem_shake256_scalar \ diag_zeroize_mlkem_shake256_pair \ @@ -629,6 +631,7 @@ case "$HOST_ARCH" in diag_zeroize_aes128_header_protection \ diag_zeroize_aes256_header_protection \ diag_zeroize_chacha20_header_protection \ + diag_zeroize_aes_siv_cmac256 \ diag_zeroize_mlkem_sha3_512 \ diag_zeroize_mlkem_shake256_scalar \ diag_zeroize_mlkem_shake256_pair \ @@ -678,6 +681,7 @@ case "$HOST_ARCH" in diag_zeroize_aes128_header_protection \ diag_zeroize_aes256_header_protection \ diag_zeroize_chacha20_header_protection \ + diag_zeroize_aes_siv_cmac256 \ diag_zeroize_mlkem_sha3_512 \ diag_zeroize_mlkem_shake256_scalar \ diag_zeroize_mlkem_shake256_pair \ diff --git a/scripts/ci/check-ci-ownership.sh b/scripts/ci/check-ci-ownership.sh index 42f0238f..f57f9935 100755 --- a/scripts/ci/check-ci-ownership.sh +++ b/scripts/ci/check-ci-ownership.sh @@ -472,8 +472,8 @@ scorecard_action=$(yq eval '.jobs.scorecard.steps[] | select(.name == "Run Score == "true" ]] \ || fail "Scorecard must publish results" -[[ $(count_feature_sets "$FEATURE_PROFILES" COMPILE_FEATURE_SETS) -eq 59 ]] \ - || fail "compile feature matrix must retain all 59 profiles" +[[ $(count_feature_sets "$FEATURE_PROFILES" COMPILE_FEATURE_SETS) -eq 60 ]] \ + || fail "compile feature matrix must retain all 60 profiles" [[ $(count_feature_sets "$FEATURE_PROFILES" EXECUTABLE_FEATURE_SETS) -eq 9 ]] \ || fail "executable feature matrix must contain the nine behavior transitions" require_unique_feature_sets "$FEATURE_PROFILES" COMPILE_FEATURE_SETS diff --git a/scripts/ci/nostd-wasm-suite.sh b/scripts/ci/nostd-wasm-suite.sh index a44c9d35..be6ac109 100755 --- a/scripts/ci/nostd-wasm-suite.sh +++ b/scripts/ci/nostd-wasm-suite.sh @@ -144,6 +144,7 @@ if [[ "$DEPTH" == "deep" ]]; then "x25519" "ml-kem" "chacha20poly1305" + "aes-siv" "ascon-aead" "checksums" "hashes" diff --git a/scripts/lib/feature-profiles.sh b/scripts/lib/feature-profiles.sh index 8cdc204d..bc65977e 100644 --- a/scripts/lib/feature-profiles.sh +++ b/scripts/lib/feature-profiles.sh @@ -32,6 +32,7 @@ COMPILE_FEATURE_SETS=( "rsa,getrandom" "x25519" "chacha20poly1305" + "aes-siv" "aegis256" "ascon-aead" "checksums" diff --git a/scripts/test/test-fuzz.sh b/scripts/test/test-fuzz.sh index 8cb51546..efe06580 100755 --- a/scripts/test/test-fuzz.sh +++ b/scripts/test/test-fuzz.sh @@ -150,7 +150,7 @@ fuzz_dictionary_for_target() { local dict="" case "$target" in - aead_aegis256|aead_aes256gcm|aead_aes256gcmsiv|aead_ascon128|aead_chacha20poly1305|aead_xchacha20poly1305) + aead_aegis256|aead_aes256gcm|aead_aes256gcmsiv|aead_aes_siv_cmac256|aead_ascon128|aead_chacha20poly1305|aead_xchacha20poly1305) dict="$REPO_ROOT/fuzz/dictionaries/aead_boundary.dict" ;; auth_phc) diff --git a/src/aead/aes.rs b/src/aead/aes.rs index af6252ba..9c37e549 100644 --- a/src/aead/aes.rs +++ b/src/aead/aes.rs @@ -600,6 +600,27 @@ pub(crate) fn aes128_expand_key(key: &[u8; KEY_SIZE_128]) -> Aes128EncKey { } } +/// Construct the table-free portable AES-128 key representation for proof harnesses. +/// +/// This bypasses runtime hardware selection without creating a second AES implementation. On +/// RISC-V, the existing table-free fixslice fallback is the portable authority. +#[cfg(feature = "diag")] +#[inline] +pub(crate) fn aes128_expand_key_forced_portable(key: &[u8; KEY_SIZE_128]) -> Aes128EncKey { + #[cfg(target_arch = "riscv64")] + { + Aes128EncKey { + inner: riscv64_fixslice_key_inner_128(key), + } + } + #[cfg(not(target_arch = "riscv64"))] + { + Aes128EncKey { + inner: Key128Inner::PortableRoundKeys(aes128_expand_key_portable(key)), + } + } +} + #[cfg(all(target_arch = "riscv64", feature = "aes-gcm-siv"))] #[inline] pub(crate) fn aes256_expand_key_riscv_vector(key: &[u8; KEY_SIZE]) -> Aes256EncKey { @@ -708,7 +729,7 @@ pub(super) unsafe fn aarch64_encrypt_block_inline(keys: &ce::CeRoundKeys, block: /// Caller must ensure AES-CE is available. #[cfg(all( target_arch = "aarch64", - any(feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256") + any(test, feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256") ))] #[target_feature(enable = "aes,neon")] #[inline] @@ -872,7 +893,12 @@ pub(super) unsafe fn aarch64_encrypt_block_128_inline(keys: &ce::Ce128RoundKeys, /// Caller must ensure AES-CE is available. #[cfg(all( target_arch = "aarch64", - any(feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256") + any( + feature = "aes-gcm", + feature = "aes-gcm-siv", + feature = "aes-siv", + feature = "aegis256" + ) ))] #[target_feature(enable = "aes,neon")] #[inline] @@ -1020,7 +1046,7 @@ pub(super) unsafe fn ppc_encrypt_block_inline(keys: &ppc::PpcRoundKeys, block: & /// Caller must ensure POWER8 crypto is available. #[cfg(all( target_arch = "powerpc64", - any(feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256") + any(test, feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256") ))] #[target_feature(enable = "altivec,vsx,power8-vector,power8-crypto")] #[inline] @@ -1088,7 +1114,12 @@ pub(super) unsafe fn ppc_encrypt_block_128_inline(keys: &ppc::Ppc128RoundKeys, b /// Caller must ensure POWER8 crypto is available. #[cfg(all( target_arch = "powerpc64", - any(feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256") + any( + feature = "aes-gcm", + feature = "aes-gcm-siv", + feature = "aes-siv", + feature = "aegis256" + ) ))] #[target_feature(enable = "altivec,vsx,power8-vector,power8-crypto")] #[inline] @@ -1649,7 +1680,12 @@ pub(super) unsafe fn s390x_encrypt_blocks_raw_128_inline( /// Caller must ensure MSA is available. #[cfg(all( target_arch = "s390x", - any(feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256") + any( + feature = "aes-gcm", + feature = "aes-gcm-siv", + feature = "aes-siv", + feature = "aegis256" + ) ))] #[inline(always)] pub(super) unsafe fn s390x_encrypt_blocks_128_inline(key: &km::Km128Key, blocks: &mut [u8], count: usize) { @@ -1812,18 +1848,90 @@ pub(crate) fn aes128_encrypt_block_prefix_5(ek: &Aes128EncKey, block: &[u8; BLOC prefix } +/// XOR a serial block stream into `state` and AES-128-encrypt after each block. +/// +/// This is the common chained-block kernel used by AES-CMAC. Backend selection occurs once per +/// stream; the portable implementation remains the semantic authority. +#[cfg(feature = "aes-siv")] +#[inline] +pub(crate) fn aes128_xor_encrypt_blocks(ek: &Aes128EncKey, state: &mut [u8; BLOCK_SIZE], blocks: &[[u8; BLOCK_SIZE]]) { + #[inline(always)] + fn xor_block(state: &mut [u8; BLOCK_SIZE], block: &[u8; BLOCK_SIZE]) { + for (state_byte, input_byte) in state.iter_mut().zip(block) { + *state_byte ^= *input_byte; + } + } + + match &ek.inner { + #[cfg(not(target_arch = "riscv64"))] + Key128Inner::PortableRoundKeys(rk) => { + for block in blocks { + xor_block(state, block); + aes128_encrypt_block_portable(rk, state); + } + } + #[cfg(target_arch = "x86_64")] + Key128Inner::X86AesNi(rk) => { + // SAFETY: this key variant is constructed only after AES-NI runtime detection. + unsafe { ni::xor_encrypt_blocks_128(rk, state, blocks) } + } + #[cfg(target_arch = "aarch64")] + Key128Inner::Aarch64Aes(rk) => { + // SAFETY: this key variant is constructed only after AES-CE runtime detection. + unsafe { ce::xor_encrypt_blocks_128(rk, state, blocks) } + } + #[cfg(target_arch = "s390x")] + Key128Inner::S390xMsa(rk) => { + for block in blocks { + xor_block(state, block); + // SAFETY: this key variant is constructed only after MSA/CPACF runtime detection. + unsafe { km::encrypt_block_128(rk, state) } + } + } + #[cfg(target_arch = "powerpc64")] + Key128Inner::Power8Crypto(rk) => { + for block in blocks { + xor_block(state, block); + // SAFETY: this key variant is constructed only after POWER8 crypto runtime detection. + unsafe { ppc::encrypt_block_128(rk, state) } + } + } + #[cfg(target_arch = "riscv64")] + Key128Inner::ScalarCrypto(rk) => { + for block in blocks { + xor_block(state, block); + // SAFETY: this key variant is constructed only after Zkne runtime detection. + unsafe { rv_scalar_aes::encrypt_block_128(rk, state) } + } + } + #[cfg(target_arch = "riscv64")] + Key128Inner::VectorCrypto(rk) => { + for block in blocks { + xor_block(state, block); + // SAFETY: this key variant is constructed only after Zvkned runtime detection. + unsafe { rv_aes::encrypt_block_128(rk, state) } + } + } + #[cfg(target_arch = "riscv64")] + Key128Inner::Fixslice(rk) => { + for block in blocks { + xor_block(state, block); + rv_fixslice_aes::encrypt_block_128(rk, state); + } + } + } +} + /// Encrypt multiple independent 16-byte blocks with AES-128 ECB. /// -/// Mirrors [`aes256_encrypt_blocks_ecb`]: routes to the s390x KM batch -/// instruction or the RV64 4-block kernels when available, otherwise calls -/// the per-block dispatcher. Used by `riscv64` from the AES-128 CTR paths -/// and by AES-128-GCM-SIV key derivation. -// Live callers are GCM-SIV key derivation (any arch), the batch CTR path on the -// arches that have a block-batch kernel, and the unit tests below -- `mod aes` -// is also compiled for `aegis256` under `cfg(test)`. +/// Mirrors [`aes256_encrypt_blocks_ecb`]: routes to the available x86_64, +/// AArch64, POWER, s390x, or RV64 batch kernel and otherwise uses the per-block +/// dispatcher. Live callers are AES-SIV CTR, AES-GCM-SIV key derivation, target +/// batch CTR paths, and the differential tests below. #[cfg(any( test, feature = "aes-gcm-siv", + feature = "aes-siv", all( feature = "aes-gcm", any( @@ -1836,6 +1944,21 @@ pub(crate) fn aes128_encrypt_block_prefix_5(ek: &Aes128EncKey, block: &[u8; BLOC ))] #[inline] pub(crate) fn aes128_encrypt_blocks_ecb(ek: &Aes128EncKey, blocks: &mut [[u8; BLOCK_SIZE]]) { + #[cfg(target_arch = "x86_64")] + if let Key128Inner::X86AesNi(ni_rk) = &ek.inner { + if !blocks.is_empty() { + if blocks.len() >= 16 && crate::platform::caps().has(crate::platform::caps::x86::VAES_READY) { + // SAFETY: the key variant proves AES-NI availability, `VAES_READY` proves the required + // VAES/AVX-512 CPU and OS state, and `blocks` contains complete disjoint AES blocks. + unsafe { ni::encrypt_blocks_128_vaes512(ni_rk, blocks) }; + } else { + // SAFETY: the key variant proves AES-NI availability, and `blocks` contains complete, + // disjoint, initialized AES blocks. + unsafe { ni::encrypt_blocks_128(ni_rk, blocks) }; + } + } + return; + } #[cfg(target_arch = "aarch64")] if let Key128Inner::Aarch64Aes(ce_rk) = &ek.inner { if !blocks.is_empty() { diff --git a/src/aead/aes/aarch64_ce.rs b/src/aead/aes/aarch64_ce.rs index 81e716bd..3e8b4c1c 100644 --- a/src/aead/aes/aarch64_ce.rs +++ b/src/aead/aes/aarch64_ce.rs @@ -1637,6 +1637,25 @@ pub(super) unsafe fn encrypt_block_128_core(keys: &Ce128RoundKeys, block: &mut [ } } +/// XOR a serial block stream into `state` and AES-128-encrypt after every block. +/// +/// # Safety +/// Caller must ensure the CPU supports AES-CE and NEON. +#[cfg(feature = "aes-siv")] +#[target_feature(enable = "aes,neon")] +pub(super) unsafe fn xor_encrypt_blocks_128(keys: &Ce128RoundKeys, state: &mut [u8; 16], blocks: &[[u8; 16]]) { + // SAFETY: AES-CE + NEON are enabled. Every load covers one complete initialized block, the + // final store covers the complete state, and the loop count depends only on public length. + unsafe { + let mut chain = vld1q_u8(state.as_ptr()); + for block in blocks { + chain = veorq_u8(chain, vld1q_u8(block.as_ptr())); + chain = encrypt_state_128_core(keys, chain); + } + vst1q_u8(state.as_mut_ptr(), chain); + } +} + #[target_feature(enable = "aes,neon")] #[inline] /// # Safety diff --git a/src/aead/aes/s390x_km.rs b/src/aead/aes/s390x_km.rs index 43a49a92..b5aa050a 100644 --- a/src/aead/aes/s390x_km.rs +++ b/src/aead/aes/s390x_km.rs @@ -304,7 +304,12 @@ pub(super) unsafe fn encrypt_block_128(key: &Km128Key, block: &mut [u8; 16]) { /// # Safety /// Caller must ensure the MSA (CPACF) facility is available. /// `blocks` must contain exactly `count * 16` bytes. -#[cfg(any(feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256"))] +#[cfg(any( + feature = "aes-gcm", + feature = "aes-gcm-siv", + feature = "aes-siv", + feature = "aegis256" +))] pub(super) unsafe fn encrypt_blocks_128(key: &Km128Key, blocks: &mut [u8], count: usize) { debug_assert_eq!(blocks.len(), count.strict_mul(16)); diff --git a/src/aead/aes/x86_64_ni.rs b/src/aead/aes/x86_64_ni.rs index ff33bde0..fcc641f6 100644 --- a/src/aead/aes/x86_64_ni.rs +++ b/src/aead/aes/x86_64_ni.rs @@ -376,7 +376,7 @@ pub(super) unsafe fn encrypt_4blocks_128(keys: &Ni128RoundKeys, blocks: __m512i) /// /// # Safety /// Caller must ensure AES-NI and SSE2 are available. -#[cfg(feature = "aes-gcm")] +#[cfg(any(test, feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aes-siv"))] #[target_feature(enable = "aes,sse2")] #[inline] pub(super) unsafe fn encrypt_4blocks_128_aesni( @@ -426,7 +426,7 @@ pub(super) unsafe fn encrypt_4blocks_128_aesni( /// /// # Safety /// Caller must ensure AVX-512F + AVX-512VL + VAES + AES + SSE2. -#[cfg(any(feature = "aes-gcm", feature = "aes-gcm-siv"))] +#[cfg(any(test, feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aes-siv"))] #[target_feature(enable = "aes,sse2,avx512f,avx512vl,vaes")] #[inline] pub(super) unsafe fn encrypt_16blocks_128( @@ -527,3 +527,78 @@ pub(super) unsafe fn encrypt_block_prefix_5_128(keys: &Ni128RoundKeys, block: &[ [prefix[0], prefix[1], prefix[2], prefix[3], prefix[4]] } } + +/// Encrypt independent AES-128 blocks in four-way AES-NI batches. +/// +/// # Safety +/// Caller must ensure the CPU supports AES-NI (`target_feature = "aes"`). +#[cfg(any(test, feature = "aes-gcm-siv", feature = "aes-siv"))] +#[target_feature(enable = "aes,sse2")] +pub(super) unsafe fn encrypt_blocks_128(keys: &Ni128RoundKeys, blocks: &mut [[u8; 16]]) { + // SAFETY: AES-NI + SSE2 are enabled. Every load and store is through a complete initialized + // block reference, and four-block batches hold disjoint mutable elements. + unsafe { + let (batches, remainder) = blocks.as_chunks_mut::<4>(); + for batch in batches { + let b0 = _mm_loadu_si128(batch[0].as_ptr().cast()); + let b1 = _mm_loadu_si128(batch[1].as_ptr().cast()); + let b2 = _mm_loadu_si128(batch[2].as_ptr().cast()); + let b3 = _mm_loadu_si128(batch[3].as_ptr().cast()); + let (b0, b1, b2, b3) = encrypt_4blocks_128_aesni(keys, b0, b1, b2, b3); + _mm_storeu_si128(batch[0].as_mut_ptr().cast(), b0); + _mm_storeu_si128(batch[1].as_mut_ptr().cast(), b1); + _mm_storeu_si128(batch[2].as_mut_ptr().cast(), b2); + _mm_storeu_si128(batch[3].as_mut_ptr().cast(), b3); + } + for block in remainder { + let state = encrypt_state_128(keys, _mm_loadu_si128(block.as_ptr().cast())); + _mm_storeu_si128(block.as_mut_ptr().cast(), state); + } + } +} + +/// Encrypt independent AES-128 blocks in sixteen-way VAES-512 batches. +/// +/// # Safety +/// Caller must ensure the CPU and OS support AES-NI, VAES, AVX-512F, and AVX-512VL. +#[cfg(any(test, feature = "aes-gcm-siv", feature = "aes-siv"))] +#[target_feature(enable = "aes,sse2,avx512f,avx512vl,vaes")] +pub(super) unsafe fn encrypt_blocks_128_vaes512(keys: &Ni128RoundKeys, blocks: &mut [[u8; 16]]) { + // SAFETY: required target features are enabled. Each 256-byte batch contains sixteen complete, + // initialized, disjoint blocks; the four unaligned vector loads and stores stay within it. + unsafe { + let (batches, remainder) = blocks.as_chunks_mut::<16>(); + for batch in batches { + let ptr = batch.as_mut_ptr().cast::(); + let b0 = _mm512_loadu_si512(ptr.cast()); + let b1 = _mm512_loadu_si512(ptr.add(64).cast()); + let b2 = _mm512_loadu_si512(ptr.add(128).cast()); + let b3 = _mm512_loadu_si512(ptr.add(192).cast()); + let (b0, b1, b2, b3) = encrypt_16blocks_128(keys, b0, b1, b2, b3); + _mm512_storeu_si512(ptr.cast(), b0); + _mm512_storeu_si512(ptr.add(64).cast(), b1); + _mm512_storeu_si512(ptr.add(128).cast(), b2); + _mm512_storeu_si512(ptr.add(192).cast(), b3); + } + encrypt_blocks_128(keys, remainder); + } +} + +/// XOR a serial block stream into `state` and AES-128-encrypt after every block. +/// +/// # Safety +/// Caller must ensure the CPU supports AES-NI (`target_feature = "aes"`). +#[cfg(feature = "aes-siv")] +#[target_feature(enable = "aes,sse2")] +pub(super) unsafe fn xor_encrypt_blocks_128(keys: &Ni128RoundKeys, state: &mut [u8; 16], blocks: &[[u8; 16]]) { + // SAFETY: AES-NI + SSE2 are enabled. Inputs are complete initialized blocks; the only write is + // the final fixed-size state store. The loop count depends solely on public input length. + unsafe { + let mut chain = _mm_loadu_si128(state.as_ptr().cast()); + for block in blocks { + chain = _mm_xor_si128(chain, _mm_loadu_si128(block.as_ptr().cast())); + chain = encrypt_state_128(keys, chain); + } + _mm_storeu_si128(state.as_mut_ptr().cast(), chain); + } +} diff --git a/src/aead/aes_siv_cmac256.rs b/src/aead/aes_siv_cmac256.rs new file mode 100644 index 00000000..3a9fa748 --- /dev/null +++ b/src/aead/aes_siv_cmac256.rs @@ -0,0 +1,617 @@ +//! AES-SIV-CMAC-256 nonce-based authenticated encryption (RFC 5297). +//! +//! This module implements only the registered 32-byte-key RFC 5116 profile. It does not expose +//! deterministic SIV, CMAC, S2V, raw AES, or a vector-of-associated-data interface. + +use core::fmt; + +use super::{AeadBufferError, OpenError, SealError, aes}; +use crate::traits::ct; + +const BLOCK_SIZE: usize = 16; +const KEY_SIZE: usize = 32; +const TAG_SIZE: usize = 16; +const CTR_BATCH_BLOCKS: usize = 8; +#[cfg(target_arch = "x86_64")] +const CTR_WIDE_BATCH_BLOCKS: usize = 16; + +define_aead_key_type!(AesSivCmac256Key, KEY_SIZE, "AES-SIV-CMAC-256 secret key (32 bytes)."); + +define_aead_tag_type!( + AesSivCmac256Tag, + TAG_SIZE, + "AES-SIV-CMAC-256 synthetic-IV authentication tag (16 bytes)." +); + +define_unit_error! { + /// Empty nonce supplied to the AES-SIV-CMAC-256 nonce-based profile. + pub struct AesSivCmac256NonceError; + "AES-SIV-CMAC-256 nonce must not be empty" +} + +/// Borrowed non-empty nonce for the AES-SIV-CMAC-256 RFC 5116 profile. +/// +/// RFC 5297 permits any nonce length of at least one byte. The nonce is the final S2V +/// associated-data component immediately before the plaintext. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct AesSivCmac256Nonce<'a>(&'a [u8]); + +impl<'a> AesSivCmac256Nonce<'a> { + /// Borrow the nonce bytes. + #[inline] + #[must_use] + pub const fn as_bytes(self) -> &'a [u8] { + self.0 + } +} + +impl<'a> TryFrom<&'a [u8]> for AesSivCmac256Nonce<'a> { + type Error = AesSivCmac256NonceError; + + #[inline] + fn try_from(bytes: &'a [u8]) -> Result { + if bytes.is_empty() { + Err(AesSivCmac256NonceError::new()) + } else { + Ok(Self(bytes)) + } + } +} + +impl AsRef<[u8]> for AesSivCmac256Nonce<'_> { + #[inline] + fn as_ref(&self) -> &[u8] { + self.0 + } +} + +impl fmt::Debug for AesSivCmac256Nonce<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("AesSivCmac256Nonce(")?; + crate::hex::fmt_hex_lower(self.0, f)?; + f.write_str(")") + } +} + +/// AES-SIV-CMAC-256 nonce-based authenticated encryption (RFC 5297 section 6.1). +/// +/// The 32-byte key is split into independent AES-128 CMAC/S2V and CTR keys. The public profile +/// accepts one RFC 5116 associated-data string and a distinct non-empty variable-length nonce. +/// Detached operations return or accept the 16-byte synthetic IV as a typed tag. Combined +/// operations use the RFC-defined `synthetic_iv || ciphertext` layout. +/// +/// Authentication failure is opaque and clears the complete unauthenticated plaintext buffer +/// before returning. The context is intentionally neither `Clone` nor `Copy`. +/// +/// # Security +/// +/// SIV preserves authenticity under nonce reuse, while repeated key/nonce/AAD/plaintext tuples +/// reveal equality. Applications should still issue unique nonces. Constant-time claims remain +/// compiler-, target-, feature-, and release-evidence-bound; see `ct.toml`. +pub struct AesSivCmac256 { + cmac_key: aes::Aes128EncKey, + ctr_key: aes::Aes128EncKey, + cmac_subkey1: [u8; BLOCK_SIZE], + cmac_subkey2: [u8; BLOCK_SIZE], +} + +impl fmt::Debug for AesSivCmac256 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AesSivCmac256").finish_non_exhaustive() + } +} + +impl Drop for AesSivCmac256 { + fn drop(&mut self) { + ct::zeroize_no_fence(&mut self.cmac_subkey1); + ct::zeroize_no_fence(&mut self.cmac_subkey2); + core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst); + } +} + +impl AesSivCmac256 { + /// Key length in bytes. + pub const KEY_SIZE: usize = KEY_SIZE; + + /// Minimum nonce length in bytes. + pub const MIN_NONCE_SIZE: usize = 1; + + /// Synthetic-IV tag length in bytes. + pub const TAG_SIZE: usize = TAG_SIZE; + + /// Construct an AES-SIV-CMAC-256 context. + #[must_use] + pub fn new(key: &AesSivCmac256Key) -> Self { + let (cmac_key_bytes, ctr_key_bytes) = key + .as_bytes() + .split_first_chunk::<16>() + .expect("AES-SIV-CMAC-256 key has two fixed 16-byte halves"); + let ctr_key_bytes = ctr_key_bytes + .first_chunk::<16>() + .expect("AES-SIV-CMAC-256 key has two fixed 16-byte halves"); + let cmac_key = aes::aes128_expand_key(cmac_key_bytes); + let ctr_key = aes::aes128_expand_key(ctr_key_bytes); + + let mut l = [0u8; BLOCK_SIZE]; + aes::aes128_encrypt_block(&cmac_key, &mut l); + let mut cmac_subkey1 = l; + double_block(&mut cmac_subkey1); + let mut cmac_subkey2 = cmac_subkey1; + double_block(&mut cmac_subkey2); + ct::zeroize(&mut l); + + Self { + cmac_key, + ctr_key, + cmac_subkey1, + cmac_subkey2, + } + } + + #[cfg(feature = "diag")] + fn new_forced_portable(key: &AesSivCmac256Key) -> Self { + let (cmac_key_bytes, ctr_key_bytes) = key + .as_bytes() + .split_first_chunk::<16>() + .expect("AES-SIV-CMAC-256 key has two fixed 16-byte halves"); + let ctr_key_bytes = ctr_key_bytes + .first_chunk::<16>() + .expect("AES-SIV-CMAC-256 key has two fixed 16-byte halves"); + + let cmac_key = aes::aes128_expand_key_forced_portable(cmac_key_bytes); + let ctr_key = aes::aes128_expand_key_forced_portable(ctr_key_bytes); + + let mut l = [0u8; BLOCK_SIZE]; + aes::aes128_encrypt_block(&cmac_key, &mut l); + let mut cmac_subkey1 = l; + double_block(&mut cmac_subkey1); + let mut cmac_subkey2 = cmac_subkey1; + double_block(&mut cmac_subkey2); + ct::zeroize(&mut l); + + Self { + cmac_key, + ctr_key, + cmac_subkey1, + cmac_subkey2, + } + } + + /// Rebuild a typed synthetic-IV tag from raw bytes. + #[inline] + pub fn tag_from_slice(bytes: &[u8]) -> Result { + if bytes.len() != TAG_SIZE { + return Err(AeadBufferError::new()); + } + let mut tag = [0u8; TAG_SIZE]; + tag.copy_from_slice(bytes); + Ok(AesSivCmac256Tag::from_bytes(tag)) + } + + /// Encrypt `buffer` in place and return its detached synthetic-IV tag. + #[must_use] + pub fn seal_in_place(&self, nonce: AesSivCmac256Nonce<'_>, aad: &[u8], buffer: &mut [u8]) -> AesSivCmac256Tag { + let tag = self.s2v(&[aad, nonce.as_bytes()], buffer); + self.ctr_xor(&tag, buffer); + AesSivCmac256Tag::from_bytes(tag) + } + + /// Decrypt `buffer` in place and authenticate its detached synthetic-IV tag. + /// + /// On authentication failure the entire buffer is cleared before one opaque verification error + /// is returned. + pub fn open_in_place( + &self, + nonce: AesSivCmac256Nonce<'_>, + aad: &[u8], + buffer: &mut [u8], + tag: &AesSivCmac256Tag, + ) -> Result<(), OpenError> { + self.ctr_xor(tag.as_bytes(), buffer); + let mut computed = self.s2v(&[aad, nonce.as_bytes()], buffer); + let accepted = ct::fixed_eq(&computed, tag.as_bytes()); + ct::zeroize(&mut computed); + + if accepted.declassify() { + Ok(()) + } else { + ct::zeroize(buffer); + Err(OpenError::verification()) + } + } + + /// Encrypt `plaintext` into `out` as `synthetic_iv || ciphertext`. + /// + /// `out` must be exactly 16 bytes longer than `plaintext`. + pub fn seal( + &self, + nonce: AesSivCmac256Nonce<'_>, + aad: &[u8], + plaintext: &[u8], + out: &mut [u8], + ) -> Result<(), SealError> { + let expected = plaintext.len().checked_add(TAG_SIZE).ok_or_else(SealError::too_large)?; + if out.len() != expected { + return Err(SealError::buffer()); + } + + let (tag_out, ciphertext) = out.split_at_mut(TAG_SIZE); + ciphertext.copy_from_slice(plaintext); + let tag = self.seal_in_place(nonce, aad, ciphertext); + tag_out.copy_from_slice(tag.as_bytes()); + Ok(()) + } + + /// Decrypt RFC-layout `synthetic_iv || ciphertext` into `out`. + /// + /// `out` must be exactly 16 bytes shorter than `tag_and_ciphertext`. On authentication failure + /// every byte of `out` is cleared before one opaque verification error is returned. + pub fn open( + &self, + nonce: AesSivCmac256Nonce<'_>, + aad: &[u8], + tag_and_ciphertext: &[u8], + out: &mut [u8], + ) -> Result<(), OpenError> { + if tag_and_ciphertext.len() < TAG_SIZE { + return Err(OpenError::buffer()); + } + let plaintext_len = tag_and_ciphertext.len().strict_sub(TAG_SIZE); + if out.len() != plaintext_len { + return Err(OpenError::buffer()); + } + + let (tag_bytes, ciphertext) = tag_and_ciphertext.split_at(TAG_SIZE); + let mut raw_tag = [0u8; TAG_SIZE]; + raw_tag.copy_from_slice(tag_bytes); + let tag = AesSivCmac256Tag::from_bytes(raw_tag); + out.copy_from_slice(ciphertext); + self.open_in_place(nonce, aad, out, &tag) + } + + fn cmac_step(&self, state: &mut [u8; BLOCK_SIZE], block: &[u8]) { + for (state_byte, input_byte) in state.iter_mut().zip(block) { + *state_byte ^= *input_byte; + } + aes::aes128_encrypt_block(&self.cmac_key, state); + } + + fn cmac(&self, input: &[u8]) -> [u8; BLOCK_SIZE] { + let mut state = [0u8; BLOCK_SIZE]; + let mut final_block = [0u8; BLOCK_SIZE]; + + if input.is_empty() { + final_block[0] = 0x80; + xor_block(&mut final_block, &self.cmac_subkey2); + } else { + let remainder = input.len().strict_rem(BLOCK_SIZE); + let final_len = if remainder == 0 { BLOCK_SIZE } else { remainder }; + let prefix_len = input.len().strict_sub(final_len); + let (prefix, final_input) = input.split_at(prefix_len); + let (prefix_blocks, prefix_tail) = prefix.as_chunks::(); + debug_assert!(prefix_tail.is_empty()); + if !prefix_blocks.is_empty() { + aes::aes128_xor_encrypt_blocks(&self.cmac_key, &mut state, prefix_blocks); + } + final_block[..final_input.len()].copy_from_slice(final_input); + if final_input.len() == BLOCK_SIZE { + xor_block(&mut final_block, &self.cmac_subkey1); + } else { + final_block[final_input.len()] = 0x80; + xor_block(&mut final_block, &self.cmac_subkey2); + } + } + + self.cmac_step(&mut state, &final_block); + ct::zeroize(&mut final_block); + state + } + + fn cmac_xorend(&self, input: &[u8], suffix: &[u8; BLOCK_SIZE]) -> [u8; BLOCK_SIZE] { + debug_assert!(input.len() >= BLOCK_SIZE); + let suffix_start = input.len().strict_sub(BLOCK_SIZE); + let prefix_len = suffix_start.strict_sub(suffix_start.strict_rem(BLOCK_SIZE)); + let (prefix, affected) = input.split_at(prefix_len); + let mut state = [0u8; BLOCK_SIZE]; + let (prefix_blocks, prefix_tail) = prefix.as_chunks::(); + debug_assert!(prefix_tail.is_empty()); + if !prefix_blocks.is_empty() { + aes::aes128_xor_encrypt_blocks(&self.cmac_key, &mut state, prefix_blocks); + } + + let mut offset = 0usize; + while affected.len().strict_sub(offset) > BLOCK_SIZE { + let mut block = [0u8; BLOCK_SIZE]; + block.copy_from_slice(&affected[offset..offset.strict_add(BLOCK_SIZE)]); + xor_suffix_window(&mut block, prefix_len.strict_add(offset), suffix_start, suffix); + self.cmac_step(&mut state, &block); + ct::zeroize(&mut block); + offset = offset.strict_add(BLOCK_SIZE); + } + + let final_input = &affected[offset..]; + let mut final_block = [0u8; BLOCK_SIZE]; + final_block[..final_input.len()].copy_from_slice(final_input); + xor_suffix_window(&mut final_block, prefix_len.strict_add(offset), suffix_start, suffix); + if final_input.len() == BLOCK_SIZE { + xor_block(&mut final_block, &self.cmac_subkey1); + } else { + final_block[final_input.len()] = 0x80; + xor_block(&mut final_block, &self.cmac_subkey2); + } + self.cmac_step(&mut state, &final_block); + ct::zeroize(&mut final_block); + state + } + + fn s2v(&self, components: &[&[u8]], plaintext: &[u8]) -> [u8; BLOCK_SIZE] { + let mut d = self.cmac(&[0u8; BLOCK_SIZE]); + for component in components { + double_block(&mut d); + let mut component_tag = self.cmac(component); + xor_block(&mut d, &component_tag); + ct::zeroize(&mut component_tag); + } + + let tag = if plaintext.len() >= BLOCK_SIZE { + self.cmac_xorend(plaintext, &d) + } else { + double_block(&mut d); + for (dst, src) in d.iter_mut().zip(plaintext) { + *dst ^= *src; + } + d[plaintext.len()] ^= 0x80; + self.cmac(&d) + }; + ct::zeroize(&mut d); + tag + } + + fn ctr_xor(&self, tag: &[u8; TAG_SIZE], data: &mut [u8]) { + if data.is_empty() { + return; + } + + #[cfg(target_arch = "x86_64")] + if data.len() >= CTR_WIDE_BATCH_BLOCKS.strict_mul(BLOCK_SIZE) { + self.ctr_xor_batched::(tag, data); + return; + } + + self.ctr_xor_batched::(tag, data); + } + + fn ctr_xor_batched(&self, tag: &[u8; TAG_SIZE], data: &mut [u8]) { + debug_assert!(BATCH_BLOCKS > 0); + let mut initial = *tag; + initial[8] &= 0x7f; + initial[12] &= 0x7f; + let mut counter = u128::from_be_bytes(initial); + let mut keystream = [[0u8; BLOCK_SIZE]; BATCH_BLOCKS]; + let mut offset = 0usize; + + while offset < data.len() { + let remaining = data.len().strict_sub(offset); + let whole_blocks = remaining.strict_div(BLOCK_SIZE); + let partial_block = usize::from(remaining.strict_rem(BLOCK_SIZE) != 0); + let block_count = whole_blocks.strict_add(partial_block).min(BATCH_BLOCKS); + + for (lane, block) in keystream[..block_count].iter_mut().enumerate() { + *block = counter.wrapping_add(lane as u128).to_be_bytes(); + } + aes::aes128_encrypt_blocks_ecb(&self.ctr_key, &mut keystream[..block_count]); + + for block in &keystream[..block_count] { + let take = data.len().strict_sub(offset).min(BLOCK_SIZE); + for (output, mask) in data[offset..offset.strict_add(take)].iter_mut().zip(block) { + *output ^= *mask; + } + offset = offset.strict_add(take); + } + + counter = counter.wrapping_add(block_count as u128); + ct::zeroize_no_fence(keystream.as_flattened_mut()); + } + + ct::zeroize_no_fence(&mut initial); + core::sync::atomic::compiler_fence(core::sync::atomic::Ordering::SeqCst); + } +} + +/// Evaluate the private CMAC/S2V state through the forced-portable AES authority. +/// +/// This diagnostic exists only for constant-time and backend-equivalence evidence. It is not a +/// supported CMAC, S2V, or deterministic-SIV product API. +#[cfg(feature = "diag")] +#[doc(hidden)] +#[inline(never)] +#[must_use] +pub fn diag_aes_siv_cmac256_s2v_portable( + key: &[u8; KEY_SIZE], + nonce: &[u8; BLOCK_SIZE], + aad: &[u8; 32], + plaintext: &[u8; 48], +) -> [u8; TAG_SIZE] { + let key = AesSivCmac256Key::from_bytes(*key); + let cipher = AesSivCmac256::new_forced_portable(&key); + cipher.s2v(&[aad, nonce], plaintext) +} + +/// Run a complete fixed-shape open through the forced-portable AES authority. +/// +/// This diagnostic exists only for generated-code and timing evidence. The returned byte is an +/// opaque success indicator; authentication failure still clears the complete plaintext buffer. +#[cfg(feature = "diag")] +#[doc(hidden)] +#[inline(never)] +#[must_use] +pub fn diag_aes_siv_cmac256_open_portable( + key: &[u8; KEY_SIZE], + nonce: &[u8; BLOCK_SIZE], + aad: &[u8; 32], + ciphertext: &[u8; 48], + tag: &[u8; TAG_SIZE], +) -> u8 { + let key = AesSivCmac256Key::from_bytes(*key); + let cipher = AesSivCmac256::new_forced_portable(&key); + let nonce = AesSivCmac256Nonce::try_from(nonce.as_slice()).expect("fixed diagnostic nonce is non-empty"); + let tag = AesSivCmac256Tag::from_bytes(*tag); + let mut buffer = *ciphertext; + let accepted = cipher.open_in_place(nonce, aad, &mut buffer, &tag).is_ok(); + let digest = buffer.iter().copied().fold(0u8, |acc, byte| acc ^ byte); + digest ^ u8::from(accepted) +} + +#[cfg(feature = "diag")] +#[doc(hidden)] +/// Exercise AES-SIV construction, seal, open, local cleanup, and retained-owner drop. +#[unsafe(no_mangle)] +#[inline(never)] +#[must_use] +pub fn diag_zeroize_aes_siv_cmac256(key: [u8; KEY_SIZE], nonce: [u8; BLOCK_SIZE], input: [u8; 48]) -> u8 { + let key = AesSivCmac256Key::from_bytes(key); + let cipher = AesSivCmac256::new(&key); + let nonce = AesSivCmac256Nonce::try_from(nonce.as_slice()).expect("fixed diagnostic nonce is non-empty"); + let mut buffer = input; + let tag = cipher.seal_in_place(nonce, b"zeroize evidence", &mut buffer); + cipher + .open_in_place(nonce, b"zeroize evidence", &mut buffer, &tag) + .expect("fresh diagnostic ciphertext authenticates"); + buffer[0] ^ tag.as_bytes()[0] +} + +#[inline] +fn xor_block(left: &mut [u8; BLOCK_SIZE], right: &[u8; BLOCK_SIZE]) { + for (left_byte, right_byte) in left.iter_mut().zip(right) { + *left_byte ^= *right_byte; + } +} + +#[inline] +fn double_block(block: &mut [u8; BLOCK_SIZE]) { + let mut carry = 0u8; + for byte in block.iter_mut().rev() { + let next_carry = *byte >> 7; + *byte = (*byte << 1) | carry; + carry = next_carry; + } + block[BLOCK_SIZE.strict_sub(1)] ^= 0x87 & 0u8.wrapping_sub(carry); +} + +fn xor_suffix_window(block: &mut [u8; BLOCK_SIZE], block_start: usize, suffix_start: usize, suffix: &[u8; BLOCK_SIZE]) { + for (index, byte) in block.iter_mut().enumerate() { + let position = block_start.strict_add(index); + if position >= suffix_start && position < suffix_start.strict_add(BLOCK_SIZE) { + *byte ^= suffix[position.strict_sub(suffix_start)]; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::aead::test_vectors::{hex_vec, hex16, hex32}; + + fn context(key_hex: &str) -> AesSivCmac256 { + AesSivCmac256::new(&AesSivCmac256Key::from_bytes(hex32(key_hex))) + } + + #[test] + fn rfc5297_appendix_a1_deterministic_construction() { + let cipher = context("fffefdfcfbfaf9f8f7f6f5f4f3f2f1f0f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff"); + let aad = hex_vec("101112131415161718191a1b1c1d1e1f2021222324252627"); + let mut plaintext = hex_vec("112233445566778899aabbccddee"); + let expected = hex_vec("85632d07c6e8f37f950acd320a2ecc9340c02b9690c4dc04daef7f6afe5c"); + + let tag = cipher.s2v(&[&aad], &plaintext); + assert_eq!(tag, expected[..TAG_SIZE]); + cipher.ctr_xor(&tag, &mut plaintext); + assert_eq!(plaintext, expected[TAG_SIZE..]); + } + + #[test] + fn rfc5297_appendix_a2_nonce_based_construction() { + let cipher = context("7f7e7d7c7b7a79787776757473727170404142434445464748494a4b4c4d4e4f"); + let aad1 = hex_vec("00112233445566778899aabbccddeeffdeaddadadeaddadaffeeddccbbaa99887766554433221100"); + let aad2 = hex_vec("102030405060708090a0"); + let nonce = hex_vec("09f911029d74e35bd84156c5635688c0"); + let mut plaintext = + hex_vec("7468697320697320736f6d6520706c61696e7465787420746f20656e6372797074207573696e67205349562d414553"); + let expected = hex_vec( + "7bdb6e3b432667eb06f4d14bff2fbd0fcb900f2fddbe404326601965c889bf17dba77ceb094fa663b7a3f748ba8af829ea64ad544a272e9c485b62a3fd5c0d", + ); + + let tag = cipher.s2v(&[&aad1, &aad2, &nonce], &plaintext); + assert_eq!(tag, expected[..TAG_SIZE]); + cipher.ctr_xor(&tag, &mut plaintext); + assert_eq!(plaintext, expected[TAG_SIZE..]); + } + + #[test] + fn ctr_uses_full_big_endian_counter_and_clears_required_bits() { + use ::aes::cipher::{Array, BlockCipherEncrypt as _, KeyInit as _}; + + let key = hex32("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + let cipher = AesSivCmac256::new(&AesSivCmac256Key::from_bytes(key)); + let tag = hex16("fffffffffffffffffffffffffffffffe"); + let mut actual = [0u8; 48]; + cipher.ctr_xor(&tag, &mut actual); + + let ctr_key: [u8; 16] = key[16..].try_into().expect("CTR key half has fixed length"); + let aes = ::aes::Aes128::new(&Array::from(ctr_key)); + let mut counter_bytes = tag; + counter_bytes[8] &= 0x7f; + counter_bytes[12] &= 0x7f; + let counter = u128::from_be_bytes(counter_bytes); + let mut expected = [0u8; 48]; + for (index, chunk) in expected.as_chunks_mut::<16>().0.iter_mut().enumerate() { + let mut block = Array::from(counter.wrapping_add(index as u128).to_be_bytes()); + aes.encrypt_block(&mut block); + chunk.copy_from_slice(&block); + } + assert_eq!(actual, expected); + } + + #[cfg(feature = "diag")] + #[test] + fn forced_portable_cmac_s2v_and_ctr_match_selected_backend() { + const LENGTHS: &[usize] = &[0, 1, 15, 16, 17, 31, 32, 33, 47, 48, 49, 63, 64, 65, 127, 128, 129]; + + for case in 0u8..32 { + let mut key_bytes = [0u8; KEY_SIZE]; + for (index, byte) in key_bytes.iter_mut().enumerate() { + let index = u8::try_from(index).expect("AES-SIV key index fits in u8"); + *byte = case.wrapping_mul(29).wrapping_add(index).rotate_left(1); + } + let key = AesSivCmac256Key::from_bytes(key_bytes); + let selected = AesSivCmac256::new(&key); + let portable = AesSivCmac256::new_forced_portable(&key); + + let mut storage = [0u8; 131]; + for (index, byte) in storage.iter_mut().enumerate() { + let index = u8::try_from(index).expect("AES-SIV test input index fits in u8"); + *byte = case.wrapping_mul(17).wrapping_add(index).rotate_left(3); + } + let offset = usize::from(case & 1); + let nonce = &storage[offset..offset.strict_add(16)]; + let aad = &storage[offset..offset.strict_add(32)]; + + for &len in LENGTHS { + let input = &storage[offset..offset.strict_add(len)]; + assert_eq!(selected.cmac(input), portable.cmac(input), "CMAC case={case} len={len}"); + assert_eq!( + selected.s2v(&[aad, nonce], input), + portable.s2v(&[aad, nonce], input), + "S2V case={case} len={len}" + ); + + let tag = selected.s2v(&[aad, nonce], input); + let mut selected_ctr = input.to_vec(); + let mut portable_ctr = selected_ctr.clone(); + selected.ctr_xor(&tag, &mut selected_ctr); + portable.ctr_xor(&tag, &mut portable_ctr); + assert_eq!(selected_ctr, portable_ctr, "CTR case={case} len={len}"); + } + } + } +} diff --git a/src/aead/mod.rs b/src/aead/mod.rs index 47e58532..af2b61bf 100644 --- a/src/aead/mod.rs +++ b/src/aead/mod.rs @@ -87,9 +87,19 @@ mod aegis256; #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", all(feature = "aegis256", target_arch = "riscv64"), all(feature = "aegis256", test), ))] +// AES-SIV-CMAC-256 uses only the AES-128 half of the shared AES authority. Keep the unused +// AES-256 sibling compiled but lint-silent for this isolated leaf instead of duplicating backends. +#[cfg_attr( + all( + feature = "aes-siv", + not(any(feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256")) + ), + expect(dead_code, reason = "isolated AES-SIV leaf reuses the shared AES-128 authority") +)] mod aes; #[cfg(feature = "aes-gcm")] mod aes128gcm; @@ -104,7 +114,17 @@ mod aes256gcmsiv; all(target_arch = "riscv64", any(feature = "aes-gcm", feature = "aes-gcm-siv")) ))] mod aes_round; -#[cfg(all(test, any(feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256")))] +#[cfg(feature = "aes-siv")] +mod aes_siv_cmac256; +#[cfg(all( + test, + any( + feature = "aes-gcm", + feature = "aes-gcm-siv", + feature = "aes-siv", + feature = "aegis256" + ) +))] mod test_vectors { use alloc::{vec, vec::Vec}; @@ -122,13 +142,13 @@ mod test_vectors { } #[track_caller] - #[cfg(any(feature = "aes-gcm", feature = "aes-gcm-siv"))] + #[cfg(any(feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aes-siv"))] pub(super) fn hex16(hex: &str) -> [u8; 16] { hex_array(hex) } #[track_caller] - #[cfg(any(feature = "aes-gcm", feature = "aes-gcm-siv"))] + #[cfg(any(feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aes-siv"))] pub(super) fn hex32(hex: &str) -> [u8; 32] { hex_array(hex) } @@ -194,6 +214,14 @@ pub use aegis256::diag_aegis256_update_portable; pub use aegis256::{Aegis256, Aegis256Key, Aegis256Tag}; #[cfg(all(feature = "diag", feature = "aegis256"))] pub use aes_round::diag_aes_enc_round_portable; +#[cfg(feature = "aes-siv")] +pub use aes_siv_cmac256::{ + AesSivCmac256, AesSivCmac256Key, AesSivCmac256Nonce, AesSivCmac256NonceError, AesSivCmac256Tag, +}; +#[cfg(all(feature = "diag", feature = "aes-siv"))] +pub use aes_siv_cmac256::{ + diag_aes_siv_cmac256_open_portable, diag_aes_siv_cmac256_s2v_portable, diag_zeroize_aes_siv_cmac256, +}; #[cfg(feature = "aes-gcm")] pub use aes128gcm::{Aes128Gcm, Aes128GcmKey, Aes128GcmTag}; #[cfg(all(feature = "diag", feature = "aes-gcm"))] diff --git a/src/hex.rs b/src/hex.rs index 97676f72..19a55665 100644 --- a/src/hex.rs +++ b/src/hex.rs @@ -11,6 +11,7 @@ use core::fmt; feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -36,6 +37,7 @@ pub enum InvalidHexError { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -59,6 +61,7 @@ impl fmt::Debug for InvalidHexError { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -79,6 +82,7 @@ impl fmt::Display for InvalidHexError { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -93,6 +97,7 @@ impl core::error::Error for InvalidHexError {} feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -117,6 +122,7 @@ const fn decode_nibble(byte: u8) -> Option { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -170,6 +176,7 @@ pub(crate) fn fmt_hex_lower(bytes: &[u8], f: &mut fmt::Formatter<'_>) -> fmt::Re feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -192,6 +199,7 @@ pub(crate) fn fmt_hex_upper(bytes: &[u8], f: &mut fmt::Formatter<'_>) -> fmt::Re feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ecdsa-p256", @@ -207,6 +215,7 @@ pub struct DisplaySecret<'a>(pub(crate) &'a [u8]); feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ecdsa-p256", @@ -226,6 +235,7 @@ impl fmt::Display for DisplaySecret<'_> { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ecdsa-p256", @@ -252,6 +262,7 @@ impl fmt::Debug for DisplaySecret<'_> { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -298,6 +309,7 @@ macro_rules! impl_hex_fmt { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -340,6 +352,7 @@ macro_rules! impl_hex_fmt_secret { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -397,6 +410,7 @@ macro_rules! impl_serde_bytes_inner { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -418,6 +432,7 @@ macro_rules! impl_serde_bytes { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -438,6 +453,7 @@ macro_rules! impl_serde_bytes { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -496,6 +512,7 @@ macro_rules! impl_serde_secret_bytes { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -514,6 +531,7 @@ macro_rules! impl_serde_secret_bytes { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", @@ -566,6 +584,7 @@ pub(crate) fn diag_zeroize_hex_error() -> bool { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "ascon-aead", feature = "chacha20poly1305", feature = "ed25519", diff --git a/src/lib.rs b/src/lib.rs index 8eda55f1..30cb9b1f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -140,6 +140,7 @@ assert!( feature = "crc64", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "aegis256", feature = "blake3", feature = "xxh3", @@ -162,6 +163,7 @@ assert!( feature = "crc64", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "aegis256", feature = "xxh3", feature = "chacha20poly1305", @@ -207,6 +209,7 @@ assert!( feature = "blake3", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -224,6 +227,7 @@ assert!( feature = "crc32", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "aegis256" ) ), @@ -232,7 +236,13 @@ assert!( #![cfg_attr( all( target_arch = "riscv64", - any(feature = "sha2", feature = "aes-gcm", feature = "aes-gcm-siv", feature = "aegis256") + any( + feature = "sha2", + feature = "aes-gcm", + feature = "aes-gcm-siv", + feature = "aes-siv", + feature = "aegis256" + ) ), feature(riscv_ext_intrinsics) )] @@ -277,6 +287,7 @@ mod macros; #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -294,6 +305,7 @@ mod hex; #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -378,6 +390,8 @@ pub use aead::{Aes128GcmSiv, Aes128GcmSivKey, Aes128GcmSivTag}; pub use aead::{Aes256Gcm, Aes256GcmKey, Aes256GcmTag}; #[cfg(feature = "aes-gcm-siv")] pub use aead::{Aes256GcmSiv, Aes256GcmSivKey, Aes256GcmSivTag}; +#[cfg(feature = "aes-siv")] +pub use aead::{AesSivCmac256, AesSivCmac256Key, AesSivCmac256Nonce, AesSivCmac256NonceError, AesSivCmac256Tag}; #[cfg(feature = "ascon-aead")] pub use aead::{AsconAead128, AsconAead128Key, AsconAead128Tag}; #[cfg(feature = "chacha20poly1305")] @@ -475,6 +489,7 @@ pub use hashes::fast::{Xxh3_128Hasher, Xxh3BuildHasher, Xxh3Hasher}; #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -491,6 +506,7 @@ pub use secret::SecretVec; #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -517,6 +533,7 @@ pub mod expert { #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -548,6 +565,7 @@ pub mod prelude { #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -704,6 +722,15 @@ let mut out = [0u8; 16]; cipher.encrypt(&nonce, b"", b"", &mut out)?; ``` +```compile_fail +use rscrypto::{AesSivCmac256, AesSivCmac256Key, aead::Nonce96}; + +let cipher = AesSivCmac256::new(&AesSivCmac256Key::from_bytes([0u8; 32])); +let nonce = Nonce96::from_bytes([0u8; 12]); +let mut message = [0u8; 16]; +let _ = cipher.seal_in_place(nonce, b"", &mut message); +``` + ```compile_fail let _ = rscrypto::aead::__SealToken(()); ``` @@ -1229,6 +1256,20 @@ use rscrypto::aead::expert::header_protection::ChaCha20HeaderProtection; fn require_clone() {} require_clone::(); ``` + +```compile_fail,E0277 +use rscrypto::AesSivCmac256Key; + +fn require_clone() {} +require_clone::(); +``` + +```compile_fail,E0277 +use rscrypto::AesSivCmac256; + +fn require_clone() {} +require_clone::(); +``` "#] pub struct __SecretCloneBoundaryAudit; diff --git a/src/macros.rs b/src/macros.rs index 96d785f7..0923f1c8 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -426,6 +426,7 @@ macro_rules! define_blake2_dispatch { #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -529,6 +530,7 @@ macro_rules! define_aead_key_type { #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", diff --git a/src/secret.rs b/src/secret.rs index 7156e2a3..ecb5bb99 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -160,6 +160,7 @@ impl Drop for SecretVec { #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -177,6 +178,7 @@ pub(crate) struct ZeroizingBytes([u8; N]); #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -215,6 +217,7 @@ impl ZeroizingBytes { #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", diff --git a/src/traits/ct.rs b/src/traits/ct.rs index cce1cc8c..f375a79c 100644 --- a/src/traits/ct.rs +++ b/src/traits/ct.rs @@ -29,6 +29,7 @@ impl CtDecision { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "argon2", feature = "ascon-aead", feature = "blake3", @@ -116,6 +117,7 @@ impl core::ops::Not for CtDecision { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "argon2", feature = "ascon-aead", feature = "blake3", @@ -161,6 +163,7 @@ fn byte_difference(left: &[u8], right: &[u8]) -> u64 { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", all(feature = "argon2", feature = "phc-strings"), feature = "ascon-aead", feature = "blake3", diff --git a/src/traits/mod.rs b/src/traits/mod.rs index fcd56cd1..017dd6ac 100644 --- a/src/traits/mod.rs +++ b/src/traits/mod.rs @@ -31,6 +31,7 @@ #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", @@ -52,6 +53,7 @@ mod xof; #[cfg(any( feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "xchacha20poly1305", feature = "aegis256", diff --git a/tests/aes_siv_cmac256.rs b/tests/aes_siv_cmac256.rs new file mode 100644 index 00000000..5cc8dde7 --- /dev/null +++ b/tests/aes_siv_cmac256.rs @@ -0,0 +1,162 @@ +#![cfg(feature = "aes-siv")] + +use aes_siv::{KeyInit as _, siv::Aes128Siv}; +use rscrypto::{ + AesSivCmac256, AesSivCmac256Key, AesSivCmac256Nonce, AesSivCmac256NonceError, AesSivCmac256Tag, + aead::{OpenError, SealError}, +}; + +fn generated_bytes(state: &mut u64, out: &mut [u8]) { + for byte in out { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *byte = state.to_le_bytes()[0]; + } +} + +fn oracle(key: &[u8; 32], nonce: &[u8], aad: &[u8], plaintext: &[u8]) -> Vec { + let mut cipher = Aes128Siv::new(key.into()); + cipher + .encrypt([aad, nonce], plaintext) + .expect("generated oracle input stays within the header-count bound") +} + +#[test] +fn nonce_is_borrowed_non_empty_and_publicly_formatted() { + assert_eq!( + AesSivCmac256Nonce::try_from(&[][..]), + Err(AesSivCmac256NonceError::new()) + ); + let bytes = [0x01, 0x23, 0x45]; + let nonce = AesSivCmac256Nonce::try_from(bytes.as_slice()).expect("non-empty nonce"); + assert_eq!(nonce.as_bytes(), bytes); + assert_eq!(format!("{nonce:?}"), "AesSivCmac256Nonce(012345)"); +} + +#[test] +fn public_profile_matches_rustcrypto_across_boundaries_and_generated_inputs() { + let lengths = [0usize, 1, 15, 16, 17, 31, 32, 33, 63, 64, 65, 255, 256, 257, 1232]; + let nonce_lengths = [1usize, 2, 15, 16, 17, 31, 32, 127]; + let mut state = 0x6165_732d_7369_7621u64; + + for case in 0..256usize { + let mut key = [0u8; 32]; + generated_bytes(&mut state, &mut key); + let mut nonce_bytes = vec![0u8; nonce_lengths[case % nonce_lengths.len()]]; + let mut aad = vec![0u8; lengths[(case.strict_mul(5).strict_add(3)) % lengths.len()]]; + let mut plaintext = vec![0u8; lengths[case % lengths.len()]]; + generated_bytes(&mut state, &mut nonce_bytes); + generated_bytes(&mut state, &mut aad); + generated_bytes(&mut state, &mut plaintext); + + let cipher = AesSivCmac256::new(&AesSivCmac256Key::from_bytes(key)); + let nonce = AesSivCmac256Nonce::try_from(nonce_bytes.as_slice()).expect("generated nonce is non-empty"); + let expected = oracle(&key, &nonce_bytes, &aad, &plaintext); + + let mut combined = vec![0u8; plaintext.len().strict_add(AesSivCmac256::TAG_SIZE)]; + cipher + .seal(nonce, &aad, &plaintext, &mut combined) + .expect("output shape is exact"); + assert_eq!(combined, expected); + + let mut opened = vec![0u8; plaintext.len()]; + cipher + .open(nonce, &aad, &combined, &mut opened) + .expect("oracle-matching ciphertext authenticates"); + assert_eq!(opened, plaintext); + + let mut in_place = plaintext.clone(); + let tag = cipher.seal_in_place(nonce, &aad, &mut in_place); + assert_eq!(tag.as_bytes(), &expected[..AesSivCmac256::TAG_SIZE]); + assert_eq!(in_place, expected[AesSivCmac256::TAG_SIZE..]); + cipher + .open_in_place(nonce, &aad, &mut in_place, &tag) + .expect("detached round trip authenticates"); + assert_eq!(in_place, plaintext); + } +} + +#[test] +fn every_tag_corruption_and_representative_input_corruption_clears_plaintext() { + let key = AesSivCmac256Key::from_bytes([0x11; 32]); + let cipher = AesSivCmac256::new(&key); + let nonce_bytes = [0x22; 16]; + let nonce = AesSivCmac256Nonce::try_from(nonce_bytes.as_slice()).expect("non-empty nonce"); + let aad = [0x33; 33]; + let plaintext = [0x44; 65]; + let mut sealed = [0u8; 81]; + cipher.seal(nonce, &aad, &plaintext, &mut sealed).expect("exact output"); + + for index in 0..AesSivCmac256::TAG_SIZE { + let mut corrupted = sealed; + corrupted[index] ^= 1; + let mut out = [0xAA; 65]; + assert_eq!( + cipher.open(nonce, &aad, &corrupted, &mut out), + Err(OpenError::verification()) + ); + assert_eq!(out, [0u8; 65]); + } + + for index in [AesSivCmac256::TAG_SIZE, 48, sealed.len().strict_sub(1)] { + let mut corrupted = sealed; + corrupted[index] ^= 1; + let mut out = [0xAA; 65]; + assert_eq!( + cipher.open(nonce, &aad, &corrupted, &mut out), + Err(OpenError::verification()) + ); + assert_eq!(out, [0u8; 65]); + } + + let mut wrong_aad = aad; + wrong_aad[16] ^= 1; + let mut out = [0xAA; 65]; + assert_eq!( + cipher.open(nonce, &wrong_aad, &sealed, &mut out), + Err(OpenError::verification()) + ); + assert_eq!(out, [0u8; 65]); + + let wrong_nonce_bytes = [0x23; 16]; + let wrong_nonce = AesSivCmac256Nonce::try_from(wrong_nonce_bytes.as_slice()).expect("non-empty nonce"); + out.fill(0xAA); + assert_eq!( + cipher.open(wrong_nonce, &aad, &sealed, &mut out), + Err(OpenError::verification()) + ); + assert_eq!(out, [0u8; 65]); +} + +#[test] +fn structural_errors_do_not_mutate_output() { + let cipher = AesSivCmac256::new(&AesSivCmac256Key::from_bytes([0x11; 32])); + let nonce_bytes = [0x22]; + let nonce = AesSivCmac256Nonce::try_from(nonce_bytes.as_slice()).expect("non-empty nonce"); + let mut out = [0xAA; 8]; + + assert_eq!(cipher.seal(nonce, b"", b"data", &mut out), Err(SealError::buffer())); + assert_eq!(out, [0xAA; 8]); + assert_eq!(cipher.open(nonce, b"", &[0u8; 15], &mut out), Err(OpenError::buffer())); + assert_eq!(out, [0xAA; 8]); + assert_eq!(cipher.open(nonce, b"", &[0u8; 20], &mut out), Err(OpenError::buffer())); + assert_eq!(out, [0xAA; 8]); + assert!(matches!( + AesSivCmac256::tag_from_slice(&[0u8; 15]), + Err(err) if err == rscrypto::aead::AeadBufferError::new() + )); + assert_eq!( + AesSivCmac256::tag_from_slice(&[0u8; 16]).map(AesSivCmac256Tag::to_bytes), + Ok([0u8; 16]) + ); +} + +#[test] +fn key_context_and_tag_debug_follow_secret_boundaries() { + let key = AesSivCmac256Key::from_bytes([0x53; 32]); + assert_eq!(format!("{key:?}"), "AesSivCmac256Key(****)"); + assert_eq!(format!("{:?}", AesSivCmac256::new(&key)), "AesSivCmac256 { .. }"); + let tag = AesSivCmac256Tag::from_bytes([0x53; 16]); + assert_eq!(format!("{tag:?}"), "AesSivCmac256Tag(53535353535353535353535353535353)"); +} diff --git a/tests/fast_hash_allocations.rs b/tests/fast_hash_allocations.rs index b765c097..be8c0380 100644 --- a/tests/fast_hash_allocations.rs +++ b/tests/fast_hash_allocations.rs @@ -1,12 +1,16 @@ -#![cfg(all(feature = "xxh3", feature = "rapidhash"))] +#![cfg(any(all(feature = "xxh3", feature = "rapidhash"), feature = "aes-siv"))] +#[cfg(all(feature = "xxh3", feature = "rapidhash"))] +use core::hash::{BuildHasher, Hasher}; use core::{ alloc::{GlobalAlloc, Layout}, cell::Cell, - hash::{BuildHasher, Hasher}, }; -use std::{alloc::System, collections::HashMap}; +use std::alloc::System; +#[cfg(all(feature = "xxh3", feature = "rapidhash"))] +use std::collections::HashMap; +#[cfg(all(feature = "xxh3", feature = "rapidhash"))] use rscrypto::{RapidSeededState, RapidStreamHasher, Xxh3_128Hasher, Xxh3BuildHasher}; struct CountingAllocator; @@ -89,6 +93,7 @@ fn measure_allocations(f: impl FnOnce()) -> usize { } #[test] +#[cfg(all(feature = "xxh3", feature = "rapidhash"))] fn fast_hashers_and_preallocated_maps_hash_without_allocating() { let xxh3_builder = Xxh3BuildHasher::with_seed(42); let rapid_builder = RapidSeededState::new(42); @@ -198,3 +203,46 @@ fn header_protection_context_construction_does_not_allocate_off_riscv64() { "header-protection context construction must not allocate off RISC-V" ); } + +#[test] +#[cfg(feature = "aes-siv")] +fn aes_siv_in_place_operations_do_not_allocate() { + use rscrypto::{AesSivCmac256, AesSivCmac256Key, AesSivCmac256Nonce}; + + let key = AesSivCmac256Key::from_bytes([0x53; 32]); + let nonce = AesSivCmac256Nonce::try_from(&b"allocation nonce"[..]).expect("nonce is non-empty"); + + #[cfg(not(target_arch = "riscv64"))] + assert_eq!( + measure_allocations(|| { + drop(AesSivCmac256::new(&key)); + }), + 0, + "AES-SIV context construction must not allocate outside the RISC-V boxed schedule backend" + ); + + let cipher = AesSivCmac256::new(&key); + let mut message = [0x42; 1232]; + let mut tag = None; + assert_eq!( + measure_allocations(|| { + tag = Some(cipher.seal_in_place(nonce, b"associated data", &mut message)); + }), + 0, + "AES-SIV in-place sealing must not allocate" + ); + assert_eq!( + measure_allocations(|| { + cipher + .open_in_place( + nonce, + b"associated data", + &mut message, + tag.as_ref().expect("seal produced a tag"), + ) + .expect("fresh ciphertext must authenticate"); + }), + 0, + "AES-SIV in-place opening must not allocate" + ); +} diff --git a/tests/secret_redaction.rs b/tests/secret_redaction.rs index a3ded261..4d4529e7 100644 --- a/tests/secret_redaction.rs +++ b/tests/secret_redaction.rs @@ -17,6 +17,7 @@ fn keyed_state_debug_snapshots_are_redacted() { feature = "aegis256", feature = "aes-gcm", feature = "aes-gcm-siv", + feature = "aes-siv", feature = "chacha20poly1305", feature = "hkdf", feature = "hmac", @@ -65,6 +66,15 @@ fn keyed_state_debug_snapshots_are_redacted() { assert_debug_snapshot(&Aes256GcmSiv::new(&key), "Aes256GcmSiv { .. }"); } + #[cfg(feature = "aes-siv")] + { + use rscrypto::{AesSivCmac256, AesSivCmac256Key}; + + let key = AesSivCmac256Key::from_bytes(KEY_32); + assert_debug_snapshot(&key, "AesSivCmac256Key(****)"); + assert_debug_snapshot(&AesSivCmac256::new(&key), "AesSivCmac256 { .. }"); + } + #[cfg(feature = "chacha20poly1305")] { use rscrypto::aead::expert::header_protection::{ChaCha20HeaderProtection, ChaCha20HeaderProtectionKey}; diff --git a/tests/serde_roundtrip.rs b/tests/serde_roundtrip.rs index ecbe9c7c..f62d18ff 100644 --- a/tests/serde_roundtrip.rs +++ b/tests/serde_roundtrip.rs @@ -102,6 +102,17 @@ mod aes256gcmsiv_serde { serde_roundtrip!(tag, Aes256GcmSivTag, 16); } +#[cfg(feature = "aes-siv")] +mod aes_siv_cmac256_serde { + #[cfg(feature = "serde-secrets")] + use rscrypto::aead::AesSivCmac256Key; + use rscrypto::aead::AesSivCmac256Tag; + + #[cfg(feature = "serde-secrets")] + serde_roundtrip!(key, AesSivCmac256Key, 32); + serde_roundtrip!(tag, AesSivCmac256Tag, 16); +} + #[cfg(feature = "ascon-aead")] mod ascon128_serde { #[cfg(feature = "serde-secrets")] diff --git a/tools/ct-dudect/src/main.rs b/tools/ct-dudect/src/main.rs index 8c5420a5..cd2fbc34 100644 --- a/tools/ct-dudect/src/main.rs +++ b/tools/ct-dudect/src/main.rs @@ -8,31 +8,31 @@ use rscrypto::aead::expert::header_protection::{ }; use rscrypto::{ Aegis256, Aegis256Key, Aes128Gcm, Aes128GcmKey, Aes128GcmSiv, Aes128GcmSivKey, Aes256Gcm, Aes256GcmKey, Aes256GcmSiv, - Aes256GcmSivKey, Argon2Params, Argon2i, AsconAead128, AsconAead128Key, Blake2b256, Blake2b512, Blake2bKey, - Blake2s128, Blake2s256, Blake2sKey, Blake3, Blake3KeyedHash, ChaCha20Poly1305, ChaCha20Poly1305Key, - EcdsaP256SecretKey, EcdsaP384SecretKey, Ed25519Keypair, Ed25519SecretKey, HkdfSha256, HkdfSha384, HmacSha256, - HmacSha256Tag, HmacSha384, HmacSha384Tag, HmacSha512, HmacSha512Tag, Kmac256, MlKem512, MlKem512Ciphertext, - MlKem512DecapsulationKey, MlKem768, MlKem768Ciphertext, MlKem768DecapsulationKey, MlKem1024, MlKem1024Ciphertext, - MlKem1024DecapsulationKey, MlKemError, Pbkdf2Sha256, Pbkdf2Sha512, RsaBlindingPair, RsaEncryptionError, - RsaOaepProfile, RsaPkcs1v15Profile, RsaPrivateKey, RsaPssProfile, RsaPublicKeyPolicy, SecretBytes, Sha512, - X25519SecretKey, XChaCha20Poly1305, XChaCha20Poly1305Key, + Aes256GcmSivKey, AesSivCmac256, AesSivCmac256Key, AesSivCmac256Nonce, AesSivCmac256Tag, Argon2Params, Argon2i, + AsconAead128, AsconAead128Key, Blake2b256, Blake2b512, Blake2bKey, Blake2s128, Blake2s256, Blake2sKey, Blake3, + Blake3KeyedHash, ChaCha20Poly1305, ChaCha20Poly1305Key, EcdsaP256SecretKey, EcdsaP384SecretKey, Ed25519Keypair, + Ed25519SecretKey, HkdfSha256, HkdfSha384, HmacSha256, HmacSha256Tag, HmacSha384, HmacSha384Tag, HmacSha512, + HmacSha512Tag, Kmac256, MlKem512, MlKem512Ciphertext, MlKem512DecapsulationKey, MlKem768, MlKem768Ciphertext, + MlKem768DecapsulationKey, MlKem1024, MlKem1024Ciphertext, MlKem1024DecapsulationKey, MlKemError, Pbkdf2Sha256, + Pbkdf2Sha512, RsaBlindingPair, RsaEncryptionError, RsaOaepProfile, RsaPkcs1v15Profile, RsaPrivateKey, RsaPssProfile, + RsaPublicKeyPolicy, SecretBytes, Sha512, X25519SecretKey, XChaCha20Poly1305, XChaCha20Poly1305Key, aead::{ Nonce96, Nonce128, Nonce192, Nonce256, diag_aes128gcm_ctr32_be, diag_aes128gcm_ghash, diag_aes128gcm_tag_aes, diag_aes128gcmsiv_ctr32, diag_aes128gcmsiv_derive_keys, diag_aes128gcmsiv_polyval_digest, diag_aes128gcmsiv_raw_tag_aes, diag_aes256gcm_ctr32_be, diag_aes256gcm_ghash, diag_aes256gcm_tag_aes, diag_aes256gcmsiv_ctr32, diag_aes256gcmsiv_derive_keys, diag_aes256gcmsiv_raw_tag_aes, + diag_aes_siv_cmac256_open_portable, diag_aes_siv_cmac256_s2v_portable, }, auth::{ diag_ecdsa_p256_basepoint_blinded_limb_digest, diag_ecdsa_p256_final_multiply_limb_digest, diag_ecdsa_p256_nonce_inverse_blinded_limb_digest, diag_ecdsa_p256_nonce_inverse_limb_digest, diag_ecdsa_p256_nonce_reduce_limb_digest, diag_ecdsa_p256_order_mul_blinded_fixed_r_limb_digest, diag_ecdsa_p256_order_mul_fixed_r_limb_digest, diag_ecdsa_p256_reduce_wide_order_limb_digest, - diag_ecdsa_p256_scalar_finish_limb_digest, - diag_ecdsa_p384_basepoint_blinded_limb_digest, diag_ecdsa_p384_final_multiply_limb_digest, - diag_ecdsa_p384_nonce_inverse_blinded_limb_digest, diag_ecdsa_p384_nonce_inverse_limb_digest, - diag_ecdsa_p384_nonce_reduce_limb_digest, diag_ecdsa_p384_order_mul_fixed_r_limb_digest, - diag_ecdsa_p384_reduce_wide_order_limb_digest, diag_ecdsa_p384_scalar_finish_limb_digest, - diag_mlkem_from_montgomery_product_domain_input_digest, + diag_ecdsa_p256_scalar_finish_limb_digest, diag_ecdsa_p384_basepoint_blinded_limb_digest, + diag_ecdsa_p384_final_multiply_limb_digest, diag_ecdsa_p384_nonce_inverse_blinded_limb_digest, + diag_ecdsa_p384_nonce_inverse_limb_digest, diag_ecdsa_p384_nonce_reduce_limb_digest, + diag_ecdsa_p384_order_mul_fixed_r_limb_digest, diag_ecdsa_p384_reduce_wide_order_limb_digest, + diag_ecdsa_p384_scalar_finish_limb_digest, diag_mlkem_from_montgomery_product_domain_input_digest, diag_mlkem_inverse_ntt_montgomery_product_input_digest, diag_mlkem_multiply_ntts_add_assign_input_digest, diag_mlkem_ntt_input_digest, diag_mlkem_to_montgomery_product_domain_input_digest, diag_mlkem512_keygen_secret_noise_digest, diag_mlkem768_keygen_secret_noise_digest, @@ -475,6 +475,141 @@ header_protection_fixed_vs_random_key!( 32, [0x63; 32] ); + +fn aes_siv_cmac256_fixed_vs_random_key_open(runner: &mut CtRunner, rng: &mut BenchRng) { + let nonce_bytes = [0x5B; 16]; + let nonce = AesSivCmac256Nonce::try_from(nonce_bytes.as_slice()).expect("timing nonce is non-empty"); + let mut inputs = Vec::with_capacity(samples()); + for class in balanced_classes(rng, samples()) { + let key_bytes = if matches!(class, Class::Left) { + [0x5C; 32] + } else { + rand_array::<32>(rng) + }; + let key = AesSivCmac256Key::from_bytes(key_bytes); + let cipher = AesSivCmac256::new(&key); + let mut ciphertext = AEAD_PLAINTEXT; + let tag = cipher.seal_in_place(nonce, AAD, &mut ciphertext); + inputs.push((class, key, ciphertext, tag)); + } + + for (class, key, ciphertext, tag) in inputs { + runner.run_one(class, || { + let cipher = AesSivCmac256::new(&key); + let mut buffer = ciphertext; + cipher.open_in_place(nonce, AAD, &mut buffer, &tag).is_ok() + }); + } +} + +fn aes_siv_cmac256_first_vs_last_tag_mismatch(runner: &mut CtRunner, rng: &mut BenchRng) { + let nonce_bytes = [0x5D; 16]; + let nonce = AesSivCmac256Nonce::try_from(nonce_bytes.as_slice()).expect("timing nonce is non-empty"); + let key = AesSivCmac256Key::from_bytes([0x5E; 32]); + let cipher = AesSivCmac256::new(&key); + let mut ciphertext = AEAD_PLAINTEXT; + let valid_tag = cipher.seal_in_place(nonce, AAD, &mut ciphertext).to_bytes(); + let mut inputs = Vec::with_capacity(samples()); + + for class in balanced_classes(rng, samples()) { + let mut tag = valid_tag; + let mismatch = if matches!(class, Class::Left) { + 0 + } else { + AesSivCmac256::TAG_SIZE.strict_sub(1) + }; + tag[mismatch] ^= 1; + inputs.push((class, AesSivCmac256Tag::from_bytes(tag))); + } + + for (class, tag) in inputs { + runner.run_one(class, || { + let mut buffer = ciphertext; + cipher.open_in_place(nonce, AAD, &mut buffer, &tag).is_err() + }); + } +} + +fn aes_siv_cmac256_portable_s2v_fixed_vs_random_key(runner: &mut CtRunner, rng: &mut BenchRng) { + let nonce = [0x61; 16]; + let aad = [0x62; 32]; + let plaintext = [0x63; 48]; + let mut inputs = Vec::with_capacity(samples()); + + for class in balanced_classes(rng, samples()) { + let key = if matches!(class, Class::Left) { + [0x64; 32] + } else { + rand_array::<32>(rng) + }; + inputs.push((class, key)); + } + + for (class, key) in inputs { + runner.run_one(class, || { + diag_aes_siv_cmac256_s2v_portable(&key, &nonce, &aad, &plaintext) + }); + } +} + +fn aes_siv_cmac256_portable_s2v_seal_fixed_vs_random_key(runner: &mut CtRunner, rng: &mut BenchRng) { + aes_siv_cmac256_portable_s2v_fixed_vs_random_key(runner, rng); +} + +fn aes_siv_cmac256_portable_open_fixed_vs_random_key(runner: &mut CtRunner, rng: &mut BenchRng) { + let nonce_bytes = [0x65; 16]; + let nonce = AesSivCmac256Nonce::try_from(nonce_bytes.as_slice()).expect("timing nonce is non-empty"); + let aad = [0x66; 32]; + let plaintext = [0x67; 48]; + let mut inputs = Vec::with_capacity(samples()); + + for class in balanced_classes(rng, samples()) { + let key_bytes = if matches!(class, Class::Left) { + [0x68; 32] + } else { + rand_array::<32>(rng) + }; + let key = AesSivCmac256Key::from_bytes(key_bytes); + let cipher = AesSivCmac256::new(&key); + let mut ciphertext = plaintext; + let tag = cipher.seal_in_place(nonce, &aad, &mut ciphertext).to_bytes(); + inputs.push((class, key_bytes, ciphertext, tag)); + } + + for (class, key, ciphertext, tag) in inputs { + runner.run_one(class, || { + diag_aes_siv_cmac256_open_portable(&key, &nonce_bytes, &aad, &ciphertext, &tag) + }); + } +} + +fn aes_siv_cmac256_portable_open_first_vs_last_tag_mismatch(runner: &mut CtRunner, rng: &mut BenchRng) { + let key = [0x69; 32]; + let nonce_bytes = [0x6A; 16]; + let nonce = AesSivCmac256Nonce::try_from(nonce_bytes.as_slice()).expect("timing nonce is non-empty"); + let aad = [0x6B; 32]; + let mut ciphertext = [0x6C; 48]; + let cipher = AesSivCmac256::new(&AesSivCmac256Key::from_bytes(key)); + let valid_tag = cipher.seal_in_place(nonce, &aad, &mut ciphertext).to_bytes(); + let mut inputs = Vec::with_capacity(samples()); + + for class in balanced_classes(rng, samples()) { + let mut tag = valid_tag; + let mismatch = if matches!(class, Class::Left) { + 0 + } else { + AesSivCmac256::TAG_SIZE.strict_sub(1) + }; + tag[mismatch] ^= 1; + inputs.push((class, tag)); + } + + for (class, tag) in inputs { + runner.run_one(class, || { + diag_aes_siv_cmac256_open_portable(&key, &nonce_bytes, &aad, &ciphertext, &tag) + }); + } +} aead_fixed_vs_random_key_open!( aes256gcm_fixed_vs_random_key_open, Aes256Gcm, @@ -2096,6 +2231,28 @@ aead_fixed_vs_random_key_seal!( 16, 0x33 ); + +fn aes_siv_cmac256_fixed_vs_random_key_seal(runner: &mut CtRunner, rng: &mut BenchRng) { + let nonce_bytes = [0x5F; 16]; + let nonce = AesSivCmac256Nonce::try_from(nonce_bytes.as_slice()).expect("timing nonce is non-empty"); + let mut inputs = Vec::with_capacity(samples()); + for class in balanced_classes(rng, samples()) { + let key_bytes = if matches!(class, Class::Left) { + [0x60; 32] + } else { + rand_array::<32>(rng) + }; + inputs.push((class, AesSivCmac256Key::from_bytes(key_bytes))); + } + + for (class, key) in inputs { + runner.run_one(class, || { + let cipher = AesSivCmac256::new(&key); + let mut buffer = AEAD_PLAINTEXT; + cipher.seal_in_place(nonce, AAD, &mut buffer).as_bytes()[0] + }); + } +} aead_fixed_vs_random_key_seal!( aes256gcm_fixed_vs_random_key_seal, Aes256Gcm, @@ -2299,6 +2456,20 @@ ctbench_main_with_seeds!( chacha20_header_protection_fixed_vs_random_key, Some(0x687063686132305f) ), + (aes_siv_cmac256_fixed_vs_random_key_open, Some(0x7369765f6f70656e)), + (aes_siv_cmac256_first_vs_last_tag_mismatch, Some(0x7369765f7461676d)), + ( + aes_siv_cmac256_portable_s2v_fixed_vs_random_key, + Some(0x7369765f73327670) + ), + ( + aes_siv_cmac256_portable_open_fixed_vs_random_key, + Some(0x7369765f706f706b) + ), + ( + aes_siv_cmac256_portable_open_first_vs_last_tag_mismatch, + Some(0x7369765f706f746d) + ), (x25519_fixed_vs_random_scalar, Some(0x7832353531395f63)), (mlkem512_keygen_secret_noise_fixed_vs_random, Some(0x6d6b3531326b676e)), (mlkem512_encapsulate_fixed_vs_random_coins, Some(0x6d6b353132656e63)), @@ -2521,6 +2692,11 @@ ctbench_main_with_seeds!( (xchacha20poly1305_fixed_vs_random_key_seal, Some(0x7863686132307365)), (aegis256_fixed_vs_random_key_seal, Some(0x6165676973736561)), (ascon_aead128_fixed_vs_random_key_seal, Some(0x6173636f6e736561)), + (aes_siv_cmac256_fixed_vs_random_key_seal, Some(0x7369765f7365616c)), + ( + aes_siv_cmac256_portable_s2v_seal_fixed_vs_random_key, + Some(0x7369765f73327673) + ), (blake2b256_keyed_fixed_vs_random_key, Some(0x6232623235365f6b)), (blake2b512_keyed_fixed_vs_random_key, Some(0x6232623531325f6b)), (blake2s128_keyed_fixed_vs_random_key, Some(0x6232733132385f6b)), diff --git a/tools/ct-harness/src/lib.rs b/tools/ct-harness/src/lib.rs index 14acccae..da2c4f10 100644 --- a/tools/ct-harness/src/lib.rs +++ b/tools/ct-harness/src/lib.rs @@ -12,7 +12,8 @@ use std::format; use rscrypto::{ Aegis256, Aegis256Key, Aes128Gcm, Aes128GcmKey, Aes128GcmSiv, Aes128GcmSivKey, Aes256Gcm, Aes256GcmKey, Aes256GcmSiv, - Aes256GcmSivKey, Argon2Params, Argon2d, Argon2i, Argon2id, AsconAead128, AsconAead128Key, Blake2b256, Blake2b512, + Aes256GcmSivKey, AesSivCmac256, AesSivCmac256Key, AesSivCmac256Nonce, Argon2Params, Argon2d, Argon2i, Argon2id, + AsconAead128, AsconAead128Key, Blake2b256, Blake2b512, Blake2bKey, Blake2s128, Blake2s256, Blake2sKey, Blake3, Blake3KeyedHash, ChaCha20Poly1305, ChaCha20Poly1305Key, Crc32, EcdsaP256SecretKey, EcdsaP384SecretKey, Ed25519PublicKey, Ed25519SecretKey, Ed25519Signature, HkdfSha256, HkdfSha384, HmacSha3_224Tag, HmacSha256, HmacSha256Tag, HmacSha384, HmacSha384Tag, HmacSha512, HmacSha512Tag, @@ -21,7 +22,7 @@ use rscrypto::{ MlKem1024DecapsulationKey, MlKem1024EncapsulationKey, MlKemError, Pbkdf2Sha256, Pbkdf2Sha512, RsaBlindingPair, RsaOaepProfile, RsaPkcs1v15Profile, RsaPrivateKey, RsaPssProfile, RsaPublicKeyPolicy, Scrypt, ScryptParams, SecretBytes, Sha256, X25519PublicKey, X25519SecretKey, XChaCha20Poly1305, XChaCha20Poly1305Key, - aead::{Nonce96, Nonce128, Nonce192, Nonce256}, + aead::{Nonce96, Nonce128, Nonce192, Nonce256, diag_aes_siv_cmac256_open_portable}, checksum::Checksum, traits::Kem as _, }; @@ -1305,6 +1306,99 @@ aead_open_entry!( 16 ); +/// AES-SIV-CMAC-256 open/authentication harness for the variable-length nonce profile. +/// +/// # Safety +/// +/// - `key` and `tag` must reference 32 and 16 initialized readable bytes respectively. +/// - `nonce` must reference `nonce_len` initialized readable bytes and must be nonempty. +/// - `aad` and `buffer` follow [`input_slice`] and [`output_slice`] bounds respectively. +/// - The mutable buffer must not overlap the nonce, AAD, or tag ranges. It may overlap the key +/// because the key is copied before the mutable borrow begins. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ct_entry_aes_siv_cmac256_open( + key: *const u8, + nonce: *const u8, + nonce_len: usize, + aad: *const u8, + aad_len: usize, + buffer: *mut u8, + buffer_len: usize, + tag: *const u8, +) -> u8 { + // SAFETY: The function contract requires exactly 32 initialized readable key bytes. + let Some(key) = (unsafe { read_array::<32>(key) }) else { + return STATUS_ERR; + }; + // SAFETY: The function contract establishes nonce allocation, bounds, and immutability. + let Some(nonce) = (unsafe { input_slice(nonce, nonce_len) }) else { + return STATUS_ERR; + }; + let Ok(nonce) = AesSivCmac256Nonce::try_from(nonce) else { + return STATUS_ERR; + }; + // SAFETY: The function contract establishes AAD allocation, bounds, and immutability. + let Some(aad) = (unsafe { input_slice(aad, aad_len) }) else { + return STATUS_ERR; + }; + // SAFETY: The function contract establishes buffer allocation, bounds, exclusivity, and + // disjointness from the live nonce, AAD, and tag ranges. + let Some(buffer) = (unsafe { output_slice(buffer, buffer_len) }) else { + return STATUS_ERR; + }; + // SAFETY: The function contract requires exactly 16 initialized readable tag bytes disjoint + // from the mutable buffer. + let Some(tag) = (unsafe { read_array::<16>(tag) }) else { + return STATUS_ERR; + }; + + let key = AesSivCmac256Key::from_bytes(key); + let cipher = AesSivCmac256::new(&key); + let tag = AesSivCmac256::tag_from_slice(&tag).expect("fixed-size harness tag is valid"); + cipher + .open_in_place(nonce, aad, buffer, &tag) + .map(|()| STATUS_OK) + .unwrap_or(STATUS_ERR) +} + +/// Fixed-shape forced-portable AES-SIV-CMAC-256 complete-open harness. +/// +/// # Safety +/// +/// `key`, `nonce`, `aad`, `ciphertext`, and `tag` must reference 32, 16, 32, 48, and 16 +/// initialized readable bytes respectively. Null pointers are rejected. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn ct_entry_aes_siv_cmac256_open_portable( + key: *const u8, + nonce: *const u8, + aad: *const u8, + ciphertext: *const u8, + tag: *const u8, +) -> u8 { + // SAFETY: Each read has the exact fixed-size validity requirement documented above. + let Some(key) = (unsafe { read_array::<32>(key) }) else { + return STATUS_ERR; + }; + // SAFETY: Each read has the exact fixed-size validity requirement documented above. + let Some(nonce) = (unsafe { read_array::<16>(nonce) }) else { + return STATUS_ERR; + }; + // SAFETY: Each read has the exact fixed-size validity requirement documented above. + let Some(aad) = (unsafe { read_array::<32>(aad) }) else { + return STATUS_ERR; + }; + // SAFETY: Each read has the exact fixed-size validity requirement documented above. + let Some(ciphertext) = (unsafe { read_array::<48>(ciphertext) }) else { + return STATUS_ERR; + }; + // SAFETY: Each read has the exact fixed-size validity requirement documented above. + let Some(tag) = (unsafe { read_array::<16>(tag) }) else { + return STATUS_ERR; + }; + + diag_aes_siv_cmac256_open_portable(&key, &nonce, &aad, &ciphertext, &tag) +} + macro_rules! hkdf_derive_entry { ($name:ident, $ty:ty) => { #[doc = "Derive HKDF output through the constant-time C ABI harness."] diff --git a/tools/wasm-runtime-vectors/Cargo.toml b/tools/wasm-runtime-vectors/Cargo.toml index ac30dba3..c612fe8c 100644 --- a/tools/wasm-runtime-vectors/Cargo.toml +++ b/tools/wasm-runtime-vectors/Cargo.toml @@ -8,4 +8,4 @@ publish = false [workspace] [dependencies] -rscrypto = { path = "../..", default-features = false, features = ["alloc", "hashes", "aes-gcm", "chacha20poly1305", "rsa", "websocket-sha1"] } +rscrypto = { path = "../..", default-features = false, features = ["alloc", "hashes", "aes-gcm", "aes-siv", "chacha20poly1305", "rsa", "websocket-sha1"] } diff --git a/tools/wasm-runtime-vectors/src/main.rs b/tools/wasm-runtime-vectors/src/main.rs index 6d16ada0..a261bcb5 100644 --- a/tools/wasm-runtime-vectors/src/main.rs +++ b/tools/wasm-runtime-vectors/src/main.rs @@ -1,5 +1,6 @@ use rscrypto::{ - Blake2b512, Blake3, Digest, RsaPrivateKey, RsaPrivateOpError, RsaPssProfile, RsaPublicKeyPolicy, Sha256, Sha512, + AesSivCmac256, AesSivCmac256Key, AesSivCmac256Nonce, Blake2b512, Blake3, Digest, RsaPrivateKey, + RsaPrivateOpError, RsaPssProfile, RsaPublicKeyPolicy, Sha256, Sha512, }; use rscrypto::aead::expert::header_protection::{ Aes128HeaderProtection, Aes128HeaderProtectionKey, Aes256HeaderProtection, Aes256HeaderProtectionKey, @@ -226,6 +227,39 @@ fn assert_header_protection_vectors_match_known_outputs() { ); } +fn assert_aes_siv_runtime_vector_and_failed_open_cleanup() { + let mut key_bytes = [0u8; 32]; + for (index, byte) in key_bytes.iter_mut().enumerate() { + *byte = u8::try_from(index).expect("key index fits in one byte"); + } + let cipher = AesSivCmac256::new(&AesSivCmac256Key::from_bytes(key_bytes)); + let nonce = AesSivCmac256Nonce::try_from(&b"wasm nonce"[..]).expect("nonce is non-empty"); + let plaintext = b"wasm AES-SIV runtime vector input"; + let mut combined = [0u8; 49]; + cipher + .seal(nonce, b"wasm associated data", plaintext, &mut combined) + .expect("fixed output shape is exact"); + assert_hex( + &combined, + "c83dab6674aac8b6ba89d5d4e714eb988d9352d177d26e424465796ce9d4199aba1694731dbeab6c045dfebac553d266af", + ); + + let mut opened = [0u8; 33]; + cipher + .open(nonce, b"wasm associated data", &combined, &mut opened) + .expect("known ciphertext authenticates"); + assert_eq!(&opened, plaintext); + + combined[15] ^= 1; + opened.fill(0xA5); + assert!( + cipher + .open(nonce, b"wasm associated data", &combined, &mut opened) + .is_err() + ); + assert_eq!(opened, [0u8; 33]); +} + #[cfg(target_feature = "simd128")] fn assert_simd128_runtime_caps_are_detected() { assert!(rscrypto::platform::caps().has(rscrypto::platform::caps::wasm::SIMD128)); @@ -240,5 +274,6 @@ fn main() { assert_rsa_caller_random_signing_roundtrips(); assert_websocket_accept_digest_matches_rfc_6455(); assert_header_protection_vectors_match_known_outputs(); + assert_aes_siv_runtime_vector_and_failed_open_cleanup(); assert_simd128_runtime_caps_are_detected(); }