PrivateV1 Convo Initialization via Inbox (#13)

* Load orginal protofiles

* Change package name

* Add prost generation

* Remove placeholders

* Add generated files + imports

* replace with chat-proto

* Add XK0

* auto formatting

* Initial implementation of PrivateV1 initialization

* Add ConvoFactory trait

* Hook up indentity placeholder

* Remove RemoteInbox until it’s needed

* Simplify Identity ownership

* Clean up x3handshake

* Move inbox encryption

* Simplify inbox encryption

* Cleanup warnings

* Add todos

* Update chat-proto crate

* Publickey Handling

* Reorg Inbox handshake

* Update Inbox convoId

* Remove file structure headers

* Update ConvoID

* Add Domain Separator trait

* Remove Convo trait functions

* Rename Context

* Add SecretKey

* Add workspace dependency

* update KE name

* Update comments for clarity

* Remove Xk0 references

* Bump chat_proto version and relock
This commit is contained in:
Jazz Turner-Baggs 2026-01-22 06:39:09 +07:00 committed by GitHub
parent 58392841cd
commit fe23c39321
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1095 additions and 131 deletions

217
Cargo.lock generated
View File

@ -9,9 +9,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [
"crypto-common",
"generic-array",
"generic-array 0.14.7",
]
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "blake2"
version = "0.10.6"
@ -27,9 +39,15 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
"generic-array 0.14.7",
]
[[package]]
name = "bytes"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
[[package]]
name = "cfg-if"
version = "1.0.4"
@ -60,6 +78,14 @@ dependencies = [
"zeroize",
]
[[package]]
name = "chat-proto"
version = "0.1.0"
source = "git+https://github.com/logos-messaging/chat_proto#21cd52b0649c959f43eec19e1edad12451ccc382"
dependencies = [
"prost",
]
[[package]]
name = "cipher"
version = "0.4.4"
@ -71,6 +97,12 @@ dependencies = [
"zeroize",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "cpufeatures"
version = "0.2.17"
@ -80,13 +112,27 @@ dependencies = [
"libc",
]
[[package]]
name = "crypto"
version = "0.1.0"
dependencies = [
"ed25519-dalek",
"generic-array 1.3.5",
"hkdf",
"rand_core",
"sha2",
"x25519-dalek",
"xeddsa",
"zeroize",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"generic-array 0.14.7",
"rand_core",
"typenum",
]
@ -100,6 +146,7 @@ dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
@ -117,6 +164,27 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "derive_more"
version = "0.99.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "digest"
version = "0.10.7"
@ -143,6 +211,36 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "either"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "equivalent"
version = "1.0.2"
@ -200,6 +298,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "generic-array"
version = "1.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542"
dependencies = [
"rustversion",
"typenum",
]
[[package]]
name = "getrandom"
version = "0.2.16"
@ -217,6 +325,12 @@ version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
version = "0.12.4"
@ -251,7 +365,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
"generic-array 0.14.7",
]
[[package]]
@ -263,6 +377,15 @@ dependencies = [
"rustversion",
]
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "libc"
version = "0.2.178"
@ -273,7 +396,14 @@ checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
name = "logos-chat"
version = "0.1.0"
dependencies = [
"blake2",
"chat-proto",
"crypto",
"hex",
"prost",
"rand_core",
"thiserror",
"x25519-dalek",
]
[[package]]
@ -310,6 +440,16 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "poly1305"
version = "0.8.0"
@ -358,6 +498,29 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "prost"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes",
"prost-derive",
]
[[package]]
name = "prost-derive"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools",
"proc-macro2",
"quote",
"syn 2.0.111",
]
[[package]]
name = "quote"
version = "1.0.42"
@ -486,12 +649,42 @@ dependencies = [
"syn 2.0.111",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2-const-stable"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9"
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "stabby"
version = "36.2.2"
@ -695,6 +888,22 @@ dependencies = [
"zeroize",
]
[[package]]
name = "xeddsa"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2460c9a9c9d1331ff6801e87badb517faa6b6758e5fb585eb27daf7622c6d5ad"
dependencies = [
"curve25519-dalek",
"derive_more",
"ed25519",
"ed25519-dalek",
"rand",
"sha2",
"x25519-dalek",
"zeroize",
]
[[package]]
name = "zerocopy"
version = "0.8.31"

View File

@ -4,5 +4,9 @@ resolver = "3"
members = [
"conversations",
"crypto",
"double-ratchets",
]
[workspace.dependencies]
blake2 = "0.10"

View File

@ -7,4 +7,11 @@ edition = "2024"
crate-type = ["staticlib"]
[dependencies]
blake2.workspace = true
chat-proto = { git = "https://github.com/logos-messaging/chat_proto" }
crypto = { path = "../crypto" }
hex = "0.4.3"
prost = "0.14.1"
rand_core = { version = "0.6" }
thiserror = "2.0.17"
x25519-dalek = { version = "2.0.1", features = ["static_secrets", "reusable_secrets", "getrandom"] }

View File

@ -8,9 +8,9 @@ pub enum ErrorCode {
BadConvoId = -2,
}
use crate::context::Ctx;
use crate::context::Context;
pub type ContextHandle = *mut Ctx;
pub type ContextHandle = *mut Context;
/// Creates a new libchat Ctx
///
@ -18,7 +18,7 @@ pub type ContextHandle = *mut Ctx;
/// Opaque handle to the store. Must be freed with conversation_store_destroy()
#[unsafe(no_mangle)]
pub extern "C" fn create_context() -> ContextHandle {
let store = Box::new(Ctx::new());
let store = Box::new(Context::new());
Box::into_raw(store) // Leak the box, return raw pointer
}

View File

@ -1,29 +1,46 @@
use crate::conversation::{ConversationId, ConversationIdOwned, ConversationStore, PrivateV1Convo};
use std::rc::Rc;
pub struct PayloadData {
pub delivery_address: String,
pub data: Vec<u8>,
}
use crypto::PrekeyBundle;
pub struct ContentData {
pub conversation_id: String,
pub data: Vec<u8>,
}
use crate::{
conversation::{ConversationId, ConversationIdOwned, ConversationStore},
identity::Identity,
inbox::Inbox,
types::{ContentData, PayloadData},
};
pub struct Ctx {
// This is the main entry point to the conversations api.
// Ctx manages lifetimes of objects to process and generate payloads.
pub struct Context {
_identity: Rc<Identity>,
store: ConversationStore,
inbox: Inbox,
}
impl Ctx {
impl Context {
pub fn new() -> Self {
let identity = Rc::new(Identity::new());
let inbox = Inbox::new(Rc::clone(&identity)); //
Self {
_identity: identity,
store: ConversationStore::new(),
inbox,
}
}
pub fn create_private_convo(&mut self, _content: &[u8]) -> ConversationIdOwned {
let new_convo = PrivateV1Convo::new();
self.store.insert(new_convo)
pub fn create_private_convo(
&mut self,
remote_bundle: &PrekeyBundle,
content: String,
) -> ConversationIdOwned {
let (convo, _payloads) = self
.inbox
.invite_to_private_convo(remote_bundle, content)
.unwrap_or_else(|_| todo!("Log/Surface Error"));
self.store.insert_convo(convo)
// TODO: Change return type to handle outbout packets.
}
pub fn send_content(&mut self, _convo_id: ConversationId, _content: &[u8]) -> Vec<PayloadData> {
@ -42,3 +59,21 @@ impl Ctx {
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::GroupTestConvo;
#[test]
fn convo_store_get() {
let mut store: ConversationStore = ConversationStore::new();
let new_convo = GroupTestConvo::new();
let convo_id = store.insert_convo(new_convo);
let convo = store.get_mut(&convo_id).ok_or_else(|| 0);
convo.unwrap();
}
}

View File

@ -3,46 +3,55 @@ use std::fmt::Debug;
use std::sync::Arc;
pub use crate::errors::ChatError;
/////////////////////////////////////////////////
// Type Definitions
/////////////////////////////////////////////////
use crate::types::ContentData;
pub type ConversationId<'a> = &'a str;
pub type ConversationIdOwned = Arc<str>;
/////////////////////////////////////////////////
// Trait Definitions
/////////////////////////////////////////////////
pub trait Convo: Debug {
pub trait Id: Debug {
fn id(&self) -> ConversationId;
fn send_frame(&mut self, message: &[u8]) -> Result<(), ChatError>;
fn handle_frame(&mut self, message: &[u8]) -> Result<(), ChatError>;
}
/////////////////////////////////////////////////
// Structs
/////////////////////////////////////////////////
pub trait ConvoFactory: Id + Debug {
fn handle_frame(
&mut self,
encoded_payload: &[u8],
) -> Result<(Box<dyn Convo>, Vec<ContentData>), ChatError>;
}
pub trait Convo: Id + Debug {
fn send_message(&mut self, content: &[u8]) -> Result<Vec<EncryptedPayload>, ChatError>;
}
pub struct ConversationStore {
conversations: HashMap<Arc<str>, Box<dyn Convo>>,
factories: HashMap<Arc<str>, Box<dyn ConvoFactory>>,
}
impl ConversationStore {
pub fn new() -> Self {
Self {
conversations: HashMap::new(),
factories: HashMap::new(),
}
}
pub fn insert(&mut self, conversation: impl Convo + 'static) -> ConversationIdOwned {
pub fn insert_convo(&mut self, conversation: impl Convo + Id + 'static) -> ConversationIdOwned {
let key: ConversationIdOwned = Arc::from(conversation.id());
self.conversations
.insert(key.clone(), Box::new(conversation));
key
}
pub fn register_factory(
&mut self,
handler: impl ConvoFactory + Id + 'static,
) -> ConversationIdOwned {
let key: ConversationIdOwned = Arc::from(handler.id());
self.factories.insert(key.clone(), Box::new(handler));
key
}
pub fn get(&self, id: ConversationId) -> Option<&(dyn Convo + '_)> {
self.conversations.get(id).map(|c| c.as_ref())
}
@ -51,17 +60,22 @@ impl ConversationStore {
Some(self.conversations.get_mut(id)?.as_mut())
}
pub fn get_factory(&mut self, id: ConversationId) -> Option<&mut (dyn ConvoFactory + '_)> {
Some(self.factories.get_mut(id)?.as_mut())
}
pub fn conversation_ids(&self) -> impl Iterator<Item = ConversationIdOwned> + '_ {
self.conversations.keys().cloned()
}
}
/////////////////////////////////////////////////
// Modules
/////////////////////////////////////////////////
pub fn factory_ids(&self) -> impl Iterator<Item = ConversationIdOwned> + '_ {
self.factories.keys().cloned()
}
}
mod group_test;
mod privatev1;
use crate::proto::EncryptedPayload;
pub use group_test::GroupTestConvo;
pub use privatev1::PrivateV1Convo;

View File

@ -1,4 +1,6 @@
use crate::conversation::{ChatError, ConversationId, Convo};
use chat_proto::logoschat::encryption::EncryptedPayload;
use crate::conversation::{ChatError, ConversationId, Convo, Id};
#[derive(Debug)]
pub struct GroupTestConvo {}
@ -9,19 +11,15 @@ impl GroupTestConvo {
}
}
impl Convo for GroupTestConvo {
impl Id for GroupTestConvo {
fn id(&self) -> ConversationId {
// implementation
"grouptest"
}
}
fn send_frame(&mut self, _message: &[u8]) -> Result<(), ChatError> {
// todo!("Not Implemented")
Ok(())
}
fn handle_frame(&mut self, _message: &[u8]) -> Result<(), ChatError> {
// todo!("Not Implemented")
Ok(())
impl Convo for GroupTestConvo {
fn send_message(&mut self, _content: &[u8]) -> Result<Vec<EncryptedPayload>, ChatError> {
Ok(vec![])
}
}

View File

@ -1,25 +1,56 @@
use crate::conversation::{ChatError, ConversationId, Convo};
use chat_proto::logoschat::{
convos::private_v1::{PrivateV1Frame, private_v1_frame::FrameType},
encryption::{Doubleratchet, EncryptedPayload, encrypted_payload::Encryption},
};
use crypto::SecretKey;
use prost::{Message, bytes::Bytes};
use crate::{
conversation::{ChatError, ConversationId, Convo, Id},
utils::timestamp_millis,
};
#[derive(Debug)]
pub struct PrivateV1Convo {}
impl PrivateV1Convo {
pub fn new() -> Self {
pub fn new(_seed_key: SecretKey) -> Self {
Self {}
}
fn encrypt(&self, frame: PrivateV1Frame) -> EncryptedPayload {
// TODO: Integrate DR
EncryptedPayload {
encryption: Some(Encryption::Doubleratchet(Doubleratchet {
dh: Bytes::from(vec![]),
msg_num: 0,
prev_chain_len: 1,
ciphertext: Bytes::from(frame.encode_to_vec()),
aux: "".into(),
})),
}
}
}
impl Id for PrivateV1Convo {
fn id(&self) -> ConversationId {
// TODO: implementation
"private_v1_convo_id"
}
}
impl Convo for PrivateV1Convo {
fn id(&self) -> ConversationId {
// implementation
"private_v1_convo_id"
}
fn send_message(&mut self, content: &[u8]) -> Result<Vec<EncryptedPayload>, ChatError> {
let frame = PrivateV1Frame {
conversation_id: self.id().into(),
sender: "delete".into(),
timestamp: timestamp_millis(),
frame_type: Some(FrameType::Content(content.to_vec().into())),
};
fn send_frame(&mut self, _message: &[u8]) -> Result<(), ChatError> {
todo!("Not Implemented")
}
let ef = self.encrypt(frame);
fn handle_frame(&mut self, _message: &[u8]) -> Result<(), ChatError> {
todo!("Not Implemented")
Ok(vec![ef])
}
}

View File

@ -0,0 +1,17 @@
pub use blake2::Digest;
use blake2::{Blake2b, digest};
use prost::bytes::Bytes;
pub use x25519_dalek::{PublicKey, StaticSecret};
pub trait CopyBytes {
fn copy_to_bytes(&self) -> Bytes;
}
impl CopyBytes for PublicKey {
fn copy_to_bytes(&self) -> Bytes {
Bytes::copy_from_slice(self.as_bytes())
}
}
#[allow(dead_code)]
pub type Blake2b128 = Blake2b<digest::consts::U16>;

View File

@ -4,4 +4,12 @@ pub use thiserror::Error;
pub enum ChatError {
#[error("protocol error: {0:?}")]
Protocol(String),
#[error("Failed to decode payload: {0}")]
DecodeError(#[from] prost::DecodeError),
#[error("incorrect bundle value: {0:?}")]
UnexpectedPayload(String),
#[error("unexpected payload contents: {0}")]
BadBundleValue(String),
#[error("handshake initiated with a unknown ephemeral key")]
UnknownEphemeralKey(),
}

View File

@ -0,0 +1,43 @@
use blake2::{Blake2b512, Digest};
use std::fmt;
use crate::crypto::{PublicKey, StaticSecret};
pub struct Identity {
secret: StaticSecret,
}
impl fmt::Debug for Identity {
// Manually implement debug to not reveal secret key material
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Identity")
.field("public_key", &self.public_key())
.finish_non_exhaustive()
}
}
impl Identity {
pub fn new() -> Self {
Self {
secret: StaticSecret::random(),
}
}
pub fn address(&self) -> String {
hex::encode(Blake2b512::digest(self.public_key()))
}
pub fn public_key(&self) -> PublicKey {
PublicKey::from(&self.secret)
}
pub fn secret(&self) -> &StaticSecret {
&self.secret
}
}
impl Default for Identity {
fn default() -> Self {
Self::new()
}
}

View File

@ -1,31 +1,4 @@
use crate::conversation::{ChatError, ConversationId, Convo};
mod handshake;
mod inbox;
#[derive(Debug)]
pub struct Inbox {
address: String,
}
impl Inbox {
pub fn new(address: impl Into<String>) -> Self {
Self {
address: address.into(),
}
}
}
impl Convo for Inbox {
fn id(&self) -> ConversationId {
self.address.as_ref()
}
fn send_frame(&mut self, _message: &[u8]) -> Result<(), ChatError> {
todo!("Not Implemented")
}
fn handle_frame(&mut self, message: &[u8]) -> Result<(), ChatError> {
if message.len() == 0 {
return Err(ChatError::Protocol("Example error".into()));
}
todo!("Not Implemented")
}
}
pub use inbox::Inbox;

View File

@ -0,0 +1,120 @@
use blake2::{
Blake2bMac,
digest::{FixedOutput, consts::U32},
};
use crypto::{DomainSeparator, PrekeyBundle, SecretKey, X3Handshake};
use rand_core::{CryptoRng, RngCore};
use crate::crypto::{PublicKey, StaticSecret};
type Blake2bMac256 = Blake2bMac<U32>;
pub struct InboxDomain;
impl DomainSeparator for InboxDomain {
const BYTES: &'static [u8] = b"logos_chat_inbox";
}
type InboxKeyExchange = X3Handshake<InboxDomain>;
pub struct InboxHandshake {}
impl InboxHandshake {
/// Performs
pub fn perform_as_initiator<R: RngCore + CryptoRng>(
identity_keypair: &StaticSecret,
recipient_bundle: &PrekeyBundle,
rng: &mut R,
) -> (SecretKey, PublicKey) {
// Perform X3DH handshake to get shared secret
let (shared_secret, ephemeral_public) =
InboxKeyExchange::initator(identity_keypair, recipient_bundle, rng);
let seed_key = Self::derive_keys_from_shared_secret(shared_secret);
(seed_key, ephemeral_public)
}
/// Perform the Inbox Handshake after receiving a keyBundle
///
/// # Arguments
/// * `identity_keypair` - Your long-term identity key pair
/// * `signed_prekey` - Your signed prekey (private)
/// * `onetime_prekey` - Your one-time prekey (private, if used)
/// * `initiator_identity` - Initiator's identity public key
/// * `initiator_ephemeral` - Initiator's ephemeral public key
pub fn perform_as_responder(
identity_keypair: &StaticSecret,
signed_prekey: &StaticSecret,
onetime_prekey: Option<&StaticSecret>,
initiator_identity: &PublicKey,
initiator_ephemeral: &PublicKey,
) -> SecretKey {
// Perform X3DH to get shared secret
let shared_secret = InboxKeyExchange::responder(
identity_keypair,
signed_prekey,
onetime_prekey,
initiator_identity,
initiator_ephemeral,
);
Self::derive_keys_from_shared_secret(shared_secret)
}
/// 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(),
&[], // No salt - input already has high entropy
b"InboxV1-Seed",
)
.unwrap()
.finalize_fixed()
.into(); // digest uses an incompatible version of GenericArray. use array as intermediary
seed_key.into()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand_core::OsRng;
#[test]
fn test_inbox_encryption_initialization() {
let mut rng = OsRng;
// Alice (initiator) generates her identity key
let alice_identity = StaticSecret::random_from_rng(&mut rng);
let alice_identity_pub = PublicKey::from(&alice_identity);
// Bob (responder) generates his keys
let bob_identity = StaticSecret::random_from_rng(&mut rng);
let bob_signed_prekey = StaticSecret::random_from_rng(&mut rng);
let bob_signed_prekey_pub = PublicKey::from(&bob_signed_prekey);
// Create Bob's prekey bundle
let bob_bundle = PrekeyBundle {
identity_key: PublicKey::from(&bob_identity),
signed_prekey: bob_signed_prekey_pub,
signature: [0u8; 64],
onetime_prekey: None,
};
// Alice performs handshake
let (alice_secret, alice_ephemeral_pub) =
InboxHandshake::perform_as_initiator(&alice_identity, &bob_bundle, &mut rng);
// Bob performs handshake
let bob_secret = InboxHandshake::perform_as_responder(
&bob_identity,
&bob_signed_prekey,
None,
&alice_identity_pub,
&alice_ephemeral_pub,
);
// Both should derive the same root key
assert_eq!(alice_secret, bob_secret);
}
}

View File

@ -0,0 +1,247 @@
use chat_proto::logoschat::inbox::InboxV1Frame;
use hex;
use prost::Message;
use prost::bytes::Bytes;
use rand_core::OsRng;
use std::collections::HashMap;
use std::rc::Rc;
use crypto::{PrekeyBundle, SecretKey};
use crate::conversation::{ChatError, ConversationId, Convo, ConvoFactory, Id, PrivateV1Convo};
use crate::crypto::{Blake2b128, CopyBytes, Digest, PublicKey, StaticSecret};
use crate::identity::Identity;
use crate::inbox::handshake::InboxHandshake;
use crate::proto::{self};
use crate::types::ContentData;
pub struct Inbox {
ident: Rc<Identity>,
local_convo_id: String,
ephemeral_keys: HashMap<String, StaticSecret>,
}
impl<'a> std::fmt::Debug for Inbox {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Inbox")
.field("ident", &self.ident)
.field("convo_id", &self.local_convo_id)
.field(
"ephemeral_keys",
&format!("<{} keys>", self.ephemeral_keys.len()),
)
.finish()
}
}
impl Inbox {
pub fn new(ident: Rc<Identity>) -> Self {
let local_convo_id = ident.address();
Self {
ident,
local_convo_id,
ephemeral_keys: HashMap::<String, StaticSecret>::new(),
}
}
fn compute_local_convo_id(addr: &str) -> String {
let hash = Blake2b128::digest(format!("{}:{}:{}", "logoschat", "inboxV1", addr));
hex::encode(hash)
}
pub fn create_bundle(&mut self) -> PrekeyBundle {
let ephemeral = StaticSecret::random();
let signed_prekey = PublicKey::from(&ephemeral);
self.ephemeral_keys
.insert(hex::encode(signed_prekey.as_bytes()), ephemeral);
PrekeyBundle {
identity_key: self.ident.public_key(),
signed_prekey: signed_prekey,
signature: [0u8; 64],
onetime_prekey: None,
}
}
pub fn invite_to_private_convo(
&self,
remote_bundle: &PrekeyBundle,
initial_message: String,
) -> Result<(PrivateV1Convo, Vec<proto::EncryptedPayload>), ChatError> {
let mut rng = OsRng;
let (seed_key, ephemeral_pub) =
InboxHandshake::perform_as_initiator(&self.ident.secret(), remote_bundle, &mut rng);
let mut convo = PrivateV1Convo::new(seed_key);
let mut initial_payloads = convo.send_message(initial_message.as_bytes())?;
// Wrap First payload in Invite
if let Some(first_message) = initial_payloads.get_mut(0) {
let old = first_message.clone();
let frame = Self::wrap_in_invite(old);
// TODO: Encrypt frame
let ciphertext = frame.encode_to_vec();
let header = proto::InboxHeaderV1 {
initiator_static: self.ident.public_key().copy_to_bytes(),
initiator_ephemeral: ephemeral_pub.copy_to_bytes(),
responder_static: remote_bundle.identity_key.copy_to_bytes(),
responder_ephemeral: remote_bundle.signed_prekey.copy_to_bytes(),
};
let handshake = proto::InboxHandshakeV1 {
header: Some(header),
payload: Bytes::from_owner(ciphertext),
};
*first_message = proto::EncryptedPayload {
encryption: Some(proto::Encryption::InboxHandshake(handshake)),
};
}
Ok((convo, initial_payloads))
}
fn wrap_in_invite(payload: proto::EncryptedPayload) -> proto::InboxV1Frame {
let invite = proto::InvitePrivateV1 {
discriminator: "default".into(),
initial_message: Some(payload),
};
proto::InboxV1Frame {
frame_type: Some(
chat_proto::logoschat::inbox::inbox_v1_frame::FrameType::InvitePrivateV1(invite),
),
}
}
fn perform_handshake(
&self,
payload: proto::EncryptedPayload,
) -> 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)?;
let initator_static = PublicKey::from(
<[u8; 32]>::try_from(header.initiator_static.as_ref())
.map_err(|_| ChatError::BadBundleValue("wrong size - initator static".into()))?,
);
let initator_ephemeral = PublicKey::from(
<[u8; 32]>::try_from(header.initiator_ephemeral.as_ref())
.map_err(|_| ChatError::BadBundleValue("wrong size - initator ephemeral".into()))?,
);
let seed_key = InboxHandshake::perform_as_responder(
self.ident.secret(),
ephemeral_key,
None,
&initator_static,
&initator_ephemeral,
);
// TODO: Decrypt Content
let frame = InboxV1Frame::decode(handshake.payload)?;
Ok((seed_key, frame))
}
fn extract_payload(
payload: proto::EncryptedPayload,
) -> Result<proto::InboxHandshakeV1, ChatError> {
let Some(proto::Encryption::InboxHandshake(handshake)) = payload.encryption else {
return Err(ChatError::Protocol(
"Expected inboxhandshake encryption".into(),
));
};
Ok(handshake)
}
fn decrypt_frame(
enc_payload: proto::InboxHandshakeV1,
) -> Result<proto::InboxV1Frame, ChatError> {
let frame_bytes = enc_payload.payload;
// TODO: decrypt payload
let frame = proto::InboxV1Frame::decode(frame_bytes)?;
Ok(frame)
}
fn lookup_ephemeral_key(&self, key: &str) -> Result<&StaticSecret, ChatError> {
self.ephemeral_keys
.get(key)
.ok_or_else(|| return ChatError::UnknownEphemeralKey())
}
}
impl Id for Inbox {
fn id(&self) -> ConversationId {
&self.local_convo_id
}
}
impl ConvoFactory for Inbox {
fn handle_frame(
&mut self,
message: &[u8],
) -> Result<(Box<dyn Convo>, Vec<ContentData>), ChatError> {
if message.len() == 0 {
return Err(ChatError::Protocol("Example error".into()));
}
let ep = proto::EncryptedPayload::decode(message)?;
let (seed_key, frame) = self.perform_handshake(ep)?;
match frame.frame_type.unwrap() {
chat_proto::logoschat::inbox::inbox_v1_frame::FrameType::InvitePrivateV1(
_invite_private_v1,
) => {
let convo = PrivateV1Convo::new(seed_key);
// TODO: Update PrivateV1 Constructor with DR, initial_message
Ok((Box::new(convo), vec![]))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_invite_privatev1_roundtrip() {
let saro_ident = Identity::new();
let saro_inbox = Inbox::new(saro_ident.into());
let raya_ident = Identity::new();
let mut raya_inbox = Inbox::new(raya_ident.into());
let bundle = raya_inbox.create_bundle();
let (_, payloads) = saro_inbox
.invite_to_private_convo(&bundle, "hello".into())
.unwrap();
let encrypted_payload = payloads
.get(0)
.expect("RemoteInbox::invite_to_private_convo did not generate any payloads");
let mut buf = Vec::new();
encrypted_payload.encode(&mut buf).unwrap();
// Test handle_frame with valid payload
let result = raya_inbox.handle_frame(&buf);
assert!(
result.is_ok(),
"handle_frame should accept valid encrypted payloads"
);
}
}

View File

@ -1,15 +1,17 @@
mod api;
mod context;
mod conversation;
mod crypto;
mod errors;
mod identity;
mod inbox;
mod proto;
mod types;
mod utils;
pub use api::*;
#[cfg(test)]
use conversation::{ConversationStore, GroupTestConvo, PrivateV1Convo};
use inbox::Inbox;
mod tests {
use super::*;
@ -113,41 +115,4 @@ mod tests {
assert_eq!(result, -1, "Should return ERR_BAD_PTR for null pointer");
}
#[test]
fn convo_store_get() {
let mut store: ConversationStore = ConversationStore::new();
let new_convo = GroupTestConvo::new();
let convo_id = store.insert(new_convo);
let convo = store.get_mut(&convo_id).ok_or_else(|| 0);
convo.unwrap();
}
#[test]
fn multi_convo_example() {
let mut store: ConversationStore = ConversationStore::new();
let raya = Inbox::new("Raya");
let saro = PrivateV1Convo::new();
let pax = GroupTestConvo::new();
store.insert(raya);
store.insert(saro);
let convo_id = store.insert(pax);
for id in store.conversation_ids().collect::<Vec<_>>() {
let a = store.get_mut(&id).unwrap();
a.send_frame(b"test message").unwrap();
println!("Conversation ID: {} :: {:?}", id, a);
}
for id in store.conversation_ids().collect::<Vec<_>>() {
let a = store.get_mut(&id).unwrap();
let _ = a.handle_frame(&[0x1, 0x2]);
}
println!("ID -> {}", store.get(&convo_id).unwrap().id());
}
}

View File

@ -0,0 +1,5 @@
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::inbox::InboxV1Frame;
pub use chat_proto::logoschat::invite::InvitePrivateV1;

View File

@ -0,0 +1,13 @@
// This struct represents Outbound data.
// It wraps an encoded payload with a delivery address, so it can be handled by the delivery service.
pub struct PayloadData {
pub delivery_address: String,
pub data: Vec<u8>,
}
// This struct represents the result of processed inbound data.
// It wraps content payload with a conversation_id
pub struct ContentData {
pub conversation_id: String,
pub data: Vec<u8>,
}

View File

@ -0,0 +1,8 @@
use std::time::{SystemTime, UNIX_EPOCH};
pub fn timestamp_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as i64
}

14
crypto/Cargo.toml Normal file
View File

@ -0,0 +1,14 @@
[package]
name = "crypto"
version = "0.1.0"
edition = "2024"
[dependencies]
x25519-dalek = { version = "2.0.1", features = ["static_secrets"] }
hkdf = "0.12"
sha2 = "0.10"
rand_core = { version = "0.6", features = ["getrandom"] }
ed25519-dalek = "2.2.0"
xeddsa = "1.0.2"
zeroize = {version = "1.8.2", features= ["derive"]}
generic-array = "1.3.5"

31
crypto/src/keys.rs Normal file
View File

@ -0,0 +1,31 @@
use std::fmt::Debug;
pub use generic_array::{GenericArray, typenum::U32};
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Clone, Zeroize, ZeroizeOnDrop, PartialEq)]
pub struct SecretKey([u8; 32]);
impl SecretKey {
pub fn as_bytes(&self) -> &[u8] {
self.0.as_slice()
}
}
impl From<[u8; 32]> for SecretKey {
fn from(value: [u8; 32]) -> Self {
SecretKey(value)
}
}
impl From<GenericArray<u8, U32>> for SecretKey {
fn from(value: GenericArray<u8, U32>) -> Self {
SecretKey(value.into())
}
}
impl Debug for SecretKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_tuple("SecretKey").field(&"<32 bytes>").finish()
}
}

5
crypto/src/lib.rs Normal file
View File

@ -0,0 +1,5 @@
mod keys;
mod x3dh;
pub use keys::{GenericArray, SecretKey};
pub use x3dh::{DomainSeparator, PrekeyBundle, X3Handshake};

214
crypto/src/x3dh.rs Normal file
View File

@ -0,0 +1,214 @@
use std::marker::PhantomData;
use hkdf::Hkdf;
use rand_core::{CryptoRng, RngCore};
use sha2::Sha256;
use x25519_dalek::{PublicKey, SharedSecret, StaticSecret};
use crate::keys::SecretKey;
/// A prekey bundle containing the public keys needed to initiate an X3DH key exchange.
#[derive(Clone, Debug)]
pub struct PrekeyBundle {
pub identity_key: PublicKey,
pub signed_prekey: PublicKey,
pub signature: [u8; 64],
pub onetime_prekey: Option<PublicKey>,
}
pub trait DomainSeparator {
const BYTES: &'static [u8];
}
pub struct X3Handshake<D: DomainSeparator> {
_phantom: PhantomData<D>,
}
impl<D: DomainSeparator> X3Handshake<D> {
fn domain_separator() -> &'static [u8] {
D::BYTES
}
/// Derive the shared secret from DH outputs using HKDF-SHA256
fn derive_shared_secret(
dh1: &SharedSecret,
dh2: &SharedSecret,
dh3: &SharedSecret,
dh4: Option<&SharedSecret>,
) -> SecretKey {
// Concatenate all DH outputs
let mut km = Vec::new();
km.extend_from_slice(dh1.as_bytes());
km.extend_from_slice(dh2.as_bytes());
km.extend_from_slice(dh3.as_bytes());
if let Some(dh4) = dh4 {
km.extend_from_slice(dh4.as_bytes());
}
// Use HKDF to derive the shared secret
// Using "X3DH" as the info parameter as per Signal protocol
let hk = Hkdf::<Sha256>::new(None, &km);
let mut output = [0u8; 32];
hk.expand(Self::domain_separator(), &mut output)
.expect("32 bytes is valid HKDF output length");
// Move into SecretKey so it gets zeroized on drop.
output.into()
}
/// Perform X3DH key agreement as the initiator
///
/// # Arguments
/// * `identity_keypair` - Initiator's long-term identity key pair
/// * `recipient_bundle` - Recipient's prekey bundle
/// * `rng` - Cryptographically secure random number generator
///
/// # Returns
/// A tuple of (shared secret bytes, ephemeral public key)
pub fn initator<R: RngCore + CryptoRng>(
identity_keypair: &StaticSecret,
recipient_bundle: &PrekeyBundle,
rng: &mut R,
) -> (SecretKey, PublicKey) {
// Generate ephemeral key for this handshake (using StaticSecret for multiple DH operations)
let ephemeral_secret = StaticSecret::random_from_rng(rng);
let ephemeral_public = PublicKey::from(&ephemeral_secret);
// Perform the 4 Diffie-Hellman operations
let dh1 = identity_keypair.diffie_hellman(&recipient_bundle.signed_prekey);
let dh2 = ephemeral_secret.diffie_hellman(&recipient_bundle.identity_key);
let dh3 = ephemeral_secret.diffie_hellman(&recipient_bundle.signed_prekey);
let dh4 = recipient_bundle
.onetime_prekey
.as_ref()
.map(|opk| ephemeral_secret.diffie_hellman(opk));
// Combine all DH outputs into shared secret
let shared_secret = Self::derive_shared_secret(&dh1, &dh2, &dh3, dh4.as_ref());
(shared_secret, ephemeral_public)
}
/// Perform X3DH key agreement as the responder
///
/// # Arguments
/// * `identity_keypair` - Responder's long-term identity key pair
/// * `signed_prekey` - Responder's signed prekey (private)
/// * `onetime_prekey` - Responder's one-time prekey (private, if used)
/// * `initiator_identity` - Initiator's identity public key
/// * `initiator_ephemeral` - Initiator's ephemeral public key
///
/// # Returns
/// The derived shared secret bytes
pub fn responder(
identity_keypair: &StaticSecret,
signed_prekey: &StaticSecret,
onetime_prekey: Option<&StaticSecret>,
initiator_identity: &PublicKey,
initiator_ephemeral: &PublicKey,
) -> SecretKey {
let dh1 = signed_prekey.diffie_hellman(initiator_identity);
let dh2 = identity_keypair.diffie_hellman(initiator_ephemeral);
let dh3 = signed_prekey.diffie_hellman(initiator_ephemeral);
let dh4 = onetime_prekey.map(|opk| opk.diffie_hellman(initiator_ephemeral));
// Combine all DH outputs into shared secret
Self::derive_shared_secret(&dh1, &dh2, &dh3, dh4.as_ref())
}
}
#[cfg(test)]
mod tests {
use super::*;
use rand_core::OsRng;
pub struct TestProtocol;
impl DomainSeparator for TestProtocol {
const BYTES: &'static [u8] = b"x3dh_tests_v1";
}
type X3DH = X3Handshake<TestProtocol>;
#[test]
fn test_x3dh_with_onetime_key() {
let mut rng = OsRng;
// Alice (initiator) generates her identity key
let alice_identity = StaticSecret::random_from_rng(&mut rng);
let alice_identity_pub = PublicKey::from(&alice_identity);
// Bob (responder) generates his keys
let bob_identity = StaticSecret::random_from_rng(&mut rng);
let bob_identity_pub = PublicKey::from(&bob_identity);
let bob_signed_prekey = StaticSecret::random_from_rng(&mut rng);
let bob_signed_prekey_pub = PublicKey::from(&bob_signed_prekey);
let bob_onetime_prekey = StaticSecret::random_from_rng(&mut rng);
let bob_onetime_prekey_pub = PublicKey::from(&bob_onetime_prekey);
// Create Bob's prekey bundle (with one-time prekey)
let bob_bundle = PrekeyBundle {
identity_key: bob_identity_pub,
signed_prekey: bob_signed_prekey_pub,
signature: [0u8; 64], // Placeholder for signature
onetime_prekey: Some(bob_onetime_prekey_pub),
};
// Alice performs X3DH
let (alice_shared_secret, alice_ephemeral_pub) =
X3DH::initator(&alice_identity, &bob_bundle, &mut rng);
// Bob performs X3DH
let bob_shared_secret = X3DH::responder(
&bob_identity,
&bob_signed_prekey,
Some(&bob_onetime_prekey),
&alice_identity_pub,
&alice_ephemeral_pub,
);
// Both should derive the same shared secret
assert_eq!(alice_shared_secret, bob_shared_secret);
}
#[test]
fn test_x3dh_without_onetime_key() {
let mut rng = OsRng;
// Alice (initiator) generates her identity key
let alice_identity = StaticSecret::random_from_rng(&mut rng);
let alice_identity_pub = PublicKey::from(&alice_identity);
// Bob (responder) generates his keys
let bob_identity = StaticSecret::random_from_rng(&mut rng);
let bob_identity_pub = PublicKey::from(&bob_identity);
let bob_signed_prekey = StaticSecret::random_from_rng(&mut rng);
let bob_signed_prekey_pub = PublicKey::from(&bob_signed_prekey);
// Create Bob's prekey bundle (without one-time prekey)
let bob_bundle = PrekeyBundle {
identity_key: bob_identity_pub,
signed_prekey: bob_signed_prekey_pub,
signature: [0u8; 64], // Placeholder for signature
onetime_prekey: None,
};
// Alice performs X3DH
let (alice_shared_secret, alice_ephemeral_pub) =
X3DH::initator(&alice_identity, &bob_bundle, &mut rng);
// Bob performs X3DH
let bob_shared_secret = X3DH::responder(
&bob_identity,
&bob_signed_prekey,
None,
&alice_identity_pub,
&alice_ephemeral_pub,
);
// Both should derive the same shared secret
assert_eq!(alice_shared_secret, bob_shared_secret);
}
}

3
rust_toolchain.toml Normal file
View File

@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]