From 6bcbbf0eb3b44b3b9bb6ca7605ac05c5bd4b3553 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 24 Oct 2026 11:10:43 +0300 Subject: [PATCH 1/7] fix(crypto)!: replace rustls-rustcrypto with coordinode-crypto - Add the coordinode-crypto workspace package: a pure-Rust rustls provider on current RustCrypto primitives, adapted from rustls-rustcrypto. Local identities are P-256, P-384 or Ed25519; RSA stays verifiable with 2048..=8192-bit public keys - Implement the rust_crypto JWT backend through jsonwebtoken's CryptoProvider interface: HMAC, ECDSA, EdDSA and RSA public verification; RSA signer and private-key factories are refused - Drop rustls-webpki 0.102, paste and the obsolete crypto-bigint from the default graph, with their advisory ignores (RUSTSEC-2026-0382 among them failed every advisory scan) - Stop ProxyServer::from_config from installing a jsonwebtoken provider: only building the built-in verifier does, so a proxy without auth leaves that choice to its host - Let the selected rustls provider decide the listener identities it presents, so a host-installed or aws-lc provider keeps RSA listeners whatever features the graph unified - State at every auth entry point that an application with its own authn/authz, such as structured-id, uses none of the proxy's auth - Test coordinode-crypto in CI (clippy, tests, MSRV 1.92, no-std) and publish-check both packages; no GitHub release for its tags - Adopt rustfmt style edition 2024 across the workspace BREAKING CHANGE: with the default rust_crypto backend the listener refuses RSA private keys; serve TLS with a P-256, P-384 or Ed25519 key, or install a provider that signs with RSA. Closes #154 --- .cargo/audit.toml | 19 +- .github/workflows/ci.yml | 44 ++- Cargo.toml | 33 +- README.md | 63 +++- benches/jwt_verify.rs | 6 +- benches/transcode_request.rs | 2 +- crates/coordinode-crypto/COPYRIGHT | 7 + crates/coordinode-crypto/Cargo.toml | 46 +++ crates/coordinode-crypto/LICENSE | 6 + crates/coordinode-crypto/LICENSE-APACHE | 201 ++++++++++++ crates/coordinode-crypto/LICENSE-MIT | 19 ++ crates/coordinode-crypto/NOTICES | 11 + crates/coordinode-crypto/README.md | 51 +++ crates/coordinode-crypto/src/aead.rs | 54 ++++ crates/coordinode-crypto/src/aead/chacha20.rs | 201 ++++++++++++ crates/coordinode-crypto/src/aead/gcm.rs | 266 ++++++++++++++++ crates/coordinode-crypto/src/hash.rs | 55 ++++ crates/coordinode-crypto/src/hmac.rs | 57 ++++ crates/coordinode-crypto/src/identity.rs | 104 ++++++ crates/coordinode-crypto/src/kx.rs | 119 +++++++ crates/coordinode-crypto/src/kx/tests.rs | 30 ++ crates/coordinode-crypto/src/lib.rs | 243 ++++++++++++++ crates/coordinode-crypto/src/public_key.rs | 39 +++ crates/coordinode-crypto/src/sign.rs | 98 ++++++ crates/coordinode-crypto/src/sign/ecdsa.rs | 74 +++++ crates/coordinode-crypto/src/sign/eddsa.rs | 53 +++ crates/coordinode-crypto/src/tests.rs | 178 +++++++++++ crates/coordinode-crypto/src/tests/tls.rs | 301 ++++++++++++++++++ crates/coordinode-crypto/src/verify.rs | 46 +++ crates/coordinode-crypto/src/verify/ecdsa.rs | 51 +++ crates/coordinode-crypto/src/verify/eddsa.rs | 32 ++ crates/coordinode-crypto/src/verify/rsa.rs | 97 ++++++ deny.toml | 44 +-- release-plz.toml | 6 + rustfmt.toml | 1 + src/auth/authz.rs | 10 +- src/auth/cache.rs | 2 +- src/auth/crypto.rs | 33 +- src/auth/crypto/rust_crypto.rs | 295 +++++++++++++++++ src/auth/crypto/rust_crypto/keys.rs | 55 ++++ src/auth/crypto/rust_crypto/tests.rs | 237 ++++++++++++++ src/auth/crypto/tests.rs | 67 +++- src/auth/forward.rs | 4 +- src/auth/forward/tests.rs | 2 +- src/auth/mod.rs | 9 +- src/auth/tests.rs | 2 +- src/auth/verifier.rs | 7 +- src/client_address.rs | 4 +- src/client_address/tests.rs | 14 +- src/config.rs | 4 +- src/config/tests.rs | 8 +- src/cors.rs | 2 +- src/cors/tests.rs | 2 +- src/embed.rs | 2 +- src/embed/tests.rs | 2 +- src/guard/gate.rs | 4 +- src/guard/grpc.rs | 6 +- src/hooks.rs | 13 + src/lib.rs | 50 ++- src/main.rs | 2 +- src/oidc/mod.rs | 4 +- src/openapi.rs | 4 +- src/openapi/tests.rs | 15 +- src/received.rs | 2 +- src/serve.rs | 2 +- src/serve/idle.rs | 4 +- src/serve/tests.rs | 37 ++- src/service.rs | 2 +- src/shield/matcher.rs | 24 +- src/shield/resolve/tests.rs | 2 +- src/shield/tests.rs | 4 +- src/tls.rs | 17 +- src/tls/tests.rs | 41 +-- src/transcode/codec.rs | 2 +- src/transcode/error.rs | 2 +- src/transcode/error/tests.rs | 4 +- src/transcode/mod.rs | 4 +- src/transcode/request.rs | 2 +- src/transcode/request/tests.rs | 4 +- src/transcode/response.rs | 2 +- src/transcode/rule/tests.rs | 2 +- src/transcode/table.rs | 4 +- src/transcode/tests.rs | 28 +- src/upstream.rs | 2 +- tests/common/mod.rs | 4 +- tests/custom_verbs.rs | 4 +- tests/dynamic_codec.rs | 4 +- tests/edge.rs | 12 +- tests/embedded.rs | 12 +- tests/error_details.rs | 4 +- tests/forwarded_headers.rs | 2 +- tests/hooks.rs | 100 +++--- tests/request_mapping.rs | 4 +- tests/shutdown.rs | 4 +- tests/streaming_request.rs | 4 +- tests/tls.rs | 6 +- tests/upstream_controls.rs | 6 +- 97 files changed, 3561 insertions(+), 312 deletions(-) create mode 100644 crates/coordinode-crypto/COPYRIGHT create mode 100644 crates/coordinode-crypto/Cargo.toml create mode 100644 crates/coordinode-crypto/LICENSE create mode 100644 crates/coordinode-crypto/LICENSE-APACHE create mode 100644 crates/coordinode-crypto/LICENSE-MIT create mode 100644 crates/coordinode-crypto/NOTICES create mode 100644 crates/coordinode-crypto/README.md create mode 100644 crates/coordinode-crypto/src/aead.rs create mode 100644 crates/coordinode-crypto/src/aead/chacha20.rs create mode 100644 crates/coordinode-crypto/src/aead/gcm.rs create mode 100644 crates/coordinode-crypto/src/hash.rs create mode 100644 crates/coordinode-crypto/src/hmac.rs create mode 100644 crates/coordinode-crypto/src/identity.rs create mode 100644 crates/coordinode-crypto/src/kx.rs create mode 100644 crates/coordinode-crypto/src/kx/tests.rs create mode 100644 crates/coordinode-crypto/src/lib.rs create mode 100644 crates/coordinode-crypto/src/public_key.rs create mode 100644 crates/coordinode-crypto/src/sign.rs create mode 100644 crates/coordinode-crypto/src/sign/ecdsa.rs create mode 100644 crates/coordinode-crypto/src/sign/eddsa.rs create mode 100644 crates/coordinode-crypto/src/tests.rs create mode 100644 crates/coordinode-crypto/src/tests/tls.rs create mode 100644 crates/coordinode-crypto/src/verify.rs create mode 100644 crates/coordinode-crypto/src/verify/ecdsa.rs create mode 100644 crates/coordinode-crypto/src/verify/eddsa.rs create mode 100644 crates/coordinode-crypto/src/verify/rsa.rs create mode 100644 rustfmt.toml create mode 100644 src/auth/crypto/rust_crypto.rs create mode 100644 src/auth/crypto/rust_crypto/keys.rs create mode 100644 src/auth/crypto/rust_crypto/tests.rs diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 9cb9818..6078335 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -3,24 +3,13 @@ # # Ignored deliberately; mirror of deny.toml, which carries the full rationale. # -# RUSTSEC-2023-0071: "Marvin Attack" timing sidechannel in `rsa` 0.9.x, via -# jsonwebtoken's rust_crypto backend and the RustCrypto rustls provider. +# RUSTSEC-2023-0071: "Marvin Attack" timing sidechannel in `rsa`, via the +# RustCrypto JWT backend and the coordinode-crypto rustls provider. # No fixed `rsa` release exists; RSA only verifies signatures with public keys -# (JWTs, TLS server certificates), and Marvin targets private-key timing. +# (JWTs, TLS peer certificates), RSA private-key operations are refused, and +# Marvin targets private-key timing. # See https://github.com/structured-world/structured-proxy/issues/48 -# -# RUSTSEC-2026-0049 / -0098 / -0099 / -0104: `rustls-webpki` 0.102 via -# rustls-rustcrypto 0.0.2-alpha, which only reads its `alg_id` constants; -# certificates are verified by rustls' own, patched rustls-webpki 0.103. -# RUSTSEC-2024-0436: `paste` (unmaintained build-time proc-macro) via the same -# crate. Remove all five with the next rustls-rustcrypto release. -# See https://github.com/structured-world/structured-proxy/issues/98 [advisories] ignore = [ "RUSTSEC-2023-0071", - "RUSTSEC-2026-0049", - "RUSTSEC-2026-0098", - "RUSTSEC-2026-0099", - "RUSTSEC-2026-0104", - "RUSTSEC-2024-0436", ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6259f04..ececf70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,7 @@ jobs: if: matrix.backend.name == 'injected_verifier' run: | tree=$(cargo tree -e normal --target all --prefix none --format '{p}' ${{ matrix.backend.flags }}) - if printf '%s\n' "$tree" | grep -E '^(rsa|rustls-rustcrypto|paste) v|^rustls-webpki v0\.102\.'; then + if printf '%s\n' "$tree" | grep -E '^rsa v'; then echo "::error::the build without a crypto backend links a crate with an ignored advisory" exit 1 fi @@ -130,11 +130,12 @@ jobs: - name: Doc tests run: cargo test --doc ${{ matrix.backend.flags }} - # With `cli`, so the packaged crate's binary compiles too: without the - # feature cargo verifies the library alone. + # Both packages, so structured-proxy is verified against the + # coordinode-crypto it would be published with; with `cli`, so the + # packaged binary compiles too. - name: Publish dry-run if: matrix.backend.name == 'rust_crypto' - run: cargo publish --dry-run --features cli + run: cargo publish --dry-run --workspace --features structured-proxy/cli # The release packages ship static musl binaries; build and test that target # on every change, so a dependency that breaks it fails here, not at release. @@ -193,6 +194,41 @@ jobs: - name: Check (injected verifier) run: cargo check --all-targets --no-default-features --features cli + # The rustls provider package: not a default member, so the jobs above do + # not build its own targets. It has its own MSRV and an allocation-only build. + coordinode-crypto: + name: coordinode-crypto + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + targets: thumbv7em-none-eabihf + - uses: dtolnay/rust-toolchain@1.92 + # Third-party action: pinned to a commit SHA (dependabot keeps it fresh). + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + # Third-party action: pinned to a commit SHA (dependabot keeps it fresh). + - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 + with: + tool: cargo-nextest + - name: Clippy + run: cargo +stable clippy -p coordinode-crypto --all-targets --all-features + - name: Test + run: cargo +stable nextest run -p coordinode-crypto --all-features + - name: Doc tests + run: cargo +stable test --doc -p coordinode-crypto --all-features + - name: MSRV (1.92) + run: cargo +1.92 check -p coordinode-crypto --all-targets + # A target with no std: a host target could pull std in through a + # transitively unified feature without failing. + - name: No-std check + env: + RUSTFLAGS: '-Dwarnings --cfg getrandom_backend="custom"' + run: cargo +stable check -p coordinode-crypto --no-default-features --features alloc --target thumbv7em-none-eabihf + security-audit: name: Security Audit runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index 6cce53a..bdcffd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,8 @@ +[workspace] +resolver = "2" +members = [".", "crates/coordinode-crypto"] +default-members = ["."] + [package] name = "structured-proxy" description = "Universal gRPC→REST transcoding proxy — config-driven, works with any gRPC service" @@ -145,16 +150,22 @@ jsonwebtoken = { version = "11", default-features = false, features = [ # rustls provider the embedder installs is used instead. reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json"] } rustls = { version = "0.23", default-features = false, features = ["std", "tls12"] } -# The pure-Rust provider of the `rust_crypto` backend. `0.0.2-alpha` still -# names its `rustls-webpki` 0.102 dependency, which carries four CRL / -# name-constraint advisories; the provider only reads algorithm identifier -# constants from it, while certificates are verified by rustls' own (patched) -# webpki. See deny.toml. -rustls-rustcrypto = { version = "0.0.2-alpha", optional = true } +# The shared, pure-Rust rustls adapter and bounded RSA public-key decoder. +coordinode-crypto = { version = "0.1", path = "crates/coordinode-crypto", optional = true } +# jsonwebtoken's provider extension keeps JWT parsing and validation in the +# library while using the current RustCrypto primitive APIs. +hmac = { version = "0.13", default-features = false, features = ["zeroize"], optional = true } +p256 = { version = "0.14", default-features = false, features = ["ecdsa", "pkcs8"], optional = true } +p384 = { version = "0.14", default-features = false, features = ["ecdsa", "pkcs8"], optional = true } +ed25519-dalek = { version = "3", default-features = false, features = ["pkcs8", "alloc", "zeroize"], optional = true } +rsa = { version = "0.10.0-rc.19", default-features = false, features = ["sha2", "encoding"], optional = true } +# The JWT extension traits use signature 2; the current primitives use signature 3. +signature = { version = "2", default-features = false, optional = true } +signature-v3 = { package = "signature", version = "3", default-features = false, features = ["alloc"], optional = true } webpki-roots = "1" globset = "0.4" base64 = "0.23" -sha2 = "0.11" +sha2 = { version = "0.11", features = ["zeroize"] } url = "2" getrandom = "0.4" # Envoy ext_authz `Authorization/Check` messages (External AuthZ). @@ -185,7 +196,7 @@ builtin_jwt = ["dep:jsonwebtoken"] # `rust_crypto` (default): pure-Rust RustCrypto backend. Pulls in `rsa`, which # carries RUSTSEC-2023-0071 (Marvin Attack). Not exploitable on our verify-only # path (public key, no private-key ops); see deny.toml / issue #48. -rust_crypto = ["builtin_jwt", "jsonwebtoken/rust_crypto", "dep:rustls-rustcrypto"] +rust_crypto = ["builtin_jwt", "dep:coordinode-crypto", "dep:hmac", "dep:p256", "dep:p384", "dep:ed25519-dalek", "dep:rsa", "dep:signature", "dep:signature-v3"] # `aws_lc_rs`: constant-time / FIPS-capable backend via aws-lc (C FFI), # advisory-free. Opt-in for consumers that allow FFI; enable with # `default-features = false, features = ["aws_lc_rs"]`. @@ -218,7 +229,11 @@ http-body = "1" # (src/tls/tests.rs), and the provider it runs on in every build. Default # features would pull in aws-lc. tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } -rustls-rustcrypto = "0.0.2-alpha" +coordinode-crypto = { version = "0.1", path = "crates/coordinode-crypto" } +# The test HTTPS servers' provider: an independent TLS peer that can present +# RSA identities, which coordinode-crypto refuses. Excluded from product +# dependencies. +rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] } # benches/jwt_verify.rs. Plots and rayon are left out: numbers are enough. criterion = { version = "0.8", default-features = false, features = ["async_tokio", "cargo_bench_support"] } # The embedding-hooks integration test (tests/hooks.rs) writes hook impls using diff --git a/README.md b/README.md index 8ce19a8..841e386 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,10 @@ tonic services. capabilities you ask for ([Building the proxy in code](#building-the-proxy-in-code)) - Requests the proxy does not serve go to your own fallback service - Hooks for auth decisions, token verification, OIDC and extra routes, with no - HTTP framework in your code + HTTP framework in your code. The auth hooks are for applications that leave + authentication to the proxy and an external identity provider; one with its + own authn/authz (such as structured-id) uses none of them + ([Applications with their own authentication](#applications-with-their-own-authentication)) ## Non-goals @@ -309,7 +312,8 @@ shield: # path never blocks on it; a store outage degrades to per-instance limits. # sync: { redis_url: "redis://127.0.0.1/", interval_ms: 500 } -# JWT auth +# JWT auth. For an application that leaves authentication to the proxy; one +# with its own authn/authz (such as structured-id) has no `auth:` section. auth: mode: "jwt" # Which traffic needs a token. Default: [transcoded, endpoints]; the @@ -973,6 +977,17 @@ returns `application/jwk-set+json` (RFC 7517 §8.5). command-line dependencies come with the `cli` feature. The library runs on your tokio runtime and logs through `tracing` to the subscriber you set up. +### Applications with their own authentication + +The proxy's auth (JWT verification, route policies and roles, claim→header +forwarding, the forward-auth endpoint, ext_authz and the `with_auth`, +`with_token_verifier` and `with_auth_decider` hooks) is for an application +that leaves authentication and authorization to the proxy and an external +identity provider. An application that verifies tokens and authorizes requests +itself, such as structured-id and the services built on its authn/authz, +embeds the proxy with none of it: no `auth:` section and none of those hooks. +Its own checks are the only ones on the request path. + ```rust,no_run use std::path::Path; use structured_proxy::ProxyServer; @@ -1284,6 +1299,11 @@ Hooks plug your own stateless logic into the proxy. Their traits use `http`, `bytes` and `serde_json` types and `#[async_trait]`, so your crate implements them without depending on axum itself. +`with_auth_decider` and `with_token_verifier` are for an application that +leaves authentication and authorization to the proxy. An application with its +own authn/authz, such as structured-id, registers neither (see +[Applications with their own authentication](#applications-with-their-own-authentication)). + ```rust use std::sync::Arc; use structured_proxy::{config::ProxyConfig, ProxyServer}; @@ -1331,7 +1351,10 @@ The hooks are: ## JWT verification The bearer-token check sits behind the `TokenVerifier` hook. A build gets one of -two implementations. +two implementations. Both are for an application that leaves token +verification to the proxy; one that verifies its own tokens, such as +structured-id, uses neither (see +[Applications with their own authentication](#applications-with-their-own-authentication)). **The built-in verifier** is what a plain config-driven deployment uses: keys from `auth.jwt` (an Ed25519 PEM file or a JWKS endpoint), verified with @@ -1339,16 +1362,17 @@ from `auth.jwt` (an Ed25519 PEM file or a JWKS endpoint), verified with | Feature | Backend | Notes | |---------|---------|-------| -| `rust_crypto` (default) | RustCrypto | Pure Rust. Uses `rsa`, which carries [RUSTSEC-2023-0071](https://rustsec.org/advisories/RUSTSEC-2023-0071); the Marvin attack targets private-key timing, and this path only verifies with public keys (see `deny.toml`). | -| `aws_lc_rs` | aws-lc | Constant-time / FIPS-capable, advisory-free, links aws-lc through C FFI. | +| `rust_crypto` (default) | RustCrypto | Current primitives through jsonwebtoken's provider interface. RSA is public-key verification only; RSA signer and private-key extraction factories refuse requests (see `deny.toml`). | +| `aws_lc_rs` | aws-lc | Explicit optional backend using C FFI. | -Both can be linked at once, as with any pair of Cargo features. `jsonwebtoken` -cannot infer a provider then, so `ProxyServer::from_config` installs `aws_lc_rs` -for the process: it is constant-time and carries no advisory. A build that wants -RustCrypto regardless drops the `aws_lc_rs` feature. +Both can be linked at once, as with any pair of Cargo features. Building the +built-in verifier explicitly installs `aws_lc_rs` when both are enabled, unless +the process installed a provider first. A build that wants RustCrypto omits +`aws_lc_rs`. A proxy without `auth.mode: "jwt"`, or with an injected verifier, +installs no `jsonwebtoken` provider: that choice stays with your application. If another crate in your process uses `jsonwebtoken` too, it may reach it before -any proxy server is built, and would hit the same ambiguity. It can also turn on +the built-in verifier is built, and would hit the same ambiguity. It can also turn on the other backend for `jsonwebtoken` directly, which leaves this crate looking single-backend while `jsonwebtoken` sees two. Settle it once at the top of `main`: @@ -1453,7 +1477,7 @@ needed. The rustls crypto provider is, in order: `rustls::crypto::CryptoProvider::install_default`, if any: an explicit choice wins; 2. aws-lc, with the `aws_lc_rs` feature; -3. the pure-Rust RustCrypto provider (`rustls-rustcrypto`), with `rust_crypto`. +3. the maintained pure-Rust RustCrypto adapter (`coordinode-crypto`), with `rust_crypto`. The default build is pure Rust; aws-lc (C) comes with `aws_lc_rs`. A `default-features = false` build brings no provider, so a crate that only @@ -1462,12 +1486,17 @@ or configures a JWKS endpoint or the rate-limit service, installs a provider before building the proxy (the outbound client needs one even for an `http://` endpoint); otherwise startup fails with an error that says so. -The RustCrypto provider verifies RSA server signatures with `rsa`, under the same -RUSTSEC-2023-0071 note as the `rust_crypto` JWT backend: only public-key -verification runs. Its current release still names `rustls-webpki` 0.102, whose -CRL and name-constraint advisories are listed in `deny.toml` with why they do not -apply: the provider reads only algorithm identifiers from it, and rustls -verifies certificates with its own patched `rustls-webpki`. +The default RustCrypto profile accepts P-256, P-384 and Ed25519 local TLS +identities. RSA private keys are refused, including RSA inside PKCS#8, before +listener activation. RSA CA and peer signatures remain verifiable with +2048..=8192-bit public keys. For example, an ECDSA leaf signed by an RSA CA +works without giving the listener an RSA signing capability. + +Rustls owns certificate-path validation. Algorithm identifiers come from +`rustls-pki-types`, without an obsolete webpki dependency. The adapter rejects +all-zero X25519 shared secrets and propagates entropy failures. Its runtime +dependencies are pure Rust; independent backends are confined to development +interoperability tests. ## How it works diff --git a/benches/jwt_verify.rs b/benches/jwt_verify.rs index c5e63b1..09df72b 100644 --- a/benches/jwt_verify.rs +++ b/benches/jwt_verify.rs @@ -7,11 +7,11 @@ use std::sync::Arc; use axum::body::Body; use axum::http::{Request, StatusCode}; -use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use base64::Engine; -use criterion::{criterion_group, criterion_main, Criterion}; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use criterion::{Criterion, criterion_group, criterion_main}; use ed25519_dalek::{Signer, SigningKey}; -use structured_proxy::auth::{middleware, Auth}; +use structured_proxy::auth::{Auth, middleware}; use structured_proxy::config::ProxyConfig; use tower::ServiceExt; diff --git a/benches/transcode_request.rs b/benches/transcode_request.rs index 3a91da8..1212289 100644 --- a/benches/transcode_request.rs +++ b/benches/transcode_request.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; use std::hint::black_box; -use criterion::{criterion_group, criterion_main, Criterion}; +use criterion::{Criterion, criterion_group, criterion_main}; use prost_reflect::{DynamicMessage, MessageDescriptor}; use structured_proxy::transcode::request; diff --git a/crates/coordinode-crypto/COPYRIGHT b/crates/coordinode-crypto/COPYRIGHT new file mode 100644 index 0000000..8a4e5a1 --- /dev/null +++ b/crates/coordinode-crypto/COPYRIGHT @@ -0,0 +1,7 @@ +Upstream rustls-rustcrypto implementation: +Copyright (c) 2023 Steve Fan, ctz. + +Modifications: +Copyright (c) 2026 Dmitry Prudnikov. + +Licensed under MIT OR Apache-2.0; see LICENSE-MIT and LICENSE-APACHE. diff --git a/crates/coordinode-crypto/Cargo.toml b/crates/coordinode-crypto/Cargo.toml new file mode 100644 index 0000000..653bd2a --- /dev/null +++ b/crates/coordinode-crypto/Cargo.toml @@ -0,0 +1,46 @@ +[package] +name = "coordinode-crypto" +version = "0.1.0" +edition = "2024" +rust-version = "1.92" +license = "MIT OR Apache-2.0" +description = "Pure-Rust RustCrypto provider for rustls" +repository = "https://github.com/structured-world/structured-proxy" +readme = "README.md" +publish = true + +[dependencies] +aead = { version = "0.6", default-features = false } +aes-gcm = { version = "0.11", default-features = false, features = ["aes", "alloc", "zeroize"] } +chacha20poly1305 = { version = "0.11", default-features = false, features = ["zeroize"] } +crypto-common = { version = "0.2", default-features = false } +crypto-bigint = { version = "0.7.6", default-features = false } +der = { version = "0.8", default-features = false } +digest = { version = "0.11", default-features = false } +ed25519-dalek = { version = "3", default-features = false, features = ["pkcs8", "zeroize"] } +getrandom = { version = "0.4", default-features = false, features = ["sys_rng"] } +hmac = { version = "0.13", default-features = false, features = ["zeroize"] } +p256 = { version = "0.14", default-features = false, features = ["alloc", "pkcs8", "ecdsa", "ecdh"] } +p384 = { version = "0.14", default-features = false, features = ["alloc", "pkcs8", "ecdsa", "ecdh"] } +pkcs8 = { version = "0.11", default-features = false } +pki-types = { package = "rustls-pki-types", version = "1", default-features = false } +rsa = { version = "0.10.0-rc.19", default-features = false, features = ["sha2", "encoding"] } +rustls = { version = "0.23", default-features = false } +sec1 = { version = "0.8", default-features = false } +sha2 = { version = "0.11", default-features = false, features = ["zeroize"] } +signature = { version = "3", default-features = false } +x25519-dalek = { version = "3", default-features = false, features = ["static_secrets", "zeroize"] } +zeroize = { version = "1", default-features = false } + +[features] +default = ["std", "tls12"] +std = ["alloc", "pki-types/std", "rustls/std"] +alloc = ["pki-types/alloc", "aead/alloc", "ed25519-dalek/alloc"] +tls12 = ["rustls/tls12"] +logging = ["rustls/logging"] + +[dev-dependencies] +# Independent certificate generation is a test oracle, not a runtime dependency. +rcgen = "0.14" +# Independent TLS interoperability oracle, excluded from product dependencies. +rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] } diff --git a/crates/coordinode-crypto/LICENSE b/crates/coordinode-crypto/LICENSE new file mode 100644 index 0000000..ae6393d --- /dev/null +++ b/crates/coordinode-crypto/LICENSE @@ -0,0 +1,6 @@ +This package is available under either of these licenses, at your option: + +* MIT, reproduced in LICENSE-MIT. +* Apache License, Version 2.0, reproduced in LICENSE-APACHE. + +See NOTICES for the upstream source and retained copyright information. diff --git a/crates/coordinode-crypto/LICENSE-APACHE b/crates/coordinode-crypto/LICENSE-APACHE new file mode 100644 index 0000000..78173fa --- /dev/null +++ b/crates/coordinode-crypto/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/crates/coordinode-crypto/LICENSE-MIT b/crates/coordinode-crypto/LICENSE-MIT new file mode 100644 index 0000000..966be2b --- /dev/null +++ b/crates/coordinode-crypto/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright (c) 2023 Steve Fan, ctz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/coordinode-crypto/NOTICES b/crates/coordinode-crypto/NOTICES new file mode 100644 index 0000000..864423e --- /dev/null +++ b/crates/coordinode-crypto/NOTICES @@ -0,0 +1,11 @@ +This package includes an adapted rustls-rustcrypto implementation from +https://github.com/RustCrypto/rustls-rustcrypto at revision +70f76c039e587192688af18a80d5d6435dedaf22. + +The upstream implementation is distributed under MIT OR Apache-2.0. Its +original license texts and copyright notice are retained in LICENSE-MIT +and LICENSE-APACHE. Adaptations include current dependency constraints, +explicit macro identifiers, identity admission, bounded public RSA decoding, +X25519 contributory checks, fallible entropy handling and AEAD corrections. + +Independent TLS/certificate backends are used only by development tests. diff --git a/crates/coordinode-crypto/README.md b/crates/coordinode-crypto/README.md new file mode 100644 index 0000000..1873f07 --- /dev/null +++ b/crates/coordinode-crypto/README.md @@ -0,0 +1,51 @@ +# CoordiNode RustCrypto provider + +A pure-Rust cryptography provider for rustls, based on +[RustCrypto/rustls-rustcrypto](https://github.com/RustCrypto/rustls-rustcrypto) +revision `70f76c039e587192688af18a80d5d6435dedaf22`. + +Rustls owns TLS and certificate-path validation. This package adapts RustCrypto +primitives to rustls and retains the upstream MIT OR Apache-2.0 licensing. + +## Use + +```rust +let provider = std::sync::Arc::new(coordinode_crypto::provider()); +let builder = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions()?; +# Ok::<(), rustls::Error>(()) +``` + +The provider supports TLS 1.3 and, with `tls12`, TLS 1.2. It supplies AES-GCM, +ChaCha20-Poly1305, X25519, P-256 and P-384 key exchange, and ECDSA/Ed25519 +local identities. X25519 rejects every all-zero shared secret. RSA certificate +and peer signatures remain verifiable with 2048..=8192-bit public keys, while +RSA private signing and decryption are unavailable through the provider. +`identity::IdentityKeyProvider` applies the same local identity admission to +an explicitly selected, separately qualified backend. + +Standalone SEC1 identities require named P-256/P-384 parameters and canonical +scalar widths. PKCS#8 identities retain algorithm checks; a PEM container name +does not identify its private-key algorithm. Rustls retains certificate-chain, +hostname, purpose and validity checks. + +## Allocation-only builds + +The library is `no_std` with an explicit `alloc` feature. The default `std` +feature enables rustls' standard-library integration. Secret key storage is +zeroized independently of that choice. + +Targets without an operating-system entropy source must supply a secure custom +backend using the [getrandom backend contract](https://docs.rs/getrandom/0.4.3/getrandom/#custom-backend). +An allocation-only compile check is: + +```sh +RUSTFLAGS='--cfg getrandom_backend="custom"' cargo check -p coordinode-crypto \ + --no-default-features --features alloc --target thumbv7em-none-eabihf +``` + +This checks compilation, not the availability or security of a target's entropy +implementation. Production entropy errors propagate as failures. + +The package has an independent version and is shared by CoordiNode and +structured-proxy. See `LICENSE-MIT`, `LICENSE-APACHE` and `NOTICES` for licensing. diff --git a/crates/coordinode-crypto/src/aead.rs b/crates/coordinode-crypto/src/aead.rs new file mode 100644 index 0000000..6ff57b7 --- /dev/null +++ b/crates/coordinode-crypto/src/aead.rs @@ -0,0 +1,54 @@ +use aead::Buffer; +use rustls::crypto::cipher::{BorrowedPayload, PrefixedPayload}; + +pub mod chacha20; +pub mod gcm; + +pub(crate) struct EncryptBufferAdapter<'a>(&'a mut PrefixedPayload); + +impl AsRef<[u8]> for EncryptBufferAdapter<'_> { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl AsMut<[u8]> for EncryptBufferAdapter<'_> { + fn as_mut(&mut self) -> &mut [u8] { + self.0.as_mut() + } +} + +impl Buffer for EncryptBufferAdapter<'_> { + fn extend_from_slice(&mut self, other: &[u8]) -> aead::Result<()> { + self.0.extend_from_slice(other); + Ok(()) + } + + fn truncate(&mut self, len: usize) { + self.0.truncate(len) + } +} + +pub(crate) struct DecryptBufferAdapter<'a, 'p>(&'a mut BorrowedPayload<'p>); + +impl AsRef<[u8]> for DecryptBufferAdapter<'_, '_> { + fn as_ref(&self) -> &[u8] { + self.0 + } +} + +impl AsMut<[u8]> for DecryptBufferAdapter<'_, '_> { + fn as_mut(&mut self) -> &mut [u8] { + self.0 + } +} + +impl Buffer for DecryptBufferAdapter<'_, '_> { + fn extend_from_slice(&mut self, _: &[u8]) -> aead::Result<()> { + unreachable!("not used by `AeadInPlace::decrypt_in_place`") + } + + fn truncate(&mut self, len: usize) { + self.0.truncate(len) + } +} diff --git a/crates/coordinode-crypto/src/aead/chacha20.rs b/crates/coordinode-crypto/src/aead/chacha20.rs new file mode 100644 index 0000000..0be7f38 --- /dev/null +++ b/crates/coordinode-crypto/src/aead/chacha20.rs @@ -0,0 +1,201 @@ +#[cfg(feature = "alloc")] +use alloc::boxed::Box; + +use super::{DecryptBufferAdapter, EncryptBufferAdapter}; + +use aead::{AeadInOut, KeySizeUser}; +use chacha20poly1305::KeyInit; +use rustls::crypto::cipher::{ + self, AeadKey, InboundOpaqueMessage, InboundPlainMessage, Iv, MessageDecrypter, + MessageEncrypter, OutboundOpaqueMessage, OutboundPlainMessage, PrefixedPayload, + Tls13AeadAlgorithm, UnsupportedOperationError, +}; +use rustls::{ConnectionTrafficSecrets, ContentType, ProtocolVersion}; + +#[cfg(feature = "tls12")] +use rustls::crypto::cipher::{KeyBlockShape, NONCE_LEN, Tls12AeadAlgorithm}; + +pub struct Chacha20Poly1305; + +impl Tls13AeadAlgorithm for Chacha20Poly1305 { + fn encrypter(&self, key: AeadKey, iv: Iv) -> Box { + Box::new(Tls13Cipher( + chacha20poly1305::ChaCha20Poly1305::new_from_slice(key.as_ref()) + .expect("key should be valid"), + iv, + )) + } + + fn decrypter(&self, key: AeadKey, iv: Iv) -> Box { + Box::new(Tls13Cipher( + chacha20poly1305::ChaCha20Poly1305::new_from_slice(key.as_ref()) + .expect("key should be valid"), + iv, + )) + } + + fn key_len(&self) -> usize { + chacha20poly1305::ChaCha20Poly1305::key_size() + } + + fn extract_keys( + &self, + key: AeadKey, + iv: Iv, + ) -> Result { + Ok(ConnectionTrafficSecrets::Chacha20Poly1305 { key, iv }) + } +} + +#[cfg(feature = "tls12")] +impl Tls12AeadAlgorithm for Chacha20Poly1305 { + fn encrypter(&self, key: AeadKey, iv: &[u8], _: &[u8]) -> Box { + Box::new(Tls12Cipher( + chacha20poly1305::ChaCha20Poly1305::new_from_slice(key.as_ref()) + .expect("key should be valid"), + Iv::copy(iv), + )) + } + + fn decrypter(&self, key: AeadKey, iv: &[u8]) -> Box { + Box::new(Tls12Cipher( + chacha20poly1305::ChaCha20Poly1305::new_from_slice(key.as_ref()) + .expect("key should be valid"), + Iv::copy(iv), + )) + } + + fn key_block_shape(&self) -> KeyBlockShape { + KeyBlockShape { + enc_key_len: 32, + fixed_iv_len: 12, + explicit_nonce_len: 0, + } + } + + fn extract_keys( + &self, + key: AeadKey, + iv: &[u8], + _explicit: &[u8], + ) -> Result { + // This should always be true because KeyBlockShape and the Iv nonce len are in + // agreement. + debug_assert_eq!(NONCE_LEN, iv.len()); + Ok(ConnectionTrafficSecrets::Chacha20Poly1305 { + key, + iv: Iv::new(iv[..].try_into().expect("conversion should succeed")), + }) + } +} + +struct Tls13Cipher(chacha20poly1305::ChaCha20Poly1305, Iv); + +impl MessageEncrypter for Tls13Cipher { + fn encrypt( + &mut self, + m: OutboundPlainMessage<'_>, + seq: u64, + ) -> Result { + let total_len = self.encrypted_payload_len(m.payload.len()); + let mut payload = PrefixedPayload::with_capacity(total_len); + + payload.extend_from_chunks(&m.payload); + payload.extend_from_slice(&m.typ.to_array()); + + let nonce: chacha20poly1305::Nonce = cipher::Nonce::new(&self.1, seq).0.into(); + let aad = cipher::make_tls13_aad(total_len); + + self.0 + .encrypt_in_place(&nonce, &aad, &mut EncryptBufferAdapter(&mut payload)) + .map_err(|_| rustls::Error::EncryptError) + .map(|()| { + OutboundOpaqueMessage::new( + ContentType::ApplicationData, + ProtocolVersion::TLSv1_2, + payload, + ) + }) + } + + fn encrypted_payload_len(&self, payload_len: usize) -> usize { + payload_len + 1 + CHACHAPOLY1305_OVERHEAD + } +} + +impl MessageDecrypter for Tls13Cipher { + fn decrypt<'a>( + &mut self, + mut m: InboundOpaqueMessage<'a>, + seq: u64, + ) -> Result, rustls::Error> { + let payload = &mut m.payload; + let nonce: chacha20poly1305::Nonce = cipher::Nonce::new(&self.1, seq).0.into(); + let aad = cipher::make_tls13_aad(payload.len()); + + self.0 + .decrypt_in_place(&nonce, &aad, &mut DecryptBufferAdapter(payload)) + .map_err(|_| rustls::Error::DecryptError)?; + + m.into_tls13_unpadded_message() + } +} + +#[cfg(feature = "tls12")] +struct Tls12Cipher(chacha20poly1305::ChaCha20Poly1305, Iv); + +#[cfg(feature = "tls12")] +impl MessageEncrypter for Tls12Cipher { + fn encrypt( + &mut self, + m: OutboundPlainMessage<'_>, + seq: u64, + ) -> Result { + let total_len = self.encrypted_payload_len(m.payload.len()); + let mut payload = PrefixedPayload::with_capacity(total_len); + + payload.extend_from_chunks(&m.payload); + + let nonce: chacha20poly1305::Nonce = cipher::Nonce::new(&self.1, seq).0.into(); + let aad = cipher::make_tls12_aad(seq, m.typ, m.version, m.payload.len()); + + self.0 + .encrypt_in_place(&nonce, &aad, &mut EncryptBufferAdapter(&mut payload)) + .map_err(|_| rustls::Error::EncryptError) + .map(|_| OutboundOpaqueMessage::new(m.typ, m.version, payload)) + } + + fn encrypted_payload_len(&self, payload_len: usize) -> usize { + payload_len + CHACHAPOLY1305_OVERHEAD + } +} + +#[cfg(feature = "tls12")] +impl MessageDecrypter for Tls12Cipher { + fn decrypt<'a>( + &mut self, + mut m: InboundOpaqueMessage<'a>, + seq: u64, + ) -> Result, rustls::Error> { + let payload = &m.payload; + if payload.len() < CHACHAPOLY1305_OVERHEAD { + return Err(rustls::Error::DecryptError); + } + let nonce: chacha20poly1305::Nonce = cipher::Nonce::new(&self.1, seq).0.into(); + let aad = cipher::make_tls12_aad( + seq, + m.typ, + m.version, + payload.len() - CHACHAPOLY1305_OVERHEAD, + ); + + let payload = &mut m.payload; + self.0 + .decrypt_in_place(&nonce, &aad, &mut DecryptBufferAdapter(payload)) + .map_err(|_| rustls::Error::DecryptError)?; + + Ok(m.into_plain_message()) + } +} + +const CHACHAPOLY1305_OVERHEAD: usize = 16; diff --git a/crates/coordinode-crypto/src/aead/gcm.rs b/crates/coordinode-crypto/src/aead/gcm.rs new file mode 100644 index 0000000..9f8deca --- /dev/null +++ b/crates/coordinode-crypto/src/aead/gcm.rs @@ -0,0 +1,266 @@ +#[cfg(feature = "alloc")] +use alloc::boxed::Box; + +use super::{DecryptBufferAdapter, EncryptBufferAdapter}; + +use aead::AeadInOut; +use crypto_common::{KeyInit, KeySizeUser}; +use rustls::crypto::cipher::{ + self, AeadKey, InboundOpaqueMessage, InboundPlainMessage, MessageDecrypter, MessageEncrypter, + OutboundOpaqueMessage, OutboundPlainMessage, PrefixedPayload, Tls13AeadAlgorithm, +}; +use rustls::{ConnectionTrafficSecrets, ContentType, ProtocolVersion}; + +#[cfg(feature = "tls12")] +use { + aead::AeadCore, + crypto_common::typenum::Unsigned, + rustls::crypto::cipher::{Iv, KeyBlockShape, Tls12AeadAlgorithm}, +}; + +#[cfg(feature = "tls12")] +const TLS12_GCM_EXPLICIT_NONCE_LEN: usize = 8; + +#[cfg(feature = "tls12")] +const TLS12_GCM_OVERHEAD: usize = TLS12_GCM_EXPLICIT_NONCE_LEN + 16; + +macro_rules! impl_gcm_tls13 { + ($name: ident, $cipher: ident, $variant: ident, $aead: ty, $overhead: expr) => { + pub struct $name; + + impl Tls13AeadAlgorithm for $name { + fn encrypter(&self, key: AeadKey, iv: cipher::Iv) -> Box { + Box::new($cipher( + <$aead>::new_from_slice(key.as_ref()) + .expect("rustls supplies the negotiated AES key length"), + iv, + )) + } + + fn decrypter(&self, key: AeadKey, iv: cipher::Iv) -> Box { + Box::new($cipher( + <$aead>::new_from_slice(key.as_ref()) + .expect("rustls supplies the negotiated AES key length"), + iv, + )) + } + + fn key_len(&self) -> usize { + <$aead>::key_size() + } + fn extract_keys( + &self, + key: AeadKey, + iv: cipher::Iv, + ) -> Result { + Ok(ConnectionTrafficSecrets::$variant { key, iv }) + } + } + + struct $cipher($aead, cipher::Iv); + + impl MessageEncrypter for $cipher { + fn encrypt( + &mut self, + m: OutboundPlainMessage<'_>, + seq: u64, + ) -> Result { + let total_len = self.encrypted_payload_len(m.payload.len()); + let mut payload = PrefixedPayload::with_capacity(total_len); + + let nonce = cipher::Nonce::new(&self.1, seq).0; + let aad = cipher::make_tls13_aad(total_len); + payload.extend_from_chunks(&m.payload); + payload.extend_from_slice(&m.typ.to_array()); + + self.0 + .encrypt_in_place(&nonce.into(), &aad, &mut EncryptBufferAdapter(&mut payload)) + .map_err(|_| rustls::Error::EncryptError) + .map(|_| { + OutboundOpaqueMessage::new( + ContentType::ApplicationData, + ProtocolVersion::TLSv1_2, + payload, + ) + }) + } + + fn encrypted_payload_len(&self, payload_len: usize) -> usize { + payload_len + 1 + $overhead + } + } + + impl MessageDecrypter for $cipher { + fn decrypt<'a>( + &mut self, + mut m: InboundOpaqueMessage<'a>, + seq: u64, + ) -> Result, rustls::Error> { + let payload = &mut m.payload; + let nonce = cipher::Nonce::new(&self.1, seq).0; + let aad = cipher::make_tls13_aad(payload.len()); + + self.0 + .decrypt_in_place(&nonce.into(), &aad, &mut DecryptBufferAdapter(payload)) + .map_err(|_| rustls::Error::DecryptError)?; + + m.into_tls13_unpadded_message() + } + } + }; +} + +#[cfg(feature = "tls12")] +macro_rules! impl_gcm_tls12 { + ($name: ident, $encrypter: ident, $decrypter: ident, $variant: ident, $aead: ty, $nonce: expr, $overhead: expr) => { + #[cfg(feature = "tls12")] + pub struct $name; + + #[cfg(feature = "tls12")] + impl Tls12AeadAlgorithm for $name { + fn encrypter( + &self, + key: AeadKey, + write_iv: &[u8], + explicit: &[u8], + ) -> Box { + Box::new($encrypter( + <$aead>::new_from_slice(key.as_ref()) + .expect("rustls supplies the negotiated AES key length"), + { + let mut iv: [u8; 12] = [0; 12]; + iv[..4].copy_from_slice(write_iv); + iv[4..].copy_from_slice(explicit); + iv + }, + )) + } + + fn decrypter(&self, dec_key: AeadKey, dec_iv: &[u8]) -> Box { + Box::new($decrypter( + <$aead>::new_from_slice(dec_key.as_ref()) + .expect("rustls supplies the negotiated AES key length"), + dec_iv + .try_into() + .expect("rustls supplies a 4-byte TLS 1.2 AES fixed IV"), + )) + } + + fn key_block_shape(&self) -> KeyBlockShape { + KeyBlockShape { + enc_key_len: <$aead>::key_size(), + fixed_iv_len: 4, + explicit_nonce_len: 8, + } + } + + fn extract_keys( + &self, + key: AeadKey, + iv: &[u8], + explicit: &[u8], + ) -> Result { + let mut nonce = [0; 12]; + nonce[..4].copy_from_slice(iv); + nonce[4..].copy_from_slice(explicit); + Ok(ConnectionTrafficSecrets::$variant { + key, + iv: Iv::new(nonce), + }) + } + } + + #[cfg(feature = "tls12")] + struct $encrypter($aead, [u8; 12]); + + #[cfg(feature = "tls12")] + impl MessageEncrypter for $encrypter { + fn encrypt( + &mut self, + m: OutboundPlainMessage<'_>, + seq: u64, + ) -> Result { + let total_len = self.encrypted_payload_len(m.payload.len()); + let mut payload = PrefixedPayload::with_capacity(total_len); + + let nonce = cipher::Nonce::new(&self.1.into(), seq).0; + let aad = cipher::make_tls12_aad(seq, m.typ, m.version, m.payload.len()); + payload.extend_from_slice(&nonce.as_ref()[4..]); // explicit + payload.extend_from_chunks(&m.payload); + + self.0 + .encrypt_inout_detached( + &nonce.into(), + &aad, + (&mut payload.as_mut()[$nonce..]).into(), + ) + .map(|tag| payload.extend(tag.as_ref() as &[u8])) + .map_err(|_| rustls::Error::EncryptError) + .map(|_| OutboundOpaqueMessage::new(m.typ, m.version, payload)) + } + fn encrypted_payload_len(&self, payload_len: usize) -> usize { + payload_len + $nonce + <$aead as AeadCore>::TagSize::USIZE + } + } + + #[cfg(feature = "tls12")] + struct $decrypter($aead, [u8; 4]); + + #[cfg(feature = "tls12")] + impl MessageDecrypter for $decrypter { + fn decrypt<'a>( + &mut self, + mut m: InboundOpaqueMessage<'a>, + seq: u64, + ) -> Result, rustls::Error> { + type TagSize = <$aead as AeadCore>::TagSize; + + let payload = &m.payload; + + if payload.len() < $overhead { + return Err(rustls::Error::DecryptError); + } + + let nonce: aead::Nonce<$aead> = { + let mut nonce = [0u8; 12]; + nonce[..4].copy_from_slice(&self.1); // dec_iv + nonce[4..].copy_from_slice(&payload[..$nonce]); + nonce.into() + }; + + let aad = cipher::make_tls12_aad(seq, m.typ, m.version, payload.len() - $overhead); + + let payload = &mut m.payload; + let tag_pos = { + let payload = &mut payload[$nonce..]; + let tag_pos = payload.len() - TagSize::to_usize(); + let (msg, tag) = payload.split_at_mut(tag_pos); + + let tag = aes_gcm::Tag::::try_from(&*tag) + .map_err(|_| rustls::Error::DecryptError)?; + self.0 + .decrypt_inout_detached(&nonce, &aad, msg.into(), &tag) + .map_err(|_| rustls::Error::DecryptError)?; + tag_pos + }; + + // We defer the truncation to here, because we may inadvertently shifted the + // original data if the decryption failed. Another way to avoid this is + // to clone the payload slice starting after the explicit nonce, + // but this will cause an additional cloning and copying + payload.rotate_left($nonce); + payload.truncate(tag_pos); + Ok(m.into_plain_message()) + } + } + }; +} + +impl_gcm_tls13! {Tls13Aes128Gcm, Tls13CipherAes128Gcm, Aes128Gcm, aes_gcm::Aes128Gcm, 16} +impl_gcm_tls13! {Tls13Aes256Gcm, Tls13CipherAes256Gcm, Aes256Gcm, aes_gcm::Aes256Gcm, 16} + +#[cfg(feature = "tls12")] +impl_gcm_tls12! {Tls12Aes128Gcm, Tls12CipherAes128GcmEncrypter, Tls12CipherAes128GcmDecrypter, Aes128Gcm, aes_gcm::Aes128Gcm, TLS12_GCM_EXPLICIT_NONCE_LEN, TLS12_GCM_OVERHEAD} + +#[cfg(feature = "tls12")] +impl_gcm_tls12! {Tls12Aes256Gcm, Tls12CipherAes256GcmEncrypter, Tls12CipherAes256GcmDecrypter, Aes256Gcm, aes_gcm::Aes256Gcm, TLS12_GCM_EXPLICIT_NONCE_LEN, TLS12_GCM_OVERHEAD} diff --git a/crates/coordinode-crypto/src/hash.rs b/crates/coordinode-crypto/src/hash.rs new file mode 100644 index 0000000..16c7610 --- /dev/null +++ b/crates/coordinode-crypto/src/hash.rs @@ -0,0 +1,55 @@ +#[cfg(feature = "alloc")] +use alloc::boxed::Box; + +use digest::{Digest, OutputSizeUser}; +use rustls::crypto::{self, hash}; +use sha2::{Sha256, Sha384}; + +macro_rules! impl_hash { + ($name:ident, $hash:ident, $context:ident, $ty:ty, $algo:expr) => { + struct $hash; + + impl hash::Hash for $hash { + fn start(&self) -> Box { + Box::new($context(<$ty>::new())) + } + + fn hash(&self, data: &[u8]) -> hash::Output { + hash::Output::new(&<$ty>::digest(data)[..]) + } + + fn output_len(&self) -> usize { + <$ty as OutputSizeUser>::output_size() + } + + fn algorithm(&self) -> hash::HashAlgorithm { + $algo + } + } + + struct $context($ty); + + impl hash::Context for $context { + fn fork_finish(&self) -> hash::Output { + hash::Output::new(&self.0.clone().finalize()[..]) + } + + fn fork(&self) -> Box { + Box::new($context(self.0.clone())) + } + + fn finish(self: Box) -> hash::Output { + hash::Output::new(&self.0.finalize()[..]) + } + + fn update(&mut self, data: &[u8]) { + self.0.update(data); + } + } + + pub const $name: &dyn crypto::hash::Hash = &$hash; + }; +} + +impl_hash! {SHA256, Sha256Hash, Sha256Context, Sha256, hash::HashAlgorithm::SHA256} +impl_hash! {SHA384, Sha384Hash, Sha384Context, Sha384, hash::HashAlgorithm::SHA384} diff --git a/crates/coordinode-crypto/src/hmac.rs b/crates/coordinode-crypto/src/hmac.rs new file mode 100644 index 0000000..929a165 --- /dev/null +++ b/crates/coordinode-crypto/src/hmac.rs @@ -0,0 +1,57 @@ +#[cfg(feature = "alloc")] +use alloc::boxed::Box; + +use crypto_common::OutputSizeUser; +use hmac::{KeyInit, Mac}; +use rustls::crypto; +use sha2::{Sha256, Sha384}; + +macro_rules! impl_hmac { + ( + $name: ident, + $hmac: ident, + $key: ident, + $ty: ty + ) => { + struct $hmac; + + impl crypto::hmac::Hmac for $hmac { + fn with_key(&self, key: &[u8]) -> Box { + Box::new($key( + hmac::Hmac::<$ty>::new_from_slice(key).expect("HMAC accepts any key length"), + )) + } + + fn hash_output_len(&self) -> usize { + <$ty>::output_size() + } + } + + struct $key(hmac::Hmac<$ty>); + + impl crypto::hmac::Key for $key { + fn sign_concat( + &self, + first: &[u8], + middle: &[&[u8]], + last: &[u8], + ) -> crypto::hmac::Tag { + let mut ctx = self.0.clone(); + ctx.update(first); + for m in middle { + ctx.update(m); + } + ctx.update(last); + crypto::hmac::Tag::new(&ctx.finalize().into_bytes()[..]) + } + + fn tag_len(&self) -> usize { + <$ty>::output_size() + } + } + pub const $name: &dyn crypto::hmac::Hmac = &$hmac; + }; +} + +impl_hmac! {SHA256, Sha256Hmac, Sha256HmacKey, Sha256} +impl_hmac! {SHA384, Sha384Hmac, Sha384HmacKey, Sha384} diff --git a/crates/coordinode-crypto/src/identity.rs b/crates/coordinode-crypto/src/identity.rs new file mode 100644 index 0000000..4a45f01 --- /dev/null +++ b/crates/coordinode-crypto/src/identity.rs @@ -0,0 +1,104 @@ +use alloc::sync::Arc; + +use der::Decode; +use pkcs8::{AssociatedOid, ObjectIdentifier, PrivateKeyInfoRef}; +use pki_types::PrivateKeyDer; +use rustls::crypto::KeyProvider; +use rustls::sign::SigningKey; +use rustls::{Error, SignatureAlgorithm}; + +const EC_PUBLIC_KEY: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); +const ED25519: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.101.112"); + +fn unsupported() -> Error { + Error::General("local TLS identities require P-256, P-384 or Ed25519 private keys".into()) +} + +/// Check the algorithm before a backend can perform any private-key operation. +/// +/// PKCS#1 RSA keys and RSA algorithms inside PKCS#8 are refused. Standalone +/// SEC1 keys require named P-256 or P-384 parameters and the canonical scalar +/// width (RFC 5915 section 3). Cryptographic key validation remains the +/// selected backend's responsibility. +/// +/// # Errors +/// Returns an error for malformed containers or unsupported identity algorithms. +pub fn check_identity_key(key: &PrivateKeyDer<'_>) -> Result<(), Error> { + match key { + PrivateKeyDer::Pkcs8(key) => { + let info = + PrivateKeyInfoRef::from_der(key.secret_pkcs8_der()).map_err(|_| unsupported())?; + if info.algorithm.oid == ED25519 { + if info.algorithm.parameters.is_some() { + return Err(unsupported()); + } + return Ok(()); + } + if info.algorithm.oid != EC_PUBLIC_KEY { + return Err(unsupported()); + } + let curve: ObjectIdentifier = info + .algorithm + .parameters + .ok_or_else(unsupported)? + .decode_as() + .map_err(|_| unsupported())?; + let length = curve_width(curve)?; + let inner = sec1::EcPrivateKey::from_der(info.private_key.as_bytes()) + .map_err(|_| unsupported())?; + if inner.private_key.len() != length { + return Err(unsupported()); + } + Ok(()) + } + PrivateKeyDer::Sec1(key) => { + let inner = + sec1::EcPrivateKey::from_der(key.secret_sec1_der()).map_err(|_| unsupported())?; + let Some(sec1::EcParameters::NamedCurve(curve)) = inner.parameters else { + return Err(unsupported()); + }; + if inner.private_key.len() != curve_width(curve)? { + return Err(unsupported()); + } + Ok(()) + } + _ => Err(unsupported()), + } +} + +fn curve_width(curve: ObjectIdentifier) -> Result { + if curve == p256::NistP256::OID { + return Ok(32); + } + if curve == p384::NistP384::OID { + return Ok(48); + } + Err(unsupported()) +} + +/// Identity policy around a separately qualified rustls key backend. +/// +/// The caller owns the wrapper for the lifetime required by its rustls provider. +/// No key is delegated until its container identifies an allowed algorithm. +#[derive(Debug)] +pub struct IdentityKeyProvider { + inner: &'static dyn KeyProvider, +} + +impl IdentityKeyProvider { + /// Wrap a backend without changing its ownership of allowed signing keys. + pub const fn new(inner: &'static dyn KeyProvider) -> Self { + Self { inner } + } +} + +impl KeyProvider for IdentityKeyProvider { + fn load_private_key(&self, key: PrivateKeyDer<'static>) -> Result, Error> { + check_identity_key(&key)?; + let key = self.inner.load_private_key(key)?; + match key.algorithm() { + SignatureAlgorithm::ECDSA | SignatureAlgorithm::ED25519 => Ok(key), + _ => Err(unsupported()), + } + } +} diff --git a/crates/coordinode-crypto/src/kx.rs b/crates/coordinode-crypto/src/kx.rs new file mode 100644 index 0000000..e710b8e --- /dev/null +++ b/crates/coordinode-crypto/src/kx.rs @@ -0,0 +1,119 @@ +#[cfg(feature = "alloc")] +use alloc::boxed::Box; + +use crypto::{SharedSecret, SupportedKxGroup}; +use crypto_common::Generate; +use getrandom::rand_core::TryCryptoRng; +use rustls::crypto; +use zeroize::Zeroizing; + +#[derive(Debug)] +pub struct X25519; + +impl X25519 { + fn start_with_rng( + rng: &mut impl TryCryptoRng, + ) -> Result, rustls::Error> { + let mut secret = Zeroizing::new([0u8; 32]); + rng.try_fill_bytes(secret.as_mut()) + .map_err(|_| rustls::Error::General("entropy source failed".into()))?; + let priv_key = x25519_dalek::StaticSecret::from(*secret); + let pub_key = (&priv_key).into(); + Ok(Box::new(X25519KeyExchange { priv_key, pub_key })) + } +} + +impl crypto::SupportedKxGroup for X25519 { + fn name(&self) -> rustls::NamedGroup { + rustls::NamedGroup::X25519 + } + + fn start(&self) -> Result, rustls::Error> { + Ok(Self::start_with_rng(&mut getrandom::SysRng)?) + } +} + +pub struct X25519KeyExchange { + // The enclosing exchange is consumed on completion and is never cloned. + priv_key: x25519_dalek::StaticSecret, + pub_key: x25519_dalek::PublicKey, +} + +impl crypto::ActiveKeyExchange for X25519KeyExchange { + fn complete(self: Box, peer: &[u8]) -> Result { + let peer_array: [u8; 32] = peer + .try_into() + .map_err(|_| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?; + let shared = self.priv_key.diffie_hellman(&peer_array.into()); + // RFC 8446 section 7.4.2 requires rejecting every all-zero shared secret. + if !shared.was_contributory() { + return Err(rustls::PeerMisbehaved::InvalidKeyShare.into()); + } + Ok(shared.as_ref().into()) + } + + fn pub_key(&self) -> &[u8] { + self.pub_key.as_bytes() + } + + fn group(&self) -> rustls::NamedGroup { + X25519.name() + } +} + +macro_rules! impl_kx { + ($name:ident, $exchange:ident, $kx_name:expr, $secret:ty, $public_key:ty) => { + #[derive(Debug)] + pub struct $name; + + impl crypto::SupportedKxGroup for $name { + fn name(&self) -> rustls::NamedGroup { + $kx_name + } + + fn start(&self) -> Result, rustls::Error> { + let priv_key = <$secret>::try_generate_from_rng(&mut getrandom::SysRng) + .map_err(|_| rustls::Error::General("entropy source failed".into()))?; + let pub_key: $public_key = (&priv_key).into(); + Ok(Box::new($exchange { + priv_key, + pub_key: pub_key.to_sec1_bytes(), + })) + } + } + + pub struct $exchange { + priv_key: $secret, + pub_key: Box<[u8]>, + } + + impl crypto::ActiveKeyExchange for $exchange { + fn complete(self: Box<$exchange>, peer: &[u8]) -> Result { + let their_pub = <$public_key>::from_sec1_bytes(peer) + .map_err(|_| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?; + Ok(self + .priv_key + .diffie_hellman(&their_pub) + .raw_secret_bytes() + .as_slice() + .into()) + } + + fn pub_key(&self) -> &[u8] { + &self.pub_key + } + + fn group(&self) -> rustls::NamedGroup { + $name.name() + } + } + }; +} + +impl_kx! {SecP256R1, SecP256R1KeyExchange, rustls::NamedGroup::secp256r1, p256::ecdh::EphemeralSecret, p256::PublicKey} +impl_kx! {SecP384R1, SecP384R1KeyExchange, rustls::NamedGroup::secp384r1, p384::ecdh::EphemeralSecret, p384::PublicKey} + +pub const ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[&X25519, &SecP256R1, &SecP384R1]; + +#[cfg(test)] +mod tests; diff --git a/crates/coordinode-crypto/src/kx/tests.rs b/crates/coordinode-crypto/src/kx/tests.rs new file mode 100644 index 0000000..6f7c210 --- /dev/null +++ b/crates/coordinode-crypto/src/kx/tests.rs @@ -0,0 +1,30 @@ +use getrandom::rand_core::{TryCryptoRng, TryRng}; + +struct FailedEntropy; + +impl TryRng for FailedEntropy { + type Error = getrandom::Error; + + fn try_next_u32(&mut self) -> Result { + Err(getrandom::Error::UNSUPPORTED) + } + + fn try_next_u64(&mut self) -> Result { + Err(getrandom::Error::UNSUPPORTED) + } + + fn try_fill_bytes(&mut self, _: &mut [u8]) -> Result<(), Self::Error> { + Err(getrandom::Error::UNSUPPORTED) + } +} + +impl TryCryptoRng for FailedEntropy {} + +// Entropy failure must return a TLS error before an exchange is constructed. +#[test] +fn entropy_failure_does_not_panic_or_create_an_exchange() { + assert!(matches!( + super::X25519::start_with_rng(&mut FailedEntropy), + Err(rustls::Error::General(_)) + )); +} diff --git a/crates/coordinode-crypto/src/lib.rs b/crates/coordinode-crypto/src/lib.rs new file mode 100644 index 0000000..02226a7 --- /dev/null +++ b/crates/coordinode-crypto/src/lib.rs @@ -0,0 +1,243 @@ +#![no_std] +#![forbid(unsafe_code)] +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![doc = include_str!("../README.md")] +#![warn( + clippy::alloc_instead_of_core, + clippy::cast_lossless, + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_precision_loss, + clippy::cast_sign_loss, + clippy::checked_conversions, + clippy::missing_errors_doc, + clippy::mod_module_files, + clippy::implicit_saturating_sub, + clippy::panic, + clippy::panic_in_result_fn, + clippy::std_instead_of_alloc, + clippy::std_instead_of_core, + clippy::unwrap_used, + rust_2018_idioms, + trivial_numeric_casts, + unused_lifetimes +)] + +#[cfg(not(feature = "alloc"))] +compile_error!("Rustls currently does not support alloc-less environments"); + +#[cfg(feature = "alloc")] +extern crate alloc; + +#[cfg(all(test, feature = "std"))] +extern crate std; + +#[cfg(feature = "alloc")] +use alloc::sync::Arc; + +use rustls::crypto::{ + CipherSuiteCommon, CryptoProvider, GetRandomFailed, KeyProvider, SecureRandom, +}; +use rustls::{CipherSuite, SupportedCipherSuite, Tls13CipherSuite}; + +#[cfg(feature = "tls12")] +use rustls::SignatureScheme; + +#[derive(Debug)] +pub struct Provider; + +pub fn provider() -> CryptoProvider { + CryptoProvider { + cipher_suites: ALL_CIPHER_SUITES.to_vec(), + kx_groups: kx::ALL_KX_GROUPS.to_vec(), + signature_verification_algorithms: verify::ALGORITHMS, + secure_random: &Provider, + key_provider: &Provider, + } +} + +impl SecureRandom for Provider { + fn fill(&self, bytes: &mut [u8]) -> Result<(), GetRandomFailed> { + use getrandom::rand_core::TryRng; + let mut rng = getrandom::SysRng; + rng.try_fill_bytes(bytes).map_err(|_| GetRandomFailed) + } +} + +impl KeyProvider for Provider { + fn load_private_key( + &self, + key_der: pki_types::PrivateKeyDer<'static>, + ) -> Result, rustls::Error> { + identity::check_identity_key(&key_der)?; + sign::any_supported_type(&key_der) + } +} + +#[cfg(feature = "tls12")] +const TLS12_ECDSA_SCHEMES: [SignatureScheme; 4] = [ + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::ECDSA_NISTP521_SHA512, + SignatureScheme::ED25519, +]; + +#[cfg(feature = "tls12")] +const TLS12_RSA_SCHEMES: [SignatureScheme; 6] = [ + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, +]; + +#[cfg(feature = "tls12")] +pub const TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: SupportedCipherSuite = + SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { + common: CipherSuiteCommon { + suite: CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + hash_provider: hash::SHA256, + confidentiality_limit: u64::MAX, + }, + kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, + sign: &TLS12_ECDSA_SCHEMES, + aead_alg: &aead::gcm::Tls12Aes128Gcm, + prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA256), + }); + +#[cfg(feature = "tls12")] +pub const TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: SupportedCipherSuite = + SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { + common: CipherSuiteCommon { + suite: CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + hash_provider: hash::SHA384, + confidentiality_limit: u64::MAX, + }, + kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, + sign: &TLS12_ECDSA_SCHEMES, + prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA384), + aead_alg: &aead::gcm::Tls12Aes256Gcm, + }); + +#[cfg(feature = "tls12")] +pub const TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: SupportedCipherSuite = + SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { + common: CipherSuiteCommon { + suite: CipherSuite::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + hash_provider: hash::SHA256, + confidentiality_limit: u64::MAX, + }, + prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA256), + kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, + sign: &TLS12_ECDSA_SCHEMES, + aead_alg: &aead::chacha20::Chacha20Poly1305, + }); + +#[cfg(feature = "tls12")] +pub const TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: SupportedCipherSuite = + SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { + common: CipherSuiteCommon { + suite: CipherSuite::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + hash_provider: hash::SHA256, + confidentiality_limit: u64::MAX, + }, + kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, + sign: &TLS12_RSA_SCHEMES, + aead_alg: &aead::gcm::Tls12Aes128Gcm, + prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA256), + }); + +#[cfg(feature = "tls12")] +pub const TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: SupportedCipherSuite = + SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { + common: CipherSuiteCommon { + suite: CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + hash_provider: hash::SHA384, + confidentiality_limit: u64::MAX, + }, + kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, + sign: &TLS12_RSA_SCHEMES, + prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA384), + aead_alg: &aead::gcm::Tls12Aes256Gcm, + }); + +#[cfg(feature = "tls12")] +pub const TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: SupportedCipherSuite = + SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { + common: CipherSuiteCommon { + suite: CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + hash_provider: hash::SHA256, + confidentiality_limit: u64::MAX, + }, + kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, + sign: &TLS12_RSA_SCHEMES, + prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA256), + aead_alg: &aead::chacha20::Chacha20Poly1305, + }); + +pub const TLS13_AES_128_GCM_SHA256: SupportedCipherSuite = + SupportedCipherSuite::Tls13(&Tls13CipherSuite { + common: CipherSuiteCommon { + suite: CipherSuite::TLS13_AES_128_GCM_SHA256, + hash_provider: hash::SHA256, + confidentiality_limit: u64::MAX, + }, + hkdf_provider: &rustls::crypto::tls13::HkdfUsingHmac(hmac::SHA256), + aead_alg: &aead::gcm::Tls13Aes128Gcm, + quic: None, + }); + +pub const TLS13_AES_256_GCM_SHA384: SupportedCipherSuite = + SupportedCipherSuite::Tls13(&Tls13CipherSuite { + common: CipherSuiteCommon { + suite: CipherSuite::TLS13_AES_256_GCM_SHA384, + hash_provider: hash::SHA384, + confidentiality_limit: u64::MAX, + }, + hkdf_provider: &rustls::crypto::tls13::HkdfUsingHmac(hmac::SHA384), + aead_alg: &aead::gcm::Tls13Aes256Gcm, + quic: None, + }); + +pub const TLS13_CHACHA20_POLY1305_SHA256: SupportedCipherSuite = + SupportedCipherSuite::Tls13(&Tls13CipherSuite { + common: CipherSuiteCommon { + suite: CipherSuite::TLS13_CHACHA20_POLY1305_SHA256, + hash_provider: hash::SHA256, + confidentiality_limit: u64::MAX, + }, + hkdf_provider: &rustls::crypto::tls13::HkdfUsingHmac(hmac::SHA256), + aead_alg: &aead::chacha20::Chacha20Poly1305, + quic: None, + }); + +static ALL_CIPHER_SUITES: &[SupportedCipherSuite] = &[ + #[cfg(feature = "tls12")] + TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + #[cfg(feature = "tls12")] + TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, + #[cfg(feature = "tls12")] + TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, + #[cfg(feature = "tls12")] + TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + #[cfg(feature = "tls12")] + TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + #[cfg(feature = "tls12")] + TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + TLS13_AES_128_GCM_SHA256, + TLS13_AES_256_GCM_SHA384, + TLS13_CHACHA20_POLY1305_SHA256, +]; + +mod aead; +mod hash; +mod hmac; +pub mod identity; +mod kx; +pub mod public_key; +pub mod sign; +mod verify; + +#[cfg(test)] +mod tests; diff --git a/crates/coordinode-crypto/src/public_key.rs b/crates/coordinode-crypto/src/public_key.rs new file mode 100644 index 0000000..3e6f457 --- /dev/null +++ b/crates/coordinode-crypto/src/public_key.rs @@ -0,0 +1,39 @@ +//! Bounded RSA public-key decoding shared by TLS and token verification. + +use crypto_bigint::BoxedUint; +use der::Decode; +use pki_types::InvalidSignature; +use rsa::RsaPublicKey; + +/// Decode a PKCS#1 RSA public key without allocating unbounded integers. +/// +/// # Errors +/// Rejects malformed DER and keys outside the supported 2048..=8192-bit range. +pub fn rsa_from_pkcs1(bytes: &[u8]) -> Result { + let key = rsa::pkcs1::RsaPublicKeyRef::from_der(bytes).map_err(|_| InvalidSignature)?; + rsa_from_components(key.modulus.as_bytes(), key.public_exponent.as_bytes()) +} + +/// Decode unsigned, minimal big-endian RSA public components. +/// +/// RFC 7518 sections 3.3 and 6.3.1 require a modulus of at least 2048 bits +/// and minimal unsigned encodings. The 8192-bit upper bound matches the +/// RustCrypto public-key implementation; exponent bounds are checked before +/// allocating or constructing modular arithmetic state. +/// +/// # Errors +/// Rejects noncanonical, out-of-range or otherwise invalid public keys. +pub fn rsa_from_components(n: &[u8], e: &[u8]) -> Result { + if n.len() < 256 || n.len() > 1024 || e.is_empty() || e.len() > 8 { + return Err(InvalidSignature); + } + if n[0] == 0 || e[0] == 0 { + return Err(InvalidSignature); + } + let n = BoxedUint::from_be_slice_vartime(n); + if n.bits() < 2048 { + return Err(InvalidSignature); + } + let e = BoxedUint::from_be_slice_vartime(e); + RsaPublicKey::new(n, e).map_err(|_| InvalidSignature) +} diff --git a/crates/coordinode-crypto/src/sign.rs b/crates/coordinode-crypto/src/sign.rs new file mode 100644 index 0000000..89a831a --- /dev/null +++ b/crates/coordinode-crypto/src/sign.rs @@ -0,0 +1,98 @@ +#[cfg(feature = "alloc")] +use alloc::{sync::Arc, vec::Vec}; +use core::marker::PhantomData; + +use self::ecdsa::{EcdsaSigningKeyP256, EcdsaSigningKeyP384}; +use self::eddsa::Ed25519SigningKey; +use pki_types::PrivateKeyDer; +use rustls::sign::{Signer, SigningKey}; +use rustls::{Error, SignatureScheme}; +use signature::{RandomizedSigner, SignatureEncoding}; + +#[derive(Debug)] +pub struct GenericRandomizedSigner +where + S: SignatureEncoding, + T: RandomizedSigner, +{ + _marker: PhantomData, + key: Arc, + scheme: SignatureScheme, +} + +impl Signer for GenericRandomizedSigner +where + S: SignatureEncoding + Send + Sync + core::fmt::Debug, + T: RandomizedSigner + Send + Sync + core::fmt::Debug, +{ + fn sign(&self, message: &[u8]) -> Result, Error> { + self.key + .try_sign_with_rng(&mut getrandom::SysRng, message) + .map_err(|_| rustls::Error::General("signing failed".into())) + .map(|sig: S| sig.to_vec()) + } + + fn scheme(&self) -> SignatureScheme { + self.scheme + } +} + +#[derive(Debug)] +pub struct GenericSigner +where + S: SignatureEncoding, + T: signature::Signer, +{ + _marker: PhantomData, + key: Arc, + scheme: SignatureScheme, +} + +impl Signer for GenericSigner +where + S: SignatureEncoding + Send + Sync + core::fmt::Debug, + T: signature::Signer + Send + Sync + core::fmt::Debug, +{ + fn sign(&self, message: &[u8]) -> Result, Error> { + self.key + .try_sign(message) + .map_err(|_| rustls::Error::General("signing failed".into())) + .map(|sig: S| sig.to_vec()) + } + + fn scheme(&self) -> SignatureScheme { + self.scheme + } +} + +/// Extract any supported key from the given DER input. +/// +/// # Errors +/// +/// Returns an error if the key couldn't be decoded. +pub fn any_supported_type(der: &PrivateKeyDer<'_>) -> Result, rustls::Error> { + any_ecdsa_type(der).or_else(|_| any_eddsa_type(der)) +} + +/// Extract any supported ECDSA key from the given DER input. +/// +/// # Errors +/// +/// Returns an error if the key couldn't be decoded. +pub fn any_ecdsa_type(der: &PrivateKeyDer<'_>) -> Result, rustls::Error> { + let p256 = |_| EcdsaSigningKeyP256::try_from(der).map(|x| Arc::new(x) as _); + let p384 = |_| EcdsaSigningKeyP384::try_from(der).map(|x| Arc::new(x) as _); + p256(()).or_else(p384) +} + +/// Extract any supported EDDSA key from the given DER input. +/// +/// # Errors +/// +/// Returns an error if the key couldn't be decoded. +pub fn any_eddsa_type(der: &PrivateKeyDer<'_>) -> Result, rustls::Error> { + Ed25519SigningKey::try_from(der).map(|x| Arc::new(x) as _) +} + +pub mod ecdsa; +pub mod eddsa; diff --git a/crates/coordinode-crypto/src/sign/ecdsa.rs b/crates/coordinode-crypto/src/sign/ecdsa.rs new file mode 100644 index 0000000..43bcd0f --- /dev/null +++ b/crates/coordinode-crypto/src/sign/ecdsa.rs @@ -0,0 +1,74 @@ +#[cfg(feature = "alloc")] +use alloc::{boxed::Box, format, sync::Arc}; +use core::marker::PhantomData; + +use der::Decode; +use pkcs8::DecodePrivateKey; +use pki_types::PrivateKeyDer; +use rustls::sign::SigningKey; +use rustls::{SignatureAlgorithm, SignatureScheme}; + +macro_rules! impl_ecdsa { + ($name: ident, $scheme: expr, $scalar_len: expr, $secret_key: ty, $signing_key: ty, $signature: ty) => { + #[derive(Debug)] + pub struct $name { + key: Arc<$signing_key>, + scheme: SignatureScheme, + } + + impl TryFrom<&PrivateKeyDer<'_>> for $name { + type Error = rustls::Error; + + fn try_from(value: &PrivateKeyDer<'_>) -> Result { + let pkey = match value { + PrivateKeyDer::Pkcs8(der) => { + <$signing_key>::from_pkcs8_der(der.secret_pkcs8_der()) + .map_err(|e| format!("invalid PKCS#8 EC private key: {e}")) + } + PrivateKeyDer::Pkcs1(_) => Err("RSA private keys are not supported".into()), + PrivateKeyDer::Sec1(der) => sec1::EcPrivateKey::from_der(der.secret_sec1_der()) + .map_err(|e| format!("failed to parse SEC1 EC private key: {e}")) + .and_then(|key| { + // RFC 5915 section 3 fixes the scalar width and requires named parameters. + if key.private_key.len() != $scalar_len || key.parameters.is_none() { + return Err("invalid SEC1 scalar width or missing curve".into()); + } + <$secret_key>::try_from(key) + .map(<$signing_key>::from) + .map_err(|e| format!("invalid SEC1 EC private key: {e}")) + }), + _ => Err("not supported".into()), + }; + pkey.map(|kp| Self { + key: Arc::new(kp), + scheme: $scheme, + }) + .map_err(rustls::Error::General) + } + } + + impl SigningKey for $name { + fn choose_scheme( + &self, + offered: &[SignatureScheme], + ) -> Option> { + if offered.contains(&self.scheme) { + Some(Box::new(super::GenericRandomizedSigner::<$signature, _> { + _marker: PhantomData, + key: self.key.clone(), + scheme: self.scheme, + })) + } else { + None + } + } + + fn algorithm(&self) -> SignatureAlgorithm { + SignatureAlgorithm::ECDSA + } + } + }; +} + +impl_ecdsa! {EcdsaSigningKeyP256, SignatureScheme::ECDSA_NISTP256_SHA256, 32, p256::SecretKey, p256::ecdsa::SigningKey, p256::ecdsa::DerSignature} +impl_ecdsa! {EcdsaSigningKeyP384, SignatureScheme::ECDSA_NISTP384_SHA384, 48, p384::SecretKey, p384::ecdsa::SigningKey, p384::ecdsa::DerSignature} diff --git a/crates/coordinode-crypto/src/sign/eddsa.rs b/crates/coordinode-crypto/src/sign/eddsa.rs new file mode 100644 index 0000000..15f4fe8 --- /dev/null +++ b/crates/coordinode-crypto/src/sign/eddsa.rs @@ -0,0 +1,53 @@ +#[cfg(feature = "alloc")] +use alloc::{boxed::Box, format, string::ToString, sync::Arc}; +use core::marker::PhantomData; + +use pkcs8::DecodePrivateKey; +use pki_types::PrivateKeyDer; +use rustls::sign::{Signer, SigningKey}; +use rustls::{SignatureAlgorithm, SignatureScheme}; + +#[derive(Debug)] +pub struct Ed25519SigningKey { + key: Arc, + scheme: SignatureScheme, +} + +impl TryFrom<&PrivateKeyDer<'_>> for Ed25519SigningKey { + type Error = rustls::Error; + + fn try_from(value: &PrivateKeyDer<'_>) -> Result { + let pkey = match value { + PrivateKeyDer::Pkcs8(der) => { + ed25519_dalek::SigningKey::from_pkcs8_der(der.secret_pkcs8_der()) + .map_err(|e| format!("failed to decrypt private key: {e}")) + } + PrivateKeyDer::Pkcs1(_) => Err("ED25519 does not support PKCS#1 key".to_string()), + PrivateKeyDer::Sec1(_) => Err("ED25519 does not support SEC1 key".to_string()), + _ => Err("not supported".into()), + }; + pkey.map(|kp| Self { + key: Arc::new(kp), + scheme: SignatureScheme::ED25519, + }) + .map_err(rustls::Error::General) + } +} + +impl SigningKey for Ed25519SigningKey { + fn choose_scheme(&self, offered: &[SignatureScheme]) -> Option> { + if offered.contains(&self.scheme) { + Some(Box::new(super::GenericSigner { + _marker: PhantomData, + key: self.key.clone(), + scheme: self.scheme, + })) + } else { + None + } + } + + fn algorithm(&self) -> SignatureAlgorithm { + SignatureAlgorithm::ED25519 + } +} diff --git a/crates/coordinode-crypto/src/tests.rs b/crates/coordinode-crypto/src/tests.rs new file mode 100644 index 0000000..45fc5c4 --- /dev/null +++ b/crates/coordinode-crypto/src/tests.rs @@ -0,0 +1,178 @@ +#![allow(clippy::unwrap_used, clippy::panic)] + +use alloc::vec::Vec; +use rustls::crypto::SupportedKxGroup; +use rustls::crypto::cipher::{AeadKey, Iv, Tls13AeadAlgorithm}; +use rustls::pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, PrivatePkcs8KeyDer}; +use rustls::{ConnectionTrafficSecrets, Error, PeerMisbehaved}; + +// TLS 1.3 must reject non-contributory X25519 shares (RFC 8446 section 7.4.2). +#[test] +fn x25519_rejects_low_order_shares() { + let mut shares = Vec::new(); + shares.push([0; 32]); + let mut one = [0; 32]; + one[0] = 1; + shares.push(one); + // Order-eight encodings from libsodium's curve25519 ref10 test boundary. + shares.push([ + 0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4, + 0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49, + 0xb8, 0x00, + ]); + shares.push([ + 0x5f, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1, 0x55, 0x9c, 0x83, 0xef, + 0x5b, 0x04, 0x44, 0x5c, 0xc4, 0x58, 0x1c, 0x8e, 0x86, 0xd8, 0x22, 0x4e, 0xdd, 0xd0, 0x9f, + 0x11, 0x57, + ]); + for first in [0xec, 0xed, 0xee] { + let mut share = [0xff; 32]; + share[0] = first; + share[31] = 0x7f; + shares.push(share); + } + for share in shares { + for high_bit in [0, 0x80] { + let mut encoded = share; + encoded[31] |= high_bit; + let result = super::kx::X25519.start().unwrap().complete(&encoded); + assert!(matches!( + result, + Err(Error::PeerMisbehaved(PeerMisbehaved::InvalidKeyShare)) + )); + } + } +} + +// A valid exchange remains interoperable while low-order shares are refused. +#[test] +fn x25519_valid_exchange_agrees() { + let alice = super::kx::X25519.start().unwrap(); + let bob = super::kx::X25519.start().unwrap(); + let alice_public = alice.pub_key().to_vec(); + let bob_public = bob.pub_key().to_vec(); + let alice_secret = alice.complete(&bob_public).unwrap(); + let bob_secret = bob.complete(&alice_public).unwrap(); + assert_eq!(alice_secret.secret_bytes(), bob_secret.secret_bytes()); + assert_ne!(alice_secret.secret_bytes(), &[0; 32]); +} + +// The provider must refuse RSA private keys independently of their DER wrapper. +#[test] +fn rsa_private_keys_are_refused() { + use rsa::pkcs1::EncodeRsaPrivateKey; + use rsa::pkcs8::EncodePrivateKey; + let key = rsa::RsaPrivateKey::new( + &mut getrandom::rand_core::UnwrapErr(getrandom::SysRng), + 2048, + ) + .unwrap(); + let keys = [ + PrivateKeyDer::Pkcs1(PrivatePkcs1KeyDer::from( + key.to_pkcs1_der().unwrap().as_bytes().to_vec(), + )), + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( + key.to_pkcs8_der().unwrap().as_bytes().to_vec(), + )), + ]; + for key in keys { + assert!( + super::provider() + .key_provider + .load_private_key(key) + .is_err() + ); + } +} + +// AES traffic-secret export must preserve the negotiated key size. +#[test] +fn aes128_exports_aes128_traffic_secrets() { + let secret = super::aead::gcm::Tls13Aes128Gcm + .extract_keys(AeadKey::from([0u8; 32]), Iv::from([0; 12])) + .unwrap(); + assert!(matches!(secret, ConnectionTrafficSecrets::Aes128Gcm { .. })); +} + +// Truncated TLS 1.2 records are peer errors, including with overflow checks enabled. +#[cfg(feature = "tls12")] +#[test] +fn chacha20_rejects_short_tls12_record() { + use rustls::crypto::cipher::{InboundOpaqueMessage, Tls12AeadAlgorithm}; + let mut decrypter = Tls12AeadAlgorithm::decrypter( + &super::aead::chacha20::Chacha20Poly1305, + AeadKey::from([0u8; 32]), + &[0; 12], + ); + let mut bytes = [0u8; 15]; + let message = InboundOpaqueMessage::new( + rustls::ContentType::ApplicationData, + rustls::ProtocolVersion::TLSv1_2, + &mut bytes, + ); + assert!(matches!( + decrypter.decrypt(message, 0), + Err(Error::DecryptError) + )); +} + +// A P-384 SEC1 algorithm identifier must not be ignored for a 32-byte scalar. +#[test] +fn sec1_curve_identifier_is_enforced() { + use der::Encode; + use pkcs8::AssociatedOid; + let key = sec1::EcPrivateKey { + private_key: &[1; 32], + parameters: Some(sec1::EcParameters::NamedCurve(p384::NistP384::OID)), + public_key: None, + } + .to_der() + .unwrap(); + assert!( + super::provider() + .key_provider + .load_private_key(PrivateKeyDer::Sec1(key.into())) + .is_err() + ); +} + +// TLS 1.2 AES export uses its 4-byte fixed IV and 8-byte explicit nonce offset. +#[cfg(feature = "tls12")] +#[test] +fn tls12_aes_export_preserves_nonce_and_algorithm() { + use rustls::crypto::cipher::Tls12AeadAlgorithm; + let secret = Tls12AeadAlgorithm::extract_keys( + &super::aead::gcm::Tls12Aes256Gcm, + AeadKey::from([0; 32]), + &[1; 4], + &[2; 8], + ) + .unwrap(); + let ConnectionTrafficSecrets::Aes256Gcm { iv, .. } = secret else { + panic!("AES-256 export changed the negotiated algorithm"); + }; + assert_eq!(iv.as_ref(), &[1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2]); +} + +// RFC 8446 section 4.2.3 prohibits RSA keys smaller than 2048 bits in TLS 1.3. +#[test] +fn rsa_verification_rejects_small_modulus() { + use rsa::pkcs1::EncodeRsaPublicKey; + use signature::{SignatureEncoding, Signer}; + let key = rsa::RsaPrivateKey::new( + &mut getrandom::rand_core::UnwrapErr(getrandom::SysRng), + 1024, + ) + .unwrap(); + let public = key.to_public_key().to_pkcs1_der().unwrap(); + let signing = rsa::pkcs1v15::SigningKey::::new(key); + let signature: rsa::pkcs1v15::Signature = signing.try_sign(b"TLS signature").unwrap(); + assert!( + super::verify::rsa::RSA_PKCS1_SHA256 + .verify_signature(public.as_bytes(), b"TLS signature", &signature.to_vec()) + .is_err() + ); +} + +#[cfg(feature = "std")] +mod tls; diff --git a/crates/coordinode-crypto/src/tests/tls.rs b/crates/coordinode-crypto/src/tests/tls.rs new file mode 100644 index 0000000..97d05f7 --- /dev/null +++ b/crates/coordinode-crypto/src/tests/tls.rs @@ -0,0 +1,301 @@ +use alloc::{format, sync::Arc, vec, vec::Vec}; +use std::io::{Read, Write}; + +use rcgen::{BasicConstraints, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair}; +use rustls::crypto::CryptoProvider; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; +use rustls::{ + ClientConfig, ClientConnection, Error, RootCertStore, ServerConfig, ServerConnection, +}; + +struct Authority { + issuer: rcgen::Issuer<'static, KeyPair>, + root: CertificateDer<'static>, +} + +impl Authority { + fn new() -> Self { + let key = KeyPair::generate().unwrap(); + let mut params = CertificateParams::new(Vec::new()).unwrap(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let root = params.self_signed(&key).unwrap().der().clone(); + Self { + issuer: rcgen::Issuer::new(params, key), + root, + } + } + + fn leaf( + &self, + algorithm: &'static rcgen::SignatureAlgorithm, + expired: bool, + ) -> (Vec>, PrivateKeyDer<'static>) { + let key = KeyPair::generate_for(algorithm).unwrap(); + let mut params = CertificateParams::new(vec!["localhost".into()]).unwrap(); + params.extended_key_usages = vec![ + ExtendedKeyUsagePurpose::ServerAuth, + ExtendedKeyUsagePurpose::ClientAuth, + ]; + if expired { + params.not_before = rcgen::date_time_ymd(1999, 1, 1); + params.not_after = rcgen::date_time_ymd(2000, 1, 1); + } + let certificate = params.signed_by(&key, &self.issuer).unwrap(); + (vec![certificate.der().clone()], key.into()) + } + + fn roots(&self) -> RootCertStore { + let mut roots = RootCertStore::empty(); + roots.add(self.root.clone()).unwrap(); + roots + } +} + +fn configs( + server_provider: CryptoProvider, + client_provider: CryptoProvider, + authority: &Authority, + leaf: &(Vec>, PrivateKeyDer<'static>), + mutual: bool, +) -> (ServerConfig, ClientConfig) { + let server_provider = Arc::new(server_provider); + let server = ServerConfig::builder_with_provider(Arc::clone(&server_provider)) + .with_safe_default_protocol_versions() + .unwrap(); + let server = if mutual { + let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider( + Arc::new(authority.roots()), + server_provider, + ) + .build() + .unwrap(); + server.with_client_cert_verifier(verifier) + } else { + server.with_no_client_auth() + }; + let server = server + .with_single_cert(leaf.0.clone(), leaf.1.clone_key()) + .unwrap(); + let client = ClientConfig::builder_with_provider(Arc::new(client_provider)) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(authority.roots()); + let client = if mutual { + client + .with_client_auth_cert(leaf.0.clone(), leaf.1.clone_key()) + .unwrap() + } else { + client.with_no_client_auth() + }; + (server, client) +} + +fn handshake(server: ServerConfig, client: ClientConfig, name: &'static str) -> Result<(), Error> { + let mut server = ServerConnection::new(Arc::new(server))?; + let mut client = ClientConnection::new(Arc::new(client), ServerName::try_from(name).unwrap())?; + for _ in 0..32 { + let mut flight = Vec::new(); + client.write_tls(&mut flight).unwrap(); + server.read_tls(&mut flight.as_slice()).unwrap(); + server.process_new_packets()?; + flight.clear(); + server.write_tls(&mut flight).unwrap(); + client.read_tls(&mut flight.as_slice()).unwrap(); + client.process_new_packets()?; + if !client.is_handshaking() && !server.is_handshaking() { + client.writer().write_all(b"client data").unwrap(); + flight.clear(); + client.write_tls(&mut flight).unwrap(); + server.read_tls(&mut flight.as_slice()).unwrap(); + server.process_new_packets()?; + let mut payload = [0; 11]; + server.reader().read_exact(&mut payload).unwrap(); + if &payload != b"client data" { + return Err(Error::General("decrypted client data changed".into())); + } + server.writer().write_all(b"server data").unwrap(); + flight.clear(); + server.write_tls(&mut flight).unwrap(); + client.read_tls(&mut flight.as_slice()).unwrap(); + client.process_new_packets()?; + client.reader().read_exact(&mut payload).unwrap(); + if &payload != b"server data" { + return Err(Error::General("decrypted server data changed".into())); + } + return Ok(()); + } + } + Err(Error::General("TLS handshake did not complete".into())) +} + +fn profile( + mut provider: CryptoProvider, + suite: rustls::CipherSuite, + group: rustls::NamedGroup, +) -> CryptoProvider { + provider + .cipher_suites + .retain(|candidate| candidate.suite() == suite); + provider + .kx_groups + .retain(|candidate| candidate.name() == group); + assert_eq!(provider.cipher_suites.len(), 1); + assert_eq!(provider.kx_groups.len(), 1); + provider +} + +fn interoperability(tls13: bool) { + let ca = Authority::new(); + for algorithm in [ + &rcgen::PKCS_ECDSA_P256_SHA256, + &rcgen::PKCS_ECDSA_P384_SHA384, + &rcgen::PKCS_ED25519, + ] { + let leaf = ca.leaf(algorithm, false); + for suite in super::super::ALL_CIPHER_SUITES { + if suite.tls13().is_some() != tls13 { + continue; + } + if !tls13 && !format!("{:?}", suite.suite()).contains("ECDSA") { + continue; + } + for group in super::super::kx::ALL_KX_GROUPS { + let our = || profile(super::super::provider(), suite.suite(), group.name()); + let other = || { + profile( + rustls::crypto::ring::default_provider(), + suite.suite(), + group.name(), + ) + }; + let (server, client) = configs(our(), other(), &ca, &leaf, true); + handshake(server, client, "localhost").unwrap(); + let (server, client) = configs(other(), our(), &ca, &leaf, true); + handshake(server, client, "localhost").unwrap(); + } + } + } +} + +// Each TLS 1.3 AEAD, exchange group and allowed local key works against an independent backend. +#[test] +fn tls13_interoperability_and_mutual_authentication() { + interoperability(true); +} + +// TLS 1.2 record and signature adapters preserve the same bidirectional contract. +#[cfg(feature = "tls12")] +#[test] +fn tls12_interoperability_and_mutual_authentication() { + interoperability(false); +} + +// Updating the primitive backend must preserve rustls name, trust and time checks. +#[test] +fn certificate_validation_is_not_bypassed() { + let ca = Authority::new(); + let leaf = ca.leaf(&rcgen::PKCS_ECDSA_P256_SHA256, false); + let (server, client) = configs( + super::super::provider(), + super::super::provider(), + &ca, + &leaf, + false, + ); + assert!(matches!( + handshake(server, client, "wrong.invalid"), + Err(Error::InvalidCertificate(_)) + )); + let expired = ca.leaf(&rcgen::PKCS_ECDSA_P256_SHA256, true); + let (server, client) = configs( + super::super::provider(), + super::super::provider(), + &ca, + &expired, + false, + ); + assert!(matches!( + handshake(server, client, "localhost"), + Err(Error::InvalidCertificate(_)) + )); + let other = Authority::new(); + let (server, _) = configs( + super::super::provider(), + super::super::provider(), + &ca, + &leaf, + false, + ); + let (_, client) = configs( + super::super::provider(), + super::super::provider(), + &other, + &leaf, + false, + ); + assert!(matches!( + handshake(server, client, "localhost"), + Err(Error::InvalidCertificate(_)) + )); +} + +fn rsa_oracle_key() -> KeyPair { + use rsa::pkcs8::EncodePrivateKey; + let key = rsa::RsaPrivateKey::new( + &mut getrandom::rand_core::UnwrapErr(getrandom::SysRng), + 2048, + ) + .unwrap(); + let der = rustls::pki_types::PrivatePkcs8KeyDer::from( + key.to_pkcs8_der().unwrap().as_bytes().to_vec(), + ); + KeyPair::from_pkcs8_der_and_sign_algo(&der, &rcgen::PKCS_RSA_SHA256).unwrap() +} + +// RSA issuer verification remains supported even though every local identity uses EC or Ed25519. +#[test] +fn rsa_issuer_with_ecdsa_identities_works() { + let key = rsa_oracle_key(); + let mut params = CertificateParams::new(Vec::new()).unwrap(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + let root = params.self_signed(&key).unwrap().der().clone(); + let ca = Authority { + issuer: rcgen::Issuer::new(params, key), + root, + }; + let leaf = ca.leaf(&rcgen::PKCS_ECDSA_P256_SHA256, false); + let (server, client) = configs( + super::super::provider(), + super::super::provider(), + &ca, + &leaf, + true, + ); + handshake(server, client, "localhost").unwrap(); +} + +// A client can verify an external TLS 1.2 RSA server without acquiring a local RSA signing path. +#[cfg(feature = "tls12")] +#[test] +fn rsa_tls12_servers_remain_verifiable() { + let ca = Authority::new(); + let key = rsa_oracle_key(); + let mut params = CertificateParams::new(vec!["localhost".into()]).unwrap(); + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + let certificate = params.signed_by(&key, &ca.issuer).unwrap(); + let leaf = (vec![certificate.der().clone()], key.into()); + for suite in [ + rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, + rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + rustls::CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + ] { + let server_provider = profile( + rustls::crypto::ring::default_provider(), + suite, + rustls::NamedGroup::X25519, + ); + let client_provider = profile(super::super::provider(), suite, rustls::NamedGroup::X25519); + let (server, client) = configs(server_provider, client_provider, &ca, &leaf, false); + handshake(server, client, "localhost").unwrap(); + } +} diff --git a/crates/coordinode-crypto/src/verify.rs b/crates/coordinode-crypto/src/verify.rs new file mode 100644 index 0000000..7eae04a --- /dev/null +++ b/crates/coordinode-crypto/src/verify.rs @@ -0,0 +1,46 @@ +use rustls::SignatureScheme; +use rustls::crypto::WebPkiSupportedAlgorithms; + +use self::ecdsa::{ECDSA_P256_SHA256, ECDSA_P256_SHA384, ECDSA_P384_SHA256, ECDSA_P384_SHA384}; +use self::eddsa::ED25519; +use self::rsa::{ + RSA_PKCS1_SHA256, RSA_PKCS1_SHA384, RSA_PKCS1_SHA512, RSA_PSS_SHA256, RSA_PSS_SHA384, + RSA_PSS_SHA512, +}; + +pub static ALGORITHMS: WebPkiSupportedAlgorithms = WebPkiSupportedAlgorithms { + all: &[ + ECDSA_P256_SHA256, + ECDSA_P256_SHA384, + ECDSA_P384_SHA256, + ECDSA_P384_SHA384, + ED25519, + RSA_PKCS1_SHA256, + RSA_PKCS1_SHA384, + RSA_PKCS1_SHA512, + RSA_PSS_SHA256, + RSA_PSS_SHA384, + RSA_PSS_SHA512, + ], + mapping: &[ + ( + SignatureScheme::ECDSA_NISTP384_SHA384, + &[ECDSA_P384_SHA384, ECDSA_P256_SHA384], + ), + ( + SignatureScheme::ECDSA_NISTP256_SHA256, + &[ECDSA_P256_SHA256, ECDSA_P384_SHA256], + ), + (SignatureScheme::ED25519, &[ED25519]), + (SignatureScheme::RSA_PKCS1_SHA256, &[RSA_PKCS1_SHA256]), + (SignatureScheme::RSA_PKCS1_SHA384, &[RSA_PKCS1_SHA384]), + (SignatureScheme::RSA_PKCS1_SHA512, &[RSA_PKCS1_SHA512]), + (SignatureScheme::RSA_PSS_SHA256, &[RSA_PSS_SHA256]), + (SignatureScheme::RSA_PSS_SHA384, &[RSA_PSS_SHA384]), + (SignatureScheme::RSA_PSS_SHA512, &[RSA_PSS_SHA512]), + ], +}; + +pub mod ecdsa; +pub mod eddsa; +pub mod rsa; diff --git a/crates/coordinode-crypto/src/verify/ecdsa.rs b/crates/coordinode-crypto/src/verify/ecdsa.rs new file mode 100644 index 0000000..1a0b5fe --- /dev/null +++ b/crates/coordinode-crypto/src/verify/ecdsa.rs @@ -0,0 +1,51 @@ +use der::Decode; +use digest::Digest; +use pki_types::{AlgorithmIdentifier, InvalidSignature, SignatureVerificationAlgorithm, alg_id}; +use signature::hazmat::PrehashVerifier; + +macro_rules! impl_generic_ecdsa_verifer { + ( + $name:ident, + $verifier:ident, + $public_key_algo:expr, + $signature_alg_id:expr, + $verifying_key:ty, + $signature:ty, + $hash:ty + ) => { + #[derive(Debug)] + struct $verifier; + + impl SignatureVerificationAlgorithm for $verifier { + fn public_key_alg_id(&self) -> AlgorithmIdentifier { + $public_key_algo + } + + fn signature_alg_id(&self) -> AlgorithmIdentifier { + $signature_alg_id + } + + fn verify_signature( + &self, + public_key: &[u8], + message: &[u8], + signature: &[u8], + ) -> Result<(), InvalidSignature> { + let signature = <$signature>::from_der(signature).map_err(|_| InvalidSignature)?; + let verifying_key = + <$verifying_key>::from_sec1_bytes(public_key).map_err(|_| InvalidSignature)?; + let digest = &<$hash>::digest(message); + verifying_key + .verify_prehash(digest, &signature) + .map_err(|_| InvalidSignature) + } + } + + pub const $name: &dyn SignatureVerificationAlgorithm = &$verifier; + }; +} + +impl_generic_ecdsa_verifer! {ECDSA_P256_SHA256, P256Sha256Verifier, alg_id::ECDSA_P256, alg_id::ECDSA_SHA256, p256::ecdsa::VerifyingKey, p256::ecdsa::DerSignature, sha2::Sha256} +impl_generic_ecdsa_verifer! {ECDSA_P256_SHA384, P256Sha384Verifier, alg_id::ECDSA_P256, alg_id::ECDSA_SHA384, p256::ecdsa::VerifyingKey, p256::ecdsa::DerSignature, sha2::Sha384} +impl_generic_ecdsa_verifer! {ECDSA_P384_SHA256, P384Sha256Verifier, alg_id::ECDSA_P384, alg_id::ECDSA_SHA256, p384::ecdsa::VerifyingKey, p384::ecdsa::DerSignature, sha2::Sha256} +impl_generic_ecdsa_verifer! {ECDSA_P384_SHA384, P384Sha384Verifier, alg_id::ECDSA_P384, alg_id::ECDSA_SHA384, p384::ecdsa::VerifyingKey, p384::ecdsa::DerSignature, sha2::Sha384} diff --git a/crates/coordinode-crypto/src/verify/eddsa.rs b/crates/coordinode-crypto/src/verify/eddsa.rs new file mode 100644 index 0000000..10a7cb0 --- /dev/null +++ b/crates/coordinode-crypto/src/verify/eddsa.rs @@ -0,0 +1,32 @@ +use pki_types::{AlgorithmIdentifier, InvalidSignature, SignatureVerificationAlgorithm, alg_id}; +use signature::Verifier; + +#[derive(Debug)] +struct Ed25519Verify; + +impl SignatureVerificationAlgorithm for Ed25519Verify { + fn public_key_alg_id(&self) -> AlgorithmIdentifier { + alg_id::ED25519 + } + + fn signature_alg_id(&self) -> AlgorithmIdentifier { + alg_id::ED25519 + } + + fn verify_signature( + &self, + public_key: &[u8], + message: &[u8], + signature: &[u8], + ) -> Result<(), InvalidSignature> { + let public_key = public_key.try_into().map_err(|_| InvalidSignature)?; + let signature = + ed25519_dalek::Signature::from_slice(signature).map_err(|_| InvalidSignature)?; + ed25519_dalek::VerifyingKey::from_bytes(public_key) + .map_err(|_| InvalidSignature)? + .verify(message, &signature) + .map_err(|_| InvalidSignature) + } +} + +pub const ED25519: &dyn SignatureVerificationAlgorithm = &Ed25519Verify; diff --git a/crates/coordinode-crypto/src/verify/rsa.rs b/crates/coordinode-crypto/src/verify/rsa.rs new file mode 100644 index 0000000..155f85d --- /dev/null +++ b/crates/coordinode-crypto/src/verify/rsa.rs @@ -0,0 +1,97 @@ +use pki_types::{AlgorithmIdentifier, InvalidSignature, SignatureVerificationAlgorithm, alg_id}; +use rsa::traits::PublicKeyParts; +use rsa::{pkcs1v15, pss}; +use sha2::{Sha256, Sha384, Sha512}; +use signature::Verifier; + +macro_rules! impl_generic_rsa_verifer { + ( + $name:ident, + $verifier:ident, + $public_key_algo:expr, + $signature_alg_id:expr, + $verifying_key:ty, + $signature:ty + ) => { + #[derive(Debug)] + struct $verifier; + + impl SignatureVerificationAlgorithm for $verifier { + fn public_key_alg_id(&self) -> AlgorithmIdentifier { + $public_key_algo + } + + fn signature_alg_id(&self) -> AlgorithmIdentifier { + $signature_alg_id + } + + fn verify_signature( + &self, + public_key: &[u8], + message: &[u8], + signature: &[u8], + ) -> Result<(), InvalidSignature> { + let public_key = crate::public_key::rsa_from_pkcs1(public_key)?; + if signature.len() != public_key.size() { + return Err(InvalidSignature); + } + let signature = <$signature>::try_from(signature).map_err(|_| InvalidSignature)?; + <$verifying_key>::new(public_key) + .verify(message, &signature) + .map_err(|_| InvalidSignature) + } + } + + pub const $name: &dyn SignatureVerificationAlgorithm = &$verifier; + }; +} + +impl_generic_rsa_verifer!( + RSA_PKCS1_SHA256, + RsaPkcs1Sha256Verifier, + alg_id::RSA_ENCRYPTION, + alg_id::RSA_PKCS1_SHA256, + pkcs1v15::VerifyingKey, + pkcs1v15::Signature +); +impl_generic_rsa_verifer!( + RSA_PKCS1_SHA384, + RsaPkcs1Sha384Verifier, + alg_id::RSA_ENCRYPTION, + alg_id::RSA_PKCS1_SHA384, + pkcs1v15::VerifyingKey, + pkcs1v15::Signature +); +impl_generic_rsa_verifer!( + RSA_PKCS1_SHA512, + RsaPkcs1Sha512Verifier, + alg_id::RSA_ENCRYPTION, + alg_id::RSA_PKCS1_SHA512, + pkcs1v15::VerifyingKey, + pkcs1v15::Signature +); + +impl_generic_rsa_verifer!( + RSA_PSS_SHA256, + RsaPssSha256Verifier, + alg_id::RSA_ENCRYPTION, + alg_id::RSA_PSS_SHA256, + pss::VerifyingKey, + pss::Signature +); +impl_generic_rsa_verifer!( + RSA_PSS_SHA384, + RsaPssSha384Verifier, + alg_id::RSA_ENCRYPTION, + alg_id::RSA_PSS_SHA384, + pss::VerifyingKey, + pss::Signature +); +impl_generic_rsa_verifer!( + RSA_PSS_SHA512, + RsaPssSha512Verifier, + alg_id::RSA_ENCRYPTION, + alg_id::RSA_PSS_SHA512, + pss::VerifyingKey, + pss::Signature +); diff --git a/deny.toml b/deny.toml index 2a1dc60..8d8414c 100644 --- a/deny.toml +++ b/deny.toml @@ -5,47 +5,25 @@ # fall back to cargo-deny defaults. [advisories] -# RUSTSEC-2023-0071: "Marvin Attack" timing sidechannel in `rsa` 0.9.x. -# Chains: rsa -> jsonwebtoken (rust_crypto backend) -> structured-proxy, and -# rsa -> rustls-rustcrypto (outbound TLS provider) -> structured-proxy. +# RUSTSEC-2023-0071: "Marvin Attack" timing sidechannel in `rsa`. +# Chains: rsa -> structured-proxy (the `rust_crypto` JWT verifier), and +# rsa -> coordinode-crypto (the RustCrypto rustls provider) -> structured-proxy. # # Ignored deliberately. See https://github.com/structured-world/structured-proxy/issues/48 # 1. No fix exists: the advisory has patched:[] / unaffected:[] in the RustSec -# DB, and it names both the latest stable `rsa` (0.9.10) and the latest -# pre-release (0.10.0-rc.18) as affected. There is nothing to upgrade to, -# release candidates included. -# 2. We keep pure-Rust crypto over C: jsonwebtoken's `rust_crypto` backend over +# DB and covers the `rsa` release this tree resolves (0.10.0-rc.19). +# 2. We keep pure-Rust crypto over C: the RustCrypto JWT backend over # `aws_lc_rs`, and the RustCrypto rustls provider over `ring` / aws-lc, # required by no-C consumers (e.g. CoordiNode ADR-013). # 3. RSA here only ever verifies signatures with public keys: JWTs, and the -# certificates and handshake signatures of the TLS servers the proxy calls -# (it presents no client certificate). Marvin targets private-key timing -# (decrypt/sign), which never runs on a verify path. +# certificates and handshake signatures of TLS peers. The JWT backend +# refuses RSA signer and private-key factories, and coordinode-crypto +# admits only P-256, P-384 and Ed25519 local identities. Marvin targets +# private-key timing (decrypt/sign), which never runs on a verify path. # Revisit when RustCrypto ships a constant-time `rsa` stable release. # # Both chains come with the `rust_crypto` feature: a `default-features = false` -# build links neither, and CI checks that none of the crates ignored here is in -# it. -# -# RUSTSEC-2026-0049 / -0098 / -0099 / -0104: `rustls-webpki` 0.102. -# Chain: rustls-webpki 0.102.8 -> rustls-rustcrypto 0.0.2-alpha -> structured-proxy. -# The advisories are in CRL handling and name-constraint checks. The provider -# only reads algorithm identifier constants from that crate (`webpki::alg_id`, -# in its src/verify/*.rs); certificates are verified by rustls' own -# `rustls-webpki` 0.103.15, in which all four are patched, so the affected code -# never runs. Remove these entries with the first `rustls-rustcrypto` release -# after 0.0.2-alpha: upstream master has dropped the dependency -# (RustCrypto/rustls-rustcrypto#107). Tracking: structured-world/structured-proxy#98 -# -# RUSTSEC-2024-0436: `paste` is unmaintained (no vulnerability). -# Chain: paste -> rustls-rustcrypto 0.0.2-alpha -> structured-proxy. -# A proc-macro that only runs at build time. Upstream is replacing it -# (RustCrypto/rustls-rustcrypto#101); remove with the same release as above. +# build links neither, and CI checks that `rsa` is not in it. ignore = [ - { id = "RUSTSEC-2023-0071", reason = "No fixed `rsa` release exists: the advisory is patched:[] and names both the latest stable (0.9.10) and the latest pre-release (0.10.0-rc.18) as affected. We deliberately keep pure-Rust crypto (jsonwebtoken's `rust_crypto` backend, the RustCrypto rustls provider) over C. RSA only verifies signatures with public keys (JWTs, TLS server certificates and handshake signatures); Marvin targets private-key timing, not exploitable on a verify path. Revisit when RustCrypto ships a constant-time `rsa` stable. Tracking: structured-world/structured-proxy#48" }, - { id = "RUSTSEC-2026-0049", reason = "rustls-webpki 0.102 via rustls-rustcrypto 0.0.2-alpha, which only reads its `alg_id` constants; certificates (and CRLs) are verified by rustls' own rustls-webpki 0.103.15, which is patched. Remove with the next rustls-rustcrypto release. Tracking: structured-world/structured-proxy#98" }, - { id = "RUSTSEC-2026-0098", reason = "rustls-webpki 0.102 via rustls-rustcrypto 0.0.2-alpha, which only reads its `alg_id` constants; name constraints are checked by rustls' own rustls-webpki 0.103.15, which is patched. Remove with the next rustls-rustcrypto release. Tracking: structured-world/structured-proxy#98" }, - { id = "RUSTSEC-2026-0099", reason = "rustls-webpki 0.102 via rustls-rustcrypto 0.0.2-alpha, which only reads its `alg_id` constants; name constraints are checked by rustls' own rustls-webpki 0.103.15, which is patched. Remove with the next rustls-rustcrypto release. Tracking: structured-world/structured-proxy#98" }, - { id = "RUSTSEC-2026-0104", reason = "rustls-webpki 0.102 via rustls-rustcrypto 0.0.2-alpha, which only reads its `alg_id` constants; no CRL is parsed through it (the proxy configures no CRLs, and rustls uses its own rustls-webpki 0.103.15, which is patched). Remove with the next rustls-rustcrypto release. Tracking: structured-world/structured-proxy#98" }, - { id = "RUSTSEC-2024-0436", reason = "`paste` is unmaintained, not vulnerable: a build-time proc-macro of rustls-rustcrypto 0.0.2-alpha, which upstream is replacing (RustCrypto/rustls-rustcrypto#101). Remove with the next rustls-rustcrypto release. Tracking: structured-world/structured-proxy#98" }, + { id = "RUSTSEC-2023-0071", reason = "No fixed `rsa` release exists: the advisory is patched:[] and covers the resolved 0.10.0-rc.19. We deliberately keep pure-Rust crypto (the RustCrypto JWT backend and rustls provider) over C. RSA only verifies signatures with public keys (JWTs, TLS peer certificates and handshake signatures); RSA private-key operations are refused, and Marvin targets private-key timing, so it is not exploitable on a verify path. Revisit when RustCrypto ships a constant-time `rsa` stable. Tracking: structured-world/structured-proxy#48" }, ] diff --git a/release-plz.toml b/release-plz.toml index 2162bf7..4734b89 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -3,3 +3,9 @@ name = "structured-proxy" # Preserve the existing tag scheme (v1.0.0, v1.0.1, ...) instead of the # release-plz default of "{package}-v{version}", so tag history stays continuous. git_tag_name = "v{{ version }}" + +[[package]] +name = "coordinode-crypto" +# Tagged as coordinode-crypto-v{version} and published to crates.io; no GitHub +# release, since a created release starts the binary packaging workflow. +git_release_enable = false diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..3501136 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +style_edition = "2024" diff --git a/src/auth/authz.rs b/src/auth/authz.rs index fafe258..efa4908 100644 --- a/src/auth/authz.rs +++ b/src/auth/authz.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use std::time::Duration; use axum::extract::State; -use axum::http::header::{HeaderName, HeaderValue, HOST}; +use axum::http::header::{HOST, HeaderName, HeaderValue}; use axum::http::{HeaderMap, StatusCode, Uri}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; @@ -18,10 +18,10 @@ use tonic::transport::Channel; use envoy_types::pb::envoy::config::core::v3::HeaderValueOption; use envoy_types::pb::envoy::service::auth::v3::{ + AttributeContext, CheckRequest, CheckResponse, DeniedHttpResponse, attribute_context::{HttpRequest, Request as AttrRequest}, authorization_client::AuthorizationClient, check_response::HttpResponse as EnvoyHttpResponse, - AttributeContext, CheckRequest, CheckResponse, DeniedHttpResponse, }; use crate::config::AuthzConfig; @@ -233,8 +233,8 @@ fn denied_to_response(denied: DeniedHttpResponse) -> Response { mod tests { use super::*; use envoy_types::pb::envoy::config::core::v3::HeaderValue as EnvoyHeaderValue; - use envoy_types::pb::envoy::r#type::v3::HttpStatus; use envoy_types::pb::envoy::service::auth::v3::{CheckResponse, OkHttpResponse}; + use envoy_types::pb::envoy::r#type::v3::HttpStatus; use envoy_types::pb::google::rpc::Status as RpcStatus; fn hvo(key: &str, value: &str) -> HeaderValueOption { @@ -395,8 +395,8 @@ mod tests { #[tokio::test] async fn fail_closed_returns_503_when_authz_unreachable() { - use axum::routing::get; use axum::Router; + use axum::routing::get; use tower::ServiceExt; let authz = Authz::build(&AuthzConfig { @@ -424,8 +424,8 @@ mod tests { #[tokio::test] async fn fail_open_passes_through_when_authz_unreachable() { - use axum::routing::get; use axum::Router; + use axum::routing::get; use tower::ServiceExt; let authz = Authz::build(&AuthzConfig { diff --git a/src/auth/cache.rs b/src/auth/cache.rs index df20350..8ebcd03 100644 --- a/src/auth/cache.rs +++ b/src/auth/cache.rs @@ -11,8 +11,8 @@ //! insert that finds the cache full sweeps them at most once per second, so it //! may leave a new token uncached until the next sweep frees room. -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; // no-std: caller-provided monotonic clock use std::time::Instant; diff --git a/src/auth/crypto.rs b/src/auth/crypto.rs index be3728a..7d0813a 100644 --- a/src/auth/crypto.rs +++ b/src/auth/crypto.rs @@ -1,22 +1,19 @@ //! Process-wide `jsonwebtoken` crypto provider selection. //! -//! `jsonwebtoken` infers its provider from its own two backend features, and -//! with both on it cannot: it falls back to a provider that panics on first -//! use. Cargo features being additive, that combination arrives on its own. -//! Either this crate's `rust_crypto` and `aws_lc_rs` are both enabled (two -//! dependents asking for different backends, or `--all-features`, which is what -//! docs.rs and `cargo-semver-checks` use), or one of ours is enabled while -//! another crate in the graph turns on the other `jsonwebtoken` feature -//! directly. The second case looks single-backend from here, so the provider is -//! installed explicitly whichever backend this crate compiled with. +//! The RustCrypto backend uses jsonwebtoken's extension interface with current +//! primitives. The optional aws-lc backend retains its existing precedence. +//! Explicit installation avoids feature inference when dependencies enable +//! different jsonwebtoken backends in one process. #[cfg(test)] mod tests; +#[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] +mod rust_crypto; + /// The provider this crate installs. /// -/// `aws_lc_rs` wins whenever it is compiled in: it is constant-time and -/// advisory-free, while `rust_crypto` pulls in `rsa` (RUSTSEC-2023-0071). +/// `aws_lc_rs` retains precedence when both optional backends are compiled in. #[cfg(feature = "aws_lc_rs")] pub(crate) fn preferred_provider() -> &'static jsonwebtoken::crypto::CryptoProvider { &jsonwebtoken::crypto::aws_lc::DEFAULT_PROVIDER @@ -26,7 +23,7 @@ pub(crate) fn preferred_provider() -> &'static jsonwebtoken::crypto::CryptoProvi /// compiled with. #[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] pub(crate) fn preferred_provider() -> &'static jsonwebtoken::crypto::CryptoProvider { - &jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER + &rust_crypto::DEFAULT_PROVIDER } /// Select the `jsonwebtoken` crypto provider for this process. @@ -35,16 +32,16 @@ pub(crate) fn preferred_provider() -> &'static jsonwebtoken::crypto::CryptoProvi /// JWT. It is idempotent, and installs the backend this crate was built with, /// so `jsonwebtoken` never has to infer one. /// -/// A plain [`ProxyServer`](crate::ProxyServer) deployment needs no call: the -/// server does this while it is being built. It is public for the case the -/// server cannot cover, which is also how the ambiguity arises in the first +/// A plain [`ProxyServer`](crate::ProxyServer) deployment with `auth.mode: +/// "jwt"` needs no call: building the built-in verifier does this. A proxy +/// without it installs nothing and leaves the provider to its host. It is +/// public for the case the server cannot cover, which is also how the ambiguity arises in the first /// place: another crate in the graph uses `jsonwebtoken` too and may reach it /// first. Call this at the top of `main` and every consumer is covered, /// whichever runs first. /// -/// With both backends linked the choice is `aws_lc_rs`: constant-time, and free -/// of the `rsa` advisory `rust_crypto` carries. A process that wants a different -/// one installs it through +/// With both backends linked the choice is `aws_lc_rs`. A process that wants +/// a different one installs it through /// [`CryptoProvider::install_default`](jsonwebtoken::crypto::CryptoProvider::install_default) /// before calling this, and the earlier choice stands. pub fn install_default_crypto_provider() { diff --git a/src/auth/crypto/rust_crypto.rs b/src/auth/crypto/rust_crypto.rs new file mode 100644 index 0000000..bb39b26 --- /dev/null +++ b/src/auth/crypto/rust_crypto.rs @@ -0,0 +1,295 @@ +//! jsonwebtoken adapter over current RustCrypto primitives. +//! +//! JWT parsing, algorithm selection and claims validation remain in jsonwebtoken. +//! This module implements its crypto extension traits; RSA is public-key only. + +use core::marker::PhantomData; + +use hmac::{KeyInit, Mac}; +use jsonwebtoken::crypto::{CryptoProvider, JwtSigner, JwtVerifier, KeyUtils}; +use jsonwebtoken::errors::{ErrorKind, Result}; +use jsonwebtoken::{Algorithm, AlgorithmFamily, DecodingKey, DecodingKeyKind, EncodingKey}; +use p256::pkcs8::DecodePrivateKey; +use rsa::traits::PublicKeyParts; +use sha2::{Sha256, Sha384, Sha512}; +use signature_v3::SignatureEncoding; + +mod keys; + +pub(super) static DEFAULT_PROVIDER: CryptoProvider = CryptoProvider { + signer_factory: signer, + verifier_factory: verifier, + key_utils: KeyUtils { + rsa_pub_components_from_private_key: |_| Err(ErrorKind::InvalidAlgorithm.into()), + rsa_pub_components_from_public_key: keys::rsa_components, + ec_pub_components_from_private_key: keys::ec_components, + ed_pub_components_from_private_key: keys::ed_components, + compute_digest: keys::digest, + }, +}; + +struct Signing { + key: K, + algorithm: Algorithm, + signature: PhantomData, +} + +impl signature::Signer> for Signing +where + K: signature_v3::Signer, + S: SignatureEncoding, +{ + fn try_sign(&self, message: &[u8]) -> core::result::Result, signature::Error> { + let signature: S = self + .key + .try_sign(message) + .map_err(|_| signature::Error::new())?; + Ok(signature.to_vec()) + } +} + +impl JwtSigner for Signing +where + K: signature_v3::Signer, + S: SignatureEncoding, +{ + fn algorithm(&self) -> Algorithm { + self.algorithm + } +} + +struct Verification { + key: K, + algorithm: Algorithm, + signature_len: usize, + signature: PhantomData, +} + +impl signature::Verifier> for Verification +where + K: signature_v3::Verifier, + S: SignatureEncoding, +{ + fn verify( + &self, + message: &[u8], + bytes: &Vec, + ) -> core::result::Result<(), signature::Error> { + // In particular, do not allocate an attacker-sized RSA signature integer. + if bytes.len() != self.signature_len { + return Err(signature::Error::new()); + } + let signature = S::try_from(bytes.as_slice()).map_err(|_| signature::Error::new())?; + self.key + .verify(message, &signature) + .map_err(|_| signature::Error::new()) + } +} + +impl JwtVerifier for Verification +where + K: signature_v3::Verifier, + S: SignatureEncoding, +{ + fn algorithm(&self) -> Algorithm { + self.algorithm + } +} + +struct JwtMac { + initial: M, + algorithm: Algorithm, +} + +impl signature::Signer> for JwtMac { + fn try_sign(&self, message: &[u8]) -> core::result::Result, signature::Error> { + // Each operation owns an independent copy of the keyed initial state. + let mut state = self.initial.clone(); + state.update(message); + Ok(state.finalize().into_bytes().to_vec()) + } +} + +impl signature::Verifier> for JwtMac { + fn verify( + &self, + message: &[u8], + bytes: &Vec, + ) -> core::result::Result<(), signature::Error> { + let mut state = self.initial.clone(); + state.update(message); + state + .verify_slice(bytes) + .map_err(|_| signature::Error::new()) + } +} + +impl JwtSigner for JwtMac { + fn algorithm(&self) -> Algorithm { + self.algorithm + } +} + +impl JwtVerifier for JwtMac { + fn algorithm(&self) -> Algorithm { + self.algorithm + } +} + +fn mac(bytes: &[u8], algorithm: Algorithm) -> Result> { + let initial = M::new_from_slice(bytes).map_err(|_| ErrorKind::InvalidKeyFormat)?; + Ok(JwtMac { initial, algorithm }) +} + +fn signing(key: K, algorithm: Algorithm) -> Signing { + Signing { + key, + algorithm, + signature: PhantomData, + } +} + +fn verification( + key: K, + algorithm: Algorithm, + signature_len: usize, +) -> Verification { + Verification { + key, + algorithm, + signature_len, + signature: PhantomData, + } +} + +fn signer(algorithm: &Algorithm, key: &EncodingKey) -> Result> { + if algorithm.family() == AlgorithmFamily::Rsa { + return Err(ErrorKind::InvalidAlgorithm.into()); + } + if key.family() != algorithm.family() { + return Err(ErrorKind::InvalidKeyFormat.into()); + } + let result: Box = match *algorithm { + Algorithm::HS256 => Box::new(mac::>(key.as_bytes(), *algorithm)?), + Algorithm::HS384 => Box::new(mac::>(key.as_bytes(), *algorithm)?), + Algorithm::HS512 => Box::new(mac::>(key.as_bytes(), *algorithm)?), + Algorithm::ES256 => Box::new(signing::<_, p256::ecdsa::Signature>( + p256::ecdsa::SigningKey::from_pkcs8_der(key.as_bytes()) + .map_err(|_| ErrorKind::InvalidEcdsaKey)?, + *algorithm, + )), + Algorithm::ES384 => Box::new(signing::<_, p384::ecdsa::Signature>( + p384::ecdsa::SigningKey::from_pkcs8_der(key.as_bytes()) + .map_err(|_| ErrorKind::InvalidEcdsaKey)?, + *algorithm, + )), + Algorithm::EdDSA => Box::new(signing::<_, ed25519_dalek::Signature>( + ed25519_dalek::SigningKey::from_pkcs8_der(key.as_bytes()) + .map_err(|_| ErrorKind::InvalidEddsaKey)?, + *algorithm, + )), + _ => return Err(ErrorKind::InvalidAlgorithm.into()), + }; + Ok(result) +} + +fn verifier(algorithm: &Algorithm, key: &DecodingKey) -> Result> { + if key.family() != algorithm.family() { + return Err(ErrorKind::InvalidKeyFormat.into()); + } + let result: Box = match *algorithm { + Algorithm::HS256 => Box::new(mac::>( + key.try_get_as_bytes()?, + *algorithm, + )?), + Algorithm::HS384 => Box::new(mac::>( + key.try_get_as_bytes()?, + *algorithm, + )?), + Algorithm::HS512 => Box::new(mac::>( + key.try_get_as_bytes()?, + *algorithm, + )?), + Algorithm::ES256 => Box::new(verification::<_, p256::ecdsa::Signature>( + p256::ecdsa::VerifyingKey::from_sec1_bytes(key.try_get_as_bytes()?) + .map_err(|_| ErrorKind::InvalidEcdsaKey)?, + *algorithm, + 64, + )), + Algorithm::ES384 => Box::new(verification::<_, p384::ecdsa::Signature>( + p384::ecdsa::VerifyingKey::from_sec1_bytes(key.try_get_as_bytes()?) + .map_err(|_| ErrorKind::InvalidEcdsaKey)?, + *algorithm, + 96, + )), + Algorithm::EdDSA => { + let bytes = key + .try_get_as_bytes()? + .try_into() + .map_err(|_| ErrorKind::InvalidEddsaKey)?; + Box::new(verification::<_, ed25519_dalek::Signature>( + ed25519_dalek::VerifyingKey::from_bytes(bytes) + .map_err(|_| ErrorKind::InvalidEddsaKey)?, + *algorithm, + 64, + )) + } + Algorithm::RS256 + | Algorithm::RS384 + | Algorithm::RS512 + | Algorithm::PS256 + | Algorithm::PS384 + | Algorithm::PS512 => { + let public = rsa_key(key)?; + let length = public.size(); + match *algorithm { + Algorithm::RS256 => Box::new(verification::<_, rsa::pkcs1v15::Signature>( + rsa::pkcs1v15::VerifyingKey::::new(public), + *algorithm, + length, + )), + Algorithm::RS384 => Box::new(verification::<_, rsa::pkcs1v15::Signature>( + rsa::pkcs1v15::VerifyingKey::::new(public), + *algorithm, + length, + )), + Algorithm::RS512 => Box::new(verification::<_, rsa::pkcs1v15::Signature>( + rsa::pkcs1v15::VerifyingKey::::new(public), + *algorithm, + length, + )), + Algorithm::PS256 => Box::new(verification::<_, rsa::pss::Signature>( + rsa::pss::VerifyingKey::::new(public), + *algorithm, + length, + )), + Algorithm::PS384 => Box::new(verification::<_, rsa::pss::Signature>( + rsa::pss::VerifyingKey::::new(public), + *algorithm, + length, + )), + Algorithm::PS512 => Box::new(verification::<_, rsa::pss::Signature>( + rsa::pss::VerifyingKey::::new(public), + *algorithm, + length, + )), + _ => return Err(ErrorKind::InvalidAlgorithm.into()), + } + } + _ => return Err(ErrorKind::InvalidAlgorithm.into()), + }; + Ok(result) +} + +fn rsa_key(key: &DecodingKey) -> Result { + let result = match key.kind() { + DecodingKeyKind::SecretOrDer(bytes) => coordinode_crypto::public_key::rsa_from_pkcs1(bytes), + DecodingKeyKind::RsaModulusExponent { n, e } => { + coordinode_crypto::public_key::rsa_from_components(n, e) + } + }; + result.map_err(|_| ErrorKind::InvalidRsaKey("invalid RSA public key".into()).into()) +} + +#[cfg(test)] +mod tests; diff --git a/src/auth/crypto/rust_crypto/keys.rs b/src/auth/crypto/rust_crypto/keys.rs new file mode 100644 index 0000000..8dcf92a --- /dev/null +++ b/src/auth/crypto/rust_crypto/keys.rs @@ -0,0 +1,55 @@ +use jsonwebtoken::Algorithm; +use jsonwebtoken::errors::{ErrorKind, Result}; +use jsonwebtoken::jwk::{EllipticCurve, ThumbprintHash}; +use p256::pkcs8::DecodePrivateKey; +use rsa::traits::PublicKeyParts; +use sha2::{Digest, Sha256, Sha384, Sha512}; + +pub(super) fn rsa_components(bytes: &[u8]) -> Result<(Vec, Vec)> { + let key = coordinode_crypto::public_key::rsa_from_pkcs1(bytes) + .map_err(|_| ErrorKind::InvalidRsaKey("invalid RSA public key".into()))?; + Ok((key.n_bytes().into_vec(), key.e_bytes().into_vec())) +} + +pub(super) fn ec_components( + bytes: &[u8], + algorithm: Algorithm, +) -> Result<(EllipticCurve, Vec, Vec)> { + match algorithm { + Algorithm::ES256 => { + let key = p256::ecdsa::SigningKey::from_pkcs8_der(bytes) + .map_err(|_| ErrorKind::InvalidEcdsaKey)?; + let point = key.verifying_key().to_sec1_point(false); + let x = point.x().ok_or(ErrorKind::InvalidEcdsaKey)?.to_vec(); + let y = point.y().ok_or(ErrorKind::InvalidEcdsaKey)?.to_vec(); + Ok((EllipticCurve::P256, x, y)) + } + Algorithm::ES384 => { + let key = p384::ecdsa::SigningKey::from_pkcs8_der(bytes) + .map_err(|_| ErrorKind::InvalidEcdsaKey)?; + let point = key.verifying_key().to_sec1_point(false); + let x = point.x().ok_or(ErrorKind::InvalidEcdsaKey)?.to_vec(); + let y = point.y().ok_or(ErrorKind::InvalidEcdsaKey)?.to_vec(); + Ok((EllipticCurve::P384, x, y)) + } + _ => Err(ErrorKind::InvalidAlgorithm.into()), + } +} + +pub(super) fn ed_components(bytes: &[u8], curve: &EllipticCurve) -> Result> { + if *curve != EllipticCurve::Ed25519 { + return Err(ErrorKind::InvalidAlgorithm.into()); + } + let key = + ed25519_dalek::SigningKey::from_pkcs8_der(bytes).map_err(|_| ErrorKind::InvalidEddsaKey)?; + Ok(key.verifying_key().as_bytes().to_vec()) +} + +pub(super) fn digest(bytes: &[u8], algorithm: ThumbprintHash) -> Result> { + Ok(match algorithm { + ThumbprintHash::SHA256 => Sha256::digest(bytes).to_vec(), + ThumbprintHash::SHA384 => Sha384::digest(bytes).to_vec(), + ThumbprintHash::SHA512 => Sha512::digest(bytes).to_vec(), + _ => return Err(ErrorKind::InvalidAlgorithm.into()), + }) +} diff --git a/src/auth/crypto/rust_crypto/tests.rs b/src/auth/crypto/rust_crypto/tests.rs new file mode 100644 index 0000000..a9216cf --- /dev/null +++ b/src/auth/crypto/rust_crypto/tests.rs @@ -0,0 +1,237 @@ +use super::*; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use jsonwebtoken::{Header, Validation, decode, encode}; +use p256::elliptic_curve::sec1::ToSec1Point; +use p256::pkcs8::EncodePrivateKey; +use serde_json::json; + +fn claims() -> serde_json::Value { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + json!({"sub": "crypto-contract", "exp": now + 3600}) +} + +fn install() { + DEFAULT_PROVIDER.install_default().unwrap(); +} + +// The public JWT API exercises the signature-2/signature-3 adapter and claims parsing together. +#[test] +fn allowed_signatures_round_trip_through_jsonwebtoken() { + install(); + let ec256 = p256::SecretKey::from_slice(&[1; 32]).unwrap(); + let ec384 = p384::SecretKey::from_slice(&[2; 48]).unwrap(); + let ed = ed25519_dalek::SigningKey::from_bytes(&[3; 32]); + let cases = [ + ( + Algorithm::HS256, + EncodingKey::from_secret(&[4; 64]), + DecodingKey::from_secret(&[4; 64]), + ), + ( + Algorithm::HS384, + EncodingKey::from_secret(&[4; 64]), + DecodingKey::from_secret(&[4; 64]), + ), + ( + Algorithm::HS512, + EncodingKey::from_secret(&[4; 64]), + DecodingKey::from_secret(&[4; 64]), + ), + ( + Algorithm::ES256, + EncodingKey::from_ec_der(ec256.to_pkcs8_der().unwrap().as_bytes()), + DecodingKey::from_ec_der(&ec256.public_key().to_sec1_bytes()), + ), + ( + Algorithm::ES384, + EncodingKey::from_ec_der(ec384.to_pkcs8_der().unwrap().as_bytes()), + DecodingKey::from_ec_der(&ec384.public_key().to_sec1_bytes()), + ), + ( + Algorithm::EdDSA, + EncodingKey::from_ed_der(ed.to_pkcs8_der().unwrap().as_bytes()), + DecodingKey::from_ed_der(ed.verifying_key().as_bytes()), + ), + ]; + for (algorithm, private, public) in cases { + let expected = claims(); + let token = encode(&Header::new(algorithm), &expected, &private).unwrap(); + let actual = + decode::(&token, &public, &Validation::new(algorithm)).unwrap(); + assert_eq!(actual.claims, expected); + let (input, signature) = token.rsplit_once('.').unwrap(); + let mut signature = URL_SAFE_NO_PAD.decode(signature).unwrap(); + signature[0] ^= 1; + let corrupt = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature)); + assert!( + decode::(&corrupt, &public, &Validation::new(algorithm)).is_err() + ); + } +} + +fn rsa_signature(key: rsa::RsaPrivateKey, algorithm: Algorithm, input: &[u8]) -> Vec { + use signature_v3::{RandomizedSigner, Signer}; + // Generated test keys are an oracle only; the product factory never accepts RSA signing. + match algorithm { + Algorithm::RS256 => rsa::pkcs1v15::SigningKey::::new(key) + .try_sign(input) + .unwrap() + .to_vec(), + Algorithm::RS384 => rsa::pkcs1v15::SigningKey::::new(key) + .try_sign(input) + .unwrap() + .to_vec(), + Algorithm::RS512 => rsa::pkcs1v15::SigningKey::::new(key) + .try_sign(input) + .unwrap() + .to_vec(), + Algorithm::PS256 => rsa::pss::SigningKey::::new(key) + .try_sign_with_rng(&mut rand::rng(), input) + .unwrap() + .to_vec(), + Algorithm::PS384 => rsa::pss::SigningKey::::new(key) + .try_sign_with_rng(&mut rand::rng(), input) + .unwrap() + .to_vec(), + Algorithm::PS512 => rsa::pss::SigningKey::::new(key) + .try_sign_with_rng(&mut rand::rng(), input) + .unwrap() + .to_vec(), + _ => panic!("test requires an RSA algorithm"), + } +} + +// Every RSA JWT algorithm accepts both PEM-derived DER and JWK components without a private path. +#[test] +fn rsa_public_jwt_verification_and_private_refusal() { + use rsa::pkcs1::EncodeRsaPublicKey; + install(); + let private = rsa::RsaPrivateKey::new(&mut rand::rng(), 2048).unwrap(); + let public = private.to_public_key(); + let der = public.to_pkcs1_der().unwrap(); + for algorithm in [ + Algorithm::RS256, + Algorithm::RS384, + Algorithm::RS512, + Algorithm::PS256, + Algorithm::PS384, + Algorithm::PS512, + ] { + let jwk = serde_json::from_value(json!({ + "kty": "RSA", "alg": algorithm, + "n": URL_SAFE_NO_PAD.encode(public.n_bytes()), + "e": URL_SAFE_NO_PAD.encode(public.e_bytes()), + })) + .unwrap(); + let expected = claims(); + let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&Header::new(algorithm)).unwrap()); + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&expected).unwrap()); + let input = format!("{header}.{payload}"); + let signature = rsa_signature(private.clone(), algorithm, input.as_bytes()); + let token = format!("{input}.{}", URL_SAFE_NO_PAD.encode(&signature)); + for key in [ + DecodingKey::from_rsa_der(der.as_bytes()), + DecodingKey::from_jwk(&jwk).unwrap(), + ] { + let actual = + decode::(&token, &key, &Validation::new(algorithm)).unwrap(); + assert_eq!(actual.claims, expected); + let verifier = (DEFAULT_PROVIDER.verifier_factory)(&algorithm, &key).unwrap(); + let mut corrupt = signature.clone(); + corrupt[0] ^= 1; + assert!(verifier.verify(input.as_bytes(), &corrupt).is_err()); + assert!( + verifier + .verify(input.as_bytes(), &vec![0; signature.len() + 1]) + .is_err() + ); + } + assert!( + (DEFAULT_PROVIDER.signer_factory)(&algorithm, &EncodingKey::from_rsa_der(&[])).is_err() + ); + } + assert!( + (DEFAULT_PROVIDER + .key_utils + .rsa_pub_components_from_private_key)(&[]) + .is_err() + ); +} + +// Untrusted key material must fail before indexing, integer allocation or algorithm confusion. +#[test] +fn malformed_public_keys_and_family_confusion_are_errors() { + for length in 0..32 { + assert!( + (DEFAULT_PROVIDER.verifier_factory)( + &Algorithm::EdDSA, + &DecodingKey::from_ed_der(&vec![0; length]) + ) + .is_err() + ); + } + assert!( + (DEFAULT_PROVIDER.verifier_factory)(&Algorithm::EdDSA, &DecodingKey::from_ed_der(&[0; 33])) + .is_err() + ); + assert!( + (DEFAULT_PROVIDER.verifier_factory)(&Algorithm::ES256, &DecodingKey::from_secret(&[0; 64])) + .is_err() + ); + assert!( + (DEFAULT_PROVIDER.signer_factory)(&Algorithm::ES256, &EncodingKey::from_secret(&[0; 64])) + .is_err() + ); + assert!( + (DEFAULT_PROVIDER.verifier_factory)( + &Algorithm::RS256, + &DecodingKey::from_rsa_der(&[0; 32]) + ) + .is_err() + ); + let oversized = DecodingKey::from_rsa_raw_components(&vec![1; 1025], &[3]); + assert!((DEFAULT_PROVIDER.verifier_factory)(&Algorithm::RS256, &oversized).is_err()); +} + +// JWK utility hooks are implemented, including typed refusal of unsupported private keys. +#[test] +fn jwk_utilities_are_real_and_algorithm_bound() { + let ec = p256::SecretKey::from_slice(&[1; 32]).unwrap(); + let der = ec.to_pkcs8_der().unwrap(); + let (curve, x, y) = + (DEFAULT_PROVIDER + .key_utils + .ec_pub_components_from_private_key)(der.as_bytes(), Algorithm::ES256) + .unwrap(); + let point = ec.public_key().to_sec1_point(false); + assert_eq!(curve, jsonwebtoken::jwk::EllipticCurve::P256); + assert_eq!(x, point.x().unwrap().as_slice()); + assert_eq!(y, point.y().unwrap().as_slice()); + assert!( + (DEFAULT_PROVIDER + .key_utils + .ec_pub_components_from_private_key)(der.as_bytes(), Algorithm::ES384) + .is_err() + ); + assert!( + (DEFAULT_PROVIDER + .key_utils + .ed_pub_components_from_private_key)( + &[], &jsonwebtoken::jwk::EllipticCurve::P256 + ) + .is_err() + ); + let digest = (DEFAULT_PROVIDER.key_utils.compute_digest)( + b"abc", + jsonwebtoken::jwk::ThumbprintHash::SHA256, + ) + .unwrap(); + assert_eq!( + URL_SAFE_NO_PAD.encode(digest), + "ungWv48Bz-pBQUDeXa4iI7ADYaOWF3qctBD_YfIAFa0" + ); +} diff --git a/src/auth/crypto/tests.rs b/src/auth/crypto/tests.rs index 56c4f01..d68e620 100644 --- a/src/auth/crypto/tests.rs +++ b/src/auth/crypto/tests.rs @@ -7,17 +7,12 @@ #[cfg(all(feature = "rust_crypto", feature = "aws_lc_rs"))] #[test] fn tie_break_selects_aws_lc_rs() { - let selected = super::preferred_provider(); assert!( - std::ptr::eq(selected, &jsonwebtoken::crypto::aws_lc::DEFAULT_PROVIDER), - "the tie-break must select the aws_lc_rs provider" - ); - assert!( - !std::ptr::eq( - selected, - &jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER + std::ptr::eq( + super::preferred_provider(), + &jsonwebtoken::crypto::aws_lc::DEFAULT_PROVIDER ), - "the tie-break must not fall back to RustCrypto" + "the tie-break must select the aws_lc_rs provider" ); } @@ -40,7 +35,7 @@ fn aws_lc_rs_only_build_installs_aws_lc_rs() { fn rust_crypto_only_build_installs_rust_crypto() { assert!(std::ptr::eq( super::preferred_provider(), - &jsonwebtoken::crypto::rust_crypto::DEFAULT_PROVIDER + &super::rust_crypto::DEFAULT_PROVIDER )); } @@ -51,3 +46,55 @@ fn installing_twice_is_harmless() { super::install_default_crypto_provider(); super::install_default_crypto_provider(); } + +/// A proxy without `auth:` uses no JWT and leaves the process-wide jsonwebtoken +/// provider to the host: an application with its own token checks must keep +/// its own choice. The probe install succeeds only if nothing installed one +/// first (each test runs in its own process). +#[test] +fn a_proxy_without_auth_installs_no_jwt_provider() { + let service = crate::ProxyServer::from_yaml_str("service:\n name: demo\n") + .unwrap() + .service(tonic::service::Routes::default()) + .unwrap(); + drop(service); + assert!( + super::preferred_provider().install_default().is_ok(), + "building a proxy without auth must not install a jsonwebtoken provider" + ); +} + +/// The built-in verifier, once built, has the provider it verifies with. +#[test] +fn building_the_builtin_verifier_installs_the_provider() { + let jwt = crate::config::JwtConfig { + jwks_uri: Some("https://issuer.example/jwks".into()), + jwks_max_age_secs: 300, + issuer: None, + audience: None, + public_key_pem_file: None, + claims_headers: Default::default(), + roles_claim: "roles".into(), + cache: Default::default(), + }; + super::super::verifier::ConfigVerifier::build(&jwt).unwrap(); + assert!(super::preferred_provider().install_default().is_err()); +} + +/// RSA public verification does not authorize any local RSA signer factory. +#[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] +#[test] +fn rust_crypto_refuses_rsa_signer_factories() { + use jsonwebtoken::{Algorithm, EncodingKey}; + let key = EncodingKey::from_rsa_der(&[]); + for algorithm in [ + Algorithm::RS256, + Algorithm::RS384, + Algorithm::RS512, + Algorithm::PS256, + Algorithm::PS384, + Algorithm::PS512, + ] { + assert!((super::preferred_provider().signer_factory)(&algorithm, &key).is_err()); + } +} diff --git a/src/auth/forward.rs b/src/auth/forward.rs index 0d8be17..d5f6d2e 100644 --- a/src/auth/forward.rs +++ b/src/auth/forward.rs @@ -7,14 +7,14 @@ use std::sync::Arc; +use axum::Router; use axum::extract::Request; use axum::http::header::{HeaderValue, LOCATION}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::routing::any; -use axum::Router; -use super::{forbidden, unauthorized, Auth, AuthDecision}; +use super::{Auth, AuthDecision, forbidden, unauthorized}; use crate::config::AuthConfig; /// A verification endpoint backed by the shared [`Auth`] machinery. diff --git a/src/auth/forward/tests.rs b/src/auth/forward/tests.rs index 557c3dc..32cd441 100644 --- a/src/auth/forward/tests.rs +++ b/src/auth/forward/tests.rs @@ -21,8 +21,8 @@ fn keypair() -> (SigningKey, String) { } fn sign(sk: &SigningKey, claims: &serde_json::Value) -> String { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"JWT"}"#); let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()); let signing_input = format!("{header}.{payload}"); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 5e9223c..79846ab 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -10,6 +10,11 @@ //! [`ProxyServer::with_token_verifier`](crate::ProxyServer::with_token_verifier). //! Everything else here — policies, roles, claim headers — is independent of //! which one verified the token. +//! +//! This module is for an application that leaves authentication and +//! authorization to the proxy. One with its own authn/authz, such as +//! structured-id, uses none of it (see +//! [the crate docs](crate#applications-with-their-own-authentication)). pub mod authz; #[cfg(feature = "builtin_jwt")] @@ -31,13 +36,13 @@ use std::collections::HashSet; use std::sync::Arc; use axum::extract::State; -use axum::http::header::{HeaderName, HeaderValue}; use axum::http::HeaderMap; +use axum::http::header::{HeaderName, HeaderValue}; use axum::middleware::Next; use axum::response::Response; use serde_json::Value; -use crate::config::{default_roles_claim, AuthConfig}; +use crate::config::{AuthConfig, default_roles_claim}; use crate::hooks::TokenVerifier; use policy::Policies; diff --git a/src/auth/tests.rs b/src/auth/tests.rs index cb553d0..fdd5731 100644 --- a/src/auth/tests.rs +++ b/src/auth/tests.rs @@ -334,7 +334,7 @@ fn jwt_mode_without_a_verifier_is_rejected() { #[cfg(feature = "builtin_jwt")] mod builtin { use super::*; - use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; use std::sync::atomic::{AtomicU32, Ordering}; // Ed25519 test keypair (generated for tests only; not a secret). diff --git a/src/auth/verifier.rs b/src/auth/verifier.rs index 61294d7..e7421a9 100644 --- a/src/auth/verifier.rs +++ b/src/auth/verifier.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use std::time::Duration; -use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; use serde_json::Value; use super::jwks::{JwksCache, MIN_REFRESH_INTERVAL}; @@ -43,9 +43,8 @@ impl ConfigVerifier { /// max age is below the refresh interval, the PEM file cannot be read, or /// it is not a valid Ed25519 public key. pub(crate) fn build(jwt: &JwtConfig) -> Result { - // Normally settled in `ProxyServer::from_config` already; repeated here - // because a verifier can also be built without going through the - // server, and the call costs one atomic once the provider is in place. + // The only place the proxy settles the jsonwebtoken provider: a proxy + // that builds no built-in verifier leaves that choice to its host. super::crypto::install_default_crypto_provider(); let keys = if let Some(uri) = &jwt.jwks_uri { diff --git a/src/client_address.rs b/src/client_address.rs index fafed73..39a3ab1 100644 --- a/src/client_address.rs +++ b/src/client_address.rs @@ -98,7 +98,7 @@ use std::sync::Arc; use std::task::{Context, Poll}; use axum::extract::ConnectInfo; -use http::header::{HeaderName, FORWARDED}; +use http::header::{FORWARDED, HeaderName}; use http::{HeaderMap, HeaderValue}; use ipnet::IpNet; use tower::{Layer, Service}; @@ -633,7 +633,7 @@ fn parse_trusted(entry: &str) -> Result { Err(_) => { return Err(format!( "trusted_proxies entry {entry:?} is neither a CIDR range nor an IP address" - )) + )); } }, }; diff --git a/src/client_address/tests.rs b/src/client_address/tests.rs index a1bd0dc..508e66b 100644 --- a/src/client_address/tests.rs +++ b/src/client_address/tests.rs @@ -726,12 +726,14 @@ fn an_unresolved_address_leaves_no_forwarding_header() { behind_lb().apply(&mut request, from); assert!(request.headers().get("x-forwarded-for").is_none()); assert!(request.headers().get("x-real-ip").is_none()); - assert!(request - .extensions() - .get::() - .unwrap() - .ip() - .is_none()); + assert!( + request + .extensions() + .get::() + .unwrap() + .ip() + .is_none() + ); } } diff --git a/src/config.rs b/src/config.rs index eb40b6a..1f58e0f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1801,7 +1801,9 @@ impl ProxyConfig { anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'"); } if !seen.insert(path.to_string()) { - anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path"); + anyhow::bail!( + "duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path" + ); } Ok(()) }; diff --git a/src/config/tests.rs b/src/config/tests.rs index 54092c9..c9d6291 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -769,9 +769,11 @@ error_details: let options = transcode_options(yaml).unwrap(); assert!(options.ndjson_envelope); assert!(options.error_details.enabled_for("/v1/public/items")); - assert!(!options - .error_details - .enabled_for("/v1/public/internal/{id}")); + assert!( + !options + .error_details + .enabled_for("/v1/public/internal/{id}") + ); assert!(!options.error_details.enabled_for("/v1/admin/items")); // The ProxyConfig still reads its own streaming key from the same section. let config: ProxyConfig = parse_yaml(yaml).unwrap(); diff --git a/src/cors.rs b/src/cors.rs index f7f9982..6c92ea4 100644 --- a/src/cors.rs +++ b/src/cors.rs @@ -14,7 +14,7 @@ use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use std::task::{ready, Context, Poll}; +use std::task::{Context, Poll, ready}; use axum::http::header::{ ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, diff --git a/src/cors/tests.rs b/src/cors/tests.rs index f415ba3..cd3fd28 100644 --- a/src/cors/tests.rs +++ b/src/cors/tests.rs @@ -1,9 +1,9 @@ use super::*; +use axum::Router; use axum::body::Body; use axum::extract::Request; use axum::http::StatusCode; use axum::routing::any; -use axum::Router; use tower::ServiceExt; /// A router whose only route answers every method with the method it saw, a diff --git a/src/embed.rs b/src/embed.rs index 2af5524..fa30f9d 100644 --- a/src/embed.rs +++ b/src/embed.rs @@ -16,7 +16,7 @@ use axum::http::header::{CONTENT_TYPE, LOCATION, WWW_AUTHENTICATE}; use axum::http::{HeaderMap, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; -use axum::routing::{get, on, MethodFilter, MethodRouter}; +use axum::routing::{MethodFilter, MethodRouter, get, on}; use axum::{Json, Router}; use crate::client_address::ClientAddress; diff --git a/src/embed/tests.rs b/src/embed/tests.rs index 93cde96..c389a37 100644 --- a/src/embed/tests.rs +++ b/src/embed/tests.rs @@ -3,10 +3,10 @@ use std::sync::Arc; use async_trait::async_trait; +use axum::Router; use axum::body::Body; use axum::http::{HeaderMap, Method, Request, StatusCode}; use axum::routing::get; -use axum::Router; use bytes::Bytes; use tower::ServiceExt; diff --git a/src/guard/gate.rs b/src/guard/gate.rs index 943a966..0b96e53 100644 --- a/src/guard/gate.rs +++ b/src/guard/gate.rs @@ -11,13 +11,13 @@ use std::task::{Context, Poll}; use axum::extract::Request; use axum::response::Response; -use http::header::RETRY_AFTER; use http::HeaderValue; +use http::header::RETRY_AFTER; use pin_project_lite::pin_project; use tokio::sync::OwnedSemaphorePermit; use tower::{Layer, Service}; -use super::{reject, Class, Concurrency, Guards, Maintenance, Scope}; +use super::{Class, Concurrency, Guards, Maintenance, Scope, reject}; use crate::client_address::{ClientAddress, Resolution}; use crate::shield::matcher::Phase; use crate::shield::{Decision, Report, Shield}; diff --git a/src/guard/grpc.rs b/src/guard/grpc.rs index daedebf..228dc3e 100644 --- a/src/guard/grpc.rs +++ b/src/guard/grpc.rs @@ -3,15 +3,15 @@ use std::convert::Infallible; use std::future::Future; use std::pin::Pin; -use std::task::{ready, Context, Poll}; +use std::task::{Context, Poll, ready}; use axum::extract::Request; use axum::response::Response; use pin_project_lite::pin_project; use tower::Service; -use super::{http_to_grpc_code, BoxedService, Rejection}; -use crate::upstream::{grpc_protocol, trailers_only, GrpcProtocol}; +use super::{BoxedService, Rejection, http_to_grpc_code}; +use crate::upstream::{GrpcProtocol, grpc_protocol, trailers_only}; /// The guarded gRPC path of one protocol: a guard's HTTP rejection becomes a /// trailers-only status in `protocol` (gRPC PROTOCOL-HTTP2, "Responses"), the diff --git a/src/hooks.rs b/src/hooks.rs index 63ed7c2..18ef421 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -8,6 +8,11 @@ //! `axum` type), so `cargo tree -i axum` in an embedder shows axum only under //! `structured-proxy`. //! +//! [`AuthDecider`] and [`TokenVerifier`] are for an application that leaves +//! authentication and authorization to the proxy. One with its own authn/authz, +//! such as structured-id, implements neither (see +//! [the crate docs](crate#applications-with-their-own-authentication)). +//! //! Stateful concerns (BFF sessions, OIDC `authorize`/`token`) are deliberately //! absent: the default build is a stateless data plane (see the crate README //! Non-goals). They are planned behind an opt-in `bff` feature. @@ -111,6 +116,10 @@ pub enum Decision { /// (e.g. JWT verification + a policy engine + header translation). Called inline /// on every proxied request *and* by the `/verify` forward-auth endpoint: same /// trait, two call sites. +/// +/// Only for an application that leaves authorization to the proxy; one with its +/// own authn/authz, such as structured-id, does not implement it (see +/// [the crate docs](crate#applications-with-their-own-authentication)). #[async_trait] pub trait AuthDecider: Send + Sync { /// Decide whether to allow, deny, or redirect the request. @@ -136,6 +145,10 @@ pub trait AuthDecider: Send + Sync { /// Everything around verification stays with the proxy: route policies /// (`require_auth` / `required_roles`), the roles claim, and the claim→header /// forwarding all operate on the returned claims. +/// +/// Only for an application that leaves token verification to the proxy; one +/// that verifies its own tokens, such as structured-id, does not implement it +/// (see [the crate docs](crate#applications-with-their-own-authentication)). #[async_trait] pub trait TokenVerifier: Send + Sync { /// Verify `token` and return its claims, or `None` to reject the request diff --git a/src/lib.rs b/src/lib.rs index 06b6aa7..92efa11 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,10 +10,24 @@ //! structured-proxy --config sflow-proxy.yaml //! ``` //! +//! ## Applications with their own authentication +//! +//! The proxy's auth (JWT verification, route policies and roles, claim→header +//! forwarding, forward-auth, ext_authz, and the [`ProxyServer::with_auth`], +//! [`ProxyServer::with_token_verifier`] and [`ProxyServer::with_auth_decider`] +//! hooks) is for an application that leaves authentication and authorization +//! to the proxy and an external identity provider. An application that +//! verifies tokens and authorizes requests itself, such as structured-id and the +//! services built on its authn/authz, embeds the proxy with none of it: no +//! `auth:` section and none of those hooks. Its own checks are the only ones on +//! the request path. +//! //! ## JWT verification //! -//! The bearer-token check sits behind [`hooks::TokenVerifier`]. A build gets one -//! of two: +//! The bearer-token check sits behind [`hooks::TokenVerifier`], for an +//! application that leaves token verification to the proxy (see +//! [above](#applications-with-their-own-authentication)). A build gets one of +//! two: //! //! - the **built-in** verifier (keys from `auth.jwt`), whose crypto backend is //! picked by a feature: `rust_crypto` (default, pure Rust) or `aws_lc_rs` @@ -21,10 +35,12 @@ //! compiled in at once — Cargo features are additive, so a dependency graph //! with two dependents asking for different backends unifies into exactly that //! build. `aws_lc_rs` then wins: it is constant-time and free of the `rsa` -//! advisory `rust_crypto` carries. [`ProxyServer::from_config`] settles that -//! choice for the process; a process where another crate may reach -//! `jsonwebtoken` before any server exists calls -//! [`install_default_crypto_provider`] from `main` instead. +//! advisory `rust_crypto` carries. Building the built-in verifier settles +//! that choice for the process, unless the host installed a provider first; +//! a proxy without `auth.mode: "jwt"`, or with an injected verifier, leaves it +//! to the host. A process where another crate may reach `jsonwebtoken` before +//! the verifier is built calls [`install_default_crypto_provider`] from `main` +//! instead. //! - an **injected** one, supplied by the embedder through //! [`ProxyServer::with_token_verifier`]. Since Cargo unifies features across a //! whole dependency graph, a backend feature cannot be chosen per binary — @@ -82,7 +98,7 @@ pub mod upstream; pub use auth::crypto::install_default_crypto_provider; pub use client_address::ClientAddress; pub use received::ReceivedRequest; -pub use serve::{serve, serve_with, serve_with_shutdown, ServeOptions}; +pub use serve::{ServeOptions, serve, serve_with, serve_with_shutdown}; pub use service::{ConnectionInfo, ProxyService}; use axum::extract::State; @@ -163,13 +179,6 @@ impl Default for ProxyServer { impl ProxyServer { /// Create from YAML config file. pub fn from_config(config: ProxyConfig) -> Self { - // Earliest point this crate owns: settle the JWT crypto provider here, - // long before the first token arrives. A process whose other crates - // reach jsonwebtoken before any server exists calls - // `install_default_crypto_provider` from `main` instead. - #[cfg(feature = "builtin_jwt")] - auth::crypto::install_default_crypto_provider(); - Self { config, descriptor_pool: None, @@ -396,6 +405,10 @@ impl ProxyServer { /// JWT auth, route policies, forward-auth and ext_authz (`auth:`); build /// the section with [`config::from_yaml`]. + /// + /// Only for an application that leaves authentication to the proxy. One + /// with its own authn/authz, such as structured-id, does not call this (see + /// [the crate docs](crate#applications-with-their-own-authentication)). pub fn with_auth(mut self, auth: config::AuthConfig) -> Self { self.config.auth = Some(auth); self @@ -414,6 +427,10 @@ impl ProxyServer { /// backs the `/verify` forward-auth endpoint. Its signature is `axum`-free /// (see [`hooks::AuthDecider`]), so the embedder never names an HTTP /// framework. + /// + /// Only for an application that leaves authorization to the proxy. One + /// with its own authn/authz, such as structured-id, does not call this (see + /// [the crate docs](crate#applications-with-their-own-authentication)). pub fn with_auth_decider(mut self, decider: Arc) -> Self { self.auth_decider = Some(decider); self @@ -485,6 +502,11 @@ impl ProxyServer { /// `auth.mode` must still be `"jwt"` for the middleware to run; `auth.jwt` /// then only configures claim forwarding, and any key source in it is /// ignored (with a warning), since the verifier owns its own keys. + /// + /// Only for an application that leaves token verification to the proxy. + /// One that verifies its own tokens, such as structured-id, does not call + /// this and has no `auth:` section (see + /// [the crate docs](crate#applications-with-their-own-authentication)). pub fn with_token_verifier(mut self, verifier: Arc) -> Self { self.token_verifier = Some(verifier); self diff --git a/src/main.rs b/src/main.rs index e0258f1..0e7ea53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -60,7 +60,7 @@ fn main() -> anyhow::Result<()> { async fn stop_requested() { #[cfg(unix)] { - use tokio::signal::unix::{signal, SignalKind}; + use tokio::signal::unix::{SignalKind, signal}; match signal(SignalKind::terminate()) { Ok(mut term) => { tokio::select! { diff --git a/src/oidc/mod.rs b/src/oidc/mod.rs index 74d03d8..a14f829 100644 --- a/src/oidc/mod.rs +++ b/src/oidc/mod.rs @@ -10,9 +10,9 @@ use std::path::Path; use axum::routing::get; use axum::{Json, Router}; -use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; use base64::Engine; -use serde_json::{json, Map, Value}; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use serde_json::{Map, Value, json}; use sha2::{Digest, Sha256}; use crate::config::OidcDiscoveryConfig; diff --git a/src/openapi.rs b/src/openapi.rs index bfe1d91..a9865e4 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -8,12 +8,12 @@ use std::collections::HashSet; use axum::http::Method; use prost_reflect::{DescriptorPool, FieldDescriptor, Kind, MessageDescriptor, MethodDescriptor}; -use serde_json::{json, Map, Value}; +use serde_json::{Map, Value, json}; use crate::config::{AliasConfig, OpenApiConfig}; +use crate::transcode::RpcSelection; use crate::transcode::request::{self, BodyMapping}; use crate::transcode::rule::{self, HttpBinding, RouteMethod}; -use crate::transcode::RpcSelection; use crate::transcode::{self, httpbody, path}; /// How a field's value travels, which decides its schema where the two differ. diff --git a/src/openapi/tests.rs b/src/openapi/tests.rs index ae83a24..8102808 100644 --- a/src/openapi/tests.rs +++ b/src/openapi/tests.rs @@ -364,11 +364,12 @@ fn a_template_the_router_cannot_serve_is_not_documented() { let spec = spec(&[]); let paths = spec["paths"].as_object().unwrap(); assert!(!paths.contains_key("/v1/broken/a{name}b"), "{paths:?}"); - assert!(!paths.values().any(|item| item - .as_object() - .unwrap() - .values() - .any(|op| op["operationId"] == "Api.Broken"))); + assert!(!paths.values().any(|item| { + item.as_object() + .unwrap() + .values() + .any(|op| op["operationId"] == "Api.Broken") + })); } #[test] @@ -710,8 +711,8 @@ fn every_response_validates_against_its_schema() { let pool = test_pool(); let spec = spec(&[]); let components = &spec["components"]["schemas"]; - let schema = &spec["paths"]["/v1/wire"]["post"]["responses"]["200"]["content"] - ["application/json"]["schema"]; + let schema = &spec["paths"]["/v1/wire"]["post"]["responses"]["200"]["content"]["application/json"] + ["schema"]; let wire = pool.get_message_by_name("test.v1.Wire").unwrap(); let choices = wire.oneofs().map(|o| o.fields().len()).max().unwrap(); // `Value.kind` has six members: every one of them is sampled too. diff --git a/src/received.rs b/src/received.rs index 0c66e89..1a88553 100644 --- a/src/received.rs +++ b/src/received.rs @@ -8,8 +8,8 @@ //! request extensions. The proxy records them and checks nothing about them. //! A remote upstream never sees request extensions. -use axum::http::uri::PathAndQuery; use axum::http::Method; +use axum::http::uri::PathAndQuery; /// The HTTP request a transcoded gRPC call was made from, as the proxy /// received it: its method, its path and query, and the gRPC path of the RPC diff --git a/src/serve.rs b/src/serve.rs index b9de352..5a5d18b 100644 --- a/src/serve.rs +++ b/src/serve.rs @@ -15,7 +15,7 @@ use hyper_util::service::TowerToHyperService; use pin_project_lite::pin_project; use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; use tokio::net::TcpListener; -use tokio::sync::{watch, OwnedSemaphorePermit, Semaphore}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore, watch}; use tokio::task::JoinSet; use tonic::transport::server::Connected; diff --git a/src/serve/idle.rs b/src/serve/idle.rs index 96e4931..d7616d8 100644 --- a/src/serve/idle.rs +++ b/src/serve/idle.rs @@ -3,9 +3,9 @@ use std::convert::Infallible; use std::future::Future; use std::pin::Pin; -use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; -use std::task::{ready, Context, Poll}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::task::{Context, Poll, ready}; use std::time::Duration; use axum::body::Body; diff --git a/src/serve/tests.rs b/src/serve/tests.rs index 7ad44cb..2909852 100644 --- a/src/serve/tests.rs +++ b/src/serve/tests.rs @@ -252,7 +252,7 @@ fn a_zero_limit_in_the_config_is_an_error() { /// A process crypto provider, for builds without a crypto backend feature. fn install_provider() { if rustls::crypto::CryptoProvider::get_default().is_none() { - rustls::crypto::CryptoProvider::install_default(rustls_rustcrypto::provider()) + rustls::crypto::CryptoProvider::install_default(coordinode_crypto::provider()) .expect("each test runs in its own process"); } } @@ -291,6 +291,41 @@ fn client_auth_without_a_client_ca_is_an_error() { } } +/// The listener YAML for the RSA identity in the TLS fixtures. +fn rsa_listener_yaml() -> String { + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/tls/testdata"); + format!("listen:\n tls:\n cert_file: {dir}/rsa.pem\n key_file: {dir}/rsa.key.pem\n") +} + +/// The RustCrypto provider presents no RSA identity, and the error names the +/// key file it refused. +#[test] +fn the_rustcrypto_provider_refuses_an_rsa_listener_key() { + rustls::crypto::CryptoProvider::install_default(coordinode_crypto::provider()) + .expect("each test runs in its own process"); + let err = crate::ProxyServer::from_yaml_str(&rsa_listener_yaml()) + .unwrap() + .serve_options() + .unwrap_err() + .to_string(); + assert!(err.contains("rsa.key.pem"), "{err}"); + assert!(err.contains("P-256, P-384 or Ed25519"), "{err}"); +} + +/// A provider the host installed keeps its own key contract, whichever +/// backend features the dependency graph unified: one that signs with RSA +/// serves an RSA listener. +#[test] +fn a_host_provider_with_rsa_serves_an_rsa_listener_key() { + rustls::crypto::CryptoProvider::install_default(rustls::crypto::ring::default_provider()) + .expect("each test runs in its own process"); + let options = crate::ProxyServer::from_yaml_str(&rsa_listener_yaml()) + .unwrap() + .serve_options() + .unwrap(); + assert!(options.tls.is_some()); +} + #[test] fn a_missing_tls_file_names_itself() { install_provider(); diff --git a/src/service.rs b/src/service.rs index 1146339..9b11373 100644 --- a/src/service.rs +++ b/src/service.rs @@ -29,7 +29,7 @@ pub type TlsConnectInfo = as Connected>::ConnectInfo; use crate::upstream::{ - grpc_protocol, is_grpc_web_preflight, BoxError, GrpcProtocol, PassThrough, Upstream, + BoxError, GrpcProtocol, PassThrough, Upstream, grpc_protocol, is_grpc_web_preflight, }; /// The proxy as a tower service, built by diff --git a/src/shield/matcher.rs b/src/shield/matcher.rs index 6ab1523..1e5b21c 100644 --- a/src/shield/matcher.rs +++ b/src/shield/matcher.rs @@ -274,16 +274,20 @@ mod tests { #[test] fn zero_rate_or_burst_is_rejected() { - assert!(compile_profile(&LimitProfileConfig { - rate: "0/min".to_string(), - burst: None, - }) - .is_err()); - assert!(compile_profile(&LimitProfileConfig { - rate: "100/min".to_string(), - burst: Some(0), - }) - .is_err()); + assert!( + compile_profile(&LimitProfileConfig { + rate: "0/min".to_string(), + burst: None, + }) + .is_err() + ); + assert!( + compile_profile(&LimitProfileConfig { + rate: "100/min".to_string(), + burst: Some(0), + }) + .is_err() + ); } #[test] diff --git a/src/shield/resolve/tests.rs b/src/shield/resolve/tests.rs index 64acb0e..972b75c 100644 --- a/src/shield/resolve/tests.rs +++ b/src/shield/resolve/tests.rs @@ -25,7 +25,7 @@ fn with_tls_provider() { { use rustls::crypto::CryptoProvider; // A concurrent test may have installed it first; either way one is set. - let installed = rustls_rustcrypto::provider().install_default().is_ok(); + let installed = coordinode_crypto::provider().install_default().is_ok(); assert!(installed || CryptoProvider::get_default().is_some()); } } diff --git a/src/shield/tests.rs b/src/shield/tests.rs index bbcc5ae..f642421 100644 --- a/src/shield/tests.rs +++ b/src/shield/tests.rs @@ -3,10 +3,10 @@ use super::*; use crate::config::{KeySourceConfig, LimitProfileConfig, RateRuleConfig, ShieldConfig}; +use axum::Router; use axum::body::Body; use axum::http::{Request, StatusCode}; use axum::routing::get; -use axum::Router; use std::collections::HashMap; use tower::ServiceExt; @@ -415,8 +415,8 @@ mod two_phase { } fn token(sk: &SigningKey, sub: &str) -> String { - use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"EdDSA","typ":"JWT"}"#); // Far-future exp so default expiry validation passes. let claims = serde_json::json!({ "sub": sub, "exp": 9_999_999_999u64 }); diff --git a/src/tls.rs b/src/tls.rs index 8e5abd0..fc49dd7 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -92,9 +92,15 @@ pub(crate) fn server_config( builder.with_client_cert_verifier(verifier) } }; - let mut server = builder - .with_single_cert(chain, key) - .map_err(|e| format!("{}: {e}", config.cert_file.display()))?; + // The provider's own key policy decides which identities it presents (the + // RustCrypto one refuses RSA), and the error may concern either file. + let mut server = builder.with_single_cert(chain, key).map_err(|e| { + format!( + "{} / {}: {e}", + config.cert_file.display(), + config.key_file.display() + ) + })?; server.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; Ok(server) } @@ -126,8 +132,7 @@ fn select_provider( }) } -/// aws-lc wins whenever it is compiled in, as it does for JWT verification: -/// constant-time, and free of the advisories the RustCrypto provider carries. +/// The explicitly enabled aws-lc backend retains precedence, as for JWT verification. #[cfg(feature = "aws_lc_rs")] fn builtin_provider() -> Option { Some(rustls::crypto::aws_lc_rs::default_provider()) @@ -136,7 +141,7 @@ fn builtin_provider() -> Option { /// The pure-Rust RustCrypto provider, the only backend this build compiled. #[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] fn builtin_provider() -> Option { - Some(rustls_rustcrypto::provider()) + Some(coordinode_crypto::provider()) } /// No crypto backend is compiled in. diff --git a/src/tls/tests.rs b/src/tls/tests.rs index f42eb17..7ac3f93 100644 --- a/src/tls/tests.rs +++ b/src/tls/tests.rs @@ -40,13 +40,16 @@ async fn serve( .collect::, _>>() .unwrap(); let key = PrivateKeyDer::from_pem_slice(key.as_bytes()).unwrap(); - let config = - rustls::ServerConfig::builder_with_provider(Arc::new(rustls_rustcrypto::provider())) - .with_protocol_versions(versions) - .unwrap() - .with_no_client_auth() - .with_single_cert(chain, key) - .unwrap(); + // An independent peer: the client under test is the proxy's, and the + // server must present RSA identities, which coordinode-crypto refuses. + let config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_protocol_versions(versions) + .unwrap() + .with_no_client_auth() + .with_single_cert(chain, key) + .unwrap(); let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(config)); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -80,7 +83,7 @@ async fn serve( /// handshakes below cover the one production uses; a build without a backend /// falls back to RustCrypto, as an embedder would install one. fn client(trusted: &str, host: &str, addr: SocketAddr) -> reqwest::Client { - let provider = Arc::new(builtin_provider().unwrap_or_else(rustls_rustcrypto::provider)); + let provider = Arc::new(builtin_provider().unwrap_or_else(coordinode_crypto::provider)); reqwest::Client::builder() .tls_backend_preconfigured(client_config_with(provider, roots(trusted)).unwrap()) .resolve(host, addr) @@ -172,11 +175,13 @@ fn production_config_trusts_the_bundled_mozilla_roots() { // The backend's provider, and the bundled root store is not empty. let config = client_config().unwrap(); assert!(!webpki_roots::TLS_SERVER_ROOTS.is_empty()); - assert!(config - .crypto_provider() - .cipher_suites - .iter() - .any(|suite| suite.version() == &rustls::version::TLS13)); + assert!( + config + .crypto_provider() + .cipher_suites + .iter() + .any(|suite| suite.version() == &rustls::version::TLS13) + ); } /// Which provider `p` is: its key provider's type, named by `Debug`. @@ -194,14 +199,14 @@ fn the_aws_lc_backend_brings_the_aws_lc_provider() { kind(&builtin), kind(&rustls::crypto::aws_lc_rs::default_provider()) ); - assert_ne!(kind(&builtin), kind(&rustls_rustcrypto::provider())); + assert_ne!(kind(&builtin), kind(&coordinode_crypto::provider())); } #[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] #[test] fn the_rust_crypto_backend_brings_the_rustcrypto_provider() { let builtin = builtin_provider().unwrap(); - assert_eq!(kind(&builtin), kind(&rustls_rustcrypto::provider())); + assert_eq!(kind(&builtin), kind(&coordinode_crypto::provider())); } #[cfg(not(any(feature = "rust_crypto", feature = "aws_lc_rs")))] @@ -213,7 +218,7 @@ fn without_a_crypto_backend_no_provider_is_linked() { #[test] fn the_installed_provider_wins_over_the_builtin_one() { - let installed = Arc::new(rustls_rustcrypto::provider()); + let installed = Arc::new(coordinode_crypto::provider()); let chosen = select_provider(Some(&installed), || { panic!("the builtin provider is not built when one is installed") }) @@ -223,7 +228,7 @@ fn the_installed_provider_wins_over_the_builtin_one() { #[test] fn without_an_installed_provider_the_builtin_one_is_used() { - let chosen = select_provider(None, || Some(rustls_rustcrypto::provider())).unwrap(); + let chosen = select_provider(None, || Some(coordinode_crypto::provider())).unwrap(); assert!(!chosen.cipher_suites.is_empty()); } @@ -245,7 +250,7 @@ fn a_provider_without_tls12_or_tls13_suites_is_refused() { // safe version is an error, not a panic. let provider = rustls::crypto::CryptoProvider { cipher_suites: Vec::new(), - ..rustls_rustcrypto::provider() + ..coordinode_crypto::provider() }; let err = client_config_with(Arc::new(provider), roots(CA)).unwrap_err(); assert!(err.contains("TLS 1.2 or 1.3"), "{err}"); diff --git a/src/transcode/codec.rs b/src/transcode/codec.rs index 2385e5d..04729fc 100644 --- a/src/transcode/codec.rs +++ b/src/transcode/codec.rs @@ -5,8 +5,8 @@ use prost::Message; use prost_reflect::{Cardinality, DynamicMessage, Kind, MessageDescriptor, ReflectMessage, Value}; -use tonic::codec::{BufferSettings, Codec, DecodeBuf, Decoder, EncodeBuf, Encoder}; use tonic::Status; +use tonic::codec::{BufferSettings, Codec, DecodeBuf, Decoder, EncodeBuf, Encoder}; /// Encoder for `DynamicMessage` → wire bytes. #[derive(Debug, Clone)] diff --git a/src/transcode/error.rs b/src/transcode/error.rs index 11ff761..fb6ecc6 100644 --- a/src/transcode/error.rs +++ b/src/transcode/error.rs @@ -5,9 +5,9 @@ //! renders the typed `google.rpc.Status` details the upstream attached in the //! `grpc-status-details-bin` trailer. +use axum::Json; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use axum::Json; use base64::Engine as _; use globset::GlobMatcher; use prost::Message as _; diff --git a/src/transcode/error/tests.rs b/src/transcode/error/tests.rs index 465509b..443a43c 100644 --- a/src/transcode/error/tests.rs +++ b/src/transcode/error/tests.rs @@ -346,7 +346,7 @@ fn object_valued_well_known_details_go_under_value() { // under `value`, decided by the type, not by the shape of its JSON: a // Struct is a JSON object yet still goes under `value`, and its own // `@type` key cannot overwrite the detail's. - use prost_reflect::prost_types::{value::Kind, Struct, Value as PbValue}; + use prost_reflect::prost_types::{Struct, Value as PbValue, value::Kind}; let mut fields = std::collections::BTreeMap::new(); fields.insert( "@type".to_string(), @@ -674,7 +674,7 @@ fn struct_keys_that_look_like_debug_info_are_kept() { // DebugInfo is withheld where an Any packs it. A Struct is data: a key // `@type` naming DebugInfo is just a string there, and the Struct must // stay whole and validly wrapped. - use prost_reflect::prost_types::{value::Kind, Struct, Value as PbValue}; + use prost_reflect::prost_types::{Struct, Value as PbValue, value::Kind}; let mut fields = std::collections::BTreeMap::new(); fields.insert( "@type".to_string(), diff --git a/src/transcode/mod.rs b/src/transcode/mod.rs index dc550d9..32a9d35 100644 --- a/src/transcode/mod.rs +++ b/src/transcode/mod.rs @@ -24,13 +24,13 @@ mod table; pub use path::proto_path_to_axum; pub use select::RpcSelection; +use axum::Router; use axum::body::{Body, Bytes}; use axum::extract::{FromRequest, FromRequestParts, OriginalUri, RawPathParams, Request, State}; use axum::http::header::{ALLOW, CONTENT_TYPE}; use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, Uri}; use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::{IntoResponse, Response}; -use axum::Router; use futures::{StreamExt, TryStreamExt}; use prost_reflect::{ DescriptorPool, DynamicMessage, FieldDescriptor, MessageDescriptor, MethodDescriptor, @@ -720,7 +720,7 @@ async fn dispatch( let (table, index) = match choice { Choice::Route { table, index } => (table, index), Choice::MethodNotAllowed { allow, .. } => { - return (StatusCode::METHOD_NOT_ALLOWED, [(ALLOW, allow)]).into_response() + return (StatusCode::METHOD_NOT_ALLOWED, [(ALLOW, allow)]).into_response(); } // Only routes served without a `Chooser` get here. Choice::NotFound => return StatusCode::NOT_FOUND.into_response(), diff --git a/src/transcode/request.rs b/src/transcode/request.rs index 2ddc6ca..3cc5596 100644 --- a/src/transcode/request.rs +++ b/src/transcode/request.rs @@ -14,8 +14,8 @@ use std::collections::HashMap; use prost_reflect::{ DynamicMessage, FieldDescriptor, Kind, MessageDescriptor, ReflectMessage, Value, }; -use serde::de::value::StrDeserializer; use serde::Deserializer; +use serde::de::value::StrDeserializer; use serde_json::Value as JsonValue; pub(crate) use presence::has_special_json; diff --git a/src/transcode/request/tests.rs b/src/transcode/request/tests.rs index a9410f6..8ec199c 100644 --- a/src/transcode/request/tests.rs +++ b/src/transcode/request/tests.rs @@ -1,10 +1,10 @@ use super::*; +use prost_reflect::DescriptorPool; use prost_reflect::prost::Message; use prost_reflect::prost_types::{ - field_descriptor_proto::{Label, Type}, DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet, + field_descriptor_proto::{Label, Type}, }; -use prost_reflect::DescriptorPool; use serde_json::Value; fn field( diff --git a/src/transcode/response.rs b/src/transcode/response.rs index 4062967..d1b0a25 100644 --- a/src/transcode/response.rs +++ b/src/transcode/response.rs @@ -7,7 +7,7 @@ //! (grpc-gateway's convention). use axum::body::Body; -use axum::http::header::{Entry, OccupiedEntry, CONTENT_TYPE}; +use axum::http::header::{CONTENT_TYPE, Entry, OccupiedEntry}; use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::Response; use tonic::metadata::MetadataMap; diff --git a/src/transcode/rule/tests.rs b/src/transcode/rule/tests.rs index eb6203c..0da295f 100644 --- a/src/transcode/rule/tests.rs +++ b/src/transcode/rule/tests.rs @@ -7,8 +7,8 @@ use prost_reflect::DescriptorPool; fn http_rule_descriptor() -> prost_reflect::MessageDescriptor { use prost_reflect::prost::Message; use prost_reflect::prost_types::{ - field_descriptor_proto::{Label, Type}, DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet, + field_descriptor_proto::{Label, Type}, }; let str_field = |name: &str, num: i32| FieldDescriptorProto { diff --git a/src/transcode/table.rs b/src/transcode/table.rs index 2bca486..489f960 100644 --- a/src/transcode/table.rs +++ b/src/transcode/table.rs @@ -16,7 +16,7 @@ use std::ops::ControlFlow; use axum::http::{HeaderValue, Method}; use rustc_hash::FxHashMap; -use super::path::{decode_multi_segment, Composite, MountedPath, Part}; +use super::path::{Composite, MountedPath, Part, decode_multi_segment}; use super::rule::RouteMethod; use super::{PathParams, RouteEntry}; @@ -881,7 +881,7 @@ impl PathTable { match &binding.entry.http_method { RouteMethod::Any => return Some(Choice::Route { table, index }), RouteMethod::One(bound) if bound == method => { - return Some(Choice::Route { table, index }) + return Some(Choice::Route { table, index }); } // A GET binding answers HEAD too, after one bound to HEAD. RouteMethod::One(bound) if *bound == Method::GET && *method == Method::HEAD => { diff --git a/src/transcode/tests.rs b/src/transcode/tests.rs index 2f5ebec..3fe996b 100644 --- a/src/transcode/tests.rs +++ b/src/transcode/tests.rs @@ -134,19 +134,25 @@ fn non_terminal_field_template_is_spelled_out_in_the_path() { ); let path = proto_path_to_axum("/v1/{name=projects/*}/topics"); let _router: Router<()> = Router::new().route(&path, get(|| async { "ok" })); - assert!(path::MountedPath::new("/v1/{name=projects/*}/topics") - .unsupported - .is_none()); + assert!( + path::MountedPath::new("/v1/{name=projects/*}/topics") + .unsupported + .is_none() + ); // `**` before the last segment has no faithful form: the binding is // left out rather than widened to one segment. A terminal one is still // a real catch-all. - assert!(path::MountedPath::new("/v1/{rest=**}/tail") - .unsupported - .is_some()); - assert!(path::MountedPath::new("/v1/{rest=a/**}/tail") - .unsupported - .is_some()); + assert!( + path::MountedPath::new("/v1/{rest=**}/tail") + .unsupported + .is_some() + ); + assert!( + path::MountedPath::new("/v1/{rest=a/**}/tail") + .unsupported + .is_some() + ); assert_eq!( proto_path_to_axum("/v1/files/{rest=**}"), "/v1/files/{*rest}" @@ -1059,8 +1065,8 @@ fn item_message() -> DynamicMessage { fn item_message_named(name: &str, count: i64) -> DynamicMessage { use prost_reflect::prost::Message; use prost_reflect::prost_types::{ - field_descriptor_proto::{Label, Type}, DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet, + field_descriptor_proto::{Label, Type}, }; let item = DescriptorProto { @@ -1191,8 +1197,8 @@ async fn ndjson_terminal_frame_carries_status_details() { /// no pool knows, so it cannot be serialized to JSON. fn unserializable_message() -> DynamicMessage { use prost_reflect::prost_types::{ - field_descriptor_proto::{Label, Type}, DescriptorProto, FieldDescriptorProto, FileDescriptorProto, + field_descriptor_proto::{Label, Type}, }; let wrapper = DescriptorProto { diff --git a/src/upstream.rs b/src/upstream.rs index 0382304..810cb16 100644 --- a/src/upstream.rs +++ b/src/upstream.rs @@ -9,7 +9,7 @@ use std::convert::Infallible; use std::future::Future; use std::pin::Pin; -use std::task::{ready, Context, Poll}; +use std::task::{Context, Poll, ready}; use bytes::Bytes; use pin_project_lite::pin_project; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 80da3cd..6c7385c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -8,10 +8,10 @@ use std::convert::Infallible; use axum::body::Body; use http::StatusCode; use prost_reflect::DescriptorPool; -use structured_proxy::transcode::error::ErrorDetailsPolicy; use structured_proxy::ProxyServer; -use tower::util::BoxCloneSyncService; +use structured_proxy::transcode::error::ErrorDetailsPolicy; use tower::ServiceExt; +use tower::util::BoxCloneSyncService; mod protos; diff --git a/tests/custom_verbs.rs b/tests/custom_verbs.rs index 9f7d319..7b4179e 100644 --- a/tests/custom_verbs.rs +++ b/tests/custom_verbs.rs @@ -11,7 +11,7 @@ mod common; use std::convert::Infallible; -use std::future::{ready, Ready}; +use std::future::{Ready, ready}; use std::pin::Pin; use std::sync::Arc; use std::task::{Context, Poll}; @@ -19,7 +19,7 @@ use std::task::{Context, Poll}; use axum::body::Body; use http::{Method, StatusCode}; use prost_reflect::{DescriptorPool, DynamicMessage, MessageDescriptor, Value as PbValue}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use structured_proxy::hooks::{ExtraRoute, ExtraRouteHandler, RouteRequest, RouteResponse}; use structured_proxy::transcode::codec::DynamicCodec; use tower::ServiceExt; diff --git a/tests/dynamic_codec.rs b/tests/dynamic_codec.rs index 698e1b5..7cf6afc 100644 --- a/tests/dynamic_codec.rs +++ b/tests/dynamic_codec.rs @@ -7,14 +7,14 @@ //! `Missing request message` and leaves the client without its response. use std::convert::Infallible; -use std::future::{ready, Ready}; +use std::future::{Ready, ready}; use std::pin::Pin; use std::task::{Context, Poll}; use prost::Message as _; use prost_reflect::prost_types::{ - field_descriptor_proto::{Label, Type}, DescriptorProto, FieldDescriptorProto, FileDescriptorProto, FileDescriptorSet, + field_descriptor_proto::{Label, Type}, }; use prost_reflect::{DescriptorPool, DynamicMessage, MessageDescriptor, ReflectMessage}; use structured_proxy::transcode::codec::DynamicCodec; diff --git a/tests/edge.rs b/tests/edge.rs index 0b8ecb7..f7d4c17 100644 --- a/tests/edge.rs +++ b/tests/edge.rs @@ -16,8 +16,8 @@ use axum::body::Body; use http::StatusCode; use prost_reflect::{DescriptorPool, DynamicMessage, Value as PbValue}; use serde_json::Value; -use structured_proxy::transcode::codec::DynamicCodec; use structured_proxy::ProxyServer; +use structured_proxy::transcode::codec::DynamicCodec; use tokio::io::{AsyncReadExt, AsyncWriteExt}; const EDGE_PROTO: &str = r#" @@ -802,10 +802,12 @@ async fn a_grpc_web_call_from_an_unlisted_origin_gets_no_allowance() { let response = tower::ServiceExt::oneshot(grpc_web_proxy_with(CORS_YAML), request) .await .unwrap(); - assert!(response - .headers() - .get("access-control-allow-origin") - .is_none()); + assert!( + response + .headers() + .get("access-control-allow-origin") + .is_none() + ); } #[tokio::test] diff --git a/tests/embedded.rs b/tests/embedded.rs index 4f6cd67..c70578f 100644 --- a/tests/embedded.rs +++ b/tests/embedded.rs @@ -8,11 +8,11 @@ //! it fails if any of them can no longer be built from its default or its //! constructor. +use structured_proxy::ProxyServer; use structured_proxy::config::{ AliasConfig, ClientAddressConfig, ConcurrencyConfig, DescriptorSource, ListenTlsConfig, ProxyConfig, ScopeConfig, Traffic, UpstreamConfig, }; -use structured_proxy::ProxyServer; #[test] fn embedded_config_is_constructible() { @@ -33,10 +33,12 @@ fn embedded_config_is_constructible() { client_address.trusted_proxies = vec!["10.0.0.0/8".into()]; config.client_address = client_address; // The default list, unlike a literal, keeps the serde defaults. - assert!(config - .forwarded_headers - .iter() - .any(|h| h == "authorization")); + assert!( + config + .forwarded_headers + .iter() + .any(|h| h == "authorization") + ); // The server accepts a programmatically-built config (the embedded path). let _server = ProxyServer::from_config(config); } diff --git a/tests/error_details.rs b/tests/error_details.rs index 5075aac..2c36c03 100644 --- a/tests/error_details.rs +++ b/tests/error_details.rs @@ -11,7 +11,7 @@ mod common; use std::convert::Infallible; -use std::future::{ready, Ready}; +use std::future::{Ready, ready}; use std::pin::Pin; use std::task::{Context, Poll}; @@ -20,7 +20,7 @@ use futures::stream::BoxStream; use http::StatusCode; use prost::Message as _; use prost_reflect::{DescriptorPool, DynamicMessage, MessageDescriptor}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use structured_proxy::transcode::codec::DynamicCodec; use structured_proxy::transcode::error::ErrorDetailsPolicy; use tonic_types::{BadRequest, DebugInfo, ErrorDetail, ErrorInfo, FieldViolation, StatusExt}; diff --git a/tests/forwarded_headers.rs b/tests/forwarded_headers.rs index 60bd4dc..9c78c3b 100644 --- a/tests/forwarded_headers.rs +++ b/tests/forwarded_headers.rs @@ -5,7 +5,7 @@ mod common; use std::convert::Infallible; -use std::future::{ready, Ready}; +use std::future::{Ready, ready}; use std::pin::Pin; use std::task::{Context, Poll}; diff --git a/tests/hooks.rs b/tests/hooks.rs index ba92521..3ea0ee1 100644 --- a/tests/hooks.rs +++ b/tests/hooks.rs @@ -11,12 +11,12 @@ use std::sync::Arc; use async_trait::async_trait; use http::{HeaderMap, Method, StatusCode}; +use structured_proxy::ProxyServer; use structured_proxy::config::ProxyConfig; use structured_proxy::hooks::{ AuthDecider, Decision, ExtraRoute, ExtraRouteHandler, MetadataDocument, OidcBackend, RequestParts, RouteRequest, RouteResponse, TokenVerifier, }; -use structured_proxy::ProxyServer; // --- embedder-supplied hook impls (axum-free) ---------------------------- @@ -195,9 +195,11 @@ async fn options_verify_request_is_decided_not_answered_by_cors() { .await .unwrap(); assert_eq!(preflight.status(), StatusCode::OK); - assert!(preflight - .headers() - .contains_key("access-control-allow-methods")); + assert!( + preflight + .headers() + .contains_key("access-control-allow-methods") + ); } #[tokio::test] @@ -399,10 +401,12 @@ auth: let result = ProxyServer::from_config(config).router(); let _ = std::fs::remove_file(&pem_path); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("registered by more than one endpoint")); + assert!( + result + .unwrap_err() + .to_string() + .contains("registered by more than one endpoint") + ); } #[tokio::test] @@ -423,10 +427,12 @@ openapi: .with_verify_path("/docs") .router(); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("registered by more than one endpoint")); + assert!( + result + .unwrap_err() + .to_string() + .contains("registered by more than one endpoint") + ); } #[tokio::test] @@ -441,10 +447,12 @@ async fn verify_path_colliding_with_oidc_route_is_a_clean_error() { .with_verify_path("/.well-known/openid-configuration") .router(); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("registered by more than one endpoint")); + assert!( + result + .unwrap_err() + .to_string() + .contains("registered by more than one endpoint") + ); } #[tokio::test] @@ -461,10 +469,12 @@ async fn extra_route_colliding_with_builtin_is_a_clean_error() { )]) .router(); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("registered by more than one endpoint")); + assert!( + result + .unwrap_err() + .to_string() + .contains("registered by more than one endpoint") + ); } #[tokio::test] @@ -514,10 +524,12 @@ async fn structurally_identical_dynamic_routes_are_a_clean_error() { ]) .router(); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("registered by more than one endpoint")); + assert!( + result + .unwrap_err() + .to_string() + .contains("registered by more than one endpoint") + ); } #[tokio::test] @@ -530,10 +542,12 @@ async fn malformed_extra_route_path_is_a_clean_error() { .with_extra_routes([ExtraRoute::new(Method::GET, "ping", Arc::new(PingHandler))]) .router(); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("must start with '/'")); + assert!( + result + .unwrap_err() + .to_string() + .contains("must start with '/'") + ); } #[tokio::test] @@ -545,10 +559,12 @@ async fn malformed_verify_path_is_a_clean_error() { .with_verify_path("auth/verify") // missing leading '/' .router(); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("must start with '/'")); + assert!( + result + .unwrap_err() + .to_string() + .contains("must start with '/'") + ); } #[tokio::test] @@ -587,10 +603,12 @@ auth: .router(); let _ = std::fs::remove_file(&pem_path); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("registered by more than one endpoint")); + assert!( + result + .unwrap_err() + .to_string() + .contains("registered by more than one endpoint") + ); } #[tokio::test] @@ -604,10 +622,12 @@ async fn verify_path_colliding_with_probe_is_a_clean_error() { .with_verify_path("/health") .router(); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("registered by more than one endpoint")); + assert!( + result + .unwrap_err() + .to_string() + .contains("registered by more than one endpoint") + ); } #[tokio::test] diff --git a/tests/request_mapping.rs b/tests/request_mapping.rs index 67e906b..d9870a9 100644 --- a/tests/request_mapping.rs +++ b/tests/request_mapping.rs @@ -9,14 +9,14 @@ mod common; use std::convert::Infallible; -use std::future::{ready, Ready}; +use std::future::{Ready, ready}; use std::pin::Pin; use std::task::{Context, Poll}; use axum::body::Body; use http::StatusCode; use prost_reflect::{DescriptorPool, DynamicMessage, MessageDescriptor}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use structured_proxy::transcode::codec::DynamicCodec; const ITEMS_PROTO: &str = r#" diff --git a/tests/shutdown.rs b/tests/shutdown.rs index 4a4a9f2..98c8e7a 100644 --- a/tests/shutdown.rs +++ b/tests/shutdown.rs @@ -239,7 +239,7 @@ impl Proxy { fn install_provider() { if rustls::crypto::CryptoProvider::get_default().is_none() { - rustls::crypto::CryptoProvider::install_default(rustls_rustcrypto::provider()) + rustls::crypto::CryptoProvider::install_default(coordinode_crypto::provider()) .expect("each test runs in its own process"); } } @@ -254,7 +254,7 @@ async fn connect(addr: SocketAddr, tls: bool, alpn: &[u8]) -> Box { roots.add(cert.unwrap()).unwrap(); } let mut config = - rustls::ClientConfig::builder_with_provider(Arc::new(rustls_rustcrypto::provider())) + rustls::ClientConfig::builder_with_provider(Arc::new(coordinode_crypto::provider())) .with_safe_default_protocol_versions() .unwrap() .with_root_certificates(roots) diff --git a/tests/streaming_request.rs b/tests/streaming_request.rs index 17d23e1..4ae4972 100644 --- a/tests/streaming_request.rs +++ b/tests/streaming_request.rs @@ -9,7 +9,7 @@ mod common; use std::convert::Infallible; -use std::future::{ready, Ready}; +use std::future::{Ready, ready}; use std::pin::Pin; use std::task::{Context, Poll}; @@ -17,7 +17,7 @@ use axum::body::Body; use futures::stream::BoxStream; use http::StatusCode; use prost_reflect::{DescriptorPool, DynamicMessage, MessageDescriptor}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use structured_proxy::transcode::codec::DynamicCodec; const THINGS_PROTO: &str = r#" diff --git a/tests/tls.rs b/tests/tls.rs index e5fe9b9..293ad20 100644 --- a/tests/tls.rs +++ b/tests/tls.rs @@ -8,7 +8,7 @@ mod protos; use std::convert::Infallible; -use std::future::{ready, Ready}; +use std::future::{Ready, ready}; use std::net::SocketAddr; use std::pin::Pin; use std::sync::Arc; @@ -119,7 +119,7 @@ impl tower::Service> for Who { // --- the embedder's TLS server ------------------------------------------------ fn provider() -> Arc { - Arc::new(rustls_rustcrypto::provider()) + Arc::new(coordinode_crypto::provider()) } fn chain() -> Vec> { @@ -245,7 +245,7 @@ const TESTDATA: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/tls/testdata"); async fn listen_builtin(listen_yaml: &str) -> SocketAddr { // Builds without a crypto backend feature take the process's provider. if rustls::crypto::CryptoProvider::get_default().is_none() { - rustls::crypto::CryptoProvider::install_default(rustls_rustcrypto::provider()) + rustls::crypto::CryptoProvider::install_default(coordinode_crypto::provider()) .expect("each test runs in its own process"); } let pool = pool(); diff --git a/tests/upstream_controls.rs b/tests/upstream_controls.rs index 4d59ea8..429c556 100644 --- a/tests/upstream_controls.rs +++ b/tests/upstream_controls.rs @@ -12,7 +12,7 @@ mod common; use std::convert::Infallible; -use std::future::{ready, Ready}; +use std::future::{Ready, ready}; use std::pin::Pin; use std::task::{Context, Poll}; @@ -23,10 +23,10 @@ use http::{HeaderMap, HeaderName, Method, StatusCode}; use http_body_util::BodyExt; use prost::Message as _; use prost_reflect::{DescriptorPool, DynamicMessage, Value as PbValue}; -use serde_json::{json, Value}; +use serde_json::{Value, json}; +use structured_proxy::ProxyServer; use structured_proxy::transcode::codec::DynamicCodec; use structured_proxy::transcode::error::ErrorDetailsPolicy; -use structured_proxy::ProxyServer; use tonic::metadata::{AsciiMetadataValue, BinaryMetadataValue}; use tower::ServiceExt; From 67fa627ec08be64fce172fe806a60f55f2ec684a Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 24 Oct 2026 11:16:38 +0300 Subject: [PATCH 2/7] fix(coordinode-crypto): bound AES-GCM confidentiality All six AES-GCM suites declared u64::MAX as their confidentiality limit, so rustls never updated the key or closed the connection before the AES-GCM record bound. Set 2^24 full-size records (RFC 8446 section 5.5, draft-irtf-cfrg-aead-limits section 5.1), as rustls' own providers do; ChaCha20-Poly1305 keeps u64::MAX. Part of #154 --- crates/coordinode-crypto/src/lib.rs | 17 +++++++++++------ crates/coordinode-crypto/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/coordinode-crypto/src/lib.rs b/crates/coordinode-crypto/src/lib.rs index 02226a7..73ea2c2 100644 --- a/crates/coordinode-crypto/src/lib.rs +++ b/crates/coordinode-crypto/src/lib.rs @@ -74,6 +74,11 @@ impl KeyProvider for Provider { } } +/// Records one AES-GCM key may protect before rustls updates or closes: 2^24 +/// full-size records (RFC 8446 section 5.5, draft-irtf-cfrg-aead-limits +/// section 5.1). ChaCha20-Poly1305 suites keep `u64::MAX`. +const AES_GCM_CONFIDENTIALITY_LIMIT: u64 = 1 << 24; + #[cfg(feature = "tls12")] const TLS12_ECDSA_SCHEMES: [SignatureScheme; 4] = [ SignatureScheme::ECDSA_NISTP256_SHA256, @@ -98,7 +103,7 @@ pub const TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: SupportedCipherSuite = common: CipherSuiteCommon { suite: CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, hash_provider: hash::SHA256, - confidentiality_limit: u64::MAX, + confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, }, kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, sign: &TLS12_ECDSA_SCHEMES, @@ -112,7 +117,7 @@ pub const TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: SupportedCipherSuite = common: CipherSuiteCommon { suite: CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, hash_provider: hash::SHA384, - confidentiality_limit: u64::MAX, + confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, }, kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, sign: &TLS12_ECDSA_SCHEMES, @@ -140,7 +145,7 @@ pub const TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: SupportedCipherSuite = common: CipherSuiteCommon { suite: CipherSuite::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, hash_provider: hash::SHA256, - confidentiality_limit: u64::MAX, + confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, }, kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, sign: &TLS12_RSA_SCHEMES, @@ -154,7 +159,7 @@ pub const TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: SupportedCipherSuite = common: CipherSuiteCommon { suite: CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, hash_provider: hash::SHA384, - confidentiality_limit: u64::MAX, + confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, }, kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, sign: &TLS12_RSA_SCHEMES, @@ -181,7 +186,7 @@ pub const TLS13_AES_128_GCM_SHA256: SupportedCipherSuite = common: CipherSuiteCommon { suite: CipherSuite::TLS13_AES_128_GCM_SHA256, hash_provider: hash::SHA256, - confidentiality_limit: u64::MAX, + confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, }, hkdf_provider: &rustls::crypto::tls13::HkdfUsingHmac(hmac::SHA256), aead_alg: &aead::gcm::Tls13Aes128Gcm, @@ -193,7 +198,7 @@ pub const TLS13_AES_256_GCM_SHA384: SupportedCipherSuite = common: CipherSuiteCommon { suite: CipherSuite::TLS13_AES_256_GCM_SHA384, hash_provider: hash::SHA384, - confidentiality_limit: u64::MAX, + confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, }, hkdf_provider: &rustls::crypto::tls13::HkdfUsingHmac(hmac::SHA384), aead_alg: &aead::gcm::Tls13Aes256Gcm, diff --git a/crates/coordinode-crypto/src/tests.rs b/crates/coordinode-crypto/src/tests.rs index 45fc5c4..333572b 100644 --- a/crates/coordinode-crypto/src/tests.rs +++ b/crates/coordinode-crypto/src/tests.rs @@ -6,6 +6,29 @@ use rustls::crypto::cipher::{AeadKey, Iv, Tls13AeadAlgorithm}; use rustls::pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, PrivatePkcs8KeyDer}; use rustls::{ConnectionTrafficSecrets, Error, PeerMisbehaved}; +// AES-GCM records under one key are bounded (RFC 8446 section 5.5, +// draft-irtf-cfrg-aead-limits section 5.1): past 2^24 full-size records rustls +// must update the key or close. ChaCha20-Poly1305 has no practical bound. +#[test] +fn aes_gcm_suites_carry_their_confidentiality_limit() { + let provider = super::provider(); + assert_eq!(provider.cipher_suites.len(), 9); + for suite in &provider.cipher_suites { + let common = match suite { + rustls::SupportedCipherSuite::Tls12(suite) => &suite.common, + rustls::SupportedCipherSuite::Tls13(suite) => &suite.common, + }; + let name = alloc::format!("{:?}", common.suite); + let expected = if name.contains("CHACHA20") { + u64::MAX + } else { + assert!(name.contains("_GCM_"), "{name}"); + 1 << 24 + }; + assert_eq!(common.confidentiality_limit, expected, "{name}"); + } +} + // TLS 1.3 must reject non-contributory X25519 shares (RFC 8446 section 7.4.2). #[test] fn x25519_rejects_low_order_shares() { From ef9ed38953529944f4b5005949cabd095179e955 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 24 Oct 2026 11:19:51 +0300 Subject: [PATCH 3/7] test(coordinode-crypto): check suites of the compiled profile The confidentiality-limit test expected nine suites, which fails the TLS 1.3-only build (`tls12` off) that offers three. Compare the exact suite list of the compiled profile, in offer order, with each limit, and run that profile's clippy and tests in CI. Part of #154 --- .github/workflows/ci.yml | 5 +++ crates/coordinode-crypto/src/tests.rs | 46 +++++++++++++++++---------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ececf70..b2f2e76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -218,6 +218,11 @@ jobs: run: cargo +stable clippy -p coordinode-crypto --all-targets --all-features - name: Test run: cargo +stable nextest run -p coordinode-crypto --all-features + # The TLS 1.3-only profile offers a different suite set. + - name: Clippy and test (no tls12) + run: | + cargo +stable clippy -p coordinode-crypto --all-targets --no-default-features --features std + cargo +stable nextest run -p coordinode-crypto --no-default-features --features std - name: Doc tests run: cargo +stable test --doc -p coordinode-crypto --all-features - name: MSRV (1.92) diff --git a/crates/coordinode-crypto/src/tests.rs b/crates/coordinode-crypto/src/tests.rs index 333572b..045d0de 100644 --- a/crates/coordinode-crypto/src/tests.rs +++ b/crates/coordinode-crypto/src/tests.rs @@ -11,22 +11,36 @@ use rustls::{ConnectionTrafficSecrets, Error, PeerMisbehaved}; // must update the key or close. ChaCha20-Poly1305 has no practical bound. #[test] fn aes_gcm_suites_carry_their_confidentiality_limit() { - let provider = super::provider(); - assert_eq!(provider.cipher_suites.len(), 9); - for suite in &provider.cipher_suites { - let common = match suite { - rustls::SupportedCipherSuite::Tls12(suite) => &suite.common, - rustls::SupportedCipherSuite::Tls13(suite) => &suite.common, - }; - let name = alloc::format!("{:?}", common.suite); - let expected = if name.contains("CHACHA20") { - u64::MAX - } else { - assert!(name.contains("_GCM_"), "{name}"); - 1 << 24 - }; - assert_eq!(common.confidentiality_limit, expected, "{name}"); - } + use rustls::CipherSuite as S; + const GCM: u64 = 1 << 24; + // The exact suites of the compiled profile, in offer order. + let mut expected = Vec::new(); + #[cfg(feature = "tls12")] + expected.extend([ + (S::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, GCM), + (S::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, GCM), + (S::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, u64::MAX), + (S::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, GCM), + (S::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, GCM), + (S::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, u64::MAX), + ]); + expected.extend([ + (S::TLS13_AES_128_GCM_SHA256, GCM), + (S::TLS13_AES_256_GCM_SHA384, GCM), + (S::TLS13_CHACHA20_POLY1305_SHA256, u64::MAX), + ]); + let actual: Vec<_> = super::provider() + .cipher_suites + .iter() + .map(|suite| { + let common = match suite { + rustls::SupportedCipherSuite::Tls12(suite) => &suite.common, + rustls::SupportedCipherSuite::Tls13(suite) => &suite.common, + }; + (common.suite, common.confidentiality_limit) + }) + .collect(); + assert_eq!(actual, expected); } // TLS 1.3 must reject non-contributory X25519 shares (RFC 8446 section 7.4.2). From 7e3ee7c676bc6ca5426d0b9a34def90980c96636 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 24 Oct 2026 11:33:27 +0300 Subject: [PATCH 4/7] fix(coordinode-crypto): refuse compressed ECDHE shares P-256 and P-384 key exchange accepted compressed SEC1 points, because the SEC1 decoder takes both forms. TLS 1.3 key shares and TLS 1.2 ECDHE points are uncompressed only (RFC 8446 section 4.2.8.2, RFC 8422 section 5.1.2): require the exact 65 / 97-byte length and the 0x04 prefix before decoding, in both TLS profiles. Part of #154 --- crates/coordinode-crypto/src/kx.rs | 11 +++++-- crates/coordinode-crypto/src/kx/tests.rs | 38 ++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/crates/coordinode-crypto/src/kx.rs b/crates/coordinode-crypto/src/kx.rs index e710b8e..195b1fe 100644 --- a/crates/coordinode-crypto/src/kx.rs +++ b/crates/coordinode-crypto/src/kx.rs @@ -62,7 +62,7 @@ impl crypto::ActiveKeyExchange for X25519KeyExchange { } macro_rules! impl_kx { - ($name:ident, $exchange:ident, $kx_name:expr, $secret:ty, $public_key:ty) => { + ($name:ident, $exchange:ident, $kx_name:expr, $secret:ty, $public_key:ty, $coordinate:expr) => { #[derive(Debug)] pub struct $name; @@ -89,6 +89,11 @@ macro_rules! impl_kx { impl crypto::ActiveKeyExchange for $exchange { fn complete(self: Box<$exchange>, peer: &[u8]) -> Result { + // Uncompressed points only (RFC 8446 section 4.2.8.2, RFC 8422 + // section 5.1.2); the SEC1 decoder would accept compressed ones. + if peer.len() != 1 + 2 * $coordinate || peer[0] != 0x04 { + return Err(rustls::PeerMisbehaved::InvalidKeyShare.into()); + } let their_pub = <$public_key>::from_sec1_bytes(peer) .map_err(|_| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?; Ok(self @@ -110,8 +115,8 @@ macro_rules! impl_kx { }; } -impl_kx! {SecP256R1, SecP256R1KeyExchange, rustls::NamedGroup::secp256r1, p256::ecdh::EphemeralSecret, p256::PublicKey} -impl_kx! {SecP384R1, SecP384R1KeyExchange, rustls::NamedGroup::secp384r1, p384::ecdh::EphemeralSecret, p384::PublicKey} +impl_kx! {SecP256R1, SecP256R1KeyExchange, rustls::NamedGroup::secp256r1, p256::ecdh::EphemeralSecret, p256::PublicKey, 32} +impl_kx! {SecP384R1, SecP384R1KeyExchange, rustls::NamedGroup::secp384r1, p384::ecdh::EphemeralSecret, p384::PublicKey, 48} pub const ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[&X25519, &SecP256R1, &SecP384R1]; diff --git a/crates/coordinode-crypto/src/kx/tests.rs b/crates/coordinode-crypto/src/kx/tests.rs index 6f7c210..050042a 100644 --- a/crates/coordinode-crypto/src/kx/tests.rs +++ b/crates/coordinode-crypto/src/kx/tests.rs @@ -1,4 +1,5 @@ use getrandom::rand_core::{TryCryptoRng, TryRng}; +use rustls::crypto::SupportedKxGroup; struct FailedEntropy; @@ -20,6 +21,43 @@ impl TryRng for FailedEntropy { impl TryCryptoRng for FailedEntropy {} +/// An uncompressed share of `group` completes; its compressed form of the same +/// point, and a share one byte short, are refused. +fn only_uncompressed_shares_complete(group: &dyn SupportedKxGroup, coordinate: usize) { + let peer = group + .start() + .expect("the system entropy source works in tests"); + let uncompressed = peer.pub_key(); + assert_eq!(uncompressed.len(), 1 + 2 * coordinate); + assert_eq!(uncompressed[0], 0x04); + assert!(group.start_and_complete(uncompressed).is_ok()); + + let mut compressed = alloc::vec![0x02 | (uncompressed[2 * coordinate] & 1)]; + compressed.extend_from_slice(&uncompressed[1..=coordinate]); + let truncated = &uncompressed[..2 * coordinate]; + for share in [compressed.as_slice(), truncated] { + assert!(matches!( + group.start_and_complete(share).map(|_| ()), + Err(rustls::Error::PeerMisbehaved( + rustls::PeerMisbehaved::InvalidKeyShare + )) + )); + } +} + +// TLS 1.3 key shares and TLS 1.2 ECDHE points are uncompressed only +// (RFC 8446 section 4.2.8.2, RFC 8422 section 5.1.2), although SEC1 decoders +// accept the compressed form of the same point. +#[test] +fn p256_key_share_is_uncompressed_only() { + only_uncompressed_shares_complete(&super::SecP256R1, 32); +} + +#[test] +fn p384_key_share_is_uncompressed_only() { + only_uncompressed_shares_complete(&super::SecP384R1, 48); +} + // Entropy failure must return a TLS error before an exchange is constructed. #[test] fn entropy_failure_does_not_panic_or_create_an_exchange() { From 377f46cc65a53c515fe49514ce0807666009e0e6 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 24 Oct 2026 11:38:30 +0300 Subject: [PATCH 5/7] fix(coordinode-crypto): bound TLS 1.2 record plaintext The TLS 1.2 AES-GCM and ChaCha20-Poly1305 decrypters returned authenticated plaintext longer than 2^14 bytes. rustls admits ciphertext up to 2^14 + 2048 and does not check the plaintext after a provider decrypts, so refuse it with PeerSentOversizedRecord (RFC 5246 section 6.2.1), as rustls' own providers do. TLS 1.3 keeps its unpadding check. Part of #154 --- crates/coordinode-crypto/src/aead.rs | 5 ++ crates/coordinode-crypto/src/aead/chacha20.rs | 4 + crates/coordinode-crypto/src/aead/gcm.rs | 4 + crates/coordinode-crypto/src/tests.rs | 84 +++++++++++++++++++ 4 files changed, 97 insertions(+) diff --git a/crates/coordinode-crypto/src/aead.rs b/crates/coordinode-crypto/src/aead.rs index 6ff57b7..175bec2 100644 --- a/crates/coordinode-crypto/src/aead.rs +++ b/crates/coordinode-crypto/src/aead.rs @@ -4,6 +4,11 @@ use rustls::crypto::cipher::{BorrowedPayload, PrefixedPayload}; pub mod chacha20; pub mod gcm; +/// Largest TLS 1.2 record plaintext, 2^14 bytes (RFC 5246 section 6.2.1). +/// rustls admits longer ciphertext, so the decrypter enforces it. +#[cfg(feature = "tls12")] +pub(crate) const TLS12_MAX_PLAINTEXT_LEN: usize = 1 << 14; + pub(crate) struct EncryptBufferAdapter<'a>(&'a mut PrefixedPayload); impl AsRef<[u8]> for EncryptBufferAdapter<'_> { diff --git a/crates/coordinode-crypto/src/aead/chacha20.rs b/crates/coordinode-crypto/src/aead/chacha20.rs index 0be7f38..3d73852 100644 --- a/crates/coordinode-crypto/src/aead/chacha20.rs +++ b/crates/coordinode-crypto/src/aead/chacha20.rs @@ -193,6 +193,10 @@ impl MessageDecrypter for Tls12Cipher { self.0 .decrypt_in_place(&nonce, &aad, &mut DecryptBufferAdapter(payload)) .map_err(|_| rustls::Error::DecryptError)?; + // The payload now holds the authenticated plaintext only. + if payload.len() > super::TLS12_MAX_PLAINTEXT_LEN { + return Err(rustls::Error::PeerSentOversizedRecord); + } Ok(m.into_plain_message()) } diff --git a/crates/coordinode-crypto/src/aead/gcm.rs b/crates/coordinode-crypto/src/aead/gcm.rs index 9f8deca..80205f9 100644 --- a/crates/coordinode-crypto/src/aead/gcm.rs +++ b/crates/coordinode-crypto/src/aead/gcm.rs @@ -243,6 +243,10 @@ macro_rules! impl_gcm_tls12 { .map_err(|_| rustls::Error::DecryptError)?; tag_pos }; + // `tag_pos` is the authenticated plaintext length. + if tag_pos > super::TLS12_MAX_PLAINTEXT_LEN { + return Err(rustls::Error::PeerSentOversizedRecord); + } // We defer the truncation to here, because we may inadvertently shifted the // original data if the decryption failed. Another way to avoid this is diff --git a/crates/coordinode-crypto/src/tests.rs b/crates/coordinode-crypto/src/tests.rs index 045d0de..ec3f69a 100644 --- a/crates/coordinode-crypto/src/tests.rs +++ b/crates/coordinode-crypto/src/tests.rs @@ -6,6 +6,90 @@ use rustls::crypto::cipher::{AeadKey, Iv, Tls13AeadAlgorithm}; use rustls::pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, PrivatePkcs8KeyDer}; use rustls::{ConnectionTrafficSecrets, Error, PeerMisbehaved}; +/// The TLS 1.2 AEAD of `suite` in `provider`. +#[cfg(feature = "tls12")] +fn tls12_aead( + provider: &rustls::crypto::CryptoProvider, + suite: rustls::CipherSuite, +) -> &'static dyn rustls::crypto::cipher::Tls12AeadAlgorithm { + match provider.cipher_suites.iter().find(|s| s.suite() == suite) { + Some(rustls::SupportedCipherSuite::Tls12(s)) => s.aead_alg, + _ => panic!("{suite:?} is not a TLS 1.2 suite of the provider"), + } +} + +/// A record of 2^14 plaintext bytes decrypts; one byte more is refused after +/// authentication, as by an independent provider. The sender is that +/// independent provider, so the record is genuinely authenticated. +#[cfg(feature = "tls12")] +fn tls12_record_limit(suite: rustls::CipherSuite) { + use rustls::crypto::cipher::{InboundOpaqueMessage, OutboundPlainMessage}; + use rustls::{ContentType, ProtocolVersion}; + + let oracle = tls12_aead(&rustls::crypto::ring::default_provider(), suite); + let ours = tls12_aead(&super::provider(), suite); + let shape = oracle.key_block_shape(); + let key = || AeadKey::from([1; 32]); + let iv = alloc::vec![0; shape.fixed_iv_len]; + let explicit = alloc::vec![0; shape.explicit_nonce_len]; + for length in [16_384, 16_385] { + let data = alloc::vec![7; length]; + let record = oracle + .encrypter(key(), &iv, &explicit) + .encrypt( + OutboundPlainMessage { + typ: ContentType::ApplicationData, + version: ProtocolVersion::TLSv1_2, + payload: data.as_slice().into(), + }, + 0, + ) + .unwrap(); + let decrypt = |decrypter: &dyn rustls::crypto::cipher::Tls12AeadAlgorithm| { + let mut bytes = record.payload.as_ref().to_vec(); + decrypter + .decrypter(key(), &iv) + .decrypt( + InboundOpaqueMessage::new(record.typ, record.version, &mut bytes), + 0, + ) + .map(|plain| plain.payload.to_vec()) + }; + // Lengths, not the buffers, so a failure prints a readable outcome. + let expected = if length == 16_384 { + Ok(length) + } else { + Err(Error::PeerSentOversizedRecord) + }; + for (who, aead) in [("oracle", oracle), ("ours", ours)] { + let outcome = decrypt(aead); + assert_eq!( + outcome.as_ref().map(Vec::len), + expected.as_ref().map(|len| *len), + "{who}, {length} bytes" + ); + if let Ok(plain) = outcome { + assert!(plain == data, "{who}: decrypted bytes differ"); + } + } + } +} + +// TLS 1.2 plaintext is at most 2^14 bytes (RFC 5246 section 6.2.1), even +// when the record authenticates. AES-128 shares this decrypter through +// `impl_gcm_tls12`; rustls' public `AeadKey` builds only 32-byte keys. +#[cfg(feature = "tls12")] +#[test] +fn tls12_aes_gcm_refuses_an_oversized_authenticated_record() { + tls12_record_limit(rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384); +} + +#[cfg(feature = "tls12")] +#[test] +fn tls12_chacha20_refuses_an_oversized_authenticated_record() { + tls12_record_limit(rustls::CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256); +} + // AES-GCM records under one key are bounded (RFC 8446 section 5.5, // draft-irtf-cfrg-aead-limits section 5.1): past 2^24 full-size records rustls // must update the key or close. ChaCha20-Poly1305 has no practical bound. From 76e07651ab4ad6f3320a78c52160d4f8ef2d919e Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 24 Oct 2026 11:43:46 +0300 Subject: [PATCH 6/7] test(coordinode-crypto): cover the AES-128 record limit The TLS 1.2 record-limit regression covered AES-256-GCM and ChaCha20-Poly1305 only. Exercise AES-128-GCM too, with the 16-byte keys of a local TLS 1.2 handshake extracted through rustls' dangerous_extract_secrets, and take a fresh key pair per decrypter under test. Part of #154 --- crates/coordinode-crypto/src/tests.rs | 146 ++++++++++++++++++++------ 1 file changed, 111 insertions(+), 35 deletions(-) diff --git a/crates/coordinode-crypto/src/tests.rs b/crates/coordinode-crypto/src/tests.rs index ec3f69a..ee0248e 100644 --- a/crates/coordinode-crypto/src/tests.rs +++ b/crates/coordinode-crypto/src/tests.rs @@ -20,49 +20,47 @@ fn tls12_aead( /// A record of 2^14 plaintext bytes decrypts; one byte more is refused after /// authentication, as by an independent provider. The sender is that -/// independent provider, so the record is genuinely authenticated. +/// independent provider, so the record is genuinely authenticated. `keys` +/// yields a fresh matching (sender, receiver) key pair for the suite. #[cfg(feature = "tls12")] -fn tls12_record_limit(suite: rustls::CipherSuite) { +fn tls12_record_limit(suite: rustls::CipherSuite, keys: &dyn Fn() -> (AeadKey, AeadKey)) { use rustls::crypto::cipher::{InboundOpaqueMessage, OutboundPlainMessage}; use rustls::{ContentType, ProtocolVersion}; let oracle = tls12_aead(&rustls::crypto::ring::default_provider(), suite); let ours = tls12_aead(&super::provider(), suite); let shape = oracle.key_block_shape(); - let key = || AeadKey::from([1; 32]); let iv = alloc::vec![0; shape.fixed_iv_len]; let explicit = alloc::vec![0; shape.explicit_nonce_len]; - for length in [16_384, 16_385] { - let data = alloc::vec![7; length]; - let record = oracle - .encrypter(key(), &iv, &explicit) - .encrypt( - OutboundPlainMessage { - typ: ContentType::ApplicationData, - version: ProtocolVersion::TLSv1_2, - payload: data.as_slice().into(), - }, - 0, - ) - .unwrap(); - let decrypt = |decrypter: &dyn rustls::crypto::cipher::Tls12AeadAlgorithm| { + for (who, receiver) in [("oracle", oracle), ("ours", ours)] { + let (tx, rx) = keys(); + let mut encrypter = oracle.encrypter(tx, &iv, &explicit); + let mut decrypter = receiver.decrypter(rx, &iv); + for (seq, length) in [(0, 16_384), (1, 16_385)] { + let data = alloc::vec![7; length]; + let record = encrypter + .encrypt( + OutboundPlainMessage { + typ: ContentType::ApplicationData, + version: ProtocolVersion::TLSv1_2, + payload: data.as_slice().into(), + }, + seq, + ) + .unwrap(); let mut bytes = record.payload.as_ref().to_vec(); - decrypter - .decrypter(key(), &iv) + let outcome = decrypter .decrypt( InboundOpaqueMessage::new(record.typ, record.version, &mut bytes), - 0, + seq, ) - .map(|plain| plain.payload.to_vec()) - }; - // Lengths, not the buffers, so a failure prints a readable outcome. - let expected = if length == 16_384 { - Ok(length) - } else { - Err(Error::PeerSentOversizedRecord) - }; - for (who, aead) in [("oracle", oracle), ("ours", ours)] { - let outcome = decrypt(aead); + .map(|plain| plain.payload.to_vec()); + // Lengths, not the buffers, so a failure prints a readable outcome. + let expected = if length == 16_384 { + Ok(length) + } else { + Err(Error::PeerSentOversizedRecord) + }; assert_eq!( outcome.as_ref().map(Vec::len), expected.as_ref().map(|len| *len), @@ -75,19 +73,97 @@ fn tls12_record_limit(suite: rustls::CipherSuite) { } } +/// A 32-byte key pair, for the suites whose key length matches +/// `AeadKey::from`. +#[cfg(feature = "tls12")] +fn fixed_256_bit_keys() -> (AeadKey, AeadKey) { + (AeadKey::from([1; 32]), AeadKey::from([1; 32])) +} + +/// A matching 16-byte AES-128-GCM key pair from a real local TLS 1.2 +/// handshake: rustls builds shorter `AeadKey`s only internally, and hands them +/// out through `dangerous_extract_secrets`. +#[cfg(all(feature = "tls12", feature = "std"))] +fn handshake_aes128_keys() -> (AeadKey, AeadKey) { + use alloc::sync::Arc; + const SUITE: rustls::CipherSuite = rustls::CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256; + + let certified = rcgen::generate_simple_self_signed(alloc::vec!["localhost".into()]).unwrap(); + let cert = certified.cert.der().clone(); + let mut provider = rustls::crypto::ring::default_provider(); + provider + .cipher_suites + .retain(|suite| suite.suite() == SUITE); + let provider = Arc::new(provider); + let mut server = rustls::ServerConfig::builder_with_provider(provider.clone()) + .with_protocol_versions(&[&rustls::version::TLS12]) + .unwrap() + .with_no_client_auth() + .with_single_cert(alloc::vec![cert.clone()], certified.signing_key.into()) + .unwrap(); + server.enable_secret_extraction = true; + let mut roots = rustls::RootCertStore::empty(); + roots.add(cert).unwrap(); + let mut client = rustls::ClientConfig::builder_with_provider(provider) + .with_protocol_versions(&[&rustls::version::TLS12]) + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + client.enable_secret_extraction = true; + let mut server = rustls::ServerConnection::new(Arc::new(server)).unwrap(); + let mut client = + rustls::ClientConnection::new(Arc::new(client), "localhost".try_into().unwrap()).unwrap(); + while client.is_handshaking() || server.is_handshaking() { + let mut bytes = Vec::new(); + client.write_tls(&mut bytes).unwrap(); + server.read_tls(&mut bytes.as_slice()).unwrap(); + server.process_new_packets().unwrap(); + bytes.clear(); + server.write_tls(&mut bytes).unwrap(); + client.read_tls(&mut bytes.as_slice()).unwrap(); + client.process_new_packets().unwrap(); + } + let ConnectionTrafficSecrets::Aes128Gcm { key: tx, .. } = + client.dangerous_extract_secrets().unwrap().tx.1 + else { + panic!("the handshake negotiated {SUITE:?}"); + }; + let ConnectionTrafficSecrets::Aes128Gcm { key: rx, .. } = + server.dangerous_extract_secrets().unwrap().rx.1 + else { + panic!("the handshake negotiated {SUITE:?}"); + }; + assert_eq!(tx.as_ref().len(), 16); + (tx, rx) +} + // TLS 1.2 plaintext is at most 2^14 bytes (RFC 5246 section 6.2.1), even -// when the record authenticates. AES-128 shares this decrypter through -// `impl_gcm_tls12`; rustls' public `AeadKey` builds only 32-byte keys. +// when the record authenticates. #[cfg(feature = "tls12")] #[test] -fn tls12_aes_gcm_refuses_an_oversized_authenticated_record() { - tls12_record_limit(rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384); +fn tls12_aes256_gcm_refuses_an_oversized_authenticated_record() { + tls12_record_limit( + rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, + &fixed_256_bit_keys, + ); +} + +#[cfg(all(feature = "tls12", feature = "std"))] +#[test] +fn tls12_aes128_gcm_refuses_an_oversized_authenticated_record() { + tls12_record_limit( + rustls::CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, + &handshake_aes128_keys, + ); } #[cfg(feature = "tls12")] #[test] fn tls12_chacha20_refuses_an_oversized_authenticated_record() { - tls12_record_limit(rustls::CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256); + tls12_record_limit( + rustls::CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, + &fixed_256_bit_keys, + ); } // AES-GCM records under one key are bounded (RFC 8446 section 5.5, From 3f60e59f2fc51a1bfc920640816bb95ae97adeac Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sat, 24 Oct 2026 17:37:16 +0300 Subject: [PATCH 7/7] refactor(crypto): take the providers from sid-crypto The RustCrypto rustls provider and the jsonwebtoken adapter now come from the shared sid-crypto crate (tls::rustcrypto::provider and jwt::rustcrypto::provider), which carries the AES-GCM confidentiality limit, uncompressed-only ECDHE shares, the TLS 1.2 plaintext bound and the bounded RSA public-key decoder. Neither installs a process default: the proxy still settles the JWT provider only when it builds the built-in verifier, and takes the host's rustls provider first. - Remove the in-tree coordinode-crypto crate and the JWT adapter; their own tests live with sid-crypto - Drop the coordinode-crypto CI job and release-plz entry; the publish dry-run checks this package alone - Point the RUSTSEC-2023-0071 rationale at the sid-crypto chain Part of #154 --- .cargo/audit.toml | 2 +- .github/workflows/ci.yml | 46 +-- Cargo.toml | 25 +- README.md | 2 +- crates/coordinode-crypto/COPYRIGHT | 7 - crates/coordinode-crypto/Cargo.toml | 46 --- crates/coordinode-crypto/LICENSE | 6 - crates/coordinode-crypto/LICENSE-APACHE | 201 ---------- crates/coordinode-crypto/LICENSE-MIT | 19 - crates/coordinode-crypto/NOTICES | 11 - crates/coordinode-crypto/README.md | 51 --- crates/coordinode-crypto/src/aead.rs | 59 --- crates/coordinode-crypto/src/aead/chacha20.rs | 205 ---------- crates/coordinode-crypto/src/aead/gcm.rs | 270 ------------- crates/coordinode-crypto/src/hash.rs | 55 --- crates/coordinode-crypto/src/hmac.rs | 57 --- crates/coordinode-crypto/src/identity.rs | 104 ----- crates/coordinode-crypto/src/kx.rs | 124 ------ crates/coordinode-crypto/src/kx/tests.rs | 68 ---- crates/coordinode-crypto/src/lib.rs | 248 ------------ crates/coordinode-crypto/src/public_key.rs | 39 -- crates/coordinode-crypto/src/sign.rs | 98 ----- crates/coordinode-crypto/src/sign/ecdsa.rs | 74 ---- crates/coordinode-crypto/src/sign/eddsa.rs | 53 --- crates/coordinode-crypto/src/tests.rs | 375 ------------------ crates/coordinode-crypto/src/tests/tls.rs | 301 -------------- crates/coordinode-crypto/src/verify.rs | 46 --- crates/coordinode-crypto/src/verify/ecdsa.rs | 51 --- crates/coordinode-crypto/src/verify/eddsa.rs | 32 -- crates/coordinode-crypto/src/verify/rsa.rs | 97 ----- deny.toml | 9 +- release-plz.toml | 6 - src/auth/crypto.rs | 7 +- src/auth/crypto/rust_crypto.rs | 295 -------------- src/auth/crypto/rust_crypto/keys.rs | 55 --- src/auth/crypto/rust_crypto/tests.rs | 237 ----------- src/auth/crypto/tests.rs | 2 +- src/serve/tests.rs | 4 +- src/shield/resolve/tests.rs | 4 +- src/tls.rs | 2 +- src/tls/tests.rs | 21 +- tests/shutdown.rs | 15 +- tests/tls.rs | 4 +- 43 files changed, 46 insertions(+), 3387 deletions(-) delete mode 100644 crates/coordinode-crypto/COPYRIGHT delete mode 100644 crates/coordinode-crypto/Cargo.toml delete mode 100644 crates/coordinode-crypto/LICENSE delete mode 100644 crates/coordinode-crypto/LICENSE-APACHE delete mode 100644 crates/coordinode-crypto/LICENSE-MIT delete mode 100644 crates/coordinode-crypto/NOTICES delete mode 100644 crates/coordinode-crypto/README.md delete mode 100644 crates/coordinode-crypto/src/aead.rs delete mode 100644 crates/coordinode-crypto/src/aead/chacha20.rs delete mode 100644 crates/coordinode-crypto/src/aead/gcm.rs delete mode 100644 crates/coordinode-crypto/src/hash.rs delete mode 100644 crates/coordinode-crypto/src/hmac.rs delete mode 100644 crates/coordinode-crypto/src/identity.rs delete mode 100644 crates/coordinode-crypto/src/kx.rs delete mode 100644 crates/coordinode-crypto/src/kx/tests.rs delete mode 100644 crates/coordinode-crypto/src/lib.rs delete mode 100644 crates/coordinode-crypto/src/public_key.rs delete mode 100644 crates/coordinode-crypto/src/sign.rs delete mode 100644 crates/coordinode-crypto/src/sign/ecdsa.rs delete mode 100644 crates/coordinode-crypto/src/sign/eddsa.rs delete mode 100644 crates/coordinode-crypto/src/tests.rs delete mode 100644 crates/coordinode-crypto/src/tests/tls.rs delete mode 100644 crates/coordinode-crypto/src/verify.rs delete mode 100644 crates/coordinode-crypto/src/verify/ecdsa.rs delete mode 100644 crates/coordinode-crypto/src/verify/eddsa.rs delete mode 100644 crates/coordinode-crypto/src/verify/rsa.rs delete mode 100644 src/auth/crypto/rust_crypto.rs delete mode 100644 src/auth/crypto/rust_crypto/keys.rs delete mode 100644 src/auth/crypto/rust_crypto/tests.rs diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 6078335..9feb315 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -4,7 +4,7 @@ # Ignored deliberately; mirror of deny.toml, which carries the full rationale. # # RUSTSEC-2023-0071: "Marvin Attack" timing sidechannel in `rsa`, via the -# RustCrypto JWT backend and the coordinode-crypto rustls provider. +# sid-crypto RustCrypto JWT and rustls providers. # No fixed `rsa` release exists; RSA only verifies signatures with public keys # (JWTs, TLS peer certificates), RSA private-key operations are refused, and # Marvin targets private-key timing. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2f2e76..3b8c7ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,12 +130,10 @@ jobs: - name: Doc tests run: cargo test --doc ${{ matrix.backend.flags }} - # Both packages, so structured-proxy is verified against the - # coordinode-crypto it would be published with; with `cli`, so the - # packaged binary compiles too. + # With `cli`, so the packaged binary compiles too. - name: Publish dry-run if: matrix.backend.name == 'rust_crypto' - run: cargo publish --dry-run --workspace --features structured-proxy/cli + run: cargo publish --dry-run --features cli # The release packages ship static musl binaries; build and test that target # on every change, so a dependency that breaks it fails here, not at release. @@ -194,46 +192,6 @@ jobs: - name: Check (injected verifier) run: cargo check --all-targets --no-default-features --features cli - # The rustls provider package: not a default member, so the jobs above do - # not build its own targets. It has its own MSRV and an allocation-only build. - coordinode-crypto: - name: coordinode-crypto - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - with: - persist-credentials: false - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - targets: thumbv7em-none-eabihf - - uses: dtolnay/rust-toolchain@1.92 - # Third-party action: pinned to a commit SHA (dependabot keeps it fresh). - - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - # Third-party action: pinned to a commit SHA (dependabot keeps it fresh). - - uses: taiki-e/install-action@8cca0918f8471c33515202b73f56ab27f2bed283 # v2 - with: - tool: cargo-nextest - - name: Clippy - run: cargo +stable clippy -p coordinode-crypto --all-targets --all-features - - name: Test - run: cargo +stable nextest run -p coordinode-crypto --all-features - # The TLS 1.3-only profile offers a different suite set. - - name: Clippy and test (no tls12) - run: | - cargo +stable clippy -p coordinode-crypto --all-targets --no-default-features --features std - cargo +stable nextest run -p coordinode-crypto --no-default-features --features std - - name: Doc tests - run: cargo +stable test --doc -p coordinode-crypto --all-features - - name: MSRV (1.92) - run: cargo +1.92 check -p coordinode-crypto --all-targets - # A target with no std: a host target could pull std in through a - # transitively unified feature without failing. - - name: No-std check - env: - RUSTFLAGS: '-Dwarnings --cfg getrandom_backend="custom"' - run: cargo +stable check -p coordinode-crypto --no-default-features --features alloc --target thumbv7em-none-eabihf - security-audit: name: Security Audit runs-on: ubuntu-latest diff --git a/Cargo.toml b/Cargo.toml index bdcffd1..1776c4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,3 @@ -[workspace] -resolver = "2" -members = [".", "crates/coordinode-crypto"] -default-members = ["."] - [package] name = "structured-proxy" description = "Universal gRPC→REST transcoding proxy — config-driven, works with any gRPC service" @@ -150,18 +145,8 @@ jsonwebtoken = { version = "11", default-features = false, features = [ # rustls provider the embedder installs is used instead. reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "json"] } rustls = { version = "0.23", default-features = false, features = ["std", "tls12"] } -# The shared, pure-Rust rustls adapter and bounded RSA public-key decoder. -coordinode-crypto = { version = "0.1", path = "crates/coordinode-crypto", optional = true } -# jsonwebtoken's provider extension keeps JWT parsing and validation in the -# library while using the current RustCrypto primitive APIs. -hmac = { version = "0.13", default-features = false, features = ["zeroize"], optional = true } -p256 = { version = "0.14", default-features = false, features = ["ecdsa", "pkcs8"], optional = true } -p384 = { version = "0.14", default-features = false, features = ["ecdsa", "pkcs8"], optional = true } -ed25519-dalek = { version = "3", default-features = false, features = ["pkcs8", "alloc", "zeroize"], optional = true } -rsa = { version = "0.10.0-rc.19", default-features = false, features = ["sha2", "encoding"], optional = true } -# The JWT extension traits use signature 2; the current primitives use signature 3. -signature = { version = "2", default-features = false, optional = true } -signature-v3 = { package = "signature", version = "3", default-features = false, features = ["alloc"], optional = true } +# The pure-Rust rustls and jsonwebtoken providers of the `rust_crypto` backend. +sid-crypto = { version = "0.1", default-features = false, features = ["std", "tls-rustcrypto", "tls12", "jwt-rustcrypto"], optional = true } webpki-roots = "1" globset = "0.4" base64 = "0.23" @@ -196,7 +181,7 @@ builtin_jwt = ["dep:jsonwebtoken"] # `rust_crypto` (default): pure-Rust RustCrypto backend. Pulls in `rsa`, which # carries RUSTSEC-2023-0071 (Marvin Attack). Not exploitable on our verify-only # path (public key, no private-key ops); see deny.toml / issue #48. -rust_crypto = ["builtin_jwt", "dep:coordinode-crypto", "dep:hmac", "dep:p256", "dep:p384", "dep:ed25519-dalek", "dep:rsa", "dep:signature", "dep:signature-v3"] +rust_crypto = ["builtin_jwt", "dep:sid-crypto"] # `aws_lc_rs`: constant-time / FIPS-capable backend via aws-lc (C FFI), # advisory-free. Opt-in for consumers that allow FFI; enable with # `default-features = false, features = ["aws_lc_rs"]`. @@ -229,9 +214,9 @@ http-body = "1" # (src/tls/tests.rs), and the provider it runs on in every build. Default # features would pull in aws-lc. tokio-rustls = { version = "0.26", default-features = false, features = ["tls12"] } -coordinode-crypto = { version = "0.1", path = "crates/coordinode-crypto" } +sid-crypto = { version = "0.1", default-features = false, features = ["std", "tls-rustcrypto", "tls12"] } # The test HTTPS servers' provider: an independent TLS peer that can present -# RSA identities, which coordinode-crypto refuses. Excluded from product +# RSA identities, which the RustCrypto provider refuses. Excluded from product # dependencies. rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] } # benches/jwt_verify.rs. Plots and rayon are left out: numbers are enough. diff --git a/README.md b/README.md index 841e386..1cc0650 100644 --- a/README.md +++ b/README.md @@ -1477,7 +1477,7 @@ needed. The rustls crypto provider is, in order: `rustls::crypto::CryptoProvider::install_default`, if any: an explicit choice wins; 2. aws-lc, with the `aws_lc_rs` feature; -3. the maintained pure-Rust RustCrypto adapter (`coordinode-crypto`), with `rust_crypto`. +3. the pure-Rust RustCrypto provider of `sid-crypto`, with `rust_crypto`. The default build is pure Rust; aws-lc (C) comes with `aws_lc_rs`. A `default-features = false` build brings no provider, so a crate that only diff --git a/crates/coordinode-crypto/COPYRIGHT b/crates/coordinode-crypto/COPYRIGHT deleted file mode 100644 index 8a4e5a1..0000000 --- a/crates/coordinode-crypto/COPYRIGHT +++ /dev/null @@ -1,7 +0,0 @@ -Upstream rustls-rustcrypto implementation: -Copyright (c) 2023 Steve Fan, ctz. - -Modifications: -Copyright (c) 2026 Dmitry Prudnikov. - -Licensed under MIT OR Apache-2.0; see LICENSE-MIT and LICENSE-APACHE. diff --git a/crates/coordinode-crypto/Cargo.toml b/crates/coordinode-crypto/Cargo.toml deleted file mode 100644 index 653bd2a..0000000 --- a/crates/coordinode-crypto/Cargo.toml +++ /dev/null @@ -1,46 +0,0 @@ -[package] -name = "coordinode-crypto" -version = "0.1.0" -edition = "2024" -rust-version = "1.92" -license = "MIT OR Apache-2.0" -description = "Pure-Rust RustCrypto provider for rustls" -repository = "https://github.com/structured-world/structured-proxy" -readme = "README.md" -publish = true - -[dependencies] -aead = { version = "0.6", default-features = false } -aes-gcm = { version = "0.11", default-features = false, features = ["aes", "alloc", "zeroize"] } -chacha20poly1305 = { version = "0.11", default-features = false, features = ["zeroize"] } -crypto-common = { version = "0.2", default-features = false } -crypto-bigint = { version = "0.7.6", default-features = false } -der = { version = "0.8", default-features = false } -digest = { version = "0.11", default-features = false } -ed25519-dalek = { version = "3", default-features = false, features = ["pkcs8", "zeroize"] } -getrandom = { version = "0.4", default-features = false, features = ["sys_rng"] } -hmac = { version = "0.13", default-features = false, features = ["zeroize"] } -p256 = { version = "0.14", default-features = false, features = ["alloc", "pkcs8", "ecdsa", "ecdh"] } -p384 = { version = "0.14", default-features = false, features = ["alloc", "pkcs8", "ecdsa", "ecdh"] } -pkcs8 = { version = "0.11", default-features = false } -pki-types = { package = "rustls-pki-types", version = "1", default-features = false } -rsa = { version = "0.10.0-rc.19", default-features = false, features = ["sha2", "encoding"] } -rustls = { version = "0.23", default-features = false } -sec1 = { version = "0.8", default-features = false } -sha2 = { version = "0.11", default-features = false, features = ["zeroize"] } -signature = { version = "3", default-features = false } -x25519-dalek = { version = "3", default-features = false, features = ["static_secrets", "zeroize"] } -zeroize = { version = "1", default-features = false } - -[features] -default = ["std", "tls12"] -std = ["alloc", "pki-types/std", "rustls/std"] -alloc = ["pki-types/alloc", "aead/alloc", "ed25519-dalek/alloc"] -tls12 = ["rustls/tls12"] -logging = ["rustls/logging"] - -[dev-dependencies] -# Independent certificate generation is a test oracle, not a runtime dependency. -rcgen = "0.14" -# Independent TLS interoperability oracle, excluded from product dependencies. -rustls = { version = "0.23", default-features = false, features = ["std", "tls12", "ring"] } diff --git a/crates/coordinode-crypto/LICENSE b/crates/coordinode-crypto/LICENSE deleted file mode 100644 index ae6393d..0000000 --- a/crates/coordinode-crypto/LICENSE +++ /dev/null @@ -1,6 +0,0 @@ -This package is available under either of these licenses, at your option: - -* MIT, reproduced in LICENSE-MIT. -* Apache License, Version 2.0, reproduced in LICENSE-APACHE. - -See NOTICES for the upstream source and retained copyright information. diff --git a/crates/coordinode-crypto/LICENSE-APACHE b/crates/coordinode-crypto/LICENSE-APACHE deleted file mode 100644 index 78173fa..0000000 --- a/crates/coordinode-crypto/LICENSE-APACHE +++ /dev/null @@ -1,201 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - -TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - -1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - -2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - -3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - -4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - -5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - -6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - -7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - -8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - -9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - -END OF TERMS AND CONDITIONS - -APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - -Copyright [yyyy] [name of copyright owner] - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. diff --git a/crates/coordinode-crypto/LICENSE-MIT b/crates/coordinode-crypto/LICENSE-MIT deleted file mode 100644 index 966be2b..0000000 --- a/crates/coordinode-crypto/LICENSE-MIT +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2023 Steve Fan, ctz - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/crates/coordinode-crypto/NOTICES b/crates/coordinode-crypto/NOTICES deleted file mode 100644 index 864423e..0000000 --- a/crates/coordinode-crypto/NOTICES +++ /dev/null @@ -1,11 +0,0 @@ -This package includes an adapted rustls-rustcrypto implementation from -https://github.com/RustCrypto/rustls-rustcrypto at revision -70f76c039e587192688af18a80d5d6435dedaf22. - -The upstream implementation is distributed under MIT OR Apache-2.0. Its -original license texts and copyright notice are retained in LICENSE-MIT -and LICENSE-APACHE. Adaptations include current dependency constraints, -explicit macro identifiers, identity admission, bounded public RSA decoding, -X25519 contributory checks, fallible entropy handling and AEAD corrections. - -Independent TLS/certificate backends are used only by development tests. diff --git a/crates/coordinode-crypto/README.md b/crates/coordinode-crypto/README.md deleted file mode 100644 index 1873f07..0000000 --- a/crates/coordinode-crypto/README.md +++ /dev/null @@ -1,51 +0,0 @@ -# CoordiNode RustCrypto provider - -A pure-Rust cryptography provider for rustls, based on -[RustCrypto/rustls-rustcrypto](https://github.com/RustCrypto/rustls-rustcrypto) -revision `70f76c039e587192688af18a80d5d6435dedaf22`. - -Rustls owns TLS and certificate-path validation. This package adapts RustCrypto -primitives to rustls and retains the upstream MIT OR Apache-2.0 licensing. - -## Use - -```rust -let provider = std::sync::Arc::new(coordinode_crypto::provider()); -let builder = rustls::ClientConfig::builder_with_provider(provider) - .with_safe_default_protocol_versions()?; -# Ok::<(), rustls::Error>(()) -``` - -The provider supports TLS 1.3 and, with `tls12`, TLS 1.2. It supplies AES-GCM, -ChaCha20-Poly1305, X25519, P-256 and P-384 key exchange, and ECDSA/Ed25519 -local identities. X25519 rejects every all-zero shared secret. RSA certificate -and peer signatures remain verifiable with 2048..=8192-bit public keys, while -RSA private signing and decryption are unavailable through the provider. -`identity::IdentityKeyProvider` applies the same local identity admission to -an explicitly selected, separately qualified backend. - -Standalone SEC1 identities require named P-256/P-384 parameters and canonical -scalar widths. PKCS#8 identities retain algorithm checks; a PEM container name -does not identify its private-key algorithm. Rustls retains certificate-chain, -hostname, purpose and validity checks. - -## Allocation-only builds - -The library is `no_std` with an explicit `alloc` feature. The default `std` -feature enables rustls' standard-library integration. Secret key storage is -zeroized independently of that choice. - -Targets without an operating-system entropy source must supply a secure custom -backend using the [getrandom backend contract](https://docs.rs/getrandom/0.4.3/getrandom/#custom-backend). -An allocation-only compile check is: - -```sh -RUSTFLAGS='--cfg getrandom_backend="custom"' cargo check -p coordinode-crypto \ - --no-default-features --features alloc --target thumbv7em-none-eabihf -``` - -This checks compilation, not the availability or security of a target's entropy -implementation. Production entropy errors propagate as failures. - -The package has an independent version and is shared by CoordiNode and -structured-proxy. See `LICENSE-MIT`, `LICENSE-APACHE` and `NOTICES` for licensing. diff --git a/crates/coordinode-crypto/src/aead.rs b/crates/coordinode-crypto/src/aead.rs deleted file mode 100644 index 175bec2..0000000 --- a/crates/coordinode-crypto/src/aead.rs +++ /dev/null @@ -1,59 +0,0 @@ -use aead::Buffer; -use rustls::crypto::cipher::{BorrowedPayload, PrefixedPayload}; - -pub mod chacha20; -pub mod gcm; - -/// Largest TLS 1.2 record plaintext, 2^14 bytes (RFC 5246 section 6.2.1). -/// rustls admits longer ciphertext, so the decrypter enforces it. -#[cfg(feature = "tls12")] -pub(crate) const TLS12_MAX_PLAINTEXT_LEN: usize = 1 << 14; - -pub(crate) struct EncryptBufferAdapter<'a>(&'a mut PrefixedPayload); - -impl AsRef<[u8]> for EncryptBufferAdapter<'_> { - fn as_ref(&self) -> &[u8] { - self.0.as_ref() - } -} - -impl AsMut<[u8]> for EncryptBufferAdapter<'_> { - fn as_mut(&mut self) -> &mut [u8] { - self.0.as_mut() - } -} - -impl Buffer for EncryptBufferAdapter<'_> { - fn extend_from_slice(&mut self, other: &[u8]) -> aead::Result<()> { - self.0.extend_from_slice(other); - Ok(()) - } - - fn truncate(&mut self, len: usize) { - self.0.truncate(len) - } -} - -pub(crate) struct DecryptBufferAdapter<'a, 'p>(&'a mut BorrowedPayload<'p>); - -impl AsRef<[u8]> for DecryptBufferAdapter<'_, '_> { - fn as_ref(&self) -> &[u8] { - self.0 - } -} - -impl AsMut<[u8]> for DecryptBufferAdapter<'_, '_> { - fn as_mut(&mut self) -> &mut [u8] { - self.0 - } -} - -impl Buffer for DecryptBufferAdapter<'_, '_> { - fn extend_from_slice(&mut self, _: &[u8]) -> aead::Result<()> { - unreachable!("not used by `AeadInPlace::decrypt_in_place`") - } - - fn truncate(&mut self, len: usize) { - self.0.truncate(len) - } -} diff --git a/crates/coordinode-crypto/src/aead/chacha20.rs b/crates/coordinode-crypto/src/aead/chacha20.rs deleted file mode 100644 index 3d73852..0000000 --- a/crates/coordinode-crypto/src/aead/chacha20.rs +++ /dev/null @@ -1,205 +0,0 @@ -#[cfg(feature = "alloc")] -use alloc::boxed::Box; - -use super::{DecryptBufferAdapter, EncryptBufferAdapter}; - -use aead::{AeadInOut, KeySizeUser}; -use chacha20poly1305::KeyInit; -use rustls::crypto::cipher::{ - self, AeadKey, InboundOpaqueMessage, InboundPlainMessage, Iv, MessageDecrypter, - MessageEncrypter, OutboundOpaqueMessage, OutboundPlainMessage, PrefixedPayload, - Tls13AeadAlgorithm, UnsupportedOperationError, -}; -use rustls::{ConnectionTrafficSecrets, ContentType, ProtocolVersion}; - -#[cfg(feature = "tls12")] -use rustls::crypto::cipher::{KeyBlockShape, NONCE_LEN, Tls12AeadAlgorithm}; - -pub struct Chacha20Poly1305; - -impl Tls13AeadAlgorithm for Chacha20Poly1305 { - fn encrypter(&self, key: AeadKey, iv: Iv) -> Box { - Box::new(Tls13Cipher( - chacha20poly1305::ChaCha20Poly1305::new_from_slice(key.as_ref()) - .expect("key should be valid"), - iv, - )) - } - - fn decrypter(&self, key: AeadKey, iv: Iv) -> Box { - Box::new(Tls13Cipher( - chacha20poly1305::ChaCha20Poly1305::new_from_slice(key.as_ref()) - .expect("key should be valid"), - iv, - )) - } - - fn key_len(&self) -> usize { - chacha20poly1305::ChaCha20Poly1305::key_size() - } - - fn extract_keys( - &self, - key: AeadKey, - iv: Iv, - ) -> Result { - Ok(ConnectionTrafficSecrets::Chacha20Poly1305 { key, iv }) - } -} - -#[cfg(feature = "tls12")] -impl Tls12AeadAlgorithm for Chacha20Poly1305 { - fn encrypter(&self, key: AeadKey, iv: &[u8], _: &[u8]) -> Box { - Box::new(Tls12Cipher( - chacha20poly1305::ChaCha20Poly1305::new_from_slice(key.as_ref()) - .expect("key should be valid"), - Iv::copy(iv), - )) - } - - fn decrypter(&self, key: AeadKey, iv: &[u8]) -> Box { - Box::new(Tls12Cipher( - chacha20poly1305::ChaCha20Poly1305::new_from_slice(key.as_ref()) - .expect("key should be valid"), - Iv::copy(iv), - )) - } - - fn key_block_shape(&self) -> KeyBlockShape { - KeyBlockShape { - enc_key_len: 32, - fixed_iv_len: 12, - explicit_nonce_len: 0, - } - } - - fn extract_keys( - &self, - key: AeadKey, - iv: &[u8], - _explicit: &[u8], - ) -> Result { - // This should always be true because KeyBlockShape and the Iv nonce len are in - // agreement. - debug_assert_eq!(NONCE_LEN, iv.len()); - Ok(ConnectionTrafficSecrets::Chacha20Poly1305 { - key, - iv: Iv::new(iv[..].try_into().expect("conversion should succeed")), - }) - } -} - -struct Tls13Cipher(chacha20poly1305::ChaCha20Poly1305, Iv); - -impl MessageEncrypter for Tls13Cipher { - fn encrypt( - &mut self, - m: OutboundPlainMessage<'_>, - seq: u64, - ) -> Result { - let total_len = self.encrypted_payload_len(m.payload.len()); - let mut payload = PrefixedPayload::with_capacity(total_len); - - payload.extend_from_chunks(&m.payload); - payload.extend_from_slice(&m.typ.to_array()); - - let nonce: chacha20poly1305::Nonce = cipher::Nonce::new(&self.1, seq).0.into(); - let aad = cipher::make_tls13_aad(total_len); - - self.0 - .encrypt_in_place(&nonce, &aad, &mut EncryptBufferAdapter(&mut payload)) - .map_err(|_| rustls::Error::EncryptError) - .map(|()| { - OutboundOpaqueMessage::new( - ContentType::ApplicationData, - ProtocolVersion::TLSv1_2, - payload, - ) - }) - } - - fn encrypted_payload_len(&self, payload_len: usize) -> usize { - payload_len + 1 + CHACHAPOLY1305_OVERHEAD - } -} - -impl MessageDecrypter for Tls13Cipher { - fn decrypt<'a>( - &mut self, - mut m: InboundOpaqueMessage<'a>, - seq: u64, - ) -> Result, rustls::Error> { - let payload = &mut m.payload; - let nonce: chacha20poly1305::Nonce = cipher::Nonce::new(&self.1, seq).0.into(); - let aad = cipher::make_tls13_aad(payload.len()); - - self.0 - .decrypt_in_place(&nonce, &aad, &mut DecryptBufferAdapter(payload)) - .map_err(|_| rustls::Error::DecryptError)?; - - m.into_tls13_unpadded_message() - } -} - -#[cfg(feature = "tls12")] -struct Tls12Cipher(chacha20poly1305::ChaCha20Poly1305, Iv); - -#[cfg(feature = "tls12")] -impl MessageEncrypter for Tls12Cipher { - fn encrypt( - &mut self, - m: OutboundPlainMessage<'_>, - seq: u64, - ) -> Result { - let total_len = self.encrypted_payload_len(m.payload.len()); - let mut payload = PrefixedPayload::with_capacity(total_len); - - payload.extend_from_chunks(&m.payload); - - let nonce: chacha20poly1305::Nonce = cipher::Nonce::new(&self.1, seq).0.into(); - let aad = cipher::make_tls12_aad(seq, m.typ, m.version, m.payload.len()); - - self.0 - .encrypt_in_place(&nonce, &aad, &mut EncryptBufferAdapter(&mut payload)) - .map_err(|_| rustls::Error::EncryptError) - .map(|_| OutboundOpaqueMessage::new(m.typ, m.version, payload)) - } - - fn encrypted_payload_len(&self, payload_len: usize) -> usize { - payload_len + CHACHAPOLY1305_OVERHEAD - } -} - -#[cfg(feature = "tls12")] -impl MessageDecrypter for Tls12Cipher { - fn decrypt<'a>( - &mut self, - mut m: InboundOpaqueMessage<'a>, - seq: u64, - ) -> Result, rustls::Error> { - let payload = &m.payload; - if payload.len() < CHACHAPOLY1305_OVERHEAD { - return Err(rustls::Error::DecryptError); - } - let nonce: chacha20poly1305::Nonce = cipher::Nonce::new(&self.1, seq).0.into(); - let aad = cipher::make_tls12_aad( - seq, - m.typ, - m.version, - payload.len() - CHACHAPOLY1305_OVERHEAD, - ); - - let payload = &mut m.payload; - self.0 - .decrypt_in_place(&nonce, &aad, &mut DecryptBufferAdapter(payload)) - .map_err(|_| rustls::Error::DecryptError)?; - // The payload now holds the authenticated plaintext only. - if payload.len() > super::TLS12_MAX_PLAINTEXT_LEN { - return Err(rustls::Error::PeerSentOversizedRecord); - } - - Ok(m.into_plain_message()) - } -} - -const CHACHAPOLY1305_OVERHEAD: usize = 16; diff --git a/crates/coordinode-crypto/src/aead/gcm.rs b/crates/coordinode-crypto/src/aead/gcm.rs deleted file mode 100644 index 80205f9..0000000 --- a/crates/coordinode-crypto/src/aead/gcm.rs +++ /dev/null @@ -1,270 +0,0 @@ -#[cfg(feature = "alloc")] -use alloc::boxed::Box; - -use super::{DecryptBufferAdapter, EncryptBufferAdapter}; - -use aead::AeadInOut; -use crypto_common::{KeyInit, KeySizeUser}; -use rustls::crypto::cipher::{ - self, AeadKey, InboundOpaqueMessage, InboundPlainMessage, MessageDecrypter, MessageEncrypter, - OutboundOpaqueMessage, OutboundPlainMessage, PrefixedPayload, Tls13AeadAlgorithm, -}; -use rustls::{ConnectionTrafficSecrets, ContentType, ProtocolVersion}; - -#[cfg(feature = "tls12")] -use { - aead::AeadCore, - crypto_common::typenum::Unsigned, - rustls::crypto::cipher::{Iv, KeyBlockShape, Tls12AeadAlgorithm}, -}; - -#[cfg(feature = "tls12")] -const TLS12_GCM_EXPLICIT_NONCE_LEN: usize = 8; - -#[cfg(feature = "tls12")] -const TLS12_GCM_OVERHEAD: usize = TLS12_GCM_EXPLICIT_NONCE_LEN + 16; - -macro_rules! impl_gcm_tls13 { - ($name: ident, $cipher: ident, $variant: ident, $aead: ty, $overhead: expr) => { - pub struct $name; - - impl Tls13AeadAlgorithm for $name { - fn encrypter(&self, key: AeadKey, iv: cipher::Iv) -> Box { - Box::new($cipher( - <$aead>::new_from_slice(key.as_ref()) - .expect("rustls supplies the negotiated AES key length"), - iv, - )) - } - - fn decrypter(&self, key: AeadKey, iv: cipher::Iv) -> Box { - Box::new($cipher( - <$aead>::new_from_slice(key.as_ref()) - .expect("rustls supplies the negotiated AES key length"), - iv, - )) - } - - fn key_len(&self) -> usize { - <$aead>::key_size() - } - fn extract_keys( - &self, - key: AeadKey, - iv: cipher::Iv, - ) -> Result { - Ok(ConnectionTrafficSecrets::$variant { key, iv }) - } - } - - struct $cipher($aead, cipher::Iv); - - impl MessageEncrypter for $cipher { - fn encrypt( - &mut self, - m: OutboundPlainMessage<'_>, - seq: u64, - ) -> Result { - let total_len = self.encrypted_payload_len(m.payload.len()); - let mut payload = PrefixedPayload::with_capacity(total_len); - - let nonce = cipher::Nonce::new(&self.1, seq).0; - let aad = cipher::make_tls13_aad(total_len); - payload.extend_from_chunks(&m.payload); - payload.extend_from_slice(&m.typ.to_array()); - - self.0 - .encrypt_in_place(&nonce.into(), &aad, &mut EncryptBufferAdapter(&mut payload)) - .map_err(|_| rustls::Error::EncryptError) - .map(|_| { - OutboundOpaqueMessage::new( - ContentType::ApplicationData, - ProtocolVersion::TLSv1_2, - payload, - ) - }) - } - - fn encrypted_payload_len(&self, payload_len: usize) -> usize { - payload_len + 1 + $overhead - } - } - - impl MessageDecrypter for $cipher { - fn decrypt<'a>( - &mut self, - mut m: InboundOpaqueMessage<'a>, - seq: u64, - ) -> Result, rustls::Error> { - let payload = &mut m.payload; - let nonce = cipher::Nonce::new(&self.1, seq).0; - let aad = cipher::make_tls13_aad(payload.len()); - - self.0 - .decrypt_in_place(&nonce.into(), &aad, &mut DecryptBufferAdapter(payload)) - .map_err(|_| rustls::Error::DecryptError)?; - - m.into_tls13_unpadded_message() - } - } - }; -} - -#[cfg(feature = "tls12")] -macro_rules! impl_gcm_tls12 { - ($name: ident, $encrypter: ident, $decrypter: ident, $variant: ident, $aead: ty, $nonce: expr, $overhead: expr) => { - #[cfg(feature = "tls12")] - pub struct $name; - - #[cfg(feature = "tls12")] - impl Tls12AeadAlgorithm for $name { - fn encrypter( - &self, - key: AeadKey, - write_iv: &[u8], - explicit: &[u8], - ) -> Box { - Box::new($encrypter( - <$aead>::new_from_slice(key.as_ref()) - .expect("rustls supplies the negotiated AES key length"), - { - let mut iv: [u8; 12] = [0; 12]; - iv[..4].copy_from_slice(write_iv); - iv[4..].copy_from_slice(explicit); - iv - }, - )) - } - - fn decrypter(&self, dec_key: AeadKey, dec_iv: &[u8]) -> Box { - Box::new($decrypter( - <$aead>::new_from_slice(dec_key.as_ref()) - .expect("rustls supplies the negotiated AES key length"), - dec_iv - .try_into() - .expect("rustls supplies a 4-byte TLS 1.2 AES fixed IV"), - )) - } - - fn key_block_shape(&self) -> KeyBlockShape { - KeyBlockShape { - enc_key_len: <$aead>::key_size(), - fixed_iv_len: 4, - explicit_nonce_len: 8, - } - } - - fn extract_keys( - &self, - key: AeadKey, - iv: &[u8], - explicit: &[u8], - ) -> Result { - let mut nonce = [0; 12]; - nonce[..4].copy_from_slice(iv); - nonce[4..].copy_from_slice(explicit); - Ok(ConnectionTrafficSecrets::$variant { - key, - iv: Iv::new(nonce), - }) - } - } - - #[cfg(feature = "tls12")] - struct $encrypter($aead, [u8; 12]); - - #[cfg(feature = "tls12")] - impl MessageEncrypter for $encrypter { - fn encrypt( - &mut self, - m: OutboundPlainMessage<'_>, - seq: u64, - ) -> Result { - let total_len = self.encrypted_payload_len(m.payload.len()); - let mut payload = PrefixedPayload::with_capacity(total_len); - - let nonce = cipher::Nonce::new(&self.1.into(), seq).0; - let aad = cipher::make_tls12_aad(seq, m.typ, m.version, m.payload.len()); - payload.extend_from_slice(&nonce.as_ref()[4..]); // explicit - payload.extend_from_chunks(&m.payload); - - self.0 - .encrypt_inout_detached( - &nonce.into(), - &aad, - (&mut payload.as_mut()[$nonce..]).into(), - ) - .map(|tag| payload.extend(tag.as_ref() as &[u8])) - .map_err(|_| rustls::Error::EncryptError) - .map(|_| OutboundOpaqueMessage::new(m.typ, m.version, payload)) - } - fn encrypted_payload_len(&self, payload_len: usize) -> usize { - payload_len + $nonce + <$aead as AeadCore>::TagSize::USIZE - } - } - - #[cfg(feature = "tls12")] - struct $decrypter($aead, [u8; 4]); - - #[cfg(feature = "tls12")] - impl MessageDecrypter for $decrypter { - fn decrypt<'a>( - &mut self, - mut m: InboundOpaqueMessage<'a>, - seq: u64, - ) -> Result, rustls::Error> { - type TagSize = <$aead as AeadCore>::TagSize; - - let payload = &m.payload; - - if payload.len() < $overhead { - return Err(rustls::Error::DecryptError); - } - - let nonce: aead::Nonce<$aead> = { - let mut nonce = [0u8; 12]; - nonce[..4].copy_from_slice(&self.1); // dec_iv - nonce[4..].copy_from_slice(&payload[..$nonce]); - nonce.into() - }; - - let aad = cipher::make_tls12_aad(seq, m.typ, m.version, payload.len() - $overhead); - - let payload = &mut m.payload; - let tag_pos = { - let payload = &mut payload[$nonce..]; - let tag_pos = payload.len() - TagSize::to_usize(); - let (msg, tag) = payload.split_at_mut(tag_pos); - - let tag = aes_gcm::Tag::::try_from(&*tag) - .map_err(|_| rustls::Error::DecryptError)?; - self.0 - .decrypt_inout_detached(&nonce, &aad, msg.into(), &tag) - .map_err(|_| rustls::Error::DecryptError)?; - tag_pos - }; - // `tag_pos` is the authenticated plaintext length. - if tag_pos > super::TLS12_MAX_PLAINTEXT_LEN { - return Err(rustls::Error::PeerSentOversizedRecord); - } - - // We defer the truncation to here, because we may inadvertently shifted the - // original data if the decryption failed. Another way to avoid this is - // to clone the payload slice starting after the explicit nonce, - // but this will cause an additional cloning and copying - payload.rotate_left($nonce); - payload.truncate(tag_pos); - Ok(m.into_plain_message()) - } - } - }; -} - -impl_gcm_tls13! {Tls13Aes128Gcm, Tls13CipherAes128Gcm, Aes128Gcm, aes_gcm::Aes128Gcm, 16} -impl_gcm_tls13! {Tls13Aes256Gcm, Tls13CipherAes256Gcm, Aes256Gcm, aes_gcm::Aes256Gcm, 16} - -#[cfg(feature = "tls12")] -impl_gcm_tls12! {Tls12Aes128Gcm, Tls12CipherAes128GcmEncrypter, Tls12CipherAes128GcmDecrypter, Aes128Gcm, aes_gcm::Aes128Gcm, TLS12_GCM_EXPLICIT_NONCE_LEN, TLS12_GCM_OVERHEAD} - -#[cfg(feature = "tls12")] -impl_gcm_tls12! {Tls12Aes256Gcm, Tls12CipherAes256GcmEncrypter, Tls12CipherAes256GcmDecrypter, Aes256Gcm, aes_gcm::Aes256Gcm, TLS12_GCM_EXPLICIT_NONCE_LEN, TLS12_GCM_OVERHEAD} diff --git a/crates/coordinode-crypto/src/hash.rs b/crates/coordinode-crypto/src/hash.rs deleted file mode 100644 index 16c7610..0000000 --- a/crates/coordinode-crypto/src/hash.rs +++ /dev/null @@ -1,55 +0,0 @@ -#[cfg(feature = "alloc")] -use alloc::boxed::Box; - -use digest::{Digest, OutputSizeUser}; -use rustls::crypto::{self, hash}; -use sha2::{Sha256, Sha384}; - -macro_rules! impl_hash { - ($name:ident, $hash:ident, $context:ident, $ty:ty, $algo:expr) => { - struct $hash; - - impl hash::Hash for $hash { - fn start(&self) -> Box { - Box::new($context(<$ty>::new())) - } - - fn hash(&self, data: &[u8]) -> hash::Output { - hash::Output::new(&<$ty>::digest(data)[..]) - } - - fn output_len(&self) -> usize { - <$ty as OutputSizeUser>::output_size() - } - - fn algorithm(&self) -> hash::HashAlgorithm { - $algo - } - } - - struct $context($ty); - - impl hash::Context for $context { - fn fork_finish(&self) -> hash::Output { - hash::Output::new(&self.0.clone().finalize()[..]) - } - - fn fork(&self) -> Box { - Box::new($context(self.0.clone())) - } - - fn finish(self: Box) -> hash::Output { - hash::Output::new(&self.0.finalize()[..]) - } - - fn update(&mut self, data: &[u8]) { - self.0.update(data); - } - } - - pub const $name: &dyn crypto::hash::Hash = &$hash; - }; -} - -impl_hash! {SHA256, Sha256Hash, Sha256Context, Sha256, hash::HashAlgorithm::SHA256} -impl_hash! {SHA384, Sha384Hash, Sha384Context, Sha384, hash::HashAlgorithm::SHA384} diff --git a/crates/coordinode-crypto/src/hmac.rs b/crates/coordinode-crypto/src/hmac.rs deleted file mode 100644 index 929a165..0000000 --- a/crates/coordinode-crypto/src/hmac.rs +++ /dev/null @@ -1,57 +0,0 @@ -#[cfg(feature = "alloc")] -use alloc::boxed::Box; - -use crypto_common::OutputSizeUser; -use hmac::{KeyInit, Mac}; -use rustls::crypto; -use sha2::{Sha256, Sha384}; - -macro_rules! impl_hmac { - ( - $name: ident, - $hmac: ident, - $key: ident, - $ty: ty - ) => { - struct $hmac; - - impl crypto::hmac::Hmac for $hmac { - fn with_key(&self, key: &[u8]) -> Box { - Box::new($key( - hmac::Hmac::<$ty>::new_from_slice(key).expect("HMAC accepts any key length"), - )) - } - - fn hash_output_len(&self) -> usize { - <$ty>::output_size() - } - } - - struct $key(hmac::Hmac<$ty>); - - impl crypto::hmac::Key for $key { - fn sign_concat( - &self, - first: &[u8], - middle: &[&[u8]], - last: &[u8], - ) -> crypto::hmac::Tag { - let mut ctx = self.0.clone(); - ctx.update(first); - for m in middle { - ctx.update(m); - } - ctx.update(last); - crypto::hmac::Tag::new(&ctx.finalize().into_bytes()[..]) - } - - fn tag_len(&self) -> usize { - <$ty>::output_size() - } - } - pub const $name: &dyn crypto::hmac::Hmac = &$hmac; - }; -} - -impl_hmac! {SHA256, Sha256Hmac, Sha256HmacKey, Sha256} -impl_hmac! {SHA384, Sha384Hmac, Sha384HmacKey, Sha384} diff --git a/crates/coordinode-crypto/src/identity.rs b/crates/coordinode-crypto/src/identity.rs deleted file mode 100644 index 4a45f01..0000000 --- a/crates/coordinode-crypto/src/identity.rs +++ /dev/null @@ -1,104 +0,0 @@ -use alloc::sync::Arc; - -use der::Decode; -use pkcs8::{AssociatedOid, ObjectIdentifier, PrivateKeyInfoRef}; -use pki_types::PrivateKeyDer; -use rustls::crypto::KeyProvider; -use rustls::sign::SigningKey; -use rustls::{Error, SignatureAlgorithm}; - -const EC_PUBLIC_KEY: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.2.1"); -const ED25519: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.101.112"); - -fn unsupported() -> Error { - Error::General("local TLS identities require P-256, P-384 or Ed25519 private keys".into()) -} - -/// Check the algorithm before a backend can perform any private-key operation. -/// -/// PKCS#1 RSA keys and RSA algorithms inside PKCS#8 are refused. Standalone -/// SEC1 keys require named P-256 or P-384 parameters and the canonical scalar -/// width (RFC 5915 section 3). Cryptographic key validation remains the -/// selected backend's responsibility. -/// -/// # Errors -/// Returns an error for malformed containers or unsupported identity algorithms. -pub fn check_identity_key(key: &PrivateKeyDer<'_>) -> Result<(), Error> { - match key { - PrivateKeyDer::Pkcs8(key) => { - let info = - PrivateKeyInfoRef::from_der(key.secret_pkcs8_der()).map_err(|_| unsupported())?; - if info.algorithm.oid == ED25519 { - if info.algorithm.parameters.is_some() { - return Err(unsupported()); - } - return Ok(()); - } - if info.algorithm.oid != EC_PUBLIC_KEY { - return Err(unsupported()); - } - let curve: ObjectIdentifier = info - .algorithm - .parameters - .ok_or_else(unsupported)? - .decode_as() - .map_err(|_| unsupported())?; - let length = curve_width(curve)?; - let inner = sec1::EcPrivateKey::from_der(info.private_key.as_bytes()) - .map_err(|_| unsupported())?; - if inner.private_key.len() != length { - return Err(unsupported()); - } - Ok(()) - } - PrivateKeyDer::Sec1(key) => { - let inner = - sec1::EcPrivateKey::from_der(key.secret_sec1_der()).map_err(|_| unsupported())?; - let Some(sec1::EcParameters::NamedCurve(curve)) = inner.parameters else { - return Err(unsupported()); - }; - if inner.private_key.len() != curve_width(curve)? { - return Err(unsupported()); - } - Ok(()) - } - _ => Err(unsupported()), - } -} - -fn curve_width(curve: ObjectIdentifier) -> Result { - if curve == p256::NistP256::OID { - return Ok(32); - } - if curve == p384::NistP384::OID { - return Ok(48); - } - Err(unsupported()) -} - -/// Identity policy around a separately qualified rustls key backend. -/// -/// The caller owns the wrapper for the lifetime required by its rustls provider. -/// No key is delegated until its container identifies an allowed algorithm. -#[derive(Debug)] -pub struct IdentityKeyProvider { - inner: &'static dyn KeyProvider, -} - -impl IdentityKeyProvider { - /// Wrap a backend without changing its ownership of allowed signing keys. - pub const fn new(inner: &'static dyn KeyProvider) -> Self { - Self { inner } - } -} - -impl KeyProvider for IdentityKeyProvider { - fn load_private_key(&self, key: PrivateKeyDer<'static>) -> Result, Error> { - check_identity_key(&key)?; - let key = self.inner.load_private_key(key)?; - match key.algorithm() { - SignatureAlgorithm::ECDSA | SignatureAlgorithm::ED25519 => Ok(key), - _ => Err(unsupported()), - } - } -} diff --git a/crates/coordinode-crypto/src/kx.rs b/crates/coordinode-crypto/src/kx.rs deleted file mode 100644 index 195b1fe..0000000 --- a/crates/coordinode-crypto/src/kx.rs +++ /dev/null @@ -1,124 +0,0 @@ -#[cfg(feature = "alloc")] -use alloc::boxed::Box; - -use crypto::{SharedSecret, SupportedKxGroup}; -use crypto_common::Generate; -use getrandom::rand_core::TryCryptoRng; -use rustls::crypto; -use zeroize::Zeroizing; - -#[derive(Debug)] -pub struct X25519; - -impl X25519 { - fn start_with_rng( - rng: &mut impl TryCryptoRng, - ) -> Result, rustls::Error> { - let mut secret = Zeroizing::new([0u8; 32]); - rng.try_fill_bytes(secret.as_mut()) - .map_err(|_| rustls::Error::General("entropy source failed".into()))?; - let priv_key = x25519_dalek::StaticSecret::from(*secret); - let pub_key = (&priv_key).into(); - Ok(Box::new(X25519KeyExchange { priv_key, pub_key })) - } -} - -impl crypto::SupportedKxGroup for X25519 { - fn name(&self) -> rustls::NamedGroup { - rustls::NamedGroup::X25519 - } - - fn start(&self) -> Result, rustls::Error> { - Ok(Self::start_with_rng(&mut getrandom::SysRng)?) - } -} - -pub struct X25519KeyExchange { - // The enclosing exchange is consumed on completion and is never cloned. - priv_key: x25519_dalek::StaticSecret, - pub_key: x25519_dalek::PublicKey, -} - -impl crypto::ActiveKeyExchange for X25519KeyExchange { - fn complete(self: Box, peer: &[u8]) -> Result { - let peer_array: [u8; 32] = peer - .try_into() - .map_err(|_| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?; - let shared = self.priv_key.diffie_hellman(&peer_array.into()); - // RFC 8446 section 7.4.2 requires rejecting every all-zero shared secret. - if !shared.was_contributory() { - return Err(rustls::PeerMisbehaved::InvalidKeyShare.into()); - } - Ok(shared.as_ref().into()) - } - - fn pub_key(&self) -> &[u8] { - self.pub_key.as_bytes() - } - - fn group(&self) -> rustls::NamedGroup { - X25519.name() - } -} - -macro_rules! impl_kx { - ($name:ident, $exchange:ident, $kx_name:expr, $secret:ty, $public_key:ty, $coordinate:expr) => { - #[derive(Debug)] - pub struct $name; - - impl crypto::SupportedKxGroup for $name { - fn name(&self) -> rustls::NamedGroup { - $kx_name - } - - fn start(&self) -> Result, rustls::Error> { - let priv_key = <$secret>::try_generate_from_rng(&mut getrandom::SysRng) - .map_err(|_| rustls::Error::General("entropy source failed".into()))?; - let pub_key: $public_key = (&priv_key).into(); - Ok(Box::new($exchange { - priv_key, - pub_key: pub_key.to_sec1_bytes(), - })) - } - } - - pub struct $exchange { - priv_key: $secret, - pub_key: Box<[u8]>, - } - - impl crypto::ActiveKeyExchange for $exchange { - fn complete(self: Box<$exchange>, peer: &[u8]) -> Result { - // Uncompressed points only (RFC 8446 section 4.2.8.2, RFC 8422 - // section 5.1.2); the SEC1 decoder would accept compressed ones. - if peer.len() != 1 + 2 * $coordinate || peer[0] != 0x04 { - return Err(rustls::PeerMisbehaved::InvalidKeyShare.into()); - } - let their_pub = <$public_key>::from_sec1_bytes(peer) - .map_err(|_| rustls::Error::from(rustls::PeerMisbehaved::InvalidKeyShare))?; - Ok(self - .priv_key - .diffie_hellman(&their_pub) - .raw_secret_bytes() - .as_slice() - .into()) - } - - fn pub_key(&self) -> &[u8] { - &self.pub_key - } - - fn group(&self) -> rustls::NamedGroup { - $name.name() - } - } - }; -} - -impl_kx! {SecP256R1, SecP256R1KeyExchange, rustls::NamedGroup::secp256r1, p256::ecdh::EphemeralSecret, p256::PublicKey, 32} -impl_kx! {SecP384R1, SecP384R1KeyExchange, rustls::NamedGroup::secp384r1, p384::ecdh::EphemeralSecret, p384::PublicKey, 48} - -pub const ALL_KX_GROUPS: &[&dyn SupportedKxGroup] = &[&X25519, &SecP256R1, &SecP384R1]; - -#[cfg(test)] -mod tests; diff --git a/crates/coordinode-crypto/src/kx/tests.rs b/crates/coordinode-crypto/src/kx/tests.rs deleted file mode 100644 index 050042a..0000000 --- a/crates/coordinode-crypto/src/kx/tests.rs +++ /dev/null @@ -1,68 +0,0 @@ -use getrandom::rand_core::{TryCryptoRng, TryRng}; -use rustls::crypto::SupportedKxGroup; - -struct FailedEntropy; - -impl TryRng for FailedEntropy { - type Error = getrandom::Error; - - fn try_next_u32(&mut self) -> Result { - Err(getrandom::Error::UNSUPPORTED) - } - - fn try_next_u64(&mut self) -> Result { - Err(getrandom::Error::UNSUPPORTED) - } - - fn try_fill_bytes(&mut self, _: &mut [u8]) -> Result<(), Self::Error> { - Err(getrandom::Error::UNSUPPORTED) - } -} - -impl TryCryptoRng for FailedEntropy {} - -/// An uncompressed share of `group` completes; its compressed form of the same -/// point, and a share one byte short, are refused. -fn only_uncompressed_shares_complete(group: &dyn SupportedKxGroup, coordinate: usize) { - let peer = group - .start() - .expect("the system entropy source works in tests"); - let uncompressed = peer.pub_key(); - assert_eq!(uncompressed.len(), 1 + 2 * coordinate); - assert_eq!(uncompressed[0], 0x04); - assert!(group.start_and_complete(uncompressed).is_ok()); - - let mut compressed = alloc::vec![0x02 | (uncompressed[2 * coordinate] & 1)]; - compressed.extend_from_slice(&uncompressed[1..=coordinate]); - let truncated = &uncompressed[..2 * coordinate]; - for share in [compressed.as_slice(), truncated] { - assert!(matches!( - group.start_and_complete(share).map(|_| ()), - Err(rustls::Error::PeerMisbehaved( - rustls::PeerMisbehaved::InvalidKeyShare - )) - )); - } -} - -// TLS 1.3 key shares and TLS 1.2 ECDHE points are uncompressed only -// (RFC 8446 section 4.2.8.2, RFC 8422 section 5.1.2), although SEC1 decoders -// accept the compressed form of the same point. -#[test] -fn p256_key_share_is_uncompressed_only() { - only_uncompressed_shares_complete(&super::SecP256R1, 32); -} - -#[test] -fn p384_key_share_is_uncompressed_only() { - only_uncompressed_shares_complete(&super::SecP384R1, 48); -} - -// Entropy failure must return a TLS error before an exchange is constructed. -#[test] -fn entropy_failure_does_not_panic_or_create_an_exchange() { - assert!(matches!( - super::X25519::start_with_rng(&mut FailedEntropy), - Err(rustls::Error::General(_)) - )); -} diff --git a/crates/coordinode-crypto/src/lib.rs b/crates/coordinode-crypto/src/lib.rs deleted file mode 100644 index 73ea2c2..0000000 --- a/crates/coordinode-crypto/src/lib.rs +++ /dev/null @@ -1,248 +0,0 @@ -#![no_std] -#![forbid(unsafe_code)] -#![cfg_attr(docsrs, feature(doc_auto_cfg))] -#![doc = include_str!("../README.md")] -#![warn( - clippy::alloc_instead_of_core, - clippy::cast_lossless, - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::cast_sign_loss, - clippy::checked_conversions, - clippy::missing_errors_doc, - clippy::mod_module_files, - clippy::implicit_saturating_sub, - clippy::panic, - clippy::panic_in_result_fn, - clippy::std_instead_of_alloc, - clippy::std_instead_of_core, - clippy::unwrap_used, - rust_2018_idioms, - trivial_numeric_casts, - unused_lifetimes -)] - -#[cfg(not(feature = "alloc"))] -compile_error!("Rustls currently does not support alloc-less environments"); - -#[cfg(feature = "alloc")] -extern crate alloc; - -#[cfg(all(test, feature = "std"))] -extern crate std; - -#[cfg(feature = "alloc")] -use alloc::sync::Arc; - -use rustls::crypto::{ - CipherSuiteCommon, CryptoProvider, GetRandomFailed, KeyProvider, SecureRandom, -}; -use rustls::{CipherSuite, SupportedCipherSuite, Tls13CipherSuite}; - -#[cfg(feature = "tls12")] -use rustls::SignatureScheme; - -#[derive(Debug)] -pub struct Provider; - -pub fn provider() -> CryptoProvider { - CryptoProvider { - cipher_suites: ALL_CIPHER_SUITES.to_vec(), - kx_groups: kx::ALL_KX_GROUPS.to_vec(), - signature_verification_algorithms: verify::ALGORITHMS, - secure_random: &Provider, - key_provider: &Provider, - } -} - -impl SecureRandom for Provider { - fn fill(&self, bytes: &mut [u8]) -> Result<(), GetRandomFailed> { - use getrandom::rand_core::TryRng; - let mut rng = getrandom::SysRng; - rng.try_fill_bytes(bytes).map_err(|_| GetRandomFailed) - } -} - -impl KeyProvider for Provider { - fn load_private_key( - &self, - key_der: pki_types::PrivateKeyDer<'static>, - ) -> Result, rustls::Error> { - identity::check_identity_key(&key_der)?; - sign::any_supported_type(&key_der) - } -} - -/// Records one AES-GCM key may protect before rustls updates or closes: 2^24 -/// full-size records (RFC 8446 section 5.5, draft-irtf-cfrg-aead-limits -/// section 5.1). ChaCha20-Poly1305 suites keep `u64::MAX`. -const AES_GCM_CONFIDENTIALITY_LIMIT: u64 = 1 << 24; - -#[cfg(feature = "tls12")] -const TLS12_ECDSA_SCHEMES: [SignatureScheme; 4] = [ - SignatureScheme::ECDSA_NISTP256_SHA256, - SignatureScheme::ECDSA_NISTP384_SHA384, - SignatureScheme::ECDSA_NISTP521_SHA512, - SignatureScheme::ED25519, -]; - -#[cfg(feature = "tls12")] -const TLS12_RSA_SCHEMES: [SignatureScheme; 6] = [ - SignatureScheme::RSA_PKCS1_SHA256, - SignatureScheme::RSA_PKCS1_SHA384, - SignatureScheme::RSA_PKCS1_SHA512, - SignatureScheme::RSA_PSS_SHA256, - SignatureScheme::RSA_PSS_SHA384, - SignatureScheme::RSA_PSS_SHA512, -]; - -#[cfg(feature = "tls12")] -pub const TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256: SupportedCipherSuite = - SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { - common: CipherSuiteCommon { - suite: CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - hash_provider: hash::SHA256, - confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, - }, - kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, - sign: &TLS12_ECDSA_SCHEMES, - aead_alg: &aead::gcm::Tls12Aes128Gcm, - prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA256), - }); - -#[cfg(feature = "tls12")] -pub const TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384: SupportedCipherSuite = - SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { - common: CipherSuiteCommon { - suite: CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, - hash_provider: hash::SHA384, - confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, - }, - kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, - sign: &TLS12_ECDSA_SCHEMES, - prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA384), - aead_alg: &aead::gcm::Tls12Aes256Gcm, - }); - -#[cfg(feature = "tls12")] -pub const TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256: SupportedCipherSuite = - SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { - common: CipherSuiteCommon { - suite: CipherSuite::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, - hash_provider: hash::SHA256, - confidentiality_limit: u64::MAX, - }, - prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA256), - kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, - sign: &TLS12_ECDSA_SCHEMES, - aead_alg: &aead::chacha20::Chacha20Poly1305, - }); - -#[cfg(feature = "tls12")] -pub const TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256: SupportedCipherSuite = - SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { - common: CipherSuiteCommon { - suite: CipherSuite::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - hash_provider: hash::SHA256, - confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, - }, - kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, - sign: &TLS12_RSA_SCHEMES, - aead_alg: &aead::gcm::Tls12Aes128Gcm, - prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA256), - }); - -#[cfg(feature = "tls12")] -pub const TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384: SupportedCipherSuite = - SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { - common: CipherSuiteCommon { - suite: CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - hash_provider: hash::SHA384, - confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, - }, - kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, - sign: &TLS12_RSA_SCHEMES, - prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA384), - aead_alg: &aead::gcm::Tls12Aes256Gcm, - }); - -#[cfg(feature = "tls12")] -pub const TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256: SupportedCipherSuite = - SupportedCipherSuite::Tls12(&rustls::Tls12CipherSuite { - common: CipherSuiteCommon { - suite: CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, - hash_provider: hash::SHA256, - confidentiality_limit: u64::MAX, - }, - kx: rustls::crypto::KeyExchangeAlgorithm::ECDHE, - sign: &TLS12_RSA_SCHEMES, - prf_provider: &rustls::crypto::tls12::PrfUsingHmac(hmac::SHA256), - aead_alg: &aead::chacha20::Chacha20Poly1305, - }); - -pub const TLS13_AES_128_GCM_SHA256: SupportedCipherSuite = - SupportedCipherSuite::Tls13(&Tls13CipherSuite { - common: CipherSuiteCommon { - suite: CipherSuite::TLS13_AES_128_GCM_SHA256, - hash_provider: hash::SHA256, - confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, - }, - hkdf_provider: &rustls::crypto::tls13::HkdfUsingHmac(hmac::SHA256), - aead_alg: &aead::gcm::Tls13Aes128Gcm, - quic: None, - }); - -pub const TLS13_AES_256_GCM_SHA384: SupportedCipherSuite = - SupportedCipherSuite::Tls13(&Tls13CipherSuite { - common: CipherSuiteCommon { - suite: CipherSuite::TLS13_AES_256_GCM_SHA384, - hash_provider: hash::SHA384, - confidentiality_limit: AES_GCM_CONFIDENTIALITY_LIMIT, - }, - hkdf_provider: &rustls::crypto::tls13::HkdfUsingHmac(hmac::SHA384), - aead_alg: &aead::gcm::Tls13Aes256Gcm, - quic: None, - }); - -pub const TLS13_CHACHA20_POLY1305_SHA256: SupportedCipherSuite = - SupportedCipherSuite::Tls13(&Tls13CipherSuite { - common: CipherSuiteCommon { - suite: CipherSuite::TLS13_CHACHA20_POLY1305_SHA256, - hash_provider: hash::SHA256, - confidentiality_limit: u64::MAX, - }, - hkdf_provider: &rustls::crypto::tls13::HkdfUsingHmac(hmac::SHA256), - aead_alg: &aead::chacha20::Chacha20Poly1305, - quic: None, - }); - -static ALL_CIPHER_SUITES: &[SupportedCipherSuite] = &[ - #[cfg(feature = "tls12")] - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - #[cfg(feature = "tls12")] - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, - #[cfg(feature = "tls12")] - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, - #[cfg(feature = "tls12")] - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - #[cfg(feature = "tls12")] - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - #[cfg(feature = "tls12")] - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, - TLS13_AES_128_GCM_SHA256, - TLS13_AES_256_GCM_SHA384, - TLS13_CHACHA20_POLY1305_SHA256, -]; - -mod aead; -mod hash; -mod hmac; -pub mod identity; -mod kx; -pub mod public_key; -pub mod sign; -mod verify; - -#[cfg(test)] -mod tests; diff --git a/crates/coordinode-crypto/src/public_key.rs b/crates/coordinode-crypto/src/public_key.rs deleted file mode 100644 index 3e6f457..0000000 --- a/crates/coordinode-crypto/src/public_key.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Bounded RSA public-key decoding shared by TLS and token verification. - -use crypto_bigint::BoxedUint; -use der::Decode; -use pki_types::InvalidSignature; -use rsa::RsaPublicKey; - -/// Decode a PKCS#1 RSA public key without allocating unbounded integers. -/// -/// # Errors -/// Rejects malformed DER and keys outside the supported 2048..=8192-bit range. -pub fn rsa_from_pkcs1(bytes: &[u8]) -> Result { - let key = rsa::pkcs1::RsaPublicKeyRef::from_der(bytes).map_err(|_| InvalidSignature)?; - rsa_from_components(key.modulus.as_bytes(), key.public_exponent.as_bytes()) -} - -/// Decode unsigned, minimal big-endian RSA public components. -/// -/// RFC 7518 sections 3.3 and 6.3.1 require a modulus of at least 2048 bits -/// and minimal unsigned encodings. The 8192-bit upper bound matches the -/// RustCrypto public-key implementation; exponent bounds are checked before -/// allocating or constructing modular arithmetic state. -/// -/// # Errors -/// Rejects noncanonical, out-of-range or otherwise invalid public keys. -pub fn rsa_from_components(n: &[u8], e: &[u8]) -> Result { - if n.len() < 256 || n.len() > 1024 || e.is_empty() || e.len() > 8 { - return Err(InvalidSignature); - } - if n[0] == 0 || e[0] == 0 { - return Err(InvalidSignature); - } - let n = BoxedUint::from_be_slice_vartime(n); - if n.bits() < 2048 { - return Err(InvalidSignature); - } - let e = BoxedUint::from_be_slice_vartime(e); - RsaPublicKey::new(n, e).map_err(|_| InvalidSignature) -} diff --git a/crates/coordinode-crypto/src/sign.rs b/crates/coordinode-crypto/src/sign.rs deleted file mode 100644 index 89a831a..0000000 --- a/crates/coordinode-crypto/src/sign.rs +++ /dev/null @@ -1,98 +0,0 @@ -#[cfg(feature = "alloc")] -use alloc::{sync::Arc, vec::Vec}; -use core::marker::PhantomData; - -use self::ecdsa::{EcdsaSigningKeyP256, EcdsaSigningKeyP384}; -use self::eddsa::Ed25519SigningKey; -use pki_types::PrivateKeyDer; -use rustls::sign::{Signer, SigningKey}; -use rustls::{Error, SignatureScheme}; -use signature::{RandomizedSigner, SignatureEncoding}; - -#[derive(Debug)] -pub struct GenericRandomizedSigner -where - S: SignatureEncoding, - T: RandomizedSigner, -{ - _marker: PhantomData, - key: Arc, - scheme: SignatureScheme, -} - -impl Signer for GenericRandomizedSigner -where - S: SignatureEncoding + Send + Sync + core::fmt::Debug, - T: RandomizedSigner + Send + Sync + core::fmt::Debug, -{ - fn sign(&self, message: &[u8]) -> Result, Error> { - self.key - .try_sign_with_rng(&mut getrandom::SysRng, message) - .map_err(|_| rustls::Error::General("signing failed".into())) - .map(|sig: S| sig.to_vec()) - } - - fn scheme(&self) -> SignatureScheme { - self.scheme - } -} - -#[derive(Debug)] -pub struct GenericSigner -where - S: SignatureEncoding, - T: signature::Signer, -{ - _marker: PhantomData, - key: Arc, - scheme: SignatureScheme, -} - -impl Signer for GenericSigner -where - S: SignatureEncoding + Send + Sync + core::fmt::Debug, - T: signature::Signer + Send + Sync + core::fmt::Debug, -{ - fn sign(&self, message: &[u8]) -> Result, Error> { - self.key - .try_sign(message) - .map_err(|_| rustls::Error::General("signing failed".into())) - .map(|sig: S| sig.to_vec()) - } - - fn scheme(&self) -> SignatureScheme { - self.scheme - } -} - -/// Extract any supported key from the given DER input. -/// -/// # Errors -/// -/// Returns an error if the key couldn't be decoded. -pub fn any_supported_type(der: &PrivateKeyDer<'_>) -> Result, rustls::Error> { - any_ecdsa_type(der).or_else(|_| any_eddsa_type(der)) -} - -/// Extract any supported ECDSA key from the given DER input. -/// -/// # Errors -/// -/// Returns an error if the key couldn't be decoded. -pub fn any_ecdsa_type(der: &PrivateKeyDer<'_>) -> Result, rustls::Error> { - let p256 = |_| EcdsaSigningKeyP256::try_from(der).map(|x| Arc::new(x) as _); - let p384 = |_| EcdsaSigningKeyP384::try_from(der).map(|x| Arc::new(x) as _); - p256(()).or_else(p384) -} - -/// Extract any supported EDDSA key from the given DER input. -/// -/// # Errors -/// -/// Returns an error if the key couldn't be decoded. -pub fn any_eddsa_type(der: &PrivateKeyDer<'_>) -> Result, rustls::Error> { - Ed25519SigningKey::try_from(der).map(|x| Arc::new(x) as _) -} - -pub mod ecdsa; -pub mod eddsa; diff --git a/crates/coordinode-crypto/src/sign/ecdsa.rs b/crates/coordinode-crypto/src/sign/ecdsa.rs deleted file mode 100644 index 43bcd0f..0000000 --- a/crates/coordinode-crypto/src/sign/ecdsa.rs +++ /dev/null @@ -1,74 +0,0 @@ -#[cfg(feature = "alloc")] -use alloc::{boxed::Box, format, sync::Arc}; -use core::marker::PhantomData; - -use der::Decode; -use pkcs8::DecodePrivateKey; -use pki_types::PrivateKeyDer; -use rustls::sign::SigningKey; -use rustls::{SignatureAlgorithm, SignatureScheme}; - -macro_rules! impl_ecdsa { - ($name: ident, $scheme: expr, $scalar_len: expr, $secret_key: ty, $signing_key: ty, $signature: ty) => { - #[derive(Debug)] - pub struct $name { - key: Arc<$signing_key>, - scheme: SignatureScheme, - } - - impl TryFrom<&PrivateKeyDer<'_>> for $name { - type Error = rustls::Error; - - fn try_from(value: &PrivateKeyDer<'_>) -> Result { - let pkey = match value { - PrivateKeyDer::Pkcs8(der) => { - <$signing_key>::from_pkcs8_der(der.secret_pkcs8_der()) - .map_err(|e| format!("invalid PKCS#8 EC private key: {e}")) - } - PrivateKeyDer::Pkcs1(_) => Err("RSA private keys are not supported".into()), - PrivateKeyDer::Sec1(der) => sec1::EcPrivateKey::from_der(der.secret_sec1_der()) - .map_err(|e| format!("failed to parse SEC1 EC private key: {e}")) - .and_then(|key| { - // RFC 5915 section 3 fixes the scalar width and requires named parameters. - if key.private_key.len() != $scalar_len || key.parameters.is_none() { - return Err("invalid SEC1 scalar width or missing curve".into()); - } - <$secret_key>::try_from(key) - .map(<$signing_key>::from) - .map_err(|e| format!("invalid SEC1 EC private key: {e}")) - }), - _ => Err("not supported".into()), - }; - pkey.map(|kp| Self { - key: Arc::new(kp), - scheme: $scheme, - }) - .map_err(rustls::Error::General) - } - } - - impl SigningKey for $name { - fn choose_scheme( - &self, - offered: &[SignatureScheme], - ) -> Option> { - if offered.contains(&self.scheme) { - Some(Box::new(super::GenericRandomizedSigner::<$signature, _> { - _marker: PhantomData, - key: self.key.clone(), - scheme: self.scheme, - })) - } else { - None - } - } - - fn algorithm(&self) -> SignatureAlgorithm { - SignatureAlgorithm::ECDSA - } - } - }; -} - -impl_ecdsa! {EcdsaSigningKeyP256, SignatureScheme::ECDSA_NISTP256_SHA256, 32, p256::SecretKey, p256::ecdsa::SigningKey, p256::ecdsa::DerSignature} -impl_ecdsa! {EcdsaSigningKeyP384, SignatureScheme::ECDSA_NISTP384_SHA384, 48, p384::SecretKey, p384::ecdsa::SigningKey, p384::ecdsa::DerSignature} diff --git a/crates/coordinode-crypto/src/sign/eddsa.rs b/crates/coordinode-crypto/src/sign/eddsa.rs deleted file mode 100644 index 15f4fe8..0000000 --- a/crates/coordinode-crypto/src/sign/eddsa.rs +++ /dev/null @@ -1,53 +0,0 @@ -#[cfg(feature = "alloc")] -use alloc::{boxed::Box, format, string::ToString, sync::Arc}; -use core::marker::PhantomData; - -use pkcs8::DecodePrivateKey; -use pki_types::PrivateKeyDer; -use rustls::sign::{Signer, SigningKey}; -use rustls::{SignatureAlgorithm, SignatureScheme}; - -#[derive(Debug)] -pub struct Ed25519SigningKey { - key: Arc, - scheme: SignatureScheme, -} - -impl TryFrom<&PrivateKeyDer<'_>> for Ed25519SigningKey { - type Error = rustls::Error; - - fn try_from(value: &PrivateKeyDer<'_>) -> Result { - let pkey = match value { - PrivateKeyDer::Pkcs8(der) => { - ed25519_dalek::SigningKey::from_pkcs8_der(der.secret_pkcs8_der()) - .map_err(|e| format!("failed to decrypt private key: {e}")) - } - PrivateKeyDer::Pkcs1(_) => Err("ED25519 does not support PKCS#1 key".to_string()), - PrivateKeyDer::Sec1(_) => Err("ED25519 does not support SEC1 key".to_string()), - _ => Err("not supported".into()), - }; - pkey.map(|kp| Self { - key: Arc::new(kp), - scheme: SignatureScheme::ED25519, - }) - .map_err(rustls::Error::General) - } -} - -impl SigningKey for Ed25519SigningKey { - fn choose_scheme(&self, offered: &[SignatureScheme]) -> Option> { - if offered.contains(&self.scheme) { - Some(Box::new(super::GenericSigner { - _marker: PhantomData, - key: self.key.clone(), - scheme: self.scheme, - })) - } else { - None - } - } - - fn algorithm(&self) -> SignatureAlgorithm { - SignatureAlgorithm::ED25519 - } -} diff --git a/crates/coordinode-crypto/src/tests.rs b/crates/coordinode-crypto/src/tests.rs deleted file mode 100644 index ee0248e..0000000 --- a/crates/coordinode-crypto/src/tests.rs +++ /dev/null @@ -1,375 +0,0 @@ -#![allow(clippy::unwrap_used, clippy::panic)] - -use alloc::vec::Vec; -use rustls::crypto::SupportedKxGroup; -use rustls::crypto::cipher::{AeadKey, Iv, Tls13AeadAlgorithm}; -use rustls::pki_types::{PrivateKeyDer, PrivatePkcs1KeyDer, PrivatePkcs8KeyDer}; -use rustls::{ConnectionTrafficSecrets, Error, PeerMisbehaved}; - -/// The TLS 1.2 AEAD of `suite` in `provider`. -#[cfg(feature = "tls12")] -fn tls12_aead( - provider: &rustls::crypto::CryptoProvider, - suite: rustls::CipherSuite, -) -> &'static dyn rustls::crypto::cipher::Tls12AeadAlgorithm { - match provider.cipher_suites.iter().find(|s| s.suite() == suite) { - Some(rustls::SupportedCipherSuite::Tls12(s)) => s.aead_alg, - _ => panic!("{suite:?} is not a TLS 1.2 suite of the provider"), - } -} - -/// A record of 2^14 plaintext bytes decrypts; one byte more is refused after -/// authentication, as by an independent provider. The sender is that -/// independent provider, so the record is genuinely authenticated. `keys` -/// yields a fresh matching (sender, receiver) key pair for the suite. -#[cfg(feature = "tls12")] -fn tls12_record_limit(suite: rustls::CipherSuite, keys: &dyn Fn() -> (AeadKey, AeadKey)) { - use rustls::crypto::cipher::{InboundOpaqueMessage, OutboundPlainMessage}; - use rustls::{ContentType, ProtocolVersion}; - - let oracle = tls12_aead(&rustls::crypto::ring::default_provider(), suite); - let ours = tls12_aead(&super::provider(), suite); - let shape = oracle.key_block_shape(); - let iv = alloc::vec![0; shape.fixed_iv_len]; - let explicit = alloc::vec![0; shape.explicit_nonce_len]; - for (who, receiver) in [("oracle", oracle), ("ours", ours)] { - let (tx, rx) = keys(); - let mut encrypter = oracle.encrypter(tx, &iv, &explicit); - let mut decrypter = receiver.decrypter(rx, &iv); - for (seq, length) in [(0, 16_384), (1, 16_385)] { - let data = alloc::vec![7; length]; - let record = encrypter - .encrypt( - OutboundPlainMessage { - typ: ContentType::ApplicationData, - version: ProtocolVersion::TLSv1_2, - payload: data.as_slice().into(), - }, - seq, - ) - .unwrap(); - let mut bytes = record.payload.as_ref().to_vec(); - let outcome = decrypter - .decrypt( - InboundOpaqueMessage::new(record.typ, record.version, &mut bytes), - seq, - ) - .map(|plain| plain.payload.to_vec()); - // Lengths, not the buffers, so a failure prints a readable outcome. - let expected = if length == 16_384 { - Ok(length) - } else { - Err(Error::PeerSentOversizedRecord) - }; - assert_eq!( - outcome.as_ref().map(Vec::len), - expected.as_ref().map(|len| *len), - "{who}, {length} bytes" - ); - if let Ok(plain) = outcome { - assert!(plain == data, "{who}: decrypted bytes differ"); - } - } - } -} - -/// A 32-byte key pair, for the suites whose key length matches -/// `AeadKey::from`. -#[cfg(feature = "tls12")] -fn fixed_256_bit_keys() -> (AeadKey, AeadKey) { - (AeadKey::from([1; 32]), AeadKey::from([1; 32])) -} - -/// A matching 16-byte AES-128-GCM key pair from a real local TLS 1.2 -/// handshake: rustls builds shorter `AeadKey`s only internally, and hands them -/// out through `dangerous_extract_secrets`. -#[cfg(all(feature = "tls12", feature = "std"))] -fn handshake_aes128_keys() -> (AeadKey, AeadKey) { - use alloc::sync::Arc; - const SUITE: rustls::CipherSuite = rustls::CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256; - - let certified = rcgen::generate_simple_self_signed(alloc::vec!["localhost".into()]).unwrap(); - let cert = certified.cert.der().clone(); - let mut provider = rustls::crypto::ring::default_provider(); - provider - .cipher_suites - .retain(|suite| suite.suite() == SUITE); - let provider = Arc::new(provider); - let mut server = rustls::ServerConfig::builder_with_provider(provider.clone()) - .with_protocol_versions(&[&rustls::version::TLS12]) - .unwrap() - .with_no_client_auth() - .with_single_cert(alloc::vec![cert.clone()], certified.signing_key.into()) - .unwrap(); - server.enable_secret_extraction = true; - let mut roots = rustls::RootCertStore::empty(); - roots.add(cert).unwrap(); - let mut client = rustls::ClientConfig::builder_with_provider(provider) - .with_protocol_versions(&[&rustls::version::TLS12]) - .unwrap() - .with_root_certificates(roots) - .with_no_client_auth(); - client.enable_secret_extraction = true; - let mut server = rustls::ServerConnection::new(Arc::new(server)).unwrap(); - let mut client = - rustls::ClientConnection::new(Arc::new(client), "localhost".try_into().unwrap()).unwrap(); - while client.is_handshaking() || server.is_handshaking() { - let mut bytes = Vec::new(); - client.write_tls(&mut bytes).unwrap(); - server.read_tls(&mut bytes.as_slice()).unwrap(); - server.process_new_packets().unwrap(); - bytes.clear(); - server.write_tls(&mut bytes).unwrap(); - client.read_tls(&mut bytes.as_slice()).unwrap(); - client.process_new_packets().unwrap(); - } - let ConnectionTrafficSecrets::Aes128Gcm { key: tx, .. } = - client.dangerous_extract_secrets().unwrap().tx.1 - else { - panic!("the handshake negotiated {SUITE:?}"); - }; - let ConnectionTrafficSecrets::Aes128Gcm { key: rx, .. } = - server.dangerous_extract_secrets().unwrap().rx.1 - else { - panic!("the handshake negotiated {SUITE:?}"); - }; - assert_eq!(tx.as_ref().len(), 16); - (tx, rx) -} - -// TLS 1.2 plaintext is at most 2^14 bytes (RFC 5246 section 6.2.1), even -// when the record authenticates. -#[cfg(feature = "tls12")] -#[test] -fn tls12_aes256_gcm_refuses_an_oversized_authenticated_record() { - tls12_record_limit( - rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - &fixed_256_bit_keys, - ); -} - -#[cfg(all(feature = "tls12", feature = "std"))] -#[test] -fn tls12_aes128_gcm_refuses_an_oversized_authenticated_record() { - tls12_record_limit( - rustls::CipherSuite::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, - &handshake_aes128_keys, - ); -} - -#[cfg(feature = "tls12")] -#[test] -fn tls12_chacha20_refuses_an_oversized_authenticated_record() { - tls12_record_limit( - rustls::CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, - &fixed_256_bit_keys, - ); -} - -// AES-GCM records under one key are bounded (RFC 8446 section 5.5, -// draft-irtf-cfrg-aead-limits section 5.1): past 2^24 full-size records rustls -// must update the key or close. ChaCha20-Poly1305 has no practical bound. -#[test] -fn aes_gcm_suites_carry_their_confidentiality_limit() { - use rustls::CipherSuite as S; - const GCM: u64 = 1 << 24; - // The exact suites of the compiled profile, in offer order. - let mut expected = Vec::new(); - #[cfg(feature = "tls12")] - expected.extend([ - (S::TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, GCM), - (S::TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, GCM), - (S::TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, u64::MAX), - (S::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, GCM), - (S::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, GCM), - (S::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, u64::MAX), - ]); - expected.extend([ - (S::TLS13_AES_128_GCM_SHA256, GCM), - (S::TLS13_AES_256_GCM_SHA384, GCM), - (S::TLS13_CHACHA20_POLY1305_SHA256, u64::MAX), - ]); - let actual: Vec<_> = super::provider() - .cipher_suites - .iter() - .map(|suite| { - let common = match suite { - rustls::SupportedCipherSuite::Tls12(suite) => &suite.common, - rustls::SupportedCipherSuite::Tls13(suite) => &suite.common, - }; - (common.suite, common.confidentiality_limit) - }) - .collect(); - assert_eq!(actual, expected); -} - -// TLS 1.3 must reject non-contributory X25519 shares (RFC 8446 section 7.4.2). -#[test] -fn x25519_rejects_low_order_shares() { - let mut shares = Vec::new(); - shares.push([0; 32]); - let mut one = [0; 32]; - one[0] = 1; - shares.push(one); - // Order-eight encodings from libsodium's curve25519 ref10 test boundary. - shares.push([ - 0xe0, 0xeb, 0x7a, 0x7c, 0x3b, 0x41, 0xb8, 0xae, 0x16, 0x56, 0xe3, 0xfa, 0xf1, 0x9f, 0xc4, - 0x6a, 0xda, 0x09, 0x8d, 0xeb, 0x9c, 0x32, 0xb1, 0xfd, 0x86, 0x62, 0x05, 0x16, 0x5f, 0x49, - 0xb8, 0x00, - ]); - shares.push([ - 0x5f, 0x9c, 0x95, 0xbc, 0xa3, 0x50, 0x8c, 0x24, 0xb1, 0xd0, 0xb1, 0x55, 0x9c, 0x83, 0xef, - 0x5b, 0x04, 0x44, 0x5c, 0xc4, 0x58, 0x1c, 0x8e, 0x86, 0xd8, 0x22, 0x4e, 0xdd, 0xd0, 0x9f, - 0x11, 0x57, - ]); - for first in [0xec, 0xed, 0xee] { - let mut share = [0xff; 32]; - share[0] = first; - share[31] = 0x7f; - shares.push(share); - } - for share in shares { - for high_bit in [0, 0x80] { - let mut encoded = share; - encoded[31] |= high_bit; - let result = super::kx::X25519.start().unwrap().complete(&encoded); - assert!(matches!( - result, - Err(Error::PeerMisbehaved(PeerMisbehaved::InvalidKeyShare)) - )); - } - } -} - -// A valid exchange remains interoperable while low-order shares are refused. -#[test] -fn x25519_valid_exchange_agrees() { - let alice = super::kx::X25519.start().unwrap(); - let bob = super::kx::X25519.start().unwrap(); - let alice_public = alice.pub_key().to_vec(); - let bob_public = bob.pub_key().to_vec(); - let alice_secret = alice.complete(&bob_public).unwrap(); - let bob_secret = bob.complete(&alice_public).unwrap(); - assert_eq!(alice_secret.secret_bytes(), bob_secret.secret_bytes()); - assert_ne!(alice_secret.secret_bytes(), &[0; 32]); -} - -// The provider must refuse RSA private keys independently of their DER wrapper. -#[test] -fn rsa_private_keys_are_refused() { - use rsa::pkcs1::EncodeRsaPrivateKey; - use rsa::pkcs8::EncodePrivateKey; - let key = rsa::RsaPrivateKey::new( - &mut getrandom::rand_core::UnwrapErr(getrandom::SysRng), - 2048, - ) - .unwrap(); - let keys = [ - PrivateKeyDer::Pkcs1(PrivatePkcs1KeyDer::from( - key.to_pkcs1_der().unwrap().as_bytes().to_vec(), - )), - PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from( - key.to_pkcs8_der().unwrap().as_bytes().to_vec(), - )), - ]; - for key in keys { - assert!( - super::provider() - .key_provider - .load_private_key(key) - .is_err() - ); - } -} - -// AES traffic-secret export must preserve the negotiated key size. -#[test] -fn aes128_exports_aes128_traffic_secrets() { - let secret = super::aead::gcm::Tls13Aes128Gcm - .extract_keys(AeadKey::from([0u8; 32]), Iv::from([0; 12])) - .unwrap(); - assert!(matches!(secret, ConnectionTrafficSecrets::Aes128Gcm { .. })); -} - -// Truncated TLS 1.2 records are peer errors, including with overflow checks enabled. -#[cfg(feature = "tls12")] -#[test] -fn chacha20_rejects_short_tls12_record() { - use rustls::crypto::cipher::{InboundOpaqueMessage, Tls12AeadAlgorithm}; - let mut decrypter = Tls12AeadAlgorithm::decrypter( - &super::aead::chacha20::Chacha20Poly1305, - AeadKey::from([0u8; 32]), - &[0; 12], - ); - let mut bytes = [0u8; 15]; - let message = InboundOpaqueMessage::new( - rustls::ContentType::ApplicationData, - rustls::ProtocolVersion::TLSv1_2, - &mut bytes, - ); - assert!(matches!( - decrypter.decrypt(message, 0), - Err(Error::DecryptError) - )); -} - -// A P-384 SEC1 algorithm identifier must not be ignored for a 32-byte scalar. -#[test] -fn sec1_curve_identifier_is_enforced() { - use der::Encode; - use pkcs8::AssociatedOid; - let key = sec1::EcPrivateKey { - private_key: &[1; 32], - parameters: Some(sec1::EcParameters::NamedCurve(p384::NistP384::OID)), - public_key: None, - } - .to_der() - .unwrap(); - assert!( - super::provider() - .key_provider - .load_private_key(PrivateKeyDer::Sec1(key.into())) - .is_err() - ); -} - -// TLS 1.2 AES export uses its 4-byte fixed IV and 8-byte explicit nonce offset. -#[cfg(feature = "tls12")] -#[test] -fn tls12_aes_export_preserves_nonce_and_algorithm() { - use rustls::crypto::cipher::Tls12AeadAlgorithm; - let secret = Tls12AeadAlgorithm::extract_keys( - &super::aead::gcm::Tls12Aes256Gcm, - AeadKey::from([0; 32]), - &[1; 4], - &[2; 8], - ) - .unwrap(); - let ConnectionTrafficSecrets::Aes256Gcm { iv, .. } = secret else { - panic!("AES-256 export changed the negotiated algorithm"); - }; - assert_eq!(iv.as_ref(), &[1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2]); -} - -// RFC 8446 section 4.2.3 prohibits RSA keys smaller than 2048 bits in TLS 1.3. -#[test] -fn rsa_verification_rejects_small_modulus() { - use rsa::pkcs1::EncodeRsaPublicKey; - use signature::{SignatureEncoding, Signer}; - let key = rsa::RsaPrivateKey::new( - &mut getrandom::rand_core::UnwrapErr(getrandom::SysRng), - 1024, - ) - .unwrap(); - let public = key.to_public_key().to_pkcs1_der().unwrap(); - let signing = rsa::pkcs1v15::SigningKey::::new(key); - let signature: rsa::pkcs1v15::Signature = signing.try_sign(b"TLS signature").unwrap(); - assert!( - super::verify::rsa::RSA_PKCS1_SHA256 - .verify_signature(public.as_bytes(), b"TLS signature", &signature.to_vec()) - .is_err() - ); -} - -#[cfg(feature = "std")] -mod tls; diff --git a/crates/coordinode-crypto/src/tests/tls.rs b/crates/coordinode-crypto/src/tests/tls.rs deleted file mode 100644 index 97d05f7..0000000 --- a/crates/coordinode-crypto/src/tests/tls.rs +++ /dev/null @@ -1,301 +0,0 @@ -use alloc::{format, sync::Arc, vec, vec::Vec}; -use std::io::{Read, Write}; - -use rcgen::{BasicConstraints, CertificateParams, ExtendedKeyUsagePurpose, IsCa, KeyPair}; -use rustls::crypto::CryptoProvider; -use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName}; -use rustls::{ - ClientConfig, ClientConnection, Error, RootCertStore, ServerConfig, ServerConnection, -}; - -struct Authority { - issuer: rcgen::Issuer<'static, KeyPair>, - root: CertificateDer<'static>, -} - -impl Authority { - fn new() -> Self { - let key = KeyPair::generate().unwrap(); - let mut params = CertificateParams::new(Vec::new()).unwrap(); - params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); - let root = params.self_signed(&key).unwrap().der().clone(); - Self { - issuer: rcgen::Issuer::new(params, key), - root, - } - } - - fn leaf( - &self, - algorithm: &'static rcgen::SignatureAlgorithm, - expired: bool, - ) -> (Vec>, PrivateKeyDer<'static>) { - let key = KeyPair::generate_for(algorithm).unwrap(); - let mut params = CertificateParams::new(vec!["localhost".into()]).unwrap(); - params.extended_key_usages = vec![ - ExtendedKeyUsagePurpose::ServerAuth, - ExtendedKeyUsagePurpose::ClientAuth, - ]; - if expired { - params.not_before = rcgen::date_time_ymd(1999, 1, 1); - params.not_after = rcgen::date_time_ymd(2000, 1, 1); - } - let certificate = params.signed_by(&key, &self.issuer).unwrap(); - (vec![certificate.der().clone()], key.into()) - } - - fn roots(&self) -> RootCertStore { - let mut roots = RootCertStore::empty(); - roots.add(self.root.clone()).unwrap(); - roots - } -} - -fn configs( - server_provider: CryptoProvider, - client_provider: CryptoProvider, - authority: &Authority, - leaf: &(Vec>, PrivateKeyDer<'static>), - mutual: bool, -) -> (ServerConfig, ClientConfig) { - let server_provider = Arc::new(server_provider); - let server = ServerConfig::builder_with_provider(Arc::clone(&server_provider)) - .with_safe_default_protocol_versions() - .unwrap(); - let server = if mutual { - let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider( - Arc::new(authority.roots()), - server_provider, - ) - .build() - .unwrap(); - server.with_client_cert_verifier(verifier) - } else { - server.with_no_client_auth() - }; - let server = server - .with_single_cert(leaf.0.clone(), leaf.1.clone_key()) - .unwrap(); - let client = ClientConfig::builder_with_provider(Arc::new(client_provider)) - .with_safe_default_protocol_versions() - .unwrap() - .with_root_certificates(authority.roots()); - let client = if mutual { - client - .with_client_auth_cert(leaf.0.clone(), leaf.1.clone_key()) - .unwrap() - } else { - client.with_no_client_auth() - }; - (server, client) -} - -fn handshake(server: ServerConfig, client: ClientConfig, name: &'static str) -> Result<(), Error> { - let mut server = ServerConnection::new(Arc::new(server))?; - let mut client = ClientConnection::new(Arc::new(client), ServerName::try_from(name).unwrap())?; - for _ in 0..32 { - let mut flight = Vec::new(); - client.write_tls(&mut flight).unwrap(); - server.read_tls(&mut flight.as_slice()).unwrap(); - server.process_new_packets()?; - flight.clear(); - server.write_tls(&mut flight).unwrap(); - client.read_tls(&mut flight.as_slice()).unwrap(); - client.process_new_packets()?; - if !client.is_handshaking() && !server.is_handshaking() { - client.writer().write_all(b"client data").unwrap(); - flight.clear(); - client.write_tls(&mut flight).unwrap(); - server.read_tls(&mut flight.as_slice()).unwrap(); - server.process_new_packets()?; - let mut payload = [0; 11]; - server.reader().read_exact(&mut payload).unwrap(); - if &payload != b"client data" { - return Err(Error::General("decrypted client data changed".into())); - } - server.writer().write_all(b"server data").unwrap(); - flight.clear(); - server.write_tls(&mut flight).unwrap(); - client.read_tls(&mut flight.as_slice()).unwrap(); - client.process_new_packets()?; - client.reader().read_exact(&mut payload).unwrap(); - if &payload != b"server data" { - return Err(Error::General("decrypted server data changed".into())); - } - return Ok(()); - } - } - Err(Error::General("TLS handshake did not complete".into())) -} - -fn profile( - mut provider: CryptoProvider, - suite: rustls::CipherSuite, - group: rustls::NamedGroup, -) -> CryptoProvider { - provider - .cipher_suites - .retain(|candidate| candidate.suite() == suite); - provider - .kx_groups - .retain(|candidate| candidate.name() == group); - assert_eq!(provider.cipher_suites.len(), 1); - assert_eq!(provider.kx_groups.len(), 1); - provider -} - -fn interoperability(tls13: bool) { - let ca = Authority::new(); - for algorithm in [ - &rcgen::PKCS_ECDSA_P256_SHA256, - &rcgen::PKCS_ECDSA_P384_SHA384, - &rcgen::PKCS_ED25519, - ] { - let leaf = ca.leaf(algorithm, false); - for suite in super::super::ALL_CIPHER_SUITES { - if suite.tls13().is_some() != tls13 { - continue; - } - if !tls13 && !format!("{:?}", suite.suite()).contains("ECDSA") { - continue; - } - for group in super::super::kx::ALL_KX_GROUPS { - let our = || profile(super::super::provider(), suite.suite(), group.name()); - let other = || { - profile( - rustls::crypto::ring::default_provider(), - suite.suite(), - group.name(), - ) - }; - let (server, client) = configs(our(), other(), &ca, &leaf, true); - handshake(server, client, "localhost").unwrap(); - let (server, client) = configs(other(), our(), &ca, &leaf, true); - handshake(server, client, "localhost").unwrap(); - } - } - } -} - -// Each TLS 1.3 AEAD, exchange group and allowed local key works against an independent backend. -#[test] -fn tls13_interoperability_and_mutual_authentication() { - interoperability(true); -} - -// TLS 1.2 record and signature adapters preserve the same bidirectional contract. -#[cfg(feature = "tls12")] -#[test] -fn tls12_interoperability_and_mutual_authentication() { - interoperability(false); -} - -// Updating the primitive backend must preserve rustls name, trust and time checks. -#[test] -fn certificate_validation_is_not_bypassed() { - let ca = Authority::new(); - let leaf = ca.leaf(&rcgen::PKCS_ECDSA_P256_SHA256, false); - let (server, client) = configs( - super::super::provider(), - super::super::provider(), - &ca, - &leaf, - false, - ); - assert!(matches!( - handshake(server, client, "wrong.invalid"), - Err(Error::InvalidCertificate(_)) - )); - let expired = ca.leaf(&rcgen::PKCS_ECDSA_P256_SHA256, true); - let (server, client) = configs( - super::super::provider(), - super::super::provider(), - &ca, - &expired, - false, - ); - assert!(matches!( - handshake(server, client, "localhost"), - Err(Error::InvalidCertificate(_)) - )); - let other = Authority::new(); - let (server, _) = configs( - super::super::provider(), - super::super::provider(), - &ca, - &leaf, - false, - ); - let (_, client) = configs( - super::super::provider(), - super::super::provider(), - &other, - &leaf, - false, - ); - assert!(matches!( - handshake(server, client, "localhost"), - Err(Error::InvalidCertificate(_)) - )); -} - -fn rsa_oracle_key() -> KeyPair { - use rsa::pkcs8::EncodePrivateKey; - let key = rsa::RsaPrivateKey::new( - &mut getrandom::rand_core::UnwrapErr(getrandom::SysRng), - 2048, - ) - .unwrap(); - let der = rustls::pki_types::PrivatePkcs8KeyDer::from( - key.to_pkcs8_der().unwrap().as_bytes().to_vec(), - ); - KeyPair::from_pkcs8_der_and_sign_algo(&der, &rcgen::PKCS_RSA_SHA256).unwrap() -} - -// RSA issuer verification remains supported even though every local identity uses EC or Ed25519. -#[test] -fn rsa_issuer_with_ecdsa_identities_works() { - let key = rsa_oracle_key(); - let mut params = CertificateParams::new(Vec::new()).unwrap(); - params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); - let root = params.self_signed(&key).unwrap().der().clone(); - let ca = Authority { - issuer: rcgen::Issuer::new(params, key), - root, - }; - let leaf = ca.leaf(&rcgen::PKCS_ECDSA_P256_SHA256, false); - let (server, client) = configs( - super::super::provider(), - super::super::provider(), - &ca, - &leaf, - true, - ); - handshake(server, client, "localhost").unwrap(); -} - -// A client can verify an external TLS 1.2 RSA server without acquiring a local RSA signing path. -#[cfg(feature = "tls12")] -#[test] -fn rsa_tls12_servers_remain_verifiable() { - let ca = Authority::new(); - let key = rsa_oracle_key(); - let mut params = CertificateParams::new(vec!["localhost".into()]).unwrap(); - params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; - let certificate = params.signed_by(&key, &ca.issuer).unwrap(); - let leaf = (vec![certificate.der().clone()], key.into()); - for suite in [ - rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, - rustls::CipherSuite::TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, - rustls::CipherSuite::TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, - ] { - let server_provider = profile( - rustls::crypto::ring::default_provider(), - suite, - rustls::NamedGroup::X25519, - ); - let client_provider = profile(super::super::provider(), suite, rustls::NamedGroup::X25519); - let (server, client) = configs(server_provider, client_provider, &ca, &leaf, false); - handshake(server, client, "localhost").unwrap(); - } -} diff --git a/crates/coordinode-crypto/src/verify.rs b/crates/coordinode-crypto/src/verify.rs deleted file mode 100644 index 7eae04a..0000000 --- a/crates/coordinode-crypto/src/verify.rs +++ /dev/null @@ -1,46 +0,0 @@ -use rustls::SignatureScheme; -use rustls::crypto::WebPkiSupportedAlgorithms; - -use self::ecdsa::{ECDSA_P256_SHA256, ECDSA_P256_SHA384, ECDSA_P384_SHA256, ECDSA_P384_SHA384}; -use self::eddsa::ED25519; -use self::rsa::{ - RSA_PKCS1_SHA256, RSA_PKCS1_SHA384, RSA_PKCS1_SHA512, RSA_PSS_SHA256, RSA_PSS_SHA384, - RSA_PSS_SHA512, -}; - -pub static ALGORITHMS: WebPkiSupportedAlgorithms = WebPkiSupportedAlgorithms { - all: &[ - ECDSA_P256_SHA256, - ECDSA_P256_SHA384, - ECDSA_P384_SHA256, - ECDSA_P384_SHA384, - ED25519, - RSA_PKCS1_SHA256, - RSA_PKCS1_SHA384, - RSA_PKCS1_SHA512, - RSA_PSS_SHA256, - RSA_PSS_SHA384, - RSA_PSS_SHA512, - ], - mapping: &[ - ( - SignatureScheme::ECDSA_NISTP384_SHA384, - &[ECDSA_P384_SHA384, ECDSA_P256_SHA384], - ), - ( - SignatureScheme::ECDSA_NISTP256_SHA256, - &[ECDSA_P256_SHA256, ECDSA_P384_SHA256], - ), - (SignatureScheme::ED25519, &[ED25519]), - (SignatureScheme::RSA_PKCS1_SHA256, &[RSA_PKCS1_SHA256]), - (SignatureScheme::RSA_PKCS1_SHA384, &[RSA_PKCS1_SHA384]), - (SignatureScheme::RSA_PKCS1_SHA512, &[RSA_PKCS1_SHA512]), - (SignatureScheme::RSA_PSS_SHA256, &[RSA_PSS_SHA256]), - (SignatureScheme::RSA_PSS_SHA384, &[RSA_PSS_SHA384]), - (SignatureScheme::RSA_PSS_SHA512, &[RSA_PSS_SHA512]), - ], -}; - -pub mod ecdsa; -pub mod eddsa; -pub mod rsa; diff --git a/crates/coordinode-crypto/src/verify/ecdsa.rs b/crates/coordinode-crypto/src/verify/ecdsa.rs deleted file mode 100644 index 1a0b5fe..0000000 --- a/crates/coordinode-crypto/src/verify/ecdsa.rs +++ /dev/null @@ -1,51 +0,0 @@ -use der::Decode; -use digest::Digest; -use pki_types::{AlgorithmIdentifier, InvalidSignature, SignatureVerificationAlgorithm, alg_id}; -use signature::hazmat::PrehashVerifier; - -macro_rules! impl_generic_ecdsa_verifer { - ( - $name:ident, - $verifier:ident, - $public_key_algo:expr, - $signature_alg_id:expr, - $verifying_key:ty, - $signature:ty, - $hash:ty - ) => { - #[derive(Debug)] - struct $verifier; - - impl SignatureVerificationAlgorithm for $verifier { - fn public_key_alg_id(&self) -> AlgorithmIdentifier { - $public_key_algo - } - - fn signature_alg_id(&self) -> AlgorithmIdentifier { - $signature_alg_id - } - - fn verify_signature( - &self, - public_key: &[u8], - message: &[u8], - signature: &[u8], - ) -> Result<(), InvalidSignature> { - let signature = <$signature>::from_der(signature).map_err(|_| InvalidSignature)?; - let verifying_key = - <$verifying_key>::from_sec1_bytes(public_key).map_err(|_| InvalidSignature)?; - let digest = &<$hash>::digest(message); - verifying_key - .verify_prehash(digest, &signature) - .map_err(|_| InvalidSignature) - } - } - - pub const $name: &dyn SignatureVerificationAlgorithm = &$verifier; - }; -} - -impl_generic_ecdsa_verifer! {ECDSA_P256_SHA256, P256Sha256Verifier, alg_id::ECDSA_P256, alg_id::ECDSA_SHA256, p256::ecdsa::VerifyingKey, p256::ecdsa::DerSignature, sha2::Sha256} -impl_generic_ecdsa_verifer! {ECDSA_P256_SHA384, P256Sha384Verifier, alg_id::ECDSA_P256, alg_id::ECDSA_SHA384, p256::ecdsa::VerifyingKey, p256::ecdsa::DerSignature, sha2::Sha384} -impl_generic_ecdsa_verifer! {ECDSA_P384_SHA256, P384Sha256Verifier, alg_id::ECDSA_P384, alg_id::ECDSA_SHA256, p384::ecdsa::VerifyingKey, p384::ecdsa::DerSignature, sha2::Sha256} -impl_generic_ecdsa_verifer! {ECDSA_P384_SHA384, P384Sha384Verifier, alg_id::ECDSA_P384, alg_id::ECDSA_SHA384, p384::ecdsa::VerifyingKey, p384::ecdsa::DerSignature, sha2::Sha384} diff --git a/crates/coordinode-crypto/src/verify/eddsa.rs b/crates/coordinode-crypto/src/verify/eddsa.rs deleted file mode 100644 index 10a7cb0..0000000 --- a/crates/coordinode-crypto/src/verify/eddsa.rs +++ /dev/null @@ -1,32 +0,0 @@ -use pki_types::{AlgorithmIdentifier, InvalidSignature, SignatureVerificationAlgorithm, alg_id}; -use signature::Verifier; - -#[derive(Debug)] -struct Ed25519Verify; - -impl SignatureVerificationAlgorithm for Ed25519Verify { - fn public_key_alg_id(&self) -> AlgorithmIdentifier { - alg_id::ED25519 - } - - fn signature_alg_id(&self) -> AlgorithmIdentifier { - alg_id::ED25519 - } - - fn verify_signature( - &self, - public_key: &[u8], - message: &[u8], - signature: &[u8], - ) -> Result<(), InvalidSignature> { - let public_key = public_key.try_into().map_err(|_| InvalidSignature)?; - let signature = - ed25519_dalek::Signature::from_slice(signature).map_err(|_| InvalidSignature)?; - ed25519_dalek::VerifyingKey::from_bytes(public_key) - .map_err(|_| InvalidSignature)? - .verify(message, &signature) - .map_err(|_| InvalidSignature) - } -} - -pub const ED25519: &dyn SignatureVerificationAlgorithm = &Ed25519Verify; diff --git a/crates/coordinode-crypto/src/verify/rsa.rs b/crates/coordinode-crypto/src/verify/rsa.rs deleted file mode 100644 index 155f85d..0000000 --- a/crates/coordinode-crypto/src/verify/rsa.rs +++ /dev/null @@ -1,97 +0,0 @@ -use pki_types::{AlgorithmIdentifier, InvalidSignature, SignatureVerificationAlgorithm, alg_id}; -use rsa::traits::PublicKeyParts; -use rsa::{pkcs1v15, pss}; -use sha2::{Sha256, Sha384, Sha512}; -use signature::Verifier; - -macro_rules! impl_generic_rsa_verifer { - ( - $name:ident, - $verifier:ident, - $public_key_algo:expr, - $signature_alg_id:expr, - $verifying_key:ty, - $signature:ty - ) => { - #[derive(Debug)] - struct $verifier; - - impl SignatureVerificationAlgorithm for $verifier { - fn public_key_alg_id(&self) -> AlgorithmIdentifier { - $public_key_algo - } - - fn signature_alg_id(&self) -> AlgorithmIdentifier { - $signature_alg_id - } - - fn verify_signature( - &self, - public_key: &[u8], - message: &[u8], - signature: &[u8], - ) -> Result<(), InvalidSignature> { - let public_key = crate::public_key::rsa_from_pkcs1(public_key)?; - if signature.len() != public_key.size() { - return Err(InvalidSignature); - } - let signature = <$signature>::try_from(signature).map_err(|_| InvalidSignature)?; - <$verifying_key>::new(public_key) - .verify(message, &signature) - .map_err(|_| InvalidSignature) - } - } - - pub const $name: &dyn SignatureVerificationAlgorithm = &$verifier; - }; -} - -impl_generic_rsa_verifer!( - RSA_PKCS1_SHA256, - RsaPkcs1Sha256Verifier, - alg_id::RSA_ENCRYPTION, - alg_id::RSA_PKCS1_SHA256, - pkcs1v15::VerifyingKey, - pkcs1v15::Signature -); -impl_generic_rsa_verifer!( - RSA_PKCS1_SHA384, - RsaPkcs1Sha384Verifier, - alg_id::RSA_ENCRYPTION, - alg_id::RSA_PKCS1_SHA384, - pkcs1v15::VerifyingKey, - pkcs1v15::Signature -); -impl_generic_rsa_verifer!( - RSA_PKCS1_SHA512, - RsaPkcs1Sha512Verifier, - alg_id::RSA_ENCRYPTION, - alg_id::RSA_PKCS1_SHA512, - pkcs1v15::VerifyingKey, - pkcs1v15::Signature -); - -impl_generic_rsa_verifer!( - RSA_PSS_SHA256, - RsaPssSha256Verifier, - alg_id::RSA_ENCRYPTION, - alg_id::RSA_PSS_SHA256, - pss::VerifyingKey, - pss::Signature -); -impl_generic_rsa_verifer!( - RSA_PSS_SHA384, - RsaPssSha384Verifier, - alg_id::RSA_ENCRYPTION, - alg_id::RSA_PSS_SHA384, - pss::VerifyingKey, - pss::Signature -); -impl_generic_rsa_verifer!( - RSA_PSS_SHA512, - RsaPssSha512Verifier, - alg_id::RSA_ENCRYPTION, - alg_id::RSA_PSS_SHA512, - pss::VerifyingKey, - pss::Signature -); diff --git a/deny.toml b/deny.toml index 8d8414c..3a5ba51 100644 --- a/deny.toml +++ b/deny.toml @@ -6,8 +6,7 @@ [advisories] # RUSTSEC-2023-0071: "Marvin Attack" timing sidechannel in `rsa`. -# Chains: rsa -> structured-proxy (the `rust_crypto` JWT verifier), and -# rsa -> coordinode-crypto (the RustCrypto rustls provider) -> structured-proxy. +# Chain: rsa -> sid-crypto (the RustCrypto JWT and rustls providers) -> structured-proxy. # # Ignored deliberately. See https://github.com/structured-world/structured-proxy/issues/48 # 1. No fix exists: the advisory has patched:[] / unaffected:[] in the RustSec @@ -17,13 +16,13 @@ # required by no-C consumers (e.g. CoordiNode ADR-013). # 3. RSA here only ever verifies signatures with public keys: JWTs, and the # certificates and handshake signatures of TLS peers. The JWT backend -# refuses RSA signer and private-key factories, and coordinode-crypto +# refuses RSA signer and private-key factories, and the rustls provider # admits only P-256, P-384 and Ed25519 local identities. Marvin targets # private-key timing (decrypt/sign), which never runs on a verify path. # Revisit when RustCrypto ships a constant-time `rsa` stable release. # -# Both chains come with the `rust_crypto` feature: a `default-features = false` -# build links neither, and CI checks that `rsa` is not in it. +# The chain comes with the `rust_crypto` feature: a `default-features = false` +# build does not link it, and CI checks that `rsa` is not in it. ignore = [ { id = "RUSTSEC-2023-0071", reason = "No fixed `rsa` release exists: the advisory is patched:[] and covers the resolved 0.10.0-rc.19. We deliberately keep pure-Rust crypto (the RustCrypto JWT backend and rustls provider) over C. RSA only verifies signatures with public keys (JWTs, TLS peer certificates and handshake signatures); RSA private-key operations are refused, and Marvin targets private-key timing, so it is not exploitable on a verify path. Revisit when RustCrypto ships a constant-time `rsa` stable. Tracking: structured-world/structured-proxy#48" }, ] diff --git a/release-plz.toml b/release-plz.toml index 4734b89..2162bf7 100644 --- a/release-plz.toml +++ b/release-plz.toml @@ -3,9 +3,3 @@ name = "structured-proxy" # Preserve the existing tag scheme (v1.0.0, v1.0.1, ...) instead of the # release-plz default of "{package}-v{version}", so tag history stays continuous. git_tag_name = "v{{ version }}" - -[[package]] -name = "coordinode-crypto" -# Tagged as coordinode-crypto-v{version} and published to crates.io; no GitHub -# release, since a created release starts the binary packaging workflow. -git_release_enable = false diff --git a/src/auth/crypto.rs b/src/auth/crypto.rs index 7d0813a..52e8cda 100644 --- a/src/auth/crypto.rs +++ b/src/auth/crypto.rs @@ -1,6 +1,6 @@ //! Process-wide `jsonwebtoken` crypto provider selection. //! -//! The RustCrypto backend uses jsonwebtoken's extension interface with current +//! The RustCrypto backend is the `sid-crypto` jsonwebtoken adapter over current //! primitives. The optional aws-lc backend retains its existing precedence. //! Explicit installation avoids feature inference when dependencies enable //! different jsonwebtoken backends in one process. @@ -8,9 +8,6 @@ #[cfg(test)] mod tests; -#[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] -mod rust_crypto; - /// The provider this crate installs. /// /// `aws_lc_rs` retains precedence when both optional backends are compiled in. @@ -23,7 +20,7 @@ pub(crate) fn preferred_provider() -> &'static jsonwebtoken::crypto::CryptoProvi /// compiled with. #[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] pub(crate) fn preferred_provider() -> &'static jsonwebtoken::crypto::CryptoProvider { - &rust_crypto::DEFAULT_PROVIDER + sid_crypto::jwt::rustcrypto::provider() } /// Select the `jsonwebtoken` crypto provider for this process. diff --git a/src/auth/crypto/rust_crypto.rs b/src/auth/crypto/rust_crypto.rs deleted file mode 100644 index bb39b26..0000000 --- a/src/auth/crypto/rust_crypto.rs +++ /dev/null @@ -1,295 +0,0 @@ -//! jsonwebtoken adapter over current RustCrypto primitives. -//! -//! JWT parsing, algorithm selection and claims validation remain in jsonwebtoken. -//! This module implements its crypto extension traits; RSA is public-key only. - -use core::marker::PhantomData; - -use hmac::{KeyInit, Mac}; -use jsonwebtoken::crypto::{CryptoProvider, JwtSigner, JwtVerifier, KeyUtils}; -use jsonwebtoken::errors::{ErrorKind, Result}; -use jsonwebtoken::{Algorithm, AlgorithmFamily, DecodingKey, DecodingKeyKind, EncodingKey}; -use p256::pkcs8::DecodePrivateKey; -use rsa::traits::PublicKeyParts; -use sha2::{Sha256, Sha384, Sha512}; -use signature_v3::SignatureEncoding; - -mod keys; - -pub(super) static DEFAULT_PROVIDER: CryptoProvider = CryptoProvider { - signer_factory: signer, - verifier_factory: verifier, - key_utils: KeyUtils { - rsa_pub_components_from_private_key: |_| Err(ErrorKind::InvalidAlgorithm.into()), - rsa_pub_components_from_public_key: keys::rsa_components, - ec_pub_components_from_private_key: keys::ec_components, - ed_pub_components_from_private_key: keys::ed_components, - compute_digest: keys::digest, - }, -}; - -struct Signing { - key: K, - algorithm: Algorithm, - signature: PhantomData, -} - -impl signature::Signer> for Signing -where - K: signature_v3::Signer, - S: SignatureEncoding, -{ - fn try_sign(&self, message: &[u8]) -> core::result::Result, signature::Error> { - let signature: S = self - .key - .try_sign(message) - .map_err(|_| signature::Error::new())?; - Ok(signature.to_vec()) - } -} - -impl JwtSigner for Signing -where - K: signature_v3::Signer, - S: SignatureEncoding, -{ - fn algorithm(&self) -> Algorithm { - self.algorithm - } -} - -struct Verification { - key: K, - algorithm: Algorithm, - signature_len: usize, - signature: PhantomData, -} - -impl signature::Verifier> for Verification -where - K: signature_v3::Verifier, - S: SignatureEncoding, -{ - fn verify( - &self, - message: &[u8], - bytes: &Vec, - ) -> core::result::Result<(), signature::Error> { - // In particular, do not allocate an attacker-sized RSA signature integer. - if bytes.len() != self.signature_len { - return Err(signature::Error::new()); - } - let signature = S::try_from(bytes.as_slice()).map_err(|_| signature::Error::new())?; - self.key - .verify(message, &signature) - .map_err(|_| signature::Error::new()) - } -} - -impl JwtVerifier for Verification -where - K: signature_v3::Verifier, - S: SignatureEncoding, -{ - fn algorithm(&self) -> Algorithm { - self.algorithm - } -} - -struct JwtMac { - initial: M, - algorithm: Algorithm, -} - -impl signature::Signer> for JwtMac { - fn try_sign(&self, message: &[u8]) -> core::result::Result, signature::Error> { - // Each operation owns an independent copy of the keyed initial state. - let mut state = self.initial.clone(); - state.update(message); - Ok(state.finalize().into_bytes().to_vec()) - } -} - -impl signature::Verifier> for JwtMac { - fn verify( - &self, - message: &[u8], - bytes: &Vec, - ) -> core::result::Result<(), signature::Error> { - let mut state = self.initial.clone(); - state.update(message); - state - .verify_slice(bytes) - .map_err(|_| signature::Error::new()) - } -} - -impl JwtSigner for JwtMac { - fn algorithm(&self) -> Algorithm { - self.algorithm - } -} - -impl JwtVerifier for JwtMac { - fn algorithm(&self) -> Algorithm { - self.algorithm - } -} - -fn mac(bytes: &[u8], algorithm: Algorithm) -> Result> { - let initial = M::new_from_slice(bytes).map_err(|_| ErrorKind::InvalidKeyFormat)?; - Ok(JwtMac { initial, algorithm }) -} - -fn signing(key: K, algorithm: Algorithm) -> Signing { - Signing { - key, - algorithm, - signature: PhantomData, - } -} - -fn verification( - key: K, - algorithm: Algorithm, - signature_len: usize, -) -> Verification { - Verification { - key, - algorithm, - signature_len, - signature: PhantomData, - } -} - -fn signer(algorithm: &Algorithm, key: &EncodingKey) -> Result> { - if algorithm.family() == AlgorithmFamily::Rsa { - return Err(ErrorKind::InvalidAlgorithm.into()); - } - if key.family() != algorithm.family() { - return Err(ErrorKind::InvalidKeyFormat.into()); - } - let result: Box = match *algorithm { - Algorithm::HS256 => Box::new(mac::>(key.as_bytes(), *algorithm)?), - Algorithm::HS384 => Box::new(mac::>(key.as_bytes(), *algorithm)?), - Algorithm::HS512 => Box::new(mac::>(key.as_bytes(), *algorithm)?), - Algorithm::ES256 => Box::new(signing::<_, p256::ecdsa::Signature>( - p256::ecdsa::SigningKey::from_pkcs8_der(key.as_bytes()) - .map_err(|_| ErrorKind::InvalidEcdsaKey)?, - *algorithm, - )), - Algorithm::ES384 => Box::new(signing::<_, p384::ecdsa::Signature>( - p384::ecdsa::SigningKey::from_pkcs8_der(key.as_bytes()) - .map_err(|_| ErrorKind::InvalidEcdsaKey)?, - *algorithm, - )), - Algorithm::EdDSA => Box::new(signing::<_, ed25519_dalek::Signature>( - ed25519_dalek::SigningKey::from_pkcs8_der(key.as_bytes()) - .map_err(|_| ErrorKind::InvalidEddsaKey)?, - *algorithm, - )), - _ => return Err(ErrorKind::InvalidAlgorithm.into()), - }; - Ok(result) -} - -fn verifier(algorithm: &Algorithm, key: &DecodingKey) -> Result> { - if key.family() != algorithm.family() { - return Err(ErrorKind::InvalidKeyFormat.into()); - } - let result: Box = match *algorithm { - Algorithm::HS256 => Box::new(mac::>( - key.try_get_as_bytes()?, - *algorithm, - )?), - Algorithm::HS384 => Box::new(mac::>( - key.try_get_as_bytes()?, - *algorithm, - )?), - Algorithm::HS512 => Box::new(mac::>( - key.try_get_as_bytes()?, - *algorithm, - )?), - Algorithm::ES256 => Box::new(verification::<_, p256::ecdsa::Signature>( - p256::ecdsa::VerifyingKey::from_sec1_bytes(key.try_get_as_bytes()?) - .map_err(|_| ErrorKind::InvalidEcdsaKey)?, - *algorithm, - 64, - )), - Algorithm::ES384 => Box::new(verification::<_, p384::ecdsa::Signature>( - p384::ecdsa::VerifyingKey::from_sec1_bytes(key.try_get_as_bytes()?) - .map_err(|_| ErrorKind::InvalidEcdsaKey)?, - *algorithm, - 96, - )), - Algorithm::EdDSA => { - let bytes = key - .try_get_as_bytes()? - .try_into() - .map_err(|_| ErrorKind::InvalidEddsaKey)?; - Box::new(verification::<_, ed25519_dalek::Signature>( - ed25519_dalek::VerifyingKey::from_bytes(bytes) - .map_err(|_| ErrorKind::InvalidEddsaKey)?, - *algorithm, - 64, - )) - } - Algorithm::RS256 - | Algorithm::RS384 - | Algorithm::RS512 - | Algorithm::PS256 - | Algorithm::PS384 - | Algorithm::PS512 => { - let public = rsa_key(key)?; - let length = public.size(); - match *algorithm { - Algorithm::RS256 => Box::new(verification::<_, rsa::pkcs1v15::Signature>( - rsa::pkcs1v15::VerifyingKey::::new(public), - *algorithm, - length, - )), - Algorithm::RS384 => Box::new(verification::<_, rsa::pkcs1v15::Signature>( - rsa::pkcs1v15::VerifyingKey::::new(public), - *algorithm, - length, - )), - Algorithm::RS512 => Box::new(verification::<_, rsa::pkcs1v15::Signature>( - rsa::pkcs1v15::VerifyingKey::::new(public), - *algorithm, - length, - )), - Algorithm::PS256 => Box::new(verification::<_, rsa::pss::Signature>( - rsa::pss::VerifyingKey::::new(public), - *algorithm, - length, - )), - Algorithm::PS384 => Box::new(verification::<_, rsa::pss::Signature>( - rsa::pss::VerifyingKey::::new(public), - *algorithm, - length, - )), - Algorithm::PS512 => Box::new(verification::<_, rsa::pss::Signature>( - rsa::pss::VerifyingKey::::new(public), - *algorithm, - length, - )), - _ => return Err(ErrorKind::InvalidAlgorithm.into()), - } - } - _ => return Err(ErrorKind::InvalidAlgorithm.into()), - }; - Ok(result) -} - -fn rsa_key(key: &DecodingKey) -> Result { - let result = match key.kind() { - DecodingKeyKind::SecretOrDer(bytes) => coordinode_crypto::public_key::rsa_from_pkcs1(bytes), - DecodingKeyKind::RsaModulusExponent { n, e } => { - coordinode_crypto::public_key::rsa_from_components(n, e) - } - }; - result.map_err(|_| ErrorKind::InvalidRsaKey("invalid RSA public key".into()).into()) -} - -#[cfg(test)] -mod tests; diff --git a/src/auth/crypto/rust_crypto/keys.rs b/src/auth/crypto/rust_crypto/keys.rs deleted file mode 100644 index 8dcf92a..0000000 --- a/src/auth/crypto/rust_crypto/keys.rs +++ /dev/null @@ -1,55 +0,0 @@ -use jsonwebtoken::Algorithm; -use jsonwebtoken::errors::{ErrorKind, Result}; -use jsonwebtoken::jwk::{EllipticCurve, ThumbprintHash}; -use p256::pkcs8::DecodePrivateKey; -use rsa::traits::PublicKeyParts; -use sha2::{Digest, Sha256, Sha384, Sha512}; - -pub(super) fn rsa_components(bytes: &[u8]) -> Result<(Vec, Vec)> { - let key = coordinode_crypto::public_key::rsa_from_pkcs1(bytes) - .map_err(|_| ErrorKind::InvalidRsaKey("invalid RSA public key".into()))?; - Ok((key.n_bytes().into_vec(), key.e_bytes().into_vec())) -} - -pub(super) fn ec_components( - bytes: &[u8], - algorithm: Algorithm, -) -> Result<(EllipticCurve, Vec, Vec)> { - match algorithm { - Algorithm::ES256 => { - let key = p256::ecdsa::SigningKey::from_pkcs8_der(bytes) - .map_err(|_| ErrorKind::InvalidEcdsaKey)?; - let point = key.verifying_key().to_sec1_point(false); - let x = point.x().ok_or(ErrorKind::InvalidEcdsaKey)?.to_vec(); - let y = point.y().ok_or(ErrorKind::InvalidEcdsaKey)?.to_vec(); - Ok((EllipticCurve::P256, x, y)) - } - Algorithm::ES384 => { - let key = p384::ecdsa::SigningKey::from_pkcs8_der(bytes) - .map_err(|_| ErrorKind::InvalidEcdsaKey)?; - let point = key.verifying_key().to_sec1_point(false); - let x = point.x().ok_or(ErrorKind::InvalidEcdsaKey)?.to_vec(); - let y = point.y().ok_or(ErrorKind::InvalidEcdsaKey)?.to_vec(); - Ok((EllipticCurve::P384, x, y)) - } - _ => Err(ErrorKind::InvalidAlgorithm.into()), - } -} - -pub(super) fn ed_components(bytes: &[u8], curve: &EllipticCurve) -> Result> { - if *curve != EllipticCurve::Ed25519 { - return Err(ErrorKind::InvalidAlgorithm.into()); - } - let key = - ed25519_dalek::SigningKey::from_pkcs8_der(bytes).map_err(|_| ErrorKind::InvalidEddsaKey)?; - Ok(key.verifying_key().as_bytes().to_vec()) -} - -pub(super) fn digest(bytes: &[u8], algorithm: ThumbprintHash) -> Result> { - Ok(match algorithm { - ThumbprintHash::SHA256 => Sha256::digest(bytes).to_vec(), - ThumbprintHash::SHA384 => Sha384::digest(bytes).to_vec(), - ThumbprintHash::SHA512 => Sha512::digest(bytes).to_vec(), - _ => return Err(ErrorKind::InvalidAlgorithm.into()), - }) -} diff --git a/src/auth/crypto/rust_crypto/tests.rs b/src/auth/crypto/rust_crypto/tests.rs deleted file mode 100644 index a9216cf..0000000 --- a/src/auth/crypto/rust_crypto/tests.rs +++ /dev/null @@ -1,237 +0,0 @@ -use super::*; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use jsonwebtoken::{Header, Validation, decode, encode}; -use p256::elliptic_curve::sec1::ToSec1Point; -use p256::pkcs8::EncodePrivateKey; -use serde_json::json; - -fn claims() -> serde_json::Value { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - json!({"sub": "crypto-contract", "exp": now + 3600}) -} - -fn install() { - DEFAULT_PROVIDER.install_default().unwrap(); -} - -// The public JWT API exercises the signature-2/signature-3 adapter and claims parsing together. -#[test] -fn allowed_signatures_round_trip_through_jsonwebtoken() { - install(); - let ec256 = p256::SecretKey::from_slice(&[1; 32]).unwrap(); - let ec384 = p384::SecretKey::from_slice(&[2; 48]).unwrap(); - let ed = ed25519_dalek::SigningKey::from_bytes(&[3; 32]); - let cases = [ - ( - Algorithm::HS256, - EncodingKey::from_secret(&[4; 64]), - DecodingKey::from_secret(&[4; 64]), - ), - ( - Algorithm::HS384, - EncodingKey::from_secret(&[4; 64]), - DecodingKey::from_secret(&[4; 64]), - ), - ( - Algorithm::HS512, - EncodingKey::from_secret(&[4; 64]), - DecodingKey::from_secret(&[4; 64]), - ), - ( - Algorithm::ES256, - EncodingKey::from_ec_der(ec256.to_pkcs8_der().unwrap().as_bytes()), - DecodingKey::from_ec_der(&ec256.public_key().to_sec1_bytes()), - ), - ( - Algorithm::ES384, - EncodingKey::from_ec_der(ec384.to_pkcs8_der().unwrap().as_bytes()), - DecodingKey::from_ec_der(&ec384.public_key().to_sec1_bytes()), - ), - ( - Algorithm::EdDSA, - EncodingKey::from_ed_der(ed.to_pkcs8_der().unwrap().as_bytes()), - DecodingKey::from_ed_der(ed.verifying_key().as_bytes()), - ), - ]; - for (algorithm, private, public) in cases { - let expected = claims(); - let token = encode(&Header::new(algorithm), &expected, &private).unwrap(); - let actual = - decode::(&token, &public, &Validation::new(algorithm)).unwrap(); - assert_eq!(actual.claims, expected); - let (input, signature) = token.rsplit_once('.').unwrap(); - let mut signature = URL_SAFE_NO_PAD.decode(signature).unwrap(); - signature[0] ^= 1; - let corrupt = format!("{input}.{}", URL_SAFE_NO_PAD.encode(signature)); - assert!( - decode::(&corrupt, &public, &Validation::new(algorithm)).is_err() - ); - } -} - -fn rsa_signature(key: rsa::RsaPrivateKey, algorithm: Algorithm, input: &[u8]) -> Vec { - use signature_v3::{RandomizedSigner, Signer}; - // Generated test keys are an oracle only; the product factory never accepts RSA signing. - match algorithm { - Algorithm::RS256 => rsa::pkcs1v15::SigningKey::::new(key) - .try_sign(input) - .unwrap() - .to_vec(), - Algorithm::RS384 => rsa::pkcs1v15::SigningKey::::new(key) - .try_sign(input) - .unwrap() - .to_vec(), - Algorithm::RS512 => rsa::pkcs1v15::SigningKey::::new(key) - .try_sign(input) - .unwrap() - .to_vec(), - Algorithm::PS256 => rsa::pss::SigningKey::::new(key) - .try_sign_with_rng(&mut rand::rng(), input) - .unwrap() - .to_vec(), - Algorithm::PS384 => rsa::pss::SigningKey::::new(key) - .try_sign_with_rng(&mut rand::rng(), input) - .unwrap() - .to_vec(), - Algorithm::PS512 => rsa::pss::SigningKey::::new(key) - .try_sign_with_rng(&mut rand::rng(), input) - .unwrap() - .to_vec(), - _ => panic!("test requires an RSA algorithm"), - } -} - -// Every RSA JWT algorithm accepts both PEM-derived DER and JWK components without a private path. -#[test] -fn rsa_public_jwt_verification_and_private_refusal() { - use rsa::pkcs1::EncodeRsaPublicKey; - install(); - let private = rsa::RsaPrivateKey::new(&mut rand::rng(), 2048).unwrap(); - let public = private.to_public_key(); - let der = public.to_pkcs1_der().unwrap(); - for algorithm in [ - Algorithm::RS256, - Algorithm::RS384, - Algorithm::RS512, - Algorithm::PS256, - Algorithm::PS384, - Algorithm::PS512, - ] { - let jwk = serde_json::from_value(json!({ - "kty": "RSA", "alg": algorithm, - "n": URL_SAFE_NO_PAD.encode(public.n_bytes()), - "e": URL_SAFE_NO_PAD.encode(public.e_bytes()), - })) - .unwrap(); - let expected = claims(); - let header = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&Header::new(algorithm)).unwrap()); - let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&expected).unwrap()); - let input = format!("{header}.{payload}"); - let signature = rsa_signature(private.clone(), algorithm, input.as_bytes()); - let token = format!("{input}.{}", URL_SAFE_NO_PAD.encode(&signature)); - for key in [ - DecodingKey::from_rsa_der(der.as_bytes()), - DecodingKey::from_jwk(&jwk).unwrap(), - ] { - let actual = - decode::(&token, &key, &Validation::new(algorithm)).unwrap(); - assert_eq!(actual.claims, expected); - let verifier = (DEFAULT_PROVIDER.verifier_factory)(&algorithm, &key).unwrap(); - let mut corrupt = signature.clone(); - corrupt[0] ^= 1; - assert!(verifier.verify(input.as_bytes(), &corrupt).is_err()); - assert!( - verifier - .verify(input.as_bytes(), &vec![0; signature.len() + 1]) - .is_err() - ); - } - assert!( - (DEFAULT_PROVIDER.signer_factory)(&algorithm, &EncodingKey::from_rsa_der(&[])).is_err() - ); - } - assert!( - (DEFAULT_PROVIDER - .key_utils - .rsa_pub_components_from_private_key)(&[]) - .is_err() - ); -} - -// Untrusted key material must fail before indexing, integer allocation or algorithm confusion. -#[test] -fn malformed_public_keys_and_family_confusion_are_errors() { - for length in 0..32 { - assert!( - (DEFAULT_PROVIDER.verifier_factory)( - &Algorithm::EdDSA, - &DecodingKey::from_ed_der(&vec![0; length]) - ) - .is_err() - ); - } - assert!( - (DEFAULT_PROVIDER.verifier_factory)(&Algorithm::EdDSA, &DecodingKey::from_ed_der(&[0; 33])) - .is_err() - ); - assert!( - (DEFAULT_PROVIDER.verifier_factory)(&Algorithm::ES256, &DecodingKey::from_secret(&[0; 64])) - .is_err() - ); - assert!( - (DEFAULT_PROVIDER.signer_factory)(&Algorithm::ES256, &EncodingKey::from_secret(&[0; 64])) - .is_err() - ); - assert!( - (DEFAULT_PROVIDER.verifier_factory)( - &Algorithm::RS256, - &DecodingKey::from_rsa_der(&[0; 32]) - ) - .is_err() - ); - let oversized = DecodingKey::from_rsa_raw_components(&vec![1; 1025], &[3]); - assert!((DEFAULT_PROVIDER.verifier_factory)(&Algorithm::RS256, &oversized).is_err()); -} - -// JWK utility hooks are implemented, including typed refusal of unsupported private keys. -#[test] -fn jwk_utilities_are_real_and_algorithm_bound() { - let ec = p256::SecretKey::from_slice(&[1; 32]).unwrap(); - let der = ec.to_pkcs8_der().unwrap(); - let (curve, x, y) = - (DEFAULT_PROVIDER - .key_utils - .ec_pub_components_from_private_key)(der.as_bytes(), Algorithm::ES256) - .unwrap(); - let point = ec.public_key().to_sec1_point(false); - assert_eq!(curve, jsonwebtoken::jwk::EllipticCurve::P256); - assert_eq!(x, point.x().unwrap().as_slice()); - assert_eq!(y, point.y().unwrap().as_slice()); - assert!( - (DEFAULT_PROVIDER - .key_utils - .ec_pub_components_from_private_key)(der.as_bytes(), Algorithm::ES384) - .is_err() - ); - assert!( - (DEFAULT_PROVIDER - .key_utils - .ed_pub_components_from_private_key)( - &[], &jsonwebtoken::jwk::EllipticCurve::P256 - ) - .is_err() - ); - let digest = (DEFAULT_PROVIDER.key_utils.compute_digest)( - b"abc", - jsonwebtoken::jwk::ThumbprintHash::SHA256, - ) - .unwrap(); - assert_eq!( - URL_SAFE_NO_PAD.encode(digest), - "ungWv48Bz-pBQUDeXa4iI7ADYaOWF3qctBD_YfIAFa0" - ); -} diff --git a/src/auth/crypto/tests.rs b/src/auth/crypto/tests.rs index d68e620..ded9a1e 100644 --- a/src/auth/crypto/tests.rs +++ b/src/auth/crypto/tests.rs @@ -35,7 +35,7 @@ fn aws_lc_rs_only_build_installs_aws_lc_rs() { fn rust_crypto_only_build_installs_rust_crypto() { assert!(std::ptr::eq( super::preferred_provider(), - &super::rust_crypto::DEFAULT_PROVIDER + sid_crypto::jwt::rustcrypto::provider() )); } diff --git a/src/serve/tests.rs b/src/serve/tests.rs index 2909852..e556a7b 100644 --- a/src/serve/tests.rs +++ b/src/serve/tests.rs @@ -252,7 +252,7 @@ fn a_zero_limit_in_the_config_is_an_error() { /// A process crypto provider, for builds without a crypto backend feature. fn install_provider() { if rustls::crypto::CryptoProvider::get_default().is_none() { - rustls::crypto::CryptoProvider::install_default(coordinode_crypto::provider()) + rustls::crypto::CryptoProvider::install_default(sid_crypto::tls::rustcrypto::provider()) .expect("each test runs in its own process"); } } @@ -301,7 +301,7 @@ fn rsa_listener_yaml() -> String { /// key file it refused. #[test] fn the_rustcrypto_provider_refuses_an_rsa_listener_key() { - rustls::crypto::CryptoProvider::install_default(coordinode_crypto::provider()) + rustls::crypto::CryptoProvider::install_default(sid_crypto::tls::rustcrypto::provider()) .expect("each test runs in its own process"); let err = crate::ProxyServer::from_yaml_str(&rsa_listener_yaml()) .unwrap() diff --git a/src/shield/resolve/tests.rs b/src/shield/resolve/tests.rs index 972b75c..5dd6473 100644 --- a/src/shield/resolve/tests.rs +++ b/src/shield/resolve/tests.rs @@ -25,7 +25,9 @@ fn with_tls_provider() { { use rustls::crypto::CryptoProvider; // A concurrent test may have installed it first; either way one is set. - let installed = coordinode_crypto::provider().install_default().is_ok(); + let installed = sid_crypto::tls::rustcrypto::provider() + .install_default() + .is_ok(); assert!(installed || CryptoProvider::get_default().is_some()); } } diff --git a/src/tls.rs b/src/tls.rs index fc49dd7..82a9d80 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -141,7 +141,7 @@ fn builtin_provider() -> Option { /// The pure-Rust RustCrypto provider, the only backend this build compiled. #[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] fn builtin_provider() -> Option { - Some(coordinode_crypto::provider()) + Some(sid_crypto::tls::rustcrypto::provider()) } /// No crypto backend is compiled in. diff --git a/src/tls/tests.rs b/src/tls/tests.rs index 7ac3f93..4abe4ef 100644 --- a/src/tls/tests.rs +++ b/src/tls/tests.rs @@ -41,7 +41,7 @@ async fn serve( .unwrap(); let key = PrivateKeyDer::from_pem_slice(key.as_bytes()).unwrap(); // An independent peer: the client under test is the proxy's, and the - // server must present RSA identities, which coordinode-crypto refuses. + // server must present RSA identities, which the RustCrypto provider refuses. let config = rustls::ServerConfig::builder_with_provider(Arc::new( rustls::crypto::ring::default_provider(), )) @@ -83,7 +83,8 @@ async fn serve( /// handshakes below cover the one production uses; a build without a backend /// falls back to RustCrypto, as an embedder would install one. fn client(trusted: &str, host: &str, addr: SocketAddr) -> reqwest::Client { - let provider = Arc::new(builtin_provider().unwrap_or_else(coordinode_crypto::provider)); + let provider = + Arc::new(builtin_provider().unwrap_or_else(sid_crypto::tls::rustcrypto::provider)); reqwest::Client::builder() .tls_backend_preconfigured(client_config_with(provider, roots(trusted)).unwrap()) .resolve(host, addr) @@ -199,14 +200,20 @@ fn the_aws_lc_backend_brings_the_aws_lc_provider() { kind(&builtin), kind(&rustls::crypto::aws_lc_rs::default_provider()) ); - assert_ne!(kind(&builtin), kind(&coordinode_crypto::provider())); + assert_ne!( + kind(&builtin), + kind(&sid_crypto::tls::rustcrypto::provider()) + ); } #[cfg(all(feature = "rust_crypto", not(feature = "aws_lc_rs")))] #[test] fn the_rust_crypto_backend_brings_the_rustcrypto_provider() { let builtin = builtin_provider().unwrap(); - assert_eq!(kind(&builtin), kind(&coordinode_crypto::provider())); + assert_eq!( + kind(&builtin), + kind(&sid_crypto::tls::rustcrypto::provider()) + ); } #[cfg(not(any(feature = "rust_crypto", feature = "aws_lc_rs")))] @@ -218,7 +225,7 @@ fn without_a_crypto_backend_no_provider_is_linked() { #[test] fn the_installed_provider_wins_over_the_builtin_one() { - let installed = Arc::new(coordinode_crypto::provider()); + let installed = Arc::new(sid_crypto::tls::rustcrypto::provider()); let chosen = select_provider(Some(&installed), || { panic!("the builtin provider is not built when one is installed") }) @@ -228,7 +235,7 @@ fn the_installed_provider_wins_over_the_builtin_one() { #[test] fn without_an_installed_provider_the_builtin_one_is_used() { - let chosen = select_provider(None, || Some(coordinode_crypto::provider())).unwrap(); + let chosen = select_provider(None, || Some(sid_crypto::tls::rustcrypto::provider())).unwrap(); assert!(!chosen.cipher_suites.is_empty()); } @@ -250,7 +257,7 @@ fn a_provider_without_tls12_or_tls13_suites_is_refused() { // safe version is an error, not a panic. let provider = rustls::crypto::CryptoProvider { cipher_suites: Vec::new(), - ..coordinode_crypto::provider() + ..sid_crypto::tls::rustcrypto::provider() }; let err = client_config_with(Arc::new(provider), roots(CA)).unwrap_err(); assert!(err.contains("TLS 1.2 or 1.3"), "{err}"); diff --git a/tests/shutdown.rs b/tests/shutdown.rs index 98c8e7a..200e3cd 100644 --- a/tests/shutdown.rs +++ b/tests/shutdown.rs @@ -239,7 +239,7 @@ impl Proxy { fn install_provider() { if rustls::crypto::CryptoProvider::get_default().is_none() { - rustls::crypto::CryptoProvider::install_default(coordinode_crypto::provider()) + rustls::crypto::CryptoProvider::install_default(sid_crypto::tls::rustcrypto::provider()) .expect("each test runs in its own process"); } } @@ -253,12 +253,13 @@ async fn connect(addr: SocketAddr, tls: bool, alpn: &[u8]) -> Box { for cert in CertificateDer::pem_slice_iter(CA.as_bytes()) { roots.add(cert.unwrap()).unwrap(); } - let mut config = - rustls::ClientConfig::builder_with_provider(Arc::new(coordinode_crypto::provider())) - .with_safe_default_protocol_versions() - .unwrap() - .with_root_certificates(roots) - .with_no_client_auth(); + let mut config = rustls::ClientConfig::builder_with_provider(Arc::new( + sid_crypto::tls::rustcrypto::provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); config.alpn_protocols = vec![alpn.to_vec()]; let stream = tokio_rustls::TlsConnector::from(Arc::new(config)) .connect(ServerName::try_from("localhost").unwrap(), tcp) diff --git a/tests/tls.rs b/tests/tls.rs index 293ad20..c803084 100644 --- a/tests/tls.rs +++ b/tests/tls.rs @@ -119,7 +119,7 @@ impl tower::Service> for Who { // --- the embedder's TLS server ------------------------------------------------ fn provider() -> Arc { - Arc::new(coordinode_crypto::provider()) + Arc::new(sid_crypto::tls::rustcrypto::provider()) } fn chain() -> Vec> { @@ -245,7 +245,7 @@ const TESTDATA: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/src/tls/testdata"); async fn listen_builtin(listen_yaml: &str) -> SocketAddr { // Builds without a crypto backend feature take the process's provider. if rustls::crypto::CryptoProvider::get_default().is_none() { - rustls::crypto::CryptoProvider::install_default(coordinode_crypto::provider()) + rustls::crypto::CryptoProvider::install_default(sid_crypto::tls::rustcrypto::provider()) .expect("each test runs in its own process"); } let pool = pool();