2026-09-10 01:43:02 -04:00
|
|
|
use std::fs;
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
|
|
|
|
|
use ed25519_dalek::SigningKey;
|
2026-09-10 20:22:31 -04:00
|
|
|
use rand::RngCore;
|
2026-09-10 21:10:50 -04:00
|
|
|
use rand::rngs::OsRng;
|
|
|
|
|
use rusqlite::{Connection, OptionalExtension, params};
|
2026-09-10 02:07:40 -04:00
|
|
|
use x25519_dalek::{PublicKey as X25519Public, StaticSecret};
|
2026-09-10 01:43:02 -04:00
|
|
|
|
|
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 17:19:50 -04:00
|
|
|
impl From<String> for Error {
|
|
|
|
|
fn from(e: String) -> Self {
|
|
|
|
|
Self(e)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:43:02 -04:00
|
|
|
pub struct Store {
|
|
|
|
|
conn: Connection,
|
2026-09-10 17:19:50 -04:00
|
|
|
msg_key: [u8; 32],
|
2026-09-10 01:43:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct SelfIdentity {
|
|
|
|
|
pub identity_sk: Vec<u8>,
|
|
|
|
|
pub identity_pk: Vec<u8>,
|
|
|
|
|
pub onion: String,
|
2026-09-10 02:07:40 -04:00
|
|
|
pub prekey_sk: Vec<u8>,
|
|
|
|
|
pub prekey_pk: Vec<u8>,
|
2026-09-10 01:43:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct Friend {
|
|
|
|
|
pub pubkey: Vec<u8>,
|
|
|
|
|
pub fingerprint: String,
|
|
|
|
|
pub petname: Option<String>,
|
|
|
|
|
pub onion: String,
|
2026-09-10 01:54:19 -04:00
|
|
|
pub last_connect_ok: bool,
|
2026-09-10 02:07:40 -04:00
|
|
|
pub prekey: Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 14:25:31 -04:00
|
|
|
pub struct FriendProfile {
|
|
|
|
|
pub display_name: String,
|
|
|
|
|
pub bio: String,
|
|
|
|
|
pub xmr_addr: String,
|
|
|
|
|
pub updated_at: i64,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:07:40 -04:00
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct Message {
|
|
|
|
|
pub dir: String,
|
|
|
|
|
pub plaintext: Vec<u8>,
|
2026-09-10 01:43:02 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-10 14:37:43 -04:00
|
|
|
#[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<String>,
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:43:02 -04:00
|
|
|
impl Store {
|
|
|
|
|
pub fn open() -> Result<Self> {
|
|
|
|
|
Self::open_at(&home_dir()?)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
pub fn home_dir() -> Result<PathBuf> {
|
|
|
|
|
home_dir()
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:07:40 -04:00
|
|
|
pub fn open_at(home: &Path) -> Result<Self> {
|
2026-09-10 17:19:50 -04:00
|
|
|
Self::open_at_with_passphrase(home, &passphrase_from_env()?)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn open_at_with_passphrase(home: &Path, passphrase: &str) -> Result<Self> {
|
|
|
|
|
if passphrase.is_empty() {
|
|
|
|
|
return Err(Error("empty passphrase".into()));
|
|
|
|
|
}
|
2026-09-10 01:43:02 -04:00
|
|
|
mkdir_700(home)?;
|
|
|
|
|
mkdir_700(&home.join("arti"))?;
|
2026-09-10 20:30:18 -04:00
|
|
|
mkdir_700(&home.join("cache"))?;
|
2026-09-10 01:43:02 -04:00
|
|
|
let db_path = home.join("onionwire.db");
|
|
|
|
|
let conn = Connection::open(&db_path)?;
|
2026-09-10 14:50:01 -04:00
|
|
|
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}")));
|
|
|
|
|
}
|
2026-09-10 20:22:31 -04:00
|
|
|
// Overwrite freed pages on DELETE. Flash wear-leveling can still keep copies;
|
|
|
|
|
// this is not a forensic / SSD crypto-shred.
|
|
|
|
|
conn.pragma_update(None, "secure_delete", "ON")?;
|
2026-09-10 01:43:02 -04:00
|
|
|
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,
|
2026-09-10 01:54:19 -04:00
|
|
|
added_at INTEGER NOT NULL,
|
2026-09-10 02:07:40 -04:00
|
|
|
last_connect_ok INTEGER NOT NULL DEFAULT 1,
|
|
|
|
|
prekey BLOB NOT NULL DEFAULT x''
|
2026-09-10 01:43:02 -04:00
|
|
|
);
|
|
|
|
|
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,
|
2026-09-10 02:07:40 -04:00
|
|
|
onion_rotated_at INTEGER NOT NULL,
|
|
|
|
|
prekey_sk BLOB NOT NULL,
|
2026-09-10 02:43:45 -04:00
|
|
|
prekey_pk BLOB NOT NULL,
|
|
|
|
|
hs_nickname TEXT NOT NULL DEFAULT 'ow0'
|
2026-09-10 01:43:02 -04:00
|
|
|
);
|
2026-09-10 14:25:31 -04:00
|
|
|
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
|
|
|
|
|
);
|
2026-09-10 17:19:50 -04:00
|
|
|
CREATE TABLE IF NOT EXISTS store_meta (
|
|
|
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
|
|
|
kdf_salt BLOB NOT NULL,
|
|
|
|
|
wrapped_key BLOB NOT NULL
|
|
|
|
|
);
|
2026-09-10 14:37:43 -04:00
|
|
|
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
|
|
|
|
|
);
|
2026-09-10 14:25:31 -04:00
|
|
|
INSERT OR IGNORE INTO self_profile (id) VALUES (1);
|
2026-09-10 01:43:02 -04:00
|
|
|
",
|
|
|
|
|
)?;
|
2026-09-10 14:50:01 -04:00
|
|
|
let check: String = conn.query_row("PRAGMA integrity_check", [], |row| row.get(0))?;
|
|
|
|
|
if check != "ok" {
|
|
|
|
|
return Err(Error(format!("integrity_check: {check}")));
|
|
|
|
|
}
|
2026-09-10 17:19:50 -04:00
|
|
|
let mut store = Self {
|
|
|
|
|
conn,
|
|
|
|
|
msg_key: [0u8; 32],
|
|
|
|
|
};
|
2026-09-10 01:54:19 -04:00
|
|
|
store.migrate()?;
|
2026-09-10 01:43:02 -04:00
|
|
|
store.ensure_self()?;
|
2026-09-10 17:19:50 -04:00
|
|
|
store.unlock_messages(passphrase)?;
|
2026-09-10 01:43:02 -04:00
|
|
|
Ok(store)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 14:50:01 -04:00
|
|
|
pub fn journal_mode(&self) -> Result<String> {
|
|
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:54:19 -04:00
|
|
|
fn migrate(&self) -> Result<()> {
|
2026-09-10 02:07:40 -04:00
|
|
|
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''",
|
2026-09-10 01:54:19 -04:00
|
|
|
)?;
|
2026-09-10 02:07:40 -04:00
|
|
|
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''",
|
|
|
|
|
)?;
|
2026-09-10 02:43:45 -04:00
|
|
|
self.add_column_if_missing(
|
|
|
|
|
"self",
|
|
|
|
|
"hs_nickname",
|
|
|
|
|
"ALTER TABLE self ADD COLUMN hs_nickname TEXT NOT NULL DEFAULT 'ow0'",
|
|
|
|
|
)?;
|
2026-09-10 02:07:40 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 17:19:50 -04:00
|
|
|
fn unlock_messages(&mut self, passphrase: &str) -> Result<()> {
|
|
|
|
|
let row: Option<(Vec<u8>, Vec<u8>)> = self
|
|
|
|
|
.conn
|
|
|
|
|
.query_row(
|
|
|
|
|
"SELECT kdf_salt, wrapped_key FROM store_meta WHERE id = 1",
|
|
|
|
|
[],
|
|
|
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
|
|
|
)
|
|
|
|
|
.optional()?;
|
|
|
|
|
match row {
|
|
|
|
|
Some((salt, wrapped)) => {
|
|
|
|
|
let wrap_key = crate::backup::kdf(passphrase, &salt)?;
|
2026-09-10 19:41:00 -04:00
|
|
|
let raw = crate::backup::aead_decrypt(&wrap_key, &wrapped, b"")
|
2026-09-10 17:19:50 -04:00
|
|
|
.map_err(|_| Error("wrong passphrase".into()))?;
|
|
|
|
|
if raw.len() != 32 {
|
|
|
|
|
return Err(Error("wrapped message key length".into()));
|
|
|
|
|
}
|
|
|
|
|
self.msg_key.copy_from_slice(&raw);
|
2026-09-10 19:41:00 -04:00
|
|
|
self.rewrap_empty_aad_messages()?;
|
2026-09-10 17:19:50 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
let mut data_key = [0u8; 32];
|
|
|
|
|
OsRng.fill_bytes(&mut data_key);
|
|
|
|
|
let mut salt = [0u8; 16];
|
|
|
|
|
OsRng.fill_bytes(&mut salt);
|
|
|
|
|
let wrap_key = crate::backup::kdf(passphrase, &salt)?;
|
2026-09-10 19:41:00 -04:00
|
|
|
let wrapped = crate::backup::aead_encrypt(&wrap_key, &data_key, b"")?;
|
2026-09-10 17:19:50 -04:00
|
|
|
self.conn.execute(
|
|
|
|
|
"INSERT INTO store_meta (id, kdf_salt, wrapped_key) VALUES (1, ?1, ?2)",
|
|
|
|
|
params![salt.as_slice(), wrapped],
|
|
|
|
|
)?;
|
|
|
|
|
self.msg_key = data_key;
|
|
|
|
|
self.reencrypt_legacy_messages()?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reencrypt_legacy_messages(&self) -> Result<()> {
|
2026-09-10 19:41:00 -04:00
|
|
|
let mut stmt = self
|
|
|
|
|
.conn
|
|
|
|
|
.prepare("SELECT id, friend_id, dir, plaintext FROM messages")?;
|
|
|
|
|
let rows: Vec<(i64, i64, String, Vec<u8>)> = stmt
|
|
|
|
|
.query_map([], |row| {
|
|
|
|
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
|
|
|
|
})?
|
2026-09-10 17:19:50 -04:00
|
|
|
.collect::<std::result::Result<_, _>>()?;
|
|
|
|
|
drop(stmt);
|
|
|
|
|
if rows.is_empty() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
let tx = self.conn.unchecked_transaction()?;
|
2026-09-10 19:41:00 -04:00
|
|
|
for (id, friend_id, dir, plain) in rows {
|
|
|
|
|
let aad = message_aad(friend_id, &dir, id);
|
|
|
|
|
let blob = crate::backup::aead_encrypt(&self.msg_key, &plain, &aad)?;
|
|
|
|
|
tx.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
|
|
|
|
params![blob, id],
|
|
|
|
|
)?;
|
|
|
|
|
}
|
|
|
|
|
tx.commit()?;
|
|
|
|
|
let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One-shot: empty-AAD v0.2 blobs → row-bound AAD. list_messages never falls back.
|
|
|
|
|
fn rewrap_empty_aad_messages(&self) -> Result<()> {
|
|
|
|
|
let mut stmt = self
|
|
|
|
|
.conn
|
|
|
|
|
.prepare("SELECT id, friend_id, dir, plaintext FROM messages")?;
|
|
|
|
|
let rows: Vec<(i64, i64, String, Vec<u8>)> = stmt
|
|
|
|
|
.query_map([], |row| {
|
|
|
|
|
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
|
|
|
|
|
})?
|
|
|
|
|
.collect::<std::result::Result<_, _>>()?;
|
|
|
|
|
drop(stmt);
|
|
|
|
|
let mut updates = Vec::new();
|
|
|
|
|
for (id, friend_id, dir, blob) in rows {
|
|
|
|
|
let aad = message_aad(friend_id, &dir, id);
|
|
|
|
|
if crate::backup::aead_decrypt(&self.msg_key, &blob, &aad).is_ok() {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if let Ok(pt) = crate::backup::aead_decrypt(&self.msg_key, &blob, b"") {
|
|
|
|
|
let new_blob = crate::backup::aead_encrypt(&self.msg_key, &pt, &aad)?;
|
|
|
|
|
updates.push((id, new_blob));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if updates.is_empty() {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
let tx = self.conn.unchecked_transaction()?;
|
|
|
|
|
for (id, blob) in updates {
|
2026-09-10 17:19:50 -04:00
|
|
|
tx.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
|
|
|
|
params![blob, id],
|
|
|
|
|
)?;
|
|
|
|
|
}
|
|
|
|
|
tx.commit()?;
|
|
|
|
|
let _ = self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:07:40 -04:00
|
|
|
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))?;
|
2026-09-10 01:54:19 -04:00
|
|
|
if has == 0 {
|
2026-09-10 02:07:40 -04:00
|
|
|
self.conn.execute(ddl, [])?;
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:43:02 -04:00
|
|
|
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();
|
2026-09-10 02:07:40 -04:00
|
|
|
let (psk, ppk) = gen_prekey();
|
2026-09-10 01:43:02 -04:00
|
|
|
self.conn.execute(
|
2026-09-10 02:43:45 -04:00
|
|
|
"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')",
|
2026-09-10 02:07:40 -04:00
|
|
|
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),
|
2026-09-10 01:43:02 -04:00
|
|
|
)?;
|
2026-09-10 02:07:40 -04:00
|
|
|
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],
|
|
|
|
|
)?;
|
|
|
|
|
}
|
2026-09-10 01:43:02 -04:00
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn self_identity(&self) -> Result<SelfIdentity> {
|
|
|
|
|
self.conn
|
|
|
|
|
.query_row(
|
2026-09-10 02:07:40 -04:00
|
|
|
"SELECT identity_sk, identity_pk, onion, prekey_sk, prekey_pk FROM self WHERE id = 1",
|
2026-09-10 01:43:02 -04:00
|
|
|
[],
|
|
|
|
|
|row| {
|
|
|
|
|
Ok(SelfIdentity {
|
|
|
|
|
identity_sk: row.get(0)?,
|
|
|
|
|
identity_pk: row.get(1)?,
|
|
|
|
|
onion: row.get(2)?,
|
2026-09-10 02:07:40 -04:00
|
|
|
prekey_sk: row.get(3)?,
|
|
|
|
|
prekey_pk: row.get(4)?,
|
2026-09-10 01:43:02 -04:00
|
|
|
})
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.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)
|
|
|
|
|
}
|
|
|
|
|
|
feat(android): SDK AAR (UniFFI) + Compose APK, Arti rustls feature split
Adds the Android product alongside the existing Linux TUI, in one repo.
SDK — crates/onionwire-sdk is a UniFFI facade over the very same `onionwire`
crate the TUI runs on. Identity, invite, friend upsert, send/receive, rotate
and wipe all delegate; no protocol is reimplemented, so an Android peer and a
Linux peer interoperate. It is its own Cargo workspace because Arti's TLS
backends are non-additive: the TUI keeps native-tls (OpenSSL), Android needs
rustls + static-sqlite (no OpenSSL, no system libsqlite3 in the NDK).
android/ — Gradle project. :sdk produces the AAR (Kotlin bindings generated at
build time + libonionwire_sdk.so via cargo-ndk), :app is a Kotlin/Compose/M3
messenger depending on :sdk only. minSdk 26, targetSdk/compileSdk 36,
INTERNET-only, data in filesDir, backups excluded.
Root Cargo.toml grows `native-tls` (default) and `rustls` features so exactly
one Arti TLS backend is selected per build graph. The default build is
unchanged: same backend, ratatui still a normal dependency, src/tui.rs
untouched.
Also: Store::self_fingerprint/set_petname + Node wrappers (additive only),
scripts/build-android-local.sh, README sections, .gitignore for local SDK paths.
2026-09-10 18:20:19 -04:00
|
|
|
/// Display fingerprint of our own identity key (identity = pubkey).
|
|
|
|
|
pub fn self_fingerprint(&self) -> Result<String> {
|
|
|
|
|
Ok(fingerprint(&self.self_identity()?.identity_pk))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Local petname only. Never part of the wire protocol.
|
|
|
|
|
pub fn set_petname(&self, pubkey: &[u8], petname: Option<&str>) -> Result<()> {
|
|
|
|
|
let n = self.conn.execute(
|
|
|
|
|
"UPDATE friends SET petname = ?1 WHERE pubkey = ?2",
|
|
|
|
|
params![petname, pubkey],
|
|
|
|
|
)?;
|
|
|
|
|
if n == 0 {
|
|
|
|
|
return Err(Error("friend not found".into()));
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:43:02 -04:00
|
|
|
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(
|
2026-09-10 02:07:40 -04:00
|
|
|
"SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends WHERE pubkey = ?1",
|
2026-09-10 01:43:02 -04:00
|
|
|
params![pubkey],
|
2026-09-10 02:07:40 -04:00
|
|
|
friend_from_row,
|
2026-09-10 01:43:02 -04:00
|
|
|
)
|
|
|
|
|
.optional()
|
|
|
|
|
.map_err(Into::into)
|
|
|
|
|
}
|
2026-09-10 01:54:19 -04:00
|
|
|
|
|
|
|
|
pub fn list_friends(&self) -> Result<Vec<Friend>> {
|
|
|
|
|
let mut stmt = self.conn.prepare(
|
2026-09-10 02:07:40 -04:00
|
|
|
"SELECT pubkey, fingerprint, petname, onion, last_connect_ok, prekey FROM friends
|
2026-09-10 01:54:19 -04:00
|
|
|
ORDER BY COALESCE(petname, fingerprint) COLLATE NOCASE",
|
|
|
|
|
)?;
|
2026-09-10 02:07:40 -04:00
|
|
|
let rows = stmt.query_map([], friend_from_row)?;
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
for row in rows {
|
|
|
|
|
out.push(row?);
|
|
|
|
|
}
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
pub fn hs_nickname(&self) -> Result<String> {
|
|
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:07:40 -04:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:43:45 -04:00
|
|
|
/// 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<bool> {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 14:25:31 -04:00
|
|
|
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<FriendProfile> {
|
|
|
|
|
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<bool> {
|
|
|
|
|
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<i64> = 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<Option<FriendProfile>> {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:07:40 -04:00
|
|
|
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<Option<Friend>> {
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 14:37:43 -04:00
|
|
|
pub fn insert_payment(&self, friend_pk: &[u8], p: PaymentWrite<'_>) -> Result<i64> {
|
|
|
|
|
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<Vec<Payment>> {
|
|
|
|
|
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<bool> {
|
|
|
|
|
let n = self.conn.execute(
|
|
|
|
|
"UPDATE payments SET verified = 1 WHERE id = ?1",
|
|
|
|
|
params![id],
|
|
|
|
|
)?;
|
|
|
|
|
Ok(n > 0)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:07:40 -04:00
|
|
|
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()))?;
|
2026-09-10 19:41:00 -04:00
|
|
|
let tx = self.conn.unchecked_transaction()?;
|
|
|
|
|
tx.execute(
|
|
|
|
|
"INSERT INTO messages (friend_id, dir, plaintext, created_at) VALUES (?1, ?2, x'', ?3)",
|
|
|
|
|
params![friend_id, dir, unix_now()],
|
|
|
|
|
)?;
|
|
|
|
|
let row_id = tx.last_insert_rowid();
|
|
|
|
|
let aad = message_aad(friend_id, dir, row_id);
|
|
|
|
|
let blob = crate::backup::aead_encrypt(&self.msg_key, plaintext, &aad)?;
|
|
|
|
|
tx.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
|
|
|
|
params![blob, row_id],
|
2026-09-10 02:07:40 -04:00
|
|
|
)?;
|
2026-09-10 19:41:00 -04:00
|
|
|
tx.commit()?;
|
2026-09-10 02:07:40 -04:00
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn list_messages(&self, friend_pk: &[u8]) -> Result<Vec<Message>> {
|
|
|
|
|
let mut stmt = self.conn.prepare(
|
2026-09-10 19:41:00 -04:00
|
|
|
"SELECT m.id, m.friend_id, m.dir, m.plaintext FROM messages m
|
2026-09-10 02:07:40 -04:00
|
|
|
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| {
|
2026-09-10 19:41:00 -04:00
|
|
|
Ok((
|
|
|
|
|
row.get::<_, i64>(0)?,
|
|
|
|
|
row.get::<_, i64>(1)?,
|
|
|
|
|
row.get::<_, String>(2)?,
|
|
|
|
|
row.get::<_, Vec<u8>>(3)?,
|
|
|
|
|
))
|
2026-09-10 01:54:19 -04:00
|
|
|
})?;
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
for row in rows {
|
2026-09-10 19:41:00 -04:00
|
|
|
let (id, friend_id, dir, blob) = row?;
|
|
|
|
|
let aad = message_aad(friend_id, &dir, id);
|
|
|
|
|
let plaintext = crate::backup::aead_decrypt(&self.msg_key, &blob, &aad)
|
2026-09-10 17:19:50 -04:00
|
|
|
.map_err(|_| Error("message decrypt failed".into()))?;
|
|
|
|
|
out.push(Message { dir, plaintext });
|
2026-09-10 01:54:19 -04:00
|
|
|
}
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
2026-09-10 02:49:42 -04:00
|
|
|
|
2026-09-10 20:22:31 -04:00
|
|
|
/// Drop chat + payments history. Identity + friends stay.
|
|
|
|
|
/// Not a forensic erase: SSD wear-leveling can keep copies.
|
2026-09-10 02:49:42 -04:00
|
|
|
pub fn wipe_messages(&self) -> Result<()> {
|
|
|
|
|
self.conn.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = zeroblob(length(plaintext))",
|
|
|
|
|
[],
|
|
|
|
|
)?;
|
|
|
|
|
self.conn.execute("DELETE FROM messages", [])?;
|
2026-09-10 20:22:31 -04:00
|
|
|
self.conn.execute("DELETE FROM payments", [])?;
|
2026-09-10 02:49:42 -04:00
|
|
|
self.conn.execute_batch("VACUUM")?;
|
2026-09-10 20:22:31 -04:00
|
|
|
self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
|
2026-09-10 02:49:42 -04:00
|
|
|
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(())
|
|
|
|
|
}
|
2026-09-10 01:43:02 -04:00
|
|
|
}
|
|
|
|
|
|
2026-09-10 14:37:43 -04:00
|
|
|
fn payment_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Payment> {
|
|
|
|
|
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)?,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 14:25:31 -04:00
|
|
|
fn profile_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<FriendProfile> {
|
|
|
|
|
Ok(FriendProfile {
|
|
|
|
|
display_name: row.get(0)?,
|
|
|
|
|
bio: row.get(1)?,
|
|
|
|
|
xmr_addr: row.get(2)?,
|
|
|
|
|
updated_at: row.get(3)?,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 02:07:40 -04:00
|
|
|
fn friend_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Friend> {
|
|
|
|
|
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<u8>, Vec<u8>) {
|
|
|
|
|
let sk = StaticSecret::random_from_rng(OsRng);
|
|
|
|
|
let pk = X25519Public::from(&sk);
|
|
|
|
|
(sk.to_bytes().to_vec(), pk.to_bytes().to_vec())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:43:02 -04:00
|
|
|
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"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 17:19:50 -04:00
|
|
|
fn passphrase_from_env() -> Result<String> {
|
|
|
|
|
match std::env::var("ONIONWIRE_STORE_PASSPHRASE") {
|
|
|
|
|
Ok(p) if p.is_empty() => Err(Error("empty passphrase".into())),
|
|
|
|
|
Ok(p) => Ok(p),
|
|
|
|
|
Err(_) => Err(Error("ONIONWIRE_STORE_PASSPHRASE required".into())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 21:10:50 -04:00
|
|
|
/// Env `ONIONWIRE_STORE_PASSPHRASE` if set (non-empty). Otherwise `read_secret`
|
|
|
|
|
/// (TTY, no echo). Empty values fail closed.
|
|
|
|
|
pub fn resolve_store_passphrase(
|
|
|
|
|
read_secret: impl FnOnce(&str) -> std::io::Result<String>,
|
|
|
|
|
) -> Result<String> {
|
|
|
|
|
match std::env::var("ONIONWIRE_STORE_PASSPHRASE") {
|
|
|
|
|
Ok(p) if p.is_empty() => Err(Error("empty passphrase".into())),
|
|
|
|
|
Ok(p) => Ok(p),
|
|
|
|
|
Err(_) => {
|
|
|
|
|
let s = read_secret("onionwire: store passphrase: ")?;
|
|
|
|
|
let s = s.trim_end_matches(['\n', '\r']).to_string();
|
|
|
|
|
if s.is_empty() {
|
|
|
|
|
Err(Error("empty passphrase".into()))
|
|
|
|
|
} else {
|
|
|
|
|
Ok(s)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:43:02 -04:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 19:41:00 -04:00
|
|
|
/// AAD: `owmsg1` || friend_id_le64 || dir || 0x00 || row_id_le64
|
|
|
|
|
fn message_aad(friend_id: i64, dir: &str, row_id: i64) -> Vec<u8> {
|
|
|
|
|
let mut aad = Vec::with_capacity(6 + 8 + dir.len() + 1 + 8);
|
|
|
|
|
aad.extend_from_slice(b"owmsg1");
|
|
|
|
|
aad.extend_from_slice(&friend_id.to_le_bytes());
|
|
|
|
|
aad.extend_from_slice(dir.as_bytes());
|
|
|
|
|
aad.push(0);
|
|
|
|
|
aad.extend_from_slice(&row_id.to_le_bytes());
|
|
|
|
|
aad
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 01:43:02 -04:00
|
|
|
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()
|
|
|
|
|
}
|
2026-09-10 17:19:50 -04:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod at_rest {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
2026-09-10 19:41:00 -04:00
|
|
|
#[test]
|
|
|
|
|
fn swapped_ciphertext_does_not_show_other_friends_body() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "aad-pass").unwrap();
|
|
|
|
|
let alice = [1u8; 32];
|
|
|
|
|
let bob = [2u8; 32];
|
|
|
|
|
store.upsert_friend(&alice, "a.onion", None).unwrap();
|
|
|
|
|
store.upsert_friend(&bob, "b.onion", None).unwrap();
|
|
|
|
|
store
|
|
|
|
|
.append_message(&alice, "out", b"secret-for-alice")
|
|
|
|
|
.unwrap();
|
|
|
|
|
store
|
|
|
|
|
.append_message(&bob, "out", b"secret-for-bob")
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
let blobs: Vec<(i64, Vec<u8>)> = {
|
|
|
|
|
let mut stmt = store
|
|
|
|
|
.conn
|
|
|
|
|
.prepare("SELECT id, plaintext FROM messages ORDER BY id")
|
|
|
|
|
.unwrap();
|
|
|
|
|
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.collect::<rusqlite::Result<Vec<_>>>()
|
|
|
|
|
.unwrap()
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(blobs.len(), 2);
|
|
|
|
|
store
|
|
|
|
|
.conn
|
|
|
|
|
.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
|
|
|
|
params![&blobs[1].1, blobs[0].0],
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
store
|
|
|
|
|
.conn
|
|
|
|
|
.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
|
|
|
|
params![&blobs[0].1, blobs[1].0],
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
if let Ok(msgs) = store.list_messages(&alice) {
|
|
|
|
|
assert!(
|
|
|
|
|
msgs.iter()
|
|
|
|
|
.all(|m| m.plaintext.as_slice() != b"secret-for-bob"),
|
|
|
|
|
"alice saw bob's body after ciphertext swap"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if let Ok(msgs) = store.list_messages(&bob) {
|
|
|
|
|
assert!(
|
|
|
|
|
msgs.iter()
|
|
|
|
|
.all(|m| m.plaintext.as_slice() != b"secret-for-alice"),
|
|
|
|
|
"bob saw alice's body after ciphertext swap"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn empty_aad_rows_rewrap_on_unlock_then_swap_fails() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "rewrap-pass").unwrap();
|
|
|
|
|
let alice = [1u8; 32];
|
|
|
|
|
let bob = [2u8; 32];
|
|
|
|
|
store.upsert_friend(&alice, "a.onion", None).unwrap();
|
|
|
|
|
store.upsert_friend(&bob, "b.onion", None).unwrap();
|
|
|
|
|
store
|
|
|
|
|
.append_message(&alice, "out", b"secret-for-alice")
|
|
|
|
|
.unwrap();
|
|
|
|
|
store
|
|
|
|
|
.append_message(&bob, "out", b"secret-for-bob")
|
|
|
|
|
.unwrap();
|
|
|
|
|
let ids: Vec<i64> = {
|
|
|
|
|
let mut stmt = store
|
|
|
|
|
.conn
|
|
|
|
|
.prepare("SELECT id FROM messages ORDER BY id")
|
|
|
|
|
.unwrap();
|
|
|
|
|
stmt.query_map([], |r| r.get(0))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.collect::<rusqlite::Result<Vec<_>>>()
|
|
|
|
|
.unwrap()
|
|
|
|
|
};
|
|
|
|
|
let a_blob = crate::backup::aead_encrypt(&store.msg_key, b"secret-for-alice", b"").unwrap();
|
|
|
|
|
let b_blob = crate::backup::aead_encrypt(&store.msg_key, b"secret-for-bob", b"").unwrap();
|
|
|
|
|
store
|
|
|
|
|
.conn
|
|
|
|
|
.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
|
|
|
|
params![a_blob, ids[0]],
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
store
|
|
|
|
|
.conn
|
|
|
|
|
.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
|
|
|
|
params![b_blob, ids[1]],
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
drop(store);
|
|
|
|
|
|
|
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "rewrap-pass").unwrap();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
store.list_messages(&alice).unwrap()[0].plaintext,
|
|
|
|
|
b"secret-for-alice"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
store.list_messages(&bob).unwrap()[0].plaintext,
|
|
|
|
|
b"secret-for-bob"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let blobs: Vec<(i64, Vec<u8>)> = {
|
|
|
|
|
let mut stmt = store
|
|
|
|
|
.conn
|
|
|
|
|
.prepare("SELECT id, plaintext FROM messages ORDER BY id")
|
|
|
|
|
.unwrap();
|
|
|
|
|
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
|
|
|
|
|
.unwrap()
|
|
|
|
|
.collect::<rusqlite::Result<Vec<_>>>()
|
|
|
|
|
.unwrap()
|
|
|
|
|
};
|
|
|
|
|
store
|
|
|
|
|
.conn
|
|
|
|
|
.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
|
|
|
|
params![&blobs[1].1, blobs[0].0],
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
store
|
|
|
|
|
.conn
|
|
|
|
|
.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = ?1 WHERE id = ?2",
|
|
|
|
|
params![&blobs[0].1, blobs[1].0],
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
if let Ok(msgs) = store.list_messages(&alice) {
|
|
|
|
|
assert!(
|
|
|
|
|
msgs.iter()
|
|
|
|
|
.all(|m| m.plaintext.as_slice() != b"secret-for-bob"),
|
|
|
|
|
"alice saw bob's body after post-rewrap swap"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-10 17:19:50 -04:00
|
|
|
#[test]
|
|
|
|
|
fn first_unlock_reencrypts_legacy_plaintext_rows() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "migrate-pass").unwrap();
|
|
|
|
|
let pk = [1u8; 32];
|
|
|
|
|
store.upsert_friend(&pk, "a.onion", None).unwrap();
|
|
|
|
|
store
|
|
|
|
|
.append_message(&pk, "out", b"legacy-plain-xyz")
|
|
|
|
|
.unwrap();
|
|
|
|
|
store.conn.execute("DELETE FROM store_meta", []).unwrap();
|
|
|
|
|
store
|
|
|
|
|
.conn
|
|
|
|
|
.execute(
|
|
|
|
|
"UPDATE messages SET plaintext = ?1",
|
|
|
|
|
params![b"legacy-plain-xyz".as_slice()],
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let _ = store.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
|
|
|
drop(store);
|
|
|
|
|
|
|
|
|
|
let store = Store::open_at_with_passphrase(dir.path(), "migrate-pass").unwrap();
|
|
|
|
|
let msgs = store.list_messages(&pk).unwrap();
|
|
|
|
|
assert_eq!(msgs[0].plaintext, b"legacy-plain-xyz");
|
|
|
|
|
drop(store);
|
|
|
|
|
let needle = b"legacy-plain-xyz";
|
|
|
|
|
for name in ["onionwire.db", "onionwire.db-wal", "onionwire.db-shm"] {
|
|
|
|
|
let path = dir.path().join(name);
|
|
|
|
|
let Ok(bytes) = std::fs::read(&path) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
assert!(
|
|
|
|
|
!bytes.windows(needle.len()).any(|w| w == needle),
|
|
|
|
|
"{name} still contains legacy plaintext"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|