Skip to content
Merged
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
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
18 changes: 18 additions & 0 deletions wacore/libsignal/src/protocol/session_cipher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -758,6 +758,18 @@ async fn message_decrypt_signal_inner<R: Rng + CryptoRng>(
Ok((decrypt.commit(), identity_change))
}

/// A refused agreement is a local failure, not a verdict about the message, so
/// it outranks the MAC-based classifications: the caller has to be able to tell
/// "this backend is down" from "this message is corrupt". Kept in the pile
/// rather than returned on the spot, since a sibling session with an open
/// receiver chain needs no agreement and may still read the message.
fn take_key_agreement_failure(errs: &mut Vec<SignalProtocolError>) -> Option<SignalProtocolError> {
let at = errs
.iter()
.position(|e| matches!(e, SignalProtocolError::KeyAgreementFailed(_)))?;
Some(errs.remove(at))
}

fn create_decryption_failure_log(
remote_address: &ProtocolAddress,
mut errs: &[SignalProtocolError],
Expand Down Expand Up @@ -1118,6 +1130,9 @@ fn decrypt_message_with_record<'a, R: Rng + CryptoRng>(
ciphertext.signal_message()
)?
);
if let Some(refused) = take_key_agreement_failure(&mut errs) {
return Err(refused);
}
// Preserve BadMac so it maps to WA Web error code 7 in retry receipts.
if errs
.iter()
Expand Down Expand Up @@ -1249,6 +1264,9 @@ fn decrypt_message_with_record<'a, R: Rng + CryptoRng>(
}) {
return Err(SignalProtocolError::DuplicatedMessage(chain, counter));
}
if let Some(refused) = take_key_agreement_failure(&mut errs) {
return Err(refused);
}
// Otherwise, if any session state produced a BadMac error, propagate it rather
// than the generic InvalidMessage. BadMac means at least one state derived a
// message key and verified the MAC — it specifically failed, which maps to WA
Expand Down
Loading
Loading