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

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

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

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

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

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

Flutter
-------

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

Governance
----------

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

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

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

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

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

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