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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions conversations/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ crate-type = ["staticlib","dylib"]
blake2.workspace = true
chat-proto = { git = "https://github.com/logos-messaging/chat_proto" }
crypto = { path = "../crypto" }
double-ratchets = { path = "../double-ratchets" }
hex = "0.4.3"
prost = "0.14.1"
rand_core = { version = "0.6" }
Expand Down
127 changes: 117 additions & 10 deletions conversations/src/conversation/privatev1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,35 +3,92 @@ use chat_proto::logoschat::{
encryption::{Doubleratchet, EncryptedPayload, encrypted_payload::Encryption},
};
use crypto::SecretKey;
use double_ratchets::{Header, InstallationKeyPair, RatchetState};
use prost::{Message, bytes::Bytes};
use std::fmt::Debug;
use x25519_dalek::PublicKey;

use crate::{
conversation::{ChatError, ConversationId, Convo, Id},
errors::EncryptionError,
proto,
types::AddressedEncryptedPayload,
utils::timestamp_millis,
};

#[derive(Debug)]
pub struct PrivateV1Convo {}
pub struct PrivateV1Convo {
dr_state: RatchetState,
}

impl PrivateV1Convo {
pub fn new(_seed_key: SecretKey) -> Self {
Self {}
pub fn new_initiator(seed_key: SecretKey, remote: PublicKey) -> Self {
// TODO: Danger - Fix double-ratchets types to Accept SecretKey
// perhaps update the DH to work with cryptocrate.
// init_sender doesn't take ownership of the key so a reference can be used.
let shared_secret: [u8; 32] = seed_key.as_bytes().to_vec().try_into().unwrap();
Self {
dr_state: RatchetState::init_sender(shared_secret, remote),
}
}

fn encrypt(&self, frame: PrivateV1Frame) -> EncryptedPayload {
// TODO: Integrate DR
pub fn new_responder(seed_key: SecretKey, dh_self: InstallationKeyPair) -> Self {
Self {
// TODO: Danger - Fix double-ratchets types to Accept SecretKey
dr_state: RatchetState::init_receiver(seed_key.as_bytes().to_owned(), dh_self),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free to make roles name consistent in different crates.

}
}
Comment on lines +34 to +39
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

double-ratchets requires a InstallationKey struct to be passed in.

As InstallationKey is just a thin wrapper around StaticKey @kaichaosun would you be ok if I refactored double-ratchets to use a common keyType as the rest of the cryptography? It would be a StructWrapper around StaticKey which would have the effect:

  • Keys can be passed without extra copies for clearer memory hygiene.
  • Remove DR direct dependence on x25519 in our entire codebase.
  • Avoid Needless copies

We can add an AbstractType or Trait Generics if we really want it to be completely independent in the future.

Copy link
Contributor

@kaichaosun kaichaosun Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would be great! thanks @jazzz


fn encrypt(&mut self, frame: PrivateV1Frame) -> EncryptedPayload {
let encoded_bytes = frame.encode_to_vec();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the name can be shorter like to_bytes or something similar

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what do you mean @kaichaosun ? I don't follow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It just looks so strange to use encode_to_vec other than something like to_bytes, since it comes from generated code, seems we can do nothing about it.

let (cipher_text, header) = self.dr_state.encrypt_message(&encoded_bytes);

EncryptedPayload {
encryption: Some(Encryption::Doubleratchet(Doubleratchet {
dh: Bytes::from(vec![]),
msg_num: 0,
prev_chain_len: 1,
ciphertext: Bytes::from(frame.encode_to_vec()),
dh: Bytes::from(Vec::from(header.dh_pub.to_bytes())),
msg_num: header.msg_num,
prev_chain_len: header.prev_chain_len,
ciphertext: Bytes::from(cipher_text),
aux: "".into(),
})),
}
}

fn decrypt(&mut self, payload: EncryptedPayload) -> Result<PrivateV1Frame, EncryptionError> {
// Validate and extract the encryption header or return errors
let dr_header = if let Some(enc) = payload.encryption {
if let proto::Encryption::Doubleratchet(dr) = enc {
dr
} else {
return Err(EncryptionError::Decryption(
"incorrect encryption type".into(),
));
}
} else {
return Err(EncryptionError::Decryption("missing payload".into()));
};

// Turn the bytes into a PublicKey
let byte_arr: [u8; 32] = dr_header
.dh
.to_vec()
.try_into()
.map_err(|_| EncryptionError::Decryption("invalid public key length".into()))?;
let dh_pub = PublicKey::from(byte_arr);

// Build the Header that DR impl expects
let header = Header {
dh_pub,
msg_num: dr_header.msg_num,
prev_chain_len: dr_header.prev_chain_len,
};
Comment on lines +79 to +83
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Orphan rule is making handling these DR types kind of annoying. I'll look at using Newtype and implementing the conversions there


// Decrypt into Frame
let content_bytes = self
.dr_state
.decrypt_message(&dr_header.ciphertext, header)
.map_err(|e| EncryptionError::Decryption(e.to_string()))?;
Ok(PrivateV1Frame::decode(content_bytes.as_slice()).unwrap())
}
}

impl Id for PrivateV1Convo {
Expand Down Expand Up @@ -66,3 +123,53 @@ impl Convo for PrivateV1Convo {
self.id().into()
}
}

impl Debug for PrivateV1Convo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PrivateV1Convo")
.field("dr_state", &"******")
.finish()
}
}

#[cfg(test)]
mod tests {
use x25519_dalek::StaticSecret;

use super::*;

#[test]
fn test_encrypt_roundtrip() {
let saro = StaticSecret::random();
let raya = StaticSecret::random();

let pub_raya = PublicKey::from(&raya);

let seed_key = saro.diffie_hellman(&pub_raya);
let send_content_bytes = vec![0, 2, 4, 6, 8];
let mut sr_convo =
PrivateV1Convo::new_initiator(SecretKey::from(seed_key.to_bytes()), pub_raya);

let installation_key_pair = InstallationKeyPair::from(raya);
let mut rs_convo = PrivateV1Convo::new_responder(
SecretKey::from(seed_key.to_bytes()),
installation_key_pair,
);

let send_frame = PrivateV1Frame {
conversation_id: "_".into(),
sender: Bytes::new(),
timestamp: timestamp_millis(),
frame_type: Some(FrameType::Content(Bytes::from(send_content_bytes.clone()))),
};
let payload = sr_convo.encrypt(send_frame.clone());
let recv_frame = rs_convo.decrypt(payload).unwrap();

assert!(
recv_frame == send_frame,
"{:?}. {:?}",
recv_frame,
send_content_bytes
);
}
}
8 changes: 8 additions & 0 deletions conversations/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,11 @@ pub enum ChatError {
#[error("convo with handle: {0} was not found")]
NoConvo(u32),
}

#[derive(Error, Debug)]
pub enum EncryptionError {
#[error("encryption: {0}")]
Encryption(String),
#[error("decryption: {0}")]
Decryption(String),
}
2 changes: 1 addition & 1 deletion conversations/src/inbox/handshake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ impl InboxHandshake {
/// Derive keys from X3DH shared secret
fn derive_keys_from_shared_secret(shared_secret: SecretKey) -> SecretKey {
let seed_key: [u8; 32] = Blake2bMac256::new_with_salt_and_personal(
shared_secret.as_bytes(),
shared_secret.as_slice(),
&[], // No salt - input already has high entropy
b"InboxV1-Seed",
)
Expand Down
39 changes: 22 additions & 17 deletions conversations/src/inbox/inbox.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use double_ratchets::InstallationKeyPair;
use hex;
use prost::Message;
use prost::bytes::Bytes;
Expand Down Expand Up @@ -88,7 +89,7 @@ impl Inbox {
let (seed_key, ephemeral_pub) =
InboxHandshake::perform_as_initiator(&self.ident.secret(), &pkb, &mut rng);

let mut convo = PrivateV1Convo::new(seed_key);
let mut convo = PrivateV1Convo::new_initiator(seed_key, remote_bundle.ephemeral_key);

let mut payloads = convo.send_message(initial_message.as_bytes())?;

Expand Down Expand Up @@ -139,16 +140,11 @@ impl Inbox {

fn perform_handshake(
&self,
payload: proto::EncryptedPayload,
ephemeral_key: &StaticSecret,
header: proto::InboxHeaderV1,
bytes: Bytes,
) -> Result<(SecretKey, proto::InboxV1Frame), ChatError> {
let handshake = Self::extract_payload(payload)?;
let header = handshake
.header
.ok_or(ChatError::UnexpectedPayload("InboxV1Header".into()))?;
let pubkey_hex = hex::encode(header.responder_ephemeral.as_ref());

let ephemeral_key = self.lookup_ephemeral_key(&pubkey_hex)?;

// Get PublicKeys from protobuf
let initator_static = PublicKey::from(
<[u8; 32]>::try_from(header.initiator_static.as_ref())
.map_err(|_| ChatError::BadBundleValue("wrong size - initator static".into()))?,
Expand All @@ -168,7 +164,7 @@ impl Inbox {
);

// TODO: Decrypt Content
let frame = proto::InboxV1Frame::decode(handshake.payload)?;
let frame = proto::InboxV1Frame::decode(bytes)?;
Ok((seed_key, frame))
}

Expand Down Expand Up @@ -215,14 +211,23 @@ impl ConvoFactory for Inbox {
return Err(ChatError::Protocol("Example error".into()));
}

let ep = proto::EncryptedPayload::decode(message)?;
let (seed_key, frame) = self.perform_handshake(ep)?;
let handshake = Self::extract_payload(proto::EncryptedPayload::decode(message)?)?;

let header = handshake
.header
.ok_or(ChatError::UnexpectedPayload("InboxV1Header".into()))?;

// Get Ephemeral key used by the initator
let key_index = hex::encode(header.responder_ephemeral.as_ref());
let ephemeral_key = self.lookup_ephemeral_key(&key_index)?;

// Perform handshake and decrypt frame
let (seed_key, frame) = self.perform_handshake(ephemeral_key, header, handshake.payload)?;

match frame.frame_type.unwrap() {
chat_proto::logoschat::inbox::inbox_v1_frame::FrameType::InvitePrivateV1(
_invite_private_v1,
) => {
let convo = PrivateV1Convo::new(seed_key);
proto::inbox_v1_frame::FrameType::InvitePrivateV1(_invite_private_v1) => {
let convo = PrivateV1Convo::new_responder(seed_key, ephemeral_key.clone().into());

// TODO: Update PrivateV1 Constructor with DR, initial_message
Ok((Box::new(convo), vec![]))
}
Expand Down
2 changes: 1 addition & 1 deletion conversations/src/proto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ pub use chat_proto::logoschat::encryption::encrypted_payload::Encryption;
pub use chat_proto::logoschat::encryption::inbox_handshake_v1::InboxHeaderV1;
pub use chat_proto::logoschat::encryption::{EncryptedPayload, InboxHandshakeV1};
pub use chat_proto::logoschat::envelope::EnvelopeV1;
pub use chat_proto::logoschat::inbox::InboxV1Frame;
pub use chat_proto::logoschat::inbox::{InboxV1Frame, inbox_v1_frame};
pub use chat_proto::logoschat::invite::InvitePrivateV1;

pub use prost::Message;
Expand Down
6 changes: 5 additions & 1 deletion crypto/src/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ use zeroize::{Zeroize, ZeroizeOnDrop};
pub struct SecretKey([u8; 32]);

impl SecretKey {
pub fn as_bytes(&self) -> &[u8] {
pub fn as_slice(&self) -> &[u8] {
self.0.as_slice()
}

pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}

impl From<[u8; 32]> for SecretKey {
Expand Down
10 changes: 10 additions & 0 deletions double-ratchets/src/keypair.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,13 @@ impl InstallationKeyPair {
Self { secret, public }
}
}

impl From<StaticSecret> for InstallationKeyPair {
fn from(value: StaticSecret) -> Self {
let public = PublicKey::from(&value);
Self {
secret: value,
public,
}
}
}