From 43bc8468e87e8613c1b6b412b9c6f1da05ded5a6 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 15 Jul 2026 20:49:02 +0700 Subject: [PATCH 01/28] Added ci.yml to run unit tests, but also check format, run clippy, and build workspace (#45) --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..4c517a67 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: Rust CI + +on: + push: + branches: + - release/0.1.2alpha + + pull_request: + branches: + - release/0.1.2alpha + + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + +jobs: + test: + name: Format, lint, build, and test + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Check formatting + run: cargo fmt --all -- --check + + - name: Run Clippy + run: cargo clippy --workspace --all-targets --all-features + + - name: Build workspace + run: cargo build --workspace --all-targets --all-features + + - name: Run tests + run: cargo test --all \ No newline at end of file From 27e2574027deaeaad708f1c4334746c435f407d9 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Fri, 17 Jul 2026 03:31:37 +0700 Subject: [PATCH 02/28] Added badges for new workflows and existing style workflow (#45) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 56b6003c..aa7cf0ea 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,10 @@ # The Bouncy Castle Crypto Package For Rust +[![Rust Style](https://github.com/bcgit/bc-rust/actions/workflows/rust-style.yml/badge.svg)](https://github.com/bcgit/bc-rust/actions/workflows/rust-style.yml) +[![Rust Build](https://github.com/bcgit/bc-rust/actions/workflows/rust-build.yml/badge.svg)](https://github.com/bcgit/bc-rust/actions/workflows/rust-build.yml) +[![Rust Tests](https://github.com/bcgit/bc-rust/actions/workflows/rust-test.yml/badge.svg)](https://github.com/bcgit/bc-rust/actions/workflows/rust-test.yml) +[![Rust Docs](https://github.com/bcgit/bc-rust/actions/workflows/rust-docs.yml/badge.svg)](https://github.com/bcgit/bc-rust/actions/workflows/rust-docs.yml) + > [!WARNING] > This package is currently in ALPHA, meaning that it is not complete or production-ready and will be evolving rapidly over the coming months. > We are releasing only a small set of cryptographic algorithms in order to get feedback from the community on the API and build structure. From 2a17ce2f585f4267d191cd574a0d20b2913e07e6 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Fri, 17 Jul 2026 03:33:40 +0700 Subject: [PATCH 03/28] Add initial rust-build.yml (#45) --- .github/workflows/rust-build.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/rust-build.yml diff --git a/.github/workflows/rust-build.yml b/.github/workflows/rust-build.yml new file mode 100644 index 00000000..ff670eb6 --- /dev/null +++ b/.github/workflows/rust-build.yml @@ -0,0 +1,25 @@ +name: Rust Build + +on: + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build workspace + run: cargo build --workspace --all-targets --all-features \ No newline at end of file From 6116291cccfc752ef8acede74ea6e74223d1ec00 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Fri, 17 Jul 2026 03:33:54 +0700 Subject: [PATCH 04/28] Add initial rust-docs.yml (#45) --- .github/workflows/rust-docs.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/rust-docs.yml diff --git a/.github/workflows/rust-docs.yml b/.github/workflows/rust-docs.yml new file mode 100644 index 00000000..838eb852 --- /dev/null +++ b/.github/workflows/rust-docs.yml @@ -0,0 +1,25 @@ +name: Rust Docs + +on: + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + docs: + name: Documentation + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build documentation + run: cargo doc --all \ No newline at end of file From 0bd460f9ed809e6256f9a78ebe31cefeafee9ffb Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Fri, 17 Jul 2026 03:34:11 +0700 Subject: [PATCH 05/28] Add initial rust-test.yml (#45) --- .github/workflows/rust-test.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 .github/workflows/rust-test.yml diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml new file mode 100644 index 00000000..ff670eb6 --- /dev/null +++ b/.github/workflows/rust-test.yml @@ -0,0 +1,25 @@ +name: Rust Build + +on: + pull_request: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build workspace + run: cargo build --workspace --all-targets --all-features \ No newline at end of file From 1c888b90c31c4fc6be16ca9c5c4f9c26675427f1 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Fri, 17 Jul 2026 03:34:47 +0700 Subject: [PATCH 06/28] Added workflow dispatch (#45) --- .github/workflows/ci.yml | 45 -------------------------------- .github/workflows/rust-build.yml | 3 ++- .github/workflows/rust-docs.yml | 3 ++- .github/workflows/rust-test.yml | 13 ++++----- 4 files changed, 11 insertions(+), 53 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 4c517a67..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Rust CI - -on: - push: - branches: - - release/0.1.2alpha - - pull_request: - branches: - - release/0.1.2alpha - - workflow_dispatch: - -permissions: - contents: read - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: "-D warnings" - -jobs: - test: - name: Format, lint, build, and test - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt, clippy - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Run Clippy - run: cargo clippy --workspace --all-targets --all-features - - - name: Build workspace - run: cargo build --workspace --all-targets --all-features - - - name: Run tests - run: cargo test --all \ No newline at end of file diff --git a/.github/workflows/rust-build.yml b/.github/workflows/rust-build.yml index ff670eb6..83e172b7 100644 --- a/.github/workflows/rust-build.yml +++ b/.github/workflows/rust-build.yml @@ -2,6 +2,7 @@ name: Rust Build on: pull_request: + workflow_dispatch: permissions: contents: read @@ -18,7 +19,7 @@ jobs: - name: Check out repository uses: actions/checkout@v4 - - name: Install Rust + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - name: Build workspace diff --git a/.github/workflows/rust-docs.yml b/.github/workflows/rust-docs.yml index 838eb852..f9f14953 100644 --- a/.github/workflows/rust-docs.yml +++ b/.github/workflows/rust-docs.yml @@ -2,6 +2,7 @@ name: Rust Docs on: pull_request: + workflow_dispatch: permissions: contents: read @@ -18,7 +19,7 @@ jobs: - name: Check out repository uses: actions/checkout@v4 - - name: Install Rust + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - name: Build documentation diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index ff670eb6..70d5086f 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -1,7 +1,8 @@ -name: Rust Build +name: Rust Tests on: pull_request: + workflow_dispatch: permissions: contents: read @@ -10,16 +11,16 @@ env: CARGO_TERM_COLOR: always jobs: - build: - name: Build + test: + name: Tests runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 - - name: Install Rust + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - - name: Build workspace - run: cargo build --workspace --all-targets --all-features \ No newline at end of file + - name: Run tests + run: cargo test --all \ No newline at end of file From 6d60f6d2cf004c14b566892c2e7e0715c8a85285 Mon Sep 17 00:00:00 2001 From: Tamish Dahiya Date: Fri, 24 Jul 2026 15:54:59 +0530 Subject: [PATCH 07/28] Update all crates inherit version from version.workspace --- Cargo.toml | 5 +++-- cli/Cargo.toml | 4 ++-- crypto/base64/Cargo.toml | 4 ++-- crypto/core-test-framework/Cargo.toml | 4 ++-- crypto/core/Cargo.toml | 4 ++-- crypto/factory/Cargo.toml | 2 +- crypto/hex/Cargo.toml | 4 ++-- crypto/hkdf/Cargo.toml | 4 ++-- crypto/hmac/Cargo.toml | 2 +- crypto/mldsa-lowmemory/Cargo.toml | 4 ++-- crypto/mldsa/Cargo.toml | 4 ++-- crypto/mlkem-lowmemory/Cargo.toml | 4 ++-- crypto/mlkem/Cargo.toml | 4 ++-- crypto/rng/Cargo.toml | 4 ++-- crypto/sha2/Cargo.toml | 2 +- crypto/sha3/Cargo.toml | 2 +- crypto/utils/Cargo.toml | 2 +- mem_usage_benches/Cargo.toml | 4 ++-- 18 files changed, 32 insertions(+), 31 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6e2ed3f9..d15af249 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = ["cli", "crypto/*", "mem_usage_benches"] [workspace.package] edition = "2024" +version = "0.1.2" [workspace.dependencies] @@ -36,7 +37,7 @@ strip = "debuginfo" # libbouncycastle [package] name = "bouncycastle" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -52,4 +53,4 @@ bouncycastle-mlkem.workspace = true bouncycastle-mlkem-lowmemory.workspace = true bouncycastle-rng.workspace = true bouncycastle-sha2.workspace = true -bouncycastle-sha3.workspace = true \ No newline at end of file +bouncycastle-sha3.workspace = true diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 489634af..2ea124ef 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cli" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -10,4 +10,4 @@ bouncycastle.workspace = true [[bin]] name = "bc-rust" -path = "src/main.rs" \ No newline at end of file +path = "src/main.rs" diff --git a/crypto/base64/Cargo.toml b/crypto/base64/Cargo.toml index 7059b7cb..a61ca6d2 100644 --- a/crypto/base64/Cargo.toml +++ b/crypto/base64/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-base64" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -13,4 +13,4 @@ bouncycastle-rng.workspace = true [[bench]] name = "base64_benches" -harness = false \ No newline at end of file +harness = false diff --git a/crypto/core-test-framework/Cargo.toml b/crypto/core-test-framework/Cargo.toml index b4aca48c..69447b69 100644 --- a/crypto/core-test-framework/Cargo.toml +++ b/crypto/core-test-framework/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "bouncycastle-core-test-framework" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] bouncycastle-core.workspace = true -[dev-dependencies] \ No newline at end of file +[dev-dependencies] diff --git a/crypto/core/Cargo.toml b/crypto/core/Cargo.toml index 784d3fef..6415376a 100644 --- a/crypto/core/Cargo.toml +++ b/crypto/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-core" -version = "0.1.2" +version.workspace = true edition.workspace = true [features] @@ -14,4 +14,4 @@ std = [] bouncycastle-utils.workspace = true [dev-dependencies] -bouncycastle-rng.workspace = true \ No newline at end of file +bouncycastle-rng.workspace = true diff --git a/crypto/factory/Cargo.toml b/crypto/factory/Cargo.toml index 0f31c933..d3060ebd 100644 --- a/crypto/factory/Cargo.toml +++ b/crypto/factory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-factory" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] diff --git a/crypto/hex/Cargo.toml b/crypto/hex/Cargo.toml index 8d88707e..3dcb7e1b 100644 --- a/crypto/hex/Cargo.toml +++ b/crypto/hex/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-hex" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -11,4 +11,4 @@ criterion.workspace = true [[bench]] name = "hex_benches" -harness = false \ No newline at end of file +harness = false diff --git a/crypto/hkdf/Cargo.toml b/crypto/hkdf/Cargo.toml index 1d8ca452..12319fae 100644 --- a/crypto/hkdf/Cargo.toml +++ b/crypto/hkdf/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-hkdf" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -17,4 +17,4 @@ bouncycastle-hex.workspace = true [[bench]] name = "hkdf_benches" -harness = false \ No newline at end of file +harness = false diff --git a/crypto/hmac/Cargo.toml b/crypto/hmac/Cargo.toml index f1e4377e..ebb14077 100644 --- a/crypto/hmac/Cargo.toml +++ b/crypto/hmac/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-hmac" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] diff --git a/crypto/mldsa-lowmemory/Cargo.toml b/crypto/mldsa-lowmemory/Cargo.toml index 83ccce74..60eeadb8 100644 --- a/crypto/mldsa-lowmemory/Cargo.toml +++ b/crypto/mldsa-lowmemory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-mldsa-lowmemory" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -19,4 +19,4 @@ serde_json = "1.0" [[bench]] name = "mldsa_benches" -harness = false \ No newline at end of file +harness = false diff --git a/crypto/mldsa/Cargo.toml b/crypto/mldsa/Cargo.toml index cddc8c69..6071b765 100644 --- a/crypto/mldsa/Cargo.toml +++ b/crypto/mldsa/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-mldsa" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -19,4 +19,4 @@ serde_json = "1.0" [[bench]] name = "mldsa_benches" -harness = false \ No newline at end of file +harness = false diff --git a/crypto/mlkem-lowmemory/Cargo.toml b/crypto/mlkem-lowmemory/Cargo.toml index 8ccb067e..6edb19ad 100644 --- a/crypto/mlkem-lowmemory/Cargo.toml +++ b/crypto/mlkem-lowmemory/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-mlkem-lowmemory" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -18,4 +18,4 @@ serde_json = "1.0" [[bench]] name = "mlkem_benches" -harness = false \ No newline at end of file +harness = false diff --git a/crypto/mlkem/Cargo.toml b/crypto/mlkem/Cargo.toml index 8235352d..2e0d26e8 100644 --- a/crypto/mlkem/Cargo.toml +++ b/crypto/mlkem/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-mlkem" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -18,4 +18,4 @@ serde_json = "1.0" [[bench]] name = "mlkem_benches" -harness = false \ No newline at end of file +harness = false diff --git a/crypto/rng/Cargo.toml b/crypto/rng/Cargo.toml index 69d3a6e6..48e1a3c6 100644 --- a/crypto/rng/Cargo.toml +++ b/crypto/rng/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-rng" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -17,4 +17,4 @@ criterion.workspace = true [[bench]] name = "hash_drbg_benches" -harness = false \ No newline at end of file +harness = false diff --git a/crypto/sha2/Cargo.toml b/crypto/sha2/Cargo.toml index 6de8662d..7ff2e037 100644 --- a/crypto/sha2/Cargo.toml +++ b/crypto/sha2/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-sha2" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] diff --git a/crypto/sha3/Cargo.toml b/crypto/sha3/Cargo.toml index 00509588..0465fadc 100644 --- a/crypto/sha3/Cargo.toml +++ b/crypto/sha3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-sha3" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] diff --git a/crypto/utils/Cargo.toml b/crypto/utils/Cargo.toml index 0616cdde..a23ce1a1 100644 --- a/crypto/utils/Cargo.toml +++ b/crypto/utils/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bouncycastle-utils" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] diff --git a/mem_usage_benches/Cargo.toml b/mem_usage_benches/Cargo.toml index 9ad569c7..53c35f9a 100644 --- a/mem_usage_benches/Cargo.toml +++ b/mem_usage_benches/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mem_usage_benches" -version = "0.1.2" +version.workspace = true edition.workspace = true [dependencies] @@ -13,4 +13,4 @@ path = "bench_mldsa_mem_usage.rs" [[bin]] name = "bench_mlkem_mem_usage" -path = "bench_mlkem_mem_usage.rs" \ No newline at end of file +path = "bench_mlkem_mem_usage.rs" From 296a874af0e0109e17e0b485b03bdcf22f94fe65 Mon Sep 17 00:00:00 2001 From: Tamish Dahiya Date: Fri, 24 Jul 2026 18:00:09 +0530 Subject: [PATCH 08/28] Deduplicate internal dependencies version in core cargo.toml --- Cargo.toml | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d15af249..f28bec92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,22 +8,22 @@ version = "0.1.2" [workspace.dependencies] # *** Internal Dependencies *** -bouncycastle = { path = "./", version = "0.1.2" } -bouncycastle-base64 = { path = "./crypto/base64", version = "0.1.2" } -bouncycastle-core = { path = "crypto/core", version = "0.1.2" } -bouncycastle-core-test-framework = { path = "./crypto/core-test-framework", version = "0.1.2" } -bouncycastle-factory = { path = "./crypto/factory", version = "0.1.2" } -bouncycastle-hex = { path = "./crypto/hex", version = "0.1.2" } -bouncycastle-hkdf = { path = "./crypto/hkdf", version = "0.1.2" } -bouncycastle-hmac = { path = "./crypto/hmac", version = "0.1.2" } -bouncycastle-mlkem = { path = "./crypto/mlkem", version = "0.1.2" } -bouncycastle-mlkem-lowmemory = { path = "./crypto/mlkem-lowmemory", version = "0.1.2" } -bouncycastle-mldsa = { path = "./crypto/mldsa", version = "0.1.2" } -bouncycastle-mldsa-lowmemory = { path = "./crypto/mldsa-lowmemory", version = "0.1.2" } -bouncycastle-rng = { path = "./crypto/rng", version = "0.1.2" } -bouncycastle-sha2 = { path = "./crypto/sha2", version = "0.1.2" } -bouncycastle-sha3 = { path = "./crypto/sha3", version = "0.1.2" } -bouncycastle-utils = { path = "./crypto/utils", version = "0.1.2" } +bouncycastle = { path = "./" } +bouncycastle-base64 = { path = "./crypto/base64" } +bouncycastle-core = { path = "crypto/core" } +bouncycastle-core-test-framework = { path = "./crypto/core-test-framework" } +bouncycastle-factory = { path = "./crypto/factory" } +bouncycastle-hex = { path = "./crypto/hex" } +bouncycastle-hkdf = { path = "./crypto/hkdf" } +bouncycastle-hmac = { path = "./crypto/hmac" } +bouncycastle-mlkem = { path = "./crypto/mlkem" } +bouncycastle-mlkem-lowmemory = { path = "./crypto/mlkem-lowmemory" } +bouncycastle-mldsa = { path = "./crypto/mldsa" } +bouncycastle-mldsa-lowmemory = { path = "./crypto/mldsa-lowmemory" } +bouncycastle-rng = { path = "./crypto/rng" } +bouncycastle-sha2 = { path = "./crypto/sha2" } +bouncycastle-sha3 = { path = "./crypto/sha3" } +bouncycastle-utils = { path = "./crypto/utils" } # *** External Dependencies *** From 366363cf6c5c03a68ca34d465b48668964560875 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Tue, 28 Jul 2026 10:41:10 -0500 Subject: [PATCH 09/28] tweaks to centralized versioning --- Cargo.toml | 2 +- alpha_0.1.2_release_notes.md | 60 ---------------------------- alpha_0.1.3_release_notes.md | 5 +++ crypto/core/src/suspendable_state.rs | 8 ++++ 4 files changed, 14 insertions(+), 61 deletions(-) delete mode 100644 alpha_0.1.2_release_notes.md create mode 100644 alpha_0.1.3_release_notes.md diff --git a/Cargo.toml b/Cargo.toml index f28bec92..82b379fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["cli", "crypto/*", "mem_usage_benches"] [workspace.package] edition = "2024" -version = "0.1.2" +version = "0.1.3" [workspace.dependencies] diff --git a/alpha_0.1.2_release_notes.md b/alpha_0.1.2_release_notes.md deleted file mode 100644 index f016ee7b..00000000 --- a/alpha_0.1.2_release_notes.md +++ /dev/null @@ -1,60 +0,0 @@ -# 0.1.2 Features / Changelog - -## Major features - -* New algorithms added to crypto/ : - * mldsa (FIPS 204) - * mldsa-lowmemory -- runs in about 1/10th of the usual memory (~ 30 kb of stack) with comparable performance impact. - * mlkem (FIPS 203) - * mlkem-lowmemory -- runs in about 1/4th of the usual memory (~ 12 kb of stack) with comparable performance impact. -* New traits [Suspendable] and [SuspendableKeyed] allow algorithms with a streaming API (`do_update()` -> - `do_final()`) to be suspended to a small byte array and then resumed later, potentially from a different host and - potentially across versions of the library. The intended use case is if you are processing a large input that depends - on one or more network round-trips and you wish to suspend to a cache and potentially transfer to a new host while - waiting for network IO. -* dyn RNG: anywhere that consumes randomness (such as keygen and non-deterministic sign / encaps functions) can now be - handed an instance of an object that impl's `bouncycastle-core::traits::RNG`. -* Rework of the Secret system for protecting secret data against leakage via returning to the memory pool unzeroized, - or being logged in debug messages, stack traces, and crash dumps. Now properly uses `core::mem::write_volatile` to - prevent - the compiler from eliding writes on drop, and introduced a new type system `Secret` that is used across the library - to give more fine-grained control over which objects (and which fields within objects) get this extra protection. - Bonus: this is a public type that you can use to protect your application data as well! - -## Minor features / bug fixes - -Trait system: - -* Split the Signature trait into a Signer and a Verifier trait. This is for two reasons: 1) some of the future signature - algorithms (like hash-based signatures) the verifier code is substantially lighter than the signer code, or we may not - even want to implement a signer in software, and 2) NIST likes to soft-deprecate algorithms by disallowing generation - of new signatures, but still allowing verification of existing signatures. -* Added traits for symmetric ciphers in the block cipher, stream cipher, and AEAD families. We don't have any of these - algorithms implemented yet, but they're coming! - -The KeyMaterial object: - -* Reworked the way KeyMaterial hazardous operations work; instead of a stateful .allow_hazardous_operations() / - .drop_hazardous_operations(), it now uses a closure-based do_hazardous_operations(). Github issue #39. -* Renamed KeyMaterial::KeyType's and deleted KeyMaterial::concatenate in order to give a better intuition and - FIPS-alignment. -* Tightened up the entropy-tracking behaviour of the KeyMaterial object, thanks to Q. T. Felix (github: - @Quant-TheodoreFelix, github issue #6) - -Docs: - -* Major overhaul of the docs (public crate docs, and inline comments) to make them more neutral and professional (Huge - thanks to @laruizlo for this big effort!). -* All crypto algorithm crates now have Memory Usage docs that list the stack memory usage of the implementation. -* All crypto algorithm crates now have `#![forbid(missing_docs)]` to ensure that they have a fully-documented public - API. - -* Other miscellaneous Github issues resolved: - * #10: https://github.com/bcgit/bc-rust/issues/10, thanks to Nicola Tuveri (github: @romen) - * #18: All public `*_out(.., out: &mut [u8])` functions now begin by zeroizing the entire provided output buffer - with `.fill(0)`, - preventing exposure of stale data in oversized output buffers or on early error returns. Thanks to Q. T. Felix ( - github: @Quant-TheodoreFelix) - * #27: "SHAKE absorb-after-squeeze": clarified and hardened the behaviour of SHAKE with respect to absorbing more - input after having been squeezed. - * #28: Removed the dependence on nightly / experimental compiler features; the library now builds on stable. diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md new file mode 100644 index 00000000..210a5aeb --- /dev/null +++ b/alpha_0.1.3_release_notes.md @@ -0,0 +1,5 @@ +# 0.1.3 Features / Changelog + +## Major features + +## Minor features / bug fixes diff --git a/crypto/core/src/suspendable_state.rs b/crypto/core/src/suspendable_state.rs index e1e23352..47a8e0f0 100644 --- a/crypto/core/src/suspendable_state.rs +++ b/crypto/core/src/suspendable_state.rs @@ -1,5 +1,7 @@ //! Helper functions for standardizing serialization and deserialization of stateful objects. +// todo -- should this move to bouncycastle-utils? + use crate::errors::SuspendableError; /// A semantic library version, ordered by `major`, then `minor`, then `patch`. @@ -63,6 +65,12 @@ pub const LIB_VERSION: SemVer = SemVer { patch: parse_version_component(env!("CARGO_PKG_VERSION_PATCH")), }; +#[test] +/// Just to check it visually +fn print_lib_ver() { + println!("LIB_VERSION: {:?}, as bytes: {:?}", LIB_VERSION, <[u8; 3]>::from(LIB_VERSION)); +} + #[test] fn test_cmp_lib_ver() { use core::cmp::Ordering; From 43e25367fabc2df0c79448468ea327451b54b47a Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Tue, 28 Jul 2026 10:41:33 -0500 Subject: [PATCH 10/28] rustfmt --- cli/src/mldsa_cmd.rs | 2 +- crypto/core/src/key_material.rs | 2 +- crypto/factory/src/rng_factory.rs | 2 +- crypto/hex/src/lib.rs | 12 +++---- crypto/mldsa-lowmemory/src/aux_functions.rs | 4 +-- crypto/mldsa-lowmemory/src/polynomial.rs | 2 +- crypto/mldsa/src/hash_mldsa.rs | 2 +- crypto/mldsa/src/lib.rs | 3 +- crypto/mldsa/tests/mldsa_tests.rs | 12 +++---- crypto/mlkem-lowmemory/src/aux_functions.rs | 2 +- crypto/mlkem-lowmemory/src/lib.rs | 8 ++--- crypto/mlkem/src/aux_functions.rs | 5 ++- crypto/mlkem/src/polynomial.rs | 36 ++++++++++----------- crypto/mlkem/tests/mlkem_key_tests.rs | 1 - crypto/rng/src/hash_drbg80090a.rs | 10 +++--- crypto/rng/src/lib.rs | 16 ++++----- crypto/rng/tests/hash_drbg80090a_tests.rs | 25 +++++++------- crypto/sha2/src/sha256.rs | 2 +- crypto/sha2/src/sha512.rs | 2 +- crypto/sha3/src/sha3.rs | 8 ++--- crypto/sha3/src/shake.rs | 4 +-- 21 files changed, 78 insertions(+), 82 deletions(-) diff --git a/cli/src/mldsa_cmd.rs b/cli/src/mldsa_cmd.rs index beb92c60..070af7ea 100644 --- a/cli/src/mldsa_cmd.rs +++ b/cli/src/mldsa_cmd.rs @@ -1,4 +1,4 @@ -//! Work in progress. +//! Work in progress. //! TODO: Use generic macros to eliminate duplicated code. use crate::helpers::{parse_seed, read_from_file, read_from_file_or_stdin, write_bytes_or_hex}; diff --git a/crypto/core/src/key_material.rs b/crypto/core/src/key_material.rs index dc3244d9..fb95d71b 100644 --- a/crypto/core/src/key_material.rs +++ b/crypto/core/src/key_material.rs @@ -547,7 +547,7 @@ impl KeyMaterialTrait for KeyMaterial { } self.security_strength = strength; - + Ok(()) } diff --git a/crypto/factory/src/rng_factory.rs b/crypto/factory/src/rng_factory.rs index c316475c..492efecb 100644 --- a/crypto/factory/src/rng_factory.rs +++ b/crypto/factory/src/rng_factory.rs @@ -30,7 +30,7 @@ //! let output: Vec = h.hash(data); //! ``` //! Equivalently, it may be invoked by passing a string instead of using the constant: -//! +//! //! ``` //! use bouncycastle_factory::AlgorithmFactory; //! use bouncycastle_core::traits::Hash; diff --git a/crypto/hex/src/lib.rs b/crypto/hex/src/lib.rs index 3b53b455..923151d7 100644 --- a/crypto/hex/src/lib.rs +++ b/crypto/hex/src/lib.rs @@ -3,14 +3,14 @@ //! This one is implemented using constant-time operations in the conversions //! from Strings to byte values, so it is safe to use on cryptographic secret values. //! -//! It should just work as expected: -//! encode takes any bytes-like rust type and returns a String, +//! It should just work as expected: +//! encode takes any bytes-like rust type and returns a String, //! decode takes a String (which can be in any bytes-like container) and returns a `Vec`. //! //! Moreover, the API of this crate is intended to mirror that of the public `hex` crate, //! so you should generally be able to swap `use hex` for `use bouncycastle_hex` and all the function //! calls and behaviours should work as expected. -//! +//! //! ``` //! use bouncycastle_hex as hex; //! @@ -72,7 +72,7 @@ pub fn encode_out>(input: T, out: &mut [u8]) -> Result::is_within_range(c as i64, 0, 9); let in_af = Condition::::is_within_range(c as i64, 10, 15); - // TODO: redo this once we have ct::u8 implemented + // TODO: redo this once we have ct::u8 implemented // The i64 is wasteful let c_09: i64 = '0' as i64 + (c as i64); @@ -111,7 +111,7 @@ pub fn decode_out>(input: T, out: &mut [u8]) -> Result { @@ -157,7 +157,7 @@ pub fn decode_out>(input: T, out: &mut [u8]) -> Result::is_within_range(b as i64, 65, 70); - // TODO: redo this once we have ct::u8 implemented + // TODO: redo this once we have ct::u8 implemented // The i64 is wasteful let c_09: i64 = b as i64 - ('0' as i64); diff --git a/crypto/mldsa-lowmemory/src/aux_functions.rs b/crypto/mldsa-lowmemory/src/aux_functions.rs index 4d91dc57..27264207 100644 --- a/crypto/mldsa-lowmemory/src/aux_functions.rs +++ b/crypto/mldsa-lowmemory/src/aux_functions.rs @@ -471,7 +471,7 @@ pub(crate) fn sample_in_ball( let mut j = [0u8]; for i in (N - TAU as usize)..N { // 7: (ctx, ๐‘—) โ† H.Squeeze(ctx, 1) - // Note: At first, it might seem to be faster to pre-squeeze a buffer outside the loop. + // Note: At first, it might seem to be faster to pre-squeeze a buffer outside the loop. // However, after experimentation and testing, the difference is not noticeable. h.squeeze_out(&mut j); @@ -567,7 +567,7 @@ pub(crate) fn rej_bounded_poly(rho: &[u8; 64], nonce: &[u8; 2] // SHAKE is fairly inefficient if only 3 bytes are squeezed at a time, so the implementation does a block instead. // size is not a limitation as long as it is a multiple of 3. // 312 seems to be the sweet spot after some experimentation - // which is possibly also related with the average rejection rate. + // which is possibly also related with the average rejection rate. // Also, 312 is a multiple of 8 (efficient for SHAKE) let mut z_arr = [0u8; 312]; h.squeeze_out(&mut z_arr); diff --git a/crypto/mldsa-lowmemory/src/polynomial.rs b/crypto/mldsa-lowmemory/src/polynomial.rs index 2e672b5a..95e3387b 100644 --- a/crypto/mldsa-lowmemory/src/polynomial.rs +++ b/crypto/mldsa-lowmemory/src/polynomial.rs @@ -95,7 +95,7 @@ impl Polynomial { pub(crate) fn check_norm(&self) -> bool { // Fine that this is not constant-time (returns true early) because it is used in a rejection loop. - // IE the early quit here leads to rejection and continuing to the top of the rejection loop, or failing + // IE the early quit here leads to rejection and continuing to the top of the rejection loop, or failing // the signature validation. // So the i32 that was just checked in a non-constant-time manner is about to get thrown away. diff --git a/crypto/mldsa/src/hash_mldsa.rs b/crypto/mldsa/src/hash_mldsa.rs index af38aff4..5e1b535a 100644 --- a/crypto/mldsa/src/hash_mldsa.rs +++ b/crypto/mldsa/src/hash_mldsa.rs @@ -1097,7 +1097,7 @@ impl< /// Note that the PH expected here *is not the same* as the `mu` computed by [`MuBuilder`]. /// To make use of this function, the user needs to compute a straight hash of the message using - /// the same hash function as the indicated in the HashML-DSA variant; + /// the same hash function as the indicated in the HashML-DSA variant; /// for example: SHA256 for HashMDSA44_with_SHA256; SHA512 for HashMLDSA65_with_SHA512; etc. fn sign_ph_out( sk: &SK, diff --git a/crypto/mldsa/src/lib.rs b/crypto/mldsa/src/lib.rs index 91852f19..15fa7408 100644 --- a/crypto/mldsa/src/lib.rs +++ b/crypto/mldsa/src/lib.rs @@ -116,11 +116,10 @@ //! //! `mu`, `ph`, and `ctx` are binding values that the verifier must reproduce. This means that getting //! them wrong does not compromise security, it just yields a signature the intended -//! verifier won't accept (a correctness/interoperability failure). +//! verifier won't accept (a correctness/interoperability failure). //! One caveat: `ctx` can still be security-relevant at the protocol level (domain separation, replay and //! cross-protocol binding), so choosing it incorrectly can weaken those properties. - #![no_std] #![forbid(unsafe_code)] #![forbid(missing_docs)] diff --git a/crypto/mldsa/tests/mldsa_tests.rs b/crypto/mldsa/tests/mldsa_tests.rs index a07d2e95..33aa8f9f 100644 --- a/crypto/mldsa/tests/mldsa_tests.rs +++ b/crypto/mldsa/tests/mldsa_tests.rs @@ -1083,9 +1083,9 @@ struct Kat { } // generated by hand against bc-java -// This is almost tgId=1, tcId=1 from +// This is almost tgId=1, tcId=1 from // https://raw.githubusercontent.com/bcgit/bc-test-data/refs/heads/main/pqc/crypto/mldsa/ML-DSA-sigGen.txt -// with the difference being that an empty ctx is added, instead of testing sign_internal directly, +// with the difference being that an empty ctx is added, instead of testing sign_internal directly, // and the signature value is run against bc-java const MLDSA44_KAT1: Kat = Kat { _parameter_set: "ML-DSA-44", @@ -1096,9 +1096,9 @@ const MLDSA44_KAT1: Kat = Kat { signature: "2bddcee4a9ac1b9d19bc1531365c5613e48b95a530339c52f5fc0b671ee01b6587fe08b290c191f82eace640fae216cca90f40fa93bb309e5ff53afc5a042050bffeb69d4e0041c34fd334a7b576c6ebae68042b315fe78f84300dc011cdb144252a06f35c9a2b0a275d00552f890361d1e7b7439572097d1f3d5833b98b751412deb3c0df23ee3a30782919444bcbcc9ac25c645180c8d1a9fe693086cba4779d6e31b1e5fd1a0ccaeb055c471ef065a273676d78c6e7feebfc607c5578107ac27765345363af57f77431e306d9407ce365708d813e44d3107c108f8c5f2848913a4099f6a0ce2ebf80c7414236d4edc07fee79efe1ab6fe70217bec958ea48221fb6b87cef6f5762b41a9c698fbb45f5994969bee21e40f20c95f9a18389e8b49d6ecccdae9f568313448b5162c981bc9ff320753aede977b15f7ddc68bba076a07deb68ac36826e70d80752fac0356d507b3e283b350a17b015f76c71314f7c2086e44601c4d9c707c99a36e55716fd34384a218242a69876c8dea9f6ec28c40189ee2b8608040a96a813f38240dc85a511b3cebab1ea893270e730aef2e29406fcc1f6826101f1361168ceb3f1632e8ee505143e0f031e744928c1e41eab923ebbfce5e9f159fd160db737b013759382274a1d9409554ab06eb72b5ce2a710d8b08f163df991c956abc823d21b0a6d9d7ae484d6cdb2e04de7a282ef317f488e20d40e4e67cbf05d1528c0ce261e3a65163ad518661989484bc964da21122a6c95ef7036b3273f93833d068d9a2c7933e19ad2fde8378c286ad57f7e22c8aa4153718f356b46b34ca4a986e6e9ef3ddb206693acf3b08c0aa5118c8efbb6a99a0fc0674f6228e2f6f53e229129d4b6c0fd6455e28587f0f169270d044398e2c377ab6f985f7f2235d68192d031f39251f9f0270451beda2d29b654511b73bce0f30174a606a1100f4fc984959704ba0e2df2bdf642ba3b0241ca9889009ae575540c71747246835018ba8faeb473b39e4ede5a6fbe0165a4db2e90739cb051f3e5c1c0ce159539758569ecc8b67a759a2e786d5b4e96834db0dd460ab1c5c7ac8f3cf19596979108dfbdc6c8798e7342026b7571ba64fa86d68e1d5179af5768a7e22db76f3a319193d04eeea626d03d4be7dd83ed6c46e2149a1e2428e60f6566833726bcc93d0b342ff4245665b54167b013b02165d29fb64055112c5d800e853bf28bf149da2284e49dc1c5f478f490ce1002f16431564534244cd9c1e0bd12ea9209efe572afb7effc0153e5b2066b1af0dd05a2d9da13308c90a0244ad95a33a64d07a75d7596e46cdcfd154e18f7677ef16b979a1f431eb59279c65a3a31c5429ceebfcc869cf538722a82e6e765bd6a3042dc825684ad4163e054c113b47276b894580db17c92f0859c240b6812716e408a557c1d7ac8cfc3abf9b43f759bb2a13c903bba936206496fed37696171717cb8995b837e869052c9c25348698617c70e98966d30f56bb5bf41369c0131de4178637c5684a64992b50f9ab9a16b51752e14ea521d5bd42bacdfcf100085629796d22bcaa6a7830f9b5859d75b290a0db031bd51b77fa7911eb17d46dcf16d45e3b1ce8fb7dc19ba969a724e43ccce9487c143302c79fb208b2ab0ea1060af3809d5397c11cc0a79ed39ab06c0ef7bceb8e094ec0e3ea52937442ccc176a31b07a52d7de84c0599dbe736cecfacbb41cac206ec3dc20603606a14a1db3ccad8037a540f3213ee9337b529f447ed2c2e287760973b8175f70c78423ace6179757c519c14a0d7b81af00646afde573a35e909ef9660623cd3651b63b79be508698888e1d3bc87344067dcc099416616c273d5ea0e41a8723d11c5ec49e0c6019f6576a2e87d0237c3f64d49afbaf09fc818829d5d0fcb4336e08503d570dc3c96fa71fd7b2d4f51d0d1acc515c2c4dad23896273b2616f7d848816c5cc17f7bb2ed5f952a708c38eb13f0fdd8f14ec71893d3f19ccc8aa15fed848583093f4d8246f36db300c9cdd5d63b507641e4d2ba9ec284d17f4a05696be00da371bd5bab0ba56a13451278dbdeed02b4093e482b96269707b09a10dba9ca1dffb4e4185f9e392ebabcea1f9d26c037d9c0dcd71f2bc13faf559d195ef0d8be7baa0cf8aa105a8069356aa2287ec1003e0759f27b02772d4855bf0b7e0b3e97b7f77bd3119669695cc706090991e6a542eb7a28547978ba8a05d9103a90789108fa15898d14982bd0d5d098019700b37939f9f0d66e78af49c6318b886ba2a9101c072bb1e7bdbdb7319eb38e3320b4fbb1fcb28e8e7fac0bd493faf3c0547214465d55ca212dae99dc53addc3378b7e7b93acbdc1c9787149ed0214cd9846852ed7c18b23ab0ae8b7afbb725d44997a38422ff17b1dbeb22e2387094e0bc59496786114d0bb398f36fdfb06c70ff0ce47e9c3eb8c32c22062ccd5306026b606a9e9c628a377f0efd71087c95b3c1ffbdec8a91f311fbba4793d3d3fbf350e6a4a491d74ab7fb0cb66afa0d66177853df464f0175cdee4f97a4e620366aa18c2d04ebab82ff31ec07722fd53e0b4926973dd41d10422747261d8772b18ab55e0bbdcb89e224a5fc2679c2b729aaa4e1a78f95cf68af3562b98f0586d02134464f87dd15b843451a9160c5f4704c994a32259ca623c937431cfe55ee97d736916ac3e7ef831a1b6978539ac6c3304de2f43b3b208f73d071d17fd5cae631f617929468fc59d529deab1c0080a85ded180f9b7029376059c5ca3ba2eab9ee556a74373eadb5983f5990146b04bd255f2380450864e33c478cfe42072eeaabf8032f2c22fbf111407bf2cc41f6cc55596cca62a69303089c3ec231f42a8358d8bce315debce8cd4cea4aa478fba3476b5252e2d64da6b70d6ea0c1b4a99abebbd194e62e25442e7ecffc788710ac6dd1da815a0a5ef8dcef34ebcc90c6f29372875d664c2a06f3485dad8bd00d94837f417412399f9f085d8666fcaf38d38620897e2d06c7399eb28c5db0ffa42a233fdc6732c3e525bd49771aad03348aad068a5e729565c10d343a10c5cd6e530994c9a400354de3af3b39d20cf2b54fc0c9bde4b6f520688694d9b53c3628f1744b61271383d852219d8afae7a284d2f0b042f2fb70778b53d30876b5a031904a241e5a1741ff2e88bf8faa60472ba66111f0560ef127ee86d24f2c502fcff7575696f7f450109776f0f5e1bbd77a48952697fb2f8657516cc061de2bcc6aff9b861356b97e576f78abab1d3acb70d14a84b7858c22b2a0ee17e1231a2da3738018205091b263c42597ea6d3d7d9dae3fa030a13162243494c4e7f8fa1a2d8e2ff11153a41474a63646770717487919da9bac9d2d3e3070f23455d686a7dbcc9de00000000000000000000000000000000000f1f343f", }; -// This is almost tgId=1, tcId=1 from +// This is almost tgId=1, tcId=1 from // https://raw.githubusercontent.com/bcgit/bc-test-data/refs/heads/main/pqc/crypto/mldsa/ML-DSA-sigGen.txt -// with the difference being that an empty ctx is added, instead of testing sign_internal directly, +// with the difference being that an empty ctx is added, instead of testing sign_internal directly, // and the signature value is run against bc-java const MLDSA65_KAT1: Kat = Kat { _parameter_set: "ML-DSA-65", @@ -1110,9 +1110,9 @@ const MLDSA65_KAT1: Kat = Kat { }; // generated by hand against bc-java -// This is almost tgId=1, tcId=1 from +// This is almost tgId=1, tcId=1 from // https://raw.githubusercontent.com/bcgit/bc-test-data/refs/heads/main/pqc/crypto/mldsa/ML-DSA-sigGen.txt -// with the difference being that an empty ctx is added, instead of testing sign_internal directly, +// with the difference being that an empty ctx is added, instead of testing sign_internal directly, // and the signature value is run against bc-java const MLDSA87_KAT1: Kat = Kat { _parameter_set: "ML-DSA-87", diff --git a/crypto/mlkem-lowmemory/src/aux_functions.rs b/crypto/mlkem-lowmemory/src/aux_functions.rs index 7df893d8..b548f440 100644 --- a/crypto/mlkem-lowmemory/src/aux_functions.rs +++ b/crypto/mlkem-lowmemory/src/aux_functions.rs @@ -62,7 +62,7 @@ pub(crate) fn byte_decode(B: &[u8; PACK_L for j in 0..d { // select the next bit, according to bitcount, then shift it up by j // there is supposed to be a `mod m` here, but that shouldn't matter as they are being checked below - F[i] |= (((B[(i * d + j) / 8] >> (i * d + j) % 8) & 1) as i16) << j; + F[i] |= (((B[(i * d + j) / 8] >> (i * d + j) % 8) & 1) as i16) << j; } // assert the mod m // These are relaxed because they are being checked above in MLKEMPublicKey::pk_decode() diff --git a/crypto/mlkem-lowmemory/src/lib.rs b/crypto/mlkem-lowmemory/src/lib.rs index 207da323..ae933459 100644 --- a/crypto/mlkem-lowmemory/src/lib.rs +++ b/crypto/mlkem-lowmemory/src/lib.rs @@ -208,16 +208,16 @@ //! There are, however, a few exceptions worth mentioning. //! //! If using a [`MLKEM::keygen_from_seed`], then it is your responsibility to ensure that the seed is -//! cryptographically random and unpredictable at a security strength that matches the MLKEM parameter set. +//! cryptographically random and unpredictable at a security strength that matches the MLKEM parameter set. //! //! Also, [`MLKEM::encaps_internal`] requires the encapsulation randomness to be provided, so the ciphertext //! will only be as strong as the randomness that you provide. -//! +//! //! A note about cryptographic side-channel attacks: considerable effort has been expended to attempt //! to make this implementation constant-time, which generally means that the core mathematical algorithm //! code that handles secret data uses bitshift-and-xor type constructions instead of if-and-loop //! constructions. That should give this implementation reasonably good resistance to timing and -//! power analysis key extraction attacks, however: +//! power analysis key extraction attacks, however: //! A) this is a "best-effort" and not formally verified, and //! B) the Rust compiler does not guarantee constant-time behaviour no matter how good the design is code, //! so like all Safe Rust code (ie Rust code that does not include inline assembly), @@ -227,7 +227,7 @@ #![no_std] #![forbid(missing_docs)] #![forbid(unsafe_code)] -// These are because variable names need to be matched exactly against FIPS 204, +// These are because variable names need to be matched exactly against FIPS 204, // for example both 'K' and 'k', or 'A' and 'a' are used and have specific meanings. // linter needs to be instructed to ignore these cases #![allow(non_snake_case)] diff --git a/crypto/mlkem/src/aux_functions.rs b/crypto/mlkem/src/aux_functions.rs index 88966d72..09998374 100644 --- a/crypto/mlkem/src/aux_functions.rs +++ b/crypto/mlkem/src/aux_functions.rs @@ -301,9 +301,8 @@ pub(crate) fn barrett_reduce(a: i16) -> i16 { a - (((t as i32) * q as i32) as i16) } - -// Not currently used. It is left here as a reference since it's useful for debugging if it's -// necessary to output values that are normalized to [0,q] to compare against intermediate results +// Not currently used. It is left here as a reference since it's useful for debugging if it's +// necessary to output values that are normalized to [0,q] to compare against intermediate results // from other libraries. // pub(super) fn cond_sub_q(a: i16) -> i16 { // let tmp = a - q; diff --git a/crypto/mlkem/src/polynomial.rs b/crypto/mlkem/src/polynomial.rs index 6617e468..40025286 100644 --- a/crypto/mlkem/src/polynomial.rs +++ b/crypto/mlkem/src/polynomial.rs @@ -9,7 +9,7 @@ use crate::mlkem::{N, q}; /// A polynomial over the ML-KEM ring. /// -/// Dev note: The following structure does not necessarily need to be declared as public. +/// Dev note: The following structure does not necessarily need to be declared as public. /// There is no real scenario where this function needs to be called directly. /// However, in order to test the Debug and Display traits, it is necessary to use STD, so those /// can't be tested from inline tests in this file and the real unit tests are in a different crate. @@ -46,8 +46,8 @@ impl Polynomial { Self { coeffs: [0i16; N] } } - /// Encodes a 32-byte message `m` into a `Polynomial`, implementing the message - /// encoding step of K-PKE.Encrypt `Decompress_1(ByteDecode_1(m))`, + /// Encodes a 32-byte message `m` into a `Polynomial`, implementing the message + /// encoding step of K-PKE.Encrypt `Decompress_1(ByteDecode_1(m))`, /// (FIPS 203, Alg. 14). Each message bit becomes one coefficient: `Decompress_1` /// (ยง4.2.1) maps bit `1` to `โŒˆq/2โŒ‰ = (q + 1) / 2 = 1665` (for `q = 3329`) and bit /// `0` to `0`, placing a set bit at the point farthest from `0` to maximize the @@ -67,25 +67,25 @@ impl Polynomial { w } - /// Decodes a `Polynomial` into its 32-byte message `m`, implementing the message + /// Decodes a `Polynomial` into its 32-byte message `m`, implementing the message /// recovery step of K-PKE.Decrypt `ByteEncode_1(Compress_1(self))`, /// (FIPS 203, Alg. 15). Each coefficient yields one message bit: `Compress_1` /// (ยง4.2.1) sets the bit when the coefficient lies nearer `q/2` than `0`, i.e. in /// the central interval `[833, 2496]` for `q = 3329`. The decision is computed - /// branchlessly and the bits are packed LSB-first. - /// Coefficients are expected to already be canonical in `[0, q]`: the unsigned - /// interval test is not periodic mod `q`, so the caller reduces beforehand (`poly_reduce()` + /// branchlessly and the bits are packed LSB-first. + /// Coefficients are expected to already be canonical in `[0, q]`: the unsigned + /// interval test is not periodic mod `q`, so the caller reduces beforehand (`poly_reduce()` /// in `pke_decrypt`) and no reduction is repeated here. pub(crate) fn to_msg(self) -> [u8; 32] { - const LOWER: i32 = q as i32 >> 2; // โŒŠq/4โŒ‹ = 832 - const UPPER: i32 = q as i32 - LOWER; // q - โŒŠq/2โŒ‹ = 2497 + const LOWER: i32 = q as i32 >> 2; // โŒŠq/4โŒ‹ = 832 + const UPPER: i32 = q as i32 - LOWER; // q - โŒŠq/2โŒ‹ = 2497 let mut msg = [0u8; 32]; // Using full reduce() might be expected here. - // However, this function is only called by pke_decrypt (see mlkem.rs), which performs a + // However, this function is only called by pke_decrypt (see mlkem.rs), which performs a // reduction on every coefficient of the polynomial immediately prior to the call. - // For completeness, testing against the bc-test-data set of KATs shows that everything passes + // For completeness, testing against the bc-test-data set of KATs shows that everything passes // without modular reduction. // self.cond_sub_q(); @@ -101,8 +101,8 @@ impl Polynomial { msg } - // Not currently used. It is left here as a reference since it's useful for debugging if it's - // necessary to output values that are normalized to [0,q] to compare against intermediate results + // Not currently used. It is left here as a reference since it's useful for debugging if it's + // necessary to output values that are normalized to [0,q] to compare against intermediate results // from other libraries. // pub(crate) fn conditional_add_q(&mut self) { // for x in self.0.iter_mut() { @@ -157,7 +157,7 @@ impl Polynomial { // bc-java has a cond_sub_q() here, however, it is not needed // The reason for this is because a modular reduction is performed immediately // prior to calling pack_ciphertext in mlkem.rs - // This can be corroborated by running the corresponding unit tests + // This can be corroborated by running the corresponding unit tests // let mut s = self.clone(); // s.cond_sub_q(); @@ -250,8 +250,8 @@ impl Polynomial { v } - // Not currently used. It is left here as a reference since it's useful for debugging if it's - // necessary to output values that are normalized to [0,q] to compare against intermediate results + // Not currently used. It is left here as a reference since it's useful for debugging if it's + // necessary to output values that are normalized to [0,q] to compare against intermediate results // from other libraries. // pub(crate) fn cond_sub_q(&mut self) { // for i in 0..N { @@ -357,8 +357,8 @@ pub fn base_mult_montgomery(a: &Polynomial, b: &Polynomial) -> Polynomial { r } -// Not currently used. It is left here as a reference since it's useful for debugging if it's -// necessary to output values that are normalized to [0,q] to compare against intermediate results +// Not currently used. It is left here as a reference since it's useful for debugging if it's +// necessary to output values that are normalized to [0,q] to compare against intermediate results // from other libraries. // /// if a is in \[-q..0], then it shifts it up by q to be in \[0..q] // pub(crate) fn conditional_add_q(a: i16) -> i16 { diff --git a/crypto/mlkem/tests/mlkem_key_tests.rs b/crypto/mlkem/tests/mlkem_key_tests.rs index b3e1e4f3..24930d20 100644 --- a/crypto/mlkem/tests/mlkem_key_tests.rs +++ b/crypto/mlkem/tests/mlkem_key_tests.rs @@ -61,7 +61,6 @@ mod mlkem_key_tests { // 2) whether it calculates H(ek) properly from a private key // 3) whether it rejects a private key if the H(ek) is wrong - let seed = KeyMaterial512::from_bytes_as_type( &hex::decode( "000102030405060708090a0b0c0d0e0f diff --git a/crypto/rng/src/hash_drbg80090a.rs b/crypto/rng/src/hash_drbg80090a.rs index 5dc10136..be70cb8d 100644 --- a/crypto/rng/src/hash_drbg80090a.rs +++ b/crypto/rng/src/hash_drbg80090a.rs @@ -216,7 +216,7 @@ impl Sp80090ADrbg for HashDRBG80090A { "Provided seed exceeds the maximum seed length.", ))?; } - // On purpose not checking the SecurityStrength field of the seed, + // On purpose not checking the SecurityStrength field of the seed, // because we assume it's pure entropy and hasn't been touched by any actual algoritms yet. if security_strength > H::MAX_SECURITY_STRENGTH { return Err(KeyMaterialError::SecurityStrength( @@ -527,7 +527,7 @@ impl RNG for HashDRBG80090A { /// the hash_df function as defined in SP 800-90Ar1 section 10.3.1. /// no_of_bits_to_return is the length of the provided output buffer. -/// Because array concatenation is not available in a no_std / no_alloc build, this takes many input parameters. +/// Because array concatenation is not available in a no_std / no_alloc build, this takes many input parameters. // To leave a parameter unused, simply provide an empty array &[0u8;0] fn hash_df( in1: &[u8], @@ -550,7 +550,7 @@ fn hash_df( let len = u32::div_ceil(out.len() as u32, H::OUTPUT_LEN as u32); let mut counter: u8 = 0x01; - // note: this could probably be performance optimized a tiny bit by pulling no_of_bits_to_return.to_le_bytes() + // note: this could probably be performance optimized a tiny bit by pulling no_of_bits_to_return.to_le_bytes() // out of the loop and by merging i and counter into the same variable. for i in 1..len { let mut h = H::default(); @@ -566,7 +566,7 @@ fn hash_df( } // Handle the last block separately since not all of it will fit in the output buffer. - // TODO: Check whether it is necessary to do a last block, + // TODO: Check whether it is necessary to do a last block, // or was the requested number of bits already a multiple of the output length let bytes_written = (len - 1) as usize * H::OUTPUT_LEN; let remainder = out.len() - bytes_written; @@ -673,7 +673,7 @@ fn hashgen(v: &[u8], out: &mut [u8]) { } // Handle the last block separately since not all of it will fit in the output buffer. - // TODO: Check whether it is necessary to do a last block, + // TODO: Check whether it is necessary to do a last block, // or was the requested number of bits already a multiple of the output length let bytes_written = (m - 1) as usize * H::OUTPUT_LEN; let remainder = out.len() - bytes_written; diff --git a/crypto/rng/src/lib.rs b/crypto/rng/src/lib.rs index 1840f4c3..30403580 100644 --- a/crypto/rng/src/lib.rs +++ b/crypto/rng/src/lib.rs @@ -66,13 +66,13 @@ pub type Default128BitRNG = HashDRBG_SHA256; /// The library's default RNG at the 256-bit security level. pub type Default256BitRNG = HashDRBG_SHA512; -/// Implements the five functions specified in SP 800-90A section 7.4 are -/// - instantate, -/// - generate, -/// - reseed, -/// - uninstantiate, and +/// Implements the five functions specified in SP 800-90A section 7.4 are +/// - instantate, +/// - generate, +/// - reseed, +/// - uninstantiate, and /// - health_test. -/// Note: this function implements Rust's Drop on the sensitive working state in place of the explicit +/// Note: this function implements Rust's Drop on the sensitive working state in place of the explicit /// Uninstantiate function listed in SP 800-90Ar1. pub trait Sp80090ADrbg { /// The input KeyMaterial must be of type [`KeyType::Seed`]. @@ -91,7 +91,7 @@ pub trait Sp80090ADrbg { /// required. /// """ /// - /// This function takes ownership of the seed KeyMaterial object, + /// This function takes ownership of the seed KeyMaterial object, /// to reduce the likelihood of its reuse in a second function call. /// /// There is no entropy requirement on the nonce, but it is expected as a KeyMaterial so that it @@ -106,7 +106,7 @@ pub trait Sp80090ADrbg { ) -> Result<(), RNGError>; /// Reseeds the DRBG with the provided seed. - /// TODO: this needs to be redesigned to take some sort of EntropySource object that will work well + /// TODO: this needs to be redesigned to take some sort of EntropySource object that will work well // with DRBGs that require frequent reseeding. fn reseed( &mut self, diff --git a/crypto/rng/tests/hash_drbg80090a_tests.rs b/crypto/rng/tests/hash_drbg80090a_tests.rs index 840dacf4..4a8967b7 100644 --- a/crypto/rng/tests/hash_drbg80090a_tests.rs +++ b/crypto/rng/tests/hash_drbg80090a_tests.rs @@ -83,11 +83,11 @@ mod tests { _ => panic!("Expected KeyMaterialError error"), } - // Skipping tests for max lengths of seeds and personalization strings - // because they are on the order of a gigabyte in size. + // Skipping tests for max lengths of seeds and personalization strings + // because they are on the order of a gigabyte in size. // Testing would blow up the test suite. - // Error case: security strength requested at init is higher than the underlying + // Error case: security strength requested at init is higher than the underlying // hash function's max security strength let mut rng = HashDRBG_SHA256::new_unititialized(); let seed = KeyMaterial256::from_bytes_as_type(&DUMMY_SEED[..32], KeyType::Seed).unwrap(); @@ -96,7 +96,7 @@ mod tests { _ => panic!("Expected KeyMaterialError error"), } - // Success case: security strength requested at init is lower than the underlying + // Success case: security strength requested at init is lower than the underlying // hash function's max security strength // ... 112 bit let mut rng = HashDRBG_SHA256::new_unititialized(); @@ -156,10 +156,9 @@ mod tests { _ => panic!("Expected KeyMaterialError error"), } - // Skipping tests for max lengths of seeds and personalization strings - // because they are on the order of a gigabyte in size. + // Skipping tests for max lengths of seeds and personalization strings + // because they are on the order of a gigabyte in size. // Testing would blow up the test suite. - } #[test] @@ -194,8 +193,8 @@ mod tests { _ => panic!("Expected Uninitialized error"), } - // Skipping tests for max lengths of seeds and personalization strings - // because they are on the order of a gigabyte in size. + // Skipping tests for max lengths of seeds and personalization strings + // because they are on the order of a gigabyte in size. // Testing would blow up the test suite. // TODO: Tests for ReseedRequired. Investigate how this gets triggered. The limits are in the exobyte range. @@ -240,8 +239,8 @@ mod tests { _ => panic!("Expected Uninitialized error"), } - // Skipping tests for max lengths of seeds and personalization strings - // because they are on the order of a gigabyte in size. + // Skipping tests for max lengths of seeds and personalization strings + // because they are on the order of a gigabyte in size. // Testing would blow up the test suite. // TODO: tests for ReseedRequired. Investigate how this gets triggered. The limits are in the exobyte range. @@ -291,8 +290,8 @@ mod tests { Ok(_) => panic!("Expected Uninitialized error"), } - // Skipping tests for max lengths of seeds and personalization strings - // because they are on the order of a gigabyte in size. + // Skipping tests for max lengths of seeds and personalization strings + // because they are on the order of a gigabyte in size. // Testing would blow up the test suite. // TODO: tests for ReseedRequired. Investigate how this gets triggered. The limits are in the exobyte range. diff --git a/crypto/sha2/src/sha256.rs b/crypto/sha2/src/sha256.rs index 7cee95ca..34d09775 100644 --- a/crypto/sha2/src/sha256.rs +++ b/crypto/sha2/src/sha256.rs @@ -150,7 +150,7 @@ pub struct SHA256Internal { byte_count: u64, x_buf: Secret<[u8; 64]>, x_buf_off: usize, - // TODO: Investigate whether maximum message size (according to FIPS 180-4) should be added + // TODO: Investigate whether maximum message size (according to FIPS 180-4) should be added // (2^64 for SHA256 and 2^128 for SHA512) } diff --git a/crypto/sha2/src/sha512.rs b/crypto/sha2/src/sha512.rs index 60207eb7..c31e3065 100644 --- a/crypto/sha2/src/sha512.rs +++ b/crypto/sha2/src/sha512.rs @@ -161,7 +161,7 @@ pub struct SHA512Internal { _params: std::marker::PhantomData, state: Sha512State, // NOTE The code currently only supports 2^67 bits, not the full 2^128 - byte_count: u64, + byte_count: u64, x_buf: Secret<[u8; 128]>, x_buf_off: usize, } diff --git a/crypto/sha3/src/sha3.rs b/crypto/sha3/src/sha3.rs index 03935071..3da20b0b 100644 --- a/crypto/sha3/src/sha3.rs +++ b/crypto/sha3/src/sha3.rs @@ -169,14 +169,14 @@ impl Hash for SHA3Internal { output } - // TODO: investigate why this doesn't take a &mut [u8; HASH_LEN] + // TODO: investigate why this doesn't take a &mut [u8; HASH_LEN] // Being able to do so would improve ergonomics fn do_final_out(mut self, output: &mut [u8]) -> usize { output.fill(0); - // this shouldn't fail because, by construction, the function is only called once, + // this shouldn't fail because, by construction, the function is only called once, // and this is the only way to absorb partial bits. - self.keccak.absorb_bits(0x02, 2).expect("do_final_out: keccak.absorb_bits failed."); + self.keccak.absorb_bits(0x02, 2).expect("do_final_out: keccak.absorb_bits failed."); let bytes_written = if output.len() <= self.output_len() { self.keccak.squeeze(output) @@ -210,7 +210,7 @@ impl Hash for SHA3Internal { ) -> Result { output.fill(0); - // Mutants note: This is just bit-setting into empty space. + // Mutants note: This is just bit-setting into empty space. // It works the same regardless of whether it's OR or XOR. let mut final_input: u16 = ((partial_byte as u16) & ((1 << num_partial_bits) - 1)) | (0x02 << num_partial_bits); diff --git a/crypto/sha3/src/shake.rs b/crypto/sha3/src/shake.rs index 1bd91dc1..6ba2a882 100644 --- a/crypto/sha3/src/shake.rs +++ b/crypto/sha3/src/shake.rs @@ -92,7 +92,7 @@ impl SHAKEInternal { mut self, additional_input: &[u8], ) -> Result, KDFError> { - // At the moment, oversized KeyMaterial is returned for most cases. + // At the moment, oversized KeyMaterial is returned for most cases. let mut output_key = KeyMaterial::<64>::new(); self.derive_key_out_final_internal(additional_input, &mut output_key)?; @@ -307,7 +307,7 @@ impl XOF for SHAKEInternal { if !(1..=7).contains(&num_partial_bits) { return Err(HashError::InvalidLength("must be in the range [0,7]")); } - // Mutants note: This is just bit-setting into empty space. + // Mutants note: This is just bit-setting into empty space. // It works the same regardless of whether it's OR or XOR. let mut final_input: u16 = ((partial_byte as u16) & ((1 << num_partial_bits) - 1)) | (0x0F << num_partial_bits); From 519e4a4a523940a0f7e03bd2b567d8e2d83204ca Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 21 Aug 2026 11:29:11 -0500 Subject: [PATCH 11/28] Extend the ct.rs Condition to unsigned datatypes. PR #63 --- CLAUDE.md | 1 + QUALITY_AND_STYLE.md | 35 +- crypto/utils/src/ct.rs | 423 ++++++++++++--- crypto/utils/tests/ct_tests.rs | 949 +++++++++++++++++++++++++-------- 4 files changed, 1105 insertions(+), 303 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 97aac7eb..f177de91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,7 @@ These are non-obvious house rules โ€” follow them when writing or modifying code - `cargo mutants` is expected to be run on each crate; surviving mutants must be investigated but not all need to die (e.g. XOR/OR equivalences in crypto code are acceptable). Config lives in `.cargo/mutants.toml` (output dir `custom_mutants_output/`). - Behaviour-critical private functions can use in-file `#[cfg(test)] mod tests` blocks when they can't be exercised from outside the crate. - For traits in `core`, the canonical tests live in `core-test-framework` and are invoked from each implementor's integration tests โ€” don't duplicate them per-implementation. +- The per-width `impl Condition` blocks in `crypto/utils/src/ct.rs` (and their test modules) are deliberately duplicated rather than macro-generated: `cargo mutants` cannot see into `macro_rules!` bodies, so a macro would hide the mask identities from mutation testing. Do not fold them back into a macro. Any change to one width in a group (i64/i32, u64/u32) must be applied to every width in that group. ## CI diff --git a/QUALITY_AND_STYLE.md b/QUALITY_AND_STYLE.md index c1d2071b..65f7e7e0 100644 --- a/QUALITY_AND_STYLE.md +++ b/QUALITY_AND_STYLE.md @@ -1,5 +1,5 @@ -This document lists general quality and style guidelines used across the library. -Hint: ask an AI to help review your PR against this style guide. +This document lists general quality and style guidelines used across the library. Hint: ask an AI to help review your PR +against this style guide. # Architecture @@ -77,20 +77,20 @@ All normal rust naming convensions from clippy apply. In addition, some library- Where possible, primitives should expose "one-shot APIs" that simply take data and return a result as a static member function that does not require object instantiation. -Other version of Bouncy Castle have a design pattern where stateful objects follow a pattern of new() -> init() -> -do_update() -> do_final(), and then optionally reset() that sets the object back to an unitialized state. Instead, -bc-rust does not have init() functions (moving this logic into new() or from() as appropriate), and consequently it also -does not have reset(). Also, we take advantage of the rust borrow checker's syntax so that all do_final() functions are -actually final, in other words they must take ownership of self `do_final(self, ...)` so that no subsequent calls can be -made to this object (as opposed to the usual pattern of taking a ref to self as in `do_update(&self, ...)`). These -tricks go a long way to reducing fallibility since now in general there is no (or very very little) object state to -track and return errors about. +Other version of Bouncy Castle have a design pattern where stateful objects follow a pattern of new () -> init () -> +do_update () -> do_final (), and then optionally reset () that sets the object back to an unitialized state. Instead, +bc-rust does not have init () functions (moving this logic into new () or from () as appropriate), and consequently it +also does not have reset (). Also, we take advantage of the rust borrow checker's syntax so that all do_final () +functions are actually final, in other words they must take ownership of self `do_final(self, ...)` so that no +subsequent calls can be made to this object (as opposed to the usual pattern of taking a ref to self as in +`do_update(&self, ...)`). These tricks go a long way to reducing fallibility since now in general there is no (or very +very little) object state to track and return errors about. Any struct that holds sensitive data must impl the `core::Secret` trait and all associated super-traits. ## Fallibility -As much as humanly possible, Result and unwrap() should be used for "Bad input data" type things and not "Programmer +As much as humanly possible, Result and unwrap () should be used for "Bad input data" type things and not "Programmer didn't read the docs" type things. `.unwrap()` causes system crashes. The use of `.unwrap()` should always be preceeded by testing that we're in a state @@ -115,6 +115,19 @@ tracks it. Use `./dev_scripts/quality_stats.sh` to see the fallibility metrics for the crate you're working on and try to get those numbers down. +## Macros + +Fundamentally, macros are an optimization that allows future maintainers to easily add existing boilerplate code to a +new type. That said, macros are typically more complex, harder to code review, and harder to debug than the unrolled +boilerplate code that they are replacing. + +Any PR that uses macros will need to justify that the macros are clearly reducing future maintainer complexity compared +to the equivalent unrolled code. Simply reducing the number of lines of code is not a sufficient justification. + +Note that rust macros tend not to play well with a lot of dev tooling for compiler errors, debuggers, profilers, and +`cargo mutants`, which is a good reason to avoid macros in core algorithm or data processing code. Macros can be used +more freely within test code. + # Docs ## Usage Examples diff --git a/crypto/utils/src/ct.rs b/crypto/utils/src/ct.rs index 464511a6..6238bf28 100644 --- a/crypto/utils/src/ct.rs +++ b/crypto/utils/src/ct.rs @@ -14,16 +14,14 @@ struct MaskType(core::marker::PhantomData); trait SupportedMaskType: sealed::Sealed {} -macro_rules! supported_mask_type { - ($($t:ty),+) => { - $( - impl sealed::Sealed for MaskType<$t> {} - impl SupportedMaskType for MaskType<$t> {} - )+ - }; -} - -supported_mask_type!(i64, u64); +impl sealed::Sealed for MaskType {} +impl SupportedMaskType for MaskType {} +impl sealed::Sealed for MaskType {} +impl SupportedMaskType for MaskType {} +impl sealed::Sealed for MaskType {} +impl SupportedMaskType for MaskType {} +impl sealed::Sealed for MaskType {} +impl SupportedMaskType for MaskType {} /// Helper functions for checking some condition on some data using constant-time operations. #[derive(Clone, Copy)] @@ -35,65 +33,90 @@ where impl Condition where MaskType: SupportedMaskType {} +// Each signed width is written out by hand rather than macro-generated: `cargo mutants` +// cannot see into macro bodies, and these mask identities are the ones most worth +// mutating. The signed widths must be edited together: `Condition` and `Condition` +// are copies of each other modulo the width token. impl Condition { - // TODO: there are a bunch of impls in here that seem to be generic and not related to i64, - // could those be moved to a generic impl for Condition ? - /// TRUE is the bit vector of all 1's pub const TRUE: Self = Self(-1); /// FALSE is the bit vector of all 0's pub const FALSE: Self = Self(0); + + /// Constant-time mask generation from a compile-time boolean. /// - pub const fn from_bool() -> Self { + /// Signed types rely on two's complement negation: `-(true as i64)` is `-1` + /// (all 1s) and `-(false as i64)` is `0` (all 0s). + pub const fn from_bool_const() -> Self { Self(-(VALUE as i64)) } - /// - pub const fn from_bool_var(value: bool) -> Self { + /// Constant-time mask generation from a runtime boolean. + pub const fn from_bool(value: bool) -> Self { Self(-(value as i64)) } - /// - pub const fn is_bit_set(value: i64, bit: i64) -> Self { - Self(-((value >> bit) & 1)) + /// Mask from the least-significant bit: TRUE iff bit 0 of `value` is set. + /// This is the parity test: `from_lsb(x)` is TRUE iff `x` is odd. It is + /// the `bit = 0` special case of [`Self::is_bit_set`]. + pub const fn from_lsb(value: i64) -> Self { + Self(-(value & 1)) } - /// + /// TRUE iff bit `bit` of `value` is set. The bit index must be public data + /// (the shift amount is timing-visible on some targets). + pub const fn is_bit_set(value: i64, bit: u32) -> Self { + Self::from_lsb(value >> bit) + } + /// TRUE iff `value < 0`, i.e. the sign (top) bit is set. The unsigned + /// counterpart of this mask is `from_msb`, where the top bit carries a + /// borrow/carry instead of a sign. pub const fn is_negative(value: i64) -> Self { - Self(value >> 63) + // Arithmetic shift replicates the sign bit across the whole word. + Self(value >> (i64::BITS - 1)) } + /// TRUE iff `value != 0`. /// + /// For any nonzero `x`, `x | x.wrapping_neg()` is negative (either `x` or its + /// two's complement has the top bit set); for zero both sides are zero. + /// `wrapping_neg` is required: plain negation overflows at `MIN`. pub const fn is_not_zero(value: i64) -> Self { - Self::is_negative(-Self::or_halves(value)) + Self::is_negative(value | value.wrapping_neg()) } - /// + /// TRUE iff `value == 0`. pub const fn is_zero(value: i64) -> Self { - Self::is_negative(Self::or_halves(value) - 1) + // Complementing the inner value maps TRUE <-> FALSE (all 1s <-> all 0s). + Self(!Self::is_not_zero(value).0) } - /// + /// TRUE iff `x == y`. pub const fn is_equal(x: i64, y: i64) -> Self { Self::is_zero(x ^ y) } + /// TRUE iff `x < y`, for the full signed range. /// + /// The naive `is_negative(x - y)` is wrong whenever `x - y` overflows + /// (e.g. `MIN < 1`): in debug it panics, in release it wraps to the opposite + /// answer. This is the standard overflow-free signed-comparison identity: when + /// the signs of `x` and `y` differ the answer is the sign of `x`; when they + /// agree the difference cannot overflow, so the answer is the sign of `x - y`. pub const fn is_lt(x: i64, y: i64) -> Self { - Self::is_negative(x - y) + Self(((x & !y) | (!(x ^ y) & x.wrapping_sub(y))) >> (i64::BITS - 1)) } - /// - // Note: this cannot currently be marked as const, since it either needs a (non-const) not (!) or a boolean OR is_zero. - pub fn is_lte(x: i64, y: i64) -> Self { - !Self::is_gt(x, y) + /// TRUE iff `x <= y`. + pub const fn is_lte(x: i64, y: i64) -> Self { + // Complementing the inner value maps TRUE <-> FALSE (all 1s <-> all 0s). + Self(!Self::is_gt(x, y).0) } - /// + /// TRUE iff `x > y`. pub const fn is_gt(x: i64, y: i64) -> Self { Self::is_lt(y, x) } - /// - // Note: this cannot currently be marked as const, since it either needs a (non-const) not (!) or a boolean OR is_zero. - pub fn is_gte(x: i64, y: i64) -> Self { - !Self::is_lt(x, y) + /// TRUE iff `x >= y`. + pub const fn is_gte(x: i64, y: i64) -> Self { + Self(!Self::is_lt(x, y).0) } - /// - pub fn is_within_range(value: i64, min: i64, max: i64) -> Self { - Self::is_gte(value, min) & Self::is_lte(value, max) + /// TRUE iff `min <= value <= max`. + pub const fn is_within_range(value: i64, min: i64, max: i64) -> Self { + Self(Self::is_gte(value, min).0 & Self::is_lte(value, max).0) } - /// + /// TRUE iff `value` occurs in `list`. The list contents and length are public. pub fn is_in_list(value: i64, list: &[i64]) -> Self { // Research question: is this actually constant-time? // A clever compiler might turn this into a short-circuiting loop. @@ -103,13 +126,14 @@ impl Condition { let mut c = Self::FALSE; for i in 0..list.len() { let diff = value ^ list[i]; - c |= Condition::::is_zero(diff); + c |= Self::is_zero(diff); } c } - /// Conditionally move the source value to the destination if the condition is true, otherwise nothing is moved. + /// Conditionally move the source value to the destination if the condition is + /// true, otherwise nothing is moved. pub fn mov(self, src: i64, dst: &mut i64) { *dst = self.select(src, *dst); } @@ -132,25 +156,165 @@ impl Condition { /// /// As a result, `1`, which is the negation of `-1`, should be returned, but `-3` is output. /// - /// Therefore, if the [`Self::TRUE`] constant value of the i64 [`Condition`] implementation is changed to `-1`, + /// Therefore, if the [`Self::TRUE`] constant value of the [`Condition`] implementation is changed to `-1`, /// the test also runs normally. pub const fn negate(self, value: i64) -> i64 { (value ^ self.0).wrapping_sub(self.0) } - /// - pub const fn or_halves(value: i64) -> i64 { - (value | (value >> 32)) & 0xFFFFFFFF - } - /// Conditional selection: return `true_value` if the condition is true, otherwise return `false_value`. + /// Conditional selection: return `true_value` if the condition is true, otherwise + /// return `false_value`. pub const fn select(self, true_value: i64, false_value: i64) -> i64 { (true_value & self.0) | (false_value & !self.0) } - /// Conditional swap: returns (lhs, rhs) if the condition is true, otherwise returns (rhs, lhs). + /// Conditional swap: returns (lhs, rhs) if the condition is true, otherwise + /// returns (rhs, lhs). pub const fn swap(self, lhs: i64, rhs: i64) -> (i64, i64) { (self.select(rhs, lhs), self.select(lhs, rhs)) } + /// Convert the mask to a runtime boolean. Only use this at genuine public + /// decision points: branching on the result leaks the condition's value. + pub const fn to_bool(self) -> bool { + self.0 != 0 + } +} + +impl Condition { + /// TRUE is the bit vector of all 1's + pub const TRUE: Self = Self(-1); + /// FALSE is the bit vector of all 0's + pub const FALSE: Self = Self(0); + + /// Constant-time mask generation from a compile-time boolean. + /// + /// Signed types rely on two's complement negation: `-(true as i32)` is `-1` + /// (all 1s) and `-(false as i32)` is `0` (all 0s). + pub const fn from_bool_const() -> Self { + Self(-(VALUE as i32)) + } + /// Constant-time mask generation from a runtime boolean. + pub const fn from_bool(value: bool) -> Self { + Self(-(value as i32)) + } + /// Mask from the least-significant bit: TRUE iff bit 0 of `value` is set. + /// This is the parity test: `from_lsb(x)` is TRUE iff `x` is odd. It is + /// the `bit = 0` special case of [`Self::is_bit_set`]. + pub const fn from_lsb(value: i32) -> Self { + Self(-(value & 1)) + } + /// TRUE iff bit `bit` of `value` is set. The bit index must be public data + /// (the shift amount is timing-visible on some targets). + pub const fn is_bit_set(value: i32, bit: u32) -> Self { + Self::from_lsb(value >> bit) + } + /// TRUE iff `value < 0`, i.e. the sign (top) bit is set. The unsigned + /// counterpart of this mask is `from_msb`, where the top bit carries a + /// borrow/carry instead of a sign. + pub const fn is_negative(value: i32) -> Self { + // Arithmetic shift replicates the sign bit across the whole word. + Self(value >> (i32::BITS - 1)) + } + /// TRUE iff `value != 0`. /// - pub const fn to_bool_var(self) -> bool { + /// For any nonzero `x`, `x | x.wrapping_neg()` is negative (either `x` or its + /// two's complement has the top bit set); for zero both sides are zero. + /// `wrapping_neg` is required: plain negation overflows at `MIN`. + pub const fn is_not_zero(value: i32) -> Self { + Self::is_negative(value | value.wrapping_neg()) + } + /// TRUE iff `value == 0`. + pub const fn is_zero(value: i32) -> Self { + // Complementing the inner value maps TRUE <-> FALSE (all 1s <-> all 0s). + Self(!Self::is_not_zero(value).0) + } + /// TRUE iff `x == y`. + pub const fn is_equal(x: i32, y: i32) -> Self { + Self::is_zero(x ^ y) + } + /// TRUE iff `x < y`, for the full signed range. + /// + /// The naive `is_negative(x - y)` is wrong whenever `x - y` overflows + /// (e.g. `MIN < 1`): in debug it panics, in release it wraps to the opposite + /// answer. This is the standard overflow-free signed-comparison identity: when + /// the signs of `x` and `y` differ the answer is the sign of `x`; when they + /// agree the difference cannot overflow, so the answer is the sign of `x - y`. + pub const fn is_lt(x: i32, y: i32) -> Self { + Self(((x & !y) | (!(x ^ y) & x.wrapping_sub(y))) >> (i32::BITS - 1)) + } + /// TRUE iff `x <= y`. + pub const fn is_lte(x: i32, y: i32) -> Self { + // Complementing the inner value maps TRUE <-> FALSE (all 1s <-> all 0s). + Self(!Self::is_gt(x, y).0) + } + /// TRUE iff `x > y`. + pub const fn is_gt(x: i32, y: i32) -> Self { + Self::is_lt(y, x) + } + /// TRUE iff `x >= y`. + pub const fn is_gte(x: i32, y: i32) -> Self { + Self(!Self::is_lt(x, y).0) + } + /// TRUE iff `min <= value <= max`. + pub const fn is_within_range(value: i32, min: i32, max: i32) -> Self { + Self(Self::is_gte(value, min).0 & Self::is_lte(value, max).0) + } + /// TRUE iff `value` occurs in `list`. The list contents and length are public. + pub fn is_in_list(value: i32, list: &[i32]) -> Self { + // Research question: is this actually constant-time? + // A clever compiler might turn this into a short-circuiting loop. + // A quick google search shows that rust doesn't have the ability to annotate specific code blocks + // as no-optimize; the only option is to insert direct assembly. + + let mut c = Self::FALSE; + for i in 0..list.len() { + let diff = value ^ list[i]; + c |= Self::is_zero(diff); + } + + c + } + + /// Conditionally move the source value to the destination if the condition is + /// true, otherwise nothing is moved. + pub fn mov(self, src: i32, dst: &mut i32) { + *dst = self.select(src, *dst); + } + + /// Conditionally negate the value. + /// + /// negate(-1) gives -3 + /// + /// `value` is `-1` (i.e., all bits are `1`, `...1111`) + /// + /// Condition `self.0` is 1 (`...0001`) (assuming `TRUE`) + /// + /// XOR operation was executed as `value ^ self.0` + /// + /// Then `...1111 XOR ...0001 = ...1110` (i.e., `-2`) + /// + /// Subtraction operation is `wrapping_sub(self.0)` + /// + /// Then `-2 - 1 = -3` + /// + /// As a result, `1`, which is the negation of `-1`, should be returned, but `-3` is output. + /// + /// Therefore, if the [`Self::TRUE`] constant value of the [`Condition`] implementation is changed to `-1`, + /// the test also runs normally. + pub const fn negate(self, value: i32) -> i32 { + (value ^ self.0).wrapping_sub(self.0) + } + /// Conditional selection: return `true_value` if the condition is true, otherwise + /// return `false_value`. + pub const fn select(self, true_value: i32, false_value: i32) -> i32 { + (true_value & self.0) | (false_value & !self.0) + } + /// Conditional swap: returns (lhs, rhs) if the condition is true, otherwise + /// returns (rhs, lhs). + pub const fn swap(self, lhs: i32, rhs: i32) -> (i32, i32) { + (self.select(rhs, lhs), self.select(lhs, rhs)) + } + /// Convert the mask to a runtime boolean. Only use this at genuine public + /// decision points: branching on the result leaks the condition's value. + pub const fn to_bool(self) -> bool { self.0 != 0 } } @@ -159,28 +323,165 @@ impl Condition { // then and change Hex and Base64 to use this. // (there's probably no noticeable performance difference u8 and u64 bit ops on a 64-bit machine, // but there would be on a 8, 16, or 32-bit machine.) +// +// Each unsigned width is written out by hand rather than macro-generated: `cargo mutants` +// cannot see into macro bodies, and these mask identities are the ones most worth +// mutating. The unsigned widths must be edited together: `Condition` and `Condition` +// are copies of each other modulo the width token. Ordering comparisons are deliberately +// omitted: multi-word callers derive `lt` from their subtraction borrow chain and convert it +// with `from_msb`. impl Condition { /// TRUE is the bit vector of all 1's pub const TRUE: Self = Self(u64::MAX); /// FALSE is the bit vector of all 0's pub const FALSE: Self = Self(0); - /// this is the core logic for constant-time mask generation for unsigned integers - /// Unlike signed integers where we can rely on Two's Complement via negation `-(v as i64)`, - /// for u64 we must use wrapping subtraction to achieve the all-ones bit pattern (u64::MAX) for true - pub const fn from_bool() -> Self { - // If VALUE is true (1) -> 0 - 1 = u64::MAX (All 1s) - // If VALUE is false (0) -> 0 - 0 = 0 (All 0s) + /// Constant-time mask generation from a compile-time boolean. + /// + /// Unlike signed integers where we can rely on Two's Complement via negation + /// `-(v as i64)`, for unsigned types we must use wrapping subtraction to achieve + /// the all-ones bit pattern for true: + /// true (1) -> `0 - 1` wraps to MAX (all 1s); false (0) -> `0 - 0 = 0` (all 0s). + pub const fn from_bool_const() -> Self { Self(0u64.wrapping_sub(VALUE as u64)) } - /// impl the select function manually for u64 - /// although a fully generic `impl` would be the ultimate long-term goal - pub fn select(self, a: u64, b: u64) -> u64 { - let mask = self.0; - (a & mask) | (b & !mask) + /// Constant-time mask generation from a runtime boolean. + pub const fn from_bool(value: bool) -> Self { + Self(0u64.wrapping_sub(value as u64)) + } + /// Mask from the least-significant bit: TRUE iff bit 0 of `value` is set. + /// This is the parity test: `from_lsb(x)` is TRUE iff `x` is odd. It is + /// the `bit = 0` special case of [`Self::is_bit_set`]. + pub const fn from_lsb(value: u64) -> Self { + Self(0u64.wrapping_sub(value & 1)) + } + /// Mask from the most-significant bit: TRUE iff the top bit of `value` is set. + /// The signed counterpart of this mask is `is_negative`, where the top bit + /// carries a sign instead of a borrow/carry. + /// + /// This is the borrow/carry adaptor: the borrow word coming out of a wrapping + /// wide subtraction chain carries its meaning entirely in the top bit, so + /// `from_msb(borrow)` is the `lt` mask of that comparison with no further work. + pub const fn from_msb(value: u64) -> Self { + Self(0u64.wrapping_sub(value >> (u64::BITS - 1))) + } + /// TRUE iff bit `bit` of `value` is set. The bit index must be public data + /// (the shift amount is timing-visible on some targets). + pub const fn is_bit_set(value: u64, bit: u32) -> Self { + Self::from_lsb(value >> bit) + } + /// TRUE iff `value != 0`. + /// + /// For any nonzero `x`, `x | x.wrapping_neg()` has the top bit set (either `x` + /// or its two's complement is >= 2^(BITS-1)); for zero both sides are zero. + pub const fn is_not_zero(value: u64) -> Self { + Self::from_msb(value | value.wrapping_neg()) + } + /// TRUE iff `value == 0`. + pub const fn is_zero(value: u64) -> Self { + // Complementing the inner value maps TRUE <-> FALSE (all 1s <-> all 0s). + Self(!Self::is_not_zero(value).0) + } + /// TRUE iff `x == y`. + pub const fn is_equal(x: u64, y: u64) -> Self { + Self::is_zero(x ^ y) + } + /// Conditional selection: return `true_value` if the condition is true, otherwise + /// return `false_value`. + pub const fn select(self, true_value: u64, false_value: u64) -> u64 { + (true_value & self.0) | (false_value & !self.0) } + /// Conditionally move the source value to the destination if the condition is + /// true, otherwise nothing is moved. + pub fn mov(self, src: u64, dst: &mut u64) { + *dst = self.select(src, *dst); + } + /// Conditional swap: returns (lhs, rhs) if the condition is true, otherwise + /// returns (rhs, lhs). + pub const fn swap(self, lhs: u64, rhs: u64) -> (u64, u64) { + (self.select(rhs, lhs), self.select(lhs, rhs)) + } + /// Convert the mask to a runtime boolean. Only use this at genuine public + /// decision points: branching on the result leaks the condition's value. + pub const fn to_bool(self) -> bool { + self.0 != 0 + } +} + +impl Condition { + /// TRUE is the bit vector of all 1's + pub const TRUE: Self = Self(u32::MAX); + /// FALSE is the bit vector of all 0's + pub const FALSE: Self = Self(0); + + /// Constant-time mask generation from a compile-time boolean. + /// + /// Unlike signed integers where we can rely on Two's Complement via negation + /// `-(v as i64)`, for unsigned types we must use wrapping subtraction to achieve + /// the all-ones bit pattern for true: + /// true (1) -> `0 - 1` wraps to MAX (all 1s); false (0) -> `0 - 0 = 0` (all 0s). + pub const fn from_bool_const() -> Self { + Self(0u32.wrapping_sub(VALUE as u32)) + } + /// Constant-time mask generation from a runtime boolean. + pub const fn from_bool(value: bool) -> Self { + Self(0u32.wrapping_sub(value as u32)) + } + /// Mask from the least-significant bit: TRUE iff bit 0 of `value` is set. + /// This is the parity test: `from_lsb(x)` is TRUE iff `x` is odd. It is + /// the `bit = 0` special case of [`Self::is_bit_set`]. + pub const fn from_lsb(value: u32) -> Self { + Self(0u32.wrapping_sub(value & 1)) + } + /// Mask from the most-significant bit: TRUE iff the top bit of `value` is set. + /// The signed counterpart of this mask is `is_negative`, where the top bit + /// carries a sign instead of a borrow/carry. /// - pub fn is_true(&self) -> bool { + /// This is the borrow/carry adaptor: the borrow word coming out of a wrapping + /// wide subtraction chain carries its meaning entirely in the top bit, so + /// `from_msb(borrow)` is the `lt` mask of that comparison with no further work. + pub const fn from_msb(value: u32) -> Self { + Self(0u32.wrapping_sub(value >> (u32::BITS - 1))) + } + /// TRUE iff bit `bit` of `value` is set. The bit index must be public data + /// (the shift amount is timing-visible on some targets). + pub const fn is_bit_set(value: u32, bit: u32) -> Self { + Self::from_lsb(value >> bit) + } + /// TRUE iff `value != 0`. + /// + /// For any nonzero `x`, `x | x.wrapping_neg()` has the top bit set (either `x` + /// or its two's complement is >= 2^(BITS-1)); for zero both sides are zero. + pub const fn is_not_zero(value: u32) -> Self { + Self::from_msb(value | value.wrapping_neg()) + } + /// TRUE iff `value == 0`. + pub const fn is_zero(value: u32) -> Self { + // Complementing the inner value maps TRUE <-> FALSE (all 1s <-> all 0s). + Self(!Self::is_not_zero(value).0) + } + /// TRUE iff `x == y`. + pub const fn is_equal(x: u32, y: u32) -> Self { + Self::is_zero(x ^ y) + } + /// Conditional selection: return `true_value` if the condition is true, otherwise + /// return `false_value`. + pub const fn select(self, true_value: u32, false_value: u32) -> u32 { + (true_value & self.0) | (false_value & !self.0) + } + /// Conditionally move the source value to the destination if the condition is + /// true, otherwise nothing is moved. + pub fn mov(self, src: u32, dst: &mut u32) { + *dst = self.select(src, *dst); + } + /// Conditional swap: returns (lhs, rhs) if the condition is true, otherwise + /// returns (rhs, lhs). + pub const fn swap(self, lhs: u32, rhs: u32) -> (u32, u32) { + (self.select(rhs, lhs), self.select(lhs, rhs)) + } + /// Convert the mask to a runtime boolean. Only use this at genuine public + /// decision points: branching on the result leaks the condition's value. + pub const fn to_bool(self) -> bool { self.0 != 0 } } diff --git a/crypto/utils/tests/ct_tests.rs b/crypto/utils/tests/ct_tests.rs index 8e9be769..7bb8ef0f 100644 --- a/crypto/utils/tests/ct_tests.rs +++ b/crypto/utils/tests/ct_tests.rs @@ -4,368 +4,855 @@ use bouncycastle_utils::ct::Condition; /// (and example usages) #[cfg(test)] -mod i64_tests { +mod generic_impl_tests { use super::*; #[test] - fn const_tests() { - assert_eq!(Condition::::TRUE.to_bool_var(), true); - assert_eq!(Condition::::FALSE.to_bool_var(), false); + fn test_bit_and() { + let ct1 = Condition::::from_bool_const::(); + let ct2 = Condition::::from_bool_const::(); + let cf1 = Condition::::from_bool_const::(); + let cf2 = Condition::::from_bool_const::(); + assert_eq!((ct1 & ct2).to_bool(), true); + assert_eq!((ct1 & cf1).to_bool(), false); + assert_eq!((cf1 & cf2).to_bool(), false); } #[test] - fn from_bool() { - assert_eq!(Condition::::from_bool::().to_bool_var(), true); - assert_eq!(Condition::::from_bool::().to_bool_var(), false); + fn test_bit_and_assign() { + let mut ct1 = Condition::::from_bool_const::(); + let ct2 = Condition::::from_bool_const::(); + let cf = Condition::::from_bool_const::(); + + ct1 &= ct2; + assert_eq!(ct1.to_bool(), true); - let btrue: bool = true; - let bfalse: bool = false; - assert_eq!(Condition::::from_bool_var(btrue).to_bool_var(), true); - assert_eq!(Condition::::from_bool_var(bfalse).to_bool_var(), false); + ct1 &= cf; + assert_eq!(ct1.to_bool(), false); } #[test] - fn is_bit_set() { - assert_eq!(Condition::::is_bit_set(1, 0).to_bool_var(), true); - assert_eq!(Condition::::is_bit_set(1, 1).to_bool_var(), false); - assert_eq!(Condition::::is_bit_set(8, 3).to_bool_var(), true); + fn test_bit_or() { + let ct1 = Condition::::from_bool_const::(); + let ct2 = Condition::::from_bool_const::(); + let cf1 = Condition::::from_bool_const::(); + let cf2 = Condition::::from_bool_const::(); + + assert_eq!((ct1 | ct2).to_bool(), true); + assert_eq!((ct1 | cf1).to_bool(), true); + assert_eq!((cf1 | cf2).to_bool(), false); } #[test] - fn is_negative() { - assert_eq!(Condition::::is_negative(-1).to_bool_var(), true); - assert_eq!(Condition::::is_negative(0).to_bool_var(), false); - assert_eq!(Condition::::is_negative(1).to_bool_var(), false); - assert_eq!(Condition::::is_negative(1 << 12).to_bool_var(), false); + fn test_bit_or_assign() { + let mut ct1 = Condition::::from_bool_const::(); + let ct2 = Condition::::from_bool_const::(); + let mut cf1 = Condition::::from_bool_const::(); + let cf2 = Condition::::from_bool_const::(); + + ct1 |= ct2; + assert_eq!(ct1.to_bool(), true); + + ct1 |= cf1; + assert_eq!(ct1.to_bool(), true); + + cf1 |= cf2; + assert_eq!(cf1.to_bool(), false); } #[test] - fn is_not_zero() { - assert_eq!(Condition::::is_not_zero(1).to_bool_var(), true); - assert_eq!(Condition::::is_not_zero(0).to_bool_var(), false); - assert_eq!(Condition::::is_not_zero(1 << 12).to_bool_var(), true); - assert_eq!(Condition::::is_not_zero(-10).to_bool_var(), true); + fn test_bit_xor() { + let ct1 = Condition::::from_bool_const::(); + let ct2 = Condition::::from_bool_const::(); + let cf1 = Condition::::from_bool_const::(); + let cf2 = Condition::::from_bool_const::(); + + assert_eq!((ct1 ^ ct2).to_bool(), false); + assert_eq!((ct1 ^ cf1).to_bool(), true); + assert_eq!((cf1 ^ cf2).to_bool(), false); } #[test] - fn is_zero() { - assert_eq!(Condition::::is_zero(1).to_bool_var(), false); - assert_eq!(Condition::::is_zero(0).to_bool_var(), true); - assert_eq!(Condition::::is_zero(1 << 12).to_bool_var(), false); + fn test_bit_xor_assign() { + let mut ct1 = Condition::::from_bool_const::(); + let mut ct2 = Condition::::from_bool_const::(); + let mut cf1 = Condition::::from_bool_const::(); + let cf2 = Condition::::from_bool_const::(); + + ct1 ^= ct2; + assert_eq!(ct1.to_bool(), false); + + ct2 ^= cf1; + assert_eq!(ct2.to_bool(), true); + + cf1 ^= cf2; + assert_eq!(cf1.to_bool(), false); } #[test] - fn is_equal() { - assert_eq!(Condition::::is_equal(1, 1).to_bool_var(), true); - assert_eq!(Condition::::is_equal(1, 2).to_bool_var(), false); - assert_eq!(Condition::::is_equal(1, -1).to_bool_var(), false); + fn test_not() { + let c = Condition::::from_bool_const::(); + assert_eq!((!c).to_bool(), false); + } +} + +/// One test module per unsigned width, written out by hand like the impls they exercise; the +/// u64 and u32 modules must be edited together so the widths stay at exact behavioural +/// parity. Boundary set: 0, 1, MAX, MAX-1, 1 << (BITS-1) โ€” the values where +/// signed-representation tricks would produce wrong masks if they had leaked into the +/// unsigned constructions. +#[cfg(test)] +mod unsigned_u64_tests { + use super::*; + + const MSB: u64 = 1 << (u64::BITS - 1); + const BOUNDARY: [u64; 5] = [0, 1, u64::MAX, u64::MAX - 1, MSB]; + + /// A mask must be exactly all-ones or all-zeros: `to_bool` only tests non-zero, + /// so a constructor returning `1` instead of all-ones would pass it and then + /// quietly corrupt every select it feeds. The two select operands differ in + /// every bit, so the assertion holds exactly when the mask is canonical. + fn assert_canonical(cond: Condition, expected: bool) { + const PATTERN: u64 = (0x5555_5555_5555_5555_u64 & (u64::MAX as u64)) as u64; + let selected = cond.select(PATTERN, !PATTERN); + assert_eq!(selected, if expected { PATTERN } else { !PATTERN }); + assert_eq!(cond.to_bool(), expected); + } + + #[test] + fn consts() { + assert_canonical(Condition::::TRUE, true); + assert_canonical(Condition::::FALSE, false); } #[test] - fn is_lt() { - assert_eq!(Condition::::is_lt(1, 2).to_bool_var(), true); - assert_eq!(Condition::::is_lt(2, 1).to_bool_var(), false); - assert_eq!(Condition::::is_lt(2, 2).to_bool_var(), false); - assert_eq!(Condition::::is_lt(0, 1).to_bool_var(), true); - assert_eq!(Condition::::is_lt(-100, -99).to_bool_var(), true); - assert_eq!(Condition::::is_lt(-98, 98).to_bool_var(), true); + fn from_bool() { + assert_canonical(Condition::::from_bool_const::(), true); + assert_canonical(Condition::::from_bool_const::(), false); + assert_canonical(Condition::::from_bool(true), true); + assert_canonical(Condition::::from_bool(false), false); + } - let mut i: i64 = 0; - assert_eq!(Condition::::is_lt(i, 1).to_bool_var(), true); - assert_eq!(Condition::::is_lt(i, -1).to_bool_var(), false); - i = 1; - assert_eq!(Condition::::is_lt(i, 1).to_bool_var(), false); + #[test] + fn from_lsb() { + for v in BOUNDARY { + assert_canonical(Condition::::from_lsb(v), v & 1 == 1); + } } #[test] - fn is_lte() { - assert_eq!(Condition::::is_lte(1, 2).to_bool_var(), true); - assert_eq!(Condition::::is_lte(2, 1).to_bool_var(), false); - assert_eq!(Condition::::is_lte(2, 2).to_bool_var(), true); - assert_eq!(Condition::::is_lte(0, 1).to_bool_var(), true); - assert_eq!(Condition::::is_lte(-100, -99).to_bool_var(), true); - assert_eq!(Condition::::is_lte(-98, 98).to_bool_var(), true); + fn from_msb() { + for v in BOUNDARY { + assert_canonical(Condition::::from_msb(v), v >> (u64::BITS - 1) == 1); + } } + /// `from_msb` exists to convert the borrow word of a wrapping subtraction chain + /// into a mask: `(x - y) >> BITS` of the widening subtraction is all-ones in the + /// low word iff x < y. #[test] - fn is_gt() { - assert_eq!(Condition::::is_gt(1, 2).to_bool_var(), false); - assert_eq!(Condition::::is_gt(2, 1).to_bool_var(), true); - assert_eq!(Condition::::is_gt(2, 2).to_bool_var(), false); - assert_eq!(Condition::::is_gt(0, 1).to_bool_var(), false); - assert_eq!(Condition::::is_gt(-100, -99).to_bool_var(), false); - assert_eq!(Condition::::is_gt(-98, 98).to_bool_var(), false); + fn from_msb_as_borrow_adaptor() { + for x in BOUNDARY { + for y in BOUNDARY { + let borrow = ((x as u128).wrapping_sub(y as u128) >> u64::BITS) as u64; + assert_canonical(Condition::::from_msb(borrow), x < y); + } + } } #[test] - fn is_gte() { - assert_eq!(Condition::::is_gte(1, 2).to_bool_var(), false); - assert_eq!(Condition::::is_gte(2, 1).to_bool_var(), true); - assert_eq!(Condition::::is_gte(2, 2).to_bool_var(), true); - assert_eq!(Condition::::is_gte(0, 1).to_bool_var(), false); - assert_eq!(Condition::::is_gte(-100, -99).to_bool_var(), false); - assert_eq!(Condition::::is_gte(-98, 98).to_bool_var(), false); + fn is_bit_set() { + // bit 0 agrees with from_lsb on every boundary value + for v in BOUNDARY { + assert_eq!( + Condition::::is_bit_set(v, 0).to_bool(), + Condition::::from_lsb(v).to_bool() + ); + } + // each single-bit value reports exactly its own bit + for k in 0..u64::BITS { + let v: u64 = 1 << k; + for bit in 0..u64::BITS { + assert_canonical(Condition::::is_bit_set(v, bit), bit == k); + } + } + // the top bit agrees with from_msb + for v in BOUNDARY { + assert_eq!( + Condition::::is_bit_set(v, u64::BITS - 1).to_bool(), + Condition::::from_msb(v).to_bool() + ); + } } #[test] - fn is_in_range() { - assert_eq!(Condition::::is_within_range(1, 0, 2).to_bool_var(), true); - assert_eq!(Condition::::is_within_range(2, 0, 1).to_bool_var(), false); - assert_eq!(Condition::::is_within_range(1, -5, 2).to_bool_var(), true); - assert_eq!(Condition::::is_within_range(0, -5, 5).to_bool_var(), true); - assert_eq!(Condition::::is_within_range(1, 0, 0).to_bool_var(), false); + fn is_zero_is_not_zero() { + for v in BOUNDARY { + assert_canonical(Condition::::is_zero(v), v == 0); + assert_canonical(Condition::::is_not_zero(v), v != 0); + } } #[test] - fn is_in_list() { - assert_eq!(Condition::::is_in_list(1, &[1, 2, 3]).to_bool_var(), true); - assert_eq!(Condition::::is_in_list(4, &[1, 2, 3]).to_bool_var(), false); - assert_eq!(Condition::::is_in_list(-3, &[1, 2, 3, 4, -5, -1]).to_bool_var(), false); - assert_eq!(Condition::::is_in_list(3, &[1, 2, 3, 3, 3, 3]).to_bool_var(), true); + fn is_equal() { + for x in BOUNDARY { + for y in BOUNDARY { + assert_canonical(Condition::::is_equal(x, y), x == y); + } + } } #[test] - fn test_mov() { - let src = 1; - let mut dst = 2; - let c1 = Condition::::from_bool::(); - c1.mov(src, &mut dst); - assert_eq!(dst, 1); + fn select_preserves_all_bits() { + // Patterned values (not just MAX/0) so a mask with any wrong bit shows up. + let a: u64 = (0xDEADBEEFCAFEBABE_u64 & u64::MAX as u64) as u64; + let b: u64 = (0x0123456789ABCDEF_u64 & u64::MAX as u64) as u64; + assert_eq!(Condition::::TRUE.select(a, b), a); + assert_eq!(Condition::::FALSE.select(a, b), b); + } - let c2 = Condition::::from_bool::(); + #[test] + fn mov() { + let src: u64 = 1; + let mut dst: u64 = 2; + Condition::::from_bool_const::().mov(src, &mut dst); + assert_eq!(dst, 1); dst = 2; - c2.mov(src, &mut dst); + Condition::::from_bool_const::().mov(src, &mut dst); assert_eq!(dst, 2); } #[test] - fn test_negate() { - let c1 = Condition::::TRUE; - assert_eq!(c1.negate(1), -1); - assert_eq!(c1.negate(0), 0); - assert_eq!(c1.negate(-1), 1); + fn swap() { + let (lhs, rhs) = Condition::::from_bool_const::().swap(1, 2); + assert_eq!((lhs, rhs), (2, 1)); + let (lhs, rhs) = Condition::::from_bool_const::().swap(1, 2); + assert_eq!((lhs, rhs), (1, 2)); + } + + #[test] + fn boolean_operators() { + let t = Condition::::TRUE; + let f = Condition::::FALSE; + assert_canonical(!t, false); + assert_canonical(!f, true); + assert_canonical(t & f, false); + assert_canonical(t & t, true); + assert_canonical(t | f, true); + assert_canonical(f | f, false); + assert_canonical(t ^ t, false); + assert_canonical(t ^ f, true); + } +} + +#[cfg(test)] +mod unsigned_u32_tests { + use super::*; - let c2 = Condition::::FALSE; - assert_eq!(c2.negate(1), 1); - assert_eq!(c2.negate(0), 0); - assert_eq!(c2.negate(-1), -1); + const MSB: u32 = 1 << (u32::BITS - 1); + const BOUNDARY: [u32; 5] = [0, 1, u32::MAX, u32::MAX - 1, MSB]; + + /// A mask must be exactly all-ones or all-zeros: `to_bool` only tests non-zero, + /// so a constructor returning `1` instead of all-ones would pass it and then + /// quietly corrupt every select it feeds. The two select operands differ in + /// every bit, so the assertion holds exactly when the mask is canonical. + fn assert_canonical(cond: Condition, expected: bool) { + const PATTERN: u32 = (0x5555_5555_5555_5555_u64 & (u32::MAX as u64)) as u32; + let selected = cond.select(PATTERN, !PATTERN); + assert_eq!(selected, if expected { PATTERN } else { !PATTERN }); + assert_eq!(cond.to_bool(), expected); } #[test] - fn test_or_halves() { - // 0 input -> 0 output - assert_eq!(Condition::::or_halves(0), 0); + fn consts() { + assert_canonical(Condition::::TRUE, true); + assert_canonical(Condition::::FALSE, false); + } - // Lower 32 bits should be preserved - assert_eq!(Condition::::or_halves(1), 1); - assert_eq!(Condition::::or_halves(0x12345678), 0x12345678); + #[test] + fn from_bool() { + assert_canonical(Condition::::from_bool_const::(), true); + assert_canonical(Condition::::from_bool_const::(), false); + assert_canonical(Condition::::from_bool(true), true); + assert_canonical(Condition::::from_bool(false), false); + } - // Upper 32 bits should be folded into lower 32 bits - // (1 << 32) OR (1 << 32 >> 32) => 0 OR 1 => 1 - assert_eq!(Condition::::or_halves(1 << 32), 1); + #[test] + fn from_lsb() { + for v in BOUNDARY { + assert_canonical(Condition::::from_lsb(v), v & 1 == 1); + } + } - // Mixed case: Upper 0x10000000 | Lower 0x00000001 => 0x10000001 - assert_eq!(Condition::::or_halves(0x10000000_00000001), 0x10000001); + #[test] + fn from_msb() { + for v in BOUNDARY { + assert_canonical(Condition::::from_msb(v), v >> (u32::BITS - 1) == 1); + } + } - // Negative number check (-1) - // -1 is 0xFFFF...FFFF - // (-1 >> 32) is -1 (Arithmetic shift preserves sign) - // (-1 | -1) is -1 - // -1 & 0xFFFFFFFF is 0x00000000FFFFFFFF (i64 value: 4294967295) - assert_eq!(Condition::::or_halves(-1), 0xFFFFFFFF); + /// `from_msb` exists to convert the borrow word of a wrapping subtraction chain + /// into a mask: `(x - y) >> BITS` of the widening subtraction is all-ones in the + /// low word iff x < y. + #[test] + fn from_msb_as_borrow_adaptor() { + for x in BOUNDARY { + for y in BOUNDARY { + let borrow = ((x as u64).wrapping_sub(y as u64) >> u32::BITS) as u32; + assert_canonical(Condition::::from_msb(borrow), x < y); + } + } + } - // i64::MIN check (Only MSB set) - // i64::MIN = 0x80000000_00000000 - // (val >> 32) = 0xFFFFFFFF_80000000 (Sign extension) - // (val | shifted) = 0xFFFFFFFF_80000000 - // (& mask) = 0x00000000_80000000 - assert_eq!(Condition::::or_halves(i64::MIN), 0x80000000); + #[test] + fn is_bit_set() { + // bit 0 agrees with from_lsb on every boundary value + for v in BOUNDARY { + assert_eq!( + Condition::::is_bit_set(v, 0).to_bool(), + Condition::::from_lsb(v).to_bool() + ); + } + // each single-bit value reports exactly its own bit + for k in 0..u32::BITS { + let v: u32 = 1 << k; + for bit in 0..u32::BITS { + assert_canonical(Condition::::is_bit_set(v, bit), bit == k); + } + } + // the top bit agrees with from_msb + for v in BOUNDARY { + assert_eq!( + Condition::::is_bit_set(v, u32::BITS - 1).to_bool(), + Condition::::from_msb(v).to_bool() + ); + } } #[test] - fn test_select() { - let c = Condition::::from_bool::(); - assert_eq!(c.select(1, 2), 1); - assert_eq!((!c).select(1, 2), 2); + fn is_zero_is_not_zero() { + for v in BOUNDARY { + assert_canonical(Condition::::is_zero(v), v == 0); + assert_canonical(Condition::::is_not_zero(v), v != 0); + } + } + + #[test] + fn is_equal() { + for x in BOUNDARY { + for y in BOUNDARY { + assert_canonical(Condition::::is_equal(x, y), x == y); + } + } + } - // or the inverse behaviour if you start with 'false'. - let cfalse = Condition::::from_bool::(); - assert_eq!(cfalse.select(1, 2), 2); - assert_eq!((!cfalse).select(1, 2), 1); + #[test] + fn select_preserves_all_bits() { + // Patterned values (not just MAX/0) so a mask with any wrong bit shows up. + let a: u32 = (0xDEADBEEFCAFEBABE_u64 & u32::MAX as u64) as u32; + let b: u32 = (0x0123456789ABCDEF_u64 & u32::MAX as u64) as u32; + assert_eq!(Condition::::TRUE.select(a, b), a); + assert_eq!(Condition::::FALSE.select(a, b), b); + } + + #[test] + fn mov() { + let src: u32 = 1; + let mut dst: u32 = 2; + Condition::::from_bool_const::().mov(src, &mut dst); + assert_eq!(dst, 1); + dst = 2; + Condition::::from_bool_const::().mov(src, &mut dst); + assert_eq!(dst, 2); } #[test] - fn test_swap() { - let c = Condition::::from_bool::(); - let (lhs, rhs) = c.swap(1, 2); - assert_eq!(lhs, 2); - assert_eq!(rhs, 1); + fn swap() { + let (lhs, rhs) = Condition::::from_bool_const::().swap(1, 2); + assert_eq!((lhs, rhs), (2, 1)); + let (lhs, rhs) = Condition::::from_bool_const::().swap(1, 2); + assert_eq!((lhs, rhs), (1, 2)); + } - // or the inverse behaviour if you start with 'false'. - let c = Condition::::from_bool::(); - let (lhs, rhs) = c.swap(1, 2); - assert_eq!(lhs, 1); - assert_eq!(rhs, 2); + #[test] + fn boolean_operators() { + let t = Condition::::TRUE; + let f = Condition::::FALSE; + assert_canonical(!t, false); + assert_canonical(!f, true); + assert_canonical(t & f, false); + assert_canonical(t & t, true); + assert_canonical(t | f, true); + assert_canonical(f | f, false); + assert_canonical(t ^ t, false); + assert_canonical(t ^ f, true); } } +/// One test module per signed width, written out by hand like the impls they exercise; the +/// i64 and i32 modules must be edited together so the widths stay at exact behavioural +/// parity. Boundary set: 0, +/-1, MIN, MIN+1, MAX, MAX-1: the values where +/// overflow or sign-extension mistakes in the mask constructions would show up. #[cfg(test)] -mod u64_tests { +mod signed_i64_tests { use super::*; + const BOUNDARY: [i64; 7] = [0, 1, -1, i64::MIN, i64::MIN + 1, i64::MAX, i64::MAX - 1]; + + /// A mask must be exactly all-ones or all-zeros: `to_bool` only tests non-zero, + /// so a constructor returning `1` instead of `-1` would pass it and then + /// quietly corrupt every select it feeds. The two select operands differ in + /// every bit, so the assertion holds exactly when the mask is canonical. + fn assert_canonical(cond: Condition, expected: bool) { + const PATTERN: i64 = (0x5555_5555_5555_5555_u64 & (i64::MAX as u64)) as i64; + let selected = cond.select(PATTERN, !PATTERN); + assert_eq!(selected, if expected { PATTERN } else { !PATTERN }); + assert_eq!(cond.to_bool(), expected); + } + #[test] - fn const_tests() { - // Ensure TRUE/FALSE are correctly interpreted as boolean. - assert_eq!(Condition::::TRUE.is_true(), true); - assert_eq!(Condition::::FALSE.is_true(), false); + fn consts() { + assert_canonical(Condition::::TRUE, true); + assert_canonical(Condition::::FALSE, false); } #[test] fn from_bool() { - // Compile-time const generics check - assert_eq!(Condition::::from_bool::().is_true(), true); - assert_eq!(Condition::::from_bool::().is_true(), false); + assert_canonical(Condition::::from_bool_const::(), true); + assert_canonical(Condition::::from_bool_const::(), false); + assert_canonical(Condition::::from_bool(true), true); + assert_canonical(Condition::::from_bool(false), false); } #[test] - fn select() { - let t = Condition::::TRUE; - let f = Condition::::FALSE; + fn from_lsb() { + for v in BOUNDARY { + assert_canonical(Condition::::from_lsb(v), v & 1 == 1); + } + } + + #[test] + fn is_bit_set() { + // bit 0 agrees with from_lsb on every boundary value + for v in BOUNDARY { + assert_eq!( + Condition::::is_bit_set(v, 0).to_bool(), + Condition::::from_lsb(v).to_bool() + ); + } + // each single-bit value reports exactly its own bit + for k in 0..i64::BITS { + let v: i64 = (1 as i64) << k; + for bit in 0..i64::BITS { + assert_canonical(Condition::::is_bit_set(v, bit), bit == k); + } + } + // the top bit agrees with is_negative + for v in BOUNDARY { + assert_eq!( + Condition::::is_bit_set(v, i64::BITS - 1).to_bool(), + Condition::::is_negative(v).to_bool() + ); + } + } - let val1: u64 = 0xDEADBEEFCAFEBABE; - let val2: u64 = 0x0000000000000000; + #[test] + fn is_negative() { + for v in BOUNDARY { + assert_canonical(Condition::::is_negative(v), v < 0); + } + } - // This test is CRITICAL. - // If TRUE was defined as '1' (like i64), this would fail because 'select' relies on bitwise mask. - // It requires TRUE to be u64::MAX (all 1s) to preserve the full bits of val1. - assert_eq!(t.select(val1, val2), val1); - assert_eq!(f.select(val1, val2), val2); + #[test] + fn is_zero_is_not_zero() { + for v in BOUNDARY { + assert_canonical(Condition::::is_zero(v), v == 0); + assert_canonical(Condition::::is_not_zero(v), v != 0); + } + } - // Cross check with from_bool - let t_gen = Condition::::from_bool::(); - assert_eq!(t_gen.select(val1, val2), val1); + #[test] + fn is_equal() { + for x in BOUNDARY { + for y in BOUNDARY { + assert_canonical(Condition::::is_equal(x, y), x == y); + } + } } + /// Differential check against the native operators over every boundary pair, + /// including the far-apart pairs where a subtract-and-check-sign construction + /// would overflow. #[test] - fn bit_ops() { - let t = Condition::::TRUE; - let f = Condition::::FALSE; + fn comparisons_match_native_operators() { + for x in BOUNDARY { + for y in BOUNDARY { + assert_canonical(Condition::::is_lt(x, y), x < y); + assert_canonical(Condition::::is_lte(x, y), x <= y); + assert_canonical(Condition::::is_gt(x, y), x > y); + assert_canonical(Condition::::is_gte(x, y), x >= y); + } + } + } - // NOT - assert_eq!((!t).is_true(), false); - assert_eq!((!f).is_true(), true); + /// Regression for the former `is_negative(x - y)` construction, which + /// overflowed for operands more than half the range apart (debug panic, + /// opposite answer in release). + #[test] + fn is_lt_far_apart_operands() { + assert_canonical(Condition::::is_lt(i64::MIN, 1), true); + assert_canonical(Condition::::is_lt(1, i64::MIN), false); + assert_canonical(Condition::::is_lt(i64::MIN, i64::MAX), true); + assert_canonical(Condition::::is_lt(i64::MAX, i64::MIN), false); + } - // AND - assert_eq!((t & t).is_true(), true); - assert_eq!((t & f).is_true(), false); - assert_eq!((f & f).is_true(), false); + #[test] + fn is_within_range() { + assert_canonical(Condition::::is_within_range(1, 0, 2), true); + assert_canonical(Condition::::is_within_range(2, 0, 1), false); + assert_canonical(Condition::::is_within_range(1, -5, 2), true); + assert_canonical(Condition::::is_within_range(0, -5, 5), true); + assert_canonical(Condition::::is_within_range(1, 0, 0), false); + for v in BOUNDARY { + for lo in BOUNDARY { + for hi in BOUNDARY { + assert_canonical( + Condition::::is_within_range(v, lo, hi), + lo <= v && v <= hi, + ); + } + } + } + } - // OR - assert_eq!((t | t).is_true(), true); - assert_eq!((t | f).is_true(), true); - assert_eq!((f | f).is_true(), false); + #[test] + fn is_in_list() { + assert_canonical(Condition::::is_in_list(1, &[1, 2, 3]), true); + assert_canonical(Condition::::is_in_list(4, &[1, 2, 3]), false); + assert_canonical(Condition::::is_in_list(-3, &[1, 2, 3, 4, -5, -1]), false); + assert_canonical(Condition::::is_in_list(3, &[1, 2, 3, 3, 3, 3]), true); + } - // XOR - assert_eq!((t ^ t).is_true(), false); - assert_eq!((t ^ f).is_true(), true); + #[test] + fn select_preserves_all_bits() { + // Patterned values (not just -1/0) so a mask with any wrong bit shows up. + let a: i64 = (0xDEADBEEFCAFEBABE_u64 & (i64::MAX as u64)) as i64; + let b: i64 = (0x0123456789ABCDEF_u64 & (i64::MAX as u64)) as i64; + assert_eq!(Condition::::TRUE.select(a, b), a); + assert_eq!(Condition::::FALSE.select(a, b), b); + } + + #[test] + fn mov() { + let src: i64 = 1; + let mut dst: i64 = 2; + Condition::::from_bool_const::().mov(src, &mut dst); + assert_eq!(dst, 1); + dst = 2; + Condition::::from_bool_const::().mov(src, &mut dst); + assert_eq!(dst, 2); + } + + #[test] + fn negate() { + let t = Condition::::TRUE; + assert_eq!(t.negate(1), -1); + assert_eq!(t.negate(0), 0); + assert_eq!(t.negate(-1), 1); + let f = Condition::::FALSE; + assert_eq!(f.negate(1), 1); + assert_eq!(f.negate(0), 0); + assert_eq!(f.negate(-1), -1); + } + + #[test] + fn swap() { + let (lhs, rhs) = Condition::::from_bool_const::().swap(1, 2); + assert_eq!((lhs, rhs), (2, 1)); + let (lhs, rhs) = Condition::::from_bool_const::().swap(1, 2); + assert_eq!((lhs, rhs), (1, 2)); + } + + #[test] + fn boolean_operators() { + let t = Condition::::TRUE; + let f = Condition::::FALSE; + assert_canonical(!t, false); + assert_canonical(!f, true); + assert_canonical(t & f, false); + assert_canonical(t & t, true); + assert_canonical(t | f, true); + assert_canonical(f | f, false); + assert_canonical(t ^ t, false); + assert_canonical(t ^ f, true); } } #[cfg(test)] -mod generic_impl_tests { +mod signed_i32_tests { use super::*; + const BOUNDARY: [i32; 7] = [0, 1, -1, i32::MIN, i32::MIN + 1, i32::MAX, i32::MAX - 1]; + + /// A mask must be exactly all-ones or all-zeros: `to_bool` only tests non-zero, + /// so a constructor returning `1` instead of `-1` would pass it and then + /// quietly corrupt every select it feeds. The two select operands differ in + /// every bit, so the assertion holds exactly when the mask is canonical. + fn assert_canonical(cond: Condition, expected: bool) { + const PATTERN: i32 = (0x5555_5555_5555_5555_u64 & (i32::MAX as u64)) as i32; + let selected = cond.select(PATTERN, !PATTERN); + assert_eq!(selected, if expected { PATTERN } else { !PATTERN }); + assert_eq!(cond.to_bool(), expected); + } + #[test] - fn test_bit_and() { - let ct1 = Condition::::from_bool::(); - let ct2 = Condition::::from_bool::(); - let cf1 = Condition::::from_bool::(); - let cf2 = Condition::::from_bool::(); - assert_eq!((ct1 & ct2).to_bool_var(), true); - assert_eq!((ct1 & cf1).to_bool_var(), false); - assert_eq!((cf1 & cf2).to_bool_var(), false); + fn consts() { + assert_canonical(Condition::::TRUE, true); + assert_canonical(Condition::::FALSE, false); } #[test] - fn test_bit_and_assign() { - let mut ct1 = Condition::::from_bool::(); - let ct2 = Condition::::from_bool::(); - let cf = Condition::::from_bool::(); + fn from_bool() { + assert_canonical(Condition::::from_bool_const::(), true); + assert_canonical(Condition::::from_bool_const::(), false); + assert_canonical(Condition::::from_bool(true), true); + assert_canonical(Condition::::from_bool(false), false); + } - ct1 &= ct2; - assert_eq!(ct1.to_bool_var(), true); + #[test] + fn from_lsb() { + for v in BOUNDARY { + assert_canonical(Condition::::from_lsb(v), v & 1 == 1); + } + } - ct1 &= cf; - assert_eq!(ct1.to_bool_var(), false); + #[test] + fn is_bit_set() { + // bit 0 agrees with from_lsb on every boundary value + for v in BOUNDARY { + assert_eq!( + Condition::::is_bit_set(v, 0).to_bool(), + Condition::::from_lsb(v).to_bool() + ); + } + // each single-bit value reports exactly its own bit + for k in 0..i32::BITS { + let v: i32 = (1 as i32) << k; + for bit in 0..i32::BITS { + assert_canonical(Condition::::is_bit_set(v, bit), bit == k); + } + } + // the top bit agrees with is_negative + for v in BOUNDARY { + assert_eq!( + Condition::::is_bit_set(v, i32::BITS - 1).to_bool(), + Condition::::is_negative(v).to_bool() + ); + } } #[test] - fn test_bit_or() { - let ct1 = Condition::::from_bool::(); - let ct2 = Condition::::from_bool::(); - let cf1 = Condition::::from_bool::(); - let cf2 = Condition::::from_bool::(); + fn is_negative() { + for v in BOUNDARY { + assert_canonical(Condition::::is_negative(v), v < 0); + } + } - assert_eq!((ct1 | ct2).to_bool_var(), true); - assert_eq!((ct1 | cf1).to_bool_var(), true); - assert_eq!((cf1 | cf2).to_bool_var(), false); + #[test] + fn is_zero_is_not_zero() { + for v in BOUNDARY { + assert_canonical(Condition::::is_zero(v), v == 0); + assert_canonical(Condition::::is_not_zero(v), v != 0); + } } #[test] - fn test_bit_or_assign() { - let mut ct1 = Condition::::from_bool::(); - let ct2 = Condition::::from_bool::(); - let mut cf1 = Condition::::from_bool::(); - let cf2 = Condition::::from_bool::(); + fn is_equal() { + for x in BOUNDARY { + for y in BOUNDARY { + assert_canonical(Condition::::is_equal(x, y), x == y); + } + } + } - ct1 |= ct2; - assert_eq!(ct1.to_bool_var(), true); + /// Differential check against the native operators over every boundary pair, + /// including the far-apart pairs where a subtract-and-check-sign construction + /// would overflow. + #[test] + fn comparisons_match_native_operators() { + for x in BOUNDARY { + for y in BOUNDARY { + assert_canonical(Condition::::is_lt(x, y), x < y); + assert_canonical(Condition::::is_lte(x, y), x <= y); + assert_canonical(Condition::::is_gt(x, y), x > y); + assert_canonical(Condition::::is_gte(x, y), x >= y); + } + } + } - ct1 |= cf1; - assert_eq!(ct1.to_bool_var(), true); + /// Regression for the former `is_negative(x - y)` construction, which + /// overflowed for operands more than half the range apart (debug panic, + /// opposite answer in release). + #[test] + fn is_lt_far_apart_operands() { + assert_canonical(Condition::::is_lt(i32::MIN, 1), true); + assert_canonical(Condition::::is_lt(1, i32::MIN), false); + assert_canonical(Condition::::is_lt(i32::MIN, i32::MAX), true); + assert_canonical(Condition::::is_lt(i32::MAX, i32::MIN), false); + } - cf1 |= cf2; - assert_eq!(cf1.to_bool_var(), false); + #[test] + fn is_within_range() { + assert_canonical(Condition::::is_within_range(1, 0, 2), true); + assert_canonical(Condition::::is_within_range(2, 0, 1), false); + assert_canonical(Condition::::is_within_range(1, -5, 2), true); + assert_canonical(Condition::::is_within_range(0, -5, 5), true); + assert_canonical(Condition::::is_within_range(1, 0, 0), false); + for v in BOUNDARY { + for lo in BOUNDARY { + for hi in BOUNDARY { + assert_canonical( + Condition::::is_within_range(v, lo, hi), + lo <= v && v <= hi, + ); + } + } + } } #[test] - fn test_bit_xor() { - let ct1 = Condition::::from_bool::(); - let ct2 = Condition::::from_bool::(); - let cf1 = Condition::::from_bool::(); - let cf2 = Condition::::from_bool::(); + fn is_in_list() { + assert_canonical(Condition::::is_in_list(1, &[1, 2, 3]), true); + assert_canonical(Condition::::is_in_list(4, &[1, 2, 3]), false); + assert_canonical(Condition::::is_in_list(-3, &[1, 2, 3, 4, -5, -1]), false); + assert_canonical(Condition::::is_in_list(3, &[1, 2, 3, 3, 3, 3]), true); + } - assert_eq!((ct1 ^ ct2).to_bool_var(), false); - assert_eq!((ct1 | cf1).to_bool_var(), true); - assert_eq!((cf1 | cf2).to_bool_var(), false); + #[test] + fn select_preserves_all_bits() { + // Patterned values (not just -1/0) so a mask with any wrong bit shows up. + let a: i32 = (0xDEADBEEFCAFEBABE_u64 & (i32::MAX as u64)) as i32; + let b: i32 = (0x0123456789ABCDEF_u64 & (i32::MAX as u64)) as i32; + assert_eq!(Condition::::TRUE.select(a, b), a); + assert_eq!(Condition::::FALSE.select(a, b), b); } #[test] - fn test_bit_xor_assign() { - let mut ct1 = Condition::::from_bool::(); - let mut ct2 = Condition::::from_bool::(); - let mut cf1 = Condition::::from_bool::(); - let cf2 = Condition::::from_bool::(); + fn mov() { + let src: i32 = 1; + let mut dst: i32 = 2; + Condition::::from_bool_const::().mov(src, &mut dst); + assert_eq!(dst, 1); + dst = 2; + Condition::::from_bool_const::().mov(src, &mut dst); + assert_eq!(dst, 2); + } - ct1 ^= ct2; - assert_eq!(ct1.to_bool_var(), false); + #[test] + fn negate() { + let t = Condition::::TRUE; + assert_eq!(t.negate(1), -1); + assert_eq!(t.negate(0), 0); + assert_eq!(t.negate(-1), 1); + let f = Condition::::FALSE; + assert_eq!(f.negate(1), 1); + assert_eq!(f.negate(0), 0); + assert_eq!(f.negate(-1), -1); + } - ct2 ^= cf1; - assert_eq!(ct2.to_bool_var(), true); + #[test] + fn swap() { + let (lhs, rhs) = Condition::::from_bool_const::().swap(1, 2); + assert_eq!((lhs, rhs), (2, 1)); + let (lhs, rhs) = Condition::::from_bool_const::().swap(1, 2); + assert_eq!((lhs, rhs), (1, 2)); + } - cf1 ^= cf2; - assert_eq!(cf1.to_bool_var(), false); + #[test] + fn boolean_operators() { + let t = Condition::::TRUE; + let f = Condition::::FALSE; + assert_canonical(!t, false); + assert_canonical(!f, true); + assert_canonical(t & f, false); + assert_canonical(t & t, true); + assert_canonical(t | f, true); + assert_canonical(f | f, false); + assert_canonical(t ^ t, false); + assert_canonical(t ^ f, true); } +} + +#[cfg(test)] +mod signed_comparison_sweep { + use super::*; + /// Dense sweep of the full i32 range: 256 x 256 pairs stepped 1 << 24 apart, checked + /// against the native operators. This covers every combination of sign and magnitude + /// region, not just the BOUNDARY values. #[test] - fn test_not() { - let c = Condition::::from_bool::(); - assert_eq!((!c).to_bool_var(), false); + fn i32_strided_full_range() { + for a in (i32::MIN..=i32::MAX).step_by(1 << 24) { + for b in (i32::MIN..=i32::MAX).step_by(1 << 24) { + assert_eq!(Condition::::is_lt(a, b).to_bool(), a < b, "{a} {b}"); + assert_eq!(Condition::::is_lte(a, b).to_bool(), a <= b, "{a} {b}"); + assert_eq!(Condition::::is_gt(a, b).to_bool(), a > b, "{a} {b}"); + assert_eq!(Condition::::is_gte(a, b).to_bool(), a >= b, "{a} {b}"); + } + } } } #[cfg(test)] mod ct_bytes_tests { + #[test] + fn test_ct_eq_bytes() { + use bouncycastle_utils::ct::ct_eq_bytes; + + // equal inputs, including the empty slice + assert!(ct_eq_bytes(&[], &[])); + assert!(ct_eq_bytes(&[0x42], &[0x42])); + assert!(ct_eq_bytes(&[0xAA; 32], &[0xAA; 32])); + + // a length mismatch is never equal, even when the shorter is a prefix + assert!(!ct_eq_bytes(&[], &[0])); + assert!(!ct_eq_bytes(&[1, 2, 3], &[1, 2])); + + // a differing byte anywhere must be detected: first, last, and single-bit-only + let a = [0xAA; 32]; + let mut b = [0xAA; 32]; + b[0] = 0xAB; + assert!(!ct_eq_bytes(&a, &b)); + b[0] = 0xAA; + b[31] = 0xAB; + assert!(!ct_eq_bytes(&a, &b)); + b[31] = 0xAA; + b[15] ^= 0x80; + assert!(!ct_eq_bytes(&a, &b)); + } + + #[test] + fn test_ct_eq_zero_bytes() { + use bouncycastle_utils::ct::ct_eq_zero_bytes; + + // all-zero inputs, including the empty slice + assert!(ct_eq_zero_bytes(&[])); + assert!(ct_eq_zero_bytes(&[0])); + assert!(ct_eq_zero_bytes(&[0; 32])); + + // a nonzero byte anywhere must be detected: first, last, and high-bit-only + assert!(!ct_eq_zero_bytes(&[1])); + let mut buf = [0u8; 32]; + buf[0] = 1; + assert!(!ct_eq_zero_bytes(&buf)); + buf[0] = 0; + buf[31] = 1; + assert!(!ct_eq_zero_bytes(&buf)); + buf[31] = 0; + buf[15] = 0x80; + assert!(!ct_eq_zero_bytes(&buf)); + } + #[test] fn test_conditional_copy_bytes() { use bouncycastle_utils::ct::conditional_copy_bytes; From 3954671c2a112ca7dbadabb4b06df53bb34054e0 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 21 Aug 2026 13:01:32 -0500 Subject: [PATCH 12/28] Added the `#[non_exhaustive]` attribute to enums that are likely to grow in the future. --- crypto/core-test-framework/src/mac.rs | 4 ++++ crypto/core/src/errors.rs | 13 +++++++++++++ crypto/core/src/key_material.rs | 5 ++++- crypto/core/src/traits.rs | 1 + crypto/core/tests/key_material_tests.rs | 2 ++ crypto/factory/src/hash_factory.rs | 1 + crypto/factory/src/kdf_factory.rs | 1 + crypto/factory/src/lib.rs | 6 ++++++ crypto/factory/src/mac_factory.rs | 1 + crypto/factory/src/rng_factory.rs | 1 + crypto/factory/src/xof_factory.rs | 1 + crypto/mlkem/src/polynomial.rs | 3 +-- 12 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crypto/core-test-framework/src/mac.rs b/crypto/core-test-framework/src/mac.rs index 853beea1..8430507c 100644 --- a/crypto/core-test-framework/src/mac.rs +++ b/crypto/core-test-framework/src/mac.rs @@ -116,6 +116,10 @@ impl TestFrameworkMAC { low_security_key.set_key_len(64).unwrap(); // truncate should be infallible low_security_key.set_security_strength(SecurityStrength::_192bit).unwrap(); } + // `SecurityStrength` is `#[non_exhaustive]`, so this arm is required. + _ => panic!( + "unhandled SecurityStrength variant -- add a case for it in the MAC test framework" + ), }; Ok(()) }) diff --git a/crypto/core/src/errors.rs b/crypto/core/src/errors.rs index 045fcb88..7be5197e 100644 --- a/crypto/core/src/errors.rs +++ b/crypto/core/src/errors.rs @@ -2,9 +2,14 @@ //! Errors defined in this module are typically one-to-one with traits defined in [`crate::traits`]. //! //! Most errors are self-explanatory, but additional description is available on some. +//! +//! All error enums exported from this crate are tagged `#[non_exhaustive]` to indicate that they +//! are highly likely to gain more branches in the future; therefore, the compiler is to treat it as +//! an error if a caller matches exhaustively against the current set of variants. /// #[derive(Debug)] +#[non_exhaustive] pub enum HashError { /// GenericError(&'static str), @@ -20,6 +25,7 @@ pub enum HashError { /// #[derive(Debug)] +#[non_exhaustive] pub enum KeyMaterialError { /// ActingOnZeroizedKey, @@ -39,6 +45,7 @@ pub enum KeyMaterialError { /// #[derive(Debug)] +#[non_exhaustive] pub enum KDFError { /// GenericError(&'static str), @@ -54,6 +61,7 @@ pub enum KDFError { /// #[derive(Debug)] +#[non_exhaustive] pub enum KEMError { /// GenericError(&'static str), @@ -77,6 +85,7 @@ pub enum KEMError { /// #[derive(Debug)] +#[non_exhaustive] pub enum MACError { /// GenericError(&'static str), @@ -92,6 +101,7 @@ pub enum MACError { /// #[derive(Debug)] +#[non_exhaustive] pub enum RNGError { /// GenericError(&'static str), @@ -118,6 +128,7 @@ pub enum RNGError { /// #[derive(Debug)] +#[non_exhaustive] pub enum SuspendableError { /// The serialized state was produced by a library version incompatible with this one. IncompatibleVersion, @@ -127,6 +138,7 @@ pub enum SuspendableError { /// #[derive(Debug)] +#[non_exhaustive] pub enum SignatureError { /// GenericError(&'static str), @@ -150,6 +162,7 @@ pub enum SignatureError { /// #[derive(Debug)] +#[non_exhaustive] pub enum SymmetricCipherError { /// GenericError(&'static str), diff --git a/crypto/core/src/key_material.rs b/crypto/core/src/key_material.rs index fb95d71b..1e2226b8 100644 --- a/crypto/core/src/key_material.rs +++ b/crypto/core/src/key_material.rs @@ -231,9 +231,12 @@ pub struct KeyMaterial { // `SerializableState` implementations (see the `TryFrom` impl below). Pin each value to its // variant name: reordering variants is fine, but never reuse or renumber an existing discriminant, // or previously-serialized states will be misread. -/// +/// The set of possible types of a KeyMaterial object. +/// How different tagging affects the behaviour of the KeyMaterial object will vary by the cryptographic algorithm that is consuming it. +/// Additional key types may be added in the future to accommodate new types of algorithms or use cases. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[repr(u8)] +#[non_exhaustive] pub enum KeyType { /// The KeyMaterial is zeroized and MUST NOT be used for any cryptographic operation in this state. Zeroized = 0, diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index a06b98ce..7e23d516 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -661,6 +661,7 @@ pub trait MAC: Sized { // release as a breaking change). #[derive(Eq, PartialEq, PartialOrd, Clone, Copy, Debug)] #[repr(u8)] +#[non_exhaustive] pub enum SecurityStrength { /// None = 0, diff --git a/crypto/core/tests/key_material_tests.rs b/crypto/core/tests/key_material_tests.rs index 00957677..efcc7759 100644 --- a/crypto/core/tests/key_material_tests.rs +++ b/crypto/core/tests/key_material_tests.rs @@ -841,6 +841,8 @@ mod test_key_material { Unknown => 1, CryptographicRandom => 2, Seed | MACKey | SymmetricCipherKey => 3, + // `KeyType` is `#[non_exhaustive]`, so this arm is required. + _ => panic!("unranked KeyType variant -- add it here and to `all_types`"), } } diff --git a/crypto/factory/src/hash_factory.rs b/crypto/factory/src/hash_factory.rs index 271d729a..edbfd17a 100644 --- a/crypto/factory/src/hash_factory.rs +++ b/crypto/factory/src/hash_factory.rs @@ -37,6 +37,7 @@ use bouncycastle_sha3::{SHA3_224_NAME, SHA3_256_NAME, SHA3_384_NAME, SHA3_512_NA /// Wrapper object for all algorithms that impl [`Hash`]. /// Note: no SHAKE because SHAKE is not NIST approved as a hash function. See FIPS 202 section A.2. +#[non_exhaustive] pub enum HashFactory { /// SHA224(sha2::SHA224), diff --git a/crypto/factory/src/kdf_factory.rs b/crypto/factory/src/kdf_factory.rs index d8be55a3..b5da24e7 100644 --- a/crypto/factory/src/kdf_factory.rs +++ b/crypto/factory/src/kdf_factory.rs @@ -59,6 +59,7 @@ use bouncycastle_sha3::{ }; /// Wrapper object for all algorithms that impl [`KDF`]. +#[non_exhaustive] pub enum KDFFactory { /// #[allow(non_camel_case_types)] diff --git a/crypto/factory/src/lib.rs b/crypto/factory/src/lib.rs index 8d1c1634..10195bf7 100644 --- a/crypto/factory/src/lib.rs +++ b/crypto/factory/src/lib.rs @@ -27,6 +27,10 @@ //! //! This crate compiles with STD; ie it is explicitly not tagged as `no_std` and it makes use of `Vec` and other //! dynamically-sized nice things. +//! +//! All enums exported from this crate are tagged `#[non_exhaustive]` to indicate that they +//! are highly likely to gain more branches in the future; therefore, the compiler is to treat it as +//! an error if a caller matches exhaustively against the current set of variants. #![forbid(unsafe_code)] #![forbid(missing_docs)] @@ -49,6 +53,7 @@ pub const DEFAULT_256_BIT: &str = "Default256Bit"; /// Top-level error type for Factories. #[derive(Debug)] +#[non_exhaustive] pub enum FactoryError { /// MACError(MACError), @@ -56,6 +61,7 @@ pub enum FactoryError { UnsupportedAlgorithm(String), } +// todo -- weird that MACError is the only one that we need to promote? impl From for FactoryError { fn from(e: MACError) -> FactoryError { Self::MACError(e) diff --git a/crypto/factory/src/mac_factory.rs b/crypto/factory/src/mac_factory.rs index 4b0fce60..f9a46768 100644 --- a/crypto/factory/src/mac_factory.rs +++ b/crypto/factory/src/mac_factory.rs @@ -95,6 +95,7 @@ pub const DEFAULT_256BIT_MAC_NAME: &str = HMAC_SHA256_NAME; /// Wrapper object for all algorithms that impl [`MAC`]. /// MACFactory deviates from the usual AlgorithmFactory trait because MAC objects do not have a no-arg constructor; /// instead they have a constructor that takes a [`KeyMaterialTrait`] and can return an error. +#[non_exhaustive] pub enum MACFactory { /// HMAC_SHA224(hmac::HMAC), diff --git a/crypto/factory/src/rng_factory.rs b/crypto/factory/src/rng_factory.rs index 492efecb..14329969 100644 --- a/crypto/factory/src/rng_factory.rs +++ b/crypto/factory/src/rng_factory.rs @@ -51,6 +51,7 @@ use bouncycastle_rng as rng; use bouncycastle_rng::{HASH_DRBG_SHA256_NAME, HASH_DRBG_SHA512_NAME}; /// Wrapper object for all algorithms that impl [`RNG`]. +#[non_exhaustive] pub enum RNGFactory { /// #[allow(non_camel_case_types)] diff --git a/crypto/factory/src/xof_factory.rs b/crypto/factory/src/xof_factory.rs index 16771242..c3d97473 100644 --- a/crypto/factory/src/xof_factory.rs +++ b/crypto/factory/src/xof_factory.rs @@ -48,6 +48,7 @@ pub const DEFAULT_128BIT_XOF_NAME: &str = SHAKE128_NAME; pub const DEFAULT_256BIT_XOF_NAME: &str = SHAKE256_NAME; /// Wrapper object for all algorithms that impl [`XOF`]. +#[non_exhaustive] pub enum XOFFactory { /// SHAKE128(sha3::SHAKE128), diff --git a/crypto/mlkem/src/polynomial.rs b/crypto/mlkem/src/polynomial.rs index 40025286..f9c14e18 100644 --- a/crypto/mlkem/src/polynomial.rs +++ b/crypto/mlkem/src/polynomial.rs @@ -21,8 +21,7 @@ use crate::mlkem::{N, q}; /// It is the responsibility of the caller to wrap sensitive instances in `Secret`. #[derive(Clone, Copy)] pub struct Polynomial { - /// Note: this is exposed publicly only for testing purposes and there is no good reason to use it in production code. - pub coeffs: [i16; N], + pub(crate) coeffs: [i16; N], } /// Convenience function to avoid ".0" all over the place. From 57998ee4ae178983b07754b3ddbe4d581c437fa0 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Fri, 21 Aug 2026 13:54:34 -0500 Subject: [PATCH 13/28] Polynomial was left pub as a consequence of the old way of handling secrets, but does not need to be anymore. And some other cosmetic refactoring. --- crypto/mldsa-lowmemory/src/lib.rs | 3 -- crypto/mldsa-lowmemory/src/polynomial.rs | 8 +---- crypto/mldsa/src/aux_functions.rs | 24 +++++++-------- crypto/mldsa/src/lib.rs | 3 -- crypto/mldsa/src/matrix.rs | 38 ++++++++---------------- crypto/mldsa/src/mldsa_keys.rs | 18 +++++------ crypto/mldsa/src/polynomial.rs | 8 +---- crypto/mlkem-lowmemory/src/lib.rs | 3 -- crypto/mlkem-lowmemory/src/polynomial.rs | 5 +--- crypto/mlkem/src/aux_functions.rs | 6 ++-- crypto/mlkem/src/lib.rs | 7 ++--- crypto/mlkem/src/matrix.rs | 38 ++++++++---------------- crypto/mlkem/src/mlkem_keys.rs | 6 ++-- crypto/mlkem/src/polynomial.rs | 19 ++++-------- 14 files changed, 62 insertions(+), 124 deletions(-) diff --git a/crypto/mldsa-lowmemory/src/lib.rs b/crypto/mldsa-lowmemory/src/lib.rs index 966f32ef..02b03d40 100644 --- a/crypto/mldsa-lowmemory/src/lib.rs +++ b/crypto/mldsa-lowmemory/src/lib.rs @@ -266,6 +266,3 @@ pub use mldsa::{MLDSA65_PK_LEN, MLDSA65_SIG_LEN, MLDSA65_SK_LEN}; pub use mldsa::{MLDSA87_PK_LEN, MLDSA87_SIG_LEN, MLDSA87_SK_LEN}; pub use mldsa::SUSPENDED_MU_BUILDER_STATE_LEN; - -// re-export just so it's visible to unit tests -pub use polynomial::Polynomial; diff --git a/crypto/mldsa-lowmemory/src/polynomial.rs b/crypto/mldsa-lowmemory/src/polynomial.rs index 95e3387b..51e81ea4 100644 --- a/crypto/mldsa-lowmemory/src/polynomial.rs +++ b/crypto/mldsa-lowmemory/src/polynomial.rs @@ -6,19 +6,13 @@ use core::ops::{Index, IndexMut}; /// A polynomial over the ML-DSA ring. /// -/// Dev note: The following structure does not necessarily need to be declared as public. -/// There is no real scenario where this function needs to be called directly. -/// However, in order to test the Debug and Display traits, it is necessary to use STD, so those -/// can't be tested from inline tests in this file and the real unit tests are in a different crate. -/// That's the reason why pub is used. -/// /// # ๐Ÿšจ Security ๐Ÿšจ /// Polynomials themselves are not inherently secret since sometimes they are part of public keys /// and sometimes private keys. /// It is the responsibility of the caller to wrap sensitive instances in `Secret`. /// Note: at the moment, nothing in this crate uses `Secret`, so I have left the `impl ZeroizablePrimitive` commented-out. #[derive(Clone, Copy)] -pub struct Polynomial { +pub(crate) struct Polynomial { pub(crate) coeffs: [i32; N], } diff --git a/crypto/mldsa/src/aux_functions.rs b/crypto/mldsa/src/aux_functions.rs index c35dc609..9eadbb89 100644 --- a/crypto/mldsa/src/aux_functions.rs +++ b/crypto/mldsa/src/aux_functions.rs @@ -407,7 +407,7 @@ pub(crate) fn sig_encode< for i in 0..l { output[pos..pos + POLY_Z_PACKED_LEN] - .copy_from_slice(&bitpack_gamma1::(&z.vec[i])); + .copy_from_slice(&bitpack_gamma1::(&z.elems[i])); pos += POLY_Z_PACKED_LEN; } @@ -416,7 +416,7 @@ pub(crate) fn sig_encode< let mut m: usize = 0; for i in 0..k { for j in 0..N { - if h.vec[i][j] != 0 { + if h.elems[i][j] != 0 { output[pos + m] = j as u8; m += 1; } @@ -453,7 +453,7 @@ pub(crate) fn sig_decode< pos += LAMBDA_over_4; for i in 0..l { - z.vec[i] = bit_unpack_gamma1::(&sig[pos..pos + POLY_Z_PACKED_LEN]); + z.elems[i] = bit_unpack_gamma1::(&sig[pos..pos + POLY_Z_PACKED_LEN]); pos += POLY_Z_PACKED_LEN; } @@ -486,7 +486,7 @@ pub(crate) fn sig_decode< return Err(()); } // 12: ๐ก[๐‘–]_๐‘ฆ[Index] โ† 1 - h.vec[i][sig[pos + j] as usize] = 1; + h.elems[i][sig[pos + j] as usize] = 1; // 13: Index โ† Index + 1 // > done by for loop @@ -672,7 +672,7 @@ pub(crate) fn expandA(rho: &[u8; 32]) -> Matrix< for r in 0..k { for s in 0..l { - A_hat[r][s] = rej_ntt_poly(rho, &[s as u8, r as u8]); + A_hat.elems[r][s] = rej_ntt_poly(rho, &[s as u8, r as u8]); } } @@ -692,11 +692,11 @@ pub(crate) fn expandS( let mut s2: Secret> = Secret::new(); for r in 0..l { - s1.vec[r] = rej_bounded_poly::(rho, &(r as u16).to_le_bytes()); + s1.elems[r] = rej_bounded_poly::(rho, &(r as u16).to_le_bytes()); } for r in 0..k { - s2.vec[r] = rej_bounded_poly::(rho, &(r as u16 + l as u16).to_le_bytes()); + s2.elems[r] = rej_bounded_poly::(rho, &(r as u16 + l as u16).to_le_bytes()); } (s1, s2) @@ -710,7 +710,7 @@ pub(crate) fn power_2_round_vec(v: &Vector) -> (Vector(&v); + y.elems[r] = bit_unpack_gamma1::(&v); } y @@ -881,8 +881,8 @@ pub(crate) fn make_hint_vecs( let mut count = 0i32; for i in 0..k { - let (w, c) = r.vec[i].make_hint::(&s.vec[i]); - out.vec[i] = w; + let (w, c) = r.elems[i].make_hint::(&s.elems[i]); + out.elems[i] = w; // mutants note: this chains up to hint_hamming_weight > OMEGA and there is no test KAT that triggers this branch count += c; @@ -942,7 +942,7 @@ pub(crate) fn use_hint_vecs( ) -> Vector { let mut out = Vector::::new(); for i in 0..k { - use_hint_polys::(&wp_approx.vec[i], &h.vec[i], &mut out.vec[i]); + use_hint_polys::(&wp_approx.elems[i], &h.elems[i], &mut out.elems[i]); } out diff --git a/crypto/mldsa/src/lib.rs b/crypto/mldsa/src/lib.rs index 15fa7408..2bea9873 100644 --- a/crypto/mldsa/src/lib.rs +++ b/crypto/mldsa/src/lib.rs @@ -188,6 +188,3 @@ pub use mldsa::{MLDSA87_PK_LEN, MLDSA87_SIG_LEN, MLDSA87_SK_LEN}; pub use mldsa::SUSPENDED_MU_BUILDER_STATE_LEN; pub use matrix::Matrix; - -// re-export just so it's visible to unit tests -pub use polynomial::Polynomial; diff --git a/crypto/mldsa/src/matrix.rs b/crypto/mldsa/src/matrix.rs index bbb3b7ca..916fe47d 100644 --- a/crypto/mldsa/src/matrix.rs +++ b/crypto/mldsa/src/matrix.rs @@ -10,26 +10,14 @@ use core::ops::{Index, IndexMut}; /// A matrix over the ML-DSA ring. #[derive(Clone)] -pub struct Matrix(/*pub(crate)*/ [[Polynomial; l]; k]); - -/// Convenience function to avoid ".0" all over the place. -impl Index for Matrix { - type Output = [Polynomial; l]; - - fn index(&self, index: usize) -> &Self::Output { - &self.0[index] - } -} -/// Convenience function to avoid ".0" all over the place. -impl IndexMut for Matrix { - fn index_mut(&mut self, index: usize) -> &mut Self::Output { - &mut self.0[index] - } +pub struct Matrix { + /// Indexed `elems[row][col]` + pub(crate) elems: [[Polynomial; l]; k], } impl Matrix { pub(crate) fn new() -> Self { - Self { 0: [[(); l]; k].map(|_| [(); l].map(|_| Polynomial::new())) } + Self { elems: [[(); l]; k].map(|_| [(); l].map(|_| Polynomial::new())) } } /// Algorithm 48 MatrixVectorNTT(๐Œ, ๐ฏ) @@ -39,18 +27,18 @@ impl Matrix { /// Performs dot product multiplication of this matrix by a vector /// Input: vector of length l /// Output: vector of length k - pub fn matrix_vector_ntt(&self, v: &Vector) -> Vector { + pub(crate) fn matrix_vector_ntt(&self, v: &Vector) -> Vector { let mut w = Vector::::new(); for i in 0..k { // split out the 0 case to skip a no-op add_ntt() - w[i].coeffs.copy_from_slice(&multiply_ntt(&self[i][0], &v[0]).coeffs); + w[i].coeffs.copy_from_slice(&multiply_ntt(&self.elems[i][0], &v[0]).coeffs); let mut w1: Polynomial; for j in 1..l { // dot product a vector into a matrix: multiply the input vector // into each row of the matrix, then sum the results to produce a vector of // length k. - w1 = multiply_ntt(&self[i][j], &v[j]); + w1 = multiply_ntt(&self.elems[i][j], &v[j]); w[i].add_ntt(&w1); } } @@ -61,7 +49,7 @@ impl Matrix { #[derive(Clone, Copy)] pub(crate) struct Vector { - pub(crate) vec: [Polynomial; LEN], + pub(crate) elems: [Polynomial; LEN], } /// Convenience function to avoid ".0" all over the place. @@ -69,13 +57,13 @@ impl Index for Vector { type Output = Polynomial; fn index(&self, index: usize) -> &Self::Output { - &self.vec[index] + &self.elems[index] } } /// Convenience function to avoid ".0" all over the place. impl IndexMut for Vector { fn index_mut(&mut self, index: usize) -> &mut Self::Output { - &mut self.vec[index] + &mut self.elems[index] } } @@ -85,7 +73,7 @@ impl ZeroizablePrimitive for Vector { impl Vector { pub(crate) const fn new() -> Self { - Self { vec: [Polynomial::new(); LEN] } + Self { elems: [Polynomial::new(); LEN] } } /// Algorithm 46 AddVectorNTT(๐ฏ, ๐ฐ)ฬ‚ @@ -176,7 +164,7 @@ impl Vector { pub(crate) fn check_norm(&self) -> bool { // Fine that this is not constant-time because it is used in a rejection loop -- the early quit leads to rejection. - for x in self.vec.iter() { + for x in self.elems.iter() { if x.check_norm::() { return true; } @@ -196,7 +184,7 @@ impl Vector { // 2: for ๐‘– from 0 to ๐‘˜ โˆ’ 1 do // 3: ๐ฐฬƒ1 โ† ๐ฐฬƒ1 || SimpleBitPack (๐ฐ1[๐‘–], (๐‘ž โˆ’ 1)/(2๐›พ2) โˆ’ 1) // 4: end for - for w in self.vec.iter() { + for w in self.elems.iter() { h.absorb(&w.w1_encode::()) .expect("absorb before squeeze is infallible"); } diff --git a/crypto/mldsa/src/mldsa_keys.rs b/crypto/mldsa/src/mldsa_keys.rs index b3c84d6f..456a26c8 100644 --- a/crypto/mldsa/src/mldsa_keys.rs +++ b/crypto/mldsa/src/mldsa_keys.rs @@ -105,7 +105,7 @@ impl MLDSAPublicKey MLDSAPublicKeyTrait(&sk_chunk).coeffs); @@ -650,7 +650,7 @@ impl(&sk_chunk).coeffs); @@ -675,7 +675,7 @@ impl`. #[derive(Clone, Copy)] -pub struct Polynomial { +pub(crate) struct Polynomial { pub(crate) coeffs: [i32; N], } diff --git a/crypto/mlkem-lowmemory/src/lib.rs b/crypto/mlkem-lowmemory/src/lib.rs index ae933459..250c9ea6 100644 --- a/crypto/mlkem-lowmemory/src/lib.rs +++ b/crypto/mlkem-lowmemory/src/lib.rs @@ -264,6 +264,3 @@ pub use mlkem::{MLKEM_RND_LEN, MLKEM_SEED_LEN, MLKEM_SS_LEN}; pub use mlkem::{MLKEM512_CT_LEN, MLKEM512_PK_LEN, MLKEM512_SK_LEN}; pub use mlkem::{MLKEM768_CT_LEN, MLKEM768_PK_LEN, MLKEM768_SK_LEN}; pub use mlkem::{MLKEM1024_CT_LEN, MLKEM1024_PK_LEN, MLKEM1024_SK_LEN}; - -// re-export just so it is visible to unit tests -pub use polynomial::Polynomial; diff --git a/crypto/mlkem-lowmemory/src/polynomial.rs b/crypto/mlkem-lowmemory/src/polynomial.rs index 28d7bbc7..20684c02 100644 --- a/crypto/mlkem-lowmemory/src/polynomial.rs +++ b/crypto/mlkem-lowmemory/src/polynomial.rs @@ -7,9 +7,6 @@ use crate::mlkem::{N, q}; use core::ops::{Index, IndexMut}; /// A polynomial over the ML-KEM ring. -/// Dev note: this doesn't strictly need to be pub ... ie there's no good reason for a caller to use this class directly, -/// but in order to test the Debug and Display traits, you need STD, so those can't be tested from inline tests in this file -/// and the real unit tests are in a different crate, so here we are. /// /// # ๐Ÿšจ Security ๐Ÿšจ /// Polynomials themselves are not inherently secret since sometimes they are part of public keys @@ -17,7 +14,7 @@ use core::ops::{Index, IndexMut}; /// It is the responsibility of the caller to wrap sensitive instances in `Secret`. /// Note: at the moment, nothing in this crate uses `Secret`, so I have left the `impl ZeroizablePrimitive` commented-out. #[derive(Clone, Copy)] -pub struct Polynomial { +pub(crate) struct Polynomial { pub(crate) coeffs: [i16; N], } diff --git a/crypto/mlkem/src/aux_functions.rs b/crypto/mlkem/src/aux_functions.rs index 09998374..dbd71e0f 100644 --- a/crypto/mlkem/src/aux_functions.rs +++ b/crypto/mlkem/src/aux_functions.rs @@ -1,8 +1,8 @@ //! Implements auxiliary functions for ML-DSA as defined in Section 7 of FIPS 204. -use crate::matrix::Vector; +use crate::matrix::{Matrix, Vector}; use crate::mlkem::{N, q, q_inv}; -use crate::{Matrix, Polynomial}; +use crate::polynomial::Polynomial; use bouncycastle_core::traits::XOF; use bouncycastle_sha3::{SHAKE128, SHAKE256}; @@ -13,7 +13,7 @@ pub(crate) fn expandA(rho: &[u8; 32]) -> Matrix { for j in 0..k { // 6: ๐€[๐‘–, ๐‘—] โ† SampleNTT(๐œŒโ€–๐‘—โ€–๐‘–) // โ–ท ๐‘— and ๐‘– are bytes 33 and 34 of the input - A_hat[i][j] = sample_ntt(rho, &[j as u8, i as u8]); + A_hat.elems[i][j] = sample_ntt(rho, &[j as u8, i as u8]); } } diff --git a/crypto/mlkem/src/lib.rs b/crypto/mlkem/src/lib.rs index 0dbbb449..cfd91c3f 100644 --- a/crypto/mlkem/src/lib.rs +++ b/crypto/mlkem/src/lib.rs @@ -153,11 +153,11 @@ #[allow(unused_imports)] use bouncycastle_core::key_material::KeyMaterialTrait; -pub mod aux_functions; +mod aux_functions; mod matrix; pub mod mlkem; mod mlkem_keys; -pub mod polynomial; +mod polynomial; /*** Exported types ***/ pub use mlkem::{MLKEM, MLKEM512, MLKEM768, MLKEM1024, MLKEMTrait}; @@ -187,6 +187,3 @@ pub use mlkem::{MLKEM768_CT_LEN, MLKEM768_PK_LEN, MLKEM768_SK_LEN}; pub use mlkem::{MLKEM1024_CT_LEN, MLKEM1024_PK_LEN, MLKEM1024_SK_LEN}; pub use matrix::Matrix; - -// re-export just so it's visible to unit tests -pub use polynomial::Polynomial; diff --git a/crypto/mlkem/src/matrix.rs b/crypto/mlkem/src/matrix.rs index de2cddc9..93356585 100644 --- a/crypto/mlkem/src/matrix.rs +++ b/crypto/mlkem/src/matrix.rs @@ -11,27 +11,13 @@ use bouncycastle_utils::secret::ZeroizablePrimitive; #[derive(Clone)] /// A matrix over the ML-KEM ring. pub struct Matrix { - /*pub(crate)*/ mat: [[Polynomial; l]; k], -} - -/// Convenience function to avoid ".0" all over the place. -impl Index for Matrix { - type Output = [Polynomial; l]; - - fn index(&self, index: usize) -> &Self::Output { - &self.mat[index] - } -} -/// Convenience function to avoid ".0" all over the place. -impl IndexMut for Matrix { - fn index_mut(&mut self, index: usize) -> &mut Self::Output { - &mut self.mat[index] - } + /// Indexed `elems[row][col]` + pub(crate) elems: [[Polynomial; l]; k], } impl Matrix { pub(crate) fn new() -> Self { - Self { mat: [[(); l]; k].map(|_| [(); l].map(|_| Polynomial::new())) } + Self { elems: [[(); l]; k].map(|_| [(); l].map(|_| Polynomial::new())) } } /// FIPS 204 Algorithm 48 MatrixVectorNTT(๐Œ, ๐ฏ) @@ -41,15 +27,15 @@ impl Matrix { /// Input: vector of length l /// Output: vector of length k /// - /// transpose: False will multiply A, where as True will multiply A^T + /// `transpose`: False will multiply A, where as True will multiply A^T pub(crate) fn matrix_vector_ntt(&self, v: &Vector) -> Vector { let mut w = Vector::::new(); for i in 0..k { // split out the 0 case to skip a no-op add_ntt() w[i] = if transpose { - polynomial::base_mult_montgomery(&self.mat[0][i], &v[0]) + polynomial::base_mult_montgomery(&self.elems[0][i], &v[0]) } else { - polynomial::base_mult_montgomery(&self.mat[i][0], &v[0]) + polynomial::base_mult_montgomery(&self.elems[i][0], &v[0]) }; let mut w1: Polynomial; @@ -58,9 +44,9 @@ impl Matrix { // into each row of the matrix, then sum the results to produce a vector of // length k. w1 = if transpose { - polynomial::base_mult_montgomery(&self.mat[j][i], &v[j]) + polynomial::base_mult_montgomery(&self.elems[j][i], &v[j]) } else { - polynomial::base_mult_montgomery(&self.mat[i][j], &v[j]) + polynomial::base_mult_montgomery(&self.elems[i][j], &v[j]) }; w[i].add(&w1); @@ -80,7 +66,7 @@ impl Matrix { #[derive(Clone, Copy)] pub(crate) struct Vector { - pub(crate) vec: [Polynomial; k], + pub(crate) elems: [Polynomial; k], } /// Convenience function to avoid ".0" all over the place. @@ -88,13 +74,13 @@ impl Index for Vector { type Output = Polynomial; fn index(&self, index: usize) -> &Self::Output { - &self.vec[index] + &self.elems[index] } } /// Convenience function to avoid ".0" all over the place. impl IndexMut for Vector { fn index_mut(&mut self, index: usize) -> &mut Self::Output { - &mut self.vec[index] + &mut self.elems[index] } } @@ -104,7 +90,7 @@ impl ZeroizablePrimitive for Vector { impl Vector { pub(crate) const fn new() -> Self { - Self { vec: [Polynomial::new(); k] } + Self { elems: [Polynomial::new(); k] } } /// Algorithm 46 AddVectorNTT(๐ฏ, ๐ฐ)ฬ‚ diff --git a/crypto/mlkem/src/mlkem_keys.rs b/crypto/mlkem/src/mlkem_keys.rs index 63398f29..8fd2bb8a 100644 --- a/crypto/mlkem/src/mlkem_keys.rs +++ b/crypto/mlkem/src/mlkem_keys.rs @@ -118,7 +118,7 @@ impl MLKEMPublicKeyTrait let t_hat = { let mut t_hat = Vector::::new(); - for (t_i, pk_chunk) in t_hat.vec.iter_mut().zip(pk_chunks) { + for (t_i, pk_chunk) in t_hat.elems.iter_mut().zip(pk_chunks) { t_i.coeffs.copy_from_slice(&byte_decode::<12, POLY_BYTES>(pk_chunk).coeffs); // FIPS 203 says: @@ -188,7 +188,7 @@ impl KEMPublicKey for MLKEMPublicKe debug_assert_eq!(pk_chunks.len(), k); debug_assert_eq!(last_chunk.len(), 32); - for (pk_chunk, t_i) in pk_chunks.into_iter().zip(&self.t_hat.vec) { + for (pk_chunk, t_i) in pk_chunks.into_iter().zip(&self.t_hat.elems) { pk_chunk.copy_from_slice(&byte_encode::<12, POLY_BYTES>(t_i)); } last_chunk.copy_from_slice(&self.rho); @@ -367,7 +367,7 @@ impl, const PK_LEN: u /// An ML-KEM private key. /// -/// This will automatically inherit the [`Secret`] protections because [`Polynomial`] wraps the underlying data with [`Secret`]. +// Dev note: This will automatically inherit the [`Secret`] protections because [`Polynomial`] wraps the underlying data with [`Secret`]. #[derive(Clone)] pub struct MLKEMPrivateKey< const k: usize, diff --git a/crypto/mlkem/src/polynomial.rs b/crypto/mlkem/src/polynomial.rs index 40025286..9d913db0 100644 --- a/crypto/mlkem/src/polynomial.rs +++ b/crypto/mlkem/src/polynomial.rs @@ -9,20 +9,13 @@ use crate::mlkem::{N, q}; /// A polynomial over the ML-KEM ring. /// -/// Dev note: The following structure does not necessarily need to be declared as public. -/// There is no real scenario where this function needs to be called directly. -/// However, in order to test the Debug and Display traits, it is necessary to use STD, so those -/// can't be tested from inline tests in this file and the real unit tests are in a different crate. -/// That's the reason why pub is used. -/// /// # ๐Ÿšจ Security ๐Ÿšจ /// Polynomials themselves are not inherently secret since sometimes they are part of public keys /// and sometimes private keys. /// It is the responsibility of the caller to wrap sensitive instances in `Secret`. #[derive(Clone, Copy)] -pub struct Polynomial { - /// Note: this is exposed publicly only for testing purposes and there is no good reason to use it in production code. - pub coeffs: [i16; N], +pub(crate) struct Polynomial { + pub(crate) coeffs: [i16; N], } /// Convenience function to avoid ".0" all over the place. @@ -263,8 +256,7 @@ impl Polynomial { /// Computes the NTT representation ๐‘“_hat of the given polynomial ๐‘“ โˆˆ ๐‘…๐‘ž. /// Input: array ๐‘“ โˆˆ โ„ค256 โ–ท the coefficients of the input polynomial /// Output: array ๐‘“_hat โˆˆ โ„ค256 โ–ท the coefficients of the NTT of the input polynomial - /// Note: this is exposed publicly only for testing purposes and there is no good reason to use it in production code. - pub fn ntt(&mut self) { + pub(crate) fn ntt(&mut self) { let mut len = 128; let mut k = 1; @@ -290,8 +282,7 @@ impl Polynomial { /// Computes the polynomial ๐‘“ โˆˆ ๐‘…๐‘ž that corresponds to the given NTT representation ๐‘“ โˆˆ ๐‘‡๐‘ž. /// Input: array ๐‘“ โˆˆ โ„ค_{256} โ–ท the coefficients of input NTT representation /// Output: array ๐‘“ โˆˆ โ„ค_{256} โ–ท the coefficients of the inverse NTT of the input - /// Note: this is exposed publicly only for testing purposes and there is no good reason to use it in production code. - pub fn inv_ntt(&mut self) { + pub(crate) fn inv_ntt(&mut self) { // FIPS 203 Alg 10 wants you to copy f_hat into f, and then act on f // but here it is performed in-place in order to optimize memory usage. @@ -330,7 +321,7 @@ impl Polynomial { /// Borrowed from: /// /// Note: this is exposed publicly only for testing purposes and there is no good reason to use it in production code. -pub fn base_mult_montgomery(a: &Polynomial, b: &Polynomial) -> Polynomial { +pub(crate) fn base_mult_montgomery(a: &Polynomial, b: &Polynomial) -> Polynomial { let mut r = Polynomial::new(); for i in 0..(N / 4) { From c0263399aa02ac1b6ab3f01359094ec6bbcf20c0 Mon Sep 17 00:00:00 2001 From: Mike Ounsworth Date: Tue, 25 Aug 2026 09:46:31 -0500 Subject: [PATCH 14/28] Updated CLAUDE.md to cover spec-aware development for RFCs and NIST documents --- CLAUDE.md | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index f177de91..6f858b53 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,11 +79,37 @@ These are non-obvious house rules โ€” follow them when writing or modifying code - **One-shot static APIs are the default.** Every primitive should expose a take-data-return-result static method in addition to any streaming API. - **Sensitive types impl `core::Secret` (and its supertraits).** Anything that holds key material needs this โ€” don't reach for raw byte arrays for secrets. - **`unwrap()` requires justification.** Either a preceding check that proves success, or an inline comment explaining why it's infallible. -- **Spec correspondence in comments.** Code that mirrors a FIPS/NIST/RFC spec should be commented line-by-line against the spec. Any deliberate deviation must be called out and justified. The "would 6-months-from-now me need >10 minutes to re-understand this?" check is the bar. +- **Spec correspondence in comments.** Code that mirrors a FIPS/NIST/RFC spec should be commented line-by-line against the spec, citing section/algorithm/step numbers. Any deliberate deviation must be called out and justified. The "would 6-months-from-now me need >10 minutes to re-understand this?" check is the bar. Never write or check these comments from memory โ€” see "Working from specifications" below. - **Every primitive crate must ship: tests (`src/tests` or `tests/`), criterion benches in `benches/`, and a CLI subcommand.** Stack-memory characteristics matter โ€” algorithms with non-trivial stack usage get a `mem_usage_benches/` harness. - **CLI commands stream.** The `cli/` binary's design is stdinโ†’stdout with ~1 KB buffers so commands compose in shell pipelines; preserve that when adding subcommands. - **Crate docs must include sections:** "Usage Examples", "Memory Usage" (stack-usage table), and usually "Security Considerations". +## Working from specifications + +**Never cite, paraphrase, or implement a specification from recall.** Model recall of RFC text, FIPS algorithm steps, NIST parameter tables, and section numbering is unreliable โ€” plausible-looking but wrong step numbers and subtly wrong constants are the failure mode. Before writing or reviewing any code, comment, or doc that references a spec, download a fresh copy and read the relevant part of it. + +Where to get them: + +``` +# RFCs โ€” plain text is easiest to grep and quote +curl -sL https://www.rfc-editor.org/rfc/rfc8446.txt -o "$SCRATCH/rfc8446.txt" + +# NIST FIPS (e.g. FIPS 203 ML-KEM, FIPS 204 ML-DSA, FIPS 202 SHA-3, FIPS 180-4 SHA-2) +curl -sL https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.203.pdf -o "$SCRATCH/FIPS-203.pdf" + +# NIST SP 800-series (note the revision suffix, e.g. r2) +curl -sL https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-56Cr2.pdf -o "$SCRATCH/SP-800-56Cr2.pdf" +``` + +Download into the session scratchpad directory, not into the repo โ€” spec PDFs must never be committed. Read PDFs with the `Read` tool's `pages` parameter (max 20 pages per call); if a download fails or the URL 404s, say so and ask rather than falling back on recall. + +Rules when working from the downloaded copy: + +- **Quote exactly, and locate precisely.** Comments and commit messages should name the document with its revision (e.g. "FIPS 203, Algorithm 13 (ML-KEM.Encaps_internal), step 2", "RFC 5869 ยง2.2"), and quote the spec verbatim where a quote is clearer than a paraphrase. Verify every section/algorithm/step number against the file you just downloaded โ€” including numbers already present in the code, which may predate a spec revision. +- **The specification is the source of truth for correct behaviour** โ€” not the C/Java/Go implementation you have seen, not the BC Java or BC C# port, and not another crate. When an existing implementation appears to disagree with the spec, re-read the spec, and if the disagreement is real, follow the spec and note the discrepancy in the PR description rather than silently copying the other implementation. +- **Optimizations are allowed, provided externally-visible behaviour is identical.** Restructuring loops, fusing steps, precomputing tables, constant-time rewrites, and in-place buffer reuse are all fine โ€” the spec constrains observable outputs (and, for this library, timing behaviour on secret data), not the shape of the code. Any such deviation from the spec's literal steps gets a comment saying which spec steps it implements and why it is equivalent. +- **Test vectors come from the spec or its official companion files** (NIST CAVP / ACVP vectors, RFC test-vector appendices), downloaded the same way. Never hand-write an "expected" value from recall. + ## Notes on testing - `cargo mutants` is expected to be run on each crate; surviving mutants must be investigated but not all need to die (e.g. XOR/OR equivalences in crypto code are acceptable). Config lives in `.cargo/mutants.toml` (output dir `custom_mutants_output/`). From e537e2313cda983d07b822dafe998a4a75d6fde5 Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 30 Aug 2026 17:24:33 +1000 Subject: [PATCH 15/28] core: split BlockCipher into block-aligned BlockCipherEncryptor/Decryptor Rework the block cipher streaming traits ahead of the first mode implementations: - Split the single BlockCipher trait into BlockCipherEncryptor and BlockCipherDecryptor (mirroring KEMEncapsulator/KEMDecapsulator) so the direction can be encoded in the implementing type. A minimal BlockCipher supertrait carries the shared MAX_SECURITY_STRENGTH. The SymmetricCipher one-shot API is no longer a supertrait. - Replace the single-block do_{en,de}crypt_block[_out] with do_{en,de}crypt_blocks[_out], taking &[[u8; BLOCK_LEN]; N] so the block count is compile-time and in/out lengths cannot disagree. - Add do_encrypt_init_rng(key, &mut dyn RNG) alongside do_encrypt_init, matching the encaps/encaps_rng pattern. - Remove the do_{en,de}crypt_final[_out] methods. The traits are now strictly block-aligned; padding of arbitrary-length data belongs to a separate PaddedEncryptor/PaddedDecryptor layer to be built on top. Update the core-test-framework block cipher test to take separate encryptor/decryptor type parameters and to exercise N = 1 and N = 2, including mixed single/multi-block encrypt vs decrypt sequences. Co-Authored-By: Claude Fable 5 --- .../src/symmetric_ciphers.rs | 73 +++++++++---- crypto/core/src/traits.rs | 103 +++++++++--------- 2 files changed, 107 insertions(+), 69 deletions(-) diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 57fc0ee1..67f10793 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -6,7 +6,7 @@ use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; use bouncycastle_core::traits::{ - AEADCipher, BlockCipher, SecurityStrength, StreamCipher, SymmetricCipher, + AEADCipher, BlockCipherDecryptor, BlockCipherEncryptor, SecurityStrength, StreamCipher, SymmetricCipher, }; /// Instance of the test framework. @@ -124,7 +124,8 @@ impl TestFrameworkBlockCipher { const KEY_LEN: usize, const INIT_DATA_LEN: usize, const BLOCK_LEN: usize, - C: BlockCipher, + E: BlockCipherEncryptor, + D: BlockCipherDecryptor, >( &self, ) { @@ -135,42 +136,76 @@ impl TestFrameworkBlockCipher { .unwrap(); // to test blocks, we'll chunk our dummy seed - let (mut encryptor, iv) = C::do_encrypt_init(&key).unwrap(); - let mut decryptor = C::do_decrypt_init(&key, &iv).unwrap(); + let (mut encryptor, iv) = E::do_encrypt_init(&key).unwrap(); + let mut decryptor = D::do_decrypt_init(&key, &iv).unwrap(); + // one block at a time (N = 1) for msg_chunk in DUMMY_SEED.as_chunks::().0.iter() { - let ct = encryptor.do_encrypt_block(msg_chunk).unwrap(); - let pt = decryptor.do_decrypt_block(&ct).unwrap(); + let ct = encryptor.do_encrypt_blocks(&[*msg_chunk]).unwrap(); + let [pt] = decryptor.do_decrypt_blocks(&ct).unwrap(); assert_eq!(msg_chunk, &pt); } // do it again using the _out versions - let (mut encryptor, iv) = C::do_encrypt_init(&key).unwrap(); - let mut decryptor = C::do_decrypt_init(&key, &iv).unwrap(); + let (mut encryptor, iv) = E::do_encrypt_init(&key).unwrap(); + let mut decryptor = D::do_decrypt_init(&key, &iv).unwrap(); - let mut ct = [0u8; BLOCK_LEN]; - let mut pt = [0u8; BLOCK_LEN]; + let mut ct = [[0u8; BLOCK_LEN]; 1]; + let mut pt = [[0u8; BLOCK_LEN]; 1]; for msg_chunk in DUMMY_SEED.as_chunks::().0.iter() { - let ct_bytes_written = encryptor.do_encrypt_block_out(msg_chunk, &mut ct).unwrap(); + let ct_bytes_written = encryptor + .do_encrypt_blocks_out(&[*msg_chunk], &mut ct) + .unwrap(); assert_eq!(ct_bytes_written, BLOCK_LEN); - let pt_bytes_written = decryptor.do_decrypt_block_out(&ct, &mut pt).unwrap(); + let pt_bytes_written = decryptor.do_decrypt_blocks_out(&ct, &mut pt).unwrap(); assert_eq!(pt_bytes_written, BLOCK_LEN); - assert_eq!(msg_chunk, &pt); + assert_eq!(msg_chunk, &pt[0]); + } + + // multi-block (N = 2): blocks encrypted together must decrypt both together and one at a time, + // and blocks encrypted one at a time must decrypt together. + let (mut encryptor, iv) = E::do_encrypt_init(&key).unwrap(); + let mut decryptor = D::do_decrypt_init(&key, &iv).unwrap(); + + let mut ct = [[0u8; BLOCK_LEN]; 2]; + let mut pt = [[0u8; BLOCK_LEN]; 2]; + for msg_pair in DUMMY_SEED.as_chunks::().0.as_chunks::<2>().0.iter() { + // encrypt together, decrypt together (by value) + let ct_by_value = encryptor.do_encrypt_blocks(msg_pair).unwrap(); + let pt_by_value = decryptor.do_decrypt_blocks(&ct_by_value).unwrap(); + assert_eq!(msg_pair, &pt_by_value); + + // encrypt together (_out), decrypt one at a time + let ct_bytes_written = encryptor.do_encrypt_blocks_out(msg_pair, &mut ct).unwrap(); + assert_eq!(ct_bytes_written, 2 * BLOCK_LEN); + for (msg_chunk, ct_chunk) in msg_pair.iter().zip(ct.iter()) { + let [pt] = decryptor.do_decrypt_blocks(&[*ct_chunk]).unwrap(); + assert_eq!(msg_chunk, &pt); + } + + // encrypt one at a time, decrypt together (_out) + for (msg_chunk, ct_chunk) in msg_pair.iter().zip(ct.iter_mut()) { + let [c] = encryptor.do_encrypt_blocks(&[*msg_chunk]).unwrap(); + *ct_chunk = c; + } + let pt_bytes_written = decryptor.do_decrypt_blocks_out(&ct, &mut pt).unwrap(); + assert_eq!(pt_bytes_written, 2 * BLOCK_LEN); + assert_eq!(msg_pair, &pt); } // test that the iv is random (ie not the same on two runs) - let (_encryptor, iv1) = C::do_encrypt_init(&key).unwrap(); - let (_encryptor, iv2) = C::do_encrypt_init(&key).unwrap(); + let (_encryptor, iv1) = E::do_encrypt_init(&key).unwrap(); + let (_encryptor, iv2) = E::do_encrypt_init(&key).unwrap(); assert_ne!(iv1, iv2); // error case: KeyMaterial of wrong type let mac_key = KeyMaterial::::from_bytes_as_type(&DUMMY_SEED[..KEY_LEN], KeyType::MACKey) .unwrap(); - match C::do_encrypt_init(&mac_key) { + match E::do_encrypt_init(&mac_key) { Err(SymmetricCipherError::KeyMaterialError(_)) => { /* good */ } _ => panic!("Unexpected error"), }; @@ -194,15 +229,15 @@ impl TestFrameworkBlockCipher { // (and bypasses the key-length guard) without complaining. do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap(); - match C::do_encrypt_init(&key) { + match E::do_encrypt_init(&key) { Ok(_) => { - if ss >= &C::MAX_SECURITY_STRENGTH { /* good */ + if ss >= &E::MAX_SECURITY_STRENGTH { /* good */ } else { panic!("Should have been a strong enough key"); } } Err(SymmetricCipherError::KeyMaterialError(_)) => { - if ss < &C::MAX_SECURITY_STRENGTH { /* good */ + if ss < &E::MAX_SECURITY_STRENGTH { /* good */ } else { panic!("Should not have accepted a key weaker than algorithm"); } diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 7e23d516..78e3698b 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -80,72 +80,75 @@ pub trait SymmetricCipher: Alg ) -> Result; } -/// The basic functions of a block cipher. +/// Metadata shared by [`BlockCipherEncryptor`] and [`BlockCipherDecryptor`]. +pub trait BlockCipher { + /// Maximum security strength supported by the algorithm; keys tagged with a lower strength are + /// rejected by the `_init` constructors. + const MAX_SECURITY_STRENGTH: SecurityStrength; +} + +/// The encryption half of a block cipher's streaming API. Strictly block-aligned: whole blocks in, whole +/// blocks out, no finalization step. Padding of non-block-aligned data is handled by a separate layer +/// (`PaddedEncryptor` / `PaddedDecryptor`) built on top of this trait. +/// +/// Encryption and decryption are separate traits (as with [`KEMEncapsulator`] / [`KEMDecapsulator`]) so +/// that the direction can be encoded in the type, and so that a policy can permit decryption of an +/// algorithm while forbidding new encryptions. +/// /// This trait allows for a block cipher to generate initialization data, such as an Initialization Vector (IV) or Counter (CTR) /// which is not technically part of the ciphertext, but must be transmitted along with the ciphertext in order for the /// recipient to perform successful decryption. The length of the initialization data is specified by the implementing struct /// via the `INIT_DATA_LEN` constant. -/// In order for these one-shot APIs to be usable securely in all contexts, the init data will be generated +/// In order for these APIs to be usable securely in all contexts, the init data will be generated /// securely by the block cipher implementation and returned along with the ciphertext, and there is no API for the /// user to provide the init data. If you require this functionality, see the documentation for the underlying implementation. -pub trait BlockCipher: - SymmetricCipher + Sized +pub trait BlockCipherEncryptor: + BlockCipher + Sized { - /// Constructor that begins a flow of the streaming API for encrypting one block at a time. - /// Allows for the implementation to return init data such as an IV which is generated prior to encrypting the first block. + /// Begins a streaming encryption flow, returning the generated init data (e.g. IV). + /// Sources randomness from the library's default OS-backed RNG. fn do_encrypt_init( key: &KeyMaterial, ) -> Result<(Self, [u8; INIT_DATA_LEN]), SymmetricCipherError>; - /// Encrypts a single block of plaintext. - fn do_encrypt_block( - &mut self, - plaintext: &[u8; BLOCK_LEN], - ) -> Result<[u8; BLOCK_LEN], SymmetricCipherError>; - /// Encrypts a single block of plaintext and writes the ciphertext to the provided buffer. - fn do_encrypt_block_out( - &mut self, - plaintext: &[u8; BLOCK_LEN], - ciphertext: &mut [u8; BLOCK_LEN], - ) -> Result; - /// Encrypts the final block of plaintext. - fn do_encrypt_final( + /// As [`BlockCipherEncryptor::do_encrypt_init`], but sources randomness from the provided RNG. + fn do_encrypt_init_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + ) -> Result<(Self, [u8; INIT_DATA_LEN]), SymmetricCipherError>; + /// Encrypts `N` consecutive blocks of plaintext. A sequence of calls is equivalent to one call over + /// the concatenation. + fn do_encrypt_blocks( &mut self, - plaintext: &[u8; BLOCK_LEN], - ) -> Result<[u8; BLOCK_LEN], SymmetricCipherError>; - /// Encrypts the final block of plaintext and writes the ciphertext to the provided buffer. - fn do_encrypt_final_out( + plaintext: &[[u8; BLOCK_LEN]; N], + ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError>; + /// Encrypts `N` consecutive blocks of plaintext into the provided buffer. Returns `N * BLOCK_LEN`. + fn do_encrypt_blocks_out( &mut self, - plaintext: &[u8; BLOCK_LEN], - ciphertext: &mut [u8; BLOCK_LEN], + plaintext: &[[u8; BLOCK_LEN]; N], + ciphertext: &mut [[u8; BLOCK_LEN]; N], ) -> Result; - /// Constructor that begins a flow of the streaming API for decryption one block at a time. +} + +/// The decryption half of a block cipher's streaming API; see [`BlockCipherEncryptor`]. +pub trait BlockCipherDecryptor: + BlockCipher + Sized +{ + /// Begins a streaming decryption flow from the init data returned by [`BlockCipherEncryptor::do_encrypt_init`]. fn do_decrypt_init( key: &KeyMaterial, init_data: &[u8; INIT_DATA_LEN], ) -> Result; - /// Decrypts a single block of ciphertext. - fn do_decrypt_block( - &mut self, - ciphertext: &[u8; BLOCK_LEN], - ) -> Result<[u8; BLOCK_LEN], SymmetricCipherError>; - /// Decrypts a single block of ciphertext and writes the plaintext to the provided buffer. - fn do_decrypt_block_out( + /// Decrypts `N` consecutive blocks of ciphertext. A sequence of calls is equivalent to one call over + /// the concatenation. + fn do_decrypt_blocks( &mut self, - ciphertext: &[u8; BLOCK_LEN], - plaintext: &mut [u8; BLOCK_LEN], - ) -> Result; - /// Decrypts the final block of ciphertext. - /// This is the decryption counterpart to [`BlockCipher::do_encrypt_final`] and is where an - /// implementation validates and strips any padding (or otherwise finalizes the flow). - fn do_decrypt_final( + ciphertext: &[[u8; BLOCK_LEN]; N], + ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError>; + /// Decrypts `N` consecutive blocks of ciphertext into the provided buffer. Returns `N * BLOCK_LEN`. + fn do_decrypt_blocks_out( &mut self, - ciphertext: &[u8; BLOCK_LEN], - ) -> Result<[u8; BLOCK_LEN], SymmetricCipherError>; - /// Decrypts the final block of ciphertext and writes the plaintext to the provided buffer. - fn do_decrypt_final_out( - &mut self, - ciphertext: &[u8; BLOCK_LEN], - plaintext: &mut [u8; BLOCK_LEN], + ciphertext: &[[u8; BLOCK_LEN]; N], + plaintext: &mut [[u8; BLOCK_LEN]; N], ) -> Result; } @@ -170,7 +173,7 @@ pub trait AEADCipher, @@ -178,7 +181,7 @@ pub trait AEADCipher Result<([u8; NONCE_LEN], usize, [u8; TAG_LEN]), SymmetricCipherError>; - /// All AEAD ciphers will also be either a [`BlockCipher`] or a [`StreamCipher`], and so will already + /// All AEAD ciphers will also be either a block cipher ([`BlockCipherEncryptor`] / [`BlockCipherDecryptor`]) or a [`StreamCipher`], and so will already /// have a streaming API. /// This allows you to finish either style of streaming API flow with AEAD specific do_final() /// that computes and returns the authentication tag. @@ -207,7 +210,7 @@ pub trait AEADCipher Result; - /// All AEAD ciphers will also be either a [`BlockCipher`] or a [`StreamCipher`], and so will already + /// All AEAD ciphers will also be either a block cipher ([`BlockCipherEncryptor`] / [`BlockCipherDecryptor`]) or a [`StreamCipher`], and so will already /// have a streaming API. /// This allows you to finish either style of streaming API flow with AEAD specific do_final() /// that computes and returns the authentication tag. From e6ad944cc89aaef03a113edd733644325caf3401 Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 30 Aug 2026 18:03:56 +1000 Subject: [PATCH 16/28] rustfmt: wrap long trait signatures and imports Formatting for the previous commit; no semantic change. Co-Authored-By: Claude Fable 5 --- .../core-test-framework/src/symmetric_ciphers.rs | 7 +++---- crypto/core/src/traits.rs | 14 ++++++++++---- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 67f10793..14f0eed7 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -6,7 +6,8 @@ use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; use bouncycastle_core::traits::{ - AEADCipher, BlockCipherDecryptor, BlockCipherEncryptor, SecurityStrength, StreamCipher, SymmetricCipher, + AEADCipher, BlockCipherDecryptor, BlockCipherEncryptor, SecurityStrength, StreamCipher, + SymmetricCipher, }; /// Instance of the test framework. @@ -154,9 +155,7 @@ impl TestFrameworkBlockCipher { let mut ct = [[0u8; BLOCK_LEN]; 1]; let mut pt = [[0u8; BLOCK_LEN]; 1]; for msg_chunk in DUMMY_SEED.as_chunks::().0.iter() { - let ct_bytes_written = encryptor - .do_encrypt_blocks_out(&[*msg_chunk], &mut ct) - .unwrap(); + let ct_bytes_written = encryptor.do_encrypt_blocks_out(&[*msg_chunk], &mut ct).unwrap(); assert_eq!(ct_bytes_written, BLOCK_LEN); let pt_bytes_written = decryptor.do_decrypt_blocks_out(&ct, &mut pt).unwrap(); diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 78e3698b..4f24d842 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -102,8 +102,11 @@ pub trait BlockCipher { /// In order for these APIs to be usable securely in all contexts, the init data will be generated /// securely by the block cipher implementation and returned along with the ciphertext, and there is no API for the /// user to provide the init data. If you require this functionality, see the documentation for the underlying implementation. -pub trait BlockCipherEncryptor: - BlockCipher + Sized +pub trait BlockCipherEncryptor< + const KEY_LEN: usize, + const INIT_DATA_LEN: usize, + const BLOCK_LEN: usize, +>: BlockCipher + Sized { /// Begins a streaming encryption flow, returning the generated init data (e.g. IV). /// Sources randomness from the library's default OS-backed RNG. @@ -130,8 +133,11 @@ pub trait BlockCipherEncryptor: - BlockCipher + Sized +pub trait BlockCipherDecryptor< + const KEY_LEN: usize, + const INIT_DATA_LEN: usize, + const BLOCK_LEN: usize, +>: BlockCipher + Sized { /// Begins a streaming decryption flow from the init data returned by [`BlockCipherEncryptor::do_encrypt_init`]. fn do_decrypt_init( From 1a44d5bcf68547886be67d92b7315ce6836640f9 Mon Sep 17 00:00:00 2001 From: David Hook Date: Sun, 30 Aug 2026 20:47:27 +1000 Subject: [PATCH 17/28] core: add one-shot encrypt_blocks/decrypt_blocks to the block cipher traits Provided (default) methods on BlockCipherEncryptor -- encrypt_blocks, encrypt_blocks_rng, encrypt_blocks_out, encrypt_blocks_out_rng -- and on BlockCipherDecryptor -- decrypt_blocks, decrypt_blocks_out -- implemented once in the trait as init + blocks, so every block-aligned mode gets the house-standard take-data-return-result static API at no cost to implementors. Arbitrary-length one-shots remain the padding layer's job. The core-test-framework block cipher test now checks the one-shots agree with the streaming API and round-trip. Co-Authored-By: Claude Fable 5 --- .../src/symmetric_ciphers.rs | 15 +++++ crypto/core/src/traits.rs | 56 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 14f0eed7..6e1c8534 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -195,6 +195,21 @@ impl TestFrameworkBlockCipher { assert_eq!(msg_pair, &pt); } + // one-shot API: must agree with the streaming API for the same key, and round-trip + let two_blocks: &[[u8; BLOCK_LEN]; 2] = + &DUMMY_SEED.as_chunks::().0.as_chunks::<2>().0[0]; + let (iv, ct) = E::encrypt_blocks(&key, two_blocks).unwrap(); + assert_eq!(D::decrypt_blocks(&key, &iv, &ct).unwrap(), *two_blocks); + let mut streamed = D::do_decrypt_init(&key, &iv).unwrap(); + assert_eq!(streamed.do_decrypt_blocks(&ct).unwrap(), *two_blocks); + + let mut ct = [[0u8; BLOCK_LEN]; 2]; + let mut pt = [[0u8; BLOCK_LEN]; 2]; + let (iv, n) = E::encrypt_blocks_out(&key, two_blocks, &mut ct).unwrap(); + assert_eq!(n, 2 * BLOCK_LEN); + assert_eq!(D::decrypt_blocks_out(&key, &iv, &ct, &mut pt).unwrap(), 2 * BLOCK_LEN); + assert_eq!(pt, *two_blocks); + // test that the iv is random (ie not the same on two runs) let (_encryptor, iv1) = E::do_encrypt_init(&key).unwrap(); let (_encryptor, iv2) = E::do_encrypt_init(&key).unwrap(); diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index 4f24d842..e13cbc8b 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -130,6 +130,44 @@ pub trait BlockCipherEncryptor< plaintext: &[[u8; BLOCK_LEN]; N], ciphertext: &mut [[u8; BLOCK_LEN]; N], ) -> Result; + + /// One-shot: encrypts `N` blocks under a fresh init. Returns the generated init data and the ciphertext. + fn encrypt_blocks( + key: &KeyMaterial, + plaintext: &[[u8; BLOCK_LEN]; N], + ) -> Result<([u8; INIT_DATA_LEN], [[u8; BLOCK_LEN]; N]), SymmetricCipherError> { + let (mut enc, init_data) = Self::do_encrypt_init(key)?; + Ok((init_data, enc.do_encrypt_blocks(plaintext)?)) + } + /// As [`BlockCipherEncryptor::encrypt_blocks`], but sources randomness from the provided RNG. + fn encrypt_blocks_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + plaintext: &[[u8; BLOCK_LEN]; N], + ) -> Result<([u8; INIT_DATA_LEN], [[u8; BLOCK_LEN]; N]), SymmetricCipherError> { + let (mut enc, init_data) = Self::do_encrypt_init_rng(key, rng)?; + Ok((init_data, enc.do_encrypt_blocks(plaintext)?)) + } + /// One-shot: encrypts `N` blocks under a fresh init into the provided buffer. + /// Returns the generated init data and `N * BLOCK_LEN`. + fn encrypt_blocks_out( + key: &KeyMaterial, + plaintext: &[[u8; BLOCK_LEN]; N], + ciphertext: &mut [[u8; BLOCK_LEN]; N], + ) -> Result<([u8; INIT_DATA_LEN], usize), SymmetricCipherError> { + let (mut enc, init_data) = Self::do_encrypt_init(key)?; + Ok((init_data, enc.do_encrypt_blocks_out(plaintext, ciphertext)?)) + } + /// As [`BlockCipherEncryptor::encrypt_blocks_out`], but sources randomness from the provided RNG. + fn encrypt_blocks_out_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + plaintext: &[[u8; BLOCK_LEN]; N], + ciphertext: &mut [[u8; BLOCK_LEN]; N], + ) -> Result<([u8; INIT_DATA_LEN], usize), SymmetricCipherError> { + let (mut enc, init_data) = Self::do_encrypt_init_rng(key, rng)?; + Ok((init_data, enc.do_encrypt_blocks_out(plaintext, ciphertext)?)) + } } /// The decryption half of a block cipher's streaming API; see [`BlockCipherEncryptor`]. @@ -156,6 +194,24 @@ pub trait BlockCipherDecryptor< ciphertext: &[[u8; BLOCK_LEN]; N], plaintext: &mut [[u8; BLOCK_LEN]; N], ) -> Result; + + /// One-shot: decrypts `N` blocks from the given init data. + fn decrypt_blocks( + key: &KeyMaterial, + init_data: &[u8; INIT_DATA_LEN], + ciphertext: &[[u8; BLOCK_LEN]; N], + ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError> { + Self::do_decrypt_init(key, init_data)?.do_decrypt_blocks(ciphertext) + } + /// One-shot: decrypts `N` blocks from the given init data into the provided buffer. Returns `N * BLOCK_LEN`. + fn decrypt_blocks_out( + key: &KeyMaterial, + init_data: &[u8; INIT_DATA_LEN], + ciphertext: &[[u8; BLOCK_LEN]; N], + plaintext: &mut [[u8; BLOCK_LEN]; N], + ) -> Result { + Self::do_decrypt_init(key, init_data)?.do_decrypt_blocks_out(ciphertext, plaintext) + } } /// The basic functions of an Authenticated Encryption with Addititional Data cipher. From b770f56f355956483adf508604bea714623f12b6 Mon Sep 17 00:00:00 2001 From: David Hook Date: Mon, 31 Aug 2026 11:55:51 +1000 Subject: [PATCH 18/28] Add 0.1.3 release notes for the block cipher trait changes (PR #96) Co-Authored-By: Claude Fable 5 --- alpha_0.1.3_release_notes.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index 210a5aeb..da7af240 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -3,3 +3,26 @@ ## Major features ## Minor features / bug fixes + +Block cipher traits (PR #96): + +* The single `BlockCipher` streaming trait is split into `BlockCipherEncryptor` and `BlockCipherDecryptor` (mirroring + `KEMEncapsulator` / `KEMDecapsulator`) so the direction is encoded in the implementing type. A minimal `BlockCipher` + supertrait carries the shared `MAX_SECURITY_STRENGTH`; the `SymmetricCipher` one-shot API is no longer a supertrait. +* The single-block `do_{en,de}crypt_block[_out]` methods are replaced by multi-block + `do_{en,de}crypt_blocks[_out]`, taking `&[[u8; BLOCK_LEN]; N]` so the block count is compile-time and + input/output lengths cannot disagree. +* `do_encrypt_init_rng(key, &mut dyn RNG)` is added alongside `do_encrypt_init`, matching the `encaps` / `encaps_rng` + pattern. +* The `do_{en,de}crypt_final[_out]` methods are removed: the traits are now strictly block-aligned, and padding of + arbitrary-length data belongs to a separate `PaddedEncryptor` / `PaddedDecryptor` layer built on top. +* One-shot static APIs are provided (default) methods implemented once in the traits -- `encrypt_blocks`, + `encrypt_blocks_rng`, `encrypt_blocks_out`, `encrypt_blocks_out_rng` on `BlockCipherEncryptor` and `decrypt_blocks`, + `decrypt_blocks_out` on `BlockCipherDecryptor` -- so every block-aligned mode gets the house-standard + take-data-return-result API at no cost to implementors. + +Testing: + +* The core-test-framework block cipher test now takes separate encryptor/decryptor type parameters, exercises N = 1 and + N = 2 (including mixed single/multi-block encrypt vs decrypt sequences), and checks the one-shots agree with the + streaming API and round-trip. From 75789c9fb32dc57009c7653e272434cce9f327f2 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 16:20:10 +0700 Subject: [PATCH 19/28] Added all src files for aes-lowmemory (#98) --- crypto/aes-lowmemory/src/aes.rs | 276 +++++++++++++++ crypto/aes-lowmemory/src/bitslice.rs | 210 +++++++++++ crypto/aes-lowmemory/src/lib.rs | 175 +++++++++ crypto/aes-lowmemory/src/round.rs | 507 +++++++++++++++++++++++++++ crypto/aes-lowmemory/src/sbox.rs | 381 ++++++++++++++++++++ crypto/aes-lowmemory/src/schedule.rs | 461 ++++++++++++++++++++++++ 6 files changed, 2010 insertions(+) create mode 100644 crypto/aes-lowmemory/src/aes.rs create mode 100644 crypto/aes-lowmemory/src/bitslice.rs create mode 100644 crypto/aes-lowmemory/src/lib.rs create mode 100644 crypto/aes-lowmemory/src/round.rs create mode 100644 crypto/aes-lowmemory/src/sbox.rs create mode 100644 crypto/aes-lowmemory/src/schedule.rs diff --git a/crypto/aes-lowmemory/src/aes.rs b/crypto/aes-lowmemory/src/aes.rs new file mode 100644 index 00000000..b1003cff --- /dev/null +++ b/crypto/aes-lowmemory/src/aes.rs @@ -0,0 +1,276 @@ +//! CIPHER() and INVCIPHER() (FIPS 197 Sec 5.1 and Sec 5.3), and the public engine types. + +use crate::bitslice::{Block, Planes, pack, unpack}; +use crate::round::{add_round_key, inv_mix_columns, inv_shift_rows, mix_columns, shift_rows}; +use crate::sbox::{inv_sbox, sbox}; +use crate::schedule::{Aes128Params, Aes192Params, Aes256Params, AesParams, expand, round_key}; +use bouncycastle_core::errors::{KeyMaterialError, SymmetricCipherError}; +use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; +use bouncycastle_core::traits::{Algorithm, SecurityStrength}; +use bouncycastle_utils::secret::Secret; + +/// The AES block length in bytes: 16 (FIPS 197 Sec 3.4, `Nb` = 4 words). +pub const BLOCK_LEN: usize = 16; + +/// The AES keyed permutation, parameterised by key length. +/// +/// Use the aliases [`Aes128`], [`Aes192`] and [`Aes256`] rather than naming this directly. +/// `P` is sealed to the three parameter sets of FIPS 197 Sec 6.1, so no fourth instantiation +/// exists. +/// +/// The only state is the key schedule, held in a [`Secret`] so that it is zeroized on drop and +/// redacted from `Debug`. There is no direction flag and no initialisation state: both directions +/// work from the same schedule (see [`Aes::decrypt_blocks2`]), and a constructed value is always +/// ready to use, so there is no `init()` or `reset()`. +pub struct Aes { + schedule: Secret, +} + +/// AES-128: 16-byte key, 10 rounds (FIPS 197 Sec 6.1). +pub type Aes128 = Aes; +/// AES-192: 24-byte key, 12 rounds (FIPS 197 Sec 6.1). +pub type Aes192 = Aes; +/// AES-256: 32-byte key, 14 rounds (FIPS 197 Sec 6.1). +pub type Aes256 = Aes; + +impl Aes

{ + /// Checks a key is fit to use before it is expanded. + /// + /// The key must be tagged [`KeyType::SymmetricCipherKey`], must be exactly `P::KEY_LEN` bytes + /// of the buffer, and must carry a [`SecurityStrength`] at least equal to its own length -- + /// which is what a key of this length from a correctly-instantiated RNG or KDF will have. + /// The checks exist to catch a key that arrived from somewhere it should not have: a seed + /// reused as a cipher key, or a 32-byte buffer holding material only derived at the 128-bit + /// strength. + /// + /// Takes `&dyn KeyMaterialTrait` so the three constructors, whose `KeyMaterial` capacities + /// differ, can share one implementation. + fn validate(key: &dyn KeyMaterialTrait) -> Result<(), SymmetricCipherError> { + if key.key_type() != KeyType::SymmetricCipherKey { + return Err(KeyMaterialError::InvalidKeyType( + "AES requires a key of type KeyType::SymmetricCipherKey.", + ) + .into()); + } + if key.key_len() != P::KEY_LEN { + return Err(KeyMaterialError::InvalidLength.into()); + } + if key.security_strength() < SecurityStrength::from_bytes(P::KEY_LEN) { + return Err(KeyMaterialError::SecurityStrength( + "The provided key has a lower security strength than the AES key length implies.", + ) + .into()); + } + Ok(()) + } + + /// CIPHER() on two blocks at once (FIPS 197 Sec 5.1, Algorithm 1). + /// + /// Algorithm 1 line by line: line 3 is the initial ADDROUNDKEY() with `w[0..3]`; lines 4-9 are + /// the `Nr - 1` full rounds; lines 10-13 are the final round, which omits MIXCOLUMNS(). + fn encrypt2(&self, q: &mut Planes) { + // line 3: state = state XOR w[0..3] + add_round_key(q, &round_key::

(&self.schedule, 0)); + + // lines 4-9: for round from 1 to Nr - 1 + for round in 1..P::NR { + sbox(q); // line 5, SUBBYTES() + shift_rows(q); // line 6, SHIFTROWS() + mix_columns(q); // line 7, MIXCOLUMNS() + add_round_key(q, &round_key::

(&self.schedule, round)); // line 8 + } + + // lines 10-12: the final round has no MIXCOLUMNS() + sbox(q); + shift_rows(q); + add_round_key(q, &round_key::

(&self.schedule, P::NR)); + } + + /// INVCIPHER() on two blocks at once (FIPS 197 Sec 5.3, Algorithm 3). + /// + /// This is the **straight** inverse cipher of Algorithm 3, not the equivalent inverse cipher + /// of Sec 5.3.5. That matters: Algorithm 3 applies INVMIXCOLUMNS() *after* ADDROUNDKEY(), + /// which lets it use the ordinary key schedule, whereas Sec 5.3.5 reorders the round to put + /// the two the other way round and needs a separate schedule with INVMIXCOLUMNS() applied to + /// each round key (Algorithm 5, KEYEXPANSIONEIC()). + /// + /// Following Algorithm 3 is therefore what allows one [`Aes`] value to encrypt *and* decrypt + /// from a single stored schedule, with no second copy and no transformation at construction + /// time -- which is the whole reason this crate can offer both directions at 176-240 bytes of + /// state. + /// + /// Line by line: line 3 is ADDROUNDKEY() with the last round key; lines 4-9 are the + /// `Nr - 1` full inverse rounds; lines 10-13 are the final one, which omits INVMIXCOLUMNS(). + fn decrypt2(&self, q: &mut Planes) { + // line 3: state = state XOR w[4*Nr .. 4*Nr+3] + add_round_key(q, &round_key::

(&self.schedule, P::NR)); + + // lines 4-9: for round from Nr - 1 down to 1 + for round in (1..P::NR).rev() { + inv_shift_rows(q); // line 5, INVSHIFTROWS() + inv_sbox(q); // line 6, INVSUBBYTES() + add_round_key(q, &round_key::

(&self.schedule, round)); // line 7 + inv_mix_columns(q); // line 8, INVMIXCOLUMNS() + } + + // lines 10-12: the final inverse round has no INVMIXCOLUMNS() + inv_shift_rows(q); + inv_sbox(q); + add_round_key(q, &round_key::

(&self.schedule, 0)); + } + + /// Encrypts two blocks in place. + /// + /// This is the natural unit of work: the bit-sliced state holds two blocks, so two blocks cost + /// almost exactly what one does. Prefer this over two [`Aes::encrypt_block`] calls whenever + /// two blocks are available and independent -- which, for a mode of operation, means CTR, or + /// the decryption direction of CBC and CFB, but *not* CBC encryption, whose blocks are + /// serially dependent. + /// + /// Infallible: a constructed [`Aes`] is always usable and every input length is fixed. + pub fn encrypt_blocks2(&self, blocks: &mut [Block; 2]) { + let mut q = pack(&blocks[0], &blocks[1]); + self.encrypt2(&mut q); + let (a, b) = blocks.split_at_mut(1); + unpack(&q, &mut a[0], &mut b[0]); + } + + /// Decrypts two blocks in place. See [`Aes::encrypt_blocks2`]. + pub fn decrypt_blocks2(&self, blocks: &mut [Block; 2]) { + let mut q = pack(&blocks[0], &blocks[1]); + self.decrypt2(&mut q); + let (a, b) = blocks.split_at_mut(1); + unpack(&q, &mut a[0], &mut b[0]); + } + + /// Encrypts one block in place. + /// + /// The bit-sliced state always holds two blocks, so a single-block call duplicates the block + /// into both halves and discards one result: it does twice the necessary work. Use + /// [`Aes::encrypt_blocks2`] where two blocks are available. + /// + /// Duplicating the block costs exactly what filling the unused half with zeros would, and it + /// buys a free self-check: the two halves must come out equal, which `debug_assert` verifies. + /// That is the whole reason for the choice -- it is not a security property, since the unused + /// half is never returned either way. + pub fn encrypt_block(&self, block: &mut Block) { + let mut q = pack(block, block); + self.encrypt2(&mut q); + let mut discard = [0u8; BLOCK_LEN]; + unpack(&q, block, &mut discard); + debug_assert_eq!(*block, discard, "the two interleaved halves must agree"); + } + + /// Decrypts one block in place. See [`Aes::encrypt_block`] for the two-blocks-at-once caveat. + pub fn decrypt_block(&self, block: &mut Block) { + let mut q = pack(block, block); + self.decrypt2(&mut q); + let mut discard = [0u8; BLOCK_LEN]; + unpack(&q, block, &mut discard); + debug_assert_eq!(*block, discard, "the two interleaved halves must agree"); + } +} + +// The three constructors and `Algorithm` impls below are written out longhand rather than +// generated with `macro_rules!`: `cargo mutants` cannot see into macro bodies, so a macro would +// hide the key checks and the security-strength constants from mutation testing (see CLAUDE.md). +// Each `new` differs only in the `KeyMaterial` capacity it accepts, which is what makes a +// wrong-length key a compile error at the call site rather than a runtime error. + +impl Aes128 { + /// Expands a 16-byte key into an AES-128 schedule. + /// + /// # Errors + /// * [`KeyMaterialError::InvalidKeyType`] if the key is not [`KeyType::SymmetricCipherKey`]. + /// * [`KeyMaterialError::InvalidLength`] if the key is not 16 bytes long. + /// * [`KeyMaterialError::SecurityStrength`] if the key carries a strength below 128 bits. + pub fn new(key: &KeyMaterial<16>) -> Result { + Self::validate(key)?; + Ok(Self { schedule: expand::(key.ref_to_bytes()) }) + } +} + +impl Aes192 { + /// Expands a 24-byte key into an AES-192 schedule. See [`Aes128::new`] for the error cases. + pub fn new(key: &KeyMaterial<24>) -> Result { + Self::validate(key)?; + Ok(Self { schedule: expand::(key.ref_to_bytes()) }) + } +} + +impl Aes256 { + /// Expands a 32-byte key into an AES-256 schedule. See [`Aes128::new`] for the error cases. + pub fn new(key: &KeyMaterial<32>) -> Result { + Self::validate(key)?; + Ok(Self { schedule: expand::(key.ref_to_bytes()) }) + } +} + +impl Algorithm for Aes128 { + const ALG_NAME: &'static str = Aes128Params::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl Algorithm for Aes192 { + const ALG_NAME: &'static str = Aes192Params::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_192bit; +} + +impl Algorithm for Aes256 { + const ALG_NAME: &'static str = Aes256Params::ALG_NAME; + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_256bit; +} + +impl core::fmt::Debug for Aes

{ + /// Prints the algorithm name only. The key schedule is secret and is never formatted. + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str(P::ALG_NAME) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_engine_sizes_match_the_documented_memory_table() { + // The "Memory Usage" table in the crate docs quotes these, and the whole point of the + // crate is that they are this small: 4 * (Nr + 1) words of schedule, nothing else, and no + // tables anywhere. If the representation grows, the docs are wrong -- fix both. + assert_eq!(size_of::(), 176, "AES-128: 4 * (10 + 1) words"); + assert_eq!(size_of::(), 208, "AES-192: 4 * (12 + 1) words"); + assert_eq!(size_of::(), 240, "AES-256: 4 * (14 + 1) words"); + } + + #[test] + fn test_engine_size_is_exactly_the_schedule() { + // No round counter, no direction flag, no initialised marker: the schedule is all there + // is, which is what makes both directions available from one value at no extra cost. + assert_eq!(size_of::(), size_of::<::Schedule>()); + assert_eq!(size_of::(), size_of::<::Schedule>()); + assert_eq!(size_of::(), size_of::<::Schedule>()); + } + + #[test] + fn test_alg_names() { + assert_eq!(::ALG_NAME, "AES-128"); + assert_eq!(::ALG_NAME, "AES-192"); + assert_eq!(::ALG_NAME, "AES-256"); + } + + #[test] + fn test_max_security_strength_matches_the_key_length() { + assert_eq!( + ::MAX_SECURITY_STRENGTH, + SecurityStrength::from_bytes(Aes128Params::KEY_LEN) + ); + assert_eq!( + ::MAX_SECURITY_STRENGTH, + SecurityStrength::from_bytes(Aes192Params::KEY_LEN) + ); + assert_eq!( + ::MAX_SECURITY_STRENGTH, + SecurityStrength::from_bytes(Aes256Params::KEY_LEN) + ); + } +} diff --git a/crypto/aes-lowmemory/src/bitslice.rs b/crypto/aes-lowmemory/src/bitslice.rs new file mode 100644 index 00000000..08ef77ff --- /dev/null +++ b/crypto/aes-lowmemory/src/bitslice.rs @@ -0,0 +1,210 @@ +//! Conversion between AES blocks and the bit-sliced representation the round functions act on. +//! +//! # What "bit-sliced" means here +//! +//! The round functions in [`crate::round`] and the S-box in [`crate::sbox`] do not operate on +//! bytes. They operate on eight `u32` *bit-planes*, `q[0]..q[7]`, where plane `q[k]` collects +//! bit `k` of every byte of the state. That is what lets the S-box be a Boolean circuit: one +//! `&` or `^` on a plane applies that gate to all sixteen byte positions at once, and no memory +//! access is ever indexed by a secret value. +//! +//! Eight 32-bit planes hold 256 bits = 32 bytes, which is *two* 16-byte AES blocks. Both blocks +//! are always processed together; see the crate docs for why, and [`crate::aes`] for how a +//! single-block call fills the unused half. +//! +//! # The layout, derived +//! +//! [`ortho`] transposes, within each byte-lane of the eight words, the 8x8 bit matrix indexed by +//! (word number, bit number within the lane): +//! +//! ```text +//! after ortho: q[k] bit (8L + i) == before ortho: q[i] bit (8L + k) +//! ``` +//! +//! [`pack`] loads block A as four little-endian `u32`s into the even words and block B into the +//! odd words, so before `ortho` byte-lane `L` of word `2c` holds `A[4c + L]`. Substituting +//! `j = 4c + L` for the byte index, and FIPS 197 Eq (3.6) `s[r,c] = in[r + 4c]` -- which makes +//! `r = j mod 4` and `c = j div 4` -- gives the layout every mask in this crate depends on: +//! +//! ```text +//! q[k] bit (8r + 2c) == bit k of s[r,c] of block A +//! q[k] bit (8r + 2c + 1) == bit k of s[r,c] of block B +//! ``` +//! +//! In words: **the byte-lane of the word selects the state row `r`, and the bit-pair within that +//! lane selects the state column `c`; the low bit of the pair is block A and the high bit is +//! block B.** Written out, the bit position of `s[r,c]` within every plane is: +//! +//! ```text +//! c=0 c=1 c=2 c=3 +//! r=0 | 0 2 4 6 +//! r=1 | 8 10 12 14 (bit position of block A; +//! r=2 | 16 18 20 22 add 1 for block B) +//! r=3 | 24 26 28 30 +//! ``` +//! +//! This is why SHIFTROWS() becomes a rotation *within* a byte-lane (row `r` lives entirely in +//! lane `r`, and one column step is two bit positions), and why MIXCOLUMNS() uses rotations by +//! 8 and 16 (one and two rows). Both are derived from this table in [`crate::round`]. +//! +//! `test_layout_matches_the_documented_table` below pins the table exhaustively; every mask in +//! this crate is only correct relative to it. +//! +//! # Provenance +//! +//! The three-stage masked-swap transpose and the even/odd two-block packing are translated from +//! BearSSL `src/symcipher/aes_ct.c` (`br_aes_ct_ortho`) and `aes_ct_cbcdec.c` (the `q[0]`, +//! `q[2]`, `q[4]`, `q[6]` load order), by Thomas Pornin, MIT licensed. + +/// One 16-byte AES block, in the order of FIPS 197 Eq (3.6): `block[r + 4c] == s[r,c]`. +pub type Block = [u8; crate::BLOCK_LEN]; + +/// The eight bit-planes holding two blocks. See the module docs for the layout. +pub(crate) type Planes = [u32; 8]; + +/// Transposes bytes into bit-planes, and back -- it is its own inverse. +/// +/// Three stages of masked swaps exchange bit-fields of width 1, 2 and 4 between pairs of words, +/// which together transpose the 8x8 bit matrix inside each byte-lane. See the module docs for +/// the resulting layout. +/// +/// Translated from BearSSL `aes_ct.c:br_aes_ct_ortho` (the `SWAP2`/`SWAP4`/`SWAP8` macros). +pub(crate) fn ortho(q: &mut Planes) { + /// One masked swap: exchanges the `cl`-selected fields of `y` into `x` and the `ch`-selected + /// fields of `x` into `y`, moving them by `s` bit positions. + /// + /// `cl` and `ch` are complementary, and `s` is exactly the field width, so in each returned + /// word the two combined operands occupy disjoint bits: `(x & cl)` and `(y & cl) << s` cannot + /// both be set in the same position. `|` and `^` therefore compute the same function here, + /// which is why `cargo mutants` reports the `| -> ^` mutants in this function as surviving -- + /// they are equivalent programs. `test_ortho_is_an_involution` and + /// `test_layout_matches_the_documented_table` are what actually pin this code. + #[inline(always)] + fn swap(cl: u32, ch: u32, s: u32, x: u32, y: u32) -> (u32, u32) { + ((x & cl) | ((y & cl) << s), ((x & ch) >> s) | (y & ch)) + } + + // Stage 1: swap single bits between adjacent words (0x55 = even bits, 0xAA = odd bits). + for (a, b) in [(0, 1), (2, 3), (4, 5), (6, 7)] { + (q[a], q[b]) = swap(0x5555_5555, 0xAAAA_AAAA, 1, q[a], q[b]); + } + // Stage 2: swap 2-bit fields between words two apart. + for (a, b) in [(0, 2), (1, 3), (4, 6), (5, 7)] { + (q[a], q[b]) = swap(0x3333_3333, 0xCCCC_CCCC, 2, q[a], q[b]); + } + // Stage 3: swap nibbles between words four apart. + for (a, b) in [(0, 4), (1, 5), (2, 6), (3, 7)] { + (q[a], q[b]) = swap(0x0F0F_0F0F, 0xF0F0_F0F0, 4, q[a], q[b]); + } +} + +/// Loads two blocks into the bit-planes. +/// +/// Block `a` goes into the even words and block `b` into the odd words as little-endian `u32`s, +/// then [`ortho`] transposes them into planes. +pub(crate) fn pack(a: &Block, b: &Block) -> Planes { + let mut q = [0u32; 8]; + for c in 0..4 { + // `try_into` cannot fail: the slice is a fixed 4-byte window of a 16-byte array. + q[2 * c] = u32::from_le_bytes(a[4 * c..4 * c + 4].try_into().unwrap()); + q[2 * c + 1] = u32::from_le_bytes(b[4 * c..4 * c + 4].try_into().unwrap()); + } + ortho(&mut q); + q +} + +/// Reads two blocks back out of the bit-planes; the exact inverse of [`pack`]. +pub(crate) fn unpack(q: &Planes, a: &mut Block, b: &mut Block) { + let mut q = *q; + ortho(&mut q); + for c in 0..4 { + a[4 * c..4 * c + 4].copy_from_slice(&q[2 * c].to_le_bytes()); + b[4 * c..4 * c + 4].copy_from_slice(&q[2 * c + 1].to_le_bytes()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A deterministic byte generator, so the tests do not depend on an RNG crate. + pub(crate) fn pseudo_random_block(seed: u32) -> Block { + let mut state = seed.wrapping_mul(2_654_435_761).wrapping_add(1); + let mut out = [0u8; 16]; + for byte in out.iter_mut() { + // xorshift32; quality is irrelevant, only that it varies every bit position. + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + *byte = (state >> 24) as u8; + } + out + } + + #[test] + fn test_layout_matches_the_documented_table() { + // Pins the module doc table: q[k] bit (8r + 2c) is bit k of s[r,c] of block A, and + // bit (8r + 2c + 1) is bit k of s[r,c] of block B. Every mask in `round` depends on it. + let a = pseudo_random_block(1); + let b = pseudo_random_block(2); + let q = pack(&a, &b); + + for j in 0..16 { + let (r, c) = (j % 4, j / 4); + let pos = 8 * r + 2 * c; + for (k, plane) in q.iter().enumerate() { + assert_eq!( + (plane >> pos) & 1, + u32::from((a[j] >> k) & 1), + "block A: plane {k} bit {pos} should be bit {k} of byte {j}" + ); + assert_eq!( + (plane >> (pos + 1)) & 1, + u32::from((b[j] >> k) & 1), + "block B: plane {k} bit {} should be bit {k} of byte {j}", + pos + 1 + ); + } + } + } + + #[test] + fn test_ortho_is_an_involution() { + let mut q = [ + 0x0123_4567, 0x89AB_CDEF, 0xFEDC_BA98, 0x7654_3210, 0xDEAD_BEEF, 0x0000_0001, + 0xFFFF_FFFF, 0xA5A5_5A5A, + ]; + let original = q; + ortho(&mut q); + assert_ne!(q, original, "ortho should actually move bits"); + ortho(&mut q); + assert_eq!(q, original); + } + + #[test] + fn test_unpack_inverts_pack() { + for seed in 0..64 { + let a = pseudo_random_block(seed); + let b = pseudo_random_block(seed + 1000); + let mut out_a = [0u8; 16]; + let mut out_b = [0u8; 16]; + unpack(&pack(&a, &b), &mut out_a, &mut out_b); + assert_eq!(out_a, a); + assert_eq!(out_b, b); + } + } + + #[test] + fn test_the_two_halves_are_independent() { + // Changing block B must not disturb block A anywhere in the round-function pipeline; + // this pins that the interleave really is bit-parallel and not overlapping. + let a = pseudo_random_block(7); + let mut out_a1 = [0u8; 16]; + let mut out_a2 = [0u8; 16]; + let mut scratch = [0u8; 16]; + unpack(&pack(&a, &[0u8; 16]), &mut out_a1, &mut scratch); + unpack(&pack(&a, &pseudo_random_block(9)), &mut out_a2, &mut scratch); + assert_eq!(out_a1, out_a2); + assert_eq!(out_a1, a); + } +} diff --git a/crypto/aes-lowmemory/src/lib.rs b/crypto/aes-lowmemory/src/lib.rs new file mode 100644 index 00000000..866a5167 --- /dev/null +++ b/crypto/aes-lowmemory/src/lib.rs @@ -0,0 +1,175 @@ +//! A constant-time, table-free AES block cipher engine (NIST FIPS 197). +//! +//! This crate provides the raw AES keyed permutation -- [`Aes128`], [`Aes192`] and [`Aes256`] -- +//! implemented as a Boolean circuit over bit-planes rather than as byte substitutions through a +//! lookup table. That makes it both smaller and constant-time; see [Design](#design). +//! +//! It is a *permutation*, not a cipher you can encrypt data with. See +//! [Security Considerations](#security-considerations). +//! +//! # Usage Examples +//! +//! ## Encrypting and decrypting a single block +//! +//! ``` +//! use bouncycastle_aes_lowmemory::Aes128; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! +//! let key = KeyMaterial::<16>::from_bytes_as_type( +//! &[0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, +//! 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c], +//! KeyType::SymmetricCipherKey, +//! ).expect("a 16-byte symmetric cipher key"); +//! +//! let aes = Aes128::new(&key).expect("a valid AES-128 key"); +//! +//! // FIPS 197 Appendix B. +//! let mut block = [0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, +//! 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, 0x34]; +//! aes.encrypt_block(&mut block); +//! assert_eq!(block, [0x39, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb, +//! 0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, 0x0b, 0x32]); +//! +//! // The same value decrypts, from the same schedule -- there is no separate decryptor. +//! aes.decrypt_block(&mut block); +//! assert_eq!(block, [0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, +//! 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, 0x34]); +//! ``` +//! +//! ## Two blocks at a time +//! +//! The bit-sliced state holds two blocks, so two independent blocks cost barely more than one. +//! Where a caller has two, [`Aes::encrypt_blocks2`] is roughly twice the throughput of two +//! [`Aes::encrypt_block`] calls: +//! +//! ``` +//! use bouncycastle_aes_lowmemory::Aes256; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! +//! let key = KeyMaterial::<32>::from_bytes_as_type(&[0x42; 32], KeyType::SymmetricCipherKey) +//! .expect("a 32-byte symmetric cipher key"); +//! let aes = Aes256::new(&key).expect("a valid AES-256 key"); +//! +//! let mut blocks = [[0u8; 16], [1u8; 16]]; +//! aes.encrypt_blocks2(&mut blocks); +//! aes.decrypt_blocks2(&mut blocks); +//! assert_eq!(blocks, [[0u8; 16], [1u8; 16]]); +//! ``` +//! +//! There is no one-shot static on the permutation, because `Aes128::new(&key)?.encrypt_block(..)` +//! already *is* the one shot. Data-level one-shots belong to the modes of operation, which take +//! arbitrary-length input and generate their own initialisation data. +//! +//! # Design +//! +//! ## Why not a lookup table +//! +//! FIPS 197 Sec 5.1.1 presents the S-box as a table (Table 4), and almost every AES +//! implementation stores it as one -- 256 bytes, or 2-8 KiB for the "T-table" variants that fold +//! MIXCOLUMNS() in. The trouble is that a table indexed by a byte of the state is indexed by +//! secret data, so on any CPU with a data cache the memory access pattern, and hence the timing, +//! depends on the key. That is a practical, repeatedly-demonstrated attack, and it is not fixable +//! while the lookup remains. +//! +//! Bouncy Castle's `AESLightEngine` in the Java and C# ports keeps two 256-byte S-box tables for +//! exactly this reason -- to be *small*, not to be constant-time -- and leaks through both the +//! cipher and the key schedule. +//! +//! ## Bit-slicing +//! +//! This crate has no tables at all. The state is transposed so that each of eight `u32` words +//! holds one *bit position* of every byte: word `q[k]` collects bit `k` of all the bytes. In that +//! form the S-box becomes a fixed Boolean circuit -- 32 AND, 77 XOR and 4 XNOR gates, the +//! 113-gate straight-line program of Boyar and Peralta -- and one `&` or `^` applies a gate to +//! every byte position at once. Nothing is ever indexed by a secret, and nothing branches on one. +//! +//! Eight 32-bit words hold 32 bytes, which is two AES blocks, so blocks are processed in pairs. +//! SHIFTROWS() and MIXCOLUMNS() become masks and rotations in the same representation, and the +//! key schedule is stored bit-sliced too, so no transposition happens inside the round loop. The +//! exact bit layout, and the derivation of every mask from it, is documented in the `bitslice` +//! and `round` modules -- those two module docs are the place to start when reading the source. +//! +//! Decryption follows FIPS 197 Algorithm 3, the straight inverse cipher, rather than the +//! equivalent inverse cipher of Sec 5.3.5. Algorithm 3 puts INVMIXCOLUMNS() after ADDROUNDKEY(), +//! so it uses the *unmodified* key schedule; the equivalent inverse cipher would need a second +//! schedule with each round key transformed. One [`Aes`] value therefore encrypts and decrypts +//! from one stored schedule. +//! +//! # Memory Usage +//! +//! There are no lookup tables and no heap allocation. The only persistent state is the key +//! schedule, which is `4 * (Nr + 1)` words -- exactly the size FIPS 197 Sec 5.2 defines, with the +//! bit-sliced form compressed so that bit-slicing costs nothing in space: +//! +//! | Type | Key | `Nr` | Schedule (persistent) | Tables | +//! |---|---|---|---|---| +//! | [`Aes128`] | 16 B | 10 | 176 B | 0 B | +//! | [`Aes192`] | 24 B | 12 | 208 B | 0 B | +//! | [`Aes256`] | 32 B | 14 | 240 B | 0 B | +//! +//! Per-call stack usage is independent of key length: 32 bytes of bit-sliced state for the two +//! blocks, 32 bytes for the round key expanded from its compressed form, plus the S-box circuit's +//! temporaries, most of which the compiler keeps in registers. +//! +//! For comparison, `AESLightEngine` carries 512 bytes of tables and a T-table implementation +//! carries 2-8 KiB, in both cases *on top of* a key schedule of this same size. +//! +//! Measure with `cargo run --release -p mem_usage_benches --bin bench_aes_mem_usage`. +//! +//! # Security Considerations +//! +//! ## A block permutation is not a cipher +//! +//! [`Aes128`] and friends transform exactly 16 bytes. Using them directly on data means ECB, +//! which is not confidential: identical plaintext blocks produce identical ciphertext blocks, so +//! structure in the plaintext survives encryption. **Do not do it.** Use a mode of operation, and +//! prefer an authenticated one so that ciphertext tampering is detected. +//! +//! ## Constant-time properties +//! +//! By construction there is no secret-dependent memory access and no secret-dependent branch, +//! in the cipher *or* in the key schedule -- SUBWORD() goes through the same circuit as +//! SUBBYTES(). The only branches are the round loops, which count over the public `Nr`. +//! +//! Caveats worth stating plainly: +//! +//! * The Rust compiler makes no guarantee it will preserve this. The code is written so that the +//! natural code generation is straight-line, and `#![forbid(unsafe_code)]` rules out the usual +//! ways of forcing the issue, but the property is not contractual. +//! * The 32-byte working state is not scrubbed after a block. Only the key schedule is wrapped in +//! `Secret`, and so only it is guaranteed to be zeroized on drop. +//! * Constant-time execution says nothing about power or electromagnetic side channels. +//! +//! # Provenance +//! +//! * Normative reference: **NIST FIPS 197** (Advanced Encryption Standard), including Update 1. +//! Every transformation cites its section, algorithm and equation numbers. +//! * The S-box circuit is the 113-gate straight-line program `SLP_AES_113.txt` from Peralta's +//! circuit collection, described in J. Boyar and R. Peralta, "A new combinational logic +//! minimization technique with applications to cryptology", +//! . +//! * The bit-sliced two-block structure, the transpose, and the SHIFTROWS()/MIXCOLUMNS() mask and +//! rotation constants are translated from BearSSL's `aes_ct` implementation by Thomas Pornin +//! (MIT licence). Each constant is re-derived from the documented bit layout in the comments, +//! and each is pinned by a test against a byte-wise reference written from the FIPS 197 +//! equations. +//! * Verified against FIPS 197 Appendix A (all three key expansions, every word), FIPS 197 +//! Appendix B, NIST SP 800-38A Appendix F.1 (ECB, all three key lengths, both directions), and +//! the NIST ACVP `ACVP-AES-ECB` vectors. + +#![no_std] +#![forbid(unsafe_code)] +#![forbid(missing_docs)] +// `AesParams` is deliberately sealed with a private supertrait so that no fourth parameter set can +// be added outside this crate; that is what triggers this lint. +#![allow(private_bounds)] + +mod aes; +mod bitslice; +mod round; +mod sbox; +mod schedule; + +pub use aes::{Aes, Aes128, Aes192, Aes256, BLOCK_LEN}; +pub use bitslice::Block; +pub use schedule::{Aes128Params, Aes192Params, Aes256Params, AesParams}; diff --git a/crypto/aes-lowmemory/src/round.rs b/crypto/aes-lowmemory/src/round.rs new file mode 100644 index 00000000..b42406cf --- /dev/null +++ b/crypto/aes-lowmemory/src/round.rs @@ -0,0 +1,507 @@ +//! The three linear round transformations, on bit-planes. +//! +//! | Function | FIPS 197 | Inverse | FIPS 197 | +//! |---|---|---|---| +//! | [`add_round_key`] | Sec 5.1.4, Eq 5.9 | itself (XOR) | Sec 5.3.4 | +//! | [`shift_rows`] | Sec 5.1.2, Eq 5.5 | [`inv_shift_rows`] | Sec 5.3.1, Eq 5.12 | +//! | [`mix_columns`] | Sec 5.1.3, Eq 5.8 | [`inv_mix_columns`] | Sec 5.3.3, Eq 5.15 | +//! +//! SUBBYTES() is in [`crate::sbox`], because it is the only non-linear step and the only one that +//! needs a circuit rather than masks and rotations. +//! +//! Everything here is XOR, AND with a constant mask, and rotation by a constant. No operation +//! depends on the data, so all of it is inherently constant-time. +//! +//! # How the layout turns row and column arithmetic into shifts +//! +//! From the layout derived in [`crate::bitslice`], within every plane the bit holding `s[r,c]` +//! of block A sits at bit position `8r + 2c` (and block B at `8r + 2c + 1`). Two consequences +//! drive every constant below: +//! +//! * **A row is a byte-lane.** All of row `r` lives in bits `8r..8r+8` of every plane, and +//! stepping one column along that row is a step of two bit positions. So SHIFTROWS(), which +//! only permutes within rows, is a rotation *inside* each byte-lane, by `2r` positions. +//! * **Rotating a whole plane by 8 changes the row.** `x.rotate_right(8)` brings the contents of +//! lane `r+1` into lane `r`, so `rotate_right(8)` reads "the next row down" and +//! `rotate_right(16)` reads "two rows down". MIXCOLUMNS(), which combines the four rows of a +//! column, is therefore expressible with those two rotations and no shuffling at all. +//! +//! Provenance: the mask and rotation constants are translated from BearSSL +//! `src/symcipher/aes_ct_enc.c` and `aes_ct_dec.c` (MIT, Thomas Pornin). Each is re-derived from +//! the layout in the comments below, and each is pinned by a test in this file against a +//! byte-wise reference written directly from the FIPS 197 equations. + +use crate::bitslice::Planes; + +/// ADDROUNDKEY(): XORs a round key into the state (FIPS 197 Sec 5.1.4, Eq 5.9). +/// +/// Eq 5.9 XORs word `w[4*round + c]` into column `c`. Here the round key has already been +/// bit-sliced into the same plane layout as the state by [`crate::schedule`], so the whole +/// transformation -- all four columns of both blocks -- is eight XORs. +/// +/// This is its own inverse, which is why FIPS 197 Sec 5.3.4 needs no separate INVADDROUNDKEY(). +#[inline(always)] +pub(crate) fn add_round_key(q: &mut Planes, round_key: &Planes) { + for (plane, key_plane) in q.iter_mut().zip(round_key.iter()) { + *plane ^= *key_plane; + } +} + +/// SHIFTROWS(): cyclically shifts row `r` left by `r` columns (FIPS 197 Sec 5.1.2, Eq 5.5). +/// +/// Eq 5.5 is `s'[r,c] = s[r,(c + r) mod 4]`. Row `r` occupies byte-lane `r` of every plane and +/// one column is two bit positions, so the new column `c` must take what is two-bits-times-`r` +/// further up the lane: a **rotate right by `2r` within lane `r`**. Rotating right, not left, +/// because taking from a higher column index means pulling data down towards bit 0. +/// +/// Written out per lane rather than as a loop, so the shift amounts stay compile-time constants: +/// +/// * lane 0 (`r = 0`): rotate by 0, so bits `0..8` pass through untouched. +/// * lane 1 (`r = 1`): rotate right by 2. Bits 10..16 drop to 8..14; bits 8..10 wrap to 14..16. +/// * lane 2 (`r = 2`): rotate right by 4. Bits 20..24 drop to 16..20; bits 16..20 wrap up. +/// * lane 3 (`r = 3`): rotate right by 6. Bits 30..32 drop to 24..26; bits 24..30 wrap up. +/// +/// Both interleaved blocks move together, since a column step of two positions carries the A and +/// B bits of that column as a pair. +/// +/// Translated from BearSSL `aes_ct_enc.c:shift_rows`. +#[inline(always)] +pub(crate) fn shift_rows(q: &mut Planes) { + for plane in q.iter_mut() { + let x = *plane; + *plane = (x & 0x0000_00FF) + | ((x & 0x0000_FC00) >> 2) + | ((x & 0x0000_0300) << 6) + | ((x & 0x00F0_0000) >> 4) + | ((x & 0x000F_0000) << 4) + | ((x & 0xC000_0000) >> 6) + | ((x & 0x3F00_0000) << 2); + } +} + +/// INVSHIFTROWS(): cyclically shifts row `r` right by `r` columns +/// (FIPS 197 Sec 5.3.1, Eq 5.12). +/// +/// Eq 5.12 is `s'[r,c] = s[r,(c - r) mod 4]`, so this is [`shift_rows`] with every lane rotation +/// reversed: **rotate left by `2r` within lane `r`**. The masks are the complementary halves of +/// the forward ones. +/// +/// Translated from BearSSL `aes_ct_dec.c:inv_shift_rows`. +#[inline(always)] +pub(crate) fn inv_shift_rows(q: &mut Planes) { + for plane in q.iter_mut() { + let x = *plane; + *plane = (x & 0x0000_00FF) + | ((x & 0x0000_3F00) << 2) + | ((x & 0x0000_C000) >> 6) + | ((x & 0x000F_0000) << 4) + | ((x & 0x00F0_0000) >> 4) + | ((x & 0x0300_0000) << 6) + | ((x & 0xFC00_0000) >> 2); + } +} + +/// MIXCOLUMNS(): multiplies every column by the fixed matrix of Eq 5.7 +/// (FIPS 197 Sec 5.1.3). +/// +/// # Derivation +/// +/// Eq 5.8 gives each output byte of a column. Collecting the four rows, and writing `s[r]` for +/// the byte in row `r` of the column being processed, every row obeys the same rule: +/// +/// ```text +/// s'[r] = {02}.s[r] ^ {03}.s[r+1] ^ s[r+2] ^ s[r+3] (rows mod 4) +/// = {02}.(s[r] ^ s[r+1]) ^ s[r+1] ^ s[r+2] ^ s[r+3] +/// ``` +/// +/// using `{03} = {02} ^ {01}`. Because "the next row" is `rotate_right(8)` and "two rows down" is +/// `rotate_right(16)` (see the module docs), with `p` the state planes and `r` = `p` rotated by 8: +/// +/// * `p[k]` is bit `k` of `s[r]`, `r[k]` is bit `k` of `s[r+1]`, +/// * `rotate_right(16)` of those two gives bit `k` of `s[r+2]` and of `s[r+3]`. +/// +/// So `s[r+2] ^ s[r+3]` is `(p[k] ^ r[k]).rotate_right(16)`, which is the `rotr16(..)` term in +/// every line below, and `s[r+1]` is the bare `r[k]`. +/// +/// The remaining `{02}.(s[r] ^ s[r+1])` is XTIMES() (Eq 4.5) in the plane basis. Multiplying by +/// `x` shifts every bit up one plane, and the degree-8 term that falls off the top is reduced by +/// XOR-ing `{1b} = 0b0001_1011` -- bits 0, 1, 3 and 4. So with `v[k] = p[k] ^ r[k]`, plane `k` of +/// `{02}.v` is: +/// +/// * `v[k-1]` from the shift, for `k >= 1` (plane 0 gets nothing from the shift), and +/// * `v[7]`, the reduction, for `k` in {0, 1, 3, 4} only. +/// +/// That is exactly where the extra `p[7] ^ r[7]` terms appear below: in the lines for planes 0, 1, +/// 3 and 4, and nowhere else. Plane 0 is the one line with no `p[k-1] ^ r[k-1]` term. +/// +/// Translated from BearSSL `aes_ct_enc.c:mix_columns`; the equivalence to Eq 5.8 is pinned by +/// `test_mix_columns_matches_equation_5_8`. +#[inline(always)] +pub(crate) fn mix_columns(q: &mut Planes) { + let p = *q; + // r[k] holds the same bit position of the next row down. + let r: Planes = core::array::from_fn(|k| p[k].rotate_right(8)); + + // The `p[7] ^ r[7]` term is the {1b} reduction, present only in planes 0, 1, 3 and 4. + q[0] = p[7] ^ r[7] ^ r[0] ^ (p[0] ^ r[0]).rotate_right(16); + q[1] = p[0] ^ r[0] ^ p[7] ^ r[7] ^ r[1] ^ (p[1] ^ r[1]).rotate_right(16); + q[2] = p[1] ^ r[1] ^ r[2] ^ (p[2] ^ r[2]).rotate_right(16); + q[3] = p[2] ^ r[2] ^ p[7] ^ r[7] ^ r[3] ^ (p[3] ^ r[3]).rotate_right(16); + q[4] = p[3] ^ r[3] ^ p[7] ^ r[7] ^ r[4] ^ (p[4] ^ r[4]).rotate_right(16); + q[5] = p[4] ^ r[4] ^ r[5] ^ (p[5] ^ r[5]).rotate_right(16); + q[6] = p[5] ^ r[5] ^ r[6] ^ (p[6] ^ r[6]).rotate_right(16); + q[7] = p[6] ^ r[6] ^ r[7] ^ (p[7] ^ r[7]).rotate_right(16); +} + +/// INVMIXCOLUMNS(): multiplies every column by the inverse matrix of Eq 5.14 +/// (FIPS 197 Sec 5.3.3). +/// +/// The same shape as [`mix_columns`] -- `r` is the next row down, `rotate_right(16)` reaches two +/// rows further -- but the defining word of Sec 4.3 is `[{0e},{09},{0d},{0b}]` (Eq 5.13) instead +/// of `[{02},{01},{01},{03}]` (Eq 5.6). Those have degree up to 3, so expanding each product +/// through XTIMES() +/// in the plane basis produces many more terms than the forward direction, and the per-plane term +/// lists below are that expansion of Eq 5.15 rather than something readable line by line. +/// +/// The reduction terms are not confined to planes 0, 1, 3 and 4 here, because the higher-degree +/// coefficients feed carries into every plane. +/// +/// Translated from BearSSL `aes_ct_dec.c:inv_mix_columns`. Rather than trust the expansion by +/// inspection, `test_inv_mix_columns_matches_equation_5_15` checks it against a byte-wise +/// reference written straight from Eq 5.15, and `test_inv_mix_columns_inverts_mix_columns` +/// checks the two are inverses. +#[inline(always)] +#[rustfmt::skip] +pub(crate) fn inv_mix_columns(q: &mut Planes) { + let p = *q; + let r: Planes = core::array::from_fn(|k| p[k].rotate_right(8)); + + q[0] = p[5] ^ p[6] ^ p[7] ^ r[0] ^ r[5] ^ r[7] + ^ (p[0] ^ p[5] ^ p[6] ^ r[0] ^ r[5]).rotate_right(16); + q[1] = p[0] ^ p[5] ^ r[0] ^ r[1] ^ r[5] ^ r[6] ^ r[7] + ^ (p[1] ^ p[5] ^ p[7] ^ r[1] ^ r[5] ^ r[6]).rotate_right(16); + q[2] = p[0] ^ p[1] ^ p[6] ^ r[1] ^ r[2] ^ r[6] ^ r[7] + ^ (p[0] ^ p[2] ^ p[6] ^ r[2] ^ r[6] ^ r[7]).rotate_right(16); + q[3] = p[0] ^ p[1] ^ p[2] ^ p[5] ^ p[6] ^ r[0] ^ r[2] ^ r[3] ^ r[5] + ^ (p[0] ^ p[1] ^ p[3] ^ p[5] ^ p[6] ^ p[7] ^ r[0] ^ r[3] ^ r[5] ^ r[7]).rotate_right(16); + q[4] = p[1] ^ p[2] ^ p[3] ^ p[5] ^ r[1] ^ r[3] ^ r[4] ^ r[5] ^ r[6] ^ r[7] + ^ (p[1] ^ p[2] ^ p[4] ^ p[5] ^ p[7] ^ r[1] ^ r[4] ^ r[5] ^ r[6]).rotate_right(16); + q[5] = p[2] ^ p[3] ^ p[4] ^ p[6] ^ r[2] ^ r[4] ^ r[5] ^ r[6] ^ r[7] + ^ (p[2] ^ p[3] ^ p[5] ^ p[6] ^ r[2] ^ r[5] ^ r[6] ^ r[7]).rotate_right(16); + q[6] = p[3] ^ p[4] ^ p[5] ^ p[7] ^ r[3] ^ r[5] ^ r[6] ^ r[7] + ^ (p[3] ^ p[4] ^ p[6] ^ p[7] ^ r[3] ^ r[6] ^ r[7]).rotate_right(16); + q[7] = p[4] ^ p[5] ^ p[6] ^ r[4] ^ r[6] ^ r[7] + ^ (p[4] ^ p[5] ^ p[7] ^ r[4] ^ r[7]).rotate_right(16); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitslice::{pack, unpack}; + + /// Runs a plane transformation over one block placed in both halves, returning the A half. + fn apply(f: fn(&mut Planes), block: [u8; 16]) -> [u8; 16] { + let mut q = pack(&block, &block); + f(&mut q); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, b, "the two interleaved blocks must transform identically"); + a + } + + /// A block whose bytes are all distinct, so any mask error that moves a byte to the wrong + /// position is visible. + fn distinct_block() -> [u8; 16] { + core::array::from_fn(|i| (i as u8).wrapping_mul(17).wrapping_add(3)) + } + + // ---- byte-wise references, written from the FIPS 197 equations ---------------------- + // These use `state[r + 4c] == s[r,c]` (Eq 3.6). They exist only to check the plane + // implementations and are deliberately naive. + + /// Eq 5.5: `s'[r,c] = s[r,(c + r) mod 4]`. + fn ref_shift_rows(s: &[u8; 16]) -> [u8; 16] { + let mut o = [0u8; 16]; + for r in 0..4 { + for c in 0..4 { + o[r + 4 * c] = s[r + 4 * ((c + r) % 4)]; + } + } + o + } + + /// Eq 5.12: `s'[r,c] = s[r,(c - r) mod 4]`. + fn ref_inv_shift_rows(s: &[u8; 16]) -> [u8; 16] { + let mut o = [0u8; 16]; + for r in 0..4 { + for c in 0..4 { + o[r + 4 * c] = s[r + 4 * ((c + 4 - r) % 4)]; + } + } + o + } + + /// Eq 4.5 XTIMES(): multiply by `{02}` in GF(2^8). + fn xtimes(b: u8) -> u8 { + (b << 1) ^ if b & 0x80 != 0 { 0x1b } else { 0 } + } + + /// General GF(2^8) multiplication. Test-only; it branches on `b` and must never see secrets. + fn gf_mul(mut a: u8, mut b: u8) -> u8 { + let mut product = 0u8; + for _ in 0..8 { + if b & 1 != 0 { + product ^= a; + } + b >>= 1; + a = xtimes(a); + } + product + } + + /// Multiplication of a column by a fixed matrix, exactly as FIPS 197 Sec 4.3 defines it. + /// + /// Eq 4.8 gives the output word `[d0,d1,d2,d3]` from the input word `[b0,b1,b2,b3]` and the + /// matrix word `[a0,a1,a2,a3]`: + /// + /// ```text + /// d0 = (a0.b0) + (a3.b1) + (a2.b2) + (a1.b3) + /// d1 = (a1.b0) + (a0.b1) + (a3.b2) + (a2.b3) + /// d2 = (a2.b0) + (a1.b1) + (a0.b2) + (a3.b3) + /// d3 = (a3.b0) + (a2.b1) + (a1.b2) + (a0.b3) + /// ``` + /// + /// so entry `(r,k)` of the matrix is `a[(r - k) mod 4]`, which is what the indexing below is. + /// Both MIXCOLUMNS() and INVMIXCOLUMNS() use this same convention; only the word differs. + fn ref_mix_columns(s: &[u8; 16], coeffs: [u8; 4]) -> [u8; 16] { + let mut o = [0u8; 16]; + for c in 0..4 { + for r in 0..4 { + let mut v = 0u8; + for k in 0..4 { + v ^= gf_mul(s[k + 4 * c], coeffs[(r + 4 - k) % 4]); + } + o[r + 4 * c] = v; + } + } + o + } + + /// Eq 5.6: `[a0, a1, a2, a3] = [{02}, {01}, {01}, {03}]`. + /// + /// Note the order: it is *not* `[{02},{03},{01},{01}]`, which is the first row of the matrix + /// in Eq 5.7 rather than the defining word. Feeding the matrix row in here instead of the + /// word silently transposes the matrix, which happens to leave INVMIXCOLUMNS() passing, so + /// this is a comment worth keeping. + const MIX_COEFFS: [u8; 4] = [0x02, 0x01, 0x01, 0x03]; + /// Eq 5.13: `[a0, a1, a2, a3] = [{0e}, {09}, {0d}, {0b}]`. + const INV_MIX_COEFFS: [u8; 4] = [0x0e, 0x09, 0x0d, 0x0b]; + + /// Eq 5.8, transcribed literally, as a cross-check on [`ref_mix_columns`]. + /// + /// ```text + /// s'0,c = ({02}.s0,c) + ({03}.s1,c) + s2,c + s3,c + /// s'1,c = s0,c + ({02}.s1,c) + ({03}.s2,c) + s3,c + /// s'2,c = s0,c + s1,c + ({02}.s2,c) + ({03}.s3,c) + /// s'3,c = ({03}.s0,c) + s1,c + s2,c + ({02}.s3,c) + /// ``` + #[rustfmt::skip] + fn ref_mix_columns_literal(s: &[u8; 16]) -> [u8; 16] { + let mut o = [0u8; 16]; + for c in 0..4 { + let (s0, s1, s2, s3) = (s[4 * c], s[4 * c + 1], s[4 * c + 2], s[4 * c + 3]); + o[4 * c] = gf_mul(0x02, s0) ^ gf_mul(0x03, s1) ^ s2 ^ s3; + o[4 * c + 1] = s0 ^ gf_mul(0x02, s1) ^ gf_mul(0x03, s2) ^ s3; + o[4 * c + 2] = s0 ^ s1 ^ gf_mul(0x02, s2) ^ gf_mul(0x03, s3); + o[4 * c + 3] = gf_mul(0x03, s0) ^ s1 ^ s2 ^ gf_mul(0x02, s3); + } + o + } + + /// Eq 5.15, transcribed literally, as a cross-check on [`ref_mix_columns`]. + /// + /// ```text + /// s'0,c = ({0e}.s0,c) + ({0b}.s1,c) + ({0d}.s2,c) + ({09}.s3,c) + /// s'1,c = ({09}.s0,c) + ({0e}.s1,c) + ({0b}.s2,c) + ({0d}.s3,c) + /// s'2,c = ({0d}.s0,c) + ({09}.s1,c) + ({0e}.s2,c) + ({0b}.s3,c) + /// s'3,c = ({0b}.s0,c) + ({0d}.s1,c) + ({09}.s2,c) + ({0e}.s3,c) + /// ``` + #[rustfmt::skip] + fn ref_inv_mix_columns_literal(s: &[u8; 16]) -> [u8; 16] { + let mut o = [0u8; 16]; + for c in 0..4 { + let (s0, s1, s2, s3) = (s[4 * c], s[4 * c + 1], s[4 * c + 2], s[4 * c + 3]); + o[4 * c] = gf_mul(0x0e, s0) ^ gf_mul(0x0b, s1) ^ gf_mul(0x0d, s2) ^ gf_mul(0x09, s3); + o[4 * c + 1] = gf_mul(0x09, s0) ^ gf_mul(0x0e, s1) ^ gf_mul(0x0b, s2) ^ gf_mul(0x0d, s3); + o[4 * c + 2] = gf_mul(0x0d, s0) ^ gf_mul(0x09, s1) ^ gf_mul(0x0e, s2) ^ gf_mul(0x0b, s3); + o[4 * c + 3] = gf_mul(0x0b, s0) ^ gf_mul(0x0d, s1) ^ gf_mul(0x09, s2) ^ gf_mul(0x0e, s3); + } + o + } + + // ---- tests -------------------------------------------------------------------------- + + #[test] + fn test_the_two_reference_forms_agree() { + // Eq 5.7 (matrix, via the Sec 4.3 convention) against Eq 5.8 (explicit bytes), and the + // same for Eq 5.14 against Eq 5.15. This is what pins the coefficient word order: get + // MIX_COEFFS wrong and these disagree, independently of the plane implementation. + for seed in 0..32u8 { + let block: [u8; 16] = core::array::from_fn(|i| (i as u8).wrapping_mul(37) ^ seed); + assert_eq!(ref_mix_columns(&block, MIX_COEFFS), ref_mix_columns_literal(&block)); + assert_eq!( + ref_mix_columns(&block, INV_MIX_COEFFS), + ref_inv_mix_columns_literal(&block) + ); + } + } + + #[test] + fn test_xtimes_reference_matches_the_spec_example() { + // FIPS 197 Sec 4.2 works through {57} . {13}; the intermediate XTIMES() chain from + // Eq 4.5 is {57}, {ae}, {47}, {8e}, {07}. + assert_eq!(xtimes(0x57), 0xae); + assert_eq!(xtimes(0xae), 0x47); + assert_eq!(xtimes(0x47), 0x8e); + assert_eq!(xtimes(0x8e), 0x07); + // and the product itself, {57} . {13} = {fe}. + assert_eq!(gf_mul(0x57, 0x13), 0xfe); + } + + #[test] + fn test_shift_rows_matches_equation_5_5() { + for seed in 0..32u8 { + let block: [u8; 16] = core::array::from_fn(|i| (i as u8).wrapping_mul(31) ^ seed); + assert_eq!(apply(shift_rows, block), ref_shift_rows(&block)); + } + assert_eq!(apply(shift_rows, distinct_block()), ref_shift_rows(&distinct_block())); + } + + #[test] + fn test_inv_shift_rows_matches_equation_5_12() { + for seed in 0..32u8 { + let block: [u8; 16] = core::array::from_fn(|i| (i as u8).wrapping_mul(31) ^ seed); + assert_eq!(apply(inv_shift_rows, block), ref_inv_shift_rows(&block)); + } + } + + #[test] + fn test_inv_shift_rows_inverts_shift_rows() { + let block = distinct_block(); + let mut q = pack(&block, &block); + shift_rows(&mut q); + inv_shift_rows(&mut q); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, block); + } + + #[test] + fn test_shift_rows_is_a_bit_permutation() { + // Push a single set bit through and require exactly one bit out, with the induced map on + // bit positions a bijection. That is the real invariant behind the seven masked terms: + // their destination ranges are pairwise disjoint and together cover all 32 bits. + // + // It also explains a known `cargo mutants` result. The `| -> ^` mutants in [`shift_rows`] + // and [`inv_shift_rows`] survive, because on disjoint operands `|` and `^` compute the + // same function -- they are equivalent programs, not a gap in the tests, and no test can + // kill them. What *would* be a bug is masks that overlap or fail to cover, and this test + // is what rules that out. + for (name, f) in [ + ("shift_rows", shift_rows as fn(&mut Planes)), + ("inv_shift_rows", inv_shift_rows as fn(&mut Planes)), + ] { + let mut destinations = [false; 32]; + for bit in 0..32 { + let mut q: Planes = [1u32 << bit; 8]; + f(&mut q); + for plane in q { + assert_eq!( + plane.count_ones(), + 1, + "{name}: bit {bit} must map to exactly one bit, got {plane:#034b}" + ); + } + let dest = q[0].trailing_zeros() as usize; + assert!(!destinations[dest], "{name}: two source bits both map to bit {dest}"); + destinations[dest] = true; + } + assert!( + destinations.iter().all(|&hit| hit), + "{name}: the masks must cover all 32 bit positions" + ); + } + } + + #[test] + fn test_shift_rows_leaves_row_zero_alone() { + // Row 0 is bytes 0, 4, 8, 12 in the Eq 3.6 layout, and Eq 5.5 does not move it. + let block = distinct_block(); + let out = apply(shift_rows, block); + for c in 0..4 { + assert_eq!(out[4 * c], block[4 * c], "row 0, column {c}"); + } + } + + #[test] + fn test_mix_columns_matches_equation_5_8() { + for seed in 0..32u8 { + let block: [u8; 16] = core::array::from_fn(|i| (i as u8).wrapping_mul(37) ^ seed); + assert_eq!(apply(mix_columns, block), ref_mix_columns(&block, MIX_COEFFS)); + } + assert_eq!( + apply(mix_columns, distinct_block()), + ref_mix_columns(&distinct_block(), MIX_COEFFS) + ); + } + + #[test] + fn test_inv_mix_columns_matches_equation_5_15() { + for seed in 0..32u8 { + let block: [u8; 16] = core::array::from_fn(|i| (i as u8).wrapping_mul(37) ^ seed); + assert_eq!(apply(inv_mix_columns, block), ref_mix_columns(&block, INV_MIX_COEFFS)); + } + } + + #[test] + fn test_inv_mix_columns_inverts_mix_columns() { + let block = distinct_block(); + let mut q = pack(&block, &block); + mix_columns(&mut q); + inv_mix_columns(&mut q); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, block); + } + + #[test] + fn test_add_round_key_is_its_own_inverse() { + let block = distinct_block(); + let key = pack(&[0xA5u8; 16], &[0x5Au8; 16]); + let mut q = pack(&block, &block); + add_round_key(&mut q, &key); + add_round_key(&mut q, &key); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, block); + } + + #[test] + fn test_add_round_key_xors_the_expected_bytes() { + let block = distinct_block(); + let key_block = [0xA5u8; 16]; + let key = pack(&key_block, &key_block); + let mut q = pack(&block, &block); + add_round_key(&mut q, &key); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + for i in 0..16 { + assert_eq!(a[i], block[i] ^ key_block[i]); + } + } +} diff --git a/crypto/aes-lowmemory/src/sbox.rs b/crypto/aes-lowmemory/src/sbox.rs new file mode 100644 index 00000000..8e68d2e3 --- /dev/null +++ b/crypto/aes-lowmemory/src/sbox.rs @@ -0,0 +1,381 @@ +//! SUBBYTES() and INVSUBBYTES() as a Boolean circuit (FIPS 197 Sec 5.1.1 and Sec 5.3.2). +//! +//! # Why a circuit and not a table +//! +//! FIPS 197 Sec 5.1.1 presents the S-box as a 256-entry lookup table (Table 4). A table lookup +//! indexed by a byte of the state is indexed by *secret data*, and on any CPU with a data cache +//! the access pattern -- hence the timing -- depends on that secret. That is the standard AES +//! cache-timing side channel, and it cannot be closed while keeping the lookup. +//! +//! So this module does not have a table. It computes the same function as Table 4 with AND, XOR +//! and XNOR gates applied to the bit-planes described in [`crate::bitslice`]. Every operation is +//! a straight-line word operation on public *positions*, so there is no secret-dependent memory +//! access and no secret-dependent branch. The two functions here are the only place in the crate +//! where secret data meets non-linear logic; everything else is XOR, rotate and mask. +//! +//! Because the planes hold sixteen byte positions of two blocks at once, one pass of the circuit +//! substitutes all 32 bytes -- the whole SUBBYTES() transformation of two blocks -- rather than +//! one byte. +//! +//! # What the circuit computes +//! +//! FIPS 197 Sec 5.1.1 defines the S-box as inversion in GF(2^8) followed by an affine map +//! (Eq. 5.2), tabulated in Table 4. The circuit below is the 113-gate straight-line program of +//! Boyar and Peralta -- 32 AND, 77 XOR and 4 XNOR gates -- which computes exactly that, +//! including the affine map and its `{63}` constant (the constant is folded into the four XNORs +//! at the end of the bottom linear transformation). +//! +//! Sources: +//! * The straight-line program `SLP_AES_113.txt`, from Peralta's circuit collection. +//! * J. Boyar and R. Peralta, "A new combinational logic minimization technique with +//! applications to cryptology", . +//! * The same circuit appears in BearSSL `aes_ct.c:br_aes_ct_bitslice_Sbox` (MIT, Thomas +//! Pornin), whose variable naming is kept here so the two can be diffed. BearSSL re-associates +//! two gates in the non-linear section (its `t17`/`t21` differ from the SLP file, computing the +//! same `t21`) and uses a different but equivalent bottom linear transformation; where they +//! disagree this file follows `SLP_AES_113.txt`. +//! +//! The gate list is a mechanical transcription of `SLP_AES_113.txt`: `+` became `^`, `x` became +//! `&`, `#` became `!(.. ^ ..)`, and the SLP variable names are unchanged apart from case. It is +//! not independently meaningful line by line and should not be "tidied"; it is verified as a +//! whole by `test_sbox_matches_fips197_table_4`, which checks all 256 inputs against Table 4. +//! +//! # Bit numbering +//! +//! The SLP numbers its inputs `U0..U7` and outputs `S0..S7` with **`U0` as the most significant +//! bit** of the byte, which is the reverse of the plane index. So `U0` is plane `q[7]` and `U7` +//! is plane `q[0]`, and likewise for the outputs. `test_sbox_matches_fips197_table_4` is what +//! pins this down -- reversing it produces a wrong S-box, not a subtly different one. + +use crate::bitslice::Planes; + +/// SUBBYTES(): applies the AES S-box to every byte position of both blocks in `q` +/// (FIPS 197 Sec 5.1.1, the transformation tabulated in Table 4). +/// +/// The 113-gate Boyar-Peralta circuit, transcribed from `SLP_AES_113.txt`. See the module docs. +pub(crate) fn sbox(q: &mut Planes) { + // SLP inputs U0..U7, most-significant bit first, so U0 is the highest plane. + let u0 = q[7]; + let u1 = q[6]; + let u2 = q[5]; + let u3 = q[4]; + let u4 = q[3]; + let u5 = q[2]; + let u6 = q[1]; + let u7 = q[0]; + + // Top linear transformation (23 gates): the input basis change. + let y14 = u3 ^ u5; + let y13 = u0 ^ u6; + let y9 = u0 ^ u3; + let y8 = u0 ^ u5; + let t0 = u1 ^ u2; + let y1 = t0 ^ u7; + let y4 = y1 ^ u3; + let y12 = y13 ^ y14; + let y2 = y1 ^ u0; + let y5 = y1 ^ u6; + let y3 = y5 ^ y8; + let t1 = u4 ^ y12; + let y15 = t1 ^ u5; + let y20 = t1 ^ u1; + let y6 = y15 ^ u7; + let y10 = y15 ^ t0; + let y11 = y20 ^ y9; + let y7 = u7 ^ y11; + let y17 = y10 ^ y11; + let y19 = y10 ^ y8; + let y16 = t0 ^ y11; + let y21 = y13 ^ y16; + let y18 = u0 ^ y16; + + // Non-linear section (62 gates): the GF(2^8) inversion, and the only ANDs in the circuit. + let t2 = y12 & y15; + let t3 = y3 & y6; + let t4 = t3 ^ t2; + let t5 = y4 & u7; + let t6 = t5 ^ t2; + let t7 = y13 & y16; + let t8 = y5 & y1; + let t9 = t8 ^ t7; + let t10 = y2 & y7; + let t11 = t10 ^ t7; + let t12 = y9 & y11; + let t13 = y14 & y17; + let t14 = t13 ^ t12; + let t15 = y8 & y10; + let t16 = t15 ^ t12; + let t17 = t4 ^ y20; + let t18 = t6 ^ t16; + let t19 = t9 ^ t14; + let t20 = t11 ^ t16; + let t21 = t17 ^ t14; + let t22 = t18 ^ y19; + let t23 = t19 ^ y21; + let t24 = t20 ^ y18; + let t25 = t21 ^ t22; + let t26 = t21 & t23; + let t27 = t24 ^ t26; + let t28 = t25 & t27; + let t29 = t28 ^ t22; + let t30 = t23 ^ t24; + let t31 = t22 ^ t26; + let t32 = t31 & t30; + let t33 = t32 ^ t24; + let t34 = t23 ^ t33; + let t35 = t27 ^ t33; + let t36 = t24 & t35; + // `cargo mutants` reports the `^ -> |` mutant on the next line as surviving. That is a true + // equivalence, not a gap: `t36` and `t34` are never both 1 for any of the 256 possible input + // bytes, so XOR and OR agree here. It is the only one of the circuit's 77 XOR gates with that + // property -- every other `^ -> |` mutant is killed by `test_sbox_matches_fips197_table_4`. + let t37 = t36 ^ t34; + let t38 = t27 ^ t36; + let t39 = t29 & t38; + let t40 = t25 ^ t39; + let t41 = t40 ^ t37; + let t42 = t29 ^ t33; + let t43 = t29 ^ t40; + let t44 = t33 ^ t37; + let t45 = t42 ^ t41; + let z0 = t44 & y15; + let z1 = t37 & y6; + let z2 = t33 & u7; + let z3 = t43 & y16; + let z4 = t40 & y1; + let z5 = t29 & y7; + let z6 = t42 & y11; + let z7 = t45 & y17; + let z8 = t41 & y10; + let z9 = t44 & y12; + let z10 = t37 & y3; + let z11 = t33 & y4; + let z12 = t43 & y13; + let z13 = t40 & y5; + let z14 = t29 & y2; + let z15 = t42 & y9; + let z16 = t45 & y14; + let z17 = t41 & y8; + + // Bottom linear transformation (28 gates): the output basis change and the affine map of + // Eq. 5.2, whose `{63}` constant is the four XNORs below. + let tc1 = z15 ^ z16; + let tc2 = z10 ^ tc1; + let tc3 = z9 ^ tc2; + let tc4 = z0 ^ z2; + let tc5 = z1 ^ z0; + let tc6 = z3 ^ z4; + let tc7 = z12 ^ tc4; + let tc8 = z7 ^ tc6; + let tc9 = z8 ^ tc7; + let tc10 = tc8 ^ tc9; + let tc11 = tc6 ^ tc5; + let tc12 = z3 ^ z5; + let tc13 = z13 ^ tc1; + let tc14 = tc4 ^ tc12; + let s3 = tc3 ^ tc11; + let tc16 = z6 ^ tc8; + let tc17 = z14 ^ tc10; + let tc18 = tc13 ^ tc14; + let s7 = !(z12 ^ tc18); + let tc20 = z15 ^ tc16; + let tc21 = tc2 ^ z11; + let s0 = tc3 ^ tc16; + let s6 = !(tc10 ^ tc18); + let s4 = tc14 ^ s3; + let s1 = !(s3 ^ tc16); + let tc26 = tc17 ^ tc20; + let s2 = !(tc26 ^ z17); + let s5 = tc21 ^ tc17; + + // SLP outputs S0..S7, most-significant bit first, mirroring the input mapping. + q[7] = s0; + q[6] = s1; + q[5] = s2; + q[4] = s3; + q[3] = s4; + q[2] = s5; + q[1] = s6; + q[0] = s7; +} + +/// INVSUBBYTES(): applies the inverse AES S-box to every byte position of both blocks in `q` +/// (FIPS 197 Sec 5.3.2, the transformation tabulated in Table 6). +/// +/// Rather than a second 113-gate circuit, this reuses [`sbox`] by conjugating it with the +/// inverse of its affine layer. Writing the S-box of Eq. 5.2 as `S(x) = A(I(x)) ^ {63}`, where +/// `I` is inversion in GF(2^8) and `A` the linear part, and letting `B` be the inverse of `A`: +/// +/// ```text +/// iS(x) = B(S(B(x ^ {63})) ^ {63}) +/// ``` +/// +/// which holds because `I` is an involution: +/// `iS(S(y)) = B(A(I(B(A(I(y)) ^ {63} ^ {63}))) ^ {63} ^ {63}) = y`. +/// +/// So applying [`inv_affine`], then the forward circuit, then [`inv_affine`] again yields the +/// inverse S-box, at the cost of 16 extra XORs and 8 complements instead of a whole second +/// circuit. Verified exhaustively against Table 6 by `test_inv_sbox_matches_fips197_table_6`. +/// +/// The derivation and the layer below are from BearSSL `aes_ct_dec.c` +/// (`br_aes_ct_bitslice_invSbox`). +pub(crate) fn inv_sbox(q: &mut Planes) { + inv_affine(q); + sbox(q); + inv_affine(q); +} + +/// `B(x ^ {63})`: the inverse of the affine layer of Eq. 5.2, composed with the constant. +/// +/// The complements on planes 0, 1, 5 and 6 are the `^ {63}`; the eight three-term XORs are `B`. +/// Translated from BearSSL `aes_ct_dec.c:br_aes_ct_bitslice_invSbox`. +fn inv_affine(q: &mut Planes) { + let q0 = !q[0]; + let q1 = !q[1]; + let q2 = q[2]; + let q3 = q[3]; + let q4 = q[4]; + let q5 = !q[5]; + let q6 = !q[6]; + let q7 = q[7]; + q[7] = q1 ^ q4 ^ q6; + q[6] = q0 ^ q3 ^ q5; + q[5] = q7 ^ q2 ^ q4; + q[4] = q6 ^ q1 ^ q3; + q[3] = q5 ^ q0 ^ q2; + q[2] = q4 ^ q7 ^ q1; + q[1] = q3 ^ q6 ^ q0; + q[0] = q2 ^ q5 ^ q7; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitslice::{pack, unpack}; + + /// FIPS 197 Table 4 (SBOX), transcribed from the published PDF. Test-only: the + /// implementation evaluates the S-box as a Boolean circuit and never indexes a table. + #[rustfmt::skip] + const SBOX_TABLE_4: [u8; 256] = [ + 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, + 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, + 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, + 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, + 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, + 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, + 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, + 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, + 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, + 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, + 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, + 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, + 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, + 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, + 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, + 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, + ]; + + /// FIPS 197 Table 6 (INVSBOX), transcribed from the published PDF. Test-only. + #[rustfmt::skip] + const INVSBOX_TABLE_6: [u8; 256] = [ + 0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb, + 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb, + 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e, + 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25, + 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92, + 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84, + 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06, + 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b, + 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73, + 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e, + 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b, + 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4, + 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f, + 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef, + 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61, + 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d, + ]; + + /// Runs a plane transformation over a block placed in both halves, returning the A half. + /// + /// Filling both halves means a wrong interleave shows up as a difference between the two + /// blocks rather than silently passing. + fn apply(f: fn(&mut Planes), block: [u8; 16]) -> [u8; 16] { + let mut q = pack(&block, &block); + f(&mut q); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, b, "the two interleaved blocks must transform identically"); + a + } + + #[test] + fn test_sbox_matches_fips197_table_4() { + // Exhaustive over the whole domain: this is the test that makes the 113 gates + // trustworthy, so it must stay exhaustive. + for x in 0..=255u8 { + let out = apply(sbox, [x; 16]); + assert!( + out.iter().all(|&b| b == out[0]), + "all 16 byte positions must substitute alike, x={x:#04x}" + ); + assert_eq!( + out[0], SBOX_TABLE_4[x as usize], + "SBOX({x:#04x}) should be {:#04x}", + SBOX_TABLE_4[x as usize] + ); + } + } + + #[test] + fn test_inv_sbox_matches_fips197_table_6() { + for x in 0..=255u8 { + let out = apply(inv_sbox, [x; 16]); + assert_eq!( + out[0], INVSBOX_TABLE_6[x as usize], + "INVSBOX({x:#04x}) should be {:#04x}", + INVSBOX_TABLE_6[x as usize] + ); + } + } + + #[test] + fn test_inv_sbox_inverts_sbox() { + for x in 0..=255u8 { + let mut q = pack(&[x; 16], &[x.wrapping_add(1); 16]); + sbox(&mut q); + inv_sbox(&mut q); + let mut a = [0u8; 16]; + let mut b = [0u8; 16]; + unpack(&q, &mut a, &mut b); + assert_eq!(a, [x; 16]); + assert_eq!(b, [x.wrapping_add(1); 16]); + } + } + + #[test] + fn test_sbox_worked_example_from_section_5_1_1() { + // FIPS 197 Sec 5.1.1: "if s(r,c) = {53} ... s'(r,c) = {ed}". + assert_eq!(apply(sbox, [0x53; 16])[0], 0xed); + assert_eq!(SBOX_TABLE_4[0x53], 0xed); + } + + #[test] + fn test_the_two_spec_tables_are_inverses() { + // Guards the transcription of both tables against a typo in either one. + for x in 0..=255u8 { + assert_eq!(INVSBOX_TABLE_6[SBOX_TABLE_4[x as usize] as usize], x); + } + } + + #[test] + fn test_sbox_operates_on_each_byte_position_independently() { + // A block of distinct values, so a mask error that mixes byte positions is caught. + let block: [u8; 16] = core::array::from_fn(|i| (i as u8) * 17); + let out = apply(sbox, block); + for i in 0..16 { + assert_eq!(out[i], SBOX_TABLE_4[block[i] as usize], "byte position {i}"); + } + } +} diff --git a/crypto/aes-lowmemory/src/schedule.rs b/crypto/aes-lowmemory/src/schedule.rs new file mode 100644 index 00000000..9ae50e38 --- /dev/null +++ b/crypto/aes-lowmemory/src/schedule.rs @@ -0,0 +1,461 @@ +//! KEYEXPANSION() (FIPS 197 Sec 5.2, Algorithm 2) and the per-key-length parameters. +//! +//! # Storage +//! +//! The schedule is `4 * (Nr + 1)` words -- 44, 52 or 60 -- exactly as FIPS 197 Sec 5.2 defines +//! it, so 176, 208 or 240 bytes. It is stored in a **compressed** bit-sliced form: because +//! bit-slicing is a permutation of bits it does not change the size, and because both interleaved +//! blocks are encrypted under the same key the two halves of a bit-sliced round key are +//! identical, so only one of every pair of words needs keeping. [`round_key`] re-doubles a single +//! round key onto the stack when the round loop needs it. +//! +//! The alternative -- storing the doubled 8-plane form -- would need 352, 416 or 480 bytes, and +//! holding the classical schedule *and* a bit-sliced copy would be worse still. Since low memory +//! is the point of this crate, neither is done: [`expand`] writes the classical schedule into the +//! final array and then rewrites it in place, one round key at a time, using eight words of +//! stack. In particular it does not mirror BearSSL's `uint32_t skey[120]` (480-byte) scratch +//! buffer. +//! +//! # Constant-time +//! +//! The key is secret, so SUBWORD() in the expansion has the same table-lookup problem as +//! SUBBYTES() in the cipher, and gets the same treatment: [`sub_word`] routes the word through +//! the bit-sliced circuit in [`crate::sbox`]. A table-driven "light" AES that only removes the +//! tables from the cipher, and not from the key schedule, still leaks through the schedule. + +use crate::bitslice::{Planes, ortho}; +use crate::sbox::sbox; +use bouncycastle_utils::secret::{Secret, ZeroizablePrimitive}; + +/// FIPS 197 Sec 5.2, Table 5: the round constants, `Rcon[j]` for `1 <= j <= 10`. +/// +/// Table 5 gives each as the word `[x, 00, 00, 00]`; only the leftmost byte is ever non-zero, and +/// words are held little-endian here, so the word `Rcon[j]` is just this byte. Indexing is shifted +/// by one against the spec: `RCON[j - 1]` is the spec's `Rcon[j]`, since the spec counts from 1. +const RCON: [u32; 10] = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]; + +/// Prevents a fourth parameter set from being added outside this crate. +/// +/// FIPS 197 Sec 6.1 defines exactly three: AES-128, AES-192 and AES-256. Because [`AesParams`] +/// has this private supertrait, only the three types in this module can implement it, so no +/// downstream crate can instantiate the cipher with an unapproved key length or round count. +trait AesParamsSealed {} + +/// The per-key-length constants of FIPS 197 Sec 6.1. +/// +/// This is a trait rather than const generic parameters because the schedule length +/// `4 * (Nr + 1)` cannot be written as an expression over another const parameter on stable +/// const-generics; each implementation spells its own array type out instead. The same pattern is +/// used by the `HashDRBG80090AParams_*` types in `bouncycastle-rng`. +/// +/// Sealed via a private supertrait, so the three types below are the only implementations. +pub trait AesParams: AesParamsSealed { + /// Key length in bytes: 16, 24 or 32 (FIPS 197 Sec 6.1). + const KEY_LEN: usize; + /// `Nk`, the key length in 32-bit words: 4, 6 or 8 (FIPS 197 Sec 6.1). + const NK: usize; + /// `Nr`, the number of rounds: 10, 12 or 14 (FIPS 197 Sec 6.1). + const NR: usize; + /// The algorithm name, as reported by `Algorithm::ALG_NAME`. + const ALG_NAME: &'static str; + /// `[u32; 4 * (NR + 1)]` -- the compressed schedule. See the module docs. + type Schedule: ZeroizablePrimitive + AsRef<[u32]> + AsMut<[u32]>; +} + +/// AES-128 parameters: 16-byte key, `Nk` = 4, `Nr` = 10 (FIPS 197 Sec 6.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Aes128Params; +/// AES-192 parameters: 24-byte key, `Nk` = 6, `Nr` = 12 (FIPS 197 Sec 6.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Aes192Params; +/// AES-256 parameters: 32-byte key, `Nk` = 8, `Nr` = 14 (FIPS 197 Sec 6.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Aes256Params; + +impl AesParamsSealed for Aes128Params {} +impl AesParamsSealed for Aes192Params {} +impl AesParamsSealed for Aes256Params {} + +impl AesParams for Aes128Params { + const KEY_LEN: usize = 16; + const NK: usize = 4; + const NR: usize = 10; + const ALG_NAME: &'static str = "AES-128"; + type Schedule = [u32; 44]; // 4 * (10 + 1) +} + +impl AesParams for Aes192Params { + const KEY_LEN: usize = 24; + const NK: usize = 6; + const NR: usize = 12; + const ALG_NAME: &'static str = "AES-192"; + type Schedule = [u32; 52]; // 4 * (12 + 1) +} + +impl AesParams for Aes256Params { + const KEY_LEN: usize = 32; + const NK: usize = 8; + const NR: usize = 14; + const ALG_NAME: &'static str = "AES-256"; + type Schedule = [u32; 60]; // 4 * (14 + 1) +} + +/// ROTWORD(): `[a0,a1,a2,a3] -> [a1,a2,a3,a0]` (FIPS 197 Sec 5.2, Eq 5.10). +/// +/// Words are held little-endian, so `a0` is the low byte. Moving `a1` down into the low byte and +/// wrapping `a0` to the top is a rotate right by 8 of the whole word. +#[inline(always)] +fn rot_word(word: u32) -> u32 { + word.rotate_right(8) +} + +/// SUBWORD(): applies the S-box to each of the four bytes of a word +/// (FIPS 197 Sec 5.2, Eq 5.11). +/// +/// The key is secret, so this must not be a table lookup. It reuses the bit-sliced circuit +/// instead, by replicating `word` into all eight planes before transposing: +/// +/// after [`ortho`], plane `q[k]` bit `8L + i` equals bit `8L + k` of the *input* word `q[i]` -- +/// and every input word is the same `word`, so that bit is bit `k` of byte `L` of `word` +/// regardless of `i`. In the layout of [`crate::bitslice`], the bit positions `8L + i` for +/// `i = 0..8` are all four columns of row `L`, in both blocks. So the transposed state holds byte +/// `L` of `word` in every position of row `L`, one S-box pass substitutes all four bytes (sixteen +/// times over, redundantly), and transposing back reassembles the word. All eight planes then +/// hold the same result, so `q[0]` is SUBWORD(`word`); `test_sub_word_fills_every_plane` checks +/// that. +/// +/// It costs a full 113-gate S-box evaluation to substitute four bytes, which is wasteful, but it +/// happens `Nr` or so times per key rather than per block. Translated from BearSSL +/// `aes_ct.c:sub_word`. +fn sub_word(word: u32) -> u32 { + let mut q: Planes = [word; 8]; + ortho(&mut q); + sbox(&mut q); + ortho(&mut q); + q[0] +} + +/// KEYEXPANSION() (FIPS 197 Sec 5.2, Algorithm 2), returning the compressed bit-sliced schedule. +/// +/// `key` must be exactly `P::KEY_LEN` bytes; [`crate::aes`] checks that before calling, so this +/// cannot fail and takes no `Result`. +/// +/// Algorithm 2 is followed literally -- lines 2-6 copy the key into `w[0..Nk]`, lines 7-16 derive +/// the rest -- and then the finished schedule is rewritten in place into the storage form +/// described in the module docs. Verified against the worked expansions in FIPS 197 +/// Appendix A.1, A.2 and A.3 by the tests at the bottom of this file, which decompress the +/// stored schedule and compare every w[i]. +pub(crate) fn expand(key: &[u8]) -> Secret { + debug_assert_eq!(key.len(), P::KEY_LEN); + + let mut schedule = Secret::::new(); + let w = (*schedule).as_mut(); + + // Algorithm 2 lines 2-6: w[i] = key[4i .. 4i+3] for i < Nk. + for i in 0..P::NK { + // Cannot fail: `key` is P::KEY_LEN == 4 * P::NK bytes, so this window is in bounds. + w[i] = u32::from_le_bytes(key[4 * i..4 * i + 4].try_into().unwrap()); + } + + // Algorithm 2 lines 7-16. + let mut temp = w[P::NK - 1]; // line 8, hoisted: w[i-1] is the temp from the previous pass + for i in P::NK..w.len() { + if i % P::NK == 0 { + // line 10: temp = SUBWORD(ROTWORD(temp)) XOR Rcon[i / Nk] + temp = sub_word(rot_word(temp)) ^ RCON[i / P::NK - 1]; + } else if P::NK > 6 && i % P::NK == 4 { + // lines 11-12: the extra substitution that only AES-256 reaches + temp = sub_word(temp); + } + // line 14: w[i] = w[i - Nk] XOR temp + temp ^= w[i - P::NK]; + w[i] = temp; + } + + // Rewrite in place into the compressed bit-sliced form, one 4-word round key at a time. + // Both interleaved blocks use the same key, so each round key is bit-sliced with the word + // duplicated into both halves; the two halves are then identical and one bit of each pair is + // redundant, so the even-position bits of the first word and the odd-position bits of the + // second are packed into a single stored word. + for base in (0..w.len()).step_by(4) { + let mut q: Planes = [0u32; 8]; + for j in 0..4 { + q[2 * j] = w[base + j]; + q[2 * j + 1] = w[base + j]; + } + ortho(&mut q); + for j in 0..4 { + // The two masks are complementary, so the operands are disjoint and `|` and `^` agree. + // That is why `cargo mutants` reports the `| -> ^` mutant here as surviving. + w[base + j] = (q[2 * j] & 0x5555_5555) | (q[2 * j + 1] & 0xAAAA_AAAA); + } + } + + schedule +} + +/// Re-doubles round key `round` of a compressed schedule into its eight-plane form. +/// +/// The inverse of the packing at the end of [`expand`]: the even-position bits are spread back +/// over both positions of each pair, and likewise the odd-position bits, giving the two identical +/// halves that [`crate::round::add_round_key`] expects. Eight words of stack, built fresh each +/// round rather than stored. +/// +/// Translated from BearSSL `aes_ct.c:br_aes_ct_skey_expand`. +#[inline(always)] +pub(crate) fn round_key(schedule: &P::Schedule, round: usize) -> Planes { + debug_assert!(round <= P::NR); + let w = schedule.as_ref(); + let mut sk: Planes = [0u32; 8]; + for j in 0..4 { + let packed = w[4 * round + j]; + let even = packed & 0x5555_5555; + let odd = packed & 0xAAAA_AAAA; + // `even` occupies only even bit positions and `even << 1` only odd ones (and vice versa + // for `odd`), so both spreads combine disjoint operands and `|` and `^` agree. Hence the + // two `| -> ^` mutants `cargo mutants` reports here as surviving. + sk[2 * j] = even | (even << 1); + sk[2 * j + 1] = odd | (odd >> 1); + } + sk +} + +#[cfg(test)] +mod tests { + use super::*; + + /// FIPS 197 Appendix A.1: every w[i] of the AES-128 key expansion, as printed + /// (i.e. the byte sequence [a0,a1,a2,a3] read left to right). + #[rustfmt::skip] + const APPENDIX_A1_WORDS: [u32; 44] = [ + 0x2b7e1516, 0x28aed2a6, 0xabf71588, 0x09cf4f3c, + 0xa0fafe17, 0x88542cb1, 0x23a33939, 0x2a6c7605, + 0xf2c295f2, 0x7a96b943, 0x5935807a, 0x7359f67f, + 0x3d80477d, 0x4716fe3e, 0x1e237e44, 0x6d7a883b, + 0xef44a541, 0xa8525b7f, 0xb671253b, 0xdb0bad00, + 0xd4d1c6f8, 0x7c839d87, 0xcaf2b8bc, 0x11f915bc, + 0x6d88a37a, 0x110b3efd, 0xdbf98641, 0xca0093fd, + 0x4e54f70e, 0x5f5fc9f3, 0x84a64fb2, 0x4ea6dc4f, + 0xead27321, 0xb58dbad2, 0x312bf560, 0x7f8d292f, + 0xac7766f3, 0x19fadc21, 0x28d12941, 0x575c006e, + 0xd014f9a8, 0xc9ee2589, 0xe13f0cc8, 0xb6630ca6, + ]; + + /// FIPS 197 Appendix A.2: every w[i] of the AES-192 key expansion, as printed. + #[rustfmt::skip] + const APPENDIX_A2_WORDS: [u32; 52] = [ + 0x8e73b0f7, 0xda0e6452, 0xc810f32b, 0x809079e5, + 0x62f8ead2, 0x522c6b7b, 0xfe0c91f7, 0x2402f5a5, + 0xec12068e, 0x6c827f6b, 0x0e7a95b9, 0x5c56fec2, + 0x4db7b4bd, 0x69b54118, 0x85a74796, 0xe92538fd, + 0xe75fad44, 0xbb095386, 0x485af057, 0x21efb14f, + 0xa448f6d9, 0x4d6dce24, 0xaa326360, 0x113b30e6, + 0xa25e7ed5, 0x83b1cf9a, 0x27f93943, 0x6a94f767, + 0xc0a69407, 0xd19da4e1, 0xec1786eb, 0x6fa64971, + 0x485f7032, 0x22cb8755, 0xe26d1352, 0x33f0b7b3, + 0x40beeb28, 0x2f18a259, 0x6747d26b, 0x458c553e, + 0xa7e1466c, 0x9411f1df, 0x821f750a, 0xad07d753, + 0xca400538, 0x8fcc5006, 0x282d166a, 0xbc3ce7b5, + 0xe98ba06f, 0x448c773c, 0x8ecc7204, 0x01002202, + ]; + + /// FIPS 197 Appendix A.3: every w[i] of the AES-256 key expansion, as printed. + #[rustfmt::skip] + const APPENDIX_A3_WORDS: [u32; 60] = [ + 0x603deb10, 0x15ca71be, 0x2b73aef0, 0x857d7781, + 0x1f352c07, 0x3b6108d7, 0x2d9810a3, 0x0914dff4, + 0x9ba35411, 0x8e6925af, 0xa51a8b5f, 0x2067fcde, + 0xa8b09c1a, 0x93d194cd, 0xbe49846e, 0xb75d5b9a, + 0xd59aecb8, 0x5bf3c917, 0xfee94248, 0xde8ebe96, + 0xb5a9328a, 0x2678a647, 0x98312229, 0x2f6c79b3, + 0x812c81ad, 0xdadf48ba, 0x24360af2, 0xfab8b464, + 0x98c5bfc9, 0xbebd198e, 0x268c3ba7, 0x09e04214, + 0x68007bac, 0xb2df3316, 0x96e939e4, 0x6c518d80, + 0xc814e204, 0x76a9fb8a, 0x5025c02d, 0x59c58239, + 0xde136967, 0x6ccc5a71, 0xfa256395, 0x9674ee15, + 0x5886ca5d, 0x2e2f31d7, 0x7e0af1fa, 0x27cf73c3, + 0x749c47ab, 0x18501dda, 0xe2757e4f, 0x7401905a, + 0xcafaaae3, 0xe4d59b34, 0x9adf6ace, 0xbd10190d, + 0xfe4890d1, 0xe6188d0b, 0x046df344, 0x706c631e, + ]; + + /// Recovers the classical `w[i]` from a stored schedule. + /// + /// [`round_key`] undoes the pair-compression, and [`ortho`] then undoes the bit-slicing, + /// leaving the duplicated pre-slicing words with `w[4*round + j]` in position `2j`. This is + /// what lets the Appendix A vectors test the real [`expand`] output rather than a + /// reimplementation of it. + fn classical_word(schedule: &P::Schedule, i: usize) -> u32 { + let mut q = round_key::

(schedule, i / 4); + ortho(&mut q); + let j = i % 4; + assert_eq!(q[2 * j], q[2 * j + 1], "both interleaved halves hold the same round key"); + q[2 * j] + } + + /// Compares a whole expansion against an Appendix A table. + /// + /// Appendix A prints a word as the byte sequence `[a0,a1,a2,a3]` left to right, so the + /// tabulated `u32` has `a0` in its *most* significant byte; words are held little-endian + /// here, so `swap_bytes` is the conversion. + fn assert_expansion_matches(key: &[u8], expected: &[u32], label: &str) { + let schedule = expand::

(key); + assert_eq!(expected.len(), 4 * (P::NR + 1), "{label}: table length"); + for (i, &want) in expected.iter().enumerate() { + let got = classical_word::

(&schedule, i).swap_bytes(); + assert_eq!(got, want, "{label}: w[{i}] should be {want:#010x}, got {got:#010x}"); + } + } + + #[test] + fn test_key_expansion_matches_fips197_appendix_a1() { + let key = [ + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, + 0x4f, 0x3c, + ]; + assert_expansion_matches::(&key, &APPENDIX_A1_WORDS, "Appendix A.1"); + } + + #[test] + fn test_key_expansion_matches_fips197_appendix_a2() { + let key = [ + 0x8e, 0x73, 0xb0, 0xf7, 0xda, 0x0e, 0x64, 0x52, 0xc8, 0x10, 0xf3, 0x2b, 0x80, 0x90, + 0x79, 0xe5, 0x62, 0xf8, 0xea, 0xd2, 0x52, 0x2c, 0x6b, 0x7b, + ]; + assert_expansion_matches::(&key, &APPENDIX_A2_WORDS, "Appendix A.2"); + } + + #[test] + fn test_key_expansion_matches_fips197_appendix_a3() { + let key = [ + 0x60, 0x3d, 0xeb, 0x10, 0x15, 0xca, 0x71, 0xbe, 0x2b, 0x73, 0xae, 0xf0, 0x85, 0x7d, + 0x77, 0x81, 0x1f, 0x35, 0x2c, 0x07, 0x3b, 0x61, 0x08, 0xd7, 0x2d, 0x98, 0x10, 0xa3, + 0x09, 0x14, 0xdf, 0xf4, + ]; + assert_expansion_matches::(&key, &APPENDIX_A3_WORDS, "Appendix A.3"); + } + + #[test] + fn test_the_first_nk_schedule_words_are_the_key_itself() { + // Algorithm 2 lines 2-6, and a check that the expansion is reading the key + // little-endian consistently with how Appendix A prints it. + let key = [ + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, + 0x4f, 0x3c, + ]; + let schedule = expand::(&key); + for i in 0..Aes128Params::NK { + let got = classical_word::(&schedule, i); + assert_eq!(got.to_le_bytes(), key[4 * i..4 * i + 4]); + } + } + + #[test] + fn test_rot_word_matches_equation_5_10() { + // FIPS 197 Eq 5.10 on the byte sequence [a0,a1,a2,a3] = [0x09,0xcf,0x4f,0x3c], which is + // the temp at i = 4 of Appendix A.1, whose ROTWORD() the appendix gives as cf4f3c09. + let word = u32::from_le_bytes([0x09, 0xcf, 0x4f, 0x3c]); + assert_eq!(rot_word(word).to_le_bytes(), [0xcf, 0x4f, 0x3c, 0x09]); + } + + #[test] + fn test_sub_word_matches_the_appendix_a1_example() { + // Appendix A.1, i = 4: "After ROTWORD()" is cf4f3c09 and "After SUBWORD()" is 8a84eb01. + // The appendix prints a word as the byte sequence [a0,a1,a2,a3]; words are held + // little-endian here, so `a0` is the low byte. + let after_rot = u32::from_le_bytes([0xcf, 0x4f, 0x3c, 0x09]); + assert_eq!(sub_word(after_rot).to_le_bytes(), [0x8a, 0x84, 0xeb, 0x01]); + } + + #[test] + fn test_sub_word_fills_every_plane() { + // The doc comment claims all eight planes end up holding SUBWORD(word); if that ever + // stopped being true, picking q[0] would be an arbitrary choice rather than a correct one. + let word = 0x1234_5678u32; + let mut q: Planes = [word; 8]; + ortho(&mut q); + sbox(&mut q); + ortho(&mut q); + assert!(q.iter().all(|&plane| plane == q[0])); + assert_eq!(q[0], sub_word(word)); + } + + #[test] + fn test_round_key_inverts_the_compression() { + // Round-tripping a known schedule: expand(), then round_key() for every round, and check + // the recovered planes match bit-slicing the classical words directly. + let key = [ + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, + 0x4f, 0x3c, + ]; + let schedule = expand::(&key); + + // Recompute the classical schedule without the compression step. + let mut w = [0u32; 44]; + for i in 0..4 { + w[i] = u32::from_le_bytes(key[4 * i..4 * i + 4].try_into().unwrap()); + } + let mut temp = w[3]; + for i in 4..44 { + if i % 4 == 0 { + temp = sub_word(rot_word(temp)) ^ RCON[i / 4 - 1]; + } + temp ^= w[i - 4]; + w[i] = temp; + } + + for round in 0..=Aes128Params::NR { + let got = round_key::(&schedule, round); + let mut expected: Planes = [0u32; 8]; + for j in 0..4 { + expected[2 * j] = w[4 * round + j]; + expected[2 * j + 1] = w[4 * round + j]; + } + ortho(&mut expected); + assert_eq!(got, expected, "round {round}"); + } + } + + #[test] + fn test_schedule_lengths_match_four_times_nr_plus_one() { + // FIPS 197 Sec 5.2: the schedule is 4 * (Nr + 1) words. The array types are written out + // by hand per parameter set, so this guards against a typo in one of them. + assert_eq!( + size_of::<::Schedule>() / 4, + 4 * (Aes128Params::NR + 1) + ); + assert_eq!( + size_of::<::Schedule>() / 4, + 4 * (Aes192Params::NR + 1) + ); + assert_eq!( + size_of::<::Schedule>() / 4, + 4 * (Aes256Params::NR + 1) + ); + } + + #[test] + fn test_key_len_is_four_times_nk() { + // FIPS 197 Sec 6.1 ties the two together; both are declared independently above. + assert_eq!(Aes128Params::KEY_LEN, 4 * Aes128Params::NK); + assert_eq!(Aes192Params::KEY_LEN, 4 * Aes192Params::NK); + assert_eq!(Aes256Params::KEY_LEN, 4 * Aes256Params::NK); + } + + #[test] + fn test_rcon_table_5_values() { + // FIPS 197 Sec 5.2: "for j > 0, these bytes may be generated by successively applying + // XTIMES() to the byte represented by x^(j-1)". Derive the table and compare, so a typo + // in the transcription of Table 5 shows up here. + let mut expected = [0u32; 10]; + let mut v: u8 = 0x01; + for slot in expected.iter_mut() { + *slot = u32::from(v); + v = (v << 1) ^ if v & 0x80 != 0 { 0x1b } else { 0 }; + } + assert_eq!(RCON, expected); + // Spot-check the two values from Table 5 that are not plain powers of two. + assert_eq!(RCON[8], 0x1b); + assert_eq!(RCON[9], 0x36); + } +} From 44ef51a1f815e119a064467af3bbf8d6b2ebf97c Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 16:20:37 +0700 Subject: [PATCH 20/28] Added tests for aes-lowmemory (#98) --- crypto/aes-lowmemory/tests/acvp_tests.rs | 266 ++++++++++++++++++ crypto/aes-lowmemory/tests/fips197_tests.rs | 230 +++++++++++++++ crypto/aes-lowmemory/tests/sp800_38a_tests.rs | 176 ++++++++++++ 3 files changed, 672 insertions(+) create mode 100644 crypto/aes-lowmemory/tests/acvp_tests.rs create mode 100644 crypto/aes-lowmemory/tests/fips197_tests.rs create mode 100644 crypto/aes-lowmemory/tests/sp800_38a_tests.rs diff --git a/crypto/aes-lowmemory/tests/acvp_tests.rs b/crypto/aes-lowmemory/tests/acvp_tests.rs new file mode 100644 index 00000000..0ab0b431 --- /dev/null +++ b/crypto/aes-lowmemory/tests/acvp_tests.rs @@ -0,0 +1,266 @@ +//! Known-answer tests against the NIST ACVP `ACVP-AES-ECB` vectors from the `bc-test-data` repo. +//! +//! Requires `bc-test-data` to be cloned alongside this repository, i.e. at `../bc-test-data` +//! relative to the root of this git project. If it is absent the tests print a warning and pass, +//! matching the convention used by the ML-KEM and ML-DSA test suites -- `cargo test` must stay +//! green for someone who has only cloned this repository. +//! +//! # Why ACVP ECB vectors +//! +//! ECB applies the raw permutation to each block independently, so an ECB test vector *is* a +//! block-permutation test vector -- which is the only reason ECB is mentioned in this crate. See +//! the crate docs on why you must never use ECB to encrypt data. +//! +//! The response file records `key`, `pt` and `ct` for every test case regardless of the group's +//! declared direction, so each case is checked in **both** directions: encrypting `pt` must give +//! `ct` and decrypting `ct` must give `pt`. That is strictly stronger than honouring the declared +//! direction, and it means the group metadata in the request file is not needed. +//! +//! # Coverage and one gap +//! +//! The AFT (Algorithm Functional Test) groups cover all three key lengths in both directions, +//! including cases whose plaintext spans several blocks. The six MCT (Monte Carlo Test) groups +//! are **not** implemented: their expected output is a `resultsArray` produced by a chained +//! key/plaintext update rule defined in the ACVP AES specification rather than in FIPS 197, and +//! implementing it from anything other than that specification would be guesswork. The test +//! reports how many it skipped so the gap is visible rather than silent. + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN}; +use bouncycastle_core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, +}; +use bouncycastle_core::traits::SecurityStrength; +use bouncycastle_hex as hex; +use serde_json::Value; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Candidate locations, covering `cargo test` run from the crate root or from the repo root. +const TEST_DATA_PATHS: [&str; 2] = [ + "../../../bc-test-data/crypto/aes_tdes_vectors/AES", + "../bc-test-data/crypto/aes_tdes_vectors/AES", +]; + +const RESPONSE_FILE: &str = "ACVP-AES-ECB.4014527.rsp.json"; + +/// Locates the ACVP AES directory, or `None` if `bc-test-data` is not checked out. +fn test_data_dir() -> Option { + for candidate in TEST_DATA_PATHS { + let path = Path::new(candidate); + if path.join(RESPONSE_FILE).exists() { + return Some(path.to_path_buf()); + } + } + println!( + "WARNING: bc-test-data not found (looked in {TEST_DATA_PATHS:?}); \ + ACVP AES-ECB tests will be skipped" + ); + None +} + +/// Builds a `KeyMaterial` from raw ACVP key bytes, including the all-zero keys. +/// +/// The ACVP set deliberately includes an all-zero key (the GFSbox-style groups vary only the +/// plaintext under a zero key). `KeyMaterial` tags an all-zero buffer as [`KeyType::Zeroized`] +/// and will not promote it outside a [`do_hazardous_operations`] closure, which is the right +/// default -- an all-zero key normally means a broken RNG, and `Aes128::new` rejecting it is +/// tested in `fips197_tests.rs`. Here the zero key is deliberate and comes from NIST, so this +/// opts in explicitly rather than the library weakening its guard. +fn cipher_key(bytes: &[u8]) -> KeyMaterial { + assert_eq!(bytes.len(), N, "key length should match the parameter set"); + let mut key = KeyMaterial::::from_bytes_as_type(bytes, KeyType::SymmetricCipherKey) + .expect("ACVP key bytes fit the buffer"); + + if key.key_type() != KeyType::SymmetricCipherKey { + do_hazardous_operations(&mut key, |k| { + k.set_key_type(KeyType::SymmetricCipherKey)?; + k.set_security_strength(SecurityStrength::from_bytes(N)) + }) + .expect("promoting a NIST all-zero test key"); + } + + key +} + +/// A single-block transformation, resolved once per test case rather than per block. +type BlockTransform = Box; + +/// Encrypts or decrypts `data` block by block, i.e. ECB, dispatching on the key length. +fn ecb(key: &[u8], data: &[u8], encrypt: bool) -> Vec { + assert_eq!(data.len() % BLOCK_LEN, 0, "ACVP ECB data must be block-aligned"); + + let transform: BlockTransform = match key.len() { + 16 => { + let km = cipher_key::<16>(key); + let aes = Aes128::new(&km).expect("valid AES-128 key"); + if encrypt { + Box::new(move |b| aes.encrypt_block(b)) + } else { + Box::new(move |b| aes.decrypt_block(b)) + } + } + 24 => { + let km = cipher_key::<24>(key); + let aes = Aes192::new(&km).expect("valid AES-192 key"); + if encrypt { + Box::new(move |b| aes.encrypt_block(b)) + } else { + Box::new(move |b| aes.decrypt_block(b)) + } + } + 32 => { + let km = cipher_key::<32>(key); + let aes = Aes256::new(&km).expect("valid AES-256 key"); + if encrypt { + Box::new(move |b| aes.encrypt_block(b)) + } else { + Box::new(move |b| aes.decrypt_block(b)) + } + } + other => panic!("ACVP AES vectors should only use 16, 24 or 32 byte keys, got {other}"), + }; + + let mut out = Vec::with_capacity(data.len()); + for chunk in data.chunks(BLOCK_LEN) { + // Cannot fail: the length is asserted block-aligned above. + let mut block: [u8; BLOCK_LEN] = chunk.try_into().unwrap(); + transform(&mut block); + out.extend_from_slice(&block); + } + out +} + +/// The same, using the two-block entry points where a pair is available. +fn ecb_pairwise(key: &[u8], data: &[u8], encrypt: bool) -> Vec { + assert_eq!(data.len() % BLOCK_LEN, 0, "ACVP ECB data must be block-aligned"); + let mut blocks: Vec<[u8; BLOCK_LEN]> = + data.chunks(BLOCK_LEN).map(|c| c.try_into().unwrap()).collect(); + + match key.len() { + 16 => { + let km = cipher_key::<16>(key); + let aes = Aes128::new(&km).unwrap(); + run_pairwise(&mut blocks, encrypt, |p, e| { + if e { aes.encrypt_blocks2(p) } else { aes.decrypt_blocks2(p) } + }); + } + 24 => { + let km = cipher_key::<24>(key); + let aes = Aes192::new(&km).unwrap(); + run_pairwise(&mut blocks, encrypt, |p, e| { + if e { aes.encrypt_blocks2(p) } else { aes.decrypt_blocks2(p) } + }); + } + 32 => { + let km = cipher_key::<32>(key); + let aes = Aes256::new(&km).unwrap(); + run_pairwise(&mut blocks, encrypt, |p, e| { + if e { aes.encrypt_blocks2(p) } else { aes.decrypt_blocks2(p) } + }); + } + other => panic!("ACVP AES vectors should only use 16, 24 or 32 byte keys, got {other}"), + } + + blocks.concat() +} + +/// Walks `blocks` two at a time, leaving a trailing odd block to a duplicated pair. +fn run_pairwise( + blocks: &mut [[u8; BLOCK_LEN]], + encrypt: bool, + transform: impl Fn(&mut [[u8; BLOCK_LEN]; 2], bool), +) { + let mut chunks = blocks.chunks_exact_mut(2); + for pair in &mut chunks { + // Cannot fail: `chunks_exact_mut(2)` yields slices of length 2. + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + transform(pair, encrypt); + } + // An odd trailing block still has to go through the two-block path. + if let [last] = chunks.into_remainder() { + let mut pair = [*last, *last]; + transform(&mut pair, encrypt); + *last = pair[0]; + } +} + +#[test] +fn acvp_aes_ecb_known_answer_tests() { + let Some(dir) = test_data_dir() else { return }; + + let contents = fs::read_to_string(dir.join(RESPONSE_FILE)).expect("readable response file"); + let parsed: Value = serde_json::from_str(&contents).expect("valid ACVP JSON"); + + // The ACVP file is an array: element 0 is the version header, element 1 the vector set. + let groups = parsed + .get(1) + .and_then(|set| set.get("testGroups")) + .and_then(Value::as_array) + .expect("testGroups array"); + + let mut checked = 0usize; + let mut skipped_mct = 0usize; + let mut by_key_len = [0usize; 3]; // 128, 192, 256 + + for group in groups { + let tests = group.get("tests").and_then(Value::as_array).expect("tests array"); + for test in tests { + let tc_id = test.get("tcId").and_then(Value::as_u64).expect("tcId"); + + // Monte Carlo groups carry a chained resultsArray instead of a single pt/ct pair. + if test.get("resultsArray").is_some() { + skipped_mct += 1; + continue; + } + + let get = |name: &str| -> Vec { + let s = test + .get(name) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("tcId {tc_id}: missing field {name}")); + hex::decode(s).unwrap_or_else(|_| panic!("tcId {tc_id}: bad hex in {name}")) + }; + + let key = get("key"); + let pt = get("pt"); + let ct = get("ct"); + + assert_eq!(pt.len(), ct.len(), "tcId {tc_id}: pt and ct differ in length"); + + assert_eq!(ecb(&key, &pt, true), ct, "tcId {tc_id}: AES-{} encrypt", key.len() * 8); + assert_eq!(ecb(&key, &ct, false), pt, "tcId {tc_id}: AES-{} decrypt", key.len() * 8); + + // The two-block path must agree with the single-block path on real vectors too. + assert_eq!( + ecb_pairwise(&key, &pt, true), + ct, + "tcId {tc_id}: AES-{} encrypt via encrypt_blocks2", + key.len() * 8 + ); + assert_eq!( + ecb_pairwise(&key, &ct, false), + pt, + "tcId {tc_id}: AES-{} decrypt via decrypt_blocks2", + key.len() * 8 + ); + + by_key_len[match key.len() { + 16 => 0, + 24 => 1, + _ => 2, + }] += 1; + checked += 1; + } + } + + println!( + "ACVP AES-ECB: {checked} test cases checked in both directions \ + (AES-128: {}, AES-192: {}, AES-256: {}); {skipped_mct} MCT cases skipped", + by_key_len[0], by_key_len[1], by_key_len[2] + ); + + // Guard against a silently-empty run: the published vector set has thousands of AFT cases + // across all three key lengths. + assert!(checked > 1000, "expected the full ACVP AFT set, only checked {checked}"); + assert!(by_key_len.iter().all(|&n| n > 0), "every key length should be covered"); +} diff --git a/crypto/aes-lowmemory/tests/fips197_tests.rs b/crypto/aes-lowmemory/tests/fips197_tests.rs new file mode 100644 index 00000000..d1261b8d --- /dev/null +++ b/crypto/aes-lowmemory/tests/fips197_tests.rs @@ -0,0 +1,230 @@ +//! Known-answer tests from NIST FIPS 197 itself. +//! +//! Appendix B -- the worked single-block AES-128 encryption -- plus its inverse, the two-block +//! path, and key-handling behaviour. +//! +//! The Appendix A key expansions are **not** tested here. The key schedule is deliberately not +//! public API (it is a `Secret` field), and a round-trip through the cipher cannot check it: a +//! wrong `w[i]` is used by encryption and decryption alike, so the round trip still succeeds. +//! Every word of all three expansions is instead checked against Appendix A inside +//! `src/schedule.rs`, where the stored schedule can be decompressed and compared directly. +//! +//! Known-answer coverage for AES-192 and AES-256, which Appendix B does not reach, is in +//! `sp800_38a_tests.rs` and `acvp_tests.rs`. +//! +//! All values here are transcribed from the published FIPS 197 (Update 1) PDF. + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; +use bouncycastle_core::traits::SecurityStrength; + +/// Appendix A.1 / Appendix B key: `2b7e151628aed2a6abf7158809cf4f3c`. +const KEY_128: [u8; 16] = [ + 0x2b, 0x7e, 0x15, 0x16, 0x28, 0xae, 0xd2, 0xa6, 0xab, 0xf7, 0x15, 0x88, 0x09, 0xcf, 0x4f, 0x3c, +]; + +/// Appendix A.2 key: `8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b`. +const KEY_192: [u8; 24] = [ + 0x8e, 0x73, 0xb0, 0xf7, 0xda, 0x0e, 0x64, 0x52, 0xc8, 0x10, 0xf3, 0x2b, 0x80, 0x90, 0x79, 0xe5, + 0x62, 0xf8, 0xea, 0xd2, 0x52, 0x2c, 0x6b, 0x7b, +]; + +/// Appendix A.3 key: +/// `603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4`. +const KEY_256: [u8; 32] = [ + 0x60, 0x3d, 0xeb, 0x10, 0x15, 0xca, 0x71, 0xbe, 0x2b, 0x73, 0xae, 0xf0, 0x85, 0x7d, 0x77, 0x81, + 0x1f, 0x35, 0x2c, 0x07, 0x3b, 0x61, 0x08, 0xd7, 0x2d, 0x98, 0x10, 0xa3, 0x09, 0x14, 0xdf, 0xf4, +]; + +fn key_material(bytes: &[u8; N]) -> KeyMaterial { + KeyMaterial::::from_bytes_as_type(bytes, KeyType::SymmetricCipherKey) + .expect("a valid symmetric cipher key") +} + +#[test] +fn appendix_b_encrypts_the_documented_block() { + // Appendix B: Input = 32 43 f6 a8 88 5a 30 8d 31 31 98 a2 e0 37 07 34 + // Key = 2b 7e 15 16 28 ae d2 a6 ab f7 15 88 09 cf 4f 3c + // The final state printed as "output" reads, column by column (Eq 3.7): + // 39 25 84 1d 02 dc 09 fb dc 11 85 97 19 6a 0b 32 + let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + + let mut block = [ + 0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, + 0x34, + ]; + aes.encrypt_block(&mut block); + assert_eq!( + block, + [ + 0x39, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb, 0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, + 0x0b, 0x32 + ] + ); +} + +#[test] +fn appendix_b_decrypts_back_to_the_documented_input() { + let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + + let mut block = [ + 0x39, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb, 0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, 0x0b, + 0x32, + ]; + aes.decrypt_block(&mut block); + assert_eq!( + block, + [ + 0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, + 0x07, 0x34 + ] + ); +} + +#[test] +fn appendix_b_two_block_path_agrees_with_the_single_block_path() { + let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + let input = [ + 0x32, 0x43, 0xf6, 0xa8, 0x88, 0x5a, 0x30, 0x8d, 0x31, 0x31, 0x98, 0xa2, 0xe0, 0x37, 0x07, + 0x34, + ]; + let expected = [ + 0x39, 0x25, 0x84, 0x1d, 0x02, 0xdc, 0x09, 0xfb, 0xdc, 0x11, 0x85, 0x97, 0x19, 0x6a, 0x0b, + 0x32, + ]; + + // Pairing the Appendix B block with an unrelated one must not disturb either half. + let other = [0xAAu8; 16]; + let mut other_alone = other; + aes.encrypt_block(&mut other_alone); + + let mut pair = [input, other]; + aes.encrypt_blocks2(&mut pair); + assert_eq!(pair[0], expected); + assert_eq!(pair[1], other_alone); + + // ...and in the other slot, which is a different bit position in the interleave. + let mut pair = [other, input]; + aes.encrypt_blocks2(&mut pair); + assert_eq!(pair[0], other_alone); + assert_eq!(pair[1], expected); +} + +/// Encryption and decryption are inverses, under each Appendix A key. +/// +/// This checks `decrypt_block` really inverts `encrypt_block` from the same stored schedule, +/// which is the load-bearing claim of following FIPS 197 Algorithm 3 rather than Sec 5.3.5. It +/// deliberately makes no claim about the schedule being *correct* -- see the module docs. +#[test] +fn encryption_and_decryption_are_inverses_for_all_three_key_lengths() { + let aes128 = Aes128::new(&key_material(&KEY_128)).unwrap(); + let aes192 = Aes192::new(&key_material(&KEY_192)).unwrap(); + let aes256 = Aes256::new(&key_material(&KEY_256)).unwrap(); + + for block in [[0u8; 16], [0xFFu8; 16], core::array::from_fn(|i| i as u8)] { + let mut b = block; + aes128.encrypt_block(&mut b); + assert_ne!(b, block, "AES-128 must actually transform the block"); + aes128.decrypt_block(&mut b); + assert_eq!(b, block, "AES-128 round trip with the Appendix A.1 key"); + + let mut b = block; + aes192.encrypt_block(&mut b); + assert_ne!(b, block, "AES-192 must actually transform the block"); + aes192.decrypt_block(&mut b); + assert_eq!(b, block, "AES-192 round trip with the Appendix A.2 key"); + + let mut b = block; + aes256.encrypt_block(&mut b); + assert_ne!(b, block, "AES-256 must actually transform the block"); + aes256.decrypt_block(&mut b); + assert_eq!(b, block, "AES-256 round trip with the Appendix A.3 key"); + } +} + +/// The three key lengths must give different results for the same input. +/// +/// Guards against a parameter set silently using another set's `Nr` or `Nk`. +#[test] +fn the_three_key_lengths_are_distinct_permutations() { + // A key whose first 16 bytes are shared, so only Nk/Nr and the extra key bytes differ. + let shared = [0x11u8; 32]; + let aes128 = Aes128::new(&key_material::<16>(&shared[..16].try_into().unwrap())).unwrap(); + let aes192 = Aes192::new(&key_material::<24>(&shared[..24].try_into().unwrap())).unwrap(); + let aes256 = Aes256::new(&key_material(&shared)).unwrap(); + + let block = [0x42u8; 16]; + let mut b128 = block; + let mut b192 = block; + let mut b256 = block; + aes128.encrypt_block(&mut b128); + aes192.encrypt_block(&mut b192); + aes256.encrypt_block(&mut b256); + + assert_ne!(b128, b192); + assert_ne!(b192, b256); + assert_ne!(b128, b256); +} + +// ---- key handling ----------------------------------------------------------------------- + +#[test] +fn a_key_of_the_wrong_type_is_rejected() { + // KeyType::Seed is not a cipher key: a seed reused directly as an AES key is a real mistake + // and the type system tracks enough to catch it. + let key = KeyMaterial::<16>::from_bytes_as_type(&[0x01; 16], KeyType::Seed).unwrap(); + assert!(Aes128::new(&key).is_err()); + + let key = KeyMaterial::<16>::from_bytes_as_type(&[0x01; 16], KeyType::MACKey).unwrap(); + assert!(Aes128::new(&key).is_err()); +} + +#[test] +fn a_key_of_the_wrong_length_is_rejected() { + // The capacity is right but only part of it is populated, so `key_len()` disagrees with the + // parameter set. This is the one length error the const generic cannot catch by itself. + let key = + KeyMaterial::<32>::from_bytes_as_type(&[0x01; 16], KeyType::SymmetricCipherKey).unwrap(); + assert!(Aes256::new(&key).is_err()); +} + +#[test] +fn a_key_carrying_too_low_a_security_strength_is_rejected() { + // A full-length key whose material was only ever derived at a lower security strength must + // not be usable at the strength its length implies. `from_bytes_as_type` tags a 32-byte key + // as 256-bit, so lower it deliberately -- lowering does not need a hazardous closure, only + // raising does. + let mut key = + KeyMaterial::<32>::from_bytes_as_type(&[0x01; 32], KeyType::SymmetricCipherKey).unwrap(); + assert_eq!(key.security_strength(), SecurityStrength::_256bit); + + key.set_security_strength(SecurityStrength::_128bit).unwrap(); + assert!( + Aes256::new(&key).is_err(), + "AES-256 must reject a 32-byte key only derived at the 128-bit strength" + ); + + // The same key at its full strength is fine, so the rejection is about the strength tag and + // not about anything else having gone wrong with the key. + let good = + KeyMaterial::<32>::from_bytes_as_type(&[0x01; 32], KeyType::SymmetricCipherKey).unwrap(); + assert!(Aes256::new(&good).is_ok()); +} + +#[test] +fn a_correctly_typed_key_of_each_length_is_accepted() { + assert!(Aes128::new(&key_material(&KEY_128)).is_ok()); + assert!(Aes192::new(&key_material(&KEY_192)).is_ok()); + assert!(Aes256::new(&key_material(&KEY_256)).is_ok()); +} + +#[test] +fn debug_does_not_print_the_key_schedule() { + // The schedule is secret; `Debug` must not be a way to leak it. + let aes = Aes128::new(&key_material(&KEY_128)).unwrap(); + let rendered = format!("{aes:?}"); + assert_eq!(rendered, "AES-128"); + // No byte of the key should appear as hex in the output. + assert!(!rendered.contains("2b")); + assert!(!rendered.contains("7e")); +} diff --git a/crypto/aes-lowmemory/tests/sp800_38a_tests.rs b/crypto/aes-lowmemory/tests/sp800_38a_tests.rs new file mode 100644 index 00000000..8e975eca --- /dev/null +++ b/crypto/aes-lowmemory/tests/sp800_38a_tests.rs @@ -0,0 +1,176 @@ +//! Known-answer tests from NIST SP 800-38A Appendix F.1, "ECB Example Vectors". +//! +//! These are the only NIST-published known-answer vectors for AES-192 and AES-256 that live in a +//! specification document rather than a separate vector file -- FIPS 197 Appendix B only covers +//! AES-128, and FIPS 197 (Update 1) removed the Appendix C example vectors in favour of a pointer +//! to the CSRC website. `acvp_tests.rs` covers far more cases, but only when the `bc-test-data` +//! repository is present, so these vectors are the always-available known-answer floor. +//! +//! ECB applies the raw permutation to each block independently, so an ECB example vector *is* a +//! block-permutation test vector. (That is the only reason ECB appears in this crate; see the +//! crate docs on why you must not use it to encrypt anything.) +//! +//! The keys are the same three keys as FIPS 197 Appendix A.1, A.2 and A.3, so these vectors also +//! pin each key expansion against a NIST-published answer, in both directions. +//! +//! Transcribed from the published SP 800-38A PDF, sections F.1.1 through F.1.6. + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN}; +use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +use bouncycastle_hex as hex; + +/// The four plaintext blocks shared by every F.1 subsection. +const PLAINTEXTS: [&str; 4] = [ + "6bc1bee22e409f96e93d7e117393172a", + "ae2d8a571e03ac9c9eb76fac45af8e51", + "30c81c46a35ce411e5fbc1191a0a52ef", + "f69f2445df4f9b17ad2b417be66c3710", +]; + +/// F.1.1 / F.1.2 key. +const KEY_128: &str = "2b7e151628aed2a6abf7158809cf4f3c"; +/// F.1.1 ECB-AES128.Encrypt output blocks. +const CIPHERTEXTS_128: [&str; 4] = [ + "3ad77bb40d7a3660a89ecaf32466ef97", + "f5d3d58503b9699de785895a96fdbaaf", + "43b1cd7f598ece23881b00e3ed030688", + "7b0c785e27e8ad3f8223207104725dd4", +]; + +/// F.1.3 / F.1.4 key. +const KEY_192: &str = "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b"; +/// F.1.3 ECB-AES192.Encrypt output blocks. +const CIPHERTEXTS_192: [&str; 4] = [ + "bd334f1d6e45f25ff712a214571fa5cc", + "974104846d0ad3ad7734ecb3ecee4eef", + "ef7afd2270e2e60adce0ba2face6444e", + "9a4b41ba738d6c72fb16691603c18e0e", +]; + +/// F.1.5 / F.1.6 key. +const KEY_256: &str = "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"; +/// F.1.5 ECB-AES256.Encrypt output blocks. +const CIPHERTEXTS_256: [&str; 4] = [ + "f3eed1bdb5d2a03c064b5a7e3db181f8", + "591ccb10d410ed26dc5ba74a31362870", + "b6ed21b99ca6f4f9f153e7b1beafed1d", + "23304b7a39f9f3ff067d8d8f9e24ecc7", +]; + +fn block(hex_str: &str) -> [u8; BLOCK_LEN] { + hex::decode(hex_str).expect("valid hex").try_into().expect("16 bytes") +} + +fn key_material(hex_str: &str) -> KeyMaterial { + let bytes = hex::decode(hex_str).expect("valid hex"); + assert_eq!(bytes.len(), N, "key length"); + KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey) + .expect("a valid symmetric cipher key") +} + +// ---- F.1.1 / F.1.2 ECB-AES128 ------------------------------------------------------------- + +#[test] +fn f_1_1_ecb_aes128_encrypt() { + let aes = Aes128::new(&key_material::<16>(KEY_128)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_128.iter()).enumerate() { + let mut b = block(pt); + aes.encrypt_block(&mut b); + assert_eq!(b, block(ct), "F.1.1 block #{}", i + 1); + } +} + +#[test] +fn f_1_2_ecb_aes128_decrypt() { + let aes = Aes128::new(&key_material::<16>(KEY_128)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_128.iter()).enumerate() { + let mut b = block(ct); + aes.decrypt_block(&mut b); + assert_eq!(b, block(pt), "F.1.2 block #{}", i + 1); + } +} + +// ---- F.1.3 / F.1.4 ECB-AES192 ------------------------------------------------------------- + +#[test] +fn f_1_3_ecb_aes192_encrypt() { + let aes = Aes192::new(&key_material::<24>(KEY_192)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_192.iter()).enumerate() { + let mut b = block(pt); + aes.encrypt_block(&mut b); + assert_eq!(b, block(ct), "F.1.3 block #{}", i + 1); + } +} + +#[test] +fn f_1_4_ecb_aes192_decrypt() { + let aes = Aes192::new(&key_material::<24>(KEY_192)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_192.iter()).enumerate() { + let mut b = block(ct); + aes.decrypt_block(&mut b); + assert_eq!(b, block(pt), "F.1.4 block #{}", i + 1); + } +} + +// ---- F.1.5 / F.1.6 ECB-AES256 ------------------------------------------------------------- + +#[test] +fn f_1_5_ecb_aes256_encrypt() { + let aes = Aes256::new(&key_material::<32>(KEY_256)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_256.iter()).enumerate() { + let mut b = block(pt); + aes.encrypt_block(&mut b); + assert_eq!(b, block(ct), "F.1.5 block #{}", i + 1); + } +} + +#[test] +fn f_1_6_ecb_aes256_decrypt() { + let aes = Aes256::new(&key_material::<32>(KEY_256)).unwrap(); + for (i, (pt, ct)) in PLAINTEXTS.iter().zip(CIPHERTEXTS_256.iter()).enumerate() { + let mut b = block(ct); + aes.decrypt_block(&mut b); + assert_eq!(b, block(pt), "F.1.6 block #{}", i + 1); + } +} + +// ---- the two-block path against the same vectors ------------------------------------------- + +/// The two-block entry points must produce exactly the single-block answers. +/// +/// This is the test that pins the interleave: a mistake in which bit of each pair belongs to +/// which block shows up here and nowhere in the single-block tests, because a single-block call +/// puts the same data in both halves. +#[test] +fn two_block_path_matches_the_f_1_vectors() { + let aes = Aes128::new(&key_material::<16>(KEY_128)).unwrap(); + + // Blocks 1 and 2 as a pair, then 3 and 4. + for chunk in 0..2 { + let (i, j) = (chunk * 2, chunk * 2 + 1); + let mut pair = [block(PLAINTEXTS[i]), block(PLAINTEXTS[j])]; + aes.encrypt_blocks2(&mut pair); + assert_eq!(pair[0], block(CIPHERTEXTS_128[i]), "pair {chunk} slot 0"); + assert_eq!(pair[1], block(CIPHERTEXTS_128[j]), "pair {chunk} slot 1"); + + aes.decrypt_blocks2(&mut pair); + assert_eq!(pair[0], block(PLAINTEXTS[i])); + assert_eq!(pair[1], block(PLAINTEXTS[j])); + } +} + +/// Swapping the two slots must swap the two results, and nothing else. +#[test] +fn two_block_path_is_slot_symmetric() { + let aes = Aes256::new(&key_material::<32>(KEY_256)).unwrap(); + + let mut forward = [block(PLAINTEXTS[0]), block(PLAINTEXTS[1])]; + let mut reversed = [block(PLAINTEXTS[1]), block(PLAINTEXTS[0])]; + aes.encrypt_blocks2(&mut forward); + aes.encrypt_blocks2(&mut reversed); + + assert_eq!(forward[0], reversed[1]); + assert_eq!(forward[1], reversed[0]); + assert_eq!(forward[0], block(CIPHERTEXTS_256[0])); + assert_eq!(forward[1], block(CIPHERTEXTS_256[1])); +} From 64ce2dfed96468830b1c505d96f8658e9e06ebb8 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 16:21:13 +0700 Subject: [PATCH 21/28] Added benchmarking for aes-lowmemory (#98) --- crypto/aes-lowmemory/benches/aes_benches.rs | 183 ++++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 crypto/aes-lowmemory/benches/aes_benches.rs diff --git a/crypto/aes-lowmemory/benches/aes_benches.rs b/crypto/aes-lowmemory/benches/aes_benches.rs new file mode 100644 index 00000000..82d81003 --- /dev/null +++ b/crypto/aes-lowmemory/benches/aes_benches.rs @@ -0,0 +1,183 @@ +//! Criterion benchmarks for the bit-sliced AES engine. +//! +//! The comparison that matters here is `encrypt_block` against `encrypt_blocks2` over the same +//! number of bytes. The bit-sliced state holds two blocks, so a single-block call does twice the +//! necessary work; the two-block path should be close to twice the throughput. That ratio is the +//! argument for modes of operation using the two-block entry points wherever their blocks are +//! independent (CTR, and the decrypt direction of CBC and CFB). + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN}; +use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +use bouncycastle_core::traits::RNG; +use bouncycastle_rng as rng; +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; +use std::hint::black_box; + +/// 16 KiB of data, i.e. 1024 AES blocks. +const NUM_BLOCKS: usize = 1024; +const DATA_LEN: usize = NUM_BLOCKS * BLOCK_LEN; + +fn random_blocks() -> Vec<[u8; BLOCK_LEN]> { + let mut blocks = vec![[0u8; BLOCK_LEN]; NUM_BLOCKS]; + let mut generator = rng::DefaultRNG::default(); + for block in blocks.iter_mut() { + generator.next_bytes_out(block).unwrap(); + } + blocks +} + +fn key() -> KeyMaterial { + let mut bytes = [0u8; N]; + rng::DefaultRNG::default().next_bytes_out(&mut bytes).unwrap(); + KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey).unwrap() +} + +fn bench_key_expansion(c: &mut Criterion) { + let mut group = c.benchmark_group("aes_lowmemory::key expansion"); + + let key128 = key::<16>(); + group.bench_function("Aes128::new()", |b| { + b.iter(|| black_box(Aes128::new(black_box(&key128)).unwrap())) + }); + + let key192 = key::<24>(); + group.bench_function("Aes192::new()", |b| { + b.iter(|| black_box(Aes192::new(black_box(&key192)).unwrap())) + }); + + let key256 = key::<32>(); + group.bench_function("Aes256::new()", |b| { + b.iter(|| black_box(Aes256::new(black_box(&key256)).unwrap())) + }); + + group.finish(); +} + +fn bench_aes128(c: &mut Criterion) { + let aes = Aes128::new(&key::<16>()).unwrap(); + let blocks = random_blocks(); + + let mut group = c.benchmark_group("aes_lowmemory::Aes128"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + + group.bench_function("16KiB -- .encrypt_block() x1024", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for block in buf.iter_mut() { + aes.encrypt_block(black_box(block)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .encrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + // `try_into` cannot fail: `chunks_exact_mut(2)` yields slices of length 2. + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.encrypt_blocks2(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .decrypt_block() x1024", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for block in buf.iter_mut() { + aes.decrypt_block(black_box(block)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .decrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.decrypt_blocks2(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.finish(); +} + +fn bench_aes192(c: &mut Criterion) { + let aes = Aes192::new(&key::<24>()).unwrap(); + let blocks = random_blocks(); + + let mut group = c.benchmark_group("aes_lowmemory::Aes192"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + + group.bench_function("16KiB -- .encrypt_block() x1024", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for block in buf.iter_mut() { + aes.encrypt_block(black_box(block)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .encrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.encrypt_blocks2(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.finish(); +} + +fn bench_aes256(c: &mut Criterion) { + let aes = Aes256::new(&key::<32>()).unwrap(); + let blocks = random_blocks(); + + let mut group = c.benchmark_group("aes_lowmemory::Aes256"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + + group.bench_function("16KiB -- .encrypt_block() x1024", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for block in buf.iter_mut() { + aes.encrypt_block(black_box(block)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .encrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.encrypt_blocks2(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.bench_function("16KiB -- .decrypt_blocks2() x512", |b| { + b.iter(|| { + let mut buf = blocks.clone(); + for pair in buf.chunks_exact_mut(2) { + let pair: &mut [[u8; BLOCK_LEN]; 2] = pair.try_into().unwrap(); + aes.decrypt_blocks2(black_box(pair)); + } + black_box(&buf); + }) + }); + + group.finish(); +} + +criterion_group!(benches, bench_key_expansion, bench_aes128, bench_aes192, bench_aes256); +criterion_main!(benches); From 2a6a46d810a64dda15635e08e23a32100fc50f19 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 16:21:39 +0700 Subject: [PATCH 22/28] Added Cargo.toml and summary.md (#98) --- crypto/aes-lowmemory/Cargo.toml | 18 ++ crypto/aes-lowmemory/summary.md | 475 ++++++++++++++++++++++++++++++++ 2 files changed, 493 insertions(+) create mode 100644 crypto/aes-lowmemory/Cargo.toml create mode 100644 crypto/aes-lowmemory/summary.md diff --git a/crypto/aes-lowmemory/Cargo.toml b/crypto/aes-lowmemory/Cargo.toml new file mode 100644 index 00000000..07fdc784 --- /dev/null +++ b/crypto/aes-lowmemory/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "bouncycastle-aes-lowmemory" +version.workspace = true +edition.workspace = true + +[dependencies] +bouncycastle-core.workspace = true +bouncycastle-utils.workspace = true + +[dev-dependencies] +bouncycastle-hex.workspace = true +bouncycastle-rng.workspace = true +criterion.workspace = true +serde_json = "1.0" + +[[bench]] +name = "aes_benches" +harness = false diff --git a/crypto/aes-lowmemory/summary.md b/crypto/aes-lowmemory/summary.md new file mode 100644 index 00000000..4933c652 --- /dev/null +++ b/crypto/aes-lowmemory/summary.md @@ -0,0 +1,475 @@ +# `crypto/aes-lowmemory` โ€” implementation summary + +A constant-time, table-free AES block cipher engine (NIST FIPS 197), added 2026-08-31 on branch +`feature/officialfrancismendoza/98-AES-lowmemory`. + +This document is the reviewer's orientation: what was built, why the design is the way it is, what +was verified and how, and โ€” importantly โ€” the three places where the working plan or model recall +turned out to be wrong. For end-user documentation see the crate docs in +[`src/lib.rs`](src/lib.rs); for the reasoning behind each individual constant, see the module docs +in [`src/bitslice.rs`](src/bitslice.rs) and [`src/round.rs`](src/round.rs), which are the right +place to start reading the source. + +--- + +## 1. What this crate is (and is not) + +It provides the **raw AES keyed permutation** โ€” `Aes128`, `Aes192`, `Aes256` โ€” transforming exactly +16 bytes at a time. It is not something you can encrypt data with: used directly on data it *is* +ECB, which is not confidential. Modes of operation and padding are separate layers. + +Consistent with the earlier scoping decision for the AES engine, the crate deliberately ships: + +* **no CLI subcommand** โ€” a bare permutation can only offer ECB, +* **no factory registration**, +* **no `core` cipher-trait implementations** (`SymmetricCipher` / `BlockCipherEncryptor` / + `BlockCipherDecryptor`) โ€” those traits are about encrypting *data* and generating initialisation + data, which are mode-of-operation concerns, +* **no `AlgorithmOID`** โ€” NIST CSOR assigns AES OIDs per mode, never to the bare cipher. + +It does implement `core::traits::Algorithm` (name and maximum security strength), which is +metadata rather than a data-encryption API. + +--- + +## 2. Design + +### 2.1 Why there is no lookup table + +FIPS 197 Sec 5.1.1 presents the S-box as a 256-entry table (Table 4), and almost every AES +implementation stores it as one โ€” 256 bytes, or 2โ€“8 KiB for the "T-table" variants that fold +MixColumns in. A table indexed by a byte of the state is indexed by **secret data**, so on any CPU +with a data cache the access pattern, and therefore the timing, depends on the key. That is the +standard, repeatedly-demonstrated AES cache-timing attack, and it cannot be fixed while the lookup +remains. + +Bouncy Castle's `AESLightEngine` in the Java and C# ports keeps two 256-byte S-box tables in order +to be *small*, not to be constant-time, and leaks through both the cipher and the key schedule. + +This crate has no tables at all. The consequence worth stating plainly: **the low-memory AES and +the constant-time AES are the same implementation here.** Removing the tables is what makes it both. + +### 2.2 Bit-slicing + +The state is transposed so that each of eight `u32` words holds one *bit position* of every byte: +word `q[k]` collects bit `k` of all the bytes. In that representation the S-box becomes a fixed +Boolean circuit and one `&` or `^` applies a gate to every byte position at once. Nothing is ever +indexed by a secret and nothing branches on one. + +Eight 32-bit words hold 256 bits = 32 bytes = **two** AES blocks, so blocks are processed in pairs. +ShiftRows and MixColumns become masks and rotations in the same representation, and the key +schedule is stored already bit-sliced, so no transposition happens inside the round loop. + +### 2.3 The bit layout โ€” derived, not assumed + +`ortho` transposes, within each byte-lane of the eight words, the 8ร—8 bit matrix indexed by +(word number, bit number within the lane): + +``` +after ortho: q[k] bit (8L + i) == before ortho: q[i] bit (8L + k) +``` + +`pack` loads block A as four little-endian `u32`s into the even words and block B into the odd +words, so before `ortho` byte-lane `L` of word `2c` holds `A[4c + L]`. Substituting `j = 4c + L` +and FIPS 197 Eq (3.6) `s[r,c] = in[r + 4c]` โ€” which makes `r = j mod 4`, `c = j div 4` โ€” gives: + +``` +q[k] bit (8r + 2c) == bit k of s[r,c] of block A +q[k] bit (8r + 2c + 1) == bit k of s[r,c] of block B +``` + +**The byte-lane of the word selects the state row `r`; the bit-pair within that lane selects the +state column `c`; the low bit of the pair is block A and the high bit is block B.** + +``` + c=0 c=1 c=2 c=3 + r=0 | 0 2 4 6 + r=1 | 8 10 12 14 (bit position of block A; + r=2 | 16 18 20 22 add 1 for block B) + r=3 | 24 26 28 30 +``` + +Everything else follows from this table: + +* **ShiftRows** only permutes within rows, and a row is a byte-lane, so it is a rotation *inside* + each byte-lane by `2r` positions (one column = two bit positions). +* **MixColumns** combines the four rows of a column, and `rotate_right(8)` moves one row, so it is + expressible with rotations by 8 and 16 plus the `{1b}` reduction, with no shuffling. + +`test_layout_matches_the_documented_table` pins this exhaustively. Every mask in the crate is only +correct relative to it, which is why it is written down rather than left implicit. + +### 2.4 Both directions from one key schedule + +Decryption follows **FIPS 197 Algorithm 3** (the straight inverse cipher), not the equivalent +inverse cipher of Sec 5.3.5. Algorithm 3 applies InvMixColumns *after* AddRoundKey, so it uses the +**unmodified** key schedule; Sec 5.3.5 reorders the round and needs a separate schedule with +InvMixColumns applied to every round key (Algorithm 5, `KEYEXPANSIONEIC()`). + +Following Algorithm 3 is what lets one `Aes` value encrypt *and* decrypt from a single stored +schedule โ€” no second copy, no transformation at construction time, no direction flag. That is the +whole reason both directions are available at 176โ€“240 bytes of state. + +### 2.5 Typing the three key sizes + +The schedule length `4ยท(Nr+1)` (44/52/60 words) cannot be written as an expression over another +const generic parameter, so a params trait is used instead โ€” the same pattern as the +`HashDRBG80090AParams_*` types in `bouncycastle-rng`: + +```rust +pub trait AesParams: AesParamsSealed { + const KEY_LEN: usize; // 16 | 24 | 32 (FIPS 197 Sec 6.1) + const NK: usize; // 4 | 6 | 8 + const NR: usize; // 10 | 12 | 14 + const ALG_NAME: &'static str; + type Schedule: ZeroizablePrimitive + AsRef<[u32]> + AsMut<[u32]>; +} +``` + +`AesParams` has a **private** supertrait, so only the three types in `schedule.rs` can implement +it and no downstream crate can instantiate the cipher with an unapproved key length or round count. +(This is what `#![allow(private_bounds)]` in `lib.rs` is for.) + +The three `new` constructors and `Algorithm` impls are written out **longhand rather than with +`macro_rules!`**, because `cargo mutants` cannot see into macro bodies and a macro would hide the +key checks and security-strength constants from mutation testing. + +### 2.6 Memory + +No lookup tables, no heap allocation. The only persistent state is the key schedule, stored in a +compressed bit-sliced form: bit-slicing is a permutation of bits so it does not change the size, and +because both interleaved blocks use the same key the two halves of a bit-sliced round key are +identical, so one word of each pair is redundant. `round_key` re-doubles a single round key onto the +stack when the round loop needs it. + +| Type | Key | `Nr` | Schedule (persistent) | Tables | +|---|---|---|---|---| +| `Aes128` | 16 B | 10 | 176 B | 0 B | +| `Aes192` | 24 B | 12 | 208 B | 0 B | +| `Aes256` | 32 B | 14 | 240 B | 0 B | + +These are **measured**, not asserted โ€” `cargo run --release -p mem_usage_benches --bin bench_aes_mem_usage` +prints exactly 176/208/240, and `test_engine_sizes_match_the_documented_memory_table` pins them so +the doc table cannot drift. + +Two things deliberately avoided: storing the doubled 8-plane schedule (352/416/480 B), and +mirroring BearSSL's `uint32_t skey[120]` 480-byte scratch buffer during expansion. `expand` writes +the classical schedule into the final array and then rewrites it in place, one round key at a time, +using eight words of stack. + +Per-call stack usage is independent of key length: 32 B of bit-sliced state for the two blocks, +32 B for the expanded round key, plus circuit temporaries that mostly stay in registers. + +### 2.7 API surface + +```rust +Aes128::new(&KeyMaterial<16>) -> Result // and 24 / 32 +aes.encrypt_block(&mut [u8; 16]) // infallible +aes.decrypt_block(&mut [u8; 16]) +aes.encrypt_blocks2(&mut [[u8; 16]; 2]) // the natural unit of work +aes.decrypt_blocks2(&mut [[u8; 16]; 2]) +``` + +No `init()`, no `reset()`, no direction flag: constructors set up state and a constructed value is +always ready. There are no one-shot statics on the permutation because +`Aes128::new(&key)?.encrypt_block(..)` already *is* the one shot; data-level one-shots belong to the +modes, which take arbitrary-length input and generate their own initialisation data. + +`encrypt_blocks2` / `decrypt_blocks2` are the pair form and roughly double throughput. A +single-block call duplicates the block into both halves and discards one result, so it does twice +the necessary work โ€” modes whose blocks are independent (CTR, and the decrypt direction of CBC and +CFB) should prefer the pair form; CBC *encryption* cannot, since its blocks are serially dependent. + +Duplicating rather than zero-filling the unused half costs the same and buys a free self-check (the +two halves must agree, which `debug_assert` verifies). It is not a security property โ€” the unused +half is never returned either way. + +--- + +## 3. Files + +### New crate + +| File | Lines | Contents | +|---|---|---| +| `Cargo.toml` | 18 | deps: `core`, `utils`; dev-deps: `hex`, `rng`, `criterion`, `serde_json` | +| [`src/lib.rs`](src/lib.rs) | 175 | Crate docs: Usage Examples, Design, Memory Usage, Security Considerations, Provenance | +| [`src/bitslice.rs`](src/bitslice.rs) | 210 | `ortho`, `pack`, `unpack`; the layout table and its exhaustive test | +| [`src/sbox.rs`](src/sbox.rs) | 377 | The 113-gate circuit; `inv_sbox`; Tables 4 and 6 for tests | +| [`src/round.rs`](src/round.rs) | 507 | AddRoundKey, ShiftRows, MixColumns and inverses; byte-wise references | +| [`src/schedule.rs`](src/schedule.rs) | 456 | `AesParams`, `expand` (Alg 2), `round_key`; Appendix A tables | +| [`src/aes.rs`](src/aes.rs) | 276 | `Aes

`, the three aliases, Alg 1 and Alg 3, key validation | +| [`tests/fips197_tests.rs`](tests/fips197_tests.rs) | 230 | Appendix B; two-block path; key handling | +| [`tests/sp800_38a_tests.rs`](tests/sp800_38a_tests.rs) | 176 | SP 800-38A F.1.1โ€“F.1.6 | +| [`tests/acvp_tests.rs`](tests/acvp_tests.rs) | 266 | NIST ACVP `ACVP-AES-ECB` loader | +| [`benches/aes_benches.rs`](benches/aes_benches.rs) | 183 | criterion; key expansion and 16 KiB throughput, 1-block vs 2-block | + +### Changed elsewhere + +* `Cargo.toml` โ€” `bouncycastle-aes-lowmemory` in `workspace.dependencies` and in the umbrella + `[dependencies]`. +* `src/lib.rs` โ€” `pub use bouncycastle_aes_lowmemory as aes_lowmemory;`. +* `mem_usage_benches/bench_aes_mem_usage.rs` (new, 131 lines), plus its `[[bin]]` entry in + `mem_usage_benches/Cargo.toml` and a `mod` line in `mem_usage_benches/lib.rs`. +* `alpha_0.1.3_release_notes.md` โ€” a "Major features" entry. + +--- + +## 4. Verification + +58 tests, all passing. The strategy is that **no expected value anywhere was written from +recall** โ€” every one is transcribed from a downloaded specification PDF or an official vector file. + +| Source | What is checked | +|---|---| +| FIPS 197 Table 4 / Table 6 | **Exhaustive**: all 256 inputs to `sbox` and `inv_sbox`. This is what makes the 113 gates trustworthy, so it must stay exhaustive. | +| FIPS 197 Sec 5.1.1 | The worked example `S[{53}] = {ed}`. | +| FIPS 197 Eq 5.5 / 5.8 / 5.12 / 5.15 | ShiftRows and MixColumns and their inverses, against byte-wise references written from the equations โ€” plus a second literal transcription of Eq 5.8/5.15 cross-checking the matrix form. | +| FIPS 197 Sec 4.2 / Eq 4.5 | The test-only `xtimes`/`gf_mul` helpers against the Sec 4.2 worked chain and `{57}ยท{13} = {fe}`. | +| FIPS 197 Table 5 | `RCON` re-derived by repeated XTIMES and compared. | +| FIPS 197 Appendix A.1/A.2/A.3 | **Every one of the 156 schedule words**, for all three key lengths. | +| FIPS 197 Appendix B | The worked AES-128 block, both directions, and via the two-block path in both slots. | +| SP 800-38A F.1.1โ€“F.1.6 | ECB known answers, all three key lengths, both directions. | +| NIST ACVP `ACVP-AES-ECB` | **2138 cases** (AES-128: 588, AES-192: 720, AES-256: 830), each checked in *both* directions and through both the single-block and two-block paths. | + +### Why Appendix A is tested inside `src/schedule.rs` + +The key schedule is deliberately not public API (a `Secret` field). A round-trip through the cipher +**cannot** validate it: a wrong `w[i]` is used by encryption and decryption alike, so the round trip +still succeeds. The Appendix A tests therefore live in the module, where `round_key` + `ortho` +decompress the stored schedule back to classical words so every `w[i]` can be compared against the +appendix directly. `tests/fips197_tests.rs` says so explicitly, so nobody mistakes its round-trip +test for schedule validation. + +### The ACVP loader + +Vectors come from `bc-test-data` at `crypto/aes_tdes_vectors/AES/ACVP-AES-ECB.4014527.rsp.json`. +If that repository is not checked out the test prints a warning and passes, matching the ML-KEM / +ML-DSA convention โ€” `cargo test` stays green for someone who has only cloned this repo. A +`checked > 1000` assertion guards against a silently-empty run. + +The response file records `key`, `pt` and `ct` for every case regardless of the group's declared +direction, so each is checked both ways; the request file's group metadata is not needed. + +Two details worth knowing: + +* Some AFT cases have multi-block plaintexts, so the loader iterates blocks (ECB). +* The set includes **all-zero keys** (the GFSbox-style groups). `KeyMaterial` tags an all-zero + buffer `Zeroized` and refuses to promote it outside a hazardous closure โ€” which is the right + default, and `Aes128::new` rejecting it is itself tested. The *test* opts in via + `do_hazardous_operations`; the engine's guard was **not** weakened to accommodate NIST. + +### Constant-time hygiene audit + +Mechanically checked, not merely claimed: + +* **Every** indexing expression in non-test code is a literal constant (`q[0]`โ€ฆ`q[7]`), a loop + counter over a fixed public range, or `4*round + j` where `round` counts over the public `Nr`. + Not one index is derived from key or state bytes. +* The only branches in non-test code are on `i % Nk` and `Nk > 6` (public parameters) in the key + expansion, and on key *metadata* (type, length, security strength) once at construction. None on + key or state bytes. +* `SUBWORD()` in the key expansion goes through the same bit-sliced circuit as `SUBBYTES()`. A + table-driven "light" AES that removes the tables only from the cipher still leaks through the + schedule; this one does not. + +Caveats are stated in the crate docs rather than glossed: the compiler is not contractually obliged +to preserve straight-line codegen; the 32-byte working state is not scrubbed after a block (only the +schedule is `Secret`); and constant-time execution says nothing about power or EM side channels. + +### Gates + +* `cargo fmt --all -- --check` โ€” clean. +* `cargo build --workspace`, `cargo test --workspace` โ€” clean, no failures. +* `cargo doc -p bouncycastle-aes-lowmemory --no-deps` โ€” **zero warnings**. +* `cargo clippy -p bouncycastle-aes-lowmemory --all-targets` โ€” **zero warnings** for this crate. +* `./dev_scripts/quality_stats.sh ./crypto/aes-lowmemory` โ€” `Err()` in core code: **3**, exactly the + three key rejections in `validate`. `unwrap()` in core code: 4, each a + `try_into()` on a fixed-size window of a fixed-size array with a preceding justification comment. + (Note: `cloc` and `bc` are not installed locally, so the line-count and ratio fields print 0.) + +### Mutation testing + +`cargo mutants -p bouncycastle-aes-lowmemory` โ€” complete run, 32 minutes: + +``` +791 mutants tested: 762 caught, 19 missed, 10 unviable, 0 timeouts +``` + +Every one of the 19 misses was investigated. **18 are provable XOR/OR equivalences and no test can +kill them; 1 was a real coverage gap, since fixed.** + +#### The 18 equivalences + +| Count | Site | Mutation | +|---|---|---| +| 6 | `round.rs` `shift_rows` | `\|` โ†’ `^` | +| 6 | `round.rs` `inv_shift_rows` | `\|` โ†’ `^` | +| 2 | `bitslice.rs` `ortho::swap` | `\|` โ†’ `^` | +| 2 | `schedule.rs` `round_key` | `\|` โ†’ `^` | +| 1 | `schedule.rs` `expand` | `\|` โ†’ `^` | +| 1 | `sbox.rs` `sbox` (the `t37` gate) | `^` โ†’ `\|` | + +`a | b` and `a ^ b` differ only where both operands have a set bit, so wherever the operands are +provably disjoint the two are the same function and no test can distinguish them. This is the +"XOR/OR equivalences in crypto code are acceptable" category named in `CLAUDE.md`. Each site is +disjoint for a different reason: + +* **`shift_rows` / `inv_shift_rows`** โ€” the seven masked terms have pairwise-disjoint destination + bit ranges that together cover all 32 bits. +* **`ortho::swap`** โ€” the masks are complementary and the shift equals the field width. +* **`expand`** โ€” the compression combines `& 0x5555_5555` with `& 0xAAAA_AAAA`, complementary masks. +* **`round_key`** โ€” `even` occupies only even bit positions and `even << 1` only odd ones (and + conversely for `odd`). +* **`sbox`, the `t37 = t36 ^ t34` gate** โ€” the interesting one, because it is a gate *inside* the + circuit rather than a mask combination, and because a surviving mutant there would suggest the + exhaustive Table 4 test had a hole. It does not: brute-forcing all 256 inputs shows `t36` and + `t34` are **never both 1**, so XOR and OR agree, and the mutant changes the output for 0 of 256 + inputs. Sweeping the same mutation across every XOR gate confirms `t37` is the **only one of the + 77** with that property โ€” every other `^ โ†’ |` mutant in the circuit is killed. So the exhaustive + test is exactly as strong as claimed; this gate just happens to have disjoint operands. + +Rather than leave the `shift_rows` case as an assertion, the underlying invariant is now tested: +`test_shift_rows_is_a_bit_permutation` pushes a single set bit through and requires exactly one bit +out, with the induced map a bijection on all 32 positions โ€” precisely the disjointness and coverage +property, and it *would* fail if a mask ever overlapped or failed to cover. Every one of the six +sites also carries an in-code comment explaining why its mutant survives, so the next reader does +not have to repeat this investigation. + +#### The one real gap, fixed + +**`< โ†’ >` in `Aes

::validate`.** There was no test for a key whose security strength is *below* +the level its length implies; because `from_bytes_as_type` always tags a key at its length-implied +strength, neither `<` nor `>` was ever true and the two comparisons behaved identically. +`a_key_carrying_too_low_a_security_strength_is_rejected` now covers it (a 32-byte key lowered to +128-bit must be rejected by `Aes256::new`), and the fix was confirmed by hand-applying the mutation +and watching that test fail, then reverting. + +This mutant still appears in the run output above, which analysed the pre-fix source โ€” the fix +landed while the run was in flight. Re-running `cargo mutants` should therefore report **18 missed, +763 caught**, all 18 being the documented equivalences. + +#### Unviable + +The 10 unviable mutants are all `replace with Err(...)` / `with ()` on functions whose return +type does not admit the substituted value (`validate`, `Debug::fmt`, `encrypt2`). `cargo mutants` +counts these as unviable rather than missed; they are a property of the config's `error_values` +list, not a coverage gap. + +--- + +## 5. Three corrections worth flagging to reviewers + +### 5.1 The working plan's bit-layout claim is wrong + +`bc-rust-aes-lowmemory-plan.md` ยง2 states the layout is "`q[k]` bit `2ยทj` is bit k of byte j of +block A". That is **false**. The correct layout, derived in ยง2.3 above and pinned exhaustively, is +`q[k]` bit `(8r + 2c)`. Anyone checking the ShiftRows or MixColumns constants against the plan's +version will conclude, wrongly, that they are all broken. The plan's own instruction โ€” "Any place +BearSSL's constants and your FIPS 197 derivation disagree: the spec wins; re-derive, then look for +the misunderstanding (it will be in the layout table)" โ€” turned out to point at the plan itself. + +### 5.2 FIPS 197 Eq 5.6 is `[{02},{01},{01},{03}]` + +Not `[{02},{03},{01},{01}]`, which is the first *row* of the Eq 5.7 matrix rather than the defining +word of Sec 4.3. Sec 4.3 Eq (4.8) defines matrix entry `(r,k)` as `a[(r-k) mod 4]`, and both +MixColumns and InvMixColumns use that same convention โ€” Eq 5.13's `[{0e},{09},{0d},{0b}]` is +correct as printed. + +This one was written into a test constant from memory and caught by the failing test. It is worth +recording because of *how* it fails: supplying the matrix row instead of the defining word silently +transposes the matrix, which leaves the InvMixColumns test **passing**, so only the forward test +detects it. A literal transcription of Eq 5.8 and Eq 5.15 was added as a second, independent +reference (`test_the_two_reference_forms_agree`) so the convention is pinned from both directions, +and `MIX_COEFFS` carries a comment about the trap. + +### 5.3 The plan's "PR B" is unnecessary + +The plan calls for downloading CAVP AESAVS `.rsp` files and opening a PR against `bcgit/bc-test-data` +to add them. `bc-test-data` **already** ships NIST ACVP AES vectors at +`crypto/aes_tdes_vectors/AES/ACVP-AES-ECB.4014527.{req,rsp}.json` โ€” 2138 AFT cases across all three +key lengths, more coverage than the AESAVS KAT/MMT files would have provided. No PR to +`bc-test-data` is needed. `serde_json` as a dev-dependency is the established way to read these +files (see the ML-KEM and ML-DSA suites). + +--- + +## 6. Scope deliberately not implemented + +| Item | Why | +|---|---| +| `BlockPermutation` trait impls, and `encrypt_blocks2`/`decrypt_blocks2` as trait methods | The trait does not exist in `crypto/core`, which has the mode-level `BlockCipher` / `BlockCipherEncryptor` / `BlockCipherDecryptor`. Introducing it is the plan's separate "PR A". The two-block entry points are inherent methods for now; promoting them to provided trait methods is a one-line delegation once the trait lands. | +| `core-test-framework` conformance test | Follows from the above โ€” there is no test suite for a raw permutation yet. | +| ACVP MCT (Monte Carlo) groups โ€” 6 cases | Their expected `resultsArray` comes from a chained key/plaintext update rule defined in the ACVP AES specification, not in FIPS 197. Implementing it from anything other than that specification would be guesswork. The test reports the skip count so the gap is visible rather than silent. | +| CLI subcommand | A bare permutation only does ECB. `aes128-cbc-*` / `-cfb-*` belong with the modes crate. | +| Factory registration | No `BlockCipherFactory` exists; not adding one here. | +| bc-java `AESLightEngine` cross-check | The plan marks it developer-local rather than committed, and 2138 ACVP vectors plus the spec appendices make it redundant. | + +--- + +## 7. Provenance and attribution + +* **Normative reference: NIST FIPS 197** (including Update 1). Every transformation cites its + section, algorithm and equation numbers, verified against a freshly downloaded copy of the PDF. +* **The S-box circuit** is the 113-gate straight-line program `SLP_AES_113.txt` from Peralta's + circuit collection โ€” 32 AND, 77 XOR, 4 XNOR โ€” described in J. Boyar and R. Peralta, "A new + combinational logic minimization technique with applications to cryptology", + . The gate list was transcribed **mechanically** from the + SLP file (`+` โ†’ `^`, `x` โ†’ `&`, `#` โ†’ `!(..^..)`, names unchanged apart from case) and the result + diffed against the generator output to rule out transcription error. It is not meaningful line by + line and should not be "tidied"; it is verified as a whole by the exhaustive Table 4 test. +* **The bit-sliced two-block structure**, the transpose, and the ShiftRows/MixColumns mask and + rotation constants are translated from BearSSL's `aes_ct` implementation by Thomas Pornin + (`src/symcipher/aes_ct.c`, `aes_ct_enc.c`, `aes_ct_dec.c`, `aes_ct_cbcdec.c`), **MIT licensed**. + Each constant is re-derived from the documented layout in the comments and pinned by a test + against a byte-wise reference written from the FIPS 197 equations. + +Two notes on where the sources disagree, both resolved in favour of the SLP file: + +* Its bottom linear transformation (`tc1..tc26`) **differs from** BearSSL's (`t46..t67`), and its + `t17`/`t21` are re-associated relative to BearSSL's. Both compute the same S-box. +* The SLP numbers inputs and outputs with `U0`/`S0` as the **most significant** bit, so `U0` is + plane `q[7]`. Reversing this produces a wrong S-box, not a subtly different one; the exhaustive + Table 4 test is what pins it. + +**Open question for maintainers:** how attribution for the BearSSL translation and the +Boyarโ€“Peralta circuit should be recorded โ€” file headers only (current state), a top-level `NOTICE` +file, or both. This is a licensing/policy call rather than a technical one. + +--- + +## 8. Reproducing the checks + +```sh +cargo build -p bouncycastle-aes-lowmemory +cargo test -p bouncycastle-aes-lowmemory # 58 tests +cargo test -p bouncycastle-aes-lowmemory --test acvp_tests -- --nocapture # prints the ACVP count +cargo doc -p bouncycastle-aes-lowmemory --no-deps # expect zero warnings +cargo clippy -p bouncycastle-aes-lowmemory --all-targets +cargo fmt --all -- --check +cargo bench -p bouncycastle-aes-lowmemory +cargo mutants -p bouncycastle-aes-lowmemory +./dev_scripts/quality_stats.sh ./crypto/aes-lowmemory + +# struct sizes; add the massif recipe in the file header for stack measurement +cargo run --release -p mem_usage_benches --bin bench_aes_mem_usage +``` + +The ACVP tests additionally need `bc-test-data` cloned as a sibling of this repository; without it +they print a warning and pass. + +--- + +## 9. Open items before merge + +1. **Decide the attribution form** for the BearSSL translation and the Boyarโ€“Peralta circuit (ยง7): + file headers only (current state), a top-level `NOTICE`, or both. A licensing/policy call rather + than a technical one. +2. **Confirm the PR base branch.** The plan specifies `release/0.1.3alpha`, set explicitly โ€” GitHub + defaults to `main`. +3. Decide whether `BlockPermutation` (plan PR A) lands before or after this crate, since it + determines whether the two-block entry points become trait methods now or later (ยง6). +4. Note in the PR description that the plan's layout claim (ยง5.1) and PR B (ยง5.3) are superseded, so + the plan document does not mislead the next reader. +5. Optionally re-run `cargo mutants` to confirm the expected 18 missed / 763 caught (ยง4). The 19th + miss was fixed while the recorded run was in flight, so the numbers above under-report by one. From 961e13feecc4ae6bd6adee89cbeb0acd140e26c9 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 18:07:26 +0700 Subject: [PATCH 23/28] Added updated release notes, .toml, mem_usage_benches, and other misc files --- .claude/settings.json | 32 ++++++ Cargo.toml | 2 + alpha_0.1.3_release_notes.md | 25 +++++ mem_usage_benches/Cargo.toml | 4 + mem_usage_benches/bench_aes_mem_usage.rs | 131 +++++++++++++++++++++++ mem_usage_benches/lib.rs | 1 + src/lib.rs | 1 + 7 files changed, 196 insertions(+) create mode 100644 .claude/settings.json create mode 100644 mem_usage_benches/bench_aes_mem_usage.rs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 00000000..d81f2e1a --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,32 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo install *)", + "Bash(cargo mutants *)", + "Bash(cat custom_mutants_output/mutants.out/timeout.txt)", + "Bash(python -c \"print\\(f'{801/\\(801+48\\)*100:.1f}% of viable mutants caught \\(801/{801+48}\\)'\\)\")", + "Read(//c/Users/fmendoza/Work/bc-test-data/crypto/ascon/**)", + "Bash(awk '/^Count = \\(1|2|5|33|68|69|153\\)$/{p=1} p&&/^\\(Count|Key|Nonce|PT|AD|CT\\) /{print} /^$/{p=0}' asconaead128/LWC_AEAD_KAT_128_128.txt)", + "Bash(awk '/^Count = \\(1|2|9|17|33\\)$/{p=1} p&&/^\\(Count|Msg|MD\\) /{print} /^$/{p=0}' asconhash256/LWC_HASH_KAT_256.txt)", + "Bash(awk '/^Count = \\(1|2|9|17|33\\)$/{p=1} p&&/^\\(Count|Msg|MD\\) /{print} /^$/{p=0}' asconxof128/LWC_XOF_KAT_128_512.txt)", + "Bash(awk '/^Count = \\(1|2|3\\)$/{p=1} p&&/^\\(Count|Msg|Z|MD\\) /{print} /^$/{p=0}' asconcxof128/LWC_CXOF_KAT_128_512.txt)", + "Bash(awk 'BEGIN{RS=\"\";FS=\"\\\\n\"} /Msg = [0-9A-F]/ && /Z = [0-9A-F]/ {print; c++} c>=2{exit}' asconcxof128/LWC_CXOF_KAT_128_512.txt)", + "Bash(awk 'BEGIN{RS=\"\";FS=\"\\\\n\"} {pt=\"\"} {for\\(i=1;i<=NF;i++\\) if\\($i ~ /^PT = /\\){pt=substr\\($i,6\\)}} length\\(pt\\)==64 {print; exit}' asconaead128/LWC_AEAD_KAT_128_128.txt)", + "Bash(rm -f tests/test_vector.rs tests/behavior.rs)", + "Bash(rm -rf tests/data)", + "Bash(awk '{p+=$4; f+=$6} END{print \"ascon passed:\",p,\" failed:\",f}')", + "Bash(sed -i -E 's/^\\(\\\\s*x\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*xc\\\\.absorb\\\\\\(piece\\\\\\)\\);/\\\\1.unwrap\\(\\);/' xof128_tests.rs)", + "Bash(sed -i -E 's/^\\(\\\\s*x\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*xc\\\\.absorb\\\\\\(piece\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*c\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/' cxof128_tests.rs)", + "Bash(git checkout *)", + "Bash(rm -f crypto/core-test-framework/src/aead.rs)", + "Bash(sed -n '/\\\\[dependencies\\\\]/,/\\\\[dev-dependencies\\\\]/p' crypto/mlkem/Cargo.toml)", + "Bash(sed -n '/\\\\[dependencies\\\\]/,/\\\\[dev-dependencies\\\\]/p' crypto/mldsa/Cargo.toml)", + "Bash(cargo doc *)", + "Bash(cp crypto/aes/src/lib.rs /tmp/lib.rs.bak)", + "Bash(sed -i 's|^pub mod key_schedule;|// pub mod key_schedule;|' crypto/aes/src/lib.rs)" + ], + "additionalDirectories": [ + "\\tmp" + ] + } +} diff --git a/Cargo.toml b/Cargo.toml index 82b379fe..f5b8c7a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ version = "0.1.3" # *** Internal Dependencies *** bouncycastle = { path = "./" } +bouncycastle-aes-lowmemory = { path = "./crypto/aes-lowmemory" } bouncycastle-base64 = { path = "./crypto/base64" } bouncycastle-core = { path = "crypto/core" } bouncycastle-core-test-framework = { path = "./crypto/core-test-framework" } @@ -41,6 +42,7 @@ version.workspace = true edition.workspace = true [dependencies] +bouncycastle-aes-lowmemory.workspace = true bouncycastle-base64.workspace = true bouncycastle-core.workspace = true bouncycastle-factory.workspace = true diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index da7af240..d2576ba7 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -2,6 +2,31 @@ ## Major features +New crate `bouncycastle-aes-lowmemory` (`bouncycastle::aes_lowmemory`): AES-128/192/256 as a raw keyed block +permutation (NIST FIPS 197), re-exported from the umbrella crate. + +* **Constant-time and table-free.** The S-box is evaluated as a Boolean circuit -- the 113-gate Boyar-Peralta + straight-line program, 32 AND / 77 XOR / 4 XNOR -- over eight `u32` bit-planes, so there is no secret-indexed + memory access and no secret-dependent branch anywhere, including in the key schedule. A table-driven "light" + AES that removes the tables only from the cipher still leaks through `SUBWORD()` in the expansion. +* **Low memory.** No lookup tables at all (0 bytes, against 512 bytes for BC Java's `AESLightEngine` and 2-8 KiB + for T-table engines) and no heap allocation. The only persistent state is the key schedule, stored bit-sliced + in a compressed form that is exactly the FIPS 197 Sec 5.2 size: `Aes128` 176 B, `Aes192` 208 B, `Aes256` 240 B. +* **Both directions from one value.** Decryption follows FIPS 197 Algorithm 3 (the straight inverse cipher) rather + than the equivalent inverse cipher of Sec 5.3.5, so it uses the unmodified key schedule -- one stored schedule + encrypts and decrypts, with no second copy and no transformation at construction time. +* **Two-block entry points.** The bit-sliced state holds two blocks, so `encrypt_blocks2` / `decrypt_blocks2` are + the natural unit of work and roughly double single-block throughput. `encrypt_block` / `decrypt_block` are + provided but do twice the necessary work; modes whose blocks are independent (CTR, and CBC/CFB decryption) + should prefer the pair form. +* Verified against FIPS 197 Appendix A.1/A.2/A.3 (every schedule word), FIPS 197 Appendix B, an exhaustive check + of all 256 S-box and inverse S-box inputs against Tables 4 and 6, SP 800-38A Appendix F.1 (ECB, all three key + lengths, both directions), and 2138 NIST ACVP `ACVP-AES-ECB` cases from `bc-test-data` (skipped with a warning + if that repository is not checked out). +* Deliberately ships no CLI subcommand, no factory entry and no `core` cipher-trait impls: a raw permutation can + only offer ECB, and those are mode-of-operation concerns. `Algorithm` is implemented (name and security + strength); per-mode OIDs and the `BlockCipherEncryptor` / `BlockCipherDecryptor` impls belong to the mode crates. + ## Minor features / bug fixes Block cipher traits (PR #96): diff --git a/mem_usage_benches/Cargo.toml b/mem_usage_benches/Cargo.toml index 53c35f9a..3dd00f7b 100644 --- a/mem_usage_benches/Cargo.toml +++ b/mem_usage_benches/Cargo.toml @@ -14,3 +14,7 @@ path = "bench_mldsa_mem_usage.rs" [[bin]] name = "bench_mlkem_mem_usage" path = "bench_mlkem_mem_usage.rs" + +[[bin]] +name = "bench_aes_mem_usage" +path = "bench_aes_mem_usage.rs" diff --git a/mem_usage_benches/bench_aes_mem_usage.rs b/mem_usage_benches/bench_aes_mem_usage.rs new file mode 100644 index 00000000..00d0acd3 --- /dev/null +++ b/mem_usage_benches/bench_aes_mem_usage.rs @@ -0,0 +1,131 @@ +//! The purpose of this binary is to perform a single run of the primitive under test so that +//! its peak memory usage can be measured with: +//! +//! valgrind --tool=massif --heap=no --stacks=yes -- target/release/bench_aes_mem_usage > /dev/null +//! +//! ms_print massif.out.835000 +//! +//! or, shoved all into one line: +//! +//! clear; clear; valgrind --tool=massif --heap=no --stacks=yes -- target/release/bench_aes_mem_usage > /dev/null; ms_print massif.out.*; rm massif.out.* +//! +//! Make sure you build in release mode! +//! +//! Note: print!() is used to force the compiler not to optimize away the actual code. +//! The important stuff for benchmarking goes to stderr so the junk can be piped to /dev/null. +//! +//! Main is at the bottom, and controls which of these actually runs -- measure one at a time, +//! because massif reports the peak across the whole process. +//! +//! # What to expect +//! +//! Unlike ML-KEM and ML-DSA, AES has no interesting stack profile: there is no polynomial +//! arithmetic and no sampling, so peak usage is a small constant plus the key schedule. The +//! numbers worth recording in the crate docs are the ones `print_struct_sizes` prints -- the +//! persistent size of each engine -- and the confirmation that per-block work is a fixed, small +//! amount of stack independent of key length. +//! +//! The point of comparison is that a table-driven AES adds 256 B (`AESLightEngine`) to 8 KiB +//! (T-tables) of static data on top of these numbers; this implementation adds zero. + +#![allow(dead_code)] +#![allow(unused_imports)] + +use bouncycastle::aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle::core::key_material::{KeyMaterial, KeyType}; + +/// This exists so /usr/bin/time can measure the base memory footprint of the harness itself. +fn bench_do_nothing() { + eprintln!("DoNothing"); + + print!("{}", 1 + 1); +} + +/// Prints the in-memory size of each engine, i.e. the persistent cost of holding a key schedule. +fn print_struct_sizes() { + use core::mem::size_of; + + // FIPS 197 Sec 5.2: the schedule is 4 * (Nr + 1) words, so 176 / 208 / 240 bytes. The + // bit-sliced form is stored compressed, so bit-slicing adds nothing to these. + println!("size_of: {}", size_of::()); + println!("size_of: {}", size_of::()); + println!("size_of: {}", size_of::()); +} + +fn key() -> KeyMaterial { + // A fixed non-zero key: an all-zero buffer would be tagged KeyType::Zeroized and rejected. + let mut bytes = [0u8; N]; + for (i, b) in bytes.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(7).wrapping_add(1); + } + KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey).unwrap() +} + +fn bench_aes128_key_expansion() { + eprintln!("Aes128::new (key expansion)"); + + let aes = Aes128::new(&key::<16>()).unwrap(); + print!("{aes:?}"); +} + +fn bench_aes192_key_expansion() { + eprintln!("Aes192::new (key expansion)"); + + let aes = Aes192::new(&key::<24>()).unwrap(); + print!("{aes:?}"); +} + +fn bench_aes256_key_expansion() { + eprintln!("Aes256::new (key expansion)"); + + let aes = Aes256::new(&key::<32>()).unwrap(); + print!("{aes:?}"); +} + +fn bench_aes128_encrypt_block() { + eprintln!("Aes128::encrypt_block"); + + let aes = Aes128::new(&key::<16>()).unwrap(); + let mut block = [0x11u8; 16]; + aes.encrypt_block(&mut block); + print!("{block:x?}"); +} + +fn bench_aes256_encrypt_block() { + eprintln!("Aes256::encrypt_block"); + + let aes = Aes256::new(&key::<32>()).unwrap(); + let mut block = [0x11u8; 16]; + aes.encrypt_block(&mut block); + print!("{block:x?}"); +} + +fn bench_aes256_decrypt_block() { + eprintln!("Aes256::decrypt_block"); + + let aes = Aes256::new(&key::<32>()).unwrap(); + let mut block = [0x11u8; 16]; + aes.decrypt_block(&mut block); + print!("{block:x?}"); +} + +fn bench_aes256_encrypt_blocks2() { + eprintln!("Aes256::encrypt_blocks2"); + + let aes = Aes256::new(&key::<32>()).unwrap(); + let mut blocks = [[0x11u8; 16], [0x22u8; 16]]; + aes.encrypt_blocks2(&mut blocks); + print!("{blocks:x?}"); +} + +fn main() { + print_struct_sizes() + // bench_do_nothing() + // bench_aes128_key_expansion() + // bench_aes192_key_expansion() + // bench_aes256_key_expansion() + // bench_aes128_encrypt_block() + // bench_aes256_encrypt_block() + // bench_aes256_decrypt_block() + // bench_aes256_encrypt_blocks2() +} diff --git a/mem_usage_benches/lib.rs b/mem_usage_benches/lib.rs index d296f2c1..487d7ee5 100644 --- a/mem_usage_benches/lib.rs +++ b/mem_usage_benches/lib.rs @@ -1,2 +1,3 @@ +mod bench_aes_mem_usage; mod bench_mldsa_mem_usage; mod bench_mlkem_mem_usage; \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index b46df8cd..ca2fb145 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub use bouncycastle_aes_lowmemory as aes_lowmemory; pub use bouncycastle_base64 as base64; pub use bouncycastle_core as core; pub use bouncycastle_factory as factory; From f119c2a579d6c718adc64336eb8c7a3895211923 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Mon, 31 Aug 2026 18:23:32 +0700 Subject: [PATCH 24/28] Updated .gitignore --- .claude/settings.json | 32 -------------------------------- .gitignore | 10 ++++++++++ 2 files changed, 10 insertions(+), 32 deletions(-) delete mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index d81f2e1a..00000000 --- a/.claude/settings.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(cargo install *)", - "Bash(cargo mutants *)", - "Bash(cat custom_mutants_output/mutants.out/timeout.txt)", - "Bash(python -c \"print\\(f'{801/\\(801+48\\)*100:.1f}% of viable mutants caught \\(801/{801+48}\\)'\\)\")", - "Read(//c/Users/fmendoza/Work/bc-test-data/crypto/ascon/**)", - "Bash(awk '/^Count = \\(1|2|5|33|68|69|153\\)$/{p=1} p&&/^\\(Count|Key|Nonce|PT|AD|CT\\) /{print} /^$/{p=0}' asconaead128/LWC_AEAD_KAT_128_128.txt)", - "Bash(awk '/^Count = \\(1|2|9|17|33\\)$/{p=1} p&&/^\\(Count|Msg|MD\\) /{print} /^$/{p=0}' asconhash256/LWC_HASH_KAT_256.txt)", - "Bash(awk '/^Count = \\(1|2|9|17|33\\)$/{p=1} p&&/^\\(Count|Msg|MD\\) /{print} /^$/{p=0}' asconxof128/LWC_XOF_KAT_128_512.txt)", - "Bash(awk '/^Count = \\(1|2|3\\)$/{p=1} p&&/^\\(Count|Msg|Z|MD\\) /{print} /^$/{p=0}' asconcxof128/LWC_CXOF_KAT_128_512.txt)", - "Bash(awk 'BEGIN{RS=\"\";FS=\"\\\\n\"} /Msg = [0-9A-F]/ && /Z = [0-9A-F]/ {print; c++} c>=2{exit}' asconcxof128/LWC_CXOF_KAT_128_512.txt)", - "Bash(awk 'BEGIN{RS=\"\";FS=\"\\\\n\"} {pt=\"\"} {for\\(i=1;i<=NF;i++\\) if\\($i ~ /^PT = /\\){pt=substr\\($i,6\\)}} length\\(pt\\)==64 {print; exit}' asconaead128/LWC_AEAD_KAT_128_128.txt)", - "Bash(rm -f tests/test_vector.rs tests/behavior.rs)", - "Bash(rm -rf tests/data)", - "Bash(awk '{p+=$4; f+=$6} END{print \"ascon passed:\",p,\" failed:\",f}')", - "Bash(sed -i -E 's/^\\(\\\\s*x\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*xc\\\\.absorb\\\\\\(piece\\\\\\)\\);/\\\\1.unwrap\\(\\);/' xof128_tests.rs)", - "Bash(sed -i -E 's/^\\(\\\\s*x\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*xc\\\\.absorb\\\\\\(piece\\\\\\)\\);/\\\\1.unwrap\\(\\);/; s/^\\(\\\\s*c\\\\.absorb\\\\\\(&msg\\\\\\)\\);/\\\\1.unwrap\\(\\);/' cxof128_tests.rs)", - "Bash(git checkout *)", - "Bash(rm -f crypto/core-test-framework/src/aead.rs)", - "Bash(sed -n '/\\\\[dependencies\\\\]/,/\\\\[dev-dependencies\\\\]/p' crypto/mlkem/Cargo.toml)", - "Bash(sed -n '/\\\\[dependencies\\\\]/,/\\\\[dev-dependencies\\\\]/p' crypto/mldsa/Cargo.toml)", - "Bash(cargo doc *)", - "Bash(cp crypto/aes/src/lib.rs /tmp/lib.rs.bak)", - "Bash(sed -i 's|^pub mod key_schedule;|// pub mod key_schedule;|' crypto/aes/src/lib.rs)" - ], - "additionalDirectories": [ - "\\tmp" - ] - } -} diff --git a/.gitignore b/.gitignore index 6d42084d..fe17c9f5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,13 @@ mutants.out*/ .idea/ .vscode/ + +# Claude Code: ignore personal/local state, but share team tooling +# (skills, slash commands, subagents, and project settings.json). +.claude/* +!.claude/settings.json +!.claude/skills/ +!.claude/commands/ +!.claude/agents/ +.claude/settings.local.json +.claude 2/ \ No newline at end of file From ff9fa67c1f4b216a27722c880e591c10290bd3ec Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Tue, 1 Sep 2026 10:14:25 +0700 Subject: [PATCH 25/28] Initial add for AES-lightengine CBC mode (#100) --- Cargo.toml | 2 + alpha_0.1.3_release_notes.md | 49 +++ crypto/aes-lowmemory/Cargo.toml | 1 + crypto/aes-lowmemory/src/aes.rs | 85 ++++- .../tests/block_permutation_tests.rs | 25 ++ .../src/block_permutation.rs | 166 ++++++++++ crypto/core-test-framework/src/lib.rs | 1 + .../src/symmetric_ciphers.rs | 14 +- crypto/core-test-framework/summary.md | 189 +++++++++++ crypto/core/src/traits.rs | 59 ++++ crypto/modes/Cargo.toml | 19 ++ crypto/modes/benches/modes_benches.rs | 245 ++++++++++++++ crypto/modes/src/cbc.rs | 232 ++++++++++++++ crypto/modes/src/iv.rs | 26 ++ crypto/modes/src/lib.rs | 187 +++++++++++ crypto/modes/tests/cbc_tests.rs | 298 ++++++++++++++++++ crypto/modes/tests/common/mod.rs | 121 +++++++ crypto/modes/tests/sp800_38a_tests.rs | 261 +++++++++++++++ src/lib.rs | 1 + 19 files changed, 1977 insertions(+), 4 deletions(-) create mode 100644 crypto/aes-lowmemory/tests/block_permutation_tests.rs create mode 100644 crypto/core-test-framework/src/block_permutation.rs create mode 100644 crypto/core-test-framework/summary.md create mode 100644 crypto/modes/Cargo.toml create mode 100644 crypto/modes/benches/modes_benches.rs create mode 100644 crypto/modes/src/cbc.rs create mode 100644 crypto/modes/src/iv.rs create mode 100644 crypto/modes/src/lib.rs create mode 100644 crypto/modes/tests/cbc_tests.rs create mode 100644 crypto/modes/tests/common/mod.rs create mode 100644 crypto/modes/tests/sp800_38a_tests.rs diff --git a/Cargo.toml b/Cargo.toml index f5b8c7a4..1dac7ac6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,7 @@ version = "0.1.3" bouncycastle = { path = "./" } bouncycastle-aes-lowmemory = { path = "./crypto/aes-lowmemory" } bouncycastle-base64 = { path = "./crypto/base64" } +bouncycastle-modes = { path = "./crypto/modes" } bouncycastle-core = { path = "crypto/core" } bouncycastle-core-test-framework = { path = "./crypto/core-test-framework" } bouncycastle-factory = { path = "./crypto/factory" } @@ -53,6 +54,7 @@ bouncycastle-mldsa.workspace = true bouncycastle-mldsa-lowmemory.workspace = true bouncycastle-mlkem.workspace = true bouncycastle-mlkem-lowmemory.workspace = true +bouncycastle-modes.workspace = true bouncycastle-rng.workspace = true bouncycastle-sha2.workspace = true bouncycastle-sha3.workspace = true diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index d2576ba7..d8a1aad1 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -27,6 +27,55 @@ permutation (NIST FIPS 197), re-exported from the umbrella crate. only offer ECB, and those are mode-of-operation concerns. `Algorithm` is implemented (name and security strength); per-mode OIDs and the `BlockCipherEncryptor` / `BlockCipherDecryptor` impls belong to the mode crates. +New crate `bouncycastle-modes` (`bouncycastle::modes`): block cipher modes of operation +(NIST SP 800-38A), currently **CBC** (Sec 6.2). Re-exported from the umbrella crate. + +* `Cbc` over any `BlockPermutation`, so the crate depends on no + concrete cipher. The direction is a type parameter: `BlockCipherEncryptor` is implemented only + for `Cbc<_, Encrypting, _, _>` and `BlockCipherDecryptor` only for `Cbc<_, Decrypting, _, _>`, + making a wrong-direction call a compile error rather than a runtime check. +* **The IV is generated, never accepted.** SP 800-38A Sec 5.3 requires the CBC IV to be + *unpredictable*, not merely unique, so `do_encrypt_init` draws one from the library's default + OS-backed DRBG (Appendix C's second recommended method) and returns it; there is no API for + supplying your own. Known-answer tests drive `do_encrypt_init_rng` with a fixed-output test RNG. +* **Parallel decryption.** Sec 6.2 notes CBC decryption's inverse cipher calls can run in + parallel, so `do_decrypt_blocks[_out]` walks the ciphertext in pairs through + `BlockPermutation::decrypt_blocks2`, with a one-block remainder for odd `N`. Measured against an + otherwise identical permutation that does not override the pair methods, this is **1.83x** the + decryption throughput (67.9 vs 37.1 MiB/s, AES-128, 16 KiB, N=8). CBC encryption is serial by + construction and does not use it. +* Strictly block-aligned, as Sec 5.2 requires of CBC. Arbitrary-length data needs a padding layer, + which does not exist in this workspace yet; when it lands, CBC gets it by being wrapped. +* Verified against all six SP 800-38A Appendix F.2 vectors (CBC-AES128/192/256, Encrypt and + Decrypt), each checked in one call, one block at a time, in a `3 + 1` grouping that exercises the + pair remainder, and through the `_out` variant. Appendix D error propagation is tested + exhaustively for the IV (every one of the 128 bit positions flips exactly its own bit of P1) and + for a ciphertext bit error (affects exactly two blocks). +* Ships no CLI subcommand yet, and no CFB -- see the crate docs' "Not yet implemented". + +`core`: new `BlockPermutation` trait (`crypto/core/src/traits.rs`), the raw +keyed permutation -- `CIPH_K` / `CIPH^-1_K` of SP 800-38A Sec 5.1 -- that a mode is built on. +`new`, `encrypt_block`, `decrypt_block`, plus provided `encrypt_blocks2` / `decrypt_blocks2` that +default to two single-block calls and which bit-sliced implementations override. The block methods +are infallible; only `new` can fail, and only on the key. `bouncycastle-aes-lowmemory` implements +it for all three key lengths (and `BlockCipher`, which is metadata only and is +`BlockPermutation`'s supertrait; the data-encryption traits are still deliberately not +implemented there). + +Testing: + +* `core-test-framework` gains `TestFrameworkBlockPermutation`, which pins the trait contract: + both directions are inverses either way round, the permutation is injective, and the pair + methods are indistinguishable from two single-block calls **including their order** -- the check + that makes an override safe. +* Fixed a latent bug in `TestFrameworkBlockCipher`: it unwrapped `set_security_strength` at all + five strengths, which a key shorter than 32 bytes cannot carry, so the framework panicked for + any 16- or 24-byte key. It now skips the strengths the key length cannot hold. The bug was + invisible until now because nothing in the workspace implemented the block cipher traits. The + identical loop in `TestFrameworkSymmetricCipher` and `TestFrameworkAEADCipher` is still unfixed; + both still have no implementors, so it stays latent. (`TestFrameworkStreamCipher` has no + security-strength handling at all and is unaffected.) + ## Minor features / bug fixes Block cipher traits (PR #96): diff --git a/crypto/aes-lowmemory/Cargo.toml b/crypto/aes-lowmemory/Cargo.toml index 07fdc784..93316d45 100644 --- a/crypto/aes-lowmemory/Cargo.toml +++ b/crypto/aes-lowmemory/Cargo.toml @@ -8,6 +8,7 @@ bouncycastle-core.workspace = true bouncycastle-utils.workspace = true [dev-dependencies] +bouncycastle-core-test-framework.workspace = true bouncycastle-hex.workspace = true bouncycastle-rng.workspace = true criterion.workspace = true diff --git a/crypto/aes-lowmemory/src/aes.rs b/crypto/aes-lowmemory/src/aes.rs index b1003cff..1b889ab7 100644 --- a/crypto/aes-lowmemory/src/aes.rs +++ b/crypto/aes-lowmemory/src/aes.rs @@ -6,7 +6,7 @@ use crate::sbox::{inv_sbox, sbox}; use crate::schedule::{Aes128Params, Aes192Params, Aes256Params, AesParams, expand, round_key}; use bouncycastle_core::errors::{KeyMaterialError, SymmetricCipherError}; use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; -use bouncycastle_core::traits::{Algorithm, SecurityStrength}; +use bouncycastle_core::traits::{Algorithm, BlockCipher, BlockPermutation, SecurityStrength}; use bouncycastle_utils::secret::Secret; /// The AES block length in bytes: 16 (FIPS 197 Sec 3.4, `Nb` = 4 words). @@ -221,6 +221,89 @@ impl Algorithm for Aes256 { const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_256bit; } +// `BlockCipher` here is metadata only -- it declares `MAX_SECURITY_STRENGTH` and nothing else, and +// it is the supertrait `BlockPermutation` requires. It is *not* one of the data-encryption traits +// (`SymmetricCipher`, `BlockCipherEncryptor`, `BlockCipherDecryptor`, `AEADCipher`), which this +// crate still deliberately does not implement: those are mode-of-operation concerns. See the crate +// docs. +// +// Both `Algorithm` and `BlockCipher` declare `MAX_SECURITY_STRENGTH`, so a bare +// `Aes128::MAX_SECURITY_STRENGTH` is ambiguous; qualify it as `::...` or +// `::...` at the use site. + +impl BlockCipher for Aes128 { + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl BlockCipher for Aes192 { + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_192bit; +} + +impl BlockCipher for Aes256 { + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_256bit; +} + +// The three `BlockPermutation` impls are one-line delegations to the inherent methods above. They +// are written out longhand rather than generated, for the `cargo mutants` reason given above. +// +// Each overrides `encrypt_blocks2` / `decrypt_blocks2`, because a pair of blocks is exactly what +// the bit-sliced state holds: the pair form costs barely more than one block, where the default +// (two single-block calls) would do four blocks' worth of work. + +impl BlockPermutation<16, BLOCK_LEN> for Aes128 { + fn new(key: &KeyMaterial<16>) -> Result { + Aes128::new(key) + } + fn encrypt_block(&self, block: &mut Block) { + Aes::encrypt_block(self, block) + } + fn decrypt_block(&self, block: &mut Block) { + Aes::decrypt_block(self, block) + } + fn encrypt_blocks2(&self, blocks: &mut [Block; 2]) { + Aes::encrypt_blocks2(self, blocks) + } + fn decrypt_blocks2(&self, blocks: &mut [Block; 2]) { + Aes::decrypt_blocks2(self, blocks) + } +} + +impl BlockPermutation<24, BLOCK_LEN> for Aes192 { + fn new(key: &KeyMaterial<24>) -> Result { + Aes192::new(key) + } + fn encrypt_block(&self, block: &mut Block) { + Aes::encrypt_block(self, block) + } + fn decrypt_block(&self, block: &mut Block) { + Aes::decrypt_block(self, block) + } + fn encrypt_blocks2(&self, blocks: &mut [Block; 2]) { + Aes::encrypt_blocks2(self, blocks) + } + fn decrypt_blocks2(&self, blocks: &mut [Block; 2]) { + Aes::decrypt_blocks2(self, blocks) + } +} + +impl BlockPermutation<32, BLOCK_LEN> for Aes256 { + fn new(key: &KeyMaterial<32>) -> Result { + Aes256::new(key) + } + fn encrypt_block(&self, block: &mut Block) { + Aes::encrypt_block(self, block) + } + fn decrypt_block(&self, block: &mut Block) { + Aes::decrypt_block(self, block) + } + fn encrypt_blocks2(&self, blocks: &mut [Block; 2]) { + Aes::encrypt_blocks2(self, blocks) + } + fn decrypt_blocks2(&self, blocks: &mut [Block; 2]) { + Aes::decrypt_blocks2(self, blocks) + } +} + impl core::fmt::Debug for Aes

{ /// Prints the algorithm name only. The key schedule is secret and is never formatted. fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { diff --git a/crypto/aes-lowmemory/tests/block_permutation_tests.rs b/crypto/aes-lowmemory/tests/block_permutation_tests.rs new file mode 100644 index 00000000..d6119d97 --- /dev/null +++ b/crypto/aes-lowmemory/tests/block_permutation_tests.rs @@ -0,0 +1,25 @@ +//! `BlockPermutation` trait conformance, via the shared test framework. +//! +//! The framework checks the properties every implementor must have -- both directions are +//! inverses, the permutation is injective, the pair methods are indistinguishable from two +//! single-block calls *including their order*, and the key checks behave. That last pair of +//! properties matters here specifically: this crate overrides `encrypt_blocks2` and +//! `decrypt_blocks2`, so the default implementation is not what runs. + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256, BLOCK_LEN}; +use bouncycastle_core_test_framework::block_permutation::TestFrameworkBlockPermutation; + +#[test] +fn aes128_conforms_to_block_permutation() { + TestFrameworkBlockPermutation::new().test::<16, BLOCK_LEN, Aes128>(); +} + +#[test] +fn aes192_conforms_to_block_permutation() { + TestFrameworkBlockPermutation::new().test::<24, BLOCK_LEN, Aes192>(); +} + +#[test] +fn aes256_conforms_to_block_permutation() { + TestFrameworkBlockPermutation::new().test::<32, BLOCK_LEN, Aes256>(); +} diff --git a/crypto/core-test-framework/src/block_permutation.rs b/crypto/core-test-framework/src/block_permutation.rs new file mode 100644 index 00000000..6eed66fe --- /dev/null +++ b/crypto/core-test-framework/src/block_permutation.rs @@ -0,0 +1,166 @@ +//! Shared conformance tests for [`BlockPermutation`] implementors. + +use crate::DUMMY_SEED; +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, +}; +use bouncycastle_core::traits::{BlockCipher, BlockPermutation, SecurityStrength}; + +/// Instance of the test framework. +pub struct TestFrameworkBlockPermutation { + // Put any config options here +} + +impl Default for TestFrameworkBlockPermutation { + fn default() -> Self { + Self::new() + } +} + +impl TestFrameworkBlockPermutation { + /// + pub fn new() -> Self { + Self {} + } + + /// Exercises the trait contract for one implementor. + /// + /// Checks, in order: + /// * `decrypt_block` inverts `encrypt_block` on every block of [`DUMMY_SEED`]; + /// * the permutation actually permutes (a block is not left unchanged); + /// * distinct inputs give distinct outputs, i.e. it is injective on the blocks tested; + /// * `encrypt_blocks2` agrees with two `encrypt_block` calls **including their order**, and + /// likewise for `decrypt_blocks2` -- this is what pins an override to the default's + /// semantics, and it is the reason the pair methods are worth having in the trait at all; + /// * the pair methods round-trip each other; + /// * a key of the wrong [`KeyType`] is rejected; + /// * the security-strength policy matches [`BlockCipher::MAX_SECURITY_STRENGTH`]. + pub fn test< + const KEY_LEN: usize, + const BLOCK_LEN: usize, + P: BlockPermutation, + >( + &self, + ) { + let key = KeyMaterial::::from_bytes_as_type( + &DUMMY_SEED[..KEY_LEN], + KeyType::SymmetricCipherKey, + ) + .unwrap(); + let perm = P::new(&key).unwrap(); + + let blocks = DUMMY_SEED.as_chunks::().0; + + // encrypt / decrypt are inverses, and the permutation is not the identity. + for block in blocks.iter() { + let mut buf = *block; + perm.encrypt_block(&mut buf); + assert_ne!(&buf, block, "encrypt_block must not be the identity"); + perm.decrypt_block(&mut buf); + assert_eq!(&buf, block, "decrypt_block must invert encrypt_block"); + + // ...and the other way round, since a mode may call either direction first. + let mut buf = *block; + perm.decrypt_block(&mut buf); + assert_ne!(&buf, block, "decrypt_block must not be the identity"); + perm.encrypt_block(&mut buf); + assert_eq!(&buf, block, "encrypt_block must invert decrypt_block"); + } + + // Distinct inputs must give distinct outputs. A permutation is injective, so this catches + // an implementation that collapses inputs (e.g. one that masks part of the block away). + for pair in blocks.as_chunks::<2>().0.iter() { + let [a, b] = pair; + assert_ne!(a, b, "DUMMY_SEED blocks should differ; test setup problem"); + let mut ea = *a; + let mut eb = *b; + perm.encrypt_block(&mut ea); + perm.encrypt_block(&mut eb); + assert_ne!(ea, eb, "distinct blocks must encrypt to distinct blocks"); + } + + // The pair methods must be indistinguishable from the single-block ones, in both slots. + // An override that swapped the two results, or that processed only one of them, fails here. + for pair in blocks.as_chunks::<2>().0.iter() { + let [a, b] = pair; + + let mut singly = [*a, *b]; + perm.encrypt_block(&mut singly[0]); + perm.encrypt_block(&mut singly[1]); + let mut paired = [*a, *b]; + perm.encrypt_blocks2(&mut paired); + assert_eq!(paired, singly, "encrypt_blocks2 must match two encrypt_block calls"); + + let mut singly = [*a, *b]; + perm.decrypt_block(&mut singly[0]); + perm.decrypt_block(&mut singly[1]); + let mut paired = [*a, *b]; + perm.decrypt_blocks2(&mut paired); + assert_eq!(paired, singly, "decrypt_blocks2 must match two decrypt_block calls"); + + // Round-trip through the pair methods alone. + let mut buf = [*a, *b]; + perm.encrypt_blocks2(&mut buf); + perm.decrypt_blocks2(&mut buf); + assert_eq!(buf, [*a, *b], "decrypt_blocks2 must invert encrypt_blocks2"); + } + + // A pair of *identical* blocks must give a pair of identical outputs. This catches an + // implementation whose two lanes are not actually independent. + let block = blocks[0]; + let mut buf = [block, block]; + perm.encrypt_blocks2(&mut buf); + assert_eq!(buf[0], buf[1], "identical inputs must give identical outputs"); + let mut single = block; + perm.encrypt_block(&mut single); + assert_eq!(buf[0], single); + + // error case: KeyMaterial of the wrong type + let mac_key = + KeyMaterial::::from_bytes_as_type(&DUMMY_SEED[..KEY_LEN], KeyType::MACKey) + .unwrap(); + match P::new(&mac_key) { + Err(SymmetricCipherError::KeyMaterialError(_)) => { /* good */ } + _ => panic!("A key that is not a SymmetricCipherKey should have been rejected"), + }; + + // error case: security strengths too weak, and strong enough + let mut key = KeyMaterial::::from_bytes_as_type( + &DUMMY_SEED[..KEY_LEN], + KeyType::SymmetricCipherKey, + ) + .unwrap(); + let security_strengths = [ + SecurityStrength::None, + SecurityStrength::_112bit, + SecurityStrength::_128bit, + SecurityStrength::_192bit, + SecurityStrength::_256bit, + ]; + for ss in security_strengths.iter() { + // `set_security_strength` enforces its key-length guard even inside a + // do_hazardous_operations() closure, so skip the strengths a KEY_LEN-byte key cannot + // carry. Do NOT relax that guard in `KeyMaterial`: core's + // `test_hazardous_ops_error_handling` requires it to stay enforced. + if ss > &SecurityStrength::from_bytes(KEY_LEN) { + continue; + } + + // Tag the key at an arbitrary strength for the purpose of this test. + do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap(); + + match P::new(&key) { + Ok(_) => assert!( + ss >= &

::MAX_SECURITY_STRENGTH, + "should have required a key at least as strong as the algorithm" + ), + Err(SymmetricCipherError::KeyMaterialError(_)) => assert!( + ss < &

::MAX_SECURITY_STRENGTH, + "should not have rejected a key strong enough for the algorithm" + ), + _ => panic!("Unexpected error"), + }; + } + } +} diff --git a/crypto/core-test-framework/src/lib.rs b/crypto/core-test-framework/src/lib.rs index afdc204a..a95ea447 100644 --- a/crypto/core-test-framework/src/lib.rs +++ b/crypto/core-test-framework/src/lib.rs @@ -14,6 +14,7 @@ // properly document everything. #![forbid(missing_docs)] +pub mod block_permutation; pub mod hash; pub mod kdf; pub mod kem; diff --git a/crypto/core-test-framework/src/symmetric_ciphers.rs b/crypto/core-test-framework/src/symmetric_ciphers.rs index 6e1c8534..180e5851 100644 --- a/crypto/core-test-framework/src/symmetric_ciphers.rs +++ b/crypto/core-test-framework/src/symmetric_ciphers.rs @@ -238,9 +238,17 @@ impl TestFrameworkBlockCipher { SecurityStrength::_256bit, ]; for ss in security_strengths.iter() { - // Tag the key at an arbitrary strength for the purpose of this test. Inside a - // do_hazardous_operations() closure, set_security_strength() raises the strength - // (and bypasses the key-length guard) without complaining. + // `set_security_strength` enforces its key-length guard even inside a + // do_hazardous_operations() closure -- a KEY_LEN-byte key cannot be tagged at a + // strength above `from_bytes(KEY_LEN)` -- so skip the strengths this key cannot carry + // rather than unwrapping an error. (A 16-byte key can reach 128-bit and no higher.) + // Do NOT "fix" this by relaxing that guard in `KeyMaterial`: core's + // `test_hazardous_ops_error_handling` requires it to stay enforced. + if ss > &SecurityStrength::from_bytes(KEY_LEN) { + continue; + } + + // Tag the key at an arbitrary strength for the purpose of this test. do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap(); match E::do_encrypt_init(&key) { diff --git a/crypto/core-test-framework/summary.md b/crypto/core-test-framework/summary.md new file mode 100644 index 00000000..e0ae3736 --- /dev/null +++ b/crypto/core-test-framework/summary.md @@ -0,0 +1,189 @@ +# `crypto/core-test-framework` โ€” changes for `BlockPermutation` and CBC + +Changes made on branch `feature/officialfrancismendoza/98-AES-lowmemory` (2026-08-31) while adding +`crypto/aes-lowmemory` and `crypto/modes`. Two things: a **new** per-trait suite for +`core::traits::BlockPermutation`, and a **bug fix** to the existing `TestFrameworkBlockCipher`. + +For what this crate is for in general, see its [`src/lib.rs`](src/lib.rs) docs: one KAT-style +harness per `core` trait, so that behaviour which should be consistent across implementations of a +trait โ€” error handling, input/output lengths, `KeyMaterial` entropy enforcement โ€” is asserted once +here rather than re-written per implementation. + +--- + +## 1. New: `TestFrameworkBlockPermutation` + +[`src/block_permutation.rs`](src/block_permutation.rs), registered as `pub mod block_permutation;` +in [`src/lib.rs`](src/lib.rs). + +`core::traits::BlockPermutation` is new in this branch: the raw keyed +permutation (`CIPH_K` / `CIPH^-1_K` of SP 800-38A Sec 5.1) that a mode of operation is built on. +It needed a conformance suite like every other `core` trait. + +```rust +TestFrameworkBlockPermutation::new().test::(); +``` + +### What it checks, and why each check exists + +| Check | What it catches | +|---|---| +| `decrypt_block` inverts `encrypt_block`, **and vice versa** | A direction implemented only one way round. A mode may call either direction first, so both orders are exercised. | +| Neither direction is the identity | A stub, or a key schedule that never got applied. | +| Distinct blocks give distinct outputs | An implementation that is not injective โ€” e.g. one masking part of the block away. A permutation must be. | +| `encrypt_blocks2` == two `encrypt_block` calls, **including their order**; same for decrypt | The whole reason the pair methods are safe to override. See below. | +| The pair methods round-trip each other | A pair path correct in one direction only. | +| Identical inputs give identical outputs from `*_blocks2` | Lanes that are not actually independent โ€” a real hazard for a bit-sliced implementation that interleaves two blocks in one word. | +| A key of the wrong `KeyType` is rejected | A seed or MAC key being reused as a cipher key. | +| The security-strength policy matches `BlockCipher::MAX_SECURITY_STRENGTH` | A `new()` that accepts a key weaker than the algorithm, or rejects one strong enough. | + +### The order check is the load-bearing one + +`BlockPermutation::encrypt_blocks2` and `decrypt_blocks2` are *provided* methods: the default is +two single-block calls, and implementations are free to override them. `bouncycastle-aes-lowmemory` +does, because a pair of blocks is exactly what its bit-sliced state holds, so the pair form costs +barely more than one block. + +An override is therefore a place where an implementation can silently disagree with the trait's +semantics โ€” most easily by returning the two results in the wrong order, which round-trips +perfectly and so passes any test that only checks encrypt-then-decrypt. Asserting equality against +two explicit single-block calls, slot by slot, is what makes an override trustworthy. That check is +the reason this suite is worth having rather than leaving each implementor to test itself. + +The mirror image of this check lives in `crypto/modes/tests/common/mod.rs` as `SwappedPairToy`, a +permutation whose pair methods deliberately swap their results, used to prove the *mode* really +takes the pair path. + +### Current implementors + +* `crypto/aes-lowmemory/tests/block_permutation_tests.rs` โ€” AES-128, AES-192, AES-256. +* `crypto/modes/tests/cbc_tests.rs` โ€” the toy permutation, checked before anything is concluded + from it. + +--- + +## 2. Fixed: `TestFrameworkBlockCipher` panicked for any key under 32 bytes + +### The bug + +`TestFrameworkBlockCipher::test` ended with a loop that tagged the test key at each of the five +`SecurityStrength` values and checked the `_init` constructor's accept/reject decision against +`MAX_SECURITY_STRENGTH`: + +```rust +for ss in security_strengths.iter() { + do_hazardous_operations(&mut key, |key| key.set_security_strength(ss.clone())).unwrap(); + // ... +} +``` + +`KeyMaterial::set_security_strength` enforces a key-length guard โ€” a key cannot be tagged at a +strength its own length cannot carry โ€” and it enforces it **even inside a +`do_hazardous_operations` closure**. So for a 16-byte key the loop reached `_192bit`, got +`Err(SecurityStrength("Security strength cannot be larger than key length."))`, and the `unwrap()` +panicked. The comment above the loop asserted the opposite ("bypasses the key-length guard"), which +is what made it look correct. + +The result: the harness was unusable for AES-128 or AES-192, i.e. for most block ciphers. + +### Why nobody had noticed + +Nothing in the workspace implemented `BlockCipherEncryptor`/`BlockCipherDecryptor`. The traits +landed in PR #96 with the harness written against them but no implementor โ€” the toy XOR-CBC cipher +that would have exercised it lives in `crypto/padding`, which is PR #97 and has not merged to this +branch. `crypto/modes`' CBC is the first implementor in the tree, and it hit the panic immediately. + +### The fix + +Skip the strengths the key length cannot hold, rather than unwrapping the error: + +```rust +if ss > &SecurityStrength::from_bytes(KEY_LEN) { + continue; +} +``` + +For a 16-byte key this tests `None`, `_112bit` and `_128bit` โ€” which still spans the +`MAX_SECURITY_STRENGTH` boundary for AES-128, so the accept/reject decision is still exercised on +both sides. Nothing is lost; the skipped cases were never reachable. + +### What **not** to do instead + +Do not relax the guard in `KeyMaterial::set_security_strength`. `core`'s +`test_hazardous_ops_error_handling` requires it to stay enforced even inside +`do_hazardous_operations`. A comment at the fix says so, because "make the setter permissive" is +the tempting one-line alternative and it breaks a core test. This is the same conclusion reached +independently on the ASCON branch. + +--- + +## 3. Still outstanding: the same bug, twice more + +The identical loop appears in two other suites in +[`src/symmetric_ciphers.rs`](src/symmetric_ciphers.rs) and is **not** fixed: + +| Suite | Loop at | Implementors in tree | Status | +|---|---|---|---| +| `TestFrameworkSymmetricCipher` | line 87 | 0 | latent, unfixed | +| `TestFrameworkBlockCipher` | line 240 | 1 (`crypto/modes`) | **fixed** | +| `TestFrameworkAEADCipher` | line 386 | 0 | latent, unfixed | +| `TestFrameworkStreamCipher` | โ€” | 0 | unaffected (no strength handling) | + +Both unfixed suites will panic the first time anything implements their trait with a key shorter +than 32 bytes โ€” which for `AEADCipher` includes ASCON-128 and AES-128-GCM. They were left alone to +keep this change scoped to what CBC needed; the fix is the same three lines in each. Worth doing +before the next implementor arrives rather than after. + +Note that `TestFrameworkStreamCipher` is a different case: it has no security-strength handling at +all, so there is nothing to fix there and nothing being checked either. + +--- + +## 4. Unchanged but newly exercised: `FixedSeedRNG` + +[`src/fixed_seed_rng.rs`](src/fixed_seed_rng.rs) already existed and was not modified. It is worth +recording that it is now what makes CBC's known-answer tests possible. + +`Cbc` deliberately has no API for a caller-supplied IV โ€” SP 800-38A Sec 5.3 requires the CBC IV to +be *unpredictable*, so `do_encrypt_init` generates one and returns it. That leaves a problem for +testing: Appendix F.2 specifies the IV, and there is no way to pass it in. + +`BlockCipherEncryptor::do_encrypt_init_rng(key, &mut dyn RNG)` is the seam. +`FixedSeedRNG::<16>::new(iv)` emits the vector's IV as its first sixteen bytes, so the test can pin +the IV without the production API ever accepting one. `crypto/modes/tests/sp800_38a_tests.rs` +asserts the returned init data really is the expected IV before comparing any ciphertext, so a +change that ignored the RNG could not pass silently. + +This is the pattern to reuse for CFB, OFB and CTR when they land. + +--- + +## 5. Verification + +```sh +cargo build -p bouncycastle-core-test-framework +cargo test --workspace # 500 tests, 0 failures +cargo fmt --all -- --check +``` + +This crate has no tests of its own โ€” it *is* tests โ€” so it is verified by its consumers. The two +new suites are exercised by: + +* `cargo test -p bouncycastle-aes-lowmemory --test block_permutation_tests` (3 tests) +* `cargo test -p bouncycastle-modes --test cbc_tests` (11 tests, including + `cbc_conforms_to_the_block_cipher_framework`, which is what the ยง2 fix unblocked, and + `the_toy_permutation_conforms_to_the_trait`) + +--- + +## 6. Open items + +1. **Fix the same loop in `TestFrameworkSymmetricCipher` and `TestFrameworkAEADCipher`** (ยง3). + Three lines each, and the next implementor of either trait will otherwise hit the panic. +2. **Decide whether the `Default` impl added to `TestFrameworkBlockPermutation` should be added to + the other suites** for consistency โ€” they all have `new()` and no `Default`, which clippy + flags on new code but not on existing code. +3. When `crypto/padding` (PR #97) merges, its toy XOR-CBC cipher becomes a second + `TestFrameworkBlockCipher` implementor. Worth re-running that suite then: an XOR-based cipher has + `encrypt_block == decrypt_block`, which is exactly the property `crypto/modes`' non-XOR toy was + chosen to avoid, so it may expose gaps this branch's tests do not. diff --git a/crypto/core/src/traits.rs b/crypto/core/src/traits.rs index e13cbc8b..bd77be26 100644 --- a/crypto/core/src/traits.rs +++ b/crypto/core/src/traits.rs @@ -87,6 +87,65 @@ pub trait BlockCipher { const MAX_SECURITY_STRENGTH: SecurityStrength; } +/// A keyed block permutation: the `CIPH_K` / `CIPH^-1_K` of NIST SP 800-38A Sec 5.1. +/// +/// This is the raw primitive a mode of operation is built on, not something to encrypt data with. +/// It transforms exactly one block, so applying it directly to data is ECB, which is not +/// confidential. [`BlockCipherEncryptor`] and [`BlockCipherDecryptor`] are the *mode* traits -- +/// they carry initialization data and chaining state; this one carries only a key schedule. +/// +/// Implementors are expected to hold that key schedule in a zeroize-on-drop wrapper +/// (`bouncycastle_utils::secret::Secret`), so it is scrubbed when the value is dropped. +/// +/// # Why the block methods are infallible +/// +/// Every length here is fixed by a type, and a constructed value is always ready to use, so there +/// is nothing a caller can get wrong once [`BlockPermutation::new`] has returned. Only `new` can +/// fail, and only because of the key. +pub trait BlockPermutation: + BlockCipher + Sized +{ + /// Expands the key. + /// + /// # Errors + /// Rejects a key whose [`KeyType`] is not [`KeyType::SymmetricCipherKey`], and one whose + /// security strength is below [`BlockCipher::MAX_SECURITY_STRENGTH`], both as a + /// [`SymmetricCipherError::KeyMaterialError`]. + fn new(key: &KeyMaterial) -> Result; + + /// The forward cipher function, in place. + fn encrypt_block(&self, block: &mut [u8; BLOCK_LEN]); + + /// The inverse cipher function, in place. + fn decrypt_block(&self, block: &mut [u8; BLOCK_LEN]); + + /// The forward cipher function on two *independent* blocks, in place. + /// + /// Provided as two [`BlockPermutation::encrypt_block`] calls. Bit-sliced implementations + /// override it, because a pair of blocks is their natural unit of work and costs barely more + /// than one; see `bouncycastle-aes-lowmemory`. + /// + /// Overrides must be indistinguishable from the default, including the order of the two + /// results. `TestFrameworkBlockPermutation` pins that. + /// + /// Modes whose structure is parallel -- CBC decryption, CFB decryption, CTR -- should prefer + /// this. CBC and CFB *encryption* cannot use it: each input block depends on the previous + /// output. + fn encrypt_blocks2(&self, blocks: &mut [[u8; BLOCK_LEN]; 2]) { + let [a, b] = blocks; + self.encrypt_block(a); + self.encrypt_block(b); + } + + /// The inverse cipher function on two *independent* blocks, in place. + /// See [`BlockPermutation::encrypt_blocks2`]. + fn decrypt_blocks2(&self, blocks: &mut [[u8; BLOCK_LEN]; 2]) { + let [a, b] = blocks; + self.decrypt_block(a); + self.decrypt_block(b); + } +} + /// The encryption half of a block cipher's streaming API. Strictly block-aligned: whole blocks in, whole /// blocks out, no finalization step. Padding of non-block-aligned data is handled by a separate layer /// (`PaddedEncryptor` / `PaddedDecryptor`) built on top of this trait. diff --git a/crypto/modes/Cargo.toml b/crypto/modes/Cargo.toml new file mode 100644 index 00000000..ec5cc847 --- /dev/null +++ b/crypto/modes/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bouncycastle-modes" +version.workspace = true +edition.workspace = true + +[dependencies] +bouncycastle-core.workspace = true +# Only for the default OS-backed DRBG that generates the IV in `do_encrypt_init`. +bouncycastle-rng.workspace = true + +[dev-dependencies] +bouncycastle-aes-lowmemory.workspace = true +bouncycastle-core-test-framework.workspace = true +bouncycastle-hex.workspace = true +criterion.workspace = true + +[[bench]] +name = "modes_benches" +harness = false diff --git a/crypto/modes/benches/modes_benches.rs b/crypto/modes/benches/modes_benches.rs new file mode 100644 index 00000000..66cdaea8 --- /dev/null +++ b/crypto/modes/benches/modes_benches.rs @@ -0,0 +1,245 @@ +//! Criterion benchmarks for the modes. +//! +//! The number to watch is the **decrypt/encrypt throughput ratio at N >= 2**. CBC encryption is +//! serial by construction (SP 800-38A Sec 6.2: each forward cipher input depends on the previous +//! output), so it can only ever use the single-block path. CBC *decryption* is parallel, and this +//! implementation hands blocks to `decrypt_blocks2` in pairs. With the bit-sliced AES, whose +//! two-block path costs barely more than one block, decryption should therefore run at roughly +//! twice the throughput of encryption. That gap is the entire justification for the pair methods +//! on `BlockPermutation`, so if it disappears, something has stopped taking the pair path. +//! +//! `N = 1` is included to show the effect vanishing: with one block there is no pair to form, so +//! decryption falls back to the single-block path and the ratio should be about 1. + +use bouncycastle_aes_lowmemory::{Aes128, Aes256}; +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +use bouncycastle_core::traits::{ + BlockCipher, BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength, +}; +use bouncycastle_modes::{Cbc, Decrypting, Encrypting}; +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; +use std::hint::black_box; + +const BLOCK_LEN: usize = 16; +/// 16 KiB, i.e. 1024 AES blocks. +const NUM_BLOCKS: usize = 1024; +const DATA_LEN: usize = NUM_BLOCKS * BLOCK_LEN; + +type Aes128Cbc

= Cbc; +type Aes256Cbc = Cbc; + +/// AES-128 with the pair methods **not** overridden, so they fall back to the trait defaults of +/// two single-block calls. +/// +/// This exists purely to isolate the value of the pair path. Comparing `Cbc` against +/// `Cbc` at the *same* `N` holds everything else fixed -- same cipher, same +/// call granularity, same amount of data movement -- so the difference is attributable to +/// `decrypt_blocks2` and nothing else. +/// +/// Comparing `N = 1` against `N = 8` does *not* isolate it: encryption, which can never pair, also +/// speeds up substantially between those two, so call granularity dominates that comparison. +struct UnpairedAes128(Aes128); + +impl BlockCipher for UnpairedAes128 { + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl BlockPermutation<16, BLOCK_LEN> for UnpairedAes128 { + fn new(key: &KeyMaterial<16>) -> Result { + Ok(Self(>::new(key)?)) + } + fn encrypt_block(&self, block: &mut [u8; BLOCK_LEN]) { + >::encrypt_block(&self.0, block) + } + fn decrypt_block(&self, block: &mut [u8; BLOCK_LEN]) { + >::decrypt_block(&self.0, block) + } + // encrypt_blocks2 / decrypt_blocks2 deliberately left as the trait defaults. +} + +type UnpairedAes128Cbc = Cbc; + +fn key() -> KeyMaterial { + let bytes: [u8; N] = core::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(1)); + KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey).unwrap() +} + +fn data() -> Vec<[u8; BLOCK_LEN]> { + (0..NUM_BLOCKS) + .map(|i| core::array::from_fn(|j| (i.wrapping_mul(31).wrapping_add(j)) as u8)) + .collect() +} + +fn bench_aes128(c: &mut Criterion) { + let k = key::<16>(); + let blocks = data(); + + let mut group = c.benchmark_group("modes::cbc::Aes128"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + + // ---- encryption: serial, one block at a time is all it can do ---- + group.bench_function("16KiB encrypt -- N=1", |b| { + b.iter(|| { + let (mut enc, _) = Aes128Cbc::::do_encrypt_init(&k).unwrap(); + for block in blocks.iter() { + black_box(enc.do_encrypt_blocks(&[*block]).unwrap()); + } + }) + }); + + group.bench_function("16KiB encrypt -- N=8", |b| { + b.iter(|| { + let (mut enc, _) = Aes128Cbc::::do_encrypt_init(&k).unwrap(); + for chunk in blocks.chunks_exact(8) { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + black_box(enc.do_encrypt_blocks(arr).unwrap()); + } + }) + }); + + // ---- decryption: parallel, uses decrypt_blocks2 for every pair ---- + let (mut enc, iv) = Aes128Cbc::::do_encrypt_init(&k).unwrap(); + let ciphertext: Vec<[u8; BLOCK_LEN]> = blocks + .chunks_exact(8) + .flat_map(|chunk| { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + enc.do_encrypt_blocks(arr).unwrap() + }) + .collect(); + + // N=1 never forms a pair, so this is the single-block path: the ratio against encrypt should + // be about 1. + group.bench_function("16KiB decrypt -- N=1 (no pairing)", |b| { + b.iter(|| { + let mut dec = Aes128Cbc::::do_decrypt_init(&k, &iv).unwrap(); + for block in ciphertext.iter() { + black_box(dec.do_decrypt_blocks(&[*block]).unwrap()); + } + }) + }); + + // N=2 and N=8 are all pairs, so every block goes through decrypt_blocks2. + group.bench_function("16KiB decrypt -- N=2 (all pairs)", |b| { + b.iter(|| { + let mut dec = Aes128Cbc::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(2) { + let arr: &[[u8; BLOCK_LEN]; 2] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + group.bench_function("16KiB decrypt -- N=8 (all pairs)", |b| { + b.iter(|| { + let mut dec = Aes128Cbc::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(8) { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + // N=9 is four pairs plus a one-block remainder, so it exercises the tail path too. + group.bench_function("16KiB decrypt -- N=9 (pairs + remainder)", |b| { + b.iter(|| { + let mut dec = Aes128Cbc::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(9) { + let arr: &[[u8; BLOCK_LEN]; 9] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + // The controlled comparison: identical N, identical cipher, pair methods overridden vs not. + // This pair of numbers -- and only this pair -- measures what `decrypt_blocks2` buys. + group.bench_function("16KiB decrypt -- N=8, pair path (blocks2 overridden)", |b| { + b.iter(|| { + let mut dec = Aes128Cbc::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(8) { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + group.bench_function("16KiB decrypt -- N=8, no pair path (trait default)", |b| { + b.iter(|| { + let mut dec = UnpairedAes128Cbc::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(8) { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + group.finish(); +} + +fn bench_aes256(c: &mut Criterion) { + let k = key::<32>(); + let blocks = data(); + + let mut group = c.benchmark_group("modes::cbc::Aes256"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + + group.bench_function("16KiB encrypt -- N=8", |b| { + b.iter(|| { + let (mut enc, _) = Aes256Cbc::::do_encrypt_init(&k).unwrap(); + for chunk in blocks.chunks_exact(8) { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + black_box(enc.do_encrypt_blocks(arr).unwrap()); + } + }) + }); + + let (mut enc, iv) = Aes256Cbc::::do_encrypt_init(&k).unwrap(); + let ciphertext: Vec<[u8; BLOCK_LEN]> = blocks + .chunks_exact(8) + .flat_map(|chunk| { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + enc.do_encrypt_blocks(arr).unwrap() + }) + .collect(); + + group.bench_function("16KiB decrypt -- N=8 (all pairs)", |b| { + b.iter(|| { + let mut dec = Aes256Cbc::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(8) { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + group.finish(); +} + +/// `do_*_init` includes a key expansion, and for encryption also an IV draw from the OS-backed +/// DRBG. Worth its own measurement, because for short messages it dominates. +fn bench_init(c: &mut Criterion) { + let k128 = key::<16>(); + let k256 = key::<32>(); + let iv = [0u8; BLOCK_LEN]; + + let mut group = c.benchmark_group("modes::cbc::init"); + + group.bench_function("Aes128 do_encrypt_init (key schedule + IV)", |b| { + b.iter(|| black_box(Aes128Cbc::::do_encrypt_init(black_box(&k128)).unwrap().1)) + }); + group.bench_function("Aes128 do_decrypt_init (key schedule only)", |b| { + b.iter(|| { + black_box(Aes128Cbc::::do_decrypt_init(black_box(&k128), &iv).unwrap()) + }) + }); + group.bench_function("Aes256 do_decrypt_init (key schedule only)", |b| { + b.iter(|| { + black_box(Aes256Cbc::::do_decrypt_init(black_box(&k256), &iv).unwrap()) + }) + }); + + group.finish(); +} + +criterion_group!(benches, bench_aes128, bench_aes256, bench_init); +criterion_main!(benches); diff --git a/crypto/modes/src/cbc.rs b/crypto/modes/src/cbc.rs new file mode 100644 index 00000000..996c441d --- /dev/null +++ b/crypto/modes/src/cbc.rs @@ -0,0 +1,232 @@ +//! The Cipher Block Chaining mode of operation (NIST SP 800-38A Sec 6.2). +//! +//! # The specification +//! +//! SP 800-38A Sec 6.2 defines the mode as, quoting verbatim: +//! +//! ```text +//! CBC Encryption: C1 = CIPH_K(P1 XOR IV); +//! Cj = CIPH_K(Pj XOR Cj-1) for j = 2 ... n. +//! +//! CBC Decryption: P1 = CIPH^-1_K(C1) XOR IV; +//! Pj = CIPH^-1_K(Cj) XOR Cj-1 for j = 2 ... n. +//! ``` +//! +//! The `j = 1` and `j >= 2` cases differ only in that the first one uses the IV where the others +//! use the previous ciphertext block. So this implementation keeps a single `chain` field holding +//! "whatever gets XORed next", initialised to the IV and replaced by each ciphertext block as it +//! is produced or consumed. That is the equivalence being used, and it is why there is no special +//! case for the first block anywhere below. +//! +//! # Parallel decryption +//! +//! Sec 6.2 notes that in CBC decryption "the input blocks for the inverse cipher function, i.e., +//! the ciphertext blocks, are immediately available, so that multiple inverse cipher operations can +//! be performed in parallel", whereas in encryption "the input block to each forward cipher +//! operation (except the first) depends on the result of the previous forward cipher operation, so +//! the forward cipher operations cannot be performed in parallel". +//! +//! This implementation uses that: decryption walks the ciphertext two blocks at a time and hands +//! both to [`BlockPermutation::decrypt_blocks2`], which a bit-sliced engine computes for barely +//! more than the cost of one block. Encryption cannot, and does not. + +use crate::iv::random_iv; +use crate::{Decrypting, Encrypting}; +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::key_material::KeyMaterial; +use bouncycastle_core::traits::{ + BlockCipher, BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, RNG, + SecurityStrength, +}; +use bouncycastle_rng::HashDRBG_SHA512; +use core::marker::PhantomData; + +/// CBC mode over any [`BlockPermutation`], with the direction encoded in the type. +/// +/// `Dir` is [`Encrypting`] or [`Decrypting`]. [`BlockCipherEncryptor`] is implemented only for the +/// former and [`BlockCipherDecryptor`] only for the latter, so a `Cbc<_, Encrypting, _, _>` has no +/// decryption methods at all -- using one in the wrong direction is a compile error rather than a +/// runtime check. +/// +/// The initialization data is one block, so `INIT_DATA_LEN == BLOCK_LEN`. +/// +/// # State +/// +/// Two fields: the permutation (which owns the key schedule, and is responsible for keeping it in +/// a zeroize-on-drop wrapper) and one block of chaining value. The chaining value is an IV or a +/// ciphertext block, both of which are public, so it is deliberately not wrapped in a `Secret`. +pub struct Cbc +where + P: BlockPermutation, +{ + perm: P, + /// `Cj-1`, initialised to the IV. See the module docs on why there is only one field for both. + chain: [u8; BLOCK_LEN], + _dir: PhantomData, +} + +impl Cbc +where + P: BlockPermutation, +{ + /// `Cj = CIPH_K(Pj XOR Cj-1)`, then `Cj` becomes the next chaining value. + #[inline] + fn encrypt_one(&mut self, plaintext: &[u8; BLOCK_LEN], ciphertext: &mut [u8; BLOCK_LEN]) { + for (out, (p, chain)) in ciphertext.iter_mut().zip(plaintext.iter().zip(self.chain.iter())) + { + *out = *p ^ *chain; + } + self.perm.encrypt_block(ciphertext); + self.chain = *ciphertext; + } + + /// `Pj = CIPH^-1_K(Cj) XOR Cj-1`, then `Cj` becomes the next chaining value. + #[inline] + fn decrypt_one(&mut self, ciphertext: &[u8; BLOCK_LEN], plaintext: &mut [u8; BLOCK_LEN]) { + *plaintext = *ciphertext; + self.perm.decrypt_block(plaintext); + for (out, chain) in plaintext.iter_mut().zip(self.chain.iter()) { + *out ^= *chain; + } + self.chain = *ciphertext; + } + + /// Decrypts two consecutive blocks with one [`BlockPermutation::decrypt_blocks2`] call. + /// + /// Writing the pair as `Cj, Cj+1` with `Cj-1` the incoming chaining value, Sec 6.2 gives + /// + /// ```text + /// Pj = CIPH^-1_K(Cj) XOR Cj-1 + /// Pj+1 = CIPH^-1_K(Cj+1) XOR Cj + /// ``` + /// + /// Neither inverse cipher depends on the other's *output* -- only on ciphertext, which is + /// already in hand -- so computing them together changes nothing. The two XOR operands do + /// differ, and the second one is `Cj`, so both are read out of `ciphertext` before the + /// chaining value is advanced to `Cj+1`. + #[inline] + fn decrypt_pair( + &mut self, + ciphertext: &[[u8; BLOCK_LEN]; 2], + plaintext: &mut [[u8; BLOCK_LEN]; 2], + ) { + *plaintext = *ciphertext; + self.perm.decrypt_blocks2(plaintext); + + let (first, rest) = plaintext.split_at_mut(1); + for (out, chain) in first[0].iter_mut().zip(self.chain.iter()) { + *out ^= *chain; // XOR Cj-1 + } + for (out, prev) in rest[0].iter_mut().zip(ciphertext[0].iter()) { + *out ^= *prev; // XOR Cj + } + + self.chain = ciphertext[1]; + } +} + +impl BlockCipher + for Cbc +where + P: BlockPermutation, +{ + /// A mode does not change the strength of the underlying cipher. + const MAX_SECURITY_STRENGTH: SecurityStrength =

::MAX_SECURITY_STRENGTH; +} + +impl + BlockCipherEncryptor for Cbc +where + P: BlockPermutation, +{ + /// Begins an encryption flow, generating the IV from the library's default OS-backed DRBG. + fn do_encrypt_init( + key: &KeyMaterial, + ) -> Result<(Self, [u8; BLOCK_LEN]), SymmetricCipherError> { + let mut rng = HashDRBG_SHA512::new_from_os(); + Self::do_encrypt_init_rng(key, &mut rng) + } + + /// As [`BlockCipherEncryptor::do_encrypt_init`], but takes the IV from the provided RNG. + fn do_encrypt_init_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + ) -> Result<(Self, [u8; BLOCK_LEN]), SymmetricCipherError> { + let perm = P::new(key)?; + let iv = random_iv::(rng)?; + Ok((Self { perm, chain: iv, _dir: PhantomData }, iv)) + } + + fn do_encrypt_blocks( + &mut self, + plaintext: &[[u8; BLOCK_LEN]; N], + ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError> { + let mut ciphertext = [[0u8; BLOCK_LEN]; N]; + self.do_encrypt_blocks_out(plaintext, &mut ciphertext)?; + Ok(ciphertext) + } + + /// The real implementation; the by-value variant above is a wrapper over it. + /// + /// Strictly serial: `Cj` is the input to block `j + 1`, so there is no pair path here. See the + /// module docs. + fn do_encrypt_blocks_out( + &mut self, + plaintext: &[[u8; BLOCK_LEN]; N], + ciphertext: &mut [[u8; BLOCK_LEN]; N], + ) -> Result { + for (p, c) in plaintext.iter().zip(ciphertext.iter_mut()) { + self.encrypt_one(p, c); + } + Ok(N * BLOCK_LEN) + } +} + +impl + BlockCipherDecryptor for Cbc +where + P: BlockPermutation, +{ + /// Begins a decryption flow from the IV returned by + /// [`BlockCipherEncryptor::do_encrypt_init`]. + fn do_decrypt_init( + key: &KeyMaterial, + init_data: &[u8; BLOCK_LEN], + ) -> Result { + let perm = P::new(key)?; + Ok(Self { perm, chain: *init_data, _dir: PhantomData }) + } + + fn do_decrypt_blocks( + &mut self, + ciphertext: &[[u8; BLOCK_LEN]; N], + ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError> { + let mut plaintext = [[0u8; BLOCK_LEN]; N]; + self.do_decrypt_blocks_out(ciphertext, &mut plaintext)?; + Ok(plaintext) + } + + /// The real implementation; the by-value variant above is a wrapper over it. + /// + /// Walks the input in pairs so the permutation's two-block path is used, with an at-most-one + /// block remainder for odd `N`. `as_chunks` splits into exactly that shape with no runtime + /// length check and no indexing arithmetic; `N` is a compile-time constant, so for even `N` the + /// tail loop is empty and for `N = 1` the pair loop is. + fn do_decrypt_blocks_out( + &mut self, + ciphertext: &[[u8; BLOCK_LEN]; N], + plaintext: &mut [[u8; BLOCK_LEN]; N], + ) -> Result { + let (ct_pairs, ct_tail) = ciphertext.as_chunks::<2>(); + let (pt_pairs, pt_tail) = plaintext.as_chunks_mut::<2>(); + + for (ct_pair, pt_pair) in ct_pairs.iter().zip(pt_pairs.iter_mut()) { + self.decrypt_pair(ct_pair, pt_pair); + } + for (c, p) in ct_tail.iter().zip(pt_tail.iter_mut()) { + self.decrypt_one(c, p); + } + + Ok(N * BLOCK_LEN) + } +} diff --git a/crypto/modes/src/iv.rs b/crypto/modes/src/iv.rs new file mode 100644 index 00000000..d2b60c02 --- /dev/null +++ b/crypto/modes/src/iv.rs @@ -0,0 +1,26 @@ +//! Initialization-vector generation, shared by the modes that need one. + +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::traits::RNG; + +/// Generates a random initialization vector. +/// +/// NIST SP 800-38A Appendix C gives two recommended methods for producing the unpredictable IVs +/// that CBC and CFB require. This is the second one verbatim: "to generate a random data block +/// using a FIPS-approved random number generator". +/// +/// The first method -- applying the forward cipher function to a nonce under the same key -- is not +/// implemented, because it needs a nonce the caller has to guarantee unique, and the API +/// deliberately does not accept caller-supplied initialization data at all. +/// +/// Appendix C also notes the IV "need not be secret", so this is not wrapped in a `Secret`: it is +/// returned to the caller to transmit alongside the ciphertext. Its *integrity* is a different +/// matter -- see the `cbc` module docs on Appendix D. +pub(crate) fn random_iv( + rng: &mut dyn RNG, +) -> Result<[u8; N], SymmetricCipherError> { + let mut iv = [0u8; N]; + // `RNGError` converts into `SymmetricCipherError` via the `From` impl in core::errors. + rng.next_bytes_out(&mut iv)?; + Ok(iv) +} diff --git a/crypto/modes/src/lib.rs b/crypto/modes/src/lib.rs new file mode 100644 index 00000000..ca639658 --- /dev/null +++ b/crypto/modes/src/lib.rs @@ -0,0 +1,187 @@ +//! Block cipher modes of operation (NIST SP 800-38A). +//! +//! A mode turns a keyed block permutation -- `bouncycastle-aes-lowmemory`'s `Aes128` and friends, +//! or anything else implementing [`BlockPermutation`] -- into something that can encrypt more than +//! one block. This crate currently provides **CBC** ([`Cbc`], SP 800-38A Sec 6.2). +//! +//! The crate is deliberately cipher-agnostic: it depends on no concrete block cipher, only on the +//! trait. Define a one-line alias for the combination you use: +//! +//! ``` +//! use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; +//! use bouncycastle_modes::Cbc; +//! +//! type Aes128Cbc

= Cbc; +//! type Aes192Cbc = Cbc; +//! type Aes256Cbc = Cbc; +//! ``` +//! +//! # Usage Examples +//! +//! The direction is part of the type: [`Cbc`](Cbc) implements +//! [`BlockCipherEncryptor`] and nothing else, and [`Cbc`](Cbc) implements +//! [`BlockCipherDecryptor`] and nothing else. The IV is generated for you and returned; there is no +//! API for supplying your own (see [Security Considerations](#security-considerations)). +//! +//! ``` +//! use bouncycastle_aes_lowmemory::Aes128; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor}; +//! use bouncycastle_modes::{Cbc, Decrypting, Encrypting}; +//! +//! type Aes128Cbc = Cbc; +//! +//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +//! .expect("a 16-byte symmetric cipher key"); +//! +//! let plaintext = [[0u8; 16], [1u8; 16], [2u8; 16]]; +//! +//! // One shot: encrypts under a freshly generated IV, which is returned alongside the ciphertext. +//! let (iv, ciphertext) = +//! Aes128Cbc::::encrypt_blocks(&key, &plaintext).expect("encryption"); +//! +//! let recovered = +//! Aes128Cbc::::decrypt_blocks(&key, &iv, &ciphertext).expect("decryption"); +//! assert_eq!(recovered, plaintext); +//! ``` +//! +//! Streaming, for data that arrives in pieces. A sequence of calls is equivalent to one call over +//! the concatenation: +//! +//! ``` +//! use bouncycastle_aes_lowmemory::Aes256; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor}; +//! use bouncycastle_modes::{Cbc, Decrypting, Encrypting}; +//! +//! type Aes256Cbc = Cbc; +//! +//! let key = KeyMaterial::<32>::from_bytes_as_type(&[0x07; 32], KeyType::SymmetricCipherKey) +//! .expect("a 32-byte symmetric cipher key"); +//! +//! let (mut encryptor, iv) = +//! Aes256Cbc::::do_encrypt_init(&key).expect("encrypt init"); +//! let first = encryptor.do_encrypt_blocks(&[[0xAAu8; 16]]).expect("block 1"); +//! let rest = encryptor.do_encrypt_blocks(&[[0xBBu8; 16], [0xCCu8; 16]]).expect("blocks 2-3"); +//! +//! let mut decryptor = Aes256Cbc::::do_decrypt_init(&key, &iv).expect("decrypt init"); +//! assert_eq!(decryptor.do_decrypt_blocks(&first).unwrap(), [[0xAAu8; 16]]); +//! assert_eq!(decryptor.do_decrypt_blocks(&rest).unwrap(), [[0xBBu8; 16], [0xCCu8; 16]]); +//! ``` +//! +//! Using the wrong direction does not compile: +//! +//! ```compile_fail +//! use bouncycastle_aes_lowmemory::Aes128; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! use bouncycastle_core::traits::BlockCipherDecryptor; +//! use bouncycastle_modes::{Cbc, Encrypting}; +//! +//! type Aes128Cbc = Cbc; +//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey).unwrap(); +//! +//! // `Encrypting` does not implement `BlockCipherDecryptor`. +//! let _ = Aes128Cbc::::do_decrypt_init(&key, &[0u8; 16]); +//! ``` +//! +//! # Block alignment +//! +//! These types are **strictly block-aligned**: whole blocks in, whole blocks out, no finalization +//! step. SP 800-38A Sec 5.2 requires exactly that of CBC ("the total number of bits in the +//! plaintext must be a multiple of the block size"), and Appendix A puts the formatting of +//! non-aligned data outside the scope of the recommendation. +//! +//! Arbitrary-length data therefore needs a padding layer on top. That layer is *not* in this +//! crate, and at the time of writing is not in the workspace at all -- see +//! [Not yet implemented](#not-yet-implemented). +//! +//! # Memory Usage +//! +//! No heap allocation, and no lookup tables of its own. A mode value is the permutation plus one +//! block of chaining value: +//! +//! ```text +//! size_of::>() == size_of::

() + BLOCK_LEN +//! ``` +//! +//! | Combination | Permutation | Chain | Total | +//! |---|---|---|---| +//! | AES-128 CBC | 176 B | 16 B | 192 B | +//! | AES-192 CBC | 208 B | 16 B | 224 B | +//! | AES-256 CBC | 240 B | 16 B | 256 B | +//! +//! `do_*_blocks_out::` adds nothing; the by-value `do_*_blocks::` adds `N * BLOCK_LEN` of +//! stack for the returned array. [`Encrypting`] and [`Decrypting`] are zero-sized and held in a +//! `PhantomData`, so encoding the direction in the type is free. The table is pinned by +//! `sizes_match_the_documented_memory_table` in `tests/cbc_tests.rs`. +//! +//! # Security Considerations +//! +//! ## CBC is not authenticated +//! +//! CBC provides confidentiality only. It does not detect tampering, and it is malleable in +//! specific, exploitable ways -- SP 800-38A Appendix D: flipping a bit of `Cj` flips the same bit +//! of the decryption of `Cj+1`, and randomises the decryption of `Cj` itself. **Authenticate the +//! ciphertext.** Prefer an AEAD; if you must use CBC, MAC the ciphertext *and* the IV, and verify +//! before decrypting. +//! +//! Combining CBC decryption with a padding check is the classic padding-oracle setup. Do not +//! report padding failures distinguishably, and do not decrypt unauthenticated ciphertext. +//! +//! ## The IV must be unpredictable, and this crate generates it +//! +//! SP 800-38A Sec 5.3 requires that "for the CBC and CFB modes, the IV for any particular execution +//! of the encryption process must be unpredictable" -- not merely unique. Appendix C spells out +//! that "for any given plaintext, it must not be possible to predict the IV that will be associated +//! to the plaintext in advance of the generation of the IV". +//! +//! Rather than accept an IV and hope, [`BlockCipherEncryptor::do_encrypt_init`] generates one from +//! the library's default OS-backed DRBG and returns it. There is deliberately **no** API for +//! supplying your own. Known-answer tests drive [`BlockCipherEncryptor::do_encrypt_init_rng`] with +//! a fixed-output test RNG instead. +//! +//! ## IV integrity +//! +//! Appendix D: "for the CBC mode, the decryption of the first ciphertext block is vulnerable to the +//! (deliberate) introduction of bit errors in specific bit positions of the IV if the integrity of +//! the IV is not protected". A flipped IV bit flips exactly that bit of `P1`. The IV need not be +//! secret, but it must be authenticated along with the ciphertext. +//! +//! ## Key and IV reuse +//! +//! Nothing here stops one key being used for many messages, which is fine for CBC provided each +//! gets a fresh unpredictable IV. It is the IV, not the key, that must not repeat. +//! +//! # Not yet implemented +//! +//! * **Padding.** There is no `Padding` trait, `PKCS7`, `PaddedEncryptor` or `PaddedDecryptor` in +//! this workspace yet, so arbitrary-length CBC is not available. When that layer lands, CBC gets +//! it for free by being wrapped -- no padding logic belongs in this crate. +//! * **CFB** (SP 800-38A Sec 6.3), and the other three modes of the recommendation (ECB, OFB, CTR). +//! * **A CLI subcommand.** `cli/` has no `aes128-cbc-*` command yet. + +#![no_std] +#![forbid(unsafe_code)] +#![forbid(missing_docs)] + +mod cbc; +mod iv; + +pub use cbc::Cbc; + +// Imports needed for docs +#[allow(unused_imports)] +use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation}; +// end of imports needed for docs + +/// Direction marker for a mode that encrypts. See [`Cbc`]. +/// +/// Zero-sized: encoding the direction in the type costs no memory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Encrypting; + +/// Direction marker for a mode that decrypts. See [`Cbc`]. +/// +/// Zero-sized: encoding the direction in the type costs no memory. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Decrypting; diff --git a/crypto/modes/tests/cbc_tests.rs b/crypto/modes/tests/cbc_tests.rs new file mode 100644 index 00000000..b2430103 --- /dev/null +++ b/crypto/modes/tests/cbc_tests.rs @@ -0,0 +1,298 @@ +//! Structural tests for CBC, driven by a toy permutation. +//! +//! These check the properties of the *mode* -- chaining, call sequencing, the pair/remainder split, +//! direction typing, SP 800-38A Appendix D error propagation -- independently of any real cipher. +//! The known-answer tests against SP 800-38A Appendix F.2 are in `sp800_38a_tests.rs`. + +mod common; + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor}; +use bouncycastle_core_test_framework::block_permutation::TestFrameworkBlockPermutation; +use bouncycastle_core_test_framework::symmetric_ciphers::TestFrameworkBlockCipher; +use bouncycastle_modes::{Cbc, Decrypting, Encrypting}; +use common::{SwappedPairToy, TOY_LEN, Toy, toy_key}; + +type ToyCbc

= Cbc; +type SwappedCbc = Cbc; + +// ---- the toy itself, and the mode, against the shared frameworks ------------------------- + +/// The toy must be a real permutation before any conclusion drawn from it is worth anything. +#[test] +fn the_toy_permutation_conforms_to_the_trait() { + TestFrameworkBlockPermutation::new().test::(); +} + +#[test] +fn cbc_conforms_to_the_block_cipher_framework() { + TestFrameworkBlockCipher::new() + .test::, ToyCbc>(); +} + +// ---- chaining and call sequencing -------------------------------------------------------- + +/// Encrypting `n` blocks must not depend on how the calls are grouped, and likewise for +/// decryption. This is the "a sequence of calls is equivalent to one call over the concatenation" +/// contract of the trait, and for CBC it is entirely about the chaining value surviving across +/// calls. +/// +/// The odd groupings matter for decryption specifically: `N = 3` and `N = 5` leave a one-block +/// remainder after the pair loop, and `N = 1` skips the pair loop altogether. +#[test] +fn call_grouping_does_not_change_the_result() { + let key = toy_key(); + let plaintext: [[u8; TOY_LEN]; 8] = + core::array::from_fn(|i| core::array::from_fn(|j| (i * TOY_LEN + j) as u8)); + + // Both encryption runs must use the same IV to be comparable, so pin it with the fixed RNG + // rather than letting `do_encrypt_init` generate a fresh one. + let iv: [u8; TOY_LEN] = core::array::from_fn(|i| 0xF0 ^ (i as u8)); + let pinned_rng = || bouncycastle_core_test_framework::FixedSeedRNG::::new(iv); + + // Reference: all eight blocks in one call. + let (mut enc, got_iv) = + ToyCbc::::do_encrypt_init_rng(&key, &mut pinned_rng()).unwrap(); + assert_eq!(got_iv, iv, "the pinned RNG should reproduce the IV"); + let reference = enc.do_encrypt_blocks(&plaintext).unwrap(); + + // The same eight blocks, grouped every way that exercises a different code path. + let (mut enc, _) = ToyCbc::::do_encrypt_init_rng(&key, &mut pinned_rng()).unwrap(); + let mut got = [[0u8; TOY_LEN]; 8]; + let a = enc.do_encrypt_blocks(&[plaintext[0]]).unwrap(); // N = 1 + let b = enc.do_encrypt_blocks(&[plaintext[1], plaintext[2]]).unwrap(); // N = 2 + let c = enc.do_encrypt_blocks(&[plaintext[3], plaintext[4], plaintext[5]]).unwrap(); // N = 3 + let d = enc.do_encrypt_blocks(&[plaintext[6], plaintext[7]]).unwrap(); // N = 2 + got[0] = a[0]; + got[1..3].copy_from_slice(&b); + got[3..6].copy_from_slice(&c); + got[6..8].copy_from_slice(&d); + + assert_eq!(got, reference, "grouping must not change the ciphertext"); + + // Now the decrypt side: one call vs several groupings, all from the same ciphertext. + let ct = reference; + + let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap(); + let all_at_once = dec.do_decrypt_blocks(&ct).unwrap(); + assert_eq!(all_at_once, plaintext); + + for grouping in [1usize, 2, 4] { + let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap(); + let mut out = [[0u8; TOY_LEN]; 8]; + let mut at = 0; + while at < 8 { + match grouping { + 1 => { + let [p] = dec.do_decrypt_blocks(&[ct[at]]).unwrap(); + out[at] = p; + } + 2 => { + let p = dec.do_decrypt_blocks(&[ct[at], ct[at + 1]]).unwrap(); + out[at..at + 2].copy_from_slice(&p); + } + _ => { + let p = dec + .do_decrypt_blocks(&[ct[at], ct[at + 1], ct[at + 2], ct[at + 3]]) + .unwrap(); + out[at..at + 4].copy_from_slice(&p); + } + } + at += grouping; + } + assert_eq!(out, plaintext, "decrypting in groups of {grouping}"); + } + + // N = 3 and N = 5 both leave a one-block remainder after the pair loop. + let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap(); + let three = dec.do_decrypt_blocks(&[ct[0], ct[1], ct[2]]).unwrap(); + let five = dec.do_decrypt_blocks(&[ct[3], ct[4], ct[5], ct[6], ct[7]]).unwrap(); + assert_eq!(three, [plaintext[0], plaintext[1], plaintext[2]]); + assert_eq!(five, [plaintext[3], plaintext[4], plaintext[5], plaintext[6], plaintext[7]]); +} + +/// The pair path in `do_decrypt_blocks_out` must actually be taken. +/// +/// [`SwappedPairToy`] returns its two pair results in the wrong order while its single-block +/// methods are correct. So a CBC decryptor that uses `decrypt_blocks2` gives the wrong answer for +/// even-length input, and the right answer for a single block. If both came out right, the pair +/// path would be dead code and every claim about it would be untested. +#[test] +fn the_pair_path_is_really_used() { + let key = toy_key(); + let plaintext = [[0xA5u8; TOY_LEN], [0x5Au8; TOY_LEN]]; + + // The correct toy round-trips. + let (mut enc, iv) = ToyCbc::::do_encrypt_init(&key).unwrap(); + let ct = enc.do_encrypt_blocks(&plaintext).unwrap(); + let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap(); + assert_eq!(dec.do_decrypt_blocks(&ct).unwrap(), plaintext); + + // The swapped-pair toy encrypts identically (encryption is serial and never pairs)... + let (mut enc, iv) = SwappedCbc::::do_encrypt_init(&key).unwrap(); + let ct = enc.do_encrypt_blocks(&plaintext).unwrap(); + + // ...but decrypting the pair together must now be wrong, because the pair path is used. + let mut dec = SwappedCbc::::do_decrypt_init(&key, &iv).unwrap(); + assert_ne!( + dec.do_decrypt_blocks(&ct).unwrap(), + plaintext, + "decrypting a pair must go through decrypt_blocks2" + ); + + // Decrypting one block at a time avoids the pair path, so it is correct even for this toy. + let mut dec = SwappedCbc::::do_decrypt_init(&key, &iv).unwrap(); + let [p0] = dec.do_decrypt_blocks(&[ct[0]]).unwrap(); + let [p1] = dec.do_decrypt_blocks(&[ct[1]]).unwrap(); + assert_eq!([p0, p1], plaintext, "the single-block path must not pair"); +} + +/// The `_out` variants must agree with the by-value ones and report the byte count. +#[test] +fn out_variants_agree_with_by_value() { + let key = toy_key(); + let plaintext = [[0x11u8; TOY_LEN], [0x22u8; TOY_LEN], [0x33u8; TOY_LEN]]; + + let (mut enc, iv) = ToyCbc::::do_encrypt_init(&key).unwrap(); + let by_value = enc.do_encrypt_blocks(&plaintext).unwrap(); + + let (mut enc, iv2) = ToyCbc::::do_encrypt_init_rng( + &key, + &mut bouncycastle_core_test_framework::FixedSeedRNG::::new(iv), + ) + .unwrap(); + assert_eq!(iv2, iv, "the pinned RNG should reproduce the IV"); + let mut out = [[0u8; TOY_LEN]; 3]; + let n = enc.do_encrypt_blocks_out(&plaintext, &mut out).unwrap(); + assert_eq!(n, 3 * TOY_LEN); + assert_eq!(out, by_value); + + let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap(); + let mut back = [[0u8; TOY_LEN]; 3]; + let n = dec.do_decrypt_blocks_out(&out, &mut back).unwrap(); + assert_eq!(n, 3 * TOY_LEN); + assert_eq!(back, plaintext); +} + +// ---- SP 800-38A Appendix D error propagation --------------------------------------------- + +/// Appendix D: "In the CBC mode, if bit errors occur in the IV, then the first ciphertext block +/// will be decrypted incorrectly, and bit errors will occur in exactly the same bit positions as +/// in the IV; the decryptions of the other ciphertext blocks are not affected." +/// +/// This is a property of the construction (`P1 = CIPH^-1(C1) XOR IV`), so it holds for any +/// permutation, and getting it wrong would mean the IV is not being XOR-ed where the spec says. +#[test] +fn an_iv_bit_error_flips_exactly_that_bit_of_the_first_block() { + let key = toy_key(); + let plaintext = [[0x00u8; TOY_LEN], [0x11u8; TOY_LEN], [0x22u8; TOY_LEN]]; + + let (mut enc, iv) = ToyCbc::::do_encrypt_init(&key).unwrap(); + let ct = enc.do_encrypt_blocks(&plaintext).unwrap(); + + for byte in 0..TOY_LEN { + for bit in 0..8 { + let mut corrupt_iv = iv; + corrupt_iv[byte] ^= 1 << bit; + + let mut dec = ToyCbc::::do_decrypt_init(&key, &corrupt_iv).unwrap(); + let got = dec.do_decrypt_blocks(&ct).unwrap(); + + let mut expected = plaintext; + expected[0][byte] ^= 1 << bit; + assert_eq!( + got, expected, + "IV byte {byte} bit {bit}: only that bit of P1 should change" + ); + } + } +} + +/// Appendix D, the ciphertext half: bit errors in `Cj` randomise the decryption of `Cj` and flip +/// the same bit positions of `Cj+1`'s decryption, leaving later blocks alone. +#[test] +fn a_ciphertext_bit_error_affects_only_two_blocks() { + let key = toy_key(); + let plaintext = [[0x00u8; TOY_LEN], [0x11u8; TOY_LEN], [0x22u8; TOY_LEN], [0x33u8; TOY_LEN]]; + + let (mut enc, iv) = ToyCbc::::do_encrypt_init(&key).unwrap(); + let ct = enc.do_encrypt_blocks(&plaintext).unwrap(); + + let mut corrupt = ct; + corrupt[1][3] ^= 0b0010_0000; + + let mut dec = ToyCbc::::do_decrypt_init(&key, &iv).unwrap(); + let got = dec.do_decrypt_blocks(&corrupt).unwrap(); + + assert_eq!(got[0], plaintext[0], "P1 depends only on C1 and the IV"); + assert_ne!(got[1], plaintext[1], "P2 comes from the corrupted C2"); + // P3 = CIPH^-1(C3) XOR C2, so the flipped bit of C2 appears verbatim in P3. + let mut expected_p3 = plaintext[2]; + expected_p3[3] ^= 0b0010_0000; + assert_eq!(got[2], expected_p3, "P3 should show the same bit flipped, and nothing else"); + assert_eq!(got[3], plaintext[3], "P4 is unaffected"); +} + +// ---- IV handling ------------------------------------------------------------------------- + +/// Two encryption flows under the same key must not reuse an IV. The framework checks this too; +/// repeated here because for CBC it is the single most important operational requirement. +#[test] +fn each_encryption_gets_a_fresh_iv() { + let key = toy_key(); + let mut seen = std::collections::BTreeSet::new(); + for _ in 0..64 { + let (_, iv) = ToyCbc::::do_encrypt_init(&key).unwrap(); + assert!(seen.insert(iv), "IV repeated across encryptions: {iv:02x?}"); + } +} + +/// Identical plaintext under the same key must give different ciphertext, because the IV differs. +/// This is the property ECB lacks and the reason CBC needs an IV at all. +#[test] +fn identical_plaintext_gives_different_ciphertext() { + let key = toy_key(); + let plaintext = [[0x77u8; TOY_LEN], [0x77u8; TOY_LEN]]; + + let (_, first) = ToyCbc::::encrypt_blocks(&key, &plaintext).unwrap(); + let (_, second) = ToyCbc::::encrypt_blocks(&key, &plaintext).unwrap(); + assert_ne!(first, second); + + // ...and, within one message, two identical plaintext blocks must not give identical + // ciphertext blocks either, because the chaining value differs. + assert_ne!(first[0], first[1], "chaining should break the ECB pattern within a message"); +} + +// ---- key handling ------------------------------------------------------------------------ + +#[test] +fn a_key_of_the_wrong_type_is_rejected() { + let bytes: [u8; TOY_LEN] = core::array::from_fn(|i| (i as u8) + 1); + let seed = KeyMaterial::::from_bytes_as_type(&bytes, KeyType::Seed).unwrap(); + assert!(ToyCbc::::do_encrypt_init(&seed).is_err()); + assert!(ToyCbc::::do_decrypt_init(&seed, &[0u8; TOY_LEN]).is_err()); +} + +// ---- memory ------------------------------------------------------------------------------ + +/// Pins the "Memory Usage" table in the crate docs. +#[test] +fn sizes_match_the_documented_memory_table() { + use core::mem::size_of; + + assert_eq!(size_of::>(), 176 + 16); + assert_eq!(size_of::>(), 208 + 16); + assert_eq!(size_of::>(), 240 + 16); + + // The direction marker is free, and does not change the layout. + assert_eq!( + size_of::>(), + size_of::>() + ); + assert_eq!(size_of::(), 0); + assert_eq!(size_of::(), 0); + + // ...and the general rule the docs state. + assert_eq!(size_of::>(), size_of::() + 16); +} diff --git a/crypto/modes/tests/common/mod.rs b/crypto/modes/tests/common/mod.rs new file mode 100644 index 00000000..fcb52c5b --- /dev/null +++ b/crypto/modes/tests/common/mod.rs @@ -0,0 +1,121 @@ +//! Toy [`BlockPermutation`] implementations, for testing the mode independently of any real cipher. +//! +//! These are **not** cryptography. They exist so the structural properties of a mode -- chaining, +//! sequencing, the pair/remainder split, direction typing -- can be tested without an AES +//! dependency and without a real cipher's vectors getting in the way. The real known-answer tests +//! are in `sp800_38a_tests.rs`. +//! +//! # Why not XOR +//! +//! The obvious toy, `block[i] ^= key[i]`, is its own inverse. That would make `encrypt_block` and +//! `decrypt_block` the same function, which hides exactly the bugs these tests are for: a CBC +//! decryptor that called the forward function, or an encryptor that called the inverse, would still +//! round-trip. [`Toy`] is therefore asymmetric: it rotates before XOR-ing, so the two directions are +//! genuinely different functions. + +use bouncycastle_core::errors::{KeyMaterialError, SymmetricCipherError}; +use bouncycastle_core::key_material::{KeyMaterial, KeyMaterialTrait, KeyType}; +use bouncycastle_core::traits::{BlockCipher, BlockPermutation, SecurityStrength}; + +/// Block and key length of the toy ciphers, chosen to match AES so the tests exercise the same +/// shapes the real thing will. +pub const TOY_LEN: usize = 16; + +/// Shared key validation, so the toys reject the same keys a real permutation would and the +/// framework's key-handling checks are meaningful. +fn validate(key: &dyn KeyMaterialTrait) -> Result<(), SymmetricCipherError> { + if key.key_type() != KeyType::SymmetricCipherKey { + return Err( + KeyMaterialError::InvalidKeyType("toy cipher needs a SymmetricCipherKey").into() + ); + } + if key.key_len() != TOY_LEN { + return Err(KeyMaterialError::InvalidLength.into()); + } + if key.security_strength() < SecurityStrength::_128bit { + return Err(KeyMaterialError::SecurityStrength("toy cipher needs a 128-bit key").into()); + } + Ok(()) +} + +/// An asymmetric toy permutation: `encrypt` is `rotate_left(1)` then XOR with the key byte. +/// +/// A true permutation on each byte, so it is a true permutation on the block, and its inverse is +/// distinctly different code (XOR then `rotate_right(1)`). +pub struct Toy { + key: [u8; TOY_LEN], +} + +impl BlockCipher for Toy { + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl BlockPermutation for Toy { + fn new(key: &KeyMaterial) -> Result { + validate(key)?; + let mut bytes = [0u8; TOY_LEN]; + bytes.copy_from_slice(key.ref_to_bytes()); + Ok(Self { key: bytes }) + } + + fn encrypt_block(&self, block: &mut [u8; TOY_LEN]) { + for (b, k) in block.iter_mut().zip(self.key.iter()) { + *b = b.rotate_left(1) ^ *k; + } + } + + fn decrypt_block(&self, block: &mut [u8; TOY_LEN]) { + for (b, k) in block.iter_mut().zip(self.key.iter()) { + *b = (*b ^ *k).rotate_right(1); + } + } +} + +/// A deliberately broken toy whose pair methods **swap** their two results. +/// +/// Used to prove that the mode really does take the pair path: with this permutation, a CBC +/// decryptor that uses `decrypt_blocks2` must produce something other than the correct plaintext. +/// If a test using this still round-trips, the pair path is dead code and the coverage claimed for +/// it is false. +/// +/// Its single-block methods are identical to [`Toy`]'s, so the two agree on odd-length input. +pub struct SwappedPairToy { + inner: Toy, +} + +impl BlockCipher for SwappedPairToy { + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl BlockPermutation for SwappedPairToy { + fn new(key: &KeyMaterial) -> Result { + Ok(Self { inner: Toy::new(key)? }) + } + + fn encrypt_block(&self, block: &mut [u8; TOY_LEN]) { + self.inner.encrypt_block(block); + } + + fn decrypt_block(&self, block: &mut [u8; TOY_LEN]) { + self.inner.decrypt_block(block); + } + + fn encrypt_blocks2(&self, blocks: &mut [[u8; TOY_LEN]; 2]) { + self.inner.encrypt_block(&mut blocks[0]); + self.inner.encrypt_block(&mut blocks[1]); + blocks.swap(0, 1); + } + + fn decrypt_blocks2(&self, blocks: &mut [[u8; TOY_LEN]; 2]) { + self.inner.decrypt_block(&mut blocks[0]); + self.inner.decrypt_block(&mut blocks[1]); + blocks.swap(0, 1); + } +} + +/// Builds a `KeyMaterial` for the toys from a fixed non-zero pattern. +pub fn toy_key() -> KeyMaterial { + let bytes: [u8; TOY_LEN] = core::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(1)); + KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey) + .expect("a valid toy key") +} diff --git a/crypto/modes/tests/sp800_38a_tests.rs b/crypto/modes/tests/sp800_38a_tests.rs new file mode 100644 index 00000000..9bc24fd2 --- /dev/null +++ b/crypto/modes/tests/sp800_38a_tests.rs @@ -0,0 +1,261 @@ +//! Known-answer tests from NIST SP 800-38A Appendix F.2, "CBC Example Vectors". +//! +//! Sections F.2.1 through F.2.6: CBC-AES128, CBC-AES192 and CBC-AES256, Encrypt and Decrypt. All +//! six share the same IV and the same four plaintext blocks (Appendix F preamble); only the key and +//! the resulting ciphertext differ. The three keys are the same three used by FIPS 197 Appendix A +//! and SP 800-38A F.1, so these vectors also re-check each AES key expansion through a second +//! construction. +//! +//! Transcribed from the published SP 800-38A PDF (2001 edition). +//! +//! # Driving the IV +//! +//! There is no API for supplying an IV -- see the crate docs. Encryption is therefore driven +//! through [`BlockCipherEncryptor::do_encrypt_init_rng`] with a [`FixedSeedRNG`] whose stream is +//! the vector's IV, and the test asserts the returned init data really is that IV before comparing +//! any ciphertext. Decryption takes the IV directly, as init data. + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation}; +use bouncycastle_core_test_framework::FixedSeedRNG; +use bouncycastle_hex as hex; +use bouncycastle_modes::{Cbc, Decrypting, Encrypting}; + +const BLOCK_LEN: usize = 16; + +/// The IV shared by every Appendix F.2 subsection. +const IV: &str = "000102030405060708090a0b0c0d0e0f"; + +/// The four plaintext blocks shared by every Appendix F subsection (Appendix F preamble). +const PLAINTEXTS: [&str; 4] = [ + "6bc1bee22e409f96e93d7e117393172a", + "ae2d8a571e03ac9c9eb76fac45af8e51", + "30c81c46a35ce411e5fbc1191a0a52ef", + "f69f2445df4f9b17ad2b417be66c3710", +]; + +/// F.2.1 / F.2.2 key. +const KEY_128: &str = "2b7e151628aed2a6abf7158809cf4f3c"; +/// F.2.1 CBC-AES128.Encrypt output blocks. +const CIPHERTEXTS_128: [&str; 4] = [ + "7649abac8119b246cee98e9b12e9197d", + "5086cb9b507219ee95db113a917678b2", + "73bed6b8e3c1743b7116e69e22229516", + "3ff1caa1681fac09120eca307586e1a7", +]; + +/// F.2.3 / F.2.4 key. +const KEY_192: &str = "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b"; +/// F.2.3 CBC-AES192.Encrypt output blocks. +const CIPHERTEXTS_192: [&str; 4] = [ + "4f021db243bc633d7178183a9fa071e8", + "b4d9ada9ad7dedf4e5e738763f69145a", + "571b242012fb7ae07fa9baac3df102e0", + "08b0e27988598881d920a9e64f5615cd", +]; + +/// F.2.5 / F.2.6 key. +const KEY_256: &str = "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"; +/// F.2.5 CBC-AES256.Encrypt output blocks. +const CIPHERTEXTS_256: [&str; 4] = [ + "f58c4c04d6e5f1ba779eabfb5f7bfbd6", + "9cfc4e967edb808d679f777bc6702c7d", + "39f23369a9d9bacfa530e26304231461", + "b2eb05e2c39be9fcda6c19078c6a9d1b", +]; + +fn block(hex_str: &str) -> [u8; BLOCK_LEN] { + hex::decode(hex_str).expect("valid hex").try_into().expect("16 bytes") +} + +fn blocks(hex_strs: &[&str; 4]) -> [[u8; BLOCK_LEN]; 4] { + core::array::from_fn(|i| block(hex_strs[i])) +} + +fn key_material(hex_str: &str) -> KeyMaterial { + let bytes = hex::decode(hex_str).expect("valid hex"); + assert_eq!(bytes.len(), N, "key length"); + KeyMaterial::::from_bytes_as_type(&bytes, KeyType::SymmetricCipherKey) + .expect("a valid symmetric cipher key") +} + +/// Runs one Appendix F.2 encrypt subsection. +/// +/// Checks the whole message in one call, then again one block at a time, then again through the +/// `_out` variant -- the vector should not care how the calls are grouped. +fn check_encrypt(section: &str, key_hex: &str, expected: &[&str; 4]) +where + P: BlockPermutation, +{ + let key = key_material::(key_hex); + let iv = block(IV); + let pt = blocks(&PLAINTEXTS); + let ct = blocks(expected); + + // All four blocks in one call. + let (mut enc, got_iv) = Cbc::::do_encrypt_init_rng( + &key, + &mut FixedSeedRNG::::new(iv), + ) + .unwrap(); + assert_eq!(got_iv, iv, "{section}: the pinned RNG should produce the vector's IV"); + assert_eq!(enc.do_encrypt_blocks(&pt).unwrap(), ct, "{section}: four blocks in one call"); + + // One block at a time. + let (mut enc, _) = Cbc::::do_encrypt_init_rng( + &key, + &mut FixedSeedRNG::::new(iv), + ) + .unwrap(); + for (i, (p, c)) in pt.iter().zip(ct.iter()).enumerate() { + let [got] = enc.do_encrypt_blocks(&[*p]).unwrap(); + assert_eq!(&got, c, "{section}: block #{}", i + 1); + } + + // Through the `_out` variant. + let (mut enc, _) = Cbc::::do_encrypt_init_rng( + &key, + &mut FixedSeedRNG::::new(iv), + ) + .unwrap(); + let mut out = [[0u8; BLOCK_LEN]; 4]; + let n = enc.do_encrypt_blocks_out(&pt, &mut out).unwrap(); + assert_eq!(n, 4 * BLOCK_LEN); + assert_eq!(out, ct, "{section}: _out variant"); +} + +/// Runs one Appendix F.2 decrypt subsection. +/// +/// Checks one call, one block at a time, and the odd grouping `3 + 1` -- which is the grouping that +/// leaves a one-block remainder after the pair loop in `do_decrypt_blocks_out`. +fn check_decrypt(section: &str, key_hex: &str, ciphertext: &[&str; 4]) +where + P: BlockPermutation, +{ + let key = key_material::(key_hex); + let iv = block(IV); + let pt = blocks(&PLAINTEXTS); + let ct = blocks(ciphertext); + + type Dec = Cbc; + + // All four blocks in one call (two pairs, no remainder). + let mut dec = Dec::::do_decrypt_init(&key, &iv).unwrap(); + assert_eq!(dec.do_decrypt_blocks(&ct).unwrap(), pt, "{section}: four blocks in one call"); + + // One block at a time (never takes the pair path). + let mut dec = Dec::::do_decrypt_init(&key, &iv).unwrap(); + for (i, (c, p)) in ct.iter().zip(pt.iter()).enumerate() { + let [got] = dec.do_decrypt_blocks(&[*c]).unwrap(); + assert_eq!(&got, p, "{section}: block #{}", i + 1); + } + + // 3 + 1: one pair plus a remainder, then a lone block. + let mut dec = Dec::::do_decrypt_init(&key, &iv).unwrap(); + let three = dec.do_decrypt_blocks(&[ct[0], ct[1], ct[2]]).unwrap(); + let one = dec.do_decrypt_blocks(&[ct[3]]).unwrap(); + assert_eq!(three, [pt[0], pt[1], pt[2]], "{section}: blocks 1-3"); + assert_eq!(one, [pt[3]], "{section}: block 4"); + + // Through the `_out` variant. + let mut dec = Dec::::do_decrypt_init(&key, &iv).unwrap(); + let mut out = [[0u8; BLOCK_LEN]; 4]; + let n = dec.do_decrypt_blocks_out(&ct, &mut out).unwrap(); + assert_eq!(n, 4 * BLOCK_LEN); + assert_eq!(out, pt, "{section}: _out variant"); +} + +#[test] +fn f_2_1_cbc_aes128_encrypt() { + check_encrypt::("F.2.1", KEY_128, &CIPHERTEXTS_128); +} + +#[test] +fn f_2_2_cbc_aes128_decrypt() { + check_decrypt::("F.2.2", KEY_128, &CIPHERTEXTS_128); +} + +#[test] +fn f_2_3_cbc_aes192_encrypt() { + check_encrypt::("F.2.3", KEY_192, &CIPHERTEXTS_192); +} + +#[test] +fn f_2_4_cbc_aes192_decrypt() { + check_decrypt::("F.2.4", KEY_192, &CIPHERTEXTS_192); +} + +#[test] +fn f_2_5_cbc_aes256_encrypt() { + check_encrypt::("F.2.5", KEY_256, &CIPHERTEXTS_256); +} + +#[test] +fn f_2_6_cbc_aes256_decrypt() { + check_decrypt::("F.2.6", KEY_256, &CIPHERTEXTS_256); +} + +/// The one-shot API must agree with the vectors too, on the decrypt side where the IV is an input. +#[test] +fn the_one_shot_api_matches_the_vectors() { + let iv = block(IV); + let pt = blocks(&PLAINTEXTS); + + assert_eq!( + Cbc::::decrypt_blocks( + &key_material::<16>(KEY_128), + &iv, + &blocks(&CIPHERTEXTS_128) + ) + .unwrap(), + pt + ); + assert_eq!( + Cbc::::decrypt_blocks( + &key_material::<24>(KEY_192), + &iv, + &blocks(&CIPHERTEXTS_192) + ) + .unwrap(), + pt + ); + assert_eq!( + Cbc::::decrypt_blocks( + &key_material::<32>(KEY_256), + &iv, + &blocks(&CIPHERTEXTS_256) + ) + .unwrap(), + pt + ); +} + +/// The IV really is what distinguishes CBC from ECB here: the same key and plaintext under the +/// F.1 (ECB) conditions gives the F.1 ciphertext, and under F.2 gives a different one. +/// +/// F.1.1 block #1 for this key is `3ad77bb40d7a3660a89ecaf32466ef97`; F.2.1 block #1 is +/// `7649abac8119b246cee98e9b12e9197d`. They differ solely because CBC XORs the IV in first. +#[test] +fn cbc_differs_from_ecb_by_the_iv() { + let key = key_material::<16>(KEY_128); + let iv = block(IV); + + // The raw permutation on P1 alone is the ECB answer from F.1.1. + let mut ecb = block(PLAINTEXTS[0]); + >::encrypt_block( + &>::new(&key).unwrap(), + &mut ecb, + ); + assert_eq!(ecb, block("3ad77bb40d7a3660a89ecaf32466ef97"), "F.1.1 block #1"); + + // CBC's C1 = CIPH_K(P1 XOR IV) is the F.2.1 answer, and differs. + let (mut enc, _) = Cbc::::do_encrypt_init_rng( + &key, + &mut FixedSeedRNG::<16>::new(iv), + ) + .unwrap(); + let [cbc] = enc.do_encrypt_blocks(&[block(PLAINTEXTS[0])]).unwrap(); + assert_eq!(cbc, block(CIPHERTEXTS_128[0]), "F.2.1 block #1"); + assert_ne!(cbc, ecb); +} diff --git a/src/lib.rs b/src/lib.rs index ca2fb145..fc69d391 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub use bouncycastle_mldsa as mldsa; pub use bouncycastle_mldsa_lowmemory as mldsa_lowmemory; pub use bouncycastle_mlkem as mlkem; pub use bouncycastle_mlkem_lowmemory as mlkem_lowmemory; +pub use bouncycastle_modes as modes; pub use bouncycastle_rng as rng; pub use bouncycastle_sha2 as sha2; pub use bouncycastle_sha3 as sha3; From ccda83adb87262b5e4bacb2ebc08d461769268cd Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Tue, 1 Sep 2026 17:25:58 +0700 Subject: [PATCH 26/28] Added CLI commands for 3 separate CBC modes (#100) --- alpha_0.1.3_release_notes.md | 19 ++- cli/src/aes_cbc_cmd.rs | 323 +++++++++++++++++++++++++++++++++++ cli/src/main.rs | 88 ++++++++++ crypto/modes/src/lib.rs | 15 +- 4 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 cli/src/aes_cbc_cmd.rs diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index d8a1aad1..cefb378d 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -51,7 +51,24 @@ New crate `bouncycastle-modes` (`bouncycastle::modes`): block cipher modes of op pair remainder, and through the `_out` variant. Appendix D error propagation is tested exhaustively for the IV (every one of the 128 bit positions flips exactly its own bit of P1) and for a ciphertext bit error (affects exactly two blocks). -* Ships no CLI subcommand yet, and no CFB -- see the crate docs' "Not yet implemented". +* No CFB yet -- see the crate docs' "Not yet implemented". + +`cli`: three new subcommands, `aes128-cbc`, `aes192-cbc` and `aes256-cbc`, each taking `encrypt` or +`decrypt` and streaming stdin to stdout in 1 KiB chunks. + +* Key from `--key` (hex) or `--key-file` (binary or hex), with the usual note that secrets on the + command line end up in shell history. The key length must match the variant exactly. +* **The IV travels in the ciphertext**: since there is no API for supplying one, `encrypt` writes + the generated IV as the first 16 bytes of its output and `decrypt` reads it back from the first + 16 bytes of its input, so `encrypt | decrypt` composes with no `--iv` flag anywhere. The IV need + not be secret (SP 800-38A Sec 5.3), so this is sound. +* Input must be a whole number of 16-byte blocks. Unaligned input is rejected with a message + pointing at the missing padding layer rather than being silently padded. +* Reads do not respect block boundaries, so a block split across two reads is carried over; + verified by round-tripping 64 KiB through `dd bs=3`. +* Verified against SP 800-38A F.2: prepending the spec's IV to the spec's ciphertext and running + `decrypt` reproduces the spec's plaintext for all three key lengths. The `encrypt` direction was + cross-checked against an independent CBC implementation under the IV the CLI generated. `core`: new `BlockPermutation` trait (`crypto/core/src/traits.rs`), the raw keyed permutation -- `CIPH_K` / `CIPH^-1_K` of SP 800-38A Sec 5.1 -- that a mode is built on. diff --git a/cli/src/aes_cbc_cmd.rs b/cli/src/aes_cbc_cmd.rs new file mode 100644 index 00000000..40727c85 --- /dev/null +++ b/cli/src/aes_cbc_cmd.rs @@ -0,0 +1,323 @@ +//! AES-CBC encryption and decryption, streaming stdin to stdout. +//! +//! # The IV travels in the ciphertext +//! +//! There is no `--iv` flag, and that is deliberate: `bouncycastle-modes` has no API for a +//! caller-supplied IV, because NIST SP 800-38A Sec 5.3 requires the CBC IV to be *unpredictable* +//! rather than merely unique. `encrypt` therefore generates one from the OS-backed DRBG and writes +//! it as the **first block of the output**; `decrypt` reads it back from the **first block of the +//! input**. So the two compose directly: +//! +//! ```text +//! bc-rust aes128-cbc encrypt --key-file k.bin < plain.bin > cipher.bin +//! bc-rust aes128-cbc decrypt --key-file k.bin < cipher.bin > plain.bin +//! ``` +//! +//! The IV is not secret (Sec 5.3), so shipping it in the clear is correct. Its *integrity* is not +//! protected, and neither is the ciphertext's -- see the warning below. +//! +//! # Input must be block-aligned +//! +//! CBC is defined only on whole blocks (SP 800-38A Sec 5.2), and this workspace has no padding +//! layer yet, so input that is not a multiple of 16 bytes is rejected rather than silently padded. +//! Padding is the caller's business until `PaddedEncryptor`/`PaddedDecryptor` land. +//! +//! # Binary in, binary out +//! +//! stdin is read as binary so the commands compose in a pipeline. `-x` renders the *output* as hex. +//! For hex input, pipe through `hex-decode` first: +//! +//! ```text +//! cat cipher.hex | bc-rust hex-decode | bc-rust aes256-cbc decrypt --key-file k.bin +//! ``` + +use crate::helpers::write_bytes_or_hex; +use bouncycastle::aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle::core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, +}; +use bouncycastle::core::traits::{ + BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength, +}; +use bouncycastle::hex; +use bouncycastle::modes::{Cbc, Decrypting, Encrypting}; +use clap::ValueEnum; +use std::io::{Read, Write}; +use std::process::exit; +use std::{fs, io}; + +/// The AES block length in bytes. +const BLOCK_LEN: usize = 16; + +/// Blocks processed per call: 64 blocks = 1 KiB, matching the other streaming commands. +/// +/// A whole chunk goes through `do_*_blocks[_out]::` in one call, which for decryption +/// means 32 pairs down the `decrypt_blocks2` path. The at-most-63-block tail at end of input is +/// flushed one block at a time; it is bounded, so its cost does not scale with the input. +const CHUNK_BLOCKS: usize = 64; + +#[derive(ValueEnum, Clone, Debug)] +pub(crate) enum AESCBCAction { + /// Encrypt stdin to stdout under CBC mode. + /// A freshly generated IV is written as the first 16 bytes of the output, so that `decrypt` + /// can read it back. Input length must be a multiple of 16 bytes. + Encrypt, + /// Decrypt stdin to stdout under CBC mode. + /// The first 16 bytes of input are taken as the IV, as written by `encrypt`. The remaining + /// length must be a multiple of 16 bytes. + Decrypt, +} + +pub(crate) fn aes128_cbc_cmd( + action: &AESCBCAction, + key: &Option, + key_file: &Option, + output_hex: bool, +) { + let key = load_key::<16>(key, key_file, "AES-128"); + match action { + AESCBCAction::Encrypt => encrypt_stream::(&key, output_hex), + AESCBCAction::Decrypt => decrypt_stream::(&key, output_hex), + } +} + +pub(crate) fn aes192_cbc_cmd( + action: &AESCBCAction, + key: &Option, + key_file: &Option, + output_hex: bool, +) { + let key = load_key::<24>(key, key_file, "AES-192"); + match action { + AESCBCAction::Encrypt => encrypt_stream::(&key, output_hex), + AESCBCAction::Decrypt => decrypt_stream::(&key, output_hex), + } +} + +pub(crate) fn aes256_cbc_cmd( + action: &AESCBCAction, + key: &Option, + key_file: &Option, + output_hex: bool, +) { + let key = load_key::<32>(key, key_file, "AES-256"); + match action { + AESCBCAction::Encrypt => encrypt_stream::(&key, output_hex), + AESCBCAction::Decrypt => decrypt_stream::(&key, output_hex), + } +} + +/// Loads the key from `--key` (hex) or `--key-file` (binary or hex), and checks its length. +/// +/// `KEY_LEN` is exact: AES has three key lengths and the command selects one, so a key of the +/// wrong length is a mistake rather than something to truncate or pad. +fn load_key( + key: &Option, + key_file: &Option, + alg: &str, +) -> KeyMaterial { + let key_bytes: Vec = if let Some(key_file) = key_file { + // A file may hold raw bytes or hex; try hex first, as the other commands do. + let raw = fs::read(key_file).unwrap_or_else(|e| { + eprintln!("Error: couldn't read key file '{key_file}': {e}"); + exit(-1); + }); + match hex::decode(&raw) { + Ok(decoded) => decoded, + Err(_) => raw, + } + } else if let Some(key) = key { + hex::decode(key).unwrap_or_else(|_| { + eprintln!("Error: `--key` must be hex. Use `--key-file` for raw bytes."); + exit(-1); + }) + } else { + eprintln!("Error: either `--key` or `--key-file` must be supplied."); + exit(-1); + }; + + if key_bytes.len() != KEY_LEN { + eprintln!("Error: {alg} needs a {KEY_LEN}-byte key, got {} bytes.", key_bytes.len()); + exit(-1); + } + + // `from_bytes_as_type` tags the key at the strength its length implies, which is exactly what + // the engine requires -- except for an all-zero key, which it marks Zeroized instead. + let mut key = + KeyMaterial::::from_bytes_as_type(&key_bytes, KeyType::SymmetricCipherKey) + .unwrap_or_else(|e| { + eprintln!("Error: couldn't load the key: {e:?}"); + exit(-1); + }); + + if key.key_type() != KeyType::SymmetricCipherKey { + // Same stance as `helpers::parse_seed`: warn, then do what was asked. A CLI is used for + // test vectors and scripting, where an all-zero key is a legitimate thing to want. + eprintln!( + "Warning: all-zero (or otherwise zeroized) key provided. Proceeding, but this is not secure." + ); + do_hazardous_operations(&mut key, |key| { + key.set_key_type(KeyType::SymmetricCipherKey)?; + key.set_security_strength(SecurityStrength::from_bytes(KEY_LEN)) + }) + .unwrap_or_else(|e| { + eprintln!("Error: couldn't tag the key: {e:?}"); + exit(-1); + }); + } + + key +} + +/// Encrypts stdin to stdout, writing the generated IV first. +fn encrypt_stream(key: &KeyMaterial, output_hex: bool) +where + P: BlockPermutation, +{ + let (mut enc, iv) = Cbc::::do_encrypt_init(key) + .unwrap_or_else(|e| { + eprintln!("Error: couldn't start encryption: {e:?}"); + exit(-1); + }); + + // The IV goes out ahead of the ciphertext, so `decrypt` can pick it up. + write_bytes_or_hex(&iv, output_hex); + + let mut out = [[0u8; BLOCK_LEN]; CHUNK_BLOCKS]; + + stream_blocks(|blocks| match <&[[u8; BLOCK_LEN]; CHUNK_BLOCKS]>::try_from(blocks) { + Ok(full_chunk) => { + // Cannot fail: the mode's block methods are infallible for a constructed value. + enc.do_encrypt_blocks_out(full_chunk, &mut out).unwrap(); + write_blocks(&out, output_hex); + } + Err(_) => { + // The bounded tail at end of input. + for block in blocks.iter() { + let [c] = enc.do_encrypt_blocks(&[*block]).unwrap(); + write_bytes_or_hex(&c, output_hex); + } + } + }); + + finish(output_hex); +} + +/// Decrypts stdin to stdout, taking the IV from the first block of input. +fn decrypt_stream(key: &KeyMaterial, output_hex: bool) +where + P: BlockPermutation, +{ + // The leading block is the IV, not ciphertext. + let mut iv = [0u8; BLOCK_LEN]; + if let Err(e) = io::stdin().read_exact(&mut iv) { + eprintln!( + "Error: input too short to contain the {BLOCK_LEN}-byte IV that `encrypt` writes \ + as its first block ({e})." + ); + exit(-1); + } + + let mut dec = Cbc::::do_decrypt_init(key, &iv) + .unwrap_or_else(|e| { + eprintln!("Error: couldn't start decryption: {e:?}"); + exit(-1); + }); + + let mut out = [[0u8; BLOCK_LEN]; CHUNK_BLOCKS]; + + stream_blocks(|blocks| match <&[[u8; BLOCK_LEN]; CHUNK_BLOCKS]>::try_from(blocks) { + Ok(full_chunk) => { + // A full chunk is 32 pairs, so this is the `decrypt_blocks2` path. + dec.do_decrypt_blocks_out(full_chunk, &mut out).unwrap(); + write_blocks(&out, output_hex); + } + Err(_) => { + for block in blocks.iter() { + let [p] = dec.do_decrypt_blocks(&[*block]).unwrap(); + write_bytes_or_hex(&p, output_hex); + } + } + }); + + finish(output_hex); +} + +/// Reads stdin a block at a time, calling `process` with a full `CHUNK_BLOCKS` slice whenever one +/// is available and once more at end of input with whatever whole blocks remain. +/// +/// `process` therefore sees a slice of exactly `CHUNK_BLOCKS` for every call but the last, which is +/// how the callers can hand a fixed-size array to `do_*_blocks_out::` and fall back +/// to single blocks only for the bounded tail. +/// +/// Reads do not respect block boundaries, so a block can arrive split across two reads; the +/// partial block is carried over rather than assumed complete. Input whose total length is not a +/// multiple of `BLOCK_LEN` is an error, because CBC is not defined on a partial block and there is +/// no padding layer to appeal to. +fn stream_blocks(mut process: impl FnMut(&[[u8; BLOCK_LEN]])) { + let mut staged = [[0u8; BLOCK_LEN]; CHUNK_BLOCKS]; + let mut read_buf = [0u8; BLOCK_LEN * CHUNK_BLOCKS]; + let mut partial = [0u8; BLOCK_LEN]; + let mut partial_len = 0usize; + let mut blocks = 0usize; + + loop { + let n = io::stdin().read(&mut read_buf).unwrap_or_else(|e| { + eprintln!("Error: failed to read from stdin: {e}"); + exit(-1); + }); + if n == 0 { + break; + } + + let mut src = &read_buf[..n]; + while !src.is_empty() { + let take = core::cmp::min(BLOCK_LEN - partial_len, src.len()); + partial[partial_len..partial_len + take].copy_from_slice(&src[..take]); + partial_len += take; + src = &src[take..]; + + if partial_len == BLOCK_LEN { + staged[blocks] = partial; + blocks += 1; + partial_len = 0; + + if blocks == CHUNK_BLOCKS { + process(&staged); + blocks = 0; + } + } + } + } + + if partial_len != 0 { + eprintln!( + "Error: input is not a whole number of {BLOCK_LEN}-byte blocks ({partial_len} \ + trailing byte(s)). CBC is defined only on whole blocks (SP 800-38A Sec 5.2), and \ + this build has no padding layer, so the input must be padded by the caller." + ); + exit(-1); + } + + if blocks != 0 { + process(&staged[..blocks]); + } +} + +/// Writes a run of whole blocks. +fn write_blocks(blocks: &[[u8; BLOCK_LEN]], output_hex: bool) { + for block in blocks.iter() { + write_bytes_or_hex(block, output_hex); + } +} + +/// Flushes stdout, and adds the trailing newline the hex-output commands all emit. +fn finish(output_hex: bool) { + if output_hex { + println!(); + } + io::stdout().flush().unwrap_or_else(|e| { + eprintln!("Error: failed to flush stdout: {e}"); + exit(-1); + }); +} diff --git a/cli/src/main.rs b/cli/src/main.rs index 45205bff..f86aec90 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,3 +1,4 @@ +mod aes_cbc_cmd; mod encoders_cmd; mod helpers; mod hkdf_cmd; @@ -8,6 +9,7 @@ mod rng_cmd; mod sha2_cmd; mod sha3_cmd; +use crate::aes_cbc_cmd::AESCBCAction; use crate::mac_cmd::HMACVariant; use crate::mldsa_cmd::MLDSAAction; use clap::{Parser, Subcommand}; @@ -271,6 +273,83 @@ enum Subcommands { x: bool, }, + /// AES-128 in CBC mode (NIST SP 800-38A Sec 6.2), streaming stdin to stdout. + /// + /// On `encrypt`, a fresh unpredictable IV is generated and written as the FIRST 16 BYTES of + /// the output; on `decrypt` it is read back from the first 16 bytes of the input, so the two + /// compose directly in a pipeline. There is deliberately no `--iv` flag. + /// + /// Input must be a whole number of 16-byte blocks: CBC is defined only on whole blocks and + /// this build has no padding layer, so unaligned input is rejected rather than padded. + /// + /// WARNING: CBC provides confidentiality only. It does not detect tampering, and neither the + /// ciphertext nor the IV is authenticated. Do not decrypt data you have not authenticated + /// separately. + /// + /// Note: in production uses, secrets should not be passed on the command-line because they get + /// logged in shell history. Use the file-based input instead. + AES128_CBC { + action: AESCBCAction, + + /// The 16-byte AES key in hex. + /// The `key_file` option is preferred to avoid leaving key material in command history. + #[arg(long)] + key: Option, + + /// A file containing the 16-byte AES key, in binary or hex. + /// If both key and key_file options are provided, the file will be used. + #[arg(short, long)] + key_file: Option, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, + + /// AES-192 in CBC mode (NIST SP 800-38A Sec 6.2), streaming stdin to stdout. + /// + /// See `aes128-cbc` for the IV convention, block-alignment requirement and warnings; only the + /// key length differs. + AES192_CBC { + action: AESCBCAction, + + /// The 24-byte AES key in hex. + /// The `key_file` option is preferred to avoid leaving key material in command history. + #[arg(long)] + key: Option, + + /// A file containing the 24-byte AES key, in binary or hex. + /// If both key and key_file options are provided, the file will be used. + #[arg(short, long)] + key_file: Option, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, + + /// AES-256 in CBC mode (NIST SP 800-38A Sec 6.2), streaming stdin to stdout. + /// + /// See `aes128-cbc` for the IV convention, block-alignment requirement and warnings; only the + /// key length differs. + AES256_CBC { + action: AESCBCAction, + + /// The 32-byte AES key in hex. + /// The `key_file` option is preferred to avoid leaving key material in command history. + #[arg(long)] + key: Option, + + /// A file containing the 32-byte AES key, in binary or hex. + /// If both key and key_file options are provided, the file will be used. + #[arg(short, long)] + key_file: Option, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, + /// The ML-KEM-512 key encapsulation algorithm. MLKEM512 { action: mlkem_cmd::MLKEMAction, @@ -564,6 +643,15 @@ fn main() { *len, *x, ), Some(Subcommands::RNG { len, x }) => rng_cmd::rng_cmd(*len, *x), + Some(Subcommands::AES128_CBC { action, key, key_file, x }) => { + aes_cbc_cmd::aes128_cbc_cmd(action, key, key_file, *x); + } + Some(Subcommands::AES192_CBC { action, key, key_file, x }) => { + aes_cbc_cmd::aes192_cbc_cmd(action, key, key_file, *x); + } + Some(Subcommands::AES256_CBC { action, key, key_file, x }) => { + aes_cbc_cmd::aes256_cbc_cmd(action, key, key_file, *x); + } Some(Subcommands::MLKEM512 { action, skfile, pkfile, ctfile, x }) => { mlkem_cmd::mlkem512_cmd(action, skfile, pkfile, ctfile, *x); } diff --git a/crypto/modes/src/lib.rs b/crypto/modes/src/lib.rs index ca639658..a25468aa 100644 --- a/crypto/modes/src/lib.rs +++ b/crypto/modes/src/lib.rs @@ -158,7 +158,20 @@ //! this workspace yet, so arbitrary-length CBC is not available. When that layer lands, CBC gets //! it for free by being wrapped -- no padding logic belongs in this crate. //! * **CFB** (SP 800-38A Sec 6.3), and the other three modes of the recommendation (ECB, OFB, CTR). -//! * **A CLI subcommand.** `cli/` has no `aes128-cbc-*` command yet. +//! +//! # Command line +//! +//! The `bc-rust` CLI exposes CBC as `aes128-cbc`, `aes192-cbc` and `aes256-cbc`, each taking +//! `encrypt` or `decrypt` and streaming stdin to stdout. Because there is no API for a +//! caller-supplied IV, `encrypt` writes the generated IV as the first block of its output and +//! `decrypt` reads it back from the first block of its input, so the two compose: +//! +//! ```text +//! bc-rust aes256-cbc encrypt --key-file k.bin < plain.bin > cipher.bin +//! bc-rust aes256-cbc decrypt --key-file k.bin < cipher.bin | cmp - plain.bin +//! ``` +//! +//! Input must be block-aligned there too, for the reason given above. #![no_std] #![forbid(unsafe_code)] From 70706e71f9a3468511c63d277b343c8a9d74ced4 Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Tue, 1 Sep 2026 20:12:50 +0700 Subject: [PATCH 27/28] Linked different CLI commands to CBC test ectors, corrected wrong branch name (#100) --- alpha_0.1.3_release_notes.md | 11 + cli/tests/aes_cbc_cli_tests.rs | 362 +++++++++++++++++++++++ crypto/aes-lowmemory/summary.md | 16 +- crypto/aes-lowmemory/tests/acvp_tests.rs | 20 +- crypto/core-test-framework/summary.md | 4 +- crypto/modes/Cargo.toml | 1 + crypto/modes/tests/acvp_tests.rs | 303 +++++++++++++++++++ 7 files changed, 711 insertions(+), 6 deletions(-) create mode 100644 cli/tests/aes_cbc_cli_tests.rs create mode 100644 crypto/modes/tests/acvp_tests.rs diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index cefb378d..e7a2dc18 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -51,6 +51,13 @@ New crate `bouncycastle-modes` (`bouncycastle::modes`): block cipher modes of op pair remainder, and through the `_out` variant. Appendix D error propagation is tested exhaustively for the IV (every one of the 128 bit positions flips exactly its own bit of P1) and for a ciphertext bit error (affects exactly two blocks). +* Also verified against the **2150 NIST ACVP `ACVP-AES-CBC` AFT cases** from `bc-test-data` (all + three key lengths, both directions, 60 of them spanning 2-10 blocks). Each case is run twice -- + block by block, and in pairs with a one-block remainder -- so the `decrypt_blocks2` path is + exercised against real vectors, not only against the toy permutation. Unlike the ECB response + file, the CBC one carries only the answer against a `tcId`, so the request and response files are + joined; the 6 MCT groups are skipped and the count reported. These vectors were already in + `bc-test-data` and previously unused. * No CFB yet -- see the crate docs' "Not yet implemented". `cli`: three new subcommands, `aes128-cbc`, `aes192-cbc` and `aes256-cbc`, each taking `encrypt` or @@ -69,6 +76,10 @@ New crate `bouncycastle-modes` (`bouncycastle::modes`): block cipher modes of op * Verified against SP 800-38A F.2: prepending the spec's IV to the spec's ciphertext and running `decrypt` reproduces the spec's plaintext for all three key lengths. The `encrypt` direction was cross-checked against an independent CBC implementation under the IV the CLI generated. +* `cli/tests/aes_cbc_cli_tests.rs` (16 tests) drives the built binary as a subprocess via + `CARGO_BIN_EXE_bc-rust`, so all of the above is asserted by `cargo test` rather than by hand: + the F.2 vectors, round trips across the chunk boundary, a fresh IV per invocation, hex/binary + agreement, `--key-file` in both hex and binary, and every error path with its message. `core`: new `BlockPermutation` trait (`crypto/core/src/traits.rs`), the raw keyed permutation -- `CIPH_K` / `CIPH^-1_K` of SP 800-38A Sec 5.1 -- that a mode is built on. diff --git a/cli/tests/aes_cbc_cli_tests.rs b/cli/tests/aes_cbc_cli_tests.rs new file mode 100644 index 00000000..9dcea30c --- /dev/null +++ b/cli/tests/aes_cbc_cli_tests.rs @@ -0,0 +1,362 @@ +//! Tests for the `aes128-cbc` / `aes192-cbc` / `aes256-cbc` subcommands. +//! +//! These drive the built `bc-rust` binary as a subprocess, because the behaviour worth testing is +//! the command-line contract itself -- the IV riding in the first block, block-alignment +//! enforcement, exit codes, key loading -- none of which is reachable from the library API. +//! +//! `CARGO_BIN_EXE_bc-rust` is set by cargo for integration tests and points at the binary for the +//! current profile, so there is nothing to build or locate by hand. + +use std::io::Write; +use std::process::{Command, Output, Stdio}; + +/// The path to the binary under test, resolved by cargo. +const BC_RUST: &str = env!("CARGO_BIN_EXE_bc-rust"); + +/// SP 800-38A Appendix F IV, shared by every F.2 subsection. +const IV: &str = "000102030405060708090a0b0c0d0e0f"; + +/// The four SP 800-38A Appendix F plaintext blocks. +const PLAINTEXT: &str = concat!( + "6bc1bee22e409f96e93d7e117393172a", + "ae2d8a571e03ac9c9eb76fac45af8e51", + "30c81c46a35ce411e5fbc1191a0a52ef", + "f69f2445df4f9b17ad2b417be66c3710", +); + +const KEY_128: &str = "2b7e151628aed2a6abf7158809cf4f3c"; +const KEY_192: &str = "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b"; +const KEY_256: &str = "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"; + +/// F.2.1 CBC-AES128.Encrypt ciphertext. +const CT_128: &str = concat!( + "7649abac8119b246cee98e9b12e9197d", + "5086cb9b507219ee95db113a917678b2", + "73bed6b8e3c1743b7116e69e22229516", + "3ff1caa1681fac09120eca307586e1a7", +); +/// F.2.3 CBC-AES192.Encrypt ciphertext. +const CT_192: &str = concat!( + "4f021db243bc633d7178183a9fa071e8", + "b4d9ada9ad7dedf4e5e738763f69145a", + "571b242012fb7ae07fa9baac3df102e0", + "08b0e27988598881d920a9e64f5615cd", +); +/// F.2.5 CBC-AES256.Encrypt ciphertext. +const CT_256: &str = concat!( + "f58c4c04d6e5f1ba779eabfb5f7bfbd6", + "9cfc4e967edb808d679f777bc6702c7d", + "39f23369a9d9bacfa530e26304231461", + "b2eb05e2c39be9fcda6c19078c6a9d1b", +); + +/// Runs `bc-rust ` with `stdin_bytes` on stdin and returns the completed output. +fn run(args: &[&str], stdin_bytes: &[u8]) -> Output { + let mut child = Command::new(BC_RUST) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn bc-rust"); + + child + .stdin + .as_mut() + .expect("stdin piped") + .write_all(stdin_bytes) + .expect("failed to write to stdin"); + + child.wait_with_output().expect("failed to wait for bc-rust") +} + +/// Runs a command that is expected to succeed, returning stdout. +fn run_ok(args: &[&str], stdin_bytes: &[u8]) -> Vec { + let out = run(args, stdin_bytes); + assert!( + out.status.success(), + "expected success from {args:?}, got {:?}\nstderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + out.stdout +} + +/// Runs a command that is expected to fail, returning stderr as a string. +fn run_err(args: &[&str], stdin_bytes: &[u8]) -> String { + let out = run(args, stdin_bytes); + assert!( + !out.status.success(), + "expected failure from {args:?}, but it succeeded\nstdout: {:?}", + String::from_utf8_lossy(&out.stdout) + ); + String::from_utf8_lossy(&out.stderr).into_owned() +} + +fn unhex(s: &str) -> Vec { + assert!(s.len().is_multiple_of(2), "hex string must have even length"); + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex")) + .collect() +} + +fn tohex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Deterministic pseudo-random bytes, so the tests do not depend on an RNG or on `/dev/urandom`. +fn pseudo_random(len: usize, seed: u32) -> Vec { + let mut state = seed.wrapping_mul(2_654_435_761).wrapping_add(1); + (0..len) + .map(|_| { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + (state >> 24) as u8 + }) + .collect() +} + +// ---- the SP 800-38A F.2 vectors, through the CLI ----------------------------------------- + +/// `decrypt` reproduces the spec plaintext when handed the spec's IV followed by the spec's +/// ciphertext. +/// +/// This is the direction that can be pinned exactly: `encrypt` picks its own IV, so it cannot be +/// asked to reproduce a published ciphertext. `encrypt` is covered by the round-trip tests below +/// and, at the library level, by `crypto/modes/tests/sp800_38a_tests.rs`. +#[test] +fn decrypt_matches_sp800_38a_f2_vectors() { + for (cmd, key, ct) in [ + ("aes128-cbc", KEY_128, CT_128), + ("aes192-cbc", KEY_192, CT_192), + ("aes256-cbc", KEY_256, CT_256), + ] { + // The CLI expects the IV as the first block of its input, which is exactly how `encrypt` + // emits it. + let input = unhex(&format!("{IV}{ct}")); + let out = run_ok(&[cmd, "decrypt", "--key", key], &input); + assert_eq!( + tohex(&out), + PLAINTEXT, + "{cmd} decrypt should reproduce the Appendix F.2 plaintext" + ); + } +} + +/// The same, with `-x`, which should give the identical answer in hex plus a trailing newline. +#[test] +fn hex_output_matches_binary_output() { + let input = unhex(&format!("{IV}{CT_128}")); + let binary = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &input); + let hex_out = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128, "-x"], &input); + + let hex_str = String::from_utf8(hex_out).expect("hex output is text"); + assert_eq!(hex_str.trim_end(), tohex(&binary)); + assert_eq!(hex_str.trim_end(), PLAINTEXT); +} + +// ---- round trips ------------------------------------------------------------------------ + +/// `encrypt | decrypt` recovers the input, for all three key lengths. +/// +/// Also checks the output length: the ciphertext is one block longer than the plaintext, because +/// the IV is prepended. +#[test] +fn encrypt_then_decrypt_round_trips() { + for (cmd, key) in [("aes128-cbc", KEY_128), ("aes192-cbc", KEY_192), ("aes256-cbc", KEY_256)] { + let plaintext = unhex(PLAINTEXT); + let ciphertext = run_ok(&[cmd, "encrypt", "--key", key], &plaintext); + assert_eq!( + ciphertext.len(), + plaintext.len() + 16, + "{cmd}: output should be the 16-byte IV plus the ciphertext" + ); + + let recovered = run_ok(&[cmd, "decrypt", "--key", key], &ciphertext); + assert_eq!(recovered, plaintext, "{cmd}: round trip"); + } +} + +/// Round trips at sizes that straddle the 1 KiB streaming chunk and the block boundary. +/// +/// 1024 is exactly one chunk; 1040 is a chunk plus one block, which exercises the tail path; 4112 +/// is four chunks plus a block; 65536 is many chunks. +#[test] +fn round_trips_across_chunk_boundaries() { + for size in [16usize, 32, 1024, 1040, 4096, 4112, 65536] { + let plaintext = pseudo_random(size, size as u32); + let ciphertext = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext); + let recovered = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &ciphertext); + assert_eq!(recovered, plaintext, "{size} bytes should round trip"); + } +} + +/// A fresh IV per invocation, so the same plaintext under the same key gives different output. +/// +/// This is the operational requirement CBC lives or dies by, and the CLI is where it is easiest to +/// get wrong (e.g. by seeding from a fixed value). +#[test] +fn each_invocation_uses_a_fresh_iv() { + let plaintext = unhex(PLAINTEXT); + let mut seen = std::collections::BTreeSet::new(); + + for _ in 0..8 { + let ciphertext = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext); + let iv = ciphertext[..16].to_vec(); + assert!(seen.insert(iv), "the CLI reused an IV across invocations"); + // ...and the body differs too, not just the IV. + let recovered = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &ciphertext); + assert_eq!(recovered, plaintext); + } +} + +// ---- key handling ----------------------------------------------------------------------- + +/// `--key-file` accepts both a hex file and a raw binary file, and agrees with `--key`. +#[test] +fn key_file_accepts_hex_and_binary() { + let dir = std::env::temp_dir().join(format!("bc_rust_cli_key_{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("create temp dir"); + + let hex_path = dir.join("key.hex"); + let bin_path = dir.join("key.bin"); + std::fs::write(&hex_path, KEY_128).expect("write hex key"); + std::fs::write(&bin_path, unhex(KEY_128)).expect("write binary key"); + + let input = unhex(&format!("{IV}{CT_128}")); + let expected = unhex(PLAINTEXT); + + for path in [&hex_path, &bin_path] { + let out = run_ok(&["aes128-cbc", "decrypt", "--key-file", path.to_str().unwrap()], &input); + assert_eq!(out, expected, "--key-file {path:?}"); + } + + std::fs::remove_dir_all(&dir).ok(); +} + +/// A key of the wrong length for the chosen variant is rejected, naming both lengths. +#[test] +fn a_key_of_the_wrong_length_is_rejected() { + let stderr = run_err(&["aes256-cbc", "encrypt", "--key", KEY_128], &unhex(PLAINTEXT)); + assert!(stderr.contains("32-byte key"), "stderr should name the expected length: {stderr}"); + assert!(stderr.contains("16 bytes"), "stderr should name the supplied length: {stderr}"); +} + +/// Omitting the key entirely is an error, not a default. +#[test] +fn a_missing_key_is_rejected() { + let stderr = run_err(&["aes128-cbc", "encrypt"], &unhex(PLAINTEXT)); + assert!(stderr.contains("--key"), "stderr should mention the key options: {stderr}"); +} + +/// An all-zero key warns but proceeds, matching `helpers::parse_seed`'s stance. NIST publishes +/// all-zero-key vectors, so refusing outright would make some of them untestable from the CLI. +#[test] +fn an_all_zero_key_warns_but_proceeds() { + let zero_key = "0".repeat(32); + let out = run(&["aes128-cbc", "encrypt", "--key", &zero_key], &unhex(PLAINTEXT)); + assert!(out.status.success(), "an all-zero key should still work"); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(stderr.to_lowercase().contains("warning"), "an all-zero key should warn: {stderr}"); + assert_eq!(out.stdout.len(), 16 + 64, "IV plus four ciphertext blocks"); +} + +// ---- block alignment and framing -------------------------------------------------------- + +/// Input that is not a whole number of blocks is rejected, with a message that explains why +/// rather than just failing. CBC has no answer for a partial block and there is no padding layer. +#[test] +fn unaligned_input_is_rejected_with_an_explanation() { + for extra in [1usize, 7, 15] { + let plaintext = pseudo_random(32 + extra, extra as u32); + let stderr = run_err(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext); + assert!( + stderr.contains("whole number of 16-byte blocks"), + "stderr should explain the alignment requirement: {stderr}" + ); + assert!( + stderr.contains("padding"), + "stderr should point at the missing padding layer: {stderr}" + ); + } +} + +/// Decrypt input shorter than the IV it must start with is rejected, and says so. +#[test] +fn decrypt_input_shorter_than_the_iv_is_rejected() { + for len in [0usize, 1, 15] { + let stderr = run_err(&["aes128-cbc", "decrypt", "--key", KEY_128], &pseudo_random(len, 1)); + assert!( + stderr.contains("IV"), + "stderr should explain the missing IV (len {len}): {stderr}" + ); + } +} + +/// Decrypt input that carries the IV but then an unaligned body is rejected too. +#[test] +fn decrypt_rejects_an_unaligned_body() { + let mut input = unhex(IV); + input.extend_from_slice(&pseudo_random(20, 3)); // 20 is not a multiple of 16 + let stderr = run_err(&["aes128-cbc", "decrypt", "--key", KEY_128], &input); + assert!( + stderr.contains("whole number of 16-byte blocks"), + "stderr should explain the alignment requirement: {stderr}" + ); +} + +/// Empty input to `encrypt` produces just the IV: zero blocks in, zero blocks out. +/// +/// Worth pinning because it is the one input length that is block-aligned but has no blocks, and +/// it is easy for a streaming loop to mishandle. +#[test] +fn empty_input_produces_only_the_iv() { + let out = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &[]); + assert_eq!(out.len(), 16, "empty input should yield exactly the IV"); + + // ...and feeding that straight back gives empty output. + let back = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &out); + assert!(back.is_empty(), "decrypting an IV with no body should give nothing"); +} + +// ---- cross-variant behaviour ------------------------------------------------------------ + +/// Decrypting with a different key length than was used to encrypt cannot succeed silently. +#[test] +fn the_three_variants_are_not_interchangeable() { + let plaintext = unhex(PLAINTEXT); + let ciphertext = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext); + + // Right length, wrong key: decryption "succeeds" but must not recover the plaintext. CBC is + // unauthenticated, so garbage out is the expected behaviour, not an error -- which is exactly + // why the crate docs insist on authenticating separately. + let wrong_key = "ff".repeat(16); + let out = run_ok(&["aes128-cbc", "decrypt", "--key", &wrong_key], &ciphertext); + assert_ne!(out, plaintext, "a wrong key must not recover the plaintext"); + assert_eq!(out.len(), plaintext.len(), "but the length is unchanged: CBC is unauthenticated"); +} + +/// The subcommands appear in `--help`, so they are discoverable. +#[test] +fn the_subcommands_are_listed_in_help() { + let out = run_ok(&["--help"], &[]); + let help = String::from_utf8_lossy(&out); + for cmd in ["aes128-cbc", "aes192-cbc", "aes256-cbc"] { + assert!(help.contains(cmd), "`--help` should list {cmd}"); + } +} + +/// Each subcommand's own help names the two actions and the IV convention. +#[test] +fn per_command_help_documents_the_iv_convention() { + let out = run_ok(&["aes128-cbc", "--help"], &[]); + let help = String::from_utf8_lossy(&out); + assert!(help.contains("encrypt"), "help should list the encrypt action"); + assert!(help.contains("decrypt"), "help should list the decrypt action"); + assert!( + help.contains("FIRST 16 BYTES") || help.contains("first 16 bytes"), + "help should explain where the IV goes: {help}" + ); +} diff --git a/crypto/aes-lowmemory/summary.md b/crypto/aes-lowmemory/summary.md index 4933c652..20ab8fca 100644 --- a/crypto/aes-lowmemory/summary.md +++ b/crypto/aes-lowmemory/summary.md @@ -1,7 +1,7 @@ # `crypto/aes-lowmemory` โ€” implementation summary -A constant-time, table-free AES block cipher engine (NIST FIPS 197), added 2026-08-31 on branch -`feature/officialfrancismendoza/98-AES-lowmemory`. +A constant-time, table-free AES block cipher engine (NIST FIPS 197), added on branch +`feature/officialfrancismendoza/100-AES-lightengine-CBC-mode`. This document is the reviewer's orientation: what was built, why the design is the way it is, what was verified and how, and โ€” importantly โ€” the three places where the working plan or model recall @@ -259,6 +259,16 @@ Two details worth knowing: default, and `Aes128::new` rejecting it is itself tested. The *test* opts in via `do_hazardous_operations`; the engine's guard was **not** weakened to accommodate NIST. +### Only the ECB file belongs to this crate + +`bc-test-data` ships thirteen ACVP AES vector sets, one per mode. This crate consumes only +`ACVP-AES-ECB`, because that is the set that tests the permutation rather than a mode. +`ACVP-AES-CBC` is consumed by [`crypto/modes/tests/acvp_tests.rs`](../modes/tests/acvp_tests.rs) +(2150 AFT cases). The remaining eleven โ€” `CBC-CS1/2/3`, `CFB8`, `CFB128`, `OFB`, `CTR`, `KW`, +`KWP`, `FF1`, `FF3-1` โ€” are unused because those modes are unimplemented, not because they are +untested. The table in the ACVP test module's docs records which file goes where, so adding a mode +includes wiring up its file. + ### Constant-time hygiene audit Mechanically checked, not merely claimed: @@ -386,7 +396,7 @@ and `MIX_COEFFS` carries a comment about the trap. ### 5.3 The plan's "PR B" is unnecessary The plan calls for downloading CAVP AESAVS `.rsp` files and opening a PR against `bcgit/bc-test-data` -to add them. `bc-test-data` **already** ships NIST ACVP AES vectors at +to add them. `bc-test-data` **already** ships NIST ACVP AES vectors for every mode, including `crypto/aes_tdes_vectors/AES/ACVP-AES-ECB.4014527.{req,rsp}.json` โ€” 2138 AFT cases across all three key lengths, more coverage than the AESAVS KAT/MMT files would have provided. No PR to `bc-test-data` is needed. `serde_json` as a dev-dependency is the established way to read these diff --git a/crypto/aes-lowmemory/tests/acvp_tests.rs b/crypto/aes-lowmemory/tests/acvp_tests.rs index 0ab0b431..b54d9f05 100644 --- a/crypto/aes-lowmemory/tests/acvp_tests.rs +++ b/crypto/aes-lowmemory/tests/acvp_tests.rs @@ -5,12 +5,30 @@ //! matching the convention used by the ML-KEM and ML-DSA test suites -- `cargo test` must stay //! green for someone who has only cloned this repository. //! -//! # Why ACVP ECB vectors +//! # Why ECB, and where the other ACVP AES files are used //! //! ECB applies the raw permutation to each block independently, so an ECB test vector *is* a //! block-permutation test vector -- which is the only reason ECB is mentioned in this crate. See //! the crate docs on why you must never use ECB to encrypt data. //! +//! `bc-test-data` ships thirteen ACVP AES vector sets, one per mode. This file deliberately +//! consumes only `ACVP-AES-ECB`, because that is the one that tests the permutation rather than a +//! mode. The others belong with whatever implements the mode: +//! +//! | Vector set | Consumed by | +//! |---|---| +//! | `ACVP-AES-ECB` | this file | +//! | `ACVP-AES-CBC` | `crypto/modes/tests/acvp_tests.rs` | +//! | `ACVP-AES-CBC-CS1` / `-CS2` / `-CS3` | nothing yet (ciphertext stealing is unimplemented) | +//! | `ACVP-AES-CFB8` / `-CFB128` | nothing yet (CFB is unimplemented) | +//! | `ACVP-AES-OFB` | nothing yet (OFB is unimplemented) | +//! | `ACVP-AES-CTR` | nothing yet (CTR is unimplemented) | +//! | `ACVP-AES-KW` / `-KWP` | nothing yet (key wrap is unimplemented) | +//! | `ACVP-AES-FF1` / `-FF3-1` | nothing yet (format-preserving encryption is unimplemented) | +//! +//! So an unused vector set here means an unimplemented mode, not an untested one. Adding a mode +//! should include wiring up its file. +//! //! The response file records `key`, `pt` and `ct` for every test case regardless of the group's //! declared direction, so each case is checked in **both** directions: encrypting `pt` must give //! `ct` and decrypting `ct` must give `pt`. That is strictly stronger than honouring the declared diff --git a/crypto/core-test-framework/summary.md b/crypto/core-test-framework/summary.md index e0ae3736..dcd404e7 100644 --- a/crypto/core-test-framework/summary.md +++ b/crypto/core-test-framework/summary.md @@ -1,6 +1,6 @@ # `crypto/core-test-framework` โ€” changes for `BlockPermutation` and CBC -Changes made on branch `feature/officialfrancismendoza/98-AES-lowmemory` (2026-08-31) while adding +Changes made on branch `feature/officialfrancismendoza/100-AES-lightengine-CBC-mode` while adding `crypto/aes-lowmemory` and `crypto/modes`. Two things: a **new** per-trait suite for `core::traits::BlockPermutation`, and a **bug fix** to the existing `TestFrameworkBlockCipher`. @@ -162,7 +162,7 @@ This is the pattern to reuse for CFB, OFB and CTR when they land. ```sh cargo build -p bouncycastle-core-test-framework -cargo test --workspace # 500 tests, 0 failures +cargo test --workspace # 517 tests, 0 failures cargo fmt --all -- --check ``` diff --git a/crypto/modes/Cargo.toml b/crypto/modes/Cargo.toml index ec5cc847..1aca516f 100644 --- a/crypto/modes/Cargo.toml +++ b/crypto/modes/Cargo.toml @@ -13,6 +13,7 @@ bouncycastle-aes-lowmemory.workspace = true bouncycastle-core-test-framework.workspace = true bouncycastle-hex.workspace = true criterion.workspace = true +serde_json = "1.0" [[bench]] name = "modes_benches" diff --git a/crypto/modes/tests/acvp_tests.rs b/crypto/modes/tests/acvp_tests.rs new file mode 100644 index 00000000..47f8b504 --- /dev/null +++ b/crypto/modes/tests/acvp_tests.rs @@ -0,0 +1,303 @@ +//! Known-answer tests against the NIST ACVP `ACVP-AES-CBC` vectors from the `bc-test-data` repo. +//! +//! Requires `bc-test-data` to be cloned alongside this repository, i.e. at `../bc-test-data` +//! relative to the root of this git project. If it is absent the test prints a warning and passes, +//! matching the convention used by the ML-KEM, ML-DSA and `aes-lowmemory` suites -- `cargo test` +//! must stay green for someone who has only cloned this repository. +//! +//! These are the counterpart to `crypto/aes-lowmemory/tests/acvp_tests.rs`, which consumes the +//! `ACVP-AES-ECB` file to test the raw permutation. CBC is a mode, so its vectors belong here. +//! +//! # Joining the request and response files +//! +//! Unlike the ECB response file, which echoes `key`, `pt` and `ct` for every case, the CBC response +//! file carries **only the answer** (`ct` for an encrypt group, `pt` for a decrypt group) against a +//! `tcId`. The key, IV and input live in the request file, and the group metadata that says which +//! direction a case is -- `direction` and `keyLen` -- lives only there too. So both files are read +//! and joined on `tcId`; there is no way to drive this from the response file alone. +//! +//! # Coverage +//! +//! 2150 AFT (Algorithm Functional Test) cases across all three key lengths and both directions, +//! including 60 whose payload spans 2 to 10 blocks. Every case is run **twice**: once block by +//! block, and once in pairs with a one-block remainder for odd lengths. The second pass is what +//! puts the multi-block cases through `BlockPermutation::decrypt_blocks2`, so the pair path is +//! exercised against real vectors and not only against the toy in `cbc_tests.rs`. +//! +//! The 6 MCT (Monte Carlo Test) groups are **not** implemented: their expected output is a +//! `resultsArray` produced by a chained update rule defined in the ACVP AES specification rather +//! than in SP 800-38A, and implementing it from anything else would be guesswork. The test reports +//! how many it skipped so the gap stays visible. + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle_core::key_material::{ + KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, +}; +use bouncycastle_core::traits::{ + BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength, +}; +use bouncycastle_core_test_framework::FixedSeedRNG; +use bouncycastle_hex as hex; +use bouncycastle_modes::{Cbc, Decrypting, Encrypting}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +const BLOCK_LEN: usize = 16; + +/// Candidate locations, covering `cargo test` run from the crate root or from the repo root. +const TEST_DATA_PATHS: [&str; 2] = [ + "../../../bc-test-data/crypto/aes_tdes_vectors/AES", + "../bc-test-data/crypto/aes_tdes_vectors/AES", +]; + +const REQUEST_FILE: &str = "ACVP-AES-CBC.4014528.req.json"; +const RESPONSE_FILE: &str = "ACVP-AES-CBC.4014528.rsp.json"; + +fn test_data_dir() -> Option { + for candidate in TEST_DATA_PATHS { + let path = Path::new(candidate); + if path.join(REQUEST_FILE).exists() && path.join(RESPONSE_FILE).exists() { + return Some(path.to_path_buf()); + } + } + println!( + "WARNING: bc-test-data not found (looked in {TEST_DATA_PATHS:?}); \ + ACVP AES-CBC tests will be skipped" + ); + None +} + +/// Builds a `KeyMaterial` from raw ACVP key bytes, including the all-zero keys. +/// +/// The ACVP set deliberately includes an all-zero key. `KeyMaterial` tags an all-zero buffer as +/// `KeyType::Zeroized` and will not promote it outside a `do_hazardous_operations` closure, which +/// is the right default -- so this opts in explicitly rather than the engine weakening its guard. +fn cipher_key(bytes: &[u8]) -> KeyMaterial { + assert_eq!(bytes.len(), N, "key length should match the parameter set"); + let mut key = KeyMaterial::::from_bytes_as_type(bytes, KeyType::SymmetricCipherKey) + .expect("ACVP key bytes fit the buffer"); + + if key.key_type() != KeyType::SymmetricCipherKey { + do_hazardous_operations(&mut key, |k| { + k.set_key_type(KeyType::SymmetricCipherKey)?; + k.set_security_strength(SecurityStrength::from_bytes(N)) + }) + .expect("promoting a NIST all-zero test key"); + } + key +} + +/// How to walk the blocks of one case. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Grouping { + /// One block per call. Never forms a pair. + Single, + /// Two blocks per call, with a one-block remainder for odd lengths. Uses the pair path. + Pairs, +} + +/// Runs one CBC case in one direction, for a given permutation, under the given grouping. +/// +/// Encryption is driven through `do_encrypt_init_rng` with a `FixedSeedRNG` emitting the vector's +/// IV, and the returned init data is checked against that IV before any ciphertext is compared -- +/// so a change that ignored the RNG could not pass silently. +fn run_case( + key_bytes: &[u8], + iv: [u8; BLOCK_LEN], + input: &[[u8; BLOCK_LEN]], + encrypt: bool, + grouping: Grouping, +) -> Vec<[u8; BLOCK_LEN]> +where + P: BlockPermutation, +{ + let key = cipher_key::(key_bytes); + let mut out: Vec<[u8; BLOCK_LEN]> = Vec::with_capacity(input.len()); + + if encrypt { + let (mut enc, got_iv) = Cbc::::do_encrypt_init_rng( + &key, + &mut FixedSeedRNG::::new(iv), + ) + .expect("encrypt init"); + assert_eq!(got_iv, iv, "the pinned RNG should reproduce the vector's IV"); + + match grouping { + Grouping::Single => { + for block in input { + let [c] = enc.do_encrypt_blocks(&[*block]).unwrap(); + out.push(c); + } + } + Grouping::Pairs => { + let (pairs, tail) = input.as_chunks::<2>(); + for pair in pairs { + out.extend_from_slice(&enc.do_encrypt_blocks(pair).unwrap()); + } + for block in tail { + let [c] = enc.do_encrypt_blocks(&[*block]).unwrap(); + out.push(c); + } + } + } + } else { + let mut dec = + Cbc::::do_decrypt_init(&key, &iv).expect("dec init"); + + match grouping { + Grouping::Single => { + for block in input { + let [p] = dec.do_decrypt_blocks(&[*block]).unwrap(); + out.push(p); + } + } + Grouping::Pairs => { + let (pairs, tail) = input.as_chunks::<2>(); + for pair in pairs { + out.extend_from_slice(&dec.do_decrypt_blocks(pair).unwrap()); + } + for block in tail { + let [p] = dec.do_decrypt_blocks(&[*block]).unwrap(); + out.push(p); + } + } + } + } + + out +} + +/// Dispatches on key length, which is what selects the AES parameter set. +fn run_case_for_key_len( + key_bytes: &[u8], + iv: [u8; BLOCK_LEN], + input: &[[u8; BLOCK_LEN]], + encrypt: bool, + grouping: Grouping, +) -> Vec<[u8; BLOCK_LEN]> { + match key_bytes.len() { + 16 => run_case::(key_bytes, iv, input, encrypt, grouping), + 24 => run_case::(key_bytes, iv, input, encrypt, grouping), + 32 => run_case::(key_bytes, iv, input, encrypt, grouping), + other => panic!("ACVP AES vectors should only use 16, 24 or 32 byte keys, got {other}"), + } +} + +fn to_blocks(bytes: &[u8]) -> Vec<[u8; BLOCK_LEN]> { + assert_eq!(bytes.len() % BLOCK_LEN, 0, "ACVP CBC payloads are block-aligned"); + bytes.chunks(BLOCK_LEN).map(|c| c.try_into().unwrap()).collect() +} + +fn decode(value: &Value, field: &str, tc_id: u64) -> Vec { + let s = value + .get(field) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("tcId {tc_id}: missing field {field}")); + hex::decode(s).unwrap_or_else(|_| panic!("tcId {tc_id}: bad hex in {field}")) +} + +#[test] +fn acvp_aes_cbc_known_answer_tests() { + let Some(dir) = test_data_dir() else { return }; + + let req: Value = serde_json::from_str( + &fs::read_to_string(dir.join(REQUEST_FILE)).expect("readable request file"), + ) + .expect("valid ACVP request JSON"); + let rsp: Value = serde_json::from_str( + &fs::read_to_string(dir.join(RESPONSE_FILE)).expect("readable response file"), + ) + .expect("valid ACVP response JSON"); + + // The response file carries only the answer, against a tcId. Index it. + let mut answers: BTreeMap = BTreeMap::new(); + for group in rsp + .get(1) + .and_then(|s| s.get("testGroups")) + .and_then(Value::as_array) + .expect("response testGroups") + { + for test in group.get("tests").and_then(Value::as_array).expect("response tests") { + let tc_id = test.get("tcId").and_then(Value::as_u64).expect("tcId"); + answers.insert(tc_id, test.clone()); + } + } + + let groups = req + .get(1) + .and_then(|s| s.get("testGroups")) + .and_then(Value::as_array) + .expect("request testGroups"); + + let mut checked = 0usize; + let mut multi_block = 0usize; + let mut skipped_mct = 0usize; + let mut per_kind: BTreeMap = BTreeMap::new(); + + for group in groups { + let test_type = group.get("testType").and_then(Value::as_str).expect("testType"); + let direction = group.get("direction").and_then(Value::as_str).expect("direction"); + let encrypt = match direction { + "encrypt" => true, + "decrypt" => false, + other => panic!("unexpected direction {other}"), + }; + + for test in group.get("tests").and_then(Value::as_array).expect("tests") { + let tc_id = test.get("tcId").and_then(Value::as_u64).expect("tcId"); + + if test_type == "MCT" { + skipped_mct += 1; + continue; + } + + let answer = answers.get(&tc_id).unwrap_or_else(|| panic!("tcId {tc_id}: no answer")); + if answer.get("resultsArray").is_some() { + skipped_mct += 1; + continue; + } + + let key_bytes = decode(test, "key", tc_id); + let iv: [u8; BLOCK_LEN] = decode(test, "iv", tc_id).try_into().expect("a 16-byte IV"); + + // Input comes from the request, expected output from the response. + let (input_field, output_field) = if encrypt { ("pt", "ct") } else { ("ct", "pt") }; + let input = to_blocks(&decode(test, input_field, tc_id)); + let expected = to_blocks(&decode(answer, output_field, tc_id)); + + assert_eq!(input.len(), expected.len(), "tcId {tc_id}: length mismatch"); + if input.len() > 1 { + multi_block += 1; + } + + for grouping in [Grouping::Single, Grouping::Pairs] { + let got = run_case_for_key_len(&key_bytes, iv, &input, encrypt, grouping); + assert_eq!( + got, + expected, + "tcId {tc_id}: AES-{} CBC {direction}, {} blocks, {grouping:?} grouping", + key_bytes.len() * 8, + input.len() + ); + } + + *per_kind.entry(format!("AES-{} {direction}", key_bytes.len() * 8)).or_default() += 1; + checked += 1; + } + } + + for (kind, n) in &per_kind { + println!("ACVP AES-CBC {kind}: {n} cases"); + } + println!( + "ACVP AES-CBC: {checked} AFT cases checked in two groupings each \ + ({multi_block} of them multi-block); {skipped_mct} MCT cases skipped" + ); + + // Guard against a silently-empty or partial run. + assert!(checked > 2000, "expected the full ACVP AFT set, only checked {checked}"); + assert!(multi_block >= 60, "expected the multi-block cases, found {multi_block}"); + assert_eq!(per_kind.len(), 6, "expected all three key lengths in both directions"); +} From 11add6177eafde3ca052c2fe9c7c764fa341d2ff Mon Sep 17 00:00:00 2001 From: officialfrancismendoza Date: Wed, 2 Sep 2026 13:38:45 +0700 Subject: [PATCH 28/28] Initial additions to CFB mode according to modes plan (#103) --- alpha_0.1.3_release_notes.md | 58 ++- cli/src/{aes_cbc_cmd.rs => aes_modes_cmd.rs} | 173 +++++-- cli/src/main.rs | 106 +++- ...bc_cli_tests.rs => aes_modes_cli_tests.rs} | 295 ++++++++--- crypto/aes-lowmemory/summary.md | 11 +- crypto/aes-lowmemory/tests/acvp_tests.rs | 3 +- crypto/core-test-framework/summary.md | 9 +- crypto/modes/benches/modes_benches.rs | 118 ++++- crypto/modes/src/cfb.rs | 299 +++++++++++ crypto/modes/src/lib.rs | 169 ++++-- crypto/modes/tests/acvp_tests.rs | 199 +++++-- crypto/modes/tests/cfb_tests.rs | 489 ++++++++++++++++++ crypto/modes/tests/common/mod.rs | 39 ++ crypto/modes/tests/sp800_38a_tests.rs | 390 ++++++++++++-- 14 files changed, 2074 insertions(+), 284 deletions(-) rename cli/src/{aes_cbc_cmd.rs => aes_modes_cmd.rs} (62%) rename cli/tests/{aes_cbc_cli_tests.rs => aes_modes_cli_tests.rs} (50%) create mode 100644 crypto/modes/src/cfb.rs create mode 100644 crypto/modes/tests/cfb_tests.rs diff --git a/alpha_0.1.3_release_notes.md b/alpha_0.1.3_release_notes.md index e7a2dc18..382eddfc 100644 --- a/alpha_0.1.3_release_notes.md +++ b/alpha_0.1.3_release_notes.md @@ -28,7 +28,8 @@ permutation (NIST FIPS 197), re-exported from the umbrella crate. strength); per-mode OIDs and the `BlockCipherEncryptor` / `BlockCipherDecryptor` impls belong to the mode crates. New crate `bouncycastle-modes` (`bouncycastle::modes`): block cipher modes of operation -(NIST SP 800-38A), currently **CBC** (Sec 6.2). Re-exported from the umbrella crate. +(NIST SP 800-38A), providing **CBC** (Sec 6.2) and **CFB** (Sec 6.3, full-block segment). +Re-exported from the umbrella crate. * `Cbc` over any `BlockPermutation`, so the crate depends on no concrete cipher. The direction is a type parameter: `BlockCipherEncryptor` is implemented only @@ -58,10 +59,47 @@ New crate `bouncycastle-modes` (`bouncycastle::modes`): block cipher modes of op file, the CBC one carries only the answer against a `tcId`, so the request and response files are joined; the 6 MCT groups are skipped and the count reported. These vectors were already in `bc-test-data` and previously unused. -* No CFB yet -- see the crate docs' "Not yet implemented". +`Cfb` implements SP 800-38A Sec 6.3 for the **full-block segment size +only**, `s = b` -- "CFB128" for AES. It has the same shape, the same API and the same IV handling as +`Cbc`, so swapping one for the other is a one-word change. -`cli`: three new subcommands, `aes128-cbc`, `aes192-cbc` and `aes256-cbc`, each taking `encrypt` or -`decrypt` and streaming stdin to stdout in 1 KiB chunks. +* **Only `s = b`.** Sec 6.3 allows any `1 <= s <= b`, but only `s = b` is block-aligned, and + `BlockCipherEncryptor` / `BlockCipherDecryptor` are block-aligned by contract. At `s = b` the + spec's equations collapse exactly: `LSB_{b-s}(Ij-1)` is the empty string so `Ij = Cj-1`, and + `MSB_s(Oj)` is the whole block so `MSB_b(Oj) = Oj`. Both substitutions are spelled out in the + module docs. CFB1 and CFB8 are deferred to a future `StreamCipher` design. +* **Both directions use the forward cipher function.** Sec 6.3 defines CFB decryption with + `CIPH_K`, not `CIPH^-1_K`, so `Cfb<_, Decrypting, _, _>` never calls + `BlockPermutation::decrypt_block` or `decrypt_blocks2`. That is asserted rather than assumed: a + test drives CFB in both directions over a permutation whose `decrypt_block` panics. A consequence + is that CFB needs only half of a block cipher. +* **Parallel decryption**, as for CBC, but through `encrypt_blocks2` -- the input blocks are + `Ij = Cj-1`, all ciphertext, so a pair can be transformed in one call. Sec 6.3 attaches the + condition that the input blocks be "first constructed (in series)", which is what building + `[chain, Cj]` before the call does. CFB encryption is serial (`Ij+1 = Cj` needs `Oj`) and does not + pair. +* Verified against SP 800-38A **Appendix F.3.13 - F.3.18** (CFB128-AES128/192/256, Encrypt and + Decrypt) in the same four groupings as CBC. The F.3 tables also publish the per-segment "Input + Block" and "Output Block" columns, and those are checked too -- the keystream `Oj = CIPH_K(Ij)` is + recovered as `Cj XOR Pj` and compared against the published value, which pins `Ij = Cj-1` rather + than only the final ciphertext. +* Also verified against the **2138 NIST ACVP `ACVP-AES-CFB128` AFT cases** (54 of them multi-block), + each run twice as for CBC. These vectors were already in `bc-test-data` and previously unused. +* Appendix D error propagation is tested for all 128 bit positions in both roles, and the results + are deliberately contrasted with CBC's, because Table D.2 swaps the two rows: a ciphertext bit + error gives **specific** bit errors in CFB's *own* block (CBC randomises it) and randomises the + next (CBC gives specific bit errors there). An IV bit error randomises `P1` for CFB where CBC + flips exactly the matching bit. +* Two hazards CFB has and CBC does not, both documented in "Security Considerations" and both + pinned by tests: flipping a bit of the **final** ciphertext block changes the plaintext precisely + with no structural trace at all (Appendix D notes the detection argument covers "every ciphertext + segment except the last one"), and an **IV repeat** gives a two-time pad rather than merely + leaking a shared prefix. + +`cli`: six new subcommands -- `aes128-cbc`, `aes192-cbc`, `aes256-cbc`, `aes128-cfb`, `aes192-cfb`, +`aes256-cfb` -- each taking `encrypt` or `decrypt` and streaming stdin to stdout in 1 KiB chunks. +All six live in `cli/src/aes_modes_cmd.rs` and share one key loader and one streaming loop, which is +generic over the encryptor / decryptor type rather than over the cipher. * Key from `--key` (hex) or `--key-file` (binary or hex), with the usual note that secrets on the command line end up in shell history. The key length must match the variant exactly. @@ -76,10 +114,16 @@ New crate `bouncycastle-modes` (`bouncycastle::modes`): block cipher modes of op * Verified against SP 800-38A F.2: prepending the spec's IV to the spec's ciphertext and running `decrypt` reproduces the spec's plaintext for all three key lengths. The `encrypt` direction was cross-checked against an independent CBC implementation under the IV the CLI generated. -* `cli/tests/aes_cbc_cli_tests.rs` (16 tests) drives the built binary as a subprocess via +* The CFB subcommands are the `s = b` segment size, and their help says so -- "CFB" alone is + ambiguous, and CFB8 is a real and different mode. Their help also carries the malleability + warning, which differs from CBC's. +* `cli/tests/aes_modes_cli_tests.rs` (19 tests) drives the built binary as a subprocess via `CARGO_BIN_EXE_bc-rust`, so all of the above is asserted by `cargo test` rather than by hand: - the F.2 vectors, round trips across the chunk boundary, a fresh IV per invocation, hex/binary - agreement, `--key-file` in both hex and binary, and every error path with its message. + the Appendix F vectors for all six subcommands, round trips across the chunk boundary, a fresh IV + per invocation, hex/binary agreement, `--key-file` in both hex and binary, and every error path + with its message. Two tests are CFB-specific: one shows the targeted single-bit malleability at + the command line and contrasts it with CBC's, and one confirms the CFB subcommands are not + secretly wired to the CBC types by feeding each mode's ciphertext to the other. `core`: new `BlockPermutation` trait (`crypto/core/src/traits.rs`), the raw keyed permutation -- `CIPH_K` / `CIPH^-1_K` of SP 800-38A Sec 5.1 -- that a mode is built on. diff --git a/cli/src/aes_cbc_cmd.rs b/cli/src/aes_modes_cmd.rs similarity index 62% rename from cli/src/aes_cbc_cmd.rs rename to cli/src/aes_modes_cmd.rs index 40727c85..f74f2b73 100644 --- a/cli/src/aes_cbc_cmd.rs +++ b/cli/src/aes_modes_cmd.rs @@ -1,26 +1,38 @@ -//! AES-CBC encryption and decryption, streaming stdin to stdout. +//! AES encryption and decryption in CBC and CFB mode, streaming stdin to stdout. +//! +//! Six subcommands -- `aes128-cbc`, `aes192-cbc`, `aes256-cbc`, `aes128-cfb`, `aes192-cfb`, +//! `aes256-cfb` -- share every line of this module below the entry points, because the two modes +//! present an identical API. The streaming helpers are generic over the encryptor / decryptor type +//! rather than over the cipher, which is what lets them. +//! +//! The CFB subcommands are the full-block segment variant, `s = b`, i.e. **CFB128** (NIST SP +//! 800-38A Sec 6.3). There is no CFB1 or CFB8 here; `bouncycastle-modes` does not implement them. //! //! # The IV travels in the ciphertext //! //! There is no `--iv` flag, and that is deliberate: `bouncycastle-modes` has no API for a -//! caller-supplied IV, because NIST SP 800-38A Sec 5.3 requires the CBC IV to be *unpredictable* -//! rather than merely unique. `encrypt` therefore generates one from the OS-backed DRBG and writes -//! it as the **first block of the output**; `decrypt` reads it back from the **first block of the -//! input**. So the two compose directly: +//! caller-supplied IV, because NIST SP 800-38A Sec 5.3 requires the CBC *and CFB* IV to be +//! *unpredictable* rather than merely unique. `encrypt` therefore generates one from the OS-backed +//! DRBG and writes it as the **first block of the output**; `decrypt` reads it back from the +//! **first block of the input**. So the two compose directly: //! //! ```text //! bc-rust aes128-cbc encrypt --key-file k.bin < plain.bin > cipher.bin //! bc-rust aes128-cbc decrypt --key-file k.bin < cipher.bin > plain.bin +//! +//! bc-rust aes256-cfb encrypt --key-file k.bin < plain.bin > cipher.bin +//! bc-rust aes256-cfb decrypt --key-file k.bin < cipher.bin > plain.bin //! ``` //! //! The IV is not secret (Sec 5.3), so shipping it in the clear is correct. Its *integrity* is not -//! protected, and neither is the ciphertext's -- see the warning below. +//! protected, and neither is the ciphertext's -- see the warnings below. //! //! # Input must be block-aligned //! -//! CBC is defined only on whole blocks (SP 800-38A Sec 5.2), and this workspace has no padding -//! layer yet, so input that is not a multiple of 16 bytes is rejected rather than silently padded. -//! Padding is the caller's business until `PaddedEncryptor`/`PaddedDecryptor` land. +//! CBC is defined only on whole blocks (SP 800-38A Sec 5.2), and CFB on whole segments -- which at +//! `s = b` is the same thing. This workspace has no padding layer yet, so input that is not a +//! multiple of 16 bytes is rejected rather than silently padded. Padding is the caller's business +//! until `PaddedEncryptor` / `PaddedDecryptor` land. //! //! # Binary in, binary out //! @@ -36,11 +48,9 @@ use bouncycastle::aes_lowmemory::{Aes128, Aes192, Aes256}; use bouncycastle::core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; -use bouncycastle::core::traits::{ - BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength, -}; +use bouncycastle::core::traits::{BlockCipherDecryptor, BlockCipherEncryptor, SecurityStrength}; use bouncycastle::hex; -use bouncycastle::modes::{Cbc, Decrypting, Encrypting}; +use bouncycastle::modes::{Cbc, Cfb, Decrypting, Encrypting}; use clap::ValueEnum; use std::io::{Read, Write}; use std::process::exit; @@ -52,61 +62,128 @@ const BLOCK_LEN: usize = 16; /// Blocks processed per call: 64 blocks = 1 KiB, matching the other streaming commands. /// /// A whole chunk goes through `do_*_blocks[_out]::` in one call, which for decryption -/// means 32 pairs down the `decrypt_blocks2` path. The at-most-63-block tail at end of input is -/// flushed one block at a time; it is bounded, so its cost does not scale with the input. +/// means 32 pairs down the permutation's two-block path. The at-most-63-block tail at end of input +/// is flushed one block at a time; it is bounded, so its cost does not scale with the input. const CHUNK_BLOCKS: usize = 64; #[derive(ValueEnum, Clone, Debug)] -pub(crate) enum AESCBCAction { - /// Encrypt stdin to stdout under CBC mode. +pub(crate) enum AESModeAction { + /// Encrypt stdin to stdout. /// A freshly generated IV is written as the first 16 bytes of the output, so that `decrypt` /// can read it back. Input length must be a multiple of 16 bytes. Encrypt, - /// Decrypt stdin to stdout under CBC mode. + /// Decrypt stdin to stdout. /// The first 16 bytes of input are taken as the IV, as written by `encrypt`. The remaining /// length must be a multiple of 16 bytes. Decrypt, } +// ---- entry points ------------------------------------------------------------------------ + pub(crate) fn aes128_cbc_cmd( - action: &AESCBCAction, + action: &AESModeAction, key: &Option, key_file: &Option, output_hex: bool, ) { let key = load_key::<16>(key, key_file, "AES-128"); match action { - AESCBCAction::Encrypt => encrypt_stream::(&key, output_hex), - AESCBCAction::Decrypt => decrypt_stream::(&key, output_hex), + AESModeAction::Encrypt => { + encrypt_stream::, 16>(&key, output_hex) + } + AESModeAction::Decrypt => { + decrypt_stream::, 16>(&key, output_hex) + } } } pub(crate) fn aes192_cbc_cmd( - action: &AESCBCAction, + action: &AESModeAction, key: &Option, key_file: &Option, output_hex: bool, ) { let key = load_key::<24>(key, key_file, "AES-192"); match action { - AESCBCAction::Encrypt => encrypt_stream::(&key, output_hex), - AESCBCAction::Decrypt => decrypt_stream::(&key, output_hex), + AESModeAction::Encrypt => { + encrypt_stream::, 24>(&key, output_hex) + } + AESModeAction::Decrypt => { + decrypt_stream::, 24>(&key, output_hex) + } } } pub(crate) fn aes256_cbc_cmd( - action: &AESCBCAction, + action: &AESModeAction, + key: &Option, + key_file: &Option, + output_hex: bool, +) { + let key = load_key::<32>(key, key_file, "AES-256"); + match action { + AESModeAction::Encrypt => { + encrypt_stream::, 32>(&key, output_hex) + } + AESModeAction::Decrypt => { + decrypt_stream::, 32>(&key, output_hex) + } + } +} + +pub(crate) fn aes128_cfb_cmd( + action: &AESModeAction, + key: &Option, + key_file: &Option, + output_hex: bool, +) { + let key = load_key::<16>(key, key_file, "AES-128"); + match action { + AESModeAction::Encrypt => { + encrypt_stream::, 16>(&key, output_hex) + } + AESModeAction::Decrypt => { + decrypt_stream::, 16>(&key, output_hex) + } + } +} + +pub(crate) fn aes192_cfb_cmd( + action: &AESModeAction, + key: &Option, + key_file: &Option, + output_hex: bool, +) { + let key = load_key::<24>(key, key_file, "AES-192"); + match action { + AESModeAction::Encrypt => { + encrypt_stream::, 24>(&key, output_hex) + } + AESModeAction::Decrypt => { + decrypt_stream::, 24>(&key, output_hex) + } + } +} + +pub(crate) fn aes256_cfb_cmd( + action: &AESModeAction, key: &Option, key_file: &Option, output_hex: bool, ) { let key = load_key::<32>(key, key_file, "AES-256"); match action { - AESCBCAction::Encrypt => encrypt_stream::(&key, output_hex), - AESCBCAction::Decrypt => decrypt_stream::(&key, output_hex), + AESModeAction::Encrypt => { + encrypt_stream::, 32>(&key, output_hex) + } + AESModeAction::Decrypt => { + decrypt_stream::, 32>(&key, output_hex) + } } } +// ---- key loading ------------------------------------------------------------------------- + /// Loads the key from `--key` (hex) or `--key-file` (binary or hex), and checks its length. /// /// `KEY_LEN` is exact: AES has three key lengths and the command selects one, so a key of the @@ -169,16 +246,19 @@ fn load_key( key } +// ---- streaming --------------------------------------------------------------------------- + /// Encrypts stdin to stdout, writing the generated IV first. -fn encrypt_stream(key: &KeyMaterial, output_hex: bool) +/// +/// Generic over the encryptor, so one body serves CBC and CFB. +fn encrypt_stream(key: &KeyMaterial, output_hex: bool) where - P: BlockPermutation, + E: BlockCipherEncryptor, { - let (mut enc, iv) = Cbc::::do_encrypt_init(key) - .unwrap_or_else(|e| { - eprintln!("Error: couldn't start encryption: {e:?}"); - exit(-1); - }); + let (mut enc, iv) = E::do_encrypt_init(key).unwrap_or_else(|e| { + eprintln!("Error: couldn't start encryption: {e:?}"); + exit(-1); + }); // The IV goes out ahead of the ciphertext, so `decrypt` can pick it up. write_bytes_or_hex(&iv, output_hex); @@ -204,9 +284,11 @@ where } /// Decrypts stdin to stdout, taking the IV from the first block of input. -fn decrypt_stream(key: &KeyMaterial, output_hex: bool) +/// +/// Generic over the decryptor, so one body serves CBC and CFB. +fn decrypt_stream(key: &KeyMaterial, output_hex: bool) where - P: BlockPermutation, + D: BlockCipherDecryptor, { // The leading block is the IV, not ciphertext. let mut iv = [0u8; BLOCK_LEN]; @@ -218,17 +300,16 @@ where exit(-1); } - let mut dec = Cbc::::do_decrypt_init(key, &iv) - .unwrap_or_else(|e| { - eprintln!("Error: couldn't start decryption: {e:?}"); - exit(-1); - }); + let mut dec = D::do_decrypt_init(key, &iv).unwrap_or_else(|e| { + eprintln!("Error: couldn't start decryption: {e:?}"); + exit(-1); + }); let mut out = [[0u8; BLOCK_LEN]; CHUNK_BLOCKS]; stream_blocks(|blocks| match <&[[u8; BLOCK_LEN]; CHUNK_BLOCKS]>::try_from(blocks) { Ok(full_chunk) => { - // A full chunk is 32 pairs, so this is the `decrypt_blocks2` path. + // A full chunk is 32 pairs, so this takes the permutation's two-block path. dec.do_decrypt_blocks_out(full_chunk, &mut out).unwrap(); write_blocks(&out, output_hex); } @@ -252,8 +333,8 @@ where /// /// Reads do not respect block boundaries, so a block can arrive split across two reads; the /// partial block is carried over rather than assumed complete. Input whose total length is not a -/// multiple of `BLOCK_LEN` is an error, because CBC is not defined on a partial block and there is -/// no padding layer to appeal to. +/// multiple of `BLOCK_LEN` is an error, because these modes are not defined on a partial block and +/// there is no padding layer to appeal to. fn stream_blocks(mut process: impl FnMut(&[[u8; BLOCK_LEN]])) { let mut staged = [[0u8; BLOCK_LEN]; CHUNK_BLOCKS]; let mut read_buf = [0u8; BLOCK_LEN * CHUNK_BLOCKS]; @@ -293,8 +374,8 @@ fn stream_blocks(mut process: impl FnMut(&[[u8; BLOCK_LEN]])) { if partial_len != 0 { eprintln!( "Error: input is not a whole number of {BLOCK_LEN}-byte blocks ({partial_len} \ - trailing byte(s)). CBC is defined only on whole blocks (SP 800-38A Sec 5.2), and \ - this build has no padding layer, so the input must be padded by the caller." + trailing byte(s)). CBC and CFB are defined only on whole blocks (SP 800-38A Sec \ + 5.2), and this build has no padding layer, so the input must be padded by the caller." ); exit(-1); } diff --git a/cli/src/main.rs b/cli/src/main.rs index f86aec90..da5657ca 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -1,4 +1,4 @@ -mod aes_cbc_cmd; +mod aes_modes_cmd; mod encoders_cmd; mod helpers; mod hkdf_cmd; @@ -9,7 +9,7 @@ mod rng_cmd; mod sha2_cmd; mod sha3_cmd; -use crate::aes_cbc_cmd::AESCBCAction; +use crate::aes_modes_cmd::AESModeAction; use crate::mac_cmd::HMACVariant; use crate::mldsa_cmd::MLDSAAction; use clap::{Parser, Subcommand}; @@ -289,7 +289,7 @@ enum Subcommands { /// Note: in production uses, secrets should not be passed on the command-line because they get /// logged in shell history. Use the file-based input instead. AES128_CBC { - action: AESCBCAction, + action: AESModeAction, /// The 16-byte AES key in hex. /// The `key_file` option is preferred to avoid leaving key material in command history. @@ -311,7 +311,7 @@ enum Subcommands { /// See `aes128-cbc` for the IV convention, block-alignment requirement and warnings; only the /// key length differs. AES192_CBC { - action: AESCBCAction, + action: AESModeAction, /// The 24-byte AES key in hex. /// The `key_file` option is preferred to avoid leaving key material in command history. @@ -333,7 +333,88 @@ enum Subcommands { /// See `aes128-cbc` for the IV convention, block-alignment requirement and warnings; only the /// key length differs. AES256_CBC { - action: AESCBCAction, + action: AESModeAction, + + /// The 32-byte AES key in hex. + /// The `key_file` option is preferred to avoid leaving key material in command history. + #[arg(long)] + key: Option, + + /// A file containing the 32-byte AES key, in binary or hex. + /// If both key and key_file options are provided, the file will be used. + #[arg(short, long)] + key_file: Option, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, + + /// AES-128 in CFB mode with a full-block segment (NIST SP 800-38A Sec 6.3), i.e. CFB128, + /// streaming stdin to stdout. + /// + /// On `encrypt`, a fresh unpredictable IV is generated and written as the FIRST 16 BYTES of + /// the output; on `decrypt` it is read back from the first 16 bytes of the input, so the two + /// compose directly in a pipeline. There is deliberately no `--iv` flag. + /// + /// Input must be a whole number of 16-byte blocks. This is the s = 128 segment size; CFB1 and + /// CFB8 are not provided. + /// + /// WARNING: CFB provides confidentiality only, and its malleability is more directly + /// exploitable than CBC's: flipping a ciphertext bit flips exactly that bit of the same + /// block's plaintext, and for the final block that change leaves no trace at all. Do not + /// decrypt data you have not authenticated separately. + /// + /// Note: in production uses, secrets should not be passed on the command-line because they get + /// logged in shell history. Use the file-based input instead. + AES128_CFB { + action: AESModeAction, + + /// The 16-byte AES key in hex. + /// The `key_file` option is preferred to avoid leaving key material in command history. + #[arg(long)] + key: Option, + + /// A file containing the 16-byte AES key, in binary or hex. + /// If both key and key_file options are provided, the file will be used. + #[arg(short, long)] + key_file: Option, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, + + /// AES-192 in CFB mode with a full-block segment (NIST SP 800-38A Sec 6.3), i.e. CFB128, + /// streaming stdin to stdout. + /// + /// See `aes128-cfb` for the IV convention, block-alignment requirement and warnings; only the + /// key length differs. + AES192_CFB { + action: AESModeAction, + + /// The 24-byte AES key in hex. + /// The `key_file` option is preferred to avoid leaving key material in command history. + #[arg(long)] + key: Option, + + /// A file containing the 24-byte AES key, in binary or hex. + /// If both key and key_file options are provided, the file will be used. + #[arg(short, long)] + key_file: Option, + + #[arg(short)] + /// Output in hex format. + x: bool, + }, + + /// AES-256 in CFB mode with a full-block segment (NIST SP 800-38A Sec 6.3), i.e. CFB128, + /// streaming stdin to stdout. + /// + /// See `aes128-cfb` for the IV convention, block-alignment requirement and warnings; only the + /// key length differs. + AES256_CFB { + action: AESModeAction, /// The 32-byte AES key in hex. /// The `key_file` option is preferred to avoid leaving key material in command history. @@ -644,13 +725,22 @@ fn main() { ), Some(Subcommands::RNG { len, x }) => rng_cmd::rng_cmd(*len, *x), Some(Subcommands::AES128_CBC { action, key, key_file, x }) => { - aes_cbc_cmd::aes128_cbc_cmd(action, key, key_file, *x); + aes_modes_cmd::aes128_cbc_cmd(action, key, key_file, *x); } Some(Subcommands::AES192_CBC { action, key, key_file, x }) => { - aes_cbc_cmd::aes192_cbc_cmd(action, key, key_file, *x); + aes_modes_cmd::aes192_cbc_cmd(action, key, key_file, *x); } Some(Subcommands::AES256_CBC { action, key, key_file, x }) => { - aes_cbc_cmd::aes256_cbc_cmd(action, key, key_file, *x); + aes_modes_cmd::aes256_cbc_cmd(action, key, key_file, *x); + } + Some(Subcommands::AES128_CFB { action, key, key_file, x }) => { + aes_modes_cmd::aes128_cfb_cmd(action, key, key_file, *x); + } + Some(Subcommands::AES192_CFB { action, key, key_file, x }) => { + aes_modes_cmd::aes192_cfb_cmd(action, key, key_file, *x); + } + Some(Subcommands::AES256_CFB { action, key, key_file, x }) => { + aes_modes_cmd::aes256_cfb_cmd(action, key, key_file, *x); } Some(Subcommands::MLKEM512 { action, skfile, pkfile, ctfile, x }) => { mlkem_cmd::mlkem512_cmd(action, skfile, pkfile, ctfile, *x); diff --git a/cli/tests/aes_cbc_cli_tests.rs b/cli/tests/aes_modes_cli_tests.rs similarity index 50% rename from cli/tests/aes_cbc_cli_tests.rs rename to cli/tests/aes_modes_cli_tests.rs index 9dcea30c..9ca60e02 100644 --- a/cli/tests/aes_cbc_cli_tests.rs +++ b/cli/tests/aes_modes_cli_tests.rs @@ -1,9 +1,14 @@ -//! Tests for the `aes128-cbc` / `aes192-cbc` / `aes256-cbc` subcommands. +//! Tests for the six AES mode subcommands: `aes128-cbc`, `aes192-cbc`, `aes256-cbc`, +//! `aes128-cfb`, `aes192-cfb`, `aes256-cfb`. //! //! These drive the built `bc-rust` binary as a subprocess, because the behaviour worth testing is //! the command-line contract itself -- the IV riding in the first block, block-alignment //! enforcement, exit codes, key loading -- none of which is reachable from the library API. //! +//! The two modes share one implementation module and one streaming loop, so most tests loop over +//! [`SHARED_PATH_MODES`] (one entry point per mode) or [`ALL_MODES`] (all six) rather than +//! duplicating a CBC test for CFB. +//! //! `CARGO_BIN_EXE_bc-rust` is set by cargo for integration tests and points at the binary for the //! current profile, so there is nothing to build or locate by hand. @@ -13,7 +18,7 @@ use std::process::{Command, Output, Stdio}; /// The path to the binary under test, resolved by cargo. const BC_RUST: &str = env!("CARGO_BIN_EXE_bc-rust"); -/// SP 800-38A Appendix F IV, shared by every F.2 subsection. +/// SP 800-38A Appendix F IV, shared by every F.2 and F.3 subsection. const IV: &str = "000102030405060708090a0b0c0d0e0f"; /// The four SP 800-38A Appendix F plaintext blocks. @@ -29,27 +34,71 @@ const KEY_192: &str = "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b"; const KEY_256: &str = "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"; /// F.2.1 CBC-AES128.Encrypt ciphertext. -const CT_128: &str = concat!( +const CBC_CT_128: &str = concat!( "7649abac8119b246cee98e9b12e9197d", "5086cb9b507219ee95db113a917678b2", "73bed6b8e3c1743b7116e69e22229516", "3ff1caa1681fac09120eca307586e1a7", ); /// F.2.3 CBC-AES192.Encrypt ciphertext. -const CT_192: &str = concat!( +const CBC_CT_192: &str = concat!( "4f021db243bc633d7178183a9fa071e8", "b4d9ada9ad7dedf4e5e738763f69145a", "571b242012fb7ae07fa9baac3df102e0", "08b0e27988598881d920a9e64f5615cd", ); /// F.2.5 CBC-AES256.Encrypt ciphertext. -const CT_256: &str = concat!( +const CBC_CT_256: &str = concat!( "f58c4c04d6e5f1ba779eabfb5f7bfbd6", "9cfc4e967edb808d679f777bc6702c7d", "39f23369a9d9bacfa530e26304231461", "b2eb05e2c39be9fcda6c19078c6a9d1b", ); +/// F.3.13 CFB128-AES128.Encrypt ciphertext. +const CFB_CT_128: &str = concat!( + "3b3fd92eb72dad20333449f8e83cfb4a", + "c8a64537a0b3a93fcde3cdad9f1ce58b", + "26751f67a3cbb140b1808cf187a4f4df", + "c04b05357c5d1c0eeac4c66f9ff7f2e6", +); + +/// F.3.15 CFB128-AES192.Encrypt ciphertext. +const CFB_CT_192: &str = concat!( + "cdc80d6fddf18cab34c25909c99a4174", + "67ce7f7f81173621961a2b70171d3d7a", + "2e1e8a1dd59b88b1c8e60fed1efac4c9", + "c05f9f9ca9834fa042ae8fba584b09ff", +); + +/// F.3.17 CFB128-AES256.Encrypt ciphertext. +const CFB_CT_256: &str = concat!( + "dc7e84bfda79164b7ecd8486985d3860", + "39ffed143b28b1c832113c6331e5407b", + "df10132415e54b92a13ed0a8267ae2f9", + "75a385741ab9cef82031623d55b1e471", +); + +/// One subcommand per mode, for the tests that exercise code shared by all six. +/// +/// Key loading, the streaming loop and every error message live in one place and are generic over +/// the mode, so running both entry points is enough to show the shared path works from either -- +/// there is no need to multiply every error-path test by six. +const SHARED_PATH_MODES: [&str; 2] = ["aes128-cbc", "aes128-cfb"]; + +/// Every subcommand, with its key and the Appendix F ciphertext it must reproduce. +/// +/// The two modes present an identical command-line contract, so almost every test below is a loop +/// over this table rather than a CBC test with a CFB copy. +const ALL_MODES: [(&str, &str, &str); 6] = [ + ("aes128-cbc", KEY_128, CBC_CT_128), + ("aes192-cbc", KEY_192, CBC_CT_192), + ("aes256-cbc", KEY_256, CBC_CT_256), + ("aes128-cfb", KEY_128, CFB_CT_128), + ("aes192-cfb", KEY_192, CFB_CT_192), + ("aes256-cfb", KEY_256, CFB_CT_256), +]; + /// Runs `bc-rust ` with `stdin_bytes` on stdin and returns the completed output. fn run(args: &[&str], stdin_bytes: &[u8]) -> Output { let mut child = Command::new(BC_RUST) @@ -118,7 +167,7 @@ fn pseudo_random(len: usize, seed: u32) -> Vec { .collect() } -// ---- the SP 800-38A F.2 vectors, through the CLI ----------------------------------------- +// ---- the SP 800-38A Appendix F vectors, through the CLI ----------------------------------- /// `decrypt` reproduces the spec plaintext when handed the spec's IV followed by the spec's /// ciphertext. @@ -127,12 +176,8 @@ fn pseudo_random(len: usize, seed: u32) -> Vec { /// asked to reproduce a published ciphertext. `encrypt` is covered by the round-trip tests below /// and, at the library level, by `crypto/modes/tests/sp800_38a_tests.rs`. #[test] -fn decrypt_matches_sp800_38a_f2_vectors() { - for (cmd, key, ct) in [ - ("aes128-cbc", KEY_128, CT_128), - ("aes192-cbc", KEY_192, CT_192), - ("aes256-cbc", KEY_256, CT_256), - ] { +fn decrypt_matches_sp800_38a_appendix_f_vectors() { + for (cmd, key, ct) in ALL_MODES { // The CLI expects the IV as the first block of its input, which is exactly how `encrypt` // emits it. let input = unhex(&format!("{IV}{ct}")); @@ -140,15 +185,38 @@ fn decrypt_matches_sp800_38a_f2_vectors() { assert_eq!( tohex(&out), PLAINTEXT, - "{cmd} decrypt should reproduce the Appendix F.2 plaintext" + "{cmd} decrypt should reproduce the Appendix F plaintext" ); } } +/// The two modes must not produce the same ciphertext from the same inputs, which is what would +/// happen if a CFB subcommand were wired to the CBC types by a copy-paste slip. +#[test] +fn the_cfb_subcommands_are_not_secretly_cbc() { + for (cbc_cmd, cfb_cmd, key, cbc_ct, cfb_ct) in [ + ("aes128-cbc", "aes128-cfb", KEY_128, CBC_CT_128, CFB_CT_128), + ("aes192-cbc", "aes192-cfb", KEY_192, CBC_CT_192, CFB_CT_192), + ("aes256-cbc", "aes256-cfb", KEY_256, CBC_CT_256, CFB_CT_256), + ] { + assert_ne!(cbc_ct, cfb_ct, "the two vectors differ to begin with"); + + // Feeding the CBC ciphertext to the CFB command must not recover the plaintext... + let cbc_input = unhex(&format!("{IV}{cbc_ct}")); + let via_cfb = run_ok(&[cfb_cmd, "decrypt", "--key", key], &cbc_input); + assert_ne!(tohex(&via_cfb), PLAINTEXT, "{cfb_cmd} must not decrypt {cbc_cmd} ciphertext"); + + // ...nor the other way round. + let cfb_input = unhex(&format!("{IV}{cfb_ct}")); + let via_cbc = run_ok(&[cbc_cmd, "decrypt", "--key", key], &cfb_input); + assert_ne!(tohex(&via_cbc), PLAINTEXT, "{cbc_cmd} must not decrypt {cfb_cmd} ciphertext"); + } +} + /// The same, with `-x`, which should give the identical answer in hex plus a trailing newline. #[test] fn hex_output_matches_binary_output() { - let input = unhex(&format!("{IV}{CT_128}")); + let input = unhex(&format!("{IV}{CBC_CT_128}")); let binary = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &input); let hex_out = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128, "-x"], &input); @@ -165,7 +233,7 @@ fn hex_output_matches_binary_output() { /// the IV is prepended. #[test] fn encrypt_then_decrypt_round_trips() { - for (cmd, key) in [("aes128-cbc", KEY_128), ("aes192-cbc", KEY_192), ("aes256-cbc", KEY_256)] { + for (cmd, key, _) in ALL_MODES { let plaintext = unhex(PLAINTEXT); let ciphertext = run_ok(&[cmd, "encrypt", "--key", key], &plaintext); assert_eq!( @@ -185,33 +253,85 @@ fn encrypt_then_decrypt_round_trips() { /// is four chunks plus a block; 65536 is many chunks. #[test] fn round_trips_across_chunk_boundaries() { - for size in [16usize, 32, 1024, 1040, 4096, 4112, 65536] { - let plaintext = pseudo_random(size, size as u32); - let ciphertext = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext); - let recovered = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &ciphertext); - assert_eq!(recovered, plaintext, "{size} bytes should round trip"); + for cmd in ["aes128-cbc", "aes128-cfb"] { + for size in [16usize, 32, 1024, 1040, 4096, 4112, 65536] { + let plaintext = pseudo_random(size, size as u32); + let ciphertext = run_ok(&[cmd, "encrypt", "--key", KEY_128], &plaintext); + let recovered = run_ok(&[cmd, "decrypt", "--key", KEY_128], &ciphertext); + assert_eq!(recovered, plaintext, "{cmd}: {size} bytes should round trip"); + } } } /// A fresh IV per invocation, so the same plaintext under the same key gives different output. /// -/// This is the operational requirement CBC lives or dies by, and the CLI is where it is easiest to -/// get wrong (e.g. by seeding from a fixed value). +/// This is the operational requirement both modes live or die by -- and for CFB an IV repeat is +/// the worse of the two, since it gives a two-time pad on the first block rather than merely +/// leaking a shared prefix. The CLI is where it is easiest to get wrong (e.g. by seeding from a +/// fixed value). #[test] fn each_invocation_uses_a_fresh_iv() { let plaintext = unhex(PLAINTEXT); - let mut seen = std::collections::BTreeSet::new(); - - for _ in 0..8 { - let ciphertext = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext); - let iv = ciphertext[..16].to_vec(); - assert!(seen.insert(iv), "the CLI reused an IV across invocations"); - // ...and the body differs too, not just the IV. - let recovered = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &ciphertext); - assert_eq!(recovered, plaintext); + + for cmd in SHARED_PATH_MODES { + let mut seen = std::collections::BTreeSet::new(); + for _ in 0..8 { + let ciphertext = run_ok(&[cmd, "encrypt", "--key", KEY_128], &plaintext); + let iv = ciphertext[..16].to_vec(); + assert!(seen.insert(iv), "{cmd} reused an IV across invocations"); + // ...and the body differs too, not just the IV. + let recovered = run_ok(&[cmd, "decrypt", "--key", KEY_128], &ciphertext); + assert_eq!(recovered, plaintext); + } } } +/// SP 800-38A Appendix D, Table D.2, CFB row: a bit error in `Cj` gives "SBE in the decryption of +/// Cj" -- specific bit errors, in the same positions. +/// +/// So flipping a bit of CFB ciphertext flips exactly that bit of the same block's plaintext. This +/// is the malleability the CFB help text warns about, and it is worth asserting at the CLI level +/// because that is where someone is most likely to treat "the output looks like garbage" as +/// tamper detection. It is *not* garbage: it is a precisely chosen change. +/// +/// The CBC row is the other way round (RBE in `Cj`), so the same edit to CBC ciphertext must +/// **not** produce a clean single-bit change -- which is checked here too, since the contrast is +/// the whole point. +#[test] +fn a_cfb_ciphertext_bit_flip_is_a_targeted_plaintext_bit_flip() { + let plaintext = unhex(PLAINTEXT); + + // CFB: flip bit 5 of byte 3 of the second ciphertext block (offset 16 past the IV). + let ciphertext = run_ok(&["aes128-cfb", "encrypt", "--key", KEY_128], &plaintext); + let mut tampered = ciphertext.clone(); + tampered[16 + 16 + 3] ^= 0b0010_0000; + let out = run_ok(&["aes128-cfb", "decrypt", "--key", KEY_128], &tampered); + + let mut expected = plaintext.clone(); + expected[16 + 3] ^= 0b0010_0000; // exactly that bit of P2 + assert_eq!( + &out[..32], + &expected[..32], + "CFB: P1 untouched and P2 should show exactly the flipped bit" + ); + // The following block is randomised, because C2 is the cipher input for P3. + assert_ne!(&out[32..48], &plaintext[32..48], "CFB: P3 should be randomised"); + // ...and nothing beyond it, since b/s = 1 at s = b. + assert_eq!(&out[48..], &plaintext[48..], "CFB: P4 should be untouched"); + + // CBC, the same edit: the targeted flip lands in the *next* block instead, and the block + // attacked is randomised. + let ciphertext = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext); + let mut tampered = ciphertext.clone(); + tampered[16 + 16 + 3] ^= 0b0010_0000; + let out = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &tampered); + + assert_ne!(&out[16..32], &expected[16..32], "CBC: P2 should be randomised, not flipped"); + let mut cbc_expected_p3 = plaintext[32..48].to_vec(); + cbc_expected_p3[3] ^= 0b0010_0000; + assert_eq!(&out[32..48], &cbc_expected_p3[..], "CBC: the flip should appear in P3"); +} + // ---- key handling ----------------------------------------------------------------------- /// `--key-file` accepts both a hex file and a raw binary file, and agrees with `--key`. @@ -225,7 +345,7 @@ fn key_file_accepts_hex_and_binary() { std::fs::write(&hex_path, KEY_128).expect("write hex key"); std::fs::write(&bin_path, unhex(KEY_128)).expect("write binary key"); - let input = unhex(&format!("{IV}{CT_128}")); + let input = unhex(&format!("{IV}{CBC_CT_128}")); let expected = unhex(PLAINTEXT); for path in [&hex_path, &bin_path] { @@ -239,9 +359,11 @@ fn key_file_accepts_hex_and_binary() { /// A key of the wrong length for the chosen variant is rejected, naming both lengths. #[test] fn a_key_of_the_wrong_length_is_rejected() { - let stderr = run_err(&["aes256-cbc", "encrypt", "--key", KEY_128], &unhex(PLAINTEXT)); - assert!(stderr.contains("32-byte key"), "stderr should name the expected length: {stderr}"); - assert!(stderr.contains("16 bytes"), "stderr should name the supplied length: {stderr}"); + for cmd in ["aes256-cbc", "aes256-cfb"] { + let stderr = run_err(&[cmd, "encrypt", "--key", KEY_128], &unhex(PLAINTEXT)); + assert!(stderr.contains("32-byte key"), "{cmd}: expected length: {stderr}"); + assert!(stderr.contains("16 bytes"), "{cmd}: supplied length: {stderr}"); + } } /// Omitting the key entirely is an error, not a default. @@ -269,42 +391,48 @@ fn an_all_zero_key_warns_but_proceeds() { /// rather than just failing. CBC has no answer for a partial block and there is no padding layer. #[test] fn unaligned_input_is_rejected_with_an_explanation() { - for extra in [1usize, 7, 15] { - let plaintext = pseudo_random(32 + extra, extra as u32); - let stderr = run_err(&["aes128-cbc", "encrypt", "--key", KEY_128], &plaintext); - assert!( - stderr.contains("whole number of 16-byte blocks"), - "stderr should explain the alignment requirement: {stderr}" - ); - assert!( - stderr.contains("padding"), - "stderr should point at the missing padding layer: {stderr}" - ); + for cmd in SHARED_PATH_MODES { + for extra in [1usize, 7, 15] { + let plaintext = pseudo_random(32 + extra, extra as u32); + let stderr = run_err(&[cmd, "encrypt", "--key", KEY_128], &plaintext); + assert!( + stderr.contains("whole number of 16-byte blocks"), + "stderr should explain the alignment requirement: {stderr}" + ); + assert!( + stderr.contains("padding"), + "stderr should point at the missing padding layer: {stderr}" + ); + } } } /// Decrypt input shorter than the IV it must start with is rejected, and says so. #[test] fn decrypt_input_shorter_than_the_iv_is_rejected() { - for len in [0usize, 1, 15] { - let stderr = run_err(&["aes128-cbc", "decrypt", "--key", KEY_128], &pseudo_random(len, 1)); - assert!( - stderr.contains("IV"), - "stderr should explain the missing IV (len {len}): {stderr}" - ); + for cmd in SHARED_PATH_MODES { + for len in [0usize, 1, 15] { + let stderr = run_err(&[cmd, "decrypt", "--key", KEY_128], &pseudo_random(len, 1)); + assert!( + stderr.contains("IV"), + "{cmd}: stderr should explain the missing IV (len {len}): {stderr}" + ); + } } } /// Decrypt input that carries the IV but then an unaligned body is rejected too. #[test] fn decrypt_rejects_an_unaligned_body() { - let mut input = unhex(IV); - input.extend_from_slice(&pseudo_random(20, 3)); // 20 is not a multiple of 16 - let stderr = run_err(&["aes128-cbc", "decrypt", "--key", KEY_128], &input); - assert!( - stderr.contains("whole number of 16-byte blocks"), - "stderr should explain the alignment requirement: {stderr}" - ); + for cmd in SHARED_PATH_MODES { + let mut input = unhex(IV); + input.extend_from_slice(&pseudo_random(20, 3)); // 20 is not a multiple of 16 + let stderr = run_err(&[cmd, "decrypt", "--key", KEY_128], &input); + assert!( + stderr.contains("whole number of 16-byte blocks"), + "{cmd}: stderr should explain the alignment requirement: {stderr}" + ); + } } /// Empty input to `encrypt` produces just the IV: zero blocks in, zero blocks out. @@ -313,12 +441,14 @@ fn decrypt_rejects_an_unaligned_body() { /// it is easy for a streaming loop to mishandle. #[test] fn empty_input_produces_only_the_iv() { - let out = run_ok(&["aes128-cbc", "encrypt", "--key", KEY_128], &[]); - assert_eq!(out.len(), 16, "empty input should yield exactly the IV"); + for cmd in SHARED_PATH_MODES { + let out = run_ok(&[cmd, "encrypt", "--key", KEY_128], &[]); + assert_eq!(out.len(), 16, "{cmd}: empty input should yield exactly the IV"); - // ...and feeding that straight back gives empty output. - let back = run_ok(&["aes128-cbc", "decrypt", "--key", KEY_128], &out); - assert!(back.is_empty(), "decrypting an IV with no body should give nothing"); + // ...and feeding that straight back gives empty output. + let back = run_ok(&[cmd, "decrypt", "--key", KEY_128], &out); + assert!(back.is_empty(), "{cmd}: decrypting an IV with no body should give nothing"); + } } // ---- cross-variant behaviour ------------------------------------------------------------ @@ -343,7 +473,7 @@ fn the_three_variants_are_not_interchangeable() { fn the_subcommands_are_listed_in_help() { let out = run_ok(&["--help"], &[]); let help = String::from_utf8_lossy(&out); - for cmd in ["aes128-cbc", "aes192-cbc", "aes256-cbc"] { + for (cmd, _, _) in ALL_MODES { assert!(help.contains(cmd), "`--help` should list {cmd}"); } } @@ -351,12 +481,33 @@ fn the_subcommands_are_listed_in_help() { /// Each subcommand's own help names the two actions and the IV convention. #[test] fn per_command_help_documents_the_iv_convention() { - let out = run_ok(&["aes128-cbc", "--help"], &[]); + for cmd in SHARED_PATH_MODES { + let out = run_ok(&[cmd, "--help"], &[]); + let help = String::from_utf8_lossy(&out); + assert!(help.contains("encrypt"), "{cmd}: help should list the encrypt action"); + assert!(help.contains("decrypt"), "{cmd}: help should list the decrypt action"); + assert!( + help.contains("FIRST 16 BYTES") || help.contains("first 16 bytes"), + "{cmd}: help should explain where the IV goes: {help}" + ); + } +} + +/// The CFB help must say which segment size it is, because "CFB" alone is ambiguous -- SP 800-38A +/// Sec 6.3 allows any `1 <= s <= b`, and CFB8 is a real and different mode that this is not. +#[test] +fn the_cfb_help_names_the_segment_size() { + for cmd in ["aes128-cfb", "aes192-cfb", "aes256-cfb"] { + let out = run_ok(&[cmd, "--help"], &[]); + let help = String::from_utf8_lossy(&out); + assert!( + help.contains("CFB128"), + "{cmd}: help should identify the segment size as CFB128: {help}" + ); + } + + // ...and the warning about its malleability must be there, since it differs from CBC's. + let out = run_ok(&["aes128-cfb", "--help"], &[]); let help = String::from_utf8_lossy(&out); - assert!(help.contains("encrypt"), "help should list the encrypt action"); - assert!(help.contains("decrypt"), "help should list the decrypt action"); - assert!( - help.contains("FIRST 16 BYTES") || help.contains("first 16 bytes"), - "help should explain where the IV goes: {help}" - ); + assert!(help.contains("WARNING"), "the CFB help should carry the malleability warning"); } diff --git a/crypto/aes-lowmemory/summary.md b/crypto/aes-lowmemory/summary.md index 20ab8fca..86e6cfa9 100644 --- a/crypto/aes-lowmemory/summary.md +++ b/crypto/aes-lowmemory/summary.md @@ -263,11 +263,12 @@ Two details worth knowing: `bc-test-data` ships thirteen ACVP AES vector sets, one per mode. This crate consumes only `ACVP-AES-ECB`, because that is the set that tests the permutation rather than a mode. -`ACVP-AES-CBC` is consumed by [`crypto/modes/tests/acvp_tests.rs`](../modes/tests/acvp_tests.rs) -(2150 AFT cases). The remaining eleven โ€” `CBC-CS1/2/3`, `CFB8`, `CFB128`, `OFB`, `CTR`, `KW`, -`KWP`, `FF1`, `FF3-1` โ€” are unused because those modes are unimplemented, not because they are -untested. The table in the ACVP test module's docs records which file goes where, so adding a mode -includes wiring up its file. +`ACVP-AES-CBC` (2150 AFT cases) and `ACVP-AES-CFB128` (2138) are consumed by +[`crypto/modes/tests/acvp_tests.rs`](../modes/tests/acvp_tests.rs). The remaining ten โ€” +`CBC-CS1/2/3`, `CFB8`, `OFB`, `CTR`, `KW`, `KWP`, `FF1`, `FF3-1` โ€” are unused because those modes +are unimplemented, not because they are untested. `CFB8` is a segment size rather than a separate +mode, and stays unused because `crypto/modes` provides only `s = b`. The table in the ACVP test +module's docs records which file goes where, so adding a mode includes wiring up its file. ### Constant-time hygiene audit diff --git a/crypto/aes-lowmemory/tests/acvp_tests.rs b/crypto/aes-lowmemory/tests/acvp_tests.rs index b54d9f05..70682aae 100644 --- a/crypto/aes-lowmemory/tests/acvp_tests.rs +++ b/crypto/aes-lowmemory/tests/acvp_tests.rs @@ -20,7 +20,8 @@ //! | `ACVP-AES-ECB` | this file | //! | `ACVP-AES-CBC` | `crypto/modes/tests/acvp_tests.rs` | //! | `ACVP-AES-CBC-CS1` / `-CS2` / `-CS3` | nothing yet (ciphertext stealing is unimplemented) | -//! | `ACVP-AES-CFB8` / `-CFB128` | nothing yet (CFB is unimplemented) | +//! | `ACVP-AES-CFB128` | `crypto/modes/tests/acvp_tests.rs` | +//! | `ACVP-AES-CFB8` | nothing yet (only the `s = b` CFB segment size is implemented) | //! | `ACVP-AES-OFB` | nothing yet (OFB is unimplemented) | //! | `ACVP-AES-CTR` | nothing yet (CTR is unimplemented) | //! | `ACVP-AES-KW` / `-KWP` | nothing yet (key wrap is unimplemented) | diff --git a/crypto/core-test-framework/summary.md b/crypto/core-test-framework/summary.md index dcd404e7..328d869c 100644 --- a/crypto/core-test-framework/summary.md +++ b/crypto/core-test-framework/summary.md @@ -52,13 +52,16 @@ the reason this suite is worth having rather than leaving each implementor to te The mirror image of this check lives in `crypto/modes/tests/common/mod.rs` as `SwappedPairToy`, a permutation whose pair methods deliberately swap their results, used to prove the *mode* really -takes the pair path. +takes the pair path. That file also holds `ForwardOnlyToy`, whose `decrypt_block` panics; it +deliberately violates the trait contract and must never be given to this suite, but it is what lets +`cfb_tests.rs` assert that CFB reaches for the inverse cipher in neither direction โ€” a property no +equality check could establish. ### Current implementors * `crypto/aes-lowmemory/tests/block_permutation_tests.rs` โ€” AES-128, AES-192, AES-256. * `crypto/modes/tests/cbc_tests.rs` โ€” the toy permutation, checked before anything is concluded - from it. + from it. `cfb_tests.rs` relies on that same check rather than repeating it. --- @@ -162,7 +165,7 @@ This is the pattern to reuse for CFB, OFB and CTR when they land. ```sh cargo build -p bouncycastle-core-test-framework -cargo test --workspace # 517 tests, 0 failures +cargo test --workspace # 547 tests, 0 failures cargo fmt --all -- --check ``` diff --git a/crypto/modes/benches/modes_benches.rs b/crypto/modes/benches/modes_benches.rs index 66cdaea8..deddafd5 100644 --- a/crypto/modes/benches/modes_benches.rs +++ b/crypto/modes/benches/modes_benches.rs @@ -10,6 +10,12 @@ //! //! `N = 1` is included to show the effect vanishing: with one block there is no pair to form, so //! decryption falls back to the single-block path and the ratio should be about 1. +//! +//! CFB has the same asymmetry for the same reason (Sec 6.3 says its encryption is serial "like CBC +//! encryption", and that its decryption can be parallelised once the input blocks are built), but +//! it reaches the pair path through `encrypt_blocks2` rather than `decrypt_blocks2`, because both +//! directions of CFB use the forward cipher function. Comparing the CFB and CBC groups also shows +//! what the forward-only property is worth on a cipher whose two directions differ in cost. use bouncycastle_aes_lowmemory::{Aes128, Aes256}; use bouncycastle_core::errors::SymmetricCipherError; @@ -17,7 +23,7 @@ use bouncycastle_core::key_material::{KeyMaterial, KeyType}; use bouncycastle_core::traits::{ BlockCipher, BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength, }; -use bouncycastle_modes::{Cbc, Decrypting, Encrypting}; +use bouncycastle_modes::{Cbc, Cfb, Decrypting, Encrypting}; use criterion::{Criterion, Throughput, criterion_group, criterion_main}; use std::hint::black_box; @@ -28,6 +34,7 @@ const DATA_LEN: usize = NUM_BLOCKS * BLOCK_LEN; type Aes128Cbc = Cbc; type Aes256Cbc = Cbc; +type Aes128Cfb = Cfb; /// AES-128 with the pair methods **not** overridden, so they fall back to the trait defaults of /// two single-block calls. @@ -59,6 +66,7 @@ impl BlockPermutation<16, BLOCK_LEN> for UnpairedAes128 { } type UnpairedAes128Cbc = Cbc; +type UnpairedAes128Cfb = Cfb; fn key() -> KeyMaterial { let bytes: [u8; N] = core::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(1)); @@ -215,6 +223,112 @@ fn bench_aes256(c: &mut Criterion) { group.finish(); } +/// CFB, AES-128. Mirrors the CBC group so the two are directly comparable. +/// +/// The pair path here is `encrypt_blocks2`, not `decrypt_blocks2`: SP 800-38A Sec 6.3 defines both +/// directions of CFB with the forward cipher function. So the `N=8, no pair path` comparison +/// measures the same thing the CBC one does, through the other pair method. +fn bench_cfb_aes128(c: &mut Criterion) { + let k = key::<16>(); + let blocks = data(); + + let mut group = c.benchmark_group("modes::cfb::Aes128"); + group.throughput(Throughput::Bytes(DATA_LEN as u64)); + + // ---- encryption: serial, Ij+1 = Cj is not known until Oj has been computed ---- + group.bench_function("16KiB encrypt -- N=1", |b| { + b.iter(|| { + let (mut enc, _) = Aes128Cfb::::do_encrypt_init(&k).unwrap(); + for block in blocks.iter() { + black_box(enc.do_encrypt_blocks(&[*block]).unwrap()); + } + }) + }); + + group.bench_function("16KiB encrypt -- N=8", |b| { + b.iter(|| { + let (mut enc, _) = Aes128Cfb::::do_encrypt_init(&k).unwrap(); + for chunk in blocks.chunks_exact(8) { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + black_box(enc.do_encrypt_blocks(arr).unwrap()); + } + }) + }); + + // ---- decryption: parallel, all input blocks are ciphertext ---- + let (mut enc, iv) = Aes128Cfb::::do_encrypt_init(&k).unwrap(); + let ciphertext: Vec<[u8; BLOCK_LEN]> = blocks + .chunks_exact(8) + .flat_map(|chunk| { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + enc.do_encrypt_blocks(arr).unwrap() + }) + .collect(); + + group.bench_function("16KiB decrypt -- N=1 (no pairing)", |b| { + b.iter(|| { + let mut dec = Aes128Cfb::::do_decrypt_init(&k, &iv).unwrap(); + for block in ciphertext.iter() { + black_box(dec.do_decrypt_blocks(&[*block]).unwrap()); + } + }) + }); + + group.bench_function("16KiB decrypt -- N=2 (all pairs)", |b| { + b.iter(|| { + let mut dec = Aes128Cfb::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(2) { + let arr: &[[u8; BLOCK_LEN]; 2] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + group.bench_function("16KiB decrypt -- N=8 (all pairs)", |b| { + b.iter(|| { + let mut dec = Aes128Cfb::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(8) { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + group.bench_function("16KiB decrypt -- N=9 (pairs + remainder)", |b| { + b.iter(|| { + let mut dec = Aes128Cfb::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(9) { + let arr: &[[u8; BLOCK_LEN]; 9] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + // The controlled comparison, as for CBC: identical N, identical cipher, `encrypt_blocks2` + // overridden vs left as the trait default. + group.bench_function("16KiB decrypt -- N=8, pair path (blocks2 overridden)", |b| { + b.iter(|| { + let mut dec = Aes128Cfb::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(8) { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + group.bench_function("16KiB decrypt -- N=8, no pair path (trait default)", |b| { + b.iter(|| { + let mut dec = UnpairedAes128Cfb::::do_decrypt_init(&k, &iv).unwrap(); + for chunk in ciphertext.chunks_exact(8) { + let arr: &[[u8; BLOCK_LEN]; 8] = chunk.try_into().unwrap(); + black_box(dec.do_decrypt_blocks(arr).unwrap()); + } + }) + }); + + group.finish(); +} + /// `do_*_init` includes a key expansion, and for encryption also an IV draw from the OS-backed /// DRBG. Worth its own measurement, because for short messages it dominates. fn bench_init(c: &mut Criterion) { @@ -241,5 +355,5 @@ fn bench_init(c: &mut Criterion) { group.finish(); } -criterion_group!(benches, bench_aes128, bench_aes256, bench_init); +criterion_group!(benches, bench_aes128, bench_aes256, bench_cfb_aes128, bench_init); criterion_main!(benches); diff --git a/crypto/modes/src/cfb.rs b/crypto/modes/src/cfb.rs new file mode 100644 index 00000000..9f7ec36d --- /dev/null +++ b/crypto/modes/src/cfb.rs @@ -0,0 +1,299 @@ +//! The Cipher Feedback mode of operation (NIST SP 800-38A Sec 6.3), full-block segment only. +//! +//! # The specification +//! +//! Sec 6.3 defines CFB with a segment size parameter `s`, "such that 1 <= s <= b", where `b` is the +//! block size. Quoting the general definition verbatim: +//! +//! ```text +//! CFB Encryption: I1 = IV; +//! Ij = LSB_{b-s}(Ij-1) | C#j-1 for j = 2 ... n; +//! Oj = CIPH_K(Ij) for j = 1, 2 ... n; +//! C#j = P#j XOR MSB_s(Oj) for j = 1, 2 ... n. +//! +//! CFB Decryption: I1 = IV; +//! Ij = LSB_{b-s}(Ij-1) | C#j-1 for j = 2 ... n; +//! Oj = CIPH_K(Ij) for j = 1, 2 ... n; +//! P#j = C#j XOR MSB_s(Oj) for j = 1, 2 ... n. +//! ``` +//! +//! # This implementation is `s = b` only +//! +//! [`Cfb`] implements the **full-block segment** case, `s = b` -- "the 128-bit CFB mode" in Sec +//! 6.3's naming, for a 128-bit block. That is the only value of `s` that is block-aligned, and so +//! the only one that fits the `BlockCipherEncryptor` / `BlockCipherDecryptor` contract. See the +//! crate docs for why CFB1 and CFB8 are out of scope. +//! +//! Substituting `s = b` collapses the equations exactly: +//! +//! * `LSB_{b-s}(Ij-1)` is `LSB_0(...)`, the empty bit string, so the concatenation +//! `LSB_0(Ij-1) | C#j-1` is just `C#j-1`. Hence `Ij = Cj-1`. +//! * `MSB_s(Oj)` is `MSB_b(Oj)`, the whole output block, so `MSB_b(Oj) = Oj`. +//! +//! leaving +//! +//! ```text +//! I1 = IV; Ij = Cj-1 (j >= 2); Oj = CIPH_K(Ij); Cj = Pj XOR Oj / Pj = Cj XOR Oj +//! ``` +//! +//! Sec 6.3's prose description of the feedback agrees: "the bits of the first input block +//! circularly shift s positions to the left, and then the ciphertext segment replaces the s least +//! significant bits of the result". At `s = b` a circular shift by `b` is the identity and the +//! ciphertext replaces all `b` bits, giving `Ij = Cj-1` again. +//! +//! As in [`crate::Cbc`], the `j = 1` and `j >= 2` cases differ only in what `Ij` is, so a single +//! `chain` field holds "the next input block", initialised to the IV and replaced by each +//! ciphertext block. There is no special case for the first block below. +//! +//! # Both directions use the *forward* cipher function +//! +//! This is the thing to notice. In the equations above, decryption computes `Oj = CIPH_K(Ij)` -- +//! `CIPH_K`, not `CIPH^-1_K`. Sec 6.3 says so in prose too: "The *forward cipher* function is +//! applied to each input block to produce the output blocks. The s most significant bits of the +//! output blocks are exclusive-ORed with the corresponding ciphertext segments to recover the +//! plaintext segments." +//! +//! So `Cfb` never calls [`BlockPermutation::decrypt_block`] or +//! [`BlockPermutation::decrypt_blocks2`], and a permutation with no working inverse at all would +//! still work in both directions of this mode. That is asserted directly by +//! `cfb_decryption_never_calls_the_inverse_cipher` in `tests/cfb_tests.rs`, which runs CFB over a +//! permutation whose `decrypt_block` panics. +//! +//! It also means CFB, unlike CBC, needs only half of a block cipher -- which is part of why the +//! mode is attractive for ciphers whose inverse is expensive. +//! +//! # Parallel decryption +//! +//! Sec 6.3: "In CFB encryption, like CBC encryption, the input block to each forward cipher +//! function (except the first) depends on the result of the previous forward cipher function; +//! therefore, multiple forward cipher operations cannot be performed in parallel. In CFB +//! decryption, the required forward cipher operations can be performed in parallel if the input +//! blocks are first constructed (in series) from the IV and the ciphertext." +//! +//! Decryption exploits that: the input blocks are `Ij = Cj-1`, all of which are ciphertext already +//! in hand, so a pair of them can go to [`BlockPermutation::encrypt_blocks2`] in one call. The +//! "constructed in series" caveat is what the code below does when it builds `[chain, Cj]` before +//! the call. Encryption is serial -- `Ij+1 = Cj = Pj XOR Oj` needs `Oj` first -- and does not pair. + +use crate::iv::random_iv; +use crate::{Decrypting, Encrypting}; +use bouncycastle_core::errors::SymmetricCipherError; +use bouncycastle_core::key_material::KeyMaterial; +use bouncycastle_core::traits::{ + BlockCipher, BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, RNG, + SecurityStrength, +}; +use bouncycastle_rng::HashDRBG_SHA512; +use core::marker::PhantomData; + +/// CFB mode with a full-block segment (`s = b`) over any [`BlockPermutation`], with the direction +/// encoded in the type. +/// +/// `Dir` is [`Encrypting`] or [`Decrypting`]. [`BlockCipherEncryptor`] is implemented only for the +/// former and [`BlockCipherDecryptor`] only for the latter, so using one in the wrong direction is +/// a compile error rather than a runtime check. +/// +/// The initialization data is one block, so `INIT_DATA_LEN == BLOCK_LEN`. +/// +/// # Segment size +/// +/// This is the `s = BLOCK_LEN * 8` variant of SP 800-38A Sec 6.3 -- "CFB128" for AES. The +/// sub-block segment sizes (CFB1, CFB8) are not block-aligned and are not implemented here; see +/// the module docs. +/// +/// # State +/// +/// Two fields, the same shape as [`crate::Cbc`]: the permutation (which owns the key schedule and +/// is responsible for keeping it in a zeroize-on-drop wrapper) and one block holding the next +/// input block `Ij`. `Ij` is an IV or a ciphertext block, both public, so it is deliberately not +/// wrapped in a `Secret`. +pub struct Cfb +where + P: BlockPermutation, +{ + perm: P, + /// `Ij`: the IV for `j = 1`, then `Cj-1`. See the module docs on why one field covers both. + chain: [u8; BLOCK_LEN], + _dir: PhantomData, +} + +impl Cfb +where + P: BlockPermutation, +{ + /// `Cj = Pj XOR CIPH_K(Ij)`, then `Cj` becomes the next input block. + /// + /// Serial: the next input block is the ciphertext produced here, so this cannot be batched. + #[inline] + fn encrypt_one(&mut self, plaintext: &[u8; BLOCK_LEN], ciphertext: &mut [u8; BLOCK_LEN]) { + let mut keystream = self.chain; // Ij + self.perm.encrypt_block(&mut keystream); // Oj = CIPH_K(Ij) + + for (out, (p, o)) in ciphertext.iter_mut().zip(plaintext.iter().zip(keystream.iter())) { + *out = *p ^ *o; // Cj = Pj XOR Oj + } + + self.chain = *ciphertext; // Ij+1 = Cj + } + + /// `Pj = Cj XOR CIPH_K(Ij)`, then `Cj` becomes the next input block. + /// + /// Note `encrypt_block`: the *forward* cipher function, as Sec 6.3 requires of CFB decryption. + /// See the module docs. + #[inline] + fn decrypt_one(&mut self, ciphertext: &[u8; BLOCK_LEN], plaintext: &mut [u8; BLOCK_LEN]) { + let mut keystream = self.chain; // Ij + self.perm.encrypt_block(&mut keystream); // Oj = CIPH_K(Ij) + + for (out, (c, o)) in plaintext.iter_mut().zip(ciphertext.iter().zip(keystream.iter())) { + *out = *c ^ *o; // Pj = Cj XOR Oj + } + + self.chain = *ciphertext; // Ij+1 = Cj + } + + /// Decrypts two consecutive blocks with one [`BlockPermutation::encrypt_blocks2`] call. + /// + /// Writing the pair as `Cj, Cj+1` with `Ij` the incoming input block, Sec 6.3 at `s = b` gives + /// + /// ```text + /// Ij (the chaining value) Oj = CIPH_K(Ij) Pj = Cj XOR Oj + /// Ij+1 = Cj Oj+1 = CIPH_K(Ij+1) Pj+1 = Cj+1 XOR Oj+1 + /// ``` + /// + /// Both input blocks are known before either cipher call: `Ij` is held in `chain` and `Ij+1` is + /// `Cj`, which is ciphertext already in hand. That is exactly the "input blocks are first + /// constructed (in series)" condition Sec 6.3 attaches to parallel CFB decryption, and it is + /// what the array literal below does. Neither forward cipher depends on the other's output, so + /// computing them together cannot change the result. + /// + /// Still the forward function, in both slots. + #[inline] + fn decrypt_pair( + &mut self, + ciphertext: &[[u8; BLOCK_LEN]; 2], + plaintext: &mut [[u8; BLOCK_LEN]; 2], + ) { + // [Ij, Ij+1] = [chain, Cj] -- constructed in series, then transformed together. + let mut keystream = [self.chain, ciphertext[0]]; + self.perm.encrypt_blocks2(&mut keystream); + + for ((out, c), o) in plaintext.iter_mut().zip(ciphertext.iter()).zip(keystream.iter()) { + for (out_byte, (c_byte, o_byte)) in out.iter_mut().zip(c.iter().zip(o.iter())) { + *out_byte = *c_byte ^ *o_byte; // Pj = Cj XOR Oj + } + } + + self.chain = ciphertext[1]; // Ij+2 = Cj+1 + } +} + +impl BlockCipher + for Cfb +where + P: BlockPermutation, +{ + /// A mode does not change the strength of the underlying cipher. + const MAX_SECURITY_STRENGTH: SecurityStrength =

::MAX_SECURITY_STRENGTH; +} + +impl + BlockCipherEncryptor for Cfb +where + P: BlockPermutation, +{ + /// Begins an encryption flow, generating the IV from the library's default OS-backed DRBG. + /// + /// Sec 5.3 puts CFB under the same requirement as CBC -- the IV "must be unpredictable" -- so + /// this is the same generated-not-accepted treatment. + fn do_encrypt_init( + key: &KeyMaterial, + ) -> Result<(Self, [u8; BLOCK_LEN]), SymmetricCipherError> { + let mut rng = HashDRBG_SHA512::new_from_os(); + Self::do_encrypt_init_rng(key, &mut rng) + } + + /// As [`BlockCipherEncryptor::do_encrypt_init`], but takes the IV from the provided RNG. + fn do_encrypt_init_rng( + key: &KeyMaterial, + rng: &mut dyn RNG, + ) -> Result<(Self, [u8; BLOCK_LEN]), SymmetricCipherError> { + let perm = P::new(key)?; + let iv = random_iv::(rng)?; + Ok((Self { perm, chain: iv, _dir: PhantomData }, iv)) + } + + fn do_encrypt_blocks( + &mut self, + plaintext: &[[u8; BLOCK_LEN]; N], + ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError> { + let mut ciphertext = [[0u8; BLOCK_LEN]; N]; + self.do_encrypt_blocks_out(plaintext, &mut ciphertext)?; + Ok(ciphertext) + } + + /// The real implementation; the by-value variant above is a wrapper over it. + /// + /// Strictly serial: `Ij+1 = Cj`, and `Cj` is not known until `Oj` has been computed. Sec 6.3 + /// says as much ("like CBC encryption ... cannot be performed in parallel"), so there is no + /// pair path here. + fn do_encrypt_blocks_out( + &mut self, + plaintext: &[[u8; BLOCK_LEN]; N], + ciphertext: &mut [[u8; BLOCK_LEN]; N], + ) -> Result { + for (p, c) in plaintext.iter().zip(ciphertext.iter_mut()) { + self.encrypt_one(p, c); + } + Ok(N * BLOCK_LEN) + } +} + +impl + BlockCipherDecryptor for Cfb +where + P: BlockPermutation, +{ + /// Begins a decryption flow from the IV returned by + /// [`BlockCipherEncryptor::do_encrypt_init`]. + fn do_decrypt_init( + key: &KeyMaterial, + init_data: &[u8; BLOCK_LEN], + ) -> Result { + let perm = P::new(key)?; + Ok(Self { perm, chain: *init_data, _dir: PhantomData }) + } + + fn do_decrypt_blocks( + &mut self, + ciphertext: &[[u8; BLOCK_LEN]; N], + ) -> Result<[[u8; BLOCK_LEN]; N], SymmetricCipherError> { + let mut plaintext = [[0u8; BLOCK_LEN]; N]; + self.do_decrypt_blocks_out(ciphertext, &mut plaintext)?; + Ok(plaintext) + } + + /// The real implementation; the by-value variant above is a wrapper over it. + /// + /// Walks the input in pairs so the permutation's two-block path is used, with an at-most-one + /// block remainder for odd `N`. `as_chunks` splits into exactly that shape with no runtime + /// length check and no indexing arithmetic; `N` is a compile-time constant, so for even `N` the + /// tail loop is empty and for `N = 1` the pair loop is. + fn do_decrypt_blocks_out( + &mut self, + ciphertext: &[[u8; BLOCK_LEN]; N], + plaintext: &mut [[u8; BLOCK_LEN]; N], + ) -> Result { + let (ct_pairs, ct_tail) = ciphertext.as_chunks::<2>(); + let (pt_pairs, pt_tail) = plaintext.as_chunks_mut::<2>(); + + for (ct_pair, pt_pair) in ct_pairs.iter().zip(pt_pairs.iter_mut()) { + self.decrypt_pair(ct_pair, pt_pair); + } + for (c, p) in ct_tail.iter().zip(pt_tail.iter_mut()) { + self.decrypt_one(c, p); + } + + Ok(N * BLOCK_LEN) + } +} diff --git a/crypto/modes/src/lib.rs b/crypto/modes/src/lib.rs index a25468aa..2efcda04 100644 --- a/crypto/modes/src/lib.rs +++ b/crypto/modes/src/lib.rs @@ -2,20 +2,34 @@ //! //! A mode turns a keyed block permutation -- `bouncycastle-aes-lowmemory`'s `Aes128` and friends, //! or anything else implementing [`BlockPermutation`] -- into something that can encrypt more than -//! one block. This crate currently provides **CBC** ([`Cbc`], SP 800-38A Sec 6.2). +//! one block. This crate provides: +//! +//! | Mode | Type | Spec | Notes | +//! |---|---|---|---| +//! | Cipher Block Chaining | [`Cbc`] | Sec 6.2 | Uses both directions of the permutation. | +//! | Cipher Feedback | [`Cfb`] | Sec 6.3 | Full-block segment (`s = b`) only, i.e. "CFB128" for AES. Uses the **forward** direction in both directions of the mode. | //! //! The crate is deliberately cipher-agnostic: it depends on no concrete block cipher, only on the //! trait. Define a one-line alias for the combination you use: //! //! ``` //! use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; -//! use bouncycastle_modes::Cbc; +//! use bouncycastle_modes::{Cbc, Cfb}; //! //! type Aes128Cbc

= Cbc; //! type Aes192Cbc = Cbc; //! type Aes256Cbc = Cbc; +//! +//! type Aes128Cfb = Cfb; +//! type Aes192Cfb = Cfb; +//! type Aes256Cfb = Cfb; //! ``` //! +//! Both types have the same shape and the same API, so swapping one for the other is a one-word +//! change. The differences that matter are in +//! [Security Considerations](#security-considerations): CFB's malleability is more directly +//! exploitable than CBC's. +//! //! # Usage Examples //! //! The direction is part of the type: [`Cbc`](Cbc) implements @@ -69,6 +83,29 @@ //! assert_eq!(decryptor.do_decrypt_blocks(&rest).unwrap(), [[0xBBu8; 16], [0xCCu8; 16]]); //! ``` //! +//! [`Cfb`] is a drop-in substitution -- same API, same IV handling: +//! +//! ``` +//! use bouncycastle_aes_lowmemory::Aes128; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor}; +//! use bouncycastle_modes::{Cfb, Decrypting, Encrypting}; +//! +//! type Aes128Cfb = Cfb; +//! +//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey) +//! .expect("a 16-byte symmetric cipher key"); +//! +//! let plaintext = [[0u8; 16], [1u8; 16], [2u8; 16]]; +//! +//! let (iv, ciphertext) = +//! Aes128Cfb::::encrypt_blocks(&key, &plaintext).expect("encryption"); +//! +//! let recovered = +//! Aes128Cfb::::decrypt_blocks(&key, &iv, &ciphertext).expect("decryption"); +//! assert_eq!(recovered, plaintext); +//! ``` +//! //! Using the wrong direction does not compile: //! //! ```compile_fail @@ -84,6 +121,21 @@ //! let _ = Aes128Cbc::::do_decrypt_init(&key, &[0u8; 16]); //! ``` //! +//! ...and the same for [`Cfb`], since the guarantee is per-type rather than crate-wide: +//! +//! ```compile_fail +//! use bouncycastle_aes_lowmemory::Aes128; +//! use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +//! use bouncycastle_core::traits::BlockCipherEncryptor; +//! use bouncycastle_modes::{Cfb, Decrypting}; +//! +//! type Aes128Cfb = Cfb; +//! let key = KeyMaterial::<16>::from_bytes_as_type(&[0x42; 16], KeyType::SymmetricCipherKey).unwrap(); +//! +//! // `Decrypting` does not implement `BlockCipherEncryptor`. +//! let _ = Aes128Cfb::::do_encrypt_init(&key); +//! ``` +//! //! # Block alignment //! //! These types are **strictly block-aligned**: whole blocks in, whole blocks out, no finalization @@ -91,6 +143,11 @@ //! plaintext must be a multiple of the block size"), and Appendix A puts the formatting of //! non-aligned data outside the scope of the recommendation. //! +//! For CFB, Sec 5.2 requires the plaintext length to be a multiple of the *segment* size `s` +//! rather than the block size. Since [`Cfb`] is the `s = b` variant, the two coincide and it is +//! block-aligned for the same reason. (A sub-block CFB would not be, which is one of the reasons +//! CFB1 and CFB8 do not fit these traits -- see [Not yet implemented](#not-yet-implemented).) +//! //! Arbitrary-length data therefore needs a padding layer on top. That layer is *not* in this //! crate, and at the time of writing is not in the workspace at all -- see //! [Not yet implemented](#not-yet-implemented). @@ -98,34 +155,61 @@ //! # Memory Usage //! //! No heap allocation, and no lookup tables of its own. A mode value is the permutation plus one -//! block of chaining value: +//! block of chaining value, for both modes: //! //! ```text //! size_of::>() == size_of::

() + BLOCK_LEN +//! size_of::>() == size_of::

() + BLOCK_LEN //! ``` //! //! | Combination | Permutation | Chain | Total | //! |---|---|---|---| -//! | AES-128 CBC | 176 B | 16 B | 192 B | -//! | AES-192 CBC | 208 B | 16 B | 224 B | -//! | AES-256 CBC | 240 B | 16 B | 256 B | +//! | AES-128 CBC or CFB | 176 B | 16 B | 192 B | +//! | AES-192 CBC or CFB | 208 B | 16 B | 224 B | +//! | AES-256 CBC or CFB | 240 B | 16 B | 256 B | +//! +//! The two modes are the same size because they hold the same thing: one block that is an IV to +//! begin with and a ciphertext block thereafter. What differs is only what it is *used* for -- an +//! XOR operand in CBC, the cipher input in CFB. //! //! `do_*_blocks_out::` adds nothing; the by-value `do_*_blocks::` adds `N * BLOCK_LEN` of -//! stack for the returned array. [`Encrypting`] and [`Decrypting`] are zero-sized and held in a -//! `PhantomData`, so encoding the direction in the type is free. The table is pinned by -//! `sizes_match_the_documented_memory_table` in `tests/cbc_tests.rs`. +//! stack for the returned array. CFB additionally uses one block of stack per call for the +//! keystream (two for the pair path), which does not scale with `N`. [`Encrypting`] and +//! [`Decrypting`] are zero-sized and held in a `PhantomData`, so encoding the direction in the type +//! is free. The table is pinned by `sizes_match_the_documented_memory_table` in `tests/cbc_tests.rs` +//! and `tests/cfb_tests.rs`. //! //! # Security Considerations //! -//! ## CBC is not authenticated +//! ## Neither mode is authenticated +//! +//! Both provide confidentiality only. Neither detects tampering, and both are malleable in +//! specific, exploitable ways. **Authenticate the ciphertext.** Prefer an AEAD; if you must use +//! one of these, MAC the ciphertext *and* the IV, and verify before decrypting. +//! +//! SP 800-38A Appendix D, Table D.2, gives the exact malleability, and the two modes differ in a +//! way that matters. Writing SBE for "specific bit errors, i.e., bit errors occur in the same bit +//! position(s) as the original bit error(s)" and RBE for "random bit errors": +//! +//! | Flipping a bit of `Cj` gives | CBC | CFB (`s = b`) | +//! |---|---|---| +//! | in the decryption of `Cj` | RBE | **SBE** | +//! | in the decryption of `Cj+1` | SBE | RBE | +//! | in later blocks | none | none | //! -//! CBC provides confidentiality only. It does not detect tampering, and it is malleable in -//! specific, exploitable ways -- SP 800-38A Appendix D: flipping a bit of `Cj` flips the same bit -//! of the decryption of `Cj+1`, and randomises the decryption of `Cj` itself. **Authenticate the -//! ciphertext.** Prefer an AEAD; if you must use CBC, MAC the ciphertext *and* the IV, and verify -//! before decrypting. +//! The rows are swapped, and the consequence is that **CFB is the more directly attackable of the +//! two**: flipping bit `k` of a CFB ciphertext block flips exactly bit `k` of *that same block's* +//! plaintext, so an attacker who knows the plaintext can set it to anything they choose, exactly as +//! with a stream cipher. In CBC the targeted flip lands in the *following* block, and the block +//! attacked is randomised. //! -//! Combining CBC decryption with a padding check is the classic padding-oracle setup. Do not +//! Appendix D also notes the detection side: "for every ciphertext segment except the last one, the +//! existence of such bit errors may be detected by their randomizing effect on the decryption of +//! the succeeding ciphertext segment". Read the exception. **For the final CFB block there is no +//! succeeding block to be randomised**, so a bit flip there produces a precisely chosen plaintext +//! change with no structural trace whatsoever. Do not rely on garbling to notice tampering. +//! +//! Combining a mode's decryption with a padding check is the classic padding-oracle setup. Do not //! report padding failures distinguishably, and do not decrypt unauthenticated ciphertext. //! //! ## The IV must be unpredictable, and this crate generates it @@ -144,56 +228,81 @@ //! //! Appendix D: "for the CBC mode, the decryption of the first ciphertext block is vulnerable to the //! (deliberate) introduction of bit errors in specific bit positions of the IV if the integrity of -//! the IV is not protected". A flipped IV bit flips exactly that bit of `P1`. The IV need not be -//! secret, but it must be authenticated along with the ciphertext. +//! the IV is not protected". A flipped IV bit flips exactly that bit of `P1`. +//! +//! For CFB the IV is an input to the cipher rather than an XOR operand, so Table D.2 gives RBE +//! instead: a flipped IV bit randomises the whole of `P1` (and, at `s = b`, nothing further). Less +//! of a targeted-modification vector than CBC's, but still a tampering vector. +//! +//! Either way the IV need not be secret, but it must be authenticated along with the ciphertext. //! //! ## Key and IV reuse //! -//! Nothing here stops one key being used for many messages, which is fine for CBC provided each -//! gets a fresh unpredictable IV. It is the IV, not the key, that must not repeat. +//! Nothing here stops one key being used for many messages, which is fine for either mode provided +//! each gets a fresh unpredictable IV. It is the IV, not the key, that must not repeat. +//! +//! **Repeating an IV is worse in CFB than in CBC.** CFB at `s = b` XORs the plaintext with +//! `O1 = CIPH_K(IV)`, so two messages under the same key and IV have the same first keystream +//! block, and `C1 XOR C1' = P1 XOR P1'` -- a two-time pad, leaking the XOR of the plaintexts +//! outright, and continuing into later blocks for as long as the ciphertexts agree. Under CBC an +//! IV repeat leaks only whether the messages share a prefix. //! //! # Not yet implemented //! //! * **Padding.** There is no `Padding` trait, `PKCS7`, `PaddedEncryptor` or `PaddedDecryptor` in -//! this workspace yet, so arbitrary-length CBC is not available. When that layer lands, CBC gets -//! it for free by being wrapped -- no padding logic belongs in this crate. -//! * **CFB** (SP 800-38A Sec 6.3), and the other three modes of the recommendation (ECB, OFB, CTR). +//! this workspace yet, so arbitrary-length CBC and CFB are not available. When that layer lands, +//! both get it for free by being wrapped -- no padding logic belongs in this crate. +//! * **Sub-block CFB segments** (CFB1, CFB8, and any other `s < b`). Sec 6.3 allows any +//! `1 <= s <= b`, but only `s = b` is block-aligned, and `BlockCipherEncryptor` / +//! `BlockCipherDecryptor` are block-aligned by contract. A sub-block CFB is a stream cipher in +//! shape -- it consumes `s` bits at a time and holds a partially-used input block between calls +//! -- so it belongs behind a `StreamCipher` trait, not this one. Note that a CFB with `s < b` +//! also invokes the block cipher once per `s` bits, so CFB1 costs 128 AES calls per block. +//! * **ECB, OFB and CTR**, the remaining three modes of the recommendation. ECB is a mode in name +//! only and should not be added as an encryption API. //! //! # Command line //! -//! The `bc-rust` CLI exposes CBC as `aes128-cbc`, `aes192-cbc` and `aes256-cbc`, each taking -//! `encrypt` or `decrypt` and streaming stdin to stdout. Because there is no API for a -//! caller-supplied IV, `encrypt` writes the generated IV as the first block of its output and -//! `decrypt` reads it back from the first block of its input, so the two compose: +//! The `bc-rust` CLI exposes both modes for all three AES key lengths -- `aes128-cbc`, +//! `aes192-cbc`, `aes256-cbc`, `aes128-cfb`, `aes192-cfb`, `aes256-cfb` -- each taking `encrypt` or +//! `decrypt` and streaming stdin to stdout. Because there is no API for a caller-supplied IV, +//! `encrypt` writes the generated IV as the first block of its output and `decrypt` reads it back +//! from the first block of its input, so the two compose: //! //! ```text //! bc-rust aes256-cbc encrypt --key-file k.bin < plain.bin > cipher.bin //! bc-rust aes256-cbc decrypt --key-file k.bin < cipher.bin | cmp - plain.bin +//! +//! bc-rust aes128-cfb encrypt --key-file k.bin < plain.bin > cipher.bin +//! bc-rust aes128-cfb decrypt --key-file k.bin < cipher.bin | cmp - plain.bin //! ``` //! -//! Input must be block-aligned there too, for the reason given above. +//! Input must be block-aligned there too, for the reason given above. The CFB subcommands are the +//! `s = b` variant, i.e. CFB128. #![no_std] #![forbid(unsafe_code)] #![forbid(missing_docs)] mod cbc; +mod cfb; mod iv; pub use cbc::Cbc; +pub use cfb::Cfb; // Imports needed for docs #[allow(unused_imports)] use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation}; // end of imports needed for docs -/// Direction marker for a mode that encrypts. See [`Cbc`]. +/// Direction marker for a mode that encrypts. See [`Cbc`] and [`Cfb`]. /// /// Zero-sized: encoding the direction in the type costs no memory. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Encrypting; -/// Direction marker for a mode that decrypts. See [`Cbc`]. +/// Direction marker for a mode that decrypts. See [`Cbc`] and [`Cfb`]. /// /// Zero-sized: encoding the direction in the type costs no memory. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crypto/modes/tests/acvp_tests.rs b/crypto/modes/tests/acvp_tests.rs index 47f8b504..1a30070f 100644 --- a/crypto/modes/tests/acvp_tests.rs +++ b/crypto/modes/tests/acvp_tests.rs @@ -1,44 +1,52 @@ -//! Known-answer tests against the NIST ACVP `ACVP-AES-CBC` vectors from the `bc-test-data` repo. +//! Known-answer tests against the NIST ACVP `ACVP-AES-CBC` and `ACVP-AES-CFB128` vectors from the +//! `bc-test-data` repo. //! //! Requires `bc-test-data` to be cloned alongside this repository, i.e. at `../bc-test-data` -//! relative to the root of this git project. If it is absent the test prints a warning and passes, +//! relative to the root of this git project. If it is absent the tests print a warning and pass, //! matching the convention used by the ML-KEM, ML-DSA and `aes-lowmemory` suites -- `cargo test` //! must stay green for someone who has only cloned this repository. //! //! These are the counterpart to `crypto/aes-lowmemory/tests/acvp_tests.rs`, which consumes the -//! `ACVP-AES-ECB` file to test the raw permutation. CBC is a mode, so its vectors belong here. +//! `ACVP-AES-ECB` file to test the raw permutation. CBC and CFB are modes, so their vectors belong +//! here. `ACVP-AES-CFB128` is the `s = b` segment size, which is the variant `Cfb` implements; the +//! separate `ACVP-AES-CFB8` file is for a segment size this crate does not provide, and stays +//! unused. //! //! # Joining the request and response files //! -//! Unlike the ECB response file, which echoes `key`, `pt` and `ct` for every case, the CBC response -//! file carries **only the answer** (`ct` for an encrypt group, `pt` for a decrypt group) against a +//! Unlike the ECB response file, which echoes `key`, `pt` and `ct` for every case, these response +//! files carry **only the answer** (`ct` for an encrypt group, `pt` for a decrypt group) against a //! `tcId`. The key, IV and input live in the request file, and the group metadata that says which //! direction a case is -- `direction` and `keyLen` -- lives only there too. So both files are read //! and joined on `tcId`; there is no way to drive this from the response file alone. //! //! # Coverage //! -//! 2150 AFT (Algorithm Functional Test) cases across all three key lengths and both directions, -//! including 60 whose payload spans 2 to 10 blocks. Every case is run **twice**: once block by -//! block, and once in pairs with a one-block remainder for odd lengths. The second pass is what -//! puts the multi-block cases through `BlockPermutation::decrypt_blocks2`, so the pair path is -//! exercised against real vectors and not only against the toy in `cbc_tests.rs`. +//! | Vector set | AFT cases | Multi-block | MCT (skipped) | +//! |---|---|---|---| +//! | `ACVP-AES-CBC` | 2150 | 60 | 6 | +//! | `ACVP-AES-CFB128` | 2138 | 54 | 6 | //! -//! The 6 MCT (Monte Carlo Test) groups are **not** implemented: their expected output is a +//! Both across all three key lengths and both directions, with the multi-block cases spanning 2 to +//! 10 blocks. Every case is run **twice**: once block by block, and once in pairs with a one-block +//! remainder for odd lengths. The second pass is what puts the multi-block cases through the +//! permutation's two-block path -- `decrypt_blocks2` for CBC, `encrypt_blocks2` for CFB, since CFB +//! decryption uses the forward cipher -- so the pair path is exercised against real vectors and not +//! only against the toys in `cbc_tests.rs` and `cfb_tests.rs`. +//! +//! The MCT (Monte Carlo Test) groups are **not** implemented: their expected output is a //! `resultsArray` produced by a chained update rule defined in the ACVP AES specification rather -//! than in SP 800-38A, and implementing it from anything else would be guesswork. The test reports -//! how many it skipped so the gap stays visible. +//! than in SP 800-38A, and implementing it from anything else would be guesswork. The tests report +//! how many they skipped so the gap stays visible. use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; use bouncycastle_core::key_material::{ KeyMaterial, KeyMaterialTrait, KeyType, do_hazardous_operations, }; -use bouncycastle_core::traits::{ - BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength, -}; +use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor, SecurityStrength}; use bouncycastle_core_test_framework::FixedSeedRNG; use bouncycastle_hex as hex; -use bouncycastle_modes::{Cbc, Decrypting, Encrypting}; +use bouncycastle_modes::{Cbc, Cfb, Decrypting, Encrypting}; use serde_json::Value; use std::collections::BTreeMap; use std::fs; @@ -52,19 +60,47 @@ const TEST_DATA_PATHS: [&str; 2] = [ "../bc-test-data/crypto/aes_tdes_vectors/AES", ]; -const REQUEST_FILE: &str = "ACVP-AES-CBC.4014528.req.json"; -const RESPONSE_FILE: &str = "ACVP-AES-CBC.4014528.rsp.json"; +/// Which mode a vector set is for. Selects both the files and the types under test. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Mode { + Cbc, + Cfb128, +} + +impl Mode { + fn request_file(self) -> &'static str { + match self { + Mode::Cbc => "ACVP-AES-CBC.4014528.req.json", + Mode::Cfb128 => "ACVP-AES-CFB128.4014530.req.json", + } + } + + fn response_file(self) -> &'static str { + match self { + Mode::Cbc => "ACVP-AES-CBC.4014528.rsp.json", + Mode::Cfb128 => "ACVP-AES-CFB128.4014530.rsp.json", + } + } + + fn label(self) -> &'static str { + match self { + Mode::Cbc => "AES-CBC", + Mode::Cfb128 => "AES-CFB128", + } + } +} -fn test_data_dir() -> Option { +fn test_data_dir(mode: Mode) -> Option { for candidate in TEST_DATA_PATHS { let path = Path::new(candidate); - if path.join(REQUEST_FILE).exists() && path.join(RESPONSE_FILE).exists() { + if path.join(mode.request_file()).exists() && path.join(mode.response_file()).exists() { return Some(path.to_path_buf()); } } println!( "WARNING: bc-test-data not found (looked in {TEST_DATA_PATHS:?}); \ - ACVP AES-CBC tests will be skipped" + ACVP {} tests will be skipped", + mode.label() ); None } @@ -98,12 +134,13 @@ enum Grouping { Pairs, } -/// Runs one CBC case in one direction, for a given permutation, under the given grouping. +/// Runs one case in one direction, for a given encryptor/decryptor pair, under the given grouping. /// -/// Encryption is driven through `do_encrypt_init_rng` with a `FixedSeedRNG` emitting the vector's -/// IV, and the returned init data is checked against that IV before any ciphertext is compared -- -/// so a change that ignored the RNG could not pass silently. -fn run_case( +/// Generic over the mode types rather than over the permutation, so the same body drives CBC and +/// CFB. Encryption is driven through `do_encrypt_init_rng` with a `FixedSeedRNG` emitting the +/// vector's IV, and the returned init data is checked against that IV before any ciphertext is +/// compared -- so a change that ignored the RNG could not pass silently. +fn run_case( key_bytes: &[u8], iv: [u8; BLOCK_LEN], input: &[[u8; BLOCK_LEN]], @@ -111,17 +148,16 @@ fn run_case( grouping: Grouping, ) -> Vec<[u8; BLOCK_LEN]> where - P: BlockPermutation, + E: BlockCipherEncryptor, + D: BlockCipherDecryptor, { let key = cipher_key::(key_bytes); let mut out: Vec<[u8; BLOCK_LEN]> = Vec::with_capacity(input.len()); if encrypt { - let (mut enc, got_iv) = Cbc::::do_encrypt_init_rng( - &key, - &mut FixedSeedRNG::::new(iv), - ) - .expect("encrypt init"); + let (mut enc, got_iv) = + E::do_encrypt_init_rng(&key, &mut FixedSeedRNG::::new(iv)) + .expect("encrypt init"); assert_eq!(got_iv, iv, "the pinned RNG should reproduce the vector's IV"); match grouping { @@ -143,8 +179,7 @@ where } } } else { - let mut dec = - Cbc::::do_decrypt_init(&key, &iv).expect("dec init"); + let mut dec = D::do_decrypt_init(&key, &iv).expect("dec init"); match grouping { Grouping::Single => { @@ -169,24 +204,54 @@ where out } -/// Dispatches on key length, which is what selects the AES parameter set. -fn run_case_for_key_len( +/// Dispatches on the mode and the key length, which together select the concrete types. +fn run_case_for( + mode: Mode, key_bytes: &[u8], iv: [u8; BLOCK_LEN], input: &[[u8; BLOCK_LEN]], encrypt: bool, grouping: Grouping, ) -> Vec<[u8; BLOCK_LEN]> { - match key_bytes.len() { - 16 => run_case::(key_bytes, iv, input, encrypt, grouping), - 24 => run_case::(key_bytes, iv, input, encrypt, grouping), - 32 => run_case::(key_bytes, iv, input, encrypt, grouping), - other => panic!("ACVP AES vectors should only use 16, 24 or 32 byte keys, got {other}"), + match (mode, key_bytes.len()) { + (Mode::Cbc, 16) => run_case::< + Cbc, + Cbc, + 16, + >(key_bytes, iv, input, encrypt, grouping), + (Mode::Cbc, 24) => run_case::< + Cbc, + Cbc, + 24, + >(key_bytes, iv, input, encrypt, grouping), + (Mode::Cbc, 32) => run_case::< + Cbc, + Cbc, + 32, + >(key_bytes, iv, input, encrypt, grouping), + (Mode::Cfb128, 16) => run_case::< + Cfb, + Cfb, + 16, + >(key_bytes, iv, input, encrypt, grouping), + (Mode::Cfb128, 24) => run_case::< + Cfb, + Cfb, + 24, + >(key_bytes, iv, input, encrypt, grouping), + (Mode::Cfb128, 32) => run_case::< + Cfb, + Cfb, + 32, + >(key_bytes, iv, input, encrypt, grouping), + (_, other) => { + panic!("ACVP AES vectors should only use 16, 24 or 32 byte keys, got {other}") + } } } fn to_blocks(bytes: &[u8]) -> Vec<[u8; BLOCK_LEN]> { - assert_eq!(bytes.len() % BLOCK_LEN, 0, "ACVP CBC payloads are block-aligned"); + assert_eq!(bytes.len() % BLOCK_LEN, 0, "ACVP payloads here are block-aligned"); bytes.chunks(BLOCK_LEN).map(|c| c.try_into().unwrap()).collect() } @@ -198,16 +263,19 @@ fn decode(value: &Value, field: &str, tc_id: u64) -> Vec { hex::decode(s).unwrap_or_else(|_| panic!("tcId {tc_id}: bad hex in {field}")) } -#[test] -fn acvp_aes_cbc_known_answer_tests() { - let Some(dir) = test_data_dir() else { return }; +/// Drives one whole vector set. Asserts everything; returns nothing. +/// +/// `min_cases` and `min_multi_block` guard against a silently-empty or partial run, which is the +/// failure mode a data-driven test is most prone to. +fn run_vector_set(mode: Mode, min_cases: usize, min_multi_block: usize) { + let Some(dir) = test_data_dir(mode) else { return }; let req: Value = serde_json::from_str( - &fs::read_to_string(dir.join(REQUEST_FILE)).expect("readable request file"), + &fs::read_to_string(dir.join(mode.request_file())).expect("readable request file"), ) .expect("valid ACVP request JSON"); let rsp: Value = serde_json::from_str( - &fs::read_to_string(dir.join(RESPONSE_FILE)).expect("readable response file"), + &fs::read_to_string(dir.join(mode.response_file())).expect("readable response file"), ) .expect("valid ACVP response JSON"); @@ -273,11 +341,12 @@ fn acvp_aes_cbc_known_answer_tests() { } for grouping in [Grouping::Single, Grouping::Pairs] { - let got = run_case_for_key_len(&key_bytes, iv, &input, encrypt, grouping); + let got = run_case_for(mode, &key_bytes, iv, &input, encrypt, grouping); assert_eq!( got, expected, - "tcId {tc_id}: AES-{} CBC {direction}, {} blocks, {grouping:?} grouping", + "tcId {tc_id}: {} AES-{} {direction}, {} blocks, {grouping:?} grouping", + mode.label(), key_bytes.len() * 8, input.len() ); @@ -289,15 +358,33 @@ fn acvp_aes_cbc_known_answer_tests() { } for (kind, n) in &per_kind { - println!("ACVP AES-CBC {kind}: {n} cases"); + println!("ACVP {} {kind}: {n} cases", mode.label()); } println!( - "ACVP AES-CBC: {checked} AFT cases checked in two groupings each \ - ({multi_block} of them multi-block); {skipped_mct} MCT cases skipped" + "ACVP {}: {checked} AFT cases checked in two groupings each \ + ({multi_block} of them multi-block); {skipped_mct} MCT cases skipped", + mode.label() ); - // Guard against a silently-empty or partial run. - assert!(checked > 2000, "expected the full ACVP AFT set, only checked {checked}"); - assert!(multi_block >= 60, "expected the multi-block cases, found {multi_block}"); + assert!( + checked >= min_cases, + "expected at least {min_cases} AFT cases for {}, only checked {checked}", + mode.label() + ); + assert!( + multi_block >= min_multi_block, + "expected at least {min_multi_block} multi-block cases for {}, found {multi_block}", + mode.label() + ); assert_eq!(per_kind.len(), 6, "expected all three key lengths in both directions"); } + +#[test] +fn acvp_aes_cbc_known_answer_tests() { + run_vector_set(Mode::Cbc, 2150, 60); +} + +#[test] +fn acvp_aes_cfb128_known_answer_tests() { + run_vector_set(Mode::Cfb128, 2138, 54); +} diff --git a/crypto/modes/tests/cfb_tests.rs b/crypto/modes/tests/cfb_tests.rs new file mode 100644 index 00000000..b2fa4450 --- /dev/null +++ b/crypto/modes/tests/cfb_tests.rs @@ -0,0 +1,489 @@ +//! Structural tests for CFB, driven by a toy permutation. +//! +//! These check the properties of the *mode* -- keystream generation, feedback, call sequencing, the +//! pair/remainder split, direction typing, SP 800-38A Appendix D error propagation -- independently +//! of any real cipher. The known-answer tests against SP 800-38A Appendix F.3.13 through F.3.18 are +//! in `sp800_38a_tests.rs`. +//! +//! Two things here have no counterpart in `cbc_tests.rs`, because they are what distinguishes CFB: +//! +//! * `cfb_decryption_never_calls_the_inverse_cipher` -- Sec 6.3 defines both directions in terms of +//! `CIPH_K`, so a permutation with no inverse must still work. +//! * `a_ciphertext_bit_error_flips_exactly_that_bit_of_the_same_block` -- Table D.2 gives CFB +//! *specific* bit errors in the block attacked, where CBC gives random ones. This is the +//! malleability difference the crate docs warn about, asserted rather than asserted-in-prose. + +mod common; + +use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; +use bouncycastle_core::key_material::{KeyMaterial, KeyType}; +use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor}; +use bouncycastle_core_test_framework::FixedSeedRNG; +use bouncycastle_core_test_framework::symmetric_ciphers::TestFrameworkBlockCipher; +use bouncycastle_modes::{Cfb, Decrypting, Encrypting}; +use common::{ForwardOnlyToy, SwappedPairToy, TOY_LEN, Toy, toy_key}; + +type ToyCfb

= Cfb; +type SwappedCfb = Cfb; +type ForwardOnlyCfb = Cfb; + +/// A fixed IV, so encryption runs are comparable. `do_encrypt_init` would generate a fresh one. +fn pinned_iv() -> [u8; TOY_LEN] { + core::array::from_fn(|i| 0x5A ^ (i as u8).wrapping_mul(3)) +} + +// ---- the mode against the shared framework ----------------------------------------------- + +/// The toy's own conformance is checked in `cbc_tests.rs`; this is the mode's. +#[test] +fn cfb_conforms_to_the_block_cipher_framework() { + TestFrameworkBlockCipher::new() + .test::, ToyCfb>(); +} + +// ---- the defining property: forward function only ---------------------------------------- + +/// SP 800-38A Sec 6.3 defines **both** directions of CFB with the forward cipher function: +/// "Oj = CIPH_K(Ij) for j = 1, 2 ... n" appears identically under "CFB Encryption" and "CFB +/// Decryption", and the prose confirms it ("The *forward cipher* function is applied to each input +/// block to produce the output blocks ... to recover the plaintext segments"). +/// +/// [`ForwardOnlyToy`] panics if its `decrypt_block` is ever called. So this test failing means the +/// implementation reached for the inverse cipher somewhere, which would make CFB unusable with a +/// forward-only primitive and would not match the spec. +/// +/// Both the single-block and the pair path are covered: `N = 1` skips the pair loop, `N = 2` is a +/// pure pair, and `N = 3` is a pair plus a remainder. +#[test] +fn cfb_decryption_never_calls_the_inverse_cipher() { + let key = toy_key(); + let iv = pinned_iv(); + let plaintext: [[u8; TOY_LEN]; 3] = + core::array::from_fn(|i| core::array::from_fn(|j| (i * 37 + j) as u8)); + + // Encryption, too -- it should also only ever use the forward function. + let (mut enc, got_iv) = + ForwardOnlyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)) + .unwrap(); + assert_eq!(got_iv, iv); + let ct = enc.do_encrypt_blocks(&plaintext).unwrap(); + + // N = 3: one pair through the pair path, then a one-block remainder. + let mut dec = ForwardOnlyCfb::::do_decrypt_init(&key, &iv).unwrap(); + assert_eq!(dec.do_decrypt_blocks(&ct).unwrap(), plaintext, "N=3 (pair + remainder)"); + + // N = 1 three times: never forms a pair. + let mut dec = ForwardOnlyCfb::::do_decrypt_init(&key, &iv).unwrap(); + for (c, p) in ct.iter().zip(plaintext.iter()) { + assert_eq!(dec.do_decrypt_blocks(&[*c]).unwrap(), [*p], "N=1"); + } + + // N = 2: a pure pair. + let mut dec = ForwardOnlyCfb::::do_decrypt_init(&key, &iv).unwrap(); + assert_eq!( + dec.do_decrypt_blocks(&[ct[0], ct[1]]).unwrap(), + [plaintext[0], plaintext[1]], + "N=2 (pure pair)" + ); +} + +/// The same ciphertext must come out of the real toy and the forward-only one, so the test above is +/// not passing because `ForwardOnlyToy` behaves differently rather than because CFB avoids the +/// inverse. +#[test] +fn the_forward_only_toy_agrees_with_the_real_one() { + let key = toy_key(); + let iv = pinned_iv(); + let plaintext = [[0x11u8; TOY_LEN], [0x22u8; TOY_LEN], [0x33u8; TOY_LEN]]; + + let (mut a, _) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + let (mut b, _) = + ForwardOnlyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)) + .unwrap(); + + assert_eq!(a.do_encrypt_blocks(&plaintext).unwrap(), b.do_encrypt_blocks(&plaintext).unwrap()); +} + +// ---- keystream and feedback -------------------------------------------------------------- + +/// At `s = b` the first ciphertext block is `C1 = P1 XOR CIPH_K(IV)`, which makes the keystream +/// directly observable: encrypting an all-zero block yields `CIPH_K(IV)` itself. +/// +/// This pins the collapse of the Sec 6.3 equations documented in `cfb.rs`: `I1 = IV`, +/// `MSB_b(O1) = O1`. If the implementation XOR-ed the plaintext in before the cipher call (i.e. did +/// CBC), or fed back the plaintext instead of the ciphertext, this would not hold. +#[test] +fn the_first_keystream_block_is_the_encrypted_iv() { + use bouncycastle_core::traits::BlockPermutation; + + let key = toy_key(); + let iv = pinned_iv(); + + // What the raw permutation makes of the IV. + let perm = >::new(&key).unwrap(); + let mut expected = iv; + perm.encrypt_block(&mut expected); + + // Encrypting zeros exposes the keystream. + let (mut enc, _) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + let [c1] = enc.do_encrypt_blocks(&[[0u8; TOY_LEN]]).unwrap(); + assert_eq!(c1, expected, "C1 = 0 XOR CIPH_K(IV) = CIPH_K(IV)"); + + // And the second input block is C1, not P1: encrypting zeros again gives CIPH_K(C1). + let mut expected2 = c1; + perm.encrypt_block(&mut expected2); + let [c2] = enc.do_encrypt_blocks(&[[0u8; TOY_LEN]]).unwrap(); + assert_eq!(c2, expected2, "I2 = C1, so C2 = CIPH_K(C1)"); +} + +/// CFB feeds back the *ciphertext*. Two identical plaintext blocks in one message must therefore +/// still give different ciphertext blocks, and -- unlike a mode that fed back the plaintext -- the +/// keystream must not repeat when the plaintext does. +#[test] +fn identical_plaintext_blocks_give_different_ciphertext_blocks() { + let key = toy_key(); + let plaintext = [[0x99u8; TOY_LEN]; 4]; + + let (_, ct) = ToyCfb::::encrypt_blocks(&key, &plaintext).unwrap(); + for i in 0..4 { + for j in (i + 1)..4 { + assert_ne!(ct[i], ct[j], "blocks {i} and {j} of the ciphertext repeat"); + } + } +} + +// ---- call sequencing --------------------------------------------------------------------- + +/// Grouping the calls differently must not change the result, in either direction. For decryption +/// the odd groupings matter specifically: `N = 3` and `N = 5` leave a one-block remainder after the +/// pair loop, and `N = 1` skips the pair loop entirely. +#[test] +fn call_grouping_does_not_change_the_result() { + let key = toy_key(); + let iv = pinned_iv(); + let plaintext: [[u8; TOY_LEN]; 8] = + core::array::from_fn(|i| core::array::from_fn(|j| (i * TOY_LEN + j) as u8)); + + // Reference: all eight in one call. + let (mut enc, got_iv) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + assert_eq!(got_iv, iv); + let reference = enc.do_encrypt_blocks(&plaintext).unwrap(); + + // Encryption, grouped 1 + 2 + 3 + 2. + let (mut enc, _) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + let mut got = [[0u8; TOY_LEN]; 8]; + let a = enc.do_encrypt_blocks(&[plaintext[0]]).unwrap(); + let b = enc.do_encrypt_blocks(&[plaintext[1], plaintext[2]]).unwrap(); + let c = enc.do_encrypt_blocks(&[plaintext[3], plaintext[4], plaintext[5]]).unwrap(); + let d = enc.do_encrypt_blocks(&[plaintext[6], plaintext[7]]).unwrap(); + got[0] = a[0]; + got[1..3].copy_from_slice(&b); + got[3..6].copy_from_slice(&c); + got[6..8].copy_from_slice(&d); + assert_eq!(got, reference, "grouping must not change the ciphertext"); + + // Decryption, in uniform groups. + let ct = reference; + let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap(); + assert_eq!(dec.do_decrypt_blocks(&ct).unwrap(), plaintext); + + for grouping in [1usize, 2, 4] { + let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap(); + let mut out = [[0u8; TOY_LEN]; 8]; + let mut at = 0; + while at < 8 { + match grouping { + 1 => { + let [p] = dec.do_decrypt_blocks(&[ct[at]]).unwrap(); + out[at] = p; + } + 2 => { + let p = dec.do_decrypt_blocks(&[ct[at], ct[at + 1]]).unwrap(); + out[at..at + 2].copy_from_slice(&p); + } + _ => { + let p = dec + .do_decrypt_blocks(&[ct[at], ct[at + 1], ct[at + 2], ct[at + 3]]) + .unwrap(); + out[at..at + 4].copy_from_slice(&p); + } + } + at += grouping; + } + assert_eq!(out, plaintext, "decrypting in groups of {grouping}"); + } + + // 3 + 5: both leave a one-block remainder after the pair loop. + let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap(); + let three = dec.do_decrypt_blocks(&[ct[0], ct[1], ct[2]]).unwrap(); + let five = dec.do_decrypt_blocks(&[ct[3], ct[4], ct[5], ct[6], ct[7]]).unwrap(); + assert_eq!(three, [plaintext[0], plaintext[1], plaintext[2]]); + assert_eq!(five, [plaintext[3], plaintext[4], plaintext[5], plaintext[6], plaintext[7]]); +} + +/// The pair path in `do_decrypt_blocks_out` must actually be taken. +/// +/// [`SwappedPairToy`] returns its two pair results in the wrong order while its single-block +/// methods are correct. CFB decryption calls `encrypt_blocks2` (not `decrypt_blocks2`), which that +/// toy also swaps, so a pair must come out wrong and a lone block must come out right. If both were +/// right, the pair path would be dead code. +#[test] +fn the_pair_path_is_really_used() { + let key = toy_key(); + let iv = pinned_iv(); + let plaintext = [[0xA5u8; TOY_LEN], [0x5Au8; TOY_LEN]]; + + // The correct toy round-trips. + let (mut enc, _) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + let ct = enc.do_encrypt_blocks(&plaintext).unwrap(); + let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap(); + assert_eq!(dec.do_decrypt_blocks(&ct).unwrap(), plaintext); + + // CFB *encryption* uses only the single-block forward call, so the swapped toy encrypts + // identically to the correct one. + let (mut enc, _) = + SwappedCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + let swapped_ct = enc.do_encrypt_blocks(&plaintext).unwrap(); + assert_eq!(swapped_ct, ct, "encryption must not use the pair path"); + + // ...but decrypting the pair together must now be wrong. + let mut dec = SwappedCfb::::do_decrypt_init(&key, &iv).unwrap(); + assert_ne!( + dec.do_decrypt_blocks(&swapped_ct).unwrap(), + plaintext, + "decrypting a pair must go through encrypt_blocks2" + ); + + // One block at a time avoids the pair path, so it is correct even for this toy. + let mut dec = SwappedCfb::::do_decrypt_init(&key, &iv).unwrap(); + let [p0] = dec.do_decrypt_blocks(&[swapped_ct[0]]).unwrap(); + let [p1] = dec.do_decrypt_blocks(&[swapped_ct[1]]).unwrap(); + assert_eq!([p0, p1], plaintext, "the single-block path must not pair"); +} + +/// The `_out` variants must agree with the by-value ones and report the byte count. +#[test] +fn out_variants_agree_with_by_value() { + let key = toy_key(); + let iv = pinned_iv(); + let plaintext = [[0x11u8; TOY_LEN], [0x22u8; TOY_LEN], [0x33u8; TOY_LEN]]; + + let (mut enc, _) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + let by_value = enc.do_encrypt_blocks(&plaintext).unwrap(); + + let (mut enc, _) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + let mut out = [[0u8; TOY_LEN]; 3]; + let n = enc.do_encrypt_blocks_out(&plaintext, &mut out).unwrap(); + assert_eq!(n, 3 * TOY_LEN); + assert_eq!(out, by_value); + + let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap(); + let mut back = [[0u8; TOY_LEN]; 3]; + let n = dec.do_decrypt_blocks_out(&out, &mut back).unwrap(); + assert_eq!(n, 3 * TOY_LEN); + assert_eq!(back, plaintext); +} + +// ---- SP 800-38A Appendix D error propagation --------------------------------------------- + +/// Appendix D, Table D.2, CFB row: bit errors in `Cj` give "SBE in the decryption of Cj", where SBE +/// means "bit errors occur in the same bit position(s) as the original bit error(s)". +/// +/// This is the CFB-specific malleability, and it is the opposite way round from CBC: there the +/// targeted flip lands in `Cj+1` and `Cj` is randomised. Because `Pj = Cj XOR Oj` and `Oj` does not +/// depend on `Cj`, flipping a bit of `Cj` flips exactly that bit of `Pj`. +/// +/// Checked for every one of the 128 bit positions, on a middle block so the knock-on effect on the +/// next block can be checked at the same time. +#[test] +fn a_ciphertext_bit_error_flips_exactly_that_bit_of_the_same_block() { + let key = toy_key(); + let iv = pinned_iv(); + let plaintext = [[0x00u8; TOY_LEN], [0x11u8; TOY_LEN], [0x22u8; TOY_LEN], [0x33u8; TOY_LEN]]; + + let (mut enc, _) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + let ct = enc.do_encrypt_blocks(&plaintext).unwrap(); + + for byte in 0..TOY_LEN { + for bit in 0..8 { + let mut corrupt = ct; + corrupt[1][byte] ^= 1 << bit; + + let mut dec = ToyCfb::::do_decrypt_init(&key, &iv).unwrap(); + let got = dec.do_decrypt_blocks(&corrupt).unwrap(); + + // P1 depends on the IV only, so it is untouched. + assert_eq!(got[0], plaintext[0], "byte {byte} bit {bit}: P1 must be unaffected"); + + // SBE: exactly the flipped bit of P2, and nothing else in that block. + let mut expected_p2 = plaintext[1]; + expected_p2[byte] ^= 1 << bit; + assert_eq!( + got[1], expected_p2, + "byte {byte} bit {bit}: P2 should show exactly that bit flipped" + ); + + // RBE in the next block: C2 is the cipher input for P3, so P3 is randomised. The + // toy is not a random function, but the value must at least differ. + assert_ne!(got[2], plaintext[2], "byte {byte} bit {bit}: P3 must be disturbed"); + + // ...and, at s = b, b/s = 1, so nothing beyond P3 is affected. + assert_eq!(got[3], plaintext[3], "byte {byte} bit {bit}: P4 must be unaffected"); + } + } +} + +/// Appendix D, Table D.2, CFB row: bit errors in the IV give "RBE in the decryption of +/// C1, C2, ..., Cj for some j between 1 and b/s". At `s = b` that is `j = 1`, so only `P1` is +/// affected -- and randomly, not in the same bit position, because the IV is a cipher *input* here +/// rather than an XOR operand. +/// +/// The contrast with CBC is the point: the identical test in `cbc_tests.rs` asserts the flipped bit +/// appears verbatim in `P1`. Here it must not. +#[test] +fn an_iv_bit_error_randomises_only_the_first_block() { + let key = toy_key(); + let iv = pinned_iv(); + let plaintext = [[0x00u8; TOY_LEN], [0x11u8; TOY_LEN], [0x22u8; TOY_LEN]]; + + let (mut enc, _) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + let ct = enc.do_encrypt_blocks(&plaintext).unwrap(); + + let mut single_bit_flips = 0usize; + + for byte in 0..TOY_LEN { + for bit in 0..8 { + let mut corrupt_iv = iv; + corrupt_iv[byte] ^= 1 << bit; + + let mut dec = ToyCfb::::do_decrypt_init(&key, &corrupt_iv).unwrap(); + let got = dec.do_decrypt_blocks(&ct).unwrap(); + + assert_ne!(got[0], plaintext[0], "IV byte {byte} bit {bit}: P1 must be disturbed"); + assert_eq!(got[1], plaintext[1], "IV byte {byte} bit {bit}: P2 must be unaffected"); + assert_eq!(got[2], plaintext[2], "IV byte {byte} bit {bit}: P3 must be unaffected"); + + // Count the cases where the damage happened to be a single bit in the same position, + // which is what CBC would give every time. + let mut cbc_like = plaintext[0]; + cbc_like[byte] ^= 1 << bit; + if got[0] == cbc_like { + single_bit_flips += 1; + } + } + } + + // The toy is a byte-wise permutation, not a random function, so a handful of coincidences are + // possible; what must not happen is CBC's behaviour across the board. + assert!( + single_bit_flips < 8, + "an IV bit error should randomise P1, not flip the same bit ({single_bit_flips}/128 \ + positions behaved like CBC)" + ); +} + +// ---- IV handling ------------------------------------------------------------------------- + +/// Sec 5.3 covers CFB with CBC: the IV "must be unpredictable". A fresh one per encryption is the +/// mechanism, and for CFB an IV repeat is worse than for CBC (see the crate docs) -- with the same +/// key and IV, the first keystream block repeats and the ciphertexts leak the XOR of the +/// plaintexts. +#[test] +fn each_encryption_gets_a_fresh_iv() { + let key = toy_key(); + let mut seen = std::collections::BTreeSet::new(); + for _ in 0..64 { + let (_, iv) = ToyCfb::::do_encrypt_init(&key).unwrap(); + assert!(seen.insert(iv), "IV repeated across encryptions: {iv:02x?}"); + } +} + +/// The consequence of the above, stated as a test: identical plaintext encrypts differently, and +/// the reason is the IV. +#[test] +fn identical_plaintext_gives_different_ciphertext() { + let key = toy_key(); + let plaintext = [[0x77u8; TOY_LEN], [0x77u8; TOY_LEN]]; + + let (_, first) = ToyCfb::::encrypt_blocks(&key, &plaintext).unwrap(); + let (_, second) = ToyCfb::::encrypt_blocks(&key, &plaintext).unwrap(); + assert_ne!(first, second); +} + +/// Reusing an IV under CFB gives a two-time pad on the first block: `C1 XOR C1' = P1 XOR P1'`. +/// +/// This documents the hazard the crate docs describe, and pins the arithmetic behind it. It is not +/// a property of this implementation to be fixed -- it is why `do_encrypt_init` refuses to take an +/// IV -- so the test asserts the leak exists, which is what makes the warning true. +#[test] +fn an_iv_repeat_leaks_the_xor_of_the_plaintexts() { + let key = toy_key(); + let iv = pinned_iv(); + + let p1 = [[0x01u8; TOY_LEN]]; + let p2 = [[0xFEu8; TOY_LEN]]; + + let (mut a, _) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + let (mut b, _) = + ToyCfb::::do_encrypt_init_rng(&key, &mut FixedSeedRNG::new(iv)).unwrap(); + + let c1 = a.do_encrypt_blocks(&p1).unwrap(); + let c2 = b.do_encrypt_blocks(&p2).unwrap(); + + for i in 0..TOY_LEN { + assert_eq!( + c1[0][i] ^ c2[0][i], + p1[0][i] ^ p2[0][i], + "byte {i}: the shared keystream cancels, leaving the plaintext XOR" + ); + } +} + +// ---- key handling ------------------------------------------------------------------------ + +#[test] +fn a_key_of_the_wrong_type_is_rejected() { + let bytes: [u8; TOY_LEN] = core::array::from_fn(|i| (i as u8) + 1); + let seed = KeyMaterial::::from_bytes_as_type(&bytes, KeyType::Seed).unwrap(); + assert!(ToyCfb::::do_encrypt_init(&seed).is_err()); + assert!(ToyCfb::::do_decrypt_init(&seed, &[0u8; TOY_LEN]).is_err()); +} + +// ---- memory ------------------------------------------------------------------------------ + +/// Pins the "Memory Usage" table in the crate docs, including the claim that CFB and CBC are the +/// same size. +#[test] +fn sizes_match_the_documented_memory_table() { + use bouncycastle_modes::Cbc; + use core::mem::size_of; + + assert_eq!(size_of::>(), 176 + 16); + assert_eq!(size_of::>(), 208 + 16); + assert_eq!(size_of::>(), 240 + 16); + + // The direction marker is free. + assert_eq!( + size_of::>(), + size_of::>() + ); + + // The general rule the docs state. + assert_eq!(size_of::>(), size_of::() + 16); + + // ...and that the two modes cost the same, which the docs claim explicitly. + assert_eq!( + size_of::>(), + size_of::>() + ); +} diff --git a/crypto/modes/tests/common/mod.rs b/crypto/modes/tests/common/mod.rs index fcb52c5b..a0f8f402 100644 --- a/crypto/modes/tests/common/mod.rs +++ b/crypto/modes/tests/common/mod.rs @@ -113,6 +113,45 @@ impl BlockPermutation for SwappedPairToy { } } +/// A toy with **no inverse at all**: its `decrypt_block` panics. +/// +/// This deliberately violates the [`BlockPermutation`] contract, and is not a permutation in any +/// useful sense. It exists to assert a property of CFB that no equality check can: SP 800-38A Sec +/// 6.3 defines *both* directions of CFB in terms of the forward cipher function `CIPH_K`, so a +/// correct `Cfb` never calls the inverse. Running CFB over this type turns any such call into a +/// test failure, in either direction, rather than a silently wrong answer. +/// +/// It must never be handed to `TestFrameworkBlockPermutation`, which would rightly reject it, and +/// it must never be used with [`crate::common::Toy`]'s sibling modes -- CBC decryption genuinely +/// needs the inverse and would panic. +/// +/// `decrypt_blocks2` is deliberately **not** overridden: the trait default calls `decrypt_block` +/// twice, so the pair path panics too, and both paths are covered by the one type. +pub struct ForwardOnlyToy { + inner: Toy, +} + +impl BlockCipher for ForwardOnlyToy { + const MAX_SECURITY_STRENGTH: SecurityStrength = SecurityStrength::_128bit; +} + +impl BlockPermutation for ForwardOnlyToy { + fn new(key: &KeyMaterial) -> Result { + Ok(Self { inner: Toy::new(key)? }) + } + + fn encrypt_block(&self, block: &mut [u8; TOY_LEN]) { + self.inner.encrypt_block(block); + } + + fn decrypt_block(&self, _block: &mut [u8; TOY_LEN]) { + panic!( + "the inverse cipher function was called, but SP 800-38A Sec 6.3 defines both \ + directions of CFB using the forward cipher function CIPH_K" + ); + } +} + /// Builds a `KeyMaterial` for the toys from a fixed non-zero pattern. pub fn toy_key() -> KeyMaterial { let bytes: [u8; TOY_LEN] = core::array::from_fn(|i| (i as u8).wrapping_mul(7).wrapping_add(1)); diff --git a/crypto/modes/tests/sp800_38a_tests.rs b/crypto/modes/tests/sp800_38a_tests.rs index 9bc24fd2..a4afc0f1 100644 --- a/crypto/modes/tests/sp800_38a_tests.rs +++ b/crypto/modes/tests/sp800_38a_tests.rs @@ -1,10 +1,16 @@ -//! Known-answer tests from NIST SP 800-38A Appendix F.2, "CBC Example Vectors". +//! Known-answer tests from NIST SP 800-38A Appendix F. //! -//! Sections F.2.1 through F.2.6: CBC-AES128, CBC-AES192 and CBC-AES256, Encrypt and Decrypt. All -//! six share the same IV and the same four plaintext blocks (Appendix F preamble); only the key and -//! the resulting ciphertext differ. The three keys are the same three used by FIPS 197 Appendix A -//! and SP 800-38A F.1, so these vectors also re-check each AES key expansion through a second -//! construction. +//! * **F.2.1 - F.2.6**, "CBC Example Vectors": CBC-AES128, CBC-AES192 and CBC-AES256, Encrypt and +//! Decrypt. +//! * **F.3.13 - F.3.18**, part of "CFB Example Vectors": CFB128-AES128, CFB128-AES192 and +//! CFB128-AES256, Encrypt and Decrypt. These are the `s = b` subsections, which is the variant +//! [`Cfb`] implements; the CFB1 (F.3.1 - F.3.6) and CFB8 (F.3.7 - F.3.12) subsections are for +//! segment sizes this crate does not provide. +//! +//! All twelve share the same IV and the same four plaintext blocks (Appendix F preamble); only the +//! key, the mode and the resulting ciphertext differ. The three keys are the same three used by +//! FIPS 197 Appendix A and SP 800-38A F.1, so these vectors also re-check each AES key expansion +//! through a second construction. //! //! Transcribed from the published SP 800-38A PDF (2001 edition). //! @@ -14,17 +20,25 @@ //! through [`BlockCipherEncryptor::do_encrypt_init_rng`] with a [`FixedSeedRNG`] whose stream is //! the vector's IV, and the test asserts the returned init data really is that IV before comparing //! any ciphertext. Decryption takes the IV directly, as init data. +//! +//! # One set of helpers, both modes +//! +//! `check_encrypt` and `check_decrypt` are generic over the *encryptor* and *decryptor* types +//! rather than over the permutation, so the same code drives `Cbc` and `Cfb`. That is only possible +//! because the two modes present an identical API, which is itself worth pinning. use bouncycastle_aes_lowmemory::{Aes128, Aes192, Aes256}; use bouncycastle_core::key_material::{KeyMaterial, KeyType}; -use bouncycastle_core::traits::{BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation}; +use bouncycastle_core::traits::{ + BlockCipherDecryptor, BlockCipherEncryptor, BlockPermutation, SecurityStrength, +}; use bouncycastle_core_test_framework::FixedSeedRNG; use bouncycastle_hex as hex; -use bouncycastle_modes::{Cbc, Decrypting, Encrypting}; +use bouncycastle_modes::{Cbc, Cfb, Decrypting, Encrypting}; const BLOCK_LEN: usize = 16; -/// The IV shared by every Appendix F.2 subsection. +/// The IV shared by every Appendix F.2 and F.3 subsection. const IV: &str = "000102030405060708090a0b0c0d0e0f"; /// The four plaintext blocks shared by every Appendix F subsection (Appendix F preamble). @@ -35,36 +49,91 @@ const PLAINTEXTS: [&str; 4] = [ "f69f2445df4f9b17ad2b417be66c3710", ]; -/// F.2.1 / F.2.2 key. +/// The AES-128 key, shared by F.2.1/F.2.2 and F.3.13/F.3.14. const KEY_128: &str = "2b7e151628aed2a6abf7158809cf4f3c"; +/// The AES-192 key, shared by F.2.3/F.2.4 and F.3.15/F.3.16. +const KEY_192: &str = "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b"; +/// The AES-256 key, shared by F.2.5/F.2.6 and F.3.17/F.3.18. +const KEY_256: &str = "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"; + +// ---- CBC, Appendix F.2 ------------------------------------------------------------------- + /// F.2.1 CBC-AES128.Encrypt output blocks. -const CIPHERTEXTS_128: [&str; 4] = [ +const CBC_CIPHERTEXTS_128: [&str; 4] = [ "7649abac8119b246cee98e9b12e9197d", "5086cb9b507219ee95db113a917678b2", "73bed6b8e3c1743b7116e69e22229516", "3ff1caa1681fac09120eca307586e1a7", ]; -/// F.2.3 / F.2.4 key. -const KEY_192: &str = "8e73b0f7da0e6452c810f32b809079e562f8ead2522c6b7b"; /// F.2.3 CBC-AES192.Encrypt output blocks. -const CIPHERTEXTS_192: [&str; 4] = [ +const CBC_CIPHERTEXTS_192: [&str; 4] = [ "4f021db243bc633d7178183a9fa071e8", "b4d9ada9ad7dedf4e5e738763f69145a", "571b242012fb7ae07fa9baac3df102e0", "08b0e27988598881d920a9e64f5615cd", ]; -/// F.2.5 / F.2.6 key. -const KEY_256: &str = "603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4"; /// F.2.5 CBC-AES256.Encrypt output blocks. -const CIPHERTEXTS_256: [&str; 4] = [ +const CBC_CIPHERTEXTS_256: [&str; 4] = [ "f58c4c04d6e5f1ba779eabfb5f7bfbd6", "9cfc4e967edb808d679f777bc6702c7d", "39f23369a9d9bacfa530e26304231461", "b2eb05e2c39be9fcda6c19078c6a9d1b", ]; +// ---- CFB128, Appendix F.3.13 - F.3.18 ---------------------------------------------------- + +/// F.3.13 CFB128-AES128.Encrypt ciphertext segments. +const CFB_CIPHERTEXTS_128: [&str; 4] = [ + "3b3fd92eb72dad20333449f8e83cfb4a", + "c8a64537a0b3a93fcde3cdad9f1ce58b", + "26751f67a3cbb140b1808cf187a4f4df", + "c04b05357c5d1c0eeac4c66f9ff7f2e6", +]; + +/// F.3.15 CFB128-AES192.Encrypt ciphertext segments. +const CFB_CIPHERTEXTS_192: [&str; 4] = [ + "cdc80d6fddf18cab34c25909c99a4174", + "67ce7f7f81173621961a2b70171d3d7a", + "2e1e8a1dd59b88b1c8e60fed1efac4c9", + "c05f9f9ca9834fa042ae8fba584b09ff", +]; + +/// F.3.17 CFB128-AES256.Encrypt ciphertext segments. +const CFB_CIPHERTEXTS_256: [&str; 4] = [ + "dc7e84bfda79164b7ecd8486985d3860", + "39ffed143b28b1c832113c6331e5407b", + "df10132415e54b92a13ed0a8267ae2f9", + "75a385741ab9cef82031623d55b1e471", +]; + +/// The "Output Block" column of F.3.13, i.e. `Oj = CIPH_K(Ij)` -- the CFB keystream. +const CFB_OUTPUT_BLOCKS_128: [&str; 4] = [ + "50fe67cc996d32b6da0937e99bafec60", + "668bcf60beb005a35354a201dab36bda", + "16bd032100975551547b4de89daea630", + "36d42170a312871947ef8714799bc5f6", +]; + +/// The "Output Block" column of F.3.15. +const CFB_OUTPUT_BLOCKS_192: [&str; 4] = [ + "a609b38df3b1133dddff2718ba09565e", + "c9e3f5289f149abd08ad44dc52b2b32b", + "1ed6965b76c76ca02d1dcef404f09626", + "36c0bbd976ccd4b7ef85cec1be273eef", +]; + +/// The "Output Block" column of F.3.17. +const CFB_OUTPUT_BLOCKS_256: [&str; 4] = [ + "b7bf3a5df43989dd97f0fa97ebce2f4a", + "97d26743252b1d54aca653cf744ace2a", + "efd80f62b6b9af8344c511b13c70b016", + "833ca131c5f655ef8d1a2346b3ddd361", +]; + +// ---- helpers ----------------------------------------------------------------------------- + fn block(hex_str: &str) -> [u8; BLOCK_LEN] { hex::decode(hex_str).expect("valid hex").try_into().expect("16 bytes") } @@ -80,13 +149,13 @@ fn key_material(hex_str: &str) -> KeyMaterial { .expect("a valid symmetric cipher key") } -/// Runs one Appendix F.2 encrypt subsection. +/// Runs one Appendix F encrypt subsection, for any mode. /// /// Checks the whole message in one call, then again one block at a time, then again through the /// `_out` variant -- the vector should not care how the calls are grouped. -fn check_encrypt(section: &str, key_hex: &str, expected: &[&str; 4]) +fn check_encrypt(section: &str, key_hex: &str, expected: &[&str; 4]) where - P: BlockPermutation, + E: BlockCipherEncryptor, { let key = key_material::(key_hex); let iv = block(IV); @@ -94,108 +163,234 @@ where let ct = blocks(expected); // All four blocks in one call. - let (mut enc, got_iv) = Cbc::::do_encrypt_init_rng( - &key, - &mut FixedSeedRNG::::new(iv), - ) - .unwrap(); + let (mut enc, got_iv) = + E::do_encrypt_init_rng(&key, &mut FixedSeedRNG::::new(iv)).unwrap(); assert_eq!(got_iv, iv, "{section}: the pinned RNG should produce the vector's IV"); assert_eq!(enc.do_encrypt_blocks(&pt).unwrap(), ct, "{section}: four blocks in one call"); // One block at a time. - let (mut enc, _) = Cbc::::do_encrypt_init_rng( - &key, - &mut FixedSeedRNG::::new(iv), - ) - .unwrap(); + let (mut enc, _) = + E::do_encrypt_init_rng(&key, &mut FixedSeedRNG::::new(iv)).unwrap(); for (i, (p, c)) in pt.iter().zip(ct.iter()).enumerate() { let [got] = enc.do_encrypt_blocks(&[*p]).unwrap(); assert_eq!(&got, c, "{section}: block #{}", i + 1); } // Through the `_out` variant. - let (mut enc, _) = Cbc::::do_encrypt_init_rng( - &key, - &mut FixedSeedRNG::::new(iv), - ) - .unwrap(); + let (mut enc, _) = + E::do_encrypt_init_rng(&key, &mut FixedSeedRNG::::new(iv)).unwrap(); let mut out = [[0u8; BLOCK_LEN]; 4]; let n = enc.do_encrypt_blocks_out(&pt, &mut out).unwrap(); assert_eq!(n, 4 * BLOCK_LEN); assert_eq!(out, ct, "{section}: _out variant"); } -/// Runs one Appendix F.2 decrypt subsection. +/// Runs one Appendix F decrypt subsection, for any mode. /// /// Checks one call, one block at a time, and the odd grouping `3 + 1` -- which is the grouping that /// leaves a one-block remainder after the pair loop in `do_decrypt_blocks_out`. -fn check_decrypt(section: &str, key_hex: &str, ciphertext: &[&str; 4]) +fn check_decrypt(section: &str, key_hex: &str, ciphertext: &[&str; 4]) where - P: BlockPermutation, + D: BlockCipherDecryptor, { let key = key_material::(key_hex); let iv = block(IV); let pt = blocks(&PLAINTEXTS); let ct = blocks(ciphertext); - type Dec = Cbc; - // All four blocks in one call (two pairs, no remainder). - let mut dec = Dec::::do_decrypt_init(&key, &iv).unwrap(); + let mut dec = D::do_decrypt_init(&key, &iv).unwrap(); assert_eq!(dec.do_decrypt_blocks(&ct).unwrap(), pt, "{section}: four blocks in one call"); // One block at a time (never takes the pair path). - let mut dec = Dec::::do_decrypt_init(&key, &iv).unwrap(); + let mut dec = D::do_decrypt_init(&key, &iv).unwrap(); for (i, (c, p)) in ct.iter().zip(pt.iter()).enumerate() { let [got] = dec.do_decrypt_blocks(&[*c]).unwrap(); assert_eq!(&got, p, "{section}: block #{}", i + 1); } // 3 + 1: one pair plus a remainder, then a lone block. - let mut dec = Dec::::do_decrypt_init(&key, &iv).unwrap(); + let mut dec = D::do_decrypt_init(&key, &iv).unwrap(); let three = dec.do_decrypt_blocks(&[ct[0], ct[1], ct[2]]).unwrap(); let one = dec.do_decrypt_blocks(&[ct[3]]).unwrap(); assert_eq!(three, [pt[0], pt[1], pt[2]], "{section}: blocks 1-3"); assert_eq!(one, [pt[3]], "{section}: block 4"); // Through the `_out` variant. - let mut dec = Dec::::do_decrypt_init(&key, &iv).unwrap(); + let mut dec = D::do_decrypt_init(&key, &iv).unwrap(); let mut out = [[0u8; BLOCK_LEN]; 4]; let n = dec.do_decrypt_blocks_out(&ct, &mut out).unwrap(); assert_eq!(n, 4 * BLOCK_LEN); assert_eq!(out, pt, "{section}: _out variant"); } +// ---- F.2: CBC ---------------------------------------------------------------------------- + #[test] fn f_2_1_cbc_aes128_encrypt() { - check_encrypt::("F.2.1", KEY_128, &CIPHERTEXTS_128); + check_encrypt::, 16>( + "F.2.1", KEY_128, &CBC_CIPHERTEXTS_128, + ); } #[test] fn f_2_2_cbc_aes128_decrypt() { - check_decrypt::("F.2.2", KEY_128, &CIPHERTEXTS_128); + check_decrypt::, 16>( + "F.2.2", KEY_128, &CBC_CIPHERTEXTS_128, + ); } #[test] fn f_2_3_cbc_aes192_encrypt() { - check_encrypt::("F.2.3", KEY_192, &CIPHERTEXTS_192); + check_encrypt::, 24>( + "F.2.3", KEY_192, &CBC_CIPHERTEXTS_192, + ); } #[test] fn f_2_4_cbc_aes192_decrypt() { - check_decrypt::("F.2.4", KEY_192, &CIPHERTEXTS_192); + check_decrypt::, 24>( + "F.2.4", KEY_192, &CBC_CIPHERTEXTS_192, + ); } #[test] fn f_2_5_cbc_aes256_encrypt() { - check_encrypt::("F.2.5", KEY_256, &CIPHERTEXTS_256); + check_encrypt::, 32>( + "F.2.5", KEY_256, &CBC_CIPHERTEXTS_256, + ); } #[test] fn f_2_6_cbc_aes256_decrypt() { - check_decrypt::("F.2.6", KEY_256, &CIPHERTEXTS_256); + check_decrypt::, 32>( + "F.2.6", KEY_256, &CBC_CIPHERTEXTS_256, + ); +} + +// ---- F.3.13 - F.3.18: CFB128 ------------------------------------------------------------- + +#[test] +fn f_3_13_cfb128_aes128_encrypt() { + check_encrypt::, 16>( + "F.3.13", KEY_128, &CFB_CIPHERTEXTS_128, + ); +} + +#[test] +fn f_3_14_cfb128_aes128_decrypt() { + check_decrypt::, 16>( + "F.3.14", KEY_128, &CFB_CIPHERTEXTS_128, + ); +} + +#[test] +fn f_3_15_cfb128_aes192_encrypt() { + check_encrypt::, 24>( + "F.3.15", KEY_192, &CFB_CIPHERTEXTS_192, + ); +} + +#[test] +fn f_3_16_cfb128_aes192_decrypt() { + check_decrypt::, 24>( + "F.3.16", KEY_192, &CFB_CIPHERTEXTS_192, + ); +} + +#[test] +fn f_3_17_cfb128_aes256_encrypt() { + check_encrypt::, 32>( + "F.3.17", KEY_256, &CFB_CIPHERTEXTS_256, + ); } +#[test] +fn f_3_18_cfb128_aes256_decrypt() { + check_decrypt::, 32>( + "F.3.18", KEY_256, &CFB_CIPHERTEXTS_256, + ); +} + +// ---- the published intermediate values ----------------------------------------------------- + +/// The F.3 subsections tabulate an "Input Block" and an "Output Block" per segment, not just the +/// ciphertext. Checking those pins the *structure* of the mode rather than only its final answer: +/// +/// * `Input Block` for segment `j` is `Ij`, and the tables show it is the IV for `j = 1` and the +/// previous **ciphertext** segment thereafter -- which is the `s = b` collapse of +/// `Ij = LSB_{b-s}(Ij-1) | C#j-1`. +/// * `Output Block` for segment `j` is `Oj = CIPH_K(Ij)`, the keystream, applied by +/// `C#j = P#j XOR MSB_s(Oj)`. +/// +/// An implementation that fed back the plaintext, or XOR-ed before the cipher call instead of +/// after, could still match a ciphertext by coincidence in one subsection; it cannot match the +/// keystream column. `Oj` is recovered here as `Cj XOR Pj`, which is what the mode must have used. +#[test] +fn the_published_keystream_blocks_match() { + for (section, key_hex, cts, obs) in [ + ("F.3.13", KEY_128, &CFB_CIPHERTEXTS_128, &CFB_OUTPUT_BLOCKS_128), + ("F.3.15", KEY_192, &CFB_CIPHERTEXTS_192, &CFB_OUTPUT_BLOCKS_192), + ("F.3.17", KEY_256, &CFB_CIPHERTEXTS_256, &CFB_OUTPUT_BLOCKS_256), + ] { + let pt = blocks(&PLAINTEXTS); + let ct = blocks(cts); + let expected_keystream = blocks(obs); + + // Oj = Cj XOR Pj, from the vector's own two columns. + for (j, ((c, p), o)) in ct.iter().zip(pt.iter()).zip(expected_keystream.iter()).enumerate() + { + let recovered: [u8; BLOCK_LEN] = core::array::from_fn(|i| c[i] ^ p[i]); + assert_eq!( + &recovered, + o, + "{section} segment #{}: the ciphertext and plaintext columns should differ by \ + the published Output Block", + j + 1 + ); + } + + // ...and that keystream really is the forward cipher applied to Ij = (IV, C1, C2, C3). + let iv = block(IV); + let inputs = [iv, ct[0], ct[1], ct[2]]; + for (j, (input, o)) in inputs.iter().zip(expected_keystream.iter()).enumerate() { + let got = forward_cipher(key_hex, input); + assert_eq!( + &got, + o, + "{section} segment #{}: Oj should be CIPH_K(Ij) with Ij the previous ciphertext", + j + 1 + ); + } + } +} + +/// Applies the raw forward cipher under a hex key of any of the three AES lengths. +fn forward_cipher(key_hex: &str, input: &[u8; BLOCK_LEN]) -> [u8; BLOCK_LEN] { + let mut out = *input; + match hex::decode(key_hex).expect("valid hex").len() { + 16 => { + let p = >::new(&key_material::<16>(key_hex)) + .unwrap(); + p.encrypt_block(&mut out); + } + 24 => { + let p = >::new(&key_material::<24>(key_hex)) + .unwrap(); + p.encrypt_block(&mut out); + } + 32 => { + let p = >::new(&key_material::<32>(key_hex)) + .unwrap(); + p.encrypt_block(&mut out); + } + other => panic!("AES keys are 16, 24 or 32 bytes, got {other}"), + } + out +} + +// ---- cross-mode relationships -------------------------------------------------------------- + /// The one-shot API must agree with the vectors too, on the decrypt side where the IV is an input. #[test] fn the_one_shot_api_matches_the_vectors() { @@ -206,7 +401,7 @@ fn the_one_shot_api_matches_the_vectors() { Cbc::::decrypt_blocks( &key_material::<16>(KEY_128), &iv, - &blocks(&CIPHERTEXTS_128) + &blocks(&CBC_CIPHERTEXTS_128) ) .unwrap(), pt @@ -215,7 +410,7 @@ fn the_one_shot_api_matches_the_vectors() { Cbc::::decrypt_blocks( &key_material::<24>(KEY_192), &iv, - &blocks(&CIPHERTEXTS_192) + &blocks(&CBC_CIPHERTEXTS_192) ) .unwrap(), pt @@ -224,7 +419,35 @@ fn the_one_shot_api_matches_the_vectors() { Cbc::::decrypt_blocks( &key_material::<32>(KEY_256), &iv, - &blocks(&CIPHERTEXTS_256) + &blocks(&CBC_CIPHERTEXTS_256) + ) + .unwrap(), + pt + ); + + assert_eq!( + Cfb::::decrypt_blocks( + &key_material::<16>(KEY_128), + &iv, + &blocks(&CFB_CIPHERTEXTS_128) + ) + .unwrap(), + pt + ); + assert_eq!( + Cfb::::decrypt_blocks( + &key_material::<24>(KEY_192), + &iv, + &blocks(&CFB_CIPHERTEXTS_192) + ) + .unwrap(), + pt + ); + assert_eq!( + Cfb::::decrypt_blocks( + &key_material::<32>(KEY_256), + &iv, + &blocks(&CFB_CIPHERTEXTS_256) ) .unwrap(), pt @@ -256,6 +479,65 @@ fn cbc_differs_from_ecb_by_the_iv() { ) .unwrap(); let [cbc] = enc.do_encrypt_blocks(&[block(PLAINTEXTS[0])]).unwrap(); - assert_eq!(cbc, block(CIPHERTEXTS_128[0]), "F.2.1 block #1"); + assert_eq!(cbc, block(CBC_CIPHERTEXTS_128[0]), "F.2.1 block #1"); assert_ne!(cbc, ecb); } + +/// CFB and CBC are genuinely different modes, and the vectors say so: under the same key, IV and +/// plaintext, F.2.1 and F.3.13 give different ciphertext from the first block onwards. +/// +/// Cheap to state, but it is the check that would fail if `Cfb` were accidentally wired to the CBC +/// code path (or vice versa) -- a mistake that every round-trip test in the suite would miss. +#[test] +fn cfb_differs_from_cbc_on_the_same_inputs() { + for (cbc_ct, cfb_ct) in [ + (&CBC_CIPHERTEXTS_128, &CFB_CIPHERTEXTS_128), + (&CBC_CIPHERTEXTS_192, &CFB_CIPHERTEXTS_192), + (&CBC_CIPHERTEXTS_256, &CFB_CIPHERTEXTS_256), + ] { + assert_ne!(blocks(cbc_ct), blocks(cfb_ct)); + assert_ne!(block(cbc_ct[0]), block(cfb_ct[0]), "they differ from the very first block"); + } +} + +/// At `s = b`, CFB and OFB coincide on the **first** block: both compute `O1 = CIPH_K(IV)` and XOR +/// it with `P1`. The spec's own tables confirm it -- F.4.1 (OFB-AES128.Encrypt) block #1 ciphertext +/// is `3b3fd92eb72dad20333449f8e83cfb4a`, the same value as F.3.13 segment #1. +/// +/// This is a cross-check on the keystream from a different appendix, and it is the reason CFB's +/// first block must not be special-cased differently from OFB's: they are the same computation. The +/// modes diverge from block 2 (CFB feeds back the ciphertext, OFB the cipher output), which is why +/// only block #1 is compared. +#[test] +fn cfb_and_ofb_agree_on_the_first_block() { + // F.4.1 OFB-AES128.Encrypt, Block #1 Ciphertext. + const OFB_AES128_BLOCK_1: &str = "3b3fd92eb72dad20333449f8e83cfb4a"; + assert_eq!(block(CFB_CIPHERTEXTS_128[0]), block(OFB_AES128_BLOCK_1)); + + // F.4.3 OFB-AES192 and F.4.5 OFB-AES256, Block #1 Ciphertext -- same story. + assert_eq!(block(CFB_CIPHERTEXTS_192[0]), block("cdc80d6fddf18cab34c25909c99a4174")); + assert_eq!(block(CFB_CIPHERTEXTS_256[0]), block("dc7e84bfda79164b7ecd8486985d3860")); + + // ...and block #2 must differ, or the mode would be OFB rather than CFB. F.4.1 block #2 is + // `7789508d16918f03f53c52dac54ed825`. + assert_ne!(block(CFB_CIPHERTEXTS_128[1]), block("7789508d16918f03f53c52dac54ed825")); +} + +/// A mode does not change the strength of the underlying cipher, for either mode. +#[test] +fn the_security_strength_is_the_ciphers() { + use bouncycastle_core::traits::BlockCipher; + + assert_eq!( + as BlockCipher>::MAX_SECURITY_STRENGTH, + SecurityStrength::_128bit + ); + assert_eq!( + as BlockCipher>::MAX_SECURITY_STRENGTH, + ::MAX_SECURITY_STRENGTH + ); + assert_eq!( + as BlockCipher>::MAX_SECURITY_STRENGTH, + as BlockCipher>::MAX_SECURITY_STRENGTH + ); +}