From cd292ab9e9d28c75a1c146bb9414d7efaccdcbf8 Mon Sep 17 00:00:00 2001 From: Mike Lodder Date: Tue, 1 Sep 2026 11:06:55 -0600 Subject: [PATCH 1/2] feat: add transport cryptography primitives --- CHANGELOG.md | 22 ++ Cargo.lock | 371 +++++++++++++++++++++- Cargo.toml | 17 +- README.md | 32 +- src/classical_signature.rs | 627 +++++++++++++++++++++++++++++++++++++ src/key_agreement.rs | 325 +++++++++++++++++++ src/lib.rs | 8 + src/random.rs | 25 ++ src/symmetric.rs | 555 ++++++++++++++++++++++++++++++++ 9 files changed, 1964 insertions(+), 18 deletions(-) create mode 100644 src/classical_signature.rs create mode 100644 src/key_agreement.rs create mode 100644 src/random.rs create mode 100644 src/symmetric.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dd5e9a6..cf2a6cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to this crate will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +## v0.5.2 - 2026-09-01 + +### Added + +- Added the optional `symmetric` feature with transport-neutral AES-GCM, + ChaCha20-Poly1305, SHA-2, HMAC/HKDF, AES block, and ChaCha20 stream + primitives for protocol adapters such as bedrock-tls. +- Added optional `random` and `key-agreement` features for operating-system + CSPRNG access and ephemeral X25519, P-256, and P-384 agreement. +- Added the optional `classical-signatures` feature with transport-neutral RSA, + P-256 ECDSA, P-384 ECDSA, and Ed25519 verification, signing, public-key + encoding, and PKCS#8/PKCS#1/SEC1 private-key loading. + +## v0.5.1 - 2026-08-31 + +### Fixed + +- Updated ML-DSA seeded and randomized key generation for the `ml-dsa` 0.1.1 + API used by the published package. + ## v0.5.0 - 2026-08-25 ### Added diff --git a/Cargo.lock b/Cargo.lock index 6fea0d7..b6d8ce4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,41 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "aes" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", "cpufeatures 0.3.0", + "zeroize", +] + +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ctutils", + "ghash", + "zeroize", ] [[package]] @@ -108,6 +134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] @@ -149,13 +176,28 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher", "cpufeatures 0.3.0", "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", ] [[package]] @@ -350,6 +392,23 @@ 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", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.6" @@ -371,6 +430,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint 0.7.5", + "rand_core 0.10.1", +] + [[package]] name = "ctr" version = "0.10.1" @@ -387,6 +456,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -398,6 +468,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.3.0", "curve25519-dalek-derive", + "digest 0.11.3", "fiat-crypto", "rustc_version", "subtle", @@ -432,6 +503,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ "const-oid 0.10.2", + "pem-rfc7468", "zeroize", ] @@ -457,6 +529,7 @@ dependencies = [ "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", + "zeroize", ] [[package]] @@ -467,11 +540,51 @@ checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der 0.7.10", "digest 0.10.7", - "elliptic-curve", - "rfc6979", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", "signature 2.2.0", ] +[[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", + "zeroize", +] + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature 3.0.0", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.11.0", + "signature 3.0.0", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.18.0" @@ -485,13 +598,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct 0.2.0", - "crypto-bigint", + "crypto-bigint 0.5.5", "digest 0.10.7", - "ff", + "ff 0.13.1", "generic-array", - "group", + "group 0.13.0", "rand_core 0.6.4", - "sec1", + "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", + "hkdf", + "hybrid-array", + "pkcs8", + "rand_core 0.10.1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -553,6 +687,16 @@ 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 = "fiat-crypto" version = "0.3.0" @@ -658,6 +802,16 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval", + "zeroize", +] + [[package]] name = "glob" version = "0.3.4" @@ -670,11 +824,22 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "ff", + "ff 0.13.1", "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 = "half" version = "2.7.1" @@ -701,6 +866,15 @@ dependencies = [ "serde", ] +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + [[package]] name = "hmac" version = "0.12.1" @@ -742,6 +916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "ctutils", + "subtle", "typenum", "zeroize", ] @@ -807,8 +982,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", - "ecdsa", - "elliptic-curve", + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", "sha2 0.10.9", ] @@ -990,6 +1165,33 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[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", + "sha2 0.11.0", +] + +[[package]] +name = "p384" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" +dependencies = [ + "ecdsa 0.17.0", + "elliptic-curve 0.14.1", + "fiat-crypto", + "primefield", + "primeorder", + "sha2 0.11.0", +] + [[package]] name = "page_size" version = "0.6.0" @@ -1010,12 +1212,31 @@ dependencies = [ "hmac 0.12.1", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.1", + "spki", +] + [[package]] name = "pkcs8" version = "0.11.0" @@ -1054,6 +1275,28 @@ dependencies = [ "plotters-backend", ] +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.0", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash", + "zeroize", +] + [[package]] name = "postcard" version = "1.1.3" @@ -1125,6 +1368,33 @@ dependencies = [ "zeroize", ] +[[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.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", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -1290,6 +1560,16 @@ 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 = "ripemd" version = "0.1.3" @@ -1299,6 +1579,25 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid 0.10.2", + "crypto-bigint 0.7.5", + "crypto-primes", + "digest 0.11.3", + "pkcs1", + "pkcs8", + "rand_core 0.10.1", + "sha2 0.11.0", + "signature 3.0.0", + "spki", + "zeroize", +] + [[package]] name = "rstest" version = "0.26.1" @@ -1377,6 +1676,20 @@ dependencies = [ "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", + "subtle", + "zeroize", +] + [[package]] name = "secp256k1" version = "0.27.0" @@ -1630,20 +1943,28 @@ dependencies = [ [[package]] name = "tectonic-bedrock" -version = "0.5.1" +version = "0.5.2" dependencies = [ + "aes", + "aes-gcm", "bip32", + "chacha20", + "chacha20poly1305", "ciborium", "criterion", "curve25519-dalek", + "ed25519-dalek", "falcon-rust", "frodo-kem-rs", "getrandom 0.4.3", "hex", + "hkdf", "hmac 0.13.0", "hqc-kem", "ml-dsa", "ml-kem", + "p256", + "p384", "postcard", "pq-mayo", "pq-mceliece", @@ -1652,6 +1973,7 @@ dependencies = [ "rand_chacha 0.10.0", "rand_core 0.10.1", "rand_core 0.6.4", + "rsa", "rstest", "serde", "serde_json", @@ -1912,6 +2234,16 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "version_check" version = "0.9.5" @@ -2053,6 +2385,17 @@ dependencies = [ "memchr", ] +[[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 = "x25519-dalek" version = "3.0.0" diff --git a/Cargo.toml b/Cargo.toml index e7c3638..6a3ab9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,10 +12,11 @@ name = "tectonic-bedrock" readme = "README.md" repository = "https://github.com/tectonic-labs/bedrock" rust-version = "1.95" -version = "0.5.1" +version = "0.5.2" [features] bird-of-prey = ["dep:curve25519-dalek", "falcon", "hmac", "ml-dsa", "sha2", "zeroize", "kgen", "sign", "vrfy"] +classical-signatures = ["dep:ed25519-dalek", "dep:p256", "dep:p384", "dep:rsa", "dep:signature", "p256/ecdsa", "p256/pkcs8", "p384/ecdsa", "p384/pkcs8", "random", "rand_core_010", "sha2", "zeroize"] decp = [] default = ["eth_falcon", "falcon", "ml-dsa", "slh-dsa", "mayo", "decp", "encp", "kgen", "sign", "vrfy", "hhd"] encp = [] @@ -24,29 +25,40 @@ falcon = ["fn-dsa-comm"] frodo = ["dep:frodo-kem-rs", "rand_core_010", "getrandom_v04"] hhd = ["zeroize", "bip32", "hmac", "sha2", "tiny-bip39", "rand_core", "kgen"] hqc = ["dep:hqc-kem", "rand_core_010", "getrandom_v04"] +key-agreement = ["dep:p256", "dep:p384", "random", "x25519-dalek", "x25519-dalek/zeroize", "zeroize"] kgen = ["fn-dsa-comm", "fn-dsa-kgen", "rand_core", "rand_core/getrandom"] mayo = ["dep:pq-mayo", "dep:signature", "rand_core_010", "getrandom_v04"] mceliece = ["dep:pq-mceliece", "rand_core_010", "getrandom_v04"] ml-dsa = ["dep:ml-dsa", "rand_core_010", "getrandom_v04"] ml-kem = ["dep:ml-kem"] +random = ["getrandom_v04"] sign = ["fn-dsa-comm", "fn-dsa-sign", "rand_core", "rand_core/getrandom"] slh-dsa = ["dep:slh-dsa", "rand_core_010", "getrandom_v04"] sntrup = ["dep:sntrup", "rand_core_010", "getrandom_v04"] +symmetric = ["dep:aes", "dep:aes-gcm", "dep:chacha20", "dep:chacha20poly1305", "dep:hkdf", "hmac", "sha2", "zeroize"] vrfy = ["fn-dsa-comm", "fn-dsa-vrfy"] xmss = ["dep:pq-xmss", "rand_core_010", "getrandom_v04", "zeroize"] xmss-extra-depths = ["xmss", "pq-xmss/extra-depths"] xwing = ["rand_core_010", "getrandom_v04", "sha3", "shake", "x25519-dalek"] [dependencies] +aes = { version = "0.9.3", default-features = false, features = ["zeroize"], optional = true } +aes-gcm = { version = "0.11.1", default-features = false, features = ["aes", "zeroize"], optional = true } bip32 = { version = "0.5.3", features = ["std", "secp256k1"], optional = true } +chacha20 = { version = "0.10.2", default-features = false, features = ["cipher", "zeroize"], optional = true } +chacha20poly1305 = { version = "0.11.0", default-features = false, features = ["zeroize"], optional = true } curve25519-dalek = { version = "5", default-features = false, features = ["alloc", "zeroize"], optional = true } +ed25519-dalek = { version = "3.0.0", default-features = false, features = ["alloc", "fast", "pkcs8", "signature", "zeroize"], optional = true } frodo-kem-rs = { version = "0.9", default-features = false, features = ["frodo"], optional = true } getrandom_v04 = { package = "getrandom", version = "0.4", features = ["sys_rng"], optional = true } hex = "0.4" -hmac = { version = "0.13", optional = true } +hkdf = { version = "0.13.0", optional = true } +hmac = { version = "0.13", features = ["zeroize"], optional = true } hqc-kem = { version = "=0.1.0-rc.0", default-features = false, features = ["alloc", "dcap", "ecap", "kgen"], optional = true } ml-dsa = { version = "0.1.1", default-features = false, features = ["alloc", "rand_core", "zeroize"], optional = true } ml-kem = { version = "0.3.2", default-features = false, features = ["alloc", "zeroize", "getrandom"], optional = true } +p256 = { version = "0.14.0", default-features = false, features = ["alloc", "arithmetic", "ecdh", "std"], optional = true } +p384 = { version = "0.14.0", default-features = false, features = ["alloc", "arithmetic", "ecdh", "std"], optional = true } pq-mayo = { version = "0.5.0", optional = true } pq-xmss = { version = "0.2.0", optional = true } rand_chacha = { version = "0.10", optional = true } @@ -54,6 +66,7 @@ rand_chacha = { version = "0.10", optional = true } # that API migrates while newer cryptographic backends use rand_core_010. rand_core = { version = "0.6", features = ["std"], optional = true } rand_core_010 = { package = "rand_core", version = "0.10", optional = true } +rsa = { version = "0.10.0-rc.18", default-features = false, features = ["encoding", "sha2", "std"], optional = true } serde = { version = "1.0", features = ["derive"] } serdect = "0.4" sha2 = { version = "0.11", optional = true } diff --git a/README.md b/README.md index 08a83c1..49df9d6 100644 --- a/README.md +++ b/README.md @@ -528,11 +528,18 @@ Control which algorithms and operations are enabled: - `mceliece` - Enable Classic McEliece key encapsulation - `frodo` - Enable FrodoKEM key encapsulation - `hqc` - Enable HQC key encapsulation and, with `hhd`, HQC HD derivation +- `classical-signatures` - Enable transport-neutral RSA, P-256 ECDSA, P-384 + ECDSA, and Ed25519 signing, verification, and private-key loading +- `key-agreement` - Enable ephemeral X25519, P-256, and P-384 key agreement + (also enables `random`) - `sntrup` - Enable Streamlined NTRU Prime key encapsulation - `xmss` - Enable XMSS stateful signatures - `xmss-extra-depths` - Enable all 147 non-standard `pq-xmss` tree-depth parameter sets - `bird-of-prey` - Enable Bird-of-Prey-2 hybrid signatures - `xwing` - Enable X-Wing hybrid KEM (requires `ml-kem` or `mceliece`) +- `symmetric` - Enable transport-neutral AES-GCM, ChaCha20-Poly1305, SHA-2, + HMAC/HKDF, AES block, and ChaCha20 stream primitives +- `random` - Enable operating-system cryptographic randomness - `hhd` - Enable hierarchical deterministic wallet support (default) ### Operation Features @@ -572,10 +579,31 @@ X-Wing hybrid KEM only: tectonic-bedrock = { version = "0.4", default-features = false, features = ["ml-kem", "xwing", "kgen", "encp", "decp"] } ``` +Current unreleased transport APIs from a sibling checkout, with symmetric +primitives only: + +```toml +tectonic-bedrock = { path = "../bedrock", default-features = false, features = ["symmetric"] } +``` + +Ephemeral key agreement only: + +```toml +tectonic-bedrock = { path = "../bedrock", default-features = false, features = ["key-agreement"] } +``` + +Conventional signature operations and private-key loading only: + +```toml +tectonic-bedrock = { path = "../bedrock", default-features = false, features = ["classical-signatures"] } +``` + ## Error Handling -All fallible operations return `Result`. The `Error` -enum includes, among others: +Scheme APIs return `Result`. The transport-neutral +modules expose focused `ClassicalSignatureError`, `SymmetricError`, +`KeyAgreementError`, and `RandomError` types. The scheme-level `Error` enum includes, +among others: - `McElieceError(String)` - Errors from the Classic McEliece KEM. - `InvalidScheme(u8)` / `InvalidSchemeStr(String)` - Invalid scheme identifiers. diff --git a/src/classical_signature.rs b/src/classical_signature.rs new file mode 100644 index 0000000..5641a3c --- /dev/null +++ b/src/classical_signature.rs @@ -0,0 +1,627 @@ +//! Transport-neutral conventional digital signatures and key loading. + +use core::fmt; + +use ed25519_dalek::{ + Signature as Ed25519Signature, SigningKey as Ed25519SigningKey, + VerifyingKey as Ed25519VerifyingKey, +}; +use p256::ecdsa::{ + Signature as P256Signature, SigningKey as P256SigningKey, VerifyingKey as P256VerifyingKey, +}; +use p384::ecdsa::{ + Signature as P384Signature, SigningKey as P384SigningKey, VerifyingKey as P384VerifyingKey, +}; +use rsa::pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPublicKey}; +use rsa::pkcs8::{DecodePrivateKey, EncodePublicKey}; +use rsa::traits::{PublicKeyParts, SignatureScheme as RsaSignatureScheme}; +use rsa::{Pkcs1v15Sign, Pss, RsaPrivateKey, RsaPublicKey}; +use sha2::{Digest as _, Sha256, Sha384, Sha512}; +use signature::Signer as _; +use signature::hazmat::PrehashVerifier; +use thiserror::Error as ThisError; + +const RSA_MINIMUM_BITS: u32 = 2048; + +/// Errors returned by conventional signature operations. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)] +pub enum ClassicalSignatureError { + /// The private key encoding or algorithm is unsupported or malformed. + #[error("invalid or unsupported conventional private key")] + InvalidPrivateKey, + /// The public key encoding is malformed or below the required strength. + #[error("invalid conventional public key")] + InvalidPublicKey, + /// The requested scheme does not match the loaded key. + #[error("signature scheme does not match the loaded key")] + UnsupportedScheme, + /// Signing failed. + #[error("conventional signature generation failed")] + SigningFailed, + /// Signature verification failed. + #[error("conventional signature verification failed")] + InvalidSignature, + /// The public-key encoding could not be produced. + #[error("conventional public-key encoding failed")] + PublicKeyEncoding, +} + +/// The public-key family of a conventional signing key. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClassicalSignatureAlgorithm { + /// RSA. + Rsa, + /// ECDSA over P-256. + EcdsaP256, + /// ECDSA over P-384. + EcdsaP384, + /// Ed25519. + Ed25519, +} + +/// A conventional signature scheme available to protocol adapters. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClassicalSignatureScheme { + /// ECDSA P-256 with SHA-256 and an ASN.1 DER signature. + EcdsaP256Sha256, + /// ECDSA P-384 with SHA-384 and an ASN.1 DER signature. + EcdsaP384Sha384, + /// Ed25519. + Ed25519, + /// RSA-PSS with SHA-256 and a 32-byte salt. + RsaPssSha256, + /// RSA-PSS with SHA-384 and a 48-byte salt. + RsaPssSha384, + /// RSA-PSS with SHA-512 and a 64-byte salt. + RsaPssSha512, + /// RSASSA-PKCS1-v1_5 with SHA-256. + RsaPkcs1Sha256, + /// RSASSA-PKCS1-v1_5 with SHA-384. + RsaPkcs1Sha384, + /// RSASSA-PKCS1-v1_5 with SHA-512. + RsaPkcs1Sha512, +} + +/// A conventional signature-verification algorithm. +/// +/// The ECDSA variants name both the public-key curve and message digest. This +/// preserves certificate-signature combinations which do not correspond to a +/// TLS handshake `SignatureScheme` name. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ClassicalVerificationAlgorithm { + /// P-256 ECDSA with SHA-256. + EcdsaP256Sha256, + /// P-256 ECDSA with SHA-384. + EcdsaP256Sha384, + /// P-256 ECDSA with SHA-512. + EcdsaP256Sha512, + /// P-384 ECDSA with SHA-256. + EcdsaP384Sha256, + /// P-384 ECDSA with SHA-384. + EcdsaP384Sha384, + /// P-384 ECDSA with SHA-512. + EcdsaP384Sha512, + /// Ed25519. + Ed25519, + /// RSA-PSS with SHA-256. + RsaPssSha256, + /// RSA-PSS with SHA-384. + RsaPssSha384, + /// RSA-PSS with SHA-512. + RsaPssSha512, + /// RSASSA-PKCS1-v1_5 with SHA-256. + RsaPkcs1Sha256, + /// RSASSA-PKCS1-v1_5 with SHA-384. + RsaPkcs1Sha384, + /// RSASSA-PKCS1-v1_5 with SHA-512. + RsaPkcs1Sha512, +} + +enum SigningKeyInner { + Rsa(RsaPrivateKey), + EcdsaP256(P256SigningKey), + EcdsaP384(P384SigningKey), + Ed25519(Ed25519SigningKey), +} + +/// A decoded conventional private signing key. +pub struct ClassicalSigningKey { + inner: SigningKeyInner, + public_key: Vec, + public_key_spki: Vec, +} + +impl ClassicalSigningKey { + /// Loads RSA, P-256, P-384, or Ed25519 key material from PKCS#8 DER. + pub fn from_pkcs8_der(der: &[u8]) -> Result { + if let Ok(key) = RsaPrivateKey::from_pkcs8_der(der) { + return Self::from_rsa(key); + } + if let Ok(key) = p256::SecretKey::from_pkcs8_der(der) { + return Self::from_p256(P256SigningKey::from(key)); + } + if let Ok(key) = p384::SecretKey::from_pkcs8_der(der) { + return Self::from_p384(P384SigningKey::from(key)); + } + if let Ok(key) = Ed25519SigningKey::from_pkcs8_der(der) { + return Self::from_ed25519(key); + } + Err(ClassicalSignatureError::InvalidPrivateKey) + } + + /// Loads an RSA private key from PKCS#1 DER. + pub fn from_pkcs1_der(der: &[u8]) -> Result { + RsaPrivateKey::from_pkcs1_der(der) + .map_err(|_| ClassicalSignatureError::InvalidPrivateKey) + .and_then(Self::from_rsa) + } + + /// Loads a P-256 or P-384 private key from SEC1 DER. + pub fn from_sec1_der(der: &[u8]) -> Result { + if let Ok(key) = p256::SecretKey::from_sec1_der(der) { + return Self::from_p256(P256SigningKey::from(key)); + } + if let Ok(key) = p384::SecretKey::from_sec1_der(der) { + return Self::from_p384(P384SigningKey::from(key)); + } + Err(ClassicalSignatureError::InvalidPrivateKey) + } + + /// Returns the key's public-key family. + pub const fn algorithm(&self) -> ClassicalSignatureAlgorithm { + match self.inner { + SigningKeyInner::Rsa(_) => ClassicalSignatureAlgorithm::Rsa, + SigningKeyInner::EcdsaP256(_) => ClassicalSignatureAlgorithm::EcdsaP256, + SigningKeyInner::EcdsaP384(_) => ClassicalSignatureAlgorithm::EcdsaP384, + SigningKeyInner::Ed25519(_) => ClassicalSignatureAlgorithm::Ed25519, + } + } + + /// Returns the public-key bytes expected by [`verify`]. + /// + /// RSA keys use PKCS#1 DER, ECDSA keys use uncompressed SEC1 points, and + /// Ed25519 keys use their 32-byte compressed encoding. + pub fn public_key(&self) -> &[u8] { + &self.public_key + } + + /// Returns the DER-encoded SubjectPublicKeyInfo for this key. + pub fn public_key_spki_der(&self) -> &[u8] { + &self.public_key_spki + } + + /// Returns whether this key can produce `scheme`. + pub const fn supports(&self, scheme: ClassicalSignatureScheme) -> bool { + matches!( + (self.algorithm(), scheme), + ( + ClassicalSignatureAlgorithm::EcdsaP256, + ClassicalSignatureScheme::EcdsaP256Sha256 + ) | ( + ClassicalSignatureAlgorithm::EcdsaP384, + ClassicalSignatureScheme::EcdsaP384Sha384 + ) | ( + ClassicalSignatureAlgorithm::Ed25519, + ClassicalSignatureScheme::Ed25519 + ) | ( + ClassicalSignatureAlgorithm::Rsa, + ClassicalSignatureScheme::RsaPssSha256 + | ClassicalSignatureScheme::RsaPssSha384 + | ClassicalSignatureScheme::RsaPssSha512 + | ClassicalSignatureScheme::RsaPkcs1Sha256 + | ClassicalSignatureScheme::RsaPkcs1Sha384 + | ClassicalSignatureScheme::RsaPkcs1Sha512 + ) + ) + } + + /// Signs `message` with the selected scheme. + pub fn sign( + &self, + scheme: ClassicalSignatureScheme, + message: &[u8], + ) -> Result, ClassicalSignatureError> { + if !self.supports(scheme) { + return Err(ClassicalSignatureError::UnsupportedScheme); + } + + match (&self.inner, scheme) { + (SigningKeyInner::EcdsaP256(key), ClassicalSignatureScheme::EcdsaP256Sha256) => { + let signature: P256Signature = key + .try_sign(message) + .map_err(|_| ClassicalSignatureError::SigningFailed)?; + let signature = signature.normalize_s(); + Ok(signature.to_der().as_bytes().to_vec()) + } + (SigningKeyInner::EcdsaP384(key), ClassicalSignatureScheme::EcdsaP384Sha384) => { + let signature: P384Signature = key + .try_sign(message) + .map_err(|_| ClassicalSignatureError::SigningFailed)?; + let signature = signature.normalize_s(); + Ok(signature.to_der().as_bytes().to_vec()) + } + (SigningKeyInner::Ed25519(key), ClassicalSignatureScheme::Ed25519) => { + Ok(key.sign(message).to_bytes().to_vec()) + } + (SigningKeyInner::Rsa(key), scheme) => sign_rsa(key, scheme, message), + _ => Err(ClassicalSignatureError::UnsupportedScheme), + } + } + + fn from_rsa(key: RsaPrivateKey) -> Result { + if key.n().bits() < RSA_MINIMUM_BITS { + return Err(ClassicalSignatureError::InvalidPrivateKey); + } + let public_key = key + .as_public_key() + .to_pkcs1_der() + .map_err(|_| ClassicalSignatureError::PublicKeyEncoding)? + .as_bytes() + .to_vec(); + let public_key_spki = key + .as_public_key() + .to_public_key_der() + .map_err(|_| ClassicalSignatureError::PublicKeyEncoding)? + .as_bytes() + .to_vec(); + Ok(Self { + inner: SigningKeyInner::Rsa(key), + public_key, + public_key_spki, + }) + } + + fn from_p256(key: P256SigningKey) -> Result { + let verifying_key = key.verifying_key(); + let public_key = verifying_key.to_sec1_point(false).as_bytes().to_vec(); + let public_key_spki = verifying_key + .to_public_key_der() + .map_err(|_| ClassicalSignatureError::PublicKeyEncoding)? + .as_bytes() + .to_vec(); + Ok(Self { + inner: SigningKeyInner::EcdsaP256(key), + public_key, + public_key_spki, + }) + } + + fn from_p384(key: P384SigningKey) -> Result { + let verifying_key = key.verifying_key(); + let public_key = verifying_key.to_sec1_point(false).as_bytes().to_vec(); + let public_key_spki = verifying_key + .to_public_key_der() + .map_err(|_| ClassicalSignatureError::PublicKeyEncoding)? + .as_bytes() + .to_vec(); + Ok(Self { + inner: SigningKeyInner::EcdsaP384(key), + public_key, + public_key_spki, + }) + } + + fn from_ed25519(key: Ed25519SigningKey) -> Result { + let verifying_key = key.verifying_key(); + let public_key = verifying_key.to_bytes().to_vec(); + let public_key_spki = verifying_key + .to_public_key_der() + .map_err(|_| ClassicalSignatureError::PublicKeyEncoding)? + .as_bytes() + .to_vec(); + Ok(Self { + inner: SigningKeyInner::Ed25519(key), + public_key, + public_key_spki, + }) + } +} + +impl fmt::Debug for ClassicalSigningKey { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClassicalSigningKey") + .field("algorithm", &self.algorithm()) + .field("public_key_len", &self.public_key.len()) + .finish_non_exhaustive() + } +} + +/// Verifies a conventional signature. +pub fn verify( + algorithm: ClassicalVerificationAlgorithm, + public_key: &[u8], + message: &[u8], + signature: &[u8], +) -> Result<(), ClassicalSignatureError> { + match algorithm { + ClassicalVerificationAlgorithm::EcdsaP256Sha256 => { + verify_p256::(public_key, message, signature) + } + ClassicalVerificationAlgorithm::EcdsaP256Sha384 => { + verify_p256::(public_key, message, signature) + } + ClassicalVerificationAlgorithm::EcdsaP256Sha512 => { + verify_p256::(public_key, message, signature) + } + ClassicalVerificationAlgorithm::EcdsaP384Sha256 => { + verify_p384::(public_key, message, signature) + } + ClassicalVerificationAlgorithm::EcdsaP384Sha384 => { + verify_p384::(public_key, message, signature) + } + ClassicalVerificationAlgorithm::EcdsaP384Sha512 => { + verify_p384::(public_key, message, signature) + } + ClassicalVerificationAlgorithm::Ed25519 => { + let key = Ed25519VerifyingKey::try_from(public_key) + .map_err(|_| ClassicalSignatureError::InvalidPublicKey)?; + let signature = Ed25519Signature::try_from(signature) + .map_err(|_| ClassicalSignatureError::InvalidSignature)?; + key.verify_strict(message, &signature) + .map_err(|_| ClassicalSignatureError::InvalidSignature) + } + ClassicalVerificationAlgorithm::RsaPssSha256 => verify_rsa( + public_key, + Pss::::new(), + &Sha256::digest(message), + signature, + ), + ClassicalVerificationAlgorithm::RsaPssSha384 => verify_rsa( + public_key, + Pss::::new(), + &Sha384::digest(message), + signature, + ), + ClassicalVerificationAlgorithm::RsaPssSha512 => verify_rsa( + public_key, + Pss::::new(), + &Sha512::digest(message), + signature, + ), + ClassicalVerificationAlgorithm::RsaPkcs1Sha256 => verify_rsa( + public_key, + Pkcs1v15Sign::new::(), + &Sha256::digest(message), + signature, + ), + ClassicalVerificationAlgorithm::RsaPkcs1Sha384 => verify_rsa( + public_key, + Pkcs1v15Sign::new::(), + &Sha384::digest(message), + signature, + ), + ClassicalVerificationAlgorithm::RsaPkcs1Sha512 => verify_rsa( + public_key, + Pkcs1v15Sign::new::(), + &Sha512::digest(message), + signature, + ), + } +} + +fn sign_rsa( + key: &RsaPrivateKey, + scheme: ClassicalSignatureScheme, + message: &[u8], +) -> Result, ClassicalSignatureError> { + let mut rng = getrandom_v04::SysRng; + let result = match scheme { + ClassicalSignatureScheme::RsaPssSha256 => { + Pss::::new().sign(Some(&mut rng), key, &Sha256::digest(message)) + } + ClassicalSignatureScheme::RsaPssSha384 => { + Pss::::new().sign(Some(&mut rng), key, &Sha384::digest(message)) + } + ClassicalSignatureScheme::RsaPssSha512 => { + Pss::::new().sign(Some(&mut rng), key, &Sha512::digest(message)) + } + ClassicalSignatureScheme::RsaPkcs1Sha256 => { + Pkcs1v15Sign::new::().sign(Some(&mut rng), key, &Sha256::digest(message)) + } + ClassicalSignatureScheme::RsaPkcs1Sha384 => { + Pkcs1v15Sign::new::().sign(Some(&mut rng), key, &Sha384::digest(message)) + } + ClassicalSignatureScheme::RsaPkcs1Sha512 => { + Pkcs1v15Sign::new::().sign(Some(&mut rng), key, &Sha512::digest(message)) + } + _ => return Err(ClassicalSignatureError::UnsupportedScheme), + }; + result.map_err(|_| ClassicalSignatureError::SigningFailed) +} + +fn verify_p256( + public_key: &[u8], + message: &[u8], + signature: &[u8], +) -> Result<(), ClassicalSignatureError> +where + D: sha2::Digest, +{ + let key = P256VerifyingKey::from_sec1_bytes(public_key) + .map_err(|_| ClassicalSignatureError::InvalidPublicKey)?; + let signature = P256Signature::from_der(signature) + .map_err(|_| ClassicalSignatureError::InvalidSignature)?; + key.verify_prehash(&D::digest(message), &signature) + .map_err(|_| ClassicalSignatureError::InvalidSignature) +} + +fn verify_p384( + public_key: &[u8], + message: &[u8], + signature: &[u8], +) -> Result<(), ClassicalSignatureError> +where + D: sha2::Digest, +{ + let key = P384VerifyingKey::from_sec1_bytes(public_key) + .map_err(|_| ClassicalSignatureError::InvalidPublicKey)?; + let signature = P384Signature::from_der(signature) + .map_err(|_| ClassicalSignatureError::InvalidSignature)?; + key.verify_prehash(&D::digest(message), &signature) + .map_err(|_| ClassicalSignatureError::InvalidSignature) +} + +fn verify_rsa( + public_key: &[u8], + scheme: S, + digest: &[u8], + signature: &[u8], +) -> Result<(), ClassicalSignatureError> +where + S: rsa::traits::SignatureScheme, +{ + let key = RsaPublicKey::from_pkcs1_der(public_key) + .map_err(|_| ClassicalSignatureError::InvalidPublicKey)?; + if key.n().bits() < RSA_MINIMUM_BITS { + return Err(ClassicalSignatureError::InvalidPublicKey); + } + key.verify(scheme, digest, signature) + .map_err(|_| ClassicalSignatureError::InvalidSignature) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use ed25519_dalek::pkcs8::EncodePrivateKey as _; + use rsa::pkcs1::EncodeRsaPrivateKey as _; + use signature::hazmat::PrehashSigner as _; + + use super::*; + + #[test] + fn p256_pkcs8_and_sec1_round_trip() { + let secret = p256::SecretKey::from_slice(&[0x11; 32]).unwrap(); + let pkcs8 = secret.to_pkcs8_der().unwrap(); + let key = ClassicalSigningKey::from_pkcs8_der(pkcs8.as_bytes()).unwrap(); + assert_eq!(key.algorithm(), ClassicalSignatureAlgorithm::EcdsaP256); + let signature = key + .sign(ClassicalSignatureScheme::EcdsaP256Sha256, b"message") + .unwrap(); + verify( + ClassicalVerificationAlgorithm::EcdsaP256Sha256, + key.public_key(), + b"message", + &signature, + ) + .unwrap(); + + let sec1 = secret.to_sec1_der().unwrap(); + assert_eq!( + ClassicalSigningKey::from_sec1_der(sec1.as_slice()) + .unwrap() + .algorithm(), + ClassicalSignatureAlgorithm::EcdsaP256 + ); + } + + #[test] + fn p384_cross_hash_verification_works() { + let secret = p384::SecretKey::from_slice(&[0x22; 48]).unwrap(); + let signing_key = P384SigningKey::from(secret.clone()); + let key = ClassicalSigningKey::from_p384(signing_key.clone()).unwrap(); + let signature: P384Signature = signing_key + .sign_prehash(&Sha256::digest(b"certificate")) + .unwrap(); + verify( + ClassicalVerificationAlgorithm::EcdsaP384Sha256, + key.public_key(), + b"certificate", + signature.to_der().as_bytes(), + ) + .unwrap(); + + let sec1 = secret.to_sec1_der().unwrap(); + assert_eq!( + ClassicalSigningKey::from_sec1_der(sec1.as_slice()) + .unwrap() + .algorithm(), + ClassicalSignatureAlgorithm::EcdsaP384 + ); + } + + #[test] + fn ed25519_pkcs8_round_trip() { + let signing_key = Ed25519SigningKey::from_bytes(&[0x33; 32]); + let pkcs8 = signing_key.to_pkcs8_der().unwrap(); + let key = ClassicalSigningKey::from_pkcs8_der(pkcs8.as_bytes()).unwrap(); + assert_eq!(key.algorithm(), ClassicalSignatureAlgorithm::Ed25519); + let signature = key + .sign(ClassicalSignatureScheme::Ed25519, b"message") + .unwrap(); + verify( + ClassicalVerificationAlgorithm::Ed25519, + key.public_key(), + b"message", + &signature, + ) + .unwrap(); + } + + #[test] + fn rsa_pkcs1_and_pkcs8_support_all_schemes() { + let mut rng = rand_core_010::UnwrapErr(getrandom_v04::SysRng); + let rsa = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let pkcs1 = rsa.to_pkcs1_der().unwrap(); + let key = ClassicalSigningKey::from_pkcs1_der(pkcs1.as_bytes()).unwrap(); + let cases = [ + ( + ClassicalSignatureScheme::RsaPssSha256, + ClassicalVerificationAlgorithm::RsaPssSha256, + ), + ( + ClassicalSignatureScheme::RsaPssSha384, + ClassicalVerificationAlgorithm::RsaPssSha384, + ), + ( + ClassicalSignatureScheme::RsaPssSha512, + ClassicalVerificationAlgorithm::RsaPssSha512, + ), + ( + ClassicalSignatureScheme::RsaPkcs1Sha256, + ClassicalVerificationAlgorithm::RsaPkcs1Sha256, + ), + ( + ClassicalSignatureScheme::RsaPkcs1Sha384, + ClassicalVerificationAlgorithm::RsaPkcs1Sha384, + ), + ( + ClassicalSignatureScheme::RsaPkcs1Sha512, + ClassicalVerificationAlgorithm::RsaPkcs1Sha512, + ), + ]; + for (scheme, verification) in cases { + let signature = key.sign(scheme, b"message").unwrap(); + verify(verification, key.public_key(), b"message", &signature).unwrap(); + } + + let pkcs8 = rsa.to_pkcs8_der().unwrap(); + assert_eq!( + ClassicalSigningKey::from_pkcs8_der(pkcs8.as_bytes()) + .unwrap() + .algorithm(), + ClassicalSignatureAlgorithm::Rsa + ); + } + + #[test] + fn malformed_inputs_and_scheme_mismatches_fail_closed() { + assert!(ClassicalSigningKey::from_pkcs8_der(&[0; 32]).is_err()); + assert!( + verify( + ClassicalVerificationAlgorithm::Ed25519, + &[0; 31], + b"message", + &[0; 64], + ) + .is_err() + ); + + let signing_key = Ed25519SigningKey::from_bytes(&[0x44; 32]); + let key = ClassicalSigningKey::from_ed25519(signing_key).unwrap(); + assert!( + key.sign(ClassicalSignatureScheme::EcdsaP256Sha256, b"message") + .is_err() + ); + } +} diff --git a/src/key_agreement.rs b/src/key_agreement.rs new file mode 100644 index 0000000..aaefdfc --- /dev/null +++ b/src/key_agreement.rs @@ -0,0 +1,325 @@ +//! Transport-neutral ephemeral elliptic-curve key agreement. + +use core::fmt; + +use p256::elliptic_curve::sec1::ToSec1Point as _; +use thiserror::Error as ThisError; +use x25519_dalek::{PublicKey as DalekPublicKey, StaticSecret as DalekStaticSecret}; +use zeroize::{Zeroize, Zeroizing}; + +use crate::random::{RandomError, fill}; + +/// Errors returned by ephemeral key-agreement operations. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)] +pub enum KeyAgreementError { + /// The operating system could not provide key material. + #[error(transparent)] + Random(#[from] RandomError), + /// The peer key has an invalid length, encoding, point, or contribution. + #[error("invalid peer key")] + InvalidPeerKey, + /// A generated public key or shared secret had an unexpected representation. + #[error("invalid generated key representation")] + InvalidGeneratedKey, +} + +macro_rules! public_key { + ($(#[$metadata:meta])* $name:ident, $length:expr) => { + $(#[$metadata])* + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct $name([u8; $length]); + + impl $name { + /// Returns the canonical fixed-width byte representation. + pub const fn as_bytes(&self) -> &[u8; $length] { + &self.0 + } + } + + impl AsRef<[u8]> for $name { + fn as_ref(&self) -> &[u8] { + &self.0 + } + } + + impl From<[u8; $length]> for $name { + fn from(bytes: [u8; $length]) -> Self { + Self(bytes) + } + } + + impl TryFrom<&[u8]> for $name { + type Error = KeyAgreementError; + + fn try_from(bytes: &[u8]) -> Result { + <[u8; $length]>::try_from(bytes) + .map(Self) + .map_err(|_| KeyAgreementError::InvalidPeerKey) + } + } + }; +} + +macro_rules! shared_secret { + ($(#[$metadata:meta])* $name:ident, $length:expr) => { + $(#[$metadata])* + pub struct $name([u8; $length]); + + impl $name { + /// Returns the fixed-width shared-secret bytes. + pub const fn as_bytes(&self) -> &[u8; $length] { + &self.0 + } + } + + impl AsRef<[u8]> for $name { + fn as_ref(&self) -> &[u8] { + &self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct(stringify!($name)).finish_non_exhaustive() + } + } + + impl Drop for $name { + fn drop(&mut self) { + self.0.zeroize(); + } + } + }; +} + +public_key!( + /// An X25519 public key. + X25519PublicKey, + 32 +); +public_key!( + /// An uncompressed SEC1 P-256 public key. + P256PublicKey, + 65 +); +public_key!( + /// An uncompressed SEC1 P-384 public key. + P384PublicKey, + 97 +); +shared_secret!( + /// An X25519 shared secret. + X25519SharedSecret, + 32 +); +shared_secret!( + /// A P-256 ECDH shared secret. + P256SharedSecret, + 32 +); +shared_secret!( + /// A P-384 ECDH shared secret. + P384SharedSecret, + 48 +); + +/// An ephemeral X25519 secret and its corresponding public key. +pub struct X25519Ephemeral { + secret: DalekStaticSecret, + public_key: X25519PublicKey, +} + +impl X25519Ephemeral { + /// Generates a fresh ephemeral key with operating-system randomness. + pub fn generate() -> Result { + let mut bytes = Zeroizing::new([0u8; 32]); + fill(bytes.as_mut())?; + let secret = DalekStaticSecret::from(*bytes); + let public_key = X25519PublicKey::from(DalekPublicKey::from(&secret).to_bytes()); + Ok(Self { secret, public_key }) + } + + /// Returns this ephemeral key's public key. + pub const fn public_key(&self) -> X25519PublicKey { + self.public_key + } + + /// Consumes this ephemeral key and agrees with `peer`. + pub fn complete(self, peer: &X25519PublicKey) -> Result { + let shared = self + .secret + .diffie_hellman(&DalekPublicKey::from(*peer.as_bytes())); + if !shared.was_contributory() { + return Err(KeyAgreementError::InvalidPeerKey); + } + Ok(X25519SharedSecret(shared.to_bytes())) + } +} + +impl fmt::Debug for X25519Ephemeral { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("X25519Ephemeral") + .field("public_key", &self.public_key) + .finish_non_exhaustive() + } +} + +/// An ephemeral P-256 secret and its corresponding uncompressed public key. +pub struct P256Ephemeral { + secret: p256::SecretKey, + public_key: P256PublicKey, +} + +impl P256Ephemeral { + /// Generates a fresh ephemeral key with operating-system randomness. + pub fn generate() -> Result { + let secret = loop { + let mut candidate = Zeroizing::new([0u8; 32]); + fill(candidate.as_mut())?; + if let Ok(secret) = p256::SecretKey::from_slice(candidate.as_ref()) { + break secret; + } + }; + let encoded = secret.public_key().to_sec1_point(false); + let public_key = P256PublicKey::try_from(encoded.as_bytes()) + .map_err(|_| KeyAgreementError::InvalidGeneratedKey)?; + Ok(Self { secret, public_key }) + } + + /// Returns this ephemeral key's canonical uncompressed public key. + pub const fn public_key(&self) -> P256PublicKey { + self.public_key + } + + /// Consumes this ephemeral key and agrees with `peer`. + pub fn complete(self, peer: &P256PublicKey) -> Result { + if peer.as_bytes().first() != Some(&0x04) { + return Err(KeyAgreementError::InvalidPeerKey); + } + let peer = p256::PublicKey::from_sec1_bytes(peer.as_ref()) + .map_err(|_| KeyAgreementError::InvalidPeerKey)?; + let shared = p256::ecdh::diffie_hellman(self.secret.to_nonzero_scalar(), peer.as_affine()); + let raw = shared.raw_secret_bytes(); + let bytes = <[u8; 32]>::try_from(raw.as_slice()) + .map_err(|_| KeyAgreementError::InvalidGeneratedKey)?; + Ok(P256SharedSecret(bytes)) + } +} + +impl fmt::Debug for P256Ephemeral { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("P256Ephemeral") + .field("public_key", &self.public_key) + .finish_non_exhaustive() + } +} + +/// An ephemeral P-384 secret and its corresponding uncompressed public key. +pub struct P384Ephemeral { + secret: p384::SecretKey, + public_key: P384PublicKey, +} + +impl P384Ephemeral { + /// Generates a fresh ephemeral key with operating-system randomness. + pub fn generate() -> Result { + let secret = loop { + let mut candidate = Zeroizing::new([0u8; 48]); + fill(candidate.as_mut())?; + if let Ok(secret) = p384::SecretKey::from_slice(candidate.as_ref()) { + break secret; + } + }; + let encoded = secret.public_key().to_sec1_point(false); + let public_key = P384PublicKey::try_from(encoded.as_bytes()) + .map_err(|_| KeyAgreementError::InvalidGeneratedKey)?; + Ok(Self { secret, public_key }) + } + + /// Returns this ephemeral key's canonical uncompressed public key. + pub const fn public_key(&self) -> P384PublicKey { + self.public_key + } + + /// Consumes this ephemeral key and agrees with `peer`. + pub fn complete(self, peer: &P384PublicKey) -> Result { + if peer.as_bytes().first() != Some(&0x04) { + return Err(KeyAgreementError::InvalidPeerKey); + } + let peer = p384::PublicKey::from_sec1_bytes(peer.as_ref()) + .map_err(|_| KeyAgreementError::InvalidPeerKey)?; + let shared = p384::ecdh::diffie_hellman(self.secret.to_nonzero_scalar(), peer.as_affine()); + let raw = shared.raw_secret_bytes(); + let bytes = <[u8; 48]>::try_from(raw.as_slice()) + .map_err(|_| KeyAgreementError::InvalidGeneratedKey)?; + Ok(P384SharedSecret(bytes)) + } +} + +impl fmt::Debug for P384Ephemeral { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("P384Ephemeral") + .field("public_key", &self.public_key) + .finish_non_exhaustive() + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn x25519_round_trip_and_low_order_rejection() { + let alice = X25519Ephemeral::generate().unwrap(); + let bob = X25519Ephemeral::generate().unwrap(); + let alice_public = alice.public_key(); + let bob_public = bob.public_key(); + let alice_secret = alice.complete(&bob_public).unwrap(); + let bob_secret = bob.complete(&alice_public).unwrap(); + assert_eq!(alice_secret.as_ref(), bob_secret.as_ref()); + + let ephemeral = X25519Ephemeral::generate().unwrap(); + assert!(ephemeral.complete(&X25519PublicKey::from([0; 32])).is_err()); + } + + #[test] + fn p256_round_trip_and_invalid_point_rejection() { + let alice = P256Ephemeral::generate().unwrap(); + let bob = P256Ephemeral::generate().unwrap(); + let alice_public = alice.public_key(); + let bob_public = bob.public_key(); + assert_eq!( + alice.complete(&bob_public).unwrap().as_ref(), + bob.complete(&alice_public).unwrap().as_ref() + ); + + let ephemeral = P256Ephemeral::generate().unwrap(); + assert!(ephemeral.complete(&P256PublicKey::from([0; 65])).is_err()); + } + + #[test] + fn p384_round_trip_and_invalid_point_rejection() { + let alice = P384Ephemeral::generate().unwrap(); + let bob = P384Ephemeral::generate().unwrap(); + let alice_public = alice.public_key(); + let bob_public = bob.public_key(); + assert_eq!( + alice.complete(&bob_public).unwrap().as_ref(), + bob.complete(&alice_public).unwrap().as_ref() + ); + + let ephemeral = P384Ephemeral::generate().unwrap(); + assert!(ephemeral.complete(&P384PublicKey::from([0; 97])).is_err()); + } + + #[test] + fn public_key_newtypes_reject_wrong_lengths() { + assert!(X25519PublicKey::try_from([0u8; 31].as_slice()).is_err()); + assert!(P256PublicKey::try_from([0u8; 64].as_slice()).is_err()); + assert!(P384PublicKey::try_from([0u8; 96].as_slice()).is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 55dac6a..3e6120a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,8 @@ mod macros; #[cfg(feature = "bird-of-prey")] pub mod bird_of_prey; +#[cfg(feature = "classical-signatures")] +pub mod classical_signature; #[cfg(feature = "bird-of-prey")] pub mod det_rng; pub mod error; @@ -38,14 +40,20 @@ pub mod hhd; feature = "sntrup" ))] pub mod kem; +#[cfg(feature = "key-agreement")] +pub mod key_agreement; #[cfg(feature = "mayo")] pub mod mayo; #[cfg(feature = "ml-dsa")] // The module implements deprecated compatibility APIs; downstream uses still warn. #[allow(deprecated)] pub mod ml_dsa; +#[cfg(feature = "random")] +pub mod random; #[cfg(feature = "slh-dsa")] pub mod slh_dsa; +#[cfg(feature = "symmetric")] +pub mod symmetric; #[cfg(feature = "xmss")] pub mod xmss; #[cfg(feature = "xwing")] diff --git a/src/random.rs b/src/random.rs new file mode 100644 index 0000000..16617e2 --- /dev/null +++ b/src/random.rs @@ -0,0 +1,25 @@ +//! Operating-system randomness for cryptographic operations. + +use thiserror::Error as ThisError; + +/// Failure to obtain cryptographically secure operating-system randomness. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)] +#[error("operating-system randomness failed")] +pub struct RandomError; + +/// Fills `destination` with cryptographically secure operating-system randomness. +pub fn fill(destination: &mut [u8]) -> Result<(), RandomError> { + getrandom_v04::fill(destination).map_err(|_| RandomError) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fills_the_requested_buffer() { + let mut bytes = [0u8; 32]; + assert!(fill(&mut bytes).is_ok()); + assert_ne!(bytes, [0u8; 32]); + } +} diff --git a/src/symmetric.rs b/src/symmetric.rs new file mode 100644 index 0000000..df3bc81 --- /dev/null +++ b/src/symmetric.rs @@ -0,0 +1,555 @@ +//! Transport-neutral symmetric cryptographic primitives. +//! +//! This module contains mechanism only. TLS record framing, QUIC packet +//! protection, nonce construction, and algorithm negotiation remain the +//! responsibility of protocol adapters. + +use core::fmt; + +use aes::cipher::{BlockCipherEncrypt, KeyInit as _, consts::U16}; +use aes::{Aes128, Aes256, Block}; +use aes_gcm::aead::{AeadInOut, consts::U12}; +use aes_gcm::{Aes128Gcm, Aes256Gcm}; +use chacha20::ChaCha20; +use chacha20::cipher::{KeyIvInit, StreamCipher, StreamCipherSeek}; +use chacha20poly1305::ChaCha20Poly1305; +use hkdf::Hkdf as RustCryptoHkdf; +use hmac::{Hmac as RustCryptoHmac, Mac}; +use sha2::{Digest as _, Sha256, Sha384}; +use thiserror::Error as ThisError; + +/// Length in bytes of every supported AEAD authentication tag. +pub const AEAD_TAG_LEN: usize = 16; + +/// Errors returned by symmetric primitive operations. +#[derive(Clone, Copy, Debug, Eq, PartialEq, ThisError)] +pub enum SymmetricError { + /// A key does not have the length required by the selected algorithm. + #[error("invalid symmetric key length")] + InvalidKeyLength, + /// A fixed-width value has an unexpected length. + #[error("invalid length: expected {expected} bytes, got {actual}")] + InvalidLength { + /// Required byte length. + expected: usize, + /// Supplied byte length. + actual: usize, + }, + /// Authentication of a ciphertext or MAC failed. + #[error("authentication failed")] + AuthenticationFailed, + /// An HKDF pseudorandom key or requested output has an invalid length. + #[error("invalid HKDF input or output length")] + InvalidHkdfLength, + /// The requested stream-cipher position is outside the supported range. + #[error("invalid stream-cipher position")] + InvalidStreamPosition, +} + +macro_rules! fixed_bytes { + ($(#[$metadata:meta])* $name:ident, $length:expr) => { + $(#[$metadata])* + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + pub struct $name([u8; $length]); + + impl $name { + /// Returns the fixed-width byte representation. + pub const fn as_array(&self) -> &[u8; $length] { + &self.0 + } + } + + impl AsRef<[u8]> for $name { + fn as_ref(&self) -> &[u8] { + &self.0 + } + } + + impl From<[u8; $length]> for $name { + fn from(value: [u8; $length]) -> Self { + Self(value) + } + } + + impl From<$name> for [u8; $length] { + fn from(value: $name) -> Self { + value.0 + } + } + + impl TryFrom<&[u8]> for $name { + type Error = SymmetricError; + + fn try_from(value: &[u8]) -> Result { + let bytes = <[u8; $length]>::try_from(value).map_err(|_| { + SymmetricError::InvalidLength { + expected: $length, + actual: value.len(), + } + })?; + Ok(Self(bytes)) + } + } + }; +} + +fixed_bytes!( + /// A 96-bit nonce for the supported AEAD algorithms. + AeadNonce, + 12 +); +fixed_bytes!( + /// A detached authentication tag produced by a supported AEAD algorithm. + AuthenticationTag, + AEAD_TAG_LEN +); +fixed_bytes!( + /// The output of SHA-256. + Sha256Digest, + 32 +); +fixed_bytes!( + /// The output of SHA-384. + Sha384Digest, + 48 +); +fixed_bytes!( + /// An HMAC-SHA-256 authentication tag. + HmacSha256Tag, + 32 +); +fixed_bytes!( + /// An HMAC-SHA-384 authentication tag. + HmacSha384Tag, + 48 +); + +/// A supported authenticated-encryption algorithm. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AeadAlgorithm { + /// AES-128-GCM. + Aes128Gcm, + /// AES-256-GCM. + Aes256Gcm, + /// ChaCha20-Poly1305. + ChaCha20Poly1305, +} + +impl AeadAlgorithm { + /// Returns the required key length in bytes. + pub const fn key_len(self) -> usize { + match self { + Self::Aes128Gcm => 16, + Self::Aes256Gcm | Self::ChaCha20Poly1305 => 32, + } + } + + /// Encrypts `payload` in place and returns its detached authentication tag. + pub fn encrypt_in_place_detached( + self, + key: &[u8], + nonce: AeadNonce, + associated_data: &[u8], + payload: &mut [u8], + ) -> Result { + match self { + Self::Aes128Gcm => { + let cipher = + Aes128Gcm::new_from_slice(key).map_err(|_| SymmetricError::InvalidKeyLength)?; + let nonce = aes_gcm::Nonce::::from(*nonce.as_array()); + cipher + .encrypt_inout_detached(&nonce, associated_data, payload.into()) + .map(|tag| AuthenticationTag::from(<[u8; AEAD_TAG_LEN]>::from(tag))) + .map_err(|_| SymmetricError::AuthenticationFailed) + } + Self::Aes256Gcm => { + let cipher = + Aes256Gcm::new_from_slice(key).map_err(|_| SymmetricError::InvalidKeyLength)?; + let nonce = aes_gcm::Nonce::::from(*nonce.as_array()); + cipher + .encrypt_inout_detached(&nonce, associated_data, payload.into()) + .map(|tag| AuthenticationTag::from(<[u8; AEAD_TAG_LEN]>::from(tag))) + .map_err(|_| SymmetricError::AuthenticationFailed) + } + Self::ChaCha20Poly1305 => { + let cipher = ChaCha20Poly1305::new_from_slice(key) + .map_err(|_| SymmetricError::InvalidKeyLength)?; + let nonce = chacha20poly1305::Nonce::from(*nonce.as_array()); + cipher + .encrypt_inout_detached(&nonce, associated_data, payload.into()) + .map(|tag| AuthenticationTag::from(<[u8; AEAD_TAG_LEN]>::from(tag))) + .map_err(|_| SymmetricError::AuthenticationFailed) + } + } + } + + /// Authenticates and decrypts `payload` in place. + pub fn decrypt_in_place_detached( + self, + key: &[u8], + nonce: AeadNonce, + associated_data: &[u8], + payload: &mut [u8], + tag: AuthenticationTag, + ) -> Result<(), SymmetricError> { + match self { + Self::Aes128Gcm => { + let cipher = + Aes128Gcm::new_from_slice(key).map_err(|_| SymmetricError::InvalidKeyLength)?; + let nonce = aes_gcm::Nonce::::from(*nonce.as_array()); + let tag = aes_gcm::Tag::from(*tag.as_array()); + cipher + .decrypt_inout_detached(&nonce, associated_data, payload.into(), &tag) + .map_err(|_| SymmetricError::AuthenticationFailed) + } + Self::Aes256Gcm => { + let cipher = + Aes256Gcm::new_from_slice(key).map_err(|_| SymmetricError::InvalidKeyLength)?; + let nonce = aes_gcm::Nonce::::from(*nonce.as_array()); + let tag = aes_gcm::Tag::from(*tag.as_array()); + cipher + .decrypt_inout_detached(&nonce, associated_data, payload.into(), &tag) + .map_err(|_| SymmetricError::AuthenticationFailed) + } + Self::ChaCha20Poly1305 => { + let cipher = ChaCha20Poly1305::new_from_slice(key) + .map_err(|_| SymmetricError::InvalidKeyLength)?; + let nonce = chacha20poly1305::Nonce::from(*nonce.as_array()); + let tag = chacha20poly1305::Tag::from(*tag.as_array()); + cipher + .decrypt_inout_detached(&nonce, associated_data, payload.into(), &tag) + .map_err(|_| SymmetricError::AuthenticationFailed) + } + } + } +} + +/// Computes SHA-256 over `data`. +pub fn sha256(data: &[u8]) -> Sha256Digest { + Sha256Digest::from(<[u8; 32]>::from(Sha256::digest(data))) +} + +/// Computes SHA-384 over `data`. +pub fn sha384(data: &[u8]) -> Sha384Digest { + Sha384Digest::from(<[u8; 48]>::from(Sha384::digest(data))) +} + +/// Incremental SHA-256 state. +#[derive(Clone, Debug, Default)] +pub struct Sha256Context(Sha256); + +impl Sha256Context { + /// Creates an empty SHA-256 context. + pub fn new() -> Self { + Self::default() + } + + /// Adds bytes to the digest. + pub fn update(&mut self, data: &[u8]) { + self.0.update(data); + } + + /// Returns the digest without consuming this context. + pub fn fork_finish(&self) -> Sha256Digest { + Sha256Digest::from(<[u8; 32]>::from(self.0.clone().finalize())) + } + + /// Consumes this context and returns the digest. + pub fn finish(self) -> Sha256Digest { + Sha256Digest::from(<[u8; 32]>::from(self.0.finalize())) + } +} + +/// Incremental SHA-384 state. +#[derive(Clone, Debug, Default)] +pub struct Sha384Context(Sha384); + +impl Sha384Context { + /// Creates an empty SHA-384 context. + pub fn new() -> Self { + Self::default() + } + + /// Adds bytes to the digest. + pub fn update(&mut self, data: &[u8]) { + self.0.update(data); + } + + /// Returns the digest without consuming this context. + pub fn fork_finish(&self) -> Sha384Digest { + Sha384Digest::from(<[u8; 48]>::from(self.0.clone().finalize())) + } + + /// Consumes this context and returns the digest. + pub fn finish(self) -> Sha384Digest { + Sha384Digest::from(<[u8; 48]>::from(self.0.finalize())) + } +} + +/// An HMAC-SHA-256 key. +pub struct HmacSha256Key(RustCryptoHmac); + +impl HmacSha256Key { + /// Constructs a key from arbitrary-length key material. + pub fn new(key: &[u8]) -> Self { + Self( + RustCryptoHmac::::new_from_slice(key).unwrap_or_else(|_| { + as hmac::KeyInit>::new(&Default::default()) + }), + ) + } + + /// Authenticates the supplied byte slices as one contiguous message. + pub fn sign<'a>(&self, parts: impl IntoIterator) -> HmacSha256Tag { + let mut mac = self.0.clone(); + parts.into_iter().for_each(|part| mac.update(part)); + HmacSha256Tag::from(<[u8; 32]>::from(mac.finalize().into_bytes())) + } + + /// Verifies a tag for the supplied slices treated as one contiguous message. + pub fn verify<'a>( + &self, + parts: impl IntoIterator, + tag: &[u8], + ) -> Result<(), SymmetricError> { + let mut mac = self.0.clone(); + parts.into_iter().for_each(|part| mac.update(part)); + mac.verify_slice(tag) + .map_err(|_| SymmetricError::AuthenticationFailed) + } +} + +impl fmt::Debug for HmacSha256Key { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HmacSha256Key") + .finish_non_exhaustive() + } +} + +/// An HMAC-SHA-384 key. +pub struct HmacSha384Key(RustCryptoHmac); + +impl HmacSha384Key { + /// Constructs a key from arbitrary-length key material. + pub fn new(key: &[u8]) -> Self { + Self( + RustCryptoHmac::::new_from_slice(key).unwrap_or_else(|_| { + as hmac::KeyInit>::new(&Default::default()) + }), + ) + } + + /// Authenticates the supplied byte slices as one contiguous message. + pub fn sign<'a>(&self, parts: impl IntoIterator) -> HmacSha384Tag { + let mut mac = self.0.clone(); + parts.into_iter().for_each(|part| mac.update(part)); + HmacSha384Tag::from(<[u8; 48]>::from(mac.finalize().into_bytes())) + } + + /// Verifies a tag for the supplied slices treated as one contiguous message. + pub fn verify<'a>( + &self, + parts: impl IntoIterator, + tag: &[u8], + ) -> Result<(), SymmetricError> { + let mut mac = self.0.clone(); + parts.into_iter().for_each(|part| mac.update(part)); + mac.verify_slice(tag) + .map_err(|_| SymmetricError::AuthenticationFailed) + } +} + +impl fmt::Debug for HmacSha384Key { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("HmacSha384Key") + .finish_non_exhaustive() + } +} + +/// An HKDF-SHA-256 expander. +pub struct HkdfSha256(RustCryptoHkdf); + +impl HkdfSha256 { + /// Extracts a pseudorandom key from input key material and an optional salt. + pub fn extract(salt: Option<&[u8]>, input_key_material: &[u8]) -> Self { + Self(RustCryptoHkdf::new(salt, input_key_material)) + } + + /// Constructs an expander from an existing pseudorandom key. + pub fn from_prk(pseudorandom_key: &[u8]) -> Result { + RustCryptoHkdf::from_prk(pseudorandom_key) + .map(Self) + .map_err(|_| SymmetricError::InvalidHkdfLength) + } + + /// Expands this key using the concatenated `info` slices. + pub fn expand(&self, info: &[&[u8]], output: &mut [u8]) -> Result<(), SymmetricError> { + self.0 + .expand_multi_info(info, output) + .map_err(|_| SymmetricError::InvalidHkdfLength) + } +} + +impl fmt::Debug for HkdfSha256 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("HkdfSha256").finish_non_exhaustive() + } +} + +/// An HKDF-SHA-384 expander. +pub struct HkdfSha384(RustCryptoHkdf); + +impl HkdfSha384 { + /// Extracts a pseudorandom key from input key material and an optional salt. + pub fn extract(salt: Option<&[u8]>, input_key_material: &[u8]) -> Self { + Self(RustCryptoHkdf::new(salt, input_key_material)) + } + + /// Constructs an expander from an existing pseudorandom key. + pub fn from_prk(pseudorandom_key: &[u8]) -> Result { + RustCryptoHkdf::from_prk(pseudorandom_key) + .map(Self) + .map_err(|_| SymmetricError::InvalidHkdfLength) + } + + /// Expands this key using the concatenated `info` slices. + pub fn expand(&self, info: &[&[u8]], output: &mut [u8]) -> Result<(), SymmetricError> { + self.0 + .expand_multi_info(info, output) + .map_err(|_| SymmetricError::InvalidHkdfLength) + } +} + +impl fmt::Debug for HkdfSha384 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("HkdfSha384").finish_non_exhaustive() + } +} + +/// Encrypts one 16-byte block with AES-128. +pub fn aes128_encrypt_block(key: &[u8], plaintext: &[u8; 16]) -> Result<[u8; 16], SymmetricError> { + aes_encrypt_block::(key, plaintext) +} + +/// Encrypts one 16-byte block with AES-256. +pub fn aes256_encrypt_block(key: &[u8], plaintext: &[u8; 16]) -> Result<[u8; 16], SymmetricError> { + aes_encrypt_block::(key, plaintext) +} + +fn aes_encrypt_block(key: &[u8], plaintext: &[u8; 16]) -> Result<[u8; 16], SymmetricError> +where + Aes: BlockCipherEncrypt + aes::cipher::KeyInit, +{ + let cipher = Aes::new_from_slice(key).map_err(|_| SymmetricError::InvalidKeyLength)?; + let mut block = Block::from(*plaintext); + cipher.encrypt_block(&mut block); + Ok(block.into()) +} + +/// Writes ChaCha20 keystream bytes beginning at `position` into `output`. +pub fn chacha20_keystream( + key: &[u8], + nonce: &[u8; 12], + position: u64, + output: &mut [u8], +) -> Result<(), SymmetricError> { + let mut cipher = + ChaCha20::new_from_slices(key, nonce).map_err(|_| SymmetricError::InvalidKeyLength)?; + cipher + .try_seek(position) + .map_err(|_| SymmetricError::InvalidStreamPosition)?; + cipher + .try_apply_keystream(output) + .map_err(|_| SymmetricError::InvalidStreamPosition) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn all_aead_algorithms_round_trip_and_authenticate() { + for algorithm in [ + AeadAlgorithm::Aes128Gcm, + AeadAlgorithm::Aes256Gcm, + AeadAlgorithm::ChaCha20Poly1305, + ] { + let key = vec![0x42; algorithm.key_len()]; + let nonce = AeadNonce::from([0x24; 12]); + let mut payload = b"bedrock symmetric primitive".to_vec(); + let plaintext = payload.clone(); + let mut tag = algorithm + .encrypt_in_place_detached(&key, nonce, b"context", &mut payload) + .unwrap(); + algorithm + .decrypt_in_place_detached(&key, nonce, b"context", &mut payload, tag) + .unwrap(); + assert_eq!(payload, plaintext); + + tag.0[0] ^= 1; + assert!( + algorithm + .decrypt_in_place_detached(&key, nonce, b"context", &mut payload, tag) + .is_err() + ); + } + } + + #[test] + fn sha2_known_answers_and_incremental_contexts_match() { + assert_eq!( + hex::encode(sha256(b"abc")), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + let mut context = Sha384Context::new(); + context.update(b"a"); + context.update(b"bc"); + assert_eq!(context.finish(), sha384(b"abc")); + } + + #[test] + fn hmac_sha256_matches_rfc_4231() { + let key = HmacSha256Key::new(&[0x0b; 20]); + let tag = key.sign([b"Hi There".as_slice()]); + assert_eq!( + hex::encode(tag), + "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" + ); + assert!( + key.verify([b"Hi ".as_slice(), b"There".as_slice()], tag.as_ref()) + .is_ok() + ); + } + + #[test] + fn hkdf_sha256_matches_rfc_5869() { + let ikm = [0x0b; 22]; + let salt = [ + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, + ]; + let info = [0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9]; + let hkdf = HkdfSha256::extract(Some(&salt), &ikm); + let mut output = [0u8; 42]; + hkdf.expand(&[&info], &mut output).unwrap(); + assert_eq!( + hex::encode(output), + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865" + ); + } + + #[test] + fn aes_block_and_chacha_stream_match_known_answers() { + let aes = aes128_encrypt_block(&[0; 16], &[0; 16]).unwrap(); + assert_eq!(hex::encode(aes), "66e94bd4ef8a2c3b884cfa59ca342b2e"); + + let mut stream = [0u8; 64]; + chacha20_keystream(&[0; 32], &[0; 12], 0, &mut stream).unwrap(); + assert_eq!( + hex::encode(stream), + "76b8e0ada0f13d90405d6ae55386bd28bdd219b8a08ded1aa836efcc8b770dc7da41597c5157488d7724e03fb8d84a376a43b8f41518a11cc387b669b2ee6586" + ); + } +} From 03f91839eb5db8919b2f0a43b872282cd767d57d Mon Sep 17 00:00:00 2001 From: Mike Lodder Date: Tue, 1 Sep 2026 13:22:05 -0600 Subject: [PATCH 2/2] fix: address transport crypto review feedback --- README.md | 9 ++-- src/classical_signature.rs | 100 +++++++++++++++++++++++++++++-------- 2 files changed, 84 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 49df9d6..b6cde0a 100644 --- a/README.md +++ b/README.md @@ -579,23 +579,22 @@ X-Wing hybrid KEM only: tectonic-bedrock = { version = "0.4", default-features = false, features = ["ml-kem", "xwing", "kgen", "encp", "decp"] } ``` -Current unreleased transport APIs from a sibling checkout, with symmetric -primitives only: +Transport APIs introduced in v0.5.2, with symmetric primitives only: ```toml -tectonic-bedrock = { path = "../bedrock", default-features = false, features = ["symmetric"] } +tectonic-bedrock = { version = "0.5.2", default-features = false, features = ["symmetric"] } ``` Ephemeral key agreement only: ```toml -tectonic-bedrock = { path = "../bedrock", default-features = false, features = ["key-agreement"] } +tectonic-bedrock = { version = "0.5.2", default-features = false, features = ["key-agreement"] } ``` Conventional signature operations and private-key loading only: ```toml -tectonic-bedrock = { path = "../bedrock", default-features = false, features = ["classical-signatures"] } +tectonic-bedrock = { version = "0.5.2", default-features = false, features = ["classical-signatures"] } ``` ## Error Handling diff --git a/src/classical_signature.rs b/src/classical_signature.rs index 5641a3c..c8a0d8c 100644 --- a/src/classical_signature.rs +++ b/src/classical_signature.rs @@ -14,6 +14,7 @@ use p384::ecdsa::{ }; use rsa::pkcs1::{DecodeRsaPrivateKey, DecodeRsaPublicKey, EncodeRsaPublicKey}; use rsa::pkcs8::{DecodePrivateKey, EncodePublicKey}; +use rsa::pss::{Signature as RsaPssSignature, VerifyingKey as RsaPssVerifyingKey}; use rsa::traits::{PublicKeyParts, SignatureScheme as RsaSignatureScheme}; use rsa::{Pkcs1v15Sign, Pss, RsaPrivateKey, RsaPublicKey}; use sha2::{Digest as _, Sha256, Sha384, Sha512}; @@ -361,24 +362,15 @@ pub fn verify( key.verify_strict(message, &signature) .map_err(|_| ClassicalSignatureError::InvalidSignature) } - ClassicalVerificationAlgorithm::RsaPssSha256 => verify_rsa( - public_key, - Pss::::new(), - &Sha256::digest(message), - signature, - ), - ClassicalVerificationAlgorithm::RsaPssSha384 => verify_rsa( - public_key, - Pss::::new(), - &Sha384::digest(message), - signature, - ), - ClassicalVerificationAlgorithm::RsaPssSha512 => verify_rsa( - public_key, - Pss::::new(), - &Sha512::digest(message), - signature, - ), + ClassicalVerificationAlgorithm::RsaPssSha256 => { + verify_rsa_pss::(public_key, &Sha256::digest(message), signature) + } + ClassicalVerificationAlgorithm::RsaPssSha384 => { + verify_rsa_pss::(public_key, &Sha384::digest(message), signature) + } + ClassicalVerificationAlgorithm::RsaPssSha512 => { + verify_rsa_pss::(public_key, &Sha512::digest(message), signature) + } ClassicalVerificationAlgorithm::RsaPkcs1Sha256 => verify_rsa( public_key, Pkcs1v15Sign::new::(), @@ -400,6 +392,7 @@ pub fn verify( } } +/// Signs a message with the requested RSA scheme. fn sign_rsa( key: &RsaPrivateKey, scheme: ClassicalSignatureScheme, @@ -430,6 +423,7 @@ fn sign_rsa( result.map_err(|_| ClassicalSignatureError::SigningFailed) } +/// Verifies an ECDSA signature with a P-256 public key and caller-selected digest. fn verify_p256( public_key: &[u8], message: &[u8], @@ -446,6 +440,7 @@ where .map_err(|_| ClassicalSignatureError::InvalidSignature) } +/// Verifies an ECDSA signature with a P-384 public key and caller-selected digest. fn verify_p384( public_key: &[u8], message: &[u8], @@ -462,6 +457,24 @@ where .map_err(|_| ClassicalSignatureError::InvalidSignature) } +/// Verifies an RSA-PSS signature while accepting its encoded salt length. +fn verify_rsa_pss( + public_key: &[u8], + digest: &[u8], + signature: &[u8], +) -> Result<(), ClassicalSignatureError> +where + D: sha2::Digest + sha2::digest::FixedOutputReset, +{ + let key = parse_rsa_public_key(public_key)?; + let signature = RsaPssSignature::try_from(signature) + .map_err(|_| ClassicalSignatureError::InvalidSignature)?; + RsaPssVerifyingKey::::new_with_auto_salt_len(key) + .verify_prehash(digest, &signature) + .map_err(|_| ClassicalSignatureError::InvalidSignature) +} + +/// Verifies an RSA signature using a caller-selected padding scheme. fn verify_rsa( public_key: &[u8], scheme: S, @@ -471,13 +484,19 @@ fn verify_rsa( where S: rsa::traits::SignatureScheme, { + let key = parse_rsa_public_key(public_key)?; + key.verify(scheme, digest, signature) + .map_err(|_| ClassicalSignatureError::InvalidSignature) +} + +/// Decodes a PKCS#1 RSA public key and enforces the minimum key size. +fn parse_rsa_public_key(public_key: &[u8]) -> Result { let key = RsaPublicKey::from_pkcs1_der(public_key) .map_err(|_| ClassicalSignatureError::InvalidPublicKey)?; if key.n().bits() < RSA_MINIMUM_BITS { return Err(ClassicalSignatureError::InvalidPublicKey); } - key.verify(scheme, digest, signature) - .map_err(|_| ClassicalSignatureError::InvalidSignature) + Ok(key) } #[cfg(test)] @@ -604,6 +623,47 @@ mod tests { ); } + #[test] + fn rsa_pss_verification_accepts_non_default_salt_length() { + let mut rng = rand_core_010::UnwrapErr(getrandom_v04::SysRng); + let rsa = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let public_key = rsa.as_public_key().to_pkcs1_der().unwrap(); + let signature = Pss::::new_with_salt(20) + .sign(Some(&mut rng), &rsa, &Sha256::digest(b"message")) + .unwrap(); + + verify( + ClassicalVerificationAlgorithm::RsaPssSha256, + public_key.as_bytes(), + b"message", + &signature, + ) + .unwrap(); + } + + #[test] + fn undersized_rsa_keys_are_rejected() { + let mut rng = rand_core_010::UnwrapErr(getrandom_v04::SysRng); + let rsa = RsaPrivateKey::new(&mut rng, 1024).unwrap(); + let private_pkcs1 = rsa.to_pkcs1_der().unwrap(); + assert_eq!( + ClassicalSigningKey::from_pkcs1_der(private_pkcs1.as_bytes()).unwrap_err(), + ClassicalSignatureError::InvalidPrivateKey + ); + + let public_pkcs1 = rsa.as_public_key().to_pkcs1_der().unwrap(); + assert_eq!( + verify( + ClassicalVerificationAlgorithm::RsaPkcs1Sha256, + public_pkcs1.as_bytes(), + b"message", + &[0; 128], + ) + .unwrap_err(), + ClassicalSignatureError::InvalidPublicKey + ); + } + #[test] fn malformed_inputs_and_scheme_mismatches_fail_closed() { assert!(ClassicalSigningKey::from_pkcs8_der(&[0; 32]).is_err());