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:
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => '复制';
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,3 +18,9 @@ chanora_diagnostics = { path = "../../crates/chanora_diagnostics" }
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio = { version = "1", features = ["sync", "rt", "macros"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# Used by integration tests to inspect the bookmark DB row layout
|
||||
# without going through the public repository API.
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
tokio = { version = "1", features = ["macros", "rt", "rt-multi-thread"] }
|
||||
|
||||
+141
-16
@@ -230,11 +230,31 @@ impl ChanoraSession {
|
||||
let dir = dir.as_ref();
|
||||
let store = IdentityFileStore::new(dir)?;
|
||||
info!(target: "chanora_core", path = ?store.path(), "identity store initialised");
|
||||
*self.identity_store.lock().await = Some(store);
|
||||
|
||||
let bookmarks = BookmarkRepository::new(dir)?;
|
||||
// Wire the bookmark repository under the same DEK so server
|
||||
// passwords are protected at rest (MVP hardening). On any
|
||||
// crypto setup failure we fall back to a plain bookmark
|
||||
// store — better to retain bookmark functionality than to
|
||||
// hard-fail.
|
||||
let bookmarks = match store.crypto() {
|
||||
Ok(c) => BookmarkRepository::with_crypto(dir, c)?,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
target: "chanora_core",
|
||||
error = %e,
|
||||
"could not derive DEK for bookmark store; falling back to plaintext"
|
||||
);
|
||||
BookmarkRepository::new(dir)?
|
||||
}
|
||||
};
|
||||
let encrypts = bookmarks.encrypts_passwords();
|
||||
*self.identity_store.lock().await = Some(store);
|
||||
*self.bookmark_store.lock().await = Some(bookmarks);
|
||||
info!(target: "chanora_core", "bookmark store initialised");
|
||||
info!(
|
||||
target: "chanora_core",
|
||||
encrypts_passwords = encrypts,
|
||||
"bookmark store initialised"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -551,10 +571,13 @@ async fn supervisor_loop(
|
||||
let mut lost_rx = initial_lost_rx;
|
||||
let mut probe = initial_probe;
|
||||
let mut cfg = initial_cfg;
|
||||
// Last snapshot signature observed by the watchdog. Used to
|
||||
// emit `SessionEvent::SnapshotChanged` only when the channel
|
||||
// or client counts actually change.
|
||||
let mut last_counts: Option<(u32, u32)> = None;
|
||||
// Stable content hash of the last snapshot observed by the
|
||||
// watchdog. Used to emit `SessionEvent::SnapshotChanged`
|
||||
// whenever the channel or client list mutates in any way —
|
||||
// count, ordering, names, or per-client channel membership.
|
||||
// Stored as a u64 so the comparison is cheap and the field
|
||||
// doesn't grow with the snapshot.
|
||||
let mut last_signature: Option<u64> = None;
|
||||
|
||||
loop {
|
||||
// Watch the current connection: race the protocol task's
|
||||
@@ -630,15 +653,17 @@ async fn supervisor_loop(
|
||||
);
|
||||
}
|
||||
misses = 0;
|
||||
// A.4 SnapshotChanged: emit when the
|
||||
// channel or client count differs from
|
||||
// the previously observed snapshot.
|
||||
let counts = (snap.channels.len() as u32, snap.clients.len() as u32);
|
||||
if last_counts != Some(counts) {
|
||||
last_counts = Some(counts);
|
||||
// A.4 / MVP: emit on any tree change.
|
||||
// Hash channels (id, parent, order, name)
|
||||
// and clients (id, channel, name) so
|
||||
// in-channel moves and renames surface
|
||||
// alongside count changes.
|
||||
let sig = snapshot_signature(&snap);
|
||||
if last_signature != Some(sig) {
|
||||
last_signature = Some(sig);
|
||||
let _ = events_tx.send(SessionEvent::SnapshotChanged {
|
||||
channels: counts.0,
|
||||
clients: counts.1,
|
||||
channels: snap.channels.len() as u32,
|
||||
clients: snap.clients.len() as u32,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -844,7 +869,7 @@ async fn supervisor_loop(
|
||||
probe = new_probe;
|
||||
// Force re-emission of SnapshotChanged
|
||||
// for the freshly reconnected session.
|
||||
last_counts = None;
|
||||
last_signature = None;
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -864,6 +889,37 @@ async fn supervisor_loop(
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a stable 64-bit signature of `snap` covering everything
|
||||
/// the UI would render. Two snapshots with identical channel
|
||||
/// memberships, names, and orderings produce the same signature;
|
||||
/// any in-channel move, rename, or reorder produces a different one.
|
||||
fn snapshot_signature(snap: &ServerSnapshot) -> u64 {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
// Server-level fields the UI shows.
|
||||
snap.server_name.hash(&mut h);
|
||||
// Channels — sorted by id so the hash is order-independent of
|
||||
// the input vector's iteration order.
|
||||
let mut channels: Vec<_> = snap.channels.iter().collect();
|
||||
channels.sort_by_key(|c| c.id.0);
|
||||
for c in &channels {
|
||||
c.id.0.hash(&mut h);
|
||||
c.parent.0.hash(&mut h);
|
||||
c.order.hash(&mut h);
|
||||
c.name.hash(&mut h);
|
||||
}
|
||||
// Clients — sorted by id.
|
||||
let mut clients: Vec<_> = snap.clients.iter().collect();
|
||||
clients.sort_by_key(|c| c.id.0);
|
||||
for c in &clients {
|
||||
c.id.0.hash(&mut h);
|
||||
c.channel.0.hash(&mut h);
|
||||
c.name.hash(&mut h);
|
||||
}
|
||||
h.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -880,6 +936,75 @@ mod tests {
|
||||
s.disconnect().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_detects_in_channel_move() {
|
||||
use chanora_protocol::{ChannelInfo, ClientInfo};
|
||||
let a = ServerSnapshot {
|
||||
server_name: "s".into(),
|
||||
welcome_message: "".into(),
|
||||
platform: "".into(),
|
||||
version: "".into(),
|
||||
channels: vec![
|
||||
ChannelInfo {
|
||||
id: chanora_protocol::ChannelId(1),
|
||||
parent: chanora_protocol::ChannelId(0),
|
||||
name: "a".into(),
|
||||
order: 0,
|
||||
},
|
||||
ChannelInfo {
|
||||
id: chanora_protocol::ChannelId(2),
|
||||
parent: chanora_protocol::ChannelId(0),
|
||||
name: "b".into(),
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
clients: vec![ClientInfo {
|
||||
id: chanora_protocol::ClientId(10),
|
||||
channel: chanora_protocol::ChannelId(1),
|
||||
name: "u".into(),
|
||||
}],
|
||||
};
|
||||
let mut b = a.clone();
|
||||
// User moves from channel 1 → 2. Counts unchanged.
|
||||
b.clients[0].channel = chanora_protocol::ChannelId(2);
|
||||
assert_ne!(
|
||||
super::snapshot_signature(&a),
|
||||
super::snapshot_signature(&b)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_is_stable_under_input_reorder() {
|
||||
use chanora_protocol::{ChannelInfo, ClientInfo};
|
||||
let a = ServerSnapshot {
|
||||
server_name: "s".into(),
|
||||
welcome_message: "".into(),
|
||||
platform: "".into(),
|
||||
version: "".into(),
|
||||
channels: vec![
|
||||
ChannelInfo {
|
||||
id: chanora_protocol::ChannelId(2),
|
||||
parent: chanora_protocol::ChannelId(0),
|
||||
name: "b".into(),
|
||||
order: 1,
|
||||
},
|
||||
ChannelInfo {
|
||||
id: chanora_protocol::ChannelId(1),
|
||||
parent: chanora_protocol::ChannelId(0),
|
||||
name: "a".into(),
|
||||
order: 0,
|
||||
},
|
||||
],
|
||||
clients: vec![],
|
||||
};
|
||||
let mut b = a.clone();
|
||||
b.channels.reverse();
|
||||
assert_eq!(
|
||||
super::snapshot_signature(&a),
|
||||
super::snapshot_signature(&b)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_address_is_rejected() {
|
||||
let s = ChanoraSession::new();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! Integration test that drives `ChanoraSession::init_storage` end
|
||||
//! to end against a fresh temp directory and verifies the MVP
|
||||
//! hardening: identity round-trips through the encrypted file
|
||||
//! envelope, and bookmark server passwords are stored as
|
||||
//! `password_blob` rather than the legacy plain `password` column.
|
||||
//!
|
||||
//! Forces the `CHANORA_DISABLE_KEYRING` toggle so the test runs
|
||||
//! without a D-Bus session.
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[tokio::test]
|
||||
async fn init_storage_encrypts_identity_and_bookmark_passwords() {
|
||||
env::set_var("CHANORA_DISABLE_KEYRING", "1");
|
||||
|
||||
let tmp = mktemp("chanora_core_init_storage_test");
|
||||
let session = chanora_core::ChanoraSession::new();
|
||||
session.init_storage(&tmp).await.unwrap();
|
||||
|
||||
// No identity yet.
|
||||
let id_path = tmp.join("identity.tskey");
|
||||
assert!(!id_path.exists(), "identity should not exist before first connect");
|
||||
|
||||
// A bookmark with a password lands as an encrypted blob.
|
||||
let id = session
|
||||
.add_bookmark(chanora_core::Bookmark {
|
||||
id: 0,
|
||||
display_name: "test".to_string(),
|
||||
host: "h".to_string(),
|
||||
nickname: "n".to_string(),
|
||||
password: Some("hunter2".to_string()),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(id > 0);
|
||||
|
||||
let rows = session.list_bookmarks().await.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].password.as_deref(), Some("hunter2"));
|
||||
|
||||
// Inspect the SQLite file directly to prove the plain column
|
||||
// is null and the blob does not contain the plaintext.
|
||||
let conn = rusqlite::Connection::open(tmp.join("chanora.db")).unwrap();
|
||||
let (plain, blob): (Option<String>, Option<Vec<u8>>) = conn
|
||||
.query_row(
|
||||
"SELECT password, password_blob FROM bookmarks WHERE id=?1",
|
||||
rusqlite::params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(plain.is_none(), "plaintext password column should be NULL");
|
||||
let blob = blob.expect("password_blob should be populated");
|
||||
assert!(
|
||||
!blob.windows(7).any(|w| w == b"hunter2"),
|
||||
"blob must not contain plaintext"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
fn mktemp(label: &str) -> PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let p = env::temp_dir()
|
||||
.join(label)
|
||||
.join(format!("{}-{nanos}", process::id()));
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
@@ -41,7 +41,7 @@ mod dto;
|
||||
mod resolver;
|
||||
|
||||
pub use adapter::{ConnectConfig, DisconnectReason, InboundVoice, ProtocolClient, SnapshotProbe};
|
||||
pub use dto::{ChannelInfo, ClientInfo, ServerSnapshot};
|
||||
pub use dto::{ChannelId, ChannelInfo, ClientId, ClientInfo, ServerSnapshot};
|
||||
|
||||
// Re-export the upstream voice types so chanora_audio can build outbound
|
||||
// voice packets without taking a direct dependency on tsclientlib /
|
||||
|
||||
@@ -16,3 +16,13 @@ rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
chacha20poly1305 = "0.10"
|
||||
rand = "0.8"
|
||||
zeroize = "1"
|
||||
# `base64` is needed to serialise the DEK as a string for the
|
||||
# keyring API (which is text-only on most platforms).
|
||||
base64 = "0.22"
|
||||
|
||||
# Platform keyring abstraction: Secret Service / kernel keyutils on
|
||||
# Linux (DEC-013.2); macOS Keychain; Windows Credential Manager;
|
||||
# iOS Keychain; on Android the keyring crate falls back to the
|
||||
# in-memory provider, so we keep a file-on-disk fallback there.
|
||||
[target.'cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))'.dependencies]
|
||||
keyring = { version = "3", default-features = false, features = ["sync-secret-service", "linux-native", "apple-native", "windows-native"] }
|
||||
|
||||
@@ -90,19 +90,31 @@ pub trait SecretStorageRepository: Send + Sync {}
|
||||
|
||||
/// Beta identity store: a single ChaCha20-Poly1305-encrypted file
|
||||
/// containing the base64 TS3 identity string. The Data Encryption
|
||||
/// Key (DEK) is 32 random bytes stored alongside in a separate
|
||||
/// `identity.dek` file with the same 0600 permissions on Unix.
|
||||
/// Key (DEK) is 32 random bytes stored in the platform keyring
|
||||
/// (Linux Secret Service via D-Bus, macOS Keychain, Windows
|
||||
/// Credential Manager, iOS Keychain) when available, with a
|
||||
/// best-effort file fallback at `identity.dek` (mode 0600) when the
|
||||
/// keyring is unreachable. Per DEC-013.2 the Linux preference is
|
||||
/// Secret Service; the kernel keyutils fallback is not yet wired —
|
||||
/// see the keyring-error log line on first run when the bus is
|
||||
/// missing.
|
||||
///
|
||||
/// The dual-file layout means an attacker who recovers either file
|
||||
/// alone can't decrypt the identity. The honest threat model:
|
||||
/// Threat-model honest assessment:
|
||||
///
|
||||
/// * **Helps against** stale backups, casual filesystem snooping
|
||||
/// that grabs one file but not the other, and accidental leaks
|
||||
/// to diagnostic exports (the ciphertext is never logged).
|
||||
/// * **Does NOT help against** a full app-private storage dump (an
|
||||
/// attacker who can read one file in the directory can read both).
|
||||
/// The v0.4 storage rework lands proper OS-keyring backing for the
|
||||
/// DEK so this two-file weakness is closed.
|
||||
/// * **Keyring path** (the normal case on a logged-in desktop): the
|
||||
/// DEK lives in the OS keyring, locked under the user's session.
|
||||
/// Recovering the identity then requires both the on-disk
|
||||
/// ciphertext *and* the active user session — a meaningful
|
||||
/// improvement over the file-fallback two-file model.
|
||||
/// * **File-fallback path** (headless servers, CI containers,
|
||||
/// Android-without-Keystore, fresh installs where the keyring
|
||||
/// isn't running yet): same dual-file guarantee as v0.4 — both
|
||||
/// files in the install dir must be readable to recover the
|
||||
/// identity.
|
||||
/// * **Does NOT help against** a malicious user inside the same
|
||||
/// session (keyring lookup succeeds for any process the user
|
||||
/// runs); v1.1+ may pursue per-process scoping where the OS
|
||||
/// supports it.
|
||||
///
|
||||
/// File format: `identity.tskey` = `[12-byte nonce][AEAD ciphertext+tag]`.
|
||||
/// Legacy plaintext files written by v0.3 are still readable; the
|
||||
@@ -112,19 +124,32 @@ pub trait SecretStorageRepository: Send + Sync {}
|
||||
pub struct IdentityFileStore {
|
||||
path: PathBuf,
|
||||
dek_path: PathBuf,
|
||||
/// Stable per-install identifier used as the keyring account
|
||||
/// name. Derived from the install directory so the same store
|
||||
/// finds the same keyring entry across restarts.
|
||||
keyring_account: String,
|
||||
}
|
||||
|
||||
impl IdentityFileStore {
|
||||
/// Service name used for the platform keyring entry. Kept
|
||||
/// short and stable so a re-install with the same install
|
||||
/// directory finds the existing DEK.
|
||||
pub const KEYRING_SERVICE: &'static str = "chanora";
|
||||
|
||||
/// Construct a store rooted at `dir`. Creates the directory and
|
||||
/// the DEK on first use; subsequent uses reuse the existing DEK.
|
||||
pub fn new(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
|
||||
let dir = dir.as_ref();
|
||||
fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
|
||||
let canonical = fs::canonicalize(dir)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| dir.to_string_lossy().into_owned());
|
||||
let store = Self {
|
||||
path: dir.join("identity.tskey"),
|
||||
dek_path: dir.join("identity.dek"),
|
||||
keyring_account: format!("identity-dek::{canonical}"),
|
||||
};
|
||||
// Ensure a DEK exists. Subsequent operations expect it.
|
||||
// Ensure a DEK exists somewhere we can retrieve it.
|
||||
store.ensure_dek()?;
|
||||
Ok(store)
|
||||
}
|
||||
@@ -134,31 +159,143 @@ impl IdentityFileStore {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Try to read the DEK from the platform keyring. Returns
|
||||
/// `Ok(None)` when no entry exists or when the platform keyring
|
||||
/// is unreachable (typical for headless / CI hosts). Decoding
|
||||
/// errors propagate as [`StorageError::Crypto`].
|
||||
///
|
||||
/// Honours `CHANORA_DISABLE_KEYRING=1` for tests and headless
|
||||
/// environments where a real Secret Service call would block on
|
||||
/// a missing D-Bus session.
|
||||
#[allow(unused_variables)]
|
||||
fn keyring_load(&self) -> Result<Option<[u8; 32]>, StorageError> {
|
||||
if keyring_disabled() {
|
||||
return Ok(None);
|
||||
}
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))]
|
||||
{
|
||||
use base64::Engine;
|
||||
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_storage", error = %e, "keyring: entry construction failed; falling back to file");
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
match entry.get_password() {
|
||||
Ok(b64) => {
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(b64.as_bytes())
|
||||
.map_err(|e| StorageError::Crypto(format!("keyring dek decode: {e}")))?;
|
||||
if bytes.len() != 32 {
|
||||
return Err(StorageError::Crypto(format!(
|
||||
"keyring dek length {} (expected 32)",
|
||||
bytes.len()
|
||||
)));
|
||||
}
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&bytes);
|
||||
Ok(Some(key))
|
||||
}
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => {
|
||||
// Bus unreachable, no session, locked keychain
|
||||
// — best-effort: fall through to file.
|
||||
warn!(target: "chanora_storage", error = %e, "keyring: lookup failed; falling back to file");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios")))]
|
||||
{
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the DEK in the platform keyring. Returns true on
|
||||
/// success and false when the keyring is unreachable (caller
|
||||
/// should then fall back to the file path).
|
||||
///
|
||||
/// Honours `CHANORA_DISABLE_KEYRING=1`.
|
||||
#[allow(unused_variables)]
|
||||
fn keyring_save(&self, key: &[u8; 32]) -> bool {
|
||||
if keyring_disabled() {
|
||||
return false;
|
||||
}
|
||||
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))]
|
||||
{
|
||||
use base64::Engine;
|
||||
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(key);
|
||||
match entry.set_password(&b64) {
|
||||
Ok(()) => {
|
||||
info!(target: "chanora_storage", "DEK stored in platform keyring");
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(target: "chanora_storage", error = %e, "keyring: save failed; falling back to file");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_dek(&self) -> Result<(), StorageError> {
|
||||
if self.dek_path.exists() {
|
||||
// Already in the keyring? Done.
|
||||
if self.keyring_load()?.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// File-fallback DEK present? Try to migrate into the
|
||||
// keyring opportunistically (lets users upgrade for free)
|
||||
// and keep the file as the live source if migration fails.
|
||||
if self.dek_path.exists() {
|
||||
if let Ok(key) = read_file_dek(&self.dek_path) {
|
||||
if self.keyring_save(&key) {
|
||||
// Best-effort scrub: remove the file copy now
|
||||
// that the keyring holds the authoritative value.
|
||||
let _ = fs::remove_file(&self.dek_path);
|
||||
}
|
||||
let mut k = key;
|
||||
k.zeroize();
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// Fresh install: generate a new DEK and store it in the
|
||||
// keyring if we can, else fall back to the file.
|
||||
let mut key = [0u8; 32];
|
||||
OsRng.fill_bytes(&mut key);
|
||||
{
|
||||
if !self.keyring_save(&key) {
|
||||
let mut f = open_private(&self.dek_path)?;
|
||||
f.write_all(&key)
|
||||
.map_err(|e| StorageError::Io(format!("write dek: {e}")))?;
|
||||
f.sync_all()
|
||||
.map_err(|e| StorageError::Io(format!("sync dek: {e}")))?;
|
||||
info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated (file fallback)");
|
||||
}
|
||||
key.zeroize();
|
||||
info!(target: "chanora_storage", path = ?self.dek_path, "DEK generated");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_dek(&self) -> Result<[u8; 32], StorageError> {
|
||||
let mut f = fs::File::open(&self.dek_path)
|
||||
.map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", self.dek_path)))?;
|
||||
let mut key = [0u8; 32];
|
||||
f.read_exact(&mut key)
|
||||
.map_err(|e| StorageError::Io(format!("read dek: {e}")))?;
|
||||
Ok(key)
|
||||
if let Some(k) = self.keyring_load()? {
|
||||
return Ok(k);
|
||||
}
|
||||
read_file_dek(&self.dek_path)
|
||||
}
|
||||
|
||||
/// Hand out a [`DekCrypto`] anchored on the same DEK that
|
||||
/// encrypts the identity file. Used by [`BookmarkRepository`]
|
||||
/// to encrypt server-password columns under the same per-install
|
||||
/// key (MVP hardening).
|
||||
pub fn crypto(&self) -> Result<impl Crypto + Clone + std::fmt::Debug + 'static, StorageError> {
|
||||
Ok(DekCrypto::new(self.load_dek()?))
|
||||
}
|
||||
|
||||
/// Read the persisted identity, if any. Transparently handles
|
||||
@@ -297,6 +434,28 @@ fn is_plausibly_legacy_plaintext(buf: &[u8]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a 32-byte DEK from `path`. Used by the file-fallback path
|
||||
/// and the legacy migration path inside `ensure_dek`.
|
||||
fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> {
|
||||
let mut f = fs::File::open(path)
|
||||
.map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", path)))?;
|
||||
let mut key = [0u8; 32];
|
||||
f.read_exact(&mut key)
|
||||
.map_err(|e| StorageError::Io(format!("read dek: {e}")))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// True when `CHANORA_DISABLE_KEYRING=1` is set. Lets tests and
|
||||
/// headless / sandboxed environments force the file-fallback path
|
||||
/// without poking a real OS keyring (which would either prompt the
|
||||
/// user or block on a missing D-Bus session).
|
||||
fn keyring_disabled() -> bool {
|
||||
matches!(
|
||||
std::env::var("CHANORA_DISABLE_KEYRING").as_deref(),
|
||||
Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn open_private(p: &Path) -> Result<fs::File, StorageError> {
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
@@ -323,12 +482,102 @@ fn open_private(p: &Path) -> Result<fs::File, StorageError> {
|
||||
.map_err(|e| StorageError::Io(format!("open {p:?}: {e}")))
|
||||
}
|
||||
|
||||
/// Public abstraction over the per-install envelope-encryption
|
||||
/// helper. Callers see only the encrypt/decrypt pair so the
|
||||
/// concrete key material stays inside `chanora_storage`.
|
||||
pub trait Crypto: Send + Sync {
|
||||
/// Encrypt `plaintext` under the per-install DEK. Returns a
|
||||
/// fresh `[nonce || ct || tag]` blob.
|
||||
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, StorageError>;
|
||||
/// Decrypt a blob previously produced by [`Self::encrypt`].
|
||||
fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, StorageError>;
|
||||
}
|
||||
|
||||
impl Crypto for DekCrypto {
|
||||
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, StorageError> {
|
||||
DekCrypto::encrypt(self, plaintext)
|
||||
}
|
||||
fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, StorageError> {
|
||||
DekCrypto::decrypt(self, blob)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared envelope encryption helper used by both
|
||||
/// [`IdentityFileStore`] and [`BookmarkRepository`]. Both rely on
|
||||
/// the same per-install 32-byte DEK so a single keyring entry (or
|
||||
/// fallback file) protects everything secret in `<storage_dir>`.
|
||||
///
|
||||
/// Wire format: `[12-byte nonce][AEAD ct+tag]`. Bytes are opaque to
|
||||
/// callers; persist them as-is.
|
||||
#[derive(Clone)]
|
||||
struct DekCrypto {
|
||||
key: [u8; 32],
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DekCrypto {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// Never expose the key in Debug output.
|
||||
f.write_str("DekCrypto { key: [redacted] }")
|
||||
}
|
||||
}
|
||||
|
||||
impl DekCrypto {
|
||||
fn new(key: [u8; 32]) -> Self {
|
||||
Self { key }
|
||||
}
|
||||
|
||||
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>, StorageError> {
|
||||
let key = Key::from_slice(&self.key);
|
||||
let cipher = ChaCha20Poly1305::new(key);
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
OsRng.fill_bytes(&mut nonce_bytes);
|
||||
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||
let ct = cipher
|
||||
.encrypt(nonce, plaintext)
|
||||
.map_err(|e| StorageError::Crypto(format!("encrypt: {e}")))?;
|
||||
let mut out = Vec::with_capacity(12 + ct.len());
|
||||
out.extend_from_slice(&nonce_bytes);
|
||||
out.extend_from_slice(&ct);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn decrypt(&self, blob: &[u8]) -> Result<Vec<u8>, StorageError> {
|
||||
if blob.len() < 12 + 16 {
|
||||
return Err(StorageError::Crypto(format!(
|
||||
"envelope length {} < minimum",
|
||||
blob.len()
|
||||
)));
|
||||
}
|
||||
let key = Key::from_slice(&self.key);
|
||||
let cipher = ChaCha20Poly1305::new(key);
|
||||
let nonce = Nonce::from_slice(&blob[..12]);
|
||||
cipher
|
||||
.decrypt(nonce, &blob[12..])
|
||||
.map_err(|e| StorageError::Crypto(format!("decrypt: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DekCrypto {
|
||||
fn drop(&mut self) {
|
||||
self.key.zeroize();
|
||||
}
|
||||
}
|
||||
|
||||
/// A persisted bookmark: a friendly label paired with a TS3 server
|
||||
/// address and the nickname the user wants when connecting.
|
||||
///
|
||||
/// Bookmark rows are uniquely identified by an auto-incrementing
|
||||
/// `id`. The `display_name` is purely cosmetic. `host` is the same
|
||||
/// string the user would type into the connect form.
|
||||
///
|
||||
/// MVP hardening: when the repository is constructed via
|
||||
/// [`BookmarkRepository::with_crypto`] the `password` column is
|
||||
/// stored as a ChaCha20-Poly1305 envelope under the per-install
|
||||
/// DEK, so a stolen SQLite file alone does not leak server
|
||||
/// passwords. The plaintext column is still read for backward
|
||||
/// compatibility and upgraded on the next `update()` call. Rows
|
||||
/// inserted by a pre-MVP build remain readable with their plain
|
||||
/// password values until upgraded.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Bookmark {
|
||||
/// Stable row id assigned by SQLite.
|
||||
@@ -349,14 +598,35 @@ pub struct Bookmark {
|
||||
/// `<storage_dir>/chanora.db`. The schema is migrated on
|
||||
/// construction; failures here abort the constructor rather than
|
||||
/// poisoning later calls.
|
||||
///
|
||||
/// MVP: when a [`Crypto`] is wired via [`Self::with_crypto`] the
|
||||
/// `password` column is replaced by an encrypted `password_blob`
|
||||
/// column. Legacy plaintext passwords in the `password` column are
|
||||
/// still read transparently and lifted into `password_blob` on the
|
||||
/// next `update()` call so existing v0.4 installs upgrade for free.
|
||||
pub struct BookmarkRepository {
|
||||
conn: Mutex<Connection>,
|
||||
crypto: Option<Box<dyn Crypto>>,
|
||||
}
|
||||
|
||||
impl BookmarkRepository {
|
||||
/// Open or create the bookmark database under `dir`.
|
||||
/// Open or create the bookmark database under `dir`. No
|
||||
/// password-column encryption — equivalent to the v0.4 behaviour
|
||||
/// and kept for tests.
|
||||
pub fn new(dir: impl AsRef<Path>) -> Result<Self, StorageError> {
|
||||
let dir = dir.as_ref();
|
||||
Self::open(dir.as_ref(), None)
|
||||
}
|
||||
|
||||
/// Open or create the bookmark database with password-column
|
||||
/// encryption wired to the per-install DEK.
|
||||
pub fn with_crypto<C>(dir: impl AsRef<Path>, crypto: C) -> Result<Self, StorageError>
|
||||
where
|
||||
C: Crypto + 'static,
|
||||
{
|
||||
Self::open(dir.as_ref(), Some(Box::new(crypto) as Box<dyn Crypto>))
|
||||
}
|
||||
|
||||
fn open(dir: &Path, crypto: Option<Box<dyn Crypto>>) -> Result<Self, StorageError> {
|
||||
fs::create_dir_all(dir).map_err(|e| StorageError::Io(format!("mkdir {dir:?}: {e}")))?;
|
||||
let path = dir.join("chanora.db");
|
||||
let conn = Connection::open(&path)
|
||||
@@ -377,36 +647,81 @@ impl BookmarkRepository {
|
||||
INSERT OR IGNORE INTO schema_version(v) VALUES (1);",
|
||||
)
|
||||
.map_err(|e| StorageError::Migration(format!("init schema: {e}")))?;
|
||||
// Schema v2 migration: encrypted password column. Idempotent.
|
||||
let has_blob: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('bookmarks') WHERE name='password_blob'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.map_err(|e| StorageError::Migration(format!("table_info: {e}")))?;
|
||||
if has_blob == 0 {
|
||||
conn.execute("ALTER TABLE bookmarks ADD COLUMN password_blob BLOB", [])
|
||||
.map_err(|e| StorageError::Migration(format!("add password_blob: {e}")))?;
|
||||
conn.execute("INSERT OR IGNORE INTO schema_version(v) VALUES (2)", [])
|
||||
.map_err(|e| StorageError::Migration(format!("bump version: {e}")))?;
|
||||
info!(target: "chanora_storage", "bookmark db migrated to v2 (password_blob)");
|
||||
}
|
||||
info!(target: "chanora_storage", path = ?path, "bookmark db opened");
|
||||
Ok(Self { conn: Mutex::new(conn) })
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
crypto,
|
||||
})
|
||||
}
|
||||
|
||||
/// True if the repository has password-column encryption wired.
|
||||
pub fn encrypts_passwords(&self) -> bool {
|
||||
self.crypto.is_some()
|
||||
}
|
||||
|
||||
/// Insert a new bookmark and return its assigned id. The `id`
|
||||
/// field on the input is ignored.
|
||||
/// field on the input is ignored. Encrypts the password if a
|
||||
/// crypto helper is wired; otherwise writes it plain.
|
||||
pub fn add(&self, b: &Bookmark) -> Result<i64, StorageError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
|
||||
let blob = match (&self.crypto, &b.password) {
|
||||
(Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?),
|
||||
_ => None,
|
||||
};
|
||||
let plain: Option<&str> = if self.crypto.is_some() {
|
||||
None
|
||||
} else {
|
||||
b.password.as_deref()
|
||||
};
|
||||
conn.execute(
|
||||
"INSERT INTO bookmarks (display_name, host, nickname, password) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![b.display_name, b.host, b.nickname, b.password],
|
||||
"INSERT INTO bookmarks (display_name, host, nickname, password, password_blob) VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![b.display_name, b.host, b.nickname, plain, blob],
|
||||
)
|
||||
.map_err(|e| StorageError::Sqlite(format!("insert: {e}")))?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Replace an existing bookmark identified by `id`. Errors with
|
||||
/// [`StorageError::NotFound`] if no such row exists.
|
||||
/// [`StorageError::NotFound`] if no such row exists. Honours the
|
||||
/// password-column encryption setting and clears the legacy
|
||||
/// plaintext column so a row inserted by a pre-MVP build is
|
||||
/// upgraded on the next update.
|
||||
pub fn update(&self, b: &Bookmark) -> Result<(), StorageError> {
|
||||
let conn = self
|
||||
.conn
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
|
||||
let blob = match (&self.crypto, &b.password) {
|
||||
(Some(c), Some(pw)) => Some(c.encrypt(pw.as_bytes())?),
|
||||
_ => None,
|
||||
};
|
||||
let plain: Option<&str> = if self.crypto.is_some() {
|
||||
None
|
||||
} else {
|
||||
b.password.as_deref()
|
||||
};
|
||||
let n = conn
|
||||
.execute(
|
||||
"UPDATE bookmarks SET display_name=?1, host=?2, nickname=?3, password=?4 WHERE id=?5",
|
||||
params![b.display_name, b.host, b.nickname, b.password, b.id],
|
||||
"UPDATE bookmarks SET display_name=?1, host=?2, nickname=?3, password=?4, password_blob=?5 WHERE id=?6",
|
||||
params![b.display_name, b.host, b.nickname, plain, blob, b.id],
|
||||
)
|
||||
.map_err(|e| StorageError::Sqlite(format!("update: {e}")))?;
|
||||
if n == 0 {
|
||||
@@ -427,7 +742,9 @@ impl BookmarkRepository {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List all bookmarks ordered by id (insertion order).
|
||||
/// List all bookmarks ordered by id (insertion order). Decrypts
|
||||
/// `password_blob` if present; falls back to the legacy plain
|
||||
/// `password` column otherwise (legacy v0.4 rows).
|
||||
pub fn list(&self) -> Result<Vec<Bookmark>, StorageError> {
|
||||
let conn = self
|
||||
.conn
|
||||
@@ -435,23 +752,45 @@ impl BookmarkRepository {
|
||||
.map_err(|_| StorageError::Sqlite("poisoned lock".to_string()))?;
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, display_name, host, nickname, password FROM bookmarks ORDER BY id",
|
||||
"SELECT id, display_name, host, nickname, password, password_blob FROM bookmarks ORDER BY id",
|
||||
)
|
||||
.map_err(|e| StorageError::Sqlite(format!("prepare: {e}")))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(Bookmark {
|
||||
id: row.get(0)?,
|
||||
display_name: row.get(1)?,
|
||||
host: row.get(2)?,
|
||||
nickname: row.get(3)?,
|
||||
password: row.get(4)?,
|
||||
})
|
||||
let id: i64 = row.get(0)?;
|
||||
let display_name: String = row.get(1)?;
|
||||
let host: String = row.get(2)?;
|
||||
let nickname: String = row.get(3)?;
|
||||
let plain: Option<String> = row.get(4)?;
|
||||
let blob: Option<Vec<u8>> = row.get(5)?;
|
||||
Ok((id, display_name, host, nickname, plain, blob))
|
||||
})
|
||||
.map_err(|e| StorageError::Sqlite(format!("query: {e}")))?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r.map_err(|e| StorageError::Sqlite(format!("row: {e}")))?);
|
||||
let (id, display_name, host, nickname, plain, blob) =
|
||||
r.map_err(|e| StorageError::Sqlite(format!("row: {e}")))?;
|
||||
let password = match (blob.as_ref(), self.crypto.as_ref()) {
|
||||
(Some(b), Some(c)) => Some(
|
||||
String::from_utf8(c.decrypt(b)?)
|
||||
.map_err(|e| StorageError::Crypto(format!("blob utf8: {e}")))?,
|
||||
),
|
||||
(Some(_), None) => {
|
||||
// We have an encrypted blob but no key. Skip the
|
||||
// password rather than panicking; the caller can
|
||||
// re-enter it.
|
||||
warn!(target: "chanora_storage", id, "encrypted bookmark password but crypto not wired; skipping");
|
||||
None
|
||||
}
|
||||
(None, _) => plain,
|
||||
};
|
||||
out.push(Bookmark {
|
||||
id,
|
||||
display_name,
|
||||
host,
|
||||
nickname,
|
||||
password,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -461,8 +800,18 @@ impl BookmarkRepository {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Tests always run against the file fallback. A real keyring
|
||||
/// hit would either prompt the developer or block on a missing
|
||||
/// D-Bus session inside CI. The override is set process-wide
|
||||
/// via a module-init guard so individual `#[test]` order does
|
||||
/// not matter.
|
||||
fn force_keyring_off() {
|
||||
std::env::set_var("CHANORA_DISABLE_KEYRING", "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
force_keyring_off();
|
||||
let tmp = tempdir();
|
||||
let store = IdentityFileStore::new(&tmp).unwrap();
|
||||
assert!(store.load().unwrap().is_none());
|
||||
@@ -474,6 +823,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_file_is_none() {
|
||||
force_keyring_off();
|
||||
let tmp = tempdir();
|
||||
let store = IdentityFileStore::new(&tmp).unwrap();
|
||||
fs::write(store.path(), " \n").unwrap();
|
||||
@@ -483,6 +833,7 @@ mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unix_mode_is_0600() {
|
||||
force_keyring_off();
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempdir();
|
||||
let store = IdentityFileStore::new(&tmp).unwrap();
|
||||
@@ -493,6 +844,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bookmark_round_trip() {
|
||||
force_keyring_off();
|
||||
let tmp = tempdir();
|
||||
let repo = BookmarkRepository::new(&tmp).unwrap();
|
||||
assert!(repo.list().unwrap().is_empty());
|
||||
@@ -513,6 +865,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bookmark_update_and_delete() {
|
||||
force_keyring_off();
|
||||
let tmp = tempdir();
|
||||
let repo = BookmarkRepository::new(&tmp).unwrap();
|
||||
let id = repo
|
||||
@@ -541,6 +894,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn bookmark_update_missing_is_notfound() {
|
||||
force_keyring_off();
|
||||
let tmp = tempdir();
|
||||
let repo = BookmarkRepository::new(&tmp).unwrap();
|
||||
let r = repo.update(&Bookmark {
|
||||
@@ -555,6 +909,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn encrypted_round_trip() {
|
||||
force_keyring_off();
|
||||
let tmp = tempdir();
|
||||
let store = IdentityFileStore::new(&tmp).unwrap();
|
||||
store.save("3456V/abcdef==").unwrap();
|
||||
@@ -567,6 +922,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn legacy_plaintext_is_read_and_upgraded() {
|
||||
force_keyring_off();
|
||||
let tmp = tempdir();
|
||||
// Pre-Beta on-disk format: raw base64 with counter prefix.
|
||||
let path = tmp.join("identity.tskey");
|
||||
@@ -581,6 +937,85 @@ mod tests {
|
||||
assert_eq!(store.load().unwrap().as_deref(), Some("9999VabcdefghIJKLmnop="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_bookmark_password_round_trip() {
|
||||
force_keyring_off();
|
||||
let tmp = tempdir();
|
||||
let id_store = IdentityFileStore::new(&tmp).unwrap();
|
||||
let crypto = id_store.crypto().unwrap();
|
||||
let repo = BookmarkRepository::with_crypto(&tmp, crypto).unwrap();
|
||||
assert!(repo.encrypts_passwords());
|
||||
let id = repo
|
||||
.add(&Bookmark {
|
||||
id: 0,
|
||||
display_name: "secret".to_string(),
|
||||
host: "h".to_string(),
|
||||
nickname: "n".to_string(),
|
||||
password: Some("hunter2".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
// The on-disk row must not contain the plain password.
|
||||
let conn = rusqlite::Connection::open(tmp.join("chanora.db")).unwrap();
|
||||
let row: (Option<String>, Option<Vec<u8>>) = conn
|
||||
.query_row(
|
||||
"SELECT password, password_blob FROM bookmarks WHERE id=?1",
|
||||
rusqlite::params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(row.0.is_none(), "plaintext password column should be NULL");
|
||||
assert!(row.1.is_some(), "password_blob should be populated");
|
||||
let blob = row.1.unwrap();
|
||||
assert!(!blob.windows(7).any(|w| w == b"hunter2"));
|
||||
// Round-trip through the repository decrypts correctly.
|
||||
let rows = repo.list().unwrap();
|
||||
assert_eq!(rows[0].password.as_deref(), Some("hunter2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_plaintext_bookmark_is_readable_and_upgraded() {
|
||||
force_keyring_off();
|
||||
let tmp = tempdir();
|
||||
// Pre-MVP write: opened without crypto, stores plaintext.
|
||||
let legacy = BookmarkRepository::new(&tmp).unwrap();
|
||||
let id = legacy
|
||||
.add(&Bookmark {
|
||||
id: 0,
|
||||
display_name: "legacy".to_string(),
|
||||
host: "h".to_string(),
|
||||
nickname: "n".to_string(),
|
||||
password: Some("old".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
drop(legacy);
|
||||
|
||||
// MVP open: same dir, with crypto. Plain row still read.
|
||||
let id_store = IdentityFileStore::new(&tmp).unwrap();
|
||||
let crypto = id_store.crypto().unwrap();
|
||||
let repo = BookmarkRepository::with_crypto(&tmp, crypto).unwrap();
|
||||
let rows = repo.list().unwrap();
|
||||
assert_eq!(rows[0].password.as_deref(), Some("old"));
|
||||
// Update lifts it into password_blob and nulls the plaintext.
|
||||
repo.update(&Bookmark {
|
||||
id,
|
||||
display_name: "legacy".to_string(),
|
||||
host: "h".to_string(),
|
||||
nickname: "n".to_string(),
|
||||
password: Some("old".to_string()),
|
||||
})
|
||||
.unwrap();
|
||||
let conn = rusqlite::Connection::open(tmp.join("chanora.db")).unwrap();
|
||||
let row: (Option<String>, Option<Vec<u8>>) = conn
|
||||
.query_row(
|
||||
"SELECT password, password_blob FROM bookmarks WHERE id=?1",
|
||||
rusqlite::params![id],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(row.0.is_none(), "plaintext should be cleared after upgrade");
|
||||
assert!(row.1.is_some(), "blob should be set after upgrade");
|
||||
}
|
||||
|
||||
fn tempdir() -> PathBuf {
|
||||
let p = std::env::temp_dir()
|
||||
.join("chanora_storage_test")
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
@@ -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:-}"
|
||||
|
||||
Reference in New Issue
Block a user