diff --git a/signal-gateway/src/gateway/mod.rs b/signal-gateway/src/gateway/mod.rs index b5c3456..45f2ee8 100644 --- a/signal-gateway/src/gateway/mod.rs +++ b/signal-gateway/src/gateway/mod.rs @@ -1,12 +1,14 @@ +#[cfg(unix)] +use crate::jsonrpc::connect_ipc; use crate::{ alertmanager::AlertPost, - jsonrpc::{Envelope, MessageTarget, RpcClient, RpcClientError, SignalMessage, connect_tcp}, + jsonrpc::{ + Envelope, Identity, MessageTarget, RpcClient, RpcClientError, SignalMessage, connect_tcp, + }, log_message::{LogMessage, Origin}, message_handler::{AdminMessageResponse, MessageHandler, MessageHandlerResult}, prometheus::{Prometheus, PrometheusConfig}, }; -#[cfg(unix)] -use crate::jsonrpc::connect_ipc; use chrono::Utc; use conf::{Conf, Subcommands}; use futures_util::FutureExt; @@ -14,6 +16,7 @@ use http::{Method, Request, Response, StatusCode}; use http_body::Body; use http_body_util::BodyExt; use prometheus_http_client::{AlertStatus, ExtractLabels}; +use std::time::Duration; use std::{collections::HashMap, fmt::Write, net::SocketAddr, path::PathBuf}; use tokio::{ join, @@ -22,7 +25,6 @@ use tokio::{ mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel}, }, }; -use std::time::Duration; use tokio_util::bytes::Buf; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, warn}; @@ -35,10 +37,7 @@ mod rate_limiter; use rate_limiter::{MultiRateLimiter, RateThreshold, SourceLocationRateLimiter}; #[derive(Conf, Debug)] -#[cfg_attr( - unix, - conf(one_of_fields(signal_cli_tcp_addr, signal_cli_socket_path)) -)] +#[cfg_attr(unix, conf(one_of_fields(signal_cli_tcp_addr, signal_cli_socket_path)))] #[cfg_attr(not(unix), conf(one_of_fields(signal_cli_tcp_addr)))] pub struct GatewayConfig { /// TCP address of signal-cli JSON-RPC server @@ -50,8 +49,10 @@ pub struct GatewayConfig { pub signal_cli_socket_path: Option, #[conf(long, env)] pub signal_account: String, - #[conf(repeat, long, env)] - pub admin_uuid: Vec, + /// Admin UUIDs mapped to their safety numbers (can be empty). + /// Example: {"uuid1": ["12345...", "67890..."], "uuid2": []} + #[conf(long, env, value_parser = serde_json::from_str)] + pub admin_safety_numbers: HashMap>, /// If set, alerts are sent to this group instead of individual admins #[conf(long, env)] pub alert_group_id: Option, @@ -61,6 +62,18 @@ pub struct GatewayConfig { pub log_handler: LogHandlerConfig, } +impl GatewayConfig { + /// Check if a UUID is a registered admin + pub fn is_admin(&self, uuid: &str) -> bool { + self.admin_safety_numbers.contains_key(uuid) + } + + /// Get all admin UUIDs + pub fn admin_uuids(&self) -> Vec { + self.admin_safety_numbers.keys().cloned().collect() + } +} + /// Wrapper for parsing gateway commands #[derive(Clone, Debug, Conf)] struct GatewayCommandWrapper { @@ -214,7 +227,10 @@ impl Gateway { async fn connect_and_run(&self) -> Result<(), Box> { #[cfg(unix)] if let Some(path) = &self.config.signal_cli_socket_path { - info!("Connecting to signal-cli via unix socket: {}", path.display()); + info!( + "Connecting to signal-cli via unix socket: {}", + path.display() + ); let client = connect_ipc(path).await?; return Ok(self.do_run(&client).await?); } @@ -229,7 +245,107 @@ impl Gateway { unreachable!("one_of_fields should ensure exactly one transport is configured") } + /// Update trust for admins with safety numbers configured. + /// + /// For each admin with safety numbers: + /// 1. Check current identities in signal-cli via listIdentities + /// 2. If any trusted identity is NOT in our config, remove the contact entirely and re-add only configured ones + /// 3. Otherwise, just trust any new safety numbers from config that aren't already trusted + async fn update_trust(&self, signal_cli: &impl RpcClient) { + for (uuid, safety_numbers) in &self.config.admin_safety_numbers { + if safety_numbers.is_empty() { + continue; + } + + // Get current identities from signal-cli + let current_identities: Vec = match signal_cli + .list_identities(Some(self.config.signal_account.clone()), Some(uuid.clone())) + .await + { + Ok(value) => serde_json::from_value(value).unwrap_or_default(), + Err(err) => { + debug!("Could not list identities for {uuid} (may not exist yet): {err}"); + Vec::new() + } + }; + + // Check if any trusted identity in signal-cli is NOT in our config + let has_revoked_identity = current_identities.iter().any(|id| { + id.trust_level.is_trusted() && !safety_numbers.contains(&id.safety_number) + }); + + if has_revoked_identity { + // Log which identities are being revoked + for id in ¤t_identities { + if id.trust_level.is_trusted() && !safety_numbers.contains(&id.safety_number) { + warn!( + "Revoking trust for admin {uuid}: safety number {} is trusted but not in config", + id.safety_number + ); + } + } + + // Remove contact to clear all existing trust + info!("Resetting trust for admin {uuid}"); + if let Err(err) = signal_cli + .remove_contact( + Some(self.config.signal_account.clone()), + uuid.clone(), + true, // forget - delete identity keys and sessions + false, // hide + ) + .await + { + error!("Failed to remove contact {uuid}: {err}"); + continue; + } + + // Re-add all configured safety numbers + for safety_number in safety_numbers { + info!("Trusting safety number for admin {uuid}"); + if let Err(err) = signal_cli + .trust( + Some(self.config.signal_account.clone()), + uuid.clone(), + false, + Some(safety_number.clone()), + ) + .await + { + error!("Failed to trust admin {uuid}: {err}"); + } + } + } else { + // Just add any new safety numbers that aren't already trusted + let already_trusted: Vec<_> = current_identities + .iter() + .filter(|id| id.trust_level.is_trusted()) + .map(|id| &id.safety_number) + .collect(); + + for safety_number in safety_numbers { + if !already_trusted.contains(&safety_number) { + info!("Trusting new safety number for admin {uuid}"); + if let Err(err) = signal_cli + .trust( + Some(self.config.signal_account.clone()), + uuid.clone(), + false, + Some(safety_number.clone()), + ) + .await + { + error!("Failed to trust admin {uuid}: {err}"); + } + } + } + } + } + } + async fn do_run(&self, signal_cli: &impl RpcClient) -> Result<(), RpcClientError> { + self.update_trust(signal_cli).await; + let mut admin_mq_rx = self .admin_mq_rx .try_lock() @@ -263,7 +379,7 @@ impl Gateway { let target = if let Some(group_id) = &self.config.alert_group_id { MessageTarget::Group(group_id.clone()) } else { - MessageTarget::Recipients(self.config.admin_uuid.clone()) + MessageTarget::Recipients(self.config.admin_uuids()) }; SignalMessage { sender: self.config.signal_account.clone(), @@ -297,9 +413,8 @@ impl Gateway { // Determine if this message came from a group let from_group = data_message.group_info.as_ref().map(|g| g.group_id.clone()); - // For group messages, check if sender is an admin - // For direct messages, check if sender is an admin - if !self.config.admin_uuid.contains(&msg.envelope.source_uuid) { + // Check if sender is an admin + if !self.config.is_admin(&msg.envelope.source_uuid) { warn!("Ignoring message from non-admin: {msg:?}"); continue; } diff --git a/signal-gateway/src/jsonrpc.rs b/signal-gateway/src/jsonrpc.rs index 09393aa..5bdc02f 100644 --- a/signal-gateway/src/jsonrpc.rs +++ b/signal-gateway/src/jsonrpc.rs @@ -440,6 +440,32 @@ pub struct GroupInfo { pub group_id: String, } +/// Trust level for an identity +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum TrustLevel { + Untrusted, + TrustedUnverified, + TrustedVerified, +} + +impl TrustLevel { + pub fn is_trusted(self) -> bool { + matches!( + self, + TrustLevel::TrustedUnverified | TrustLevel::TrustedVerified + ) + } +} + +/// Identity information returned by listIdentities +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Identity { + pub safety_number: String, + pub trust_level: TrustLevel, +} + /// Connect to signal-cli over tcp socket pub async fn connect_tcp( tcp: impl ToSocketAddrs, diff --git a/signal-gateway/src/message_handler.rs b/signal-gateway/src/message_handler.rs index 65d281b..c09efcc 100644 --- a/signal-gateway/src/message_handler.rs +++ b/signal-gateway/src/message_handler.rs @@ -1,11 +1,6 @@ //! Message handler types for admin messages not handled by the gateway. -use std::{ - error::Error, - future::Future, - path::PathBuf, - pin::Pin, -}; +use std::{error::Error, future::Future, path::PathBuf, pin::Pin}; /// Response to an admin message. #[non_exhaustive] diff --git a/signal-gateway/src/transports/mod.rs b/signal-gateway/src/transports/mod.rs index 07964ae..6ddfba9 100644 --- a/signal-gateway/src/transports/mod.rs +++ b/signal-gateway/src/transports/mod.rs @@ -4,9 +4,9 @@ use futures_util::{Sink, SinkExt, Stream, stream::StreamExt}; use jsonrpsee::core::client::{ReceivedMessage, TransportReceiverT, TransportSenderT}; use thiserror::Error; -mod stream_codec; #[cfg(unix)] pub mod ipc; +mod stream_codec; pub mod tcp; #[derive(Debug, Error)]