From 69a6b0a0f5ad66a28e554c4cb56d04dc2fdffc83 Mon Sep 17 00:00:00 2001 From: LoadingALIAS Date: Tue, 21 Jul 2026 15:11:19 -0400 Subject: [PATCH 1/2] crypto: seal secret-bearing comparison decisions --- .changes/owned-secret-equality.md | 2 +- .../seal-secret-bearing-comparison-4815.md | 11 ++ README.md | 6 + THREAT_MODEL.md | 8 +- ct.toml | 81 ++++++------ docs/constant-time.md | 29 ++++- docs/migration/RustCrypto/aes-gcm.md | 2 +- docs/migration/RustCrypto/ascon-aead.md | 2 +- docs/migration/RustCrypto/chacha20poly1305.md | 2 +- docs/migration/RustCrypto/hmac.md | 8 +- docs/migration/RustCrypto/pbkdf2.md | 6 +- docs/migration/RustCrypto/scrypt.md | 2 +- docs/migration/RustCrypto/x25519-dalek.md | 2 +- docs/migration/blake3.md | 11 +- docs/migration/sha3-kmac.md | 4 +- docs/migration/tiny-keccak.md | 4 +- docs/test-vector-coverage.md | 2 +- examples/mlkem_encapsulation.rs | 2 +- scripts/ct/asm_heuristics.py | 55 +++++--- scripts/ct/evidence_validation_test.py | 48 ++++++- src/aead/aegis256.rs | 14 +-- src/aead/aegis256/s390x_vperm.rs | 2 +- src/aead/aes.rs | 31 ++--- src/aead/aes128gcm.rs | 29 ++--- src/aead/aes128gcmsiv.rs | 23 ++-- src/aead/aes256gcm.rs | 25 ++-- src/aead/aes256gcmsiv.rs | 23 ++-- src/aead/aes_round.rs | 3 +- src/aead/ascon128.rs | 11 +- src/aead/chacha20poly1305.rs | 14 +-- src/aead/ghash.rs | 5 +- src/aead/polyval.rs | 13 +- src/aead/targets.rs | 12 +- src/aead/xchacha20poly1305.rs | 2 +- src/auth/argon2/mod.rs | 13 +- src/auth/ecdsa.rs | 28 ++--- src/auth/ecdsa_aarch64_asm.rs | 16 ++- src/auth/ecdsa_x86_64_asm.rs | 20 +-- src/auth/ed25519.rs | 42 +++---- src/auth/ed25519/aarch64_asm.rs | 4 +- src/auth/ed25519/point.rs | 8 +- src/auth/ed25519/point_avx2.rs | 4 +- src/auth/ed25519/x86_64_asm.rs | 8 +- src/auth/hmac.rs | 54 ++++---- src/auth/hmac_sha3.rs | 29 +++-- src/auth/kmac.rs | 10 +- src/auth/mlkem.rs | 30 ++--- src/auth/mlkem/portable.rs | 8 +- src/auth/mod.rs | 2 +- src/auth/pbkdf2.rs | 10 +- src/auth/poly1305.rs | 25 ++-- src/auth/rsa.rs | 29 ++--- src/auth/scrypt.rs | 15 ++- src/auth/x25519.rs | 30 ++--- src/backend/curve25519.rs | 2 +- src/hashes/crypto/blake3/mod.rs | 38 +++--- src/lib.rs | 82 ++++++++++-- src/macros.rs | 29 ++--- src/traits/aead.rs | 4 +- src/traits/ct.rs | 118 ++++++++++++++++-- src/traits/error.rs | 17 +-- src/traits/kem.rs | 4 +- src/traits/mac.rs | 36 ++---- tests/aead_foundations.rs | 2 +- tests/api_consistency.rs | 7 +- tests/argon2_miri.rs | 2 +- tests/chacha20poly1305.rs | 8 +- tests/hmac_sha256_proptest.rs | 2 +- tests/hmac_sha256_vectors.rs | 10 +- tests/hmac_sha2_family_vectors.rs | 20 +-- tests/hmac_sha384_proptest.rs | 2 +- tests/hmac_sha3_vectors.rs | 16 +-- tests/hmac_sha512_proptest.rs | 2 +- tests/mlkem_ops.rs | 23 ++-- tests/mlkem_properties.rs | 6 +- tests/mlkem_types.rs | 14 ++- tests/owned_equality.rs | 25 ++-- tests/poly1305_vectors.rs | 4 +- tests/root_surface.rs | 8 +- tests/x25519_oracle.rs | 6 +- tests/x25519_vectors.rs | 18 ++- tests/x25519_wycheproof.rs | 7 +- tools/ct-binsec-harness/src/main.rs | 20 +-- tools/ct-harness/src/lib.rs | 2 +- 84 files changed, 861 insertions(+), 552 deletions(-) create mode 100644 .changes/seal-secret-bearing-comparison-4815.md diff --git a/.changes/owned-secret-equality.md b/.changes/owned-secret-equality.md index 264f8f95..b3fd3b9b 100644 --- a/.changes/owned-secret-equality.md +++ b/.changes/owned-secret-equality.md @@ -2,7 +2,7 @@ "rscrypto" = "minor" --- -Secret equality is now owned by fixed-size cryptographic key, tag, and +Secret comparison is now owned by fixed-size cryptographic key, tag, and shared-secret types. The public generic `ConstantTimeEq` trait, arbitrary-slice comparison helper, and slice/array implementations have been removed; `SecretBytes` and `SecretVec` no longer provide equality. diff --git a/.changes/seal-secret-bearing-comparison-4815.md b/.changes/seal-secret-bearing-comparison-4815.md new file mode 100644 index 00000000..14fd1aa3 --- /dev/null +++ b/.changes/seal-secret-bearing-comparison-4815.md @@ -0,0 +1,11 @@ +--- +"rscrypto" = "minor" +--- + +Secret-bearing fixed-size keys, shared secrets, keypairs, authentication tags, +and keyed BLAKE3 outputs no longer implement `PartialEq` or `Eq`. Their inherent +`ct_eq` methods return an opaque `CtDecision`; callers must explicitly consume +it with `declassify()` when revealing equality is intended. Verification APIs +continue to return an opaque `Result`. Diagnostic HMAC and Ascon tag-comparison +helpers now return `CtDecision` instead of `bool`. Custom `Mac` implementations +must now provide `verify`; `Mac::Tag` no longer requires `Eq`. diff --git a/README.md b/README.md index f246ec8e..765854b7 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,12 @@ claim by itself. A claim exists only where the matching signed GitHub release includes an attested `rscrypto-X.Y.Z-ct-evidence.tar.gz` bundle that passes all required gates for that exact version, commit, target, profile, and feature set. +Secret-bearing fixed-size owners do not implement `PartialEq` or `Eq`. Their +`ct_eq` methods return an opaque `CtDecision`; callers must explicitly consume +it with `declassify()` to obtain a branchable bit. Verification APIs keep that +boundary internal and return one opaque `Result`. This is misuse resistance at +the Rust API boundary, not proof about downstream machine code. + The main candidate secret-bearing surfaces in [`ct.toml`](ct.toml) are MAC/tag verification, AEAD authentication failure shape, X25519 scalar multiplication, Ed25519 signing and secret public-key derivation, ECDSA diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 54ca3ff2..cbb30c15 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -19,7 +19,7 @@ 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 equality, and selected +7. MAC/tag verification, fixed-size owner comparison/declassification, and selected password-verification comparisons. Public parsing, raw hashes, checksums, non-cryptographic hashes, public-key @@ -45,7 +45,9 @@ Everything that crosses the boundary: | Build configuration | Cargo features, target features | Trusted. | Outputs are digests, tags, ciphertexts, signatures, derived keys, and opaque -errors. A failed verification returns one success/failure bit and nothing else. +errors. Direct comparison of fixed-size secret-bearing owners returns an opaque +`CtDecision`; only explicit, consuming declassification exposes the equality +bit. A failed verification returns one success/failure result and nothing else. ## Assets @@ -108,7 +110,7 @@ Ordered by exposure to untrusted input: | Wrong output from accelerated kernels | Portable path is the byte-for-byte authority | Portable-vs-accelerated differential tests and native CI | | Timing leakage | Constant-time coding rules on claimed paths | `ct.toml` evidence gate: timing tests, generated-code review, binary checks where supported | | Oracle behavior | Opaque errors, failed-open output wipe, single-bit failure shape | AEAD and verification tests, fuzz targets | -| Secret exposure at rest | Zeroize on drop, masked `Debug`, and fixed-size equality only on semantic secret owners | `src/secret.rs`, `ct.toml`, and per-owner tests | +| Secret exposure at rest | Zeroize on drop, masked `Debug`, and sealed fixed-size comparison only on semantic secret owners | `src/secret.rs`, `ct.toml`, and per-owner tests | | Supply chain | Minimal optional runtime dependencies, `cargo deny`, `cargo audit`, signed tags, Trusted Publishing, release attestations | `deny.toml`, `.github/workflows/release.yaml`, `docs/release.md` | ## Known Gaps diff --git a/ct.toml b/ct.toml index 3ad2a56e..dac497a5 100644 --- a/ct.toml +++ b/ct.toml @@ -43,7 +43,7 @@ public_len_symbols = [ ] formal_owner_widths = [16, 32, 48, 64] formal_limitation = "BINSEC owner kernels cover the representative 16-, 32-, 48-, and 64-byte production monomorphizations. The 28- and ML-KEM-sized owner monomorphizations require linked-binary disassembly/closure and heuristics on every release lane; BINSEC proof kernels are deferred because they add no caller-selectable shape and the large copies exceed the present proof budget." -downstream_limitation = "Evidence binds this final linked harness executable and its exact compiler/linker configuration. It does not claim constant-time behavior for arbitrary downstream binaries that rebuild or inline rscrypto differently. Returning bool remains the T3.4 declassification limitation." +downstream_limitation = "Evidence binds this final linked harness executable and its exact compiler/linker configuration. It does not claim constant-time behavior for arbitrary downstream binaries that rebuild or inline rscrypto differently. Production owner comparisons return an opaque CtDecision and the evidence ABI declassifies only at its retained entrypoints." [[asm_public_operand]] primitive = "password.argon2i" @@ -2591,8 +2591,8 @@ name = "x86_64-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2253 -compiler_api_sha256 = "3911999a7a2d68a4d0dd5e0604f0d8cb78b04c340c36fc39e82ad658f348c9f0" +compiler_api_item_count = 2261 +compiler_api_sha256 = "a08f563d15c951a2286451972cbc524d69100bfe6e3c027d8dbed381d869767f" claim = "ct-intended" physical_timing = "required" binsec = "required" @@ -2603,8 +2603,8 @@ name = "aarch64-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2248 -compiler_api_sha256 = "1179eb9727d21ccfbecbc64bef9e54b2ddb98739991edc1ca1460cd806829ea8" +compiler_api_item_count = 2256 +compiler_api_sha256 = "58d9a255a6c13f093a5a0946be4c6cf3c08125415738d9e2013157cbbb84e186" claim = "ct-intended" physical_timing = "required" binsec = "required" @@ -2663,8 +2663,8 @@ name = "aarch64-apple-darwin" group = "macos" backend = "llvm" linker = "apple-ld-unpinned" -compiler_api_item_count = 2248 -compiler_api_sha256 = "1179eb9727d21ccfbecbc64bef9e54b2ddb98739991edc1ca1460cd806829ea8" +compiler_api_item_count = 2256 +compiler_api_sha256 = "58d9a255a6c13f093a5a0946be4c6cf3c08125415738d9e2013157cbbb84e186" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2688,8 +2688,8 @@ name = "s390x-unknown-linux-gnu" group = "ibm" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2242 -compiler_api_sha256 = "93ae78986ed4ad9b10aa847b8120eceeba8dc72a545d3e4fea581a8c939df5a9" +compiler_api_item_count = 2250 +compiler_api_sha256 = "613c917f01b4ac52fa19f4a3c33f1e73e154d8ce74622f98c3ab74448072b6f8" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2701,8 +2701,8 @@ name = "powerpc64le-unknown-linux-gnu" group = "ibm" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2242 -compiler_api_sha256 = "35dd3622aa03f55488161e245664b40f7245518d187ed4759d97772d2ecd2ff6" +compiler_api_item_count = 2250 +compiler_api_sha256 = "9f6f605b4ad27fe7fcc4de4b830ae99ee4b1ab54e5ccf848da6845c6ecc9a788" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2714,8 +2714,8 @@ name = "riscv64gc-unknown-linux-gnu" group = "linux" backend = "llvm" linker = "platform-default-unpinned" -compiler_api_item_count = 2244 -compiler_api_sha256 = "4da5379ebd04c77e14b8fcc449393c8322af346cf6b80f399e3511fbac8c3cc1" +compiler_api_item_count = 2252 +compiler_api_sha256 = "7b5a444acb4147d30322f21acde5ccf1a671b13420ccbb4820e004fd118d8fb5" claim = "ct-intended" physical_timing = "required" binsec = "unsupported" @@ -2797,19 +2797,20 @@ ci = "artifact-only" [[operation]] id = "secrets.fixed_owner_equality" api = [ - "rscrypto::*Key::eq", - "rscrypto::*SharedSecret::eq", - "rscrypto::*Tag::eq", - "rscrypto::Blake3KeyedHash::eq", - "rscrypto::MlKem*DecapsulationKey::eq", - "rscrypto::MlKem*PreparedDecapsulationKey::eq", + "rscrypto::*Key::ct_eq", + "rscrypto::*SharedSecret::ct_eq", + "rscrypto::*Tag::ct_eq", + "rscrypto::Blake3KeyedHash::ct_eq", + "rscrypto::Ed25519Keypair::ct_eq", + "rscrypto::MlKem*DecapsulationKey::ct_eq", + "rscrypto::MlKem*PreparedDecapsulationKey::ct_eq", ] features = ["algorithm owner feature"] targets = ["all-supported"] secret_inputs = ["left owner bytes", "right owner bytes"] public_inputs = ["concrete owner type and fixed size"] variable_time_components = ["none at the Rust source boundary; optimized lowering is target-specific evidence"] -permitted_leakage = ["owner type", "fixed owner size", "boolean equality result"] +permitted_leakage = ["owner type", "fixed owner size", "explicitly declassified equality result"] claim = "ct-intended" evidence = [ "primitive:owner_equality.fixed", @@ -2818,7 +2819,7 @@ evidence = [ "unit:owner_equality.fixed.48", "unit:owner_equality.fixed.64", ] -limitation = "Returning bool declassifies the comparison result until T3.4. T3.3 owns exact release-binary proof." +limitation = "CtDecision seals the source-level result until explicit declassification. Ed25519Keypair composes its secret and public 32-byte decisions before declassification. T3.3 owns exact release-binary proof; this API boundary does not generalize evidence to downstream codegen." [[operation]] id = "secrets.generic_wrapper_lifecycle" @@ -2903,7 +2904,7 @@ variable_time_components = ["ordinary public-data parsing, formatting, hashing, permitted_leakage = ["all inputs and results are public", "tag equality result"] claim = "best-effort" evidence = ["primitive:public_hashes_and_checksums"] -limitation = "Nonces, encoded tags, and error metadata are public values. Ordinary equality is intentional except tag-owner PartialEq, which maps to the fixed-owner equality operation." +limitation = "Nonces, encoded public values, and error metadata use ordinary equality where useful. Secret-bearing tag owners instead expose ct_eq through the fixed-owner comparison operation." [[operation]] id = "mac.compute_and_finalize" @@ -2919,7 +2920,7 @@ variable_time_components = ["public message-length loops", "public backend dispa permitted_leakage = ["algorithm", "message length", "backend", "returned tag"] claim = "ct-intended" evidence = ["primitive:mac.hmac_verify", "primitive:mac.kmac256_verify", "harness:ct_entry_hmac_sha256_verify", "harness:ct_entry_kmac256_verify"] -limitation = "Mac is implementable downstream. Its default verification inherits the downstream Tag::Eq implementation and cannot prove that implementation's timing behavior." +limitation = "Mac is implementable downstream. Each implementation owns verify; the default verify_tag delegates to it, so downstream verification timing remains outside rscrypto's evidence." [[operation]] id = "mac.verify_typed_tag" @@ -2932,11 +2933,11 @@ features = ["hmac", "hmac-sha3", "poly1305"] targets = ["all-supported"] secret_inputs = ["MAC key/state", "computed tag"] public_inputs = ["message", "typed expected tag", "public lengths"] -variable_time_components = ["MAC computation over public length", "fixed owner equality", "opaque result conversion"] +variable_time_components = ["MAC computation over public length", "sealed fixed-owner comparison", "opaque result conversion"] permitted_leakage = ["message length", "tag size", "opaque verification result"] claim = "ct-intended" evidence = ["primitive:mac.hmac_verify", "unit:mac.hmac_verify.sha256", "unit:mac.hmac_verify.sha384", "unit:mac.hmac_verify.sha512"] -limitation = "The public Mac trait trusts downstream Tag::Eq. Built-in tag owners are checked; downstream implementations are an explicit boundary." +limitation = "The public Mac trait requires each implementation to define verify. Built-in implementations use sealed tag-owner comparisons; downstream implementations are an explicit evidence boundary." [[operation]] id = "kmac.variable_output_and_verify" @@ -2956,7 +2957,7 @@ id = "keyed_hash.compute_and_verify" api = [ "rscrypto::Blake2*::{new_keyed,keyed_digest}", "rscrypto::Blake3::{new_keyed,keyed_digest,derive_key,verify_keyed}", - "rscrypto::Blake3KeyedHash::{from_bytes,to_bytes,as_bytes,eq}", + "rscrypto::Blake3KeyedHash::{from_bytes,to_bytes,as_bytes,ct_eq}", ] features = ["blake2b", "blake2s", "blake3", "parallel"] targets = ["all-supported", "backend selected by public target capabilities"] @@ -3044,11 +3045,11 @@ variable_time_components = ["public backend dispatch", "opaque all-zero shared-s permitted_leakage = ["public key", "backend", "opaque success/failure", "caller-authorized shared-secret exposure"] claim = "ct-intended" evidence = ["primitive:kx.x25519", "harness:ct_entry_x25519", "dudect:x25519_fixed_vs_random_scalar"] -limitation = "Exact assembly and portable lowering are target-specific evidence. Shared-secret comparison remains a bool-returning owner operation until T3.4." +limitation = "Exact assembly and portable lowering are target-specific evidence. Shared-secret comparison returns CtDecision; downstream declassification and codegen remain outside this operation's release evidence." [[operation]] id = "kem.mlkem512" -api = ["rscrypto::MlKem512::{generate_keypair,generate_keypair_with,encapsulate,encapsulate_with,decapsulate}", "rscrypto::MlKem512*::{from_bytes,try_from_slice,to_bytes,as_bytes,expose_secret,duplicate_secret,eq}"] +api = ["rscrypto::MlKem512::{generate_keypair,generate_keypair_with,encapsulate,encapsulate_with,decapsulate}", "rscrypto::MlKem512*::{from_bytes,try_from_slice,to_bytes,as_bytes,expose_secret,duplicate_secret}", "rscrypto::MlKem512{DecapsulationKey,PreparedDecapsulationKey,SharedSecret}::ct_eq", "rscrypto::MlKem512{EncapsulationKey,PreparedEncapsulationKey,Ciphertext}::eq"] features = ["ml-kem", "getrandom", "serde", "serde-secrets"] targets = ["all-supported", "portable implementation"] secret_inputs = ["key-generation secret seed", "decapsulation key", "encapsulation randomness", "shared secret", "implicit-rejection fallback"] @@ -3057,11 +3058,11 @@ variable_time_components = ["public matrix rejection sampling", "public fixed-sh permitted_leakage = ["parameter set", "public key", "ciphertext", "fixed sizes", "caller-authorized secret exposure"] claim = "ct-intended" evidence = ["primitive:kem.mlkem512", "unit:kem.mlkem512.keygen", "unit:kem.mlkem512.encapsulate", "unit:kem.mlkem512.decapsulate"] -limitation = "Public encapsulation-key/ciphertext equality is ordinary equality. Decapsulation-key/shared-secret equality uses their fixed owners." +limitation = "Public encapsulation-key/ciphertext equality is ordinary equality. Decapsulation-key/shared-secret owners return CtDecision and require explicit declassification." [[operation]] id = "kem.mlkem768" -api = ["rscrypto::MlKem768::{generate_keypair,generate_keypair_with,encapsulate,encapsulate_with,decapsulate}", "rscrypto::MlKem768*::{from_bytes,try_from_slice,to_bytes,as_bytes,expose_secret,duplicate_secret,eq}"] +api = ["rscrypto::MlKem768::{generate_keypair,generate_keypair_with,encapsulate,encapsulate_with,decapsulate}", "rscrypto::MlKem768*::{from_bytes,try_from_slice,to_bytes,as_bytes,expose_secret,duplicate_secret}", "rscrypto::MlKem768{DecapsulationKey,PreparedDecapsulationKey,SharedSecret}::ct_eq", "rscrypto::MlKem768{EncapsulationKey,PreparedEncapsulationKey,Ciphertext}::eq"] features = ["ml-kem", "getrandom", "serde", "serde-secrets"] targets = ["all-supported", "portable implementation"] secret_inputs = ["key-generation secret seed", "decapsulation key", "encapsulation randomness", "shared secret", "implicit-rejection fallback"] @@ -3070,11 +3071,11 @@ variable_time_components = ["public matrix rejection sampling", "public fixed-sh permitted_leakage = ["parameter set", "public key", "ciphertext", "fixed sizes", "caller-authorized secret exposure"] claim = "ct-intended" evidence = ["primitive:kem.mlkem768", "unit:kem.mlkem768.keygen", "unit:kem.mlkem768.encapsulate", "unit:kem.mlkem768.decapsulate"] -limitation = "Public encapsulation-key/ciphertext equality is ordinary equality. Decapsulation-key/shared-secret equality uses their fixed owners." +limitation = "Public encapsulation-key/ciphertext equality is ordinary equality. Decapsulation-key/shared-secret owners return CtDecision and require explicit declassification." [[operation]] id = "kem.mlkem1024" -api = ["rscrypto::MlKem1024::{generate_keypair,generate_keypair_with,encapsulate,encapsulate_with,decapsulate}", "rscrypto::MlKem1024*::{from_bytes,try_from_slice,to_bytes,as_bytes,expose_secret,duplicate_secret,eq}"] +api = ["rscrypto::MlKem1024::{generate_keypair,generate_keypair_with,encapsulate,encapsulate_with,decapsulate}", "rscrypto::MlKem1024*::{from_bytes,try_from_slice,to_bytes,as_bytes,expose_secret,duplicate_secret}", "rscrypto::MlKem1024{DecapsulationKey,PreparedDecapsulationKey,SharedSecret}::ct_eq", "rscrypto::MlKem1024{EncapsulationKey,PreparedEncapsulationKey,Ciphertext}::eq"] features = ["ml-kem", "getrandom", "serde", "serde-secrets"] targets = ["all-supported", "portable implementation"] secret_inputs = ["key-generation secret seed", "decapsulation key", "encapsulation randomness", "shared secret", "implicit-rejection fallback"] @@ -3083,7 +3084,7 @@ variable_time_components = ["public matrix rejection sampling", "public fixed-sh permitted_leakage = ["parameter set", "public key", "ciphertext", "fixed sizes", "caller-authorized secret exposure"] claim = "ct-intended" evidence = ["primitive:kem.mlkem1024", "unit:kem.mlkem1024.keygen", "unit:kem.mlkem1024.encapsulate", "unit:kem.mlkem1024.decapsulate"] -limitation = "Public encapsulation-key/ciphertext equality is ordinary equality. Decapsulation-key/shared-secret equality uses their fixed owners." +limitation = "Public encapsulation-key/ciphertext equality is ordinary equality. Decapsulation-key/shared-secret owners return CtDecision and require explicit declassification." [[operation]] id = "signature.ed25519_signing" @@ -3207,18 +3208,18 @@ features = ["always"] variants = ["Owner16", "Owner32", "Owner48", "Owner64"] release_owner_widths = [16, 28, 32, 48, 64, 1632, 2400, 3168] entrypoints = [ - "Aes128GcmKey::eq", - "X25519SecretKey::eq", - "HmacSha384Tag::eq", - "HmacSha512Tag::eq", - "fixed-size secret/key/tag owner Eq implementations", + "Aes128GcmKey::ct_eq", + "X25519SecretKey::ct_eq", + "HmacSha384Tag::ct_eq", + "HmacSha512Tag::ct_eq", + "fixed-size secret/key/tag owner ct_eq implementations", ] secrets = ["left_owner_bytes", "right_owner_bytes"] public = ["concrete_owner_type", "owner_size"] may_leak = ["concrete_owner_type", "owner_size", "equality_result"] must_not_leak_ref = "common_secret_data" required = ["tier_a"] -notes = "Public callers select a semantic owner, never a raw secret length. Source structure and black_box are not machine-code proofs; T3.3 owns exact release-binary evidence. Returning bool remains an explicit T3.4 limitation." +notes = "Public callers select a semantic owner, never a raw secret length. CtDecision is opaque, non-Copy, and explicitly declassified; it prevents an accidental source-level branch but is not a machine-code proof. T3.3 owns exact release-binary evidence." [primitive.harness] status = "covered" symbols = [ @@ -3303,7 +3304,7 @@ id = "keyed_hash.blake3_verify" tier = "A" claim = "ct-intended" features = ["blake3"] -entrypoints = ["Blake3::verify_keyed", "Blake3KeyedHash::eq"] +entrypoints = ["Blake3::verify_keyed", "Blake3KeyedHash::ct_eq"] secrets = ["key", "computed_tag", "expected_tag"] public = ["data"] may_leak = ["input_length", "opaque_success_or_failure"] diff --git a/docs/constant-time.md b/docs/constant-time.md index ba2d428b..2fcb1662 100644 --- a/docs/constant-time.md +++ b/docs/constant-time.md @@ -79,6 +79,24 @@ otherwise: Public length may leak. Public algorithm/profile selection may leak. A single opaque authentication success/failure result may leak. +## Source-Level Decision Boundary + +Secret-bearing fixed-size keys, shared secrets, authentication tags, keypairs, +and keyed outputs do not implement `PartialEq` or `Eq`. Their inherent `ct_eq` +methods return `CtDecision`, an opaque, non-`Copy` value with no public +constructor, formatting, equality, or implicit boolean conversion. Decisions +can be composed with bitwise `&`, `|`, and `!`; the consuming `declassify()` +method is the only public route to a branchable equality bit. + +Verification APIs keep that boundary inside the primitive and return one opaque +`Result`. Public keys, nonces, signatures, and ciphertext containers are public +data and retain ordinary equality where useful. + +This API prevents accidental source-level branching during comparison. It does +not prove the generated machine code. A constant-time claim still requires the +exact compiler, target, CPU features, crate features, profile, linker, and +binary recorded by the matching release evidence. + ## Target Scope A target is not claimed because it builds. It is claimed only when the release @@ -167,12 +185,11 @@ and 64-byte cases. Public-length internal comparisons are mapped in `ct.toml` to retained production entrypoints or to an explicit limitation; an uncovered call is not silently treated as binary evidence. -This executable is an unpublished evidence surface. It proves only its exact -source, toolchain, backend, target, target features, feature set, profile, and -linker configuration. It is not the crate's public API, it is not a sealed -decision type, and it does not generalize to arbitrary downstream binaries. -Equality still returns `bool`; that declassification limitation remains until -T3.4. +This executable is an unpublished evidence surface. It declassifies the +production `CtDecision` only at retained evidence ABI entrypoints. It proves +only its exact source, toolchain, backend, target, target features, feature set, +profile, and linker configuration. It is not the crate's public API and does +not generalize to arbitrary downstream binaries. Assembly triage is grouped by primitive, reachable symbol, finding kind, and artifact. Register-indexed memory is presented first, then conditional control diff --git a/docs/migration/RustCrypto/aes-gcm.md b/docs/migration/RustCrypto/aes-gcm.md index d60f6974..d3da2c22 100644 --- a/docs/migration/RustCrypto/aes-gcm.md +++ b/docs/migration/RustCrypto/aes-gcm.md @@ -132,5 +132,5 @@ cipher.decrypt_in_place(&nonce, aad, &mut buffer, &tag)?; - **Random nonces are unsafe at scale.** With 96-bit nonces, the collision probability after `2^32` messages is around `2^-32`. For random-nonce flows, switch to `XChaCha20Poly1305` (192-bit nonces): see `chacha20poly1305.md`. - **`AeadInPlace` trait import not needed.** RustCrypto requires importing `aead::AeadInPlace` separately to call the `_in_place_detached` methods. rscrypto exposes both shapes through the single `Aead` trait. - **`generic-array` is gone.** rscrypto does not return `GenericArray` from any AEAD method. Tags are typed newtypes (`Aes256GcmTag`) wrapping `[u8; 16]`; key/nonce types wrap `[u8; N]` directly. -- **Hardware acceleration.** Both crates dispatch to AES-NI on x86_64 and AES-CE on aarch64. rscrypto adds VAES (AVX-512), s390x CPACF, and a portable constant-time bitsliced fallback. Force the portable kernel via `RSCRYPTO_AES_GCM_FORCE=portable` (std only) or the crate's `portable-only` feature. +- **Hardware acceleration.** Both crates dispatch to AES-NI on x86_64 and AES-CE on aarch64. rscrypto adds VAES (AVX-512), s390x CPACF, and a portable bitsliced fallback that avoids secret-indexed tables. That source property is not a universal timing proof; constant-time coverage is limited to the compiler, target, features, and binary in the matching [release evidence](../../constant-time.md). Force the portable kernel via `RSCRYPTO_AES_GCM_FORCE=portable` (std only) or the crate's `portable-only` feature. - **`no_std`.** Both crates support `no_std`. rscrypto's combined API requires the caller to provide an output buffer, which fits stack-only embedded use. The `vec!` calls in the examples above are for std convenience; in `no_std` they become fixed-size arrays. diff --git a/docs/migration/RustCrypto/ascon-aead.md b/docs/migration/RustCrypto/ascon-aead.md index 3a506149..76424e20 100644 --- a/docs/migration/RustCrypto/ascon-aead.md +++ b/docs/migration/RustCrypto/ascon-aead.md @@ -81,7 +81,7 @@ cipher.decrypt_in_place(&nonce, aad, &mut buffer, &tag)?; ## Notes - **NIST SP 800-232 finalised on 2025-08-13.** Both crates ship the final spec (the 16-byte key / 16-byte nonce / 16-byte tag layout). Outputs are bit-identical (verified in the harness). -- **Why Ascon?** Designed for severely resource-constrained targets (ATmega, Cortex-M0+, RFID). Code size ~3 KB, no AES tables, no `unsafe` / SIMD requirement, constant-time on every target. If you're embedded, prefer Ascon over AES-GCM where you can. +- **Why Ascon?** Designed for severely resource-constrained targets (ATmega, Cortex-M0+, RFID). Code size ~3 KB, no AES tables, and no `unsafe` / SIMD requirement. The portable source has fixed-work, table-free structure; generated-code constant-time claims are limited to the compiler, target, features, and binary in the matching [release evidence](../../constant-time.md). If you're embedded, prefer Ascon over AES-GCM where you can. - **128-bit key is the only key length.** Ascon-AEAD does not have a 256-bit variant; the 128-bit spec is what NIST standardised. - **Nonce reuse semantics.** Ascon-AEAD-128 is *not* nonce-misuse-resistant. Reusing `(key, nonce)` reveals plaintext XORs. Use a counter or a fresh random 128-bit nonce per message; the larger nonce space (128 bits vs. AES-GCM's 96) makes random nonces safer at high volume. - **No `Payload`, no `KeyInit` import.** Same simplification as the rest of the AEAD lane. diff --git a/docs/migration/RustCrypto/chacha20poly1305.md b/docs/migration/RustCrypto/chacha20poly1305.md index 8ebedea5..c2ffc143 100644 --- a/docs/migration/RustCrypto/chacha20poly1305.md +++ b/docs/migration/RustCrypto/chacha20poly1305.md @@ -115,5 +115,5 @@ cipher.decrypt_in_place(&nonce, aad, &mut buffer, &tag)?; - **96-bit nonce risk: nonce reuse is catastrophic.** With ChaCha20-Poly1305 (96-bit nonce), random-nonce selection gives a `2^-32` collision after `2^32` messages. Use a deterministic counter, or migrate to XChaCha20-Poly1305 (192-bit nonce) where random nonces are safe to ~2^96 messages. - **No `Payload`.** Same simplification as `aes-gcm.md`: positional `aad` and `msg`/`buffer` args. - **`AeadInPlace` import not needed.** rscrypto exposes both combined and in-place shapes through the single `Aead` trait. -- **Software-only acceleration.** ChaCha20 has no hardware AES; both crates use SIMD ChaCha20 implementations. rscrypto runtime-dispatches between SSE2/AVX2/AVX-512 on x86_64 and NEON on aarch64; portable scalar fallback is constant-time and always available. Force portable via `RSCRYPTO_CHACHA20_POLY1305_FORCE=portable` (std only). +- **Software-only acceleration.** ChaCha20 has no hardware AES; both crates use SIMD ChaCha20 implementations. rscrypto runtime-dispatches between SSE2/AVX2/AVX-512 on x86_64 and NEON on aarch64. The always-available portable scalar fallback has fixed-work source structure, but generated-code constant-time coverage is limited to the compiler, target, features, and binary in the matching [release evidence](../../constant-time.md). Force portable via `RSCRYPTO_CHACHA20_POLY1305_FORCE=portable` (std only). - **`no_std`.** Both crates support `no_std`. diff --git a/docs/migration/RustCrypto/hmac.md b/docs/migration/RustCrypto/hmac.md index 45cf0274..763d81db 100644 --- a/docs/migration/RustCrypto/hmac.md +++ b/docs/migration/RustCrypto/hmac.md @@ -1,6 +1,6 @@ # Migration: `hmac` (RustCrypto) → `rscrypto` -> Replace `Hmac::` / `Hmac::` (generic over digest) with named rscrypto types such as `HmacSha256` and `HmacSha3_256`. Key construction is infallible, `finalize()` borrows, and one-shot helpers return typed tags with fixed-size, type-owned equality. +> Replace `Hmac::` / `Hmac::` (generic over digest) with named rscrypto types such as `HmacSha256` and `HmacSha3_256`. Key construction is infallible, `finalize()` borrows, and one-shot helpers return typed tags with sealed comparison decisions. Verified against `hmac = "0.13.0"` and the `rscrypto` 0.6.4 line. Evidence: `tests/hmac_sha256_vectors.rs`, `tests/hmac_sha2_family_vectors.rs`, `tests/hmac_sha3_vectors.rs`, the HMAC proptests, and `tests/hmac_wycheproof.rs`. @@ -88,7 +88,7 @@ let tag = mac.finalize(); // borrows &self `finalize()` borrows in rscrypto; you can read the running tag, keep feeding bytes, and finalize again. `mac.reset()` restores the keyed initial state without rebuilding. -### Constant-time verification +### Opaque verification ```rust // Before @@ -106,7 +106,7 @@ let expected_tag = HmacSha256Tag::from_bytes(expected_tag_bytes); HmacSha256::verify_tag(key, data, &expected_tag)?; // Result<(), VerificationError> ``` -Streaming form: `let mut mac = HmacSha256::new(key); mac.update(data); mac.verify(&expected_tag)?;`. Tags are typed (`HmacSha256Tag`, `HmacSha384Tag`, `HmacSha512Tag`, and the `HmacSha3_*Tag` family), and `==` on those typed tags is constant-time. Prefer `verify` / `verify_tag` for authentication decisions; use `as_bytes()` / `to_bytes()` only at protocol serialization boundaries. +Streaming form: `let mut mac = HmacSha256::new(key); mac.update(data); mac.verify(&expected_tag)?;`. Tags are typed (`HmacSha256Tag`, `HmacSha384Tag`, `HmacSha512Tag`, and the `HmacSha3_*Tag` family) and deliberately do not implement `PartialEq` or `Eq`. Prefer `verify` / `verify_tag` for authentication decisions. Direct tag comparison is explicit: `left.ct_eq(&right)` returns an opaque `CtDecision`, and only `.declassify()` exposes a branchable bit. This source-level boundary is not a universal timing proof; constant-time claims remain limited to the compiler, target, features, and binary in the matching [release evidence](../../constant-time.md). Use `as_bytes()` / `to_bytes()` only at protocol serialization boundaries. ## Notes @@ -115,5 +115,5 @@ Streaming form: `let mut mac = HmacSha256::new(key); mac.update(data); mac.verif - **`finalize` consumes vs. borrows.** Same pattern as `sha2` / `sha3` migrations, except HMAC returns a typed tag rather than a raw byte array. - **`Mac::chain_update` builder pattern.** RustCrypto's `chain_update(data) -> Self` lets you write `mac.chain_update(a).chain_update(b).finalize()`. rscrypto does not currently expose the chained variant; use `.update(a); .update(b);` then `.finalize()`. Same number of statements, no `Self` returns to thread. - **Long keys are pre-hashed (RFC 2104).** Both crates pre-hash any key longer than the underlying hash's block size (64 bytes for SHA-256, 128 bytes for SHA-384/SHA-512, and SHA-3 rates of 144/136/104/72 bytes for SHA3-224/256/384/512). Outputs are byte-identical regardless of key length. -- **`MacError` → `VerificationError`.** RustCrypto's `MacError` is opaque (no detail). rscrypto's `VerificationError` is also opaque: both are designed not to leak timing information through error variants. The names differ; the contract is the same. +- **`MacError` → `VerificationError`.** RustCrypto's `MacError` is opaque (no detail). rscrypto's `VerificationError` is also opaque, so neither exposes an error-variant oracle. Error opacity alone does not prove identical timing. The names differ; the result contract is the same. - **`no_std`.** Both crates support `no_std`. rscrypto's `mac_to_vec` / `finalize_to_vec` helpers are gated on `alloc`; the core fixed-array API works in pure `no_std`. diff --git a/docs/migration/RustCrypto/pbkdf2.md b/docs/migration/RustCrypto/pbkdf2.md index 79757fa1..28de2840 100644 --- a/docs/migration/RustCrypto/pbkdf2.md +++ b/docs/migration/RustCrypto/pbkdf2.md @@ -83,7 +83,7 @@ let k_mac: [u8; 32] = state.derive_array_with_params(mac_params)?; `pbkdf2 = "0.13"` does not expose the precompute as a public type: every `pbkdf2_hmac` call rebuilds the schedule. Migrating to `Pbkdf2Sha256::new(...)` is a free perf win for multi-key derivations. -### Constant-time password verification +### Opaque password verification ```rust // Before @@ -99,10 +99,10 @@ let ok: bool = got.ct_eq(&stored_hash).into(); // After use rscrypto::Pbkdf2Sha256; Pbkdf2Sha256::verify_password(submitted_password, &stored_salt, stored_iters, &stored_hash)?; -// Ok(()) on match, Err(VerificationError) on mismatch: both via constant-time compare +// Ok(()) on match, Err(VerificationError) on mismatch after full tag comparison ``` -Drop the `subtle` dependency for the verify path. The stateful form is `state.verify(salt, iters, &expected)`, which applies the same default policy. +Drop the `subtle` dependency for the verify path. The stateful form is `state.verify(salt, iters, &expected)`, which applies the same default policy. The comparison has content-independent source structure for a public output length, but generated-code constant-time claims remain limited to the compiler, target, features, and binary in the matching [release evidence](../../constant-time.md). ## Notes diff --git a/docs/migration/RustCrypto/scrypt.md b/docs/migration/RustCrypto/scrypt.md index b3c7f4df..8ad62016 100644 --- a/docs/migration/RustCrypto/scrypt.md +++ b/docs/migration/RustCrypto/scrypt.md @@ -109,5 +109,5 @@ Common canonical RustCrypto scrypt records with 32-byte outputs remain verifiabl - `ScryptParams::default()` is `log_n=17, r=8, p=1`, the current [OWASP Password Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html) baseline when Argon2id is unavailable. - The raw algorithm accepts arbitrary salt lengths; generated password records use 16 bytes. - Working buffers are zeroized on drop. Target-size overflow and allocation failure are distinct errors. -- scrypt ROMix uses password-derived, data-dependent memory access and is not a local side-channel constant-time claim. Final verifier comparison is constant-time. +- scrypt ROMix uses password-derived, data-dependent memory access and is not a local side-channel constant-time claim. The final verifier traverses all expected bytes before returning one opaque result; any generated-code timing claim is limited to the exact configuration in the matching [release evidence](../../constant-time.md). - scrypt requires `alloc` and is not FIPS 140-3 approved. diff --git a/docs/migration/RustCrypto/x25519-dalek.md b/docs/migration/RustCrypto/x25519-dalek.md index dd7a96b8..1e2b5b9b 100644 --- a/docs/migration/RustCrypto/x25519-dalek.md +++ b/docs/migration/RustCrypto/x25519-dalek.md @@ -118,5 +118,5 @@ If your protocol requires the ephemeral-only guarantee at the type level, file a - **Low-order rejection: explicit vs implicit.** rscrypto's `Err(X25519Error)` on all-zero shared secret is the safer default because many protocols assume the shared secret is non-zero and skip the check, opening contributory-behaviour attacks. If you migrated *because* of an all-zero-shared-secret incident, the `?` propagation is exactly what you want. - **`SharedSecret` zeroizes on drop.** Both crates do this. rscrypto exposes `expose_secret() -> SecretBytes<32>` if you need to feed the raw bytes into a KDF (and want the zeroization-on-drop guarantee preserved). - **No HKDF integration.** Some protocols KDF the X25519 shared secret immediately (e.g., Noise, Signal). rscrypto's `HkdfSha256` (from `features = ["hkdf"]`) is the natural pairing; see `RustCrypto/hkdf.md`. -- **Constant-time scalar mult.** Both crates use constant-time field arithmetic. rscrypto's portable backend is constant-time on every target; SIMD acceleration (when available) is differential-tested against the portable path. +- **Scalar-multiplication timing.** rscrypto's portable backend uses fixed-work field-arithmetic source structure, and accelerated paths are differential-tested against it. That source property does not prove every compiler/target binary; constant-time coverage is limited to the exact configurations in the matching [release evidence](../../constant-time.md). - **`no_std`.** Both crates support `no_std` with no `alloc` requirement. The `getrandom`-backed `try_generate()` requires `getrandom`; the closure form `try_generate_with(|buf| ...)` does not. diff --git a/docs/migration/blake3.md b/docs/migration/blake3.md index cd981d1a..9f46982d 100644 --- a/docs/migration/blake3.md +++ b/docs/migration/blake3.md @@ -2,7 +2,7 @@ > Same algorithm, same outputs. Replace `blake3::Hasher` with `rscrypto::Blake3`. > Unkeyed hashes return `[u8; 32]`; keyed hashes return `Blake3KeyedHash` so -> equality stays constant-time for authenticator use. +> authenticator comparison requires an explicit sealed decision. Verified against `blake3 = "1.8.5"` and the `rscrypto` 0.5.0 line. Evidence: `tests/blake3_official_vectors.rs` and `tests/blake3_differential.rs`. @@ -84,7 +84,9 @@ let tag = Blake3::keyed_digest(&key, b"message"); assert!(Blake3::verify_keyed(&key, b"message", &tag).is_ok()); ``` -`tag` is a `Blake3KeyedHash`, not a raw array. Its `==` is constant-time. +`tag` is a `Blake3KeyedHash`, not a raw array. It does not implement `==`; +use `tag.ct_eq(&other)` for an opaque `CtDecision`, or prefer +`Blake3::verify_keyed` for an authentication result. Streaming form: `Blake3::new_keyed(&key)` (matches `blake3::Hasher::new_keyed`). ### Key derivation (KDF) @@ -134,7 +136,10 @@ Renames: `reader.fill(&mut out)` → `reader.squeeze(&mut out)`. The one-shot fo because unkeyed hashes are usually content IDs, cache keys, or integrity fingerprints; ordinary array equality is fine there. For authenticator use, `Blake3::keyed_digest` returns `Blake3KeyedHash` and `Blake3::verify_keyed` - verifies it in constant time. + traverses the fixed-size tag before returning one opaque result. This is a + source-level boundary, not a universal timing proof; constant-time claims are + limited to the compiler, target, features, and binary in the matching + [release evidence](../constant-time.md). - **`finalize` borrows in both crates.** `blake3::Hasher::finalize(&self)` and `Blake3::finalize(&self)` are both idempotent: call repeatedly without rebuilding. - **Parallel hashing.** `blake3` ships a `rayon` feature for multi-threaded chunk hashing. rscrypto exposes the same via the `parallel` umbrella feature (`features = ["blake3", "parallel"]`); the public API is unchanged. - **`std::io::Write`.** `blake3::Hasher: io::Write`. `rscrypto::Blake3` diff --git a/docs/migration/sha3-kmac.md b/docs/migration/sha3-kmac.md index 209e521f..4e5e49fd 100644 --- a/docs/migration/sha3-kmac.md +++ b/docs/migration/sha3-kmac.md @@ -103,7 +103,7 @@ let mut tag = [0u8; 64]; Kmac256::mac_into(key, custom, data, &mut tag); // 64-byte tag, distinct from 32-byte tag ``` -### Constant-time verification +### Opaque verification ```rust // Before @@ -122,7 +122,7 @@ use rscrypto::Kmac256; Kmac256::verify_tag(key, custom, data, &expected)?; // Result<(), VerificationError> ``` -Streaming form: `let mut k = Kmac256::new(key, custom); k.update(data); k.verify(&expected)?;`. Drop the `subtle` dependency. +Streaming form: `let mut k = Kmac256::new(key, custom); k.update(data); k.verify(&expected)?;`. Drop the `subtle` dependency. Verification traverses the public-length expected tag before returning one opaque result; generated-code constant-time claims remain limited to the exact configuration in the matching [release evidence](../constant-time.md). ## Notes diff --git a/docs/migration/tiny-keccak.md b/docs/migration/tiny-keccak.md index 5093bfc6..1ebfa4e5 100644 --- a/docs/migration/tiny-keccak.md +++ b/docs/migration/tiny-keccak.md @@ -108,7 +108,7 @@ let mut tag = [0u8; 64]; Kmac256::mac_into(key, custom, data, &mut tag); ``` -### KMAC256: constant-time verification +### KMAC256: opaque verification ```rust // Before @@ -129,7 +129,7 @@ use rscrypto::Kmac256; Kmac256::verify_tag(key, custom, data, &expected)?; // Result<(), VerificationError> ``` -Drop the `subtle` dependency for the verify path. Streaming form: `let mut k = Kmac256::new(key, custom); k.update(data); k.verify(&expected)?;`. +Drop the `subtle` dependency for the verify path. Streaming form: `let mut k = Kmac256::new(key, custom); k.update(data); k.verify(&expected)?;`. Verification traverses the public-length expected tag before returning one opaque result; generated-code constant-time claims remain limited to the exact configuration in the matching [release evidence](../constant-time.md). ### cSHAKE256: XOF streaming diff --git a/docs/test-vector-coverage.md b/docs/test-vector-coverage.md index b9e685f7..5b2ec634 100644 --- a/docs/test-vector-coverage.md +++ b/docs/test-vector-coverage.md @@ -68,7 +68,7 @@ the concrete inputs and outputs of each cryptography API. | ECDSA P-256/P-384 signing and verification | `tests/ecdsa_oracle.rs`; `src/auth/ecdsa.rs` unit tests; RustCrypto `p256 0.14.0` / `p384 0.13.1` oracles; `fuzz/target_impls/auth_ecdsa_verify.rs`; `fuzz/target_impls/auth_ecdsa_sign.rs` | Invalid SEC1 public keys, malformed SPKI, malformed DER signatures, zero/out-of-range scalars, tampered signatures, wrong messages, deterministic signing, blinded signing, low-S normalization, public-key derivation, and fuzz parser/differential coverage | CT evidence covers blinded signing. Public verification remains public-input work unless promoted by the CT manifest. Wycheproof ECDSA vectors are not mapped yet. | | Ed25519 | `tests/ed25519_rfc8032_vectors.rs`, `tests/ed25519_oracle.rs` | `tests/ed25519_wycheproof.rs` covers Wycheproof valid/invalid signatures and invalid public/signature encodings; unit tests cover small-order and non-canonical signatures | Current suite maps directly | | X25519 | `tests/x25519_vectors.rs`, `tests/x25519_oracle.rs` | `tests/x25519_wycheproof.rs` covers Wycheproof valid/acceptable XDH vectors and rejects all-zero shared secrets; RFC low-order and non-canonical public cases remain in `tests/x25519_vectors.rs` | ASN/JWK/PEM suites do not apply to byte-array API | -| ML-KEM-512/768/1024 | `tests/mlkem_acvp.rs` covers NIST ACVP FIPS 203 keyGen, encapsulation, decapsulation, decapsulationKeyCheck, and encapsulationKeyCheck vectors for all parameter sets; `tests/mlkem_properties.rs` differentials arbitrary seeds against the `fips203` crate; `tests/mlkem_types.rs` checks FIPS 203 sizes, randomness, security categories, byte wrappers, secret redaction, public-value equality, and fixed secret-owner equality | `tests/mlkem_ops.rs` covers non-canonical public-key rejection before randomness, prepared-key parity, prepared-key invalid material, wrong-length parsers, decapsulation-key hash mismatch, and modified-ciphertext implicit rejection; `fuzz/target_impls/auth_mlkem512.rs`, `auth_mlkem768.rs`, and `auth_mlkem1024.rs` cover round trips, parser inputs, and modified ciphertexts | No vendored Wycheproof ML-KEM suite is currently mapped; official ACVP vectors plus all-profile FIPS 203 differential/property coverage are the primary oracle set | +| ML-KEM-512/768/1024 | `tests/mlkem_acvp.rs` covers NIST ACVP FIPS 203 keyGen, encapsulation, decapsulation, decapsulationKeyCheck, and encapsulationKeyCheck vectors for all parameter sets; `tests/mlkem_properties.rs` differentials arbitrary seeds against the `fips203` crate; `tests/mlkem_types.rs` checks FIPS 203 sizes, randomness, security categories, byte wrappers, secret redaction, public-value equality, and sealed secret-owner comparison | `tests/mlkem_ops.rs` covers non-canonical public-key rejection before randomness, prepared-key parity, prepared-key invalid material, wrong-length parsers, decapsulation-key hash mismatch, and modified-ciphertext implicit rejection; `fuzz/target_impls/auth_mlkem512.rs`, `auth_mlkem768.rs`, and `auth_mlkem1024.rs` cover round trips, parser inputs, and modified ciphertexts | No vendored Wycheproof ML-KEM suite is currently mapped; official ACVP vectors plus all-profile FIPS 203 differential/property coverage are the primary oracle set | | RSA signatures | `tests/rsa_wycheproof.rs`, `tests/rsa_nist_cavp.rs`, `tests/rsa_public_key.rs` | Wycheproof invalid PKCS#1 v1.5/PSS signatures; `tests/rsa_profile_confusion.rs` rejects PKCS#1/PSS and protocol-scheme confusion | RSA-PSS parameter Wycheproof suites are partly not mapped because the public profile supports SHA-2 fixed profiles | | RSA OAEP / RSAES-PKCS1-v1_5 | `tests/rsa_wycheproof.rs`, `tests/rsa_public_key.rs` | Wycheproof invalid ciphertexts; scratch decrypt failure clears plaintext; unsupported MGF1-SHA1 vectors reject | Current SHA-2 OAEP suites map directly | | RSA key parsing / X.509 / TLS / COSE adapters | `tests/rsa_public_key.rs`, `tests/rsa_allocations.rs`, `tests/rsa_leakage.rs` | DER non-canonical forms, unsupported algorithms, policy boundaries, profile confusion, and leakage gate | Keep these tests explicit because the attack surface is protocol/profile confusion, not only raw RSA math | diff --git a/examples/mlkem_encapsulation.rs b/examples/mlkem_encapsulation.rs index cf890cfe..83721d4d 100644 --- a/examples/mlkem_encapsulation.rs +++ b/examples/mlkem_encapsulation.rs @@ -7,7 +7,7 @@ fn main() -> Result<(), Box> { let (ciphertext, shared_secret) = MlKem768::try_encapsulate(&encapsulation_key)?; let decapsulated = MlKem768::decapsulate(&decapsulation_key, &ciphertext)?; - assert_eq!(shared_secret, decapsulated); + assert!(shared_secret.ct_eq(&decapsulated).declassify()); println!( "ML-KEM-768 encapsulated {} shared-secret bytes", shared_secret.as_bytes().len() diff --git a/scripts/ct/asm_heuristics.py b/scripts/ct/asm_heuristics.py index 5eb1d6ed..2ec4f2b9 100755 --- a/scripts/ct/asm_heuristics.py +++ b/scripts/ct/asm_heuristics.py @@ -426,13 +426,24 @@ def apply_waivers(findings: list[dict[str, Any]], configured: list[dict[str, Any ) accepted_primitives = {row["primitive"] for row in accepted} - unresolved = sorted(set(finding.get("primitive_ids", [])) - accepted_primitives) + public_classification = finding.get("public_classification") + public_primitives = ( + {public_classification["primitive"]} + if isinstance(public_classification, dict) + and public_classification.get("primitive") in finding.get("primitive_ids", []) + else set() + ) + resolved_primitives = accepted_primitives | public_primitives + unresolved = sorted(set(finding.get("primitive_ids", [])) - resolved_primitives) finding["waivers"] = accepted finding["unresolved_primitive_ids"] = unresolved finding["waived"] = not unresolved and bool(accepted) - if finding["waived"]: + if not unresolved and resolved_primitives: finding["operand_class"] = "public" finding["disposition"] = "accepted" + elif unresolved and finding["disposition"] == "accepted": + finding["operand_class"] = "unproven" + finding["disposition"] = "needs-fix" if finding["severity"] == "fail" else "needs-binsec" return [f"asm_waiver[{index}] did not match generated disassembly" for index in range(len(configured)) if index not in used] @@ -1026,15 +1037,17 @@ def summarize( rows = {} for symbol in sorted(symbols): symbol_findings = findings_by_symbol.get(symbol, []) - unwaived = [item for item in symbol_findings if not item.get("waived")] + unresolved = [ + item for item in symbol_findings if not item.get("waived") and item["disposition"] != "accepted" + ] rows[symbol] = { "present": symbol in functions, "instruction_lines": len(functions[symbol].lines) if symbol in functions else 0, "finding_count": len(symbol_findings), - "unwaived_fail_count": sum(1 for item in unwaived if item["severity"] == "fail"), - "unwaived_warn_count": sum(1 for item in unwaived if item["severity"] == "warn"), - "needs_fix_count": sum(1 for item in unwaived if item["disposition"] == "needs-fix"), - "needs_binsec_count": sum(1 for item in unwaived if item["disposition"] == "needs-binsec"), + "unwaived_fail_count": sum(1 for item in unresolved if item["severity"] == "fail"), + "unwaived_warn_count": sum(1 for item in unresolved if item["severity"] == "warn"), + "needs_fix_count": sum(1 for item in unresolved if item["disposition"] == "needs-fix"), + "needs_binsec_count": sum(1 for item in unresolved if item["disposition"] == "needs-binsec"), "accepted_count": sum(1 for item in symbol_findings if item["disposition"] == "accepted"), } return rows @@ -1045,23 +1058,31 @@ def summarize_closure( functions: dict[str, FunctionBody], findings: list[dict[str, Any]], ) -> dict[str, Any]: - unwaived = [item for item in findings if not item.get("waived")] by_primitive: dict[str, dict[str, Any]] = {} for primitive_id, roots in sorted(closures.items()): reachable = sorted({symbol for symbols in roots.values() for symbol in symbols}) primitive_findings = [item for item in findings if primitive_id in item.get("primitive_ids", [])] - primitive_unwaived = [item for item in unwaived if primitive_id in item.get("primitive_ids", [])] + primitive_unresolved = [ + item + for item in primitive_findings + if not item.get("waived") + and primitive_id in item.get("unresolved_primitive_ids", item.get("primitive_ids", [])) + ] by_primitive[primitive_id] = { "root_symbols": sorted(roots), "reachable_symbol_count": len(reachable), "reachable_symbols": reachable, "missing_root_symbols": sorted(root for root in roots if root not in functions), "finding_count": len(primitive_findings), - "unwaived_fail_count": sum(1 for item in primitive_unwaived if item["severity"] == "fail"), - "unwaived_warn_count": sum(1 for item in primitive_unwaived if item["severity"] == "warn"), - "needs_fix_count": sum(1 for item in primitive_unwaived if item["disposition"] == "needs-fix"), - "needs_binsec_count": sum(1 for item in primitive_unwaived if item["disposition"] == "needs-binsec"), - "accepted_count": sum(1 for item in primitive_findings if primitive_id not in item["unresolved_primitive_ids"]), + "unwaived_fail_count": sum(1 for item in primitive_unresolved if item["severity"] == "fail"), + "unwaived_warn_count": sum(1 for item in primitive_unresolved if item["severity"] == "warn"), + "needs_fix_count": sum(1 for item in primitive_unresolved if item["disposition"] == "needs-fix"), + "needs_binsec_count": sum(1 for item in primitive_unresolved if item["disposition"] == "needs-binsec"), + "accepted_count": sum( + 1 + for item in primitive_findings + if primitive_id not in item.get("unresolved_primitive_ids", item.get("primitive_ids", [])) + ), } return by_primitive @@ -1248,7 +1269,11 @@ def main() -> int: unwaived_failures = [ item for item in findings if item["severity"] == "fail" and item["disposition"] != "accepted" and not item.get("waived") ] - unwaived_warnings = [item for item in findings if item["severity"] == "warn" and not item.get("waived")] + unwaived_warnings = [ + item + for item in findings + if item["severity"] == "warn" and item["disposition"] != "accepted" and not item.get("waived") + ] needs_fix = [item for item in findings if item["disposition"] == "needs-fix" and not item.get("waived")] needs_binsec = [item for item in findings if item["disposition"] == "needs-binsec" and not item.get("waived")] accepted = [item for item in findings if item["disposition"] == "accepted"] diff --git a/scripts/ct/evidence_validation_test.py b/scripts/ct/evidence_validation_test.py index 0465343e..f0c32d73 100644 --- a/scripts/ct/evidence_validation_test.py +++ b/scripts/ct/evidence_validation_test.py @@ -7,7 +7,13 @@ from pathlib import Path import validate as manifest_validation -from asm_heuristics import apply_public_operand_rules +from asm_heuristics import ( + FunctionBody, + apply_public_operand_rules, + apply_waivers, + summarize, + summarize_closure, +) from provenance import codegen_value from symbolize_linked_binary import Symbol, parse_indirect_symbols, parse_link_map, symbolize from validate_release_evidence import ( @@ -212,11 +218,13 @@ def required_target_without_snapshot(manifest) -> None: finding = { "symbol": "rscrypto::auth::argon2::fill_segment_inner", "kind": "variable_latency_division", + "severity": "fail", "primitive_ids": ["password.argon2i"], "roots": ["ct_entry_argon2i_verify"], "locator": "fixture", "operand_class": "unproven", "disposition": "needs-fix", + "waived": False, } rule = { "primitive": "password.argon2i", @@ -229,6 +237,44 @@ def required_target_without_snapshot(manifest) -> None: } assert apply_public_operand_rules([finding], [rule]) == [] assert finding["operand_class"] == "public" and finding["disposition"] == "accepted" + assert apply_waivers([finding], [], "aarch64-apple-darwin") == [] + assert finding["unresolved_primitive_ids"] == [] + + functions = { + finding["symbol"]: FunctionBody(finding["symbol"], Path("fixture"), 0, []), + rule["root"]: FunctionBody(rule["root"], Path("fixture"), 0, []), + } + symbol_summary = summarize(set(functions), functions, [finding])[finding["symbol"]] + assert symbol_summary["unwaived_fail_count"] == 0 + assert symbol_summary["accepted_count"] == 1 + + closures = {rule["primitive"]: {rule["root"]: set(functions)}} + primitive_summary = summarize_closure(closures, functions, [finding])[rule["primitive"]] + assert primitive_summary["unwaived_fail_count"] == 0 + assert primitive_summary["accepted_count"] == 1 + + mixed = dict( + finding, + primitive_ids=[rule["primitive"], "fixture.unresolved"], + operand_class="unproven", + disposition="needs-fix", + waived=False, + ) + assert apply_public_operand_rules([mixed], [rule]) == [] + assert apply_waivers([mixed], [], "aarch64-apple-darwin") == [] + assert mixed["unresolved_primitive_ids"] == ["fixture.unresolved"] + assert mixed["operand_class"] == "unproven" and mixed["disposition"] == "needs-fix" + + mixed_closures = { + rule["primitive"]: {rule["root"]: set(functions)}, + "fixture.unresolved": {rule["root"]: set(functions)}, + } + mixed_summary = summarize_closure(mixed_closures, functions, [mixed]) + assert mixed_summary[rule["primitive"]]["unwaived_fail_count"] == 0 + assert mixed_summary[rule["primitive"]]["accepted_count"] == 1 + assert mixed_summary["fixture.unresolved"]["unwaived_fail_count"] == 1 + assert mixed_summary["fixture.unresolved"]["accepted_count"] == 0 + extra = dict(finding, locator="fixture-2", operand_class="unproven", disposition="needs-fix") assert apply_public_operand_rules([finding, extra], [rule]) diff --git a/src/aead/aegis256.rs b/src/aead/aegis256.rs index 8ca06513..a47a615a 100644 --- a/src/aead/aegis256.rs +++ b/src/aead/aegis256.rs @@ -290,12 +290,12 @@ define_aead_tag_type!(Aegis256Tag, TAG_SIZE, "AEGIS-256 128-bit authentication t /// /// # Security /// -/// On x86_64 (AES-NI), aarch64 (AES-CE), and POWER (vcipher), all AES -/// round operations use constant-time hardware instructions. On RISC-V -/// without hardware AES extensions (Zkne / Zvkned), the implementation -/// falls back to the constant-time portable round function instead of -/// secret-indexed lookup tables. That fallback is much slower, but it -/// avoids the cache-timing side channel. +/// On x86_64 (AES-NI), aarch64 (AES-CE), and POWER (vcipher), AES rounds use +/// dedicated hardware instructions. On RISC-V without hardware AES extensions +/// (Zkne / Zvkned), the implementation falls back to a fixed-work portable +/// source implementation without secret-indexed lookup tables. These source +/// and ISA properties are necessary, not sufficient: generated-code timing +/// claims are configuration- and release-evidence-bound; see `ct.toml`. /// /// # Examples /// @@ -667,7 +667,7 @@ impl Aead for Aegis256 { )))] let computed = decrypt_portable(key, nonce, aad, buffer); - if !ct::fixed_eq(&computed, tag.as_bytes()) { + if !ct::fixed_eq(&computed, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/aegis256/s390x_vperm.rs b/src/aead/aegis256/s390x_vperm.rs index 2f2ca4f5..11c7fe59 100644 --- a/src/aead/aegis256/s390x_vperm.rs +++ b/src/aead/aegis256/s390x_vperm.rs @@ -187,7 +187,7 @@ unsafe fn vperm_z(table: i64x2, indices: i64x2, mask_0f: i64x2) -> i64x2 { } /// Single AES round via Hamburg vperm: SubBytes + ShiftRows + MixColumns + -/// AddRoundKey. Constant-time — all register-to-register, no table lookups. +/// AddRoundKey using only register-to-register operations and no table lookups. /// /// # Safety /// Requires the s390x vector facility (z13+). diff --git a/src/aead/aes.rs b/src/aead/aes.rs index dbd809c5..defe4a89 100644 --- a/src/aead/aes.rs +++ b/src/aead/aes.rs @@ -1,16 +1,17 @@ #![allow(clippy::indexing_slicing)] -//! Portable constant-time AES block cipher core with hardware dispatch. +//! Portable table-free AES block-cipher core with hardware dispatch. //! //! This module provides AES-128 and AES-256 key expansion and single-block -//! encryption for use by AES-based AEAD constructions (GCM, GCM-SIV). All -//! operations are constant-time: no table lookups indexed by secret data. +//! encryption for use by AES-based AEAD constructions (GCM, GCM-SIV). The Rust +//! source uses fixed operation schedules and no secret-indexed table lookups; +//! generated-code timing claims remain bound to `ct.toml` release evidence. //! //! On x86_64 with AES-NI, aarch64 with AES-CE, or RISC-V with scalar/vector //! crypto AES, the hardware path is selected at key-expansion time. The //! portable S-box uses algebraic inversion in GF(2^8) via the Fermat power -//! chain (x^254) and constant-time field arithmetic, avoiding any lookup -//! tables that could leak through cache timing. +//! chain (x^254) and fixed-schedule field arithmetic, avoiding lookup tables +//! that could leak through cache timing. /// AES block size in bytes. pub(crate) const BLOCK_SIZE: usize = 16; @@ -128,7 +129,7 @@ enum KeyInner { Riscv64ScalarCrypto(rv_scalar_aes::RvScalarRoundKeys), #[cfg(target_arch = "riscv64")] Riscv64VectorCrypto(rv_aes::RvRoundKeys), - /// Hamburg vperm via vrgather.vv -- uses portable key schedule, constant-time. + /// Hamburg vperm via `vrgather.vv` with the table-free portable key schedule. #[cfg(target_arch = "riscv64")] #[allow(dead_code)] // V-only AES is kept for GCM-SIV and explicit diagnostic paths; GCM does not select it yet. Riscv64Vperm([u32; EXPANDED_KEY_WORDS]), @@ -212,7 +213,7 @@ enum Key128Inner { Riscv64ScalarCrypto(rv_scalar_aes::RvScalar128RoundKeys), #[cfg(target_arch = "riscv64")] Riscv64VectorCrypto(rv_aes::Rv128RoundKeys), - /// Hamburg vperm via vrgather.vv -- uses portable key schedule, constant-time. + /// Hamburg vperm via `vrgather.vv` with the table-free portable key schedule. #[cfg(target_arch = "riscv64")] #[allow(dead_code)] // V-only AES is kept for GCM-SIV and explicit diagnostic paths; GCM does not select it yet. Riscv64Vperm([u32; EXPANDED_KEY_WORDS_128]), @@ -268,19 +269,19 @@ impl Drop for Aes128EncKey { } } -// Constant-time GF(2^8) arithmetic for the AES S-box +// Fixed-schedule GF(2^8) arithmetic for the AES S-box. /// Multiply two elements in GF(2^8) mod the AES irreducible polynomial /// p(x) = x^8 + x^4 + x^3 + x + 1 (0x11b). /// -/// Constant-time: fixed iteration count, no secret-dependent branches. +/// The Rust source has a fixed operation count and no secret-dependent branches. #[inline(always)] const fn gf256_mul(a: u8, b: u8) -> u8 { // Schoolbook carryless multiply into a u16, then reduce. let a = a as u16; let b = b as u16; - // Accumulate partial products (unrolled, constant-time). + // Accumulate partial products with a fixed unrolled schedule. let mut prod: u16 = 0; prod ^= a.wrapping_mul(b & 1); prod ^= (a << 1).wrapping_mul((b >> 1) & 1); @@ -313,7 +314,7 @@ const fn gf256_sq(x: u8) -> u8 { /// Compute x^(-1) in GF(2^8) via the Fermat power chain: x^254. /// /// Returns 0 for input 0 (matching the AES S-box convention). -/// Constant-time: always executes the same operations regardless of input. +/// The Rust source always executes the same operations regardless of input. #[inline(always)] const fn gf256_inv(x: u8) -> u8 { // Addition chain for 254 = 2+4+8+16+32+64+128: @@ -337,7 +338,7 @@ const fn gf256_inv(x: u8) -> u8 { /// AES forward S-box: S(x) = affine(x^{-1}). /// /// Computes the inverse in GF(2^8), then applies the AES affine transform. -/// Constant-time: no table lookups, fixed operations. +/// The Rust source has no table lookups and a fixed operation schedule. #[inline(always)] const fn sbox(x: u8) -> u8 { let inv = gf256_inv(x); @@ -571,7 +572,7 @@ fn riscv64_fixslice_key_inner_128(key: &[u8; KEY_SIZE_128]) -> Key128Inner { /// /// Mirrors [`aes256_expand_key`]: each backend converts to its native /// hardware round-key format at expansion time when its capability is -/// detected; otherwise the constant-time portable schedule is used. +/// detected; otherwise the table-free portable schedule is used. #[inline] pub(crate) fn aes128_expand_key(key: &[u8; KEY_SIZE_128]) -> Aes128EncKey { #[cfg(target_arch = "x86_64")] @@ -670,7 +671,7 @@ pub(crate) fn aes256_expand_key_riscv_vperm(key: &[u8; KEY_SIZE]) -> Aes256EncKe #[cfg(all(target_arch = "riscv64", feature = "aes-gcm-siv"))] #[inline] pub(crate) fn aes256_expand_key_riscv_ttable(key: &[u8; KEY_SIZE]) -> Aes256EncKey { - // Preserve the old RISC-V ttable entry point while routing to the constant-time fixslice schedule. + // Preserve the old RISC-V ttable entry point while routing to the table-free fixslice schedule. Aes256EncKey { inner: riscv64_fixslice_key_inner(key), } @@ -709,7 +710,7 @@ pub(crate) fn aes128_expand_key_riscv_vperm(key: &[u8; KEY_SIZE_128]) -> Aes128E #[cfg(all(target_arch = "riscv64", feature = "aes-gcm-siv"))] #[inline] pub(crate) fn aes128_expand_key_riscv_ttable(key: &[u8; KEY_SIZE_128]) -> Aes128EncKey { - // Preserve the old RISC-V ttable entry point while routing to the constant-time fixslice schedule. + // Preserve the old RISC-V ttable entry point while routing to the table-free fixslice schedule. Aes128EncKey { inner: riscv64_fixslice_key_inner_128(key), } diff --git a/src/aead/aes128gcm.rs b/src/aead/aes128gcm.rs index eaf6e6db..0c7c0cf6 100644 --- a/src/aead/aes128gcm.rs +++ b/src/aead/aes128gcm.rs @@ -33,8 +33,8 @@ define_aead_tag_type!(Aes128GcmTag, TAG_SIZE, "AES-128-GCM authentication tag (1 /// nonce uniqueness rules, same 96-bit IV, same combined and detached /// surfaces. AES-128 has the same multi-target hardware acceleration — /// AES-NI / VAES, AES-CE, CPACF KM, POWER vcipher, RV64 Zvkned / Zkne / -/// vperm / fixslice — and falls back to the constant-time portable -/// schedule when no hardware path is available. +/// vperm / fixslice — and falls back to the table-free portable schedule when +/// no hardware path is available. /// /// # Nonce Uniqueness /// @@ -101,11 +101,12 @@ define_aead_tag_type!(Aes128GcmTag, TAG_SIZE, "AES-128-GCM authentication tag (1 /// /// # Security /// -/// On x86_64 (AES-NI), aarch64 (AES-CE), and s390x (CPACF), all AES -/// operations use constant-time hardware instructions. On RISC-V without -/// hardware AES extensions (Zkne / Zvkned), encryption falls back to the -/// constant-time portable / fixslice implementation. That path is slower, -/// but it avoids secret-indexed lookup tables. +/// On x86_64 (AES-NI), aarch64 (AES-CE), and s390x (CPACF), AES operations use +/// dedicated hardware instructions. On RISC-V without hardware AES extensions +/// (Zkne / Zvkned), encryption falls back to a fixed-work portable / fixslice +/// source implementation that avoids secret-indexed lookup tables. These +/// source and ISA properties are necessary, not sufficient: generated-code +/// timing claims are configuration- and release-evidence-bound; see `ct.toml`. pub struct Aes128Gcm { /// Pre-expanded AES-128 round keys. ek: aes::Aes128EncKey, @@ -872,7 +873,7 @@ impl Aead for Aes128Gcm { // 2. `acc` and `h_polyval` are initialized GHASH field elements. acc = unsafe { polyval::x86_clmul128_reduce_inline(acc, h_polyval) }; let expected = encrypt_j0_tag(&self.ek, &j0, acc); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -904,7 +905,7 @@ impl Aead for Aes128Gcm { // SAFETY: x86 GHASH final multiply because PCLMUL_READY was checked above. acc = unsafe { polyval::x86_clmul128_reduce_inline(acc, h_polyval) }; let expected = encrypt_j0_tag(&self.ek, &j0, acc); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -946,7 +947,7 @@ impl Aead for Aes128Gcm { // 2. `acc` and `h_polyval` are initialized GHASH field elements. acc = unsafe { polyval::aarch64_clmul128_reduce_inline(acc, h_polyval) }; let expected = encrypt_j0_tag(&self.ek, &j0, acc); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -972,7 +973,7 @@ impl Aead for Aes128Gcm { // SAFETY: POWER8 carryless multiply because backend resolution confirmed POWER8 crypto. acc = unsafe { polyval::ppc_clmul128_reduce_inline(acc, h_polyval) }; let expected = encrypt_j0_tag(&self.ek, &j0, acc); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -986,7 +987,7 @@ impl Aead for Aes128Gcm { compute_tag_short_wide(&self.ek, self.h_powers_rev[3], &self.h_powers_rev, &j0, aad, buffer) .map_err(|_| OpenError::too_large())? { - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -996,7 +997,7 @@ impl Aead for Aes128Gcm { if should_use_wide_ghash(self.backend, aad.len(), buffer.len()) { let expected = compute_tag_wide(&self.ek, self.h_powers_rev[3], &self.h_powers_rev, &j0, aad, buffer) .map_err(|_| OpenError::too_large())?; - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -1004,7 +1005,7 @@ impl Aead for Aes128Gcm { return Ok(()); } let expected = compute_tag(&self.ek, self.h_powers_rev[3], &j0, aad, buffer).map_err(|_| OpenError::too_large())?; - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/aes128gcmsiv.rs b/src/aead/aes128gcmsiv.rs index b8b18d6f..3c0ed5c0 100644 --- a/src/aead/aes128gcmsiv.rs +++ b/src/aead/aes128gcmsiv.rs @@ -78,11 +78,12 @@ define_aead_tag_type!( /// /// # Security /// -/// On x86_64 (AES-NI), aarch64 (AES-CE), and s390x (CPACF), all AES -/// operations use constant-time hardware instructions. On RISC-V without -/// hardware AES extensions (Zkne / Zvkned), encryption falls back to the -/// constant-time portable / fixslice implementation. That path is slower, -/// but it avoids secret-indexed lookup tables. +/// On x86_64 (AES-NI), aarch64 (AES-CE), and s390x (CPACF), AES operations use +/// dedicated hardware instructions. On RISC-V without hardware AES extensions +/// (Zkne / Zvkned), encryption falls back to a fixed-work portable / fixslice +/// source implementation that avoids secret-indexed lookup tables. These +/// source and ISA properties are necessary, not sufficient: generated-code +/// timing claims are configuration- and release-evidence-bound; see `ct.toml`. pub struct Aes128GcmSiv { master_ek: aes::Aes128EncKey, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] @@ -518,7 +519,7 @@ fn decrypt_riscv( ct::zeroize(&mut auth_key); ct::zeroize(&mut enc_key); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -859,7 +860,7 @@ unsafe fn decrypt_fused_aarch64( expected[15] &= 0x7f; aes::aarch64_encrypt_block_128_inline(&enc_ek, &mut expected); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1124,7 +1125,7 @@ unsafe fn decrypt_fused_ppc( expected[15] &= 0x7f; aes::ppc_encrypt_block_128_inline(&enc_ek, &mut expected); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1370,7 +1371,7 @@ unsafe fn decrypt_fused_s390x( aes::s390x_encrypt_block_raw_128_inline(enc_key_bytes, &mut expected); ct::zeroize(enc_key_bytes); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1515,7 +1516,7 @@ impl Aead for Aes128GcmSiv { let expected = compute_tag_wide(&auth_key, &ek, nonce, aad, buffer); ct::zeroize(&mut auth_key); ct::zeroize(&mut enc_key); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -1581,7 +1582,7 @@ impl Aead for Aes128GcmSiv { ct::zeroize(&mut auth_key); ct::zeroize(&mut enc_key); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/aes256gcm.rs b/src/aead/aes256gcm.rs index 82056ce3..418ccdec 100644 --- a/src/aead/aes256gcm.rs +++ b/src/aead/aes256gcm.rs @@ -96,11 +96,12 @@ define_aead_tag_type!(Aes256GcmTag, TAG_SIZE, "AES-256-GCM authentication tag (1 /// /// # Security /// -/// On x86_64 (AES-NI), aarch64 (AES-CE), and s390x (CPACF), all AES -/// operations use constant-time hardware instructions. On RISC-V without -/// hardware AES extensions (Zkne / Zvkned), encryption falls back to the -/// constant-time portable implementation. That path is slower, but it -/// avoids secret-indexed lookup tables. +/// On x86_64 (AES-NI), aarch64 (AES-CE), and s390x (CPACF), AES operations use +/// dedicated hardware instructions. On RISC-V without hardware AES extensions +/// (Zkne / Zvkned), encryption falls back to a fixed-work portable source +/// implementation that avoids secret-indexed lookup tables. These source and +/// ISA properties are necessary, not sufficient: generated-code timing claims +/// are configuration- and release-evidence-bound; see `ct.toml`. pub struct Aes256Gcm { /// Pre-expanded AES-256 round keys. ek: aes::Aes256EncKey, @@ -875,7 +876,7 @@ impl Aead for Aes256Gcm { // 2. `acc` and `h_polyval` are initialized GHASH field elements. acc = unsafe { polyval::x86_clmul128_reduce_inline(acc, h_polyval) }; let expected = encrypt_j0_tag(&self.ek, &j0, acc); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -907,7 +908,7 @@ impl Aead for Aes256Gcm { // SAFETY: x86 GHASH final multiply because PCLMUL_READY was checked above. acc = unsafe { polyval::x86_clmul128_reduce_inline(acc, h_polyval) }; let expected = encrypt_j0_tag(&self.ek, &j0, acc); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -949,7 +950,7 @@ impl Aead for Aes256Gcm { // 2. `acc` and `h_polyval` are initialized GHASH field elements. acc = unsafe { polyval::aarch64_clmul128_reduce_inline(acc, h_polyval) }; let expected = encrypt_j0_tag(&self.ek, &j0, acc); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -975,7 +976,7 @@ impl Aead for Aes256Gcm { // SAFETY: POWER8 carryless multiply because backend resolution confirmed POWER8 crypto. acc = unsafe { polyval::ppc_clmul128_reduce_inline(acc, h_polyval) }; let expected = encrypt_j0_tag(&self.ek, &j0, acc); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -989,7 +990,7 @@ impl Aead for Aes256Gcm { compute_tag_short_wide(&self.ek, self.h_powers_rev[3], &self.h_powers_rev, &j0, aad, buffer) .map_err(|_| OpenError::too_large())? { - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -999,7 +1000,7 @@ impl Aead for Aes256Gcm { if should_use_wide_ghash(self.backend, aad.len(), buffer.len()) { let expected = compute_tag_wide(&self.ek, self.h_powers_rev[3], &self.h_powers_rev, &j0, aad, buffer) .map_err(|_| OpenError::too_large())?; - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -1007,7 +1008,7 @@ impl Aead for Aes256Gcm { return Ok(()); } let expected = compute_tag(&self.ek, self.h_powers_rev[3], &j0, aad, buffer).map_err(|_| OpenError::too_large())?; - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/aes256gcmsiv.rs b/src/aead/aes256gcmsiv.rs index 36d9ca26..6373250e 100644 --- a/src/aead/aes256gcmsiv.rs +++ b/src/aead/aes256gcmsiv.rs @@ -77,11 +77,12 @@ define_aead_tag_type!( /// /// # Security /// -/// On x86_64 (AES-NI), aarch64 (AES-CE), and s390x (CPACF), all AES -/// operations use constant-time hardware instructions. On RISC-V without -/// hardware AES extensions (Zkne / Zvkned), encryption falls back to the -/// constant-time portable implementation. That path is slower, but it -/// avoids secret-indexed lookup tables. +/// On x86_64 (AES-NI), aarch64 (AES-CE), and s390x (CPACF), AES operations use +/// dedicated hardware instructions. On RISC-V without hardware AES extensions +/// (Zkne / Zvkned), encryption falls back to a fixed-work portable source +/// implementation that avoids secret-indexed lookup tables. These source and +/// ISA properties are necessary, not sufficient: generated-code timing claims +/// are configuration- and release-evidence-bound; see `ct.toml`. pub struct Aes256GcmSiv { master_ek: aes::Aes256EncKey, #[cfg_attr(target_arch = "wasm32", allow(dead_code))] @@ -515,7 +516,7 @@ fn decrypt_riscv( ct::zeroize(&mut auth_key); ct::zeroize(&mut enc_key); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -888,7 +889,7 @@ unsafe fn decrypt_fused_aarch64( expected[15] &= 0x7f; aes::aarch64_encrypt_block_inline(&enc_ek, &mut expected); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1173,7 +1174,7 @@ unsafe fn decrypt_fused_ppc( expected[15] &= 0x7f; aes::ppc_encrypt_block_inline(&enc_ek, &mut expected); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1437,7 +1438,7 @@ unsafe fn decrypt_fused_s390x( aes::s390x_encrypt_block_raw_inline(enc_key_bytes, &mut expected); ct::zeroize(enc_key_bytes); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1582,7 +1583,7 @@ impl Aead for Aes256GcmSiv { let expected = compute_tag_wide(&auth_key, &ek, nonce, aad, buffer); ct::zeroize(&mut auth_key); ct::zeroize(&mut enc_key); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -1648,7 +1649,7 @@ impl Aead for Aes256GcmSiv { ct::zeroize(&mut auth_key); ct::zeroize(&mut enc_key); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/aes_round.rs b/src/aead/aes_round.rs index aac29719..09f48810 100644 --- a/src/aead/aes_round.rs +++ b/src/aead/aes_round.rs @@ -12,7 +12,8 @@ const BLOCK_SIZE: usize = 16; // These tables implement the Hamburg technique (CHES 2009) for computing the // AES S-box using only 4-bit nibble lookups via byte-shuffle instructions // (PSHUFB, VPERM, vrgather). All operations are register-to-register with -// no secret-dependent memory access — constant-time by construction. +// no secret-dependent memory access in the source/assembly design. Exact +// timing claims remain bound to the generated binary and release evidence. // // Reference: Hamburg, "Accelerating AES with Vector Permute Instructions" // https://www.shiftleft.org/papers/vector_aes/vector_aes.pdf diff --git a/src/aead/ascon128.rs b/src/aead/ascon128.rs index 7ac0e6db..4eff73df 100644 --- a/src/aead/ascon128.rs +++ b/src/aead/ascon128.rs @@ -2,8 +2,10 @@ //! Ascon-AEAD128 authenticated encryption (NIST SP 800-232). //! -//! Pure Rust, constant-time, `no_std` implementation with 128-bit key, -//! 128-bit nonce, and 128-bit authentication tag. +//! Pure Rust, `no_std` implementation with fixed-work, table-free source +//! structure, a 128-bit key, a 128-bit nonce, and a 128-bit authentication +//! tag. Generated-code timing claims remain configuration- and +//! release-evidence-bound; see `ct.toml`. use core::fmt; @@ -348,7 +350,7 @@ impl Aead for AsconAead128 { } let expected = self.finalize(&mut s); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -358,13 +360,12 @@ impl Aead for AsconAead128 { } #[cfg(feature = "diag")] -#[must_use] pub fn diag_ascon_aead128_tag_portable( key: &[u8; KEY_SIZE], nonce: &[u8; NONCE_SIZE], block: &[u8; RATE], expected: &[u8; TAG_SIZE], -) -> bool { +) -> ct::CtDecision { let key = AsconAead128Key::from_bytes(*key); let nonce = Nonce128::from_bytes(*nonce); let cipher = AsconAead128::new(&key); diff --git a/src/aead/chacha20poly1305.rs b/src/aead/chacha20poly1305.rs index 9358dba8..bcccee70 100644 --- a/src/aead/chacha20poly1305.rs +++ b/src/aead/chacha20poly1305.rs @@ -219,7 +219,7 @@ impl ChaCha20Poly1305 { let mut poly_key = chacha20::poly1305_key_gen(self.key.as_bytes(), nonce.as_bytes()); let expected = poly1305::authenticate_aead_empty_text_portable(aad, &poly_key); ct::zeroize(&mut poly_key); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { return Some(Err(OpenError::verification())); } Some(Ok(())) @@ -258,7 +258,7 @@ impl ChaCha20Poly1305 { let mut poly_key = chacha20::poly1305_key_gen(self.key.as_bytes(), nonce.as_bytes()); let expected = poly1305::authenticate_aead_short_text_portable(aad, buffer, &poly_key); ct::zeroize(&mut poly_key); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Some(Err(OpenError::verification())); } @@ -376,7 +376,7 @@ impl ChaCha20Poly1305 { } let expected = x86_64_asm::open_in_place(self.key.as_bytes(), nonce.as_bytes(), aad, buffer); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Some(Err(OpenError::verification())); } @@ -431,7 +431,7 @@ impl ChaCha20Poly1305 { } let expected = x86_64_asm::open_in_place(self.key.as_bytes(), nonce.as_bytes(), aad, buffer); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Some(Err(OpenError::verification())); } @@ -476,7 +476,7 @@ impl ChaCha20Poly1305 { } let expected = aarch64_asm::open_in_place(self.key.as_bytes(), nonce.as_bytes(), aad, buffer); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Some(Err(OpenError::verification())); } @@ -581,7 +581,7 @@ impl ChaCha20Poly1305 { let expected = authenticator.finalize(lengths); ct::zeroize(&mut poly_key); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Some(Err(OpenError::verification())); } @@ -671,7 +671,7 @@ impl ChaCha20Poly1305 { let expected = self .compute_tag(nonce, aad, buffer) .map_err(|_| OpenError::too_large())?; - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/ghash.rs b/src/aead/ghash.rs index f8e6659b..77ef35e6 100644 --- a/src/aead/ghash.rs +++ b/src/aead/ghash.rs @@ -1,6 +1,9 @@ #![allow(clippy::indexing_slicing)] -//! Constant-time GHASH universal hash (NIST SP 800-38D). +//! Fixed-schedule, table-free GHASH universal hash (NIST SP 800-38D). +//! +//! Generated-code timing claims remain configuration- and +//! release-evidence-bound; see `ct.toml`. //! //! GHASH operates in GF(2^128) with the irreducible polynomial //! x^128 + x^7 + x^2 + x + 1 (big-endian / MSB-first convention). diff --git a/src/aead/polyval.rs b/src/aead/polyval.rs index f00e251e..ceef49f1 100644 --- a/src/aead/polyval.rs +++ b/src/aead/polyval.rs @@ -1,13 +1,16 @@ #![allow(clippy::indexing_slicing)] -//! Constant-time POLYVAL universal hash (RFC 8452). +//! Fixed-schedule, table-free POLYVAL universal hash (RFC 8452). +//! +//! Generated-code timing claims remain configuration- and +//! release-evidence-bound; see `ct.toml`. //! //! POLYVAL operates in GF(2^128) with the irreducible polynomial //! x^128 + x^127 + x^126 + x^121 + 1 (the bit-reversal of GHASH's //! polynomial). //! //! The portable implementation uses: -//! - **Pornin's bmul64** for constant-time 64×64 carryless multiplication (16 integer multiplies +//! - **Pornin's bmul64** for fixed-schedule 64×64 carryless multiplication (16 integer multiplies //! per call, no table lookups) //! - **Karatsuba** decomposition for the 128×128 product (6 bmul64 calls) //! - **Montgomery reduction** (2-pass fold from the bottom) for modular reduction — the key @@ -2258,7 +2261,7 @@ impl Drop for Polyval { } } -// Pornin's bmul64: constant-time 64×64 → low-64 carryless multiplication +// Pornin's bmul64: fixed-schedule 64×64 → low-64 carryless multiplication. /// Carryless multiply of two 64-bit values, returning the LOW 64 bits. /// @@ -2273,7 +2276,9 @@ impl Drop for Polyval { /// carryless product at each bit position. /// /// Cost: 16 integer multiplies + 20 bitwise ops. -/// Constant-time: no branches, no table lookups, no data-dependent memory access. +/// The Rust source has no branches, table lookups, or data-dependent memory +/// access. Integer-multiply latency and generated code remain target-specific +/// evidence questions. #[inline] fn bmul64(x: u64, y: u64) -> u64 { let x0 = x & 0x1111_1111_1111_1111; diff --git a/src/aead/targets.rs b/src/aead/targets.rs index ae00363d..da060484 100644 --- a/src/aead/targets.rs +++ b/src/aead/targets.rs @@ -43,7 +43,7 @@ pub enum AeadBackend { Aarch64AesPmull, Aarch64Sve2AesPmull, S390xMsa, - /// Hamburg vperm AES rounds for AEGIS — constant-time via z/Vector VPERM. + /// Hamburg vperm AES rounds for AEGIS using register-only z/Vector VPERM. /// Used on s390x z13+ where no single-round AES instruction exists. S390xVperm, S390xVector, @@ -52,7 +52,7 @@ pub enum AeadBackend { Riscv64ScalarCrypto, Riscv64VectorCrypto, Riscv64Vector, - /// Hamburg vperm AES via `vrgather.vv` — practically constant-time. + /// Hamburg vperm AES via register-only `vrgather.vv` operations. /// Kept as an explicit backend, but not selected for V-only RISC-V until /// it beats the scalar portable path on benchmark hardware. Riscv64Vperm, @@ -205,7 +205,7 @@ fn select_gcm_backend(arch: Arch, caps: Caps) -> AeadBackend { } else if caps.has(riscv::ZKNE) && (caps.has(riscv::ZBC) || caps.has(riscv::ZBKC)) { AeadBackend::Riscv64ScalarCrypto } else { - // Constant-time scalar fallback. V-only Hamburg vperm is currently + // Table-free scalar fallback. V-only Hamburg vperm is currently // much slower than the portable path on RISE benchmark hardware, so // do not select it automatically. AeadBackend::Portable @@ -248,7 +248,7 @@ fn select_aegis_backend(arch: Arch, caps: Caps) -> AeadBackend { } Arch::S390x => { // AEGIS needs single AES rounds — CPACF KM/KMA only do full blocks. - // Hamburg vperm provides constant-time rounds on z13+ (vector facility). + // Hamburg vperm keeps the rounds register-only on z13+ (vector facility). if caps.has(s390x::VECTOR) { AeadBackend::S390xVperm } else { @@ -268,7 +268,7 @@ fn select_aegis_backend(arch: Arch, caps: Caps) -> AeadBackend { } else if caps.has(riscv::ZKNE) { AeadBackend::Riscv64ScalarCrypto } else { - // Constant-time scalar fallback. V-only Hamburg vperm is currently + // Table-free scalar fallback. V-only Hamburg vperm is currently // much slower than the portable path on RISE benchmark hardware, so // do not select it automatically. AeadBackend::Portable @@ -485,7 +485,7 @@ mod tests { AeadBackend::Portable ); - // Tier 4: constant-time portable fallback (bare scalar, no V, no crypto) + // Tier 4: table-free portable fallback (bare scalar, no V, no crypto) assert_eq!( select_backend(AeadPrimitive::Aes256Gcm, Arch::Riscv64, Caps::NONE), AeadBackend::Portable diff --git a/src/aead/xchacha20poly1305.rs b/src/aead/xchacha20poly1305.rs index 9a780585..f02f6e31 100644 --- a/src/aead/xchacha20poly1305.rs +++ b/src/aead/xchacha20poly1305.rs @@ -206,7 +206,7 @@ impl Aead for XChaCha20Poly1305 { .map_err(|_| OpenError::too_large())?; ct::zeroize(&mut poly_key); - if !ct::fixed_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()).declassify() { ct::zeroize(buffer); ct::zeroize(&mut subkey); return Err(OpenError::verification()); diff --git a/src/auth/argon2/mod.rs b/src/auth/argon2/mod.rs index 7ea0efd3..9f9e70f4 100644 --- a/src/auth/argon2/mod.rs +++ b/src/auth/argon2/mod.rs @@ -41,7 +41,8 @@ //! - [`Argon2Params::new`] rejects invalid cost profiles, so constructed parameters are always //! valid. //! - The memory matrix is zeroized on drop. -//! - [`Argon2id::verify`] is constant-time with respect to the stored hash bytes. +//! - [`Argon2id::verify`] traverses every stored-hash byte before returning an opaque result; +//! generated-code timing claims remain configuration- and release-evidence-bound. //! //! # Compliance //! @@ -1891,7 +1892,11 @@ macro_rules! define_argon2_variant { argon2_hash_with_context(params, context, password, salt, $variant, out) } - /// Verify `expected` against a freshly-computed hash in constant time. + /// Verify `expected` after traversing the freshly computed hash and every + /// expected byte. + /// + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. /// /// # Errors /// @@ -1916,7 +1921,7 @@ macro_rules! define_argon2_variant { let bytes_match = ct::public_len_eq(&actual, expected); ct::zeroize(&mut actual); - let success = !hash_failed & bytes_match; + let success = !hash_failed & bytes_match.declassify(); if core::hint::black_box(success) { Ok(()) } else { @@ -2082,7 +2087,7 @@ impl Argon2idPassword { ) .map_err(|_| VerificationError::new())?; let verified = ct::fixed_eq(actual.as_array(), &approved.expected); - if !core::hint::black_box(verified) { + if !core::hint::black_box(verified.declassify()) { return Err(VerificationError::new()); } if approved.params == self.generation && approved.salt_len as usize == PASSWORD_SALT_LEN { diff --git a/src/auth/ecdsa.rs b/src/auth/ecdsa.rs index b5f83ea8..fd00b378 100644 --- a/src/auth/ecdsa.rs +++ b/src/auth/ecdsa.rs @@ -804,6 +804,12 @@ impl EcdsaP256SecretKey { /// P-256 secret scalar length in bytes. pub const LENGTH: usize = 32; + /// Compare two secret keys without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(&self.0, &other.0) + } + /// Parse a P-256 secret scalar. pub fn from_bytes(bytes: [u8; Self::LENGTH]) -> Result { Self::from_zeroizing_bytes(ZeroizingBytes::new(bytes)) @@ -942,14 +948,6 @@ impl EcdsaP256SecretKey { } } -impl PartialEq for EcdsaP256SecretKey { - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(&self.0, &other.0) - } -} - -impl Eq for EcdsaP256SecretKey {} - impl fmt::Debug for EcdsaP256SecretKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("EcdsaP256SecretKey(****)") @@ -969,6 +967,12 @@ impl EcdsaP384SecretKey { /// P-384 secret scalar length in bytes. pub const LENGTH: usize = 48; + /// Compare two secret keys without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(&self.0, &other.0) + } + /// Parse a P-384 secret scalar. pub fn from_bytes(bytes: [u8; Self::LENGTH]) -> Result { Self::from_zeroizing_bytes(ZeroizingBytes::new(bytes)) @@ -1106,14 +1110,6 @@ impl EcdsaP384SecretKey { } } -impl PartialEq for EcdsaP384SecretKey { - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(&self.0, &other.0) - } -} - -impl Eq for EcdsaP384SecretKey {} - impl fmt::Debug for EcdsaP384SecretKey { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("EcdsaP384SecretKey(****)") diff --git a/src/auth/ecdsa_aarch64_asm.rs b/src/auth/ecdsa_aarch64_asm.rs index a86644e7..c4fe9838 100644 --- a/src/auth/ecdsa_aarch64_asm.rs +++ b/src/auth/ecdsa_aarch64_asm.rs @@ -75,8 +75,8 @@ pub(super) fn p256_scalarmulbase_generator(scalar: &[u64; 4]) -> [u64; 8] { // SAFETY: P-256 fixed-base scalar multiplication call because: // 1. This module is compiled only for supported AArch64, matching the embedded assembly ABI. // 2. `out` and `scalar` are fixed-size arrays with the exact limb counts required by s2n-bignum. - // 3. The assembly routine is constant-time with respect to the scalar; ECDSA signing uses secret - // nonce material. + // 3. The scalar may contain secret nonce material. Generated-code timing assurance is + // configuration- and release-evidence-bound; see `ct.toml`. // 4. The table is generated for `P256_AARCH64_BASEPOINT_BLOCKSIZE` and contains every entry the // s2n-bignum fixed-base routine reads for that block size. unsafe { @@ -125,7 +125,8 @@ pub(super) fn p384_field_mul(lhs: &[u64; 6], rhs: &[u64; 6]) -> [u64; 6] { // 1. This module is compiled only for supported AArch64, matching the embedded assembly ABI. // 2. `out`, `lhs`, and `rhs` are six-limb arrays, which is the exact P-384 field size. // 3. Callers route only P-384 field elements in Montgomery form through this wrapper. - // 4. The routine is constant-time with respect to the field-element values. + // 4. Field elements may be secret. Generated-code timing assurance is configuration- and + // release-evidence-bound; see `ct.toml`. unsafe { rscrypto_bignum_montmul_p384(out.as_mut_ptr(), lhs.as_ptr(), rhs.as_ptr()) }; out } @@ -137,7 +138,8 @@ pub(super) fn p384_field_square(value: &[u64; 6]) -> [u64; 6] { // 1. This module is compiled only for supported AArch64, matching the embedded assembly ABI. // 2. `out` and `value` are six-limb arrays, which is the exact P-384 field size. // 3. Callers route only P-384 field elements in Montgomery form through this wrapper. - // 4. The routine is constant-time with respect to the field-element value. + // 4. Field elements may be secret. Generated-code timing assurance is configuration- and + // release-evidence-bound; see `ct.toml`. unsafe { rscrypto_bignum_montsqr_p384(out.as_mut_ptr(), value.as_ptr()) }; out } @@ -184,7 +186,8 @@ pub(super) fn p384_point_double(point: &[u64; 18]) -> [u64; 18] { // 1. This module is compiled only for supported AArch64, matching the embedded assembly ABI. // 2. `out` and `point` are 18-limb arrays laid out as x/y/z P-384 Montgomery coordinates. // 3. Callers preserve the Rust-level infinity flag and select the infinity result when required. - // 4. The routine is constant-time with respect to the point coordinate values. + // 4. Point coordinates may be secret. Generated-code timing assurance is configuration- and + // release-evidence-bound; see `ct.toml`. unsafe { rscrypto_p384_montjdouble_alt(out.as_mut_ptr(), point.as_ptr()) }; out } @@ -197,7 +200,8 @@ pub(super) fn p384_point_mixadd(lhs: &[u64; 18], rhs: &[u64; 12]) -> [u64; 18] { // 2. `out` and `lhs` are 18-limb Jacobian arrays; `rhs` is a 12-limb affine x/y array. // 3. Callers preserve the Rust-level infinity and zero-digit handling around this raw point // operation. - // 4. The routine is constant-time with respect to the point coordinate values. + // 4. Point coordinates may be secret. Generated-code timing assurance is configuration- and + // release-evidence-bound; see `ct.toml`. unsafe { rscrypto_p384_montjmixadd_alt(out.as_mut_ptr(), lhs.as_ptr(), rhs.as_ptr()) }; out } diff --git a/src/auth/ecdsa_x86_64_asm.rs b/src/auth/ecdsa_x86_64_asm.rs index c645b0b3..8e17bdc6 100644 --- a/src/auth/ecdsa_x86_64_asm.rs +++ b/src/auth/ecdsa_x86_64_asm.rs @@ -76,8 +76,8 @@ pub(super) fn p256_scalarmulbase_generator(scalar: &[u64; 4]) -> [u64; 8] { // 1. This module is compiled only for Linux x86-64 System V, matching the embedded assembly ABI. // 2. `out` and `scalar` are fixed-size arrays with the exact limb counts required by s2n-bignum. // 3. Runtime capabilities prove BMI2 and ADX before selecting this backend. - // 4. The assembly routine is constant-time with respect to the scalar; ECDSA signing uses secret - // nonce material. + // 4. The scalar may contain secret nonce material. Generated-code timing assurance is + // configuration- and release-evidence-bound; see `ct.toml`. // 5. The table is generated for `P256_AARCH64_BASEPOINT_BLOCKSIZE` and contains every entry the // s2n-bignum fixed-base routine reads for that block size. unsafe { @@ -93,8 +93,8 @@ pub(super) fn p256_scalarmulbase_generator(scalar: &[u64; 4]) -> [u64; 8] { // 1. This module is compiled only for Linux x86-64 System V, matching the embedded assembly ABI. // 2. `out` and `scalar` are fixed-size arrays with the exact limb counts required by s2n-bignum. // 3. The `_alt` routine is the baseline x86-64 backend and does not require BMI2 or ADX. - // 4. The assembly routine is constant-time with respect to the scalar; ECDSA signing uses secret - // nonce material. + // 4. The scalar may contain secret nonce material. Generated-code timing assurance is + // configuration- and release-evidence-bound; see `ct.toml`. // 5. The table is generated for `P256_AARCH64_BASEPOINT_BLOCKSIZE` and contains every entry the // s2n-bignum fixed-base routine reads for that block size. unsafe { @@ -134,7 +134,8 @@ pub(super) fn p384_field_mul(lhs: &[u64; 6], rhs: &[u64; 6]) -> [u64; 6] { // 2. `out`, `lhs`, and `rhs` are six-limb arrays, which is the exact P-384 field size. // 3. Runtime capabilities prove BMI2 and ADX before selecting this backend. // 4. Callers route only P-384 field elements in Montgomery form through this wrapper. - // 5. The routine is constant-time with respect to the field-element values. + // 5. Field elements may be secret. Generated-code timing assurance is configuration- and + // release-evidence-bound; see `ct.toml`. unsafe { rscrypto_bignum_montmul_p384(out.as_mut_ptr(), lhs.as_ptr(), rhs.as_ptr()) }; } else { // SAFETY: baseline P-384 field Montgomery multiplication call because: @@ -142,7 +143,8 @@ pub(super) fn p384_field_mul(lhs: &[u64; 6], rhs: &[u64; 6]) -> [u64; 6] { // 2. `out`, `lhs`, and `rhs` are six-limb arrays, which is the exact P-384 field size. // 3. The `_alt` routine is the baseline x86-64 backend and does not require BMI2 or ADX. // 4. Callers route only P-384 field elements in Montgomery form through this wrapper. - // 5. The routine is constant-time with respect to the field-element values. + // 5. Field elements may be secret. Generated-code timing assurance is configuration- and + // release-evidence-bound; see `ct.toml`. unsafe { rscrypto_bignum_montmul_p384_alt(out.as_mut_ptr(), lhs.as_ptr(), rhs.as_ptr()) }; } @@ -159,7 +161,8 @@ pub(super) fn p384_field_square(value: &[u64; 6]) -> [u64; 6] { // 2. `out` and `value` are six-limb arrays, which is the exact P-384 field size. // 3. Runtime capabilities prove BMI2 and ADX before selecting this backend. // 4. Callers route only P-384 field elements in Montgomery form through this wrapper. - // 5. The routine is constant-time with respect to the field-element value. + // 5. Field elements may be secret. Generated-code timing assurance is configuration- and + // release-evidence-bound; see `ct.toml`. unsafe { rscrypto_bignum_montsqr_p384(out.as_mut_ptr(), value.as_ptr()) }; } else { // SAFETY: baseline P-384 field Montgomery square call because: @@ -167,7 +170,8 @@ pub(super) fn p384_field_square(value: &[u64; 6]) -> [u64; 6] { // 2. `out` and `value` are six-limb arrays, which is the exact P-384 field size. // 3. The `_alt` routine is the baseline x86-64 backend and does not require BMI2 or ADX. // 4. Callers route only P-384 field elements in Montgomery form through this wrapper. - // 5. The routine is constant-time with respect to the field-element value. + // 5. Field elements may be secret. Generated-code timing assurance is configuration- and + // release-evidence-bound; see `ct.toml`. unsafe { rscrypto_bignum_montsqr_p384_alt(out.as_mut_ptr(), value.as_ptr()) }; } diff --git a/src/auth/ed25519.rs b/src/auth/ed25519.rs index 1dcff47f..6cbe4949 100644 --- a/src/auth/ed25519.rs +++ b/src/auth/ed25519.rs @@ -126,18 +126,16 @@ const _: fn(&scalar::Scalar, &scalar::Scalar, &scalar::Scalar) -> scalar::Scalar /// parameters at the call site. pub struct Ed25519SecretKey([u8; Self::LENGTH]); -impl PartialEq for Ed25519SecretKey { - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(&self.0, &other.0) - } -} - -impl Eq for Ed25519SecretKey {} - impl Ed25519SecretKey { /// Secret key length in bytes. pub const LENGTH: usize = SECRET_KEY_LENGTH; + /// Compare two secret keys without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(&self.0, &other.0) + } + /// Construct a secret key from its byte representation. #[inline] #[must_use] @@ -438,17 +436,13 @@ pub struct DiagEd25519VerifyScalars { pub public_key: [u8; PUBLIC_KEY_LENGTH], } -impl PartialEq for Ed25519Keypair { - fn eq(&self, other: &Self) -> bool { - let secret_eq = self.secret == other.secret; - let public_eq = self.public == other.public; - secret_eq & public_eq +impl Ed25519Keypair { + /// Compare both halves of two keypairs without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + self.secret.ct_eq(&other.secret) & ct::fixed_eq(self.public.as_bytes(), other.public.as_bytes()) } -} -impl Eq for Ed25519Keypair {} - -impl Ed25519Keypair { /// Explicitly duplicate this secret-bearing keypair. #[inline] #[must_use] @@ -840,7 +834,7 @@ fn verify_aarch64_encoded_r( } aarch64_asm::double_scalar_basepoint_encoded(s_canonical, neg_challenge_bytes, public_key).map(|combined| { - if ct::fixed_eq(&combined, r_bytes) { + if ct::fixed_eq(&combined, r_bytes).declassify() { Ok(()) } else { Err(VerificationError::new()) @@ -858,7 +852,7 @@ fn verify_aarch64_encoded_r( fn is_small_order_encoded(bytes: &[u8; PUBLIC_KEY_LENGTH]) -> bool { SMALL_ORDER_ENCODINGS .iter() - .any(|small_order| ct::fixed_eq(bytes, small_order)) + .any(|small_order| ct::fixed_eq(bytes, small_order).declassify()) } // Dispatch: AArch64 assembly verify fast path → IFMA → AVX2 → portable @@ -979,7 +973,7 @@ mod tests { } #[test] - fn keypair_equality_requires_matching_secret_and_public_halves() { + fn keypair_ct_equality_requires_matching_secret_and_public_halves() { let first = Ed25519Keypair::from_secret_key(Ed25519SecretKey::from_bytes([1u8; Ed25519SecretKey::LENGTH])); let second = Ed25519Keypair::from_secret_key(Ed25519SecretKey::from_bytes([2u8; Ed25519SecretKey::LENGTH])); @@ -988,10 +982,10 @@ mod tests { let mut public_mismatch = first.duplicate_secret(); public_mismatch.public = second.public; - assert_eq!(first, first); - assert_ne!(first, secret_mismatch); - assert_ne!(first, public_mismatch); - assert_ne!(first, second); + assert!(first.ct_eq(&first).declassify()); + assert!(!first.ct_eq(&secret_mismatch).declassify()); + assert!(!first.ct_eq(&public_mismatch).declassify()); + assert!(!first.ct_eq(&second).declassify()); } #[test] diff --git a/src/auth/ed25519/aarch64_asm.rs b/src/auth/ed25519/aarch64_asm.rs index e970513c..ef8d5c08 100644 --- a/src/auth/ed25519/aarch64_asm.rs +++ b/src/auth/ed25519/aarch64_asm.rs @@ -49,8 +49,8 @@ pub(super) fn basepoint_mul_encoded(s: &[u8; SECRET_KEY_LENGTH]) -> [u8; PUBLIC_ // target. // 2. `out` has space for eight `u64` affine limbs. // 3. `s_words` has four little-endian `u64` scalar limbs, matching the s2n-bignum ABI. - // 4. The assembly routine is constant-time with respect to the scalar; signing uses secret nonce - // material. + // 4. The scalar may contain secret nonce material. Generated-code timing assurance is + // configuration- and release-evidence-bound; see `ct.toml`. unsafe { rscrypto_edwards25519_scalarmulbase_alt(out.as_mut_ptr(), s_words.as_ptr()) }; encode_affine_point(&out) diff --git a/src/auth/ed25519/point.rs b/src/auth/ed25519/point.rs index 1cd42ade..ec0c014f 100644 --- a/src/auth/ed25519/point.rs +++ b/src/auth/ed25519/point.rs @@ -387,10 +387,10 @@ impl ExtendedPoint { let rhs_x = rhs.x.mul(&self.z).normalize().to_bytes(); let lhs_y = self.y.mul(&rhs.z).normalize().to_bytes(); let rhs_y = rhs.y.mul(&self.z).normalize().to_bytes(); - let diff = u8::from(ct::fixed_eq(&lhs_z, &zero)) - | u8::from(ct::fixed_eq(&rhs_z, &zero)) - | u8::from(!ct::fixed_eq(&lhs_x, &rhs_x)) - | u8::from(!ct::fixed_eq(&lhs_y, &rhs_y)); + let diff = ct::fixed_eq(&lhs_z, &zero).into_u8() + | ct::fixed_eq(&rhs_z, &zero).into_u8() + | (!ct::fixed_eq(&lhs_x, &rhs_x)).into_u8() + | (!ct::fixed_eq(&lhs_y, &rhs_y)).into_u8(); core::hint::black_box(diff) == 0 } diff --git a/src/auth/ed25519/point_avx2.rs b/src/auth/ed25519/point_avx2.rs index 238ec7af..2dbe0e03 100644 --- a/src/auth/ed25519/point_avx2.rs +++ b/src/auth/ed25519/point_avx2.rs @@ -237,7 +237,7 @@ fn ct_abs_i8(value: i8) -> u8 { ((value ^ sign) - sign) as u8 } -/// Constant-time cached-point selection for AVX2 fixed-base tables. +/// Fixed-schedule cached-point selection for AVX2 fixed-base tables. /// /// # Safety /// @@ -728,7 +728,7 @@ impl CachedPointIfma { } } -/// Constant-time cached-point selection for IFMA fixed-base tables. +/// Fixed-schedule cached-point selection for IFMA fixed-base tables. /// /// # Safety /// diff --git a/src/auth/ed25519/x86_64_asm.rs b/src/auth/ed25519/x86_64_asm.rs index abcae6f4..ed77a964 100644 --- a/src/auth/ed25519/x86_64_asm.rs +++ b/src/auth/ed25519/x86_64_asm.rs @@ -61,8 +61,8 @@ fn basepoint_mul_affine(s: &[u8; SECRET_KEY_LENGTH]) -> [u64; AFFINE_POINT_LIMBS // 2. `out` has space for eight `u64` affine limbs. // 3. `s_words` has four little-endian `u64` scalar limbs, matching the s2n-bignum ABI. // 4. Runtime capabilities prove BMI2 and ADX before selecting this backend. - // 5. The assembly routine is constant-time with respect to the scalar; signing uses secret nonce - // material. + // 5. The scalar may contain secret nonce material. Generated-code timing assurance is + // configuration- and release-evidence-bound; see `ct.toml`. unsafe { rscrypto_edwards25519_scalarmulbase(out.as_mut_ptr(), s_words.as_ptr()) }; } else { // SAFETY: baseline fixed-base scalar multiplication call because: @@ -70,8 +70,8 @@ fn basepoint_mul_affine(s: &[u8; SECRET_KEY_LENGTH]) -> [u64; AFFINE_POINT_LIMBS // 2. `out` has space for eight `u64` affine limbs. // 3. `s_words` has four little-endian `u64` scalar limbs, matching the s2n-bignum ABI. // 4. The `_alt` routine is the baseline x86-64 backend and does not require BMI2 or ADX. - // 5. The assembly routine is constant-time with respect to the scalar; signing uses secret nonce - // material. + // 5. The scalar may contain secret nonce material. Generated-code timing assurance is + // configuration- and release-evidence-bound; see `ct.toml`. unsafe { rscrypto_edwards25519_scalarmulbase_alt(out.as_mut_ptr(), s_words.as_ptr()) }; } diff --git a/src/auth/hmac.rs b/src/auth/hmac.rs index 08a5fee4..4ffb4ec8 100644 --- a/src/auth/hmac.rs +++ b/src/auth/hmac.rs @@ -26,15 +26,6 @@ macro_rules! define_hmac_tag_type { #[derive(Clone, Copy)] pub struct $name([u8; Self::LENGTH]); - impl PartialEq for $name { - #[inline] - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(&self.0, &other.0) - } - } - - impl Eq for $name {} - impl core::hash::Hash for $name { #[inline] fn hash(&self, state: &mut H) { @@ -46,6 +37,12 @@ macro_rules! define_hmac_tag_type { /// Tag length in bytes. pub const LENGTH: usize = $len; + /// Compare two tags without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(&self.0, &other.0) + } + /// Construct a typed tag from raw bytes. #[inline] #[must_use] @@ -359,7 +356,10 @@ impl HmacSha256 { ::mac(key, data) } - /// Verify `expected` against the HMAC tag of `data` in constant time. + /// Verify `expected` through the tag owner's sealed comparison decision. + /// + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. #[inline] #[must_use = "HMAC verification must be checked; a dropped Result silently accepts a forged tag"] pub fn verify_tag(key: &[u8], data: &[u8], expected: &HmacSha256Tag) -> Result<(), VerificationError> { @@ -413,8 +413,10 @@ impl HmacSha256 { } #[cfg(feature = "diag")] -#[must_use] -pub fn diag_hmac_sha256_verify_portable(key: &[u8; SHA256_TAG_SIZE], expected: &[u8; SHA256_TAG_SIZE]) -> bool { +pub fn diag_hmac_sha256_verify_portable( + key: &[u8; SHA256_TAG_SIZE], + expected: &[u8; SHA256_TAG_SIZE], +) -> ct::CtDecision { let compress = crate::hashes::crypto::sha256::kernels::compress_blocks_fn( crate::hashes::crypto::sha256::kernels::Sha256KernelId::Portable, ); @@ -590,7 +592,7 @@ impl Mac for HmacSha256 { #[inline] fn verify(&self, expected: &Self::Tag) -> Result<(), VerificationError> { - if self.finalize() == *expected { + if self.finalize().ct_eq(expected).declassify() { Ok(()) } else { Err(VerificationError::new()) @@ -650,7 +652,10 @@ impl HmacSha384 { ::mac(key, data) } - /// Verify `expected` against the HMAC tag of `data` in constant time. + /// Verify `expected` through the tag owner's sealed comparison decision. + /// + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. #[inline] #[must_use = "HMAC verification must be checked; a dropped Result silently accepts a forged tag"] pub fn verify_tag(key: &[u8], data: &[u8], expected: &HmacSha384Tag) -> Result<(), VerificationError> { @@ -702,8 +707,10 @@ impl HmacSha384 { } #[cfg(feature = "diag")] -#[must_use] -pub fn diag_hmac_sha384_verify_portable(key: &[u8; SHA384_TAG_SIZE], expected: &[u8; SHA384_TAG_SIZE]) -> bool { +pub fn diag_hmac_sha384_verify_portable( + key: &[u8; SHA384_TAG_SIZE], + expected: &[u8; SHA384_TAG_SIZE], +) -> ct::CtDecision { let compress = crate::hashes::crypto::sha384::kernels::compress_blocks_fn( crate::hashes::crypto::sha384::kernels::Sha384KernelId::Portable, ); @@ -862,7 +869,7 @@ impl Mac for HmacSha384 { #[inline] fn verify(&self, expected: &Self::Tag) -> Result<(), VerificationError> { - if self.finalize() == *expected { + if self.finalize().ct_eq(expected).declassify() { Ok(()) } else { Err(VerificationError::new()) @@ -922,7 +929,10 @@ impl HmacSha512 { ::mac(key, data) } - /// Verify `expected` against the HMAC tag of `data` in constant time. + /// Verify `expected` through the tag owner's sealed comparison decision. + /// + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. #[inline] #[must_use = "HMAC verification must be checked; a dropped Result silently accepts a forged tag"] pub fn verify_tag(key: &[u8], data: &[u8], expected: &HmacSha512Tag) -> Result<(), VerificationError> { @@ -974,8 +984,10 @@ impl HmacSha512 { } #[cfg(feature = "diag")] -#[must_use] -pub fn diag_hmac_sha512_verify_portable(key: &[u8; SHA512_TAG_SIZE], expected: &[u8; SHA512_TAG_SIZE]) -> bool { +pub fn diag_hmac_sha512_verify_portable( + key: &[u8; SHA512_TAG_SIZE], + expected: &[u8; SHA512_TAG_SIZE], +) -> ct::CtDecision { let compress = crate::hashes::crypto::sha512::kernels::compress_blocks_fn( crate::hashes::crypto::sha512::kernels::Sha512KernelId::Portable, ); @@ -1134,7 +1146,7 @@ impl Mac for HmacSha512 { #[inline] fn verify(&self, expected: &Self::Tag) -> Result<(), VerificationError> { - if self.finalize() == *expected { + if self.finalize().ct_eq(expected).declassify() { Ok(()) } else { Err(VerificationError::new()) diff --git a/src/auth/hmac_sha3.rs b/src/auth/hmac_sha3.rs index 2e52cd8a..08f00155 100644 --- a/src/auth/hmac_sha3.rs +++ b/src/auth/hmac_sha3.rs @@ -23,15 +23,6 @@ macro_rules! define_hmac_sha3_tag_type { #[derive(Clone, Copy)] pub struct $name([u8; Self::LENGTH]); - impl PartialEq for $name { - #[inline] - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(&self.0, &other.0) - } - } - - impl Eq for $name {} - impl core::hash::Hash for $name { #[inline] fn hash(&self, state: &mut H) { @@ -43,6 +34,12 @@ macro_rules! define_hmac_sha3_tag_type { /// Tag length in bytes. pub const LENGTH: usize = $len; + /// Compare two tags without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(&self.0, &other.0) + } + /// Construct a typed tag from raw bytes. #[inline] #[must_use] @@ -251,7 +248,10 @@ macro_rules! define_hmac_sha3 { ::mac(key, data) } - /// Verify `expected` against the HMAC tag of `data` in constant time. + /// Verify `expected` through the tag owner's sealed comparison decision. + /// + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. #[inline] #[must_use = "HMAC verification must be checked; a dropped Result silently accepts a forged tag"] pub fn verify_tag(key: &[u8], data: &[u8], expected: &$tag) -> Result<(), VerificationError> { @@ -298,6 +298,15 @@ macro_rules! define_hmac_sha3 { fn reset(&mut self) { self.inner = self.inner_init.clone(); } + + #[inline] + fn verify(&self, expected: &Self::Tag) -> Result<(), VerificationError> { + if self.finalize().ct_eq(expected).declassify() { + Ok(()) + } else { + Err(VerificationError::new()) + } + } } }; } diff --git a/src/auth/kmac.rs b/src/auth/kmac.rs index 4c641c72..3d89126f 100644 --- a/src/auth/kmac.rs +++ b/src/auth/kmac.rs @@ -89,9 +89,11 @@ macro_rules! define_kmac { out } - /// Verify `expected` against the MAC of `data` in constant time. + /// Verify `expected` after traversing its public-length contents. /// /// This is the one-shot helper. Use [`Self::verify`] for an already-accumulated state. + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. #[must_use = "MAC verification must be checked; a dropped Result silently accepts a forged tag"] pub fn verify_tag( key: &[u8], @@ -104,9 +106,11 @@ macro_rules! define_kmac { state.verify(expected) } - #[doc = concat!("Verify `expected` against the current KMAC", $bits, " output in constant time.")] + #[doc = concat!("Verify `expected` against the current KMAC", $bits, " output after a full public-length comparison.")] /// This checks the MAC for the bytes already absorbed into `self`; it does /// not recompute from `(key, customization, data)` like [`Self::verify_tag`]. + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. #[must_use = "MAC verification must be checked; a dropped Result silently accepts a forged tag"] pub fn verify(&self, expected: &[u8]) -> Result<(), VerificationError> { if expected.is_empty() { @@ -119,7 +123,7 @@ macro_rules! define_kmac { for chunk in expected.chunks(block.len()) { reader.squeeze(&mut block[..chunk.len()]); - diff |= u8::from(!ct::public_len_eq(&block[..chunk.len()], chunk)); + diff |= (!ct::public_len_eq(&block[..chunk.len()], chunk)).into_u8(); } ct::zeroize(&mut block); diff --git a/src/auth/mlkem.rs b/src/auth/mlkem.rs index f9edc929..29385d70 100644 --- a/src/auth/mlkem.rs +++ b/src/auth/mlkem.rs @@ -127,6 +127,12 @@ macro_rules! define_mlkem_secret_bytes { /// Length in bytes. pub const LENGTH: usize = $len; + /// Compare two secret values without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(&self.0, &other.0) + } + /// Construct the typed value from raw bytes. #[inline] #[must_use] @@ -163,15 +169,6 @@ macro_rules! define_mlkem_secret_bytes { } } - impl PartialEq for $name { - #[inline] - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(&self.0, &other.0) - } - } - - impl Eq for $name {} - impl fmt::Debug for $name { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}(****)", stringify!($name)) @@ -428,6 +425,12 @@ macro_rules! define_mlkem_prepared_keys { pub const fn as_bytes(&self) -> &[u8; Self::LENGTH] { self.key.as_bytes() } + + /// Compare two prepared secret keys without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(self.as_bytes(), other.as_bytes()) + } } impl core::convert::TryFrom<$decapsulation_key> for $prepared_decapsulation_key { @@ -462,15 +465,6 @@ macro_rules! define_mlkem_prepared_keys { } } - impl PartialEq for $prepared_decapsulation_key { - #[inline] - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(self.as_bytes(), other.as_bytes()) - } - } - - impl Eq for $prepared_decapsulation_key {} - impl fmt::Debug for $prepared_decapsulation_key { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}(****)", stringify!($prepared_decapsulation_key)) diff --git a/src/auth/mlkem/portable.rs b/src/auth/mlkem/portable.rs index 6f14d7e7..cf22d0a4 100644 --- a/src/auth/mlkem/portable.rs +++ b/src/auth/mlkem/portable.rs @@ -6536,7 +6536,7 @@ fn inverse_ntt_scaled_add_assign(poly: &mut Poly, addend: &Poly, final_scale_mon // 3. `poly` and `addend` are fixed 256-coefficient polynomials; all loads/stores use public fixed // offsets. // 4. `addend` is already in canonical modulo-Q representation, so the final add uses the same - // constant-time modular addition as `poly_add_assign`. + // fixed-schedule modular addition as `poly_add_assign`. // 5. The memory access schedule depends only on public ML-KEM dimensions, not on coefficient // values. unsafe { @@ -9486,7 +9486,7 @@ fn prf_eta(seed: &[u8; SEED_BYTES], nonce: u8, out: & fn ct_eq_mask(a: &[u8], b: &[u8]) -> u8 { debug_assert_eq!(a.len(), b.len()); - 0u8.wrapping_sub(u8::from(ct::public_len_eq(a, b))) + ct::public_len_eq(a, b).into_mask() } #[inline] @@ -9561,12 +9561,12 @@ mod tests { .unwrap(); let decapsulated = MlKem512::decapsulate(&decapsulation_key, &ciphertext).unwrap(); - assert_eq!(decapsulated, shared_secret); + assert!(decapsulated.ct_eq(&shared_secret).declassify()); let mut modified = ciphertext.to_bytes(); modified[0] ^= 1; let rejected = MlKem512::decapsulate(&decapsulation_key, &MlKem512Ciphertext::from_bytes(modified)).unwrap(); - assert_ne!(rejected, shared_secret); + assert!(!rejected.ct_eq(&shared_secret).declassify()); } #[test] diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 8bc53dbc..0466078f 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -35,7 +35,7 @@ //! let bob = rscrypto::X25519SecretKey::from_bytes([9u8; 32]); //! let alice_shared = alice.diffie_hellman(&bob.public_key())?; //! let bob_shared = bob.diffie_hellman(&alice.public_key())?; -//! assert_eq!(alice_shared, bob_shared); +//! assert!(alice_shared.ct_eq(&bob_shared).declassify()); //! # } //! # Ok::<(), Box>(()) //! ``` diff --git a/src/auth/pbkdf2.rs b/src/auth/pbkdf2.rs index 8792aca2..d4a0cff4 100644 --- a/src/auth/pbkdf2.rs +++ b/src/auth/pbkdf2.rs @@ -417,16 +417,22 @@ macro_rules! define_pbkdf2_sha2 { Ok(out) } - /// Verify `expected` against the derived key in constant time using the + /// Verify `expected` after a full public-length comparison using the /// default password policy. + /// + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. #[allow(clippy::indexing_slicing)] #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] pub fn verify(&self, salt: &[u8], iterations: u32, expected: &[u8]) -> Result<(), VerificationError> { self.verify_with_policy(salt, iterations, expected, &Self::DEFAULT_VERIFY_POLICY) } - /// Verify `expected` against the derived key in constant time using an + /// Verify `expected` after a full public-length comparison using an /// explicit password policy. + /// + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. #[allow(clippy::indexing_slicing)] #[must_use = "password verification must be checked; a dropped Result silently accepts the wrong password"] pub fn verify_with_policy( diff --git a/src/auth/poly1305.rs b/src/auth/poly1305.rs index e9ef2e22..b8bee637 100644 --- a/src/auth/poly1305.rs +++ b/src/auth/poly1305.rs @@ -86,15 +86,6 @@ impl fmt::Debug for Poly1305OneTimeKey { #[derive(Clone, Copy)] pub struct Poly1305Tag([u8; Self::LENGTH]); -impl PartialEq for Poly1305Tag { - #[inline] - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(&self.0, &other.0) - } -} - -impl Eq for Poly1305Tag {} - impl core::hash::Hash for Poly1305Tag { #[inline] fn hash(&self, state: &mut H) { @@ -106,6 +97,12 @@ impl Poly1305Tag { /// Poly1305 tag length in bytes. pub const LENGTH: usize = TAG_SIZE; + /// Compare two tags without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(&self.0, &other.0) + } + /// Construct a typed tag from raw bytes. #[inline] #[must_use] @@ -476,11 +473,14 @@ impl Poly1305 { Poly1305Tag::from_bytes(core::mem::take(&mut self.state).finalize()) } - /// Verify `expected` against the current Poly1305 state in constant time. + /// Verify `expected` through the tag owner's sealed comparison decision. + /// + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. #[inline] #[must_use = "Poly1305 verification must be checked; a dropped Result silently accepts a forged tag"] pub fn verify(self, expected: &Poly1305Tag) -> Result<(), VerificationError> { - if self.finalize() == *expected { + if self.finalize().ct_eq(expected).declassify() { Ok(()) } else { Err(VerificationError::new()) @@ -501,7 +501,8 @@ impl Poly1305 { #[must_use = "Poly1305 verification must be checked; a dropped Result silently accepts a forged tag"] pub fn verify_once(key: Poly1305OneTimeKey, data: &[u8], expected: &Poly1305Tag) -> Result<(), VerificationError> { Self::authenticate_once(key, data) - .eq(expected) + .ct_eq(expected) + .declassify() .then_some(()) .ok_or_else(VerificationError::new) } diff --git a/src/auth/rsa.rs b/src/auth/rsa.rs index 61a7d30b..3b77a1a7 100644 --- a/src/auth/rsa.rs +++ b/src/auth/rsa.rs @@ -3397,7 +3397,7 @@ impl RsaPrivateKeyComponents { scratch.checked.as_mut_slice(), &mut scratch.mul_scratch, )?; - if !ct::public_len_eq(scratch.checked.as_slice(), scratch.one.as_slice()) { + if !ct::public_len_eq(scratch.checked.as_slice(), scratch.one.as_slice()).declassify() { return Err(RsaPrivateOpError::InvalidBlindingFactor); } } @@ -3435,7 +3435,7 @@ impl RsaPrivateKeyComponents { &mut scratch.public_scratch, ) .map_err(|_| RsaPrivateOpError::FaultCheckFailed)?; - if ct::public_len_eq(scratch.checked.as_slice(), scratch.encoded.as_slice()) { + if ct::public_len_eq(scratch.checked.as_slice(), scratch.encoded.as_slice()).declassify() { scratch .blinded_private_result .as_mut_slice() @@ -3492,7 +3492,7 @@ impl RsaPrivateKeyComponents { &mut scratch.public_scratch, ) .map_err(|_| RsaPrivateOpError::FaultCheckFailed)?; - if ct::public_len_eq(scratch.checked.as_slice(), scratch.encoded.as_slice()) { + if ct::public_len_eq(scratch.checked.as_slice(), scratch.encoded.as_slice()).declassify() { scratch .blinded_private_result .as_mut_slice() @@ -7229,7 +7229,7 @@ fn keygen_miller_rabin_accepts_base( return Err(RsaKeyGenerationError::ArithmeticFailure); } - let mut accepted = keygen_is_one_fixed(&x) || ct::public_len_eq(&x, n_minus_one_fixed); + let mut accepted = keygen_is_one_fixed(&x) || ct::public_len_eq(&x, n_minus_one_fixed).declassify(); for _ in 1..powers_of_two { if accepted { break; @@ -7242,7 +7242,7 @@ fn keygen_miller_rabin_accepts_base( } x.copy_from_slice(&squared); ct::zeroize(&mut squared); - accepted = ct::public_len_eq(&x, n_minus_one_fixed); + accepted = ct::public_len_eq(&x, n_minus_one_fixed).declassify(); } ct::zeroize(&mut x); @@ -8205,7 +8205,7 @@ where } let (decoded_label_hash, rest) = masked_db.split_at(h_len); - bad |= u8::from(!ct::public_len_eq(decoded_label_hash, label_hash.as_ref())); + bad |= (!ct::public_len_eq(decoded_label_hash, label_hash.as_ref())).into_u8(); let mut seen_separator = 0u8; let mut separator = 0usize; @@ -8324,7 +8324,7 @@ where verifier.update(salt); let expected_h = verifier.finalize(); - if ct::public_len_eq(expected_h.as_ref(), h) { + if ct::public_len_eq(expected_h.as_ref(), h).declassify() { Ok(()) } else { Err(VerificationError::new()) @@ -8359,8 +8359,8 @@ where valid &= byte == 0xff; } valid &= encoded.get(separator_index).copied() == Some(0x00); - valid &= ct::public_len_eq(prefix, digest_info_prefix); - valid &= ct::public_len_eq(value, digest.as_ref()); + valid &= ct::public_len_eq(prefix, digest_info_prefix).declassify(); + valid &= ct::public_len_eq(value, digest.as_ref()).declassify(); if valid { Ok(()) } else { Err(VerificationError::new()) } } @@ -8480,16 +8480,13 @@ fn ct_unsigned_be_lt_public_shape(left: &[u8], right: &[u8]) -> bool { } fn ct_slices_eq_public_shape(left: &[u8], right: &[u8]) -> bool { - if left.len() != right.len() { - return false; - } - ct::public_len_eq(left, right) + ct::public_len_eq(left, right).declassify() } fn product_matches_unsigned_be_fixed(left: &[u8], right: &[u8], expected: &[u8]) -> bool { let mut product = vec![0u8; expected.len()]; - let matched = private_import_product_unsigned_be_to_fixed(left, right, &mut product).is_ok() - && ct::public_len_eq(&product, expected); + let imported = private_import_product_unsigned_be_to_fixed(left, right, &mut product).is_ok(); + let matched = imported & ct::public_len_eq(&product, expected).declassify(); ct::zeroize(&mut product); matched } @@ -8506,7 +8503,7 @@ fn ct_eq_left_padded_unsigned_be(value: &[u8], expected: &[u8]) -> bool { dst.copy_from_slice(value); let eq = ct::public_len_eq(&padded, expected); ct::zeroize(&mut padded); - eq + eq.declassify() } fn private_component_modulus(bytes: &[u8]) -> Result { diff --git a/src/auth/scrypt.rs b/src/auth/scrypt.rs index aff89740..864be5ed 100644 --- a/src/auth/scrypt.rs +++ b/src/auth/scrypt.rs @@ -33,7 +33,8 @@ //! destination slice. //! - Allocation failure surfaces as [`ScryptError::AllocationFailed`] rather than a panic. //! - Working buffers (B, V, scratch) are zeroised on drop. -//! - [`Scrypt::verify`] is constant-time with respect to the reference tag bytes. +//! - [`Scrypt::verify`] traverses every reference-tag byte before returning an opaque result; +//! generated-code timing claims remain configuration- and release-evidence-bound. //! //! # Compliance //! @@ -926,7 +927,7 @@ fn scrypt_hash(params: &ScryptParams, password: &[u8], salt: &[u8], out: &mut [u /// scrypt password-hashing (RFC 7914). /// -/// Provides raw derivation and constant-time verification. Use +/// Provides raw derivation and opaque full-tag verification. Use /// `ScryptPassword` (feature `phc-strings`) for generated, bounded PHC password records. /// /// # Examples @@ -965,7 +966,11 @@ impl Scrypt { scrypt_hash(params, password, salt, out) } - /// Verify `expected` against a freshly-computed hash in constant time. + /// Verify `expected` after traversing the freshly computed hash and every + /// expected byte. + /// + /// Generated-code timing claims are configuration- and release-evidence-bound; + /// see `ct.toml`. /// /// # Errors /// @@ -986,7 +991,7 @@ impl Scrypt { let bytes_match = ct::public_len_eq(&actual, expected); ct::zeroize(&mut actual); - let success = !hash_failed & bytes_match; + let success = !hash_failed & bytes_match.declassify(); if core::hint::black_box(success) { Ok(()) } else { @@ -1062,7 +1067,7 @@ impl ScryptPassword { Scrypt::derive(&approved.params, password, approved.salt(), actual.as_mut_array()) .map_err(|_| VerificationError::new())?; let verified = ct::fixed_eq(actual.as_array(), &approved.expected); - if !core::hint::black_box(verified) { + if !core::hint::black_box(verified.declassify()) { return Err(VerificationError::new()); } if approved.params == self.generation && approved.salt_len as usize == PASSWORD_SALT_LEN { diff --git a/src/auth/x25519.rs b/src/auth/x25519.rs index 8b662df9..e0b9c4a9 100644 --- a/src/auth/x25519.rs +++ b/src/auth/x25519.rs @@ -14,7 +14,7 @@ //! let alice_shared = alice.diffie_hellman(&bob_public)?; //! let bob_shared = bob.diffie_hellman(&alice_public)?; //! -//! assert_eq!(alice_shared, bob_shared); +//! assert!(alice_shared.ct_eq(&bob_shared).declassify()); //! assert_eq!(alice_public.as_bytes().len(), X25519PublicKey::LENGTH); //! # Ok::<(), rscrypto::auth::X25519Error>(()) //! ``` @@ -148,18 +148,16 @@ define_unit_error! { /// X25519 secret scalar bytes. pub struct X25519SecretKey([u8; Self::LENGTH]); -impl PartialEq for X25519SecretKey { - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(&self.0, &other.0) - } -} - -impl Eq for X25519SecretKey {} - impl X25519SecretKey { /// Secret key length in bytes. pub const LENGTH: usize = POINT_LENGTH; + /// Compare two secret keys without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(&self.0, &other.0) + } + /// Construct a secret key from its byte representation. #[inline] #[must_use] @@ -496,18 +494,16 @@ impl From for X25519PublicKey { /// X25519 shared secret bytes. pub struct X25519SharedSecret([u8; Self::LENGTH]); -impl PartialEq for X25519SharedSecret { - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(&self.0, &other.0) - } -} - -impl Eq for X25519SharedSecret {} - impl X25519SharedSecret { /// Shared secret length in bytes. pub const LENGTH: usize = POINT_LENGTH; + /// Compare two shared secrets without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(&self.0, &other.0) + } + /// Construct a shared secret from its byte representation. #[inline] #[must_use] diff --git a/src/backend/curve25519.rs b/src/backend/curve25519.rs index 121a2d3e..bf15b085 100644 --- a/src/backend/curve25519.rs +++ b/src/backend/curve25519.rs @@ -289,7 +289,7 @@ impl FieldElement { self.normalize().0.iter().all(|&limb| limb == 0) } - /// Constant-time conditional swap. + /// Branchless conditional swap at the Rust source level. #[inline] #[cfg(feature = "x25519")] #[allow(dead_code)] diff --git a/src/hashes/crypto/blake3/mod.rs b/src/hashes/crypto/blake3/mod.rs index 1b63f56f..696238d1 100644 --- a/src/hashes/crypto/blake3/mod.rs +++ b/src/hashes/crypto/blake3/mod.rs @@ -47,15 +47,6 @@ const OUTPUT_BLOCK_LEN: usize = 2 * OUT_LEN; const CV_STACK_LEN: usize = 54; /// BLAKE3 keyed-hash output (32 bytes). -/// -/// Equality is constant-time, matching the security semantics expected for tag -/// verification. -// Manual `PartialEq` is constant-time but byte-equivalent to a derived -// implementation, so the `Hash`/`Eq` contract `a == b → hash(a) == hash(b)` -// is preserved. The lint flags the divergence between `derive(Hash)` and a -// hand-written `eq`, which is the right shape here: variable-time `==` on a -// MAC-shaped output would be a footgun. -#[allow(clippy::derived_hash_with_manual_eq)] #[derive(Clone, Copy, Default, Hash)] pub struct Blake3KeyedHash([u8; OUT_LEN]); @@ -63,6 +54,12 @@ impl Blake3KeyedHash { /// Keyed-hash output length in bytes. pub const LENGTH: usize = OUT_LEN; + /// Compare two keyed hashes without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> ct::CtDecision { + ct::fixed_eq(&self.0, &other.0) + } + /// Construct from raw bytes. #[inline] #[must_use] @@ -106,15 +103,6 @@ impl AsRef<[u8]> for Blake3KeyedHash { } } -impl PartialEq for Blake3KeyedHash { - #[inline] - fn eq(&self, other: &Self) -> bool { - ct::fixed_eq(&self.0, &other.0) - } -} - -impl Eq for Blake3KeyedHash {} - impl core::fmt::Debug for Blake3KeyedHash { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "Blake3KeyedHash(")?; @@ -2782,14 +2770,15 @@ impl Blake3 { Blake3KeyedHash::from_bytes(digest_public_oneshot(key_words, KEYED_HASH, data)) } - /// Compute and verify the keyed hash of `data` in constant time. + /// Compute and verify the keyed hash through its sealed comparison decision. /// /// This avoids accidental use of short-circuit `==` on `[u8; 32]` when - /// authenticating data with keyed BLAKE3. + /// authenticating data with keyed BLAKE3. Generated-code timing claims are + /// configuration- and release-evidence-bound; see `ct.toml`. #[inline] #[must_use = "keyed-hash verification must be checked; a dropped Result silently accepts a forged tag"] pub fn verify_keyed(key: &[u8; KEY_LEN], data: &[u8], expected: &Blake3KeyedHash) -> Result<(), VerificationError> { - if Self::keyed_digest(key, data) == *expected { + if Self::keyed_digest(key, data).ct_eq(expected).declassify() { Ok(()) } else { Err(VerificationError::new()) @@ -4502,9 +4491,10 @@ mod tests { for chunk in input.chunks(split) { h.update(chunk); } - assert_eq!( - Blake3KeyedHash::from_bytes(h.finalize()), - expected_keyed, + assert!( + Blake3KeyedHash::from_bytes(h.finalize()) + .ct_eq(&expected_keyed) + .declassify(), "keyed streaming mismatch len={len} split={split}" ); } diff --git a/src/lib.rs b/src/lib.rs index 1749cf35..ccbb13c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,14 +106,15 @@ assert!( //! //! # Security Posture //! -//! Constant-time equality and fixed-width verification checks where the input -//! shape has already reached the primitive boundary. Public structural rejects -//! such as malformed lengths, unsupported algorithms, or out-of-range RSA -//! representatives may fail before the full primitive work. Opaque verification -//! errors leak no failure detail. Zeroize on drop for every secret-bearing -//! type. `strict_*` arithmetic on counters and lengths; release builds keep -//! `overflow-checks = true`. Continuous libFuzzer with corpus replay in CI; -//! Miri on the portable backends. +//! Fixed-size secret-bearing owners expose comparison through an opaque +//! [`ct::CtDecision`] and require explicit declassification. Public structural +//! rejects such as malformed lengths, unsupported algorithms, or out-of-range +//! RSA representatives may fail before the full primitive work. Opaque +//! verification errors leak no failure detail. Generated-code constant-time +//! claims remain compiler-, target-, feature-, and release-evidence-bound. +//! Zeroize on drop for every secret-bearing type. `strict_*` arithmetic on +//! counters and lengths; release builds keep `overflow-checks = true`. +//! Continuous libFuzzer with corpus replay in CI; Miri on the portable backends. //! //! `rscrypto` is a primitives crate, not a FIPS 140-3 validated module. It //! exposes FIPS-aligned primitives (AES-256-GCM, SHA-2, SHA-3 / SHAKE, HMAC, @@ -848,12 +849,75 @@ let tag = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); let _ = tag == [0u8; HmacSha256Tag::LENGTH]; ``` +```compile_fail +use rscrypto::HmacSha256Tag; + +let left = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let right = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let _ = left == right; +``` + +```compile_fail +use rscrypto::HmacSha256Tag; + +let left = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let right = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let _: bool = left.ct_eq(&right); +``` + +```compile_fail +use rscrypto::HmacSha256Tag; + +let left = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let right = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let _: bool = left.ct_eq(&right).into(); +``` + +```compile_fail +use rscrypto::HmacSha256Tag; + +let left = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let right = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +if left.ct_eq(&right) {} +``` + +```compile_fail +let _ = rscrypto::ct::CtDecision { mask: u8::MAX }; +``` + +```compile_fail +use rscrypto::HmacSha256Tag; + +let left = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let right = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let decision = left.ct_eq(&right); +let _ = decision.declassify(); +let _ = decision.declassify(); +``` + +```compile_fail +use rscrypto::HmacSha256Tag; + +let first = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let second = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let third = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let _ = first.ct_eq(&second) == second.ct_eq(&third); +``` + +```compile_fail +use rscrypto::HmacSha256Tag; + +let left = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let right = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let _ = format!("{:?}", left.ct_eq(&right)); +``` + ```rust use rscrypto::HmacSha256Tag; let left = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); let right = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); -assert!(left == right); +assert!(left.ct_eq(&right).declassify()); ``` "#] pub struct __OwnerEqualityBoundaryAudit; diff --git a/src/macros.rs b/src/macros.rs index 09e5b992..a6851b4f 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -445,18 +445,16 @@ macro_rules! define_aead_key_type { #[doc = $doc] pub struct $name([u8; Self::LENGTH]); - impl PartialEq for $name { - fn eq(&self, other: &Self) -> bool { - crate::traits::ct::fixed_eq(&self.0, &other.0) - } - } - - impl Eq for $name {} - impl $name { /// Key length in bytes. pub const LENGTH: usize = $len; + /// Compare two keys without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> crate::traits::ct::CtDecision { + crate::traits::ct::fixed_eq(&self.0, &other.0) + } + /// Construct a typed key from raw bytes. #[inline] #[must_use] @@ -551,15 +549,6 @@ macro_rules! define_aead_tag_type { #[derive(Clone, Copy)] pub struct $name([u8; Self::LENGTH]); - impl PartialEq for $name { - #[inline] - fn eq(&self, other: &Self) -> bool { - crate::traits::ct::fixed_eq(&self.0, &other.0) - } - } - - impl Eq for $name {} - impl core::hash::Hash for $name { #[inline] fn hash(&self, state: &mut H) { @@ -571,6 +560,12 @@ macro_rules! define_aead_tag_type { /// Tag length in bytes. pub const LENGTH: usize = $len; + /// Compare two tags without exposing a branchable boolean. + #[inline] + pub fn ct_eq(&self, other: &Self) -> crate::traits::ct::CtDecision { + crate::traits::ct::fixed_eq(&self.0, &other.0) + } + /// Construct a typed tag from raw bytes. #[inline] #[must_use] diff --git a/src/traits/aead.rs b/src/traits/aead.rs index ad90c16d..a1fdb56a 100644 --- a/src/traits/aead.rs +++ b/src/traits/aead.rs @@ -50,13 +50,13 @@ pub trait Aead { const TAG_SIZE: usize; /// Algorithm-specific key wrapper. - type Key: Eq; + type Key; /// Algorithm-specific nonce wrapper. type Nonce: AeadNonce; /// Algorithm-specific tag wrapper. - type Tag: Copy + Eq + Debug + AsRef<[u8]>; + type Tag: Copy + Debug + AsRef<[u8]>; /// Construct a new AEAD instance from `key`. #[must_use] diff --git a/src/traits/ct.rs b/src/traits/ct.rs index b56e85ee..34e55f81 100644 --- a/src/traits/ct.rs +++ b/src/traits/ct.rs @@ -1,5 +1,88 @@ //! Secret-handling utilities for cryptographic operations. +/// Opaque result of a content-independent cryptographic comparison. +/// +/// Safe code can obtain a decision only from a semantic owner such as a key, +/// shared secret, or authentication tag. The private representation prevents +/// implicit conversion or inspection; [`declassify`](Self::declassify) is the +/// single explicit boundary that exposes the equality result as a `bool`. +/// +/// `CtDecision` preserves the source-level comparison structure. Constant-time +/// claims still depend on the exact compiler, target, features, and generated +/// binary covered by the release evidence in `ct.toml`. +#[must_use = "a cryptographic comparison decision must be composed or explicitly declassified"] +pub struct CtDecision { + mask: u8, +} + +impl CtDecision { + const TRUE_MASK: u8 = u8::MAX; + + #[inline(always)] + const fn from_difference(difference: u64) -> Self { + let nonzero = ((difference | difference.wrapping_neg()) >> 63) as u8; + Self { + mask: nonzero.wrapping_sub(1), + } + } + + /// Expose the comparison result as a branchable boolean. + /// + /// Call this only at the semantic boundary where revealing equality is + /// intended. Declassification consumes the decision so the opaque value + /// cannot accidentally be reused after that boundary. + #[inline(always)] + #[must_use] + pub const fn declassify(self) -> bool { + self.mask == Self::TRUE_MASK + } + + #[inline(always)] + #[allow(dead_code)] + pub(crate) const fn into_u8(self) -> u8 { + self.mask & 1 + } + + #[inline(always)] + #[allow(dead_code)] + pub(crate) const fn into_mask(self) -> u8 { + self.mask + } +} + +impl core::ops::BitAnd for CtDecision { + type Output = Self; + + #[inline(always)] + fn bitand(self, rhs: Self) -> Self::Output { + Self { + mask: self.mask & rhs.mask, + } + } +} + +impl core::ops::BitOr for CtDecision { + type Output = Self; + + #[inline(always)] + fn bitor(self, rhs: Self) -> Self::Output { + Self { + mask: self.mask | rhs.mask, + } + } +} + +impl core::ops::Not for CtDecision { + type Output = Self; + + #[inline(always)] + fn not(self) -> Self::Output { + Self { + mask: self.mask ^ Self::TRUE_MASK, + } + } +} + #[inline(always)] fn byte_difference(left: &[u8], right: &[u8]) -> u64 { let mut difference = 0u64; @@ -28,10 +111,9 @@ fn byte_difference(left: &[u8], right: &[u8]) -> u64 { /// evidence is tracked separately in `ct.toml`; source structure is not a /// universal constant-time guarantee. #[inline(always)] -#[must_use] #[allow(dead_code)] -pub(crate) fn fixed_eq(left: &[u8; N], right: &[u8; N]) -> bool { - byte_difference(left, right) == 0 +pub(crate) fn fixed_eq(left: &[u8; N], right: &[u8; N]) -> CtDecision { + CtDecision::from_difference(byte_difference(left, right)) } /// Compare two byte slices whose lengths are public protocol inputs. @@ -40,13 +122,12 @@ pub(crate) fn fixed_eq(left: &[u8; N], right: &[u8; N]) -> bool /// traversed without content-dependent exits. Keep callers individually /// classified in `ct.toml`; fixed-shape owner types must use [`fixed_eq`]. #[inline] -#[must_use] #[allow(dead_code)] -pub(crate) fn public_len_eq(left: &[u8], right: &[u8]) -> bool { +pub(crate) fn public_len_eq(left: &[u8], right: &[u8]) -> CtDecision { if left.len() != right.len() { - return false; + return CtDecision::from_difference(1); } - byte_difference(left, right) == 0 + CtDecision::from_difference(byte_difference(left, right)) } /// Volatile-zero a byte slice without emitting a compiler fence. @@ -158,19 +239,32 @@ mod tests { #[test] fn fixed_eq_checks_every_position() { let value = [0x5a; 64]; - assert!(fixed_eq(&value, &value)); + assert!(fixed_eq(&value, &value).declassify()); for index in [0, value.len() / 2, value.len() - 1] { let mut different = value; different[index] ^= 1; - assert!(!fixed_eq(&value, &different)); + assert!(!fixed_eq(&value, &different).declassify()); } } #[test] fn public_len_eq_exposes_only_length_and_result() { - assert!(public_len_eq(b"abcdef", b"abcdef")); - assert!(!public_len_eq(b"abcdef", b"abcdeg")); - assert!(!public_len_eq(b"abcdef", b"abcde")); + assert!(public_len_eq(b"abcdef", b"abcdef").declassify()); + assert!(!public_len_eq(b"abcdef", b"abcdeg").declassify()); + assert!(!public_len_eq(b"abcdef", b"abcde").declassify()); + } + + #[test] + fn decisions_compose_before_declassification() { + let equal = fixed_eq(b"equal", b"equal"); + let different = fixed_eq(b"equal", b"other"); + assert!(!(equal & different).declassify()); + + let equal = fixed_eq(b"equal", b"equal"); + let different = fixed_eq(b"equal", b"other"); + assert!((equal | different).declassify()); + + assert!((!fixed_eq(b"equal", b"other")).declassify()); } // ── zeroize ───────────────────────────────────────────────────────────── diff --git a/src/traits/error.rs b/src/traits/error.rs index 267609f3..96045ac3 100644 --- a/src/traits/error.rs +++ b/src/traits/error.rs @@ -1,13 +1,14 @@ //! Error types for cryptographic operations. //! -//! Minimal, timing-safe error types designed to prevent information leakage. +//! Minimal, opaque error types designed to avoid failure-detail leakage. //! Individual crates may define additional errors as needed. define_unit_error! { /// Verification failed. /// /// Returned when cryptographic verification fails (MAC tags, AEAD tags, - /// signatures). Intentionally opaque to prevent timing side-channels. + /// signatures). Intentionally opaque so callers cannot distinguish internal + /// failure causes. /// /// # Examples /// @@ -23,12 +24,12 @@ define_unit_error! { /// /// # Security /// - /// This error provides no details about the failure to prevent timing - /// side-channels. Treat it as a generic authentication failure and avoid - /// mapping it to finer-grained protocol responses that would recreate an - /// oracle. The underlying verification should compare a fixed-size semantic - /// owner whose implementation is covered by the constant-time evidence - /// policy in `ct.toml`. + /// This error provides no details about the failure. Treat it as a generic + /// authentication failure and avoid mapping it to finer-grained protocol + /// responses that would recreate an oracle. Opacity does not by itself make + /// success and failure take identical time. The underlying verification + /// should use a sealed comparison decision, with any generated-code timing + /// claim bounded by `ct.toml` and matching release evidence. #[non_exhaustive] pub struct VerificationError; "verification failed" diff --git a/src/traits/kem.rs b/src/traits/kem.rs index f54371fd..e69d397c 100644 --- a/src/traits/kem.rs +++ b/src/traits/kem.rs @@ -30,13 +30,13 @@ pub trait Kem { type EncapsulationKey: Clone + Eq + Debug + AsRef<[u8]>; /// Private key used by the decapsulating party. - type DecapsulationKey: Eq + Debug + AsRef<[u8]>; + type DecapsulationKey: Debug + AsRef<[u8]>; /// Public ciphertext sent by the encapsulating party. type Ciphertext: Clone + Eq + Debug + AsRef<[u8]>; /// Secret established by encapsulation or decapsulation. - type SharedSecret: Eq + Debug + AsRef<[u8]>; + type SharedSecret: Debug + AsRef<[u8]>; /// Error returned by key generation. type KeyGenerationError; diff --git a/src/traits/mac.rs b/src/traits/mac.rs index aa7460d5..bbdc1ae1 100644 --- a/src/traits/mac.rs +++ b/src/traits/mac.rs @@ -11,20 +11,19 @@ use crate::traits::VerificationError; /// /// This trait is intended for fixed-size algorithms like HMAC-SHA256. /// -/// Each implementation owns a semantic tag type whose [`Eq`] implementation -/// defines verification behavior. rscrypto's built-in MACs compare their -/// fixed-size tag owners without content-dependent exits. External -/// implementations must provide the same property; the trait cannot prove it. +/// Each implementation owns both a semantic tag type and its verification +/// operation. rscrypto's built-in MACs keep fixed-size tag comparison opaque +/// until verification returns one success or failure result. External +/// implementations must provide their own equivalent verification boundary; +/// the trait cannot prove its timing behavior. pub trait Mac: Clone { /// Tag size in bytes. const TAG_SIZE: usize; /// The authentication tag type. /// - /// This should be a semantic owner type, not a raw byte array. Its [`Eq`] - /// implementation is used by the default verification methods and must not - /// exit based on secret tag contents. - type Tag: Copy + Eq + Debug + AsRef<[u8]>; + /// This should be a semantic owner type, not a raw byte array. + type Tag: Copy + Debug + AsRef<[u8]>; /// Construct a new MAC state with `key`. #[must_use] @@ -87,25 +86,16 @@ pub trait Mac: Clone { Self::mac(key, data).as_ref().to_vec() } - /// Verify `expected` against the current tag using the tag owner's equality. - #[inline] + /// Verify `expected` against the current tag. #[must_use = "MAC verification must be checked; a dropped Result silently accepts a forged tag"] - fn verify(&self, expected: &Self::Tag) -> Result<(), VerificationError> { - if self.finalize() == *expected { - Ok(()) - } else { - Err(VerificationError::new()) - } - } + fn verify(&self, expected: &Self::Tag) -> Result<(), VerificationError>; - /// Compute and verify a tag in one shot using the tag owner's equality. + /// Compute and verify a tag in one shot. #[inline] #[must_use = "MAC verification must be checked; a dropped Result silently accepts a forged tag"] fn verify_tag(key: &[u8], data: &[u8], expected: &Self::Tag) -> Result<(), VerificationError> { - if Self::mac(key, data) == *expected { - Ok(()) - } else { - Err(VerificationError::new()) - } + let mut mac = Self::new(key); + mac.update(data); + mac.verify(expected) } } diff --git a/tests/aead_foundations.rs b/tests/aead_foundations.rs index 72fa0803..68b5d778 100644 --- a/tests/aead_foundations.rs +++ b/tests/aead_foundations.rs @@ -102,7 +102,7 @@ fn detached_aliases_match_core_behavior() { let tag_right = aead.encrypt_in_place_detached(&nonce, aad, &mut right).unwrap(); assert_eq!(left, right); - assert_eq!(tag_left, tag_right); + assert!(tag_left.ct_eq(&tag_right).declassify()); aead .decrypt_in_place_detached(&nonce, aad, &mut right, &tag_right) diff --git a/tests/api_consistency.rs b/tests/api_consistency.rs index c477810e..ccf341c1 100644 --- a/tests/api_consistency.rs +++ b/tests/api_consistency.rs @@ -114,18 +114,13 @@ fn squeeze_32(mut reader: impl Xof) -> [u8; 32] { } #[cfg(any(feature = "hmac", feature = "hmac-sha3"))] -fn assert_mac_api() -where - M: Mac, - M::Tag: PartialEq + core::fmt::Debug, -{ +fn assert_mac_api() { let key = b"api-consistency-key"; let mut mac = M::new(key); mac.update(b"abc"); let expected = mac.finalize(); mac.reset(); mac.update(b"abc"); - assert_eq!(mac.finalize(), expected); assert!(mac.verify(&expected).is_ok()); } diff --git a/tests/argon2_miri.rs b/tests/argon2_miri.rs index aad94811..8460ee76 100644 --- a/tests/argon2_miri.rs +++ b/tests/argon2_miri.rs @@ -56,7 +56,7 @@ fn argon2id_minimal_p2_no_ub() { assert_ne!(out, [0u8; 4]); } -/// Verify path — exercises the `H'` expansion + constant-time tag compare. +/// Verify path — exercises the `H'` expansion and full stored-tag comparison. #[test] fn argon2id_minimal_verify_no_ub() { let params = Argon2Params::new(8, 1, 1).expect("minimal params are valid"); diff --git a/tests/chacha20poly1305.rs b/tests/chacha20poly1305.rs index 97934d52..0c36a0e8 100644 --- a/tests/chacha20poly1305.rs +++ b/tests/chacha20poly1305.rs @@ -195,8 +195,8 @@ fn chacha20poly1305_diag_x86_asm_encrypt_matches_owned_path() { actual, expected, "x86 asm ciphertext mismatch plaintext_len={plaintext_len} aad_len={aad_len}" ); - assert_eq!( - actual_tag, expected_tag, + assert!( + actual_tag.ct_eq(&expected_tag).declassify(), "x86 asm tag mismatch plaintext_len={plaintext_len} aad_len={aad_len}" ); } @@ -234,8 +234,8 @@ fn chacha20poly1305_diag_owned_par4_encrypt_matches_normal_path() { actual, expected, "owned par4 ciphertext mismatch plaintext_len={plaintext_len} aad_len={aad_len}" ); - assert_eq!( - actual_tag, expected_tag, + assert!( + actual_tag.ct_eq(&expected_tag).declassify(), "owned par4 tag mismatch plaintext_len={plaintext_len} aad_len={aad_len}" ); } diff --git a/tests/hmac_sha256_proptest.rs b/tests/hmac_sha256_proptest.rs index 8f3f445c..e747e7e6 100644 --- a/tests/hmac_sha256_proptest.rs +++ b/tests/hmac_sha256_proptest.rs @@ -35,6 +35,6 @@ proptest! { } let streaming = mac.finalize(); - prop_assert_eq!(streaming, oneshot); + prop_assert!(streaming.ct_eq(&oneshot).declassify()); } } diff --git a/tests/hmac_sha256_vectors.rs b/tests/hmac_sha256_vectors.rs index 6eccfd77..cbdf6721 100644 --- a/tests/hmac_sha256_vectors.rs +++ b/tests/hmac_sha256_vectors.rs @@ -33,7 +33,10 @@ fn hmac_sha256_rfc4231_vectors() { for (i, (key, data, expected_hex)) in cases.iter().enumerate() { let expected = HmacSha256Tag::from_bytes(decode_hex::<32>(&expected_hex.replace('\n', ""))); let actual = HmacSha256::mac(key, data); - assert_eq!(actual, expected, "HMAC-SHA256 RFC 4231 vector {i} mismatch"); + assert!( + actual.ct_eq(&expected).declassify(), + "HMAC-SHA256 RFC 4231 vector {i} mismatch" + ); assert!(HmacSha256::verify_tag(key, data, &expected).is_ok()); } } @@ -84,6 +87,9 @@ fn hmac_sha256_oneshot_matches_streaming() { mac.update(&data); let streaming = mac.finalize(); - assert_eq!(oneshot, streaming, "oneshot != streaming at data len {len}"); + assert!( + oneshot.ct_eq(&streaming).declassify(), + "oneshot != streaming at data len {len}" + ); } } diff --git a/tests/hmac_sha2_family_vectors.rs b/tests/hmac_sha2_family_vectors.rs index b7e3cbe4..4abe584b 100644 --- a/tests/hmac_sha2_family_vectors.rs +++ b/tests/hmac_sha2_family_vectors.rs @@ -46,7 +46,10 @@ fn hmac_sha384_rfc4231_vectors() { for (i, (key, data, expected_hex)) in cases.iter().enumerate() { let expected = HmacSha384Tag::from_bytes(decode_hex::<48>(expected_hex)); let actual = HmacSha384::mac(key, data); - assert_eq!(actual, expected, "HMAC-SHA384 RFC 4231 vector {i} mismatch"); + assert!( + actual.ct_eq(&expected).declassify(), + "HMAC-SHA384 RFC 4231 vector {i} mismatch" + ); assert!(HmacSha384::verify_tag(key, data, &expected).is_ok()); } @@ -98,7 +101,10 @@ fn hmac_sha512_rfc4231_vectors() { for (i, (key, data, expected_hex)) in cases.iter().enumerate() { let expected = HmacSha512Tag::from_bytes(decode_hex::<64>(expected_hex)); let actual = HmacSha512::mac(key, data); - assert_eq!(actual, expected, "HMAC-SHA512 RFC 4231 vector {i} mismatch"); + assert!( + actual.ct_eq(&expected).declassify(), + "HMAC-SHA512 RFC 4231 vector {i} mismatch" + ); assert!(HmacSha512::verify_tag(key, data, &expected).is_ok()); } @@ -155,18 +161,16 @@ fn hmac_sha2_64bit_family_oneshot_matches_streaming() { let oneshot384 = HmacSha384::mac(key, &data); let mut streaming384 = HmacSha384::new(key); streaming384.update(&data); - assert_eq!( - oneshot384, - streaming384.finalize(), + assert!( + oneshot384.ct_eq(&streaming384.finalize()).declassify(), "HMAC-SHA384 oneshot != streaming at len {len}" ); let oneshot512 = HmacSha512::mac(key, &data); let mut streaming512 = HmacSha512::new(key); streaming512.update(&data); - assert_eq!( - oneshot512, - streaming512.finalize(), + assert!( + oneshot512.ct_eq(&streaming512.finalize()).declassify(), "HMAC-SHA512 oneshot != streaming at len {len}" ); } diff --git a/tests/hmac_sha384_proptest.rs b/tests/hmac_sha384_proptest.rs index a9e8e3a6..ae121f29 100644 --- a/tests/hmac_sha384_proptest.rs +++ b/tests/hmac_sha384_proptest.rs @@ -35,6 +35,6 @@ proptest! { } let streaming = mac.finalize(); - prop_assert_eq!(streaming, oneshot); + prop_assert!(streaming.ct_eq(&oneshot).declassify()); } } diff --git a/tests/hmac_sha3_vectors.rs b/tests/hmac_sha3_vectors.rs index 26471623..49b083df 100644 --- a/tests/hmac_sha3_vectors.rs +++ b/tests/hmac_sha3_vectors.rs @@ -64,9 +64,8 @@ macro_rules! assert_hmac_sha3 { let expected = <$tag>::from_bytes(hmac_sha3_oracle!($oracle, &key, &data, $tag_len, $rate)); - assert_eq!( - <$ours>::mac(&key, &data), - expected, + assert!( + <$ours>::mac(&key, &data).ct_eq(&expected).declassify(), "{} one-shot mismatch key_len={} data_len={}", $name, key_len, @@ -78,9 +77,8 @@ macro_rules! assert_hmac_sha3 { for chunk in data.chunks(chunk_len) { streaming.update(chunk); } - assert_eq!( - streaming.finalize(), - expected, + assert!( + streaming.finalize().ct_eq(&expected).declassify(), "{} streaming mismatch key_len={} data_len={} chunk_len={}", $name, key_len, @@ -90,7 +88,11 @@ macro_rules! assert_hmac_sha3 { streaming.reset(); streaming.update(&data); - assert_eq!(streaming.finalize(), expected, "{} reset mismatch", $name); + assert!( + streaming.finalize().ct_eq(&expected).declassify(), + "{} reset mismatch", + $name + ); let mut corrupted = expected.to_bytes(); if !corrupted.is_empty() { diff --git a/tests/hmac_sha512_proptest.rs b/tests/hmac_sha512_proptest.rs index 92b02b5b..0352f94a 100644 --- a/tests/hmac_sha512_proptest.rs +++ b/tests/hmac_sha512_proptest.rs @@ -35,6 +35,6 @@ proptest! { } let streaming = mac.finalize(); - prop_assert_eq!(streaming, oneshot); + prop_assert!(streaming.ct_eq(&oneshot).declassify()); } } diff --git a/tests/mlkem_ops.rs b/tests/mlkem_ops.rs index 37183153..3b0adcf3 100644 --- a/tests/mlkem_ops.rs +++ b/tests/mlkem_ops.rs @@ -55,7 +55,7 @@ macro_rules! mlkem_profile_tests { .unwrap(); let decapsulated = <$profile>::decapsulate(&dk, &ciphertext).unwrap(); - assert_eq!(encapsulated, decapsulated); + assert!(encapsulated.ct_eq(&decapsulated).declassify()); } #[test] @@ -90,7 +90,7 @@ macro_rules! mlkem_profile_tests { let profile_prepared_ek = <$profile>::prepare_encapsulation_key(&ek).unwrap(); let profile_prepared_dk = <$profile>::prepare_decapsulation_key(&dk).unwrap(); assert_eq!(prepared_ek, profile_prepared_ek); - assert_eq!(prepared_dk, profile_prepared_dk); + assert!(prepared_dk.ct_eq(&profile_prepared_dk).declassify()); let (validating_ciphertext, validating_shared) = <$profile>::encapsulate(&ek, |out| { out.copy_from_slice(&encaps_random); @@ -105,15 +105,14 @@ macro_rules! mlkem_profile_tests { .unwrap(); assert_eq!(validating_ciphertext, prepared_ciphertext); - assert_eq!(validating_shared, prepared_shared); - assert_eq!( - prepared_dk.decapsulate(&prepared_ciphertext).unwrap(), - validating_shared - ); - assert_eq!( - <$profile>::decapsulate_prepared(&prepared_dk, &prepared_ciphertext).unwrap(), - <$profile>::decapsulate(&dk, &prepared_ciphertext).unwrap() - ); + assert!(validating_shared.ct_eq(&prepared_shared).declassify()); + + let prepared_decapsulated = prepared_dk.decapsulate(&prepared_ciphertext).unwrap(); + assert!(prepared_decapsulated.ct_eq(&validating_shared).declassify()); + + let profile_prepared_shared = <$profile>::decapsulate_prepared(&prepared_dk, &prepared_ciphertext).unwrap(); + let validating_decapsulated = <$profile>::decapsulate(&dk, &prepared_ciphertext).unwrap(); + assert!(profile_prepared_shared.ct_eq(&validating_decapsulated).declassify()); } #[test] @@ -208,7 +207,7 @@ macro_rules! mlkem_profile_tests { let modified = <$ciphertext>::from_bytes(modified); let rejected = <$profile>::decapsulate(&dk, &modified).unwrap(); - assert_ne!(encapsulated, rejected); + assert!(!encapsulated.ct_eq(&rejected).declassify()); } }; } diff --git a/tests/mlkem_properties.rs b/tests/mlkem_properties.rs index ba67128a..9c3d1f1d 100644 --- a/tests/mlkem_properties.rs +++ b/tests/mlkem_properties.rs @@ -139,7 +139,11 @@ macro_rules! mlkem_profile_properties { modified[byte_idx] ^= 1u8 << bit_idx; let rejected = <$profile>::decapsulate(&dk, &<$ciphertext>::from_bytes(modified)).unwrap(); - prop_assert_ne!(encapsulated, rejected, "{name} modified ciphertext returned original shared secret", name = $name); + prop_assert!( + !encapsulated.ct_eq(&rejected).declassify(), + "{name} modified ciphertext returned original shared secret", + name = $name + ); } #[test] diff --git a/tests/mlkem_types.rs b/tests/mlkem_types.rs index 570ca368..1c7133c3 100644 --- a/tests/mlkem_types.rs +++ b/tests/mlkem_types.rs @@ -74,8 +74,18 @@ fn mlkem_secret_values_redact_debug_and_require_explicit_extraction() { let ss768 = MlKem768SharedSecret::from_bytes([0xb2; MlKem768SharedSecret::LENGTH]); assert_eq!(format!("{dk768:?}"), "MlKem768DecapsulationKey(****)"); assert_eq!(format!("{ss768:?}"), "MlKem768SharedSecret(****)"); - assert!(dk768 == MlKem768DecapsulationKey::from_bytes([0xb1; MlKem768DecapsulationKey::LENGTH])); - assert!(ss768 != MlKem768SharedSecret::from_bytes([0xb3; MlKem768SharedSecret::LENGTH])); + assert!( + dk768 + .ct_eq(&MlKem768DecapsulationKey::from_bytes( + [0xb1; MlKem768DecapsulationKey::LENGTH] + )) + .declassify() + ); + assert!( + !ss768 + .ct_eq(&MlKem768SharedSecret::from_bytes([0xb3; MlKem768SharedSecret::LENGTH])) + .declassify() + ); let dk1024 = MlKem1024DecapsulationKey::from_bytes([0xc1; MlKem1024DecapsulationKey::LENGTH]); let ss1024 = MlKem1024SharedSecret::from_bytes([0xc2; MlKem1024SharedSecret::LENGTH]); diff --git a/tests/owned_equality.rs b/tests/owned_equality.rs index e5e86b92..aadfa205 100644 --- a/tests/owned_equality.rs +++ b/tests/owned_equality.rs @@ -1,37 +1,42 @@ -fn assert_owned_equality(make: impl Fn([u8; N]) -> T) -where - T: PartialEq, -{ +fn assert_owned_equality(make: impl Fn([u8; N]) -> T, compare: impl Fn(&T, &T) -> bool) { let bytes = [0x5a; N]; - assert!(make(bytes) == make(bytes)); + assert!(compare(&make(bytes), &make(bytes))); for index in [0, N / 2, N.strict_sub(1)] { let mut different = bytes; different[index] ^= 1; - assert!(make(bytes) != make(different)); + assert!(!compare(&make(bytes), &make(different))); } } #[test] #[cfg(feature = "aes-gcm")] fn aes128_key_owns_16_byte_equality() { - assert_owned_equality(rscrypto::Aes128GcmKey::from_bytes); + assert_owned_equality(rscrypto::Aes128GcmKey::from_bytes, |left, right| { + left.ct_eq(right).declassify() + }); } #[test] #[cfg(feature = "x25519")] fn x25519_secret_key_owns_32_byte_equality() { - assert_owned_equality(rscrypto::X25519SecretKey::from_bytes); + assert_owned_equality(rscrypto::X25519SecretKey::from_bytes, |left, right| { + left.ct_eq(right).declassify() + }); } #[test] #[cfg(feature = "hmac")] fn hmac_sha384_tag_owns_48_byte_equality() { - assert_owned_equality(rscrypto::HmacSha384Tag::from_bytes); + assert_owned_equality(rscrypto::HmacSha384Tag::from_bytes, |left, right| { + left.ct_eq(right).declassify() + }); } #[test] #[cfg(feature = "hmac")] fn hmac_sha512_tag_owns_64_byte_equality() { - assert_owned_equality(rscrypto::HmacSha512Tag::from_bytes); + assert_owned_equality(rscrypto::HmacSha512Tag::from_bytes, |left, right| { + left.ct_eq(right).declassify() + }); } diff --git a/tests/poly1305_vectors.rs b/tests/poly1305_vectors.rs index 26be1fa0..45b3a886 100644 --- a/tests/poly1305_vectors.rs +++ b/tests/poly1305_vectors.rs @@ -14,7 +14,7 @@ fn poly1305_matches_rfc_8439_section_2_5_2() { let expected = Poly1305Tag::from_bytes(decode_hex_array("a8061dc1305136c6c22b8baf0c0127a9")); let tag = Poly1305::authenticate_once(key, message); - assert_eq!(tag, expected); + assert!(tag.ct_eq(&expected).declassify()); } #[test] @@ -27,7 +27,7 @@ fn poly1305_streaming_matches_oneshot() { poly1305.update(b"bcde"); poly1305.update(b"f"); - assert_eq!(poly1305.finalize(), expected); + assert!(poly1305.finalize().ct_eq(&expected).declassify()); } #[test] diff --git a/tests/root_surface.rs b/tests/root_surface.rs index 44a3e075..ca31fb3e 100644 --- a/tests/root_surface.rs +++ b/tests/root_surface.rs @@ -220,17 +220,17 @@ fn root_surface_mac_exports_compile() { let mut mac = HmacSha256::new(key); mac.update(data); - assert_eq!(tag, mac.finalize()); + assert!(tag.ct_eq(&mac.finalize()).declassify()); assert!(mac.verify(&tag).is_ok()); let mut mac384 = HmacSha384::new(key); mac384.update(data); - assert_eq!(tag384, mac384.finalize()); + assert!(tag384.ct_eq(&mac384.finalize()).declassify()); assert!(mac384.verify(&tag384).is_ok()); let mut mac512 = HmacSha512::new(key); mac512.update(data); - assert_eq!(tag512, mac512.finalize()); + assert!(tag512.ct_eq(&mac512.finalize()).declassify()); assert!(mac512.verify(&tag512).is_ok()); } @@ -814,7 +814,7 @@ fn root_surface_key_exchange_exports_compile() { assert_eq!(alice_public.as_bytes().len(), 32); assert_eq!(alice_shared.as_bytes().len(), 32); - assert_eq!(alice_shared, bob_shared); + assert!(alice_shared.ct_eq(&bob_shared).declassify()); let _ = X25519Error::new(); } diff --git a/tests/x25519_oracle.rs b/tests/x25519_oracle.rs index c0ff0857..113696c8 100644 --- a/tests/x25519_oracle.rs +++ b/tests/x25519_oracle.rs @@ -16,7 +16,7 @@ #![cfg(all(feature = "x25519", not(miri)))] use proptest::prelude::*; -use rscrypto::{X25519Error, X25519PublicKey, X25519SecretKey}; +use rscrypto::{X25519PublicKey, X25519SecretKey}; use x25519_dalek::{PublicKey as DalekPublicKey, StaticSecret as DalekStaticSecret}; const PROPTEST_CASES: u32 = if cfg!(debug_assertions) { 64 } else { 256 }; @@ -51,7 +51,7 @@ proptest::proptest! { let ours_result = ours_secret.diffie_hellman(&ours_peer); if dalek_shared == [0u8; 32] { - prop_assert_eq!(ours_result, Err(X25519Error::new()), + prop_assert!(ours_result.is_err(), "dalek emitted all-zero shared secret but rscrypto did not reject"); } else { let ours = ours_result.expect("rscrypto rejected non-low-order point that dalek accepted"); @@ -97,7 +97,7 @@ proptest::proptest! { let dalek_view = dalek_bob.diffie_hellman(&dalek_alice_pub).to_bytes(); if dalek_view == [0u8; 32] { - prop_assert_eq!(ours_view, Err(X25519Error::new())); + prop_assert!(ours_view.is_err()); } else { let ours = ours_view.expect("rscrypto rejected what dalek accepted"); prop_assert_eq!(*ours.as_bytes(), dalek_view); diff --git a/tests/x25519_vectors.rs b/tests/x25519_vectors.rs index a2b76f3d..29dfa028 100644 --- a/tests/x25519_vectors.rs +++ b/tests/x25519_vectors.rs @@ -1,6 +1,6 @@ #![cfg(feature = "x25519")] -use rscrypto::{X25519Error, X25519PublicKey, X25519SecretKey, X25519SharedSecret}; +use rscrypto::{X25519PublicKey, X25519SecretKey, X25519SharedSecret}; use x25519_dalek::{PublicKey as DalekPublicKey, StaticSecret as DalekStaticSecret}; mod common; @@ -79,7 +79,7 @@ fn rfc_7748_diffie_hellman_vector_matches() { assert_eq!(alice.public_key(), alice_public); assert_eq!(bob.public_key(), bob_public); assert_eq!(*alice_shared.as_bytes(), expected); - assert_eq!(alice_shared, bob_shared); + assert!(alice_shared.ct_eq(&bob_shared).declassify()); } #[test] @@ -87,11 +87,8 @@ fn low_order_points_return_all_zero_error() { let secret = X25519SecretKey::from_bytes([0x42; X25519SecretKey::LENGTH]); let low_order = X25519PublicKey::from_bytes([0u8; X25519PublicKey::LENGTH]); - assert_eq!(secret.diffie_hellman(&low_order), Err(X25519Error::new())); - assert_eq!( - X25519SharedSecret::diffie_hellman(&secret, &low_order), - Err(X25519Error::new()) - ); + assert!(secret.diffie_hellman(&low_order).is_err()); + assert!(X25519SharedSecret::diffie_hellman(&secret, &low_order).is_err()); } #[test] @@ -101,9 +98,8 @@ fn non_canonical_public_inputs_are_accepted_and_reduced() { "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", )); - assert_eq!( - secret.diffie_hellman(&public), - Err(X25519Error::new()), + assert!( + secret.diffie_hellman(&public).is_err(), "u = p should reduce to zero and fail the all-zero check" ); assert_eq!( @@ -142,7 +138,7 @@ fn public_keys_and_shared_secrets_match_x25519_dalek() { let dalek_shared = dalek_secret.diffie_hellman(&dalek_peer).to_bytes(); if dalek_shared.iter().all(|&byte| byte == 0) { - assert_eq!(ours_shared, Err(X25519Error::new())); + assert!(ours_shared.is_err()); } else { assert_eq!(*ours_shared.unwrap().as_bytes(), dalek_shared); } diff --git a/tests/x25519_wycheproof.rs b/tests/x25519_wycheproof.rs index 577bcd45..38dde501 100644 --- a/tests/x25519_wycheproof.rs +++ b/tests/x25519_wycheproof.rs @@ -1,6 +1,6 @@ #![cfg(feature = "x25519")] -use rscrypto::{X25519Error, X25519PublicKey, X25519SecretKey}; +use rscrypto::{X25519PublicKey, X25519SecretKey}; use serde_json::Value; mod common; @@ -60,9 +60,8 @@ fn wycheproof_x25519_vectors_match_or_reject_all_zero_shared_secret() { if expected_shared == [0u8; 32] { counts.zero_shared_rejected = counts.zero_shared_rejected.strict_add(1); - assert_eq!( - result, - Err(X25519Error::new()), + assert!( + result.is_err(), "Wycheproof X25519 tcId {} must reject all-zero shared secret", test["tcId"] ); diff --git a/tools/ct-binsec-harness/src/main.rs b/tools/ct-binsec-harness/src/main.rs index 625e0c2a..395fac22 100644 --- a/tools/ct-binsec-harness/src/main.rs +++ b/tools/ct-binsec-harness/src/main.rs @@ -273,7 +273,7 @@ pub extern "C" fn ct_binsec_owner_eq_64() -> ! { let lhs = HmacSha512Tag::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_LHS_64)) }); // SAFETY: These pointers reference fixed harness globals with static storage. let rhs = HmacSha512Tag::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_RHS_64)) }); - ct_binsec_done(u8::from(lhs == rhs)) + ct_binsec_done(u8::from(lhs.ct_eq(&rhs).declassify())) } #[unsafe(no_mangle)] @@ -283,7 +283,7 @@ pub extern "C" fn ct_binsec_owner_eq_48() -> ! { let lhs = HmacSha384Tag::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_LHS_48)) }); // SAFETY: These pointers reference fixed harness globals with static storage. let rhs = HmacSha384Tag::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_RHS_48)) }); - ct_binsec_done(u8::from(lhs == rhs)) + ct_binsec_done(u8::from(lhs.ct_eq(&rhs).declassify())) } #[unsafe(no_mangle)] @@ -293,7 +293,7 @@ pub extern "C" fn ct_binsec_owner_eq_32() -> ! { let lhs = X25519SecretKey::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_LHS_32)) }); // SAFETY: These pointers reference fixed harness globals with static storage. let rhs = X25519SecretKey::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_RHS_32)) }); - ct_binsec_done(u8::from(lhs == rhs)) + ct_binsec_done(u8::from(lhs.ct_eq(&rhs).declassify())) } #[unsafe(no_mangle)] @@ -303,7 +303,7 @@ pub extern "C" fn ct_binsec_owner_eq_16() -> ! { let lhs = Aes128GcmKey::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_LHS_16)) }); // SAFETY: These pointers reference fixed harness globals with static storage. let rhs = Aes128GcmKey::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_RHS_16)) }); - ct_binsec_done(u8::from(lhs == rhs)) + ct_binsec_done(u8::from(lhs.ct_eq(&rhs).declassify())) } #[unsafe(no_mangle)] @@ -314,7 +314,7 @@ pub extern "C" fn ct_binsec_hmac_sha256_verify() -> ! { // SAFETY: These pointers reference fixed harness globals with static storage. let expected = unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_TAG_32)) }; let ok = rscrypto::diag_hmac_sha256_verify_portable(&key, &expected); - ct_binsec_done(u8::from(ok)) + ct_binsec_done(u8::from(ok.declassify())) } #[unsafe(no_mangle)] @@ -325,7 +325,7 @@ pub extern "C" fn ct_binsec_hmac_sha384_verify() -> ! { // SAFETY: These pointers reference fixed harness globals with static storage. let expected = unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_TAG_48)) }; let ok = rscrypto::diag_hmac_sha384_verify_portable(&key, &expected); - ct_binsec_done(u8::from(ok)) + ct_binsec_done(u8::from(ok.declassify())) } #[unsafe(no_mangle)] @@ -336,7 +336,7 @@ pub extern "C" fn ct_binsec_hmac_sha512_verify() -> ! { // SAFETY: These pointers reference fixed harness globals with static storage. let expected = unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_TAG_64)) }; let ok = rscrypto::diag_hmac_sha512_verify_portable(&key, &expected); - ct_binsec_done(u8::from(ok)) + ct_binsec_done(u8::from(ok.declassify())) } #[unsafe(no_mangle)] @@ -359,8 +359,8 @@ pub extern "C" fn ct_binsec_blake3_verify_keyed() -> ! { let expected = unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_TAG_32)) }; let expected = Blake3KeyedHash::from_bytes(expected); let actual = rscrypto::hashes::crypto::diag_blake3_keyed_digest_portable(&key); - let ok = actual == expected; - ct_binsec_done(u8::from(ok)) + let ok = actual.ct_eq(&expected); + ct_binsec_done(u8::from(ok.declassify())) } #[unsafe(no_mangle)] @@ -485,7 +485,7 @@ pub extern "C" fn ct_binsec_ascon_aead128_tag_portable() -> ! { // SAFETY: These pointers reference fixed harness globals with static storage. let expected = unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_RHS_16)) }; let ok = rscrypto::aead::diag_ascon_aead128_tag_portable(&key, &nonce, &block, &expected); - ct_binsec_done(u8::from(ok)) + ct_binsec_done(u8::from(ok.declassify())) } macro_rules! aead_seal_entry { diff --git a/tools/ct-harness/src/lib.rs b/tools/ct-harness/src/lib.rs index cfe6e4d5..9efe7b7f 100644 --- a/tools/ct-harness/src/lib.rs +++ b/tools/ct-harness/src/lib.rs @@ -812,7 +812,7 @@ macro_rules! fixed_owner_eq_entry { return STATUS_ERR; }; - u8::from(<$type>::from_bytes(a) == <$type>::from_bytes(b)) + u8::from(<$type>::from_bytes(a).ct_eq(&<$type>::from_bytes(b)).declassify()) } }; } From 4f77d177a1b789b393afe1045d2736f195bf4d91 Mon Sep 17 00:00:00 2001 From: LoadingALIAS Date: Tue, 21 Jul 2026 16:13:23 -0400 Subject: [PATCH 2/2] aead: migrate POWER tag test to constant-time equality --- src/aead/chacha20poly1305.rs | 4 ++-- src/aead/poly1305.rs | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/aead/chacha20poly1305.rs b/src/aead/chacha20poly1305.rs index bcccee70..1c96b1e8 100644 --- a/src/aead/chacha20poly1305.rs +++ b/src/aead/chacha20poly1305.rs @@ -1090,8 +1090,8 @@ mod tests { actual, expected, "Power short ciphertext mismatch plaintext_len={plaintext_len} aad_len={aad_len}" ); - assert_eq!( - actual_tag, expected_tag, + assert!( + actual_tag.ct_eq(&expected_tag).declassify(), "Power short tag mismatch plaintext_len={plaintext_len} aad_len={aad_len}" ); } diff --git a/src/aead/poly1305.rs b/src/aead/poly1305.rs index a3da3dbd..4ef31e27 100644 --- a/src/aead/poly1305.rs +++ b/src/aead/poly1305.rs @@ -933,7 +933,9 @@ mod tests { use super::authenticate; #[cfg(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "riscv64"))] use super::{ComputeBlockFn, State, authenticate_aead_with}; - use crate::aead::{AeadByteLengths, targets::AeadPrimitive}; + #[cfg(any(target_arch = "x86_64", target_arch = "aarch64", target_arch = "riscv64"))] + use crate::aead::AeadByteLengths; + use crate::aead::targets::AeadPrimitive; #[cfg(target_arch = "aarch64")] use crate::platform::caps::aarch64; #[cfg(target_arch = "riscv64")]