From 628af1faf683edeea23a9042753d3bc698f2198b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lomig=20Me=CC=81gard?= Date: Wed, 5 Aug 2026 21:35:02 +0200 Subject: [PATCH] feat!: sign and verify data as first-class ceremony steps Signing arbitrary data needed a smart card (`piv_sign`), and verifying a signature happened only as a side effect of `issue_certificate` checking a CSR. A ceremony that wanted to sign a release manifest with a software or HSM key, or to check a signature it was handed, had no step for it. This adds `sign_data` and `verify_signature`, plus the groundwork they turned out to need. ## Signature algorithms gain DSL names `SignAlgorithm` was the only algorithm enum without `Display`/`FromStr`, so its DSL spelling came from serde's snake_case derive and a hand-rolled table in `piv_sign`. That put `algorithm: ecdsa_sha256` next to `algorithm: ECDSA-P256` in the same ceremony file. It now spells the same way as `KeyAlgorithm` and `WrapAlgorithm`, with a wire-contract test pinning the eight strings, and the ML-DSA parameter sets gain a DSL spelling for the first time. `SignAlgorithm::accepts_key` replaces the compatibility guard each backend arm reinvented. `key_algorithm()` could not serve as that predicate: it is lossy for RSA, mapping both schemes to `Rsa2048`, so an RSA-4096 key failed its own equality check. ## Software verification consolidates onto OpenSSL It ran on three mechanisms at once: the `rsa` crate for RSA CSRs, `p256` for ECDSA, and OpenSSL for ML-DSA, while OpenSSL produced all three signatures. - `rite_openssl::verify_signature` covers every family, and `sign`/`verify` share its primitives, so the backend has one signing path and one verification path rather than a match arm each. - `rite-stdlib`'s `signatures` module is the seam actions go through, so swapping the provider behind it touches one file. - `rsa` and `p256` leave the workspace. They remain only as transitive dependencies of `yubikey`, absent from default and musl builds, so the RUSTSEC-2023-0071 rationale is rewritten around where the crate now actually appears. `pki` and `crypto` gain `openssl`, which they verify through. Verification is told which algorithm to use rather than inferring one from the key, so a CSR naming ECDSA while carrying an RSA key is refused instead of quietly verified as RSA. The CSR allowlist stays, folded into the same table as the identifiers Rite emits, so Rite cannot generate a CSR its own `issue_certificate` refuses. Ed25519 and ECDSA-P384 were declared in both algorithm enums and implemented nowhere: `generate_keypair` accepted them and the OpenSSL backend then refused. Both now work end to end, with their RFC 5480 and RFC 8410 signature identifiers. ## The actions `sign_data` is the generic counterpart to `piv_sign`: any backend implementing `SignBackend`, key taken by artifact reference rather than device slot. The algorithm follows from the key, with an `algorithm:` override for the one case where a key does not determine it, an RSA key choosing between PKCS#1 v1.5 and PSS. An override the key cannot perform is refused up front rather than at the backend. `verify_signature` needs no backend, which is the point. Verification takes only a public key, so the step works on evidence the ceremony did not produce: a signature made on a card that will never expose its key, or one that arrived with a document from outside. Naming a `backend:` delegates the check instead; doing so with a key that backend does not hold is an error rather than a silent fall back to software, which would misreport who checked the evidence. A failed check fails the step. That made `ActionType::requires_backend()` too coarse, since an action can also *accept* a backend without needing one. It becomes `backend_usage() -> BackendUsage` with three states, so `rite check` no longer warns that a delegated verification "does not use a backend". `examples/showcase/sign_and_verify.rite.yaml` signs and verifies a manifest, contrasting the two step shapes. Its assertion is that verification succeeded, never that the signature equals fixed bytes, since ML-DSA signing is hedged. The negative cases are integration tests. ## Also - `docs/development/cryptographic-dependencies.md` states which library performs which class of work, so a contributor adding an algorithm has a rule to follow rather than a guess. The OpenSSL 3.5 requirement moves into CONTRIBUTING.md, where someone hits it. - `ActionType::ALL` ties the editor's action catalogue to the enum. Nothing did before, so a new action was invisible in completion while working perfectly at run time; the sync test caught `gather_entropy`, missing since it shipped. BREAKING CHANGE: `piv_sign` takes `ECDSA-SHA256`, `ECDSA-SHA384`, and `RSA-PKCS1-SHA256`; the snake_case spellings are no longer accepted. `rite_openssl::verify_ml_dsa_signature` is replaced by `verify_signature`, which takes a `SignAlgorithm`. `ActionType::requires_backend` is replaced by `ActionType::backend_usage`, returning `BackendUsage`. --- .cargo/audit.toml | 25 +- CONTRIBUTING.md | 9 +- Cargo.lock | 224 +------ Cargo.toml | 2 - README.md | 2 +- crates/rite-ls/src/actions.rs | 39 ++ crates/rite-model/src/lib.rs | 4 +- crates/rite-model/src/types.rs | 147 ++++- crates/rite-openssl/src/backend.rs | 551 +++++++++++------- crates/rite-openssl/src/lib.rs | 6 +- crates/rite-piv/src/ops.rs | 6 +- crates/rite-resolver/src/resolve.rs | 6 +- crates/rite-runtime/src/lib.rs | 2 +- crates/rite-sdk/src/types.rs | 203 ++++++- crates/rite-stdlib/Cargo.toml | 8 +- crates/rite-stdlib/src/crypto/mod.rs | 4 + crates/rite-stdlib/src/crypto/sign_data.rs | 222 +++++++ .../src/crypto/verify_signature.rs | 198 +++++++ crates/rite-stdlib/src/lib.rs | 16 +- crates/rite-stdlib/src/params.rs | 32 + crates/rite-stdlib/src/piv/params.rs | 12 +- crates/rite-stdlib/src/piv/sign.rs | 72 ++- .../rite-stdlib/src/pki/issue_certificate.rs | 89 +-- crates/rite-stdlib/src/pki/oids.rs | 167 ++++-- crates/rite-stdlib/src/signatures.rs | 81 +++ crates/rite-stdlib/tests/pki_algorithms.rs | 158 +++++ crates/rite-stdlib/tests/sign_verify.rs | 223 +++++++ .../development/cryptographic-dependencies.md | 90 +++ docs/development/hardware-backends.md | 4 +- examples/piv/yubikey_signing.rite.yaml | 2 +- examples/showcase/README.md | 9 + examples/showcase/sign_and_verify.rite.yaml | 100 ++++ .../showcase/test_data/release_manifest.txt | 10 + 33 files changed, 2122 insertions(+), 601 deletions(-) create mode 100644 crates/rite-stdlib/src/crypto/sign_data.rs create mode 100644 crates/rite-stdlib/src/crypto/verify_signature.rs create mode 100644 crates/rite-stdlib/src/signatures.rs create mode 100644 crates/rite-stdlib/tests/pki_algorithms.rs create mode 100644 crates/rite-stdlib/tests/sign_verify.rs create mode 100644 docs/development/cryptographic-dependencies.md create mode 100644 examples/showcase/sign_and_verify.rite.yaml create mode 100644 examples/showcase/test_data/release_manifest.txt diff --git a/.cargo/audit.toml b/.cargo/audit.toml index de04a20..ce042a8 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -10,18 +10,23 @@ ignore = [ # timing sidechannels in the `rsa` crate. No fixed version is available # upstream. # - # Why it does not apply here: the `rsa` crate is pulled in only for - # *public-key* operations — verifying the self-signature on an incoming - # CSR (`rsa::RsaPublicKey::verify` in - # `crates/rite-stdlib/src/pki/issue_certificate.rs`). The Marvin attack - # targets RSA *private-key* decryption/signing (PKCS#1 v1.5 unpadding), - # which is never performed: Rite holds no RSA private keys in this crate - # and never decrypts or signs with `rsa`. All private-key crypto lives in - # the OpenSSL backend (`rite-openssl`), not in the `rsa` crate. + # Why it does not apply here: `rsa` is not a dependency of this workspace. + # It arrives only transitively through the `yubikey` crate, and so only in + # builds with the `piv` or `yubikey` feature enabled. The default build and + # the static musl release artifacts do not contain it at all. Software RSA + # in Rite is performed by OpenSSL, through `rite-openssl`. + # + # Where it does appear, the attack still does not reach: Marvin targets RSA + # *private-key* operations (PKCS#1 v1.5 decryption and signing), and + # `yubikey` performs none. It uses `rsa` only to rebuild an `RsaPublicKey` + # from the modulus and exponent a card reports and re-encode it as SPKI + # (`yubikey::piv`). The private key never leaves the card, which is the + # reason for using one. # # Revisit if any of the following becomes true: - # - `rsa` gains a private-key code path here (decryption, signing, or - # key import) — grep for `RsaPrivateKey`, `decrypt`, `sign`; + # - `rsa` returns as a direct dependency of a crate in this workspace; + # - `yubikey` gains an RSA private-key code path — grep the dependency + # for `RsaPrivateKey`, `decrypt`, and `sign`; # - a fixed release of `rsa` ships (drop this entry and upgrade). "RUSTSEC-2023-0071", ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb446c0..0dec180 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,8 @@ Pull requests are welcome, but opening an issue to discuss the change first is s CLI behavior conventions are documented in `docs/development/cli-conventions.md`. Runtime and frontend architecture is documented in `docs/development/runtime-and-frontend.md`, -crate layout in `docs/development/crate-layout.md`, and the testing strategy in +crate layout in `docs/development/crate-layout.md`, the crypto stack in +`docs/development/cryptographic-dependencies.md`, and the testing strategy in `docs/development/testing.md`. ## AI-assisted contributions @@ -22,6 +23,12 @@ datapoint for any contribution, not as a compliance step. Requires Rust 1.88+ and `libssl-dev` (OpenSSL headers). +The post-quantum algorithms (ML-DSA) additionally need **OpenSSL 3.5 or +newer**. Building against an older one succeeds, with those algorithms absent +from the binary and their tests and examples skipped; see +`docs/development/cryptographic-dependencies.md`. `--features openssl-vendored` +bundles a current OpenSSL and sidesteps the question. + ```sh cargo build -p rite --features openssl-vendored cargo run -p rite -- check examples/showcase/demo.rite.yaml diff --git a/Cargo.lock b/Cargo.lock index 79b8839..dc08c09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -369,12 +369,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -447,22 +441,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "crypto-bigint" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" -dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -479,9 +457,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.1", ] [[package]] @@ -491,7 +467,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", - "subtle", ] [[package]] @@ -680,27 +655,12 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der 0.7.10", "digest 0.10.7", - "elliptic-curve 0.13.8", - "rfc6979 0.4.0", + "elliptic-curve", + "rfc6979", "signature 2.2.0", "spki 0.7.3", ] -[[package]] -name = "ecdsa" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" -dependencies = [ - "der 0.8.1", - "digest 0.11.3", - "elliptic-curve 0.14.1", - "rfc6979 0.6.0", - "signature 3.0.0", - "spki 0.8.0", - "zeroize", -] - [[package]] name = "either" version = "1.16.0" @@ -714,37 +674,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct 0.2.0", - "crypto-bigint 0.5.5", + "crypto-bigint", "digest 0.10.7", - "ff 0.13.1", + "ff", "generic-array", - "group 0.13.0", + "group", "hkdf 0.12.4", "pem-rfc7468 0.7.0", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", - "sec1 0.7.3", - "subtle", - "zeroize", -] - -[[package]] -name = "elliptic-curve" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" -dependencies = [ - "base16ct 1.0.0", - "crypto-bigint 0.7.5", - "crypto-common 0.2.2", - "digest 0.11.3", - "ff 0.14.0", - "group 0.14.0", - "hybrid-array", - "pem-rfc7468 1.0.0", - "pkcs8 0.11.0", - "rand_core 0.10.1", - "sec1 0.8.1", + "sec1", "subtle", "zeroize", ] @@ -802,16 +741,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "ff" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" -dependencies = [ - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -978,22 +907,11 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff 0.13.1", + "ff", "rand_core 0.6.4", "subtle", ] -[[package]] -name = "group" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" -dependencies = [ - "ff 0.14.0", - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -1094,9 +1012,7 @@ version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ - "subtle", "typenum", - "zeroize", ] [[package]] @@ -1525,34 +1441,21 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", - "primeorder 0.13.6", + "ecdsa", + "elliptic-curve", + "primeorder", "sha2 0.10.9", ] -[[package]] -name = "p256" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" -dependencies = [ - "ecdsa 0.17.0", - "elliptic-curve 0.14.1", - "primefield", - "primeorder 0.14.0", - "sha2 0.11.0", -] - [[package]] name = "p384" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" dependencies = [ - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", - "primeorder 0.13.6", + "ecdsa", + "elliptic-curve", + "primeorder", "sha2 0.10.9", ] @@ -1669,7 +1572,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ "der 0.7.10", - "pkcs8 0.10.2", + "pkcs8", "spki 0.7.3", ] @@ -1683,16 +1586,6 @@ dependencies = [ "spki 0.7.3", ] -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der 0.8.1", - "spki 0.8.0", -] - [[package]] name = "pkg-config" version = "0.3.33" @@ -1747,40 +1640,13 @@ dependencies = [ "termtree", ] -[[package]] -name = "primefield" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" -dependencies = [ - "crypto-bigint 0.7.5", - "crypto-common 0.2.2", - "ff 0.14.0", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "primeorder" version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" dependencies = [ - "elliptic-curve 0.13.8", -] - -[[package]] -name = "primeorder" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" -dependencies = [ - "elliptic-curve 0.14.1", - "once_cell", - "primefield", - "serdect", - "wnaf", + "elliptic-curve", ] [[package]] @@ -1988,16 +1854,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "rfc6979" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" -dependencies = [ - "crypto-bigint 0.7.5", - "hmac 0.13.0", -] - [[package]] name = "rite" version = "0.4.2" @@ -2131,14 +1987,12 @@ version = "0.4.2" dependencies = [ "chrono", "openssl", - "p256 0.14.0", "rite-model", "rite-openssl", "rite-piv", "rite-runtime", "rite-sdk", "rite-yubikey", - "rsa", "secrecy 0.10.3", "serde", "serde_json", @@ -2195,7 +2049,7 @@ dependencies = [ "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", "sha2 0.10.9", "signature 2.2.0", @@ -2263,21 +2117,7 @@ dependencies = [ "base16ct 0.2.0", "der 0.7.10", "generic-array", - "pkcs8 0.10.2", - "subtle", - "zeroize", -] - -[[package]] -name = "sec1" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" -dependencies = [ - "base16ct 1.0.0", - "ctutils", - "der 0.8.1", - "hybrid-array", + "pkcs8", "subtle", "zeroize", ] @@ -2349,16 +2189,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serdect" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" -dependencies = [ - "base16ct 1.0.0", - "serde", -] - [[package]] name = "sha1" version = "0.10.7" @@ -2456,7 +2286,6 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" dependencies = [ - "digest 0.11.3", "rand_core 0.10.1", ] @@ -3133,17 +2962,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wnaf" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab12e7090f27e2ffd9322651492942d50c2926094af30601e1964337db39daf1" -dependencies = [ - "ff 0.14.0", - "group 0.14.0", - "hybrid-array", -] - [[package]] name = "x509-cert" version = "0.2.5" @@ -3192,15 +3010,15 @@ dependencies = [ "base16ct 0.2.0", "der 0.7.10", "des", - "ecdsa 0.16.9", - "elliptic-curve 0.13.8", + "ecdsa", + "elliptic-curve", "hmac 0.12.1", "log", "nom", "num-bigint-dig", "num-integer", "num-traits", - "p256 0.13.2", + "p256", "p384", "pbkdf2", "pcsc", diff --git a/Cargo.toml b/Cargo.toml index 172cf17..855c199 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -80,8 +80,6 @@ x509-cert = { version = "0.3.0", features = ["builder"] } signature = "3.0.0" sha2 = "0.11.0" hkdf = "0.13.0" -rsa = "0.9.10" -p256 = { version = "0.14.0", features = ["ecdsa", "pkcs8"] } zeroize = "1.8.2" [workspace.lints.rust] diff --git a/README.md b/README.md index 34359c0..2d5b713 100644 --- a/README.md +++ b/README.md @@ -138,7 +138,7 @@ Both bundle the `rite-ls` language server. For other LSP-aware editors, run `rit - [ ] Ceremony resumption after interruption - [ ] Teardown act on abort or failure - [x] **Cryptographic backends** - - [x] OpenSSL: RSA, ECDSA-P256, signing, wrapping, PKI + - [x] OpenSSL: RSA, ECDSA-P256/P384, Ed25519, signing, wrapping, PKI - [x] Post-quantum: ML-DSA-44/65/87 signatures and certificates (needs OpenSSL 3.5+) - [ ] Post-quantum: ML-KEM key encapsulation - [x] Hardware backends diff --git a/crates/rite-ls/src/actions.rs b/crates/rite-ls/src/actions.rs index 4c81da4..7ad6644 100644 --- a/crates/rite-ls/src/actions.rs +++ b/crates/rite-ls/src/actions.rs @@ -60,11 +60,26 @@ pub static ALL: &[ActionMeta] = &[ short: "Export public key from keypair", long: "Export public key from keypair.", }, + ActionMeta { + name: "sign_data", + short: "Sign data with a backend-managed key", + long: "Sign arbitrary data with a backend-managed key. The signature algorithm follows from the key unless `algorithm:` names another one the key accepts.", + }, + ActionMeta { + name: "verify_signature", + short: "Verify a signature against a public key", + long: "Verify a signature over data, given the signer's public key. Needs no backend, so it works on evidence the ceremony did not produce; naming a `backend:` delegates the check to that backend.", + }, ActionMeta { name: "attest", short: "Formal attestation statement", long: "Formal attestation statement.", }, + ActionMeta { + name: "gather_entropy", + short: "Fold human-supplied entropy into the ceremony seed", + long: "Fold human-supplied entropy into the ceremony seed. A participant supplies a free-form random value, such as the result of rolling physical dice, which is mixed into the entropy source's ratchet.", + }, ActionMeta { name: "tpm_attest", short: "TPM attestation with PCR measurements and cryptographic quotes", @@ -101,3 +116,27 @@ pub static ALL: &[ActionMeta] = &[ pub fn hover_description(name: &str) -> Option<&'static str> { ALL.iter().find(|a| a.name == name).map(|a| a.long) } + +#[cfg(test)] +mod tests { + use super::ALL; + use rite_model::ActionType; + use std::collections::BTreeSet; + + /// Completion offers exactly the actions the runtime has. + /// + /// This list is static, so nothing in the compiler ties it to `ActionType`: + /// a new action would otherwise be invisible in the editor while working + /// perfectly at run time, and a removed one would still be suggested. + #[test] + fn catalogue_matches_the_action_types() { + let catalogued: BTreeSet<&str> = ALL.iter().map(|a| a.name).collect(); + let defined: BTreeSet = ActionType::ALL.iter().map(ToString::to_string).collect(); + let defined: BTreeSet<&str> = defined.iter().map(String::as_str).collect(); + + assert_eq!( + catalogued, defined, + "editor action catalogue is out of step with ActionType" + ); + } +} diff --git a/crates/rite-model/src/lib.rs b/crates/rite-model/src/lib.rs index 2603086..ae65034 100644 --- a/crates/rite-model/src/lib.rs +++ b/crates/rite-model/src/lib.rs @@ -32,8 +32,8 @@ pub use material::MaterialSource; pub use safe_path::{PathSafetyError, confine, is_safe_component, safe_join, validate_component}; pub use types::{ - ActionType, DutyType, Metadata, OutputType, ParameterType, derive_role_name, derive_step_name, - role_type, + ActionType, BackendUsage, DutyType, Metadata, OutputType, ParameterType, derive_role_name, + derive_step_name, role_type, }; pub use ir::{ diff --git a/crates/rite-model/src/types.rs b/crates/rite-model/src/types.rs index 137f659..d3d681f 100644 --- a/crates/rite-model/src/types.rs +++ b/crates/rite-model/src/types.rs @@ -98,6 +98,17 @@ pub enum ActionType { UnwrapKey, /// Export public key from keypair. ExportPublic, + /// Sign arbitrary data with a backend-managed key. + /// + /// The signature algorithm follows from the key unless `algorithm:` names + /// another one the key accepts. Requires a backend implementing `SignBackend`. + SignData, + /// Verify a signature over data, given the signer's public key. + /// + /// Needs no backend: verification takes only a public key, so it works on + /// evidence the ceremony did not produce. Naming a `backend:` delegates the + /// check to that backend instead. + VerifySignature, /// Formal attestation statement. Attest, /// Fold human-supplied entropy into the ceremony seed. @@ -142,27 +153,83 @@ pub enum ActionType { GenerateCsr, } +/// Whether an action needs the `backend:` field on its step. +/// +/// Three states, not two: an action can also *accept* a backend without needing +/// one. Verification is the case that forces the distinction, since a signature +/// check needs only a public key but may still be delegated to a device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum BackendUsage { + /// The step must name a backend; omitting it is an error. + Required, + /// The step may name a backend, which changes how the action runs. + Optional, + /// The action never uses a backend; naming one is a mistake worth warning about. + Unused, +} + impl ActionType { - /// Returns `true` if this action requires a `backend:` field in the step. + /// Every action type, for callers that need to enumerate them. + /// + /// Maintained by hand: `#[non_exhaustive]` means no downstream crate can + /// derive this list, and adding a variant produces no error outside this + /// crate. Adding one here is what keeps editor completion and any other + /// catalogue from silently missing it. The test below catches omissions. + pub const ALL: &'static [ActionType] = &[ + ActionType::ClockCheck, + ActionType::Confirm, + ActionType::CheckValue, + ActionType::OralReadback, + ActionType::MachineInfo, + ActionType::GenerateKeypair, + ActionType::WrapKey, + ActionType::UnwrapKey, + ActionType::ExportPublic, + ActionType::SignData, + ActionType::VerifySignature, + ActionType::Attest, + ActionType::GatherEntropy, + ActionType::TpmAttest, + ActionType::PivReadCertificate, + ActionType::PivSign, + ActionType::YubikeyAttestSlot, + ActionType::IssueCertificate, + ActionType::GenerateCsr, + ]; + + /// How this action relates to the `backend:` field on its step. /// /// TODO: Replace this with a nested enum split — `ActionType::Backend(BackendAction)` vs - /// `ActionType::Local(LocalAction)`. The `requires_backend` check then becomes - /// `matches!(self, ActionType::Backend(_))` with no hardcoded list, and each group gains - /// its own `impl` block. This is a breaking change to every match on `ActionType` variants. - pub fn requires_backend(self) -> bool { - matches!( - self, + /// `ActionType::Local(LocalAction)`, once a home is found for the actions that are + /// neither. This is a breaking change to every match on `ActionType` variants. + pub fn backend_usage(self) -> BackendUsage { + match self { ActionType::GenerateKeypair - | ActionType::WrapKey - | ActionType::UnwrapKey - | ActionType::ExportPublic - | ActionType::GenerateCsr - | ActionType::IssueCertificate - | ActionType::PivReadCertificate - | ActionType::PivSign - | ActionType::YubikeyAttestSlot - | ActionType::TpmAttest - ) + | ActionType::SignData + | ActionType::WrapKey + | ActionType::UnwrapKey + | ActionType::ExportPublic + | ActionType::GenerateCsr + | ActionType::IssueCertificate + | ActionType::PivReadCertificate + | ActionType::PivSign + | ActionType::YubikeyAttestSlot + | ActionType::TpmAttest => BackendUsage::Required, + + // Verification needs only a public key, so a backend is a choice + // rather than a requirement: naming one delegates the check to it. + ActionType::VerifySignature => BackendUsage::Optional, + + ActionType::ClockCheck + | ActionType::Confirm + | ActionType::CheckValue + | ActionType::OralReadback + | ActionType::MachineInfo + | ActionType::Attest + | ActionType::GatherEntropy => BackendUsage::Unused, + } } /// Returns the `with:` field names that are required for this action. @@ -197,6 +264,8 @@ impl ActionType { ActionType::TpmAttest => "Record TPM platform attestation (PCR values).", ActionType::GenerateKeypair => "Generate an asymmetric keypair.", ActionType::ExportPublic => "Export the public component of a keypair.", + ActionType::SignData => "Sign data with a ceremony key.", + ActionType::VerifySignature => "Verify a signature against a public key.", ActionType::WrapKey => "Wrap (encrypt) a key for secure transport.", ActionType::UnwrapKey => "Unwrap (decrypt) a transported key.", ActionType::GenerateCsr => "Generate a Certificate Signing Request.", @@ -220,6 +289,8 @@ impl std::fmt::Display for ActionType { ActionType::WrapKey => write!(f, "wrap_key"), ActionType::UnwrapKey => write!(f, "unwrap_key"), ActionType::ExportPublic => write!(f, "export_public"), + ActionType::SignData => write!(f, "sign_data"), + ActionType::VerifySignature => write!(f, "verify_signature"), ActionType::Attest => write!(f, "attest"), ActionType::GatherEntropy => write!(f, "gather_entropy"), ActionType::TpmAttest => write!(f, "tpm_attest"), @@ -552,4 +623,46 @@ mod tests { ); } } + + /// `ActionType::ALL` is written by hand, so it can fall behind the enum. + /// + /// The match below is exhaustive, so adding a variant fails to compile + /// here. That is the whole mechanism: it puts a compiler error next to the + /// list an author has to extend. The name check then catches a variant + /// listed twice, and `rite-ls` compares its own catalogue against `ALL`, + /// which is what catches one left out. + #[test] + fn all_lists_every_action_type() { + for action in ActionType::ALL { + match action { + ActionType::ClockCheck + | ActionType::Confirm + | ActionType::CheckValue + | ActionType::OralReadback + | ActionType::MachineInfo + | ActionType::GenerateKeypair + | ActionType::WrapKey + | ActionType::UnwrapKey + | ActionType::ExportPublic + | ActionType::SignData + | ActionType::VerifySignature + | ActionType::Attest + | ActionType::GatherEntropy + | ActionType::TpmAttest + | ActionType::PivReadCertificate + | ActionType::PivSign + | ActionType::YubikeyAttestSlot + | ActionType::IssueCertificate + | ActionType::GenerateCsr => {} + } + } + + let names: std::collections::BTreeSet = + ActionType::ALL.iter().map(ToString::to_string).collect(); + assert_eq!( + names.len(), + ActionType::ALL.len(), + "two actions share a DSL name" + ); + } } diff --git a/crates/rite-openssl/src/backend.rs b/crates/rite-openssl/src/backend.rs index 18156f8..d467f6f 100644 --- a/crates/rite-openssl/src/backend.rs +++ b/crates/rite-openssl/src/backend.rs @@ -12,7 +12,7 @@ use openssl::cms::CmsContentInfo; use openssl::ec::{EcGroup, EcKey}; use openssl::hash::MessageDigest; use openssl::nid::Nid; -use openssl::pkey::{PKey, PKeyRef, Private}; +use openssl::pkey::{HasPublic, Id, PKey, PKeyRef, Private}; use openssl::rsa::{Padding, Rsa}; use openssl::sign::{Signer, Verifier}; use openssl::symm::Cipher; @@ -122,11 +122,12 @@ impl Backend for OpenSslBackend { } rite_sdk::backend_capabilities!( - /// Supports RSA-2048, RSA-4096, ECDSA-P256, and (with OpenSSL 3.5+) - /// ML-DSA-44/65/87 key generation and storage. + /// Supports RSA-2048, RSA-4096, ECDSA-P256, ECDSA-P384, Ed25519, and + /// (with OpenSSL 3.5+) ML-DSA-44/65/87 key generation and storage. as_keystore_mut: KeyStoreBackend, - /// Supports RSA-PKCS1-v1.5 (SHA-256), RSA-PSS (SHA-256), ECDSA-P256 - /// (SHA-256), and (with OpenSSL 3.5+) ML-DSA-44/65/87 signing. + /// Supports RSA-PKCS1-v1.5 (SHA-256), RSA-PSS (SHA-256), ECDSA + /// (SHA-256/SHA-384), Ed25519, and (with OpenSSL 3.5+) ML-DSA-44/65/87 + /// signing. as_sign_mut: SignBackend, /// Supports CMS-RSA-GCM and CMS-RSA-CBC key wrapping and unwrapping. as_transport_mut: KeyTransportBackend, @@ -140,45 +141,66 @@ fn ossl_err(context: &str, e: &openssl::error::ErrorStack) -> BackendError { BackendError::Other(format!("{context}: {e}")) } -/// Infer `KeyAlgorithm` from a private key recovered by CMS decryption. +/// Reject a signature request whose algorithm does not match the stored key. /// -/// CMS `EnvelopedData` carries the raw key bytes as opaque content — the algorithm -/// of the wrapped key is not encoded in the CMS structure itself. -fn detect_key_algorithm(pkey: &PKey) -> Result { - if let Ok(rsa) = pkey.rsa() { - return match rsa.size() { - 256 => Ok(KeyAlgorithm::Rsa2048), - 512 => Ok(KeyAlgorithm::Rsa4096), - n => Err(BackendError::UnsupportedAlgorithm(format!( - "RSA modulus size {n} bytes ({} bits) not supported (expected 2048 or 4096 bits)", - n.saturating_mul(8) - ))), - }; +/// Runs once at the top of `sign` and `verify`, so the per-key-type arms below +/// only have to select an OpenSSL primitive. `operation` names the caller for +/// the error message ("Sign" or "Verify"). +fn check_key_accepted( + operation: &str, + algorithm: SignAlgorithm, + key_algorithm: KeyAlgorithm, +) -> Result<(), BackendError> { + if algorithm.accepts_key(key_algorithm) { + return Ok(()); } + Err(BackendError::UnsupportedAlgorithm(format!( + "{operation} algorithm {algorithm} not supported for {key_algorithm} keys" + ))) +} - if let Ok(ec_key) = pkey.ec_key() { - let nid = ec_key - .group() - .curve_name() - .ok_or_else(|| BackendError::Other("EC key has no named curve".to_string()))?; - return match nid { - Nid::X9_62_PRIME256V1 => Ok(KeyAlgorithm::EcdsaP256), - _ => Err(BackendError::UnsupportedAlgorithm(format!( - "EC curve {nid:?} is not supported for key transport (only P-256)" +/// Recover the `KeyAlgorithm` of a key from the key object itself. +/// +/// Needed wherever the algorithm is not carried alongside the key: CMS +/// `EnvelopedData` holds the raw key bytes as opaque content, and a bare SPKI +/// public key arrives with no ceremony metadata attached. +fn key_algorithm_of(pkey: &PKeyRef) -> Result { + match pkey.id() { + Id::RSA => match pkey.bits() { + 2048 => Ok(KeyAlgorithm::Rsa2048), + 4096 => Ok(KeyAlgorithm::Rsa4096), + bits => Err(BackendError::UnsupportedAlgorithm(format!( + "RSA key size {bits} bits not supported (expected 2048 or 4096)" ))), - }; - } + }, + Id::EC => { + let ec_key = pkey.ec_key().map_err(|e| ossl_err("Read EC key", &e))?; + let nid = ec_key + .group() + .curve_name() + .ok_or_else(|| BackendError::Other("EC key has no named curve".to_string()))?; + match nid { + Nid::X9_62_PRIME256V1 => Ok(KeyAlgorithm::EcdsaP256), + Nid::SECP384R1 => Ok(KeyAlgorithm::EcdsaP384), + _ => Err(BackendError::UnsupportedAlgorithm(format!( + "EC curve {nid:?} is not supported (expected P-256 or P-384)" + ))), + } + } + Id::ED25519 => Ok(KeyAlgorithm::Ed25519), + _ => { + #[cfg(ossl350)] + for (algorithm, key_type) in ML_DSA_KEY_TYPES { + if pkey.is_a(key_type) { + return Ok(algorithm); + } + } - #[cfg(ossl350)] - for (algorithm, key_type) in ML_DSA_KEY_TYPES { - if pkey.is_a(key_type) { - return Ok(algorithm); + Err(BackendError::UnsupportedAlgorithm( + "Key is not RSA, a supported EC curve, Ed25519, or ML-DSA".to_string(), + )) } } - - Err(BackendError::Other( - "Unwrapped key is not RSA, a supported EC key, or ML-DSA".to_string(), - )) } /// Seed length shared by every ML-DSA parameter set (FIPS 204 xi is 32 bytes). @@ -224,64 +246,183 @@ fn generate_ml_dsa(_algorithm: KeyAlgorithm) -> Result, BackendErr Err(unsupported_ml_dsa("key generation")) } -/// Sign with ML-DSA. +/// Refuse ML-DSA on a build whose OpenSSL has no provider for it. +/// +/// The signing and verification paths below are generic: `digest_for` already +/// routes ML-DSA to the digest-free `EVP_DigestSign` path that FIPS 204 needs, +/// so no separate implementation is required. What a pre-3.5 build does need is +/// this, an error naming the missing provider instead of whatever OpenSSL +/// reports when handed a key type it does not know. +#[cfg(not(ossl350))] +fn check_ml_dsa_available(algorithm: SignAlgorithm, operation: &str) -> Result<(), BackendError> { + if matches!( + algorithm, + SignAlgorithm::MlDsa44 | SignAlgorithm::MlDsa65 | SignAlgorithm::MlDsa87 + ) { + return Err(unsupported_ml_dsa(operation)); + } + Ok(()) +} + +/// Refuse ML-DSA on a build whose OpenSSL has no provider for it. /// -/// FIPS 204 signs the message directly with no pre-hash, so this takes the -/// digest-free `EVP_DigestSign` path. Signing is hedged by default, so two -/// signatures over the same message with the same key differ. +/// This build has one, so every algorithm is available. #[cfg(ossl350)] -fn ml_dsa_sign(pkey: &PKeyRef, message: &[u8]) -> Result, BackendError> { - let mut signer = - Signer::new_without_digest(pkey).map_err(|e| ossl_err("Create ML-DSA signer", &e))?; - signer - .sign_oneshot_to_vec(message) - .map_err(|e| ossl_err("ML-DSA sign operation", &e)) +#[allow(clippy::unnecessary_wraps)] +fn check_ml_dsa_available(_algorithm: SignAlgorithm, _operation: &str) -> Result<(), BackendError> { + Ok(()) } -#[cfg(not(ossl350))] -fn ml_dsa_sign(_pkey: &PKeyRef, _message: &[u8]) -> Result, BackendError> { - Err(unsupported_ml_dsa("signing")) +/// The RSA padding controls `Signer` and `Verifier` both have, which the +/// `openssl` crate does not express through a shared trait. +trait RsaPadding { + fn padding(&mut self, padding: Padding) -> Result<(), openssl::error::ErrorStack>; + fn mgf1_md(&mut self, md: MessageDigest) -> Result<(), openssl::error::ErrorStack>; +} + +impl RsaPadding for Signer<'_> { + fn padding(&mut self, padding: Padding) -> Result<(), openssl::error::ErrorStack> { + self.set_rsa_padding(padding) + } + fn mgf1_md(&mut self, md: MessageDigest) -> Result<(), openssl::error::ErrorStack> { + self.set_rsa_mgf1_md(md) + } } -/// Verify an ML-DSA signature against an SPKI DER public key, without a backend. +impl RsaPadding for Verifier<'_> { + fn padding(&mut self, padding: Padding) -> Result<(), openssl::error::ErrorStack> { + self.set_rsa_padding(padding) + } + fn mgf1_md(&mut self, md: MessageDigest) -> Result<(), openssl::error::ErrorStack> { + self.set_rsa_mgf1_md(md) + } +} + +/// Apply the padding scheme an RSA algorithm names. A no-op for everything else. /// -/// Verification needs only the public key, so this takes no [`OpenSslBackend`] -/// and no [`KeyId`]. Signing-only devices (PIV cards, HSMs) can therefore have -/// their signatures checked through the same path as software keys. +/// PKCS#1 v1.5 is OpenSSL's default, but it is set explicitly so the scheme is +/// never left to a library default that could change. +fn apply_rsa_padding( + operation: &mut T, + algorithm: SignAlgorithm, +) -> Result<(), BackendError> { + match algorithm { + SignAlgorithm::RsaPkcs1Sha256 => operation + .padding(Padding::PKCS1) + .map_err(|e| ossl_err("Set PKCS1 padding", &e)), + SignAlgorithm::RsaPssSha256 => { + operation + .padding(Padding::PKCS1_PSS) + .map_err(|e| ossl_err("Set PSS padding", &e))?; + operation + .mgf1_md(MessageDigest::sha256()) + .map_err(|e| ossl_err("Set MGF1 MD", &e)) + } + _ => Ok(()), + } +} + +/// The message digest an algorithm signs over, or `None` for the digest-free +/// schemes that take the message whole (Ed25519, ML-DSA). +fn digest_for(algorithm: SignAlgorithm) -> Option { + match algorithm { + SignAlgorithm::EcdsaSha384 => Some(MessageDigest::sha384()), + SignAlgorithm::Ed25519 + | SignAlgorithm::MlDsa44 + | SignAlgorithm::MlDsa65 + | SignAlgorithm::MlDsa87 => None, + _ => Some(MessageDigest::sha256()), + } +} + +/// Sign `message` with a private key. /// -/// # Errors +/// The caller has already checked that the key and algorithm agree, so this +/// only selects an OpenSSL primitive. +fn sign_with_key( + pkey: &PKeyRef, + message: &[u8], + algorithm: SignAlgorithm, +) -> Result, BackendError> { + check_ml_dsa_available(algorithm, "signing")?; + + let mut signer = match digest_for(algorithm) { + Some(digest) => Signer::new(digest, pkey), + None => Signer::new_without_digest(pkey), + } + .map_err(|e| ossl_err("Create signer", &e))?; + apply_rsa_padding(&mut signer, algorithm)?; + signer + .sign_oneshot_to_vec(message) + .map_err(|e| ossl_err("Sign operation", &e)) +} + +/// Verify `signature` over `message` with a public key. /// -/// Returns [`BackendError::UnsupportedAlgorithm`] when the linked OpenSSL -/// predates 3.5, and [`BackendError::Other`] when the public key or signature -/// cannot be parsed. -#[cfg(ossl350)] -pub fn verify_ml_dsa_signature( - public_der: &[u8], +/// The caller has already checked that the key and algorithm agree, so this +/// only selects an OpenSSL primitive. +fn verify_with_key( + pkey: &PKeyRef, message: &[u8], signature: &[u8], + algorithm: SignAlgorithm, ) -> Result { - let pub_pkey = PKey::public_key_from_der(public_der) - .map_err(|e| ossl_err("Decode ML-DSA public key", &e))?; - let mut verifier = Verifier::new_without_digest(&pub_pkey) - .map_err(|e| ossl_err("Create ML-DSA verifier", &e))?; + check_ml_dsa_available(algorithm, "verification")?; + + let mut verifier = match digest_for(algorithm) { + Some(digest) => Verifier::new(digest, pkey), + None => Verifier::new_without_digest(pkey), + } + .map_err(|e| ossl_err("Create verifier", &e))?; + apply_rsa_padding(&mut verifier, algorithm)?; verifier .verify_oneshot(signature, message) - .map_err(|e| ossl_err("ML-DSA verify operation", &e)) + .map_err(|e| ossl_err("Verify operation", &e)) } -/// Verify an ML-DSA signature against an SPKI DER public key, without a backend. +/// Read the key algorithm out of an SPKI DER public key. +/// +/// A public key that arrives as bytes carries no metadata, so callers that must +/// know what they are holding (to pick a signature algorithm, say) recover it +/// from the key structure. /// /// # Errors /// -/// Always returns [`BackendError::UnsupportedAlgorithm`]: this build links an -/// OpenSSL older than 3.5, which has no ML-DSA provider. -#[cfg(not(ossl350))] -pub fn verify_ml_dsa_signature( - _public_der: &[u8], - _message: &[u8], - _signature: &[u8], +/// Returns [`BackendError::UnsupportedAlgorithm`] for a key type this crate +/// does not handle, and [`BackendError::Other`] when the key cannot be parsed. +pub fn public_key_algorithm(public_der: &[u8]) -> Result { + let pkey = + PKey::public_key_from_der(public_der).map_err(|e| ossl_err("Decode public key", &e))?; + key_algorithm_of(&pkey) +} + +/// Verify a signature against an SPKI DER public key, without a backend. +/// +/// Verification needs only the public key, so this takes no [`OpenSslBackend`] +/// and no [`KeyId`]. Signatures from signing-only devices (PIV cards, HSMs) are +/// therefore checked through the same path as software keys. +/// +/// The key is required to match `algorithm`. Without that check, a caller who +/// took the algorithm from an untrusted source (a CSR's `signatureAlgorithm`, +/// say) could hand over an RSA key labelled as ECDSA and have OpenSSL quietly +/// verify it as RSA. +/// +/// # Errors +/// +/// Returns [`BackendError::UnsupportedAlgorithm`] when the key and algorithm +/// disagree, or when the algorithm is absent from this build (ML-DSA on an +/// OpenSSL older than 3.5), and [`BackendError::Other`] when the public key or +/// signature cannot be parsed. +pub fn verify_signature( + public_der: &[u8], + message: &[u8], + signature: &[u8], + algorithm: SignAlgorithm, ) -> Result { - Err(unsupported_ml_dsa("verification")) + let pkey = + PKey::public_key_from_der(public_der).map_err(|e| ossl_err("Decode public key", &e))?; + check_key_accepted("Verify", algorithm, key_algorithm_of(&pkey)?)?; + verify_with_key(&pkey, message, signature, algorithm) } /// Error for an ML-DSA `operation` on a build linked against OpenSSL below 3.5. @@ -320,19 +461,27 @@ impl KeyStoreBackend for OpenSslBackend { let rsa = Rsa::generate(4096).map_err(|e| ossl_err("RSA-4096 keygen", &e))?; PKey::from_rsa(rsa).map_err(|e| ossl_err("PKey from RSA-4096", &e))? } - KeyAlgorithm::EcdsaP256 => { - let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1) - .map_err(|e| ossl_err("Load P-256 group", &e))?; + KeyAlgorithm::EcdsaP256 | KeyAlgorithm::EcdsaP384 => { + let (nid, name) = if spec.algorithm == KeyAlgorithm::EcdsaP384 { + (Nid::SECP384R1, "ECDSA-P384") + } else { + (Nid::X9_62_PRIME256V1, "ECDSA-P256") + }; + let group = EcGroup::from_curve_name(nid) + .map_err(|e| ossl_err(&format!("Load {name} group"), &e))?; let ec_key = - EcKey::generate(&group).map_err(|e| ossl_err("ECDSA-P256 keygen", &e))?; - PKey::from_ec_key(ec_key).map_err(|e| ossl_err("PKey from ECDSA-P256", &e))? + EcKey::generate(&group).map_err(|e| ossl_err(&format!("{name} keygen"), &e))?; + PKey::from_ec_key(ec_key).map_err(|e| ossl_err(&format!("PKey from {name}"), &e))? + } + KeyAlgorithm::Ed25519 => { + PKey::generate_ed25519().map_err(|e| ossl_err("Ed25519 keygen", &e))? } KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87 => { generate_ml_dsa(spec.algorithm)? } other => { return Err(BackendError::UnsupportedAlgorithm(format!( - "Algorithm {other:?} not yet implemented for OpenSslBackend" + "Algorithm {other} not yet implemented for OpenSslBackend" ))); } }; @@ -383,64 +532,8 @@ impl SignBackend for OpenSslBackend { algorithm: SignAlgorithm, ) -> Result, BackendError> { let key = self.get_key(key_id)?; - - match key.algorithm { - KeyAlgorithm::Rsa2048 | KeyAlgorithm::Rsa4096 => { - let mut signer = Signer::new(MessageDigest::sha256(), &key.pkey) - .map_err(|e| ossl_err("Create signer", &e))?; - - match algorithm { - SignAlgorithm::RsaPkcs1Sha256 => { - signer - .set_rsa_padding(Padding::PKCS1) - .map_err(|e| ossl_err("Set PKCS1 padding", &e))?; - } - SignAlgorithm::RsaPssSha256 => { - signer - .set_rsa_padding(Padding::PKCS1_PSS) - .map_err(|e| ossl_err("Set PSS padding", &e))?; - signer - .set_rsa_mgf1_md(MessageDigest::sha256()) - .map_err(|e| ossl_err("Set MGF1 MD", &e))?; - } - other => { - return Err(BackendError::UnsupportedAlgorithm(format!( - "Sign algorithm {other:?} not supported for RSA keys" - ))); - } - } - - signer - .sign_oneshot_to_vec(message) - .map_err(|e| ossl_err("Sign operation", &e)) - } - KeyAlgorithm::EcdsaP256 => { - if algorithm != SignAlgorithm::EcdsaSha256 { - return Err(BackendError::UnsupportedAlgorithm(format!( - "Sign algorithm {algorithm:?} not supported for ECDSA-P256 keys" - ))); - } - let mut signer = Signer::new(MessageDigest::sha256(), &key.pkey) - .map_err(|e| ossl_err("Create ECDSA signer", &e))?; - signer - .sign_oneshot_to_vec(message) - .map_err(|e| ossl_err("ECDSA sign operation", &e)) - } - KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87 => { - // ML-DSA fixes one signature scheme per parameter set, so the - // request is valid only if it names the key's own algorithm. - if algorithm.key_algorithm() != key.algorithm { - return Err(BackendError::UnsupportedAlgorithm(format!( - "Sign algorithm {algorithm:?} not supported for {} keys", - key.algorithm - ))); - } - ml_dsa_sign(&key.pkey, message) - } - other => Err(BackendError::UnsupportedAlgorithm(format!( - "Signing not yet implemented for algorithm {other:?}" - ))), - } + check_key_accepted("Sign", algorithm, key.algorithm)?; + sign_with_key(&key.pkey, message, algorithm) } fn verify( @@ -451,68 +544,10 @@ impl SignBackend for OpenSslBackend { algorithm: SignAlgorithm, ) -> Result { let key = self.get_key(key_id)?; - - match key.algorithm { - KeyAlgorithm::Rsa2048 | KeyAlgorithm::Rsa4096 => { - let pub_pkey = PKey::public_key_from_der(&key.public_der) - .map_err(|e| ossl_err("Decode public key", &e))?; - - let mut verifier = Verifier::new(MessageDigest::sha256(), &pub_pkey) - .map_err(|e| ossl_err("Create verifier", &e))?; - - match algorithm { - SignAlgorithm::RsaPkcs1Sha256 => { - verifier - .set_rsa_padding(Padding::PKCS1) - .map_err(|e| ossl_err("Set PKCS1 padding", &e))?; - } - SignAlgorithm::RsaPssSha256 => { - verifier - .set_rsa_padding(Padding::PKCS1_PSS) - .map_err(|e| ossl_err("Set PSS padding", &e))?; - verifier - .set_rsa_mgf1_md(MessageDigest::sha256()) - .map_err(|e| ossl_err("Set MGF1 MD", &e))?; - } - other => { - return Err(BackendError::UnsupportedAlgorithm(format!( - "Verify algorithm {other:?} not supported for RSA keys" - ))); - } - } - - Ok(verifier - .verify_oneshot(signature, message) - .map_err(|e| ossl_err("Verify operation", &e))?) - } - KeyAlgorithm::EcdsaP256 => { - if algorithm != SignAlgorithm::EcdsaSha256 { - return Err(BackendError::UnsupportedAlgorithm(format!( - "Verify algorithm {algorithm:?} not supported for ECDSA-P256 keys" - ))); - } - - let pub_pkey = PKey::public_key_from_der(&key.public_der) - .map_err(|e| ossl_err("Decode ECDSA public key", &e))?; - let mut verifier = Verifier::new(MessageDigest::sha256(), &pub_pkey) - .map_err(|e| ossl_err("Create ECDSA verifier", &e))?; - Ok(verifier - .verify_oneshot(signature, message) - .map_err(|e| ossl_err("ECDSA verify operation", &e))?) - } - KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87 => { - if algorithm.key_algorithm() != key.algorithm { - return Err(BackendError::UnsupportedAlgorithm(format!( - "Verify algorithm {algorithm:?} not supported for {} keys", - key.algorithm - ))); - } - verify_ml_dsa_signature(&key.public_der, message, signature) - } - other => Err(BackendError::UnsupportedAlgorithm(format!( - "Verification not yet implemented for algorithm {other:?}" - ))), - } + check_key_accepted("Verify", algorithm, key.algorithm)?; + // The stored private key carries its public half, so there is nothing + // to decode: `public_der` exists for export, not for verification. + verify_with_key(&key.pkey, message, signature, algorithm) } } @@ -706,7 +741,7 @@ impl KeyTransportBackend for OpenSslBackend { let pkey = parse_private_key_der(&key_material)?; - let key_algorithm = detect_key_algorithm(&pkey)?; + let key_algorithm = key_algorithm_of(&pkey)?; self.store_key(key_algorithm, label.to_string(), pkey) } @@ -893,6 +928,118 @@ mod tests { assert!(valid); } + /// Every signature family the backend claims must round-trip through + /// `sign` and `verify`, including the two digest-free schemes whose OpenSSL + /// path differs (Ed25519 and, above, ML-DSA). + #[test] + fn signs_and_verifies_every_supported_algorithm() { + let cases: &[(KeyAlgorithm, SignAlgorithm)] = &[ + (KeyAlgorithm::Rsa2048, SignAlgorithm::RsaPkcs1Sha256), + (KeyAlgorithm::Rsa2048, SignAlgorithm::RsaPssSha256), + (KeyAlgorithm::EcdsaP256, SignAlgorithm::EcdsaSha256), + (KeyAlgorithm::EcdsaP384, SignAlgorithm::EcdsaSha384), + (KeyAlgorithm::Ed25519, SignAlgorithm::Ed25519), + ]; + + for &(key_algorithm, algorithm) in cases { + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let metadata = backend + .generate_key(spec(key_algorithm, "signing-key")) + .unwrap(); + let message = b"ceremony transcript"; + + let signature = backend + .sign(&metadata.key_id, message, algorithm) + .unwrap_or_else(|e| panic!("{key_algorithm} signs with {algorithm}: {e}")); + + assert!( + backend + .verify(&metadata.key_id, message, &signature, algorithm) + .unwrap(), + "{key_algorithm} must verify its own {algorithm} signature" + ); + assert!( + !backend + .verify(&metadata.key_id, b"tampered", &signature, algorithm) + .unwrap(), + "{key_algorithm} must reject a {algorithm} signature over other data" + ); + + // The same signature must check out through the backend-free entry + // point, which is what actions and CSR checking use. + let public_der = metadata.public_key.as_ref().unwrap(); + assert!(verify_signature(public_der, message, &signature, algorithm).unwrap()); + } + } + + /// `verify_signature` takes its algorithm from the caller, which for CSR + /// checking means from the document being checked. A key of another family + /// must be refused rather than verified under whatever scheme it fits. + #[test] + fn backend_free_verification_refuses_a_key_of_the_wrong_family() { + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let metadata = backend + .generate_key(spec(KeyAlgorithm::Rsa2048, "rsa-key")) + .unwrap(); + let message = b"data"; + let signature = backend + .sign(&metadata.key_id, message, SignAlgorithm::RsaPkcs1Sha256) + .unwrap(); + let public_der = metadata.public_key.as_ref().unwrap(); + + let err = verify_signature(public_der, message, &signature, SignAlgorithm::EcdsaSha256) + .unwrap_err(); + assert!( + matches!(err, BackendError::UnsupportedAlgorithm(_)), + "{err:?}" + ); + } + + /// An RSA signature scheme is defined for any modulus size, so the shared + /// compatibility check must not pin RSA requests to one key size. + #[test] + fn signs_with_an_rsa_4096_key() { + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let metadata = backend + .generate_key(spec(KeyAlgorithm::Rsa4096, "signing-key-4096")) + .unwrap(); + + let message = b"Hello, large modulus!"; + let signature = backend + .sign(&metadata.key_id, message, SignAlgorithm::RsaPkcs1Sha256) + .unwrap(); + + let valid = backend + .verify( + &metadata.key_id, + message, + &signature, + SignAlgorithm::RsaPkcs1Sha256, + ) + .unwrap(); + assert!(valid); + } + + /// A key of the wrong family is refused before any OpenSSL primitive is + /// selected, and the error names both sides in their DSL spelling. + #[test] + fn rejects_a_signature_algorithm_the_key_cannot_perform() { + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let metadata = backend + .generate_key(spec(KeyAlgorithm::EcdsaP256, "signing-key-p256")) + .unwrap(); + + let err = backend + .sign(&metadata.key_id, b"data", SignAlgorithm::RsaPkcs1Sha256) + .unwrap_err(); + + let BackendError::UnsupportedAlgorithm(message) = err else { + panic!("expected an unsupported-algorithm error, got {err:?}"); + }; + assert!(message.contains("RSA-PKCS1-SHA256"), "{message}"); + assert!(message.contains("ECDSA-P256"), "{message}"); + } + #[test] fn test_backend_fingerprint() { let backend = OpenSslBackend::try_new("my-backend").unwrap(); diff --git a/crates/rite-openssl/src/lib.rs b/crates/rite-openssl/src/lib.rs index b001c11..cd5d268 100644 --- a/crates/rite-openssl/src/lib.rs +++ b/crates/rite-openssl/src/lib.rs @@ -12,6 +12,10 @@ //! - [`KeyTransportBackend`](rite_sdk::KeyTransportBackend): key wrapping and unwrapping //! - [`RandomBackend`](rite_sdk::RandomBackend): random byte generation //! +//! [`verify_signature`] is also available on its own, without a backend +//! instance: checking a signature needs only the public key, so it works for +//! keys this crate never held (a PIV card's, say). +//! //! # Feature flags //! //! - `vendored`: bundle OpenSSL at build time (no system library needed). Required for @@ -27,7 +31,7 @@ mod backend; -pub use backend::{OpenSslBackend, verify_ml_dsa_signature}; +pub use backend::{OpenSslBackend, public_key_algorithm, verify_signature}; /// Whether this build can perform ML-DSA operations. /// diff --git a/crates/rite-piv/src/ops.rs b/crates/rite-piv/src/ops.rs index 008cb07..a0ba638 100644 --- a/crates/rite-piv/src/ops.rs +++ b/crates/rite-piv/src/ops.rs @@ -385,7 +385,7 @@ pub fn sign( SignAlgorithm::RsaPssSha256 => { return Err(BackendError::UnsupportedAlgorithm( "RSA-PSS requires client-side encoding that is not implemented; \ - use rsa_pkcs1_sha256" + use RSA-PKCS1-SHA256" .to_string(), )); } @@ -397,14 +397,14 @@ pub fn sign( // `SignAlgorithm` is #[non_exhaustive]; reject anything PIV cannot do. _ => { return Err(BackendError::UnsupportedAlgorithm(format!( - "{algorithm:?} is not supported by PIV cards" + "{algorithm} is not supported by PIV cards" ))); } }; // The card algorithm comes from the shared SignAlgorithm -> KeyAlgorithm // pairing so this table cannot drift from the SDK or the mock backend. let yk_algo = convert::to_yubikey_algorithm(algorithm.key_algorithm()).ok_or_else(|| { - BackendError::UnsupportedAlgorithm(format!("{algorithm:?} is not supported by PIV cards")) + BackendError::UnsupportedAlgorithm(format!("{algorithm} is not supported by PIV cards")) })?; let sig = piv::sign_data(yk, &input, yk_algo, slot).map_err(map_error)?; Ok(sig.to_vec()) diff --git a/crates/rite-resolver/src/resolve.rs b/crates/rite-resolver/src/resolve.rs index a6c1407..5c90c0d 100644 --- a/crates/rite-resolver/src/resolve.rs +++ b/crates/rite-resolver/src/resolve.rs @@ -15,7 +15,7 @@ use rite_model::{ MaterialSource, Metadata, Output, OutputId, ParamId, Parameter, PostCeremonyDuty, RetryPolicy, Role, RoleId, Section, SectionId, Step, StepId, StepInputs, SymbolTable, }; -use rite_model::{DutyType, ParameterType}; +use rite_model::{BackendUsage, DutyType, ParameterType}; use std::collections::{HashMap, HashSet}; /// Schema versions this resolver understands. @@ -558,7 +558,7 @@ impl ResolveContext { step: &schema::StepBody, ceremony: &schema::Ceremony, ) { - if step.backend.is_some() && !step.action.requires_backend() { + if step.backend.is_some() && step.action.backend_usage() == BackendUsage::Unused { self.warnings .push(ResolveWarning::UnusedBackend { step: id.clone() }); } @@ -570,7 +570,7 @@ impl ResolveContext { backend: backend_name.clone(), }); } - if step.action.requires_backend() && step.backend.is_none() { + if step.action.backend_usage() == BackendUsage::Required && step.backend.is_none() { self.add_error(ResolveError::MissingRequiredBackend { step: id.clone(), action: step.action, diff --git a/crates/rite-runtime/src/lib.rs b/crates/rite-runtime/src/lib.rs index c512eec..eb77f9b 100644 --- a/crates/rite-runtime/src/lib.rs +++ b/crates/rite-runtime/src/lib.rs @@ -101,4 +101,4 @@ pub use expressions::{ }; // Artifact resolution (used by action implementors). -pub use artifact_resolver::{resolve_artifact_bytes, resolve_backend_key}; +pub use artifact_resolver::{BackendKeyMeta, resolve_artifact_bytes, resolve_backend_key}; diff --git a/crates/rite-sdk/src/types.rs b/crates/rite-sdk/src/types.rs index 9de026b..9116af3 100644 --- a/crates/rite-sdk/src/types.rs +++ b/crates/rite-sdk/src/types.rs @@ -96,6 +96,31 @@ pub enum KeyAlgorithm { Aes256, } +impl KeyAlgorithm { + /// The signature algorithm to use with this key unless told otherwise. + /// + /// `None` for symmetric keys, which sign nothing. + /// + /// A key algorithm does not always determine a signature algorithm: RSA + /// keys work with both PKCS#1 v1.5 and PSS, and this picks v1.5 for + /// interoperability. Everywhere else the pairing is forced, either by the + /// curve's matching digest strength (RFC 5480) or by the scheme naming its + /// own digest (Ed25519, ML-DSA). + #[must_use] + pub fn default_sign_algorithm(self) -> Option { + match self { + KeyAlgorithm::Rsa2048 | KeyAlgorithm::Rsa4096 => Some(SignAlgorithm::RsaPkcs1Sha256), + KeyAlgorithm::EcdsaP256 => Some(SignAlgorithm::EcdsaSha256), + KeyAlgorithm::EcdsaP384 => Some(SignAlgorithm::EcdsaSha384), + KeyAlgorithm::Ed25519 => Some(SignAlgorithm::Ed25519), + KeyAlgorithm::MlDsa44 => Some(SignAlgorithm::MlDsa44), + KeyAlgorithm::MlDsa65 => Some(SignAlgorithm::MlDsa65), + KeyAlgorithm::MlDsa87 => Some(SignAlgorithm::MlDsa87), + KeyAlgorithm::Aes128 | KeyAlgorithm::Aes256 => None, + } + } +} + impl fmt::Display for KeyAlgorithm { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { @@ -262,7 +287,7 @@ pub struct KeySecurityAttributes { /// Signature algorithm. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] +#[serde(into = "String", try_from = "String")] #[non_exhaustive] pub enum SignAlgorithm { /// RSASSA-PKCS1-v1_5 with SHA-256. @@ -284,11 +309,18 @@ pub enum SignAlgorithm { } impl SignAlgorithm { - /// The key algorithm this signature algorithm is used with. + /// A representative key algorithm for this signature algorithm. /// - /// This pairing is the shared source of truth for backends that select a - /// device algorithm from a signature request and for test doubles that - /// mint stand-in keys, so the two cannot drift apart. + /// Used where a signature request must be turned into a concrete key + /// algorithm: selecting a card algorithm, or minting a stand-in key for a + /// rehearsal. Keeping that mapping here means those callers cannot drift + /// apart from each other. + /// + /// The mapping is deliberately lossy for RSA: both RSA schemes answer + /// `Rsa2048`, because a signature algorithm does not name a modulus size. + /// Use [`accepts_key`](Self::accepts_key) to test whether a key may be used + /// with this algorithm; the equality `alg.key_algorithm() == key` is not + /// that test and rejects RSA-4096 keys. #[must_use] pub fn key_algorithm(self) -> KeyAlgorithm { match self { @@ -301,6 +333,78 @@ impl SignAlgorithm { SignAlgorithm::MlDsa87 => KeyAlgorithm::MlDsa87, } } + + /// Whether a key of `key_algorithm` may be used with this signature algorithm. + /// + /// The compatibility check every signing backend needs, in one place, so + /// each one does not reinvent it per key type. Curve and parameter set are + /// pinned: an ECDSA-SHA256 request will not take a P-384 key, and each + /// ML-DSA parameter set fixes its own scheme. Only the RSA schemes span + /// more than one key, since they are defined for any modulus size. + #[must_use] + pub fn accepts_key(self, key_algorithm: KeyAlgorithm) -> bool { + match self { + SignAlgorithm::RsaPkcs1Sha256 | SignAlgorithm::RsaPssSha256 => { + matches!(key_algorithm, KeyAlgorithm::Rsa2048 | KeyAlgorithm::Rsa4096) + } + // Every other scheme pins exactly one key algorithm, which + // `key_algorithm` already names. Restating the pairing here would + // give it two places to be wrong in. + SignAlgorithm::EcdsaSha256 + | SignAlgorithm::EcdsaSha384 + | SignAlgorithm::Ed25519 + | SignAlgorithm::MlDsa44 + | SignAlgorithm::MlDsa65 + | SignAlgorithm::MlDsa87 => self.key_algorithm() == key_algorithm, + } + } +} + +impl fmt::Display for SignAlgorithm { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SignAlgorithm::RsaPkcs1Sha256 => write!(f, "RSA-PKCS1-SHA256"), + SignAlgorithm::RsaPssSha256 => write!(f, "RSA-PSS-SHA256"), + SignAlgorithm::EcdsaSha256 => write!(f, "ECDSA-SHA256"), + SignAlgorithm::EcdsaSha384 => write!(f, "ECDSA-SHA384"), + SignAlgorithm::Ed25519 => write!(f, "Ed25519"), + SignAlgorithm::MlDsa44 => write!(f, "ML-DSA-44"), + SignAlgorithm::MlDsa65 => write!(f, "ML-DSA-65"), + SignAlgorithm::MlDsa87 => write!(f, "ML-DSA-87"), + } + } +} + +impl std::str::FromStr for SignAlgorithm { + type Err = ParseError; + + fn from_str(s: &str) -> Result { + match s { + "RSA-PKCS1-SHA256" => Ok(Self::RsaPkcs1Sha256), + "RSA-PSS-SHA256" => Ok(Self::RsaPssSha256), + "ECDSA-SHA256" => Ok(Self::EcdsaSha256), + "ECDSA-SHA384" => Ok(Self::EcdsaSha384), + "Ed25519" => Ok(Self::Ed25519), + "ML-DSA-44" => Ok(Self::MlDsa44), + "ML-DSA-65" => Ok(Self::MlDsa65), + "ML-DSA-87" => Ok(Self::MlDsa87), + _ => Err(ParseError(s.to_owned())), + } + } +} + +impl From for String { + fn from(a: SignAlgorithm) -> String { + a.to_string() + } +} + +impl TryFrom for SignAlgorithm { + type Error = ParseError; + + fn try_from(s: String) -> Result { + s.parse() + } } /// Wrapping algorithm. Determines both the cryptographic method and the output format. @@ -737,6 +841,9 @@ mod tests { (KeyAlgorithm::EcdsaP256, "\"ECDSA-P256\""), (KeyAlgorithm::EcdsaP384, "\"ECDSA-P384\""), (KeyAlgorithm::Ed25519, "\"Ed25519\""), + (KeyAlgorithm::MlDsa44, "\"ML-DSA-44\""), + (KeyAlgorithm::MlDsa65, "\"ML-DSA-65\""), + (KeyAlgorithm::MlDsa87, "\"ML-DSA-87\""), (KeyAlgorithm::Aes128, "\"AES-128\""), (KeyAlgorithm::Aes256, "\"AES-256\""), ]; @@ -748,6 +855,92 @@ mod tests { } } + #[test] + fn sign_algorithm_serde_roundtrip() { + // Serde uses Display strings via `serde(into/try_from)`. These are the canonical + // strings for ceremony YAML `algorithm:` fields and transcripts. + let cases: &[(SignAlgorithm, &str)] = &[ + (SignAlgorithm::RsaPkcs1Sha256, "\"RSA-PKCS1-SHA256\""), + (SignAlgorithm::RsaPssSha256, "\"RSA-PSS-SHA256\""), + (SignAlgorithm::EcdsaSha256, "\"ECDSA-SHA256\""), + (SignAlgorithm::EcdsaSha384, "\"ECDSA-SHA384\""), + (SignAlgorithm::Ed25519, "\"Ed25519\""), + (SignAlgorithm::MlDsa44, "\"ML-DSA-44\""), + (SignAlgorithm::MlDsa65, "\"ML-DSA-65\""), + (SignAlgorithm::MlDsa87, "\"ML-DSA-87\""), + ]; + for &(variant, expected) in cases { + let serialized = serde_json::to_string(&variant).unwrap(); + assert_eq!(serialized, expected, "serialize {variant:?}"); + let deserialized: SignAlgorithm = serde_json::from_str(expected).unwrap(); + assert_eq!(deserialized, variant, "deserialize {expected}"); + } + } + + #[test] + fn sign_algorithm_from_str_rejects_unknown() { + assert!("RSA-PKCS1-SHA512".parse::().is_err()); + assert!("".parse::().is_err()); + assert!( + "ecdsa-sha256".parse::().is_err(), + "must be case-sensitive" + ); + assert!( + "ecdsa_sha256".parse::().is_err(), + "the snake_case spelling is not accepted" + ); + } + + /// The default must be a signature algorithm the key can actually perform, + /// or actions that derive one would hand backends an impossible request. + #[test] + fn every_default_sign_algorithm_accepts_its_own_key() { + let signing_keys = [ + KeyAlgorithm::Rsa2048, + KeyAlgorithm::Rsa4096, + KeyAlgorithm::EcdsaP256, + KeyAlgorithm::EcdsaP384, + KeyAlgorithm::Ed25519, + KeyAlgorithm::MlDsa44, + KeyAlgorithm::MlDsa65, + KeyAlgorithm::MlDsa87, + ]; + for key_algorithm in signing_keys { + let algorithm = key_algorithm + .default_sign_algorithm() + .unwrap_or_else(|| panic!("{key_algorithm} must have a default")); + assert!( + algorithm.accepts_key(key_algorithm), + "{key_algorithm} defaults to {algorithm}, which rejects it" + ); + } + + // Symmetric keys sign nothing. + assert!(KeyAlgorithm::Aes128.default_sign_algorithm().is_none()); + assert!(KeyAlgorithm::Aes256.default_sign_algorithm().is_none()); + } + + #[test] + fn sign_algorithm_accepts_key_spans_rsa_sizes_and_pins_everything_else() { + // RSA signature schemes are defined for any modulus size, so both key + // sizes are valid. This is what `key_algorithm()` cannot express. + for algorithm in [SignAlgorithm::RsaPkcs1Sha256, SignAlgorithm::RsaPssSha256] { + assert!(algorithm.accepts_key(KeyAlgorithm::Rsa2048)); + assert!(algorithm.accepts_key(KeyAlgorithm::Rsa4096)); + assert!(!algorithm.accepts_key(KeyAlgorithm::EcdsaP256)); + } + + // Curves and ML-DSA parameter sets are pinned: a signature algorithm + // names exactly one key algorithm and rejects its neighbours. + assert!(SignAlgorithm::EcdsaSha256.accepts_key(KeyAlgorithm::EcdsaP256)); + assert!(!SignAlgorithm::EcdsaSha256.accepts_key(KeyAlgorithm::EcdsaP384)); + assert!(SignAlgorithm::MlDsa65.accepts_key(KeyAlgorithm::MlDsa65)); + assert!(!SignAlgorithm::MlDsa65.accepts_key(KeyAlgorithm::MlDsa87)); + + // A signing algorithm never accepts a symmetric key. + assert!(!SignAlgorithm::Ed25519.accepts_key(KeyAlgorithm::Aes256)); + } + #[test] fn wrap_algorithm_serde_roundtrip() { // Serde uses Display strings via `serde(into/try_from)`. These strings appear diff --git a/crates/rite-stdlib/Cargo.toml b/crates/rite-stdlib/Cargo.toml index f37c098..c2e2173 100644 --- a/crates/rite-stdlib/Cargo.toml +++ b/crates/rite-stdlib/Cargo.toml @@ -15,8 +15,10 @@ publish = true default = ["verification", "attestation", "crypto", "pki", "openssl"] verification = ["dep:subtle", "dep:sysinfo"] attestation = [] -crypto = [] -pki = ["dep:x509-cert", "dep:signature", "dep:rsa", "dep:p256"] +# Both verify signatures in process (CSR proof-of-possession, `verify_signature`), +# so they need a crypto provider rather than only a backend at run time. +crypto = ["openssl"] +pki = ["dep:x509-cert", "dep:signature", "openssl"] openssl = ["dep:rite-openssl"] openssl-vendored = ["openssl", "rite-openssl/vendored"] # Hardware smart-card backends. Opt-in: they pull in the `yubikey` crate, which @@ -43,8 +45,6 @@ subtle = { workspace = true, optional = true } sysinfo = { workspace = true, optional = true } x509-cert = { workspace = true, optional = true } signature = { workspace = true, optional = true } -rsa = { workspace = true, optional = true } -p256 = { workspace = true, optional = true } secrecy = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/rite-stdlib/src/crypto/mod.rs b/crates/rite-stdlib/src/crypto/mod.rs index f08cd46..0529346 100644 --- a/crates/rite-stdlib/src/crypto/mod.rs +++ b/crates/rite-stdlib/src/crypto/mod.rs @@ -2,10 +2,14 @@ mod export_public; mod generate_keypair; +mod sign_data; mod unwrap_key; +mod verify_signature; mod wrap_key; pub use export_public::ExportPublicAction; pub use generate_keypair::GenerateKeypairAction; +pub use sign_data::SignDataAction; pub use unwrap_key::UnwrapKeyAction; +pub use verify_signature::VerifySignatureAction; pub use wrap_key::WrapKeyAction; diff --git a/crates/rite-stdlib/src/crypto/sign_data.rs b/crates/rite-stdlib/src/crypto/sign_data.rs new file mode 100644 index 0000000..9efe05a --- /dev/null +++ b/crates/rite-stdlib/src/crypto/sign_data.rs @@ -0,0 +1,222 @@ +//! `sign_data` action: sign arbitrary data with a backend-managed key. + +use rite_model::{ActionType, StepFact}; +use rite_runtime::{ + Action, ActionCategory, ActionError, ActionMetadata, ArtifactValue, HandlerContext, Icon, + Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_artifact_bytes, + resolve_backend_key, +}; +use rite_sdk::{Backend, KeyAlgorithm, SignAlgorithm, SignBackend}; +use serde_json::json; + +use crate::params::SignDataParams; + +/// Sign data with a key the backend holds. +/// +/// The generic counterpart to `piv_sign`: it works with any backend +/// implementing `SignBackend`, and takes the key by artifact reference rather +/// than by device slot. +pub struct SignDataAction; + +impl Action for SignDataAction { + fn metadata(&self) -> ActionMetadata { + ActionMetadata { + action_type: ActionType::SignData, + description: "Sign data with a backend-managed key", + category: ActionCategory::Crypto, + } + } + + fn execute( + &self, + step: &StepInfo, + ctx: &HandlerContext, + params: &serde_json::Value, + reporter: &mut Reporter<'_>, + backend: Option<&mut dyn Backend>, + ) -> Result { + let typed: SignDataParams = parse_params(params)?; + + if let Some(message) = &typed.message { + reporter.log(Icon::Info, message.clone())?; + } + + let key_ref = step.required_named_input("key", "sign_data")?; + let data_ref = step.required_named_input("data", "sign_data")?; + + let key_id = key_ref.artifact_id(); + let (key_backend_name, backend_key_id, key_algorithm, _) = + resolve_backend_key(ctx.artifacts, &key_id).map_err(|e| { + ActionError::Failed(format!( + "sign_data input 'key' ('{}') must be a backend-managed key: {e}", + key_ref.display_name() + )) + })?; + let backend_key_id = backend_key_id.clone(); + + let algorithm = resolve_sign_algorithm(typed.algorithm.as_deref(), key_algorithm)?; + + let data_id = data_ref.artifact_id(); + let data = + resolve_artifact_bytes(ctx.artifacts, &data_id, data_ref.property()).map_err(|e| { + ActionError::Failed(format!( + "sign_data input 'data' ('{}') could not be resolved: {e}", + data_ref.display_name() + )) + })?; + + reporter.log( + Icon::Spinner, + format!( + "Signing {} ({} bytes) with {algorithm}...", + data_ref.display_name(), + data.len() + ), + )?; + + let backend = backend + .ok_or_else(|| ActionError::Failed("Backend required for sign_data".to_string()))?; + let backend_name = backend.name().to_string(); + let backend_fingerprint = backend.fingerprint(); + + let sign_backend = + require_sign_backend(backend, key_backend_name, &key_ref.display_name())?; + let signature = sign_backend.sign(&backend_key_id, &data, algorithm)?; + + let signature_fingerprint = compute_fingerprint(&signature); + reporter.log( + Icon::Checkmark, + format!("Signature produced ({} bytes)", signature.len()), + )?; + + reporter.fact(StepFact::BackendOperation { + step: step.id.clone(), + kind: "sign_data".to_string(), + inputs: json!({ + "key_artifact": key_ref.display_name(), + "data_artifact": data_ref.display_name(), + "key_algorithm": key_algorithm.to_string(), + "algorithm": algorithm.to_string(), + }), + outputs: json!({ + "backend": backend_name, + "backend_fingerprint": backend_fingerprint, + "signature_len": signature.len(), + }), + fingerprint: Some(signature_fingerprint), + })?; + + if let Some(produces) = &step.produces { + reporter.log( + Icon::Info, + format!("Signature stored as artifact '{produces}'"), + )?; + Ok(StepResult::completed_with_artifact( + "Signature produced", + produces.clone(), + ArtifactValue::Bytes(signature), + )) + } else { + Ok(StepResult::completed("Signature produced")) + } + } +} + +/// Check that `backend` owns the key, and expose its signing capability. +/// +/// Shared by both signing actions so an operator who points a step at the wrong +/// backend gets the same message either way. Read `name()` and `fingerprint()` +/// before calling: the returned capability borrows the backend mutably. +pub(super) fn require_sign_backend<'a>( + backend: &'a mut dyn Backend, + key_owner: &str, + key_name: &str, +) -> Result<&'a mut dyn SignBackend, ActionError> { + let backend_name = backend.name().to_string(); + if key_owner != backend_name { + return Err(ActionError::Failed(format!( + "Key '{key_name}' is owned by backend '{key_owner}', but this step runs on '{backend_name}'" + ))); + } + backend.as_sign_mut().ok_or_else(|| { + ActionError::Failed(format!("Backend '{backend_name}' does not support signing")) + }) +} + +/// Decide which signature algorithm to use for a key. +/// +/// Shared with `verify_signature`, which faces the same choice from the other +/// side and must reach the same answer for an unannotated step. +/// +/// An explicit name is checked against the key rather than trusted: a ceremony +/// that names an algorithm the key cannot perform is a mistake worth reporting +/// before any signing is attempted, not a backend error mid-step. +pub(super) fn resolve_sign_algorithm( + requested: Option<&str>, + key_algorithm: KeyAlgorithm, +) -> Result { + let Some(requested) = requested else { + return key_algorithm.default_sign_algorithm().ok_or_else(|| { + ActionError::Failed(format!("{key_algorithm} keys cannot produce signatures")) + }); + }; + + let algorithm: SignAlgorithm = requested + .parse() + .map_err(|_| ActionError::Failed(format!("Unknown signature algorithm: {requested}")))?; + + if !algorithm.accepts_key(key_algorithm) { + return Err(ActionError::Failed(format!( + "Signature algorithm {algorithm} cannot be used with a {key_algorithm} key" + ))); + } + Ok(algorithm) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn derives_the_algorithm_from_the_key_when_unspecified() { + assert_eq!( + resolve_sign_algorithm(None, KeyAlgorithm::EcdsaP384).unwrap(), + SignAlgorithm::EcdsaSha384 + ); + assert_eq!( + resolve_sign_algorithm(None, KeyAlgorithm::MlDsa87).unwrap(), + SignAlgorithm::MlDsa87 + ); + } + + /// The override exists for this case: an RSA key admits two schemes and the + /// key alone cannot say which the ceremony wants. + #[test] + fn honours_an_override_the_key_supports() { + assert_eq!( + resolve_sign_algorithm(Some("RSA-PSS-SHA256"), KeyAlgorithm::Rsa4096).unwrap(), + SignAlgorithm::RsaPssSha256 + ); + } + + #[test] + fn rejects_an_override_the_key_cannot_perform() { + let err = resolve_sign_algorithm(Some("ECDSA-SHA256"), KeyAlgorithm::Rsa2048).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("ECDSA-SHA256"), "{message}"); + assert!(message.contains("RSA-2048"), "{message}"); + } + + #[test] + fn rejects_an_unknown_algorithm_name() { + assert!(resolve_sign_algorithm(Some("ecdsa_sha256"), KeyAlgorithm::EcdsaP256).is_err()); + } + + /// A symmetric key reaching a signing step is a ceremony authoring error, + /// and the message should say so rather than name a missing default. + #[test] + fn rejects_a_key_that_cannot_sign() { + let err = resolve_sign_algorithm(None, KeyAlgorithm::Aes256).unwrap_err(); + assert!(err.to_string().contains("cannot produce signatures")); + } +} diff --git a/crates/rite-stdlib/src/crypto/verify_signature.rs b/crates/rite-stdlib/src/crypto/verify_signature.rs new file mode 100644 index 0000000..1f40f29 --- /dev/null +++ b/crates/rite-stdlib/src/crypto/verify_signature.rs @@ -0,0 +1,198 @@ +//! `verify_signature` action: check a signature against a public key. + +use rite_model::{ActionType, StepFact}; +use rite_runtime::{ + Action, ActionCategory, ActionError, ActionMetadata, BackendKeyMeta, HandlerContext, Icon, + Reporter, StepInfo, StepResult, compute_fingerprint, parse_params, resolve_artifact_bytes, + resolve_backend_key, +}; +use rite_sdk::{Backend, KeyAlgorithm}; +use serde_json::json; + +use super::sign_data::{require_sign_backend, resolve_sign_algorithm}; +use crate::params::VerifySignatureParams; + +/// Verify a signature over data, given the signer's public key. +/// +/// Unlike every other cryptographic action, this one needs no backend. +/// Verification takes only a public key, so a ceremony can check evidence it +/// did not produce: a signature made on a smart card, or one that arrived with +/// a document from outside. Naming a `backend:` on the step delegates the check +/// to that backend instead, which is what a remote or hardware verifier needs. +pub struct VerifySignatureAction; + +impl Action for VerifySignatureAction { + fn metadata(&self) -> ActionMetadata { + ActionMetadata { + action_type: ActionType::VerifySignature, + description: "Verify a signature against a public key", + category: ActionCategory::Crypto, + } + } + + fn execute( + &self, + step: &StepInfo, + ctx: &HandlerContext, + params: &serde_json::Value, + reporter: &mut Reporter<'_>, + backend: Option<&mut dyn Backend>, + ) -> Result { + let typed: VerifySignatureParams = parse_params(params)?; + + if let Some(message) = &typed.message { + reporter.log(Icon::Info, message.clone())?; + } + + let key_ref = step.required_named_input("key", "verify_signature")?; + let data_ref = step.required_named_input("data", "verify_signature")?; + let signature_ref = step.required_named_input("signature", "verify_signature")?; + + let key_id = key_ref.artifact_id(); + let backend_key = resolve_backend_key(ctx.artifacts, &key_id).ok(); + let (public_der, key_algorithm) = + resolve_public_key(ctx, key_ref, &key_id, backend_key.as_ref())?; + + let algorithm = resolve_sign_algorithm(typed.algorithm.as_deref(), key_algorithm)?; + + let data_id = data_ref.artifact_id(); + let data = + resolve_artifact_bytes(ctx.artifacts, &data_id, data_ref.property()).map_err(|e| { + ActionError::Failed(format!( + "verify_signature input 'data' ('{}') could not be resolved: {e}", + data_ref.display_name() + )) + })?; + + let signature_id = signature_ref.artifact_id(); + let signature = + resolve_artifact_bytes(ctx.artifacts, &signature_id, signature_ref.property()) + .map_err(|e| { + ActionError::Failed(format!( + "verify_signature input 'signature' ('{}') could not be resolved: {e}", + signature_ref.display_name() + )) + })?; + + reporter.log( + Icon::Spinner, + format!( + "Verifying {algorithm} signature over {}...", + data_ref.display_name() + ), + )?; + + let (verified, checked_by) = match backend { + Some(backend) => verify_through_backend( + backend, + backend_key.as_ref(), + &data, + &signature, + algorithm, + &key_ref.display_name(), + )?, + None => ( + crate::signatures::verify(&public_der, &data, &signature, algorithm) + .map_err(|e| ActionError::Failed(format!("Verification failed to run: {e}")))?, + "software".to_string(), + ), + }; + + if !verified { + reporter.log(Icon::Cross, "Signature does not match")?; + return Err(ActionError::Failed(format!( + "Signature verification failed: the {algorithm} signature '{}' does not match '{}' under key '{}'", + signature_ref.display_name(), + data_ref.display_name(), + key_ref.display_name() + ))); + } + + reporter.log(Icon::Checkmark, "Signature verified")?; + + reporter.fact(StepFact::BackendOperation { + step: step.id.clone(), + kind: "verify_signature".to_string(), + inputs: json!({ + "key_artifact": key_ref.display_name(), + "data_artifact": data_ref.display_name(), + "signature_artifact": signature_ref.display_name(), + "algorithm": algorithm.to_string(), + "verifier": checked_by, + }), + outputs: json!({ + "verified": true, + "public_key_fingerprint": compute_fingerprint(&public_der), + "signature_fingerprint": compute_fingerprint(&signature), + }), + fingerprint: None, + })?; + + Ok(StepResult::completed("Signature verified")) + } +} + +/// Delegate verification to the backend named on the step. +/// +/// The backend verifies by key reference, so this needs a key the backend +/// holds. A step that names a backend but passes a bare public key is refused +/// rather than quietly verified in software: the author asked for a specific +/// verifier, and silently substituting another would misreport who checked the +/// evidence. +fn verify_through_backend( + backend: &mut dyn Backend, + backend_key: Option<&BackendKeyMeta<'_>>, + data: &[u8], + signature: &[u8], + algorithm: rite_sdk::SignAlgorithm, + key_name: &str, +) -> Result<(bool, String), ActionError> { + let backend_name = backend.name().to_string(); + + let Some((owner, key_id, _, _)) = backend_key else { + return Err(ActionError::Failed(format!( + "Step names backend '{backend_name}', but key '{key_name}' is not managed by a backend. \ + Drop the `backend:` field to verify it in software." + ))); + }; + + let sign_backend = require_sign_backend(backend, owner, key_name)?; + let verified = sign_backend.verify(key_id, data, signature, algorithm)?; + Ok((verified, backend_name)) +} + +/// Recover the public key to verify under, and the algorithm it implies. +/// +/// A backend-managed key states its own algorithm. A bare public key does not, +/// so the provider reads it out of the SPKI structure rather than the ceremony +/// having to declare something the bytes already say. +fn resolve_public_key( + ctx: &HandlerContext, + key_ref: &rite_model::ArtifactRef, + key_id: &rite_model::ArtifactId, + backend_key: Option<&BackendKeyMeta<'_>>, +) -> Result<(Vec, KeyAlgorithm), ActionError> { + match backend_key { + Some((_, _, algorithm, Some(public_key))) => Ok(((*public_key).clone(), *algorithm)), + Some((_, _, _, None)) => Err(ActionError::Failed(format!( + "Key '{}' has no exportable public key, so its signatures cannot be verified here", + key_ref.display_name() + ))), + None => { + let der = + resolve_artifact_bytes(ctx.artifacts, key_id, key_ref.property()).map_err(|e| { + ActionError::Failed(format!( + "verify_signature input 'key' ('{}') could not be resolved: {e}", + key_ref.display_name() + )) + })?; + let algorithm = crate::signatures::public_key_algorithm(&der).map_err(|e| { + ActionError::Failed(format!( + "verify_signature input 'key' ('{}') is not a usable public key: {e}", + key_ref.display_name() + )) + })?; + Ok((der, algorithm)) + } + } +} diff --git a/crates/rite-stdlib/src/lib.rs b/crates/rite-stdlib/src/lib.rs index a0d4a6f..c917602 100644 --- a/crates/rite-stdlib/src/lib.rs +++ b/crates/rite-stdlib/src/lib.rs @@ -4,7 +4,8 @@ //! //! - **Verification**: `clock_check`, `confirm`, `check_value`, `oral_readback`, `machine_info` //! - **Attestation**: `attest` -//! - **Crypto**: `generate_keypair`, `export_public`, `wrap_key`, `unwrap_key` +//! - **Crypto**: `generate_keypair`, `export_public`, `wrap_key`, `unwrap_key`, +//! `sign_data`, `verify_signature` //! - **PKI**: `generate_csr`, `issue_certificate` //! //! # Backend integration @@ -17,8 +18,9 @@ //! //! - `verification`: verification actions (requires `subtle`, `sysinfo`) //! - `attestation`: attestation recording -//! - `crypto`: crypto actions (`generate_keypair`, `export_public`, `wrap_key`, `unwrap_key`) -//! - `pki`: PKI actions (`generate_csr`, `issue_certificate`; requires `x509-cert`, `der`, `sha1`, `rsa`, `p256`) +//! - `crypto`: crypto actions (`generate_keypair`, `export_public`, `wrap_key`, `unwrap_key`, +//! `sign_data`, `verify_signature`) +//! - `pki`: PKI actions (`generate_csr`, `issue_certificate`; requires `x509-cert`, `signature`) //! - `piv`: PIV smart-card actions (`piv_read_certificate`, `piv_sign`; requires PC/SC) //! - `yubikey`: `YubiKey` actions (`yubikey_attest_slot`; implies `piv`; requires PC/SC) //! - `default`: all features enabled except the hardware backends (`piv`, `yubikey`) @@ -41,6 +43,7 @@ pub mod backend; mod params; +pub mod signatures; #[cfg(feature = "attestation")] pub mod attestation; @@ -63,7 +66,10 @@ pub use backend::{MockBackend, create_backend, default_backend_factory}; #[cfg(feature = "attestation")] pub use attestation::AttestAction; #[cfg(feature = "crypto")] -pub use crypto::{ExportPublicAction, GenerateKeypairAction, UnwrapKeyAction, WrapKeyAction}; +pub use crypto::{ + ExportPublicAction, GenerateKeypairAction, SignDataAction, UnwrapKeyAction, + VerifySignatureAction, WrapKeyAction, +}; pub use entropy::GatherEntropyAction; #[cfg(feature = "yubikey")] pub use piv::YubikeyAttestSlotAction; @@ -112,6 +118,8 @@ pub fn register_stdlib(registry: &mut ActionRegistry) { registry.register(Arc::new(ExportPublicAction)); registry.register(Arc::new(WrapKeyAction)); registry.register(Arc::new(UnwrapKeyAction)); + registry.register(Arc::new(SignDataAction)); + registry.register(Arc::new(VerifySignatureAction)); } #[cfg(feature = "pki")] diff --git a/crates/rite-stdlib/src/params.rs b/crates/rite-stdlib/src/params.rs index 766f339..ec2a397 100644 --- a/crates/rite-stdlib/src/params.rs +++ b/crates/rite-stdlib/src/params.rs @@ -175,6 +175,38 @@ pub struct WrapKeyParams { pub algorithm: Option, } +/// Params for `sign_data` action. +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SignDataParams { + /// Signature algorithm. Defaults to the one implied by the key. + /// + /// Worth setting for an RSA key, which can sign under either + /// `"RSA-PKCS1-SHA256"` (the default) or `"RSA-PSS-SHA256"`. Every other + /// key type admits exactly one algorithm, so naming it only restates the + /// key. + #[serde(default)] + pub algorithm: Option, + /// Optional display message. + #[serde(default)] + pub message: Option, +} + +/// Params for `verify_signature` action. +#[cfg(feature = "crypto")] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct VerifySignatureParams { + /// Signature algorithm. Defaults to the one implied by the public key. + /// + /// Required when checking an RSA-PSS signature, since an RSA key alone + /// does not say which scheme was used. + #[serde(default)] + pub algorithm: Option, + /// Optional display message. + #[serde(default)] + pub message: Option, +} + /// Params for `unwrap_key` action. #[cfg(feature = "crypto")] #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/crates/rite-stdlib/src/piv/params.rs b/crates/rite-stdlib/src/piv/params.rs index 2293ea0..7e40ab8 100644 --- a/crates/rite-stdlib/src/piv/params.rs +++ b/crates/rite-stdlib/src/piv/params.rs @@ -19,7 +19,7 @@ pub struct PivSignParams { /// PIV slot containing the signing key (default: "9c"). #[serde(default = "default_sign_slot")] pub slot: String, - /// Signing algorithm: `ecdsa_sha256`, `ecdsa_sha384`, `rsa_pkcs1_sha256`. + /// Signing algorithm: `ECDSA-SHA256`, `ECDSA-SHA384`, `RSA-PKCS1-SHA256`. #[serde(default = "default_sign_algorithm")] pub algorithm: String, /// Optional display message. @@ -31,8 +31,10 @@ fn default_sign_slot() -> String { "9c".to_string() } +// Spelled by the SDK enum rather than a literal, so the default cannot drift +// from the names the action parses. fn default_sign_algorithm() -> String { - "ecdsa_sha256".to_string() + rite_sdk::SignAlgorithm::EcdsaSha256.to_string() } /// Parameters for the `yubikey_attest_slot` action. @@ -70,7 +72,7 @@ mod tests { let json = serde_json::json!({}); let params: PivSignParams = serde_json::from_value(json).unwrap(); assert_eq!(params.slot, "9c"); - assert_eq!(params.algorithm, "ecdsa_sha256"); + assert_eq!(params.algorithm, "ECDSA-SHA256"); assert!(params.message.is_none()); } @@ -78,12 +80,12 @@ mod tests { fn sign_params_explicit_values() { let json = serde_json::json!({ "slot": "9d", - "algorithm": "rsa_pkcs1_sha256", + "algorithm": "RSA-PKCS1-SHA256", "message": "Sign the key" }); let params: PivSignParams = serde_json::from_value(json).unwrap(); assert_eq!(params.slot, "9d"); - assert_eq!(params.algorithm, "rsa_pkcs1_sha256"); + assert_eq!(params.algorithm, "RSA-PKCS1-SHA256"); assert_eq!(params.message.as_deref(), Some("Sign the key")); } } diff --git a/crates/rite-stdlib/src/piv/sign.rs b/crates/rite-stdlib/src/piv/sign.rs index b8722d6..d0be9e8 100644 --- a/crates/rite-stdlib/src/piv/sign.rs +++ b/crates/rite-stdlib/src/piv/sign.rs @@ -152,21 +152,32 @@ impl Action for PivSignAction { } } -/// Map a string algorithm name to a `SignAlgorithm` supported by PIV cards. +/// What `piv_sign` offers to ceremony authors. /// -/// This is the action-level allowlist: it names what `piv_sign` offers to -/// ceremony authors. RSA-PSS is absent because PIV cards apply a raw RSA -/// operation and the client-side PSS encoding is not implemented. +/// The action-level allowlist, applied on top of the SDK's parsing. RSA-PSS is +/// absent because PIV cards apply a raw RSA operation and the client-side PSS +/// encoding is not implemented; Ed25519 and ML-DSA because no PIV card does them. +const SUPPORTED_ALGORITHMS: [SignAlgorithm; 3] = [ + SignAlgorithm::EcdsaSha256, + SignAlgorithm::EcdsaSha384, + SignAlgorithm::RsaPkcs1Sha256, +]; + +/// Parse an algorithm name from the DSL and check it against the allowlist. fn parse_sign_algorithm(s: &str) -> Result { - match s { - "ecdsa_sha256" => Ok(SignAlgorithm::EcdsaSha256), - "ecdsa_sha384" => Ok(SignAlgorithm::EcdsaSha384), - "rsa_pkcs1_sha256" => Ok(SignAlgorithm::RsaPkcs1Sha256), - other => Err(ActionError::Failed(format!( - "Unsupported signing algorithm: {other}. \ - Supported: ecdsa_sha256, ecdsa_sha384, rsa_pkcs1_sha256" - ))), - } + s.parse() + .ok() + .filter(|algorithm| SUPPORTED_ALGORITHMS.contains(algorithm)) + .ok_or_else(|| { + let supported = SUPPORTED_ALGORITHMS + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + ActionError::Failed(format!( + "Unsupported signing algorithm: {s}. Supported: {supported}" + )) + }) } #[cfg(test)] @@ -177,32 +188,43 @@ mod tests { #[test] fn parse_sign_algorithm_valid() { assert_eq!( - parse_sign_algorithm("ecdsa_sha256").unwrap(), + parse_sign_algorithm("ECDSA-SHA256").unwrap(), SignAlgorithm::EcdsaSha256 ); assert_eq!( - parse_sign_algorithm("ecdsa_sha384").unwrap(), + parse_sign_algorithm("ECDSA-SHA384").unwrap(), SignAlgorithm::EcdsaSha384 ); assert_eq!( - parse_sign_algorithm("rsa_pkcs1_sha256").unwrap(), + parse_sign_algorithm("RSA-PKCS1-SHA256").unwrap(), SignAlgorithm::RsaPkcs1Sha256 ); } #[test] fn parse_sign_algorithm_invalid() { - let err = parse_sign_algorithm("ed25519").unwrap_err(); + let err = parse_sign_algorithm("SHA256-WITH-VIBES").unwrap_err(); let msg = err.to_string(); - assert!(msg.contains("ed25519")); + assert!(msg.contains("SHA256-WITH-VIBES")); assert!(msg.contains("Supported:")); } #[test] - fn parse_sign_algorithm_rejects_pss() { - // PIV cards do raw RSA; the client-side PSS encoding is not - // implemented, so the action must refuse it up front. - assert!(parse_sign_algorithm("rsa_pss_sha256").is_err()); + fn parse_sign_algorithm_rejects_algorithms_no_piv_card_offers() { + // Each of these parses as a `SignAlgorithm`, so only the action-level + // allowlist stands between them and a card that cannot perform them. + // PIV does raw RSA and the client-side PSS encoding is not implemented; + // Ed25519 and ML-DSA have no PIV key reference at all. + for name in ["RSA-PSS-SHA256", "Ed25519", "ML-DSA-87"] { + assert!( + name.parse::().is_ok(), + "{name} must be a known algorithm for this test to mean anything" + ); + assert!( + parse_sign_algorithm(name).is_err(), + "{name} must be refused" + ); + } } // Signing needs the mock's embedded crypto (and its lazy stand-in key), @@ -247,7 +269,7 @@ mod tests { Some(ArtifactId::new("signature")), Some(input), ); - let params = serde_json::json!({ "slot": "9c", "algorithm": "ecdsa_sha256" }); + let params = serde_json::json!({ "slot": "9c", "algorithm": "ECDSA-SHA256" }); let mut backend = MockBackend::new("token".to_string(), "seed".to_string()); let result = { @@ -376,7 +398,7 @@ mod tests { Some(ArtifactId::new("signature")), Some(input), ); - let params = serde_json::json!({ "slot": "9c", "algorithm": "ecdsa_sha256" }); + let params = serde_json::json!({ "slot": "9c", "algorithm": "ECDSA-SHA256" }); let mut backend = EmptySlotBackend; let mut reporter = harness.reporter(StepId::new("sign")); @@ -492,7 +514,7 @@ mod tests { None, Some(input), ); - let params = serde_json::json!({ "slot": slot, "algorithm": "ecdsa_sha256" }); + let params = serde_json::json!({ "slot": slot, "algorithm": "ECDSA-SHA256" }); let mut reporter = harness.reporter(StepId::new("sign")); PivSignAction .execute(&step, &ctx, ¶ms, &mut reporter, Some(&mut backend)) diff --git a/crates/rite-stdlib/src/pki/issue_certificate.rs b/crates/rite-stdlib/src/pki/issue_certificate.rs index b518f9b..a554113 100644 --- a/crates/rite-stdlib/src/pki/issue_certificate.rs +++ b/crates/rite-stdlib/src/pki/issue_certificate.rs @@ -47,8 +47,7 @@ use x509_cert::{ use crate::params::IssueCertificateParams; use super::oids::{ - ECDSA_WITH_SHA256, ML_DSA_44, ML_DSA_65, ML_DSA_87, SHA256_WITH_RSA_ENCRYPTION, - sig_profile_for_algorithm, + sig_profile_for_algorithm, verifiable_algorithm_names, verifiable_sign_algorithm, }; /// id-kp-serverAuth OID (1.3.6.1.5.5.7.3.1) @@ -516,15 +515,22 @@ fn extract_san_from_csr(csr: &CertReq) -> Option { None } -/// SHA-256 `DigestInfo` DER prefix for PKCS#1 v1.5 (RFC 3447 §9.2, note 1). -const SHA256_DIGEST_INFO_PREFIX: &[u8] = &[ - 0x30, 0x31, 0x30, 0x0d, 0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, 0x05, - 0x00, 0x04, 0x20, -]; - /// Verify the CSR's self-signature. +/// +/// Proof of possession: the requester holds the private key matching the public +/// key inside the request. The algorithm is named by the CSR itself, so it is +/// resolved against the allowlist first and passed to the verifier explicitly. +/// Letting the verifier infer a scheme from the key would accept a request that +/// claims one algorithm and carries a key for another. fn verify_csr_signature(csr: &CertReq) -> Result<(), String> { let oid = csr.algorithm.oid; + let algorithm = verifiable_sign_algorithm(oid).ok_or_else(|| { + format!( + "CSR signature algorithm {oid} is not supported for verification. \ + Supported algorithms are {}.", + verifiable_algorithm_names() + ) + })?; let spki_der = csr .info @@ -536,72 +542,15 @@ fn verify_csr_signature(csr: &CertReq) -> Result<(), String> { .to_der() .map_err(|e| format!("Failed to encode CertReqInfo: {e}"))?; - if oid == SHA256_WITH_RSA_ENCRYPTION { - use rsa::pkcs8::DecodePublicKey; - use sha2::Digest; - - let rsa_key = rsa::RsaPublicKey::from_public_key_der(&spki_der) - .map_err(|e| format!("Failed to parse RSA public key from CSR: {e}"))?; - - let hash = sha2::Sha256::digest(&info_der); - let mut digest_info = SHA256_DIGEST_INFO_PREFIX.to_vec(); - digest_info.extend_from_slice(&hash); - - rsa_key - .verify( - rsa::pkcs1v15::Pkcs1v15Sign::new_unprefixed(), - &digest_info, - csr.signature.raw_bytes(), - ) - .map_err(|_| { - "CSR self-signature verification failed: signature does not match".to_string() - }) - } else if oid == ECDSA_WITH_SHA256 { - use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier}; - use p256::pkcs8::DecodePublicKey; - - let verifying_key = VerifyingKey::from_public_key_der(&spki_der) - .map_err(|e| format!("Failed to parse ECDSA P-256 public key from CSR: {e}"))?; - let signature = Signature::from_der(csr.signature.raw_bytes()) - .map_err(|e| format!("Failed to parse ECDSA signature from CSR: {e}"))?; - - verifying_key.verify(&info_der, &signature).map_err(|_| { - "CSR self-signature verification failed: ECDSA signature does not match".to_string() - }) - } else if oid == ML_DSA_44 || oid == ML_DSA_65 || oid == ML_DSA_87 { - verify_ml_dsa_csr(&spki_der, &info_der, csr.signature.raw_bytes()) - } else { - Err(format!( - "CSR signature algorithm {oid} is not supported for verification. \ - Supported algorithms are sha256WithRSAEncryption, ecdsa-with-SHA256, \ - and ML-DSA-44/65/87." - )) - } -} - -/// Verify an ML-DSA CSR self-signature. -/// -/// The RSA and ECDSA branches above verify with `RustCrypto`, but no equivalent -/// in-tree verifier covers ML-DSA, so this delegates to the same OpenSSL -/// provider that produced the signature. Unifying all three onto one -/// implementation is tracked as the open question in the meta-repo's -/// `sign-verify-actions` investigation. -#[cfg(feature = "openssl")] -fn verify_ml_dsa_csr(spki_der: &[u8], info_der: &[u8], signature: &[u8]) -> Result<(), String> { - match rite_openssl::verify_ml_dsa_signature(spki_der, info_der, signature) { + match crate::signatures::verify(&spki_der, &info_der, csr.signature.raw_bytes(), algorithm) { Ok(true) => Ok(()), - Ok(false) => Err( - "CSR self-signature verification failed: ML-DSA signature does not match".to_string(), - ), - Err(e) => Err(format!("Failed to verify ML-DSA CSR self-signature: {e}")), + Ok(false) => Err(format!( + "CSR self-signature verification failed: {algorithm} signature does not match" + )), + Err(e) => Err(format!("Failed to verify CSR self-signature: {e}")), } } -#[cfg(not(feature = "openssl"))] -fn verify_ml_dsa_csr(_spki_der: &[u8], _info_der: &[u8], _signature: &[u8]) -> Result<(), String> { - Err("ML-DSA CSR verification requires the 'openssl' feature".to_string()) -} - fn parse_csr(bytes: &[u8]) -> Result { if bytes.starts_with(b"-----") { let pem_str = diff --git a/crates/rite-stdlib/src/pki/oids.rs b/crates/rite-stdlib/src/pki/oids.rs index eafa88e..a081dbe 100644 --- a/crates/rite-stdlib/src/pki/oids.rs +++ b/crates/rite-stdlib/src/pki/oids.rs @@ -12,6 +12,13 @@ pub(super) const SHA256_WITH_RSA_ENCRYPTION: ObjectIdentifier = pub(super) const ECDSA_WITH_SHA256: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.2"); +/// ecdsa-with-SHA384 (1.2.840.10045.4.3.3) +pub(super) const ECDSA_WITH_SHA384: ObjectIdentifier = + ObjectIdentifier::new_unwrap("1.2.840.10045.4.3.3"); + +/// id-Ed25519 (1.3.101.112) +pub(super) const ED25519: ObjectIdentifier = ObjectIdentifier::new_unwrap("1.3.101.112"); + /// id-ml-dsa-44 (2.16.840.1.101.3.4.3.17) pub(super) const ML_DSA_44: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.3.17"); @@ -24,56 +31,138 @@ pub(super) const ML_DSA_65: ObjectIdentifier = pub(super) const ML_DSA_87: ObjectIdentifier = ObjectIdentifier::new_unwrap("2.16.840.1.101.3.4.3.19"); +/// The X.509 signature identifier for each algorithm Rite signs and accepts. +/// +/// One table, deliberately. It answers both directions: which identifier to +/// stamp on something Rite signs, and whether an identifier found on an +/// incoming CSR may be verified. Keeping them separate let the emit set and the +/// accept set drift, which would have Rite generating CSRs its own +/// `issue_certificate` refuses. +/// +/// Membership is ceremony **policy**, not a statement of what the crypto +/// provider can do. OpenSSL will happily verify md5WithRSA and SHA-1; a key +/// ceremony should not accept either, so what may appear on a CSR is named here +/// and everything else is refused by default. +/// +/// `null_parameters` distinguishes RFC 3279, where RSA identifiers carry an +/// explicit NULL, from RFC 5758 and RFC 8410, where the parameters are absent. +const SIGNATURE_IDENTIFIERS: [(SignAlgorithm, ObjectIdentifier, bool, &str); 7] = [ + ( + SignAlgorithm::RsaPkcs1Sha256, + SHA256_WITH_RSA_ENCRYPTION, + true, + "sha256WithRSAEncryption", + ), + ( + SignAlgorithm::EcdsaSha256, + ECDSA_WITH_SHA256, + false, + "ecdsa-with-SHA256", + ), + ( + SignAlgorithm::EcdsaSha384, + ECDSA_WITH_SHA384, + false, + "ecdsa-with-SHA384", + ), + (SignAlgorithm::Ed25519, ED25519, false, "Ed25519"), + (SignAlgorithm::MlDsa44, ML_DSA_44, false, "ML-DSA-44"), + (SignAlgorithm::MlDsa65, ML_DSA_65, false, "ML-DSA-65"), + (SignAlgorithm::MlDsa87, ML_DSA_87, false, "ML-DSA-87"), +]; + +/// The signature profile to use when signing with a key of `key_algorithm`. +/// +/// Which signature algorithm suits a key is the SDK's decision, shared with the +/// signing actions; this adds only the X.509 encoding of that choice. pub(super) fn sig_profile_for_algorithm( key_algorithm: KeyAlgorithm, ) -> Result<(SignAlgorithm, AlgorithmIdentifier, &'static str), String> { - match key_algorithm { - KeyAlgorithm::Rsa2048 | KeyAlgorithm::Rsa4096 => Ok(( - SignAlgorithm::RsaPkcs1Sha256, - AlgorithmIdentifier { - oid: SHA256_WITH_RSA_ENCRYPTION, - // RSA algorithm identifiers carry explicit NULL parameters per RFC 3279. - parameters: Some(Any::null()), - }, - "sha256WithRSAEncryption", - )), - KeyAlgorithm::EcdsaP256 => Ok(( - SignAlgorithm::EcdsaSha256, - AlgorithmIdentifier { - oid: ECDSA_WITH_SHA256, - // RFC 5758: ECDSA-with-SHA2 identifiers use absent parameters. - parameters: None, - }, - "ecdsa-with-SHA256", - )), - // FIPS 204 fixes one signature scheme per parameter set, with no hash - // or padding left to choose, so the key algorithm fully determines the - // signature algorithm identifier. Parameters are absent. - KeyAlgorithm::MlDsa44 | KeyAlgorithm::MlDsa65 | KeyAlgorithm::MlDsa87 => { - let (sign_algorithm, oid, name) = match key_algorithm { - KeyAlgorithm::MlDsa44 => (SignAlgorithm::MlDsa44, ML_DSA_44, "ML-DSA-44"), - KeyAlgorithm::MlDsa65 => (SignAlgorithm::MlDsa65, ML_DSA_65, "ML-DSA-65"), - _ => (SignAlgorithm::MlDsa87, ML_DSA_87, "ML-DSA-87"), - }; - Ok(( - sign_algorithm, + let sign_algorithm = key_algorithm.default_sign_algorithm().ok_or_else(|| { + format!("key algorithm '{key_algorithm}' is not supported for PKI signing yet") + })?; + let (identifier, name) = signature_identifier(sign_algorithm).ok_or_else(|| { + format!("signature algorithm '{sign_algorithm}' has no X.509 identifier in Rite") + })?; + Ok((sign_algorithm, identifier, name)) +} + +/// The X.509 identifier and display name for a signature algorithm. +fn signature_identifier( + algorithm: SignAlgorithm, +) -> Option<(AlgorithmIdentifier, &'static str)> { + SIGNATURE_IDENTIFIERS + .iter() + .find(|(candidate, ..)| *candidate == algorithm) + .map(|(_, oid, null_parameters, name)| { + ( AlgorithmIdentifier { - oid, - parameters: None, + oid: *oid, + parameters: null_parameters.then(Any::null), }, - name, - )) - } - other => Err(format!( - "key algorithm '{other}' is not supported for PKI signing yet" - )), - } + *name, + ) + }) +} + +/// Resolve a CSR `signatureAlgorithm` OID against the table above. +pub(super) fn verifiable_sign_algorithm(oid: ObjectIdentifier) -> Option { + SIGNATURE_IDENTIFIERS + .iter() + .find(|(_, candidate, ..)| *candidate == oid) + .map(|(algorithm, ..)| *algorithm) +} + +/// The accepted algorithm names, for an error message listing what is allowed. +pub(super) fn verifiable_algorithm_names() -> String { + SIGNATURE_IDENTIFIERS + .iter() + .map(|(algorithm, ..)| algorithm.to_string()) + .collect::>() + .join(", ") } #[cfg(test)] mod tests { use super::*; + /// Every key algorithm Rite can sign with must reach an X.509 identifier. + /// + /// The SDK decides which signature algorithm suits a key; this module + /// decides how to encode it. A key algorithm that gains a default in the + /// SDK without an identifier here would fail at certificate-issuing time. + #[test] + fn every_signing_key_algorithm_has_an_identifier() { + let signing_keys = [ + KeyAlgorithm::Rsa2048, + KeyAlgorithm::Rsa4096, + KeyAlgorithm::EcdsaP256, + KeyAlgorithm::EcdsaP384, + KeyAlgorithm::Ed25519, + KeyAlgorithm::MlDsa44, + KeyAlgorithm::MlDsa65, + KeyAlgorithm::MlDsa87, + ]; + for key_algorithm in signing_keys { + let (sign_algorithm, ..) = sig_profile_for_algorithm(key_algorithm) + .unwrap_or_else(|e| panic!("{key_algorithm}: {e}")); + // What Rite stamps on a certificate is what it accepts on a CSR. + assert_eq!( + verifiable_sign_algorithm( + signature_identifier(sign_algorithm) + .expect("identifier") + .0 + .oid + ), + Some(sign_algorithm), + "{key_algorithm} signs with {sign_algorithm}, which is not accepted on a CSR" + ); + } + + // Symmetric keys sign nothing, and must say so rather than panic. + assert!(sig_profile_for_algorithm(KeyAlgorithm::Aes256).is_err()); + } + /// The ML-DSA identifiers are a wire contract: they appear in every /// certificate and CSR the runtime emits, so they are pinned by value /// rather than by whatever the constants happen to hold. diff --git a/crates/rite-stdlib/src/signatures.rs b/crates/rite-stdlib/src/signatures.rs new file mode 100644 index 0000000..02bba74 --- /dev/null +++ b/crates/rite-stdlib/src/signatures.rs @@ -0,0 +1,81 @@ +//! Signature verification that needs no backend. +//! +//! The single seam between the action library and the software crypto +//! provider. Everything here could be satisfied by a different provider, so +//! everything that verifies a signature without a device goes through this +//! module rather than calling `rite_openssl` directly. A provider swap should +//! be a change to this file and nothing else. +//! +//! Verification is separated from signing because it needs only a public key. +//! That makes it the one cryptographic operation a ceremony can perform on +//! evidence it did not produce: a CSR that arrived from elsewhere, or a +//! signature made on a smart card that will never expose its key. + +use rite_sdk::{BackendError, KeyAlgorithm, SignAlgorithm}; + +/// Verify `signature` over `message` with an SPKI DER public key. +/// +/// The key must match `algorithm`. Callers routinely take the algorithm from +/// the document under inspection, so this is refused rather than reinterpreted. +/// +/// # Errors +/// +/// Returns [`BackendError::UnsupportedAlgorithm`] when the key and algorithm +/// disagree, or when this build has no implementation of the algorithm, and +/// [`BackendError::Other`] when the key or signature cannot be parsed. +/// +/// A well-formed signature that simply does not match is `Ok(false)`, not an +/// error. +#[cfg(feature = "openssl")] +pub fn verify( + public_der: &[u8], + message: &[u8], + signature: &[u8], + algorithm: SignAlgorithm, +) -> Result { + rite_openssl::verify_signature(public_der, message, signature, algorithm) +} + +/// Verify `signature` over `message` with an SPKI DER public key. +/// +/// # Errors +/// +/// Always fails: this build has no crypto provider compiled in. +#[cfg(not(feature = "openssl"))] +pub fn verify( + _public_der: &[u8], + _message: &[u8], + _signature: &[u8], + algorithm: SignAlgorithm, +) -> Result { + Err(BackendError::UnsupportedAlgorithm(format!( + "verifying {algorithm} requires the 'openssl' feature" + ))) +} + +/// Read the key algorithm out of an SPKI DER public key. +/// +/// A public key that arrives as bytes carries no ceremony metadata, so the +/// algorithm has to be recovered from the structure itself before anything can +/// be verified under it. +/// +/// # Errors +/// +/// Returns [`BackendError::UnsupportedAlgorithm`] for a key of a type Rite does +/// not handle, and [`BackendError::Other`] when the key cannot be parsed. +#[cfg(feature = "openssl")] +pub fn public_key_algorithm(public_der: &[u8]) -> Result { + rite_openssl::public_key_algorithm(public_der) +} + +/// Read the key algorithm out of an SPKI DER public key. +/// +/// # Errors +/// +/// Always fails: this build has no crypto provider compiled in. +#[cfg(not(feature = "openssl"))] +pub fn public_key_algorithm(_public_der: &[u8]) -> Result { + Err(BackendError::UnsupportedAlgorithm( + "reading a public key requires the 'openssl' feature".to_string(), + )) +} diff --git a/crates/rite-stdlib/tests/pki_algorithms.rs b/crates/rite-stdlib/tests/pki_algorithms.rs new file mode 100644 index 0000000..91366e2 --- /dev/null +++ b/crates/rite-stdlib/tests/pki_algorithms.rs @@ -0,0 +1,158 @@ +// Every key algorithm the PKI actions accept must survive the full chain: +// generate a key, self-sign a CSR, have `issue_certificate` check that +// self-signature, and produce a certificate an outside verifier accepts. +// +// The CSR check is the interesting link. It is the one place the runtime +// verifies a signature it did not produce, and the algorithm it verifies under +// comes from the CSR rather than from the key, so a new algorithm reaching +// `generate_csr` without reaching the verifier's allowlist would fail here. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use openssl::x509::X509; +use rite_model::{ArtifactId, ArtifactRef, StepId, StepInputs}; +use rite_openssl::OpenSslBackend; +use rite_runtime::{ + Action, ArtifactValue, ExecutionState, StepInfo, test_support::ReporterHarness, +}; +use rite_stdlib::{GenerateCsrAction, GenerateKeypairAction, IssueCertificateAction}; +use std::collections::HashMap; + +fn named_inputs(pairs: &[(&str, ArtifactId)]) -> StepInputs { + let map = pairs + .iter() + .map(|(name, id)| { + ( + (*name).to_string(), + ArtifactRef::Produced { + id: id.clone(), + property: None, + }, + ) + }) + .collect(); + StepInputs::Named(map) +} + +fn step(id: &str, produces: &str, inputs: Option) -> StepInfo { + StepInfo::new( + StepId::new(id), + None, + Some("openssl".to_string()), + Some(ArtifactId::new(produces)), + inputs, + ) +} + +/// Run keygen, CSR, and issuance for one algorithm; return the certificate DER. +fn issue_self_signed_root(algorithm: &str) -> Vec { + let mut backend = OpenSslBackend::try_new("test").unwrap(); + let mut harness = ReporterHarness::new(); + let mut state = ExecutionState::new(HashMap::new(), HashMap::new(), HashMap::new(), false); + + let keypair_id = ArtifactId::new("root_key"); + let keygen_step = step("generate_root", "root_key", None); + let keygen_result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(keygen_step.id.clone()); + GenerateKeypairAction + .execute( + &keygen_step, + &ctx, + &serde_json::json!({ "algorithm": algorithm }), + &mut reporter, + Some(&mut backend), + ) + .unwrap_or_else(|e| panic!("generate_keypair {algorithm}: {e}")) + }; + state = state.with_material( + keypair_id.clone(), + keygen_result + .artifacts + .into_iter() + .find(|(id, _)| id == &keypair_id) + .map(|(_, v)| v) + .expect("keypair artifact"), + ); + + let csr_id = ArtifactId::new("root_csr"); + let csr_step = step( + "generate_root_csr", + "root_csr", + Some(named_inputs(&[("signing_key", keypair_id.clone())])), + ); + let csr_result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(csr_step.id.clone()); + GenerateCsrAction + .execute( + &csr_step, + &ctx, + &serde_json::json!({ "subject": format!("CN=Test Root {algorithm}") }), + &mut reporter, + Some(&mut backend), + ) + .unwrap_or_else(|e| panic!("generate_csr {algorithm}: {e}")) + }; + state = state.with_material( + csr_id.clone(), + csr_result + .artifacts + .into_iter() + .find(|(id, _)| id == &csr_id) + .map(|(_, v)| v) + .expect("CSR artifact"), + ); + + let cert_id = ArtifactId::new("root_cert"); + let cert_step = step( + "issue_root_cert", + "root_cert", + Some(named_inputs(&[ + ("signing_key", keypair_id), + ("csr", csr_id), + ])), + ); + let cert_result = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(cert_step.id.clone()); + IssueCertificateAction + .execute( + &cert_step, + &ctx, + &serde_json::json!({ "profile": "root_ca", "validity_days": 365 }), + &mut reporter, + Some(&mut backend), + ) + .unwrap_or_else(|e| panic!("issue_certificate {algorithm}: {e}")) + }; + + match cert_result + .artifacts + .into_iter() + .find(|(id, _)| id == &cert_id) + .map(|(_, v)| v) + .expect("certificate artifact") + { + ArtifactValue::Certificate { der } => der, + other => panic!("expected a certificate artifact, got {other:?}"), + } +} + +#[test] +fn every_signing_algorithm_completes_the_pki_chain() { + let mut algorithms = vec!["RSA-2048", "ECDSA-P256", "ECDSA-P384", "Ed25519"]; + if rite_openssl::ML_DSA_AVAILABLE { + algorithms.push("ML-DSA-65"); + } + + for algorithm in algorithms { + let der = issue_self_signed_root(algorithm); + let cert = X509::from_der(&der).unwrap_or_else(|e| panic!("{algorithm} cert DER: {e}")); + let public_key = cert.public_key().expect("certificate exposes a public key"); + assert!( + cert.verify(&public_key) + .unwrap_or_else(|e| panic!("{algorithm} verification runs: {e}")), + "{algorithm} root certificate must verify under its own public key" + ); + } +} diff --git a/crates/rite-stdlib/tests/sign_verify.rs b/crates/rite-stdlib/tests/sign_verify.rs new file mode 100644 index 0000000..a22b5f0 --- /dev/null +++ b/crates/rite-stdlib/tests/sign_verify.rs @@ -0,0 +1,223 @@ +// Round trip through the `sign_data` and `verify_signature` actions. +// +// The example ceremony covers the happy path end to end. What it cannot show is +// that verification would have failed: a check that always passes is worth +// nothing, so the negative cases live here. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use rite_model::{ArtifactId, ArtifactRef, StepId, StepInputs}; +use rite_openssl::OpenSslBackend; +use rite_runtime::{ + Action, ArtifactValue, ExecutionState, StepInfo, test_support::ReporterHarness, +}; +use rite_stdlib::{GenerateKeypairAction, SignDataAction, VerifySignatureAction}; +use std::collections::HashMap; + +const MESSAGE: &[u8] = b"rite release manifest"; + +fn named_inputs(pairs: &[(&str, &str)]) -> StepInputs { + StepInputs::Named( + pairs + .iter() + .map(|(name, id)| { + ( + (*name).to_string(), + ArtifactRef::Produced { + id: ArtifactId::new(*id), + property: None, + }, + ) + }) + .collect(), + ) +} + +fn step(id: &str, produces: Option<&str>, backend: Option<&str>, inputs: StepInputs) -> StepInfo { + StepInfo::new( + StepId::new(id), + None, + backend.map(ToString::to_string), + produces.map(ArtifactId::new), + Some(inputs), + ) +} + +/// A ceremony carried far enough to have a key, a document, and a signature. +struct Signed { + backend: OpenSslBackend, + harness: ReporterHarness, + state: ExecutionState, +} + +impl Signed { + /// Overwrite an artifact, to stage what a tampered ceremony would hold. + fn replace(self, id: &str, value: ArtifactValue) -> Self { + Self { + state: self.state.with_material(ArtifactId::new(id), value), + ..self + } + } +} + +/// Generate a key of `algorithm` and sign `MESSAGE` with it. +fn sign_with(algorithm: &str, sign_params: &serde_json::Value) -> Signed { + let mut backend = OpenSslBackend::try_new("openssl").unwrap(); + let mut harness = ReporterHarness::new(); + let mut state = ExecutionState::new(HashMap::new(), HashMap::new(), HashMap::new(), false); + + let keygen_step = step( + "generate", + Some("signing_key"), + Some("openssl"), + StepInputs::Named(HashMap::new()), + ); + let keygen = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(keygen_step.id.clone()); + GenerateKeypairAction + .execute( + &keygen_step, + &ctx, + &serde_json::json!({ "algorithm": algorithm }), + &mut reporter, + Some(&mut backend), + ) + .unwrap_or_else(|e| panic!("generate_keypair {algorithm}: {e}")) + }; + for (id, value) in keygen.artifacts { + state = state.with_material(id, value); + } + state = state.with_material( + ArtifactId::new("document"), + ArtifactValue::Bytes(MESSAGE.to_vec()), + ); + + let sign_step = step( + "sign", + Some("signature"), + Some("openssl"), + named_inputs(&[("key", "signing_key"), ("data", "document")]), + ); + let signed = { + let ctx = state.handler_context(); + let mut reporter = harness.reporter(sign_step.id.clone()); + SignDataAction + .execute( + &sign_step, + &ctx, + sign_params, + &mut reporter, + Some(&mut backend), + ) + .unwrap_or_else(|e| panic!("sign_data {algorithm}: {e}")) + }; + for (id, value) in signed.artifacts { + state = state.with_material(id, value); + } + + Signed { + backend, + harness, + state, + } +} + +/// Run `verify_signature` over the artifacts in `signed`. +fn verify( + signed: &mut Signed, + params: &serde_json::Value, + backend: Option<&str>, +) -> Result<(), String> { + let verify_step = step( + "verify", + None, + backend, + named_inputs(&[ + ("key", "signing_key"), + ("data", "document"), + ("signature", "signature"), + ]), + ); + let ctx = signed.state.handler_context(); + let mut reporter = signed.harness.reporter(verify_step.id.clone()); + // The executor supplies a backend exactly when the step names one. + let backend_arg: Option<&mut dyn rite_sdk::Backend> = + backend.map(|_| &mut signed.backend as &mut dyn rite_sdk::Backend); + VerifySignatureAction + .execute(&verify_step, &ctx, params, &mut reporter, backend_arg) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +#[test] +fn verifies_a_signature_it_just_produced() { + let mut signed = sign_with("ECDSA-P256", &serde_json::json!({})); + verify(&mut signed, &serde_json::json!({}), None).expect("signature must verify"); +} + +/// ML-DSA signing is hedged, so the same key over the same message gives +/// different bytes each time. The assertion is that verification succeeds, never +/// that the signature equals a fixed value. +#[test] +fn verifies_a_post_quantum_signature() { + if !rite_openssl::ML_DSA_AVAILABLE { + return; + } + let mut signed = sign_with("ML-DSA-65", &serde_json::json!({})); + verify(&mut signed, &serde_json::json!({}), None).expect("ML-DSA signature must verify"); +} + +/// An RSA key admits two schemes, so the algorithm named at signing time has to +/// reach verification. Deriving it from the key would pick PKCS#1 v1.5 and fail. +#[test] +fn round_trips_an_rsa_pss_signature_through_the_override() { + let mut signed = sign_with( + "RSA-2048", + &serde_json::json!({ "algorithm": "RSA-PSS-SHA256" }), + ); + + verify( + &mut signed, + &serde_json::json!({ "algorithm": "RSA-PSS-SHA256" }), + None, + ) + .expect("PSS signature must verify when the scheme is named"); + + let err = verify(&mut signed, &serde_json::json!({}), None) + .expect_err("a PSS signature must not verify as PKCS#1 v1.5"); + assert!(err.contains("does not match"), "{err}"); +} + +/// The whole point of a verification step. A signature over other bytes must +/// fail the step, not be recorded as checked. +#[test] +fn fails_when_the_signed_data_differs() { + let mut signed = sign_with("ECDSA-P256", &serde_json::json!({})).replace( + "document", + ArtifactValue::Bytes(b"a different manifest".to_vec()), + ); + + let err = verify(&mut signed, &serde_json::json!({}), None) + .expect_err("verification must fail for data that was never signed"); + assert!(err.contains("does not match"), "{err}"); +} + +#[test] +fn fails_when_the_signature_is_corrupt() { + let mut signed = sign_with("ECDSA-P256", &serde_json::json!({})) + .replace("signature", ArtifactValue::Bytes(vec![0u8; 70])); + + assert!( + verify(&mut signed, &serde_json::json!({}), None).is_err(), + "a corrupt signature must fail the step" + ); +} + +/// Naming a backend delegates the check to it, which is what a remote or +/// hardware verifier needs. The result must agree with the software path. +#[test] +fn delegates_to_the_backend_when_the_step_names_one() { + let mut signed = sign_with("ECDSA-P256", &serde_json::json!({})); + verify(&mut signed, &serde_json::json!({}), Some("openssl")) + .expect("the backend must verify its own signature"); +} diff --git a/docs/development/cryptographic-dependencies.md b/docs/development/cryptographic-dependencies.md new file mode 100644 index 0000000..382f5e9 --- /dev/null +++ b/docs/development/cryptographic-dependencies.md @@ -0,0 +1,90 @@ +# Cryptographic dependencies + +Which library performs which class of work, and where to add a new algorithm. + +## The split + +**OpenSSL performs cryptographic primitives.** Key generation, signing, +verification, wrapping, unwrapping, and random bytes. Every one of them, for +every algorithm, through `rite-openssl`. + +**RustCrypto performs ASN.1 and DER structure.** `x509-cert` and the `der` +family build and parse certificates, CSRs, and algorithm identifiers. They +handle no key material and perform no cryptographic operation. + +The dividing line is whether the code touches a key. Parsing a +`SubjectPublicKeyInfo` is structure. Verifying a signature under that key is a +primitive. A new algorithm needs work on both sides: an OID and identifier in +`rite-stdlib/src/pki/oids.rs`, and an implementation in `rite-openssl`. + +## Why one provider for primitives + +The rule exists because the alternative was tried. Signature verification once +ran on three implementations at once: the `rsa` crate for RSA, `p256` for +ECDSA, and OpenSSL for ML-DSA, while OpenSSL produced all three signatures. + +That shape has no upside and several costs: + +- **Doubled bug surface for one operation.** Signing and verifying through + different implementations means a disagreement between them is a Rite bug, + discoverable only by testing the pair. +- **A new dependency per algorithm.** Each signature family arrives as its own + crate, at its own maturity, with its own release cadence and advisories. The + set only grows. +- **Advisory exposure that is hard to reason about.** Whether an advisory + applies depends on which code path a crate is used for, and that argument has + to be rebuilt every time the set changes. See `.cargo/audit.toml` for the one + entry still carried and what it took to justify. + +Choosing OpenSSL specifically follows from a property of the domain: ceremonies +are largely performed on hardware (PIV cards, HSMs), so a real ceremony's +signing already happens outside any Rust crate. Software crypto is the +rehearsal and the software-only case, and it should agree with the widest +deployed implementation rather than be a second opinion. + +The cost is a C dependency and its build requirements. That is accepted. + +## Where the seam is + +`rite-stdlib/src/signatures.rs`. + +Actions call `signatures::verify`, never `rite_openssl::` directly. That module +is the only place backend-free cryptography names a provider, so swapping the +one behind it means rewriting a file rather than auditing every action. + +Backend *construction* is a separate seam and names providers of its own +(`backend/mod.rs`, and `backend/mock.rs` for the rehearsal mock). Those pick +which device performs an operation; `signatures.rs` covers the operations that +use no device at all. + +Verification specifically needs a seam because it needs no backend: it takes a +public key, so it is the one cryptographic operation a ceremony can perform on +evidence it did not produce. A CSR that arrived from elsewhere, or a signature +made on a card that will never expose its key, is checked here. + +Operations that need a private key go through the `rite-sdk` backend traits +instead. Those already abstract the provider, because the provider might be a +smart card. + +## Build-time capability + +**Algorithm availability is fixed when `rite-openssl` compiles, not when it +runs.** ML-DSA arrived in OpenSSL 3.5, and the bindings for it sit behind a +`cfg` resolved from the OpenSSL headers present at build time. A binary linked +against OpenSSL 3.0 contains no ML-DSA code at all, so no runtime check can +recover the capability. + +Two pieces make this visible: + +- `crates/rite-openssl/build.rs` derives the `ossl350` cfg from the version + `openssl-sys` publishes through its `links` metadata. (`openssl-sys` is a + direct dependency of `rite-openssl` for this reason alone, since `links` + metadata reaches only direct dependents.) +- `rite_openssl::ML_DSA_AVAILABLE` exposes the result. Branch on it wherever a + useful alternative exists, such as skipping a test, rather than waiting for + an `UnsupportedAlgorithm` error mid-ceremony. + +**Building with ML-DSA support requires OpenSSL 3.5 or newer.** Distributions +still shipping 3.0, including Ubuntu 24.04, produce a working build with the +post-quantum algorithms absent. `--features openssl-vendored` bundles a current +OpenSSL and always has them. diff --git a/docs/development/hardware-backends.md b/docs/development/hardware-backends.md index 073af9e..f4177eb 100644 --- a/docs/development/hardware-backends.md +++ b/docs/development/hardware-backends.md @@ -94,8 +94,8 @@ CI cannot touch a physical device, so: `yubikey_attest_slot` emits an attestation certificate that chains to the Yubico attestation root. 4. Cover the full algorithm matrix, not just the example's default: repeat - `piv_sign` for each supported algorithm (`ecdsa_sha256`, `ecdsa_sha384`, - `rsa_pkcs1_sha256`, each against a slot provisioned with the matching key + `piv_sign` for each supported algorithm (`ECDSA-SHA256`, `ECDSA-SHA384`, + `RSA-PKCS1-SHA256`, each against a slot provisioned with the matching key type) and verify every signature off-card against the slot certificate (`openssl dgst -sha256 -verify`). Cargo tests exercise these paths only against test doubles; the encoding the card actually accepts (bare digest diff --git a/examples/piv/yubikey_signing.rite.yaml b/examples/piv/yubikey_signing.rite.yaml index d4bd7f5..ed2886b 100644 --- a/examples/piv/yubikey_signing.rite.yaml +++ b/examples/piv/yubikey_signing.rite.yaml @@ -95,7 +95,7 @@ sections: reads: "${artifact.manifest}" with: slot: "9c" - algorithm: ecdsa_sha256 + algorithm: ECDSA-SHA256 message: "Enter the PIV PIN to sign the release manifest" creates: manifest_signature description: "Produce a detached ECDSA-P256 signature over the manifest." diff --git a/examples/showcase/README.md b/examples/showcase/README.md index fdf62f0..9c6d8a3 100644 --- a/examples/showcase/README.md +++ b/examples/showcase/README.md @@ -41,3 +41,12 @@ Demonstrates verifiable ceremony randomness: a participant folds a physical dice roll into the run seed with `gather_entropy`, then a certificate is issued whose serial number is drawn from that seed. `rite verify` later re-derives the seed, the dice contribution, and the serial from the transcript alone. + +### `sign_and_verify.rite.yaml` — Detached Signature over a Release Manifest + +Signs a document with `sign_data` and checks it back with `verify_signature`. +The contrast between the two steps is the point: signing names a `backend:` +because it needs the private key, while verification names none, because a +public key is all a signature check requires. That is what lets the same step +shape verify a signature made on a smart card, or one that arrived from outside +the ceremony. Neither step names an algorithm; both derive it from the key. diff --git a/examples/showcase/sign_and_verify.rite.yaml b/examples/showcase/sign_and_verify.rite.yaml new file mode 100644 index 0000000..3b4c3cd --- /dev/null +++ b/examples/showcase/sign_and_verify.rite.yaml @@ -0,0 +1,100 @@ +version: "0.2" +name: "Detached Signature over a Release Manifest" +description: | + Sign a document with a ceremony key, then verify the signature back. + + Demonstrates sign_data and verify_signature. The signing step needs the + private key, so it names a backend. The verification step does not: checking a + signature needs only the public key, which is why it can be pointed at + evidence the ceremony never produced. + + The verification step is not ornamental. A signature nobody checked is an + assumption, and the operator finding out during the ceremony beats a relying + party finding out later. + +backends: + openssl: + provider: openssl + +materials: + manifest: + type: digital + path: "test_data/release_manifest.txt" + description: "The document to be signed." + +output: + manifest_signature: + type: document + description: "Detached signature over the release manifest." + +roles: + signer: + name: "Release Signer" + person: "Alice Rivera" + witness: + name: "Witness" + person: "Bob Jones" + +acts: + - id: signing + name: "Signing" + description: "Generate the key and sign the manifest." + - id: verification + name: "Verification" + description: "Prove the signature checks out before the ceremony closes." + +sections: + produce: + act: signing + name: "Sign the Manifest" + role: ${role.signer} + steps: + generate_signing_key: + action: generate_keypair + backend: openssl + with: + algorithm: ECDSA-P256 + creates: signing_keypair + description: "Generate the keypair that will sign the manifest." + + sign_manifest: + action: sign_data + backend: openssl + reads: + key: ${artifact.signing_keypair} + data: ${artifact.manifest} + with: + message: "Signing the release manifest." + creates: manifest_signature + description: | + Produce a detached signature. No algorithm: field, so it follows from + the key: an ECDSA-P256 key signs with ECDSA-SHA256. Naming an + algorithm is only useful for an RSA key, which can sign under either + PKCS#1 v1.5 or PSS. + + confirm: + act: verification + name: "Verify the Signature" + role: ${role.witness} + steps: + verify_manifest_signature: + action: verify_signature + reads: + key: ${artifact.signing_keypair} + data: ${artifact.manifest} + signature: ${artifact.manifest_signature} + with: + message: "Checking the signature against the signing key." + description: | + Verify in software, with no backend: field. The step needs only the + public key, so the same step shape would check a signature made on a + smart card, or one that arrived from outside the ceremony. + + A failure here fails the ceremony. This is a check, not a record. + + witness_confirms: + action: confirm + role: ${role.witness} + with: + statement: "I observed the signature being produced and verified." + description: "The machine checked the mathematics; the witness attests to the circumstances." diff --git a/examples/showcase/test_data/release_manifest.txt b/examples/showcase/test_data/release_manifest.txt new file mode 100644 index 0000000..76b0562 --- /dev/null +++ b/examples/showcase/test_data/release_manifest.txt @@ -0,0 +1,10 @@ +rite release manifest +====================== + +artifact: rite-cli +version: 0.4.2 +sha256: 0000000000000000000000000000000000000000000000000000000000000000 + +This file stands in for the real artifact a ceremony would sign. The bytes +here are what `sign_data` signs and what `verify_signature` checks the +signature against.