diff --git a/.changes/owned-secret-equality.md b/.changes/owned-secret-equality.md new file mode 100644 index 00000000..264f8f95 --- /dev/null +++ b/.changes/owned-secret-equality.md @@ -0,0 +1,8 @@ +--- +"rscrypto" = "minor" +--- + +Secret equality 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/THREAT_MODEL.md b/THREAT_MODEL.md index a5675dfd..54ca3ff2 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, constant-time byte equality, and selected +7. MAC/tag verification, fixed-size owner equality, and selected password-verification comparisons. Public parsing, raw hashes, checksums, non-cryptographic hashes, public-key @@ -108,7 +108,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`, constant-time equality on secret types | `src/secret.rs` and per-type tests | +| 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 | | 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 1bd84317..8d532a96 100644 --- a/ct.toml +++ b/ct.toml @@ -8,6 +8,68 @@ release_report = "ct-report.json" harness = "tools/ct-harness" artifact_script = "scripts/ct/artifacts.sh" +[operation_inventory] +schema_version = 1 +authority = "ct.toml" +scope = ["production", "feature-gated", "target-gated", "trait-defaults", "escape-hatches"] +completeness = "Every security primitive must be referenced by at least one checked public-operation family; every family must carry evidence or an explicit limitation; the all-features compiler API snapshot prevents unaudited public-surface drift." +compiler_api_prefixes = [ + "rscrypto::aead", + "rscrypto::auth", + "rscrypto::backend", + "rscrypto::hashes::crypto", + "rscrypto::hex", + "rscrypto::secret", + "rscrypto::traits", +] +compiler_api_item_count = 2248 +compiler_api_sha256 = "1179eb9727d21ccfbecbc64bef9e54b2ddb98739991edc1ca1460cd806829ea8" + +[[public_len_comparison]] +id = "kmac.verify_expected_output" +source = "src/auth/kmac.rs" +call_count = 1 +operations = ["kmac.variable_output_and_verify"] +public_length = "The caller selects KMAC output length and supplies an expected buffer of that public length." +secret_contents = "The computed KMAC output and expected bytes are traversed fully for each public-length chunk." +tests = "KMAC NIST/Wycheproof/differential tests plus kmac256_valid_vs_invalid_tag DudeCT." + +[[public_len_comparison]] +id = "rsa.public_modulus_and_profile_shapes" +source = "src/auth/rsa.rs" +call_count = 12 +operations = ["rsa.private_key_import_export_and_generation", "rsa.private_sign_and_decrypt", "rsa.public_operations"] +public_length = "Widths derive from the public modulus, digest algorithm, DER/padding profile, or caller-visible encoded length." +secret_contents = "Private representatives, decoded plaintext/hash fields, or validation intermediates are compared without content-dependent exits after public shape checks." +tests = "RSA official/Wycheproof/differential/private-operation tests and RSA CT harness/DudeCT cases." + +[[public_len_comparison]] +id = "mlkem.parameter_set_shapes" +source = "src/auth/mlkem/portable.rs" +call_count = 1 +operations = ["kem.mlkem512", "kem.mlkem768", "kem.mlkem1024"] +public_length = "Encoded key/ciphertext widths are fixed by the public ML-KEM parameter set and validated before comparison." +secret_contents = "Decapsulation implicit-rejection ciphertext/key material is traversed fully at that public parameter-set width." +tests = "ML-KEM ACVP, differential, property, operation, CT harness, and DudeCT cases for all parameter sets." + +[[public_len_comparison]] +id = "argon2.caller_selected_output" +source = "src/auth/argon2/mod.rs" +call_count = 1 +operations = ["password.argon2i", "password.argon2d_and_argon2id"] +public_length = "Primitive verification derives exactly the caller-supplied expected output length after public bounds checks." +secret_contents = "The freshly derived password verifier and expected bytes are traversed fully before the temporary is cleared." +tests = "Argon2 vectors/differentials/parallel tests and variant CT harness/DudeCT cases." + +[[public_len_comparison]] +id = "scrypt.caller_selected_output" +source = "src/auth/scrypt.rs" +call_count = 1 +operations = ["password.scrypt"] +public_length = "Primitive verification derives exactly the caller-supplied expected output length after public minimum-length checks." +secret_contents = "The freshly derived password verifier and expected bytes are traversed fully before the temporary is cleared." +tests = "scrypt vectors/differentials and verification CT harness/DudeCT cases." + [status] ct_claimed = "all required evidence has passed for the exact primitive/configuration pair" ct_intended = "implementation is in scope for the policy; release evidence must pass before a release claim is made" @@ -50,10 +112,10 @@ path = "tools/ct-harness" kind = "staticlib" status = "covered" symbols = [ - "ct_entry_constant_time_eq", - "ct_entry_secret_bytes32_eq", - "ct_entry_x25519_secret_eq", - "ct_entry_ed25519_secret_eq", + "ct_entry_owner_eq_16", + "ct_entry_owner_eq_32", + "ct_entry_owner_eq_48", + "ct_entry_owner_eq_64", "ct_entry_secret_bytes32_debug_masked", "ct_entry_hmac_sha256_verify", "ct_entry_hmac_sha384_verify", @@ -120,8 +182,8 @@ symbols = [ "ct_entry_classification_best_effort", ] covers = [ - "ct.constant_time_eq", - "secret_wrappers.equality_and_display", + "owner_equality.fixed", + "secret_wrappers.exposure_and_display", "mac.hmac_verify", "mac.kmac256_verify", "keyed_hash.blake3_verify", @@ -148,18 +210,45 @@ covers = [ notes = "Harness coverage is complete for Step 3/4/5. This does not make release CT claims; ct-claimed still requires the evidence gates in the required profiles." [[dudect_case]] -name = "constant_time_eq_equal_vs_first_diff" -primitive = "ct.constant_time_eq" -filter = "constant_time_eq" -left_class = "equal 64-byte inputs" -right_class = "same length with first byte different" +name = "owner_eq_16_equal_vs_first_diff" +primitive = "owner_equality.fixed" +filter = "owner_eq_16" +left_class = "equal Aes128GcmKey owners" +right_class = "Aes128GcmKey owners with first byte different" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "owner_eq_32_equal_vs_first_diff" +primitive = "owner_equality.fixed" +filter = "owner_eq_32" +left_class = "equal X25519SecretKey owners" +right_class = "X25519SecretKey owners with first byte different" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "owner_eq_48_equal_vs_first_diff" +primitive = "owner_equality.fixed" +filter = "owner_eq_48" +left_class = "equal HmacSha384Tag owners" +right_class = "HmacSha384Tag owners with first byte different" samples = 20000 smoke_samples = 2000 [[dudect_case]] -name = "secret_wrappers_eq_and_debug_fixed_vs_random" -primitive = "secret_wrappers.equality_and_display" -filter = "secret_wrappers" +name = "owner_eq_64_equal_vs_first_diff" +primitive = "owner_equality.fixed" +filter = "owner_eq_64" +left_class = "equal HmacSha512Tag owners" +right_class = "HmacSha512Tag owners with first byte different" +samples = 20000 +smoke_samples = 2000 + +[[dudect_case]] +name = "secret_wrappers_debug_fixed_vs_random" +primitive = "secret_wrappers.exposure_and_display" +filter = "secret_wrappers_debug" left_class = "fixed secret bytes" right_class = "random secret bytes" samples = 20000 @@ -1243,6 +1332,34 @@ right_class = "parallel BLAKE3 keyed digest over 1 MiB with random same-length k samples = 4096 smoke_samples = 512 +[[evidence_unit]] +id = "owner_equality.fixed.16" +primitive = "owner_equality.fixed" +variant = "Owner16" +dudect = ["owner_eq_16_equal_vs_first_diff"] +binsec = ["owner_equality.fixed.16.portable.all"] + +[[evidence_unit]] +id = "owner_equality.fixed.32" +primitive = "owner_equality.fixed" +variant = "Owner32" +dudect = ["owner_eq_32_equal_vs_first_diff"] +binsec = ["owner_equality.fixed.32.portable.all"] + +[[evidence_unit]] +id = "owner_equality.fixed.48" +primitive = "owner_equality.fixed" +variant = "Owner48" +dudect = ["owner_eq_48_equal_vs_first_diff"] +binsec = ["owner_equality.fixed.48.portable.all"] + +[[evidence_unit]] +id = "owner_equality.fixed.64" +primitive = "owner_equality.fixed" +variant = "Owner64" +dudect = ["owner_eq_64_equal_vs_first_diff"] +binsec = ["owner_equality.fixed.64.portable.all"] + [[evidence_unit]] id = "mac.hmac_verify.sha256" primitive = "mac.hmac_verify" @@ -1548,9 +1665,9 @@ binsec = ["kdf.pbkdf2.sha512_verify.portable.all"] notes = "The DudeCT case executes Pbkdf2Sha256 and Pbkdf2Sha512 with the same class input." [[binsec_kernel]] -id = "ct.constant_time_eq.64.x86_64_linux" -primitive = "ct.constant_time_eq" -symbol = "ct_binsec_constant_time_eq_64" +id = "owner_equality.fixed.64.x86_64_linux" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_64" tier = "A" required = true targets = ["x86_64-unknown-linux-gnu"] @@ -1564,9 +1681,9 @@ assumptions = [] claim = "ct-intended" [[binsec_kernel]] -id = "ct.constant_time_eq.32.x86_64_linux" -primitive = "ct.constant_time_eq" -symbol = "ct_binsec_constant_time_eq_32" +id = "owner_equality.fixed.32.x86_64_linux" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_32" tier = "A" required = true targets = ["x86_64-unknown-linux-gnu"] @@ -1580,9 +1697,9 @@ assumptions = [] claim = "ct-intended" [[binsec_kernel]] -id = "ct.constant_time_eq.16.x86_64_linux" -primitive = "ct.constant_time_eq" -symbol = "ct_binsec_constant_time_eq_16" +id = "owner_equality.fixed.16.x86_64_linux" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_16" tier = "A" required = true targets = ["x86_64-unknown-linux-gnu"] @@ -1596,19 +1713,20 @@ assumptions = [] claim = "ct-intended" [[binsec_kernel]] -id = "secret_wrappers.secret_bytes32_eq.x86_64_linux" -primitive = "secret_wrappers.equality_and_display" -symbol = "ct_binsec_secret_bytes32_eq" +id = "owner_equality.fixed.48.x86_64_linux" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_48" tier = "A" required = true targets = ["x86_64-unknown-linux-gnu"] rustflags = ["-C", "target-cpu=x86-64"] secrets = [ - { name = "CT_BINSEC_LHS_32", kind = "global", bytes = 32 }, - { name = "CT_BINSEC_RHS_32", kind = "global", bytes = 32 }, + { name = "CT_BINSEC_LHS_48", kind = "global", bytes = 48 }, + { name = "CT_BINSEC_RHS_48", kind = "global", bytes = 48 }, ] -public = [{ name = "LEN_32", kind = "const", value = 32 }] +public = [{ name = "LEN_48", kind = "const", value = 48 }] assumptions = [] +notes = "HmacSha384Tag owner equality with a fixed public owner size." claim = "ct-intended" [[binsec_kernel]] @@ -1711,9 +1829,9 @@ notes = "Diagnostic verifier target for the shipped AVX-512 IFMA Ed25519 signing claim = "ct-intended" [[binsec_kernel]] -id = "ct.constant_time_eq.64.aarch64_linux" -primitive = "ct.constant_time_eq" -symbol = "ct_binsec_constant_time_eq_64" +id = "owner_equality.fixed.64.aarch64_linux" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_64" tier = "A" required = true targets = ["aarch64-unknown-linux-gnu"] @@ -1727,9 +1845,9 @@ assumptions = [] claim = "ct-intended" [[binsec_kernel]] -id = "ct.constant_time_eq.32.aarch64_linux" -primitive = "ct.constant_time_eq" -symbol = "ct_binsec_constant_time_eq_32" +id = "owner_equality.fixed.32.aarch64_linux" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_32" tier = "A" required = true targets = ["aarch64-unknown-linux-gnu"] @@ -1743,9 +1861,9 @@ assumptions = [] claim = "ct-intended" [[binsec_kernel]] -id = "ct.constant_time_eq.16.aarch64_linux" -primitive = "ct.constant_time_eq" -symbol = "ct_binsec_constant_time_eq_16" +id = "owner_equality.fixed.16.aarch64_linux" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_16" tier = "A" required = true targets = ["aarch64-unknown-linux-gnu"] @@ -1759,19 +1877,20 @@ assumptions = [] claim = "ct-intended" [[binsec_kernel]] -id = "secret_wrappers.secret_bytes32_eq.aarch64_linux" -primitive = "secret_wrappers.equality_and_display" -symbol = "ct_binsec_secret_bytes32_eq" +id = "owner_equality.fixed.48.aarch64_linux" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_48" tier = "A" required = true targets = ["aarch64-unknown-linux-gnu"] rustflags = ["-C", "target-feature=+neon,+lse"] secrets = [ - { name = "CT_BINSEC_LHS_32", kind = "global", bytes = 32 }, - { name = "CT_BINSEC_RHS_32", kind = "global", bytes = 32 }, + { name = "CT_BINSEC_LHS_48", kind = "global", bytes = 48 }, + { name = "CT_BINSEC_RHS_48", kind = "global", bytes = 48 }, ] -public = [{ name = "LEN_32", kind = "const", value = 32 }] +public = [{ name = "LEN_48", kind = "const", value = 48 }] assumptions = [] +notes = "HmacSha384Tag owner equality with a fixed public owner size." claim = "ct-intended" [[binsec_kernel]] @@ -1843,9 +1962,9 @@ notes = "Production passes an Ed25519 signed radix-16 digit; the BINSEC harness claim = "ct-intended" [[binsec_kernel]] -id = "ct.constant_time_eq.portable.all" -primitive = "ct.constant_time_eq" -symbol = "ct_binsec_constant_time_eq_64" +id = "owner_equality.fixed.64.portable.all" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_64" tier = "A" required = true targets = ["*"] @@ -1856,13 +1975,13 @@ secrets = [ ] public = [{ name = "LEN_64", kind = "const", value = 64 }] assumptions = [] -notes = "Portable all-target kernel for the fixed-length equality primitive. Target-specific kernels may add stronger ISA coverage." +notes = "Portable all-target HmacSha512Tag owner equality kernel." claim = "ct-intended" [[binsec_kernel]] -id = "secret_wrappers.secret_bytes32_eq.portable.all" -primitive = "secret_wrappers.equality_and_display" -symbol = "ct_binsec_secret_bytes32_eq" +id = "owner_equality.fixed.32.portable.all" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_32" tier = "A" required = true targets = ["*"] @@ -1873,7 +1992,41 @@ secrets = [ ] public = [{ name = "LEN_32", kind = "const", value = 32 }] assumptions = [] -notes = "Portable all-target kernel for secret wrapper equality." +notes = "Portable all-target X25519SecretKey owner equality kernel." +claim = "ct-intended" + +[[binsec_kernel]] +id = "owner_equality.fixed.16.portable.all" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_16" +tier = "A" +required = true +targets = ["*"] +rustflags = [] +secrets = [ + { name = "CT_BINSEC_LHS_16", kind = "global", bytes = 16 }, + { name = "CT_BINSEC_RHS_16", kind = "global", bytes = 16 }, +] +public = [{ name = "LEN_16", kind = "const", value = 16 }] +assumptions = [] +notes = "Portable all-target Aes128GcmKey owner equality kernel." +claim = "ct-intended" + +[[binsec_kernel]] +id = "owner_equality.fixed.48.portable.all" +primitive = "owner_equality.fixed" +symbol = "ct_binsec_owner_eq_48" +tier = "A" +required = true +targets = ["*"] +rustflags = [] +secrets = [ + { name = "CT_BINSEC_LHS_48", kind = "global", bytes = 48 }, + { name = "CT_BINSEC_RHS_48", kind = "global", bytes = 48 }, +] +public = [{ name = "LEN_48", kind = "const", value = 48 }] +assumptions = [] +notes = "Portable all-target HmacSha384Tag owner equality kernel." claim = "ct-intended" [[binsec_kernel]] @@ -2546,47 +2699,458 @@ binsec = "unsupported" binsec_reason = "WASM targets do not run the native Linux BINSEC executable harness in this workflow." 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", +] +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"] +claim = "ct-intended" +evidence = [ + "primitive:owner_equality.fixed", + "unit:owner_equality.fixed.16", + "unit:owner_equality.fixed.32", + "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." + +[[operation]] +id = "secrets.generic_wrapper_lifecycle" +api = [ + "rscrypto::SecretBytes::{new,as_bytes,expose}", + "rscrypto::SecretVec::{as_bytes,len,is_empty,clear,into_unprotected_vec}", + "rscrypto::traits::ct::zeroize", +] +features = ["always", "alloc for SecretVec"] +targets = ["all-supported"] +secret_inputs = ["wrapped bytes", "bytes being cleared"] +public_inputs = ["wrapper size", "allocation length", "explicit exposure request"] +variable_time_components = ["public-length copy and clear loops", "allocator behavior for SecretVec extraction/drop"] +permitted_leakage = ["wrapper type", "public length", "explicitly exposed bytes"] +claim = "best-effort" +evidence = ["primitive:secret_wrappers.exposure_and_display", "harness:ct_entry_secret_bytes32_debug_masked"] +limitation = "SecretBytes and SecretVec intentionally provide no equality. Explicit extraction transfers secret-lifecycle responsibility to the caller; zeroization evidence is maintained separately from timing claims." + +[[operation]] +id = "secrets.owner_construction_and_exposure" +api = [ + "rscrypto::*Key::{from_bytes,try_from_slice,generate,try_generate,try_generate_with,duplicate_secret,as_bytes,expose_secret,display_secret}", + "rscrypto::*SharedSecret::{from_bytes,duplicate_secret,as_bytes,expose_secret,display_secret}", + "rscrypto::MlKem*DecapsulationKey::{from_bytes,try_from_slice,duplicate_secret,as_bytes,expose_secret,display_secret}", + "rscrypto::Poly1305OneTimeKey::{from_bytes,try_generate,try_generate_with,as_bytes,expose_secret}", +] +features = ["algorithm owner feature", "getrandom", "serde-secrets"] +targets = ["all-supported"] +secret_inputs = ["raw secret bytes", "generated entropy", "serialized secret material"] +public_inputs = ["concrete owner type", "fixed owner size", "parse success", "exposure request"] +variable_time_components = ["entropy-source latency", "public parse/feature paths", "explicit formatting and serialization"] +permitted_leakage = ["owner type", "fixed size", "construction success", "caller-authorized secret exposure"] +claim = "best-effort" +evidence = ["primitive:secret_wrappers.exposure_and_display"] +limitation = "display_secret, expose_secret, AsRef/as_bytes, and serde-secrets are explicit escape hatches. Debug remains masked; the manifest does not claim that authorized formatting or serialization hides the secret." + +[[operation]] +id = "aead.construct_and_seal" +api = [ + "rscrypto::Aead::{new,encrypt_in_place,encrypt,seal,seal_with_random_nonce}", + "rscrypto::{Aes128Gcm,Aes256Gcm,Aes128GcmSiv,Aes256GcmSiv,ChaCha20Poly1305,XChaCha20Poly1305,Aegis256,AsconAead128}::{new,encrypt_in_place,encrypt}", + "rscrypto::NonceCounter::*::encrypt*", +] +features = ["aead algorithm feature", "alloc/std for allocating helpers", "getrandom for random nonces"] +targets = ["all-supported", "backend selected by public target capabilities"] +secret_inputs = ["key", "plaintext", "derived authentication/encryption subkeys", "cipher state"] +public_inputs = ["nonce", "AAD", "plaintext length", "output capacity", "backend capabilities"] +variable_time_components = ["public-length loops", "public buffer errors", "public backend dispatch", "entropy-source latency for random nonces"] +permitted_leakage = ["algorithm", "lengths", "nonce", "AAD", "backend", "opaque success/failure"] +claim = "ct-intended" +evidence = ["primitive:aead.symmetric_transform", "unit:aead.symmetric_transform.aes128gcm", "unit:aead.symmetric_transform.chacha20poly1305"] +limitation = "Release claims require exact target artifacts. Random-nonce helper timing includes the platform entropy source." + +[[operation]] +id = "aead.open_and_authenticate" +api = [ + "rscrypto::Aead::{decrypt_in_place,decrypt,open}", + "rscrypto::{Aes128Gcm,Aes256Gcm,Aes128GcmSiv,Aes256GcmSiv,ChaCha20Poly1305,XChaCha20Poly1305,Aegis256,AsconAead128}::{decrypt_in_place,decrypt}", +] +features = ["aead algorithm feature", "alloc/std for allocating helpers"] +targets = ["all-supported", "backend selected by public target capabilities"] +secret_inputs = ["key", "derived authentication/encryption subkeys", "authenticated plaintext"] +public_inputs = ["nonce", "AAD", "ciphertext", "tag owner", "lengths", "backend capabilities"] +variable_time_components = ["public-length loops", "public buffer-shape rejection", "public backend dispatch", "opaque authentication result"] +permitted_leakage = ["algorithm", "lengths", "nonce", "AAD", "backend", "opaque success/failure"] +claim = "ct-intended" +evidence = ["primitive:aead.open_authentication", "primitive:aead.symmetric_transform", "unit:aead.open_authentication.aes128gcm", "unit:aead.open_authentication.ascon_aead128"] +limitation = "The bool-like authentication decision is surfaced only as an opaque Result, but exact release-machine-code evidence remains T3.3 work." + +[[operation]] +id = "aead.public_nonce_tag_and_error_values" +api = [ + "rscrypto::aead::Nonce*::{from_bytes,to_bytes,as_bytes,generate,try_random}", + "rscrypto::*Tag::{from_bytes,try_from,to_bytes,as_bytes,as_slice}", + "rscrypto::aead::{AeadBufferError,SealError,OpenError,RandomSealError,NonceCounter*}::*", +] +features = ["aead algorithm feature", "serde"] +targets = ["all-supported"] +secret_inputs = [] +public_inputs = ["nonce bytes", "tag bytes", "buffer shape", "opaque error category"] +variable_time_components = ["ordinary public-data parsing, formatting, hashing, and equality"] +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." + +[[operation]] +id = "mac.compute_and_finalize" +api = [ + "rscrypto::Mac::{new,update,update_vectored,update_io_slices,finalize,reset,mac,finalize_to_vec,mac_to_vec}", + "rscrypto::{HmacSha*,Kmac*,Poly1305}::{new,update,finalize,reset,mac}", +] +features = ["hmac", "hmac-sha3", "kmac", "poly1305", "alloc/std for Vec/IoSlice helpers"] +targets = ["all-supported", "backend selected by public target capabilities"] +secret_inputs = ["MAC key", "MAC state", "message when caller classifies it secret", "computed tag"] +public_inputs = ["message length", "algorithm", "backend capabilities"] +variable_time_components = ["public message-length loops", "public backend dispatch", "allocation for Vec helpers"] +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." + +[[operation]] +id = "mac.verify_typed_tag" +api = [ + "rscrypto::Mac::{verify,verify_tag}", + "rscrypto::HmacSha*::verify_tag", + "rscrypto::Poly1305::verify", +] +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"] +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." + +[[operation]] +id = "kmac.variable_output_and_verify" +api = ["rscrypto::{Kmac128,Kmac256}::{new,update,finalize,mac,mac_array,verify,verify_tag}"] +features = ["kmac", "alloc for allocating outputs"] +targets = ["all-supported"] +secret_inputs = ["KMAC key", "state", "computed output"] +public_inputs = ["customization", "message", "requested output length", "expected output length"] +variable_time_components = ["public-length sponge absorption/squeeze", "public expected-length mismatch", "public-length full-content comparison"] +permitted_leakage = ["customization", "message/output lengths", "opaque verification result"] +claim = "ct-intended" +evidence = ["primitive:mac.kmac256_verify", "harness:ct_entry_kmac256_verify", "dudect:kmac256_valid_vs_invalid_tag"] +limitation = "KMAC has protocol-defined variable output. Length is explicitly classified public; this is the only MAC verification family retaining an internal public-length comparison." + +[[operation]] +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}", +] +features = ["blake2b", "blake2s", "blake3", "parallel"] +targets = ["all-supported", "backend selected by public target capabilities"] +secret_inputs = ["key", "derive-key material", "keyed state", "keyed output when caller keeps it secret"] +public_inputs = ["message", "context", "message/output lengths", "backend capabilities"] +variable_time_components = ["public-length chunking", "public parallel threshold", "public backend dispatch", "fixed owner comparison for Blake3 verification"] +permitted_leakage = ["algorithm", "context", "lengths", "backend", "verification result"] +claim = "ct-intended" +evidence = ["primitive:keyed_hash.blake2_blake3", "primitive:keyed_hash.blake3_verify", "harness:ct_entry_blake3_keyed_digest", "harness:ct_entry_blake3_verify_keyed"] +limitation = "Keyed outputs are explicitly extractable/serializable. Only Blake3KeyedHash has a typed built-in verification path." + +[[operation]] +id = "kdf.hkdf_extract_expand" +api = ["rscrypto::HkdfSha{256,384,512}::{new,extract,expand,expand_array,derive,derive_array,prk}"] +features = ["hkdf", "hmac"] +targets = ["all-supported"] +secret_inputs = ["input key material", "PRK", "OKM"] +public_inputs = ["salt unless protocol classifies it secret", "info", "requested output length"] +variable_time_components = ["public input/output-length loops", "public output-length validation"] +permitted_leakage = ["salt/info/output lengths", "length error", "explicit PRK/OKM exposure"] +claim = "ct-intended" +evidence = ["primitive:kdf.hkdf", "unit:kdf.hkdf.sha256", "unit:kdf.hkdf.sha384"] +limitation = "prk() and returned/output buffers expose derived secret material to the caller by design." + +[[operation]] +id = "kdf.pbkdf2_derive_and_verify" +api = ["rscrypto::Pbkdf2Sha{256,512}::{derive,verify,verify_password}", "rscrypto::Pbkdf2Params::*", "rscrypto::Pbkdf2VerifyPolicy::*"] +features = ["pbkdf2", "alloc", "phc-strings"] +targets = ["all-supported"] +secret_inputs = ["password", "derived key", "computed verifier"] +public_inputs = ["salt", "iteration count", "output length", "PHC string", "verification policy"] +variable_time_components = ["public iteration count", "public output-length loops", "PHC parsing and policy rejection", "public-length full-content comparison"] +permitted_leakage = ["parameters", "salt", "lengths", "policy decision", "opaque verification result"] +claim = "ct-intended" +evidence = ["primitive:kdf.pbkdf2", "unit:kdf.pbkdf2.sha256", "unit:kdf.pbkdf2.sha512"] +limitation = "Primitive verify accepts a caller-selected public output length; PHC helpers pin and approve resource bounds before deriving." + +[[operation]] +id = "password.argon2i" +api = ["rscrypto::Argon2i::{derive,derive_with_context,verify}"] +features = ["argon2", "alloc", "parallel"] +targets = ["all-supported", "backend selected by public target capabilities"] +secret_inputs = ["password", "secret/pepper", "computed output", "working memory"] +public_inputs = ["salt", "associated data", "Argon2 parameters", "output length", "backend capabilities"] +variable_time_components = ["public resource parameters", "public output length", "allocation/thread scheduling", "public backend dispatch"] +permitted_leakage = ["parameters", "lengths", "backend", "allocation failure", "opaque verification result"] +claim = "ct-intended" +evidence = ["primitive:password.argon2i", "harness:ct_entry_argon2i_hash", "harness:ct_entry_argon2i_verify"] +limitation = "OS allocation and parallel scheduling are outside instruction-level CT claims." + +[[operation]] +id = "password.argon2d_and_argon2id" +api = ["rscrypto::{Argon2d,Argon2id}::{derive,derive_with_context,verify}", "rscrypto::Argon2idPassword::{hash_password,verify_password,verify_password_with_context}"] +features = ["argon2", "alloc", "parallel", "phc-strings", "getrandom"] +targets = ["all-supported", "backend selected by public target capabilities"] +secret_inputs = ["password", "secret/pepper", "computed output", "working memory"] +public_inputs = ["salt", "associated data", "resource parameters", "PHC string/policy", "backend capabilities"] +variable_time_components = ["Argon2d/Argon2id data-dependent memory addressing", "public resource parameters", "allocation/thread scheduling", "PHC parsing"] +permitted_leakage = ["parameters", "salt", "lengths", "backend", "opaque verification result"] +claim = "best-effort" +evidence = ["primitive:password.argon2d_argon2id", "harness:ct_entry_argon2id_verify"] +limitation = "Argon2d and the data-dependent Argon2id phase intentionally use password-dependent memory addresses and are not claimed constant-time." + +[[operation]] +id = "password.scrypt" +api = ["rscrypto::Scrypt::{derive,verify}", "rscrypto::ScryptPassword::{hash_password,verify_password}", "rscrypto::ScryptParams::*"] +features = ["scrypt", "alloc", "parallel", "phc-strings", "getrandom"] +targets = ["all-supported"] +secret_inputs = ["password", "computed output", "ROMix working memory"] +public_inputs = ["salt", "resource parameters", "output length", "PHC string/policy"] +variable_time_components = ["password-dependent ROMix memory addressing", "public resource parameters", "allocation/thread scheduling", "PHC parsing"] +permitted_leakage = ["parameters", "salt", "lengths", "opaque verification result"] +claim = "best-effort" +evidence = ["primitive:password.scrypt", "harness:ct_entry_scrypt_verify"] +limitation = "scrypt intentionally uses password-dependent memory addressing and is not claimed constant-time." + +[[operation]] +id = "kx.x25519_key_agreement" +api = ["rscrypto::X25519SecretKey::{public_key,diffie_hellman}", "rscrypto::X25519SharedSecret::diffie_hellman", "rscrypto::X25519PublicKey::{from_bytes,basepoint,to_bytes,as_bytes}"] +features = ["x25519", "getrandom", "serde-secrets"] +targets = ["all-supported", "aarch64/x86_64 assembly where selected", "portable fallback"] +secret_inputs = ["secret scalar", "shared secret", "clamped scalar scratch"] +public_inputs = ["peer public key", "backend capabilities"] +variable_time_components = ["public backend dispatch", "opaque all-zero shared-secret rejection"] +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." + +[[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}"] +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"] +public_inputs = ["encapsulation key", "ciphertext", "parameter set", "encoded lengths"] +variable_time_components = ["public matrix rejection sampling", "public fixed-shape parsing", "full fixed-shape implicit-rejection comparison"] +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." + +[[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}"] +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"] +public_inputs = ["encapsulation key", "ciphertext", "parameter set", "encoded lengths"] +variable_time_components = ["public matrix rejection sampling", "public fixed-shape parsing", "full fixed-shape implicit-rejection comparison"] +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." + +[[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}"] +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"] +public_inputs = ["encapsulation key", "ciphertext", "parameter set", "encoded lengths"] +variable_time_components = ["public matrix rejection sampling", "public fixed-shape parsing", "full fixed-shape implicit-rejection comparison"] +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." + +[[operation]] +id = "signature.ed25519_signing" +api = ["rscrypto::Ed25519SecretKey::{public_key,sign}", "rscrypto::Ed25519Keypair::{from_secret_key,sign,try_sign}"] +features = ["ed25519", "getrandom", "serde-secrets"] +targets = ["all-supported", "target-selected fixed-base backend"] +secret_inputs = ["secret seed", "expanded scalar", "nonce prefix", "deterministic nonce", "signing intermediates"] +public_inputs = ["message", "public key", "backend capabilities"] +variable_time_components = ["public message-length hashing", "public backend dispatch"] +permitted_leakage = ["message length", "public key", "signature", "backend"] +claim = "ct-intended" +evidence = ["primitive:signature.ed25519_sign", "harness:ct_entry_ed25519_sign"] +limitation = "Target-specific fixed-base kernels require their own release artifacts; source structure is not proof." + +[[operation]] +id = "signature.ecdsa_signing" +api = ["rscrypto::EcdsaP{256,384}SecretKey::{try_sign,try_sign_blinded}", "rscrypto::EcdsaP{256,384}Keypair::{try_sign,try_sign_blinded}"] +features = ["ecdsa-p256", "ecdsa-p384", "getrandom", "serde-secrets"] +targets = ["all-supported", "target-selected arithmetic backend"] +secret_inputs = ["private scalar", "deterministic nonce", "blinding input", "signing intermediates"] +public_inputs = ["message", "public key", "backend capabilities"] +variable_time_components = ["public message-length hashing", "public backend dispatch", "opaque invalid-scalar/signing failure"] +permitted_leakage = ["message length", "public key", "signature", "backend", "opaque failure"] +claim = "ct-intended" +evidence = ["primitive:signature.ecdsa_p256_sign", "primitive:signature.ecdsa_p384_sign", "harness:ct_entry_ecdsa_p256_sign", "harness:ct_entry_ecdsa_p384_sign"] +limitation = "Blinding closures and entropy-source behavior are external. Exact arithmetic kernels require target-specific release evidence." + +[[operation]] +id = "signature.public_parse_serialize_and_verify" +api = ["rscrypto::{Ed25519,EcdsaP256,EcdsaP384}PublicKey::{from_bytes,to_bytes,as_bytes,verify}", "rscrypto::{Ed25519,EcdsaP256,EcdsaP384}Signature::{from_bytes,from_der,to_bytes,as_bytes}"] +features = ["ed25519", "ecdsa-p256", "ecdsa-p384", "serde"] +targets = ["all-supported"] +secret_inputs = [] +public_inputs = ["public key", "message", "signature", "encoding"] +variable_time_components = ["public parsing and validation", "public message-length hashing", "public scalar/point arithmetic", "ordinary public-value equality"] +permitted_leakage = ["all inputs", "parse failure", "opaque verification result"] +claim = "best-effort" +evidence = ["primitive:signature.ed25519_verify_failure_shape", "harness:ct_entry_ed25519_verify"] +limitation = "Signature verification inputs are public and are not blanket constant-time claims. Failure remains opaque; public signature/key equality is intentionally ordinary." + +[[operation]] +id = "rsa.private_key_import_export_and_generation" +api = ["rscrypto::RsaPrivateKey::{from_components,from_pkcs1_der*,from_pkcs8_der*,to_pkcs1_der,to_pkcs8_der,generate*}", "rscrypto::RsaPrivateKey::{modulus_bits,public_key}"] +features = ["rsa", "alloc", "getrandom for generation"] +targets = ["all-supported"] +secret_inputs = ["private components", "private DER", "generated prime candidates", "exported secret DER"] +public_inputs = ["public exponent", "policy", "requested modulus size", "DER shape"] +variable_time_components = ["DER parsing", "big-integer allocation", "secret prime search and rejection", "entropy-source latency"] +permitted_leakage = ["public modulus size/exponent", "parse/policy result", "generation progress/timing", "caller-authorized DER exposure"] +claim = "ct-intended" +evidence = ["primitive:rsa.private_key_material", "unit:rsa.private_key_material.component_validation_32", "harness:ct_entry_rsa_private_key_pkcs8_roundtrip"] +limitation = "Prime generation is inherently variable-work and not a CT claim. Secret DER export is an explicit SecretVec escape hatch. The ct-intended claim covers fixed-shape private-component validation/import leaves tracked by evidence." + +[[operation]] +id = "rsa.private_sign_and_decrypt" +api = ["rscrypto::RsaPrivateKey::{sign_signature*,sign_pkcs1v15*,sign_pss*,decrypt_oaep*,decrypt_pkcs1v15*}"] +features = ["rsa", "alloc", "getrandom for blinding/randomized padding"] +targets = ["all-supported"] +secret_inputs = ["private exponent/components", "plaintext", "blinding factor", "PSS salt", "private arithmetic scratch"] +public_inputs = ["message/digest", "ciphertext", "padding profile", "modulus size", "output capacity"] +variable_time_components = ["public modulus-width loops", "public padding/hash profiles", "entropy-source latency", "opaque input-shape rejection"] +permitted_leakage = ["modulus/profile/lengths", "opaque success/failure"] +claim = "ct-intended" +evidence = ["primitive:rsa.private_ops", "unit:rsa.private_ops.pkcs1v15_sign", "unit:rsa.private_ops.pss_sign", "unit:rsa.private_ops.oaep_decrypt", "unit:rsa.private_ops.pkcs1v15_decrypt"] +limitation = "All remaining variable-length internal comparisons use public modulus/profile-derived shapes. Full private operations remain target-artifact/DudeCT evidence, not source-only claims." + +[[operation]] +id = "rsa.public_operations" +api = ["rscrypto::RsaPublicKey::{from_pkcs1_der,from_spki_der,to_pkcs1_der,to_spki_der,encrypt*,verify*}", "rscrypto::Rsa*Profile::*", "rscrypto::RsaPublicKeyPolicy::*"] +features = ["rsa", "alloc", "getrandom for randomized encryption"] +targets = ["all-supported"] +secret_inputs = ["plaintext passed to encryption"] +public_inputs = ["public key", "message/digest", "signature", "ciphertext length", "padding/profile/policy"] +variable_time_components = ["public DER parsing", "public-key arithmetic", "public padding validation", "entropy-source latency"] +permitted_leakage = ["public key/profile/lengths", "parse/policy result", "verification result"] +claim = "best-effort" +limitation = "Public verification/parsing inputs are not CT claims. Encryption consumes plaintext but necessarily reveals ciphertext length and includes external randomness; no secret-dependent private-key arithmetic occurs." + +[[operation]] +id = "public.hash_checksum_and_xof" +api = ["rscrypto::{Digest,Xof,Checksum,ChecksumCombine,FastHash}::*", "rscrypto::{Sha*,Blake*,AsconHash*,Shake*,Cshake*,Crc*,Xxh3*,RapidHash*}::*"] +features = ["hash/checksum algorithm feature", "std/alloc for I/O wrappers"] +targets = ["all-supported", "backend selected by public target capabilities"] +secret_inputs = [] +public_inputs = ["data", "seed", "initial checksum", "requested output length", "backend capabilities"] +variable_time_components = ["public-length loops", "public backend dispatch", "public I/O behavior"] +permitted_leakage = ["all inputs/results", "lengths", "backend"] +claim = "best-effort" +evidence = ["primitive:public_hashes_and_checksums", "harness:ct_entry_public_sha256_digest", "harness:ct_entry_public_crc32_checksum"] +limitation = "Unkeyed hashes/checksums are public-data APIs and make no whole-API CT claim. Callers hashing secrets must use a separately inventoried keyed construction where appropriate." + +[[operation]] +id = "traits.downstream_security_defaults" +api = ["rscrypto::TrySigner::try_sign", "rscrypto::TrySignerInto::{try_sign_into,try_sign_to_vec}", "rscrypto::Verifier::verify", "rscrypto::Kem::{generate_keypair,encapsulate,decapsulate}"] +features = ["always", "alloc for try_sign_to_vec"] +targets = ["all-supported"] +secret_inputs = ["implementation-defined signing key, decapsulation key, randomness, and derived secret"] +public_inputs = ["implementation-defined message, signature, public key, and ciphertext"] +variable_time_components = ["entirely implementation-defined except for each built-in implementation's separately inventoried path"] +permitted_leakage = ["implementation-defined", "opaque verification failure where the contract requires it"] +claim = "unsupported" +limitation = "These public traits are downstream implementation boundaries. Built-in rscrypto implementations map to their algorithm operation rows; the traits cannot enforce timing, zeroization, or error-opacity properties on external implementations." + +[[operation]] +id = "diagnostic.security_entrypoints" +api = ["rscrypto::diag_*", "rscrypto::{aead,auth,hashes,secret}::*::diag_*"] +features = ["diag"] +targets = ["host or explicitly cfg-gated diagnostic target"] +secret_inputs = ["operation-specific diagnostic key, password, scalar, state, or intermediate"] +public_inputs = ["operation-specific fixed diagnostic shape and backend selector"] +variable_time_components = ["operation-specific; each linked primitive/harness owns classification"] +permitted_leakage = ["diagnostic digest/result intentionally returned to the caller", "feature and target"] +claim = "unsupported" +limitation = "The diag feature is an evidence/test surface, not a production secrecy boundary. Diagnostic return values deliberately declassify intermediates for harnesses and must not be enabled as an application security API." + [[primitive]] -id = "ct.constant_time_eq" +id = "owner_equality.fixed" tier = "A" claim = "ct-intended" features = ["always"] -entrypoints = ["rscrypto::traits::ct::constant_time_eq", "rscrypto::ConstantTimeEq::ct_eq"] -secrets = ["left_bytes", "right_bytes"] -public = ["left_len", "right_len"] -may_leak = ["input_length", "length_mismatch"] +variants = ["Owner16", "Owner32", "Owner48", "Owner64"] +entrypoints = [ + "Aes128GcmKey::eq", + "X25519SecretKey::eq", + "HmacSha384Tag::eq", + "HmacSha512Tag::eq", + "fixed-size secret/key/tag owner 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 = "Length mismatch is public shape. Equal-length byte contents must not influence timing." +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." [primitive.harness] status = "covered" -symbols = ["ct_entry_constant_time_eq"] +symbols = ["ct_entry_owner_eq_16", "ct_entry_owner_eq_32", "ct_entry_owner_eq_48", "ct_entry_owner_eq_64"] [[primitive]] -id = "secret_wrappers.equality_and_display" -tier = "A" -claim = "ct-intended" +id = "secret_wrappers.exposure_and_display" +tier = "C" +claim = "best-effort" features = ["always"] entrypoints = [ - "rscrypto::SecretBytes::eq", - "secret-key newtype Eq impls", - "shared-secret newtype Eq impls", - "secret-key Debug impls", + "rscrypto::SecretBytes::new", + "rscrypto::SecretBytes::as_bytes", + "rscrypto::SecretBytes::expose", + "rscrypto::SecretVec::as_bytes", + "rscrypto::SecretVec::into_unprotected_vec", + "secret owner expose_secret methods", + "secret owner display_secret methods", + "secret owner Debug implementations", ] secrets = ["secret_bytes"] -public = ["type", "length"] -may_leak = ["type", "input_length"] +public = ["owner_type", "owner_length", "explicit_exposure_request"] +may_leak = ["owner_type", "owner_length", "explicitly_exposed_secret"] must_not_leak_ref = "common_secret_data" -required = ["tier_a"] -notes = "Secret Debug must remain masked. Explicit display_secret APIs are caller opt-in and outside opaque logging claims." +required = ["classification"] +notes = "Generic secret wrappers deliberately implement neither PartialEq nor Eq. Debug remains masked. expose, into_unprotected_vec, and display_secret are explicit caller-authorized declassification/lifecycle escape hatches, not constant-time claims." [primitive.harness] status = "covered" -symbols = [ - "ct_entry_secret_bytes32_eq", - "ct_entry_x25519_secret_eq", - "ct_entry_ed25519_secret_eq", - "ct_entry_secret_bytes32_debug_masked", -] +symbols = ["ct_entry_secret_bytes32_debug_masked"] [[primitive]] id = "mac.hmac_verify" @@ -2634,7 +3198,7 @@ id = "keyed_hash.blake3_verify" tier = "A" claim = "ct-intended" features = ["blake3"] -entrypoints = ["Blake3::verify_keyed", "Blake3KeyedHash::eq", "Blake3KeyedHash::ct_eq"] +entrypoints = ["Blake3::verify_keyed", "Blake3KeyedHash::eq"] secrets = ["key", "computed_tag", "expected_tag"] public = ["data"] may_leak = ["input_length", "opaque_success_or_failure"] @@ -3133,7 +3697,7 @@ public = ["data", "context", "output_length"] may_leak = ["input_length", "output_length"] must_not_leak_ref = "common_secret_data" required = ["tier_b"] -notes = "Caller-visible verification must use ConstantTimeEq or a typed verify helper." +notes = "Blake3 verification uses the Blake3KeyedHash owner. Blake2 keyed outputs have no generic comparison API; protocols needing built-in verification should use a typed MAC/KMAC/Blake3 owner." [primitive.harness] status = "covered" symbols = [ diff --git a/docs/constant-time.md b/docs/constant-time.md index 5185035c..b8c064ef 100644 --- a/docs/constant-time.md +++ b/docs/constant-time.md @@ -45,7 +45,8 @@ process. `ct.toml` places the following highest-sensitivity surfaces inside the release evidence gate. This is intent, not a standalone public claim: -- MAC/tag verification and constant-time byte equality. +- MAC/tag verification and fixed-size equality owned by concrete key, secret, + tag, and keyed-output types. - AEAD authentication and failed-open cleanup. - X25519 scalar multiplication. - ML-KEM-512/768/1024 key generation secret noise, encapsulation coins, diff --git a/docs/migration/RustCrypto/blake2.md b/docs/migration/RustCrypto/blake2.md index 067be9ac..c14c4c1b 100644 --- a/docs/migration/RustCrypto/blake2.md +++ b/docs/migration/RustCrypto/blake2.md @@ -126,5 +126,8 @@ For streaming keyed mode, use `Blake2b512::new_keyed(&key)`. The MAC type and th - **MAC unification.** RustCrypto separates `Blake2bMac` from `Blake2b` because the MAC and the hash use different parameter blocks. rscrypto exposes both modes from the same type via `keyed_digest` / `new_keyed`. Personalisation, salt, and tree-hashing parameters are reachable through `Blake2bParams` / `Blake2sParams`. - **`finalize` consumes vs. borrows.** Same as `sha2` / `sha3`: drop `.clone()`. - **`Output` → `[u8; N]`.** Same as `sha2` / `sha3`. -- **Constant-time tag comparison.** RustCrypto's `Mac::verify(&tag)` does the constant-time check for you. rscrypto leaves Blake2 keyed-output verification at the call site: import `rscrypto::ConstantTimeEq` and call `tag.ct_eq(&expected)` instead of `==`. +- **No generic keyed-output comparison.** rscrypto's Blake2 keyed digest + returns raw bytes without a generic secret-comparison API. Use HMAC, KMAC, + or typed `Blake3KeyedHash` verification when the protocol needs built-in + verification. - **`no_std`.** Both crates support `no_std`. rscrypto runtime-detects SIMD when `std` is enabled and falls back to compile-time `target_feature` selection in `no_std` builds. diff --git a/docs/migration/RustCrypto/hmac.md b/docs/migration/RustCrypto/hmac.md index 1c3274de..45cf0274 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 constant-time 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 fixed-size, type-owned equality. 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`. diff --git a/docs/test-vector-coverage.md b/docs/test-vector-coverage.md index acf7da6e..b9e685f7 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, and constant-time 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 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 | | 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/docs/types.md b/docs/types.md index 2392b9d0..91e85231 100644 --- a/docs/types.md +++ b/docs/types.md @@ -20,10 +20,9 @@ impl types. | `Kem` | Key encapsulation mechanism profile | | `FastHash` | One-shot seeded non-crypto hash | | `Aead` | Authenticated encryption | -| `ConstantTimeEq` | Constant-time byte equality | Prelude: `rscrypto::prelude` re-exports `Aead`, `Checksum`, -`ChecksumCombine`, `ConstantTimeEq`, `Digest`, `FastHash`, `Kem`, `Mac`, +`ChecksumCombine`, `Digest`, `FastHash`, `Kem`, `Mac`, `VerificationError`, and `Xof`. ## Native API Conventions @@ -238,12 +237,15 @@ also has `seal_random_to_vec`. | Item | Purpose | |------|---------| -| `ct::constant_time_eq` | Constant-time byte comparison | | `ct::zeroize` | Volatile-write buffer wipe | | `DisplaySecret` | Opt-in hex display for secret keys | | `SecretBytes` | Fixed-size secret byte buffer that zeroizes on drop | | `SecretVec` | Variable-length secret allocation that zeroizes on drop; ordinary extraction requires `into_unprotected_vec()` | +Generic secret wrappers deliberately do not implement equality. Fixed-size +keys, shared secrets, authentication tags, and keyed outputs compare only +through their concrete semantic owner types. + Secret keys, shared secrets, keypairs, and AEAD cipher contexts do not implement `Clone`. Where duplication is necessary, call the concrete type's `duplicate_secret()` method so the additional secret lifetime is visible at the diff --git a/scripts/ct/dudect_report.py b/scripts/ct/dudect_report.py index 31045136..73beaae9 100755 --- a/scripts/ct/dudect_report.py +++ b/scripts/ct/dudect_report.py @@ -15,10 +15,25 @@ CASE_METADATA = { - "constant_time_eq_equal_vs_first_diff": { - "primitive": "ct.constant_time_eq", - "left_class": "equal 64-byte inputs", - "right_class": "same length with first byte different", + "owner_eq_16_equal_vs_first_diff": { + "primitive": "owner_equality.fixed", + "left_class": "equal Aes128GcmKey owners", + "right_class": "Aes128GcmKey owners with first byte different", + }, + "owner_eq_32_equal_vs_first_diff": { + "primitive": "owner_equality.fixed", + "left_class": "equal X25519SecretKey owners", + "right_class": "X25519SecretKey owners with first byte different", + }, + "owner_eq_48_equal_vs_first_diff": { + "primitive": "owner_equality.fixed", + "left_class": "equal HmacSha384Tag owners", + "right_class": "HmacSha384Tag owners with first byte different", + }, + "owner_eq_64_equal_vs_first_diff": { + "primitive": "owner_equality.fixed", + "left_class": "equal HmacSha512Tag owners", + "right_class": "HmacSha512Tag owners with first byte different", }, "hmac_sha256_valid_vs_invalid_tag": { "primitive": "mac.hmac_verify", @@ -35,8 +50,8 @@ "left_class": "valid keyed digest", "right_class": "invalid first digest byte", }, - "secret_wrappers_eq_and_debug_fixed_vs_random": { - "primitive": "secret_wrappers.equality_and_display", + "secret_wrappers_debug_fixed_vs_random": { + "primitive": "secret_wrappers.exposure_and_display", "left_class": "fixed secret bytes", "right_class": "random secret bytes", }, diff --git a/scripts/ct/validate.py b/scripts/ct/validate.py index 394ecb92..e92a667e 100755 --- a/scripts/ct/validate.py +++ b/scripts/ct/validate.py @@ -7,6 +7,7 @@ import hashlib import json import re +import subprocess import sys from pathlib import Path @@ -19,6 +20,7 @@ VALID_BINSEC_INPUT_KINDS = {"global", "const"} VALID_BINSEC_POLICIES = {"required", "unsupported"} VALID_PHYSICAL_TIMING_POLICIES = {"required", "unsupported"} +VALID_OPERATION_EVIDENCE_KINDS = {"primitive", "unit", "harness", "dudect", "binsec"} CT_REQUIRED_PROFILE_NAMES = {"tier_a", "tier_b"} PROVENANCE_REQUIRED_KEYS = { "schema_version", @@ -198,7 +200,90 @@ def dudect_registered_benches(root: Path, errors: list[str]) -> set[str]: fail(errors, "DudeCT source has no ctbench_main_with_seeds! registry") return set() body = text[start:] - return set(re.findall(r"^\s*\(([A-Za-z0-9_]+),\s*Some\(", body, re.MULTILINE)) + return set(re.findall(r"\(\s*([A-Za-z0-9_]+),\s*Some\(", body)) + + +def compiler_public_api_snapshot(root: Path, prefixes: tuple[str, ...], errors: list[str]) -> tuple[int, str] | None: + """Return the audited public security API count and digest from rustdoc JSON.""" + target_dir = root / "target" / "ct-api-inventory" + command = [ + "cargo", + "rustdoc", + "--quiet", + "--lib", + "--all-features", + "--target-dir", + str(target_dir), + "--", + "-Z", + "unstable-options", + "--output-format", + "json", + ] + completed = subprocess.run(command, cwd=root, capture_output=True, text=True, check=False) + if completed.returncode != 0: + detail = completed.stderr.strip().splitlines() + suffix = f": {detail[-1]}" if detail else "" + fail(errors, f"compiler public-API inventory failed{suffix}") + return None + + rustdoc_path = target_dir / "doc" / "rscrypto.json" + if not rustdoc_path.is_file(): + fail(errors, f"compiler public-API inventory missing {rustdoc_path}") + return None + try: + rustdoc = json.loads(rustdoc_path.read_text()) + except json.JSONDecodeError as exc: + fail(errors, f"invalid compiler public-API inventory JSON: {exc}") + return None + + index = rustdoc.get("index", {}) + paths = rustdoc.get("paths", {}) + rows: set[str] = set() + for item_id, summary in paths.items(): + if summary.get("crate_id") != 0: + continue + path = "::".join(summary.get("path", [])) + if not path.startswith(prefixes): + continue + item = index.get(item_id) + if not isinstance(item, dict) or item.get("visibility") != "public": + continue + kind = summary.get("kind") + if kind == "function": + rows.add(path) + continue + + inner = item.get("inner", {}) + if kind == "trait": + for child_id in inner.get("trait", {}).get("items", []): + child = index.get(str(child_id), {}) + if "function" in child.get("inner", {}): + rows.add(f"{path}::{child.get('name')}") + continue + + type_inner = inner.get(kind, {}) + if not isinstance(type_inner, dict): + continue + for impl_id in type_inner.get("impls", []): + implementation = index.get(str(impl_id), {}).get("inner", {}).get("impl", {}) + trait = implementation.get("trait") + trait_name = trait.get("path") if isinstance(trait, dict) else None + for child_id in implementation.get("items", []): + child = index.get(str(child_id), {}) + if "function" not in child.get("inner", {}): + continue + span = child.get("span") or {} + if not str(span.get("filename", "")).startswith("src/"): + continue + name = child.get("name") + if trait_name: + rows.add(f"<{path} as {trait_name}>::{name}") + elif child.get("visibility") == "public": + rows.add(f"{path}::{name}") + + encoded = ("\n".join(sorted(rows)) + "\n").encode() + return len(rows), hashlib.sha256(encoded).hexdigest() def validate_manifest(root: Path, errors: list[str], warnings: list[str]) -> dict: @@ -465,6 +550,151 @@ def validate_manifest(root: Path, errors: list[str], warnings: list[str]) -> dic if not kernel.get("required", False): fail(errors, f"evidence unit {unit_id} cannot rely on non-required BINSEC kernel {kernel_id}") + inventory = ct.get("operation_inventory") + if not isinstance(inventory, dict): + fail(errors, "ct.toml missing [operation_inventory]") + else: + if inventory.get("schema_version") != 1: + fail(errors, f"operation_inventory schema_version expected 1, got {inventory.get('schema_version')!r}") + if inventory.get("authority") != "ct.toml": + fail(errors, "operation_inventory authority must be ct.toml") + required_scope = {"production", "feature-gated", "target-gated", "trait-defaults", "escape-hatches"} + actual_scope = set(inventory.get("scope", [])) + if actual_scope != required_scope: + fail(errors, f"operation_inventory scope must be exactly {', '.join(sorted(required_scope))}") + prefixes = inventory.get("compiler_api_prefixes") + if not isinstance(prefixes, list) or not prefixes or any(not isinstance(prefix, str) or not prefix for prefix in prefixes): + fail(errors, "operation_inventory compiler_api_prefixes must be a non-empty list of strings") + elif len(prefixes) != len(set(prefixes)): + fail(errors, "operation_inventory compiler_api_prefixes must not contain duplicates") + else: + snapshot = compiler_public_api_snapshot(root, tuple(prefixes), errors) + if snapshot is not None: + actual_count, actual_sha256 = snapshot + expected_count = inventory.get("compiler_api_item_count") + expected_sha256 = inventory.get("compiler_api_sha256") + if actual_count != expected_count: + fail(errors, f"compiler public-API inventory expected {expected_count} items, got {actual_count}") + if actual_sha256 != expected_sha256: + fail( + errors, + "compiler public-API inventory digest changed; audit and classify the public surface before updating ct.toml", + ) + + operation_ids: set[str] = set() + operation_api: dict[str, str] = {} + referenced_primitives: set[str] = set() + known_evidence = { + "primitive": primitive_ids, + "unit": unit_ids, + "harness": all_harness_symbols, + "dudect": dudect_names, + "binsec": binsec_ids, + } + for operation in ct.get("operation", []): + operation_id = operation.get("id", "") + if operation_id in operation_ids: + fail(errors, f"duplicate operation id {operation_id}") + operation_ids.add(operation_id) + + claim = operation.get("claim") + if claim not in VALID_CLAIMS: + fail(errors, f"operation {operation_id} has invalid claim {claim!r}") + if claim == "ct-claimed": + fail(errors, f"operation {operation_id} is ct-claimed before release evidence gates exist") + + for field in ("api", "features", "targets", "variable_time_components", "permitted_leakage"): + values = operation.get(field) + if not isinstance(values, list) or not values or any(not isinstance(value, str) or not value for value in values): + fail(errors, f"operation {operation_id} {field} must be a non-empty list of strings") + for field in ("secret_inputs", "public_inputs"): + values = operation.get(field) + if not isinstance(values, list) or any(not isinstance(value, str) or not value for value in values): + fail(errors, f"operation {operation_id} {field} must be a list of strings") + + for api in operation.get("api", []): + owner = operation_api.get(api) + if owner is not None: + fail(errors, f"operation API family {api!r} is listed by both {owner} and {operation_id}") + operation_api[api] = operation_id + + evidence = operation.get("evidence", []) + limitation = operation.get("limitation") + if not evidence and not (isinstance(limitation, str) and limitation.strip()): + fail(errors, f"operation {operation_id} requires linked evidence or an explicit limitation") + if evidence and (not isinstance(evidence, list) or any(not isinstance(value, str) for value in evidence)): + fail(errors, f"operation {operation_id} evidence must be a list of kind:id strings") + evidence = [] + for reference in evidence: + kind, separator, evidence_id = reference.partition(":") + if not separator or kind not in VALID_OPERATION_EVIDENCE_KINDS or not evidence_id: + fail(errors, f"operation {operation_id} has invalid evidence reference {reference!r}") + continue + if evidence_id not in known_evidence[kind]: + fail(errors, f"operation {operation_id} references unknown {kind} evidence {evidence_id!r}") + if kind == "primitive": + referenced_primitives.add(evidence_id) + + if not operation_ids: + fail(errors, "ct.toml has no [[operation]] security inventory") + missing_operation_primitives = sorted(primitive_ids - referenced_primitives) + if missing_operation_primitives: + fail(errors, f"security operation inventory does not map primitive(s): {', '.join(missing_operation_primitives)}") + + comparison_ids: set[str] = set() + expected_public_len_calls: dict[str, int] = {} + for comparison in ct.get("public_len_comparison", []): + comparison_id = comparison.get("id", "") + if comparison_id in comparison_ids: + fail(errors, f"duplicate public-length comparison id {comparison_id}") + comparison_ids.add(comparison_id) + + source = comparison.get("source") + if not isinstance(source, str) or not source.startswith("src/") or not (root / source).is_file(): + fail(errors, f"public-length comparison {comparison_id} has invalid source {source!r}") + continue + call_count = comparison.get("call_count") + if isinstance(call_count, bool) or not isinstance(call_count, int) or call_count <= 0: + fail(errors, f"public-length comparison {comparison_id} call_count must be a positive integer") + continue + if source in expected_public_len_calls: + fail(errors, f"public-length comparison source {source} is listed more than once") + expected_public_len_calls[source] = call_count + + operations = comparison.get("operations") + if not isinstance(operations, list) or not operations: + fail(errors, f"public-length comparison {comparison_id} operations must be a non-empty list") + else: + for operation_id in operations: + if operation_id not in operation_ids: + fail(errors, f"public-length comparison {comparison_id} references unknown operation {operation_id!r}") + for field in ("public_length", "secret_contents", "tests"): + if not isinstance(comparison.get(field), str) or not comparison.get(field, "").strip(): + fail(errors, f"public-length comparison {comparison_id} requires {field}") + + actual_public_len_calls: dict[str, int] = {} + for source_path in (root / "src").rglob("*.rs"): + count = source_path.read_text().count("ct::public_len_eq(") + if count: + actual_public_len_calls[source_path.relative_to(root).as_posix()] = count + if actual_public_len_calls != expected_public_len_calls: + missing_sources = sorted(set(actual_public_len_calls) - set(expected_public_len_calls)) + stale_sources = sorted(set(expected_public_len_calls) - set(actual_public_len_calls)) + wrong_counts = sorted( + source + for source in set(actual_public_len_calls).intersection(expected_public_len_calls) + if actual_public_len_calls[source] != expected_public_len_calls[source] + ) + if missing_sources: + fail(errors, f"unclassified public_len_eq caller source(s): {', '.join(missing_sources)}") + if stale_sources: + fail(errors, f"stale public_len_eq inventory source(s): {', '.join(stale_sources)}") + for source in wrong_counts: + fail( + errors, + f"public_len_eq call count for {source} expected {expected_public_len_calls[source]}, got {actual_public_len_calls[source]}", + ) + return ct diff --git a/src/aead/aegis256.rs b/src/aead/aegis256.rs index 0ed957e2..8ca06513 100644 --- a/src/aead/aegis256.rs +++ b/src/aead/aegis256.rs @@ -667,7 +667,7 @@ impl Aead for Aegis256 { )))] let computed = decrypt_portable(key, nonce, aad, buffer); - if !ct::constant_time_eq(&computed, tag.as_bytes()) { + if !ct::fixed_eq(&computed, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/aes128gcm.rs b/src/aead/aes128gcm.rs index 5b1c7ad0..eaf6e6db 100644 --- a/src/aead/aes128gcm.rs +++ b/src/aead/aes128gcm.rs @@ -872,7 +872,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -904,7 +904,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -946,7 +946,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -972,7 +972,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -986,7 +986,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -996,7 +996,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -1004,7 +1004,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/aes128gcmsiv.rs b/src/aead/aes128gcmsiv.rs index 6f43ee8e..b8b18d6f 100644 --- a/src/aead/aes128gcmsiv.rs +++ b/src/aead/aes128gcmsiv.rs @@ -518,7 +518,7 @@ fn decrypt_riscv( ct::zeroize(&mut auth_key); ct::zeroize(&mut enc_key); - if !ct::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -859,7 +859,7 @@ unsafe fn decrypt_fused_aarch64( expected[15] &= 0x7f; aes::aarch64_encrypt_block_128_inline(&enc_ek, &mut expected); - if !ct::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1124,7 +1124,7 @@ unsafe fn decrypt_fused_ppc( expected[15] &= 0x7f; aes::ppc_encrypt_block_128_inline(&enc_ek, &mut expected); - if !ct::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1370,7 +1370,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1515,7 +1515,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -1581,7 +1581,7 @@ impl Aead for Aes128GcmSiv { ct::zeroize(&mut auth_key); ct::zeroize(&mut enc_key); - if !ct::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/aes256gcm.rs b/src/aead/aes256gcm.rs index d318e693..82056ce3 100644 --- a/src/aead/aes256gcm.rs +++ b/src/aead/aes256gcm.rs @@ -875,7 +875,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -907,7 +907,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -949,7 +949,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -975,7 +975,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -989,7 +989,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -999,7 +999,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -1007,7 +1007,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/aes256gcmsiv.rs b/src/aead/aes256gcmsiv.rs index c2db9f01..36d9ca26 100644 --- a/src/aead/aes256gcmsiv.rs +++ b/src/aead/aes256gcmsiv.rs @@ -515,7 +515,7 @@ fn decrypt_riscv( ct::zeroize(&mut auth_key); ct::zeroize(&mut enc_key); - if !ct::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -888,7 +888,7 @@ unsafe fn decrypt_fused_aarch64( expected[15] &= 0x7f; aes::aarch64_encrypt_block_inline(&enc_ek, &mut expected); - if !ct::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1173,7 +1173,7 @@ unsafe fn decrypt_fused_ppc( expected[15] &= 0x7f; aes::ppc_encrypt_block_inline(&enc_ek, &mut expected); - if !ct::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1437,7 +1437,7 @@ unsafe fn decrypt_fused_s390x( aes::s390x_encrypt_block_raw_inline(enc_key_bytes, &mut expected); ct::zeroize(enc_key_bytes); - if !ct::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(crate::traits::VerificationError::new()); } @@ -1582,7 +1582,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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -1648,7 +1648,7 @@ impl Aead for Aes256GcmSiv { ct::zeroize(&mut auth_key); ct::zeroize(&mut enc_key); - if !ct::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/ascon128.rs b/src/aead/ascon128.rs index 46c48bba..7ac0e6db 100644 --- a/src/aead/ascon128.rs +++ b/src/aead/ascon128.rs @@ -348,7 +348,7 @@ impl Aead for AsconAead128 { } let expected = self.finalize(&mut s); - if !ct::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } @@ -374,7 +374,7 @@ pub fn diag_ascon_aead128_tag_portable( s[1] ^= load_bytes(&block[8..]); permute_8_portable(&mut s); let tag = cipher.finalize(&mut s); - ct::constant_time_eq(&tag, expected) + ct::fixed_eq(&tag, expected) } #[cfg(test)] diff --git a/src/aead/chacha20poly1305.rs b/src/aead/chacha20poly1305.rs index da160046..9358dba8 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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { 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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { 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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { 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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { 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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { 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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { 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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { ct::zeroize(buffer); return Err(OpenError::verification()); } diff --git a/src/aead/mod.rs b/src/aead/mod.rs index 391e3b9f..9c8b5a24 100644 --- a/src/aead/mod.rs +++ b/src/aead/mod.rs @@ -299,13 +299,6 @@ macro_rules! define_nonce_type { } } - impl crate::traits::ConstantTimeEq for $name { - #[inline] - fn ct_eq(&self, other: &Self) -> bool { - crate::traits::ct::constant_time_eq(&self.0, &other.0) - } - } - impl $name { #[doc = concat!( "Construct a nonce by filling bytes from the provided closure.\n\n", diff --git a/src/aead/xchacha20poly1305.rs b/src/aead/xchacha20poly1305.rs index 945e5e4c..9a780585 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::constant_time_eq(&expected, tag.as_bytes()) { + if !ct::fixed_eq(&expected, tag.as_bytes()) { 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 33bae3ba..7ea0efd3 100644 --- a/src/auth/argon2/mod.rs +++ b/src/auth/argon2/mod.rs @@ -1913,7 +1913,7 @@ macro_rules! define_argon2_variant { .map_err(|_| VerificationError::new())?; actual.resize(expected.len(), 0); let hash_failed = Self::derive(params, password, salt, &mut actual).is_err(); - let bytes_match = ct::constant_time_eq(&actual, expected); + let bytes_match = ct::public_len_eq(&actual, expected); ct::zeroize(&mut actual); let success = !hash_failed & bytes_match; @@ -2081,7 +2081,7 @@ impl Argon2idPassword { actual.as_mut_array(), ) .map_err(|_| VerificationError::new())?; - let verified = ct::constant_time_eq(actual.as_array(), &approved.expected); + let verified = ct::fixed_eq(actual.as_array(), &approved.expected); if !core::hint::black_box(verified) { return Err(VerificationError::new()); } diff --git a/src/auth/ecdsa.rs b/src/auth/ecdsa.rs index 1fd1f0a1..b5f83ea8 100644 --- a/src/auth/ecdsa.rs +++ b/src/auth/ecdsa.rs @@ -944,7 +944,7 @@ impl EcdsaP256SecretKey { impl PartialEq for EcdsaP256SecretKey { fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) + ct::fixed_eq(&self.0, &other.0) } } @@ -962,8 +962,6 @@ impl Drop for EcdsaP256SecretKey { } } -impl_ct_eq!(EcdsaP256SecretKey); - /// P-384 ECDSA secret scalar. pub struct EcdsaP384SecretKey([u8; Self::LENGTH]); @@ -1110,7 +1108,7 @@ impl EcdsaP384SecretKey { impl PartialEq for EcdsaP384SecretKey { fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) + ct::fixed_eq(&self.0, &other.0) } } @@ -1128,8 +1126,6 @@ impl Drop for EcdsaP384SecretKey { } } -impl_ct_eq!(EcdsaP384SecretKey); - impl crate::traits::TrySigner for EcdsaP256SecretKey { type Signature = EcdsaP256Signature; type Error = EcdsaError; @@ -1537,8 +1533,6 @@ impl fmt::Debug for EcdsaP256Signature { } } -impl_ct_eq!(EcdsaP256Signature, bytes); - /// P-384 ECDSA signature encoded as fixed-width `r || s`. #[derive(Clone, Copy, PartialEq, Eq, Hash)] pub struct EcdsaP384Signature { @@ -1592,8 +1586,6 @@ impl fmt::Debug for EcdsaP384Signature { } } -impl_ct_eq!(EcdsaP384Signature, bytes); - #[inline(always)] fn mask_nonzero_u64(value: u64) -> u64 { 0u64.wrapping_sub((value | value.wrapping_neg()) >> 63) diff --git a/src/auth/ed25519.rs b/src/auth/ed25519.rs index 5a4360a7..1dcff47f 100644 --- a/src/auth/ed25519.rs +++ b/src/auth/ed25519.rs @@ -128,7 +128,7 @@ pub struct Ed25519SecretKey([u8; Self::LENGTH]); impl PartialEq for Ed25519SecretKey { fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) + ct::fixed_eq(&self.0, &other.0) } } @@ -243,8 +243,6 @@ impl Drop for Ed25519SecretKey { } } -impl_ct_eq!(Ed25519SecretKey); - /// Ed25519 public key bytes. #[derive(Clone, Copy)] pub struct Ed25519PublicKey { @@ -332,8 +330,6 @@ impl fmt::Debug for Ed25519PublicKey { impl_hex_fmt!(Ed25519PublicKey); impl_serde_bytes!(Ed25519PublicKey); -impl_ct_eq!(Ed25519PublicKey, bytes); - impl Ed25519PublicKey { /// Verify a message/signature pair against this public key. /// @@ -396,8 +392,6 @@ impl fmt::Debug for Ed25519Signature { impl_hex_fmt!(Ed25519Signature); impl_serde_bytes!(Ed25519Signature); -impl_ct_eq!(Ed25519Signature); - impl crate::traits::TrySigner for Ed25519SecretKey { type Signature = Ed25519Signature; type Error = core::convert::Infallible; @@ -846,7 +840,7 @@ fn verify_aarch64_encoded_r( } aarch64_asm::double_scalar_basepoint_encoded(s_canonical, neg_challenge_bytes, public_key).map(|combined| { - if ct::constant_time_eq(&combined, r_bytes) { + if ct::fixed_eq(&combined, r_bytes) { Ok(()) } else { Err(VerificationError::new()) @@ -864,7 +858,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::constant_time_eq(bytes, small_order)) + .any(|small_order| ct::fixed_eq(bytes, small_order)) } // Dispatch: AArch64 assembly verify fast path → IFMA → AVX2 → portable diff --git a/src/auth/ed25519/point.rs b/src/auth/ed25519/point.rs index 487982e3..1cd42ade 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::constant_time_eq(&lhs_z, &zero)) - | u8::from(ct::constant_time_eq(&rhs_z, &zero)) - | u8::from(!ct::constant_time_eq(&lhs_x, &rhs_x)) - | u8::from(!ct::constant_time_eq(&lhs_y, &rhs_y)); + 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)); core::hint::black_box(diff) == 0 } diff --git a/src/auth/hmac.rs b/src/auth/hmac.rs index 5e27dcd6..08a5fee4 100644 --- a/src/auth/hmac.rs +++ b/src/auth/hmac.rs @@ -29,21 +29,7 @@ macro_rules! define_hmac_tag_type { impl PartialEq for $name { #[inline] fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) - } - } - - impl PartialEq<[u8; $len]> for $name { - #[inline] - fn eq(&self, other: &[u8; $len]) -> bool { - ct::constant_time_eq(&self.0, other) - } - } - - impl PartialEq<$name> for [u8; $len] { - #[inline] - fn eq(&self, other: &$name) -> bool { - ct::constant_time_eq(self, &other.0) + ct::fixed_eq(&self.0, &other.0) } } @@ -140,13 +126,6 @@ macro_rules! define_hmac_tag_type { } } - impl crate::traits::ConstantTimeEq for $name { - #[inline] - fn ct_eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) - } - } - impl core::fmt::Debug for $name { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "{}(", stringify!($name))?; @@ -440,7 +419,7 @@ pub fn diag_hmac_sha256_verify_portable(key: &[u8; SHA256_TAG_SIZE], expected: & crate::hashes::crypto::sha256::kernels::Sha256KernelId::Portable, ); let tag = HmacSha256::mac_with_compress_for_test(key, b"binsec", compress); - ct::constant_time_eq(&tag, expected) + ct::fixed_eq(&tag, expected) } impl Mac for HmacSha256 { @@ -611,7 +590,7 @@ impl Mac for HmacSha256 { #[inline] fn verify(&self, expected: &Self::Tag) -> Result<(), VerificationError> { - if ct::constant_time_eq(self.finalize().as_bytes(), expected.as_bytes()) { + if self.finalize() == *expected { Ok(()) } else { Err(VerificationError::new()) @@ -729,7 +708,7 @@ pub fn diag_hmac_sha384_verify_portable(key: &[u8; SHA384_TAG_SIZE], expected: & crate::hashes::crypto::sha384::kernels::Sha384KernelId::Portable, ); let tag = HmacSha384::mac_with_compress_for_test(key, b"binsec", compress); - ct::constant_time_eq(&tag, expected) + ct::fixed_eq(&tag, expected) } impl Mac for HmacSha384 { @@ -883,7 +862,7 @@ impl Mac for HmacSha384 { #[inline] fn verify(&self, expected: &Self::Tag) -> Result<(), VerificationError> { - if ct::constant_time_eq(self.finalize().as_bytes(), expected.as_bytes()) { + if self.finalize() == *expected { Ok(()) } else { Err(VerificationError::new()) @@ -1001,7 +980,7 @@ pub fn diag_hmac_sha512_verify_portable(key: &[u8; SHA512_TAG_SIZE], expected: & crate::hashes::crypto::sha512::kernels::Sha512KernelId::Portable, ); let tag = HmacSha512::mac_with_compress_for_test(key, b"binsec", compress); - ct::constant_time_eq(&tag, expected) + ct::fixed_eq(&tag, expected) } impl Mac for HmacSha512 { @@ -1155,7 +1134,7 @@ impl Mac for HmacSha512 { #[inline] fn verify(&self, expected: &Self::Tag) -> Result<(), VerificationError> { - if ct::constant_time_eq(self.finalize().as_bytes(), expected.as_bytes()) { + if self.finalize() == *expected { Ok(()) } else { Err(VerificationError::new()) @@ -1250,8 +1229,8 @@ mod tests { let expected = oracle_hmac_sha384(&key, &data); assert_eq!( - HmacSha384::mac(&key, &data), - expected, + HmacSha384::mac(&key, &data).as_bytes(), + &expected, "sha384 public oneshot mismatch kernel={} key_len={} data_len={}", id.as_str(), key_len, @@ -1271,8 +1250,8 @@ mod tests { streaming.update(chunk); } assert_eq!( - streaming.finalize(), - expected, + streaming.finalize().as_bytes(), + &expected, "sha384 forced streaming mismatch kernel={} key_len={} data_len={} chunk_len={}", id.as_str(), key_len, @@ -1283,8 +1262,8 @@ mod tests { streaming.reset(); streaming.update(&data); assert_eq!( - streaming.finalize(), - expected, + streaming.finalize().as_bytes(), + &expected, "sha384 forced reset mismatch kernel={} key_len={} data_len={}", id.as_str(), key_len, @@ -1312,8 +1291,8 @@ mod tests { let expected = oracle_hmac_sha512(&key, &data); assert_eq!( - HmacSha512::mac(&key, &data), - expected, + HmacSha512::mac(&key, &data).as_bytes(), + &expected, "sha512 public oneshot mismatch kernel={} key_len={} data_len={}", id.as_str(), key_len, @@ -1333,8 +1312,8 @@ mod tests { streaming.update(chunk); } assert_eq!( - streaming.finalize(), - expected, + streaming.finalize().as_bytes(), + &expected, "sha512 forced streaming mismatch kernel={} key_len={} data_len={} chunk_len={}", id.as_str(), key_len, @@ -1345,8 +1324,8 @@ mod tests { streaming.reset(); streaming.update(&data); assert_eq!( - streaming.finalize(), - expected, + streaming.finalize().as_bytes(), + &expected, "sha512 forced reset mismatch kernel={} key_len={} data_len={}", id.as_str(), key_len, diff --git a/src/auth/hmac_sha3.rs b/src/auth/hmac_sha3.rs index 523bc2ad..2e52cd8a 100644 --- a/src/auth/hmac_sha3.rs +++ b/src/auth/hmac_sha3.rs @@ -26,21 +26,7 @@ macro_rules! define_hmac_sha3_tag_type { impl PartialEq for $name { #[inline] fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) - } - } - - impl PartialEq<[u8; $len]> for $name { - #[inline] - fn eq(&self, other: &[u8; $len]) -> bool { - ct::constant_time_eq(&self.0, other) - } - } - - impl PartialEq<$name> for [u8; $len] { - #[inline] - fn eq(&self, other: &$name) -> bool { - ct::constant_time_eq(self, &other.0) + ct::fixed_eq(&self.0, &other.0) } } @@ -137,13 +123,6 @@ macro_rules! define_hmac_sha3_tag_type { } } - impl crate::traits::ConstantTimeEq for $name { - #[inline] - fn ct_eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) - } - } - impl core::fmt::Debug for $name { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "{}(", stringify!($name))?; diff --git a/src/auth/kmac.rs b/src/auth/kmac.rs index 94ac0448..4c641c72 100644 --- a/src/auth/kmac.rs +++ b/src/auth/kmac.rs @@ -119,7 +119,7 @@ macro_rules! define_kmac { for chunk in expected.chunks(block.len()) { reader.squeeze(&mut block[..chunk.len()]); - diff |= u8::from(!ct::constant_time_eq(&block[..chunk.len()], chunk)); + diff |= u8::from(!ct::public_len_eq(&block[..chunk.len()], chunk)); } ct::zeroize(&mut block); diff --git a/src/auth/mlkem.rs b/src/auth/mlkem.rs index c075a41a..f9edc929 100644 --- a/src/auth/mlkem.rs +++ b/src/auth/mlkem.rs @@ -92,7 +92,7 @@ macro_rules! define_mlkem_public_bytes { impl PartialEq for $name { #[inline] fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) + self.0 == other.0 } } @@ -113,7 +113,6 @@ macro_rules! define_mlkem_public_bytes { } } - impl_ct_eq!($name); impl_hex_fmt!($name); impl_serde_bytes!($name); }; @@ -167,7 +166,7 @@ macro_rules! define_mlkem_secret_bytes { impl PartialEq for $name { #[inline] fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) + ct::fixed_eq(&self.0, &other.0) } } @@ -185,7 +184,6 @@ macro_rules! define_mlkem_secret_bytes { } } - impl_ct_eq!($name); impl_hex_fmt_secret!($name); impl_serde_secret_bytes!($name); }; @@ -357,7 +355,7 @@ macro_rules! define_mlkem_prepared_keys { impl PartialEq for $prepared_encapsulation_key { #[inline] fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(self.as_bytes(), other.as_bytes()) + self.as_bytes() == other.as_bytes() } } @@ -378,13 +376,6 @@ macro_rules! define_mlkem_prepared_keys { } } - impl crate::traits::ConstantTimeEq for $prepared_encapsulation_key { - #[inline] - fn ct_eq(&self, other: &Self) -> bool { - ct::constant_time_eq(self.as_bytes(), other.as_bytes()) - } - } - #[doc = concat!("Validated, reusable ", $doc_name, " decapsulation key.")] pub struct $prepared_decapsulation_key { key: $decapsulation_key, @@ -474,7 +465,7 @@ macro_rules! define_mlkem_prepared_keys { impl PartialEq for $prepared_decapsulation_key { #[inline] fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(self.as_bytes(), other.as_bytes()) + ct::fixed_eq(self.as_bytes(), other.as_bytes()) } } @@ -485,13 +476,6 @@ macro_rules! define_mlkem_prepared_keys { write!(f, "{}(****)", stringify!($prepared_decapsulation_key)) } } - - impl crate::traits::ConstantTimeEq for $prepared_decapsulation_key { - #[inline] - fn ct_eq(&self, other: &Self) -> bool { - ct::constant_time_eq(self.as_bytes(), other.as_bytes()) - } - } }; } diff --git a/src/auth/mlkem/portable.rs b/src/auth/mlkem/portable.rs index 229db9b8..6f14d7e7 100644 --- a/src/auth/mlkem/portable.rs +++ b/src/auth/mlkem/portable.rs @@ -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::constant_time_eq(a, b))) + 0u8.wrapping_sub(u8::from(ct::public_len_eq(a, b))) } #[inline] diff --git a/src/auth/poly1305.rs b/src/auth/poly1305.rs index ad53e909..e9ef2e22 100644 --- a/src/auth/poly1305.rs +++ b/src/auth/poly1305.rs @@ -89,21 +89,7 @@ pub struct Poly1305Tag([u8; Self::LENGTH]); impl PartialEq for Poly1305Tag { #[inline] fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) - } -} - -impl PartialEq<[u8; TAG_SIZE]> for Poly1305Tag { - #[inline] - fn eq(&self, other: &[u8; TAG_SIZE]) -> bool { - ct::constant_time_eq(&self.0, other) - } -} - -impl PartialEq for [u8; TAG_SIZE] { - #[inline] - fn eq(&self, other: &Poly1305Tag) -> bool { - ct::constant_time_eq(self, &other.0) + ct::fixed_eq(&self.0, &other.0) } } @@ -200,13 +186,6 @@ impl AsRef<[u8; TAG_SIZE]> for Poly1305Tag { } } -impl crate::traits::ConstantTimeEq for Poly1305Tag { - #[inline] - fn ct_eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) - } -} - impl fmt::Debug for Poly1305Tag { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Poly1305Tag(")?; @@ -501,7 +480,7 @@ impl Poly1305 { #[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 ct::constant_time_eq(self.finalize().as_bytes(), expected.as_bytes()) { + if self.finalize() == *expected { Ok(()) } else { Err(VerificationError::new()) diff --git a/src/auth/rsa.rs b/src/auth/rsa.rs index 7c7ecb2c..61a7d30b 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::constant_time_eq(scratch.checked.as_slice(), scratch.one.as_slice()) { + if !ct::public_len_eq(scratch.checked.as_slice(), scratch.one.as_slice()) { return Err(RsaPrivateOpError::InvalidBlindingFactor); } } @@ -3435,7 +3435,7 @@ impl RsaPrivateKeyComponents { &mut scratch.public_scratch, ) .map_err(|_| RsaPrivateOpError::FaultCheckFailed)?; - if ct::constant_time_eq(scratch.checked.as_slice(), scratch.encoded.as_slice()) { + if ct::public_len_eq(scratch.checked.as_slice(), scratch.encoded.as_slice()) { scratch .blinded_private_result .as_mut_slice() @@ -3492,7 +3492,7 @@ impl RsaPrivateKeyComponents { &mut scratch.public_scratch, ) .map_err(|_| RsaPrivateOpError::FaultCheckFailed)?; - if ct::constant_time_eq(scratch.checked.as_slice(), scratch.encoded.as_slice()) { + if ct::public_len_eq(scratch.checked.as_slice(), scratch.encoded.as_slice()) { 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::constant_time_eq(&x, n_minus_one_fixed); + let mut accepted = keygen_is_one_fixed(&x) || ct::public_len_eq(&x, n_minus_one_fixed); 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::constant_time_eq(&x, n_minus_one_fixed); + accepted = ct::public_len_eq(&x, n_minus_one_fixed); } ct::zeroize(&mut x); @@ -8205,7 +8205,7 @@ where } let (decoded_label_hash, rest) = masked_db.split_at(h_len); - bad |= u8::from(!ct::constant_time_eq(decoded_label_hash, label_hash.as_ref())); + bad |= u8::from(!ct::public_len_eq(decoded_label_hash, label_hash.as_ref())); let mut seen_separator = 0u8; let mut separator = 0usize; @@ -8324,7 +8324,7 @@ where verifier.update(salt); let expected_h = verifier.finalize(); - if ct::constant_time_eq(expected_h.as_ref(), h) { + if ct::public_len_eq(expected_h.as_ref(), h) { Ok(()) } else { Err(VerificationError::new()) @@ -8359,8 +8359,8 @@ where valid &= byte == 0xff; } valid &= encoded.get(separator_index).copied() == Some(0x00); - valid &= ct::constant_time_eq(prefix, digest_info_prefix); - valid &= ct::constant_time_eq(value, digest.as_ref()); + valid &= ct::public_len_eq(prefix, digest_info_prefix); + valid &= ct::public_len_eq(value, digest.as_ref()); if valid { Ok(()) } else { Err(VerificationError::new()) } } @@ -8483,13 +8483,13 @@ fn ct_slices_eq_public_shape(left: &[u8], right: &[u8]) -> bool { if left.len() != right.len() { return false; } - ct::constant_time_eq(left, right) + ct::public_len_eq(left, right) } 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::constant_time_eq(&product, expected); + && ct::public_len_eq(&product, expected); ct::zeroize(&mut product); matched } @@ -8504,7 +8504,7 @@ fn ct_eq_left_padded_unsigned_be(value: &[u8], expected: &[u8]) -> bool { return false; }; dst.copy_from_slice(value); - let eq = ct::constant_time_eq(&padded, expected); + let eq = ct::public_len_eq(&padded, expected); ct::zeroize(&mut padded); eq } diff --git a/src/auth/scrypt.rs b/src/auth/scrypt.rs index 9cbc321f..aff89740 100644 --- a/src/auth/scrypt.rs +++ b/src/auth/scrypt.rs @@ -983,7 +983,7 @@ impl Scrypt { actual.resize(expected.len(), 0); let hash_failed = Self::derive(params, password, salt, &mut actual).is_err(); - let bytes_match = ct::constant_time_eq(&actual, expected); + let bytes_match = ct::public_len_eq(&actual, expected); ct::zeroize(&mut actual); let success = !hash_failed & bytes_match; @@ -1061,7 +1061,7 @@ impl ScryptPassword { let mut actual = crate::secret::ZeroizingBytes::::zeroed(); Scrypt::derive(&approved.params, password, approved.salt(), actual.as_mut_array()) .map_err(|_| VerificationError::new())?; - let verified = ct::constant_time_eq(actual.as_array(), &approved.expected); + let verified = ct::fixed_eq(actual.as_array(), &approved.expected); if !core::hint::black_box(verified) { return Err(VerificationError::new()); } diff --git a/src/auth/x25519.rs b/src/auth/x25519.rs index c3e83ba1..8b662df9 100644 --- a/src/auth/x25519.rs +++ b/src/auth/x25519.rs @@ -150,7 +150,7 @@ pub struct X25519SecretKey([u8; Self::LENGTH]); impl PartialEq for X25519SecretKey { fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) + ct::fixed_eq(&self.0, &other.0) } } @@ -350,8 +350,6 @@ impl Drop for X25519SecretKey { } } -impl_ct_eq!(X25519SecretKey); - /// X25519 public key bytes. #[derive(Clone, Copy)] pub struct X25519PublicKey { @@ -481,8 +479,6 @@ impl fmt::Debug for X25519PublicKey { impl_hex_fmt!(X25519PublicKey); impl_serde_bytes!(X25519PublicKey); -impl_ct_eq!(X25519PublicKey, bytes); - impl From<&X25519SecretKey> for X25519PublicKey { #[inline] fn from(secret: &X25519SecretKey) -> Self { @@ -502,7 +498,7 @@ pub struct X25519SharedSecret([u8; Self::LENGTH]); impl PartialEq for X25519SharedSecret { fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) + ct::fixed_eq(&self.0, &other.0) } } @@ -604,8 +600,6 @@ impl Drop for X25519SharedSecret { } } -impl_ct_eq!(X25519SharedSecret); - #[allow(clippy::indexing_slicing)] #[must_use] #[cfg(any( diff --git a/src/hashes/crypto/blake3/kernel_test.rs b/src/hashes/crypto/blake3/kernel_test.rs index 461e4f80..2dab9a5e 100644 --- a/src/hashes/crypto/blake3/kernel_test.rs +++ b/src/hashes/crypto/blake3/kernel_test.rs @@ -186,7 +186,7 @@ mod tests { // Keyed oneshot (uses length-based dispatch). let ours_keyed = Blake3::keyed_digest(KEY, &msg); let expected_keyed = *blake3::keyed_hash(KEY, &msg).as_bytes(); - assert_eq!(ours_keyed, expected_keyed, "blake3 keyed oneshot mismatch"); + assert_eq!(ours_keyed.as_bytes(), &expected_keyed, "blake3 keyed oneshot mismatch"); // Derive-key oneshot (uses length-based dispatch for key material). let ours_derived = Blake3::derive_key(CONTEXT, &msg); @@ -312,7 +312,7 @@ mod tests { let ours = Blake3::keyed_digest(KEY, &msg); let expected = *blake3::keyed_hash(KEY, &msg).as_bytes(); - assert_eq!(ours, expected, "blake3 keyed oneshot mismatch len={len}"); + assert_eq!(ours.as_bytes(), &expected, "blake3 keyed oneshot mismatch len={len}"); let ours = Blake3::derive_key(CONTEXT, &msg); let expected = blake3::derive_key(CONTEXT, &msg); @@ -442,7 +442,12 @@ mod tests { // Keyed hash one-shot. let ours_keyed = Blake3::keyed_digest(KEY, &msg); let expected_keyed = *blake3::keyed_hash(KEY, &msg).as_bytes(); - assert_eq!(ours_keyed, expected_keyed, "blake3 keyed mismatch len={}", len); + assert_eq!( + ours_keyed.as_bytes(), + &expected_keyed, + "blake3 keyed mismatch len={}", + len + ); // Derive-key one-shot. let ours_derived = Blake3::derive_key(CONTEXT, &msg); diff --git a/src/hashes/crypto/blake3/mod.rs b/src/hashes/crypto/blake3/mod.rs index 697e4212..1b63f56f 100644 --- a/src/hashes/crypto/blake3/mod.rs +++ b/src/hashes/crypto/blake3/mod.rs @@ -109,28 +109,12 @@ impl AsRef<[u8]> for Blake3KeyedHash { impl PartialEq for Blake3KeyedHash { #[inline] fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) - } -} - -impl PartialEq<[u8; OUT_LEN]> for Blake3KeyedHash { - #[inline] - fn eq(&self, other: &[u8; OUT_LEN]) -> bool { - ct::constant_time_eq(&self.0, other) - } -} - -impl PartialEq for [u8; OUT_LEN] { - #[inline] - fn eq(&self, other: &Blake3KeyedHash) -> bool { - ct::constant_time_eq(self, &other.0) + ct::fixed_eq(&self.0, &other.0) } } impl Eq for Blake3KeyedHash {} -impl_ct_eq!(Blake3KeyedHash); - impl core::fmt::Debug for Blake3KeyedHash { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "Blake3KeyedHash(")?; diff --git a/src/hex.rs b/src/hex.rs index 835e25ae..b37bf606 100644 --- a/src/hex.rs +++ b/src/hex.rs @@ -310,29 +310,6 @@ macro_rules! impl_serde_secret_bytes { ($type:ty) => {}; } -/// Implement [`ConstantTimeEq`](crate::traits::ConstantTimeEq) for a type -/// whose secret material lives in a single byte-array field. -/// -/// Default form uses the tuple field `.0`; pass a field name for named structs. -macro_rules! impl_ct_eq { - ($type:ty) => { - impl crate::traits::ConstantTimeEq for $type { - #[inline] - fn ct_eq(&self, other: &Self) -> bool { - crate::traits::ct::constant_time_eq(&self.0, &other.0) - } - } - }; - ($type:ty, $field:ident) => { - impl crate::traits::ConstantTimeEq for $type { - #[inline] - fn ct_eq(&self, other: &Self) -> bool { - crate::traits::ct::constant_time_eq(&self.$field, &other.$field) - } - } - }; -} - /// Generate a `random()` constructor that fills `Self::LENGTH` bytes from /// the operating system CSPRNG via `getrandom`. /// diff --git a/src/lib.rs b/src/lib.rs index c5121a22..1749cf35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -261,7 +261,7 @@ mod macros; // Internal modules (not published as separate crates) // `hex` is an internal utility module for public byte newtypes that expose -// hex formatting, parsing, constant-time equality, or explicit secret display. +// hex formatting, parsing, or explicit secret display. // Public re-exports of `DisplaySecret` / `InvalidHexError` stay gated to the // features that surface them in the public API. #[cfg(any( @@ -572,9 +572,7 @@ pub use secret::SecretVec; feature = "ascon-aead" ))] pub use traits::Aead; -pub use traits::{ - Checksum, ChecksumCombine, ConstantTimeEq, Kem, Mac, TrySigner, TrySignerInto, VerificationError, Verifier, ct, -}; +pub use traits::{Checksum, ChecksumCombine, Kem, Mac, TrySigner, TrySignerInto, VerificationError, Verifier, ct}; #[cfg(any( feature = "sha2", feature = "sha3", @@ -612,8 +610,7 @@ pub mod prelude { ))] pub use crate::traits::Aead; pub use crate::traits::{ - Checksum, ChecksumCombine, ConstantTimeEq, Digest, FastHash, Kem, Mac, TrySigner, TrySignerInto, VerificationError, - Verifier, Xof, + Checksum, ChecksumCombine, Digest, FastHash, Kem, Mac, TrySigner, TrySignerInto, VerificationError, Verifier, Xof, }; } @@ -809,6 +806,58 @@ let _ = writer.crc(); "#] pub struct __ApiPatternAudit; +#[cfg(all(doctest, feature = "full"))] +#[doc(hidden)] +#[doc = r#" +```compile_fail +use rscrypto::ConstantTimeEq; +``` + +```compile_fail +let left = [0u8; 32]; +let right = [0u8; 32]; +let _ = left.ct_eq(&right); +``` + +```compile_fail +let left = [0u8; 32]; +let right = [0u8; 32]; +let _ = rscrypto::ct::constant_time_eq(&left, &right); +``` + +```compile_fail +use rscrypto::SecretBytes; + +let left = SecretBytes::new([0u8; 32]); +let right = SecretBytes::new([0u8; 32]); +let _ = left == right; +``` + +```compile_fail +use rscrypto::SecretVec; + +fn compare(left: &SecretVec, right: &SecretVec) -> bool { + left == right +} +``` + +```compile_fail +use rscrypto::HmacSha256Tag; + +let tag = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let _ = tag == [0u8; HmacSha256Tag::LENGTH]; +``` + +```rust +use rscrypto::HmacSha256Tag; + +let left = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +let right = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); +assert!(left == right); +``` +"#] +pub struct __OwnerEqualityBoundaryAudit; + // Compile-time trait assertions. // // Every public type must be Send + Sync + Debug. Most must also be Clone. diff --git a/src/macros.rs b/src/macros.rs index ecf5333a..09e5b992 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -447,7 +447,7 @@ macro_rules! define_aead_key_type { impl PartialEq for $name { fn eq(&self, other: &Self) -> bool { - crate::traits::ct::constant_time_eq(&self.0, &other.0) + crate::traits::ct::fixed_eq(&self.0, &other.0) } } @@ -493,8 +493,6 @@ macro_rules! define_aead_key_type { } } - impl_ct_eq!($name); - impl core::fmt::Debug for $name { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "{}(****)", stringify!($name)) @@ -556,7 +554,7 @@ macro_rules! define_aead_tag_type { impl PartialEq for $name { #[inline] fn eq(&self, other: &Self) -> bool { - crate::traits::ct::constant_time_eq(&self.0, &other.0) + crate::traits::ct::fixed_eq(&self.0, &other.0) } } @@ -609,8 +607,6 @@ macro_rules! define_aead_tag_type { } } - impl_ct_eq!($name); - impl core::fmt::Debug for $name { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { write!(f, "{}(", stringify!($name))?; diff --git a/src/secret.rs b/src/secret.rs index 50e78125..c7e465a8 100644 --- a/src/secret.rs +++ b/src/secret.rs @@ -58,15 +58,6 @@ impl AsRef<[u8]> for SecretBytes { } } -impl PartialEq for SecretBytes { - #[inline] - fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(&self.0, &other.0) - } -} - -impl Eq for SecretBytes {} - impl fmt::Debug for SecretBytes { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str("SecretBytes(****)") @@ -152,17 +143,6 @@ impl core::ops::Deref for SecretVec { } } -#[cfg(feature = "alloc")] -impl PartialEq for SecretVec { - #[inline] - fn eq(&self, other: &Self) -> bool { - ct::constant_time_eq(self.as_bytes(), other.as_bytes()) - } -} - -#[cfg(feature = "alloc")] -impl Eq for SecretVec {} - #[cfg(feature = "alloc")] impl fmt::Debug for SecretVec { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { diff --git a/src/traits/ct.rs b/src/traits/ct.rs index aeed941d..b56e85ee 100644 --- a/src/traits/ct.rs +++ b/src/traits/ct.rs @@ -1,124 +1,52 @@ -//! Constant-time utilities for cryptographic operations. +//! Secret-handling utilities for cryptographic operations. -/// Constant-time equality comparison. -/// -/// Implementors guarantee that the comparison examines every byte regardless -/// of mismatches, preventing timing side-channels. -/// -/// # Examples -/// -/// ``` -/// use rscrypto::ConstantTimeEq; -/// -/// let a = [1u8, 2, 3, 4]; -/// let b = [1u8, 2, 3, 4]; -/// let c = [1u8, 2, 3, 5]; -/// -/// assert!(a.ct_eq(&b)); -/// assert!(!a.ct_eq(&c)); -/// ``` -pub trait ConstantTimeEq { - /// Returns `true` if `self` and `other` are equal. - /// - /// The comparison is constant-time with respect to the data contents. - #[must_use] - fn ct_eq(&self, other: &Self) -> bool; -} +#[inline(always)] +fn byte_difference(left: &[u8], right: &[u8]) -> u64 { + let mut difference = 0u64; + let mut left_chunks = left.chunks_exact(8); + let mut right_chunks = right.chunks_exact(8); -impl ConstantTimeEq for [u8; N] { - #[inline] - fn ct_eq(&self, other: &Self) -> bool { - constant_time_eq(self, other) + for (left_chunk, right_chunk) in left_chunks.by_ref().zip(right_chunks.by_ref()) { + let (Ok(left_bytes), Ok(right_bytes)) = (<&[u8; 8]>::try_from(left_chunk), <&[u8; 8]>::try_from(right_chunk)) + else { + return u64::MAX; + }; + difference |= u64::from_ne_bytes(*left_bytes) ^ u64::from_ne_bytes(*right_bytes); } -} -impl ConstantTimeEq for [u8] { - #[inline] - fn ct_eq(&self, other: &Self) -> bool { - constant_time_eq(self, other) + let mut remainder = 0u8; + for (left_byte, right_byte) in left_chunks.remainder().iter().zip(right_chunks.remainder()) { + remainder |= left_byte ^ right_byte; } + difference | u64::from(remainder) } -/// Constant-time byte equality comparison. -/// -/// Returns `true` if `a` and `b` have equal length and identical contents. -/// The comparison examines every byte regardless of mismatches, preventing -/// timing side-channels for equal-length inputs. Length mismatches return -/// `false` immediately. -/// -/// # Examples +/// Compare two fixed-size byte arrays with work independent of their contents. /// -/// ``` -/// use rscrypto::traits::ct::constant_time_eq; -/// -/// let a = [1u8, 2, 3, 4]; -/// let b = [1u8, 2, 3, 4]; -/// let c = [1u8, 2, 3, 5]; +/// This is crate-private by design. Public comparison policy belongs to the +/// concrete cryptographic type that owns the bytes. Optimized machine-code +/// 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 +} + +/// Compare two byte slices whose lengths are public protocol inputs. /// -/// assert!(constant_time_eq(&a, &b)); -/// assert!(!constant_time_eq(&a, &c)); -/// assert!(!constant_time_eq(&a, &[])); -/// ``` +/// Length mismatch is intentionally observable. Equal-length contents are +/// traversed without content-dependent exits. Keep callers individually +/// classified in `ct.toml`; fixed-shape owner types must use [`fixed_eq`]. #[inline] #[must_use] -pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { - if a.len() != b.len() { +#[allow(dead_code)] +pub(crate) fn public_len_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { return false; } - - let len = a.len(); - let mut acc0 = 0u64; - let mut acc1 = 0u64; - let mut acc2 = 0u64; - let mut acc3 = 0u64; - let mut i = 0usize; - let end32 = len & !31; - let a_ptr = a.as_ptr(); - let b_ptr = b.as_ptr(); - - while i < end32 { - // SAFETY: `end32` rounds `len` down to a multiple of 32, so each unaligned - // 8-byte load stays within the slice bounds. - unsafe { - acc0 |= - core::ptr::read_unaligned(a_ptr.add(i).cast::()) ^ core::ptr::read_unaligned(b_ptr.add(i).cast::()); - acc1 |= core::ptr::read_unaligned(a_ptr.add(i.strict_add(8)).cast::()) - ^ core::ptr::read_unaligned(b_ptr.add(i.strict_add(8)).cast::()); - acc2 |= core::ptr::read_unaligned(a_ptr.add(i.strict_add(16)).cast::()) - ^ core::ptr::read_unaligned(b_ptr.add(i.strict_add(16)).cast::()); - acc3 |= core::ptr::read_unaligned(a_ptr.add(i.strict_add(24)).cast::()) - ^ core::ptr::read_unaligned(b_ptr.add(i.strict_add(24)).cast::()); - } - i = i.strict_add(32); - } - - let mut acc = acc0 | acc1 | acc2 | acc3; - let end8 = len & !7; - while i < end8 { - // SAFETY: `end8` rounds `len` down to a multiple of 8, so each unaligned - // 8-byte load stays within the slice bounds. - unsafe { - acc |= - core::ptr::read_unaligned(a_ptr.add(i).cast::()) ^ core::ptr::read_unaligned(b_ptr.add(i).cast::()); - } - i = i.strict_add(8); - } - - while i < len { - // SAFETY: `i < len`, so these raw-pointer reads are in-bounds. - unsafe { - acc |= (*a_ptr.add(i) ^ *b_ptr.add(i)) as u64; - } - i = i.strict_add(1); - } - - let mut observed = acc; - // SAFETY: `observed` is a valid stack slot. The volatile round-trip prevents - // the optimizer from collapsing the loop into an early-exit byte compare. - unsafe { - core::ptr::write_volatile(&mut observed, acc); - core::ptr::read_volatile(&observed) == 0 - } + byte_difference(left, right) == 0 } /// Volatile-zero a byte slice without emitting a compiler fence. @@ -227,98 +155,22 @@ pub(crate) fn zeroize_words(words: &mut [T]) { mod tests { use super::*; - // ── constant_time_eq ──────────────────────────────────────────────────── - - #[test] - fn equal_slices() { - assert!(constant_time_eq(b"abcdef", b"abcdef")); - } - - #[test] - fn differ_first_byte() { - assert!(!constant_time_eq(b"\x00bcdef", b"\xFFbcdef")); - } - - #[test] - fn differ_last_byte() { - assert!(!constant_time_eq(b"abcde\x00", b"abcde\xFF")); - } - - #[test] - fn differ_middle_byte() { - assert!(!constant_time_eq(b"ab\x00def", b"ab\xFFdef")); - } - #[test] - fn both_empty() { - assert!(constant_time_eq(b"", b"")); - } - - #[test] - fn one_empty_one_not() { - assert!(!constant_time_eq(b"", b"x")); - assert!(!constant_time_eq(b"x", b"")); - } - - #[test] - fn length_mismatch() { - assert!(!constant_time_eq(b"short", b"longer")); - assert!(!constant_time_eq(b"longer", b"short")); - } - - #[test] - fn single_byte_equal() { - assert!(constant_time_eq(&[0x42], &[0x42])); - } - - #[test] - fn single_byte_differ() { - assert!(!constant_time_eq(&[0x00], &[0x01])); - } - - #[test] - fn all_zeros_equal() { - assert!(constant_time_eq(&[0u8; 64], &[0u8; 64])); - } - - #[test] - fn all_ones_equal() { - assert!(constant_time_eq(&[0xFF; 64], &[0xFF; 64])); - } - - #[test] - fn equal_misaligned_long_slices() { - let a = [0x5Au8; 73]; - let b = [0x5Au8; 73]; - assert!(constant_time_eq(&a[1..], &b[1..])); - } - - #[test] - fn differ_misaligned_long_slices() { - let a = [0x5Au8; 73]; - let mut b = [0x5Au8; 73]; - b[64] ^= 0x80; - assert!(!constant_time_eq(&a[1..], &b[1..])); - } - - // ── ConstantTimeEq trait ──────────────────────────────────────────────── - - #[test] - fn ct_eq_fixed_array() { - let a = [1u8, 2, 3, 4]; - let b = [1u8, 2, 3, 4]; - let c = [1u8, 2, 3, 5]; - assert!(a.ct_eq(&b)); - assert!(!a.ct_eq(&c)); + fn fixed_eq_checks_every_position() { + let value = [0x5a; 64]; + assert!(fixed_eq(&value, &value)); + for index in [0, value.len() / 2, value.len() - 1] { + let mut different = value; + different[index] ^= 1; + assert!(!fixed_eq(&value, &different)); + } } #[test] - fn ct_eq_slice() { - let a: &[u8] = &[10, 20, 30]; - let b: &[u8] = &[10, 20, 30]; - let c: &[u8] = &[10, 20, 31]; - assert!(a.ct_eq(b)); - assert!(!a.ct_eq(c)); + 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")); } // ── zeroize ───────────────────────────────────────────────────────────── diff --git a/src/traits/error.rs b/src/traits/error.rs index 9c9403f1..267609f3 100644 --- a/src/traits/error.rs +++ b/src/traits/error.rs @@ -12,19 +12,13 @@ define_unit_error! { /// # Examples /// /// ``` - /// use rscrypto::traits::{VerificationError, ct::constant_time_eq}; + /// # #[cfg(feature = "hmac")] + /// # { + /// use rscrypto::{HmacSha256, HmacSha256Tag, Mac}; /// - /// fn verify(computed: &[u8; 32], expected: &[u8; 32]) -> Result<(), VerificationError> { - /// if constant_time_eq(computed, expected) { - /// Ok(()) - /// } else { - /// Err(VerificationError::new()) - /// } - /// } - /// - /// let a = [0u8; 32]; - /// let b = [1u8; 32]; - /// assert!(verify(&a, &b).is_err()); + /// let expected = HmacSha256Tag::from_bytes([0u8; HmacSha256Tag::LENGTH]); + /// assert!(HmacSha256::verify_tag(b"key", b"message", &expected).is_err()); + /// # } /// ``` /// /// # Security @@ -32,7 +26,9 @@ define_unit_error! { /// 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 use constant-time comparison. + /// oracle. The underlying verification should compare a fixed-size semantic + /// owner whose implementation is covered by the constant-time evidence + /// policy in `ct.toml`. #[non_exhaustive] pub struct VerificationError; "verification failed" diff --git a/src/traits/mac.rs b/src/traits/mac.rs index a19538ec..aa7460d5 100644 --- a/src/traits/mac.rs +++ b/src/traits/mac.rs @@ -5,52 +5,25 @@ use core::fmt::Debug; -use crate::traits::{VerificationError, ct}; +use crate::traits::VerificationError; /// Message authentication code producing a fixed-size tag. /// /// This trait is intended for fixed-size algorithms like HMAC-SHA256. /// -/// # Examples -/// -/// ```rust -/// # use rscrypto::traits::Mac; -/// # #[derive(Clone)] -/// # struct MyMac { key: u8, state: u8 } -/// # impl Mac for MyMac { -/// # const TAG_SIZE: usize = 4; -/// # type Tag = [u8; 4]; -/// # fn new(key: &[u8]) -> Self { -/// # Self { key: key.first().copied().unwrap_or(0), state: 0 } -/// # } -/// # fn update(&mut self, data: &[u8]) { -/// # self.state = data.iter().fold(self.state, |acc, &b| acc.wrapping_add(b)); -/// # } -/// # fn finalize(&self) -> Self::Tag { [self.state ^ self.key; 4] } -/// # fn reset(&mut self) { self.state = 0; } -/// # } -/// -/// let key = b"secret-key"; -/// -/// // One-shot. -/// let tag = MyMac::mac(key, b"hello world"); -/// -/// // Streaming. -/// let mut mac = MyMac::new(key); -/// mac.update(b"hello "); -/// mac.update(b"world"); -/// assert!(mac.verify(&tag).is_ok()); -/// -/// // Verify. -/// assert!(MyMac::verify_tag(key, b"hello world", &tag).is_ok()); -/// ``` +/// 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. pub trait Mac: Clone { /// Tag size in bytes. const TAG_SIZE: usize; /// The authentication tag type. /// - /// Typically `[u8; N]`. + /// 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]>; /// Construct a new MAC state with `key`. @@ -114,22 +87,22 @@ pub trait Mac: Clone { Self::mac(key, data).as_ref().to_vec() } - /// Verify `expected` against the current tag in constant time. + /// Verify `expected` against the current tag using the tag owner's equality. #[inline] #[must_use = "MAC verification must be checked; a dropped Result silently accepts a forged tag"] fn verify(&self, expected: &Self::Tag) -> Result<(), VerificationError> { - if ct::constant_time_eq(self.finalize().as_ref(), expected.as_ref()) { + if self.finalize() == *expected { Ok(()) } else { Err(VerificationError::new()) } } - /// Compute and verify a tag in one shot. + /// Compute and verify a tag in one shot using the tag owner's equality. #[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 ct::constant_time_eq(Self::mac(key, data).as_ref(), expected.as_ref()) { + if Self::mac(key, data) == *expected { Ok(()) } else { Err(VerificationError::new()) diff --git a/src/traits/mod.rs b/src/traits/mod.rs index ffbc5444..e8aa2a21 100644 --- a/src/traits/mod.rs +++ b/src/traits/mod.rs @@ -59,7 +59,6 @@ mod xof; ))] pub use aead::Aead; pub use checksum::{Checksum, ChecksumCombine}; -pub use ct::ConstantTimeEq; pub use digest::Digest; pub use error::VerificationError; pub use fast_hash::FastHash; diff --git a/tests/mlkem_types.rs b/tests/mlkem_types.rs index 3c93f551..570ca368 100644 --- a/tests/mlkem_types.rs +++ b/tests/mlkem_types.rs @@ -1,10 +1,9 @@ #![cfg(feature = "ml-kem")] use rscrypto::{ - ConstantTimeEq, MlKem512, MlKem512Ciphertext, MlKem512DecapsulationKey, MlKem512EncapsulationKey, - MlKem512SharedSecret, MlKem768, MlKem768Ciphertext, MlKem768DecapsulationKey, MlKem768EncapsulationKey, - MlKem768SharedSecret, MlKem1024, MlKem1024Ciphertext, MlKem1024DecapsulationKey, MlKem1024EncapsulationKey, - MlKem1024SharedSecret, + MlKem512, MlKem512Ciphertext, MlKem512DecapsulationKey, MlKem512EncapsulationKey, MlKem512SharedSecret, MlKem768, + MlKem768Ciphertext, MlKem768DecapsulationKey, MlKem768EncapsulationKey, MlKem768SharedSecret, MlKem1024, + MlKem1024Ciphertext, MlKem1024DecapsulationKey, MlKem1024EncapsulationKey, MlKem1024SharedSecret, }; #[test] @@ -48,10 +47,8 @@ fn mlkem_public_values_roundtrip_all_bytes() { let ct512 = MlKem512Ciphertext::from_bytes([0x52; MlKem512Ciphertext::LENGTH]); assert_eq!(ek512.to_bytes(), [0x51; MlKem512EncapsulationKey::LENGTH]); assert_eq!(ct512.to_bytes(), [0x52; MlKem512Ciphertext::LENGTH]); - assert!(ek512.ct_eq(&MlKem512EncapsulationKey::from_bytes( - [0x51; MlKem512EncapsulationKey::LENGTH] - ))); - assert!(!ct512.ct_eq(&MlKem512Ciphertext::from_bytes([0x53; MlKem512Ciphertext::LENGTH]))); + assert!(ek512 == MlKem512EncapsulationKey::from_bytes([0x51; MlKem512EncapsulationKey::LENGTH])); + assert!(ct512 != MlKem512Ciphertext::from_bytes([0x53; MlKem512Ciphertext::LENGTH])); let ek768 = MlKem768EncapsulationKey::from_bytes([0x61; MlKem768EncapsulationKey::LENGTH]); let ct768 = MlKem768Ciphertext::from_bytes([0x62; MlKem768Ciphertext::LENGTH]); @@ -77,10 +74,8 @@ 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.ct_eq(&MlKem768DecapsulationKey::from_bytes( - [0xb1; MlKem768DecapsulationKey::LENGTH] - ))); - assert!(!ss768.ct_eq(&MlKem768SharedSecret::from_bytes([0xb3; MlKem768SharedSecret::LENGTH]))); + assert!(dk768 == MlKem768DecapsulationKey::from_bytes([0xb1; MlKem768DecapsulationKey::LENGTH])); + assert!(ss768 != MlKem768SharedSecret::from_bytes([0xb3; MlKem768SharedSecret::LENGTH])); 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 new file mode 100644 index 00000000..e5e86b92 --- /dev/null +++ b/tests/owned_equality.rs @@ -0,0 +1,37 @@ +fn assert_owned_equality(make: impl Fn([u8; N]) -> T) +where + T: PartialEq, +{ + let bytes = [0x5a; N]; + assert!(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)); + } +} + +#[test] +#[cfg(feature = "aes-gcm")] +fn aes128_key_owns_16_byte_equality() { + assert_owned_equality(rscrypto::Aes128GcmKey::from_bytes); +} + +#[test] +#[cfg(feature = "x25519")] +fn x25519_secret_key_owns_32_byte_equality() { + assert_owned_equality(rscrypto::X25519SecretKey::from_bytes); +} + +#[test] +#[cfg(feature = "hmac")] +fn hmac_sha384_tag_owns_48_byte_equality() { + assert_owned_equality(rscrypto::HmacSha384Tag::from_bytes); +} + +#[test] +#[cfg(feature = "hmac")] +fn hmac_sha512_tag_owns_64_byte_equality() { + assert_owned_equality(rscrypto::HmacSha512Tag::from_bytes); +} diff --git a/tests/root_surface.rs b/tests/root_surface.rs index 0b89d17e..44a3e075 100644 --- a/tests/root_surface.rs +++ b/tests/root_surface.rs @@ -104,7 +104,9 @@ fn fill_rsa_random_from(bytes: &[u8]) -> impl FnMut(&mut [u8]) -> Result<(), Rsa #[test] fn root_surface_core_exports_compile() { let _ = VerificationError::new(); - assert!(ct::constant_time_eq(b"ok", b"ok")); + let mut secret = [0x5a; 8]; + ct::zeroize(&mut secret); + assert_eq!(secret, [0; 8]); } #[test] diff --git a/tools/ct-binsec-harness/src/main.rs b/tools/ct-binsec-harness/src/main.rs index decba6e5..625e0c2a 100644 --- a/tools/ct-binsec-harness/src/main.rs +++ b/tools/ct-binsec-harness/src/main.rs @@ -1,15 +1,16 @@ -use core::{ptr, slice}; +use core::ptr; use rscrypto::{ Aegis256, Aegis256Key, Aes128Gcm, Aes128GcmKey, Aes128GcmSiv, Aes128GcmSivKey, Aes256Gcm, Aes256GcmKey, Aes256GcmSiv, Aes256GcmSivKey, AsconAead128, AsconAead128Key, Blake3KeyedHash, ChaCha20Poly1305, - ChaCha20Poly1305Key, Kmac256, SecretBytes, XChaCha20Poly1305, XChaCha20Poly1305Key, + ChaCha20Poly1305Key, HmacSha384Tag, HmacSha512Tag, Kmac256, X25519SecretKey, XChaCha20Poly1305, + XChaCha20Poly1305Key, aead::{Nonce96, Nonce128, Nonce192, Nonce256}, - traits::ct, }; const LEN_16: usize = 16; const LEN_32: usize = 32; +const LEN_48: usize = 48; const LEN_64: usize = 64; const CURVE25519_LIMBS: usize = 5; const ARGON2_BLOCK_WORDS: usize = 128; @@ -96,6 +97,14 @@ pub static mut CT_BINSEC_LHS_32: [u8; LEN_32] = [0u8; LEN_32]; #[used] pub static mut CT_BINSEC_RHS_32: [u8; LEN_32] = [0u8; LEN_32]; +#[unsafe(no_mangle)] +#[used] +pub static mut CT_BINSEC_LHS_48: [u8; LEN_48] = [0u8; LEN_48]; + +#[unsafe(no_mangle)] +#[used] +pub static mut CT_BINSEC_RHS_48: [u8; LEN_48] = [0u8; LEN_48]; + #[unsafe(no_mangle)] #[used] pub static mut CT_BINSEC_LHS_64: [u8; LEN_64] = [0u8; LEN_64]; @@ -232,13 +241,6 @@ pub static mut CT_BINSEC_RSA_OTHER_32: [u8; 32] = [0u8; 32]; #[used] pub static mut CT_BINSEC_RESULT: u8 = 0; -#[inline(always)] -unsafe fn slice_from_global(ptr: *const [u8; N]) -> &'static [u8] { - // SAFETY: BINSEC harness globals are fixed-size byte arrays with static - // storage duration. The caller passes the address of one such global. - unsafe { slice::from_raw_parts(ptr.cast::(), N) } -} - #[inline(always)] unsafe fn array_from_global(ptr: *const [u8; N]) -> [u8; N] { // SAFETY: BINSEC harness globals are fixed-size byte arrays with static @@ -266,41 +268,41 @@ pub extern "C" fn ct_binsec_done(result: u8) -> ! { #[unsafe(no_mangle)] #[inline(never)] -pub extern "C" fn ct_binsec_constant_time_eq_64() -> ! { +pub extern "C" fn ct_binsec_owner_eq_64() -> ! { // SAFETY: These pointers reference fixed harness globals with static storage. - let lhs = unsafe { slice_from_global(ptr::addr_of!(CT_BINSEC_LHS_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 = unsafe { slice_from_global(ptr::addr_of!(CT_BINSEC_RHS_64)) }; - ct_binsec_done(u8::from(ct::constant_time_eq(lhs, rhs))) + let rhs = HmacSha512Tag::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_RHS_64)) }); + ct_binsec_done(u8::from(lhs == rhs)) } #[unsafe(no_mangle)] #[inline(never)] -pub extern "C" fn ct_binsec_constant_time_eq_32() -> ! { +pub extern "C" fn ct_binsec_owner_eq_48() -> ! { // SAFETY: These pointers reference fixed harness globals with static storage. - let lhs = unsafe { slice_from_global(ptr::addr_of!(CT_BINSEC_LHS_32)) }; + 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 = unsafe { slice_from_global(ptr::addr_of!(CT_BINSEC_RHS_32)) }; - ct_binsec_done(u8::from(ct::constant_time_eq(lhs, rhs))) + let rhs = HmacSha384Tag::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_RHS_48)) }); + ct_binsec_done(u8::from(lhs == rhs)) } #[unsafe(no_mangle)] #[inline(never)] -pub extern "C" fn ct_binsec_constant_time_eq_16() -> ! { +pub extern "C" fn ct_binsec_owner_eq_32() -> ! { // SAFETY: These pointers reference fixed harness globals with static storage. - let lhs = unsafe { slice_from_global(ptr::addr_of!(CT_BINSEC_LHS_16)) }; + 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 = unsafe { slice_from_global(ptr::addr_of!(CT_BINSEC_RHS_16)) }; - ct_binsec_done(u8::from(ct::constant_time_eq(lhs, rhs))) + let rhs = X25519SecretKey::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_RHS_32)) }); + ct_binsec_done(u8::from(lhs == rhs)) } #[unsafe(no_mangle)] #[inline(never)] -pub extern "C" fn ct_binsec_secret_bytes32_eq() -> ! { +pub extern "C" fn ct_binsec_owner_eq_16() -> ! { // SAFETY: These pointers reference fixed harness globals with static storage. - let lhs = SecretBytes::<32>::from(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_LHS_32)) }); + 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 = SecretBytes::<32>::from(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_RHS_32)) }); + let rhs = Aes128GcmKey::from_bytes(unsafe { array_from_global(ptr::addr_of!(CT_BINSEC_RHS_16)) }); ct_binsec_done(u8::from(lhs == rhs)) } @@ -822,10 +824,10 @@ pub unsafe extern "C" fn ct_binsec_ed25519_select_basepoint_cached_ifma() -> ! { #[unsafe(no_mangle)] #[used] pub static CT_BINSEC_ENTRYPOINTS: [extern "C" fn() -> !; 44] = [ - ct_binsec_constant_time_eq_64, - ct_binsec_constant_time_eq_32, - ct_binsec_constant_time_eq_16, - ct_binsec_secret_bytes32_eq, + ct_binsec_owner_eq_16, + ct_binsec_owner_eq_32, + ct_binsec_owner_eq_48, + ct_binsec_owner_eq_64, ct_binsec_hmac_sha256_verify, ct_binsec_hmac_sha384_verify, ct_binsec_hmac_sha512_verify, @@ -877,5 +879,5 @@ pub static CT_BINSEC_X86_ENTRYPOINTS: [unsafe extern "C" fn() -> !; 2] = [ ]; fn main() { - ct_binsec_constant_time_eq_64(); + ct_binsec_owner_eq_64(); } diff --git a/tools/ct-dudect/src/main.rs b/tools/ct-dudect/src/main.rs index 4dbf5968..fa35a0ba 100644 --- a/tools/ct-dudect/src/main.rs +++ b/tools/ct-dudect/src/main.rs @@ -31,7 +31,7 @@ use rscrypto::{ diag_mlkem_multiply_ntts_add_assign_input_digest, diag_mlkem_ntt_input_digest, diag_mlkem_to_montgomery_product_domain_input_digest, diag_rsa_import_pkcs8_private_key_der_stage, diag_rsa_validate_pkcs8_private_key_der, diag_rsa_validate_pkcs8_private_key_der_stage, - traits::{Kem as _, ct}, + traits::Kem as _, RsaEncryptionError, RsaPublicKeyPolicy, }; @@ -206,24 +206,33 @@ fn argon2i_parallel_params() -> Argon2Params { .unwrap() } -fn constant_time_eq_equal_vs_first_diff(runner: &mut CtRunner, rng: &mut BenchRng) { - let mut inputs = Vec::with_capacity(samples()); - for _ in 0..samples() { - let class = random_class(rng); - let a = rand_array::<64>(rng); - let mut b = a; - if matches!(class, Class::Right) { - b[0] ^= 1; - } - inputs.push((class, a, b)); - } +macro_rules! fixed_owner_eq_case { + ($name:ident, $type:ty, $len:expr) => { + fn $name(runner: &mut CtRunner, rng: &mut BenchRng) { + let mut inputs = Vec::with_capacity(samples()); + for _ in 0..samples() { + let class = random_class(rng); + let left = rand_array::<$len>(rng); + let mut right = left; + if matches!(class, Class::Right) { + right[0] ^= 1; + } + inputs.push((class, <$type>::from_bytes(left), <$type>::from_bytes(right))); + } - for (class, a, b) in inputs { - runner.run_one(class, || ct::constant_time_eq(&a, &b)); - } + for (class, left, right) in inputs { + runner.run_one(class, || left == right); + } + } + }; } -fn secret_wrappers_eq_and_debug_fixed_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { +fixed_owner_eq_case!(owner_eq_16_equal_vs_first_diff, Aes128GcmKey, 16); +fixed_owner_eq_case!(owner_eq_32_equal_vs_first_diff, X25519SecretKey, 32); +fixed_owner_eq_case!(owner_eq_48_equal_vs_first_diff, HmacSha384Tag, 48); +fixed_owner_eq_case!(owner_eq_64_equal_vs_first_diff, HmacSha512Tag, 64); + +fn secret_wrappers_debug_fixed_vs_random(runner: &mut CtRunner, rng: &mut BenchRng) { let mut inputs = Vec::with_capacity(samples()); for _ in 0..samples() { let class = random_class(rng); @@ -237,10 +246,8 @@ fn secret_wrappers_eq_and_debug_fixed_vs_random(runner: &mut CtRunner, rng: &mut for (class, bytes) in inputs { runner.run_one(class, || { - let left = SecretBytes::<32>::new(bytes); - let right = SecretBytes::<32>::new(bytes); - let masked = format!("{left:?}"); - left == right && masked.as_str() == "SecretBytes(****)" + let secret = SecretBytes::<32>::new(bytes); + format!("{secret:?}").as_str() == "SecretBytes(****)" }); } } @@ -1961,8 +1968,11 @@ mod tests { } ctbench_main_with_seeds!( - (constant_time_eq_equal_vs_first_diff, Some(0x727363727970746f)), - (secret_wrappers_eq_and_debug_fixed_vs_random, Some(0x7365637265745f77)), + (owner_eq_16_equal_vs_first_diff, Some(0x6f776e657231365f)), + (owner_eq_32_equal_vs_first_diff, Some(0x6f776e657233325f)), + (owner_eq_48_equal_vs_first_diff, Some(0x6f776e657234385f)), + (owner_eq_64_equal_vs_first_diff, Some(0x6f776e657236345f)), + (secret_wrappers_debug_fixed_vs_random, Some(0x7365637265745f77)), (hmac_sha256_valid_vs_invalid_tag, Some(0x686d61635f736861)), (hmac_sha384_valid_vs_invalid_tag, Some(0x686d61633338345f)), (hmac_sha512_valid_vs_invalid_tag, Some(0x686d61633531325f)), diff --git a/tools/ct-harness/src/lib.rs b/tools/ct-harness/src/lib.rs index 3d95b4fe..b132fab3 100644 --- a/tools/ct-harness/src/lib.rs +++ b/tools/ct-harness/src/lib.rs @@ -24,7 +24,7 @@ use rscrypto::{ XChaCha20Poly1305Key, aead::{Nonce96, Nonce128, Nonce192, Nonce256}, checksum::Checksum, - traits::{Kem as _, ct}, + traits::Kem as _, }; const STATUS_ERR: u8 = 0; @@ -155,22 +155,6 @@ fn rsa_ct_fixture_key(pkcs8_der: &[u8]) -> Option { RsaPrivateKey::from_pkcs8_der_with_policy(pkcs8_der, &RsaPublicKeyPolicy::legacy_verification()).ok() } -/// Constant-time equality harness. -#[unsafe(no_mangle)] -pub extern "C" fn ct_entry_constant_time_eq(a: *const u8, a_len: usize, b: *const u8, b_len: usize) -> u8 { - // SAFETY: FFI inputs are converted under `input_slice`'s contract; invalid - // null/nonzero pairs return an error status instead of dereferencing. - let Some(a) = (unsafe { input_slice(a, a_len) }) else { - return STATUS_ERR; - }; - // SAFETY: Same reasoning as above for `b`. - let Some(b) = (unsafe { input_slice(b, b_len) }) else { - return STATUS_ERR; - }; - - u8::from(ct::constant_time_eq(a, b)) -} - /// HMAC-SHA256 tag verification harness. #[unsafe(no_mangle)] pub extern "C" fn ct_entry_hmac_sha256_verify( @@ -814,47 +798,28 @@ macro_rules! fixed_tag_verify_entry { fixed_tag_verify_entry!(ct_entry_hmac_sha384_verify, HmacSha384, HmacSha384Tag, 48); fixed_tag_verify_entry!(ct_entry_hmac_sha512_verify, HmacSha512, HmacSha512Tag, 64); -#[unsafe(no_mangle)] -pub extern "C" fn ct_entry_secret_bytes32_eq(a: *const u8, b: *const u8) -> u8 { - // SAFETY: Fixed-size FFI inputs are copied by value after null checks. - let Some(a) = (unsafe { read_array::<32>(a) }) else { - return STATUS_ERR; - }; - // SAFETY: Fixed-size FFI inputs are copied by value after null checks. - let Some(b) = (unsafe { read_array::<32>(b) }) else { - return STATUS_ERR; - }; - - u8::from(SecretBytes::<32>::from(a) == SecretBytes::<32>::from(b)) -} +macro_rules! fixed_owner_eq_entry { + ($name:ident, $type:ty, $len:expr) => { + #[unsafe(no_mangle)] + pub extern "C" fn $name(a: *const u8, b: *const u8) -> u8 { + // SAFETY: Fixed-size FFI inputs are copied by value after null checks. + let Some(a) = (unsafe { read_array::<$len>(a) }) else { + return STATUS_ERR; + }; + // SAFETY: Fixed-size FFI inputs are copied by value after null checks. + let Some(b) = (unsafe { read_array::<$len>(b) }) else { + return STATUS_ERR; + }; -#[unsafe(no_mangle)] -pub extern "C" fn ct_entry_x25519_secret_eq(a: *const u8, b: *const u8) -> u8 { - // SAFETY: Fixed-size FFI inputs are copied by value after null checks. - let Some(a) = (unsafe { read_array::<32>(a) }) else { - return STATUS_ERR; - }; - // SAFETY: Fixed-size FFI inputs are copied by value after null checks. - let Some(b) = (unsafe { read_array::<32>(b) }) else { - return STATUS_ERR; + u8::from(<$type>::from_bytes(a) == <$type>::from_bytes(b)) + } }; - - u8::from(X25519SecretKey::from_bytes(a) == X25519SecretKey::from_bytes(b)) } -#[unsafe(no_mangle)] -pub extern "C" fn ct_entry_ed25519_secret_eq(a: *const u8, b: *const u8) -> u8 { - // SAFETY: Fixed-size FFI inputs are copied by value after null checks. - let Some(a) = (unsafe { read_array::<32>(a) }) else { - return STATUS_ERR; - }; - // SAFETY: Fixed-size FFI inputs are copied by value after null checks. - let Some(b) = (unsafe { read_array::<32>(b) }) else { - return STATUS_ERR; - }; - - u8::from(Ed25519SecretKey::from_bytes(a) == Ed25519SecretKey::from_bytes(b)) -} +fixed_owner_eq_entry!(ct_entry_owner_eq_16, Aes128GcmKey, 16); +fixed_owner_eq_entry!(ct_entry_owner_eq_32, X25519SecretKey, 32); +fixed_owner_eq_entry!(ct_entry_owner_eq_48, HmacSha384Tag, 48); +fixed_owner_eq_entry!(ct_entry_owner_eq_64, HmacSha512Tag, 64); #[unsafe(no_mangle)] pub extern "C" fn ct_entry_secret_bytes32_debug_masked(secret: *const u8) -> u8 {