diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2c48305b..3e09052e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,16 @@ updates: interval: "weekly" cooldown: default-days: 7 + - package-ecosystem: "cargo" + directory: "/native" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 10 + groups: + rust-development-dependencies: + dependency-type: "development" + patterns: ["*"] + +# Native vcpkg/OpenSSL baseline updates are reviewed manually because they +# change the cryptographic/runtime trusted computing base. diff --git a/.github/workflows/native-primitives.yml b/.github/workflows/native-primitives.yml new file mode 100644 index 00000000..35faffe0 --- /dev/null +++ b/.github/workflows/native-primitives.yml @@ -0,0 +1,74 @@ +name: Native Primitives Windows CI + +on: + push: + branches: [dev] + paths: + - "native/**" + - ".github/workflows/native-primitives.yml" + pull_request: + branches: [dev] + paths: + - "native/**" + - ".github/workflows/native-primitives.yml" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + shell: pwsh + working-directory: native + +jobs: + test: + runs-on: windows-2022 + timeout-minutes: 60 + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.97.1-x86_64-pc-windows-msvc + components: clippy, rustfmt + + - name: Fetch pinned vcpkg baseline + run: | + $manifest = Get-Content vcpkg.json | ConvertFrom-Json + $baseline = $manifest.'builtin-baseline' + git -C $env:VCPKG_INSTALLATION_ROOT fetch --refetch --depth=1 origin $baseline + if ($LASTEXITCODE -ne 0) { throw "Fetching the native vcpkg baseline failed." } + + - name: Configure native test harness + run: | + cmake -S . -B build -G "Visual Studio 17 2022" -A x64 ` + -DCMAKE_TOOLCHAIN_FILE="$env:VCPKG_INSTALLATION_ROOT\scripts\buildsystems\vcpkg.cmake" ` + -DVCPKG_TARGET_TRIPLET=x64-windows + if ($LASTEXITCODE -ne 0) { throw "Configuring the native test harness failed." } + + - name: Run complete matrix + run: .\scripts\test.ps1 + + dependency-policy: + runs-on: windows-2025 + timeout-minutes: 30 + + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 + with: + toolchain: 1.97.1-x86_64-pc-windows-msvc + + - uses: taiki-e/install-action@a2a5f6e99e1a31540baa0468acfa302cff0f359f # v2 + with: + tool: cargo-audit@0.22.2,cargo-deny@0.20.2 + fallback: none + + - name: Audit Rust dependencies + run: cargo audit + + - name: Enforce dependency policy + run: cargo deny check diff --git a/README.md b/README.md index cda5a1e8..d81c2095 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,12 @@ You can follow the latest updates and plans for MSAL Python in the [Roadmap](htt ## Samples and Documentation +The repository also hosts the pre-release +[Microsoft.Identity.Native.Primitives](https://github.com/AzureAD/microsoft-authentication-library-for-python/tree/dev/native) +project under `native/`. It provides Windows CNG/OpenSSL and bounded mTLS +transport primitives through a C ABI. It builds and tests independently and +is not integrated into the `msal` Python package or its PyPI distributions. + MSAL Python supports multiple [application types and authentication scenarios](https://docs.microsoft.com/azure/active-directory/develop/authentication-flows-app-scenarios). The generic documents on [Auth Scenarios](https://docs.microsoft.com/azure/active-directory/develop/authentication-scenarios) diff --git a/native/.editorconfig b/native/.editorconfig new file mode 100644 index 00000000..5a3befc0 --- /dev/null +++ b/native/.editorconfig @@ -0,0 +1,24 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{rs,c,h}] +indent_style = space +indent_size = 4 + +[*.{md,yml,yaml,json,toml}] +indent_style = space +indent_size = 2 + +[*.ps1] +end_of_line = crlf +indent_style = space +indent_size = 4 + +[CMakeLists.txt] +indent_style = space +indent_size = 4 diff --git a/native/.gitattributes b/native/.gitattributes new file mode 100644 index 00000000..e5c5f06c --- /dev/null +++ b/native/.gitattributes @@ -0,0 +1,24 @@ +* text=auto + +*.rs text eol=lf +*.c text eol=lf +*.h text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.toml text eol=lf +CMakeLists.txt text eol=lf + +*.ps1 text eol=crlf +*.bat text eol=crlf +*.cmd text eol=crlf + +*.dll binary +*.exe binary +*.lib binary +*.pdb binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary diff --git a/native/.gitignore b/native/.gitignore new file mode 100644 index 00000000..e2935a5b --- /dev/null +++ b/native/.gitignore @@ -0,0 +1,10 @@ +/build/ +/Testing/ + +/artifacts/ +/target/ + +*.log +*.pdb +*.user +*.suo diff --git a/native/AGENTS.md b/native/AGENTS.md new file mode 100644 index 00000000..66851846 --- /dev/null +++ b/native/AGENTS.md @@ -0,0 +1,107 @@ +# Microsoft.Identity.Native.Primitives instructions + +These instructions apply to the standalone native project under `native/`, +not the `msal/` Python implementation. Python package behavior and packaging +must remain independent unless a separate integration change is approved. + +## Review priorities + +- Review bugs, security issues, ownership/lifetime errors, and ABI contract + violations before style. +- Comment only on concrete, actionable issues introduced by the change. +- Prefer no review comment over a speculative security finding. +- Require regression tests for fixes and negative/adversarial tests for + security-sensitive changes where practical. + +## Native project boundary + +- Production implementation is Rust. +- Never add production Python, Node.js, or C++ implementations. +- C is allowed only for external ABI/conformance tests and minimal native + examples. +- Do not add MSAL, token acquisition, token caching, Managed Identity + orchestration, Azure Identity, or Azure SDK authentication behavior. +- Language-specific integrations are outside this native project's scope. + +## Key and cryptographic invariants + +- Never suggest exporting a private key as a workaround. +- Preserve opaque, non-exportable key semantics. +- Private-key bytes and private RSA parameters must not leave the CNG/KSP + boundary. +- Treat CNG key creation, key usage, export policy, KSP selection, + KeyGuard/VBS, and attestation changes as security-sensitive. +- Do not claim hardware/VBS/attestation guarantees unless the implementation + verifies the required policy and evidence. +- Treat algorithm, digest, padding, PSS salt, and certificate/key-binding + changes as security-sensitive. + +## Unsafe Rust, FFI, and ownership + +- Treat all unsafe and FFI code as security-critical. +- Every unsafe block requires a precise `SAFETY:` comment describing pointer, + lifetime, aliasing, threading, and ownership invariants as applicable. +- Check `Send` and `Sync` assumptions against the underlying native API. +- Check native handle ownership, partial initialization, cancellation, and + every error-path cleanup. +- Keep allocation/free pairing explicit and within the same allocator family. +- Document caller-owned versus callee-owned memory. +- Bound lengths before pointer traversal, string scanning, or allocation. +- Do not add infallible allocation to provider callbacks where allocation + failure is expected to become a structured native error. +- Preserve panic containment across exported ABI and OpenSSL callback + boundaries. + +## Native ABI + +- Public ABI changes require compatibility and versioning review. +- Check structure size, order, alignment, field types, enum numeric values, + calling convention, ownership, allocation/free rules, nullability, and field + interpretation. +- Update the public header, Rust layouts, C conformance tests, documentation, + changelog, exported-symbol checks, and + `MICROSOFT_IDENTITY_NATIVE_ABI_VERSION` when required. +- Do not add compatibility aliases or silently reinterpret existing fields + without an approved design. + +## TLS, networking, and HTTP + +- Treat TLS trust, SNI, certificate-chain, hostname, and IP verification + changes as security-sensitive. +- Do not weaken peer verification or add success-shaped fallbacks. +- Treat DNS cancellation, socket inheritance, deadlines, and concurrency as + native ownership/security concerns. +- Treat HTTP parsing and framing changes as security-sensitive. +- Do not add arbitrary redirects, proxy selection, Host-header overrides, + transfer encoding, duplicate framing, or ambiguous message lengths without + an approved design. +- Preserve bounded requests, responses, headers, and total I/O deadlines. +- Do not log tokens, private keys, credentials, secret bodies, secret + responses, or sensitive key identifiers. + +## Intentional patterns not to "fix" + +- Internal crate names such as `cng-core`, `cng-provider`, and `mtls-bridge` + intentionally describe the current implementation. +- C files under `tests/native` and `examples/c` intentionally exercise the + external compiler/ABI boundary; they are not production implementations. +- The caller supplies an already-acquired certificate-bound token; token + acquisition and token semantic validation are outside this native project. +- The raw ABI accepts a caller-selected destination; authorization belongs in + a trusted higher-level host. +- The library does not isolate mutually untrusted callers in one process. +- Exact `Content-Length` framing, no redirects, no proxy selection, no + transfer encoding, and strict header validation are intentional. +- Reserving the bounded response capacity before reading plaintext is an + intentional zeroization tradeoff; optimize it only with an approved design + that preserves plaintext cleanup guarantees. +- The explicit provider module path and private OpenSSL library context are + intentional loading and isolation choices. + +## Validation + +- Follow `CONTRIBUTING.md`, `docs/security-review.md`, and + `docs/versioning.md`. +- Run the complete Debug and Release Rust/native matrix. +- Keep formatting, Clippy, dependency audit, and dependency policy checks + clean. diff --git a/native/CHANGELOG.md b/native/CHANGELOG.md new file mode 100644 index 00000000..28152eaf --- /dev/null +++ b/native/CHANGELOG.md @@ -0,0 +1,99 @@ +# Changelog + +All user-visible changes and security fixes must be recorded in this file. +This project follows [Semantic Versioning](docs/versioning.md) for its product +version and versions its native ABI separately. + +## Unreleased + +### Added + +- Microsoft contribution, code-of-conduct, support, maintainer, ownership, and + release-governance documentation. +- Structured bug, feature, design-proposal, documentation, and pull-request + templates. +- Native ABI versioning, development, release, and security-review guides. +- Cargo and GitHub Actions Dependabot monitoring and repository-specific + Copilot instructions. + +### Changed + +- Moved the standalone native project under `native/` in the MSAL Python + repository, preserving the Rust implementation, native ABI, and lockfile. +- Expressed workspace dependency requirements as explicit compatible ranges + and documented reviewed dependency updates. +- Scoped native CI and Dependabot to the new location without integrating + native sources or binaries into the Python package. +- Adopted Microsoft Security Response Center vulnerability reporting and + Microsoft repository legal/trademark language. + +### Fixed + +- Preserve available public material when OpenSSL duplicates a public-only RSA + key with a selection that also includes unavailable private material. +- Resolve the example's provider DLL path before passing it to the ABI, which + requires absolute paths. +- Configure the pinned OpenSSL installation and native test harness before the + first quick-start build command. +- Reject digest updates that conflict with retained explicit PSS MGF1 state + before changing the signature context. +- Include IPv6 Host brackets in HTTP header/request limits and the reservation + made before copying authorization or body plaintext. +- Wipe copied request paths, STORE URIs, header values, and bodies on validation + failures by retaining them in zeroizing storage immediately after copying. +- Accept valid HTTP response token field names and surrounding Content-Length + whitespace without permitting duplicate or ambiguous framing. +- Permit secure server-initiated TLS 1.2 renegotiation for delayed client + authentication without enabling unsafe legacy renegotiation or resetting the + total I/O deadline. +- Retain the key, digest, padding, MGF1, and salt settings during null-key + signing-context reuse while resetting message hashing. +- Bound RSA modulus and exponent encodings before BIGNUM conversion, retaining + mathematical limits and supported native-word padding. +- Wipe NUL-terminated STORE URI copies, decoded identifiers, STORE descriptions, + and temporary UTF-16 provider/key names on success and failure paths. + +### Security + +- Documented mandatory review areas and evidence for unsafe Rust, FFI, CNG, + OpenSSL provider, TLS, HTTP framing, secret handling, and ABI changes. + +### Breaking + +## 0.2.0 - Pre-release + +### Added + +- Rust OpenSSL 3 provider for persisted Windows CNG RSA signing keys. +- Opaque CNG key loading through a strict `cng://` STORE URI. +- Bounded Rust TLS/HTTP transport for certificate-bound identity requests. +- Language-neutral C ABI and external C conformance tests. +- Debug and Release Windows validation, dependency auditing, and security + documentation. + +### Changed + +- Renamed the pre-release project and public native surface to + Microsoft.Identity.Native.Primitives. +- Aligned the Rust crates, CMake/vcpkg metadata, provider identity, public DLL + names, header, exported functions, and public ABI prefix at version 0.2.0. +- Clarified that token acquisition, MSAL, Azure SDK integration, and + language-specific SDKs are outside the repository scope. + +### Fixed + +- Hardened FFI string ownership, native allocation failures, signature state + updates, CNG key-policy checks, provider loading, DNS cancellation, socket + inheritance, TLS IP verification, HTTP framing, and plaintext zeroization. + +### Security + +- Enforced non-exportable private-key policy and bounded native inputs and + outputs. +- Added explicit security-boundary, deployment, release, and dependency-policy + requirements. + +### Breaking + +- Replaced the former pre-release product and ABI names without compatibility + aliases. No stable public release preceded this rename. diff --git a/native/CMakeLists.txt b/native/CMakeLists.txt new file mode 100644 index 00000000..335c1667 --- /dev/null +++ b/native/CMakeLists.txt @@ -0,0 +1,125 @@ +cmake_minimum_required(VERSION 3.21) + +project(MicrosoftIdentityNativePrimitivesNativeTests VERSION 0.2.0 LANGUAGES C) + +if(NOT WIN32) + message(FATAL_ERROR "The native test harness requires Windows CNG.") +endif() + +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) + +find_package(OpenSSL 3.6 REQUIRED COMPONENTS Crypto SSL) + +add_library(rust_native_provider MODULE IMPORTED GLOBAL) +set_target_properties(rust_native_provider PROPERTIES + IMPORTED_CONFIGURATIONS "DEBUG;RELEASE" + IMPORTED_LOCATION_DEBUG "${CMAKE_CURRENT_SOURCE_DIR}/target/debug/microsoft_identity_native_provider.dll" + IMPORTED_LOCATION_RELEASE "${CMAKE_CURRENT_SOURCE_DIR}/target/release/microsoft_identity_native_provider.dll" +) + +add_library(rust_native_primitives SHARED IMPORTED GLOBAL) +set_target_properties(rust_native_primitives PROPERTIES + IMPORTED_CONFIGURATIONS "DEBUG;RELEASE" + IMPORTED_LOCATION_DEBUG "${CMAKE_CURRENT_SOURCE_DIR}/target/debug/microsoft_identity_native_primitives.dll" + IMPORTED_IMPLIB_DEBUG "${CMAKE_CURRENT_SOURCE_DIR}/target/debug/microsoft_identity_native_primitives.dll.lib" + IMPORTED_LOCATION_RELEASE "${CMAKE_CURRENT_SOURCE_DIR}/target/release/microsoft_identity_native_primitives.dll" + IMPORTED_IMPLIB_RELEASE "${CMAKE_CURRENT_SOURCE_DIR}/target/release/microsoft_identity_native_primitives.dll.lib" +) + +add_library(cng_test_support STATIC + tests/native/support/cng_key.c +) +target_include_directories(cng_test_support + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/native/support" +) +target_link_libraries(cng_test_support PUBLIC ncrypt bcrypt) +target_compile_definitions(cng_test_support PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(native_provider_test + tests/native/cng_provider_test.c +) +target_include_directories(native_provider_test + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/native/support" +) +target_link_libraries(native_provider_test PRIVATE OpenSSL::Crypto cng_test_support) +target_compile_definitions(native_provider_test PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(native_test_pki + tests/native/cng_test_pki.c +) +target_include_directories(native_test_pki PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(native_test_pki PRIVATE OpenSSL::Crypto) +target_compile_definitions(native_test_pki PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(native_mtls_test_server + tests/native/cng_mtls_test_server.c +) +target_link_libraries(native_mtls_test_server + PRIVATE + OpenSSL::SSL + OpenSSL::Crypto + ws2_32 +) +target_compile_definitions(native_mtls_test_server PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(native_integration_test + tests/native/cng_integration_test.c +) +target_include_directories(native_integration_test + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/include" + "${CMAKE_CURRENT_SOURCE_DIR}/tests/native/support" +) +target_link_libraries(native_integration_test + PRIVATE + rust_native_primitives + cng_test_support + OpenSSL::Crypto +) +target_compile_definitions(native_integration_test PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) + +add_executable(microsoft_identity_native_example + examples/c/mtls_request.c +) +target_include_directories(microsoft_identity_native_example + PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/include" +) +target_link_libraries(microsoft_identity_native_example PRIVATE rust_native_primitives) +target_compile_definitions(microsoft_identity_native_example + PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX +) + +if(MSVC) + foreach(target + cng_test_support + native_provider_test + native_test_pki + native_mtls_test_server + native_integration_test + microsoft_identity_native_example) + target_compile_options(${target} PRIVATE /W4) + endforeach() +endif() + +include(CTest) +if(BUILD_TESTING) + add_test( + NAME native_provider_test + COMMAND native_provider_test "$" + ) + add_test( + NAME native_integration_test + COMMAND native_integration_test + "$" + "$" + "$" + "$" + "$\\test-artifacts" + ) +endif() diff --git a/native/CONTRIBUTING.md b/native/CONTRIBUTING.md new file mode 100644 index 00000000..cc8c67d2 --- /dev/null +++ b/native/CONTRIBUTING.md @@ -0,0 +1,163 @@ +# Contributing to Microsoft.Identity.Native.Primitives + +Microsoft.Identity.Native.Primitives welcomes contributions. This project +contains security-sensitive Rust, native ABI, Windows CNG, OpenSSL provider, +TLS, and HTTP code. Contributions are reviewed for correctness, security, +compatibility, and fit with the project direction. + +These requirements apply to the standalone project under `native/`. Follow the +MSAL Python repository's [contributing guide](../contributing.md) for branches, +ownership, and the common contribution process. Run the native commands in this +document from `native/`, not the repository root. + +## Contributor License Agreement + +Most contributions require you to agree to a Contributor License Agreement +(CLA) declaring that you have the right to, and actually do, grant us the +rights to use your contribution. For details, visit +. + +When you submit a pull request, a CLA bot will automatically determine whether +you need to provide a CLA and decorate the pull request appropriately. Follow +the instructions provided by the bot. You will only need to do this once +across all repositories using the Microsoft CLA. + +Contributions cannot be accepted until the required Microsoft CLA is complete. + +## Before writing code + +Small bug fixes and documentation corrections may proceed normally. Open an +issue before starting work when the expected behavior or scope is unclear. + +Submit a +[design proposal issue](https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/new) +and agree on the high-level design with the maintainers before investing heavily in a +substantial change involving any of the following: + +- the public native ABI or its ownership/versioning contract; +- Windows CNG behavior, key creation, key policy, KSP selection, or + opaque/non-exportable key behavior; +- OpenSSL provider behavior, algorithms, properties, or loading; +- cryptographic algorithms, parameters, or certificate/private-key binding; +- TLS configuration, chain validation, hostname/IP verification, or peer + verification; +- HTTP parsing, framing, redirects, proxies, or destination controls; +- native memory ownership, FFI design, unsafe Rust, threading, concurrency, or + `Send`/`Sync` assumptions; +- platform support or dependencies that enter the trusted computing base; +- a new native identity primitive; or +- a new key backend, KSP, HSM, or opaque-key abstraction. + +Early design agreement prevents contributors from spending significant effort +on an approach that does not fit the end-to-end security model or roadmap. +Substantial pull requests should reference an approved design-proposal issue. + +## Development prerequisites + +The supported development environment is: + +- Windows x64; +- Visual Studio 2022 native C/C++ build tools; +- Rust 1.97.1 for `x86_64-pc-windows-msvc`; +- CMake; +- vcpkg; and +- the project-pinned OpenSSL baseline. The current development baseline is + OpenSSL 3.6.3; production release requirements are documented separately. + +See [Development](docs/development.md) and +[Building and testing](docs/building-and-testing.md) for setup instructions. + +## Required validation + +Before opening a pull request, run: + +```powershell +cargo fmt --all -- --check +cargo check --locked --workspace --all-targets --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +cargo test --locked --workspace --all-features +cargo test --locked --release --workspace --all-features +``` + +Also run: + +- Debug native provider tests; +- Release native provider tests; +- Debug native ABI/mTLS integration tests; +- Release native ABI/mTLS integration tests; +- the project-specific CNG test-key audit; +- `cargo audit`; and +- `cargo deny check`. + +The native project scripts run the Rust and native Debug/Release matrix: + +```powershell +.\scripts\test.ps1 +cargo audit +cargo deny check +``` + +## Engineering and security bar + +The following invariants are part of the native project contract: + +- The production implementation is Rust. +- Do not introduce production Python, Node.js, or C++ code. +- C is allowed only for external ABI/conformance tests and minimal native + examples. +- This native project does not implement MSAL, token acquisition, Azure Identity, + or Azure SDK authentication flows. +- Private-key bytes must never leave the CNG/KSP boundary. +- Private-key export must remain disabled. +- Every unsafe block must include a precise `SAFETY:` comment explaining the + invariant that makes the operation valid. +- Native handle ownership must be explicit. +- FFI allocation/free pairing and caller-owned versus callee-owned memory must + be documented. +- Input lengths must be bounded before traversal or allocation. +- Tokens, secret request bodies, secret responses, private keys, and sensitive + key identifiers must not be logged. +- TLS peer verification must not be weakened. +- Arbitrary redirects, proxies, Host-header overrides, ambiguous HTTP framing, + and transfer-encoding support require an approved design. +- Public ABI changes require explicit compatibility and versioning analysis. +- Bug fixes require regression tests. +- Security-sensitive changes require negative or adversarial tests where + practical. + +Review [Security model](docs/security-model.md), +[ABI contract](docs/abi.md), [Versioning](docs/versioning.md), and +[Security review](docs/security-review.md) before changing a sensitive area. + +## Pull requests + +Pull requests must be: + +- focused on one problem; +- covered by appropriate tests; +- warning-clean; +- documented when behavior or contracts change; and +- free of unrelated refactoring. + +Describe security, unsafe/FFI, native ownership, ABI, cryptographic/CNG, TLS/HTTP, +dependency, compatibility, performance, and memory impacts even when the answer +is "none." + +Maintainers may ask for a large change to be split into independently +reviewable increments. Changes are merged only after required reviews, +validation, and CLA checks succeed. + +## Reporting bugs and requesting features + +Use the MSAL Python repository's +[GitHub Issues](https://github.com/AzureAD/microsoft-authentication-library-for-python/issues) +for native bugs, feature requests, design proposals, and documentation issues. +Identify the affected `native/` component in the issue. + +Do not report security vulnerabilities through GitHub Issues. Follow +[SECURITY.md](SECURITY.md). + +## Code of Conduct + +This project follows the +[Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). diff --git a/native/Cargo.lock b/native/Cargo.lock new file mode 100644 index 00000000..b94c6781 --- /dev/null +++ b/native/Cargo.lock @@ -0,0 +1,130 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cng-core" +version = "0.2.0" +dependencies = [ + "windows-sys", + "zeroize", +] + +[[package]] +name = "cng-provider" +version = "0.2.0" +dependencies = [ + "cng-core", + "libc", + "openssl-provider-sys", + "zeroize", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "mtls-bridge" +version = "0.2.0" +dependencies = [ + "cng-core", + "libc", + "openssl-provider-sys", + "static_assertions", + "windows-sys", + "zeroize", +] + +[[package]] +name = "openssl-provider-sys" +version = "0.2.0" +dependencies = [ + "libc", + "openssl-sys", + "static_assertions", +] + +[[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 = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "rust-tests" +version = "0.2.0" +dependencies = [ + "cng-core", + "mtls-bridge", + "openssl-provider-sys", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" diff --git a/native/Cargo.toml b/native/Cargo.toml new file mode 100644 index 00000000..6efceed8 --- /dev/null +++ b/native/Cargo.toml @@ -0,0 +1,53 @@ +[workspace] +members = [ + "crates/cng-core", + "crates/openssl-provider-sys", + "crates/cng-provider", + "crates/mtls-bridge", + "crates/rust-tests", +] +resolver = "2" + +[workspace.package] +edition = "2024" +license = "MIT" +publish = false +rust-version = "1.97" +version = "0.2.0" + +[workspace.dependencies] +libc = ">=0.2.177, <0.3" +openssl-sys = ">=0.9.110, <0.10" +static_assertions = ">=1.1.0, <2" +windows-sys = { version = ">=0.61.2, <0.62", features = [ + "Win32_Foundation", + "Win32_Globalization", + "Win32_Networking_WinSock", + "Win32_Security", + "Win32_Security_Cryptography", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_LibraryLoader", + "Win32_System_Memory", + "Win32_System_Threading", +] } +zeroize = ">=1.8.2, <2" + +[workspace.lints.rust] +unsafe_op_in_unsafe_fn = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "warn", priority = -1 } +missing_errors_doc = "allow" +missing_panics_doc = "allow" +missing_safety_doc = "allow" +module_name_repetitions = "allow" +similar_names = "allow" +too_many_lines = "allow" +undocumented_unsafe_blocks = "deny" +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_sign_loss = "allow" +format_collect = "allow" +must_use_candidate = "allow" diff --git a/native/LICENSE b/native/LICENSE new file mode 100644 index 00000000..22aed37e --- /dev/null +++ b/native/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Microsoft Corporation. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/native/README.md b/native/README.md new file mode 100644 index 00000000..a64a10a0 --- /dev/null +++ b/native/README.md @@ -0,0 +1,225 @@ +# Microsoft.Identity.Native.Primitives + +Rust-native building blocks for identity scenarios that require +platform-protected, non-exportable cryptographic keys. + +The first implemented primitives bridge Windows CNG-backed keys into OpenSSL +and provide a bounded mTLS transport for certificate-bound identity requests +without exporting private-key material. + +This standalone native project lives under `native/` in the MSAL Python +repository. It is not integrated into the `msal` package, and neither native +sources nor DLLs are included in MSAL Python's PyPI distributions. + +## What this project is + +- A Rust OpenSSL 3 provider for persisted Windows CNG RSA signing keys. +- A Rust DNS, TCP, TLS, HTTP/1.1 request, and response transport. +- A stable language-neutral C ABI exported by + `microsoft_identity_native_primitives.dll`. +- Native conformance tests that consume the Rust DLLs from an external C + compiler boundary. + +## Why it exists + +OpenSSL-based applications cannot ordinarily associate a TLS client +certificate with a non-exportable Windows CNG or KeyGuard private key. +Exporting that key would defeat its security model. + +The current CNG/OpenSSL primitives let OpenSSL request RSA signatures from CNG +while private-key bytes remain inside the Windows key-storage provider. The +Rust transport owns the complete certificate-bound request so callers do not +need to reproduce the CNG/OpenSSL integration. + +## Request flow + +```text +caller already has an mtls_pop token + | + v +caller invokes the Rust DLL through the C ABI + | + v +Rust presents the matching opaque CNG key over mTLS + | + v +resource receives the token and matching client certificate +``` + +## What this project does not do + +- No token acquisition. +- No MSAL dependency or configuration. +- No token cache. +- No Managed Identity orchestration. +- No Azure SDK integration. +- No Node.js, Python, or other language SDK. +- No language-specific authentication library. +- No authorization decision for caller-selected resources. +- No isolation between mutually untrusted code in the same process. + +Language and identity-library integrations are outside this native project's +scope. Hosting it alongside MSAL Python does not add a Python binding or change +MSAL's token acquisition behavior. + +## Repository boundary + +| Path | Purpose | +|---|---| +| `crates/cng-core` | CNG/BCrypt ownership, key policy, hashing, signing | +| `crates/cng-provider` | OpenSSL KEYMGMT, SIGNATURE, and STORE provider | +| `crates/mtls-bridge` | Bounded TLS/HTTP transport and exported ABI | +| `crates/openssl-provider-sys` | Minimal OpenSSL provider FFI | +| `include/microsoft_identity_native_primitives.h` | Stable native contract | +| `examples/c` | Minimal caller with an already-acquired token | +| `tests/native` | External C ABI/provider/mTLS conformance tests | + +There is no production C or C++ implementation. C remains only where an +external compiler boundary is the test or example being demonstrated. + +## Quick start + +With the [build prerequisites](#build-and-test) installed, configure the native +test harness and pinned OpenSSL installation before the first build. Run from +the repository root: + +```powershell +Set-Location native +$vcpkg = 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\vcpkg' +cmake -S . -B build -G 'Visual Studio 17 2022' -A x64 ` + -DCMAKE_TOOLCHAIN_FILE="$vcpkg\scripts\buildsystems\vcpkg.cmake" ` + -DVCPKG_TARGET_TRIPLET=x64-windows +``` + +Then build the Release configuration from the same `native/` directory: + +```powershell +.\scripts\build.ps1 -Configuration Release +``` + +The staged `artifacts\Release` directory contains the native programs and +libraries. A request needs: + +- `microsoft_identity_native_primitives.dll` and + `microsoft_identity_native_provider.dll`; +- the staged OpenSSL runtime (`libcrypto-3-x64.dll` and `libssl-3-x64.dll`); +- an existing persisted CNG RSA signing key; +- a PEM client-certificate chain whose leaf public key matches that CNG key; +- a PEM CA bundle used to verify the resource server; and +- an already-acquired `mtls_pop` token. + +The STORE URI identifies the persisted key: + +```text +cng://?key=&provider= +``` + +The `provider` field may be omitted to use the Microsoft Software Key Storage +Provider. Values use RFC 3986 percent encoding; `+` is not treated as a space. + +The client-certificate file must contain complete `CERTIFICATE` PEM blocks, +with the leaf certificate first and any intermediates afterward. Its leaf +public key must match the selected CNG private key. The CA bundle verifies the +server and is not a substitute for the client-certificate chain. + +The standalone C example reads its token from an environment variable only to +keep the demonstration simple: + +```powershell +$env:NATIVE_PRIMITIVES_MTLS_POP_TOKEN = '' +$providerModulePath = (Resolve-Path -LiteralPath ` + '.\artifacts\Release\microsoft_identity_native_provider.dll' ` + -ErrorAction Stop).Path + +.\artifacts\Release\microsoft_identity_native_example.exe ` + $providerModulePath ` + 'cng://?key=&provider=' ` + 'C:\absolute\path\client-chain.pem' ` + 'C:\absolute\path\ca-bundle.pem' ` + '' ` + 443 ` + '/resource/path' +``` + +Success prints the HTTP status followed by the bounded response body. +Production callers should pass the token directly from protected application +memory. Do not use environment variables, command-line arguments, logs, or +persisted files for production tokens; environment values remain in process +storage and may be inherited by child processes. + +Common failures include a missing CNG key, a certificate/key mismatch, an +invalid DNS or IP host, an authorization value that is not `mtls_pop`, an +incorrect CA bundle, a response without exact `Content-Length` framing, and a +connection or total I/O timeout. The native request returns a structured error +code, system error, and bounded diagnostic message through +`MICROSOFT_IDENTITY_NATIVE_ERROR`. + +## Build and test + +Requirements: + +- Windows x64 +- Visual Studio 2022 C++ build tools +- Rust 1.97.1 `x86_64-pc-windows-msvc` +- CMake and vcpkg + +Configure using the [quick-start steps](#quick-start) before building. + +Build and run the complete Rust/native matrix: + +```powershell +.\scripts\build.ps1 +.\scripts\test.ps1 +``` + +## Documentation + +- [Architecture](docs/architecture.md) +- [Security model](docs/security-model.md) +- [Security review](docs/security-review.md) +- [C ABI](docs/abi.md) +- [Versioning](docs/versioning.md) +- [Development](docs/development.md) +- [Building and testing](docs/building-and-testing.md) +- [Release process](docs/releasing.md) + +The project is pre-release. + +## Contributing + +Contributions are welcome. Read the repository's +[contributing guide](../contributing.md) and the +[native requirements](CONTRIBUTING.md), complete the Microsoft CLA when +required, and use a design proposal before substantial ABI, CNG, OpenSSL, +cryptographic, TLS/HTTP, unsafe/FFI, platform, or new-primitive work. + +## Support + +See [SUPPORT.md](SUPPORT.md) for the current pre-release support policy and +[CHANGELOG.md](CHANGELOG.md) for user-visible changes. + +## Security + +Do not report suspected vulnerabilities through public GitHub Issues. Follow +the [Microsoft Security Response Center reporting process](SECURITY.md). + +## Code of Conduct + +This project has adopted the +[Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). + +## Trademarks + +This project may contain trademarks or logos for projects, products, or +services. Authorized use of Microsoft trademarks or logos is subject to and +must follow +[Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). +Use of Microsoft trademarks or logos in modified versions of this project must +not cause confusion or imply Microsoft sponsorship. Any use of third-party +trademarks or logos is subject to those third parties' policies. + +## License + +Copyright (c) Microsoft Corporation. All rights reserved. + +Licensed under the [MIT License](LICENSE). diff --git a/native/SECURITY.md b/native/SECURITY.md new file mode 100644 index 00000000..12f0bb58 --- /dev/null +++ b/native/SECURITY.md @@ -0,0 +1,110 @@ +# Security policy + +Microsoft.Identity.Native.Primitives is pre-release software. Do not treat the +current API or binaries as a supported workload-isolation boundary. Only the +current `dev` branch is evaluated during pre-release development. An explicit +production support and servicing policy will be published before general +availability. + + + +## Security + +Microsoft takes the security of our software products and services seriously, +which includes all source code repositories managed through our GitHub +organizations. + +If you believe you have found a security vulnerability in any Microsoft-owned +repository that meets +[Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), +please report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues.** + +Instead, please report them to the Microsoft Security Response Center (MSRC) at +[https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). + +You should receive a response within 24 hours. If for some reason you do not, +please follow up using the messaging functionality found at the bottom of the +Activity tab on your vulnerability report on +[https://msrc.microsoft.com/report/vulnerability](https://msrc.microsoft.com/report/vulnerability/) +or via email as described in the instructions at the bottom of +[https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). +Additional information can be found at +[microsoft.com/msrc](https://www.microsoft.com/msrc) or on MSRC's +[FAQ page for reporting an issue](https://www.microsoft.com/en-us/msrc/faqs-report-an-issue). + +Please include the requested information listed below (as much as you can +provide) to help us better understand the nature and scope of the possible +issue: + +- Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, + etc.) +- Full paths of source file(s) related to the manifestation of the issue +- The location of the affected source code (tag/branch/commit or direct URL) +- Any special configuration required to reproduce the issue +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +If you are reporting for a bug bounty, more complete reports can contribute to +a higher bounty award. Please visit our +[Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for +more details about our active programs. + +## Preferred Languages + +We prefer all communications to be in English. + +## Policy + +Microsoft follows the principle of +[Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). + + + +If the MSRC reporting portal is unavailable, you may email +[secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt the +message using the key published on the +[MSRC PGP key page](https://www.microsoft.com/msrc/pgp-key-msrc). + +## Project-specific reporting information + +Include the following when relevant and safe to provide: + +- the affected version, commit, branch, or release artifact; +- Windows version and build; +- Rust and OpenSSL versions; +- selected CNG provider/KSP and non-sensitive key properties; +- whether exploitation requires code execution in the host process or access + to the persisted key; +- the native error category and system/OpenSSL error codes; +- minimal reproduction steps; and +- expected security impact. + +Do not send access tokens, private keys, private-key blobs, credentials, tenant +secrets, production certificates containing private material, or sensitive +production request/response bodies unless MSRC specifically requests them +through an approved secure channel. + +## Security-sensitive areas + +Changes in these areas require explicit security review: + +- unsafe Rust and FFI; +- the public C ABI and native allocation/free contract; +- CNG and BCrypt handle ownership; +- key creation, usage, ACL, export, KSP, and attestation policy; +- OpenSSL provider loading, algorithms, and property selection; +- certificate/private-key binding; +- TLS configuration, certificate validation, hostname/IP verification, and + peer verification; +- HTTP parsing and framing; and +- token, request, response, key identifier, and diagnostic handling. + +See [Security model](docs/security-model.md) and +[Security review](docs/security-review.md). diff --git a/native/SUPPORT.md b/native/SUPPORT.md new file mode 100644 index 00000000..cdcd09f0 --- /dev/null +++ b/native/SUPPORT.md @@ -0,0 +1,26 @@ +# Support + +Microsoft.Identity.Native.Primitives is currently pre-release software. + +Only the latest `dev` branch and explicitly published native pre-release builds +are evaluated unless a release states otherwise. No general-availability support +commitment, service-level agreement, servicing window, or backport policy is +implied. + +A production support and version-servicing policy will be established before +general availability. After GA, this document can evolve into a formal +supported-version matrix. + +## Getting help + +- Search or open + [GitHub Issues](https://github.com/AzureAD/microsoft-authentication-library-for-python/issues) + for reproducible native bugs and approved feature discussions. +- Read [CONTRIBUTING.md](CONTRIBUTING.md) before proposing or implementing a + change. +- Review [CHANGELOG.md](CHANGELOG.md) for native user-visible changes. + MSAL Python package releases do not imply a native release or native support + commitment. + +Do not use GitHub Issues for suspected vulnerabilities. Follow the +[Microsoft Security Response Center reporting process](SECURITY.md). diff --git a/native/THIRD-PARTY-NOTICES.md b/native/THIRD-PARTY-NOTICES.md new file mode 100644 index 00000000..18c81756 --- /dev/null +++ b/native/THIRD-PARTY-NOTICES.md @@ -0,0 +1,26 @@ +# Third-party notices + +Microsoft.Identity.Native.Primitives uses third-party software through Cargo +and vcpkg. The authoritative dependency versions are recorded in the committed +Cargo lockfile and vcpkg manifest. + +Dependencies and licenses are reviewed with `cargo deny`; known Rust +vulnerabilities are reviewed with `cargo audit`. These checks supplement, but +do not replace, the notice and source-distribution obligations for released +artifacts. + +## OpenSSL + +OpenSSL is licensed under the Apache License 2.0. Source, license text, and +notices are available from . + +Redistributors must preserve all licenses and notices required by the exact +dependency set included with their binaries. Release packaging should generate +an SBOM and a complete machine-derived license report rather than treating this +summary as exhaustive. + +## Release artifacts + +Release owners must review the exact Rust crates, vcpkg ports, OpenSSL runtime, +licenses, notices, and source-offer requirements associated with each release. +See [Release process](docs/releasing.md). diff --git a/native/crates/cng-core/Cargo.toml b/native/crates/cng-core/Cargo.toml new file mode 100644 index 00000000..a38d1c27 --- /dev/null +++ b/native/crates/cng-core/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "cng-core" +version.workspace = true +description = "Windows CNG key ownership, policy validation, hashing, and opaque signing primitives" +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[dependencies] +windows-sys.workspace = true +zeroize.workspace = true + +[lints] +workspace = true diff --git a/native/crates/cng-core/src/lib.rs b/native/crates/cng-core/src/lib.rs new file mode 100644 index 00000000..bdea1b5e --- /dev/null +++ b/native/crates/cng-core/src/lib.rs @@ -0,0 +1,2501 @@ +#![cfg_attr(not(windows), allow(dead_code))] + +#[cfg(not(windows))] +compile_error!("cng-core requires Windows"); + +use std::alloc::{Layout, alloc, dealloc}; +use std::ffi::c_void; +use std::fmt::{self, Write}; +use std::marker::PhantomData; +use std::ops::Deref; +use std::ptr::NonNull; +use std::ptr::{null, null_mut}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering, fence}; + +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_ALREADY_EXISTS, ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, + ERROR_PATH_NOT_FOUND, GetLastError, HANDLE, NTE_BAD_KEYSET, NTE_EXISTS, NTE_NO_MORE_ITEMS, + NTE_NOT_FOUND, WAIT_ABANDONED, WAIT_OBJECT_0, WAIT_TIMEOUT, +}; +use windows_sys::Win32::Globalization::{LCMAP_UPPERCASE, LCMapStringEx, LOCALE_NAME_INVARIANT}; +use windows_sys::Win32::Security::Cryptography::{ + BCRYPT_ALG_HANDLE, BCRYPT_HASH_HANDLE, BCRYPT_HASH_LENGTH, BCRYPT_OBJECT_LENGTH, + BCRYPT_PKCS1_PADDING_INFO, BCRYPT_PSS_PADDING_INFO, BCRYPT_RSAPRIVATE_BLOB, + BCRYPT_RSAPUBLIC_BLOB, BCRYPT_RSAPUBLIC_MAGIC, BCRYPT_SHA256_ALGORITHM, + BCRYPT_SHA384_ALGORITHM, BCRYPT_SHA512_ALGORITHM, BCRYPT_USE_SYSTEM_PREFERRED_RNG, + BCryptCloseAlgorithmProvider, BCryptCreateHash, BCryptDestroyHash, BCryptDuplicateHash, + BCryptFinishHash, BCryptGenRandom, BCryptGetProperty, BCryptHashData, + BCryptOpenAlgorithmProvider, NCRYPT_ALLOW_ALL_USAGES, NCRYPT_ALLOW_SIGNING_FLAG, + NCRYPT_EXPORT_POLICY_PROPERTY, NCRYPT_KEY_HANDLE, NCRYPT_KEY_USAGE_PROPERTY, + NCRYPT_LENGTH_PROPERTY, NCRYPT_PAD_PKCS1_FLAG, NCRYPT_PAD_PSS_FLAG, NCRYPT_PROV_HANDLE, + NCRYPT_RSA_ALGORITHM, NCRYPT_SILENT_FLAG, NCryptCreatePersistedKey, NCryptDeleteKey, + NCryptEnumKeys, NCryptExportKey, NCryptFinalizeKey, NCryptFreeBuffer, NCryptFreeObject, + NCryptGetProperty, NCryptKeyName, NCryptOpenKey, NCryptOpenStorageProvider, NCryptSetProperty, + NCryptSignHash, +}; +use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser}; +use windows_sys::Win32::System::Threading::{ + AddSIDToBoundaryDescriptor, ClosePrivateNamespace, CreateBoundaryDescriptorW, CreateMutexW, + CreatePrivateNamespaceW, DeleteBoundaryDescriptor, GetCurrentProcess, OpenPrivateNamespaceW, + OpenProcessToken, ReleaseMutex, WaitForSingleObject, +}; +use windows_sys::core::PCWSTR; +use zeroize::{Zeroize, Zeroizing}; + +pub const DEFAULT_PROVIDER_NAME: &str = "Microsoft Software Key Storage Provider"; +pub const TEST_KEY_PREFIXES: &[&str] = &[ + "openssl-cng-provider-test-", + "openssl-cng-integration-", + "openssl-cng-rust-test-", +]; +const CREATE_RACE_OPEN_ATTEMPTS: u32 = 128; +const NCRYPT_ALLOW_KEY_ATTESTATION_FLAG: u32 = 0x0000_0010; +const MIN_RSA_BITS: u32 = 2_048; +const MAX_RSA_BITS: u32 = 16_384; +const MAX_PUBLIC_BLOB_BYTES: u32 = 4_096; +const CREATE_RACE_MAX_DELAY_MS: u64 = 25; +const KEY_CREATE_MUTEX_WAIT_MS: u32 = 10_000; +const KEY_CREATE_MUTEX_PREFIX: &[u8] = b"openssl-cng-provider-key-create-"; +const KEY_CREATE_MUTEX_NAME_LEN: usize = KEY_CREATE_MUTEX_PREFIX.len() + 64 + 1; +const KEY_CREATE_MUTEX_HASH_CHUNK_UNITS: usize = 64; +const KEY_CREATE_NAMESPACE_ALIAS: &str = "openssl-cng-provider-v1"; +const KEY_CREATE_NAMESPACE_BOUNDARY: &str = "openssl-cng-provider-user-boundary-v1"; +const KEY_CREATE_NAMESPACE_OPEN_ATTEMPTS: u32 = 8; +static KEY_CREATE_NAMESPACE: Mutex = + Mutex::new(KeyCreationNamespaceState { + handle: 0, + references: 0, + }); + +const ERROR_DETAIL_LENGTH: usize = 80; + +#[derive(Clone, Eq, PartialEq)] +struct ErrorDetail { + bytes: [u8; ERROR_DETAIL_LENGTH], + length: usize, +} + +impl ErrorDetail { + const fn new() -> Self { + Self { + bytes: [0; ERROR_DETAIL_LENGTH], + length: 0, + } + } + + fn as_str(&self) -> &str { + // SAFETY: `write_str` copies only complete UTF-8 characters from valid Rust strings. + unsafe { std::str::from_utf8_unchecked(&self.bytes[..self.length]) } + } +} + +impl Write for ErrorDetail { + fn write_str(&mut self, value: &str) -> fmt::Result { + for character in value.chars() { + let mut encoded = [0_u8; 4]; + let encoded = character.encode_utf8(&mut encoded).as_bytes(); + let Some(end) = self.length.checked_add(encoded.len()) else { + break; + }; + if end > self.bytes.len() { + break; + } + self.bytes[self.length..end].copy_from_slice(encoded); + self.length = end; + } + Ok(()) + } +} + +#[derive(Clone, Eq, PartialEq)] +pub struct CngError { + operation: &'static str, + status: i32, + detail: ErrorDetail, + memory_failure: bool, +} + +impl fmt::Debug for CngError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + let detail = (self.detail.length != 0).then(|| self.detail.as_str()); + formatter + .debug_struct("CngError") + .field("operation", &self.operation) + .field("status", &self.status) + .field("detail", &detail) + .field("memory_failure", &self.memory_failure) + .finish() + } +} + +impl CngError { + #[must_use] + pub const fn status(&self) -> i32 { + self.status + } + + fn from_status(operation: &'static str, status: i32) -> Self { + Self { + operation, + status, + detail: ErrorDetail::new(), + memory_failure: false, + } + } + + fn detail(operation: &'static str, detail: impl fmt::Display) -> Self { + let mut buffer = ErrorDetail::new(); + let _ = write!(&mut buffer, "{detail}"); + Self { + operation, + status: 0, + detail: buffer, + memory_failure: false, + } + } + + fn with_operation(mut self, operation: &'static str) -> Self { + self.operation = operation; + self + } + + #[must_use] + pub const fn memory_failure(operation: &'static str) -> Self { + Self { + operation, + status: 0, + detail: ErrorDetail::new(), + memory_failure: true, + } + } + + #[must_use] + pub const fn is_memory_failure(&self) -> bool { + self.memory_failure + } +} + +impl fmt::Display for CngError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.memory_failure { + write!(formatter, "{}: memory allocation failed", self.operation) + } else if self.detail.length != 0 { + write!(formatter, "{}: {}", self.operation, self.detail.as_str()) + } else { + write!( + formatter, + "{} failed with status 0x{:08X}", + self.operation, self.status as u32 + ) + } + } +} + +impl std::error::Error for CngError {} + +struct Shared { + references: AtomicUsize, + value: T, +} + +struct FallibleArc { + pointer: NonNull>, + marker: PhantomData>, +} + +impl FallibleArc { + fn try_new(value: T) -> Result { + let layout = Layout::new::>(); + // SAFETY: `layout` describes one `Shared` and a null allocation is handled below. + let pointer = unsafe { alloc(layout).cast::>() }; + let Some(pointer) = NonNull::new(pointer) else { + return Err(value); + }; + // SAFETY: `pointer` is aligned and allocated for one `Shared`. + unsafe { + pointer.as_ptr().write(Shared { + references: AtomicUsize::new(1), + value, + }); + } + Ok(Self { + pointer, + marker: PhantomData, + }) + } +} + +impl Clone for FallibleArc { + fn clone(&self) -> Self { + // SAFETY: a live `FallibleArc` keeps the shared allocation initialized. + let references = unsafe { &self.pointer.as_ref().references }; + let previous = references.fetch_add(1, Ordering::Relaxed); + if previous > isize::MAX as usize { + std::process::abort(); + } + Self { + pointer: self.pointer, + marker: PhantomData, + } + } +} + +impl Deref for FallibleArc { + type Target = T; + + fn deref(&self) -> &Self::Target { + // SAFETY: every live `FallibleArc` owns one reference to the initialized allocation. + unsafe { &self.pointer.as_ref().value } + } +} + +impl Drop for FallibleArc { + fn drop(&mut self) { + // SAFETY: a live `FallibleArc` keeps the shared allocation initialized. + let references = unsafe { &self.pointer.as_ref().references }; + if references.fetch_sub(1, Ordering::Release) != 1 { + return; + } + fence(Ordering::Acquire); + // SAFETY: this was the final reference, so no other thread can access the value. The + // allocation uses the same layout passed to `alloc`. + unsafe { + std::ptr::drop_in_place(self.pointer.as_ptr()); + dealloc(self.pointer.as_ptr().cast(), Layout::new::>()); + } + } +} + +// SAFETY: shared immutable access requires `T: Sync`, and final destruction may occur on any +// thread only when `T: Send`. +unsafe impl Send for FallibleArc {} +// SAFETY: shared immutable access is safe when `T: Sync`; ownership can cross threads when +// `T: Send`. +unsafe impl Sync for FallibleArc {} + +fn try_zeroed_bytes( + length: usize, + operation: &'static str, +) -> Result>, CngError> { + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(length) + .map_err(|_| CngError::memory_failure(operation))?; + bytes.resize(length, 0); + Ok(Zeroizing::new(bytes)) +} + +fn try_copy_bytes(value: &[u8], operation: &'static str) -> Result, CngError> { + let mut copy = Vec::new(); + copy.try_reserve_exact(value.len()) + .map_err(|_| CngError::memory_failure(operation))?; + copy.extend_from_slice(value); + Ok(copy) +} + +fn try_copy_str(value: &str, operation: &'static str) -> Result { + let mut copy = String::new(); + copy.try_reserve_exact(value.len()) + .map_err(|_| CngError::memory_failure(operation))?; + copy.push_str(value); + Ok(copy) +} + +fn wide(value: &str, operation: &'static str) -> Result>, CngError> { + if value.is_empty() || value.contains('\0') { + return Err(CngError::detail( + operation, + "a non-empty name without NUL characters is required", + )); + } + let length = value + .encode_utf16() + .count() + .checked_add(1) + .ok_or_else(|| CngError::memory_failure(operation))?; + let mut wide = Vec::new(); + wide.try_reserve_exact(length) + .map_err(|_| CngError::memory_failure(operation))?; + wide.extend(value.encode_utf16()); + wide.push(0); + Ok(Zeroizing::new(wide)) +} + +fn security_bits(bits: u32) -> u32 { + match bits { + 15_360.. => 256, + 7_680.. => 192, + 3_072.. => 128, + 2_048.. => 112, + 1_024.. => 80, + _ => 0, + } +} + +fn missing_key(status: i32) -> bool { + status == NTE_BAD_KEYSET || status == NTE_NOT_FOUND +} + +struct KeyCreationNamespaceState { + handle: usize, + references: usize, +} + +struct KeyCreationNamespace; + +impl KeyCreationNamespace { + fn open_handle() -> Result { + let boundary_name = wide( + KEY_CREATE_NAMESPACE_BOUNDARY, + "CNG key creation private namespace", + )?; + let alias = wide( + KEY_CREATE_NAMESPACE_ALIAS, + "CNG key creation private namespace", + )?; + // SAFETY: the boundary name is live and NUL-terminated. + let mut boundary = unsafe { CreateBoundaryDescriptorW(boundary_name.as_ptr(), 0) }; + if boundary.is_null() { + // SAFETY: GetLastError immediately follows the failed boundary creation. + return Err(CngError::from_status( + "CreateBoundaryDescriptorW(CNG key creation)", + unsafe { GetLastError() } as i32, + )); + } + let result = (|| { + add_current_user_boundary_sid(&mut boundary)?; + for attempt in 0..KEY_CREATE_NAMESPACE_OPEN_ATTEMPTS { + // SAFETY: boundary and alias are live; null attributes request the token's + // default security descriptor within the current-user boundary. + let namespace = + unsafe { CreatePrivateNamespaceW(null(), boundary.cast(), alias.as_ptr()) }; + if !namespace.is_null() { + return Ok(namespace); + } + // SAFETY: GetLastError immediately follows CreatePrivateNamespaceW. + let status = unsafe { GetLastError() }; + if status != ERROR_ALREADY_EXISTS { + return Err(CngError::from_status( + "CreatePrivateNamespaceW(CNG key creation)", + status as i32, + )); + } + // SAFETY: boundary and alias are the same current-user namespace identity. + let namespace = unsafe { OpenPrivateNamespaceW(boundary.cast(), alias.as_ptr()) }; + if !namespace.is_null() { + return Ok(namespace); + } + // SAFETY: GetLastError immediately follows OpenPrivateNamespaceW. + let status = unsafe { GetLastError() }; + if !matches!(status, ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND) + || attempt + 1 == KEY_CREATE_NAMESPACE_OPEN_ATTEMPTS + { + return Err(CngError::from_status( + "OpenPrivateNamespaceW(CNG key creation)", + status as i32, + )); + } + std::thread::yield_now(); + } + unreachable!("private namespace retry loop always returns") + })(); + // SAFETY: boundary is owned by this function and no longer needed after namespace open. + unsafe { DeleteBoundaryDescriptor(boundary) }; + result + } + + fn acquire() -> Result { + let mut state = KEY_CREATE_NAMESPACE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let references = state.references.checked_add(1).ok_or_else(|| { + CngError::detail( + "CNG key creation private namespace", + "too many concurrent namespace users", + ) + })?; + if state.references == 0 { + if state.handle != 0 { + return Err(CngError::detail( + "CNG key creation private namespace", + "the process-local namespace state is inconsistent", + )); + } + state.handle = Self::open_handle()? as usize; + } else if state.handle == 0 { + return Err(CngError::detail( + "CNG key creation private namespace", + "the process-local namespace handle is unavailable", + )); + } + state.references = references; + Ok(Self) + } +} + +impl Drop for KeyCreationNamespace { + fn drop(&mut self) { + let mut state = KEY_CREATE_NAMESPACE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.references == 0 { + return; + } + state.references -= 1; + if state.references == 0 { + let handle = std::mem::take(&mut state.handle); + if handle != 0 { + // Keep the manager locked until the alias is unregistered so another local + // acquisition cannot race the final close and fail with ERROR_DUP_NAME. + // SAFETY: the final process-local namespace lease owns this handle. Flags zero + // close only this handle and do not destroy the namespace while another process + // uses it. + let _ = unsafe { ClosePrivateNamespace(handle as HANDLE, 0) }; + } + } + } +} + +fn add_current_user_boundary_sid(boundary: &mut HANDLE) -> Result<(), CngError> { + let mut token = null_mut(); + // SAFETY: current process pseudo-handle is valid and token output storage is writable. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) } == 0 { + // SAFETY: GetLastError immediately follows OpenProcessToken. + return Err(CngError::from_status( + "OpenProcessToken(CNG key creation)", + unsafe { GetLastError() } as i32, + )); + } + let result = (|| { + let mut length = 0_u32; + // SAFETY: the first call intentionally requests the required TokenUser buffer length. + let sizing = + unsafe { GetTokenInformation(token, TokenUser, null_mut(), 0, &raw mut length) }; + // SAFETY: GetLastError immediately follows the sizing query when it fails. + let sizing_error = unsafe { GetLastError() }; + if sizing != 0 || sizing_error != ERROR_INSUFFICIENT_BUFFER || length == 0 { + return Err(CngError::from_status( + "GetTokenInformation(CNG key creation size)", + sizing_error as i32, + )); + } + let length = usize::try_from(length) + .map_err(|_| CngError::memory_failure("CNG key creation token information"))?; + let units = length + .checked_add(size_of::() - 1) + .map(|value| value / size_of::()) + .ok_or_else(|| CngError::memory_failure("CNG key creation token information"))?; + let mut information = Vec::::new(); + information + .try_reserve_exact(units) + .map_err(|_| CngError::memory_failure("CNG key creation token information"))?; + information.resize(units, 0); + let mut written = 0_u32; + // SAFETY: the aligned buffer is writable for at least `length` bytes. + if unsafe { + GetTokenInformation( + token, + TokenUser, + information.as_mut_ptr().cast(), + u32::try_from(length).unwrap_or(u32::MAX), + &raw mut written, + ) + } == 0 + { + // SAFETY: GetLastError immediately follows GetTokenInformation. + return Err(CngError::from_status( + "GetTokenInformation(CNG key creation)", + unsafe { GetLastError() } as i32, + )); + } + if usize::try_from(written).ok() != Some(length) { + return Err(CngError::detail( + "GetTokenInformation(CNG key creation)", + "Windows returned an inconsistent TokenUser length", + )); + } + // SAFETY: successful TokenUser output starts with an aligned TOKEN_USER structure. + let user = unsafe { &*information.as_ptr().cast::() }; + // SAFETY: boundary is live and TokenUser contains a live SID in `information`. + if unsafe { AddSIDToBoundaryDescriptor(boundary, user.User.Sid) } == 0 { + // SAFETY: GetLastError immediately follows AddSIDToBoundaryDescriptor. + return Err(CngError::from_status( + "AddSIDToBoundaryDescriptor(CNG key creation)", + unsafe { GetLastError() } as i32, + )); + } + Ok(()) + })(); + // SAFETY: token is owned by this function. + unsafe { + let _ = CloseHandle(token); + } + result +} + +fn private_mutex_name(name: &[u16]) -> Result, CngError> { + let alias = wide( + KEY_CREATE_NAMESPACE_ALIAS, + "CNG key creation private mutex name", + )?; + let length = alias + .len() + .checked_add(name.len()) + .ok_or_else(|| CngError::memory_failure("CNG key creation private mutex name"))?; + let mut qualified = Vec::new(); + qualified + .try_reserve_exact(length) + .map_err(|_| CngError::memory_failure("CNG key creation private mutex name"))?; + qualified.extend_from_slice(&alias[..alias.len() - 1]); + qualified.push(u16::from(b'\\')); + qualified.extend_from_slice(name); + Ok(qualified) +} + +struct KeyCreationMutexGuard { + handle: HANDLE, + acquired: bool, + namespace: Option, +} + +impl KeyCreationMutexGuard { + fn acquire(name: &[u16]) -> Result { + let namespace = KeyCreationNamespace::acquire()?; + let qualified_name = private_mutex_name(name)?; + // SAFETY: the qualified name is live and NUL-terminated; private namespace security is + // constrained by the current-user boundary. + let handle = unsafe { CreateMutexW(null(), 0, qualified_name.as_ptr()) }; + if handle.is_null() { + // SAFETY: GetLastError has no preconditions and immediately follows CreateMutexW. + return Err(CngError::from_status( + "CreateMutexW(CNG key creation)", + unsafe { GetLastError() } as i32, + )); + } + + let mut guard = Self { + handle, + acquired: false, + namespace: Some(namespace), + }; + // SAFETY: `handle` is a live mutex handle owned by `guard`. + let wait = unsafe { WaitForSingleObject(handle, KEY_CREATE_MUTEX_WAIT_MS) }; + match wait { + WAIT_OBJECT_0 | WAIT_ABANDONED => { + guard.acquired = true; + Ok(guard) + } + WAIT_TIMEOUT => Err(CngError::detail( + "CNG key creation mutex wait", + format_args!("timed out after {KEY_CREATE_MUTEX_WAIT_MS} ms"), + )), + _ => { + // SAFETY: GetLastError has no preconditions and immediately follows a failed wait. + Err(CngError::from_status( + "WaitForSingleObject(CNG key creation mutex)", + unsafe { GetLastError() } as i32, + )) + } + } + } +} + +impl Drop for KeyCreationMutexGuard { + fn drop(&mut self) { + if self.acquired { + // SAFETY: this process owns the mutex after a successful or abandoned wait. + let _ = unsafe { ReleaseMutex(self.handle) }; + } + if !self.handle.is_null() { + // SAFETY: `handle` is owned by this guard and closed exactly once. + let _ = unsafe { CloseHandle(self.handle) }; + self.handle = null_mut(); + } + drop(self.namespace.take()); + } +} + +fn windows_ordinal_uppercase(value: &[u16]) -> Result, CngError> { + let source_length = i32::try_from(value.len()).map_err(|_| { + CngError::detail( + "CNG key creation mutex name", + "a provider or key name exceeds the Windows casing limit", + ) + })?; + // SAFETY: the nonempty source slice is readable for `source_length` UTF-16 code units. The + // null destination with zero length requests the exact output size. + let required = unsafe { + LCMapStringEx( + LOCALE_NAME_INVARIANT, + LCMAP_UPPERCASE, + value.as_ptr(), + source_length, + null_mut(), + 0, + null(), + null(), + 0, + ) + }; + if required == 0 { + // SAFETY: GetLastError has no preconditions and immediately follows LCMapStringEx. + return Err(CngError::from_status( + "CNG key creation mutex name", + unsafe { GetLastError() } as i32, + )); + } + let required = usize::try_from(required).map_err(|_| { + CngError::detail( + "CNG key creation mutex name", + "Windows returned an invalid uppercase mapping length", + ) + })?; + let mut uppercase = Vec::new(); + uppercase + .try_reserve_exact(required) + .map_err(|_| CngError::memory_failure("CNG key creation mutex name"))?; + uppercase.resize(required, 0); + // SAFETY: the source remains readable and `uppercase` is writable for exactly `required` + // UTF-16 code units, as returned by the sizing call above. + let written = unsafe { + LCMapStringEx( + LOCALE_NAME_INVARIANT, + LCMAP_UPPERCASE, + value.as_ptr(), + source_length, + uppercase.as_mut_ptr(), + i32::try_from(required).map_err(|_| { + CngError::detail( + "CNG key creation mutex name", + "Windows returned an invalid uppercase mapping length", + ) + })?, + null(), + null(), + 0, + ) + }; + if written == 0 { + // SAFETY: GetLastError has no preconditions and immediately follows LCMapStringEx. + return Err(CngError::from_status( + "CNG key creation mutex name", + unsafe { GetLastError() } as i32, + )); + } + if usize::try_from(written).ok() != Some(required) { + return Err(CngError::detail( + "CNG key creation mutex name", + "Windows changed the uppercase mapping length between calls", + )); + } + Ok(uppercase) +} + +fn hash_mutex_name_field( + hash: &mut HashState, + field: &[u8], + value: &[u16], +) -> Result<(), CngError> { + let uppercase = windows_ordinal_uppercase(value)?; + let length = u64::try_from(uppercase.len()).map_err(|_| { + CngError::detail( + "CNG key creation mutex name", + "a canonical provider or key name is too long", + ) + })?; + hash.update(field)?; + hash.update(&length.to_le_bytes())?; + + let mut encoded = [0_u8; KEY_CREATE_MUTEX_HASH_CHUNK_UNITS * 2]; + for chunk in uppercase.chunks(KEY_CREATE_MUTEX_HASH_CHUNK_UNITS) { + for (target, unit) in encoded.chunks_exact_mut(2).zip(chunk.iter().copied()) { + target.copy_from_slice(&unit.to_le_bytes()); + } + hash.update(&encoded[..chunk.len() * 2])?; + } + Ok(()) +} + +fn key_creation_mutex_name( + provider_name: &[u16], + key_name: &[u16], +) -> Result<[u16; KEY_CREATE_MUTEX_NAME_LEN], CngError> { + let digest = (|| { + let mut hash = HashState::new(Digest::Sha256)?; + hash.update(b"openssl-cng-provider:key-create-mutex:v2\0")?; + hash_mutex_name_field(&mut hash, b"provider\0", provider_name)?; + hash_mutex_name_field(&mut hash, b"key\0", key_name)?; + hash.finish_clone() + })() + .map_err(|error| error.with_operation("CNG key creation mutex name"))?; + + let mut name = [0_u16; KEY_CREATE_MUTEX_NAME_LEN]; + for (target, value) in name.iter_mut().zip(KEY_CREATE_MUTEX_PREFIX.iter().copied()) { + *target = u16::from(value); + } + let mut offset = KEY_CREATE_MUTEX_PREFIX.len(); + for value in digest.iter().copied() { + const HEX: &[u8; 16] = b"0123456789abcdef"; + name[offset] = u16::from(HEX[usize::from(value >> 4)]); + name[offset + 1] = u16::from(HEX[usize::from(value & 0x0f)]); + offset += 2; + } + debug_assert_eq!(offset + 1, name.len()); + Ok(name) +} + +fn read_u32_property( + key: NCRYPT_KEY_HANDLE, + property: PCWSTR, + operation: &'static str, +) -> Result { + let mut value = 0_u32; + let mut written = 0_u32; + // SAFETY: `value` is writable for four bytes, `written` is valid, and `key` is held alive + // by the caller for the duration of the property query. + let status = unsafe { + NCryptGetProperty( + key, + property, + (&raw mut value).cast(), + size_of::() as u32, + &raw mut written, + 0, + ) + }; + if status != 0 { + return Err(CngError::from_status(operation, status)); + } + if written as usize != size_of::() { + return Err(CngError::detail( + operation, + "CNG returned an unexpected property size", + )); + } + Ok(value) +} + +fn read_le_u32(bytes: &[u8], offset: usize) -> Option { + let value: [u8; 4] = bytes.get(offset..offset + 4)?.try_into().ok()?; + Some(u32::from_le_bytes(value)) +} + +struct KeyInner { + provider: NCRYPT_PROV_HANDLE, + key: NCRYPT_KEY_HANDLE, + provider_name: Zeroizing, + key_name: Zeroizing, + bits: u32, + security_bits: u32, + modulus: Vec, + exponent: Vec, + sign_lock: Mutex<()>, +} + +// SAFETY: NCrypt handles are process-local opaque handles. All signing calls using `key` are +// serialized by `sign_lock`; the remaining operations are immutable metadata reads. Drop runs +// exactly once through Arc ownership. +unsafe impl Send for KeyInner {} +// SAFETY: The same synchronization and immutable-access invariant used for `Send` makes shared +// references safe across threads. +unsafe impl Sync for KeyInner {} + +impl Drop for KeyInner { + fn drop(&mut self) { + if self.key != 0 { + // SAFETY: `key` is an owned NCrypt handle and is released exactly once here. + let _ = unsafe { NCryptFreeObject(self.key) }; + } + if self.provider != 0 { + // SAFETY: `provider` is an owned NCrypt handle and is released exactly once here. + let _ = unsafe { NCryptFreeObject(self.provider) }; + } + self.modulus.zeroize(); + self.exponent.zeroize(); + } +} + +#[derive(Clone)] +pub struct CngKey(FallibleArc); + +impl fmt::Debug for CngKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("CngKey") + .field("provider_name", &self.provider_name()) + .field("key_name", &self.key_name()) + .field("bits", &self.bits()) + .finish_non_exhaustive() + } +} + +impl CngKey { + pub fn open(provider_name: &str, key_name: &str) -> Result { + Self::open_internal(provider_name, key_name, None) + } + + pub fn open_or_create( + provider_name: &str, + key_name: &str, + bits: u32, + ) -> Result { + if !(MIN_RSA_BITS..=MAX_RSA_BITS).contains(&bits) { + return Err(CngError::detail( + "CngKey::open_or_create", + format_args!("RSA keys must be between {MIN_RSA_BITS} and {MAX_RSA_BITS} bits"), + )); + } + Self::open_internal(provider_name, key_name, Some(bits)) + } + + fn open_internal( + provider_name: &str, + key_name: &str, + create_bits: Option, + ) -> Result { + let provider_name = if provider_name.is_empty() { + DEFAULT_PROVIDER_NAME + } else { + provider_name + }; + let provider_wide = wide(provider_name, "provider name conversion")?; + let key_wide = wide(key_name, "key name conversion")?; + let create_request = create_bits + .map(|bits| { + key_creation_mutex_name( + &provider_wide[..provider_wide.len() - 1], + &key_wide[..key_wide.len() - 1], + ) + .map(|mutex_name| (bits, mutex_name)) + }) + .transpose()?; + let mut provider = 0; + let mut key = 0; + let mut created = false; + let mut delete_created_on_failure = false; + let mut create_guard = None; + + // SAFETY: output handle storage and both NUL-terminated UTF-16 strings are valid. + let status = + unsafe { NCryptOpenStorageProvider(&raw mut provider, provider_wide.as_ptr(), 0) }; + if status != 0 { + return Err(CngError::from_status("NCryptOpenStorageProvider", status)); + } + + // SAFETY: `provider` is live and the output/key-name pointers are valid. + let mut status = unsafe { + NCryptOpenKey( + provider, + &raw mut key, + key_wide.as_ptr(), + 0, + NCRYPT_SILENT_FLAG, + ) + }; + if missing_key(status) { + let Some((bits, mutex_name)) = create_request.as_ref() else { + // SAFETY: `provider` is owned by this function and has not been freed. + let _ = unsafe { NCryptFreeObject(provider) }; + return Err(CngError::detail( + "NCryptOpenKey", + "the named CNG key does not exist", + )); + }; + create_guard = Some(match KeyCreationMutexGuard::acquire(mutex_name) { + Ok(guard) => guard, + Err(error) => { + // SAFETY: `provider` is owned by this function and has not been freed. + let _ = unsafe { NCryptFreeObject(provider) }; + return Err(error); + } + }); + // Another caller may have finalized the key while this process waited. + // SAFETY: provider, output storage, and the NUL-terminated key name remain live. + status = unsafe { + NCryptOpenKey( + provider, + &raw mut key, + key_wide.as_ptr(), + 0, + NCRYPT_SILENT_FLAG, + ) + }; + if missing_key(status) { + // SAFETY: `provider` and UTF-16 key name are live, and `key` is writable. + status = unsafe { + NCryptCreatePersistedKey( + provider, + &raw mut key, + NCRYPT_RSA_ALGORITHM, + key_wide.as_ptr(), + 0, + 0, + ) + }; + if status == NTE_EXISTS { + if key != 0 { + // SAFETY: a failed concurrent create may still return a losing handle. It + // was not created by this caller and must be released, never deleted. + let _ = unsafe { NCryptFreeObject(key) }; + key = 0; + } + status = open_after_create_race(provider, &key_wide, &mut key); + } else if status == 0 { + created = true; + delete_created_on_failure = true; + let usage = NCRYPT_ALLOW_SIGNING_FLAG; + let export_policy = 0_u32; + // SAFETY: each property pointer references a four-byte value for the duration + // of the call, and `key` is a valid unfinalized key handle. + status = unsafe { + NCryptSetProperty( + key, + NCRYPT_LENGTH_PROPERTY, + (&raw const *bits).cast(), + size_of::() as u32, + 0, + ) + }; + if status == 0 { + // SAFETY: same property-buffer and live-handle invariant as above. + status = unsafe { + NCryptSetProperty( + key, + NCRYPT_KEY_USAGE_PROPERTY, + (&raw const usage).cast(), + size_of::() as u32, + 0, + ) + }; + } + if status == 0 { + // SAFETY: same property-buffer and live-handle invariant as above. + status = unsafe { + NCryptSetProperty( + key, + NCRYPT_EXPORT_POLICY_PROPERTY, + (&raw const export_policy).cast(), + size_of::() as u32, + 0, + ) + }; + } + if status == 0 { + // SAFETY: `key` is a valid unfinalized persisted key. + status = unsafe { NCryptFinalizeKey(key, NCRYPT_SILENT_FLAG) }; + } + if status == 0 { + // Once finalized, another caller can open this persisted key. Never delete + // it during later local setup failure. + delete_created_on_failure = false; + } + if status == NTE_EXISTS { + // Another caller finalized the same name first. This handle is the losing + // unfinalized state: release it without deleting the winner, then open that + // finalized key with bounded retry/backoff. + if key != 0 { + // SAFETY: the unfinalized losing handle is owned by this caller. + let _ = unsafe { NCryptFreeObject(key) }; + key = 0; + } + created = false; + delete_created_on_failure = false; + status = open_after_create_race(provider, &key_wide, &mut key); + } + } + } + } + + if status != 0 { + if delete_created_on_failure && key != 0 { + // SAFETY: only this caller's unfinalized key is eligible for deletion. + if unsafe { NCryptDeleteKey(key, 0) } == 0 { + key = 0; + } + } + if key != 0 { + // SAFETY: the key handle is owned and has not otherwise been released. + let _ = unsafe { NCryptFreeObject(key) }; + } + // SAFETY: the provider handle is owned and has not otherwise been released. + let _ = unsafe { NCryptFreeObject(provider) }; + return Err(CngError::from_status( + if created { + "CNG key creation/finalization" + } else { + "NCryptOpenKey" + }, + status, + )); + } + let result = Self::finish_open(provider, key, provider_name, key_name, create_bits); + if result.is_err() { + if delete_created_on_failure && key != 0 { + // SAFETY: only this caller's unfinalized key is eligible for deletion. + if unsafe { NCryptDeleteKey(key, 0) } == 0 { + key = 0; + } + } + if key != 0 { + // SAFETY: the handle remains owned here. + let _ = unsafe { NCryptFreeObject(key) }; + } + // SAFETY: the provider handle remains owned here. + let _ = unsafe { NCryptFreeObject(provider) }; + } + drop(create_guard); + result + } + + fn finish_open( + provider: NCRYPT_PROV_HANDLE, + key: NCRYPT_KEY_HANDLE, + provider_name: &str, + key_name: &str, + requested_bits: Option, + ) -> Result { + let export_policy = read_u32_property( + key, + NCRYPT_EXPORT_POLICY_PROPERTY, + "NCryptGetProperty(Export Policy)", + )?; + if export_policy != 0 { + return Err(CngError::detail( + "CngKey::open", + format_args!( + "the persisted key has export policy 0x{export_policy:08X}; zero is required" + ), + )); + } + + let usage = read_u32_property( + key, + NCRYPT_KEY_USAGE_PROPERTY, + "NCryptGetProperty(Key Usage)", + )?; + let allowed_usage = NCRYPT_ALLOW_SIGNING_FLAG | NCRYPT_ALLOW_KEY_ATTESTATION_FLAG; + if usage == NCRYPT_ALLOW_ALL_USAGES + || usage & NCRYPT_ALLOW_SIGNING_FLAG == 0 + || usage & !allowed_usage != 0 + { + return Err(CngError::detail( + "CngKey::open", + format_args!( + "the persisted key usage 0x{usage:08X} is not restricted to signing and optional attestation" + ), + )); + } + + let (bits, exponent, modulus) = export_public_blob(key)?; + let key_security_bits = security_bits(bits); + if !(MIN_RSA_BITS..=MAX_RSA_BITS).contains(&bits) || key_security_bits < 112 { + return Err(CngError::detail( + "CngKey::open", + format_args!("the persisted RSA key is too small ({bits} bits)"), + )); + } + if requested_bits.is_some_and(|expected| expected != bits) { + return Err(CngError::detail( + "CngKey::open_or_create", + format_args!( + "the persisted key is {bits} bits, not the requested {} bits", + requested_bits.unwrap_or_default() + ), + )); + } + + let provider_name = Zeroizing::new(try_copy_str( + provider_name, + "CngKey provider name retention", + )?); + let key_name = Zeroizing::new(try_copy_str(key_name, "CngKey key name retention")?); + let inner = KeyInner { + provider, + key, + provider_name, + key_name, + bits, + security_bits: key_security_bits, + modulus, + exponent, + sign_lock: Mutex::new(()), + }; + match FallibleArc::try_new(inner) { + Ok(inner) => Ok(Self(inner)), + Err(mut inner) => { + // The caller still owns and releases the handles when `finish_open` fails. + inner.key = 0; + inner.provider = 0; + drop(inner); + Err(CngError::memory_failure("CngKey shared ownership")) + } + } + } + + #[must_use] + pub fn provider_name(&self) -> &str { + self.0.provider_name.as_str() + } + + #[must_use] + pub fn key_name(&self) -> &str { + self.0.key_name.as_str() + } + + #[must_use] + pub fn bits(&self) -> u32 { + self.0.bits + } + + #[must_use] + pub fn security_bits(&self) -> u32 { + self.0.security_bits + } + + #[must_use] + pub fn max_signature_size(&self) -> usize { + self.0.modulus.len() + } + + #[must_use] + pub fn public_modulus(&self) -> &[u8] { + &self.0.modulus + } + + #[must_use] + pub fn public_exponent(&self) -> &[u8] { + &self.0.exponent + } + + pub fn sign_digest( + &self, + digest: Digest, + padding: RsaPadding, + hash: &[u8], + output: Option<&mut [u8]>, + ) -> Result { + if hash.len() != digest.output_len() { + return Err(CngError::detail( + "NCryptSignHash", + "the digest length does not match the selected algorithm", + )); + } + let mut required = 0_u32; + let (padding_info, flags): (*const c_void, u32); + let pkcs1; + let pss; + match padding { + RsaPadding::Pkcs1 => { + pkcs1 = BCRYPT_PKCS1_PADDING_INFO { + pszAlgId: digest.bcrypt_name(), + }; + padding_info = (&raw const pkcs1).cast(); + flags = NCRYPT_PAD_PKCS1_FLAG; + } + RsaPadding::Pss { salt_len } => { + pss = BCRYPT_PSS_PADDING_INFO { + pszAlgId: digest.bcrypt_name(), + cbSalt: salt_len, + }; + padding_info = (&raw const pss).cast(); + flags = NCRYPT_PAD_PSS_FLAG; + } + } + + let _guard = self + .0 + .sign_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // SAFETY: the key and padding structure remain live under the mutex, the digest slice is + // readable, and output is intentionally null for the size query. + let status = unsafe { + NCryptSignHash( + self.0.key, + padding_info, + hash.as_ptr(), + hash.len() as u32, + null_mut(), + 0, + &raw mut required, + flags | NCRYPT_SILENT_FLAG, + ) + }; + if status != 0 { + return Err(CngError::from_status("NCryptSignHash(size)", status)); + } + let expected = self.max_signature_size(); + if required as usize != expected { + return Err(CngError::detail( + "NCryptSignHash(size)", + format_args!( + "the KSP reported a {required}-byte signature for a {expected}-byte RSA modulus" + ), + )); + } + + let Some(output) = output else { + return Ok(expected); + }; + if output.len() < required as usize || output.len() > u32::MAX as usize { + return Err(CngError::detail( + "NCryptSignHash", + format_args!( + "signature buffer is {} bytes; {required} bytes are required", + output.len() + ), + )); + } + let mut written = 0_u32; + // SAFETY: all input objects remain live under the mutex, and `output` is writable for the + // declared size. + let status = unsafe { + NCryptSignHash( + self.0.key, + padding_info, + hash.as_ptr(), + hash.len() as u32, + output.as_mut_ptr(), + output.len() as u32, + &raw mut written, + flags | NCRYPT_SILENT_FLAG, + ) + }; + if status != 0 { + output.zeroize(); + return Err(CngError::from_status("NCryptSignHash", status)); + } + if written as usize != expected || written as usize > output.len() { + output.zeroize(); + return Err(CngError::detail( + "NCryptSignHash", + format_args!( + "the KSP wrote an inconsistent {written}-byte signature; {expected} bytes were expected" + ), + )); + } + Ok(expected) + } +} + +fn open_after_create_race( + provider: NCRYPT_PROV_HANDLE, + key_name: &[u16], + key: &mut NCRYPT_KEY_HANDLE, +) -> i32 { + for attempt in 0..CREATE_RACE_OPEN_ATTEMPTS { + if attempt < 4 { + std::thread::yield_now(); + } else { + let shift = (attempt - 4).min(5); + let delay = (1_u64 << shift).min(CREATE_RACE_MAX_DELAY_MS); + std::thread::sleep(std::time::Duration::from_millis(delay)); + } + if *key != 0 { + // SAFETY: any handle returned by a failed prior open attempt remains caller-owned. + let _ = unsafe { NCryptFreeObject(*key) }; + *key = 0; + } + // SAFETY: provider, output storage, and the NUL-terminated key name remain live. + let status = + unsafe { NCryptOpenKey(provider, key, key_name.as_ptr(), 0, NCRYPT_SILENT_FLAG) }; + if status == 0 || !missing_key(status) || attempt + 1 == CREATE_RACE_OPEN_ATTEMPTS { + return status; + } + } + NTE_EXISTS +} + +fn export_public_blob(key: NCRYPT_KEY_HANDLE) -> Result<(u32, Vec, Vec), CngError> { + let mut required = 0_u32; + // SAFETY: `key` is live; all optional input/output buffers are null for the size query. + let status = unsafe { + NCryptExportKey( + key, + 0, + BCRYPT_RSAPUBLIC_BLOB, + null(), + null_mut(), + 0, + &raw mut required, + NCRYPT_SILENT_FLAG, + ) + }; + if status != 0 { + return Err(CngError::from_status( + "NCryptExportKey(public size)", + status, + )); + } + if !(24..=MAX_PUBLIC_BLOB_BYTES).contains(&required) { + return Err(CngError::detail( + "NCryptExportKey(public size)", + format_args!("CNG reported an invalid {required}-byte RSA public blob"), + )); + } + let mut blob = try_zeroed_bytes(required as usize, "NCryptExportKey(public buffer)")?; + let mut written = 0_u32; + // SAFETY: `blob` is writable for `required` bytes and the key remains live. + let status = unsafe { + NCryptExportKey( + key, + 0, + BCRYPT_RSAPUBLIC_BLOB, + null(), + blob.as_mut_ptr(), + required, + &raw mut written, + NCRYPT_SILENT_FLAG, + ) + }; + if status != 0 { + return Err(CngError::from_status("NCryptExportKey(public)", status)); + } + if written != required || blob.len() < 24 { + return Err(CngError::detail( + "NCryptExportKey(public)", + "CNG returned a truncated RSA public blob", + )); + } + + let magic = read_le_u32(&blob, 0); + let bits = read_le_u32(&blob, 4); + let exponent_len = read_le_u32(&blob, 8); + let modulus_len = read_le_u32(&blob, 12); + let (Some(magic), Some(bits), Some(exponent_len), Some(modulus_len)) = + (magic, bits, exponent_len, modulus_len) + else { + return Err(CngError::detail( + "NCryptExportKey(public)", + "CNG returned an invalid RSA public blob header", + )); + }; + let expected = 24_usize + .checked_add(exponent_len as usize) + .and_then(|length| length.checked_add(modulus_len as usize)); + if magic != BCRYPT_RSAPUBLIC_MAGIC + || !(MIN_RSA_BITS..=MAX_RSA_BITS).contains(&bits) + || exponent_len == 0 + || exponent_len > 8 + || modulus_len == 0 + || modulus_len != bits.div_ceil(8) + || expected != Some(blob.len()) + { + return Err(CngError::detail( + "NCryptExportKey(public)", + "CNG returned an invalid RSA public blob", + )); + } + let exponent_end = 24 + exponent_len as usize; + let mut exponent = try_copy_bytes(&blob[24..exponent_end], "NCryptExportKey(public exponent)")?; + let exponent_value = exponent + .iter() + .fold(0_u64, |value, byte| (value << 8) | u64::from(*byte)); + if exponent_value < 3 || exponent_value & 1 == 0 { + exponent.zeroize(); + return Err(CngError::detail( + "NCryptExportKey(public)", + "CNG returned an invalid RSA public exponent", + )); + } + let modulus = match try_copy_bytes(&blob[exponent_end..], "NCryptExportKey(public modulus)") { + Ok(modulus) => modulus, + Err(error) => { + exponent.zeroize(); + return Err(error); + } + }; + Ok((bits, exponent, modulus)) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RsaPadding { + Pkcs1, + Pss { salt_len: u32 }, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Digest { + Sha256, + Sha384, + Sha512, +} + +impl Digest { + #[must_use] + pub const fn canonical_name(self) -> &'static str { + match self { + Self::Sha256 => "SHA256", + Self::Sha384 => "SHA384", + Self::Sha512 => "SHA512", + } + } + + #[must_use] + pub const fn output_len(self) -> usize { + match self { + Self::Sha256 => 32, + Self::Sha384 => 48, + Self::Sha512 => 64, + } + } + + fn bcrypt_name(self) -> PCWSTR { + match self { + Self::Sha256 => BCRYPT_SHA256_ALGORITHM, + Self::Sha384 => BCRYPT_SHA384_ALGORITHM, + Self::Sha512 => BCRYPT_SHA512_ALGORITHM, + } + } + + pub fn parse(value: &str) -> Option { + if value.eq_ignore_ascii_case("SHA256") + || value.eq_ignore_ascii_case("SHA2-256") + || value.eq_ignore_ascii_case("SHA-256") + { + Some(Self::Sha256) + } else if value.eq_ignore_ascii_case("SHA384") + || value.eq_ignore_ascii_case("SHA2-384") + || value.eq_ignore_ascii_case("SHA-384") + { + Some(Self::Sha384) + } else if value.eq_ignore_ascii_case("SHA512") + || value.eq_ignore_ascii_case("SHA2-512") + || value.eq_ignore_ascii_case("SHA-512") + { + Some(Self::Sha512) + } else { + None + } + } +} + +struct HashAlgorithm { + handle: BCRYPT_ALG_HANDLE, + object_len: u32, +} + +// SAFETY: algorithm provider handles are immutable after opening and BCrypt permits them to be +// shared between independent hash objects. +unsafe impl Send for HashAlgorithm {} +// SAFETY: all mutable state belongs to individual hash handles; the shared algorithm handle is +// queried only during construction. +unsafe impl Sync for HashAlgorithm {} + +impl Drop for HashAlgorithm { + fn drop(&mut self) { + if !self.handle.is_null() { + // SAFETY: `handle` is owned and released exactly once. + let _ = unsafe { BCryptCloseAlgorithmProvider(self.handle, 0) }; + } + } +} + +pub struct HashState { + algorithm: FallibleArc, + handle: BCRYPT_HASH_HANDLE, + object: Zeroizing>, + digest: Digest, +} + +impl HashState { + pub fn new(digest: Digest) -> Result { + let mut handle = null_mut(); + // SAFETY: output storage is valid and the algorithm name is a static NUL-terminated string. + let status = unsafe { + BCryptOpenAlgorithmProvider(&raw mut handle, digest.bcrypt_name(), null(), 0) + }; + if status < 0 { + return Err(CngError::from_status("BCryptOpenAlgorithmProvider", status)); + } + let mut algorithm = HashAlgorithm { + handle, + object_len: 0, + }; + let object_len = + bcrypt_u32_property(algorithm.handle, BCRYPT_OBJECT_LENGTH, "ObjectLength")?; + let hash_len = + bcrypt_u32_property(algorithm.handle, BCRYPT_HASH_LENGTH, "HashDigestLength")?; + if hash_len as usize != digest.output_len() { + return Err(CngError::detail( + "BCryptGetProperty(HashDigestLength)", + "BCrypt returned an unexpected digest length", + )); + } + algorithm.object_len = object_len; + let algorithm = FallibleArc::try_new(algorithm).map_err(|algorithm| { + drop(algorithm); + CngError::memory_failure("BCrypt algorithm shared ownership") + })?; + let mut object = try_zeroed_bytes(object_len as usize, "BCryptCreateHash object buffer")?; + let mut hash = null_mut(); + // SAFETY: the algorithm handle is live, `object` is writable for the supplied size, and + // the hash output pointer is valid. + let status = unsafe { + BCryptCreateHash( + algorithm.handle, + &raw mut hash, + object.as_mut_ptr(), + object_len, + null(), + 0, + 0, + ) + }; + if status < 0 { + if !hash.is_null() { + // SAFETY: a failed creation that returned a handle leaves that handle owned here. + let _ = unsafe { BCryptDestroyHash(hash) }; + } + return Err(CngError::from_status("BCryptCreateHash", status)); + } + Ok(Self { + algorithm, + handle: hash, + object, + digest, + }) + } + + #[must_use] + pub const fn digest(&self) -> Digest { + self.digest + } + + pub fn update(&mut self, mut data: &[u8]) -> Result<(), CngError> { + while !data.is_empty() { + let chunk_len = data.len().min(u32::MAX as usize); + // SAFETY: the hash handle is live and the chunk is readable for `chunk_len` bytes. + let status = unsafe { BCryptHashData(self.handle, data.as_ptr(), chunk_len as u32, 0) }; + if status < 0 { + return Err(CngError::from_status("BCryptHashData", status)); + } + data = &data[chunk_len..]; + } + Ok(()) + } + + pub fn finish_clone(&self) -> Result>, CngError> { + let duplicate = self.try_clone()?; + let mut output = + try_zeroed_bytes(self.digest.output_len(), "BCryptFinishHash output buffer")?; + // SAFETY: the duplicated hash handle is live and `output` is writable for its full size. + let status = unsafe { + BCryptFinishHash( + duplicate.handle, + output.as_mut_ptr(), + output.len() as u32, + 0, + ) + }; + if status < 0 { + return Err(CngError::from_status("BCryptFinishHash", status)); + } + Ok(output) + } + + pub fn hash_message(digest: Digest, message: &[u8]) -> Result>, CngError> { + let mut state = Self::new(digest)?; + state.update(message)?; + state.finish_clone() + } + + pub fn try_clone(&self) -> Result { + let mut object = try_zeroed_bytes( + self.algorithm.object_len as usize, + "BCryptDuplicateHash object buffer", + )?; + let mut handle = null_mut(); + // SAFETY: the source hash and shared algorithm remain live, while `object` and the output + // handle pointer are writable for the declared sizes. + let status = unsafe { + BCryptDuplicateHash( + self.handle, + &raw mut handle, + object.as_mut_ptr(), + self.algorithm.object_len, + 0, + ) + }; + if status < 0 { + if !handle.is_null() { + // SAFETY: a failed duplication that returned a handle leaves that handle owned + // here. + let _ = unsafe { BCryptDestroyHash(handle) }; + } + return Err(CngError::from_status("BCryptDuplicateHash", status)); + } + Ok(Self { + algorithm: self.algorithm.clone(), + handle, + object, + digest: self.digest, + }) + } +} + +impl Drop for HashState { + fn drop(&mut self) { + if !self.handle.is_null() { + // SAFETY: `handle` is owned by this state and released exactly once. + let _ = unsafe { BCryptDestroyHash(self.handle) }; + } + self.object.zeroize(); + } +} + +fn bcrypt_u32_property( + handle: BCRYPT_ALG_HANDLE, + property: PCWSTR, + property_name: &'static str, +) -> Result { + let mut value = 0_u32; + let mut written = 0_u32; + // SAFETY: the algorithm handle is live and both output pointers are writable. + let status = unsafe { + BCryptGetProperty( + handle, + property, + (&raw mut value).cast(), + size_of::() as u32, + &raw mut written, + 0, + ) + }; + if status < 0 { + return Err(CngError::from_status("BCryptGetProperty", status)); + } + if written as usize != size_of::() { + return Err(CngError::detail( + "BCryptGetProperty", + format_args!("{property_name} had an unexpected size"), + )); + } + Ok(value) +} + +pub fn random_bytes(output: &mut [u8]) -> Result<(), CngError> { + if output.len() > u32::MAX as usize { + return Err(CngError::detail( + "BCryptGenRandom", + "requested random buffer is too large", + )); + } + // SAFETY: `output` is writable for the supplied length; the system-preferred RNG accepts a + // null algorithm handle. + let status = unsafe { + BCryptGenRandom( + null_mut(), + output.as_mut_ptr(), + output.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + if status < 0 { + Err(CngError::from_status("BCryptGenRandom", status)) + } else { + Ok(()) + } +} + +pub fn private_export_is_blocked(provider_name: &str, key_name: &str) -> Result<(), CngError> { + let key = CngKey::open(provider_name, key_name)?; + let mut size = 0_u32; + // SAFETY: the key is held live, and null output requests only a private-blob size query. + let status = unsafe { + NCryptExportKey( + key.0.key, + 0, + BCRYPT_RSAPRIVATE_BLOB, + null(), + null_mut(), + 0, + &raw mut size, + NCRYPT_SILENT_FLAG, + ) + }; + if status == 0 { + Err(CngError::detail( + "NCryptExportKey(private)", + "CNG unexpectedly allowed a private-key export size query", + )) + } else { + Ok(()) + } +} + +pub fn delete_test_key(provider_name: &str, key_name: &str) -> Result<(), CngError> { + if !TEST_KEY_PREFIXES + .iter() + .any(|prefix| key_name.starts_with(prefix)) + { + return Err(CngError::detail( + "delete_test_key", + "refusing to delete a key without a recognized test-owned prefix", + )); + } + + let provider_name = if provider_name.is_empty() { + DEFAULT_PROVIDER_NAME + } else { + provider_name + }; + let provider_wide = wide(provider_name, "provider name conversion")?; + let key_wide = wide(key_name, "key name conversion")?; + let mut provider = 0; + let mut key = 0; + // SAFETY: output storage and UTF-16 input strings are valid. + let status = unsafe { NCryptOpenStorageProvider(&raw mut provider, provider_wide.as_ptr(), 0) }; + if status != 0 { + return Err(CngError::from_status("NCryptOpenStorageProvider", status)); + } + // SAFETY: the provider is live and output/key-name pointers are valid. + let status = unsafe { + NCryptOpenKey( + provider, + &raw mut key, + key_wide.as_ptr(), + 0, + NCRYPT_SILENT_FLAG, + ) + }; + if missing_key(status) { + // SAFETY: the provider handle is owned here. + let _ = unsafe { NCryptFreeObject(provider) }; + return Ok(()); + } + if status != 0 { + // SAFETY: the provider handle is owned here. + let _ = unsafe { NCryptFreeObject(provider) }; + return Err(CngError::from_status("NCryptOpenKey(delete)", status)); + } + // SAFETY: this function accepts only explicitly test-owned names and owns the opened handle. + let delete_status = unsafe { NCryptDeleteKey(key, NCRYPT_SILENT_FLAG) }; + if delete_status == 0 { + key = 0; + } + if key != 0 { + // SAFETY: failed deletion leaves the opened key handle owned here. + let _ = unsafe { NCryptFreeObject(key) }; + } + // SAFETY: the provider handle is owned here. + let _ = unsafe { NCryptFreeObject(provider) }; + if delete_status != 0 { + Err(CngError::from_status("NCryptDeleteKey", delete_status)) + } else { + Ok(()) + } +} + +fn try_utf16_lossy(value: &[u16], operation: &'static str) -> Result { + let capacity = value + .len() + .checked_mul(3) + .ok_or_else(|| CngError::memory_failure(operation))?; + let mut output = String::new(); + output + .try_reserve_exact(capacity) + .map_err(|_| CngError::memory_failure(operation))?; + for character in char::decode_utf16(value.iter().copied()) { + output.push(character.unwrap_or(char::REPLACEMENT_CHARACTER)); + } + Ok(output) +} + +pub fn project_test_keys(provider_name: &str) -> Result, CngError> { + let provider_name = if provider_name.is_empty() { + DEFAULT_PROVIDER_NAME + } else { + provider_name + }; + let provider_wide = wide(provider_name, "provider name conversion")?; + let mut provider = 0; + // SAFETY: output storage and the NUL-terminated provider name are valid. + let status = unsafe { NCryptOpenStorageProvider(&raw mut provider, provider_wide.as_ptr(), 0) }; + if status != 0 { + return Err(CngError::from_status("NCryptOpenStorageProvider", status)); + } + let mut enumeration_state: *mut c_void = null_mut(); + let mut matches = Vec::new(); + let result = loop { + let mut key_name: *mut NCryptKeyName = null_mut(); + // SAFETY: provider is live; both output slots remain writable for the call and enumeration + // state is preserved between calls as required by NCrypt. + let status = unsafe { + NCryptEnumKeys( + provider, + null(), + &raw mut key_name, + &raw mut enumeration_state, + 0, + ) + }; + if status == NTE_NO_MORE_ITEMS { + break Ok(matches); + } + if status != 0 { + break Err(CngError::from_status("NCryptEnumKeys", status)); + } + let mut item_result = Ok(()); + if !key_name.is_null() { + // SAFETY: NCrypt returned a live NCryptKeyName allocation with a NUL-terminated name. + let name_pointer = unsafe { (*key_name).pszName }; + if !name_pointer.is_null() { + let mut length = 0_usize; + // SAFETY: NCrypt key names are NUL-terminated; the defensive bound prevents an + // unbounded scan if a provider violates that contract. + while length < 32_768 && unsafe { *name_pointer.add(length) } != 0 { + length += 1; + } + if length < 32_768 { + // SAFETY: the bounded scan established `length` initialized UTF-16 code units. + let units = unsafe { std::slice::from_raw_parts(name_pointer, length) }; + match try_utf16_lossy(units, "NCryptEnumKeys key name") { + Ok(name) + if TEST_KEY_PREFIXES + .iter() + .any(|prefix| name.starts_with(prefix)) => + { + if matches.try_reserve(1).is_err() { + item_result = + Err(CngError::memory_failure("NCryptEnumKeys result list")); + } else { + matches.push(name); + } + } + Ok(_) => {} + Err(error) => item_result = Err(error), + } + } + } + // SAFETY: key_name is the allocation returned by this NCryptEnumKeys iteration. + unsafe { + let _ = NCryptFreeBuffer(key_name.cast()); + } + } + if let Err(error) = item_result { + break Err(error); + } + }; + if !enumeration_state.is_null() { + // SAFETY: enumeration_state is the NCrypt-owned state returned by NCryptEnumKeys. + unsafe { + let _ = NCryptFreeBuffer(enumeration_state); + } + } + // SAFETY: provider is owned by this function. + unsafe { + let _ = NCryptFreeObject(provider); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader, Read as IoRead, Write as IoWrite}; + use std::process::{Child, ChildStdout, Command, Stdio}; + use std::sync::Arc; + use std::time::{Duration, Instant}; + use windows_sys::Win32::Foundation::ERROR_INVALID_HANDLE; + use windows_sys::Win32::System::Threading::{CreateEventW, GetProcessHandleCount}; + + const CASE_VARIANT_MUTEX_CHILD: &str = "OPENSSL_CNG_CASE_VARIANT_MUTEX_CHILD"; + const CASE_VARIANT_MUTEX_PROVIDER: &str = "OPENSSL_CNG_CASE_VARIANT_MUTEX_PROVIDER"; + const CASE_VARIANT_MUTEX_KEY: &str = "OPENSSL_CNG_CASE_VARIANT_MUTEX_KEY"; + const CASE_VARIANT_MUTEX_HOLDER_READY: &str = "case-variant-mutex-holder-ready"; + const CASE_VARIANT_MUTEX_CONTENDER_READY: &str = "case-variant-mutex-contender-ready"; + const HANDLE_LIFECYCLE_CHILD: &str = "OPENSSL_CNG_HANDLE_LIFECYCLE_CHILD"; + const NAMESPACE_HANDOFF_CHILD: &str = "OPENSSL_CNG_NAMESPACE_HANDOFF_CHILD"; + + #[test] + fn memory_errors_use_the_allocation_free_marker() { + let error = try_zeroed_bytes(usize::MAX, "test allocation").expect_err("capacity overflow"); + assert!(error.is_memory_failure()); + assert_eq!(error.status(), 0); + assert_eq!( + error.to_string(), + "test allocation: memory allocation failed" + ); + assert!(error.clone().is_memory_failure()); + } + + #[test] + fn inline_error_details_preserve_dynamic_status_text() { + let error = CngError::detail( + "test operation", + format_args!("buffer {} requires {}", 12, 34), + ); + assert!(!error.is_memory_failure()); + assert_eq!(error.to_string(), "test operation: buffer 12 requires 34"); + } + + #[test] + fn fallible_utf16_conversion_replaces_invalid_surrogates() { + let converted = try_utf16_lossy( + &[u16::from(b'A'), 0xD800, u16::from(b'B')], + "test conversion", + ) + .expect("conversion"); + assert_eq!(converted, "A\u{FFFD}B"); + } + + fn owned_name() -> String { + let mut random = [0_u8; 16]; + random_bytes(&mut random).expect("random test key suffix"); + let suffix = random + .iter() + .map(|value| format!("{value:02x}")) + .collect::(); + random.zeroize(); + format!("openssl-cng-rust-test-{}-{suffix}", std::process::id()) + } + + fn mutex_name( + provider_name: &str, + key_name: &str, + ) -> Result<[u16; KEY_CREATE_MUTEX_NAME_LEN], CngError> { + let provider = wide(provider_name, "test provider name")?; + let key = wide(key_name, "test key name")?; + key_creation_mutex_name(&provider[..provider.len() - 1], &key[..key.len() - 1]) + } + + fn process_handle_count() -> u32 { + let mut count = 0_u32; + // SAFETY: the current-process pseudo-handle is always valid and count is writable. + let succeeded = unsafe { GetProcessHandleCount(GetCurrentProcess(), &raw mut count) }; + assert_ne!(succeeded, 0, "query process handle count"); + count + } + + fn run_isolated_test(test_name: &str, environment: &str) -> bool { + if std::env::var_os(environment).is_some() { + return false; + } + let executable = std::env::current_exe().expect("locate current test executable"); + let output = Command::new(executable) + .arg("--exact") + .arg(test_name) + .arg("--nocapture") + .env(environment, "1") + .output() + .expect("spawn isolated test process"); + assert!( + output.status.success(), + "isolated test {test_name} failed with {}; stdout: {}; stderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ); + true + } + + fn legacy_local_mutex_name(name: &[u16]) -> Vec { + let mut local = "Local\\".encode_utf16().collect::>(); + local.extend_from_slice(name); + local + } + + fn case_variant_child_configuration() -> Option<(String, String, String)> { + let mode = std::env::var(CASE_VARIANT_MUTEX_CHILD).ok()?; + let provider = + std::env::var(CASE_VARIANT_MUTEX_PROVIDER).expect("case-variant provider name"); + let key = std::env::var(CASE_VARIANT_MUTEX_KEY).expect("case-variant key name"); + Some((mode, provider, key)) + } + + #[test] + fn case_variant_mutex_holder_process() { + let Some((mode, provider, key)) = case_variant_child_configuration() else { + return; + }; + assert_eq!(mode, "holder"); + let name = mutex_name(&provider, &key).expect("holder mutex name"); + let _guard = KeyCreationMutexGuard::acquire(&name).expect("holder mutex acquisition"); + println!("{CASE_VARIANT_MUTEX_HOLDER_READY}"); + std::io::stdout().flush().expect("flush holder readiness"); + let mut release = [0_u8; 1]; + std::io::stdin() + .read_exact(&mut release) + .expect("wait for holder release"); + } + + #[test] + fn case_variant_mutex_contender_process() { + let Some((mode, provider, key)) = case_variant_child_configuration() else { + return; + }; + assert_eq!(mode, "contender"); + println!("{CASE_VARIANT_MUTEX_CONTENDER_READY}"); + std::io::stdout() + .flush() + .expect("flush contender readiness"); + let name = mutex_name(&provider, &key).expect("contender mutex name"); + let _guard = KeyCreationMutexGuard::acquire(&name).expect("contender mutex acquisition"); + } + + fn spawn_case_variant_child( + test_name: &str, + mode: &str, + provider_name: &str, + key_name: &str, + ) -> Result { + let executable = std::env::current_exe() + .map_err(|error| format!("locate current test executable: {error}"))?; + Command::new(executable) + .arg("--exact") + .arg(test_name) + .arg("--nocapture") + .env(CASE_VARIANT_MUTEX_CHILD, mode) + .env(CASE_VARIANT_MUTEX_PROVIDER, provider_name) + .env(CASE_VARIANT_MUTEX_KEY, key_name) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| format!("spawn {mode} process: {error}")) + } + + fn read_until_marker( + child: &mut Child, + marker: &str, + label: &str, + ) -> Result<(BufReader, String), String> { + let stdout = child + .stdout + .take() + .ok_or_else(|| format!("{label} stdout is unavailable"))?; + let mut reader = BufReader::new(stdout); + let mut output = String::new(); + loop { + let mut line = String::new(); + let read = reader + .read_line(&mut line) + .map_err(|error| format!("read {label} output: {error}"))?; + if read == 0 { + let status = child + .wait() + .map_err(|error| format!("wait for {label}: {error}"))?; + let mut stderr = String::new(); + if let Some(mut stream) = child.stderr.take() { + stream + .read_to_string(&mut stderr) + .map_err(|error| format!("read {label} stderr: {error}"))?; + } + return Err(format!( + "{label} exited before readiness with {status}; stdout: {}; stderr: {}", + output.trim(), + stderr.trim() + )); + } + output.push_str(&line); + if line.contains(marker) { + return Ok((reader, output)); + } + } + } + + fn release_mutex_holder(child: &mut Child) -> Result<(), String> { + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "holder stdin is unavailable".to_owned())?; + stdin + .write_all(&[1]) + .map_err(|error| format!("release mutex holder: {error}")) + } + + fn finish_child( + child: &mut Child, + mut stdout: BufReader, + mut output: String, + label: &str, + ) -> Result<(), String> { + let status = child + .wait() + .map_err(|error| format!("wait for {label}: {error}"))?; + stdout + .read_to_string(&mut output) + .map_err(|error| format!("read remaining {label} output: {error}"))?; + let mut stderr = String::new(); + if let Some(mut stream) = child.stderr.take() { + stream + .read_to_string(&mut stderr) + .map_err(|error| format!("read {label} stderr: {error}"))?; + } + if status.success() { + Ok(()) + } else { + Err(format!( + "{label} failed with {status}; stdout: {}; stderr: {}", + output.trim(), + stderr.trim() + )) + } + } + + fn run_case_variant_mutex_test( + holder_provider: &str, + holder_key: &str, + contender_provider: &str, + contender_key: &str, + ) -> Result<(), String> { + let mut holder = spawn_case_variant_child( + "tests::case_variant_mutex_holder_process", + "holder", + holder_provider, + holder_key, + )?; + let (holder_stdout, holder_output) = + read_until_marker(&mut holder, CASE_VARIANT_MUTEX_HOLDER_READY, "holder")?; + + let mut contender = match spawn_case_variant_child( + "tests::case_variant_mutex_contender_process", + "contender", + contender_provider, + contender_key, + ) { + Ok(child) => child, + Err(error) => { + let _ = release_mutex_holder(&mut holder); + let _ = finish_child(&mut holder, holder_stdout, holder_output, "holder"); + return Err(error); + } + }; + let (contender_stdout, contender_output) = match read_until_marker( + &mut contender, + CASE_VARIANT_MUTEX_CONTENDER_READY, + "contender", + ) { + Ok(output) => output, + Err(error) => { + let release = release_mutex_holder(&mut holder); + let holder_result = + finish_child(&mut holder, holder_stdout, holder_output, "holder"); + release?; + holder_result?; + return Err(error); + } + }; + + std::thread::sleep(Duration::from_millis(250)); + let bypassed = contender + .try_wait() + .map_err(|error| format!("poll contender process: {error}"))? + .is_some(); + + let release = release_mutex_holder(&mut holder); + let holder_result = finish_child(&mut holder, holder_stdout, holder_output, "holder"); + let contender_result = finish_child( + &mut contender, + contender_stdout, + contender_output, + "contender", + ); + release?; + holder_result?; + contender_result?; + if bypassed { + return Err( + "case-variant contender acquired a different named mutex while holder owned it" + .to_owned(), + ); + } + Ok(()) + } + + #[test] + fn digest_aliases_are_strict() { + assert_eq!(Digest::parse("sha-256"), Some(Digest::Sha256)); + assert_eq!(Digest::parse("SHA2-384"), Some(Digest::Sha384)); + assert_eq!(Digest::parse("sha512"), Some(Digest::Sha512)); + assert_eq!(Digest::parse("SHA1"), None); + } + + #[test] + fn hash_state_clone_is_independent() { + let mut hash = HashState::new(Digest::Sha256).expect("create hash"); + hash.update(b"abc").expect("update hash"); + let mut duplicate = hash.try_clone().expect("duplicate hash"); + hash.update(b"def").expect("finish first input"); + duplicate.update(b"ghi").expect("finish second input"); + assert_ne!( + hash.finish_clone().expect("first digest").as_slice(), + duplicate.finish_clone().expect("second digest").as_slice() + ); + } + + #[test] + fn persisted_key_lifecycle_and_private_export_policy() { + let name = owned_name(); + delete_test_key(DEFAULT_PROVIDER_NAME, &name).expect("pre-clean test key"); + let result = (|| { + let key = CngKey::open_or_create(DEFAULT_PROVIDER_NAME, &name, 2_048)?; + assert_eq!(key.bits(), 2_048); + assert_eq!(key.max_signature_size(), 256); + assert!(!key.public_modulus().is_empty()); + assert!(!key.public_exponent().is_empty()); + let digest = HashState::hash_message(Digest::Sha256, b"cng-core lifecycle")?; + let required = key.sign_digest(Digest::Sha256, RsaPadding::Pkcs1, &digest, None)?; + assert_eq!(required, 256); + let mut signature = vec![0_u8; required]; + let written = key.sign_digest( + Digest::Sha256, + RsaPadding::Pss { salt_len: 32 }, + &digest, + Some(&mut signature), + )?; + assert_eq!(written, 256); + private_export_is_blocked(DEFAULT_PROVIDER_NAME, &name) + })(); + delete_test_key(DEFAULT_PROVIDER_NAME, &name).expect("delete test key"); + result.expect("CNG lifecycle"); + assert!(CngKey::open(DEFAULT_PROVIDER_NAME, &name).is_err()); + } + + #[test] + fn concurrent_open_or_create_converges_on_finalized_key() { + let name = owned_name(); + delete_test_key(DEFAULT_PROVIDER_NAME, &name).expect("pre-clean concurrent test key"); + let result = (|| -> Result<(), CngError> { + let barrier = Arc::new(std::sync::Barrier::new(6)); + let threads = (0..6) + .map(|_| { + let barrier = Arc::clone(&barrier); + let name = name.clone(); + std::thread::spawn(move || { + barrier.wait(); + CngKey::open_or_create(DEFAULT_PROVIDER_NAME, &name, 2_048) + }) + }) + .collect::>(); + let mut keys = Vec::new(); + for thread in threads { + keys.push(thread.join().expect("concurrent key thread")?); + } + let modulus = keys[0].public_modulus(); + let exponent = keys[0].public_exponent(); + if !keys + .iter() + .all(|key| key.public_modulus() == modulus && key.public_exponent() == exponent) + { + return Err(CngError::detail( + "concurrent open-or-create test", + "callers opened different persisted keys", + )); + } + drop(keys); + Ok(()) + })(); + delete_test_key(DEFAULT_PROVIDER_NAME, &name).expect("delete concurrent test key"); + result.expect("concurrent open-or-create"); + } + + #[test] + fn key_creation_namespace_scopes_release_process_handles() { + if run_isolated_test( + "tests::key_creation_namespace_scopes_release_process_handles", + HANDLE_LIFECYCLE_CHILD, + ) { + return; + } + + let mutex_name = + mutex_name(DEFAULT_PROVIDER_NAME, "namespace-handle-lifecycle").expect("mutex name"); + for _ in 0..4 { + drop(KeyCreationNamespace::acquire().expect("warm up private namespace")); + drop(KeyCreationMutexGuard::acquire(&mutex_name).expect("warm up private mutex")); + } + let baseline = process_handle_count(); + for _ in 0..128 { + drop(KeyCreationNamespace::acquire().expect("acquire private namespace")); + drop(KeyCreationMutexGuard::acquire(&mutex_name).expect("acquire private mutex")); + } + let after = process_handle_count(); + let state = KEY_CREATE_NAMESPACE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(state.references, 0); + assert_eq!(state.handle, 0); + assert!( + after <= baseline.saturating_add(2), + "private namespace handle count grew from {baseline} to {after}" + ); + } + + #[test] + fn concurrent_namespace_handoffs_do_not_race_alias_teardown() { + const THREADS: usize = 8; + const ITERATIONS: usize = 256; + + if run_isolated_test( + "tests::concurrent_namespace_handoffs_do_not_race_alias_teardown", + NAMESPACE_HANDOFF_CHILD, + ) { + return; + } + + let barrier = Arc::new(std::sync::Barrier::new(THREADS)); + let threads = (0..THREADS) + .map(|_| { + let barrier = Arc::clone(&barrier); + std::thread::spawn(move || -> Result<(), CngError> { + barrier.wait(); + for _ in 0..ITERATIONS { + drop(KeyCreationNamespace::acquire()?); + std::thread::yield_now(); + } + Ok(()) + }) + }) + .collect::>(); + for thread in threads { + thread + .join() + .expect("namespace handoff thread") + .expect("namespace handoff acquisition"); + } + let state = KEY_CREATE_NAMESPACE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert_eq!(state.references, 0); + assert_eq!(state.handle, 0); + } + + #[test] + fn wrong_type_local_object_cannot_block_private_coordination() { + let name = owned_name(); + delete_test_key(DEFAULT_PROVIDER_NAME, &name).expect("pre-clean squatting test key"); + let mutex_name = mutex_name(DEFAULT_PROVIDER_NAME, &name).expect("mutex name"); + let local_name = legacy_local_mutex_name(&mutex_name); + // SAFETY: the generated name is live and NUL-terminated; default event security is used. + let event = unsafe { CreateEventW(null(), 1, 0, local_name.as_ptr()) }; + assert!(!event.is_null(), "create wrong-type namespace object"); + // SAFETY: opening the squatted Local name as a mutex must fail with the wrong object type. + let collision = unsafe { CreateMutexW(null(), 0, local_name.as_ptr()) }; + assert!(collision.is_null()); + // SAFETY: GetLastError immediately follows the failed CreateMutexW. + assert_eq!(unsafe { GetLastError() }, ERROR_INVALID_HANDLE); + + let result = CngKey::open_or_create(DEFAULT_PROVIDER_NAME, &name, 2_048); + // SAFETY: this test owns the event handle. + unsafe { + let _ = CloseHandle(event); + } + let key = result.expect("key creation must survive a wrong-type named object"); + assert_eq!(key.bits(), 2_048); + drop(key); + delete_test_key(DEFAULT_PROVIDER_NAME, &name).expect("delete squatting test key"); + } + + #[test] + fn held_local_mutex_uses_private_coordinator_and_converges() { + let name = owned_name(); + delete_test_key(DEFAULT_PROVIDER_NAME, &name).expect("pre-clean held-mutex test key"); + let mutex_name = mutex_name(DEFAULT_PROVIDER_NAME, &name).expect("mutex name"); + let local_name = legacy_local_mutex_name(&mutex_name); + // SAFETY: creates and initially owns the deterministic legacy Local mutex. + let held_mutex = unsafe { CreateMutexW(null(), 1, local_name.as_ptr()) }; + assert!(!held_mutex.is_null(), "hold legacy Local creation mutex"); + let barrier = Arc::new(std::sync::Barrier::new(5)); + let start = Instant::now(); + let threads = (0..4) + .map(|_| { + let barrier = Arc::clone(&barrier); + let name = name.clone(); + std::thread::spawn(move || { + barrier.wait(); + CngKey::open_or_create(DEFAULT_PROVIDER_NAME, &name, 2_048) + }) + }) + .collect::>(); + barrier.wait(); + + let mut keys = Vec::new(); + for thread in threads { + keys.push(thread.join().expect("held-mutex key thread")); + } + let elapsed = start.elapsed(); + // SAFETY: this thread initially owned the mutex and owns its handle. + unsafe { + let _ = ReleaseMutex(held_mutex); + let _ = CloseHandle(held_mutex); + } + let result = (|| -> Result<(), CngError> { + let keys = keys.into_iter().collect::, _>>()?; + let modulus = keys[0].public_modulus().to_vec(); + let exponent = keys[0].public_exponent().to_vec(); + if !keys + .iter() + .all(|key| key.public_modulus() == modulus && key.public_exponent() == exponent) + { + return Err(CngError::detail( + "held-mutex fallback test", + "fallback callers opened different persisted keys", + )); + } + drop(keys); + let reopened = CngKey::open(DEFAULT_PROVIDER_NAME, &name)?; + if reopened.public_modulus() != modulus || reopened.public_exponent() != exponent { + return Err(CngError::detail( + "held-mutex fallback test", + "the finalized persisted key did not converge", + )); + } + Ok(()) + })(); + delete_test_key(DEFAULT_PROVIDER_NAME, &name).expect("delete held-mutex test key"); + assert!( + elapsed < Duration::from_millis(u64::from(KEY_CREATE_MUTEX_WAIT_MS)), + "a held Local mutex blocked the private per-user coordinator" + ); + result.expect("held Local mutex private coordination"); + } + + #[test] + fn key_creation_mutex_name_is_exact_and_boundary_safe() { + let name = mutex_name(DEFAULT_PROVIDER_NAME, "key-a").expect("mutex name"); + let repeated = mutex_name(DEFAULT_PROVIDER_NAME, "key-a").expect("repeated mutex name"); + let distinct = mutex_name(DEFAULT_PROVIDER_NAME, "key-b").expect("distinct mutex name"); + let boundary_a = mutex_name("provider-a", "key").expect("boundary A"); + let boundary_b = mutex_name("provider", "a-key").expect("boundary B"); + let composed = mutex_name(DEFAULT_PROVIDER_NAME, "\u{e9}").expect("composed"); + let decomposed = mutex_name(DEFAULT_PROVIDER_NAME, "e\u{301}").expect("decomposed"); + let lowercase = mutex_name("microsoft software key storage provider", "case-\u{e9}") + .expect("lowercase mutex name"); + let uppercase = mutex_name("MICROSOFT SOFTWARE KEY STORAGE PROVIDER", "CASE-\u{c9}") + .expect("uppercase mutex name"); + + assert_eq!(name, repeated); + assert_ne!(name, distinct); + assert_ne!(boundary_a, boundary_b); + assert_ne!(composed, decomposed); + assert_eq!(lowercase, uppercase); + assert_eq!(name.last(), Some(&0)); + assert!( + name.iter() + .zip(KEY_CREATE_MUTEX_PREFIX) + .all(|(wide, byte)| *wide == u16::from(*byte)) + ); + } + + #[test] + fn case_variant_mutex_serializes_across_processes() { + let suffix = owned_name(); + let holder_key = format!("{suffix}-case-\u{e9}"); + let contender_key = format!("{}-CASE-\u{c9}", suffix.to_ascii_uppercase()); + run_case_variant_mutex_test( + "microsoft software key storage provider", + &holder_key, + "MICROSOFT SOFTWARE KEY STORAGE PROVIDER", + &contender_key, + ) + .expect("case-variant cross-process mutex serialization"); + } + + #[test] + fn deletion_rejects_non_test_names() { + assert!(delete_test_key(DEFAULT_PROVIDER_NAME, "production-key").is_err()); + } +} diff --git a/native/crates/cng-provider/Cargo.toml b/native/crates/cng-provider/Cargo.toml new file mode 100644 index 00000000..2f058706 --- /dev/null +++ b/native/crates/cng-provider/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "cng-provider" +version.workspace = true +description = "Rust OpenSSL 3 provider for opaque Windows CNG RSA signing keys" +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[lib] +name = "microsoft_identity_native_provider" +crate-type = ["cdylib"] + +[dependencies] +cng-core = { version = "0.2.0", path = "../cng-core" } +libc.workspace = true +openssl-provider-sys = { version = "0.2.0", path = "../openssl-provider-sys" } +zeroize.workspace = true + +[lints] +workspace = true diff --git a/native/crates/cng-provider/src/allocation.rs b/native/crates/cng-provider/src/allocation.rs new file mode 100644 index 00000000..ce55ad0e --- /dev/null +++ b/native/crates/cng-provider/src/allocation.rs @@ -0,0 +1,200 @@ +use std::alloc::{Layout, alloc}; +use std::ptr::NonNull; + +#[cfg(test)] +thread_local! { + static BOX_ALLOCATION_FAILURE_COUNTDOWN: std::cell::Cell> = + const { std::cell::Cell::new(None) }; + static STRING_ALLOCATION_FAILURE_COUNTDOWN: std::cell::Cell> = + const { std::cell::Cell::new(None) }; +} + +#[cfg(test)] +#[derive(Clone, Copy, Default)] +struct CopyObservation { + length: usize, + pointer: *mut u8, + freed: bool, + dirty: bool, +} + +#[cfg(test)] +thread_local! { + static COPY_OBSERVATION: std::cell::Cell = + const { std::cell::Cell::new(CopyObservation { + length: 0, pointer: std::ptr::null_mut(), freed: false, dirty: false, + }) }; +} + +#[cfg(test)] +struct ObservedAllocator; + +#[cfg(test)] +// SAFETY: layouts and allocation/free pairing are delegated unchanged to System. +// The allocation-free thread-local observer initializes the watched allocation, +// including unused capacity, before inspecting its bytes immediately before free. +unsafe impl std::alloc::GlobalAlloc for ObservedAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // SAFETY: layout is supplied by the allocating caller. + let pointer = unsafe { std::alloc::System.alloc(layout) }; + COPY_OBSERVATION.with(|observation| { + let mut state = observation.get(); + if state.length != 0 && layout.size() == state.length && !pointer.is_null() { + // SAFETY: System allocated exactly this many writable bytes. + unsafe { pointer.write_bytes(0, layout.size()) }; + state.pointer = pointer; + observation.set(state); + } + }); + pointer + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + COPY_OBSERVATION.with(|observation| { + let mut state = observation.get(); + if pointer == state.pointer { + // SAFETY: the observer initialized the full allocation, which remains + // live until System.dealloc below, even if decoding filled only a prefix. + let bytes = unsafe { std::slice::from_raw_parts(pointer, state.length) }; + state.dirty = bytes.iter().any(|byte| *byte != 0); + state.freed = true; + state.pointer = std::ptr::null_mut(); + observation.set(state); + } + }); + // SAFETY: pointer/layout pairing comes unchanged from this allocator. + unsafe { std::alloc::System.dealloc(pointer, layout) }; + } +} + +#[cfg(test)] +#[global_allocator] +static ALLOCATOR: ObservedAllocator = ObservedAllocator; + +#[cfg(test)] +pub fn assert_deallocated_copy_is_wiped(length: usize, operation: impl FnOnce()) { + COPY_OBSERVATION.set(CopyObservation { + length, + ..CopyObservation::default() + }); + operation(); + let observed = COPY_OBSERVATION.replace(CopyObservation::default()); + assert!(observed.freed, "decoded name allocation was not observed"); + assert!(!observed.dirty, "decoded name allocation was not wiped"); +} + +pub fn try_box(value: T) -> Option> { + #[cfg(test)] + if BOX_ALLOCATION_FAILURE_COUNTDOWN.with(|countdown| match countdown.get() { + Some(0) => { + countdown.set(None); + true + } + Some(remaining) => { + countdown.set(Some(remaining - 1)); + false + } + None => false, + }) { + return None; + } + + let layout = Layout::new::(); + let pointer = if layout.size() == 0 { + NonNull::::dangling().as_ptr() + } else { + // SAFETY: `layout` describes one `T`; a null result is handled below. + unsafe { alloc(layout).cast::() } + }; + if pointer.is_null() { + return None; + } + // SAFETY: `pointer` is aligned and allocated for one `T` (or is the valid dangling pointer + // for a zero-sized `T`). Ownership is immediately transferred to the returned Box. + unsafe { + pointer.write(value); + Some(Box::from_raw(pointer)) + } +} + +pub fn try_copy_bytes(value: &[u8]) -> Option> { + let mut copy = Vec::new(); + copy.try_reserve_exact(value.len()).ok()?; + copy.extend_from_slice(value); + Some(copy) +} + +pub fn try_copy_bytes_with_nul(value: &[u8]) -> Option> { + let required = value.len().checked_add(1)?; + let mut copy = Vec::new(); + copy.try_reserve_exact(required).ok()?; + copy.extend_from_slice(value); + copy.push(0); + Some(copy) +} + +pub fn try_zeroed_bytes(length: usize) -> Option> { + let mut bytes = Vec::new(); + bytes.try_reserve_exact(length).ok()?; + bytes.resize(length, 0); + Some(bytes) +} + +pub fn try_copy_str(value: &str) -> Option { + #[cfg(test)] + if STRING_ALLOCATION_FAILURE_COUNTDOWN.with(|countdown| match countdown.get() { + Some(0) => { + countdown.set(None); + true + } + Some(remaining) => { + countdown.set(Some(remaining - 1)); + false + } + None => false, + }) { + return None; + } + + let mut copy = String::new(); + copy.try_reserve_exact(value.len()).ok()?; + copy.push_str(value); + Some(copy) +} + +#[cfg(test)] +pub fn fail_box_allocation_after(successful_allocations: usize) { + BOX_ALLOCATION_FAILURE_COUNTDOWN.with(|countdown| { + countdown.set(Some(successful_allocations)); + }); +} + +#[cfg(test)] +pub fn fail_string_allocation_after(successful_allocations: usize) { + STRING_ALLOCATION_FAILURE_COUNTDOWN.with(|countdown| { + countdown.set(Some(successful_allocations)); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct DropCounter<'a>(&'a AtomicUsize); + + impl Drop for DropCounter<'_> { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + + #[test] + fn failed_box_allocation_drops_the_input_once() { + let drops = AtomicUsize::new(0); + fail_box_allocation_after(0); + + assert!(try_box(DropCounter(&drops)).is_none()); + assert_eq!(drops.load(Ordering::Relaxed), 1); + } +} diff --git a/native/crates/cng-provider/src/error.rs b/native/crates/cng-provider/src/error.rs new file mode 100644 index 00000000..4632b6ed --- /dev/null +++ b/native/crates/cng-provider/src/error.rs @@ -0,0 +1,109 @@ +use std::ffi::CStr; +use std::fmt::{self, Write}; + +use cng_core::CngError; +use openssl_provider_sys::{ERR_LIB_USER, ERR_new, ERR_set_error}; + +pub const INVALID_PARAMETER: i32 = 1; +pub const CNG_FAILURE: i32 = 2; +pub const MEMORY_FAILURE: i32 = 3; +pub const KEY_NOT_LOADED: i32 = 4; +pub const UNSUPPORTED_DIGEST: i32 = 5; +pub const UNSUPPORTED_PADDING: i32 = 6; +pub const PRIVATE_EXPORT_DISABLED: i32 = 7; +pub const SIGNATURE_FAILURE: i32 = 8; +pub const INVALID_URI: i32 = 9; +pub const INVALID_REFERENCE: i32 = 10; +pub const KEY_VALIDATION_FAILURE: i32 = 11; + +const ERROR_BUFFER_LENGTH: usize = 512; + +struct ErrorBuffer { + bytes: [u8; ERROR_BUFFER_LENGTH], + length: usize, +} + +impl ErrorBuffer { + const fn new() -> Self { + Self { + bytes: [0; ERROR_BUFFER_LENGTH], + length: 0, + } + } + + fn push(&mut self, byte: u8) { + if self.length + 1 < self.bytes.len() { + self.bytes[self.length] = byte; + self.length += 1; + } + } + + fn as_c_str(&self) -> &CStr { + // SAFETY: the buffer is zero-initialized, `push` never writes NUL, and always leaves the + // byte following the written message as the terminating NUL. + unsafe { CStr::from_bytes_with_nul_unchecked(&self.bytes[..=self.length]) } + } +} + +impl Write for ErrorBuffer { + fn write_str(&mut self, value: &str) -> fmt::Result { + for byte in value.bytes() { + if byte == 0 { + self.push(b'\\'); + self.push(b'0'); + } else { + self.push(byte); + } + } + Ok(()) + } +} + +fn raise_cstr(reason: i32, message: &CStr) { + // SAFETY: OpenSSL copies the formatted message during this call. + unsafe { + ERR_new(); + ERR_set_error(ERR_LIB_USER, reason, c"%s".as_ptr(), message.as_ptr()); + } +} + +pub fn raise(reason: i32, message: impl fmt::Display) { + if reason == MEMORY_FAILURE { + raise_memory_failure(); + return; + } + let mut buffer = ErrorBuffer::new(); + let _ = write!(&mut buffer, "{message}"); + raise_cstr(reason, buffer.as_c_str()); +} + +pub fn raise_memory_failure() { + raise_cstr(MEMORY_FAILURE, c"memory allocation failed"); +} + +const fn cng_reason(reason: i32, error: &CngError) -> i32 { + if error.is_memory_failure() { + MEMORY_FAILURE + } else { + reason + } +} + +pub fn raise_cng(reason: i32, error: &CngError) { + if cng_reason(reason, error) == MEMORY_FAILURE { + raise_memory_failure(); + } else { + raise(reason, error); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cng_memory_errors_map_to_the_static_memory_reason() { + let error = CngError::memory_failure("test allocation"); + assert_eq!(cng_reason(CNG_FAILURE, &error), MEMORY_FAILURE); + } +} diff --git a/native/crates/cng-provider/src/key.rs b/native/crates/cng-provider/src/key.rs new file mode 100644 index 00000000..089d871f --- /dev/null +++ b/native/crates/cng-provider/src/key.rs @@ -0,0 +1,505 @@ +use std::ffi::{c_int, c_void}; +use std::ptr::null_mut; + +use cng_core::CngKey; +use openssl_provider_sys::{ + BIGNUM, BN_bin2bn, BN_bn2bin, BN_free, BN_is_negative, BN_is_odd, BN_num_bits, OSSL_PARAM, + OSSL_PARAM_BLD_free, OSSL_PARAM_BLD_new, OSSL_PARAM_BLD_push_BN, OSSL_PARAM_BLD_to_param, + OSSL_PARAM_free, OSSL_PARAM_get_BN, OSSL_PARAM_set_BN, OsslCallback, +}; +use zeroize::Zeroize; + +use crate::allocation; +use crate::error::{self, INVALID_PARAMETER}; +use crate::params; + +pub const KEY_MAGIC: u32 = 0x434E_474B; +const MAX_MODULUS_ENCODING_BYTES: usize = 16_384 / 8 + size_of::(); +const MAX_EXPONENT_ENCODING_BYTES: usize = size_of::(); + +#[cfg(test)] +thread_local! { + static BN_CONVERSION_ATTEMPTS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[derive(Clone)] +pub enum KeyMaterial { + Empty, + Private(CngKey), + Public { + modulus: Vec, + exponent: Vec, + bits: u32, + }, +} + +pub struct ProviderKey { + pub magic: u32, + pub provider_context: *mut c_void, + pub material: KeyMaterial, +} + +impl ProviderKey { + pub fn new(provider_context: *mut c_void, material: KeyMaterial) -> *mut Self { + let Some(key) = allocation::try_box(Self { + magic: KEY_MAGIC, + provider_context, + material, + }) else { + error::raise_memory_failure(); + return null_mut(); + }; + Box::into_raw(key) + } + + pub fn is_valid(&self) -> bool { + self.magic == KEY_MAGIC && !matches!(self.material, KeyMaterial::Empty) + } + + pub fn private_key(&self) -> Option<&CngKey> { + match &self.material { + KeyMaterial::Private(key) => Some(key), + KeyMaterial::Empty | KeyMaterial::Public { .. } => None, + } + } + + pub fn public_parts(&self) -> Option<(&[u8], &[u8], u32)> { + match &self.material { + KeyMaterial::Private(key) => { + Some((key.public_modulus(), key.public_exponent(), key.bits())) + } + KeyMaterial::Public { + modulus, + exponent, + bits, + } => Some((modulus, exponent, *bits)), + KeyMaterial::Empty => None, + } + } + + pub fn security_bits(&self) -> u32 { + match &self.material { + KeyMaterial::Private(key) => key.security_bits(), + KeyMaterial::Public { bits, .. } => rsa_security_bits(*bits), + KeyMaterial::Empty => 0, + } + } + + pub fn max_size(&self) -> usize { + self.public_parts() + .map_or(0, |(modulus, _, _)| modulus.len()) + } + + pub fn public_is_valid(&self) -> bool { + let Some((modulus, exponent, bits)) = self.public_parts() else { + return false; + }; + modulus.len() >= 256 + && !modulus.iter().all(|value| *value == 0) + && modulus.last().is_some_and(|value| value & 1 == 1) + && !exponent.is_empty() + && exponent.last().is_some_and(|value| value & 1 == 1) + && (exponent.len() > 1 || exponent[0] >= 3) + && bits >= 2_048 + && self.security_bits() >= 112 + } +} + +impl Drop for ProviderKey { + fn drop(&mut self) { + self.magic = 0; + if let KeyMaterial::Public { + modulus, exponent, .. + } = &mut self.material + { + modulus.zeroize(); + exponent.zeroize(); + } + } +} + +pub unsafe fn free_provider_key(pointer: *mut ProviderKey) { + if pointer.is_null() { + return; + } + // SAFETY: callers transfer a Box allocation created by `ProviderKey::new` exactly once. + let mut key = unsafe { Box::from_raw(pointer) }; + if key.magic != KEY_MAGIC { + std::mem::forget(key); + return; + } + key.magic = 0; +} + +pub unsafe fn import_public(key: &mut ProviderKey, params_array: *const OSSL_PARAM) -> bool { + // SAFETY: the parameter array is OpenSSL-owned and terminated. + let modulus_param = unsafe { params::locate(params_array, c"n") }; + // SAFETY: the parameter array is OpenSSL-owned and terminated. + let exponent_param = unsafe { params::locate(params_array, c"e") }; + if modulus_param.is_null() || exponent_param.is_null() { + error::raise( + INVALID_PARAMETER, + "RSA public import requires both modulus and exponent", + ); + return false; + } + // SAFETY: both parameters were located in the live OpenSSL-owned array. + let modulus = unsafe { &*modulus_param }; + // SAFETY: same invariant for the exponent parameter. + let exponent = unsafe { &*exponent_param }; + if [modulus, exponent].iter().any(|parameter| { + parameter.data_type != openssl_provider_sys::OSSL_PARAM_UNSIGNED_INTEGER + || parameter.data.is_null() + || parameter.data_size == 0 + }) || modulus.data_size > MAX_MODULUS_ENCODING_BYTES + || exponent.data_size > MAX_EXPONENT_ENCODING_BYTES + { + error::raise( + INVALID_PARAMETER, + "RSA public parameter encodings exceed their bounds or have an invalid type", + ); + return false; + } + + let mut modulus_bn: *mut BIGNUM = null_mut(); + let mut exponent_bn: *mut BIGNUM = null_mut(); + #[cfg(test)] + BN_CONVERSION_ATTEMPTS.with(|attempts| attempts.set(attempts.get() + 1)); + // SAFETY: OpenSSL owns the source parameters and initializes the returned BIGNUM pointers. + let parsed = unsafe { + OSSL_PARAM_get_BN(modulus_param, &raw mut modulus_bn) == 1 + && OSSL_PARAM_get_BN(exponent_param, &raw mut exponent_bn) == 1 + }; + if !parsed || modulus_bn.is_null() || exponent_bn.is_null() { + // SAFETY: null is accepted and any returned BIGNUMs are owned here. + unsafe { + BN_free(modulus_bn); + BN_free(exponent_bn); + } + error::raise(INVALID_PARAMETER, "RSA public parameters are invalid"); + return false; + } + + // SAFETY: both BIGNUM pointers are live and owned here. + let modulus_bits = unsafe { BN_num_bits(modulus_bn) }; + // SAFETY: both BIGNUM pointers are live and owned here. + let exponent_bits = unsafe { BN_num_bits(exponent_bn) }; + // SAFETY: both BIGNUM pointers are live and owned here. + let valid = unsafe { + BN_is_negative(modulus_bn) == 0 + && BN_is_negative(exponent_bn) == 0 + && BN_is_odd(modulus_bn) == 1 + && BN_is_odd(exponent_bn) == 1 + } && (2_048..=16_384).contains(&modulus_bits) + && (2..=32).contains(&exponent_bits); + if !valid { + // SAFETY: both BIGNUMs are owned here. + unsafe { + BN_free(modulus_bn); + BN_free(exponent_bn); + } + error::raise( + INVALID_PARAMETER, + "a bounded odd RSA modulus and odd exponent are required", + ); + return false; + } + + let Some(mut modulus) = allocation::try_zeroed_bytes(((modulus_bits + 7) / 8) as usize) else { + // SAFETY: both BIGNUMs are owned here. + unsafe { + BN_free(modulus_bn); + BN_free(exponent_bn); + } + error::raise_memory_failure(); + return false; + }; + let Some(mut exponent) = allocation::try_zeroed_bytes(((exponent_bits + 7) / 8) as usize) + else { + // SAFETY: both BIGNUMs are owned here. + unsafe { + BN_free(modulus_bn); + BN_free(exponent_bn); + } + error::raise_memory_failure(); + return false; + }; + // SAFETY: output buffers are sized from BN_num_bits and both BIGNUMs remain live. + let converted = unsafe { + BN_bn2bin(modulus_bn, modulus.as_mut_ptr()) == modulus.len() as c_int + && BN_bn2bin(exponent_bn, exponent.as_mut_ptr()) == exponent.len() as c_int + }; + // SAFETY: both BIGNUMs are owned here and no longer needed. + unsafe { + BN_free(modulus_bn); + BN_free(exponent_bn); + } + if !converted + || exponent + .iter() + .fold(0_u64, |value, byte| (value << 8) | u64::from(*byte)) + < 3 + { + modulus.zeroize(); + exponent.zeroize(); + error::raise(INVALID_PARAMETER, "unable to retain RSA public parameters"); + return false; + } + + key.material = KeyMaterial::Public { + modulus, + exponent, + bits: modulus_bits as u32, + }; + true +} + +pub unsafe fn set_bn_param(key: &ProviderKey, param: *mut OSSL_PARAM, bytes: &[u8]) -> bool { + if param.is_null() || bytes.len() > c_int::MAX as usize { + return false; + } + // SAFETY: `bytes` is readable and the returned BIGNUM is owned here. + let number = unsafe { BN_bin2bn(bytes.as_ptr(), bytes.len() as c_int, null_mut()) }; + if number.is_null() { + error::raise_memory_failure(); + return false; + } + // SAFETY: the mutable parameter and BIGNUM are live for this immediate OpenSSL call. + let result = unsafe { OSSL_PARAM_set_BN(param, number) == 1 }; + // SAFETY: `number` is owned here. + unsafe { BN_free(number) }; + if !result { + let _ = key; + error::raise( + INVALID_PARAMETER, + "unable to return an RSA public parameter", + ); + } + result +} + +pub unsafe fn export_public( + key: &ProviderKey, + callback: OsslCallback, + callback_argument: *mut c_void, +) -> bool { + let Some(callback) = callback else { + return false; + }; + let Some((modulus, exponent, _)) = key.public_parts() else { + return false; + }; + if modulus.len() > c_int::MAX as usize || exponent.len() > c_int::MAX as usize { + return false; + } + + // SAFETY: input slices are readable and the returned BIGNUMs are owned here. + let modulus_bn = unsafe { BN_bin2bn(modulus.as_ptr(), modulus.len() as c_int, null_mut()) }; + // SAFETY: input slices are readable and the returned BIGNUMs are owned here. + let exponent_bn = unsafe { BN_bin2bn(exponent.as_ptr(), exponent.len() as c_int, null_mut()) }; + // SAFETY: returns an owned builder or null. + let builder = unsafe { OSSL_PARAM_BLD_new() }; + let mut built = null_mut(); + // SAFETY: all pointers are live, and OpenSSL copies/retains values through the builder. + let okay = !modulus_bn.is_null() + && !exponent_bn.is_null() + && !builder.is_null() + && unsafe { OSSL_PARAM_BLD_push_BN(builder, c"n".as_ptr(), modulus_bn) == 1 } + // SAFETY: same live-builder and BIGNUM invariant. + && unsafe { OSSL_PARAM_BLD_push_BN(builder, c"e".as_ptr(), exponent_bn) == 1 }; + if okay { + // SAFETY: builder is live and returns an independently owned parameter array. + built = unsafe { OSSL_PARAM_BLD_to_param(builder) }; + } + let result = if built.is_null() { + error::raise_memory_failure(); + false + } else { + // SAFETY: `built` is a valid terminated parameter array for this synchronous callback. + unsafe { callback(built, callback_argument) == 1 } + }; + // SAFETY: all allocations are owned here and null is accepted by these free functions. + unsafe { + OSSL_PARAM_free(built); + OSSL_PARAM_BLD_free(builder); + BN_free(modulus_bn); + BN_free(exponent_bn); + } + result +} + +fn rsa_security_bits(bits: u32) -> u32 { + match bits { + 15_360.. => 256, + 7_680.. => 192, + 3_072.. => 128, + 2_048.. => 112, + 1_024.. => 80, + _ => 0, + } +} + +pub fn names_match(left: &ProviderKey, right: &ProviderKey) -> bool { + let Some((left_modulus, left_exponent, _)) = left.public_parts() else { + return false; + }; + let Some((right_modulus, right_exponent, _)) = right.public_parts() else { + return false; + }; + left_modulus == right_modulus && left_exponent == right_exponent +} + +#[cfg(test)] +mod tests { + use super::*; + + fn import_encoded_public(modulus: &mut [u8], exponent: &mut [u8]) -> bool { + let parameters = [ + OSSL_PARAM { + key: c"n".as_ptr(), + data_type: openssl_provider_sys::OSSL_PARAM_UNSIGNED_INTEGER, + data: modulus.as_mut_ptr().cast(), + data_size: modulus.len(), + return_size: usize::MAX, + }, + OSSL_PARAM { + key: c"e".as_ptr(), + data_type: openssl_provider_sys::OSSL_PARAM_UNSIGNED_INTEGER, + data: exponent.as_mut_ptr().cast(), + data_size: exponent.len(), + return_size: usize::MAX, + }, + openssl_provider_sys::param_end(), + ]; + let mut key = ProviderKey { + magic: KEY_MAGIC, + provider_context: null_mut(), + material: KeyMaterial::Empty, + }; + // SAFETY: both native-endian encoding buffers and the terminated array remain live. + unsafe { import_public(&mut key, parameters.as_ptr()) } + } + + #[test] + fn oversized_encodings_never_reach_bignum_conversion() { + for (modulus_length, exponent_length) in [ + (MAX_MODULUS_ENCODING_BYTES + 1, 8), + (256, MAX_EXPONENT_ENCODING_BYTES + 1), + (65_536, 8), + (256, 65_536), + ] { + for significant in [false, true] { + let mut modulus = vec![if significant { 0x81 } else { 0 }; modulus_length]; + modulus[0] = 1; + modulus[255] = 0x80; + let mut exponent = vec![if significant { 3 } else { 0 }; exponent_length]; + exponent[0] = 3; + BN_CONVERSION_ATTEMPTS.set(0); + assert!(!import_encoded_public(&mut modulus, &mut exponent)); + assert_eq!(BN_CONVERSION_ATTEMPTS.get(), 0); + } + } + } + + #[test] + fn bounded_encodings_preserve_padding_and_mathematical_limits() { + for (bits, padding) in [(2_048, 0), (16_384, 0), (16_384, size_of::())] { + let mut modulus = vec![0; bits / 8 + padding]; + modulus[0] = 1; + modulus[bits / 8 - 1] = 0x80; + for exponent_length in [4, 8] { + let mut exponent = vec![0; exponent_length]; + exponent[0] = 3; + assert!(import_encoded_public(&mut modulus, &mut exponent)); + } + } + let mut modulus = vec![0; 256]; + modulus[0] = 1; + modulus[255] = 0x80; + assert!(!import_encoded_public(&mut modulus, &mut [1, 0, 0, 0, 1])); + assert!(!import_encoded_public(&mut modulus, &mut [2])); + let mut too_many_bits = vec![0; 2049]; + too_many_bits[0] = 1; + too_many_bits[2048] = 1; + assert!(!import_encoded_public(&mut too_many_bits, &mut [3])); + } + + fn public_key(modulus_last_byte: u8) -> ProviderKey { + let mut modulus = vec![0x80; 256]; + modulus[255] = modulus_last_byte; + ProviderKey { + magic: KEY_MAGIC, + provider_context: null_mut(), + material: KeyMaterial::Public { + modulus, + exponent: vec![3], + bits: 2_048, + }, + } + } + + #[test] + fn public_validation_accepts_odd_rsa_modulus() { + assert!(public_key(1).public_is_valid()); + } + + #[test] + fn public_validation_rejects_even_rsa_modulus() { + assert!(!public_key(2).public_is_valid()); + } + + #[test] + fn public_import_rejects_even_rsa_modulus() { + let mut modulus = [0x80_u8; 256]; + modulus[255] = 2; + let exponent = [3_u8]; + let mut modulus_storage = [0_u8; 256]; + let mut exponent_storage = [0_u8; 8]; + let mut modulus_parameter = OSSL_PARAM { + key: c"n".as_ptr(), + data_type: openssl_provider_sys::OSSL_PARAM_UNSIGNED_INTEGER, + data: modulus_storage.as_mut_ptr().cast(), + data_size: modulus_storage.len(), + return_size: usize::MAX, + }; + let mut exponent_parameter = OSSL_PARAM { + key: c"e".as_ptr(), + data_type: openssl_provider_sys::OSSL_PARAM_UNSIGNED_INTEGER, + data: exponent_storage.as_mut_ptr().cast(), + data_size: exponent_storage.len(), + return_size: usize::MAX, + }; + // SAFETY: source bytes are readable and returned BIGNUMs are owned by this test. + let modulus_bn = unsafe { BN_bin2bn(modulus.as_ptr(), modulus.len() as c_int, null_mut()) }; + // SAFETY: same invariant for the exponent. + let exponent_bn = + unsafe { BN_bin2bn(exponent.as_ptr(), exponent.len() as c_int, null_mut()) }; + assert!(!modulus_bn.is_null()); + assert!(!exponent_bn.is_null()); + // SAFETY: parameters and BIGNUMs are live for these immediate conversion calls. + let modulus_set = unsafe { OSSL_PARAM_set_BN(&raw mut modulus_parameter, modulus_bn) }; + assert_eq!(modulus_set, 1); + // SAFETY: same live parameter and BIGNUM invariant. + let exponent_set = unsafe { OSSL_PARAM_set_BN(&raw mut exponent_parameter, exponent_bn) }; + assert_eq!(exponent_set, 1); + // SAFETY: both BIGNUMs are owned by this test. + unsafe { + BN_free(modulus_bn); + BN_free(exponent_bn); + } + let parameters = [ + modulus_parameter, + exponent_parameter, + openssl_provider_sys::param_end(), + ]; + let mut key = ProviderKey { + magic: KEY_MAGIC, + provider_context: null_mut(), + material: KeyMaterial::Empty, + }; + + // SAFETY: the parameter array is live, valid, and terminated. + assert!(!unsafe { import_public(&mut key, parameters.as_ptr()) }); + assert!(matches!(key.material, KeyMaterial::Empty)); + } +} diff --git a/native/crates/cng-provider/src/keymgmt.rs b/native/crates/cng-provider/src/keymgmt.rs new file mode 100644 index 00000000..f82e16c7 --- /dev/null +++ b/native/crates/cng-provider/src/keymgmt.rs @@ -0,0 +1,491 @@ +use std::ffi::{c_int, c_void}; +use std::ptr::{null, null_mut}; + +use cng_core::{CngKey, DEFAULT_PROVIDER_NAME}; +use openssl_provider_sys::{ + OSSL_FUNC_KEYMGMT_DUP, OSSL_FUNC_KEYMGMT_EXPORT, OSSL_FUNC_KEYMGMT_EXPORT_TYPES, + OSSL_FUNC_KEYMGMT_FREE, OSSL_FUNC_KEYMGMT_GET_PARAMS, OSSL_FUNC_KEYMGMT_GETTABLE_PARAMS, + OSSL_FUNC_KEYMGMT_HAS, OSSL_FUNC_KEYMGMT_IMPORT, OSSL_FUNC_KEYMGMT_IMPORT_TYPES, + OSSL_FUNC_KEYMGMT_LOAD, OSSL_FUNC_KEYMGMT_MATCH, OSSL_FUNC_KEYMGMT_NEW, + OSSL_FUNC_KEYMGMT_QUERY_OPERATION_NAME, OSSL_FUNC_KEYMGMT_VALIDATE, + OSSL_KEYMGMT_SELECT_KEYPAIR, OSSL_KEYMGMT_SELECT_PRIVATE_KEY, OSSL_KEYMGMT_SELECT_PUBLIC_KEY, + OSSL_KEYMGMT_VALIDATE_FULL_CHECK, OSSL_KEYMGMT_VALIDATE_QUICK_CHECK, OSSL_PARAM, + OSSL_PARAM_INTEGER, OSSL_PARAM_UNSIGNED_INTEGER, OSSL_PARAM_UTF8_STRING, OsslCallback, + OsslDispatch, param_definition, param_end, +}; + +use crate::allocation; +use crate::error::{self, CNG_FAILURE, INVALID_PARAMETER, KEY_NOT_LOADED, PRIVATE_EXPORT_DISABLED}; +use crate::key::{ + KEY_MAGIC, KeyMaterial, ProviderKey, export_public, free_provider_key, import_public, + names_match, set_bn_param, +}; +use crate::params; + +const RSA_BITS: u32 = 2_048; + +struct StaticParams([OSSL_PARAM; N]); +// SAFETY: these arrays contain only immutable static key pointers and null data pointers. +unsafe impl Sync for StaticParams {} + +static PRIVATE_IMPORT_TYPES: StaticParams<3> = StaticParams([ + param_definition(c"cng-key-name".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"cng-provider-name".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_end(), +]); +static PUBLIC_IMPORT_TYPES: StaticParams<3> = StaticParams([ + param_definition(c"n".as_ptr(), OSSL_PARAM_UNSIGNED_INTEGER), + param_definition(c"e".as_ptr(), OSSL_PARAM_UNSIGNED_INTEGER), + param_end(), +]); +static GETTABLE_PARAMS: StaticParams<9> = StaticParams([ + param_definition(c"bits".as_ptr(), OSSL_PARAM_INTEGER), + param_definition(c"security-bits".as_ptr(), OSSL_PARAM_INTEGER), + param_definition(c"max-size".as_ptr(), OSSL_PARAM_INTEGER), + param_definition(c"default-digest".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"n".as_ptr(), OSSL_PARAM_UNSIGNED_INTEGER), + param_definition(c"e".as_ptr(), OSSL_PARAM_UNSIGNED_INTEGER), + param_definition(c"cng-key-name".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"cng-provider-name".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_end(), +]); +static EXPORT_TYPES: StaticParams<3> = StaticParams([ + param_definition(c"n".as_ptr(), OSSL_PARAM_UNSIGNED_INTEGER), + param_definition(c"e".as_ptr(), OSSL_PARAM_UNSIGNED_INTEGER), + param_end(), +]); + +unsafe extern "C" fn keymgmt_new(provider_context: *mut c_void) -> *mut c_void { + ffi_guard!(null_mut(), { + ProviderKey::new(provider_context, KeyMaterial::Empty).cast() + }) +} + +unsafe extern "C" fn keymgmt_load(reference: *const c_void, reference_size: usize) -> *mut c_void { + ffi_guard!(null_mut(), { + if reference.is_null() || reference_size != size_of::<*mut ProviderKey>() { + return null_mut(); + } + let mutable_reference = reference.cast::<*mut ProviderKey>().cast_mut(); + // SAFETY: STORE supplies a pointer-sized object-reference slot for this provider. + let key = unsafe { *mutable_reference }; + if key.is_null() { + return null_mut(); + } + // SAFETY: the reference is a provider-owned key pointer retained by STORE. + if unsafe { (*key).magic } != KEY_MAGIC { + return null_mut(); + } + // SAFETY: ownership transfers atomically from STORE to OpenSSL by nulling its slot. + unsafe { *mutable_reference = null_mut() }; + key.cast() + }) +} + +unsafe extern "C" fn keymgmt_free(key_data: *mut c_void) { + ffi_guard_void!({ + // SAFETY: OpenSSL returns provider key allocations exactly once. + unsafe { free_provider_key(key_data.cast()) }; + }); +} + +unsafe extern "C" fn keymgmt_dup(key_data: *const c_void, selection: c_int) -> *mut c_void { + ffi_guard!(null_mut(), { + if key_data.is_null() || selection & OSSL_KEYMGMT_SELECT_KEYPAIR == 0 { + return null_mut(); + } + // SAFETY: OpenSSL passes a live provider key for the duration of this call. + let source = unsafe { &*key_data.cast::() }; + if !source.is_valid() { + return null_mut(); + } + let private_key = source + .private_key() + .filter(|_| selection & OSSL_KEYMGMT_SELECT_PRIVATE_KEY != 0); + let material = if let Some(key) = private_key { + KeyMaterial::Private(key.clone()) + } else if selection & OSSL_KEYMGMT_SELECT_PUBLIC_KEY != 0 { + let Some((modulus, exponent, bits)) = source.public_parts() else { + return null_mut(); + }; + let Some(modulus) = allocation::try_copy_bytes(modulus) else { + error::raise_memory_failure(); + return null_mut(); + }; + let Some(exponent) = allocation::try_copy_bytes(exponent) else { + error::raise_memory_failure(); + return null_mut(); + }; + KeyMaterial::Public { + modulus, + exponent, + bits, + } + } else { + return null_mut(); + }; + ProviderKey::new(source.provider_context, material).cast() + }) +} + +unsafe extern "C" fn keymgmt_import( + key_data: *mut c_void, + selection: c_int, + input: *const OSSL_PARAM, +) -> c_int { + ffi_guard!(0, { + if key_data.is_null() || input.is_null() || selection & OSSL_KEYMGMT_SELECT_KEYPAIR == 0 { + error::raise( + INVALID_PARAMETER, + "RSA key import requires a keypair selection", + ); + return 0; + } + // SAFETY: OpenSSL passes a live provider key allocated by `keymgmt_new`. + let key = unsafe { &mut *key_data.cast::() }; + if key.magic != KEY_MAGIC { + return 0; + } + if selection & OSSL_KEYMGMT_SELECT_PRIVATE_KEY == 0 { + // SAFETY: the input is a valid terminated OpenSSL parameter array. + return i32::from(unsafe { import_public(key, input) }); + } + + // SAFETY: the input is a valid terminated OpenSSL parameter array. + let key_param = unsafe { params::locate(input, c"cng-key-name") }; + // SAFETY: the located parameter remains live with the input array. + let key_name = match unsafe { params::get_utf8_owned(key_param, 512) } { + Ok(value) => value, + Err(params::Utf8OwnedError::Memory) => { + error::raise_memory_failure(); + return 0; + } + Err(params::Utf8OwnedError::Invalid) => { + error::raise( + INVALID_PARAMETER, + "cng-key-name is required as a bounded UTF-8 string", + ); + return 0; + } + }; + // SAFETY: the input is a valid terminated OpenSSL parameter array. + let provider_param = unsafe { params::locate(input, c"cng-provider-name") }; + let provider_name = if provider_param.is_null() { + let Some(value) = allocation::try_copy_str(DEFAULT_PROVIDER_NAME) else { + error::raise_memory_failure(); + return 0; + }; + value + } else { + // SAFETY: the located parameter remains live with the input array. + match unsafe { params::get_utf8_owned(provider_param, 512) } { + Ok(value) => value, + Err(params::Utf8OwnedError::Memory) => { + error::raise_memory_failure(); + return 0; + } + Err(params::Utf8OwnedError::Invalid) => { + error::raise( + INVALID_PARAMETER, + "cng-provider-name is not a bounded UTF-8 string", + ); + return 0; + } + } + }; + match CngKey::open_or_create(&provider_name, &key_name, RSA_BITS) { + Ok(loaded) => { + key.material = KeyMaterial::Private(loaded); + 1 + } + Err(cng_error) => { + error::raise_cng(CNG_FAILURE, &cng_error); + 0 + } + } + }) +} + +unsafe extern "C" fn keymgmt_import_types(selection: c_int) -> *const OSSL_PARAM { + ffi_guard!(null(), { + if selection & OSSL_KEYMGMT_SELECT_PRIVATE_KEY != 0 { + PRIVATE_IMPORT_TYPES.0.as_ptr() + } else if selection & OSSL_KEYMGMT_SELECT_PUBLIC_KEY != 0 { + PUBLIC_IMPORT_TYPES.0.as_ptr() + } else { + null() + } + }) +} + +unsafe extern "C" fn keymgmt_has(key_data: *const c_void, selection: c_int) -> c_int { + ffi_guard!(0, { + if key_data.is_null() { + return 0; + } + // SAFETY: OpenSSL passes a live provider key for the duration of this call. + let key = unsafe { &*key_data.cast::() }; + i32::from( + key.is_valid() + && (selection & OSSL_KEYMGMT_SELECT_PRIVATE_KEY == 0 + || key.private_key().is_some()) + && (selection & OSSL_KEYMGMT_SELECT_PUBLIC_KEY == 0 + || key.public_parts().is_some()), + ) + }) +} + +unsafe extern "C" fn keymgmt_validate( + key_data: *const c_void, + selection: c_int, + check_type: c_int, +) -> c_int { + ffi_guard!(0, { + if !matches!( + check_type, + OSSL_KEYMGMT_VALIDATE_FULL_CHECK | OSSL_KEYMGMT_VALIDATE_QUICK_CHECK + ) { + return 0; + } + // SAFETY: forwards the same live key pointer and selection. + if unsafe { keymgmt_has(key_data, selection) } != 1 { + return 0; + } + if selection & OSSL_KEYMGMT_SELECT_KEYPAIR == 0 { + return 1; + } + // SAFETY: `keymgmt_has` established a valid non-null provider key. + i32::from(unsafe { (&*key_data.cast::()).public_is_valid() }) + }) +} + +unsafe extern "C" fn keymgmt_match( + left_data: *const c_void, + right_data: *const c_void, + selection: c_int, +) -> c_int { + ffi_guard!(0, { + if left_data.is_null() || right_data.is_null() { + return 0; + } + // SAFETY: OpenSSL passes two live provider keys for this synchronous comparison. + let left = unsafe { &*left_data.cast::() }; + // SAFETY: same invariant as `left`. + let right = unsafe { &*right_data.cast::() }; + if !left.is_valid() || !right.is_valid() { + return 0; + } + i32::from(selection & OSSL_KEYMGMT_SELECT_KEYPAIR == 0 || names_match(left, right)) + }) +} + +unsafe extern "C" fn keymgmt_get_params(key_data: *mut c_void, output: *mut OSSL_PARAM) -> c_int { + ffi_guard!(0, { + if key_data.is_null() || output.is_null() { + return 0; + } + // SAFETY: OpenSSL passes a live provider key and mutable terminated parameter array. + let key = unsafe { &*key_data.cast::() }; + let Some((modulus, exponent, bits)) = key.public_parts() else { + error::raise(KEY_NOT_LOADED, "no CNG key has been loaded"); + return 0; + }; + + // SAFETY: each locate/set pair operates on the live mutable output array. + let parameter = unsafe { params::locate_mut(output, c"bits") }; + let parameter_set = parameter.is_null() || { + // SAFETY: the located output parameter remains live and writable. + unsafe { params::set_int(parameter, bits as c_int, false) } + }; + if !parameter_set { + return 0; + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"security-bits") }; + let parameter_set = parameter.is_null() || { + // SAFETY: the located output parameter remains live and writable. + unsafe { params::set_int(parameter, key.security_bits() as c_int, false) } + }; + if !parameter_set { + return 0; + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"max-size") }; + let parameter_set = parameter.is_null() || { + // SAFETY: the located output parameter remains live and writable. + unsafe { params::set_int(parameter, key.max_size() as c_int, false) } + }; + if !parameter_set { + return 0; + } + // SAFETY: same output-array invariant; the returned string is static. + let parameter = unsafe { params::locate_mut(output, c"default-digest") }; + let parameter_set = parameter.is_null() || { + // SAFETY: the located output parameter remains live and the string is static. + unsafe { params::set_utf8(parameter, c"SHA256") } + }; + if !parameter_set { + return 0; + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"n") }; + let parameter_set = parameter.is_null() || { + // SAFETY: the located output parameter and provider key remain live. + unsafe { set_bn_param(key, parameter, modulus) } + }; + if !parameter_set { + return 0; + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"e") }; + let parameter_set = parameter.is_null() || { + // SAFETY: the located output parameter and provider key remain live. + unsafe { set_bn_param(key, parameter, exponent) } + }; + if !parameter_set { + return 0; + } + + if let Some(private) = key.private_key() { + // SAFETY: same output-array invariant. Gettable metadata declares UTF8_STRING, so + // OpenSSL copies these dynamic strings during the callback. + let parameter = unsafe { params::locate_mut(output, c"cng-key-name") }; + let parameter_set = parameter.is_null() || { + // SAFETY: the output parameter remains live and OpenSSL copies UTF8_STRING data. + unsafe { params::set_utf8_str(parameter, private.key_name()) } + }; + if !parameter_set { + return 0; + } + // SAFETY: same output-array and synchronous-copy invariant. + let parameter = unsafe { params::locate_mut(output, c"cng-provider-name") }; + let parameter_set = parameter.is_null() || { + // SAFETY: the output parameter remains live and OpenSSL copies UTF8_STRING data. + unsafe { params::set_utf8_str(parameter, private.provider_name()) } + }; + if !parameter_set { + return 0; + } + } + 1 + }) +} + +unsafe extern "C" fn keymgmt_gettable_params(_provider_context: *mut c_void) -> *const OSSL_PARAM { + ffi_guard!(null(), { GETTABLE_PARAMS.0.as_ptr() }) +} + +unsafe extern "C" fn keymgmt_export( + key_data: *mut c_void, + selection: c_int, + callback: OsslCallback, + callback_argument: *mut c_void, +) -> c_int { + ffi_guard!(0, { + if key_data.is_null() { + return 0; + } + // SAFETY: OpenSSL passes a live provider key. + let key = unsafe { &*key_data.cast::() }; + if !key.is_valid() { + return 0; + } + if selection & OSSL_KEYMGMT_SELECT_PRIVATE_KEY != 0 { + error::raise( + PRIVATE_EXPORT_DISABLED, + "the CNG private key cannot be exported", + ); + return 0; + } + if selection & OSSL_KEYMGMT_SELECT_PUBLIC_KEY == 0 { + return 0; + } + // SAFETY: callback and callback argument are valid only for this synchronous export. + i32::from(unsafe { export_public(key, callback, callback_argument) }) + }) +} + +unsafe extern "C" fn keymgmt_export_types(selection: c_int) -> *const OSSL_PARAM { + ffi_guard!(null(), { + if selection & OSSL_KEYMGMT_SELECT_PRIVATE_KEY != 0 + || selection & OSSL_KEYMGMT_SELECT_PUBLIC_KEY == 0 + { + null() + } else { + EXPORT_TYPES.0.as_ptr() + } + }) +} + +unsafe extern "C" fn keymgmt_query_operation_name(operation_id: c_int) -> *const i8 { + ffi_guard!(null(), { + if operation_id == openssl_provider_sys::OSSL_OP_SIGNATURE { + c"RSA".as_ptr() + } else { + null() + } + }) +} + +pub static KEYMGMT_FUNCTIONS: [OsslDispatch; 15] = [ + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_NEW, + function: keymgmt_new as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_LOAD, + function: keymgmt_load as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_FREE, + function: keymgmt_free as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_DUP, + function: keymgmt_dup as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_IMPORT, + function: keymgmt_import as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_IMPORT_TYPES, + function: keymgmt_import_types as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_HAS, + function: keymgmt_has as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_VALIDATE, + function: keymgmt_validate as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_MATCH, + function: keymgmt_match as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_GET_PARAMS, + function: keymgmt_get_params as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_GETTABLE_PARAMS, + function: keymgmt_gettable_params as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_EXPORT, + function: keymgmt_export as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_EXPORT_TYPES, + function: keymgmt_export_types as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_KEYMGMT_QUERY_OPERATION_NAME, + function: keymgmt_query_operation_name as *const c_void, + }, + OsslDispatch { + function_id: 0, + function: null(), + }, +]; diff --git a/native/crates/cng-provider/src/lib.rs b/native/crates/cng-provider/src/lib.rs new file mode 100644 index 00000000..182ca53f --- /dev/null +++ b/native/crates/cng-provider/src/lib.rs @@ -0,0 +1,303 @@ +use std::ffi::{CStr, c_int, c_void}; +use std::ptr::{null, null_mut}; + +use openssl_provider_sys::{ + OSSL_FUNC_PROVIDER_GET_PARAMS, OSSL_FUNC_PROVIDER_GET_REASON_STRINGS, + OSSL_FUNC_PROVIDER_GETTABLE_PARAMS, OSSL_FUNC_PROVIDER_QUERY_OPERATION, + OSSL_FUNC_PROVIDER_TEARDOWN, OSSL_OP_KEYMGMT, OSSL_OP_SIGNATURE, OSSL_OP_STORE, OSSL_PARAM, + OSSL_PARAM_UNSIGNED_INTEGER, OSSL_PARAM_UTF8_PTR, OsslAlgorithm, OsslCoreHandle, OsslDispatch, + OsslItem, param_definition, param_end, +}; + +macro_rules! ffi_guard { + ($default:expr, $body:block) => {{ + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)).unwrap_or_else(|_| { + crate::error::raise( + crate::error::INVALID_PARAMETER, + "a Rust panic was contained at the provider FFI boundary", + ); + $default + }) + }}; +} + +macro_rules! ffi_guard_void { + ($body:block) => {{ + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| $body)).map_err(|_| { + crate::error::raise( + crate::error::INVALID_PARAMETER, + "a Rust panic was contained at the provider FFI boundary", + ); + }); + }}; +} + +mod allocation; +mod error; +mod key; +mod keymgmt; +mod params; +mod signature; +mod store; +mod uri; + +struct StaticParams([OSSL_PARAM; N]); +// SAFETY: these arrays contain only immutable static key pointers and null data pointers. +unsafe impl Sync for StaticParams {} + +struct ProviderContext { + _handle: *const OsslCoreHandle, +} + +const PROVIDER_VERSION_BYTES: &[u8] = concat!(env!("CARGO_PKG_VERSION"), "\0").as_bytes(); + +fn provider_version() -> &'static CStr { + // SAFETY: Cargo package versions are valid UTF-8 without interior NUL bytes, and the + // compile-time concatenation appends exactly one terminating NUL. + unsafe { CStr::from_bytes_with_nul_unchecked(PROVIDER_VERSION_BYTES) } +} + +static PROVIDER_GETTABLE_PARAMS: StaticParams<5> = StaticParams([ + param_definition(c"name".as_ptr(), OSSL_PARAM_UTF8_PTR), + param_definition(c"version".as_ptr(), OSSL_PARAM_UTF8_PTR), + param_definition(c"buildinfo".as_ptr(), OSSL_PARAM_UTF8_PTR), + param_definition(c"status".as_ptr(), OSSL_PARAM_UNSIGNED_INTEGER), + param_end(), +]); + +unsafe extern "C" fn provider_gettable_params(_context: *mut c_void) -> *const OSSL_PARAM { + ffi_guard!(null(), { PROVIDER_GETTABLE_PARAMS.0.as_ptr() }) +} + +unsafe extern "C" fn provider_get_params(_context: *mut c_void, output: *mut OSSL_PARAM) -> c_int { + ffi_guard!(0, { + if output.is_null() { + return 0; + } + // SAFETY: OpenSSL passes a mutable terminated parameter array. + let parameter = unsafe { params::locate_mut(output, c"name") }; + if !parameter.is_null() + // SAFETY: the output parameter is live and the returned string is static. + && !unsafe { + params::set_utf8_static(parameter, c"Windows CNG RSA provider (Rust)") + } + { + return 0; + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"version") }; + if !parameter.is_null() + // SAFETY: the output parameter is live and the returned string is static. + && !unsafe { params::set_utf8_static(parameter, provider_version()) } + { + return 0; + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"buildinfo") }; + if !parameter.is_null() + // SAFETY: the output parameter is live and the returned string is static. + && !unsafe { + params::set_utf8_static(parameter, c"Rust OpenSSL 3 provider using NCrypt") + } + { + return 0; + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"status") }; + let status_set = parameter.is_null() || { + // SAFETY: the located output parameter remains live and writable. + unsafe { params::set_int(parameter, 1, true) } + }; + if !status_set { + return 0; + } + 1 + }) +} + +static KEYMGMT_ALGORITHMS: [OsslAlgorithm; 2] = [ + OsslAlgorithm { + algorithm_names: c"RSA:rsaEncryption".as_ptr(), + property_definition: c"provider=microsoft_identity_native_provider,cng.provider=yes" + .as_ptr(), + implementation: keymgmt::KEYMGMT_FUNCTIONS.as_ptr(), + algorithm_description: c"Windows CNG persisted RSA key management".as_ptr(), + }, + OsslAlgorithm { + algorithm_names: null(), + property_definition: null(), + implementation: null(), + algorithm_description: null(), + }, +]; + +static SIGNATURE_ALGORITHMS: [OsslAlgorithm; 2] = [ + OsslAlgorithm { + algorithm_names: c"RSA".as_ptr(), + property_definition: c"provider=microsoft_identity_native_provider,cng.provider=yes" + .as_ptr(), + implementation: signature::SIGNATURE_DISPATCH.as_ptr(), + algorithm_description: c"Windows CNG RSA signatures".as_ptr(), + }, + OsslAlgorithm { + algorithm_names: null(), + property_definition: null(), + implementation: null(), + algorithm_description: null(), + }, +]; + +static STORE_ALGORITHMS: [OsslAlgorithm; 2] = [ + OsslAlgorithm { + algorithm_names: c"cng".as_ptr(), + property_definition: c"provider=microsoft_identity_native_provider,cng.provider=yes" + .as_ptr(), + implementation: store::STORE_FUNCTIONS.as_ptr(), + algorithm_description: c"CNG persisted-key STORE loader".as_ptr(), + }, + OsslAlgorithm { + algorithm_names: null(), + property_definition: null(), + implementation: null(), + algorithm_description: null(), + }, +]; + +unsafe extern "C" fn provider_query_operation( + _context: *mut c_void, + operation_id: c_int, + no_cache: *mut c_int, +) -> *const OsslAlgorithm { + ffi_guard!(null(), { + if !no_cache.is_null() { + // SAFETY: OpenSSL supplies writable integer storage when non-null. + unsafe { *no_cache = 0 }; + } + match operation_id { + OSSL_OP_KEYMGMT => KEYMGMT_ALGORITHMS.as_ptr(), + OSSL_OP_SIGNATURE => SIGNATURE_ALGORITHMS.as_ptr(), + OSSL_OP_STORE => STORE_ALGORITHMS.as_ptr(), + _ => null(), + } + }) +} + +static REASON_STRINGS: [OsslItem; 12] = [ + OsslItem { + id: error::INVALID_PARAMETER as u32, + ptr: c"invalid parameter".as_ptr().cast_mut().cast(), + }, + OsslItem { + id: error::CNG_FAILURE as u32, + ptr: c"Windows CNG operation failed".as_ptr().cast_mut().cast(), + }, + OsslItem { + id: error::MEMORY_FAILURE as u32, + ptr: c"memory allocation failed".as_ptr().cast_mut().cast(), + }, + OsslItem { + id: error::KEY_NOT_LOADED as u32, + ptr: c"CNG key is not loaded".as_ptr().cast_mut().cast(), + }, + OsslItem { + id: error::UNSUPPORTED_DIGEST as u32, + ptr: c"unsupported digest".as_ptr().cast_mut().cast(), + }, + OsslItem { + id: error::UNSUPPORTED_PADDING as u32, + ptr: c"unsupported RSA padding".as_ptr().cast_mut().cast(), + }, + OsslItem { + id: error::PRIVATE_EXPORT_DISABLED as u32, + ptr: c"private-key export is disabled".as_ptr().cast_mut().cast(), + }, + OsslItem { + id: error::SIGNATURE_FAILURE as u32, + ptr: c"RSA signature operation failed".as_ptr().cast_mut().cast(), + }, + OsslItem { + id: error::INVALID_URI as u32, + ptr: c"invalid CNG STORE URI".as_ptr().cast_mut().cast(), + }, + OsslItem { + id: error::INVALID_REFERENCE as u32, + ptr: c"invalid provider object reference" + .as_ptr() + .cast_mut() + .cast(), + }, + OsslItem { + id: error::KEY_VALIDATION_FAILURE as u32, + ptr: c"CNG key validation failed".as_ptr().cast_mut().cast(), + }, + OsslItem { + id: 0, + ptr: null_mut(), + }, +]; + +unsafe extern "C" fn provider_get_reason_strings(_context: *mut c_void) -> *const OsslItem { + ffi_guard!(null(), { REASON_STRINGS.as_ptr() }) +} + +unsafe extern "C" fn provider_teardown(context: *mut c_void) { + ffi_guard_void!({ + if !context.is_null() { + // SAFETY: OpenSSL releases the Box from OSSL_provider_init exactly once after all + // provider-owned operation objects have been freed. + drop(unsafe { Box::from_raw(context.cast::()) }); + } + }); +} + +static PROVIDER_FUNCTIONS: [OsslDispatch; 6] = [ + OsslDispatch { + function_id: OSSL_FUNC_PROVIDER_TEARDOWN, + function: provider_teardown as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_PROVIDER_GETTABLE_PARAMS, + function: provider_gettable_params as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_PROVIDER_GET_PARAMS, + function: provider_get_params as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_PROVIDER_QUERY_OPERATION, + function: provider_query_operation as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_PROVIDER_GET_REASON_STRINGS, + function: provider_get_reason_strings as *const c_void, + }, + OsslDispatch { + function_id: 0, + function: null(), + }, +]; + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn OSSL_provider_init( + handle: *const OsslCoreHandle, + input: *const OsslDispatch, + output: *mut *const OsslDispatch, + provider_context: *mut *mut c_void, +) -> c_int { + ffi_guard!(0, { + if handle.is_null() || input.is_null() || output.is_null() || provider_context.is_null() { + return 0; + } + let Some(context) = allocation::try_box(ProviderContext { _handle: handle }) else { + error::raise_memory_failure(); + return 0; + }; + let context = Box::into_raw(context); + // SAFETY: both output pointers were validated and are writable for one pointer. + unsafe { + *provider_context = context.cast(); + *output = PROVIDER_FUNCTIONS.as_ptr(); + } + 1 + }) +} diff --git a/native/crates/cng-provider/src/params.rs b/native/crates/cng-provider/src/params.rs new file mode 100644 index 00000000..81a29f43 --- /dev/null +++ b/native/crates/cng-provider/src/params.rs @@ -0,0 +1,339 @@ +use std::ffi::{CStr, c_char, c_int}; +use std::ptr::copy_nonoverlapping; + +use openssl_provider_sys::{ + OSSL_PARAM, OSSL_PARAM_INTEGER, OSSL_PARAM_OCTET_STRING, OSSL_PARAM_UNSIGNED_INTEGER, + OSSL_PARAM_UTF8_PTR, OSSL_PARAM_UTF8_STRING, +}; + +use crate::allocation; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Utf8OwnedError { + Invalid, + Memory, +} + +pub unsafe fn locate(mut params: *const OSSL_PARAM, name: &CStr) -> *const OSSL_PARAM { + if params.is_null() { + return std::ptr::null(); + } + loop { + // SAFETY: callers provide a valid OSSL_PARAM array terminated by a null key. + let param = unsafe { &*params }; + if param.key.is_null() { + return std::ptr::null(); + } + // SAFETY: OpenSSL parameter keys are NUL-terminated strings. + if unsafe { CStr::from_ptr(param.key) } == name { + return params; + } + // SAFETY: the terminator check above permits advancing to the next array element. + params = unsafe { params.add(1) }; + } +} + +pub unsafe fn locate_mut(params: *mut OSSL_PARAM, name: &CStr) -> *mut OSSL_PARAM { + // SAFETY: mutable and const OSSL_PARAM arrays have identical traversal rules. + unsafe { locate(params.cast_const(), name).cast_mut() } +} + +pub unsafe fn get_utf8_owned( + param: *const OSSL_PARAM, + maximum: usize, +) -> Result { + if param.is_null() || maximum == 0 { + return Err(Utf8OwnedError::Invalid); + } + // SAFETY: the pointer was checked and comes from an OpenSSL parameter array. + let param = unsafe { &*param }; + let bytes = if param.data_type == OSSL_PARAM_UTF8_STRING { + if param.data.is_null() || param.data_size == 0 || param.data_size >= maximum { + return Err(Utf8OwnedError::Invalid); + } + // SAFETY: OpenSSL declares `data_size` readable bytes for UTF8_STRING. + let bytes = unsafe { std::slice::from_raw_parts(param.data.cast::(), param.data_size) }; + if bytes.contains(&0) { + return Err(Utf8OwnedError::Invalid); + } + bytes + } else if param.data_type == OSSL_PARAM_UTF8_PTR { + if param.data.is_null() { + return Err(Utf8OwnedError::Invalid); + } + // SAFETY: UTF8_PTR data points to a valid pointer-sized slot containing a C string. + let value = unsafe { *param.data.cast::<*const c_char>() }; + if value.is_null() { + return Err(Utf8OwnedError::Invalid); + } + let mut length = 0; + while length < maximum { + // SAFETY: OpenSSL's UTF8_PTR contract supplies a readable NUL-terminated string. + if unsafe { *value.add(length) } == 0 { + break; + } + length += 1; + } + if length == maximum { + return Err(Utf8OwnedError::Invalid); + } + // SAFETY: the bounded scan above found a terminator after `length` readable bytes. + unsafe { std::slice::from_raw_parts(value.cast::(), length) } + } else { + return Err(Utf8OwnedError::Invalid); + }; + let value = std::str::from_utf8(bytes).map_err(|_| Utf8OwnedError::Invalid)?; + allocation::try_copy_str(value).ok_or(Utf8OwnedError::Memory) +} + +pub unsafe fn get_int(param: *const OSSL_PARAM) -> Option { + if param.is_null() { + return None; + } + // SAFETY: the pointer was checked and comes from an OpenSSL parameter array. + let param = unsafe { &*param }; + if param.data.is_null() { + return None; + } + match (param.data_type, param.data_size) { + (OSSL_PARAM_INTEGER, 4) => { + // SAFETY: data_size confirms a readable i32 slot; unaligned access is permitted. + Some(unsafe { param.data.cast::().read_unaligned() }) + } + (OSSL_PARAM_INTEGER, 8) => { + // SAFETY: data_size confirms a readable i64 slot; unaligned access is permitted. + i32::try_from(unsafe { param.data.cast::().read_unaligned() }).ok() + } + (OSSL_PARAM_UNSIGNED_INTEGER, 4) => { + // SAFETY: data_size confirms a readable u32 slot; unaligned access is permitted. + i32::try_from(unsafe { param.data.cast::().read_unaligned() }).ok() + } + (OSSL_PARAM_UNSIGNED_INTEGER, 8) => { + // SAFETY: data_size confirms a readable u64 slot; unaligned access is permitted. + i32::try_from(unsafe { param.data.cast::().read_unaligned() }).ok() + } + _ => None, + } +} + +pub unsafe fn set_int(param: *mut OSSL_PARAM, value: c_int, unsigned: bool) -> bool { + if param.is_null() { + return false; + } + // SAFETY: the pointer was checked and comes from a mutable OpenSSL parameter array. + let param = unsafe { &mut *param }; + match (param.data_type, param.data_size) { + (OSSL_PARAM_INTEGER, 4) => { + param.return_size = 4; + if !param.data.is_null() { + // SAFETY: data_size confirms a writable four-byte slot. + unsafe { param.data.cast::().write_unaligned(value) }; + } + true + } + (OSSL_PARAM_INTEGER, 8) => { + param.return_size = 8; + if !param.data.is_null() { + // SAFETY: data_size confirms a writable eight-byte slot. + unsafe { param.data.cast::().write_unaligned(i64::from(value)) }; + } + true + } + (OSSL_PARAM_UNSIGNED_INTEGER, 4) if unsigned || value >= 0 => { + param.return_size = 4; + if !param.data.is_null() { + // SAFETY: data_size confirms a writable four-byte slot. + unsafe { param.data.cast::().write_unaligned(value as u32) }; + } + true + } + (OSSL_PARAM_UNSIGNED_INTEGER, 8) if unsigned || value >= 0 => { + param.return_size = 8; + if !param.data.is_null() { + // SAFETY: data_size confirms a writable eight-byte slot. + unsafe { param.data.cast::().write_unaligned(value as u64) }; + } + true + } + (data_type, 0) + if param.data.is_null() + && matches!(data_type, OSSL_PARAM_INTEGER | OSSL_PARAM_UNSIGNED_INTEGER) => + { + param.return_size = 4; + true + } + _ => false, + } +} + +unsafe fn set_utf8_bytes(param: *mut OSSL_PARAM, bytes: &[u8]) -> bool { + if param.is_null() { + return false; + } + // SAFETY: the pointer was checked and comes from a mutable OpenSSL parameter array. + let param = unsafe { &mut *param }; + if param.data_type != OSSL_PARAM_UTF8_STRING || bytes.contains(&0) { + return false; + } + param.return_size = bytes.len(); + if param.data.is_null() { + return true; + } + if param.data_size < bytes.len() { + return false; + } + // SAFETY: data_size confirms enough writable bytes for the copied string. + unsafe { + copy_nonoverlapping(bytes.as_ptr(), param.data.cast::(), bytes.len()); + if param.data_size > bytes.len() { + param.data.cast::().add(bytes.len()).write(0); + } + } + true +} + +pub unsafe fn set_utf8(param: *mut OSSL_PARAM, value: &CStr) -> bool { + // SAFETY: forwards the same live parameter and NUL-free C string contents. + unsafe { set_utf8_bytes(param, value.to_bytes()) } +} + +pub unsafe fn set_utf8_str(param: *mut OSSL_PARAM, value: &str) -> bool { + // SAFETY: forwards the same live parameter and borrowed UTF-8 bytes. + unsafe { set_utf8_bytes(param, value.as_bytes()) } +} + +pub unsafe fn set_utf8_static(param: *mut OSSL_PARAM, value: &'static CStr) -> bool { + if param.is_null() { + return false; + } + // SAFETY: the pointer was checked and comes from a mutable OpenSSL parameter array. + let param = unsafe { &mut *param }; + if param.data_type == OSSL_PARAM_UTF8_STRING { + // SAFETY: forwards the same live parameter; the copy setter accepts any CStr lifetime. + return unsafe { set_utf8(param, value) }; + } + if param.data_type != OSSL_PARAM_UTF8_PTR { + return false; + } + param.return_size = value.to_bytes().len(); + if param.data.is_null() { + return true; + } + // SAFETY: UTF8_PTR data is a writable pointer slot; data_size describes the referenced string, + // not the slot. `value` has static lifetime. + unsafe { param.data.cast::<*const c_char>().write(value.as_ptr()) }; + true +} + +pub unsafe fn set_octets(param: *mut OSSL_PARAM, value: &[u8]) -> bool { + if param.is_null() { + return false; + } + // SAFETY: the pointer was checked and comes from a mutable OpenSSL parameter array. + let param = unsafe { &mut *param }; + param.return_size = value.len(); + if param.data.is_null() { + return true; + } + if param.data_type != OSSL_PARAM_OCTET_STRING || param.data_size < value.len() { + return false; + } + // SAFETY: data_size confirms a writable destination at least as large as `value`. + unsafe { copy_nonoverlapping(value.as_ptr(), param.data.cast::(), value.len()) }; + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dynamic_utf8_never_sets_utf8_pointer_backing() { + let dynamic = std::ffi::CString::new("dynamic").expect("CString"); + let original = c"unchanged".as_ptr(); + let mut slot = original; + let mut parameter = OSSL_PARAM { + key: c"value".as_ptr(), + data_type: OSSL_PARAM_UTF8_PTR, + data: (&raw mut slot).cast(), + data_size: 0, + return_size: usize::MAX, + }; + + // SAFETY: parameter and pointer slot are live for this immediate setter call. + assert!(!unsafe { set_utf8(&raw mut parameter, &dynamic) }); + assert_eq!(slot, original); + } + + #[test] + fn static_utf8_pointer_setter_requires_static_backing() { + let mut slot: *const c_char = std::ptr::null(); + let mut parameter = OSSL_PARAM { + key: c"value".as_ptr(), + data_type: OSSL_PARAM_UTF8_PTR, + data: (&raw mut slot).cast(), + data_size: 0, + return_size: usize::MAX, + }; + + // SAFETY: parameter and pointer slot are live, and the supplied C string is static. + assert!(unsafe { set_utf8_static(&raw mut parameter, c"static") }); + assert_eq!(slot, c"static".as_ptr()); + assert_eq!(parameter.return_size, 6); + } + + #[test] + fn utf8_string_setter_copies_dynamic_data() { + let dynamic = std::ffi::CString::new("copy").expect("CString"); + let mut buffer = [0x7f_i8; 8]; + let mut parameter = OSSL_PARAM { + key: c"value".as_ptr(), + data_type: OSSL_PARAM_UTF8_STRING, + data: buffer.as_mut_ptr().cast(), + data_size: buffer.len(), + return_size: usize::MAX, + }; + + // SAFETY: parameter and destination buffer are live for this immediate setter call. + assert!(unsafe { set_utf8(&raw mut parameter, &dynamic) }); + assert_eq!( + &buffer[..5], + &[b'c' as i8, b'o' as i8, b'p' as i8, b'y' as i8, 0] + ); + } + + #[test] + fn integer_setter_supports_size_queries_for_both_integer_types() { + for data_type in [OSSL_PARAM_INTEGER, OSSL_PARAM_UNSIGNED_INTEGER] { + let mut parameter = OSSL_PARAM { + key: c"value".as_ptr(), + data_type, + data: std::ptr::null_mut(), + data_size: 0, + return_size: usize::MAX, + }; + // SAFETY: parameter is live and intentionally requests only the required size. + assert!(unsafe { set_int(&raw mut parameter, 6, false) }); + assert_eq!(parameter.return_size, size_of::()); + } + } + + #[test] + fn owned_utf8_reports_allocation_failure() { + let mut value = *b"value"; + let parameter = OSSL_PARAM { + key: c"value".as_ptr(), + data_type: OSSL_PARAM_UTF8_STRING, + data: value.as_mut_ptr().cast(), + data_size: value.len(), + return_size: usize::MAX, + }; + allocation::fail_string_allocation_after(0); + + assert_eq!( + // SAFETY: parameter and its UTF-8 backing bytes remain live for this call. + unsafe { get_utf8_owned(&raw const parameter, 64) }, + Err(Utf8OwnedError::Memory) + ); + } +} diff --git a/native/crates/cng-provider/src/signature.rs b/native/crates/cng-provider/src/signature.rs new file mode 100644 index 00000000..aa4594bb --- /dev/null +++ b/native/crates/cng-provider/src/signature.rs @@ -0,0 +1,1242 @@ +use std::ffi::{CStr, c_char, c_int, c_void}; +use std::ptr::{null, null_mut}; + +use cng_core::{CngKey, Digest, HashState, RsaPadding}; +use openssl_provider_sys::{ + OSSL_FUNC_SIGNATURE_DIGEST_SIGN, OSSL_FUNC_SIGNATURE_DIGEST_SIGN_FINAL, + OSSL_FUNC_SIGNATURE_DIGEST_SIGN_INIT, OSSL_FUNC_SIGNATURE_DIGEST_SIGN_UPDATE, + OSSL_FUNC_SIGNATURE_DUPCTX, OSSL_FUNC_SIGNATURE_FREECTX, OSSL_FUNC_SIGNATURE_GET_CTX_PARAMS, + OSSL_FUNC_SIGNATURE_GETTABLE_CTX_PARAMS, OSSL_FUNC_SIGNATURE_NEWCTX, + OSSL_FUNC_SIGNATURE_SET_CTX_PARAMS, OSSL_FUNC_SIGNATURE_SETTABLE_CTX_PARAMS, OSSL_PARAM, + OSSL_PARAM_INTEGER, OSSL_PARAM_OCTET_STRING, OSSL_PARAM_UTF8_STRING, OsslDispatch, + RSA_PKCS1_PADDING, RSA_PKCS1_PSS_PADDING, RSA_PSS_SALTLEN_AUTO, + RSA_PSS_SALTLEN_AUTO_DIGEST_MAX, RSA_PSS_SALTLEN_DIGEST, RSA_PSS_SALTLEN_MAX, param_definition, + param_end, +}; + +use crate::allocation; +use crate::error::{ + self, CNG_FAILURE, INVALID_PARAMETER, KEY_NOT_LOADED, SIGNATURE_FAILURE, UNSUPPORTED_DIGEST, + UNSUPPORTED_PADDING, +}; +use crate::key::{KEY_MAGIC, ProviderKey}; +use crate::params; + +struct StaticParams([OSSL_PARAM; N]); +// SAFETY: these arrays contain only immutable static key pointers and null data pointers. +unsafe impl Sync for StaticParams {} + +static SETTABLE_PARAMS: StaticParams<7> = StaticParams([ + param_definition(c"digest".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"pad-mode".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"pad-mode".as_ptr(), OSSL_PARAM_INTEGER), + param_definition(c"mgf1-digest".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"saltlen".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"saltlen".as_ptr(), OSSL_PARAM_INTEGER), + param_end(), +]); +static GETTABLE_PARAMS: StaticParams<8> = StaticParams([ + param_definition(c"pad-mode".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"pad-mode".as_ptr(), OSSL_PARAM_INTEGER), + param_definition(c"digest".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"mgf1-digest".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"saltlen".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_definition(c"saltlen".as_ptr(), OSSL_PARAM_INTEGER), + param_definition(c"algorithm-id".as_ptr(), OSSL_PARAM_OCTET_STRING), + param_end(), +]); + +pub struct SignatureContext { + provider_context: *mut c_void, + key: Option, + hash: Option, + digest: Option, + hash_started: bool, + padding: c_int, + pss_salt_length: c_int, + mgf1_digest: Option, + mgf1_explicit: bool, +} + +impl SignatureContext { + fn new(provider_context: *mut c_void) -> Self { + Self { + provider_context, + key: None, + hash: None, + digest: None, + hash_started: false, + padding: RSA_PKCS1_PADDING, + pss_salt_length: RSA_PSS_SALTLEN_DIGEST, + mgf1_digest: None, + mgf1_explicit: false, + } + } + + fn validate_mgf1_digest(digest: Option, mgf1_digest: Option) -> bool { + if mgf1_digest.is_some() && mgf1_digest == digest { + return true; + } + error::raise( + UNSUPPORTED_DIGEST, + "Windows CNG requires the PSS MGF1 digest to match the message digest", + ); + false + } + + fn set_digest(&mut self, name: &str) -> bool { + let Some(digest) = Digest::parse(name) else { + error::raise( + UNSUPPORTED_DIGEST, + format_args!("digest {name} is not supported"), + ); + return false; + }; + if self.hash_started && self.digest != Some(digest) { + error::raise( + INVALID_PARAMETER, + "the digest cannot change after message data is supplied", + ); + return false; + } + if self.mgf1_explicit && !Self::validate_mgf1_digest(Some(digest), self.mgf1_digest) { + return false; + } + if self.digest == Some(digest) && self.hash.is_some() { + return true; + } + let hash = match HashState::new(digest) { + Ok(hash) => hash, + Err(cng_error) => { + error::raise_cng(CNG_FAILURE, &cng_error); + return false; + } + }; + self.hash = Some(hash); + self.digest = Some(digest); + if !self.mgf1_explicit { + self.mgf1_digest = Some(digest); + } + true + } + + unsafe fn set_params(&mut self, input: *const OSSL_PARAM) -> bool { + if input.is_null() { + return true; + } + let mut padding = self.padding; + let mut salt_length = self.pss_salt_length; + let mut digest = self.digest; + let mut mgf1_digest = self.mgf1_digest; + let mut mgf1_explicit = self.mgf1_explicit; + + // SAFETY: `input` is an OpenSSL-owned terminated parameter array. + let parameter = unsafe { params::locate(input, c"pad-mode") }; + if !parameter.is_null() { + // SAFETY: the located parameter remains live with `input`. + let value = match unsafe { parse_padding(parameter) } { + Ok(Some(value)) => value, + Ok(None) | Err(params::Utf8OwnedError::Invalid) => { + error::raise( + UNSUPPORTED_PADDING, + "only PKCS#1 v1.5 and RSA-PSS signing are supported", + ); + return false; + } + Err(params::Utf8OwnedError::Memory) => { + error::raise_memory_failure(); + return false; + } + }; + padding = value; + } + + // SAFETY: `input` is an OpenSSL-owned terminated parameter array. + let parameter = unsafe { params::locate(input, c"digest") }; + if !parameter.is_null() { + // SAFETY: the located parameter remains live with `input`. + let value = match unsafe { params::get_utf8_owned(parameter, 64) } { + Ok(value) => value, + Err(params::Utf8OwnedError::Memory) => { + error::raise_memory_failure(); + return false; + } + Err(params::Utf8OwnedError::Invalid) => { + error::raise(INVALID_PARAMETER, "the digest parameter is invalid"); + return false; + } + }; + let Some(value) = Digest::parse(&value) else { + error::raise( + UNSUPPORTED_DIGEST, + format_args!("digest {value} is not supported"), + ); + return false; + }; + if self.hash_started && self.digest != Some(value) { + error::raise( + INVALID_PARAMETER, + "the digest cannot change after message data is supplied", + ); + return false; + } + digest = Some(value); + } + + if padding != RSA_PKCS1_PSS_PADDING { + mgf1_digest = None; + mgf1_explicit = false; + } + + // SAFETY: `input` is an OpenSSL-owned terminated parameter array. + let parameter = unsafe { params::locate(input, c"mgf1-digest") }; + if !parameter.is_null() { + if padding != RSA_PKCS1_PSS_PADDING { + error::raise(INVALID_PARAMETER, "an MGF1 digest requires RSA-PSS padding"); + return false; + } + // SAFETY: the located parameter remains live with `input`. + let value = match unsafe { params::get_utf8_owned(parameter, 64) } { + Ok(value) => value, + Err(params::Utf8OwnedError::Memory) => { + error::raise_memory_failure(); + return false; + } + Err(params::Utf8OwnedError::Invalid) => { + error::raise(INVALID_PARAMETER, "the MGF1 digest parameter is invalid"); + return false; + } + }; + let parsed_mgf1 = Digest::parse(&value); + if !Self::validate_mgf1_digest(digest, parsed_mgf1) { + return false; + } + mgf1_digest = parsed_mgf1; + mgf1_explicit = true; + } + + // SAFETY: `input` is an OpenSSL-owned terminated parameter array. + let parameter = unsafe { params::locate(input, c"saltlen") }; + if !parameter.is_null() { + // SAFETY: the located parameter remains live with `input`. + let value = match unsafe { parse_salt_length(parameter) } { + Ok(Some(value)) => value, + Ok(None) | Err(params::Utf8OwnedError::Invalid) => { + error::raise(INVALID_PARAMETER, "the RSA-PSS salt length is invalid"); + return false; + } + Err(params::Utf8OwnedError::Memory) => { + error::raise_memory_failure(); + return false; + } + }; + if padding != RSA_PKCS1_PSS_PADDING { + error::raise( + INVALID_PARAMETER, + "RSA-PSS salt length requires RSA-PSS padding", + ); + return false; + } + salt_length = value; + } + + if mgf1_explicit && !Self::validate_mgf1_digest(digest, mgf1_digest) { + return false; + } + let replacement_hash = if digest != self.digest || self.hash.is_none() { + match digest { + Some(digest) => match HashState::new(digest) { + Ok(hash) => Some(hash), + Err(cng_error) => { + error::raise_cng(CNG_FAILURE, &cng_error); + return false; + } + }, + None => None, + } + } else { + None + }; + if !mgf1_explicit { + mgf1_digest = digest; + } + self.padding = padding; + self.pss_salt_length = salt_length; + self.digest = digest; + self.mgf1_digest = mgf1_digest; + self.mgf1_explicit = mgf1_explicit; + if let Some(hash) = replacement_hash { + self.hash = Some(hash); + } + true + } + + fn resolved_padding(&self) -> Option { + match self.padding { + RSA_PKCS1_PADDING => Some(RsaPadding::Pkcs1), + RSA_PKCS1_PSS_PADDING => { + let key = self.key.as_ref()?; + let digest = self.digest?; + resolved_pss_salt_length( + self.pss_salt_length, + key.bits(), + key.max_signature_size(), + digest.output_len(), + ) + .map(|salt_len| RsaPadding::Pss { salt_len }) + } + _ => None, + } + } + + fn sign_digest( + &self, + digest_bytes: &[u8], + signature: *mut u8, + signature_length: *mut usize, + signature_size: usize, + ) -> c_int { + let (Some(key), Some(digest)) = (&self.key, self.digest) else { + return 0; + }; + if signature_length.is_null() { + return 0; + } + let Some(padding) = self.resolved_padding() else { + error::raise( + INVALID_PARAMETER, + "the RSA padding or PSS salt length is invalid for this key", + ); + return 0; + }; + let output = if signature.is_null() { + None + } else { + // SAFETY: OpenSSL declares the signature pointer writable for `signature_size` bytes. + Some(unsafe { std::slice::from_raw_parts_mut(signature, signature_size) }) + }; + match key.sign_digest(digest, padding, digest_bytes, output) { + Ok(written) => { + // SAFETY: the pointer was checked and OpenSSL owns writable size_t storage. + unsafe { *signature_length = written }; + 1 + } + Err(cng_error) => { + error::raise_cng(SIGNATURE_FAILURE, &cng_error); + 0 + } + } + } +} + +unsafe fn parse_padding( + parameter: *const OSSL_PARAM, +) -> Result, params::Utf8OwnedError> { + // SAFETY: caller provides a live parameter pointer. + if let Some(value) = unsafe { params::get_int(parameter) } { + return Ok(matches!(value, RSA_PKCS1_PADDING | RSA_PKCS1_PSS_PADDING).then_some(value)); + } + // SAFETY: caller provides a live parameter pointer. + let value = unsafe { params::get_utf8_owned(parameter, 32) }?; + if value.eq_ignore_ascii_case("pkcs1") { + Ok(Some(RSA_PKCS1_PADDING)) + } else if value.eq_ignore_ascii_case("pss") { + Ok(Some(RSA_PKCS1_PSS_PADDING)) + } else { + Ok(None) + } +} + +unsafe fn parse_salt_length( + parameter: *const OSSL_PARAM, +) -> Result, params::Utf8OwnedError> { + // SAFETY: caller provides a live parameter pointer. + if let Some(value) = unsafe { params::get_int(parameter) } { + return Ok((value >= 0 + || matches!( + value, + RSA_PSS_SALTLEN_DIGEST + | RSA_PSS_SALTLEN_MAX + | RSA_PSS_SALTLEN_AUTO + | RSA_PSS_SALTLEN_AUTO_DIGEST_MAX + )) + .then_some(value)); + } + // SAFETY: caller provides a live parameter pointer. + let value = unsafe { params::get_utf8_owned(parameter, 32) }?; + if value.eq_ignore_ascii_case("digest") { + Ok(Some(RSA_PSS_SALTLEN_DIGEST)) + } else if value.eq_ignore_ascii_case("max") { + Ok(Some(RSA_PSS_SALTLEN_MAX)) + } else if value.eq_ignore_ascii_case("auto") { + Ok(Some(RSA_PSS_SALTLEN_AUTO)) + } else if value.eq_ignore_ascii_case("auto-digestmax") { + Ok(Some(RSA_PSS_SALTLEN_AUTO_DIGEST_MAX)) + } else { + Ok(value.parse::().ok().filter(|parsed| *parsed >= 0)) + } +} + +pub fn resolved_pss_salt_length( + configured: c_int, + key_bits: u32, + modulus_bytes: usize, + digest_bytes: usize, +) -> Option { + let mut maximum = modulus_bytes.checked_sub(digest_bytes + 2)?; + if key_bits & 7 == 1 { + maximum = maximum.checked_sub(1)?; + } + let resolved = match configured { + RSA_PSS_SALTLEN_DIGEST => digest_bytes, + RSA_PSS_SALTLEN_MAX | RSA_PSS_SALTLEN_AUTO => maximum, + RSA_PSS_SALTLEN_AUTO_DIGEST_MAX => maximum.min(digest_bytes), + explicit if explicit >= 0 => usize::try_from(explicit) + .ok() + .filter(|value| *value <= maximum)?, + _ => return None, + }; + u32::try_from(resolved).ok() +} + +unsafe extern "C" fn signature_newctx( + provider_context: *mut c_void, + _properties: *const c_char, +) -> *mut c_void { + ffi_guard!(null_mut(), { + let Some(context) = allocation::try_box(SignatureContext::new(provider_context)) else { + error::raise_memory_failure(); + return null_mut(); + }; + Box::into_raw(context).cast() + }) +} + +unsafe extern "C" fn signature_freectx(signature_context: *mut c_void) { + ffi_guard_void!({ + if !signature_context.is_null() { + // SAFETY: OpenSSL releases a Box allocation created by `signature_newctx` exactly once. + drop(unsafe { Box::from_raw(signature_context.cast::()) }); + } + }); +} + +unsafe extern "C" fn signature_dupctx(signature_context: *mut c_void) -> *mut c_void { + ffi_guard!(null_mut(), { + if signature_context.is_null() { + return null_mut(); + } + // SAFETY: OpenSSL passes a live signature context. + let source = unsafe { &*signature_context.cast::() }; + let hash = match &source.hash { + Some(value) => match value.try_clone() { + Ok(copy) => Some(copy), + Err(cng_error) => { + error::raise_cng(CNG_FAILURE, &cng_error); + return null_mut(); + } + }, + None => None, + }; + let Some(context) = allocation::try_box(SignatureContext { + provider_context: source.provider_context, + key: source.key.clone(), + hash, + digest: source.digest, + hash_started: source.hash_started, + padding: source.padding, + pss_salt_length: source.pss_salt_length, + mgf1_digest: source.mgf1_digest, + mgf1_explicit: source.mgf1_explicit, + }) else { + error::raise_memory_failure(); + return null_mut(); + }; + Box::into_raw(context).cast() + }) +} + +unsafe extern "C" fn digest_sign_init( + signature_context: *mut c_void, + digest_name: *const c_char, + provider_key: *mut c_void, + input: *const OSSL_PARAM, +) -> c_int { + ffi_guard!(0, { + if signature_context.is_null() { + return 0; + } + // SAFETY: OpenSSL passes a live signature context. + let context = unsafe { &mut *signature_context.cast::() }; + let mut next = SignatureContext::new(context.provider_context); + if provider_key.is_null() { + if context.key.is_none() { + error::raise(KEY_NOT_LOADED, "DigestSignInit requires a loaded CNG key"); + return 0; + } + next.key.clone_from(&context.key); + next.digest = context.digest; + next.padding = context.padding; + next.pss_salt_length = context.pss_salt_length; + next.mgf1_digest = context.mgf1_digest; + next.mgf1_explicit = context.mgf1_explicit; + } else { + // SAFETY: non-null provider key data was allocated by this provider. + let key = unsafe { &*provider_key.cast::() }; + if key.magic != KEY_MAGIC || key.private_key().is_none() { + error::raise(KEY_NOT_LOADED, "DigestSignInit requires a loaded CNG key"); + return 0; + } + next.key = key.private_key().cloned(); + } + let digest = if digest_name.is_null() { + next.digest.unwrap_or(Digest::Sha256).canonical_name() + } else { + // SAFETY: OpenSSL supplies a NUL-terminated digest name. + let Ok(value) = unsafe { std::ffi::CStr::from_ptr(digest_name) }.to_str() else { + error::raise(INVALID_PARAMETER, "the digest name is invalid"); + return 0; + }; + value + }; + let Some(digest) = Digest::parse(digest) else { + error::raise(UNSUPPORTED_DIGEST, "the digest is not supported"); + return 0; + }; + next.digest = Some(digest); + // SAFETY: `input` is null or a live terminated parameter array. + if !unsafe { next.set_params(input) } + || !next.set_digest(next.digest.unwrap_or(digest).canonical_name()) + { + return 0; + } + *context = next; + 1 + }) +} + +unsafe extern "C" fn digest_sign_update( + signature_context: *mut c_void, + data: *const u8, + data_length: usize, +) -> c_int { + ffi_guard!(0, { + if signature_context.is_null() || (data.is_null() && data_length != 0) { + return 0; + } + // SAFETY: OpenSSL passes a live signature context. + let context = unsafe { &mut *signature_context.cast::() }; + let Some(hash) = context.hash.as_mut() else { + return 0; + }; + let bytes = if data_length == 0 { + &[][..] + } else { + // SAFETY: OpenSSL declares `data` readable for `data_length` bytes. + unsafe { std::slice::from_raw_parts(data, data_length) } + }; + match hash.update(bytes) { + Ok(()) => { + context.hash_started |= data_length != 0; + 1 + } + Err(cng_error) => { + error::raise_cng(CNG_FAILURE, &cng_error); + 0 + } + } + }) +} + +unsafe extern "C" fn digest_sign_final( + signature_context: *mut c_void, + signature: *mut u8, + signature_length: *mut usize, + signature_size: usize, +) -> c_int { + ffi_guard!(0, { + if signature_context.is_null() { + return 0; + } + // SAFETY: OpenSSL passes a live signature context. + let context = unsafe { &*signature_context.cast::() }; + let Some(hash) = &context.hash else { + return 0; + }; + match hash.finish_clone() { + Ok(digest) => context.sign_digest( + digest.as_slice(), + signature, + signature_length, + signature_size, + ), + Err(cng_error) => { + error::raise_cng(CNG_FAILURE, &cng_error); + 0 + } + } + }) +} + +unsafe extern "C" fn digest_sign( + signature_context: *mut c_void, + signature: *mut u8, + signature_length: *mut usize, + signature_size: usize, + message: *const u8, + message_length: usize, +) -> c_int { + ffi_guard!(0, { + if signature_context.is_null() || (message.is_null() && message_length != 0) { + return 0; + } + // SAFETY: OpenSSL passes a live signature context. + let context = unsafe { &*signature_context.cast::() }; + let Some(digest_algorithm) = context.digest else { + return 0; + }; + let message = if message_length == 0 { + &[][..] + } else { + // SAFETY: OpenSSL declares `message` readable for `message_length` bytes. + unsafe { std::slice::from_raw_parts(message, message_length) } + }; + match HashState::hash_message(digest_algorithm, message) { + Ok(digest) => context.sign_digest( + digest.as_slice(), + signature, + signature_length, + signature_size, + ), + Err(cng_error) => { + error::raise_cng(CNG_FAILURE, &cng_error); + 0 + } + } + }) +} + +unsafe extern "C" fn signature_set_ctx_params( + signature_context: *mut c_void, + input: *const OSSL_PARAM, +) -> c_int { + ffi_guard!(0, { + if signature_context.is_null() { + return 0; + } + // SAFETY: OpenSSL passes a live signature context and a null or terminated parameter array. + i32::from(unsafe { (&mut *signature_context.cast::()).set_params(input) }) + }) +} + +unsafe extern "C" fn signature_settable_ctx_params( + _signature_context: *mut c_void, + _provider_context: *mut c_void, +) -> *const OSSL_PARAM { + ffi_guard!(null(), { SETTABLE_PARAMS.0.as_ptr() }) +} + +unsafe extern "C" fn signature_get_ctx_params( + signature_context: *mut c_void, + output: *mut OSSL_PARAM, +) -> c_int { + ffi_guard!(0, { + if signature_context.is_null() || output.is_null() { + return 0; + } + // SAFETY: OpenSSL passes a live signature context and mutable terminated parameter array. + let context = unsafe { &*signature_context.cast::() }; + // SAFETY: each locate/set pair operates on the live output array. + let parameter = unsafe { params::locate_mut(output, c"pad-mode") }; + if !parameter.is_null() { + // SAFETY: the output parameter is live and its requested type controls encoding. + if !unsafe { set_padding_output(parameter, context.padding) } { + return 0; + } + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"digest") }; + if !parameter.is_null() { + let Some(digest) = context.digest else { + return 0; + }; + let value = digest_name(digest); + // SAFETY: value is static and the output parameter is live. + if !unsafe { params::set_utf8(parameter, value) } { + return 0; + } + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"mgf1-digest") }; + if !parameter.is_null() { + let Some(digest) = context.mgf1_digest else { + return 0; + }; + // SAFETY: value is static and the output parameter is live. + if !unsafe { params::set_utf8(parameter, digest_name(digest)) } { + return 0; + } + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"saltlen") }; + if !parameter.is_null() { + // SAFETY: the output parameter is live and its requested type controls encoding. + if !unsafe { set_salt_length_output(parameter, context.pss_salt_length) } { + return 0; + } + } + // SAFETY: same output-array invariant. + let parameter = unsafe { params::locate_mut(output, c"algorithm-id") }; + if !parameter.is_null() { + if context.padding != RSA_PKCS1_PADDING { + error::raise( + UNSUPPORTED_PADDING, + "AlgorithmIdentifier is available only for PKCS#1 v1.5 signatures", + ); + return 0; + } + let Some(digest) = context.digest else { + return 0; + }; + // SAFETY: the output parameter is live and the algorithm identifier is static. + if !unsafe { params::set_octets(parameter, algorithm_identifier(digest)) } { + return 0; + } + } + 1 + }) +} + +unsafe fn set_padding_output(parameter: *mut OSSL_PARAM, padding: c_int) -> bool { + // SAFETY: caller provides a live output parameter. + match unsafe { (*parameter).data_type } { + OSSL_PARAM_INTEGER => { + // SAFETY: forwards the same live integer output parameter. + unsafe { params::set_int(parameter, padding, false) } + } + OSSL_PARAM_UTF8_STRING => { + let value = match padding { + RSA_PKCS1_PADDING => c"pkcs1", + RSA_PKCS1_PSS_PADDING => c"pss", + _ => return false, + }; + // SAFETY: forwards the same live UTF8 output parameter and static text. + unsafe { params::set_utf8(parameter, value) } + } + _ => false, + } +} + +unsafe fn set_salt_length_output(parameter: *mut OSSL_PARAM, salt_length: c_int) -> bool { + // SAFETY: caller provides a live output parameter. + match unsafe { (*parameter).data_type } { + OSSL_PARAM_INTEGER => { + // SAFETY: forwards the same live signed integer output parameter. + unsafe { params::set_int(parameter, salt_length, false) } + } + OSSL_PARAM_UTF8_STRING => { + let mut explicit_buffer = [0_u8; 12]; + let value = match salt_length { + RSA_PSS_SALTLEN_DIGEST => c"digest", + RSA_PSS_SALTLEN_MAX => c"max", + RSA_PSS_SALTLEN_AUTO => c"auto", + RSA_PSS_SALTLEN_AUTO_DIGEST_MAX => c"auto-digestmax", + explicit if explicit >= 0 => decimal_cstr(explicit, &mut explicit_buffer), + _ => return false, + }; + // SAFETY: OpenSSL copies gettable UTF8_STRING values synchronously. + unsafe { params::set_utf8(parameter, value) } + } + _ => false, + } +} + +unsafe extern "C" fn signature_gettable_ctx_params( + _signature_context: *mut c_void, + _provider_context: *mut c_void, +) -> *const OSSL_PARAM { + ffi_guard!(null(), { GETTABLE_PARAMS.0.as_ptr() }) +} + +fn decimal_cstr(value: c_int, buffer: &mut [u8; 12]) -> &CStr { + let negative = value < 0; + let mut magnitude = if negative { + -i64::from(value) + } else { + i64::from(value) + } as u64; + let mut index = buffer.len() - 1; + loop { + index -= 1; + buffer[index] = b'0' + (magnitude % 10) as u8; + magnitude /= 10; + if magnitude == 0 { + break; + } + } + if negative { + index -= 1; + buffer[index] = b'-'; + } + // SAFETY: the constructed suffix contains only sign/digit bytes followed by the untouched + // terminating NUL in the last buffer slot. + unsafe { CStr::from_bytes_with_nul_unchecked(&buffer[index..]) } +} + +fn digest_name(digest: Digest) -> &'static std::ffi::CStr { + match digest { + Digest::Sha256 => c"SHA256", + Digest::Sha384 => c"SHA384", + Digest::Sha512 => c"SHA512", + } +} + +fn algorithm_identifier(digest: Digest) -> &'static [u8] { + match digest { + Digest::Sha256 => &[ + 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0b, 0x05, + 0x00, + ], + Digest::Sha384 => &[ + 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0c, 0x05, + 0x00, + ], + Digest::Sha512 => &[ + 0x30, 0x0d, 0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x0d, 0x05, + 0x00, + ], + } +} + +pub static SIGNATURE_FUNCTIONS: [OsslDispatch; 11] = [ + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_NEWCTX, + function: signature_newctx as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_DIGEST_SIGN_INIT, + function: digest_sign_init as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_DIGEST_SIGN_UPDATE, + function: digest_sign_update as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_DIGEST_SIGN_FINAL, + function: digest_sign_final as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_DIGEST_SIGN, + function: digest_sign as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_FREECTX, + function: signature_freectx as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_DUPCTX, + function: signature_dupctx as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_GET_CTX_PARAMS, + function: signature_get_ctx_params as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_GETTABLE_CTX_PARAMS, + function: signature_gettable_ctx_params as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_SET_CTX_PARAMS, + function: signature_set_ctx_params as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_SIGNATURE_SETTABLE_CTX_PARAMS, + function: signature_settable_ctx_params as *const c_void, + }, + // The terminating entry is appended by `signature_dispatch()` below because this exact array + // length is exposed to static initialization. +]; + +pub static SIGNATURE_DISPATCH: [OsslDispatch; 12] = [ + SIGNATURE_FUNCTIONS[0], + SIGNATURE_FUNCTIONS[1], + SIGNATURE_FUNCTIONS[2], + SIGNATURE_FUNCTIONS[3], + SIGNATURE_FUNCTIONS[4], + SIGNATURE_FUNCTIONS[5], + SIGNATURE_FUNCTIONS[6], + SIGNATURE_FUNCTIONS[7], + SIGNATURE_FUNCTIONS[8], + SIGNATURE_FUNCTIONS[9], + SIGNATURE_FUNCTIONS[10], + OsslDispatch { + function_id: 0, + function: null(), + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + use openssl_provider_sys::{ + EVP_SIGNATURE_fetch, EVP_SIGNATURE_free, EVP_SIGNATURE_gettable_ctx_params, + EVP_SIGNATURE_settable_ctx_params, OSSL_LIB_CTX_free, OSSL_LIB_CTX_new, OSSL_PROVIDER_load, + OSSL_PROVIDER_unload, param_int, param_utf8_string, + }; + + unsafe fn metadata_types(mut parameters: *const OSSL_PARAM, key: &CStr) -> Vec { + let mut types = Vec::new(); + while !parameters.is_null() { + // SAFETY: callers provide a terminated metadata array. + let parameter = unsafe { &*parameters }; + if parameter.key.is_null() { + break; + } + // SAFETY: metadata keys are NUL-terminated static strings. + if unsafe { CStr::from_ptr(parameter.key) } == key { + types.push(parameter.data_type); + } + // SAFETY: the terminator check permits advancing one entry. + parameters = unsafe { parameters.add(1) }; + } + types + } + + fn assert_dual_text_integer_metadata(parameters: *const OSSL_PARAM) { + for key in [c"pad-mode", c"saltlen"] { + // SAFETY: callers pass a live terminated metadata array. + let types = unsafe { metadata_types(parameters, key) }; + assert!(types.contains(&OSSL_PARAM_UTF8_STRING), "{key:?} UTF8"); + assert!(types.contains(&OSSL_PARAM_INTEGER), "{key:?} integer"); + } + } + + fn get_padding_and_salt(context: &mut SignatureContext) -> (c_int, c_int, String, String) { + let mut padding = 0; + let mut salt_length = 0; + let mut padding_text = [0_i8; 32]; + let mut salt_text = [0_i8; 32]; + let mut integer_output = [ + param_int(c"pad-mode".as_ptr(), &raw mut padding), + param_int(c"saltlen".as_ptr(), &raw mut salt_length), + param_end(), + ]; + // SAFETY: context and the terminated output array are live. + let integer_result = unsafe { + signature_get_ctx_params( + std::ptr::from_mut(context).cast(), + integer_output.as_mut_ptr(), + ) + }; + assert_eq!(integer_result, 1); + let mut text_output = [ + param_utf8_string( + c"pad-mode".as_ptr(), + padding_text.as_mut_ptr(), + padding_text.len(), + ), + param_utf8_string(c"saltlen".as_ptr(), salt_text.as_mut_ptr(), salt_text.len()), + param_end(), + ]; + // SAFETY: context and the terminated output array are live. + let text_result = unsafe { + signature_get_ctx_params(std::ptr::from_mut(context).cast(), text_output.as_mut_ptr()) + }; + assert_eq!(text_result, 1); + // SAFETY: successful UTF8_STRING writes NUL-terminated both oversized buffers. + let padding_text = unsafe { CStr::from_ptr(padding_text.as_ptr()) } + .to_str() + .expect("padding UTF-8") + .to_owned(); + // SAFETY: same successful UTF8_STRING output invariant. + let salt_text = unsafe { CStr::from_ptr(salt_text.as_ptr()) } + .to_str() + .expect("salt UTF-8") + .to_owned(); + (padding, salt_length, padding_text, salt_text) + } + + #[test] + fn resolves_pss_salt_modes() { + assert_eq!( + resolved_pss_salt_length(RSA_PSS_SALTLEN_DIGEST, 2_048, 256, 32), + Some(32) + ); + assert_eq!( + resolved_pss_salt_length(RSA_PSS_SALTLEN_MAX, 2_048, 256, 32), + Some(222) + ); + assert_eq!( + resolved_pss_salt_length(RSA_PSS_SALTLEN_AUTO_DIGEST_MAX, 2_048, 256, 32), + Some(32) + ); + assert_eq!(resolved_pss_salt_length(223, 2_048, 256, 32), None); + } + + #[test] + fn rsa_signature_metadata_matches_default_provider_dual_types() { + assert_dual_text_integer_metadata(SETTABLE_PARAMS.0.as_ptr()); + assert_dual_text_integer_metadata(GETTABLE_PARAMS.0.as_ptr()); + + // SAFETY: creates an owned private OpenSSL context for default-provider comparison. + let libctx = unsafe { OSSL_LIB_CTX_new() }; + assert!(!libctx.is_null()); + // SAFETY: context and static provider name are live. + let provider = unsafe { OSSL_PROVIDER_load(libctx, c"default".as_ptr()) }; + assert!(!provider.is_null()); + // SAFETY: context and static algorithm/property strings are live. + let signature = + unsafe { EVP_SIGNATURE_fetch(libctx, c"RSA".as_ptr(), c"provider=default".as_ptr()) }; + assert!(!signature.is_null()); + // SAFETY: the fetched signature remains live. + let default_settable = unsafe { EVP_SIGNATURE_settable_ctx_params(signature) }; + // SAFETY: the fetched signature remains live. + let default_gettable = unsafe { EVP_SIGNATURE_gettable_ctx_params(signature) }; + assert_dual_text_integer_metadata(default_settable); + assert_dual_text_integer_metadata(default_gettable); + // SAFETY: all handles are owned by this test and released in dependency order. + unsafe { + EVP_SIGNATURE_free(signature); + let _ = OSSL_PROVIDER_unload(provider); + OSSL_LIB_CTX_free(libctx); + } + } + + #[test] + fn rsa_padding_and_salt_parameters_round_trip_in_both_types() { + let cases = [ + (RSA_PSS_SALTLEN_DIGEST, "digest"), + (RSA_PSS_SALTLEN_MAX, "max"), + (RSA_PSS_SALTLEN_AUTO, "auto"), + (RSA_PSS_SALTLEN_AUTO_DIGEST_MAX, "auto-digestmax"), + (32, "32"), + ]; + for (salt_length, salt_text) in cases { + let mut context = SignatureContext::new(null_mut()); + let mut padding = RSA_PKCS1_PSS_PADDING; + let mut salt = salt_length; + let integer_input = [ + param_int(c"pad-mode".as_ptr(), &raw mut padding), + param_int(c"saltlen".as_ptr(), &raw mut salt), + param_end(), + ]; + // SAFETY: the terminated input array remains live for the call. + assert!(unsafe { context.set_params(integer_input.as_ptr()) }); + assert_eq!( + get_padding_and_salt(&mut context), + ( + RSA_PKCS1_PSS_PADDING, + salt_length, + "pss".to_owned(), + salt_text.to_owned() + ) + ); + + let mut text_context = SignatureContext::new(null_mut()); + let mut padding_text = *b"pss"; + let mut salt_text_bytes = salt_text.as_bytes().to_vec(); + let text_input = [ + param_utf8_string( + c"pad-mode".as_ptr(), + padding_text.as_mut_ptr().cast(), + padding_text.len(), + ), + param_utf8_string( + c"saltlen".as_ptr(), + salt_text_bytes.as_mut_ptr().cast(), + salt_text_bytes.len(), + ), + param_end(), + ]; + // SAFETY: both text buffers and the terminated input array remain live. + assert!(unsafe { text_context.set_params(text_input.as_ptr()) }); + let round_trip = get_padding_and_salt(&mut text_context); + assert_eq!(round_trip.0, RSA_PKCS1_PSS_PADDING); + assert_eq!(round_trip.1, salt_length); + assert_eq!(round_trip.2, "pss"); + assert_eq!(round_trip.3, salt_text); + } + + let mut pkcs1_context = SignatureContext::new(null_mut()); + let mut pkcs1 = RSA_PKCS1_PADDING; + let integer_input = [param_int(c"pad-mode".as_ptr(), &raw mut pkcs1), param_end()]; + // SAFETY: the terminated integer input remains live. + assert!(unsafe { pkcs1_context.set_params(integer_input.as_ptr()) }); + let round_trip = get_padding_and_salt(&mut pkcs1_context); + assert_eq!(round_trip.0, RSA_PKCS1_PADDING); + assert_eq!(round_trip.2, "pkcs1"); + + let mut pkcs1_text_context = SignatureContext::new(null_mut()); + let mut pkcs1_text = *b"pkcs1"; + let text_input = [ + param_utf8_string( + c"pad-mode".as_ptr(), + pkcs1_text.as_mut_ptr().cast(), + pkcs1_text.len(), + ), + param_end(), + ]; + // SAFETY: the text buffer and terminated input remain live. + assert!(unsafe { pkcs1_text_context.set_params(text_input.as_ptr()) }); + assert_eq!( + get_padding_and_salt(&mut pkcs1_text_context).0, + RSA_PKCS1_PADDING + ); + } + + #[test] + fn rsa_salt_integer_rejects_unknown_negative_values() { + let mut context = SignatureContext::new(null_mut()); + context.padding = RSA_PKCS1_PSS_PADDING; + let mut invalid = -5; + let input = [ + param_int(c"saltlen".as_ptr(), &raw mut invalid), + param_end(), + ]; + // SAFETY: the terminated input array remains live for the call. + assert!(!unsafe { context.set_params(input.as_ptr()) }); + } + + #[test] + fn failed_parameter_updates_leave_signature_state_unchanged() { + let mut context = SignatureContext::new(null_mut()); + let mut initial_digest = *b"SHA256"; + let initial = [ + param_utf8_string( + c"digest".as_ptr(), + initial_digest.as_mut_ptr().cast(), + initial_digest.len(), + ), + param_end(), + ]; + // SAFETY: the digest buffer and terminated parameter array remain live. + assert!(unsafe { context.set_params(initial.as_ptr()) }); + + let mut pss = RSA_PKCS1_PSS_PADDING; + let mut replacement_digest = *b"SHA384"; + let mut incompatible_mgf1 = *b"SHA256"; + let invalid_mgf1 = [ + param_int(c"pad-mode".as_ptr(), &raw mut pss), + param_utf8_string( + c"digest".as_ptr(), + replacement_digest.as_mut_ptr().cast(), + replacement_digest.len(), + ), + param_utf8_string( + c"mgf1-digest".as_ptr(), + incompatible_mgf1.as_mut_ptr().cast(), + incompatible_mgf1.len(), + ), + param_end(), + ]; + // SAFETY: all buffers and the terminated parameter array remain live. + assert!(!unsafe { context.set_params(invalid_mgf1.as_ptr()) }); + assert_eq!(context.digest, Some(Digest::Sha256)); + assert_eq!(context.padding, RSA_PKCS1_PADDING); + assert_eq!(context.mgf1_digest, Some(Digest::Sha256)); + assert!(!context.mgf1_explicit); + + let mut replacement_digest = *b"SHA384"; + let mut invalid_salt = -5; + let invalid_salt_update = [ + param_int(c"pad-mode".as_ptr(), &raw mut pss), + param_utf8_string( + c"digest".as_ptr(), + replacement_digest.as_mut_ptr().cast(), + replacement_digest.len(), + ), + param_int(c"saltlen".as_ptr(), &raw mut invalid_salt), + param_end(), + ]; + // SAFETY: all buffers and the terminated parameter array remain live. + assert!(!unsafe { context.set_params(invalid_salt_update.as_ptr()) }); + assert_eq!(context.digest, Some(Digest::Sha256)); + assert_eq!(context.padding, RSA_PKCS1_PADDING); + } + + #[test] + fn digest_update_rejects_retained_explicit_mgf1_mismatch() { + let mut context = SignatureContext::new(null_mut()); + let mut pss = RSA_PKCS1_PSS_PADDING; + let mut sha256 = *b"SHA256"; + let mut mgf1 = *b"SHA256"; + let initial = [ + param_int(c"pad-mode".as_ptr(), &raw mut pss), + param_utf8_string(c"digest".as_ptr(), sha256.as_mut_ptr().cast(), sha256.len()), + param_utf8_string( + c"mgf1-digest".as_ptr(), + mgf1.as_mut_ptr().cast(), + mgf1.len(), + ), + param_end(), + ]; + // SAFETY: all buffers and the terminated input array remain live. + assert!(unsafe { context.set_params(initial.as_ptr()) }); + + let mut sha384 = *b"SHA384"; + let digest_only = [ + param_utf8_string(c"digest".as_ptr(), sha384.as_mut_ptr().cast(), sha384.len()), + param_end(), + ]; + // SAFETY: the digest buffer and terminated input array remain live. + assert!(!unsafe { context.set_params(digest_only.as_ptr()) }); + assert_eq!(context.digest, Some(Digest::Sha256)); + assert_eq!(context.mgf1_digest, Some(Digest::Sha256)); + assert!(context.mgf1_explicit); + assert_eq!(context.padding, RSA_PKCS1_PSS_PADDING); + + let mut replacement_mgf1 = *b"SHA384"; + let matching_update = [ + param_utf8_string(c"digest".as_ptr(), sha384.as_mut_ptr().cast(), sha384.len()), + param_utf8_string( + c"mgf1-digest".as_ptr(), + replacement_mgf1.as_mut_ptr().cast(), + replacement_mgf1.len(), + ), + param_end(), + ]; + // SAFETY: both buffers and the terminated input array remain live. + assert!(unsafe { context.set_params(matching_update.as_ptr()) }); + assert_eq!(context.digest, Some(Digest::Sha384)); + assert_eq!(context.mgf1_digest, Some(Digest::Sha384)); + assert!(context.mgf1_explicit); + } + + #[test] + fn leaving_pss_clears_explicit_mgf1_state() { + let mut context = SignatureContext::new(null_mut()); + let mut pss = RSA_PKCS1_PSS_PADDING; + let mut sha256 = *b"SHA256"; + let mut mgf1 = *b"SHA256"; + let pss_input = [ + param_int(c"pad-mode".as_ptr(), &raw mut pss), + param_utf8_string(c"digest".as_ptr(), sha256.as_mut_ptr().cast(), sha256.len()), + param_utf8_string( + c"mgf1-digest".as_ptr(), + mgf1.as_mut_ptr().cast(), + mgf1.len(), + ), + param_end(), + ]; + // SAFETY: all buffers and the terminated parameter array remain live. + assert!(unsafe { context.set_params(pss_input.as_ptr()) }); + assert!(context.mgf1_explicit); + + let mut pkcs1 = RSA_PKCS1_PADDING; + let pkcs1_input = [param_int(c"pad-mode".as_ptr(), &raw mut pkcs1), param_end()]; + // SAFETY: the terminated parameter array remains live. + assert!(unsafe { context.set_params(pkcs1_input.as_ptr()) }); + assert!(!context.mgf1_explicit); + assert_eq!(context.mgf1_digest, context.digest); + + let mut sha384 = *b"SHA384"; + let digest_input = [ + param_utf8_string(c"digest".as_ptr(), sha384.as_mut_ptr().cast(), sha384.len()), + param_end(), + ]; + // SAFETY: the digest buffer and terminated parameter array remain live. + assert!(unsafe { context.set_params(digest_input.as_ptr()) }); + assert_eq!(context.digest, Some(Digest::Sha384)); + assert_eq!(context.mgf1_digest, Some(Digest::Sha384)); + } +} diff --git a/native/crates/cng-provider/src/store.rs b/native/crates/cng-provider/src/store.rs new file mode 100644 index 00000000..83ab6d04 --- /dev/null +++ b/native/crates/cng-provider/src/store.rs @@ -0,0 +1,358 @@ +use std::ffi::{CStr, c_char, c_int, c_void}; +use std::ptr::{null, null_mut}; + +use cng_core::CngKey; +use openssl_provider_sys::{ + OSSL_FUNC_STORE_CLOSE, OSSL_FUNC_STORE_EOF, OSSL_FUNC_STORE_EXPORT_OBJECT, + OSSL_FUNC_STORE_LOAD, OSSL_FUNC_STORE_OPEN, OSSL_FUNC_STORE_SET_CTX_PARAMS, + OSSL_FUNC_STORE_SETTABLE_CTX_PARAMS, OSSL_OBJECT_PKEY, OSSL_PARAM, OSSL_PARAM_INTEGER, + OSSL_PARAM_UTF8_STRING, OSSL_PASSPHRASE_CALLBACK, OSSL_STORE_INFO_PKEY, OsslCallback, + OsslDispatch, param_definition, param_end, param_int, param_octet_string, param_utf8_string, +}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::allocation; +use crate::error::{self, CNG_FAILURE, INVALID_PARAMETER, INVALID_REFERENCE, INVALID_URI}; +use crate::key::{KEY_MAGIC, KeyMaterial, ProviderKey, export_public, free_provider_key}; +use crate::params; +use crate::uri::{StoreUriError, parse_store_uri}; + +struct StaticParams([OSSL_PARAM; N]); +// SAFETY: these arrays contain only immutable static key pointers and null data pointers. +unsafe impl Sync for StaticParams {} + +static SETTABLE_PARAMS: StaticParams<3> = StaticParams([ + param_definition(c"expect".as_ptr(), OSSL_PARAM_INTEGER), + param_definition(c"properties".as_ptr(), OSSL_PARAM_UTF8_STRING), + param_end(), +]); + +struct StoreContext { + reference: OwnedProviderKeyReference, + expected_type: c_int, + loaded: bool, + description: Zeroizing>, +} + +#[repr(transparent)] +struct OwnedProviderKeyReference(*mut ProviderKey); + +impl OwnedProviderKeyReference { + const fn new(reference: *mut ProviderKey) -> Self { + Self(reference) + } + + const fn is_null(&self) -> bool { + self.0.is_null() + } +} + +impl Drop for OwnedProviderKeyReference { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: this wrapper is the sole owner until KEYMGMT_LOAD nulls its pointer slot. + unsafe { free_provider_key(self.0) }; + self.0 = null_mut(); + } + } +} + +impl Drop for StoreContext { + fn drop(&mut self) { + self.description.zeroize(); + } +} + +unsafe extern "C" fn store_open(provider_context: *mut c_void, uri: *const c_char) -> *mut c_void { + ffi_guard!(null_mut(), { + if uri.is_null() { + error::raise(INVALID_URI, "a CNG STORE URI is required"); + return null_mut(); + } + // SAFETY: OpenSSL supplies a NUL-terminated URI string. + let uri = unsafe { CStr::from_ptr(uri) }; + let Ok(uri_text) = uri.to_str() else { + error::raise(INVALID_URI, "the CNG STORE URI is not valid UTF-8"); + return null_mut(); + }; + let parsed = match parse_store_uri(uri_text) { + Ok(parsed) => parsed, + Err(StoreUriError::Invalid) => { + error::raise( + INVALID_URI, + "expected cng://?key=&provider=", + ); + return null_mut(); + } + Err(StoreUriError::Memory) => { + error::raise_memory_failure(); + return null_mut(); + } + }; + let key = match CngKey::open(&parsed.provider_name, &parsed.key_name) { + Ok(key) => key, + Err(cng_error) => { + error::raise_cng(CNG_FAILURE, &cng_error); + return null_mut(); + } + }; + let Some(description) = allocation::try_copy_bytes_with_nul(uri.to_bytes()) else { + error::raise_memory_failure(); + return null_mut(); + }; + let description = Zeroizing::new(description); + let reference = ProviderKey::new(provider_context, KeyMaterial::Private(key)); + if reference.is_null() { + return null_mut(); + } + let Some(context) = allocation::try_box(StoreContext { + reference: OwnedProviderKeyReference::new(reference), + expected_type: 0, + loaded: false, + description, + }) else { + error::raise_memory_failure(); + return null_mut(); + }; + Box::into_raw(context).cast() + }) +} + +unsafe extern "C" fn store_settable_ctx_params( + _provider_context: *mut c_void, +) -> *const OSSL_PARAM { + ffi_guard!(null(), { SETTABLE_PARAMS.0.as_ptr() }) +} + +unsafe extern "C" fn store_set_ctx_params( + loader_context: *mut c_void, + input: *const OSSL_PARAM, +) -> c_int { + ffi_guard!(0, { + if loader_context.is_null() { + return 0; + } + if input.is_null() { + return 1; + } + // SAFETY: OpenSSL passes a live STORE context. + let store = unsafe { &mut *loader_context.cast::() }; + // SAFETY: input is a terminated OpenSSL parameter array. + let parameter = unsafe { params::locate(input, c"expect") }; + if !parameter.is_null() { + // SAFETY: the located parameter remains live with input. + let Some(expected) = (unsafe { params::get_int(parameter) }) else { + error::raise(INVALID_PARAMETER, "the STORE expectation is invalid"); + return 0; + }; + if expected != 0 && expected != OSSL_STORE_INFO_PKEY { + error::raise( + INVALID_PARAMETER, + "the CNG store supports only private-key (PKEY) expectations", + ); + return 0; + } + store.expected_type = expected; + } + // SAFETY: input is a terminated OpenSSL parameter array. + let parameter = unsafe { params::locate(input, c"properties") }; + if !parameter.is_null() { + // SAFETY: the located parameter remains live with input. + match unsafe { params::get_utf8_owned(parameter, 256) } { + Ok(_) => {} + Err(params::Utf8OwnedError::Memory) => { + error::raise_memory_failure(); + return 0; + } + Err(params::Utf8OwnedError::Invalid) => { + error::raise( + INVALID_PARAMETER, + "the STORE property query is invalid or too long", + ); + return 0; + } + } + } + 1 + }) +} + +unsafe extern "C" fn store_load( + loader_context: *mut c_void, + object_callback: OsslCallback, + callback_argument: *mut c_void, + _passphrase_callback: OSSL_PASSPHRASE_CALLBACK, + _passphrase_argument: *mut c_void, +) -> c_int { + ffi_guard!(0, { + let Some(object_callback) = object_callback else { + return 0; + }; + if loader_context.is_null() { + return 0; + } + // SAFETY: OpenSSL passes a live STORE context. + let store = unsafe { &mut *loader_context.cast::() }; + if store.loaded || store.reference.is_null() { + return 0; + } + store.loaded = true; + let mut object_type = OSSL_OBJECT_PKEY; + let mut data_type = *b"RSA\0"; + let description_length = store.description.len().saturating_sub(1); + let params = [ + param_int(c"type".as_ptr(), &raw mut object_type), + param_utf8_string(c"data-type".as_ptr(), data_type.as_mut_ptr().cast(), 0), + param_octet_string( + c"reference".as_ptr(), + (&raw mut store.reference).cast(), + size_of::<*mut ProviderKey>(), + ), + param_utf8_string( + c"desc".as_ptr(), + store.description.as_mut_ptr().cast(), + description_length, + ), + param_end(), + ]; + // SAFETY: the parameter array and all referenced stack/STORE data remain live for this + // synchronous callback. + unsafe { object_callback(params.as_ptr(), callback_argument) } + }) +} + +unsafe extern "C" fn store_eof(loader_context: *mut c_void) -> c_int { + ffi_guard!(1, { + if loader_context.is_null() { + 1 + } else { + // SAFETY: OpenSSL passes a live STORE context. + i32::from(unsafe { (*loader_context.cast::()).loaded }) + } + }) +} + +unsafe extern "C" fn store_close(loader_context: *mut c_void) -> c_int { + ffi_guard!(0, { + if !loader_context.is_null() { + // SAFETY: OpenSSL closes the Box allocation from `store_open` exactly once. + drop(unsafe { Box::from_raw(loader_context.cast::()) }); + } + 1 + }) +} + +unsafe extern "C" fn store_export_object( + loader_context: *mut c_void, + object_reference: *const c_void, + reference_size: usize, + export_callback: OsslCallback, + callback_argument: *mut c_void, +) -> c_int { + ffi_guard!(0, { + if loader_context.is_null() + || object_reference.is_null() + || reference_size != size_of::<*mut ProviderKey>() + { + error::raise( + INVALID_REFERENCE, + "the CNG STORE object reference is invalid", + ); + return 0; + } + // SAFETY: STORE object references are pointer-sized slots populated by this provider. + let key = unsafe { *object_reference.cast::<*mut ProviderKey>() }; + if key.is_null() + // SAFETY: a non-null pointer is a provider-owned key retained through the callback. + || unsafe { (*key).magic } != KEY_MAGIC + { + error::raise( + INVALID_REFERENCE, + "the CNG STORE object reference is no longer available", + ); + return 0; + } + // SAFETY: the key and export callback remain live for this synchronous call. + i32::from(unsafe { export_public(&*key, export_callback, callback_argument) }) + }) +} + +pub static STORE_FUNCTIONS: [OsslDispatch; 8] = [ + OsslDispatch { + function_id: OSSL_FUNC_STORE_OPEN, + function: store_open as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_STORE_SETTABLE_CTX_PARAMS, + function: store_settable_ctx_params as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_STORE_SET_CTX_PARAMS, + function: store_set_ctx_params as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_STORE_LOAD, + function: store_load as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_STORE_EOF, + function: store_eof as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_STORE_CLOSE, + function: store_close as *const c_void, + }, + OsslDispatch { + function_id: OSSL_FUNC_STORE_EXPORT_OBJECT, + function: store_export_object as *const c_void, + }, + OsslDispatch { + function_id: 0, + function: null(), + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::CString; + + use cng_core::{DEFAULT_PROVIDER_NAME, delete_test_key, random_bytes}; + + fn owned_name() -> String { + let mut random = [0_u8; 16]; + random_bytes(&mut random).expect("random STORE test key suffix"); + let suffix = random + .iter() + .map(|value| format!("{value:02x}")) + .collect::(); + random.zeroize(); + format!( + "openssl-cng-rust-test-store-{}-{suffix}", + std::process::id() + ) + } + + #[test] + fn store_context_allocation_failure_has_one_reference_owner() { + let name = owned_name(); + let key = + CngKey::open_or_create(DEFAULT_PROVIDER_NAME, &name, 2_048).expect("create test key"); + drop(key); + let uri = CString::new(format!("cng://?key={name}")).expect("STORE URI"); + + allocation::fail_box_allocation_after(1); + // SAFETY: the URI is NUL-terminated and the null provider context is opaque to STORE. + let context = unsafe { store_open(null_mut(), uri.as_ptr()) }; + if !context.is_null() { + // SAFETY: a non-null result is a live STORE context owned by this test. + let _ = unsafe { store_close(context) }; + } + + delete_test_key(DEFAULT_PROVIDER_NAME, &name).expect("delete STORE test key"); + assert!( + context.is_null(), + "fault injection must fail StoreContext allocation" + ); + } +} diff --git a/native/crates/cng-provider/src/uri.rs b/native/crates/cng-provider/src/uri.rs new file mode 100644 index 00000000..af5363b2 --- /dev/null +++ b/native/crates/cng-provider/src/uri.rs @@ -0,0 +1,163 @@ +use cng_core::DEFAULT_PROVIDER_NAME; +use zeroize::Zeroizing; + +use crate::allocation; + +pub const MAX_KEY_NAME: usize = 512; +pub const MAX_PROVIDER_NAME: usize = 512; +pub const MAX_STORE_URI: usize = 2048; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct StoreUri { + pub key_name: Zeroizing, + pub provider_name: Zeroizing, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum StoreUriError { + Invalid, + Memory, +} + +pub fn parse_store_uri(uri: &str) -> Result { + if uri.is_empty() || uri.len() >= MAX_STORE_URI { + return Err(StoreUriError::Invalid); + } + let (prefix, query) = uri.split_at_checked(7).ok_or(StoreUriError::Invalid)?; + if !prefix.eq_ignore_ascii_case("cng://?") || query.is_empty() { + return Err(StoreUriError::Invalid); + } + + let mut key_name = None; + let mut provider_name = None; + for field in query.split('&') { + if field.is_empty() || field.matches('=').count() != 1 { + return Err(StoreUriError::Invalid); + } + let (name, encoded) = field.split_once('=').ok_or(StoreUriError::Invalid)?; + let value = decode_value(encoded)?; + match name { + "key" if key_name.is_none() && value.len() < MAX_KEY_NAME => key_name = Some(value), + "provider" if provider_name.is_none() && value.len() < MAX_PROVIDER_NAME => { + provider_name = Some(value); + } + _ => return Err(StoreUriError::Invalid), + } + } + + let key_name = key_name.ok_or(StoreUriError::Invalid)?; + let provider_name = match provider_name { + Some(value) => value, + None => Zeroizing::new( + allocation::try_copy_str(DEFAULT_PROVIDER_NAME).ok_or(StoreUriError::Memory)?, + ), + }; + Ok(StoreUri { + key_name, + provider_name, + }) +} + +fn decode_value(encoded: &str) -> Result, StoreUriError> { + if encoded.is_empty() { + return Err(StoreUriError::Invalid); + } + let source = encoded.as_bytes(); + let mut decoded = Zeroizing::new(Vec::new()); + decoded + .try_reserve_exact(source.len()) + .map_err(|_| StoreUriError::Memory)?; + let mut index = 0; + while index < source.len() { + let value = if source[index] == b'%' { + let high = source + .get(index + 1) + .and_then(|value| hex(*value)) + .ok_or(StoreUriError::Invalid)?; + let low = source + .get(index + 2) + .and_then(|value| hex(*value)) + .ok_or(StoreUriError::Invalid)?; + index += 3; + (high << 4) | low + } else { + let value = source[index]; + if !value.is_ascii_alphanumeric() && !matches!(value, b'-' | b'.' | b'_' | b'~') { + return Err(StoreUriError::Invalid); + } + index += 1; + value + }; + if value == 0 || value < 0x20 || value == 0x7f { + return Err(StoreUriError::Invalid); + } + decoded.push(value); + } + if decoded.is_empty() { + return Err(StoreUriError::Invalid); + } + let value = std::str::from_utf8(&decoded).map_err(|_| StoreUriError::Invalid)?; + Ok(Zeroizing::new( + allocation::try_copy_str(value).ok_or(StoreUriError::Memory)?, + )) +} + +const fn hex(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_strict_encoded_names() { + let parsed = parse_store_uri( + "CNG://?key=client-%E6%B5%8B%E8%AF%95&provider=Microsoft%20Software%20Key%20Storage%20Provider", + ) + .expect("valid URI"); + assert_eq!(parsed.key_name.as_str(), "client-测试"); + assert_eq!(parsed.provider_name.as_str(), DEFAULT_PROVIDER_NAME); + } + + #[test] + fn defaults_provider() { + let parsed = parse_store_uri("cng://?key=test-key").expect("valid URI"); + assert_eq!(parsed.provider_name.as_str(), DEFAULT_PROVIDER_NAME); + } + + #[test] + fn rejects_ambiguous_or_malformed_fields() { + for uri in [ + "cng://?", + "cng://?provider=x", + "cng://?key=x&", + "cng://?key=x&key=y", + "cng://?Key=x", + "cng://?key=x+y", + "cng://?key=%", + "cng://?key=%00", + "cng://?key=x=1", + "cng://?key=x&unknown=y", + ] { + assert!(parse_store_uri(uri).is_err(), "{uri}"); + } + } + + #[test] + fn decoded_identifiers_are_wiped_on_parse_failures() { + for uri in [ + format!("cng://?key={}&unknown=x", "k".repeat(80)), + format!("cng://?key={}%00", "k".repeat(77)), + ] { + allocation::assert_deallocated_copy_is_wiped(80, || { + assert_eq!(parse_store_uri(&uri), Err(StoreUriError::Invalid)); + }); + } + } +} diff --git a/native/crates/mtls-bridge/Cargo.toml b/native/crates/mtls-bridge/Cargo.toml new file mode 100644 index 00000000..5ce2bf39 --- /dev/null +++ b/native/crates/mtls-bridge/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "mtls-bridge" +version.workspace = true +description = "Bounded Rust mTLS and HTTP transport using an opaque provider-backed client key" +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[lib] +name = "microsoft_identity_native_primitives" +crate-type = ["cdylib", "rlib"] + +[dependencies] +libc.workspace = true +openssl-provider-sys = { version = "0.2.0", path = "../openssl-provider-sys" } +static_assertions.workspace = true +windows-sys.workspace = true +zeroize.workspace = true + +[dev-dependencies] +cng-core = { version = "0.2.0", path = "../cng-core" } + +[lints] +workspace = true diff --git a/native/crates/mtls-bridge/src/abi.rs b/native/crates/mtls-bridge/src/abi.rs new file mode 100644 index 00000000..8a92ed2a --- /dev/null +++ b/native/crates/mtls-bridge/src/abi.rs @@ -0,0 +1,246 @@ +use std::ffi::{c_char, c_int, c_uchar}; +use std::fmt::{self, Write}; + +use static_assertions::const_assert_eq; + +pub const ABI_VERSION: u32 = 1; +pub const MAX_HEADERS: usize = 16; +pub const MAX_HEADER_BYTES: usize = 16_384; +pub const MAX_HEADER_NAME_BYTES: usize = 64; +pub const MAX_HEADER_VALUE_BYTES: usize = 12_288; +pub const MAX_BODY_BYTES: usize = 1_048_576; +pub const MAX_RESPONSE_BYTES: usize = 1_048_576; + +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ErrorCode { + None = 0, + InvalidArgument = 1, + Limit = 2, + Provider = 3, + Store = 4, + Certificate = 5, + Dns = 6, + Connect = 7, + Tls = 8, + Io = 9, + Http = 10, + Memory = 11, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct MicrosoftIdentityNativeHeader { + pub name: *const c_char, + pub value: *const c_char, +} + +#[repr(C)] +pub struct MicrosoftIdentityNativeRequest { + pub struct_size: u32, + pub abi_version: u32, + pub provider_module_path: *const c_char, + pub store_uri: *const c_char, + pub client_certificate_path: *const c_char, + pub ca_bundle_path: *const c_char, + pub host: *const c_char, + pub port: u16, + pub method: *const c_char, + pub target: *const c_char, + pub headers: *const MicrosoftIdentityNativeHeader, + pub header_count: usize, + pub body: *const c_uchar, + pub body_size: usize, + pub connect_timeout_ms: u32, + pub io_timeout_ms: u32, +} + +#[repr(C)] +pub struct MicrosoftIdentityNativeResponse { + pub http_status: c_int, + pub headers: *mut c_char, + pub headers_length: usize, + pub body: *mut c_uchar, + pub body_length: usize, +} + +impl MicrosoftIdentityNativeResponse { + pub const fn empty() -> Self { + Self { + http_status: 0, + headers: std::ptr::null_mut(), + headers_length: 0, + body: std::ptr::null_mut(), + body_length: 0, + } + } +} + +#[repr(C)] +pub struct MicrosoftIdentityNativeError { + pub code: ErrorCode, + pub system_error: c_int, + pub openssl_error: u32, + pub message: [c_char; 512], +} + +impl MicrosoftIdentityNativeError { + pub const fn empty() -> Self { + Self { + code: ErrorCode::None, + system_error: 0, + openssl_error: 0, + message: [0; 512], + } + } +} + +const_assert_eq!(size_of::(), 16); +const_assert_eq!(size_of::(), 112); +const_assert_eq!(size_of::(), 40); +const_assert_eq!(size_of::(), 524); + +#[derive(Clone)] +pub struct FailureMessage { + bytes: [u8; 511], + length: usize, +} + +impl FailureMessage { + fn from_text(value: &str) -> Self { + let mut output = Self { + bytes: [0; 511], + length: 0, + }; + let _ = output.write_str(value); + output + } + + fn from_arguments(arguments: fmt::Arguments<'_>) -> Self { + let mut output = Self { + bytes: [0; 511], + length: 0, + }; + let _ = fmt::write(&mut output, arguments); + output + } + + pub fn as_bytes(&self) -> &[u8] { + &self.bytes[..self.length] + } +} + +impl Write for FailureMessage { + fn write_str(&mut self, value: &str) -> fmt::Result { + let available = self.bytes.len() - self.length; + let mut copied = value.len().min(available); + while copied != 0 && !value.is_char_boundary(copied) { + copied -= 1; + } + self.bytes[self.length..self.length + copied].copy_from_slice(&value.as_bytes()[..copied]); + self.length += copied; + Ok(()) + } +} + +impl fmt::Debug for FailureMessage { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + std::str::from_utf8(self.as_bytes()) + .unwrap_or("") + .fmt(formatter) + } +} + +#[derive(Clone, Debug)] +pub struct BridgeFailure { + pub code: ErrorCode, + pub system_error: i32, + pub openssl_error: u32, + pub message: FailureMessage, +} + +impl BridgeFailure { + pub fn new(code: ErrorCode, message: &str) -> Self { + Self { + code, + system_error: 0, + openssl_error: 0, + message: FailureMessage::from_text(message), + } + } + + pub fn formatted(code: ErrorCode, message: fmt::Arguments<'_>) -> Self { + Self { + code, + system_error: 0, + openssl_error: 0, + message: FailureMessage::from_arguments(message), + } + } + + pub fn system(code: ErrorCode, system_error: i32, message: &str) -> Self { + Self { + code, + system_error, + openssl_error: 0, + message: FailureMessage::from_text(message), + } + } + + pub fn system_formatted( + code: ErrorCode, + system_error: i32, + message: fmt::Arguments<'_>, + ) -> Self { + Self { + code, + system_error, + openssl_error: 0, + message: FailureMessage::from_arguments(message), + } + } + + pub fn openssl_formatted( + code: ErrorCode, + openssl_error: u32, + message: fmt::Arguments<'_>, + ) -> Self { + Self { + code, + system_error: 0, + openssl_error, + message: FailureMessage::from_arguments(message), + } + } + + pub fn memory() -> Self { + Self::new( + ErrorCode::Memory, + "Unable to allocate memory for the native mTLS request or response.", + ) + } +} + +pub unsafe fn clear_error(error: *mut MicrosoftIdentityNativeError) { + if !error.is_null() { + // SAFETY: the caller supplied writable ABI error storage. + unsafe { error.write(MicrosoftIdentityNativeError::empty()) }; + } +} + +pub unsafe fn write_error(error: *mut MicrosoftIdentityNativeError, failure: &BridgeFailure) { + if error.is_null() { + return; + } + let mut output = MicrosoftIdentityNativeError::empty(); + output.code = failure.code; + output.system_error = failure.system_error; + output.openssl_error = failure.openssl_error; + let bytes = failure.message.as_bytes(); + let length = bytes.len().min(output.message.len() - 1); + for (destination, source) in output.message[..length].iter_mut().zip(&bytes[..length]) { + *destination = *source as c_char; + } + // SAFETY: the caller supplied writable ABI error storage. + unsafe { error.write(output) }; +} diff --git a/native/crates/mtls-bridge/src/http.rs b/native/crates/mtls-bridge/src/http.rs new file mode 100644 index 00000000..495bc659 --- /dev/null +++ b/native/crates/mtls-bridge/src/http.rs @@ -0,0 +1,1064 @@ +use std::ffi::c_char; +use std::fmt; +use std::net::IpAddr; +use std::path::Path; + +use zeroize::Zeroizing; + +use crate::abi::{ + BridgeFailure, ErrorCode, MAX_BODY_BYTES, MAX_HEADER_BYTES, MAX_HEADER_NAME_BYTES, + MAX_HEADER_VALUE_BYTES, MAX_HEADERS, MAX_RESPONSE_BYTES, MicrosoftIdentityNativeRequest, +}; + +const DEFAULT_TIMEOUT_MS: u32 = 10_000; +const MAX_TIMEOUT_MS: u32 = 120_000; +const MAX_HOST: usize = 253; +const MAX_METHOD: usize = 8; +const MAX_TARGET: usize = 2_048; +const MAX_GENERAL_HEADER_VALUE: usize = 1_024; +const MAX_REQUEST_BYTES: usize = MAX_HEADER_BYTES + MAX_BODY_BYTES; +const INVALID_CORE_MESSAGE: &str = + "A provider, CNG URI, certificate path, host, port, method, or relative target is invalid."; +const INVALID_HEADER_MESSAGE: &str = "Only bounded Accept, Authorization, Content-Type, User-Agent, X-Request-ID, and x-ms-tokenboundauth headers are accepted."; + +pub struct Header { + pub name: String, + pub value: Zeroizing, +} + +impl fmt::Debug for Header { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Header") + .field("name", &self.name) + .field("value", &"[REDACTED]") + .finish() + } +} + +pub struct ValidatedRequest { + pub provider_module_path: Zeroizing, + pub store_uri: Zeroizing, + pub client_certificate_path: Zeroizing, + pub ca_bundle_path: Zeroizing, + pub host: String, + pub port: u16, + pub method: String, + pub target: String, + pub headers: Vec
, + pub body: Zeroizing>, + pub connect_timeout_ms: u32, + pub io_timeout_ms: u32, +} + +impl fmt::Debug for ValidatedRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ValidatedRequest") + .field("provider_module_path", &"[REDACTED]") + .field("store_uri", &"[REDACTED]") + .field("client_certificate_path", &"[REDACTED]") + .field("ca_bundle_path", &"[REDACTED]") + .field("host", &self.host) + .field("port", &self.port) + .field("method", &self.method) + .field("target", &self.target) + .field("headers", &self.headers) + .field("body", &"[REDACTED]") + .field("connect_timeout_ms", &self.connect_timeout_ms) + .field("io_timeout_ms", &self.io_timeout_ms) + .finish() + } +} + +impl ValidatedRequest { + pub unsafe fn from_abi( + request: *const MicrosoftIdentityNativeRequest, + ) -> Result { + if request.is_null() { + return Err(BridgeFailure::new( + ErrorCode::InvalidArgument, + "The request ABI version or structure size is invalid.", + )); + } + // SAFETY: reading the first two u32 fields requires only the documented ABI prefix. + let struct_size = unsafe { request.cast::().read_unaligned() }; + // SAFETY: the ABI prefix contains a second u32 immediately after struct_size. + let abi_version = unsafe { request.cast::().add(1).read_unaligned() }; + if struct_size < size_of::() as u32 + || abi_version != super::abi::ABI_VERSION + { + return Err(BridgeFailure::new( + ErrorCode::InvalidArgument, + "The request ABI version or structure size is invalid.", + )); + } + // SAFETY: struct_size now establishes that the complete version-1 structure is readable. + let request = unsafe { &*request }; + + // SAFETY: each ABI string is caller-owned and validated with a bounded scan. + let provider_module_path = Zeroizing::new(unsafe { + read_utf8(request.provider_module_path, 32_767, INVALID_CORE_MESSAGE) + }?); + // SAFETY: same bounded caller-owned string invariant. + let store_uri = + Zeroizing::new(unsafe { read_utf8(request.store_uri, 2_047, INVALID_CORE_MESSAGE) }?); + // SAFETY: same bounded caller-owned string invariant. + let client_certificate_path = Zeroizing::new(unsafe { + read_utf8( + request.client_certificate_path, + 32_767, + INVALID_CORE_MESSAGE, + ) + }?); + // SAFETY: same bounded caller-owned string invariant. + let ca_bundle_path = Zeroizing::new(unsafe { + read_utf8(request.ca_bundle_path, 32_767, INVALID_CORE_MESSAGE) + }?); + // SAFETY: same bounded caller-owned string invariant. + let host = unsafe { read_utf8(request.host, MAX_HOST, INVALID_CORE_MESSAGE) }?; + // SAFETY: same bounded caller-owned string invariant. + let method = unsafe { read_utf8(request.method, MAX_METHOD, INVALID_CORE_MESSAGE) }?; + // SAFETY: same bounded caller-owned string invariant. + let target = unsafe { read_utf8(request.target, MAX_TARGET, INVALID_CORE_MESSAGE) }?; + + if !Path::new(provider_module_path.as_str()).is_absolute() + || !Path::new(client_certificate_path.as_str()).is_absolute() + || !Path::new(ca_bundle_path.as_str()).is_absolute() + { + return Err(BridgeFailure::new( + ErrorCode::InvalidArgument, + "Provider, client-certificate, and CA-bundle paths must be absolute.", + )); + } + + if !store_uri + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("cng://?")) + || !host_is_valid(&host) + || request.port == 0 + || !method_is_allowed(&method) + || !target_is_valid(&target) + { + return Err(invalid_core()); + } + if request.header_count > MAX_HEADERS + || (request.header_count != 0 && request.headers.is_null()) + || request.body_size > MAX_BODY_BYTES + || (request.body_size != 0 && request.body.is_null()) + { + return Err(BridgeFailure::new( + ErrorCode::Limit, + "The request exceeds the header or body limit.", + )); + } + + let mut headers = Vec::new(); + headers + .try_reserve_exact(request.header_count) + .map_err(|_| BridgeFailure::memory())?; + if request.header_count != 0 { + // SAFETY: the count was bounded and the pointer is non-null by the preceding checks. + let input = + unsafe { std::slice::from_raw_parts(request.headers, request.header_count) }; + for header in input { + // SAFETY: header strings are caller-owned and scanned within ABI limits. + let name = unsafe { + read_utf8(header.name, MAX_HEADER_NAME_BYTES, INVALID_HEADER_MESSAGE) + }?; + // SAFETY: same bounded caller-owned string invariant. + let value = Zeroizing::new(unsafe { + read_utf8(header.value, MAX_HEADER_VALUE_BYTES, INVALID_HEADER_MESSAGE) + }?); + if !header_name_is_allowed(&name) + || !header_value_is_valid(&name, &value) + || headers + .iter() + .any(|existing: &Header| existing.name.eq_ignore_ascii_case(&name)) + { + return Err(BridgeFailure::new( + ErrorCode::InvalidArgument, + INVALID_HEADER_MESSAGE, + )); + } + headers.push(Header { name, value }); + } + } + let body = Zeroizing::new(if request.body_size == 0 { + Vec::new() + } else { + // SAFETY: the body pointer is non-null and the size is bounded by one MiB. + try_copy_bytes(unsafe { std::slice::from_raw_parts(request.body, request.body_size) })? + }); + let connect_timeout_ms = if request.connect_timeout_ms == 0 { + DEFAULT_TIMEOUT_MS + } else { + request.connect_timeout_ms + }; + let io_timeout_ms = if request.io_timeout_ms == 0 { + DEFAULT_TIMEOUT_MS + } else { + request.io_timeout_ms + }; + if connect_timeout_ms > MAX_TIMEOUT_MS || io_timeout_ms > MAX_TIMEOUT_MS { + return Err(BridgeFailure::new( + ErrorCode::Limit, + "Connection and I/O timeouts must not exceed 120 seconds.", + )); + } + + Ok(Self { + provider_module_path, + store_uri, + client_certificate_path, + ca_bundle_path, + host, + port: request.port, + method, + target, + headers, + body, + connect_timeout_ms, + io_timeout_ms, + }) + } +} + +fn invalid_core() -> BridgeFailure { + BridgeFailure::new(ErrorCode::InvalidArgument, INVALID_CORE_MESSAGE) +} + +unsafe fn read_utf8( + pointer: *const c_char, + maximum: usize, + invalid_message: &str, +) -> Result { + if pointer.is_null() { + return Err(BridgeFailure::new( + ErrorCode::InvalidArgument, + invalid_message, + )); + } + let mut length = 0; + while length <= maximum { + // SAFETY: the ABI requires a readable NUL-terminated string; the bounded scan prevents + // reading beyond the documented maximum plus terminator. + if unsafe { *pointer.add(length) } == 0 { + break; + } + length += 1; + } + if length == 0 || length > maximum { + return Err(BridgeFailure::new( + ErrorCode::InvalidArgument, + invalid_message, + )); + } + // SAFETY: the bounded scan established exactly `length` initialized bytes before the NUL. + let bytes = unsafe { std::slice::from_raw_parts(pointer.cast::(), length) }; + let value = std::str::from_utf8(bytes) + .map_err(|_| BridgeFailure::new(ErrorCode::InvalidArgument, invalid_message))?; + try_copy_string(value) +} + +fn method_is_allowed(method: &str) -> bool { + matches!( + method, + "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "HEAD" | "OPTIONS" + ) +} + +fn host_is_valid(host: &str) -> bool { + if host.parse::().is_ok() { + return true; + } + let bytes = host.as_bytes(); + if bytes.is_empty() + || bytes.len() > MAX_HOST + || matches!(bytes.first(), Some(b'.' | b'-')) + || matches!(bytes.last(), Some(b'.' | b'-')) + { + return false; + } + for (index, value) in bytes.iter().copied().enumerate() { + if !value.is_ascii_alphanumeric() && value != b'.' && value != b'-' { + return false; + } + if value == b'.' + && ((index > 0 && matches!(bytes[index - 1], b'.' | b'-')) + || (index + 1 < bytes.len() && bytes[index + 1] == b'-')) + { + return false; + } + } + true +} + +fn target_is_valid(target: &str) -> bool { + let bytes = target.as_bytes(); + !bytes.is_empty() + && bytes.len() <= MAX_TARGET + && bytes[0] == b'/' + && !target.contains("://") + && !target.contains('#') + && bytes + .iter() + .all(|value| (0x21..=0x7e).contains(value) && *value != b'\\') +} + +fn header_name_is_allowed(name: &str) -> bool { + [ + "Accept", + "Authorization", + "Content-Type", + "User-Agent", + "X-Request-ID", + "x-ms-tokenboundauth", + ] + .iter() + .any(|allowed| name.eq_ignore_ascii_case(allowed)) +} + +fn header_value_is_valid(name: &str, value: &str) -> bool { + if value.is_empty() + || value.len() > MAX_HEADER_VALUE_BYTES + || !value + .as_bytes() + .iter() + .all(|byte| (0x20..=0x7e).contains(byte)) + { + return false; + } + if name.eq_ignore_ascii_case("Authorization") { + let Some(credential) = value.get("mtls_pop ".len()..) else { + return false; + }; + return value[.."mtls_pop ".len()].eq_ignore_ascii_case("mtls_pop ") + && !credential.is_empty() + && credential + .as_bytes() + .iter() + .all(|byte| (0x21..=0x7e).contains(byte)); + } + if name.eq_ignore_ascii_case("x-ms-tokenboundauth") { + return value.eq_ignore_ascii_case("true"); + } + value.len() <= MAX_GENERAL_HEADER_VALUE +} + +pub fn build_http_request(request: &ValidatedRequest) -> Result>, BridgeFailure> { + let is_ipv6 = request.host.parse::().is_ok(); + let mut port_storage = [0_u8; 5]; + let port = decimal_bytes(usize::from(request.port), &mut port_storage); + let mut length_storage = [0_u8; 20]; + let content_length = decimal_bytes(request.body.len(), &mut length_storage); + let mut header_length = request + .method + .len() + .checked_add(1) + .and_then(|value| value.checked_add(request.target.len())) + .and_then(|value| value.checked_add(b" HTTP/1.1\r\nHost: ".len())) + .and_then(|value| value.checked_add(request.host.len())) + .and_then(|value| value.checked_add(if is_ipv6 { b"[]".len() } else { 0 })) + .and_then(|value| { + value.checked_add(if request.port == 443 { + b"\r\n".len() + } else { + 1 + port.len() + b"\r\n".len() + }) + }) + .and_then(|value| value.checked_add(b"Connection: close\r\nContent-Length: ".len())) + .and_then(|value| value.checked_add(content_length.len())) + .and_then(|value| value.checked_add(b"\r\n".len())) + .ok_or_else(BridgeFailure::memory)?; + for header in &request.headers { + header_length = header_length + .checked_add(header.name.len()) + .and_then(|value| value.checked_add(b": ".len())) + .and_then(|value| value.checked_add(header.value.len())) + .and_then(|value| value.checked_add(b"\r\n".len())) + .ok_or_else(BridgeFailure::memory)?; + } + if header_length > MAX_HEADER_BYTES { + return Err(BridgeFailure::new( + ErrorCode::Limit, + "The generated HTTP request headers are too large.", + )); + } + let total_length = header_length + .checked_add(b"\r\n".len()) + .and_then(|value| value.checked_add(request.body.len())) + .ok_or_else(BridgeFailure::memory)?; + if total_length > MAX_REQUEST_BYTES { + return Err(BridgeFailure::new( + ErrorCode::Limit, + "The generated HTTP request exceeds its limit.", + )); + } + let mut output = Vec::new(); + output + .try_reserve_exact(total_length) + .map_err(|_| BridgeFailure::memory())?; + output.extend_from_slice(request.method.as_bytes()); + output.push(b' '); + output.extend_from_slice(request.target.as_bytes()); + output.extend_from_slice(b" HTTP/1.1\r\nHost: "); + if is_ipv6 { + output.push(b'['); + } + output.extend_from_slice(request.host.as_bytes()); + if is_ipv6 { + output.push(b']'); + } + if request.port != 443 { + output.push(b':'); + output.extend_from_slice(port); + } + output.extend_from_slice(b"\r\nConnection: close\r\nContent-Length: "); + output.extend_from_slice(content_length); + output.extend_from_slice(b"\r\n"); + for header in &request.headers { + output.extend_from_slice(header.name.as_bytes()); + output.extend_from_slice(b": "); + output.extend_from_slice(header.value.as_bytes()); + output.extend_from_slice(b"\r\n"); + } + output.extend_from_slice(b"\r\n"); + output.extend_from_slice(&request.body); + Ok(Zeroizing::new(output)) +} + +#[derive(Eq, PartialEq)] +pub struct ParsedResponse { + pub status: i32, + pub headers: Zeroizing>, + pub body: Zeroizing>, +} + +impl fmt::Debug for ParsedResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ParsedResponse") + .field("status", &self.status) + .field("headers_length", &self.headers.len()) + .field("body_length", &self.body.len()) + .finish() + } +} + +#[derive(Clone, Copy, Debug)] +pub struct ResponseFraming { + pub expected_length: usize, + pub status: i32, +} + +pub fn inspect_http_response(raw: &[u8], method: &str) -> Result { + let header_end = find_bytes(raw, b"\r\n\r\n").ok_or_else(|| { + BridgeFailure::new( + ErrorCode::Http, + "The HTTP response header is missing or too large.", + ) + })?; + if header_end > MAX_HEADER_BYTES { + return Err(BridgeFailure::new( + ErrorCode::Http, + "The HTTP response header is missing or too large.", + )); + } + let status_end = find_bytes(&raw[..header_end + 2], b"\r\n") + .ok_or_else(|| BridgeFailure::new(ErrorCode::Http, "The HTTP status line is malformed."))?; + let status_line = &raw[..status_end]; + if status_line.len() < 12 + || (!status_line.starts_with(b"HTTP/1.1 ") && !status_line.starts_with(b"HTTP/1.0 ")) + || !(b'1'..=b'5').contains(&status_line[9]) + || !status_line[10].is_ascii_digit() + || !status_line[11].is_ascii_digit() + || (status_line.len() > 12 && status_line[12] != b' ') + { + return Err(BridgeFailure::new( + ErrorCode::Http, + "The HTTP status line is unsupported.", + )); + } + let status = i32::from(status_line[9] - b'0') * 100 + + i32::from(status_line[10] - b'0') * 10 + + i32::from(status_line[11] - b'0'); + let headers_start = status_end + 2; + let headers = &raw[headers_start..header_end + 2]; + let content_length = parse_content_length(headers)?; + let header_bytes = header_end + 4; + let expected_length = if response_has_no_body(method, status) { + header_bytes + } else { + let Some(content_length) = content_length else { + return Err(BridgeFailure::new( + ErrorCode::Http, + "A body-bearing HTTP response requires Content-Length.", + )); + }; + if content_length > MAX_RESPONSE_BYTES.saturating_sub(header_bytes) { + return Err(BridgeFailure::new( + ErrorCode::Limit, + "The HTTPS response exceeds 1 MiB.", + )); + } + header_bytes + content_length + }; + Ok(ResponseFraming { + expected_length, + status, + }) +} + +fn parse_content_length(headers: &[u8]) -> Result, BridgeFailure> { + let mut content_length = None; + let mut cursor = 0; + while cursor < headers.len() { + let relative_end = find_bytes(&headers[cursor..], b"\r\n").ok_or_else(|| { + BridgeFailure::new( + ErrorCode::Http, + "The HTTP response contains a malformed header.", + ) + })?; + if relative_end == 0 { + break; + } + let line = &headers[cursor..cursor + relative_end]; + let colon = line + .iter() + .position(|value| *value == b':') + .ok_or_else(|| { + BridgeFailure::new( + ErrorCode::Http, + "The HTTP response contains a malformed header.", + ) + })?; + let name = &line[..colon]; + let value = &line[colon + 1..]; + if name.is_empty() + || !name.iter().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) + { + return Err(BridgeFailure::new( + ErrorCode::Http, + "The HTTP response header name is invalid.", + )); + } + if !value + .iter() + .all(|byte| (*byte >= 0x20 || *byte == b'\t') && *byte <= 0x7e) + { + return Err(BridgeFailure::new( + ErrorCode::Http, + "The HTTP response header value is invalid.", + )); + } + if name.eq_ignore_ascii_case(b"Content-Length") { + if content_length.is_some() { + return Err(BridgeFailure::new( + ErrorCode::Http, + "Duplicate Content-Length headers are rejected.", + )); + } + let start = value + .iter() + .position(|byte| !matches!(byte, b' ' | b'\t')) + .unwrap_or(value.len()); + let end = value + .iter() + .rposition(|byte| !matches!(byte, b' ' | b'\t')) + .map_or(start, |end| end + 1); + let value = &value[start..end]; + if value.is_empty() || !value.iter().all(u8::is_ascii_digit) { + return Err(BridgeFailure::new( + ErrorCode::Http, + "The Content-Length header is invalid.", + )); + } + let mut parsed = 0_usize; + for digit in value { + parsed = parsed + .checked_mul(10) + .and_then(|current| current.checked_add(usize::from(digit - b'0'))) + .ok_or_else(|| { + BridgeFailure::new(ErrorCode::Http, "The Content-Length header is invalid.") + })?; + } + content_length = Some(parsed); + } else if name.eq_ignore_ascii_case(b"Transfer-Encoding") { + return Err(BridgeFailure::new( + ErrorCode::Http, + "Transfer-Encoding responses are not accepted.", + )); + } + cursor += relative_end + 2; + } + Ok(content_length) +} + +pub fn parse_http_response(raw: &[u8], method: &str) -> Result { + let framing = inspect_http_response(raw, method)?; + if raw.len() != framing.expected_length { + return Err(BridgeFailure::new( + ErrorCode::Http, + "The response body does not match its HTTP framing.", + )); + } + let header_end = find_bytes(raw, b"\r\n\r\n").expect("inspected header delimiter"); + let status_end = + find_bytes(&raw[..header_end + 2], b"\r\n").expect("inspected status delimiter"); + let headers = Zeroizing::new(try_copy_bytes(&raw[status_end + 2..header_end + 2])?); + let body = Zeroizing::new(try_copy_bytes(&raw[header_end + 4..])?); + Ok(ParsedResponse { + status: framing.status, + headers, + body, + }) +} + +pub fn response_has_no_body(method: &str, status: i32) -> bool { + method == "HEAD" || status / 100 == 1 || status == 204 || status == 304 +} + +pub fn find_bytes(buffer: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(0); + } + buffer + .windows(needle.len()) + .position(|window| window == needle) +} + +fn try_copy_string(value: &str) -> Result { + let mut output = String::new(); + output + .try_reserve_exact(value.len()) + .map_err(|_| BridgeFailure::memory())?; + output.push_str(value); + Ok(output) +} + +fn try_copy_bytes(value: &[u8]) -> Result, BridgeFailure> { + let mut output = Vec::new(); + output + .try_reserve_exact(value.len()) + .map_err(|_| BridgeFailure::memory())?; + output.extend_from_slice(value); + Ok(output) +} + +fn decimal_bytes(mut value: usize, storage: &mut [u8]) -> &[u8] { + let mut cursor = storage.len(); + loop { + cursor -= 1; + storage[cursor] = b'0' + (value % 10) as u8; + value /= 10; + if value == 0 { + return &storage[cursor..]; + } + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use std::alloc::{GlobalAlloc, Layout, System}; + use std::cell::Cell; + + use crate::abi::{ABI_VERSION, MicrosoftIdentityNativeHeader}; + + #[derive(Clone, Copy, Default)] + struct CopyObservation { + length: usize, + pointer: *mut u8, + freed: bool, + dirty: bool, + } + + thread_local! { + static COPY_OBSERVATION: Cell = const { Cell::new(CopyObservation { + length: 0, + pointer: std::ptr::null_mut(), + freed: false, + dirty: false, + }) }; + } + + struct ObservedAllocator; + + // SAFETY: allocation/free pairing and layouts are delegated unchanged to System. + // The thread-local observer is allocation-free and has no destructor. Each fixture + // selects a copy's unique allocation size and initializes all bytes before freeing it. + unsafe impl GlobalAlloc for ObservedAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + // SAFETY: the caller supplies a valid allocation layout. + let pointer = unsafe { System.alloc(layout) }; + COPY_OBSERVATION.with(|observation| { + let mut state = observation.get(); + if state.length != 0 && layout.size() == state.length { + state.pointer = pointer; + observation.set(state); + } + }); + pointer + } + + unsafe fn dealloc(&self, pointer: *mut u8, layout: Layout) { + COPY_OBSERVATION.with(|observation| { + let mut state = observation.get(); + if pointer == state.pointer { + // SAFETY: the fixture's watched copy initialized all `length` bytes; + // its allocation remains live until System.dealloc below. + let bytes = unsafe { std::slice::from_raw_parts(pointer, state.length) }; + state.dirty = bytes.iter().any(|byte| *byte != 0); + state.freed = true; + state.pointer = std::ptr::null_mut(); + observation.set(state); + } + }); + // SAFETY: pointer and layout come unchanged from this allocator. + unsafe { System.dealloc(pointer, layout) }; + } + } + + #[global_allocator] + static ALLOCATOR: ObservedAllocator = ObservedAllocator; + + fn abi_request() -> MicrosoftIdentityNativeRequest { + MicrosoftIdentityNativeRequest { + struct_size: size_of::() as u32, + abi_version: ABI_VERSION, + provider_module_path: c"C:\\provider.dll".as_ptr(), + store_uri: c"cng://?key=test".as_ptr(), + client_certificate_path: c"C:\\client.pem".as_ptr(), + ca_bundle_path: c"C:\\ca.pem".as_ptr(), + host: c"localhost".as_ptr(), + port: 443, + method: c"POST".as_ptr(), + target: c"/".as_ptr(), + headers: std::ptr::null(), + header_count: 0, + body: std::ptr::null(), + body_size: 0, + connect_timeout_ms: 0, + io_timeout_ms: 0, + } + } + + fn assert_failed_copy_is_wiped( + input: &MicrosoftIdentityNativeRequest, + length: usize, + expected_error: ErrorCode, + ) { + assert_deallocated_copy_is_wiped(length, || { + // SAFETY: fixtures keep the complete ABI request and every referenced string, + // header array and body alive through this immediate validation call. + let result = unsafe { ValidatedRequest::from_abi(input) }; + assert_eq!( + result.expect_err("fixture must fail validation").code, + expected_error + ); + }); + } + + pub(crate) fn assert_deallocated_copy_is_wiped(length: usize, operation: impl FnOnce()) { + COPY_OBSERVATION.set(CopyObservation { + length, + ..CopyObservation::default() + }); + operation(); + let observed = COPY_OBSERVATION.replace(CopyObservation::default()); + assert!(observed.freed, "request copy was not observed"); + assert!(!observed.dirty, "request copy was freed without wiping"); + } + + fn success(method: &str, raw: &str, status: i32, body: &[u8]) { + let parsed = parse_http_response(raw.as_bytes(), method).expect("valid response"); + assert_eq!(parsed.status, status); + assert_eq!(parsed.body.as_slice(), body); + } + + fn failure(method: &str, raw: &str) { + assert_eq!( + parse_http_response(raw.as_bytes(), method) + .expect_err("invalid response") + .code, + ErrorCode::Http + ); + } + + #[test] + fn rejected_headers_wipe_copied_values() { + let value = std::ffi::CString::new(format!("mtls_pop {}", "t".repeat(71))) + .expect("synthetic token"); + for name in [c"Authorization", c"Not-Allowed"] { + let headers = [MicrosoftIdentityNativeHeader { + name: name.as_ptr(), + value: value.as_ptr(), + }; 2]; + let mut input = abi_request(); + input.headers = headers.as_ptr(); + input.header_count = headers.len(); + assert_failed_copy_is_wiped(&input, value.as_bytes().len(), ErrorCode::InvalidArgument); + } + } + + #[test] + fn rejected_timeouts_wipe_copied_body() { + let body = [b's'; 80]; + for (connect_timeout_ms, io_timeout_ms) in + [(MAX_TIMEOUT_MS + 1, 0), (0, MAX_TIMEOUT_MS + 1)] + { + let mut input = abi_request(); + input.body = body.as_ptr(); + input.body_size = body.len(); + input.connect_timeout_ms = connect_timeout_ms; + input.io_timeout_ms = io_timeout_ms; + assert_failed_copy_is_wiped(&input, body.len(), ErrorCode::Limit); + } + } + + #[test] + fn later_validation_failure_wipes_store_uri_copy() { + let value = std::ffi::CString::new(format!("cng://?{}", "k".repeat(73))) + .expect("synthetic STORE URI"); + let mut input = abi_request(); + input.store_uri = value.as_ptr(); + input.client_certificate_path = std::ptr::null(); + assert_failed_copy_is_wiped(&input, value.as_bytes().len(), ErrorCode::InvalidArgument); + } + + #[test] + fn response_header_names_accept_http_token_punctuation() { + success( + "GET", + "HTTP/1.1 200 OK\r\nX!#$%&'*+-.^_`|~: test\r\nContent-Length: 0\r\n\r\n", + 200, + b"", + ); + failure("GET", "HTTP/1.1 200 OK\r\nContent-Length : 0\r\n\r\n"); + failure( + "GET", + "HTTP/1.1 200 OK\r\nX Request: test\r\nContent-Length: 0\r\n\r\n", + ); + } + + #[test] + fn content_length_accepts_surrounding_ows_but_not_ambiguous_values() { + success( + "GET", + "HTTP/1.1 200 OK\r\nContent-Length: \t5 \t\r\n\r\nhello", + 200, + b"hello", + ); + for value in [" \t ", "5 0", "+5", "5,5"] { + failure( + "GET", + &format!("HTTP/1.1 200 OK\r\nContent-Length: {value}\r\n\r\nhello"), + ); + } + failure( + "GET", + "HTTP/1.1 200 OK\r\nContent-Length: 0 \t\r\nContent-Length:\t0 \r\n\r\n", + ); + } + + #[test] + fn ipv6_request_limits_include_host_brackets() { + for (port, body_length, header_limit) in [ + (443, 0, MAX_HEADER_BYTES), + (8443, 0, MAX_HEADER_BYTES), + (443, MAX_BODY_BYTES, MAX_HEADER_BYTES - 2), + (8443, MAX_BODY_BYTES, MAX_HEADER_BYTES - 2), + ] { + let mut request = ValidatedRequest { + provider_module_path: Zeroizing::new(r"C:\provider.dll".to_owned()), + store_uri: Zeroizing::new("cng://?key=test".to_owned()), + client_certificate_path: Zeroizing::new(r"C:\client.pem".to_owned()), + ca_bundle_path: Zeroizing::new(r"C:\ca.pem".to_owned()), + host: "::1".to_owned(), + port, + method: "POST".to_owned(), + target: "/".to_owned(), + headers: [ + ( + "Authorization", + format!( + "mtls_pop {}", + "t".repeat(MAX_HEADER_VALUE_BYTES - "mtls_pop ".len()) + ), + ), + ("Accept", "a".repeat(MAX_GENERAL_HEADER_VALUE)), + ("Content-Type", "c".repeat(MAX_GENERAL_HEADER_VALUE)), + ("User-Agent", "u".repeat(MAX_GENERAL_HEADER_VALUE)), + ("x-ms-tokenboundauth", "true".to_owned()), + ("X-Request-ID", "r".to_owned()), + ] + .into_iter() + .map(|(name, value)| Header { + name: name.to_owned(), + value: Zeroizing::new(value), + }) + .collect(), + body: Zeroizing::new(vec![b'b'; body_length]), + connect_timeout_ms: DEFAULT_TIMEOUT_MS, + io_timeout_ms: DEFAULT_TIMEOUT_MS, + }; + let initial = build_http_request(&request).expect("initial IPv6 request"); + let initial_header_length = + find_bytes(&initial, b"\r\n\r\n").expect("header delimiter") + 2; + let padding = request.headers.last_mut().expect("padding header"); + padding + .value + .push_str(&"r".repeat(header_limit - initial_header_length)); + assert!(request.headers.iter().all(|header| { + header_name_is_allowed(&header.name) + && header_value_is_valid(&header.name, &header.value) + })); + let at_limit = build_http_request(&request).expect("IPv6 request at limit"); + assert_eq!( + find_bytes(&at_limit, b"\r\n\r\n").expect("header delimiter") + 2, + header_limit + ); + + request + .headers + .last_mut() + .expect("padding header") + .value + .push('r'); + match build_http_request(&request) { + Err(error) => assert_eq!(error.code, ErrorCode::Limit), + Ok(_) => panic!("IPv6 request one byte over its wire limit was accepted"), + } + } + } + + #[test] + fn response_framing_matches_native_contract() { + success( + "GET", + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + 200, + b"hello", + ); + failure("GET", "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\npartial"); + failure("GET", "HTTP/1.1 200 OK\r\nContent-Length: 8\r\n\r\nshort"); + failure( + "GET", + "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n", + ); + success( + "HEAD", + "HTTP/1.1 200 OK\r\nContent-Length: 999\r\n\r\n", + 200, + b"", + ); + success( + "GET", + "HTTP/1.1 103 Early Hints\r\nContent-Length: 99\r\n\r\n", + 103, + b"", + ); + success( + "GET", + "HTTP/1.1 204 No Content\r\nContent-Length: 12\r\n\r\n", + 204, + b"", + ); + success( + "GET", + "HTTP/1.1 304 Not Modified\r\nContent-Length: 12\r\n\r\n", + 304, + b"", + ); + success( + "HEAD", + "HTTP/1.1 200 OK\r\nContent-Length: 1048577\r\n\r\n", + 200, + b"", + ); + success( + "GET", + "HTTP/1.1 304 Not Modified\r\nContent-Length: 1048577\r\n\r\n", + 304, + b"", + ); + failure("HEAD", "HTTP/1.1 200 OK\r\nContent-Length: 4\r\n\r\nbody"); + } + + #[test] + fn rejects_duplicate_or_ambiguous_framing() { + failure( + "GET", + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nContent-Length: 0\r\n\r\n", + ); + failure("GET", "HTTP/1.1 200 OK\r\nContent-Length: +1\r\n\r\nx"); + assert_eq!( + parse_http_response(b"HTTP/1.1 200 OK\r\nContent-Length: 1048577\r\n\r\n", "GET") + .expect_err("oversized body-bearing response") + .code, + ErrorCode::Limit + ); + } + + #[test] + fn parsed_response_plaintext_uses_zeroize_on_drop_storage() { + fn assert_zeroize_on_drop() {} + assert_zeroize_on_drop::>>(); + + let parsed = parse_http_response( + b"HTTP/1.1 200 OK\r\nX-Secret: header\r\nContent-Length: 6\r\n\r\nsecret", + "GET", + ) + .expect("parse response"); + let _: &Zeroizing> = &parsed.headers; + let _: &Zeroizing> = &parsed.body; + assert_eq!(parsed.body.as_slice(), b"secret"); + } + + #[test] + fn token_bound_headers_are_narrowly_validated_and_redacted() { + assert!(header_name_is_allowed("Authorization")); + assert!(header_value_is_valid( + "Authorization", + "mtls_pop header.payload.signature" + )); + assert!(!header_value_is_valid( + "Authorization", + "Bearer header.payload.signature" + )); + assert!(!header_value_is_valid( + "Authorization", + "mtls_pop token with spaces" + )); + assert!(header_value_is_valid("x-ms-tokenboundauth", "true")); + assert!(!header_value_is_valid("x-ms-tokenboundauth", "false")); + + let header = Header { + name: "Authorization".to_owned(), + value: Zeroizing::new("mtls_pop secret".to_owned()), + }; + let debug = format!("{header:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("secret")); + } +} diff --git a/native/crates/mtls-bridge/src/lib.rs b/native/crates/mtls-bridge/src/lib.rs new file mode 100644 index 00000000..f631defc --- /dev/null +++ b/native/crates/mtls-bridge/src/lib.rs @@ -0,0 +1,224 @@ +// Bridge failures use an inline, allocation-free ABI-sized message buffer. +#![allow(clippy::result_large_err)] + +use std::ffi::c_void; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::ptr::{copy_nonoverlapping, null_mut}; + +use openssl_provider_sys::ERR_clear_error; +use zeroize::Zeroize; + +mod abi; +mod http; +mod openssl; +mod transport; + +pub use abi::{ + MicrosoftIdentityNativeError, MicrosoftIdentityNativeHeader, MicrosoftIdentityNativeRequest, + MicrosoftIdentityNativeResponse, +}; + +use abi::{BridgeFailure, ErrorCode, clear_error, write_error}; +use http::{ParsedResponse, ValidatedRequest, build_http_request, parse_http_response}; +use openssl::OpenSslState; +use transport::{ + Deadline, Winsock, connect_tcp, establish_tls, ssl_expect_clean_end, ssl_read_response, + ssl_shutdown_deadline, ssl_write_all, +}; + +#[unsafe(no_mangle)] +pub extern "C" fn microsoft_identity_native_abi_version() -> u32 { + catch_unwind(|| abi::ABI_VERSION).unwrap_or(abi::ABI_VERSION) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn microsoft_identity_native_request( + request: *const MicrosoftIdentityNativeRequest, + response: *mut MicrosoftIdentityNativeResponse, + error: *mut MicrosoftIdentityNativeError, +) -> i32 { + // SAFETY: error is optional ABI output storage. + unsafe { clear_error(error) }; + if response.is_null() { + // SAFETY: error is optional ABI output storage. + unsafe { + write_error( + error, + &BridgeFailure::new( + ErrorCode::InvalidArgument, + "A response structure is required.", + ), + ); + } + return 0; + } + // SAFETY: the non-null response pointer is caller-provided writable ABI storage. + unsafe { response.write(MicrosoftIdentityNativeResponse::empty()) }; + // SAFETY: clears only the current thread's OpenSSL error queue. + unsafe { ERR_clear_error() }; + + let result = catch_unwind(AssertUnwindSafe(|| { + // SAFETY: validation performs bounded reads of the caller-owned ABI structure. + let request = unsafe { ValidatedRequest::from_abi(request) }?; + perform_request(&request) + })); + match result { + Ok(Ok(parsed)) => { + let allocation = allocate_response(&parsed); + match allocation { + Ok(output) => { + // SAFETY: response points to writable ABI output storage. + unsafe { response.write(output) }; + 1 + } + Err(failure) => { + // SAFETY: error is optional ABI output storage. + unsafe { write_error(error, &failure) }; + 0 + } + } + } + Ok(Err(failure)) => { + // SAFETY: error is optional ABI output storage. + unsafe { write_error(error, &failure) }; + 0 + } + Err(_) => { + // SAFETY: error is optional ABI output storage. + unsafe { + write_error( + error, + &BridgeFailure::new( + ErrorCode::Io, + "A Rust panic was contained at the native bridge boundary.", + ), + ); + } + 0 + } + } +} + +fn perform_request(request: &ValidatedRequest) -> Result { + let http_request = build_http_request(request)?; + let mut openssl = OpenSslState::initialize(request)?; + let _winsock = Winsock::startup()?; + let connect_deadline = Deadline::new(request.connect_timeout_ms); + let socket = connect_tcp(request, connect_deadline)?; + let io_deadline = Deadline::new(request.io_timeout_ms); + establish_tls(&mut openssl, &socket, request, io_deadline)?; + ssl_write_all(openssl.ssl, socket.raw(), &http_request, io_deadline)?; + let raw_response = ssl_read_response(openssl.ssl, socket.raw(), &request.method, io_deadline)?; + let parsed = parse_http_response(&raw_response, &request.method)?; + ssl_expect_clean_end(openssl.ssl, socket.raw(), io_deadline)?; + ssl_shutdown_deadline(openssl.ssl, socket.raw(), io_deadline)?; + Ok(parsed) +} + +fn allocate_response( + parsed: &ParsedResponse, +) -> Result { + let header_size = parsed.headers.len().checked_add(1).ok_or_else(|| { + BridgeFailure::new( + ErrorCode::Memory, + "Unable to allocate the parsed HTTP response.", + ) + })?; + // SAFETY: malloc returns suitably aligned storage or null; header_size is nonzero. + let headers = unsafe { libc::malloc(header_size) }.cast::(); + if headers.is_null() { + return Err(BridgeFailure::new( + ErrorCode::Memory, + "Unable to allocate the parsed HTTP response.", + )); + } + // SAFETY: `headers` is writable for header_size bytes. + unsafe { + copy_nonoverlapping( + parsed.headers.as_ptr(), + headers.cast::(), + parsed.headers.len(), + ); + *headers.add(parsed.headers.len()) = 0; + } + let body = if parsed.body.is_empty() { + null_mut() + } else { + // SAFETY: malloc returns writable storage or null for the requested nonzero length. + unsafe { libc::malloc(parsed.body.len()) }.cast::() + }; + if !parsed.body.is_empty() && body.is_null() { + // SAFETY: headers was allocated above for exactly header_size writable bytes. + unsafe { + wipe_bytes(headers.cast(), header_size); + libc::free(headers.cast::()); + } + return Err(BridgeFailure::new( + ErrorCode::Memory, + "Unable to allocate the parsed HTTP response.", + )); + } + if !body.is_null() { + // SAFETY: body is writable for parsed.body.len() bytes. + unsafe { copy_nonoverlapping(parsed.body.as_ptr(), body, parsed.body.len()) }; + } + Ok(MicrosoftIdentityNativeResponse { + http_status: parsed.status, + headers, + headers_length: parsed.headers.len(), + body, + body_length: parsed.body.len(), + }) +} + +unsafe fn wipe_bytes(pointer: *mut u8, length: usize) { + if !pointer.is_null() && length != 0 { + // SAFETY: callers provide a writable allocation of at least `length` bytes. + unsafe { std::slice::from_raw_parts_mut(pointer, length) }.zeroize(); + } +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn microsoft_identity_native_response_free( + response: *mut MicrosoftIdentityNativeResponse, +) { + let _ = catch_unwind(AssertUnwindSafe(|| { + if response.is_null() { + return; + } + // SAFETY: response is caller-provided ABI storage initialized by this bridge on success. + let response = unsafe { &mut *response }; + if !response.headers.is_null() { + if response.headers_length <= abi::MAX_HEADER_BYTES + 2 { + // SAFETY: successful bridge allocations include the trailing NUL byte. + unsafe { + wipe_bytes(response.headers.cast(), response.headers_length + 1); + } + } + // SAFETY: successful response headers are allocated with libc::malloc. + unsafe { libc::free(response.headers.cast::()) }; + } + if !response.body.is_null() { + if response.body_length <= abi::MAX_RESPONSE_BYTES { + // SAFETY: successful body allocations contain body_length writable bytes. + unsafe { wipe_bytes(response.body, response.body_length) }; + } + // SAFETY: successful response bodies are allocated with libc::malloc. + unsafe { libc::free(response.body.cast::()) }; + } + *response = MicrosoftIdentityNativeResponse::empty(); + })); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plaintext_allocation_wipe_clears_every_byte() { + let mut plaintext = b"intermediate response plaintext".to_vec(); + // SAFETY: the vector owns a writable allocation covering its current length. + unsafe { wipe_bytes(plaintext.as_mut_ptr(), plaintext.len()) }; + assert!(plaintext.iter().all(|byte| *byte == 0)); + } +} diff --git a/native/crates/mtls-bridge/src/openssl.rs b/native/crates/mtls-bridge/src/openssl.rs new file mode 100644 index 00000000..f86f80c3 --- /dev/null +++ b/native/crates/mtls-bridge/src/openssl.rs @@ -0,0 +1,863 @@ +use std::ffi::CStr; +use std::fs::File; +use std::io::Read; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::ptr::{null, null_mut}; + +use openssl_provider_sys::{ + BIO_free, BIO_new_mem_buf, ERR_clear_error, ERR_error_string_n, ERR_peek_last_error, EVP_PKEY, + EVP_PKEY_free, OSSL_LIB_CTX, OSSL_LIB_CTX_free, OSSL_LIB_CTX_new, OSSL_PROVIDER, + OSSL_PROVIDER_add_builtin, OSSL_PROVIDER_load, OSSL_PROVIDER_unload, OSSL_STORE_INFO_PKEY, + OSSL_STORE_INFO_free, OSSL_STORE_INFO_get_type, OSSL_STORE_INFO_get1_PKEY, OSSL_STORE_close, + OSSL_STORE_eof, OSSL_STORE_error, OSSL_STORE_expect, OSSL_STORE_load, OSSL_STORE_open_ex, + OsslProviderInitFn, PEM_read_bio_X509, PEM_read_bio_X509_AUX, SSL, SSL_CTX, + SSL_CTX_add1_chain_cert, SSL_CTX_check_private_key, SSL_CTX_free, SSL_CTX_get_cert_store, + SSL_CTX_new_ex, SSL_CTX_set_max_proto_version, SSL_CTX_set_min_proto_version, + SSL_CTX_set_options, SSL_CTX_set_verify, SSL_CTX_set_verify_depth, SSL_CTX_use_PrivateKey, + SSL_CTX_use_certificate, SSL_VERIFY_PEER, TLS_client_method, TLS1_2_VERSION, X509, + X509_STORE_add_cert, X509_free, X509_new_ex, +}; +use windows_sys::Win32::Foundation::{FreeLibrary, GetLastError, HMODULE}; +use windows_sys::Win32::System::LibraryLoader::{ + GetProcAddress, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW, +}; +use zeroize::Zeroizing; + +use crate::abi::{BridgeFailure, ErrorCode}; +use crate::http::ValidatedRequest; + +const MAX_PEM_BYTES: usize = 4 * 1024 * 1024; +const CERTIFICATE_BEGIN: &[u8] = b"-----BEGIN CERTIFICATE-----"; +const CERTIFICATE_END: &[u8] = b"-----END CERTIFICATE-----"; +const SSL_OP_IGNORE_UNEXPECTED_EOF: u64 = 1 << 7; + +pub struct OpenSslState { + libctx: *mut OSSL_LIB_CTX, + default_provider: *mut OSSL_PROVIDER, + cng_provider: *mut OSSL_PROVIDER, + ssl_ctx: *mut SSL_CTX, + pub ssl: *mut SSL, + private_key: *mut EVP_PKEY, + certificate: *mut X509, + module: HMODULE, +} + +impl OpenSslState { + pub fn initialize(request: &ValidatedRequest) -> Result { + let mut state = Self { + libctx: null_mut(), + default_provider: null_mut(), + cng_provider: null_mut(), + ssl_ctx: null_mut(), + ssl: null_mut(), + private_key: null_mut(), + certificate: null_mut(), + module: null_mut(), + }; + let (provider_path, module_name) = normalize_provider_path(&request.provider_module_path)?; + // SAFETY: creates an owned private OpenSSL library context. + state.libctx = unsafe { OSSL_LIB_CTX_new() }; + if state.libctx.is_null() { + return Err(openssl_failure( + ErrorCode::Provider, + "Creating the private OpenSSL context", + )); + } + + let provider_wide = try_wide_path(&provider_path)?; + // SAFETY: the canonical UTF-16 path is NUL-terminated and retained for the call. + state.module = unsafe { + LoadLibraryExW( + provider_wide.as_ptr(), + null_mut(), + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_SYSTEM32, + ) + }; + if state.module.is_null() { + return Err(BridgeFailure::system( + ErrorCode::Provider, + // SAFETY: GetLastError has no preconditions. + unsafe { GetLastError() } as i32, + "Loading the provider module by its UTF-8 path failed.", + )); + } + // SAFETY: the module is live and the exported symbol name is NUL-terminated. + let entry = unsafe { GetProcAddress(state.module, c"OSSL_provider_init".as_ptr().cast()) }; + let Some(entry) = entry else { + return Err(BridgeFailure::system( + ErrorCode::Provider, + // SAFETY: GetLastError has no preconditions. + unsafe { GetLastError() } as i32, + "The provider module has no OSSL_provider_init export.", + )); + }; + // SAFETY: `GetProcAddress` returned the provider's required OSSL_provider_init export. On + // Windows x64, extern "system" and extern "C" use the same ABI and function-pointer size. + let init: OsslProviderInitFn = unsafe { std::mem::transmute(entry) }; + // SAFETY: the private libctx and module-name C string remain live; OpenSSL records the + // process-lifetime function address while `state.module` is retained. + if unsafe { + OSSL_PROVIDER_add_builtin(state.libctx, module_name.as_ptr().cast(), Some(init)) + } != 1 + { + return Err(openssl_failure( + ErrorCode::Provider, + "Registering the provider module", + )); + } + // SAFETY: the private libctx is live and names are static/NUL-terminated. + state.default_provider = unsafe { OSSL_PROVIDER_load(state.libctx, c"default".as_ptr()) }; + // SAFETY: same invariant, with the registered module name retained locally for the call. + state.cng_provider = + unsafe { OSSL_PROVIDER_load(state.libctx, module_name.as_ptr().cast()) }; + if state.default_provider.is_null() || state.cng_provider.is_null() { + return Err(openssl_failure( + ErrorCode::Provider, + "Loading the OpenSSL providers", + )); + } + + state.private_key = load_store_key(state.libctx, &request.store_uri)?; + // SAFETY: the private libctx and TLS client method are live. + state.ssl_ctx = unsafe { SSL_CTX_new_ex(state.libctx, null(), TLS_client_method()) }; + if state.ssl_ctx.is_null() + // SAFETY: SSL_CTX is live and the control has no pointer argument. + || !unsafe { SSL_CTX_set_min_proto_version(state.ssl_ctx, TLS1_2_VERSION) } + // SAFETY: Key Vault's token-bound client-certificate path requires TLS 1.2. + || unsafe { SSL_CTX_set_max_proto_version(state.ssl_ctx, TLS1_2_VERSION) } != 1 + { + return Err(openssl_failure( + ErrorCode::Certificate, + "Creating the TLS client context", + )); + } + // HTTP framing is enforced independently, so an omitted close_notify + // cannot truncate a response that the bridge accepts as complete. + // Keep OpenSSL's secure-renegotiation checks: Key Vault requests the client + // certificate after reading the token-bound HTTP headers. + // SAFETY: SSL_CTX is live and the option is supported by OpenSSL 3. + unsafe { + SSL_CTX_set_options(state.ssl_ctx, SSL_OP_IGNORE_UNEXPECTED_EOF); + } + configure_ca_bundle(state.libctx, state.ssl_ctx, &request.ca_bundle_path)?; + state.certificate = configure_client_certificate( + state.libctx, + state.ssl_ctx, + &request.client_certificate_path, + )?; + // SAFETY: the SSL_CTX and provider-backed EVP_PKEY are live. + if unsafe { SSL_CTX_use_PrivateKey(state.ssl_ctx, state.private_key) } != 1 + // SAFETY: the SSL_CTX has both its certificate and key configured. + || unsafe { SSL_CTX_check_private_key(state.ssl_ctx) } != 1 + { + return Err(openssl_failure( + ErrorCode::Certificate, + "Configuring the client certificate and key", + )); + } + // SAFETY: the SSL_CTX is live; callback null requests standard verification. + unsafe { + SSL_CTX_set_verify(state.ssl_ctx, SSL_VERIFY_PEER, None); + SSL_CTX_set_verify_depth(state.ssl_ctx, 6); + } + Ok(state) + } + + pub fn ssl_context(&self) -> *mut SSL_CTX { + self.ssl_ctx + } +} + +impl Drop for OpenSslState { + fn drop(&mut self) { + // SAFETY: each non-null handle is owned by this state and released once in dependency + // order. The thread error queue is cleared before provider-owned code is unloaded. + unsafe { + if !self.ssl.is_null() { + openssl_provider_sys::SSL_free(self.ssl); + } + if !self.ssl_ctx.is_null() { + SSL_CTX_free(self.ssl_ctx); + } + if !self.certificate.is_null() { + X509_free(self.certificate); + } + if !self.private_key.is_null() { + EVP_PKEY_free(self.private_key); + } + ERR_clear_error(); + if !self.cng_provider.is_null() { + let _ = OSSL_PROVIDER_unload(self.cng_provider); + } + if !self.default_provider.is_null() { + let _ = OSSL_PROVIDER_unload(self.default_provider); + } + if !self.libctx.is_null() { + OSSL_LIB_CTX_free(self.libctx); + } + ERR_clear_error(); + if !self.module.is_null() { + let _ = FreeLibrary(self.module); + } + } + } +} + +fn normalize_provider_path(input: &str) -> Result<(PathBuf, Zeroizing>), BridgeFailure> { + let path = Path::new(input); + let full_path = path.canonicalize().map_err(|error| { + BridgeFailure::system( + ErrorCode::Provider, + error.raw_os_error().unwrap_or_default(), + "The provider module path cannot be normalized.", + ) + })?; + if !full_path.is_file() { + return Err(BridgeFailure::new( + ErrorCode::Provider, + "The provider module path is not a file.", + )); + } + let filename = full_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| { + BridgeFailure::new( + ErrorCode::Provider, + "The provider module name is not valid UTF-8.", + ) + })?; + let module_name = filename + .len() + .checked_sub(4) + .and_then(|offset| filename.get(offset..).map(|suffix| (offset, suffix))) + .filter(|(_, suffix)| suffix.eq_ignore_ascii_case(".dll")) + .map_or(filename, |(offset, _)| &filename[..offset]); + if module_name.is_empty() || module_name.len() >= 256 { + return Err(BridgeFailure::new( + ErrorCode::Provider, + "The provider module name is too long.", + )); + } + if module_name.as_bytes().contains(&0) { + return Err(BridgeFailure::new( + ErrorCode::Provider, + "The provider module name contains a NUL character.", + )); + } + let module_name = try_cstring(module_name)?; + Ok((full_path, module_name)) +} + +fn load_store_key(libctx: *mut OSSL_LIB_CTX, uri: &str) -> Result<*mut EVP_PKEY, BridgeFailure> { + if uri.as_bytes().contains(&0) { + return Err(BridgeFailure::new( + ErrorCode::Store, + "The CNG STORE URI contains a NUL character.", + )); + } + let uri = try_cstring(uri)?; + // SAFETY: all C strings and the private libctx are live; optional callbacks/params are null. + let store = unsafe { + OSSL_STORE_open_ex( + uri.as_ptr().cast(), + libctx, + c"provider=microsoft_identity_native_provider".as_ptr(), + null(), + null_mut(), + null(), + None, + null_mut(), + ) + }; + if store.is_null() + // SAFETY: store is checked before this call. + || unsafe { OSSL_STORE_expect(store, OSSL_STORE_INFO_PKEY) } != 1 + { + if !store.is_null() { + // SAFETY: store is owned by this function. + unsafe { + let _ = OSSL_STORE_close(store); + } + } + return Err(openssl_failure( + ErrorCode::Store, + "Opening the CNG STORE URI", + )); + } + + let mut key = null_mut(); + loop { + // SAFETY: store remains live until the end of this function. + if unsafe { OSSL_STORE_eof(store) } != 0 { + break; + } + // SAFETY: store remains live and returns an owned info object or null. + let info = unsafe { OSSL_STORE_load(store) }; + if info.is_null() { + // SAFETY: store remains live. + if unsafe { OSSL_STORE_error(store) } != 0 { + // SAFETY: store is owned here. + unsafe { + let _ = OSSL_STORE_close(store); + } + return Err(openssl_failure( + ErrorCode::Store, + "Loading the CNG STORE key", + )); + } + break; + } + // SAFETY: info is a live OSSL_STORE_INFO. + if unsafe { OSSL_STORE_INFO_get_type(info) } == OSSL_STORE_INFO_PKEY { + // SAFETY: info is live; get1 returns an independently referenced EVP_PKEY. + key = unsafe { OSSL_STORE_INFO_get1_PKEY(info) }; + // SAFETY: info is owned here. + unsafe { OSSL_STORE_INFO_free(info) }; + break; + } + // SAFETY: info is owned here. + unsafe { OSSL_STORE_INFO_free(info) }; + } + // SAFETY: store is owned here. + unsafe { + let _ = OSSL_STORE_close(store); + } + if key.is_null() { + Err(BridgeFailure::new( + ErrorCode::Store, + "The CNG STORE URI returned no private key.", + )) + } else { + Ok(key) + } +} + +fn read_pem_file(path: &str, description: &str) -> Result, BridgeFailure> { + let mut file = File::open(path).map_err(|error| { + BridgeFailure::system_formatted( + ErrorCode::Certificate, + error.raw_os_error().unwrap_or_default(), + format_args!("Unable to open the {description}."), + ) + })?; + let size = file.metadata().map_err(|error| { + BridgeFailure::system_formatted( + ErrorCode::Certificate, + error.raw_os_error().unwrap_or_default(), + format_args!("Unable to inspect the {description}."), + ) + })?; + let Ok(size) = usize::try_from(size.len()) else { + return Err(BridgeFailure::formatted( + ErrorCode::Certificate, + format_args!("The {description} must be between 1 byte and 4 MiB."), + )); + }; + if size == 0 || size > MAX_PEM_BYTES { + return Err(BridgeFailure::formatted( + ErrorCode::Certificate, + format_args!("The {description} must be between 1 byte and 4 MiB."), + )); + } + let mut contents = Vec::new(); + contents + .try_reserve_exact(size) + .map_err(|_| BridgeFailure::memory())?; + contents.resize(size, 0); + file.read_exact(&mut contents).map_err(|error| { + BridgeFailure::system_formatted( + ErrorCode::Certificate, + error.raw_os_error().unwrap_or_default(), + format_args!("Unable to read the {description}."), + ) + })?; + let mut extra = [0_u8; 1]; + if file.read(&mut extra).map_err(|error| { + BridgeFailure::system_formatted( + ErrorCode::Certificate, + error.raw_os_error().unwrap_or_default(), + format_args!("Unable to read the {description}."), + ) + })? != 0 + { + return Err(BridgeFailure::formatted( + ErrorCode::Certificate, + format_args!("The {description} changed while it was being read."), + )); + } + Ok(contents) +} + +fn configure_client_certificate( + libctx: *mut OSSL_LIB_CTX, + ssl_ctx: *mut SSL_CTX, + path: &str, +) -> Result<*mut X509, BridgeFailure> { + let contents = read_pem_file(path, "client certificate PEM file")?; + validate_certificate_pem(&contents, "client certificate PEM file")?; + let mut cursor = 0; + let leaf = next_certificate_pem_block(&contents, &mut cursor, "client certificate PEM file")? + .expect("validated client certificate contains a leaf"); + let certificate = parse_x509_block(libctx, leaf, true, "Loading the client certificate")?; + if + // SAFETY: both the SSL_CTX and certificate are live when non-null. + unsafe { SSL_CTX_use_certificate(ssl_ctx, certificate) } != 1 { + // SAFETY: the local certificate reference is owned here. + unsafe { X509_free(certificate) }; + return Err(openssl_failure( + ErrorCode::Certificate, + "Loading the client certificate", + )); + } + + while let Some(block) = + next_certificate_pem_block(&contents, &mut cursor, "client certificate PEM file")? + { + let chain = + match parse_x509_block(libctx, block, false, "Loading the client certificate chain") { + Ok(chain) => chain, + Err(error) => { + // SAFETY: the local leaf reference is owned here. + unsafe { X509_free(certificate) }; + return Err(error); + } + }; + // SAFETY: SSL_CTX and chain are live; add1 increments the chain certificate reference. + let installed = unsafe { SSL_CTX_add1_chain_cert(ssl_ctx, chain) }; + // SAFETY: local chain reference is owned here after add1. + unsafe { X509_free(chain) }; + if !installed { + // SAFETY: the local leaf reference is owned here. + unsafe { X509_free(certificate) }; + return Err(openssl_failure( + ErrorCode::Certificate, + "Installing the client certificate chain", + )); + } + } + Ok(certificate) +} + +fn configure_ca_bundle( + libctx: *mut OSSL_LIB_CTX, + ssl_ctx: *mut SSL_CTX, + path: &str, +) -> Result<(), BridgeFailure> { + let contents = read_pem_file(path, "CA bundle PEM file")?; + validate_certificate_pem(&contents, "CA bundle PEM file")?; + // SAFETY: SSL_CTX is live. + let store = unsafe { SSL_CTX_get_cert_store(ssl_ctx) }; + if store.is_null() { + return Err(openssl_failure( + ErrorCode::Certificate, + "Opening the CA bundle", + )); + } + let mut cursor = 0; + while let Some(block) = + next_certificate_pem_block(&contents, &mut cursor, "CA bundle PEM file")? + { + let certificate = parse_x509_block(libctx, block, true, "Loading the CA bundle")?; + // SAFETY: store and certificate are live; X509_STORE_add_cert retains the certificate. + let installed = unsafe { X509_STORE_add_cert(store, certificate) }; + // SAFETY: local certificate reference is owned here. + unsafe { X509_free(certificate) }; + if installed != 1 { + return Err(openssl_failure( + ErrorCode::Certificate, + "Installing the CA bundle", + )); + } + } + Ok(()) +} + +fn validate_certificate_pem(contents: &[u8], description: &str) -> Result { + let mut cursor = 0; + let mut count = 0; + while next_certificate_pem_block(contents, &mut cursor, description)?.is_some() { + count += 1; + } + if count == 0 { + return Err(BridgeFailure::formatted( + ErrorCode::Certificate, + format_args!("The {description} contains no complete CERTIFICATE PEM block."), + )); + } + Ok(count) +} + +fn next_certificate_pem_block<'a>( + contents: &'a [u8], + cursor: &mut usize, + description: &str, +) -> Result, BridgeFailure> { + while *cursor < contents.len() && is_pem_whitespace(contents[*cursor]) { + *cursor += 1; + } + if *cursor == contents.len() { + return Ok(None); + } + let start = *cursor; + if !contents[start..].starts_with(CERTIFICATE_BEGIN) { + return Err(malformed_pem(description)); + } + let body_start = start + CERTIFICATE_BEGIN.len(); + let Some(relative_end) = crate::http::find_bytes(&contents[body_start..], CERTIFICATE_END) + else { + return Err(malformed_pem(description)); + }; + let end = body_start + relative_end + CERTIFICATE_END.len(); + *cursor = end; + Ok(Some(&contents[start..end])) +} + +fn is_pem_whitespace(value: u8) -> bool { + matches!(value, b' ' | b'\t' | b'\r' | b'\n') +} + +fn malformed_pem(description: &str) -> BridgeFailure { + BridgeFailure::formatted( + ErrorCode::Certificate, + format_args!( + "The {description} must contain only whitespace and complete CERTIFICATE PEM blocks." + ), + ) +} + +fn parse_x509_block( + libctx: *mut OSSL_LIB_CTX, + block: &[u8], + auxiliary: bool, + operation: &str, +) -> Result<*mut X509, BridgeFailure> { + // SAFETY: the block remains live for the complete BIO lifetime. + let bio = unsafe { BIO_new_mem_buf(block.as_ptr().cast(), block.len() as i32) }; + if bio.is_null() { + return Err(openssl_failure(ErrorCode::Certificate, operation)); + } + // SAFETY: creates an owned empty X509 bound to the bridge's private library context. + let mut certificate = unsafe { X509_new_ex(libctx, null()) }; + if certificate.is_null() { + // SAFETY: bio is owned here. + unsafe { + let _ = BIO_free(bio); + } + return Err(openssl_failure(ErrorCode::Certificate, operation)); + } + // SAFETY: clears only the current thread's OpenSSL error queue before parsing this block. + unsafe { ERR_clear_error() }; + // SAFETY: bio and the preallocated private-context X509 are live; optional callbacks are null. + let parsed = unsafe { + if auxiliary { + PEM_read_bio_X509_AUX(bio, &raw mut certificate, None, null_mut()) + } else { + PEM_read_bio_X509(bio, &raw mut certificate, None, null_mut()) + } + }; + if parsed.is_null() { + let failure = openssl_failure(ErrorCode::Certificate, operation); + // SAFETY: on failure, any non-null value retained in the output slot is owned here. + unsafe { + if !certificate.is_null() { + X509_free(certificate); + } + let _ = BIO_free(bio); + } + return Err(failure); + } + debug_assert_eq!(parsed, certificate); + // SAFETY: bio is owned here; the parsed X509 is returned to the caller. + unsafe { + let _ = BIO_free(bio); + } + Ok(parsed) +} + +pub fn openssl_failure(code: ErrorCode, operation: &str) -> BridgeFailure { + // SAFETY: OpenSSL error queue access has no preconditions. + let openssl_error = unsafe { ERR_peek_last_error() } as u32; + let mut detail = [0_i8; 256]; + if openssl_error != 0 { + // SAFETY: detail is writable and the error code came from OpenSSL. + unsafe { ERR_error_string_n(openssl_error, detail.as_mut_ptr(), detail.len()) }; + } + let detail = if detail[0] == 0 { + None + } else { + // SAFETY: ERR_error_string_n always NUL-terminates a non-empty destination. + unsafe { CStr::from_ptr(detail.as_ptr()) }.to_str().ok() + }; + match detail { + Some(detail) => BridgeFailure::openssl_formatted( + code, + openssl_error, + format_args!("{operation} failed: {detail}."), + ), + None => BridgeFailure::openssl_formatted( + code, + openssl_error, + format_args!("{operation} failed."), + ), + } +} + +fn try_wide_path(path: &Path) -> Result>, BridgeFailure> { + let length = path + .as_os_str() + .encode_wide() + .count() + .checked_add(1) + .ok_or_else(BridgeFailure::memory)?; + let mut output = Vec::new(); + output + .try_reserve_exact(length) + .map_err(|_| BridgeFailure::memory())?; + output.extend(path.as_os_str().encode_wide()); + output.push(0); + Ok(Zeroizing::new(output)) +} + +fn try_cstring(value: &str) -> Result>, BridgeFailure> { + let length = value + .len() + .checked_add(1) + .ok_or_else(BridgeFailure::memory)?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(length) + .map_err(|_| BridgeFailure::memory())?; + bytes.extend_from_slice(value.as_bytes()); + bytes.push(0); + Ok(Zeroizing::new(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use openssl_provider_sys::{EVP_set_default_properties, X509_get_pubkey}; + use std::sync::Mutex; + + const TEST_CERTIFICATE: &[u8] = b"-----BEGIN CERTIFICATE-----\n\ +MIIDSTCCAjGgAwIBAgIBATANBgkqhkiG9w0BAQsFADBGMSAwHgYDVQQKDBdPcGVu\n\ +U1NMIENORyBsb2NhbCB0ZXN0czEiMCAGA1UEAwwZT3BlblNTTCBDTkcgTG9jYWwg\n\ +VGVzdCBDQTAeFw0yNjA4MTcyMjUyMjFaFw0yNjA4MTgyMjUzMjFaMEYxIDAeBgNV\n\ +BAoMF09wZW5TU0wgQ05HIGxvY2FsIHRlc3RzMSIwIAYDVQQDDBlPcGVuU1NMIENO\n\ +RyBMb2NhbCBUZXN0IENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA\n\ +smN1o5KqmX4TPBqJ4D2HnnCDP9ivAbZ4KCQK3Fay1IdjZaCr62FCa/gH1/XsC6Za\n\ +N2mrXf5QsZjkWc/zc7bmAOJ+FiZ3v5Jk9hTdWwPsz9lByrhXSGShVwjZjrf+Rf2l\n\ +q5+TX8TGaq/pHAYbyMw2PcK6rhpCsmur+cCl7iCRWMxrTdhWPT6IXhx6j27D8az2\n\ +9MM4sDi1Cazh/VHel17MsJ6WvORRb/t8/2ZI/SzF1UdlGZyqgpapoSZIEgNN6pSg\n\ +vMtkMDiir+jOy3IDBbezi8Cm+320zvQk05SVIc+9JofGwsVgAa7Cf9KFM8Qy6DvW\n\ +g6YldC+MRVescNwMF/g8TQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1Ud\n\ +DwEB/wQEAwIBBjAdBgNVHQ4EFgQUA4r//P0QtcpZqoHHwFEK14J3u9IwDQYJKoZI\n\ +hvcNAQELBQADggEBADPCB63zWvoHQrzzyPVrXV5rdkwDuN8sDFCS6N/nWuumnrZ3\n\ +l5jQBbmTLU7GoTA7tPIRAFFH+8V0e1ws0vABew18gNgRQt23QZLMbKg1QJkOfirq\n\ +pdq9hHnjNDsS1fcDCKc4joghIRWZIW1FKjQVXlhCX+nQWuSTi/bGPQ4WcRGEhqXE\n\ +MQt8uvmOdaPTJUbMV1gy0NqwjypirCAwfZRpz7k5x7RsuftwcpvZpy+HTVIEqAj+\n\ +sO1+939usuNfkDTQdpfUTWZY8Fa+1aGR8M+0R4FdW8XcF6lnExf2TDWBEbarnfBT\n\ +MkbjF87zqe14SXsBFriIJlRgbWEbFjC5zLG3ceY=\n\ +-----END CERTIFICATE-----\n"; + + static GLOBAL_PROPERTIES_LOCK: Mutex<()> = Mutex::new(()); + + struct PrivateContext { + libctx: *mut OSSL_LIB_CTX, + provider: *mut OSSL_PROVIDER, + } + + impl PrivateContext { + fn new() -> Self { + // SAFETY: creates an owned private OpenSSL library context. + let libctx = unsafe { OSSL_LIB_CTX_new() }; + assert!(!libctx.is_null()); + // SAFETY: the private context is live and the provider name is static. + let provider = unsafe { OSSL_PROVIDER_load(libctx, c"default".as_ptr()) }; + assert!(!provider.is_null()); + Self { libctx, provider } + } + } + + impl Drop for PrivateContext { + fn drop(&mut self) { + // SAFETY: both handles are owned by this test context. + unsafe { + let _ = OSSL_PROVIDER_unload(self.provider); + OSSL_LIB_CTX_free(self.libctx); + } + } + } + + #[test] + fn store_uri_conversion_is_wiped_after_success_and_failure() { + struct OwnedTestKey { + key: Option, + name: String, + } + impl Drop for OwnedTestKey { + fn drop(&mut self) { + drop(self.key.take()); + cng_core::delete_test_key(cng_core::DEFAULT_PROVIDER_NAME, &self.name) + .expect("delete process-owned URI cleanup test key"); + } + } + struct NativeProvider(*mut OSSL_PROVIDER); + impl Drop for NativeProvider { + fn drop(&mut self) { + // SAFETY: this test owns the provider reference, and clears its error queue + // before unloading provider code; the library context is still live. + unsafe { + ERR_clear_error(); + let _ = OSSL_PROVIDER_unload(self.0); + } + } + } + + let name = format!( + "openssl-cng-provider-test-{}-uri-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + ); + assert!(cng_core::CngKey::open(cng_core::DEFAULT_PROVIDER_NAME, &name).is_err()); + let _key = OwnedTestKey { + key: Some( + cng_core::CngKey::open_or_create(cng_core::DEFAULT_PROVIDER_NAME, &name, 2048) + .expect("create process-owned test key"), + ), + name: name.clone(), + }; + let context = PrivateContext::new(); + let provider_path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("artifacts") + .join(if cfg!(debug_assertions) { + "Debug" + } else { + "Release" + }) + .join("microsoft_identity_native_provider.dll"); + let provider_path = try_cstring(provider_path.to_str().expect("test provider path")) + .expect("NUL-terminated provider path"); + // SAFETY: the private context and complete absolute provider path remain live. + let provider = unsafe { OSSL_PROVIDER_load(context.libctx, provider_path.as_ptr().cast()) }; + assert!( + !provider.is_null(), + "load native provider for STORE cleanup regression" + ); + let _provider = NativeProvider(provider); + let uri = format!("cng://?key={name}"); + crate::http::tests::assert_deallocated_copy_is_wiped(uri.len() + 1, || { + let key = load_store_key(context.libctx, &uri).expect("load existing test key"); + // SAFETY: STORE returned one owned EVP_PKEY reference. + unsafe { EVP_PKEY_free(key) }; + }); + let missing_uri = format!("{uri}-missing"); + crate::http::tests::assert_deallocated_copy_is_wiped(missing_uri.len() + 1, || { + assert!(load_store_key(context.libctx, &missing_uri).is_err()); + }); + } + + struct GlobalPropertiesReset; + + impl Drop for GlobalPropertiesReset { + fn drop(&mut self) { + // SAFETY: a null property query restores the global context defaults. + unsafe { + let _ = EVP_set_default_properties(null_mut(), null()); + } + } + } + + #[test] + fn strict_pem_validation_rejects_trailing_malformed_data_for_all_inputs() { + for description in ["client certificate PEM file", "CA bundle PEM file"] { + let mut incomplete = TEST_CERTIFICATE.to_vec(); + incomplete.extend_from_slice(b"\n-----BEGIN CERTIFICATE-----\n"); + assert_eq!( + validate_certificate_pem(&incomplete, description) + .expect_err("incomplete trailing PEM must fail") + .code, + ErrorCode::Certificate + ); + + let mut garbage = TEST_CERTIFICATE.to_vec(); + garbage.extend_from_slice(b"\nnot-a-certificate"); + assert_eq!( + validate_certificate_pem(&garbage, description) + .expect_err("trailing garbage must fail") + .code, + ErrorCode::Certificate + ); + } + } + + #[test] + fn valid_multi_certificate_pem_is_complete_and_parseable() { + let context = PrivateContext::new(); + let mut bundle = b" \r\n".to_vec(); + bundle.extend_from_slice(TEST_CERTIFICATE); + bundle.extend_from_slice(b"\n\t"); + bundle.extend_from_slice(TEST_CERTIFICATE); + bundle.extend_from_slice(b"\r\n"); + assert_eq!( + validate_certificate_pem(&bundle, "test certificate bundle") + .expect("valid multi-certificate PEM"), + 2 + ); + + let mut cursor = 0; + let mut parsed = 0; + while let Some(block) = + next_certificate_pem_block(&bundle, &mut cursor, "test certificate bundle") + .expect("validated PEM iteration") + { + let certificate = parse_x509_block( + context.libctx, + block, + parsed == 0, + "Parsing a private-context certificate", + ) + .expect("parse complete certificate block"); + // SAFETY: each parsed certificate is owned by this test. + unsafe { X509_free(certificate) }; + parsed += 1; + } + assert_eq!(parsed, 2); + } + + #[test] + fn private_libctx_certificate_parsing_ignores_hostile_global_properties() { + let _lock = GLOBAL_PROPERTIES_LOCK.lock().expect("global property lock"); + let context = PrivateContext::new(); + // SAFETY: installs a deliberately unsatisfiable global provider property query. + let configured = + unsafe { EVP_set_default_properties(null_mut(), c"provider=null".as_ptr()) }; + assert_eq!(configured, 1); + let _reset = GlobalPropertiesReset; + + let certificate = parse_x509_block( + context.libctx, + TEST_CERTIFICATE, + true, + "Parsing with a hostile global property query", + ) + .expect("private-context X509 parsing"); + // SAFETY: the certificate is live and retains the private library context. + let public_key = unsafe { X509_get_pubkey(certificate) }; + assert!( + !public_key.is_null(), + "public-key decoding must use the private context" + ); + // SAFETY: both returned OpenSSL objects are owned by this test. + unsafe { + EVP_PKEY_free(public_key); + X509_free(certificate); + } + } +} diff --git a/native/crates/mtls-bridge/src/transport.rs b/native/crates/mtls-bridge/src/transport.rs new file mode 100644 index 00000000..38616f2f --- /dev/null +++ b/native/crates/mtls-bridge/src/transport.rs @@ -0,0 +1,1264 @@ +use std::ffi::{CString, c_void}; +use std::net::IpAddr; +use std::ptr::{null, null_mut}; +use std::time::{Duration, Instant}; + +use openssl_provider_sys::{ + SSL, SSL_ERROR_SYSCALL, SSL_ERROR_WANT_READ, SSL_ERROR_WANT_WRITE, SSL_ERROR_ZERO_RETURN, + SSL_connect, SSL_get_error, SSL_get_verify_result, SSL_get0_param, SSL_new, SSL_read_ex, + SSL_set_fd, SSL_set_tlsext_host_name, SSL_set1_host, SSL_shutdown, SSL_write_ex, + X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS, X509_V_OK, X509_VERIFY_PARAM_set_hostflags, + X509_VERIFY_PARAM_set1_ip_asc, +}; +use windows_sys::Win32::Foundation::{GetLastError, HANDLE, HMODULE, WAIT_OBJECT_0, WAIT_TIMEOUT}; +use windows_sys::Win32::Networking::WinSock::{ + ADDRINFOEXW, AF_UNSPEC, FD_SET, FIONBIO, FreeAddrInfoExW, GetAddrInfoExCancel, + GetAddrInfoExOverlappedResult, GetAddrInfoExW, INVALID_SOCKET, IPPROTO_TCP, NS_DNS, SO_ERROR, + SOCK_STREAM, SOCKET, SOCKET_ERROR, SOL_SOCKET, TIMEVAL, WSA_FLAG_NO_HANDLE_INHERIT, + WSA_INVALID_EVENT, WSA_IO_PENDING, WSACleanup, WSACloseEvent, WSACreateEvent, WSADATA, + WSAETIMEDOUT, WSAEWOULDBLOCK, WSAGetLastError, WSASocketW, WSAStartup, closesocket, connect, + getsockopt, ioctlsocket, select, +}; +use windows_sys::Win32::System::IO::OVERLAPPED; +use windows_sys::Win32::System::LibraryLoader::{ + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS, GET_MODULE_HANDLE_EX_FLAG_PIN, GetModuleHandleExW, +}; +use windows_sys::Win32::System::Threading::{INFINITE, WaitForSingleObject}; +use zeroize::Zeroizing; + +use crate::abi::{BridgeFailure, ErrorCode, MAX_HEADER_BYTES, MAX_RESPONSE_BYTES}; +use crate::http::{ValidatedRequest, find_bytes, inspect_http_response}; +use crate::openssl::{OpenSslState, openssl_failure}; + +const MAX_INFORMATIONAL_RESPONSES: usize = 16; + +pub struct Winsock; + +impl Winsock { + pub fn startup() -> Result { + // SAFETY: WSADATA is plain output storage initialized by WSAStartup. + let mut data: WSADATA = unsafe { std::mem::zeroed() }; + // SAFETY: the output pointer is valid and version 2.2 is requested. + let result = unsafe { WSAStartup(0x0202, &raw mut data) }; + if result != 0 { + Err(BridgeFailure::system_formatted( + ErrorCode::Connect, + result, + format_args!("WSAStartup failed with error {result}."), + )) + } else { + Ok(Self) + } + } +} + +impl Drop for Winsock { + fn drop(&mut self) { + // SAFETY: each successful Winsock::startup owns one matching cleanup. + unsafe { + let _ = WSACleanup(); + } + } +} + +#[derive(Clone, Copy)] +pub struct Deadline { + expires: Instant, +} + +impl Deadline { + pub fn new(timeout_ms: u32) -> Self { + Self { + expires: Instant::now() + Duration::from_millis(u64::from(timeout_ms)), + } + } + + fn remaining_ms(self) -> Option { + let remaining = self.expires.checked_duration_since(Instant::now())?; + if remaining.is_zero() { + return None; + } + Some( + remaining + .as_millis() + .clamp(1, u128::from(u32::MAX)) + .try_into() + .unwrap_or(u32::MAX), + ) + } + + fn ensure(self, code: ErrorCode, operation: &str) -> Result<(), BridgeFailure> { + if self.remaining_ms().is_some() { + Ok(()) + } else { + Err(BridgeFailure::system_formatted( + code, + WSAETIMEDOUT, + format_args!("{operation} exceeded the total I/O deadline."), + )) + } + } +} + +pub struct Socket(SOCKET); + +impl Socket { + pub const fn raw(&self) -> SOCKET { + self.0 + } +} + +impl Drop for Socket { + fn drop(&mut self) { + if self.0 != INVALID_SOCKET { + // SAFETY: this wrapper owns the connected socket. + unsafe { + let _ = closesocket(self.0); + } + } + } +} + +struct DnsQuery { + overlapped: OVERLAPPED, + cancel_handle: HANDLE, + hints: ADDRINFOEXW, + addresses: *mut ADDRINFOEXW, + host: Vec, + port: [u16; 6], + winsock_reference: bool, +} + +// SAFETY: once submitted, the one-element query vector has a stable address and is touched only +// by the owning thread after the completion event is signaled. +unsafe impl Send for DnsQuery {} + +impl Drop for DnsQuery { + fn drop(&mut self) { + // SAFETY: completed address results and event handles are owned by this query. + unsafe { + if !self.addresses.is_null() { + FreeAddrInfoExW(self.addresses); + } + let event = self.overlapped.hEvent as isize; + if event != WSA_INVALID_EVENT && event != 0 { + let _ = WSACloseEvent(event); + } + if self.winsock_reference { + let _ = WSACleanup(); + } + } + } +} + +pub fn connect_tcp( + request: &ValidatedRequest, + deadline: Deadline, +) -> Result { + let host = try_utf16_z(&request.host)?; + let mut port = [0_u16; 6]; + write_decimal_utf16(request.port, &mut port); + let mut query_storage = Vec::new(); + query_storage + .try_reserve_exact(1) + .map_err(|_| BridgeFailure::memory())?; + let event = { + // SAFETY: WSACreateEvent has no preconditions after Winsock startup. + unsafe { WSACreateEvent() } + }; + if event == WSA_INVALID_EVENT { + return Err(BridgeFailure::system( + ErrorCode::Dns, + // SAFETY: WSAGetLastError has no preconditions. + unsafe { WSAGetLastError() }, + "Unable to create the name-resolution event.", + )); + } + // Keep an independent Winsock reference with the asynchronous query so timeout cancellation + // cleanup remains valid after the calling request releases its own Winsock reference. + // SAFETY: WSADATA is plain output storage. + let mut dns_winsock: WSADATA = unsafe { std::mem::zeroed() }; + // SAFETY: requests an independent Winsock 2.2 reference for the query lifetime. + let dns_startup = unsafe { WSAStartup(0x0202, &raw mut dns_winsock) }; + if dns_startup != 0 { + // SAFETY: the event was created above and is still owned locally. + unsafe { + let _ = WSACloseEvent(event); + } + return Err(BridgeFailure::system( + ErrorCode::Dns, + dns_startup, + "Unable to retain Winsock for name resolution.", + )); + } + query_storage.push(DnsQuery { + overlapped: OVERLAPPED { + hEvent: event as usize as HANDLE, + ..Default::default() + }, + cancel_handle: null_mut(), + hints: ADDRINFOEXW { + ai_family: i32::from(AF_UNSPEC), + ai_socktype: SOCK_STREAM, + ai_protocol: IPPROTO_TCP, + ..Default::default() + }, + addresses: null_mut(), + host, + port, + winsock_reference: true, + }); + let query = &mut query_storage[0]; + let Some(remaining) = deadline.remaining_ms() else { + return Err(BridgeFailure::system( + ErrorCode::Dns, + WSAETIMEDOUT, + "Name resolution exceeded the connection deadline.", + )); + }; + // SAFETY: the reserved one-element vector has a stable address; all input strings and output + // fields remain live until completion or asynchronous cancellation cleanup. + let mut result = unsafe { + GetAddrInfoExW( + query.host.as_ptr(), + query.port.as_ptr(), + NS_DNS, + null(), + &raw const query.hints, + &raw mut query.addresses, + null(), + &raw const query.overlapped, + None, + &raw mut query.cancel_handle, + ) + }; + if result == WSA_IO_PENDING { + // SAFETY: the completion event belongs to this pending query. + let wait = unsafe { WaitForSingleObject(query.overlapped.hEvent, remaining) }; + if wait == WAIT_TIMEOUT { + // SAFETY: the cancel handle belongs to the pending lookup. + unsafe { + let _ = GetAddrInfoExCancel(&raw const query.cancel_handle); + } + retain_cancelled_query(query_storage); + return Err(BridgeFailure::system( + ErrorCode::Dns, + WSAETIMEDOUT, + "Name resolution exceeded the connection deadline.", + )); + } + if wait == WAIT_OBJECT_0 { + // SAFETY: the completion event is signaled and the overlapped structure remains live. + result = unsafe { GetAddrInfoExOverlappedResult(&raw const query.overlapped) }; + } else { + // SAFETY: GetLastError has no preconditions. + let wait_error = unsafe { GetLastError() } as i32; + // SAFETY: the cancel handle belongs to the pending lookup. + unsafe { + let _ = GetAddrInfoExCancel(&raw const query.cancel_handle); + } + retain_cancelled_query(query_storage); + return Err(BridgeFailure::system_formatted( + ErrorCode::Dns, + wait_error, + format_args!("Waiting for name resolution failed with error {wait_error}."), + )); + } + } + if deadline.remaining_ms().is_none() { + return Err(BridgeFailure::system( + ErrorCode::Dns, + WSAETIMEDOUT, + "Name resolution exceeded the connection deadline.", + )); + } + if result != 0 { + return if result == WSAETIMEDOUT { + Err(BridgeFailure::system( + ErrorCode::Dns, + result, + "Name resolution exceeded the connection deadline.", + )) + } else { + Err(BridgeFailure::system_formatted( + ErrorCode::Dns, + result, + format_args!("Name resolution failed with Winsock error {result}."), + )) + }; + } + + let mut current = query.addresses; + let mut last_error = 0; + while !current.is_null() { + if deadline.remaining_ms().is_none() { + last_error = WSAETIMEDOUT; + break; + } + // SAFETY: current points into the completed linked address list owned by query. + let address = unsafe { &*current }; + // SAFETY: address family/type/protocol came from Winsock. + let socket_handle = unsafe { + WSASocketW( + address.ai_family, + address.ai_socktype, + address.ai_protocol, + null(), + 0, + WSA_FLAG_NO_HANDLE_INHERIT, + ) + }; + if socket_handle == INVALID_SOCKET { + // SAFETY: WSAGetLastError has no preconditions. + last_error = unsafe { WSAGetLastError() }; + current = address.ai_next; + continue; + } + let mut nonblocking = 1_u32; + // SAFETY: socket is live and nonblocking points to writable u32 storage. + if unsafe { ioctlsocket(socket_handle, FIONBIO, &raw mut nonblocking) } != 0 { + // SAFETY: WSAGetLastError has no preconditions. + last_error = unsafe { WSAGetLastError() }; + // SAFETY: socket is owned locally on this failed attempt. + unsafe { + let _ = closesocket(socket_handle); + } + current = address.ai_next; + continue; + } + // SAFETY: Winsock supplied a valid socket address and length. + let mut connect_result = unsafe { + connect( + socket_handle, + address.ai_addr, + address.ai_addrlen.min(i32::MAX as usize) as i32, + ) + }; + if connect_result == SOCKET_ERROR + // SAFETY: WSAGetLastError has no preconditions. + && unsafe { WSAGetLastError() } == WSAEWOULDBLOCK + { + match wait_connect(socket_handle, deadline) { + Ok(()) => connect_result = 0, + Err(error) => last_error = error, + } + } + if connect_result == 0 { + if deadline.remaining_ms().is_some() { + return Ok(Socket(socket_handle)); + } + last_error = WSAETIMEDOUT; + } + if last_error == 0 { + // SAFETY: WSAGetLastError has no preconditions. + last_error = unsafe { WSAGetLastError() }; + } + // SAFETY: socket is owned locally on this failed attempt. + unsafe { + let _ = closesocket(socket_handle); + } + current = address.ai_next; + } + if last_error == WSAETIMEDOUT { + Err(BridgeFailure::system( + ErrorCode::Connect, + last_error, + "The TCP connection exceeded its total deadline.", + )) + } else { + Err(BridgeFailure::system_formatted( + ErrorCode::Connect, + last_error, + format_args!("The TCP connection failed with Winsock error {last_error}."), + )) + } +} + +fn retain_cancelled_query(mut query_storage: Vec) { + if !pin_current_module_for_dns_cleanup() { + debug_assert!(cancelled_cleanup_is_unload_safe(false, false)); + // The cancelled query remains OS-owned. Leaking it is unload-safe; running Rust cleanup + // code from an unpinned DLL would not be. + std::mem::forget(query_storage); + return; + } + debug_assert!(cancelled_cleanup_is_unload_safe(true, true)); + let raw = query_storage.as_mut_ptr() as usize; + let length = query_storage.len(); + let capacity = query_storage.capacity(); + std::mem::forget(query_storage); + let spawned = std::thread::Builder::new().spawn(move || { + // SAFETY: these parts came from the forgotten one-element query vector and this is + // the sole reconstruction. + let query_storage = unsafe { Vec::from_raw_parts(raw as *mut DnsQuery, length, capacity) }; + let query = &query_storage[0]; + // SAFETY: the event remains live in query until completion. + let completed = unsafe { WaitForSingleObject(query.overlapped.hEvent, INFINITE) }; + if completed == WAIT_OBJECT_0 { + // SAFETY: the completion event is signaled and the overlapped structure remains live. + unsafe { + let _ = GetAddrInfoExOverlappedResult(&raw const query.overlapped); + } + drop(query_storage); + } else { + std::mem::forget(query_storage); + } + }); + if spawned.is_err() { + // The raw query and extra Winsock reference are intentionally retained rather than + // freeing memory still referenced by an in-flight system call. The DLL is already pinned, + // so either disposition is unload-safe. + } +} + +const fn cancelled_cleanup_is_unload_safe(module_pinned: bool, detached_thread: bool) -> bool { + !detached_thread || module_pinned +} + +fn pin_current_module_for_dns_cleanup() -> bool { + let mut module: HMODULE = null_mut(); + // SAFETY: FROM_ADDRESS interprets this function address as an address inside the containing + // module. PIN makes the rare timeout cleanup thread safe until process termination. + unsafe { + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + (pin_current_module_for_dns_cleanup as *const ()).cast::(), + &raw mut module, + ) != 0 + && !module.is_null() + } +} + +fn wait_connect(socket_handle: SOCKET, deadline: Deadline) -> Result<(), i32> { + let Some(remaining) = deadline.remaining_ms() else { + return Err(WSAETIMEDOUT); + }; + let mut write_set = fd_set(socket_handle); + let mut error_set = fd_set(socket_handle); + let timeout = timeval(remaining); + // SAFETY: all fd sets and timeout storage remain live for select. + let selected = unsafe { + select( + 0, + null_mut(), + &raw mut write_set, + &raw mut error_set, + &raw const timeout, + ) + }; + if deadline.remaining_ms().is_none() { + return Err(WSAETIMEDOUT); + } + if selected == 0 { + return Err(WSAETIMEDOUT); + } + if selected == SOCKET_ERROR || error_set.fd_count != 0 { + return Err(socket_error(socket_handle)); + } + if write_set.fd_count == 0 { + return Err(WSAETIMEDOUT); + } + let error = socket_error(socket_handle); + if error == 0 { Ok(()) } else { Err(error) } +} + +fn socket_error(socket_handle: SOCKET) -> i32 { + let mut error = 0_i32; + let mut size = size_of::() as i32; + // SAFETY: socket is live and the output pointers are writable. + if unsafe { + getsockopt( + socket_handle, + SOL_SOCKET, + SO_ERROR, + (&raw mut error).cast::(), + &raw mut size, + ) + } == 0 + { + error + } else { + // SAFETY: WSAGetLastError has no preconditions. + unsafe { WSAGetLastError() } + } +} + +fn fd_set(socket_handle: SOCKET) -> FD_SET { + let mut fd_array = [0; 64]; + fd_array[0] = socket_handle; + FD_SET { + fd_count: 1, + fd_array, + } +} + +fn timeval(milliseconds: u32) -> TIMEVAL { + TIMEVAL { + tv_sec: (milliseconds / 1_000) as i32, + tv_usec: ((milliseconds % 1_000) * 1_000) as i32, + } +} + +fn try_utf16_z(value: &str) -> Result, BridgeFailure> { + let length = value + .encode_utf16() + .count() + .checked_add(1) + .ok_or_else(BridgeFailure::memory)?; + let mut output = Vec::new(); + output + .try_reserve_exact(length) + .map_err(|_| BridgeFailure::memory())?; + output.extend(value.encode_utf16()); + output.push(0); + Ok(output) +} + +fn try_cstring(value: &str) -> Result { + let length = value + .len() + .checked_add(1) + .ok_or_else(BridgeFailure::memory)?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(length) + .map_err(|_| BridgeFailure::memory())?; + bytes.extend_from_slice(value.as_bytes()); + bytes.push(0); + // SAFETY: callers reject interior NUL bytes and this helper appends exactly one terminator. + Ok(unsafe { CString::from_vec_with_nul_unchecked(bytes) }) +} + +fn write_decimal_utf16(mut value: u16, output: &mut [u16; 6]) { + let mut reversed = [0_u16; 5]; + let mut length = 0; + loop { + reversed[length] = u16::from(b'0') + value % 10; + length += 1; + value /= 10; + if value == 0 { + break; + } + } + for (destination, source) in output[..length] + .iter_mut() + .zip(reversed[..length].iter().rev()) + { + *destination = *source; + } +} + +pub fn establish_tls( + state: &mut OpenSslState, + socket: &Socket, + request: &ValidatedRequest, + deadline: Deadline, +) -> Result<(), BridgeFailure> { + // SAFETY: state owns a live SSL_CTX. + state.ssl = unsafe { SSL_new(state.ssl_context()) }; + if state.ssl.is_null() { + return Err(openssl_failure( + ErrorCode::Tls, + "Establishing the verified TLS connection", + )); + } + configure_peer_name(state.ssl, &request.host)?; + // SAFETY: SSL and socket are live. This mirrors OpenSSL's Windows socket descriptor ABI. + if unsafe { SSL_set_fd(state.ssl, socket.raw() as i32) } != 1 { + return Err(openssl_failure( + ErrorCode::Tls, + "Associating the TLS connection socket", + )); + } + ssl_connect_deadline(state.ssl, socket.raw(), deadline)?; + // SAFETY: SSL handshake completed. + let verification = unsafe { SSL_get_verify_result(state.ssl) }; + if verification != X509_V_OK { + return Err(BridgeFailure::formatted( + ErrorCode::Tls, + format_args!("The server certificate verification result was {verification}."), + )); + } + Ok(()) +} + +fn configure_peer_name(ssl: *mut SSL, host: &str) -> Result<(), BridgeFailure> { + if host.as_bytes().contains(&0) { + return Err(BridgeFailure::new( + ErrorCode::Tls, + "The peer host contains a NUL character.", + )); + } + let host = try_cstring(host)?; + if host.to_bytes().is_empty() { + return Err(BridgeFailure::new( + ErrorCode::Tls, + "The peer host is empty.", + )); + } + if host + .to_str() + .ok() + .and_then(|value| value.parse::().ok()) + .is_some() + { + // SAFETY: SSL and its verification parameter are live; host is NUL-terminated. + if unsafe { X509_VERIFY_PARAM_set1_ip_asc(SSL_get0_param(ssl), host.as_ptr()) } != 1 { + return Err(openssl_failure( + ErrorCode::Tls, + "Configuring IP certificate validation", + )); + } + } else { + // SAFETY: SSL is live and host is NUL-terminated for the immediate SNI call. + if !unsafe { SSL_set_tlsext_host_name(ssl, host.as_ptr()) } { + return Err(openssl_failure( + ErrorCode::Tls, + "Configuring hostname validation", + )); + } + // SAFETY: SSL and its verification parameter are live. The flag is installed before + // SSL_set1_host so partial-label wildcards are never accepted for this connection. + let parameter = unsafe { SSL_get0_param(ssl) }; + if parameter.is_null() { + return Err(openssl_failure( + ErrorCode::Tls, + "Configuring hostname validation", + )); + } + // SAFETY: parameter is the live verification parameter owned by SSL. + unsafe { + X509_VERIFY_PARAM_set_hostflags(parameter, X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS); + } + // SAFETY: SSL is live and host is NUL-terminated for the immediate validation call. + if unsafe { SSL_set1_host(ssl, host.as_ptr()) } != 1 { + return Err(openssl_failure( + ErrorCode::Tls, + "Configuring hostname validation", + )); + } + } + Ok(()) +} + +fn ssl_connect_deadline( + ssl: *mut SSL, + socket: SOCKET, + deadline: Deadline, +) -> Result<(), BridgeFailure> { + loop { + deadline.ensure(ErrorCode::Tls, "Establishing the verified TLS connection")?; + // A synchronous provider/KSP operation invoked inside OpenSSL cannot be preempted. The + // total deadline is therefore enforced immediately before and after the OpenSSL call. + // SAFETY: SSL is live and associated with a nonblocking socket. + let result = unsafe { SSL_connect(ssl) }; + deadline.ensure(ErrorCode::Tls, "Establishing the verified TLS connection")?; + if result == 1 { + return Ok(()); + } + ssl_wait_for_retry( + ssl, + result, + socket, + deadline, + ErrorCode::Tls, + "Establishing the verified TLS connection", + )?; + } +} + +pub fn ssl_write_all( + ssl: *mut SSL, + socket: SOCKET, + mut data: &[u8], + deadline: Deadline, +) -> Result<(), BridgeFailure> { + while !data.is_empty() { + deadline.ensure(ErrorCode::Io, "Writing the HTTPS request")?; + let mut written = 0_usize; + // SAFETY: SSL is live and data is readable for its full length. + let result = unsafe { + SSL_write_ex( + ssl, + data.as_ptr().cast::(), + data.len(), + &raw mut written, + ) + }; + deadline.ensure(ErrorCode::Io, "Writing the HTTPS request")?; + if result != 1 { + ssl_wait_for_retry( + ssl, + result, + socket, + deadline, + ErrorCode::Io, + "Writing the HTTPS request", + )?; + continue; + } + if written == 0 { + return Err(BridgeFailure::new( + ErrorCode::Io, + "Writing the HTTPS request made no progress.", + )); + } + data = &data[written..]; + } + Ok(()) +} + +pub fn ssl_read_response( + ssl: *mut SSL, + socket: SOCKET, + method: &str, + deadline: Deadline, +) -> Result>, BridgeFailure> { + let mut bytes = Vec::new(); + // Reserve the full bound once so response plaintext is never copied through a reallocation + // that cannot be zeroized after the allocator releases the old buffer. + bytes + .try_reserve_exact(MAX_RESPONSE_BYTES) + .map_err(|_| BridgeFailure::memory())?; + let mut buffer = Zeroizing::new(bytes); + let mut progress = ResponseProgress::default(); + loop { + if progress.is_complete(buffer.len()) { + return Ok(buffer); + } + let remaining_wire_bytes = progress.remaining_wire_bytes()?; + let mut available = (buffer.capacity() - buffer.len()).min(remaining_wire_bytes); + if progress.framing_known { + available = available.min(progress.expected_length - buffer.len()); + } + let old_len = buffer.len(); + // SAFETY: spare capacity is writable; set_len occurs only for the initialized bytes + // reported by OpenSSL. + let destination = unsafe { buffer.as_mut_ptr().add(old_len) }; + let mut received = 0_usize; + deadline.ensure(ErrorCode::Io, "Reading the HTTPS response")?; + // SAFETY: SSL is live and destination has `available` writable spare bytes. + let result = unsafe { + SSL_read_ex( + ssl, + destination.cast::(), + available, + &raw mut received, + ) + }; + deadline.ensure(ErrorCode::Io, "Reading the HTTPS response")?; + if result == 1 { + if received == 0 { + return Err(BridgeFailure::new( + ErrorCode::Io, + "Reading the HTTPS response made no progress.", + )); + } + // SAFETY: OpenSSL initialized exactly `received` bytes in spare capacity. + unsafe { buffer.set_len(old_len + received) }; + progress.record_received(&mut buffer, method, received)?; + continue; + } + ssl_wait_for_retry( + ssl, + result, + socket, + deadline, + ErrorCode::Io, + "Reading the HTTPS response", + )?; + } +} + +#[derive(Debug, Default)] +struct ResponseProgress { + expected_length: usize, + framing_known: bool, + wire_bytes: usize, + informational_responses: usize, +} + +impl ResponseProgress { + fn is_complete(&self, buffer_length: usize) -> bool { + self.framing_known && buffer_length == self.expected_length + } + + fn remaining_wire_bytes(&self) -> Result { + MAX_RESPONSE_BYTES + .checked_sub(self.wire_bytes) + .filter(|remaining| *remaining != 0) + .ok_or_else(|| { + BridgeFailure::new(ErrorCode::Limit, "The HTTPS response exceeds 1 MiB.") + }) + } + + fn record_received( + &mut self, + buffer: &mut Vec, + method: &str, + received: usize, + ) -> Result<(), BridgeFailure> { + self.wire_bytes = self + .wire_bytes + .checked_add(received) + .filter(|total| *total <= MAX_RESPONSE_BYTES) + .ok_or_else(|| { + BridgeFailure::new(ErrorCode::Limit, "The HTTPS response exceeds 1 MiB.") + })?; + while !self.framing_known && find_bytes(buffer, b"\r\n\r\n").is_some() { + let framing = inspect_http_response(buffer, method)?; + if framing.status / 100 == 1 && framing.status != 101 { + if self.informational_responses == MAX_INFORMATIONAL_RESPONSES { + return Err(BridgeFailure::new( + ErrorCode::Limit, + "The HTTPS response contains too many informational responses.", + )); + } + self.informational_responses += 1; + buffer.drain(..framing.expected_length); + continue; + } + let informational_bytes = self + .wire_bytes + .checked_sub(buffer.len()) + .expect("drained response bytes cannot exceed received bytes"); + if framing.expected_length > MAX_RESPONSE_BYTES.saturating_sub(informational_bytes) { + return Err(BridgeFailure::new( + ErrorCode::Limit, + "The HTTPS response exceeds 1 MiB.", + )); + } + self.expected_length = framing.expected_length; + self.framing_known = true; + if buffer.len() > self.expected_length { + return Err(BridgeFailure::new( + ErrorCode::Http, + "The HTTP response contains bytes beyond its declared framing.", + )); + } + } + if !self.framing_known && buffer.len() > MAX_HEADER_BYTES + 3 { + return Err(BridgeFailure::new( + ErrorCode::Http, + "The HTTP response header is too large.", + )); + } + Ok(()) + } +} + +pub fn ssl_expect_clean_end( + ssl: *mut SSL, + socket: SOCKET, + deadline: Deadline, +) -> Result<(), BridgeFailure> { + let mut unexpected = Zeroizing::new([0_u8; 1]); + loop { + let mut received = 0_usize; + deadline.ensure(ErrorCode::Io, "Waiting for the TLS response shutdown")?; + // SAFETY: SSL is live and unexpected is writable for one byte. + let result = unsafe { + SSL_read_ex( + ssl, + unexpected.as_mut_ptr().cast(), + unexpected.len(), + &raw mut received, + ) + }; + deadline.ensure(ErrorCode::Io, "Waiting for the TLS response shutdown")?; + if result == 1 { + return Err(BridgeFailure::new( + ErrorCode::Http, + "The HTTP response contains bytes beyond its declared framing.", + )); + } + // SAFETY: SSL is live and result came from SSL_read_ex. + let error = unsafe { SSL_get_error(ssl, result) }; + match error { + SSL_ERROR_ZERO_RETURN => return Ok(()), + SSL_ERROR_WANT_READ => wait_for_socket( + socket, + true, + deadline, + ErrorCode::Io, + "Waiting for the TLS response shutdown", + )?, + SSL_ERROR_WANT_WRITE => wait_for_socket( + socket, + false, + deadline, + ErrorCode::Io, + "Waiting for the TLS response shutdown", + )?, + _ => { + return ssl_wait_for_retry( + ssl, + result, + socket, + deadline, + ErrorCode::Io, + "Waiting for the TLS response shutdown", + ); + } + } + } +} + +pub fn ssl_shutdown_deadline( + ssl: *mut SSL, + socket: SOCKET, + deadline: Deadline, +) -> Result<(), BridgeFailure> { + let mut sent_close_notify = false; + loop { + deadline.ensure(ErrorCode::Io, "Shutting down the TLS connection")?; + // SAFETY: SSL is live. + let result = unsafe { SSL_shutdown(ssl) }; + deadline.ensure(ErrorCode::Io, "Shutting down the TLS connection")?; + if result == 1 { + return Ok(()); + } + if result == 0 && !sent_close_notify { + sent_close_notify = true; + continue; + } + ssl_wait_for_retry( + ssl, + result, + socket, + deadline, + ErrorCode::Io, + "Shutting down the TLS connection", + )?; + } +} + +fn ssl_wait_for_retry( + ssl: *mut SSL, + ssl_result: i32, + socket: SOCKET, + deadline: Deadline, + code: ErrorCode, + operation: &str, +) -> Result<(), BridgeFailure> { + // SAFETY: SSL is live and ssl_result came from an SSL operation. + match unsafe { SSL_get_error(ssl, ssl_result) } { + SSL_ERROR_WANT_READ => wait_for_socket(socket, true, deadline, code, operation), + SSL_ERROR_WANT_WRITE => wait_for_socket(socket, false, deadline, code, operation), + SSL_ERROR_ZERO_RETURN => Err(BridgeFailure::formatted( + code, + format_args!("{operation} ended with a TLS close before completion."), + )), + SSL_ERROR_SYSCALL => { + let system_error = if ssl_result == 0 { + 0 + } else { + // SAFETY: WSAGetLastError has no preconditions. + unsafe { WSAGetLastError() } + }; + if system_error == 0 { + Err(BridgeFailure::system_formatted( + code, + system_error, + format_args!("{operation} encountered an unexpected TLS EOF."), + )) + } else { + Err(BridgeFailure::system_formatted( + code, + system_error, + format_args!("{operation} failed with Winsock error {system_error}."), + )) + } + } + _ => Err(openssl_failure(code, operation)), + } +} + +fn wait_for_socket( + socket: SOCKET, + read: bool, + deadline: Deadline, + code: ErrorCode, + operation: &str, +) -> Result<(), BridgeFailure> { + let Some(remaining) = deadline.remaining_ms() else { + return Err(BridgeFailure::system_formatted( + code, + WSAETIMEDOUT, + format_args!("{operation} exceeded the total I/O deadline."), + )); + }; + let mut read_set = fd_set(socket); + let mut write_set = fd_set(socket); + let mut error_set = fd_set(socket); + let timeout = timeval(remaining); + // SAFETY: selected fd sets and timeout remain live for the call. + let selected = unsafe { + select( + 0, + if read { &raw mut read_set } else { null_mut() }, + if read { null_mut() } else { &raw mut write_set }, + &raw mut error_set, + &raw const timeout, + ) + }; + deadline.ensure(code, operation)?; + if selected == 0 { + return Err(BridgeFailure::system_formatted( + code, + WSAETIMEDOUT, + format_args!("{operation} exceeded the total I/O deadline."), + )); + } + if selected == SOCKET_ERROR || error_set.fd_count != 0 { + let error = socket_error(socket); + return Err(BridgeFailure::system_formatted( + code, + error, + format_args!("{operation} failed with Winsock error {error}."), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use openssl_provider_sys::{ + MBSTRING_ASC, OSSL_LIB_CTX, OSSL_LIB_CTX_free, OSSL_LIB_CTX_new, OSSL_PROVIDER, + OSSL_PROVIDER_load, OSSL_PROVIDER_unload, SSL_CTX_free, SSL_CTX_new_ex, SSL_free, + TLS_client_method, X509, X509_NAME_add_entry_by_txt, X509_VERIFY_PARAM_get_hostflags, + X509_VERIFY_PARAM_get0_host, X509_check_host, X509_free, X509_get_subject_name, + X509_new_ex, + }; + + struct TestSsl { + libctx: *mut OSSL_LIB_CTX, + provider: *mut OSSL_PROVIDER, + context: *mut openssl_provider_sys::SSL_CTX, + ssl: *mut SSL, + } + + impl TestSsl { + fn new() -> Self { + // SAFETY: creates an owned private OpenSSL library context. + let libctx = unsafe { OSSL_LIB_CTX_new() }; + assert!(!libctx.is_null()); + // SAFETY: the private context is live and the provider name is static. + let provider = unsafe { OSSL_PROVIDER_load(libctx, c"default".as_ptr()) }; + assert!(!provider.is_null()); + // SAFETY: the private context and TLS method are live. + let context = unsafe { SSL_CTX_new_ex(libctx, null(), TLS_client_method()) }; + assert!(!context.is_null()); + // SAFETY: context is live. + let ssl = unsafe { SSL_new(context) }; + assert!(!ssl.is_null()); + Self { + libctx, + provider, + context, + ssl, + } + } + + fn certificate_with_common_name(&self, common_name: &str) -> *mut X509 { + // SAFETY: creates an owned X509 associated with this private context. + let certificate = unsafe { X509_new_ex(self.libctx, null()) }; + assert!(!certificate.is_null()); + // SAFETY: certificate is live. + let subject = unsafe { X509_get_subject_name(certificate) }; + assert!(!subject.is_null()); + // SAFETY: subject and the ASCII common-name bytes remain live for the call. + let added = unsafe { + X509_NAME_add_entry_by_txt( + subject, + c"CN".as_ptr(), + MBSTRING_ASC, + common_name.as_ptr(), + common_name.len() as i32, + -1, + 0, + ) + }; + assert_eq!(added, 1); + certificate + } + } + + impl Drop for TestSsl { + fn drop(&mut self) { + // SAFETY: each OpenSSL handle is owned by this test fixture. + unsafe { + SSL_free(self.ssl); + SSL_CTX_free(self.context); + let _ = OSSL_PROVIDER_unload(self.provider); + OSSL_LIB_CTX_free(self.libctx); + } + } + } + + fn check_host(certificate: *mut X509, host: &str, flags: u32) -> i32 { + let host = CString::new(host).expect("host CString"); + // SAFETY: certificate and host are live; no peer-name allocation is requested. + unsafe { + X509_check_host( + certificate, + host.as_ptr(), + host.as_bytes().len(), + flags, + null_mut(), + ) + } + } + + fn process_wire_response( + wire: &[u8], + ) -> Result<(ResponseProgress, Zeroizing>), BridgeFailure> { + let mut buffer = Zeroizing::new(wire.to_vec()); + let mut progress = ResponseProgress::default(); + progress.record_received(&mut buffer, "GET", wire.len())?; + Ok((progress, buffer)) + } + + #[test] + fn detached_dns_cleanup_requires_a_pinned_module() { + assert!(cancelled_cleanup_is_unload_safe(false, false)); + assert!(cancelled_cleanup_is_unload_safe(true, false)); + assert!(cancelled_cleanup_is_unload_safe(true, true)); + assert!(!cancelled_cleanup_is_unload_safe(false, true)); + assert!(pin_current_module_for_dns_cleanup()); + } + + #[test] + fn decimal_port_encoding_is_nul_terminated() { + let mut output = [0_u16; 6]; + write_decimal_utf16(65_535, &mut output); + assert_eq!( + &output, + &[ + u16::from(b'6'), + u16::from(b'5'), + u16::from(b'5'), + u16::from(b'3'), + u16::from(b'5'), + 0 + ] + ); + } + + #[test] + fn dns_peer_names_disable_partial_wildcards_but_keep_openssl_label_policy() { + let tls = TestSsl::new(); + configure_peer_name(tls.ssl, "www.example.com").expect("configure DNS peer name"); + // SAFETY: SSL owns a live verification parameter. + let parameter = unsafe { SSL_get0_param(tls.ssl) }; + assert!(!parameter.is_null()); + // SAFETY: parameter is live. + let flags = unsafe { X509_VERIFY_PARAM_get_hostflags(parameter) }; + assert_ne!(flags & X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS, 0); + + let partial = tls.certificate_with_common_name("w*.example.com"); + assert_eq!(check_host(partial, "www.example.com", 0), 1); + assert_eq!(check_host(partial, "www.example.com", flags), 0); + // SAFETY: certificate is owned by this test. + unsafe { X509_free(partial) }; + + let full_label = tls.certificate_with_common_name("*.example.com"); + assert_eq!(check_host(full_label, "www.example.com", flags), 1); + assert_eq!(check_host(full_label, "deep.www.example.com", flags), 0); + // SAFETY: certificate is owned by this test. + unsafe { X509_free(full_label) }; + } + + #[test] + fn ipv4_peer_names_keep_binary_ip_verification() { + let tls = TestSsl::new(); + configure_peer_name(tls.ssl, "127.0.0.1").expect("configure IPv4 peer name"); + // SAFETY: SSL owns a live verification parameter. + let parameter = unsafe { SSL_get0_param(tls.ssl) }; + assert!(!parameter.is_null()); + // SAFETY: parameter is live. IP verification must not be converted into a DNS host entry. + assert!(unsafe { X509_VERIFY_PARAM_get0_host(parameter, 0) }.is_null()); + // SAFETY: parameter is live. + let flags = unsafe { X509_VERIFY_PARAM_get_hostflags(parameter) }; + assert_eq!(flags & X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS, 0); + } + + #[test] + fn informational_response_count_is_bounded() { + let mut wire = Vec::new(); + for _ in 0..=MAX_INFORMATIONAL_RESPONSES { + wire.extend_from_slice(b"HTTP/1.1 100 Continue\r\n\r\n"); + } + wire.extend_from_slice(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"); + assert_eq!( + process_wire_response(&wire) + .expect_err("too many informational responses") + .code, + ErrorCode::Limit + ); + } + + #[test] + fn informational_response_bytes_count_toward_the_total_limit() { + let mut wire = Vec::new(); + let padding = vec![b'a'; 16_000]; + for _ in 0..MAX_INFORMATIONAL_RESPONSES { + wire.extend_from_slice(b"HTTP/1.1 100 Continue\r\nX-Pad: "); + wire.extend_from_slice(&padding); + wire.extend_from_slice(b"\r\n\r\n"); + } + wire.extend_from_slice(b"HTTP/1.1 200 OK\r\nContent-Length: 900000\r\n\r\n"); + assert_eq!( + process_wire_response(&wire) + .expect_err("cumulative informational response bytes") + .code, + ErrorCode::Limit + ); + } + + #[test] + fn cumulative_response_limit_accepts_the_exact_boundary() { + let informational = b"HTTP/1.1 100 Continue\r\n\r\n"; + let mut body_length = MAX_RESPONSE_BYTES; + let final_header = loop { + let header = + format!("HTTP/1.1 200 OK\r\nContent-Length: {body_length}\r\n\r\n").into_bytes(); + let adjusted = MAX_RESPONSE_BYTES - informational.len() - header.len(); + if adjusted == body_length { + break header; + } + body_length = adjusted; + }; + let mut wire = Vec::with_capacity(MAX_RESPONSE_BYTES); + wire.extend_from_slice(informational); + wire.extend_from_slice(&final_header); + wire.resize(MAX_RESPONSE_BYTES, b'x'); + + let (progress, final_response) = + process_wire_response(&wire).expect("exact cumulative boundary"); + assert!(progress.is_complete(final_response.len())); + assert_eq!(progress.wire_bytes, MAX_RESPONSE_BYTES); + assert_eq!( + crate::http::parse_http_response(&final_response, "GET") + .expect("final boundary response") + .body + .len(), + body_length + ); + } + + #[test] + fn one_continue_then_final_response_is_returned_normally() { + let wire = b"HTTP/1.1 100 Continue\r\n\r\n\ +HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"; + let (progress, final_response) = + process_wire_response(wire).expect("normal informational response"); + assert!(progress.is_complete(final_response.len())); + assert_eq!(progress.informational_responses, 1); + let parsed = + crate::http::parse_http_response(&final_response, "GET").expect("final response"); + assert_eq!(parsed.status, 200); + assert_eq!(parsed.body.as_slice(), b"hello"); + } +} diff --git a/native/crates/openssl-provider-sys/Cargo.toml b/native/crates/openssl-provider-sys/Cargo.toml new file mode 100644 index 00000000..a402f3b0 --- /dev/null +++ b/native/crates/openssl-provider-sys/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "openssl-provider-sys" +version.workspace = true +description = "Minimal OpenSSL provider and TLS FFI required by Microsoft.Identity.Native.Primitives" +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[dependencies] +libc.workspace = true +openssl-sys.workspace = true +static_assertions.workspace = true + +[lints] +workspace = true diff --git a/native/crates/openssl-provider-sys/src/lib.rs b/native/crates/openssl-provider-sys/src/lib.rs new file mode 100644 index 00000000..0f879068 --- /dev/null +++ b/native/crates/openssl-provider-sys/src/lib.rs @@ -0,0 +1,354 @@ +#![allow( + hidden_glob_reexports, + non_camel_case_types, + non_snake_case, + non_upper_case_globals +)] + +use std::ffi::{c_char, c_int, c_long, c_uint, c_ulong, c_void}; +use std::ptr::{null, null_mut}; + +pub use openssl_sys::*; +use static_assertions::const_assert_eq; + +pub enum OsslCoreHandle {} +pub enum OsslStoreCtx {} +pub enum OsslStoreInfo {} +pub enum UiMethod {} +pub enum EvpSignature {} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct OsslDispatch { + pub function_id: c_int, + pub function: *const c_void, +} + +// SAFETY: dispatch tables are immutable process-lifetime arrays of function addresses. +unsafe impl Sync for OsslDispatch {} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct OsslItem { + pub id: c_uint, + pub ptr: *mut c_void, +} + +// SAFETY: reason-string tables are immutable process-lifetime arrays. +unsafe impl Sync for OsslItem {} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct OsslAlgorithm { + pub algorithm_names: *const c_char, + pub property_definition: *const c_char, + pub implementation: *const OsslDispatch, + pub algorithm_description: *const c_char, +} + +// SAFETY: algorithm tables point only at immutable process-lifetime strings and dispatch arrays. +unsafe impl Sync for OsslAlgorithm {} + +pub type OsslCallback = + Option c_int>; +pub type OsslProviderInitFn = unsafe extern "C" fn( + handle: *const OsslCoreHandle, + input: *const OsslDispatch, + output: *mut *const OsslDispatch, + provider_context: *mut *mut c_void, +) -> c_int; + +const_assert_eq!(size_of::(), 16); +const_assert_eq!(align_of::(), 8); +const_assert_eq!(size_of::(), 16); +const_assert_eq!(size_of::(), 32); +const_assert_eq!(size_of::(), 40); + +pub const OSSL_PARAM_INTEGER: c_uint = 1; +pub const OSSL_PARAM_UNSIGNED_INTEGER: c_uint = 2; +pub const OSSL_PARAM_UTF8_STRING: c_uint = 4; +pub const OSSL_PARAM_OCTET_STRING: c_uint = 5; +pub const OSSL_PARAM_UTF8_PTR: c_uint = 6; + +pub const OSSL_OP_KEYMGMT: c_int = 10; +pub const OSSL_OP_SIGNATURE: c_int = 12; +pub const OSSL_OP_STORE: c_int = 22; + +pub const OSSL_KEYMGMT_SELECT_PRIVATE_KEY: c_int = 0x01; +pub const OSSL_KEYMGMT_SELECT_PUBLIC_KEY: c_int = 0x02; +pub const OSSL_KEYMGMT_SELECT_KEYPAIR: c_int = + OSSL_KEYMGMT_SELECT_PRIVATE_KEY | OSSL_KEYMGMT_SELECT_PUBLIC_KEY; +pub const OSSL_KEYMGMT_VALIDATE_FULL_CHECK: c_int = 0; +pub const OSSL_KEYMGMT_VALIDATE_QUICK_CHECK: c_int = 1; + +pub const OSSL_OBJECT_PKEY: c_int = 2; +pub const OSSL_STORE_INFO_PUBKEY: c_int = 3; +pub const OSSL_STORE_INFO_PKEY: c_int = 4; + +pub const OSSL_FUNC_CORE_NEW_ERROR: c_int = 5; +pub const OSSL_FUNC_CORE_SET_ERROR_DEBUG: c_int = 6; +pub const OSSL_FUNC_CORE_VSET_ERROR: c_int = 7; + +pub const OSSL_FUNC_PROVIDER_TEARDOWN: c_int = 1024; +pub const OSSL_FUNC_PROVIDER_GETTABLE_PARAMS: c_int = 1025; +pub const OSSL_FUNC_PROVIDER_GET_PARAMS: c_int = 1026; +pub const OSSL_FUNC_PROVIDER_QUERY_OPERATION: c_int = 1027; +pub const OSSL_FUNC_PROVIDER_GET_REASON_STRINGS: c_int = 1029; + +pub const OSSL_FUNC_KEYMGMT_NEW: c_int = 1; +pub const OSSL_FUNC_KEYMGMT_LOAD: c_int = 8; +pub const OSSL_FUNC_KEYMGMT_FREE: c_int = 10; +pub const OSSL_FUNC_KEYMGMT_GET_PARAMS: c_int = 11; +pub const OSSL_FUNC_KEYMGMT_GETTABLE_PARAMS: c_int = 12; +pub const OSSL_FUNC_KEYMGMT_QUERY_OPERATION_NAME: c_int = 20; +pub const OSSL_FUNC_KEYMGMT_HAS: c_int = 21; +pub const OSSL_FUNC_KEYMGMT_VALIDATE: c_int = 22; +pub const OSSL_FUNC_KEYMGMT_MATCH: c_int = 23; +pub const OSSL_FUNC_KEYMGMT_IMPORT: c_int = 40; +pub const OSSL_FUNC_KEYMGMT_IMPORT_TYPES: c_int = 41; +pub const OSSL_FUNC_KEYMGMT_EXPORT: c_int = 42; +pub const OSSL_FUNC_KEYMGMT_EXPORT_TYPES: c_int = 43; +pub const OSSL_FUNC_KEYMGMT_DUP: c_int = 44; + +pub const OSSL_FUNC_SIGNATURE_NEWCTX: c_int = 1; +pub const OSSL_FUNC_SIGNATURE_DIGEST_SIGN_INIT: c_int = 8; +pub const OSSL_FUNC_SIGNATURE_DIGEST_SIGN_UPDATE: c_int = 9; +pub const OSSL_FUNC_SIGNATURE_DIGEST_SIGN_FINAL: c_int = 10; +pub const OSSL_FUNC_SIGNATURE_DIGEST_SIGN: c_int = 11; +pub const OSSL_FUNC_SIGNATURE_FREECTX: c_int = 16; +pub const OSSL_FUNC_SIGNATURE_DUPCTX: c_int = 17; +pub const OSSL_FUNC_SIGNATURE_GET_CTX_PARAMS: c_int = 18; +pub const OSSL_FUNC_SIGNATURE_GETTABLE_CTX_PARAMS: c_int = 19; +pub const OSSL_FUNC_SIGNATURE_SET_CTX_PARAMS: c_int = 20; +pub const OSSL_FUNC_SIGNATURE_SETTABLE_CTX_PARAMS: c_int = 21; + +pub const OSSL_FUNC_STORE_OPEN: c_int = 1; +pub const OSSL_FUNC_STORE_SETTABLE_CTX_PARAMS: c_int = 3; +pub const OSSL_FUNC_STORE_SET_CTX_PARAMS: c_int = 4; +pub const OSSL_FUNC_STORE_LOAD: c_int = 5; +pub const OSSL_FUNC_STORE_EOF: c_int = 6; +pub const OSSL_FUNC_STORE_CLOSE: c_int = 7; +pub const OSSL_FUNC_STORE_EXPORT_OBJECT: c_int = 8; + +pub const RSA_PKCS1_PADDING: c_int = 1; +pub const RSA_PKCS1_PSS_PADDING: c_int = 6; +pub const RSA_PSS_SALTLEN_DIGEST: c_int = -1; +pub const RSA_PSS_SALTLEN_AUTO: c_int = -2; +pub const RSA_PSS_SALTLEN_MAX: c_int = -3; +pub const RSA_PSS_SALTLEN_AUTO_DIGEST_MAX: c_int = -4; + +pub const X509_CHECK_FLAG_NO_PARTIAL_WILDCARDS: c_uint = 0x4; +pub const MBSTRING_ASC: c_int = 0x1001; + +pub const ERR_LIB_USER: c_int = 128; + +#[must_use] +pub const fn param_end() -> OSSL_PARAM { + OSSL_PARAM { + key: null(), + data_type: 0, + data: null_mut(), + data_size: 0, + return_size: 0, + } +} + +#[must_use] +pub const fn param_definition(key: *const c_char, data_type: c_uint) -> OSSL_PARAM { + OSSL_PARAM { + key, + data_type, + data: null_mut(), + data_size: 0, + return_size: 0, + } +} + +#[must_use] +pub const fn param_int(key: *const c_char, value: *mut c_int) -> OSSL_PARAM { + OSSL_PARAM { + key, + data_type: OSSL_PARAM_INTEGER, + data: value.cast(), + data_size: size_of::(), + return_size: usize::MAX, + } +} + +#[must_use] +pub const fn param_utf8_string( + key: *const c_char, + value: *mut c_char, + length: usize, +) -> OSSL_PARAM { + OSSL_PARAM { + key, + data_type: OSSL_PARAM_UTF8_STRING, + data: value.cast(), + data_size: length, + return_size: usize::MAX, + } +} + +#[must_use] +pub const fn param_octet_string( + key: *const c_char, + value: *mut c_void, + length: usize, +) -> OSSL_PARAM { + OSSL_PARAM { + key, + data_type: OSSL_PARAM_OCTET_STRING, + data: value, + data_size: length, + return_size: usize::MAX, + } +} + +unsafe extern "C" { + pub fn OSSL_PROVIDER_add_builtin( + context: *mut OSSL_LIB_CTX, + name: *const c_char, + init: Option, + ) -> c_int; + + pub fn OSSL_STORE_open_ex( + uri: *const c_char, + context: *mut OSSL_LIB_CTX, + properties: *const c_char, + ui_method: *const UiMethod, + ui_data: *mut c_void, + params: *const OSSL_PARAM, + post_process: Option< + unsafe extern "C" fn(info: *mut OsslStoreInfo, data: *mut c_void) -> *mut OsslStoreInfo, + >, + post_process_data: *mut c_void, + ) -> *mut OsslStoreCtx; + pub fn OSSL_STORE_expect(context: *mut OsslStoreCtx, expected_type: c_int) -> c_int; + pub fn OSSL_STORE_load(context: *mut OsslStoreCtx) -> *mut OsslStoreInfo; + pub fn OSSL_STORE_eof(context: *mut OsslStoreCtx) -> c_int; + pub fn OSSL_STORE_error(context: *mut OsslStoreCtx) -> c_int; + pub fn OSSL_STORE_close(context: *mut OsslStoreCtx) -> c_int; + pub fn OSSL_STORE_INFO_get_type(info: *const OsslStoreInfo) -> c_int; + pub fn OSSL_STORE_INFO_get1_PKEY(info: *const OsslStoreInfo) -> *mut EVP_PKEY; + pub fn OSSL_STORE_INFO_free(info: *mut OsslStoreInfo); + + pub fn SSL_CTX_new_ex( + context: *mut OSSL_LIB_CTX, + properties: *const c_char, + method: *const SSL_METHOD, + ) -> *mut SSL_CTX; + pub fn SSL_set_fd(ssl: *mut SSL, descriptor: c_int) -> c_int; + pub fn SSL_set1_host(ssl: *mut SSL, host: *const c_char) -> c_int; + + pub fn BIO_free(bio: *mut BIO) -> c_int; + pub fn PEM_read_bio_X509_AUX( + bio: *mut BIO, + output: *mut *mut X509, + callback: openssl_sys::pem_password_cb, + user_data: *mut c_void, + ) -> *mut X509; + pub fn X509_new_ex(context: *mut OSSL_LIB_CTX, properties: *const c_char) -> *mut X509; + pub fn X509_get_subject_name(certificate: *const X509) -> *mut X509_NAME; + pub fn X509_NAME_add_entry_by_txt( + name: *mut X509_NAME, + field: *const c_char, + field_type: c_int, + bytes: *const u8, + length: c_int, + location: c_int, + set: c_int, + ) -> c_int; + pub fn X509_VERIFY_PARAM_set_hostflags(parameter: *mut X509_VERIFY_PARAM, flags: c_uint); + pub fn X509_VERIFY_PARAM_get_hostflags(parameter: *const X509_VERIFY_PARAM) -> c_uint; + pub fn X509_VERIFY_PARAM_set1_ip_asc( + parameter: *mut X509_VERIFY_PARAM, + address: *const c_char, + ) -> c_int; + pub fn X509_VERIFY_PARAM_get0_host( + parameter: *mut X509_VERIFY_PARAM, + index: c_int, + ) -> *mut c_char; + pub fn X509_check_host( + certificate: *mut X509, + name: *const c_char, + name_length: usize, + flags: c_uint, + peer_name: *mut *mut c_char, + ) -> c_int; + pub fn EVP_set_default_properties( + context: *mut OSSL_LIB_CTX, + properties: *const c_char, + ) -> c_int; + pub fn EVP_SIGNATURE_fetch( + context: *mut OSSL_LIB_CTX, + algorithm: *const c_char, + properties: *const c_char, + ) -> *mut EvpSignature; + pub fn EVP_SIGNATURE_gettable_ctx_params(signature: *const EvpSignature) -> *const OSSL_PARAM; + pub fn EVP_SIGNATURE_settable_ctx_params(signature: *const EvpSignature) -> *const OSSL_PARAM; + pub fn EVP_SIGNATURE_free(signature: *mut EvpSignature); + pub fn ERR_error_string_n(error: c_ulong, buffer: *mut c_char, length: usize); + pub fn OSSL_PARAM_set_BN(parameter: *mut OSSL_PARAM, value: *const BIGNUM) -> c_int; +} + +#[must_use] +pub unsafe fn SSL_CTX_set_min_proto_version(context: *mut SSL_CTX, version: c_int) -> bool { + // SAFETY: callers provide a live SSL_CTX; the control takes no pointer argument. + unsafe { + openssl_sys::SSL_CTX_ctrl( + context, + openssl_sys::SSL_CTRL_SET_MIN_PROTO_VERSION, + c_long::from(version), + null_mut(), + ) == 1 + } +} + +#[must_use] +pub unsafe fn SSL_CTX_add1_chain_cert(context: *mut SSL_CTX, certificate: *mut X509) -> bool { + // SAFETY: callers provide live objects; OpenSSL increments the certificate reference because + // the `larg` argument is one. + unsafe { + openssl_sys::SSL_CTX_ctrl( + context, + openssl_sys::SSL_CTRL_CHAIN_CERT, + 1, + certificate.cast(), + ) == 1 + } +} + +#[must_use] +pub unsafe fn SSL_set_tlsext_host_name(ssl: *mut SSL, host: *const c_char) -> bool { + // SAFETY: callers provide a live SSL and a NUL-terminated host string retained for the call. + unsafe { + openssl_sys::SSL_ctrl( + ssl, + openssl_sys::SSL_CTRL_SET_TLSEXT_HOSTNAME, + 0, + host.cast_mut().cast(), + ) == 1 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provider_ffi_layouts_match_openssl_x64() { + assert_eq!(size_of::(), 16); + assert_eq!(size_of::(), 16); + assert_eq!(size_of::(), 32); + assert_eq!(size_of::(), 40); + } + + #[test] + fn parameter_constructors_match_header_constants() { + let end = param_end(); + assert!(end.key.is_null()); + assert_eq!(OSSL_PARAM_UTF8_STRING, 4); + assert_eq!(OSSL_PARAM_UNSIGNED_INTEGER, 2); + } +} diff --git a/native/crates/rust-tests/Cargo.toml b/native/crates/rust-tests/Cargo.toml new file mode 100644 index 00000000..2a5181dd --- /dev/null +++ b/native/crates/rust-tests/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "rust-tests" +version.workspace = true +description = "Cross-crate Rust conformance tests for Microsoft.Identity.Native.Primitives" +edition.workspace = true +license.workspace = true +publish.workspace = true +rust-version.workspace = true + +[dependencies] +cng-core = { version = "0.2.0", path = "../cng-core" } +mtls-bridge = { version = "0.2.0", path = "../mtls-bridge" } +openssl-provider-sys = { version = "0.2.0", path = "../openssl-provider-sys" } + +[lints] +workspace = true diff --git a/native/crates/rust-tests/src/bin/key-audit.rs b/native/crates/rust-tests/src/bin/key-audit.rs new file mode 100644 index 00000000..548c97ff --- /dev/null +++ b/native/crates/rust-tests/src/bin/key-audit.rs @@ -0,0 +1,18 @@ +use cng_core::{DEFAULT_PROVIDER_NAME, project_test_keys}; + +fn main() { + match project_test_keys(DEFAULT_PROVIDER_NAME) { + Ok(keys) if keys.is_empty() => println!("No project-specific CNG test keys remain."), + Ok(keys) => { + eprintln!("Project-specific CNG test keys remain:"); + for key in keys { + eprintln!(" {key}"); + } + std::process::exit(1); + } + Err(error) => { + eprintln!("Unable to enumerate project-specific CNG keys: {error}"); + std::process::exit(1); + } + } +} diff --git a/native/crates/rust-tests/src/bin/key-race-helper.rs b/native/crates/rust-tests/src/bin/key-race-helper.rs new file mode 100644 index 00000000..83dc690f --- /dev/null +++ b/native/crates/rust-tests/src/bin/key-race-helper.rs @@ -0,0 +1,47 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use cng_core::{CngKey, DEFAULT_PROVIDER_NAME}; + +fn unix_time_millis() -> Result { + let elapsed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("system clock precedes Unix epoch: {error}"))?; + u64::try_from(elapsed.as_millis()).map_err(|_| "system time does not fit in u64".to_owned()) +} + +fn public_key(key: &CngKey) -> String { + key.public_modulus() + .iter() + .chain(key.public_exponent()) + .map(|value| format!("{value:02x}")) + .collect() +} + +fn run() -> Result<(), String> { + let mut arguments = std::env::args().skip(1); + let key_name = arguments + .next() + .ok_or_else(|| "missing key name".to_owned())?; + let start_millis = arguments + .next() + .ok_or_else(|| "missing start time".to_owned())? + .parse::() + .map_err(|error| format!("invalid start time: {error}"))?; + if arguments.next().is_some() { + return Err("unexpected extra argument".to_owned()); + } + + let now = unix_time_millis()?; + std::thread::sleep(Duration::from_millis(start_millis.saturating_sub(now))); + let key = CngKey::open_or_create(DEFAULT_PROVIDER_NAME, &key_name, 2_048) + .map_err(|error| error.to_string())?; + println!("{}", public_key(&key)); + Ok(()) +} + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} diff --git a/native/crates/rust-tests/src/lib.rs b/native/crates/rust-tests/src/lib.rs new file mode 100644 index 00000000..001ae146 --- /dev/null +++ b/native/crates/rust-tests/src/lib.rs @@ -0,0 +1,10 @@ +#[cfg(test)] +mod tests { + #[test] + fn workspace_smoke_test() { + assert_eq!( + microsoft_identity_native_primitives::microsoft_identity_native_abi_version(), + 1 + ); + } +} diff --git a/native/crates/rust-tests/tests/key_creation_race.rs b/native/crates/rust-tests/tests/key_creation_race.rs new file mode 100644 index 00000000..b59db0ff --- /dev/null +++ b/native/crates/rust-tests/tests/key_creation_race.rs @@ -0,0 +1,104 @@ +use std::process::{Command, Output, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use cng_core::{CngKey, DEFAULT_PROVIDER_NAME, delete_test_key, random_bytes}; + +fn owned_name() -> String { + let mut random = [0_u8; 16]; + random_bytes(&mut random).expect("random multiprocess test key suffix"); + let suffix = random + .iter() + .map(|value| format!("{value:02x}")) + .collect::(); + format!( + "openssl-cng-rust-test-process-race-{}-{suffix}", + std::process::id() + ) +} + +fn helper_output(output: Output, label: &str) -> Result { + if !output.status.success() { + return Err(format!( + "{label} failed with {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr).trim() + )); + } + let output = + String::from_utf8(output.stdout).map_err(|error| format!("{label} output: {error}"))?; + let output = output.trim(); + if output.is_empty() { + return Err(format!("{label} returned an empty public key")); + } + Ok(output.to_owned()) +} + +fn public_key(key: &CngKey) -> String { + key.public_modulus() + .iter() + .chain(key.public_exponent()) + .map(|value| format!("{value:02x}")) + .collect() +} + +fn race_key_creation(name: &str) -> Result<(), String> { + let start = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|error| format!("system clock precedes Unix epoch: {error}"))? + .as_millis() + .checked_add(2_000) + .ok_or_else(|| "start time overflow".to_owned())?; + let start = u64::try_from(start).map_err(|_| "start time does not fit in u64".to_owned())?; + let helper = env!("CARGO_BIN_EXE_key-race-helper"); + let spawn = || { + Command::new(helper) + .arg(name) + .arg(start.to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + }; + + let first = spawn().map_err(|error| format!("spawn first helper: {error}"))?; + let second = match spawn() { + Ok(child) => child, + Err(error) => { + let _ = first.wait_with_output(); + return Err(format!("spawn second helper: {error}")); + } + }; + let first_output = first + .wait_with_output() + .map_err(|error| format!("wait for first helper: {error}"))?; + let second_output = second + .wait_with_output() + .map_err(|error| format!("wait for second helper: {error}"))?; + let first = helper_output(first_output, "first helper")?; + let second = helper_output(second_output, "second helper")?; + if first != second { + return Err("helpers observed different public keys".to_owned()); + } + + let reopened = CngKey::open(DEFAULT_PROVIDER_NAME, name).map_err(|error| error.to_string())?; + if first != public_key(&reopened) { + return Err("helper output differs from the persisted public key".to_owned()); + } + Ok(()) +} + +#[test] +fn multiprocess_open_or_create_converges_on_one_key() { + let name = owned_name(); + delete_test_key(DEFAULT_PROVIDER_NAME, &name).expect("pre-clean multiprocess test key"); + + let result = race_key_creation(&name); + let cleanup = delete_test_key(DEFAULT_PROVIDER_NAME, &name); + + cleanup.expect("delete multiprocess test key"); + result.expect("multiprocess open-or-create"); + assert!( + CngKey::open(DEFAULT_PROVIDER_NAME, &name).is_err(), + "test-owned key must be deleted" + ); +} diff --git a/native/deny.toml b/native/deny.toml new file mode 100644 index 00000000..a92c9ad6 --- /dev/null +++ b/native/deny.toml @@ -0,0 +1,18 @@ +[advisories] +version = 2 +yanked = "deny" + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[licenses] +version = 2 +allow = [ + "Apache-2.0", + "MIT", +] + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/native/docs/abi.md b/native/docs/abi.md new file mode 100644 index 00000000..223ee6f2 --- /dev/null +++ b/native/docs/abi.md @@ -0,0 +1,56 @@ +# C ABI + +The Microsoft.Identity.Native.Primitives native contract is declared in +`include/microsoft_identity_native_primitives.h`. + +## Request ownership + +The caller owns the request structure and every referenced string, header +array, and body buffer. They must remain readable until +`microsoft_identity_native_request` returns. All strings are UTF-8; HTTP +protocol fields are restricted to ASCII. + +Provider, client-certificate, and CA-bundle paths must be absolute. The target +must be origin-form, beginning with `/`. + +Each header name is limited to +`MICROSOFT_IDENTITY_NATIVE_MAX_HEADER_NAME_BYTES`, each value to +`MICROSOFT_IDENTITY_NATIVE_MAX_HEADER_VALUE_BYTES`, and the combined generated +request headers to `MICROSOFT_IDENTITY_NATIVE_MAX_HEADER_BYTES`. + +## Response ownership + +Pass fresh or zeroed `MICROSOFT_IDENTITY_NATIVE_RESPONSE` storage. On success, +the returned structure owns native header and body allocations. + +Response header names follow the HTTP token grammar. Content-Length permits +surrounding spaces and tabs, but duplicate lengths, internal whitespace, +nondecimal lengths, and transfer encoding remain rejected. + +The response is move-only: + +- Do not copy the structure. +- Do not alter its pointers or lengths. +- Call `microsoft_identity_native_response_free` exactly once on the original + structure. +- A repeated free on that reset original structure is harmless. Freeing a + copied structure is invalid. + +## Errors + +`microsoft_identity_native_request` returns nonzero on success and zero on +failure. `MICROSOFT_IDENTITY_NATIVE_ERROR` contains a stable category, optional +system/OpenSSL code, and a bounded message. Failures do not return a partially +owned response. + +Rust panics are caught at the ABI boundary and converted to an I/O error. + +## Versioning + +Call `microsoft_identity_native_abi_version` before use and compare it with +`MICROSOFT_IDENTITY_NATIVE_ABI_VERSION`. The request begins with structure size +and ABI version fields so incompatible inputs are rejected deterministically. + +ABI compatibility rules and version increments are defined in +[Versioning](versioning.md). Changes to this contract also require the review +described in [Security review](security-review.md). diff --git a/native/docs/architecture.md b/native/docs/architecture.md new file mode 100644 index 00000000..340ceea2 --- /dev/null +++ b/native/docs/architecture.md @@ -0,0 +1,108 @@ +# Architecture + +## Objective + +Microsoft.Identity.Native.Primitives provides Rust-native building blocks for +identity scenarios that require platform-protected, non-exportable +cryptographic keys. + +The first set of primitives lets a native caller make an mTLS-authenticated +HTTPS request using a persisted Windows CNG RSA key without exporting the +key's private bytes. It is compatible with opaque keys backed by Windows CNG, +including appropriately provisioned KeyGuard keys. + +The security-critical operation is the complete outbound transaction. +Returning a token while expecting each language runtime to reproduce the +matching client-certificate transport would make correct use dependent on +every caller. The current primitives instead own provider loading, key lookup, +the TLS handshake, HTTP request construction, and response framing behind one +native ABI. + +Future primitives may support other native cryptographic backends or opaque-key +mechanisms. They must be implemented and reviewed explicitly; this native project +does not provide placeholder backends or a generic software-key abstraction. + +## Runtime flow + +```text +Caller + | + | MICROSOFT_IDENTITY_NATIVE_REQUEST + v +microsoft_identity_native_primitives.dll + |- validate all ABI inputs + |- create private OSSL_LIB_CTX + |- load microsoft_identity_native_provider.dll from an explicit path + |- load CNG key via OSSL_STORE + |- load certificate and CA bundle + |- resolve host and connect socket + |- configure SNI and certificate verification + |- run TLS client-auth handshake + |- write one HTTP/1.1 request + |- parse one bounded response + v +MICROSOFT_IDENTITY_NATIVE_RESPONSE +``` + +## Rust crates + +### `cng-core` + +Owns NCrypt and BCrypt handles through Rust RAII. It validates persisted key +policy, exports only public RSA parameters, hashes messages, and invokes +`NCryptSignHash`. Signing operations on a shared key are serialized. + +### `cng-provider` + +Implements the OpenSSL 3 provider callbacks needed for RSA: + +- KEYMGMT for public parameters, validation, duplication, and non-exportable + private-key references; +- SIGNATURE for SHA-256/384/512 with PKCS#1 and PSS; and +- STORE for strict `cng://` URI loading. + +OpenSSL receives an opaque provider-backed key. Private-key parameters are +never exported. + +### `mtls-bridge` + +Exports the C ABI and owns the network transaction. It uses a private OpenSSL +library context so provider selection and certificate parsing do not depend on +process-global OpenSSL properties. + +### `openssl-provider-sys` + +Defines the small set of OpenSSL provider structures, constants, callbacks, +STORE APIs, and TLS functions not exposed by the normal Rust OpenSSL bindings +used by the project. + +## Why there is a C ABI + +The C ABI is the language-neutral boundary, not a C implementation. Any +runtime capable of calling a Windows DLL can use the same Rust transaction +without receiving the private key or reimplementing the TLS behavior. + +The ABI is versioned and structure-sized so future versions can add fields +while preserving deterministic validation. + +## Why C tests remain + +Files under `tests/native` compile as independent C programs. They verify: + +- exported symbols and calling convention; +- structure layouts as seen by a C compiler; +- allocation and free ownership; +- OpenSSL provider loading from an external consumer; +- certificate/key matching; +- successful and failing mTLS handshakes; and +- strict HTTP framing. + +They are test clients for the Rust DLLs, not product code. + +## Related documentation + +- [Security model](security-model.md) +- [Security review](security-review.md) +- [C ABI](abi.md) +- [Versioning](versioning.md) +- [Development](development.md) diff --git a/native/docs/building-and-testing.md b/native/docs/building-and-testing.md new file mode 100644 index 00000000..78508bdf --- /dev/null +++ b/native/docs/building-and-testing.md @@ -0,0 +1,81 @@ +# Building and testing + +These instructions build and validate the current +Microsoft.Identity.Native.Primitives Windows CNG/OpenSSL primitives. + +Run the commands below from the repository's `native/` directory. + +## Configure + +Use Windows x64, Visual Studio 2022, Rust 1.97.1 MSVC, CMake, and vcpkg. + +```powershell +$vcpkg = 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\vcpkg' +cmake -S . -B build -G 'Visual Studio 17 2022' -A x64 ` + -DCMAKE_TOOLCHAIN_FILE="$vcpkg\scripts\buildsystems\vcpkg.cmake" ` + -DVCPKG_TARGET_TRIPLET=x64-windows +``` + +The pinned vcpkg registry currently supplies OpenSSL 3.6.3. + +## OpenSSL servicing plan + +OpenSSL 3.6 reaches end of life on November 1, 2026. It is a temporary +development target, not the long-term production baseline. + +The first supported Microsoft.Identity.Native.Primitives release will target +OpenSSL 3.5 LTS, which is supported through April 8, 2030. Before a production +release: + +- pin a reviewed vcpkg version or override that supplies the selected OpenSSL + 3.5 servicing release; +- run the complete Debug and Release provider/ABI/mTLS matrix against it; +- retain OpenSSL 3.6 only as a compatibility lane until its end of life; and +- test OpenSSL 4.0 separately before claiming compatibility. + +Do not publish production binaries validated only against OpenSSL 3.6. The +OpenSSL baseline, runtime DLLs, and test evidence must move together. + +## Build + +```powershell +.\scripts\build.ps1 +``` + +Debug and Release artifacts are staged under `artifacts`. + +## Test + +```powershell +.\scripts\test.ps1 +``` + +The script runs: + +- `cargo fmt --check` +- workspace checks and Clippy with warnings denied +- Debug and Release Rust tests +- Debug and Release external C provider tests +- Debug and Release native ABI/mTLS integration tests +- project-owned CNG test-key audit + +The C tests are external conformance tests, not product implementations. They +validate C compiler layouts, calling conventions, exports, allocation +ownership, provider behavior, and a complete local mTLS transaction against +the Rust DLLs. + +Provider tests cover null-key signing-context reuse, retained PSS settings, +and paired digest/MGF1 updates across independent messages. The loopback TLS +server covers both initial-handshake client authentication and delayed client +certificate requests after token-bound HTTP headers. Delayed authentication +also has a total-deadline regression. These tests use the pinned Windows +OpenSSL runtime; they do not establish live Key Vault service behavior. + +Rust regressions cover pre-conversion RSA encoding bounds, supported padding, +and URI cleanup on successful/failed STORE loads and decoded-name failures. +The STORE cleanup regression uses `cng-core` only as an internal test dependency +to create and delete a process-owned key; no new external dependency is added. + +See [Development](development.md) for contributor setup, +[Security review](security-review.md) for sensitive-change evidence, and +[Release process](releasing.md) for the complete release gate. diff --git a/native/docs/development.md b/native/docs/development.md new file mode 100644 index 00000000..6ec3b99c --- /dev/null +++ b/native/docs/development.md @@ -0,0 +1,102 @@ +# Development + +## Supported environment + +Development and validation currently target: + +- Windows x64; +- Visual Studio 2022 native C/C++ build tools; +- Rust 1.97.1 for `x86_64-pc-windows-msvc`, including `rustfmt` and Clippy; +- CMake 3.21 or later; +- vcpkg; and +- the vcpkg-pinned OpenSSL development baseline, currently OpenSSL 3.6.3. + +OpenSSL 3.6 is the current development target, not the long-term production +support commitment. See [Building and testing](building-and-testing.md) for +the OpenSSL servicing plan. + +## Configure + +Run native commands from the repository's `native/` directory. + +Use a Visual Studio 2022 developer environment: + +```powershell +$vcpkg = 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\VC\vcpkg' + +cmake -S . -B build -G 'Visual Studio 17 2022' -A x64 ` + -DCMAKE_TOOLCHAIN_FILE="$vcpkg\scripts\buildsystems\vcpkg.cmake" ` + -DVCPKG_TARGET_TRIPLET=x64-windows +``` + +If Visual Studio is installed in another edition or location, adjust the +vcpkg path without changing the pinned manifest baseline. + +## Repository layout + +| Path | Responsibility | +|---|---| +| `crates/cng-core` | Windows CNG/BCrypt handle ownership, key policy, hashing, signing | +| `crates/cng-provider` | Rust OpenSSL provider implementation | +| `crates/mtls-bridge` | Rust TLS/HTTP transport and public native ABI | +| `crates/openssl-provider-sys` | Minimal OpenSSL provider FFI definitions | +| `crates/rust-tests` | Rust integration utilities and CNG key audit | +| `include` | Public C ABI | +| `tests/native` | External C conformance and integration tests | +| `examples/c` | Minimal external native example | + +Internal CNG-oriented crate and source names are intentional because CNG is the +current backend. They are not public product branding. + +## Build and test + +Build both configurations: + +```powershell +.\scripts\build.ps1 +``` + +Run the complete local matrix: + +```powershell +.\scripts\test.ps1 +cargo audit +cargo deny check +``` + +The test script establishes the project-local OpenSSL environment. Direct +Cargo commands that compile OpenSSL-dependent crates must set `OPENSSL_DIR` and +`OPENSSL_LIB_DIR` to the matching project-local vcpkg installation. + +## Development rules + +- Run formatting rather than hand-formatting Rust. +- Keep builds warning-clean. +- Use fallible allocation in provider-reachable FFI paths where allocation + failure must become a structured native error. +- Add a precise `SAFETY:` comment to every unsafe block. +- Model native handles with explicit ownership and deterministic cleanup. +- Keep input traversal and allocation bounded. +- Preserve the public ABI's ownership and allocator boundary. +- Add regression tests for fixes and negative tests for security-sensitive + behavior. +- Do not add production managed-language or C++ implementations. + +Read [CONTRIBUTING.md](../CONTRIBUTING.md) before substantial work and use a +[design proposal issue](https://github.com/AzureAD/microsoft-authentication-library-for-python/issues/new) for changes +listed there. + +## Generated and local files + +`build`, `target`, `artifacts`, test PKI, logs, symbols, and IDE user settings +are local outputs and must not be committed. + +The native integration tests create process-owned CNG keys. A successful test +run ends with: + +```text +No project-specific CNG test keys remain. +``` + +If a test process is interrupted, rerun the key audit and remove only keys +identified as project-owned. diff --git a/native/docs/releasing.md b/native/docs/releasing.md new file mode 100644 index 00000000..925c92f1 --- /dev/null +++ b/native/docs/releasing.md @@ -0,0 +1,89 @@ +# Release process + +Microsoft.Identity.Native.Primitives is pre-release. This checklist defines +the expected release bar; it does not claim that Microsoft signing, +publication, or provenance infrastructure is already configured. Native release +authority belongs to the MSAL Python repository maintainers under +[CODEOWNERS](../../CODEOWNERS). A Python package release does not publish native +artifacts. + +## Release preparation + +- Confirm the intended release scope and approved design proposals. +- Complete the `Unreleased` section in [CHANGELOG.md](../CHANGELOG.md). +- Select the product SemVer and apply it consistently to Cargo, CMake, vcpkg, + and provider-reported metadata. +- Decide whether `MICROSOFT_IDENTITY_NATIVE_ABI_VERSION` must change under + [Versioning](versioning.md). +- Commit the final `Cargo.lock`. +- Review the pinned vcpkg baseline and exact OpenSSL version. +- Confirm the supported OpenSSL branch and servicing lifetime. +- Review every dependency entering the native trusted computing base. +- Confirm all user-facing, ABI, security-model, development, and support + documentation is current. + +## Validation + +Run from a clean checkout: + +```powershell +cargo fmt --all -- --check +cargo check --locked --workspace --all-targets --all-features +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +cargo test --locked --workspace --all-features +cargo test --locked --release --workspace --all-features +.\scripts\test.ps1 +cargo audit +cargo deny check +``` + +Record evidence for: + +- Debug and Release Rust tests; +- Debug and Release native provider tests; +- Debug and Release native ABI/mTLS integration tests; +- project-specific CNG test-key cleanup audit; +- certificate/key mismatch and TLS verification failures; +- bounded HTTP framing and timeout failures; +- exported symbols in `microsoft_identity_native_primitives.dll`; +- the value returned by `microsoft_identity_native_abi_version()`; and +- public C structure sizes and constants. + +## Security and legal review + +- Review all changes to unsafe Rust, FFI, native ownership, CNG/key policy, + cryptography, OpenSSL provider behavior, TLS, HTTP framing, and secret + handling. +- Confirm negative/adversarial tests cover security-sensitive behavior. +- Review dependency licenses and third-party notice obligations. +- Generate an SBOM for the exact released dependency and binary set. +- Generate build provenance tied to the source commit and build environment. +- Verify that no token, key, certificate private material, credential, or + sensitive production payload is included in artifacts or logs. + +## Artifact preparation + +- Build release artifacts from the reviewed commit. +- Include the required OpenSSL runtime and notices when distribution requires + them. +- Generate SHA-256 checksums for every published artifact. +- Inspect DLL dependencies and the public export table. +- Sign the release commit or tag. +- Sign Windows DLLs when Microsoft signing infrastructure is available and + configured for this repository. +- Verify signatures and checksums from a clean environment. + +## Publication + +- Create a signed, annotated release tag that matches the product version. +- Publish release notes derived from the changelog. +- Attach only reviewed, signed artifacts and their SHA-256 checksums. +- Attach or link the SBOM and provenance statement. +- Verify downloads, signatures, checksums, exports, ABI version, and a minimal + native request from the published artifacts. +- Update [SUPPORT.md](../SUPPORT.md) when the release changes supported + versions or servicing commitments. + +Coordinate native release tags, artifact publication, and servicing commitments +with the repository maintainers; do not reuse the Python package release +pipeline for native artifacts. diff --git a/native/docs/security-model.md b/native/docs/security-model.md new file mode 100644 index 00000000..a5d95935 --- /dev/null +++ b/native/docs/security-model.md @@ -0,0 +1,113 @@ +# Security model + +## Assets + +The current Microsoft.Identity.Native.Primitives CNG/OpenSSL primitives protect: + +- the persisted CNG private-key capability; +- certificate-bound access tokens supplied by the caller; +- request bodies and response bodies copied through native memory; +- TLS peer identity; and +- the integrity of HTTP request and response framing. + +## Trusted computing base + +The trusted computing base includes: + +- the calling process; +- `microsoft_identity_native_primitives.dll`; +- `microsoft_identity_native_provider.dll`; +- the selected OpenSSL runtime; +- Windows CNG and the selected KSP; +- the supplied certificate and CA files; and +- operating-system access controls protecting those files and the persisted + key. + +## Enforced properties + +The current implementation enforces: + +- bounded ABI inputs; +- strict header allowlisting; +- `mtls_pop` authorization scheme validation; +- no redirects, proxy selection, arbitrary Host header, or transfer encoding; +- verified CA trust and hostname/IP identity; +- secure server-initiated TLS 1.2 renegotiation for delayed client authentication, + under the same total I/O deadline and without unsafe legacy renegotiation; +- opaque CNG signing with public-key export only; +- rejection of persisted keys with nonzero private export policy; +- minimum RSA strength; +- pre-conversion RSA encoding limits: 2,056 modulus bytes and eight exponent + bytes, accommodating one native-word modulus pad and padded 32-bit exponents + while retaining the 16,384-bit modulus and 32-bit exponent limits; +- exact Content-Length response framing; and +- panic containment across native boundaries. + +## Important non-goals + +### In-process caller isolation + +The native primitive cannot identify which package, module, plugin, or other +component inside one process initiated a call. All in-process code runs with +the process identity. A malicious dependency that can call arbitrary native +APIs may be able to invoke the primitive or Windows CNG directly. + +Use an out-of-process broker with OS-authenticated IPC when separate workloads +must receive different authorization. + +### Token acquisition + +Microsoft.Identity.Native.Primitives does not acquire OAuth tokens and does not +validate token audience, tenant, scopes, or claims. The caller is responsible +for supplying the correct certificate-bound token for the selected destination. + +### Destination authorization + +The low-level ABI accepts a destination from the caller. A higher-level trusted +application must restrict keys, hosts, methods, paths, tenants, and operations +before invoking the ABI. + +### Managed-runtime zeroization + +Native copies are redacted or zeroized where practical. Original strings or +byte objects owned by a managed runtime remain subject to that runtime's memory +behavior. + +Copied request paths, STORE URIs, header values, and bodies use zeroizing +storage, including on validation failures. + +NUL-terminated STORE copies, decoded provider/key identifiers, retained STORE +descriptions, and temporary UTF-16 provider/key names also use zeroizing +storage. This does not guarantee cleanup of copies made internally by Windows +or OpenSSL. + +### Response-buffer reservation + +Each in-flight request reserves the bounded 1 MiB native response capacity +before reading response plaintext. This avoids reallocating plaintext through +an old allocation that can no longer be zeroized, at the cost of approximately +1 MiB of reserved response memory per concurrent request. Hosts must bound +concurrency according to their memory budget. + +### Attestation policy + +Successfully using a VBS/KeyGuard key does not, by itself, prove an attestation +policy. A deployment that requires hardware or virtual-isolation guarantees +must validate the expected KSP, key properties, and attestation evidence as +part of its provisioning and authorization design. + +## Deployment recommendations + +- Install the primitives DLL, provider, OpenSSL dependencies, certificates, + and policy in a directory not writable by the application identity. +- Restrict the persisted key's ACL to the intended service or broker identity. +- Pin or verify the provider and OpenSSL binaries used by production. +- Allowlist destination hosts and operations outside the raw ABI. +- Avoid logging tokens, STORE URIs, certificate paths, request bodies, or + response bodies. +- Use bounded concurrency at the host level. +- Treat an aborted non-idempotent request as having an unknown remote outcome. + +Use [Security review](security-review.md) when changing any enforced property +or trusted-computing-base component. Public-contract changes must also follow +[Versioning](versioning.md). diff --git a/native/docs/security-review.md b/native/docs/security-review.md new file mode 100644 index 00000000..4eb0f947 --- /dev/null +++ b/native/docs/security-review.md @@ -0,0 +1,100 @@ +# Security review + +This guide defines the minimum review evidence for changes to +Microsoft.Identity.Native.Primitives. It supplements, and does not replace, +Microsoft security review processes. + +## When security review is required + +Require explicit security review for changes involving: + +- unsafe Rust, raw pointers, callback lifetimes, or FFI; +- C ABI layouts, exports, calling conventions, versioning, or ownership; +- native allocation, zeroization, error paths, or handle cleanup; +- CNG/BCrypt handles, key creation, key ACLs, key usage, export policy, KSP + selection, KeyGuard/VBS behavior, or attestation; +- cryptographic algorithms, digest/padding/salt parameters, or signing; +- OpenSSL provider loading, dispatch, property selection, STORE, KEYMGMT, or + SIGNATURE behavior; +- certificate parsing, chain construction, or certificate/private-key binding; +- DNS, sockets, TLS, trust anchors, SNI, hostname/IP verification, or peer + verification; +- HTTP request construction, header validation, redirects, proxies, framing, + limits, timeouts, or response parsing; +- tokens, request/response plaintext, diagnostics, logs, or sensitive key + identifiers; +- threading, cancellation, concurrency, or `Send`/`Sync` assumptions; or +- dependencies that enter the trusted computing base. + +## Review questions + +### Key boundary + +- Can private-key bytes, private parameters, or an exportable private blob + leave the selected CNG/KSP boundary? +- Is private export policy rejected explicitly? +- Are key usage, size, provider, and certificate binding validated? +- Could a new path select or exercise a different key than intended? +- Does the change make claims about KeyGuard/VBS or attestation that the code + does not enforce? + +### Unsafe Rust and FFI + +- Does every unsafe block state the exact `SAFETY:` invariant? +- Are pointer validity, alignment, lifetime, aliasing, and nullability checked? +- Are lengths bounded before pointer traversal, conversion, or allocation? +- Is panic containment preserved at every exported callback boundary? +- Are Rust allocations always released by the matching Rust free function? +- Are caller-owned and callee-owned objects unambiguous? + +### Native ownership and concurrency + +- Is each CNG, BCrypt, OpenSSL, socket, DNS, allocation, and OS handle owned + exactly once? +- Are partial initialization and every failure path cleaned up correctly? +- Are cancellation races and timeouts safe? +- Are `Send` and `Sync` implementations or assumptions justified by the native + API contract? + +### TLS and HTTP + +- Are certificate chain and hostname/IP checks still mandatory? +- Can the caller bypass trust, SNI, or peer verification? +- Are redirects, proxies, Host overrides, transfer encoding, duplicate + framing, or ambiguous lengths introduced? +- Are request and response sizes and total deadlines still bounded? +- Could retry behavior duplicate a non-idempotent operation? + +### Secrets and diagnostics + +- Can tokens, keys, credentials, certificates with private material, request + bodies, response bodies, or sensitive key identifiers reach logs or errors? +- Are native plaintext copies zeroized where the ownership model permits? +- Are errors useful without exposing secrets? + +### Compatibility + +- Does the change alter a public structure, enum value, exported symbol, + calling convention, ownership rule, allocation/free pair, or existing field + interpretation? +- Is the product and ABI version impact documented under + [Versioning](versioning.md)? +- Are old callers rejected safely and deterministically where compatibility is + not possible? + +## Required evidence + +Security-sensitive pull requests should include: + +- a linked design proposal when required by [CONTRIBUTING.md](../CONTRIBUTING.md); +- a concise threat and security-impact analysis; +- regression tests; +- negative/adversarial tests where practical; +- Debug and Release Rust/native results; +- dependency-policy results when the trusted computing base changes; +- ABI layout/export evidence when the public boundary changes; and +- documentation and changelog updates. + +Prefer no security finding over a speculative claim. Review comments should +identify a concrete violated invariant, reachable failure mode, or contract +regression. diff --git a/native/docs/versioning.md b/native/docs/versioning.md new file mode 100644 index 00000000..b91066ec --- /dev/null +++ b/native/docs/versioning.md @@ -0,0 +1,122 @@ +# Versioning + +Microsoft.Identity.Native.Primitives has three related but distinct version +dimensions: + +1. the product/package version; +2. the public native ABI version; and +3. the supported OpenSSL/provider compatibility range. + +Changing one dimension does not automatically change the others. + +## Product version + +The workspace, CMake project, vcpkg manifest, and provider-reported product +version use [Semantic Versioning](https://semver.org/). + +### Major + +Increment the SemVer major version for an incompatible change to a supported +public product contract, including removal of supported functionality, +incompatible behavior changes, or an ABI break after a stable release. + +During the pre-1.0 period, incompatible changes may use a minor version +increment, but they must still be identified under `Breaking` in +[CHANGELOG.md](../CHANGELOG.md) and reviewed explicitly. + +### Minor + +Increment the SemVer minor version for backward-compatible additions such as: + +- a new native identity primitive; +- a new optional ABI entry point or versioned structure extension; +- support for an additional reviewed backend or platform; +- a new supported cryptographic capability; or +- a substantial backward-compatible behavior or diagnostic improvement. + +### Patch + +Increment the SemVer patch version for backward-compatible bug, security, +documentation, packaging, and servicing fixes that do not add a public +capability or break an existing contract. + +Version changes must be applied consistently wherever the product version is +published or reported. + +## Rust dependency versions + +All crates inherit their product version and external dependency requirements from +`native/Cargo.toml`. Product version `0.2.0` is independent of the MSAL Python +package version. + +Workspace dependencies use explicit compatible ranges, not exact version pins. +For example, `>=0.2.177, <0.3` permits compatible `libc` updates but excludes the +next potentially breaking `0.x` minor version. These ranges are equivalent to +the original Cargo caret requirements and do not broaden the compatibility +contract. + +`native/Cargo.lock` records the exact direct and transitive versions validated +together. Keep it committed and use `--locked` for builds and tests. Dependabot +monitors the `/native` Cargo workspace; an update is reviewed and validated +before changing the lockfile. For a targeted manual update, run from `native/`: + +```powershell +cargo update -p libc +``` + +Review the manifest and lockfile diff, run the complete Rust/native matrix and +dependency-policy checks, and commit the reviewed update. Changing an upper +bound requires an explicit compatibility review. Do not replace reviewed +ranges with wildcard dependencies or discard the lockfile. + +## Native ABI version + +The native ABI version is exposed by +`microsoft_identity_native_abi_version()` and +`MICROSOFT_IDENTITY_NATIVE_ABI_VERSION`. It is independent of the product +version. + +Increment `MICROSOFT_IDENTITY_NATIVE_ABI_VERSION` before releasing an +ABI-incompatible change. ABI-breaking changes include: + +- changing a public structure's field order, layout, size, or alignment; +- changing a field's type, width, signedness, or interpretation; +- changing public enum numeric values; +- removing or renaming an exported function; +- changing a calling convention; +- changing ownership, lifetime, mutability, or thread-safety rules; +- changing the allocation/free contract or allocator boundary; +- changing whether pointers may be null or whether buffers may alias; +- changing the meaning of an existing flag, error, field, or return value; or +- making previously valid input invalid without an explicitly versioned + compatibility mechanism. + +Adding a field directly to an existing fixed-layout structure is breaking even +when the field is appended. A compatible extension requires a reviewed +versioning design, such as a new structure size/version contract that old +consumers can safely use. + +An ABI version increment does not by itself make a change safe. The release +must also document migration, reject incompatible structures +deterministically, update C conformance tests, and verify the exported symbol +table and layouts. + +## OpenSSL/provider compatibility + +OpenSSL compatibility is a runtime and provider contract, not the native ABI +version. + +- Pin and review the development baseline through vcpkg. +- Document the OpenSSL branches and servicing releases validated for each + published product release. +- Treat provider callback, property-query, STORE, key-management, signature, + and TLS API compatibility as release criteria. +- Do not infer compatibility with a new OpenSSL major or minor branch from a + successful compile alone. + +Changing the supported OpenSSL range may require a product minor or major +version depending on whether existing supported deployments remain compatible. +It does not require a native ABI increment unless the C contract also changes. + +See [ABI](abi.md), [Building and testing](building-and-testing.md), and +[Release process](releasing.md). diff --git a/native/examples/c/mtls_request.c b/native/examples/c/mtls_request.c new file mode 100644 index 00000000..64b28110 --- /dev/null +++ b/native/examples/c/mtls_request.c @@ -0,0 +1,86 @@ +#include "microsoft_identity_native_primitives.h" + +#include + +#include +#include + +int main(int argc, char **argv) { + MICROSOFT_IDENTITY_NATIVE_HEADER headers[3]; + MICROSOFT_IDENTITY_NATIVE_REQUEST request = {0}; + MICROSOFT_IDENTITY_NATIVE_RESPONSE response = {0}; + MICROSOFT_IDENTITY_NATIVE_ERROR error = {0}; + char authorization[MICROSOFT_IDENTITY_NATIVE_MAX_HEADER_VALUE_BYTES + 1U]; + const char *token = getenv("NATIVE_PRIMITIVES_MTLS_POP_TOKEN"); + char *port_end = NULL; + unsigned long port; + uint32_t actual_abi; + int written; + int succeeded; + + if (argc != 8 || token == NULL || token[0] == '\0') { + fprintf(stderr, + "Usage: %s " + " \n" + "Set NATIVE_PRIMITIVES_MTLS_POP_TOKEN to an already-acquired " + "token.\n", + argv[0]); + return 2; + } + + actual_abi = microsoft_identity_native_abi_version(); + if (actual_abi != MICROSOFT_IDENTITY_NATIVE_ABI_VERSION) { + fprintf(stderr, + "Unsupported Native Primitives ABI: expected %u, received %u.\n", + MICROSOFT_IDENTITY_NATIVE_ABI_VERSION, actual_abi); + return 1; + } + + written = snprintf(authorization, sizeof(authorization), "mtls_pop %s", token); + if (written < 0 || (size_t)written >= sizeof(authorization)) { + fprintf(stderr, "The supplied token is too large.\n"); + return 2; + } + port = strtoul(argv[6], &port_end, 10); + if (port_end == argv[6] || *port_end != '\0' || port == 0 || port > 65535) { + SecureZeroMemory(authorization, sizeof(authorization)); + fprintf(stderr, "The port must be an integer from 1 through 65535.\n"); + return 2; + } + + headers[0].name = "Authorization"; + headers[0].value = authorization; + headers[1].name = "x-ms-tokenboundauth"; + headers[1].value = "true"; + headers[2].name = "Accept"; + headers[2].value = "application/json"; + + request.struct_size = (uint32_t)sizeof(request); + request.abi_version = MICROSOFT_IDENTITY_NATIVE_ABI_VERSION; + request.provider_module_path = argv[1]; + request.store_uri = argv[2]; + request.client_certificate_path = argv[3]; + request.ca_bundle_path = argv[4]; + request.host = argv[5]; + request.port = (uint16_t)port; + request.method = "GET"; + request.target = argv[7]; + request.headers = headers; + request.header_count = 3; + + succeeded = microsoft_identity_native_request(&request, &response, &error); + SecureZeroMemory(authorization, sizeof(authorization)); + if (!succeeded) { + fprintf(stderr, "Native Primitives error %d: %s\n", + (int)error.code, error.message); + return 1; + } + + printf("HTTP %d\n", response.http_status); + if (response.body_length != 0) { + fwrite(response.body, 1, response.body_length, stdout); + fputc('\n', stdout); + } + microsoft_identity_native_response_free(&response); + return 0; +} diff --git a/native/include/cng_provider_params.h b/native/include/cng_provider_params.h new file mode 100644 index 00000000..3f861ff3 --- /dev/null +++ b/native/include/cng_provider_params.h @@ -0,0 +1,16 @@ +#ifndef CNG_PROVIDER_PARAMS_H +#define CNG_PROVIDER_PARAMS_H + +#define CNG_PARAM_KEY_NAME "cng-key-name" +#define CNG_PARAM_PROVIDER_NAME "cng-provider-name" + +#define CNG_DEFAULT_PROVIDER_NAME "Microsoft Software Key Storage Provider" +#define CNG_DEFAULT_KEY_NAME "openssl-cng-provider-poc" + +#define CNG_STORE_SCHEME "cng" +#define CNG_STORE_URI_PREFIX "cng://?" +#define CNG_MAX_KEY_NAME 512 +#define CNG_MAX_PROVIDER_NAME 512 +#define CNG_MAX_STORE_URI 2048 + +#endif diff --git a/native/include/microsoft_identity_native_primitives.h b/native/include/microsoft_identity_native_primitives.h new file mode 100644 index 00000000..39e8bb38 --- /dev/null +++ b/native/include/microsoft_identity_native_primitives.h @@ -0,0 +1,110 @@ +#ifndef MICROSOFT_IDENTITY_NATIVE_PRIMITIVES_H +#define MICROSOFT_IDENTITY_NATIVE_PRIMITIVES_H + +#include +#include + +#ifdef _WIN32 +#ifdef MICROSOFT_IDENTITY_NATIVE_BUILD +#define MICROSOFT_IDENTITY_NATIVE_API __declspec(dllexport) +#else +#define MICROSOFT_IDENTITY_NATIVE_API __declspec(dllimport) +#endif +#else +#define MICROSOFT_IDENTITY_NATIVE_API +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +#define MICROSOFT_IDENTITY_NATIVE_ABI_VERSION 1U +#define MICROSOFT_IDENTITY_NATIVE_MAX_HEADERS 16U +#define MICROSOFT_IDENTITY_NATIVE_MAX_HEADER_BYTES 16384U +#define MICROSOFT_IDENTITY_NATIVE_MAX_HEADER_NAME_BYTES 64U +#define MICROSOFT_IDENTITY_NATIVE_MAX_HEADER_VALUE_BYTES 12288U +#define MICROSOFT_IDENTITY_NATIVE_MAX_BODY_BYTES 1048576U +#define MICROSOFT_IDENTITY_NATIVE_MAX_RESPONSE_BYTES 1048576U + +typedef enum microsoft_identity_native_error_code_st { + MICROSOFT_IDENTITY_NATIVE_ERROR_NONE = 0, + MICROSOFT_IDENTITY_NATIVE_ERROR_INVALID_ARGUMENT = 1, + MICROSOFT_IDENTITY_NATIVE_ERROR_LIMIT = 2, + MICROSOFT_IDENTITY_NATIVE_ERROR_PROVIDER = 3, + MICROSOFT_IDENTITY_NATIVE_ERROR_STORE = 4, + MICROSOFT_IDENTITY_NATIVE_ERROR_CERTIFICATE = 5, + MICROSOFT_IDENTITY_NATIVE_ERROR_DNS = 6, + MICROSOFT_IDENTITY_NATIVE_ERROR_CONNECT = 7, + MICROSOFT_IDENTITY_NATIVE_ERROR_TLS = 8, + MICROSOFT_IDENTITY_NATIVE_ERROR_IO = 9, + MICROSOFT_IDENTITY_NATIVE_ERROR_HTTP = 10, + MICROSOFT_IDENTITY_NATIVE_ERROR_MEMORY = 11 +} MICROSOFT_IDENTITY_NATIVE_ERROR_CODE; + +typedef struct microsoft_identity_native_header_st { + /* ABI strings are UTF-8; HTTP header fields are restricted to ASCII. */ + const char *name; + const char *value; +} MICROSOFT_IDENTITY_NATIVE_HEADER; + +typedef struct microsoft_identity_native_request_st { + uint32_t struct_size; + uint32_t abi_version; + /* All string fields use UTF-8. Protocol fields are validated as ASCII. */ + const char *provider_module_path; + const char *store_uri; + const char *client_certificate_path; + const char *ca_bundle_path; + const char *host; + uint16_t port; + const char *method; + const char *target; + const MICROSOFT_IDENTITY_NATIVE_HEADER *headers; + size_t header_count; + const unsigned char *body; + size_t body_size; + uint32_t connect_timeout_ms; + uint32_t io_timeout_ms; +} MICROSOFT_IDENTITY_NATIVE_REQUEST; + +typedef struct microsoft_identity_native_response_st { + /* + * Owning move-only output. Pass a fresh/zeroed instance to + * microsoft_identity_native_request(). + * On success, do not copy or modify its pointers/lengths. Call + * microsoft_identity_native_response_free() exactly once on the original instance. + */ + int http_status; + char *headers; + size_t headers_length; + unsigned char *body; + size_t body_length; +} MICROSOFT_IDENTITY_NATIVE_RESPONSE; + +typedef struct microsoft_identity_native_error_st { + MICROSOFT_IDENTITY_NATIVE_ERROR_CODE code; + int system_error; + unsigned long openssl_error; + char message[512]; +} MICROSOFT_IDENTITY_NATIVE_ERROR; + +MICROSOFT_IDENTITY_NATIVE_API uint32_t +microsoft_identity_native_abi_version(void); +/* + * `response` must point to fresh or zeroed storage with no outstanding allocation. + * The implementation resets it before use. A successful response owns native allocations until + * microsoft_identity_native_response_free() is called; copying the structure duplicates + * ownership and is invalid. + */ +MICROSOFT_IDENTITY_NATIVE_API int microsoft_identity_native_request( + const MICROSOFT_IDENTITY_NATIVE_REQUEST *request, + MICROSOFT_IDENTITY_NATIVE_RESPONSE *response, + MICROSOFT_IDENTITY_NATIVE_ERROR *error); +MICROSOFT_IDENTITY_NATIVE_API void microsoft_identity_native_response_free( + MICROSOFT_IDENTITY_NATIVE_RESPONSE *response); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/native/rustfmt.toml b/native/rustfmt.toml new file mode 100644 index 00000000..ed49ca7b --- /dev/null +++ b/native/rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2024" +max_width = 100 diff --git a/native/scripts/build.ps1 b/native/scripts/build.ps1 new file mode 100644 index 00000000..1721d6ce --- /dev/null +++ b/native/scripts/build.ps1 @@ -0,0 +1,108 @@ +param( + [ValidateSet("Debug", "Release", "All")] + [string]$Configuration = "All" +) + +$ErrorActionPreference = "Stop" +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$OpenSslRoot = Join-Path $RepoRoot "build\vcpkg_installed\x64-windows" +$ArtifactRoot = Join-Path $RepoRoot "artifacts" + +if (-not (Test-Path (Join-Path $OpenSslRoot "include\openssl\ssl.h"))) { + throw "Project-local OpenSSL 3.6 installation was not found at $OpenSslRoot." +} +$RustVersion = (& rustc --version) +if ($LASTEXITCODE -ne 0 -or $RustVersion -notmatch '^rustc 1\.97\.1 ') { + throw "Rust stable 1.97.1 x86_64-pc-windows-msvc is required; found '$RustVersion'." +} + +$PreviousOpenSslDir = $env:OPENSSL_DIR +$PreviousOpenSslLibDir = $env:OPENSSL_LIB_DIR + +function Build-Configuration { + param([string]$Name) + + $IsDebug = $Name -eq "Debug" + $env:OPENSSL_DIR = $OpenSslRoot + $env:OPENSSL_LIB_DIR = if ($IsDebug) { + Join-Path $OpenSslRoot "debug\lib" + } else { + Join-Path $OpenSslRoot "lib" + } + + Push-Location $RepoRoot + try { + if ($IsDebug) { + & cargo build --locked -p cng-provider -p mtls-bridge + $CargoOutput = Join-Path $RepoRoot "target\debug" + } else { + & cargo build --locked --release -p cng-provider -p mtls-bridge + $CargoOutput = Join-Path $RepoRoot "target\release" + } + if ($LASTEXITCODE -ne 0) { + throw "Cargo $Name build failed with exit code $LASTEXITCODE." + } + } + finally { + Pop-Location + } + + $CMake = Get-Command cmake -ErrorAction SilentlyContinue + if ($null -eq $CMake) { + $CMakePath = "C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe" + } else { + $CMakePath = $CMake.Source + } + if (-not (Test-Path -LiteralPath $CMakePath)) { + throw "CMake was not found. Configure the native test harness before building." + } + & $CMakePath --build (Join-Path $RepoRoot "build") --config $Name ` + --target native_provider_test native_test_pki native_mtls_test_server native_integration_test microsoft_identity_native_example ` + --parallel + if ($LASTEXITCODE -ne 0) { + throw "Native $Name test-harness build failed with exit code $LASTEXITCODE." + } + + $Destination = Join-Path $ArtifactRoot $Name + if (Test-Path -LiteralPath $Destination) { + Remove-Item -LiteralPath $Destination -Recurse -Force + } + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + Copy-Item ` + (Join-Path $CargoOutput "microsoft_identity_native_provider.dll"), ` + (Join-Path $CargoOutput "microsoft_identity_native_primitives.dll") ` + -Destination $Destination -Force + + $NativeReference = Join-Path $RepoRoot "build\$Name" + foreach ($File in @( + "native_provider_test.exe", + "native_test_pki.exe", + "native_mtls_test_server.exe", + "native_integration_test.exe", + "microsoft_identity_native_example.exe", + "libcrypto-3-x64.dll", + "libssl-3-x64.dll" + )) { + $Source = Join-Path $NativeReference $File + if (-not (Test-Path $Source)) { + throw "Required reference test/runtime artifact is missing: $Source" + } + Copy-Item $Source -Destination $Destination -Force + } + Write-Host "Staged Rust $Name artifacts at $Destination" +} + +try { + $Configurations = if ($Configuration -eq "All") { + @("Debug", "Release") + } else { + @($Configuration) + } + foreach ($Name in $Configurations) { + Build-Configuration $Name + } +} +finally { + $env:OPENSSL_DIR = $PreviousOpenSslDir + $env:OPENSSL_LIB_DIR = $PreviousOpenSslLibDir +} diff --git a/native/scripts/loc.ps1 b/native/scripts/loc.ps1 new file mode 100644 index 00000000..7f956ad1 --- /dev/null +++ b/native/scripts/loc.ps1 @@ -0,0 +1,31 @@ +$ErrorActionPreference = "Stop" +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +function Count-RustProductionLines { + $Total = 0 + $Crates = @("cng-core", "openssl-provider-sys", "cng-provider", "mtls-bridge") + foreach ($Crate in $Crates) { + foreach ($File in Get-ChildItem (Join-Path $RepoRoot "crates\$Crate\src") -Filter "*.rs" -Recurse) { + $Lines = Get-Content -LiteralPath $File.FullName + $TestStart = 0 + for ($Index = 0; $Index -lt $Lines.Count; $Index++) { + if ($Lines[$Index] -match '^\s*#\[cfg\(test\)\]') { + $TestStart = $Index + break + } + } + if ($TestStart -eq 0) { + $Total += $Lines.Count + } else { + $Total += $TestStart + } + } + } + return $Total +} + +$RustLines = Count-RustProductionLines + +[PSCustomObject]@{ + RustProductionPhysicalLines = $RustLines +} | Format-List diff --git a/native/scripts/test.ps1 b/native/scripts/test.ps1 new file mode 100644 index 00000000..8f323736 --- /dev/null +++ b/native/scripts/test.ps1 @@ -0,0 +1,104 @@ +param([switch]$SkipBuild) + +$ErrorActionPreference = "Stop" +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path +$OpenSslRoot = Join-Path $RepoRoot "build\vcpkg_installed\x64-windows" +$PreviousOpenSslDir = $env:OPENSSL_DIR +$PreviousOpenSslLibDir = $env:OPENSSL_LIB_DIR +$PreviousPath = $env:PATH + +function Set-OpenSslConfiguration { + param([string]$Name) + + $env:OPENSSL_DIR = $OpenSslRoot + $env:OPENSSL_LIB_DIR = if ($Name -eq "Debug") { + Join-Path $OpenSslRoot "debug\lib" + } else { + Join-Path $OpenSslRoot "lib" + } + $NativePath = Join-Path $RepoRoot "build\$Name" + $env:PATH = "$NativePath;$PreviousPath" +} + +function Invoke-NativeTests { + param([string]$Name) + + $Directory = Join-Path $RepoRoot "artifacts\$Name" + Write-Host "Running native tests against Rust $Name DLLs..." + & (Join-Path $Directory "native_provider_test.exe") ` + (Join-Path $Directory "microsoft_identity_native_provider.dll") + if ($LASTEXITCODE -ne 0) { + throw "Rust $Name provider signing test failed with exit code $LASTEXITCODE." + } + & (Join-Path $Directory "native_integration_test.exe") ` + (Join-Path $Directory "microsoft_identity_native_provider.dll") ` + (Join-Path $Directory "native_provider_test.exe") ` + (Join-Path $Directory "native_test_pki.exe") ` + (Join-Path $Directory "native_mtls_test_server.exe") ` + (Join-Path $Directory "test-artifacts") + if ($LASTEXITCODE -ne 0) { + throw "Rust $Name native integration failed with exit code $LASTEXITCODE." + } +} + +try { + if (-not $SkipBuild) { + & (Join-Path $PSScriptRoot "build.ps1") -Configuration All + } + Set-OpenSslConfiguration "Debug" + Push-Location $RepoRoot + try { + & cargo fmt --all -- --check + if ($LASTEXITCODE -ne 0) { + throw "cargo fmt --check failed." + } + & cargo check --locked --workspace --all-targets --all-features + if ($LASTEXITCODE -ne 0) { + throw "cargo check failed." + } + & cargo clippy --locked --workspace --all-targets --all-features -- -D warnings + if ($LASTEXITCODE -ne 0) { + throw "cargo clippy failed." + } + & cargo test --locked --workspace --all-features + if ($LASTEXITCODE -ne 0) { + throw "Debug cargo test failed." + } + } + finally { + Pop-Location + } + + Set-OpenSslConfiguration "Release" + Push-Location $RepoRoot + try { + & cargo test --locked --release --workspace --all-features + if ($LASTEXITCODE -ne 0) { + throw "Release cargo test failed." + } + } + finally { + Pop-Location + } + + foreach ($Name in @("Debug", "Release")) { + Invoke-NativeTests $Name + } + + Set-OpenSslConfiguration "Debug" + Push-Location $RepoRoot + try { + & cargo run --locked -p rust-tests --bin key-audit + if ($LASTEXITCODE -ne 0) { + throw "Project-specific CNG key audit failed." + } + } + finally { + Pop-Location + } +} +finally { + $env:OPENSSL_DIR = $PreviousOpenSslDir + $env:OPENSSL_LIB_DIR = $PreviousOpenSslLibDir + $env:PATH = $PreviousPath +} diff --git a/native/tests/native/cng_integration_test.c b/native/tests/native/cng_integration_test.c new file mode 100644 index 00000000..35beb2d1 --- /dev/null +++ b/native/tests/native/cng_integration_test.c @@ -0,0 +1,915 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "cng_key.h" +#include "microsoft_identity_native_primitives.h" +#include "cng_provider_params.h" + +#define PROCESS_TIMEOUT_MS 30000U + +static int split_provider_path(const char *input, + char *directory, + size_t directory_size, + char *module_name, + size_t module_name_size) +{ + char full_path[32768]; + char *slash; + char *dot; + DWORD length = + GetFullPathNameA(input, (DWORD)sizeof(full_path), full_path, NULL); + + if (length == 0 || length >= sizeof(full_path)) { + return 0; + } + slash = strrchr(full_path, '\\'); + if (slash == NULL || slash == full_path) { + return 0; + } + *slash = '\0'; + if (strcpy_s(directory, directory_size, full_path) != 0 + || strcpy_s(module_name, module_name_size, slash + 1) != 0) { + return 0; + } + dot = strrchr(module_name, '.'); + if (dot != NULL && _stricmp(dot, ".dll") == 0) { + *dot = '\0'; + } + return 1; +} + +static int url_encode(const char *input, char *output, size_t output_size) +{ + static const char hex[] = "0123456789ABCDEF"; + size_t source; + size_t destination = 0; + + for (source = 0; input[source] != '\0'; ++source) { + unsigned char value = (unsigned char)input[source]; + int unreserved = + (value >= 'A' && value <= 'Z') + || (value >= 'a' && value <= 'z') + || (value >= '0' && value <= '9') + || value == '-' || value == '.' || value == '_' || value == '~'; + + if (unreserved) { + if (destination + 1 >= output_size) { + return 0; + } + output[destination++] = (char)value; + } else { + if (destination + 3 >= output_size) { + return 0; + } + output[destination++] = '%'; + output[destination++] = hex[value >> 4]; + output[destination++] = hex[value & 15U]; + } + } + output[destination] = '\0'; + return 1; +} + +static int make_store_uri(const char *key_name, + const char *provider_name, + char *uri, + size_t uri_size) +{ + char encoded_key[1024]; + char encoded_provider[1024]; + + return url_encode(key_name, encoded_key, sizeof(encoded_key)) + && url_encode(provider_name, encoded_provider, + sizeof(encoded_provider)) + && sprintf_s(uri, uri_size, "cng://?key=%s&provider=%s", + encoded_key, encoded_provider) + >= 0; +} + +static int generate_owned_key_name(char *key_name, size_t key_name_size) +{ + unsigned char random_bytes[16]; + char error[512] = { 0 }; + unsigned int attempt; + + for (attempt = 0; attempt < 8; ++attempt) { + CNG_KEY *existing = NULL; + + if (!BCRYPT_SUCCESS(BCryptGenRandom( + NULL, random_bytes, sizeof(random_bytes), + BCRYPT_USE_SYSTEM_PREFERRED_RNG)) + || sprintf_s( + key_name, key_name_size, + "openssl-cng-integration-%lu-" + "%02x%02x%02x%02x%02x%02x%02x%02x" + "%02x%02x%02x%02x%02x%02x%02x%02x", + GetCurrentProcessId(), random_bytes[0], random_bytes[1], + random_bytes[2], random_bytes[3], random_bytes[4], + random_bytes[5], random_bytes[6], random_bytes[7], + random_bytes[8], random_bytes[9], random_bytes[10], + random_bytes[11], random_bytes[12], random_bytes[13], + random_bytes[14], random_bytes[15]) + < 0) { + SecureZeroMemory(random_bytes, sizeof(random_bytes)); + return 0; + } + error[0] = '\0'; + if (!cng_key_open(CNG_DEFAULT_PROVIDER_NAME, key_name, &existing, + error, sizeof(error))) { + SecureZeroMemory(random_bytes, sizeof(random_bytes)); + return 1; + } + cng_key_free(existing); + } + SecureZeroMemory(random_bytes, sizeof(random_bytes)); + return 0; +} + +static int make_path(char *output, + size_t output_size, + const char *directory, + const char *name) +{ + return sprintf_s(output, output_size, "%s\\%s", directory, name) >= 0; +} + +static wchar_t *utf8_to_wide(const char *value) +{ + int required = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, + -1, NULL, 0); + wchar_t *wide; + + if (required <= 0) { + return NULL; + } + wide = (wchar_t *)calloc((size_t)required, sizeof(*wide)); + if (wide == NULL + || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, + wide, required) + != required) { + free(wide); + return NULL; + } + return wide; +} + +static int create_utf8_directory(const char *path) +{ + wchar_t *wide = utf8_to_wide(path); + int result = + wide != NULL + && (CreateDirectoryW(wide, NULL) || GetLastError() == ERROR_ALREADY_EXISTS); + + free(wide); + return result; +} + +static int copy_utf8_file(const char *source, const char *destination) +{ + wchar_t *source_wide = utf8_to_wide(source); + wchar_t *destination_wide = utf8_to_wide(destination); + int result = source_wide != NULL && destination_wide != NULL + && CopyFileW(source_wide, destination_wide, FALSE); + + free(destination_wide); + free(source_wide); + return result; +} + +static int start_process(const char *command_line, PROCESS_INFORMATION *process) +{ + STARTUPINFOA startup; + char mutable_command[32768]; + + if (strcpy_s(mutable_command, sizeof(mutable_command), command_line) + != 0) { + return 0; + } + SecureZeroMemory(&startup, sizeof(startup)); + SecureZeroMemory(process, sizeof(*process)); + startup.cb = sizeof(startup); + if (!CreateProcessA(NULL, mutable_command, NULL, NULL, FALSE, + CREATE_NO_WINDOW, NULL, NULL, &startup, process)) { + fprintf(stderr, "CreateProcess failed with Win32 error %lu.\n", + GetLastError()); + return 0; + } + CloseHandle(process->hThread); + process->hThread = NULL; + return 1; +} + +static int wait_process(PROCESS_INFORMATION *process, DWORD timeout_ms) +{ + DWORD wait_result; + DWORD exit_code = STILL_ACTIVE; + + wait_result = WaitForSingleObject(process->hProcess, timeout_ms); + if (wait_result != WAIT_OBJECT_0 + || !GetExitCodeProcess(process->hProcess, &exit_code)) { + return 0; + } + return exit_code == 0; +} + +static void stop_process(PROCESS_INFORMATION *process) +{ + if (process->hProcess != NULL) { + if (WaitForSingleObject(process->hProcess, 0) == WAIT_TIMEOUT) { + TerminateProcess(process->hProcess, 1); + WaitForSingleObject(process->hProcess, 5000); + } + CloseHandle(process->hProcess); + process->hProcess = NULL; + } +} + +static int run_process(const char *command_line) +{ + PROCESS_INFORMATION process; + int result; + + if (!start_process(command_line, &process)) { + return 0; + } + result = wait_process(&process, PROCESS_TIMEOUT_MS); + stop_process(&process); + return result; +} + +static EVP_PKEY *load_store_key(OSSL_LIB_CTX *libctx, const char *uri) +{ + OSSL_STORE_CTX *store = + OSSL_STORE_open_ex(uri, libctx, + "provider=microsoft_identity_native_provider", NULL, NULL, + NULL, NULL, NULL); + OSSL_STORE_INFO *info = NULL; + EVP_PKEY *key = NULL; + + if (store == NULL || !OSSL_STORE_expect(store, OSSL_STORE_INFO_PKEY)) { + goto cleanup; + } + while (!OSSL_STORE_eof(store)) { + info = OSSL_STORE_load(store); + if (info == NULL) { + break; + } + if (OSSL_STORE_INFO_get_type(info) == OSSL_STORE_INFO_PKEY) { + key = OSSL_STORE_INFO_get1_PKEY(info); + OSSL_STORE_INFO_free(info); + info = NULL; + break; + } + OSSL_STORE_INFO_free(info); + info = NULL; + } + +cleanup: + OSSL_STORE_INFO_free(info); + if (store != NULL) { + OSSL_STORE_close(store); + } + return key; +} + +static EVP_PKEY *create_default_public_key(OSSL_LIB_CTX *libctx, + EVP_PKEY *source) +{ + EVP_PKEY_CTX *context = NULL; + EVP_PKEY *public_key = NULL; + OSSL_PARAM *params = NULL; + + if (EVP_PKEY_todata(source, EVP_PKEY_PUBLIC_KEY, ¶ms) <= 0) { + goto cleanup; + } + context = EVP_PKEY_CTX_new_from_name(libctx, "RSA", "provider=default"); + if (context == NULL || EVP_PKEY_fromdata_init(context) <= 0 + || EVP_PKEY_fromdata(context, &public_key, EVP_PKEY_PUBLIC_KEY, + params) + <= 0) { + EVP_PKEY_free(public_key); + public_key = NULL; + } + +cleanup: + OSSL_PARAM_free(params); + EVP_PKEY_CTX_free(context); + return public_key; +} + +static int malformed_store_uris_are_rejected(OSSL_LIB_CTX *libctx) +{ + static const char *invalid[] = { + "cng://", + "cng://?provider=Microsoft%20Software%20Key%20Storage%20Provider", + "cng://?key=a&key=b", + "cng://?key=a&unknown=b", + "cng://?key=a&provider=x&provider=y", + "cng://?key=a%", + "cng://?key=a+b", + "cng://?key=", + "cng://?key=a&" + }; + char oversized[700]; + size_t index; + + memset(oversized, 'a', sizeof(oversized)); + memcpy(oversized, "cng://?key=", 11); + oversized[sizeof(oversized) - 1] = '\0'; + + for (index = 0; index < sizeof(invalid) / sizeof(invalid[0]); ++index) { + OSSL_STORE_CTX *store = + OSSL_STORE_open_ex(invalid[index], libctx, + "provider=microsoft_identity_native_provider", NULL, NULL, NULL, + NULL, NULL); + if (store != NULL) { + OSSL_STORE_close(store); + fprintf(stderr, "Malformed STORE URI was accepted: %s\n", + invalid[index]); + return 0; + } + ERR_clear_error(); + } + { + OSSL_STORE_CTX *store = + OSSL_STORE_open_ex(oversized, libctx, + "provider=microsoft_identity_native_provider", + NULL, NULL, NULL, NULL, NULL); + if (store != NULL) { + OSSL_STORE_close(store); + fprintf(stderr, "Oversized STORE URI field was accepted.\n"); + return 0; + } + ERR_clear_error(); + } + return 1; +} + +static int pubkey_store_expectation_is_rejected(OSSL_LIB_CTX *libctx, + const char *uri) +{ + OSSL_STORE_CTX *store = + OSSL_STORE_open_ex(uri, libctx, + "provider=microsoft_identity_native_provider", NULL, NULL, + NULL, NULL, NULL); + int result; + + if (store == NULL) { + return 0; + } + ERR_clear_error(); + result = OSSL_STORE_expect(store, OSSL_STORE_INFO_PUBKEY) == 0; + OSSL_STORE_close(store); + ERR_clear_error(); + if (!result) { + fprintf(stderr, "STORE accepted an unsupported PUBKEY expectation.\n"); + } + return result; +} + +static int wait_for_port(const char *ready_file, unsigned short *port) +{ + unsigned int attempt; + + for (attempt = 0; attempt < 200; ++attempt) { + FILE *file = NULL; + unsigned int parsed; + + if (fopen_s(&file, ready_file, "rb") == 0 && file != NULL) { + if (fscanf_s(file, "%u", &parsed) == 1 && parsed > 0 + && parsed <= 65535U) { + fclose(file); + *port = (unsigned short)parsed; + return 1; + } + fclose(file); + } + Sleep(50); + } + return 0; +} + +static void initialize_request(MICROSOFT_IDENTITY_NATIVE_REQUEST *request, + const char *provider_path, + const char *store_uri, + const char *certificate_path, + const char *ca_path, + const char *host, + unsigned short port) +{ + SecureZeroMemory(request, sizeof(*request)); + request->struct_size = sizeof(*request); + request->abi_version = MICROSOFT_IDENTITY_NATIVE_ABI_VERSION; + request->provider_module_path = provider_path; + request->store_uri = store_uri; + request->client_certificate_path = certificate_path; + request->ca_bundle_path = ca_path; + request->host = host; + request->port = port; + request->method = "POST"; + request->target = "/mtls"; + request->connect_timeout_ms = 5000; + request->io_timeout_ms = 5000; +} + +int main(int argc, char **argv) +{ + char key_name[128]; + char store_uri[CNG_MAX_STORE_URI]; + char artifact_root[32768]; + char artifact_directory[32768]; + char provider_directory[32768]; + char module_name[256]; + char command_line[32768]; + char ca_path[32768]; + char server_cert_path[32768]; + char server_key_path[32768]; + char client_cert_path[32768]; + char wrong_cert_path[32768]; + char ready_file[32768]; + char unicode_directory[32768]; + char unicode_provider_path[32768]; + char unicode_client_cert_path[32768]; + char unicode_ca_path[32768]; + OSSL_LIB_CTX *libctx = NULL; + OSSL_PROVIDER *default_provider = NULL; + OSSL_PROVIDER *cng_provider = NULL; + EVP_PKEY *key = NULL; + EVP_PKEY *key_duplicate = NULL; + EVP_PKEY *public_key = NULL; + EVP_PKEY_CTX *key_check = NULL; + OSSL_PARAM *private_params = NULL; + PROCESS_INFORMATION server_process; + unsigned short port = 0; + MICROSOFT_IDENTITY_NATIVE_REQUEST request; + MICROSOFT_IDENTITY_NATIVE_RESPONSE response; + MICROSOFT_IDENTITY_NATIVE_ERROR bridge_error; + MICROSOFT_IDENTITY_NATIVE_HEADER headers[2]; + const unsigned char body[] = "local request body"; + char platform_error[512] = { 0 }; + CNG_KEY *deleted_key = NULL; + int server_started = 0; + int owns_key_name = 0; + int result = EXIT_FAILURE; + + SecureZeroMemory(&server_process, sizeof(server_process)); + SecureZeroMemory(&response, sizeof(response)); + if (argc != 6) { + fprintf(stderr, + "Usage: %s " + " \n", + argv[0]); + return EXIT_FAILURE; + } + if (!generate_owned_key_name(key_name, sizeof(key_name)) + || !make_store_uri(key_name, CNG_DEFAULT_PROVIDER_NAME, store_uri, + sizeof(store_uri))) { + return EXIT_FAILURE; + } + owns_key_name = 1; + + if (strcpy_s(artifact_root, sizeof(artifact_root), argv[5]) != 0 + || (_mkdir(artifact_root) != 0 && errno != EEXIST) + || sprintf_s(artifact_directory, sizeof(artifact_directory), + "%s\\run-%lu", artifact_root, GetCurrentProcessId()) + < 0 + || (_mkdir(artifact_directory) != 0 && errno != EEXIST) + || !make_path(ca_path, sizeof(ca_path), artifact_directory, "ca.pem") + || !make_path(server_cert_path, sizeof(server_cert_path), + artifact_directory, "server-cert.pem") + || !make_path(server_key_path, sizeof(server_key_path), + artifact_directory, "server-key.pem") + || !make_path(client_cert_path, sizeof(client_cert_path), + artifact_directory, "client-cert.pem") + || !make_path(wrong_cert_path, sizeof(wrong_cert_path), + artifact_directory, "wrong-client-cert.pem") + || !make_path(ready_file, sizeof(ready_file), artifact_directory, + "server.ready") + || !make_path(unicode_directory, sizeof(unicode_directory), + artifact_directory, + "unicode-\xE6\xB5\x8B\xE8\xAF\x95") + || !create_utf8_directory(unicode_directory) + || !make_path(unicode_provider_path, sizeof(unicode_provider_path), + unicode_directory, "microsoft_identity_native_provider.dll") + || !make_path(unicode_client_cert_path, + sizeof(unicode_client_cert_path), unicode_directory, + "client-cert.pem") + || !make_path(unicode_ca_path, sizeof(unicode_ca_path), + unicode_directory, "ca.pem")) { + fprintf(stderr, "Unable to prepare the build-tree artifact path.\n"); + goto cleanup; + } + + if (sprintf_s(command_line, sizeof(command_line), "\"%s\" \"%s\"", + argv[2], argv[1]) + < 0 + || !run_process(command_line)) { + fprintf(stderr, "The preserved provider signing test failed.\n"); + goto cleanup; + } + + if (sprintf_s(command_line, sizeof(command_line), + "\"%s\" \"%s\" \"%s\" \"%s\" \"%s\" \"%s\" " + "\"pki.ready\"", + argv[3], argv[1], artifact_directory, key_name, + CNG_DEFAULT_PROVIDER_NAME, store_uri) + < 0 + || !run_process(command_line)) { + fprintf(stderr, "Local PKI generation failed.\n"); + goto cleanup; + } + if (!copy_utf8_file(argv[1], unicode_provider_path) + || !copy_utf8_file(client_cert_path, unicode_client_cert_path) + || !copy_utf8_file(ca_path, unicode_ca_path)) { + fprintf(stderr, "Unable to prepare Unicode-path test artifacts.\n"); + goto cleanup; + } + + if (!split_provider_path(argv[1], provider_directory, + sizeof(provider_directory), module_name, + sizeof(module_name))) { + goto cleanup; + } + libctx = OSSL_LIB_CTX_new(); + if (libctx == NULL + || !OSSL_PROVIDER_set_default_search_path(libctx, + provider_directory) + || (default_provider = OSSL_PROVIDER_load(libctx, "default")) == NULL + || (cng_provider = OSSL_PROVIDER_load(libctx, module_name)) == NULL) { + ERR_print_errors_fp(stderr); + goto cleanup; + } + key = load_store_key(libctx, store_uri); + if (key == NULL) { + fprintf(stderr, "The STORE URI returned no key.\n"); + ERR_print_errors_fp(stderr); + goto cleanup; + } + key_duplicate = EVP_PKEY_dup(key); + public_key = create_default_public_key(libctx, key); + key_check = EVP_PKEY_CTX_new_from_pkey(libctx, key, NULL); + if (key_duplicate == NULL || public_key == NULL + || EVP_PKEY_eq(key, public_key) != 1 || key_check == NULL + || EVP_PKEY_public_check(key_check) <= 0 + || EVP_PKEY_private_check(key_check) <= 0 + || !malformed_store_uris_are_rejected(libctx) + || !pubkey_store_expectation_is_rejected(libctx, store_uri)) { + fprintf(stderr, "STORE loading, duplication, matching, or validation failed.\n"); + ERR_print_errors_fp(stderr); + goto cleanup; + } + EVP_PKEY_free(key); + key = key_duplicate; + key_duplicate = NULL; + + ERR_clear_error(); + if (EVP_PKEY_todata(key, EVP_PKEY_PRIVATE_KEY, &private_params) > 0 + || !cng_private_export_is_blocked( + CNG_DEFAULT_PROVIDER_NAME, key_name, platform_error, + sizeof(platform_error))) { + fprintf(stderr, "Private key export was not blocked.\n"); + goto cleanup; + } + OSSL_PARAM_free(private_params); + private_params = NULL; + ERR_clear_error(); + + initialize_request(&request, argv[1], store_uri, wrong_cert_path, ca_path, + "localhost", 1); + if (microsoft_identity_native_request(&request, &response, &bridge_error) + || bridge_error.code != MICROSOFT_IDENTITY_NATIVE_ERROR_CERTIFICATE) { + fprintf(stderr, + "A mismatched client key and certificate was not rejected " + "(code %d).\n", + bridge_error.code); + goto cleanup; + } + + initialize_request(&request, argv[1], store_uri, client_cert_path, ca_path, + "localhost", 1); + request.target = "https://localhost/mtls"; + if (microsoft_identity_native_request(&request, &response, &bridge_error) + || bridge_error.code != MICROSOFT_IDENTITY_NATIVE_ERROR_INVALID_ARGUMENT) { + fprintf(stderr, "An absolute request target was not rejected.\n"); + goto cleanup; + } + headers[0].name = "X-Request-ID"; + headers[0].value = "safe\r\nInjected: yes"; + initialize_request(&request, argv[1], store_uri, client_cert_path, ca_path, + "localhost", 1); + request.headers = headers; + request.header_count = 1; + if (microsoft_identity_native_request(&request, &response, &bridge_error) + || bridge_error.code != MICROSOFT_IDENTITY_NATIVE_ERROR_INVALID_ARGUMENT) { + fprintf(stderr, "CRLF header injection was not rejected.\n"); + goto cleanup; + } + + DeleteFileA(ready_file); + if (sprintf_s(command_line, sizeof(command_line), + "\"%s\" \"%s\" \"%s\" \"%s\" \"%s\" 2 success", + argv[4], ca_path, server_cert_path, server_key_path, + ready_file) + < 0 + || !start_process(command_line, &server_process)) { + fprintf(stderr, "Unable to start the local mTLS server.\n"); + goto cleanup; + } + server_started = 1; + if (!wait_for_port(ready_file, &port)) { + fprintf(stderr, "The local mTLS server did not become ready.\n"); + goto cleanup; + } + + initialize_request(&request, argv[1], store_uri, client_cert_path, ca_path, + "127.0.0.1", port); + if (microsoft_identity_native_request(&request, &response, &bridge_error) + || bridge_error.code != MICROSOFT_IDENTITY_NATIVE_ERROR_TLS) { + fprintf(stderr, + "Hostname verification failure was not reported " + "(code %d, system %d): %s\n", + bridge_error.code, bridge_error.system_error, + bridge_error.message); + goto cleanup; + } + + headers[0].name = "Accept"; + headers[0].value = "text/plain"; + headers[1].name = "X-Request-ID"; + headers[1].value = "cng-local-integration"; + initialize_request(&request, unicode_provider_path, store_uri, + unicode_client_cert_path, unicode_ca_path, "localhost", + port); + request.headers = headers; + request.header_count = 2; + request.body = body; + request.body_size = sizeof(body) - 1; + if (!microsoft_identity_native_request(&request, &response, &bridge_error) + || response.http_status != 200 + || response.headers == NULL + || strstr(response.headers, "Content-Type: text/plain") == NULL + || response.body_length != sizeof("cng mTLS success\n") - 1 + || memcmp(response.body, "cng mTLS success\n", + sizeof("cng mTLS success\n") - 1) + != 0) { + fprintf(stderr, "The local mTLS request failed: %s\n", + bridge_error.message); + goto cleanup; + } + microsoft_identity_native_response_free(&response); + + if (!wait_process(&server_process, PROCESS_TIMEOUT_MS)) { + fprintf(stderr, "The local mTLS server did not exit successfully.\n"); + goto cleanup; + } + stop_process(&server_process); + server_started = 0; + + { + const char *modes[] = { "delayed-cert", "delayed-cert-drip" }; + size_t mode_index; + for (mode_index = 0; mode_index < 2; ++mode_index) { + DeleteFileA(ready_file); + if (sprintf_s(command_line, sizeof(command_line), + "\"%s\" \"%s\" \"%s\" \"%s\" \"%s\" 1 %s", + argv[4], ca_path, server_cert_path, server_key_path, + ready_file, modes[mode_index]) < 0 + || !start_process(command_line, &server_process)) { + fprintf(stderr, "Unable to start delayed-certificate server.\n"); + goto cleanup; + } + server_started = 1; + if (!wait_for_port(ready_file, &port)) { + fprintf(stderr, "Delayed-certificate server was not ready.\n"); + goto cleanup; + } + initialize_request(&request, unicode_provider_path, store_uri, + unicode_client_cert_path, unicode_ca_path, + "localhost", port); + headers[0].name = "Authorization"; + headers[0].value = "mtls_pop synthetic-test-token"; + headers[1].name = "x-ms-tokenboundauth"; + headers[1].value = "true"; + request.headers = headers; + request.header_count = 2; + if (mode_index == 1) { + request.io_timeout_ms = 1000; + if (microsoft_identity_native_request(&request, &response, &bridge_error) + || bridge_error.code != MICROSOFT_IDENTITY_NATIVE_ERROR_IO + || bridge_error.system_error != WSAETIMEDOUT) { + fprintf(stderr, "Delayed authentication reset the I/O deadline: %s\n", + bridge_error.message); + goto cleanup; + } + } else { + if (!microsoft_identity_native_request(&request, &response, &bridge_error) + || response.http_status != 200 + || response.body_length != sizeof("cng mTLS success\n") - 1 + || memcmp(response.body, "cng mTLS success\n", + sizeof("cng mTLS success\n") - 1) != 0) { + fprintf(stderr, "Delayed certificate authentication failed: %s\n", + bridge_error.message); + (void)wait_process(&server_process, PROCESS_TIMEOUT_MS); + goto cleanup; + } + microsoft_identity_native_response_free(&response); + } + if (!wait_process(&server_process, PROCESS_TIMEOUT_MS)) { + fprintf(stderr, "Delayed-certificate server did not exit successfully.\n"); + goto cleanup; + } + stop_process(&server_process); + server_started = 0; + } + } + + DeleteFileA(ready_file); + if (sprintf_s(command_line, sizeof(command_line), + "\"%s\" \"%s\" \"%s\" \"%s\" \"%s\" 1 early-hints", + argv[4], ca_path, server_cert_path, server_key_path, + ready_file) + < 0 + || !start_process(command_line, &server_process)) { + fprintf(stderr, "Unable to start the informational test server.\n"); + goto cleanup; + } + server_started = 1; + if (!wait_for_port(ready_file, &port)) { + fprintf(stderr, "The informational test server was not ready.\n"); + goto cleanup; + } + initialize_request(&request, unicode_provider_path, store_uri, + unicode_client_cert_path, unicode_ca_path, "localhost", + port); + if (!microsoft_identity_native_request(&request, &response, &bridge_error) + || response.http_status != 200 + || response.body_length != sizeof("cng mTLS success\n") - 1 + || memcmp(response.body, "cng mTLS success\n", + sizeof("cng mTLS success\n") - 1) + != 0) { + fprintf(stderr, "The informational response flow failed: %s\n", + bridge_error.message); + goto cleanup; + } + microsoft_identity_native_response_free(&response); + if (!wait_process(&server_process, PROCESS_TIMEOUT_MS)) { + fprintf(stderr, "The informational test server did not exit.\n"); + goto cleanup; + } + stop_process(&server_process); + server_started = 0; + + DeleteFileA(ready_file); + if (sprintf_s(command_line, sizeof(command_line), + "\"%s\" \"%s\" \"%s\" \"%s\" \"%s\" 1 tls-truncate", + argv[4], ca_path, server_cert_path, server_key_path, + ready_file) + < 0 + || !start_process(command_line, &server_process)) { + fprintf(stderr, "Unable to start the TLS truncation test server.\n"); + goto cleanup; + } + server_started = 1; + if (!wait_for_port(ready_file, &port)) { + fprintf(stderr, "The TLS truncation test server was not ready.\n"); + goto cleanup; + } + initialize_request(&request, unicode_provider_path, store_uri, + unicode_client_cert_path, unicode_ca_path, "localhost", + port); + if (!microsoft_identity_native_request(&request, &response, &bridge_error) + || response.http_status != 200 + || response.body_length != sizeof("cng mTLS success\n") - 1 + || memcmp(response.body, "cng mTLS success\n", + sizeof("cng mTLS success\n") - 1) + != 0) { + fprintf(stderr, + "A completely framed response without close_notify failed: %s\n", + bridge_error.message); + goto cleanup; + } + microsoft_identity_native_response_free(&response); + if (!wait_process(&server_process, PROCESS_TIMEOUT_MS)) { + fprintf(stderr, "The TLS truncation server did not exit cleanly.\n"); + goto cleanup; + } + stop_process(&server_process); + server_started = 0; + + DeleteFileA(ready_file); + if (sprintf_s(command_line, sizeof(command_line), + "\"%s\" \"%s\" \"%s\" \"%s\" \"%s\" " + "1 close-delimited", + argv[4], ca_path, server_cert_path, server_key_path, + ready_file) + < 0 + || !start_process(command_line, &server_process)) { + fprintf(stderr, "Unable to start the close-delimited test server.\n"); + goto cleanup; + } + server_started = 1; + if (!wait_for_port(ready_file, &port)) { + fprintf(stderr, "The close-delimited test server was not ready.\n"); + goto cleanup; + } + initialize_request(&request, unicode_provider_path, store_uri, + unicode_client_cert_path, unicode_ca_path, "localhost", + port); + if (microsoft_identity_native_request(&request, &response, &bridge_error) + || bridge_error.code != MICROSOFT_IDENTITY_NATIVE_ERROR_HTTP) { + fprintf(stderr, + "A close-delimited response was not rejected (code %d).\n", + bridge_error.code); + goto cleanup; + } + if (!wait_process(&server_process, PROCESS_TIMEOUT_MS)) { + fprintf(stderr, "The close-delimited server did not exit cleanly.\n"); + goto cleanup; + } + stop_process(&server_process); + server_started = 0; + + DeleteFileA(ready_file); + if (sprintf_s(command_line, sizeof(command_line), + "\"%s\" \"%s\" \"%s\" \"%s\" \"%s\" 1 drip", + argv[4], ca_path, server_cert_path, server_key_path, + ready_file) + < 0 + || !start_process(command_line, &server_process)) { + fprintf(stderr, "Unable to start the deadline test server.\n"); + goto cleanup; + } + server_started = 1; + if (!wait_for_port(ready_file, &port)) { + fprintf(stderr, "The deadline test server was not ready.\n"); + goto cleanup; + } + initialize_request(&request, unicode_provider_path, store_uri, + unicode_client_cert_path, unicode_ca_path, "localhost", + port); + request.io_timeout_ms = 250; + if (microsoft_identity_native_request(&request, &response, &bridge_error) + || bridge_error.code != MICROSOFT_IDENTITY_NATIVE_ERROR_IO + || bridge_error.system_error != WSAETIMEDOUT) { + fprintf(stderr, + "The monotonic total I/O deadline was not enforced " + "(code %d, system %d).\n", + bridge_error.code, bridge_error.system_error); + goto cleanup; + } + if (!wait_process(&server_process, PROCESS_TIMEOUT_MS)) { + fprintf(stderr, "The deadline test server did not exit cleanly.\n"); + goto cleanup; + } + stop_process(&server_process); + server_started = 0; + printf("STORE, provider ownership, and local mTLS integration passed.\n"); + result = EXIT_SUCCESS; + +cleanup: + microsoft_identity_native_response_free(&response); + if (server_started) { + stop_process(&server_process); + } + OSSL_PARAM_free(private_params); + EVP_PKEY_CTX_free(key_check); + EVP_PKEY_free(public_key); + EVP_PKEY_free(key_duplicate); + EVP_PKEY_free(key); + if (cng_provider != NULL) { + OSSL_PROVIDER_unload(cng_provider); + } + if (default_provider != NULL) { + OSSL_PROVIDER_unload(default_provider); + } + OSSL_LIB_CTX_free(libctx); + + if (owns_key_name) { + platform_error[0] = '\0'; + if (!cng_delete_named_key(CNG_DEFAULT_PROVIDER_NAME, key_name, + platform_error, sizeof(platform_error))) { + fprintf(stderr, "CNG test-key deletion failed: %s\n", + platform_error); + result = EXIT_FAILURE; + } else if (cng_key_open(CNG_DEFAULT_PROVIDER_NAME, key_name, + &deleted_key, platform_error, + sizeof(platform_error))) { + fprintf(stderr, "The CNG test key still exists after deletion.\n"); + cng_key_free(deleted_key); + result = EXIT_FAILURE; + } else { + printf("Deleted process-owned CNG test key '%s'.\n", key_name); + } + } + return result; +} diff --git a/native/tests/native/cng_mtls_test_server.c b/native/tests/native/cng_mtls_test_server.c new file mode 100644 index 00000000..440b0674 --- /dev/null +++ b/native/tests/native/cng_mtls_test_server.c @@ -0,0 +1,396 @@ +#include +#include +#include + +#include +#include +#include + +#include +#include +#include + +#define SERVER_REQUEST_LIMIT 65536U +#define SERVER_ACCEPT_TIMEOUT_SECONDS 30L + +static const char response_text[] = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Content-Length: 17\r\n" + "Connection: close\r\n" + "\r\n" + "cng mTLS success\n"; + +static const char close_delimited_response[] = + "HTTP/1.1 200 OK\r\n" + "Content-Type: text/plain\r\n" + "Connection: close\r\n" + "\r\n" + "partial"; + +static const char early_hints_response[] = + "HTTP/1.1 103 Early Hints\r\n" + "Content-Length: 999\r\n" + "\r\n"; + +static void print_errors(const char *operation) +{ + fprintf(stderr, "%s failed.\n", operation); + ERR_print_errors_fp(stderr); +} + +static const unsigned char *find_bytes(const unsigned char *buffer, + size_t length, + const char *needle, + size_t needle_length) +{ + size_t index; + + for (index = 0; index + needle_length <= length; ++index) { + if (memcmp(buffer + index, needle, needle_length) == 0) { + return buffer + index; + } + } + return NULL; +} + +static int read_request(SSL *ssl, int require_token_bound_header) +{ + unsigned char buffer[SERVER_REQUEST_LIMIT]; + size_t length = 0; + const unsigned char *header_end = NULL; + + while (length < sizeof(buffer)) { + size_t received = 0; + if (SSL_read_ex(ssl, buffer + length, sizeof(buffer) - length, + &received) + != 1) { + return 0; + } + length += received; + header_end = find_bytes(buffer, length, "\r\n\r\n", 4); + if (header_end != NULL) { + break; + } + } + if (header_end == NULL + || length < sizeof("POST /mtls HTTP/1.1\r\n") - 1 + || memcmp(buffer, "POST /mtls HTTP/1.1\r\n", + sizeof("POST /mtls HTTP/1.1\r\n") - 1) + != 0) { + return 0; + } + return !require_token_bound_header + || find_bytes(buffer, (size_t)(header_end - buffer) + 2, + "x-ms-tokenboundauth: true\r\n", + sizeof("x-ms-tokenboundauth: true\r\n") - 1) != NULL; +} + +static int request_client_certificate(SSL *ssl, SOCKET client) +{ + u_long nonblocking = 1; + ULONGLONG deadline = GetTickCount64() + 10000; + int hello_sent = 0; + int result = 0; + + if (!SSL_get_secure_renegotiation_support(ssl) + || ioctlsocket(client, FIONBIO, &nonblocking) == SOCKET_ERROR) { + return 0; + } + SSL_set_verify(ssl, SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, NULL); + SSL_set_options(ssl, SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION); + if (SSL_renegotiate(ssl) != 1) { + goto cleanup; + } + while (GetTickCount64() < deadline) { + unsigned char byte; + size_t received = 0; + int operation_result; + int error; + fd_set read_set, write_set; + struct timeval timeout = { 0, 100000 }; + + if (!hello_sent) { + operation_result = SSL_do_handshake(ssl); + if (operation_result == 1) { + hello_sent = 1; + continue; + } + error = SSL_get_error(ssl, operation_result); + } else { + operation_result = SSL_peek_ex(ssl, &byte, 1, &received); + error = SSL_get_error(ssl, operation_result); + if (SSL_total_renegotiations(ssl) == 1 + && !SSL_renegotiate_pending(ssl)) { + X509 *peer = SSL_get1_peer_certificate(ssl); + result = peer != NULL && SSL_get_verify_result(ssl) == X509_V_OK; + X509_free(peer); + break; + } + } + if (error != SSL_ERROR_WANT_READ && error != SSL_ERROR_WANT_WRITE) { + break; + } + FD_ZERO(&read_set); + FD_ZERO(&write_set); + if (error == SSL_ERROR_WANT_READ) { + FD_SET(client, &read_set); + } else { + FD_SET(client, &write_set); + } + if (select(0, &read_set, &write_set, NULL, &timeout) == SOCKET_ERROR) { + break; + } + } +cleanup: + nonblocking = 0; + if (ioctlsocket(client, FIONBIO, &nonblocking) == SOCKET_ERROR) { + return 0; + } + return result; +} + +static int write_all(SSL *ssl, const char *data, size_t length) +{ + while (length != 0) { + size_t written = 0; + if (SSL_write_ex(ssl, data, length, &written) != 1 || written == 0) { + return 0; + } + data += written; + length -= written; + } + return 1; +} + +static void write_drip_response(SSL *ssl) +{ + static const char *parts[] = { + "HTTP/1.1 200 OK\r\n", + "Content-Type: text/plain\r\nContent-Length: 5\r\n\r\n", + "hello" + }; + size_t index; + + for (index = 0; index < sizeof(parts) / sizeof(parts[0]); ++index) { + if (!write_all(ssl, parts[index], strlen(parts[index]))) { + break; + } + if (index + 1 < sizeof(parts) / sizeof(parts[0])) { + Sleep(175); + } + } +} + +static int write_ready_file(const char *path, unsigned short port) +{ + FILE *file = NULL; + int result = 0; + + if (fopen_s(&file, path, "wb") == 0 && file != NULL) { + result = fprintf(file, "%u\n", (unsigned int)port) > 0 + && fclose(file) == 0; + file = NULL; + } + if (file != NULL) { + fclose(file); + } + return result; +} + +int main(int argc, char **argv) +{ + WSADATA winsock; + OSSL_LIB_CTX *libctx = NULL; + OSSL_PROVIDER *default_provider = NULL; + SSL_CTX *ssl_ctx = NULL; + SOCKET listener = INVALID_SOCKET; + struct sockaddr_in address; + int address_size = sizeof(address); + int max_connections; + const char *mode; + int connection_index; + int successful_requests = 0; + int result = EXIT_FAILURE; + int delayed_certificate; + int delayed_deadline; + + if (argc != 7) { + fprintf(stderr, + "Usage: %s " + " " + "\n", + argv[0]); + return EXIT_FAILURE; + } + max_connections = atoi(argv[5]); + mode = argv[6]; + if (max_connections <= 0 || max_connections > 16) { + fprintf(stderr, "max-connections must be between 1 and 16.\n"); + return EXIT_FAILURE; + } + if (strcmp(mode, "success") != 0 + && strcmp(mode, "early-hints") != 0 + && strcmp(mode, "close-delimited") != 0 + && strcmp(mode, "tls-truncate") != 0 + && strcmp(mode, "drip") != 0 + && strcmp(mode, "delayed-cert") != 0 + && strcmp(mode, "delayed-cert-drip") != 0) { + fprintf(stderr, "The response mode is invalid.\n"); + return EXIT_FAILURE; + } + delayed_deadline = strcmp(mode, "delayed-cert-drip") == 0; + delayed_certificate = strcmp(mode, "delayed-cert") == 0 || delayed_deadline; + if (WSAStartup(MAKEWORD(2, 2), &winsock) != 0) { + fprintf(stderr, "WSAStartup failed.\n"); + return EXIT_FAILURE; + } + + libctx = OSSL_LIB_CTX_new(); + default_provider = OSSL_PROVIDER_load(libctx, "default"); + ssl_ctx = SSL_CTX_new_ex(libctx, "provider=default", + TLS_server_method()); + if (libctx == NULL || default_provider == NULL || ssl_ctx == NULL + || !SSL_CTX_set_min_proto_version(ssl_ctx, TLS1_2_VERSION) + || (delayed_certificate + && !SSL_CTX_set_max_proto_version(ssl_ctx, TLS1_2_VERSION)) + || SSL_CTX_use_certificate_chain_file(ssl_ctx, argv[2]) != 1 + || SSL_CTX_use_PrivateKey_file(ssl_ctx, argv[3], + SSL_FILETYPE_PEM) + != 1 + || SSL_CTX_check_private_key(ssl_ctx) != 1 + || !SSL_CTX_load_verify_locations(ssl_ctx, argv[1], NULL)) { + print_errors("Configuring the local TLS server"); + goto cleanup; + } + SSL_CTX_set_verify(ssl_ctx, + delayed_certificate ? SSL_VERIFY_NONE + : SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, + NULL); + SSL_CTX_set_verify_depth(ssl_ctx, 4); + + listener = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + SecureZeroMemory(&address, sizeof(address)); + address.sin_family = AF_INET; + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + address.sin_port = 0; + if (listener == INVALID_SOCKET + || bind(listener, (struct sockaddr *)&address, sizeof(address)) + == SOCKET_ERROR + || listen(listener, 4) == SOCKET_ERROR + || getsockname(listener, (struct sockaddr *)&address, + &address_size) + == SOCKET_ERROR + || !write_ready_file(argv[4], ntohs(address.sin_port))) { + fprintf(stderr, "Unable to start the loopback test server: %d.\n", + WSAGetLastError()); + goto cleanup; + } + + for (connection_index = 0; connection_index < max_connections; + ++connection_index) { + fd_set read_set; + struct timeval timeout; + SOCKET client; + SSL *ssl; + X509 *peer_certificate = NULL; + int skip_shutdown = 0; + + FD_ZERO(&read_set); + FD_SET(listener, &read_set); + timeout.tv_sec = SERVER_ACCEPT_TIMEOUT_SECONDS; + timeout.tv_usec = 0; + if (select(0, &read_set, NULL, NULL, &timeout) <= 0) { + fprintf(stderr, "Timed out waiting for a test connection.\n"); + goto cleanup; + } + client = accept(listener, NULL, NULL); + if (client == INVALID_SOCKET) { + fprintf(stderr, "accept failed: %d.\n", WSAGetLastError()); + goto cleanup; + } + + ssl = SSL_new(ssl_ctx); + if (ssl != NULL && SSL_set_fd(ssl, (int)client) == 1 + && SSL_accept(ssl) == 1 + && SSL_get_verify_result(ssl) == X509_V_OK + && ((peer_certificate = SSL_get1_peer_certificate(ssl)) != NULL + || delayed_certificate) + && (!delayed_certificate || peer_certificate == NULL) + && read_request(ssl, delayed_certificate)) { + if (delayed_certificate) { + if (delayed_deadline) { + Sleep(600); + } + if (request_client_certificate(ssl, client)) { + if (delayed_deadline) { + ++successful_requests; + Sleep(600); + skip_shutdown = 1; + (void)write_all(ssl, response_text, sizeof(response_text) - 1); + } else if (write_all(ssl, response_text, sizeof(response_text) - 1)) { + ++successful_requests; + printf("Verified client certificate after secure TLS renegotiation.\n"); + } + } else { + skip_shutdown = 1; + print_errors("Delayed client certificate authentication"); + } + } else if (strcmp(mode, "success") == 0) { + if (write_all(ssl, response_text, + sizeof(response_text) - 1)) { + ++successful_requests; + } + } else if (strcmp(mode, "early-hints") == 0) { + if (write_all(ssl, early_hints_response, + sizeof(early_hints_response) - 1) + && write_all(ssl, response_text, + sizeof(response_text) - 1)) { + ++successful_requests; + } + } else if (strcmp(mode, "close-delimited") == 0) { + if (write_all(ssl, close_delimited_response, + sizeof(close_delimited_response) - 1)) { + ++successful_requests; + } + skip_shutdown = 1; + } else if (strcmp(mode, "tls-truncate") == 0) { + if (write_all(ssl, response_text, + sizeof(response_text) - 1)) { + ++successful_requests; + } + skip_shutdown = 1; + } else { + ++successful_requests; + write_drip_response(ssl); + } + } else { + ERR_clear_error(); + } + if (ssl != NULL && !skip_shutdown) { + (void)SSL_shutdown(ssl); + } + X509_free(peer_certificate); + SSL_free(ssl); + closesocket(client); + } + + if (successful_requests != 1) { + fprintf(stderr, "Expected one successful request, received %d.\n", + successful_requests); + goto cleanup; + } + result = EXIT_SUCCESS; + +cleanup: + if (listener != INVALID_SOCKET) { + closesocket(listener); + } + SSL_CTX_free(ssl_ctx); + if (default_provider != NULL) { + OSSL_PROVIDER_unload(default_provider); + } + OSSL_LIB_CTX_free(libctx); + WSACleanup(); + return result; +} diff --git a/native/tests/native/cng_provider_test.c b/native/tests/native/cng_provider_test.c new file mode 100644 index 00000000..23927240 --- /dev/null +++ b/native/tests/native/cng_provider_test.c @@ -0,0 +1,526 @@ +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "cng_key.h" +#include "cng_provider_params.h" + +static const unsigned char test_message[] = + "OpenSSL provider signing with a non-exportable CNG key"; + +static void print_openssl_errors(const char *operation) +{ + fprintf(stderr, "%s failed.\n", operation); + ERR_print_errors_fp(stderr); +} + +static int split_provider_path(const char *input, + char *directory, + size_t directory_size, + char *module_name, + size_t module_name_size) +{ + char full_path[32768]; + char *slash; + char *dot; + DWORD length; + + length = GetFullPathNameA(input, (DWORD)sizeof(full_path), full_path, NULL); + if (length == 0 || length >= sizeof(full_path)) { + fprintf(stderr, "Unable to resolve provider DLL path: Win32 %lu.\n", + GetLastError()); + return 0; + } + + slash = strrchr(full_path, '\\'); + if (slash == NULL || slash == full_path) { + fprintf(stderr, "Provider DLL path must include a directory.\n"); + return 0; + } + *slash = '\0'; + + if (strlen(full_path) + 1 > directory_size + || strlen(slash + 1) + 1 > module_name_size) { + fprintf(stderr, "Provider DLL path is too long.\n"); + return 0; + } + strcpy_s(directory, directory_size, full_path); + strcpy_s(module_name, module_name_size, slash + 1); + + dot = strrchr(module_name, '.'); + if (dot != NULL && _stricmp(dot, ".dll") == 0) { + *dot = '\0'; + } + return module_name[0] != '\0'; +} + +static EVP_PKEY *create_cng_pkey(OSSL_LIB_CTX *libctx, + const char *property_query, + char *key_name, + char *provider_name) +{ + EVP_PKEY_CTX *context = NULL; + EVP_PKEY *key = NULL; + OSSL_PARAM params[3]; + + context = EVP_PKEY_CTX_new_from_name(libctx, "RSA", property_query); + if (context == NULL) { + print_openssl_errors("EVP_PKEY_CTX_new_from_name"); + goto cleanup; + } + if (EVP_PKEY_fromdata_init(context) <= 0) { + print_openssl_errors("EVP_PKEY_fromdata_init"); + goto cleanup; + } + + params[0] = OSSL_PARAM_construct_utf8_string(CNG_PARAM_KEY_NAME, + key_name, 0); + params[1] = OSSL_PARAM_construct_utf8_string(CNG_PARAM_PROVIDER_NAME, + provider_name, 0); + params[2] = OSSL_PARAM_construct_end(); + + if (EVP_PKEY_fromdata(context, &key, EVP_PKEY_KEYPAIR, params) <= 0) { + print_openssl_errors("EVP_PKEY_fromdata for the named CNG key"); + key = NULL; + } + +cleanup: + EVP_PKEY_CTX_free(context); + return key; +} + +static EVP_PKEY *create_public_key(OSSL_LIB_CTX *libctx, + EVP_PKEY *cng_key, + const char *property_query) +{ + EVP_PKEY_CTX *context = NULL; + EVP_PKEY *public_key = NULL; + OSSL_PARAM *public_params = NULL; + + if (EVP_PKEY_todata(cng_key, EVP_PKEY_PUBLIC_KEY, &public_params) <= 0) { + print_openssl_errors("EVP_PKEY_todata for public key"); + goto cleanup; + } + + context = EVP_PKEY_CTX_new_from_name(libctx, "RSA", property_query); + if (context == NULL || EVP_PKEY_fromdata_init(context) <= 0 + || EVP_PKEY_fromdata(context, &public_key, EVP_PKEY_PUBLIC_KEY, + public_params) + <= 0) { + print_openssl_errors("RSA public-key import"); + EVP_PKEY_free(public_key); + public_key = NULL; + } + +cleanup: + OSSL_PARAM_free(public_params); + EVP_PKEY_CTX_free(context); + return public_key; +} + +static int confirm_provider_private_export_blocked(EVP_PKEY *key) +{ + OSSL_PARAM *private_params = NULL; + int result; + + ERR_clear_error(); + result = EVP_PKEY_todata(key, EVP_PKEY_PRIVATE_KEY, &private_params); + OSSL_PARAM_free(private_params); + if (result > 0) { + fprintf(stderr, + "Provider unexpectedly exported private-key parameters.\n"); + return 0; + } + ERR_clear_error(); + return 1; +} + +static int confirm_public_key_duplication(OSSL_LIB_CTX *libctx, + EVP_PKEY *cng_key, + EVP_PKEY *verification_key, + const char *property_query) +{ + EVP_PKEY *public_key = NULL; + EVP_PKEY *duplicate = NULL; + EVP_PKEY_CTX *duplicate_context = NULL; + int result = 0; + + public_key = create_public_key(libctx, cng_key, property_query); + if (public_key == NULL) { + goto cleanup; + } + duplicate = EVP_PKEY_dup(public_key); + if (duplicate == NULL) { + print_openssl_errors("Public-only provider EVP_PKEY_dup"); + goto cleanup; + } + EVP_PKEY_free(public_key); + public_key = NULL; + + duplicate_context = EVP_PKEY_CTX_new_from_pkey( + libctx, duplicate, property_query); + if (duplicate_context == NULL + || EVP_PKEY_eq(duplicate, verification_key) != 1 + || EVP_PKEY_public_check(duplicate_context) != 1) { + print_openssl_errors("Duplicated RSA public-key validation"); + goto cleanup; + } + ERR_clear_error(); + if (EVP_PKEY_private_check(duplicate_context) != 0 + || !confirm_provider_private_export_blocked(duplicate)) { + fprintf(stderr, "Public-only duplicate unexpectedly has private material.\n"); + goto cleanup; + } + ERR_clear_error(); + printf("Public-only RSA duplication preserves independent public material.\n"); + result = 1; + +cleanup: + EVP_PKEY_CTX_free(duplicate_context); + EVP_PKEY_free(duplicate); + EVP_PKEY_free(public_key); + return result; +} + +static int sign_and_verify(OSSL_LIB_CTX *libctx, EVP_PKEY *signing_key, + EVP_PKEY *verification_key, + const char *signing_property_query, + const char *digest_name, const char *padding_name, + const char *signing_salt_name, + const char *verification_salt_name) +{ + EVP_MD_CTX *sign_context = NULL; + EVP_MD_CTX *verify_context = NULL; + unsigned char *signature = NULL; + size_t signature_length = 0; + char padding[16]; + char signing_salt[32]; + char verification_salt[32]; + OSSL_PARAM signing_params[4]; + OSSL_PARAM verification_params[3]; + int is_pss; + int result = 0; + size_t message_index; + static const unsigned char second_message[] = + "second message after context reuse"; + static const unsigned char third_message[] = + "message after digest parameter update"; + static const unsigned char fourth_message[] = + "message after updated context reuse"; + const unsigned char *messages[] = {test_message, second_message, + third_message, fourth_message}; + const size_t message_lengths[] = { + sizeof(test_message) - 1, sizeof(second_message) - 1, + sizeof(third_message) - 1, sizeof(fourth_message) - 1}; + char mgf1_digest[16]; + char updated_digest[] = "SHA512"; + OSSL_PARAM updates[2]; + const char *current_digest = digest_name; + + is_pss = _stricmp(padding_name, OSSL_PKEY_RSA_PAD_MODE_PSS) == 0; + if (strcpy_s(padding, sizeof(padding), padding_name) != 0) + { + return 0; + } + + signing_params[0] = OSSL_PARAM_construct_utf8_string( + OSSL_SIGNATURE_PARAM_PAD_MODE, padding, 0); + verification_params[0] = OSSL_PARAM_construct_utf8_string( + OSSL_SIGNATURE_PARAM_PAD_MODE, padding, 0); + if (is_pss) + { + if (strcpy_s(signing_salt, sizeof(signing_salt), signing_salt_name) != + 0 || + strcpy_s(verification_salt, sizeof(verification_salt), + verification_salt_name) != 0) + { + return 0; + } + signing_params[1] = OSSL_PARAM_construct_utf8_string( + OSSL_SIGNATURE_PARAM_PSS_SALTLEN, signing_salt, 0); + if (strcpy_s(mgf1_digest, sizeof(mgf1_digest), digest_name) != 0) + { + return 0; + } + signing_params[2] = OSSL_PARAM_construct_utf8_string( + OSSL_SIGNATURE_PARAM_MGF1_DIGEST, mgf1_digest, 0); + signing_params[3] = OSSL_PARAM_construct_end(); + verification_params[1] = OSSL_PARAM_construct_utf8_string( + OSSL_SIGNATURE_PARAM_PSS_SALTLEN, verification_salt, 0); + verification_params[2] = OSSL_PARAM_construct_end(); + } + else + { + signing_params[1] = OSSL_PARAM_construct_end(); + signing_params[2] = OSSL_PARAM_construct_end(); + verification_params[1] = OSSL_PARAM_construct_end(); + verification_params[2] = OSSL_PARAM_construct_end(); + } + + sign_context = EVP_MD_CTX_new(); + verify_context = EVP_MD_CTX_new(); + if (sign_context == NULL || verify_context == NULL) + { + print_openssl_errors("EVP_MD_CTX_new"); + goto cleanup; + } + + updates[0] = is_pss + ? OSSL_PARAM_construct_utf8_string( + OSSL_SIGNATURE_PARAM_MGF1_DIGEST, updated_digest, 0) + : OSSL_PARAM_construct_end(); + updates[1] = OSSL_PARAM_construct_end(); + for (message_index = 0; message_index < 4; ++message_index) + { + if (message_index == 2) + { + current_digest = updated_digest; + } + if ((message_index == 0 + ? EVP_DigestSignInit_ex(sign_context, NULL, digest_name, + libctx, signing_property_query, + signing_key, signing_params) + : EVP_DigestSignInit_ex( + sign_context, NULL, + message_index == 2 ? current_digest : NULL, NULL, NULL, + NULL, message_index == 2 ? updates : NULL)) <= 0 || + EVP_DigestSignUpdate(sign_context, messages[message_index], + message_lengths[message_index]) <= 0 || + EVP_DigestSignFinal(sign_context, NULL, &signature_length) <= 0) + { + print_openssl_errors("EVP_DigestSign size query"); + goto cleanup; + } + + free(signature); + signature = (unsigned char *)malloc(signature_length); + if (signature == NULL) + { + fprintf(stderr, "Unable to allocate the signature buffer.\n"); + goto cleanup; + } + if (EVP_DigestSignFinal(sign_context, signature, &signature_length) <= + 0) + { + print_openssl_errors("EVP_DigestSign"); + goto cleanup; + } + + if (EVP_DigestVerifyInit_ex(verify_context, NULL, current_digest, + libctx, "provider=default", + verification_key, + verification_params) <= 0 || + EVP_DigestVerifyUpdate(verify_context, messages[message_index], + message_lengths[message_index]) <= 0 || + EVP_DigestVerifyFinal(verify_context, signature, + signature_length) <= 0) + { + print_openssl_errors("Default-provider EVP_DigestVerify"); + goto cleanup; + } + + printf("Verified %s RSA signature with %s padding (%zu bytes, message " + "%zu).\n", + current_digest, padding_name, signature_length, + message_index + 1); + } + result = 1; + +cleanup: + free(signature); + EVP_MD_CTX_free(sign_context); + EVP_MD_CTX_free(verify_context); + return result; +} + +static int generate_owned_key_name(const char *provider_name, char *key_name, + size_t key_name_size) +{ + unsigned char random_bytes[16]; + char error[512] = {0}; + unsigned int attempt; + + for (attempt = 0; attempt < 8; ++attempt) + { + CNG_KEY *existing = NULL; + + if (!BCRYPT_SUCCESS(BCryptGenRandom(NULL, random_bytes, + sizeof(random_bytes), + BCRYPT_USE_SYSTEM_PREFERRED_RNG)) || + sprintf_s(key_name, key_name_size, + "openssl-cng-provider-test-%lu-" + "%02x%02x%02x%02x%02x%02x%02x%02x" + "%02x%02x%02x%02x%02x%02x%02x%02x", + GetCurrentProcessId(), random_bytes[0], random_bytes[1], + random_bytes[2], random_bytes[3], random_bytes[4], + random_bytes[5], random_bytes[6], random_bytes[7], + random_bytes[8], random_bytes[9], random_bytes[10], + random_bytes[11], random_bytes[12], random_bytes[13], + random_bytes[14], random_bytes[15]) < 0) + { + SecureZeroMemory(random_bytes, sizeof(random_bytes)); + return 0; + } + error[0] = '\0'; + if (!cng_key_open(provider_name, key_name, &existing, error, + sizeof(error))) { + SecureZeroMemory(random_bytes, sizeof(random_bytes)); + return 1; + } + cng_key_free(existing); + } + SecureZeroMemory(random_bytes, sizeof(random_bytes)); + return 0; +} + +int main(int argc, char **argv) +{ + static const char *digests[] = { "SHA256", "SHA384", "SHA512" }; + OSSL_LIB_CTX *libctx = NULL; + OSSL_PROVIDER *default_provider = NULL; + OSSL_PROVIDER *cng_provider = NULL; + EVP_PKEY *cng_key = NULL; + EVP_PKEY *public_key = NULL; + char provider_directory[32768]; + char module_name[256]; + char provider_property_query[320]; + char generated_key_name[128]; + char error[512] = { 0 }; + char *key_name; + char *cng_provider_name; + int key_created = 0; + int result = EXIT_FAILURE; + size_t digest_index; + + if (argc < 2 || argc > 3) { + fprintf(stderr, + "Usage: %s " + "[CNG-provider-name]\n", + argv[0]); + return EXIT_FAILURE; + } + + if (!split_provider_path(argv[1], provider_directory, + sizeof(provider_directory), module_name, + sizeof(module_name))) { + return EXIT_FAILURE; + } + if (sprintf_s(provider_property_query, + sizeof(provider_property_query), "provider=%s", + module_name) + < 0) { + return EXIT_FAILURE; + } + + cng_provider_name = + argc >= 3 ? argv[2] : CNG_DEFAULT_PROVIDER_NAME; + if (!generate_owned_key_name(cng_provider_name, generated_key_name, + sizeof(generated_key_name))) { + fprintf(stderr, "Unable to allocate a unique process-owned key name.\n"); + return EXIT_FAILURE; + } + key_name = generated_key_name; + + libctx = OSSL_LIB_CTX_new(); + if (libctx == NULL + || !OSSL_PROVIDER_set_default_search_path(libctx, + provider_directory)) { + print_openssl_errors("OpenSSL library context initialization"); + goto cleanup; + } + + default_provider = OSSL_PROVIDER_load(libctx, "default"); + cng_provider = OSSL_PROVIDER_load(libctx, module_name); + if (default_provider == NULL || cng_provider == NULL) { + print_openssl_errors("OSSL_PROVIDER_load"); + goto cleanup; + } + + cng_key = create_cng_pkey(libctx, provider_property_query, key_name, + cng_provider_name); + if (cng_key == NULL) { + goto cleanup; + } + key_created = 1; + if (EVP_PKEY_get_bits(cng_key) != 2048 + || EVP_PKEY_get_size(cng_key) != 256) { + fprintf(stderr, "Provider key metadata is not 2048-bit RSA.\n"); + goto cleanup; + } + + public_key = create_public_key(libctx, cng_key, "provider=default"); + if (public_key == NULL + || !confirm_provider_private_export_blocked(cng_key) + || !confirm_public_key_duplication( + libctx, cng_key, public_key, provider_property_query)) { + goto cleanup; + } + + if (!cng_private_export_is_blocked(cng_provider_name, key_name, error, + sizeof(error))) { + fprintf(stderr, "CNG private export check failed: %s\n", error); + goto cleanup; + } + + for (digest_index = 0; + digest_index < sizeof(digests) / sizeof(digests[0]); + ++digest_index) { + if (!sign_and_verify(libctx, cng_key, public_key, + provider_property_query, + digests[digest_index], + OSSL_PKEY_RSA_PAD_MODE_PKCSV15, NULL, NULL) + || !sign_and_verify(libctx, cng_key, public_key, + provider_property_query, + digests[digest_index], + OSSL_PKEY_RSA_PAD_MODE_PSS, + OSSL_PKEY_RSA_PSS_SALT_LEN_DIGEST, + OSSL_PKEY_RSA_PSS_SALT_LEN_DIGEST)) { + goto cleanup; + } + } + if (!sign_and_verify(libctx, cng_key, public_key, + provider_property_query, "SHA256", + OSSL_PKEY_RSA_PAD_MODE_PSS, "auto-digestmax", + OSSL_PKEY_RSA_PSS_SALT_LEN_DIGEST)) { + goto cleanup; + } + if (!sign_and_verify(libctx, cng_key, public_key, + provider_property_query, "SHA384", + OSSL_PKEY_RSA_PAD_MODE_PSS, "20", "20")) { + goto cleanup; + } + + printf("Private-key export is unavailable through OpenSSL and CNG.\n"); + result = EXIT_SUCCESS; + +cleanup: + EVP_PKEY_free(public_key); + EVP_PKEY_free(cng_key); + if (cng_provider != NULL) { + (void)OSSL_PROVIDER_unload(cng_provider); + } + if (default_provider != NULL) { + (void)OSSL_PROVIDER_unload(default_provider); + } + OSSL_LIB_CTX_free(libctx); + + if (key_created) { + error[0] = '\0'; + if (!cng_delete_named_key(cng_provider_name, key_name, error, + sizeof(error))) { + fprintf(stderr, "Test-key cleanup failed: %s\n", error); + result = EXIT_FAILURE; + } else { + printf("Deleted process-owned test CNG key '%s'.\n", key_name); + } + } + return result; +} diff --git a/native/tests/native/cng_test_pki.c b/native/tests/native/cng_test_pki.c new file mode 100644 index 00000000..e71264e0 --- /dev/null +++ b/native/tests/native/cng_test_pki.c @@ -0,0 +1,569 @@ +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "cng_provider_params.h" + +static void print_errors(const char *operation) +{ + fprintf(stderr, "%s failed.\n", operation); + ERR_print_errors_fp(stderr); +} + +static int split_provider_path(const char *input, + char *directory, + size_t directory_size, + char *module_name, + size_t module_name_size) +{ + char full_path[32768]; + char *slash; + char *dot; + DWORD length = + GetFullPathNameA(input, (DWORD)sizeof(full_path), full_path, NULL); + + if (length == 0 || length >= sizeof(full_path)) { + return 0; + } + slash = strrchr(full_path, '\\'); + if (slash == NULL || slash == full_path) { + return 0; + } + *slash = '\0'; + if (strcpy_s(directory, directory_size, full_path) != 0 + || strcpy_s(module_name, module_name_size, slash + 1) != 0) { + return 0; + } + dot = strrchr(module_name, '.'); + if (dot != NULL && _stricmp(dot, ".dll") == 0) { + *dot = '\0'; + } + return module_name[0] != '\0'; +} + +static int output_path(char *path, + size_t path_size, + const char *directory, + const char *name) +{ + return sprintf_s(path, path_size, "%s\\%s", directory, name) >= 0; +} + +static EVP_PKEY *create_cng_key(OSSL_LIB_CTX *libctx, + const char *property_query, + char *key_name, + char *provider_name) +{ + EVP_PKEY_CTX *context = + EVP_PKEY_CTX_new_from_name(libctx, "RSA", property_query); + EVP_PKEY *key = NULL; + OSSL_PARAM params[3]; + + params[0] = OSSL_PARAM_construct_utf8_string( + CNG_PARAM_KEY_NAME, key_name, 0); + params[1] = OSSL_PARAM_construct_utf8_string( + CNG_PARAM_PROVIDER_NAME, provider_name, 0); + params[2] = OSSL_PARAM_construct_end(); + if (context == NULL || EVP_PKEY_fromdata_init(context) <= 0 + || EVP_PKEY_fromdata(context, &key, EVP_PKEY_KEYPAIR, params) <= 0) { + print_errors("Creating the persisted CNG test key"); + EVP_PKEY_free(key); + key = NULL; + } + EVP_PKEY_CTX_free(context); + return key; +} + +static EVP_PKEY *load_store_key(OSSL_LIB_CTX *libctx, const char *uri) +{ + OSSL_STORE_CTX *store = + OSSL_STORE_open_ex(uri, libctx, + "provider=microsoft_identity_native_provider", NULL, NULL, + NULL, NULL, NULL); + OSSL_STORE_INFO *info = NULL; + EVP_PKEY *key = NULL; + + if (store == NULL || !OSSL_STORE_expect(store, OSSL_STORE_INFO_PKEY)) { + print_errors("Opening the CNG STORE URI"); + goto cleanup; + } + while (!OSSL_STORE_eof(store)) { + info = OSSL_STORE_load(store); + if (info == NULL) { + break; + } + if (OSSL_STORE_INFO_get_type(info) == OSSL_STORE_INFO_PKEY) { + key = OSSL_STORE_INFO_get1_PKEY(info); + OSSL_STORE_INFO_free(info); + info = NULL; + break; + } + OSSL_STORE_INFO_free(info); + info = NULL; + } + if (key == NULL) { + print_errors("Loading the CNG STORE key"); + } + +cleanup: + OSSL_STORE_INFO_free(info); + if (store != NULL) { + OSSL_STORE_close(store); + } + return key; +} + +static EVP_PKEY *create_default_public_key(OSSL_LIB_CTX *libctx, + EVP_PKEY *source) +{ + EVP_PKEY_CTX *context = NULL; + EVP_PKEY *public_key = NULL; + OSSL_PARAM *params = NULL; + + if (EVP_PKEY_todata(source, EVP_PKEY_PUBLIC_KEY, ¶ms) <= 0) { + goto cleanup; + } + context = EVP_PKEY_CTX_new_from_name(libctx, "RSA", "provider=default"); + if (context == NULL || EVP_PKEY_fromdata_init(context) <= 0 + || EVP_PKEY_fromdata(context, &public_key, EVP_PKEY_PUBLIC_KEY, + params) + <= 0) { + EVP_PKEY_free(public_key); + public_key = NULL; + } + +cleanup: + OSSL_PARAM_free(params); + EVP_PKEY_CTX_free(context); + if (public_key == NULL) { + print_errors("Converting the CNG public key"); + } + return public_key; +} + +static EVP_PKEY *generate_rsa_key(OSSL_LIB_CTX *libctx) +{ + EVP_PKEY_CTX *context = + EVP_PKEY_CTX_new_from_name(libctx, "RSA", "provider=default"); + EVP_PKEY *key = NULL; + unsigned int bits = 2048; + OSSL_PARAM params[] = { + OSSL_PARAM_uint(OSSL_PKEY_PARAM_RSA_BITS, &bits), + OSSL_PARAM_END + }; + + if (context == NULL || EVP_PKEY_keygen_init(context) <= 0 + || EVP_PKEY_CTX_set_params(context, params) <= 0 + || EVP_PKEY_generate(context, &key) <= 0) { + print_errors("Generating a test RSA key"); + EVP_PKEY_free(key); + key = NULL; + } + EVP_PKEY_CTX_free(context); + return key; +} + +static X509_NAME *create_name(const char *common_name) +{ + X509_NAME *name = X509_NAME_new(); + + if (name == NULL + || X509_NAME_add_entry_by_txt( + name, "O", MBSTRING_ASC, + (const unsigned char *)"OpenSSL CNG local tests", -1, -1, 0) + != 1 + || X509_NAME_add_entry_by_txt( + name, "CN", MBSTRING_ASC, + (const unsigned char *)common_name, -1, -1, 0) + != 1) { + X509_NAME_free(name); + return NULL; + } + return name; +} + +static int add_extension(X509 *certificate, + X509 *issuer, + int nid, + const char *value) +{ + X509V3_CTX context; + X509_EXTENSION *extension; + int result; + + X509V3_set_ctx(&context, issuer, certificate, NULL, NULL, 0); + extension = X509V3_EXT_conf_nid(NULL, &context, nid, (char *)value); + if (extension == NULL) { + return 0; + } + result = X509_add_ext(certificate, extension, -1); + X509_EXTENSION_free(extension); + return result == 1; +} + +static int sign_certificate(OSSL_LIB_CTX *libctx, + X509 *certificate, + EVP_PKEY *issuer_key) +{ + EVP_MD_CTX *context = EVP_MD_CTX_new(); + int result = + context != NULL + && EVP_DigestSignInit_ex(context, NULL, "SHA256", libctx, + "provider=default", issuer_key, NULL) + > 0 + && X509_sign_ctx(certificate, context) > 0; + + EVP_MD_CTX_free(context); + return result; +} + +static X509 *create_certificate(OSSL_LIB_CTX *libctx, + long serial, + X509_NAME *subject, + EVP_PKEY *public_key, + X509 *issuer, + EVP_PKEY *issuer_key, + int certificate_type) +{ + X509 *certificate = X509_new_ex(libctx, "provider=default"); + X509_NAME *issuer_name = + issuer == NULL ? subject : X509_get_subject_name(issuer); + int extensions_ok; + + if ((certificate_type != 0 && issuer == NULL) || certificate == NULL + || X509_set_version(certificate, 2) != 1 + || ASN1_INTEGER_set(X509_get_serialNumber(certificate), serial) != 1 + || X509_gmtime_adj(X509_getm_notBefore(certificate), -60) == NULL + || X509_gmtime_adj(X509_getm_notAfter(certificate), + 24L * 60L * 60L) + == NULL + || X509_set_subject_name(certificate, subject) != 1 + || X509_set_issuer_name(certificate, issuer_name) != 1 + || X509_set_pubkey(certificate, public_key) != 1) { + X509_free(certificate); + return NULL; + } + + if (certificate_type == 0) { + extensions_ok = + add_extension(certificate, certificate, NID_basic_constraints, + "critical,CA:TRUE") + && add_extension(certificate, certificate, NID_key_usage, + "critical,keyCertSign,cRLSign") + && add_extension(certificate, certificate, + NID_subject_key_identifier, "hash"); + } else if (certificate_type == 3) { + extensions_ok = + add_extension(certificate, issuer, NID_basic_constraints, + "critical,CA:TRUE,pathlen:0") + && add_extension(certificate, issuer, NID_key_usage, + "critical,keyCertSign,cRLSign") + && add_extension(certificate, issuer, + NID_subject_key_identifier, "hash") + && add_extension(certificate, issuer, + NID_authority_key_identifier, "keyid:always"); + } else if (certificate_type == 1) { + extensions_ok = + add_extension(certificate, issuer, NID_basic_constraints, + "critical,CA:FALSE") + && add_extension(certificate, issuer, NID_key_usage, + "critical,digitalSignature,keyEncipherment") + && add_extension(certificate, issuer, NID_ext_key_usage, + "serverAuth") + && add_extension(certificate, issuer, NID_subject_alt_name, + "DNS:localhost") + && add_extension(certificate, issuer, + NID_authority_key_identifier, "keyid:always"); + } else { + extensions_ok = + add_extension(certificate, issuer, NID_basic_constraints, + "critical,CA:FALSE") + && add_extension(certificate, issuer, NID_key_usage, + "critical,digitalSignature") + && add_extension(certificate, issuer, NID_ext_key_usage, + "clientAuth") + && add_extension(certificate, issuer, + NID_authority_key_identifier, "keyid:always"); + } + if (!extensions_ok + || !sign_certificate(libctx, certificate, issuer_key)) { + X509_free(certificate); + return NULL; + } + return certificate; +} + +static X509_REQ *create_cng_csr(OSSL_LIB_CTX *libctx, + EVP_PKEY *cng_key, + EVP_PKEY *public_key, + X509_NAME *subject, + const char *property_query) +{ + X509_REQ *request = X509_REQ_new_ex(libctx, "provider=default"); + EVP_MD_CTX *sign_context = EVP_MD_CTX_new(); + + if (request == NULL || sign_context == NULL + || X509_REQ_set_version(request, 0) != 1 + || X509_REQ_set_subject_name(request, subject) != 1 + || X509_REQ_set_pubkey(request, public_key) != 1) { + print_errors("Preparing the CNG-signed CSR"); + X509_REQ_free(request); + EVP_MD_CTX_free(sign_context); + return NULL; + } + if (EVP_DigestSignInit_ex(sign_context, NULL, "SHA256", libctx, + property_query, cng_key, NULL) + <= 0) { + print_errors("Initializing the CNG-signed CSR"); + X509_REQ_free(request); + EVP_MD_CTX_free(sign_context); + return NULL; + } + if (X509_REQ_sign_ctx(request, sign_context) <= 0) { + print_errors("Creating and verifying the CNG-signed CSR"); + X509_REQ_free(request); + request = NULL; + } else { + if (X509_REQ_verify(request, public_key) != 1) { + print_errors("Verifying the CNG-signed CSR"); + X509_REQ_free(request); + request = NULL; + } + } + EVP_MD_CTX_free(sign_context); + return request; +} + +static int write_certificate(const char *path, X509 *certificate) +{ + BIO *output = BIO_new_file(path, "wb"); + int result = + output != NULL && PEM_write_bio_X509(output, certificate) == 1; + + BIO_free(output); + return result; +} + +static int write_certificate_chain(const char *path, + X509 *certificate, + X509 *intermediate) +{ + BIO *output = BIO_new_file(path, "wb"); + int result = + output != NULL && PEM_write_bio_X509(output, certificate) == 1 + && PEM_write_bio_X509(output, intermediate) == 1; + + BIO_free(output); + return result; +} + +static int write_private_key(const char *path, EVP_PKEY *key) +{ + BIO *output = BIO_new_file(path, "wb"); + int result = output != NULL + && PEM_write_bio_PrivateKey(output, key, NULL, NULL, 0, + NULL, NULL) + == 1; + + BIO_free(output); + return result; +} + +static int write_csr(const char *path, X509_REQ *request) +{ + BIO *output = BIO_new_file(path, "wb"); + int result = + output != NULL && PEM_write_bio_X509_REQ(output, request) == 1; + + BIO_free(output); + return result; +} + +int main(int argc, char **argv) +{ + OSSL_LIB_CTX *libctx = NULL; + OSSL_PROVIDER *default_provider = NULL; + OSSL_PROVIDER *cng_provider = NULL; + EVP_PKEY *created_cng_key = NULL; + EVP_PKEY *cng_key = NULL; + EVP_PKEY *cng_public = NULL; + EVP_PKEY *ca_key = NULL; + EVP_PKEY *intermediate_key = NULL; + EVP_PKEY *server_key = NULL; + EVP_PKEY *wrong_key = NULL; + X509_NAME *ca_name = NULL; + X509_NAME *intermediate_name = NULL; + X509_NAME *server_name = NULL; + X509_NAME *client_name = NULL; + X509_NAME *wrong_name = NULL; + X509 *ca_certificate = NULL; + X509 *intermediate_certificate = NULL; + X509 *server_certificate = NULL; + X509 *client_certificate = NULL; + X509 *wrong_certificate = NULL; + X509_REQ *client_request = NULL; + char provider_directory[32768]; + char module_name[256]; + char property_query[320]; + char path[32768]; + int result = EXIT_FAILURE; + + if (argc != 7) { + fprintf(stderr, + "Usage: %s " + " \n", + argv[0]); + return EXIT_FAILURE; + } + if (_mkdir(argv[2]) != 0 && errno != EEXIST) { + fprintf(stderr, "Unable to create output directory '%s'.\n", argv[2]); + return EXIT_FAILURE; + } + if (!split_provider_path(argv[1], provider_directory, + sizeof(provider_directory), module_name, + sizeof(module_name)) + || sprintf_s(property_query, sizeof(property_query), "provider=%s", + module_name) + < 0) { + fprintf(stderr, "The provider path is invalid.\n"); + return EXIT_FAILURE; + } + + libctx = OSSL_LIB_CTX_new(); + if (libctx == NULL + || !OSSL_PROVIDER_set_default_search_path(libctx, + provider_directory) + || (default_provider = OSSL_PROVIDER_load(libctx, "default")) == NULL + || (cng_provider = OSSL_PROVIDER_load(libctx, module_name)) == NULL) { + print_errors("Loading providers for local PKI generation"); + goto cleanup; + } + + created_cng_key = + create_cng_key(libctx, property_query, argv[3], argv[4]); + if (created_cng_key == NULL) { + goto cleanup; + } + EVP_PKEY_free(created_cng_key); + created_cng_key = NULL; + cng_key = load_store_key(libctx, argv[5]); + cng_public = create_default_public_key(libctx, cng_key); + ca_key = generate_rsa_key(libctx); + intermediate_key = generate_rsa_key(libctx); + server_key = generate_rsa_key(libctx); + wrong_key = generate_rsa_key(libctx); + ca_name = create_name("OpenSSL CNG Local Test CA"); + intermediate_name = create_name("OpenSSL CNG Client Intermediate"); + server_name = create_name("localhost"); + client_name = create_name("CNG mTLS Client"); + wrong_name = create_name("Wrong mTLS Client"); + if (cng_key == NULL || cng_public == NULL || ca_key == NULL + || intermediate_key == NULL || server_key == NULL + || wrong_key == NULL || ca_name == NULL || intermediate_name == NULL + || server_name == NULL || client_name == NULL || wrong_name == NULL) { + goto cleanup; + } + + ca_certificate = create_certificate(libctx, 1, ca_name, ca_key, NULL, + ca_key, 0); + intermediate_certificate = create_certificate( + libctx, 2, intermediate_name, intermediate_key, ca_certificate, + ca_key, 3); + server_certificate = create_certificate( + libctx, 3, server_name, server_key, ca_certificate, ca_key, 1); + client_certificate = create_certificate( + libctx, 4, client_name, cng_public, intermediate_certificate, + intermediate_key, 2); + wrong_certificate = create_certificate( + libctx, 5, wrong_name, wrong_key, ca_certificate, ca_key, 2); + client_request = create_cng_csr(libctx, cng_key, cng_public, client_name, + property_query); + if (ca_certificate == NULL || intermediate_certificate == NULL + || server_certificate == NULL + || client_certificate == NULL || wrong_certificate == NULL + || client_request == NULL + || X509_check_private_key(client_certificate, cng_key) != 1) { + print_errors("Creating the local test certificates"); + goto cleanup; + } + + if (!output_path(path, sizeof(path), argv[2], "ca.pem") + || !write_certificate(path, ca_certificate) + || !output_path(path, sizeof(path), argv[2], "server-cert.pem") + || !write_certificate(path, server_certificate) + || !output_path(path, sizeof(path), argv[2], "server-key.pem") + || !write_private_key(path, server_key) + || !output_path(path, sizeof(path), argv[2], "client-cert.pem") + || !write_certificate_chain(path, client_certificate, + intermediate_certificate) + || !output_path(path, sizeof(path), argv[2], + "wrong-client-cert.pem") + || !write_certificate(path, wrong_certificate) + || !output_path(path, sizeof(path), argv[2], "client.csr.pem") + || !write_csr(path, client_request) + || !output_path(path, sizeof(path), argv[2], argv[6])) { + print_errors("Writing local PKI artifacts"); + goto cleanup; + } + { + FILE *marker = NULL; + int marker_ok; + if (fopen_s(&marker, path, "wb") != 0 || marker == NULL) { + fprintf(stderr, "Unable to write the PKI marker.\n"); + goto cleanup; + } + marker_ok = fputs("ready\n", marker) >= 0; + marker_ok = fclose(marker) == 0 && marker_ok; + marker = NULL; + if (!marker_ok) { + if (marker != NULL) { + fclose(marker); + } + fprintf(stderr, "Unable to write the PKI marker.\n"); + goto cleanup; + } + } + + printf("Generated local test PKI in %s using STORE key %s.\n", argv[2], + argv[3]); + result = EXIT_SUCCESS; + +cleanup: + X509_REQ_free(client_request); + X509_free(wrong_certificate); + X509_free(client_certificate); + X509_free(server_certificate); + X509_free(intermediate_certificate); + X509_free(ca_certificate); + X509_NAME_free(wrong_name); + X509_NAME_free(client_name); + X509_NAME_free(server_name); + X509_NAME_free(intermediate_name); + X509_NAME_free(ca_name); + EVP_PKEY_free(wrong_key); + EVP_PKEY_free(server_key); + EVP_PKEY_free(intermediate_key); + EVP_PKEY_free(ca_key); + EVP_PKEY_free(cng_public); + EVP_PKEY_free(cng_key); + EVP_PKEY_free(created_cng_key); + if (cng_provider != NULL) { + OSSL_PROVIDER_unload(cng_provider); + } + if (default_provider != NULL) { + OSSL_PROVIDER_unload(default_provider); + } + OSSL_LIB_CTX_free(libctx); + return result; +} diff --git a/native/tests/native/support/cng_key.c b/native/tests/native/support/cng_key.c new file mode 100644 index 00000000..aa9ba5dc --- /dev/null +++ b/native/tests/native/support/cng_key.c @@ -0,0 +1,733 @@ +#include "cng_key.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +struct cng_key_st { + volatile LONG references; + SRWLOCK sign_lock; + NCRYPT_PROV_HANDLE provider; + NCRYPT_KEY_HANDLE key; + char *provider_name; + char *key_name; + unsigned int bits; + unsigned int security_bits; + unsigned char *modulus; + size_t modulus_length; + unsigned char *exponent; + size_t exponent_length; +}; + +static void secure_free_string(char *value) +{ + if (value != NULL) { + SecureZeroMemory(value, strlen(value)); + free(value); + } +} + +static void set_error(char *error, size_t error_size, const char *format, ...) +{ + va_list args; + + if (error == NULL || error_size == 0) { + return; + } + + va_start(args, format); + (void)vsnprintf(error, error_size, format, args); + va_end(args); + error[error_size - 1] = '\0'; +} + +static char *duplicate_string(const char *value) +{ + size_t length; + char *copy; + + if (value == NULL) { + return NULL; + } + + length = strlen(value) + 1; + copy = (char *)malloc(length); + if (copy != NULL) { + memcpy(copy, value, length); + } + return copy; +} + +static wchar_t *utf8_to_wide(const char *value, char *error, size_t error_size) +{ + int required; + wchar_t *wide; + + if (value == NULL || value[0] == '\0') { + set_error(error, error_size, "A non-empty UTF-8 name is required."); + return NULL; + } + + required = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, + NULL, 0); + if (required <= 0) { + set_error(error, error_size, + "MultiByteToWideChar size query failed with Win32 error %lu.", + GetLastError()); + return NULL; + } + + wide = (wchar_t *)calloc((size_t)required, sizeof(wchar_t)); + if (wide == NULL) { + set_error(error, error_size, "Out of memory converting a CNG name."); + return NULL; + } + + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, wide, + required) != required) { + set_error(error, error_size, + "MultiByteToWideChar conversion failed with Win32 error %lu.", + GetLastError()); + free(wide); + return NULL; + } + + return wide; +} + +static unsigned int rsa_security_bits(unsigned int bits) +{ + if (bits >= 15360) { + return 256; + } + if (bits >= 7680) { + return 192; + } + if (bits >= 3072) { + return 128; + } + if (bits >= 2048) { + return 112; + } + if (bits >= 1024) { + return 80; + } + return 0; +} + +static int is_missing_key_status(SECURITY_STATUS status) +{ + return status == NTE_BAD_KEYSET || status == NTE_NOT_FOUND; +} + +static int read_dword_property(NCRYPT_KEY_HANDLE key, + LPCWSTR property, + const char *property_name, + DWORD *value, + char *error, + size_t error_size) +{ + DWORD result_size = 0; + SECURITY_STATUS status; + + status = NCryptGetProperty(key, property, (PBYTE)value, sizeof(*value), + &result_size, 0); + if (status != ERROR_SUCCESS || result_size != sizeof(*value)) { + set_error(error, error_size, + "NCryptGetProperty(%s) failed with status 0x%08lX.", + property_name, (unsigned long)status); + return 0; + } + + return 1; +} + +static int export_public_blob(CNG_KEY *key, char *error, size_t error_size) +{ + SECURITY_STATUS status; + DWORD blob_size = 0; + DWORD written = 0; + unsigned char *blob = NULL; + BCRYPT_RSAKEY_BLOB *header; + size_t expected_size; + unsigned char *cursor; + + status = NCryptExportKey(key->key, 0, BCRYPT_RSAPUBLIC_BLOB, NULL, NULL, 0, + &blob_size, 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptExportKey public size query failed with status " + "0x%08lX.", + (unsigned long)status); + return 0; + } + + blob = (unsigned char *)malloc(blob_size); + if (blob == NULL) { + set_error(error, error_size, "Out of memory exporting the public key."); + return 0; + } + + status = NCryptExportKey(key->key, 0, BCRYPT_RSAPUBLIC_BLOB, NULL, blob, + blob_size, &written, 0); + if (status != ERROR_SUCCESS || written != blob_size) { + set_error(error, error_size, + "NCryptExportKey public export failed with status 0x%08lX.", + (unsigned long)status); + free(blob); + return 0; + } + + if (blob_size < sizeof(*header)) { + set_error(error, error_size, "CNG returned a truncated RSA public blob."); + free(blob); + return 0; + } + + header = (BCRYPT_RSAKEY_BLOB *)blob; + expected_size = sizeof(*header) + (size_t)header->cbPublicExp + + (size_t)header->cbModulus; + if (header->Magic != BCRYPT_RSAPUBLIC_MAGIC + || header->BitLength == 0 + || header->cbPublicExp == 0 + || header->cbModulus == 0 + || expected_size != blob_size) { + set_error(error, error_size, "CNG returned an invalid RSA public blob."); + free(blob); + return 0; + } + + key->exponent = (unsigned char *)malloc(header->cbPublicExp); + key->modulus = (unsigned char *)malloc(header->cbModulus); + if (key->exponent == NULL || key->modulus == NULL) { + set_error(error, error_size, + "Out of memory retaining the RSA public key."); + free(blob); + return 0; + } + + cursor = blob + sizeof(*header); + memcpy(key->exponent, cursor, header->cbPublicExp); + cursor += header->cbPublicExp; + memcpy(key->modulus, cursor, header->cbModulus); + key->exponent_length = header->cbPublicExp; + key->modulus_length = header->cbModulus; + key->bits = header->BitLength; + key->security_bits = rsa_security_bits(key->bits); + free(blob); + return 1; +} + +static int cng_key_open_internal(const char *provider_name, + const char *key_name, + unsigned int bits, + int create_if_missing, + CNG_KEY **out_key, + char *error, + size_t error_size) +{ + CNG_KEY *result = NULL; + wchar_t *provider_wide = NULL; + wchar_t *key_wide = NULL; + SECURITY_STATUS status; + DWORD export_policy = 0; + DWORD key_usage = NCRYPT_ALLOW_SIGNING_FLAG; + DWORD actual_key_usage = 0; + int created = 0; + + if (out_key == NULL + || (create_if_missing && bits < 2048U)) { + set_error(error, error_size, "Invalid CNG key arguments."); + return 0; + } + *out_key = NULL; + + if (provider_name == NULL || provider_name[0] == '\0') { + provider_name = CNG_DEFAULT_PROVIDER_NAME; + } + if (key_name == NULL || key_name[0] == '\0') { + key_name = CNG_DEFAULT_KEY_NAME; + } + + provider_wide = utf8_to_wide(provider_name, error, error_size); + key_wide = utf8_to_wide(key_name, error, error_size); + if (provider_wide == NULL || key_wide == NULL) { + goto cleanup; + } + + result = (CNG_KEY *)calloc(1, sizeof(*result)); + if (result == NULL) { + set_error(error, error_size, "Out of memory creating a CNG key object."); + goto cleanup; + } + result->references = 1; + InitializeSRWLock(&result->sign_lock); + + result->provider_name = duplicate_string(provider_name); + result->key_name = duplicate_string(key_name); + if (result->provider_name == NULL || result->key_name == NULL) { + set_error(error, error_size, "Out of memory retaining CNG key names."); + goto cleanup; + } + + status = NCryptOpenStorageProvider(&result->provider, provider_wide, 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptOpenStorageProvider failed with status 0x%08lX.", + (unsigned long)status); + goto cleanup; + } + + status = NCryptOpenKey(result->provider, &result->key, key_wide, 0, 0); + if (is_missing_key_status(status)) { + if (!create_if_missing) { + set_error(error, error_size, + "The named CNG key does not exist."); + goto cleanup; + } + status = NCryptCreatePersistedKey(result->provider, &result->key, + NCRYPT_RSA_ALGORITHM, key_wide, 0, 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptCreatePersistedKey failed with status 0x%08lX.", + (unsigned long)status); + goto cleanup; + } + created = 1; + + status = NCryptSetProperty(result->key, NCRYPT_LENGTH_PROPERTY, + (PBYTE)&bits, sizeof(bits), 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptSetProperty(%s) failed with status 0x%08lX.", + "Length", (unsigned long)status); + goto cleanup; + } + + status = NCryptSetProperty(result->key, NCRYPT_KEY_USAGE_PROPERTY, + (PBYTE)&key_usage, sizeof(key_usage), 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptSetProperty(%s) failed with status 0x%08lX.", + "Key Usage", (unsigned long)status); + goto cleanup; + } + + status = NCryptSetProperty(result->key, NCRYPT_EXPORT_POLICY_PROPERTY, + (PBYTE)&export_policy, + sizeof(export_policy), 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptSetProperty(%s) failed with status 0x%08lX.", + "Export Policy", (unsigned long)status); + goto cleanup; + } + + status = NCryptFinalizeKey(result->key, 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptFinalizeKey failed with status 0x%08lX.", + (unsigned long)status); + goto cleanup; + } + } else if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptOpenKey failed with status 0x%08lX.", + (unsigned long)status); + goto cleanup; + } + + if (!read_dword_property(result->key, NCRYPT_EXPORT_POLICY_PROPERTY, + "Export Policy", &export_policy, error, + error_size)) { + goto cleanup; + } + if (export_policy != 0) { + set_error(error, error_size, + "The persisted key has export policy 0x%08lX; a " + "non-exportable private key is required.", + (unsigned long)export_policy); + goto cleanup; + } + + if (!read_dword_property(result->key, NCRYPT_KEY_USAGE_PROPERTY, + "Key Usage", &actual_key_usage, error, + error_size)) { + goto cleanup; + } + if (actual_key_usage != NCRYPT_ALLOW_ALL_USAGES + && (actual_key_usage & NCRYPT_ALLOW_SIGNING_FLAG) == 0) { + set_error(error, error_size, + "The persisted key does not allow RSA signing."); + goto cleanup; + } + + if (!export_public_blob(result, error, error_size)) { + goto cleanup; + } + if (result->bits < 2048U || result->security_bits < 112U) { + set_error(error, error_size, + "The persisted RSA key is too small (%u bits).", + result->bits); + goto cleanup; + } + if (bits != 0 && result->bits != bits) { + set_error(error, error_size, + "The persisted key is %u bits; %u bits were requested.", + result->bits, bits); + goto cleanup; + } + + free(provider_wide); + free(key_wide); + *out_key = result; + return 1; + +cleanup: + free(provider_wide); + free(key_wide); + if (created && result != NULL && result->key != 0) { + if (NCryptDeleteKey(result->key, 0) == ERROR_SUCCESS) { + result->key = 0; + } + } + cng_key_free(result); + return 0; +} + +int cng_key_open_or_create(const char *provider_name, + const char *key_name, + unsigned int bits, + CNG_KEY **out_key, + char *error, + size_t error_size) +{ + return cng_key_open_internal(provider_name, key_name, bits, 1, out_key, + error, error_size); +} + +int cng_key_open(const char *provider_name, + const char *key_name, + CNG_KEY **out_key, + char *error, + size_t error_size) +{ + return cng_key_open_internal(provider_name, key_name, 0, 0, out_key, + error, error_size); +} + +int cng_key_up_ref(CNG_KEY *key) +{ + LONG current; + + if (key == NULL) { + return 0; + } + current = InterlockedCompareExchange(&key->references, 0, 0); + while (current > 0) { + if (current == LONG_MAX) { + return 0; + } + LONG observed = InterlockedCompareExchange( + &key->references, current + 1, current); + if (observed == current) { + return 1; + } + current = observed; + } + return 0; +} + +void cng_key_free(CNG_KEY *key) +{ + if (key == NULL) { + return; + } + if (InterlockedDecrement(&key->references) != 0) { + return; + } + + if (key->key != 0) { + (void)NCryptFreeObject(key->key); + } + if (key->provider != 0) { + (void)NCryptFreeObject(key->provider); + } + secure_free_string(key->provider_name); + secure_free_string(key->key_name); + free(key->modulus); + free(key->exponent); + SecureZeroMemory(key, sizeof(*key)); + free(key); +} + +const char *cng_key_provider_name(const CNG_KEY *key) +{ + return key == NULL ? NULL : key->provider_name; +} + +const char *cng_key_name(const CNG_KEY *key) +{ + return key == NULL ? NULL : key->key_name; +} + +unsigned int cng_key_bits(const CNG_KEY *key) +{ + return key == NULL ? 0 : key->bits; +} + +unsigned int cng_key_security_bits(const CNG_KEY *key) +{ + return key == NULL ? 0 : key->security_bits; +} + +size_t cng_key_max_signature_size(const CNG_KEY *key) +{ + return key == NULL ? 0 : key->modulus_length; +} + +const unsigned char *cng_key_public_modulus(const CNG_KEY *key, + size_t *length) +{ + if (length != NULL) { + *length = key == NULL ? 0 : key->modulus_length; + } + return key == NULL ? NULL : key->modulus; +} + +const unsigned char *cng_key_public_exponent(const CNG_KEY *key, + size_t *length) +{ + if (length != NULL) { + *length = key == NULL ? 0 : key->exponent_length; + } + return key == NULL ? NULL : key->exponent; +} + +int cng_key_sign_hash(CNG_KEY *key, + LPCWSTR bcrypt_digest_name, + CNG_RSA_PADDING padding, + ULONG pss_salt_length, + const unsigned char *hash, + size_t hash_length, + unsigned char *signature, + size_t signature_size, + size_t *signature_length, + char *error, + size_t error_size) +{ + SECURITY_STATUS status; + DWORD required = 0; + DWORD written = 0; + DWORD flags; + void *padding_info; + BCRYPT_PKCS1_PADDING_INFO pkcs1_info; + BCRYPT_PSS_PADDING_INFO pss_info; + + if (key == NULL || key->key == 0 || bcrypt_digest_name == NULL + || hash == NULL || hash_length > MAXDWORD + || signature_length == NULL || signature_size > MAXDWORD) { + set_error(error, error_size, "Invalid NCryptSignHash arguments."); + return 0; + } + + if (padding == CNG_RSA_PADDING_PKCS1) { + pkcs1_info.pszAlgId = bcrypt_digest_name; + padding_info = &pkcs1_info; + flags = NCRYPT_PAD_PKCS1_FLAG; + } else if (padding == CNG_RSA_PADDING_PSS) { + pss_info.pszAlgId = bcrypt_digest_name; + pss_info.cbSalt = pss_salt_length; + padding_info = &pss_info; + flags = NCRYPT_PAD_PSS_FLAG; + } else { + set_error(error, error_size, "Unsupported CNG RSA padding mode."); + return 0; + } + + AcquireSRWLockExclusive(&key->sign_lock); + status = NCryptSignHash(key->key, padding_info, (PBYTE)hash, + (DWORD)hash_length, NULL, 0, &required, flags); + if (status != ERROR_SUCCESS) { + ReleaseSRWLockExclusive(&key->sign_lock); + set_error(error, error_size, + "NCryptSignHash size query failed with status 0x%08lX.", + (unsigned long)status); + return 0; + } + + *signature_length = required; + if (signature == NULL) { + ReleaseSRWLockExclusive(&key->sign_lock); + return 1; + } + if (signature_size < required) { + ReleaseSRWLockExclusive(&key->sign_lock); + set_error(error, error_size, + "Signature buffer is %zu bytes; %lu bytes are required.", + signature_size, (unsigned long)required); + return 0; + } + + status = NCryptSignHash(key->key, padding_info, (PBYTE)hash, + (DWORD)hash_length, signature, + (DWORD)signature_size, &written, flags); + ReleaseSRWLockExclusive(&key->sign_lock); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptSignHash failed with status 0x%08lX.", + (unsigned long)status); + return 0; + } + + *signature_length = written; + return 1; +} + +int cng_private_export_is_blocked(const char *provider_name, + const char *key_name, + char *error, + size_t error_size) +{ + NCRYPT_PROV_HANDLE provider = 0; + NCRYPT_KEY_HANDLE key = 0; + wchar_t *provider_wide = NULL; + wchar_t *key_wide = NULL; + SECURITY_STATUS status; + DWORD private_blob_size = 0; + DWORD export_policy = 0; + int result = 0; + + if (provider_name == NULL || provider_name[0] == '\0') { + provider_name = CNG_DEFAULT_PROVIDER_NAME; + } + if (key_name == NULL || key_name[0] == '\0') { + key_name = CNG_DEFAULT_KEY_NAME; + } + + provider_wide = utf8_to_wide(provider_name, error, error_size); + key_wide = utf8_to_wide(key_name, error, error_size); + if (provider_wide == NULL || key_wide == NULL) { + goto cleanup; + } + + status = NCryptOpenStorageProvider(&provider, provider_wide, 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptOpenStorageProvider failed with status 0x%08lX.", + (unsigned long)status); + goto cleanup; + } + status = NCryptOpenKey(provider, &key, key_wide, 0, 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptOpenKey failed with status 0x%08lX.", + (unsigned long)status); + goto cleanup; + } + if (!read_dword_property(key, NCRYPT_EXPORT_POLICY_PROPERTY, + "Export Policy", &export_policy, error, + error_size)) { + goto cleanup; + } + if (export_policy != 0) { + set_error(error, error_size, + "The persisted key export policy is 0x%08lX, not zero.", + (unsigned long)export_policy); + goto cleanup; + } + + status = NCryptExportKey(key, 0, BCRYPT_RSAPRIVATE_BLOB, NULL, NULL, 0, + &private_blob_size, 0); + if (status == ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptExportKey unexpectedly allowed a private-export " + "size query."); + goto cleanup; + } + + result = 1; + +cleanup: + if (key != 0) { + (void)NCryptFreeObject(key); + } + if (provider != 0) { + (void)NCryptFreeObject(provider); + } + free(provider_wide); + free(key_wide); + return result; +} + +int cng_delete_named_key(const char *provider_name, + const char *key_name, + char *error, + size_t error_size) +{ + NCRYPT_PROV_HANDLE provider = 0; + NCRYPT_KEY_HANDLE key = 0; + wchar_t *provider_wide = NULL; + wchar_t *key_wide = NULL; + SECURITY_STATUS status; + int result = 0; + + if (provider_name == NULL || provider_name[0] == '\0') { + provider_name = CNG_DEFAULT_PROVIDER_NAME; + } + if (key_name == NULL || key_name[0] == '\0') { + key_name = CNG_DEFAULT_KEY_NAME; + } + + provider_wide = utf8_to_wide(provider_name, error, error_size); + key_wide = utf8_to_wide(key_name, error, error_size); + if (provider_wide == NULL || key_wide == NULL) { + goto cleanup; + } + + status = NCryptOpenStorageProvider(&provider, provider_wide, 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptOpenStorageProvider failed with status 0x%08lX.", + (unsigned long)status); + goto cleanup; + } + + status = NCryptOpenKey(provider, &key, key_wide, 0, 0); + if (is_missing_key_status(status)) { + result = 1; + goto cleanup; + } + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptOpenKey for deletion failed with status 0x%08lX.", + (unsigned long)status); + goto cleanup; + } + + status = NCryptDeleteKey(key, 0); + if (status != ERROR_SUCCESS) { + set_error(error, error_size, + "NCryptDeleteKey failed with status 0x%08lX.", + (unsigned long)status); + goto cleanup; + } + key = 0; + result = 1; + +cleanup: + if (key != 0) { + (void)NCryptFreeObject(key); + } + if (provider != 0) { + (void)NCryptFreeObject(provider); + } + free(provider_wide); + free(key_wide); + return result; +} diff --git a/native/tests/native/support/cng_key.h b/native/tests/native/support/cng_key.h new file mode 100644 index 00000000..ee57fd4b --- /dev/null +++ b/native/tests/native/support/cng_key.h @@ -0,0 +1,64 @@ +#ifndef CNG_KEY_H +#define CNG_KEY_H + +#include +#include + +#include "cng_provider_params.h" + +typedef struct cng_key_st CNG_KEY; + +typedef enum cng_rsa_padding_st { + CNG_RSA_PADDING_PKCS1 = 1, + CNG_RSA_PADDING_PSS = 2 +} CNG_RSA_PADDING; + +int cng_key_open_or_create(const char *provider_name, + const char *key_name, + unsigned int bits, + CNG_KEY **out_key, + char *error, + size_t error_size); + +int cng_key_open(const char *provider_name, + const char *key_name, + CNG_KEY **out_key, + char *error, + size_t error_size); + +int cng_key_up_ref(CNG_KEY *key); +void cng_key_free(CNG_KEY *key); + +const char *cng_key_provider_name(const CNG_KEY *key); +const char *cng_key_name(const CNG_KEY *key); +unsigned int cng_key_bits(const CNG_KEY *key); +unsigned int cng_key_security_bits(const CNG_KEY *key); +size_t cng_key_max_signature_size(const CNG_KEY *key); +const unsigned char *cng_key_public_modulus(const CNG_KEY *key, + size_t *length); +const unsigned char *cng_key_public_exponent(const CNG_KEY *key, + size_t *length); + +int cng_key_sign_hash(CNG_KEY *key, + LPCWSTR bcrypt_digest_name, + CNG_RSA_PADDING padding, + ULONG pss_salt_length, + const unsigned char *hash, + size_t hash_length, + unsigned char *signature, + size_t signature_size, + size_t *signature_length, + char *error, + size_t error_size); + +int cng_private_export_is_blocked(const char *provider_name, + const char *key_name, + char *error, + size_t error_size); + +int cng_delete_named_key(const char *provider_name, + const char *key_name, + char *error, + size_t error_size); + +#endif diff --git a/native/vcpkg.json b/native/vcpkg.json new file mode 100644 index 00000000..ddd61686 --- /dev/null +++ b/native/vcpkg.json @@ -0,0 +1,9 @@ +{ + "name": "microsoft-identity-native-primitives", + "version-string": "0.2.0", + "description": "Rust-native identity primitives for platform-protected keys, including Windows CNG/OpenSSL and bounded mTLS transport", + "dependencies": [ + "openssl" + ], + "builtin-baseline": "94a541197763a4f449a1b91478df48c0584a6256" +}