From 0a0dd3a8d82d0918d7238d4a402ae9ed33fcc1c2 Mon Sep 17 00:00:00 2001 From: Sirius DevOps Date: Thu, 10 Sep 2026 01:43:02 -0400 Subject: [PATCH] [verified] Add sqlite store and first-run identity. Friends are UNIQUE(pubkey); onion is a locator. Honor ONIONWIRE_HOME. --- Cargo.lock | 54 ++++---------- Cargo.toml | 3 + src/lib.rs | 3 + src/main.rs | 18 ++++- src/store.rs | 195 +++++++++++++++++++++++++++++++++++++++++++++++++ tests/store.rs | 118 ++++++++++++++++++++++++++++++ 6 files changed, 349 insertions(+), 42 deletions(-) create mode 100644 src/lib.rs create mode 100644 src/store.rs create mode 100644 tests/store.rs diff --git a/Cargo.lock b/Cargo.lock index aeab3ef..4eb14ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1269,12 +1269,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - [[package]] name = "foreign-types" version = "0.3.2" @@ -1525,7 +1519,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -1533,9 +1527,6 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "foldhash 0.2.0", -] [[package]] name = "hashbrown" @@ -1545,11 +1536,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.11.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.15.5", ] [[package]] @@ -2055,10 +2046,11 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "91632f3b4fb6bd1d72aa3d78f41ffecfcf2b1a6648d8c241dbe7dbfaf4875e15" dependencies = [ + "cc", "pkg-config", "vcpkg", ] @@ -2353,7 +2345,10 @@ name = "onionwire" version = "0.1.0" dependencies = [ "arti-client", + "ed25519-dalek", "futures", + "rand 0.8.8", + "rusqlite", "safelog", "tempfile", "tokio", @@ -2762,6 +2757,7 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ + "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -2958,21 +2954,11 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rsqlite-vfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.20", -] - [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7" dependencies = [ "bitflags 2.13.2", "fallible-iterator", @@ -2980,7 +2966,6 @@ dependencies = [ "hashlink", "libsqlite3-sys", "smallvec", - "sqlite-wasm-rs", "time", ] @@ -3432,18 +3417,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" -[[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" -dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", -] - [[package]] name = "ssh-cipher-fork-arti" version = "0.2.0" @@ -3676,7 +3649,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "js-sys", "num-conv", "powerfmt", "serde_core", diff --git a/Cargo.toml b/Cargo.toml index 0d4aacb..ee21c0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,9 @@ publish = false arti-client = { version = "0.46", features = ["tokio", "onion-service-client", "onion-service-service"] } futures = "0.3" safelog = "0.9" +ed25519-dalek = { version = "2", features = ["rand_core"] } +rand = "0.8" +rusqlite = { version = "0.36", features = ["bundled"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "time"] } tor-cell = "0.46" tor-hsservice = "0.46" diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..8290257 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,3 @@ +mod store; + +pub use store::{Friend, SelfIdentity, Store}; diff --git a/src/main.rs b/src/main.rs index e7a11a9..ebcc7ee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,19 @@ fn main() { - println!("Hello, world!"); + match onionwire::Store::open() { + Ok(store) => match store.self_identity() { + Ok(me) => { + let fp: String = me.identity_pk.iter().map(|b| format!("{b:02x}")).collect(); + println!("onionwire ready"); + println!("fingerprint {fp}"); + } + Err(e) => { + eprintln!("onionwire: {e}"); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("onionwire: {e}"); + std::process::exit(1); + } + } } diff --git a/src/store.rs b/src/store.rs new file mode 100644 index 0000000..956ea16 --- /dev/null +++ b/src/store.rs @@ -0,0 +1,195 @@ +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() +} diff --git a/tests/store.rs b/tests/store.rs new file mode 100644 index 0000000..5432e18 --- /dev/null +++ b/tests/store.rs @@ -0,0 +1,118 @@ +//! M1 store: identity on `self`, friends keyed by pubkey, onion is locator. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::sync::{Mutex, MutexGuard}; + +use onionwire::Store; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct TempHome { + dir: tempfile::TempDir, + _guard: MutexGuard<'static, ()>, +} + +impl TempHome { + fn new() -> Self { + let dir = tempfile::tempdir().expect("tempdir"); + let guard = ENV_LOCK.lock().expect("env lock"); + unsafe { + std::env::set_var("ONIONWIRE_HOME", dir.path()); + } + Self { dir, _guard: guard } + } + + fn path(&self) -> &Path { + self.dir.path() + } +} + +impl Drop for TempHome { + fn drop(&mut self) { + unsafe { + std::env::remove_var("ONIONWIRE_HOME"); + } + } +} + +fn mode(path: &Path) -> u32 { + fs::metadata(path).unwrap().permissions().mode() & 0o777 +} + +fn pk(tag: u8) -> [u8; 32] { + let mut k = [0u8; 32]; + k[0] = tag; + k +} + +#[test] +fn first_run_creates_0700_dirs_and_self_row() { + let home = TempHome::new(); + let store = Store::open().expect("open"); + + let arti = home.path().join("arti"); + let db = home.path().join("onionwire.db"); + assert!(arti.is_dir(), "arti dir"); + assert!(db.is_file(), "onionwire.db"); + assert_eq!(mode(home.path()), 0o700); + assert_eq!(mode(&arti), 0o700); + + let me = store.self_identity().expect("self"); + assert_eq!(me.identity_pk.len(), 32); + assert_eq!(me.identity_sk.len(), 32); + assert_ne!(me.identity_pk, me.identity_sk); + assert_eq!(me.onion, ""); + assert_eq!(store.friend_count().unwrap(), 0); + + drop(store); + let store2 = Store::open().expect("reopen"); + let me2 = store2.self_identity().expect("self again"); + assert_eq!(me.identity_pk, me2.identity_pk); + assert_eq!(me.identity_sk, me2.identity_sk); +} + +#[test] +fn insert_friend_by_pubkey() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + store + .upsert_friend(&pk(1), "aaa.onion", Some("alice")) + .expect("insert"); + let f = store.get_friend(&pk(1)).expect("row").expect("found"); + assert_eq!(f.onion, "aaa.onion"); + assert_eq!(f.petname.as_deref(), Some("alice")); + assert_eq!(f.pubkey, pk(1)); + assert!(!f.fingerprint.is_empty()); + assert_eq!(store.friend_count().unwrap(), 1); +} + +#[test] +fn same_pubkey_different_onion_updates_locator() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + store + .upsert_friend(&pk(1), "old.onion", Some("alice")) + .expect("insert"); + store + .upsert_friend(&pk(1), "new.onion", None) + .expect("update"); + assert_eq!(store.friend_count().unwrap(), 1); + let f = store.get_friend(&pk(1)).expect("row").expect("found"); + assert_eq!(f.onion, "new.onion"); + assert_eq!(f.petname.as_deref(), Some("alice"), "petname is local"); +} + +#[test] +fn unknown_pubkey_inserts_new_row() { + let _home = TempHome::new(); + let store = Store::open().expect("open"); + store.upsert_friend(&pk(1), "a.onion", None).expect("first"); + store + .upsert_friend(&pk(2), "b.onion", None) + .expect("second"); + assert_eq!(store.friend_count().unwrap(), 2); + assert_eq!(store.get_friend(&pk(1)).unwrap().unwrap().onion, "a.onion"); + assert_eq!(store.get_friend(&pk(2)).unwrap().unwrap().onion, "b.onion"); +}