From c3782d1ae624ef2df644abcff2c80e50b95a0b23 Mon Sep 17 00:00:00 2001 From: everbarry Date: Thu, 9 Apr 2026 20:11:46 +0200 Subject: [PATCH] add fuzzing and v1.0.2 --- .gitignore | 9 + Cargo.toml | 1 + README.md | 3 +- docs/FUZZING.md | 106 +++ docs/PYTHON_API.md | 22 +- docs/SECURITY.md | 61 +- fuzz/Cargo.lock | 762 ++++++++++++++++++ fuzz/Cargo.toml | 61 ++ fuzz/fuzz_targets/bbs_verify_presentation.rs | 8 + fuzz/fuzz_targets/crypto_deserialize.rs | 8 + .../handshake_initiator_complete.rs | 8 + fuzz/fuzz_targets/handshake_respond.rs | 8 + fuzz/fuzz_targets/replay_sequence.rs | 8 + fuzz/fuzz_targets/session_decrypt.rs | 8 + fuzz/src/lib.rs | 142 ++++ scripts/fuzz-libfuzzer.sh | 46 ++ src/credential/bbs.rs | 27 + src/credential/mod.rs | 2 +- src/credential/schnorr.rs | 111 +++ src/error.rs | 3 + src/node.rs | 140 +++- src/python.rs | 44 +- src/transport/session.rs | 10 + tests/test_zkac.py | 77 +- 24 files changed, 1628 insertions(+), 47 deletions(-) create mode 100644 docs/FUZZING.md create mode 100644 fuzz/Cargo.lock create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/fuzz_targets/bbs_verify_presentation.rs create mode 100644 fuzz/fuzz_targets/crypto_deserialize.rs create mode 100644 fuzz/fuzz_targets/handshake_initiator_complete.rs create mode 100644 fuzz/fuzz_targets/handshake_respond.rs create mode 100644 fuzz/fuzz_targets/replay_sequence.rs create mode 100644 fuzz/fuzz_targets/session_decrypt.rs create mode 100644 fuzz/src/lib.rs create mode 100755 scripts/fuzz-libfuzzer.sh diff --git a/.gitignore b/.gitignore index 002c17e..0a48682 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,15 @@ # Rust target +# cargo-fuzz / fuzzing +fuzz/corpus/ +fuzz/artifacts/ +fuzz/target/ +fuzz/crashes/ +crash-* +leak-* +*.profraw + # Python / Maturin python/zkac/__pycache__ python/zkac/*.cpython* diff --git a/Cargo.toml b/Cargo.toml index f6fd175..24df2e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,3 +26,4 @@ pyo3 = { version = "0.25", features = ["extension-module"], optional = true } [features] default = [] python = ["pyo3"] +fuzz-expose = [] # `Session::new_fuzz` for fuzz builds only diff --git a/README.md b/README.md index e635123..5f44ac0 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ ## Documentation - **[Python API](docs/PYTHON_API.md)** — types and usage for `import zkac` -- **[Security model](SECURITY.md)** — threat model, assumptions, operational guidance +- **[Security model](docs/SECURITY.md)** — threat model, assumptions, operational guidance +- **[Fuzzing](docs/FUZZING.md)** — `cargo-fuzz` harnesses ## Rust diff --git a/docs/FUZZING.md b/docs/FUZZING.md new file mode 100644 index 0000000..d2a97cc --- /dev/null +++ b/docs/FUZZING.md @@ -0,0 +1,106 @@ +# Fuzzing ZKAC + +This repository ships a **libFuzzer** harness via [`cargo-fuzz`](https://github.com/rust-fuzz/cargo-fuzz). You can reuse `zkac_fuzz` from other fuzzers (e.g. AFL++, Honggfuzz) with a small custom binary. + +## Prerequisites + +- **Rust** toolchain (`rustc`, `cargo`). +- **`cargo-fuzz`:** `cargo install cargo-fuzz` +- **AddressSanitizer / default `cargo fuzz`:** requires **nightly** Rust (e.g. `rustup toolchain install nightly`). If you use a distro Rust **without** `rustup`, use sanitizer `none` (see below). + +## Quick start + +From the repository root: + +```bash +chmod +x scripts/fuzz-libfuzzer.sh +./scripts/fuzz-libfuzzer.sh +``` + +Fuzz a single target for 5 minutes: + +```bash +FUZZ_TIME=300 ./scripts/fuzz-libfuzzer.sh handshake_respond +``` + +### Stable Rust (no nightly) + +Default `cargo fuzz` enables AddressSanitizer and needs nightly. To fuzz on **stable**, build and run with sanitizer `none`: + +```bash +cargo fuzz build -s none handshake_respond +cargo fuzz run -s none handshake_respond -- -max_total_time=60 +``` + +The helper script sets `SANITIZER=none` by default for broad compatibility. It also prepends `~/.cargo/bin` to `PATH` so `cargo-fuzz` is found after `cargo install cargo-fuzz`. + +### Did anything crash? + +After a run: + +1. **Exit code** — `0` means every target finished its time/run budget **without** libFuzzer treating the process as a crash. Non-zero means at least one target failed (crash, abort, or libFuzzer error). +2. **Stdout** — On failure, libFuzzer prints `ERROR: libFuzzer: deadly signal` (or ASan messages if you use a sanitizer), the panic/backtrace, and a **reproduce** command. +3. **Artifacts** — Crashes are written under `fuzz/artifacts//`, typically `crash-` (and sometimes `leak-*` with leak sanitizer). Re-run with: + ```bash + cargo fuzz run -s none fuzz/artifacts//crash- + ``` +4. **Minimize** — Shrink a crashing input with `cargo fuzz tmin -s none `. + +If the script stops early, scroll up to the last `=== cargo-fuzz: ===` block: the lines after it show which target failed. + +### Nightly + AddressSanitizer + +```bash +SANITIZER=address FUZZ_TIME=120 ./scripts/fuzz-libfuzzer.sh session_decrypt +``` + +## Targets (`fuzz/fuzz_targets/`) + +| Target | Exercises | +|--------|-----------| +| `handshake_respond` | X25519 responder (`handshake::respond`) | +| `handshake_initiator_complete` | Initiator `complete` with arbitrary response bytes | +| `session_decrypt` | ChaCha20-Poly1305 decrypt + replay guard | +| `replay_sequence` | Sliding-window replay logic | +| `crypto_deserialize` | Ristretto public key, Schnorr signature, BBS+ issuer key parsing | +| `bbs_verify_presentation` | BBS+ proof parse + verify (heavy; keep corpora small) | + +Shared logic lives in `fuzz/src/lib.rs` (`zkac_fuzz`). + +## Crate feature `fuzz-expose` + +Fuzz builds enable `zkac`’s **`fuzz-expose`** feature so harnesses can call `Session::new_fuzz`. Do not enable this in production binaries. + +## corpora / artifacts + +- **Inputs:** seed with `mkdir -p fuzz/corpus/` and pass `-artifact_prefix=fuzz/artifacts/` if you want crash dumps. +- **Gitignored:** `fuzz/corpus/`, `fuzz/artifacts/`, crash files (see `.gitignore`). + +## AFL++ (optional) + +1. Install [AFL++](https://github.com/AFLplusplus/AFLplusplus) and `cargo install cargo-afl`. +2. Add a binary in `fuzz/` (or a separate crate) that calls into `zkac_fuzz::dispatch_all` or individual `zkac_fuzz::*` functions. +3. Build with the `cargo-afl` wrapper (not plain `cargo build`): + +```bash +cd fuzz +cargo afl build --release +# then point cargo-afl at your harness binary, e.g.: +# cargo afl fuzz -i in -o out target/release/your_afl_bin +``` + +(`fuzz/Cargo.toml` already enables `zkac`’s `fuzz-expose` for the dependency.) + +The `afl` crate’s `fuzz!` macro must be linked with AFL’s runtime; use `cargo afl build` as documented in [cargo-afl](https://github.com/rust-fuzz/afl.rs). + +## Honggfuzz (optional) + +Install the `honggfuzz` binary (distro package or source). Point it at a binary that exercises `zkac_fuzz` (similar to AFL). Honggfuzz does not require the `cargo-fuzz` project layout; you can compile a small `main` that loops over inputs. + +## CI smoke + +Short runs can use `-runs=1000` instead of time limits: + +```bash +cargo fuzz run -s none handshake_respond -- -runs=1000 -print_final_stats=1 +``` diff --git a/docs/PYTHON_API.md b/docs/PYTHON_API.md index d70b9ca..3be6d05 100644 --- a/docs/PYTHON_API.md +++ b/docs/PYTHON_API.md @@ -1,6 +1,6 @@ # ZKAC Python API Reference -Version 0.1. Cryptographic stack: **BBS+** on BLS12-381 (credentials), **X25519** + **ChaCha20-Poly1305** (transport), **BLAKE2b** (role IDs). +Version 0.2. Cryptographic stack: **BBS+** on BLS12-381 (credentials), **X25519** + **ChaCha20-Poly1305** (transport), **Schnorr/Ristretto255** (identity), **BLAKE2b** (role IDs, signatures). ```python import zkac @@ -16,16 +16,16 @@ Upper bound on BBS+ proof size in an encrypted auth packet (256 KiB). Larger pro ### `Keypair()` -Generates a new random keypair. +Generates a new random keypair (long-term identity). -### `Keypair.public_key() -> PublicKey` - -Raises `ValueError` if consumed by `Node(...)`. +- `public_key() -> PublicKey` — raises `ValueError` if consumed by `Node(...)` +- `sign(msg: bytes) -> bytes` — 64-byte Schnorr signature over `msg` ### `PublicKey` - `to_bytes() -> bytes` — 32 bytes - `from_bytes(bytes) -> PublicKey` +- `verify(msg: bytes, signature: bytes) -> bool` — verify a 64-byte Schnorr signature - Equality, hash, `repr` supported ## BBS+ credentials @@ -71,7 +71,8 @@ New random issuer. `from_secret_key(bytes)` restores from 32-byte secret. - `public_key() -> PublicKey` - `connect() -> (PendingConnect, bytes)` — 32-byte init message - `accept(init_msg) -> (Session, bytes)` — `init_msg` 32 bytes -- `complete_connect(pending, response_msg, credential) -> (Session, bytes)` — `response_msg` 32 bytes +- `prove_identity(session) -> bytes` — server produces encrypted identity proof (Schnorr signature over transcript) +- `complete_connect(pending, response_msg, identity_proof, expected_server_pk, credential) -> (Session, bytes)` — verifies server identity, then produces BBS+ auth packet - `verify_auth(session, encrypted_auth, registry) -> bytes` — returns 32-byte `role_id` ### `Session` @@ -84,13 +85,14 @@ New random issuer. `from_secret_key(bytes)` restores from 32-byte secret. 1. Issuer creates `BbsIssuer()`; server `register_role(role_id, issuer.public_key(), epoch)`. 2. Member: `prepare_blind_request` → issuer `issue_blind` → `Credential.finalize`. -3. Client: `connect` → server `accept` → client `complete_connect` → server `verify_auth`. -4. Use `Session.encrypt` / `decrypt` for data. +3. **Out-of-band:** client obtains server's `PublicKey` (static config, pinning, etc.). +4. Client: `connect` → server `accept` → server `prove_identity` → client `complete_connect` (verifies server identity + sends BBS+ auth) → server `verify_auth`. +5. Use `Session.encrypt` / `decrypt` for data. ## Errors -Raises `ValueError` with descriptive messages for crypto failures, replay, and bad inputs. +Raises `ValueError` with descriptive messages for crypto failures, replay, identity verification, and bad inputs. ## Further reading -[Security model and assumptions](./SECURITY.md) +[Security model and assumptions](../SECURITY.md) diff --git a/docs/SECURITY.md b/docs/SECURITY.md index b1fdc03..1cb3637 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -1,13 +1,14 @@ -# Security model and audit notes (ZKAC 0.1) +# Security model and audit notes (ZKAC 0.2) This document summarizes the design, residual risks, and recommendations for operators integrating **ZKAC**. It is not a substitute for independent review before high-assurance deployment. ## Goals - **Authentication:** Only holders of a valid BBS+ credential for a registered role can complete `verify_auth` for that role. +- **Server identity:** The server proves its long-term identity to the client via a Schnorr signature over the session transcript; clients verify against a pinned public key. This prevents MITM attacks without requiring TLS. - **Confidentiality & integrity:** Session payloads are authenticated-encrypted (ChaCha20-Poly1305) with a key derived from an ephemeral X25519 handshake. - **Replay resistance:** Duplicate ciphertexts in a direction are rejected (sliding window + monotonic counter). -- **Unlinkability (credential layer):** BBS+ presentations are intended to be unlinkable across sessions when the presentation header (here, the session transcript hash) differs; the verifier learns only the disclosed attributes (opaque `role_id`, epoch) and validity. +- **Unlinkability (credential layer):** BBS+ presentations are unlinkable across sessions when the presentation header (the session transcript hash) differs; the verifier learns only the disclosed attributes (opaque `role_id`, epoch) and validity. Client anonymity is preserved: the client never reveals its long-term key during the handshake. - **Server cannot forge credentials:** The server stores only the issuer **public** key per role; forging requires the issuer secret key. ## Cryptographic components @@ -15,9 +16,26 @@ This document summarizes the design, residual risks, and recommendations for ope | Layer | Primitive | Purpose | |-------|-----------|---------| | Transport | X25519 ephemeral DH, HKDF-SHA256, ChaCha20-Poly1305 | Session keys, AEAD | +| Identity | Schnorr on Ristretto255, BLAKE2b-512 challenge | Server identity binding | | Credentials | BBS+ on BLS12-381 (zkryptium), SHAKE256 ciphersuite | Blind issuance, ZK presentations | | Role IDs | BLAKE2b-512 (truncated to 32 bytes) | Opaque role identifiers | +## Protocol flow + +``` +Client Server + |--- init_msg (eph_pk) ------------>| + | | accept() + | | prove_identity() → sign(transcript) + |<-- response_msg + identity_pkt ---| + | complete DH | + | decrypt + verify server sig | + | encrypt BBS+ auth | + |--- encrypted BBS+ auth ---------> | + | | verify_auth() + |===== encrypted session ===========>| +``` + ## Threats considered ### Network attacker (passive) @@ -26,8 +44,9 @@ This document summarizes the design, residual risks, and recommendations for ope ### Network attacker (active / MITM) -- **Without binding the handshake to authenticated long-term keys:** A MITM can run its own X25519 exchange with each side and decrypt/modify traffic unless an **out-of-band** binding exists (e.g. TLS with server authentication, or clients verifying server `PublicKey` through a trusted channel). The library’s `Keypair` / `PublicKey` are available for application-level identity display or future binding; **this release does not sign the handshake with long-term keys**. -- **Recommendation:** Run the protocol inside **TLS 1.3** (or similar) for production, or add an explicit long-term key signing step over the transcript if you need standalone MITM resistance. +- **Server impersonation:** The server signs the session transcript hash with its long-term Ristretto255 key (`prove_identity`). The client verifies this signature against the **pinned** server public key. A MITM running a separate DH exchange produces a different transcript; it cannot forge the server's signature. The client aborts on mismatch. +- **Client impersonation:** The BBS+ presentation is bound to the session transcript hash. A MITM cannot relay a presentation from one session to another (different transcripts) or forge one (requires a valid credential from the issuer). +- **Relay attack:** A MITM that relays the real server's identity proof to a client fails because the proof is encrypted under the MITM-to-server session keys (not the client-to-MITM keys), and the signature is over the wrong transcript. ### Malicious server @@ -35,10 +54,11 @@ This document summarizes the design, residual risks, and recommendations for ope - **Cannot** forge BBS+ credentials without the issuer secret key. - **Cannot** learn `member_secret` from presentations under the BBS+ security assumptions. - **Cannot** distinguish which specific member authenticated among valid credential holders (unlinkability holds against the verifier for distinct presentation headers). +- **Cannot** learn the client's long-term public key — it is never transmitted. ### Malicious client -- Cannot decrypt others’ traffic without session keys. +- Cannot decrypt others' traffic without session keys. - Cannot produce valid auth for a role without a valid credential + correct epoch + registry entry. ### Denial of service @@ -47,29 +67,50 @@ This document summarizes the design, residual risks, and recommendations for ope - **Handshake:** Fixed 32-byte messages; no variable-length handshake parsing. - General packet limits should still be enforced at the application layer (total message size, rate limits). +## Key distribution + +The server's long-term `PublicKey` (32-byte Ristretto255 point) functions as a **self-authenticating identity** — no certificate authority is required. The client must obtain and pin this key before connecting. + +Recommended strategies: + +1. **Static configuration** (default): embed the server public key in client config, environment variable, or CLI flag. Equivalent to WireGuard's `[Peer] PublicKey = ...`. +2. **Trust On First Use (TOFU):** accept the server's key on first connection, pin it for subsequent sessions. Risk: first connection is vulnerable. +3. **Out-of-band verification:** compare public key fingerprints over a trusted side channel (phone, in-person, encrypted messaging). +4. **Key registry / directory:** a trusted service maps names to public keys. Shifts trust to the registry and its authentication channel. + ## Operational requirements 1. **Issuer secret key:** Protect `BbsIssuer` secret material (HSM, KMS, or encrypted at rest). Compromise = ability to issue arbitrary credentials for that role. -2. **Member storage:** `member_secret` and finalized `Credential` material must be protected; loss = re-enrollment required. -3. **Epoch revocation:** On compromise or policy change, call `set_epoch` and re-issue credentials only to legitimate members; old credentials become invalid at verification time. -4. **Registry integrity:** The server’s `(role_id → public key, epoch)` mapping must be integrity-protected (trusted storage or signed updates), or attackers could swap keys or epochs. -5. **Role ID privacy:** `role_id` is a hash of the role name only if you use `role_id("myrole")`; treat role names as secrets if enumeration is a concern, or derive role IDs with an additional secret salt known to members. +2. **Server long-term key:** Protect the `Node` `Keypair` secret. Compromise = ability to impersonate the server. Rotate the key and distribute the new public key to clients if compromised. +3. **Member storage:** `member_secret` and finalized `Credential` material must be protected; loss = re-enrollment required. +4. **Epoch revocation:** On compromise or policy change, call `set_epoch` and re-issue credentials only to legitimate members; old credentials become invalid at verification time. +5. **Registry integrity:** The server's `(role_id → public key, epoch)` mapping must be integrity-protected (trusted storage or signed updates), or attackers could swap keys or epochs. +6. **Role ID privacy:** `role_id` is a hash of the role name only if you use `role_id("myrole")`; treat role names as secrets if enumeration is a concern, or derive role IDs with an additional secret salt known to members. ## Implementation notes (audit checklist) - [x] BBS+ proof verification uses the same header and presentation binding as proof generation (`verify_presentation` in Rust). - [x] Session transcript is included in the presentation via `present(transcript_hash)`. +- [x] Server identity proof: Schnorr signature over `transcript_hash`, verified against pinned public key before BBS+ auth proceeds. +- [x] Schnorr nonce is deterministic (`H(sk || msg)`) — no dependence on RNG quality at signing time. - [x] Replay protection is symmetric per direction in `Session`. - [x] Constant-time comparisons are used where critical in transport/replay paths (`subtle` crate). +- [x] Client long-term key is never transmitted, preserving BBS+ unlinkability. - [ ] **External:** Python bindings surface raw bytes; callers must not log secrets (`secret_key_bytes`, `member_secret`, `prover_blind`). - [ ] **External:** Use secure randomness from the OS (library uses OS RNG for key generation paths exposed in Rust). +## Design decisions + +- **Server-only identity proof:** Only the server signs the transcript. Adding client long-term signing would break BBS+ unlinkability (the server could correlate sessions by client public key). Client authentication is handled entirely by the anonymous BBS+ credential. +- **Deterministic Schnorr nonces:** The signing nonce is derived as `H("zkac-schnorr-nonce" || sk || msg)`, eliminating a class of RNG-failure attacks (cf. PS3 ECDSA, Sony 2010). Same key + same message = same signature. + ## Known limitations - **No post-quantum** primitives: classical security assumptions only. - **Epoch granularity:** Revocation is coarse (epoch bump); plan issuance and rotation policy accordingly. - **zkryptium dependency:** Security follows the underlying crate and BLS12-381/BBS+ standards; keep dependencies updated. +- **Key distribution:** The library provides the cryptographic mechanism; initial key distribution is an application-layer responsibility. ## Reporting issues -Report security-sensitive findings through your project’s private disclosure channel (configure `SECURITY.md` contact or GitHub security advisories when the repository is public). +Report security-sensitive findings through your project's private disclosure channel (configure `SECURITY.md` contact or GitHub security advisories when the repository is public). diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock new file mode 100644 index 0000000..6a5c0ea --- /dev/null +++ b/fuzz/Cargo.lock @@ -0,0 +1,762 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bls12_381_plus" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa37cf2a8c96054d2dc3d708efe35cc0347014af0d30b86c736b4388ff8491c" +dependencies = [ + "arrayref", + "elliptic-curve", + "ff", + "group", + "hex", + "pairing", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "cc" +version = "1.2.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rand_core", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "bitvec", + "rand_core", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "keccak" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "pairing" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fec4625e73cf41ef4bb6846cafa6d44736525f442ba45e407c4a000a13996f" +dependencies = [ + "group", +] + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[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 = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[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", + "serde_derive", +] + +[[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 = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[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 = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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 = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zkac" +version = "0.1.0" +dependencies = [ + "blake2", + "chacha20poly1305", + "curve25519-dalek", + "digest", + "hex", + "hkdf", + "rand", + "sha2", + "subtle", + "thiserror", + "x25519-dalek", + "zeroize", + "zkryptium", +] + +[[package]] +name = "zkac-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "rand", + "zkac", +] + +[[package]] +name = "zkryptium" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39159a1cd33b28bad7c3502528f77da679dd6f45a055581f5ac56954c458c6e5" +dependencies = [ + "bls12_381_plus", + "digest", + "elliptic-curve", + "ff", + "group", + "hex", + "rand", + "serde", + "serde_json", + "sha2", + "sha3", + "thiserror", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..bb72c31 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "zkac-fuzz" +version = "0.0.0" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +rand = { version = "0.8", features = ["std_rng"] } +zkac = { path = "..", features = ["fuzz-expose"] } + +[lib] +name = "zkac_fuzz" +path = "src/lib.rs" + +[[bin]] +name = "handshake_respond" +path = "fuzz_targets/handshake_respond.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "handshake_initiator_complete" +path = "fuzz_targets/handshake_initiator_complete.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "session_decrypt" +path = "fuzz_targets/session_decrypt.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "replay_sequence" +path = "fuzz_targets/replay_sequence.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "crypto_deserialize" +path = "fuzz_targets/crypto_deserialize.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "bbs_verify_presentation" +path = "fuzz_targets/bbs_verify_presentation.rs" +test = false +doc = false +bench = false + +[workspace] diff --git a/fuzz/fuzz_targets/bbs_verify_presentation.rs b/fuzz/fuzz_targets/bbs_verify_presentation.rs new file mode 100644 index 0000000..9656b37 --- /dev/null +++ b/fuzz/fuzz_targets/bbs_verify_presentation.rs @@ -0,0 +1,8 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zkac_fuzz::bbs_verify_presentation; + +fuzz_target!(|data: &[u8]| { + bbs_verify_presentation(data); +}); diff --git a/fuzz/fuzz_targets/crypto_deserialize.rs b/fuzz/fuzz_targets/crypto_deserialize.rs new file mode 100644 index 0000000..d0cae71 --- /dev/null +++ b/fuzz/fuzz_targets/crypto_deserialize.rs @@ -0,0 +1,8 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zkac_fuzz::crypto_deserialize; + +fuzz_target!(|data: &[u8]| { + crypto_deserialize(data); +}); diff --git a/fuzz/fuzz_targets/handshake_initiator_complete.rs b/fuzz/fuzz_targets/handshake_initiator_complete.rs new file mode 100644 index 0000000..8bf6ba0 --- /dev/null +++ b/fuzz/fuzz_targets/handshake_initiator_complete.rs @@ -0,0 +1,8 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zkac_fuzz::handshake_initiator_complete; + +fuzz_target!(|data: &[u8]| { + handshake_initiator_complete(data); +}); diff --git a/fuzz/fuzz_targets/handshake_respond.rs b/fuzz/fuzz_targets/handshake_respond.rs new file mode 100644 index 0000000..6c69c80 --- /dev/null +++ b/fuzz/fuzz_targets/handshake_respond.rs @@ -0,0 +1,8 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zkac_fuzz::handshake_respond; + +fuzz_target!(|data: &[u8]| { + handshake_respond(data); +}); diff --git a/fuzz/fuzz_targets/replay_sequence.rs b/fuzz/fuzz_targets/replay_sequence.rs new file mode 100644 index 0000000..333f31c --- /dev/null +++ b/fuzz/fuzz_targets/replay_sequence.rs @@ -0,0 +1,8 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zkac_fuzz::replay_sequence; + +fuzz_target!(|data: &[u8]| { + replay_sequence(data); +}); diff --git a/fuzz/fuzz_targets/session_decrypt.rs b/fuzz/fuzz_targets/session_decrypt.rs new file mode 100644 index 0000000..dd4dec1 --- /dev/null +++ b/fuzz/fuzz_targets/session_decrypt.rs @@ -0,0 +1,8 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zkac_fuzz::session_decrypt; + +fuzz_target!(|data: &[u8]| { + session_decrypt(data); +}); diff --git a/fuzz/src/lib.rs b/fuzz/src/lib.rs new file mode 100644 index 0000000..7c9a3ab --- /dev/null +++ b/fuzz/src/lib.rs @@ -0,0 +1,142 @@ +//! Shared fuzz harnesses (`cargo-fuzz`); see `docs/FUZZING.md`. Inputs are capped for DoS bounds. + +use std::sync::OnceLock; + +use rand::{rngs::StdRng, SeedableRng}; +use zkac::credential::{ + role_id, verify_presentation, IssuerKeyPair, IssuerPublicKey, Presentation, PublicKey, + Signature, +}; +use zkac::transport::handshake::{self, InitiatorHandshake}; +use zkac::transport::{ReplayGuard, Session}; + +/// Maximum bytes passed into crypto entry points (DoS bound for fuzzing). +pub const MAX_INPUT_LEN: usize = 1 << 20; + +/// Max proof / issuer key blob size for BBS+ fuzz paths. +pub const MAX_BBS_INPUT: usize = zkac::MAX_BBS_AUTH_PROOF_BYTES; + +fn trim(data: &[u8]) -> &[u8] { + if data.len() > MAX_INPUT_LEN { + &data[..MAX_INPUT_LEN] + } else { + data + } +} + +/// Fuzz `handshake::respond` with arbitrary 32-byte initiator public key material. +pub fn handshake_respond(data: &[u8]) { + let data = trim(data); + let mut init = [0u8; 32]; + let n = data.len().min(32); + init[..n].copy_from_slice(&data[..n]); + let _ = handshake::respond(rand::thread_rng(), &init); +} + +/// Fuzz initiator `complete` with seeded RNG and arbitrary response bytes. +pub fn handshake_initiator_complete(data: &[u8]) { + let data = trim(data); + if data.len() < 8 + 32 { + return; + } + let seed = u64::from_le_bytes(data[0..8].try_into().unwrap()); + let rng = StdRng::seed_from_u64(seed); + let (pending, _init_msg) = InitiatorHandshake::begin(rng); + let mut response = [0u8; 32]; + response.copy_from_slice(&data[8..40]); + let _ = pending.complete(&response); +} + +/// Fuzz `Session::decrypt` with derived keys XOR-mixed from input (covers AEAD + replay). +pub fn session_decrypt(data: &[u8]) { + let data = trim(data); + let mut send_key = [0x5au8; 32]; + let mut recv_key = [0xa5u8; 32]; + for (i, b) in data.iter().take(32).enumerate() { + send_key[i] ^= b; + recv_key[i] ^= b.rotate_left(1); + } + let mut transcript = [0u8; 32]; + let tail = data.len().saturating_sub(32); + for (i, b) in data.iter().skip(32).take(32).enumerate() { + transcript[i] ^= *b; + } + if tail > 0 { + transcript[31] ^= (tail as u8).wrapping_mul(0x9d); + } + let mut recv_session = Session::new_fuzz(send_key, recv_key, transcript); + let _ = recv_session.decrypt(data); +} + +/// Exercise `ReplayGuard` with a stream of little-endian counters. +pub fn replay_sequence(data: &[u8]) { + let data = trim(data); + let mut guard = ReplayGuard::new(); + for chunk in data.chunks_exact(8) { + let c = u64::from_le_bytes(chunk.try_into().unwrap()); + if guard.check(c).is_ok() { + guard.accept(c); + } + } +} + +/// Fuzz deserialization: Ristretto public key, Schnorr signature, BBS+ issuer public key. +pub fn crypto_deserialize(data: &[u8]) { + let data = trim(data); + if data.len() >= 32 { + let mut pk = [0u8; 32]; + pk.copy_from_slice(&data[..32]); + let _ = PublicKey::from_bytes(pk); + } + if data.len() >= 64 { + let mut sig = [0u8; 64]; + sig.copy_from_slice(&data[..64]); + let _ = Signature::from_bytes(&sig); + } + let blob = if data.len() > 4096 { + &data[..4096] + } else { + data + }; + let _ = IssuerPublicKey::from_bytes(blob); +} + +static BBS_SETUP: OnceLock<(IssuerPublicKey, [u8; 32])> = OnceLock::new(); + +fn bbs_setup() -> &'static (IssuerPublicKey, [u8; 32]) { + BBS_SETUP.get_or_init(|| { + let issuer = IssuerKeyPair::generate().expect("issuer generation"); + let rid = role_id("fuzz_target"); + (issuer.public_key(), rid) + }) +} + +/// Fuzz BBS+ proof parsing and verification (pairing-heavy; keep corpora small). +pub fn bbs_verify_presentation(data: &[u8]) { + let data = if data.len() > MAX_BBS_INPUT { + &data[..MAX_BBS_INPUT] + } else { + data + }; + let (pk, rid) = bbs_setup(); + let pres = Presentation::from_bytes(data.to_vec()); + let nonce = [0xabu8; 32]; + let _ = verify_presentation(pk, &pres, rid, 1, &nonce); +} + +/// Multiplexed entry for custom fuzz drivers: `data[0] % 6` selects the harness, rest is payload. +pub fn dispatch_all(data: &[u8]) { + if data.is_empty() { + return; + } + let body = &data[1..]; + match data[0] % 6 { + 0 => handshake_respond(body), + 1 => handshake_initiator_complete(body), + 2 => session_decrypt(body), + 3 => replay_sequence(body), + 4 => crypto_deserialize(body), + 5 => bbs_verify_presentation(body), + _ => {} + } +} diff --git a/scripts/fuzz-libfuzzer.sh b/scripts/fuzz-libfuzzer.sh new file mode 100755 index 0000000..586cc16 --- /dev/null +++ b/scripts/fuzz-libfuzzer.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Run libFuzzer targets via cargo-fuzz (LLVM coverage + mutation). +# Usage: +# ./scripts/fuzz-libfuzzer.sh # all targets, 60s each, sanitizer none (stable-friendly) +# FUZZ_TIME=300 ./scripts/fuzz-libfuzzer.sh session_decrypt +# SANITIZER=address ./scripts/fuzz-libfuzzer.sh # needs nightly rustc (rustup) + +set -euo pipefail + +export PATH="${HOME}/.cargo/bin:${PATH}" + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +if ! command -v cargo-fuzz >/dev/null 2>&1; then + echo "Install cargo-fuzz: cargo install cargo-fuzz" >&2 + exit 1 +fi + +FUZZ_TIME="${FUZZ_TIME:-60}" +SANITIZER="${SANITIZER:-none}" + +TARGETS=( + handshake_respond + handshake_initiator_complete + session_decrypt + replay_sequence + crypto_deserialize + bbs_verify_presentation +) + +run_one() { + local name="$1" + echo "=== cargo-fuzz: $name (max_total_time=${FUZZ_TIME}s, sanitizer=${SANITIZER}) ===" + cargo fuzz run -s "$SANITIZER" "$name" -- -max_total_time="$FUZZ_TIME" -print_final_stats=1 +} + +if [[ $# -gt 0 ]]; then + for name in "$@"; do + run_one "$name" + done +else + for name in "${TARGETS[@]}"; do + run_one "$name" + done +fi diff --git a/src/credential/bbs.rs b/src/credential/bbs.rs index 23d1402..ae96cfe 100644 --- a/src/credential/bbs.rs +++ b/src/credential/bbs.rs @@ -12,6 +12,9 @@ use crate::{Error, Result}; const ROLE_ID_DOMAIN: &[u8] = b"zkac-role-id-v1"; const BBS_HEADER: &[u8] = b"zkac-bbs-credential-v1"; +/// zkryptium deserializers index fixed offsets; reject shorter inputs to avoid panics. +const MIN_ISSUER_PUBKEY_BYTES: usize = 96; +const MIN_POK_PROOF_BYTES: usize = 240; type CS = BbsBls12381Shake256; @@ -119,6 +122,11 @@ impl IssuerPublicKey { } pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() < MIN_ISSUER_PUBKEY_BYTES { + return Err(Error::CredentialError( + "issuer public key: invalid length".into(), + )); + } let inner = BBSplusPublicKey::from_bytes(bytes) .map_err(|e| Error::CredentialError(e.to_string()))?; Ok(Self { inner }) @@ -234,6 +242,10 @@ pub fn verify_presentation( epoch: u64, nonce: &[u8], ) -> Result<()> { + if presentation.proof_bytes.len() < MIN_POK_PROOF_BYTES { + return Err(Error::InvalidPresentation); + } + let proof = PoKSignature::::from_bytes(&presentation.proof_bytes) .map_err(|e| Error::CredentialError(format!("invalid proof: {e}")))?; @@ -386,6 +398,21 @@ mod tests { assert_eq!(issuer2.public_key().to_bytes(), pk_bytes); } + #[test] + fn issuer_pk_rejects_too_short() { + assert!(IssuerPublicKey::from_bytes(&[]).is_err()); + assert!(IssuerPublicKey::from_bytes(&[0u8; 95]).is_err()); + } + + #[test] + fn verify_presentation_rejects_too_short_proof() { + let issuer = IssuerKeyPair::generate().unwrap(); + let pk = issuer.public_key(); + let rid = role_id("short-proof"); + assert!(verify_presentation(&pk, &Presentation::from_bytes(vec![]), &rid, 1, b"n").is_err()); + assert!(verify_presentation(&pk, &Presentation::from_bytes(vec![0u8; 100]), &rid, 1, b"n").is_err()); + } + #[test] fn multiple_members_same_role() { let issuer = IssuerKeyPair::generate().unwrap(); diff --git a/src/credential/mod.rs b/src/credential/mod.rs index 02e2c3d..28dd89d 100644 --- a/src/credential/mod.rs +++ b/src/credential/mod.rs @@ -7,4 +7,4 @@ pub use bbs::{ prepare_blind_request, role_id, verify_presentation, }; pub use roles::RoleRegistry; -pub use schnorr::{Keypair, PublicKey}; +pub use schnorr::{Keypair, PublicKey, Signature, SIGNATURE_LEN}; diff --git a/src/credential/schnorr.rs b/src/credential/schnorr.rs index 44967ef..f827d4f 100644 --- a/src/credential/schnorr.rs +++ b/src/credential/schnorr.rs @@ -1,13 +1,19 @@ +use blake2::Blake2b512; use curve25519_dalek::{ constants::RISTRETTO_BASEPOINT_TABLE, ristretto::{CompressedRistretto, RistrettoPoint}, scalar::Scalar, }; +use digest::Digest; use rand::{CryptoRng, RngCore}; use zeroize::{Zeroize, ZeroizeOnDrop}; use crate::{Error, Result}; +const SCHNORR_DOMAIN: &[u8] = b"zkac-schnorr-v1"; + +pub const SIGNATURE_LEN: usize = 64; + #[derive(Clone, Zeroize, ZeroizeOnDrop)] pub struct SecretKey { scalar: Scalar, @@ -19,6 +25,11 @@ pub struct PublicKey { pub(crate) compressed: CompressedRistretto, } +pub struct Signature { + r: CompressedRistretto, + s: Scalar, +} + #[derive(Zeroize, ZeroizeOnDrop)] pub struct Keypair { secret: SecretKey, @@ -26,6 +37,16 @@ pub struct Keypair { public: PublicKey, } +/// Hash (R || pk || msg) into a Scalar challenge. +fn challenge(r: &CompressedRistretto, pk: &CompressedRistretto, msg: &[u8]) -> Scalar { + let mut h = Blake2b512::new(); + h.update(SCHNORR_DOMAIN); + h.update(r.as_bytes()); + h.update(pk.as_bytes()); + h.update(msg); + Scalar::from_hash(h) +} + impl Keypair { pub fn generate(rng: &mut R) -> Self { let scalar = Scalar::random(rng); @@ -42,6 +63,23 @@ impl Keypair { pub fn public(&self) -> &PublicKey { &self.public } + + pub fn sign(&self, msg: &[u8]) -> Signature { + // Deterministic nonce: H("zkac-schnorr-nonce" || sk || msg) → scalar k. + // Avoids dependency on RNG quality at signing time. + let mut nh = Blake2b512::new(); + nh.update(b"zkac-schnorr-nonce"); + nh.update(self.secret.scalar.as_bytes()); + nh.update(msg); + let k = Scalar::from_hash(nh); + + let r_point = &k * RISTRETTO_BASEPOINT_TABLE; + let r_compressed = r_point.compress(); + let e = challenge(&r_compressed, &self.public.compressed, msg); + let s = k + e * self.secret.scalar; + + Signature { r: r_compressed, s } + } } impl PublicKey { @@ -61,6 +99,39 @@ impl PublicKey { pub fn as_compressed(&self) -> &CompressedRistretto { &self.compressed } + + pub fn verify(&self, msg: &[u8], sig: &Signature) -> Result<()> { + let r_point = sig.r + .decompress() + .ok_or(Error::DeserializationError("invalid signature R point"))?; + let e = challenge(&sig.r, &self.compressed, msg); + // Check: s * G == R + e * pk + let lhs = &sig.s * RISTRETTO_BASEPOINT_TABLE; + let rhs = r_point + e * self.point; + if lhs == rhs { + Ok(()) + } else { + Err(Error::IdentityVerificationFailed("schnorr signature invalid")) + } + } +} + +impl Signature { + pub fn to_bytes(&self) -> [u8; SIGNATURE_LEN] { + let mut buf = [0u8; SIGNATURE_LEN]; + buf[..32].copy_from_slice(self.r.as_bytes()); + buf[32..].copy_from_slice(self.s.as_bytes()); + buf + } + + pub fn from_bytes(bytes: &[u8; SIGNATURE_LEN]) -> Result { + let r = CompressedRistretto::from_slice(&bytes[..32]) + .map_err(|_| Error::DeserializationError("invalid signature R"))?; + let s_bytes: [u8; 32] = bytes[32..].try_into().unwrap(); + let s = Option::from(Scalar::from_canonical_bytes(s_bytes)) + .ok_or(Error::DeserializationError("invalid signature scalar"))?; + Ok(Signature { r, s }) + } } #[cfg(test)] @@ -75,4 +146,44 @@ mod tests { let pk2 = PublicKey::from_bytes(bytes).unwrap(); assert_eq!(*kp.public(), pk2); } + + #[test] + fn sign_verify_roundtrip() { + let kp = Keypair::generate(&mut OsRng); + let msg = b"hello world"; + let sig = kp.sign(msg); + kp.public().verify(msg, &sig).unwrap(); + } + + #[test] + fn signature_serialization() { + let kp = Keypair::generate(&mut OsRng); + let sig = kp.sign(b"test"); + let bytes = sig.to_bytes(); + let sig2 = Signature::from_bytes(&bytes).unwrap(); + kp.public().verify(b"test", &sig2).unwrap(); + } + + #[test] + fn wrong_message_rejected() { + let kp = Keypair::generate(&mut OsRng); + let sig = kp.sign(b"correct"); + assert!(kp.public().verify(b"wrong", &sig).is_err()); + } + + #[test] + fn wrong_key_rejected() { + let kp1 = Keypair::generate(&mut OsRng); + let kp2 = Keypair::generate(&mut OsRng); + let sig = kp1.sign(b"msg"); + assert!(kp2.public().verify(b"msg", &sig).is_err()); + } + + #[test] + fn deterministic_signature() { + let kp = Keypair::generate(&mut OsRng); + let s1 = kp.sign(b"same msg"); + let s2 = kp.sign(b"same msg"); + assert_eq!(s1.to_bytes(), s2.to_bytes()); + } } diff --git a/src/error.rs b/src/error.rs index d7d30d7..9878995 100644 --- a/src/error.rs +++ b/src/error.rs @@ -28,6 +28,9 @@ pub enum Error { #[error("role not registered")] RoleNotRegistered, + + #[error("identity verification failed: {0}")] + IdentityVerificationFailed(&'static str), } pub type Result = std::result::Result; diff --git a/src/node.rs b/src/node.rs index d30e7f4..debb612 100644 --- a/src/node.rs +++ b/src/node.rs @@ -1,8 +1,9 @@ -//! High-level client/server node: X25519 transport + BBS+ credential authentication. +//! High-level client/server node: X25519 transport + BBS+ credential authentication +//! with server identity binding (Schnorr signature over session transcript). use rand::{CryptoRng, RngCore}; -use crate::credential::{Keypair, PublicKey, RoleRegistry}; +use crate::credential::{Keypair, PublicKey, Signature, SIGNATURE_LEN, RoleRegistry}; use crate::credential::bbs::{Credential, Presentation}; use crate::transport::handshake::{self, InitiatorHandshake, HANDSHAKE_MSG_LEN}; use crate::transport::Session; @@ -11,8 +12,11 @@ use crate::{Error, Result}; /// Maximum BBS+ proof size accepted in an auth packet (DoS bound). pub const MAX_BBS_AUTH_PROOF_BYTES: usize = 256 * 1024; +/// Identity proof payload: `[pubkey: 32] [signature: 64]`. +const IDENTITY_PROOF_LEN: usize = 32 + SIGNATURE_LEN; + /// A node that can act as either client (initiator) or server (responder). -/// Holds the node's long-term identity keypair used for the transport handshake. +/// Holds the node's long-term identity keypair. pub struct Node { keypair: Keypair, } @@ -43,20 +47,43 @@ impl Node { (PendingConnect { handshake: hs }, msg) } - /// Complete the connection using a BBS+ credential. - /// Produces an encrypted session and an auth packet containing a - /// ZK presentation bound to the session transcript. + /// Complete the connection: verify the server's identity proof, then + /// produce a BBS+ auth packet bound to the session transcript. + /// + /// `identity_proof` is the encrypted packet from `prove_identity`. + /// `expected_server_pk` is the pinned server public key the client trusts. /// /// Auth payload layout: `[role_id: 32] [epoch: 8 LE] [proof_len: u32 LE] [proof]` - /// (`epoch` is redundant with the credential; kept for forward compatibility.) pub fn complete_connect( &self, pending: PendingConnect, response_msg: &[u8; HANDSHAKE_MSG_LEN], + identity_proof: &[u8], + expected_server_pk: &PublicKey, credential: &Credential, ) -> Result<(Session, Vec)> { let mut session = pending.handshake.complete(response_msg)?; + // --- Verify server identity --- + let id_payload = session.decrypt(identity_proof)?; + if id_payload.len() != IDENTITY_PROOF_LEN { + return Err(Error::IdentityVerificationFailed("identity proof wrong length")); + } + + let mut pk_bytes = [0u8; 32]; + pk_bytes.copy_from_slice(&id_payload[..32]); + let server_pk = PublicKey::from_bytes(pk_bytes)?; + + if server_pk != *expected_server_pk { + return Err(Error::IdentityVerificationFailed("server public key mismatch")); + } + + let mut sig_bytes = [0u8; SIGNATURE_LEN]; + sig_bytes.copy_from_slice(&id_payload[32..]); + let sig = Signature::from_bytes(&sig_bytes)?; + server_pk.verify(session.transcript_hash(), &sig)?; + + // --- BBS+ credential auth --- let transcript = session.transcript_hash(); let presentation = credential.present(transcript)?; let proof_bytes = presentation.to_bytes(); @@ -88,6 +115,19 @@ impl Node { Ok((resp.session, resp.response_msg)) } + /// Produce an encrypted identity proof: the server signs the session + /// transcript with its long-term key so the client can verify it is + /// talking to the expected server. + /// + /// Payload (encrypted): `[pubkey: 32] [schnorr_signature: 64]` + pub fn prove_identity(&self, session: &mut Session) -> Result> { + let sig = self.keypair.sign(session.transcript_hash()); + let mut payload = Vec::with_capacity(IDENTITY_PROOF_LEN); + payload.extend_from_slice(&self.keypair.public().to_bytes()); + payload.extend_from_slice(&sig.to_bytes()); + session.encrypt(&payload) + } + /// Verify the initiator's encrypted BBS+ auth packet. /// Returns the authenticated opaque `role_id` (32 bytes). pub fn verify_auth( @@ -128,8 +168,7 @@ mod tests { use crate::credential::bbs::{self, IssuerKeyPair}; use rand::rngs::OsRng; - #[test] - fn full_handshake_with_bbs_auth() { + fn test_credential() -> (Credential, RoleRegistry, [u8; 32]) { let issuer = IssuerKeyPair::generate().unwrap(); let pk = issuer.public_key(); let rid = bbs::role_id("admin"); @@ -143,15 +182,25 @@ mod tests { &sig, req.member_secret, req.prover_blind, rid, 1, &pk, ).unwrap(); + (cred, registry, rid) + } + + #[test] + fn full_handshake_with_identity_and_bbs_auth() { + let (cred, registry, rid) = test_credential(); + let client_kp = Keypair::generate(&mut OsRng); let server_kp = Keypair::generate(&mut OsRng); + let server_pk = *server_kp.public(); let client = Node::new(client_kp); let server = Node::new(server_kp); let (pending, init_msg) = client.connect(OsRng); let (mut server_session, response_msg) = server.accept(OsRng, &init_msg).unwrap(); + let identity_proof = server.prove_identity(&mut server_session).unwrap(); + let (mut client_session, auth_packet) = client - .complete_connect(pending, &response_msg, &cred) + .complete_connect(pending, &response_msg, &identity_proof, &server_pk, &cred) .unwrap(); let role_id = server @@ -166,6 +215,70 @@ mod tests { assert_eq!(client_session.decrypt(&pkt).unwrap(), b"response"); } + #[test] + fn wrong_server_key_rejected() { + let (cred, _, _) = test_credential(); + + let client_kp = Keypair::generate(&mut OsRng); + let server_kp = Keypair::generate(&mut OsRng); + let wrong_pk = Keypair::generate(&mut OsRng); + let client = Node::new(client_kp); + let server = Node::new(server_kp); + + let (pending, init_msg) = client.connect(OsRng); + let (mut server_session, response_msg) = server.accept(OsRng, &init_msg).unwrap(); + let identity_proof = server.prove_identity(&mut server_session).unwrap(); + + // Client expects a different key than what the server actually has + let result = client.complete_connect( + pending, &response_msg, &identity_proof, wrong_pk.public(), &cred, + ); + assert!(result.is_err()); + } + + #[test] + fn mitm_separate_sessions_detected() { + let (cred, _registry, _) = test_credential(); + + let client_kp = Keypair::generate(&mut OsRng); + let server_kp = Keypair::generate(&mut OsRng); + let mitm_kp = Keypair::generate(&mut OsRng); + let server_pk = *server_kp.public(); + let client = Node::new(client_kp); + let server = Node::new(server_kp); + let mitm = Node::new(mitm_kp); + + // Client initiates to MITM + let (pending, init_msg) = client.connect(OsRng); + + // MITM accepts from client, initiates to server + let (mut mitm_client_session, mitm_response) = mitm.accept(OsRng, &init_msg).unwrap(); + let mitm_identity = mitm.prove_identity(&mut mitm_client_session).unwrap(); + + // Client tries to verify — MITM signed with wrong key + let result = client.complete_connect( + pending, &mitm_response, &mitm_identity, &server_pk, &cred, + ); + assert!(result.is_err(), "MITM must be detected: wrong server key"); + + // Even if MITM relays the real server's response_msg, it cannot + // forge the server's signature over the MITM's transcript. + let (_mitm_pending, mitm_init) = mitm.connect(OsRng); + let (mut real_server_session, _real_response) = server.accept(OsRng, &mitm_init).unwrap(); + let real_identity = server.prove_identity(&mut real_server_session).unwrap(); + + // The real server's identity proof is bound to the MITM<->server + // transcript, not the client<->MITM transcript. The client cannot + // use it because the sessions are different DH exchanges. + let (pending2, init_msg2) = client.connect(OsRng); + let (_mitm_session2, mitm_resp2) = mitm.accept(OsRng, &init_msg2).unwrap(); + // Relay real_identity (from server) to client — wrong session + let result = client.complete_connect( + pending2, &mitm_resp2, &real_identity, &server_pk, &cred, + ); + assert!(result.is_err(), "relayed identity proof must fail: different transcript"); + } + #[test] fn bbs_wrong_role_rejected() { let issuer = IssuerKeyPair::generate().unwrap(); @@ -184,13 +297,16 @@ mod tests { &sig, req.member_secret, req.prover_blind, rid, 1, &pk, ).unwrap(); + let server_kp = Keypair::generate(&mut OsRng); + let server_pk = *server_kp.public(); let client = Node::new(Keypair::generate(&mut OsRng)); - let server = Node::new(Keypair::generate(&mut OsRng)); + let server = Node::new(server_kp); let (pending, init_msg) = client.connect(OsRng); let (mut server_session, response_msg) = server.accept(OsRng, &init_msg).unwrap(); + let identity_proof = server.prove_identity(&mut server_session).unwrap(); let (_, auth_packet) = client - .complete_connect(pending, &response_msg, &cred) + .complete_connect(pending, &response_msg, &identity_proof, &server_pk, &cred) .unwrap(); let verified_rid = server diff --git a/src/python.rs b/src/python.rs index fcaeb95..cdbbf05 100644 --- a/src/python.rs +++ b/src/python.rs @@ -3,7 +3,7 @@ use pyo3::prelude::*; use pyo3::types::PyBytes; use rand::rngs::OsRng; -use crate::credential::{self, bbs}; +use crate::credential::{self, bbs, SIGNATURE_LEN}; use crate::transport::handshake::HANDSHAKE_MSG_LEN; fn to_py_err(e: crate::Error) -> PyErr { @@ -38,6 +38,14 @@ impl PyKeypair { inner: *kp.public(), }) } + + fn sign<'py>(&self, py: Python<'py>, msg: &[u8]) -> PyResult> { + let kp = self.inner.as_ref().ok_or_else(|| { + PyValueError::new_err("keypair was consumed by Node") + })?; + let sig = kp.sign(msg); + Ok(PyBytes::new(py, &sig.to_bytes())) + } } // ── Ristretto PublicKey ────────────────────────────────────────────── @@ -74,6 +82,18 @@ impl PyPublicKey { u64::from_le_bytes(b[..8].try_into().unwrap()) } + fn verify(&self, msg: &[u8], signature: &[u8]) -> PyResult { + if signature.len() != SIGNATURE_LEN { + return Err(PyValueError::new_err("signature must be 64 bytes")); + } + let sig_arr: [u8; SIGNATURE_LEN] = signature.try_into().unwrap(); + let sig = credential::Signature::from_bytes(&sig_arr).map_err(to_py_err)?; + match self.inner.verify(msg, &sig) { + Ok(()) => Ok(true), + Err(_) => Ok(false), + } + } + fn __repr__(&self) -> String { let hex = bytes_to_hex(&self.inner.to_bytes()); format!("PublicKey({hex})") @@ -369,11 +389,13 @@ impl PyNode { ) } - /// Complete handshake with BBS+ credential authentication. + /// Complete handshake: verify server identity, then produce BBS+ auth. fn complete_connect( &self, pending: &mut PyPendingConnect, response_msg: &[u8], + identity_proof: &[u8], + expected_server_pk: &PyPublicKey, credential: &PyCredential, ) -> PyResult<(PySession, Vec)> { let p = pending @@ -388,12 +410,28 @@ impl PyNode { let (session, auth_packet) = self .inner - .complete_connect(p, &msg, &credential.inner) + .complete_connect( + p, + &msg, + identity_proof, + &expected_server_pk.inner, + &credential.inner, + ) .map_err(to_py_err)?; Ok((PySession { inner: session }, auth_packet)) } + /// Produce encrypted identity proof (server signs transcript with long-term key). + fn prove_identity<'py>( + &self, + py: Python<'py>, + session: &mut PySession, + ) -> PyResult> { + let proof = self.inner.prove_identity(&mut session.inner).map_err(to_py_err)?; + Ok(PyBytes::new(py, &proof)) + } + fn accept(&self, init_msg: &[u8]) -> PyResult<(PySession, Vec)> { if init_msg.len() != HANDSHAKE_MSG_LEN { return Err(PyValueError::new_err("init_msg must be 32 bytes")); diff --git a/src/transport/session.rs b/src/transport/session.rs index c4e77bb..2c817b7 100644 --- a/src/transport/session.rs +++ b/src/transport/session.rs @@ -41,6 +41,16 @@ impl Session { } } + /// Fuzz builds only (`fuzz-expose` feature). + #[cfg(feature = "fuzz-expose")] + pub fn new_fuzz( + send_key: [u8; 32], + recv_key: [u8; 32], + transcript_hash: [u8; 32], + ) -> Self { + Self::new(send_key, recv_key, transcript_hash) + } + /// Transcript hash binding this session to a specific handshake. /// Sign this value for authentication proofs. pub fn transcript_hash(&self) -> &[u8; 32] { diff --git a/tests/test_zkac.py b/tests/test_zkac.py index 150b9d4..a486bf1 100644 --- a/tests/test_zkac.py +++ b/tests/test_zkac.py @@ -168,6 +168,31 @@ class TestBbsCredentials: assert cred.epoch() == 42 +class TestSchnorrSignature: + def test_sign_verify(self): + kp = zkac.Keypair() + pk = kp.public_key() + sig = kp.sign(b"hello") + assert len(sig) == 64 + assert pk.verify(b"hello", sig) + + def test_wrong_message(self): + kp = zkac.Keypair() + pk = kp.public_key() + sig = kp.sign(b"correct") + assert not pk.verify(b"wrong", sig) + + def test_wrong_key(self): + kp1 = zkac.Keypair() + kp2 = zkac.Keypair() + sig = kp1.sign(b"msg") + assert not kp2.public_key().verify(b"msg", sig) + + def test_deterministic(self): + kp = zkac.Keypair() + assert kp.sign(b"same") == kp.sign(b"same") + + class TestNodeHandshake: def _make_credential(self): issuer = zkac.BbsIssuer() @@ -187,12 +212,15 @@ class TestNodeHandshake: reg.register_role(rid, pk, 1) client = zkac.Node(zkac.Keypair()) - server = zkac.Node(zkac.Keypair()) + server_kp = zkac.Keypair() + server_pk = server_kp.public_key() + server = zkac.Node(server_kp) pending, init_msg = client.connect() server_session, response_msg = server.accept(init_msg) + identity_proof = server.prove_identity(server_session) client_session, auth_packet = client.complete_connect( - pending, response_msg, cred + pending, response_msg, identity_proof, server_pk, cred ) verified_rid = server.verify_auth(server_session, auth_packet, reg) @@ -204,18 +232,37 @@ class TestNodeHandshake: pkt = server_session.encrypt(b"response") assert client_session.decrypt(pkt) == b"response" + def test_wrong_server_key_rejected(self): + _, pk, rid, cred = self._make_credential() + + client = zkac.Node(zkac.Keypair()) + server = zkac.Node(zkac.Keypair()) + wrong_pk = zkac.Keypair().public_key() + + pending, init_msg = client.connect() + server_session, response_msg = server.accept(init_msg) + identity_proof = server.prove_identity(server_session) + + with pytest.raises(ValueError, match="identity verification failed"): + client.complete_connect( + pending, response_msg, identity_proof, wrong_pk, cred + ) + def test_replay_rejected(self): _, pk, rid, cred = self._make_credential() reg = zkac.RoleRegistry() reg.register_role(rid, pk, 1) client = zkac.Node(zkac.Keypair()) - server = zkac.Node(zkac.Keypair()) + server_kp = zkac.Keypair() + server_pk = server_kp.public_key() + server = zkac.Node(server_kp) pending, init_msg = client.connect() server_session, response_msg = server.accept(init_msg) + identity_proof = server.prove_identity(server_session) client_session, auth_packet = client.complete_connect( - pending, response_msg, cred + pending, response_msg, identity_proof, server_pk, cred ) server.verify_auth(server_session, auth_packet, reg) @@ -230,12 +277,15 @@ class TestNodeHandshake: reg.register_role(rid, pk, 1) client = zkac.Node(zkac.Keypair()) - server = zkac.Node(zkac.Keypair()) + server_kp = zkac.Keypair() + server_pk = server_kp.public_key() + server = zkac.Node(server_kp) pending, init_msg = client.connect() server_session, response_msg = server.accept(init_msg) + identity_proof = server.prove_identity(server_session) client_session, auth_packet = client.complete_connect( - pending, response_msg, cred + pending, response_msg, identity_proof, server_pk, cred ) server.verify_auth(server_session, auth_packet, reg) @@ -248,11 +298,18 @@ class TestNodeHandshake: _, pk, rid, cred = self._make_credential() client = zkac.Node(zkac.Keypair()) - server = zkac.Node(zkac.Keypair()) + server_kp = zkac.Keypair() + server_pk = server_kp.public_key() + server = zkac.Node(server_kp) pending, init_msg = client.connect() - _, response_msg = server.accept(init_msg) - client.complete_connect(pending, response_msg, cred) + server_session, response_msg = server.accept(init_msg) + identity_proof = server.prove_identity(server_session) + client.complete_connect( + pending, response_msg, identity_proof, server_pk, cred + ) with pytest.raises(ValueError, match="consumed"): - client.complete_connect(pending, response_msg, cred) + client.complete_connect( + pending, response_msg, identity_proof, server_pk, cred + )