diff --git a/.changes/add-websocket-accept-digest-a6f2.md b/.changes/add-websocket-accept-digest-a6f2.md new file mode 100644 index 00000000..5f991240 --- /dev/null +++ b/.changes/add-websocket-accept-digest-a6f2.md @@ -0,0 +1,5 @@ +--- +"rscrypto" = "minor" +--- + +Add an allocation-free RFC 6455 WebSocket accept-digest capability behind the explicit `websocket-sha1` feature without exposing general-purpose SHA-1. diff --git a/.config/benchmark-matrix.json b/.config/benchmark-matrix.json index 95a29fbe..2253333f 100644 --- a/.config/benchmark-matrix.json +++ b/.config/benchmark-matrix.json @@ -3,7 +3,17 @@ "default_features": ["parallel", "full"], "crates": { "checksum": ["crc"], - "hashes": ["sha2", "sha3", "kmac_cshake", "ascon", "xxh3", "rapidhash", "blake2", "blake3"], + "hashes": [ + "sha2", + "sha3", + "websocket_sha1", + "kmac_cshake", + "ascon", + "xxh3", + "rapidhash", + "blake2", + "blake3" + ], "auth": ["auth", "kmac_cshake", "password_hashing", "rsa"], "rsa": ["rsa"], "aead": ["aead"] @@ -27,6 +37,12 @@ "required": true, "features": ["parallel", "sha3"] }, + "websocket_sha1": { + "binary": "websocket_sha1", + "kind": "criterion", + "required": true, + "features": ["websocket-sha1"] + }, "kmac_cshake": { "binary": "kmac_cshake", "kind": "criterion", @@ -151,6 +167,11 @@ "sha3-256": { "crate": "hashes", "bench": "sha3", "filter": "sha3-256" }, "sha3-384": { "crate": "hashes", "bench": "sha3", "filter": "sha3-384" }, "sha3-512": { "crate": "hashes", "bench": "sha3", "filter": "sha3-512" }, + "websocket-accept-digest": { + "crate": "hashes", + "bench": "websocket_sha1", + "filter": "websocket-accept-digest" + }, "shake128": { "crate": "hashes", "bench": "sha3", "filter": "shake128" }, "shake256": { "crate": "hashes", "bench": "sha3", "filter": "shake256" }, "cshake256": { "crate": "auth", "bench": "kmac_cshake", "filter": "cshake256" }, @@ -213,6 +234,7 @@ "sha3-256", "sha3-384", "sha3-512", + "websocket-accept-digest", "shake128", "shake256", "cshake256", @@ -316,6 +338,7 @@ "sha3-256", "sha3-384", "sha3-512", + "websocket-accept-digest", "shake128", "shake256", "cshake256", @@ -337,6 +360,7 @@ "sha3-256", "sha3-384", "sha3-512", + "websocket-accept-digest", "shake128", "shake256", "cshake256", @@ -360,6 +384,7 @@ "crc", "sha2", "sha3", + "websocket_sha1", "kmac_cshake", "ascon", "auth", @@ -371,7 +396,16 @@ "kernels": ["blake3"], "checksum_comp": ["crc"], "checksum_kernels": ["crc"], - "hashes_comp": ["sha2", "sha3", "kmac_cshake", "ascon", "xxh3", "rapidhash", "blake3"], + "hashes_comp": [ + "sha2", + "sha3", + "websocket_sha1", + "kmac_cshake", + "ascon", + "xxh3", + "rapidhash", + "blake3" + ], "auth_comp": ["auth"], "aead_comp": ["aead"], "hashes_kernels": ["blake3"] diff --git a/.github/workflows/_ci-suite.yaml b/.github/workflows/_ci-suite.yaml index 2d054716..7d9e5549 100644 --- a/.github/workflows/_ci-suite.yaml +++ b/.github/workflows/_ci-suite.yaml @@ -76,9 +76,7 @@ jobs: name: Cargo Graph Assurance uses: ./.github/workflows/_rust-job.yaml with: - # PR runners are provisioned from protected main, whose pre-transition - # definition still enables RunsOn MagicCache until this change lands. - runner: ubuntu-latest + runner: runs-on=${{ github.run_id }}/runner=linux-x64-ci timeout_minutes: 90 tools_mode: rail toolchain_components: "rust-src" diff --git a/Cargo.lock b/Cargo.lock index 1f7bfc0e..a20ea065 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2023,6 +2023,7 @@ dependencies = [ "scrypt", "serde", "serde_json", + "sha1", "sha2 0.10.9", "sha2 0.11.0", "sha3 0.12.0", @@ -2181,6 +2182,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/Cargo.toml b/Cargo.toml index c8e89000..165e10f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -161,6 +161,10 @@ ascon-hash = [] xxh3 = [] rapidhash = [] +# Compatibility-only WebSocket handshake capability. Deliberately excluded +# from every umbrella feature, including `full`. +websocket-sha1 = [] + hmac = ["sha2"] hmac-sha3 = ["sha3"] hkdf = ["hmac"] @@ -238,6 +242,7 @@ scrypt = { version = "0.12.0", default-features = false } sha2 = "0.11.0" sha2_010 = { package = "sha2", version = "0.10.9" } sha3 = "0.12.0" +sha1 = { version = "0.11.0", default-features = false } keccak = "0.2.1" xxhash-rust = { version = "0.8.18", features = ["xxh3"] } rapidhash = "=4.5.1" @@ -366,6 +371,11 @@ name = "sha3" harness = false required-features = ["sha3"] +[[bench]] +name = "websocket_sha1" +harness = false +required-features = ["websocket-sha1"] + [[bench]] name = "ascon" harness = false diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index 18830a75..9f2e355f 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -28,9 +28,9 @@ Review the `ct_intended` candidate core before the rest of the repository: This order prioritizes secret-dependent computation; it does not remove public parsers, dispatch, or unsafe kernels from the security boundary. Public parsing, -raw hashes, checksums, non-cryptographic hashes, public-key verification math, -benchmark paths, and unlisted build configurations carry no blanket -constant-time claim. +raw hashes, compatibility-only WebSocket accept digests, checksums, +non-cryptographic hashes, public-key verification math, benchmark paths, and +unlisted build configurations carry no blanket constant-time claim. ## System boundary @@ -118,6 +118,7 @@ Ordered by exposure to untrusted input: | Secret-bearing compute | Sign, decrypt, decapsulate, derive; the release-evidenced subset of `ct.toml` | Timing leakage, incorrect arithmetic | | `unsafe` low-level code | SIMD/assembly kernels, raw buffer helpers, zeroization, and dispatch | Undefined behavior, divergence from the portable authority | | Dispatch | `src/platform`, `src/backend` | Selecting a kernel the CPU cannot run, or one that produces wrong output | +| Compatibility operations | `hashes::legacy::WebSocketAcceptDigest::compute` | Capability expansion or treating broken SHA-1 collision resistance as authentication | ## Mitigations and evidence @@ -130,6 +131,7 @@ Ordered by exposure to untrusted input: | Oracle behavior | Opaque errors, failed-open output clearing, single-bit failure shape | AEAD and verification tests, fuzz targets | | Secret exposure at rest | Zeroize at the last owned use and on drop, masked `Debug` and errors, and sealed fixed-size comparison only on semantic secret owners | [`docs/secret-ownership.md`](docs/secret-ownership.md), [`docs/secret-lifecycle.md`](docs/secret-lifecycle.md), `scripts/check/zeroize-evidence.sh`, and `tests/secret_redaction.rs` | | Supply chain | Minimal optional runtime dependencies, `cargo deny`, `cargo audit`, signed tags, Trusted Publishing, release attestations | [`deny.toml`](deny.toml), [`.github/workflows/release.yaml`](.github/workflows/release.yaml), [`docs/release.md`](docs/release.md) | +| Legacy primitive misuse | Semantic-only API, explicit leaf feature, no umbrella membership, no raw/streaming SHA-1 | Feature-boundary check, compile-fail root-surface doctest, RFC/oracle tests | ## Known gaps diff --git a/benches/websocket_sha1.rs b/benches/websocket_sha1.rs new file mode 100644 index 00000000..f32549a2 --- /dev/null +++ b/benches/websocket_sha1.rs @@ -0,0 +1,35 @@ +//! RFC 6455 WebSocket accept-digest comparison benchmark. + +use core::hint::black_box; + +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; +use rscrypto::hashes::legacy::WebSocketAcceptDigest; +use sha1::{Digest as _, Sha1}; + +const WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const REPRESENTATIVE_KEY: &[u8] = b"dGhlIHNhbXBsZSBub25jZQ=="; + +fn websocket_accept_digest(c: &mut Criterion) { + let mut group = c.benchmark_group("websocket-accept-digest/24-byte-key"); + group.throughput(Throughput::Bytes( + REPRESENTATIVE_KEY.len().strict_add(WEBSOCKET_GUID.len()) as u64, + )); + + group.bench_function("rscrypto", |b| { + b.iter(|| black_box(WebSocketAcceptDigest::compute(black_box(REPRESENTATIVE_KEY)))) + }); + + group.bench_function("rustcrypto", |b| { + b.iter(|| { + let mut sha1 = Sha1::new(); + sha1.update(black_box(REPRESENTATIVE_KEY)); + sha1.update(WEBSOCKET_GUID); + black_box(sha1.finalize()) + }) + }); + + group.finish(); +} + +criterion_group!(benches, websocket_accept_digest); +criterion_main!(benches); diff --git a/docs/benchmarking.md b/docs/benchmarking.md index d8766830..014aebeb 100644 --- a/docs/benchmarking.md +++ b/docs/benchmarking.md @@ -62,6 +62,7 @@ shape-compatible: | ---------------------------- | --------------------------------------------------------------------------- | | AEAD | RustCrypto AEADs, `aws-lc-rs`, `ring`, `aegis` | | SHA-2 / HMAC / HKDF / PBKDF2 | RustCrypto, `aws-lc-rs`, `ring` | +| WebSocket accept digest | RustCrypto `sha1`, with identical field bytes and fixed RFC 6455 GUID | | BLAKE2 / BLAKE3 | RustCrypto, `dryoc`, upstream `blake3` | | ECDSA P-256/P-384 | RustCrypto `p256`/`p384`, `aws-lc-rs`, `ring` | | Ed25519 / X25519 | dalek, `aws-lc-rs`, `ring` where API-compatible, `dryoc` | @@ -101,6 +102,9 @@ Some common libraries are not primary benchmark baselines: batched inverse with reusable scratch and cleanup. Use it with the fixed- and caller-entropy signing rows to distinguish inverse cost from CRT exponentiation and whole-operation overhead. +- The WebSocket row measures one 24-byte `Sec-WebSocket-Key` field value plus + the fixed 36-byte RFC 6455 GUID. Both implementations receive the same two + slices; Base64 and HTTP parsing are intentionally outside the timed region. - `ring` X25519 is excluded from static-key Diffie-Hellman rows because its public API exposes an ephemeral agreement shape that consumes the private key. - `dryoc` XChaCha20-Poly1305 is excluded from one-shot AEAD rows because the diff --git a/docs/features.md b/docs/features.md index 01dd80ad..334f71ac 100644 --- a/docs/features.md +++ b/docs/features.md @@ -104,6 +104,21 @@ rscrypto = { version = "0.8.1", features = ["full", "portable-only"] } | `aegis256` | -- | AEGIS-256 | | `ascon-aead` | -- | Ascon-AEAD128 | +### Compatibility-only features + +| Feature | Pulls in | Enables | +| ---------------- | -------- | ------------------------------------------------------------------------------- | +| `websocket-sha1` | -- | RFC 6455 `hashes::legacy::WebSocketAcceptDigest`; no raw or streaming SHA-1 API | + +`websocket-sha1` is deliberately excluded from `crypto-hashes`, `hashes`, and +`full`. Cargo's `--all-features` activates it because that flag activates every +declared leaf, but no rscrypto feature activates it transitively. The operation +exists only for WebSocket handshake compatibility: it hashes the field bytes +and RFC 6455 GUID, returns the public 20-byte digest, and leaves HTTP parsing and +Base64 encoding to the caller. SHA-1 collision resistance is broken; do not use +this capability for authentication, signatures, certificates, content +identity, or any new protocol. + ### Auxiliary features | Feature | Effect | diff --git a/docs/test-vector-coverage.md b/docs/test-vector-coverage.md index 37f62fd2..737e4ef9 100644 --- a/docs/test-vector-coverage.md +++ b/docs/test-vector-coverage.md @@ -34,6 +34,7 @@ the concrete inputs and outputs of each cryptography API. | SHA-384 | `tests/sha2_official_vectors.rs`, `testdata/sha2/sha384.blb` | Streaming/oneshot and dispatch equivalence; shared SHA-512-family boundary test panics before overlength absorption | Inputs beyond the FIPS 180-4 length field panic | | SHA-512 | `tests/sha2_official_vectors.rs`, `tests/sha512_differential.rs`, `testdata/sha2/sha512.blb` | Streaming/oneshot and dispatch equivalence; `message_length_above_boundary_panics` covers the encoded-length boundary | Inputs beyond the FIPS 180-4 length field panic | | SHA-512/256 | `tests/sha2_official_vectors.rs`, `testdata/sha2/sha512_256.blb` | Streaming/oneshot and dispatch equivalence; shared SHA-512-family boundary test panics before overlength absorption | Inputs beyond the FIPS 180-4 length field panic | +| WebSocket accept digest | RFC 6455 example plus private SHA-1 KATs in `src/hashes/legacy/sha1.rs`; RustCrypto `sha1` differential coverage in `tests/websocket_sha1.rs` and `fuzz/target_impls/hash_websocket_sha1.rs` | Byte-preservation checks and key lengths crossing 55/56/63/64-byte padding and block boundaries | Compatibility-only SHA-1 capability; no collision-resistance or authentication claim | | SHA3-224/256/384/512 | `tests/sha3_official_vectors.rs`, `tests/sha3_differential.rs`, `testdata/sha3/sha3_*.blb` | Streaming/oneshot and dispatch equivalence | No invalid input class | | SHAKE128 | `tests/sha3_official_vectors.rs`, `tests/shake128_differential.rs`, `testdata/sha3/shake128.blb` | XOF output-length boundaries | No invalid input class | | SHAKE256 | `tests/sha3_official_vectors.rs`, `tests/shake256_differential.rs`, `testdata/sha3/shake256.blb` | XOF output-length boundaries | No invalid input class | diff --git a/docs/types.md b/docs/types.md index 3ec01fc4..e10ea4d6 100644 --- a/docs/types.md +++ b/docs/types.md @@ -80,6 +80,19 @@ Aliases: `hashes::crypto::AsconXof128` and `hashes::crypto::AsconXof128Reader`. `Blake2bKey` and `Blake2sKey` make caller-facing key validation explicit while borrowing key bytes without allocation or copying. +## Compatibility-only protocol operations + +Feature: `websocket-sha1` (not included by `crypto-hashes`, `hashes`, or `full`). + +| Type | Output | Standard | +| --------------------------------------- | ------ | -------- | +| `hashes::legacy::WebSocketAcceptDigest` | 20B | RFC 6455 | + +`WebSocketAcceptDigest::compute` hashes the caller's field value byte-for-byte +with the fixed RFC GUID. It exposes neither raw nor streaming SHA-1. The digest +is public compatibility data, not an authentication result; the caller owns +HTTP parsing and Base64 encoding. + ## Fast hashes Features: `fast-hashes` or `xxh3` / `rapidhash`. diff --git a/fuzz-packages/hash-websocket-sha1/Cargo.lock b/fuzz-packages/hash-websocket-sha1/Cargo.lock new file mode 100644 index 00000000..9e018f41 --- /dev/null +++ b/fuzz-packages/hash-websocket-sha1/Cargo.lock @@ -0,0 +1,166 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rscrypto" +version = "0.8.1" + +[[package]] +name = "rscrypto-fuzz-hash-websocket-sha1" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "rscrypto", + "rscrypto-fuzz-support", + "sha1", +] + +[[package]] +name = "rscrypto-fuzz-support" +version = "0.0.0" +dependencies = [ + "rscrypto", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" diff --git a/fuzz-packages/hash-websocket-sha1/Cargo.toml b/fuzz-packages/hash-websocket-sha1/Cargo.toml new file mode 100644 index 00000000..7c379b49 --- /dev/null +++ b/fuzz-packages/hash-websocket-sha1/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rscrypto-fuzz-hash-websocket-sha1" +version = "0.0.0" +publish = false +edition = "2024" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +rscrypto = { path = "../..", default-features = false, features = ["websocket-sha1"] } +rscrypto_fuzz = { package = "rscrypto-fuzz-support", path = "../../fuzz/support" } +sha1 = { version = "0.11.0", default-features = false } + +[workspace] +members = ["."] + +[[bin]] +name = "hash_websocket_sha1" +path = "fuzz_targets/hash_websocket_sha1.rs" +doc = false diff --git a/fuzz-packages/hash-websocket-sha1/corpus/hash_websocket_sha1/seed-basic b/fuzz-packages/hash-websocket-sha1/corpus/hash_websocket_sha1/seed-basic new file mode 100644 index 00000000..b5400fe8 --- /dev/null +++ b/fuzz-packages/hash-websocket-sha1/corpus/hash_websocket_sha1/seed-basic @@ -0,0 +1 @@ +dGhlIHNhbXBsZSBub25jZQ== diff --git a/fuzz-packages/hash-websocket-sha1/fuzz_targets/hash_websocket_sha1.rs b/fuzz-packages/hash-websocket-sha1/fuzz_targets/hash_websocket_sha1.rs new file mode 100644 index 00000000..07f1e2b4 --- /dev/null +++ b/fuzz-packages/hash-websocket-sha1/fuzz_targets/hash_websocket_sha1.rs @@ -0,0 +1,8 @@ +#![no_main] + +#[path = "../../../fuzz/target_impls/hash_websocket_sha1.rs"] +mod target_impl; + +libfuzzer_sys::fuzz_target!(|data: &[u8]| { + target_impl::run(data); +}); diff --git a/fuzz-packages/hash-websocket-sha1/tests/corpus_replay.rs b/fuzz-packages/hash-websocket-sha1/tests/corpus_replay.rs new file mode 100644 index 00000000..57678883 --- /dev/null +++ b/fuzz-packages/hash-websocket-sha1/tests/corpus_replay.rs @@ -0,0 +1,20 @@ +use std::path::PathBuf; + +use rscrypto_fuzz::replay_corpus_dir; + +fn corpus_dir(target: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("corpus").join(target) +} + +#[path = "../../../fuzz/target_impls/hash_websocket_sha1.rs"] +mod hash_websocket_sha1; + +#[test] +fn replay_hash_websocket_sha1_corpus() { + let replayed = replay_corpus_dir( + "hash_websocket_sha1", + corpus_dir("hash_websocket_sha1"), + hash_websocket_sha1::run, + ); + assert_ne!(replayed, 0, "hash_websocket_sha1 corpus should not be empty"); +} diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index b438ed0e..80ec664d 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -822,6 +822,7 @@ dependencies = [ "rscrypto", "rscrypto-fuzz-support", "scrypt", + "sha1", "sha2", "sha3", "tiny-keccak", @@ -932,6 +933,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.11.0" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 2fd985af..457d7d2d 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -26,6 +26,7 @@ rscrypto = { path = "..", features = [ "std", "checksums", "hashes", + "websocket-sha1", "auth", "aead", "argon2", @@ -60,6 +61,7 @@ cshake = { version = "0.2.1", default-features = false } digest = "0.11" sha2 = "0.11" sha3 = "0.12" +sha1 = { version = "0.11.0", default-features = false } tiny-keccak = { version = "2.0", features = ["kmac", "shake"] } # Fast hashes @@ -296,6 +298,11 @@ name = "hash_cshake256" path = "fuzz_targets/hash_cshake256.rs" doc = false +[[bin]] +name = "hash_websocket_sha1" +path = "fuzz_targets/hash_websocket_sha1.rs" +doc = false + [[bin]] name = "hex_parse" path = "fuzz_targets/hex_parse.rs" diff --git a/fuzz/corpus/hash_websocket_sha1/seed-basic b/fuzz/corpus/hash_websocket_sha1/seed-basic new file mode 100644 index 00000000..b5400fe8 --- /dev/null +++ b/fuzz/corpus/hash_websocket_sha1/seed-basic @@ -0,0 +1 @@ +dGhlIHNhbXBsZSBub25jZQ== diff --git a/fuzz/fuzz_targets/hash_websocket_sha1.rs b/fuzz/fuzz_targets/hash_websocket_sha1.rs new file mode 100644 index 00000000..8c1573ed --- /dev/null +++ b/fuzz/fuzz_targets/hash_websocket_sha1.rs @@ -0,0 +1,8 @@ +#![no_main] + +#[path = "../target_impls/hash_websocket_sha1.rs"] +mod target_impl; + +libfuzzer_sys::fuzz_target!(|data: &[u8]| { + target_impl::run(data); +}); diff --git a/fuzz/target_impls/hash_websocket_sha1.rs b/fuzz/target_impls/hash_websocket_sha1.rs new file mode 100644 index 00000000..0214b48a --- /dev/null +++ b/fuzz/target_impls/hash_websocket_sha1.rs @@ -0,0 +1,15 @@ +use rscrypto::hashes::legacy::WebSocketAcceptDigest; +use sha1::{Digest as _, Sha1}; + +const WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +pub(super) fn run(data: &[u8]) { + let ours = WebSocketAcceptDigest::compute(data); + + let mut oracle = Sha1::new(); + oracle.update(data); + oracle.update(WEBSOCKET_GUID); + let expected = oracle.finalize(); + + assert_eq!(ours.as_ref(), expected.as_slice(), "WebSocket accept digest mismatch"); +} diff --git a/fuzz/tests/corpus_replay.rs b/fuzz/tests/corpus_replay.rs index c9152356..68279dc7 100644 --- a/fuzz/tests/corpus_replay.rs +++ b/fuzz/tests/corpus_replay.rs @@ -141,6 +141,9 @@ mod hash_sha2; #[path = "../target_impls/hash_sha3.rs"] mod hash_sha3; +#[path = "../target_impls/hash_websocket_sha1.rs"] +mod hash_websocket_sha1; + #[path = "../target_impls/hex_parse.rs"] mod hex_parse; @@ -481,6 +484,16 @@ fn replay_hash_sha3_corpus() { assert_ne!(replayed, 0, "hash_sha3 corpus should not be empty"); } +#[test] +fn replay_hash_websocket_sha1_corpus() { + let replayed = replay_corpus_dir( + "hash_websocket_sha1", + corpus_dir("hash_websocket_sha1"), + hash_websocket_sha1::run, + ); + assert_ne!(replayed, 0, "hash_websocket_sha1 corpus should not be empty"); +} + #[test] fn replay_hex_parse_corpus() { let replayed = replay_corpus_dir("hex_parse", corpus_dir("hex_parse"), hex_parse::run); diff --git a/scripts/check/check-linux.sh b/scripts/check/check-linux.sh index ddf9f2b8..f79647ca 100755 --- a/scripts/check/check-linux.sh +++ b/scripts/check/check-linux.sh @@ -57,9 +57,11 @@ for i in "${!LINUX_TARGETS[@]}"; do targets[i]="$target" ( - toolchain_env=() + # Keep the command prefix non-empty: Bash 3.2 treats an empty array + # expansion as an unbound variable under `set -u`. + toolchain_env=(env) if [[ "$target" == riscv64* ]]; then - toolchain_env=(env "RUSTUP_TOOLCHAIN=$NIGHTLY_TOOLCHAIN") + toolchain_env+=("RUSTUP_TOOLCHAIN=$NIGHTLY_TOOLCHAIN") fi # shellcheck disable=SC2086 if ! CC="$ZIG_CC" CARGO_TARGET_DIR="$target_dir" \ diff --git a/scripts/check/check.sh b/scripts/check/check.sh index 08b93330..d862fe8c 100755 --- a/scripts/check/check.sh +++ b/scripts/check/check.sh @@ -86,6 +86,14 @@ if ! "$SCRIPT_DIR/../ct/python.sh" "$SCRIPT_DIR/auth-vector-provenance.py" >"$LO fi ok +step "Checking feature boundaries" +if ! "$SCRIPT_DIR/../ct/python.sh" "$SCRIPT_DIR/feature-boundaries.py" >"$LOG_DIR/feature-boundaries.log" 2>&1; then + fail + show_error "$LOG_DIR/feature-boundaries.log" + exit 1 +fi +ok + step "Checking benchmark catalog" if ! "$SCRIPT_DIR/../ct/python.sh" "$SCRIPT_DIR/../bench/benchmark_catalog_test.py" >"$LOG_DIR/benchmark-catalog.log" 2>&1; then fail diff --git a/scripts/check/feature-boundaries.py b/scripts/check/feature-boundaries.py new file mode 100644 index 00000000..0ea2609d --- /dev/null +++ b/scripts/check/feature-boundaries.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Validate features that must remain explicit, standalone capabilities.""" + +from __future__ import annotations + +import pathlib +import sys +import tomllib + + +REPO_ROOT = pathlib.Path(__file__).resolve().parents[2] +EXPLICIT_ONLY_FEATURES = ("websocket-sha1",) + + +def local_feature(reference: str, features: dict[str, list[str]]) -> str | None: + if reference.startswith("dep:"): + return None + name = reference.split("/", maxsplit=1)[0].removesuffix("?") + return name if name in features else None + + +def reaches( + source: str, + target: str, + features: dict[str, list[str]], + visited: set[str] | None = None, +) -> bool: + if source == target: + return True + seen = set() if visited is None else visited + if source in seen: + return False + seen.add(source) + return any( + dependency is not None and reaches(dependency, target, features, seen) + for dependency in (local_feature(item, features) for item in features[source]) + ) + + +def main() -> int: + with (REPO_ROOT / "Cargo.toml").open("rb") as manifest: + features = tomllib.load(manifest)["features"] + + errors: list[str] = [] + for target in EXPLICIT_ONLY_FEATURES: + if target not in features: + errors.append(f"missing explicit-only feature: {target}") + continue + if features[target]: + errors.append(f"{target} must not activate dependencies: {features[target]}") + + for source in features: + if source != target and reaches(source, target, features): + errors.append(f"{source} must not activate explicit-only feature {target}") + + if errors: + print("feature boundary validation failed:", file=sys.stderr) + for error in errors: + print(f"- {error}", file=sys.stderr) + return 1 + + print("feature boundaries ok") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/check-ci-ownership.sh b/scripts/ci/check-ci-ownership.sh index cace2b36..42f0238f 100755 --- a/scripts/ci/check-ci-ownership.sh +++ b/scripts/ci/check-ci-ownership.sh @@ -472,10 +472,10 @@ scorecard_action=$(yq eval '.jobs.scorecard.steps[] | select(.name == "Run Score == "true" ]] \ || fail "Scorecard must publish results" -[[ $(count_feature_sets "$FEATURE_PROFILES" COMPILE_FEATURE_SETS) -eq 58 ]] \ - || fail "compile feature matrix must retain all 58 profiles" -[[ $(count_feature_sets "$FEATURE_PROFILES" EXECUTABLE_FEATURE_SETS) -eq 8 ]] \ - || fail "executable feature matrix must contain the eight behavior transitions" +[[ $(count_feature_sets "$FEATURE_PROFILES" COMPILE_FEATURE_SETS) -eq 59 ]] \ + || fail "compile feature matrix must retain all 59 profiles" +[[ $(count_feature_sets "$FEATURE_PROFILES" EXECUTABLE_FEATURE_SETS) -eq 9 ]] \ + || fail "executable feature matrix must contain the nine behavior transitions" require_unique_feature_sets "$FEATURE_PROFILES" COMPILE_FEATURE_SETS require_unique_feature_sets "$FEATURE_PROFILES" EXECUTABLE_FEATURE_SETS require_feature_subset "$FEATURE_PROFILES" EXECUTABLE_FEATURE_SETS "$FEATURE_PROFILES" COMPILE_FEATURE_SETS diff --git a/scripts/ci/nostd-wasm-suite.sh b/scripts/ci/nostd-wasm-suite.sh index 00fda157..a44c9d35 100755 --- a/scripts/ci/nostd-wasm-suite.sh +++ b/scripts/ci/nostd-wasm-suite.sh @@ -133,6 +133,7 @@ if [[ "$DEPTH" == "deep" ]]; then "alloc,crc32" "sha2" "sha3" + "websocket-sha1" "xxh3" "hmac" "hmac-sha3" diff --git a/scripts/lib/feature-profiles.sh b/scripts/lib/feature-profiles.sh index fa4b1177..8cdc204d 100644 --- a/scripts/lib/feature-profiles.sh +++ b/scripts/lib/feature-profiles.sh @@ -18,6 +18,7 @@ COMPILE_FEATURE_SETS=( "alloc,crc32" "sha2" "sha3" + "websocket-sha1" "blake2b" "blake2s" "xxh3" @@ -69,6 +70,7 @@ COMPILE_FEATURE_SETS=( # Execute the full primitive surface once for each behavior-changing boundary. # Leaf and umbrella composition remains compile-checked above. EXECUTABLE_FEATURE_SETS=( + "websocket-sha1" "full" "std,full" "std,full,getrandom" diff --git a/src/backend/mod.rs b/src/backend/mod.rs index d64af482..ded0d40e 100644 --- a/src/backend/mod.rs +++ b/src/backend/mod.rs @@ -13,6 +13,7 @@ pub(crate) mod ascon; feature = "crc64", feature = "argon2", feature = "sha2", + feature = "websocket-sha1", all(feature = "sha3", any(test, feature = "diag")), all( any(feature = "blake2b", feature = "blake2s"), diff --git a/src/hashes/legacy/mod.rs b/src/hashes/legacy/mod.rs new file mode 100644 index 00000000..b433a9b2 --- /dev/null +++ b/src/hashes/legacy/mod.rs @@ -0,0 +1,57 @@ +//! Capability-confined operations required by legacy protocol standards. +//! +//! These APIs expose complete protocol operations, not general-purpose access +//! to superseded primitives. They are excluded from every umbrella feature so +//! applications must opt in to the exact compatibility capability they need. +//! +//! The capability type is deliberately not available at the crate root: +//! +//! ```compile_fail +//! use rscrypto::WebSocketAcceptDigest; +//! ``` + +mod sha1; + +const WEBSOCKET_GUID: &[u8; 36] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +/// The 20-byte digest used to construct an RFC 6455 WebSocket accept value. +/// +/// [`Self::compute`] hashes the `Sec-WebSocket-Key` field value exactly as +/// received, followed by RFC 6455's fixed GUID. The caller remains responsible +/// for HTTP field parsing and Base64 encoding the returned public bytes. +/// +/// This type is a protocol compatibility value. SHA-1 collision resistance is +/// broken, and this operation provides no authentication or integrity claim. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct WebSocketAcceptDigest([u8; 20]); + +impl WebSocketAcceptDigest { + /// Computes the RFC 6455 WebSocket accept digest without allocating. + /// + /// `sec_websocket_key` is hashed byte-for-byte. This method does not trim, + /// decode, validate, or otherwise interpret the HTTP field value. + #[must_use] + #[inline] + pub fn compute(sec_websocket_key: &[u8]) -> Self { + Self(sha1::digest_websocket_key(sec_websocket_key)) + } +} + +impl AsRef<[u8]> for WebSocketAcceptDigest { + #[inline] + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +#[cfg(test)] +mod tests { + use super::WebSocketAcceptDigest; + + #[test] + fn digest_type_has_public_value_semantics() { + fn assert_traits>() {} + + assert_traits::(); + } +} diff --git a/src/hashes/legacy/sha1.rs b/src/hashes/legacy/sha1.rs new file mode 100644 index 00000000..90ee5533 --- /dev/null +++ b/src/hashes/legacy/sha1.rs @@ -0,0 +1,746 @@ +//! Private SHA-1 core for the RFC 6455 accept-digest capability. + +use crate::backend::cache::OnceCache; + +#[cfg(all(not(miri), target_arch = "aarch64"))] +mod aarch64; +#[cfg(all(not(miri), target_arch = "x86_64"))] +mod x86_64; + +const BLOCK_LEN: usize = 64; +const LENGTH_OFFSET: usize = 56; +const MAX_MESSAGE_BYTES: u64 = u64::MAX / 8; +const INITIAL_STATE: [u32; 5] = [0x6745_2301, 0xefcd_ab89, 0x98ba_dcfe, 0x1032_5476, 0xc3d2_e1f0]; +const STANDARD_WEBSOCKET_KEY_LEN: usize = 24; +const STANDARD_WEBSOCKET_MESSAGE_LEN: usize = STANDARD_WEBSOCKET_KEY_LEN.strict_add(super::WEBSOCKET_GUID.len()); + +type CompressFn = fn(&mut [u32; 5], &[u8; BLOCK_LEN]); + +static ACTIVE_COMPRESS: OnceCache = OnceCache::new(); + +const COMPILE_TIME_HW: bool = cfg!(all( + not(miri), + not(feature = "portable-only"), + any( + all( + target_arch = "aarch64", + any(target_os = "macos", target_feature = "sha2") + ), + all( + target_arch = "x86_64", + target_feature = "sha", + target_feature = "ssse3", + target_feature = "sse4.1" + ) + ) +)); + +struct Sha1 { + state: [u32; 5], + buffer: [u8; BLOCK_LEN], + buffer_len: usize, + message_len: u64, + compress: CompressFn, +} + +impl Sha1 { + fn new() -> Self { + Self::new_with(selected_compress()) + } + + const fn new_with(compress: CompressFn) -> Self { + Self { + state: INITIAL_STATE, + buffer: [0; BLOCK_LEN], + buffer_len: 0, + message_len: 0, + compress, + } + } + + fn update(&mut self, mut input: &[u8]) { + let input_len = u64::try_from(input.len()).expect("SHA-1 input length exceeds u64"); + let message_len = self + .message_len + .checked_add(input_len) + .expect("SHA-1 message byte length overflow"); + assert!( + message_len <= MAX_MESSAGE_BYTES, + "SHA-1 message length exceeds the 64-bit bit-length field" + ); + self.message_len = message_len; + + if self.buffer_len != 0 { + let available = BLOCK_LEN.strict_sub(self.buffer_len); + let take = core::cmp::min(available, input.len()); + let end = self.buffer_len.strict_add(take); + self.buffer[self.buffer_len..end].copy_from_slice(&input[..take]); + self.buffer_len = end; + input = &input[take..]; + + if self.buffer_len == BLOCK_LEN { + (self.compress)(&mut self.state, &self.buffer); + self.buffer_len = 0; + } + } + + while input.len() >= BLOCK_LEN { + let (block, rest) = input.split_at(BLOCK_LEN); + let block = <&[u8; BLOCK_LEN]>::try_from(block).expect("split SHA-1 block has exact length"); + (self.compress)(&mut self.state, block); + input = rest; + } + + if !input.is_empty() { + self.buffer[..input.len()].copy_from_slice(input); + self.buffer_len = input.len(); + } + } + + fn finalize(mut self) -> [u8; 20] { + let bit_len = self + .message_len + .checked_mul(8) + .expect("SHA-1 message bit length overflow"); + + self.buffer[self.buffer_len] = 0x80; + self.buffer_len = self.buffer_len.strict_add(1); + + if self.buffer_len > LENGTH_OFFSET { + self.buffer[self.buffer_len..].fill(0); + (self.compress)(&mut self.state, &self.buffer); + self.buffer_len = 0; + } + + self.buffer[self.buffer_len..LENGTH_OFFSET].fill(0); + self.buffer[LENGTH_OFFSET..].copy_from_slice(&bit_len.to_be_bytes()); + (self.compress)(&mut self.state, &self.buffer); + + state_to_digest(&self.state) + } +} + +#[inline] +pub(super) fn digest_websocket_key(sec_websocket_key: &[u8]) -> [u8; 20] { + if let Ok(standard_key) = <&[u8; STANDARD_WEBSOCKET_KEY_LEN]>::try_from(sec_websocket_key) { + digest_standard_websocket_key(standard_key) + } else { + digest_parts(sec_websocket_key, super::WEBSOCKET_GUID) + } +} + +#[inline] +fn digest_standard_websocket_key(sec_websocket_key: &[u8; STANDARD_WEBSOCKET_KEY_LEN]) -> [u8; 20] { + if COMPILE_TIME_HW { + return digest_standard_websocket_key_with(sec_websocket_key, compress_compile_time); + } + + digest_standard_websocket_key_with(sec_websocket_key, selected_compress()) +} + +#[inline(always)] +fn digest_standard_websocket_key_with( + sec_websocket_key: &[u8; STANDARD_WEBSOCKET_KEY_LEN], + mut compress: impl FnMut(&mut [u32; 5], &[u8; BLOCK_LEN]), +) -> [u8; 20] { + let mut first_block = [0u8; BLOCK_LEN]; + first_block[..STANDARD_WEBSOCKET_KEY_LEN].copy_from_slice(sec_websocket_key); + first_block[STANDARD_WEBSOCKET_KEY_LEN..STANDARD_WEBSOCKET_MESSAGE_LEN].copy_from_slice(super::WEBSOCKET_GUID); + first_block[STANDARD_WEBSOCKET_MESSAGE_LEN] = 0x80; + + let mut final_block = [0u8; BLOCK_LEN]; + let bit_len = u64::try_from(STANDARD_WEBSOCKET_MESSAGE_LEN) + .expect("fixed WebSocket message length fits u64") + .strict_mul(8); + final_block[LENGTH_OFFSET..].copy_from_slice(&bit_len.to_be_bytes()); + + let mut state = INITIAL_STATE; + compress(&mut state, &first_block); + compress(&mut state, &final_block); + state_to_digest(&state) +} + +#[inline(always)] +fn compress_compile_time(state: &mut [u32; 5], block: &[u8; BLOCK_LEN]) { + #[cfg(all( + not(miri), + not(feature = "portable-only"), + target_arch = "aarch64", + any(target_os = "macos", target_feature = "sha2") + ))] + { + // SAFETY: This function is reachable only when the Apple AArch64 target + // contract or a static `sha2` target feature guarantees SHA instructions. + unsafe { aarch64::compress(state, block) } + } + + #[cfg(all( + not(miri), + not(feature = "portable-only"), + target_arch = "x86_64", + target_feature = "sha", + target_feature = "ssse3", + target_feature = "sse4.1" + ))] + { + // SAFETY: The compile-time cfg above establishes every target feature + // required by the x86_64 SHA-NI kernel. + unsafe { x86_64::compress(state, block) } + } + + #[cfg(not(all( + not(miri), + not(feature = "portable-only"), + any( + all(target_arch = "aarch64", any(target_os = "macos", target_feature = "sha2")), + all( + target_arch = "x86_64", + target_feature = "sha", + target_feature = "ssse3", + target_feature = "sse4.1" + ) + ) + )))] + { + compress_portable(state, block); + } +} + +fn state_to_digest(state: &[u32; 5]) -> [u8; 20] { + let mut digest = [0u8; 20]; + for (word, output) in state.iter().zip(digest.as_chunks_mut::<4>().0) { + *output = word.to_be_bytes(); + } + digest +} + +pub(super) fn digest_parts(first: &[u8], second: &[u8]) -> [u8; 20] { + let mut sha1 = Sha1::new(); + sha1.update(first); + sha1.update(second); + sha1.finalize() +} + +#[cfg(test)] +fn digest_parts_with(first: &[u8], second: &[u8], compress: CompressFn) -> [u8; 20] { + let mut sha1 = Sha1::new_with(compress); + sha1.update(first); + sha1.update(second); + sha1.finalize() +} + +#[cfg(miri)] +fn selected_compress() -> CompressFn { + ACTIVE_COMPRESS.get_or_init(|| compress_portable) +} + +#[cfg(all(not(miri), target_arch = "aarch64"))] +fn selected_compress() -> CompressFn { + ACTIVE_COMPRESS.get_or_init(|| { + use crate::platform::caps::aarch64; + + if crate::platform::caps().has(aarch64::SHA2) { + compress_aarch64_sha2 + } else { + compress_portable + } + }) +} + +#[cfg(all(not(miri), target_arch = "aarch64"))] +fn compress_aarch64_sha2(state: &mut [u32; 5], block: &[u8; BLOCK_LEN]) { + // SAFETY: This wrapper is selected only when AArch64 SHA2 is guaranteed by + // the macOS target contract or reported by `platform::caps()`. + unsafe { aarch64::compress(state, block) } +} + +#[cfg(all(not(miri), target_arch = "x86_64"))] +fn selected_compress() -> CompressFn { + ACTIVE_COMPRESS.get_or_init(|| { + use crate::platform::caps::x86; + + let required = x86::SHA | x86::SSSE3 | x86::SSE41; + if crate::platform::caps().has(required) { + compress_x86_sha + } else { + compress_portable + } + }) +} + +#[cfg(all(not(miri), target_arch = "x86_64"))] +fn compress_x86_sha(state: &mut [u32; 5], block: &[u8; BLOCK_LEN]) { + // SAFETY: This wrapper is selected only after compile-time or runtime + // validation of SHA, SSSE3, and SSE4.1. x86_64 supplies SSE2. + unsafe { x86_64::compress(state, block) } +} + +#[cfg(all(not(miri), not(any(target_arch = "aarch64", target_arch = "x86_64"))))] +fn selected_compress() -> CompressFn { + ACTIVE_COMPRESS.get_or_init(|| compress_portable) +} + +// The four-round portable schedule follows the optimizer-legible structure in +// RustCrypto `sha1` 0.11.0 (MIT OR Apache-2.0), adapted to rscrypto's checked +// arithmetic and single-block private capability boundary. + +const ROUND_CONSTANTS: [u32; 4] = [0x5a82_7999, 0x6ed9_eba1, 0x8f1b_bcdc, 0xca62_c1d6]; + +#[inline(always)] +fn add4(left: [u32; 4], right: [u32; 4]) -> [u32; 4] { + [ + left[0].wrapping_add(right[0]), + left[1].wrapping_add(right[1]), + left[2].wrapping_add(right[2]), + left[3].wrapping_add(right[3]), + ] +} + +#[inline(always)] +fn xor4(left: [u32; 4], right: [u32; 4]) -> [u32; 4] { + [ + left[0] ^ right[0], + left[1] ^ right[1], + left[2] ^ right[2], + left[3] ^ right[3], + ] +} + +#[inline(always)] +fn first_add(e: u32, words: [u32; 4]) -> [u32; 4] { + let [first, second, third, fourth] = words; + [e.wrapping_add(first), second, third, fourth] +} + +#[inline(always)] +fn schedule_first(left: [u32; 4], right: [u32; 4]) -> [u32; 4] { + let [_, _, left_two, left_three] = left; + let [right_zero, right_one, _, _] = right; + [ + left[0] ^ left_two, + left[1] ^ left_three, + left[2] ^ right_zero, + left[3] ^ right_one, + ] +} + +#[inline(always)] +fn schedule_second(left: [u32; 4], right: [u32; 4]) -> [u32; 4] { + let [left_zero, left_one, left_two, left_three] = left; + let [_, right_one, right_two, right_three] = right; + + let word_16 = (left_zero ^ right_one).rotate_left(1); + let word_17 = (left_one ^ right_two).rotate_left(1); + let word_18 = (left_two ^ right_three).rotate_left(1); + let word_19 = (left_three ^ word_16).rotate_left(1); + [word_16, word_17, word_18, word_19] +} + +#[inline(always)] +fn first_half(state: [u32; 4], message: [u32; 4]) -> [u32; 4] { + first_add(state[0].rotate_left(30), message) +} + +#[inline(always)] +fn rounds_choose(state: [u32; 4], message: [u32; 4]) -> [u32; 4] { + let [mut a, mut b, mut c, mut d] = state; + let [first, second, third, fourth] = message; + + let mut e = a.rotate_left(5).wrapping_add(d ^ (b & (c ^ d))).wrapping_add(first); + b = b.rotate_left(30); + d = e + .rotate_left(5) + .wrapping_add(c ^ (a & (b ^ c))) + .wrapping_add(second) + .wrapping_add(d); + a = a.rotate_left(30); + c = d + .rotate_left(5) + .wrapping_add(b ^ (e & (a ^ b))) + .wrapping_add(third) + .wrapping_add(c); + e = e.rotate_left(30); + b = c + .rotate_left(5) + .wrapping_add(a ^ (d & (e ^ a))) + .wrapping_add(fourth) + .wrapping_add(b); + d = d.rotate_left(30); + + [b, c, d, e] +} + +#[inline(always)] +fn rounds_parity(state: [u32; 4], message: [u32; 4]) -> [u32; 4] { + let [mut a, mut b, mut c, mut d] = state; + let [first, second, third, fourth] = message; + + let mut e = a.rotate_left(5).wrapping_add(b ^ c ^ d).wrapping_add(first); + b = b.rotate_left(30); + d = e + .rotate_left(5) + .wrapping_add(a ^ b ^ c) + .wrapping_add(second) + .wrapping_add(d); + a = a.rotate_left(30); + c = d + .rotate_left(5) + .wrapping_add(e ^ a ^ b) + .wrapping_add(third) + .wrapping_add(c); + e = e.rotate_left(30); + b = c + .rotate_left(5) + .wrapping_add(d ^ e ^ a) + .wrapping_add(fourth) + .wrapping_add(b); + d = d.rotate_left(30); + + [b, c, d, e] +} + +#[inline(always)] +fn rounds_majority(state: [u32; 4], message: [u32; 4]) -> [u32; 4] { + let [mut a, mut b, mut c, mut d] = state; + let [first, second, third, fourth] = message; + + let mut e = a + .rotate_left(5) + .wrapping_add((b & c) ^ (b & d) ^ (c & d)) + .wrapping_add(first); + b = b.rotate_left(30); + d = e + .rotate_left(5) + .wrapping_add((a & b) ^ (a & c) ^ (b & c)) + .wrapping_add(second) + .wrapping_add(d); + a = a.rotate_left(30); + c = d + .rotate_left(5) + .wrapping_add((e & a) ^ (e & b) ^ (a & b)) + .wrapping_add(third) + .wrapping_add(c); + e = e.rotate_left(30); + b = c + .rotate_left(5) + .wrapping_add((d & e) ^ (d & a) ^ (e & a)) + .wrapping_add(fourth) + .wrapping_add(b); + d = d.rotate_left(30); + + [b, c, d, e] +} + +#[inline(always)] +fn digest_rounds(state: [u32; 4], work: [u32; 4], phase: usize) -> [u32; 4] { + let constant = [ROUND_CONSTANTS[phase]; 4]; + let work = add4(work, constant); + match phase { + 0 => rounds_choose(state, work), + 2 => rounds_majority(state, work), + // The constant-table lookup above rejects phases outside 0..4. Both + // remaining valid phases use SHA-1's parity function. + _ => rounds_parity(state, work), + } +} + +macro_rules! rounds4 { + ($left:ident, $right:ident, $words:expr, $phase:expr) => { + digest_rounds($left, first_half($right, $words), $phase) + }; +} + +macro_rules! expand_schedule { + ($first:expr, $second:expr, $third:expr, $fourth:expr) => { + schedule_second(xor4(schedule_first($first, $second), $third), $fourth) + }; +} + +macro_rules! schedule_rounds4 { + ($left:ident, $right:ident, $first:expr, $second:expr, $third:expr, $fourth:expr, $next:expr, $phase:expr) => { + $next = expand_schedule!($first, $second, $third, $fourth); + $right = rounds4!($left, $right, $next, $phase); + }; +} + +#[inline(always)] +fn compress_portable(state: &mut [u32; 5], block: &[u8; BLOCK_LEN]) { + let mut words = [[0u32; 4]; 4]; + for (word_group, bytes) in words.iter_mut().zip(block.as_chunks::<16>().0) { + for (word, chunk) in word_group.iter_mut().zip(bytes.as_chunks::<4>().0) { + *word = u32::from_be_bytes(*chunk); + } + } + + let [mut words_zero, mut words_one, mut words_two, mut words_three] = words; + let mut words_four; + let mut state_zero = [state[0], state[1], state[2], state[3]]; + let mut state_one = first_add(state[4], words_zero); + + state_one = digest_rounds(state_zero, state_one, 0); + state_zero = rounds4!(state_one, state_zero, words_one, 0); + state_one = rounds4!(state_zero, state_one, words_two, 0); + state_zero = rounds4!(state_one, state_zero, words_three, 0); + schedule_rounds4!( + state_zero, + state_one, + words_zero, + words_one, + words_two, + words_three, + words_four, + 0 + ); + + schedule_rounds4!( + state_one, + state_zero, + words_one, + words_two, + words_three, + words_four, + words_zero, + 1 + ); + schedule_rounds4!( + state_zero, + state_one, + words_two, + words_three, + words_four, + words_zero, + words_one, + 1 + ); + schedule_rounds4!( + state_one, + state_zero, + words_three, + words_four, + words_zero, + words_one, + words_two, + 1 + ); + schedule_rounds4!( + state_zero, + state_one, + words_four, + words_zero, + words_one, + words_two, + words_three, + 1 + ); + schedule_rounds4!( + state_one, + state_zero, + words_zero, + words_one, + words_two, + words_three, + words_four, + 1 + ); + + schedule_rounds4!( + state_zero, + state_one, + words_one, + words_two, + words_three, + words_four, + words_zero, + 2 + ); + schedule_rounds4!( + state_one, + state_zero, + words_two, + words_three, + words_four, + words_zero, + words_one, + 2 + ); + schedule_rounds4!( + state_zero, + state_one, + words_three, + words_four, + words_zero, + words_one, + words_two, + 2 + ); + schedule_rounds4!( + state_one, + state_zero, + words_four, + words_zero, + words_one, + words_two, + words_three, + 2 + ); + schedule_rounds4!( + state_zero, + state_one, + words_zero, + words_one, + words_two, + words_three, + words_four, + 2 + ); + + schedule_rounds4!( + state_one, + state_zero, + words_one, + words_two, + words_three, + words_four, + words_zero, + 3 + ); + schedule_rounds4!( + state_zero, + state_one, + words_two, + words_three, + words_four, + words_zero, + words_one, + 3 + ); + schedule_rounds4!( + state_one, + state_zero, + words_three, + words_four, + words_zero, + words_one, + words_two, + 3 + ); + schedule_rounds4!( + state_zero, + state_one, + words_four, + words_zero, + words_one, + words_two, + words_three, + 3 + ); + schedule_rounds4!( + state_one, + state_zero, + words_zero, + words_one, + words_two, + words_three, + words_four, + 3 + ); + + let [a, b, c, d] = state_zero; + let e = state_one[0].rotate_left(30); + state[0] = state[0].wrapping_add(a); + state[1] = state[1].wrapping_add(b); + state[2] = state[2].wrapping_add(c); + state[3] = state[3].wrapping_add(d); + state[4] = state[4].wrapping_add(e); +} + +#[cfg(test)] +mod tests { + use super::{compress_portable, digest_parts, digest_parts_with, digest_websocket_key}; + + #[cfg(all(not(miri), any(target_arch = "aarch64", target_arch = "x86_64")))] + use super::CompressFn; + + fn assert_hex(message: &[u8], expected: [u8; 20]) { + assert_eq!(digest_parts_with(message, b"", compress_portable), expected); + } + + #[cfg(all(not(miri), any(target_arch = "aarch64", target_arch = "x86_64")))] + fn assert_backend_matches_portable(compress: CompressFn) { + let mut input = [0u8; 257]; + for (index, byte) in input.iter_mut().enumerate() { + *byte = index.to_le_bytes()[0].wrapping_mul(37).wrapping_add(11); + } + + for len in [0usize, 1, 19, 20, 27, 28, 55, 56, 63, 64, 65, 127, 128, 129, 256, 257] { + let message = &input[..len]; + assert_eq!( + digest_parts_with(message, b"", compress), + digest_parts_with(message, b"", compress_portable), + "SHA-1 backend mismatch for message length {len}" + ); + } + } + + #[test] + fn sha1_known_answer_tests() { + assert_hex( + b"", + [ + 0xda, 0x39, 0xa3, 0xee, 0x5e, 0x6b, 0x4b, 0x0d, 0x32, 0x55, 0xbf, 0xef, 0x95, 0x60, 0x18, 0x90, 0xaf, 0xd8, + 0x07, 0x09, + ], + ); + assert_hex( + b"abc", + [ + 0xa9, 0x99, 0x3e, 0x36, 0x47, 0x06, 0x81, 0x6a, 0xba, 0x3e, 0x25, 0x71, 0x78, 0x50, 0xc2, 0x6c, 0x9c, 0xd0, + 0xd8, 0x9d, + ], + ); + assert_hex( + b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", + [ + 0x84, 0x98, 0x3e, 0x44, 0x1c, 0x3b, 0xd2, 0x6e, 0xba, 0xae, 0x4a, 0xa1, 0xf9, 0x51, 0x29, 0xe5, 0xe5, 0x46, + 0x70, 0xf1, + ], + ); + } + + #[test] + fn split_updates_match_contiguous_known_answer() { + assert_eq!( + digest_parts(b"abcdbcdecdefdefgefghfghi", b"ghijhijkijkljklmklmnlmnomnopnopq"), + digest_parts(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", b"") + ); + } + + #[test] + fn standard_websocket_key_path_matches_general_path() { + let key = b"dGhlIHNhbXBsZSBub25jZQ=="; + assert_eq!( + digest_websocket_key(key), + digest_parts(key, super::super::WEBSOCKET_GUID) + ); + } + + #[cfg(all(not(miri), target_arch = "aarch64"))] + #[test] + fn aarch64_sha2_matches_portable() { + use crate::platform::caps::aarch64; + + if crate::platform::caps().has(aarch64::SHA2) { + assert_backend_matches_portable(super::compress_aarch64_sha2); + } + } + + #[cfg(all(not(miri), target_arch = "x86_64"))] + #[test] + fn x86_sha_matches_portable_when_available() { + use crate::platform::caps::x86; + + if crate::platform::caps().has(x86::SHA | x86::SSSE3 | x86::SSE41) { + assert_backend_matches_portable(super::compress_x86_sha); + } + } +} diff --git a/src/hashes/legacy/sha1/aarch64.rs b/src/hashes/legacy/sha1/aarch64.rs new file mode 100644 index 00000000..6fa44634 --- /dev/null +++ b/src/hashes/legacy/sha1/aarch64.rs @@ -0,0 +1,154 @@ +//! AArch64 SHA-1 Crypto Extension compression. +//! +//! The instruction schedule follows RustCrypto `sha1` 0.11.0's AArch64 +//! backend (MIT OR Apache-2.0), narrowed to one exact 64-byte block. + +use core::arch::aarch64::*; + +const ROUND_CONSTANTS: [u32; 4] = [0x5a82_7999, 0x6ed9_eba1, 0x8f1b_bcdc, 0xca62_c1d6]; + +/// Compresses one SHA-1 block with AArch64 SHA instructions. +/// +/// # Safety +/// +/// The caller must establish the AArch64 `sha2` target feature before entry. +#[target_feature(enable = "sha2")] +pub(super) unsafe fn compress(state: &mut [u32; 5], block: &[u8; 64]) { + // SAFETY: The target-feature contract above makes every SHA/NEON intrinsic + // legal. `state` contains five initialized u32 words: the vector load/store + // touches exactly the first four. The four unaligned byte loads cover + // block[0..64] in 16-byte increments, so all pointer additions remain inside + // the exact 64-byte array. No pointer or reference escapes this call. + unsafe { + let mut abcd = vld1q_u32(state.as_ptr()); + let mut e_zero = state[4]; + let [constant_zero, constant_one, constant_two, constant_three] = + ROUND_CONSTANTS.map(|constant| vdupq_n_u32(constant)); + let saved_abcd = abcd; + let saved_e = e_zero; + let pointer = block.as_ptr(); + + let mut message_zero = vreinterpretq_u32_u8(vrev32q_u8(vld1q_u8(pointer))); + let mut message_one = vreinterpretq_u32_u8(vrev32q_u8(vld1q_u8(pointer.add(16)))); + let mut message_two = vreinterpretq_u32_u8(vrev32q_u8(vld1q_u8(pointer.add(32)))); + let mut message_three = vreinterpretq_u32_u8(vrev32q_u8(vld1q_u8(pointer.add(48)))); + + let mut work_zero = vaddq_u32(message_zero, constant_zero); + let mut work_one = vaddq_u32(message_one, constant_zero); + + let mut e_one = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1cq_u32(abcd, e_zero, work_zero); + work_zero = vaddq_u32(message_two, constant_zero); + message_zero = vsha1su0q_u32(message_zero, message_one, message_two); + + e_zero = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1cq_u32(abcd, e_one, work_one); + work_one = vaddq_u32(message_three, constant_zero); + message_zero = vsha1su1q_u32(message_zero, message_three); + message_one = vsha1su0q_u32(message_one, message_two, message_three); + + e_one = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1cq_u32(abcd, e_zero, work_zero); + work_zero = vaddq_u32(message_zero, constant_zero); + message_one = vsha1su1q_u32(message_one, message_zero); + message_two = vsha1su0q_u32(message_two, message_three, message_zero); + + e_zero = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1cq_u32(abcd, e_one, work_one); + work_one = vaddq_u32(message_one, constant_one); + message_two = vsha1su1q_u32(message_two, message_one); + message_three = vsha1su0q_u32(message_three, message_zero, message_one); + + e_one = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1cq_u32(abcd, e_zero, work_zero); + work_zero = vaddq_u32(message_two, constant_one); + message_three = vsha1su1q_u32(message_three, message_two); + message_zero = vsha1su0q_u32(message_zero, message_one, message_two); + + e_zero = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1pq_u32(abcd, e_one, work_one); + work_one = vaddq_u32(message_three, constant_one); + message_zero = vsha1su1q_u32(message_zero, message_three); + message_one = vsha1su0q_u32(message_one, message_two, message_three); + + e_one = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1pq_u32(abcd, e_zero, work_zero); + work_zero = vaddq_u32(message_zero, constant_one); + message_one = vsha1su1q_u32(message_one, message_zero); + message_two = vsha1su0q_u32(message_two, message_three, message_zero); + + e_zero = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1pq_u32(abcd, e_one, work_one); + work_one = vaddq_u32(message_one, constant_one); + message_two = vsha1su1q_u32(message_two, message_one); + message_three = vsha1su0q_u32(message_three, message_zero, message_one); + + e_one = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1pq_u32(abcd, e_zero, work_zero); + work_zero = vaddq_u32(message_two, constant_two); + message_three = vsha1su1q_u32(message_three, message_two); + message_zero = vsha1su0q_u32(message_zero, message_one, message_two); + + e_zero = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1pq_u32(abcd, e_one, work_one); + work_one = vaddq_u32(message_three, constant_two); + message_zero = vsha1su1q_u32(message_zero, message_three); + message_one = vsha1su0q_u32(message_one, message_two, message_three); + + e_one = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1mq_u32(abcd, e_zero, work_zero); + work_zero = vaddq_u32(message_zero, constant_two); + message_one = vsha1su1q_u32(message_one, message_zero); + message_two = vsha1su0q_u32(message_two, message_three, message_zero); + + e_zero = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1mq_u32(abcd, e_one, work_one); + work_one = vaddq_u32(message_one, constant_two); + message_two = vsha1su1q_u32(message_two, message_one); + message_three = vsha1su0q_u32(message_three, message_zero, message_one); + + e_one = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1mq_u32(abcd, e_zero, work_zero); + work_zero = vaddq_u32(message_two, constant_two); + message_three = vsha1su1q_u32(message_three, message_two); + message_zero = vsha1su0q_u32(message_zero, message_one, message_two); + + e_zero = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1mq_u32(abcd, e_one, work_one); + work_one = vaddq_u32(message_three, constant_three); + message_zero = vsha1su1q_u32(message_zero, message_three); + message_one = vsha1su0q_u32(message_one, message_two, message_three); + + e_one = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1mq_u32(abcd, e_zero, work_zero); + work_zero = vaddq_u32(message_zero, constant_three); + message_one = vsha1su1q_u32(message_one, message_zero); + message_two = vsha1su0q_u32(message_two, message_three, message_zero); + + e_zero = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1pq_u32(abcd, e_one, work_one); + work_one = vaddq_u32(message_one, constant_three); + message_two = vsha1su1q_u32(message_two, message_one); + message_three = vsha1su0q_u32(message_three, message_zero, message_one); + + e_one = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1pq_u32(abcd, e_zero, work_zero); + work_zero = vaddq_u32(message_two, constant_three); + message_three = vsha1su1q_u32(message_three, message_two); + + e_zero = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1pq_u32(abcd, e_one, work_one); + work_one = vaddq_u32(message_three, constant_three); + + e_one = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1pq_u32(abcd, e_zero, work_zero); + + e_zero = vsha1h_u32(vgetq_lane_u32(abcd, 0)); + abcd = vsha1pq_u32(abcd, e_one, work_one); + + abcd = vaddq_u32(saved_abcd, abcd); + e_zero = e_zero.wrapping_add(saved_e); + vst1q_u32(state.as_mut_ptr(), abcd); + state[4] = e_zero; + } +} diff --git a/src/hashes/legacy/sha1/x86_64.rs b/src/hashes/legacy/sha1/x86_64.rs new file mode 100644 index 00000000..29ad99df --- /dev/null +++ b/src/hashes/legacy/sha1/x86_64.rs @@ -0,0 +1,231 @@ +//! x86_64 SHA-NI compression for SHA-1. +//! +//! The instruction schedule follows RustCrypto `sha1` 0.11.0's x86 backend +//! (MIT OR Apache-2.0), narrowed to one exact 64-byte block. + +use core::arch::x86_64::*; + +macro_rules! rounds4 { + ($left:ident, $right:ident, $words:expr, $phase:expr) => { + _mm_sha1rnds4_epu32($left, _mm_sha1nexte_epu32($right, $words), $phase) + }; +} + +macro_rules! expand_schedule { + ($first:expr, $second:expr, $third:expr, $fourth:expr) => { + _mm_sha1msg2_epu32(_mm_xor_si128(_mm_sha1msg1_epu32($first, $second), $third), $fourth) + }; +} + +macro_rules! schedule_rounds4 { + ($left:ident, $right:ident, $first:expr, $second:expr, $third:expr, $fourth:expr, $next:expr, $phase:expr) => { + $next = expand_schedule!($first, $second, $third, $fourth); + $right = rounds4!($left, $right, $next, $phase); + }; +} + +/// Compresses one SHA-1 block with x86 SHA-NI. +/// +/// # Safety +/// +/// The caller must establish `sha`, `ssse3`, and `sse4.1` before entry. SSE2 +/// is part of the x86_64 baseline. +#[target_feature(enable = "sha,sse2,ssse3,sse4.1")] +pub(super) unsafe fn compress(state: &mut [u32; 5], block: &[u8; 64]) { + // SAFETY: The target-feature contract makes every intrinsic legal. The + // unaligned state load/store touches exactly state[0..4]. Transmuting the + // exact 64-byte block to four integer vectors preserves every bit, all vector + // bit patterns are valid, and the by-value conversion requires no source + // alignment. No pointer or reference escapes. + unsafe { + let byte_swap_mask = _mm_set_epi64x(0x0001_0203_0405_0607, 0x0809_0a0b_0c0d_0e0f); + let mut state_abcd = _mm_loadu_si128(state.as_ptr().cast()); + state_abcd = _mm_shuffle_epi32(state_abcd, 0b0001_1011); + let state_e_bits = i32::from_ne_bytes(state[4].to_ne_bytes()); + let mut state_e = _mm_set_epi32(state_e_bits, 0, 0, 0); + let [mut words_zero, mut words_one, mut words_two, mut words_three] = + core::mem::transmute::<[u8; 64], [__m128i; 4]>(*block); + words_zero = _mm_shuffle_epi8(words_zero, byte_swap_mask); + words_one = _mm_shuffle_epi8(words_one, byte_swap_mask); + words_two = _mm_shuffle_epi8(words_two, byte_swap_mask); + words_three = _mm_shuffle_epi8(words_three, byte_swap_mask); + let mut words_four; + + let mut state_zero = state_abcd; + let mut state_one = _mm_add_epi32(state_e, words_zero); + + state_one = _mm_sha1rnds4_epu32(state_zero, state_one, 0); + state_zero = rounds4!(state_one, state_zero, words_one, 0); + state_one = rounds4!(state_zero, state_one, words_two, 0); + state_zero = rounds4!(state_one, state_zero, words_three, 0); + schedule_rounds4!( + state_zero, + state_one, + words_zero, + words_one, + words_two, + words_three, + words_four, + 0 + ); + + schedule_rounds4!( + state_one, + state_zero, + words_one, + words_two, + words_three, + words_four, + words_zero, + 1 + ); + schedule_rounds4!( + state_zero, + state_one, + words_two, + words_three, + words_four, + words_zero, + words_one, + 1 + ); + schedule_rounds4!( + state_one, + state_zero, + words_three, + words_four, + words_zero, + words_one, + words_two, + 1 + ); + schedule_rounds4!( + state_zero, + state_one, + words_four, + words_zero, + words_one, + words_two, + words_three, + 1 + ); + schedule_rounds4!( + state_one, + state_zero, + words_zero, + words_one, + words_two, + words_three, + words_four, + 1 + ); + + schedule_rounds4!( + state_zero, + state_one, + words_one, + words_two, + words_three, + words_four, + words_zero, + 2 + ); + schedule_rounds4!( + state_one, + state_zero, + words_two, + words_three, + words_four, + words_zero, + words_one, + 2 + ); + schedule_rounds4!( + state_zero, + state_one, + words_three, + words_four, + words_zero, + words_one, + words_two, + 2 + ); + schedule_rounds4!( + state_one, + state_zero, + words_four, + words_zero, + words_one, + words_two, + words_three, + 2 + ); + schedule_rounds4!( + state_zero, + state_one, + words_zero, + words_one, + words_two, + words_three, + words_four, + 2 + ); + + schedule_rounds4!( + state_one, + state_zero, + words_one, + words_two, + words_three, + words_four, + words_zero, + 3 + ); + schedule_rounds4!( + state_zero, + state_one, + words_two, + words_three, + words_four, + words_zero, + words_one, + 3 + ); + schedule_rounds4!( + state_one, + state_zero, + words_three, + words_four, + words_zero, + words_one, + words_two, + 3 + ); + schedule_rounds4!( + state_zero, + state_one, + words_four, + words_zero, + words_one, + words_two, + words_three, + 3 + ); + schedule_rounds4!( + state_one, + state_zero, + words_zero, + words_one, + words_two, + words_three, + words_four, + 3 + ); + + state_abcd = _mm_add_epi32(state_abcd, state_zero); + state_e = _mm_sha1nexte_epu32(state_one, state_e); + state_abcd = _mm_shuffle_epi32(state_abcd, 0b0001_1011); + _mm_storeu_si128(state.as_mut_ptr().cast(), state_abcd); + state[4] = u32::from_ne_bytes(_mm_extract_epi32(state_e, 3).to_ne_bytes()); + } +} diff --git a/src/hashes/mod.rs b/src/hashes/mod.rs index dcc24822..7a73dd3b 100644 --- a/src/hashes/mod.rs +++ b/src/hashes/mod.rs @@ -57,6 +57,10 @@ //! rscrypto = { version = "0.8.1", default-features = false, features = ["hashes"] } //! ``` //! +//! The compatibility-only `websocket-sha1` feature is deliberately absent +//! from `hashes`, `crypto-hashes`, and `full`. It exposes only the complete +//! RFC 6455 accept-digest operation under [`legacy`]. +//! //! # API Conventions //! //! - Fixed-output digests use `Type::digest(data)` for one-shot and `new` / `update` / `finalize` / @@ -69,6 +73,7 @@ //! //! - `crypto` - Cryptographic hash functions (safe by default). //! - `fast` - Non-cryptographic hashes (**NOT CRYPTO**). +//! - `legacy` - Capability-confined compatibility operations; never general-purpose hashes. //! - `introspect` (requires `diag` feature) - Advanced kernel selection reporting. //! //! # Advanced @@ -105,6 +110,8 @@ pub mod introspect; ) ))] pub mod io; +#[cfg(feature = "websocket-sha1")] +pub mod legacy; #[cfg(feature = "sha2")] mod util; diff --git a/src/lib.rs b/src/lib.rs index 6200286c..6ad3bc3f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -351,7 +351,8 @@ mod secret; feature = "blake3", feature = "ascon-hash", feature = "xxh3", - feature = "rapidhash" + feature = "rapidhash", + feature = "websocket-sha1" ))] pub mod hashes; diff --git a/tests/root_surface.rs b/tests/root_surface.rs index 49b20392..fdeb597d 100644 --- a/tests/root_surface.rs +++ b/tests/root_surface.rs @@ -37,6 +37,8 @@ use rscrypto::hashes::fast::Xxh3_64; use rscrypto::hashes::introspect::{ DispatchInfo as HashDispatchInfo, KernelIntrospect as HashKernelIntrospect, kernel_for as hash_kernel_for, }; +#[cfg(feature = "websocket-sha1")] +use rscrypto::hashes::legacy::WebSocketAcceptDigest; #[cfg(all(feature = "hashes", feature = "std"))] use rscrypto::hashes::{DigestReader, DigestWriter}; #[cfg(feature = "hashes")] @@ -114,6 +116,13 @@ fn root_surface_core_exports_compile() { assert_eq!(secret, [0; 8]); } +#[test] +#[cfg(feature = "websocket-sha1")] +fn websocket_accept_digest_stays_on_legacy_module_surface() { + let digest = WebSocketAcceptDigest::compute(b"dGhlIHNhbXBsZSBub25jZQ=="); + assert_eq!(digest.as_ref().len(), 20); +} + #[test] #[cfg(feature = "aead")] fn root_surface_aead_exports_compile() { diff --git a/tests/websocket_sha1.rs b/tests/websocket_sha1.rs new file mode 100644 index 00000000..d0cbf849 --- /dev/null +++ b/tests/websocket_sha1.rs @@ -0,0 +1,63 @@ +#![cfg(feature = "websocket-sha1")] + +use proptest::prelude::*; +use rscrypto::hashes::legacy::WebSocketAcceptDigest; +use sha1::{Digest as _, Sha1}; + +const WEBSOCKET_GUID: &[u8] = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + +fn oracle(sec_websocket_key: &[u8]) -> [u8; 20] { + let mut sha1 = Sha1::new(); + sha1.update(sec_websocket_key); + sha1.update(WEBSOCKET_GUID); + let output = sha1.finalize(); + let mut digest = [0u8; 20]; + digest.copy_from_slice(&output); + digest +} + +#[test] +fn rfc_6455_example_matches_exact_digest() { + let digest = WebSocketAcceptDigest::compute(b"dGhlIHNhbXBsZSBub25jZQ=="); + assert_eq!( + digest.as_ref(), + [ + 0xb3, 0x7a, 0x4f, 0x2c, 0xc0, 0x62, 0x4f, 0x16, 0x90, 0xf6, 0x46, 0x06, 0xcf, 0x38, 0x59, 0x45, 0xb2, 0xbe, 0xc4, + 0xea, + ] + ); +} + +#[test] +fn field_value_is_hashed_byte_for_byte() { + let original = WebSocketAcceptDigest::compute(b"example-key"); + let leading_space = WebSocketAcceptDigest::compute(b" example-key"); + let trailing_space = WebSocketAcceptDigest::compute(b"example-key "); + + assert_ne!(original, leading_space); + assert_ne!(original, trailing_space); + assert_eq!(leading_space.as_ref(), oracle(b" example-key")); + assert_eq!(trailing_space.as_ref(), oracle(b"example-key ")); +} + +#[test] +fn padding_and_block_boundaries_match_independent_oracle() { + for len in [0usize, 1, 19, 20, 27, 28, 55, 56, 63, 64, 127, 128] { + let key = vec![0xa5; len]; + assert_eq!( + WebSocketAcceptDigest::compute(&key).as_ref(), + oracle(&key), + "WebSocket accept digest mismatch for key length {len}" + ); + } +} + +proptest! { + #[test] + fn generated_field_values_match_independent_oracle( + key in proptest::collection::vec(any::(), 0..4096), + ) { + let ours = WebSocketAcceptDigest::compute(&key); + prop_assert_eq!(ours.as_ref(), oracle(&key)); + } +} diff --git a/tests/websocket_sha1_allocations.rs b/tests/websocket_sha1_allocations.rs new file mode 100644 index 00000000..47ebe770 --- /dev/null +++ b/tests/websocket_sha1_allocations.rs @@ -0,0 +1,93 @@ +#![cfg(feature = "websocket-sha1")] + +use core::{ + alloc::{GlobalAlloc, Layout}, + cell::Cell, +}; +use std::alloc::System; + +use rscrypto::hashes::legacy::WebSocketAcceptDigest; + +const MEASUREMENT_DISABLED: usize = usize::MAX; + +struct CountingAllocator; + +std::thread_local! { + static ALLOCATIONS: Cell = const { Cell::new(MEASUREMENT_DISABLED) }; +} + +fn record_allocation() { + discard_measurement_result(ALLOCATIONS.try_with(|allocations| { + let count = allocations.get(); + if count != MEASUREMENT_DISABLED { + allocations.set(count.strict_add(1)); + } + })); +} + +fn discard_measurement_result(_result: Result<(), std::thread::AccessError>) {} + +// SAFETY: Every operation delegates the original pointer and layout unchanged +// to `System`; thread-local accounting does not touch allocated memory. +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record_allocation(); + // SAFETY: `layout` is forwarded unchanged to the system allocator. + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + record_allocation(); + // SAFETY: `layout` is forwarded unchanged to the system allocator. + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + // SAFETY: `ptr` and `layout` originated from the delegated system allocator. + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + record_allocation(); + // SAFETY: All arguments are forwarded unchanged to the system allocator. + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static GLOBAL: CountingAllocator = CountingAllocator; + +struct AllocationMeasurement; + +impl Drop for AllocationMeasurement { + fn drop(&mut self) { + discard_measurement_result(ALLOCATIONS.try_with(|allocations| allocations.set(MEASUREMENT_DISABLED))); + } +} + +fn measure_allocations(f: impl FnOnce()) -> usize { + ALLOCATIONS.with(|allocations| { + assert_eq!( + allocations.replace(0), + MEASUREMENT_DISABLED, + "allocation measurements must not be nested" + ); + }); + + let measurement = AllocationMeasurement; + f(); + let count = ALLOCATIONS.with(|allocations| allocations.replace(MEASUREMENT_DISABLED)); + drop(measurement); + count +} + +#[test] +fn websocket_accept_digest_does_not_allocate() { + let key = b"dGhlIHNhbXBsZSBub25jZQ=="; + let allocations = measure_allocations(|| { + let digest = WebSocketAcceptDigest::compute(key); + assert_eq!(digest.as_ref().len(), 20); + }); + + assert_eq!(allocations, 0, "WebSocket accept digest must not allocate"); +} diff --git a/tools/wasm-runtime-vectors/Cargo.toml b/tools/wasm-runtime-vectors/Cargo.toml index 34c5242d..1d9c1dec 100644 --- a/tools/wasm-runtime-vectors/Cargo.toml +++ b/tools/wasm-runtime-vectors/Cargo.toml @@ -8,4 +8,4 @@ publish = false [workspace] [dependencies] -rscrypto = { path = "../..", default-features = false, features = ["alloc", "hashes", "rsa"] } +rscrypto = { path = "../..", default-features = false, features = ["alloc", "hashes", "rsa", "websocket-sha1"] } diff --git a/tools/wasm-runtime-vectors/src/main.rs b/tools/wasm-runtime-vectors/src/main.rs index 2a6bf876..0ddae4d1 100644 --- a/tools/wasm-runtime-vectors/src/main.rs +++ b/tools/wasm-runtime-vectors/src/main.rs @@ -1,6 +1,7 @@ use rscrypto::{ Blake2b512, Blake3, Digest, RsaPrivateKey, RsaPrivateOpError, RsaPssProfile, RsaPublicKeyPolicy, Sha256, Sha512, }; +use rscrypto::hashes::legacy::WebSocketAcceptDigest; const RSA_PRIVATE_KEY_PEM: &str = include_str!("../fixtures/rsa2048_private_pkcs1.txt"); @@ -170,6 +171,17 @@ fn assert_rsa_caller_random_signing_roundtrips() { assert!(signature.iter().all(|&byte| byte == 0)); } +fn assert_websocket_accept_digest_matches_rfc_6455() { + let digest = WebSocketAcceptDigest::compute(b"dGhlIHNhbXBsZSBub25jZQ=="); + assert_eq!( + digest.as_ref(), + [ + 0xb3, 0x7a, 0x4f, 0x2c, 0xc0, 0x62, 0x4f, 0x16, 0x90, 0xf6, 0x46, 0x06, 0xcf, 0x38, 0x59, 0x45, 0xb2, + 0xbe, 0xc4, 0xea, + ] + ); +} + #[cfg(target_feature = "simd128")] fn assert_simd128_runtime_caps_are_detected() { assert!(rscrypto::platform::caps().has(rscrypto::platform::caps::wasm::SIMD128)); @@ -182,5 +194,6 @@ fn main() { assert_core_hash_vectors_match_known_outputs(); assert_streaming_hashes_match_oneshot_across_block_boundaries(); assert_rsa_caller_random_signing_roundtrips(); + assert_websocket_accept_digest_matches_rfc_6455(); assert_simd128_runtime_caps_are_detected(); }