rpc: Add support for OAEP-based encryption format (#15058)

This PR adds support for a new encryption format for exchanging access
tokens during the authentication flow.

The new format uses Optimal Asymmetric Encryption Padding (OAEP) instead
of PKCS#1 v1.5, which is known to be vulnerable to side-channel attacks.

**Note: We are not yet encrypting access tokens using the new format, as
this is a breaking change between the client and the server. This PR
only adds support for it, and makes it so the client and server can
decrypt either format moving forward.**

This required bumping the RSA key size from 1024 bits to 2048 bits. This
is necessary to be able to encode the access token into the ciphertext
when using OAEP.

This also follows OWASP recommendations:

> If ECC is not available and RSA must be used, then ensure that the key
is at least 2048 bits.
>
> —
[source](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html#algorithms)

Release Notes:

- N/A
This commit is contained in:
Marshall Bowers 2024-07-23 21:25:25 -04:00 committed by GitHub
parent edf7f6defe
commit c84da37030
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 79 additions and 13 deletions

1
Cargo.lock generated
View File

@ -8886,6 +8886,7 @@ dependencies = [
"rsa", "rsa",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.7",
"strum", "strum",
"tracing", "tracing",
"util", "util",

View File

@ -164,10 +164,21 @@ pub fn hash_access_token(token: &str) -> String {
/// Encrypts the given access token with the given public key to avoid leaking it on the way /// Encrypts the given access token with the given public key to avoid leaking it on the way
/// to the client. /// to the client.
pub fn encrypt_access_token(access_token: &str, public_key: String) -> Result<String> { pub fn encrypt_access_token(access_token: &str, public_key: String) -> Result<String> {
use rpc::auth::EncryptionFormat;
/// The encryption format to use for the access token.
///
/// Currently we're using the original encryption format to avoid
/// breaking compatibility with older clients.
///
/// Once enough clients are capable of decrypting the newer encryption
/// format we can start encrypting with `EncryptionFormat::V1`.
const ENCRYPTION_FORMAT: EncryptionFormat = EncryptionFormat::V0;
let native_app_public_key = let native_app_public_key =
rpc::auth::PublicKey::try_from(public_key).context("failed to parse app public key")?; rpc::auth::PublicKey::try_from(public_key).context("failed to parse app public key")?;
let encrypted_access_token = native_app_public_key let encrypted_access_token = native_app_public_key
.encrypt_string(access_token) .encrypt_string(access_token, ENCRYPTION_FORMAT)
.context("failed to encrypt access token with public key")?; .context("failed to encrypt access token with public key")?;
Ok(encrypted_access_token) Ok(encrypted_access_token)
} }

View File

@ -30,6 +30,7 @@ rand.workspace = true
rsa.workspace = true rsa.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
sha2.workspace = true
strum.workspace = true strum.workspace = true
tracing = { version = "0.1.34", features = ["log"] } tracing = { version = "0.1.34", features = ["log"] }
util.workspace = true util.workspace = true

View File

@ -1,9 +1,29 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use rand::{thread_rng, Rng as _}; use rand::{thread_rng, Rng as _};
use rsa::pkcs1::{DecodeRsaPublicKey, EncodeRsaPublicKey}; use rsa::pkcs1::{DecodeRsaPublicKey, EncodeRsaPublicKey};
use rsa::{Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey}; use rsa::traits::PaddingScheme;
use rsa::{Oaep, Pkcs1v15Encrypt, RsaPrivateKey, RsaPublicKey};
use sha2::Sha256;
use std::convert::TryFrom; use std::convert::TryFrom;
fn oaep_sha256_padding() -> impl PaddingScheme {
Oaep::new::<Sha256>()
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum EncryptionFormat {
/// The original encryption format.
///
/// This is using [`Pkcs1v15Encrypt`], which is vulnerable to side-channel attacks.
/// As such, we're in the process of phasing it out.
///
/// See [here](https://people.redhat.com/~hkario/marvin/) for more details.
V0,
/// The new encryption key format using Optimal Asymmetric Encryption Padding (OAEP) with a SHA-256 digest.
V1,
}
pub struct PublicKey(RsaPublicKey); pub struct PublicKey(RsaPublicKey);
pub struct PrivateKey(RsaPrivateKey); pub struct PrivateKey(RsaPrivateKey);
@ -11,7 +31,7 @@ pub struct PrivateKey(RsaPrivateKey);
/// Generate a public and private key for asymmetric encryption. /// Generate a public and private key for asymmetric encryption.
pub fn keypair() -> Result<(PublicKey, PrivateKey)> { pub fn keypair() -> Result<(PublicKey, PrivateKey)> {
let mut rng = thread_rng(); let mut rng = thread_rng();
let bits = 1024; let bits = 2048;
let private_key = RsaPrivateKey::new(&mut rng, bits)?; let private_key = RsaPrivateKey::new(&mut rng, bits)?;
let public_key = RsaPublicKey::from(&private_key); let public_key = RsaPublicKey::from(&private_key);
Ok((PublicKey(public_key), PrivateKey(private_key))) Ok((PublicKey(public_key), PrivateKey(private_key)))
@ -30,13 +50,14 @@ pub fn random_token() -> String {
impl PublicKey { impl PublicKey {
/// Convert a string to a base64-encoded string that can only be decoded with the corresponding /// Convert a string to a base64-encoded string that can only be decoded with the corresponding
/// private key. /// private key.
pub fn encrypt_string(&self, string: &str) -> Result<String> { pub fn encrypt_string(&self, string: &str, format: EncryptionFormat) -> Result<String> {
let mut rng = thread_rng(); let mut rng = thread_rng();
let bytes = string.as_bytes(); let bytes = string.as_bytes();
let encrypted_bytes = self let encrypted_bytes = match format {
.0 EncryptionFormat::V0 => self.0.encrypt(&mut rng, Pkcs1v15Encrypt, bytes),
.encrypt(&mut rng, PADDING_SCHEME, bytes) EncryptionFormat::V1 => self.0.encrypt(&mut rng, oaep_sha256_padding(), bytes),
.context("failed to encrypt string with public key")?; }
.context("failed to encrypt string with public key")?;
let encrypted_string = base64::encode_config(&encrypted_bytes, base64::URL_SAFE); let encrypted_string = base64::encode_config(&encrypted_bytes, base64::URL_SAFE);
Ok(encrypted_string) Ok(encrypted_string)
} }
@ -49,7 +70,12 @@ impl PrivateKey {
.context("failed to base64-decode encrypted string")?; .context("failed to base64-decode encrypted string")?;
let bytes = self let bytes = self
.0 .0
.decrypt(PADDING_SCHEME, &encrypted_bytes) .decrypt(oaep_sha256_padding(), &encrypted_bytes)
.or_else(|_err| {
// If we failed to decrypt using the new format, try decrypting with the old
// one to handle mismatches between the client and server.
self.0.decrypt(Pkcs1v15Encrypt, &encrypted_bytes)
})
.context("failed to decrypt string with private key")?; .context("failed to decrypt string with private key")?;
let string = String::from_utf8(bytes).context("decrypted content was not valid utf8")?; let string = String::from_utf8(bytes).context("decrypted content was not valid utf8")?;
Ok(string) Ok(string)
@ -78,8 +104,6 @@ impl TryFrom<String> for PublicKey {
} }
} }
const PADDING_SCHEME: Pkcs1v15Encrypt = Pkcs1v15Encrypt;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -99,7 +123,34 @@ mod tests {
// * encrypt the token using the public key. // * encrypt the token using the public key.
let public = PublicKey::try_from(public_string).unwrap(); let public = PublicKey::try_from(public_string).unwrap();
let token = random_token(); let token = random_token();
let encrypted_token = public.encrypt_string(&token).unwrap(); let encrypted_token = public.encrypt_string(&token, EncryptionFormat::V1).unwrap();
assert_eq!(token.len(), 64);
assert_ne!(encrypted_token, token);
assert_printable(&token);
assert_printable(&encrypted_token);
// CLIENT:
// * decrypt the token using the private key.
let decrypted_token = private.decrypt_string(&encrypted_token).unwrap();
assert_eq!(decrypted_token, token);
}
#[test]
fn test_generate_encrypt_and_decrypt_token_with_v0_encryption_format() {
// CLIENT:
// * generate a keypair for asymmetric encryption
// * serialize the public key to send it to the server.
let (public, private) = keypair().unwrap();
let public_string = String::try_from(public).unwrap();
assert_printable(&public_string);
// SERVER:
// * parse the public key
// * generate a random token.
// * encrypt the token using the public key.
let public = PublicKey::try_from(public_string).unwrap();
let token = random_token();
let encrypted_token = public.encrypt_string(&token, EncryptionFormat::V0).unwrap();
assert_eq!(token.len(), 64); assert_eq!(token.len(), 64);
assert_ne!(encrypted_token, token); assert_ne!(encrypted_token, token);
assert_printable(&token); assert_printable(&token);
@ -130,7 +181,9 @@ mod tests {
for _ in 0..5 { for _ in 0..5 {
let token = random_token(); let token = random_token();
let (public_key, _) = keypair().unwrap(); let (public_key, _) = keypair().unwrap();
let encrypted_token = public_key.encrypt_string(&token).unwrap(); let encrypted_token = public_key
.encrypt_string(&token, EncryptionFormat::V1)
.unwrap();
let public_key_str = String::try_from(public_key).unwrap(); let public_key_str = String::try_from(public_key).unwrap();
assert_printable(&token); assert_printable(&token);