eidetica/service/server.rs
1//! Service server: accepts Unix socket connections and dispatches `BackendImpl` operations.
2//!
3//! The server wraps an `Instance` (not just a backend) so it can handle write
4//! notifications through the Instance's callback system.
5
6use std::collections::{HashMap, HashSet};
7use std::os::unix::fs::PermissionsExt;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicU64, Ordering};
11
12use tokio::net::UnixListener;
13use tokio::sync::{mpsc, watch};
14use tokio::task::JoinSet;
15
16use crate::Instance;
17use crate::auth::crypto::{PublicKey, generate_challenge, verify_challenge_response};
18use crate::auth::errors::AuthError;
19use crate::auth::types::{Permission, SigKey};
20use crate::auth::validation::permissions::resolve_identity_permission;
21use crate::backend::{CacheScope, VerificationStatus};
22use crate::database::Database;
23use crate::entry::ID;
24use crate::instance::{CallbackId, WriteSource};
25use crate::service::error::ServiceError;
26use crate::service::protocol::{
27 AuthenticatedDbRequest, DatabaseOp, HandshakeAck, MergeState, Notification, PROTOCOL_VERSION,
28 ServerFrame, ServiceRequest, ServiceResponse, read_frame, write_frame,
29};
30use crate::user::system_databases::lookup_user_record;
31
32/// Connection identifier. Monotonic per server-run; reused only after
33/// `AtomicU64` wraps (effectively never on a real daemon). Purely
34/// diagnostic now — registry-based routing went away in the per-db
35/// callback refactor.
36type ConnectionId = u64;
37
38/// Per-connection context carried through the dispatch chain. Holds:
39///
40/// - `tx`: the writer-channel sender — both `ServerFrame::Response`s from
41/// the dispatcher and `ServerFrame::Notification`s from subscribed
42/// per-db callbacks ride through this same channel, so the order
43/// observed by the client is the order frames hit `frame_tx`.
44/// - `instance`: needed in `Drop` to call `remove_write_callback` for
45/// the connection's registered subscriptions.
46/// - `subscribed`: `root_id -> CallbackId` for every `SubscribeWrites`
47/// this connection has done. Cleaned up on disconnect (via the guard's
48/// `Drop`) and on explicit `UnsubscribeWrites`.
49///
50/// There is no per-connection registry on the server. Each subscription
51/// is just a per-db callback registered against the daemon's Instance
52/// (`Instance::register_write_callback`); the daemon's existing
53/// `fire_write_callbacks` dispatch path handles fan-out by walking the
54/// per-tree callback list, no separate fan-out mechanism required.
55struct ConnectionContext {
56 conn_id: ConnectionId,
57 tx: mpsc::UnboundedSender<ServerFrame>,
58 instance: Instance,
59 subscribed: std::sync::Mutex<HashMap<ID, CallbackId>>,
60}
61
62impl ConnectionContext {
63 fn subscribed_lock(&self) -> std::sync::MutexGuard<'_, HashMap<ID, CallbackId>> {
64 self.subscribed
65 .lock()
66 .unwrap_or_else(|poisoned| poisoned.into_inner())
67 }
68}
69
70/// RAII cleanup: on connection drop (clean EOF, error, or panic), call
71/// `Instance::remove_write_callback` for every subscription this
72/// connection registered. Holds an `Arc<ConnectionContext>` rather than
73/// borrowing so the cleanup path is identical for every exit case
74/// without sprinkling `remove_*` calls through the connection handler.
75struct ConnectionGuard {
76 ctx: Arc<ConnectionContext>,
77}
78
79impl Drop for ConnectionGuard {
80 fn drop(&mut self) {
81 let subs = self.ctx.subscribed_lock();
82 if !subs.is_empty() {
83 tracing::debug!(
84 conn_id = self.ctx.conn_id,
85 "Unregistering {} subscriptions on disconnect",
86 subs.len()
87 );
88 }
89 for (tree_id, id) in subs.iter() {
90 self.ctx.instance.remove_write_callback(tree_id, *id);
91 }
92 }
93}
94
95/// Per-connection authentication state.
96///
97/// A connection moves `PreAuth → AwaitingProof → Authenticated` on a successful
98/// `TrustedLoginUser` / `TrustedLoginProve` exchange. A failed proof drops the
99/// connection back to `PreAuth` so the client can retry without reconnecting.
100/// Any other request while in `AwaitingProof` also resets the state so a
101/// half-finished login can't be exploited mid-flight.
102///
103/// "Trusted" refers to the assumption that whoever can reach this socket is
104/// already authorised by filesystem permissions (mode 0600 under
105/// `$XDG_RUNTIME_DIR`); see the protocol module docs and the Service
106/// Architecture brain doc § Trusted login threat model.
107#[derive(Debug, Clone)]
108enum ConnectionState {
109 /// No login attempt yet, or last attempt failed/abandoned.
110 PreAuth,
111 /// `TrustedLoginUser` succeeded; waiting for the client's `TrustedLoginProve`.
112 AwaitingProof {
113 username: String,
114 user_uuid: String,
115 challenge: Vec<u8>,
116 expected_pubkey: PublicKey,
117 },
118 /// Login completed. `login_pubkey` is the verified root pubkey for the
119 /// user, established at `TrustedLoginProve` time. `session_keyset` is the
120 /// set of pubkeys the client has further proven possession of via
121 /// `SessionKeyChallenge`/`SessionKeyRegister`; it always contains
122 /// `login_pubkey` and may include additional per-DB keys the user owns.
123 /// The dispatch path for `Authenticated`/`AuthenticatedDb` requests
124 /// validates the identity hint against this set and gates against the
125 /// resulting *acting* pubkey, so a single connection can drive ops on
126 /// databases authored by any key the user has proven they hold.
127 /// `user_uuid` is the per-user scope key for the unified CRDT-state
128 /// cache (see [`crate::backend::CacheScope::User`]).
129 /// `pending_key_challenges` holds outstanding registration challenges,
130 /// keyed by the pubkey the challenge was issued for. Each challenge is
131 /// single-use: the matching `SessionKeyRegister` consumes it whether
132 /// verification succeeds or fails.
133 #[allow(dead_code)] // username surfaces in audit/logging follow-ups
134 Authenticated {
135 username: String,
136 user_uuid: String,
137 login_pubkey: PublicKey,
138 session_keyset: HashSet<PublicKey>,
139 pending_key_challenges: HashMap<PublicKey, Vec<u8>>,
140 },
141}
142
143/// Eidetica service server that listens on a Unix domain socket.
144///
145/// The server wraps a full `Instance` so it can dispatch both storage operations
146/// (via the backend) and write callbacks (via `Instance::put_entry()`'s notification path).
147///
148/// CRDT-state caching lives in the underlying `BackendImpl` (scope-keyed
149/// via [`crate::backend::CacheScope`]); wire handlers route through
150/// `instance.backend()` directly rather than keeping a separate
151/// service-layer cache.
152pub struct ServiceServer {
153 instance: Instance,
154 socket_path: PathBuf,
155}
156
157impl ServiceServer {
158 /// Create a new service server.
159 ///
160 /// # Arguments
161 /// * `instance` - The Instance to serve. The server holds a strong reference.
162 /// * `socket_path` - Path for the Unix domain socket.
163 pub fn new(instance: Instance, socket_path: impl Into<PathBuf>) -> Self {
164 Self {
165 instance,
166 socket_path: socket_path.into(),
167 }
168 }
169
170 /// Get the socket path.
171 pub fn socket_path(&self) -> &Path {
172 &self.socket_path
173 }
174
175 /// Run the server until the shutdown signal is received.
176 ///
177 /// Removes any stale socket file, creates the parent directory, binds the
178 /// listener, and loops accepting connections. Each connection is handled in
179 /// a spawned task. On shutdown, the socket file is cleaned up.
180 ///
181 /// # Arguments
182 /// * `shutdown` - A watch receiver; the server stops when the sender is dropped.
183 pub async fn run(&self, mut shutdown: watch::Receiver<()>) -> crate::Result<()> {
184 // Remove stale socket if it exists
185 if self.socket_path.exists() {
186 tokio::fs::remove_file(&self.socket_path).await?;
187 }
188
189 // Create parent directory with owner-only permissions (0700)
190 if let Some(parent) = self.socket_path.parent() {
191 tokio::fs::create_dir_all(parent).await?;
192 tokio::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)).await?;
193 }
194
195 let listener = UnixListener::bind(&self.socket_path)?;
196
197 // Restrict socket to owner-only access (0600)
198 tokio::fs::set_permissions(&self.socket_path, std::fs::Permissions::from_mode(0o600))
199 .await?;
200
201 tracing::info!("Service server listening on {}", self.socket_path.display());
202
203 // Diagnostic per-connection counter — purely for logging. No
204 // registry-based routing needed; each connection's subscriptions
205 // live as per-db callbacks on the Instance, dispatched by the
206 // existing `fire_write_callbacks` path.
207 let next_conn_id = Arc::new(AtomicU64::new(1));
208
209 // Track active per-connection tasks so shutdown actually
210 // disconnects them. Without this the shutdown signal only stops
211 // the accept loop; live handlers keep running until the client
212 // closes its end, which means "shutdown" is half-done — clients
213 // hang on response reads from a daemon they were told had stopped.
214 let mut handlers = JoinSet::new();
215
216 loop {
217 tokio::select! {
218 accept_result = listener.accept() => {
219 match accept_result {
220 Ok((stream, _addr)) => {
221 let instance = self.instance.clone();
222 let conn_id = next_conn_id.fetch_add(1, Ordering::Relaxed);
223 handlers.spawn(async move {
224 if let Err(e) = handle_connection(stream, instance, conn_id).await {
225 tracing::debug!(conn_id, "Connection handler error: {e}");
226 }
227 });
228 }
229 Err(e) => {
230 tracing::error!("Failed to accept connection: {e}");
231 }
232 }
233 }
234 // Reap finished handlers as they complete. Without this arm
235 // the JoinSet only drains at shutdown, so every connection the
236 // daemon has ever served keeps its task entry alive for the
237 // daemon's lifetime.
238 _ = handlers.join_next(), if !handlers.is_empty() => {}
239 _ = shutdown.changed() => {
240 tracing::info!("Service server shutting down");
241 break;
242 }
243 }
244 }
245
246 // Abort live handlers. Each aborted task drops its `UnixStream`
247 // halves, which the client observes as a clean EOF on its read
248 // side — the reader task exits, sets `closed`, and any in-flight
249 // or subsequent `request()` surfaces `ConnectionAborted`.
250 handlers.abort_all();
251 // Drain to completion so the tempdir-owned socket file isn't
252 // pulled out from under any handler that hasn't yet observed
253 // the abort.
254 while handlers.join_next().await.is_some() {}
255
256 // Clean up socket file
257 let _ = tokio::fs::remove_file(&self.socket_path).await;
258 Ok(())
259 }
260}
261
262/// Handle a single client connection.
263///
264/// I/O is split across two tasks:
265///
266/// - **Writer task** (spawned below) owns the `WriteHalf` and drains an
267/// `mpsc::UnboundedReceiver<ServerFrame>` into `write_frame` in
268/// submission order. Responses (dispatched from the request loop) and
269/// server-pushed notifications (from subscribed per-db callbacks both
270/// capture clones of the same `frame_tx`, so a single connection-local
271/// order is preserved.
272///
273/// - **This task** owns the `ReadHalf`, runs the auth/request loop, and
274/// sends `ServerFrame::Response(...)` into the channel. It also holds
275/// a [`ConnectionGuard`] whose Drop unregisters every subscription this
276/// connection made on every exit path (clean EOF, error, or panic).
277///
278/// Handshake still runs inline on the read side first; the writer task is
279/// not started until the handshake succeeds, so a version-mismatch close
280/// uses the simpler inline writer path.
281async fn handle_connection(
282 stream: tokio::net::UnixStream,
283 instance: Instance,
284 conn_id: ConnectionId,
285) -> crate::Result<()> {
286 let (mut reader, mut writer) = tokio::io::split(stream);
287
288 // 1. Read and validate handshake (inline writer, no channel yet).
289 let handshake: crate::service::protocol::Handshake = match read_frame(&mut reader).await? {
290 Some(h) => h,
291 None => return Ok(()), // Client disconnected before handshake
292 };
293
294 if handshake.protocol_version != PROTOCOL_VERSION {
295 // Send error ack and close
296 let ack = HandshakeAck {
297 protocol_version: PROTOCOL_VERSION,
298 };
299 write_frame(&mut writer, &ack).await?;
300 return Err(crate::Error::Io(std::io::Error::new(
301 std::io::ErrorKind::InvalidData,
302 format!(
303 "Protocol version mismatch: client={}, server={}",
304 handshake.protocol_version, PROTOCOL_VERSION
305 ),
306 )));
307 }
308
309 // Send handshake ack
310 let ack = HandshakeAck {
311 protocol_version: PROTOCOL_VERSION,
312 };
313 write_frame(&mut writer, &ack).await?;
314
315 // 2. Spin up the per-connection writer task.
316 //
317 // TODO(backpressure): this channel is `unbounded`, so a stalled client
318 // reader buffers frames in daemon memory without limit. Acceptable
319 // under the v1 trust posture (single trusted local client over a Unix
320 // socket — an unresponsive reader is treated as a client-side bug, not
321 // an attacker). Before serving multiple or untrusted clients this
322 // needs a bound + drop policy. Complication: this single channel
323 // carries both `Response` and `Notification` frames. Naive bounding
324 // is wrong — dropping a `Response` breaks request/response
325 // correlation. Two viable shapes for the follow-up:
326 //
327 // a) **Split**: separate `response_tx` (bounded, block-send) from
328 // `notification_tx` (bounded, drop-oldest); writer `select!`s
329 // with response priority. Cleanest semantics but touches every
330 // `frame_tx.clone()` site and the cleanup-ordering comment at
331 // the end of this function needs migration.
332 //
333 // b) **Variant-aware drop**: bound the single channel; on full,
334 // `try_send` returns `Full(frame)`; reject if Notification,
335 // block if Response. Less plumbing, policy duplicated per send
336 // site.
337 //
338 // Under the cursor-only `Notification::DatabaseWrite` shape (no entry
339 // bodies, just `previous_tips`/`post_tips`), drops are *recoverable*:
340 // a subscriber that misses event N still sees event N+1's `post_tips`
341 // and calls `ids_added(cursor, post_tips)` to catch up. That makes
342 // drop-oldest the right policy and considerably softens the cost of
343 // dropping at all — but the work still needs doing for prod.
344 let (frame_tx, mut frame_rx) = mpsc::unbounded_channel::<ServerFrame>();
345 let writer_task = tokio::spawn(async move {
346 while let Some(frame) = frame_rx.recv().await {
347 if let Err(e) = write_frame(&mut writer, &frame).await {
348 tracing::debug!(conn_id, "Connection writer error: {e}");
349 break;
350 }
351 }
352 });
353
354 let ctx = Arc::new(ConnectionContext {
355 conn_id,
356 tx: frame_tx.clone(),
357 instance: instance.clone(),
358 subscribed: std::sync::Mutex::new(HashMap::new()),
359 });
360 let guard = ConnectionGuard { ctx: ctx.clone() };
361
362 // 3. Request/response loop with per-connection auth state. Responses
363 // travel through the writer channel as `ServerFrame::Response`.
364 let mut state = ConnectionState::PreAuth;
365 let loop_result: crate::Result<()> = async {
366 loop {
367 let request: ServiceRequest = match read_frame(&mut reader).await? {
368 Some(req) => req,
369 None => break, // Clean EOF
370 };
371
372 let response = dispatch(&instance, &mut state, &ctx, request).await;
373 if frame_tx
374 .send(ServerFrame::Response(Box::new(response)))
375 .is_err()
376 {
377 // Writer task has exited; nothing more we can send.
378 break;
379 }
380 }
381 Ok(())
382 }
383 .await;
384
385 // Cleanup ordering matters — every clone of `frame_tx` must be
386 // dropped before `writer_task.await` can complete (the task is
387 // waiting on `frame_rx.recv()`, which only returns `None` when no
388 // senders remain):
389 //
390 // 1. Drop the local `frame_tx` (this fn's own clone).
391 // 2. Drop `guard`, which runs `ConnectionGuard::drop` and calls
392 // `Instance::remove_write_callback(tree_id, id)` for every
393 // subscription this connection registered. Each removed callback
394 // Arc drops its captured `tx` clone. (No-op when nothing was
395 // subscribed — e.g. handshake-then-malformed-frame paths.)
396 // 3. Drop `ctx` — releases `ctx.tx`, the last sender clone held
397 // on the request-loop side.
398 // 4. The writer task's `recv()` returns `None`; the task exits and
399 // `writer_task.await` finishes.
400 //
401 // Note: a callback dispatch in flight when step 2 runs holds its
402 // own Arc to the closure (from `fire_write_callbacks`'s snapshot).
403 // It can still send a final notification through its captured `tx`
404 // before that Arc is released; the writer task will drain that
405 // frame before exiting in step 4.
406 drop(frame_tx);
407 drop(guard);
408 drop(ctx);
409 let _ = writer_task.await;
410
411 // Session teardown: subscription cleanup ran via the guard's Drop.
412 // The unified CRDT-state cache lives in `BackendImpl` and is bounded
413 // by its own eviction policy (byte-bounded LRU on the in-memory
414 // backend; disk-bounded on SQL). Per-user cache slots survive
415 // disconnect intentionally so a reconnecting client recovers
416 // materialized state from tier 2 without recomputing from entries.
417
418 loop_result
419}
420
421/// Dispatch a service request to the appropriate Instance/Backend method.
422async fn dispatch(
423 instance: &Instance,
424 state: &mut ConnectionState,
425 ctx: &ConnectionContext,
426 request: ServiceRequest,
427) -> ServiceResponse {
428 match dispatch_inner(instance, state, ctx, request).await {
429 Ok(resp) => resp,
430 Err(e) => ServiceResponse::Error(ServiceError::from(&e)),
431 }
432}
433
434/// Inner dispatch that returns Result for ergonomic error handling.
435async fn dispatch_inner(
436 instance: &Instance,
437 state: &mut ConnectionState,
438 ctx: &ConnectionContext,
439 request: ServiceRequest,
440) -> crate::Result<ServiceResponse> {
441 match request {
442 // === Pre-auth: login handshake ===
443 ServiceRequest::TrustedLoginUser { username } => {
444 handle_trusted_login_user(instance, state, username).await
445 }
446 ServiceRequest::TrustedLoginProve { signature } => {
447 handle_trusted_login_prove(state, &signature)
448 }
449
450 // === Pre-auth: server identity ===
451 ServiceRequest::GetInstanceMetadata => {
452 let metadata = instance.backend().get_instance_metadata().await?;
453 Ok(ServiceResponse::InstanceMetadata(metadata))
454 }
455
456 // === Post-auth: extend the session keyset ===
457 ServiceRequest::SessionKeyChallenge { pubkey } => {
458 handle_session_key_challenge(state, pubkey)
459 }
460 ServiceRequest::SessionKeyRegister { pubkey, signature } => {
461 handle_session_key_register(state, pubkey, &signature)
462 }
463
464 // === Authenticated storage operations ===
465 //
466 // Gate 1: the connection must have completed `TrustedLogin*`. Gate 2:
467 // the per-tree permission gate. Every `DatabaseOp` carries its target
468 // `root_id` explicitly, so the gate is *unconditional* — there is no
469 // tree-less op to fall through it — with two exceptions handled below
470 // (`SubmitSignedEntry`, verification-gated; `SetInstanceMetadata`,
471 // gated against the server-known `_databases`, not the request root).
472 ServiceRequest::AuthenticatedDb(inner) => {
473 let (login_pubkey, keyset_snapshot, session_user_uuid) = match state {
474 ConnectionState::Authenticated {
475 login_pubkey,
476 session_keyset,
477 user_uuid,
478 ..
479 } => (
480 login_pubkey.clone(),
481 session_keyset.clone(),
482 user_uuid.clone(),
483 ),
484 _ => {
485 return Err(crate::Error::Auth(Box::new(
486 AuthError::InvalidAuthConfiguration {
487 reason: "database operation requires an authenticated connection; \
488 complete TrustedLogin* first"
489 .to_string(),
490 },
491 )));
492 }
493 };
494
495 let AuthenticatedDbRequest {
496 root_id,
497 identity,
498 op,
499 } = *inner;
500
501 // Submit is verification-gated, not session-gated.
502 //
503 // Reads are session-gated (confidentiality boundary); submits
504 // are verification-gated (integrity boundary). `SubmitSignedEntry`
505 // requires only an *authenticated* connection (gate 1, the
506 // `ConnectionState::Authenticated` match above, still applies).
507 // Which tree the entry belongs to, and whether its signer may
508 // write that tree, is decided by the server's own verification
509 // pass in the handler (store `Unverified`, then
510 // `Database::open(...).verify()`) against the tree's *real*
511 // pinned auth lineage — not by who holds the socket. An attacker
512 // without a key the tree's auth grants cannot produce a
513 // `Verified` entry, and unverified junk is excluded from every
514 // default read by the frontier cut, so the per-tree session gate
515 // adds no correctness or isolation property here; it only blocks
516 // a legitimate transporter (e.g. an admin session carrying a
517 // user-signed genesis). See the verification-gated-submit design
518 // doc for the full threat analysis.
519 let is_submit = matches!(op, DatabaseOp::SubmitSignedEntry { .. });
520
521 // `SetInstanceMetadata` rewrites the daemon's pointers to its own
522 // system DBs. It is gated against `_databases` (a server-known
523 // tree), not the request's `root_id`, so an instance admin — by
524 // construction a user with Admin on `_databases` via the
525 // first-user bootstrap — is required. Fail closed (require_existing
526 // = true): `_databases` always exists on an initialized daemon and
527 // this is never a creation flow, so the create-flow passthrough
528 // must not apply (it would let any authenticated user rewrite
529 // system-DB pointers if `_databases` were ever unreadable).
530 let is_set_metadata = matches!(op, DatabaseOp::SetInstanceMetadata { .. });
531
532 // Submit accepts any identity hint (admin transports user-signed
533 // entries); every other op resolves an acting pubkey from the
534 // keyset and gates per-tree against it.
535 let acting_pubkey = if is_submit {
536 // Use the hint if it parses as a pubkey (for submit metadata),
537 // otherwise fall back to login_pubkey — submit doesn't gate
538 // on this value.
539 identity
540 .hint()
541 .pubkey
542 .clone()
543 .unwrap_or_else(|| login_pubkey.clone())
544 } else {
545 resolve_acting_pubkey(&identity, &login_pubkey, &keyset_snapshot)?
546 };
547
548 // Per-tree permission gate. Unconditional for every op *except*
549 // submit (verification in the handler is its boundary) and
550 // set-metadata (gated against `_databases` below). Create-flow
551 // passthrough (false) for the rest, so a not-yet-propagated tree is
552 // waved through and database creation works.
553 if is_set_metadata {
554 gate_tree_permission(
555 instance,
556 &acting_pubkey,
557 &identity,
558 instance.databases_db_id(),
559 Permission::Admin(0),
560 true,
561 )
562 .await?;
563 } else if !is_submit {
564 gate_tree_permission(
565 instance,
566 &acting_pubkey,
567 &identity,
568 &root_id,
569 op.required_permission(),
570 false,
571 )
572 .await?;
573 }
574
575 dispatch_database_op(
576 instance,
577 ctx,
578 &acting_pubkey,
579 &identity,
580 &session_user_uuid,
581 root_id,
582 op,
583 )
584 .await
585 }
586 }
587}
588
589/// Resolve the *acting* pubkey for a session-gated op.
590///
591/// The identity hint, when present, must be in the connection's session
592/// keyset (proof of possession registered via `SessionKeyChallenge` /
593/// `SessionKeyRegister`, or established at login time). Returning the hint
594/// as the acting pubkey lets the per-tree gate check the actual key the
595/// caller wants to act as, not the connection-wide login key.
596///
597/// An absent hint defaults to the login pubkey — matches the pre-keyset
598/// behavior where every op acted as the login identity.
599fn resolve_acting_pubkey(
600 identity: &SigKey,
601 login_pubkey: &PublicKey,
602 session_keyset: &HashSet<PublicKey>,
603) -> crate::Result<PublicKey> {
604 match &identity.hint().pubkey {
605 Some(claimed) if session_keyset.contains(claimed) => Ok(claimed.clone()),
606 Some(claimed) => Err(crate::Error::Auth(Box::new(
607 AuthError::SigningKeyMismatch {
608 reason: format!(
609 "request identity claims pubkey '{claimed}' but it is not in the session keyset; \
610 register it first via SessionKeyChallenge/SessionKeyRegister"
611 ),
612 },
613 ))),
614 None => Ok(login_pubkey.clone()),
615 }
616}
617
618/// Dispatch a Database-level op against the server's local `Database`.
619///
620/// Additive sibling of `dispatch_backend_op`. The caller has already verified
621/// the session identity and run the unconditional per-tree permission gate on
622/// `root_id`. Because the server runs the `Database` layer here, verify-on-read
623/// and the Verified frontier are server-side **by construction**.
624async fn dispatch_database_op(
625 instance: &Instance,
626 ctx: &ConnectionContext,
627 acting_pubkey: &PublicKey,
628 identity: &SigKey,
629 user_uuid: &str,
630 root_id: ID,
631 op: DatabaseOp,
632) -> crate::Result<ServiceResponse> {
633 match op {
634 DatabaseOp::GetEntry { id } => {
635 let entry = instance.backend().get(&id).await?;
636 // Post-fetch owning-tree Read gate: a raw entry id carries no
637 // inline tree, so the pre-dispatch gate (which keys on `root_id`)
638 // could not cover the entry's real owning tree.
639 gate_entry_read(instance, acting_pubkey, identity, &entry).await?;
640 Ok(ServiceResponse::Entry(entry))
641 }
642
643 DatabaseOp::GetVerifiedTips => {
644 // The server runs the Database layer, so `snapshot()` returns the
645 // Verified frontier by construction — no client-side verify, no
646 // remote-detection heuristic.
647 let db = Database::open(instance, &root_id).await?;
648 let snapshot = db.snapshot().await?;
649 Ok(ServiceResponse::Ids(snapshot))
650 }
651
652 DatabaseOp::SubmitSignedEntry { entry } => {
653 // The client signed this entry; the server does NOT trust its
654 // claimed validity. Store it `Unverified`, then run our OWN
655 // verification pass against the entry's pinned settings. A
656 // poisoned entry never reaches `Verified` and is excluded from
657 // every default read by the frontier cut — D1 is closed by
658 // construction here, not by gate-hardening a raw `Put`.
659 //
660 // Only settled-state writes trigger an event: the
661 // `put_entry(.., Unverified, ..)` is a no-fire path by
662 // design (see `Instance::put_entry`), and `Database::verify`
663 // fires its own batched `Verified` event for any entries
664 // the pass settles. The handler just chains the two — no
665 // extra fire bookkeeping here. Note this gates the trigger
666 // only; the event's raw-frontier cursors can still bracket
667 // an unsettled tip (see `Notification` rustdoc).
668 instance
669 .put_entry(
670 &root_id,
671 VerificationStatus::Unverified,
672 *entry,
673 WriteSource::Remote,
674 )
675 .await?;
676 Database::open(instance, &root_id).await?.verify().await?;
677 Ok(ServiceResponse::Ok)
678 }
679
680 DatabaseOp::BeginTransaction { stores, scope } => {
681 // Single-sourced: both this handler and the Phase-3 remote seam
682 // call `Database::transaction_context`, so `Transaction::commit`'s
683 // build-sign path has one source of truth.
684 let db = Database::open(instance, &root_id).await?;
685 let ctx = db.transaction_context(&stores, scope).await?;
686 Ok(ServiceResponse::TransactionContext(ctx))
687 }
688
689 DatabaseOp::GetStoreState { store } => {
690 // Server-materialized merged state (unencrypted stores only).
691 // Encrypted stores must use GetStoreEntries instead — the
692 // ephemeral transaction here has no encryptor, and Doc
693 // deserialization would fail on ciphertext.
694 let db = Database::open(instance, &root_id).await?;
695 let value = db.get_store_state(&store).await?;
696 Ok(ServiceResponse::CrdtValue(value))
697 }
698
699 DatabaseOp::GetStoreEntries { store, tips, scope } => {
700 // Universal primitive (encrypted + unencrypted): returns raw
701 // Entry records with opaque data in canonical CRDT replay order.
702 // For encrypted stores the client decrypts+merges locally.
703 let db = Database::open(instance, &root_id).await?;
704 let entries = db.get_store_entries(&store, &tips, scope).await?;
705 Ok(ServiceResponse::Entries(entries))
706 }
707
708 DatabaseOp::GetStoreTipsUpToEntries { store, up_to } => {
709 let db = Database::open(instance, &root_id).await?;
710 let boundary = crate::Snapshot::from(up_to);
711 let snapshot = db
712 .ops()
713 .store_snapshot_at(&root_id, &store, &boundary)
714 .await?;
715 Ok(ServiceResponse::Ids(snapshot))
716 }
717
718 DatabaseOp::ComputeMergeState { store, entry_ids } => {
719 let db = Database::open(instance, &root_id).await?;
720 let merge_base = db
721 .ops()
722 .find_merge_base(&root_id, &store, &entry_ids)
723 .await?;
724 let path = db
725 .ops()
726 .get_path_from_to(&root_id, &store, &merge_base, &entry_ids)
727 .await?;
728 Ok(ServiceResponse::MergeState(MergeState { merge_base, path }))
729 }
730
731 DatabaseOp::GetCachedCrdtState { store, key } => {
732 // Per-tree Read gate already ran above. Try the caller's own
733 // User-scoped slot first (where client-uploaded ciphertext for
734 // encrypted stores lives), then fall back to Shared (where the
735 // daemon's own materialization of unencrypted stores lives).
736 // The fallback is what gives cross-user dedup on plaintext
737 // stores: alice triggers a server materialization, blob lands
738 // in Shared, bob's later read finds it without recomputing.
739 let backend = instance.require_local_engine()?;
740 let mut blob = backend
741 .get_cached_crdt_state(&CacheScope::User(user_uuid.to_string()), &key, &store)
742 .await?;
743 if blob.is_none() {
744 blob = backend
745 .get_cached_crdt_state(&CacheScope::Shared, &key, &store)
746 .await?;
747 }
748 Ok(ServiceResponse::CachedCrdtState(blob))
749 }
750
751 DatabaseOp::CacheCrdtState { store, key, blob } => {
752 // Per-tree Read gate already ran above. Per-user trust: the
753 // blob is opaque (cipher- or plaintext) and stored verbatim;
754 // only the submitting user can read it back. We never promote
755 // a client upload to Shared — the daemon can't verify the
756 // merge result, so cross-user visibility would be a poison
757 // vector. Shared writes only come from the daemon's own
758 // in-process (LocalBackend) materialization path.
759 instance
760 .require_local_engine()?
761 .cache_crdt_state(CacheScope::User(user_uuid.to_string()), &key, &store, blob)
762 .await?;
763 Ok(ServiceResponse::Ok)
764 }
765
766 DatabaseOp::SetInstanceMetadata { metadata } => {
767 // Admin-on-`_databases` gate already ran in the dispatcher (against
768 // the server-known system tree, not `root_id`).
769 instance.backend().set_instance_metadata(&metadata).await?;
770 Ok(ServiceResponse::Ok)
771 }
772
773 DatabaseOp::SubscribeWrites { tips } => {
774 // Per-tree Read gate already ran in the dispatcher.
775 //
776 // Subscription is just a per-db callback registered against the
777 // daemon's Instance. The callback's body pushes a notification
778 // frame into this connection's writer channel; the daemon's
779 // existing `fire_write_callbacks` dispatch handles fan-out by
780 // walking the per-tree callback list. No separate registry or
781 // global-publisher hook needed.
782 //
783 // Idempotent: a tree this connection has already subscribed to
784 // is a no-op (we'd otherwise register a second callback that
785 // pushes a duplicate frame on every write). Take the lock
786 // briefly to early-out, then release before any await — the
787 // std::Mutex isn't `Send` and can't cross an await point.
788 //
789 // Under the current dispatch shape the per-connection request
790 // loop is single-threaded (one frame → one dispatch → one
791 // response), so a second `SubscribeWrites` for the same tree
792 // on the same connection can only arrive *after* the first
793 // call has fully completed and recorded its subscription. The
794 // post-await Entry-API guard below is therefore **defensive
795 // against a future shape change** (per-connection parallel
796 // dispatch, or another path that takes `ctx` and registers
797 // callbacks concurrently) rather than fixing a present race.
798 // Cheap to keep — one Entry-API call plus a single
799 // `remove_write_callback` in the unreachable Occupied arm.
800 {
801 let subs = ctx.subscribed_lock();
802 if subs.contains_key(&root_id) {
803 return Ok(ServiceResponse::Ok);
804 }
805 }
806 let tx = ctx.tx.clone();
807 // Capture the initial cursor and register the callback atomically
808 // under the tree lock. The fire path (`put_entry` /
809 // `Database::verify`) holds this same lock while advancing cursors,
810 // so no write can land between the tips snapshot and the
811 // registration: the empty-`tips` cursor is exactly the daemon's
812 // frontier at the instant this subscription begins, with no
813 // lost-update gap. Safe to hold across the `snapshot` await —
814 // `Instance::snapshot` reads the raw backend (no tree lock) and,
815 // unlike the fire path, we spawn/await no user callback under the
816 // guard, so there is no reentrancy.
817 let tree_lock = instance.tree_lock(&root_id);
818 let tree_guard = tree_lock.lock().await;
819 // Initial cursor: the client's supplied `tips` if non-empty;
820 // otherwise the daemon's current tips at subscribe-time (the "I have
821 // no initial state; give me events from now" posture documented on
822 // the wire variant). The cursor advances per fire as usual.
823 let initial_tips = if tips.is_empty() {
824 // Propagate rather than defaulting: an error here would seed
825 // the daemon-side cursor at `EMPTY`, which is the "replay
826 // from the beginning" value rather than "start from now".
827 instance.snapshot(&root_id).await?
828 } else {
829 tips
830 };
831 let id = instance.register_write_callback(
832 root_id.clone(),
833 initial_tips,
834 move |event, db| {
835 // ORDERING INVARIANT — DO NOT add an `.await` before the
836 // `tx.send` below. Per-tree notifications are delivered in
837 // daemon-canonical order, and that guarantee rests entirely
838 // on this send running *synchronously*, in the closure's
839 // pre-future prefix. `Instance::spawn_write_callbacks`
840 // invokes this closure while the caller holds the tree's
841 // `tree_lock` (see `put_entry` / `Database::verify`), and it
842 // advances each subscription's cursor in the same critical
843 // section. Because the send happens here — before any await,
844 // still under that lock, in cursor-advance order — two
845 // connections writing the same tree concurrently cannot
846 // interleave their frames: writer A's send completes and A
847 // drops the lock before writer B can acquire it and send.
848 // The returned future is deliberately a no-op; only that
849 // (empty) tail is spawned and drained lock-free. Moving the
850 // send *into* the future — or awaiting anything before it —
851 // would push it out of the locked section onto the runtime's
852 // scheduler and let same-tree frames reorder (the bug fixed
853 // in "preserve write-notification order under concurrent
854 // writers"). `mpsc::UnboundedSender::send` is non-blocking
855 // and takes `&self`, which is exactly what makes a
856 // synchronous send here possible.
857 //
858 // Send failure (writer task gone) is silently dropped; the
859 // connection is about to be torn down and the guard's Drop
860 // will unregister us next.
861 //
862 // The closure only fires for settled-state writes today
863 // (Verified). Unverified writes go through `put_entry`
864 // without firing `fire_write_callbacks`, so no notification
865 // is ever *triggered* by them — though the cursors shipped
866 // here are raw frontiers and can bracket an unsettled tip.
867 let frame = ServerFrame::Notification(Notification::DatabaseWrite {
868 root_id: db.root_id().clone(),
869 previous_tips: event.previous_tips().clone(),
870 post_tips: event.post_tips().clone(),
871 source: event.source(),
872 });
873 let _ = tx.send(frame);
874 async move { Ok(()) }
875 },
876 );
877 // Cursor captured and callback live; release the tree lock before
878 // touching the std subscribed-lock below (never nest the two).
879 drop(tree_guard);
880 // Re-acquire the lock to record this connection's subscription.
881 // Defensive Entry-API guard: in the current shape (single-
882 // threaded per-connection dispatch) the Occupied arm is
883 // unreachable — see the comment above the early-out check.
884 // Kept so that a future shape change (parallel dispatch per
885 // connection, or another concurrent path that takes `ctx`)
886 // can't leave us with two callbacks pushing duplicate frames
887 // on every write; the loser drops its just-registered
888 // callback and the first registration stays.
889 use std::collections::hash_map::Entry as MapEntry;
890 let mut subs = ctx.subscribed_lock();
891 match subs.entry(root_id.clone()) {
892 MapEntry::Vacant(slot) => {
893 slot.insert(id);
894 }
895 MapEntry::Occupied(_) => {
896 drop(subs);
897 instance.remove_write_callback(&root_id, id);
898 }
899 }
900 Ok(ServiceResponse::Ok)
901 }
902
903 DatabaseOp::UnsubscribeWrites => {
904 // Per-tree Read gate already ran. Idempotent: unsubscribing a
905 // tree this connection was not subscribed to is a no-op.
906 let removed = ctx.subscribed_lock().remove(&root_id);
907 if let Some(id) = removed {
908 instance.remove_write_callback(&root_id, id);
909 }
910 Ok(ServiceResponse::Ok)
911 }
912 }
913}
914
915/// Handle `ServiceRequest::TrustedLoginUser`: look up the user's full record,
916/// mint a challenge, and move the connection into `AwaitingProof`.
917async fn handle_trusted_login_user(
918 instance: &Instance,
919 state: &mut ConnectionState,
920 username: String,
921) -> crate::Result<ServiceResponse> {
922 // Look up the full `UserInfo`. The encrypted root key + salt ship to the
923 // client so it can decrypt locally and sign the challenge in the same
924 // round-trip; the daemon never sees the password or the plaintext key.
925 // The non-credential fields (user_database_id, status) ride along so the
926 // client can build the `User` session after proof without a second wire
927 // read of `_users`. If the lookup fails (no such user, disabled,
928 // duplicates), drop back to `PreAuth` and bubble the error.
929 let users_db = instance.users_db().await?;
930 let (user_uuid, user_info) = match lookup_user_record(&users_db, &username).await {
931 Ok(v) => v,
932 Err(e) => {
933 *state = ConnectionState::PreAuth;
934 return Err(e);
935 }
936 };
937
938 let expected_pubkey = user_info.credentials.root_key_id.clone();
939 let challenge = generate_challenge();
940 *state = ConnectionState::AwaitingProof {
941 username,
942 user_uuid: user_uuid.clone(),
943 challenge: challenge.clone(),
944 expected_pubkey,
945 };
946 Ok(ServiceResponse::TrustedLoginChallenge {
947 challenge,
948 user_uuid,
949 user_info,
950 })
951}
952
953/// Handle `ServiceRequest::TrustedLoginProve`: verify the signature against the
954/// stored challenge and either transition to `Authenticated` or drop back
955/// to `PreAuth`.
956fn handle_trusted_login_prove(
957 state: &mut ConnectionState,
958 signature: &[u8],
959) -> crate::Result<ServiceResponse> {
960 let (username, user_uuid, challenge, expected_pubkey) =
961 match std::mem::replace(state, ConnectionState::PreAuth) {
962 ConnectionState::AwaitingProof {
963 username,
964 user_uuid,
965 challenge,
966 expected_pubkey,
967 } => (username, user_uuid, challenge, expected_pubkey),
968 other => {
969 // Restore the (unexpected) state we just took out so subsequent
970 // requests see consistent state.
971 *state = other;
972 return Err(crate::Error::Io(std::io::Error::new(
973 std::io::ErrorKind::InvalidData,
974 "TrustedLoginProve received outside of AwaitingProof state",
975 )));
976 }
977 };
978
979 match verify_challenge_response(&challenge, signature, &expected_pubkey) {
980 Ok(()) => {
981 let mut session_keyset = HashSet::new();
982 session_keyset.insert(expected_pubkey.clone());
983 *state = ConnectionState::Authenticated {
984 username,
985 user_uuid,
986 login_pubkey: expected_pubkey,
987 session_keyset,
988 pending_key_challenges: HashMap::new(),
989 };
990 Ok(ServiceResponse::TrustedLoginOk)
991 }
992 Err(e) => {
993 // Already reset to PreAuth via the mem::replace above.
994 Err(crate::Error::Auth(Box::new(e)))
995 }
996 }
997}
998
999/// Handle `ServiceRequest::SessionKeyChallenge`: mint a single-use challenge
1000/// bound to `pubkey` and stash it in the connection's pending-challenges map.
1001///
1002/// Requires an authenticated connection. A repeat call for the same pubkey
1003/// overwrites the prior challenge — last-issued wins, so a stale challenge
1004/// can't be replayed.
1005fn handle_session_key_challenge(
1006 state: &mut ConnectionState,
1007 pubkey: PublicKey,
1008) -> crate::Result<ServiceResponse> {
1009 match state {
1010 ConnectionState::Authenticated {
1011 pending_key_challenges,
1012 ..
1013 } => {
1014 let challenge = generate_challenge();
1015 pending_key_challenges.insert(pubkey, challenge.clone());
1016 Ok(ServiceResponse::SessionKeyChallenge { challenge })
1017 }
1018 _ => Err(crate::Error::Auth(Box::new(
1019 AuthError::InvalidAuthConfiguration {
1020 reason: "SessionKeyChallenge requires an authenticated connection; \
1021 complete TrustedLogin* first"
1022 .to_string(),
1023 },
1024 ))),
1025 }
1026}
1027
1028/// Handle `ServiceRequest::SessionKeyRegister`: verify the signature against
1029/// the matching pending challenge and, on success, add `pubkey` to the
1030/// connection's session keyset.
1031///
1032/// The challenge is consumed (removed) whether verification succeeds or fails,
1033/// so a bad signature can't be retried against the same challenge.
1034fn handle_session_key_register(
1035 state: &mut ConnectionState,
1036 pubkey: PublicKey,
1037 signature: &[u8],
1038) -> crate::Result<ServiceResponse> {
1039 match state {
1040 ConnectionState::Authenticated {
1041 session_keyset,
1042 pending_key_challenges,
1043 ..
1044 } => {
1045 let challenge = pending_key_challenges.remove(&pubkey).ok_or_else(|| {
1046 crate::Error::Auth(Box::new(AuthError::InvalidAuthConfiguration {
1047 reason: format!(
1048 "no outstanding SessionKeyChallenge for pubkey '{pubkey}'; \
1049 issue the challenge before registering"
1050 ),
1051 }))
1052 })?;
1053 verify_challenge_response(&challenge, signature, &pubkey)
1054 .map_err(|e| crate::Error::Auth(Box::new(e)))?;
1055 session_keyset.insert(pubkey);
1056 Ok(ServiceResponse::Ok)
1057 }
1058 _ => Err(crate::Error::Auth(Box::new(
1059 AuthError::InvalidAuthConfiguration {
1060 reason: "SessionKeyRegister requires an authenticated connection; \
1061 complete TrustedLogin* first"
1062 .to_string(),
1063 },
1064 ))),
1065 }
1066}
1067
1068/// Resolve `pubkey`'s permission against `tree_id`'s `auth_settings` and reject
1069/// the request if the resolved level doesn't cover `required`.
1070///
1071/// If the database doesn't exist on this daemon yet and `require_existing`
1072/// is false, the gate passes through so the dispatched op surfaces its own
1073/// response (NotFound, empty result, or — for write coordination — a
1074/// no-op). This is what keeps the legitimate "create a new database" flow
1075/// working: `Database::create` reads tips on the tree before its root entry
1076/// has propagated, so an outright denial here would break creation. The
1077/// cost is that callers can still distinguish "no such database" from
1078/// "exists but no access"; closing that existence-leak channel is filed as
1079/// a follow-up.
1080///
1081/// `require_existing = true` flips that to **fail closed**: an absent
1082/// target is denied rather than waved through. Used for the
1083/// `SetInstanceMetadata` admin gate (D8) — `_databases` always exists on an
1084/// initialized daemon and that op is never a creation flow, so the
1085/// create-flow passthrough there would only ever be a fail-open hole that
1086/// lets any authenticated user rewrite the daemon's system-DB pointers if
1087/// `_databases` were ever unreadable.
1088///
1089/// When the gate does fire, the denial error is the same shape regardless
1090/// of which sub-check failed (key not in auth_settings, mismatched hint,
1091/// insufficient permission level): no internal detail about *why* leaks
1092/// back over the wire.
1093///
1094/// System databases (`_users`, `_databases`, `_sync`, `_instance`) are gated
1095/// like any other tree: callers must hold the required permission in the
1096/// system DB's `auth_settings`. The instance-admin bootstrap (first user on
1097/// the device) writes the first user as `Admin(0)` on `_users` and
1098/// `_databases`, which is how legitimate administrative access is granted —
1099/// the previous hardcoded read exemption is gone. The daemon's device-keyed
1100/// local path still handles internal system-database maintenance writes that
1101/// originate inside the server.
1102async fn gate_tree_permission(
1103 instance: &Instance,
1104 pubkey: &PublicKey,
1105 identity: &SigKey,
1106 tree_id: &ID,
1107 required: Permission,
1108 require_existing: bool,
1109) -> crate::Result<()> {
1110 let denied = || {
1111 crate::Error::Auth(Box::new(AuthError::PermissionDenied {
1112 reason: format!("tree {tree_id}: pubkey {pubkey} not permitted for {required:?}"),
1113 }))
1114 };
1115
1116 if !instance.has_database(tree_id).await {
1117 return if require_existing {
1118 Err(denied())
1119 } else {
1120 Ok(())
1121 };
1122 }
1123
1124 let database = Database::open(instance, tree_id).await?;
1125 let settings_store = database.get_settings().await?;
1126 let auth_settings = settings_store.auth_snapshot().await?;
1127
1128 let resolved =
1129 match resolve_identity_permission(pubkey, identity, &auth_settings, Some(instance)).await {
1130 Ok(p) => p,
1131 // Resolution failures (key not found, mismatch, etc.) collapse to the
1132 // same shape as an insufficient-permission denial, so the client
1133 // can't tell whether its identity was unknown or merely too low.
1134 Err(_) => return Err(denied()),
1135 };
1136
1137 let allowed = match required {
1138 Permission::Read => true,
1139 Permission::Write(_) => resolved.can_write(),
1140 Permission::Admin(_) => resolved.can_admin(),
1141 };
1142
1143 if !allowed {
1144 return Err(denied());
1145 }
1146
1147 Ok(())
1148}
1149
1150/// Per-tree read gate for ops keyed by a raw entry id, which therefore
1151/// carry no inline tree id and never hit the pre-dispatch `tree_id()` gate
1152/// (`Get`). The tree to authorise against is only knowable *after* the
1153/// fetch: it is the entry's claimed `tree.root`, or — for a tree-root
1154/// entry, whose `root()` is `None` — the entry's own id.
1155///
1156/// Model B (hard multi-tenant boundary): system DBs are unencrypted and
1157/// protected solely by this gate, so a raw cross-tree `Get` MUST resolve
1158/// and check the real owning tree before returning content. Delegates to
1159/// `gate_tree_permission`, so the `has_database`-absent passthrough and the
1160/// opaque denial shape are identical to the inline-tree-id path.
1161async fn gate_entry_read(
1162 instance: &Instance,
1163 pubkey: &PublicKey,
1164 identity: &SigKey,
1165 entry: &crate::entry::Entry,
1166) -> crate::Result<()> {
1167 let owning_tree = entry.root().unwrap_or_else(|| entry.id());
1168 // create-flow passthrough (false): a Get against a tree not yet
1169 // registered on this daemon must be waved through, same as the
1170 // inline-tree-id path.
1171 gate_tree_permission(
1172 instance,
1173 pubkey,
1174 identity,
1175 &owning_tree,
1176 Permission::Read,
1177 false,
1178 )
1179 .await
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184 use super::*;
1185 use crate::backend::database::InMemory;
1186 use crate::service::protocol::{Handshake, write_frame};
1187
1188 /// Helper: start a server on a temp socket, return path + shutdown sender.
1189 async fn start_test_server() -> (PathBuf, watch::Sender<()>, Instance) {
1190 let dir = tempfile::tempdir().unwrap();
1191 let socket_path = dir.keep().join("test.sock");
1192 let (instance, _admin) = Instance::create_backend(
1193 Box::new(InMemory::new()),
1194 crate::NewUser::passwordless("admin"),
1195 )
1196 .await
1197 .unwrap();
1198 let (tx, rx) = watch::channel(());
1199 let server = ServiceServer::new(instance.clone(), socket_path.clone());
1200 tokio::spawn(async move {
1201 let _ = server.run(rx).await;
1202 });
1203 // Wait for the socket to appear (server binds asynchronously). Poll
1204 // with a short sleep instead of a fixed delay so a slow sandbox
1205 // (where this test was occasionally flaky under `nix build`) doesn't
1206 // race the bind step.
1207 for _ in 0..50 {
1208 if socket_path.exists() {
1209 break;
1210 }
1211 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1212 }
1213 (socket_path, tx, instance)
1214 }
1215
1216 #[tokio::test]
1217 async fn test_server_starts_and_shuts_down() {
1218 let (socket_path, tx, _instance) = start_test_server().await;
1219 assert!(socket_path.exists());
1220 drop(tx);
1221 // Poll for cleanup with the same robustness as the bind wait.
1222 for _ in 0..50 {
1223 if !socket_path.exists() {
1224 break;
1225 }
1226 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1227 }
1228 // Socket should be cleaned up
1229 assert!(!socket_path.exists());
1230 }
1231
1232 #[tokio::test]
1233 async fn test_wrong_protocol_version() {
1234 let (socket_path, _tx, _instance) = start_test_server().await;
1235
1236 let stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
1237 let (mut reader, mut writer) = tokio::io::split(stream);
1238
1239 // Send wrong version
1240 let handshake = Handshake {
1241 protocol_version: 999,
1242 };
1243 write_frame(&mut writer, &handshake).await.unwrap();
1244
1245 // Read ack (server sends its version back)
1246 let ack: Option<HandshakeAck> = read_frame(&mut reader).await.unwrap();
1247 let ack = ack.unwrap();
1248 assert_eq!(ack.protocol_version, PROTOCOL_VERSION);
1249
1250 // Connection should be closed by server after version mismatch
1251 // Next read should get EOF
1252 let result: crate::Result<Option<ServiceResponse>> = read_frame(&mut reader).await;
1253 assert!(result.unwrap().is_none());
1254 }
1255
1256 /// Load-bearing invariant: `ConnectionState` must never hold plaintext
1257 /// signing material in any variant. The daemon participates in storage
1258 /// and challenge-response only; the rejected Branch A design held
1259 /// decrypted user keys server-side and that boundary was reinstated by
1260 /// design (see Service Architecture doc § Decision record).
1261 ///
1262 /// Structure of the test: construct each variant, destructure with named
1263 /// fields (so adding a field forces this test to be edited), and check
1264 /// the static type of each field is not `PrivateKey`. A future refactor
1265 /// that adds e.g. `decrypted_root_key: PrivateKey` to `Authenticated`
1266 /// would either fail the type check at runtime or fail the destructure
1267 /// match exhaustiveness at compile time.
1268 #[test]
1269 fn connection_state_never_holds_private_key() {
1270 use crate::auth::crypto::{PrivateKey, generate_keypair};
1271 use std::any::TypeId;
1272
1273 fn assert_not_private_key<T: 'static>(_value: &T, label: &str) {
1274 assert_ne!(
1275 TypeId::of::<T>(),
1276 TypeId::of::<PrivateKey>(),
1277 "ConnectionState field `{label}` is PrivateKey — daemon must not hold plaintext keys"
1278 );
1279 }
1280
1281 let (_signing, pubkey) = generate_keypair();
1282 let states = [
1283 ConnectionState::PreAuth,
1284 ConnectionState::AwaitingProof {
1285 username: "u".to_string(),
1286 user_uuid: "uu".to_string(),
1287 challenge: vec![1, 2, 3],
1288 expected_pubkey: pubkey.clone(),
1289 },
1290 ConnectionState::Authenticated {
1291 username: "u".to_string(),
1292 user_uuid: "uu".to_string(),
1293 login_pubkey: pubkey.clone(),
1294 session_keyset: {
1295 let mut s = HashSet::new();
1296 s.insert(pubkey);
1297 s
1298 },
1299 pending_key_challenges: HashMap::new(),
1300 },
1301 ];
1302
1303 for state in &states {
1304 match state {
1305 ConnectionState::PreAuth => {}
1306 ConnectionState::AwaitingProof {
1307 username,
1308 user_uuid,
1309 challenge,
1310 expected_pubkey,
1311 } => {
1312 assert_not_private_key(username, "AwaitingProof::username");
1313 assert_not_private_key(user_uuid, "AwaitingProof::user_uuid");
1314 assert_not_private_key(challenge, "AwaitingProof::challenge");
1315 assert_not_private_key(expected_pubkey, "AwaitingProof::expected_pubkey");
1316 }
1317 ConnectionState::Authenticated {
1318 username,
1319 user_uuid,
1320 login_pubkey,
1321 session_keyset,
1322 pending_key_challenges,
1323 } => {
1324 assert_not_private_key(username, "Authenticated::username");
1325 assert_not_private_key(user_uuid, "Authenticated::user_uuid");
1326 assert_not_private_key(login_pubkey, "Authenticated::login_pubkey");
1327 for k in session_keyset {
1328 assert_not_private_key(k, "Authenticated::session_keyset entry");
1329 }
1330 for (k, ch) in pending_key_challenges {
1331 assert_not_private_key(k, "Authenticated::pending_key_challenges key");
1332 assert_not_private_key(
1333 ch,
1334 "Authenticated::pending_key_challenges challenge",
1335 );
1336 }
1337 }
1338 }
1339 }
1340 }
1341
1342 #[tokio::test]
1343 async fn test_authenticated_request_rejected_without_login() {
1344 // Companion to the integration test `test_unauthenticated_backend_op_rejected`
1345 // — exercises the same gate path against the raw protocol so a
1346 // regression here surfaces immediately, not just at the
1347 // `RemoteConnection` layer.
1348 let (socket_path, _tx, _instance) = start_test_server().await;
1349
1350 let stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
1351 let (mut reader, mut writer) = tokio::io::split(stream);
1352
1353 write_frame(
1354 &mut writer,
1355 &Handshake {
1356 protocol_version: PROTOCOL_VERSION,
1357 },
1358 )
1359 .await
1360 .unwrap();
1361 let _ack: Option<HandshakeAck> = read_frame(&mut reader).await.unwrap();
1362
1363 // Send an AuthenticatedDb request without completing TrustedLogin.
1364 write_frame(
1365 &mut writer,
1366 &ServiceRequest::AuthenticatedDb(Box::new(AuthenticatedDbRequest {
1367 root_id: crate::entry::ID::default(),
1368 identity: crate::auth::types::SigKey::default(),
1369 op: DatabaseOp::GetEntry {
1370 id: crate::entry::ID::from_bytes("nonexistent"),
1371 },
1372 })),
1373 )
1374 .await
1375 .unwrap();
1376
1377 let frame: Option<ServerFrame> = read_frame(&mut reader).await.unwrap();
1378 let resp = match frame.unwrap() {
1379 ServerFrame::Response(r) => *r,
1380 other => panic!("Expected Response frame, got {other:?}"),
1381 };
1382 match resp {
1383 ServiceResponse::Error(e) => {
1384 assert_eq!(
1385 e.module, "auth",
1386 "expected an auth-module error from the gate; got {e:?}"
1387 );
1388 }
1389 other => panic!("Expected gate Error, got {other:?}"),
1390 }
1391 }
1392
1393 #[tokio::test]
1394 async fn test_get_instance_metadata() {
1395 let (socket_path, _tx, _instance) = start_test_server().await;
1396
1397 let stream = tokio::net::UnixStream::connect(&socket_path).await.unwrap();
1398 let (mut reader, mut writer) = tokio::io::split(stream);
1399
1400 // Handshake
1401 write_frame(
1402 &mut writer,
1403 &Handshake {
1404 protocol_version: PROTOCOL_VERSION,
1405 },
1406 )
1407 .await
1408 .unwrap();
1409 let _ack: Option<HandshakeAck> = read_frame(&mut reader).await.unwrap();
1410
1411 // Request metadata
1412 write_frame(&mut writer, &ServiceRequest::GetInstanceMetadata)
1413 .await
1414 .unwrap();
1415
1416 let frame: Option<ServerFrame> = read_frame(&mut reader).await.unwrap();
1417 let resp = match frame.unwrap() {
1418 ServerFrame::Response(r) => *r,
1419 other => panic!("Expected Response frame, got {other:?}"),
1420 };
1421 match resp {
1422 ServiceResponse::InstanceMetadata(Some(_meta)) => {
1423 // Server was initialized so metadata should exist
1424 }
1425 other => panic!("Expected InstanceMetadata(Some), got {other:?}"),
1426 }
1427 }
1428
1429 #[tokio::test]
1430 async fn test_stale_socket_cleanup() {
1431 let dir = tempfile::tempdir().unwrap();
1432 let socket_path = dir.path().join("test.sock");
1433
1434 // Create a stale socket file
1435 tokio::fs::write(&socket_path, "stale").await.unwrap();
1436 assert!(socket_path.exists());
1437
1438 let (instance, _admin) = Instance::create_backend(
1439 Box::new(InMemory::new()),
1440 crate::NewUser::passwordless("admin"),
1441 )
1442 .await
1443 .unwrap();
1444 let (_tx, rx) = watch::channel(());
1445 let server = ServiceServer::new(instance, socket_path.clone());
1446
1447 // Server should remove stale socket and bind successfully
1448 let handle = tokio::spawn(async move { server.run(rx).await });
1449
1450 // The server binds asynchronously; poll until it accepts a connection
1451 // rather than racing a fixed sleep (flaky under parallel test load).
1452 let mut stream = None;
1453 for _ in 0..200 {
1454 if let Ok(s) = tokio::net::UnixStream::connect(&socket_path).await {
1455 stream = Some(s);
1456 break;
1457 }
1458 tokio::time::sleep(std::time::Duration::from_millis(10)).await;
1459 }
1460 assert!(
1461 stream.is_some(),
1462 "server did not bind a connectable socket in time"
1463 );
1464
1465 handle.abort();
1466 }
1467
1468 /// D8 regression: `require_existing` flips the create-flow passthrough
1469 /// to fail-closed. An absent target is waved through with `false` (the
1470 /// `Database::create` path) but denied with `true` (the
1471 /// `SetInstanceMetadata` admin gate), so an unreadable `_databases`
1472 /// can't become a fail-open hole.
1473 #[tokio::test]
1474 async fn test_gate_require_existing_fails_closed_on_absent_db() {
1475 use crate::auth::crypto::generate_keypair;
1476
1477 let (instance, _admin) = Instance::create_backend(
1478 Box::new(InMemory::new()),
1479 crate::NewUser::passwordless("admin"),
1480 )
1481 .await
1482 .unwrap();
1483 let (_sk, pubkey) = generate_keypair();
1484 let absent = ID::from_bytes("no-such-tree");
1485
1486 gate_tree_permission(
1487 &instance,
1488 &pubkey,
1489 &SigKey::default(),
1490 &absent,
1491 Permission::Admin(0),
1492 false,
1493 )
1494 .await
1495 .expect("create-flow passthrough must wave an absent tree through");
1496
1497 let err = gate_tree_permission(
1498 &instance,
1499 &pubkey,
1500 &SigKey::default(),
1501 &absent,
1502 Permission::Admin(0),
1503 true,
1504 )
1505 .await
1506 .expect_err("require_existing must deny an absent tree");
1507 assert!(
1508 matches!(&err, crate::Error::Auth(b) if matches!(**b, AuthError::PermissionDenied { .. })),
1509 "expected PermissionDenied, got: {err:?}",
1510 );
1511 }
1512}