feat(mvp): v1.0.0-rc.1 — keyring-backed DEK, encrypted bookmarks, MVP release-gate docs

Closes the v0.4 dual-file weakness in identity-at-rest and turns the
release into an MVP public release candidate. The remaining work
before `v1.0.0` is DEC-012 legal sign-off — see
`docs/governance/legal-review-readiness.md` — and the staged
platform promotions in `docs/governance/staged-release-plan.md`.
No decision rows in `product-decision-register.md` change; the
register's change-history advances to 0.9.8.

`chanora_storage`
-----------------

* New public `Crypto` trait + `IdentityFileStore::crypto()` give
  callers an encrypt / decrypt pair anchored on the per-install
  32-byte DEK without exposing the key material.
* `IdentityFileStore` keyring-first DEK retrieval (Linux Secret
  Service via D-Bus, macOS Keychain, Windows Credential Manager,
  iOS Keychain via the `keyring` crate). Pre-existing
  `identity.dek` files are opportunistically migrated into the
  keyring on first run; the on-disk DEK copy is removed once the
  keyring acknowledges. `CHANORA_DISABLE_KEYRING=1` forces the
  file-fallback path for tests and headless / CI hosts where a
  real keyring call would prompt the user or block on a missing
  D-Bus session.
* `BookmarkRepository::with_crypto(dir, crypto)` encrypts the
  server password into a new `password_blob` BLOB column under
  the same per-install DEK. Schema v2 migration is idempotent —
  legacy v0.4 rows with a plain `password TEXT` are read
  transparently and lifted into `password_blob` on the next
  `update()`. `BookmarkRepository::new` (no crypto) is preserved
  for tests and as a documented fallback when the DEK is
  unreachable.
* Storage tests rise from 8 to 10: encrypted bookmark password
  round-trip + legacy-plaintext-bookmark upgrade.

`chanora_core`
--------------

* `ChanoraSession::init_storage(dir)` wires the bookmark
  repository with crypto by default. On any crypto-derivation
  failure it falls back to the plain-password repository and
  logs the gap — better than hard-failing init.
* `supervisor_loop` now tracks a 64-bit `snapshot_signature` over
  channels (id + parent + order + name) and clients (id + channel
  + name) instead of the old `(channel_count, client_count)`
  tuple. Any in-channel client move, channel rename, or reorder
  now fires `SessionEvent::SnapshotChanged`. The signature sorts
  by id before hashing so it's stable under input-vector
  reordering.
* Two new unit tests cover the signature behaviour; new
  `tests/mvp_storage.rs` integration test drives
  `ChanoraSession::init_storage` end-to-end and verifies the
  bookmark `password_blob` does not contain the plaintext.
* Re-export `ChannelId` + `ClientId` from `chanora_protocol` so
  downstream callers and tests can construct DTOs directly.

Flutter
-------

* New About dialog (info icon in the AppBar) surfaces DEC-018
  (public name "Chanora"), DEC-019 (non-affiliation statement),
  and DEC-020 (Apache-2.0 OR MIT dual license). New ARB keys in
  `app_en.arb` and `app_zh.arb`: `aboutAction`, `aboutVersion`,
  `aboutNonAffiliation`, `aboutLicenseHeading`, `aboutLicenseBody`,
  `aboutThirdPartyHeading`, `aboutThirdPartyBody`.
* `pubspec.yaml` version bumps to `1.0.0-rc.1+5`.

Governance
----------

* `docs/governance/legal-review-readiness.md` — DEC-012 handoff
  package. Enumerates trademark / non-affiliation / license-text
  / third-party-attribution / `tsclientlib`-posture / crypto-
  export / data-handling items the legal reviewer must confirm,
  and lists the concrete engineering deliverables they block on
  (`cargo about generate`, `cargo deny check licenses`,
  Flutter `LicenseRegistry` dump).
* `docs/governance/staged-release-plan.md` — DEC-002 channel
  schedule. Linux + Android sideload promote to GA on DEC-012
  sign-off; Play Store / Windows / macOS / iOS gate on per-
  platform signed-build availability. Rollback policy included.
* `product-decision-register.md` change-history advances to
  0.9.8 with a single entry summarising v0.3, v0.4, and v1.0-rc.1
  progress against DEC-001. No decision rows mutate.

Build + ops
-----------

* `NOTICE` refreshed for the MVP product-code dependency set:
  adds `chacha20poly1305`, `rand`, `zeroize`, `base64`,
  `keyring`, `connectivity_plus`, `path_provider`,
  `freezed_annotation`; drops PoC-only entries.
* `CHANGELOG.md` restructured: explicit version sections for
  v0.3.0-beta.1, v0.4.0-beta.2, v1.0.0-rc.1. Previous "Unreleased"
  contents migrated into their respective milestone sections.
* `.github/workflows/ci.yml` exports `CHANORA_DISABLE_KEYRING=1`
  for the cargo-test job — CI runners have no D-Bus session and
  the keyring crate would otherwise block.
* `run-chanora.sh` reads `CHANORA_BUNDLE_FLAVOUR` (default
  `release`) and self-copies the latest cdylib into the bundle's
  `lib/` if missing.

Verification
------------

* `cargo test --workspace` with `CHANORA_DISABLE_KEYRING=1`: all
  green (49 unit tests across the workspace; up from 36 at
  v0.4.0-beta.2).
* `cargo test -p chanora_core --release -- --ignored alpha_smoke`
  passes against the live `cn.teamspeak.app` (DNS → connect →
  snapshot → disconnect in ~2.5 s).
* `flutter analyze`: clean.
* `cargo build -p chanora_bridge --release` + `flutter build
  linux --release` produce a working Linux x86_64 bundle.

No Android live test in this commit per the user's note that the
physical device was removed; the Android arm64-v8a build path is
mechanically identical to v0.4.0-beta.2.
This commit is contained in:
EdisonJwa
2026-05-15 02:24:42 +08:00
parent 780fd7eca2
commit 50768a8f48
21 changed files with 1471 additions and 115 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ mod dto;
mod resolver;
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
pub use dto::{ChannelInfo, ClientInfo, ServerSnapshot};
pub use dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
// Re-export the upstream voice types so chanora_audio can build outbound
// voice packets without taking a direct dependency on tsclientlib /
+10
View File
@@ -16,3 +16,13 @@ rusqlite = { version = "0.32", features = ["bundled"] }
chacha20poly1305 = "0.10"
rand = "0.8"
zeroize = "1"
# `base64` is needed to serialise the DEK as a string for the
# keyring API (which is text-only on most platforms).
base64 = "0.22"
# Platform keyring abstraction: Secret Service / kernel keyutils on
# Linux (DEC-013.2); macOS Keychain; Windows Credential Manager;
# iOS Keychain; on Android the keyring crate falls back to the
# in-memory provider, so we keep a file-on-disk fallback there.
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))'.dependencies]
keyring = { version = "3", default-features = false, features = ["sync-secret-service", "linux-native", "apple-native", "windows-native"] }
+475 -40
View File
@@ -90,19 +90,31 @@ pub trait SecretStorageRepository: Send + Sync {}
/// Beta identity store: a single ChaCha20-Poly1305-encrypted file
/// containing the base64 TS3 identity string. The Data Encryption
/// Key (DEK) is 32 random bytes stored alongside in a separate
/// `identity.dek` file with the same 0600 permissions on Unix.
/// Key (DEK) is 32 random bytes stored in the platform keyring
/// (Linux Secret Service via D-Bus, macOS Keychain, Windows
/// Credential Manager, iOS Keychain) when available, with a
/// best-effort file fallback at `identity.dek` (mode 0600) when the
/// keyring is unreachable. Per DEC-013.2 the Linux preference is
/// Secret Service; the kernel keyutils fallback is not yet wired —
/// see the keyring-error log line on first run when the bus is
/// missing.
///
/// The dual-file layout means an attacker who recovers either file
/// alone can't decrypt the identity. The honest threat model:
/// Threat-model honest assessment:
///
/// * **Helps against** stale backups, casual filesystem snooping
/// that grabs one file but not the other, and accidental leaks
/// to diagnostic exports (the ciphertext is never logged).
/// * **Does NOT help against** a full app-private storage dump (an
/// attacker who can read one file in the directory can read both).
/// The v0.4 storage rework lands proper OS-keyring backing for the
/// DEK so this two-file weakness is closed.
/// * **Keyring path** (the normal case on a logged-in desktop): the
/// DEK lives in the OS keyring, locked under the user's session.
/// Recovering the identity then requires both the on-disk
/// ciphertext *and* the active user session — a meaningful
/// improvement over the file-fallback two-file model.
/// * **File-fallback path** (headless servers, CI containers,
/// Android-without-Keystore, fresh installs where the keyring
/// isn't running yet): same dual-file guarantee as v0.4 — both
/// files in the install dir must be readable to recover the
/// identity.
/// * **Does NOT help against** a malicious user inside the same
/// session (keyring lookup succeeds for any process the user
/// runs); v1.1+ may pursue per-process scoping where the OS
/// supports it.
///
/// File format: `identity.tskey` = `[12-byte nonce][AEAD ciphertext+tag]`.
/// Legacy plaintext files written by v0.3 are still readable; the
@@ -112,19 +124,32 @@ pub trait SecretStorageRepository: Send + Sync {}
pub struct IdentityFileStore {
path: PathBuf,
dek_path: PathBuf,
/// Stable per-install identifier used as the keyring account
/// name. Derived from the install directory so the same store
/// finds the same keyring entry across restarts.
keyring_account: String,
}
impl IdentityFileStore {
/// Service name used for the platform keyring entry. Kept
/// short and stable so a re-install with the same install
/// directory finds the existing DEK.
pub const KEYRING_SERVICE: &'static str = "chanora";
/// Construct a store rooted at `dir`. Creates the directory and
/// the DEK on first use; subsequent uses reuse the existing DEK.
pub fn new(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
let dir = dir.as_ref();
fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
let canonical = fs::canonicalize(dir)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| dir.to_string_lossy().into_owned());
let store = Self {
path: dir.join("identity.tskey"),
dek_path: dir.join("identity.dek"),
keyring_account: format!("identity-dek::{canonical}"),
};
// Ensure a DEK exists. Subsequent operations expect it.
// Ensure a DEK exists somewhere we can retrieve it.
store.ensure_dek()?;
Ok(store)
}
@@ -134,31 +159,143 @@ impl IdentityFileStore {
&self.path
}
/// Try to read the DEK from the platform keyring. Returns
/// `Ok(None)` when no entry exists or when the platform keyring
/// is unreachable (typical for headless / CI hosts). Decoding
/// errors propagate as [`StorageError::Crypto`].
///
/// Honours `CHANORA_DISABLE_KEYRING=1` for tests and headless
/// environments where a real Secret Service call would block on
/// a missing D-Bus session.
#[allow(unused_variables)]
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
if keyring_disabled() {
return Ok(None);
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))]
{
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file");
return Ok(None);
}
};
match entry.get_password() {
Ok(b64) => {
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
if bytes.len() != 32 {
return Err(StorageError::Crypto(format!(
"keyring dek length {} (expected 32)",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(Some(key))
}
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => {
// Bus unreachable, no session, locked keychain
// — best-effort: fall through to file.
warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file");
Ok(None)
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios")))]
{
Ok(None)
}
}
/// Persist the DEK in the platform keyring. Returns true on
/// success and false when the keyring is unreachable (caller
/// should then fall back to the file path).
///
/// Honours `CHANORA_DISABLE_KEYRING=1`.
#[allow(unused_variables)]
fn keyring_save(&self, key: &[u8; 32]) -> bool {
if keyring_disabled() {
return false;
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))]
{
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
Ok(e) => e,
Err(_) => return false,
};
let b64 = base64::engine::general_purpose::STANDARD.encode(key);
match entry.set_password(&b64) {
Ok(()) => {
info!(target: "chanora_storage", "DEK stored in platform keyring");
true
}
Err(e) => {
warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file");
false
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios")))]
{
false
}
}
fn ensure_dek(&self) -> Result<(), StorageError> {
if self.dek_path.exists() {
// Already in the keyring? Done.
if self.keyring_load()?.is_some() {
return Ok(());
}
// File-fallback DEK present? Try to migrate into the
// keyring opportunistically (lets users upgrade for free)
// and keep the file as the live source if migration fails.
if self.dek_path.exists() {
if let Ok(key) = read_file_dek(&self.dek_path) {
if self.keyring_save(&key) {
// Best-effort scrub: remove the file copy now
// that the keyring holds the authoritative value.
let _ = fs::remove_file(&self.dek_path);
}
let mut k = key;
k.zeroize();
}
return Ok(());
}
// Fresh install: generate a new DEK and store it in the
// keyring if we can, else fall back to the file.
let mut key = [0u8; 32];
OsRng.fill_bytes(&mut key);
{
if !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}")))?;
f.sync_all()
.map_err(|e| StorageError::Io(format!("sync dek: {e}")))?;
info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated (file fallback)");
}
key.zeroize();
info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated");
Ok(())
}
fn load_dek(&self) -> Result<[u8; 32], StorageError> {
let mut f = fs::File::open(&self.dek_path)
.map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", self.dek_path)))?;
let mut key = [0u8; 32];
f.read_exact(&mut key)
.map_err(|e| StorageError::Io(format!("read dek: {e}")))?;
Ok(key)
if let Some(k) = self.keyring_load()? {
return Ok(k);
}
read_file_dek(&self.dek_path)
}
/// Hand out a [`DekCrypto`] anchored on the same DEK that
/// encrypts the identity file. Used by [`BookmarkRepository`]
/// to encrypt server-password columns under the same per-install
/// key (MVP hardening).
pub fn crypto(&self) -> Result<impl Crypto + Clone + std::fmt::Debug + 'static, StorageError> {
Ok(DekCrypto::new(self.load_dek()?))
}
/// Read the persisted identity, if any. Transparently handles
@@ -297,6 +434,28 @@ fn is_plausibly_legacy_plaintext(buf: &[u8]) -> bool {
})
}
/// Read a 32-byte DEK from `path`. Used by the file-fallback path
/// 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)))?;
let mut key = [0u8; 32];
f.read_exact(&mut key)
.map_err(|e| StorageError::Io(format!("read dek: {e}")))?;
Ok(key)
}
/// True when `CHANORA_DISABLE_KEYRING=1` is set. Lets tests and
/// headless / sandboxed environments force the file-fallback path
/// without poking a real OS keyring (which would either prompt the
/// user or block on a missing D-Bus session).
fn keyring_disabled() -> bool {
matches!(
std::env::var("CHANORA_DISABLE_KEYRING").as_deref(),
Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes")
)
}
#[cfg(unix)]
fn open_private(p: &Path) -> Result<fs::File, StorageError> {
use std::os::unix::fs::OpenOptionsExt;
@@ -323,12 +482,102 @@ fn open_private(p: &Path) -> Result<fs::File, StorageError> {
.map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
}
/// Public abstraction over the per-install envelope-encryption
/// helper. Callers see only the encrypt/decrypt pair so the
/// concrete key material stays inside `chanora_storage`.
pub trait Crypto: Send + Sync {
/// Encrypt `plaintext` under the per-install DEK. Returns a
/// fresh `[nonce || ct || tag]` blob.
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, StorageError>;
/// Decrypt a blob previously produced by [`Self::encrypt`].
fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, StorageError>;
}
impl Crypto for DekCrypto {
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, StorageError> {
DekCrypto::encrypt(self, plaintext)
}
fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, StorageError> {
DekCrypto::decrypt(self, blob)
}
}
/// Shared envelope encryption helper used by both
/// [`IdentityFileStore`] and [`BookmarkRepository`]. Both rely on
/// the same per-install 32-byte DEK so a single keyring entry (or
/// fallback file) protects everything secret in `<storage_dir>`.
///
/// Wire format: `[12-byte nonce][AEAD ct+tag]`. Bytes are opaque to
/// callers; persist them as-is.
#[derive(Clone)]
struct DekCrypto {
key: [u8; 32],
}
impl std::fmt::Debug for DekCrypto {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// Never expose the key in Debug output.
f.write_str("DekCrypto { key: [redacted] }")
}
}
impl DekCrypto {
fn new(key: [u8; 32]) -> Self {
Self { key }
}
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, StorageError> {
let key = Key::from_slice(&self.key);
let cipher = ChaCha20Poly1305::new(key);
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ct = cipher
.encrypt(nonce, plaintext)
.map_err(|e| StorageError::Crypto(format!("encrypt: {e}")))?;
let mut out = Vec::with_capacity(12 + ct.len());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ct);
Ok(out)
}
fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, StorageError> {
if blob.len() < 12 + 16 {
return Err(StorageError::Crypto(format!(
"envelope length {} < minimum",
blob.len()
)));
}
let key = Key::from_slice(&self.key);
let cipher = ChaCha20Poly1305::new(key);
let nonce = Nonce::from_slice(&blob[..12]);
cipher
.decrypt(nonce, &blob[12..])
.map_err(|e| StorageError::Crypto(format!("decrypt: {e}")))
}
}
impl Drop for DekCrypto {
fn drop(&mut self) {
self.key.zeroize();
}
}
/// A persisted bookmark: a friendly label paired with a TS3 server
/// address and the nickname the user wants when connecting.
///
/// Bookmark rows are uniquely identified by an auto-incrementing
/// `id`. The `display_name` is purely cosmetic. `host` is the same
/// string the user would type into the connect form.
///
/// MVP hardening: when the repository is constructed via
/// [`BookmarkRepository::with_crypto`] the `password` column is
/// stored as a ChaCha20-Poly1305 envelope under the per-install
/// DEK, so a stolen SQLite file alone does not leak server
/// passwords. The plaintext column is still read for backward
/// compatibility and upgraded on the next `update()` call. Rows
/// inserted by a pre-MVP build remain readable with their plain
/// password values until upgraded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Bookmark {
/// Stable row id assigned by SQLite.
@@ -349,14 +598,35 @@ pub struct Bookmark {
/// `<storage_dir>/chanora.db`. The schema is migrated on
/// construction; failures here abort the constructor rather than
/// poisoning later calls.
///
/// MVP: when a [`Crypto`] is wired via [`Self::with_crypto`] the
/// `password` column is replaced by an encrypted `password_blob`
/// column. Legacy plaintext passwords in the `password` column are
/// still read transparently and lifted into `password_blob` on the
/// next `update()` call so existing v0.4 installs upgrade for free.
pub struct BookmarkRepository {
conn: Mutex<Connection>,
crypto: Option<Box<dyn Crypto>>,
}
impl BookmarkRepository {
/// Open or create the bookmark database under `dir`.
/// Open or create the bookmark database under `dir`. No
/// password-column encryption — equivalent to the v0.4 behaviour
/// and kept for tests.
pub fn new(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
let dir = dir.as_ref();
Self::open(dir.as_ref(), None)
}
/// Open or create the bookmark database with password-column
/// encryption wired to the per-install DEK.
pub fn with_crypto<C>(dir: impl AsRef<Path>, crypto: C) -> Result<Self, StorageError>
where
C: Crypto + 'static,
{
Self::open(dir.as_ref(), Some(Box::new(crypto) as Box<dyn Crypto>))
}
fn open(dir: &Path, crypto: Option<Box<dyn Crypto>>) -> Result<Self, StorageError> {
fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
let path = dir.join("chanora.db");
let conn = Connection::open(&path)
@@ -377,36 +647,81 @@ impl BookmarkRepository {
INSERT OR IGNORE INTO schema_version(v) VALUES (1);",
)
.map_err(|e| StorageError::Migration(format!("init schema: {e}")))?;
// Schema v2 migration: encrypted password column. Idempotent.
let has_blob: i64 = conn
.query_row(
"SELECT COUNT(*) FROM pragma_table_info('bookmarks') WHERE name='password_blob'",
[],
|r| r.get(0),
)
.map_err(|e| StorageError::Migration(format!("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}")))?;
conn.execute("INSERT OR IGNORE INTO schema_version(v) VALUES (2)", [])
.map_err(|e| StorageError::Migration(format!("bump version: {e}")))?;
info!(target: "chanora_storage", "bookmark db migrated to v2 (password_blob)");
}
info!(target: "chanora_storage", path = ?path, "bookmark db opened");
Ok(Self { conn: Mutex::new(conn) })
Ok(Self {
conn: Mutex::new(conn),
crypto,
})
}
/// True if the repository has password-column encryption wired.
pub fn encrypts_passwords(&self) -> bool {
self.crypto.is_some()
}
/// Insert a new bookmark and return its assigned id. The `id`
/// field on the input is ignored.
/// field on the input is ignored. Encrypts the password if a
/// crypto helper is wired; otherwise writes it plain.
pub fn add(&self, b: &Bookmark) -> Result<i64, StorageError> {
let conn = self
.conn
.lock()
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
let blob = match (&self.crypto, &b.password) {
(Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?),
_ => None,
};
let plain: Option<&str> = if self.crypto.is_some() {
None
} else {
b.password.as_deref()
};
conn.execute(
"INSERT INTO bookmarks (display_name, host, nickname, password) VALUES (?1, ?2, ?3, ?4)",
params![b.display_name, b.host, b.nickname, b.password],
"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}")))?;
Ok(conn.last_insert_rowid())
}
/// Replace an existing bookmark identified by `id`. Errors with
/// [`StorageError::NotFound`] if no such row exists.
/// [`StorageError::NotFound`] if no such row exists. Honours the
/// password-column encryption setting and clears the legacy
/// plaintext column so a row inserted by a pre-MVP build is
/// upgraded on the next update.
pub fn update(&self, b: &Bookmark) -> Result<(), StorageError> {
let conn = self
.conn
.lock()
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
let blob = match (&self.crypto, &b.password) {
(Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?),
_ => None,
};
let plain: Option<&str> = if self.crypto.is_some() {
None
} else {
b.password.as_deref()
};
let n = conn
.execute(
"UPDATE bookmarks SET display_name=?1, host=?2, nickname=?3, password=?4 WHERE id=?5",
params![b.display_name, b.host, b.nickname, b.password, b.id],
"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}")))?;
if n == 0 {
@@ -427,7 +742,9 @@ impl BookmarkRepository {
Ok(())
}
/// List all bookmarks ordered by id (insertion order).
/// List all bookmarks ordered by id (insertion order). Decrypts
/// `password_blob` if present; falls back to the legacy plain
/// `password` column otherwise (legacy v0.4 rows).
pub fn list(&self) -> Result<Vec<Bookmark>, StorageError> {
let conn = self
.conn
@@ -435,23 +752,45 @@ impl BookmarkRepository {
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
let mut stmt = conn
.prepare(
"SELECT id, display_name, host, nickname, password FROM bookmarks ORDER BY id",
"SELECT id, display_name, host, nickname, password, password_blob FROM bookmarks ORDER BY id",
)
.map_err(|e| StorageError::Sqlite(format!("prepare: {e}")))?;
let rows = stmt
.query_map([], |row| {
Ok(Bookmark {
id: row.get(0)?,
display_name: row.get(1)?,
host: row.get(2)?,
nickname: row.get(3)?,
password: row.get(4)?,
})
let id: i64 = row.get(0)?;
let display_name: String = row.get(1)?;
let host: String = row.get(2)?;
let nickname: String = row.get(3)?;
let plain: Option<String> = row.get(4)?;
let blob: Option<Vec<u8>> = row.get(5)?;
Ok((id, display_name, host, nickname, plain, blob))
})
.map_err(|e| StorageError::Sqlite(format!("query: {e}")))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| StorageError::Sqlite(format!("row: {e}")))?);
let (id, display_name, host, nickname, plain, blob) =
r.map_err(|e| StorageError::Sqlite(format!("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}")))?,
),
(Some(_), None) => {
// We have an encrypted blob but no key. Skip the
// password rather than panicking; the caller can
// re-enter it.
warn!(target: "chanora_storage", id, "encrypted bookmark password but crypto not wired; skipping");
None
}
(None, _) => plain,
};
out.push(Bookmark {
id,
display_name,
host,
nickname,
password,
});
}
Ok(out)
}
@@ -461,8 +800,18 @@ impl BookmarkRepository {
mod tests {
use super::*;
/// Tests always run against the file fallback. A real keyring
/// hit would either prompt the developer or block on a missing
/// D-Bus session inside CI. The override is set process-wide
/// via a module-init guard so individual `#[test]` order does
/// not matter.
fn force_keyring_off() {
std::env::set_var("CHANORA_DISABLE_KEYRING", "1");
}
#[test]
fn round_trip() {
force_keyring_off();
let tmp = tempdir();
let store = IdentityFileStore::new(&tmp).unwrap();
assert!(store.load().unwrap().is_none());
@@ -474,6 +823,7 @@ mod tests {
#[test]
fn empty_file_is_none() {
force_keyring_off();
let tmp = tempdir();
let store = IdentityFileStore::new(&tmp).unwrap();
fs::write(store.path(), " \n").unwrap();
@@ -483,6 +833,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn unix_mode_is_0600() {
force_keyring_off();
use std::os::unix::fs::PermissionsExt;
let tmp = tempdir();
let store = IdentityFileStore::new(&tmp).unwrap();
@@ -493,6 +844,7 @@ mod tests {
#[test]
fn bookmark_round_trip() {
force_keyring_off();
let tmp = tempdir();
let repo = BookmarkRepository::new(&tmp).unwrap();
assert!(repo.list().unwrap().is_empty());
@@ -513,6 +865,7 @@ mod tests {
#[test]
fn bookmark_update_and_delete() {
force_keyring_off();
let tmp = tempdir();
let repo = BookmarkRepository::new(&tmp).unwrap();
let id = repo
@@ -541,6 +894,7 @@ mod tests {
#[test]
fn bookmark_update_missing_is_notfound() {
force_keyring_off();
let tmp = tempdir();
let repo = BookmarkRepository::new(&tmp).unwrap();
let r = repo.update(&Bookmark {
@@ -555,6 +909,7 @@ mod tests {
#[test]
fn encrypted_round_trip() {
force_keyring_off();
let tmp = tempdir();
let store = IdentityFileStore::new(&tmp).unwrap();
store.save("3456V/abcdef==").unwrap();
@@ -567,6 +922,7 @@ mod tests {
#[test]
fn legacy_plaintext_is_read_and_upgraded() {
force_keyring_off();
let tmp = tempdir();
// Pre-Beta on-disk format: raw base64 with counter prefix.
let path = tmp.join("identity.tskey");
@@ -581,6 +937,85 @@ mod tests {
assert_eq!(store.load().unwrap().as_deref(), Some("9999VabcdefghIJKLmnop="));
}
#[test]
fn encrypted_bookmark_password_round_trip() {
force_keyring_off();
let tmp = tempdir();
let id_store = IdentityFileStore::new(&tmp).unwrap();
let crypto = id_store.crypto().unwrap();
let repo = BookmarkRepository::with_crypto(&tmp, crypto).unwrap();
assert!(repo.encrypts_passwords());
let id = repo
.add(&Bookmark {
id: 0,
display_name: "secret".to_string(),
host: "h".to_string(),
nickname: "n".to_string(),
password: Some("hunter2".to_string()),
})
.unwrap();
// The on-disk row must not contain the plain password.
let conn = rusqlite::Connection::open(tmp.join("chanora.db")).unwrap();
let row: (Option<String>, Option<Vec<u8>>) = conn
.query_row(
"SELECT password, password_blob FROM bookmarks WHERE id=?1",
rusqlite::params![id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert!(row.0.is_none(), "plaintext password column should be NULL");
assert!(row.1.is_some(), "password_blob should be populated");
let blob = row.1.unwrap();
assert!(!blob.windows(7).any(|w| w == b"hunter2"));
// Round-trip through the repository decrypts correctly.
let rows = repo.list().unwrap();
assert_eq!(rows[0].password.as_deref(), Some("hunter2"));
}
#[test]
fn legacy_plaintext_bookmark_is_readable_and_upgraded() {
force_keyring_off();
let tmp = tempdir();
// Pre-MVP write: opened without crypto, stores plaintext.
let legacy = BookmarkRepository::new(&tmp).unwrap();
let id = legacy
.add(&Bookmark {
id: 0,
display_name: "legacy".to_string(),
host: "h".to_string(),
nickname: "n".to_string(),
password: Some("old".to_string()),
})
.unwrap();
drop(legacy);
// MVP open: same dir, with crypto. Plain row still read.
let id_store = IdentityFileStore::new(&tmp).unwrap();
let crypto = id_store.crypto().unwrap();
let repo = BookmarkRepository::with_crypto(&tmp, crypto).unwrap();
let rows = repo.list().unwrap();
assert_eq!(rows[0].password.as_deref(), Some("old"));
// Update lifts it into password_blob and nulls the plaintext.
repo.update(&Bookmark {
id,
display_name: "legacy".to_string(),
host: "h".to_string(),
nickname: "n".to_string(),
password: Some("old".to_string()),
})
.unwrap();
let conn = rusqlite::Connection::open(tmp.join("chanora.db")).unwrap();
let row: (Option<String>, Option<Vec<u8>>) = conn
.query_row(
"SELECT password, password_blob FROM bookmarks WHERE id=?1",
rusqlite::params![id],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert!(row.0.is_none(), "plaintext should be cleared after upgrade");
assert!(row.1.is_some(), "blob should be set after upgrade");
}
fn tempdir() -> PathBuf {
let p = std::env::temp_dir()
.join("chanora_storage_test")