Secret storage, and a dehydrated device to put in it
#193 asked for device dehydration and needed something that did not exist: somewhere a new login can read a secret from. moho had no secret storage at all - `setup_recovery_key` makes a backup key, keeps it in the local crypto store and hands the base58 over to be written down, and cross-signing uploads public keys while the private ones stay local. Nothing wrote `m.secret_storage.*`. So this is two pieces. `ssss.rs` is the account's safe on the homeserver: the key description, the pointer at the current key, and one event per secret holding ciphertext keyed by which key encrypted it. The cryptography is the crate's - PBKDF2, base58, AES-CTR with HMAC - and what is here is the account data around it, which is the half the crate leaves to the client. A secret is written alongside whatever is already there rather than over it, because a secret may be encrypted under several keys and dropping the others would lock out every client holding one. `dehydration.rs` is the device. It is uploaded encrypted, sits on the server being sent room keys like any other device, and is never used to read anything; a new login takes it down, collects what it was sent, and puts a fresh one back. A fresh one always, because the old one's one-time keys are spent and a device left in place stops being able to receive. The pickle key is kept in the safe under `org.matrix.msc3814`, which is where Element puts it, so a device set up in either is usable by the other. Two decisions worth naming: - The recovery code is never written to disk. An earlier draft of this had a `dehydration_unlock` field on the account so reconnects could rehydrate unattended, which would have put the key to the entire safe in a config file to save typing it once. Instead the pickle key is cached by the crypto store after the first unlock - which is what the crate's own `save_dehydrated_device_pickle_key` is for - and the code is asked for only on a login that has never run. - Collecting is best-effort and never fatal. A dehydrated device that cannot be fetched costs the keys it was holding; failing the connection over it would cost the whole account. A test caught a real bug on the way: the pickle key goes into account data as base64 text, and reading it back through `from_slice` hands 43 bytes to something that wants 32. Then the fix was wrong too - vodozemac encodes with STANDARD_NO_PAD, so decoding as padded base64 fails on every key this daemon writes. Both spellings are accepted now, since the secret is shared with whatever client set it up. Closes #193. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -220,6 +220,15 @@ pub struct MatrixAccountConfig {
|
||||
/// and turns off again if their account goes quiet.
|
||||
#[serde(default)]
|
||||
pub prefer_sliding_sync: bool,
|
||||
/// Whether this account keeps a dehydrated device - see
|
||||
/// backend/matrix/dehydration.rs.
|
||||
///
|
||||
/// Only the flag. The key that opens it lives in the account's secret
|
||||
/// storage on the server and is cached by the crypto store, never here:
|
||||
/// writing the recovery code into a config file would put the key to the
|
||||
/// whole safe on disk to save typing it once.
|
||||
#[serde(default)]
|
||||
pub dehydration_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
/// A media server to hold calls on, where the homeserver names none.
|
||||
@@ -641,6 +650,19 @@ impl AccountStore {
|
||||
///
|
||||
/// Empty clears it, which puts calls back on the mesh between the people
|
||||
/// in the room - that being what works with no infrastructure at all.
|
||||
/// Remembers whether this account keeps a dehydrated device.
|
||||
pub fn set_matrix_dehydration(&self, account_id: &str, enabled: bool) -> Result<bool> {
|
||||
let mut matrix = self.matrix.lock().unwrap();
|
||||
match matrix.get_mut(account_id) {
|
||||
None => Ok(false),
|
||||
Some(a) => {
|
||||
a.dehydration_enabled = enabled;
|
||||
self.persist(&self.irc.lock().unwrap(), &self.discord.lock().unwrap(), &self.sneedchat.lock().unwrap(), &matrix, &self.kick.lock().unwrap())?;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns sliding sync on or off for this account.
|
||||
///
|
||||
/// Clears the token with it, for the same reason the kind-changed path
|
||||
@@ -897,6 +919,7 @@ pub fn irc_account_to_json(a: &IrcAccountConfig, state: &str) -> Account {
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -931,6 +954,7 @@ pub fn discord_account_to_json(a: &DiscordAccountConfig, state: &str) -> Account
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -964,6 +988,7 @@ pub fn sneedchat_account_to_json(a: &SneedChatAccountConfig, state: &str) -> Acc
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,6 +1031,7 @@ pub fn kick_account_to_json(a: &KickAccountConfig, state: &str) -> Account {
|
||||
has_key_backup: false,
|
||||
rtc_focus_url: None,
|
||||
sliding_sync: false,
|
||||
dehydration: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1043,6 +1069,7 @@ pub fn matrix_account_to_json(a: &MatrixAccountConfig, state: &str, has_key_back
|
||||
has_key_backup,
|
||||
rtc_focus_url: a.rtc_focus_url.clone(),
|
||||
sliding_sync: a.prefer_sliding_sync,
|
||||
dehydration: a.dehydration_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,7 @@ pub(super) async fn try_sso_login(state: &AppState, login_id: &str, homeserver_u
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -417,6 +418,7 @@ async fn try_registration(state: &AppState, login_id: &str, homeserver_url: &str
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -472,6 +474,7 @@ pub(super) async fn try_login(state: &AppState, login_id: &str, homeserver_url:
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -488,6 +491,7 @@ pub(super) async fn try_login(state: &AppState, login_id: &str, homeserver_url:
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
|
||||
@@ -267,6 +267,7 @@ mod tests {
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
//! A dehydrated device: somewhere for room keys to go while you are away.
|
||||
//!
|
||||
//! ## The gap this closes
|
||||
//!
|
||||
//! Room keys are delivered to *devices that exist*. A device created today
|
||||
//! cannot be sent the key for a message encrypted yesterday, because yesterday
|
||||
//! it was not there to be sent to. Key backup covers most of that - a new
|
||||
//! login restores history from the server - but not the window between a key
|
||||
//! being shared and the new device existing, because those keys went to
|
||||
//! to-device messages addressed to devices that were, and nobody re-sends them.
|
||||
//!
|
||||
//! A dehydrated device is a device that exists for exactly that reason. It is
|
||||
//! uploaded to the homeserver in encrypted form, sits there being sent room
|
||||
//! keys like any other device, and is never used to read anything. A new login
|
||||
//! takes it down, decrypts it, collects everything it was sent, and puts a
|
||||
//! fresh one back.
|
||||
//!
|
||||
//! ## Why this needed secret storage first
|
||||
//!
|
||||
//! The device is encrypted with a pickle key, and a new login has to be able
|
||||
//! to decrypt it. A new login has no local crypto store - that is what makes
|
||||
//! it new - so the key cannot be kept locally, or the feature would only ever
|
||||
//! help a device that did not need helping. It goes in the account's secret
|
||||
//! storage under `org.matrix.msc3814`, which is where Element puts it too, so
|
||||
//! a dehydrated device set up in either is usable by the other.
|
||||
//!
|
||||
//! That is why `ssss.rs` exists, and why it was written before this.
|
||||
|
||||
use super::crypto::CryptoSession;
|
||||
use super::http;
|
||||
use super::ssss;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use matrix_sdk_crypto::store::types::DehydratedDeviceKey;
|
||||
use ruma_common::OwnedDeviceId;
|
||||
use base64::Engine as _;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// The pickle key is kept in secret storage as base64 text, because account
|
||||
/// data is JSON and holds no bytes - and because that is what Element writes,
|
||||
/// which is what makes a device set up in either usable by the other.
|
||||
fn key_from_stored(bytes: &[u8]) -> Result<DehydratedDeviceKey> {
|
||||
let text = std::str::from_utf8(bytes).context("the stored pickle key is not text")?;
|
||||
let text = text.trim();
|
||||
// Unpadded first, because that is what `to_base64` writes - vodozemac
|
||||
// encodes with STANDARD_NO_PAD, which is what caught this: decoding as
|
||||
// padded standard base64 fails with "Invalid padding" on every key this
|
||||
// daemon has ever written. Padded is still accepted, because the secret is
|
||||
// shared with whatever client set it up and not every one of them agrees.
|
||||
let raw = base64::engine::general_purpose::STANDARD_NO_PAD
|
||||
.decode(text)
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(text))
|
||||
.context("the stored pickle key is not base64")?;
|
||||
DehydratedDeviceKey::from_slice(&raw).context("the stored pickle key is the wrong size")
|
||||
}
|
||||
|
||||
/// The endpoint, still unstable and still named for its MSC.
|
||||
const BASE: &str = "/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device";
|
||||
|
||||
/// What the device is called in somebody's device list.
|
||||
const DISPLAY_NAME: &str = "moho (dehydrated)";
|
||||
|
||||
/// How many of the accumulated to-device messages to take at a time.
|
||||
const EVENT_PAGE: u32 = 100;
|
||||
|
||||
/// Puts the pickle key in the account's safe, making the safe if there is none.
|
||||
///
|
||||
/// Answers with the recovery code where one had to be made, so it can be shown
|
||||
/// once and written down. `None` means the account already had secret storage
|
||||
/// and nothing new needs keeping.
|
||||
pub async fn enable(
|
||||
state: &crate::state::AppState,
|
||||
account_id: &str,
|
||||
unlock_with: Option<&str>,
|
||||
) -> Result<Option<String>> {
|
||||
let config = state.accounts.get_matrix(account_id).context("no such Matrix account")?;
|
||||
let session = state.runtime.get_matrix_machine(account_id).context("account is not connected")?;
|
||||
let base = config.homeserver_url.trim_end_matches('/');
|
||||
let token = &config.access_token;
|
||||
let user = &config.user_id;
|
||||
|
||||
// An account that already has secret storage must be opened rather than
|
||||
// replaced: making a second one would orphan every secret in the first,
|
||||
// including a backup key somebody else's client is relying on.
|
||||
let (key, fresh_code) = match ssss::default_key_id(base, token, user).await {
|
||||
Some(_) => {
|
||||
let Some(input) = unlock_with.filter(|s| !s.is_empty()) else {
|
||||
bail!("this account already has secret storage - its passphrase or recovery code is needed to add to it")
|
||||
};
|
||||
(ssss::unlock(base, token, user, input).await?, None)
|
||||
}
|
||||
None => {
|
||||
let (key, code) = ssss::create(base, token, user, unlock_with).await?;
|
||||
(key, Some(code))
|
||||
}
|
||||
};
|
||||
|
||||
// Reuse the pickle key already in the safe where there is one, so a second
|
||||
// client enabling this does not strand the device the first uploaded.
|
||||
let pickle_key = match ssss::read_secret(base, token, user, &key, ssss::DEHYDRATION_SECRET).await? {
|
||||
Some(bytes) => key_from_stored(&bytes)?,
|
||||
None => {
|
||||
let fresh = DehydratedDeviceKey::new();
|
||||
ssss::store_secret(base, token, user, &key, ssss::DEHYDRATION_SECRET, fresh.to_base64().as_bytes()).await?;
|
||||
fresh
|
||||
}
|
||||
};
|
||||
|
||||
// Cached locally as well as kept in the safe. This is what lets every
|
||||
// later connect rehydrate without asking for the recovery code again -
|
||||
// and it is why the code itself is never written down here. Storing the
|
||||
// code would put the key to the account's whole safe in a config file, to
|
||||
// save typing it once on a login that by definition has nothing yet.
|
||||
session
|
||||
.machine
|
||||
.dehydrated_devices()
|
||||
.save_dehydrated_device_pickle_key(&pickle_key)
|
||||
.await
|
||||
.context("caching the pickle key")?;
|
||||
|
||||
upload(&session, base, token, &pickle_key).await?;
|
||||
let _ = state.accounts.set_matrix_dehydration(account_id, true);
|
||||
Ok(fresh_code)
|
||||
}
|
||||
|
||||
/// Creates a device and puts it on the server, replacing any already there.
|
||||
async fn upload(session: &CryptoSession, base: &str, token: &str, pickle_key: &DehydratedDeviceKey) -> Result<()> {
|
||||
let devices = session.machine.dehydrated_devices();
|
||||
let device = devices.create().await.context("creating a dehydrated device")?;
|
||||
let request = device
|
||||
.keys_for_upload(DISPLAY_NAME.to_string(), pickle_key)
|
||||
.await
|
||||
.context("preparing the dehydrated device for upload")?;
|
||||
|
||||
// Sent as plain JSON rather than through the crypto machine's own request
|
||||
// queue: this one is not an OutgoingRequest, and the endpoint is unstable
|
||||
// enough that ruma's own path is the only part worth borrowing.
|
||||
let body = json!({
|
||||
"device_id": request.device_id,
|
||||
"device_data": request.device_data,
|
||||
"initial_device_display_name": request.initial_device_display_name,
|
||||
"device_keys": request.device_keys,
|
||||
"one_time_keys": request.one_time_keys,
|
||||
"fallback_keys": request.fallback_keys,
|
||||
});
|
||||
http::put_json(&format!("{base}{BASE}"), token, body).await.context("uploading the dehydrated device")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Opens the safe with a recovery code, caches the pickle key, and collects.
|
||||
///
|
||||
/// The one moment the code is needed: a login that has never run before has no
|
||||
/// cached key, and this is how it gets one. Every connect after this uses the
|
||||
/// cache, so the code is typed once rather than kept.
|
||||
pub async fn rehydrate_with_code(state: &crate::state::AppState, account_id: &str, code: &str) -> Result<()> {
|
||||
let config = state.accounts.get_matrix(account_id).context("no such Matrix account")?;
|
||||
let session = state.runtime.get_matrix_machine(account_id).context("account is not connected")?;
|
||||
let base = config.homeserver_url.trim_end_matches('/');
|
||||
|
||||
let key = ssss::unlock(base, &config.access_token, &config.user_id, code).await?;
|
||||
let Some(bytes) = ssss::read_secret(base, &config.access_token, &config.user_id, &key, ssss::DEHYDRATION_SECRET).await? else {
|
||||
bail!("this account's secret storage holds no dehydrated device key")
|
||||
};
|
||||
let pickle_key = key_from_stored(&bytes)?;
|
||||
session
|
||||
.machine
|
||||
.dehydrated_devices()
|
||||
.save_dehydrated_device_pickle_key(&pickle_key)
|
||||
.await
|
||||
.context("caching the pickle key")?;
|
||||
let _ = state.accounts.set_matrix_dehydration(account_id, true);
|
||||
|
||||
rehydrate(state, account_id, &session, &config).await
|
||||
}
|
||||
|
||||
/// Takes down whatever was left last time, collects what it was sent, and puts
|
||||
/// a fresh one back.
|
||||
///
|
||||
/// Run on connect. Quiet about an account that has none - most do not, and a
|
||||
/// line about it on every connect would be noise.
|
||||
pub async fn rehydrate_on_connect(state: &crate::state::AppState, account_id: &str, session: &CryptoSession) {
|
||||
let Some(config) = state.accounts.get_matrix(account_id) else { return };
|
||||
if !config.dehydration_enabled {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = rehydrate(state, account_id, session, &config).await {
|
||||
// Never fatal. A dehydrated device that cannot be collected costs the
|
||||
// keys it was holding; failing the connection over it would cost the
|
||||
// whole account.
|
||||
tracing::warn!("matrix[{account_id}]: collecting the dehydrated device: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
async fn rehydrate(
|
||||
state: &crate::state::AppState,
|
||||
account_id: &str,
|
||||
session: &CryptoSession,
|
||||
config: &crate::accounts::MatrixAccountConfig,
|
||||
) -> Result<()> {
|
||||
let base = config.homeserver_url.trim_end_matches('/');
|
||||
let token = &config.access_token;
|
||||
let user = &config.user_id;
|
||||
|
||||
// The locally cached key, which every connect after the first has. A
|
||||
// login that has never been given the recovery code has none, and that is
|
||||
// not an error - it is the state `rehydrate_with_code` exists to leave.
|
||||
let Some(pickle_key) = session
|
||||
.machine
|
||||
.dehydrated_devices()
|
||||
.get_dehydrated_device_pickle_key()
|
||||
.await
|
||||
.context("reading the cached pickle key")?
|
||||
else {
|
||||
tracing::debug!("matrix[{account_id}]: dehydration is on but this login has no pickle key yet");
|
||||
return Ok(());
|
||||
};
|
||||
let _ = (user, &ssss::DEHYDRATION_SECRET);
|
||||
|
||||
// What is on the server now, if anything.
|
||||
let existing = http::get_json(&format!("{base}{BASE}"), token).await.ok();
|
||||
if let Some(existing) = existing {
|
||||
let device_id: OwnedDeviceId = existing["device_id"]
|
||||
.as_str()
|
||||
.context("the server's dehydrated device has no id")?
|
||||
.into();
|
||||
let data = serde_json::from_value(existing["device_data"].clone()).context("unreadable dehydrated device")?;
|
||||
|
||||
let devices = session.machine.dehydrated_devices();
|
||||
let rehydrated = devices.rehydrate(&pickle_key, &device_id, data).await.context("rehydrating")?;
|
||||
|
||||
let mut next: Option<String> = None;
|
||||
let mut collected = 0usize;
|
||||
loop {
|
||||
let url = match &next {
|
||||
Some(t) => format!(
|
||||
"{base}{BASE}/{device_id}/events?limit={EVENT_PAGE}&next_batch={}",
|
||||
url::form_urlencoded::byte_serialize(t.as_bytes()).collect::<String>()
|
||||
),
|
||||
None => format!("{base}{BASE}/{device_id}/events?limit={EVENT_PAGE}"),
|
||||
};
|
||||
let page: Value = http::post_json(&url, Some(token), json!({})).await.context("reading its events")?;
|
||||
let events: Vec<_> = page["events"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|e| ruma_common::serde::Raw::from_json_string(e.to_string()).ok())
|
||||
.collect();
|
||||
if events.is_empty() {
|
||||
break;
|
||||
}
|
||||
collected += events.len();
|
||||
rehydrated
|
||||
.receive_events(events, &super::crypto::decryption_settings())
|
||||
.await
|
||||
.context("taking the keys out of its events")?;
|
||||
match page["next_batch"].as_str() {
|
||||
// The server repeats the token when there is nothing more.
|
||||
Some(t) if Some(t.to_string()) != next => next = Some(t.to_string()),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
if collected > 0 {
|
||||
tracing::info!("matrix[{account_id}]: collected {collected} event(s) from the dehydrated device");
|
||||
}
|
||||
}
|
||||
|
||||
// A fresh one, always: the old device's one-time keys are spent, and a
|
||||
// device left in place stops being able to receive after enough traffic.
|
||||
upload(session, base, token, &pickle_key).await?;
|
||||
let _ = account_id;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The pickle key survives the round trip through secret storage as text.
|
||||
///
|
||||
/// Account data is JSON and holds no bytes, so the key goes in as base64 -
|
||||
/// and has to come back out through the decode, not straight into
|
||||
/// `from_slice`. Writing this test is what caught that: passing the base64
|
||||
/// text to `from_slice` hands it 43 bytes where it wants 32, and it was
|
||||
/// wrong in the live path as well as in the test.
|
||||
#[test]
|
||||
fn a_pickle_key_survives_being_written_down_and_read_back() {
|
||||
let key = DehydratedDeviceKey::new();
|
||||
let written = key.to_base64();
|
||||
let read = key_from_stored(written.as_bytes()).expect("should read back");
|
||||
assert_eq!(read.to_base64(), written);
|
||||
|
||||
// And the padded spelling of the same key, since the secret is shared
|
||||
// with whatever client wrote it.
|
||||
let padded = base64::engine::general_purpose::STANDARD
|
||||
.encode(base64::engine::general_purpose::STANDARD_NO_PAD.decode(&written).unwrap());
|
||||
assert_eq!(key_from_stored(padded.as_bytes()).unwrap().to_base64(), written);
|
||||
}
|
||||
|
||||
/// Anything that is not a key is refused rather than producing one that
|
||||
/// silently decrypts nothing.
|
||||
#[test]
|
||||
fn something_that_is_not_a_key_is_refused() {
|
||||
assert!(key_from_stored(b"not base64 at all !!").is_err());
|
||||
// Valid base64, wrong length.
|
||||
assert!(key_from_stored(b"aGVsbG8").is_err());
|
||||
}
|
||||
|
||||
/// The endpoint is the one the MSC names, which is what Element talks to.
|
||||
#[test]
|
||||
fn the_endpoint_is_the_one_the_msc_names() {
|
||||
assert_eq!(BASE, "/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device");
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,8 @@ pub mod calls;
|
||||
pub mod protocol;
|
||||
pub mod roomstate;
|
||||
pub mod sliding;
|
||||
pub mod dehydration;
|
||||
pub mod ssss;
|
||||
pub mod stickers;
|
||||
pub mod rooms;
|
||||
pub mod verification;
|
||||
|
||||
@@ -79,6 +79,7 @@ mod live {
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -200,6 +201,7 @@ mod live {
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -315,6 +317,7 @@ mod live {
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -475,6 +478,7 @@ mod live {
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
@@ -634,6 +638,7 @@ mod live {
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Secret storage - the account's own safe, on the homeserver.
|
||||
//!
|
||||
//! ## What it is for
|
||||
//!
|
||||
//! A secret kept here survives losing every device. It is encrypted with a key
|
||||
//! derived from one passphrase or one recovery code, stored as account data,
|
||||
//! and can be opened again by a login that has nothing else - which is exactly
|
||||
//! the situation every other recovery mechanism cannot help with, because they
|
||||
//! all assume something local.
|
||||
//!
|
||||
//! moho already had a recovery key, and it was not this: `backup.rs` makes a
|
||||
//! backup decryption key, keeps it in the local crypto store and hands the
|
||||
//! base58 string over to be written down. That works and is not replaced here.
|
||||
//! What it cannot do is hold anything else, which is why this exists - the
|
||||
//! dehydrated device's pickle key has to be readable by a login that has never
|
||||
//! run before, and there was nowhere to put it.
|
||||
//!
|
||||
//! ## The shape on the server
|
||||
//!
|
||||
//! Three kinds of account data, and the indirection between them is the part
|
||||
//! worth holding in mind:
|
||||
//!
|
||||
//! - `m.secret_storage.key.<key id>` describes a key - how it was derived, and
|
||||
//! a MAC that lets a client check a typed passphrase without being able to
|
||||
//! decrypt anything. It never contains the key.
|
||||
//! - `m.secret_storage.default_key` names which of those is the current one.
|
||||
//! - One event per secret, named for the secret, holding the ciphertext keyed
|
||||
//! by the id of the key that encrypted it. A secret can be encrypted under
|
||||
//! several keys at once, which is how a client rotates one without losing
|
||||
//! the others.
|
||||
//!
|
||||
//! The cryptography is the crate's: `SecretStorageKey` does the PBKDF2, the
|
||||
//! base58 and the AES-CTR-plus-HMAC. What is here is the account data around
|
||||
//! it, which is the half the crate deliberately leaves to the client.
|
||||
|
||||
use super::http;
|
||||
use anyhow::{bail, Context, Result};
|
||||
use matrix_sdk_crypto::secret_storage::SecretStorageKey;
|
||||
use ruma_events::secret::request::SecretName;
|
||||
use ruma_events::secret_storage::key::SecretStorageKeyEventContent;
|
||||
use ruma_events::EventContentFromType;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Where the current key is named.
|
||||
pub const DEFAULT_KEY_EVENT: &str = "m.secret_storage.default_key";
|
||||
|
||||
/// The prefix a key description is filed under.
|
||||
pub const KEY_EVENT_PREFIX: &str = "m.secret_storage.key.";
|
||||
|
||||
/// The secret this was built for. Unstable, and named for its MSC, because
|
||||
/// that is what Element writes too - a dehydrated device set up there has to
|
||||
/// be findable here and the other way about.
|
||||
pub const DEHYDRATION_SECRET: &str = "org.matrix.msc3814";
|
||||
|
||||
fn account_data_url(homeserver_url: &str, user_id: &str, kind: &str) -> String {
|
||||
format!(
|
||||
"{}/_matrix/client/v3/user/{}/account_data/{}",
|
||||
homeserver_url.trim_end_matches('/'),
|
||||
url::form_urlencoded::byte_serialize(user_id.as_bytes()).collect::<String>(),
|
||||
url::form_urlencoded::byte_serialize(kind.as_bytes()).collect::<String>()
|
||||
)
|
||||
}
|
||||
|
||||
/// Reads one global account data event, or `None` where the server has none.
|
||||
///
|
||||
/// A missing event answers 404 and that is an ordinary answer rather than a
|
||||
/// failure: an account that has never used secret storage has none of these.
|
||||
pub async fn read_account_data(homeserver_url: &str, token: &str, user_id: &str, kind: &str) -> Option<Value> {
|
||||
http::get_json(&account_data_url(homeserver_url, user_id, kind), token).await.ok()
|
||||
}
|
||||
|
||||
pub async fn write_account_data(homeserver_url: &str, token: &str, user_id: &str, kind: &str, body: Value) -> Result<()> {
|
||||
http::put_json(&account_data_url(homeserver_url, user_id, kind), token, body)
|
||||
.await
|
||||
.with_context(|| format!("writing {kind}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Which key this account's secrets are encrypted under, if any.
|
||||
pub async fn default_key_id(homeserver_url: &str, token: &str, user_id: &str) -> Option<String> {
|
||||
read_account_data(homeserver_url, token, user_id, DEFAULT_KEY_EVENT)
|
||||
.await?
|
||||
.get("key")?
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Opens the account's safe with a passphrase or a recovery code.
|
||||
///
|
||||
/// The description on the server says which of the two it is expecting and
|
||||
/// carries a MAC, so a wrong answer is refused here rather than producing a
|
||||
/// key that silently decrypts nothing.
|
||||
pub async fn unlock(homeserver_url: &str, token: &str, user_id: &str, input: &str) -> Result<SecretStorageKey> {
|
||||
let Some(key_id) = default_key_id(homeserver_url, token, user_id).await else {
|
||||
bail!("this account has no secret storage yet")
|
||||
};
|
||||
let content = read_account_data(homeserver_url, token, user_id, &format!("{KEY_EVENT_PREFIX}{key_id}"))
|
||||
.await
|
||||
.context("the account names a secret storage key it does not describe")?;
|
||||
// Rebuilt from the event type as well as the body, because the key's own
|
||||
// id lives in the type - `m.secret_storage.key.<id>` - and not in the
|
||||
// content. That is why this cannot simply be deserialised.
|
||||
let raw = serde_json::value::to_raw_value(&content).context("unreadable secret storage key description")?;
|
||||
let content = SecretStorageKeyEventContent::from_parts(&format!("{KEY_EVENT_PREFIX}{key_id}"), &raw)
|
||||
.context("unreadable secret storage key description")?;
|
||||
SecretStorageKey::from_account_data(input, content)
|
||||
.map_err(|e| anyhow::anyhow!("that passphrase or recovery code did not open secret storage: {e}"))
|
||||
}
|
||||
|
||||
/// Makes an account's secret storage, and makes it the default.
|
||||
///
|
||||
/// Answers with the recovery code to write down. A passphrase, where one was
|
||||
/// given, is a second way in rather than a replacement - the code is what
|
||||
/// works when the passphrase is forgotten, which is the case this exists for.
|
||||
pub async fn create(homeserver_url: &str, token: &str, user_id: &str, passphrase: Option<&str>) -> Result<(SecretStorageKey, String)> {
|
||||
let key = match passphrase.filter(|p| !p.is_empty()) {
|
||||
Some(p) => SecretStorageKey::new_from_passphrase(p),
|
||||
None => SecretStorageKey::new(),
|
||||
};
|
||||
let recovery_code = key.to_base58();
|
||||
|
||||
// The description first, then the pointer at it. In that order on purpose:
|
||||
// a default_key naming a description that does not exist is an account
|
||||
// nothing can open, while a description nothing points at is merely unused.
|
||||
write_account_data(
|
||||
homeserver_url,
|
||||
token,
|
||||
user_id,
|
||||
&format!("{KEY_EVENT_PREFIX}{}", key.key_id()),
|
||||
serde_json::to_value(key.event_content()).context("serialising the key description")?,
|
||||
)
|
||||
.await?;
|
||||
write_account_data(homeserver_url, token, user_id, DEFAULT_KEY_EVENT, json!({ "key": key.key_id() })).await?;
|
||||
|
||||
Ok((key, recovery_code))
|
||||
}
|
||||
|
||||
/// Puts a secret in the safe.
|
||||
///
|
||||
/// Written alongside whatever is already there rather than over it: a secret
|
||||
/// may be encrypted under several keys at once, and dropping the others would
|
||||
/// lock out every client holding one of them.
|
||||
pub async fn store_secret(
|
||||
homeserver_url: &str,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
key: &SecretStorageKey,
|
||||
name: &str,
|
||||
plaintext: &[u8],
|
||||
) -> Result<()> {
|
||||
let secret_name = SecretName::from(name);
|
||||
let encrypted = key.encrypt(plaintext.to_vec(), &secret_name);
|
||||
|
||||
let mut event = read_account_data(homeserver_url, token, user_id, name).await.unwrap_or_else(|| json!({}));
|
||||
if !event["encrypted"].is_object() {
|
||||
event["encrypted"] = json!({});
|
||||
}
|
||||
event["encrypted"][key.key_id()] = serde_json::to_value(&encrypted).context("serialising the secret")?;
|
||||
write_account_data(homeserver_url, token, user_id, name, event).await
|
||||
}
|
||||
|
||||
/// Takes a secret back out, or `None` where this key did not encrypt it.
|
||||
pub async fn read_secret(
|
||||
homeserver_url: &str,
|
||||
token: &str,
|
||||
user_id: &str,
|
||||
key: &SecretStorageKey,
|
||||
name: &str,
|
||||
) -> Result<Option<Vec<u8>>> {
|
||||
let Some(event) = read_account_data(homeserver_url, token, user_id, name).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(mine) = event["encrypted"].get(key.key_id()) else {
|
||||
// Stored under somebody else's key. Not an error - it means this
|
||||
// recovery code is not the one that safe was locked with.
|
||||
return Ok(None);
|
||||
};
|
||||
let data = serde_json::from_value(mine.clone()).context("unreadable stored secret")?;
|
||||
let plaintext = key
|
||||
.decrypt(&data, &SecretName::from(name))
|
||||
.map_err(|e| anyhow::anyhow!("the stored secret did not verify: {e}"))?;
|
||||
Ok(Some(plaintext))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The round trip this module exists for, without a server in it: what is
|
||||
/// put in comes back out, and only with the right key.
|
||||
#[test]
|
||||
fn a_secret_survives_being_locked_and_opened() {
|
||||
let key = SecretStorageKey::new();
|
||||
let name = SecretName::from(DEHYDRATION_SECRET);
|
||||
let secret = b"a pickle key, as it happens".to_vec();
|
||||
|
||||
let encrypted = key.encrypt(secret.clone(), &name);
|
||||
assert_eq!(key.decrypt(&encrypted, &name).unwrap(), secret);
|
||||
}
|
||||
|
||||
/// A different key must not open it, which is the whole security claim.
|
||||
#[test]
|
||||
fn another_key_does_not_open_it() {
|
||||
let name = SecretName::from(DEHYDRATION_SECRET);
|
||||
let encrypted = SecretStorageKey::new().encrypt(b"secret".to_vec(), &name);
|
||||
assert!(SecretStorageKey::new().decrypt(&encrypted, &name).is_err());
|
||||
}
|
||||
|
||||
/// The secret's own name is part of the key derivation, so a ciphertext
|
||||
/// cannot be lifted from one secret and read as another.
|
||||
#[test]
|
||||
fn a_secret_cannot_be_read_under_a_different_name() {
|
||||
let key = SecretStorageKey::new();
|
||||
let encrypted = key.encrypt(b"secret".to_vec(), &SecretName::from(DEHYDRATION_SECRET));
|
||||
assert!(key.decrypt(&encrypted, &SecretName::from("m.megolm_backup.v1")).is_err());
|
||||
}
|
||||
|
||||
/// A recovery code opens what it locked, which is what makes this worth
|
||||
/// having: the code is the thing somebody writes down.
|
||||
#[test]
|
||||
fn a_recovery_code_reopens_the_safe() {
|
||||
let key = SecretStorageKey::new();
|
||||
let code = key.to_base58();
|
||||
let content = key.event_content().clone();
|
||||
|
||||
let reopened = SecretStorageKey::from_account_data(&code, content).expect("the code should open it");
|
||||
assert_eq!(reopened.key_id(), key.key_id());
|
||||
|
||||
let name = SecretName::from(DEHYDRATION_SECRET);
|
||||
let encrypted = key.encrypt(b"pickle".to_vec(), &name);
|
||||
assert_eq!(reopened.decrypt(&encrypted, &name).unwrap(), b"pickle");
|
||||
}
|
||||
|
||||
/// And a passphrase does too, where one was set - the description carries
|
||||
/// what is needed to derive the same key again.
|
||||
#[test]
|
||||
fn a_passphrase_reopens_the_safe() {
|
||||
let key = SecretStorageKey::new_from_passphrase("correct horse battery staple");
|
||||
let content = key.event_content().clone();
|
||||
let reopened = SecretStorageKey::from_account_data("correct horse battery staple", content.clone())
|
||||
.expect("the passphrase should open it");
|
||||
assert_eq!(reopened.key_id(), key.key_id());
|
||||
// And the wrong one is refused rather than producing a key that
|
||||
// decrypts nothing.
|
||||
assert!(SecretStorageKey::from_account_data("hunter2", content).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_data_urls_escape_what_goes_in_them() {
|
||||
let url = account_data_url("https://h.example.org/", "@a:b.org", "m.secret_storage.key.abc");
|
||||
assert!(url.contains("%40a%3Ab.org"), "{url}");
|
||||
assert!(url.ends_with("m.secret_storage.key.abc"), "{url}");
|
||||
// The trailing slash on the homeserver must not double up.
|
||||
assert!(!url.contains("org//_matrix"), "{url}");
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,11 @@ pub(super) async fn run_sync(state: &AppState, config: &MatrixAccountConfig, acc
|
||||
.runtime
|
||||
.set_matrix_server_facts(account_id, server::read_facts(&config.homeserver_url, &access_token).await);
|
||||
backup::reactivate_on_connect(state, account_id, &session).await;
|
||||
// Collect whatever the dehydrated device was sent while this login did not
|
||||
// exist, and leave a fresh one behind. After the backup reactivation
|
||||
// because both put room keys into the same store, and this is the one that
|
||||
// can be skipped: it is quiet and best-effort by design.
|
||||
dehydration::rehydrate_on_connect(state, account_id, &session).await;
|
||||
|
||||
// A resumed connection (next_batch already persisted) only ever gets
|
||||
// *incremental* `/sync` responses from here on - rooms.join in any
|
||||
|
||||
@@ -588,6 +588,7 @@ mod tests {
|
||||
next_batch: None,
|
||||
used_sliding_sync: false,
|
||||
prefer_sliding_sync: false,
|
||||
dehydration_enabled: false,
|
||||
display_name: None,
|
||||
rtc_focus_url: None,
|
||||
};
|
||||
|
||||
@@ -94,6 +94,9 @@ pub struct Account {
|
||||
/// Matrix only: whether this account uses sliding sync where offered.
|
||||
#[serde(rename = "slidingSync", default)]
|
||||
pub sliding_sync: bool,
|
||||
/// Matrix only: whether this account keeps a dehydrated device.
|
||||
#[serde(rename = "dehydration", default)]
|
||||
pub dehydration: bool,
|
||||
}
|
||||
|
||||
/// One room in a Sneedchat account's currently-enabled list, as exposed on
|
||||
|
||||
@@ -4070,6 +4070,34 @@ pub async fn dispatch(
|
||||
|
||||
// Where this account's room calls go, for a homeserver that names no
|
||||
// media server of its own.
|
||||
// Keeping a dehydrated device, so room keys shared while this login
|
||||
// did not exist are not simply lost - see backend/matrix/dehydration.rs.
|
||||
//
|
||||
// Answers with a recovery code only where one had to be made, which is
|
||||
// the single moment it can be shown: it is not stored anywhere this
|
||||
// daemon can read it back.
|
||||
"enableMatrixDehydration" => {
|
||||
let Some(account_id) = p_str_opt(params, "accountId") else {
|
||||
return (None, Some("enableMatrixDehydration requires \"accountId\"".to_string()));
|
||||
};
|
||||
match backend::matrix::dehydration::enable(state, account_id, p_str_opt(params, "unlockWith")).await {
|
||||
Ok(code) => (Some(serde_json::json!({ "recoveryCode": code })), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// The one moment the recovery code is needed: a login that has never
|
||||
// run has no cached pickle key, and this is how it gets one.
|
||||
"rehydrateMatrixDevice" => {
|
||||
let (Some(account_id), Some(code)) = (p_str_opt(params, "accountId"), p_str_opt(params, "recoveryCode")) else {
|
||||
return (None, Some("rehydrateMatrixDevice requires \"accountId\" and \"recoveryCode\"".to_string()));
|
||||
};
|
||||
match backend::matrix::dehydration::rehydrate_with_code(state, account_id, code).await {
|
||||
Ok(()) => (Some(ok_node()), None),
|
||||
Err(e) => (None, Some(format!("{e:#}"))),
|
||||
}
|
||||
}
|
||||
|
||||
// Sliding sync, per account and off by default - see
|
||||
// MatrixAccountConfig::prefer_sliding_sync for why it is a switch
|
||||
// rather than something taken automatically. Turning it either way
|
||||
|
||||
Reference in New Issue
Block a user