feat: add delivery sequence numbers + major server/client refactor
Delivery sequence numbers (MLS epoch ordering fix):
- schemas/node.capnp: add Envelope{seq,data} struct; enqueue returns seq:UInt64;
fetch/fetchWait return List(Envelope) instead of List(Data)
- storage.rs: Store trait enqueue returns u64; fetch/fetch_limited return
Vec<(u64, Vec<u8>)>; FileBackedStore gains QueueMapV3 with per-inbox seq
counters and V2→V3 on-disk migration
- migrations/002_add_seq.sql: seq column, delivery_seq_counters table, index
- sql_store.rs: atomic UPSERT counter via RETURNING, ORDER BY seq, SCHEMA_VERSION→3
- node_service/delivery.rs: builds Envelope list; returns seq from enqueue
- client/rpc.rs: enqueue→u64, fetch_all/fetch_wait→Vec<(u64,Vec<u8>)>
- client/commands.rs: sort-by-seq before MLS processing; retry loop in cmd_recv
and receive_pending_plaintexts for correct epoch ordering
Server refactor:
- Split monolithic main.rs into node_service/{mod,delivery,auth_ops,key_ops,p2p_ops}
- Add auth.rs (token validation, rate limiting), config.rs, metrics.rs, tls.rs
- Add SQL migrations runner (001_initial.sql, 002_add_seq.sql)
- OPAQUE PAKE login/registration, sealed-sender mode, queue depth limit (1000)
Client refactor:
- Split lib.rs into client/{commands,rpc,state,retry,hex,mod}
- Add cmd_whoami, cmd_health, cmd_check_key, cmd_ping subcommands
- Add cmd_register_user, cmd_login (OPAQUE), cmd_refresh_keypackage
- Hybrid PQ envelope (X25519 + ML-KEM-768) on all send/recv paths
- E2E test suite expanded
Other:
- quicnprotochat-gui: Tauri 2 desktop GUI skeleton (backend + HTML UI)
- quicnprotochat-p2p: iroh-based P2P transport stub
- quicnprotochat-core: app_message, hybrid_crypto modules; GroupMember API updates
- .github/workflows/size-lint.yml: binary size regression check
- docs: protocol comparison, roadmap updates, fully-operational checklist
This commit is contained in:
351
crates/quicnprotochat-server/src/node_service/auth_ops.rs
Normal file
351
crates/quicnprotochat-server/src/node_service/auth_ops.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
use capnp::capability::Promise;
|
||||
use opaque_ke::{
|
||||
CredentialFinalization, CredentialRequest, RegistrationRequest, RegistrationUpload,
|
||||
ServerLogin, ServerRegistration,
|
||||
};
|
||||
use quicnprotochat_core::opaque_auth::OpaqueSuite;
|
||||
use quicnprotochat_proto::node_capnp::node_service;
|
||||
|
||||
use crate::auth::{coded_error, current_timestamp, PendingLogin, SESSION_TTL_SECS};
|
||||
use crate::error_codes::*;
|
||||
use crate::metrics;
|
||||
use crate::storage::StorageError;
|
||||
|
||||
use super::NodeServiceImpl;
|
||||
|
||||
// Audit events in this module must never include secrets (no session tokens, passwords, or raw keys).
|
||||
|
||||
fn storage_err(err: StorageError) -> capnp::Error {
|
||||
coded_error(E009_STORAGE_ERROR, err)
|
||||
}
|
||||
|
||||
impl NodeServiceImpl {
|
||||
pub fn handle_opaque_login_start(
|
||||
&mut self,
|
||||
params: node_service::OpaqueLoginStartParams,
|
||||
mut results: node_service::OpaqueLoginStartResults,
|
||||
) -> 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(v) => v.to_string().unwrap_or_default().to_string(),
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
};
|
||||
let request_bytes = match p.get_request() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
};
|
||||
|
||||
if username.is_empty() {
|
||||
return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty"));
|
||||
}
|
||||
|
||||
let credential_request = match CredentialRequest::<OpaqueSuite>::deserialize(&request_bytes) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Promise::err(coded_error(
|
||||
E010_OPAQUE_ERROR,
|
||||
format!("invalid credential request: {e}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let password_file = match self.store.get_user_record(&username) {
|
||||
Ok(Some(bytes)) => match ServerRegistration::<OpaqueSuite>::deserialize(&bytes) {
|
||||
Ok(pf) => Some(pf),
|
||||
Err(e) => {
|
||||
return Promise::err(coded_error(
|
||||
E010_OPAQUE_ERROR,
|
||||
format!("corrupt user record: {e}"),
|
||||
))
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
return Promise::err(coded_error(E010_OPAQUE_ERROR, "user not registered"))
|
||||
}
|
||||
Err(e) => return Promise::err(storage_err(e)),
|
||||
};
|
||||
|
||||
let mut rng = rand::rngs::OsRng;
|
||||
let result = match ServerLogin::<OpaqueSuite>::start(
|
||||
&mut rng,
|
||||
&self.opaque_setup,
|
||||
password_file,
|
||||
credential_request,
|
||||
username.as_bytes(),
|
||||
Default::default(),
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Promise::err(coded_error(
|
||||
E010_OPAQUE_ERROR,
|
||||
format!("OPAQUE login start failed: {e}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let state_bytes = result.state.serialize().to_vec();
|
||||
self.pending_logins.insert(
|
||||
username.clone(),
|
||||
PendingLogin {
|
||||
state_bytes,
|
||||
created_at: current_timestamp(),
|
||||
},
|
||||
);
|
||||
|
||||
let response_bytes = result.message.serialize();
|
||||
results.get().set_response(&response_bytes);
|
||||
|
||||
tracing::info!(user = %username, "OPAQUE login started");
|
||||
Promise::ok(())
|
||||
}
|
||||
|
||||
pub fn handle_opaque_register_start(
|
||||
&mut self,
|
||||
params: node_service::OpaqueRegisterStartParams,
|
||||
mut results: node_service::OpaqueRegisterStartResults,
|
||||
) -> 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(v) => v.to_string().unwrap_or_default().to_string(),
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
};
|
||||
let request_bytes = match p.get_request() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
};
|
||||
|
||||
if username.is_empty() {
|
||||
return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty"));
|
||||
}
|
||||
|
||||
if let Ok(true) = self.store.has_user_record(&username) {
|
||||
return Promise::err(coded_error(
|
||||
E018_USER_EXISTS,
|
||||
format!("user '{}' already registered", username),
|
||||
));
|
||||
}
|
||||
|
||||
let registration_request = match RegistrationRequest::<OpaqueSuite>::deserialize(&request_bytes) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Promise::err(coded_error(
|
||||
E010_OPAQUE_ERROR,
|
||||
format!("invalid registration request: {e}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let result = match ServerRegistration::<OpaqueSuite>::start(
|
||||
&self.opaque_setup,
|
||||
registration_request,
|
||||
username.as_bytes(),
|
||||
) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Promise::err(coded_error(
|
||||
E010_OPAQUE_ERROR,
|
||||
format!("OPAQUE registration start failed: {e}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let response_bytes = result.message.serialize();
|
||||
results.get().set_response(&response_bytes);
|
||||
|
||||
tracing::info!(user = %username, "OPAQUE registration started");
|
||||
Promise::ok(())
|
||||
}
|
||||
|
||||
pub fn handle_opaque_login_finish(
|
||||
&mut self,
|
||||
params: node_service::OpaqueLoginFinishParams,
|
||||
mut results: node_service::OpaqueLoginFinishResults,
|
||||
) -> 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(v) => v.to_string().unwrap_or_default().to_string(),
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
};
|
||||
let finalization_bytes = match p.get_finalization() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
};
|
||||
let identity_key = p.get_identity_key().unwrap_or_default().to_vec();
|
||||
|
||||
if username.is_empty() {
|
||||
return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty"));
|
||||
}
|
||||
|
||||
let pending = match self.pending_logins.remove(&username) {
|
||||
Some((_, pl)) => pl,
|
||||
None => {
|
||||
// Audit: login failure — do not log secrets (no token, no password).
|
||||
tracing::warn!(user = %username, "audit: auth login failure (no pending login)");
|
||||
metrics::record_auth_login_failure_total();
|
||||
return Promise::err(coded_error(E019_NO_PENDING_LOGIN, "no pending login for this username"))
|
||||
}
|
||||
};
|
||||
|
||||
let server_login = match ServerLogin::<OpaqueSuite>::deserialize(&pending.state_bytes) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return Promise::err(coded_error(
|
||||
E010_OPAQUE_ERROR,
|
||||
format!("corrupt login state: {e}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let finalization = match CredentialFinalization::<OpaqueSuite>::deserialize(&finalization_bytes) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return Promise::err(coded_error(
|
||||
E010_OPAQUE_ERROR,
|
||||
format!("invalid credential finalization: {e}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let _result = match server_login.finish(finalization, Default::default()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(user = %username, "audit: auth login failure (OPAQUE finish failed)");
|
||||
metrics::record_auth_login_failure_total();
|
||||
return Promise::err(coded_error(
|
||||
E010_OPAQUE_ERROR,
|
||||
format!("OPAQUE login finish failed (bad password?): {e}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
if identity_key.is_empty() {
|
||||
metrics::record_auth_login_failure_total();
|
||||
return Promise::err(coded_error(
|
||||
E016_IDENTITY_MISMATCH,
|
||||
"identity key required to bind session token",
|
||||
));
|
||||
}
|
||||
|
||||
if let Ok(Some(stored_ik)) = self.store.get_user_identity_key(&username) {
|
||||
if stored_ik != identity_key {
|
||||
tracing::warn!(user = %username, "audit: auth login failure (identity mismatch)");
|
||||
metrics::record_auth_login_failure_total();
|
||||
return Promise::err(coded_error(
|
||||
E016_IDENTITY_MISMATCH,
|
||||
"identity key does not match registered key",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let mut token = [0u8; 32];
|
||||
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut token);
|
||||
let token_vec = token.to_vec();
|
||||
|
||||
let now = current_timestamp();
|
||||
self.sessions.insert(
|
||||
token_vec.clone(),
|
||||
crate::auth::SessionInfo {
|
||||
username: username.clone(),
|
||||
identity_key,
|
||||
created_at: now,
|
||||
expires_at: now + SESSION_TTL_SECS,
|
||||
},
|
||||
);
|
||||
|
||||
results.get().set_session_token(&token_vec);
|
||||
|
||||
// Audit: login success — do not log session token or any secrets.
|
||||
metrics::record_auth_login_success_total();
|
||||
tracing::info!(user = %username, "audit: auth login success — session token issued");
|
||||
Promise::ok(())
|
||||
}
|
||||
|
||||
pub fn handle_opaque_register_finish(
|
||||
&mut self,
|
||||
params: node_service::OpaqueRegisterFinishParams,
|
||||
mut results: node_service::OpaqueRegisterFinishResults,
|
||||
) -> 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(v) => v.to_string().unwrap_or_default().to_string(),
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
};
|
||||
let upload_bytes = match p.get_upload() {
|
||||
Ok(v) => v.to_vec(),
|
||||
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
|
||||
};
|
||||
let identity_key = p.get_identity_key().unwrap_or_default().to_vec();
|
||||
|
||||
if username.is_empty() {
|
||||
return Promise::err(coded_error(E011_USERNAME_EMPTY, "username must not be empty"));
|
||||
}
|
||||
|
||||
let _request = match RegistrationRequest::<OpaqueSuite>::deserialize(&upload_bytes) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
return Promise::err(coded_error(
|
||||
E010_OPAQUE_ERROR,
|
||||
format!("invalid registration upload: {e}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
match self.store.has_user_record(&username) {
|
||||
Ok(true) => {
|
||||
return Promise::err(coded_error(
|
||||
E018_USER_EXISTS,
|
||||
format!("user '{}' already registered", username),
|
||||
))
|
||||
}
|
||||
Err(e) => return Promise::err(storage_err(e)),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
let upload = match RegistrationUpload::<OpaqueSuite>::deserialize(&upload_bytes) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Promise::err(coded_error(
|
||||
E010_OPAQUE_ERROR,
|
||||
format!("invalid registration upload: {e}"),
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
let password_file = ServerRegistration::<OpaqueSuite>::finish(upload);
|
||||
let record_bytes = password_file.serialize().to_vec();
|
||||
|
||||
if let Err(e) = self
|
||||
.store
|
||||
.store_user_record(&username, record_bytes)
|
||||
.map_err(storage_err)
|
||||
{
|
||||
return Promise::err(e);
|
||||
}
|
||||
|
||||
if !identity_key.is_empty() {
|
||||
if let Err(e) = self
|
||||
.store
|
||||
.store_user_identity_key(&username, identity_key)
|
||||
.map_err(storage_err)
|
||||
{
|
||||
return Promise::err(e);
|
||||
}
|
||||
}
|
||||
|
||||
results.get().set_success(true);
|
||||
tracing::info!(user = %username, "OPAQUE registration complete");
|
||||
Promise::ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user