use std::fs; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use ed25519_dalek::SigningKey; use rand::rngs::OsRng; use rusqlite::{Connection, OptionalExtension, params}; use x25519_dalek::{PublicKey as X25519Public, StaticSecret}; pub type Result = std::result::Result; #[derive(Debug)] pub struct Error(String); impl std::fmt::Display for Error { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.0.fmt(f) } } impl std::error::Error for Error {} impl From for Error { fn from(e: rusqlite::Error) -> Self { Self(e.to_string()) } } impl From for Error { fn from(e: std::io::Error) -> Self { Self(e.to_string()) } } pub struct Store { conn: Connection, } pub struct SelfIdentity { pub identity_sk: Vec, pub identity_pk: Vec, pub onion: String, pub prekey_sk: Vec, pub prekey_pk: Vec, } pub struct Friend { pub pubkey: Vec, pub fingerprint: String, pub petname: Option, pub onion: String, pub last_connect_ok: bool, pub prekey: Vec, } pub struct FriendProfile { pub display_name: String, pub bio: String, pub xmr_addr: String, pub updated_at: i64, } #[derive(Debug)] pub struct Message { pub dir: String, pub plaintext: Vec, } #[derive(Debug)] pub struct Payment { pub id: i64, pub dir: String, pub kind: String, pub amount_atomic: String, pub address: String, pub memo: String, pub txid: Option, pub verified: bool, pub created_at: i64, } pub struct PaymentWrite<'a> { pub dir: &'a str, pub kind: &'a str, pub amount_atomic: &'a str, pub address: &'a str, pub memo: &'a str, pub txid: Option<&'a str>, pub verified: bool, } impl Store { pub fn open() -> Result { Self::open_at(&home_dir()?) } pub fn home_dir() -> Result { home_dir() } pub fn open_at(home: &Path) -> Result { mkdir_700(home)?; mkdir_700(&home.join("arti"))?; let db_path = home.join("onionwire.db"); let conn = Connection::open(&db_path)?; let journal: String = conn.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?; if !journal.eq_ignore_ascii_case("wal") { return Err(Error(format!("journal_mode WAL failed: {journal}"))); } conn.execute_batch( " PRAGMA foreign_keys = ON; CREATE TABLE IF NOT EXISTS friends ( id INTEGER PRIMARY KEY, pubkey BLOB NOT NULL UNIQUE, fingerprint TEXT NOT NULL, petname TEXT, onion TEXT NOT NULL, onion_updated_at INTEGER NOT NULL, added_at INTEGER NOT NULL, last_connect_ok INTEGER NOT NULL DEFAULT 1, prekey BLOB NOT NULL DEFAULT x'' ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY, friend_id INTEGER NOT NULL REFERENCES friends(id), dir TEXT NOT NULL CHECK(dir IN ('in','out')), plaintext BLOB NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS self ( id INTEGER PRIMARY KEY CHECK (id = 1), identity_sk BLOB NOT NULL, identity_pk BLOB NOT NULL, onion TEXT NOT NULL, onion_rotated_at INTEGER NOT NULL, prekey_sk BLOB NOT NULL, prekey_pk BLOB NOT NULL, hs_nickname TEXT NOT NULL DEFAULT 'ow0' ); CREATE TABLE IF NOT EXISTS self_profile ( id INTEGER PRIMARY KEY CHECK (id = 1), display_name TEXT NOT NULL DEFAULT '', bio TEXT NOT NULL DEFAULT '', xmr_addr TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS friend_profiles ( pubkey BLOB PRIMARY KEY, display_name TEXT NOT NULL, bio TEXT NOT NULL, xmr_addr TEXT NOT NULL DEFAULT '', updated_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS payments ( id INTEGER PRIMARY KEY, friend_id INTEGER NOT NULL REFERENCES friends(id), dir TEXT NOT NULL CHECK(dir IN ('in','out')), kind TEXT NOT NULL CHECK(kind IN ('invoice','receipt')), amount_atomic TEXT NOT NULL, address TEXT NOT NULL, memo TEXT NOT NULL DEFAULT '', txid TEXT, verified INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); INSERT OR IGNORE INTO self_profile (id) VALUES (1); ", )?; let check: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?; if check != "ok" { return Err(Error(format!("integrity_check: {check}"))); } let store = Self { conn }; store.migrate()?; store.ensure_self()?; Ok(store) } pub fn journal_mode(&self) -> Result { self.conn .query_row("PRAGMA journal_mode", [], |row| row.get(0)) .map_err(Into::into) } pub fn replace_identity_keys( &self, identity_sk: &[u8], identity_pk: &[u8], prekey_sk: &[u8], prekey_pk: &[u8], ) -> Result<()> { if identity_sk.len() != 32 || identity_pk.len() != 32 || prekey_sk.len() != 32 || prekey_pk.len() != 32 { return Err(Error("identity key length".into())); } self.conn.execute( "UPDATE self SET identity_sk = ?1, identity_pk = ?2, prekey_sk = ?3, prekey_pk = ?4 WHERE id = 1", params![identity_sk, identity_pk, prekey_sk, prekey_pk], )?; Ok(()) } fn migrate(&self) -> Result<()> { self.add_column_if_missing( "friends", "last_connect_ok", "ALTER TABLE friends ADD COLUMN last_connect_ok INTEGER NOT NULL DEFAULT 1", )?; self.add_column_if_missing( "friends", "prekey", "ALTER TABLE friends ADD COLUMN prekey BLOB NOT NULL DEFAULT x''", )?; self.add_column_if_missing( "self", "prekey_sk", "ALTER TABLE self ADD COLUMN prekey_sk BLOB NOT NULL DEFAULT x''", )?; self.add_column_if_missing( "self", "prekey_pk", "ALTER TABLE self ADD COLUMN prekey_pk BLOB NOT NULL DEFAULT x''", )?; self.add_column_if_missing( "self", "hs_nickname", "ALTER TABLE self ADD COLUMN hs_nickname TEXT NOT NULL DEFAULT 'ow0'", )?; Ok(()) } fn add_column_if_missing(&self, table: &str, column: &str, ddl: &str) -> Result<()> { let sql = format!("SELECT COUNT(*) FROM pragma_table_info('{table}') WHERE name = '{column}'"); let has: i64 = self.conn.query_row(&sql, [], |row| row.get(0))?; if has == 0 { self.conn.execute(ddl, [])?; } Ok(()) } fn ensure_self(&self) -> Result<()> { let exists: i64 = self.conn .query_row("SELECT COUNT(*) FROM self WHERE id = 1", [], |row| { row.get(0) })?; if exists == 0 { let signing = SigningKey::generate(&mut OsRng); let sk = signing.to_bytes().to_vec(); let pk = signing.verifying_key().to_bytes().to_vec(); let (psk, ppk) = gen_prekey(); self.conn.execute( "INSERT INTO self (id, identity_sk, identity_pk, onion, onion_rotated_at, prekey_sk, prekey_pk, hs_nickname) VALUES (1, ?1, ?2, '', 0, ?3, ?4, 'ow0')", params![sk, pk, psk, ppk], )?; } else { let empty: i64 = self.conn.query_row( "SELECT CASE WHEN length(prekey_sk) = 32 AND length(prekey_pk) = 32 THEN 0 ELSE 1 END FROM self WHERE id = 1", [], |row| row.get(0), )?; if empty != 0 { let (psk, ppk) = gen_prekey(); self.conn.execute( "UPDATE self SET prekey_sk = ?1, prekey_pk = ?2 WHERE id = 1", params![psk, ppk], )?; } } Ok(()) } pub fn self_identity(&self) -> Result { self.conn .query_row( "SELECT identity_sk, identity_pk, onion, prekey_sk, prekey_pk FROM self WHERE id = 1", [], |row| { Ok(SelfIdentity { identity_sk: row.get(0)?, identity_pk: row.get(1)?, onion: row.get(2)?, prekey_sk: row.get(3)?, prekey_pk: row.get(4)?, }) }, ) .map_err(Into::into) } pub fn friend_count(&self) -> Result { self.conn .query_row("SELECT COUNT(*) FROM friends", [], |row| row.get(0)) .map_err(Into::into) } pub fn upsert_friend(&self, pubkey: &[u8], onion: &str, petname: Option<&str>) -> Result<()> { let now = unix_now(); let fp = fingerprint(pubkey); self.conn.execute( "INSERT INTO friends (pubkey, fingerprint, petname, onion, onion_updated_at, added_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5) ON CONFLICT(pubkey) DO UPDATE SET onion = excluded.onion, onion_updated_at = excluded.onion_updated_at, petname = COALESCE(excluded.petname, friends.petname)", params![pubkey, fp, petname, onion, now], )?; Ok(()) } pub fn get_friend(&self, pubkey: &[u8]) -> Result> { self.conn .query_row( "SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends WHERE pubkey = ?1", params![pubkey], friend_from_row, ) .optional() .map_err(Into::into) } pub fn list_friends(&self) -> Result> { let mut stmt = self.conn.prepare( "SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends ORDER BY COALESCE(petname, fingerprint) COLLATE NOCASE", )?; let rows = stmt.query_map([], friend_from_row)?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } pub fn hs_nickname(&self) -> Result { self.conn .query_row("SELECT hs_nickname FROM self WHERE id = 1", [], |row| { row.get(0) }) .map_err(Into::into) } pub fn set_hs_nickname(&self, nickname: &str) -> Result<()> { self.conn.execute( "UPDATE self SET hs_nickname = ?1 WHERE id = 1", params![nickname], )?; Ok(()) } pub fn set_onion(&self, onion: &str) -> Result<()> { let now = unix_now(); self.conn.execute( "UPDATE self SET onion = ?1, onion_rotated_at = ?2 WHERE id = 1", params![onion, now], )?; Ok(()) } /// Apply a signed locator update. Bad sig / stale ts / unknown friend → no change. pub fn apply_loc(&self, pubkey: &[u8], onion: &str, ts: i64, sig: &[u8]) -> Result { let loc = crate::loc::Loc { onion: onion.to_string(), ts, sig: sig.to_vec(), }; if !crate::loc::verify(pubkey, &loc) { return Ok(false); } let Some(updated_at) = self .conn .query_row( "SELECT onion_updated_at FROM friends WHERE pubkey = ?1", params![pubkey], |row| row.get::<_, i64>(0), ) .optional()? else { return Ok(false); }; if ts <= updated_at { return Ok(false); } let n = self.conn.execute( "UPDATE friends SET onion = ?1, onion_updated_at = ?2 WHERE pubkey = ?3", params![onion, ts, pubkey], )?; Ok(n > 0) } pub fn set_self_profile(&self, display_name: &str, bio: &str, xmr_addr: &str) -> Result<()> { crate::profile::validate(display_name, bio, xmr_addr).map_err(|e| Error(e.to_string()))?; let prev: i64 = self.conn.query_row( "SELECT updated_at FROM self_profile WHERE id = 1", [], |row| row.get(0), )?; let now = unix_now().max(prev + 1); self.conn.execute( "UPDATE self_profile SET display_name = ?1, bio = ?2, xmr_addr = ?3, updated_at = ?4 WHERE id = 1", params![display_name, bio, xmr_addr, now], )?; Ok(()) } pub fn self_profile(&self) -> Result { self.conn .query_row( "SELECT display_name, bio, xmr_addr, updated_at FROM self_profile WHERE id = 1", [], profile_from_row, ) .map_err(Into::into) } /// Apply a signed profile. Unknown friend / bad sig / stale ts → no change, no insert. pub fn apply_profile(&self, pubkey: &[u8], prf: &crate::profile::Profile) -> Result { if !crate::profile::verify(pubkey, prf) { return Ok(false); } let exists: i64 = self.conn.query_row( "SELECT COUNT(*) FROM friends WHERE pubkey = ?1", params![pubkey], |row| row.get(0), )?; if exists == 0 { return Ok(false); } let prev: Option = self .conn .query_row( "SELECT updated_at FROM friend_profiles WHERE pubkey = ?1", params![pubkey], |row| row.get(0), ) .optional()?; if prev.is_some_and(|t| prf.ts <= t) { return Ok(false); } self.conn.execute( "INSERT INTO friend_profiles (pubkey, display_name, bio, xmr_addr, updated_at) VALUES (?1, ?2, ?3, ?4, ?5) ON CONFLICT(pubkey) DO UPDATE SET display_name = excluded.display_name, bio = excluded.bio, xmr_addr = excluded.xmr_addr, updated_at = excluded.updated_at", params![pubkey, prf.display_name, prf.bio, prf.xmr_addr, prf.ts], )?; Ok(true) } pub fn friend_profile(&self, pubkey: &[u8]) -> Result> { self.conn .query_row( "SELECT display_name, bio, xmr_addr, updated_at FROM friend_profiles WHERE pubkey = ?1", params![pubkey], profile_from_row, ) .optional() .map_err(Into::into) } pub fn set_friend_prekey(&self, pubkey: &[u8], prekey: &[u8]) -> Result<()> { let n = self.conn.execute( "UPDATE friends SET prekey = ?1 WHERE pubkey = ?2", params![prekey, pubkey], )?; if n == 0 { return Err(Error("friend not found".into())); } Ok(()) } pub fn get_friend_by_prekey(&self, prekey: &[u8]) -> Result> { self.conn .query_row( "SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends WHERE prekey = ?1", params![prekey], friend_from_row, ) .optional() .map_err(Into::into) } pub fn insert_payment(&self, friend_pk: &[u8], p: PaymentWrite<'_>) -> Result { if p.dir != "in" && p.dir != "out" { return Err(Error("dir must be in or out".into())); } if p.kind != "invoice" && p.kind != "receipt" { return Err(Error("kind must be invoice or receipt".into())); } crate::pay::parse_atomic(p.amount_atomic).map_err(|e| Error(e.to_string()))?; crate::pay::check_address(p.address).map_err(|e| Error(e.to_string()))?; let friend_id: i64 = self .conn .query_row( "SELECT id FROM friends WHERE pubkey = ?1", params![friend_pk], |row| row.get(0), ) .map_err(|_| Error("friend not found".into()))?; self.conn.execute( "INSERT INTO payments (friend_id, dir, kind, amount_atomic, address, memo, txid, verified, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", params![ friend_id, p.dir, p.kind, p.amount_atomic, p.address, p.memo, p.txid, i64::from(p.verified), unix_now() ], )?; Ok(self.conn.last_insert_rowid()) } pub fn list_payments(&self, friend_pk: &[u8]) -> Result> { let mut stmt = self.conn.prepare( "SELECT p.id, p.dir, p.kind, p.amount_atomic, p.address, p.memo, p.txid, p.verified, p.created_at FROM payments p JOIN friends f ON f.id = p.friend_id WHERE f.pubkey = ?1 ORDER BY p.id", )?; let rows = stmt.query_map(params![friend_pk], payment_from_row)?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } pub fn mark_verified(&self, id: i64) -> Result { let n = self.conn.execute( "UPDATE payments SET verified = 1 WHERE id = ?1", params![id], )?; Ok(n > 0) } pub fn append_message(&self, friend_pk: &[u8], dir: &str, plaintext: &[u8]) -> Result<()> { if dir != "in" && dir != "out" { return Err(Error("dir must be in or out".into())); } let friend_id: i64 = self .conn .query_row( "SELECT id FROM friends WHERE pubkey = ?1", params![friend_pk], |row| row.get(0), ) .map_err(|_| Error("friend not found".into()))?; self.conn.execute( "INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, ?3, ?4)", params![friend_id, dir, plaintext, unix_now()], )?; Ok(()) } pub fn list_messages(&self, friend_pk: &[u8]) -> Result> { let mut stmt = self.conn.prepare( "SELECT m.dir, m.plaintext FROM messages m JOIN friends f ON f.id = m.friend_id WHERE f.pubkey = ?1 ORDER BY m.id", )?; let rows = stmt.query_map(params![friend_pk], |row| { Ok(Message { dir: row.get(0)?, plaintext: row.get(1)?, }) })?; let mut out = Vec::new(); for row in rows { out.push(row?); } Ok(out) } /// Overwrite message bodies, delete rows, VACUUM. Identity + friends stay. pub fn wipe_messages(&self) -> Result<()> { self.conn.execute( "UPDATE messages SET plaintext = zeroblob(length(plaintext))", [], )?; self.conn.execute("DELETE FROM messages", [])?; self.conn.execute_batch("VACUUM")?; Ok(()) } /// Delete the data dir. Next `open_at` is a new identity. Caller must drop Store first. pub fn wipe_all(home: &Path) -> Result<()> { if home.exists() { fs::remove_dir_all(home)?; } Ok(()) } } fn payment_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Payment { id: row.get(0)?, dir: row.get(1)?, kind: row.get(2)?, amount_atomic: row.get(3)?, address: row.get(4)?, memo: row.get(5)?, txid: row.get(6)?, verified: row.get::<_, i64>(7)? != 0, created_at: row.get(8)?, }) } fn profile_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(FriendProfile { display_name: row.get(0)?, bio: row.get(1)?, xmr_addr: row.get(2)?, updated_at: row.get(3)?, }) } fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Friend { pubkey: row.get(0)?, fingerprint: row.get(1)?, petname: row.get(2)?, onion: row.get(3)?, last_connect_ok: row.get::<_, i64>(4)? != 0, prekey: row.get(5)?, }) } fn gen_prekey() -> (Vec, Vec) { let sk = StaticSecret::random_from_rng(OsRng); let pk = X25519Public::from(&sk); (sk.to_bytes().to_vec(), pk.to_bytes().to_vec()) } fn home_dir() -> Result { if let Some(p) = std::env::var_os("ONIONWIRE_HOME") { return Ok(PathBuf::from(p)); } let home = std::env::var_os("HOME").ok_or_else(|| Error("HOME not set".into()))?; Ok(PathBuf::from(home).join(".local/share/onionwire")) } fn mkdir_700(path: &Path) -> Result<()> { fs::create_dir_all(path)?; let mut perms = fs::metadata(path)?.permissions(); perms.set_mode(0o700); fs::set_permissions(path, perms)?; Ok(()) } fn unix_now() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0) } fn fingerprint(pubkey: &[u8]) -> String { pubkey.iter().map(|b| format!("{b:02x}")).collect() }