move code out to a new signal-cli-jsonrpc-client

this makes it easier to reuse the RPC stuff and the signal trust set stuff
This commit is contained in:
Chris Beck
2025-12-18 15:21:44 -07:00
parent c326bd0a3b
commit 4783d2d9c6
13 changed files with 82 additions and 14 deletions
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "signal-cli-jsonrpc-client"
version = "0.1.0"
edition.workspace = true
description = "JSON-RPC client for signal-cli daemon"
license = "MIT OR Apache-2.0"
[lints]
workspace = true
[dependencies]
futures-util = { workspace = true }
jsonrpsee = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
tracing = { workspace = true }
+32
View File
@@ -0,0 +1,32 @@
# signal-cli-jsonrpc-client
A Rust JSON-RPC client for [signal-cli](https://github.com/AsamK/signal-cli) daemon.
## Origin
The RPC interface and transport code in this crate is based on the example client code from the signal-cli repository:
- <https://github.com/AsamK/signal-cli/blob/master/client/src/jsonrpc.rs>
## Usage
```rust
use signal_cli_jsonrpc_client::{connect_tcp, RpcClient};
// Connect to signal-cli daemon via TCP
let client = connect_tcp("127.0.0.1:7583").await?;
// Use the RpcClient trait methods
let version = client.version().await?;
```
## Features
- TCP transport for connecting to signal-cli daemon
- Unix domain socket transport (on Unix systems)
- Full RPC interface matching signal-cli's JSON-RPC API
- Helper types for sending messages and handling received messages
## License
MIT or Apache 2.0 at your option.
+18
View File
@@ -0,0 +1,18 @@
//! JSON-RPC client for signal-cli daemon.
//!
//! This crate provides a Rust client for communicating with [signal-cli](https://github.com/AsamK/signal-cli)
//! running in JSON-RPC daemon mode. It supports both TCP and Unix domain socket connections.
//!
//! The RPC interface and transport code is based on the example client code from the signal-cli repository:
//! <https://github.com/AsamK/signal-cli/blob/master/client/src/jsonrpc.rs>
mod rpc;
pub(crate) mod transports;
mod trust_set;
pub use jsonrpsee::core::client::SubscriptionClientT;
pub use rpc::{
DataMessage, Envelope, GroupInfo, Identity, JsonLink, MessageTarget, RecvMessage, RpcClient,
RpcClientError, SignalMessage, TrustLevel, connect_ipc, connect_tcp,
};
pub use trust_set::{SafetyNumber, SignalTrustSet, Uuid};
+612
View File
@@ -0,0 +1,612 @@
//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs
#![allow(clippy::too_many_arguments)]
use jsonrpsee::{async_client::ClientBuilder, core::client::SubscriptionClientT, proc_macros::rpc};
use serde::Deserialize;
use serde_json::Value;
use tokio::net::ToSocketAddrs;
pub use jsonrpsee::core::ClientError as RpcClientError;
#[rpc(client)]
pub trait Rpc {
#[method(name = "addDevice", param_kind = map)]
async fn add_device(
&self,
account: Option<String>,
uri: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "addStickerPack", param_kind = map)]
async fn add_sticker_pack(
&self,
account: Option<String>,
uri: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "block", param_kind = map)]
fn block(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "deleteLocalAccountData", param_kind = map)]
fn delete_local_account_data(
&self,
account: Option<String>,
#[allow(non_snake_case)] ignoreRegistered: Option<bool>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getAttachment", param_kind = map)]
fn get_attachment(
&self,
account: Option<String>,
id: String,
recipient: Option<String>,
#[allow(non_snake_case)] groupId: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getAvatar", param_kind = map)]
fn get_avatar(
&self,
account: Option<String>,
contact: Option<String>,
profile: Option<String>,
#[allow(non_snake_case)] groupId: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getSticker", param_kind = map)]
fn get_sticker(
&self,
account: Option<String>,
#[allow(non_snake_case)] packId: String,
#[allow(non_snake_case)] stickerId: u32,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "getUserStatus", param_kind = map)]
fn get_user_status(
&self,
account: Option<String>,
recipients: Vec<String>,
usernames: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "joinGroup", param_kind = map)]
fn join_group(&self, account: Option<String>, uri: String) -> Result<Value, ErrorObjectOwned>;
#[allow(non_snake_case)]
#[method(name = "finishChangeNumber", param_kind = map)]
fn finish_change_number(
&self,
account: Option<String>,
number: String,
verificationCode: String,
pin: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "finishLink", param_kind = map)]
fn finish_link(
&self,
#[allow(non_snake_case)] deviceLinkUri: String,
#[allow(non_snake_case)] deviceName: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listAccounts", param_kind = map)]
fn list_accounts(&self) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listContacts", param_kind = map)]
fn list_contacts(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] allRecipients: bool,
blocked: Option<bool>,
name: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listDevices", param_kind = map)]
fn list_devices(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listGroups", param_kind = map)]
fn list_groups(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listIdentities", param_kind = map)]
fn list_identities(
&self,
account: Option<String>,
number: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "listStickerPacks", param_kind = map)]
fn list_sticker_packs(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "quitGroup", param_kind = map)]
fn quit_group(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupId: String,
delete: bool,
admins: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "register", param_kind = map)]
fn register(
&self,
account: Option<String>,
voice: bool,
captcha: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeContact", param_kind = map)]
fn remove_contact(
&self,
account: Option<String>,
recipient: String,
forget: bool,
hide: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removeDevice", param_kind = map)]
fn remove_device(
&self,
account: Option<String>,
#[allow(non_snake_case)] deviceId: u32,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "removePin", param_kind = map)]
fn remove_pin(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "remoteDelete", param_kind = map)]
fn remote_delete(
&self,
account: Option<String>,
#[allow(non_snake_case)] targetTimestamp: u64,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool,
) -> Result<Value, ErrorObjectOwned>;
#[allow(non_snake_case)]
#[method(name = "send", param_kind = map)]
fn send(
&self,
account: Option<String>,
recipients: Vec<String>,
groupIds: Vec<String>,
noteToSelf: bool,
endSession: bool,
message: String,
attachments: Vec<String>,
viewOnce: bool,
mentions: Vec<String>,
textStyle: Vec<String>,
quoteTimestamp: Option<u64>,
quoteAuthor: Option<String>,
quoteMessage: Option<String>,
quoteMention: Vec<String>,
quoteTextStyle: Vec<String>,
quoteAttachment: Vec<String>,
previewUrl: Option<String>,
previewTitle: Option<String>,
previewDescription: Option<String>,
previewImage: Option<String>,
sticker: Option<String>,
storyTimestamp: Option<u64>,
storyAuthor: Option<String>,
editTimestamp: Option<u64>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendContacts", param_kind = map)]
fn send_contacts(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendPaymentNotification", param_kind = map)]
fn send_payment_notification(
&self,
account: Option<String>,
recipient: String,
receipt: String,
note: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendReaction", param_kind = map)]
fn send_reaction(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
#[allow(non_snake_case)] noteToSelf: bool,
emoji: String,
#[allow(non_snake_case)] targetAuthor: String,
#[allow(non_snake_case)] targetTimestamp: u64,
remove: bool,
story: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendReceipt", param_kind = map)]
fn send_receipt(
&self,
account: Option<String>,
recipient: String,
#[allow(non_snake_case)] targetTimestamps: Vec<u64>,
r#type: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendSyncRequest", param_kind = map)]
fn send_sync_request(&self, account: Option<String>) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendTyping", param_kind = map)]
fn send_typing(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
stop: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "sendMessageRequestResponse", param_kind = map)]
fn send_message_request_response(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
r#type: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "setPin", param_kind = map)]
fn set_pin(&self, account: Option<String>, pin: String) -> Result<Value, ErrorObjectOwned>;
#[method(name = "submitRateLimitChallenge", param_kind = map)]
fn submit_rate_limit_challenge(
&self,
account: Option<String>,
challenge: String,
captcha: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "startChangeNumber", param_kind = map)]
fn start_change_number(
&self,
account: Option<String>,
number: String,
voice: bool,
captcha: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "startLink", param_kind = map)]
fn start_link(&self, account: Option<String>) -> Result<JsonLink, ErrorObjectOwned>;
#[method(name = "trust", param_kind = map)]
fn trust(
&self,
account: Option<String>,
recipient: String,
#[allow(non_snake_case)] trustAllKnownKeys: bool,
#[allow(non_snake_case)] verifiedSafetyNumber: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "unblock", param_kind = map)]
fn unblock(
&self,
account: Option<String>,
recipients: Vec<String>,
#[allow(non_snake_case)] groupIds: Vec<String>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "unregister", param_kind = map)]
fn unregister(
&self,
account: Option<String>,
#[allow(non_snake_case)] deleteAccount: bool,
) -> Result<Value, ErrorObjectOwned>;
#[allow(non_snake_case)]
#[method(name = "updateAccount", param_kind = map)]
fn update_account(
&self,
account: Option<String>,
deviceName: Option<String>,
unrestrictedUnidentifiedSender: Option<bool>,
discoverableByNumber: Option<bool>,
numberSharing: Option<bool>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateConfiguration", param_kind = map)]
fn update_configuration(
&self,
account: Option<String>,
#[allow(non_snake_case)] readReceipts: Option<bool>,
#[allow(non_snake_case)] unidentifiedDeliveryIndicators: Option<bool>,
#[allow(non_snake_case)] typingIndicators: Option<bool>,
#[allow(non_snake_case)] linkPreviews: Option<bool>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateContact", param_kind = map)]
fn update_contact(
&self,
account: Option<String>,
recipient: String,
name: Option<String>,
expiration: Option<u32>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateGroup", param_kind = map)]
fn update_group(
&self,
account: Option<String>,
#[allow(non_snake_case)] groupId: Option<String>,
name: Option<String>,
description: Option<String>,
avatar: Option<String>,
member: Vec<String>,
#[allow(non_snake_case)] removeMember: Vec<String>,
admin: Vec<String>,
#[allow(non_snake_case)] removeAdmin: Vec<String>,
ban: Vec<String>,
unban: Vec<String>,
#[allow(non_snake_case)] resetLink: bool,
#[allow(non_snake_case)] link: Option<String>,
#[allow(non_snake_case)] setPermissionAddMember: Option<String>,
#[allow(non_snake_case)] setPermissionEditDetails: Option<String>,
#[allow(non_snake_case)] setPermissionSendMessages: Option<String>,
expiration: Option<u32>,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "updateProfile", param_kind = map)]
fn update_profile(
&self,
account: Option<String>,
#[allow(non_snake_case)] givenName: Option<String>,
#[allow(non_snake_case)] familyName: Option<String>,
about: Option<String>,
#[allow(non_snake_case)] aboutEmoji: Option<String>,
#[allow(non_snake_case)] mobileCoinAddress: Option<String>,
avatar: Option<String>,
#[allow(non_snake_case)] removeAvatar: bool,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "uploadStickerPack", param_kind = map)]
fn upload_sticker_pack(
&self,
account: Option<String>,
path: String,
) -> Result<Value, ErrorObjectOwned>;
#[method(name = "verify", param_kind = map)]
fn verify(
&self,
account: Option<String>,
#[allow(non_snake_case)] verificationCode: String,
pin: Option<String>,
) -> Result<Value, ErrorObjectOwned>;
#[subscription(
name = "subscribeReceive" => "receive",
unsubscribe = "unsubscribeReceive",
item = RecvMessage,
param_kind = map
)]
async fn subscribe_receive(&self, account: Option<String>) -> SubscriptionResult;
#[method(name = "version")]
fn version(&self) -> Result<Value, ErrorObjectOwned>;
}
#[allow(unused)]
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct JsonLink {
pub device_link_uri: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RecvMessage {
pub envelope: Envelope,
}
#[allow(unused)]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Envelope {
pub source: String,
pub source_number: String,
pub source_uuid: String,
pub source_name: String,
pub source_device: i64,
pub timestamp: u64,
pub data_message: Option<DataMessage>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataMessage {
pub timestamp: u64,
pub message: String,
#[serde(default)]
pub group_info: Option<GroupInfo>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
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,
) -> Result<impl SubscriptionClientT, std::io::Error> {
let (sender, receiver) = crate::transports::tcp::connect(tcp).await?;
Ok(ClientBuilder::default().build_with_tokio(sender, receiver))
}
/// Connect to signal-cli over unix domain socket
#[cfg(unix)]
pub async fn connect_ipc(
path: impl AsRef<std::path::Path>,
) -> Result<impl SubscriptionClientT, std::io::Error> {
let (sender, receiver) = crate::transports::ipc::connect(path).await?;
Ok(ClientBuilder::default().build_with_tokio(sender, receiver))
}
impl Envelope {
/// Send a read-receipt for an envelope
pub async fn send_read_receipt(
&self,
client: &impl RpcClient,
account: impl Into<String>,
) -> Result<(), RpcClientError> {
if let Some(dm) = self.data_message.as_ref() {
let _ = client
.send_receipt(
Some(account.into()),
self.source_uuid.clone(),
vec![dm.timestamp],
"read".into(),
)
.await?;
}
Ok(())
}
}
/// Target for a SignalMessage - either individual recipients or a group
#[derive(Clone, Debug)]
pub enum MessageTarget {
/// Send to individual recipients
Recipients(Vec<String>),
/// Send to a group
Group(String),
}
/// Helper for invoking send, which has way too many parameters
pub struct SignalMessage {
pub sender: String,
pub target: MessageTarget,
pub message: String,
pub attachments: Vec<String>,
}
impl SignalMessage {
#[allow(non_snake_case)]
pub async fn send(self, client: &impl RpcClient) -> Result<(), RpcClientError> {
// See note about string indexing here: https://github.com/AsamK/signal-cli/wiki/FAQ#string-indexing-units
let message_len_utf16: usize = self.message.chars().map(|c| c.len_utf16()).sum();
/*
account: Option<String>,
recipients: Vec<String>,
groupIds: Vec<String>,
noteToSelf: bool,
endSession: bool,
message: String,
attachments: Vec<String>,
viewOnce: bool,
mentions: Vec<String>,
textStyle: Vec<String>,
quoteTimestamp: Option<u64>,
quoteAuthor: Option<String>,
quoteMessage: Option<String>,
quoteMention: Vec<String>,
quoteTextStyle: Vec<String>,
quoteAttachment: Vec<String>,
previewUrl: Option<String>,
previewTitle: Option<String>,
previewDescription: Option<String>,
previewImage: Option<String>,
sticker: Option<String>,
storyTimestamp: Option<u64>,
storyAuthor: Option<String>,
editTimestamp: Option<u64>,
*/
let account = Some(self.sender);
let (recipients, groupIds) = match self.target {
MessageTarget::Recipients(r) => (r, vec![]),
MessageTarget::Group(g) => (vec![], vec![g]),
};
let noteToSelf = false;
let endSession = false;
let message = self.message;
let attachments = self.attachments;
let viewOnce = false;
let mentions = vec![];
let textStyle = vec![format!("0:{message_len_utf16}:MONOSPACE")];
let quoteTimestamp = None;
let quoteAuthor = None;
let quoteMention = vec![];
let quoteMessage = None;
let quoteTextStyle = vec![];
let quoteAttachment = vec![];
let previewUrl = None;
let previewTitle = None;
let previewDescription = None;
let previewImage = None;
let sticker = None;
let storyTimestamp = None;
let storyAuthor = None;
let editTimestamp = None;
let _resp = client
.send(
account,
recipients,
groupIds,
noteToSelf,
endSession,
message,
attachments,
viewOnce,
mentions,
textStyle,
quoteTimestamp,
quoteAuthor,
quoteMessage,
quoteMention,
quoteTextStyle,
quoteAttachment,
previewUrl,
previewTitle,
previewDescription,
previewImage,
sticker,
storyTimestamp,
storyAuthor,
editTimestamp,
)
.await?;
Ok(())
}
}
@@ -0,0 +1,21 @@
use std::{io::Error, path::Path};
use futures_util::stream::StreamExt;
use jsonrpsee::core::client::{TransportReceiverT, TransportSenderT};
use tokio::net::UnixStream;
use tokio_util::codec::Decoder;
use super::{Receiver, Sender, stream_codec::StreamCodec};
/// Connect to a JSON-RPC server via Unix domain socket.
pub async fn connect(
socket: impl AsRef<Path>,
) -> Result<(impl TransportSenderT + Send, impl TransportReceiverT + Send), Error> {
let connection = UnixStream::connect(socket).await?;
let (sink, stream) = StreamCodec::stream_incoming().framed(connection).split();
let sender = Sender { inner: sink };
let receiver = Receiver { inner: stream };
Ok((sender, receiver))
}
@@ -0,0 +1,62 @@
//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs
use futures_util::{Sink, SinkExt, Stream, stream::StreamExt};
use jsonrpsee::core::client::{ReceivedMessage, TransportReceiverT, TransportSenderT};
use thiserror::Error;
#[cfg(unix)]
pub mod ipc;
mod stream_codec;
pub mod tcp;
#[derive(Debug, Error)]
enum Errors {
#[error("Other: {0}")]
Other(String),
#[error("Closed")]
Closed,
}
struct Sender<T: Send + Sink<String>> {
inner: T,
}
impl<T: Send + Sink<String, Error = impl std::error::Error> + Unpin + 'static> TransportSenderT
for Sender<T>
{
type Error = Errors;
async fn send(&mut self, body: String) -> Result<(), Self::Error> {
self.inner
.send(body)
.await
.map_err(|e| Errors::Other(format!("{e:?}")))?;
Ok(())
}
async fn close(&mut self) -> Result<(), Self::Error> {
self.inner
.close()
.await
.map_err(|e| Errors::Other(format!("{e:?}")))?;
Ok(())
}
}
struct Receiver<T: Send + Stream> {
inner: T,
}
impl<T: Send + Stream<Item = Result<String, std::io::Error>> + Unpin + 'static> TransportReceiverT
for Receiver<T>
{
type Error = Errors;
async fn receive(&mut self) -> Result<ReceivedMessage, Self::Error> {
match self.inner.next().await {
None => Err(Errors::Closed),
Some(Ok(msg)) => Ok(ReceivedMessage::Text(msg)),
Some(Err(e)) => Err(Errors::Other(format!("{e:?}"))),
}
}
}
@@ -0,0 +1,65 @@
//! This copied from https://github.com/AsamK/signal-cli/blob/f9a36c6e0404d06bd396b24b5ea699e49ed29b89/client/src/jsonrpc.rs
use std::{io, str};
use tokio_util::{
bytes::BytesMut,
codec::{Decoder, Encoder},
};
type Separator = u8;
/// Stream codec for streaming protocols (ipc, tcp)
#[derive(Debug, Default)]
pub struct StreamCodec {
incoming_separator: Separator,
outgoing_separator: Separator,
}
impl StreamCodec {
/// Default codec with streaming input data. Input can be both enveloped and not.
pub fn stream_incoming() -> Self {
StreamCodec::new(b'\n', b'\n')
}
/// New custom stream codec
pub fn new(incoming_separator: Separator, outgoing_separator: Separator) -> Self {
StreamCodec {
incoming_separator,
outgoing_separator,
}
}
}
impl Decoder for StreamCodec {
type Item = String;
type Error = io::Error;
fn decode(&mut self, buf: &mut BytesMut) -> io::Result<Option<Self::Item>> {
if let Some(i) = buf
.as_ref()
.iter()
.position(|&b| b == self.incoming_separator)
{
let line = buf.split_to(i);
let _ = buf.split_to(1);
match str::from_utf8(line.as_ref()) {
Ok(s) => Ok(Some(s.to_string())),
Err(_) => Err(io::Error::other("invalid UTF-8")),
}
} else {
Ok(None)
}
}
}
impl Encoder<String> for StreamCodec {
type Error = io::Error;
fn encode(&mut self, msg: String, buf: &mut BytesMut) -> io::Result<()> {
let mut payload = msg.into_bytes();
payload.push(self.outgoing_separator);
buf.extend_from_slice(&payload);
Ok(())
}
}
@@ -0,0 +1,21 @@
use std::io::Error;
use futures_util::stream::StreamExt;
use jsonrpsee::core::client::{TransportReceiverT, TransportSenderT};
use tokio::net::{TcpStream, ToSocketAddrs};
use tokio_util::codec::Decoder;
use super::{Receiver, Sender, stream_codec::StreamCodec};
/// Connect to a JSON-RPC TCP server.
pub async fn connect(
socket: impl ToSocketAddrs,
) -> Result<(impl TransportSenderT + Send, impl TransportReceiverT + Send), Error> {
let connection = TcpStream::connect(socket).await?;
let (sink, stream) = StreamCodec::stream_incoming().framed(connection).split();
let sender = Sender { inner: sink };
let receiver = Receiver { inner: stream };
Ok((sender, receiver))
}
+546
View File
@@ -0,0 +1,546 @@
//! Signal trust set - a set of Signal UUIDs with optional safety numbers.
//!
//! Supports two deserialization formats:
//! - Map: `{"uuid1": ["safety1", "safety2"], "uuid2": []}` - UUIDs with safety numbers
//! - Sequence: `["uuid1", "uuid2"]` - UUIDs with no safety numbers (simpler)
use crate::{Envelope, Identity, RpcClient};
use serde::{
Deserialize, Deserializer,
de::{MapAccess, SeqAccess, Visitor},
};
use std::{borrow::Borrow, collections::HashMap, fmt, ops::Deref, str::FromStr};
use tracing::{debug, info, warn};
/// A validated Signal UUID in the format `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`.
#[derive(Clone, PartialEq, Eq, Hash, Deserialize)]
#[serde(try_from = "String")]
pub struct Uuid(String);
impl fmt::Debug for Uuid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl FromStr for Uuid {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// UUID format: 8-4-4-4-12 hex chars (36 chars total with dashes)
if s.len() != 36 {
return Err(format!("UUID must be 36 characters, got {}", s.len()));
}
let parts: Vec<&str> = s.split('-').collect();
if parts.len() != 5 {
return Err(format!(
"UUID must have 5 dash-separated parts, got {}",
parts.len()
));
}
let expected_lens = [8, 4, 4, 4, 12];
for (i, (part, &expected)) in parts.iter().zip(&expected_lens).enumerate() {
if part.len() != expected {
return Err(format!(
"UUID part {} has wrong length: expected {}, got {}",
i + 1,
expected,
part.len()
));
}
if !part.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(format!("UUID part {} contains non-hex characters", i + 1));
}
}
Ok(Uuid(s.to_owned()))
}
}
impl TryFrom<String> for Uuid {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl TryFrom<&str> for Uuid {
type Error = String;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
impl Deref for Uuid {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<str> for Uuid {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Borrow<str> for Uuid {
fn borrow(&self) -> &str {
&self.0
}
}
impl fmt::Display for Uuid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// A validated Signal safety number (60 digits, optionally separated by whitespace).
#[derive(Clone, PartialEq, Eq, Hash, Deserialize)]
#[serde(try_from = "String")]
pub struct SafetyNumber(String);
impl fmt::Debug for SafetyNumber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.0, f)
}
}
impl FromStr for SafetyNumber {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// Safety number format: 60 digits, optionally grouped with whitespace
let mut digit_count = 0;
for c in s.chars() {
if c.is_ascii_digit() {
digit_count += 1;
} else if !c.is_whitespace() {
return Err(format!(
"Safety number must contain only digits and whitespace, found '{c}'"
));
}
}
if digit_count != 60 {
return Err(format!(
"Safety number must contain exactly 60 digits, got {digit_count}"
));
}
Ok(SafetyNumber(s.to_owned()))
}
}
impl TryFrom<String> for SafetyNumber {
type Error = String;
fn try_from(s: String) -> Result<Self, Self::Error> {
s.parse()
}
}
impl TryFrom<&str> for SafetyNumber {
type Error = String;
fn try_from(s: &str) -> Result<Self, Self::Error> {
s.parse()
}
}
impl Deref for SafetyNumber {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl AsRef<str> for SafetyNumber {
fn as_ref(&self) -> &str {
&self.0
}
}
impl Borrow<str> for SafetyNumber {
fn borrow(&self) -> &str {
&self.0
}
}
impl fmt::Display for SafetyNumber {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// A set of Signal UUIDs with optional safety numbers for trust verification.
///
/// Can be deserialized from either:
/// - A map of UUID -> safety numbers: `{"uuid1": ["12345..."], "uuid2": []}`
/// - A sequence of UUIDs (no safety numbers): `["uuid1", "uuid2"]`
#[derive(Clone, Default)]
pub struct SignalTrustSet {
map: HashMap<Uuid, Vec<SafetyNumber>>,
}
impl fmt::Debug for SignalTrustSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Debug::fmt(&self.map, f)
}
}
impl SignalTrustSet {
/// Create an empty container.
pub fn new() -> Self {
Self::default()
}
/// Check if the sender of an envelope is trusted.
///
/// Currently checks if the source UUID is in the trust set.
/// Will eventually also verify safety numbers.
pub fn is_trusted(&self, envelope: &Envelope) -> bool {
self.map.contains_key(envelope.source_uuid.as_str())
}
/// Get all UUIDs as an iterator of string slices.
pub fn uuids(&self) -> impl Iterator<Item = &str> {
self.map.keys().map(|u| u.as_ref())
}
/// Get the number of admin UUIDs.
pub fn len(&self) -> usize {
self.map.len()
}
/// Check if empty.
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
/// Iterate over UUID and safety number pairs.
pub fn iter(&self) -> impl Iterator<Item = (&Uuid, &Vec<SafetyNumber>)> {
self.map.iter()
}
/// Get safety numbers for a specific UUID.
pub fn get(&self, uuid: &str) -> Option<&Vec<SafetyNumber>> {
self.map.get(uuid)
}
/// Update trust for all UUIDs with safety numbers configured.
///
/// For each UUID 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
///
/// Returns an error if any trust operation fails.
pub async fn update_trust(
&self,
signal_cli: &impl RpcClient,
signal_account: &str,
) -> Result<(), String> {
info!("Updating trust for {} configured UUIDs", self.map.len());
for (uuid, safety_numbers) in &self.map {
if safety_numbers.is_empty() {
continue;
}
// Helper to check if a safety number string is in our configured list
let is_configured = |sn: &str| safety_numbers.iter().any(|s| s.as_ref() == sn);
// Get current identities from signal-cli
let current_identities: Vec<Identity> = match signal_cli
.list_identities(Some(signal_account.to_owned()), Some(uuid.to_string()))
.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() && !is_configured(&id.safety_number));
if has_revoked_identity {
// Log which identities are being revoked
for id in &current_identities {
if id.trust_level.is_trusted() && !is_configured(&id.safety_number) {
warn!(
"Revoking trust for {uuid}: safety number {} is trusted in signal-cli but not in config",
id.safety_number
);
}
}
// Remove contact to clear all existing trust
warn!("Resetting all trust for {uuid} due to revoked identity");
signal_cli
.remove_contact(
Some(signal_account.to_owned()),
uuid.to_string(),
true, // forget - delete identity keys and sessions
false, // hide
)
.await
.map_err(|err| format!("Failed to remove contact {uuid}: {err}"))?;
// Re-add all configured safety numbers
for safety_number in safety_numbers {
info!("Trusting safety number for {uuid}");
signal_cli
.trust(
Some(signal_account.to_owned()),
uuid.to_string(),
false,
Some(safety_number.to_string()),
)
.await
.map_err(|err| format!("Failed to trust {uuid}: {err}"))?;
}
// Verify the reset worked correctly
let new_identities: Vec<Identity> = signal_cli
.list_identities(Some(signal_account.to_owned()), Some(uuid.to_string()))
.await
.map_err(|err| format!("Failed to verify trust reset for {uuid}: {err}"))
.and_then(|value| {
serde_json::from_value(value)
.map_err(|err| format!("Failed to parse identities for {uuid}: {err}"))
})?;
let trusted_now: Vec<&str> = new_identities
.iter()
.filter(|id| id.trust_level.is_trusted())
.map(|id| id.safety_number.as_str())
.collect();
// Check all configured safety numbers are now trusted
for safety_number in safety_numbers {
if !trusted_now.contains(&safety_number.as_ref()) {
return Err(format!(
"Verification failed for {uuid}: safety number {} should be trusted but isn't",
safety_number
));
}
}
// Check no unexpected safety numbers are trusted
for sn in &trusted_now {
if !is_configured(sn) {
return Err(format!(
"Verification failed for {uuid}: safety number {} is trusted but not in config",
sn
));
}
}
info!(
"Trust reset verified for {uuid}: {} safety numbers trusted",
trusted_now.len()
);
} else {
// Just add any new safety numbers that aren't already trusted
let already_trusted: Vec<&str> = current_identities
.iter()
.filter(|id| id.trust_level.is_trusted())
.map(|id| id.safety_number.as_str())
.collect();
for safety_number in safety_numbers {
if !already_trusted.contains(&safety_number.as_ref()) {
info!("Trusting new safety number for {uuid}");
signal_cli
.trust(
Some(signal_account.to_owned()),
uuid.to_string(),
false,
Some(safety_number.to_string()),
)
.await
.map_err(|err| format!("Failed to trust {uuid}: {err}"))?;
}
}
}
}
Ok(())
}
}
impl<'de> Deserialize<'de> for SignalTrustSet {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(SignalTrustSetVisitor)
}
}
struct SignalTrustSetVisitor;
impl<'de> Visitor<'de> for SignalTrustSetVisitor {
type Value = SignalTrustSet;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a map of UUIDs to safety numbers, or a sequence of UUIDs")
}
fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
where
M: MapAccess<'de>,
{
let mut map = HashMap::with_capacity(access.size_hint().unwrap_or(0));
while let Some((key, value)) = access.next_entry::<Uuid, Vec<SafetyNumber>>()? {
map.insert(key, value);
}
Ok(SignalTrustSet { map })
}
fn visit_seq<S>(self, mut access: S) -> Result<Self::Value, S::Error>
where
S: SeqAccess<'de>,
{
let mut map = HashMap::with_capacity(access.size_hint().unwrap_or(0));
while let Some(uuid) = access.next_element::<Uuid>()? {
map.insert(uuid, Vec::new());
}
Ok(SignalTrustSet { map })
}
}
impl FromIterator<Uuid> for SignalTrustSet {
fn from_iter<I: IntoIterator<Item = Uuid>>(iter: I) -> Self {
Self {
map: iter.into_iter().map(|uuid| (uuid, Vec::new())).collect(),
}
}
}
impl FromIterator<(Uuid, Vec<SafetyNumber>)> for SignalTrustSet {
fn from_iter<I: IntoIterator<Item = (Uuid, Vec<SafetyNumber>)>>(iter: I) -> Self {
Self {
map: iter.into_iter().collect(),
}
}
}
impl<'a> IntoIterator for &'a SignalTrustSet {
type Item = (&'a Uuid, &'a Vec<SafetyNumber>);
type IntoIter = std::collections::hash_map::Iter<'a, Uuid, Vec<SafetyNumber>>;
fn into_iter(self) -> Self::IntoIter {
self.map.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
const UUID1: &str = "12345678-1234-1234-1234-123456789abc";
const UUID2: &str = "abcdef12-abcd-abcd-abcd-abcdef123456";
const UUID3: &str = "00000000-0000-0000-0000-000000000000";
const SAFETY1: &str = "123456789012345678901234567890123456789012345678901234567890";
const SAFETY2: &str = "098765432109876543210987654321098765432109876543210987654321";
#[test]
fn test_uuid_validation() {
assert!(UUID1.parse::<Uuid>().is_ok());
assert!("not-a-uuid".parse::<Uuid>().is_err());
assert!("12345678-1234-1234-1234-12345678".parse::<Uuid>().is_err()); // too short
assert!(
"12345678-1234-1234-1234-123456789abcdef"
.parse::<Uuid>()
.is_err()
); // too long
assert!(
"12345678-1234-1234-1234-123456789xyz"
.parse::<Uuid>()
.is_err()
); // non-hex
}
#[test]
fn test_safety_number_validation() {
assert!(SAFETY1.parse::<SafetyNumber>().is_ok());
// With whitespace (common format)
assert!(
"12345 67890 12345 67890 12345 67890 12345 67890 12345 67890 12345 67890"
.parse::<SafetyNumber>()
.is_ok()
);
assert!("12345".parse::<SafetyNumber>().is_err()); // too short
assert!(
"12345678901234567890123456789012345678901234567890123456789x"
.parse::<SafetyNumber>()
.is_err()
); // non-digit
}
#[test]
fn test_deserialize_map() {
let json = format!(r#"{{"{UUID1}": ["{SAFETY1}", "{SAFETY2}"], "{UUID2}": []}}"#);
let trust_set: SignalTrustSet = serde_json::from_str(&json).unwrap();
assert_eq!(trust_set.len(), 2);
assert!(trust_set.get(UUID1).is_some());
assert!(trust_set.get(UUID2).is_some());
assert_eq!(trust_set.get(UUID1).unwrap().len(), 2);
assert!(trust_set.get(UUID2).unwrap().is_empty());
}
#[test]
fn test_deserialize_seq() {
let json = format!(r#"["{UUID1}", "{UUID2}", "{UUID3}"]"#);
let trust_set: SignalTrustSet = serde_json::from_str(&json).unwrap();
assert_eq!(trust_set.len(), 3);
assert!(trust_set.get(UUID1).is_some());
assert!(trust_set.get(UUID2).is_some());
assert!(trust_set.get(UUID3).is_some());
// All should have empty safety numbers
assert!(trust_set.get(UUID1).unwrap().is_empty());
assert!(trust_set.get(UUID2).unwrap().is_empty());
assert!(trust_set.get(UUID3).unwrap().is_empty());
}
#[test]
fn test_empty_map() {
let json = r#"{}"#;
let uuids: SignalTrustSet = serde_json::from_str(json).unwrap();
assert!(uuids.is_empty());
}
#[test]
fn test_empty_seq() {
let json = r#"[]"#;
let uuids: SignalTrustSet = serde_json::from_str(json).unwrap();
assert!(uuids.is_empty());
}
#[test]
fn test_invalid_uuid_rejected() {
let json = r#"["not-a-valid-uuid"]"#;
assert!(serde_json::from_str::<SignalTrustSet>(json).is_err());
}
#[test]
fn test_invalid_safety_number_rejected() {
let json = format!(r#"{{"{UUID1}": ["invalid"]}}"#);
assert!(serde_json::from_str::<SignalTrustSet>(&json).is_err());
}
}