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.