use crate::canonical_json;
use crate::error::MessengerError;
use crate::ids::{DeviceId, RequestId, UserId};
use crate::store::CryptoStore;
use crate::wire::OutgoingRequest;
use mail4agent_vodozemac::olm::{
Account, AccountPickle, IdentityKeys, InboundCreationResult, PreKeyMessage, Session, SessionConfig,
SessionCreationError,
};
use mail4agent_vodozemac::Curve25519PublicKey;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
const SUPPORTED_ALGORITHMS: [&str; 2] = ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"];
const SIGNED_CURVE25519: &str = "signed_curve25519";
#[derive(Serialize, Deserialize)]
struct AccountRecord {
account: AccountPickle,
device_keys_published: bool,
}
fn target_one_time_key_count(max_number_of_one_time_keys: usize) -> usize {
max_number_of_one_time_keys / 2
}
pub struct OlmAccountState {
account: Account,
device_keys_published: bool,
}
impl OlmAccountState {
pub fn load_or_create<S: CryptoStore>(store: &mut S) -> Result<Self, MessengerError> {
if let Some(bytes) = store.account()? {
let record: AccountRecord = serde_json::from_slice(bytes)
.map_err(|source| MessengerError::Crypto(format!("decode pickled Olm account: {source}")))?;
return Ok(Self {
account: Account::from_pickle(record.account),
device_keys_published: record.device_keys_published,
});
}
let state = Self { account: Account::new(), device_keys_published: false };
state.save(store)?;
Ok(state)
}
pub fn identity_keys(&self) -> IdentityKeys {
self.account.identity_keys()
}
pub fn one_time_key_target(&self) -> usize {
target_one_time_key_count(self.account.max_number_of_one_time_keys())
}
pub fn pending_one_time_key_count(&self) -> usize {
self.account.one_time_keys().len()
}
pub fn pending_fallback_key(&self) -> Option<Curve25519PublicKey> {
self.account.fallback_key().values().next().copied()
}
pub fn device_keys_json(&self, user_id: &UserId, device_id: &DeviceId) -> Result<Value, MessengerError> {
let identity = self.account.identity_keys();
let mut keys = Map::new();
keys.insert(
format!("curve25519:{}", device_id.as_str()),
Value::String(identity.curve25519.to_base64()),
);
keys.insert(
format!("ed25519:{}", device_id.as_str()),
Value::String(identity.ed25519.to_base64()),
);
let mut value = serde_json::json!({
"algorithms": SUPPORTED_ALGORITHMS,
"device_id": device_id.as_str(),
"user_id": user_id.as_str(),
});
if let Some(obj) = value.as_object_mut() {
obj.insert("keys".to_string(), Value::Object(keys));
}
let key_id = format!("ed25519:{}", device_id.as_str());
canonical_json::sign_json(&mut value, user_id.as_str(), &key_id, |bytes| self.account.sign(bytes))?;
Ok(value)
}
fn signed_one_time_key_json(
&self,
user_id: &UserId,
device_id: &DeviceId,
public_key: Curve25519PublicKey,
fallback: bool,
) -> Result<Value, MessengerError> {
let mut value = serde_json::json!({ "key": public_key.to_base64() });
if fallback {
if let Some(obj) = value.as_object_mut() {
obj.insert("fallback".to_string(), Value::Bool(true));
}
}
let key_id = format!("ed25519:{}", device_id.as_str());
canonical_json::sign_json(&mut value, user_id.as_str(), &key_id, |bytes| self.account.sign(bytes))?;
Ok(value)
}
pub fn on_sync_counts<S: CryptoStore>(
&mut self,
store: &mut S,
published_signed_curve25519_count: u64,
unused_fallback_key_types: &[String],
) -> Result<(), MessengerError> {
let mut mutated = false;
if self.account.one_time_keys().is_empty() {
let target = self.one_time_key_target();
let published = published_signed_curve25519_count as usize;
if published < target {
self.account.generate_one_time_keys(target - published);
mutated = true;
}
}
let fallback_still_available =
unused_fallback_key_types.iter().any(|kind| kind == SIGNED_CURVE25519);
if !fallback_still_available && self.account.fallback_key().is_empty() {
self.account.generate_fallback_key();
mutated = true;
}
if mutated {
self.save(store)?;
}
Ok(())
}
pub fn keys_upload_request(
&self,
id: RequestId,
user_id: &UserId,
device_id: &DeviceId,
) -> Result<Option<OutgoingRequest>, MessengerError> {
let one_time_keys = self.account.one_time_keys();
let fallback_key = self.account.fallback_key();
if self.device_keys_published && one_time_keys.is_empty() && fallback_key.is_empty() {
return Ok(None);
}
let mut body = Map::new();
if !self.device_keys_published {
body.insert("device_keys".to_string(), self.device_keys_json(user_id, device_id)?);
}
if !one_time_keys.is_empty() {
let mut otk_map = Map::new();
for (key_id, public_key) in &one_time_keys {
let signed = self.signed_one_time_key_json(user_id, device_id, *public_key, false)?;
otk_map.insert(format!("{SIGNED_CURVE25519}:{}", key_id.to_base64()), signed);
}
body.insert("one_time_keys".to_string(), Value::Object(otk_map));
}
if !fallback_key.is_empty() {
let mut fallback_map = Map::new();
for (key_id, public_key) in &fallback_key {
let signed = self.signed_one_time_key_json(user_id, device_id, *public_key, true)?;
fallback_map.insert(format!("{SIGNED_CURVE25519}:{}", key_id.to_base64()), signed);
}
body.insert("fallback_keys".to_string(), Value::Object(fallback_map));
}
Ok(Some(OutgoingRequest::keys_upload(id, Value::Object(body))))
}
pub fn on_keys_upload_response<S: CryptoStore>(&mut self, store: &mut S) -> Result<(), MessengerError> {
self.account.mark_keys_as_published();
self.device_keys_published = true;
self.save(store)
}
pub fn create_outbound_session(
&self,
session_config: SessionConfig,
identity_key: Curve25519PublicKey,
one_time_key: Curve25519PublicKey,
) -> Result<Session, SessionCreationError> {
self.account.create_outbound_session(session_config, identity_key, one_time_key)
}
pub fn create_inbound_session<S: CryptoStore>(
&mut self,
store: &mut S,
session_config: SessionConfig,
their_identity_key: Curve25519PublicKey,
pre_key_message: &PreKeyMessage,
) -> Result<InboundCreationResult, MessengerError> {
let result = self
.account
.create_inbound_session(session_config, their_identity_key, pre_key_message)
.map_err(|source| MessengerError::Crypto(format!("create inbound Olm session: {source}")))?;
self.save(store)?;
Ok(result)
}
fn save<S: CryptoStore>(&self, store: &mut S) -> Result<(), MessengerError> {
let record = AccountRecord { account: self.account.pickle(), device_keys_published: self.device_keys_published };
let bytes = serde_json::to_vec(&record)
.map_err(|source| MessengerError::Crypto(format!("encode pickled Olm account: {source}")))?;
store.save_account(bytes)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ids::DeviceId;
use crate::store::{InsecurePlainCodecForTests, RecordCodec, Store};
fn device_id() -> DeviceId {
DeviceId::parse("DEV1").expect("valid device id")
}
fn user_id() -> UserId {
UserId::parse("@alice:example.org").expect("valid user id")
}
fn new_store() -> Store<InsecurePlainCodecForTests> {
Store::new(device_id(), InsecurePlainCodecForTests)
}
fn fallback_present() -> Vec<String> {
vec![SIGNED_CURVE25519.to_string()]
}
fn drain<C: RecordCodec>(store: &mut Store<C>) {
if let Some(batch) = store.take_flush_batch() {
store.ack_flush(batch.id);
}
}
#[test]
fn ensure_account_generates_once_and_reuses_on_second_open() {
let mut store = new_store();
let first = OlmAccountState::load_or_create(&mut store)
.expect("first open creates a fresh account");
let first_identity = first.identity_keys();
let batch = store.take_flush_batch().expect("creating the account dirtied the store");
store.ack_flush(batch.id);
let mut reloaded = Store::load(batch.records, InsecurePlainCodecForTests, device_id())
.expect("reload from the flushed records");
let second = OlmAccountState::load_or_create(&mut reloaded)
.expect("second open reuses the persisted account");
assert_eq!(second.identity_keys(), first_identity, "the same identity is reused, never re-derived");
assert!(
reloaded.take_flush_batch().is_none(),
"reusing an existing account must not dirty the store again"
);
}
#[test]
fn ensure_account_tops_up_otks_below_watermark() {
let mut store = new_store();
let mut state =
OlmAccountState::load_or_create(&mut store).expect("create");
drain(&mut store);
let target = state.one_time_key_target();
assert!(target > 0, "a freshly created account always has room for at least one key");
let published = target / 2;
state
.on_sync_counts(&mut store, published as u64, &fallback_present())
.expect("tops up below the watermark");
assert_eq!(state.pending_one_time_key_count(), target - published, "tops up exactly to the watermark");
assert!(
store.take_flush_batch().is_some(),
"a top-up must be persisted before it can be uploaded"
);
let mut store2 = new_store();
let mut state2 =
OlmAccountState::load_or_create(&mut store2).expect("create");
drain(&mut store2);
state2
.on_sync_counts(&mut store2, target as u64, &fallback_present())
.expect("no top-up needed at the watermark");
assert_eq!(state2.pending_one_time_key_count(), 0);
assert!(store2.take_flush_batch().is_none(), "nothing generated, nothing to persist");
}
#[test]
fn ensure_account_uploads_fallback_key_when_unused_types_reports_empty() {
let mut store = new_store();
let mut state =
OlmAccountState::load_or_create(&mut store).expect("create");
drain(&mut store);
let target = state.one_time_key_target() as u64;
state.on_sync_counts(&mut store, target, &[]).expect("generates a fallback key");
assert_eq!(state.pending_one_time_key_count(), 0, "OTKs are already at the watermark");
assert!(state.pending_fallback_key().is_some(), "a fresh fallback key was generated");
let batch = store.take_flush_batch().expect("the fallback key must be persisted before upload");
store.ack_flush(batch.id);
let pending_before = state.pending_fallback_key();
state.on_sync_counts(&mut store, target, &[]).expect("retry is a no-op");
assert_eq!(state.pending_fallback_key(), pending_before, "retry keeps the same fallback key");
assert!(store.take_flush_batch().is_none(), "nothing new to persist on retry");
}
#[test]
fn device_keys_are_signed_and_verify() {
let mut store = new_store();
let state =
OlmAccountState::load_or_create(&mut store).expect("create");
let value = state.device_keys_json(&user_id(), &device_id()).expect("signs device keys");
let identity = state.identity_keys();
let key_id = format!("ed25519:{}", device_id().as_str());
canonical_json::verify_json_signature(&value, user_id().as_str(), &key_id, &identity.ed25519)
.expect("device_keys must verify against the device's own ed25519 key");
}
#[test]
fn retry_resends_the_same_unpublished_keys() {
let mut store = new_store();
let mut state =
OlmAccountState::load_or_create(&mut store).expect("create");
drain(&mut store);
state.on_sync_counts(&mut store, 0, &[]).expect("first top-up plus fallback");
let batch = store.take_flush_batch().expect("generation is persisted before any upload attempt");
store.ack_flush(batch.id);
let first_request = state
.keys_upload_request(RequestId::next(0), &user_id(), &device_id())
.expect("builds a request")
.expect("something to upload");
state.on_sync_counts(&mut store, 0, &[]).expect("retry does not regenerate");
assert!(store.take_flush_batch().is_none(), "nothing new was generated, nothing new to persist");
let second_request = state
.keys_upload_request(RequestId::next(1), &user_id(), &device_id())
.expect("builds a request")
.expect("still something to upload");
assert_eq!(first_request.body, second_request.body, "a retry resends byte-identical key material");
}
#[test]
fn upload_response_marks_keys_published() {
let mut store = new_store();
let mut state =
OlmAccountState::load_or_create(&mut store).expect("create");
drain(&mut store);
state.on_sync_counts(&mut store, 0, &[]).expect("top up");
drain(&mut store);
let before = state
.keys_upload_request(RequestId::next(0), &user_id(), &device_id())
.expect("builds a request")
.expect("device keys, OTKs, and a fallback key all await upload on the first attempt");
let body = before.body.expect("keys_upload always carries a body");
assert!(body.get("device_keys").is_some(), "the first upload includes device_keys");
state.on_keys_upload_response(&mut store).expect("mark published");
let batch = store.take_flush_batch().expect("marking keys published is itself persisted");
store.ack_flush(batch.id);
let after = state
.keys_upload_request(RequestId::next(1), &user_id(), &device_id())
.expect("builds a request");
assert!(after.is_none(), "nothing is left to upload once everything is marked published");
}
}