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::{params, Connection, OptionalExtension}; 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 struct Friend { pub pubkey: Vec, pub fingerprint: String, pub petname: Option, pub onion: String, } impl Store { pub fn open() -> Result { Self::open_at(&home_dir()?) } 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)?; 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 ); 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 ); ", )?; let store = Self { conn }; store.ensure_self()?; Ok(store) } 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(); self.conn.execute( "INSERT INTO self (id, identity_sk, identity_pk, onion, onion_rotated_at) VALUES (1, ?1, ?2, '', 0)", params![sk, pk], )?; } Ok(()) } pub fn self_identity(&self) -> Result { self.conn .query_row( "SELECT identity_sk, identity_pk, onion FROM self WHERE id = 1", [], |row| { Ok(SelfIdentity { identity_sk: row.get(0)?, identity_pk: row.get(1)?, onion: row.get(2)?, }) }, ) .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 FROM friends WHERE pubkey = ?1", params![pubkey], |row| { Ok(Friend { pubkey: row.get(0)?, fingerprint: row.get(1)?, petname: row.get(2)?, onion: row.get(3)?, }) }, ) .optional() .map_err(Into::into) } } 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() }