diff --git a/crates/nuncio-cal/src/caldav.rs b/crates/nuncio-cal/src/caldav.rs index 055334e..3c5ee2a 100644 --- a/crates/nuncio-cal/src/caldav.rs +++ b/crates/nuncio-cal/src/caldav.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; use nuncio_core::model::CalendarEvent; +use nuncio_core::redact::Redacted; use serde::{Deserialize, Serialize}; use tracing::{debug, info, instrument, warn}; @@ -14,7 +15,7 @@ use crate::parser::{CalendarError, IcalParserAdapter}; /// `caldav_url` must already resolve to a specific calendar collection (e.g. /// `https://caldav.example.com/dav/calendars/user/jmaes/work/`) -- PROPFIND-based /// `calendar-home-set` auto-discovery is out of scope for this client. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct CalDavAccountConfig { /// Nuncio account identifier this calendar collection belongs to. pub account_id: String, @@ -23,22 +24,10 @@ pub struct CalDavAccountConfig { /// Basic-auth username (or app-specific username, per provider). pub username: String, /// Basic-auth secret (password or app-specific token) resolved from the OS keyring by the - /// caller -- never stored anywhere else in plaintext. - pub auth_token: String, -} - -impl std::fmt::Debug for CalDavAccountConfig { - /// Manual `Debug` impl that redacts `auth_token` -- a derived impl would print the raw - /// secret verbatim the moment anything (a `debug!` log, an assertion failure message, - /// a panic payload) formats this config with `{:?}`. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CalDavAccountConfig") - .field("account_id", &self.account_id) - .field("caldav_url", &self.caldav_url) - .field("username", &self.username) - .field("auth_token", &"") - .finish() - } + /// caller -- never stored anywhere else in plaintext. Wrapped in [`Redacted`] so a derived + /// `Debug`, a `Display`, or `serde` can never disclose it to a log; the raw value is + /// reachable only through an explicit `expose_secret` call. + pub auth_token: Redacted, } /// Best-effort extraction of the `UID` property from a raw (possibly malformed) VEVENT/ @@ -202,7 +191,10 @@ impl CalDavClient { .request(report_method, &self.config.caldav_url) .header("Content-Type", "application/xml; charset=utf-8") .header("Depth", "1") - .basic_auth(&self.config.username, Some(&self.config.auth_token)) + .basic_auth( + &self.config.username, + Some(self.config.auth_token.expose_secret()), + ) .body(body) .send() .await @@ -258,7 +250,7 @@ mod tests { account_id: "acct-1".to_string(), caldav_url: "https://caldav.example.com/calendars/work/".to_string(), username: "jmaes".to_string(), - auth_token: "app-token-secret".to_string(), + auth_token: Redacted::new("app-token-secret".to_string()), } } diff --git a/crates/nuncio-cal/tests/wiremock_caldav_test.rs b/crates/nuncio-cal/tests/wiremock_caldav_test.rs index d8df94b..d9023c3 100644 --- a/crates/nuncio-cal/tests/wiremock_caldav_test.rs +++ b/crates/nuncio-cal/tests/wiremock_caldav_test.rs @@ -60,7 +60,7 @@ async fn wiremock_caldav_report_fetches_and_resolves_tzid_events() { account_id: "acct-wm-1".to_string(), caldav_url: format!("{}/calendars/work/", mock_server.uri()), username: "james.maes".to_string(), - auth_token: "wiremock-app-token".to_string(), + auth_token: "wiremock-app-token".to_string().into(), }; let client = CalDavClient::new(config); @@ -106,7 +106,7 @@ async fn wiremock_caldav_report_server_error_surfaces_as_transport_failure() { account_id: "acct-wm-2".to_string(), caldav_url: format!("{}/calendars/broken/", mock_server.uri()), username: "james.maes".to_string(), - auth_token: "wiremock-app-token".to_string(), + auth_token: "wiremock-app-token".to_string().into(), }; let client = CalDavClient::new(config); diff --git a/crates/nuncio-contacts/src/carddav.rs b/crates/nuncio-contacts/src/carddav.rs index 3d6c7a1..23ddfd1 100644 --- a/crates/nuncio-contacts/src/carddav.rs +++ b/crates/nuncio-contacts/src/carddav.rs @@ -1,6 +1,7 @@ //! CardDAV (RFC 6352) `REPORT` client and multistatus response parser. use async_trait::async_trait; +use nuncio_core::redact::Redacted; use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{debug, info, instrument}; @@ -28,7 +29,7 @@ pub enum CardDavError { /// `carddav_url` must already resolve to a specific address book collection (e.g. /// `https://carddav.example.com/dav/addressbooks/user/jmaes/contacts/`) -- PROPFIND-based /// `addressbook-home-set` auto-discovery is out of scope for this client. -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct CardDavAccountConfig { /// Nuncio account identifier this address book collection belongs to. pub account_id: String, @@ -37,22 +38,10 @@ pub struct CardDavAccountConfig { /// Basic-auth username (or app-specific username, per provider). pub username: String, /// Basic-auth secret (password or app-specific token) resolved from the OS keyring by the - /// caller -- never stored anywhere else in plaintext. - pub auth_token: String, -} - -impl std::fmt::Debug for CardDavAccountConfig { - /// Manual `Debug` impl that redacts `auth_token` -- a derived impl would print the raw - /// secret verbatim the moment anything (a `debug!` log, an assertion failure message, - /// a panic payload) formats this config with `{:?}`. - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CardDavAccountConfig") - .field("account_id", &self.account_id) - .field("carddav_url", &self.carddav_url) - .field("username", &self.username) - .field("auth_token", &"") - .finish() - } + /// caller -- never stored anywhere else in plaintext. Wrapped in [`Redacted`] so a derived + /// `Debug`, a `Display`, or `serde` can never disclose it to a log; the raw value is + /// reachable only through an explicit `expose_secret` call. + pub auth_token: Redacted, } /// CardDAV client protocol engine managing address book `REPORT` queries against a real server. @@ -134,7 +123,10 @@ impl CardDavClient { .request(report_method, &self.config.carddav_url) .header("Content-Type", "application/xml; charset=utf-8") .header("Depth", "1") - .basic_auth(&self.config.username, Some(&self.config.auth_token)) + .basic_auth( + &self.config.username, + Some(self.config.auth_token.expose_secret()), + ) .body(body) .send() .await @@ -186,7 +178,7 @@ mod tests { account_id: "acct-1".to_string(), carddav_url: "https://carddav.example.com/addressbooks/contacts/".to_string(), username: "jmaes".to_string(), - auth_token: "app-token-secret".to_string(), + auth_token: Redacted::new("app-token-secret".to_string()), } } diff --git a/crates/nuncio-contacts/tests/wiremock_carddav_test.rs b/crates/nuncio-contacts/tests/wiremock_carddav_test.rs index a9c3ee4..dd64c64 100644 --- a/crates/nuncio-contacts/tests/wiremock_carddav_test.rs +++ b/crates/nuncio-contacts/tests/wiremock_carddav_test.rs @@ -57,7 +57,7 @@ async fn wiremock_carddav_report_fetches_and_parses_vcards() { account_id: "acct-wm-1".to_string(), carddav_url: format!("{}/addressbooks/contacts/", mock_server.uri()), username: "james.maes".to_string(), - auth_token: "wiremock-app-token".to_string(), + auth_token: "wiremock-app-token".to_string().into(), }; let client = CardDavClient::new(config); @@ -102,7 +102,7 @@ async fn wiremock_carddav_report_server_error_surfaces_as_transport_failure() { account_id: "acct-wm-2".to_string(), carddav_url: format!("{}/addressbooks/broken/", mock_server.uri()), username: "james.maes".to_string(), - auth_token: "wiremock-app-token".to_string(), + auth_token: "wiremock-app-token".to_string().into(), }; let client = CardDavClient::new(config); @@ -143,7 +143,7 @@ END:VCARD account_id: "acct-wm-3".to_string(), carddav_url: format!("{}/addressbooks/malformed/", mock_server.uri()), username: "james.maes".to_string(), - auth_token: "wiremock-app-token".to_string(), + auth_token: "wiremock-app-token".to_string().into(), }; let client = CardDavClient::new(config); diff --git a/crates/nuncio-core/src/lib.rs b/crates/nuncio-core/src/lib.rs index f72c4b7..b91cb1b 100644 --- a/crates/nuncio-core/src/lib.rs +++ b/crates/nuncio-core/src/lib.rs @@ -7,6 +7,7 @@ pub mod config; pub mod export; pub mod mcp_policy; pub mod model; +pub mod redact; pub mod update; pub mod worm_audit; @@ -17,6 +18,7 @@ pub use config::{ pub use export::{ExportEngine, ExportError, ExportFormat, ExportSummary}; pub use mcp_policy::{AgentPermissions, DataType, McpAgentPolicy}; pub use model::{Attachment, CalendarEvent, Contact, DaemonTelemetry, Email, Folder}; +pub use redact::Redacted; pub use update::{ReleaseInfo, UpdateCheckResult, UpdateEngine, UpdateError}; pub use worm_audit::{verify_worm_chain, WormAuditError, WormAuditRecord}; diff --git a/crates/nuncio-core/src/redact.rs b/crates/nuncio-core/src/redact.rs new file mode 100644 index 0000000..357d788 --- /dev/null +++ b/crates/nuncio-core/src/redact.rs @@ -0,0 +1,203 @@ +//! Secret-safe value wrapper and the logging redaction policy it enforces. +//! +//! # Logging redaction policy +//! +//! Telemetry (`tracing` spans, events, and any `Debug`/`Display`/`serde` +//! rendering that can reach a log sink) is a plaintext side channel. The +//! following values MUST NEVER appear in it, in any encoding: +//! +//! - message bodies, subjects, and snippets; +//! - sender/recipient email addresses and any other message correspondents; +//! - account credentials (passwords, app-specific passwords/tokens); +//! - gRPC bearer tokens; +//! - keyring secret *values* (the material stored in the OS vault) and any +//! derived key material (encryption keys, HMAC signing keys); +//! - vCard / iCal personal data (contact names, phones, addresses; event +//! summaries, locations, attendees, descriptions). +//! +//! The following identifying, non-sensitive values ARE allowed in telemetry, +//! because operating the daemon is impossible without them and none of them +//! reveals content or grants access: +//! +//! - opaque ids (account id, message id, rule id, folder id) and UIDs; +//! - counts, sizes, and durations; +//! - operation / rule / transport / action *kinds* (the discriminant, never +//! its payload); +//! - folder names, hostnames, ports, and the per-request `request_id`; +//! - the keyring *lookup key* (e.g. `nuncio/`) — an identifier +//! that names a vault entry but is not itself the stored secret. +//! +//! [`Redacted`] is the mechanical enforcement point for the first list: a +//! field whose type is `Redacted` cannot leak through a derived `Debug`, a +//! `Display`, or `serde` serialization, because each of those renders a fixed +//! placeholder instead of the inner value. The secret is reachable only via an +//! explicit [`Redacted::expose_secret`] / [`Redacted::into_inner`] call, which +//! is easy to audit and never happens implicitly. + +use std::fmt; + +use serde::de::{Deserialize, Deserializer}; +use serde::ser::{Serialize, Serializer}; + +/// The single placeholder rendered wherever a redacted value would otherwise +/// appear (`Debug`, `Display`, and `serde`). +const PLACEHOLDER: &str = ""; + +/// A wrapper for a sensitive value that never renders its contents through +/// `Debug`, `Display`, or `serde`. +/// +/// Wrapping a credential/token/key-material field in `Redacted` makes it +/// impossible to leak that field into telemetry by accident: the compiler still +/// lets a struct derive `Debug`/`Serialize`, but the wrapped field renders as +/// ``. Reaching the real value is deliberate and greppable — it +/// requires calling [`expose_secret`](Self::expose_secret) or +/// [`into_inner`](Self::into_inner). +/// +/// Equality is derived over the inner value so wrapped fields remain comparable +/// in tests and config diffs; it is intentionally not constant-time and is not +/// meant for authenticating a secret against an attacker-supplied guess. +#[derive(Clone, PartialEq, Eq)] +pub struct Redacted(T); + +impl Redacted { + /// Wrap `value` so it is protected from accidental disclosure in telemetry. + pub fn new(value: T) -> Self { + Self(value) + } + + /// Borrow the wrapped secret. Naming the call `expose_secret` keeps every + /// deliberate disclosure of the value greppable and reviewable. + pub fn expose_secret(&self) -> &T { + &self.0 + } + + /// Consume the wrapper and return the owned secret. + pub fn into_inner(self) -> T { + self.0 + } +} + +impl From for Redacted { + fn from(value: T) -> Self { + Self(value) + } +} + +/// Renders `` regardless of the inner value, so a derived `Debug` on +/// any containing struct can never print the secret. +impl fmt::Debug for Redacted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(PLACEHOLDER) + } +} + +/// Renders ``, so interpolating the value with `{}` (e.g. into a +/// log message or error) discloses nothing. +impl fmt::Display for Redacted { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(PLACEHOLDER) + } +} + +/// Serializes the placeholder, never the inner value. This closes the +/// `serde_json`/structured-logging leak path: even serializing a whole struct +/// that contains a `Redacted` field emits `` for it. As a result +/// serialization is deliberately lossy — a `Redacted` must never be used for +/// a field whose real value has to survive a serialization round trip. +impl Serialize for Redacted { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(PLACEHOLDER) + } +} + +/// Deserializes transparently into `Redacted` by deserializing the inner +/// `T`. This exists so structs that hold a `Redacted` can still derive +/// `Deserialize`; it does not reverse [`Serialize`], which is lossy by design. +impl<'de, T> Deserialize<'de> for Redacted +where + T: Deserialize<'de>, +{ + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + Ok(Self(T::deserialize(deserializer)?)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SENTINEL: &str = "super-secret-app-token-9f3a"; + + #[test] + fn debug_renders_placeholder_not_the_secret() { + let wrapped = Redacted::new(SENTINEL.to_string()); + let rendered = format!("{wrapped:?}"); + assert_eq!(rendered, PLACEHOLDER); + assert!(!rendered.contains(SENTINEL)); + } + + #[test] + fn display_renders_placeholder_not_the_secret() { + let wrapped = Redacted::new(SENTINEL.to_string()); + let rendered = format!("{wrapped}"); + assert_eq!(rendered, PLACEHOLDER); + assert!(!rendered.contains(SENTINEL)); + } + + #[test] + fn derived_debug_of_a_containing_struct_does_not_leak() { + // Fields are exercised only through the derived `Debug`, which + // dead-code analysis deliberately ignores; the test asserts on that + // rendering, so silence the false-positive unread-field lint. + #[derive(Debug)] + #[allow(dead_code)] + struct Config { + username: String, + auth_token: Redacted, + } + let cfg = Config { + username: "jmaes".to_string(), + auth_token: Redacted::new(SENTINEL.to_string()), + }; + let rendered = format!("{cfg:?}"); + assert!(rendered.contains("jmaes")); + assert!(!rendered.contains(SENTINEL)); + assert!(rendered.contains(PLACEHOLDER)); + } + + #[test] + fn serialize_emits_placeholder_not_the_secret() { + let wrapped = Redacted::new(SENTINEL.to_string()); + let json = serde_json::to_string(&wrapped).expect("serialize"); + assert!(!json.contains(SENTINEL)); + assert_eq!(json, format!("\"{PLACEHOLDER}\"")); + } + + #[test] + fn expose_and_into_inner_return_the_real_value() { + let wrapped = Redacted::new(SENTINEL.to_string()); + assert_eq!(wrapped.expose_secret(), SENTINEL); + assert_eq!(wrapped.into_inner(), SENTINEL); + } + + #[test] + fn deserialize_wraps_the_inner_value() { + let wrapped: Redacted = + serde_json::from_str(&format!("\"{SENTINEL}\"")).expect("deserialize"); + assert_eq!(wrapped.expose_secret(), SENTINEL); + } + + #[test] + fn from_and_equality() { + let a: Redacted = SENTINEL.to_string().into(); + let b = Redacted::new(SENTINEL.to_string()); + assert_eq!(a, b); + assert_ne!(a, Redacted::new("other".to_string())); + } +} diff --git a/crates/nunciod/src/calendar_sync.rs b/crates/nunciod/src/calendar_sync.rs index 6b698a7..d0a5c45 100644 --- a/crates/nunciod/src/calendar_sync.rs +++ b/crates/nunciod/src/calendar_sync.rs @@ -100,7 +100,7 @@ pub async fn sync_caldav_account( account_id: account.id.clone(), caldav_url: account.dav_collection_url().unwrap_or_default().to_string(), username: account.email_address.clone(), - auth_token: password.to_string(), + auth_token: nuncio_core::redact::Redacted::new(password.to_string()), }); sync_with_backend(db, &client, calendar_id, start_window, end_window).await } diff --git a/crates/nunciod/tests/no_secrets_canary_test.rs b/crates/nunciod/tests/no_secrets_canary_test.rs new file mode 100644 index 0000000..3113464 --- /dev/null +++ b/crates/nunciod/tests/no_secrets_canary_test.rs @@ -0,0 +1,537 @@ +//! Cross-crate "no secrets in logs" canary. +//! +//! This is the regression net for the logging redaction policy documented in +//! `nuncio_core::redact`. It installs a capturing `tracing` subscriber, seeds a +//! set of unmistakable SENTINEL secrets/PII, then drives a representative slice +//! of the now-instrumented code paths across the daemon and the library crates +//! using only mock backends and loopback wiremock servers (no live network): +//! +//! - gRPC auth reject (a wrong bearer token) and authenticated calls; +//! - an account add carrying an account password; +//! - a mock inbound mail sync carrying a sentinel subject/body/sender/recipient; +//! - a real JMAP protocol sync (wiremock) carrying the same sentinel content +//! plus sentinel credentials; +//! - an outbox-affecting mark-read and an outbound send carrying sentinel content; +//! - a filter rule firing on a sentinel message; +//! - a webhook dispatch signed with a sentinel HMAC key; +//! - a CalDAV and a CardDAV sync authenticated with a sentinel token and +//! carrying sentinel event/contact PII. +//! +//! It then asserts that NONE of the sentinel strings appear anywhere in the +//! captured telemetry, while also proving the log-emitting paths actually ran +//! (so the test can never pass by capturing nothing). + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::sync::{Arc, Mutex}; + +use nuncio_cal::{CalDavAccountConfig, CalDavClient, CalendarBackend}; +use nuncio_contacts::{CardDavAccountConfig, CardDavClient, ContactsBackend}; +use nuncio_core::model::{Email, Folder}; +use nuncio_core::EventBus; +use nuncio_filter::{FilterEngine, NsqlParser, ValidationOptions, WebhookDispatcher}; +use nuncio_mail::{JmapEngine, MailBackend, MockMailBackend, MockMessageSender}; +use nuncio_proto::v1::{ + AccountConfig, AddAccountRequest, GetStatusRequest, MarkReadRequest, SendMessageRequest, + SyncRequest, TlsMode, +}; +use nuncio_store::db::DatabaseEngine; +use nuncio_store::vault::{SecretManager, GRPC_TOKEN_ACCOUNT}; +use nunciod::grpc::{ + serve_on_listener_with_overrides, AccountsEngineOverrides, CalendarEngineOverrides, + ContactsEngineOverrides, MailEngineOverrides, +}; +use tokio::net::TcpListener; +use tracing::field::{Field, Visit}; +use tracing::span::Attributes; +use tracing::{Event, Id, Subscriber}; +use tracing_subscriber::layer::Context as LayerContext; +use tracing_subscriber::prelude::*; +use tracing_subscriber::registry::LookupSpan; +use wiremock::matchers::{body_partial_json, method, path}; +use wiremock::{Mock, MockServer, ResponseTemplate}; + +// ---- Sentinels: none of these may EVER appear in captured telemetry. ---- +const SENTINEL_BEARER: &str = "SENTINELbearerTokenAaBbCc001"; +const SENTINEL_PASSWORD: &str = "SENTINELaccountPasswordDdEe002"; +const SENTINEL_SUBJECT: &str = "SENTINELmessageSubjectFfGg003"; +const SENTINEL_BODY: &str = "SENTINELmessageBodyHhIi004"; +const SENTINEL_SENDER: &str = "sentinel-sender-jjkk005@example.invalid"; +const SENTINEL_RECIPIENT: &str = "sentinel-recipient-llmm006@example.invalid"; +const SENTINEL_WEBHOOK_KEY: &str = "SENTINELwebhookHmacKeyNnOo007"; +const SENTINEL_DAV_TOKEN: &str = "SENTINELdavAuthTokenPpQq008"; +const SENTINEL_EVENT_SUMMARY: &str = "SENTINELeventSummaryRrSs009"; +const SENTINEL_CONTACT_NAME: &str = "SENTINELcontactNameTtUu010"; + +const ACCOUNT_ID: &str = "acct-canary-1"; +const ACCOUNT_EMAIL: &str = "canary@nuncio.mx"; +const KEYRING_KEY: &str = "nuncio/acct-canary-1"; + +/// Thread-safe sink recording every rendered field value from every captured +/// span and event, so a single global subscriber can collect telemetry emitted +/// on any daemon/runtime thread. +#[derive(Default)] +struct Sink(Mutex>); + +impl Sink { + fn push_all(&self, values: Vec) { + if let Ok(mut guard) = self.0.lock() { + guard.extend(values); + } + } + + fn snapshot(&self) -> Vec { + self.0.lock().map(|g| g.clone()).unwrap_or_default() + } +} + +struct FieldVisitor(Vec); + +impl Visit for FieldVisitor { + fn record_str(&mut self, field: &Field, value: &str) { + self.0.push(format!("{}={}", field.name(), value)); + } + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + self.0.push(format!("{}={:?}", field.name(), value)); + } +} + +struct CaptureLayer(Arc); + +impl tracing_subscriber::Layer for CaptureLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_new_span(&self, attrs: &Attributes<'_>, _id: &Id, _ctx: LayerContext<'_, S>) { + let mut visitor = FieldVisitor(vec![format!("span={}", attrs.metadata().name())]); + attrs.record(&mut visitor); + self.0.push_all(visitor.0); + } + + fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) { + let mut visitor = FieldVisitor(vec![format!("event={}", event.metadata().target())]); + event.record(&mut visitor); + self.0.push_all(visitor.0); + } +} + +fn sample_proto_config() -> AccountConfig { + AccountConfig { + id: ACCOUNT_ID.to_string(), + name: "Canary Account".to_string(), + email_address: ACCOUNT_EMAIL.to_string(), + keyring_secret_key: KEYRING_KEY.to_string(), + sync_interval: Some(nuncio_proto::time::duration_from_secs(60)), + transport: Some(nuncio_proto::v1::account_config::Transport::ImapSmtp( + nuncio_proto::v1::ImapSmtpTransport { + imap_host: "imap.nuncio.mx".to_string(), + imap_port: 993, + imap_tls_mode: TlsMode::ImplicitTls.into(), + smtp_host: "smtp.nuncio.mx".to_string(), + smtp_port: 465, + smtp_tls_mode: TlsMode::ImplicitTls.into(), + }, + )), + } +} + +fn sentinel_inbound_email() -> Email { + Email { + id: "msg-canary-1".to_string(), + account_id: ACCOUNT_ID.to_string(), + folder_id: "inbox".to_string(), + remote_id: "msg-canary-1".to_string(), + uid_validity: "1".to_string(), + subject: SENTINEL_SUBJECT.to_string(), + sender: SENTINEL_SENDER.to_string(), + recipient: SENTINEL_RECIPIENT.to_string(), + received_at: 1_700_000_000, + read: false, + body_plain: Some(SENTINEL_BODY.to_string()), + body_html: None, + attachments: Vec::new(), + } +} + +/// Boots the real daemon gRPC server (all services, bearer-authenticated) on an +/// ephemeral loopback port, backed by a real temp file DB and the given mail +/// overrides + filter engine. +async fn boot_daemon( + secrets: Arc, + overrides: MailEngineOverrides, + filter_engine: Arc, +) -> (std::net::SocketAddr, String, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("temp dir for db"); + let db_path = dir.path().join("canary.db"); + let db = Arc::new( + DatabaseEngine::connect_file(&db_path, &secrets) + .await + .expect("open temp file db"), + ); + let token = hex::encode( + secrets + .get_or_create_key_bytes(GRPC_TOKEN_ACCOUNT, 32) + .expect("mint bearer token from mock vault"), + ); + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("listener has addr"); + + let event_bus = Arc::new(EventBus::new()); + let server_secrets = secrets.clone(); + let server_token = token.clone(); + tokio::spawn(async move { + let _ = serve_on_listener_with_overrides( + listener, + event_bus, + db, + filter_engine, + server_secrets, + server_token, + overrides, + CalendarEngineOverrides::default(), + ContactsEngineOverrides::default(), + AccountsEngineOverrides::default(), + ) + .await; + }); + (addr, token, dir) +} + +/// Drives the daemon over authenticated gRPC: an auth-reject with a sentinel +/// bearer token, an account add with a sentinel password, an inbound sync +/// carrying sentinel content, a mark-read, and an outbound send. +async fn drive_daemon() { + let secrets = Arc::new(SecretManager::mock()); + + let mock_backend = MockMailBackend::new(); + mock_backend.add_folder(Folder { + id: "inbox".to_string(), + name: "Inbox".to_string(), + total_messages: 1, + unread_messages: 1, + }); + mock_backend.add_message(sentinel_inbound_email()); + let mock_sender = MockMessageSender::new(); + let overrides = MailEngineOverrides { + mail_backend: Some(Arc::new(mock_backend)), + message_sender: Some(Arc::new(mock_sender)), + }; + + // A rule that fires on the sentinel subject, so the daemon's filter path is + // exercised against sentinel content during any evaluation it performs. + let rule = NsqlParser::parse_rule( + "Canary Rule", + 1, + &format!("WHERE subject CONTAINS '{SENTINEL_SUBJECT}' ACTION MARK READ"), + ) + .expect("parse canary rule"); + let filter_engine = Arc::new(FilterEngine::new(vec![rule]).expect("compile rule")); + + let (addr, token, _db_dir) = boot_daemon(secrets, overrides, filter_engine).await; + let addr_str = addr.to_string(); + + // (1) Auth reject: a call bearing the WRONG (sentinel) token is rejected, + // and that token must never be echoed to a log. + let mut unauth = + nuncio_proto::v1::system_client::SystemClient::connect(format!("http://{addr}")) + .await + .expect("system client connects"); + let mut bad = tonic::Request::new(GetStatusRequest {}); + bad.metadata_mut().insert( + "authorization", + format!("Bearer {SENTINEL_BEARER}") + .parse() + .expect("ascii metadata"), + ); + let err = unauth + .get_status(bad) + .await + .expect_err("wrong bearer token must be rejected"); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + + let mut accounts = nuncio_proto::client::connect_accounts(&addr_str, &token) + .await + .expect("accounts client"); + let mut mail = nuncio_proto::client::connect_mail(&addr_str, &token) + .await + .expect("mail client"); + + // (2) Account add carrying a sentinel password. + accounts + .add_account(AddAccountRequest { + config: Some(sample_proto_config()), + password: SENTINEL_PASSWORD.to_string(), + }) + .await + .expect("add_account succeeds"); + + // (3) Inbound sync over the mock backend carrying sentinel content. + let synced = mail + .sync(SyncRequest { account_id: None }) + .await + .expect("sync succeeds") + .into_inner(); + assert_eq!(synced.synced_count, 1); + + // (4) Outbox-affecting mark-read. + mail.mark_read(MarkReadRequest { + message_id: "msg-canary-1".to_string(), + read: true, + }) + .await + .expect("mark_read succeeds"); + + // (5) Outbound send carrying sentinel content. + mail.send_message(SendMessageRequest { + to: SENTINEL_RECIPIENT.to_string(), + cc: None, + subject: SENTINEL_SUBJECT.to_string(), + body_text: SENTINEL_BODY.to_string(), + body_html: None, + attachments: Vec::new(), + account_id: None, + }) + .await + .expect("send_message succeeds"); +} + +/// Drives the filter engine (a rule fires on the sentinel email) and a webhook +/// dispatch signed with a sentinel HMAC key against a loopback wiremock server. +async fn drive_filter_and_webhook() { + let rule = NsqlParser::parse_rule( + "Canary Webhook Rule", + 1, + &format!("WHERE subject CONTAINS '{SENTINEL_SUBJECT}' ACTION MARK READ"), + ) + .expect("parse rule"); + let engine = FilterEngine::new(vec![rule]).expect("compile rule"); + let matches = engine.evaluate(&sentinel_inbound_email()); + assert_eq!(matches.len(), 1, "rule must fire on the sentinel message"); + + let mock_server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/hook")) + .respond_with(ResponseTemplate::new(200)) + .mount(&mock_server) + .await; + + let dispatcher = WebhookDispatcher::new(SENTINEL_WEBHOOK_KEY); + let opts = ValidationOptions { + available_folders: None, + allowed_forward_domains: None, + block_private_webhooks: false, + }; + let url = format!("{}/hook", mock_server.uri()); + let status = dispatcher + .dispatch_with_options( + &url, + "rule-canary", + "msg-canary-1", + SENTINEL_SUBJECT, + SENTINEL_SENDER, + &opts, + ) + .await + .expect("webhook dispatch to loopback mock succeeds"); + assert_eq!(status, 200); +} + +/// Drives a CalDAV and a CardDAV sync authenticated with the sentinel token and +/// carrying sentinel PII, against loopback wiremock servers. +async fn drive_dav() { + // CalDAV. + let cal_server = MockServer::start().await; + let cal_body = format!( + r#" + + + /calendars/work/e.ics + BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +SUMMARY:{SENTINEL_EVENT_SUMMARY} +DTSTART:20240301T120000Z +DTEND:20240301T130000Z +END:VEVENT +END:VCALENDAR + +"# + ); + Mock::given(method("REPORT")) + .and(path("/calendars/work/")) + .respond_with(ResponseTemplate::new(207).set_body_string(cal_body)) + .mount(&cal_server) + .await; + let cal_client = CalDavClient::new(CalDavAccountConfig { + account_id: ACCOUNT_ID.to_string(), + caldav_url: format!("{}/calendars/work/", cal_server.uri()), + username: ACCOUNT_EMAIL.to_string(), + auth_token: SENTINEL_DAV_TOKEN.to_string().into(), + }); + let events = + CalendarBackend::fetch_events(&cal_client, "cal-work", 1_700_000_000, 1_800_000_000) + .await + .expect("caldav fetch succeeds"); + assert_eq!(events.len(), 1); + + // CardDAV. + let card_server = MockServer::start().await; + let card_body = format!( + r#" + + + /addressbooks/contacts/c.vcf + BEGIN:VCARD +VERSION:4.0 +FN:{SENTINEL_CONTACT_NAME} +EMAIL:{SENTINEL_SENDER} +END:VCARD + +"# + ); + Mock::given(method("REPORT")) + .and(path("/addressbooks/contacts/")) + .respond_with(ResponseTemplate::new(207).set_body_string(card_body)) + .mount(&card_server) + .await; + let card_client = CardDavClient::new(CardDavAccountConfig { + account_id: ACCOUNT_ID.to_string(), + carddav_url: format!("{}/addressbooks/contacts/", card_server.uri()), + username: ACCOUNT_EMAIL.to_string(), + auth_token: SENTINEL_DAV_TOKEN.to_string().into(), + }); + let contacts = ContactsBackend::fetch_contacts(&card_client, ACCOUNT_ID) + .await + .expect("carddav fetch succeeds"); + assert_eq!(contacts.len(), 1); +} + +/// Drives a real JMAP protocol sync (session discovery + Mailbox/get + +/// Email/query + Email/get) with sentinel credentials, carrying sentinel +/// content in the fetched message. +async fn drive_jmap() { + let server = MockServer::start().await; + let session_body = serde_json::json!({ + "username": "canary@nuncio.mx", + "primaryAccounts": { "urn:ietf:params:jmap:mail": "acct-100" }, + "apiUrl": format!("{}/jmap/api", server.uri()), + "state": "session-state-1" + }); + Mock::given(method("GET")) + .and(path("/.well-known/jmap")) + .respond_with(ResponseTemplate::new(200).set_body_json(&session_body)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/jmap/api")) + .and(body_partial_json(serde_json::json!({ + "methodCalls": [["Mailbox/get", {}, "c1"]] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "methodResponses": [["Mailbox/get", { + "accountId": "acct-100", + "list": [{"id": "mb-inbox", "name": "Inbox", "totalEmails": 1, "unreadEmails": 1}] + }, "c1"]] + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/jmap/api")) + .and(body_partial_json(serde_json::json!({ + "methodCalls": [["Email/query", {}, "c1"]] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "methodResponses": [["Email/query", {"ids": ["msg-jmap-1"]}, "c1"]] + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/jmap/api")) + .and(body_partial_json(serde_json::json!({ + "methodCalls": [["Email/get", {}, "c1"]] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "methodResponses": [["Email/get", { + "accountId": "acct-100", + "state": "sync-state-1", + "list": [{ + "id": "msg-jmap-1", + "subject": SENTINEL_SUBJECT, + "from": [{ "email": SENTINEL_SENDER }], + "to": [{ "email": SENTINEL_RECIPIENT }], + "receivedAt": 1700002000i64, + "isUnread": true, + "bodySnippet": SENTINEL_BODY + }] + }, "c1"]] + }))) + .mount(&server) + .await; + + let engine = + JmapEngine::with_credentials("acct-100", &server.uri(), ACCOUNT_EMAIL, SENTINEL_PASSWORD); + let folders = engine.sync_folders().await.expect("jmap sync_folders"); + assert_eq!(folders.len(), 1); + let (emails, _state) = engine + .sync_messages("mb-inbox", None) + .await + .expect("jmap sync_messages"); + assert_eq!(emails.len(), 1); +} + +#[tokio::test] +async fn no_sentinel_secret_appears_in_any_captured_telemetry() { + let sink = Arc::new(Sink::default()); + let subscriber = tracing_subscriber::registry().with(CaptureLayer(sink.clone())); + tracing::subscriber::set_global_default(subscriber) + .expect("install the global capturing subscriber"); + + drive_daemon().await; + drive_filter_and_webhook().await; + drive_dav().await; + drive_jmap().await; + + let captured = sink.snapshot(); + + // Prove the log-emitting paths actually ran: the test must never pass by + // capturing nothing. + assert!( + !captured.is_empty(), + "no telemetry captured -- the canary exercised nothing" + ); + let haystack = captured.join("\n"); + assert!( + haystack.contains("webhook"), + "expected the webhook dispatch path to have logged; got:\n{haystack}" + ); + assert!( + haystack.contains("rule matched") || haystack.contains("dispatching actions"), + "expected the filter rule-fire path to have logged; got:\n{haystack}" + ); + + // The core assertion: no sentinel secret or PII may appear anywhere. + let sentinels = [ + ("bearer token", SENTINEL_BEARER), + ("account password", SENTINEL_PASSWORD), + ("message subject", SENTINEL_SUBJECT), + ("message body", SENTINEL_BODY), + ("sender address", SENTINEL_SENDER), + ("recipient address", SENTINEL_RECIPIENT), + ("webhook signing key", SENTINEL_WEBHOOK_KEY), + ("DAV auth token", SENTINEL_DAV_TOKEN), + ("calendar event summary", SENTINEL_EVENT_SUMMARY), + ("contact name", SENTINEL_CONTACT_NAME), + ]; + for (label, sentinel) in sentinels { + let leaked: Vec<&String> = captured + .iter() + .filter(|line| line.contains(sentinel)) + .collect(); + assert!( + leaked.is_empty(), + "SECRET LEAK: the {label} sentinel appeared in telemetry: {leaked:?}" + ); + } +} diff --git a/docs/LOGGING.md b/docs/LOGGING.md new file mode 100644 index 0000000..0d47739 --- /dev/null +++ b/docs/LOGGING.md @@ -0,0 +1,56 @@ +# Logging & redaction policy + +Nuncio's telemetry (`tracing` spans and events, plus anything that reaches a log +sink through a `Debug` / `Display` / `serde` rendering) is a plaintext side +channel. This document is the authoritative statement of what may and may not +appear in it. It is enforced mechanically by two things: + +- **`nuncio_core::redact::Redacted`** — a newtype whose `Debug`, `Display`, + and `serde` all render the fixed placeholder ``, so a secret field + wrapped in it cannot leak even through a *derived* `Debug` on the containing + struct. The inner value is reachable only via an explicit, greppable + `expose_secret()` / `into_inner()` call. The module doc comment restates this + policy next to the type. +- **the no-secrets canary** — `crates/nunciod/tests/no_secrets_canary_test.rs`, + a workspace test that drives a representative slice of the instrumented code + paths with known sentinel secrets and asserts none of them appear in captured + telemetry. It is the regression net: if a future change starts logging a + secret, this test fails. + +## MUST NEVER be logged (in any encoding) + +- Message bodies, subjects, and snippets. +- Sender / recipient email addresses and any other message correspondents. +- Account credentials: passwords and app-specific passwords/tokens. +- gRPC bearer tokens. +- Keyring secret **values** (the material stored in the OS vault) and any + derived key material (encryption keys, HMAC signing keys). +- vCard / iCal personal data: contact names, phone numbers, addresses; event + summaries, locations, attendees, and descriptions. + +## MAY be logged (identifying but non-sensitive) + +Operating the daemon is impossible without correlation identifiers, and none of +the following reveals content or grants access: + +- Opaque ids: account id, message id, rule id, folder id — and UIDs. +- Counts, sizes, and durations. +- Operation / rule / transport / action **kinds** (the discriminant only, never + its payload). +- Folder names, hostnames, ports, and the per-request `request_id`. +- The keyring **lookup key** (e.g. `nuncio/`) — an identifier that + names a vault entry but is not itself the stored secret. + +## Guidance for contributors + +- Prefer logging an **id or a kind** over the value it points at. If you need to + correlate a log line with a message/event/contact, log its id/UID, never its + content. +- When a struct must hold a credential/token/key, make the field type + `Redacted` rather than relying on a hand-written `Debug`. Reach the value + only at the point of use, via `expose_secret()`. +- Never interpolate a whole config/request struct into a log or error with + `{:?}` unless every sensitive field it holds is `Redacted` (or otherwise + self-redacting). +- If you add an instrumented path that could plausibly touch any of the + "MUST NEVER" values, extend the canary to drive it with a sentinel.