Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c8a5b668f | ||
|
|
203bd9044d |
@@ -1,44 +1,82 @@
|
|||||||
# kestrove-core
|
# Kestrove core
|
||||||
|
|
||||||
Rust workspace that holds **all** Kestrove crypto and vault logic. Apple,
|
This is the heart of **Kestrove**, the self-hosted password manager. Everything that touches your secrets
|
||||||
Android and desktop apps call it through UniFFI. The server uses only `kv-proto`.
|
lives here, in one Rust codebase that every app shares:
|
||||||
|
|
||||||
| Crate | Status | Purpose |
|
- the **encryption** (your master password and Secret Key turn into the keys that lock your vault),
|
||||||
|---|---|---|
|
- the **vault** itself (logins, passkeys, one-time codes, notes, cards, tags, "Sign in with Google"…),
|
||||||
| `kv-proto` | ✅ M0 | Wire types (Envelope, KDF params, API bodies). No secrets. |
|
- **sync** with your Kestrove server, and the merging of changes made on two devices at once,
|
||||||
| `kv-crypto` | ✅ M0 | 2SKD (Argon2id + Secret Key), XChaCha20-Poly1305 envelopes, HPKE vault-key sealing, keyset, Ed25519, Secret Key format |
|
- **passkeys** (creating and signing them), **one-time codes**, the **password generator**,
|
||||||
| `kv-model` | ✅ M0 | Items, URL matching (PSL), TOTP (RFC 6238), password generator |
|
- **import** from 1Password and CSV, and encrypted export.
|
||||||
| `kv-ffi` | ✅ M1 | The exported API (`VaultCore` with SQLite storage, local unlock key, sync; `totp_code`, `match_url`, `generate_password`) |
|
|
||||||
| `kv-store` | ✅ M1 | SQLite replica (ciphertext only), base copies for merges, 10-version history |
|
|
||||||
| `kv-sync` | ✅ M1 | TLS 1.3 + SPKI pins, OPAQUE login, RFC 9421 signing, pull/push/merge, signed manifest |
|
|
||||||
|
|
||||||
Spec: `../kestrove-spec/docs/02-cryptography.md` and `03-sync-protocol.md`.
|
Because all apps use this same code, a vault works the same on iPhone, Mac, Android, Windows, Linux and in
|
||||||
|
the browser, and the security-critical parts are written and reviewed once.
|
||||||
|
|
||||||
## Setup
|
You only need this repository to **build the apps** or to work on Kestrove itself. To run a server, see
|
||||||
|
[`kestrove-server`](https://gitea.samialabri.com/kestrove/kestrove-server).
|
||||||
|
|
||||||
|
## How the apps use it
|
||||||
|
|
||||||
|
| App | How it gets the core |
|
||||||
|
|---|---|
|
||||||
|
| iPhone, iPad, Mac (`kestrove-apple`) | `scripts/build-apple.sh` builds a Swift package |
|
||||||
|
| Android (`kestrove-android`) | `scripts/build-android.sh` builds the native libraries and Kotlin bindings |
|
||||||
|
| Windows and Linux (`kestrove-desktop`) | Uses the Rust code directly |
|
||||||
|
| Web vault (`kestrove-web`) | `crates/kv-wasm/build.sh` builds a WebAssembly package (`kestrove-web` keeps a copy) |
|
||||||
|
| Server (`kestrove-server`) | Uses only the shared message formats (`kv-proto`); it never handles secrets |
|
||||||
|
|
||||||
|
Keep the repositories **side by side in one folder** (`kestrove-core`, `kestrove-apple`, …): the apps find
|
||||||
|
the core by that relative path.
|
||||||
|
|
||||||
|
## Build and test
|
||||||
|
|
||||||
|
You need Rust (install it once from [rustup.rs](https://rustup.rs)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh # once
|
cargo test --workspace --all-features # every test
|
||||||
cargo test --workspace
|
cargo clippy --workspace --all-targets --all-features
|
||||||
```
|
```
|
||||||
|
|
||||||
## Bindings
|
**Build for the apps:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./scripts/build-apple.sh # → ../kestrove-apple/Packages/KestroveCore (XCFramework + Swift)
|
./scripts/build-apple.sh # → ../kestrove-apple/Packages/KestroveCore (needs Xcode)
|
||||||
./scripts/build-android.sh # → ../kestrove-android/core-ffi (jniLibs + Kotlin); needs cargo-ndk
|
ANDROID_NDK_HOME=~/Library/Android/sdk/ndk/27.3.13750724 ./scripts/build-android.sh
|
||||||
|
# → ../kestrove-android/core-ffi (needs the Android NDK and cargo-ndk)
|
||||||
|
(cd ../kestrove-web && scripts/vendor-core.sh)
|
||||||
|
# → builds kv-wasm and copies it into the web vault (needs wasm-pack)
|
||||||
```
|
```
|
||||||
|
|
||||||
The desktop (Tauri) app depends on `kv-ffi` directly as a Rust crate.
|
Run these again after changing the core, so the apps pick up the change.
|
||||||
|
|
||||||
## Test vectors and the live-server test
|
**Test against a real server** (start a fresh local `kestrove-server` first; the test file explains how):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cargo run -p kv-crypto --example gen-vectors # writes crates/kv-crypto/tests/vectors and ../kestrove-spec/vectors
|
cargo test -p kv-ffi --test live_server -- --ignored
|
||||||
cargo test -p kv-ffi --test live_server -- --ignored # against a fresh local kestrove-server; see the test's docs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Rules
|
## What's inside
|
||||||
* No `unsafe` outside generated FFI glue.
|
|
||||||
* Secrets live in `Zeroizing<_>`. Never log, `Debug`-print or `format!` a secret.
|
| Crate | What it does |
|
||||||
* Any change to formats needs a spec change first and new vectors in `kestrove-spec/vectors`.
|
|---|---|
|
||||||
* `cargo deny check` must pass. There are no GPL dependencies (App Store).
|
| `kv-crypto` | Keys and encryption: turning the master password and Secret Key into keys (Argon2id), sealing every item (XChaCha20-Poly1305), sealing vault keys for devices (HPKE), signatures (Ed25519). |
|
||||||
|
| `kv-model` | Vault items and what's in them, website matching (which login belongs to which site), one-time codes (TOTP), passkey signing (WebAuthn), the password generator. |
|
||||||
|
| `kv-store` | The encrypted copy of the vault kept on each device (SQLite). It stores only ciphertext. |
|
||||||
|
| `kv-sync` | Talking to the server: login without sending the password (OPAQUE), signed requests, pulling and pushing changes, merging edits from two devices, and a signed summary that catches a server hiding or rolling back data. |
|
||||||
|
| `kv-proto` | The message formats shared with the server. No secrets. |
|
||||||
|
| `kv-ffi` | The one API every app calls (`VaultCore`): create an account, unlock, read and save items, passkeys, sync, add devices. Also the browser bridge that the Mac and desktop apps use to answer the browser extension. |
|
||||||
|
| `kv-wasm` | The same API for the web vault, as WebAssembly in a Web Worker. |
|
||||||
|
| `kv-import` | Import from 1Password (`.1pux`) and CSV, and passphrase-encrypted export (`.kvex`). |
|
||||||
|
| `kv-ipc`, `kv-nmh` | The small helper (`kestrove-nmh`) that browsers start to reach the Kestrove app, and its local connection to the app. |
|
||||||
|
|
||||||
|
The design behind all this, with the reasons, is in
|
||||||
|
[`kestrove-spec`](https://gitea.samialabri.com/kestrove/kestrove-spec): start with `docs/02-cryptography.md`
|
||||||
|
and `docs/03-sync-protocol.md`.
|
||||||
|
|
||||||
|
## Rules for changes
|
||||||
|
|
||||||
|
- No `unsafe` code outside the generated bindings.
|
||||||
|
- Secrets are kept in `Zeroizing<…>` so they're wiped from memory, and are never logged or printed.
|
||||||
|
- Changing a stored or transmitted format? Change the spec first, then add test vectors to
|
||||||
|
`kestrove-spec/vectors` (`cargo run -p kv-crypto --example gen-vectors` writes them).
|
||||||
|
- No GPL dependencies (the apps go to the App Store); `cargo deny check` must pass.
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
//! Settings that follow the user to every device (kestrove-spec/docs/02 "App settings"): the colour theme and
|
||||||
|
//! light/dark appearance. They live in one end-to-end encrypted item per account (a secure note carrying
|
||||||
|
//! `app_settings`, at `kv_model::app_settings_id`), so they sync like everything else and the server can't read
|
||||||
|
//! them. Apps hide that item; older versions show it as a note, which explains itself.
|
||||||
|
|
||||||
|
use kv_model::{app_settings_id, AppSettings, Item, ItemKind};
|
||||||
|
|
||||||
|
use crate::{decrypt, now, CoreError, Result, VaultCore};
|
||||||
|
|
||||||
|
/// Theme ids the apps offer, in display order (kestrove-spec/design/themes.json). Unknown ids from newer apps are
|
||||||
|
/// kept and passed through; each app falls back to `spruce` for one it doesn't know.
|
||||||
|
pub const THEMES: &[&str] = &["spruce", "ocean", "violet", "rose", "crimson", "amber", "graphite"];
|
||||||
|
const APPEARANCES: &[&str] = &["system", "light", "dark"];
|
||||||
|
|
||||||
|
const NOTE_TITLE: &str = "Kestrove settings";
|
||||||
|
const NOTE_TEXT: &str = "Kestrove keeps the settings that sync between your devices here, such as your colour theme. \
|
||||||
|
Newer versions of the app hide this note. Deleting it only resets those settings.";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default, uniffi::Record)]
|
||||||
|
pub struct AppThemeDto {
|
||||||
|
/// A theme id (see `app_themes`), or none if never set (apps use `spruce`).
|
||||||
|
pub theme: Option<String>,
|
||||||
|
/// `system`, `light` or `dark`; none if never set (apps follow the system).
|
||||||
|
pub appearance: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The theme ids the apps offer, in order.
|
||||||
|
#[uniffi::export]
|
||||||
|
pub fn app_themes() -> Vec<String> {
|
||||||
|
THEMES.iter().map(|t| (*t).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn valid_id(s: &str) -> bool {
|
||||||
|
!s.is_empty() && s.len() <= 32 && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
}
|
||||||
|
|
||||||
|
#[uniffi::export]
|
||||||
|
impl VaultCore {
|
||||||
|
/// The theme synced across this account's devices. Empty when none was ever synced.
|
||||||
|
pub fn app_theme(&self) -> Result<AppThemeDto> {
|
||||||
|
let (acct, u) = self.unlocked_snapshot()?;
|
||||||
|
let id = app_settings_id(acct.account_id);
|
||||||
|
let Some(li) = self.store.get(acct.primary_vault, id)? else { return Ok(AppThemeDto::default()) };
|
||||||
|
let s = decrypt(&acct, &u.vault_key, &li.record)?.and_then(|i| i.app_settings.clone()).unwrap_or_default();
|
||||||
|
Ok(AppThemeDto { theme: s.theme, appearance: s.appearance })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the theme for every device of this account (it syncs with the next sync). Keeps every other setting.
|
||||||
|
pub fn set_app_theme(&self, theme: Option<String>, appearance: Option<String>) -> Result<()> {
|
||||||
|
if theme.as_deref().is_some_and(|t| !valid_id(t)) {
|
||||||
|
return Err(CoreError::Invalid { msg: "theme".into() });
|
||||||
|
}
|
||||||
|
if appearance.as_deref().is_some_and(|a| !APPEARANCES.contains(&a)) {
|
||||||
|
return Err(CoreError::Invalid { msg: "appearance".into() });
|
||||||
|
}
|
||||||
|
let (acct, u) = self.unlocked_snapshot()?;
|
||||||
|
let id = app_settings_id(acct.account_id);
|
||||||
|
let existing = self.store.get(acct.primary_vault, id)?;
|
||||||
|
let current = match &existing {
|
||||||
|
Some(li) => decrypt(&acct, &u.vault_key, &li.record)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let mut model = current.unwrap_or_else(|| {
|
||||||
|
let mut item = Item::new_login(NOTE_TITLE, now());
|
||||||
|
item.id = id;
|
||||||
|
item.kind = ItemKind::SecureNote;
|
||||||
|
item.notes = Some(NOTE_TEXT.into());
|
||||||
|
item
|
||||||
|
});
|
||||||
|
let mut settings = model.app_settings.take().unwrap_or_else(AppSettings::default);
|
||||||
|
if settings.theme == theme && settings.appearance == appearance && existing.is_some() {
|
||||||
|
model.app_settings = Some(settings);
|
||||||
|
return Ok(()); // nothing changed: no new version to sync
|
||||||
|
}
|
||||||
|
settings.theme = theme;
|
||||||
|
settings.appearance = appearance;
|
||||||
|
model.app_settings = Some(settings);
|
||||||
|
let existing = existing.filter(|li| !li.record.deleted);
|
||||||
|
self.write_item(&acct, &u, &mut model, existing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for the account's settings item, which the vault list never shows.
|
||||||
|
pub(crate) fn is_settings_item(item: &Item) -> bool {
|
||||||
|
item.app_settings.is_some()
|
||||||
|
}
|
||||||
@@ -32,7 +32,9 @@ use zeroize::Zeroizing;
|
|||||||
|
|
||||||
uniffi::setup_scaffolding!();
|
uniffi::setup_scaffolding!();
|
||||||
|
|
||||||
|
mod app_settings;
|
||||||
mod passkeys;
|
mod passkeys;
|
||||||
|
pub use app_settings::{app_themes, AppThemeDto};
|
||||||
pub use passkeys::{kestrove_aaguid, PasskeyAssertion, PasskeyRegistration, PasskeySummary};
|
pub use passkeys::{kestrove_aaguid, PasskeyAssertion, PasskeyRegistration, PasskeySummary};
|
||||||
mod import;
|
mod import;
|
||||||
pub use import::{DuplicateHandling, ImportFormat, ImportOptions, ImportPreview, ImportReport};
|
pub use import::{DuplicateHandling, ImportFormat, ImportOptions, ImportPreview, ImportReport};
|
||||||
@@ -615,7 +617,9 @@ impl VaultCore {
|
|||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for li in self.store.list(acct.primary_vault)? {
|
for li in self.store.list(acct.primary_vault)? {
|
||||||
if let Some(item) = decrypt(&acct, &u.vault_key, &li.record)? {
|
if let Some(item) = decrypt(&acct, &u.vault_key, &li.record)? {
|
||||||
out.push(summary(&item));
|
if !app_settings::is_settings_item(&item) {
|
||||||
|
out.push(summary(&item));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.sort_by(|a, b| {
|
out.sort_by(|a, b| {
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ impl VaultCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Seals and stores a modified item like `save_item` does: next `seq`, previous version to history.
|
/// Seals and stores a modified item like `save_item` does: next `seq`, previous version to history.
|
||||||
fn write_item(
|
pub(crate) fn write_item(
|
||||||
&self,
|
&self,
|
||||||
acct: &AccountRecord,
|
acct: &AccountRecord,
|
||||||
u: &Unlocked,
|
u: &Unlocked,
|
||||||
|
|||||||
@@ -484,7 +484,9 @@ async fn scan_to_join_between_two_clients() {
|
|||||||
let st = server.state();
|
let st = server.state();
|
||||||
let bundle = st.joins.values().next().unwrap().bundle.as_ref().unwrap();
|
let bundle = st.joins.values().next().unwrap().bundle.as_ref().unwrap();
|
||||||
let raw = serde_json::to_string(bundle).unwrap();
|
let raw = serde_json::to_string(bundle).unwrap();
|
||||||
assert!(!raw.contains("K1") && !raw.contains("kestrove://"));
|
// Not the Secret Key in either form (random ciphertext can contain "K1" by chance, so check the key).
|
||||||
|
let compact: String = sk.chars().filter(|c| *c != '-').collect();
|
||||||
|
assert!(!raw.contains(&sk) && !raw.contains(&compact[2..]) && !raw.contains("kestrove://"));
|
||||||
assert_eq!(st.tickets.values().filter(|t| t.bound_key_sha256.is_some()).count(), 1);
|
assert_eq!(st.tickets.values().filter(|t| t.bound_key_sha256.is_some()).count(), 1);
|
||||||
}
|
}
|
||||||
let done = desktop.join_poll().await.unwrap();
|
let done = desktop.join_poll().await.unwrap();
|
||||||
@@ -742,3 +744,47 @@ fn sign_in_with_is_saved_listed_and_normalised() {
|
|||||||
assert_eq!(sign_in_providers()[0].name, "Google");
|
assert_eq!(sign_in_providers()[0].name, "Google");
|
||||||
assert_eq!(sign_in_provider_name("apple".into()), "Apple");
|
assert_eq!(sign_in_provider_name("apple".into()), "Apple");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn theme_syncs_between_devices_and_stays_out_of_the_list() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let a = VaultCore::open(dir.path().join("a.sqlite").to_string_lossy().into_owned()).unwrap();
|
||||||
|
let sk = a.create_account(PW.into()).unwrap();
|
||||||
|
connect(&a, &server);
|
||||||
|
a.register_account(SETUP_TOKEN.into(), None).await.unwrap();
|
||||||
|
assert_eq!(a.app_theme().unwrap(), AppThemeDto::default());
|
||||||
|
|
||||||
|
a.set_app_theme(Some("ocean".into()), Some("dark".into())).unwrap();
|
||||||
|
assert_eq!(a.app_theme().unwrap(), AppThemeDto { theme: Some("ocean".into()), appearance: Some("dark".into()) });
|
||||||
|
assert!(a.list_items().unwrap().is_empty(), "the settings item is hidden");
|
||||||
|
assert_eq!(a.sync_now().await.unwrap().pushed, 1);
|
||||||
|
// Setting the same theme again writes nothing.
|
||||||
|
a.set_app_theme(Some("ocean".into()), Some("dark".into())).unwrap();
|
||||||
|
assert_eq!(a.sync_now().await.unwrap().pushed, 0);
|
||||||
|
|
||||||
|
let b = VaultCore::open(dir.path().join("b.sqlite").to_string_lossy().into_owned()).unwrap();
|
||||||
|
connect(&b, &server);
|
||||||
|
b.login(PW.into(), sk, None, None).await.unwrap();
|
||||||
|
b.sync_now().await.unwrap();
|
||||||
|
assert_eq!(b.app_theme().unwrap().theme.as_deref(), Some("ocean"));
|
||||||
|
|
||||||
|
// Both change it before syncing: still one item, and both devices end up with the same choice (the whole
|
||||||
|
// settings object is one merge unit, so the later change wins).
|
||||||
|
a.set_app_theme(Some("violet".into()), Some("dark".into())).unwrap();
|
||||||
|
b.set_app_theme(Some("ocean".into()), Some("light".into())).unwrap();
|
||||||
|
a.sync_now().await.unwrap();
|
||||||
|
b.sync_now().await.unwrap();
|
||||||
|
a.sync_now().await.unwrap();
|
||||||
|
let (ta, tb) = (a.app_theme().unwrap(), b.app_theme().unwrap());
|
||||||
|
assert_eq!(ta, tb);
|
||||||
|
assert!(
|
||||||
|
ta == AppThemeDto { theme: Some("violet".into()), appearance: Some("dark".into()) }
|
||||||
|
|| ta == AppThemeDto { theme: Some("ocean".into()), appearance: Some("light".into()) }
|
||||||
|
);
|
||||||
|
assert!(a.list_items().unwrap().is_empty() && b.list_items().unwrap().is_empty());
|
||||||
|
|
||||||
|
assert!(matches!(a.set_app_theme(Some("Not A Theme!".into()), None), Err(CoreError::Invalid { .. })));
|
||||||
|
assert!(matches!(a.set_app_theme(None, Some("purple".into())), Err(CoreError::Invalid { .. })));
|
||||||
|
assert_eq!(app_themes()[0], "spruce");
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,6 +58,32 @@ pub fn provider_name(id: &str) -> &str {
|
|||||||
PROVIDERS.iter().find(|(p, _, _)| *p == id).map_or(id, |(_, name, _)| name)
|
PROVIDERS.iter().find(|(p, _, _)| *p == id).map_or(id, |(_, name, _)| name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Settings that follow the user across devices (docs/02 "App settings"), kept in one item per account: a
|
||||||
|
/// secure note with this field, at [`app_settings_id`]. Apps hide that item; older versions show it as a note.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct AppSettings {
|
||||||
|
/// Colour theme id: `spruce`, `ocean`, `violet`, `rose`, `crimson`, `amber`, `graphite`, or one a newer app knows.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub theme: Option<String>,
|
||||||
|
/// `system`, `light` or `dark`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub appearance: Option<String>,
|
||||||
|
/// Settings from newer apps, kept as they are.
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub extra: BTreeMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The id of the account's settings item: the same on every device, so two devices that create it at the same
|
||||||
|
/// time write the same item (and the sync merge combines them) instead of two copies.
|
||||||
|
pub fn app_settings_id(account_id: Uuid) -> Uuid {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut h = Sha256::new();
|
||||||
|
h.update(b"kestrove/app-settings/v1");
|
||||||
|
h.update(account_id.as_bytes());
|
||||||
|
let bytes: [u8; 16] = h.finalize()[..16].try_into().expect("16 bytes");
|
||||||
|
uuid::Builder::from_random_bytes(bytes).into_uuid()
|
||||||
|
}
|
||||||
|
|
||||||
/// One item. Field-level structure is what makes three-way merges possible (docs/03).
|
/// One item. Field-level structure is what makes three-way merges possible (docs/03).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct Item {
|
pub struct Item {
|
||||||
@@ -87,6 +113,9 @@ pub struct Item {
|
|||||||
/// The account signs in through an identity provider instead of (or as well as) a password.
|
/// The account signs in through an identity provider instead of (or as well as) a password.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub sign_in_with: Option<SignInWith>,
|
pub sign_in_with: Option<SignInWith>,
|
||||||
|
/// Only on the account's settings item (see [`AppSettings`]).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub app_settings: Option<AppSettings>,
|
||||||
pub created_at: i64,
|
pub created_at: i64,
|
||||||
pub modified_at: i64,
|
pub modified_at: i64,
|
||||||
/// Per-item edit counter, bound inside the ciphertext: every new version has a larger `seq` than the
|
/// Per-item edit counter, bound inside the ciphertext: every new version has a larger `seq` than the
|
||||||
@@ -115,6 +144,7 @@ impl Item {
|
|||||||
favorite: false,
|
favorite: false,
|
||||||
android_apps: vec![],
|
android_apps: vec![],
|
||||||
sign_in_with: None,
|
sign_in_with: None,
|
||||||
|
app_settings: None,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
modified_at: now,
|
modified_at: now,
|
||||||
seq: 1,
|
seq: 1,
|
||||||
@@ -201,3 +231,30 @@ mod format_tests {
|
|||||||
assert_eq!(provider_name("newco"), "newco");
|
assert_eq!(provider_name("newco"), "newco");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod settings_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_id_is_stable_per_account() {
|
||||||
|
let a = Uuid::parse_str("6f1c2b9a-4e0d-4c7f-9b3a-8e2d1c0b9a87").unwrap();
|
||||||
|
let b = Uuid::parse_str("11111111-2222-4333-8444-555555555555").unwrap();
|
||||||
|
assert_eq!(app_settings_id(a), app_settings_id(a));
|
||||||
|
assert_ne!(app_settings_id(a), app_settings_id(b));
|
||||||
|
assert_eq!(app_settings_id(a).get_version_num(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn settings_round_trip_and_keep_unknown_keys() {
|
||||||
|
let v = serde_json::json!({"theme": "ocean", "appearance": "dark", "font_size": "large"});
|
||||||
|
let s: AppSettings = serde_json::from_value(v.clone()).unwrap();
|
||||||
|
assert_eq!((s.theme.as_deref(), s.appearance.as_deref()), (Some("ocean"), Some("dark")));
|
||||||
|
assert_eq!(serde_json::to_value(&s).unwrap(), v);
|
||||||
|
// An older item without the field still reads.
|
||||||
|
let item: Item = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "6f1c2b9a-4e0d-4c7f-9b3a-8e2d1c0b9a87", "kind": "secure_note", "title": "x", "created_at": 1, "modified_at": 1
|
||||||
|
})).unwrap();
|
||||||
|
assert!(item.app_settings.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ pub mod matching;
|
|||||||
pub mod passkey;
|
pub mod passkey;
|
||||||
pub mod totp;
|
pub mod totp;
|
||||||
|
|
||||||
pub use item::{normalize_tags, provider_name, Item, ItemKind, Passkey, SignInWith, MAX_TAGS, MAX_TAG_LEN, PROVIDERS};
|
pub use item::{
|
||||||
|
app_settings_id, normalize_tags, provider_name, AppSettings, Item, ItemKind, Passkey, SignInWith, MAX_TAGS,
|
||||||
|
MAX_TAG_LEN, PROVIDERS,
|
||||||
|
};
|
||||||
#[cfg(feature = "psl")]
|
#[cfg(feature = "psl")]
|
||||||
pub use matching::registrable_domain;
|
pub use matching::registrable_domain;
|
||||||
pub use matching::{match_url, MatchLevel};
|
pub use matching::{match_url, MatchLevel};
|
||||||
|
|||||||
@@ -257,7 +257,9 @@ impl WebCore {
|
|||||||
|
|
||||||
pub fn list_items(&self) -> Result<Vec<ItemSummary>> {
|
pub fn list_items(&self) -> Result<Vec<ItemSummary>> {
|
||||||
let (acct, u) = self.unlocked_snapshot()?;
|
let (acct, u) = self.unlocked_snapshot()?;
|
||||||
let mut out: Vec<ItemSummary> = self.all_items(&acct, &u)?.iter().map(summary).collect();
|
// The account's settings item (docs/02 "App settings") is never listed.
|
||||||
|
let mut out: Vec<ItemSummary> =
|
||||||
|
self.all_items(&acct, &u)?.iter().filter(|i| i.app_settings.is_none()).map(summary).collect();
|
||||||
out.sort_by(|a, b| {
|
out.sort_by(|a, b| {
|
||||||
b.favorite.cmp(&a.favorite).then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase()))
|
b.favorite.cmp(&a.favorite).then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase()))
|
||||||
});
|
});
|
||||||
@@ -322,6 +324,57 @@ impl WebCore {
|
|||||||
Ok(self.store.put_local(vault, &tomb)?)
|
Ok(self.store.put_local(vault, &tomb)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The theme synced across this account's devices (kv-ffi `app_theme`): `(theme, appearance)`.
|
||||||
|
pub fn app_theme(&self) -> Result<AppThemeDto> {
|
||||||
|
let (acct, u) = self.unlocked_snapshot()?;
|
||||||
|
let id = kv_model::app_settings_id(acct.account_id);
|
||||||
|
let Some(li) = self.store.get(acct.primary_vault, id)? else { return Ok(AppThemeDto::default()) };
|
||||||
|
let s = decrypt(&acct, &u.vault_key, &li.record)?.and_then(|i| i.app_settings.clone()).unwrap_or_default();
|
||||||
|
Ok(AppThemeDto { theme: s.theme, appearance: s.appearance })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves the theme for every device of this account (kv-ffi `set_app_theme`; same item, same rules).
|
||||||
|
pub fn set_app_theme(&self, theme: Option<String>, appearance: Option<String>) -> Result<()> {
|
||||||
|
let valid = |s: &str| {
|
||||||
|
!s.is_empty()
|
||||||
|
&& s.len() <= 32
|
||||||
|
&& s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
|
||||||
|
};
|
||||||
|
if theme.as_deref().is_some_and(|t| !valid(t)) {
|
||||||
|
return Err(CoreError::Invalid { msg: "theme".into() });
|
||||||
|
}
|
||||||
|
if appearance.as_deref().is_some_and(|a| !["system", "light", "dark"].contains(&a)) {
|
||||||
|
return Err(CoreError::Invalid { msg: "appearance".into() });
|
||||||
|
}
|
||||||
|
let (acct, u) = self.unlocked_snapshot()?;
|
||||||
|
let id = kv_model::app_settings_id(acct.account_id);
|
||||||
|
let existing = self.store.get(acct.primary_vault, id)?;
|
||||||
|
let current = match &existing {
|
||||||
|
Some(li) => decrypt(&acct, &u.vault_key, &li.record)?,
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let had = current.is_some();
|
||||||
|
let mut model = current.unwrap_or_else(|| {
|
||||||
|
let mut item = Item::new_login("Kestrove settings", now());
|
||||||
|
item.id = id;
|
||||||
|
item.kind = kv_model::ItemKind::SecureNote;
|
||||||
|
item.notes = Some(
|
||||||
|
"Kestrove keeps the settings that sync between your devices here, such as your colour theme. \
|
||||||
|
Newer versions of the app hide this note. Deleting it only resets those settings."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
item
|
||||||
|
});
|
||||||
|
let mut settings = model.app_settings.take().unwrap_or_default();
|
||||||
|
if had && settings.theme == theme && settings.appearance == appearance {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
settings.theme = theme;
|
||||||
|
settings.appearance = appearance;
|
||||||
|
model.app_settings = Some(settings);
|
||||||
|
self.write_item(&acct, &u, &mut model, existing.filter(|_| had), had)
|
||||||
|
}
|
||||||
|
|
||||||
/// Earlier versions of an item (local edits and merge losers), newest first. At most 10.
|
/// Earlier versions of an item (local edits and merge losers), newest first. At most 10.
|
||||||
pub fn item_history(&self, id: &str) -> Result<Vec<ItemDto>> {
|
pub fn item_history(&self, id: &str) -> Result<Vec<ItemDto>> {
|
||||||
let id = parse_id(id)?;
|
let id = parse_id(id)?;
|
||||||
|
|||||||
@@ -313,3 +313,10 @@ pub struct JoinConfirm {
|
|||||||
/// Same format as `DeviceDto.key_fingerprint`.
|
/// Same format as `DeviceDto.key_fingerprint`.
|
||||||
pub key_fingerprint: String,
|
pub key_fingerprint: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AppThemeDto {
|
||||||
|
pub theme: Option<String>,
|
||||||
|
pub appearance: Option<String>,
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ export interface SignInWith { provider: string; account?: string | null; }
|
|||||||
|
|
||||||
export interface SignInProvider { id: string; name: string; }
|
export interface SignInProvider { id: string; name: string; }
|
||||||
|
|
||||||
|
/** Synced theme (docs/02 "App settings"): a theme id such as "ocean", and "system" | "light" | "dark". */
|
||||||
|
export interface AppThemeDto { theme: string | null; appearance: string | null; }
|
||||||
|
|
||||||
/** `id: ""` creates a new item. Omitted optional fields default to null / [] / false / 0. */
|
/** `id: ""` creates a new item. Omitted optional fields default to null / [] / false / 0. */
|
||||||
export interface ItemDto {
|
export interface ItemDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -525,6 +528,17 @@ impl VaultCore {
|
|||||||
self.run(|s| async move { s.core.list_items() })
|
self.run(|s| async move { s.core.list_items() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The colour theme and appearance synced across this account's devices.
|
||||||
|
#[wasm_bindgen(js_name = appTheme, unchecked_return_type = "Promise<AppThemeDto>")]
|
||||||
|
pub fn app_theme(&self) -> Promise {
|
||||||
|
self.run(|s| async move { s.core.app_theme() })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen(js_name = setAppTheme, unchecked_return_type = "Promise<void>")]
|
||||||
|
pub fn set_app_theme(&self, theme: Option<String>, appearance: Option<String>) -> Promise {
|
||||||
|
self.run(move |s| async move { s.core.set_app_theme(theme, appearance) })
|
||||||
|
}
|
||||||
|
|
||||||
#[wasm_bindgen(js_name = getItem, unchecked_return_type = "Promise<ItemDto>")]
|
#[wasm_bindgen(js_name = getItem, unchecked_return_type = "Promise<ItemDto>")]
|
||||||
pub fn get_item(&self, id: String) -> Promise {
|
pub fn get_item(&self, id: String) -> Promise {
|
||||||
self.run(move |s| async move { s.core.get_item(&id) })
|
self.run(move |s| async move { s.core.get_item(&id) })
|
||||||
|
|||||||
@@ -420,3 +420,26 @@ async fn removed_web_vault_reenrols_with_the_same_ticket() {
|
|||||||
web.forget_revoked_device().unwrap();
|
web.forget_revoked_device().unwrap();
|
||||||
assert!(matches!(app.approve_device(old.to_string()).await, Err(kv_ffi::CoreError::NotFound)));
|
assert!(matches!(app.approve_device(old.to_string()).await, Err(kv_ffi::CoreError::NotFound)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn theme_set_in_the_app_reaches_the_web_vault_and_back() {
|
||||||
|
let server = TestServer::start().await;
|
||||||
|
let (app, _sk, _) = app_with_item(&server).await;
|
||||||
|
app.set_app_theme(Some("amber".into()), Some("light".into())).unwrap();
|
||||||
|
app.sync_now().await.unwrap();
|
||||||
|
|
||||||
|
let add = app.add_device_payload().await.unwrap();
|
||||||
|
let setup = kv_wasm::core::parse_setup_payload(&add.payload).unwrap();
|
||||||
|
let (_store, web) = new_web(&server);
|
||||||
|
web.login_with_setup(&setup, PW).await.unwrap();
|
||||||
|
web.sync_now().await.unwrap();
|
||||||
|
let t = web.app_theme().unwrap();
|
||||||
|
assert_eq!((t.theme.as_deref(), t.appearance.as_deref()), (Some("amber"), Some("light")));
|
||||||
|
assert_eq!(web.list_items().unwrap().len(), 1, "only Bank; the settings item is hidden");
|
||||||
|
|
||||||
|
web.set_app_theme(Some("rose".into()), Some("dark".into())).unwrap();
|
||||||
|
web.sync_now().await.unwrap();
|
||||||
|
app.sync_now().await.unwrap();
|
||||||
|
assert_eq!(app.app_theme().unwrap().theme.as_deref(), Some("rose"));
|
||||||
|
assert_eq!(app.list_items().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user