diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 000000000..931ee417b --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,15 @@ + +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-unknown-linux-gnu-gcc" + +[target.aarch64_be-unknown-linux-gnu] +linker = "aarch64_be-none-linux-gnu-gcc" + +[target.riscv64gc-unknown-linux-gnu] +linker = "riscv64-unknown-linux-gnu-gcc" + +[target.riscv32gc-unknown-linux-gnu] +linker = "riscv32-unknown-linux-gnu-gcc" + +[target.powerpc64le-unknown-linux-gnu] +linker = "powerpc64le-unknown-linux-gnu-gcc" diff --git a/.github/workflows/all.yml b/.github/workflows/all.yml index 6eed804b2..39468ca9d 100644 --- a/.github/workflows/all.yml +++ b/.github/workflows/all.yml @@ -101,3 +101,9 @@ jobs: needs: [ base ] uses: ./.github/workflows/baremetal.yml secrets: inherit + rust: + name: Rust + permissions: + contents: 'read' + id-token: 'write' + uses: ./.github/workflows/rust.yml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 000000000..c59c02a4e --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,146 @@ +# Copyright (c) The mlkem-native project authors +# SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT + +name: Rust +permissions: + contents: read +on: + workflow_call: + workflow_dispatch: + +jobs: + rust: + name: Rust bindings (${{ matrix.target.name }}) + strategy: + fail-fast: false + matrix: + target: + - runner: ubuntu-latest + name: "x86_64" + - runner: ubuntu-24.04-arm + name: "aarch64" + - runner: macos-latest + name: "macos (aarch64)" + - runner: macos-15-intel + name: "macos (x86_64)" + runs-on: ${{ matrix.target.runner }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-shell + with: + nix-shell: rust + gh_token: ${{ secrets.GITHUB_TOKEN }} + script: | + cargo build --workspace + cargo test --workspace + cargo publish --dry-run -p mlkem-native + - name: Vector extension tests + uses: ./.github/actions/setup-shell + with: + nix-shell: rust + gh_token: ${{ secrets.GITHUB_TOKEN }} + script: | + ARCH=$(uname -m) + OS=$(uname -s) + if [ "$ARCH" = "x86_64" ]; then + RUSTFLAGS="-C target-feature=+avx2" cargo build --workspace + RUSTFLAGS="-C target-feature=+avx2" cargo test --workspace + elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then + # Test generic backend (explicitly disable NEON) + RUSTFLAGS="-C target-feature=-neon" cargo build --workspace + RUSTFLAGS="-C target-feature=-neon" cargo test --workspace + # Test SHA3 backend; Apple Silicon (Darwin arm64) guarantees ARMv8.4-A+SHA3 + if [ "$OS" = "Darwin" ]; then + RUSTFLAGS="-C target-feature=+sha3" cargo build --workspace + RUSTFLAGS="-C target-feature=+sha3" cargo test --workspace + fi + fi + - uses: ./.github/actions/setup-shell + name: ACVP test + with: + nix-shell: rust + gh_token: ${{ secrets.GITHUB_TOKEN }} + script: | + cargo build --release --bin acvp_mlkem + for version in v1.1.0.40 v1.1.0.41; do + python3 test/acvp/acvp_client.py \ + --binary ./target/release/acvp_mlkem \ + --version $version + done + + rust_cross: + name: Rust cross-compilation (${{ matrix.target.name }}) + # Mirror the C CI: skip cross jobs on fork PRs to avoid long queue times. + if: github.repository_owner == 'pq-code-package' || !github.event.pull_request.head.repo.fork + strategy: + fail-fast: false + matrix: + target: + # Run riscv64 and ppc64le from the aarch64 runner + - name: "riscv64" + runner: ubuntu-24.04-arm + nix_shell: cross-rust-riscv64 + rust_target: riscv64gc-unknown-linux-gnu + cargo_runner_var: CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_RUNNER + qemu_bin: qemu-riscv64 + - name: "ppc64le" + runner: ubuntu-24.04-arm + nix_shell: cross-rust-ppc64le + rust_target: powerpc64le-unknown-linux-gnu + cargo_runner_var: CARGO_TARGET_POWERPC64LE_UNKNOWN_LINUX_GNU_RUNNER + qemu_bin: qemu-ppc64le + # Run aarch64 cross-compilation from the x86_64 runner + - name: "aarch64" + runner: ubuntu-latest + nix_shell: cross-rust-aarch64 + rust_target: aarch64-unknown-linux-gnu + cargo_runner_var: CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUNNER + qemu_bin: qemu-aarch64 + runs-on: ${{ matrix.target.runner }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-shell + with: + nix-shell: ${{ matrix.target.nix_shell }} + nix-cache: true + gh_token: ${{ secrets.GITHUB_TOKEN }} + script: | + rustup target add ${{ matrix.target.rust_target }} + + # Build first to extract the nix-store sysroot from the ELF interpreter. + # Nix cross-toolchains don't expose a usable --sysroot; the target glibc + # lives at a hash-addressed path in the nix store that is only visible in + # the .interp section of a compiled binary. + cargo build --workspace --target ${{ matrix.target.rust_target }} + SAMPLE_BIN=$(find target/${{ matrix.target.rust_target }}/debug -maxdepth 2 -executable -type f -name 'acvp_mlkem' | head -1) + SYSROOT=$(readelf -l "$SAMPLE_BIN" | grep -oP '(?<=\[)[^\]]+' | grep -E 'ld-linux|ld-musl|ld64|ld\.so' | sed 's|/lib[^/]*/ld.*||') + + # Helper: sets the QEMU runner env var and runs cargo test. + # $1 = extra qemu flags (empty for none), $2 = RUSTFLAGS (empty for none). + run_tests() { + export ${{ matrix.target.cargo_runner_var }}="${{ matrix.target.qemu_bin }}${1:+ $1} -L $SYSROOT" + RUSTFLAGS="$2" cargo test --workspace --target ${{ matrix.target.rust_target }} + } + + case ${{ matrix.target.rust_target }} in + riscv64gc-unknown-linux-gnu) + # Generic backend (no RVV), then RVV backend at each VLEN. + # VLENs match the C CI (multi-functest uses 128/256/512/1024). + run_tests "" "" + for vlen in 128 256 512 1024; do + run_tests "-cpu rv64,v=true,vlen=$vlen" "-C target-feature=+v" + done + ;; + powerpc64le-unknown-linux-gnu) + # Generic backend only (no vector backend for ppc64le yet). + run_tests "" "" + ;; + aarch64-unknown-linux-gnu) + # Generic backend (NEON disabled), analogous to C CI no_opt. + run_tests "" "-C target-feature=-neon" + # NEON backend (default on aarch64), analogous to C CI opt. + run_tests "" "" + # SHA3 backend (ARMv8.4-A); qemu-aarch64 -cpu max emulates it. + run_tests "-cpu max" "-C target-feature=+sha3" + ;; + esac diff --git a/.gitignore b/.gitignore index 3f9aeb0d0..4ea3e0e37 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .vscode .idea test/build +target __pycache__/ # Downloaded ACVP test data test/acvp/.acvp-data/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..361233124 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,448 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "getrandom", + "hybrid-array", + "rand_core", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "kem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd" +dependencies = [ + "crypto-common", + "rand_core", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mlkem-native" +version = "1.1.0-rc.1" +dependencies = [ + "cc", + "hybrid-array", + "kem", + "rand_core", + "zeroize", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..f26dd5e90 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,50 @@ +# Copyright (c) The mlkem-native project authors +# SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT + +[package] +name = "mlkem-native" +version = "1.1.0-rc.1" +edition = "2024" +description = "ML-KEM (FIPS 203) via mlkem-native" +categories = ["cryptography"] +keywords = ["cryptography", "post-quantum", "security", "mlkem", "ml-kem"] +repository = "https://github.com/pq-code-package/mlkem-native" +readme = "README.md" +license = "Apache-2.0 OR ISC OR MIT" +build = "bindings/rust/mlkem-native/build.rs" +include = [ + "bindings/rust/mlkem-native/build.rs", + "bindings/rust/mlkem-native/src/lib.rs", + "bindings/rust/mlkem-native/src/sys.rs", + "bindings/rust/mlkem-native/examples/kem.rs", + "bindings/rust/mlkem-native/examples/serialization.rs", + "mlkem/**", +] + +[lib] +path = "bindings/rust/mlkem-native/src/lib.rs" + +[[example]] +name = "kem" +path = "bindings/rust/mlkem-native/examples/kem.rs" + +[[example]] +name = "serialization" +path = "bindings/rust/mlkem-native/examples/serialization.rs" + +[[bin]] +name = "acvp_mlkem" +path = "bindings/rust/mlkem-native/src/bin/acvp_mlkem.rs" + +[dependencies] +kem = "0.3.0-rc.6" +hybrid-array = { version = "0.4", features = ["extra-sizes"] } +rand_core = { version = "0.10", default-features = false } +zeroize = { version = "1.8.2" } + +[dev-dependencies] +kem = { version = "0.3.0-rc.6", features = ["getrandom"] } +rand_core = "0.10" + +[build-dependencies] +cc = "1.2.60" diff --git a/bindings/rust/mlkem-native/build.rs b/bindings/rust/mlkem-native/build.rs new file mode 100644 index 000000000..e533654b5 --- /dev/null +++ b/bindings/rust/mlkem-native/build.rs @@ -0,0 +1,153 @@ +// Copyright (c) The mlkem-native project authors +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT + +use std::env; +use std::path::{Path, PathBuf}; + +// x86_64: enable when AVX2 is present (e.g. RUSTFLAGS="-C target-feature=+avx2"). +// AArch64 LE: enable when NEON is present (mandatory on ARMv8-A, but check to be safe). +// Big-endian AArch64 is excluded: the assembly backend only supports little-endian. +// SHA3 (ARMv8.4-A): enables optimized Keccak x1/x2/x4 backends via __ARM_FEATURE_SHA3. +// RISC-V 64: arith-only backend (no RVV FIPS202 backend); enable with RUSTFLAGS="-C target-feature=+v". +enum Backend { + Generic, + Avx2, + Aarch64 { sha3: bool }, + Riscv64Rvv, +} + +impl Backend { + fn detect() -> Self { + let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap_or_default(); + let endian = env::var("CARGO_CFG_TARGET_ENDIAN").unwrap_or_default(); + let features = env::var("CARGO_CFG_TARGET_FEATURE").unwrap_or_default(); + let has = |f: &str| features.split(',').any(|t| t == f); + + match arch.as_str() { + "x86_64" if has("avx2") => Backend::Avx2, + "aarch64" if endian == "little" && has("neon") => Backend::Aarch64 { sha3: has("sha3") }, + "riscv64" if has("v") => Backend::Riscv64Rvv, + _ => Backend::Generic, + } + } + + // AArch64 and x86_64 have assembly backends bundled in mlkem_native_asm.S; + // RISC-V uses only C intrinsics so no separate asm compilation is needed. + fn needs_asm(&self) -> bool { + matches!(self, Backend::Avx2 | Backend::Aarch64 { .. }) + } + + fn apply(&self, build: &mut cc::Build) { + let is_msvc = env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default() == "msvc"; + match self { + Backend::Avx2 => { + build + .define("MLK_CONFIG_USE_NATIVE_BACKEND_ARITH", None) + .define("MLK_CONFIG_USE_NATIVE_BACKEND_FIPS202", None) + .flag(if is_msvc { "/arch:AVX2" } else { "-mavx2" }); + } + Backend::Aarch64 { sha3 } => { + build + .define("MLK_CONFIG_USE_NATIVE_BACKEND_ARITH", None) + .define("MLK_CONFIG_USE_NATIVE_BACKEND_FIPS202", None); + if *sha3 { + build.flag("-march=armv8.4-a+sha3"); + } + } + Backend::Riscv64Rvv => { + build + .define("MLK_CONFIG_USE_NATIVE_BACKEND_ARITH", None) + .flag("-march=rv64gcv"); + } + Backend::Generic => {} + } + } + + fn emit_cfg(&self) { + for key in [ + "mlkem_native_backend_avx2", + "mlkem_native_backend_aarch64", + "mlkem_native_backend_aarch64_sha3", + "mlkem_native_backend_riscv64_rvv", + ] { + println!("cargo:rustc-check-cfg=cfg({key})"); + } + match self { + Backend::Avx2 => println!("cargo:rustc-cfg=mlkem_native_backend_avx2"), + Backend::Aarch64 { sha3 } => { + println!("cargo:rustc-cfg=mlkem_native_backend_aarch64"); + if *sha3 { + println!("cargo:rustc-cfg=mlkem_native_backend_aarch64_sha3"); + } + } + Backend::Riscv64Rvv => println!("cargo:rustc-cfg=mlkem_native_backend_riscv64_rvv"), + Backend::Generic => {} + } + } +} + +// When cross-compiling, derive the C compiler explicitly so that a CC env +// var pointing at the host compiler (common in Nix shells) doesn't +// silently produce host-arch object files. CC_ takes precedence +// over this fallback; CC on its own is ignored when cross-compiling +// because it typically names the host compiler, not the cross-compiler. +// +// RISC-V triples include "gc" (e.g. riscv64gc-unknown-linux-gnu) but the +// gcc binary omits it (riscv64-unknown-linux-gnu-gcc). +fn cross_compiler(host: &str, target: &str) -> Option { + if host == target { + return None; + } + let cc_env = format!("CC_{}", target.replace('-', "_")); + if env::var(&cc_env).is_ok() { + return None; + } + let gcc_triple = target + .replace("riscv64gc-", "riscv64-") + .replace("riscv32gc-", "riscv32-"); + Some(format!("{}-gcc", gcc_triple)) +} + +fn base_build(mlkem_dir: &Path, level: u32, compiler: Option<&str>) -> cc::Build { + let level_str = level.to_string(); + let mut build = cc::Build::new(); + build + .include(mlkem_dir) + .define("MLK_CONFIG_PARAMETER_SET", level_str.as_str()) + .define("MLK_CONFIG_NO_RANDOMIZED_API", None) + .opt_level(3); + if let Some(cc) = compiler { + build.compiler(cc); + } + build +} + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + // The mlkem C sources are bundled alongside this crate in the `mlkem/` + // directory (a symlink to the repository root's mlkem/ in the source tree, + // and the resolved files in published packages). + let mlkem_dir = manifest_dir.join("mlkem"); + + println!("cargo:rerun-if-changed={}", mlkem_dir.display()); + + let host = env::var("HOST").unwrap_or_default(); + let target = env::var("TARGET").unwrap_or_default(); + let compiler = cross_compiler(&host, &target); + let backend = Backend::detect(); + backend.emit_cfg(); + + for &level in &[512u32, 768, 1024] { + let mut c_build = base_build(&mlkem_dir, level, compiler.as_deref()); + c_build.file(mlkem_dir.join("mlkem_native.c")); + backend.apply(&mut c_build); + c_build.compile(&format!("mlkem_native_{}", level)); + + if backend.needs_asm() { + let mut asm_build = base_build(&mlkem_dir, level, compiler.as_deref()); + asm_build.file(mlkem_dir.join("mlkem_native_asm.S")); + backend.apply(&mut asm_build); + asm_build.compile(&format!("mlkem_native_asm_{}", level)); + } + } +} diff --git a/bindings/rust/mlkem-native/examples/kem.rs b/bindings/rust/mlkem-native/examples/kem.rs new file mode 100644 index 000000000..f1ffac266 --- /dev/null +++ b/bindings/rust/mlkem-native/examples/kem.rs @@ -0,0 +1,43 @@ +// Copyright (c) The mlkem-native project authors +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT + +//! Demonstrates key generation, encapsulation, and decapsulation for all +//! three ML-KEM parameter sets using the system's secure RNG. +//! +//! Run with: +//! cargo run --example kem + +use mlkem_native::{ + kem::{Decapsulate, Encapsulate, Kem}, + mlkem1024, mlkem512, mlkem768, +}; + +fn demo(name: &str) +where + K::EncapsulationKey: Encapsulate, + K::DecapsulationKey: Decapsulate, +{ + // Alice generates a keypair. The decapsulation key is kept secret; + // the encapsulation key is shared with the sender. + let (dk, ek) = K::generate_keypair(); + + // Bob encapsulates a fresh shared secret to Alice's encapsulation key. + // `ct` is the ciphertext sent to Alice; `ss_sender` is Bob's share. + let (ct, ss_sender) = ek.encapsulate(); + + // Alice decapsulates the ciphertext to recover the shared secret. + let ss_receiver = dk.decapsulate(&ct); + + assert_eq!(ss_sender, ss_receiver, "{name}: shared secrets must match"); + + println!( + "{name}: shared secret ({} bytes) established successfully", + ss_sender.as_slice().len() + ); +} + +fn main() { + demo::("ML-KEM-512"); + demo::("ML-KEM-768"); + demo::("ML-KEM-1024"); +} diff --git a/bindings/rust/mlkem-native/examples/serialization.rs b/bindings/rust/mlkem-native/examples/serialization.rs new file mode 100644 index 000000000..fd9db87f7 --- /dev/null +++ b/bindings/rust/mlkem-native/examples/serialization.rs @@ -0,0 +1,37 @@ +// Copyright (c) The mlkem-native project authors +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT + +//! Demonstrates serialization and deserialization of ML-KEM keys. +//! +//! Run with: +//! cargo run --example serialization + +use mlkem_native::{ + kem::{Decapsulate, Encapsulate, KeyExport, Kem}, + mlkem768::{DecapsulationKey, EncapsulationKey, MlKem}, +}; + +fn main() { + // Generate a fresh keypair. + let (dk, ek) = MlKem::generate_keypair(); + + // --- Serialize --- + let ek_bytes: Vec = ek.to_bytes().to_vec(); // 1184 bytes + let dk_bytes: Vec = dk.as_bytes().to_vec(); // 2400 bytes + + println!("ek: {} bytes", ek_bytes.len()); + println!("dk: {} bytes", dk_bytes.len()); + + // --- Deserialize --- + // Both constructors validate the key (check_pk / check_sk) and return + // Err(InvalidKey) if the bytes are the wrong length or fail validation. + let ek2 = EncapsulationKey::try_from(ek_bytes.as_slice()).expect("valid ek"); + let dk2 = DecapsulationKey::try_from(dk_bytes.as_slice()).expect("valid dk"); + + // Verify the round-tripped keys still work correctly. + let (ct, ss_sender) = ek2.encapsulate(); + let ss_receiver = dk2.decapsulate(&ct); + assert_eq!(ss_sender, ss_receiver, "shared secrets must match after round-trip"); + + println!("Round-trip successful — shared secret established."); +} diff --git a/bindings/rust/mlkem-native/src/bin/acvp_mlkem.rs b/bindings/rust/mlkem-native/src/bin/acvp_mlkem.rs new file mode 100644 index 000000000..c23d86608 --- /dev/null +++ b/bindings/rust/mlkem-native/src/bin/acvp_mlkem.rs @@ -0,0 +1,284 @@ +// Copyright (c) The mlkem-native project authors +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT + +//! ACVP test harness for ML-KEM using the safe `mlkem-native` Rust API. +//! +//! Usage: acvp_mlkem <512|768|1024> [encapDecap|keyGen] [AFT|VAL] {test specific arguments} +//! +//! Matches the CLI interface of test/acvp/acvp_mlkem.c, with the security +//! level prepended as an additional leading argument (since this single binary +//! covers all three parameter sets). + +use std::process; + +use rand_core::{TryCryptoRng, TryRng}; + +const SYMBYTES: usize = 32; + +fn decode_hex_char(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'A'..=b'F' => Some(10 + c - b'A'), + b'a'..=b'f' => Some(10 + c - b'a'), + _ => None, + } +} + +/// Decode a `prefix=HEXSTRING` CLI argument into exactly `expected_len` bytes. +/// Returns `Err(())` and prints a diagnostic to stderr on failure. +fn decode_hex(prefix: &str, arg: &str, expected_len: usize) -> Result, ()> { + let full_prefix = format!("{}=", prefix); + let hex = match arg.strip_prefix(&full_prefix) { + Some(h) => h, + None => { + eprintln!( + "Argument {} invalid: Expected argument of the form '{}=HEX' with \ + HEX being a hex encoding of {} bytes", + arg, prefix, expected_len + ); + return Err(()); + } + }; + + if hex.len() != 2 * expected_len { + eprintln!( + "Argument {} invalid: Expected {}=HEX with HEX being a hex encoding of {} bytes", + arg, prefix, expected_len + ); + return Err(()); + } + + hex.as_bytes() + .chunks(2) + .map(|chunk| match (decode_hex_char(chunk[0]), decode_hex_char(chunk[1])) { + (Some(hi), Some(lo)) => Ok((hi << 4) | lo), + _ => { + eprintln!("Argument {} invalid: non-hex character in value", arg); + Err(()) + } + }) + .collect() +} + +fn die_usage(msg: &str) -> ! { + eprintln!("{}", msg); + process::exit(1); +} + +fn print_hex(name: &str, data: &[u8]) { + print!("{}=", name); + for b in data { + print!("{:02X}", b); + } + println!(); +} + +/// A deterministic RNG that replays a fixed byte string exactly once. +/// +/// Used to feed caller-supplied randomness into the standard `encapsulate_with_rng` +/// and `try_generate_from_rng` APIs, giving the same result as the dedicated +/// `_derand` entry points in the C library. +struct FixedRng<'a>(&'a [u8]); + +impl TryRng for FixedRng<'_> { + type Error = core::convert::Infallible; + + fn try_next_u32(&mut self) -> Result { + let mut b = [0u8; 4]; + self.try_fill_bytes(&mut b)?; + Ok(u32::from_le_bytes(b)) + } + + fn try_next_u64(&mut self) -> Result { + let mut b = [0u8; 8]; + self.try_fill_bytes(&mut b)?; + Ok(u64::from_le_bytes(b)) + } + + fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> { + let n = dst.len(); + dst.copy_from_slice(&self.0[..n]); + self.0 = &self.0[n..]; + Ok(()) + } +} + +// Not a real CSPRNG — only used with caller-supplied test vectors where the +// ACVP protocol requires deterministic, reproduced output. +impl TryCryptoRng for FixedRng<'_> {} + +/// Core ACVP logic for one security level, using only the safe mlkem-native API. +macro_rules! run_level { + ( + module = $module:ident, + pk_len = $pk_len:literal, + sk_len = $sk_len:literal, + ct_len = $ct_len:literal, + args = $args:expr + ) => {{ + use mlkem_native::kem::common::{Generate, KeyExport}; + use mlkem_native::kem::{Ciphertext, Decapsulate, Decapsulator, Encapsulate}; + use mlkem_native::$module::{DecapsulationKey, EncapsulationKey, MlKem}; + + let args: &[String] = $args; + + const USAGE: &str = + "acvp_mlkem [encapDecap|keyGen] [AFT|VAL] {test specific arguments}"; + const ENCAPS_USAGE: &str = + "acvp_mlkem encapDecap AFT encapsulation ek=HEX m=HEX"; + const DECAPS_USAGE: &str = + "acvp_mlkem encapDecap VAL decapsulation dk=HEX c=HEX"; + const EK_CHECK_USAGE: &str = + "acvp_mlkem encapDecap VAL encapsulationKeyCheck ek=HEX"; + const DK_CHECK_USAGE: &str = + "acvp_mlkem encapDecap VAL decapsulationKeyCheck dk=HEX"; + const KEYGEN_USAGE: &str = "acvp_mlkem keyGen AFT z=HEX d=HEX"; + + if args.is_empty() { + die_usage(USAGE); + } + + match args[0].as_str() { + "encapDecap" => { + let args = &args[1..]; + if args.len() < 2 { + die_usage( + "acvp_mlkem encapDecap [AFT|VAL] \ + [encapsulation|decapsulation|\ + encapsulationKeyCheck|decapsulationKeyCheck] ...", + ); + } + let test_type = args[0].as_str(); + let function = args[1].as_str(); + let args = &args[2..]; + + match (test_type, function) { + ("AFT", "encapsulation") => { + if args.len() < 2 { + die_usage(ENCAPS_USAGE); + } + let ek_bytes = decode_hex("ek", &args[0], $pk_len) + .unwrap_or_else(|_| die_usage(ENCAPS_USAGE)); + let m_bytes = decode_hex("m", &args[1], SYMBYTES) + .unwrap_or_else(|_| die_usage(ENCAPS_USAGE)); + let ek = EncapsulationKey::try_from(ek_bytes.as_slice()) + .unwrap_or_else(|_| die_usage(ENCAPS_USAGE)); + let (ct, ss) = ek.encapsulate_with_rng(&mut FixedRng(&m_bytes)); + print_hex("c", ct.as_slice()); + print_hex("k", ss.as_slice()); + } + ("VAL", "decapsulation") => { + if args.len() < 2 { + die_usage(DECAPS_USAGE); + } + let dk_bytes = decode_hex("dk", &args[0], $sk_len) + .unwrap_or_else(|_| die_usage(DECAPS_USAGE)); + let c_bytes = decode_hex("c", &args[1], $ct_len) + .unwrap_or_else(|_| die_usage(DECAPS_USAGE)); + let dk = DecapsulationKey::try_from(dk_bytes.as_slice()) + .unwrap_or_else(|_| die_usage(DECAPS_USAGE)); + let mut ct = Ciphertext::::default(); + ct.as_mut_slice().copy_from_slice(&c_bytes); + let ss = dk.decapsulate(&ct); + print_hex("k", ss.as_slice()); + } + ("VAL", "encapsulationKeyCheck") => { + if args.is_empty() { + die_usage(EK_CHECK_USAGE); + } + // ACVP 1.1.0.40+ sends keys of wrong length to test rejection; + // a decode failure is reported as testPassed=0. + let ek_bytes = match decode_hex("ek", &args[0], $pk_len) { + Ok(v) => v, + Err(_) => { + println!("testPassed=0"); + return; + } + }; + // Importing via try_from runs the FIPS 203 §7.2 modulus check. + let passed = EncapsulationKey::try_from(ek_bytes.as_slice()).is_ok(); + println!("testPassed={}", if passed { 1 } else { 0 }); + } + ("VAL", "decapsulationKeyCheck") => { + if args.is_empty() { + die_usage(DK_CHECK_USAGE); + } + // ACVP 1.1.0.40+ sends keys of wrong length to test rejection. + let dk_bytes = match decode_hex("dk", &args[0], $sk_len) { + Ok(v) => v, + Err(_) => { + println!("testPassed=0"); + return; + } + }; + // Importing via try_from runs the FIPS 203 §7.3 hash check. + let passed = DecapsulationKey::try_from(dk_bytes.as_slice()).is_ok(); + println!("testPassed={}", if passed { 1 } else { 0 }); + } + _ => die_usage(USAGE), + } + } + "keyGen" => { + let args = &args[1..]; + if args.is_empty() || args[0] != "AFT" || args.len() < 3 { + die_usage(KEYGEN_USAGE); + } + let z = decode_hex("z", &args[1], SYMBYTES) + .unwrap_or_else(|_| die_usage(KEYGEN_USAGE)); + let d = decode_hex("d", &args[2], SYMBYTES) + .unwrap_or_else(|_| die_usage(KEYGEN_USAGE)); + // coins = d || z (matches the C reference: zd[0..32]=d, zd[32..64]=z) + let mut coins = [0u8; 64]; + coins[..32].copy_from_slice(&d); + coins[32..].copy_from_slice(&z); + let dk = DecapsulationKey::try_generate_from_rng(&mut FixedRng(&coins)) + .unwrap_or_else(|e| match e {}); + print_hex("ek", dk.encapsulation_key().to_bytes().as_slice()); + print_hex("dk", dk.as_bytes()); + } + _ => die_usage(USAGE), + } + }}; +} + +fn main() { + let args: Vec = std::env::args().collect(); + + if args.len() < 2 { + die_usage( + "Usage: acvp_mlkem <512|768|1024> [encapDecap|keyGen] [AFT|VAL] \ + {test specific arguments}", + ); + } + + let level: u32 = args[1] + .parse() + .unwrap_or_else(|_| die_usage("Invalid security level: expected 512, 768, or 1024")); + + let rest = &args[2..]; + + match level { + 512 => run_level!( + module = mlkem512, + pk_len = 800, + sk_len = 1632, + ct_len = 768, + args = rest + ), + 768 => run_level!( + module = mlkem768, + pk_len = 1184, + sk_len = 2400, + ct_len = 1088, + args = rest + ), + 1024 => run_level!( + module = mlkem1024, + pk_len = 1568, + sk_len = 3168, + ct_len = 1568, + args = rest + ), + _ => die_usage("Invalid security level: expected 512, 768, or 1024"), + } +} diff --git a/bindings/rust/mlkem-native/src/lib.rs b/bindings/rust/mlkem-native/src/lib.rs new file mode 100644 index 000000000..367af25b7 --- /dev/null +++ b/bindings/rust/mlkem-native/src/lib.rs @@ -0,0 +1,457 @@ +// Copyright (c) The mlkem-native project authors +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT + +//! ML-KEM (FIPS 203) implemented via [mlkem-native], exposing the +//! [RustCrypto KEM traits]. +//! +//! All three parameter sets are provided as submodules: +//! - [`mlkem512`] — NIST security level 1 +//! - [`mlkem768`] — NIST security level 3 +//! - [`mlkem1024`] — NIST security level 5 +//! +//! Each submodule exposes a marker struct `MlKem` implementing [`kem::Kem`], +//! plus `EncapsulationKey` and `DecapsulationKey` types. +//! +//! # Example +//! +//! ```rust,ignore +//! use mlkem_native::{kem::{Decapsulate, Encapsulate, Kem}, mlkem768}; +//! +//! // Key generation (uses system RNG; requires the `getrandom` feature on `kem`). +//! let (dk, ek) = mlkem768::MlKem::generate_keypair(); +//! +//! // Encapsulate a shared secret to the recipient's public key. +//! let (ct, ss_sender) = ek.encapsulate(); +//! +//! // Decapsulate to recover the same shared secret. +//! let ss_receiver = dk.decapsulate(&ct); +//! +//! assert_eq!(ss_sender.as_slice(), ss_receiver.as_slice()); +//! ``` +//! +//! [mlkem-native]: https://github.com/pq-code-package/mlkem-native +//! [RustCrypto KEM traits]: https://crates.io/crates/kem + +#![no_std] + +pub use kem; + +mod sys; + +macro_rules! define_mlkem { + ( + module = $module:ident, + sys_mod = $sys_mod:ident, + pk_len = $pk_len:literal, + sk_len = $sk_len:literal, + ek_start = $ek_start:literal, + pk_size = $pk_size:ty, + ct_size = $ct_size:ty, + keypair_derand = $keypair_derand:ident, + enc_derand = $enc_derand:ident, + dec = $dec:ident, + check_pk = $check_pk:ident, + check_sk = $check_sk:ident, + ) => { + pub mod $module { + use crate::sys::$sys_mod::{$check_pk, $check_sk, $dec, $enc_derand, $keypair_derand}; + use hybrid_array::Array; + use kem::{ + Ciphertext, Decapsulate, Encapsulate, Kem, SharedKey, + common::{Generate, InvalidKey, KeyExport, KeySizeUser, TryKeyInit}, + }; + use rand_core::{CryptoRng, TryCryptoRng}; + use zeroize::Zeroize; + + /// Marker struct for this ML-KEM parameter set; implements [`Kem`]. + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] + pub struct MlKem; + + impl Kem for MlKem { + type DecapsulationKey = DecapsulationKey; + type EncapsulationKey = EncapsulationKey; + type SharedKeySize = hybrid_array::typenum::U32; + type CiphertextSize = $ct_size; + } + + /// The encapsulation (public) key. + #[derive(Clone, PartialEq, Eq)] + pub struct EncapsulationKey([u8; $pk_len]); + + impl core::fmt::Debug for EncapsulationKey { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_tuple("EncapsulationKey") + .field(&self.0.as_ref()) + .finish() + } + } + + impl KeySizeUser for EncapsulationKey { + type KeySize = $pk_size; + } + + impl TryKeyInit for EncapsulationKey { + fn new(key: &Array) -> Result { + Self::try_from(key.as_slice()) + } + } + + impl KeyExport for EncapsulationKey { + fn to_bytes(&self) -> Array { + let mut out = Array::::default(); + out.copy_from_slice(&self.0); + out + } + } + + impl Encapsulate for EncapsulationKey { + type Kem = MlKem; + + fn encapsulate_with_rng( + &self, + rng: &mut R, + ) -> (Ciphertext, SharedKey) { + let mut ct = Ciphertext::::default(); + let mut ss = SharedKey::::default(); + let mut coins = [0u8; 32]; // MLKEM_SYMBYTES + rng.fill_bytes(&mut coins); + // SAFETY: All pointers are derived from correctly-sized, + // initialized arrays (`ct` = $ct_size bytes, `ss` = 32 + // bytes, `self.0` = $pk_len bytes, `coins` = 32 bytes). + // None of the buffers overlap, and the C function only + // reads/writes within the advertised sizes. + let ret = unsafe { + $enc_derand( + ct.as_mut_slice().as_mut_ptr(), + ss.as_mut_slice().as_mut_ptr(), + self.0.as_ptr(), + coins.as_ptr(), + ) + }; + assert_eq!(ret, 0, "enc_derand failed — key may have been corrupted"); + (ct, ss) + } + } + + /// The decapsulation (secret) key. + /// + /// The encapsulation key is stored alongside to support + /// [`kem::Decapsulator::encapsulation_key`]. + pub struct DecapsulationKey { + sk: [u8; $sk_len], + ek: EncapsulationKey, + } + + impl Drop for DecapsulationKey { + fn drop(&mut self) { + self.sk.zeroize(); + } + } + + impl DecapsulationKey { + pub fn as_bytes(&self) -> &[u8] { + &self.sk + } + } + + impl core::fmt::Debug for DecapsulationKey { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("DecapsulationKey([redacted])") + } + } + + impl kem::Decapsulator for DecapsulationKey { + type Kem = MlKem; + + fn encapsulation_key(&self) -> &EncapsulationKey { + &self.ek + } + } + + impl Decapsulate for DecapsulationKey { + fn decapsulate(&self, ct: &Ciphertext) -> SharedKey { + let mut ss = SharedKey::::default(); + // SAFETY: All pointers are derived from correctly-sized, + // initialized arrays (`ss` = 32 bytes, `ct` = $ct_size + // bytes, `self.sk` = $sk_len bytes). None of the buffers + // overlap, and the C function only reads/writes within + // the advertised sizes. + let ret = unsafe { + $dec( + ss.as_mut_slice().as_mut_ptr(), + ct.as_slice().as_ptr(), + self.sk.as_ptr(), + ) + }; + // ML-KEM decapsulation always produces a value (implicit rejection + // for invalid ciphertexts per FIPS 203 §8.3); non-zero only if the + // hash check on the secret key fails, which is guarded against on + // construction. + assert_eq!(ret, 0, "dec failed — key may have been corrupted"); + ss + } + } + + impl Generate for DecapsulationKey { + fn try_generate_from_rng( + rng: &mut R, + ) -> Result { + let mut pk = [0u8; $pk_len]; + let mut sk = [0u8; $sk_len]; + let mut coins = [0u8; 64]; // 2 × MLKEM_SYMBYTES + rng.try_fill_bytes(&mut coins)?; + // SAFETY: All pointers are derived from correctly-sized, + // initialized arrays (`pk` = $pk_len bytes, `sk` = $sk_len + // bytes, `coins` = 64 bytes). None of the buffers overlap, + // and the C function only reads/writes within the + // advertised sizes. + let ret = unsafe { + $keypair_derand(pk.as_mut_ptr(), sk.as_mut_ptr(), coins.as_ptr()) + }; + assert_eq!(ret, 0, "keypair_derand failed unexpectedly"); + Ok(Self { + sk, + ek: EncapsulationKey(pk), + }) + } + } + + impl TryFrom<&[u8]> for DecapsulationKey { + type Error = InvalidKey; + + fn try_from(bytes: &[u8]) -> Result { + let arr: [u8; $sk_len] = bytes.try_into().map_err(|_| InvalidKey)?; + // SAFETY: `arr` is a correctly-sized [$sk_len-byte] array; + // the C function reads exactly $sk_len bytes from the pointer. + if unsafe { $check_sk(arr.as_ptr()) } != 0 { + return Err(InvalidKey); + } + let mut pk = [0u8; $pk_len]; + pk.copy_from_slice(&arr[$ek_start..$ek_start + $pk_len]); + Ok(Self { + sk: arr, + ek: EncapsulationKey::try_from(pk.as_slice())?, + }) + } + } + + impl TryFrom<&[u8]> for EncapsulationKey { + type Error = InvalidKey; + + fn try_from(bytes: &[u8]) -> Result { + let arr: [u8; $pk_len] = bytes.try_into().map_err(|_| InvalidKey)?; + let key = Self(arr); + // SAFETY: `key.0` is a correctly-sized [$pk_len-byte] array; + // the C function reads exactly $pk_len bytes from the pointer. + if unsafe { $check_pk(key.0.as_ptr()) } != 0 { + return Err(InvalidKey); + } + Ok(key) + } + } + } + }; +} + +define_mlkem! { + module = mlkem512, + sys_mod = mlkem512, + pk_len = 800, + sk_len = 1632, + ek_start = 768, + pk_size = hybrid_array::typenum::U800, + ct_size = hybrid_array::typenum::U768, + keypair_derand = PQCP_MLKEM_NATIVE_MLKEM512_keypair_derand, + enc_derand = PQCP_MLKEM_NATIVE_MLKEM512_enc_derand, + dec = PQCP_MLKEM_NATIVE_MLKEM512_dec, + check_pk = PQCP_MLKEM_NATIVE_MLKEM512_check_pk, + check_sk = PQCP_MLKEM_NATIVE_MLKEM512_check_sk, +} + +define_mlkem! { + module = mlkem768, + sys_mod = mlkem768, + pk_len = 1184, + sk_len = 2400, + ek_start = 1152, + pk_size = hybrid_array::sizes::U1184, + ct_size = hybrid_array::sizes::U1088, + keypair_derand = PQCP_MLKEM_NATIVE_MLKEM768_keypair_derand, + enc_derand = PQCP_MLKEM_NATIVE_MLKEM768_enc_derand, + dec = PQCP_MLKEM_NATIVE_MLKEM768_dec, + check_pk = PQCP_MLKEM_NATIVE_MLKEM768_check_pk, + check_sk = PQCP_MLKEM_NATIVE_MLKEM768_check_sk, +} + +define_mlkem! { + module = mlkem1024, + sys_mod = mlkem1024, + pk_len = 1568, + sk_len = 3168, + ek_start = 1536, + pk_size = hybrid_array::sizes::U1568, + ct_size = hybrid_array::sizes::U1568, + keypair_derand = PQCP_MLKEM_NATIVE_MLKEM1024_keypair_derand, + enc_derand = PQCP_MLKEM_NATIVE_MLKEM1024_enc_derand, + dec = PQCP_MLKEM_NATIVE_MLKEM1024_dec, + check_pk = PQCP_MLKEM_NATIVE_MLKEM1024_check_pk, + check_sk = PQCP_MLKEM_NATIVE_MLKEM1024_check_sk, +} + +#[cfg(test)] +mod tests { + // Each parameter set gets the same suite of tests, generated by this macro. + // + // Arguments: + // $module — one of mlkem512 / mlkem768 / mlkem1024 + // $pk_len — public-key (encapsulation key) byte length + // $sk_len — secret-key (decapsulation key) byte length + macro_rules! define_kem_tests { + ($module:ident, $pk_len:literal, $sk_len:literal) => { + mod $module { + use crate::$module::{DecapsulationKey, EncapsulationKey, MlKem}; + use kem::{Decapsulate, Encapsulate, Kem, KeyExport}; + + // ── Core KEM correctness ────────────────────────────────── + + /// Encapsulation followed by decapsulation must recover the + /// same shared secret. + #[test] + fn roundtrip() { + let (dk, ek) = MlKem::generate_keypair(); + let (ct, ss_enc) = ek.encapsulate(); + let ss_dec = dk.decapsulate(&ct); + assert_eq!(ss_enc, ss_dec); + } + + /// Decapsulating with the *wrong* secret key must not recover + /// the sender's shared secret (FIPS 203 implicit rejection). + #[test] + fn wrong_key_implicit_rejection() { + let (dk1, _) = MlKem::generate_keypair(); + let (_, ek2) = MlKem::generate_keypair(); + let (ct, ss_enc) = ek2.encapsulate(); + let ss_wrong = dk1.decapsulate(&ct); + assert_ne!(ss_enc, ss_wrong); + } + + /// Flipping a bit in the ciphertext must not recover the + /// sender's shared secret (FIPS 203 implicit rejection). + #[test] + fn tampered_ciphertext_implicit_rejection() { + let (dk, ek) = MlKem::generate_keypair(); + let (mut ct, ss_enc) = ek.encapsulate(); + ct.as_mut_slice()[0] ^= 0x01; + let ss_dec = dk.decapsulate(&ct); + assert_ne!(ss_enc, ss_dec); + } + + // ── Key sizes ───────────────────────────────────────────── + + #[test] + fn key_byte_lengths() { + let (dk, ek) = MlKem::generate_keypair(); + assert_eq!(ek.to_bytes().len(), $pk_len); + assert_eq!(dk.as_bytes().len(), $sk_len); + } + + // ── Serialization round-trips ───────────────────────────── + + /// A serialized then deserialized encapsulation key must be + /// equal to the original and must still produce a ciphertext + /// that the original decapsulation key can open. + #[test] + fn encapsulation_key_serialization() { + let (dk, ek) = MlKem::generate_keypair(); + let ek_bytes = ek.to_bytes(); + let ek2 = EncapsulationKey::try_from(ek_bytes.as_slice()) + .expect("deserialization of a freshly exported key must succeed"); + assert_eq!(ek, ek2); + let (ct, ss_enc) = ek2.encapsulate(); + let ss_dec = dk.decapsulate(&ct); + assert_eq!(ss_enc, ss_dec); + } + + /// A serialized then deserialized decapsulation key must still + /// open ciphertexts produced for the corresponding + /// encapsulation key. + #[test] + fn decapsulation_key_serialization() { + let (dk, ek) = MlKem::generate_keypair(); + let dk2 = DecapsulationKey::try_from(dk.as_bytes()) + .expect("deserialization of a freshly exported key must succeed"); + let (ct, ss_enc) = ek.encapsulate(); + let ss_dec = dk2.decapsulate(&ct); + assert_eq!(ss_enc, ss_dec); + } + + // ── Invalid-length inputs ───────────────────────────────── + + #[test] + fn encapsulation_key_wrong_length() { + assert!( + EncapsulationKey::try_from(&[0u8; $pk_len - 1][..]).is_err(), + "one byte short should be InvalidKey" + ); + assert!( + EncapsulationKey::try_from(&[0u8; $pk_len + 1][..]).is_err(), + "one byte long should be InvalidKey" + ); + } + + #[test] + fn decapsulation_key_wrong_length() { + assert!( + DecapsulationKey::try_from(&[0u8; $sk_len - 1][..]).is_err(), + "one byte short should be InvalidKey" + ); + assert!( + DecapsulationKey::try_from(&[0u8; $sk_len + 1][..]).is_err(), + "one byte long should be InvalidKey" + ); + } + + // ── Key corruption rejection ────────────────────────────── + + /// Flipping a bit in the stored H(ek) section of a decapsulation + /// key must cause `try_from` to return `Err` (FIPS 203 §7.3). + /// + /// H(ek) occupies the 32 bytes at `sk_len - 64`. Any single-bit + /// change there makes the hash comparison fail. + #[test] + fn corrupted_decapsulation_key_rejected() { + let (dk, _) = MlKem::generate_keypair(); + let mut dk_bytes = dk.as_bytes().to_vec(); + dk_bytes[$sk_len - 64] ^= 1; + assert!( + DecapsulationKey::try_from(dk_bytes.as_slice()).is_err(), + "a bit-flipped H(ek) must be rejected by the hash check" + ); + } + + /// Corrupting an encapsulation key so that a polynomial + /// coefficient exceeds q must cause `try_from` to return `Err` + /// (FIPS 203 §7.2). + /// + /// Byte 1 holds bits [11:8] of the first coefficient in its + /// lower nibble. Valid values are at most 0xD (q-1 = 3328 = + /// 0xD00), so ORing with 0x0E forces the nibble to be at least + /// 0xE (≥ 0xE00 = 3584 > q), regardless of the original value. + #[test] + fn corrupted_encapsulation_key_rejected() { + let (_, ek) = MlKem::generate_keypair(); + let mut ek_bytes = ek.to_bytes().to_vec(); + ek_bytes[1] |= 0x0E; + assert!( + EncapsulationKey::try_from(ek_bytes.as_slice()).is_err(), + "an out-of-range coefficient must be rejected by the modulus check" + ); + } + } + }; + } + + define_kem_tests!(mlkem512, 800, 1632); + define_kem_tests!(mlkem768, 1184, 2400); + define_kem_tests!(mlkem1024, 1568, 3168); +} diff --git a/bindings/rust/mlkem-native/src/sys.rs b/bindings/rust/mlkem-native/src/sys.rs new file mode 100644 index 000000000..ab7888c75 --- /dev/null +++ b/bindings/rust/mlkem-native/src/sys.rs @@ -0,0 +1,117 @@ +// Copyright (c) The mlkem-native project authors +// SPDX-License-Identifier: Apache-2.0 OR ISC OR MIT + +#![allow( + non_upper_case_globals, + non_camel_case_types, + non_snake_case, + dead_code +)] + +pub mod mlkem512 { + pub const MLKEM512_SECRETKEYBYTES: usize = 1632; + pub const MLKEM512_PUBLICKEYBYTES: usize = 800; + pub const MLKEM512_CIPHERTEXTBYTES: usize = 768; + pub const MLKEM512_SYMBYTES: usize = 32; + pub const MLKEM512_BYTES: usize = 32; + + pub const MLKEM_SYMBYTES: usize = 32; + pub const MLKEM_BYTES: usize = 32; + + pub const MLK_ERR_FAIL: i32 = -1; + pub const MLK_ERR_OUT_OF_MEMORY: i32 = -2; + pub const MLK_ERR_RNG_FAIL: i32 = -3; + + unsafe extern "C" { + pub fn PQCP_MLKEM_NATIVE_MLKEM512_keypair_derand( + pk: *mut u8, + sk: *mut u8, + coins: *const u8, + ) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM512_enc_derand( + ct: *mut u8, + ss: *mut u8, + pk: *const u8, + coins: *const u8, + ) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM512_dec(ss: *mut u8, ct: *const u8, sk: *const u8) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM512_check_pk(pk: *const u8) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM512_check_sk(sk: *const u8) -> i32; + } +} + +pub mod mlkem768 { + pub const MLKEM768_SECRETKEYBYTES: usize = 2400; + pub const MLKEM768_PUBLICKEYBYTES: usize = 1184; + pub const MLKEM768_CIPHERTEXTBYTES: usize = 1088; + pub const MLKEM768_SYMBYTES: usize = 32; + pub const MLKEM768_BYTES: usize = 32; + + pub const MLKEM_SYMBYTES: usize = 32; + pub const MLKEM_BYTES: usize = 32; + + pub const MLK_ERR_FAIL: i32 = -1; + pub const MLK_ERR_OUT_OF_MEMORY: i32 = -2; + pub const MLK_ERR_RNG_FAIL: i32 = -3; + + unsafe extern "C" { + pub fn PQCP_MLKEM_NATIVE_MLKEM768_keypair_derand( + pk: *mut u8, + sk: *mut u8, + coins: *const u8, + ) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM768_enc_derand( + ct: *mut u8, + ss: *mut u8, + pk: *const u8, + coins: *const u8, + ) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM768_dec(ss: *mut u8, ct: *const u8, sk: *const u8) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM768_check_pk(pk: *const u8) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM768_check_sk(sk: *const u8) -> i32; + } +} + +pub mod mlkem1024 { + pub const MLKEM1024_SECRETKEYBYTES: usize = 3168; + pub const MLKEM1024_PUBLICKEYBYTES: usize = 1568; + pub const MLKEM1024_CIPHERTEXTBYTES: usize = 1568; + pub const MLKEM1024_SYMBYTES: usize = 32; + pub const MLKEM1024_BYTES: usize = 32; + + pub const MLKEM_SYMBYTES: usize = 32; + pub const MLKEM_BYTES: usize = 32; + + pub const MLK_ERR_FAIL: i32 = -1; + pub const MLK_ERR_OUT_OF_MEMORY: i32 = -2; + pub const MLK_ERR_RNG_FAIL: i32 = -3; + + unsafe extern "C" { + pub fn PQCP_MLKEM_NATIVE_MLKEM1024_keypair_derand( + pk: *mut u8, + sk: *mut u8, + coins: *const u8, + ) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM1024_enc_derand( + ct: *mut u8, + ss: *mut u8, + pk: *const u8, + coins: *const u8, + ) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM1024_dec(ss: *mut u8, ct: *const u8, sk: *const u8) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM1024_check_pk(pk: *const u8) -> i32; + + pub fn PQCP_MLKEM_NATIVE_MLKEM1024_check_sk(sk: *const u8) -> i32; + } +} diff --git a/flake.nix b/flake.nix index ee1c3bcd1..39b9fc416 100644 --- a/flake.nix +++ b/flake.nix @@ -47,6 +47,8 @@ export PATH=$PWD/scripts:$PATH export PROOF_DIR="$PWD/proofs/hol_light" ''; + rustPackages = [ pkgs.rustup pkgs.llvmPackages.libclang ]; + rustEnv = { LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib"; }; in { _module.args.pkgs = import inputs.nixpkgs { @@ -87,7 +89,7 @@ packages.toolchain_aarch64_be = util.toolchain_aarch64_be; packages.gcc-arm-embedded = pkgs.gcc-arm-embedded; - devShells.default = util.mkShell { + devShells.default = (util.mkShell { packages = builtins.attrValues { inherit (config.packages) linters cbmc hol_light s2n_bignum slothy toolchains_native hol_server; @@ -95,8 +97,9 @@ direnv nix-direnv zig_0_13; - } ++ pkgs.lib.optionals (!pkgs.stdenv.isDarwin) [ config.packages.valgrind_varlat ]; - }; + } ++ rustPackages + ++ pkgs.lib.optionals (!pkgs.stdenv.isDarwin) [ config.packages.valgrind_varlat ]; + }).overrideAttrs (_: rustEnv); packages.hol_server = util.hol_server.hol_server_start; devShells.hol_light = (util.mkShell { @@ -170,6 +173,26 @@ ]; }; + devShells.rust = (util.mkShell { + packages = builtins.attrValues { inherit (config.packages) toolchains_native; } + ++ rustPackages; + }).overrideAttrs (_: rustEnv); + + devShells.cross-rust-aarch64 = (util.mkShell { + packages = builtins.attrValues { inherit (config.packages) toolchain_aarch64; } + ++ rustPackages; + }).overrideAttrs (_: rustEnv); + + devShells.cross-rust-riscv64 = (util.mkShell { + packages = builtins.attrValues { inherit (config.packages) toolchain_riscv64; } + ++ rustPackages; + }).overrideAttrs (_: rustEnv); + + devShells.cross-rust-ppc64le = (util.mkShell { + packages = builtins.attrValues { inherit (config.packages) toolchain_ppc64le; } + ++ rustPackages; + }).overrideAttrs (_: rustEnv); + devShells.cross-avr = util.mkShell (import ./nix/avr { inherit pkgs; }); devShells.linter = util.mkShellNoCC { diff --git a/scripts/lint b/scripts/lint index b1245f1f2..c14d16edb 100755 --- a/scripts/lint +++ b/scripts/lint @@ -224,7 +224,7 @@ gh_group_end check-spdx() { local success=true - for file in $(git ls-files -- ":/" ":/!:*.json" ":/!:*.png" ":/!:*.patch" ":/!:*LICENSE*" ":/!:.git*" ":/!:flake.lock"); do + for file in $(git ls-files -- ":/" ":/!:*.json" ":/!:*.png" ":/!:*.patch" ":/!:*LICENSE*" ":/!:.git*" ":/!:flake.lock" ":/!:Cargo.lock"); do # Ignore symlinks if [[ ! -L $file && $(grep "SPDX-License-Identifier:" "$file" | wc -l) == 0 ]]; then gh_error "$file" "${line:-1}" "Missing license header error" "$file is missing SPDX License header" diff --git a/test/acvp/acvp_client.py b/test/acvp/acvp_client.py index 0c31b3a10..6d81ff1c6 100755 --- a/test/acvp/acvp_client.py +++ b/test/acvp/acvp_client.py @@ -101,16 +101,24 @@ def info(msg, **kwargs): def get_acvp_binary(tg): - """Convert JSON dict for ACVP test group to suitable ACVP binary.""" + """Return the command prefix (list) for the binary handling this test group. + + Without --binary: one-element list with the level-specific C binary path. + With --binary: two-element list [binary, level] for a combined binary + such as the Rust acvp_mlkem that takes the level as its + first argument. + """ parameterSetToLevel = { "ML-KEM-512": 512, "ML-KEM-768": 768, "ML-KEM-1024": 1024, } level = parameterSetToLevel[tg["parameterSet"]] + if custom_binary is not None: + return [custom_binary, str(level)] basedir = f"./test/build/mlkem{level}/bin" acvp_bin = f"acvp_mlkem{level}" - return f"{basedir}/{acvp_bin}" + return [f"{basedir}/{acvp_bin}"] def run_encapDecap_test(tg, tc): @@ -119,8 +127,7 @@ def run_encapDecap_test(tg, tc): results = {"tcId": tc["tcId"]} if tg["function"] == "encapsulation": acvp_bin = get_acvp_binary(tg) - acvp_call = exec_prefix + [ - acvp_bin, + acvp_call = exec_prefix + acvp_bin + [ "encapDecap", "AFT", "encapsulation", @@ -139,8 +146,7 @@ def run_encapDecap_test(tg, tc): results[k] = v elif tg["function"] == "decapsulation": acvp_bin = get_acvp_binary(tg) - acvp_call = exec_prefix + [ - acvp_bin, + acvp_call = exec_prefix + acvp_bin + [ "encapDecap", "VAL", "decapsulation", @@ -159,8 +165,7 @@ def run_encapDecap_test(tg, tc): results[k] = v elif tg["function"] == "encapsulationKeyCheck": acvp_bin = get_acvp_binary(tg) - acvp_call = exec_prefix + [ - acvp_bin, + acvp_call = exec_prefix + acvp_bin + [ "encapDecap", "VAL", "encapsulationKeyCheck", @@ -179,8 +184,7 @@ def run_encapDecap_test(tg, tc): elif tg["function"] == "decapsulationKeyCheck": acvp_bin = get_acvp_binary(tg) - acvp_call = exec_prefix + [ - acvp_bin, + acvp_call = exec_prefix + acvp_bin + [ "encapDecap", "VAL", "decapsulationKeyCheck", @@ -205,8 +209,7 @@ def run_keyGen_test(tg, tc): results = {"tcId": tc["tcId"]} acvp_bin = get_acvp_binary(tg) - acvp_call = exec_prefix + [ - acvp_bin, + acvp_call = exec_prefix + acvp_bin + [ "keyGen", "AFT", f"z={tc['z']}", @@ -341,8 +344,21 @@ def test(prompt, expected, output, version): default="v1.1.0.41", help="ACVP test vector version (default: v1.1.0.41)", ) +parser.add_argument( + "--binary", + "-b", + default=None, + help=( + "Path to a combined ACVP binary that accepts the security level " + "(512, 768, or 1024) as its first argument, e.g. the Rust acvp_mlkem " + "binary. When omitted, the default per-level C binaries are used." + ), +) args = parser.parse_args() +# Set by --binary; read by get_acvp_binary(). +custom_binary = args.binary + if args.prompt is None: print(f"Using ACVP test vectors version {args.version}", file=sys.stderr)