Files
OpenShell/crates/openshell-crypto
Varsha 3391187c06 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>
2026-10-30 16:38:00 +00:00
..

OpenShell crypto backend

See Cryptography and FIPS for the architecture, contributor rules, and production OpenSSL/FIPS follow-up requirements.

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 OpenSSL implementation builds on the standalone proof of concept following the generic backend refactor. Application-wide OpenSSL selection and downstream FIPS qualification remain separate work; enabling this backend is not a FIPS build or compliance claim.

Boundaries

CryptoBackend defines randomness, incremental SHA-256, and AES-256-GCM without crypto-library types. ProtocolBackend adds Rustls, rcgen, and jsonwebtoken adapters. These adapters retain their protocol libraries' validation and encoding; they do not expose AWS-LC types. A later native OpenSSL TLS integration may need an additional transport adapter; this interface does not claim to provide one.

Digest creation, updates, and finalization return Result so backends can report provider and operation failures. Callers discard a digest after an update error; the one-shot SHA-256 helper propagates the first failure without fallback.

Callers use aead, pki, tls, and jwt, or an explicit CryptoContext. Certificate policy and JWT claim validation remain at their existing callers. pki::KeyPair owns a backend SigningKey and implements rcgen 0.14's PublicKeyData and SigningKey traits directly. The backend generates/imports keys, signs messages, and exports PKCS#8 DER. PEM wrapping and certificate encoding remain in the facade. Public keys expose algorithm-specific bytes and SPKI; private exports are fallible, including for non-exportable keys. Certificate encoding never requires private-key export. pki::Certificate retains public issuance parameters separately from the encoded certificate, allowing generated certificates to be used as issuers without retaining private keys. Persisted proxy CAs use pki::issuer_from_der and keep their original certificate bytes in issued chains. Persisted CA keys are imported through the selected backend. Provider resources must remain alive for the full key lifetime. Default certificate serial numbers and key identifiers are derived through the selected digest backend, preserving the existing SHA-256 derivation without depending on rcgen's compiled crypto implementation.

Build selection

The crate's default aws-lc feature selects the current implementation. The root workspace declares that choice once in its openshell-crypto dependency; application crates inherit it rather than choosing a backend independently. Standalone examples select their own default through their path dependency. Disabling crate defaults removes its AWS-LC implementation and requires an explicit context before use; use without initialization fails closed with an initialization panic. This mode is for embedders and backend contract tests, not an independently runnable OpenShell product configuration.

Integration features (tls-tonic, tls-kube, tls-sqlx) activate dependencies. The aws-lc feature supplies their AWS-LC choices conditionally. These libraries still own their crypto selection internally; they do not consult CryptoContext. Cargo feature unification does not prevent direct library use. 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.

Selecting system OpenSSL

An embedder enables openssl and disables crate defaults:

openshell-crypto = { path = "crates/openshell-crypto", default-features = false, features = ["openssl"] }

Install the context before facade use or JWT initialization:

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 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 and jsonwebtoken-openssl. 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. 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

default_context() lazily selects AWS-LC when that feature is enabled. An embedder can call install_default_context(context) before first use. Reinstalling a clone of the same context succeeds; installing a different context fails even if names match. There is no live backend switching. Independent contexts can be used without changing process state, including in contract tests.

tls::provider() constructs the selected context's provider. tls::ensure_default_provider() retains an existing Rustls process default and returns it; it does not attest ownership. Directly constructed OpenShell TLS configurations use the facade provider, including the CLI health-check connector. The dependency-owned clients listed below retain their own selection paths. If a context was explicitly selected before facade use, these configurations use that context even if another library already initialized Rustls. Otherwise they preserve an embedder's installed provider. Key loading, custom verification algorithms, and the gateway client-certificate verifier use the same configuration-provider policy. Explicit protocol-version constraints remain unchanged. A provider incompatible with the requested versions fails during construction rather than falling back. This rule controls first-party configs; it cannot override dependency-owned TLS.

JWT initialization likewise preserves an existing process provider. install_jwt_provider() reports whether this call installed it; jsonwebtoken 10 has no public getter for verifying a previously installed provider. JWT adapters retain that library's algorithm/key binding and claim-validation checks. Select a custom context before JWT initialization, including indirect initialization by dependencies. Neither global provider can be replaced safely.

CryptoContext::verify_posture(false) reports the context's capabilities, not ownership of process globals. A strict request is always rejected in this stage. The module version is explicitly unknown because the backend wrapper does not provide that information through the API used here. Backend identity, enabled algorithms, and a successful operation are not deployment FIPS evidence.

Compatibility and exclusions

AEAD stores a random 12-byte nonce and appends a 16-byte tag to ciphertext. AAD construction, envelope version, key IDs, and encoding remain in the credential driver. Random nonces do not guarantee uniqueness; existing key-volume and rotation constraints still apply. Authentication failures expose no plaintext or distinction between wrong keys, AAD, nonce, ciphertext, or tags.

Coverage excludes dependency-owned SSH/russh primitives, AWS SigV4 signing, 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. Application-wide OpenSSL integration, strict policy, module version discovery, and deployment qualification remain follow-ups.

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 dependency exception inventory 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.

The remaining first-party uses require explicit classification; the facade is not a whole-application crypto coverage claim:

Remaining use Current scope and follow-up
Reqwest clients (OIDC/JWKS, token exchange, provider refresh, Vault, token grants, telemetry) These use Rustls's process-default provider after ensuring one exists; they do not necessarily honor an explicitly selected CryptoContext. The OpenSSL follow-up must supply compatible preconfigured TLS or another supported transport integration and test actual provider use.
Kubernetes client TLS (kube-client 0.99) The AWS-LC-enabled client attempts to install AWS-LC as the Rustls process default. It does not consult CryptoContext; the OpenSSL follow-up must adapt this transport before claiming selected-backend coverage.
Outer-fence evidence digest (openshell-isolation-interface::contract) The digest binds generation and native enforcement evidence using sha2 through Sha256Digest::compute. This security-relevant hash remains outside facade dispatch and must be migrated with failure propagation before claiming exclusive backend ownership.
VM image/archive digest verification and cached supervisor validation (openshell-driver-vm) Security-relevant artifact integrity remains outside this runtime credential migration. Moving it requires covering the streaming readers, embedded-artifact cache, and build-time digest generation together; it must be addressed before claiming exclusive backend ownership.
UUID generation outside Sandbox Protocol request envelopes Includes runtime identities, authentication nonces, and session identifiers as well as ordinary correlation IDs. These still use the UUID library's entropy source. They are not attested by context posture; a follow-up must classify each use and propagate entropy failure through currently infallible constructors.
DNS policy contract fingerprints (openshell-supervisor-network::policy_dns) These bind cached DNS state to policy and are security-relevant. They remain outside this migration; migrating the cache identity API and its failure handling is required for exclusive backend ownership.
Credential-storage names (credentials, Kubernetes Secrets, Vault) Deterministic namespace derivation, not encryption or proof of authorization. These retain their current hashing implementation and stored names; backend coverage is not asserted for them.
Provider-refresh advisory-lock keys Deterministic database lock selection for coordinating refreshes, not authentication or encryption. These remain outside the selected-backend contract.
Endpoint IDs and pagination fingerprints Deterministic identification/request matching, not authentication. These remain outside the selected-backend contract.
Debug token fingerprints Diagnostic display only; direct hashing remains outside the contract even though the input is a credential.
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.

The cargo-deny 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, key usage, and subject key identifier. Missing identifiers use the selected backend's SHA-256 over SPKI, truncated to 20 bytes. It rejects trailing bytes, malformed metadata, and subject names that rcgen cannot represent without loss (including repeated attribute OIDs and multi-valued RDNs). Parsing does not establish trust or verify signatures; the proxy separately checks certificate/key matching through the selected TLS backend. rcgen's x509-parser feature stays disabled: in 0.14.10 it also compiles CSR verification that requires AWS-LC or Ring. No fork or fallback backend is needed for CA import, and the parser's former Ring exception is removed from deny.toml. The standalone facade also excludes AWS-LC when default features are disabled. The build-time Z3 archive downloader retains the narrowly allowed Rustls/Ring wrappers in deny.toml; context capabilities do not attest build tools.

Extending and checking

Implement both traits without importing application crates. Retain provider/key resources for the full operation lifetime, propagate entropy and authentication failures, and never fall back after a failure. Document unsupported algorithms and avoid secret-bearing diagnostics. Add protocol adapters rather than moving certificate or authorization policy into primitive implementations.

Run cargo test -p openshell-crypto for known-answer crypto, tampering, JWT validation, key import/export, encryption compatibility, context substitution, and an actual TLS handshake after process-default preemption. cargo test -p openshell-crypto --no-default-features --test independent_backend exercises independent test-only key and AEAD implementations without AWS-LC or 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:

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.