feat: Sprint 10+11 — privacy hardening and multi-device support
Privacy Hardening (Sprint 10): - Server --redact-logs flag: SHA-256 hashed identity prefixes in audit logs, payload_len omitted when enabled - Client /privacy command suite: redact-keys on|off, auto-clear with duration parsing, padding on|off for traffic analysis resistance - Forward secrecy: /verify-fs checks MLS epoch advancement, /rotate-all-keys rotates MLS leaf + hybrid KEM keypair - Dummy message type (0x09): constant-rate traffic padding every 30s, silently discarded by recipients, serialize_dummy() + parse support - delete_messages_before() for auto-clear in ConversationStore Multi-Device Support (Sprint 11): - Device registry: registerDevice @24, listDevices @25, revokeDevice @26 RPCs with Device struct (deviceId, deviceName, registeredAt) - Server storage: devices table (migration 008), max 5 per identity, E029_DEVICE_LIMIT and E030_DEVICE_NOT_FOUND error codes - Device cleanup integrated into deleteAccount transaction - Client REPL: /devices, /register-device <name>, /revoke-device <id> 72 core + 35 server tests pass.
This commit is contained in:
@@ -749,6 +749,15 @@ impl ConversationStore {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete messages older than `cutoff_ms` (epoch milliseconds) across all conversations.
|
||||
pub fn delete_messages_before(&self, cutoff_ms: u64) -> anyhow::Result<usize> {
|
||||
let rows = self.conn.execute(
|
||||
"DELETE FROM messages WHERE timestamp_ms < ?1",
|
||||
params![cutoff_ms as i64],
|
||||
)?;
|
||||
Ok(rows)
|
||||
}
|
||||
}
|
||||
|
||||
/// An entry in the offline outbox queue.
|
||||
|
||||
@@ -12,7 +12,7 @@ use anyhow::Context;
|
||||
use quicproquo_core::{
|
||||
AppMessage, DiskKeyStore, GroupMember, IdentityKeypair, ReceivedMessage,
|
||||
compute_safety_number, hybrid_encrypt, parse as parse_app_msg, serialize_chat,
|
||||
serialize_delete, serialize_edit, serialize_file_ref, serialize_reaction,
|
||||
serialize_delete, serialize_dummy, serialize_edit, serialize_file_ref, serialize_reaction,
|
||||
serialize_read_receipt, serialize_typing,
|
||||
};
|
||||
use quicproquo_proto::node_capnp::node_service;
|
||||
@@ -27,8 +27,9 @@ use super::conversation::{
|
||||
use super::display;
|
||||
use super::rpc::{
|
||||
connect_node, create_channel, delete_account, download_blob_chunk, enqueue,
|
||||
enqueue_with_ttl, fetch_hybrid_key, fetch_key_package, fetch_wait, resolve_identity,
|
||||
resolve_user, try_hybrid_decrypt, upload_blob_chunk, upload_hybrid_key, upload_key_package,
|
||||
enqueue_with_ttl, fetch_hybrid_key, fetch_key_package, fetch_wait, list_devices,
|
||||
register_device, resolve_identity, resolve_user, revoke_device, try_hybrid_decrypt,
|
||||
upload_blob_chunk, upload_hybrid_key, upload_key_package,
|
||||
};
|
||||
use super::session::SessionState;
|
||||
use super::state::{decode_identity_key, load_or_init_state};
|
||||
@@ -89,6 +90,18 @@ enum SlashCommand {
|
||||
DeleteAccount,
|
||||
/// Set or query disappearing message TTL for the active conversation.
|
||||
Disappear { arg: Option<String> },
|
||||
/// Privacy controls: redact-keys, auto-clear, padding.
|
||||
Privacy { arg: Option<String> },
|
||||
/// Verify that MLS epoch has advanced since last send (forward secrecy check).
|
||||
VerifyFs,
|
||||
/// Rotate MLS leaf key AND regenerate + upload hybrid KEM keypair.
|
||||
RotateAllKeys,
|
||||
/// List all registered devices.
|
||||
Devices,
|
||||
/// Register this device with a name.
|
||||
RegisterDevice { name: String },
|
||||
/// Revoke a device by hex ID prefix.
|
||||
RevokeDevice { id_prefix: String },
|
||||
}
|
||||
|
||||
fn parse_input(line: &str) -> Input {
|
||||
@@ -284,6 +297,24 @@ fn parse_input(line: &str) -> Input {
|
||||
},
|
||||
"/delete-account" => Input::Slash(SlashCommand::DeleteAccount),
|
||||
"/disappear" => Input::Slash(SlashCommand::Disappear { arg }),
|
||||
"/privacy" => Input::Slash(SlashCommand::Privacy { arg }),
|
||||
"/verify-fs" => Input::Slash(SlashCommand::VerifyFs),
|
||||
"/rotate-all-keys" => Input::Slash(SlashCommand::RotateAllKeys),
|
||||
"/devices" => Input::Slash(SlashCommand::Devices),
|
||||
"/register-device" => match arg {
|
||||
Some(name) => Input::Slash(SlashCommand::RegisterDevice { name }),
|
||||
None => {
|
||||
display::print_error("usage: /register-device <name>");
|
||||
Input::Empty
|
||||
}
|
||||
},
|
||||
"/revoke-device" => match arg {
|
||||
Some(id_prefix) => Input::Slash(SlashCommand::RevokeDevice { id_prefix }),
|
||||
None => {
|
||||
display::print_error("usage: /revoke-device <hex-id-prefix>");
|
||||
Input::Empty
|
||||
}
|
||||
},
|
||||
_ => {
|
||||
display::print_error(&format!("unknown command: {cmd}. Try /help"));
|
||||
Input::Empty
|
||||
@@ -566,6 +597,28 @@ pub async fn run_repl(
|
||||
let now = std::time::Instant::now();
|
||||
session.typing_indicators.retain(|_, ts| now.duration_since(*ts).as_secs() < 10);
|
||||
|
||||
// Auto-clear: delete messages older than the configured duration.
|
||||
if let Some(max_age_secs) = session.auto_clear_secs {
|
||||
let cutoff_ms = now_ms().saturating_sub(max_age_secs as u64 * 1000);
|
||||
if let Err(e) = session.conv_store.delete_messages_before(cutoff_ms) {
|
||||
tracing::debug!(error = %e, "auto-clear failed");
|
||||
}
|
||||
}
|
||||
|
||||
// Traffic padding: send a dummy message every ~30 seconds.
|
||||
if session.padding_enabled {
|
||||
static LAST_PADDING: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let now_secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let last = LAST_PADDING.load(std::sync::atomic::Ordering::Relaxed);
|
||||
if now_secs.saturating_sub(last) >= 30 {
|
||||
LAST_PADDING.store(now_secs, std::sync::atomic::Ordering::Relaxed);
|
||||
send_dummy_message(&mut session, &client).await;
|
||||
}
|
||||
}
|
||||
|
||||
match poll_messages(&mut session, &client).await {
|
||||
Ok(()) => {
|
||||
consecutive_errors = 0;
|
||||
@@ -782,6 +835,12 @@ async fn handle_slash(
|
||||
SlashCommand::Download { index } => cmd_download(session, client, index).await,
|
||||
SlashCommand::DeleteAccount => cmd_delete_account(session, client).await,
|
||||
SlashCommand::Disappear { arg } => cmd_disappear(session, arg.as_deref()),
|
||||
SlashCommand::Privacy { arg } => cmd_privacy(session, arg.as_deref()),
|
||||
SlashCommand::VerifyFs => cmd_verify_fs(session),
|
||||
SlashCommand::RotateAllKeys => cmd_rotate_all_keys(session, client).await,
|
||||
SlashCommand::Devices => cmd_devices(client).await,
|
||||
SlashCommand::RegisterDevice { name } => cmd_register_device(client, &name).await,
|
||||
SlashCommand::RevokeDevice { id_prefix } => cmd_revoke_device(client, &id_prefix).await,
|
||||
};
|
||||
if let Err(e) = result {
|
||||
display::print_error(&format!("{e:#}"));
|
||||
@@ -824,6 +883,15 @@ fn print_help() {
|
||||
display::print_status(" /disappear <duration> - Set disappearing messages (1h, 30m, 1d, 300)");
|
||||
display::print_status(" /disappear off - Disable disappearing messages");
|
||||
display::print_status(" /disappear - Show current setting");
|
||||
display::print_status(" /privacy - Show current privacy settings");
|
||||
display::print_status(" /privacy redact-keys on|off - Redact identity keys in /members, /group-info");
|
||||
display::print_status(" /privacy auto-clear <dur> - Auto-clear local messages older than <dur>");
|
||||
display::print_status(" /privacy padding on|off - Toggle dummy traffic padding");
|
||||
display::print_status(" /verify-fs - Verify MLS forward secrecy (epoch advancement)");
|
||||
display::print_status(" /rotate-all-keys - Rotate MLS key + regenerate hybrid KEM keypair");
|
||||
display::print_status(" /devices - List all registered devices");
|
||||
display::print_status(" /register-device <name> - Register this device with a name");
|
||||
display::print_status(" /revoke-device <hex-prefix> - Remove a device by hex ID prefix");
|
||||
display::print_status(" /quit - Exit");
|
||||
}
|
||||
|
||||
@@ -898,6 +966,148 @@ fn cmd_disappear(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_privacy(
|
||||
session: &mut SessionState,
|
||||
arg: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
match arg {
|
||||
None => {
|
||||
display::print_status(&format!(
|
||||
"redact-keys: {}",
|
||||
if session.redact_keys { "on" } else { "off" }
|
||||
));
|
||||
display::print_status(&format!(
|
||||
"auto-clear: {}",
|
||||
match session.auto_clear_secs {
|
||||
Some(secs) => format_ttl(secs),
|
||||
None => "off".to_string(),
|
||||
}
|
||||
));
|
||||
display::print_status(&format!(
|
||||
"padding: {}",
|
||||
if session.padding_enabled { "on" } else { "off" }
|
||||
));
|
||||
}
|
||||
Some(s) if s.starts_with("redact-keys ") => {
|
||||
let val = s.trim_start_matches("redact-keys ").trim();
|
||||
match val {
|
||||
"on" => {
|
||||
session.redact_keys = true;
|
||||
display::print_status("key redaction enabled");
|
||||
}
|
||||
"off" => {
|
||||
session.redact_keys = false;
|
||||
display::print_status("key redaction disabled");
|
||||
}
|
||||
_ => display::print_error("usage: /privacy redact-keys on|off"),
|
||||
}
|
||||
}
|
||||
Some(s) if s.starts_with("auto-clear ") => {
|
||||
let val = s.trim_start_matches("auto-clear ").trim();
|
||||
if val.eq_ignore_ascii_case("off") {
|
||||
session.auto_clear_secs = None;
|
||||
display::print_status("auto-clear disabled");
|
||||
} else {
|
||||
let secs = parse_duration_secs(val)
|
||||
.context("invalid duration; use e.g. 30m, 1h, 1d, or 300")?;
|
||||
if secs == 0 {
|
||||
anyhow::bail!("auto-clear duration must be greater than 0");
|
||||
}
|
||||
session.auto_clear_secs = Some(secs);
|
||||
display::print_status(&format!(
|
||||
"auto-clear enabled: messages older than {} will be deleted",
|
||||
format_ttl(secs)
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(s) if s.starts_with("padding ") => {
|
||||
let val = s.trim_start_matches("padding ").trim();
|
||||
match val {
|
||||
"on" => {
|
||||
session.padding_enabled = true;
|
||||
display::print_status("traffic padding enabled (dummy messages every 30s)");
|
||||
}
|
||||
"off" => {
|
||||
session.padding_enabled = false;
|
||||
display::print_status("traffic padding disabled");
|
||||
}
|
||||
"status" => {
|
||||
display::print_status(&format!(
|
||||
"padding: {}",
|
||||
if session.padding_enabled { "on" } else { "off" }
|
||||
));
|
||||
}
|
||||
_ => display::print_error("usage: /privacy padding on|off|status"),
|
||||
}
|
||||
}
|
||||
Some(_) => {
|
||||
display::print_error("usage: /privacy [redact-keys on|off | auto-clear <dur>|off | padding on|off|status]");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_verify_fs(session: &SessionState) -> anyhow::Result<()> {
|
||||
let conv_id = session
|
||||
.active_conversation
|
||||
.as_ref()
|
||||
.context("no active conversation")?;
|
||||
|
||||
let member = session
|
||||
.members
|
||||
.get(conv_id)
|
||||
.context("no group member for active conversation")?;
|
||||
|
||||
let current_epoch = member.epoch().context("no MLS group in active conversation")?;
|
||||
|
||||
match session.last_send_epoch {
|
||||
Some(last) if current_epoch > last => {
|
||||
display::print_status(&format!(
|
||||
"forward secrecy OK: epoch advanced from {} to {}",
|
||||
last, current_epoch
|
||||
));
|
||||
}
|
||||
Some(last) if current_epoch == last => {
|
||||
display::print_status(&format!(
|
||||
"warning: MLS epoch has NOT advanced since last send (epoch {}). \
|
||||
Use /rotate-all-keys or /update-key to rotate keys.",
|
||||
current_epoch
|
||||
));
|
||||
}
|
||||
Some(last) => {
|
||||
display::print_status(&format!(
|
||||
"unexpected: current epoch {} < last send epoch {}",
|
||||
current_epoch, last
|
||||
));
|
||||
}
|
||||
None => {
|
||||
display::print_status(&format!(
|
||||
"no previous send recorded. Current epoch: {}",
|
||||
current_epoch
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_rotate_all_keys(
|
||||
session: &mut SessionState,
|
||||
client: &node_service::Client,
|
||||
) -> anyhow::Result<()> {
|
||||
// Step 1: MLS leaf key rotation (same as /update-key).
|
||||
cmd_update_key(session, client).await?;
|
||||
|
||||
// Step 2: Generate new hybrid KEM keypair and upload.
|
||||
let new_kp = quicproquo_core::HybridKeypair::generate();
|
||||
let id_key = session.identity.public_key_bytes();
|
||||
upload_hybrid_key(client, &id_key, &new_kp.public_key()).await?;
|
||||
session.hybrid_kp = Some(new_kp);
|
||||
|
||||
display::print_status("all keys rotated: MLS leaf key + hybrid KEM keypair");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Discover nearby qpq servers via mDNS (requires `--features mesh` build).
|
||||
fn cmd_mesh_peers() -> anyhow::Result<()> {
|
||||
use super::mesh_discovery::MeshDiscovery;
|
||||
@@ -1626,11 +1836,20 @@ async fn cmd_members(
|
||||
let ids = member.member_identities();
|
||||
let mut names = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
let mut name = resolve_or_hex(client, id).await;
|
||||
if id.as_slice() == my_key.as_slice() {
|
||||
name.push_str(" (you)");
|
||||
if session.redact_keys {
|
||||
let short = &hex::encode(&id[..4.min(id.len())]);
|
||||
let mut name = format!("[redacted-{short}]");
|
||||
if id.as_slice() == my_key.as_slice() {
|
||||
name.push_str(" (you)");
|
||||
}
|
||||
names.push(name);
|
||||
} else {
|
||||
let mut name = resolve_or_hex(client, id).await;
|
||||
if id.as_slice() == my_key.as_slice() {
|
||||
name.push_str(" (you)");
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
display::print_status(&format!("Members ({}): {}", names.len(), names.join(", ")));
|
||||
Ok(())
|
||||
@@ -1665,11 +1884,20 @@ async fn cmd_group_info(
|
||||
|
||||
let mut names = Vec::with_capacity(ids.len());
|
||||
for id in &ids {
|
||||
let mut name = resolve_or_hex(client, id).await;
|
||||
if id.as_slice() == my_key.as_slice() {
|
||||
name.push_str(" (you)");
|
||||
if session.redact_keys {
|
||||
let short = &hex::encode(&id[..4.min(id.len())]);
|
||||
let mut name = format!("[redacted-{short}]");
|
||||
if id.as_slice() == my_key.as_slice() {
|
||||
name.push_str(" (you)");
|
||||
}
|
||||
names.push(name);
|
||||
} else {
|
||||
let mut name = resolve_or_hex(client, id).await;
|
||||
if id.as_slice() == my_key.as_slice() {
|
||||
name.push_str(" (you)");
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
display::print_status(&format!(" {}", names.join(", ")));
|
||||
|
||||
@@ -2451,12 +2679,19 @@ async fn do_send(
|
||||
.send_message(&padded)
|
||||
.context("MLS send_message failed")?;
|
||||
|
||||
// Collect epoch and recipients before releasing the mutable borrow on session.
|
||||
let epoch = member.epoch();
|
||||
let recipients: Vec<Vec<u8>> = member
|
||||
.member_identities()
|
||||
.into_iter()
|
||||
.filter(|id| id.as_slice() != my_key.as_slice())
|
||||
.collect();
|
||||
|
||||
// Track epoch for /verify-fs (must be after member borrow is released).
|
||||
if let Some(epoch) = epoch {
|
||||
session.last_send_epoch = Some(epoch);
|
||||
}
|
||||
|
||||
let ttl = session.disappear_ttl.get(&conv_id).copied();
|
||||
|
||||
for recipient_key in &recipients {
|
||||
@@ -2496,6 +2731,60 @@ async fn do_send(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a dummy message for traffic analysis resistance.
|
||||
async fn send_dummy_message(
|
||||
session: &mut SessionState,
|
||||
client: &node_service::Client,
|
||||
) {
|
||||
let conv_id = match session.active_conversation.as_ref() {
|
||||
Some(id) => id.clone(),
|
||||
None => return,
|
||||
};
|
||||
let my_key = session.identity_bytes();
|
||||
let identity = std::sync::Arc::clone(&session.identity);
|
||||
|
||||
let member = match session.get_member_mut(&conv_id) {
|
||||
Some(m) => m,
|
||||
None => return,
|
||||
};
|
||||
if member.group_ref().is_none() {
|
||||
return;
|
||||
}
|
||||
|
||||
let recipients: Vec<Vec<u8>> = member
|
||||
.member_identities()
|
||||
.into_iter()
|
||||
.filter(|id| id.as_slice() != my_key.as_slice())
|
||||
.collect();
|
||||
|
||||
if recipients.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let dummy_payload = serialize_dummy();
|
||||
let sealed = quicproquo_core::sealed_sender::seal(&identity, &dummy_payload);
|
||||
let padded = quicproquo_core::padding::pad(&sealed);
|
||||
|
||||
let ct = match member.send_message(&padded) {
|
||||
Ok(ct) => ct,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let idx = (now_ms() as usize) % recipients.len();
|
||||
let rk = &recipients[idx];
|
||||
|
||||
let payload = match fetch_hybrid_key(client, rk).await {
|
||||
Ok(Some(ref pk)) => match hybrid_encrypt(pk, &ct, b"", b"") {
|
||||
Ok(p) => p,
|
||||
Err(_) => ct,
|
||||
},
|
||||
_ => ct,
|
||||
};
|
||||
|
||||
let _ = enqueue(client, rk, &payload).await;
|
||||
let _ = session.save_member(&conv_id);
|
||||
}
|
||||
|
||||
// ── Outbox drain ─────────────────────────────────────────────────────────────
|
||||
|
||||
async fn drain_outbox(
|
||||
@@ -2600,6 +2889,13 @@ async fn poll_messages(
|
||||
break;
|
||||
}
|
||||
|
||||
// Dummy messages: silently discard (traffic padding).
|
||||
if let Ok((_, AppMessage::Dummy)) = &parsed {
|
||||
any_changed = true;
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Read receipts: ephemeral, show subtle notification.
|
||||
if let Ok((_, AppMessage::ReadReceipt { .. })) = &parsed {
|
||||
let is_active = session
|
||||
@@ -2941,3 +3237,81 @@ async fn replenish_pending_key(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Device management commands ──────────────────────────────────────────────
|
||||
|
||||
async fn cmd_devices(client: &node_service::Client) -> anyhow::Result<()> {
|
||||
let devices = list_devices(client).await?;
|
||||
if devices.is_empty() {
|
||||
display::print_status("No devices registered.");
|
||||
return Ok(());
|
||||
}
|
||||
display::print_status(&format!("{} device(s):", devices.len()));
|
||||
for (device_id, name, registered_at) in &devices {
|
||||
let id_hex = hex::encode(device_id);
|
||||
let name_display = if name.is_empty() { "(unnamed)" } else { name.as_str() };
|
||||
display::print_status(&format!(
|
||||
" {} - {} (registered: {})",
|
||||
&id_hex[..16.min(id_hex.len())],
|
||||
name_display,
|
||||
registered_at
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_register_device(
|
||||
client: &node_service::Client,
|
||||
name: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut device_id = [0u8; 16];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut device_id);
|
||||
let success = register_device(client, &device_id, name).await?;
|
||||
if success {
|
||||
display::print_status(&format!(
|
||||
"Device registered: {} ({})",
|
||||
hex::encode(device_id),
|
||||
name
|
||||
));
|
||||
} else {
|
||||
display::print_error("Device already exists with that ID.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn cmd_revoke_device(
|
||||
client: &node_service::Client,
|
||||
id_prefix: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let devices = list_devices(client).await?;
|
||||
let prefix_lower = id_prefix.to_lowercase();
|
||||
let matches: Vec<_> = devices
|
||||
.iter()
|
||||
.filter(|(id, _, _)| hex::encode(id).starts_with(&prefix_lower))
|
||||
.collect();
|
||||
|
||||
match matches.len() {
|
||||
0 => {
|
||||
display::print_error(&format!("No device matching prefix '{id_prefix}'"));
|
||||
}
|
||||
1 => {
|
||||
let (device_id, name, _) = matches[0];
|
||||
let success = revoke_device(client, device_id).await?;
|
||||
if success {
|
||||
display::print_status(&format!(
|
||||
"Device revoked: {} ({})",
|
||||
hex::encode(device_id),
|
||||
if name.is_empty() { "(unnamed)" } else { name.as_str() }
|
||||
));
|
||||
} else {
|
||||
display::print_error("Device not found on server.");
|
||||
}
|
||||
}
|
||||
n => {
|
||||
display::print_error(&format!(
|
||||
"Ambiguous prefix '{id_prefix}' matches {n} devices. Be more specific."
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -870,6 +870,105 @@ pub async fn delete_account(
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
/// Register a device for the authenticated identity.
|
||||
pub async fn register_device(
|
||||
client: &node_service::Client,
|
||||
device_id: &[u8],
|
||||
device_name: &str,
|
||||
) -> anyhow::Result<bool> {
|
||||
let mut req = client.register_device_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
p.set_device_id(device_id);
|
||||
p.set_device_name(device_name);
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
set_auth(&mut auth)?;
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("register_device RPC failed")?;
|
||||
|
||||
let success = resp
|
||||
.get()
|
||||
.context("register_device: bad response")?
|
||||
.get_success();
|
||||
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
/// List all registered devices for the authenticated identity.
|
||||
pub async fn list_devices(
|
||||
client: &node_service::Client,
|
||||
) -> anyhow::Result<Vec<(Vec<u8>, String, u64)>> {
|
||||
let mut req = client.list_devices_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
set_auth(&mut auth)?;
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("list_devices RPC failed")?;
|
||||
|
||||
let devices = resp
|
||||
.get()
|
||||
.context("list_devices: bad response")?
|
||||
.get_devices()
|
||||
.context("list_devices: missing devices field")?;
|
||||
|
||||
let mut result = Vec::with_capacity(devices.len() as usize);
|
||||
for i in 0..devices.len() {
|
||||
let entry = devices.get(i);
|
||||
let device_id = entry
|
||||
.get_device_id()
|
||||
.context("list_devices: missing device_id")?
|
||||
.to_vec();
|
||||
let device_name = entry
|
||||
.get_device_name()
|
||||
.context("list_devices: missing device_name")?
|
||||
.to_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let registered_at = entry.get_registered_at();
|
||||
result.push((device_id, device_name, registered_at));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Revoke (remove) a registered device.
|
||||
pub async fn revoke_device(
|
||||
client: &node_service::Client,
|
||||
device_id: &[u8],
|
||||
) -> anyhow::Result<bool> {
|
||||
let mut req = client.revoke_device_request();
|
||||
{
|
||||
let mut p = req.get();
|
||||
p.set_device_id(device_id);
|
||||
let mut auth = p.reborrow().init_auth();
|
||||
set_auth(&mut auth)?;
|
||||
}
|
||||
|
||||
let resp = req
|
||||
.send()
|
||||
.promise
|
||||
.await
|
||||
.context("revoke_device RPC failed")?;
|
||||
|
||||
let success = resp
|
||||
.get()
|
||||
.context("revoke_device: bad response")?
|
||||
.get_success();
|
||||
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
/// Return the current Unix timestamp in milliseconds.
|
||||
pub fn current_timestamp_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
|
||||
@@ -44,6 +44,14 @@ pub struct SessionState {
|
||||
pub typing_indicators: HashMap<String, Instant>,
|
||||
/// Per-conversation disappearing message TTL in seconds. None = messages persist.
|
||||
pub disappear_ttl: HashMap<ConversationId, u32>,
|
||||
/// When true, /members and /group-info redact identity keys as `[redacted-XXXX]`.
|
||||
pub redact_keys: bool,
|
||||
/// When Some(secs), auto-clear local messages older than this duration.
|
||||
pub auto_clear_secs: Option<u32>,
|
||||
/// When true, send periodic dummy messages for traffic analysis resistance.
|
||||
pub padding_enabled: bool,
|
||||
/// Last epoch at which we sent a message (for /verify-fs).
|
||||
pub last_send_epoch: Option<u64>,
|
||||
}
|
||||
|
||||
impl SessionState {
|
||||
@@ -80,6 +88,10 @@ impl SessionState {
|
||||
typing_notify_enabled: true,
|
||||
typing_indicators: HashMap::new(),
|
||||
disappear_ttl: HashMap::new(),
|
||||
redact_keys: false,
|
||||
auto_clear_secs: None,
|
||||
padding_enabled: false,
|
||||
last_send_epoch: None,
|
||||
};
|
||||
|
||||
// Migrate legacy single-group into conversations if present and not yet migrated.
|
||||
|
||||
Reference in New Issue
Block a user