From 50768a8f48ff557006279d005d783cf3867f9004 Mon Sep 17 00:00:00 2001 From: EdisonJwa Date: Fri, 15 May 2026 02:24:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(mvp):=20v1.0.0-rc.1=20=E2=80=94=20keyring-?= =?UTF-8?q?backed=20DEK,=20encrypted=20bookmarks,=20MVP=20release-gate=20d?= =?UTF-8?q?ocs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .github/workflows/ci.yml | 6 + CHANGELOG.md | 118 +++- Cargo.lock | 90 ++- NOTICE | 91 ++-- apps/chanora_flutter/lib/l10n/app_en.arb | 10 + apps/chanora_flutter/lib/l10n/app_zh.arb | 7 + .../lib/l10n/generated/app_localizations.dart | 42 ++ .../l10n/generated/app_localizations_en.dart | 26 + .../l10n/generated/app_localizations_zh.dart | 26 + apps/chanora_flutter/lib/main.dart | 73 +++ apps/chanora_flutter/pubspec.yaml | 2 +- core/chanora_core/Cargo.toml | 6 + core/chanora_core/src/lib.rs | 157 +++++- core/chanora_core/tests/mvp_storage.rs | 74 +++ crates/chanora_protocol/src/lib.rs | 2 +- crates/chanora_storage/Cargo.toml | 10 + crates/chanora_storage/src/lib.rs | 515 ++++++++++++++++-- docs/governance/legal-review-readiness.md | 191 +++++++ docs/governance/product-decision-register.md | 1 + docs/governance/staged-release-plan.md | 111 ++++ run-chanora.sh | 28 +- 21 files changed, 1471 insertions(+), 115 deletions(-) create mode 100644 core/chanora_core/tests/mvp_storage.rs create mode 100644 docs/governance/legal-review-readiness.md create mode 100644 docs/governance/staged-release-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 732de89..6d83def 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,12 @@ jobs: - name: cargo check --workspace run: cargo check --workspace --locked - name: cargo test --workspace + env: + # Storage tests must not hit the real OS keyring on CI: + # there is no D-Bus session available and the call would + # block. The runtime code carries the same toggle for + # headless / sandboxed environments. + CHANORA_DISABLE_KEYRING: "1" run: cargo test --workspace --locked --no-fail-fast - name: cargo clippy run: cargo clippy --workspace --all-targets -- -D warnings diff --git a/CHANGELOG.md b/CHANGELOG.md index e3412c5..db8b44c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,123 @@ This project is expected to follow a Conventional Commits style workflow. ## [Unreleased] -### Added — Beta build (v0.2.0-beta.1) +## [v1.0.0-rc.1] — MVP Public release candidate + +This is the first release candidate for the MVP public release per +DEC-001 sequencing. 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`. + +### Added + +- **Platform keyring backing for the per-install Data Encryption Key.** + `chanora_storage::IdentityFileStore` now stores the 32-byte DEK in + the OS keyring (Linux Secret Service via D-Bus, macOS Keychain, + Windows Credential Manager, iOS Keychain) when one is available, + and transparently falls back to the v0.4 file-fallback at + `/identity.dek` otherwise. Existing file-fallback + installs are migrated into the keyring on first run when the + bus is reachable; the on-disk DEK copy is removed once the + keyring acknowledges the write. +- **`CHANORA_DISABLE_KEYRING=1`** environment override forces the + file-fallback path. Used by tests and headless / CI hosts where + a real keyring call would prompt the user or block on a missing + D-Bus session. +- **`chanora_storage::Crypto` trait + `IdentityFileStore::crypto()`** + give callers an envelope encrypt / decrypt pair anchored on the + per-install DEK. Used by `BookmarkRepository::with_crypto`. +- **Encrypted server passwords for bookmarks.** New `password_blob` + BLOB column (schema v2). When the bookmark repository is + constructed with a `Crypto` handle, server passwords are + ChaCha20-Poly1305-encrypted under the same per-install DEK as + the identity. Legacy `password TEXT` rows are still read for + backward compatibility and lifted into `password_blob` on the + next `update()`. `chanora_core::ChanoraSession::init_storage` + wires this automatically. +- **`SessionEvent::SnapshotChanged` now triggers on any tree + mutation**, not just count changes. The watchdog probe hashes + channels (id + parent + order + name) and clients (id + channel + + name); any change to those bits — including in-channel client + moves and channel renames — emits the event. +- **In-app About dialog** with the public product name (DEC-018), + the non-affiliation statement (DEC-019), the dual-license + declaration (DEC-020), and a pointer to the `NOTICE` file for + third-party attribution. New AppBar info icon opens the dialog. +- **`docs/governance/legal-review-readiness.md`** — the + engineering-side handoff package for the DEC-012 legal / + trademark / licensing review. +- **`docs/governance/staged-release-plan.md`** — the staging + schedule for DEC-002's five-platform MVP target. Linux + Android + sideload promote to GA on DEC-012 sign-off; Windows / macOS / + iOS gate on per-platform build host availability. + +### Changed + +- **`chanora_storage` test count rises from 8 to 10**: encrypted + bookmark password round-trip and legacy-plaintext-bookmark + upgrade scenarios. +- **`chanora_core` test count rises from 5 to 7**: signature + detects in-channel move; signature is stable under input-vector + reordering. +- **`NOTICE` refreshed** for the MVP product-code dependency set + (`chacha20poly1305`, `rand`, `zeroize`, `keyring`, + `connectivity_plus`, `path_provider`, `freezed_annotation`, the + removed `linux-keyutils` / `hound`, etc.). The PoC-era + enumeration is preserved upstream in git history. +- **`chanora_protocol::lib`** now also re-exports `ChannelId` and + `ClientId` for downstream signature / hashing helpers. + +### Fixed + +- The watchdog signature blind spot from v0.4: same-count + snapshots no longer suppress `SnapshotChanged`. + +### Security + +- DEK now sits behind the OS session lock on every supported + desktop platform; the v0.4 two-file weakness is closed for those + installs. Android, iOS, and other platforms without keyring + reach fall back to the v0.4 file model; `RISK-PoC-002` remains + open for them. + +### Notes (MVP scope honesty) + +- DEC-012 legal review has *not* been performed. `v1.0.0-rc.1` is + not the public release; it is the candidate that the review + signs off on (or rejects) before `v1.0.0` is tagged. +- Crash reporting is intentionally disabled (DEC-017). Repository + grep for `sentry|crashlytics|bugsnag` returns zero hits in the + MVP product code. +- Automatic diagnostic upload remains forbidden (DEC-016). The + user-initiated export path is the only way logs leave the + device. +- Android Keystore-backed DEK and iOS-side + `AVAudioSession.Mode.voiceChat` engagement are deferred to + v1.1. +- iOS, Windows, and macOS release binaries are not built into + `v1.0.0-rc.1`. The release page ships Linux x86_64 + Android + arm64-v8a only; the other three platforms are source-buildable + and promote per `staged-release-plan.md`. + +## [v0.4.0-beta.2] — External Beta + +Server password input, channel join via tap, self mute (mic + +speaker), master output gain slider, SQLite bookmarks with save / +connect / delete, ChaCha20-Poly1305-encrypted identity at rest +with `identity.dek` file, Android `MODE_IN_COMMUNICATION` routing, +default log filter trims `tsproto::resend` chatter, GitHub Actions +CI on every push. + +## [v0.3.0-beta.1] — Internal Beta (polish) + +A.1 cross-platform DNS, A.2 identity persistence, A.3 redacted +diagnostic export, A.4 SnapshotChanged event, A.5 mobile voice- +preset flag, A.6 reconnect supervisor with watchdog + UI banner, +A.6.1 OS connectivity signal drives reconnect timing. + +## [v0.2.0-beta.1] — Internal Beta first build + - **Voice in/out wired end-to-end through the Flutter UI.** Per DEC-001 this reaches the Internal Beta milestone. Build hash: see the diff --git a/Cargo.lock b/Cargo.lock index 0b6a5b5..2ef229b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -392,6 +392,7 @@ dependencies = [ "chanora_protocol", "chanora_state", "chanora_storage", + "rusqlite", "thiserror 2.0.18", "tokio", "tracing", @@ -433,7 +434,9 @@ dependencies = [ name = "chanora_storage" version = "0.0.1-pre" dependencies = [ + "base64", "chacha20poly1305", + "keyring", "rand 0.8.6", "rusqlite", "thiserror 2.0.18", @@ -721,6 +724,27 @@ dependencies = [ "generic-array", ] +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "delegate-attr" version = "0.3.0" @@ -1675,6 +1699,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1693,6 +1733,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libsqlite3-sys" version = "0.30.1" @@ -1704,6 +1753,16 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "litemap" version = "0.8.2" @@ -2577,7 +2636,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -2605,7 +2664,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -2679,6 +2738,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4162,6 +4234,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/NOTICE b/NOTICE index 9ab4f4d..23520c0 100644 --- a/NOTICE +++ b/NOTICE @@ -23,58 +23,63 @@ tracked by DEC-012 in docs/governance/product-decision-register.md and must complete before any public/store release. -Direct Rust dependencies of the proof-of-concept code (current as of -2026-05-14): +Direct Rust dependencies of the MVP product code (as of v1.0.0-rc.1): - * tsclientlib — MIT OR Apache-2.0 + Protocol + audio + * tsclientlib — MIT OR Apache-2.0 https://github.com/ReSpeak/tsclientlib - * flutter_rust_bridge — MIT - https://github.com/fzyzcjy/flutter_rust_bridge - * cpal — Apache-2.0 + * tsproto / tsproto-packets — MIT OR Apache-2.0 (workspace of tsclientlib) + * cpal — Apache-2.0 https://github.com/RustAudio/cpal - * rusqlite — MIT + * audiopus — MIT OR Apache-2.0 + + Bridge + framework glue + * flutter_rust_bridge — MIT + https://github.com/fzyzcjy/flutter_rust_bridge + * tokio — MIT + * futures — MIT OR Apache-2.0 + * thiserror — MIT OR Apache-2.0 + * tracing / tracing-subscriber / tracing-android — MIT + * serde — MIT OR Apache-2.0 + + Storage + secure storage + * rusqlite (bundled) — MIT https://github.com/rusqlite/rusqlite - * keyring — MIT OR Apache-2.0 + * libsqlite3-sys — MIT + * chacha20poly1305 — Apache-2.0 OR MIT + * rand — MIT OR Apache-2.0 + * zeroize — MIT OR Apache-2.0 + * base64 — MIT OR Apache-2.0 + * keyring — MIT OR Apache-2.0 https://github.com/hwchen/keyring-rs - * linux-keyutils — BSD-3-Clause - * hound — Apache-2.0 - * regex — MIT OR Apache-2.0 - * serde / serde_json — MIT OR Apache-2.0 - * tokio — MIT - * tracing / tracing-subscriber — MIT - * jni — MIT OR Apache-2.0 - * ndk-context — MIT OR Apache-2.0 - * android_logger — MIT OR Apache-2.0 - * thiserror — MIT OR Apache-2.0 - * zeroize — MIT OR Apache-2.0 - * indoc — MIT OR Apache-2.0 - * tempfile — MIT OR Apache-2.0 - * once_cell — MIT OR Apache-2.0 - * anyhow — MIT OR Apache-2.0 - * clap — MIT OR Apache-2.0 - * futures — MIT OR Apache-2.0 - * serial_test — MIT -Direct Flutter / Dart dependencies of the FRB hello PoC: + Android JNI + * jni — MIT OR Apache-2.0 + * ndk-context — MIT OR Apache-2.0 - * Flutter framework — BSD-3-Clause - * flutter_rust_bridge — MIT (Dart side mirrors the Rust side) +Direct Flutter / Dart dependencies of the MVP product code: -Direct Android dependencies of the Android audio spike: + * Flutter framework — BSD-3-Clause + * flutter_rust_bridge — MIT (Dart side mirrors the Rust side) + * connectivity_plus — BSD-3-Clause + * path_provider — BSD-3-Clause + * intl — BSD-3-Clause + * cupertino_icons — MIT + * freezed_annotation — MIT + * flutter_lints (dev) — BSD-3-Clause + * build_runner (dev) — BSD-3-Clause + +Direct Android-app dependencies (apps/chanora_flutter/android/): * androidx.core:core-ktx, androidx.appcompat:appcompat — Apache-2.0 * Android NDK r26.x runtime libraries — Apache-2.0 / per-component licences - * Kotlin stdlib — Apache-2.0 - * Gradle wrapper — Apache-2.0 + * Kotlin stdlib — Apache-2.0 + * Gradle wrapper — Apache-2.0 -This list reflects PoC code only. The product-code dependency set -(`apps/chanora_flutter/`, `crates/chanora_*`) is not yet established; -its full license inventory will be re-collected and reviewed under -DEC-012 before public release. - -Transitive dependencies are not enumerated here. A complete -machine-generated inventory must be produced by the build tooling -(e.g. `cargo about` for Rust and the Flutter LicenseRegistry for Dart) -and shipped with released artefacts. See -docs/security/dependency-and-supply-chain-report.md for the audit -record. +Transitive dependencies are not enumerated here. The full machine- +generated license inventory for a release build can be produced with +`cargo about generate` (Rust) and Flutter's `LicenseRegistry` (Dart); +that output must be shipped with each released artefact. See +docs/security/dependency-and-supply-chain-report.md for the supply- +chain audit record and docs/governance/legal-review-readiness.md for +the DEC-012 sign-off checklist. diff --git a/apps/chanora_flutter/lib/l10n/app_en.arb b/apps/chanora_flutter/lib/l10n/app_en.arb index 8f37858..80747d3 100644 --- a/apps/chanora_flutter/lib/l10n/app_en.arb +++ b/apps/chanora_flutter/lib/l10n/app_en.arb @@ -17,6 +17,16 @@ "disconnectAction": "Disconnect", "refreshAction": "Refresh", "diagnosticsAction": "Diagnostics", + "aboutAction": "About", + "aboutVersion": "Version {version}", + "@aboutVersion": { + "placeholders": { "version": { "type": "String" } } + }, + "aboutNonAffiliation": "Chanora is independent and is not affiliated with, endorsed by, sponsored by, or officially associated with TeamSpeak.", + "aboutLicenseHeading": "License", + "aboutLicenseBody": "Chanora is dual-licensed under the Apache License, Version 2.0 or the MIT License, at your option. The full license texts ship as LICENSE-APACHE and LICENSE-MIT at the repository root.", + "aboutThirdPartyHeading": "Third-party software", + "aboutThirdPartyBody": "Chanora is built on tsclientlib, flutter_rust_bridge, cpal, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.", "copyAction": "Copy", "closeAction": "Close", "startAudioAction": "Start audio", diff --git a/apps/chanora_flutter/lib/l10n/app_zh.arb b/apps/chanora_flutter/lib/l10n/app_zh.arb index 148d9af..b50af3c 100644 --- a/apps/chanora_flutter/lib/l10n/app_zh.arb +++ b/apps/chanora_flutter/lib/l10n/app_zh.arb @@ -16,6 +16,13 @@ "disconnectAction": "断开连接", "refreshAction": "刷新", "diagnosticsAction": "诊断信息", + "aboutAction": "关于", + "aboutVersion": "版本 {version}", + "aboutNonAffiliation": "Chanora 是独立项目,与 TeamSpeak 之间不存在任何附属、认可、赞助或官方关联关系。", + "aboutLicenseHeading": "许可协议", + "aboutLicenseBody": "Chanora 采用 Apache License 2.0 或 MIT License 双协议授权,使用者可任选其一。完整协议文本以 LICENSE-APACHE 与 LICENSE-MIT 形式随仓库一同分发。", + "aboutThirdPartyHeading": "第三方组件", + "aboutThirdPartyBody": "Chanora 基于 tsclientlib、flutter_rust_bridge、cpal、rusqlite、Flutter 框架等开源组件构建。完整归属信息请参阅仓库根目录的 NOTICE 文件。", "copyAction": "复制", "closeAction": "关闭", "startAudioAction": "启动语音", diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart index d091f0d..3bc0be1 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations.dart @@ -169,6 +169,48 @@ abstract class AppL10n { /// **'Diagnostics'** String get diagnosticsAction; + /// No description provided for @aboutAction. + /// + /// In en, this message translates to: + /// **'About'** + String get aboutAction; + + /// No description provided for @aboutVersion. + /// + /// In en, this message translates to: + /// **'Version {version}'** + String aboutVersion(String version); + + /// No description provided for @aboutNonAffiliation. + /// + /// In en, this message translates to: + /// **'Chanora is independent and is not affiliated with, endorsed by, sponsored by, or officially associated with TeamSpeak.'** + String get aboutNonAffiliation; + + /// No description provided for @aboutLicenseHeading. + /// + /// In en, this message translates to: + /// **'License'** + String get aboutLicenseHeading; + + /// No description provided for @aboutLicenseBody. + /// + /// In en, this message translates to: + /// **'Chanora is dual-licensed under the Apache License, Version 2.0 or the MIT License, at your option. The full license texts ship as LICENSE-APACHE and LICENSE-MIT at the repository root.'** + String get aboutLicenseBody; + + /// No description provided for @aboutThirdPartyHeading. + /// + /// In en, this message translates to: + /// **'Third-party software'** + String get aboutThirdPartyHeading; + + /// No description provided for @aboutThirdPartyBody. + /// + /// In en, this message translates to: + /// **'Chanora is built on tsclientlib, flutter_rust_bridge, cpal, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.'** + String get aboutThirdPartyBody; + /// No description provided for @copyAction. /// /// In en, this message translates to: diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart index 3780c65..0c17a20 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_en.dart @@ -46,6 +46,32 @@ class AppL10nEn extends AppL10n { @override String get diagnosticsAction => 'Diagnostics'; + @override + String get aboutAction => 'About'; + + @override + String aboutVersion(String version) { + return 'Version $version'; + } + + @override + String get aboutNonAffiliation => + 'Chanora is independent and is not affiliated with, endorsed by, sponsored by, or officially associated with TeamSpeak.'; + + @override + String get aboutLicenseHeading => 'License'; + + @override + String get aboutLicenseBody => + 'Chanora is dual-licensed under the Apache License, Version 2.0 or the MIT License, at your option. The full license texts ship as LICENSE-APACHE and LICENSE-MIT at the repository root.'; + + @override + String get aboutThirdPartyHeading => 'Third-party software'; + + @override + String get aboutThirdPartyBody => + 'Chanora is built on tsclientlib, flutter_rust_bridge, cpal, rusqlite, and the Flutter framework, among others. See the NOTICE file at the repository root for the current attribution list.'; + @override String get copyAction => 'Copy'; diff --git a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart index 4d3eac1..0f92e87 100644 --- a/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart +++ b/apps/chanora_flutter/lib/l10n/generated/app_localizations_zh.dart @@ -44,6 +44,32 @@ class AppL10nZh extends AppL10n { @override String get diagnosticsAction => '诊断信息'; + @override + String get aboutAction => '关于'; + + @override + String aboutVersion(String version) { + return '版本 $version'; + } + + @override + String get aboutNonAffiliation => + 'Chanora 是独立项目,与 TeamSpeak 之间不存在任何附属、认可、赞助或官方关联关系。'; + + @override + String get aboutLicenseHeading => '许可协议'; + + @override + String get aboutLicenseBody => + 'Chanora 采用 Apache License 2.0 或 MIT License 双协议授权,使用者可任选其一。完整协议文本以 LICENSE-APACHE 与 LICENSE-MIT 形式随仓库一同分发。'; + + @override + String get aboutThirdPartyHeading => '第三方组件'; + + @override + String get aboutThirdPartyBody => + 'Chanora 基于 tsclientlib、flutter_rust_bridge、cpal、rusqlite、Flutter 框架等开源组件构建。完整归属信息请参阅仓库根目录的 NOTICE 文件。'; + @override String get copyAction => '复制'; diff --git a/apps/chanora_flutter/lib/main.dart b/apps/chanora_flutter/lib/main.dart index ca8e8b8..8c2d38c 100644 --- a/apps/chanora_flutter/lib/main.dart +++ b/apps/chanora_flutter/lib/main.dart @@ -19,6 +19,10 @@ import 'l10n/generated/app_localizations.dart'; import 'src/rust/api.dart' as rust; import 'src/rust/frb_generated.dart'; +/// Public version string shown in the About dialog. Aligned with +/// `pubspec.yaml` and the git tag for the MVP release candidate. +const String _kAppVersion = 'v1.0.0-rc.1'; + Future main() async { WidgetsFlutterBinding.ensureInitialized(); await RustLib.init(); @@ -369,6 +373,70 @@ class _BetaHomeState extends State<_BetaHome> { ); } + Future _onShowAbout(BuildContext context) async { + // DEC-018 / DEC-019 / DEC-020 surface: public name, non- + // affiliation statement, dual-license declaration. The Flutter + // showAboutDialog widget is intentionally bare so the legal + // text comes from us, not a framework default. + final l10n = AppL10n.of(context); + final theme = Theme.of(context); + if (!mounted) return; + await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n.aboutAction), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.appTitle, + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + l10n.aboutVersion(_kAppVersion), + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 16), + Text( + l10n.aboutNonAffiliation, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + l10n.aboutLicenseHeading, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text( + l10n.aboutLicenseBody, + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 12), + Text( + l10n.aboutThirdPartyHeading, + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + Text( + l10n.aboutThirdPartyBody, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(l10n.closeAction), + ), + ], + ), + ); + } + Future _onAddCurrentBookmark() async { final l10n = AppL10n.of(context); final nameCtl = TextEditingController(text: _hostCtl.text.trim()); @@ -453,6 +521,11 @@ class _BetaHomeState extends State<_BetaHome> { appBar: AppBar( title: Text(l10n.appTitle), actions: [ + IconButton( + tooltip: l10n.aboutAction, + icon: const Icon(Icons.info_outline), + onPressed: () => _onShowAbout(context), + ), IconButton( tooltip: l10n.diagnosticsAction, icon: const Icon(Icons.bug_report_outlined), diff --git a/apps/chanora_flutter/pubspec.yaml b/apps/chanora_flutter/pubspec.yaml index 40c8b60..534a52b 100644 --- a/apps/chanora_flutter/pubspec.yaml +++ b/apps/chanora_flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.2.0+2 +version: 1.0.0-rc.1+5 environment: sdk: ^3.11.5 diff --git a/core/chanora_core/Cargo.toml b/core/chanora_core/Cargo.toml index 6fb3f4d..57b0a22 100644 --- a/core/chanora_core/Cargo.toml +++ b/core/chanora_core/Cargo.toml @@ -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"] } diff --git a/core/chanora_core/src/lib.rs b/core/chanora_core/src/lib.rs index 50e4408..2407fd4 100644 --- a/core/chanora_core/src/lib.rs +++ b/core/chanora_core/src/lib.rs @@ -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 = 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(); diff --git a/core/chanora_core/tests/mvp_storage.rs b/core/chanora_core/tests/mvp_storage.rs new file mode 100644 index 0000000..aa2790b --- /dev/null +++ b/core/chanora_core/tests/mvp_storage.rs @@ -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, Option>) = 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 +} diff --git a/crates/chanora_protocol/src/lib.rs b/crates/chanora_protocol/src/lib.rs index c1d25ec..87627d4 100644 --- a/crates/chanora_protocol/src/lib.rs +++ b/crates/chanora_protocol/src/lib.rs @@ -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 / diff --git a/crates/chanora_storage/Cargo.toml b/crates/chanora_storage/Cargo.toml index def5d64..fa565fb 100644 --- a/crates/chanora_storage/Cargo.toml +++ b/crates/chanora_storage/Cargo.toml @@ -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"] } diff --git a/crates/chanora_storage/src/lib.rs b/crates/chanora_storage/src/lib.rs index 9fd6455..0ca64d1 100644 --- a/crates/chanora_storage/src/lib.rs +++ b/crates/chanora_storage/src/lib.rs @@ -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) -> Result { 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, 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 { + 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 { use std::os::unix::fs::OpenOptionsExt; @@ -323,12 +482,102 @@ fn open_private(p: &Path) -> Result { .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, StorageError>; + /// Decrypt a blob previously produced by [`Self::encrypt`]. + fn decrypt(&self, blob: &[u8]) -> Result, StorageError>; +} + +impl Crypto for DekCrypto { + fn encrypt(&self, plaintext: &[u8]) -> Result, StorageError> { + DekCrypto::encrypt(self, plaintext) + } + fn decrypt(&self, blob: &[u8]) -> Result, 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 ``. +/// +/// 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, 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, 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 { /// `/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, + crypto: Option>, } 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) -> Result { - 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(dir: impl AsRef, crypto: C) -> Result + where + C: Crypto + 'static, + { + Self::open(dir.as_ref(), Some(Box::new(crypto) as Box)) + } + + fn open(dir: &Path, crypto: Option>) -> Result { 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 { 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, 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 = row.get(4)?; + let blob: Option> = 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, Option>) = 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, Option>) = 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") diff --git a/docs/governance/legal-review-readiness.md b/docs/governance/legal-review-readiness.md new file mode 100644 index 0000000..181600c --- /dev/null +++ b/docs/governance/legal-review-readiness.md @@ -0,0 +1,191 @@ +# Legal review readiness — DEC-012 sign-off checklist + +| Version | Date | Status | +|---|---|---| +| 0.1.0 | 2026-05-15 | Initial draft alongside v1.0.0-rc.1 | + +## Purpose + +DEC-012 in `product-decision-register.md` records the legal / +trademark / licensing review as the **only** outstanding gate before +the MVP public release per DEC-001 sequencing. That decision was +accepted as a *release gate* on 2026-05-14; the actual review work +has not been performed. + +This document is the engineering-side handoff package for that +review. It enumerates exactly what the reviewer needs to confirm, +points to the artefacts in this repository that answer each item, +and lists the work the reviewer must perform that the engineering +side cannot. + +Engineering does **not** make legal decisions. Items marked +"engineering: done" mean the underlying technical artefact is in +place; the corresponding legal confirmation is still required. + +## Scope + +Reviewer is expected to confirm or correct each of the following +before `v1.0.0-rc.1` is promoted to `v1.0.0` and any release +artefact is published publicly or to a store. + +### 1. Trademark — "Chanora" + +* **DEC-018** accepted "Chanora" as the public product name. +* Engineering: name appears in `Cargo.toml`, `pubspec.yaml`, the + About dialog, the AppBar title via the `appTitle` localisation + key, and every commit message. +* **Reviewer action**: + - Trademark registrability check in target jurisdictions (CN, US, + EU at minimum, per DEC-002 target platforms' user base). + - Confirm no conflicting registration in voice-communication + software / mobile-app categories. + - Issue go / no-go ruling. A no-go ruling triggers a rename which + invalidates `v1.0.0-rc.1` and forces a new RC. + +### 2. Non-affiliation statement — TeamSpeak + +* **DEC-019** accepted the working wording: + > Chanora is independent and is not affiliated with, endorsed by, + > sponsored by, or officially associated with TeamSpeak. +* Engineering: that exact sentence ships in: + - `NOTICE` (top of file). + - `README.md` `## License and trademark` section. + - The in-app About dialog (English: `aboutNonAffiliation` ARB key; + Chinese Simplified: `aboutNonAffiliation` in `app_zh.arb`, + translated by an engineer — translation should be reviewed for + legal precision). +* **Reviewer action**: + - Confirm the English wording is sufficient under target-market + consumer-protection and unfair-competition statutes. + - Confirm the Chinese-Simplified translation does not weaken the + statement. + - Confirm there is no remaining text anywhere in the product that + could imply affiliation (search hints: "TeamSpeak", "official", + "endorsed"). + +### 3. Trademark usage — "TeamSpeak" + +The product documentation and UI strings reference "TeamSpeak" in +several places where we describe interoperability (e.g. +"TeamSpeak-compatible servers"). This is nominative use. + +* **Reviewer action**: + - Confirm each occurrence of "TeamSpeak" in user-facing strings, + documentation, and store metadata is permissible nominative + use under target-jurisdiction trademark law. + - Recommend a `™` or `®` symbol convention if required. + +### 4. License posture — Chanora's own code + +* **DEC-020** accepted dual-license **Apache-2.0 OR MIT**. +* Engineering: the texts ship as `LICENSE-APACHE` and `LICENSE-MIT` + at the repository root; the aggregator `LICENSE` references both. + Cargo-level package manifests carry `license.workspace = true` + pointing to `Apache-2.0 OR MIT` in the workspace `Cargo.toml`. +* **Reviewer action**: + - Confirm the dual-license declaration is consistent with all + contributor agreements (none in place yet — see open items). + - Confirm `LICENSE` file contents satisfy each app store's source- + code-availability and inbound-license requirements. + +### 5. Third-party license posture — direct dependencies + +* `NOTICE` enumerates the direct dependency list as of v1.0.0-rc.1. +* Each direct dependency is permissively licensed + (`MIT`, `Apache-2.0`, `MIT OR Apache-2.0`, `BSD-3-Clause`). + No GPL / LGPL / AGPL surfaces in the direct set. +* **Reviewer action**: + - Confirm the `NOTICE` enumeration matches what the build tooling + actually links (the audit must be repeated against a `cargo + about generate --workspace` output and a Flutter + `LicenseRegistry` dump as of the release build). + - Confirm each direct dependency's attribution obligations are + satisfied (Apache-2.0 requires a copy of the license text, the + NOTICE entry, and a list of changes in any modified copies). + - Confirm no copyleft transitive dependency creeps in via + `tsclientlib` or `cpal`. The most likely failure mode is a + crypto / DSP subdep with LGPL coverage; `cargo deny` config + should refuse those. + +### 6. `tsclientlib` posture specifically + +The project pins `tsclientlib` to a specific commit +(`04aa249` on `https://github.com/ReSpeak/tsclientlib`). The crate is +upstream-licensed `MIT OR Apache-2.0`. It implements the +TeamSpeak 3 protocol from publicly observed behaviour, not from +TeamSpeak proprietary sources. + +* **Reviewer action**: + - Confirm linking against `tsclientlib` does not by itself create + a derivative-work obligation under TeamSpeak's own licenses or + EULAs. + - Confirm using `tsclientlib` to talk to third-party + TeamSpeak-protocol servers does not create a trademark or + contract-tort exposure. + +### 7. Crypto + secure-storage compliance + +* `chacha20poly1305` (Apache-2.0 OR MIT) provides envelope + encryption for the identity at rest and bookmark passwords. +* `keyring` (Apache-2.0 OR MIT) hits the platform Secret Service / + Keychain / Credential Manager for the DEK. +* No symmetric or asymmetric primitive other than the above is + introduced by Chanora's own code; `tsclientlib` carries its own + protocol-level crypto. +* **Reviewer action**: + - Confirm export-control posture for the resulting binary + (cryptography category determination, ECCN, any EAR self- + classification needed for store distribution). + - Confirm any privacy-statement updates required by jurisdictions + that treat persistent device identifiers as personal data. + +### 8. Data handling — DEC-016 / DEC-017 + +* **DEC-016** No automatic diagnostic upload. The `export_diagnostics` + bridge function is invoked only on user action and the redacted + output is local-only (Clipboard or share-sheet). +* **DEC-017** Crash reporting disabled. Repository grep for + `sentry|crashlytics|bugsnag` returns zero hits as of v1.0.0-rc.1. +* Engineering: diagnostics redaction is enforced at write-time by + the in-bridge `RedactingLogLayer`; tests + `chanora_diagnostics::tests::*` cover the policy. +* **Reviewer action**: + - Confirm privacy policy text aligns: no telemetry, no automatic + upload, no crash reporting in MVP. + - Confirm app-store privacy-label entries are consistent. + +### 9. Store-listing copy + +Out of scope for engineering; reviewer drafts and validates per +DEC-002 staged platform list: + + - Google Play Store (Android arm64-v8a) + - Apple App Store (iOS, MVP gate) + - Microsoft Store / direct (Windows) + - Mac App Store / direct (macOS) + - Linux (direct distribution; no store) + +## Open engineering work blocking sign-off + +These are concrete items that engineering must close before the +reviewer's work can complete. They do **not** require legal input +themselves — they are listed here so the reviewer's scope is clear. + +1. Produce a `cargo about generate --workspace` output checked into + `docs/security/`. +2. Produce a Flutter `LicenseRegistry` dump for the release build + checked into the same path. +3. Wire `cargo deny check licenses` into CI with a deny-list of + GPL / LGPL / AGPL / commercial source licenses. +4. Confirm iOS and macOS build artefacts can ship (DEC-002 staged + release allows deferring these; today neither has a live build). + +## Out-of-scope + +The following are *not* part of DEC-012 and have their own owners +and decisions: + +* Cryptographic primitive selection (`chacha20poly1305`, key sizes, + KDF choice) — Security Architect; closed by DEC-013.2. +* Codec choice (Opus) — Software Architect. +* TLS / connection security — falls inside `tsclientlib`. diff --git a/docs/governance/product-decision-register.md b/docs/governance/product-decision-register.md index 2c0b0a9..c90a36f 100644 --- a/docs/governance/product-decision-register.md +++ b/docs/governance/product-decision-register.md @@ -196,3 +196,4 @@ release but is not an open decision: | Version | Date | Description | |---|---|---| | 0.9.7 | 2026-05-14 | DEC-001 release-sequence progress recorded: Internal Alpha (`v0.1.0-alpha.1`, commit 3bb038c) completed on 2026-05-14; **Internal Beta first build (`v0.2.0-beta.1`)** reached the same day. Beta milestone adds voice in/out: `crates/chanora_audio` promoted from scaffold to a cpal-based capture + playback engine with `audiopus` Opus encoding and tsclientlib `AudioHandler` for decode + jitter buffer + mix; `crates/chanora_protocol` extended to forward inbound voice packets and accept outbound `OutPacket`s via mpsc channels; `core/chanora_core::ChanoraSession` exposes `start_audio`, `set_ptt`, and `audio_stats`; `crates/chanora_bridge` adds matching DTOs; the Flutter UI gains a "Start audio" action and a hold-to-talk PTT button with live frame counters. Verified end-to-end against `cn.teamspeak.app`; capture runs in graceful playback-only mode on hosts with no usable microphone (e.g. the PipeWire `auto_null` source on the verification host). No decision rows change; this entry documents progress against DEC-001 only. | +| 0.9.8 | 2026-05-15 | DEC-001 release-sequence progress recorded for the polished Internal Beta and the External Beta milestones, plus the first MVP-public release candidate. **`v0.3.0-beta.1`** ("Internal Beta polish") added the supervisor + reconnect-with-watchdog path (A.6), OS-connectivity-aware backoff (A.6.1), persistent identity at rest as a plain 0600 file (A.2), the redacted in-memory log sink + user-initiated diagnostic export per DEC-016 (A.3), the `SnapshotChanged` lifecycle event for UI auto-refresh (A.4), and the `mobile_voice_preset` config-surface plumb-through (A.5). **`v0.4.0-beta.2`** ("External Beta") added the server-password input, channel join via tap, self mute (input + output), master output gain, SQLite-backed bookmark list, ChaCha20-Poly1305 encryption of the identity at rest with the DEK in a separate `identity.dek` file, Android `AudioManager.setMode(MODE_IN_COMMUNICATION)` routing engagement via JNI, and the `.github/workflows/ci.yml` pipeline. **`v1.0.0-rc.1`** ("MVP Public release candidate") closes the v0.4 DEK-on-disk weakness on every keyring-reachable platform: `chanora_storage::IdentityFileStore` now stores the DEK in the OS keyring (Linux Secret Service via D-Bus / macOS Keychain / Windows Credential Manager / iOS Keychain via the `keyring` crate) and migrates pre-existing file-fallback installs into the keyring opportunistically; bookmark server passwords are ChaCha20-Poly1305-encrypted under the same per-install DEK and the legacy plain `password TEXT` column is upgraded into a new `password_blob BLOB` column on the next `update()`; `SessionEvent::SnapshotChanged` now fires on any tree mutation (the in-channel-move blind spot from A.4 is closed); the in-app About dialog surfaces DEC-018 / DEC-019 / DEC-020. New `docs/governance/legal-review-readiness.md` carries the DEC-012 handoff package (trademark check, non-affiliation wording, third-party license posture, `cargo about` deliverables, `cargo deny` lifelines); new `docs/governance/staged-release-plan.md` enumerates the DEC-002 platform staging (Linux + Android sideload GA on DEC-012 sign-off; Windows, macOS, iOS gate on per-platform signed-build availability). No decision rows change; DEC-012 remains the sole outstanding release gate. | diff --git a/docs/governance/staged-release-plan.md b/docs/governance/staged-release-plan.md new file mode 100644 index 0000000..3504332 --- /dev/null +++ b/docs/governance/staged-release-plan.md @@ -0,0 +1,111 @@ +# Staged MVP release plan — DEC-002 channels + +| Version | Date | Status | +|---|---|---| +| 0.1.0 | 2026-05-15 | Initial draft alongside v1.0.0-rc.1 | + +## Purpose + +DEC-002 in `product-decision-register.md` accepts a five-platform +MVP target (Windows, macOS, Linux, Android, iOS) and explicitly +permits a **staged release** in which not all platforms ship on the +same day. This document records the staging plan for the MVP public +release. + +## Channel definitions + +* **GA — Linux desktop**: x86_64 GNU/Linux. Direct distribution + via the GitHub Releases page (release tarball + AppImage when + the bundle is added). No app store. +* **GA — Android**: arm64-v8a APK. Direct distribution from the + Releases page during early MVP. Play Store submission deferred + until DEC-012 legal sign-off includes Play-specific consumer- + protection review (`docs/governance/legal-review-readiness.md` + §9). Other Android ABIs (armeabi-v7a, x86_64) are deferred to + v1.1. +* **Beta — Windows**: x86_64 MSI / portable zip. The build steps + are documented in `docs/release/windows-build.md`; a live + artefact has not yet been produced because the development + toolchain ran on Linux. Public bits land once a Windows builder + produces a signed artefact. +* **Beta — macOS**: Apple Silicon (`aarch64-apple-darwin`) + Intel + (`x86_64-apple-darwin`) universal `.app`. Same gating as Windows: + build documentation only at v1.0.0-rc.1; an Apple-side builder + must produce a notarised artefact. +* **Beta — iOS**: arm64 IPA. Same gating; in addition the + `mobile_voice_preset` AAudio engagement on Android is documented + as a routing hint, and the iOS equivalent + (`AVAudioSession.Mode.voiceChat`) is not yet wired. + +## Promotion schedule + +Each row triggers when the listed prerequisite is satisfied. There +is no calendar. + +| Order | Channel | Prerequisite | +|---|---|---| +| 1 | Linux GA | DEC-012 legal sign-off; `v1.0.0` tag cut from `v1.0.0-rc.N` after all RC reviews close. | +| 2 | Android GA (sideload) | Same prerequisite; APK signed with the project key; Releases page updated. | +| 3 | Android Play Store | DEC-012 §9 Play-listing review complete; signing key migrated to Play App Signing if not already there. | +| 4 | Windows Beta | Live x86_64 Windows builder produces a signed MSI; smoke test on a Windows 11 host that the build host can verify. | +| 5 | macOS Beta | Live `aarch64-apple-darwin` build with code-signing certificate and notarisation; smoke test. | +| 6 | iOS Beta | TestFlight build cycle; per-device entitlement review; mic permission flow validated. | +| 7 | Windows / macOS / iOS GA | Each platform promoted from Beta after its first round of public-Beta feedback is triaged. | + +## Per-platform readiness as of v1.0.0-rc.1 + +### Linux x86_64 + +* Cargo build: green (`cargo check --workspace`, `cargo test + --workspace` with `CHANORA_DISABLE_KEYRING=1`). +* Flutter build: `flutter build linux --release` documented; live + bundle exists at `apps/chanora_flutter/build/linux/x64/`. +* Audio: cpal-on-PipeWire / ALSA verified through Beta lifecycle. +* Secure storage: Secret Service via D-Bus when a session is + available; file-fallback when not. +* Status: **GA-ready** pending DEC-012. + +### Android arm64-v8a + +* Cargo + cargo-ndk build: green; the APK pipeline produced + `app-release.apk` for v0.4.0-beta.2. +* Audio: cpal-on-AAudio with `AudioManager.MODE_IN_COMMUNICATION` + engaged via JNI on engine start. +* Secure storage: keyring crate falls back to file on Android in + v1.0.0-rc.1 (no Android Keystore wiring yet). The identity and + bookmark passwords remain ChaCha20-Poly1305-encrypted under the + file-DEK in this configuration. +* Status: **GA-ready for sideload** pending DEC-012; Play Store + promotion is a separate gate. + +### Windows / macOS / iOS + +* Build docs exist (`docs/release/windows-build.md`, + `docs/release/ios-build.md`); macOS does not have a dedicated + doc yet because the build is a straightforward + `flutter build macos` once Apple-side signing is configured. +* Status: **Beta-track**. The RC tag still ships for these + platforms in source form (anyone with the appropriate toolchain + can build), but no signed binary is included. + +## Rollback policy + +Each platform's Releases-page artefact carries the exact `v1.0.0-rc.N` +or `v1.0.0` tag. If a critical regression surfaces in a channel: + +1. Mark the affected Releases asset "deprecated — do not download" in + the GitHub UI within 24h. +2. Cut a `v1.0.x` patch from the `release-1.0` branch (created + when `v1.0.0` is tagged). +3. Re-promote per the schedule above; no platform fast-tracks the + schedule, no platform skips a channel. + +## Out-of-scope for MVP + +* iOS hardware AEC engagement (`AVAudioSession.Mode.voiceChat`). +* Android `setInputPreset(VOICE_COMMUNICATION)` (RISK-AUDIO-MOBILE-001). +* Android Keystore-backed DEK (the file-fallback path is exercised in + the current build). +* Multi-server connection (DEC-006: explicitly out of MVP). +* Crash reporting (DEC-017: explicitly disabled). +* Automatic diagnostic upload (DEC-016: user-initiated only). diff --git a/run-chanora.sh b/run-chanora.sh index 49e211d..15dad59 100755 --- a/run-chanora.sh +++ b/run-chanora.sh @@ -1,26 +1,32 @@ #!/usr/bin/env bash -# Launch the Chanora v0.2.0-beta.1 desktop build. +# Launch a locally-built Chanora desktop bundle. # -# The Flutter app expects libchanora_bridge.so on the dynamic linker -# search path. The bundle's lib/ already contains it, so we add that -# directory to LD_LIBRARY_PATH and exec the binary. +# Set CHANORA_BUNDLE_FLAVOUR=debug to point at the debug bundle, +# anything else (or unset) selects the release bundle. The Flutter +# app expects libchanora_bridge.so on the dynamic linker search +# path; this script copies the latest release cdylib into the +# bundle's lib/ if it isn't there yet. set -euo pipefail HERE="$(cd "$(dirname "$0")" && pwd)" -BUNDLE="$HERE/apps/chanora_flutter/build/linux/x64/debug/bundle" +FLAVOUR="${CHANORA_BUNDLE_FLAVOUR:-release}" +BUNDLE="$HERE/apps/chanora_flutter/build/linux/x64/$FLAVOUR/bundle" if [[ ! -x "$BUNDLE/chanora_flutter" ]]; then echo "error: Flutter bundle not built. Run:" >&2 - echo " (cd apps/chanora_flutter && flutter build linux --debug)" >&2 - echo " cp target/release/libchanora_bridge.so $BUNDLE/lib/" >&2 + echo " (cd apps/chanora_flutter && flutter build linux --$FLAVOUR)" >&2 exit 1 fi if [[ ! -e "$BUNDLE/lib/libchanora_bridge.so" ]]; then - echo "error: libchanora_bridge.so missing from $BUNDLE/lib/." >&2 - echo "Run: cargo build --release -p chanora_bridge" >&2 - echo "Then: cp target/release/libchanora_bridge.so $BUNDLE/lib/" >&2 - exit 1 + if [[ -e "$HERE/target/$FLAVOUR/libchanora_bridge.so" ]]; then + cp "$HERE/target/$FLAVOUR/libchanora_bridge.so" "$BUNDLE/lib/" + else + echo "error: libchanora_bridge.so missing from $BUNDLE/lib/." >&2 + echo "Run: cargo build --$FLAVOUR -p chanora_bridge" >&2 + echo "Then: cp target/$FLAVOUR/libchanora_bridge.so $BUNDLE/lib/" >&2 + exit 1 + fi fi export LD_LIBRARY_PATH="$BUNDLE/lib:${LD_LIBRARY_PATH:-}"