Sync with the server and scan-to-join onboarding
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

- 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:
2026-10-18 07:51:06 +09:00
co-authored by Claude Opus 5.5
parent 7701b65087
commit e19370f261
13 changed files with 3532 additions and 116 deletions
+21 -1
View File
@@ -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.
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)
- [ ] 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] Local IPC between `kestrove-nmh` and the app, plus the pairing AEAD channel
- [x] Native-messaging manifest installers (`native-messaging/install.sh`, `install.ps1`)
+1
View File
@@ -9,6 +9,7 @@
<body>
<main id="app"></main>
<div id="overlay" hidden></div>
<div id="modal" hidden></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1115 -48
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -40,12 +40,25 @@ base64 = "0.22"
uuid = { version = "1", features = ["v4"] }
url = "2"
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]
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]
windows-sys = { version = "0.61", features = [
"Win32_Security_Cryptography",
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
@@ -63,6 +76,7 @@ core-foundation = "0.10"
[dev-dependencies]
tempfile = "3"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
[profile.release]
lto = true
+5
View File
@@ -9,6 +9,11 @@ ignore = [
{ 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-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]
+545
View File
@@ -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
View File
@@ -3,7 +3,10 @@
pub mod autolock;
pub mod broker;
pub mod channel;
pub mod device_key;
pub mod framing;
pub mod ipc;
pub mod private_file;
pub mod qr;
pub mod sync;
pub mod words;
+200 -33
View File
@@ -10,6 +10,11 @@ use std::{io, thread};
use kestrove_desktop::autolock::{self, Activity, Reason};
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 kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore};
use serde::{Deserialize, Serialize};
@@ -22,6 +27,7 @@ const CLIPBOARD_CLEAR: Duration = Duration::from_secs(90);
struct App {
core: Arc<VaultCore>,
sync: Arc<SyncService>,
broker: Arc<Broker>,
activity: Arc<Activity>,
data_dir: PathBuf,
@@ -32,9 +38,6 @@ struct App {
}
impl App {
fn secret_key_path(&self) -> PathBuf {
self.data_dir.join("secret-key")
}
fn settings_path(&self) -> PathBuf {
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]
fn state(app: State<'_, App>) -> AppState {
AppState {
has_account: app.core.has_account(),
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(),
idle_minutes: app.idle_minutes.load(Ordering::Relaxed),
}
}
// 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]
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 sk = app.core.create_account(master_password.to_string()).map_err(err)?;
remember_secret_key(&app, &sk);
let sk = app.sync.create_account(&server_url, sync::parse_pins(&pins), &setup_code, master_password).await;
app.activity.touch();
Ok(sk)
sk.map(|s| s.to_string())
}
/// `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<()> {
let master_password = Zeroizing::new(master_password);
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)?;
if let Some(sk) = typed {
remember_secret_key(&app, &sk);
app.sync.remember_secret_key(&sk);
}
app.activity.touch();
app.sync.request_sync();
Ok(())
}
#[tauri::command]
fn lock(app: State<'_, App>) {
async fn lock(app: State<'_, App>) -> CmdResult<()> {
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.
@@ -251,11 +253,14 @@ fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
modified_at: 0,
})
.map_err(err)
.inspect(|_| app.sync.request_sync())
}
#[tauri::command]
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]
@@ -341,7 +346,120 @@ fn pending_saves(app: State<'_, App>) -> Vec<SaveView> {
#[tauri::command]
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
@@ -365,13 +483,19 @@ fn start_ipc(broker: Arc<Broker>) -> io::Result<()> {
Ok(())
}
fn lock_and_notify(handle: &AppHandle, core: &VaultCore, reason: &str) {
core.lock();
fn lock_and_notify(handle: &AppHandle, sync: &Arc<SyncService>, reason: &str) {
sync.core().lock();
let sync = sync.clone();
tauri::async_runtime::spawn(async move { sync.on_lock().await });
let _ = handle.emit("locked", reason);
}
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)?;
#[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))?;
}
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 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"))?);
@@ -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();
autolock::spawn(
activity.clone(),
@@ -412,7 +553,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Reason::ScreenLocked => "screen-locked",
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 menu = Menu::with_items(app, &[&open, &lock_item, &quit])?;
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() {
"open" => {
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();
}
}
"lock" => lock_and_notify(handle, &core, "manual"),
"lock" => lock_and_notify(handle, &sync, "manual"),
"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);
}
_ => {}
@@ -446,6 +592,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
app.manage(App {
core,
sync,
broker,
activity,
data_dir,
@@ -485,7 +632,27 @@ fn main() {
list_pairings,
revoke_pairing,
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!())
.expect("error while running Kestrove");
+171
View File
@@ -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()]);
}
}
+670
View File
@@ -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());
}
}
+156
View File
@@ -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
View File
@@ -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 Pairing { id: string; name: string; caller: string; words: string[]; created_at: number }
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 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] {
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 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() {
screen++;
const state = await invoke<AppState>("state");
app.replaceChildren();
if (!state.has_account) renderCreate();
if (!state.has_account) await renderOnboarding();
else if (state.locked) renderUnlock(state);
else await renderVault(state);
await renderPrompts(state);
}
// ------------------------------------------------------------------ account
// ------------------------------------------------------------------ onboarding (docs/10)
function renderCreate() {
const pw = el("input", { type: "password", placeholder: "Master password (10+ chars)" });
type Tab = "phone" | "kit" | "paste" | "create";
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 btn = el("button", { textContent: "Create vault", className: "primary" });
const card = el("div", { className: "card" }, el("h2", { textContent: "Create your Kestrove vault" }), pw, btn, err);
btn.onclick = async () => {
btn.disabled = true;
const start = el("button", { textContent: "Show QR code", className: "primary" });
body.append(
el("p", { className: "muted", textContent: "Your phone sends this computer everything it needs. You'll only type your master password." }),
el("label", { className: "grid" }, "Your Kestrove server", url),
start, err,
);
start.onclick = async () => {
start.disabled = true;
err.textContent = "";
try {
const secretKey = await invoke<string>("create_account", { masterPassword: pw.value });
pw.value = "";
card.replaceChildren(
el("h2", { textContent: "Your Secret Key" }),
el("p", { className: "mono", textContent: secretKey }),
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 }),
);
const join = await invoke<JoinView>("join_start", { serverUrl: url.value });
showJoin(body, join);
} catch (e) {
err.textContent = errText(e);
start.disabled = false;
}
};
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) {
err.textContent = errText(e);
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));
pw.focus();
body.append(
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) {
const pw = el("input", { type: "password", placeholder: "Master password" });
const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" });
@@ -84,26 +349,86 @@ function renderUnlock(state: AppState) {
// ------------------------------------------------------------------ vault
let refreshList: (() => Promise<void>) | null = null;
let syncBox: HTMLElement | null = null;
async function renderVault(state: AppState) {
const items = await invoke<ItemSummary[]>("list_items");
const detail = el("section");
const list = el("ul");
for (const it of items) {
const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" }));
li.onclick = async () => {
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." }));
const fill = async () => {
const items = await invoke<ItemSummary[]>("list_items");
const selected = list.querySelector("li.sel")?.getAttribute("data-id");
list.replaceChildren();
for (const it of items) {
const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" }));
li.dataset.id = it.id;
if (it.id === selected) li.classList.add("sel");
li.onclick = async () => {
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 settings = el("button", { textContent: "Settings", onclick: () => renderSettings(detail, state) });
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);
if (state.ipc_error) aside.append(el("p", { className: "err pad", textContent: state.ipc_error }));
syncBox = el("div", { className: "sync" });
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);
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) {
@@ -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));
}
// ------------------------------------------------------------------ settings
async function renderSettings(target: HTMLElement, state: AppState) {
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 })));
@@ -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”." }));
const syncSection = el("div");
const devicesSection = el("div");
target.replaceChildren(
el("h2", { textContent: "Settings" }),
el("label", { className: "row" }, "Lock after idle for ", idle),
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" }),
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)
@@ -260,7 +808,27 @@ function saveCard(s: SaveView): HTMLElement {
void listen("pairing-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).
let lastTouch = 0;
+31 -2
View File
@@ -1,7 +1,7 @@
:root { color-scheme: light dark; font: 14px system-ui, sans-serif; }
body { margin: 0; }
main { display: grid; grid-template-columns: 300px 1fr; height: 100vh; }
.center { display: grid; place-items: center; height: 100vh; grid-column: 1 / -1; }
main { display: grid; grid-template-columns: 300px 1fr; height: 100vh; overflow: auto; }
.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; }
input, button { font: inherit; padding: 8px; border-radius: 6px; border: 1px solid #8886; }
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; }
.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; }
#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; }