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:
2026-09-12 10:23:10 -04:00
co-authored by Claude Opus 5
parent 2505f58e15
commit 412c23201d
11 changed files with 644 additions and 0 deletions
+27
View File
@@ -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,
}
}
+4
View File
@@ -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,
};
+1
View File
@@ -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,
};
+312
View File
@@ -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");
}
}
+2
View File
@@ -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;
+5
View File
@@ -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,
};
+256
View File
@@ -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}");
}
}
+5
View File
@@ -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
+1
View File
@@ -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,
};
+3
View File
@@ -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
+28
View File
@@ -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