feat(channel): add password-based Matrix login and recovery key for E2EE (#2916)

Add password-based login as an alternative to manual access token copying
for the Matrix channel. Users can now provide user_id + password instead
of obtaining an access token from Element. Also add recovery_key support
to import E2EE room-key backups for decrypting encrypted room history.

Changes:
- MatrixConfig: access_token is now Optional, add password and
  recovery_key fields
- MatrixChannel: new_full() constructor, init_client_password_login()
  using matrix_sdk login_username API, recovery key import via
  encryption().recovery().recover()
- Onboarding wizard: login method selector (password vs access token)
  with recovery key prompt for password flow
- Gateway API: mask/restore new secret fields
- Secret store: encrypt/decrypt password and recovery_key

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
simianastronaut 2026-03-12 10:24:32 -04:00
parent 7170810e98
commit 6bd363f139
6 changed files with 632 additions and 190 deletions

View File

@ -7,7 +7,7 @@ use matrix_sdk::{
events::reaction::ReactionEventContent,
events::relation::{Annotation, InReplyTo, Thread},
events::room::message::{
MessageType, OriginalSyncRoomMessageEvent, RoomMessageEventContent,
MessageType, OriginalSyncRoomMessageEvent, Relation, RoomMessageEventContent,
},
events::room::MediaSource,
OwnedEventId, OwnedRoomId, OwnedUserId,
@ -27,11 +27,13 @@ use tokio::sync::{mpsc, Mutex, OnceCell, RwLock};
#[derive(Clone)]
pub struct MatrixChannel {
homeserver: String,
access_token: String,
access_token: Option<String>,
room_id: String,
allowed_users: Vec<String>,
session_owner_hint: Option<String>,
session_device_id_hint: Option<String>,
password: Option<String>,
recovery_key: Option<String>,
zeroclaw_dir: Option<PathBuf>,
resolved_room_id_cache: Arc<RwLock<Option<String>>>,
sdk_client: Arc<OnceCell<MatrixSdkClient>>,
@ -115,7 +117,7 @@ impl MatrixChannel {
pub fn new(
homeserver: String,
access_token: String,
access_token: Option<String>,
room_id: String,
allowed_users: Vec<String>,
) -> Self {
@ -124,13 +126,13 @@ impl MatrixChannel {
pub fn new_with_session_hint(
homeserver: String,
access_token: String,
access_token: Option<String>,
room_id: String,
allowed_users: Vec<String>,
owner_hint: Option<String>,
device_id_hint: Option<String>,
) -> Self {
Self::new_with_session_hint_and_zeroclaw_dir(
Self::new_full(
homeserver,
access_token,
room_id,
@ -138,20 +140,49 @@ impl MatrixChannel {
owner_hint,
device_id_hint,
None,
None,
None,
)
}
pub fn new_with_session_hint_and_zeroclaw_dir(
homeserver: String,
access_token: String,
access_token: Option<String>,
room_id: String,
allowed_users: Vec<String>,
owner_hint: Option<String>,
device_id_hint: Option<String>,
zeroclaw_dir: Option<PathBuf>,
) -> Self {
Self::new_full(
homeserver,
access_token,
room_id,
allowed_users,
owner_hint,
device_id_hint,
None,
None,
zeroclaw_dir,
)
}
#[allow(clippy::too_many_arguments)]
pub fn new_full(
homeserver: String,
access_token: Option<String>,
room_id: String,
allowed_users: Vec<String>,
owner_hint: Option<String>,
device_id_hint: Option<String>,
password: Option<String>,
recovery_key: Option<String>,
zeroclaw_dir: Option<PathBuf>,
) -> Self {
let homeserver = homeserver.trim_end_matches('/').to_string();
let access_token = access_token.trim().to_string();
let access_token = access_token
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty());
let room_id = room_id.trim().to_string();
let allowed_users = allowed_users
.into_iter()
@ -166,6 +197,8 @@ impl MatrixChannel {
allowed_users,
session_owner_hint: Self::normalize_optional_field(owner_hint),
session_device_id_hint: Self::normalize_optional_field(device_id_hint),
password: Self::normalize_optional_field(password),
recovery_key: Self::normalize_optional_field(recovery_key),
zeroclaw_dir,
resolved_room_id_cache: Arc::new(RwLock::new(None)),
sdk_client: Arc::new(OnceCell::new()),
@ -197,7 +230,16 @@ impl MatrixChannel {
}
fn auth_header_value(&self) -> String {
format!("Bearer {}", self.access_token)
match self.access_token.as_deref() {
Some(token) => format!("Bearer {token}"),
None => String::new(),
}
}
/// Returns true when the channel is configured for password-based login
/// (user_id + password present, no access_token).
fn uses_password_login(&self) -> bool {
self.access_token.is_none() && self.password.is_some() && self.session_owner_hint.is_some()
}
fn matrix_store_dir(&self) -> Option<PathBuf> {
@ -285,108 +327,235 @@ impl MatrixChannel {
Ok(self.get_my_identity().await?.user_id)
}
/// Build (or reuse) the SDK client.
///
/// Two login strategies are supported:
///
/// 1. **Access-token restore** (legacy) — requires `access_token` plus
/// user_id/device_id from config hints or whoami.
/// 2. **Password login** — requires `user_id` + `password`. An optional
/// `device_id` hint reuses an existing device; otherwise the homeserver
/// allocates a new one. After login an optional `recovery_key` is used
/// to import E2EE room-key backups.
async fn matrix_client(&self) -> anyhow::Result<MatrixSdkClient> {
let client = self
.sdk_client
.get_or_try_init(|| async {
let identity = self.get_my_identity().await;
let whoami = match identity {
Ok(whoami) => Some(whoami),
Err(error) => {
if self.session_owner_hint.is_some() && self.session_device_id_hint.is_some()
{
tracing::warn!(
"Matrix whoami failed; falling back to configured session hints for E2EE session restore: {error}"
);
None
} else {
return Err(error);
}
}
};
let resolved_user_id = if let Some(whoami) = whoami.as_ref() {
if let Some(hinted) = self.session_owner_hint.as_ref() {
if hinted != &whoami.user_id {
tracing::warn!(
"Matrix configured user_id '{}' does not match whoami '{}'; using whoami.",
crate::security::redact(hinted),
crate::security::redact(&whoami.user_id)
);
}
}
whoami.user_id.clone()
if self.uses_password_login() {
self.init_client_password_login().await
} else {
self.session_owner_hint.clone().ok_or_else(|| {
anyhow::anyhow!(
"Matrix session restore requires user_id when whoami is unavailable"
)
})?
};
let resolved_device_id = match (whoami.as_ref(), self.session_device_id_hint.as_ref()) {
(Some(whoami), Some(hinted)) => {
if let Some(whoami_device_id) = whoami.device_id.as_ref() {
if whoami_device_id != hinted {
tracing::warn!(
"Matrix configured device_id '{}' does not match whoami '{}'; using whoami.",
crate::security::redact(hinted),
crate::security::redact(whoami_device_id)
);
}
whoami_device_id.clone()
} else {
hinted.clone()
}
}
(Some(whoami), None) => whoami.device_id.clone().ok_or_else(|| {
anyhow::anyhow!(
"Matrix whoami response did not include device_id. Set channels.matrix.device_id to enable E2EE session restore."
)
})?,
(None, Some(hinted)) => hinted.clone(),
(None, None) => {
return Err(anyhow::anyhow!(
"Matrix E2EE session restore requires device_id when whoami is unavailable"
));
}
};
let mut client_builder = MatrixSdkClient::builder().homeserver_url(&self.homeserver);
if let Some(store_dir) = self.matrix_store_dir() {
tokio::fs::create_dir_all(&store_dir).await.map_err(|error| {
anyhow::anyhow!(
"Matrix failed to initialize persistent store directory at '{}': {error}",
store_dir.display()
)
})?;
client_builder = client_builder.sqlite_store(&store_dir, None);
self.init_client_access_token().await
}
let client = client_builder.build().await?;
let user_id: OwnedUserId = resolved_user_id.parse()?;
let session = MatrixSession {
meta: SessionMeta {
user_id,
device_id: resolved_device_id.into(),
},
tokens: SessionTokens {
access_token: self.access_token.clone(),
refresh_token: None,
},
};
client.restore_session(session).await?;
Ok::<MatrixSdkClient, anyhow::Error>(client)
})
.await?;
Ok(client.clone())
}
/// Password-based login: `user_id` + `password` are required.
/// Optionally reuses a `device_id` from config; the homeserver allocates
/// one when absent. After login the recovery key (if provided) is used
/// to pull E2EE room-key backups.
async fn init_client_password_login(&self) -> anyhow::Result<MatrixSdkClient> {
let user_id_str = self.session_owner_hint.as_deref().ok_or_else(|| {
anyhow::anyhow!("Matrix password login requires user_id to be set in the config")
})?;
let password = self.password.as_deref().ok_or_else(|| {
anyhow::anyhow!("Matrix password login requires password to be set in the config")
})?;
let mut client_builder = MatrixSdkClient::builder().homeserver_url(&self.homeserver);
if let Some(store_dir) = self.matrix_store_dir() {
tokio::fs::create_dir_all(&store_dir)
.await
.map_err(|error| {
anyhow::anyhow!(
"Matrix failed to initialize persistent store directory at '{}': {error}",
store_dir.display()
)
})?;
client_builder = client_builder.sqlite_store(&store_dir, None);
}
let client = client_builder.build().await?;
// Extract the localpart from a full user_id (@user:server) for the
// login call; matrix-sdk expects the localpart only.
let localpart = user_id_str
.strip_prefix('@')
.and_then(|rest| rest.split(':').next())
.unwrap_or(user_id_str);
let mut login_builder = client.matrix_auth().login_username(localpart, password);
// Reuse an existing device_id when the user configured one.
let device_id_hint = self.session_device_id_hint.clone();
if let Some(ref device_id) = device_id_hint {
login_builder = login_builder.device_id(device_id);
}
let login_response = login_builder
.initial_device_display_name("ZeroClaw")
.await
.map_err(|error| anyhow::anyhow!("Matrix password login failed: {error}"))?;
tracing::info!(
"Matrix password login succeeded for user '{}', device '{}'",
crate::security::redact(login_response.user_id.as_str()),
crate::security::redact(login_response.device_id.as_str()),
);
if let Some(ref hinted) = device_id_hint {
if hinted != login_response.device_id.as_str() {
tracing::warn!(
"Matrix password login returned a different device_id ('{}') than the configured hint ('{}'); a new device was created. Update channels.matrix.device_id to '{}' to reuse this device on next restart.",
crate::security::redact(login_response.device_id.as_str()),
crate::security::redact(hinted),
crate::security::redact(login_response.device_id.as_str()),
);
}
}
// Recovery key: import E2EE room-key backups so the bot can decrypt
// historical messages in encrypted rooms.
if let Some(ref recovery_key) = self.recovery_key {
tracing::info!("Matrix recovery key provided; importing E2EE room-key backup...");
match client.encryption().recovery().recover(recovery_key).await {
Ok(()) => {
tracing::info!(
"Matrix E2EE room-key backup imported successfully via recovery key."
);
}
Err(error) => {
tracing::warn!(
"Matrix recovery key import failed (E2EE history may be incomplete): {error}"
);
}
}
}
Ok(client)
}
/// Access-token-based session restore (the original flow).
async fn init_client_access_token(&self) -> anyhow::Result<MatrixSdkClient> {
let access_token = self.access_token.clone().ok_or_else(|| {
anyhow::anyhow!(
"Matrix channel requires either an access_token or a user_id + password for login"
)
})?;
let identity = self.get_my_identity().await;
let whoami = match identity {
Ok(whoami) => Some(whoami),
Err(error) => {
if self.session_owner_hint.is_some() && self.session_device_id_hint.is_some() {
tracing::warn!(
"Matrix whoami failed; falling back to configured session hints for E2EE session restore: {error}"
);
None
} else {
return Err(error);
}
}
};
let resolved_user_id = if let Some(whoami) = whoami.as_ref() {
if let Some(hinted) = self.session_owner_hint.as_ref() {
if hinted != &whoami.user_id {
tracing::warn!(
"Matrix configured user_id '{}' does not match whoami '{}'; using whoami.",
crate::security::redact(hinted),
crate::security::redact(&whoami.user_id)
);
}
}
whoami.user_id.clone()
} else {
self.session_owner_hint.clone().ok_or_else(|| {
anyhow::anyhow!(
"Matrix session restore requires user_id when whoami is unavailable"
)
})?
};
let resolved_device_id = match (whoami.as_ref(), self.session_device_id_hint.as_ref()) {
(Some(whoami), Some(hinted)) => {
if let Some(whoami_device_id) = whoami.device_id.as_ref() {
if whoami_device_id != hinted {
tracing::warn!(
"Matrix configured device_id '{}' does not match whoami '{}'; using whoami.",
crate::security::redact(hinted),
crate::security::redact(whoami_device_id)
);
}
whoami_device_id.clone()
} else {
hinted.clone()
}
}
(Some(whoami), None) => whoami.device_id.clone().ok_or_else(|| {
anyhow::anyhow!(
"Matrix whoami response did not include device_id. Set channels.matrix.device_id to enable E2EE session restore."
)
})?,
(None, Some(hinted)) => hinted.clone(),
(None, None) => {
return Err(anyhow::anyhow!(
"Matrix E2EE session restore requires device_id when whoami is unavailable"
));
}
};
let mut client_builder = MatrixSdkClient::builder().homeserver_url(&self.homeserver);
if let Some(store_dir) = self.matrix_store_dir() {
tokio::fs::create_dir_all(&store_dir)
.await
.map_err(|error| {
anyhow::anyhow!(
"Matrix failed to initialize persistent store directory at '{}': {error}",
store_dir.display()
)
})?;
client_builder = client_builder.sqlite_store(&store_dir, None);
}
let client = client_builder.build().await?;
let user_id: OwnedUserId = resolved_user_id.parse()?;
let session = MatrixSession {
meta: SessionMeta {
user_id,
device_id: resolved_device_id.into(),
},
tokens: SessionTokens {
access_token,
refresh_token: None,
},
};
client.restore_session(session).await?;
// Recovery key: also supported in access-token mode.
if let Some(ref recovery_key) = self.recovery_key {
tracing::info!("Matrix recovery key provided; importing E2EE room-key backup...");
match client.encryption().recovery().recover(recovery_key).await {
Ok(()) => {
tracing::info!(
"Matrix E2EE room-key backup imported successfully via recovery key."
);
}
Err(error) => {
tracing::warn!(
"Matrix recovery key import failed (E2EE history may be incomplete): {error}"
);
}
}
}
Ok(client)
}
async fn resolve_room_id(&self) -> anyhow::Result<String> {
let configured = self.room_id.trim();
@ -773,7 +942,10 @@ impl Channel for MatrixChannel {
let client = reqwest::Client::new();
match client
.get(&url)
.header("Authorization", format!("Bearer {}", access_token))
.header(
"Authorization",
format!("Bearer {}", access_token.as_deref().unwrap_or_default()),
)
.send()
.await
{
@ -1094,7 +1266,7 @@ mod tests {
fn make_channel() -> MatrixChannel {
MatrixChannel::new(
"https://matrix.org".to_string(),
"syt_test_token".to_string(),
Some("syt_test_token".to_string()),
"!room:matrix.org".to_string(),
vec!["@user:matrix.org".to_string()],
)
@ -1104,7 +1276,7 @@ mod tests {
fn creates_with_correct_fields() {
let ch = make_channel();
assert_eq!(ch.homeserver, "https://matrix.org");
assert_eq!(ch.access_token, "syt_test_token");
assert_eq!(ch.access_token.as_deref(), Some("syt_test_token"));
assert_eq!(ch.room_id, "!room:matrix.org");
assert_eq!(ch.allowed_users.len(), 1);
}
@ -1113,7 +1285,7 @@ mod tests {
fn strips_trailing_slash() {
let ch = MatrixChannel::new(
"https://matrix.org/".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec![],
);
@ -1124,7 +1296,7 @@ mod tests {
fn no_trailing_slash_unchanged() {
let ch = MatrixChannel::new(
"https://matrix.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec![],
);
@ -1135,7 +1307,7 @@ mod tests {
fn multiple_trailing_slashes_strip_all() {
let ch = MatrixChannel::new(
"https://matrix.org//".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec![],
);
@ -1146,18 +1318,29 @@ mod tests {
fn trims_access_token() {
let ch = MatrixChannel::new(
"https://matrix.org".to_string(),
" syt_test_token ".to_string(),
Some(" syt_test_token ".to_string()),
"!r:m".to_string(),
vec![],
);
assert_eq!(ch.access_token, "syt_test_token");
assert_eq!(ch.access_token.as_deref(), Some("syt_test_token"));
}
#[test]
fn empty_access_token_becomes_none() {
let ch = MatrixChannel::new(
"https://matrix.org".to_string(),
Some(" ".to_string()),
"!r:m".to_string(),
vec![],
);
assert!(ch.access_token.is_none());
}
#[test]
fn session_hints_are_normalized() {
let ch = MatrixChannel::new_with_session_hint(
"https://matrix.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec![],
Some(" @bot:matrix.org ".to_string()),
@ -1172,7 +1355,7 @@ mod tests {
fn empty_session_hints_are_ignored() {
let ch = MatrixChannel::new_with_session_hint(
"https://matrix.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec![],
Some(" ".to_string()),
@ -1187,7 +1370,7 @@ mod tests {
fn matrix_store_dir_is_derived_from_zeroclaw_dir() {
let ch = MatrixChannel::new_with_session_hint_and_zeroclaw_dir(
"https://matrix.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec![],
None,
@ -1205,7 +1388,7 @@ mod tests {
fn matrix_store_dir_absent_without_zeroclaw_dir() {
let ch = MatrixChannel::new_with_session_hint(
"https://matrix.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec![],
None,
@ -1215,6 +1398,73 @@ mod tests {
assert!(ch.matrix_store_dir().is_none());
}
#[test]
fn password_login_detection() {
// Password login: no access_token, has password + user_id
let ch = MatrixChannel::new_full(
"https://matrix.org".to_string(),
None,
"!r:m".to_string(),
vec![],
Some("@bot:matrix.org".to_string()),
None,
Some("hunter2".to_string()),
None,
None,
);
assert!(ch.uses_password_login());
// Access token login: has access_token
let ch2 = make_channel();
assert!(!ch2.uses_password_login());
// Incomplete password config: no user_id
let ch3 = MatrixChannel::new_full(
"https://matrix.org".to_string(),
None,
"!r:m".to_string(),
vec![],
None,
None,
Some("hunter2".to_string()),
None,
None,
);
assert!(!ch3.uses_password_login());
}
#[test]
fn recovery_key_is_normalized() {
let ch = MatrixChannel::new_full(
"https://matrix.org".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec![],
None,
None,
None,
Some(" EsT0 Abcd Efgh ".to_string()),
None,
);
assert_eq!(ch.recovery_key.as_deref(), Some("EsT0 Abcd Efgh"));
}
#[test]
fn empty_recovery_key_becomes_none() {
let ch = MatrixChannel::new_full(
"https://matrix.org".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec![],
None,
None,
None,
Some(" ".to_string()),
None,
);
assert!(ch.recovery_key.is_none());
}
#[test]
fn encode_path_segment_encodes_room_refs() {
assert_eq!(
@ -1298,7 +1548,7 @@ mod tests {
fn trims_room_id_and_allowed_users() {
let ch = MatrixChannel::new(
"https://matrix.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
" !room:matrix.org ".to_string(),
vec![
" @user:matrix.org ".to_string(),
@ -1317,7 +1567,7 @@ mod tests {
fn wildcard_allows_anyone() {
let ch = MatrixChannel::new(
"https://m.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec!["*".to_string()],
);
@ -1342,7 +1592,7 @@ mod tests {
fn user_case_insensitive() {
let ch = MatrixChannel::new(
"https://m.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec!["@User:Matrix.org".to_string()],
);
@ -1354,7 +1604,7 @@ mod tests {
fn empty_allowlist_denies_all() {
let ch = MatrixChannel::new(
"https://m.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!r:m".to_string(),
vec![],
);
@ -1477,7 +1727,7 @@ mod tests {
async fn invalid_room_reference_fails_fast() {
let ch = MatrixChannel::new(
"https://matrix.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"room_without_prefix".to_string(),
vec![],
);
@ -1492,7 +1742,7 @@ mod tests {
async fn target_room_id_keeps_canonical_room_id_without_lookup() {
let ch = MatrixChannel::new(
"https://matrix.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"!canonical:matrix.org".to_string(),
vec![],
);
@ -1505,7 +1755,7 @@ mod tests {
async fn target_room_id_uses_cached_alias_resolution() {
let ch = MatrixChannel::new(
"https://matrix.org".to_string(),
"tok".to_string(),
Some("tok".to_string()),
"#ops:matrix.org".to_string(),
vec![],
);

View File

@ -2932,13 +2932,15 @@ fn collect_configured_channels(
if let Some(ref mx) = config.channels_config.matrix {
channels.push(ConfiguredChannel {
display_name: "Matrix",
channel: Arc::new(MatrixChannel::new_with_session_hint_and_zeroclaw_dir(
channel: Arc::new(MatrixChannel::new_full(
mx.homeserver.clone(),
mx.access_token.clone(),
mx.room_id.clone(),
mx.allowed_users.clone(),
mx.user_id.clone(),
mx.device_id.clone(),
mx.password.clone(),
mx.recovery_key.clone(),
config.config_path.parent().map(|path| path.to_path_buf()),
)),
});

View File

@ -3129,7 +3129,9 @@ pub struct MatrixConfig {
/// Matrix homeserver URL (e.g. `"https://matrix.org"`).
pub homeserver: String,
/// Matrix access token for the bot account.
pub access_token: String,
/// Required when using access-token login; auto-populated when using password login.
#[serde(default)]
pub access_token: Option<String>,
/// Optional Matrix user ID (e.g. `"@bot:matrix.org"`).
#[serde(default)]
pub user_id: Option<String>,
@ -3140,6 +3142,16 @@ pub struct MatrixConfig {
pub room_id: String,
/// Allowed Matrix user IDs. Empty = deny all.
pub allowed_users: Vec<String>,
/// Optional Matrix account password for password-based login.
/// When set (along with `user_id`), the bot logs in with password instead of a
/// pre-existing access token. The resulting access token is managed automatically.
#[serde(default)]
pub password: Option<String>,
/// Optional E2EE recovery key (also called "security key") for decrypting
/// room history in encrypted rooms. When provided, the bot imports the key
/// backup after login so it can read messages from before its session started.
#[serde(default)]
pub recovery_key: Option<String>,
}
impl ChannelConfig for MatrixConfig {
@ -4393,11 +4405,21 @@ impl Config {
)?;
}
if let Some(ref mut mx) = config.channels_config.matrix {
decrypt_secret(
decrypt_optional_secret(
&store,
&mut mx.access_token,
"config.channels_config.matrix.access_token",
)?;
decrypt_optional_secret(
&store,
&mut mx.password,
"config.channels_config.matrix.password",
)?;
decrypt_optional_secret(
&store,
&mut mx.recovery_key,
"config.channels_config.matrix.recovery_key",
)?;
}
if let Some(ref mut wa) = config.channels_config.whatsapp {
decrypt_optional_secret(
@ -5230,11 +5252,21 @@ impl Config {
)?;
}
if let Some(ref mut mx) = config_to_save.channels_config.matrix {
encrypt_secret(
encrypt_optional_secret(
&store,
&mut mx.access_token,
"config.channels_config.matrix.access_token",
)?;
encrypt_optional_secret(
&store,
&mut mx.password,
"config.channels_config.matrix.password",
)?;
encrypt_optional_secret(
&store,
&mut mx.recovery_key,
"config.channels_config.matrix.recovery_key",
)?;
}
if let Some(ref mut wa) = config_to_save.channels_config.whatsapp {
encrypt_optional_secret(
@ -6235,16 +6267,18 @@ tool_dispatcher = "xml"
async fn matrix_config_serde() {
let mc = MatrixConfig {
homeserver: "https://matrix.org".into(),
access_token: "syt_token_abc".into(),
access_token: Some("syt_token_abc".into()),
user_id: Some("@bot:matrix.org".into()),
device_id: Some("DEVICE123".into()),
room_id: "!room123:matrix.org".into(),
allowed_users: vec!["@user:matrix.org".into()],
password: None,
recovery_key: None,
};
let json = serde_json::to_string(&mc).unwrap();
let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.homeserver, "https://matrix.org");
assert_eq!(parsed.access_token, "syt_token_abc");
assert_eq!(parsed.access_token.as_deref(), Some("syt_token_abc"));
assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
assert_eq!(parsed.room_id, "!room123:matrix.org");
@ -6255,11 +6289,13 @@ tool_dispatcher = "xml"
async fn matrix_config_toml_roundtrip() {
let mc = MatrixConfig {
homeserver: "https://synapse.local:8448".into(),
access_token: "tok".into(),
access_token: Some("tok".into()),
user_id: None,
device_id: None,
room_id: "!abc:synapse.local".into(),
allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
password: None,
recovery_key: None,
};
let toml_str = toml::to_string(&mc).unwrap();
let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
@ -6280,6 +6316,27 @@ allowed_users = ["@ops:matrix.org"]
assert_eq!(parsed.homeserver, "https://matrix.org");
assert!(parsed.user_id.is_none());
assert!(parsed.device_id.is_none());
assert!(parsed.password.is_none());
assert!(parsed.recovery_key.is_none());
}
#[test]
async fn matrix_config_password_login_fields() {
let mc = MatrixConfig {
homeserver: "https://matrix.org".into(),
access_token: None,
user_id: Some("@bot:matrix.org".into()),
device_id: None,
room_id: "!room:matrix.org".into(),
allowed_users: vec!["*".into()],
password: Some("hunter2".into()),
recovery_key: Some("EsT0 Abcd Efgh Ijkl".into()),
};
let json = serde_json::to_string(&mc).unwrap();
let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.access_token.is_none());
assert_eq!(parsed.password.as_deref(), Some("hunter2"));
assert_eq!(parsed.recovery_key.as_deref(), Some("EsT0 Abcd Efgh Ijkl"));
}
#[test]
@ -6344,11 +6401,13 @@ allowed_users = ["@ops:matrix.org"]
}),
matrix: Some(MatrixConfig {
homeserver: "https://m.org".into(),
access_token: "tok".into(),
access_token: Some("tok".into()),
user_id: None,
device_id: None,
room_id: "!r:m".into(),
allowed_users: vec!["@u:m".into()],
password: None,
recovery_key: None,
}),
signal: None,
whatsapp: None,

View File

@ -772,7 +772,9 @@ fn mask_sensitive_fields(config: &crate::config::Config) -> crate::config::Confi
mask_optional_secret(&mut webhook.secret);
}
if let Some(matrix) = masked.channels_config.matrix.as_mut() {
mask_required_secret(&mut matrix.access_token);
mask_optional_secret(&mut matrix.access_token);
mask_optional_secret(&mut matrix.password);
mask_optional_secret(&mut matrix.recovery_key);
}
if let Some(whatsapp) = masked.channels_config.whatsapp.as_mut() {
mask_optional_secret(&mut whatsapp.access_token);
@ -911,7 +913,9 @@ fn restore_masked_sensitive_fields(
incoming.channels_config.matrix.as_mut(),
current.channels_config.matrix.as_ref(),
) {
restore_required_secret(&mut incoming_ch.access_token, &current_ch.access_token);
restore_optional_secret(&mut incoming_ch.access_token, &current_ch.access_token);
restore_optional_secret(&mut incoming_ch.password, &current_ch.password);
restore_optional_secret(&mut incoming_ch.recovery_key, &current_ch.recovery_key);
}
if let (Some(incoming_ch), Some(current_ch)) = (
incoming.channels_config.whatsapp.as_mut(),

View File

@ -860,11 +860,13 @@ mod tests {
let mut config = Config::default();
config.channels_config.matrix = Some(MatrixConfig {
homeserver: "https://m.org".into(),
access_token: "tok".into(),
access_token: Some("tok".into()),
user_id: None,
device_id: None,
room_id: "!r:m".into(),
allowed_users: vec![],
password: None,
recovery_key: None,
});
let entries = all_integrations();
let mx = entries.iter().find(|e| e.name == "Matrix").unwrap();

View File

@ -3909,8 +3909,6 @@ fn setup_channels() -> Result<ChannelsConfig> {
style("Matrix Setup").white().bold(),
style("— self-hosted, federated chat").dim()
);
print_bullet("You need a Matrix account and an access token.");
print_bullet("Get a token via Element → Settings → Help & About → Access Token.");
println!();
let homeserver: String = Input::new()
@ -3922,72 +3920,197 @@ fn setup_channels() -> Result<ChannelsConfig> {
continue;
}
let access_token: String =
Input::new().with_prompt(" Access token").interact_text()?;
// Login method selection
let login_methods = &["Password login (recommended)", "Access token (manual)"];
let login_choice = Select::new()
.with_prompt(" Login method")
.items(login_methods)
.default(0)
.interact()?;
if access_token.trim().is_empty() {
println!(" {} Skipped — token required", style("").dim());
continue;
}
let (access_token, password, detected_user_id, detected_device_id, recovery_key) =
if login_choice == 0 {
// ── Password login ──
print_bullet("Enter your Matrix user ID and password.");
print_bullet("A device session will be created automatically.");
println!();
// Test connection (run entirely in separate thread — Response must be used/dropped there)
let hs = homeserver.trim_end_matches('/');
print!(" {} Testing connection... ", style("").dim());
let hs_owned = hs.to_string();
let access_token_clone = access_token.clone();
let thread_result = std::thread::spawn(move || {
let client = reqwest::blocking::Client::new();
let resp = client
.get(format!("{hs_owned}/_matrix/client/v3/account/whoami"))
.header("Authorization", format!("Bearer {access_token_clone}"))
.send()?;
let ok = resp.status().is_success();
let user_id: String = Input::new()
.with_prompt(" User ID (e.g. @bot:matrix.org)")
.interact_text()?;
if !ok {
return Ok::<_, reqwest::Error>((false, None, None));
}
let payload: Value = match resp.json() {
Ok(payload) => payload,
Err(_) => Value::Null,
};
let user_id = payload
.get("user_id")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let device_id = payload
.get("device_id")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
Ok::<_, reqwest::Error>((true, user_id, device_id))
})
.join();
let (detected_user_id, detected_device_id) = match thread_result {
Ok(Ok((true, user_id, device_id))) => {
println!(
"\r {} Connection verified ",
style("").green().bold()
);
if device_id.is_none() {
println!(
" {} Homeserver did not return device_id from whoami. If E2EE decryption fails, set channels.matrix.device_id manually in config.toml.",
style("⚠️").yellow().bold()
);
if user_id.trim().is_empty() {
println!(" {} Skipped — user ID required", style("").dim());
continue;
}
(user_id, device_id)
}
_ => {
println!(
"\r {} Connection failed — check homeserver URL and token",
style("").red().bold()
let pw: String = Input::new().with_prompt(" Password").interact_text()?;
if pw.trim().is_empty() {
println!(" {} Skipped — password required", style("").dim());
continue;
}
// Test password login
let hs = homeserver.trim_end_matches('/');
print!(" {} Testing password login... ", style("").dim());
let hs_owned = hs.to_string();
let user_id_clone = user_id.clone();
let pw_clone = pw.clone();
let thread_result = std::thread::spawn(move || {
let client = reqwest::blocking::Client::new();
let localpart = user_id_clone
.strip_prefix('@')
.and_then(|rest| rest.split(':').next())
.unwrap_or(&user_id_clone);
let body = serde_json::json!({
"type": "m.login.password",
"identifier": {
"type": "m.id.user",
"user": localpart
},
"password": pw_clone,
"initial_device_display_name": "ZeroClaw (setup check)"
});
let resp = client
.post(format!("{hs_owned}/_matrix/client/v3/login"))
.json(&body)
.send()?;
let ok = resp.status().is_success();
if !ok {
return Ok::<_, reqwest::Error>((false, None));
}
let payload: Value = resp.json().unwrap_or(Value::Null);
let device_id = payload
.get("device_id")
.and_then(|v| v.as_str())
.map(|v| v.to_string());
Ok::<_, reqwest::Error>((true, device_id))
})
.join();
let detected_device_id = match thread_result {
Ok(Ok((true, device_id))) => {
println!(
"\r {} Password login verified ",
style("").green().bold()
);
device_id
}
_ => {
println!(
"\r {} Password login failed — check credentials and homeserver URL",
style("").red().bold()
);
continue;
}
};
// Optional recovery key for E2EE
println!();
print_bullet("Optional: provide a recovery key (security key) to decrypt E2EE room history.");
print_bullet("Leave empty to skip (you can add it later in config.toml).");
let rk: String = Input::new()
.with_prompt(" Recovery key (or empty to skip)")
.default(String::new())
.interact_text()?;
let recovery_key = if rk.trim().is_empty() {
None
} else {
Some(rk.trim().to_string())
};
(
None,
Some(pw),
Some(user_id),
detected_device_id,
recovery_key,
)
} else {
// ── Access token login (legacy) ──
print_bullet("You need a Matrix account and an access token.");
print_bullet(
"Get a token via Element → Settings → Help & About → Access Token.",
);
continue;
}
};
println!();
let access_token: String =
Input::new().with_prompt(" Access token").interact_text()?;
if access_token.trim().is_empty() {
println!(" {} Skipped — token required", style("").dim());
continue;
}
// Test connection
let hs = homeserver.trim_end_matches('/');
print!(" {} Testing connection... ", style("").dim());
let hs_owned = hs.to_string();
let access_token_clone = access_token.clone();
let thread_result = std::thread::spawn(move || {
let client = reqwest::blocking::Client::new();
let resp = client
.get(format!("{hs_owned}/_matrix/client/v3/account/whoami"))
.header("Authorization", format!("Bearer {access_token_clone}"))
.send()?;
let ok = resp.status().is_success();
if !ok {
return Ok::<_, reqwest::Error>((false, None, None));
}
let payload: Value = match resp.json() {
Ok(payload) => payload,
Err(_) => Value::Null,
};
let user_id = payload
.get("user_id")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
let device_id = payload
.get("device_id")
.and_then(|value| value.as_str())
.map(|value| value.to_string());
Ok::<_, reqwest::Error>((true, user_id, device_id))
})
.join();
let (detected_user_id, detected_device_id) = match thread_result {
Ok(Ok((true, user_id, device_id))) => {
println!(
"\r {} Connection verified ",
style("").green().bold()
);
if device_id.is_none() {
println!(
" {} Homeserver did not return device_id from whoami. If E2EE decryption fails, set channels.matrix.device_id manually in config.toml.",
style("⚠️").yellow().bold()
);
}
(user_id, device_id)
}
_ => {
println!(
"\r {} Connection failed — check homeserver URL and token",
style("").red().bold()
);
continue;
}
};
(
Some(access_token),
None,
detected_user_id,
detected_device_id,
None,
)
};
let room_id: String = Input::new()
.with_prompt(" Room ID (e.g. !abc123:matrix.org)")
@ -4011,6 +4134,8 @@ fn setup_channels() -> Result<ChannelsConfig> {
device_id: detected_device_id,
room_id,
allowed_users,
password,
recovery_key,
});
}
ChannelMenuChoice::Signal => {