[verified] Add sqlite store and first-run identity.
Friends are UNIQUE(pubkey); onion is a locator. Honor ONIONWIRE_HOME.
This commit is contained in:
parent
84b9c940cc
commit
0a0dd3a8d8
6 changed files with 349 additions and 42 deletions
54
Cargo.lock
generated
54
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
3
src/lib.rs
Normal file
3
src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mod store;
|
||||
|
||||
pub use store::{Friend, SelfIdentity, Store};
|
||||
18
src/main.rs
18
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
195
src/store.rs
Normal file
195
src/store.rs
Normal 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
118
tests/store.rs
Normal 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");
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue