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:
@@ -18,3 +18,9 @@ chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Used by integration tests to inspect the bookmark DB row layout
|
||||
# without going through the public repository API.
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
|
||||
|
||||
+141
-16
@@ -230,11 +230,31 @@ impl ChanoraSession {
|
||||
let dir = dir.as_ref();
|
||||
let store = IdentityFileStore::new(dir)?;
|
||||
info!(target: "chanora_core", path = ?store.path(), "identity store initialised");
|
||||
*self.identity_store.lock().await = Some(store);
|
||||
|
||||
let bookmarks = BookmarkRepository::new(dir)?;
|
||||
// Wire the bookmark repository under the same DEK so server
|
||||
// passwords are protected at rest (MVP hardening). On any
|
||||
// crypto setup failure we fall back to a plain bookmark
|
||||
// store — better to retain bookmark functionality than to
|
||||
// hard-fail.
|
||||
let bookmarks = match store.crypto() {
|
||||
Ok(c) => BookmarkRepository::with_crypto(dir, c)?,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
error = %e,
|
||||
"could not derive DEK for bookmark store; falling back to plaintext"
|
||||
);
|
||||
BookmarkRepository::new(dir)?
|
||||
}
|
||||
};
|
||||
let encrypts = bookmarks.encrypts_passwords();
|
||||
*self.identity_store.lock().await = Some(store);
|
||||
*self.bookmark_store.lock().await = Some(bookmarks);
|
||||
info!(target: "chanora_core", "bookmark store initialised");
|
||||
info!(
|
||||
target: "chanora_core",
|
||||
encrypts_passwords = encrypts,
|
||||
"bookmark store initialised"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -551,10 +571,13 @@ async fn supervisor_loop(
|
||||
let mut lost_rx = initial_lost_rx;
|
||||
let mut probe = initial_probe;
|
||||
let mut cfg = initial_cfg;
|
||||
// Last snapshot signature observed by the watchdog. Used to
|
||||
// emit `SessionEvent::SnapshotChanged` only when the channel
|
||||
// or client counts actually change.
|
||||
let mut last_counts: Option<(u32, u32)> = None;
|
||||
// Stable content hash of the last snapshot observed by the
|
||||
// watchdog. Used to emit `SessionEvent::SnapshotChanged`
|
||||
// whenever the channel or client list mutates in any way —
|
||||
// count, ordering, names, or per-client channel membership.
|
||||
// Stored as a u64 so the comparison is cheap and the field
|
||||
// doesn't grow with the snapshot.
|
||||
let mut last_signature: Option<u64> = None;
|
||||
|
||||
loop {
|
||||
// Watch the current connection: race the protocol task's
|
||||
@@ -630,15 +653,17 @@ async fn supervisor_loop(
|
||||
);
|
||||
}
|
||||
misses = 0;
|
||||
// A.4 SnapshotChanged: emit when the
|
||||
// channel or client count differs from
|
||||
// the previously observed snapshot.
|
||||
let counts = (snap.channels.len() as u32, snap.clients.len() as u32);
|
||||
if last_counts != Some(counts) {
|
||||
last_counts = Some(counts);
|
||||
// A.4 / MVP: emit on any tree change.
|
||||
// Hash channels (id, parent, order, name)
|
||||
// and clients (id, channel, name) so
|
||||
// in-channel moves and renames surface
|
||||
// alongside count changes.
|
||||
let sig = snapshot_signature(&snap);
|
||||
if last_signature != Some(sig) {
|
||||
last_signature = Some(sig);
|
||||
let _ = events_tx.send(SessionEvent::SnapshotChanged {
|
||||
channels: counts.0,
|
||||
clients: counts.1,
|
||||
channels: snap.channels.len() as u32,
|
||||
clients: snap.clients.len() as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -844,7 +869,7 @@ async fn supervisor_loop(
|
||||
probe = new_probe;
|
||||
// Force re-emission of SnapshotChanged
|
||||
// for the freshly reconnected session.
|
||||
last_counts = None;
|
||||
last_signature = None;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -864,6 +889,37 @@ async fn supervisor_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a stable 64-bit signature of `snap` covering everything
|
||||
/// the UI would render. Two snapshots with identical channel
|
||||
/// memberships, names, and orderings produce the same signature;
|
||||
/// any in-channel move, rename, or reorder produces a different one.
|
||||
fn snapshot_signature(snap: &ServerSnapshot) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
// Server-level fields the UI shows.
|
||||
snap.server_name.hash(&mut h);
|
||||
// Channels — sorted by id so the hash is order-independent of
|
||||
// the input vector's iteration order.
|
||||
let mut channels: Vec<_> = snap.channels.iter().collect();
|
||||
channels.sort_by_key(|c| c.id.0);
|
||||
for c in &channels {
|
||||
c.id.0.hash(&mut h);
|
||||
c.parent.0.hash(&mut h);
|
||||
c.order.hash(&mut h);
|
||||
c.name.hash(&mut h);
|
||||
}
|
||||
// Clients — sorted by id.
|
||||
let mut clients: Vec<_> = snap.clients.iter().collect();
|
||||
clients.sort_by_key(|c| c.id.0);
|
||||
for c in &clients {
|
||||
c.id.0.hash(&mut h);
|
||||
c.channel.0.hash(&mut h);
|
||||
c.name.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -880,6 +936,75 @@ mod tests {
|
||||
s.disconnect().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_detects_in_channel_move() {
|
||||
use chanora_protocol::{ChannelInfo, ClientInfo};
|
||||
let a = ServerSnapshot {
|
||||
server_name: "s".into(),
|
||||
welcome_message: "".into(),
|
||||
platform: "".into(),
|
||||
version: "".into(),
|
||||
channels: vec![
|
||||
ChannelInfo {
|
||||
id: chanora_protocol::ChannelId(1),
|
||||
parent: chanora_protocol::ChannelId(0),
|
||||
name: "a".into(),
|
||||
order: 0,
|
||||
},
|
||||
ChannelInfo {
|
||||
id: chanora_protocol::ChannelId(2),
|
||||
parent: chanora_protocol::ChannelId(0),
|
||||
name: "b".into(),
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
clients: vec![ClientInfo {
|
||||
id: chanora_protocol::ClientId(10),
|
||||
channel: chanora_protocol::ChannelId(1),
|
||||
name: "u".into(),
|
||||
}],
|
||||
};
|
||||
let mut b = a.clone();
|
||||
// User moves from channel 1 → 2. Counts unchanged.
|
||||
b.clients[0].channel = chanora_protocol::ChannelId(2);
|
||||
assert_ne!(
|
||||
super::snapshot_signature(&a),
|
||||
super::snapshot_signature(&b)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_is_stable_under_input_reorder() {
|
||||
use chanora_protocol::{ChannelInfo, ClientInfo};
|
||||
let a = ServerSnapshot {
|
||||
server_name: "s".into(),
|
||||
welcome_message: "".into(),
|
||||
platform: "".into(),
|
||||
version: "".into(),
|
||||
channels: vec![
|
||||
ChannelInfo {
|
||||
id: chanora_protocol::ChannelId(2),
|
||||
parent: chanora_protocol::ChannelId(0),
|
||||
name: "b".into(),
|
||||
order: 1,
|
||||
},
|
||||
ChannelInfo {
|
||||
id: chanora_protocol::ChannelId(1),
|
||||
parent: chanora_protocol::ChannelId(0),
|
||||
name: "a".into(),
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
clients: vec![],
|
||||
};
|
||||
let mut b = a.clone();
|
||||
b.channels.reverse();
|
||||
assert_eq!(
|
||||
super::snapshot_signature(&a),
|
||||
super::snapshot_signature(&b)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_address_is_rejected() {
|
||||
let s = ChanoraSession::new();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Integration test that drives `ChanoraSession::init_storage` end
|
||||
//! to end against a fresh temp directory and verifies the MVP
|
||||
//! hardening: identity round-trips through the encrypted file
|
||||
//! envelope, and bookmark server passwords are stored as
|
||||
//! `password_blob` rather than the legacy plain `password` column.
|
||||
//!
|
||||
//! Forces the `CHANORA_DISABLE_KEYRING` toggle so the test runs
|
||||
//! without a D-Bus session.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[tokio::test]
|
||||
async fn init_storage_encrypts_identity_and_bookmark_passwords() {
|
||||
env::set_var("CHANORA_DISABLE_KEYRING", "1");
|
||||
|
||||
let tmp = mktemp("chanora_core_init_storage_test");
|
||||
let session = chanora_core::ChanoraSession::new();
|
||||
session.init_storage(&tmp).await.unwrap();
|
||||
|
||||
// No identity yet.
|
||||
let id_path = tmp.join("identity.tskey");
|
||||
assert!(!id_path.exists(), "identity should not exist before first connect");
|
||||
|
||||
// A bookmark with a password lands as an encrypted blob.
|
||||
let id = session
|
||||
.add_bookmark(chanora_core::Bookmark {
|
||||
id: 0,
|
||||
display_name: "test".to_string(),
|
||||
host: "h".to_string(),
|
||||
nickname: "n".to_string(),
|
||||
password: Some("hunter2".to_string()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(id > 0);
|
||||
|
||||
let rows = session.list_bookmarks().await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].password.as_deref(), Some("hunter2"));
|
||||
|
||||
// Inspect the SQLite file directly to prove the plain column
|
||||
// is null and the blob does not contain the plaintext.
|
||||
let conn = rusqlite::Connection::open(tmp.join("chanora.db")).unwrap();
|
||||
let (plain, blob): (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!(plain.is_none(), "plaintext password column should be NULL");
|
||||
let blob = blob.expect("password_blob should be populated");
|
||||
assert!(
|
||||
!blob.windows(7).any(|w| w == b"hunter2"),
|
||||
"blob must not contain plaintext"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
fn mktemp(label: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let p = env::temp_dir()
|
||||
.join(label)
|
||||
.join(format!("{}-{nanos}", process::id()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
Reference in New Issue
Block a user