[verified] Add sqlite store and first-run identity.

Friends are UNIQUE(pubkey); onion is a locator. Honor ONIONWIRE_HOME.
This commit is contained in:
Sirius DevOps 2026-09-10 01:43:02 -04:00
parent 84b9c940cc
commit 0a0dd3a8d8
No known key found for this signature in database
6 changed files with 349 additions and 42 deletions

54
Cargo.lock generated
View file

@ -1269,12 +1269,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.3.2" version = "0.3.2"
@ -1525,7 +1519,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [ dependencies = [
"foldhash 0.1.5", "foldhash",
] ]
[[package]] [[package]]
@ -1533,9 +1527,6 @@ name = "hashbrown"
version = "0.16.1" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash 0.2.0",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
@ -1545,11 +1536,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]] [[package]]
name = "hashlink" name = "hashlink"
version = "0.11.1" version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [ dependencies = [
"hashbrown 0.16.1", "hashbrown 0.15.5",
] ]
[[package]] [[package]]
@ -2055,10 +2046,11 @@ dependencies = [
[[package]] [[package]]
name = "libsqlite3-sys" name = "libsqlite3-sys"
version = "0.37.0" version = "0.34.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" checksum = "91632f3b4fb6bd1d72aa3d78f41ffecfcf2b1a6648d8c241dbe7dbfaf4875e15"
dependencies = [ dependencies = [
"cc",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
@ -2353,7 +2345,10 @@ name = "onionwire"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"arti-client", "arti-client",
"ed25519-dalek",
"futures", "futures",
"rand 0.8.8",
"rusqlite",
"safelog", "safelog",
"tempfile", "tempfile",
"tokio", "tokio",
@ -2762,6 +2757,7 @@ version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [ dependencies = [
"libc",
"rand_chacha 0.3.1", "rand_chacha 0.3.1",
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
@ -2958,21 +2954,11 @@ dependencies = [
"zeroize", "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]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.39.0" version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"fallible-iterator", "fallible-iterator",
@ -2980,7 +2966,6 @@ dependencies = [
"hashlink", "hashlink",
"libsqlite3-sys", "libsqlite3-sys",
"smallvec", "smallvec",
"sqlite-wasm-rs",
"time", "time",
] ]
@ -3432,18 +3417,6 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" 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]] [[package]]
name = "ssh-cipher-fork-arti" name = "ssh-cipher-fork-arti"
version = "0.2.0" version = "0.2.0"
@ -3676,7 +3649,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [ dependencies = [
"deranged", "deranged",
"js-sys",
"num-conv", "num-conv",
"powerfmt", "powerfmt",
"serde_core", "serde_core",

View file

@ -10,6 +10,9 @@ publish = false
arti-client = { version = "0.46", features = ["tokio", "onion-service-client", "onion-service-service"] } arti-client = { version = "0.46", features = ["tokio", "onion-service-client", "onion-service-service"] }
futures = "0.3" futures = "0.3"
safelog = "0.9" 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"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "time"] }
tor-cell = "0.46" tor-cell = "0.46"
tor-hsservice = "0.46" tor-hsservice = "0.46"

3
src/lib.rs Normal file
View file

@ -0,0 +1,3 @@
mod store;
pub use store::{Friend, SelfIdentity, Store};

View file

@ -1,3 +1,19 @@
fn main() { 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);
}
}
} }

195
src/store.rs Normal file
View file

@ -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<T> = std::result::Result<T, Error>;
#[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<rusqlite::Error> for Error {
fn from(e: rusqlite::Error) -> Self {
Self(e.to_string())
}
}
impl From<std::io::Error> 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<u8>,
pub identity_pk: Vec<u8>,
pub onion: String,
}
pub struct Friend {
pub pubkey: Vec<u8>,
pub fingerprint: String,
pub petname: Option<String>,
pub onion: String,
}
impl Store {
pub fn open() -> Result<Self> {
Self::open_at(&home_dir()?)
}
fn open_at(home: &Path) -> Result<Self> {
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<SelfIdentity> {
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<i64> {
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<Option<Friend>> {
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<PathBuf> {
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()
}

118
tests/store.rs Normal file
View file

@ -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");
}