mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-02-10 17:03:12 +00:00
parent
71f7b8a485
commit
9d55933f00
3
Cargo.lock
generated
3
Cargo.lock
generated
@ -97,7 +97,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "chat-proto"
|
name = "chat-proto"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/logos-messaging/chat_proto#21cd52b0649c959f43eec19e1edad12451ccc382"
|
source = "git+https://github.com/logos-messaging/chat_proto#44d5360c41d721a011d20ee69a75a85357b33b0e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"prost",
|
"prost",
|
||||||
]
|
]
|
||||||
@ -503,6 +503,7 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
|||||||
name = "logos-chat"
|
name = "logos-chat"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
"blake2",
|
"blake2",
|
||||||
"chat-proto",
|
"chat-proto",
|
||||||
"crypto",
|
"crypto",
|
||||||
|
|||||||
@ -7,6 +7,7 @@ edition = "2024"
|
|||||||
crate-type = ["staticlib","dylib"]
|
crate-type = ["staticlib","dylib"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
base64ct = { version = "1.6", features = ["alloc"] }
|
||||||
blake2.workspace = true
|
blake2.workspace = true
|
||||||
chat-proto = { git = "https://github.com/logos-messaging/chat_proto" }
|
chat-proto = { git = "https://github.com/logos-messaging/chat_proto" }
|
||||||
crypto = { path = "../crypto" }
|
crypto = { path = "../crypto" }
|
||||||
|
|||||||
@ -17,6 +17,8 @@ use crate::inbox::handshake::InboxHandshake;
|
|||||||
use crate::proto;
|
use crate::proto;
|
||||||
use crate::types::{AddressedEncryptedPayload, ContentData};
|
use crate::types::{AddressedEncryptedPayload, ContentData};
|
||||||
|
|
||||||
|
use super::introduction::{sign_intro_binding, verify_intro_binding};
|
||||||
|
|
||||||
/// Compute the deterministic Delivery_address for an installation
|
/// Compute the deterministic Delivery_address for an installation
|
||||||
fn delivery_address_for_installation(_: PublicKey) -> String {
|
fn delivery_address_for_installation(_: PublicKey) -> String {
|
||||||
// TODO: Implement Delivery Address
|
// TODO: Implement Delivery Address
|
||||||
@ -59,10 +61,12 @@ impl Inbox {
|
|||||||
self.ephemeral_keys
|
self.ephemeral_keys
|
||||||
.insert(hex::encode(signed_prekey.as_bytes()), ephemeral);
|
.insert(hex::encode(signed_prekey.as_bytes()), ephemeral);
|
||||||
|
|
||||||
|
let signature = sign_intro_binding(self.ident.secret(), &signed_prekey, OsRng);
|
||||||
|
|
||||||
PrekeyBundle {
|
PrekeyBundle {
|
||||||
identity_key: self.ident.public_key(),
|
identity_key: self.ident.public_key(),
|
||||||
signed_prekey: signed_prekey,
|
signed_prekey,
|
||||||
signature: [0u8; 64],
|
signature,
|
||||||
onetime_prekey: None,
|
onetime_prekey: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -74,11 +78,17 @@ impl Inbox {
|
|||||||
) -> Result<(PrivateV1Convo, Vec<AddressedEncryptedPayload>), ChatError> {
|
) -> Result<(PrivateV1Convo, Vec<AddressedEncryptedPayload>), ChatError> {
|
||||||
let mut rng = OsRng;
|
let mut rng = OsRng;
|
||||||
|
|
||||||
// TODO: Include signature in introduction bundle. Manaully fill for now
|
verify_intro_binding(
|
||||||
|
&remote_bundle.installation_key,
|
||||||
|
&remote_bundle.ephemeral_key,
|
||||||
|
&remote_bundle.signature,
|
||||||
|
)
|
||||||
|
.map_err(|_| ChatError::BadBundleValue("invalid signature".into()))?;
|
||||||
|
|
||||||
let pkb = PrekeyBundle {
|
let pkb = PrekeyBundle {
|
||||||
identity_key: remote_bundle.installation_key,
|
identity_key: remote_bundle.installation_key,
|
||||||
signed_prekey: remote_bundle.ephemeral_key,
|
signed_prekey: remote_bundle.ephemeral_key,
|
||||||
signature: [0u8; 64],
|
signature: remote_bundle.signature,
|
||||||
onetime_prekey: None,
|
onetime_prekey: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,48 @@
|
|||||||
|
use base64ct::{Base64UrlUnpadded, Encoding};
|
||||||
|
use chat_proto::logoschat::intro::IntroBundle;
|
||||||
use crypto::PrekeyBundle;
|
use crypto::PrekeyBundle;
|
||||||
use x25519_dalek::PublicKey;
|
use prost::Message;
|
||||||
|
use rand_core::{CryptoRng, RngCore};
|
||||||
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
|
||||||
use crate::errors::ChatError;
|
use crate::errors::ChatError;
|
||||||
|
|
||||||
|
const BUNDLE_PREFIX: &str = "logos_chatintro_1_";
|
||||||
|
|
||||||
|
const INTRO_DOMAIN_SEPARATOR: &[u8] = b"logos_chatintro_v1_bind";
|
||||||
|
|
||||||
|
fn intro_binding_message(identity: &PublicKey, ephemeral: &PublicKey) -> Vec<u8> {
|
||||||
|
let mut message = Vec::with_capacity(INTRO_DOMAIN_SEPARATOR.len() + 64);
|
||||||
|
message.extend_from_slice(INTRO_DOMAIN_SEPARATOR);
|
||||||
|
message.extend_from_slice(identity.as_bytes());
|
||||||
|
message.extend_from_slice(ephemeral.as_bytes());
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sign_intro_binding<R: RngCore + CryptoRng>(
|
||||||
|
secret: &StaticSecret,
|
||||||
|
ephemeral: &PublicKey,
|
||||||
|
rng: R,
|
||||||
|
) -> [u8; 64] {
|
||||||
|
let identity = PublicKey::from(secret);
|
||||||
|
let message = intro_binding_message(&identity, ephemeral);
|
||||||
|
crypto::xeddsa_sign(secret, &message, rng)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn verify_intro_binding(
|
||||||
|
pubkey: &PublicKey,
|
||||||
|
ephemeral: &PublicKey,
|
||||||
|
signature: &[u8; 64],
|
||||||
|
) -> Result<(), crypto::SignatureError> {
|
||||||
|
let message = intro_binding_message(pubkey, ephemeral);
|
||||||
|
crypto::xeddsa_verify(pubkey, &message, signature)
|
||||||
|
}
|
||||||
|
|
||||||
/// Supplies remote participants with the required keys to use Inbox protocol
|
/// Supplies remote participants with the required keys to use Inbox protocol
|
||||||
pub struct Introduction {
|
pub struct Introduction {
|
||||||
pub installation_key: PublicKey,
|
pub installation_key: PublicKey,
|
||||||
pub ephemeral_key: PublicKey,
|
pub ephemeral_key: PublicKey,
|
||||||
|
pub signature: [u8; 64],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<PrekeyBundle> for Introduction {
|
impl From<PrekeyBundle> for Introduction {
|
||||||
@ -14,20 +50,28 @@ impl From<PrekeyBundle> for Introduction {
|
|||||||
Introduction {
|
Introduction {
|
||||||
installation_key: value.identity_key,
|
installation_key: value.identity_key,
|
||||||
ephemeral_key: value.signed_prekey,
|
ephemeral_key: value.signed_prekey,
|
||||||
|
signature: value.signature,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Into<Vec<u8>> for Introduction {
|
impl From<Introduction> for Vec<u8> {
|
||||||
fn into(self) -> Vec<u8> {
|
fn from(intro: Introduction) -> Vec<u8> {
|
||||||
// TODO: avoid copies, via writing directly to slice
|
use prost::bytes::Bytes;
|
||||||
let link = format!(
|
|
||||||
"Bundle:{}:{}",
|
|
||||||
hex::encode(self.installation_key.as_bytes()),
|
|
||||||
hex::encode(self.ephemeral_key.as_bytes()),
|
|
||||||
);
|
|
||||||
|
|
||||||
link.into_bytes()
|
let bundle = IntroBundle {
|
||||||
|
installation_pubkey: Bytes::copy_from_slice(intro.installation_key.as_bytes()),
|
||||||
|
ephemeral_pubkey: Bytes::copy_from_slice(intro.ephemeral_key.as_bytes()),
|
||||||
|
signature: Bytes::copy_from_slice(&intro.signature),
|
||||||
|
};
|
||||||
|
|
||||||
|
let base64_encoded = Base64UrlUnpadded::encode_string(&bundle.encode_to_vec());
|
||||||
|
|
||||||
|
let mut result = String::with_capacity(BUNDLE_PREFIX.len() + base64_encoded.len());
|
||||||
|
result.push_str(BUNDLE_PREFIX);
|
||||||
|
result.push_str(&base64_encoded);
|
||||||
|
|
||||||
|
result.into_bytes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,30 +79,102 @@ impl TryFrom<&[u8]> for Introduction {
|
|||||||
type Error = ChatError;
|
type Error = ChatError;
|
||||||
|
|
||||||
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
|
||||||
let str_value = String::from_utf8_lossy(value);
|
let str_value = std::str::from_utf8(value)
|
||||||
let parts: Vec<&str> = str_value.splitn(3, ':').collect();
|
.map_err(|_| ChatError::BadBundleValue("invalid UTF-8".into()))?;
|
||||||
|
|
||||||
if parts[0] != "Bundle" {
|
let base64_part = str_value.strip_prefix(BUNDLE_PREFIX).ok_or_else(|| {
|
||||||
return Err(ChatError::BadBundleValue(
|
ChatError::BadBundleValue("not recognized as an introduction bundle".into())
|
||||||
"not recognized as an introduction bundle".into(),
|
})?;
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let installation_bytes: [u8; 32] = hex::decode(parts[1])
|
let proto_bytes = Base64UrlUnpadded::decode_vec(base64_part)
|
||||||
.map_err(|_| ChatError::BadParsing("installation_key"))?
|
.map_err(|_| ChatError::BadBundleValue("invalid base64".into()))?;
|
||||||
|
|
||||||
|
let bundle = IntroBundle::decode(proto_bytes.as_slice())
|
||||||
|
.map_err(|_| ChatError::BadBundleValue("invalid protobuf".into()))?;
|
||||||
|
|
||||||
|
let installation_bytes: [u8; 32] = bundle
|
||||||
|
.installation_pubkey
|
||||||
|
.as_ref()
|
||||||
.try_into()
|
.try_into()
|
||||||
.map_err(|_| ChatError::InvalidKeyLength)?;
|
.map_err(|_| ChatError::InvalidKeyLength)?;
|
||||||
let installation_key = PublicKey::from(installation_bytes);
|
|
||||||
|
|
||||||
let ephemeral_bytes: [u8; 32] = hex::decode(parts[2])
|
let ephemeral_bytes: [u8; 32] = bundle
|
||||||
.map_err(|_| ChatError::BadParsing("ephemeral_key"))?
|
.ephemeral_pubkey
|
||||||
|
.as_ref()
|
||||||
.try_into()
|
.try_into()
|
||||||
.map_err(|_| ChatError::InvalidKeyLength)?;
|
.map_err(|_| ChatError::InvalidKeyLength)?;
|
||||||
let ephemeral_key = PublicKey::from(ephemeral_bytes);
|
|
||||||
|
let signature: [u8; 64] = bundle
|
||||||
|
.signature
|
||||||
|
.as_ref()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| ChatError::BadBundleValue("invalid signature length".into()))?;
|
||||||
|
|
||||||
Ok(Introduction {
|
Ok(Introduction {
|
||||||
installation_key,
|
installation_key: PublicKey::from(installation_bytes),
|
||||||
ephemeral_key,
|
ephemeral_key: PublicKey::from(ephemeral_bytes),
|
||||||
|
signature,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand_core::OsRng;
|
||||||
|
|
||||||
|
fn create_test_introduction() -> Introduction {
|
||||||
|
let install_secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let install_pub = PublicKey::from(&install_secret);
|
||||||
|
|
||||||
|
let ephemeral_secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let ephemeral_pub = PublicKey::from(&ephemeral_secret);
|
||||||
|
|
||||||
|
let signature = sign_intro_binding(&install_secret, &ephemeral_pub, OsRng);
|
||||||
|
|
||||||
|
Introduction {
|
||||||
|
installation_key: install_pub,
|
||||||
|
ephemeral_key: ephemeral_pub,
|
||||||
|
signature,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialization_roundtrip() {
|
||||||
|
let intro = create_test_introduction();
|
||||||
|
let original_install = intro.installation_key;
|
||||||
|
let original_ephemeral = intro.ephemeral_key;
|
||||||
|
let original_signature = intro.signature;
|
||||||
|
|
||||||
|
let encoded: Vec<u8> = intro.into();
|
||||||
|
let decoded = Introduction::try_from(encoded.as_slice()).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(decoded.installation_key, original_install);
|
||||||
|
assert_eq!(decoded.ephemeral_key, original_ephemeral);
|
||||||
|
assert_eq!(decoded.signature, original_signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_prefix_rejected() {
|
||||||
|
assert!(Introduction::try_from(b"wrong_prefix_AAAA".as_slice()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_invalid_base64_rejected() {
|
||||||
|
assert!(Introduction::try_from(b"logos_chatintro_1_!!!invalid!!!".as_slice()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_truncated_payload_rejected() {
|
||||||
|
let intro = create_test_introduction();
|
||||||
|
let encoded: Vec<u8> = intro.into();
|
||||||
|
let encoded_str = String::from_utf8(encoded).unwrap();
|
||||||
|
|
||||||
|
let truncated = format!(
|
||||||
|
"logos_chatintro_1_{}",
|
||||||
|
&encoded_str[BUNDLE_PREFIX.len()..][..10]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(Introduction::try_from(truncated.as_bytes()).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
mod keys;
|
mod keys;
|
||||||
mod x3dh;
|
mod x3dh;
|
||||||
|
mod xeddsa_sign;
|
||||||
|
|
||||||
pub use keys::{GenericArray, SecretKey};
|
pub use keys::{GenericArray, SecretKey};
|
||||||
pub use x3dh::{DomainSeparator, PrekeyBundle, X3Handshake};
|
pub use x3dh::{DomainSeparator, PrekeyBundle, X3Handshake};
|
||||||
|
pub use xeddsa_sign::{xeddsa_sign, xeddsa_verify, SignatureError};
|
||||||
|
|||||||
118
crypto/src/xeddsa_sign.rs
Normal file
118
crypto/src/xeddsa_sign.rs
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
//! XEdDSA signing using X25519 keys.
|
||||||
|
//!
|
||||||
|
//! This module provides generic XEdDSA sign and verify functions
|
||||||
|
//! that allow signing arbitrary messages with X25519 keys.
|
||||||
|
|
||||||
|
use rand_core::{CryptoRng, RngCore};
|
||||||
|
use x25519_dalek::{PublicKey, StaticSecret};
|
||||||
|
use xeddsa::{xed25519, Sign, Verify};
|
||||||
|
|
||||||
|
/// Error type for signature verification failures.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct SignatureError;
|
||||||
|
|
||||||
|
impl std::fmt::Display for SignatureError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "signature verification failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SignatureError {}
|
||||||
|
|
||||||
|
/// Sign a message using XEdDSA with an X25519 secret key.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `secret` - The X25519 secret key to sign with
|
||||||
|
/// * `message` - The message to sign
|
||||||
|
/// * `rng` - A cryptographically secure random number generator
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// A 64-byte XEdDSA signature
|
||||||
|
pub fn xeddsa_sign<R: RngCore + CryptoRng>(
|
||||||
|
secret: &StaticSecret,
|
||||||
|
message: &[u8],
|
||||||
|
mut rng: R,
|
||||||
|
) -> [u8; 64] {
|
||||||
|
let signing_key = xed25519::PrivateKey::from(secret);
|
||||||
|
signing_key.sign(message, &mut rng)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify an XEdDSA signature using an X25519 public key.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `pubkey` - The X25519 public key to verify with
|
||||||
|
/// * `message` - The message that was signed
|
||||||
|
/// * `signature` - The 64-byte XEdDSA signature to verify
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// `Ok(())` if the signature is valid, `Err(SignatureError)` otherwise
|
||||||
|
pub fn xeddsa_verify(
|
||||||
|
pubkey: &PublicKey,
|
||||||
|
message: &[u8],
|
||||||
|
signature: &[u8; 64],
|
||||||
|
) -> Result<(), SignatureError> {
|
||||||
|
let verify_key = xed25519::PublicKey::from(pubkey);
|
||||||
|
verify_key.verify(message, signature).map_err(|_| SignatureError)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand_core::OsRng;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sign_and_verify_roundtrip() {
|
||||||
|
let secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let public = PublicKey::from(&secret);
|
||||||
|
let message = b"test message";
|
||||||
|
|
||||||
|
let signature = xeddsa_sign(&secret, message, OsRng);
|
||||||
|
|
||||||
|
assert!(xeddsa_verify(&public, message, &signature).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrong_key_fails() {
|
||||||
|
let secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let message = b"test message";
|
||||||
|
|
||||||
|
let signature = xeddsa_sign(&secret, message, OsRng);
|
||||||
|
|
||||||
|
let wrong_secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let wrong_public = PublicKey::from(&wrong_secret);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
xeddsa_verify(&wrong_public, message, &signature),
|
||||||
|
Err(SignatureError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_wrong_message_fails() {
|
||||||
|
let secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let public = PublicKey::from(&secret);
|
||||||
|
let message = b"test message";
|
||||||
|
|
||||||
|
let signature = xeddsa_sign(&secret, message, OsRng);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
xeddsa_verify(&public, b"wrong message", &signature),
|
||||||
|
Err(SignatureError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_corrupted_signature_fails() {
|
||||||
|
let secret = StaticSecret::random_from_rng(OsRng);
|
||||||
|
let public = PublicKey::from(&secret);
|
||||||
|
let message = b"test message";
|
||||||
|
|
||||||
|
let mut signature = xeddsa_sign(&secret, message, OsRng);
|
||||||
|
signature[0] ^= 0xFF;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
xeddsa_verify(&public, message, &signature),
|
||||||
|
Err(SignatureError)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user