|
|
|
@@ -79,8 +79,26 @@ pub enum StorageError {
|
|
|
|
|
Crypto(String),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl StorageError {
|
|
|
|
|
fn io_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
|
|
|
|
StorageError::Io(format!("{ctx}: {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn crypto_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
|
|
|
|
StorageError::Crypto(format!("{ctx}: {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sqlite_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
|
|
|
|
StorageError::Sqlite(format!("{ctx}: {e}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn migration_ctx(ctx: impl std::fmt::Display, e: impl std::fmt::Display) -> Self {
|
|
|
|
|
StorageError::Migration(format!("{ctx}: {e}"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_dir(dir: &Path) -> Result<(), StorageError> {
|
|
|
|
|
fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))
|
|
|
|
|
fs::create_dir_all(dir).map_err(|e| StorageError::io_ctx(format!("mkdir {dir:?}"), e))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Audio-related per-identity settings persisted alongside the
|
|
|
|
@@ -246,7 +264,7 @@ impl IdentityFileStore {
|
|
|
|
|
Ok(b64) => {
|
|
|
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
|
|
|
.decode(b64.as_bytes())
|
|
|
|
|
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::crypto_ctx("keyring dek decode", e))?;
|
|
|
|
|
if bytes.len() != 32 {
|
|
|
|
|
return Err(StorageError::Crypto(format!(
|
|
|
|
|
"keyring dek length {} (expected 32)",
|
|
|
|
@@ -359,9 +377,9 @@ impl IdentityFileStore {
|
|
|
|
|
let _ = self.keyring_save(&key);
|
|
|
|
|
let mut f = open_private(&self.dek_path)?;
|
|
|
|
|
f.write_all(&key)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("write dek: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx("write dek", e))?;
|
|
|
|
|
f.sync_all()
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("sync dek: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx("sync dek", e))?;
|
|
|
|
|
info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated (file fallback)");
|
|
|
|
|
key.zeroize();
|
|
|
|
|
Ok(())
|
|
|
|
@@ -397,11 +415,11 @@ impl IdentityFileStore {
|
|
|
|
|
let mut f = match fs::File::open(&self.path) {
|
|
|
|
|
Ok(f) => f,
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
|
|
|
Err(e) => return Err(StorageError::Io(format!("open {:?}: {e}", self.path))),
|
|
|
|
|
Err(e) => return Err(StorageError::io_ctx(format!("open {:?}", self.path), e)),
|
|
|
|
|
};
|
|
|
|
|
let mut buf = Vec::new();
|
|
|
|
|
f.read_to_end(&mut buf)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("read {:?}: {e}", self.path)))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx(format!("read {:?}", self.path), e))?;
|
|
|
|
|
if buf.is_empty() {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
}
|
|
|
|
@@ -420,11 +438,11 @@ impl IdentityFileStore {
|
|
|
|
|
let nonce = Nonce::from_slice(nonce_bytes);
|
|
|
|
|
let pt = cipher.decrypt(nonce, &buf[12..]).map_err(|e| {
|
|
|
|
|
key_bytes.zeroize();
|
|
|
|
|
StorageError::Crypto(format!("decrypt: {e}"))
|
|
|
|
|
StorageError::crypto_ctx("decrypt", e)
|
|
|
|
|
})?;
|
|
|
|
|
key_bytes.zeroize();
|
|
|
|
|
let s = String::from_utf8(pt)
|
|
|
|
|
.map_err(|e| StorageError::Crypto(format!("plaintext not utf8: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::crypto_ctx("plaintext not utf8", e))?;
|
|
|
|
|
let trimmed = s.trim().to_string();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
return Ok(None);
|
|
|
|
@@ -440,7 +458,7 @@ impl IdentityFileStore {
|
|
|
|
|
"identity file is in legacy plaintext format; will encrypt on next save"
|
|
|
|
|
);
|
|
|
|
|
let s = String::from_utf8(buf)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("legacy not utf8: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx("legacy not utf8", e))?;
|
|
|
|
|
let trimmed = s.trim().to_string();
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
Ok(None)
|
|
|
|
@@ -462,7 +480,7 @@ impl IdentityFileStore {
|
|
|
|
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
|
|
|
let ct = cipher.encrypt(nonce, plaintext).map_err(|e| {
|
|
|
|
|
key_bytes.zeroize();
|
|
|
|
|
StorageError::Crypto(format!("encrypt: {e}"))
|
|
|
|
|
StorageError::crypto_ctx("encrypt", e)
|
|
|
|
|
})?;
|
|
|
|
|
key_bytes.zeroize();
|
|
|
|
|
|
|
|
|
@@ -471,14 +489,14 @@ impl IdentityFileStore {
|
|
|
|
|
{
|
|
|
|
|
let mut f = open_private(&tmp)?;
|
|
|
|
|
f.write_all(&nonce_bytes)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("write nonce: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx("write nonce", e))?;
|
|
|
|
|
f.write_all(&ct)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("write ct: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx("write ct", e))?;
|
|
|
|
|
f.sync_all()
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("sync {tmp:?}: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx(format!("sync {tmp:?}"), e))?;
|
|
|
|
|
}
|
|
|
|
|
fs::rename(&tmp, &self.path)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("rename {tmp:?} -> {:?}: {e}", self.path)))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx(format!("rename {tmp:?} -> {:?}", self.path), e))?;
|
|
|
|
|
info!(target: "chanora_storage", path = ?self.path, "identity persisted (encrypted)");
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
@@ -507,17 +525,17 @@ impl IdentityFileStore {
|
|
|
|
|
let path = self.meta_path();
|
|
|
|
|
let tmp = path.with_extension("json.tmp");
|
|
|
|
|
let body = serde_json::to_vec_pretty(m)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("meta serialize: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx("meta serialize", e))?;
|
|
|
|
|
{
|
|
|
|
|
let mut f = fs::File::create(&tmp)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("open meta {tmp:?}: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx(format!("open meta {tmp:?}"), e))?;
|
|
|
|
|
f.write_all(&body)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("write meta: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx("write meta", e))?;
|
|
|
|
|
f.sync_all()
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("sync meta: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx("sync meta", e))?;
|
|
|
|
|
}
|
|
|
|
|
fs::rename(&tmp, &path)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("rename meta {tmp:?} -> {path:?}: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx(format!("rename meta {tmp:?} -> {path:?}"), e))?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -623,10 +641,10 @@ fn is_plausibly_legacy_plaintext(buf: &[u8]) -> bool {
|
|
|
|
|
/// and the legacy migration path inside `ensure_dek`.
|
|
|
|
|
fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> {
|
|
|
|
|
let mut f =
|
|
|
|
|
fs::File::open(path).map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", path)))?;
|
|
|
|
|
fs::File::open(path).map_err(|e| StorageError::io_ctx(format!("open dek {path:?}"), e))?;
|
|
|
|
|
let mut key = [0u8; 32];
|
|
|
|
|
f.read_exact(&mut key)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("read dek: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::io_ctx("read dek", e))?;
|
|
|
|
|
Ok(key)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -656,7 +674,7 @@ fn open_private(p: &Path) -> Result<fs::File, StorageError> {
|
|
|
|
|
.truncate(true)
|
|
|
|
|
.mode(0o600)
|
|
|
|
|
.open(p)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
|
|
|
|
|
.map_err(|e| StorageError::io_ctx(format!("open {p:?}"), e))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(not(unix))]
|
|
|
|
@@ -670,7 +688,7 @@ fn open_private(p: &Path) -> Result<fs::File, StorageError> {
|
|
|
|
|
.write(true)
|
|
|
|
|
.truncate(true)
|
|
|
|
|
.open(p)
|
|
|
|
|
.map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
|
|
|
|
|
.map_err(|e| StorageError::io_ctx(format!("open {p:?}"), e))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Public abstraction over the per-install envelope-encryption
|
|
|
|
@@ -725,7 +743,7 @@ impl DekCrypto {
|
|
|
|
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
|
|
|
|
let ct = cipher
|
|
|
|
|
.encrypt(nonce, plaintext)
|
|
|
|
|
.map_err(|e| StorageError::Crypto(format!("encrypt: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::crypto_ctx("encrypt", e))?;
|
|
|
|
|
let mut out = Vec::with_capacity(12 + ct.len());
|
|
|
|
|
out.extend_from_slice(&nonce_bytes);
|
|
|
|
|
out.extend_from_slice(&ct);
|
|
|
|
@@ -744,7 +762,7 @@ impl DekCrypto {
|
|
|
|
|
let nonce = Nonce::from_slice(&blob[..12]);
|
|
|
|
|
cipher
|
|
|
|
|
.decrypt(nonce, &blob[12..])
|
|
|
|
|
.map_err(|e| StorageError::Crypto(format!("decrypt: {e}")))
|
|
|
|
|
.map_err(|e| StorageError::crypto_ctx("decrypt", e))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -821,9 +839,9 @@ impl BookmarkRepository {
|
|
|
|
|
ensure_dir(dir)?;
|
|
|
|
|
let path = dir.join("chanora.db");
|
|
|
|
|
let conn = Connection::open(&path)
|
|
|
|
|
.map_err(|e| StorageError::Sqlite(format!("open {path:?}: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::sqlite_ctx(format!("open {path:?}"), e))?;
|
|
|
|
|
conn.pragma_update(None, "foreign_keys", "ON")
|
|
|
|
|
.map_err(|e| StorageError::Sqlite(format!("pragma: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::sqlite_ctx("pragma", e))?;
|
|
|
|
|
conn.execute_batch(
|
|
|
|
|
"CREATE TABLE IF NOT EXISTS bookmarks (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
@@ -837,7 +855,7 @@ impl BookmarkRepository {
|
|
|
|
|
);
|
|
|
|
|
INSERT OR IGNORE INTO schema_version(v) VALUES (1);",
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| StorageError::Migration(format!("init schema: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::migration_ctx("init schema", e))?;
|
|
|
|
|
// Schema v2 migration: encrypted password column. Idempotent.
|
|
|
|
|
let has_blob: i64 = conn
|
|
|
|
|
.query_row(
|
|
|
|
@@ -845,12 +863,12 @@ impl BookmarkRepository {
|
|
|
|
|
[],
|
|
|
|
|
|r| r.get(0),
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| StorageError::Migration(format!("table_info: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::migration_ctx("table_info", e))?;
|
|
|
|
|
if has_blob == 0 {
|
|
|
|
|
conn.execute("ALTER TABLE bookmarks ADD COLUMN password_blob BLOB", [])
|
|
|
|
|
.map_err(|e| StorageError::Migration(format!("add password_blob: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::migration_ctx("add password_blob", e))?;
|
|
|
|
|
conn.execute("INSERT OR IGNORE INTO schema_version(v) VALUES (2)", [])
|
|
|
|
|
.map_err(|e| StorageError::Migration(format!("bump version: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::migration_ctx("bump version", e))?;
|
|
|
|
|
info!(target: "chanora_storage", "bookmark db migrated to v2 (password_blob)");
|
|
|
|
|
}
|
|
|
|
|
info!(target: "chanora_storage", path = ?path, "bookmark db opened");
|
|
|
|
@@ -886,7 +904,7 @@ impl BookmarkRepository {
|
|
|
|
|
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
|
|
|
params![b.display_name, b.host, b.nickname, plain, blob],
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::sqlite_ctx("insert", e))?;
|
|
|
|
|
Ok(conn.last_insert_rowid())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -915,20 +933,20 @@ impl BookmarkRepository {
|
|
|
|
|
|row| row.get(0),
|
|
|
|
|
)
|
|
|
|
|
.optional()
|
|
|
|
|
.map_err(|e| StorageError::Sqlite(format!("select: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::sqlite_ctx("select", e))?;
|
|
|
|
|
if let Some(id) = existing {
|
|
|
|
|
conn.execute(
|
|
|
|
|
"UPDATE bookmarks SET nickname = ?1, password = ?2, password_blob = ?3 WHERE id = ?4",
|
|
|
|
|
params![b.nickname, plain, blob, id],
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::sqlite_ctx("update", e))?;
|
|
|
|
|
Ok(id)
|
|
|
|
|
} else {
|
|
|
|
|
conn.execute(
|
|
|
|
|
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
|
|
|
params![b.display_name, b.host, b.nickname, plain, blob],
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::sqlite_ctx("insert", e))?;
|
|
|
|
|
Ok(conn.last_insert_rowid())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
@@ -957,7 +975,7 @@ impl BookmarkRepository {
|
|
|
|
|
"UPDATE bookmarks SET display_name=?1, host=?2, nickname=?3, password=?4, password_blob=?5 WHERE id=?6",
|
|
|
|
|
params![b.display_name, b.host, b.nickname, plain, blob, b.id],
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::sqlite_ctx("update", e))?;
|
|
|
|
|
if n == 0 {
|
|
|
|
|
Err(StorageError::NotFound)
|
|
|
|
|
} else {
|
|
|
|
@@ -972,7 +990,7 @@ impl BookmarkRepository {
|
|
|
|
|
.lock()
|
|
|
|
|
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
|
|
|
|
|
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])
|
|
|
|
|
.map_err(|e| StorageError::Sqlite(format!("delete: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::sqlite_ctx("delete", e))?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -988,7 +1006,7 @@ impl BookmarkRepository {
|
|
|
|
|
.prepare(
|
|
|
|
|
"SELECT id, display_name, host, nickname, password, password_blob FROM bookmarks ORDER BY id",
|
|
|
|
|
)
|
|
|
|
|
.map_err(|e| StorageError::Sqlite(format!("prepare: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::sqlite_ctx("prepare", e))?;
|
|
|
|
|
let rows = stmt
|
|
|
|
|
.query_map([], |row| {
|
|
|
|
|
let id: i64 = row.get(0)?;
|
|
|
|
@@ -999,15 +1017,15 @@ impl BookmarkRepository {
|
|
|
|
|
let blob: Option<Vec<u8>> = row.get(5)?;
|
|
|
|
|
Ok((id, display_name, host, nickname, plain, blob))
|
|
|
|
|
})
|
|
|
|
|
.map_err(|e| StorageError::Sqlite(format!("query: {e}")))?;
|
|
|
|
|
.map_err(|e| StorageError::sqlite_ctx("query", e))?;
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
for r in rows {
|
|
|
|
|
let (id, display_name, host, nickname, plain, blob) =
|
|
|
|
|
r.map_err(|e| StorageError::Sqlite(format!("row: {e}")))?;
|
|
|
|
|
r.map_err(|e| StorageError::sqlite_ctx("row", e))?;
|
|
|
|
|
let password = match (blob.as_ref(), self.crypto.as_ref()) {
|
|
|
|
|
(Some(b), Some(c)) => Some(
|
|
|
|
|
String::from_utf8(c.decrypt(b)?)
|
|
|
|
|
.map_err(|e| StorageError::Crypto(format!("blob utf8: {e}")))?,
|
|
|
|
|
.map_err(|e| StorageError::crypto_ctx("blob utf8", e))?,
|
|
|
|
|
),
|
|
|
|
|
(Some(_), None) => {
|
|
|
|
|
// We have an encrypted blob but no key. Skip the
|
|
|
|
|