Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ jobs:
strategy:
fail-fast: false
matrix:
crate: [ picky-asn1, picky-asn1-der, picky-asn1-x509, picky, picky-crypto ]
crate: [ picky-asn1, picky-asn1-der, picky-asn1-x509, picky, picky-crypto, picky-crypto-rsa-crt ]
include:
- crate: picky-asn1
msvc: "1.85"
Expand All @@ -115,6 +115,8 @@ jobs:
msvc: "1.85"
- crate: picky-crypto
msvc: "1.85"
- crate: picky-crypto-rsa-crt
msvc: "1.85"

steps:
- uses: actions/checkout@v6
Expand Down
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ members = [
"picky-asn1",
"picky-asn1-der",
"picky-asn1-x509",
"picky-crypto-rsa-crt",
"picky-test-data",
"ffi",
]
Expand All @@ -22,6 +23,7 @@ default-members = [
"picky-asn1",
"picky-asn1-der",
"picky-asn1-x509",
"picky-crypto-rsa-crt",
]

[profile.ffi-production]
Expand Down
21 changes: 21 additions & 0 deletions picky-crypto-rsa-crt/Cargo.toml
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"
24 changes: 24 additions & 0 deletions picky-crypto-rsa-crt/INTENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# picky-crypto-rsa-crt
Comment thread
CBenoit marked this conversation as resolved.
Resolved

## 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`.
38 changes: 38 additions & 0 deletions picky-crypto-rsa-crt/README.md
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
140 changes: 140 additions & 0 deletions picky-crypto-rsa-crt/src/lib.rs
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))
Comment thread
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()))
}
Loading
Loading