Repository navigation
feat(picky-crypto): add RSA CRT parameter completion #553
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
Benoît Cortier (CBenoit)
wants to merge
13
commits into
master
Choose a base branch
from
cbenoit-picky-crypto-rsa-crt
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
170586f
feat(picky-crypto): add RSA CRT parameter completion
CBenoit 99b00ae
fix(picky-crypto): enforce the 16384-bit modulus limit
CBenoit ad11856
ci: install cargo-hack with cargo
CBenoit 461575e
test(picky-crypto): cover structural RSA CRT inputs
CBenoit cf8726a
build(picky-crypto): keep picky-crypto-rsa-crt unpublished
CBenoit c1de080
build(picky-crypto): use the Devolutions author for picky-crypto-rsa-crt
CBenoit 8ca8d68
test(picky-crypto): read RSA CRT keys from the Wycheproof submodule
CBenoit 4685132
test(picky-crypto): prune redundant RSA CRT tests
CBenoit f728bd3
test(picky-crypto): isolate each RSA CRT consistency check
CBenoit d8db9e5
test(picky-crypto): control structural RSA CRT keys and check padding
CBenoit bf30780
test(picky-crypto): check the swapped-prime CRT coefficient
CBenoit f3387b0
test(picky-crypto): follow the testsuite vectors module path
CBenoit 5b29254
chore(picky-crypto): align picky-crypto-rsa-crt intent and drop its f…
CBenoit File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| [package] | ||
| name = "picky-crypto-rsa-crt" | ||
| version = "0.1.0" | ||
| edition = "2024" | ||
| rust-version = "1.85" | ||
| authors = ["Devolutions Inc. <infos@devolutions.net>"] | ||
| keywords = ["rsa", "cryptography", "pkcs8"] | ||
| categories = ["cryptography", "no-std"] | ||
| description = "Complete CRT parameters for two-prime RSA private keys" | ||
| license = "MIT OR Apache-2.0" | ||
| repository = "https://github.com/Devolutions/picky-rs" | ||
| readme = "README.md" | ||
| publish = false | ||
|
|
||
| [dependencies] | ||
| crypto-bigint = { version = "0.7.5", default-features = false, features = ["alloc", "zeroize"] } | ||
| zeroize = { version = "1.8", default-features = false, features = ["alloc"] } | ||
|
|
||
| [dev-dependencies] | ||
| picky-crypto-testsuite = { path = "../picky-crypto-testsuite" } | ||
| rstest = "0.26" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| # picky-crypto-rsa-crt | ||
|
|
||
| ## Purpose | ||
|
|
||
| Completes RSA private keys given as components into a full key, so that format code (SSH, PuTTY) and picky's `PrivateKey::from_rsa_components` can produce a PKCS#8 private key, the contract's single private-key encoding. | ||
|
|
||
| Input: `n`, `e`, `d`, `p`, `q`. | ||
| Output: `dP`, `dQ`, `qInv`. | ||
|
|
||
| This is key-format completion, not a standardized algorithm, so it is not a `picky-crypto` operation. | ||
| It lives in its own crate so that the arithmetic stays out of format crates, and so that FIPS binaries can exclude it from their dependency graph. | ||
|
|
||
| ## Invariants | ||
|
|
||
| - It depends only on `crypto-bigint` and `zeroize`. | ||
| It does not depend on the contract crate. | ||
| - It is a thin layer: `crypto-bigint` performs the arithmetic in constant time. | ||
| - No variable-time operation on secret values (`d`, `p`, `q` and the computed CRT values); in particular no `*_vartime` conversions on them. | ||
| - Intermediate secret values this crate owns are zeroized; scratch state inside `crypto-bigint` is wiped only as far as that library does. | ||
| - It checks that the inputs are consistent (`p × q = n`, and the other checks its README lists), and returns an error on mismatch, never a panic. | ||
| - It only completes CRT parameters. | ||
| It never factors `n`, generates keys or performs RSA operations. | ||
| - It is not FIPS-capable. | ||
| FIPS binaries never depend on it, and the FIPS dependency-ban fixture bans `crypto-bigint`. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| # picky-crypto-rsa-crt | ||
|
|
||
| Complete the CRT parameters `dP`, `dQ`, and `qInv` from `n`, `e`, `d`, `p`, and `q` for a two-prime RSA private key. | ||
| This `no_std` crate requires an allocator and uses the [PKCS #1 definitions]: `dP = d mod (p − 1)`, `dQ = d mod (q − 1)`, and `qInv = q⁻¹ mod p`. | ||
| It does not factor `n`, generate keys, or perform RSA operations, and is not FIPS-capable. | ||
|
|
||
| ## Encoding and checks | ||
|
|
||
| Inputs are unsigned big-endian integers and may include leading zero bytes. | ||
| Outputs are unsigned big-endian integers padded with zeros to the input encoding lengths: `dP` and `qInv` have `p.len()` bytes, and `dQ` has `q.len()` bytes. | ||
|
|
||
| `complete_crt_params` returns `Error::InvalidLength` when `n` is empty, its value exceeds 16384 bits, its encoding exceeds 2049 bytes, or any other input is longer than `n`. | ||
| At 2049 bytes, `n` must start with a zero sign byte. | ||
| The limit bounds allocation and arithmetic work. | ||
| It returns `Error::InconsistentKey` unless all of these checks hold: | ||
|
|
||
| - `p` and `q` are odd and at least 3. | ||
| - `p × q = n`, without overflow. | ||
| - `q` is invertible modulo `p`. | ||
| - `e × dP ≡ 1 (mod p − 1)` and `e × dQ ≡ 1 (mod q − 1)`. | ||
|
|
||
| These are consistency checks, not primality tests or a complete RSA key validation policy. | ||
| The exponent checks accept private exponents derived modulo either φ(n) or λ(n). | ||
|
|
||
| ## Secret handling | ||
|
|
||
| Arithmetic uses constant-time `crypto-bigint` operations with a shared precision determined by `n.len()`. | ||
| `n`, `e`, and all input lengths are treated as public. | ||
| Consistency checks are aggregated before a single error branch, and outputs are not trimmed according to their values. | ||
| Owned secret intermediates and outputs are zeroized on drop, and `CrtParams` debug output is redacted. | ||
| Callers remain responsible for clearing input buffers. | ||
|
|
||
| ### Limitations | ||
|
|
||
| Only intermediate secret values this crate owns are zeroized. | ||
| Scratch state inside `crypto-bigint`, including safegcd inversion state, multiplication scratch space, and stack copies used by division and encoding, is wiped only as far as that library does. | ||
|
|
||
| [PKCS #1 definitions]: https://www.rfc-editor.org/rfc/rfc8017#section-3.2 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| #![no_std] | ||
| #![forbid(unsafe_code)] | ||
| #![doc = include_str!("../README.md")] | ||
|
|
||
| extern crate alloc; | ||
|
|
||
| use alloc::boxed::Box; | ||
| use core::fmt; | ||
| use crypto_bigint::{BoxedUint, ConcatenatingMul, CtEq, NonZero, Odd}; | ||
| use zeroize::{ZeroizeOnDrop, Zeroizing}; | ||
|
|
||
| /// Maximum modulus encoding length in bytes, including an optional leading zero sign byte. | ||
| pub const MAX_MODULUS_LEN: usize = 2049; | ||
|
|
||
| /// Zeroizing, unsigned big-endian CRT parameters for a two-prime RSA private key. | ||
| pub struct CrtParams { | ||
| dp: Zeroizing<Box<[u8]>>, | ||
| dq: Zeroizing<Box<[u8]>>, | ||
| qinv: Zeroizing<Box<[u8]>>, | ||
| } | ||
|
|
||
| impl CrtParams { | ||
| /// Return `d mod (p − 1)`, padded to the input `p` encoding length. | ||
| pub fn dp(&self) -> &[u8] { | ||
| &self.dp | ||
| } | ||
|
|
||
| /// Return `d mod (q − 1)`, padded to the input `q` encoding length. | ||
| pub fn dq(&self) -> &[u8] { | ||
| &self.dq | ||
| } | ||
|
|
||
| /// Return `q⁻¹ mod p`, padded to the input `p` encoding length. | ||
| pub fn qinv(&self) -> &[u8] { | ||
| &self.qinv | ||
| } | ||
| } | ||
|
|
||
| impl ZeroizeOnDrop for CrtParams {} | ||
|
|
||
| impl fmt::Debug for CrtParams { | ||
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| f.write_str("CrtParams { [REDACTED] }") | ||
| } | ||
| } | ||
|
|
||
| /// An invalid input length or inconsistent RSA key components. | ||
| #[derive(Debug, Clone, Copy, PartialEq, Eq)] | ||
| #[non_exhaustive] | ||
| pub enum Error { | ||
| /// The modulus is empty, exceeds 16384 bits or 2049 bytes, or another input is longer than the modulus. | ||
| InvalidLength, | ||
| /// The components fail one or more key consistency checks. | ||
| InconsistentKey, | ||
| } | ||
|
|
||
| impl fmt::Display for Error { | ||
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| f.write_str(match self { | ||
| Self::InvalidLength => "invalid RSA component encoding length", | ||
| Self::InconsistentKey => "inconsistent RSA key components", | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| impl core::error::Error for Error {} | ||
|
|
||
| /// Complete CRT parameters from unsigned big-endian RSA key components. | ||
| /// | ||
| /// Leading zero bytes are accepted. | ||
| /// The length and consistency checks are described in the crate documentation. | ||
| pub fn complete_crt_params(n: &[u8], e: &[u8], d: &[u8], p: &[u8], q: &[u8]) -> Result<CrtParams, Error> { | ||
| let (p_len, q_len) = (p.len(), q.len()); | ||
| if n.is_empty() | ||
| || n.len() > MAX_MODULUS_LEN | ||
| || (n.len() == MAX_MODULUS_LEN && n.first() != Some(&0)) | ||
|
CBenoit marked this conversation as resolved.
Resolved
|
||
| || [e, d, p, q].iter().any(|v| v.len() > n.len()) | ||
| { | ||
| return Err(Error::InvalidLength); | ||
| } | ||
|
|
||
| // The public length bound makes the precision conversion and all allocations bounded. | ||
| let precision = (n.len() * 8) as u32; | ||
| let n = BoxedUint::from_be_slice(n, precision).map_err(|_| Error::InvalidLength)?; | ||
| let e = BoxedUint::from_be_slice(e, precision).map_err(|_| Error::InvalidLength)?; | ||
| let d = Zeroizing::new(BoxedUint::from_be_slice(d, precision).map_err(|_| Error::InvalidLength)?); | ||
| let p = Zeroizing::new(BoxedUint::from_be_slice(p, precision).map_err(|_| Error::InvalidLength)?); | ||
| let q = Zeroizing::new(BoxedUint::from_be_slice(q, precision).map_err(|_| Error::InvalidLength)?); | ||
| let one = BoxedUint::one_with_precision(precision); | ||
|
|
||
| // Odd and NonZero substitute one for invalid inputs in constant time. | ||
| // Keep their validity bits while borrowing their safe placeholders without branching. | ||
| let odd_p = Odd::new((*p).clone()).map(Zeroizing::new); | ||
| let odd_q = Odd::new((*q).clone()).map(Zeroizing::new); | ||
| let p_minus_one = NonZero::new(p.wrapping_sub(&one)).map(Zeroizing::new); | ||
| let q_minus_one = NonZero::new(q.wrapping_sub(&one)).map(Zeroizing::new); | ||
| let mut valid = odd_p.is_some() & odd_q.is_some() & p_minus_one.is_some() & q_minus_one.is_some(); | ||
|
|
||
| let product = p.checked_mul(&*q).map(Zeroizing::new); | ||
| valid &= product.is_some() & product.as_inner_unchecked().ct_eq(&n); | ||
|
|
||
| let (quotient, dp) = d.div_rem(&**p_minus_one.as_inner_unchecked()); | ||
| let (_quotient, dp) = (Zeroizing::new(quotient), Zeroizing::new(dp)); | ||
| let (quotient, dq) = d.div_rem(&**q_minus_one.as_inner_unchecked()); | ||
| let (_quotient, dq) = (Zeroizing::new(quotient), Zeroizing::new(dq)); | ||
|
|
||
| let p_nonzero = Zeroizing::new((**odd_p.as_inner_unchecked()).clone().into_nz()); | ||
| let (quotient, reduced_q) = q.div_rem(&*p_nonzero); | ||
| let (_quotient, reduced_q) = (Zeroizing::new(quotient), Zeroizing::new(reduced_q)); | ||
| let qinv = reduced_q.invert_odd_mod(odd_p.as_inner_unchecked()).map(Zeroizing::new); | ||
| valid &= qinv.is_some(); | ||
|
|
||
| // Widen the exponent products to 2W; mixed-precision division returns a W-bit remainder. | ||
| let edp = Zeroizing::new(e.concatenating_mul(&*dp)); | ||
| let edq = Zeroizing::new(e.concatenating_mul(&*dq)); | ||
| let (quotient, remainder) = edp.div_rem(&**p_minus_one.as_inner_unchecked()); | ||
| let (_quotient, remainder) = (Zeroizing::new(quotient), Zeroizing::new(remainder)); | ||
| valid &= remainder.ct_eq(&one); | ||
| let (quotient, remainder) = edq.div_rem(&**q_minus_one.as_inner_unchecked()); | ||
| let (_quotient, remainder) = (Zeroizing::new(quotient), Zeroizing::new(remainder)); | ||
| valid &= remainder.ct_eq(&one); | ||
|
|
||
| if !valid.to_bool() { | ||
| return Err(Error::InconsistentKey); | ||
| } | ||
|
|
||
| Ok(CrtParams { | ||
| dp: encode(&dp, p_len)?, | ||
| dq: encode(&dq, q_len)?, | ||
| qinv: encode(qinv.as_inner_unchecked(), p_len)?, | ||
| }) | ||
| } | ||
|
|
||
| fn encode(value: &BoxedUint, len: usize) -> Result<Zeroizing<Box<[u8]>>, Error> { | ||
| let bytes = Zeroizing::new(value.to_be_bytes()); | ||
| // Precision is at least n.len() bytes, and output lengths cannot exceed n.len(). | ||
| let start = bytes.len().checked_sub(len).ok_or(Error::InvalidLength)?; | ||
| let bytes = bytes.get(start..).ok_or(Error::InvalidLength)?; | ||
| Ok(Zeroizing::new(bytes.into())) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.