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:
@@ -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 /
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user