feat(grpc): persistent account management with keyring-only credentials

Add an authenticated Accounts gRPC service (AddAccount/ListAccounts), mounted
with the same bearer interceptor as System (every service authenticated). The
daemon now uses a persistent DB (~/.nuncio/nuncio.db; was ephemeral temp_dir)
and stores account passwords ONLY in the OS keyring -- never in SQLite, logs,
Debug/Serialize output, or the ListAccounts response (CLI PasswordArg redacts;
rpassword prompt, no echo). Wire nuncio-cli account add/list to the daemon over
gRPC. Tests prove persistence across restart and credential secrecy.
Security-reviewed, APPROVED.

Refs #156 #157
This commit is contained in:
James Maes committed 2026-07-26 23:36:00 -05:00
1 parent ec077115bb
commit 515e09c7b9
13 files changed
+1363 -141

No files matched your search

+103 -1
View File
@@ -1,7 +1,53 @@
//! Command-line argument hierarchy and Clap subcommand parsing.
use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Carries an account password credential from an interactive `rpassword`
/// prompt (see `nuncio-cli`'s `main`) through to `Accounts/AddAccount`
/// without ever being reachable as a Clap CLI flag, printed by `Debug`, or
/// carried by value through `Serialize`/`Deserialize` (backlog stories
/// 1.C.1 / 1.C.2, GH #156 / GH #157).
///
/// `AccountSubcommand::Add`'s `password` field is `#[arg(skip)]`, so Clap
/// never parses it from argv; `main` populates it exactly once, right after
/// `Cli::parse()`, by prompting without echo. `Debug`, `Serialize` are
/// hand-implemented below to redact the value on principle -- defense in
/// depth in case a future caller logs or serializes a `Commands` value --
/// and `Deserialize` always yields an empty password, since a password
/// must never round-trip through a serialized command payload.
#[derive(Clone, Default)]
pub struct PasswordArg(pub String);
impl std::fmt::Debug for PasswordArg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("PasswordArg(\"[REDACTED]\")")
}
}
impl PartialEq for PasswordArg {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl Eq for PasswordArg {}
impl Serialize for PasswordArg {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str("[REDACTED]")
}
}
impl<'de> Deserialize<'de> for PasswordArg {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
// Discard whatever was serialized (see the redacting `Serialize`
// impl above) and always yield an empty password rather than ever
// reconstructing a real credential from a deserialized payload.
let _ = String::deserialize(deserializer);
Ok(PasswordArg(String::new()))
}
}
/// Nuncio CLI — Pure Noun + Verb Developer-First Mail & Calendar Automation.
#[derive(Parser, Debug, Clone, PartialEq, Eq)]
@@ -160,6 +206,12 @@ pub enum AccountSubcommand {
/// SMTP connection transport mode (implicit_tls, start_tls, plain).
#[arg(long, default_value = "implicit_tls", help = "SMTP transport mode")]
smtp_mode: String,
/// Account password credential. Never a CLI flag -- always read
/// interactively without echo (via `rpassword`) in `main` and
/// injected here immediately before dispatch to the daemon's
/// `Accounts/AddAccount` RPC. See [`PasswordArg`].
#[arg(skip)]
password: PasswordArg,
},
/// Display details for a specific configured account.
Show {
@@ -368,6 +420,7 @@ mod tests {
smtp_port: 465,
imap_mode: "implicit_tls".to_string(),
smtp_mode: "implicit_tls".to_string(),
password: PasswordArg::default(),
}
}
);
@@ -414,4 +467,53 @@ mod tests {
}
);
}
#[test]
fn account_add_password_is_never_parsed_from_argv() {
// `--password` is deliberately not a recognized flag: the password
// MUST always come from an interactive, non-echoing prompt in
// `main`, never from argv (which would land it in shell history and
// process listings).
let cli_add = Cli::parse_from([
"nuncio",
"account",
"add",
"--email",
"a@b.com",
"--imap-host",
"imap.b.com",
]);
match cli_add.command {
Commands::Account {
action: AccountSubcommand::Add { password, .. },
} => assert_eq!(password, PasswordArg::default()),
other => panic!("expected Account::Add, got {other:?}"),
}
}
#[test]
fn password_arg_debug_and_serialize_always_redact_the_value() {
let secret = PasswordArg("super-secret-value".to_string());
assert_eq!(format!("{secret:?}"), "PasswordArg(\"[REDACTED]\")");
let json = serde_json::to_string(&secret).expect("serializes");
assert_eq!(json, "\"[REDACTED]\"");
assert!(!json.contains("super-secret-value"));
}
#[test]
fn password_arg_deserialize_never_reconstructs_a_real_credential() {
let deserialized: PasswordArg =
serde_json::from_str("\"super-secret-value\"").expect("deserializes");
assert_eq!(deserialized, PasswordArg::default());
assert_eq!(deserialized.0, "");
}
#[test]
fn password_arg_equality_and_default() {
assert_eq!(PasswordArg::default(), PasswordArg(String::new()));
assert_ne!(PasswordArg("a".to_string()), PasswordArg("b".to_string()));
let cloned = PasswordArg("clone-me".to_string()).clone();
assert_eq!(cloned.0, "clone-me");
}
}
+2 -1
View File
@@ -7,7 +7,8 @@ pub mod output;
pub mod runner;
pub use args::{
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, MailSubcommand, SystemSubcommand,
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, MailSubcommand, PasswordArg,
SystemSubcommand,
};
pub use output::JsonResponse;
pub use runner::HeadlessRunner;
+18 -2
View File
@@ -1,9 +1,25 @@
use clap::Parser;
use nuncio_cli::{args::Cli, HeadlessRunner};
use nuncio_cli::{args::Cli, AccountSubcommand, Commands, HeadlessRunner, PasswordArg};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let mut cli = Cli::parse();
// `account add`'s password is deliberately never a Clap-parsed CLI
// flag (see `PasswordArg`'s doc comment): read it here, interactively
// and without echo, immediately after parsing argv and before any
// other work, then inject it into the already-parsed command. This is
// the ONLY place in the whole `nuncio-cli` binary that ever reads a raw
// password from the terminal.
if let Commands::Account {
action: AccountSubcommand::Add { password, .. },
} = &mut cli.command
{
let entered = rpassword::prompt_password("Account password: ")
.map_err(|e| format!("failed to read password: {e}"))?;
*password = PasswordArg(entered);
}
let runner = HeadlessRunner::ephemeral().await?;
let output_str = runner.execute_command(&cli.command, cli.json).await;
+427 -71
View File
@@ -14,6 +14,31 @@ use crate::args::{
use crate::output::{format_json, format_json_error};
/// Parses a `--imap-mode`/`--smtp-mode` CLI string into `nuncio_core::TlsMode`.
/// Rejects anything else rather than silently defaulting, since silently
/// falling back to e.g. `Plain` for a typo'd mode would be a serious
/// transport-security footgun.
fn parse_tls_mode(mode: &str) -> Result<nuncio_core::TlsMode, String> {
match mode {
"implicit_tls" => Ok(nuncio_core::TlsMode::ImplicitTls),
"start_tls" => Ok(nuncio_core::TlsMode::StartTls),
"plain" => Ok(nuncio_core::TlsMode::Plain),
other => Err(format!(
"invalid tls mode '{other}' (expected implicit_tls, start_tls, or plain)"
)),
}
}
/// Maps a `nuncio_core::TlsMode` onto its wire-format `nuncio.v1.TlsMode`
/// enum value, mirroring `nunciod::grpc`'s server-side mapping.
fn map_tls_mode_to_proto(mode: nuncio_core::TlsMode) -> nuncio_proto::v1::TlsMode {
match mode {
nuncio_core::TlsMode::ImplicitTls => nuncio_proto::v1::TlsMode::ImplicitTls,
nuncio_core::TlsMode::StartTls => nuncio_proto::v1::TlsMode::StartTls,
nuncio_core::TlsMode::Plain => nuncio_proto::v1::TlsMode::Plain,
}
}
/// Errors emitted by the CLI headless runner.
#[derive(Error, Debug)]
pub enum RunnerError {
@@ -29,11 +54,19 @@ pub enum RunnerError {
///
/// Most commands operate against an ephemeral local engine (database +
/// event bus) for now. `system status` (see [`Self::handle_system_status`])
/// is the exception: it is a thin gRPC client of the real `nunciod` daemon's
/// `nuncio.v1.System` API (backlog story 1.A.3 / GH-150), authenticated by a
/// bearer token read from an injected [`SecretManager`] — production code
/// uses [`SecretManager::production`] (the real OS keyring), while tests
/// inject [`SecretManager::mock`] so no test ever touches the real vault.
/// and `account add` / `account list` (backlog stories 1.C.1 / 1.C.2,
/// GH #156 / GH #157, see [`Self::handle_add_account`] /
/// [`Self::handle_accounts_list`]) are the exceptions: they are thin gRPC
/// clients of the real `nunciod` daemon's `nuncio.v1.System` and
/// `nuncio.v1.Accounts` APIs, authenticated by a bearer token read from an
/// injected [`SecretManager`] — production code uses
/// [`SecretManager::production`] (the real OS keyring), while tests inject
/// [`SecretManager::mock`] so no test ever touches the real vault.
///
/// `account add` persists through the daemon so the account (and its
/// password, stored ONLY in the daemon's OS keyring vault) survives past
/// this CLI process exiting -- unlike this runner's own ephemeral local
/// `db`, which is thrown away when the process exits.
pub struct HeadlessRunner {
event_bus: EventBus,
db: DatabaseEngine,
@@ -102,9 +135,16 @@ impl HeadlessRunner {
smtp_port: _,
imap_mode,
smtp_mode,
password,
} => {
self.handle_add_account(
email, imap_host, *imap_port, imap_mode, smtp_mode, json_mode,
email,
imap_host,
*imap_port,
imap_mode,
smtp_mode,
&password.0,
json_mode,
)
.await
}
@@ -757,6 +797,17 @@ impl HeadlessRunner {
}
}
/// `account add`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Accounts` API (backlog story 1.C.1 / 1.C.2,
/// GH #156 / GH #157).
///
/// The account configuration AND `password` are sent to the daemon in a
/// single `AddAccount` RPC; the daemon is solely responsible for
/// writing the password to the OS keyring vault and the config to its
/// persistent database -- this runner's own ephemeral local `db` is
/// never touched for this command, so the account survives this CLI
/// process exiting. `password` is never logged here, only forwarded.
#[allow(clippy::too_many_arguments)]
async fn handle_add_account(
&self,
email: &str,
@@ -764,63 +815,149 @@ impl HeadlessRunner {
imap_port: u16,
imap_mode: &str,
smtp_mode: &str,
password: &str,
json_mode: bool,
) -> String {
let imap_tls_mode = match parse_tls_mode(imap_mode) {
Ok(mode) => mode,
Err(e) => return Self::render_error(&e, json_mode),
};
let smtp_tls_mode = match parse_tls_mode(smtp_mode) {
Ok(mode) => mode,
Err(e) => return Self::render_error(&e, json_mode),
};
let keyring_key = format!("nuncio/{}", email);
let account_id = format!("acct-{}", email.replace('@', "-at-").replace('.', "-"));
let acct = nuncio_core::AccountConfig {
let proto_config = nuncio_proto::v1::AccountConfig {
id: account_id.clone(),
name: email.to_string(),
email_address: email.to_string(),
protocol: nuncio_core::AccountProtocol::ImapSmtp,
protocol: nuncio_proto::v1::AccountProtocol::ImapSmtp.into(),
server_host: imap_host.to_string(),
server_port: imap_port,
server_port: u32::from(imap_port),
use_tls: true,
imap_tls_mode: nuncio_core::TlsMode::ImplicitTls,
smtp_tls_mode: nuncio_core::TlsMode::ImplicitTls,
imap_tls_mode: map_tls_mode_to_proto(imap_tls_mode).into(),
smtp_tls_mode: map_tls_mode_to_proto(smtp_tls_mode).into(),
keyring_secret_key: keyring_key.clone(),
sync_interval_secs: 300,
};
if let Err(e) = self.db.save_account(&acct).await {
if json_mode {
return format_json_error(&e.to_string());
} else {
return format!("Failed to save account: {e}");
}
}
let mut client = match self.connect_accounts_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
if json_mode {
format_json(&json!({
"configured": true,
"account_id": account_id,
"email": email,
"imap_host": imap_host,
"imap_port": imap_port,
"imap_mode": imap_mode,
"smtp_mode": smtp_mode,
"keyring_key": keyring_key
}))
} else {
format!(
"Account '{}' (ID: {}) saved to SQLite database and configured for IMAP ({}:{}, mode: {}) and SMTP (mode: {})",
email, account_id, imap_host, imap_port, imap_mode, smtp_mode
)
match client
.add_account(nuncio_proto::v1::AddAccountRequest {
config: Some(proto_config),
password: password.to_string(),
})
.await
{
Ok(response) => {
let account_id = response.into_inner().id;
if json_mode {
format_json(&json!({
"configured": true,
"account_id": account_id,
"email": email,
"imap_host": imap_host,
"imap_port": imap_port,
"imap_mode": imap_mode,
"smtp_mode": smtp_mode,
"keyring_key": keyring_key
}))
} else {
format!(
"Account '{}' (ID: {}) added via nunciod daemon and configured for IMAP ({}:{}, mode: {}) and SMTP (mode: {})",
email, account_id, imap_host, imap_port, imap_mode, smtp_mode
)
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected add_account: {status}"),
json_mode,
),
}
}
/// `account list`: a real thin gRPC client of the running `nunciod`
/// daemon's `nuncio.v1.Accounts` API (backlog story 1.C.1 / 1.C.2,
/// GH #156 / GH #157). The daemon's `ListAccounts` response never
/// contains password credentials (see `nunciod::grpc`'s `Accounts`
/// implementation), so there is nothing to scrub here.
async fn handle_accounts_list(&self, json_mode: bool) -> String {
let accounts = self.db.list_accounts().await.unwrap_or_default();
let mut client = match self.connect_accounts_client().await {
Ok(client) => client,
Err(e) => return Self::render_error(&e, json_mode),
};
match client
.list_accounts(nuncio_proto::v1::ListAccountsRequest {})
.await
{
Ok(response) => {
let accounts = response.into_inner().accounts;
if json_mode {
let accounts_json: Vec<serde_json::Value> = accounts
.iter()
.map(|a| {
json!({
"id": a.id,
"name": a.name,
"email_address": a.email_address,
"protocol": a.protocol().as_str_name(),
"server_host": a.server_host,
"server_port": a.server_port,
"use_tls": a.use_tls,
"imap_tls_mode": a.imap_tls_mode().as_str_name(),
"smtp_tls_mode": a.smtp_tls_mode().as_str_name(),
"keyring_secret_key": a.keyring_secret_key,
"sync_interval_secs": a.sync_interval_secs,
})
})
.collect();
format_json(&json!({ "accounts": accounts_json }))
} else {
format!(
"Configured Accounts: {} account(s) registered",
accounts.len()
)
}
}
Err(status) => Self::render_error(
&format!("nunciod daemon rejected list_accounts: {status}"),
json_mode,
),
}
}
/// Resolves the gRPC bearer token from the injected vault and dials the
/// `nuncio.v1.Accounts` service at `self.grpc_addr`, shared by
/// [`Self::handle_add_account`] and [`Self::handle_accounts_list`].
async fn connect_accounts_client(
&self,
) -> Result<nuncio_proto::client::AuthenticatedAccountsClient, String> {
let token_bytes = self
.secrets
.get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to read gRPC bearer token from vault: {e}"))?;
let token = hex::encode(token_bytes);
nuncio_proto::client::connect_accounts(&self.grpc_addr, &token)
.await
.map_err(|e| format!("nunciod daemon unreachable at {}: {e}", self.grpc_addr))
}
/// Formats an error message consistently for both JSON and human-text
/// output modes.
fn render_error(message: &str, json_mode: bool) -> String {
if json_mode {
format_json(&json!({
"accounts": accounts
}))
format_json_error(message)
} else {
format!(
"Configured Accounts: {} account(s) registered",
accounts.len()
)
format!("Error: {message}")
}
}
@@ -887,41 +1024,108 @@ impl HeadlessRunner {
mod tests {
use super::*;
#[test]
fn parse_tls_mode_accepts_all_valid_modes_and_rejects_garbage() {
assert_eq!(
parse_tls_mode("implicit_tls").expect("valid mode"),
nuncio_core::TlsMode::ImplicitTls
);
assert_eq!(
parse_tls_mode("start_tls").expect("valid mode"),
nuncio_core::TlsMode::StartTls
);
assert_eq!(
parse_tls_mode("plain").expect("valid mode"),
nuncio_core::TlsMode::Plain
);
let err = parse_tls_mode("not-a-real-mode").expect_err("garbage mode must be rejected");
assert!(err.contains("invalid tls mode"));
}
#[test]
fn map_tls_mode_to_proto_mirrors_every_variant() {
assert_eq!(
map_tls_mode_to_proto(nuncio_core::TlsMode::ImplicitTls),
nuncio_proto::v1::TlsMode::ImplicitTls
);
assert_eq!(
map_tls_mode_to_proto(nuncio_core::TlsMode::StartTls),
nuncio_proto::v1::TlsMode::StartTls
);
assert_eq!(
map_tls_mode_to_proto(nuncio_core::TlsMode::Plain),
nuncio_proto::v1::TlsMode::Plain
);
}
/// `account add` must reject an invalid `--imap-mode`/`--smtp-mode`
/// string BEFORE ever dialing the daemon -- proven here by pointing at
/// an address nothing is listening on and confirming the failure is the
/// validation error, not a connection error.
#[tokio::test]
async fn account_add_rejects_invalid_tls_mode_before_dialing_the_daemon() {
let runner =
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), "127.0.0.1:1".into())
.await
.expect("runner init");
let bad_imap_mode = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Add {
email: "x@y.com".to_string(),
imap_host: "imap.y.com".to_string(),
imap_port: 993,
smtp_host: "smtp.y.com".to_string(),
smtp_port: 465,
imap_mode: "not-a-real-mode".to_string(),
smtp_mode: "implicit_tls".to_string(),
password: crate::args::PasswordArg("pw".to_string()),
},
},
true,
)
.await;
assert!(bad_imap_mode.contains("invalid tls mode"));
assert!(!bad_imap_mode.contains("unreachable"));
let bad_smtp_mode = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Add {
email: "x@y.com".to_string(),
imap_host: "imap.y.com".to_string(),
imap_port: 993,
smtp_host: "smtp.y.com".to_string(),
smtp_port: 465,
imap_mode: "implicit_tls".to_string(),
smtp_mode: "also-not-real".to_string(),
password: crate::args::PasswordArg("pw".to_string()),
},
},
false,
)
.await;
assert!(bad_smtp_mode.starts_with("Error: invalid tls mode"));
}
#[tokio::test]
async fn headless_runner_executes_all_pure_noun_verb_commands() {
let runner = HeadlessRunner::ephemeral()
.await
.expect("ephemeral runner initializes");
// Account Noun Commands
let acct_add = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Add {
email: "james.maes@kof22.com".to_string(),
imap_host: "mail.kof22.com".to_string(),
imap_port: 993,
smtp_host: "mail.kof22.com".to_string(),
smtp_port: 465,
imap_mode: "implicit_tls".to_string(),
smtp_mode: "implicit_tls".to_string(),
},
},
true,
)
.await;
assert!(acct_add.contains(r#""email":"james.maes@kof22.com""#));
let acct_list = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::List,
},
true,
)
.await;
assert!(acct_list.contains("acct-james-maes-at-kof22-com"));
// Account Noun Commands: `Add`/`List` are exercised separately
// below via `ephemeral_with` + `SecretManager::mock()` against a
// live test gRPC server, for the exact same reason `system status`
// is (backlog stories 1.C.1 / 1.C.2, GH #156 / GH #157): they are
// now real gRPC clients of the `nunciod` daemon's `Accounts` API,
// so they must never run against this `ephemeral()`-constructed
// runner's production `SecretManager` or its (unreachable in CI)
// default gRPC address. `Show` still reads this runner's own
// ephemeral local `db` (out of scope for this backlog story), so it
// is safe to exercise here.
let acct_show = runner
.execute_command(
&Commands::Account {
@@ -1165,6 +1369,158 @@ mod tests {
assert!(text_out.contains("9.9.9"));
}
/// Reference-client proof for backlog stories 1.C.1 / 1.C.2 (GH #156 /
/// GH #157): boots a stub `nuncio.v1.Accounts` gRPC server (mirroring
/// `system_status_reports_live_daemon_status_over_grpc_when_reachable`'s
/// `StubSystem` pattern above) and drives the real `HeadlessRunner`'s
/// `account add` / `account list` gRPC client paths against it.
///
/// Real persistence-across-restart and credential-secrecy (never in
/// SQLite, never in `ListAccounts`) are proven by `nunciod`'s own
/// `grpc::tests`; this test exists purely to prove the CLI's connect +
/// call + JSON-format happy path, AND that the password the CLI sends
/// over the wire is exactly what the caller supplied (never mangled,
/// dropped, or substituted).
#[tokio::test]
async fn account_add_and_list_round_trip_over_grpc_to_a_stub_daemon() {
use nuncio_proto::v1::accounts_server::{Accounts as AccountsService, AccountsServer};
use nuncio_proto::v1::{
AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
AddAccountRequest, AddAccountResponse, ListAccountsRequest, ListAccountsResponse,
TlsMode as TlsModeProto,
};
use std::sync::Mutex;
/// Minimal test-only stub of `nuncio.v1.Accounts`: records the last
/// `AddAccountRequest` it received (so this test can assert on
/// exactly what the CLI sent over the wire, including the
/// password) and returns a fixed `ListAccounts` response.
#[derive(Default)]
struct StubAccounts {
last_add_request: Arc<Mutex<Option<AddAccountRequest>>>,
}
#[tonic::async_trait]
impl AccountsService for StubAccounts {
async fn add_account(
&self,
request: tonic::Request<AddAccountRequest>,
) -> Result<tonic::Response<AddAccountResponse>, tonic::Status> {
let req = request.into_inner();
let id = req
.config
.as_ref()
.map(|c| c.id.clone())
.unwrap_or_default();
*self
.last_add_request
.lock()
.unwrap_or_else(|e| e.into_inner()) = Some(req);
Ok(tonic::Response::new(AddAccountResponse { id }))
}
async fn list_accounts(
&self,
_request: tonic::Request<ListAccountsRequest>,
) -> Result<tonic::Response<ListAccountsResponse>, tonic::Status> {
Ok(tonic::Response::new(ListAccountsResponse {
accounts: vec![AccountConfigProto {
id: "acct-stub-1".to_string(),
name: "Stub Account".to_string(),
email_address: "stub@nuncio.mx".to_string(),
protocol: AccountProtocolProto::ImapSmtp.into(),
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
use_tls: true,
imap_tls_mode: TlsModeProto::ImplicitTls.into(),
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
keyring_secret_key: "nuncio/acct-stub-1".to_string(),
sync_interval_secs: 300,
}],
}))
}
}
let stub = StubAccounts::default();
let probe = stub.last_add_request.clone();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(AccountsServer::new(stub))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await;
});
let runner =
HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("ephemeral runner initializes");
let add_out = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Add {
email: "james.maes@kof22.com".to_string(),
imap_host: "mail.kof22.com".to_string(),
imap_port: 993,
smtp_host: "mail.kof22.com".to_string(),
smtp_port: 465,
imap_mode: "implicit_tls".to_string(),
smtp_mode: "implicit_tls".to_string(),
password: crate::args::PasswordArg("s3cr3t-cli-password".to_string()),
},
},
true,
)
.await;
assert!(add_out.contains(r#""email":"james.maes@kof22.com""#));
assert!(add_out.contains("acct-james-maes-at-kof22-com"));
// The password must never be echoed back in the CLI's own output.
assert!(!add_out.contains("s3cr3t-cli-password"));
// ...but it MUST have been sent to the daemon intact, exactly as
// the caller supplied it.
let recorded = probe
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
.expect("stub daemon received an add_account request");
assert_eq!(recorded.password, "s3cr3t-cli-password");
assert_eq!(
recorded
.config
.expect("config present on the request")
.keyring_secret_key,
"nuncio/james.maes@kof22.com"
);
let list_out = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::List,
},
true,
)
.await;
assert!(list_out.contains("acct-stub-1"));
assert!(list_out.contains("stub@nuncio.mx"));
assert!(list_out.contains(r#""protocol":"ACCOUNT_PROTOCOL_IMAP_SMTP""#));
let list_out_text = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::List,
},
false,
)
.await;
assert!(list_out_text.contains("1 account(s) registered"));
}
#[test]
fn runner_error_display() {
let err = RunnerError::InitFailed("failed to open database".to_string());
+65 -31
View File
@@ -4,7 +4,7 @@
use nuncio_cli::{
AccountSubcommand, CalSubcommand, Commands, FolderSubcommand, HeadlessRunner, MailSubcommand,
SystemSubcommand,
PasswordArg, SystemSubcommand,
};
use nuncio_store::vault::SecretManager;
use serde_json::Value;
@@ -31,36 +31,11 @@ async fn system_test_cli_noun_verb_execution_matrix() {
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
// 2. Account list & add
let out: String = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::List,
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
let out: String = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Add {
email: "test@nuncio.mx".to_string(),
imap_host: "mail.nuncio.mx".to_string(),
imap_port: 993,
smtp_host: "mail.nuncio.mx".to_string(),
smtp_port: 465,
imap_mode: "implicit_tls".to_string(),
smtp_mode: "implicit_tls".to_string(),
},
},
true,
)
.await;
let json: Value = serde_json::from_str(&out).expect("valid json");
assert_eq!(json["status"], "ok");
// 2. Account list & add are real gRPC clients of the `nunciod` daemon's
// `Accounts` API (backlog stories 1.C.1 / 1.C.2, GH #156 / GH #157), so
// -- exactly like `system status` above -- they are exercised
// separately below via `ephemeral_with` + `SecretManager::mock()`
// rather than through this `ephemeral()`-constructed runner.
// 3. Folder list
let out: String = runner
@@ -145,3 +120,62 @@ async fn system_status_reports_honest_error_when_daemon_unreachable() {
.expect("error message present")
.contains("unreachable"));
}
/// `account add` / `account list` are real gRPC clients of the `nunciod`
/// daemon's `Accounts` API (backlog stories 1.C.1 / 1.C.2, GH #156 /
/// GH #157). With no daemon reachable, both must report a clear, honest
/// error rather than fabricating success or silently falling back to any
/// local state.
#[tokio::test]
async fn account_add_and_list_report_honest_errors_when_daemon_unreachable() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
drop(listener); // free the port; nothing is listening on it now
let runner = HeadlessRunner::ephemeral_with(Arc::new(SecretManager::mock()), addr.to_string())
.await
.expect("runner init");
let list_out: String = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::List,
},
true,
)
.await;
let list_json: Value = serde_json::from_str(&list_out).expect("valid json");
assert_eq!(list_json["status"], "error");
assert!(list_json["error"]
.as_str()
.expect("error message present")
.contains("unreachable"));
let add_out: String = runner
.execute_command(
&Commands::Account {
action: AccountSubcommand::Add {
email: "test@nuncio.mx".to_string(),
imap_host: "mail.nuncio.mx".to_string(),
imap_port: 993,
smtp_host: "mail.nuncio.mx".to_string(),
smtp_port: 465,
imap_mode: "implicit_tls".to_string(),
smtp_mode: "implicit_tls".to_string(),
password: PasswordArg("irrelevant-unreachable-daemon".to_string()),
},
},
true,
)
.await;
let add_json: Value = serde_json::from_str(&add_out).expect("valid json");
assert_eq!(add_json["status"], "error");
assert!(add_json["error"]
.as_str()
.expect("error message present")
.contains("unreachable"));
// The password must never leak into an error message either.
assert!(!add_out.contains("irrelevant-unreachable-daemon"));
}
@@ -129,3 +129,88 @@ message EventError {
// Mirrors `CoreEvent::ShuttingDown`, which carries no fields.
message ShuttingDown {}
// Accounts manages persisted mail/calendar account configurations
// (backlog stories 1.C.1 / 1.C.2, GH #156 / GH #157).
//
// SECURITY (GH #165): this service MUST be mounted behind the SAME
// `BearerAuthInterceptor` that guards `System` -- every gRPC service `nunciod`
// exposes MUST be authenticated, with no exceptions carved out for "just one
// more" unauthenticated service. See `nunciod::grpc::serve_on_listener`.
service Accounts {
// AddAccount persists a new account configuration to the daemon's
// database and stores its password credential in the OS keyring vault.
// The password is NEVER written to the database and NEVER echoed back.
rpc AddAccount(AddAccountRequest) returns (AddAccountResponse);
// ListAccounts returns every persisted account configuration. Password
// credentials are NEVER included in this response.
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
}
// Mirrors `nuncio_core::AccountProtocol`.
enum AccountProtocol {
ACCOUNT_PROTOCOL_UNSPECIFIED = 0;
ACCOUNT_PROTOCOL_JMAP = 1;
ACCOUNT_PROTOCOL_IMAP_SMTP = 2;
}
// Mirrors `nuncio_core::TlsMode`.
enum TlsMode {
TLS_MODE_UNSPECIFIED = 0;
TLS_MODE_IMPLICIT_TLS = 1;
TLS_MODE_START_TLS = 2;
TLS_MODE_PLAIN = 3;
}
// Mirrors `nuncio_core::AccountConfig` field-for-field, MINUS the password
// credential: this message is used both as the request payload (alongside
// a separate `password` field) and as the response payload of
// `ListAccounts`, and the password MUST NEVER appear in the latter.
message AccountConfig {
// Unique identifier for the account configuration.
string id = 1;
// User-friendly display name (e.g. "Work Mail").
string name = 2;
// Primary email address associated with the account.
string email_address = 3;
// Protocol engine type.
AccountProtocol protocol = 4;
// Server hostname or IP address.
string server_host = 5;
// Server port number (1..=65535).
uint32 server_port = 6;
// Whether TLS connection encryption is enabled.
bool use_tls = 7;
// IMAP connection security transport mode.
TlsMode imap_tls_mode = 8;
// SMTP connection security transport mode.
TlsMode smtp_tls_mode = 9;
// Keyring entry key used to retrieve OS vault credentials.
string keyring_secret_key = 10;
// Background sync polling interval in seconds (minimum 10s).
uint64 sync_interval_secs = 11;
}
// AddAccountRequest carries the full account configuration plus the raw
// password credential. `password` is written ONLY to the OS keyring vault
// (under `config.keyring_secret_key`) by the daemon -- it is never persisted
// to SQLite, never logged, and never echoed back in `AddAccountResponse`.
message AddAccountRequest {
AccountConfig config = 1;
string password = 2;
}
// AddAccountResponse deliberately carries only the persisted account's id,
// never the password nor the full config (callers already have both).
message AddAccountResponse {
string id = 1;
}
// ListAccountsRequest currently carries no parameters.
message ListAccountsRequest {}
// ListAccountsResponse. `accounts` MUST NEVER contain password credentials
// -- see `AccountConfig`'s doc comment.
message ListAccountsResponse {
repeated AccountConfig accounts = 1;
}
+57 -11
View File
@@ -9,6 +9,7 @@
//! implementation of the client-side authentication handshake with the
//! server-side interceptor in `nunciod::grpc`.
use crate::v1::accounts_client::AccountsClient;
use crate::v1::system_client::SystemClient;
use crate::v1::{Event, SubscribeRequest};
use thiserror::Error;
@@ -83,35 +84,63 @@ impl tonic::service::Interceptor for BearerTokenInterceptor {
pub type AuthenticatedSystemClient =
SystemClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// Dials the `nuncio.v1.System` gRPC endpoint at `addr` (a `host:port` pair,
/// e.g. `127.0.0.1:9420`) and returns a client that injects
/// `authorization: Bearer <token>` metadata on every call.
/// The authenticated `nuncio.v1.Accounts` client type returned by
/// [`connect_accounts`].
pub type AuthenticatedAccountsClient =
AccountsClient<InterceptedService<Channel, BearerTokenInterceptor>>;
/// Dials `addr` (a `host:port` pair, e.g. `127.0.0.1:9420`) over plain HTTP
/// (the loopback gRPC transport is never TLS-wrapped; auth is via bearer
/// token instead) and returns the connected [`Channel`], shared by every
/// per-service `connect_*` helper in this module so they agree on identical
/// endpoint-parsing and connection-error handling.
///
/// `addr` is expected to be a loopback address; this helper does not
/// enforce that itself, matching the daemon's own loopback-only bind
/// contract (see `nunciod::grpc::serve`).
pub async fn connect_system(
addr: &str,
token: &str,
) -> Result<AuthenticatedSystemClient, ConnectError> {
let interceptor = BearerTokenInterceptor::new(token)?;
async fn dial(addr: &str) -> Result<Channel, ConnectError> {
let uri = format!("http://{addr}");
let endpoint = Endpoint::from_shared(uri).map_err(|source| ConnectError::InvalidEndpoint {
addr: addr.to_string(),
source,
})?;
let channel = endpoint
endpoint
.connect()
.await
.map_err(|source| ConnectError::Transport {
addr: addr.to_string(),
source,
})?;
})
}
/// Dials the `nuncio.v1.System` gRPC endpoint at `addr` and returns a client
/// that injects `authorization: Bearer <token>` metadata on every call.
pub async fn connect_system(
addr: &str,
token: &str,
) -> Result<AuthenticatedSystemClient, ConnectError> {
let interceptor = BearerTokenInterceptor::new(token)?;
let channel = dial(addr).await?;
Ok(SystemClient::with_interceptor(channel, interceptor))
}
/// Dials the `nuncio.v1.Accounts` gRPC endpoint at `addr` and returns a
/// client that injects `authorization: Bearer <token>` metadata on every
/// call (backlog stories 1.C.1 / 1.C.2, GH #156 / GH #157).
///
/// `Accounts` is guarded by the exact same `BearerAuthInterceptor` as
/// `System` on the server side (see `nunciod::grpc::serve_on_listener`,
/// GH #165), so this shares [`BearerTokenInterceptor`] and [`dial`] with
/// [`connect_system`] rather than hand-rolling a second auth handshake.
pub async fn connect_accounts(
addr: &str,
token: &str,
) -> Result<AuthenticatedAccountsClient, ConnectError> {
let interceptor = BearerTokenInterceptor::new(token)?;
let channel = dial(addr).await?;
Ok(AccountsClient::with_interceptor(channel, interceptor))
}
/// Opens the `nuncio.v1.System/Subscribe` server-streaming RPC on an already
/// authenticated client (as returned by [`connect_system`]), reusing the
/// bearer-token interceptor already attached to `client` rather than
@@ -172,6 +201,23 @@ mod tests {
assert!(matches!(err, ConnectError::InvalidToken(_)));
}
#[tokio::test]
async fn connect_accounts_fails_closed_on_invalid_token() {
let err = connect_accounts("127.0.0.1:0", "tok\ntoken")
.await
.expect_err("invalid token must fail before dialing");
assert!(matches!(err, ConnectError::InvalidToken(_)));
}
#[tokio::test]
async fn connect_accounts_reports_transport_error_when_daemon_unreachable() {
let err = connect_accounts("127.0.0.1:1", "abc123")
.await
.expect_err("connecting to an unreachable daemon must fail");
assert!(matches!(err, ConnectError::Transport { .. }));
assert!(err.to_string().contains("127.0.0.1:1"));
}
#[tokio::test]
async fn connect_system_reports_transport_error_when_daemon_unreachable() {
// Port 0 alone is not connectable; using an explicit closed port on
+1
View File
@@ -34,3 +34,4 @@ hex.workspace = true
[dev-dependencies]
nuncio-cli = { path = "../nuncio-cli" }
sqlx.workspace = true
+506 -16
View File
@@ -8,13 +8,18 @@
//! transport is out of scope here and lands in a later story.
use nuncio_core::{CoreEvent, EventBus};
use nuncio_proto::v1::accounts_server::{Accounts, AccountsServer};
use nuncio_proto::v1::event::Kind;
use nuncio_proto::v1::system_server::{System, SystemServer};
use nuncio_proto::v1::{
BatchFilterProgress, DatabaseRecovered, Event, EventError, FilterExecuted, GetStatusRequest,
GetStatusResponse, MessageFlagsChanged, ShuttingDown, SubscribeRequest, SyncCompleted,
SyncStarted, UpdateAvailable,
AccountConfig as AccountConfigProto, AccountProtocol as AccountProtocolProto,
AddAccountRequest, AddAccountResponse, BatchFilterProgress, DatabaseRecovered, Event,
EventError, FilterExecuted, GetStatusRequest, GetStatusResponse, ListAccountsRequest,
ListAccountsResponse, MessageFlagsChanged, ShuttingDown, SubscribeRequest, SyncCompleted,
SyncStarted, TlsMode as TlsModeProto, UpdateAvailable,
};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::SecretManager;
use std::pin::Pin;
use std::sync::Arc;
use subtle::ConstantTimeEq;
@@ -170,6 +175,167 @@ impl System for SystemGrpcService {
}
}
/// Maps a `nuncio_core::AccountProtocol` onto its wire-format
/// `nuncio.v1.AccountProtocol` enum value.
fn map_account_protocol_to_proto(protocol: nuncio_core::AccountProtocol) -> AccountProtocolProto {
match protocol {
nuncio_core::AccountProtocol::Jmap => AccountProtocolProto::Jmap,
nuncio_core::AccountProtocol::ImapSmtp => AccountProtocolProto::ImapSmtp,
}
}
/// Maps a wire-format `nuncio.v1.AccountProtocol` enum value back onto
/// `nuncio_core::AccountProtocol`. `Unspecified` is treated as an invalid
/// request rather than silently defaulting to a protocol the caller never
/// asked for.
fn map_account_protocol_from_proto(
protocol: AccountProtocolProto,
) -> Result<nuncio_core::AccountProtocol, Status> {
match protocol {
AccountProtocolProto::Jmap => Ok(nuncio_core::AccountProtocol::Jmap),
AccountProtocolProto::ImapSmtp => Ok(nuncio_core::AccountProtocol::ImapSmtp),
AccountProtocolProto::Unspecified => {
Err(Status::invalid_argument("account protocol is required"))
}
}
}
/// Maps a `nuncio_core::TlsMode` onto its wire-format `nuncio.v1.TlsMode`
/// enum value.
fn map_tls_mode_to_proto(mode: nuncio_core::TlsMode) -> TlsModeProto {
match mode {
nuncio_core::TlsMode::ImplicitTls => TlsModeProto::ImplicitTls,
nuncio_core::TlsMode::StartTls => TlsModeProto::StartTls,
nuncio_core::TlsMode::Plain => TlsModeProto::Plain,
}
}
/// Maps a wire-format `nuncio.v1.TlsMode` enum value back onto
/// `nuncio_core::TlsMode`. `Unspecified` is rejected rather than silently
/// defaulting.
fn map_tls_mode_from_proto(mode: TlsModeProto) -> Result<nuncio_core::TlsMode, Status> {
match mode {
TlsModeProto::ImplicitTls => Ok(nuncio_core::TlsMode::ImplicitTls),
TlsModeProto::StartTls => Ok(nuncio_core::TlsMode::StartTls),
TlsModeProto::Plain => Ok(nuncio_core::TlsMode::Plain),
TlsModeProto::Unspecified => Err(Status::invalid_argument("tls mode is required")),
}
}
/// Maps a `nuncio_core::AccountConfig` onto its wire-format
/// `nuncio.v1.AccountConfig` representation for `ListAccounts` responses.
/// The password credential is NEVER part of `AccountConfig` (it lives only
/// in the OS keyring, keyed by `keyring_secret_key`), so there is nothing to
/// scrub here -- there is simply no field to carry it.
fn map_account_config_to_proto(config: nuncio_core::AccountConfig) -> AccountConfigProto {
AccountConfigProto {
id: config.id,
name: config.name,
email_address: config.email_address,
protocol: map_account_protocol_to_proto(config.protocol).into(),
server_host: config.server_host,
server_port: u32::from(config.server_port),
use_tls: config.use_tls,
imap_tls_mode: map_tls_mode_to_proto(config.imap_tls_mode).into(),
smtp_tls_mode: map_tls_mode_to_proto(config.smtp_tls_mode).into(),
keyring_secret_key: config.keyring_secret_key,
sync_interval_secs: config.sync_interval_secs,
}
}
/// Maps a wire-format `nuncio.v1.AccountConfig` request payload back onto
/// `nuncio_core::AccountConfig`. Rejects a `server_port` outside `1..=65535`
/// with `Status::invalid_argument` rather than silently truncating it.
fn map_account_config_from_proto(
config: AccountConfigProto,
) -> Result<nuncio_core::AccountConfig, Status> {
let protocol = map_account_protocol_from_proto(config.protocol())?;
let imap_tls_mode = map_tls_mode_from_proto(config.imap_tls_mode())?;
let smtp_tls_mode = map_tls_mode_from_proto(config.smtp_tls_mode())?;
let server_port = u16::try_from(config.server_port)
.map_err(|_| Status::invalid_argument("server_port must be in range 1..=65535"))?;
Ok(nuncio_core::AccountConfig {
id: config.id,
name: config.name,
email_address: config.email_address,
protocol,
server_host: config.server_host,
server_port,
use_tls: config.use_tls,
imap_tls_mode,
smtp_tls_mode,
keyring_secret_key: config.keyring_secret_key,
sync_interval_secs: config.sync_interval_secs,
})
}
/// `nuncio.v1.Accounts` gRPC service implementation backed by the daemon's
/// live [`DatabaseEngine`] and [`SecretManager`] vault (backlog stories
/// 1.C.1 / 1.C.2, GH #156 / GH #157).
///
/// Persists [`nuncio_core::AccountConfig`] rows via `DatabaseEngine::save_account`
/// and routes the password credential exclusively to the OS keyring vault via
/// `SecretManager::set_secret` -- the password is never written to SQLite,
/// never logged, and never echoed back in any RPC response.
struct AccountsGrpcService {
db: Arc<DatabaseEngine>,
secrets: Arc<SecretManager>,
}
#[tonic::async_trait]
impl Accounts for AccountsGrpcService {
async fn add_account(
&self,
request: Request<AddAccountRequest>,
) -> Result<Response<AddAccountResponse>, Status> {
let req = request.into_inner();
let proto_config = req
.config
.ok_or_else(|| Status::invalid_argument("config is required"))?;
if req.password.is_empty() {
return Err(Status::invalid_argument("password is required"));
}
let config = map_account_config_from_proto(proto_config)?;
config
.validate()
.map_err(|e| Status::invalid_argument(e.to_string()))?;
// Store the password credential in the OS keyring FIRST, keyed by
// this account's `keyring_secret_key` -- it is never written to
// SQLite. If this fails, the account row below is deliberately
// never saved, so a failed credential write can never leave behind
// an account config with no retrievable password.
self.secrets
.set_secret(&config.keyring_secret_key, &req.password)
.map_err(|e| Status::internal(format!("failed to store credential in vault: {e}")))?;
self.db
.save_account(&config)
.await
.map_err(|e| Status::internal(format!("failed to persist account: {e}")))?;
Ok(Response::new(AddAccountResponse { id: config.id }))
}
async fn list_accounts(
&self,
_request: Request<ListAccountsRequest>,
) -> Result<Response<ListAccountsResponse>, Status> {
let accounts = self
.db
.list_accounts()
.await
.map_err(|e| Status::internal(format!("failed to list accounts: {e}")))?
.into_iter()
.map(map_account_config_to_proto)
.collect();
Ok(Response::new(ListAccountsResponse { accounts }))
}
}
/// Bearer-token authentication interceptor for the loopback `nuncio.v1` gRPC
/// server.
///
@@ -217,8 +383,8 @@ impl tonic::service::Interceptor for BearerAuthInterceptor {
}
/// Binds a loopback TCP listener at `addr` and serves the `nuncio.v1.System`
/// gRPC service on it, authenticated by `token`, until the transport server
/// errors.
/// and `nuncio.v1.Accounts` gRPC services on it, both authenticated by
/// `token`, until the transport server errors.
///
/// `addr` MUST be a loopback address (e.g. `127.0.0.1:PORT`); callers are
/// responsible for passing loopback-only addresses (see
@@ -226,6 +392,8 @@ impl tonic::service::Interceptor for BearerAuthInterceptor {
pub async fn serve(
addr: &str,
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
secrets: Arc<SecretManager>,
token: impl Into<Arc<str>>,
) -> Result<(), GrpcServeError> {
let listener = TcpListener::bind(addr)
@@ -234,11 +402,21 @@ pub async fn serve(
addr: addr.to_string(),
source,
})?;
serve_on_listener(listener, event_bus, token).await
serve_on_listener(listener, event_bus, db, secrets, token).await
}
/// Serves the `nuncio.v1.System` gRPC service on an already-bound
/// [`TcpListener`].
/// Serves the `nuncio.v1.System` and `nuncio.v1.Accounts` gRPC services on
/// an already-bound [`TcpListener`].
///
/// # Security (GH #165)
///
/// EVERY service mounted on this server MUST be wrapped in its own
/// [`BearerAuthInterceptor`] via `*Server::with_interceptor`, exactly like
/// `System` and `Accounts` below -- never `add_service(SomeServer::new(...))`
/// unwrapped. This is a hard, non-negotiable invariant: an un-intercepted
/// service mounted here would be reachable by any local process without
/// authentication. If a future story adds another service, mount it the
/// same way.
///
/// Exposed separately from [`serve`] so tests can bind an ephemeral loopback
/// port (`127.0.0.1:0`), read back the OS-assigned port via
@@ -248,14 +426,23 @@ pub async fn serve(
pub async fn serve_on_listener(
listener: TcpListener,
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
secrets: Arc<SecretManager>,
token: impl Into<Arc<str>>,
) -> Result<(), GrpcServeError> {
let service = SystemGrpcService { event_bus };
let interceptor = BearerAuthInterceptor::new(token);
let svc = SystemServer::with_interceptor(service, interceptor);
let token: Arc<str> = token.into();
let system_service = SystemGrpcService { event_bus };
let system_interceptor = BearerAuthInterceptor::new(token.clone());
let system_svc = SystemServer::with_interceptor(system_service, system_interceptor);
let accounts_service = AccountsGrpcService { db, secrets };
let accounts_interceptor = BearerAuthInterceptor::new(token);
let accounts_svc = AccountsServer::with_interceptor(accounts_service, accounts_interceptor);
Server::builder()
.add_service(svc)
.add_service(system_svc)
.add_service(accounts_svc)
.serve_with_incoming(TcpListenerStream::new(listener))
.await
.map_err(GrpcServeError::Transport)
@@ -265,12 +452,35 @@ pub async fn serve_on_listener(
mod tests {
use super::*;
use nuncio_core::{CoreCommand, EngineStatus};
use nuncio_proto::v1::accounts_client::AccountsClient;
use nuncio_proto::v1::system_client::SystemClient;
use tonic::Code;
/// Spawns a test server backed by a fresh ephemeral database and a
/// fresh [`SecretManager::mock`] vault -- the right default for every
/// test that only exercises `System` or doesn't care about pre-existing
/// account/keyring state. Tests that DO care (persistence-across-restart,
/// credential-secrecy) build their own `db`/`secrets` and call
/// [`spawn_test_server_with`] directly instead.
async fn spawn_test_server(
event_bus: Arc<EventBus>,
token: &str,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let secrets = Arc::new(SecretManager::mock());
spawn_test_server_with(event_bus, Arc::new(db), secrets, token).await
}
/// Spawns a test server on an ephemeral loopback port backed by the
/// given `db` and `secrets`, so tests can share (and re-open) the same
/// database path / mock keyring state across multiple server instances.
async fn spawn_test_server_with(
event_bus: Arc<EventBus>,
db: Arc<DatabaseEngine>,
secrets: Arc<SecretManager>,
token: &str,
) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0")
.await
@@ -278,7 +488,7 @@ mod tests {
let addr = listener.local_addr().expect("listener has local addr");
let token = token.to_string();
let handle = tokio::spawn(async move {
let _ = serve_on_listener(listener, event_bus, token).await;
let _ = serve_on_listener(listener, event_bus, db, secrets, token).await;
});
(addr, handle)
}
@@ -400,8 +610,12 @@ mod tests {
// `spawn_test_server` helper above which pre-binds the listener
// itself and calls `serve_on_listener` directly.
let event_bus = Arc::new(EventBus::new());
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let secrets = Arc::new(SecretManager::mock());
let _handle = tokio::spawn(async move {
let _ = serve("127.0.0.1:0", event_bus, "token").await;
let _ = serve("127.0.0.1:0", event_bus, Arc::new(db), secrets, "token").await;
});
// Yield so the spawned task is polled at least once and actually
// reaches the bind + delegate-to-`serve_on_listener` call before
@@ -412,9 +626,19 @@ mod tests {
#[tokio::test]
async fn serve_fails_closed_when_bind_address_is_invalid() {
let event_bus = Arc::new(EventBus::new());
let err = serve("not-a-valid-addr", event_bus, "token")
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect_err("invalid bind address must fail");
.expect("connect ephemeral test db");
let secrets = Arc::new(SecretManager::mock());
let err = serve(
"not-a-valid-addr",
event_bus,
Arc::new(db),
secrets,
"token",
)
.await
.expect_err("invalid bind address must fail");
assert!(matches!(err, GrpcServeError::Bind { .. }));
}
@@ -568,4 +792,270 @@ mod tests {
}
}
}
fn sample_account_config_proto(id: &str, keyring_secret_key: &str) -> AccountConfigProto {
AccountConfigProto {
id: id.to_string(),
name: "Restart Test Account".to_string(),
email_address: format!("{id}@nuncio.mx"),
protocol: AccountProtocolProto::ImapSmtp.into(),
server_host: "imap.nuncio.mx".to_string(),
server_port: 993,
use_tls: true,
imap_tls_mode: TlsModeProto::ImplicitTls.into(),
smtp_tls_mode: TlsModeProto::ImplicitTls.into(),
keyring_secret_key: keyring_secret_key.to_string(),
sync_interval_secs: 60,
}
}
#[tokio::test]
async fn add_account_rejects_missing_bearer_token() {
// Confirms `Accounts` is mounted behind its own `BearerAuthInterceptor`
// exactly like `System` (GH #165: no un-intercepted service).
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = AccountsClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.add_account(AddAccountRequest {
config: Some(sample_account_config_proto(
"acct-noauth",
"nuncio/acct-noauth",
)),
password: "irrelevant".to_string(),
})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}
#[tokio::test]
async fn list_accounts_rejects_missing_bearer_token() {
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = AccountsClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.list_accounts(ListAccountsRequest {})
.await
.expect_err("missing bearer token must be rejected");
assert_eq!(err.code(), Code::Unauthenticated);
}
#[tokio::test]
async fn add_account_rejects_empty_password() {
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = AccountsClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.add_account(authed_bearer_request(AddAccountRequest {
config: Some(sample_account_config_proto("acct-nopw", "nuncio/acct-nopw")),
password: String::new(),
}))
.await
.expect_err("empty password must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
#[tokio::test]
async fn add_account_rejects_missing_config() {
let event_bus = Arc::new(EventBus::new());
let (addr, _handle) = spawn_test_server(event_bus, "correct-token").await;
let mut client = AccountsClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let err = client
.add_account(authed_bearer_request(AddAccountRequest {
config: None,
password: "some-password".to_string(),
}))
.await
.expect_err("missing config must be rejected");
assert_eq!(err.code(), Code::InvalidArgument);
}
/// Backlog story 1.C.1 (GH #156): proves an account added through the
/// daemon persists across a daemon restart. Simulates the restart by
/// dropping the first `DatabaseEngine`/server pair and opening a brand
/// new one at the SAME on-disk database path (a real persistent temp
/// file, not `connect_ephemeral`'s throwaway one), then calling
/// `ListAccounts` against the second server instance.
#[tokio::test]
async fn add_account_persists_across_a_simulated_daemon_restart() {
let dir = tempfile::tempdir().expect("create persistent temp dir");
let db_path = dir.path().join("accounts_restart_test.db");
let secrets = Arc::new(SecretManager::mock());
// "First daemon run".
let db_first_run = Arc::new(
DatabaseEngine::connect_file(&db_path, &secrets)
.await
.expect("open db on first run"),
);
let (addr, _handle) = spawn_test_server_with(
Arc::new(EventBus::new()),
db_first_run.clone(),
secrets.clone(),
"correct-token",
)
.await;
let mut client = AccountsClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
let add_response = client
.add_account(authed_bearer_request(AddAccountRequest {
config: Some(sample_account_config_proto(
"acct-restart-1",
"nuncio/acct-restart-1",
)),
password: "restart-secret-pw".to_string(),
}))
.await
.expect("add_account succeeds")
.into_inner();
assert_eq!(add_response.id, "acct-restart-1");
db_first_run.close().await;
// "Second daemon run": a brand new `DatabaseEngine`, opened at the
// exact same `db_path`, sharing the SAME mock `SecretManager` (so
// the previously-provisioned keyring entry is still readable, just
// like a real OS keyring survives a daemon restart).
let db_second_run = Arc::new(
DatabaseEngine::connect_file(&db_path, &secrets)
.await
.expect("re-open db on second run"),
);
let (addr2, _handle2) = spawn_test_server_with(
Arc::new(EventBus::new()),
db_second_run,
secrets.clone(),
"correct-token",
)
.await;
let mut client2 = AccountsClient::connect(format!("http://{addr2}"))
.await
.expect("client connects");
let list_response = client2
.list_accounts(authed_bearer_request(ListAccountsRequest {}))
.await
.expect("list_accounts succeeds")
.into_inner();
assert_eq!(list_response.accounts.len(), 1);
let restored = &list_response.accounts[0];
assert_eq!(restored.id, "acct-restart-1");
assert_eq!(restored.email_address, "acct-restart-1@nuncio.mx");
assert_eq!(restored.keyring_secret_key, "nuncio/acct-restart-1");
// The credential itself must have survived too, in the (mock)
// keyring -- never in the database.
let stored_password = secrets
.get_secret("nuncio/acct-restart-1")
.expect("password still retrievable from vault after restart");
assert_eq!(stored_password, "restart-secret-pw");
}
/// Backlog story 1.C.2 (GH #157): proves the password credential is
/// retrievable from the (mock) keyring, but never appears in any
/// `accounts` SQLite column, and never appears in the `ListAccounts`
/// wire response.
#[tokio::test]
async fn add_account_password_never_touches_sqlite_or_the_list_response() {
const PASSWORD: &str = "super-secret-credential-42";
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let db = Arc::new(db);
let secrets = Arc::new(SecretManager::mock());
let (addr, _handle) = spawn_test_server_with(
Arc::new(EventBus::new()),
db.clone(),
secrets.clone(),
"correct-token",
)
.await;
let mut client = AccountsClient::connect(format!("http://{addr}"))
.await
.expect("client connects");
client
.add_account(authed_bearer_request(AddAccountRequest {
config: Some(sample_account_config_proto(
"acct-secrecy-1",
"nuncio/acct-secrecy-1",
)),
password: PASSWORD.to_string(),
}))
.await
.expect("add_account succeeds");
// The password IS retrievable from the (mock) keyring vault, keyed
// by this account's `keyring_secret_key`.
let stored_password = secrets
.get_secret("nuncio/acct-secrecy-1")
.expect("password stored in vault");
assert_eq!(stored_password, PASSWORD);
// The password is NEVER present in any `accounts` table column.
#[allow(clippy::type_complexity)]
let rows: Vec<(
String,
String,
String,
String,
String,
i64,
i64,
String,
i64,
)> = sqlx::query_as(
"SELECT id, name, email_address, protocol, server_host, server_port, use_tls, \
keyring_secret_key, sync_interval_secs FROM accounts",
)
.fetch_all(db.pool())
.await
.expect("query accounts table directly");
assert_eq!(rows.len(), 1);
let (id, name, email_address, protocol, server_host, _port, _tls, keyring_key, _interval) =
&rows[0];
for text_column in [
id.as_str(),
name.as_str(),
email_address.as_str(),
protocol.as_str(),
server_host.as_str(),
keyring_key.as_str(),
] {
assert!(
!text_column.contains(PASSWORD),
"password leaked into an accounts DB column: {text_column:?}"
);
}
// The password is NEVER present in the `ListAccounts` response.
let list_response = client
.list_accounts(authed_bearer_request(ListAccountsRequest {}))
.await
.expect("list_accounts succeeds")
.into_inner();
assert_eq!(list_response.accounts.len(), 1);
let response_debug = format!("{list_response:?}");
assert!(
!response_debug.contains(PASSWORD),
"password leaked into the ListAccounts response: {response_debug}"
);
}
}
+51
View File
@@ -6,6 +6,30 @@ pub mod grpc;
pub mod orchestrator;
pub use orchestrator::SelfHealingSyncOrchestrator;
/// Default PERSISTENT database path for `nunciod`: `~/.nuncio/nuncio.db`
/// (or `%USERPROFILE%\.nuncio\nuncio.db` on Windows, since `USERPROFILE` is
/// checked first). This is deliberately NOT a temp/ephemeral path -- it is
/// what makes accounts (backlog stories 1.C.1 / 1.C.2, GH #156 / GH #157)
/// and every other piece of daemon state survive a daemon restart.
///
/// Mirrors `nuncio_store::recovery::CorruptedBackupManager::default_backup_dir`'s
/// existing `~/.nuncio/...` convention so all of `nunciod`'s on-disk state
/// lives under the same root directory.
///
/// Callers that need a different (e.g. isolated, ephemeral) path -- tests,
/// CI, multiple daemon instances on one machine -- MUST set the
/// `NUNCIO_DB_PATH` environment variable instead of calling this directly;
/// see `main`'s boot sequence.
pub fn default_db_path() -> std::path::PathBuf {
if let Ok(home_str) = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")) {
std::path::PathBuf::from(home_str)
.join(".nuncio")
.join("nuncio.db")
} else {
std::path::PathBuf::from(".nuncio").join("nuncio.db")
}
}
/// Environment variable that opts `nunciod` in to its autonomous
/// background auto-update-check loop.
///
@@ -62,4 +86,31 @@ mod tests {
std::env::remove_var(AUTO_UPDATE_ENV_VAR);
assert!(!auto_update_task_enabled());
}
#[test]
fn default_db_path_is_persistent_under_dot_nuncio_directory() {
// Deliberately does not mutate `USERPROFILE`/`HOME` (real env vars
// many other things -- `tempfile`, `keyring`, etc. -- may also
// read); instead this asserts the path SHAPE holds regardless of
// which of the two branches the current process environment takes,
// proving the default is a stable `.nuncio/nuncio.db` layout and
// never a bare OS temp directory.
let path = default_db_path();
assert_eq!(
path.file_name().and_then(|n| n.to_str()),
Some("nuncio.db"),
"default db path must end in nuncio.db, got {path:?}"
);
assert_eq!(
path.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str()),
Some(".nuncio"),
"default db path must live under a .nuncio directory, got {path:?}"
);
assert!(
!path.starts_with(std::env::temp_dir()),
"default db path must never be an ephemeral temp path, got {path:?}"
);
}
}
+25 -6
View File
@@ -15,9 +15,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
tracing::info!("Starting Nuncio Central Daemon Service (nunciod)...");
let event_bus = Arc::new(EventBus::new());
// PERSISTENT database path (backlog stories 1.C.1 / 1.C.2, GH #156 /
// GH #157): defaults to `~/.nuncio/nuncio.db` -- NOT a temp/ephemeral
// path -- so accounts (and everything else) survive a daemon restart.
// `NUNCIO_DB_PATH` overrides it, which tests/CI use to point at an
// isolated temp path instead of touching a real user's home directory.
let db_path = std::env::var("NUNCIO_DB_PATH")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::env::temp_dir().join("nuncio_main.db"));
.unwrap_or_else(|_| nunciod::default_db_path());
let orchestrator = nunciod::SelfHealingSyncOrchestrator::new(&db_path, event_bus.clone());
let (db, _summary) = orchestrator.initialize_and_recover().await?;
@@ -278,26 +283,40 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
})
});
// gRPC `nuncio.v1.System` server (backlog story 1.A.2 / GH-149).
// gRPC `nuncio.v1.System` + `nuncio.v1.Accounts` server (backlog
// stories 1.A.2 / GH-149 and 1.C.1 + 1.C.2 / GH-156 + GH-157).
//
// Runs ALONGSIDE the existing JSON-RPC IPC server below; the migration
// off the hand-rolled JSON-RPC transport happens in later stories. The
// bearer token is minted (or loaded, on subsequent runs) from the real
// OS keyring vault via `SecretManager::production()`, fails closed if
// the keyring is unavailable, and is never logged.
let grpc_secrets = nuncio_store::vault::SecretManager::production();
// the keyring is unavailable, and is never logged. `Accounts` reuses
// this SAME `SecretManager` to write account password credentials to
// the OS keyring (never to SQLite) -- see `nunciod::grpc`'s security
// comment (GH #165) on why EVERY mounted service shares this one
// `BearerAuthInterceptor`.
let grpc_secrets = Arc::new(nuncio_store::vault::SecretManager::production());
let grpc_token_bytes = grpc_secrets
.get_or_create_key_bytes(nuncio_store::vault::GRPC_TOKEN_ACCOUNT, 32)
.map_err(|e| format!("failed to provision gRPC bearer token from vault: {e}"))?;
let grpc_token = hex::encode(grpc_token_bytes);
let grpc_addr = nunciod::grpc::grpc_addr_from_env();
tracing::info!(
"nunciod gRPC (nuncio.v1.System) starting on {} (loopback only)",
"nunciod gRPC (nuncio.v1.System, nuncio.v1.Accounts) starting on {} (loopback only)",
grpc_addr
);
let grpc_event_bus = event_bus.clone();
let grpc_db = db.clone();
let _grpc_task = tokio::spawn(async move {
if let Err(e) = nunciod::grpc::serve(&grpc_addr, grpc_event_bus, grpc_token).await {
if let Err(e) = nunciod::grpc::serve(
&grpc_addr,
grpc_event_bus,
grpc_db,
grpc_secrets,
grpc_token,
)
.await
{
tracing::error!("nunciod gRPC server failed: {}", e);
}
});
+13 -1
View File
@@ -14,6 +14,7 @@
use nuncio_cli::{Commands, HeadlessRunner, MailSubcommand, SystemSubcommand};
use nuncio_core::ipc::{IpcClient, IpcDaemonServer};
use nuncio_core::{CoreCommand, EventBus};
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use std::sync::Arc;
use tokio::net::TcpListener;
@@ -143,8 +144,19 @@ async fn cli_system_status_round_trips_over_grpc_to_live_daemon() {
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
let server_event_bus = event_bus.clone();
let (server_db, _server_db_dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let server_secrets = secrets.clone();
let _server_handle = tokio::spawn(async move {
let _ = nunciod::grpc::serve_on_listener(listener, server_event_bus, token).await;
let _ = nunciod::grpc::serve_on_listener(
listener,
server_event_bus,
Arc::new(server_db),
server_secrets,
token,
)
.await;
});
// 4. Drive the real `nuncio-cli` `HeadlessRunner`'s gRPC `system status`
+10 -1
View File
@@ -16,6 +16,7 @@
use nuncio_core::{CoreCommand, EngineStatus, EventBus};
use nuncio_proto::v1::system_client::SystemClient;
use nuncio_proto::v1::GetStatusRequest;
use nuncio_store::db::DatabaseEngine;
use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT};
use nunciod::grpc::serve_on_listener;
use std::sync::Arc;
@@ -26,13 +27,21 @@ use tonic::{Code, Request};
/// Because `TcpListener::bind` has already succeeded (the OS is accepting
/// connections on the returned port) before this function returns, callers
/// can dial `addr` immediately with no fixed sleep.
///
/// Backs the mounted `nuncio.v1.Accounts` service with its own fresh
/// ephemeral database + mock keyring, since this test file only exercises
/// `System` and doesn't care about account/keyring state.
async fn start_server(event_bus: Arc<EventBus>, token: String) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind ephemeral loopback port");
let addr = listener.local_addr().expect("listener has local addr");
let (db, _dir) = DatabaseEngine::connect_ephemeral()
.await
.expect("connect ephemeral test db");
let secrets = Arc::new(SecretManager::mock());
tokio::spawn(async move {
let _ = serve_on_listener(listener, event_bus, token).await;
let _ = serve_on_listener(listener, event_bus, Arc::new(db), secrets, token).await;
});
addr
}