---
status: accepted
date: 2026-05-13
decision-makers: Constructor Fabric Steering Committee
---

# Server-side TLS on macOS via the corecrypto rustls CryptoProvider

**ID**: `cpt-toolkit-adr-macos-server-side-corecrypto`

## Table of Contents

<!-- generated by `cypilot toc` -->

## Background

This ADR **extends** the macOS provider scope decided in [ADR 0001](0001-macos-fips-via-corecrypto-provider.md). The provider was deliberately client-side-only; this ADR adds server-side `KeyProvider` so a `rustls::ServerConfig` works on macOS+FIPS, and brings the algorithm scope into parity with the Windows path. Cross-OS architecture and the dual `default_provider()` / `fips_provider()` factory pattern are documented in the [FIPS PRD](../PRD.md) (see §3 module-specific environment constraints and §7 public library interfaces).

## Context and Problem Statement

ADR 0001 introduced `cf-gears-rustls-corecrypto-provider` with explicit scope **client-side TLS only**. The signer module was a rejecting stub (`Err("server-side TLS … is not supported")`), justified at the time by "cf-gears terminates HTTPS at the reverse proxy in production".

That assumption is now too narrow:

1. **Deployments without a reverse proxy** — single-process edge / on-prem installs run cf-gears-example-server directly on the public TLS port and need to hold a private key.
2. **mTLS / client-cert auth** — outbound mTLS (we are the TLS client presenting a cert to an upstream service) uses the same `KeyProvider::load_private_key` code path as server-side TLS.
3. **API parity with the Windows provider** — `rustls-cng-crypto` on Windows already exposes full server-side signing (RSA + ECDSA P-256/P-384/P-521); an asymmetric macOS provider forces conditional code in cf-gears-example-server.

FIPS-claim mechanics are unchanged: Apple corecrypto's CMVP scope already covers every algorithm we add (RSA-PSS / RSA-PKCS#1 v1.5 SHA-2 family; ECDSA P-256/P-384/**P-521** with matching SHA-2). The only Rust-side work is structural DER unwrapping before handing bytes to `SecKeyCreateWithData`.

## Considered Options

* **Option A** — Implement `SigningKey` over `SecKeyCreateSignature`, including P-521 in both signer and verify — **chosen**
* **Option B** — Keep `signer.rs` as a rejecting stub
* **Option C** — Implement, P-256 / P-384 only (skip P-521)
* **Option D** — Server-side keys live in macOS Keychain via `SecItemCopyMatching`; in-memory PEM/DER load is unsupported

## Decision Outcome

Chosen option: **Option A**.

Key reasons:

* Every algorithm we add is already in Apple corecrypto's CMVP scope — zero additional CMVP exposure.
* Rust-side work is **strictly structural DER parsing** — no cryptographic primitives execute outside the corecrypto boundary. See [FIPS posture](#fips-posture--what-is-and-is-not-inside-the-cryptographic-boundary) below.
* P-521 costs ~20 LOC each in `verify.rs` and `signer/ec.rs`. Including it gives API parity with `rustls-cng-crypto` **and** client/server scope symmetry inside our own crate.
* Option B is rejected because the original "reverse-proxy always" assumption blocks two real deployment shapes (edge / on-prem; mTLS).
* Option C is rejected — supporting P-521 is trivial relative to the value of one consistent FIPS-Approved curve set across OSes and roles.
* Option D is **deferred**, not rejected — Keychain-stored keys are a strictly stronger FIPS posture (key never leaves the validated module's boundary even at load). It can layer on later via a second `SigningKey` constructor accepting `SecKeyRef`. Tracked as TODO-1 in [FIPS PRD §13](../PRD.md#13-open-questions).

### FIPS posture — what is and is NOT inside the cryptographic boundary

This table is the load-bearing technical content of this ADR and is **not** duplicated in the PRD.

| Operation | Where it runs | FIPS-validated? |
|---|---|---|
| RSA signing (PSS, PKCS#1 v1.5) | `SecKeyCreateSignature` → corecrypto | **Yes** |
| ECDSA signing (P-256/384/521) | `SecKeyCreateSignature` → corecrypto | **Yes** |
| ECDSA per-signature `k` generation | corecrypto's DRBG | **Yes** |
| RSA-PSS salt length | `salt_len == hash_len` per Apple's `SecKey.h` (`saltLength = 32/48/64` for SHA-{256,384,512}) — matches RFC 8446 §4.2.3 / RFC 8017 §9.1 | **Yes** |
| EC public-point derivation `Q = d · G` | **never executed** — `Q` is read from SEC1 `publicKey` OPTIONAL field | n/a (avoided) |
| TLS 1.3 HKDF key schedule | composition over corecrypto HMAC; HKDF itself is an Approved KDF (NIST SP 800-56C / RFC 5869) | **Yes** |
| TLS 1.3 AEAD (AES-GCM) | CommonCrypto AES-GCM (NIST GCM CAVS) | **Yes** |
| **TLS 1.2 PRF (P_hash)** | `PrfUsingHmac` over corecrypto HMAC. corecrypto does **NOT** expose a dedicated CAVS-validated TLS PRF (unlike `aws-lc-fips`, which has `tls_prf::Algorithm` in its CAVS scope). `Prf::fips()` returns the default `false`. | **No** — composition is not CAVS-validated as a TLS PRF on macOS |
| PKCS#8 / SEC1 envelope DER parse | `pkcs8` + `sec1` crates (pure-Rust, no crypto primitives) | Out of boundary, acceptable — no cryptographic operation, no algorithm choice |
| Private-key bytes (PKCS#1 RSA, X9.63 EC) transit user-space before `SecKeyCreateWithData` | our process address space | Out of boundary. Same posture as `rustls-cng-crypto` and `aws-lc-rs/fips` PEM-loading. Strict-FIPS deployments prefer the Keychain flow (TODO-1) |

**What this design avoids that a naive implementation would do wrong**: deriving `Q = d · G` in `p256`/`p384`/`p521` Rust crates. That derivation is EC scalar-multiplication on the private scalar — a cryptographic primitive — performed outside any CMVP-validated module. Reading the public point from SEC1 DER (which standard tooling — rcgen, OpenSSL, GnuTLS — embeds by default) keeps the provider strictly correct.

**Fail-closed rejections** in `signer/ec.rs`:
- SEC1 input without the `publicKey` OPTIONAL field → `Err(...)` referencing the FIPS boundary.
- SEC1 `publicKey` in compressed form (`0x02`/`0x03` prefix) → reject (decompression would also be a sub-FIPS operation).
- Curve OID not P-256/P-384/P-521 → reject.

**Consequence for the FIPS claim**: the provider's two factories (`default_provider()` and `fips_provider()`, full discussion in PRD §6.2) interact with the TLS 1.2 PRF gap. `ClientConfig::fips()` / `ServerConfig::fips()` is `true` only on configs built from `fips_provider()` + `require_ems = true`.

### Consequences

* `signer.rs` is replaced with a `signer/` directory: `mod.rs` (`KeyProvider` + dispatcher), `rsa.rs` (RSA-PSS + RSA-PKCS#1 v1.5), `ec.rs` (ECDSA P-256/P-384/P-521). Layout mirrors [`rustls-cng-crypto`'s `src/signer/`](https://docs.rs/rustls-cng-crypto/0.1.2/rustls_cng_crypto/) for ease of cross-reference.
* `verify.rs` gains `EcdsaP521Sha512`; `SUPPORTED_SIG_ALGS` table grows by one entry; `ECDSA_SIG_SCHEMES` in `provider.rs` includes P-521 so TLS 1.2 ECDHE_ECDSA suites advertise the full curve set.
* New workspace deps: `pkcs8`, `sec1` — both pure-Rust DER parsers. **Curve-arithmetic crates (`p256`/`p384`/`p521` with `arithmetic` feature) are intentionally NOT pulled** — they would silently bring scalar-multiplication routines into the build, and a future maintainer could accidentally reintroduce out-of-boundary `Q = d · G` derivation. Restricting the surface to `sec1` makes that regression a compile error, not a silent FIPS-claim violation.
* `RSA_SCHEMES` becomes the single source of truth for the TLS-1.2 RSA cipher suites' sign-list (previously duplicated in `provider.rs`).
* `ServerConfig::builder_with_provider(...).with_single_cert(...)` now succeeds. Callers requiring `ServerConfig::fips() == true` must still set `require_ems = true` (NIST SP 800-52 Rev. 2 §3.5).
* ADR 0001's "client-side TLS only" sentence is superseded; ADR 0001 status becomes `amended`.
* Six new integration tests in `tests/handshake_smoke.rs` exercise `ServerConfig` handshakes (TLS 1.3 × {P-256, P-384, P-521, RSA-2048}; TLS 1.2 × {ECDHE_ECDSA, ECDHE_RSA}).

### Confirmation

* **Unit-level**: every signer file has roundtrip tests (sign with our `SigningKey`, verify through `verify::SUPPORTED_SIG_ALGS`). 6 RSA schemes + 3 EC curves.
* **Cross-implementation PSS-salt regression** (`rsa_pss_apple_salt_length_matches_rfc8017`): signs with our path, verifies with the pure-Rust `rsa` crate using **explicit** `salt_len = digest_len`. Catches a hypothetical future Apple SDK change to default salt length — would manifest as TLS 1.3 interop break against any RFC-compliant peer.
* **Integration**: `tests/handshake_smoke.rs::server_handshake_*` covers full `ServerConfig`-driven TLS 1.3 / TLS 1.2 handshakes per key type. `server_config_with_loaded_key_advertises_fips` asserts the FIPS invariant survives key import.
* **Runtime OE gate** (fail-closed): the first `fips_provider()` call invokes `oe::validate_oe()` (reads `kern.osproductversion` via sysctl, matches against [`SUPPORTED_OE_MACOS_MAJOR`](../../../../libs/rustls-corecrypto-provider/src/oe.rs)). Mismatch panics under `--features fips` unless `CF_GEARS_FIPS_OE_OVERRIDE=1` is set. See PRD §8.3.1.
* **G-6 wire-shape regression**: [`tests/fips_provider_invariants.rs`](../../../../libs/rustls-corecrypto-provider/tests/fips_provider_invariants.rs) pins the `fips_provider()` shape (AES-GCM only, NIST P-curves only, TLS 1.3 only, `fips() = true`). Catches a refactor that accidentally adds ChaCha20 / X25519 / TLS 1.2 to the FIPS path.
* **No regression**: existing client-side `handshake_*` tests against `openssl s_server` and `provider::tests::*` stay green.

## Pros and Cons of the Options

### Option A — Implement, including P-521 (chosen)

* Good: API parity with `rustls-cng-crypto` (Windows) and with our own `verify.rs` (client side).
* Good: every cryptographic operation routes through Apple corecrypto's CMVP-validated module — FIPS-claim mechanics for the data path are unchanged.
* Good: `Q = d · G` derivation is explicitly **avoided** — `publicKey` comes from SEC1 DER; SEC1 without `publicKey` is rejected fail-closed.
* Good: added deps (`pkcs8`, `sec1`) are pure-Rust DER parsers with no curve arithmetic — the surface is restricted by construction.
* Bad: in-process PEM/DER load means the private-key bytes transit user-space before `SecKeyCreateWithData`. Strict FIPS auditors operating under a "no plaintext CSPs outside the boundary" reading will require the Keychain-stored key flow (deferred — TODO-1).
* Bad: keys whose SEC1 lacks `publicKey` cannot be loaded at all (e.g. `openssl ec -no_public` output). Operators must re-export with `publicKey` embedded (default for OpenSSL / rcgen / GnuTLS).

### Option B — Keep rejecting stub

* Good: zero implementation cost.
* Bad: forces every macOS deployment through a reverse proxy — blocks edge / on-prem shapes.
* Bad: blocks the mTLS work tracked under a separate ticket.
* Bad: leaves the macOS provider permanently smaller in scope than its Windows counterpart.

### Option C — P-256 / P-384 only (no P-521)

* Good: marginally smaller patch surface.
* Bad: asymmetric with `rustls-cng-crypto` (Windows offers P-521) for a saving of ~20 LOC.
* Bad: asymmetric inside our own crate — if `verify.rs` also drops P-521 we lose interop; if it doesn't, signer/verify scopes diverge.

### Option D — Keychain-only flow (`SecItemCopyMatching`)

* Good: strictest FIPS posture — private-key material never leaves the corecrypto boundary even at load.
* Bad: rejects the standard PEM/DER-from-file deployment shape that `rustls-cng-crypto` and `aws-lc-fips` both support.
* Bad: requires every operator to provision keys through Keychain — separate, OS-specific tooling.
* Decision: **defer** to a future enhancement (TODO-1). The two paths can coexist via a second `SigningKey` constructor accepting `SecKeyRef`.

## More Information

* Implementation: [`libs/rustls-corecrypto-provider/src/signer/`](../../../../libs/rustls-corecrypto-provider/src/signer/) (new directory) and [`libs/rustls-corecrypto-provider/src/verify.rs`](../../../../libs/rustls-corecrypto-provider/src/verify.rs) (+P-521).
* Reference: [`rustls-cng-crypto`'s `signer/`](https://docs.rs/rustls-cng-crypto/0.1.2/rustls_cng_crypto/) — Windows-side API surface we now match.
* Apple `SecKeyCreateSignature`: <https://developer.apple.com/documentation/security/seckeycreatesignature>
* Apple `SecKeyCreateWithData`: <https://developer.apple.com/documentation/security/seckeycreatewithdata>

## Traceability

- **DESIGN**: [`libs/rustls-corecrypto-provider/src/signer/mod.rs`](../../../../libs/rustls-corecrypto-provider/src/signer/mod.rs), [`signer/rsa.rs`](../../../../libs/rustls-corecrypto-provider/src/signer/rsa.rs), [`signer/ec.rs`](../../../../libs/rustls-corecrypto-provider/src/signer/ec.rs), [`verify.rs`](../../../../libs/rustls-corecrypto-provider/src/verify.rs)
- **Supersedes scope of**: [ADR 0001](0001-macos-fips-via-corecrypto-provider.md) — lines stating "only supports client-side TLS"; that ADR's status is `amended`.
- **Related**: [ADR 0002](0002-fips-feature-target-conditional-shim.md) — the per-target shim machinery, unaffected by this ADR.
- **Strategy doc**: [FIPS PRD](../PRD.md) — full cross-OS architecture, dual-factory pattern, and verification gates.

This decision directly addresses:

* `cpt-toolkit-nfr-fips-cross-os` — Valid FIPS 140-3 claim on Linux **and** macOS **for both client and server TLS roles**.
* `cpt-toolkit-design-tls-uniformity` — Single rustls-based TLS stack across all supported OSes, with symmetric client/server algorithm scope.
