feat(mobile): per-user device bindings, multi-user Inbox routing, and admin-mediated authorization

- Device→user bindings persisted in config table (auth.rs), loaded at plugin start
- RelayApp now routes Inbox responses per-user via UserChannelApi, never globally
- New mobile_bind_device LLM tool for admin-mediated device→user assignment
- Per-user event forwarders (events.rs) with per-user debounced notifiers
- Config listener (auth::config_listener) refreshes bindings cache reactively
- Reconcile loop catches users who unlock after boot
- Hello/Logout treated as device-registry ops (no user resolution needed)
- Unbound device payloads are silently dropped
- RelayAgent::authorize_client → bind_device (atomic bind + authorize)
- Approval rules seed mobile_bind_device/revoke_device as require
This commit is contained in:
2026-07-11 11:18:35 +01:00
parent a847dda88f
commit 2c54778116
15 changed files with 856 additions and 221 deletions
+223 -92
View File
@@ -1,40 +1,80 @@
//! The application layer on top of the payload-agnostic [`RelayClient`].
//!
//! `RelayApp` owns the Skald-specific semantics that the transport crate
//! deliberately knows nothing about: the E2E JSON payload schemas (`payloads`),
//! the `InboxApi` dispatch, and the authorization policy. It consumes
//! `client.events()` and calls `client.send(...)`; the client handles the wire,
//! crypto, counters, and device registry.
//! `RelayApp` is the plugin's central shared hub (the mobile analogue of the
//! Telegram `TgShared`): it owns the E2E payload semantics, the device→user
//! bindings cache, and the per-user forwarder/notifier registries. It knows
//! nothing about the wire — the client handles transport, crypto, counters, and
//! the device registry.
//!
//! # Multi-user (blueprint §13)
//!
//! Every device is bound to one Skald user (`auth::Binding`). Inbound payloads
//! resolve the sending device's user, then apply to **that user's** Inbox via the
//! [`UserChannelApi`] seam. Outbound Inbox pushes go only to the authorized
//! devices bound to the target user — never a global broadcast. A per-user
//! forwarder (`events`) drives pushes from the user's event stream.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use anyhow::Result;
use tokio::sync::broadcast;
use tokio::sync::{broadcast, Mutex, RwLock};
use tokio_util::sync::CancellationToken;
use tracing::{debug, warn};
use tracing::{debug, info, warn};
use core_api::inbox::InboxApi;
use core_api::config_api::ConfigApi;
use core_api::user_channel::UserChannelApi;
use skald_relay_client::{ClientState, RelayClient, RelayEvent};
use crate::PLUGIN_ID;
use crate::auth::{self, MobileConfig};
use crate::notifier::DelayedNotifier;
use crate::payloads::{self, ClientPayload};
/// Glue between the relay transport ([`RelayClient`]) and Skald's Inbox.
/// The plugin's shared application state.
pub struct RelayApp {
client: Arc<RelayClient>,
inbox: Arc<dyn InboxApi>,
/// When true, a freshly paired device stays Pending until a human confirms;
/// when false, the app auto-authorizes on `ClientPaired`.
/// Per-user runtime resolver (blueprint §13). `None` = user locked (§9).
pub(crate) user_channel: Arc<dyn UserChannelApi>,
/// Config store — used to persist binding removals (logout/revoke).
config: Arc<dyn ConfigApi>,
/// Device→user bindings, cached in memory; kept in sync by `auth::config_listener`.
pub(crate) bindings: RwLock<MobileConfig>,
/// When true, a freshly paired device stays Pending until an admin binds it
/// (`mobile_bind_device`, which authorizes it). Binding *is* the confirmation.
require_device_confirmation: bool,
/// Debounce before an unresolved Inbox item is pushed to the phone.
pub(crate) notify_delay: Duration,
/// Cancellation for every task spawned by this run (forwarders, listeners).
cancel: CancellationToken,
/// user_ids with an active per-user forwarder task.
pub(crate) forwarders: Mutex<HashSet<String>>,
/// Per-user debounced notifiers, created on demand by the forwarders.
pub(crate) notifiers: Mutex<HashMap<String, Arc<DelayedNotifier>>>,
}
impl RelayApp {
pub fn new(
client: Arc<RelayClient>,
inbox: Arc<dyn InboxApi>,
user_channel: Arc<dyn UserChannelApi>,
config: Arc<dyn ConfigApi>,
bindings: MobileConfig,
require_device_confirmation: bool,
) -> Self {
Self { client, inbox, require_device_confirmation }
notify_delay: Duration,
cancel: CancellationToken,
) -> Arc<Self> {
Arc::new(Self {
client,
user_channel,
config,
bindings: RwLock::new(bindings),
require_device_confirmation,
notify_delay,
cancel,
forwarders: Mutex::new(HashSet::new()),
notifiers: Mutex::new(HashMap::new()),
})
}
/// The underlying transport client (used by the `RelayAgent` impl + router).
@@ -42,128 +82,219 @@ impl RelayApp {
&self.client
}
// ── Inbox → clients ───────────────────────────────────────────────────────
/// Cancellation token for this run's spawned tasks.
pub(crate) fn cancel(&self) -> CancellationToken {
self.cancel.clone()
}
/// Build the Inbox snapshot and send it (encrypted) to every Authorized
/// client. `live=false` so the relay stores-and-forwards + pushes to offline
/// phones.
pub async fn broadcast_inbox(&self) -> Result<()> {
let snapshot = self.inbox.list_pending().await;
/// Write guard on the bindings cache (used by the config listener).
pub(crate) async fn bindings_mut(&self) -> tokio::sync::RwLockWriteGuard<'_, MobileConfig> {
self.bindings.write().await
}
/// The user bound to a device pubkey, if any.
pub(crate) async fn user_for_device(&self, pubkey: &[u8; 32]) -> Option<String> {
self.bindings.read().await.user_for_pubkey(&hex::encode(pubkey))
}
// ── Bind / unbind (admin-mediated, via the control tools) ─────────────────
/// Bind a paired device to a user and authorize it, then push that user's
/// current Inbox to it. Persists the binding (fires `ConfigKeyUpdated`, which
/// refreshes the cache and spawns the user's forwarder).
pub async fn bind_device(
&self,
pubkey: [u8; 32],
user_id: String,
display: Option<String>,
) -> Result<()> {
// Update the persisted config + local cache up front (avoids a race with
// the listener before its event arrives).
let snapshot = {
let mut cfg = self.bindings.write().await;
cfg.upsert(auth::Binding { pubkey_hex: hex::encode(pubkey), user_id: user_id.clone(), display });
cfg.clone()
};
auth::save_config(&*self.config, &snapshot).await?;
// Authorize at the relay level so pushes/sends reach the device.
self.client.authorize(&pubkey).await?;
info!(plugin = PLUGIN_ID, user_id = %user_id, device = %hex::encode(pubkey), "device bound + authorized");
// Send the user's current Inbox to the freshly bound device.
if let Err(e) = self.push_inbox_to_user(&user_id).await {
warn!(plugin = PLUGIN_ID, error = %e, "failed to push inbox after bind");
}
Ok(())
}
/// Revoke a device and drop its binding.
pub async fn revoke_device(&self, pubkey: [u8; 32]) -> Result<()> {
self.client.revoke(&pubkey).await?;
let snapshot = {
let mut cfg = self.bindings.write().await;
cfg.remove(&hex::encode(pubkey));
cfg.clone()
};
auth::save_config(&*self.config, &snapshot).await?;
Ok(())
}
// ── Inbox → user's devices ────────────────────────────────────────────────
/// Build the Inbox snapshot for `user_id` and send it to every Authorized
/// device bound to that user. `live=false` so the relay stores-and-forwards +
/// pushes to offline phones. No-op if the user is locked (§9).
pub async fn push_inbox_to_user(&self, user_id: &str) -> Result<()> {
let Some(handle) = self.user_channel.resolve_user(user_id).await else {
debug!(plugin = PLUGIN_ID, user_id, "inbox push skipped — user locked");
return Ok(());
};
let snapshot = handle.inbox().list_pending().await;
let plaintext = serde_json::to_vec(&payloads::build_inbox_update(&snapshot))?;
self.broadcast_plaintext(&plaintext).await;
self.send_to_user_devices(user_id, &plaintext, false).await;
Ok(())
}
/// Build and send a generic notification to all Authorized clients.
pub async fn broadcast_notification(&self, title: &str, body: &str) -> Result<()> {
let plaintext = serde_json::to_vec(&payloads::build_notification(title, body))?;
self.broadcast_plaintext(&plaintext).await;
Ok(())
}
/// Send an opaque plaintext to every Authorized device (`live=false`).
async fn broadcast_plaintext(&self, plaintext: &[u8]) {
for c in self
/// Send an opaque plaintext to every Authorized device bound to `user_id`.
async fn send_to_user_devices(&self, user_id: &str, plaintext: &[u8], live: bool) {
let bound = self.bindings.read().await.pubkeys_for_user(user_id);
if bound.is_empty() {
return;
}
let authorized: HashSet<[u8; 32]> = self
.client
.list_clients()
.await
.into_iter()
.filter(|c| c.state == ClientState::Authorized)
{
if let Err(e) = self.client.send(&c.ed25519_pub, plaintext, false).await {
warn!(plugin = PLUGIN_ID, error = %e, "failed to send to client");
.map(|c| c.ed25519_pub)
.collect();
for pk_hex in bound {
let Some(pk) = skald_relay_common::crypto::decode_hex::<32>(&pk_hex) else { continue };
if !authorized.contains(&pk) {
continue;
}
if let Err(e) = self.client.send(&pk, plaintext, live).await {
warn!(plugin = PLUGIN_ID, error = %e, "failed to send to device");
}
}
}
/// Send the current Inbox snapshot to a single client (the targeted reply to
/// `inbox_request`). `live=true`: the requester is online by construction.
async fn send_inbox_to(&self, client_ed25519_pub: &[u8; 32]) -> Result<()> {
let snapshot = self.inbox.list_pending().await;
/// Send the current Inbox snapshot to a single requesting device (`live=true`:
/// the requester is online by construction).
async fn send_inbox_to_device(&self, user_id: &str, device: &[u8; 32]) -> Result<()> {
let Some(handle) = self.user_channel.resolve_user(user_id).await else {
return Ok(());
};
let snapshot = handle.inbox().list_pending().await;
let plaintext = serde_json::to_vec(&payloads::build_inbox_update(&snapshot))?;
self.client.send(client_ed25519_pub, &plaintext, true).await
self.client.send(device, &plaintext, true).await
}
// ── Clients → Inbox ───────────────────────────────────────────────────────
// ── Devices → Inbox ───────────────────────────────────────────────────────
/// Apply a decoded client payload to the Inbox. `payload` is the clean inner
/// JSON the client already decrypted + de-framed.
/// Apply a decoded client payload to the sending device's *user's* Inbox.
/// Unbound device or locked user → the request is ignored (no cross-user leak).
async fn apply_client_payload(&self, from: &[u8; 32], payload: &[u8]) {
match payloads::parse_client_payload(payload) {
ClientPayload::ApprovalResponse { request_id, approved, reason } => {
if approved {
self.inbox.approve(request_id).await;
} else {
self.inbox.reject(request_id, reason.unwrap_or_default()).await;
}
let _ = self.broadcast_inbox().await;
}
ClientPayload::ClarificationResponse { request_id, answer } => {
self.inbox.answer(request_id, answer).await;
let _ = self.broadcast_inbox().await;
}
ClientPayload::ElicitationResponse { request_id, action, content } => {
// `content` may hold a secret (SSH/sudo password): hand it straight
// to the Inbox; never log/persist it in clear (payloads.md §3.1).
self.inbox.resolve_elicitation(request_id, action, content).await;
let _ = self.broadcast_inbox().await;
}
let parsed = payloads::parse_client_payload(payload);
// Hello / Logout are device-registry ops that need no user resolution.
match &parsed {
ClientPayload::Hello { device_info } => {
if let Err(e) = self.client.set_device_info(from, &device_info.to_string()).await {
warn!(plugin = PLUGIN_ID, error = %e, "failed to persist device_info");
}
}
ClientPayload::InboxRequest => {
if let Err(e) = self.send_inbox_to(from).await {
warn!(plugin = PLUGIN_ID, error = %e, "failed to send targeted inbox snapshot");
}
return;
}
ClientPayload::Logout => {
if let Err(e) = self.client.revoke(from).await {
if let Err(e) = self.revoke_device(*from).await {
warn!(plugin = PLUGIN_ID, error = %e, "logout revoke failed");
}
return;
}
ClientPayload::Unknown => {
debug!(plugin = PLUGIN_ID, "unknown/ignored client payload");
return;
}
_ => {}
}
// Everything else acts on a user's Inbox: resolve the device's user.
let Some(user_id) = self.user_for_device(from).await else {
warn!(plugin = PLUGIN_ID, device = %hex::encode(from), "payload from unbound device — ignored");
return;
};
let Some(handle) = self.user_channel.resolve_user(&user_id).await else {
debug!(plugin = PLUGIN_ID, user_id = %user_id, "payload dropped — user locked");
return;
};
let inbox = handle.inbox();
match parsed {
ClientPayload::ApprovalResponse { request_id, approved, reason } => {
if approved {
inbox.approve(request_id).await;
} else {
inbox.reject(request_id, reason.unwrap_or_default()).await;
}
let _ = self.push_inbox_to_user(&user_id).await;
}
ClientPayload::ClarificationResponse { request_id, answer } => {
inbox.answer(request_id, answer).await;
let _ = self.push_inbox_to_user(&user_id).await;
}
ClientPayload::ElicitationResponse { request_id, action, content } => {
// `content` may hold a secret (SSH/sudo password): hand it straight
// to the Inbox; never log/persist it in clear (payloads.md §3.1).
inbox.resolve_elicitation(request_id, action, content).await;
let _ = self.push_inbox_to_user(&user_id).await;
}
ClientPayload::InboxRequest => {
if let Err(e) = self.send_inbox_to_device(&user_id, from).await {
warn!(plugin = PLUGIN_ID, error = %e, "failed to send targeted inbox snapshot");
}
}
// Handled above.
ClientPayload::Hello { .. } | ClientPayload::Logout | ClientPayload::Unknown => {}
}
}
// ── Event loop ────────────────────────────────────────────────────────────
/// Consume the client's [`RelayEvent`] stream until `cancel` fires. This is
/// where the authorization policy and Inbox application live.
pub async fn run_event_loop(
self: Arc<Self>,
mut rx: broadcast::Receiver<RelayEvent>,
cancel: CancellationToken,
) {
/// Consume the client's [`RelayEvent`] stream until cancelled. Applies inbound
/// payloads and the pairing authorization policy, and lazily spawns a per-user
/// forwarder when a bound device becomes active.
pub async fn run_event_loop(self: Arc<Self>, mut rx: broadcast::Receiver<RelayEvent>) {
let cancel = self.cancel.clone();
loop {
tokio::select! {
_ = cancel.cancelled() => break,
ev = rx.recv() => match ev {
Ok(RelayEvent::Message { from, payload, .. }) => {
// A bound + unlocked device becoming active ⇒ ensure its
// user's forwarder is running so Inbox events reach the phone.
if let Some(user_id) = self.user_for_device(&from).await {
if let Some(handle) = self.user_channel.resolve_user(&user_id).await {
crate::events::ensure_forwarder(Arc::clone(&self), user_id, handle).await;
}
}
self.apply_client_payload(&from, &payload).await;
}
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
if self.require_device_confirmation {
debug!(
plugin = PLUGIN_ID,
device = %hex::encode(ed25519_pub),
"new device paired (pending manual confirmation)"
);
let _ = self
.broadcast_notification(
"New device",
"A new device is pending confirmation",
)
.await;
} else if let Err(e) = self.client.authorize(&ed25519_pub).await {
warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed");
} else {
// Send the newly-authorized device the current snapshot.
let _ = self.broadcast_inbox().await;
// The device is not bound to any user yet, so there is no
// one to push to. An admin binds it with `mobile_bind_device`
// (which authorizes it). We only optionally pre-authorize.
if !self.require_device_confirmation {
if let Err(e) = self.client.authorize(&ed25519_pub).await {
warn!(plugin = PLUGIN_ID, error = %e, "auto-authorize failed");
}
}
info!(
plugin = PLUGIN_ID,
device = %hex::encode(ed25519_pub),
"new device paired — awaiting admin binding (mobile_bind_device)"
);
}
Ok(RelayEvent::ClientRevoked { .. })
| Ok(RelayEvent::Connected)