Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
91 changes: 88 additions & 3 deletions wacore/libsignal/src/core/curve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ pub enum CurveError {
BadKeyType(u8),
#[error("bad key length <{1}> for key with type <{0}>")]
BadKeyLength(KeyType, usize),
/// Only a substituted agreement can produce this: the default never fails.
#[error("the active crypto provider failed the key agreement")]
AgreementFailed(#[from] crate::crypto::CryptoProviderError),
}

impl TryFrom<u8> for KeyType {
Expand Down Expand Up @@ -516,15 +519,28 @@ impl PrivateKey {

pub fn calculate_agreement(&self, their_key: &PublicKey) -> Result<[u8; 32], CurveError> {
match (&self.key, their_key.key) {
// The single place the agreement is decided, so a consumer that
// supplies the primitive replaces it for every caller at once.
(PrivateKeyData::DjbPrivateKey { key, .. }, PublicKeyData::DjbPublicKey(pub_key)) => {
// Use from_bytes_without_cache since agreement doesn't need the Edwards cache
let private_key = curve25519::PrivateKey::from_bytes_without_cache(*key);
Ok(private_key.calculate_agreement(&pub_key))
Ok(crate::crypto::x25519_agreement(key, &pub_key)?)
Comment thread
jlucaso1 marked this conversation as resolved.
}
}
}
}

/// This crate's own X25519 agreement, and the only copy of that path: it is
/// what [`SignalCryptoProvider::x25519_agreement`] runs by default.
///
/// [`SignalCryptoProvider::x25519_agreement`]: crate::crypto::SignalCryptoProvider::x25519_agreement
pub(crate) fn x25519_agreement(
private_key: &[u8; curve25519::PRIVATE_KEY_LENGTH],
their_public_key: &[u8; curve25519::PUBLIC_KEY_LENGTH],
) -> [u8; 32] {
// from_bytes_without_cache because agreement never reads the Edwards cache.
curve25519::PrivateKey::from_bytes_without_cache(*private_key)
.calculate_agreement(their_public_key)
}

impl TryFrom<&[u8]> for PrivateKey {
type Error = CurveError;

Expand Down Expand Up @@ -799,6 +815,75 @@ mod tests {
}
}

fn key_bytes(hex_key: &str) -> [u8; 32] {
let mut out = [0u8; 32];
hex::decode_to_slice(hex_key, &mut out).expect("32 hex-encoded bytes");
out
}

/// RFC 7748 section 6.1. With no provider installed the agreement runs the
/// library's own path, so this pins the bytes that routing must preserve.
#[test]
fn rfc7748_agreement_vector() {
let alice_private = PrivateKey::deserialize(&key_bytes(
"77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a",
))
.expect("alice private key");
let bob_private = PrivateKey::deserialize(&key_bytes(
"5dab087e624a8a4b79e17f8b83800ee66f3bb1292618b6fd1c2f8b27ff88e0eb",
))
.expect("bob private key");
let alice_public = PublicKey::from_djb_public_key_bytes(&key_bytes(
"8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a",
))
.expect("alice public key");
let bob_public = PublicKey::from_djb_public_key_bytes(&key_bytes(
"de9edb7d7b7dc1b4d35b61c2ece435373f8343c85b78674dadfc7e146f882b4f",
))
.expect("bob public key");
let expected =
key_bytes("4a5d9d5ba4ce2de1728e3bf480350f25e07e21c947d19e3376f09b3c1e161742");

assert_eq!(alice_private.public_key().expect("derive"), alice_public);
assert_eq!(bob_private.public_key().expect("derive"), bob_public);
assert_eq!(
alice_private.calculate_agreement(&bob_public).expect("dh"),
expected
);
assert_eq!(
bob_private.calculate_agreement(&alice_public).expect("dh"),
expected
);
}

/// A key of the wrong length never reaches the agreement at all.
#[test]
fn agreement_rejects_wrong_length_keys() {
assert!(matches!(
PrivateKey::deserialize(&[0x42; 31]),
Err(CurveError::BadKeyLength(KeyType::Djb, 31))
));
assert!(matches!(
PublicKey::from_djb_public_key_bytes(&[0x42; 33]),
Err(CurveError::BadKeyLength(KeyType::Djb, 33))
));
}

/// Signing state is expensive and the agreement has no use for it; routing
/// must not start building it behind our back.
#[test]
fn agreement_leaves_the_signing_cache_cold() {
let mut csprng = rng();
let alice = KeyPair::generate(&mut csprng);
let bob = KeyPair::generate(&mut csprng);

alice
.calculate_agreement(&bob.public_key)
.expect("agreement");

assert!(!alice.private_key.has_warm_signing_cache());
}

#[test]
fn test_key_agreement_works_without_signing() {
let mut csprng = rng();
Expand Down
11 changes: 11 additions & 0 deletions wacore/libsignal/src/crypto/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ pub fn hmac_sha256_two_part(key: &[u8], first: &[u8], second: &[u8]) -> [u8; 32]
provider::provider().hmac_sha256_two_part(key, first, second)
}

/// X25519 key agreement over raw 32-byte keys. Delegates to the active
/// [`SignalCryptoProvider`], whose default is this crate's own implementation
/// and never fails; only a substituted backend can return an error.
#[inline]
pub fn x25519_agreement(
private_key: &[u8; 32],
their_public_key: &[u8; 32],
) -> std::result::Result<[u8; 32], CryptoProviderError> {
provider::provider().x25519_agreement(private_key, their_public_key)
}

/// In-place AES-256-GCM seal. On entry `buffer` holds the plaintext; on return
/// it holds `ciphertext || tag` (length grown by 16). Zero allocations with
/// the default [`RustCryptoProvider`].
Expand Down
121 changes: 119 additions & 2 deletions wacore/libsignal/src/crypto/provider.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
//! Pluggable Signal crypto provider.
//!
//! Default uses RustCrypto (soft). Override via [`set_crypto_provider`] to
//! delegate hot-path primitives to a faster backend (e.g. `node:crypto`
//! over a WASM bridge). Must be set before any crypto call.
//! delegate the primitives to another backend: AES-256-CBC, AES-256-GCM,
//! HMAC-SHA256, the transport AEAD and the X25519 agreement. Must be set
//! before any crypto call, key agreement included.

use std::sync::OnceLock;

Expand Down Expand Up @@ -220,6 +221,26 @@ pub trait SignalCryptoProvider: Send + Sync + 'static {
self.hmac_sha256(key, &input)
}

/// X25519 key agreement over the raw 32-byte keys as this crate stores
/// them; an implementation clamps the private key itself, as X25519
/// requires (clamping is idempotent, so repeating it is safe). Overriding
/// this replaces the primitive for every agreement the crate performs.
///
/// A backend that can fail reports it here instead of panicking or
/// answering with fabricated bytes, which would let a session advance on
/// key material nobody agreed to. The default is this crate's own
/// implementation: it cannot fail, and keeps today's result byte for byte.
fn x25519_agreement(
&self,
private_key: &[u8; 32],
their_public_key: &[u8; 32],
) -> Result<[u8; 32], CryptoProviderError> {
Ok(crate::core::curve::x25519_agreement(
private_key,
their_public_key,
))
}

/// In-place AES-256-GCM seal. On entry `buffer` holds the plaintext; on
/// return it holds `ciphertext || tag` (length grown by 16).
///
Expand Down Expand Up @@ -574,6 +595,102 @@ mod tests {
);
}

/// A provider written before the agreement joined the trait: it implements
/// only the required methods and must keep agreeing keys correctly.
struct SymmetricOnlyProvider;

impl SignalCryptoProvider for SymmetricOnlyProvider {
fn aes_256_cbc_encrypt(
&self,
key: &[u8; 32],
iv: &[u8; 16],
plaintext: &[u8],
out: &mut Vec<u8>,
) -> Result<(), CryptoProviderError> {
RustCryptoProvider.aes_256_cbc_encrypt(key, iv, plaintext, out)
}

fn aes_256_cbc_decrypt(
&self,
key: &[u8; 32],
iv: &[u8; 16],
ciphertext: &[u8],
out: &mut Vec<u8>,
) -> Result<(), CryptoProviderError> {
RustCryptoProvider.aes_256_cbc_decrypt(key, iv, ciphertext, out)
}

fn aes_256_gcm_encrypt(
&self,
key: &[u8; 32],
nonce: &[u8; 12],
aad: &[u8],
plaintext: &[u8],
out: &mut Vec<u8>,
) -> Result<(), CryptoProviderError> {
RustCryptoProvider.aes_256_gcm_encrypt(key, nonce, aad, plaintext, out)
}

fn aes_256_gcm_decrypt(
&self,
key: &[u8; 32],
nonce: &[u8; 12],
aad: &[u8],
ciphertext_with_tag: &[u8],
out: &mut Vec<u8>,
) -> Result<(), CryptoProviderError> {
RustCryptoProvider.aes_256_gcm_decrypt(key, nonce, aad, ciphertext_with_tag, out)
}

fn hmac_sha256(&self, key: &[u8], input: &[u8]) -> [u8; 32] {
RustCryptoProvider.hmac_sha256(key, input)
}
}

/// RFC 7748 section 6.1, reached through the trait default.
#[test]
fn provider_without_agreement_override_keeps_the_pure_path() {
let alice_private = [
0x77, 0x07, 0x6d, 0x0a, 0x73, 0x18, 0xa5, 0x7d, 0x3c, 0x16, 0xc1, 0x72, 0x51, 0xb2,
0x66, 0x45, 0xdf, 0x4c, 0x2f, 0x87, 0xeb, 0xc0, 0x99, 0x2a, 0xb1, 0x77, 0xfb, 0xa5,
0x1d, 0xb9, 0x2c, 0x2a,
];
let bob_public = [
0xde, 0x9e, 0xdb, 0x7d, 0x7b, 0x7d, 0xc1, 0xb4, 0xd3, 0x5b, 0x61, 0xc2, 0xec, 0xe4,
0x35, 0x37, 0x3f, 0x83, 0x43, 0xc8, 0x5b, 0x78, 0x67, 0x4d, 0xad, 0xfc, 0x7e, 0x14,
0x6f, 0x88, 0x2b, 0x4f,
];
let expected = [
0x4a, 0x5d, 0x9d, 0x5b, 0xa4, 0xce, 0x2d, 0xe1, 0x72, 0x8e, 0x3b, 0xf4, 0x80, 0x35,
0x0f, 0x25, 0xe0, 0x7e, 0x21, 0xc9, 0x47, 0xd1, 0x9e, 0x33, 0x76, 0xf0, 0x9b, 0x3c,
0x1e, 0x16, 0x17, 0x42,
];

assert_eq!(
SymmetricOnlyProvider
.x25519_agreement(&alice_private, &bob_public)
.expect("the default cannot fail"),
expected
);
assert_eq!(
RustCryptoProvider
.x25519_agreement(&alice_private, &bob_public)
.expect("the default cannot fail"),
expected
);
}

/// A public key of low order agrees to the all-zero secret; the primitive
/// reports it as bytes rather than failing, and callers must not read that
/// as a usable secret.
#[test]
fn provider_agreement_with_low_order_point_is_all_zero() {
let agreement = RustCryptoProvider
.x25519_agreement(&[0x42; 32], &[0u8; 32])
.expect("the default cannot fail");
assert_eq!(agreement, [0u8; 32]);
}

/// NIST SP 800-38D Test Case 14: all-zero key/nonce, 128-bit plaintext.
#[test]
fn rust_provider_gcm_nist_tc14() {
Expand Down
5 changes: 5 additions & 0 deletions wacore/libsignal/src/protocol/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::{
ProtocolAddress,
curve::{CurveError, KeyType},
},
crypto::CryptoProviderError,
protocol::CiphertextMessageType,
};
use thiserror::Error;
Expand Down Expand Up @@ -56,6 +57,9 @@ pub enum SignalProtocolError {
#[error("invalid signature detected")]
SignatureValidationFailed,

#[error("the active crypto provider failed the key agreement")]
KeyAgreementFailed(#[source] CryptoProviderError),

#[error("untrusted identity for address {0}")]
UntrustedIdentity(ProtocolAddress),

Expand Down Expand Up @@ -108,6 +112,7 @@ impl From<CurveError> for SignalProtocolError {
CurveError::NoKeyTypeIdentifier => Self::NoKeyTypeIdentifier,
CurveError::BadKeyType(raw) => Self::BadKeyType(raw),
CurveError::BadKeyLength(key_type, len) => Self::BadKeyLength(key_type, len),
CurveError::AgreementFailed(source) => Self::KeyAgreementFailed(source),
}
}
}
Expand Down
12 changes: 12 additions & 0 deletions wacore/libsignal/src/protocol/session_cipher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1091,6 +1091,14 @@ fn decrypt_message_with_record<'a, R: Rng + CryptoRng>(
record.set_session_state(current_state);
return Err(error);
}
Err(e @ SignalProtocolError::KeyAgreementFailed(_)) => {
// A refused agreement says nothing about this message: every
// other session would ask the same backend and get the same
// answer, and collapsing it into the aggregate verdict below
// would have the caller treat a live message as corrupt.
record.set_session_state(current_state);
return Err(e);
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
Comment thread
jlucaso1 marked this conversation as resolved.
Outdated
}
Err(e) if !ciphertext.is_available() => {
// Authentication succeeded, but the provider rejected the
// caller-owned body after it had been consumed for in-place
Expand Down Expand Up @@ -1200,6 +1208,10 @@ fn decrypt_message_with_record<'a, R: Rng + CryptoRng>(
record.restore_previous_session(idx, previous);
return Err(error);
}
Err(e @ SignalProtocolError::KeyAgreementFailed(_)) => {
record.restore_previous_session(idx, previous);
return Err(e);
}
Err(e) if !ciphertext.is_available() => {
record.restore_previous_session(idx, previous);
return Err(e);
Expand Down
Loading