Skip to content

Commit 02cc224

Browse files
committed
feat: implement double ratchet, x3dh foundations, and desktop UI refactoring
1 parent 9d8b9be commit 02cc224

33 files changed

+3256
-1213
lines changed

docs/roadmap.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@
4444
- [x] Inbox Zero: TTL auto-deletion (backend + UI).
4545
- [ ] **Advanced Cryptography (Double Ratchet)**
4646
- [ ] Implement **X3DH** (Extended Triple Diffie-Hellman) for initial key exchange.
47-
- [ ] Implement **Double Ratchet** session management (root key, chain keys).
48-
- [ ] Store session states securely in SQLite (using `sqlcipher` or application-level encryption).
47+
- [x] Implement **Double Ratchet** session management (root key, chain keys).
48+
- [x] Store session states securely in SQLite (using `sqlcipher` or application-level encryption).
4949
- [ ] Header Encryption (hide routing metadata).
5050
- [ ] **Inbox Zero Logic**
5151
- [ ] **Semantic Actions:**

ratatoskr-core/Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ serde = { version = "1.0", features = ["derive"] }
99
serde_json = "1.0"
1010
libp2p = { version = "0.53", features = ["tokio", "gossipsub", "mdns", "noise", "yamux", "tcp", "dns", "websocket", "macros", "kad"] }
1111
ed25519-dalek = "2.1"
12-
x25519-dalek = { version = "2.0", features = ["static_secrets"] }
12+
x25519-dalek = { version = "2.0", features = ["serde", "static_secrets"] }
1313
rand = "0.8"
1414
aes-gcm = "0.10"
1515
sha2 = "0.10"
@@ -18,6 +18,8 @@ thiserror = "1.0"
1818
log = "0.4"
1919
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite"] }
2020
bip39 = "2.2.2"
21+
hkdf = "0.12.4"
22+
hmac = "0.12.1"
2123

2224
[dev-dependencies]
23-
tempfile = "3.10"
25+
tempfile = "3.10"
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
CREATE TABLE IF NOT EXISTS sessions (
2+
did TEXT PRIMARY KEY,
3+
session_data BLOB NOT NULL,
4+
updated_at INTEGER NOT NULL
5+
);
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
CREATE TABLE IF NOT EXISTS my_signed_prekeys (
2+
id INTEGER PRIMARY KEY,
3+
key_data BLOB NOT NULL, -- Serialized KeyPair or Secret
4+
created_at INTEGER NOT NULL
5+
);
6+
7+
CREATE TABLE IF NOT EXISTS my_onetime_prekeys (
8+
id INTEGER PRIMARY KEY AUTOINCREMENT,
9+
key_data BLOB NOT NULL,
10+
published INTEGER DEFAULT 0 -- 0=No, 1=Yes
11+
);

ratatoskr-core/src/key_vault.rs

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use rand::RngCore;
55
use std::fs;
66
use std::path::Path;
77
use thiserror::Error;
8+
use x25519_dalek::StaticSecret;
89

910
#[derive(Error, Debug)]
1011
pub enum VaultError {
@@ -17,7 +18,8 @@ pub enum VaultError {
1718
}
1819

1920
pub struct KeyVault {
20-
keypair: SigningKey,
21+
pub signing_key: SigningKey,
22+
pub dh_identity: StaticSecret,
2123
}
2224

2325
impl KeyVault {
@@ -42,50 +44,77 @@ impl KeyVault {
4244
let mnemonic = Mnemonic::parse_in(Language::English, phrase)?;
4345
let seed = mnemonic.to_seed(""); // No password for seed
4446

45-
// We use the first 32 bytes of the seed as the Ed25519 secret key.
4647
// BIP-39 seeds are 64 bytes.
47-
let mut secret_bytes = [0u8; 32];
48-
secret_bytes.copy_from_slice(&seed[0..32]);
48+
// First 32 bytes -> Ed25519 Signing Key
49+
let mut signing_bytes = [0u8; 32];
50+
signing_bytes.copy_from_slice(&seed[0..32]);
51+
let signing_key = SigningKey::from_bytes(&signing_bytes);
52+
53+
// Second 32 bytes -> X25519 Identity Key
54+
let mut dh_bytes = [0u8; 32];
55+
dh_bytes.copy_from_slice(&seed[32..64]);
56+
let dh_identity = StaticSecret::from(dh_bytes);
4957

50-
let signing_key = SigningKey::from_bytes(&secret_bytes);
5158
Ok(Self {
52-
keypair: signing_key,
59+
signing_key,
60+
dh_identity,
5361
})
5462
}
5563

5664
/// Legacy generation (random bytes, no mnemonic recovery possible unless saved)
5765
pub fn generate_random() -> Self {
58-
let mut secret_bytes = [0u8; 32];
59-
OsRng.fill_bytes(&mut secret_bytes);
60-
let signing_key = SigningKey::from_bytes(&secret_bytes);
66+
let mut signing_bytes = [0u8; 32];
67+
OsRng.fill_bytes(&mut signing_bytes);
68+
let signing_key = SigningKey::from_bytes(&signing_bytes);
69+
70+
let mut dh_bytes = [0u8; 32];
71+
OsRng.fill_bytes(&mut dh_bytes);
72+
let dh_identity = StaticSecret::from(dh_bytes);
73+
6174
Self {
62-
keypair: signing_key,
75+
signing_key,
76+
dh_identity,
6377
}
6478
}
6579

6680
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<(), VaultError> {
67-
let bytes = self.keypair.to_bytes();
81+
// We save 64 bytes: 32 bytes Ed25519 + 32 bytes X25519
82+
let mut bytes = Vec::with_capacity(64);
83+
bytes.extend_from_slice(&self.signing_key.to_bytes());
84+
bytes.extend_from_slice(&self.dh_identity.to_bytes());
6885
fs::write(path, bytes)?;
6986
Ok(())
7087
}
7188

7289
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self, VaultError> {
7390
let bytes = fs::read(path)?;
74-
let secret_bytes: [u8; 32] = bytes
75-
.try_into()
76-
.map_err(|_| VaultError::Crypto("Invalid key length".into()))?;
91+
if bytes.len() != 64 {
92+
return Err(VaultError::Crypto(
93+
"Invalid key file length. Expected 64 bytes.".into(),
94+
));
95+
}
96+
97+
let signing_bytes: [u8; 32] = bytes[0..32].try_into().unwrap();
98+
let dh_bytes: [u8; 32] = bytes[32..64].try_into().unwrap();
99+
100+
let signing_key = SigningKey::from_bytes(&signing_bytes);
101+
let dh_identity = StaticSecret::from(dh_bytes);
77102

78-
let signing_key = SigningKey::from_bytes(&secret_bytes);
79103
Ok(Self {
80-
keypair: signing_key,
104+
signing_key,
105+
dh_identity,
81106
})
82107
}
83108

84109
pub fn public_key_hex(&self) -> String {
85-
hex::encode(self.keypair.verifying_key().as_bytes())
110+
hex::encode(self.signing_key.verifying_key().as_bytes())
111+
}
112+
113+
pub fn dh_public_key_hex(&self) -> String {
114+
hex::encode(x25519_dalek::PublicKey::from(&self.dh_identity).as_bytes())
86115
}
87116

88117
pub fn signing_key(&self) -> &SigningKey {
89-
&self.keypair
118+
&self.signing_key
90119
}
91120
}

ratatoskr-core/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
pub mod access_control;
22
pub mod crypto;
33
pub mod key_vault;
4+
pub mod messaging;
45
pub mod models;
56
pub mod network;
7+
pub mod ratchet;
68
pub mod storage;
9+
pub mod x3dh;
710

811
pub fn init() -> String {
912
"Ratatoskr Core: Ready".to_string()

ratatoskr-core/src/messaging.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
use crate::key_vault::KeyVault;
2+
use crate::models::EncryptedMessage;
3+
use crate::storage::Storage;
4+
use crate::x3dh::{self, PreKeyBundle};
5+
use ed25519_dalek::VerifyingKey;
6+
use x25519_dalek::PublicKey;
7+
8+
pub struct MessagingService<'a> {
9+
storage: &'a Storage,
10+
vault: &'a KeyVault,
11+
}
12+
13+
impl<'a> MessagingService<'a> {
14+
pub fn new(storage: &'a Storage, vault: &'a KeyVault) -> Self {
15+
Self { storage, vault }
16+
}
17+
18+
/// Encrypts a message for a recipient.
19+
pub async fn encrypt_message(
20+
&self,
21+
recipient_did: &str,
22+
recipient_ed25519_key: Option<&VerifyingKey>, // Required if new session
23+
recipient_bundle: Option<&PreKeyBundle>, // Required if new session
24+
plaintext: &[u8],
25+
) -> Result<EncryptedMessage, String> {
26+
// 1. Load Session
27+
let session_opt = self
28+
.storage
29+
.load_session(recipient_did)
30+
.await
31+
.map_err(|e| e.to_string())?;
32+
33+
if let Some(mut session) = session_opt {
34+
// Existing Session
35+
let (header, ciphertext) = session.encrypt(plaintext).map_err(|e| e.to_string())?;
36+
37+
// Save Session
38+
self.storage
39+
.save_session(recipient_did, &session)
40+
.await
41+
.map_err(|e| e.to_string())?;
42+
43+
Ok(EncryptedMessage::Whisper { header, ciphertext })
44+
} else {
45+
// New Session - Needs Bundle
46+
let bundle = recipient_bundle.ok_or("No session and no PreKeyBundle provided")?;
47+
let recipient_vk =
48+
recipient_ed25519_key.ok_or("No session and no Recipient Identity Key provided")?;
49+
50+
let result = x3dh::initialize_alice(
51+
&self.vault.dh_identity,
52+
PublicKey::from(&self.vault.dh_identity),
53+
bundle,
54+
recipient_vk,
55+
plaintext,
56+
)?;
57+
58+
// Save Session
59+
self.storage
60+
.save_session(recipient_did, &result.session)
61+
.await
62+
.map_err(|e| e.to_string())?;
63+
64+
Ok(EncryptedMessage::X3dhInit {
65+
sender_identity_key: PublicKey::from(&self.vault.dh_identity),
66+
ephemeral_key: result.ephemeral_key,
67+
header: result.initial_header,
68+
ciphertext: result.initial_ciphertext,
69+
used_spk: bundle.signed_prekey, // Tell Bob which SPK we used
70+
used_opk: result.used_opk, // Tell Bob which OPK we used
71+
})
72+
}
73+
}
74+
75+
/// Decrypts an incoming message
76+
pub async fn decrypt_message(
77+
&self,
78+
sender_did: &str,
79+
message: EncryptedMessage,
80+
) -> Result<Vec<u8>, String> {
81+
match message {
82+
EncryptedMessage::Whisper { header, ciphertext } => {
83+
let mut session = self
84+
.storage
85+
.load_session(sender_did)
86+
.await
87+
.map_err(|e| e.to_string())?
88+
.ok_or("No session found for sender")?;
89+
90+
let plaintext = session.decrypt(&header, &ciphertext)?;
91+
92+
self.storage
93+
.save_session(sender_did, &session)
94+
.await
95+
.map_err(|e| e.to_string())?;
96+
Ok(plaintext)
97+
}
98+
EncryptedMessage::X3dhInit {
99+
sender_identity_key,
100+
ephemeral_key,
101+
header,
102+
ciphertext,
103+
used_spk,
104+
used_opk,
105+
} => {
106+
// Bob receives init
107+
108+
// 1. Fetch Secrets
109+
let spk_secret = self
110+
.storage
111+
.get_signed_prekey_secret(&used_spk)
112+
.await
113+
.map_err(|e| e.to_string())?
114+
.ok_or("Signed PreKey not found")?;
115+
116+
let mut opk_secret = None;
117+
if let Some(opk_pub) = used_opk {
118+
let secret = self
119+
.storage
120+
.get_onetime_prekey_secret(&opk_pub)
121+
.await
122+
.map_err(|e| e.to_string())?
123+
.ok_or("One-time PreKey not found")?;
124+
opk_secret = Some(secret);
125+
}
126+
127+
// 2. Initialize
128+
let (session, plaintext) = x3dh::initialize_bob(
129+
&self.vault.dh_identity,
130+
&spk_secret,
131+
opk_secret.as_ref(),
132+
sender_identity_key,
133+
ephemeral_key,
134+
&header,
135+
&ciphertext,
136+
)?;
137+
138+
// 3. Save Session
139+
self.storage
140+
.save_session(sender_did, &session)
141+
.await
142+
.map_err(|e| e.to_string())?;
143+
144+
// 4. Delete consumed OPK
145+
if let Some(opk_pub) = used_opk {
146+
self.storage
147+
.delete_onetime_prekey(&opk_pub)
148+
.await
149+
.map_err(|e| e.to_string())?;
150+
}
151+
152+
Ok(plaintext)
153+
}
154+
}
155+
}
156+
}

ratatoskr-core/src/models.rs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use serde::{Deserialize, Serialize};
2+
use x25519_dalek::PublicKey;
23

34
#[derive(Serialize, Deserialize, Debug, Clone)]
45
pub enum SosType {
@@ -55,7 +56,7 @@ pub struct ChatMessage {
5556
pub recipient_did: String,
5657
pub msg_type: MessageType,
5758
pub status: MessageStatus,
58-
pub content: Vec<u8>, // Encrypted blob
59+
pub content: Vec<u8>, // Encrypted blob or Plaintext (depends on context, usually Plaintext in DB)
5960
pub timestamp: u64,
6061
pub ttl: Option<u64>, // Optional expiry timestamp
6162
pub schema_id: String, // For protocol extensibility
@@ -68,3 +69,31 @@ pub struct Message {
6869
pub content: String,
6970
pub timestamp: u64,
7071
}
72+
73+
// --- Protocol Models ---
74+
75+
#[derive(Clone, Debug, Serialize, Deserialize)]
76+
pub struct RatchetHeader {
77+
pub dh_pub: PublicKey,
78+
pub n: u32, // Number of the message in the sending chain
79+
pub pn: u32, // Number of the previous sending chain
80+
}
81+
82+
#[derive(Serialize, Deserialize, Debug)]
83+
pub enum EncryptedMessage {
84+
/// Initial X3DH handshake message + first payload
85+
X3dhInit {
86+
sender_identity_key: PublicKey, // IK_A
87+
ephemeral_key: PublicKey, // EK_A
88+
header: RatchetHeader,
89+
ciphertext: Vec<u8>, // Initial message
90+
used_spk: PublicKey, // The SPK used (so Bob knows which secret to pick)
91+
used_opk: Option<PublicKey>, // The OPK used (if any)
92+
},
93+
94+
/// Subsequent Double Ratchet message
95+
Whisper {
96+
header: RatchetHeader,
97+
ciphertext: Vec<u8>,
98+
},
99+
}

0 commit comments

Comments
 (0)