mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-07 08:55:37 +08:00
feat(crypto): add a system OpenSSL backend and dependency guardrails (#4843)
* feat(crypto): add an opt-in system OpenSSL backend Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com> * ci(crypto): enforce crypto dependency boundaries with cargo-deny Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com> * fix(crypto): reject encrypted PEM keys without prompting Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com> * fix(crypto): validate OpenSSL keys and propagate TLS setup errors Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com> * fix(crypto): address OpenSSL backend review findings Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com> * test(crypto): verify OpenSSL integration and linkage Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com> * fix(crypto): reject inconsistent RSA private keys Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com> * fix(ci): isolate OpenSSL checks and clear deny wrappers Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com> --------- Signed-off-by: Varsha Prasad Narsing <varshaprasad96@gmail.com>
This commit is contained in:
@@ -34,3 +34,5 @@ Every PR from this issue-backed workflow must have its own existing issue and us
|
||||
Do not apply acceptance or roadmap decisions on behalf of a maintainer. Do not introduce `agent:*` workflow labels.
|
||||
|
||||
For first-party security-sensitive crypto, use `openshell-crypto` and extend its facade/backend contracts when needed. Follow the [crypto architecture](../../../crates/openshell-crypto/CRYPTO.md) for compatibility, failure handling, and explicit coverage exceptions.
|
||||
|
||||
When changing crypto dependencies, run `mise run rust:deny:policy`, including its dependency-boundary regression fixtures. Review any `deny.toml` wrapper change against the documented exception scope; an existing allowance does not authorize new security-sensitive operations outside the facade.
|
||||
|
||||
@@ -18,3 +18,5 @@ Use this skill after an authorized `review-security-issue` review identifies an
|
||||
Begin any fix comments with `> **🔧 security-fix-agent**`. Do not change human disposition or introduce `agent:*` workflow labels.
|
||||
|
||||
For first-party security-sensitive crypto, use `openshell-crypto` and extend its facade/backend contracts when needed. Follow the [crypto architecture](../../../crates/openshell-crypto/CRYPTO.md) for compatibility, failure handling, and explicit coverage exceptions.
|
||||
|
||||
When changing crypto dependencies, run `mise run rust:deny:policy`, including its dependency-boundary regression fixtures. Review any `deny.toml` wrapper change against the documented exception scope; an existing allowance does not authorize new security-sensitive operations outside the facade.
|
||||
|
||||
@@ -153,7 +153,7 @@ Read through the full diff (and the PR description if available). Produce a summ
|
||||
suggested change.
|
||||
- Do not fabricate concerns or claim a behavioral regression without evidence
|
||||
for both the prior and proposed behavior.
|
||||
- **Cryptographic boundaries**: For changed first-party security-sensitive crypto, verify use of `openshell-crypto`, backend dispatch, failure propagation without fallback, and protocol/storage compatibility. Missing operations belong in the facade/backend contracts. Check dependency-owned and non-security exceptions against the repository's [crypto architecture](../../../crates/openshell-crypto/CRYPTO.md); protocol/parsing types alone are not bypasses. Report undocumented bypasses or scope expansion under **Potential Concerns**.
|
||||
- **Cryptographic boundaries**: For changed first-party security-sensitive crypto, verify use of `openshell-crypto`, backend dispatch, failure propagation without fallback, and protocol/storage compatibility. Missing operations belong in the facade/backend contracts. Check dependency-owned and other explicit exceptions against the repository's [crypto architecture](../../../crates/openshell-crypto/CRYPTO.md); protocol/parsing types alone are not bypasses. Check `mise run rust:deny:policy` results and review changes to `deny.toml` wrappers; package-level allowances do not authorize new bypasses within those packages. Report undocumented bypasses or scope expansion under **Potential Concerns**.
|
||||
- **Agent infrastructure**: When the PR changes behavior, commands, or development workflows, use the `sync-agent-infra` maintenance map to check that related skills were updated. When it adds, removes, or renames skills or crates; changes workflow relationships or skill coverage; modifies issue or PR templates; or changes agent cross-references, apply the full consistency checklist. Report missing companion updates or drift under **Potential Concerns**.
|
||||
|
||||
## Step 5: Output
|
||||
|
||||
@@ -164,6 +164,13 @@ not substitute the current `main` tip or the event's older PR base SHA. Merge
|
||||
groups and manual runs use their explicit baseline. Findings are reported by
|
||||
`Reject new high or critical findings`; distinguish those from scanner failures.
|
||||
|
||||
For `Cargo Deny`, the metadata-only commands clear Rust compiler wrappers after
|
||||
entering mise; they do not require sccache. For OpenSSL backend and dynamic-linkage
|
||||
checks, use `nix develop .#crypto` from outside the default shell. The release
|
||||
toolchains in the default shell expose an unprefixed system AWS-LC that conflicts
|
||||
with OpenSSL. The crypto shell uses native OpenSSL and Cargo's symbol-prefixed
|
||||
AWS-LC build for dual-backend tests.
|
||||
|
||||
For `Protobuf Compatibility`, check the logged train and comparison baseline.
|
||||
Branch Checks compares the prospective merge tree with its target; release qualification
|
||||
compares the tagged candidate with the previous stable release. Both use
|
||||
|
||||
@@ -196,8 +196,17 @@ jobs:
|
||||
prepare-shell: "true"
|
||||
shell-installable: .#devShells.x86_64-linux.default
|
||||
|
||||
- name: Install pinned cargo-deny
|
||||
run: mise install --locked github:EmbarkStudios/cargo-deny
|
||||
|
||||
# Enter mise inside the Nix shell so Nix's cargo-deny cannot shadow the pin.
|
||||
# mise sets RUSTC_WRAPPER=sccache, but this metadata-only job does not
|
||||
# install sccache. Clear wrappers after mise applies its environment.
|
||||
- name: Verify crypto dependency guardrail
|
||||
run: mise exec github:EmbarkStudios/cargo-deny -- env RUSTC_WRAPPER= RUSTC_WORKSPACE_WRAPPER= uv run --no-project python tasks/scripts/test-crypto-dependency-policy.py
|
||||
|
||||
- name: Check dependencies
|
||||
run: cargo deny check licenses bans sources
|
||||
run: mise exec github:EmbarkStudios/cargo-deny -- env RUSTC_WRAPPER= RUSTC_WORKSPACE_WRAPPER= cargo deny check licenses bans sources
|
||||
|
||||
rust-format:
|
||||
name: Rust format
|
||||
@@ -320,7 +329,7 @@ jobs:
|
||||
- name: Verify crypto backend independence
|
||||
shell: nix develop -c bash -euo pipefail {0}
|
||||
run: |
|
||||
cargo tree --locked -p openshell-crypto --no-default-features --edges normal,build,dev --prefix none > /tmp/openshell-crypto-dependencies.txt
|
||||
cargo tree --locked -p openshell-crypto --no-default-features --edges normal,build,dev --target all --prefix none > /tmp/openshell-crypto-dependencies.txt
|
||||
if grep -Eq '^(ring|aws-lc-rs|aws-lc-sys|aws-lc-fips-sys) v' /tmp/openshell-crypto-dependencies.txt; then
|
||||
echo "ERROR: backend-free crypto build includes Ring or AWS-LC" >&2
|
||||
cat /tmp/openshell-crypto-dependencies.txt >&2
|
||||
@@ -328,6 +337,44 @@ jobs:
|
||||
fi
|
||||
cargo test --locked -p openshell-crypto --no-default-features --test independent_backend
|
||||
|
||||
- name: Verify OpenSSL backend
|
||||
shell: nix develop .#crypto -c bash -euo pipefail {0}
|
||||
env:
|
||||
OPENSSL_NO_VENDOR: "1"
|
||||
OPENSSL_STATIC: "0"
|
||||
run: |
|
||||
cargo tree --locked -p openshell-crypto --no-default-features --features openssl --edges normal,build,dev --target all --prefix none > /tmp/openshell-openssl-dependencies.txt
|
||||
if grep -Eq '^(ring|aws-lc-rs|aws-lc-sys|aws-lc-fips-sys|openssl-src) v' /tmp/openshell-openssl-dependencies.txt; then
|
||||
echo "ERROR: OpenSSL-only crypto build includes another backend or vendored OpenSSL" >&2
|
||||
cat /tmp/openshell-openssl-dependencies.txt >&2
|
||||
exit 1
|
||||
fi
|
||||
cargo clippy --locked -p openshell-crypto --no-default-features --features openssl --all-targets -- -D warnings
|
||||
cargo test --locked -p openshell-crypto --no-default-features --features openssl
|
||||
for feature in tls-tonic tls-kube tls-sqlx; do
|
||||
cargo check --locked -p openshell-crypto --no-default-features --features "openssl,$feature"
|
||||
done
|
||||
cargo clippy --locked -p openshell-crypto --features openssl --all-targets -- -D warnings
|
||||
cargo test --locked -p openshell-crypto --features openssl
|
||||
|
||||
- name: Verify dynamic OpenSSL linkage on Linux
|
||||
if: runner.os == 'Linux'
|
||||
shell: nix develop .#crypto -c bash -euo pipefail {0}
|
||||
env:
|
||||
OPENSSL_NO_VENDOR: "1"
|
||||
OPENSSL_STATIC: "0"
|
||||
run: |
|
||||
cargo test --locked -p openshell-crypto --no-default-features --features openssl --test openssl_tls --no-run --message-format=json > "$RUNNER_TEMP/openssl-artifacts.jsonl"
|
||||
executable=$(uv run --no-project python -c 'import json, sys; artifacts = [json.loads(line) for line in sys.stdin]; paths = [a["executable"] for a in artifacts if a.get("reason") == "compiler-artifact" and a.get("target", {}).get("name") == "openssl_tls" and a.get("executable")]; assert len(paths) == 1, paths; print(paths[0])' < "$RUNNER_TEMP/openssl-artifacts.jsonl")
|
||||
ldd "$executable" > "$RUNNER_TEMP/openssl-linkage.txt"
|
||||
cat "$RUNNER_TEMP/openssl-linkage.txt"
|
||||
for library in libssl libcrypto; do
|
||||
if ! grep -Eq "^[[:space:]]*$library\\.so[^[:space:]]* => /" "$RUNNER_TEMP/openssl-linkage.txt"; then
|
||||
echo "ERROR: $library did not resolve to a dynamic library" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Test
|
||||
shell: nix develop -c bash -euo pipefail {0}
|
||||
env:
|
||||
|
||||
@@ -240,7 +240,7 @@ When behavior, commands, or development workflows change, review the related age
|
||||
|
||||
## Security
|
||||
|
||||
- Route first-party security-sensitive cryptographic operations through `openshell-crypto`. If an operation is missing, extend the facade and backend implementation instead of adding a direct cryptographic implementation in an application crate. Preserve protocol compatibility and propagate backend failures without fallback. Protocol/parsing types and documented non-security or dependency-owned exceptions remain permitted; classify exceptions explicitly. Follow [Cryptography and FIPS](crates/openshell-crypto/CRYPTO.md).
|
||||
- Route first-party security-sensitive cryptographic operations through `openshell-crypto`. If an operation is missing, extend the facade and backend implementation instead of adding a direct cryptographic implementation in an application crate. Preserve protocol compatibility and propagate backend failures without fallback. Protocol/parsing types and documented exceptions remain permitted; classify exceptions explicitly. Run `mise run rust:deny:policy` when changing crypto dependencies; review `deny.toml` exceptions rather than widening them to pass CI. Follow [Cryptography and FIPS](crates/openshell-crypto/CRYPTO.md).
|
||||
- Never commit secrets, API keys, or credentials. If a file looks like it contains secrets (`.env`, `credentials.json`, etc.), do not stage it.
|
||||
- Do not run destructive operations (force push, hard reset, database drops) without explicit human confirmation.
|
||||
- Scope changes to the issue at hand. Do not make unrelated changes in the same branch.
|
||||
|
||||
Generated
+80
-1
@@ -2215,6 +2215,21 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -3384,6 +3399,16 @@ dependencies = [
|
||||
"simple_asn1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken-openssl"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53aec0291d5d8b37fbf826ccc23f5473536dad8dafda4161a7b6b56c2b0e0202"
|
||||
dependencies = [
|
||||
"jsonwebtoken",
|
||||
"openssl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "k8s-openapi"
|
||||
version = "0.24.0"
|
||||
@@ -4450,17 +4475,21 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"jsonwebtoken",
|
||||
"jsonwebtoken-openssl",
|
||||
"k8s-openapi",
|
||||
"kube",
|
||||
"openssl",
|
||||
"pem 3.0.6",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"rustls-openssl",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"thiserror 2.0.20",
|
||||
"tonic",
|
||||
"x509-parser 0.18.1",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4961,7 +4990,6 @@ dependencies = [
|
||||
"seccompiler",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.10.9",
|
||||
"socket2",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
@@ -5376,12 +5404,49 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry"
|
||||
version = "0.32.0"
|
||||
@@ -6956,6 +7021,20 @@ dependencies = [
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-openssl"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d67ff7ccc9938b2b7cee4ccc7efd33da2afe904ab7bd644f008defb259f288a2"
|
||||
dependencies = [
|
||||
"foreign-types",
|
||||
"once_cell",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"rustls",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
|
||||
@@ -209,7 +209,9 @@ pub fn build_rustls_config(materials: &TlsMaterials) -> Result<rustls::ClientCon
|
||||
.into_diagnostic()?;
|
||||
let key = load_private_key(&materials.key)?;
|
||||
|
||||
openshell_crypto::tls::client_builder()
|
||||
openshell_crypto::tls::try_client_builder()
|
||||
.into_diagnostic()
|
||||
.wrap_err("configure gateway TLS client")?
|
||||
.with_root_certificates(roots)
|
||||
.with_client_auth_cert(cert_chain, key)
|
||||
.into_diagnostic()
|
||||
@@ -256,7 +258,9 @@ impl ServerCertVerifier for InsecureServerCertVerifier {
|
||||
}
|
||||
|
||||
pub fn build_insecure_rustls_config() -> Result<rustls::ClientConfig> {
|
||||
let config = openshell_crypto::tls::client_builder()
|
||||
let config = openshell_crypto::tls::try_client_builder()
|
||||
.into_diagnostic()
|
||||
.wrap_err("configure gateway TLS client")?
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(std::sync::Arc::new(InsecureServerCertVerifier))
|
||||
.with_no_client_auth();
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
OpenShell routes first-party security-sensitive cryptography through
|
||||
`openshell-crypto`. The workspace selects AWS-LC as the production backend.
|
||||
The interface supports a future system-OpenSSL backend; the current production
|
||||
implementation does not assert FIPS compliance, and strict posture requests fail
|
||||
closed.
|
||||
The opt-in `openssl` feature supplies a system-OpenSSL backend for explicit
|
||||
context selection. Neither backend asserts deployment FIPS compliance, and strict
|
||||
posture requests fail closed. Application startup, dependency-owned transports,
|
||||
and downstream packaging/qualification remain separate OpenSSL integration work.
|
||||
|
||||
## Backend boundary
|
||||
|
||||
@@ -37,7 +38,16 @@ Explicit context selection controls first-party TLS builders even when a
|
||||
dependency initialized Rustls first. Without explicit selection, those builders
|
||||
preserve an embedder's installed provider. JWT adapters retain jsonwebtoken's
|
||||
process-global provider constraints; that provider cannot be replaced safely.
|
||||
Neither behavior redirects dependency-owned cryptography automatically.
|
||||
Neither behavior redirects dependency-owned cryptography automatically. Enabling
|
||||
OpenSSL does not replace the AWS-LC default; an embedder must install its context
|
||||
explicitly. System libraries and provider configuration belong to the deployment;
|
||||
the backend does not modify provider policy.
|
||||
|
||||
Runtime TLS configuration uses the facade's `try_client_builder` and
|
||||
`try_server_builder` APIs (including explicit protocol-version variants). A
|
||||
provider with no usable cipher suites or compatible key-exchange groups produces
|
||||
a configuration error without panic or fallback. The older infallible builders
|
||||
are compatibility helpers, not the runtime error-handling contract.
|
||||
|
||||
## Contributor rules and coverage
|
||||
|
||||
@@ -56,6 +66,74 @@ cache fingerprints, UUID entropy, SSH, and other dependency-owned operations.
|
||||
Review changes against that scope. Cargo features and contributor instructions
|
||||
alone do not prove exclusive backend ownership.
|
||||
|
||||
## Dependency guardrail
|
||||
|
||||
The root `deny.toml` uses cargo-deny's `bans.deny` and `wrappers` rules to reject
|
||||
unapproved direct dependencies on named crypto implementations. AWS-LC and OpenSSL
|
||||
are accessible through `openshell-crypto` and the explicitly listed libraries
|
||||
that own their own crypto operations. Native `*-sys` crates and the OpenSSL
|
||||
protocol adapters have separate rules to prevent bypassing the provider boundary.
|
||||
`openssl-src` is banned without wrapper exceptions: OpenSSL must come from the
|
||||
system, including when enabled transitively through another dependency.
|
||||
The catalog also covers RustCrypto hashes, MACs, ciphers, and signature libraries.
|
||||
Protocol/parsing types remain usable in application crates.
|
||||
|
||||
Cargo Deny in Branch Checks enforces the policy with all workspace features and
|
||||
no target filter, including build and development dependencies. Renaming a
|
||||
dependency in `Cargo.toml` does not bypass a package-name ban. An allowed wrapper
|
||||
on one path does not excuse another unapproved direct parent of the same package.
|
||||
Run the policy and its offline regression fixtures with:
|
||||
|
||||
```shell
|
||||
mise run rust:deny:policy
|
||||
```
|
||||
|
||||
Run just the regression fixtures with `mise run rust:deny:crypto-test`. They use
|
||||
the cargo-deny version pinned in `mise.toml`, as does the Branch Checks job; the
|
||||
fixtures reject a mismatched tool version before checking dependency graphs. They use
|
||||
local stub packages to exercise cargo-deny without compiling cryptography or
|
||||
downloading dependencies. They test allowed provider paths, shared-dependency
|
||||
bypasses, aliases, build/dev/target dependencies, and unapproved intermediary
|
||||
wrappers. The normal policy check separately validates the real dependency graph.
|
||||
|
||||
### Existing direct first-party exceptions
|
||||
|
||||
These are package-level allowances, not permission to add new security-sensitive
|
||||
operations outside the facade. Migration must preserve protocols and stored
|
||||
identities and propagate backend failures. Remove an allowance when its last use
|
||||
is migrated or deleted.
|
||||
|
||||
| Dependency | Allowed application crates | Classification |
|
||||
| --- | --- | --- |
|
||||
| `sha2` | `openshell-core` | Endpoint identities and canonical policy hashes; policy binding still requires migration. |
|
||||
| `sha2` | `openshell-driver-docker`, `openshell-driver-kubernetes-secrets`, `openshell-driver-vault` | Deterministic volume and credential-storage names, not authentication. |
|
||||
| `sha2` | `openshell-driver-kubernetes`, `openshell-driver-podman` | Destination-trust ConfigMap names and operator-bundle generation labels, not authenticity proofs. Trust relies on immutable staging, exact-byte validation, and TLS verification; see the README's destination-trust classification. |
|
||||
| `sha2` | `openshell-driver-vm`, `openshell-isolation-interface`, `openshell-supervisor-network` | Artifact integrity, outer-fence evidence, and DNS policy binding; security-sensitive migration remains required. |
|
||||
| `sha2` | `openshell-gateway-interceptors` | Provider-profile snapshot revision hashes; content identity outside backend dispatch. |
|
||||
| `sha2` | `openshell-supervisor-process` | Diagnostic debug-token fingerprints. |
|
||||
| `sha2`, `hmac` | `openshell-server` | Naming, coordination, request matching, and tests, plus security-sensitive bearer-cache keys, launch-session derivation, policy/review bindings, credential handles, and protected mutation fingerprints. The security-sensitive uses must migrate; the HMAC exception is for `grpc::mutation_replay`'s protection against offline guessing. |
|
||||
| `rsa` | `openshell-server` | Development dependency for OIDC test keys. Cargo-deny cannot restrict a wrapper exception to development dependencies. |
|
||||
| `sha1` | `openshell-supervisor-network` | WebSocket accept-handshake hashing, a protocol requirement rather than cryptographic authentication. |
|
||||
|
||||
The other wrappers in `deny.toml` cover dependency-owned operations: SSH,
|
||||
TLS/X.509/JWT, AWS SigV4, OAuth PKCE, database protocols, archive handling,
|
||||
artifact/content hashing, and implementation-to-implementation dependencies.
|
||||
The existing Rustls/Ring exceptions support the Z3 downloader; wrapper rules
|
||||
constrain direct parents, not an entire ancestry exclusively to Z3. Dependency
|
||||
upgrades that introduce a new parent must receive review instead of automatically
|
||||
adding that parent to the allowlist.
|
||||
|
||||
This is an enumerated dependency guardrail, not a source scanner or a FIPS
|
||||
attestation. It does not automatically recognize a new cryptographic crate,
|
||||
detect hand-written cryptography, inspect calls through re-exports, or prevent
|
||||
new uses inside an already allowed package. Generic entropy sources such as
|
||||
`rand`, `getrandom`, and `uuid` remain outside this catalog because ordinary
|
||||
scheduling and dependency-owned entropy coexist with the outstanding
|
||||
security-sensitive UUID uses. Standalone Cargo workspaces require their own
|
||||
policy invocation. Review source changes as well as manifests, extend the catalog
|
||||
when introducing a crypto library, and document every new exception here with
|
||||
its scope and migration requirement. Do not weaken the policy merely to pass CI.
|
||||
|
||||
## OpenSSL and FIPS follow-up
|
||||
|
||||
Backend capability reporting describes that backend's operations. A backend name,
|
||||
@@ -77,6 +155,9 @@ establish deployment compliance. A production OpenSSL/FIPS profile requires:
|
||||
- Tests in the qualified target environment covering TLS/mTLS, JWT, persisted CA
|
||||
loading, credential encryption, and negative startup/algorithm cases.
|
||||
|
||||
These are follow-up requirements, not guarantees of the current backend.
|
||||
These are follow-up requirements, not guarantees of the current backends. The
|
||||
OpenSSL protocol adapters also have infallible callbacks and provider-ownership
|
||||
limitations documented in the crate README; backend tests do not resolve those
|
||||
deployment requirements.
|
||||
Implementation details and extension APIs live in the
|
||||
[crypto crate README](README.md).
|
||||
|
||||
@@ -15,6 +15,8 @@ repository.workspace = true
|
||||
# embedder must install its backend before any crypto operation.
|
||||
default = ["aws-lc"]
|
||||
aws-lc = ["dep:aws-lc-rs", "rustls/aws_lc_rs", "rcgen/aws_lc_rs", "jsonwebtoken/aws_lc_rs", "tonic?/tls-aws-lc", "kube?/aws-lc-rs", "sqlx?/tls-rustls-aws-lc-rs"]
|
||||
# Makes OpenSsl available for explicit context selection; does not change defaults.
|
||||
openssl = ["dep:openssl", "dep:rustls-openssl", "dep:jsonwebtoken-openssl", "dep:zeroize"]
|
||||
tls-tonic = ["dep:tonic"]
|
||||
tls-kube = ["dep:kube", "dep:k8s-openapi"]
|
||||
tls-sqlx = ["dep:sqlx"]
|
||||
@@ -26,6 +28,11 @@ k8s-openapi = { workspace = true, optional = true }
|
||||
sqlx = { workspace = true, optional = true }
|
||||
|
||||
aws-lc-rs = { workspace = true, optional = true }
|
||||
openssl = { version = "0.10.79", default-features = false, optional = true }
|
||||
rustls-openssl = { version = "=0.4.1", default-features = false, features = ["tls12"], optional = true }
|
||||
# Version 1 implements jsonwebtoken 10's provider contract (v2 requires v11).
|
||||
jsonwebtoken-openssl = { version = "=1.0.0", optional = true }
|
||||
zeroize = { workspace = true, optional = true }
|
||||
rustls = { workspace = true }
|
||||
rcgen = { workspace = true }
|
||||
jsonwebtoken = { workspace = true }
|
||||
|
||||
@@ -3,13 +3,14 @@
|
||||
See [Cryptography and FIPS](CRYPTO.md) for the architecture,
|
||||
contributor rules, and production OpenSSL/FIPS follow-up requirements.
|
||||
|
||||
`openshell-crypto` owns first-party crypto backend selection. AWS-LC is the only
|
||||
production implementation. This change preserves TLS algorithms, Ed25519 gateway
|
||||
`openshell-crypto` owns first-party crypto backend selection. AWS-LC remains the
|
||||
application default; an optional system-OpenSSL backend supports explicit context
|
||||
selection by embedders. The facade preserves TLS algorithms, Ed25519 gateway
|
||||
JWTs, P-256 certificate keys, native trust roots, and credential envelope formats.
|
||||
The interface is intended to support a system-OpenSSL backend for regulated
|
||||
deployments, related to [#900](https://github.com/NVIDIA/OpenShell/issues/900).
|
||||
This stage implements AWS-LC only. An OpenSSL proof of concept will validate the
|
||||
provider integration separately; this is not a FIPS build or compliance claim.
|
||||
The OpenSSL implementation builds on the standalone proof of concept following
|
||||
[the generic backend refactor](https://github.com/NVIDIA/OpenShell/pull/3351).
|
||||
Application-wide OpenSSL selection and downstream FIPS qualification remain
|
||||
separate work; enabling this backend is not a FIPS build or compliance claim.
|
||||
|
||||
## Boundaries
|
||||
|
||||
@@ -60,11 +61,118 @@ A second backend must adapt these integrations and the application startup path.
|
||||
Disabling this crate's feature alone does not remove dependency-owned crypto
|
||||
from the entire product graph.
|
||||
|
||||
The later OpenSSL build must use system shared libraries without vendoring,
|
||||
respect system provider configuration, and verify the resulting linked artifacts.
|
||||
Linking and module qualification are build/deployment concerns, separate from the
|
||||
Rust key and primitive contracts. The proof of concept will test those concerns
|
||||
and the Rustls provider integration before a production OpenSSL backend is added.
|
||||
### Selecting system OpenSSL
|
||||
|
||||
An embedder enables `openssl` and disables crate defaults:
|
||||
|
||||
```toml
|
||||
openshell-crypto = { path = "crates/openshell-crypto", default-features = false, features = ["openssl"] }
|
||||
```
|
||||
|
||||
Install the context before facade use or JWT initialization:
|
||||
|
||||
```rust,ignore
|
||||
use openshell_crypto::{CryptoContext, OpenSsl, install_default_context};
|
||||
|
||||
install_default_context(CryptoContext::new(Box::new(OpenSsl)))?;
|
||||
```
|
||||
|
||||
The feature makes `OpenSsl` available without selecting it automatically. When
|
||||
both backends are enabled, `CryptoContext::default()` still chooses AWS-LC. With
|
||||
AWS-LC disabled, use before installation fails closed. Inspect Cargo's resolved
|
||||
graph for other dependencies that re-enable AWS-LC. This feature adds no gateway
|
||||
flag, image, or supported OpenSSL-only whole-application build.
|
||||
|
||||
Use OpenSSL 3 development libraries, a C compiler, and pkg-config. The Nix shell
|
||||
supplies these; Homebrew OpenSSL works on macOS. Set `OPENSSL_NO_VENDOR=1` and
|
||||
`OPENSSL_STATIC=0` for shared-library builds, then inspect `otool -L` (macOS) or
|
||||
`ldd` (Linux). No vendoring feature is enabled. Runtime libraries, provider
|
||||
packages and configuration must be supplied and verified by the deployment.
|
||||
|
||||
The backend uses OpenSSL's default library context and configuration without
|
||||
loading modules or overriding property queries. Configure the process before
|
||||
startup; do not unload providers or change policy during operations. Keys retain
|
||||
OpenSSL resources for their lifetime. Capabilities describe implemented operations,
|
||||
not runtime algorithm availability. `provider_version` stays unknown because the
|
||||
library version does not identify the module that performed an operation.
|
||||
|
||||
Certificate keys support P-256, P-384, Ed25519 and RSA, with PEM/PKCS#8 import and
|
||||
PKCS#8 export. Encrypted PEM keys, including those with an empty passphrase, are
|
||||
rejected without prompting for a password. RSA generation uses 2048 bits. P-521 certificate keys are rejected:
|
||||
rcgen 0.14 exposes that descriptor only with AWS-LC enabled. Existing P-521 users
|
||||
must retain AWS-LC until this limitation is resolved. This backend uses exportable
|
||||
software keys and does not add HSM or PKCS#11 loading.
|
||||
|
||||
TLS uses `rustls-openssl` 0.4.1 with TLS 1.2/1.3, without its `fips`, vendored or
|
||||
prefer-post-quantum features. The jsonwebtoken 10 adapter includes compatibility
|
||||
guards for malformed HMAC signatures, EC key consistency and algorithm/curve
|
||||
binding, RSA public-component bounds, JOSE RSA-PSS salt lengths and fixed-width
|
||||
EC JWK coordinates. Certificate key import also checks EC key consistency and
|
||||
requires RSA keys in the 2048–8192-bit range, matching AWS-LC.
|
||||
RSA certificate imports and JWT signing also validate private-key consistency,
|
||||
including the private exponent, primes, and CRT parameters, before using the key.
|
||||
RSA signing/verification, EC signing, and EC JWK extraction reuse the validated
|
||||
OpenSSL key instead of parsing it again through the upstream adapter. Claim
|
||||
validation stays in jsonwebtoken. AEAD setup errors remain distinct from authentication errors;
|
||||
tentative plaintext is cleared on failure and returned only after authentication.
|
||||
|
||||
At AEAD finalization, all OpenSSL errors map to `CryptoError::Authentication`.
|
||||
The [EVP AEAD interface](https://docs.openssl.org/3.0/man3/EVP_EncryptInit/#aead-interface)
|
||||
reports unsuccessful authentication through the finalization result; it does not
|
||||
provide a portable error-stack discriminator between a bad tag and a late provider
|
||||
failure across providers. A provider failure at this stage therefore shares that
|
||||
classification. Setup and update failures remain `CryptoError::Operation`.
|
||||
No failure releases tentative plaintext or triggers a backend fallback.
|
||||
|
||||
The optional `tls-tonic`, `tls-kube`, and `tls-sqlx` features can each compile with
|
||||
`--no-default-features --features openssl,<feature>`; CI checks those combinations
|
||||
individually. These features expose integration dependencies, not OpenSSL transport
|
||||
adapters for them. Compilation does not establish that dependency-owned clients
|
||||
use OpenSSL at runtime or make an OpenSSL-only whole-application build supported.
|
||||
|
||||
Linux CI checks that the OpenSSL-only TLS test executable dynamically resolves
|
||||
both `libssl` and `libcrypto`. The Nix devshell supplies those libraries; this is
|
||||
a linkage regression check, not validation against a distro's OpenSSL packages,
|
||||
provider configuration, or FIPS operating mode. Distro qualification remains
|
||||
downstream work.
|
||||
The test explicitly creates a native OpenSSL TLS context to retain `libssl` for
|
||||
this assertion; the backend itself uses Rustls for TLS protocol handling.
|
||||
|
||||
Both protocol adapters are external community-maintained dependencies:
|
||||
[`rustls-openssl`](https://github.com/tofay/rustls-openssl) and
|
||||
[`jsonwebtoken-openssl`](https://github.com/arckoor/jsonwebtoken-providers).
|
||||
As of 2026-10-23, upstream rustls-openssl has released 0.4.2, while the JWT adapter's
|
||||
repository remains unarchived with recent activity. This is not a support or
|
||||
security-maintenance commitment from OpenShell or those projects. The exact
|
||||
`rustls-openssl =0.4.1` pin preserves the reviewed provider behavior until a
|
||||
deliberate upgrade is validated. `jsonwebtoken-openssl =1.0.0` matches our
|
||||
jsonwebtoken 10 provider API; version 2 targets jsonwebtoken 11. Dependency
|
||||
updates must rerun interoperability, rejection, provider-failure, and linkage
|
||||
checks. Distro packagers need both Rust adapter crates as well as system OpenSSL;
|
||||
packaging Rust sources does not permit vendoring the native OpenSSL library.
|
||||
|
||||
Protocol limitations remain: Rustls hash/HKDF callbacks and JWT JWK thumbprint
|
||||
hashing cannot return backend errors; adapter helpers can panic on provider
|
||||
failure. The TLS adapter also uses direct OpenSSL SHA operations and low-level
|
||||
EC key generation that do not establish provider-module ownership. First-party
|
||||
TLS setup uses `tls::try_client_builder()` / `tls::try_server_builder()` and their
|
||||
`_with_protocol_versions` variants. These return configuration errors when the
|
||||
selected provider has no usable ciphers or compatible key-exchange groups, without
|
||||
switching providers or relaxing the requested protocol versions. The older
|
||||
infallible builders remain for API compatibility and panic on configuration errors;
|
||||
runtime consumers should use the fallible variants. This backend does not
|
||||
promise panic-free protocol operation or a provider-qualified TLS path. These
|
||||
limitations require disposition during downstream integration and qualification.
|
||||
|
||||
The pinned `rustls-openssl` 0.4.1 verifier prints OpenSSL error stacks directly to
|
||||
stderr through `std::dbg!`, including in release builds, when verification returns
|
||||
an OpenSSL error. Peer-controlled malformed signatures can trigger this path;
|
||||
the output bypasses application logging controls and can create repeated
|
||||
diagnostic noise. Verification still fails. Upstream
|
||||
[0.4.2 removes this output](https://github.com/tofay/rustls-openssl/blob/0.4.2/src/verify.rs#L347).
|
||||
Upgrade and validate the dependency before deploying this TLS path; that release
|
||||
also changes provider routing and FIPS reporting, so the upgrade requires more
|
||||
than removing the diagnostic call.
|
||||
|
||||
## Lifecycle and posture
|
||||
|
||||
@@ -114,14 +222,16 @@ SPIFFE JWT verification, and non-Rust client runtimes. SPIFFE uses its AWS-LC
|
||||
backend directly, outside the facade. AWS SDK HTTPS also
|
||||
retains its dependency-owned selection. Non-security content hashes and ordinary
|
||||
scheduling randomness are not migrated. No SSH or PQC capability is asserted by
|
||||
the primitive capability report. OpenSSL, strict policy, module version discovery,
|
||||
and deployment qualification belong in follow-up work.
|
||||
the primitive capability report. Application-wide OpenSSL integration, strict
|
||||
policy, module version discovery, and deployment qualification remain follow-ups.
|
||||
|
||||
Operator certificate fingerprints, runtime refresh replay hashes and derived token
|
||||
Migrated paths for operator certificate fingerprints, runtime refresh replay hashes and derived token
|
||||
IDs, bearer-cache fingerprints, Sandbox Protocol request digests and request-ID
|
||||
entropy, Kubernetes/VM runtime
|
||||
fence entropy, VM socket-directory entropy, and rootfs staging-token entropy use
|
||||
the selected backend.
|
||||
the selected backend. The [dependency exception inventory](CRYPTO.md#existing-direct-first-party-exceptions)
|
||||
records remaining direct implementations, including other bearer-cache and
|
||||
session-identity paths.
|
||||
These operations retain SHA-256, UUID v4 encoding where applicable, and existing
|
||||
hex encodings. Backend failures propagate; callers must not substitute another
|
||||
implementation or continue with a placeholder identity.
|
||||
@@ -144,9 +254,11 @@ not a whole-application crypto coverage claim:
|
||||
| Destination-trust generation labels (`openshell-server` network trust, Podman operator staging, Kubernetes ConfigMap naming, VM staged-snapshot checks) | Deterministic identity derivation for naming and generation equality, not encryption or proof of authorization. Trust decisions use the gateway's normalization, immutable staging, exact-byte validation, and TLS verification rather than these digests. These retain `sha2` directly. |
|
||||
| Schema compatibility tests, content-cache identities, retry jitter, test fixtures | Non-authentication uses remain unchanged. |
|
||||
|
||||
Cargo feature selection does not prevent a crate from adding another crypto
|
||||
dependency. Review first-party security-sensitive operations against this scope;
|
||||
no source-scanning boundary lint is installed.
|
||||
The [cargo-deny dependency guardrail](CRYPTO.md#dependency-guardrail) rejects
|
||||
unapproved direct dependencies on the catalogued crypto implementations. It
|
||||
preserves explicitly classified exceptions, including security-sensitive uses
|
||||
awaiting migration. Review operations inside those crates against that scope;
|
||||
the dependency check does not inspect source-level usage.
|
||||
|
||||
Durable proxy CA loading uses backend key import and `pki::issuer_from_der`.
|
||||
The helper uses `x509-parser` without verification features to read the subject,
|
||||
@@ -180,6 +292,32 @@ Ring. Inspect that boundary with
|
||||
`cargo tree -p openshell-crypto --no-default-features --edges normal,build,dev`.
|
||||
Branch Checks runs this test separately from workspace tests and rejects Ring or
|
||||
AWS-LC in the dependency tree, including build and test dependencies.
|
||||
It also runs OpenSSL-only and dual-backend tests and Clippy, and rejects Ring,
|
||||
AWS-LC and vendored OpenSSL from the OpenSSL-only crate graph. Run locally with
|
||||
system OpenSSL available:
|
||||
|
||||
```shell
|
||||
OPENSSL_NO_VENDOR=1 OPENSSL_STATIC=0 cargo test --locked -p openshell-crypto --no-default-features --features openssl
|
||||
OPENSSL_NO_VENDOR=1 OPENSSL_STATIC=0 cargo test --locked -p openshell-crypto --features openssl
|
||||
OPENSSL_NO_VENDOR=1 OPENSSL_STATIC=0 cargo clippy --locked -p openshell-crypto --no-default-features --features openssl --all-targets -- -D warnings
|
||||
OPENSSL_NO_VENDOR=1 OPENSSL_STATIC=0 cargo clippy --locked -p openshell-crypto --features openssl --all-targets -- -D warnings
|
||||
cargo tree --locked -p openshell-crypto --no-default-features --features openssl --edges normal,build,dev
|
||||
```
|
||||
|
||||
For Nix, run these commands from `nix develop .#crypto`, entered outside the
|
||||
default development shell. CI uses this native toolchain for both OpenSSL-only
|
||||
and dual-backend checks. The default shell's release toolchains supply an
|
||||
unprefixed system AWS-LC whose `libcrypto`/`libssl` names and symbols collide with
|
||||
OpenSSL. The crypto shell selects Nix OpenSSL explicitly and lets Cargo build
|
||||
symbol-prefixed AWS-LC for the dual-backend tests.
|
||||
|
||||
Tests cover known answers, tampering, chunked AEAD, PKI/CA import, JWT algorithms
|
||||
and claims, TLS 1.2/1.3 mutual authentication and rejected peers, and AWS-LC/OpenSSL
|
||||
interoperability. A subprocess uses an unsatisfiable property query to test
|
||||
unavailable algorithms without modifying its parent process. Its ignored child
|
||||
test is invoked by the parent automatically. These are backend contract tests,
|
||||
not target RHEL qualification or product E2E tests.
|
||||
|
||||
Existing bootstrap, credential-store, gateway TLS/OIDC, and proxy tests exercise the
|
||||
migrated consumers. The cargo-deny Ring restrictions and their explicit wrapper
|
||||
exceptions remain in force.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! The sole production backend. Backend-specific imports belong here.
|
||||
//! The default AWS-LC backend. Backend-specific imports belong here.
|
||||
|
||||
use crate::{Capabilities, CryptoBackend, CryptoError, Digest, FipsState, ProtocolBackend};
|
||||
use aws_lc_rs::{
|
||||
@@ -36,6 +36,7 @@ impl CryptoBackend for AwsLc {
|
||||
"ECDSA-P384-SHA384",
|
||||
"ECDSA-P521-SHA512",
|
||||
"Ed25519",
|
||||
"RSA",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,18 @@
|
||||
|
||||
//! Backend-neutral crypto operations and library-specific protocol adapters.
|
||||
//!
|
||||
//! AWS-LC is the only production implementation in this first stage. This crate
|
||||
//! AWS-LC is the default; an opt-in system-OpenSSL backend supports explicit selection. This crate
|
||||
//! does not enable FIPS mode, change algorithms, or attest dependency-owned crypto.
|
||||
//! The extension target is a system-OpenSSL backend for regulated deployments
|
||||
//! (<https://github.com/NVIDIA/OpenShell/issues/900>). See the crate README for
|
||||
//! See the crate README for
|
||||
//! initialization, key ownership, and dependency coverage boundaries.
|
||||
|
||||
pub mod aead;
|
||||
#[cfg(feature = "aws-lc")]
|
||||
mod aws_lc;
|
||||
#[cfg(feature = "openssl")]
|
||||
mod openssl_backend;
|
||||
#[cfg(feature = "openssl")]
|
||||
pub use openssl_backend::OpenSsl;
|
||||
pub mod jwt;
|
||||
pub mod pki;
|
||||
pub mod tls;
|
||||
@@ -46,6 +49,10 @@ pub enum FipsState {
|
||||
}
|
||||
|
||||
/// Operations implemented by a backend. Protocol coverage is documented separately.
|
||||
///
|
||||
/// These lists describe each backend individually, not a common set required of
|
||||
/// every backend. Host provider policy can still reject a listed operation;
|
||||
/// capabilities do not guarantee runtime availability or FIPS compliance.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Capabilities {
|
||||
/// Stable backend identifier.
|
||||
@@ -56,7 +63,8 @@ pub struct Capabilities {
|
||||
pub fips: FipsState,
|
||||
/// Primitive operations supported by this implementation.
|
||||
pub primitives: &'static [&'static str],
|
||||
/// Key generation algorithms available through the PKI adapter.
|
||||
/// Key algorithms implemented by this backend's PKI and JWT adapters.
|
||||
/// Listing an algorithm does not imply support for every operation or variant.
|
||||
pub key_algorithms: &'static [&'static str],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Opt-in OpenSSL implementation using the process's default library context.
|
||||
//! Provider configuration belongs to the embedding process, not this backend.
|
||||
|
||||
use crate::{
|
||||
Capabilities, CryptoBackend, CryptoError, Digest, FipsState, ProtocolBackend, aead::Sealed, pki,
|
||||
};
|
||||
use openssl::{
|
||||
bn::BigNumContext,
|
||||
ec::PointConversionForm,
|
||||
hash::{Hasher, MessageDigest},
|
||||
nid::Nid,
|
||||
pkey::{Id, PKey, Private},
|
||||
pkey_ctx::PkeyCtx,
|
||||
sign::Signer,
|
||||
symm::{Cipher, Crypter, Mode},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
mod jwt;
|
||||
|
||||
/// System-OpenSSL backend for explicit [`crate::CryptoContext`] selection.
|
||||
///
|
||||
/// Uses OpenSSL's default library context and configuration. It does not load
|
||||
/// providers, override property queries, or attest a FIPS module. Select before
|
||||
/// initializing crypto-dependent libraries; changing provider configuration while
|
||||
/// operations are active is unsupported. The `openssl` feature does not change
|
||||
/// the AWS-LC default or redirect dependency-owned transports.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct OpenSsl;
|
||||
|
||||
struct Sha256(Hasher);
|
||||
impl Digest for Sha256 {
|
||||
fn update(&mut self, bytes: &[u8]) -> Result<(), CryptoError> {
|
||||
self.0.update(bytes).map_err(|_| CryptoError::Operation)
|
||||
}
|
||||
fn finish(mut self: Box<Self>) -> Result<[u8; 32], CryptoError> {
|
||||
self.0
|
||||
.finish()
|
||||
.map_err(|_| CryptoError::Operation)?
|
||||
.as_ref()
|
||||
.try_into()
|
||||
.map_err(|_| CryptoError::Operation)
|
||||
}
|
||||
}
|
||||
|
||||
impl CryptoBackend for OpenSsl {
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
Capabilities {
|
||||
backend: "openssl",
|
||||
// The library version is not the identity/version of the provider
|
||||
// module that performed an operation in the default context.
|
||||
provider_version: None,
|
||||
fips: FipsState::NotVerified,
|
||||
primitives: &["CSPRNG", "SHA-256", "AES-256-GCM"],
|
||||
key_algorithms: &["ECDSA-P256-SHA256", "ECDSA-P384-SHA384", "Ed25519", "RSA"],
|
||||
}
|
||||
}
|
||||
fn fill_random(&self, output: &mut [u8]) -> Result<(), CryptoError> {
|
||||
for chunk in output.chunks_mut(1024 * 1024) {
|
||||
openssl::rand::rand_bytes(chunk).map_err(|_| CryptoError::Random)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn sha256_digest(&self) -> Result<Box<dyn Digest>, CryptoError> {
|
||||
Ok(Box::new(Sha256(
|
||||
Hasher::new(MessageDigest::sha256()).map_err(|_| CryptoError::Operation)?,
|
||||
)))
|
||||
}
|
||||
fn seal(&self, key: &[u8; 32], aad: &[u8], plaintext: &[u8]) -> Result<Sealed, CryptoError> {
|
||||
let mut nonce = [0; 12];
|
||||
self.fill_random(&mut nonce)?;
|
||||
let mut tag = [0; 16];
|
||||
let mut crypter = Crypter::new(Cipher::aes_256_gcm(), Mode::Encrypt, key, Some(&nonce))
|
||||
.map_err(|_| CryptoError::Operation)?;
|
||||
update_aad(&mut crypter, aad)?;
|
||||
// Retain room for the tag after truncating the encryption output.
|
||||
let capacity = output_size(plaintext.len())?
|
||||
.checked_add(tag.len())
|
||||
.ok_or(CryptoError::Operation)?;
|
||||
let mut ciphertext = vec![0; capacity];
|
||||
let written = update_data(&mut crypter, plaintext, &mut ciphertext)?;
|
||||
let final_len = crypter
|
||||
.finalize(&mut ciphertext[written..])
|
||||
.map_err(|_| CryptoError::Operation)?;
|
||||
ciphertext.truncate(written + final_len);
|
||||
crypter
|
||||
.get_tag(&mut tag)
|
||||
.map_err(|_| CryptoError::Operation)?;
|
||||
ciphertext.extend_from_slice(&tag);
|
||||
Ok(Sealed { nonce, ciphertext })
|
||||
}
|
||||
fn open(
|
||||
&self,
|
||||
key: &[u8; 32],
|
||||
aad: &[u8],
|
||||
nonce: &[u8; 12],
|
||||
ciphertext: &[u8],
|
||||
) -> Result<Vec<u8>, CryptoError> {
|
||||
let split = ciphertext
|
||||
.len()
|
||||
.checked_sub(16)
|
||||
.ok_or(CryptoError::Authentication)?;
|
||||
let (data, tag) = ciphertext.split_at(split);
|
||||
let mut crypter = Crypter::new(Cipher::aes_256_gcm(), Mode::Decrypt, key, Some(nonce))
|
||||
.map_err(|_| CryptoError::Operation)?;
|
||||
update_aad(&mut crypter, aad)?;
|
||||
crypter.set_tag(tag).map_err(|_| CryptoError::Operation)?;
|
||||
// EVP writes tentative plaintext before authenticating. Clear it on every
|
||||
// failure path and return it only after finalization verifies the tag.
|
||||
let mut plaintext = Zeroizing::new(vec![0; output_size(data.len())?]);
|
||||
let written = update_data(&mut crypter, data, &mut plaintext)?;
|
||||
// EVP finalization has no portable provider-independent classification
|
||||
// for bad tags versus late provider failures. Treat all finalization
|
||||
// failures as authentication failures; never release tentative plaintext.
|
||||
let final_len = crypter
|
||||
.finalize(&mut plaintext[written..])
|
||||
.map_err(|_| CryptoError::Authentication)?;
|
||||
plaintext.truncate(written + final_len);
|
||||
Ok(std::mem::take(&mut *plaintext))
|
||||
}
|
||||
}
|
||||
|
||||
fn output_size(len: usize) -> Result<usize, CryptoError> {
|
||||
len.checked_add(Cipher::aes_256_gcm().block_size())
|
||||
.ok_or(CryptoError::Operation)
|
||||
}
|
||||
|
||||
fn update_aad(crypter: &mut Crypter, aad: &[u8]) -> Result<(), CryptoError> {
|
||||
for chunk in aad.chunks(1024 * 1024) {
|
||||
crypter
|
||||
.aad_update(chunk)
|
||||
.map_err(|_| CryptoError::Operation)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_data(
|
||||
crypter: &mut Crypter,
|
||||
data: &[u8],
|
||||
output: &mut [u8],
|
||||
) -> Result<usize, CryptoError> {
|
||||
let mut written = 0;
|
||||
// OpenSSL's update APIs use signed-int lengths. Bound both input and output
|
||||
// slices rather than allowing the Rust bindings to panic on large records.
|
||||
for chunk in data.chunks(1024 * 1024) {
|
||||
let end = written + output_size(chunk.len())?;
|
||||
written += crypter
|
||||
.update(chunk, &mut output[written..end])
|
||||
.map_err(|_| CryptoError::Operation)?;
|
||||
}
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
fn key_error(_: openssl::error::ErrorStack) -> rcgen::Error {
|
||||
rcgen::Error::RemoteKeyError
|
||||
}
|
||||
|
||||
struct Key {
|
||||
inner: PKey<Private>,
|
||||
algorithm: &'static rcgen::SignatureAlgorithm,
|
||||
public: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
fn wrap(
|
||||
inner: PKey<Private>,
|
||||
algorithm: &'static rcgen::SignatureAlgorithm,
|
||||
) -> Result<pki::KeyPair, rcgen::Error> {
|
||||
let public = match inner.id() {
|
||||
Id::ED25519 => inner.raw_public_key().map_err(key_error)?,
|
||||
Id::EC => {
|
||||
let ec = inner.ec_key().map_err(key_error)?;
|
||||
// Decoding a key does not validate its scalar, public point, or
|
||||
// their consistency. Reject invalid material before exposing it
|
||||
// to certificate construction or signing.
|
||||
ec.check_key().map_err(key_error)?;
|
||||
let mut ctx = BigNumContext::new().map_err(key_error)?;
|
||||
ec.public_key()
|
||||
.to_bytes(ec.group(), PointConversionForm::UNCOMPRESSED, &mut ctx)
|
||||
.map_err(key_error)?
|
||||
}
|
||||
Id::RSA => {
|
||||
// Preserve AWS-LC's 2048–8192-bit range for certificate keys on PEM
|
||||
// and PKCS#8 DER imports, before exposing a signing key.
|
||||
if !(2048..=8192).contains(&inner.bits()) {
|
||||
return Err(rcgen::Error::CouldNotParseKeyPair);
|
||||
}
|
||||
let rsa = inner.rsa().map_err(key_error)?;
|
||||
// Decoding and size checks do not establish consistency of the
|
||||
// private exponent, primes, and CRT parameters. Signing may
|
||||
// still succeed through CRT even when the private exponent is wrong.
|
||||
if !matches!(rsa.check_key(), Ok(true)) {
|
||||
return Err(rcgen::Error::CouldNotParseKeyPair);
|
||||
}
|
||||
rsa.public_key_to_der_pkcs1().map_err(key_error)?
|
||||
}
|
||||
_ => return Err(rcgen::Error::UnsupportedSignatureAlgorithm),
|
||||
};
|
||||
pki::KeyPair::new(Box::new(Self {
|
||||
inner,
|
||||
algorithm,
|
||||
public,
|
||||
}))
|
||||
}
|
||||
fn import(inner: PKey<Private>) -> Result<pki::KeyPair, rcgen::Error> {
|
||||
let algorithm = match inner.id() {
|
||||
Id::ED25519 => &rcgen::PKCS_ED25519,
|
||||
Id::RSA => &rcgen::PKCS_RSA_SHA256,
|
||||
Id::EC => match inner.ec_key().map_err(key_error)?.group().curve_name() {
|
||||
Some(Nid::X9_62_PRIME256V1) => &rcgen::PKCS_ECDSA_P256_SHA256,
|
||||
Some(Nid::SECP384R1) => &rcgen::PKCS_ECDSA_P384_SHA384,
|
||||
_ => return Err(rcgen::Error::UnsupportedSignatureAlgorithm),
|
||||
},
|
||||
_ => return Err(rcgen::Error::UnsupportedSignatureAlgorithm),
|
||||
};
|
||||
Self::wrap(inner, algorithm)
|
||||
}
|
||||
}
|
||||
|
||||
impl pki::SigningKey for Key {
|
||||
fn algorithm(&self) -> &'static rcgen::SignatureAlgorithm {
|
||||
self.algorithm
|
||||
}
|
||||
fn public_key_raw(&self) -> &[u8] {
|
||||
&self.public
|
||||
}
|
||||
fn sign(&self, message: &[u8]) -> Result<Vec<u8>, rcgen::Error> {
|
||||
let mut signer = if self.algorithm == &rcgen::PKCS_ED25519 {
|
||||
Signer::new_without_digest(&self.inner)
|
||||
} else {
|
||||
let digest = if self.algorithm == &rcgen::PKCS_ECDSA_P384_SHA384
|
||||
|| self.algorithm == &rcgen::PKCS_RSA_SHA384
|
||||
{
|
||||
MessageDigest::sha384()
|
||||
} else if self.algorithm == &rcgen::PKCS_RSA_SHA512 {
|
||||
MessageDigest::sha512()
|
||||
} else if self.algorithm == &rcgen::PKCS_ECDSA_P256_SHA256
|
||||
|| self.algorithm == &rcgen::PKCS_RSA_SHA256
|
||||
{
|
||||
MessageDigest::sha256()
|
||||
} else {
|
||||
return Err(rcgen::Error::UnsupportedSignatureAlgorithm);
|
||||
};
|
||||
Signer::new(digest, &self.inner)
|
||||
}
|
||||
.map_err(key_error)?;
|
||||
signer.sign_oneshot_to_vec(message).map_err(key_error)
|
||||
}
|
||||
fn export_pkcs8_der(&self) -> Result<Vec<u8>, rcgen::Error> {
|
||||
self.inner.private_key_to_pkcs8().map_err(key_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenSsl {
|
||||
fn generate(
|
||||
algorithm: &'static rcgen::SignatureAlgorithm,
|
||||
) -> Result<PKey<Private>, rcgen::Error> {
|
||||
if algorithm == &rcgen::PKCS_ED25519 {
|
||||
let mut context = PkeyCtx::new_id(Id::ED25519).map_err(key_error)?;
|
||||
context.keygen_init().map_err(key_error)?;
|
||||
return context.keygen().map_err(key_error);
|
||||
}
|
||||
if [
|
||||
&rcgen::PKCS_RSA_SHA256,
|
||||
&rcgen::PKCS_RSA_SHA384,
|
||||
&rcgen::PKCS_RSA_SHA512,
|
||||
]
|
||||
.contains(&algorithm)
|
||||
{
|
||||
let mut context = PkeyCtx::new_id(Id::RSA).map_err(key_error)?;
|
||||
context.keygen_init().map_err(key_error)?;
|
||||
context.set_rsa_keygen_bits(2048).map_err(key_error)?;
|
||||
return context.keygen().map_err(key_error);
|
||||
}
|
||||
let curve = if algorithm == &rcgen::PKCS_ECDSA_P256_SHA256 {
|
||||
Nid::X9_62_PRIME256V1
|
||||
} else if algorithm == &rcgen::PKCS_ECDSA_P384_SHA384 {
|
||||
Nid::SECP384R1
|
||||
} else {
|
||||
return Err(rcgen::Error::UnsupportedSignatureAlgorithm);
|
||||
};
|
||||
let mut context = PkeyCtx::new_id(Id::EC).map_err(key_error)?;
|
||||
context.keygen_init().map_err(key_error)?;
|
||||
context
|
||||
.set_ec_paramgen_curve_nid(curve)
|
||||
.map_err(key_error)?;
|
||||
context.keygen().map_err(key_error)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolBackend for OpenSsl {
|
||||
fn tls_provider(&self) -> rustls::crypto::CryptoProvider {
|
||||
rustls_openssl::default_provider()
|
||||
}
|
||||
fn generate_keypair(
|
||||
&self,
|
||||
algorithm: &'static rcgen::SignatureAlgorithm,
|
||||
) -> Result<pki::KeyPair, rcgen::Error> {
|
||||
Key::wrap(Self::generate(algorithm)?, algorithm)
|
||||
}
|
||||
fn import_keypair_pem(&self, pem: &str) -> Result<pki::KeyPair, rcgen::Error> {
|
||||
// The default OpenSSL callback prompts on the controlling terminal.
|
||||
// This API has no passphrase contract: reject encrypted input without
|
||||
// terminal I/O, including input encrypted with an empty passphrase.
|
||||
let mut password_requested = false;
|
||||
let key = PKey::private_key_from_pem_callback(pem.as_bytes(), |_| {
|
||||
password_requested = true;
|
||||
Ok(0)
|
||||
});
|
||||
if password_requested {
|
||||
return Err(rcgen::Error::CouldNotParseKeyPair);
|
||||
}
|
||||
Key::import(key.map_err(key_error)?)
|
||||
}
|
||||
fn import_keypair_der(&self, der: &[u8]) -> Result<pki::KeyPair, rcgen::Error> {
|
||||
Key::import(PKey::private_key_from_pkcs8(der).map_err(key_error)?)
|
||||
}
|
||||
fn jwt_provider(&self) -> &'static jsonwebtoken::crypto::CryptoProvider {
|
||||
jwt::provider()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "aws-lc"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::pki::SigningKey;
|
||||
|
||||
#[test]
|
||||
fn signing_rejects_an_unmapped_algorithm() {
|
||||
// Exercise signing directly: generation/import currently reject P-521,
|
||||
// but extending those paths must not silently select SHA-256 here.
|
||||
let mut context = PkeyCtx::new_id(Id::EC).unwrap();
|
||||
context.keygen_init().unwrap();
|
||||
context.set_ec_paramgen_curve_nid(Nid::SECP521R1).unwrap();
|
||||
let key = Key {
|
||||
inner: context.keygen().unwrap(),
|
||||
algorithm: &rcgen::PKCS_ECDSA_P521_SHA512,
|
||||
public: Vec::new(),
|
||||
};
|
||||
assert!(matches!(
|
||||
key.sign(b"certificate contents"),
|
||||
Err(rcgen::Error::UnsupportedSignatureAlgorithm)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
//! Compatibility checks around the jsonwebtoken 10 OpenSSL adapter.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use jsonwebtoken::{
|
||||
Algorithm, AlgorithmFamily, DecodingKey, DecodingKeyKind, EncodingKey,
|
||||
crypto::{CryptoProvider, JwtSigner, JwtVerifier},
|
||||
errors::{ErrorKind, Result},
|
||||
jwk::EllipticCurve,
|
||||
signature::{Error, Signer, Verifier},
|
||||
};
|
||||
use openssl::{
|
||||
bn::{BigNum, BigNumContext},
|
||||
ecdsa::EcdsaSig,
|
||||
hash::MessageDigest,
|
||||
nid::Nid,
|
||||
pkey::{PKey, Private, Public},
|
||||
rsa::{Padding, Rsa},
|
||||
sign::{RsaPssSaltlen, Signer as OpenSslSigner, Verifier as OpenSslVerifier},
|
||||
};
|
||||
|
||||
pub(super) fn provider() -> &'static CryptoProvider {
|
||||
static PROVIDER: OnceLock<CryptoProvider> = OnceLock::new();
|
||||
PROVIDER.get_or_init(|| {
|
||||
let mut provider = jsonwebtoken_openssl::DEFAULT_PROVIDER.clone();
|
||||
provider.signer_factory = signer;
|
||||
provider.verifier_factory = verifier;
|
||||
provider.jwk_utils.extract_ec_public_key_coordinates = ec_coordinates;
|
||||
provider
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_ec_key(algorithm: Algorithm, der: &[u8]) -> Result<PKey<Private>> {
|
||||
let expected = match algorithm {
|
||||
Algorithm::ES256 => Nid::X9_62_PRIME256V1,
|
||||
Algorithm::ES384 => Nid::SECP384R1,
|
||||
_ => return Err(ErrorKind::InvalidAlgorithm.into()),
|
||||
};
|
||||
let key = PKey::private_key_from_der(der).map_err(|_| ErrorKind::InvalidEcdsaKey)?;
|
||||
let ec = key.ec_key().map_err(|_| ErrorKind::InvalidEcdsaKey)?;
|
||||
if ec.group().curve_name() != Some(expected) {
|
||||
return Err(ErrorKind::InvalidEcdsaKey.into());
|
||||
}
|
||||
ec.check_key().map_err(|_| ErrorKind::InvalidEcdsaKey)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
// Signature required by jsonwebtoken's provider factory.
|
||||
#[allow(clippy::trivially_copy_pass_by_ref)]
|
||||
fn signer(algorithm: &Algorithm, key: &EncodingKey) -> Result<Box<dyn JwtSigner>> {
|
||||
if matches!(algorithm, Algorithm::ES256 | Algorithm::ES384) {
|
||||
if key.family() != AlgorithmFamily::Ec {
|
||||
return Err(ErrorKind::InvalidKeyFormat.into());
|
||||
}
|
||||
let key = parse_ec_key(*algorithm, key.inner())?;
|
||||
let (digest, width) = match algorithm {
|
||||
Algorithm::ES256 => (MessageDigest::sha256(), 32),
|
||||
Algorithm::ES384 => (MessageDigest::sha384(), 48),
|
||||
_ => return Err(ErrorKind::InvalidAlgorithm.into()),
|
||||
};
|
||||
return Ok(Box::new(EcSigner {
|
||||
key,
|
||||
algorithm: *algorithm,
|
||||
digest,
|
||||
width,
|
||||
}));
|
||||
}
|
||||
if let Some((digest, padding)) = rsa_parameters(*algorithm) {
|
||||
if key.family() != AlgorithmFamily::Rsa {
|
||||
return Err(ErrorKind::InvalidKeyFormat.into());
|
||||
}
|
||||
let key =
|
||||
PKey::private_key_from_der(key.inner()).map_err(|_| ErrorKind::InvalidKeyFormat)?;
|
||||
check_rsa_key(&key)?;
|
||||
// Public-component checks are insufficient for signing keys. Keep this
|
||||
// private-key validation out of the public verifier path.
|
||||
if !matches!(key.rsa().and_then(|rsa| rsa.check_key()), Ok(true)) {
|
||||
return Err(ErrorKind::InvalidKeyFormat.into());
|
||||
}
|
||||
return Ok(Box::new(RsaSigner {
|
||||
key,
|
||||
algorithm: *algorithm,
|
||||
digest,
|
||||
padding,
|
||||
}));
|
||||
}
|
||||
(jsonwebtoken_openssl::DEFAULT_PROVIDER.signer_factory)(algorithm, key)
|
||||
}
|
||||
|
||||
#[allow(clippy::trivially_copy_pass_by_ref)]
|
||||
fn verifier(algorithm: &Algorithm, key: &DecodingKey) -> Result<Box<dyn JwtVerifier>> {
|
||||
if let Some((digest, padding)) = rsa_parameters(*algorithm) {
|
||||
let key = rsa_public_key(key)?;
|
||||
check_rsa_key(&key)?;
|
||||
return Ok(Box::new(RsaVerifier {
|
||||
key,
|
||||
algorithm: *algorithm,
|
||||
digest,
|
||||
padding,
|
||||
}));
|
||||
}
|
||||
let inner = (jsonwebtoken_openssl::DEFAULT_PROVIDER.verifier_factory)(algorithm, key)?;
|
||||
let expected = match algorithm {
|
||||
Algorithm::HS256 => 32,
|
||||
Algorithm::HS384 => 48,
|
||||
Algorithm::HS512 => 64,
|
||||
_ => return Ok(inner),
|
||||
};
|
||||
Ok(Box::new(HmacVerifier { inner, expected }))
|
||||
}
|
||||
|
||||
fn rsa_public_key(key: &DecodingKey) -> Result<PKey<Public>> {
|
||||
if key.family() != AlgorithmFamily::Rsa {
|
||||
return Err(ErrorKind::InvalidKeyFormat.into());
|
||||
}
|
||||
let rsa = match key.kind() {
|
||||
DecodingKeyKind::SecretOrDer(der) => Rsa::public_key_from_der_pkcs1(der),
|
||||
DecodingKeyKind::RsaModulusExponent { n, e } => Rsa::from_public_components(
|
||||
BigNum::from_slice(n).map_err(|_| ErrorKind::InvalidKeyFormat)?,
|
||||
BigNum::from_slice(e).map_err(|_| ErrorKind::InvalidKeyFormat)?,
|
||||
),
|
||||
}
|
||||
.map_err(|_| ErrorKind::InvalidKeyFormat)?;
|
||||
PKey::from_rsa(rsa).map_err(|_| ErrorKind::InvalidKeyFormat.into())
|
||||
}
|
||||
|
||||
struct HmacVerifier {
|
||||
inner: Box<dyn JwtVerifier>,
|
||||
expected: usize,
|
||||
}
|
||||
|
||||
impl Verifier<Vec<u8>> for HmacVerifier {
|
||||
fn verify(&self, message: &[u8], signature: &Vec<u8>) -> std::result::Result<(), Error> {
|
||||
// The underlying constant-time comparison requires equal lengths.
|
||||
// Signature length is public; reject malformed input before comparison.
|
||||
if signature.len() != self.expected {
|
||||
return Err(Error::new());
|
||||
}
|
||||
self.inner.verify(message, signature)
|
||||
}
|
||||
}
|
||||
|
||||
impl JwtVerifier for HmacVerifier {
|
||||
fn algorithm(&self) -> Algorithm {
|
||||
self.inner.algorithm()
|
||||
}
|
||||
}
|
||||
|
||||
fn rsa_parameters(algorithm: Algorithm) -> Option<(MessageDigest, Padding)> {
|
||||
match algorithm {
|
||||
Algorithm::RS256 => Some((MessageDigest::sha256(), Padding::PKCS1)),
|
||||
Algorithm::RS384 => Some((MessageDigest::sha384(), Padding::PKCS1)),
|
||||
Algorithm::RS512 => Some((MessageDigest::sha512(), Padding::PKCS1)),
|
||||
Algorithm::PS256 => Some((MessageDigest::sha256(), Padding::PKCS1_PSS)),
|
||||
Algorithm::PS384 => Some((MessageDigest::sha384(), Padding::PKCS1_PSS)),
|
||||
Algorithm::PS512 => Some((MessageDigest::sha512(), Padding::PKCS1_PSS)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn check_rsa_key<T: openssl::pkey::HasPublic>(key: &PKey<T>) -> Result<()> {
|
||||
if key.id() != openssl::pkey::Id::RSA || !(2048..=8192).contains(&key.bits()) {
|
||||
return Err(ErrorKind::InvalidKeyFormat.into());
|
||||
}
|
||||
let rsa = key.rsa().map_err(|_| ErrorKind::InvalidKeyFormat)?;
|
||||
// Match AWS-LC's signature-key bounds: a positive odd modulus and an odd
|
||||
// public exponent greater than one, at most 33 bits. OpenSSL's component
|
||||
// constructors only assemble a key; they do not enforce these constraints.
|
||||
if rsa.n().is_negative()
|
||||
|| !rsa.n().is_odd()
|
||||
|| rsa.e().is_negative()
|
||||
|| !rsa.e().is_odd()
|
||||
|| !(2..=33).contains(&rsa.e().num_bits())
|
||||
{
|
||||
return Err(ErrorKind::InvalidKeyFormat.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct RsaSigner {
|
||||
key: PKey<Private>,
|
||||
algorithm: Algorithm,
|
||||
digest: MessageDigest,
|
||||
padding: Padding,
|
||||
}
|
||||
|
||||
impl Signer<Vec<u8>> for RsaSigner {
|
||||
fn try_sign(&self, message: &[u8]) -> std::result::Result<Vec<u8>, Error> {
|
||||
let mut signer = OpenSslSigner::new(self.digest, &self.key).map_err(Error::from_source)?;
|
||||
signer
|
||||
.set_rsa_padding(self.padding)
|
||||
.map_err(Error::from_source)?;
|
||||
if self.padding == Padding::PKCS1_PSS {
|
||||
signer
|
||||
.set_rsa_mgf1_md(self.digest)
|
||||
.map_err(Error::from_source)?;
|
||||
// JOSE requires salt length equal to the digest length (RFC 7518 3.5).
|
||||
signer
|
||||
.set_rsa_pss_saltlen(RsaPssSaltlen::DIGEST_LENGTH)
|
||||
.map_err(Error::from_source)?;
|
||||
}
|
||||
signer
|
||||
.sign_oneshot_to_vec(message)
|
||||
.map_err(Error::from_source)
|
||||
}
|
||||
}
|
||||
|
||||
impl JwtSigner for RsaSigner {
|
||||
fn algorithm(&self) -> Algorithm {
|
||||
self.algorithm
|
||||
}
|
||||
}
|
||||
|
||||
struct RsaVerifier {
|
||||
key: PKey<Public>,
|
||||
algorithm: Algorithm,
|
||||
digest: MessageDigest,
|
||||
padding: Padding,
|
||||
}
|
||||
|
||||
impl Verifier<Vec<u8>> for RsaVerifier {
|
||||
fn verify(&self, message: &[u8], signature: &Vec<u8>) -> std::result::Result<(), Error> {
|
||||
let mut verifier =
|
||||
OpenSslVerifier::new(self.digest, &self.key).map_err(Error::from_source)?;
|
||||
verifier
|
||||
.set_rsa_padding(self.padding)
|
||||
.map_err(Error::from_source)?;
|
||||
if self.padding == Padding::PKCS1_PSS {
|
||||
verifier
|
||||
.set_rsa_mgf1_md(self.digest)
|
||||
.map_err(Error::from_source)?;
|
||||
verifier
|
||||
.set_rsa_pss_saltlen(RsaPssSaltlen::DIGEST_LENGTH)
|
||||
.map_err(Error::from_source)?;
|
||||
}
|
||||
if verifier
|
||||
.verify_oneshot(signature, message)
|
||||
.map_err(Error::from_source)?
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JwtVerifier for RsaVerifier {
|
||||
fn algorithm(&self) -> Algorithm {
|
||||
self.algorithm
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the validated key rather than asking the upstream factory to parse it again.
|
||||
struct EcSigner {
|
||||
key: PKey<Private>,
|
||||
algorithm: Algorithm,
|
||||
digest: MessageDigest,
|
||||
width: i32,
|
||||
}
|
||||
|
||||
impl Signer<Vec<u8>> for EcSigner {
|
||||
fn try_sign(&self, message: &[u8]) -> std::result::Result<Vec<u8>, Error> {
|
||||
let mut signer = OpenSslSigner::new(self.digest, &self.key).map_err(Error::from_source)?;
|
||||
let der = signer
|
||||
.sign_oneshot_to_vec(message)
|
||||
.map_err(Error::from_source)?;
|
||||
// JOSE uses fixed-width r || s, whereas OpenSSL returns an ASN.1 sequence.
|
||||
let signature = EcdsaSig::from_der(&der).map_err(Error::from_source)?;
|
||||
let mut raw = signature
|
||||
.r()
|
||||
.to_vec_padded(self.width)
|
||||
.map_err(Error::from_source)?;
|
||||
raw.extend(
|
||||
signature
|
||||
.s()
|
||||
.to_vec_padded(self.width)
|
||||
.map_err(Error::from_source)?,
|
||||
);
|
||||
Ok(raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl JwtSigner for EcSigner {
|
||||
fn algorithm(&self) -> Algorithm {
|
||||
self.algorithm
|
||||
}
|
||||
}
|
||||
|
||||
fn ec_coordinates(der: &[u8], algorithm: Algorithm) -> Result<(EllipticCurve, Vec<u8>, Vec<u8>)> {
|
||||
let key = parse_ec_key(algorithm, der)?;
|
||||
let ec = key.ec_key().map_err(|_| ErrorKind::InvalidEcdsaKey)?;
|
||||
let (curve, width) = match algorithm {
|
||||
Algorithm::ES256 => (EllipticCurve::P256, 32),
|
||||
Algorithm::ES384 => (EllipticCurve::P384, 48),
|
||||
_ => return Err(ErrorKind::InvalidAlgorithm.into()),
|
||||
};
|
||||
let mut x = BigNum::new().map_err(Error::from_source)?;
|
||||
let mut y = BigNum::new().map_err(Error::from_source)?;
|
||||
let mut context = BigNumContext::new().map_err(Error::from_source)?;
|
||||
ec.public_key()
|
||||
.affine_coordinates_gfp(ec.group(), &mut x, &mut y, &mut context)
|
||||
.map_err(|_| ErrorKind::InvalidEcdsaKey)?;
|
||||
// JWK coordinates have fixed width, including leading zeros (RFC 7518 6.2.1).
|
||||
Ok((
|
||||
curve,
|
||||
x.to_vec_padded(width).map_err(Error::from_source)?,
|
||||
y.to_vec_padded(width).map_err(Error::from_source)?,
|
||||
))
|
||||
}
|
||||
@@ -53,34 +53,80 @@ pub fn configuration_provider() -> Arc<CryptoProvider> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Client builder using the same provider as key loading and custom verifiers.
|
||||
/// Fallible client builder using the same provider as key loading and verifiers.
|
||||
///
|
||||
/// Returns a configuration error if the selected provider has no usable cipher
|
||||
/// suites or key-exchange groups. Does not change providers or protocol policy.
|
||||
pub fn try_client_builder()
|
||||
-> Result<rustls::ConfigBuilder<rustls::ClientConfig, rustls::WantsVerifier>, rustls::Error> {
|
||||
try_client_builder_with_protocol_versions(rustls::DEFAULT_VERSIONS)
|
||||
}
|
||||
|
||||
/// Fallible client builder preserving an explicit protocol-version policy.
|
||||
pub fn try_client_builder_with_protocol_versions(
|
||||
versions: &[&'static rustls::SupportedProtocolVersion],
|
||||
) -> Result<rustls::ConfigBuilder<rustls::ClientConfig, rustls::WantsVerifier>, rustls::Error> {
|
||||
rustls::ClientConfig::builder_with_provider(configuration_provider())
|
||||
.with_protocol_versions(versions)
|
||||
}
|
||||
|
||||
/// Fallible server builder using the selected configuration provider.
|
||||
///
|
||||
/// Returns a configuration error if the selected provider has no usable cipher
|
||||
/// suites or key-exchange groups. Does not change providers or protocol policy.
|
||||
pub fn try_server_builder()
|
||||
-> Result<rustls::ConfigBuilder<rustls::ServerConfig, rustls::WantsVerifier>, rustls::Error> {
|
||||
try_server_builder_with_protocol_versions(rustls::DEFAULT_VERSIONS)
|
||||
}
|
||||
|
||||
/// Fallible server builder preserving an explicit protocol-version policy.
|
||||
pub fn try_server_builder_with_protocol_versions(
|
||||
versions: &[&'static rustls::SupportedProtocolVersion],
|
||||
) -> Result<rustls::ConfigBuilder<rustls::ServerConfig, rustls::WantsVerifier>, rustls::Error> {
|
||||
rustls::ServerConfig::builder_with_provider(configuration_provider())
|
||||
.with_protocol_versions(versions)
|
||||
}
|
||||
|
||||
/// Compatibility client builder. Use [`try_client_builder`] in runtime code.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the selected provider cannot support the default protocol versions.
|
||||
#[must_use]
|
||||
pub fn client_builder() -> rustls::ConfigBuilder<rustls::ClientConfig, rustls::WantsVerifier> {
|
||||
client_builder_with_protocol_versions(rustls::DEFAULT_VERSIONS)
|
||||
}
|
||||
|
||||
/// Client builder preserving an explicit protocol-version policy.
|
||||
/// Compatibility client builder preserving an explicit protocol-version policy.
|
||||
/// Prefer [`try_client_builder_with_protocol_versions`] in runtime code.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the selected provider cannot support the requested protocol versions.
|
||||
#[must_use]
|
||||
pub fn client_builder_with_protocol_versions(
|
||||
versions: &[&'static rustls::SupportedProtocolVersion],
|
||||
) -> rustls::ConfigBuilder<rustls::ClientConfig, rustls::WantsVerifier> {
|
||||
rustls::ClientConfig::builder_with_provider(configuration_provider())
|
||||
.with_protocol_versions(versions)
|
||||
try_client_builder_with_protocol_versions(versions)
|
||||
.expect("selected TLS provider must support the requested protocol versions")
|
||||
}
|
||||
|
||||
/// Server builder using the selected configuration provider.
|
||||
/// Compatibility server builder. Use [`try_server_builder`] in runtime code.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the selected provider cannot support the default protocol versions.
|
||||
#[must_use]
|
||||
pub fn server_builder() -> rustls::ConfigBuilder<rustls::ServerConfig, rustls::WantsVerifier> {
|
||||
server_builder_with_protocol_versions(rustls::DEFAULT_VERSIONS)
|
||||
}
|
||||
|
||||
/// Server builder preserving an explicit protocol-version policy.
|
||||
/// Compatibility server builder preserving an explicit protocol-version policy.
|
||||
/// Prefer [`try_server_builder_with_protocol_versions`] in runtime code.
|
||||
///
|
||||
/// # Panics
|
||||
/// Panics if the selected provider cannot support the requested protocol versions.
|
||||
#[must_use]
|
||||
pub fn server_builder_with_protocol_versions(
|
||||
versions: &[&'static rustls::SupportedProtocolVersion],
|
||||
) -> rustls::ConfigBuilder<rustls::ServerConfig, rustls::WantsVerifier> {
|
||||
rustls::ServerConfig::builder_with_provider(configuration_provider())
|
||||
.with_protocol_versions(versions)
|
||||
try_server_builder_with_protocol_versions(versions)
|
||||
.expect("selected TLS provider must support the requested protocol versions")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
#[cfg(feature = "openssl")]
|
||||
use openshell_crypto::{CryptoContext, OpenSsl};
|
||||
#[cfg(feature = "openssl")]
|
||||
use std::sync::Once;
|
||||
|
||||
// The context-selection test uses only the backend-neutral handshake helper.
|
||||
#[cfg(feature = "openssl")]
|
||||
#[allow(dead_code)]
|
||||
pub fn init_openssl() {
|
||||
static INIT: Once = Once::new();
|
||||
INIT.call_once(|| {
|
||||
// Explicit facade selection must override an existing Rustls default.
|
||||
#[cfg(feature = "aws-lc")]
|
||||
rustls::crypto::aws_lc_rs::default_provider()
|
||||
.install_default()
|
||||
.unwrap();
|
||||
openshell_crypto::install_default_context(CryptoContext::new(Box::new(OpenSsl))).unwrap();
|
||||
});
|
||||
}
|
||||
|
||||
pub fn handshake(
|
||||
client: rustls::ClientConfig,
|
||||
server: rustls::ServerConfig,
|
||||
host: &'static str,
|
||||
) -> Result<(rustls::ClientConnection, rustls::ServerConnection), rustls::Error> {
|
||||
let mut client = rustls::ClientConnection::new(Arc::new(client), host.try_into().unwrap())?;
|
||||
let mut server = rustls::ServerConnection::new(Arc::new(server))?;
|
||||
for _ in 0..10 {
|
||||
let mut wire = Vec::new();
|
||||
client.write_tls(&mut wire).unwrap();
|
||||
server.read_tls(&mut wire.as_slice()).unwrap();
|
||||
server.process_new_packets()?;
|
||||
wire.clear();
|
||||
server.write_tls(&mut wire).unwrap();
|
||||
client.read_tls(&mut wire.as_slice()).unwrap();
|
||||
client.process_new_packets()?;
|
||||
if !client.is_handshaking() && !server.is_handshaking() {
|
||||
return Ok((client, server));
|
||||
}
|
||||
}
|
||||
Err(rustls::Error::General("handshake did not finish".into()))
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
#![cfg(feature = "aws-lc")]
|
||||
|
||||
mod common;
|
||||
|
||||
use openshell_crypto::{
|
||||
Capabilities, CryptoBackend, CryptoContext, CryptoError, Digest, ProtocolBackend, aead::Sealed,
|
||||
};
|
||||
@@ -171,10 +173,12 @@ fn selected_context_controls_application_adapters_and_cannot_be_replaced() {
|
||||
.unwrap();
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add(cert.der().clone()).unwrap();
|
||||
let client = openshell_crypto::tls::client_builder()
|
||||
let client = openshell_crypto::tls::try_client_builder()
|
||||
.unwrap()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
let server = openshell_crypto::tls::server_builder()
|
||||
let server = openshell_crypto::tls::try_server_builder()
|
||||
.unwrap()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![cert.der().clone()],
|
||||
@@ -184,24 +188,7 @@ fn selected_context_controls_application_adapters_and_cannot_be_replaced() {
|
||||
assert_eq!(client.crypto_provider().cipher_suites.len(), 1);
|
||||
assert_eq!(server.crypto_provider().cipher_suites.len(), 1);
|
||||
let suite = client.crypto_provider().cipher_suites[0].suite();
|
||||
let mut client =
|
||||
rustls::ClientConnection::new(std::sync::Arc::new(client), "localhost".try_into().unwrap())
|
||||
.unwrap();
|
||||
let mut server = rustls::ServerConnection::new(std::sync::Arc::new(server)).unwrap();
|
||||
for _ in 0..10 {
|
||||
let mut wire = Vec::new();
|
||||
client.write_tls(&mut wire).unwrap();
|
||||
server.read_tls(&mut wire.as_slice()).unwrap();
|
||||
server.process_new_packets().unwrap();
|
||||
wire.clear();
|
||||
server.write_tls(&mut wire).unwrap();
|
||||
client.read_tls(&mut wire.as_slice()).unwrap();
|
||||
client.process_new_packets().unwrap();
|
||||
if !client.is_handshaking() && !server.is_handshaking() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(!client.is_handshaking() && !server.is_handshaking());
|
||||
let (client, _) = common::handshake(client, server, "localhost").unwrap();
|
||||
assert_eq!(client.negotiated_cipher_suite().unwrap().suite(), suite);
|
||||
assert_eq!(
|
||||
openshell_crypto::default_context()
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# Negative test: an unsatisfiable property query, not a FIPS configuration.
|
||||
openssl_conf = openshell_init
|
||||
|
||||
[openshell_init]
|
||||
alg_section = algorithm_properties
|
||||
|
||||
[algorithm_properties]
|
||||
default_properties = provider=openshell_nonexistent_provider
|
||||
@@ -0,0 +1,223 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(all(feature = "aws-lc", feature = "openssl"))]
|
||||
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, crypto::CryptoProvider};
|
||||
use openshell_crypto::{CryptoContext, OpenSsl, ProtocolBackend};
|
||||
use openssl::{hash::MessageDigest, pkey::PKey, rsa::Padding, sign::RsaPssSaltlen};
|
||||
|
||||
const MESSAGE: &[u8] = b"header.claims";
|
||||
const RSA_ALGORITHMS: [Algorithm; 6] = [
|
||||
Algorithm::RS256,
|
||||
Algorithm::RS384,
|
||||
Algorithm::RS512,
|
||||
Algorithm::PS256,
|
||||
Algorithm::PS384,
|
||||
Algorithm::PS512,
|
||||
];
|
||||
|
||||
fn providers() -> [(&'static str, &'static CryptoProvider); 2] {
|
||||
[
|
||||
("aws-lc", CryptoContext::default().backend().jwt_provider()),
|
||||
("openssl", OpenSsl.jwt_provider()),
|
||||
]
|
||||
}
|
||||
|
||||
// AWS-LC defers some checks until the operation; OpenSSL checks at construction.
|
||||
// Assert rejection by each provider, not just equality (both accepting is a bug).
|
||||
fn reject_signing(case: &str, algorithm: Algorithm, key: &EncodingKey) {
|
||||
for (name, provider) in providers() {
|
||||
let accepted = (provider.signer_factory)(&algorithm, key)
|
||||
.is_ok_and(|signer| signer.try_sign(MESSAGE).is_ok());
|
||||
assert!(!accepted, "{name} accepted {case} for {algorithm:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_verification(case: &str, algorithm: Algorithm, key: &DecodingKey, signature: &Vec<u8>) {
|
||||
for (name, provider) in providers() {
|
||||
let accepted = (provider.verifier_factory)(&algorithm, key)
|
||||
.is_ok_and(|verifier| verifier.verify(MESSAGE, signature).is_ok());
|
||||
assert!(!accepted, "{name} accepted {case} for {algorithm:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_keys_are_rejected_by_both_backends() {
|
||||
for der in [&[][..], &[0x30, 0x03, 0x02, 0x01][..]] {
|
||||
for algorithm in RSA_ALGORITHMS {
|
||||
reject_signing(
|
||||
"malformed RSA DER",
|
||||
algorithm,
|
||||
&EncodingKey::from_rsa_der(der),
|
||||
);
|
||||
reject_verification(
|
||||
"malformed RSA DER",
|
||||
algorithm,
|
||||
&DecodingKey::from_rsa_der(der),
|
||||
&vec![0; 256],
|
||||
);
|
||||
}
|
||||
for algorithm in [Algorithm::ES256, Algorithm::ES384] {
|
||||
reject_signing(
|
||||
"malformed EC DER",
|
||||
algorithm,
|
||||
&EncodingKey::from_ec_der(der),
|
||||
);
|
||||
reject_verification(
|
||||
"malformed EC public key",
|
||||
algorithm,
|
||||
&DecodingKey::from_ec_der(der),
|
||||
&vec![0; 64],
|
||||
);
|
||||
}
|
||||
reject_signing(
|
||||
"malformed Ed25519 DER",
|
||||
Algorithm::EdDSA,
|
||||
&EncodingKey::from_ed_der(der),
|
||||
);
|
||||
reject_verification(
|
||||
"malformed Ed25519 public key",
|
||||
Algorithm::EdDSA,
|
||||
&DecodingKey::from_ed_der(der),
|
||||
&vec![0; 64],
|
||||
);
|
||||
}
|
||||
|
||||
let rsa = openssl::rsa::Rsa::generate(1024).unwrap();
|
||||
let encoding = EncodingKey::from_rsa_der(&rsa.private_key_to_der().unwrap());
|
||||
let decoding = DecodingKey::from_rsa_der(&rsa.public_key_to_der_pkcs1().unwrap());
|
||||
// Use a real signature so failure cannot be attributed only to garbage bytes.
|
||||
let native = PKey::from_rsa(rsa).unwrap();
|
||||
for (algorithm, digest) in [
|
||||
(Algorithm::RS256, MessageDigest::sha256()),
|
||||
(Algorithm::RS384, MessageDigest::sha384()),
|
||||
(Algorithm::RS512, MessageDigest::sha512()),
|
||||
] {
|
||||
let mut signer = openssl::sign::Signer::new(digest, &native).unwrap();
|
||||
let signature = signer.sign_oneshot_to_vec(MESSAGE).unwrap();
|
||||
reject_verification("undersized RSA key", algorithm, &decoding, &signature);
|
||||
}
|
||||
for algorithm in RSA_ALGORITHMS {
|
||||
reject_signing("undersized RSA key", algorithm, &encoding);
|
||||
}
|
||||
|
||||
for (curve, algorithm, wrong_algorithm) in [
|
||||
(
|
||||
&rcgen::PKCS_ECDSA_P256_SHA256,
|
||||
Algorithm::ES256,
|
||||
Algorithm::ES384,
|
||||
),
|
||||
(
|
||||
&rcgen::PKCS_ECDSA_P384_SHA384,
|
||||
Algorithm::ES384,
|
||||
Algorithm::ES256,
|
||||
),
|
||||
] {
|
||||
let key = OpenSsl.generate_keypair(curve).unwrap();
|
||||
let encoding = EncodingKey::from_ec_pem(key.serialize_pem().unwrap().as_bytes()).unwrap();
|
||||
reject_signing("mismatched EC curve", wrong_algorithm, &encoding);
|
||||
let decoding = DecodingKey::from_ec_pem(key.public_key_pem().as_bytes()).unwrap();
|
||||
let signer = (OpenSsl.jwt_provider().signer_factory)(&algorithm, &encoding).unwrap();
|
||||
let signature = signer.try_sign(MESSAGE).unwrap();
|
||||
reject_verification(
|
||||
"mismatched EC curve",
|
||||
wrong_algorithm,
|
||||
&decoding,
|
||||
&signature,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_signatures_are_rejected_by_both_backends() {
|
||||
for (key_algorithm, algorithms) in [
|
||||
(&rcgen::PKCS_RSA_SHA256, &RSA_ALGORITHMS[..]),
|
||||
(&rcgen::PKCS_ECDSA_P256_SHA256, &[Algorithm::ES256][..]),
|
||||
(&rcgen::PKCS_ECDSA_P384_SHA384, &[Algorithm::ES384][..]),
|
||||
(&rcgen::PKCS_ED25519, &[Algorithm::EdDSA][..]),
|
||||
] {
|
||||
let key = OpenSsl.generate_keypair(key_algorithm).unwrap();
|
||||
let private = key.serialize_pem().unwrap();
|
||||
let public = key.public_key_pem();
|
||||
let (encoding, decoding) = match algorithms[0] {
|
||||
Algorithm::RS256 => (
|
||||
EncodingKey::from_rsa_pem(private.as_bytes()).unwrap(),
|
||||
DecodingKey::from_rsa_pem(public.as_bytes()).unwrap(),
|
||||
),
|
||||
Algorithm::EdDSA => (
|
||||
EncodingKey::from_ed_pem(private.as_bytes()).unwrap(),
|
||||
DecodingKey::from_ed_pem(public.as_bytes()).unwrap(),
|
||||
),
|
||||
_ => (
|
||||
EncodingKey::from_ec_pem(private.as_bytes()).unwrap(),
|
||||
DecodingKey::from_ec_pem(public.as_bytes()).unwrap(),
|
||||
),
|
||||
};
|
||||
for &algorithm in algorithms {
|
||||
check_signatures(algorithm, &encoding, &decoding);
|
||||
}
|
||||
}
|
||||
for algorithm in [Algorithm::HS256, Algorithm::HS384, Algorithm::HS512] {
|
||||
check_signatures(
|
||||
algorithm,
|
||||
&EncodingKey::from_secret(b"parity-test-key"),
|
||||
&DecodingKey::from_secret(b"parity-test-key"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn check_signatures(algorithm: Algorithm, encoding: &EncodingKey, decoding: &DecodingKey) {
|
||||
for (producer, provider) in providers() {
|
||||
let signer = (provider.signer_factory)(&algorithm, encoding).unwrap();
|
||||
let signature = signer.try_sign(MESSAGE).unwrap();
|
||||
// Positive controls ensure the fixtures work with both providers.
|
||||
for (consumer, provider) in providers() {
|
||||
let verifier = (provider.verifier_factory)(&algorithm, decoding).unwrap();
|
||||
assert!(
|
||||
verifier.verify(MESSAGE, &signature).is_ok(),
|
||||
"{producer} -> {consumer}: {algorithm:?}"
|
||||
);
|
||||
assert!(verifier.verify(b"changed.claims", &signature).is_err());
|
||||
}
|
||||
let mut extended = signature.clone();
|
||||
extended.push(0);
|
||||
let mut tampered = signature.clone();
|
||||
tampered[0] ^= 1;
|
||||
for (case, invalid) in [
|
||||
("empty signature", vec![]),
|
||||
(
|
||||
"truncated signature",
|
||||
signature[..signature.len() - 1].to_vec(),
|
||||
),
|
||||
("extended signature", extended),
|
||||
("tampered signature", tampered),
|
||||
] {
|
||||
reject_verification(case, algorithm, decoding, &invalid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_jose_pss_salt_is_rejected_by_both_backends() {
|
||||
let key = OpenSsl.generate_keypair(&rcgen::PKCS_RSA_SHA256).unwrap();
|
||||
let native = PKey::private_key_from_pkcs8(&key.serialize_der().unwrap()).unwrap();
|
||||
let decoding = DecodingKey::from_rsa_pem(key.public_key_pem().as_bytes()).unwrap();
|
||||
for (algorithm, digest) in [
|
||||
(Algorithm::PS256, MessageDigest::sha256()),
|
||||
(Algorithm::PS384, MessageDigest::sha384()),
|
||||
(Algorithm::PS512, MessageDigest::sha512()),
|
||||
] {
|
||||
let digest_len = i32::try_from(digest.size()).unwrap();
|
||||
for salt_len in [0, digest_len - 1, digest_len + 1] {
|
||||
let mut signer = openssl::sign::Signer::new(digest, &native).unwrap();
|
||||
signer.set_rsa_padding(Padding::PKCS1_PSS).unwrap();
|
||||
signer.set_rsa_mgf1_md(digest).unwrap();
|
||||
signer
|
||||
.set_rsa_pss_saltlen(RsaPssSaltlen::custom(salt_len))
|
||||
.unwrap();
|
||||
let signature = signer.sign_oneshot_to_vec(MESSAGE).unwrap();
|
||||
reject_verification("non-JOSE PSS salt length", algorithm, &decoding, &signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(feature = "openssl")]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{handshake, init_openssl};
|
||||
#[cfg(feature = "aws-lc")]
|
||||
use openshell_crypto::CryptoContext;
|
||||
use openshell_crypto::OpenSsl;
|
||||
use openshell_crypto::{CryptoBackend, CryptoError, ProtocolBackend, aead, jwt, pki, tls};
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[test]
|
||||
fn primitives_and_posture() {
|
||||
init_openssl();
|
||||
assert_eq!(
|
||||
openshell_crypto::sha256(b"abc").unwrap(),
|
||||
[
|
||||
0xba, 0x78, 0x16, 0xbf, 0x8f, 0x01, 0xcf, 0xea, 0x41, 0x41, 0x40, 0xde, 0x5d, 0xae,
|
||||
0x22, 0x23, 0xb0, 0x03, 0x61, 0xa3, 0x96, 0x17, 0x7a, 0x9c, 0xb4, 0x10, 0xff, 0x61,
|
||||
0xf2, 0x00, 0x15, 0xad
|
||||
]
|
||||
);
|
||||
let mut digest = openshell_crypto::sha256_digest().unwrap();
|
||||
digest.update(b"a").unwrap();
|
||||
digest.update(b"bc").unwrap();
|
||||
assert_eq!(
|
||||
digest.finish().unwrap(),
|
||||
openshell_crypto::sha256(b"abc").unwrap()
|
||||
);
|
||||
assert_ne!(
|
||||
openshell_crypto::random_bytes::<32>().unwrap(),
|
||||
openshell_crypto::random_bytes::<32>().unwrap()
|
||||
);
|
||||
let context = openshell_crypto::default_context();
|
||||
let caps = context.verify_posture(false).unwrap();
|
||||
assert_eq!(caps.backend, "openssl");
|
||||
assert_eq!(caps.provider_version, None);
|
||||
assert_eq!(caps.fips, openshell_crypto::FipsState::NotVerified);
|
||||
assert_eq!(
|
||||
context.verify_posture(true),
|
||||
Err(CryptoError::UnsupportedPosture)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aead_compatibility_and_rejection() {
|
||||
init_openssl();
|
||||
// NIST AES-256-GCM empty-message vector: ciphertext consists only of the tag.
|
||||
let tag = [
|
||||
0x53, 0x0f, 0x8a, 0xfb, 0xc7, 0x45, 0x36, 0xb9, 0xa9, 0x63, 0xb4, 0xf1, 0xc4, 0xcb, 0x73,
|
||||
0x8b,
|
||||
];
|
||||
assert_eq!(aead::open(&[0; 32], b"", &[0; 12], &tag).unwrap(), b"");
|
||||
for plain in [b"".as_slice(), b"credential-value"] {
|
||||
let sealed = aead::seal(&[7; 32], b"record-id", plain).unwrap();
|
||||
assert_eq!(sealed.ciphertext.len(), plain.len() + 16);
|
||||
assert_eq!(
|
||||
aead::open(&[7; 32], b"record-id", &sealed.nonce, &sealed.ciphertext).unwrap(),
|
||||
plain
|
||||
);
|
||||
assert_eq!(
|
||||
aead::open(&[8; 32], b"record-id", &sealed.nonce, &sealed.ciphertext),
|
||||
Err(CryptoError::Authentication)
|
||||
);
|
||||
assert_eq!(
|
||||
aead::open(&[7; 32], b"wrong-id", &sealed.nonce, &sealed.ciphertext),
|
||||
Err(CryptoError::Authentication)
|
||||
);
|
||||
let mut damaged = sealed.ciphertext.clone();
|
||||
damaged[0] ^= 1;
|
||||
assert_eq!(
|
||||
aead::open(&[7; 32], b"record-id", &sealed.nonce, &damaged),
|
||||
Err(CryptoError::Authentication)
|
||||
);
|
||||
let mut nonce = sealed.nonce;
|
||||
nonce[0] ^= 1;
|
||||
assert_eq!(
|
||||
aead::open(&[7; 32], b"record-id", &nonce, &sealed.ciphertext),
|
||||
Err(CryptoError::Authentication)
|
||||
);
|
||||
let mut damaged_tag = sealed.ciphertext.clone();
|
||||
*damaged_tag.last_mut().unwrap() ^= 1;
|
||||
assert_eq!(
|
||||
aead::open(&[7; 32], b"record-id", &sealed.nonce, &damaged_tag),
|
||||
Err(CryptoError::Authentication)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
aead::open(&[0; 32], b"", &[0; 12], &[0; 15]),
|
||||
Err(CryptoError::Authentication)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aead_handles_multiple_update_chunks() {
|
||||
let plaintext = vec![0xab; 2 * 1024 * 1024 + 17];
|
||||
let aad = vec![0xcd; 1024 * 1024 + 1];
|
||||
let sealed = OpenSsl.seal(&[7; 32], &aad, &plaintext).unwrap();
|
||||
assert_eq!(
|
||||
OpenSsl
|
||||
.open(&[7; 32], &aad, &sealed.nonce, &sealed.ciphertext)
|
||||
.unwrap(),
|
||||
plaintext
|
||||
);
|
||||
#[cfg(feature = "aws-lc")]
|
||||
assert_eq!(
|
||||
CryptoContext::default()
|
||||
.backend()
|
||||
.open(&[7; 32], &aad, &sealed.nonce, &sealed.ciphertext)
|
||||
.unwrap(),
|
||||
plaintext
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_rejects_mismatched_curve_and_pss_salt() {
|
||||
init_openssl();
|
||||
let key = OpenSsl
|
||||
.generate_keypair(&rcgen::PKCS_ECDSA_P256_SHA256)
|
||||
.unwrap();
|
||||
let encoding =
|
||||
jsonwebtoken::EncodingKey::from_ec_pem(key.serialize_pem().unwrap().as_bytes()).unwrap();
|
||||
assert!(
|
||||
jwt::encode(
|
||||
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::ES384),
|
||||
&serde_json::json!({"exp":4_000_000_000_u64}),
|
||||
&encoding
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let key = OpenSsl.generate_keypair(&rcgen::PKCS_RSA_SHA256).unwrap();
|
||||
let native =
|
||||
openssl::pkey::PKey::private_key_from_pkcs8(&key.serialize_der().unwrap()).unwrap();
|
||||
let decoding =
|
||||
jsonwebtoken::DecodingKey::from_rsa_pem(key.public_key_pem().as_bytes()).unwrap();
|
||||
let message = b"header.claims";
|
||||
for (algorithm, digest) in [
|
||||
(
|
||||
jsonwebtoken::Algorithm::PS256,
|
||||
openssl::hash::MessageDigest::sha256(),
|
||||
),
|
||||
(
|
||||
jsonwebtoken::Algorithm::PS384,
|
||||
openssl::hash::MessageDigest::sha384(),
|
||||
),
|
||||
(
|
||||
jsonwebtoken::Algorithm::PS512,
|
||||
openssl::hash::MessageDigest::sha512(),
|
||||
),
|
||||
] {
|
||||
let mut signer = openssl::sign::Signer::new(digest, &native).unwrap();
|
||||
signer
|
||||
.set_rsa_padding(openssl::rsa::Padding::PKCS1_PSS)
|
||||
.unwrap();
|
||||
signer
|
||||
.set_rsa_pss_saltlen(openssl::sign::RsaPssSaltlen::custom(0))
|
||||
.unwrap();
|
||||
let signature = signer.sign_oneshot_to_vec(message).unwrap();
|
||||
let verifier = (OpenSsl.jwt_provider().verifier_factory)(&algorithm, &decoding).unwrap();
|
||||
assert!(verifier.verify(message, &signature).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_rejects_rsa_keys_below_the_existing_minimum() {
|
||||
let rsa = openssl::rsa::Rsa::generate(1024).unwrap();
|
||||
let encoding = jsonwebtoken::EncodingKey::from_rsa_der(&rsa.private_key_to_der().unwrap());
|
||||
let decoding = jsonwebtoken::DecodingKey::from_rsa_der(&rsa.public_key_to_der_pkcs1().unwrap());
|
||||
for algorithm in [
|
||||
jsonwebtoken::Algorithm::RS256,
|
||||
jsonwebtoken::Algorithm::RS384,
|
||||
jsonwebtoken::Algorithm::RS512,
|
||||
jsonwebtoken::Algorithm::PS256,
|
||||
jsonwebtoken::Algorithm::PS384,
|
||||
jsonwebtoken::Algorithm::PS512,
|
||||
] {
|
||||
assert!((OpenSsl.jwt_provider().signer_factory)(&algorithm, &encoding).is_err());
|
||||
assert!((OpenSsl.jwt_provider().verifier_factory)(&algorithm, &decoding).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwk_coordinates_preserve_leading_zeros() {
|
||||
use openssl::{
|
||||
bn::{BigNum, BigNumContext},
|
||||
ec::{EcGroup, EcKey, EcPoint, PointConversionForm},
|
||||
nid::Nid,
|
||||
pkey::PKey,
|
||||
};
|
||||
let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap();
|
||||
let mut context = BigNumContext::new().unwrap();
|
||||
// Deterministic test keys. Find a coordinate with a leading zero to exercise
|
||||
// fixed-width JWK encoding; normal random-key tests rarely cover this case.
|
||||
for scalar in 1..=1024 {
|
||||
let private = BigNum::from_u32(scalar).unwrap();
|
||||
let mut public = EcPoint::new(&group).unwrap();
|
||||
public
|
||||
.mul_generator2(&group, &private, &mut context)
|
||||
.unwrap();
|
||||
let raw = public
|
||||
.to_bytes(&group, PointConversionForm::UNCOMPRESSED, &mut context)
|
||||
.unwrap();
|
||||
if raw[1] != 0 && raw[33] != 0 {
|
||||
continue;
|
||||
}
|
||||
let key =
|
||||
PKey::from_ec_key(EcKey::from_private_components(&group, &private, &public).unwrap())
|
||||
.unwrap();
|
||||
let der = key.private_key_to_der().unwrap();
|
||||
let (curve, x, y) = (OpenSsl
|
||||
.jwt_provider()
|
||||
.jwk_utils
|
||||
.extract_ec_public_key_coordinates)(
|
||||
&der, jsonwebtoken::Algorithm::ES256
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(curve, jsonwebtoken::jwk::EllipticCurve::P256);
|
||||
assert_eq!(x, raw[1..33]);
|
||||
assert_eq!(y, raw[33..]);
|
||||
assert!(
|
||||
(OpenSsl
|
||||
.jwt_provider()
|
||||
.jwk_utils
|
||||
.extract_ec_public_key_coordinates)(
|
||||
&der, jsonwebtoken::Algorithm::ES384
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
return;
|
||||
}
|
||||
panic!("deterministic fixture range must include a leading-zero coordinate");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_owned_keys_and_certificates() {
|
||||
init_openssl();
|
||||
for alg in [
|
||||
&rcgen::PKCS_ECDSA_P256_SHA256,
|
||||
&rcgen::PKCS_ECDSA_P384_SHA384,
|
||||
&rcgen::PKCS_ED25519,
|
||||
&rcgen::PKCS_RSA_SHA256,
|
||||
&rcgen::PKCS_RSA_SHA384,
|
||||
&rcgen::PKCS_RSA_SHA512,
|
||||
] {
|
||||
let key = pki::generate_keypair_for(alg).unwrap();
|
||||
let der = key.serialize_der().unwrap();
|
||||
let from_der = pki::KeyPair::from_pkcs8_der(&der).unwrap();
|
||||
let from_pem = pki::KeyPair::from_pem(&key.serialize_pem().unwrap()).unwrap();
|
||||
for imported in [&key, &from_der, &from_pem] {
|
||||
assert_eq!(key.public_key_der(), imported.public_key_der());
|
||||
let cert = pki::self_signed(
|
||||
rcgen::CertificateParams::new(vec!["localhost".into()]).unwrap(),
|
||||
imported,
|
||||
)
|
||||
.unwrap();
|
||||
let x509 = openssl::x509::X509::from_der(cert.der()).unwrap();
|
||||
assert!(x509.verify(&x509.public_key().unwrap()).unwrap());
|
||||
}
|
||||
}
|
||||
assert!(pki::KeyPair::from_pkcs8_der(b"invalid key").is_err());
|
||||
assert!(pki::KeyPair::from_pem("invalid key").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_algorithms_and_claims_validation() {
|
||||
use jsonwebtoken::{
|
||||
Algorithm, DecodingKey, EncodingKey, Header, Validation, errors::ErrorKind,
|
||||
};
|
||||
init_openssl();
|
||||
let claims = serde_json::json!({"sub":"sandbox", "iss":"gateway", "aud":"sandbox", "exp":4_000_000_000_u64});
|
||||
let mut keys = vec![(
|
||||
Algorithm::HS256,
|
||||
EncodingKey::from_secret(b"backend-test-secret"),
|
||||
DecodingKey::from_secret(b"backend-test-secret"),
|
||||
)];
|
||||
for (alg, key_alg) in [
|
||||
(Algorithm::EdDSA, &rcgen::PKCS_ED25519),
|
||||
(Algorithm::ES256, &rcgen::PKCS_ECDSA_P256_SHA256),
|
||||
(Algorithm::RS256, &rcgen::PKCS_RSA_SHA256),
|
||||
] {
|
||||
let key = pki::generate_keypair_for(key_alg).unwrap();
|
||||
let private = key.serialize_pem().unwrap();
|
||||
let public = key.public_key_pem();
|
||||
let pair = match alg {
|
||||
Algorithm::EdDSA => (
|
||||
EncodingKey::from_ed_pem(private.as_bytes()).unwrap(),
|
||||
DecodingKey::from_ed_pem(public.as_bytes()).unwrap(),
|
||||
),
|
||||
Algorithm::ES256 => (
|
||||
EncodingKey::from_ec_pem(private.as_bytes()).unwrap(),
|
||||
DecodingKey::from_ec_pem(public.as_bytes()).unwrap(),
|
||||
),
|
||||
_ => (
|
||||
EncodingKey::from_rsa_pem(private.as_bytes()).unwrap(),
|
||||
DecodingKey::from_rsa_pem(public.as_bytes()).unwrap(),
|
||||
),
|
||||
};
|
||||
keys.push((alg, pair.0, pair.1));
|
||||
}
|
||||
for (alg, private, public) in keys {
|
||||
let token = jwt::encode(&Header::new(alg), &claims, &private).unwrap();
|
||||
let mut validation = Validation::new(alg);
|
||||
validation.set_issuer(&["gateway"]);
|
||||
validation.set_audience(&["sandbox"]);
|
||||
assert_eq!(
|
||||
jwt::decode::<serde_json::Value>(&token, &public, &validation)
|
||||
.unwrap()
|
||||
.claims,
|
||||
claims
|
||||
);
|
||||
let mut tampered = token.clone().into_bytes();
|
||||
let signature_start = tampered.iter().rposition(|b| *b == b'.').unwrap() + 1;
|
||||
tampered[signature_start] = if tampered[signature_start] == b'A' {
|
||||
b'B'
|
||||
} else {
|
||||
b'A'
|
||||
};
|
||||
assert_eq!(
|
||||
jwt::decode::<serde_json::Value>(&tampered, &public, &validation)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
&ErrorKind::InvalidSignature
|
||||
);
|
||||
validation.set_issuer(&["wrong"]);
|
||||
assert_eq!(
|
||||
jwt::decode::<serde_json::Value>(&token, &public, &validation)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
&ErrorKind::InvalidIssuer
|
||||
);
|
||||
validation.set_issuer(&["gateway"]);
|
||||
validation.set_audience(&["wrong"]);
|
||||
assert_eq!(
|
||||
jwt::decode::<serde_json::Value>(&token, &public, &validation)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
&ErrorKind::InvalidAudience
|
||||
);
|
||||
validation.set_audience(&["sandbox"]);
|
||||
let expired =
|
||||
serde_json::json!({"sub":"sandbox", "iss":"gateway", "aud":"sandbox", "exp":1});
|
||||
let token = jwt::encode(&Header::new(alg), &expired, &private).unwrap();
|
||||
assert_eq!(
|
||||
jwt::decode::<serde_json::Value>(&token, &public, &validation)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
&ErrorKind::ExpiredSignature
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls13_handshake_and_application_data() {
|
||||
init_openssl();
|
||||
let pki::CertifiedKey { cert, key_pair } =
|
||||
pki::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add(cert.der().clone()).unwrap();
|
||||
let client = tls::client_builder_with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
let server = tls::server_builder_with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(
|
||||
vec![cert.der().clone()],
|
||||
rustls::pki_types::PrivatePkcs8KeyDer::from(key_pair.serialize_der().unwrap()).into(),
|
||||
)
|
||||
.unwrap();
|
||||
let (mut client, mut server) = handshake(client, server, "localhost").unwrap();
|
||||
assert_eq!(
|
||||
client.protocol_version(),
|
||||
Some(rustls::ProtocolVersion::TLSv1_3)
|
||||
);
|
||||
assert_eq!(
|
||||
client.negotiated_cipher_suite().unwrap().suite(),
|
||||
rustls::CipherSuite::TLS13_AES_256_GCM_SHA384
|
||||
);
|
||||
client.writer().write_all(b"openssl transport").unwrap();
|
||||
let mut wire = Vec::new();
|
||||
client.write_tls(&mut wire).unwrap();
|
||||
server.read_tls(&mut wire.as_slice()).unwrap();
|
||||
server.process_new_packets().unwrap();
|
||||
let mut received = [0; 17];
|
||||
server.reader().read_exact(&mut received).unwrap();
|
||||
assert_eq!(&received, b"openssl transport");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_ca_signs_with_openssl() {
|
||||
init_openssl();
|
||||
let key = pki::generate_keypair().unwrap();
|
||||
let mut params = rcgen::CertificateParams::default();
|
||||
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
|
||||
let ca = pki::self_signed(params, &key).unwrap();
|
||||
let pem = key.serialize_pem().unwrap();
|
||||
let issuer = pki::issuer_from_der(ca.der(), pki::KeyPair::from_pem(&pem).unwrap()).unwrap();
|
||||
let leaf_key = pki::generate_keypair().unwrap();
|
||||
let leaf = pki::signed_by_issuer(
|
||||
rcgen::CertificateParams::new(vec!["localhost".into()]).unwrap(),
|
||||
&leaf_key,
|
||||
&issuer,
|
||||
)
|
||||
.unwrap();
|
||||
let ca = openssl::x509::X509::from_der(ca.der()).unwrap();
|
||||
let leaf = openssl::x509::X509::from_der(leaf.der()).unwrap();
|
||||
assert!(leaf.verify(&ca.public_key().unwrap()).unwrap());
|
||||
assert_eq!(
|
||||
leaf.issuer_name().to_der().unwrap(),
|
||||
ca.subject_name().to_der().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_hmac_signatures_are_rejected() {
|
||||
init_openssl();
|
||||
for algorithm in [
|
||||
jsonwebtoken::Algorithm::HS256,
|
||||
jsonwebtoken::Algorithm::HS384,
|
||||
jsonwebtoken::Algorithm::HS512,
|
||||
] {
|
||||
let token = jwt::encode(
|
||||
&jsonwebtoken::Header::new(algorithm),
|
||||
&serde_json::json!({"exp":4_000_000_000_u64}),
|
||||
&jsonwebtoken::EncodingKey::from_secret(b"test-key"),
|
||||
)
|
||||
.unwrap();
|
||||
let (message, _) = token.rsplit_once('.').unwrap();
|
||||
for signature in ["", "AA", &"A".repeat(100)] {
|
||||
assert_eq!(
|
||||
jwt::decode::<serde_json::Value>(
|
||||
format!("{message}.{signature}"),
|
||||
&jsonwebtoken::DecodingKey::from_secret(b"test-key"),
|
||||
&jsonwebtoken::Validation::new(algorithm),
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
&jsonwebtoken::errors::ErrorKind::InvalidSignature
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(feature = "aws-lc")]
|
||||
fn backends_preserve_ciphertext_keys_and_jwt_signatures() {
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey};
|
||||
init_openssl();
|
||||
let aws = CryptoContext::default();
|
||||
assert_eq!(aws.backend().capabilities().backend, "aws-lc");
|
||||
let openssl: &dyn ProtocolBackend = &OpenSsl;
|
||||
for (producer, consumer) in [(aws.backend(), openssl), (openssl, aws.backend())] {
|
||||
let sealed = producer
|
||||
.seal(&[42; 32], b"record-id", b"persisted credential")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
consumer
|
||||
.open(&[42; 32], b"record-id", &sealed.nonce, &sealed.ciphertext)
|
||||
.unwrap(),
|
||||
b"persisted credential"
|
||||
);
|
||||
for (key_algorithm, algorithms) in [
|
||||
(&rcgen::PKCS_ED25519, &[Algorithm::EdDSA][..]),
|
||||
(&rcgen::PKCS_ECDSA_P256_SHA256, &[Algorithm::ES256][..]),
|
||||
(&rcgen::PKCS_ECDSA_P384_SHA384, &[Algorithm::ES384][..]),
|
||||
] {
|
||||
let key = producer.generate_keypair(key_algorithm).unwrap();
|
||||
let imported = consumer
|
||||
.import_keypair_der(&key.serialize_der().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(imported.public_key_der(), key.public_key_der());
|
||||
let private = key.serialize_pem().unwrap();
|
||||
let public = key.public_key_pem();
|
||||
let (encoding, decoding) = if algorithms[0] == Algorithm::EdDSA {
|
||||
(
|
||||
EncodingKey::from_ed_pem(private.as_bytes()).unwrap(),
|
||||
DecodingKey::from_ed_pem(public.as_bytes()).unwrap(),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
EncodingKey::from_ec_pem(private.as_bytes()).unwrap(),
|
||||
DecodingKey::from_ec_pem(public.as_bytes()).unwrap(),
|
||||
)
|
||||
};
|
||||
for algorithm in algorithms {
|
||||
check_signature(producer, consumer, *algorithm, &encoding, &decoding);
|
||||
}
|
||||
}
|
||||
let key = OpenSsl.generate_keypair(&rcgen::PKCS_RSA_SHA256).unwrap();
|
||||
let imported = consumer
|
||||
.import_keypair_der(&key.serialize_der().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(imported.public_key_der(), key.public_key_der());
|
||||
let encoding = EncodingKey::from_rsa_pem(key.serialize_pem().unwrap().as_bytes()).unwrap();
|
||||
let decoding = DecodingKey::from_rsa_pem(key.public_key_pem().as_bytes()).unwrap();
|
||||
for algorithm in [
|
||||
Algorithm::RS256,
|
||||
Algorithm::RS384,
|
||||
Algorithm::RS512,
|
||||
Algorithm::PS256,
|
||||
Algorithm::PS384,
|
||||
Algorithm::PS512,
|
||||
] {
|
||||
check_signature(producer, consumer, algorithm, &encoding, &decoding);
|
||||
}
|
||||
for algorithm in [Algorithm::HS256, Algorithm::HS384, Algorithm::HS512] {
|
||||
check_signature(
|
||||
producer,
|
||||
consumer,
|
||||
algorithm,
|
||||
&EncodingKey::from_secret(b"compatibility key"),
|
||||
&DecodingKey::from_secret(b"compatibility key"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "aws-lc")]
|
||||
fn check_signature(
|
||||
producer: &dyn ProtocolBackend,
|
||||
consumer: &dyn ProtocolBackend,
|
||||
algorithm: jsonwebtoken::Algorithm,
|
||||
encoding: &jsonwebtoken::EncodingKey,
|
||||
decoding: &jsonwebtoken::DecodingKey,
|
||||
) {
|
||||
let signer = (producer.jwt_provider().signer_factory)(&algorithm, encoding).unwrap();
|
||||
let verifier = (consumer.jwt_provider().verifier_factory)(&algorithm, decoding).unwrap();
|
||||
let signature = signer.try_sign(b"header.claims").unwrap();
|
||||
verifier
|
||||
.verify(b"header.claims", &signature)
|
||||
.unwrap_or_else(|error| {
|
||||
panic!(
|
||||
"{} -> {} {algorithm:?}: {error}",
|
||||
producer.capabilities().backend,
|
||||
consumer.capabilities().backend
|
||||
)
|
||||
});
|
||||
assert!(verifier.verify(b"wrong.claims", &signature).is_err());
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(feature = "openssl")]
|
||||
|
||||
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey};
|
||||
use openshell_crypto::{OpenSsl, ProtocolBackend};
|
||||
use openssl::{
|
||||
bn::{BigNum, BigNumContext},
|
||||
ec::{EcGroup, EcKey},
|
||||
nid::Nid,
|
||||
pkey::PKey,
|
||||
rsa::Rsa,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn jwt_rejects_invalid_rsa_public_components() {
|
||||
let provider = OpenSsl.jwt_provider();
|
||||
let valid = Rsa::generate(2048).unwrap();
|
||||
let mut oversized_exponent = BigNum::new().unwrap();
|
||||
oversized_exponent.set_bit(33).unwrap();
|
||||
oversized_exponent.add_word(1).unwrap();
|
||||
for exponent in [
|
||||
BigNum::from_u32(0).unwrap(),
|
||||
BigNum::from_u32(1).unwrap(),
|
||||
BigNum::from_u32(2).unwrap(),
|
||||
BigNum::from_u32(65536).unwrap(),
|
||||
oversized_exponent,
|
||||
] {
|
||||
// Both JWK components and DER reach the adapter; parsing alone accepts
|
||||
// these values and must not be mistaken for RSA public-key validation.
|
||||
let key = Rsa::from_public_components(valid.n().to_owned().unwrap(), exponent).unwrap();
|
||||
let der = key.public_key_to_der_pkcs1().unwrap();
|
||||
let keys = [
|
||||
DecodingKey::from_rsa_raw_components(&key.n().to_vec(), &key.e().to_vec()),
|
||||
DecodingKey::from_rsa_der(&der),
|
||||
];
|
||||
for algorithm in [
|
||||
Algorithm::RS256,
|
||||
Algorithm::RS384,
|
||||
Algorithm::RS512,
|
||||
Algorithm::PS256,
|
||||
Algorithm::PS384,
|
||||
Algorithm::PS512,
|
||||
] {
|
||||
for key in &keys {
|
||||
assert!(
|
||||
(provider.verifier_factory)(&algorithm, key).is_err(),
|
||||
"invalid RSA exponent accepted for {algorithm:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut even_modulus = valid.n().to_owned().unwrap();
|
||||
even_modulus.sub_word(1).unwrap();
|
||||
let invalid = DecodingKey::from_rsa_raw_components(&even_modulus.to_vec(), &valid.e().to_vec());
|
||||
for algorithm in [Algorithm::RS256, Algorithm::PS256] {
|
||||
assert!((provider.verifier_factory)(&algorithm, &invalid).is_err());
|
||||
}
|
||||
|
||||
// Preserve non-FIPS compatibility, including the historically valid e=3.
|
||||
for exponent in [3, 65537] {
|
||||
let key = DecodingKey::from_rsa_raw_components(
|
||||
&valid.n().to_vec(),
|
||||
&BigNum::from_u32(exponent).unwrap().to_vec(),
|
||||
);
|
||||
for algorithm in [Algorithm::RS256, Algorithm::PS256] {
|
||||
assert!((provider.verifier_factory)(&algorithm, &key).is_ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pki_and_jwt_reject_inconsistent_ec_private_keys() {
|
||||
let provider = OpenSsl.jwt_provider();
|
||||
for (curve, algorithm) in [
|
||||
(Nid::X9_62_PRIME256V1, Algorithm::ES256),
|
||||
(Nid::SECP384R1, Algorithm::ES384),
|
||||
] {
|
||||
let group = EcGroup::from_curve_name(curve).unwrap();
|
||||
let valid = EcKey::generate(&group).unwrap();
|
||||
let other = EcKey::generate(&group).unwrap();
|
||||
let mut order = BigNum::new().unwrap();
|
||||
group
|
||||
.order(&mut order, &mut BigNumContext::new().unwrap())
|
||||
.unwrap();
|
||||
let invalid_keys = [
|
||||
EcKey::from_private_components(&group, valid.private_key(), other.public_key())
|
||||
.unwrap(),
|
||||
EcKey::from_private_components(
|
||||
&group,
|
||||
&BigNum::from_u32(0).unwrap(),
|
||||
valid.public_key(),
|
||||
)
|
||||
.unwrap(),
|
||||
EcKey::from_private_components(&group, &order, valid.public_key()).unwrap(),
|
||||
];
|
||||
for ec in invalid_keys {
|
||||
assert!(ec.check_key().is_err());
|
||||
let sec1 = ec.private_key_to_der().unwrap();
|
||||
let key = PKey::from_ec_key(ec).unwrap();
|
||||
let pkcs8 = key.private_key_to_pkcs8().unwrap();
|
||||
let pem = String::from_utf8(key.private_key_to_pem_pkcs8().unwrap()).unwrap();
|
||||
// These are parseable encodings of invalid key material.
|
||||
assert!(PKey::private_key_from_pkcs8(&pkcs8).is_ok());
|
||||
assert!(OpenSsl.import_keypair_der(&pkcs8).is_err());
|
||||
assert!(OpenSsl.import_keypair_pem(&pem).is_err());
|
||||
assert!(
|
||||
(provider.signer_factory)(&algorithm, &EncodingKey::from_ec_der(&sec1)).is_err()
|
||||
);
|
||||
assert!(
|
||||
(provider.jwk_utils.extract_ec_public_key_coordinates)(&sec1, algorithm).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
let sec1 = valid.private_key_to_der().unwrap();
|
||||
let key = PKey::from_ec_key(valid).unwrap();
|
||||
assert!(
|
||||
OpenSsl
|
||||
.import_keypair_der(&key.private_key_to_pkcs8().unwrap())
|
||||
.is_ok()
|
||||
);
|
||||
assert!((provider.signer_factory)(&algorithm, &EncodingKey::from_ec_der(&sec1)).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pki_enforces_rsa_minimum_size_for_pem_and_pkcs8() {
|
||||
// Certificate construction uses the facade for its random serial number.
|
||||
openshell_crypto::install_default_context(openshell_crypto::CryptoContext::new(Box::new(
|
||||
OpenSsl,
|
||||
)))
|
||||
.unwrap();
|
||||
#[cfg(feature = "aws-lc")]
|
||||
let aws = openshell_crypto::CryptoContext::default();
|
||||
let backends: &[&dyn ProtocolBackend] = &[
|
||||
&OpenSsl,
|
||||
#[cfg(feature = "aws-lc")]
|
||||
aws.backend(),
|
||||
];
|
||||
for bits in [1024, 2048] {
|
||||
let rsa = Rsa::generate(bits).unwrap();
|
||||
let traditional_pem = String::from_utf8(rsa.private_key_to_pem().unwrap()).unwrap();
|
||||
let native = PKey::from_rsa(rsa).unwrap();
|
||||
let der = native.private_key_to_pkcs8().unwrap();
|
||||
let pem = String::from_utf8(native.private_key_to_pem_pkcs8().unwrap()).unwrap();
|
||||
for backend in backends {
|
||||
for (format, imported) in [
|
||||
("PKCS#8 DER", backend.import_keypair_der(&der)),
|
||||
("PKCS#8 PEM", backend.import_keypair_pem(&pem)),
|
||||
("PKCS#1 PEM", backend.import_keypair_pem(&traditional_pem)),
|
||||
] {
|
||||
if bits < 2048 {
|
||||
assert!(
|
||||
imported.is_err(),
|
||||
"{} accepted a {bits}-bit RSA key from {format}",
|
||||
backend.capabilities().backend
|
||||
);
|
||||
} else {
|
||||
let key = imported.unwrap();
|
||||
assert_eq!(key.public_key_der(), native.public_key_to_der().unwrap());
|
||||
let certificate = openshell_crypto::pki::self_signed(
|
||||
rcgen::CertificateParams::new(vec!["localhost".into()]).unwrap(),
|
||||
&key,
|
||||
)
|
||||
.unwrap();
|
||||
let x509 = openssl::x509::X509::from_der(certificate.der()).unwrap();
|
||||
assert!(x509.verify(&native).unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pki_rejects_oversized_rsa_imports() {
|
||||
// A parseable oversized modulus isolates the size gate without expensive
|
||||
// 8193-bit prime generation. This synthetic key is not a consistent RSA pair.
|
||||
let seed = Rsa::generate(2048).unwrap();
|
||||
let mut modulus = seed.n().to_owned().unwrap();
|
||||
modulus.set_bit(8192).unwrap();
|
||||
let oversized = Rsa::from_private_components(
|
||||
modulus,
|
||||
seed.e().to_owned().unwrap(),
|
||||
seed.d().to_owned().unwrap(),
|
||||
seed.p().unwrap().to_owned().unwrap(),
|
||||
seed.q().unwrap().to_owned().unwrap(),
|
||||
seed.dmp1().unwrap().to_owned().unwrap(),
|
||||
seed.dmq1().unwrap().to_owned().unwrap(),
|
||||
seed.iqmp().unwrap().to_owned().unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let traditional_pem = String::from_utf8(oversized.private_key_to_pem().unwrap()).unwrap();
|
||||
let native = PKey::from_rsa(oversized).unwrap();
|
||||
assert_eq!(native.bits(), 8193);
|
||||
let der = native.private_key_to_pkcs8().unwrap();
|
||||
let pem = String::from_utf8(native.private_key_to_pem_pkcs8().unwrap()).unwrap();
|
||||
assert!(PKey::private_key_from_pkcs8(&der).is_ok());
|
||||
assert!(PKey::private_key_from_pem(pem.as_bytes()).is_ok());
|
||||
assert!(OpenSsl.import_keypair_der(&der).is_err());
|
||||
assert!(OpenSsl.import_keypair_pem(&pem).is_err());
|
||||
assert!(OpenSsl.import_keypair_pem(&traditional_pem).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pki_and_jwt_reject_inconsistent_rsa_private_keys() {
|
||||
let valid = Rsa::generate(2048).unwrap();
|
||||
#[cfg(feature = "aws-lc")]
|
||||
let aws = openshell_crypto::CryptoContext::default();
|
||||
let backends: &[&dyn ProtocolBackend] = &[
|
||||
&OpenSsl,
|
||||
#[cfg(feature = "aws-lc")]
|
||||
aws.backend(),
|
||||
];
|
||||
for component in 0..4 {
|
||||
let mut private = [
|
||||
valid.d().to_owned().unwrap(),
|
||||
valid.dmp1().unwrap().to_owned().unwrap(),
|
||||
valid.dmq1().unwrap().to_owned().unwrap(),
|
||||
valid.iqmp().unwrap().to_owned().unwrap(),
|
||||
];
|
||||
private[component].add_word(2).unwrap();
|
||||
let [
|
||||
private_exponent,
|
||||
first_exponent,
|
||||
second_exponent,
|
||||
coefficient,
|
||||
] = private;
|
||||
let malformed = Rsa::from_private_components(
|
||||
valid.n().to_owned().unwrap(),
|
||||
valid.e().to_owned().unwrap(),
|
||||
private_exponent,
|
||||
valid.p().unwrap().to_owned().unwrap(),
|
||||
valid.q().unwrap().to_owned().unwrap(),
|
||||
first_exponent,
|
||||
second_exponent,
|
||||
coefficient,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!matches!(malformed.check_key(), Ok(true)));
|
||||
let pkcs1 = malformed.private_key_to_der().unwrap();
|
||||
let traditional_pem = String::from_utf8(malformed.private_key_to_pem().unwrap()).unwrap();
|
||||
let native = PKey::from_rsa(malformed).unwrap();
|
||||
let der = native.private_key_to_pkcs8().unwrap();
|
||||
let pem = String::from_utf8(native.private_key_to_pem_pkcs8().unwrap()).unwrap();
|
||||
// Parsing succeeds; consistency validation must reject these keys.
|
||||
assert!(PKey::private_key_from_pkcs8(&der).is_ok());
|
||||
assert!(PKey::private_key_from_pem(pem.as_bytes()).is_ok());
|
||||
for backend in backends {
|
||||
for imported in [
|
||||
backend.import_keypair_der(&der),
|
||||
backend.import_keypair_pem(&pem),
|
||||
backend.import_keypair_pem(&traditional_pem),
|
||||
] {
|
||||
assert!(
|
||||
imported.is_err(),
|
||||
"{} accepted corrupted RSA component {component}",
|
||||
backend.capabilities().backend
|
||||
);
|
||||
}
|
||||
for encoding in [
|
||||
EncodingKey::from_rsa_der(&pkcs1),
|
||||
EncodingKey::from_rsa_pem(pem.as_bytes()).unwrap(),
|
||||
] {
|
||||
for algorithm in [
|
||||
Algorithm::RS256,
|
||||
Algorithm::RS384,
|
||||
Algorithm::RS512,
|
||||
Algorithm::PS256,
|
||||
Algorithm::PS384,
|
||||
Algorithm::PS512,
|
||||
] {
|
||||
let signer = (backend.jwt_provider().signer_factory)(&algorithm, &encoding);
|
||||
// OpenSSL validates at construction; AWS-LC may defer it
|
||||
// until signing. Neither may successfully use the key.
|
||||
if backend.capabilities().backend == "openssl" {
|
||||
assert!(
|
||||
signer.is_err(),
|
||||
"OpenSSL accepted corrupted RSA component {component} for {algorithm:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
!signer.is_ok_and(|signer| signer.try_sign(b"header.claims").is_ok()),
|
||||
"AWS-LC signed with corrupted RSA component {component} for {algorithm:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(feature = "openssl")]
|
||||
|
||||
use openshell_crypto::{OpenSsl, ProtocolBackend};
|
||||
use openssl::{ec::EcGroup, ec::EcKey, nid::Nid, pkey::PKey, rsa::Rsa, symm::Cipher};
|
||||
use std::{
|
||||
process::{Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn encrypted_pem_is_rejected_without_prompting() {
|
||||
// Isolate terminal I/O and bound execution: reverting to OpenSSL's default
|
||||
// callback must fail the test, whether a controlling terminal exists or not.
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--ignored", "--exact", "pem_import_child", "--nocapture"])
|
||||
.env("OPENSHELL_TEST_PEM_IMPORT", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() >= deadline {
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap();
|
||||
panic!("PEM import did not finish without interactive input");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let result = child.wait_with_output().unwrap();
|
||||
let stdout = String::from_utf8_lossy(&result.stdout);
|
||||
let stderr = String::from_utf8_lossy(&result.stderr);
|
||||
assert!(result.status.success(), "{stdout}\n{stderr}");
|
||||
assert!(stdout.contains("1 passed"), "{stdout}");
|
||||
assert!(stderr.is_empty(), "PEM import wrote to stderr: {stderr}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run by the parent with an input deadline and captured diagnostics"]
|
||||
fn pem_import_child() {
|
||||
assert_eq!(std::env::var("OPENSHELL_TEST_PEM_IMPORT").unwrap(), "1");
|
||||
let ec = EcKey::generate(&EcGroup::from_curve_name(Nid::X9_62_PRIME256V1).unwrap()).unwrap();
|
||||
let rsa = Rsa::generate(2048).unwrap();
|
||||
let keys = [
|
||||
PKey::from_ec_key(ec.clone()).unwrap(),
|
||||
PKey::from_rsa(rsa.clone()).unwrap(),
|
||||
];
|
||||
for key in &keys {
|
||||
let pem = key.private_key_to_pem_pkcs8().unwrap();
|
||||
let imported = OpenSsl
|
||||
.import_keypair_pem(std::str::from_utf8(&pem).unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(imported.public_key_der(), key.public_key_to_der().unwrap());
|
||||
}
|
||||
// Both the generic PKCS#8 and traditional PEM loaders can request passwords.
|
||||
for password in [b"test-only-passphrase".as_slice(), b""] {
|
||||
let mut encrypted = keys
|
||||
.iter()
|
||||
.map(|key| {
|
||||
key.private_key_to_pem_pkcs8_passphrase(Cipher::aes_256_cbc(), password)
|
||||
.unwrap()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
encrypted.push(
|
||||
ec.private_key_to_pem_passphrase(Cipher::aes_256_cbc(), password)
|
||||
.unwrap(),
|
||||
);
|
||||
encrypted.push(
|
||||
rsa.private_key_to_pem_passphrase(Cipher::aes_256_cbc(), password)
|
||||
.unwrap(),
|
||||
);
|
||||
for pem in encrypted {
|
||||
assert!(
|
||||
OpenSsl
|
||||
.import_keypair_pem(std::str::from_utf8(&pem).unwrap())
|
||||
.is_err(),
|
||||
"encrypted PEM must be rejected, including with an empty passphrase"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(OpenSsl.import_keypair_pem("invalid key").is_err());
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(feature = "openssl")]
|
||||
|
||||
mod common;
|
||||
|
||||
use common::{handshake, init_openssl};
|
||||
use openshell_crypto::{OpenSsl, ProtocolBackend, pki, tls};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn native_tls_library_is_available_for_linkage_check() {
|
||||
// The backend uses Rustls for TLS and libcrypto for primitives. Exercise
|
||||
// libssl explicitly so Linux's --as-needed linker retains both native
|
||||
// libraries for CI's dynamic-linkage assertion on this test executable.
|
||||
let _context = openssl::ssl::SslContext::builder(openssl::ssl::SslMethod::tls())
|
||||
.unwrap()
|
||||
.build();
|
||||
}
|
||||
|
||||
fn leaf(
|
||||
ca: &pki::Certificate,
|
||||
ca_key: &pki::KeyPair,
|
||||
usage: rcgen::ExtendedKeyUsagePurpose,
|
||||
) -> pki::CertifiedKey {
|
||||
let key_pair = pki::generate_keypair().unwrap();
|
||||
let mut params = rcgen::CertificateParams::new(vec!["localhost".into()]).unwrap();
|
||||
params.extended_key_usages = vec![usage];
|
||||
params.key_usages = vec![rcgen::KeyUsagePurpose::DigitalSignature];
|
||||
let cert = pki::signed_by(params, &key_pair, ca, ca_key).unwrap();
|
||||
pki::CertifiedKey { cert, key_pair }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tls12_and_tls13_enforce_server_and_client_authentication() {
|
||||
init_openssl();
|
||||
// An explicit empty protocol policy must not be replaced with defaults.
|
||||
assert!(tls::try_client_builder_with_protocol_versions(&[]).is_err());
|
||||
assert!(tls::try_server_builder_with_protocol_versions(&[]).is_err());
|
||||
assert!(std::ptr::eq(
|
||||
tls::configuration_provider().secure_random,
|
||||
OpenSsl.tls_provider().secure_random
|
||||
));
|
||||
let ca_key = pki::generate_keypair().unwrap();
|
||||
let mut params = rcgen::CertificateParams::default();
|
||||
params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
|
||||
params.key_usages = vec![rcgen::KeyUsagePurpose::KeyCertSign];
|
||||
let ca = pki::self_signed(params, &ca_key).unwrap();
|
||||
let server_key = leaf(&ca, &ca_key, rcgen::ExtendedKeyUsagePurpose::ServerAuth);
|
||||
let client_key = leaf(&ca, &ca_key, rcgen::ExtendedKeyUsagePurpose::ClientAuth);
|
||||
let mut roots = rustls::RootCertStore::empty();
|
||||
roots.add(ca.der().clone()).unwrap();
|
||||
for version in [&rustls::version::TLS12, &rustls::version::TLS13] {
|
||||
let verifier = rustls::server::WebPkiClientVerifier::builder_with_provider(
|
||||
Arc::new(roots.clone()),
|
||||
tls::configuration_provider(),
|
||||
)
|
||||
.build()
|
||||
.unwrap();
|
||||
let server = tls::try_server_builder_with_protocol_versions(&[version])
|
||||
.unwrap()
|
||||
.with_client_cert_verifier(verifier)
|
||||
.with_single_cert(
|
||||
vec![server_key.cert.der().clone()],
|
||||
rustls::pki_types::PrivatePkcs8KeyDer::from(
|
||||
server_key.key_pair.serialize_der().unwrap(),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
.unwrap();
|
||||
let client = tls::try_client_builder_with_protocol_versions(&[version])
|
||||
.unwrap()
|
||||
.with_root_certificates(roots.clone())
|
||||
.with_client_auth_cert(
|
||||
vec![client_key.cert.der().clone()],
|
||||
rustls::pki_types::PrivatePkcs8KeyDer::from(
|
||||
client_key.key_pair.serialize_der().unwrap(),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
.unwrap();
|
||||
handshake(client.clone(), server.clone(), "localhost").unwrap();
|
||||
assert!(matches!(
|
||||
handshake(client, server.clone(), "wrong.example"),
|
||||
Err(rustls::Error::InvalidCertificate(_))
|
||||
));
|
||||
let anonymous = tls::try_client_builder_with_protocol_versions(&[version])
|
||||
.unwrap()
|
||||
.with_root_certificates(roots.clone())
|
||||
.with_no_client_auth();
|
||||
assert!(matches!(
|
||||
handshake(anonymous, server.clone(), "localhost"),
|
||||
Err(rustls::Error::NoCertificatesPresented)
|
||||
));
|
||||
let untrusted = tls::try_client_builder_with_protocol_versions(&[version])
|
||||
.unwrap()
|
||||
.with_root_certificates(rustls::RootCertStore::empty())
|
||||
.with_no_client_auth();
|
||||
assert!(matches!(
|
||||
handshake(untrusted, server.clone(), "localhost"),
|
||||
Err(rustls::Error::InvalidCertificate(_))
|
||||
));
|
||||
let stranger = pki::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
|
||||
let unauthorized = tls::try_client_builder_with_protocol_versions(&[version])
|
||||
.unwrap()
|
||||
.with_root_certificates(roots.clone())
|
||||
.with_client_auth_cert(
|
||||
vec![stranger.cert.der().clone()],
|
||||
rustls::pki_types::PrivatePkcs8KeyDer::from(
|
||||
stranger.key_pair.serialize_der().unwrap(),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
handshake(unauthorized, server, "localhost"),
|
||||
Err(rustls::Error::InvalidCertificate(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
#![cfg(feature = "openssl")]
|
||||
|
||||
use openshell_crypto::{CryptoBackend, CryptoContext, CryptoError, OpenSsl, ProtocolBackend, tls};
|
||||
use std::{
|
||||
process::{Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn unavailable_provider_fails_without_fallback() {
|
||||
// OpenSSL reads configuration once. Exercise failure in a fresh process,
|
||||
// without changing the test runner's environment or process-global state.
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--ignored",
|
||||
"--exact",
|
||||
"unavailable_provider_child",
|
||||
"--nocapture",
|
||||
])
|
||||
.env("OPENSHELL_TEST_UNAVAILABLE", "1")
|
||||
.env(
|
||||
"OPENSSL_CONF",
|
||||
concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/openssl-unavailable.cnf"
|
||||
),
|
||||
)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while child.try_wait().unwrap().is_none() {
|
||||
if Instant::now() >= deadline {
|
||||
// The child may have exited since try_wait; still reap it below.
|
||||
let _ = child.kill();
|
||||
let result = child.wait_with_output().unwrap();
|
||||
panic!(
|
||||
"unavailable-provider child exceeded the 10-second deadline\n{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let result = child.wait_with_output().unwrap();
|
||||
assert!(
|
||||
result.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&result.stdout),
|
||||
String::from_utf8_lossy(&result.stderr)
|
||||
);
|
||||
assert!(String::from_utf8_lossy(&result.stdout).contains("1 passed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "run in a subprocess with deliberately unavailable OpenSSL algorithms"]
|
||||
fn unavailable_provider_child() {
|
||||
assert_eq!(std::env::var("OPENSHELL_TEST_UNAVAILABLE").unwrap(), "1");
|
||||
openshell_crypto::install_default_context(CryptoContext::new(Box::new(OpenSsl))).unwrap();
|
||||
assert_eq!(
|
||||
openshell_crypto::sha256(b"message"),
|
||||
Err(CryptoError::Operation)
|
||||
);
|
||||
assert_eq!(
|
||||
openshell_crypto::random_bytes::<32>(),
|
||||
Err(CryptoError::Random)
|
||||
);
|
||||
assert_eq!(
|
||||
OpenSsl.seal(&[0; 32], b"", b"plaintext").unwrap_err(),
|
||||
CryptoError::Random
|
||||
);
|
||||
// A full-length tag reaches cipher initialization: unavailable AES is an
|
||||
// operational error, not a corrupt stored credential.
|
||||
assert_eq!(
|
||||
OpenSsl.open(&[0; 32], b"", &[0; 12], &[0; 16]),
|
||||
Err(CryptoError::Operation)
|
||||
);
|
||||
for algorithm in [
|
||||
&rcgen::PKCS_ECDSA_P256_SHA256,
|
||||
&rcgen::PKCS_ECDSA_P384_SHA384,
|
||||
&rcgen::PKCS_ED25519,
|
||||
&rcgen::PKCS_RSA_SHA256,
|
||||
] {
|
||||
assert!(OpenSsl.generate_keypair(algorithm).is_err());
|
||||
}
|
||||
assert!(
|
||||
openshell_crypto::jwt::encode(
|
||||
&jsonwebtoken::Header::default(),
|
||||
&serde_json::json!({"exp":4_000_000_000_u64}),
|
||||
&jsonwebtoken::EncodingKey::from_secret(b"test-only-key"),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
// Exercise the same facade builders as production callers: a provider with
|
||||
// no usable ciphers must return an error, not panic or fall back to AWS-LC.
|
||||
assert!(OpenSsl.tls_provider().cipher_suites.is_empty());
|
||||
let errors = [
|
||||
tls::try_client_builder().unwrap_err(),
|
||||
tls::try_server_builder().unwrap_err(),
|
||||
tls::try_client_builder_with_protocol_versions(&[&rustls::version::TLS13]).unwrap_err(),
|
||||
tls::try_server_builder_with_protocol_versions(&[&rustls::version::TLS13]).unwrap_err(),
|
||||
];
|
||||
for error in errors {
|
||||
assert!(
|
||||
matches!(error, rustls::Error::General(ref message) if message == "no usable cipher suites configured")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -675,10 +675,12 @@ fn tls_client_config(tls: &SandboxTlsClientConfig) -> Result<rustls::ClientConfi
|
||||
BackendError::Descriptor(format!("load boundary TLS CA certificate: {error}"))
|
||||
})?;
|
||||
}
|
||||
let mut config =
|
||||
openshell_crypto::tls::client_builder_with_protocol_versions(&[&rustls::version::TLS13])
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
let mut config = openshell_crypto::tls::try_client_builder_with_protocol_versions(&[
|
||||
&rustls::version::TLS13,
|
||||
])
|
||||
.map_err(|error| BackendError::Descriptor(format!("configure boundary TLS client: {error}")))?
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
config.alpn_protocols = vec![b"h2".to_vec()];
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ bytes = { workspace = true }
|
||||
hex = "0.4"
|
||||
ipnet = "2"
|
||||
rand = "0.10"
|
||||
sha2 = { workspace = true }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
|
||||
@@ -5055,9 +5055,15 @@ mod linux {
|
||||
"boundary TLS private-key file contains no private key",
|
||||
)
|
||||
})?;
|
||||
let mut config = openshell_crypto::tls::server_builder_with_protocol_versions(&[
|
||||
let mut config = openshell_crypto::tls::try_server_builder_with_protocol_versions(&[
|
||||
&rustls::version::TLS13,
|
||||
])
|
||||
.map_err(|error| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("configure boundary TLS server: {error}"),
|
||||
)
|
||||
})?
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certificates, private_key)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
|
||||
|
||||
@@ -194,10 +194,11 @@ impl ServerCertVerifier for InsecureServerCertVerifier {
|
||||
/// rustls client config that disables server certificate verification.
|
||||
///
|
||||
/// Pairs with [`InsecureTlsConnector`] for transports that need to skip
|
||||
/// verification (development, debug). Returns `Result` for symmetry with
|
||||
/// future verifying variants; the current implementation cannot fail.
|
||||
/// verification (development, debug). Returns an error if the selected crypto
|
||||
/// provider cannot support the TLS configuration.
|
||||
pub fn build_insecure_rustls_config() -> Result<rustls::ClientConfig> {
|
||||
Ok(openshell_crypto::tls::client_builder()
|
||||
Ok(openshell_crypto::tls::try_client_builder()
|
||||
.map_err(|e| SdkError::invalid_config(format!("configure gateway TLS client: {e}")))?
|
||||
.dangerous()
|
||||
.with_custom_certificate_verifier(std::sync::Arc::new(InsecureServerCertVerifier))
|
||||
.with_no_client_auth())
|
||||
|
||||
@@ -408,7 +408,9 @@ fn build_server_config(
|
||||
.build()
|
||||
.map_err(|e| Error::tls(format!("failed to build client verifier: {e}")))?;
|
||||
|
||||
let builder = openshell_crypto::tls::server_builder().with_client_cert_verifier(verifier);
|
||||
let builder = openshell_crypto::tls::try_server_builder()
|
||||
.map_err(|e| Error::tls(format!("configure gateway TLS server: {e}")))?
|
||||
.with_client_cert_verifier(verifier);
|
||||
if let Some(resolver) = resolver {
|
||||
builder.with_cert_resolver(resolver)
|
||||
} else {
|
||||
@@ -417,7 +419,9 @@ fn build_server_config(
|
||||
.map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))?
|
||||
}
|
||||
} else {
|
||||
let builder = openshell_crypto::tls::server_builder().with_no_client_auth();
|
||||
let builder = openshell_crypto::tls::try_server_builder()
|
||||
.map_err(|e| Error::tls(format!("configure gateway TLS server: {e}")))?
|
||||
.with_no_client_auth();
|
||||
if let Some(resolver) = resolver {
|
||||
builder.with_cert_resolver(resolver)
|
||||
} else {
|
||||
|
||||
@@ -121,7 +121,9 @@ impl SandboxCa {
|
||||
.wrap_err("parse proxy CA private key")?
|
||||
.ok_or_else(|| miette!("proxy CA private key file contains no private key"))?;
|
||||
let ca_der = certificates[0].clone();
|
||||
openshell_crypto::tls::server_builder()
|
||||
openshell_crypto::tls::try_server_builder()
|
||||
.into_diagnostic()
|
||||
.wrap_err("configure proxy CA TLS validation")?
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certificates, private_key)
|
||||
.into_diagnostic()
|
||||
@@ -221,7 +223,9 @@ impl ProxyTlsState {
|
||||
/// Get or generate a leaf cert for the hostname and return a TLS acceptor.
|
||||
fn acceptor_for(&self, hostname: &str) -> Result<TlsAcceptor> {
|
||||
let leaf = self.cert_cache.get_or_generate(hostname)?;
|
||||
let mut server_config = openshell_crypto::tls::server_builder()
|
||||
let mut server_config = openshell_crypto::tls::try_server_builder()
|
||||
.into_diagnostic()
|
||||
.wrap_err("configure proxy TLS server")?
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(leaf.cert_chain.clone(), leaf.private_key.clone_key())
|
||||
.into_diagnostic()?;
|
||||
@@ -278,7 +282,9 @@ pub async fn tls_connect_upstream(
|
||||
/// `system_ca_bundle` is ignored because the native store already reflects all
|
||||
/// operator-installed trust anchors.
|
||||
pub fn build_upstream_client_config(system_ca_bundle: &str) -> Result<Arc<ClientConfig>> {
|
||||
let mut config = openshell_crypto::tls::client_builder()
|
||||
let mut config = openshell_crypto::tls::try_client_builder()
|
||||
.into_diagnostic()
|
||||
.wrap_err("configure upstream TLS client")?
|
||||
.with_root_certificates(build_upstream_root_store(system_ca_bundle)?)
|
||||
.with_no_client_auth();
|
||||
config.alpn_protocols = vec![b"http/1.1".to_vec()];
|
||||
|
||||
@@ -49,15 +49,355 @@ registries = []
|
||||
|
||||
# -- Bans ----------------------------------------------------------------------
|
||||
[bans]
|
||||
deny = [
|
||||
{ name = "ring", wrappers = ["rustls", "rustls-webpki"], reason = "Use the selected crypto backend instead. These wrappers are allowed for z3-sys's prebuilt archive downloader." },
|
||||
]
|
||||
multiple-versions = "warn"
|
||||
wildcards = "allow"
|
||||
highlight = "all"
|
||||
workspace-default-features = "allow"
|
||||
external-default-features = "allow"
|
||||
|
||||
# Crypto implementations must stay behind the facade or an explicit wrapper.
|
||||
# See crates/openshell-crypto/CRYPTO.md#dependency-guardrail for classifications.
|
||||
# Wrappers permit only direct parents, not arbitrary descendants. Keep this
|
||||
# catalog and its exceptions reviewed when changing cryptographic dependencies.
|
||||
|
||||
[[bans.deny]]
|
||||
name = "ring"
|
||||
wrappers = ["rustls", "rustls-webpki"]
|
||||
reason = "Use the selected crypto backend instead. These wrappers are allowed for z3-sys's prebuilt archive downloader."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "aws-lc-rs"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"jsonwebtoken",
|
||||
"openshell-crypto",
|
||||
"rcgen",
|
||||
"russh",
|
||||
"rustls",
|
||||
"rustls-webpki",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "aws-lc-sys"
|
||||
reason = "Native crypto bindings belong behind their provider libraries, not application crates."
|
||||
wrappers = [
|
||||
"aws-lc-rs",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "aws-lc-fips-sys"
|
||||
# Keep this boundary even when the FIPS build feature is disabled.
|
||||
# The aws-lc-rs wrapper is intentionally unused in the current resolved graph;
|
||||
# cargo-deny's unused-wrapper warning for this entry is expected. Revisit this
|
||||
# acknowledgement if the FIPS dependency is enabled; other unused wrappers
|
||||
# still require review.
|
||||
reason = "Native crypto bindings belong behind their provider libraries, not application crates."
|
||||
wrappers = [
|
||||
"aws-lc-rs",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "openssl"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"jsonwebtoken-openssl",
|
||||
"openshell-crypto",
|
||||
"rustls-openssl",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "openssl-src"
|
||||
reason = "Use system-provided OpenSSL; vendored OpenSSL is not permitted."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "openssl-sys"
|
||||
reason = "Native crypto bindings belong behind their provider libraries, not application crates."
|
||||
wrappers = [
|
||||
"openssl",
|
||||
"rustls-openssl",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "rustls-openssl"
|
||||
reason = "OpenSSL protocol adapters are owned by openshell-crypto."
|
||||
wrappers = [
|
||||
"openshell-crypto",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "jsonwebtoken-openssl"
|
||||
reason = "OpenSSL protocol adapters are owned by openshell-crypto."
|
||||
wrappers = [
|
||||
"openshell-crypto",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "sha1"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"axum",
|
||||
"openshell-supervisor-network",
|
||||
"russh",
|
||||
"ssh-key",
|
||||
"tungstenite",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "sha2"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"aws-sigv4",
|
||||
"bcrypt-pbkdf",
|
||||
"ed25519-dalek",
|
||||
"jsonwebtoken",
|
||||
"lzma-rust2",
|
||||
"oauth2",
|
||||
"oci-client",
|
||||
"openshell-core",
|
||||
"openshell-driver-docker",
|
||||
"openshell-driver-kubernetes",
|
||||
"openshell-driver-kubernetes-secrets",
|
||||
"openshell-driver-podman",
|
||||
"openshell-driver-vault",
|
||||
"openshell-driver-vm",
|
||||
"openshell-gateway-interceptors",
|
||||
"openshell-isolation-interface",
|
||||
"openshell-server",
|
||||
"openshell-supervisor-network",
|
||||
"openshell-supervisor-process",
|
||||
"p256",
|
||||
"p384",
|
||||
"p521",
|
||||
"pageant",
|
||||
"pest_meta",
|
||||
"pkcs5",
|
||||
"rsa",
|
||||
"russh",
|
||||
"scrypt",
|
||||
"sqlx-core",
|
||||
"sqlx-macros-core",
|
||||
"sqlx-postgres",
|
||||
"ssh-key",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "sha3"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"ml-kem",
|
||||
"russh",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "hmac"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"aws-sigv4",
|
||||
"hkdf",
|
||||
"jsonwebtoken",
|
||||
"openshell-server",
|
||||
"pbkdf2",
|
||||
"rfc6979",
|
||||
"russh",
|
||||
"sqlx-postgres",
|
||||
"ssh-key",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "hkdf"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"elliptic-curve",
|
||||
"sqlx-postgres",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "rsa"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"jsonwebtoken",
|
||||
"openshell-server",
|
||||
"russh",
|
||||
"ssh-key",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "aes"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"aes-gcm",
|
||||
"pkcs5",
|
||||
"russh",
|
||||
"ssh-cipher",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "aes-gcm"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"pkcs5",
|
||||
"ssh-cipher",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "chacha20"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"rand",
|
||||
"ssh-cipher",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "chacha20poly1305"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "poly1305"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"ssh-cipher",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "p256"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"jsonwebtoken",
|
||||
"russh",
|
||||
"ssh-key",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "p384"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"jsonwebtoken",
|
||||
"russh",
|
||||
"ssh-key",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "p521"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"russh",
|
||||
"ssh-key",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "ed25519-dalek"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"jsonwebtoken",
|
||||
"russh",
|
||||
"ssh-key",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "curve25519-dalek"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"ed25519-dalek",
|
||||
"russh",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "blake2"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"argon2",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "blake3"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "md-5"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"sqlx-postgres",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "md5"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"russh",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "pbkdf2"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"bcrypt-pbkdf",
|
||||
"pkcs5",
|
||||
"russh",
|
||||
"scrypt",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "scrypt"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"pkcs5",
|
||||
"russh",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "argon2"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
wrappers = [
|
||||
"ssh-key",
|
||||
]
|
||||
|
||||
[[bans.deny]]
|
||||
name = "k256"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "secp256k1"
|
||||
reason = "Use openshell-crypto; exceptions are classified in CRYPTO.md."
|
||||
|
||||
# Lower-level implementations used by the dependency-owned SSH stack.
|
||||
[[bans.deny]]
|
||||
name = "bcrypt-pbkdf"
|
||||
wrappers = ["ssh-key"]
|
||||
reason = "SSH key derivation is dependency-owned."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "des"
|
||||
reason = "No DES implementation is enabled; compatibility exceptions require explicit review."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "ecdsa"
|
||||
wrappers = ["p256", "p384", "p521", "russh"]
|
||||
reason = "Use the facade for signing; dependency-owned implementations remain permitted."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "fiat-crypto"
|
||||
wrappers = ["curve25519-dalek", "p384"]
|
||||
reason = "Curve arithmetic belongs behind the dependency-owned implementations."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "ml-kem"
|
||||
wrappers = ["russh"]
|
||||
reason = "Post-quantum SSH cryptography is dependency-owned."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "rfc6979"
|
||||
wrappers = ["ecdsa"]
|
||||
reason = "Signature nonce generation belongs behind the signing implementation."
|
||||
|
||||
[[bans.deny]]
|
||||
name = "ssh-cipher"
|
||||
wrappers = ["ssh-key"]
|
||||
reason = "SSH ciphers are dependency-owned."
|
||||
|
||||
# -- Sources -------------------------------------------------------------------
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
|
||||
@@ -209,6 +209,26 @@
|
||||
devShells = {
|
||||
default = pkgs.mkShellNoCC commonDevShell;
|
||||
|
||||
# Crypto backend tests need native OpenSSL and Cargo's symbol-prefixed
|
||||
# AWS-LC build. The release toolchains expose an unprefixed system
|
||||
# AWS-LC libcrypto/libssl that collides with OpenSSL when both link.
|
||||
crypto = pkgs.mkShell {
|
||||
packages = [
|
||||
rustToolchain
|
||||
pkgs.cmakeMinimal
|
||||
pkgs.pkg-config
|
||||
pkgs.uv
|
||||
];
|
||||
buildInputs = [ pkgs.openssl_3_5 ];
|
||||
env = {
|
||||
OPENSSL_LIB_DIR = "${pkgs.lib.getLib pkgs.openssl_3_5}/lib";
|
||||
OPENSSL_INCLUDE_DIR = "${pkgs.lib.getDev pkgs.openssl_3_5}/include";
|
||||
OPENSSL_NO_VENDOR = "1";
|
||||
OPENSSL_STATIC = "0";
|
||||
AWS_LC_SYS_USE_SYSTEM = "0";
|
||||
};
|
||||
};
|
||||
|
||||
testing = pkgs.mkShellNoCC (
|
||||
commonDevShell
|
||||
// {
|
||||
|
||||
@@ -76,12 +76,18 @@ hide = true
|
||||
|
||||
["rust:deny"]
|
||||
description = "Check dependencies for all cargo-deny rules"
|
||||
depends = ["rust:deny:crypto-test"]
|
||||
run = "cargo deny check"
|
||||
|
||||
["rust:deny:policy"]
|
||||
description = "Check dependencies for license violations, bans, and source restrictions"
|
||||
depends = ["rust:deny:crypto-test"]
|
||||
run = "cargo deny check licenses bans sources"
|
||||
|
||||
["rust:deny:crypto-test"]
|
||||
description = "Verify cargo-deny rejects crypto dependencies that bypass the facade"
|
||||
run = "uv run --no-project python tasks/scripts/test-crypto-dependency-policy.py"
|
||||
|
||||
|
||||
["rust:verify:telemetry-off"]
|
||||
description = "Verify telemetry emission code is compiled out with --no-default-features"
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"""Exercise the real cargo-deny policy with tiny, offline Cargo package graphs.
|
||||
|
||||
No crypto code is built or downloaded. Package names and dependency edges are
|
||||
enough to check that an approved wrapper cannot mask an application bypass.
|
||||
This standalone runner needs no pytest environment in the Rust policy job.
|
||||
Graph failures are collected so every case runs before the command fails.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import tempfile
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
POLICY = ROOT / "deny.toml"
|
||||
|
||||
|
||||
def check_graph(
|
||||
directory: Path,
|
||||
edges: list[tuple[str, str, str, str]],
|
||||
banned: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
"""Each edge is (parent, package, manifest section, dependency alias)."""
|
||||
packages = {name for parent, child, _, _ in edges for name in (parent, child)}
|
||||
members = {
|
||||
name
|
||||
for name in packages
|
||||
if name.startswith("openshell-") or name == "policy-test-app"
|
||||
}
|
||||
directory.mkdir()
|
||||
(directory / "Cargo.toml").write_text(
|
||||
'[workspace]\nresolver = "2"\nmembers = '
|
||||
+ json.dumps(sorted(members))
|
||||
+ "\nexclude = "
|
||||
+ json.dumps(sorted(packages - members))
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
for package in packages:
|
||||
source = directory / package / "src"
|
||||
source.mkdir(parents=True)
|
||||
(source / "lib.rs").write_text("", encoding="utf-8")
|
||||
manifest = (
|
||||
f'[package]\nname = "{package}"\nversion = "0.0.0"\nedition = "2021"\n'
|
||||
)
|
||||
sections: dict[str, list[str]] = {}
|
||||
for parent, child, section, alias in edges:
|
||||
if parent == package:
|
||||
sections.setdefault(section, []).append(
|
||||
f'{alias} = {{ package = "{child}", path = "../{child}" }}'
|
||||
)
|
||||
for section, dependencies in sections.items():
|
||||
manifest += f"\n[{section}]\n" + "\n".join(dependencies) + "\n"
|
||||
(source.parent / "Cargo.toml").write_text(manifest, encoding="utf-8")
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"cargo",
|
||||
"deny",
|
||||
"--offline",
|
||||
"--color",
|
||||
"never",
|
||||
"--manifest-path",
|
||||
str(directory / "Cargo.toml"),
|
||||
"--config",
|
||||
str(POLICY),
|
||||
"check",
|
||||
"bans",
|
||||
],
|
||||
cwd=directory,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if not banned:
|
||||
passed = result.returncode == 0
|
||||
else:
|
||||
# A metadata/configuration failure must not count as a successful ban.
|
||||
passed = result.returncode != 0 and all(
|
||||
f"error[banned]: crate '{package} =" in output for package in banned
|
||||
)
|
||||
if not passed:
|
||||
raise AssertionError(f"{directory.name}: unexpected policy result\n{output}")
|
||||
print(f"PASS {directory.name}")
|
||||
|
||||
|
||||
def edge(
|
||||
parent: str, child: str, section: str = "dependencies", alias: str = ""
|
||||
) -> tuple[str, str, str, str]:
|
||||
return parent, child, section, alias or child
|
||||
|
||||
|
||||
def main() -> None:
|
||||
tools = tomllib.loads((ROOT / "mise.toml").read_text(encoding="utf-8"))["tools"]
|
||||
expected = tools["github:EmbarkStudios/cargo-deny"]["version"]
|
||||
version = subprocess.run(
|
||||
["cargo", "deny", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if version.returncode != 0 or version.stdout.strip() != f"cargo-deny {expected}":
|
||||
actual = (version.stdout + version.stderr).strip()
|
||||
raise RuntimeError(
|
||||
f"Crypto policy fixtures require cargo-deny {expected} from mise.toml; "
|
||||
f"found: {actual}. Run mise install, then mise run rust:deny:policy."
|
||||
)
|
||||
facade = [
|
||||
edge("policy-test-app", "openshell-crypto"),
|
||||
edge("openshell-crypto", "aws-lc-rs"),
|
||||
edge("aws-lc-rs", "aws-lc-sys"),
|
||||
edge("aws-lc-rs", "aws-lc-fips-sys"),
|
||||
edge("openshell-crypto", "openssl"),
|
||||
edge("openssl", "openssl-sys"),
|
||||
edge("openshell-crypto", "rustls-openssl"),
|
||||
edge("rustls-openssl", "openssl"),
|
||||
edge("rustls-openssl", "openssl-sys"),
|
||||
edge("openshell-crypto", "jsonwebtoken-openssl"),
|
||||
edge("jsonwebtoken-openssl", "openssl"),
|
||||
]
|
||||
failures: list[AssertionError] = []
|
||||
|
||||
def run_graph(
|
||||
directory: Path,
|
||||
edges: list[tuple[str, str, str, str]],
|
||||
banned: tuple[str, ...] = (),
|
||||
) -> None:
|
||||
try:
|
||||
check_graph(directory, edges, banned)
|
||||
except AssertionError as error:
|
||||
failures.append(error)
|
||||
print(f"FAIL {directory.name}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="openshell-crypto-policy-") as temp:
|
||||
root = Path(temp)
|
||||
run_graph(root / "facade-backends", facade)
|
||||
run_graph(
|
||||
root / "vendored-openssl",
|
||||
[*facade, edge("openssl-sys", "openssl-src", "build-dependencies")],
|
||||
("openssl-src",),
|
||||
)
|
||||
run_graph(
|
||||
root / "documented-exceptions",
|
||||
[
|
||||
edge("openshell-server", "rsa", "dev-dependencies"),
|
||||
edge("openshell-driver-vault", "sha2"),
|
||||
edge("policy-test-app", "russh"),
|
||||
edge("russh", "aes"),
|
||||
edge("policy-test-app", "rustls"),
|
||||
edge("rustls", "ring"),
|
||||
],
|
||||
)
|
||||
# Keep the valid facade path present in every negative case. A shared
|
||||
# implementation must still reject each unapproved direct parent.
|
||||
direct_packages = (
|
||||
"aws-lc-rs",
|
||||
"aws-lc-sys",
|
||||
"aws-lc-fips-sys",
|
||||
"openssl",
|
||||
"openssl-sys",
|
||||
"openssl-src",
|
||||
"rustls-openssl",
|
||||
"jsonwebtoken-openssl",
|
||||
"ring",
|
||||
"sha2",
|
||||
"hmac",
|
||||
"aes-gcm",
|
||||
"rsa",
|
||||
"ed25519-dalek",
|
||||
"blake3",
|
||||
"ecdsa",
|
||||
"ml-kem",
|
||||
)
|
||||
# cargo-deny reports every violation; require each diagnostic so one
|
||||
# working ban cannot hide another missing or weakened rule.
|
||||
run_graph(
|
||||
root / "direct-dependencies",
|
||||
[
|
||||
*facade,
|
||||
*(edge("policy-test-app", package) for package in direct_packages),
|
||||
],
|
||||
direct_packages,
|
||||
)
|
||||
for name, section in (
|
||||
("renamed", "dependencies"),
|
||||
("build", "build-dependencies"),
|
||||
("dev", "dev-dependencies"),
|
||||
("windows-only", "target.'cfg(windows)'.dependencies"),
|
||||
):
|
||||
run_graph(
|
||||
root / name,
|
||||
[*facade, edge("policy-test-app", "openssl", section, "crypto_alias")],
|
||||
("openssl",),
|
||||
)
|
||||
run_graph(
|
||||
root / "unapproved-wrapper",
|
||||
[
|
||||
*facade,
|
||||
edge("policy-test-app", "another-crypto-wrapper"),
|
||||
edge("another-crypto-wrapper", "openssl"),
|
||||
],
|
||||
("openssl",),
|
||||
)
|
||||
|
||||
if failures:
|
||||
raise ExceptionGroup("Crypto dependency policy fixtures failed", failures)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user