diff --git a/.changes/add-header-protection-masks-a3f4.md b/.changes/add-header-protection-masks-a3f4.md new file mode 100644 index 00000000..79eee606 --- /dev/null +++ b/.changes/add-header-protection-masks-a3f4.md @@ -0,0 +1,5 @@ +--- +"rscrypto" = "minor" +--- + +Add narrow AES-128, AES-256, and ChaCha20 header-protection generators with allocation-free mask operations and distinct non-exportable key types. diff --git a/.config/benchmark-matrix.json b/.config/benchmark-matrix.json index 2253333f..b38a33a1 100644 --- a/.config/benchmark-matrix.json +++ b/.config/benchmark-matrix.json @@ -214,6 +214,7 @@ "aes-128-gcm": { "crate": "aead", "bench": "aead", "filter": "^aes-128-gcm/" }, "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" }, "aead-diag": { "crate": "aead", "bench": "aead_diag", "filter": "chacha20-poly1305/encrypt" } }, "selectors": { @@ -272,7 +273,8 @@ "aes-256-gcm", "aes-128-gcm", "aegis-256", - "ascon-aead128" + "ascon-aead128", + "header-protection" ], "auth": [ "hmac-sha256", @@ -305,7 +307,8 @@ "aes-256-gcm", "aes-128-gcm", "aegis-256", - "ascon-aead128" + "ascon-aead128", + "header-protection" ], "aeaddiag": ["aead-diag"], "chacha20poly1305diag": ["aead-diag"], diff --git a/Cargo.lock b/Cargo.lock index a20ea065..73c595cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1984,6 +1984,7 @@ name = "rscrypto" version = "0.8.1" dependencies = [ "aegis", + "aes", "aes-gcm", "aes-gcm-siv", "argon2", @@ -1993,6 +1994,7 @@ dependencies = [ "aws-lc-sys", "blake2", "blake3", + "chacha20", "chacha20poly1305", "crc", "crc-fast", diff --git a/Cargo.toml b/Cargo.toml index 165e10f9..51d6b455 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -227,6 +227,7 @@ gungraun = "=0.19.4" proptest = "^1.11.0" # Oracles +aes = "0.9.2" cshake = { version = "0.2.1", default-features = false } crc = "3.4.0" crc-fast = { version = "1.10.0", default-features = false, features = ["std"] } @@ -263,6 +264,7 @@ rustcrypto-ml-kem = { package = "ml-kem", version = "0.3.2", default-features = hmac = "0.13.0" hkdf = "0.13.0" chacha20poly1305 = "0.11.0" +chacha20 = "0.10.1" aes-gcm = "0.11.0" aes-gcm-siv = "0.12.0" aegis = "0.9.15" diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 9f2e355f..d4954976 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -23,7 +23,8 @@ Review the `ct_intended` candidate core before the rest of the repository: 5. ML-KEM secret-noise key generation, encapsulation coins, decapsulation secret-key material, and implicit rejection. 6. AEAD authentication and failed-open cleanup. -7. MAC/tag verification, fixed-size owner comparison/declassification, and selected +7. Header-protection mask generation. +8. MAC/tag verification, fixed-size owner comparison/declassification, and selected password-verification comparisons. This order prioritizes secret-dependent computation; it does not remove public @@ -72,7 +73,7 @@ claims remain limited to the release-evidenced configurations. ## Assets 1. Long-term secrets: private keys, passwords, master keys. -2. Session secrets: X25519 and ML-KEM shared secrets, AEAD keys, signing +2. Session secrets: X25519 and ML-KEM shared secrets, AEAD and header-protection keys, signing nonces, blinding factors. 3. Intermediate secret state: key schedules, scalars, limbs, DRBG state, sampler buffers. @@ -115,7 +116,7 @@ Ordered by exposure to untrusted input: | ----------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | | Parsers | RSA DER/SPKI/PKCS#8 import, ECDSA DER signatures and SEC1 points, ML-KEM key and ciphertext parsing, PHC strings, hex | Memory safety, panics, accepting what should be rejected | | Verification oracles | MAC `verify_tag`, AEAD open, signature `verify`, ML-KEM implicit rejection | Timing or error detail beyond the single failure bit | -| Secret-bearing compute | Sign, decrypt, decapsulate, derive; the release-evidenced subset of `ct.toml` | Timing leakage, incorrect arithmetic | +| Secret-bearing compute | Sign, decrypt, decapsulate, derive, generate header masks; the release-evidenced subset of `ct.toml` | Timing leakage, incorrect arithmetic | | `unsafe` low-level code | SIMD/assembly kernels, raw buffer helpers, zeroization, and dispatch | Undefined behavior, divergence from the portable authority | | Dispatch | `src/platform`, `src/backend` | Selecting a kernel the CPU cannot run, or one that produces wrong output | | Compatibility operations | `hashes::legacy::WebSocketAcceptDigest::compute` | Capability expansion or treating broken SHA-1 collision resistance as authentication | diff --git a/benches/aead.rs b/benches/aead.rs index 743c9dc0..8640cf56 100644 --- a/benches/aead.rs +++ b/benches/aead.rs @@ -1374,6 +1374,99 @@ fn ascon_aead128_decrypt(c: &mut Criterion) { g.finish(); } +// Fixed-size header protection + +fn header_protection(c: &mut Criterion) { + use aes::cipher::{Array, BlockCipherEncrypt as _, KeyInit as _}; + use chacha20::cipher::{KeyIvInit as _, StreamCipherCore as _}; + use rscrypto::aead::expert::header_protection::{ + Aes128HeaderProtection, Aes128HeaderProtectionKey, Aes256HeaderProtection, Aes256HeaderProtectionKey, + ChaCha20HeaderProtection, ChaCha20HeaderProtectionKey, + }; + + let mut construction = c.benchmark_group("header-protection/construct"); + construction.bench_function("rscrypto/aes128", |b| { + b.iter(|| { + let key = Aes128HeaderProtectionKey::from_bytes(black_box(KEY_16)); + black_box(Aes128HeaderProtection::new(&key)) + }) + }); + construction.bench_function("rustcrypto/aes128", |b| { + b.iter(|| aes::Aes128::new(black_box(&Array::from(KEY_16)))) + }); + construction.bench_function("rscrypto/aes256", |b| { + b.iter(|| { + let key = Aes256HeaderProtectionKey::from_bytes(black_box(KEY_32)); + black_box(Aes256HeaderProtection::new(&key)) + }) + }); + construction.bench_function("rustcrypto/aes256", |b| { + b.iter(|| aes::Aes256::new(black_box(&Array::from(KEY_32)))) + }); + construction.bench_function("rscrypto/chacha20", |b| { + b.iter(|| { + let key = ChaCha20HeaderProtectionKey::from_bytes(black_box(KEY_32)); + black_box(ChaCha20HeaderProtection::new(&key)) + }) + }); + construction.bench_function("rustcrypto/chacha20", |b| { + b.iter(|| { + chacha20::ChaChaCore::::new( + black_box(&KEY_32).into(), + black_box(&NONCE_12).into(), + ) + }) + }); + construction.finish(); + + let sample = [0x07; 16]; + let aes128_rs = Aes128HeaderProtection::new(&Aes128HeaderProtectionKey::from_bytes(KEY_16)); + let aes256_rs = Aes256HeaderProtection::new(&Aes256HeaderProtectionKey::from_bytes(KEY_32)); + let chacha_rs = ChaCha20HeaderProtection::new(&ChaCha20HeaderProtectionKey::from_bytes(KEY_32)); + let aes128_rc = aes::Aes128::new(&Array::from(KEY_16)); + let aes256_rc = aes::Aes256::new(&Array::from(KEY_32)); + + let mut mask = c.benchmark_group("header-protection/mask"); + mask.bench_function("rscrypto/aes128", |b| { + b.iter(|| black_box(aes128_rs.mask(black_box(&sample)))) + }); + mask.bench_function("rustcrypto/aes128", |b| { + b.iter(|| { + let mut block = Array::from(*black_box(&sample)); + aes128_rc.encrypt_block(&mut block); + black_box(<[u8; 5]>::try_from(&block[..5]).expect("five-byte prefix has fixed length")) + }) + }); + mask.bench_function("rscrypto/aes256", |b| { + b.iter(|| black_box(aes256_rs.mask(black_box(&sample)))) + }); + mask.bench_function("rustcrypto/aes256", |b| { + b.iter(|| { + let mut block = Array::from(*black_box(&sample)); + aes256_rc.encrypt_block(&mut block); + black_box(<[u8; 5]>::try_from(&block[..5]).expect("five-byte prefix has fixed length")) + }) + }); + mask.bench_function("rscrypto/chacha20", |b| { + b.iter(|| black_box(chacha_rs.mask(black_box(&sample)))) + }); + mask.bench_function("rustcrypto/chacha20", |b| { + b.iter(|| { + let counter = u32::from_le_bytes(sample[..4].try_into().expect("counter prefix has fixed length")); + let nonce: [u8; 12] = sample[4..].try_into().expect("nonce suffix has fixed length"); + let mut core = chacha20::ChaChaCore::::new( + black_box(&KEY_32).into(), + black_box(&nonce).into(), + ); + core.set_block_pos(counter); + let mut block = chacha20::cipher::array::Array::::default(); + core.write_keystream_block(&mut block); + black_box(<[u8; 5]>::try_from(&block[..5]).expect("five-byte prefix has fixed length")) + }) + }); + mask.finish(); +} + // Criterion harness criterion_group!( @@ -1394,5 +1487,6 @@ criterion_group!( aegis256_decrypt, ascon_aead128_encrypt, ascon_aead128_decrypt, + header_protection, ); criterion_main!(benches); diff --git a/ct.toml b/ct.toml index db187a16..905091ce 100644 --- a/ct.toml +++ b/ct.toml @@ -726,6 +726,33 @@ right_class = "random scalar" samples = 20000 smoke_samples = 2000 +[[dudect_case]] +name = "aes128_header_protection_fixed_vs_random_key" +primitive = "aead.symmetric_transform" +filter = "aes128_header_protection_fixed_vs_random_key" +left_class = "complete AES-128 header-protection operation with fixed secret key" +right_class = "complete AES-128 header-protection operation with random secret key" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "aes256_header_protection_fixed_vs_random_key" +primitive = "aead.symmetric_transform" +filter = "aes256_header_protection_fixed_vs_random_key" +left_class = "complete AES-256 header-protection operation with fixed secret key" +right_class = "complete AES-256 header-protection operation with random secret key" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "chacha20_header_protection_fixed_vs_random_key" +primitive = "aead.symmetric_transform" +filter = "chacha20_header_protection_fixed_vs_random_key" +left_class = "complete ChaCha20 header-protection operation with fixed secret key" +right_class = "complete ChaCha20 header-protection operation with random secret key" +samples = 20000 +smoke_samples = 2000 + [[dudect_case]] name = "mlkem512_keygen_secret_noise_fixed_vs_random" primitive = "kem.mlkem512" @@ -1855,6 +1882,27 @@ variant = "AsconAead128" dudect = ["ascon_aead128_fixed_vs_random_key_seal"] binsec = ["aead.symmetric_transform.ascon_aead128_tag_portable.all"] +[[evidence_unit]] +id = "aead.symmetric_transform.aes128_header_protection" +primitive = "aead.symmetric_transform" +variant = "Aes128HeaderProtection" +dudect = ["aes128_header_protection_fixed_vs_random_key"] +binsec = ["aead.symmetric_transform.aes_round_portable.all"] + +[[evidence_unit]] +id = "aead.symmetric_transform.aes256_header_protection" +primitive = "aead.symmetric_transform" +variant = "Aes256HeaderProtection" +dudect = ["aes256_header_protection_fixed_vs_random_key"] +binsec = ["aead.symmetric_transform.aes_round_portable.all"] + +[[evidence_unit]] +id = "aead.symmetric_transform.chacha20_header_protection" +primitive = "aead.symmetric_transform" +variant = "ChaCha20HeaderProtection" +dudect = ["chacha20_header_protection_fixed_vs_random_key"] +binsec = ["aead.symmetric_transform.chacha20poly1305_seal.portable.all"] + [[evidence_unit]] id = "rsa.private_ops.pkcs1v15_sign" primitive = "rsa.private_ops" @@ -2787,8 +2835,8 @@ name = "x86_64-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2246 -compiler_api_sha256 = "c3584d2a5516984b8acd3189c2d73f2aa4f7b33af63e2aec4cb31a0bb3b58a4d" +compiler_api_item_count = 2268 +compiler_api_sha256 = "6e3f13819536b857ab38b6b77280db1ca7da49ca9ddc91bf964e4656f0ecea19" claim = "ct-intended" physical_timing = "required" binsec = "required" @@ -2799,8 +2847,8 @@ name = "aarch64-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2240 -compiler_api_sha256 = "c573e79dace25ef23c10cedca144416acefa9e360c48ffa8bea14636cc3dd9c3" +compiler_api_item_count = 2262 +compiler_api_sha256 = "8e85d195e87964207eaf4a142b157eef0d8f8aea85b838ee6c172279b1fe23df" claim = "ct-intended" physical_timing = "required" binsec = "required" @@ -2859,8 +2907,8 @@ name = "aarch64-apple-darwin" group = "macos" backend = "llvm" linker = "apple-ld-unpinned" -compiler_api_item_count = 2240 -compiler_api_sha256 = "c573e79dace25ef23c10cedca144416acefa9e360c48ffa8bea14636cc3dd9c3" +compiler_api_item_count = 2262 +compiler_api_sha256 = "8e85d195e87964207eaf4a142b157eef0d8f8aea85b838ee6c172279b1fe23df" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2884,8 +2932,8 @@ name = "s390x-unknown-linux-gnu" group = "ibm" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2239 -compiler_api_sha256 = "44733fbae78fed04e9534deec1c367c411bc9f6004eceb52f5aaa6ef1ac41502" +compiler_api_item_count = 2261 +compiler_api_sha256 = "daa3642fedbb5532ec987e192ef9f3f8e522237df89779dca933ea17920a3911" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2897,8 +2945,8 @@ name = "powerpc64le-unknown-linux-gnu" group = "ibm" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2239 -compiler_api_sha256 = "b1ff2caddb915001a54731b9a0f53328c5541e9213c83da8e01b9a680eb53913" +compiler_api_item_count = 2261 +compiler_api_sha256 = "9ea6ddaa217b1181d383fc4dd51690439005044199ae5af472d55a54d7775ea9" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2910,8 +2958,8 @@ name = "riscv64gc-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2239 -compiler_api_sha256 = "461621913477bbb623f12d4b07af646cd49e597027c24133294aeec9251aa34b" +compiler_api_item_count = 2261 +compiler_api_sha256 = "cc0faa52fd40a16280c020505643c008b1bfbc0e6f7c3b9d05e333bbb7b87eb2" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -3099,6 +3147,26 @@ evidence = [ ] limitation = "Release claims require exact target artifacts. Random-nonce helper timing includes the platform entropy source." +[[operation]] +id = "aead.header_protection" +api = [ + "rscrypto::aead::expert::header_protection::{Aes128HeaderProtection,Aes256HeaderProtection,ChaCha20HeaderProtection}::{new,mask}", +] +features = ["aes-gcm for AES variants", "chacha20poly1305 for the ChaCha20 variant"] +targets = ["all-supported", "AES backend selected by public target capabilities"] +secret_inputs = ["header-protection key", "expanded AES schedule or retained ChaCha20 key", "full cipher output block"] +public_inputs = ["algorithm variant", "fixed-size ciphertext sample", "backend capabilities"] +variable_time_components = ["public backend dispatch"] +permitted_leakage = ["algorithm", "sample", "backend", "returned five-byte mask"] +claim = "ct-intended" +evidence = [ + "primitive:aead.symmetric_transform", + "unit:aead.symmetric_transform.aes128_header_protection", + "unit:aead.symmetric_transform.aes256_header_protection", + "unit:aead.symmetric_transform.chacha20_header_protection", +] +limitation = "DudeCT covers complete key construction and mask generation. AES reuses the existing portable-round BINSEC leaf; ChaCha20 reuses the portable seal leaf. Exact release claims remain bound to target artifacts." + [[operation]] id = "aead.open_and_authenticate" api = [ diff --git a/docs/constant-time.md b/docs/constant-time.md index c0f62412..abb83caa 100644 --- a/docs/constant-time.md +++ b/docs/constant-time.md @@ -48,6 +48,7 @@ evidence gate. This is intent, not a standalone public claim: - MAC/tag verification and fixed-size equality owned by concrete key, secret, 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. - 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 334f71ac..4dd6c7b1 100644 --- a/docs/features.md +++ b/docs/features.md @@ -97,9 +97,9 @@ rscrypto = { version = "0.8.1", features = ["full", "portable-only"] } | `rsa` | `alloc`, `sha2` | RSA public/private keys, RSA signatures, OAEP, PKCS#1 v1.5, key generation | | `x25519` | -- | X25519 key exchange | | `ml-kem` | `sha3` | ML-KEM-512, ML-KEM-768, and ML-KEM-1024 key encapsulation | -| `aes-gcm` | -- | AES-128-GCM and AES-256-GCM | +| `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 | -| `chacha20poly1305` | -- | ChaCha20-Poly1305 | +| `chacha20poly1305` | -- | ChaCha20-Poly1305 and expert ChaCha20 header-protection mask generation | | `xchacha20poly1305` | -- | XChaCha20-Poly1305 | | `aegis256` | -- | AEGIS-256 | | `ascon-aead` | -- | Ascon-AEAD128 | diff --git a/docs/secret-lifecycle.md b/docs/secret-lifecycle.md index 63f6496c..52be32a8 100644 --- a/docs/secret-lifecycle.md +++ b/docs/secret-lifecycle.md @@ -26,6 +26,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 | | 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 | @@ -80,6 +81,7 @@ The gate maps evidence to production behavior as follows: | `diag_zeroize_blake3_thread_scratch`, `diag_zeroize_blake3_parallel_scratch` | Thread-local and per-state heap CV wipe before reuse or deallocation | | `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_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 | @@ -91,7 +93,7 @@ architectures retain the source audit only. ## Formatting and error audit [`tests/secret_redaction.rs`](../tests/secret_redaction.rs) holds exact `Debug` -and error snapshots for generic secret wrappers, AEAD keys and contexts, +and error snapshots for generic secret wrappers, AEAD and header-protection keys and contexts, HMAC/HKDF/KMAC/PBKDF2 state, keyed BLAKE2/BLAKE3 state and XOF readers, ECDSA/Ed25519 keypairs, X25519 and ML-KEM shared secrets, prepared ML-KEM decapsulation state, Argon2 context, and representative secret-input errors. diff --git a/docs/secret-ownership.md b/docs/secret-ownership.md index 702c97e9..6759871a 100644 --- a/docs/secret-ownership.md +++ b/docs/secret-ownership.md @@ -36,6 +36,8 @@ capability for permanent retention. | `expert::DisplaySecret<'a>` | Neither | Intentionally prints bytes through both `Display` and `Debug` | Hex formatting only | Borrowed | Explicit opt-in escape hatch for integrations that must render a key; never use it in logs | | AEAD `*Key` types | Explicit duplicate; no `Clone` or `Copy` | Masked | `SecretBytes` export, hex opt-in, and `serde-secrets` | Inline | Lets an owned cipher context coexist with a caller-retained typed key while making the extra lifetime visible | | AEAD cipher contexts | Neither | Masked | None | Inline, except boxed RISC-V fixslice AES schedules | Reusable expanded key and authentication subkey state without exposing a generic duplication path | +| Header-protection keys | Neither | Masked | None | Inline | One-way construction boundary for a distinct protocol-derived key; no generic duplication, formatting, serialization, or export surface | +| Header-protection contexts | Neither | Masked | None | Inline, except boxed RISC-V fixslice AES schedules | Reusable fixed-size mask generation without exposing a block-cipher or stream-cipher surface | | ECDSA P-256/P-384 secret keys and keypairs | Explicit duplicate; no `Clone` or `Copy` | Secret keys are masked; keypairs show only the public half | `SecretBytes` export and hex opt-in; no Serde | Inline | Caller-controlled key/keypair duplication for independent signing owners | | Ed25519 secret key and keypair | Explicit duplicate; no `Clone` or `Copy` | Secret key is masked; keypair shows only the public half | Secret-key `SecretBytes` export, hex opt-in, and `serde-secrets`; no keypair Serde | Inline | Independent signing owners; keypair duplication also copies its expanded secret state deliberately | | X25519 secret key and shared secret | Explicit duplicate; no `Clone` or `Copy` | Masked | `SecretBytes` export, hex opt-in, and `serde-secrets` | Inline | Explicit transfer of key-agreement material into a separately owned protocol or KDF context | @@ -79,6 +81,7 @@ key. Generated-code timing claims remain limited by | `ZeroizingBytes` | Neither | Neither | Inline | Generation and parsing scratch that cannot escape as a generic clone | | 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 | | 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 e10ea4d6..8f4dbdab 100644 --- a/docs/types.md +++ b/docs/types.md @@ -219,6 +219,14 @@ must explicitly import `aead::expert::AeadWithNonce` for caller-nonce detached forms remain allocation-free. With `alloc`, decryption has `decrypt_to_vec`; with `alloc` + `getrandom`, sealing has `seal_random_to_vec`. +`aead::expert::header_protection` exposes narrow AES-128, AES-256, and ChaCha20 +mask generators for protocols that define a 16-byte sample and five-byte mask. +The concrete key types are distinct from packet-protection keys, and the API +does not expose ECB mode, a general stream cipher, or the unused block bytes. +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. + ## Error types | Error | When | Recovery | diff --git a/fuzz-packages/aead-header-protection/Cargo.lock b/fuzz-packages/aead-header-protection/Cargo.lock new file mode 100644 index 00000000..327b0433 --- /dev/null +++ b/fuzz-packages/aead-header-protection/Cargo.lock @@ -0,0 +1,194 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "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 = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[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 = "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", +] + +[[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 = "rscrypto" +version = "0.8.1" + +[[package]] +name = "rscrypto-fuzz-aead-header-protection" +version = "0.0.0" +dependencies = [ + "aes", + "chacha20", + "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-header-protection/Cargo.toml b/fuzz-packages/aead-header-protection/Cargo.toml new file mode 100644 index 00000000..c2c65b1a --- /dev/null +++ b/fuzz-packages/aead-header-protection/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "rscrypto-fuzz-aead-header-protection" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +rscrypto = { path = "../..", features = ["std", "aes-gcm", "chacha20poly1305"] } +rscrypto_fuzz = { package = "rscrypto-fuzz-support", path = "../../fuzz/support" } +aes = "0.9.2" +chacha20 = "0.10.1" + +[workspace] +members = ["."] + +[[bin]] +name = "aead_header_protection" +path = "fuzz_targets/aead_header_protection.rs" +doc = false diff --git a/fuzz-packages/aead-header-protection/corpus/aead_header_protection/seed-basic b/fuzz-packages/aead-header-protection/corpus/aead_header_protection/seed-basic new file mode 100644 index 00000000..193e7cf8 --- /dev/null +++ b/fuzz-packages/aead-header-protection/corpus/aead_header_protection/seed-basic @@ -0,0 +1 @@ +rscrypto fuzz seed v1 aead header protection 0123456789abcdef0123456789abcdef0123456789abcdef diff --git a/fuzz-packages/aead-header-protection/fuzz_targets/aead_header_protection.rs b/fuzz-packages/aead-header-protection/fuzz_targets/aead_header_protection.rs new file mode 100644 index 00000000..9342cb59 --- /dev/null +++ b/fuzz-packages/aead-header-protection/fuzz_targets/aead_header_protection.rs @@ -0,0 +1,8 @@ +#![no_main] + +#[path = "../../../fuzz/target_impls/aead_header_protection.rs"] +mod target_impl; + +libfuzzer_sys::fuzz_target!(|data: &[u8]| { + target_impl::run(data); +}); diff --git a/fuzz-packages/aead-header-protection/tests/corpus_replay.rs b/fuzz-packages/aead-header-protection/tests/corpus_replay.rs new file mode 100644 index 00000000..f3ef8955 --- /dev/null +++ b/fuzz-packages/aead-header-protection/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_header_protection.rs"] +mod aead_header_protection; + +#[test] +fn replay_aead_header_protection_corpus() { + let replayed = replay_corpus_dir( + "aead_header_protection", + corpus_dir("aead_header_protection"), + aead_header_protection::run, + ); + assert_ne!(replayed, 0, "aead_header_protection corpus should not be empty"); +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index 80ec664d..a41d18a7 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -800,6 +800,7 @@ name = "rscrypto-fuzz" version = "0.0.0" dependencies = [ "aegis", + "aes", "aes-gcm", "aes-gcm-siv", "argon2", @@ -807,6 +808,7 @@ dependencies = [ "ascon-hash", "blake2", "blake3", + "chacha20", "chacha20poly1305", "crc", "cshake", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 457d7d2d..051020f6 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -36,11 +36,13 @@ rscrypto = { path = "..", features = [ # ── Oracle crates for differential testing ──────────────────────────────────── # AEAD +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"] } aegis = "0.9" chacha20poly1305 = { version = "0.11", default-features = false, features = ["alloc"] } +chacha20 = "0.10.1" # Auth argon2 = { version = "0.5.3", default-features = false, features = ["alloc"] } @@ -121,6 +123,11 @@ name = "aead_nonce_counter" path = "fuzz_targets/aead_nonce_counter.rs" doc = false +[[bin]] +name = "aead_header_protection" +path = "fuzz_targets/aead_header_protection.rs" +doc = false + # ── Auth targets ────────────────────────────────────────────────────────────── [[bin]] name = "auth_ed25519" diff --git a/fuzz/corpus/aead_header_protection/seed-basic b/fuzz/corpus/aead_header_protection/seed-basic new file mode 100644 index 00000000..193e7cf8 --- /dev/null +++ b/fuzz/corpus/aead_header_protection/seed-basic @@ -0,0 +1 @@ +rscrypto fuzz seed v1 aead header protection 0123456789abcdef0123456789abcdef0123456789abcdef diff --git a/fuzz/fuzz_targets/aead_header_protection.rs b/fuzz/fuzz_targets/aead_header_protection.rs new file mode 100644 index 00000000..5e2ee8d9 --- /dev/null +++ b/fuzz/fuzz_targets/aead_header_protection.rs @@ -0,0 +1,8 @@ +#![no_main] + +#[path = "../target_impls/aead_header_protection.rs"] +mod target_impl; + +libfuzzer_sys::fuzz_target!(|data: &[u8]| { + target_impl::run(data); +}); diff --git a/fuzz/target_impls/aead_header_protection.rs b/fuzz/target_impls/aead_header_protection.rs new file mode 100644 index 00000000..6d1362da --- /dev/null +++ b/fuzz/target_impls/aead_header_protection.rs @@ -0,0 +1,36 @@ +use aes::cipher::{Array, BlockCipherEncrypt as _, KeyInit as _}; +use chacha20::cipher::{KeyIvInit as _, StreamCipherCore as _}; +use rscrypto::aead::expert::header_protection::{ + Aes128HeaderProtection, Aes128HeaderProtectionKey, Aes256HeaderProtection, Aes256HeaderProtectionKey, + ChaCha20HeaderProtection, ChaCha20HeaderProtectionKey, +}; +use rscrypto_fuzz::{FuzzInput, some_or_return}; + +pub(super) fn run(data: &[u8]) { + let mut input = FuzzInput::new(data); + let key: [u8; 32] = some_or_return!(input.bytes()); + let sample: [u8; 16] = some_or_return!(input.bytes()); + + let key128: [u8; 16] = key[..16].try_into().expect("AES-128 key prefix has fixed length"); + let hp128 = Aes128HeaderProtection::new(&Aes128HeaderProtectionKey::from_bytes(key128)); + let hp256 = Aes256HeaderProtection::new(&Aes256HeaderProtectionKey::from_bytes(key)); + let chacha = ChaCha20HeaderProtection::new(&ChaCha20HeaderProtectionKey::from_bytes(key)); + + let aes128_oracle = aes::Aes128::new(&Array::from(key128)); + let mut block128 = Array::from(sample); + aes128_oracle.encrypt_block(&mut block128); + assert_eq!(hp128.mask(&sample), block128[..5]); + + let aes256_oracle = aes::Aes256::new(&Array::from(key)); + let mut block256 = Array::from(sample); + aes256_oracle.encrypt_block(&mut block256); + assert_eq!(hp256.mask(&sample), block256[..5]); + + let counter = u32::from_le_bytes(sample[..4].try_into().expect("counter prefix has fixed length")); + let nonce: [u8; 12] = sample[4..].try_into().expect("nonce suffix has fixed length"); + let mut core = chacha20::ChaChaCore::::new((&key).into(), (&nonce).into()); + core.set_block_pos(counter); + let mut chacha_block = chacha20::cipher::array::Array::::default(); + core.write_keystream_block(&mut chacha_block); + assert_eq!(chacha.mask(&sample), chacha_block[..5]); +} diff --git a/fuzz/tests/corpus_replay.rs b/fuzz/tests/corpus_replay.rs index 68279dc7..1e79da08 100644 --- a/fuzz/tests/corpus_replay.rs +++ b/fuzz/tests/corpus_replay.rs @@ -21,6 +21,9 @@ mod aead_ascon128; #[path = "../target_impls/aead_chacha20poly1305.rs"] mod aead_chacha20poly1305; +#[path = "../target_impls/aead_header_protection.rs"] +mod aead_header_protection; + #[path = "../target_impls/aead_nonce_counter.rs"] mod aead_nonce_counter; @@ -188,6 +191,16 @@ fn replay_aead_chacha20poly1305_corpus() { assert_ne!(replayed, 0, "aead_chacha20poly1305 corpus should not be empty"); } +#[test] +fn replay_aead_header_protection_corpus() { + let replayed = replay_corpus_dir( + "aead_header_protection", + corpus_dir("aead_header_protection"), + aead_header_protection::run, + ); + assert_ne!(replayed, 0, "aead_header_protection corpus should not be empty"); +} + #[test] fn replay_aead_nonce_counter_corpus() { let replayed = replay_corpus_dir( diff --git a/scripts/bench/profile.sh b/scripts/bench/profile.sh index a880a9f0..3ed8c680 100755 --- a/scripts/bench/profile.sh +++ b/scripts/bench/profile.sh @@ -47,7 +47,7 @@ ARTIFACT=$( exit 1 } -COMMAND=("$ARTIFACT") +COMMAND=("$ARTIFACT" --bench) [[ -n "$FILTER" ]] && COMMAND+=("$FILTER") COMMAND+=(--profile-time "$PROFILE_SECONDS" --noplot) diff --git a/scripts/check/zeroize-evidence.sh b/scripts/check/zeroize-evidence.sh index 950472a7..688ab758 100755 --- a/scripts/check/zeroize-evidence.sh +++ b/scripts/check/zeroize-evidence.sh @@ -66,6 +66,9 @@ for symbol in \ diag_aegis256_update_portable \ diag_aes128gcm_ghash \ diag_aes256gcm_ghash \ + diag_zeroize_aes128_header_protection \ + diag_zeroize_aes256_header_protection \ + diag_zeroize_chacha20_header_protection \ diag_zeroize_mlkem_sha3_512 \ diag_zeroize_mlkem_shake256_scalar \ diag_zeroize_mlkem_shake256_pair \ @@ -109,6 +112,9 @@ for symbol in \ diag_aegis256_update_portable \ diag_aes128gcm_ghash \ diag_aes256gcm_ghash \ + diag_zeroize_aes128_header_protection \ + diag_zeroize_aes256_header_protection \ + diag_zeroize_chacha20_header_protection \ diag_zeroize_mlkem_sha3_512 \ diag_zeroize_mlkem_shake256_scalar \ diag_zeroize_mlkem_shake256_pair \ @@ -121,6 +127,18 @@ for symbol in \ fi done +for symbol in \ + diag_zeroize_aes128_header_protection \ + diag_zeroize_aes256_header_protection \ + diag_zeroize_chacha20_header_protection; do + FUNCTION_IR="$(sed -n "/define .*@$symbol(/,/^}/p" "$LLVM_IR")" + if [[ "$(grep -c 'store volatile .* 0' <<<"$FUNCTION_IR" || true)" -lt 2 ]] || \ + ! grep -q 'fence syncscope("singlethread") seq_cst' <<<"$FUNCTION_IR"; then + echo "zeroize release evidence does not clear header-protection owners and materialized output in $symbol" >&2 + exit 1 + fi +done + POLY1305_IR="$(sed -n '/define .*@diag_poly1305_block_portable_digest(/,/^}/p' "$LLVM_IR")" if [[ "$(grep -c 'store volatile i32 0' <<<"$POLY1305_IR" || true)" -lt 14 ]] || \ ! grep -q 'fence syncscope("singlethread") seq_cst' <<<"$POLY1305_IR"; then @@ -608,6 +626,9 @@ case "$HOST_ARCH" in diag_aegis256_update_portable \ diag_aes128gcm_ghash \ diag_aes256gcm_ghash \ + diag_zeroize_aes128_header_protection \ + diag_zeroize_aes256_header_protection \ + diag_zeroize_chacha20_header_protection \ diag_zeroize_mlkem_sha3_512 \ diag_zeroize_mlkem_shake256_scalar \ diag_zeroize_mlkem_shake256_pair \ @@ -654,6 +675,9 @@ case "$HOST_ARCH" in diag_aegis256_update_portable \ diag_aes128gcm_ghash \ diag_aes256gcm_ghash \ + diag_zeroize_aes128_header_protection \ + diag_zeroize_aes256_header_protection \ + diag_zeroize_chacha20_header_protection \ diag_zeroize_mlkem_sha3_512 \ diag_zeroize_mlkem_shake256_scalar \ diag_zeroize_mlkem_shake256_pair \ diff --git a/src/aead/aes.rs b/src/aead/aes.rs index 1d8c792e..af6252ba 100644 --- a/src/aead/aes.rs +++ b/src/aead/aes.rs @@ -1758,6 +1758,60 @@ pub(crate) fn aes128_encrypt_block(ek: &Aes128EncKey, block: &mut [u8; BLOCK_SIZ } } +/// Encrypt one block with AES-256 and return only its first five bytes. +/// +/// Hardware AES backends keep the unused output bytes in registers. Portable +/// and other target backends retain the authoritative block implementation and +/// clear its operation-local output before returning. +#[cfg(feature = "aes-gcm")] +#[inline] +pub(crate) fn aes256_encrypt_block_prefix_5(ek: &Aes256EncKey, block: &[u8; BLOCK_SIZE]) -> [u8; 5] { + #[cfg(target_arch = "x86_64")] + if let KeyInner::X86AesNi(ni_rk) = &ek.inner { + // SAFETY: this variant is constructed only after runtime detection confirms AES-NI. + return unsafe { ni::encrypt_block_prefix_5(ni_rk, block) }; + } + + #[cfg(target_arch = "aarch64")] + if let KeyInner::Aarch64Aes(ce_rk) = &ek.inner { + // SAFETY: this variant is constructed only after runtime detection confirms AES-CE. + return unsafe { ce::encrypt_block_prefix_5(ce_rk, block) }; + } + + let mut output = *block; + aes256_encrypt_block(ek, &mut output); + let prefix = [output[0], output[1], output[2], output[3], output[4]]; + crate::traits::ct::zeroize(&mut output); + prefix +} + +/// Encrypt one block with AES-128 and return only its first five bytes. +/// +/// Hardware AES backends keep the unused output bytes in registers. Portable +/// and other target backends retain the authoritative block implementation and +/// clear its operation-local output before returning. +#[cfg(feature = "aes-gcm")] +#[inline] +pub(crate) fn aes128_encrypt_block_prefix_5(ek: &Aes128EncKey, block: &[u8; BLOCK_SIZE]) -> [u8; 5] { + #[cfg(target_arch = "x86_64")] + if let Key128Inner::X86AesNi(ni_rk) = &ek.inner { + // SAFETY: this variant is constructed only after runtime detection confirms AES-NI. + return unsafe { ni::encrypt_block_prefix_5_128(ni_rk, block) }; + } + + #[cfg(target_arch = "aarch64")] + if let Key128Inner::Aarch64Aes(ce_rk) = &ek.inner { + // SAFETY: this variant is constructed only after runtime detection confirms AES-CE. + return unsafe { ce::encrypt_block_prefix_5_128(ce_rk, block) }; + } + + let mut output = *block; + aes128_encrypt_block(ek, &mut output); + let prefix = [output[0], output[1], output[2], output[3], output[4]]; + crate::traits::ct::zeroize(&mut output); + prefix +} + /// Encrypt multiple independent 16-byte blocks with AES-128 ECB. /// /// Mirrors [`aes256_encrypt_blocks_ecb`]: routes to the s390x KM batch diff --git a/src/aead/aes/aarch64_ce.rs b/src/aead/aes/aarch64_ce.rs index ea1250aa..81e716bd 100644 --- a/src/aead/aes/aarch64_ce.rs +++ b/src/aead/aes/aarch64_ce.rs @@ -111,8 +111,34 @@ pub(super) unsafe fn expand_key(key: &[u8; 32]) -> CeRoundKeys { unsafe { expand_key_hw(key) } } -/// Core block-encrypt logic — `#[target_feature]` + `#[inline(always)]` for -/// guaranteed inlining without register spills. +/// Encrypts one AES-256 state with the expanded AES-CE schedule. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports the AArch64 `aes` and `neon` +/// features. +#[target_feature(enable = "aes,neon")] +#[inline] +unsafe fn encrypt_state_core(keys: &CeRoundKeys, mut state: uint8x16_t) -> uint8x16_t { + let k = &keys.rk; + state = vaesmcq_u8(vaeseq_u8(state, k[0])); + state = vaesmcq_u8(vaeseq_u8(state, k[1])); + state = vaesmcq_u8(vaeseq_u8(state, k[2])); + state = vaesmcq_u8(vaeseq_u8(state, k[3])); + state = vaesmcq_u8(vaeseq_u8(state, k[4])); + state = vaesmcq_u8(vaeseq_u8(state, k[5])); + state = vaesmcq_u8(vaeseq_u8(state, k[6])); + state = vaesmcq_u8(vaeseq_u8(state, k[7])); + state = vaesmcq_u8(vaeseq_u8(state, k[8])); + state = vaesmcq_u8(vaeseq_u8(state, k[9])); + state = vaesmcq_u8(vaeseq_u8(state, k[10])); + state = vaesmcq_u8(vaeseq_u8(state, k[11])); + state = vaesmcq_u8(vaeseq_u8(state, k[12])); + state = vaeseq_u8(state, k[13]); + veorq_u8(state, k[14]) +} + +/// Core block-encrypt logic — target-gated and inlined to avoid register spills. #[target_feature(enable = "aes,neon")] #[inline] /// # Safety @@ -125,29 +151,7 @@ pub(super) unsafe fn encrypt_block_core(keys: &CeRoundKeys, block: &mut [u8; 16] // 2. The caller guarantees the current CPU supports AES-CE + NEON. // 3. `block` is exactly one writable initialized AES block. unsafe { - let k = &keys.rk; - let mut state = vld1q_u8(block.as_ptr()); - - // Rounds 1–13: AESE absorbs the previous round's AddRoundKey, - // then SubBytes + ShiftRows. AESMC applies MixColumns. - state = vaesmcq_u8(vaeseq_u8(state, k[0])); - state = vaesmcq_u8(vaeseq_u8(state, k[1])); - state = vaesmcq_u8(vaeseq_u8(state, k[2])); - state = vaesmcq_u8(vaeseq_u8(state, k[3])); - state = vaesmcq_u8(vaeseq_u8(state, k[4])); - state = vaesmcq_u8(vaeseq_u8(state, k[5])); - state = vaesmcq_u8(vaeseq_u8(state, k[6])); - state = vaesmcq_u8(vaeseq_u8(state, k[7])); - state = vaesmcq_u8(vaeseq_u8(state, k[8])); - state = vaesmcq_u8(vaeseq_u8(state, k[9])); - state = vaesmcq_u8(vaeseq_u8(state, k[10])); - state = vaesmcq_u8(vaeseq_u8(state, k[11])); - state = vaesmcq_u8(vaeseq_u8(state, k[12])); - - // Round 14 (final): SubBytes + ShiftRows, then AddRoundKey (no MixColumns). - state = vaeseq_u8(state, k[13]); - state = veorq_u8(state, k[14]); - + let state = encrypt_state_core(keys, vld1q_u8(block.as_ptr())); vst1q_u8(block.as_mut_ptr(), state); } } @@ -1490,6 +1494,26 @@ pub(super) unsafe fn encrypt_block(keys: &CeRoundKeys, block: &mut [u8; 16]) { unsafe { encrypt_block_core(keys, block) } } +#[cfg(feature = "aes-gcm")] +/// Encrypt one block and return only its first five bytes. +/// +/// The unused eleven output bytes remain in the vector state and are never +/// materialized in addressable memory. +/// +/// # Safety +/// Caller must ensure the CPU supports AES-CE (`target_feature = "aes"`). +#[target_feature(enable = "aes,neon")] +pub(super) unsafe fn encrypt_block_prefix_5(keys: &CeRoundKeys, block: &[u8; 16]) -> [u8; 5] { + // SAFETY: target_feature gate guarantees AES-CE + NEON. The fixed-size input + // supplies one complete block load; lane extraction reads only the low eight + // bytes of the initialized encrypted state. + unsafe { + let state = encrypt_state_core(keys, vld1q_u8(block.as_ptr())); + let prefix = vgetq_lane_u64(vreinterpretq_u64_u8(state), 0).to_le_bytes(); + [prefix[0], prefix[1], prefix[2], prefix[3], prefix[4]] + } +} + // AES-128 (11 round keys, 10 rounds) /// AES-128 round keys stored as 11 × 128-bit NEON vectors for AES-CE. @@ -1573,6 +1597,29 @@ pub(super) unsafe fn expand_key_128(key: &[u8; 16]) -> Ce128RoundKeys { unsafe { expand_key_128_hw(key) } } +/// Encrypts one AES-128 state with the expanded AES-CE schedule. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports the AArch64 `aes` and `neon` +/// features. +#[target_feature(enable = "aes,neon")] +#[inline] +unsafe fn encrypt_state_128_core(keys: &Ce128RoundKeys, mut state: uint8x16_t) -> uint8x16_t { + let k = &keys.rk; + state = vaesmcq_u8(vaeseq_u8(state, k[0])); + state = vaesmcq_u8(vaeseq_u8(state, k[1])); + state = vaesmcq_u8(vaeseq_u8(state, k[2])); + state = vaesmcq_u8(vaeseq_u8(state, k[3])); + state = vaesmcq_u8(vaeseq_u8(state, k[4])); + state = vaesmcq_u8(vaeseq_u8(state, k[5])); + state = vaesmcq_u8(vaeseq_u8(state, k[6])); + state = vaesmcq_u8(vaeseq_u8(state, k[7])); + state = vaesmcq_u8(vaeseq_u8(state, k[8])); + state = vaeseq_u8(state, k[9]); + veorq_u8(state, k[10]) +} + #[target_feature(enable = "aes,neon")] #[inline] /// # Safety @@ -1585,25 +1632,7 @@ pub(super) unsafe fn encrypt_block_128_core(keys: &Ce128RoundKeys, block: &mut [ // 2. The caller guarantees the current CPU supports AES-CE + NEON. // 3. `block` is exactly one writable initialized AES block. unsafe { - let k = &keys.rk; - let mut state = vld1q_u8(block.as_ptr()); - - // Rounds 1–9: AESE absorbs the previous round's AddRoundKey, - // then SubBytes + ShiftRows. AESMC applies MixColumns. - state = vaesmcq_u8(vaeseq_u8(state, k[0])); - state = vaesmcq_u8(vaeseq_u8(state, k[1])); - state = vaesmcq_u8(vaeseq_u8(state, k[2])); - state = vaesmcq_u8(vaeseq_u8(state, k[3])); - state = vaesmcq_u8(vaeseq_u8(state, k[4])); - state = vaesmcq_u8(vaeseq_u8(state, k[5])); - state = vaesmcq_u8(vaeseq_u8(state, k[6])); - state = vaesmcq_u8(vaeseq_u8(state, k[7])); - state = vaesmcq_u8(vaeseq_u8(state, k[8])); - - // Round 10 (final): SubBytes + ShiftRows, then AddRoundKey (no MixColumns). - state = vaeseq_u8(state, k[9]); - state = veorq_u8(state, k[10]); - + let state = encrypt_state_128_core(keys, vld1q_u8(block.as_ptr())); vst1q_u8(block.as_mut_ptr(), state); } } @@ -2470,3 +2499,23 @@ pub(super) unsafe fn encrypt_block_128(keys: &Ce128RoundKeys, block: &mut [u8; 1 // SAFETY: target_feature gate guarantees AES-CE + NEON. unsafe { encrypt_block_128_core(keys, block) } } + +#[cfg(feature = "aes-gcm")] +/// Encrypt one block and return only its first five bytes. +/// +/// The unused eleven output bytes remain in the vector state and are never +/// materialized in addressable memory. +/// +/// # Safety +/// Caller must ensure the CPU supports AES-CE (`target_feature = "aes"`). +#[target_feature(enable = "aes,neon")] +pub(super) unsafe fn encrypt_block_prefix_5_128(keys: &Ce128RoundKeys, block: &[u8; 16]) -> [u8; 5] { + // SAFETY: target_feature gate guarantees AES-CE + NEON. The fixed-size input + // supplies one complete block load; lane extraction reads only the low eight + // bytes of the initialized encrypted state. + unsafe { + let state = encrypt_state_128_core(keys, vld1q_u8(block.as_ptr())); + let prefix = vgetq_lane_u64(vreinterpretq_u64_u8(state), 0).to_le_bytes(); + [prefix[0], prefix[1], prefix[2], prefix[3], prefix[4]] + } +} diff --git a/src/aead/aes/x86_64_ni.rs b/src/aead/aes/x86_64_ni.rs index c9fbb60c..ff33bde0 100644 --- a/src/aead/aes/x86_64_ni.rs +++ b/src/aead/aes/x86_64_ni.rs @@ -220,37 +220,66 @@ pub(super) unsafe fn encrypt_16blocks( ) } +/// Encrypts one AES-256 state with the expanded AES-NI schedule. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports the x86 `aes` and `sse2` features. +#[target_feature(enable = "aes,sse2")] +#[inline] +unsafe fn encrypt_state(keys: &NiRoundKeys, mut state: __m128i) -> __m128i { + let k = &keys.rk; + state = _mm_xor_si128(state, k[0]); + state = _mm_aesenc_si128(state, k[1]); + state = _mm_aesenc_si128(state, k[2]); + state = _mm_aesenc_si128(state, k[3]); + state = _mm_aesenc_si128(state, k[4]); + state = _mm_aesenc_si128(state, k[5]); + state = _mm_aesenc_si128(state, k[6]); + state = _mm_aesenc_si128(state, k[7]); + state = _mm_aesenc_si128(state, k[8]); + state = _mm_aesenc_si128(state, k[9]); + state = _mm_aesenc_si128(state, k[10]); + state = _mm_aesenc_si128(state, k[11]); + state = _mm_aesenc_si128(state, k[12]); + state = _mm_aesenc_si128(state, k[13]); + _mm_aesenclast_si128(state, k[14]) +} + /// Encrypt a single 16-byte block using AES-256 with AES-NI. /// /// # Safety /// Caller must ensure the CPU supports AES-NI (`target_feature = "aes"`). #[target_feature(enable = "aes,sse2")] pub(super) unsafe fn encrypt_block(keys: &NiRoundKeys, block: &mut [u8; 16]) { - // SAFETY: target_feature gate guarantees AES-NI + SSE2. + // SAFETY: target_feature gate guarantees AES-NI + SSE2. The fixed-size block + // supplies one complete unaligned load and store. unsafe { - let k = &keys.rk; - let mut state = _mm_loadu_si128(block.as_ptr().cast()); - - state = _mm_xor_si128(state, k[0]); - state = _mm_aesenc_si128(state, k[1]); - state = _mm_aesenc_si128(state, k[2]); - state = _mm_aesenc_si128(state, k[3]); - state = _mm_aesenc_si128(state, k[4]); - state = _mm_aesenc_si128(state, k[5]); - state = _mm_aesenc_si128(state, k[6]); - state = _mm_aesenc_si128(state, k[7]); - state = _mm_aesenc_si128(state, k[8]); - state = _mm_aesenc_si128(state, k[9]); - state = _mm_aesenc_si128(state, k[10]); - state = _mm_aesenc_si128(state, k[11]); - state = _mm_aesenc_si128(state, k[12]); - state = _mm_aesenc_si128(state, k[13]); - state = _mm_aesenclast_si128(state, k[14]); - + let state = encrypt_state(keys, _mm_loadu_si128(block.as_ptr().cast())); _mm_storeu_si128(block.as_mut_ptr().cast(), state); } } +#[cfg(feature = "aes-gcm")] +/// Encrypt one block and return only its first five bytes. +/// +/// The unused eleven output bytes remain in the SIMD state and are never +/// materialized in addressable memory. +/// +/// # Safety +/// Caller must ensure the CPU supports AES-NI (`target_feature = "aes"`). +#[target_feature(enable = "aes,sse2")] +pub(super) unsafe fn encrypt_block_prefix_5(keys: &NiRoundKeys, block: &[u8; 16]) -> [u8; 5] { + // SAFETY: target_feature gate guarantees AES-NI + SSE2. The fixed-size input + // supplies one complete unaligned block load; lane extraction reads only the + // low eight bytes of the initialized encrypted state. + unsafe { + let state = encrypt_state(keys, _mm_loadu_si128(block.as_ptr().cast())); + let prefix = _mm_cvtsi128_si64(state).cast_unsigned().to_le_bytes(); + [prefix[0], prefix[1], prefix[2], prefix[3], prefix[4]] + } +} + // AES-128 (11 round keys, 10 rounds) /// AES-128 round keys stored as 11 × 128-bit values for AES-NI. @@ -443,29 +472,58 @@ pub(super) unsafe fn encrypt_16blocks_128( ) } +/// Encrypts one AES-128 state with the expanded AES-NI schedule. +/// +/// # Safety +/// +/// The caller must ensure the CPU supports the x86 `aes` and `sse2` features. +#[target_feature(enable = "aes,sse2")] +#[inline] +unsafe fn encrypt_state_128(keys: &Ni128RoundKeys, mut state: __m128i) -> __m128i { + let k = &keys.rk; + state = _mm_xor_si128(state, k[0]); + state = _mm_aesenc_si128(state, k[1]); + state = _mm_aesenc_si128(state, k[2]); + state = _mm_aesenc_si128(state, k[3]); + state = _mm_aesenc_si128(state, k[4]); + state = _mm_aesenc_si128(state, k[5]); + state = _mm_aesenc_si128(state, k[6]); + state = _mm_aesenc_si128(state, k[7]); + state = _mm_aesenc_si128(state, k[8]); + state = _mm_aesenc_si128(state, k[9]); + _mm_aesenclast_si128(state, k[10]) +} + /// Encrypt a single 16-byte block using AES-128 with AES-NI. /// /// # Safety /// Caller must ensure the CPU supports AES-NI (`target_feature = "aes"`). #[target_feature(enable = "aes,sse2")] pub(super) unsafe fn encrypt_block_128(keys: &Ni128RoundKeys, block: &mut [u8; 16]) { - // SAFETY: target_feature gate guarantees AES-NI + SSE2. + // SAFETY: target_feature gate guarantees AES-NI + SSE2. The fixed-size block + // supplies one complete unaligned load and store. unsafe { - let k = &keys.rk; - let mut state = _mm_loadu_si128(block.as_ptr().cast()); - - state = _mm_xor_si128(state, k[0]); - state = _mm_aesenc_si128(state, k[1]); - state = _mm_aesenc_si128(state, k[2]); - state = _mm_aesenc_si128(state, k[3]); - state = _mm_aesenc_si128(state, k[4]); - state = _mm_aesenc_si128(state, k[5]); - state = _mm_aesenc_si128(state, k[6]); - state = _mm_aesenc_si128(state, k[7]); - state = _mm_aesenc_si128(state, k[8]); - state = _mm_aesenc_si128(state, k[9]); - state = _mm_aesenclast_si128(state, k[10]); - + let state = encrypt_state_128(keys, _mm_loadu_si128(block.as_ptr().cast())); _mm_storeu_si128(block.as_mut_ptr().cast(), state); } } + +#[cfg(feature = "aes-gcm")] +/// Encrypt one block and return only its first five bytes. +/// +/// The unused eleven output bytes remain in the SIMD state and are never +/// materialized in addressable memory. +/// +/// # Safety +/// Caller must ensure the CPU supports AES-NI (`target_feature = "aes"`). +#[target_feature(enable = "aes,sse2")] +pub(super) unsafe fn encrypt_block_prefix_5_128(keys: &Ni128RoundKeys, block: &[u8; 16]) -> [u8; 5] { + // SAFETY: target_feature gate guarantees AES-NI + SSE2. The fixed-size input + // supplies one complete unaligned block load; lane extraction reads only the + // low eight bytes of the initialized encrypted state. + unsafe { + let state = encrypt_state_128(keys, _mm_loadu_si128(block.as_ptr().cast())); + let prefix = _mm_cvtsi128_si64(state).cast_unsigned().to_le_bytes(); + [prefix[0], prefix[1], prefix[2], prefix[3], prefix[4]] + } +} diff --git a/src/aead/header_protection.rs b/src/aead/header_protection.rs new file mode 100644 index 00000000..ebf1a644 --- /dev/null +++ b/src/aead/header_protection.rs @@ -0,0 +1,239 @@ +//! Narrow fixed-size header-protection mask generation. +//! +//! These are the one-sample AES and ChaCha20 operations specified by +//! [RFC 9001 section 5.4](https://www.rfc-editor.org/rfc/rfc9001.html#section-5.4). +//! Packet parsing, header-bit selection, and packet protection remain protocol-layer concerns. + +use core::fmt; + +#[cfg(feature = "aes-gcm")] +use super::aes; +#[cfg(feature = "chacha20poly1305")] +use super::chacha20; +use crate::traits::ct; + +/// Header-protection sample length in bytes. +const SAMPLE_SIZE: usize = 16; +/// Header-protection mask length in bytes. +const MASK_SIZE: usize = 5; + +macro_rules! define_header_protection_key { + ($name:ident, $len:expr, $doc:literal) => { + #[doc = $doc] + pub struct $name([u8; Self::LENGTH]); + + impl $name { + /// Key length in bytes. + pub const LENGTH: usize = $len; + + /// Construct a header-protection key from its protocol-derived bytes. + #[inline] + #[must_use] + pub const fn from_bytes(bytes: [u8; Self::LENGTH]) -> Self { + Self(bytes) + } + } + + impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}(****)", stringify!($name)) + } + } + + impl Drop for $name { + #[inline] + fn drop(&mut self) { + ct::zeroize(&mut self.0); + } + } + }; +} + +#[cfg(feature = "aes-gcm")] +define_header_protection_key!( + Aes128HeaderProtectionKey, + 16, + "A 128-bit AES header-protection key, distinct from packet-protection keys." +); + +#[cfg(feature = "aes-gcm")] +define_header_protection_key!( + Aes256HeaderProtectionKey, + 32, + "A 256-bit AES header-protection key, distinct from packet-protection keys." +); + +#[cfg(feature = "chacha20poly1305")] +define_header_protection_key!( + ChaCha20HeaderProtectionKey, + 32, + "A 256-bit ChaCha20 header-protection key, distinct from packet-protection keys." +); + +/// AES-128 header-protection mask generator. +/// +/// This is a deliberately narrow one-block capability. It accepts exactly one 16-byte sample and +/// returns only the first five bytes of AES-128 encryption. It does not expose ECB mode or the +/// remaining block bytes. +/// +/// AES-NI and AES-CE backends extract the mask directly from vector state without materializing +/// the unused output bytes. Other backends clear their complete operation-local output block. +/// +/// The context owns an expanded AES key schedule and is intentionally neither `Clone` nor `Copy`. +/// Mask generation does not allocate. On alloc-enabled RISC-V without a hardware AES backend, +/// construction retains the existing boxed fixslice schedule; no-alloc builds store it inline. +#[cfg(feature = "aes-gcm")] +pub struct Aes128HeaderProtection { + expanded_key: aes::Aes128EncKey, +} + +#[cfg(feature = "aes-gcm")] +impl fmt::Debug for Aes128HeaderProtection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Aes128HeaderProtection").finish_non_exhaustive() + } +} + +#[cfg(feature = "aes-gcm")] +impl Aes128HeaderProtection { + /// Construct a mask generator from a distinct header-protection key. + #[inline] + #[must_use] + pub fn new(key: &Aes128HeaderProtectionKey) -> Self { + Self { + expanded_key: aes::aes128_expand_key(&key.0), + } + } + + /// Generate a five-byte mask from one 16-byte sample. + #[inline] + #[must_use] + pub fn mask(&self, sample: &[u8; SAMPLE_SIZE]) -> [u8; MASK_SIZE] { + aes::aes128_encrypt_block_prefix_5(&self.expanded_key, sample) + } +} + +/// AES-256 header-protection mask generator. +/// +/// This is a deliberately narrow one-block capability. It accepts exactly one 16-byte sample and +/// returns only the first five bytes of AES-256 encryption. It does not expose ECB mode or the +/// remaining block bytes. +/// +/// AES-NI and AES-CE backends extract the mask directly from vector state without materializing +/// the unused output bytes. Other backends clear their complete operation-local output block. +/// +/// The context owns an expanded AES key schedule and is intentionally neither `Clone` nor `Copy`. +/// Mask generation does not allocate. On alloc-enabled RISC-V without a hardware AES backend, +/// construction retains the existing boxed fixslice schedule; no-alloc builds store it inline. +#[cfg(feature = "aes-gcm")] +pub struct Aes256HeaderProtection { + expanded_key: aes::Aes256EncKey, +} + +#[cfg(feature = "aes-gcm")] +impl fmt::Debug for Aes256HeaderProtection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Aes256HeaderProtection").finish_non_exhaustive() + } +} + +#[cfg(feature = "aes-gcm")] +impl Aes256HeaderProtection { + /// Construct a mask generator from a distinct header-protection key. + #[inline] + #[must_use] + pub fn new(key: &Aes256HeaderProtectionKey) -> Self { + Self { + expanded_key: aes::aes256_expand_key(&key.0), + } + } + + /// Generate a five-byte mask from one 16-byte sample. + #[inline] + #[must_use] + pub fn mask(&self, sample: &[u8; SAMPLE_SIZE]) -> [u8; MASK_SIZE] { + aes::aes256_encrypt_block_prefix_5(&self.expanded_key, sample) + } +} + +/// ChaCha20 header-protection mask generator. +/// +/// The sample's first four bytes are interpreted as a little-endian block counter and the +/// remaining twelve bytes as the nonce. The full temporary ChaCha20 block is cleared after its +/// first five bytes are copied into the returned mask. +/// +/// The context owns key material and is intentionally neither `Clone` nor `Copy`. +#[cfg(feature = "chacha20poly1305")] +pub struct ChaCha20HeaderProtection { + key: [u8; chacha20::KEY_SIZE], +} + +#[cfg(feature = "chacha20poly1305")] +impl fmt::Debug for ChaCha20HeaderProtection { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ChaCha20HeaderProtection").finish_non_exhaustive() + } +} + +#[cfg(feature = "chacha20poly1305")] +impl ChaCha20HeaderProtection { + /// Construct a mask generator from a distinct header-protection key. + #[inline] + #[must_use] + pub fn new(key: &ChaCha20HeaderProtectionKey) -> Self { + Self { key: key.0 } + } + + /// Generate a five-byte mask from one 16-byte sample. + #[inline] + #[must_use] + pub fn mask(&self, sample: &[u8; SAMPLE_SIZE]) -> [u8; MASK_SIZE] { + let counter = u32::from_le_bytes([sample[0], sample[1], sample[2], sample[3]]); + let mut nonce = [0u8; chacha20::NONCE_SIZE]; + nonce.copy_from_slice(&sample[4..]); + + let mut block = chacha20::block(&self.key, counter, &nonce); + let mut mask = [0u8; MASK_SIZE]; + mask.copy_from_slice(&block[..MASK_SIZE]); + ct::zeroize(&mut block); + mask + } +} + +#[cfg(feature = "chacha20poly1305")] +impl Drop for ChaCha20HeaderProtection { + #[inline] + fn drop(&mut self) { + ct::zeroize(&mut self.key); + } +} + +#[cfg(all(feature = "diag", feature = "aes-gcm"))] +/// Exercise AES-128 header protection while retaining key, schedule, and temporary-block cleanup. +#[unsafe(no_mangle)] +#[inline(never)] +#[must_use] +pub fn diag_zeroize_aes128_header_protection(key: [u8; 16], sample: [u8; SAMPLE_SIZE]) -> [u8; MASK_SIZE] { + let key = Aes128HeaderProtectionKey::from_bytes(key); + Aes128HeaderProtection::new(&key).mask(&sample) +} + +#[cfg(all(feature = "diag", feature = "aes-gcm"))] +/// Exercise AES-256 header protection while retaining key, schedule, and temporary-block cleanup. +#[unsafe(no_mangle)] +#[inline(never)] +#[must_use] +pub fn diag_zeroize_aes256_header_protection(key: [u8; 32], sample: [u8; SAMPLE_SIZE]) -> [u8; MASK_SIZE] { + let key = Aes256HeaderProtectionKey::from_bytes(key); + Aes256HeaderProtection::new(&key).mask(&sample) +} + +#[cfg(all(feature = "diag", feature = "chacha20poly1305"))] +/// Exercise ChaCha20 header protection while retaining key, context, and temporary-block cleanup. +#[unsafe(no_mangle)] +#[inline(never)] +#[must_use] +pub fn diag_zeroize_chacha20_header_protection(key: [u8; 32], sample: [u8; SAMPLE_SIZE]) -> [u8; MASK_SIZE] { + let key = ChaCha20HeaderProtectionKey::from_bytes(key); + ChaCha20HeaderProtection::new(&key).mask(&sample) +} diff --git a/src/aead/mod.rs b/src/aead/mod.rs index e4e30c22..47e58532 100644 --- a/src/aead/mod.rs +++ b/src/aead/mod.rs @@ -61,9 +61,26 @@ use crate::traits::VerificationError; #[doc(hidden)] pub use crate::traits::aead::SealToken as __SealToken; -/// Explicit-nonce sealing for protocols that prove nonce uniqueness. +/// Expert cryptographic capabilities with protocol-enforced preconditions. pub mod expert { pub use crate::traits::aead::AeadWithNonce; + + /// Fixed-size header-protection mask generation. + /// + /// This capability exposes only the one-sample, five-byte-mask operation. It does not expose + /// general AES block encryption or a raw ChaCha20 stream. + #[cfg(any(feature = "aes-gcm", feature = "chacha20poly1305"))] + #[cfg_attr(docsrs, doc(cfg(any(feature = "aes-gcm", feature = "chacha20poly1305"))))] + pub mod header_protection { + #[cfg(feature = "aes-gcm")] + #[cfg_attr(docsrs, doc(cfg(feature = "aes-gcm")))] + pub use crate::aead::header_protection::{ + Aes128HeaderProtection, Aes128HeaderProtectionKey, Aes256HeaderProtection, Aes256HeaderProtectionKey, + }; + #[cfg(feature = "chacha20poly1305")] + #[cfg_attr(docsrs, doc(cfg(feature = "chacha20poly1305")))] + pub use crate::aead::header_protection::{ChaCha20HeaderProtection, ChaCha20HeaderProtectionKey}; + } } #[cfg(feature = "aegis256")] mod aegis256; @@ -131,6 +148,8 @@ mod chacha20; mod chacha20poly1305; #[cfg(feature = "aes-gcm")] mod ghash; +#[cfg(any(feature = "aes-gcm", feature = "chacha20poly1305"))] +mod header_protection; #[cfg(feature = "diag")] pub mod introspect; #[cfg(feature = "aes-gcm")] @@ -255,6 +274,10 @@ pub use chacha20poly1305::{ }; #[cfg(all(feature = "diag", feature = "aes-gcm"))] pub use ghash::diag_ghash_block_portable; +#[cfg(all(feature = "diag", feature = "chacha20poly1305"))] +pub use header_protection::diag_zeroize_chacha20_header_protection; +#[cfg(all(feature = "diag", feature = "aes-gcm"))] +pub use header_protection::{diag_zeroize_aes128_header_protection, diag_zeroize_aes256_header_protection}; #[cfg(feature = "aes-gcm")] pub use nonce_counter::{NonceCounter, NonceCounterExhausted, NonceCounterSealError}; #[cfg(all( diff --git a/src/lib.rs b/src/lib.rs index 6ad3bc3f..8eda55f1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -668,6 +668,18 @@ pub struct __RootSurfaceAudit; use rscrypto::DisplaySecret; ``` +```compile_fail +use rscrypto::Aes128HeaderProtection; +``` + +```compile_fail +use rscrypto::Aes256HeaderProtection; +``` + +```compile_fail +use rscrypto::ChaCha20HeaderProtection; +``` + ```compile_fail use rscrypto::platform::OverrideError; ``` @@ -696,6 +708,15 @@ cipher.encrypt(&nonce, b"", b"", &mut out)?; let _ = rscrypto::aead::__SealToken(()); ``` +```compile_fail,E0308 +use rscrypto::aead::expert::header_protection::{ + Aes128HeaderProtection, ChaCha20HeaderProtectionKey, +}; + +let key = ChaCha20HeaderProtectionKey::from_bytes([0u8; 32]); +let _ = Aes128HeaderProtection::new(&key); +``` + ```rust use rscrypto::{ Aead, ChaCha20Poly1305, ChaCha20Poly1305Key, @@ -1166,6 +1187,48 @@ use rscrypto::RsaPrivateScratch; fn require_clone() {} require_clone::(); ``` + +```compile_fail,E0277 +use rscrypto::aead::expert::header_protection::Aes128HeaderProtectionKey; + +fn require_clone() {} +require_clone::(); +``` + +```compile_fail,E0277 +use rscrypto::aead::expert::header_protection::Aes128HeaderProtection; + +fn require_clone() {} +require_clone::(); +``` + +```compile_fail,E0277 +use rscrypto::aead::expert::header_protection::Aes256HeaderProtectionKey; + +fn require_clone() {} +require_clone::(); +``` + +```compile_fail,E0277 +use rscrypto::aead::expert::header_protection::Aes256HeaderProtection; + +fn require_clone() {} +require_clone::(); +``` + +```compile_fail,E0277 +use rscrypto::aead::expert::header_protection::ChaCha20HeaderProtectionKey; + +fn require_clone() {} +require_clone::(); +``` + +```compile_fail,E0277 +use rscrypto::aead::expert::header_protection::ChaCha20HeaderProtection; + +fn require_clone() {} +require_clone::(); +``` "#] pub struct __SecretCloneBoundaryAudit; diff --git a/tests/fast_hash_allocations.rs b/tests/fast_hash_allocations.rs index fc2cdb37..b765c097 100644 --- a/tests/fast_hash_allocations.rs +++ b/tests/fast_hash_allocations.rs @@ -153,3 +153,48 @@ fn fast_hashers_and_preallocated_maps_hash_without_allocating() { "preallocated RapidHash HashMap operations must not allocate" ); } + +#[cfg(all(feature = "aes-gcm", feature = "chacha20poly1305"))] +#[test] +fn header_protection_mask_generation_does_not_allocate() { + use rscrypto::aead::expert::header_protection::{ + Aes128HeaderProtection, Aes128HeaderProtectionKey, Aes256HeaderProtection, Aes256HeaderProtectionKey, + ChaCha20HeaderProtection, ChaCha20HeaderProtectionKey, + }; + + let aes128 = Aes128HeaderProtection::new(&Aes128HeaderProtectionKey::from_bytes([0x11; 16])); + let aes256 = Aes256HeaderProtection::new(&Aes256HeaderProtectionKey::from_bytes([0x22; 32])); + let chacha20 = ChaCha20HeaderProtection::new(&ChaCha20HeaderProtectionKey::from_bytes([0x33; 32])); + let sample = [0x44; 16]; + + let allocations = measure_allocations(|| { + core::hint::black_box(aes128.mask(core::hint::black_box(&sample))); + core::hint::black_box(aes256.mask(core::hint::black_box(&sample))); + core::hint::black_box(chacha20.mask(core::hint::black_box(&sample))); + }); + + assert_eq!(allocations, 0, "header-protection mask generation must not allocate"); +} + +#[cfg(all(feature = "aes-gcm", feature = "chacha20poly1305", not(target_arch = "riscv64")))] +#[test] +fn header_protection_context_construction_does_not_allocate_off_riscv64() { + use rscrypto::aead::expert::header_protection::{ + Aes128HeaderProtection, Aes128HeaderProtectionKey, Aes256HeaderProtection, Aes256HeaderProtectionKey, + ChaCha20HeaderProtection, ChaCha20HeaderProtectionKey, + }; + + let allocations = measure_allocations(|| { + let key128 = Aes128HeaderProtectionKey::from_bytes([0x11; 16]); + core::hint::black_box(Aes128HeaderProtection::new(&key128)); + let key256 = Aes256HeaderProtectionKey::from_bytes([0x22; 32]); + core::hint::black_box(Aes256HeaderProtection::new(&key256)); + let chacha_key = ChaCha20HeaderProtectionKey::from_bytes([0x33; 32]); + core::hint::black_box(ChaCha20HeaderProtection::new(&chacha_key)); + }); + + assert_eq!( + allocations, 0, + "header-protection context construction must not allocate off RISC-V" + ); +} diff --git a/tests/header_protection.rs b/tests/header_protection.rs new file mode 100644 index 00000000..6ebca128 --- /dev/null +++ b/tests/header_protection.rs @@ -0,0 +1,211 @@ +#![cfg(any(feature = "aes-gcm", feature = "chacha20poly1305"))] + +#[cfg(feature = "aes-gcm")] +use aes::cipher::{Array, BlockCipherEncrypt as _, KeyInit as _}; +#[cfg(feature = "chacha20poly1305")] +use chacha20::cipher::{KeyIvInit as _, StreamCipherCore as _}; +#[cfg(feature = "aes-gcm")] +use rscrypto::aead::expert::header_protection::{ + Aes128HeaderProtection, Aes128HeaderProtectionKey, Aes256HeaderProtection, Aes256HeaderProtectionKey, +}; +#[cfg(feature = "chacha20poly1305")] +use rscrypto::aead::expert::header_protection::{ChaCha20HeaderProtection, ChaCha20HeaderProtectionKey}; + +#[cfg(miri)] +const GENERATED_CASES: usize = 8; +#[cfg(not(miri))] +const GENERATED_CASES: usize = 512; + +fn decode_hex(hex: &str) -> [u8; N] { + fn nibble(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte.strict_sub(b'0')), + b'a'..=b'f' => Some(byte.strict_sub(b'a').strict_add(10)), + b'A'..=b'F' => Some(byte.strict_sub(b'A').strict_add(10)), + _ => None, + } + } + + assert_eq!(hex.len(), N.strict_mul(2), "test fixture must have exact length"); + let mut out = [0u8; N]; + for (dst, pair) in out.iter_mut().zip(hex.as_bytes().as_chunks::<2>().0) { + let high = nibble(pair[0]).expect("test fixture must contain hexadecimal bytes"); + let low = nibble(pair[1]).expect("test fixture must contain hexadecimal bytes"); + *dst = high.strict_shl(4) | low; + } + out +} + +fn generated_bytes(state: &mut u64) -> [u8; N] { + let mut out = [0u8; N]; + for byte in &mut out { + *state ^= *state << 13; + *state ^= *state >> 7; + *state ^= *state << 17; + *byte = state.to_le_bytes()[0]; + } + out +} + +#[cfg(feature = "aes-gcm")] +fn aes128_oracle(key: &[u8; 16], sample: &[u8; 16]) -> [u8; 5] { + let cipher = aes::Aes128::new(&Array::from(*key)); + let mut block = Array::from(*sample); + cipher.encrypt_block(&mut block); + block[..5].try_into().expect("five-byte prefix has fixed length") +} + +#[cfg(feature = "aes-gcm")] +fn aes256_oracle(key: &[u8; 32], sample: &[u8; 16]) -> [u8; 5] { + let cipher = aes::Aes256::new(&Array::from(*key)); + let mut block = Array::from(*sample); + cipher.encrypt_block(&mut block); + block[..5].try_into().expect("five-byte prefix has fixed length") +} + +#[cfg(feature = "chacha20poly1305")] +fn chacha20_oracle(key: &[u8; 32], sample: &[u8; 16]) -> [u8; 5] { + let counter = u32::from_le_bytes( + sample[..4] + .try_into() + .expect("four-byte counter prefix has fixed length"), + ); + let nonce: [u8; 12] = sample[4..] + .try_into() + .expect("twelve-byte nonce suffix has fixed length"); + let mut core = chacha20::ChaChaCore::::new(key.into(), (&nonce).into()); + core.set_block_pos(counter); + let mut block = chacha20::cipher::array::Array::::default(); + core.write_keystream_block(&mut block); + block[..5].try_into().expect("five-byte prefix has fixed length") +} + +#[cfg(feature = "aes-gcm")] +#[test] +fn rfc9001_aes128_client_and_server_initial_masks() { + let client_key = decode_hex("9f50449e04a0e810283a1e9933adedd2"); + let client_sample = decode_hex("d1b1c98dd7689fb8ec11d242b123dc9b"); + let client = Aes128HeaderProtection::new(&Aes128HeaderProtectionKey::from_bytes(client_key)); + assert_eq!(client.mask(&client_sample), decode_hex("437b9aec36")); + + let server_key = decode_hex("c206b8d9b9f0f37644430b490eeaa314"); + let server_sample = decode_hex("2cd0991cd25b0aac406a5816b6394100"); + let server = Aes128HeaderProtection::new(&Aes128HeaderProtectionKey::from_bytes(server_key)); + assert_eq!(server.mask(&server_sample), decode_hex("2ec0d8356a")); +} + +#[cfg(feature = "chacha20poly1305")] +#[test] +fn rfc9001_chacha20_short_header_mask() { + let key = decode_hex("25a282b9e82f06f21f488917a4fc8f1b73573685608597d0efcb076b0ab7a7a4"); + let sample = decode_hex("5e5cd55c41f69080575d7999c25a5bfb"); + let context = ChaCha20HeaderProtection::new(&ChaCha20HeaderProtectionKey::from_bytes(key)); + assert_eq!(context.mask(&sample), decode_hex("aefefe7d03")); + + let counter = u32::from_le_bytes(sample[..4].try_into().expect("counter prefix is four bytes")); + assert_eq!( + counter, 0x5cd5_5c5e, + "the sample counter must be interpreted little-endian" + ); +} + +#[cfg(feature = "aes-gcm")] +#[test] +fn aes_masks_match_independent_oracles_for_generated_inputs() { + let mut state = 0x7273_6372_7970_746fu64; + for _ in 0..GENERATED_CASES { + let key128 = generated_bytes(&mut state); + let key256 = generated_bytes(&mut state); + let sample = generated_bytes(&mut state); + let hp128 = Aes128HeaderProtection::new(&Aes128HeaderProtectionKey::from_bytes(key128)); + let hp256 = Aes256HeaderProtection::new(&Aes256HeaderProtectionKey::from_bytes(key256)); + assert_eq!(hp128.mask(&sample), aes128_oracle(&key128, &sample)); + assert_eq!(hp256.mask(&sample), aes256_oracle(&key256, &sample)); + } +} + +#[cfg(feature = "chacha20poly1305")] +#[test] +fn chacha20_masks_match_independent_oracle_for_generated_inputs() { + let mut state = 0x6865_6164_6572_2d70u64; + for _ in 0..GENERATED_CASES { + let key = generated_bytes(&mut state); + let sample = generated_bytes(&mut state); + let hp = ChaCha20HeaderProtection::new(&ChaCha20HeaderProtectionKey::from_bytes(key)); + assert_eq!(hp.mask(&sample), chacha20_oracle(&key, &sample)); + } +} + +#[cfg(feature = "aes-gcm")] +#[test] +fn aes_zero_one_and_each_sample_byte_match_oracles() { + for value in [0u8, u8::MAX] { + let key128 = [value; 16]; + let key256 = [value; 32]; + let sample = [value; 16]; + let hp128 = Aes128HeaderProtection::new(&Aes128HeaderProtectionKey::from_bytes(key128)); + let hp256 = Aes256HeaderProtection::new(&Aes256HeaderProtectionKey::from_bytes(key256)); + assert_eq!(hp128.mask(&sample), aes128_oracle(&key128, &sample)); + assert_eq!(hp256.mask(&sample), aes256_oracle(&key256, &sample)); + } + + let key128 = [0x53; 16]; + let key256 = [0xa7; 32]; + let hp128 = Aes128HeaderProtection::new(&Aes128HeaderProtectionKey::from_bytes(key128)); + let hp256 = Aes256HeaderProtection::new(&Aes256HeaderProtectionKey::from_bytes(key256)); + for index in 0..16 { + let mut sample = [0x39; 16]; + sample[index] ^= 0x80; + assert_eq!(hp128.mask(&sample), aes128_oracle(&key128, &sample)); + assert_eq!(hp256.mask(&sample), aes256_oracle(&key256, &sample)); + } +} + +#[cfg(feature = "chacha20poly1305")] +#[test] +fn chacha20_zero_one_and_each_sample_byte_match_oracle() { + for value in [0u8, u8::MAX] { + let key = [value; 32]; + let sample = [value; 16]; + let hp = ChaCha20HeaderProtection::new(&ChaCha20HeaderProtectionKey::from_bytes(key)); + assert_eq!(hp.mask(&sample), chacha20_oracle(&key, &sample)); + } + + let key = [0x53; 32]; + let hp = ChaCha20HeaderProtection::new(&ChaCha20HeaderProtectionKey::from_bytes(key)); + for index in 0..16 { + let mut sample = [0x39; 16]; + sample[index] ^= 0x80; + assert_eq!(hp.mask(&sample), chacha20_oracle(&key, &sample)); + } +} + +#[test] +fn key_and_context_debug_output_is_redacted() { + #[cfg(feature = "aes-gcm")] + { + let key128 = Aes128HeaderProtectionKey::from_bytes([0x53; 16]); + assert_eq!(format!("{key128:?}"), "Aes128HeaderProtectionKey(****)"); + assert_eq!( + format!("{:?}", Aes128HeaderProtection::new(&key128)), + "Aes128HeaderProtection { .. }" + ); + + let key256 = Aes256HeaderProtectionKey::from_bytes([0x53; 32]); + assert_eq!(format!("{key256:?}"), "Aes256HeaderProtectionKey(****)"); + assert_eq!( + format!("{:?}", Aes256HeaderProtection::new(&key256)), + "Aes256HeaderProtection { .. }" + ); + } + + #[cfg(feature = "chacha20poly1305")] + { + let key = ChaCha20HeaderProtectionKey::from_bytes([0x53; 32]); + assert_eq!(format!("{key:?}"), "ChaCha20HeaderProtectionKey(****)"); + assert_eq!( + format!("{:?}", ChaCha20HeaderProtection::new(&key)), + "ChaCha20HeaderProtection { .. }" + ); + } +} diff --git a/tests/secret_redaction.rs b/tests/secret_redaction.rs index 96c1df68..a3ded261 100644 --- a/tests/secret_redaction.rs +++ b/tests/secret_redaction.rs @@ -30,6 +30,9 @@ fn keyed_state_debug_snapshots_are_redacted() { #[cfg(feature = "aes-gcm")] { + use rscrypto::aead::expert::header_protection::{ + Aes128HeaderProtection, Aes128HeaderProtectionKey, Aes256HeaderProtection, Aes256HeaderProtectionKey, + }; use rscrypto::{Aes128Gcm, Aes128GcmKey, Aes256Gcm, Aes256GcmKey}; let key = Aes128GcmKey::from_bytes(KEY_16); @@ -39,6 +42,14 @@ fn keyed_state_debug_snapshots_are_redacted() { let key = Aes256GcmKey::from_bytes(KEY_32); assert_debug_snapshot(&key, "Aes256GcmKey(****)"); assert_debug_snapshot(&Aes256Gcm::new(&key), "Aes256Gcm { .. }"); + + let key = Aes128HeaderProtectionKey::from_bytes(KEY_16); + assert_debug_snapshot(&key, "Aes128HeaderProtectionKey(****)"); + assert_debug_snapshot(&Aes128HeaderProtection::new(&key), "Aes128HeaderProtection { .. }"); + + let key = Aes256HeaderProtectionKey::from_bytes(KEY_32); + assert_debug_snapshot(&key, "Aes256HeaderProtectionKey(****)"); + assert_debug_snapshot(&Aes256HeaderProtection::new(&key), "Aes256HeaderProtection { .. }"); } #[cfg(feature = "aes-gcm-siv")] @@ -56,11 +67,16 @@ fn keyed_state_debug_snapshots_are_redacted() { #[cfg(feature = "chacha20poly1305")] { + use rscrypto::aead::expert::header_protection::{ChaCha20HeaderProtection, ChaCha20HeaderProtectionKey}; use rscrypto::{ChaCha20Poly1305, ChaCha20Poly1305Key}; let key = ChaCha20Poly1305Key::from_bytes(KEY_32); assert_debug_snapshot(&key, "ChaCha20Poly1305Key(****)"); assert_debug_snapshot(&ChaCha20Poly1305::new(&key), "ChaCha20Poly1305 { .. }"); + + let key = ChaCha20HeaderProtectionKey::from_bytes(KEY_32); + assert_debug_snapshot(&key, "ChaCha20HeaderProtectionKey(****)"); + assert_debug_snapshot(&ChaCha20HeaderProtection::new(&key), "ChaCha20HeaderProtection { .. }"); } #[cfg(feature = "xchacha20poly1305")] @@ -377,7 +393,7 @@ fn secret_input_error_snapshots_do_not_echo_input_bytes() { #[cfg(feature = "aes-gcm")] { - let error = rscrypto::OpenError::verification(); + let error = rscrypto::aead::OpenError::verification(); assert_debug_snapshot(&error, "Verification(VerificationError)"); assert_eq!(error.to_string(), "verification failed"); } diff --git a/tools/ct-dudect/src/main.rs b/tools/ct-dudect/src/main.rs index a3a65d31..8c5420a5 100644 --- a/tools/ct-dudect/src/main.rs +++ b/tools/ct-dudect/src/main.rs @@ -2,6 +2,10 @@ use core::cell::RefCell; use dudect_bencher::{BenchRng, Class, CtRunner, ctbench_main_with_seeds, rand::RngExt}; use rscrypto::aead::expert::AeadWithNonce; +use rscrypto::aead::expert::header_protection::{ + Aes128HeaderProtection, Aes128HeaderProtectionKey, Aes256HeaderProtection, Aes256HeaderProtectionKey, + ChaCha20HeaderProtection, ChaCha20HeaderProtectionKey, +}; use rscrypto::{ Aegis256, Aegis256Key, Aes128Gcm, Aes128GcmKey, Aes128GcmSiv, Aes128GcmSivKey, Aes256Gcm, Aes256GcmKey, Aes256GcmSiv, Aes256GcmSivKey, Argon2Params, Argon2i, AsconAead128, AsconAead128Key, Blake2b256, Blake2b512, Blake2bKey, @@ -424,6 +428,53 @@ aead_fixed_vs_random_key_open!( 16, 0x13 ); + +macro_rules! header_protection_fixed_vs_random_key { + ($name:ident, $context:ty, $key:ty, $key_len:expr, $fixed_key:expr) => { + fn $name(runner: &mut CtRunner, rng: &mut BenchRng) { + let sample = [0x6d; 16]; + let mut inputs = Vec::with_capacity(samples()); + for class in balanced_classes(rng, samples()) { + let key = if matches!(class, Class::Left) { + $fixed_key + } else { + rand_array::<$key_len>(rng) + }; + inputs.push((class, key)); + } + + for (class, key) in inputs { + runner.run_one(class, || { + let key = <$key>::from_bytes(key); + let context = <$context>::new(&key); + core::hint::black_box(context.mask(&sample))[0] + }); + } + } + }; +} + +header_protection_fixed_vs_random_key!( + aes128_header_protection_fixed_vs_random_key, + Aes128HeaderProtection, + Aes128HeaderProtectionKey, + 16, + [0x61; 16] +); +header_protection_fixed_vs_random_key!( + aes256_header_protection_fixed_vs_random_key, + Aes256HeaderProtection, + Aes256HeaderProtectionKey, + 32, + [0x62; 32] +); +header_protection_fixed_vs_random_key!( + chacha20_header_protection_fixed_vs_random_key, + ChaCha20HeaderProtection, + ChaCha20HeaderProtectionKey, + 32, + [0x63; 32] +); aead_fixed_vs_random_key_open!( aes256gcm_fixed_vs_random_key_open, Aes256Gcm, @@ -2236,6 +2287,18 @@ ctbench_main_with_seeds!( (xchacha20poly1305_fixed_vs_random_key_open, Some(0x7863686163686132)), (aegis256_fixed_vs_random_key_open, Some(0x61656769736f706e)), (ascon_aead128_fixed_vs_random_key_open, Some(0x6173636f6e6f706e)), + ( + aes128_header_protection_fixed_vs_random_key, + Some(0x6870616573313238) + ), + ( + aes256_header_protection_fixed_vs_random_key, + Some(0x6870616573323536) + ), + ( + chacha20_header_protection_fixed_vs_random_key, + Some(0x687063686132305f) + ), (x25519_fixed_vs_random_scalar, Some(0x7832353531395f63)), (mlkem512_keygen_secret_noise_fixed_vs_random, Some(0x6d6b3531326b676e)), (mlkem512_encapsulate_fixed_vs_random_coins, Some(0x6d6b353132656e63)), diff --git a/tools/wasm-runtime-vectors/Cargo.toml b/tools/wasm-runtime-vectors/Cargo.toml index 1d9c1dec..ac30dba3 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", "rsa", "websocket-sha1"] } +rscrypto = { path = "../..", default-features = false, features = ["alloc", "hashes", "aes-gcm", "chacha20poly1305", "rsa", "websocket-sha1"] } diff --git a/tools/wasm-runtime-vectors/src/main.rs b/tools/wasm-runtime-vectors/src/main.rs index 0ddae4d1..6d16ada0 100644 --- a/tools/wasm-runtime-vectors/src/main.rs +++ b/tools/wasm-runtime-vectors/src/main.rs @@ -1,6 +1,10 @@ use rscrypto::{ Blake2b512, Blake3, Digest, RsaPrivateKey, RsaPrivateOpError, RsaPssProfile, RsaPublicKeyPolicy, Sha256, Sha512, }; +use rscrypto::aead::expert::header_protection::{ + Aes128HeaderProtection, Aes128HeaderProtectionKey, Aes256HeaderProtection, Aes256HeaderProtectionKey, + ChaCha20HeaderProtection, ChaCha20HeaderProtectionKey, +}; use rscrypto::hashes::legacy::WebSocketAcceptDigest; const RSA_PRIVATE_KEY_PEM: &str = include_str!("../fixtures/rsa2048_private_pkcs1.txt"); @@ -62,6 +66,17 @@ fn decode_base64(input: &str) -> Vec { out } +fn decode_hex(encoded: &str) -> [u8; N] { + assert_eq!(encoded.len(), N.strict_mul(2)); + let mut decoded = [0u8; N]; + for (byte, chunk) in decoded.iter_mut().zip(encoded.as_bytes().as_chunks::<2>().0) { + let high = hex_value(chunk[0]).expect("known vector must contain hexadecimal digits"); + let low = hex_value(chunk[1]).expect("known vector must contain hexadecimal digits"); + *byte = high.strict_shl(4) | low; + } + decoded +} + fn patterned_bytes(len: usize) -> Vec { (0..len) .map(|i| { @@ -182,6 +197,35 @@ fn assert_websocket_accept_digest_matches_rfc_6455() { ); } +fn assert_header_protection_vectors_match_known_outputs() { + // RFC 9001 Appendix A.2 client Initial header-protection vector. + let aes128 = Aes128HeaderProtection::new(&Aes128HeaderProtectionKey::from_bytes(decode_hex( + "9f50449e04a0e810283a1e9933adedd2", + ))); + assert_hex( + &aes128.mask(&decode_hex("d1b1c98dd7689fb8ec11d242b123dc9b")), + "437b9aec36", + ); + + // FIPS 197 Appendix C.3 AES-256 encryption vector, truncated only after encryption. + let aes256 = Aes256HeaderProtection::new(&Aes256HeaderProtectionKey::from_bytes(decode_hex( + "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + ))); + assert_hex( + &aes256.mask(&decode_hex("00112233445566778899aabbccddeeff")), + "8ea2b7ca51", + ); + + // RFC 9001 Appendix A.5 ChaCha20 short-header protection vector. + let chacha20 = ChaCha20HeaderProtection::new(&ChaCha20HeaderProtectionKey::from_bytes(decode_hex( + "25a282b9e82f06f21f488917a4fc8f1b73573685608597d0efcb076b0ab7a7a4", + ))); + assert_hex( + &chacha20.mask(&decode_hex("5e5cd55c41f69080575d7999c25a5bfb")), + "aefefe7d03", + ); +} + #[cfg(target_feature = "simd128")] fn assert_simd128_runtime_caps_are_detected() { assert!(rscrypto::platform::caps().has(rscrypto::platform::caps::wasm::SIMD128)); @@ -195,5 +239,6 @@ fn main() { assert_streaming_hashes_match_oneshot_across_block_boundaries(); assert_rsa_caller_random_signing_roundtrips(); assert_websocket_accept_digest_matches_rfc_6455(); + assert_header_protection_vectors_match_known_outputs(); assert_simd128_runtime_caps_are_detected(); }