- DS sender identity binding (Phase 4.3): explicit audit logging of sender_prefix in enqueue/batch_enqueue, documenting that sender identity is always derived from authenticated session - Username enumeration mitigation (Phase 4.5): 5ms timing floor on resolveUser responses + rate limiting to prevent bulk enumeration - Add /update-key REPL command for MLS leaf key rotation via propose_self_update + auto-commit + fan-out to group members - Add 4 new E2E tests: message delivery round-trip, key rotation update path, oversized payload rejection, multi-party group (12 total)
180 lines
6.7 KiB
Rust
180 lines
6.7 KiB
Rust
//! resolveUser / resolveIdentity RPCs: bidirectional username ↔ identity key lookup.
|
|
|
|
use capnp::capability::Promise;
|
|
use quicproquo_proto::node_capnp::node_service;
|
|
use std::time::Duration;
|
|
use tokio::time::Instant;
|
|
|
|
use crate::auth::{check_rate_limit, coded_error, validate_auth_context};
|
|
use crate::error_codes::*;
|
|
use crate::metrics;
|
|
use crate::storage::StorageError;
|
|
|
|
use super::NodeServiceImpl;
|
|
|
|
/// Minimum response time for resolveUser to mask DB lookup timing differences.
|
|
const RESOLVE_TIMING_FLOOR: Duration = Duration::from_millis(5);
|
|
|
|
fn storage_err(err: StorageError) -> capnp::Error {
|
|
coded_error(E009_STORAGE_ERROR, err)
|
|
}
|
|
|
|
impl NodeServiceImpl {
|
|
pub fn handle_resolve_user(
|
|
&mut self,
|
|
params: node_service::ResolveUserParams,
|
|
mut results: node_service::ResolveUserResults,
|
|
) -> Promise<(), capnp::Error> {
|
|
let p = match params.get() {
|
|
Ok(p) => p,
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
let username = match p.get_username() {
|
|
Ok(u) => u,
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
let auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) {
|
|
Ok(ctx) => ctx,
|
|
Err(e) => return Promise::err(e),
|
|
};
|
|
|
|
let username_str = match username.to_str() {
|
|
Ok(s) => s,
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
|
|
if username_str.is_empty() {
|
|
return Promise::err(coded_error(E020_BAD_PARAMS, "username must not be empty"));
|
|
}
|
|
|
|
// Federation: parse user@domain format.
|
|
let addr = crate::federation::address::FederatedAddress::parse(username_str);
|
|
let is_remote = match (&addr.domain, &self.local_domain) {
|
|
(Some(d), Some(ld)) => d != ld,
|
|
(Some(_), None) => true,
|
|
_ => false,
|
|
};
|
|
|
|
if is_remote {
|
|
// Proxy to remote server via federation.
|
|
if let (Some(ref fed_client), Some(ref domain)) = (&self.federation_client, &addr.domain) {
|
|
if fed_client.has_peer(domain) {
|
|
let fed = fed_client.clone();
|
|
let user = addr.username.clone();
|
|
let dom = domain.clone();
|
|
return Promise::from_future(async move {
|
|
match fed.proxy_resolve_user(&dom, &user).await {
|
|
Ok(Some(key)) => {
|
|
results.get().set_identity_key(&key);
|
|
}
|
|
Ok(None) => {
|
|
// Not found on remote — return empty.
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "federation proxy_resolve_user failed");
|
|
// Fall through — return empty (not found).
|
|
}
|
|
}
|
|
Ok(())
|
|
});
|
|
}
|
|
}
|
|
// No federation client or unknown peer — return empty (not found).
|
|
return Promise::ok(());
|
|
}
|
|
|
|
// Rate-limit resolve requests to prevent bulk enumeration.
|
|
if let Err(e) = check_rate_limit(&self.rate_limits, &auth_ctx.token) {
|
|
tracing::warn!("rate_limit_hit");
|
|
metrics::record_rate_limit_hit_total();
|
|
return Promise::err(e);
|
|
}
|
|
|
|
// Timing floor: record the start time so we can pad the response to a
|
|
// fixed minimum duration, masking DB-lookup timing differences between
|
|
// existing and non-existing usernames.
|
|
let deadline = Instant::now() + RESOLVE_TIMING_FLOOR;
|
|
|
|
// Local resolution.
|
|
let identity_key = match self.store.get_user_identity_key(&addr.username) {
|
|
Ok(Some(key)) => key,
|
|
Ok(None) => {
|
|
// Return empty Data — caller checks length to detect "not found".
|
|
// Pad to timing floor before responding.
|
|
return Promise::from_future(async move {
|
|
tokio::time::sleep_until(deadline).await;
|
|
Ok(())
|
|
});
|
|
}
|
|
Err(e) => return Promise::err(storage_err(e)),
|
|
};
|
|
|
|
let mut r = results.get();
|
|
r.set_identity_key(&identity_key);
|
|
|
|
// Attempt to include a KT Merkle inclusion proof.
|
|
// Non-fatal: if the log is unavailable or has no entry, return just the key.
|
|
if let Ok(log) = self.kt_log.lock() {
|
|
if let Some(leaf_idx) = log.find(&addr.username, &identity_key) {
|
|
match log.inclusion_proof(leaf_idx) {
|
|
Ok(proof) => match proof.to_bytes() {
|
|
Ok(bytes) => {
|
|
r.set_inclusion_proof(&bytes);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "KT proof serialise failed");
|
|
}
|
|
},
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "KT inclusion_proof failed");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Pad to timing floor before responding.
|
|
Promise::from_future(async move {
|
|
tokio::time::sleep_until(deadline).await;
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
pub fn handle_resolve_identity(
|
|
&mut self,
|
|
params: node_service::ResolveIdentityParams,
|
|
mut results: node_service::ResolveIdentityResults,
|
|
) -> Promise<(), capnp::Error> {
|
|
let p = match params.get() {
|
|
Ok(p) => p,
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
let identity_key = match p.get_identity_key() {
|
|
Ok(v) => v,
|
|
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
|
};
|
|
let _auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) {
|
|
Ok(ctx) => ctx,
|
|
Err(e) => return Promise::err(e),
|
|
};
|
|
|
|
if identity_key.len() != 32 {
|
|
return Promise::err(coded_error(
|
|
E004_IDENTITY_KEY_LENGTH,
|
|
format!("identityKey must be exactly 32 bytes, got {}", identity_key.len()),
|
|
));
|
|
}
|
|
|
|
match self.store.resolve_identity_key(identity_key) {
|
|
Ok(Some(username)) => {
|
|
results.get().set_username(&username);
|
|
}
|
|
Ok(None) => {
|
|
// Return empty string — caller checks length to detect "not found".
|
|
}
|
|
Err(e) => return Promise::err(storage_err(e)),
|
|
}
|
|
|
|
Promise::ok(())
|
|
}
|
|
}
|