Sync with the server and scan-to-join onboarding
- Device key (DeviceSigner): Windows TPM via CNG with a DPAPI-sealed software fallback, Linux Secret Service with a 0600 file fallback (warned in Settings), macOS keychain for dev runs. The store used is recorded; no silent switching. - SyncService (Tauri-free): server URL and pins, create account with the server's setup code, sync after unlock/save/browser save, every 15 min while unlocked and on demand, status events, integrity alarm, device list with fingerprint approval and revoke. - Onboarding: "Use my phone" (join_start/poll, QR rendered locally, 6-digit code), Emergency Kit from PDF/PNG/JPEG (hayro + rqrr), paste setup code, new account. Then only the master password. - From a signed-in desktop: "Add a phone or tablet" QR with countdown (cancel, lock and quit revoke the ticket) and "Approve a computer" via pasted join code. - KESTROVE_DATA_DIR / KESTROVE_DEVICE_NAME for a second instance; ignored live test drives three instances against a real server. - deny: ignore three unmaintained notices for zbus 5 sub-crates (Linux keyring). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -50,9 +50,29 @@ system's on Windows (`GetLastInputInfo`) and macOS; on Linux it is activity insi
|
|||||||
and fills). Screen lock: Windows input-desktop check, Linux logind `LockedHint`, macOS session dictionary.
|
and fills). Screen lock: Windows input-desktop check, Linux logind `LockedHint`, macOS session dictionary.
|
||||||
Sleep: a jump in the wall clock between polls.
|
Sleep: a jump in the wall clock between polls.
|
||||||
|
|
||||||
|
## Sync and onboarding (docs/03, docs/10)
|
||||||
|
|
||||||
|
* **First start** offers four ways in: **Use my phone** (default: type the server URL, scan the QR on screen
|
||||||
|
with a signed-in phone, compare the 6-digit code, then type only the master password), **Emergency Kit**
|
||||||
|
(PDF, or a PNG/JPEG photo of its QR), **Paste setup code**, and **New account** (server URL + the server's
|
||||||
|
`KV_SETUP_CODE`).
|
||||||
|
* **Device key** (`device_key.rs`): Windows TPM via CNG, else a DPAPI-sealed software key; Linux Secret Service,
|
||||||
|
else a 0600 file (with a warning in Settings); macOS dev runs use the keychain. `KESTROVE_DEVICE_KEY=file`
|
||||||
|
forces the file store.
|
||||||
|
* **Sync** (`sync.rs`) runs after unlock, after a save, every 15 minutes while unlocked, and on "Sync now".
|
||||||
|
Lock drops all keys (core), cancels a scan-to-join and revokes an "Add a device" ticket still on screen.
|
||||||
|
An integrity alarm shows a hard alert and a banner; syncing that vault stops.
|
||||||
|
* **Settings → Devices**: approve pending devices (compare fingerprints), remove devices, **Add a phone or
|
||||||
|
tablet** (10-minute single-use QR; Done/Cancel revokes it) and **Approve a computer** (paste the join code a
|
||||||
|
new computer shows under "No phone?").
|
||||||
|
* **Two instances on one machine**: `KESTROVE_DATA_DIR=/tmp/kv-b Kestrove.app/Contents/MacOS/kestrove-desktop`
|
||||||
|
(the second one has no browser integration). `KESTROVE_DEVICE_NAME` overrides the device name.
|
||||||
|
* **Live test**: `tests/live_sync.rs` (ignored) drives three instances against a running server; see its header.
|
||||||
|
|
||||||
## Roadmap (M3, kestrove-spec/docs/07)
|
## Roadmap (M3, kestrove-spec/docs/07)
|
||||||
- [ ] Unlock with Windows Hello (KeyCredentialManager, TPM) and libsecret on Linux
|
- [ ] Unlock with Windows Hello (KeyCredentialManager, TPM) and libsecret on Linux
|
||||||
- [x] Persistent vault (`VaultCore::open` in app data); sync UI still to do
|
- [x] Persistent vault (`VaultCore::open` in app data)
|
||||||
|
- [x] Sync, device approval and scan-to-join onboarding
|
||||||
- [x] Auto-lock on idle, screen lock and suspend
|
- [x] Auto-lock on idle, screen lock and suspend
|
||||||
- [x] Local IPC between `kestrove-nmh` and the app, plus the pairing AEAD channel
|
- [x] Local IPC between `kestrove-nmh` and the app, plus the pairing AEAD channel
|
||||||
- [x] Native-messaging manifest installers (`native-messaging/install.sh`, `install.ps1`)
|
- [x] Native-messaging manifest installers (`native-messaging/install.sh`, `install.ps1`)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<main id="app"></main>
|
<main id="app"></main>
|
||||||
<div id="overlay" hidden></div>
|
<div id="overlay" hidden></div>
|
||||||
|
<div id="modal" hidden></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
+1115
-48
File diff suppressed because it is too large
Load Diff
@@ -40,12 +40,25 @@ base64 = "0.22"
|
|||||||
uuid = { version = "1", features = ["v4"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
url = "2"
|
url = "2"
|
||||||
zeroize = "1"
|
zeroize = "1"
|
||||||
|
# Device key (docs/03 "Device keys"): P-256 software key where no hardware key is available.
|
||||||
|
p256 = { version = "0.13", features = ["ecdsa"] }
|
||||||
|
# Onboarding (docs/10): render QR codes locally, read the Emergency Kit QR from an image or a PDF.
|
||||||
|
qrcode = { version = "0.14", default-features = false, features = ["svg"] }
|
||||||
|
rqrr = { version = "0.11", default-features = false }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
|
||||||
|
hayro = { version = "0.8", default-features = false }
|
||||||
|
tokio = { version = "1", features = ["time", "sync", "macros"] }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
|
||||||
|
# macOS (dev) keychain / Linux Secret Service for the device key. Windows uses CNG (TPM) or DPAPI instead.
|
||||||
|
[target.'cfg(not(windows))'.dependencies]
|
||||||
|
keyring = "4"
|
||||||
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
[target.'cfg(windows)'.dependencies]
|
||||||
windows-sys = { version = "0.61", features = [
|
windows-sys = { version = "0.61", features = [
|
||||||
|
"Win32_Security_Cryptography",
|
||||||
"Win32_Foundation",
|
"Win32_Foundation",
|
||||||
"Win32_Security",
|
"Win32_Security",
|
||||||
"Win32_Security_Authorization",
|
"Win32_Security_Authorization",
|
||||||
@@ -63,6 +76,7 @@ core-foundation = "0.10"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ ignore = [
|
|||||||
{ id = "RUSTSEC-2025-0141", reason = "bincode 1.x via uniffi_macros 0.28 (build-time)" },
|
{ id = "RUSTSEC-2025-0141", reason = "bincode 1.x via uniffi_macros 0.28 (build-time)" },
|
||||||
{ id = "RUSTSEC-2024-0436", reason = "paste via uniffi 0.28 (proc-macro)" },
|
{ id = "RUSTSEC-2024-0436", reason = "paste via uniffi 0.28 (proc-macro)" },
|
||||||
{ id = "RUSTSEC-2024-0370", reason = "proc-macro-error via glib-macros 0.18 / Tauri gtk stack (proc-macro)" },
|
{ id = "RUSTSEC-2024-0370", reason = "proc-macro-error via glib-macros 0.18 / Tauri gtk stack (proc-macro)" },
|
||||||
|
# zbus 5 sub-crates folded into zbus 6 (no vulnerability). Linux only: keyring's Secret Service store
|
||||||
|
# (device key) still uses zbus 5; revisit when secret-service moves to zbus 6.
|
||||||
|
{ id = "RUSTSEC-2026-0370", reason = "zvariant via zbus 5 (keyring Secret Service store, Linux)" },
|
||||||
|
{ id = "RUSTSEC-2026-0371", reason = "zbus_names via zbus 5 (keyring Secret Service store, Linux)" },
|
||||||
|
{ id = "RUSTSEC-2026-0372", reason = "zvariant_derive via zbus 5 (keyring Secret Service store, Linux)" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[licenses]
|
[licenses]
|
||||||
|
|||||||
@@ -0,0 +1,545 @@
|
|||||||
|
//! This device's P-256 request-signing key (docs/03 "Device keys"), behind kv-ffi's `DeviceSigner`.
|
||||||
|
//!
|
||||||
|
//! Where the key lives, best first:
|
||||||
|
//! * Windows: a non-exportable key in the TPM (CNG "Microsoft Platform Crypto Provider"); without a TPM, a
|
||||||
|
//! software key sealed with DPAPI (current user) in the app-data folder.
|
||||||
|
//! * Linux: a software key in the Secret Service (GNOME Keyring, KWallet); without one, a 0600 file (with a warning).
|
||||||
|
//! * macOS (development only; the shipped Mac app is kestrove-apple): the login keychain.
|
||||||
|
//!
|
||||||
|
//! The store that created the key is recorded next to the device id. A later launch uses only that store: if it
|
||||||
|
//! is unavailable, signing fails instead of silently enrolling a new device.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use kv_ffi::CoreError;
|
||||||
|
use p256::ecdsa::{signature::Signer as _, Signature, SigningKey};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::private_file;
|
||||||
|
|
||||||
|
const ID_FILE: &str = "device-id";
|
||||||
|
const STORE_FILE: &str = "device-key-store";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum KeyStorage {
|
||||||
|
/// Windows TPM via CNG; the private key never leaves the chip.
|
||||||
|
Tpm,
|
||||||
|
/// Windows software key sealed with DPAPI (current user).
|
||||||
|
Dpapi,
|
||||||
|
/// macOS keychain (development builds).
|
||||||
|
Keychain,
|
||||||
|
/// Linux Secret Service.
|
||||||
|
SecretService,
|
||||||
|
/// Owner-only file in the app-data folder (Linux without a Secret Service, tests).
|
||||||
|
File,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl KeyStorage {
|
||||||
|
fn tag(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
KeyStorage::Tpm => "tpm",
|
||||||
|
KeyStorage::Dpapi => "dpapi",
|
||||||
|
KeyStorage::Keychain => "keychain",
|
||||||
|
KeyStorage::SecretService => "secret-service",
|
||||||
|
KeyStorage::File => "file",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_tag(s: &str) -> Option<Self> {
|
||||||
|
[KeyStorage::Tpm, KeyStorage::Dpapi, KeyStorage::Keychain, KeyStorage::SecretService, KeyStorage::File]
|
||||||
|
.into_iter()
|
||||||
|
.find(|k| k.tag() == s.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For the settings screen.
|
||||||
|
pub fn describe(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
KeyStorage::Tpm => "TPM (hardware)",
|
||||||
|
KeyStorage::Dpapi => "Windows DPAPI (software key)",
|
||||||
|
KeyStorage::Keychain => "macOS keychain (software key)",
|
||||||
|
KeyStorage::SecretService => "Secret Service keyring (software key)",
|
||||||
|
KeyStorage::File => "file in the app folder (software key)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Backend {
|
||||||
|
Software(SigningKey),
|
||||||
|
#[cfg(windows)]
|
||||||
|
Cng(cng::Key),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct DeviceKey {
|
||||||
|
id: String,
|
||||||
|
public: Vec<u8>,
|
||||||
|
backend: Backend,
|
||||||
|
storage: KeyStorage,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for DeviceKey {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.debug_struct("DeviceKey").field("id", &self.id).field("storage", &self.storage).finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeviceKey {
|
||||||
|
/// Loads this device's key, creating it (and a new device id) on first use in the best store available.
|
||||||
|
pub fn load_or_create(data_dir: &Path) -> io::Result<Self> {
|
||||||
|
Self::load_or_create_with(data_dir, &platform_stores())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Same, but only in an owner-only file. For tests and for `KESTROVE_DEVICE_KEY=file` dev runs.
|
||||||
|
pub fn load_or_create_file(data_dir: &Path) -> io::Result<Self> {
|
||||||
|
Self::load_or_create_with(data_dir, &[KeyStorage::File])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_or_create_with(data_dir: &Path, preferred: &[KeyStorage]) -> io::Result<Self> {
|
||||||
|
let id_path = data_dir.join(ID_FILE);
|
||||||
|
let store_path = data_dir.join(STORE_FILE);
|
||||||
|
let id = read_text(&id_path)?;
|
||||||
|
let recorded = read_text(&store_path)?.and_then(|s| KeyStorage::from_tag(&s));
|
||||||
|
if let (Some(id), Some(storage)) = (id, recorded) {
|
||||||
|
if let Some(key) = load(data_dir, &id, storage)? {
|
||||||
|
return Ok(key);
|
||||||
|
}
|
||||||
|
// The id is known but its key is gone (keyring wiped, folder copied from another machine):
|
||||||
|
// this is a new device as far as the server is concerned.
|
||||||
|
eprintln!("kestrove: the device key in {} is missing; creating a new device key", storage.describe());
|
||||||
|
}
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let mut last_err = io::Error::other("no key store available");
|
||||||
|
for &storage in preferred {
|
||||||
|
match create(data_dir, &id, storage) {
|
||||||
|
Ok(key) => {
|
||||||
|
private_file::write(&id_path, id.as_bytes())?;
|
||||||
|
private_file::write(&store_path, storage.tag().as_bytes())?;
|
||||||
|
return Ok(key);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("kestrove: device key store {} unavailable: {e}", storage.describe());
|
||||||
|
last_err = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(last_err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn storage(&self) -> KeyStorage {
|
||||||
|
self.storage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shown in settings when the key is less protected than it should be.
|
||||||
|
pub fn warning(&self) -> Option<&'static str> {
|
||||||
|
(self.storage == KeyStorage::File).then_some(
|
||||||
|
"No system keyring was found, so this device's sync key is stored in a file only your user can read.",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_raw(&self, message: &[u8]) -> io::Result<Vec<u8>> {
|
||||||
|
match &self.backend {
|
||||||
|
Backend::Software(k) => {
|
||||||
|
let sig: Signature = k.sign(message);
|
||||||
|
Ok(sig.to_bytes().to_vec())
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
Backend::Cng(k) => k.sign(message),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl kv_ffi::DeviceSigner for DeviceKey {
|
||||||
|
fn device_id(&self) -> String {
|
||||||
|
self.id.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn public_key(&self) -> Vec<u8> {
|
||||||
|
self.public.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, CoreError> {
|
||||||
|
self.sign_raw(&message).map_err(|e| CoreError::Internal { msg: format!("device key: {e}") })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_text(path: &Path) -> io::Result<Option<String>> {
|
||||||
|
Ok(private_file::read(path)?.and_then(|b| String::from_utf8(b).ok()).map(|s| s.trim().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platform_stores() -> Vec<KeyStorage> {
|
||||||
|
if std::env::var_os("KESTROVE_DEVICE_KEY").is_some_and(|v| v == "file") {
|
||||||
|
return vec![KeyStorage::File];
|
||||||
|
}
|
||||||
|
if cfg!(windows) {
|
||||||
|
vec![KeyStorage::Tpm, KeyStorage::Dpapi]
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
vec![KeyStorage::Keychain, KeyStorage::File]
|
||||||
|
} else {
|
||||||
|
vec![KeyStorage::SecretService, KeyStorage::File]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn software(id: &str, key: SigningKey, storage: KeyStorage) -> DeviceKey {
|
||||||
|
let public = key.verifying_key().to_encoded_point(false).as_bytes().to_vec();
|
||||||
|
DeviceKey { id: id.to_string(), public, backend: Backend::Software(key), storage }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scalar(bytes: &[u8]) -> io::Result<SigningKey> {
|
||||||
|
SigningKey::from_slice(bytes).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "corrupt device key"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new_scalar() -> Zeroizing<Vec<u8>> {
|
||||||
|
// `SigningKey::random` needs a rand_core RNG; draw a scalar from the OS and retry the (2^-128) invalid case.
|
||||||
|
loop {
|
||||||
|
let mut b = Zeroizing::new(vec![0u8; 32]);
|
||||||
|
getrandom::getrandom(&mut b).expect("OS random number generator");
|
||||||
|
if SigningKey::from_slice(&b).is_ok() {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_file(data_dir: &Path) -> PathBuf {
|
||||||
|
data_dir.join("device-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load(data_dir: &Path, id: &str, storage: KeyStorage) -> io::Result<Option<DeviceKey>> {
|
||||||
|
let secret = match storage {
|
||||||
|
KeyStorage::File => private_file::read(&key_file(data_dir))?.map(Zeroizing::new),
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
KeyStorage::Keychain | KeyStorage::SecretService => keyring_slot::get(id)?,
|
||||||
|
#[cfg(windows)]
|
||||||
|
KeyStorage::Dpapi => match private_file::read(&key_file(data_dir).with_extension("dpapi"))? {
|
||||||
|
Some(sealed) => Some(dpapi::unprotect(&sealed)?),
|
||||||
|
None => None,
|
||||||
|
},
|
||||||
|
#[cfg(windows)]
|
||||||
|
KeyStorage::Tpm => {
|
||||||
|
return Ok(cng::Key::open(id)?.map(|k| DeviceKey {
|
||||||
|
id: id.to_string(),
|
||||||
|
public: k.public_key().unwrap_or_default(),
|
||||||
|
backend: Backend::Cng(k),
|
||||||
|
storage,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
#[allow(unreachable_patterns)]
|
||||||
|
_ => return Err(io::Error::other(format!("{} is not available on this platform", storage.describe()))),
|
||||||
|
};
|
||||||
|
secret.map(|s| Ok(software(id, parse_scalar(&s)?, storage))).transpose()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(data_dir: &Path, id: &str, storage: KeyStorage) -> io::Result<DeviceKey> {
|
||||||
|
#[cfg(windows)]
|
||||||
|
if storage == KeyStorage::Tpm {
|
||||||
|
let k = cng::Key::create(id)?;
|
||||||
|
let public = k.public_key()?;
|
||||||
|
return Ok(DeviceKey { id: id.to_string(), public, backend: Backend::Cng(k), storage });
|
||||||
|
}
|
||||||
|
let secret = new_scalar();
|
||||||
|
match storage {
|
||||||
|
KeyStorage::File => private_file::write(&key_file(data_dir), &secret)?,
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
KeyStorage::Keychain | KeyStorage::SecretService => keyring_slot::put(id, &secret)?,
|
||||||
|
#[cfg(windows)]
|
||||||
|
KeyStorage::Dpapi => {
|
||||||
|
private_file::write(&key_file(data_dir).with_extension("dpapi"), &dpapi::protect(&secret)?)?
|
||||||
|
}
|
||||||
|
#[allow(unreachable_patterns)]
|
||||||
|
_ => return Err(io::Error::other(format!("{} is not available on this platform", storage.describe()))),
|
||||||
|
}
|
||||||
|
Ok(software(id, parse_scalar(&secret)?, storage))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
mod keyring_slot {
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
const SERVICE: &str = "app.kestrove.desktop";
|
||||||
|
|
||||||
|
fn entry(id: &str) -> io::Result<keyring::Entry> {
|
||||||
|
keyring::Entry::new(SERVICE, &format!("device-key-{id}")).map_err(io::Error::other)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get(id: &str) -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||||
|
match entry(id)?.get_secret() {
|
||||||
|
Ok(s) => Ok(Some(Zeroizing::new(s))),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(io::Error::other(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put(id: &str, secret: &[u8]) -> io::Result<()> {
|
||||||
|
let e = entry(id)?;
|
||||||
|
e.set_secret(secret).map_err(io::Error::other)?;
|
||||||
|
// Read back: some Secret Service setups accept a write into a locked collection and lose it.
|
||||||
|
match e.get_secret() {
|
||||||
|
Ok(s) if s == secret => Ok(()),
|
||||||
|
_ => Err(io::Error::other("the keyring did not keep the device key")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod dpapi {
|
||||||
|
use std::io;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
use windows_sys::Win32::Foundation::LocalFree;
|
||||||
|
use windows_sys::Win32::Security::Cryptography::{
|
||||||
|
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
|
||||||
|
};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
const ENTROPY: &[u8] = b"kestrove/v1/desktop/device-key";
|
||||||
|
|
||||||
|
fn blob(b: &[u8]) -> CRYPT_INTEGER_BLOB {
|
||||||
|
CRYPT_INTEGER_BLOB { cbData: b.len() as u32, pbData: b.as_ptr() as *mut u8 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copies DPAPI's output and frees it.
|
||||||
|
unsafe fn take(out: CRYPT_INTEGER_BLOB) -> Zeroizing<Vec<u8>> {
|
||||||
|
let v = Zeroizing::new(std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec());
|
||||||
|
ptr::write_bytes(out.pbData, 0, out.cbData as usize);
|
||||||
|
LocalFree(out.pbData as _);
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn protect(secret: &[u8]) -> io::Result<Vec<u8>> {
|
||||||
|
let (input, entropy) = (blob(secret), blob(ENTROPY));
|
||||||
|
let mut out = CRYPT_INTEGER_BLOB { cbData: 0, pbData: ptr::null_mut() };
|
||||||
|
// SAFETY: the input blobs point at live slices; on success DPAPI allocates `out`, which `take` frees.
|
||||||
|
let ok = unsafe {
|
||||||
|
CryptProtectData(
|
||||||
|
&input,
|
||||||
|
ptr::null(),
|
||||||
|
&entropy,
|
||||||
|
ptr::null(),
|
||||||
|
ptr::null(),
|
||||||
|
CRYPTPROTECT_UI_FORBIDDEN,
|
||||||
|
&mut out,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(unsafe { take(out) }.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unprotect(sealed: &[u8]) -> io::Result<Zeroizing<Vec<u8>>> {
|
||||||
|
let (input, entropy) = (blob(sealed), blob(ENTROPY));
|
||||||
|
let mut out = CRYPT_INTEGER_BLOB { cbData: 0, pbData: ptr::null_mut() };
|
||||||
|
// SAFETY: as in `protect`.
|
||||||
|
let ok = unsafe {
|
||||||
|
CryptUnprotectData(
|
||||||
|
&input,
|
||||||
|
ptr::null_mut(),
|
||||||
|
&entropy,
|
||||||
|
ptr::null(),
|
||||||
|
ptr::null(),
|
||||||
|
CRYPTPROTECT_UI_FORBIDDEN,
|
||||||
|
&mut out,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if ok == 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(unsafe { take(out) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
mod cng {
|
||||||
|
use std::io;
|
||||||
|
use std::ptr;
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use windows_sys::Win32::Security::Cryptography::{
|
||||||
|
NCryptCreatePersistedKey, NCryptExportKey, NCryptFinalizeKey, NCryptFreeObject, NCryptOpenKey,
|
||||||
|
NCryptOpenStorageProvider, NCryptSignHash, BCRYPT_ECCKEY_BLOB, BCRYPT_ECCPUBLIC_BLOB,
|
||||||
|
BCRYPT_ECDSA_P256_ALGORITHM, BCRYPT_ECDSA_PUBLIC_P256_MAGIC, MS_PLATFORM_CRYPTO_PROVIDER, NCRYPT_KEY_HANDLE,
|
||||||
|
NCRYPT_PROV_HANDLE, NCRYPT_SILENT_FLAG,
|
||||||
|
};
|
||||||
|
|
||||||
|
const NTE_BAD_KEYSET: i32 = 0x8009_0016_u32 as i32;
|
||||||
|
|
||||||
|
pub struct Key(NCRYPT_KEY_HANDLE);
|
||||||
|
|
||||||
|
// SAFETY: NCrypt key handles may be used from any thread.
|
||||||
|
unsafe impl Send for Key {}
|
||||||
|
unsafe impl Sync for Key {}
|
||||||
|
|
||||||
|
impl Drop for Key {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: the handle came from NCryptOpenKey/NCryptCreatePersistedKey and is freed once.
|
||||||
|
unsafe { NCryptFreeObject(self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn check(hr: i32, what: &str) -> io::Result<()> {
|
||||||
|
if hr == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(io::Error::other(format!("{what} failed: 0x{:08x}", hr as u32)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wide(s: &str) -> Vec<u16> {
|
||||||
|
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key_name(id: &str) -> Vec<u16> {
|
||||||
|
wide(&format!("Kestrove device {id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Provider(NCRYPT_PROV_HANDLE);
|
||||||
|
|
||||||
|
impl Drop for Provider {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// SAFETY: opened by NCryptOpenStorageProvider below.
|
||||||
|
unsafe { NCryptFreeObject(self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn provider() -> io::Result<Provider> {
|
||||||
|
let mut h: NCRYPT_PROV_HANDLE = 0;
|
||||||
|
// SAFETY: out-pointer to a local; the provider name is a static wide string.
|
||||||
|
check(unsafe { NCryptOpenStorageProvider(&mut h, MS_PLATFORM_CRYPTO_PROVIDER, 0) }, "opening the TPM")?;
|
||||||
|
Ok(Provider(h))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Key {
|
||||||
|
pub fn open(id: &str) -> io::Result<Option<Self>> {
|
||||||
|
let p = provider()?;
|
||||||
|
let name = key_name(id);
|
||||||
|
let mut h: NCRYPT_KEY_HANDLE = 0;
|
||||||
|
// SAFETY: valid provider handle, NUL-terminated name, out-pointer to a local.
|
||||||
|
let hr = unsafe { NCryptOpenKey(p.0, &mut h, name.as_ptr(), 0, NCRYPT_SILENT_FLAG) };
|
||||||
|
if hr == NTE_BAD_KEYSET {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
check(hr, "opening the TPM key")?;
|
||||||
|
Ok(Some(Key(h)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create(id: &str) -> io::Result<Self> {
|
||||||
|
let p = provider()?;
|
||||||
|
let name = key_name(id);
|
||||||
|
let mut h: NCRYPT_KEY_HANDLE = 0;
|
||||||
|
// SAFETY: as in `open`; the key is non-exportable by default.
|
||||||
|
check(
|
||||||
|
unsafe { NCryptCreatePersistedKey(p.0, &mut h, BCRYPT_ECDSA_P256_ALGORITHM, name.as_ptr(), 0, 0) },
|
||||||
|
"creating the TPM key",
|
||||||
|
)?;
|
||||||
|
let key = Key(h);
|
||||||
|
// SAFETY: a handle from NCryptCreatePersistedKey.
|
||||||
|
check(unsafe { NCryptFinalizeKey(key.0, NCRYPT_SILENT_FLAG) }, "finalizing the TPM key")?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SEC1 uncompressed public key (0x04 || X || Y).
|
||||||
|
pub fn public_key(&self) -> io::Result<Vec<u8>> {
|
||||||
|
let mut buf = [0u8; 8 + 64];
|
||||||
|
let mut len = 0u32;
|
||||||
|
// SAFETY: the output buffer is large enough for a P-256 BCRYPT_ECCPUBLIC_BLOB (8-byte header + X + Y).
|
||||||
|
check(
|
||||||
|
unsafe {
|
||||||
|
NCryptExportKey(
|
||||||
|
self.0,
|
||||||
|
0,
|
||||||
|
BCRYPT_ECCPUBLIC_BLOB,
|
||||||
|
ptr::null(),
|
||||||
|
buf.as_mut_ptr(),
|
||||||
|
buf.len() as u32,
|
||||||
|
&mut len,
|
||||||
|
0,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"exporting the TPM public key",
|
||||||
|
)?;
|
||||||
|
let header = std::mem::size_of::<BCRYPT_ECCKEY_BLOB>();
|
||||||
|
let magic = u32::from_le_bytes(buf[0..4].try_into().expect("4 bytes"));
|
||||||
|
let cb = u32::from_le_bytes(buf[4..8].try_into().expect("4 bytes")) as usize;
|
||||||
|
if magic != BCRYPT_ECDSA_PUBLIC_P256_MAGIC || cb != 32 || len as usize != header + 64 {
|
||||||
|
return Err(io::Error::other("unexpected TPM public key format"));
|
||||||
|
}
|
||||||
|
let mut out = vec![0x04];
|
||||||
|
out.extend_from_slice(&buf[header..header + 64]);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ECDSA P-256 over SHA-256(message); CNG returns the raw r || s form the core expects.
|
||||||
|
pub fn sign(&self, message: &[u8]) -> io::Result<Vec<u8>> {
|
||||||
|
let digest = Sha256::digest(message);
|
||||||
|
let mut sig = vec![0u8; 64];
|
||||||
|
let mut len = 0u32;
|
||||||
|
// SAFETY: digest and signature buffers are live and sized as passed.
|
||||||
|
check(
|
||||||
|
unsafe {
|
||||||
|
NCryptSignHash(
|
||||||
|
self.0,
|
||||||
|
ptr::null(),
|
||||||
|
digest.as_ptr(),
|
||||||
|
digest.len() as u32,
|
||||||
|
sig.as_mut_ptr(),
|
||||||
|
sig.len() as u32,
|
||||||
|
&mut len,
|
||||||
|
NCRYPT_SILENT_FLAG,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"signing with the TPM key",
|
||||||
|
)?;
|
||||||
|
sig.truncate(len as usize);
|
||||||
|
Ok(sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use kv_ffi::DeviceSigner;
|
||||||
|
use p256::ecdsa::{signature::Verifier, VerifyingKey};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn file_key_is_stable_and_signs_raw_p256() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let a = DeviceKey::load_or_create_file(dir.path()).unwrap();
|
||||||
|
let b = DeviceKey::load_or_create_file(dir.path()).unwrap();
|
||||||
|
assert_eq!(a.device_id(), b.device_id());
|
||||||
|
assert_eq!(a.public_key(), b.public_key());
|
||||||
|
assert_eq!(a.public_key().len(), 65);
|
||||||
|
assert!(uuid::Uuid::parse_str(&a.device_id()).is_ok());
|
||||||
|
|
||||||
|
let sig = a.sign(b"hello".to_vec()).unwrap();
|
||||||
|
assert_eq!(sig.len(), 64);
|
||||||
|
let vk = VerifyingKey::from_sec1_bytes(&a.public_key()).unwrap();
|
||||||
|
vk.verify(b"hello", &Signature::from_slice(&sig).unwrap()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lost_key_means_new_device_id() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let a = DeviceKey::load_or_create_file(dir.path()).unwrap();
|
||||||
|
std::fs::remove_file(key_file(dir.path())).unwrap();
|
||||||
|
let b = DeviceKey::load_or_create_file(dir.path()).unwrap();
|
||||||
|
assert_ne!(a.device_id(), b.device_id());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn key_file_is_owner_only() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
DeviceKey::load_or_create_file(dir.path()).unwrap();
|
||||||
|
let mode = std::fs::metadata(key_file(dir.path())).unwrap().permissions().mode();
|
||||||
|
assert_eq!(mode & 0o777, 0o600);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,10 @@
|
|||||||
pub mod autolock;
|
pub mod autolock;
|
||||||
pub mod broker;
|
pub mod broker;
|
||||||
pub mod channel;
|
pub mod channel;
|
||||||
|
pub mod device_key;
|
||||||
pub mod framing;
|
pub mod framing;
|
||||||
pub mod ipc;
|
pub mod ipc;
|
||||||
pub mod private_file;
|
pub mod private_file;
|
||||||
|
pub mod qr;
|
||||||
|
pub mod sync;
|
||||||
pub mod words;
|
pub mod words;
|
||||||
|
|||||||
+200
-33
@@ -10,6 +10,11 @@ use std::{io, thread};
|
|||||||
|
|
||||||
use kestrove_desktop::autolock::{self, Activity, Reason};
|
use kestrove_desktop::autolock::{self, Activity, Reason};
|
||||||
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
||||||
|
use kestrove_desktop::device_key::DeviceKey;
|
||||||
|
use kestrove_desktop::sync::{
|
||||||
|
self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService,
|
||||||
|
SyncStatus,
|
||||||
|
};
|
||||||
use kestrove_desktop::{ipc, private_file};
|
use kestrove_desktop::{ipc, private_file};
|
||||||
use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore};
|
use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -22,6 +27,7 @@ const CLIPBOARD_CLEAR: Duration = Duration::from_secs(90);
|
|||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
core: Arc<VaultCore>,
|
core: Arc<VaultCore>,
|
||||||
|
sync: Arc<SyncService>,
|
||||||
broker: Arc<Broker>,
|
broker: Arc<Broker>,
|
||||||
activity: Arc<Activity>,
|
activity: Arc<Activity>,
|
||||||
data_dir: PathBuf,
|
data_dir: PathBuf,
|
||||||
@@ -32,9 +38,6 @@ struct App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
fn secret_key_path(&self) -> PathBuf {
|
|
||||||
self.data_dir.join("secret-key")
|
|
||||||
}
|
|
||||||
fn settings_path(&self) -> PathBuf {
|
fn settings_path(&self) -> PathBuf {
|
||||||
self.data_dir.join("settings.json")
|
self.data_dir.join("settings.json")
|
||||||
}
|
}
|
||||||
@@ -137,36 +140,32 @@ impl Ui for TauriUi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remember_secret_key(app: &App, secret_key: &str) {
|
|
||||||
if let Err(e) = private_file::write(&app.secret_key_path(), secret_key.trim().as_bytes()) {
|
|
||||||
eprintln!("kestrove: could not store the Secret Key: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn stored_secret_key(app: &App) -> Option<Zeroizing<String>> {
|
|
||||||
let b = private_file::read(&app.secret_key_path()).ok()??;
|
|
||||||
String::from_utf8(b).ok().map(Zeroizing::new)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn state(app: State<'_, App>) -> AppState {
|
fn state(app: State<'_, App>) -> AppState {
|
||||||
AppState {
|
AppState {
|
||||||
has_account: app.core.has_account(),
|
has_account: app.core.has_account(),
|
||||||
locked: app.core.is_locked(),
|
locked: app.core.is_locked(),
|
||||||
remembers_secret_key: stored_secret_key(&app).is_some(),
|
remembers_secret_key: app.sync.stored_secret_key().is_some(),
|
||||||
ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
||||||
idle_minutes: app.idle_minutes.load(Ordering::Relaxed),
|
idle_minutes: app.idle_minutes.load(Ordering::Relaxed),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Argon2id takes ~0.5 s, so these run as async commands off the main thread.
|
// Argon2id takes ~0.5 s, so these run as async commands off the main thread.
|
||||||
|
|
||||||
|
/// "Create a new account": a new vault here, registered with a fresh server using its setup code.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn create_account(app: State<'_, App>, master_password: String) -> CmdResult<String> {
|
async fn create_account(
|
||||||
|
app: State<'_, App>,
|
||||||
|
server_url: String,
|
||||||
|
pins: String,
|
||||||
|
setup_code: String,
|
||||||
|
master_password: String,
|
||||||
|
) -> CmdResult<String> {
|
||||||
let master_password = Zeroizing::new(master_password);
|
let master_password = Zeroizing::new(master_password);
|
||||||
let sk = app.core.create_account(master_password.to_string()).map_err(err)?;
|
let sk = app.sync.create_account(&server_url, sync::parse_pins(&pins), &setup_code, master_password).await;
|
||||||
remember_secret_key(&app, &sk);
|
|
||||||
app.activity.touch();
|
app.activity.touch();
|
||||||
Ok(sk)
|
sk.map(|s| s.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `secret_key` may be omitted once this device remembers it.
|
/// `secret_key` may be omitted once this device remembers it.
|
||||||
@@ -174,18 +173,21 @@ async fn create_account(app: State<'_, App>, master_password: String) -> CmdResu
|
|||||||
async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option<String>) -> CmdResult<()> {
|
async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option<String>) -> CmdResult<()> {
|
||||||
let master_password = Zeroizing::new(master_password);
|
let master_password = Zeroizing::new(master_password);
|
||||||
let typed = secret_key.map(Zeroizing::new).filter(|s| !s.trim().is_empty());
|
let typed = secret_key.map(Zeroizing::new).filter(|s| !s.trim().is_empty());
|
||||||
let sk = typed.clone().or_else(|| stored_secret_key(&app)).ok_or("Enter your Secret Key.")?;
|
let sk = typed.clone().or_else(|| app.sync.stored_secret_key()).ok_or("Enter your Secret Key.")?;
|
||||||
app.core.unlock(master_password.to_string(), sk.trim().to_string()).map_err(err)?;
|
app.core.unlock(master_password.to_string(), sk.trim().to_string()).map_err(err)?;
|
||||||
if let Some(sk) = typed {
|
if let Some(sk) = typed {
|
||||||
remember_secret_key(&app, &sk);
|
app.sync.remember_secret_key(&sk);
|
||||||
}
|
}
|
||||||
app.activity.touch();
|
app.activity.touch();
|
||||||
|
app.sync.request_sync();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn lock(app: State<'_, App>) {
|
async fn lock(app: State<'_, App>) -> CmdResult<()> {
|
||||||
app.core.lock();
|
app.core.lock();
|
||||||
|
app.sync.on_lock().await;
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UI input (throttled in JS): feeds the idle timer where the OS has no system idle time.
|
/// UI input (throttled in JS): feeds the idle timer where the OS has no system idle time.
|
||||||
@@ -251,11 +253,14 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
|
|||||||
modified_at: 0,
|
modified_at: 0,
|
||||||
})
|
})
|
||||||
.map_err(err)
|
.map_err(err)
|
||||||
|
.inspect(|_| app.sync.request_sync())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn delete_item(app: State<'_, App>, id: String) -> CmdResult<()> {
|
fn delete_item(app: State<'_, App>, id: String) -> CmdResult<()> {
|
||||||
app.core.delete_item(id).map_err(err)
|
app.core.delete_item(id).map_err(err)?;
|
||||||
|
app.sync.request_sync();
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -341,7 +346,120 @@ fn pending_saves(app: State<'_, App>) -> Vec<SaveView> {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> {
|
fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> {
|
||||||
app.broker.resolve_save(&id, accept).map(|_| ())
|
if app.broker.resolve_save(&id, accept)?.is_some() {
|
||||||
|
app.sync.request_sync();
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- sync server (docs/03)
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn sync_status(app: State<'_, App>) -> SyncStatus {
|
||||||
|
app.sync.status()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn sync_now(app: State<'_, App>) -> CmdResult<SyncStatus> {
|
||||||
|
app.sync.sync_now().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Settings: server URL and optional pins for a vault created before sync (or to change them).
|
||||||
|
#[tauri::command]
|
||||||
|
fn configure_server(app: State<'_, App>, server_url: String, pins: String) -> CmdResult<()> {
|
||||||
|
app.sync.configure(&server_url, sync::parse_pins(&pins))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn server_setup_open(app: State<'_, App>) -> CmdResult<bool> {
|
||||||
|
app.sync.setup_open().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn register_server(app: State<'_, App>, setup_code: String) -> CmdResult<()> {
|
||||||
|
app.sync.register(&setup_code).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn list_devices(app: State<'_, App>) -> CmdResult<Vec<DeviceView>> {
|
||||||
|
app.sync.devices().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn approve_device(app: State<'_, App>, device_id: String) -> CmdResult<()> {
|
||||||
|
app.sync.approve_device(&device_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn revoke_device(app: State<'_, App>, device_id: String) -> CmdResult<()> {
|
||||||
|
app.sync.revoke_device(&device_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- onboarding (docs/10)
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn join_start(app: State<'_, App>, server_url: String) -> CmdResult<JoinView> {
|
||||||
|
app.sync.join_start(&server_url).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn join_poll(app: State<'_, App>) -> CmdResult<JoinPollView> {
|
||||||
|
app.sync.join_poll().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn join_cancel(app: State<'_, App>) {
|
||||||
|
app.sync.join_cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn setup_from_text(app: State<'_, App>, text: String) -> CmdResult<SetupView> {
|
||||||
|
app.sync.setup_from_text(&Zeroizing::new(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Open Emergency Kit…": the file's bytes (raw IPC body), read by the web view's file picker.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn setup_from_file(app: State<'_, App>, request: tauri::ipc::Request<'_>) -> CmdResult<SetupView> {
|
||||||
|
let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { return Err("Expected the file's bytes.".into()) };
|
||||||
|
app.sync.setup_from_file(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn clear_setup(app: State<'_, App>) {
|
||||||
|
app.sync.clear_setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn finish_setup(app: State<'_, App>, master_password: String) -> CmdResult<SignIn> {
|
||||||
|
let r = app.sync.finish_setup(Zeroizing::new(master_password)).await;
|
||||||
|
app.activity.touch();
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn add_device(app: State<'_, App>) -> CmdResult<AddDeviceView> {
|
||||||
|
app.sync.add_device().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn cancel_add_device(app: State<'_, App>) -> CmdResult<()> {
|
||||||
|
app.sync.cancel_add_device().await;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn approve_join(app: State<'_, App>, join_code: String) -> CmdResult<JoinConfirmView> {
|
||||||
|
app.sync.approve_join(&join_code).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn send_join(app: State<'_, App>) -> CmdResult<()> {
|
||||||
|
app.sync.send_join().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn kit_qr(app: State<'_, App>) -> CmdResult<String> {
|
||||||
|
app.sync.kit_qr()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- startup
|
// ---------------------------------------------------------------- startup
|
||||||
@@ -365,13 +483,19 @@ fn start_ipc(broker: Arc<Broker>) -> io::Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn lock_and_notify(handle: &AppHandle, core: &VaultCore, reason: &str) {
|
fn lock_and_notify(handle: &AppHandle, sync: &Arc<SyncService>, reason: &str) {
|
||||||
core.lock();
|
sync.core().lock();
|
||||||
|
let sync = sync.clone();
|
||||||
|
tauri::async_runtime::spawn(async move { sync.on_lock().await });
|
||||||
let _ = handle.emit("locked", reason);
|
let _ = handle.emit("locked", reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let data_dir = app.path().app_data_dir()?;
|
// KESTROVE_DATA_DIR runs a second instance with its own vault (development and the two-device tests).
|
||||||
|
let data_dir = match std::env::var_os("KESTROVE_DATA_DIR") {
|
||||||
|
Some(d) => PathBuf::from(d),
|
||||||
|
None => app.path().app_data_dir()?,
|
||||||
|
};
|
||||||
std::fs::create_dir_all(&data_dir)?;
|
std::fs::create_dir_all(&data_dir)?;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -379,6 +503,23 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700))?;
|
std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700))?;
|
||||||
}
|
}
|
||||||
let core = VaultCore::open(data_dir.join("vault.sqlite").to_string_lossy().into_owned())?;
|
let core = VaultCore::open(data_dir.join("vault.sqlite").to_string_lossy().into_owned())?;
|
||||||
|
let key = if std::env::var_os("KESTROVE_DEVICE_KEY").is_some_and(|v| v == "file") {
|
||||||
|
DeviceKey::load_or_create_file(&data_dir)
|
||||||
|
} else {
|
||||||
|
DeviceKey::load_or_create(&data_dir)
|
||||||
|
};
|
||||||
|
let sync = {
|
||||||
|
let handle = app.handle().clone();
|
||||||
|
SyncService::new(
|
||||||
|
core.clone(),
|
||||||
|
&data_dir,
|
||||||
|
key.map_err(|e| e.to_string()),
|
||||||
|
Box::new(move |s: &SyncStatus| {
|
||||||
|
let _ = handle.emit("sync-status", s);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
tauri::async_runtime::spawn(sync.clone().run());
|
||||||
let activity = Activity::new();
|
let activity = Activity::new();
|
||||||
let ui = Arc::new(TauriUi { handle: app.handle().clone(), activity: activity.clone() });
|
let ui = Arc::new(TauriUi { handle: app.handle().clone(), activity: activity.clone() });
|
||||||
let broker = Broker::new(core.clone(), ui, PairingStore::open(data_dir.join("pairings.json"))?);
|
let broker = Broker::new(core.clone(), ui, PairingStore::open(data_dir.join("pairings.json"))?);
|
||||||
@@ -400,7 +541,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
{
|
{
|
||||||
let (handle, core, minutes) = (app.handle().clone(), core.clone(), idle_minutes.clone());
|
let (handle, sync, minutes) = (app.handle().clone(), sync.clone(), idle_minutes.clone());
|
||||||
let unlocked = core.clone();
|
let unlocked = core.clone();
|
||||||
autolock::spawn(
|
autolock::spawn(
|
||||||
activity.clone(),
|
activity.clone(),
|
||||||
@@ -412,7 +553,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Reason::ScreenLocked => "screen-locked",
|
Reason::ScreenLocked => "screen-locked",
|
||||||
Reason::Sleep => "sleep",
|
Reason::Sleep => "sleep",
|
||||||
};
|
};
|
||||||
lock_and_notify(&handle, &core, why);
|
lock_and_notify(&handle, &sync, why);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -423,7 +564,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
||||||
let menu = Menu::with_items(app, &[&open, &lock_item, &quit])?;
|
let menu = Menu::with_items(app, &[&open, &lock_item, &quit])?;
|
||||||
let mut tray = TrayIconBuilder::with_id("main").menu(&menu).tooltip("Kestrove").on_menu_event({
|
let mut tray = TrayIconBuilder::with_id("main").menu(&menu).tooltip("Kestrove").on_menu_event({
|
||||||
let core = core.clone();
|
let sync = sync.clone();
|
||||||
move |handle, event| match event.id().as_ref() {
|
move |handle, event| match event.id().as_ref() {
|
||||||
"open" => {
|
"open" => {
|
||||||
if let Some(w) = handle.get_webview_window("main") {
|
if let Some(w) = handle.get_webview_window("main") {
|
||||||
@@ -431,9 +572,14 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let _ = w.set_focus();
|
let _ = w.set_focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"lock" => lock_and_notify(handle, &core, "manual"),
|
"lock" => lock_and_notify(handle, &sync, "manual"),
|
||||||
"quit" => {
|
"quit" => {
|
||||||
core.lock();
|
sync.core().lock();
|
||||||
|
// Revoke an "Add a device" ticket still on screen (bounded: never hang Quit on the network).
|
||||||
|
let s = sync.clone();
|
||||||
|
let _ = tauri::async_runtime::block_on(async move {
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), s.on_lock()).await
|
||||||
|
});
|
||||||
handle.exit(0);
|
handle.exit(0);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -446,6 +592,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
app.manage(App {
|
app.manage(App {
|
||||||
core,
|
core,
|
||||||
|
sync,
|
||||||
broker,
|
broker,
|
||||||
activity,
|
activity,
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -485,7 +632,27 @@ fn main() {
|
|||||||
list_pairings,
|
list_pairings,
|
||||||
revoke_pairing,
|
revoke_pairing,
|
||||||
pending_saves,
|
pending_saves,
|
||||||
resolve_save
|
resolve_save,
|
||||||
|
sync_status,
|
||||||
|
sync_now,
|
||||||
|
configure_server,
|
||||||
|
server_setup_open,
|
||||||
|
register_server,
|
||||||
|
list_devices,
|
||||||
|
approve_device,
|
||||||
|
revoke_device,
|
||||||
|
join_start,
|
||||||
|
join_poll,
|
||||||
|
join_cancel,
|
||||||
|
setup_from_text,
|
||||||
|
setup_from_file,
|
||||||
|
clear_setup,
|
||||||
|
finish_setup,
|
||||||
|
add_device,
|
||||||
|
cancel_add_device,
|
||||||
|
approve_join,
|
||||||
|
send_join,
|
||||||
|
kit_qr
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running Kestrove");
|
.expect("error while running Kestrove");
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
//! QR codes for onboarding (docs/10): rendered locally (no network), and read back from an Emergency Kit
|
||||||
|
//! image (PNG/JPEG) or PDF.
|
||||||
|
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
|
||||||
|
use qrcode::{EcLevel, QrCode};
|
||||||
|
|
||||||
|
/// Largest file we try to decode (an Emergency Kit PDF or a phone photo).
|
||||||
|
pub const MAX_INPUT: usize = 25 * 1024 * 1024;
|
||||||
|
/// Rendering a page above this many pixels per side is pointless for a QR and slow.
|
||||||
|
const MAX_SIDE: u32 = 4000;
|
||||||
|
|
||||||
|
/// An SVG `data:` URI for `text` (level M, as docs/10 sizes the codes). Dark modules on white with a quiet zone,
|
||||||
|
/// so it scans in dark mode too.
|
||||||
|
pub fn svg_data_uri(text: &str) -> Result<String, String> {
|
||||||
|
let code = QrCode::with_error_correction_level(text.as_bytes(), EcLevel::M).map_err(|e| e.to_string())?;
|
||||||
|
let svg = code
|
||||||
|
.render::<qrcode::render::svg::Color>()
|
||||||
|
.quiet_zone(true)
|
||||||
|
.min_dimensions(240, 240)
|
||||||
|
.dark_color(qrcode::render::svg::Color("#000000"))
|
||||||
|
.light_color(qrcode::render::svg::Color("#ffffff"))
|
||||||
|
.build();
|
||||||
|
let mut uri = String::from("data:image/svg+xml;charset=utf-8,");
|
||||||
|
for b in svg.bytes() {
|
||||||
|
// Percent-encode everything outside a conservative safe set.
|
||||||
|
if b.is_ascii_alphanumeric() || b" -_.!~*'()/:=,;".contains(&b) {
|
||||||
|
uri.push(b as char);
|
||||||
|
} else {
|
||||||
|
let _ = write!(uri, "%{b:02X}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every QR text found in an image or PDF, in reading order.
|
||||||
|
pub fn decode_file(bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||||
|
if bytes.len() > MAX_INPUT {
|
||||||
|
return Err("The file is too large.".into());
|
||||||
|
}
|
||||||
|
if bytes.starts_with(b"%PDF") {
|
||||||
|
decode_pdf(bytes)
|
||||||
|
} else {
|
||||||
|
let img = image::load_from_memory(bytes).map_err(|_| "This is not a PNG, JPEG or PDF file.".to_string())?;
|
||||||
|
Ok(decode_luma(&img.to_luma8()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_luma(img: &image::GrayImage) -> Vec<String> {
|
||||||
|
let (w, h) = img.dimensions();
|
||||||
|
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(w as usize, h as usize, |x, y| {
|
||||||
|
img.get_pixel(x as u32, y as u32).0[0]
|
||||||
|
});
|
||||||
|
prepared.detect_grids().into_iter().filter_map(|g| g.decode().ok().map(|(_, text)| text)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_pdf(bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||||
|
use hayro::hayro_interpret::InterpreterSettings;
|
||||||
|
use hayro::hayro_syntax::Pdf;
|
||||||
|
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||||
|
|
||||||
|
let pdf = Pdf::new(bytes.to_vec()).map_err(|_| "This PDF could not be read (is it password-protected?).")?;
|
||||||
|
let cache = hayro::RenderCache::new();
|
||||||
|
let settings = InterpreterSettings::default();
|
||||||
|
let mut found = Vec::new();
|
||||||
|
for page in pdf.pages().iter().take(10) {
|
||||||
|
let (w, h) = page.render_dimensions();
|
||||||
|
// ~216 dpi: a 35 mm code becomes ~300 px, plenty for the detector.
|
||||||
|
let scale = (3.0_f32).min(MAX_SIDE as f32 / w.max(h).max(1.0));
|
||||||
|
let pixmap = hayro::render(
|
||||||
|
page,
|
||||||
|
&cache,
|
||||||
|
&settings,
|
||||||
|
&hayro::RenderSettings::default(),
|
||||||
|
&hayro::PixmapSettings { x_scale: scale, y_scale: scale, bg_color: WHITE },
|
||||||
|
);
|
||||||
|
let (pw, ph) = (u32::from(pixmap.width()), u32::from(pixmap.height()));
|
||||||
|
let rgba = pixmap.data_as_u8_slice();
|
||||||
|
let gray = image::GrayImage::from_fn(pw, ph, |x, y| {
|
||||||
|
let i = ((y * pw + x) * 4) as usize;
|
||||||
|
// Premultiplied over white: the background is opaque white, so treat the channels as plain RGB.
|
||||||
|
let (r, g, b) = (u32::from(rgba[i]), u32::from(rgba[i + 1]), u32::from(rgba[i + 2]));
|
||||||
|
image::Luma([((r * 299 + g * 587 + b * 114) / 1000) as u8])
|
||||||
|
});
|
||||||
|
found.extend(decode_luma(&gray));
|
||||||
|
}
|
||||||
|
Ok(found)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const PAYLOAD: &str = "kestrove://setup/v1?s=https%3A%2F%2Fkestrove.samialabri.com&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&k=K12TB9D5MPJTB9D5MPJTB9D5MPJTW";
|
||||||
|
|
||||||
|
fn png_of(text: &str) -> Vec<u8> {
|
||||||
|
let code = QrCode::with_error_correction_level(text.as_bytes(), EcLevel::M).unwrap();
|
||||||
|
let w = code.width();
|
||||||
|
let scale = 6u32;
|
||||||
|
let side = (w as u32 + 8) * scale;
|
||||||
|
let colors = code.to_colors();
|
||||||
|
let img = image::GrayImage::from_fn(side, side, |x, y| {
|
||||||
|
let (mx, my) = ((x / scale) as i64 - 4, (y / scale) as i64 - 4);
|
||||||
|
let dark = mx >= 0
|
||||||
|
&& my >= 0
|
||||||
|
&& (mx as usize) < w
|
||||||
|
&& (my as usize) < w
|
||||||
|
&& colors[my as usize * w + mx as usize] == qrcode::Color::Dark;
|
||||||
|
image::Luma([if dark { 0 } else { 255 }])
|
||||||
|
});
|
||||||
|
let mut out = std::io::Cursor::new(Vec::new());
|
||||||
|
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||||
|
out.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn png_round_trip() {
|
||||||
|
assert_eq!(decode_file(&png_of(PAYLOAD)).unwrap(), vec![PAYLOAD.to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn svg_is_a_data_uri() {
|
||||||
|
let uri = svg_data_uri(PAYLOAD).unwrap();
|
||||||
|
assert!(uri.starts_with("data:image/svg+xml;charset=utf-8,%3C"));
|
||||||
|
assert!(!uri.contains('<') && !uri.contains('#') && !uri.contains('"'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_other_files() {
|
||||||
|
assert!(decode_file(b"hello").is_err());
|
||||||
|
assert!(decode_file(b"%PDF-1.4 garbage").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A minimal one-page PDF that draws the QR modules as filled rectangles (vector, like a generated Kit).
|
||||||
|
#[test]
|
||||||
|
fn pdf_with_vector_qr() {
|
||||||
|
let code = QrCode::with_error_correction_level(PAYLOAD.as_bytes(), EcLevel::M).unwrap();
|
||||||
|
let w = code.width();
|
||||||
|
let colors = code.to_colors();
|
||||||
|
let m = 4.0; // points per module
|
||||||
|
let mut content = String::from("0 g\n");
|
||||||
|
for y in 0..w {
|
||||||
|
for x in 0..w {
|
||||||
|
if colors[y * w + x] == qrcode::Color::Dark {
|
||||||
|
let px = 100.0 + x as f32 * m;
|
||||||
|
let py = 600.0 - y as f32 * m;
|
||||||
|
content.push_str(&format!("{px} {py} {m} {m} re f\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let objs = [
|
||||||
|
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||||
|
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>".to_string(),
|
||||||
|
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
|
||||||
|
];
|
||||||
|
let mut pdf = String::from("%PDF-1.4\n");
|
||||||
|
let mut offsets = Vec::new();
|
||||||
|
for (i, o) in objs.iter().enumerate() {
|
||||||
|
offsets.push(pdf.len());
|
||||||
|
pdf.push_str(&format!("{} 0 obj\n{o}\nendobj\n", i + 1));
|
||||||
|
}
|
||||||
|
let xref = pdf.len();
|
||||||
|
pdf.push_str(&format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1));
|
||||||
|
for off in offsets {
|
||||||
|
pdf.push_str(&format!("{off:010} 00000 n \n"));
|
||||||
|
}
|
||||||
|
pdf.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1));
|
||||||
|
assert_eq!(decode_file(pdf.as_bytes()).unwrap(), vec![PAYLOAD.to_string()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
//! Sync server connection and onboarding (docs/03, docs/10) on top of kv-ffi, without Tauri types so the
|
||||||
|
//! integration tests can drive it. The UI layer (main.rs) wraps each method in a command.
|
||||||
|
//!
|
||||||
|
//! Secrets stay here: setup payloads (Secret Key, ticket) never go to the web view except as the QR image the
|
||||||
|
//! user asked to see.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use kv_ffi::{CoreError, DeviceDto, LoginResult, PlatformDto, SetupInfo, VaultCore};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use crate::device_key::DeviceKey;
|
||||||
|
use crate::{private_file, qr};
|
||||||
|
|
||||||
|
/// Background sync while unlocked (docs/03 "Triggers").
|
||||||
|
pub const SYNC_PERIOD: Duration = Duration::from_secs(15 * 60);
|
||||||
|
/// Saves in quick succession (or an unlock right after a save) become one sync.
|
||||||
|
const COALESCE: Duration = Duration::from_millis(800);
|
||||||
|
|
||||||
|
/// What the UI shows about sync. Sent with every change (event `sync-status`).
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||||
|
pub struct SyncStatus {
|
||||||
|
pub server_url: Option<String>,
|
||||||
|
pub pins: Vec<String>,
|
||||||
|
pub logged_in: bool,
|
||||||
|
pub syncing: bool,
|
||||||
|
/// Unix seconds of the last successful sync.
|
||||||
|
pub last_sync_at: Option<u64>,
|
||||||
|
pub last_error: Option<String>,
|
||||||
|
/// This device waits for approval on another device.
|
||||||
|
pub pending_approval: bool,
|
||||||
|
/// Other devices waiting for this user's approval.
|
||||||
|
pub pending_devices: u32,
|
||||||
|
/// The server failed verification; syncing that vault has stopped (docs/03 "Integrity").
|
||||||
|
pub integrity_alarm: Option<String>,
|
||||||
|
pub device_fingerprint: Option<String>,
|
||||||
|
pub key_storage: Option<String>,
|
||||||
|
pub key_warning: Option<String>,
|
||||||
|
/// Items an edit here brought back after a delete elsewhere, from the last sync.
|
||||||
|
pub restored_items: u32,
|
||||||
|
/// Bumped whenever a sync changed local items, so the UI knows to refresh the list.
|
||||||
|
pub changes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What we persist besides the core's own records (`sync.json`, 0600).
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
struct Saved {
|
||||||
|
server_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pins: Vec<String>,
|
||||||
|
last_sync_at: Option<u64>,
|
||||||
|
integrity_alarm: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DeviceView {
|
||||||
|
pub device_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub platform: String,
|
||||||
|
pub approved: bool,
|
||||||
|
pub revoked: bool,
|
||||||
|
pub current: bool,
|
||||||
|
pub created_at: String,
|
||||||
|
pub key_fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A setup payload waiting for the master password. The Secret Key stays in Rust.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct SetupView {
|
||||||
|
pub server_url: String,
|
||||||
|
pub server_host: String,
|
||||||
|
pub account_id: String,
|
||||||
|
/// A ticket means no manual approval will be needed.
|
||||||
|
pub preapproved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct JoinView {
|
||||||
|
/// QR image (`data:` URI) to scan with a signed-in phone.
|
||||||
|
pub qr_svg: String,
|
||||||
|
/// The same as text, for "Paste a computer's join code" on another computer.
|
||||||
|
pub qr_text: String,
|
||||||
|
pub expires_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct JoinPollView {
|
||||||
|
/// `"123 456"` once a phone scanned the QR.
|
||||||
|
pub code: Option<String>,
|
||||||
|
/// Set when the bundle arrived: ask for the master password and call `finish_setup`.
|
||||||
|
pub setup: Option<SetupView>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct AddDeviceView {
|
||||||
|
pub qr_svg: String,
|
||||||
|
pub expires_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct JoinConfirmView {
|
||||||
|
pub code: String,
|
||||||
|
pub device_name: String,
|
||||||
|
pub platform: String,
|
||||||
|
pub key_fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SignIn {
|
||||||
|
Ok,
|
||||||
|
PendingApproval,
|
||||||
|
}
|
||||||
|
|
||||||
|
type Notify = Box<dyn Fn(&SyncStatus) + Send + Sync>;
|
||||||
|
|
||||||
|
pub struct SyncService {
|
||||||
|
core: Arc<VaultCore>,
|
||||||
|
data_dir: PathBuf,
|
||||||
|
key: Result<Arc<DeviceKey>, String>,
|
||||||
|
status: Mutex<SyncStatus>,
|
||||||
|
/// From "Use my phone", the Emergency Kit or a pasted code, until `finish_setup` succeeds.
|
||||||
|
pending_setup: Mutex<Option<SetupInfo>>,
|
||||||
|
/// The "Add a device" ticket on screen, revoked on cancel and on lock.
|
||||||
|
add_device_ticket: Mutex<Option<String>>,
|
||||||
|
wake: tokio::sync::Notify,
|
||||||
|
notify: Notify,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||||
|
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_secs() -> u64 {
|
||||||
|
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User-facing text for a core error.
|
||||||
|
pub fn describe(e: &CoreError) -> String {
|
||||||
|
match e {
|
||||||
|
CoreError::Network { msg } => format!("Can't reach the server: {msg}"),
|
||||||
|
CoreError::PinMismatch => {
|
||||||
|
"The server's certificate doesn't match the pinned keys. Someone may be intercepting \
|
||||||
|
the connection, so Kestrove won't talk to it."
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
CoreError::Server { status: 429, .. } => "Too many attempts. Wait a minute and try again.".into(),
|
||||||
|
CoreError::BadCredentials => "Wrong master password or Secret Key.".into(),
|
||||||
|
CoreError::PendingApproval => "This computer is waiting for approval on one of your other devices.".into(),
|
||||||
|
CoreError::NotConfigured => "No sync server is set up yet.".into(),
|
||||||
|
CoreError::NotAuthenticated => "Sign in to the sync server again (lock and unlock with your password).".into(),
|
||||||
|
CoreError::IntegrityAlarm { vault_id, reason } => format!(
|
||||||
|
"The server sent vault data that failed verification (vault {vault_id}: {reason}). Syncing stopped; \
|
||||||
|
your local data is unchanged."
|
||||||
|
),
|
||||||
|
CoreError::Invalid { msg } => msg.clone(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn platform_name(p: PlatformDto) -> &'static str {
|
||||||
|
match p {
|
||||||
|
PlatformDto::Ios => "iOS",
|
||||||
|
PlatformDto::Macos => "macOS",
|
||||||
|
PlatformDto::Android => "Android",
|
||||||
|
PlatformDto::Windows => "Windows",
|
||||||
|
PlatformDto::Linux => "Linux",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn this_platform() -> PlatformDto {
|
||||||
|
if cfg!(windows) {
|
||||||
|
PlatformDto::Windows
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
PlatformDto::Macos
|
||||||
|
} else {
|
||||||
|
PlatformDto::Linux
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The computer's name for the device list ("Sami's PC"); `KESTROVE_DEVICE_NAME` overrides it.
|
||||||
|
pub fn device_name() -> String {
|
||||||
|
if let Ok(n) = std::env::var("KESTROVE_DEVICE_NAME") {
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
#[cfg(windows)]
|
||||||
|
let host = std::env::var("COMPUTERNAME").ok();
|
||||||
|
#[cfg(unix)]
|
||||||
|
let host = {
|
||||||
|
let mut buf = [0u8; 256];
|
||||||
|
// SAFETY: the buffer is valid for its length; gethostname NUL-terminates on success (we also cap it).
|
||||||
|
let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len() - 1) };
|
||||||
|
(rc == 0).then(|| {
|
||||||
|
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||||
|
String::from_utf8_lossy(&buf[..end]).trim_end_matches(".local").to_string()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
host.filter(|h| !h.is_empty()).unwrap_or_else(|| format!("{} computer", platform_name(this_platform())))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_of(url: &str) -> String {
|
||||||
|
url::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string)).unwrap_or_else(|| url.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_view(s: &SetupInfo) -> SetupView {
|
||||||
|
SetupView {
|
||||||
|
server_url: s.server_url.clone(),
|
||||||
|
server_host: host_of(&s.server_url),
|
||||||
|
account_id: s.account_id.clone(),
|
||||||
|
preapproved: s.ticket.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn device_view(d: DeviceDto) -> DeviceView {
|
||||||
|
DeviceView {
|
||||||
|
device_id: d.device_id,
|
||||||
|
name: d.name,
|
||||||
|
platform: platform_name(d.platform).into(),
|
||||||
|
approved: d.approved,
|
||||||
|
revoked: d.revoked,
|
||||||
|
current: d.current,
|
||||||
|
created_at: d.created_at,
|
||||||
|
key_fingerprint: d.key_fingerprint,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits "a, b c" pin lists typed by the user.
|
||||||
|
pub fn parse_pins(text: &str) -> Vec<String> {
|
||||||
|
text.split(|c: char| c == ',' || c.is_whitespace()).filter(|s| !s.is_empty()).map(str::to_string).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncService {
|
||||||
|
/// `key` is this device's signing key (or why it could not be loaded; sync is then unavailable).
|
||||||
|
pub fn new(core: Arc<VaultCore>, data_dir: &Path, key: Result<DeviceKey, String>, notify: Notify) -> Arc<Self> {
|
||||||
|
let saved: Saved = private_file::read(&data_dir.join("sync.json"))
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let key = key.map(Arc::new);
|
||||||
|
let mut status = SyncStatus {
|
||||||
|
server_url: saved.server_url,
|
||||||
|
pins: saved.pins,
|
||||||
|
last_sync_at: saved.last_sync_at,
|
||||||
|
integrity_alarm: saved.integrity_alarm,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
match &key {
|
||||||
|
Ok(k) => {
|
||||||
|
status.key_storage = Some(k.storage().describe().into());
|
||||||
|
status.key_warning = k.warning().map(str::to_string);
|
||||||
|
let signer: Arc<dyn kv_ffi::DeviceSigner> = k.clone();
|
||||||
|
if let Err(e) = core.set_device_signer(signer, device_name(), this_platform()) {
|
||||||
|
status.key_warning = Some(format!("Sync is unavailable: {}", describe(&e)));
|
||||||
|
}
|
||||||
|
status.device_fingerprint = core.device_key_fingerprint().ok();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
status.key_warning = Some(format!("Sync is unavailable: this device's key could not be loaded ({e})."))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Arc::new(Self {
|
||||||
|
core,
|
||||||
|
data_dir: data_dir.to_path_buf(),
|
||||||
|
key,
|
||||||
|
status: Mutex::new(status),
|
||||||
|
pending_setup: Mutex::new(None),
|
||||||
|
add_device_ticket: Mutex::new(None),
|
||||||
|
wake: tokio::sync::Notify::new(),
|
||||||
|
notify,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn core(&self) -> &Arc<VaultCore> {
|
||||||
|
&self.core
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn status(&self) -> SyncStatus {
|
||||||
|
let mut s = lock(&self.status).clone();
|
||||||
|
s.logged_in = self.core.is_logged_in();
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&self, f: impl FnOnce(&mut SyncStatus)) {
|
||||||
|
let snapshot = {
|
||||||
|
let mut s = lock(&self.status);
|
||||||
|
f(&mut s);
|
||||||
|
let saved = Saved {
|
||||||
|
server_url: s.server_url.clone(),
|
||||||
|
pins: s.pins.clone(),
|
||||||
|
last_sync_at: s.last_sync_at,
|
||||||
|
integrity_alarm: s.integrity_alarm.clone(),
|
||||||
|
};
|
||||||
|
if let Ok(bytes) = serde_json::to_vec(&saved) {
|
||||||
|
if let Err(e) = private_file::write(&self.data_dir.join("sync.json"), &bytes) {
|
||||||
|
eprintln!("kestrove: could not save sync settings: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.clone()
|
||||||
|
};
|
||||||
|
let mut snapshot = snapshot;
|
||||||
|
snapshot.logged_in = self.core.is_logged_in();
|
||||||
|
(self.notify)(&snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_key(&self) -> Result<(), String> {
|
||||||
|
self.key.as_ref().map(|_| ()).map_err(|e| format!("Sync is unavailable: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Secret Key on this device
|
||||||
|
|
||||||
|
fn secret_key_path(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("secret-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remember_secret_key(&self, secret_key: &str) {
|
||||||
|
if let Err(e) = private_file::write(&self.secret_key_path(), secret_key.trim().as_bytes()) {
|
||||||
|
eprintln!("kestrove: could not store the Secret Key: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stored_secret_key(&self) -> Option<Zeroizing<String>> {
|
||||||
|
let b = private_file::read(&self.secret_key_path()).ok()??;
|
||||||
|
String::from_utf8(b).ok().map(Zeroizing::new)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ server settings and sign-in
|
||||||
|
|
||||||
|
/// Saves the server URL and optional SPKI pins (base64 SHA-256).
|
||||||
|
pub fn configure(&self, url: &str, pins: Vec<String>) -> Result<(), String> {
|
||||||
|
self.require_key()?;
|
||||||
|
let url = url.trim().trim_end_matches('/').to_string();
|
||||||
|
self.core.configure_server(url.clone(), pins.clone()).map_err(|e| describe(&e))?;
|
||||||
|
self.update(|s| {
|
||||||
|
s.server_url = Some(url);
|
||||||
|
s.pins = pins;
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Create a new account": creates the vault here and registers it with a fresh server using the setup code
|
||||||
|
/// (`KV_SETUP_CODE`). Returns the Secret Key for the Emergency Kit.
|
||||||
|
pub async fn create_account(
|
||||||
|
&self,
|
||||||
|
server_url: &str,
|
||||||
|
pins: Vec<String>,
|
||||||
|
setup_code: &str,
|
||||||
|
master_password: Zeroizing<String>,
|
||||||
|
) -> Result<Zeroizing<String>, String> {
|
||||||
|
if self.core.has_account() {
|
||||||
|
return Err("This computer already has a vault.".into());
|
||||||
|
}
|
||||||
|
if setup_code.trim().is_empty() {
|
||||||
|
return Err("Enter the server's setup code.".into());
|
||||||
|
}
|
||||||
|
self.configure(server_url, pins)?;
|
||||||
|
let info = self.core.server_info().await.map_err(|e| describe(&e))?;
|
||||||
|
if !info.setup_open {
|
||||||
|
return Err(
|
||||||
|
"This server already has an account. Choose “Use my phone” or “Paste setup code” instead.".into()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let sk = Zeroizing::new(self.core.create_account(master_password.to_string()).map_err(|e| describe(&e))?);
|
||||||
|
self.remember_secret_key(&sk);
|
||||||
|
if let Err(e) = self.core.register_account(setup_code.trim().to_string()).await {
|
||||||
|
return Err(format!(
|
||||||
|
"Your vault was created on this computer, but the server refused it: {}. Check the setup code in \
|
||||||
|
Settings → Sync and try again.",
|
||||||
|
describe(&e)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let _ = self.sync_now().await;
|
||||||
|
Ok(sk)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// For a vault created before sync was set up: registers it with a fresh server.
|
||||||
|
pub async fn register(&self, setup_code: &str) -> Result<(), String> {
|
||||||
|
self.core.register_account(setup_code.trim().to_string()).await.map_err(|e| describe(&e))?;
|
||||||
|
self.sync_now().await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the configured server still accepts a setup code (no account yet).
|
||||||
|
pub async fn setup_open(&self) -> Result<bool, String> {
|
||||||
|
Ok(self.core.server_info().await.map_err(|e| describe(&e))?.setup_open)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ sync
|
||||||
|
|
||||||
|
/// Wakes the background loop (after unlock, after a save).
|
||||||
|
pub fn request_sync(&self) {
|
||||||
|
self.wake.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn can_sync(&self) -> bool {
|
||||||
|
self.key.is_ok() && self.core.has_account() && !self.core.is_locked() && lock(&self.status).server_url.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs forever: syncs when woken and every 15 minutes while unlocked.
|
||||||
|
pub async fn run(self: Arc<Self>) {
|
||||||
|
loop {
|
||||||
|
let _ = tokio::time::timeout(SYNC_PERIOD, self.wake.notified()).await;
|
||||||
|
tokio::time::sleep(COALESCE).await;
|
||||||
|
if self.can_sync() {
|
||||||
|
let _ = self.sync_now().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn sync_now(&self) -> Result<SyncStatus, String> {
|
||||||
|
self.require_key()?;
|
||||||
|
if self.core.is_locked() {
|
||||||
|
return Err("Unlock Kestrove first.".into());
|
||||||
|
}
|
||||||
|
self.update(|s| s.syncing = true);
|
||||||
|
let result = self.core.sync_now().await;
|
||||||
|
let mut pending_devices = None;
|
||||||
|
if result.is_ok() {
|
||||||
|
pending_devices = self
|
||||||
|
.core
|
||||||
|
.list_devices()
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.map(|ds| ds.iter().filter(|d| !d.approved && !d.revoked && !d.current).count() as u32);
|
||||||
|
}
|
||||||
|
let err = result.as_ref().err().map(describe);
|
||||||
|
self.update(|s| {
|
||||||
|
s.syncing = false;
|
||||||
|
match &result {
|
||||||
|
Ok(r) => {
|
||||||
|
s.last_sync_at = Some(now_secs());
|
||||||
|
s.last_error = None;
|
||||||
|
s.pending_approval = false;
|
||||||
|
s.integrity_alarm = None;
|
||||||
|
s.restored_items = r.restored_item_ids.len() as u32;
|
||||||
|
if r.pulled + r.merged > 0 || !r.restored_item_ids.is_empty() {
|
||||||
|
s.changes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(CoreError::PendingApproval) => {
|
||||||
|
s.pending_approval = true;
|
||||||
|
s.last_error = None;
|
||||||
|
}
|
||||||
|
Err(e @ CoreError::IntegrityAlarm { .. }) => {
|
||||||
|
s.integrity_alarm = Some(describe(e));
|
||||||
|
s.last_error = err.clone();
|
||||||
|
// Other vaults may still have synced.
|
||||||
|
s.changes += 1;
|
||||||
|
}
|
||||||
|
Err(_) => s.last_error = err.clone(),
|
||||||
|
}
|
||||||
|
if let Some(n) = pending_devices {
|
||||||
|
s.pending_devices = n;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
match err {
|
||||||
|
Some(e) if !matches!(result, Err(CoreError::PendingApproval)) => Err(e),
|
||||||
|
_ => Ok(self.status()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ devices
|
||||||
|
|
||||||
|
pub async fn devices(&self) -> Result<Vec<DeviceView>, String> {
|
||||||
|
let ds = self.core.list_devices().await.map_err(|e| describe(&e))?;
|
||||||
|
let pending = ds.iter().filter(|d| !d.approved && !d.revoked && !d.current).count() as u32;
|
||||||
|
self.update(|s| s.pending_devices = pending);
|
||||||
|
Ok(ds.into_iter().map(device_view).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the user compared `key_fingerprint` with the one the other device shows.
|
||||||
|
pub async fn approve_device(&self, device_id: &str) -> Result<(), String> {
|
||||||
|
self.core.approve_device(device_id.to_string()).await.map_err(|e| describe(&e))?;
|
||||||
|
self.devices().await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_device(&self, device_id: &str) -> Result<(), String> {
|
||||||
|
self.core.revoke_device(device_id.to_string()).await.map_err(|e| describe(&e))?;
|
||||||
|
self.devices().await.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ onboarding: a new computer
|
||||||
|
|
||||||
|
/// "Use my phone": opens a rendezvous and returns the QR. Then poll `join_poll` every 2 seconds.
|
||||||
|
pub async fn join_start(&self, server_url: &str) -> Result<JoinView, String> {
|
||||||
|
self.require_key()?;
|
||||||
|
let url = server_url.trim().trim_end_matches('/').to_string();
|
||||||
|
let session = self.core.join_start(url.clone()).await.map_err(|e| describe(&e))?;
|
||||||
|
self.update(|s| {
|
||||||
|
s.server_url = Some(url);
|
||||||
|
s.pins = vec![];
|
||||||
|
});
|
||||||
|
Ok(JoinView { qr_svg: qr::svg_data_uri(&session.qr)?, qr_text: session.qr, expires_at: session.expires_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn join_poll(&self) -> Result<JoinPollView, String> {
|
||||||
|
match self.core.join_poll().await {
|
||||||
|
Ok(p) => {
|
||||||
|
let setup = p.setup.map(|s| {
|
||||||
|
let view = setup_view(&s);
|
||||||
|
*lock(&self.pending_setup) = Some(s);
|
||||||
|
view
|
||||||
|
});
|
||||||
|
Ok(JoinPollView { code: p.code, setup })
|
||||||
|
}
|
||||||
|
Err(CoreError::NotFound) => Err("The code expired. Start again.".into()),
|
||||||
|
// The server allows one poll per second per rendezvous; a 429 just means "not yet".
|
||||||
|
Err(CoreError::Server { status: 429, .. }) => Ok(JoinPollView { code: None, setup: None }),
|
||||||
|
Err(e) => Err(describe(&e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn join_cancel(&self) {
|
||||||
|
self.core.join_cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Paste setup code".
|
||||||
|
pub fn setup_from_text(&self, text: &str) -> Result<SetupView, String> {
|
||||||
|
let info = kv_ffi::parse_setup_payload(text.trim().to_string()).map_err(|e| describe(&e))?;
|
||||||
|
let view = setup_view(&info);
|
||||||
|
*lock(&self.pending_setup) = Some(info);
|
||||||
|
Ok(view)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Open Emergency Kit…": a PNG/JPEG photo or the Kit PDF.
|
||||||
|
pub fn setup_from_file(&self, bytes: &[u8]) -> Result<SetupView, String> {
|
||||||
|
let texts = qr::decode_file(bytes)?;
|
||||||
|
let text = texts
|
||||||
|
.iter()
|
||||||
|
.find(|t| t.starts_with("kestrove://setup/"))
|
||||||
|
.ok_or("No Kestrove setup code was found in this file. Try a sharper photo, or paste the code.")?;
|
||||||
|
self.setup_from_text(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_setup(&self) {
|
||||||
|
*lock(&self.pending_setup) = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Signs in with the pending setup payload and the master password, then downloads the vault.
|
||||||
|
/// `PendingApproval` (a Kit code has no ticket): approve this computer elsewhere, then call again.
|
||||||
|
pub async fn finish_setup(&self, master_password: Zeroizing<String>) -> Result<SignIn, String> {
|
||||||
|
self.require_key()?;
|
||||||
|
let setup = lock(&self.pending_setup).clone().ok_or("Scan or paste a setup code first.")?;
|
||||||
|
let result =
|
||||||
|
self.core.login_with_setup(setup.clone(), master_password.to_string()).await.map_err(|e| describe(&e))?;
|
||||||
|
self.update(|s| {
|
||||||
|
s.server_url = Some(setup.server_url.clone());
|
||||||
|
s.pins = setup.spki_pins.clone();
|
||||||
|
s.pending_approval = result == LoginResult::PendingApproval;
|
||||||
|
});
|
||||||
|
if result == LoginResult::PendingApproval {
|
||||||
|
return Ok(SignIn::PendingApproval);
|
||||||
|
}
|
||||||
|
self.remember_secret_key(&setup.secret_key);
|
||||||
|
self.clear_setup();
|
||||||
|
let _ = self.sync_now().await;
|
||||||
|
Ok(SignIn::Ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ onboarding: from this (signed-in) computer
|
||||||
|
|
||||||
|
/// "Add a device": a 10-minute, single-use QR. Cancel (or lock) revokes it.
|
||||||
|
pub async fn add_device(&self) -> Result<AddDeviceView, String> {
|
||||||
|
self.cancel_add_device().await;
|
||||||
|
let p = self.core.add_device_payload().await.map_err(|e| describe(&e))?;
|
||||||
|
*lock(&self.add_device_ticket) = Some(p.ticket_id);
|
||||||
|
Ok(AddDeviceView { qr_svg: qr::svg_data_uri(&p.payload)?, expires_at: p.expires_at })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Revokes the "Add a device" ticket if one is on screen. A ticket already used or expired is fine.
|
||||||
|
pub async fn cancel_add_device(&self) {
|
||||||
|
let id = lock(&self.add_device_ticket).take();
|
||||||
|
if let Some(id) = id {
|
||||||
|
if let Err(e) = self.core.revoke_enrollment_ticket(id).await {
|
||||||
|
if !matches!(e, CoreError::NotFound) {
|
||||||
|
eprintln!("kestrove: could not revoke the enrollment ticket: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Approve a computer": the join code shown (as QR and text) on the new computer.
|
||||||
|
pub async fn approve_join(&self, join_code: &str) -> Result<JoinConfirmView, String> {
|
||||||
|
let c = self.core.join_approve(join_code.trim().to_string()).await.map_err(|e| match e {
|
||||||
|
CoreError::NotFound => "That join code expired or was already used.".to_string(),
|
||||||
|
e => describe(&e),
|
||||||
|
})?;
|
||||||
|
Ok(JoinConfirmView {
|
||||||
|
code: c.code,
|
||||||
|
device_name: c.device_name,
|
||||||
|
platform: platform_name(c.platform).into(),
|
||||||
|
key_fingerprint: c.key_fingerprint,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the user saw the same code on both screens.
|
||||||
|
pub async fn send_join(&self) -> Result<(), String> {
|
||||||
|
self.core.join_send().await.map_err(|e| describe(&e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emergency Kit setup code (server, pins, account, Secret Key; no ticket) as a QR.
|
||||||
|
pub fn kit_qr(&self) -> Result<String, String> {
|
||||||
|
qr::svg_data_uri(&self.core.setup_payload_for_kit().map_err(|e| describe(&e))?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called whenever the vault locks: nothing secret stays live (the core drops its keys itself).
|
||||||
|
pub async fn on_lock(&self) {
|
||||||
|
self.core.join_cancel();
|
||||||
|
self.cancel_add_device().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pins_split_on_commas_and_spaces() {
|
||||||
|
assert_eq!(parse_pins(" a=, b=\nc= "), vec!["a=", "b=", "c="]);
|
||||||
|
assert!(parse_pins(" ").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn device_name_is_never_empty() {
|
||||||
|
assert!(!device_name().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn settings_persist_and_secrets_stay_out_of_views() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let core = VaultCore::new_in_memory();
|
||||||
|
let key = DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string());
|
||||||
|
let svc = SyncService::new(core, dir.path(), key, Box::new(|_| {}));
|
||||||
|
svc.configure("http://127.0.0.1:9/", vec![]).unwrap();
|
||||||
|
assert_eq!(svc.status().server_url.as_deref(), Some("http://127.0.0.1:9"));
|
||||||
|
assert!(svc.status().device_fingerprint.is_some());
|
||||||
|
|
||||||
|
let payload = "kestrove://setup/v1?s=https%3A%2F%2Fkestrove.samialabri.com&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&k=K12TB9D5MPJTB9D5MPJTB9D5MPJTW";
|
||||||
|
let view = svc.setup_from_text(payload).unwrap();
|
||||||
|
assert_eq!(view.server_host, "kestrove.samialabri.com");
|
||||||
|
assert!(!view.preapproved);
|
||||||
|
assert!(!serde_json::to_string(&view).unwrap().contains("K12TB9"));
|
||||||
|
assert!(svc.setup_from_text("kestrove://setup/v9?s=x").is_err());
|
||||||
|
|
||||||
|
// A second service over the same folder sees the saved server.
|
||||||
|
let again = SyncService::new(
|
||||||
|
VaultCore::new_in_memory(),
|
||||||
|
dir.path(),
|
||||||
|
DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()),
|
||||||
|
Box::new(|_| {}),
|
||||||
|
);
|
||||||
|
assert_eq!(again.status().server_url.as_deref(), Some("http://127.0.0.1:9"));
|
||||||
|
assert_eq!(again.status().device_fingerprint, svc.status().device_fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sync_needs_unlock() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let svc = SyncService::new(
|
||||||
|
VaultCore::new_in_memory(),
|
||||||
|
dir.path(),
|
||||||
|
DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()),
|
||||||
|
Box::new(|_| {}),
|
||||||
|
);
|
||||||
|
assert!(svc.sync_now().await.is_err());
|
||||||
|
assert!(!svc.can_sync());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
//! End to end against a real kestrove-server (ignored by default):
|
||||||
|
//!
|
||||||
|
//! ```sh
|
||||||
|
//! KV_SETUP_CODE=… KV_BIND=127.0.0.1:18090 KV_PUBLIC_URL=http://127.0.0.1:18090 kestrove-server &
|
||||||
|
//! KV_TEST_SERVER=http://127.0.0.1:18090 KV_TEST_SETUP_CODE=… cargo test --test live_sync -- --ignored --nocapture
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Three "app instances" with separate data folders go through the same `SyncService` calls the UI makes:
|
||||||
|
//! A creates the account; B joins with "Use my phone", approved from A with the pasted join code; items sync both
|
||||||
|
//! ways; C uses the Emergency Kit code (no ticket), waits for approval, A approves it by fingerprint.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use kestrove_desktop::device_key::DeviceKey;
|
||||||
|
use kestrove_desktop::sync::{SignIn, SyncService};
|
||||||
|
use kv_ffi::{ItemDto, ItemKindDto, VaultCore};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
const PASSWORD: &str = "correct horse battery staple";
|
||||||
|
|
||||||
|
fn instance(dir: &Path) -> Arc<SyncService> {
|
||||||
|
let core = VaultCore::open(dir.join("vault.sqlite").to_string_lossy().into_owned()).unwrap();
|
||||||
|
let key = DeviceKey::load_or_create_file(dir).map_err(|e| e.to_string());
|
||||||
|
SyncService::new(core, dir, key, Box::new(|_| {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn login(title: &str) -> ItemDto {
|
||||||
|
ItemDto {
|
||||||
|
id: String::new(),
|
||||||
|
kind: ItemKindDto::Login,
|
||||||
|
title: title.into(),
|
||||||
|
username: Some(format!("{title}-user")),
|
||||||
|
password: Some("hunter2".into()),
|
||||||
|
urls: vec!["https://example.com".into()],
|
||||||
|
totp: None,
|
||||||
|
notes: None,
|
||||||
|
favorite: false,
|
||||||
|
tags: vec![],
|
||||||
|
modified_at: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn titles(svc: &SyncService) -> Vec<String> {
|
||||||
|
svc.core().list_items().unwrap().into_iter().map(|i| i.title).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server allows 5 auth calls a minute per IP; three devices from one machine hit that.
|
||||||
|
async fn patiently<T, F, Fut>(mut f: F) -> Result<T, String>
|
||||||
|
where
|
||||||
|
F: FnMut() -> Fut,
|
||||||
|
Fut: std::future::Future<Output = Result<T, String>>,
|
||||||
|
{
|
||||||
|
let start = Instant::now();
|
||||||
|
loop {
|
||||||
|
match f().await {
|
||||||
|
Err(e) if e.contains("Too many attempts") && start.elapsed() < Duration::from_secs(150) => {
|
||||||
|
eprintln!("rate limited; waiting");
|
||||||
|
tokio::time::sleep(Duration::from_secs(15)).await;
|
||||||
|
}
|
||||||
|
other => return other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[ignore = "needs a running kestrove-server (KV_TEST_SERVER, KV_TEST_SETUP_CODE)"]
|
||||||
|
async fn create_join_approve_and_sync() {
|
||||||
|
let server = std::env::var("KV_TEST_SERVER").expect("KV_TEST_SERVER");
|
||||||
|
let code = std::env::var("KV_TEST_SETUP_CODE").expect("KV_TEST_SETUP_CODE");
|
||||||
|
let (da, db, dc) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||||
|
|
||||||
|
// A: "Create a new account".
|
||||||
|
let a = instance(da.path());
|
||||||
|
let sk = a.create_account(&server, vec![], &code, Zeroizing::new(PASSWORD.into())).await.unwrap();
|
||||||
|
assert!(sk.starts_with("K1"));
|
||||||
|
assert!(a.status().logged_in);
|
||||||
|
assert!(a.status().last_sync_at.is_some(), "first sync after registration: {:?}", a.status().last_error);
|
||||||
|
a.core().save_item(login("from-a")).unwrap();
|
||||||
|
a.sync_now().await.unwrap();
|
||||||
|
|
||||||
|
// A: "Add a device" QR, then Cancel revokes the ticket.
|
||||||
|
let add = a.add_device().await.unwrap();
|
||||||
|
assert!(add.qr_svg.starts_with("data:image/svg+xml"));
|
||||||
|
a.cancel_add_device().await;
|
||||||
|
|
||||||
|
// B: "Use my phone" → the QR (and its text for the paste fallback).
|
||||||
|
let b = instance(db.path());
|
||||||
|
let join = b.join_start(&server).await.unwrap();
|
||||||
|
assert!(join.qr_text.starts_with("kestrove://join/v1?"));
|
||||||
|
let first = b.join_poll().await.unwrap();
|
||||||
|
assert!(first.code.is_none() && first.setup.is_none());
|
||||||
|
|
||||||
|
// A: "Approve a computer" with the pasted join code; both show the same code.
|
||||||
|
let confirm = a.approve_join(&join.qr_text).await.unwrap();
|
||||||
|
assert_eq!(Some(confirm.key_fingerprint.clone()), b.status().device_fingerprint);
|
||||||
|
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||||
|
let seen = b.join_poll().await.unwrap();
|
||||||
|
assert_eq!(seen.code.as_deref(), Some(confirm.code.as_str()));
|
||||||
|
a.send_join().await.unwrap();
|
||||||
|
|
||||||
|
// B: the bundle arrives; then only the master password.
|
||||||
|
let setup = loop {
|
||||||
|
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||||
|
if let Some(s) = b.join_poll().await.unwrap().setup {
|
||||||
|
break s;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
assert!(setup.preapproved);
|
||||||
|
assert_eq!(setup.server_url, server.trim_end_matches('/'));
|
||||||
|
let r = patiently(|| b.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap();
|
||||||
|
assert_eq!(r, SignIn::Ok);
|
||||||
|
assert_eq!(titles(&b), vec!["from-a"]);
|
||||||
|
assert!(b.stored_secret_key().is_some(), "B remembers the Secret Key, so unlock needs only the password");
|
||||||
|
|
||||||
|
// An item from B reaches A.
|
||||||
|
b.core().save_item(login("from-b")).unwrap();
|
||||||
|
b.sync_now().await.unwrap();
|
||||||
|
a.sync_now().await.unwrap();
|
||||||
|
let mut got = titles(&a);
|
||||||
|
got.sort();
|
||||||
|
assert_eq!(got, vec!["from-a", "from-b"]);
|
||||||
|
|
||||||
|
// Lock clears keys: no sync while locked, and unlocking with the password works again.
|
||||||
|
b.core().lock();
|
||||||
|
b.on_lock().await;
|
||||||
|
assert!(b.sync_now().await.is_err());
|
||||||
|
b.core().unlock(PASSWORD.into(), b.stored_secret_key().unwrap().to_string()).unwrap();
|
||||||
|
b.sync_now().await.unwrap();
|
||||||
|
|
||||||
|
// C: Emergency Kit code (no ticket) → pending → A approves after comparing fingerprints.
|
||||||
|
let kit = a.core().setup_payload_for_kit().unwrap();
|
||||||
|
let c = instance(dc.path());
|
||||||
|
let view = c.setup_from_text(&kit).unwrap();
|
||||||
|
assert!(!view.preapproved);
|
||||||
|
let r = patiently(|| c.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap();
|
||||||
|
assert_eq!(r, SignIn::PendingApproval);
|
||||||
|
let devices = a.devices().await.unwrap();
|
||||||
|
let pending: Vec<_> = devices.iter().filter(|d| !d.approved && !d.revoked).collect();
|
||||||
|
assert_eq!(pending.len(), 1);
|
||||||
|
assert_eq!(Some(pending[0].key_fingerprint.clone()), c.status().device_fingerprint);
|
||||||
|
assert_eq!(a.status().pending_devices, 1);
|
||||||
|
a.approve_device(&pending[0].device_id).await.unwrap();
|
||||||
|
let r = patiently(|| c.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap();
|
||||||
|
assert_eq!(r, SignIn::Ok);
|
||||||
|
let mut got = titles(&c);
|
||||||
|
got.sort();
|
||||||
|
assert_eq!(got, vec!["from-a", "from-b"]);
|
||||||
|
|
||||||
|
// Revoke C from A; C can no longer sync.
|
||||||
|
let c_id = pending[0].device_id.clone();
|
||||||
|
a.revoke_device(&c_id).await.unwrap();
|
||||||
|
assert!(a.devices().await.unwrap().iter().any(|d| d.device_id == c_id && d.revoked));
|
||||||
|
assert!(c.sync_now().await.is_err());
|
||||||
|
}
|
||||||
+600
-32
@@ -8,9 +8,21 @@ interface ItemDto { id: string; title: string; username: string | null; password
|
|||||||
interface PairingView { id: string; name: string; caller: string; words: string[] }
|
interface PairingView { id: string; name: string; caller: string; words: string[] }
|
||||||
interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
|
interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
|
||||||
interface SaveView { id: string; origin: string; username: string; update: boolean }
|
interface SaveView { id: string; origin: string; username: string; update: boolean }
|
||||||
|
interface SyncStatus {
|
||||||
|
server_url: string | null; pins: string[]; logged_in: boolean; syncing: boolean; last_sync_at: number | null; last_error: string | null;
|
||||||
|
pending_approval: boolean; pending_devices: number; integrity_alarm: string | null; device_fingerprint: string | null;
|
||||||
|
key_storage: string | null; key_warning: string | null; restored_items: number; changes: number;
|
||||||
|
}
|
||||||
|
interface DeviceView { device_id: string; name: string; platform: string; approved: boolean; revoked: boolean; current: boolean; created_at: string; key_fingerprint: string }
|
||||||
|
interface SetupView { server_url: string; server_host: string; account_id: string; preapproved: boolean }
|
||||||
|
interface JoinView { qr_svg: string; qr_text: string; expires_at: number }
|
||||||
|
interface JoinPollView { code: string | null; setup: SetupView | null }
|
||||||
|
interface AddDeviceView { qr_svg: string; expires_at: number }
|
||||||
|
interface JoinConfirmView { code: string; device_name: string; platform: string; key_fingerprint: string }
|
||||||
|
|
||||||
const app = document.getElementById("app")!;
|
const app = document.getElementById("app")!;
|
||||||
const overlay = document.getElementById("overlay")!;
|
const overlay = document.getElementById("overlay")!;
|
||||||
|
const modal = document.getElementById("modal")!;
|
||||||
|
|
||||||
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> & Record<string, unknown> = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] {
|
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> & Record<string, unknown> = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] {
|
||||||
const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props);
|
const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props);
|
||||||
@@ -19,43 +31,296 @@ function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLEl
|
|||||||
}
|
}
|
||||||
|
|
||||||
const errText = (e: unknown) => (typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
|
const errText = (e: unknown) => (typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
/// "ab12cd34…" → "ab12 cd34 …" so two screens are easy to compare.
|
||||||
|
const groups = (hex: string) => hex.replace(/(.{4})/g, "$1 ").trim();
|
||||||
|
|
||||||
|
function countdown(expiresAt: number, onExpire: () => void): HTMLElement {
|
||||||
|
const span = el("span", { className: "muted" });
|
||||||
|
const tick = () => {
|
||||||
|
if (!span.isConnected && span.dataset.started) return;
|
||||||
|
span.dataset.started = "1";
|
||||||
|
const left = Math.max(0, Math.round(expiresAt - Date.now() / 1000));
|
||||||
|
span.textContent = `Expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`;
|
||||||
|
if (left === 0) onExpire();
|
||||||
|
else setTimeout(tick, 1000);
|
||||||
|
};
|
||||||
|
queueMicrotask(tick);
|
||||||
|
return span;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ago(unix: number): string {
|
||||||
|
const s = Math.max(0, Math.round(Date.now() / 1000 - unix));
|
||||||
|
if (s < 60) return "just now";
|
||||||
|
if (s < 3600) return `${Math.floor(s / 60)} min ago`;
|
||||||
|
if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
|
||||||
|
return new Date(unix * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bumped on every full render so polling loops of a screen that is gone stop.
|
||||||
|
let screen = 0;
|
||||||
|
let lastChanges = -1;
|
||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
|
screen++;
|
||||||
const state = await invoke<AppState>("state");
|
const state = await invoke<AppState>("state");
|
||||||
app.replaceChildren();
|
app.replaceChildren();
|
||||||
if (!state.has_account) renderCreate();
|
if (!state.has_account) await renderOnboarding();
|
||||||
else if (state.locked) renderUnlock(state);
|
else if (state.locked) renderUnlock(state);
|
||||||
else await renderVault(state);
|
else await renderVault(state);
|
||||||
await renderPrompts(state);
|
await renderPrompts(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ account
|
// ------------------------------------------------------------------ onboarding (docs/10)
|
||||||
|
|
||||||
function renderCreate() {
|
type Tab = "phone" | "kit" | "paste" | "create";
|
||||||
const pw = el("input", { type: "password", placeholder: "Master password (10+ chars)" });
|
|
||||||
|
async function renderOnboarding(tab: Tab = "phone") {
|
||||||
|
const status = await invoke<SyncStatus>("sync_status");
|
||||||
|
const body = el("div", { className: "grid" });
|
||||||
|
const tabs: [Tab, string][] = [["phone", "Use my phone"], ["kit", "Emergency Kit"], ["paste", "Paste setup code"], ["create", "New account"]];
|
||||||
|
const nav = el("div", { className: "tabs" }, ...tabs.map(([t, label]) => el("button", {
|
||||||
|
textContent: label,
|
||||||
|
className: t === tab ? "tab sel" : "tab",
|
||||||
|
onclick: async () => {
|
||||||
|
await invoke("join_cancel");
|
||||||
|
app.replaceChildren();
|
||||||
|
screen++;
|
||||||
|
await renderOnboarding(t);
|
||||||
|
},
|
||||||
|
})));
|
||||||
|
const card = el("div", { className: "card wide" }, el("h2", { textContent: "Set up Kestrove on this computer" }), nav, body);
|
||||||
|
if (status.key_warning) card.append(el("p", { className: "warn", textContent: status.key_warning }));
|
||||||
|
app.append(el("div", { className: "center" }, card));
|
||||||
|
if (tab === "phone") phonePanel(body, status);
|
||||||
|
else if (tab === "kit") kitPanel(body);
|
||||||
|
else if (tab === "paste") pastePanel(body);
|
||||||
|
else createPanel(body, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
function serverInput(status: SyncStatus): HTMLInputElement {
|
||||||
|
return el("input", { type: "url", placeholder: "https://kestrove.example.com", value: status.server_url ?? "", spellcheck: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function phonePanel(body: HTMLElement, status: SyncStatus) {
|
||||||
|
const url = serverInput(status);
|
||||||
const err = el("div", { className: "err" });
|
const err = el("div", { className: "err" });
|
||||||
const btn = el("button", { textContent: "Create vault", className: "primary" });
|
const start = el("button", { textContent: "Show QR code", className: "primary" });
|
||||||
const card = el("div", { className: "card" }, el("h2", { textContent: "Create your Kestrove vault" }), pw, btn, err);
|
body.append(
|
||||||
btn.onclick = async () => {
|
el("p", { className: "muted", textContent: "Your phone sends this computer everything it needs. You'll only type your master password." }),
|
||||||
btn.disabled = true;
|
el("label", { className: "grid" }, "Your Kestrove server", url),
|
||||||
|
start, err,
|
||||||
|
);
|
||||||
|
start.onclick = async () => {
|
||||||
|
start.disabled = true;
|
||||||
|
err.textContent = "";
|
||||||
try {
|
try {
|
||||||
const secretKey = await invoke<string>("create_account", { masterPassword: pw.value });
|
const join = await invoke<JoinView>("join_start", { serverUrl: url.value });
|
||||||
pw.value = "";
|
showJoin(body, join);
|
||||||
card.replaceChildren(
|
} catch (e) {
|
||||||
el("h2", { textContent: "Your Secret Key" }),
|
err.textContent = errText(e);
|
||||||
el("p", { className: "mono", textContent: secretKey }),
|
start.disabled = false;
|
||||||
el("p", { className: "muted", textContent: "Write this down in your Emergency Kit. You need it to add devices. This device remembers it." }),
|
}
|
||||||
el("button", { textContent: "Continue", className: "primary", onclick: render }),
|
};
|
||||||
);
|
url.onkeydown = (e) => e.key === "Enter" && start.click();
|
||||||
|
if (!url.value) url.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showJoin(body: HTMLElement, join: JoinView) {
|
||||||
|
const mine = screen;
|
||||||
|
const codeBox = el("div", { className: "code muted", textContent: "Waiting for your phone…" });
|
||||||
|
const err = el("div", { className: "err" });
|
||||||
|
const text = el("textarea", { readOnly: true, className: "mono small", rows: 3, value: join.qr_text });
|
||||||
|
const copy = el("button", { textContent: "Copy", onclick: () => copyText(text) });
|
||||||
|
const cancel = el("button", {
|
||||||
|
textContent: "Cancel",
|
||||||
|
onclick: async () => {
|
||||||
|
await invoke("join_cancel");
|
||||||
|
await render();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
body.replaceChildren(
|
||||||
|
el("ol", { className: "steps" },
|
||||||
|
el("li", { textContent: "On your phone, open Kestrove → Settings → Add a computer." }),
|
||||||
|
el("li", { textContent: "Scan this code." }),
|
||||||
|
el("li", { textContent: "Check that both screens show the same 6 digits, then approve on the phone." })),
|
||||||
|
el("img", { src: join.qr_svg, alt: "QR code for your phone", className: "qr" }),
|
||||||
|
el("div", { className: "row" }, countdown(join.expires_at, () => {
|
||||||
|
if (mine === screen) err.textContent = "The code expired. Cancel and start again.";
|
||||||
|
})),
|
||||||
|
codeBox,
|
||||||
|
el("details", {},
|
||||||
|
el("summary", { textContent: "No phone? Approve from another computer" }),
|
||||||
|
el("p", { className: "muted", textContent: "On a computer that is already signed in, open Settings → Devices → Approve a computer, and paste this join code:" }),
|
||||||
|
text, el("div", { className: "row" }, copy)),
|
||||||
|
err,
|
||||||
|
el("div", { className: "row" }, cancel),
|
||||||
|
);
|
||||||
|
void (async () => {
|
||||||
|
while (mine === screen) {
|
||||||
|
await sleep(2000);
|
||||||
|
if (mine !== screen) return;
|
||||||
|
try {
|
||||||
|
const p = await invoke<JoinPollView>("join_poll");
|
||||||
|
if (p.code) {
|
||||||
|
codeBox.className = "code";
|
||||||
|
codeBox.replaceChildren(el("div", { className: "muted", textContent: "Your phone should show:" }), el("div", { className: "digits", textContent: p.code }));
|
||||||
|
}
|
||||||
|
if (p.setup) {
|
||||||
|
if (mine === screen) finishSetup(body, p.setup);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = errText(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
function kitPanel(body: HTMLElement) {
|
||||||
|
const file = el("input", { type: "file", accept: ".png,.jpg,.jpeg,.pdf,image/png,image/jpeg,application/pdf" });
|
||||||
|
const err = el("div", { className: "err" });
|
||||||
|
const open = el("button", { textContent: "Open Emergency Kit…", className: "primary", onclick: () => file.click() });
|
||||||
|
file.hidden = true;
|
||||||
|
file.onchange = async () => {
|
||||||
|
const f = file.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
err.textContent = "";
|
||||||
|
open.disabled = true;
|
||||||
|
open.textContent = "Reading…";
|
||||||
|
try {
|
||||||
|
const bytes = new Uint8Array(await f.arrayBuffer());
|
||||||
|
const view = await invoke<SetupView>("setup_from_file", bytes);
|
||||||
|
finishSetup(body, view);
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = errText(e);
|
||||||
|
} finally {
|
||||||
|
open.disabled = false;
|
||||||
|
open.textContent = "Open Emergency Kit…";
|
||||||
|
file.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
body.append(
|
||||||
|
el("p", { className: "muted", textContent: "Choose your Emergency Kit PDF, or a photo or screenshot of its QR code (PNG or JPEG). Another device will need to approve this computer." }),
|
||||||
|
file, open, err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pastePanel(body: HTMLElement) {
|
||||||
|
const text = el("textarea", { rows: 4, className: "mono small", placeholder: "kestrove://setup/v1?…", spellcheck: false });
|
||||||
|
const err = el("div", { className: "err" });
|
||||||
|
const next = el("button", { textContent: "Continue", className: "primary" });
|
||||||
|
next.onclick = async () => {
|
||||||
|
err.textContent = "";
|
||||||
|
try {
|
||||||
|
const view = await invoke<SetupView>("setup_from_text", { text: text.value });
|
||||||
|
text.value = "";
|
||||||
|
finishSetup(body, view);
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = errText(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
body.append(el("p", { className: "muted", textContent: "Paste the setup code from your Emergency Kit or from “Add a device” on another device." }), text, next, err);
|
||||||
|
text.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The last step of every way in: only the master password.
|
||||||
|
function finishSetup(body: HTMLElement, view: SetupView) {
|
||||||
|
const pw = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" });
|
||||||
|
const err = el("div", { className: "err" });
|
||||||
|
const btn = el("button", { textContent: "Sign in", className: "primary" });
|
||||||
|
const waiting = el("div");
|
||||||
|
const submit = async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
err.textContent = "";
|
||||||
|
btn.textContent = "Signing in…";
|
||||||
|
try {
|
||||||
|
const r = await invoke<"ok" | "pending_approval">("finish_setup", { masterPassword: pw.value });
|
||||||
|
if (r === "ok") {
|
||||||
|
pw.value = "";
|
||||||
|
await render();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const status = await invoke<SyncStatus>("sync_status");
|
||||||
|
waiting.replaceChildren(el("div", { className: "notice" },
|
||||||
|
el("p", { textContent: "Almost done: approve this computer on a device that is already signed in (Settings → Devices). Check that it shows this fingerprint:" }),
|
||||||
|
el("p", { className: "mono", textContent: groups(status.device_fingerprint ?? "") }),
|
||||||
|
el("p", { className: "muted", textContent: "Then come back and choose “Check again”." })));
|
||||||
|
btn.textContent = "Check again";
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = errText(e);
|
||||||
|
btn.textContent = "Sign in";
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
};
|
||||||
|
btn.onclick = submit;
|
||||||
|
pw.onkeydown = (e) => e.key === "Enter" && void submit();
|
||||||
|
body.replaceChildren(
|
||||||
|
el("p", {}, "Signing in to ", el("strong", { textContent: view.server_host }), "."),
|
||||||
|
el("p", { className: "muted", textContent: view.preapproved ? "This computer is pre-approved." : "Another device will need to approve this computer." }),
|
||||||
|
pw, btn, waiting, err,
|
||||||
|
el("button", { textContent: "Start over", className: "link", onclick: async () => { await invoke("clear_setup"); await render(); } }),
|
||||||
|
);
|
||||||
|
pw.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPanel(body: HTMLElement, status: SyncStatus) {
|
||||||
|
const url = serverInput(status);
|
||||||
|
const code = el("input", { placeholder: "Setup code (KV_SETUP_CODE in the server's .env)", spellcheck: false });
|
||||||
|
const pins = el("input", { placeholder: "SPKI pins, base64, comma-separated (optional)", className: "mono small", spellcheck: false });
|
||||||
|
const pw = el("input", { type: "password", placeholder: "Master password (10+ characters)", autocomplete: "new-password" });
|
||||||
|
const pw2 = el("input", { type: "password", placeholder: "Repeat master password", autocomplete: "new-password" });
|
||||||
|
const err = el("div", { className: "err" });
|
||||||
|
const btn = el("button", { textContent: "Create account", className: "primary" });
|
||||||
|
btn.onclick = async () => {
|
||||||
|
err.textContent = "";
|
||||||
|
if (pw.value !== pw2.value) {
|
||||||
|
err.textContent = "The passwords don't match.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "Creating…";
|
||||||
|
try {
|
||||||
|
const secretKey = await invoke<string>("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, masterPassword: pw.value });
|
||||||
|
pw.value = pw2.value = "";
|
||||||
|
await showSecretKey(secretKey);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err.textContent = errText(e);
|
err.textContent = errText(e);
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Create account";
|
||||||
|
// A vault created here whose server registration failed: continue to the vault (Settings → Sync retries).
|
||||||
|
if ((await invoke<AppState>("state")).has_account) {
|
||||||
|
body.append(el("button", { textContent: "Continue to the vault", onclick: render }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
app.append(el("div", { className: "center" }, card));
|
body.append(
|
||||||
pw.focus();
|
el("p", { className: "muted", textContent: "For the first device on a new server. You'll get a Secret Key: keep it with your Emergency Kit." }),
|
||||||
|
el("label", { className: "grid" }, "Server", url),
|
||||||
|
el("label", { className: "grid" }, "Setup code", code),
|
||||||
|
el("details", {}, el("summary", { textContent: "Certificate pins (advanced)" }), pins),
|
||||||
|
pw, pw2, btn, err,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showSecretKey(secretKey: string) {
|
||||||
|
const kit = await invoke<string>("kit_qr").catch(() => null);
|
||||||
|
const card = el("div", { className: "card wide" },
|
||||||
|
el("h2", { textContent: "Your Secret Key" }),
|
||||||
|
el("p", { className: "mono big", textContent: secretKey }),
|
||||||
|
el("p", { className: "muted", textContent: "Write it down, or print this screen, and keep it with your Emergency Kit. Every new device needs it with your master password. This computer remembers it." }));
|
||||||
|
if (kit) {
|
||||||
|
card.append(
|
||||||
|
el("p", { textContent: "Setup code for your Emergency Kit (server, account and Secret Key; keep it as safe as the Secret Key):" }),
|
||||||
|
el("img", { src: kit, alt: "Emergency Kit setup code", className: "qr" }));
|
||||||
|
}
|
||||||
|
card.append(el("button", { textContent: "Continue", className: "primary", onclick: render }));
|
||||||
|
app.replaceChildren(el("div", { className: "center" }, card));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ unlock
|
||||||
|
|
||||||
function renderUnlock(state: AppState) {
|
function renderUnlock(state: AppState) {
|
||||||
const pw = el("input", { type: "password", placeholder: "Master password" });
|
const pw = el("input", { type: "password", placeholder: "Master password" });
|
||||||
const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" });
|
const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" });
|
||||||
@@ -84,26 +349,86 @@ function renderUnlock(state: AppState) {
|
|||||||
|
|
||||||
// ------------------------------------------------------------------ vault
|
// ------------------------------------------------------------------ vault
|
||||||
|
|
||||||
|
let refreshList: (() => Promise<void>) | null = null;
|
||||||
|
let syncBox: HTMLElement | null = null;
|
||||||
|
|
||||||
async function renderVault(state: AppState) {
|
async function renderVault(state: AppState) {
|
||||||
const items = await invoke<ItemSummary[]>("list_items");
|
|
||||||
const detail = el("section");
|
const detail = el("section");
|
||||||
const list = el("ul");
|
const list = el("ul");
|
||||||
for (const it of items) {
|
const fill = async () => {
|
||||||
const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" }));
|
const items = await invoke<ItemSummary[]>("list_items");
|
||||||
li.onclick = async () => {
|
const selected = list.querySelector("li.sel")?.getAttribute("data-id");
|
||||||
list.querySelectorAll("li").forEach((n) => n.classList.remove("sel"));
|
list.replaceChildren();
|
||||||
li.classList.add("sel");
|
for (const it of items) {
|
||||||
await renderDetail(detail, it.id);
|
const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" }));
|
||||||
};
|
li.dataset.id = it.id;
|
||||||
list.append(li);
|
if (it.id === selected) li.classList.add("sel");
|
||||||
}
|
li.onclick = async () => {
|
||||||
if (!items.length) list.append(el("li", { className: "muted", textContent: "No items yet." }));
|
list.querySelectorAll("li").forEach((n) => n.classList.remove("sel"));
|
||||||
|
li.classList.add("sel");
|
||||||
|
await renderDetail(detail, it.id);
|
||||||
|
};
|
||||||
|
list.append(li);
|
||||||
|
}
|
||||||
|
if (!items.length) list.append(el("li", { className: "muted", textContent: "No items yet." }));
|
||||||
|
};
|
||||||
|
await fill();
|
||||||
|
refreshList = fill;
|
||||||
const add = el("button", { textContent: "+ New login", onclick: () => renderEditor(detail) });
|
const add = el("button", { textContent: "+ New login", onclick: () => renderEditor(detail) });
|
||||||
const settings = el("button", { textContent: "Settings", onclick: () => renderSettings(detail, state) });
|
const settings = el("button", { textContent: "Settings", onclick: () => renderSettings(detail, state) });
|
||||||
const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } });
|
const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } });
|
||||||
const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list);
|
syncBox = el("div", { className: "sync" });
|
||||||
if (state.ipc_error) aside.append(el("p", { className: "err pad", textContent: state.ipc_error }));
|
const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list, syncBox);
|
||||||
|
if (state.ipc_error) aside.insertBefore(el("p", { className: "err pad", textContent: state.ipc_error }), list);
|
||||||
app.append(aside, detail);
|
app.append(aside, detail);
|
||||||
|
const status = await invoke<SyncStatus>("sync_status");
|
||||||
|
lastChanges = status.changes;
|
||||||
|
showSync(status, detail, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync footer and banners in the sidebar; updated on every `sync-status` event.
|
||||||
|
function showSync(s: SyncStatus, detail?: HTMLElement, state?: AppState) {
|
||||||
|
if (!syncBox?.isConnected) return;
|
||||||
|
const openSettings = () => {
|
||||||
|
const d = detail ?? (document.querySelector("main section") as HTMLElement | null);
|
||||||
|
if (d && state) void renderSettings(d, state);
|
||||||
|
else if (d) void invoke<AppState>("state").then((st) => renderSettings(d, st));
|
||||||
|
};
|
||||||
|
const parts: Node[] = [];
|
||||||
|
if (s.integrity_alarm) {
|
||||||
|
parts.push(el("div", { className: "alarm" },
|
||||||
|
el("strong", { textContent: "Sync stopped: integrity alarm" }),
|
||||||
|
el("p", { textContent: s.integrity_alarm }),
|
||||||
|
el("p", { textContent: "The server (or someone controlling it) sent data that doesn't match what your devices signed. Don't trust this server until you've checked it." })));
|
||||||
|
}
|
||||||
|
if (s.pending_devices > 0) {
|
||||||
|
parts.push(el("div", { className: "notice" }, `${s.pending_devices} device${s.pending_devices > 1 ? "s" : ""} waiting for approval. `,
|
||||||
|
el("button", { textContent: "Review", className: "link", onclick: openSettings })));
|
||||||
|
}
|
||||||
|
if (s.pending_approval) {
|
||||||
|
parts.push(el("div", { className: "notice", textContent: `This computer waits for approval on another device. Fingerprint: ${groups(s.device_fingerprint ?? "")}` }));
|
||||||
|
}
|
||||||
|
if (s.restored_items > 0) {
|
||||||
|
parts.push(el("div", { className: "notice", textContent: `${s.restored_items} item${s.restored_items > 1 ? "s" : ""} deleted on another device came back because they were edited here.` }));
|
||||||
|
}
|
||||||
|
let line: string;
|
||||||
|
if (!s.server_url) line = "Not connected to a sync server";
|
||||||
|
else if (s.syncing) line = "Syncing…";
|
||||||
|
else if (s.last_error) line = s.last_error;
|
||||||
|
else if (s.last_sync_at) line = `Synced ${ago(s.last_sync_at)}`;
|
||||||
|
else line = "Not synced yet";
|
||||||
|
const now = el("button", {
|
||||||
|
textContent: "Sync now",
|
||||||
|
disabled: s.syncing || !s.server_url,
|
||||||
|
onclick: async () => {
|
||||||
|
now.disabled = true;
|
||||||
|
await invoke("sync_now").catch(() => undefined);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
parts.push(el("div", { className: "row between" },
|
||||||
|
el("span", { className: s.last_error && !s.syncing ? "err small" : "muted small", textContent: line, title: s.server_url ?? "" }),
|
||||||
|
s.server_url ? now : el("button", { textContent: "Set up", onclick: openSettings })));
|
||||||
|
syncBox.replaceChildren(...parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderDetail(target: HTMLElement, id: string) {
|
async function renderDetail(target: HTMLElement, id: string) {
|
||||||
@@ -166,6 +491,8 @@ function renderEditor(target: HTMLElement) {
|
|||||||
target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, el("div", { className: "row" }, pass, gen), url, totp, save, err));
|
target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, el("div", { className: "row" }, pass, gen), url, totp, save, err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ settings
|
||||||
|
|
||||||
async function renderSettings(target: HTMLElement, state: AppState) {
|
async function renderSettings(target: HTMLElement, state: AppState) {
|
||||||
const pairings = await invoke<Pairing[]>("list_pairings");
|
const pairings = await invoke<Pairing[]>("list_pairings");
|
||||||
const idle = el("select", {}, ...[1, 5, 10, 15, 30, 60, 120].map((m) => el("option", { value: String(m), textContent: `${m} min`, selected: m === state.idle_minutes })));
|
const idle = el("select", {}, ...[1, 5, 10, 15, 30, 60, 120].map((m) => el("option", { value: String(m), textContent: `${m} min`, selected: m === state.idle_minutes })));
|
||||||
@@ -187,13 +514,234 @@ async function renderSettings(target: HTMLElement, state: AppState) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!pairings.length) list.append(el("li", { className: "muted", textContent: "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”." }));
|
if (!pairings.length) list.append(el("li", { className: "muted", textContent: "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”." }));
|
||||||
|
const syncSection = el("div");
|
||||||
|
const devicesSection = el("div");
|
||||||
target.replaceChildren(
|
target.replaceChildren(
|
||||||
el("h2", { textContent: "Settings" }),
|
el("h2", { textContent: "Settings" }),
|
||||||
el("label", { className: "row" }, "Lock after idle for ", idle),
|
el("label", { className: "row" }, "Lock after idle for ", idle),
|
||||||
el("p", { className: "muted", textContent: "Kestrove also locks on screen lock and sleep." }),
|
el("p", { className: "muted", textContent: "Kestrove also locks on screen lock and sleep." }),
|
||||||
|
el("h3", { textContent: "Sync" }),
|
||||||
|
syncSection,
|
||||||
|
el("h3", { textContent: "Devices" }),
|
||||||
|
devicesSection,
|
||||||
|
el("h3", { textContent: "Emergency Kit" }),
|
||||||
|
kitSection(),
|
||||||
el("h3", { textContent: "Paired browsers" }),
|
el("h3", { textContent: "Paired browsers" }),
|
||||||
list,
|
list,
|
||||||
);
|
);
|
||||||
|
const status = await invoke<SyncStatus>("sync_status");
|
||||||
|
renderSyncSettings(syncSection, status, () => renderSettings(target, state));
|
||||||
|
if (status.server_url && status.logged_in) await renderDevices(devicesSection);
|
||||||
|
else devicesSection.append(el("p", { className: "muted", textContent: "Connect to a sync server first." }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promise<void>) {
|
||||||
|
const facts = el("dl", { className: "facts" },
|
||||||
|
el("dt", { textContent: "Server" }), el("dd", { className: "mono", textContent: s.server_url ?? "—" }),
|
||||||
|
el("dt", { textContent: "Pins" }), el("dd", { className: "mono small", textContent: s.pins.length ? s.pins.join(", ") : "none (system certificates)" }),
|
||||||
|
el("dt", { textContent: "Last sync" }), el("dd", { textContent: s.last_sync_at ? ago(s.last_sync_at) : "never" }),
|
||||||
|
el("dt", { textContent: "This device" }), el("dd", { className: "mono", textContent: groups(s.device_fingerprint ?? "—") }),
|
||||||
|
el("dt", { textContent: "Device key" }), el("dd", { textContent: s.key_storage ?? "unavailable" }));
|
||||||
|
box.replaceChildren(facts);
|
||||||
|
if (s.key_warning) box.append(el("p", { className: "warn", textContent: s.key_warning }));
|
||||||
|
if (s.last_error) box.append(el("p", { className: "err", textContent: s.last_error }));
|
||||||
|
if (s.server_url && s.logged_in) return;
|
||||||
|
|
||||||
|
// A vault from before sync (or whose registration failed): connect it.
|
||||||
|
const url = el("input", { type: "url", placeholder: "https://kestrove.example.com", value: s.server_url ?? "" });
|
||||||
|
const pins = el("input", { placeholder: "SPKI pins (optional)", className: "mono small", value: s.pins.join(", ") });
|
||||||
|
const code = el("input", { placeholder: "Setup code (only for a new server)" });
|
||||||
|
const err = el("div", { className: "err" });
|
||||||
|
const btn = el("button", { textContent: "Connect", className: "primary" });
|
||||||
|
btn.onclick = async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
err.textContent = "";
|
||||||
|
try {
|
||||||
|
await invoke("configure_server", { serverUrl: url.value, pins: pins.value });
|
||||||
|
if (await invoke<boolean>("server_setup_open")) {
|
||||||
|
if (!code.value.trim()) throw new Error("This server has no account yet: enter its setup code to register this vault.");
|
||||||
|
await invoke("register_server", { setupCode: code.value });
|
||||||
|
} else {
|
||||||
|
await invoke("sync_now");
|
||||||
|
}
|
||||||
|
await reload();
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = errText(e);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
box.append(el("div", { className: "card" }, url, pins, code, btn, err));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderDevices(box: HTMLElement) {
|
||||||
|
const err = el("div", { className: "err" });
|
||||||
|
const list = el("ul", { className: "plain" });
|
||||||
|
const reload = () => renderDevices(box);
|
||||||
|
let devices: DeviceView[] = [];
|
||||||
|
try {
|
||||||
|
devices = await invoke<DeviceView[]>("list_devices");
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = errText(e);
|
||||||
|
}
|
||||||
|
for (const d of devices.filter((d) => !d.revoked)) {
|
||||||
|
const badges = [d.current ? "this device" : "", d.approved ? "" : "waiting for approval"].filter(Boolean).join(" · ");
|
||||||
|
const li = el("li", {},
|
||||||
|
el("div", {}, el("strong", { textContent: d.name }), ` ${d.platform}`, badges ? el("span", { className: d.approved ? "muted" : "warn", textContent: ` · ${badges}` }) : ""),
|
||||||
|
el("div", { className: "muted mono small", textContent: groups(d.key_fingerprint) }),
|
||||||
|
el("div", { className: "muted small", textContent: `Added ${new Date(d.created_at).toLocaleString()}` }));
|
||||||
|
const actions = el("div", { className: "row" });
|
||||||
|
if (!d.approved) {
|
||||||
|
actions.append(el("button", {
|
||||||
|
textContent: "Approve…",
|
||||||
|
className: "primary",
|
||||||
|
onclick: () => confirmModal(
|
||||||
|
`Approve “${d.name}”?`,
|
||||||
|
[el("p", { textContent: "Only approve it if the new device shows exactly this fingerprint:" }), el("p", { className: "mono big", textContent: groups(d.key_fingerprint) })],
|
||||||
|
"The fingerprints match: approve",
|
||||||
|
async () => { await invoke("approve_device", { deviceId: d.device_id }); await reload(); }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (!d.current) {
|
||||||
|
actions.append(el("button", {
|
||||||
|
textContent: d.approved ? "Remove" : "Reject",
|
||||||
|
className: "danger",
|
||||||
|
onclick: () => confirmModal(`Remove “${d.name}”?`, [el("p", { textContent: "It will be signed out and can't sync again. Its local copy stays on it." })], "Remove device",
|
||||||
|
async () => { await invoke("revoke_device", { deviceId: d.device_id }); await reload(); }),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
li.append(actions);
|
||||||
|
list.append(li);
|
||||||
|
}
|
||||||
|
box.replaceChildren(
|
||||||
|
el("div", { className: "row" },
|
||||||
|
el("button", { textContent: "Add a phone or tablet…", onclick: addDeviceModal }),
|
||||||
|
el("button", { textContent: "Approve a computer…", onclick: approveComputerModal })),
|
||||||
|
list, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function kitSection(): HTMLElement {
|
||||||
|
const box = el("div");
|
||||||
|
const show = el("button", {
|
||||||
|
textContent: "Show Emergency Kit setup code",
|
||||||
|
onclick: async () => {
|
||||||
|
try {
|
||||||
|
const qr = await invoke<string>("kit_qr");
|
||||||
|
box.replaceChildren(
|
||||||
|
el("p", { className: "warn", textContent: "This code contains your Secret Key. Keep it as safe as your Emergency Kit." }),
|
||||||
|
el("img", { src: qr, alt: "Emergency Kit setup code", className: "qr" }),
|
||||||
|
el("button", { textContent: "Hide", onclick: () => box.replaceChildren(show) }));
|
||||||
|
} catch (e) {
|
||||||
|
box.append(el("p", { className: "err", textContent: errText(e) }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
box.append(show);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ dialogs
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modal.replaceChildren();
|
||||||
|
modal.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(...children: Node[]) {
|
||||||
|
modal.replaceChildren(el("div", { className: "prompt" }, ...children));
|
||||||
|
modal.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmModal(title: string, body: Node[], yes: string, action: () => Promise<void>) {
|
||||||
|
const err = el("div", { className: "err" });
|
||||||
|
const ok = el("button", { textContent: yes, className: "primary" });
|
||||||
|
ok.onclick = async () => {
|
||||||
|
ok.disabled = true;
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = errText(e);
|
||||||
|
ok.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
openModal(el("h3", { textContent: title }), ...body, el("div", { className: "row" }, ok, el("button", { textContent: "Cancel", onclick: closeModal })), err);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Add a device": a single-use, 10-minute QR. Cancel or Done revokes the ticket if it wasn't used.
|
||||||
|
async function addDeviceModal() {
|
||||||
|
let view: AddDeviceView;
|
||||||
|
try {
|
||||||
|
view = await invoke<AddDeviceView>("add_device");
|
||||||
|
} catch (e) {
|
||||||
|
openModal(el("h3", { textContent: "Add a device" }), el("p", { className: "err", textContent: errText(e) }), el("button", { textContent: "Close", onclick: closeModal }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const done = async () => {
|
||||||
|
closeModal();
|
||||||
|
await invoke("cancel_add_device");
|
||||||
|
};
|
||||||
|
openModal(
|
||||||
|
el("h3", { textContent: "Add a phone or tablet" }),
|
||||||
|
el("p", { textContent: "In Kestrove on the new device, choose “Scan setup code” and scan this. Then it only asks for your master password." }),
|
||||||
|
el("img", { src: view.qr_svg, alt: "Setup code for a new device", className: "qr" }),
|
||||||
|
el("p", { className: "warn small", textContent: "It contains your Secret Key and works once. Don't show it to anyone else." }),
|
||||||
|
el("div", { className: "row between" }, countdown(view.expires_at, () => { if (!modal.hidden) void done(); }), el("button", { textContent: "Done", className: "primary", onclick: done })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desktops usually have no camera: approve a new computer from its pasted join code.
|
||||||
|
function approveComputerModal() {
|
||||||
|
const text = el("textarea", { rows: 4, className: "mono small", placeholder: "kestrove://join/v1?…", spellcheck: false });
|
||||||
|
const err = el("div", { className: "err" });
|
||||||
|
const next = el("button", { textContent: "Continue", className: "primary" });
|
||||||
|
const cancel = async () => {
|
||||||
|
await invoke("join_cancel");
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
next.onclick = async () => {
|
||||||
|
next.disabled = true;
|
||||||
|
err.textContent = "";
|
||||||
|
try {
|
||||||
|
const c = await invoke<JoinConfirmView>("approve_join", { joinCode: text.value });
|
||||||
|
const approve = el("button", { textContent: "The codes match: approve", className: "primary" });
|
||||||
|
const err2 = el("div", { className: "err" });
|
||||||
|
approve.onclick = async () => {
|
||||||
|
approve.disabled = true;
|
||||||
|
try {
|
||||||
|
await invoke("send_join");
|
||||||
|
openModal(el("h3", { textContent: "Approved" }), el("p", { textContent: `${c.device_name} will finish setting up and ask for your master password.` }), el("button", { textContent: "Close", className: "primary", onclick: closeModal }));
|
||||||
|
} catch (e) {
|
||||||
|
err2.textContent = errText(e);
|
||||||
|
approve.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
openModal(
|
||||||
|
el("h3", { textContent: `Add “${c.device_name}” (${c.platform})?` }),
|
||||||
|
el("p", { textContent: "Check that the new computer shows exactly:" }),
|
||||||
|
el("div", { className: "digits", textContent: c.code }),
|
||||||
|
el("p", { className: "muted small", textContent: `Device key ${groups(c.key_fingerprint)}` }),
|
||||||
|
el("div", { className: "row" }, approve, el("button", { textContent: "Cancel", onclick: cancel })),
|
||||||
|
err2,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
err.textContent = errText(e);
|
||||||
|
next.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
openModal(
|
||||||
|
el("h3", { textContent: "Approve a computer" }),
|
||||||
|
el("p", { textContent: "On the new computer choose “Use my phone”, open “No phone? Approve from another computer” and copy the join code. Paste it here:" }),
|
||||||
|
text,
|
||||||
|
el("div", { className: "row" }, next, el("button", { textContent: "Cancel", onclick: cancel })),
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
text.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(area: HTMLTextAreaElement) {
|
||||||
|
area.select();
|
||||||
|
if (navigator.clipboard) void navigator.clipboard.writeText(area.value).catch(() => document.execCommand("copy"));
|
||||||
|
else document.execCommand("copy");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ browser prompts (pairing, save)
|
// ------------------------------------------------------------------ browser prompts (pairing, save)
|
||||||
@@ -260,7 +808,27 @@ function saveCard(s: SaveView): HTMLElement {
|
|||||||
|
|
||||||
void listen("pairing-request", () => render());
|
void listen("pairing-request", () => render());
|
||||||
void listen("save-request", () => render());
|
void listen("save-request", () => render());
|
||||||
void listen("locked", () => render());
|
void listen("locked", () => {
|
||||||
|
closeModal();
|
||||||
|
void render();
|
||||||
|
});
|
||||||
|
let alarmShown = false;
|
||||||
|
void listen<SyncStatus>("sync-status", (e) => {
|
||||||
|
const s = e.payload;
|
||||||
|
showSync(s);
|
||||||
|
if (lastChanges !== -1 && s.changes !== lastChanges && refreshList && syncBox?.isConnected) void refreshList();
|
||||||
|
lastChanges = s.changes;
|
||||||
|
if (s.integrity_alarm && !alarmShown) {
|
||||||
|
alarmShown = true;
|
||||||
|
openModal(el("h3", { textContent: "Sync stopped: integrity alarm" }), el("p", { textContent: s.integrity_alarm }),
|
||||||
|
el("p", { textContent: "Your local data is unchanged and stays usable. Don't trust the server until you've checked it." }),
|
||||||
|
el("button", { textContent: "OK", className: "primary", onclick: closeModal }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Keep "Synced 3 min ago" fresh.
|
||||||
|
setInterval(() => {
|
||||||
|
if (syncBox?.isConnected) void invoke<SyncStatus>("sync_status").then((s) => showSync(s));
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
// Activity for the idle timer (Linux has no system idle time without extra deps).
|
// Activity for the idle timer (Linux has no system idle time without extra deps).
|
||||||
let lastTouch = 0;
|
let lastTouch = 0;
|
||||||
|
|||||||
+31
-2
@@ -1,7 +1,7 @@
|
|||||||
:root { color-scheme: light dark; font: 14px system-ui, sans-serif; }
|
:root { color-scheme: light dark; font: 14px system-ui, sans-serif; }
|
||||||
body { margin: 0; }
|
body { margin: 0; }
|
||||||
main { display: grid; grid-template-columns: 300px 1fr; height: 100vh; }
|
main { display: grid; grid-template-columns: 300px 1fr; height: 100vh; overflow: auto; }
|
||||||
.center { display: grid; place-items: center; height: 100vh; grid-column: 1 / -1; }
|
.center { display: grid; place-items: center; min-height: 100vh; padding: 16px 0; box-sizing: border-box; grid-column: 1 / -1; }
|
||||||
.card { display: grid; gap: 8px; width: 340px; }
|
.card { display: grid; gap: 8px; width: 340px; }
|
||||||
input, button { font: inherit; padding: 8px; border-radius: 6px; border: 1px solid #8886; }
|
input, button { font: inherit; padding: 8px; border-radius: 6px; border: 1px solid #8886; }
|
||||||
button { cursor: pointer; }
|
button { cursor: pointer; }
|
||||||
@@ -27,3 +27,32 @@ ul.plain li { list-style: none; padding: 8px 0; border-bottom: 1px solid #8883;
|
|||||||
.prompt { background: Canvas; color: CanvasText; border-radius: 10px; padding: 16px 20px; width: 420px; box-shadow: 0 10px 40px #0005; }
|
.prompt { background: Canvas; color: CanvasText; border-radius: 10px; padding: 16px 20px; width: 420px; box-shadow: 0 10px 40px #0005; }
|
||||||
.words { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 12px; }
|
.words { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 12px; }
|
||||||
.words span { font: 600 15px ui-monospace, monospace; padding: 4px 8px; border-radius: 6px; background: #8882; }
|
.words span { font: 600 15px ui-monospace, monospace; padding: 4px 8px; border-radius: 6px; background: #8882; }
|
||||||
|
#modal { position: fixed; inset: 0; background: #0006; display: grid; place-content: center; z-index: 2; }
|
||||||
|
#modal[hidden] { display: none; }
|
||||||
|
#modal .prompt { display: grid; gap: 8px; max-height: 90vh; overflow: auto; }
|
||||||
|
aside { display: flex; flex-direction: column; height: 100vh; box-sizing: border-box; }
|
||||||
|
main > section { height: 100vh; overflow: auto; box-sizing: border-box; }
|
||||||
|
aside > ul { flex: 1; }
|
||||||
|
.sync { border-top: 1px solid #8884; padding: 8px 12px; display: grid; gap: 8px; }
|
||||||
|
.between { justify-content: space-between; }
|
||||||
|
.small { font-size: 12px; }
|
||||||
|
.big { font-size: 18px; }
|
||||||
|
.grid { display: grid; gap: 8px; }
|
||||||
|
.card.wide { width: 440px; }
|
||||||
|
.tabs { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.tab { border-color: transparent; background: none; }
|
||||||
|
.tab.sel { background: #8883; font-weight: 600; }
|
||||||
|
textarea { font: inherit; padding: 8px; border-radius: 6px; border: 1px solid #8886; resize: vertical; width: 100%; box-sizing: border-box; }
|
||||||
|
.qr { width: 240px; height: 240px; justify-self: center; image-rendering: pixelated; border-radius: 6px; }
|
||||||
|
.code { text-align: center; padding: 8px; border-radius: 8px; background: #8881; }
|
||||||
|
.digits { font: 700 32px ui-monospace, monospace; letter-spacing: 2px; text-align: center; }
|
||||||
|
.steps { margin: 0; padding-left: 20px; display: grid; gap: 4px; }
|
||||||
|
.notice { background: #2c6e8622; border-radius: 8px; padding: 8px 10px; }
|
||||||
|
.alarm { background: #d3333322; border: 1px solid #d33; border-radius: 8px; padding: 8px 10px; }
|
||||||
|
.alarm p { margin: 4px 0 0; }
|
||||||
|
.warn { color: #b26b00; }
|
||||||
|
.link { border: none; background: none; color: #2c6e86; padding: 0; text-decoration: underline; justify-self: start; }
|
||||||
|
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0 0 8px; }
|
||||||
|
.facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||||
|
details { display: grid; gap: 6px; }
|
||||||
|
summary { cursor: pointer; }
|
||||||
|
|||||||
Reference in New Issue
Block a user