Sync with the server and scan-to-join onboarding
- Device key (DeviceSigner): Windows TPM via CNG with a DPAPI-sealed software fallback, Linux Secret Service with a 0600 file fallback (warned in Settings), macOS keychain for dev runs. The store used is recorded; no silent switching. - SyncService (Tauri-free): server URL and pins, create account with the server's setup code, sync after unlock/save/browser save, every 15 min while unlocked and on demand, status events, integrity alarm, device list with fingerprint approval and revoke. - Onboarding: "Use my phone" (join_start/poll, QR rendered locally, 6-digit code), Emergency Kit from PDF/PNG/JPEG (hayro + rqrr), paste setup code, new account. Then only the master password. - From a signed-in desktop: "Add a phone or tablet" QR with countdown (cancel, lock and quit revoke the ticket) and "Approve a computer" via pasted join code. - KESTROVE_DATA_DIR / KESTROVE_DEVICE_NAME for a second instance; ignored live test drives three instances against a real server. - deny: ignore three unmaintained notices for zbus 5 sub-crates (Linux keyring). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Generated
+1115
-48
File diff suppressed because it is too large
Load Diff
@@ -40,12 +40,25 @@ base64 = "0.22"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,545 @@
|
||||
//! This device's P-256 request-signing key (docs/03 "Device keys"), behind kv-ffi's `DeviceSigner`.
|
||||
//!
|
||||
//! Where the key lives, best first:
|
||||
//! * Windows: a non-exportable key in the TPM (CNG "Microsoft Platform Crypto Provider"); without a TPM, a
|
||||
//! software key sealed with DPAPI (current user) in the app-data folder.
|
||||
//! * Linux: a software key in the Secret Service (GNOME Keyring, KWallet); without one, a 0600 file (with a warning).
|
||||
//! * macOS (development only; the shipped Mac app is kestrove-apple): the login keychain.
|
||||
//!
|
||||
//! The store that created the key is recorded next to the device id. A later launch uses only that store: if it
|
||||
//! is unavailable, signing fails instead of silently enrolling a new device.
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use kv_ffi::CoreError;
|
||||
use p256::ecdsa::{signature::Signer as _, Signature, SigningKey};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::private_file;
|
||||
|
||||
const ID_FILE: &str = "device-id";
|
||||
const STORE_FILE: &str = "device-key-store";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyStorage {
|
||||
/// Windows TPM via CNG; the private key never leaves the chip.
|
||||
Tpm,
|
||||
/// Windows software key sealed with DPAPI (current user).
|
||||
Dpapi,
|
||||
/// macOS keychain (development builds).
|
||||
Keychain,
|
||||
/// Linux Secret Service.
|
||||
SecretService,
|
||||
/// Owner-only file in the app-data folder (Linux without a Secret Service, tests).
|
||||
File,
|
||||
}
|
||||
|
||||
impl KeyStorage {
|
||||
fn tag(self) -> &'static str {
|
||||
match self {
|
||||
KeyStorage::Tpm => "tpm",
|
||||
KeyStorage::Dpapi => "dpapi",
|
||||
KeyStorage::Keychain => "keychain",
|
||||
KeyStorage::SecretService => "secret-service",
|
||||
KeyStorage::File => "file",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_tag(s: &str) -> Option<Self> {
|
||||
[KeyStorage::Tpm, KeyStorage::Dpapi, KeyStorage::Keychain, KeyStorage::SecretService, KeyStorage::File]
|
||||
.into_iter()
|
||||
.find(|k| k.tag() == s.trim())
|
||||
}
|
||||
|
||||
/// For the settings screen.
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
KeyStorage::Tpm => "TPM (hardware)",
|
||||
KeyStorage::Dpapi => "Windows DPAPI (software key)",
|
||||
KeyStorage::Keychain => "macOS keychain (software key)",
|
||||
KeyStorage::SecretService => "Secret Service keyring (software key)",
|
||||
KeyStorage::File => "file in the app folder (software key)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Backend {
|
||||
Software(SigningKey),
|
||||
#[cfg(windows)]
|
||||
Cng(cng::Key),
|
||||
}
|
||||
|
||||
pub struct DeviceKey {
|
||||
id: String,
|
||||
public: Vec<u8>,
|
||||
backend: Backend,
|
||||
storage: KeyStorage,
|
||||
}
|
||||
|
||||
impl fmt::Debug for DeviceKey {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("DeviceKey").field("id", &self.id).field("storage", &self.storage).finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl DeviceKey {
|
||||
/// Loads this device's key, creating it (and a new device id) on first use in the best store available.
|
||||
pub fn load_or_create(data_dir: &Path) -> io::Result<Self> {
|
||||
Self::load_or_create_with(data_dir, &platform_stores())
|
||||
}
|
||||
|
||||
/// Same, but only in an owner-only file. For tests and for `KESTROVE_DEVICE_KEY=file` dev runs.
|
||||
pub fn load_or_create_file(data_dir: &Path) -> io::Result<Self> {
|
||||
Self::load_or_create_with(data_dir, &[KeyStorage::File])
|
||||
}
|
||||
|
||||
fn load_or_create_with(data_dir: &Path, preferred: &[KeyStorage]) -> io::Result<Self> {
|
||||
let id_path = data_dir.join(ID_FILE);
|
||||
let store_path = data_dir.join(STORE_FILE);
|
||||
let id = read_text(&id_path)?;
|
||||
let recorded = read_text(&store_path)?.and_then(|s| KeyStorage::from_tag(&s));
|
||||
if let (Some(id), Some(storage)) = (id, recorded) {
|
||||
if let Some(key) = load(data_dir, &id, storage)? {
|
||||
return Ok(key);
|
||||
}
|
||||
// The id is known but its key is gone (keyring wiped, folder copied from another machine):
|
||||
// this is a new device as far as the server is concerned.
|
||||
eprintln!("kestrove: the device key in {} is missing; creating a new device key", storage.describe());
|
||||
}
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let mut last_err = io::Error::other("no key store available");
|
||||
for &storage in preferred {
|
||||
match create(data_dir, &id, storage) {
|
||||
Ok(key) => {
|
||||
private_file::write(&id_path, id.as_bytes())?;
|
||||
private_file::write(&store_path, storage.tag().as_bytes())?;
|
||||
return Ok(key);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("kestrove: device key store {} unavailable: {e}", storage.describe());
|
||||
last_err = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(last_err)
|
||||
}
|
||||
|
||||
pub fn storage(&self) -> KeyStorage {
|
||||
self.storage
|
||||
}
|
||||
|
||||
/// Shown in settings when the key is less protected than it should be.
|
||||
pub fn warning(&self) -> Option<&'static str> {
|
||||
(self.storage == KeyStorage::File).then_some(
|
||||
"No system keyring was found, so this device's sync key is stored in a file only your user can read.",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
fn sign_raw(&self, message: &[u8]) -> io::Result<Vec<u8>> {
|
||||
match &self.backend {
|
||||
Backend::Software(k) => {
|
||||
let sig: Signature = k.sign(message);
|
||||
Ok(sig.to_bytes().to_vec())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
Backend::Cng(k) => k.sign(message),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl kv_ffi::DeviceSigner for DeviceKey {
|
||||
fn device_id(&self) -> String {
|
||||
self.id.clone()
|
||||
}
|
||||
|
||||
fn public_key(&self) -> Vec<u8> {
|
||||
self.public.clone()
|
||||
}
|
||||
|
||||
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, CoreError> {
|
||||
self.sign_raw(&message).map_err(|e| CoreError::Internal { msg: format!("device key: {e}") })
|
||||
}
|
||||
}
|
||||
|
||||
fn read_text(path: &Path) -> io::Result<Option<String>> {
|
||||
Ok(private_file::read(path)?.and_then(|b| String::from_utf8(b).ok()).map(|s| s.trim().to_string()))
|
||||
}
|
||||
|
||||
fn platform_stores() -> Vec<KeyStorage> {
|
||||
if std::env::var_os("KESTROVE_DEVICE_KEY").is_some_and(|v| v == "file") {
|
||||
return vec![KeyStorage::File];
|
||||
}
|
||||
if cfg!(windows) {
|
||||
vec![KeyStorage::Tpm, KeyStorage::Dpapi]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
vec![KeyStorage::Keychain, KeyStorage::File]
|
||||
} else {
|
||||
vec![KeyStorage::SecretService, KeyStorage::File]
|
||||
}
|
||||
}
|
||||
|
||||
fn software(id: &str, key: SigningKey, storage: KeyStorage) -> DeviceKey {
|
||||
let public = key.verifying_key().to_encoded_point(false).as_bytes().to_vec();
|
||||
DeviceKey { id: id.to_string(), public, backend: Backend::Software(key), storage }
|
||||
}
|
||||
|
||||
fn parse_scalar(bytes: &[u8]) -> io::Result<SigningKey> {
|
||||
SigningKey::from_slice(bytes).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "corrupt device key"))
|
||||
}
|
||||
|
||||
fn new_scalar() -> Zeroizing<Vec<u8>> {
|
||||
// `SigningKey::random` needs a rand_core RNG; draw a scalar from the OS and retry the (2^-128) invalid case.
|
||||
loop {
|
||||
let mut b = Zeroizing::new(vec![0u8; 32]);
|
||||
getrandom::getrandom(&mut b).expect("OS random number generator");
|
||||
if SigningKey::from_slice(&b).is_ok() {
|
||||
return b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn key_file(data_dir: &Path) -> PathBuf {
|
||||
data_dir.join("device-key")
|
||||
}
|
||||
|
||||
fn load(data_dir: &Path, id: &str, storage: KeyStorage) -> io::Result<Option<DeviceKey>> {
|
||||
let secret = match storage {
|
||||
KeyStorage::File => private_file::read(&key_file(data_dir))?.map(Zeroizing::new),
|
||||
#[cfg(not(windows))]
|
||||
KeyStorage::Keychain | KeyStorage::SecretService => keyring_slot::get(id)?,
|
||||
#[cfg(windows)]
|
||||
KeyStorage::Dpapi => match private_file::read(&key_file(data_dir).with_extension("dpapi"))? {
|
||||
Some(sealed) => Some(dpapi::unprotect(&sealed)?),
|
||||
None => None,
|
||||
},
|
||||
#[cfg(windows)]
|
||||
KeyStorage::Tpm => {
|
||||
return Ok(cng::Key::open(id)?.map(|k| DeviceKey {
|
||||
id: id.to_string(),
|
||||
public: k.public_key().unwrap_or_default(),
|
||||
backend: Backend::Cng(k),
|
||||
storage,
|
||||
}))
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => return Err(io::Error::other(format!("{} is not available on this platform", storage.describe()))),
|
||||
};
|
||||
secret.map(|s| Ok(software(id, parse_scalar(&s)?, storage))).transpose()
|
||||
}
|
||||
|
||||
fn create(data_dir: &Path, id: &str, storage: KeyStorage) -> io::Result<DeviceKey> {
|
||||
#[cfg(windows)]
|
||||
if storage == KeyStorage::Tpm {
|
||||
let k = cng::Key::create(id)?;
|
||||
let public = k.public_key()?;
|
||||
return Ok(DeviceKey { id: id.to_string(), public, backend: Backend::Cng(k), storage });
|
||||
}
|
||||
let secret = new_scalar();
|
||||
match storage {
|
||||
KeyStorage::File => private_file::write(&key_file(data_dir), &secret)?,
|
||||
#[cfg(not(windows))]
|
||||
KeyStorage::Keychain | KeyStorage::SecretService => keyring_slot::put(id, &secret)?,
|
||||
#[cfg(windows)]
|
||||
KeyStorage::Dpapi => {
|
||||
private_file::write(&key_file(data_dir).with_extension("dpapi"), &dpapi::protect(&secret)?)?
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => return Err(io::Error::other(format!("{} is not available on this platform", storage.describe()))),
|
||||
}
|
||||
Ok(software(id, parse_scalar(&secret)?, storage))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
mod keyring_slot {
|
||||
use std::io;
|
||||
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const SERVICE: &str = "app.kestrove.desktop";
|
||||
|
||||
fn entry(id: &str) -> io::Result<keyring::Entry> {
|
||||
keyring::Entry::new(SERVICE, &format!("device-key-{id}")).map_err(io::Error::other)
|
||||
}
|
||||
|
||||
pub fn get(id: &str) -> io::Result<Option<Zeroizing<Vec<u8>>>> {
|
||||
match entry(id)?.get_secret() {
|
||||
Ok(s) => Ok(Some(Zeroizing::new(s))),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(io::Error::other(e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn put(id: &str, secret: &[u8]) -> io::Result<()> {
|
||||
let e = entry(id)?;
|
||||
e.set_secret(secret).map_err(io::Error::other)?;
|
||||
// Read back: some Secret Service setups accept a write into a locked collection and lose it.
|
||||
match e.get_secret() {
|
||||
Ok(s) if s == secret => Ok(()),
|
||||
_ => Err(io::Error::other("the keyring did not keep the device key")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod dpapi {
|
||||
use std::io;
|
||||
use std::ptr;
|
||||
|
||||
use windows_sys::Win32::Foundation::LocalFree;
|
||||
use windows_sys::Win32::Security::Cryptography::{
|
||||
CryptProtectData, CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB,
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const ENTROPY: &[u8] = b"kestrove/v1/desktop/device-key";
|
||||
|
||||
fn blob(b: &[u8]) -> CRYPT_INTEGER_BLOB {
|
||||
CRYPT_INTEGER_BLOB { cbData: b.len() as u32, pbData: b.as_ptr() as *mut u8 }
|
||||
}
|
||||
|
||||
/// Copies DPAPI's output and frees it.
|
||||
unsafe fn take(out: CRYPT_INTEGER_BLOB) -> Zeroizing<Vec<u8>> {
|
||||
let v = Zeroizing::new(std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec());
|
||||
ptr::write_bytes(out.pbData, 0, out.cbData as usize);
|
||||
LocalFree(out.pbData as _);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn protect(secret: &[u8]) -> io::Result<Vec<u8>> {
|
||||
let (input, entropy) = (blob(secret), blob(ENTROPY));
|
||||
let mut out = CRYPT_INTEGER_BLOB { cbData: 0, pbData: ptr::null_mut() };
|
||||
// SAFETY: the input blobs point at live slices; on success DPAPI allocates `out`, which `take` frees.
|
||||
let ok = unsafe {
|
||||
CryptProtectData(
|
||||
&input,
|
||||
ptr::null(),
|
||||
&entropy,
|
||||
ptr::null(),
|
||||
ptr::null(),
|
||||
CRYPTPROTECT_UI_FORBIDDEN,
|
||||
&mut out,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(unsafe { take(out) }.to_vec())
|
||||
}
|
||||
|
||||
pub fn unprotect(sealed: &[u8]) -> io::Result<Zeroizing<Vec<u8>>> {
|
||||
let (input, entropy) = (blob(sealed), blob(ENTROPY));
|
||||
let mut out = CRYPT_INTEGER_BLOB { cbData: 0, pbData: ptr::null_mut() };
|
||||
// SAFETY: as in `protect`.
|
||||
let ok = unsafe {
|
||||
CryptUnprotectData(
|
||||
&input,
|
||||
ptr::null_mut(),
|
||||
&entropy,
|
||||
ptr::null(),
|
||||
ptr::null(),
|
||||
CRYPTPROTECT_UI_FORBIDDEN,
|
||||
&mut out,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(unsafe { take(out) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod cng {
|
||||
use std::io;
|
||||
use std::ptr;
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use windows_sys::Win32::Security::Cryptography::{
|
||||
NCryptCreatePersistedKey, NCryptExportKey, NCryptFinalizeKey, NCryptFreeObject, NCryptOpenKey,
|
||||
NCryptOpenStorageProvider, NCryptSignHash, BCRYPT_ECCKEY_BLOB, BCRYPT_ECCPUBLIC_BLOB,
|
||||
BCRYPT_ECDSA_P256_ALGORITHM, BCRYPT_ECDSA_PUBLIC_P256_MAGIC, MS_PLATFORM_CRYPTO_PROVIDER, NCRYPT_KEY_HANDLE,
|
||||
NCRYPT_PROV_HANDLE, NCRYPT_SILENT_FLAG,
|
||||
};
|
||||
|
||||
const NTE_BAD_KEYSET: i32 = 0x8009_0016_u32 as i32;
|
||||
|
||||
pub struct Key(NCRYPT_KEY_HANDLE);
|
||||
|
||||
// SAFETY: NCrypt key handles may be used from any thread.
|
||||
unsafe impl Send for Key {}
|
||||
unsafe impl Sync for Key {}
|
||||
|
||||
impl Drop for Key {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: the handle came from NCryptOpenKey/NCryptCreatePersistedKey and is freed once.
|
||||
unsafe { NCryptFreeObject(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
fn check(hr: i32, what: &str) -> io::Result<()> {
|
||||
if hr == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::other(format!("{what} failed: 0x{:08x}", hr as u32)))
|
||||
}
|
||||
}
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
fn key_name(id: &str) -> Vec<u16> {
|
||||
wide(&format!("Kestrove device {id}"))
|
||||
}
|
||||
|
||||
struct Provider(NCRYPT_PROV_HANDLE);
|
||||
|
||||
impl Drop for Provider {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: opened by NCryptOpenStorageProvider below.
|
||||
unsafe { NCryptFreeObject(self.0) };
|
||||
}
|
||||
}
|
||||
|
||||
fn provider() -> io::Result<Provider> {
|
||||
let mut h: NCRYPT_PROV_HANDLE = 0;
|
||||
// SAFETY: out-pointer to a local; the provider name is a static wide string.
|
||||
check(unsafe { NCryptOpenStorageProvider(&mut h, MS_PLATFORM_CRYPTO_PROVIDER, 0) }, "opening the TPM")?;
|
||||
Ok(Provider(h))
|
||||
}
|
||||
|
||||
impl Key {
|
||||
pub fn open(id: &str) -> io::Result<Option<Self>> {
|
||||
let p = provider()?;
|
||||
let name = key_name(id);
|
||||
let mut h: NCRYPT_KEY_HANDLE = 0;
|
||||
// SAFETY: valid provider handle, NUL-terminated name, out-pointer to a local.
|
||||
let hr = unsafe { NCryptOpenKey(p.0, &mut h, name.as_ptr(), 0, NCRYPT_SILENT_FLAG) };
|
||||
if hr == NTE_BAD_KEYSET {
|
||||
return Ok(None);
|
||||
}
|
||||
check(hr, "opening the TPM key")?;
|
||||
Ok(Some(Key(h)))
|
||||
}
|
||||
|
||||
pub fn create(id: &str) -> io::Result<Self> {
|
||||
let p = provider()?;
|
||||
let name = key_name(id);
|
||||
let mut h: NCRYPT_KEY_HANDLE = 0;
|
||||
// SAFETY: as in `open`; the key is non-exportable by default.
|
||||
check(
|
||||
unsafe { NCryptCreatePersistedKey(p.0, &mut h, BCRYPT_ECDSA_P256_ALGORITHM, name.as_ptr(), 0, 0) },
|
||||
"creating the TPM key",
|
||||
)?;
|
||||
let key = Key(h);
|
||||
// SAFETY: a handle from NCryptCreatePersistedKey.
|
||||
check(unsafe { NCryptFinalizeKey(key.0, NCRYPT_SILENT_FLAG) }, "finalizing the TPM key")?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// SEC1 uncompressed public key (0x04 || X || Y).
|
||||
pub fn public_key(&self) -> io::Result<Vec<u8>> {
|
||||
let mut buf = [0u8; 8 + 64];
|
||||
let mut len = 0u32;
|
||||
// SAFETY: the output buffer is large enough for a P-256 BCRYPT_ECCPUBLIC_BLOB (8-byte header + X + Y).
|
||||
check(
|
||||
unsafe {
|
||||
NCryptExportKey(
|
||||
self.0,
|
||||
0,
|
||||
BCRYPT_ECCPUBLIC_BLOB,
|
||||
ptr::null(),
|
||||
buf.as_mut_ptr(),
|
||||
buf.len() as u32,
|
||||
&mut len,
|
||||
0,
|
||||
)
|
||||
},
|
||||
"exporting the TPM public key",
|
||||
)?;
|
||||
let header = std::mem::size_of::<BCRYPT_ECCKEY_BLOB>();
|
||||
let magic = u32::from_le_bytes(buf[0..4].try_into().expect("4 bytes"));
|
||||
let cb = u32::from_le_bytes(buf[4..8].try_into().expect("4 bytes")) as usize;
|
||||
if magic != BCRYPT_ECDSA_PUBLIC_P256_MAGIC || cb != 32 || len as usize != header + 64 {
|
||||
return Err(io::Error::other("unexpected TPM public key format"));
|
||||
}
|
||||
let mut out = vec![0x04];
|
||||
out.extend_from_slice(&buf[header..header + 64]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// ECDSA P-256 over SHA-256(message); CNG returns the raw r || s form the core expects.
|
||||
pub fn sign(&self, message: &[u8]) -> io::Result<Vec<u8>> {
|
||||
let digest = Sha256::digest(message);
|
||||
let mut sig = vec![0u8; 64];
|
||||
let mut len = 0u32;
|
||||
// SAFETY: digest and signature buffers are live and sized as passed.
|
||||
check(
|
||||
unsafe {
|
||||
NCryptSignHash(
|
||||
self.0,
|
||||
ptr::null(),
|
||||
digest.as_ptr(),
|
||||
digest.len() as u32,
|
||||
sig.as_mut_ptr(),
|
||||
sig.len() as u32,
|
||||
&mut len,
|
||||
NCRYPT_SILENT_FLAG,
|
||||
)
|
||||
},
|
||||
"signing with the TPM key",
|
||||
)?;
|
||||
sig.truncate(len as usize);
|
||||
Ok(sig)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use kv_ffi::DeviceSigner;
|
||||
use p256::ecdsa::{signature::Verifier, VerifyingKey};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_key_is_stable_and_signs_raw_p256() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = DeviceKey::load_or_create_file(dir.path()).unwrap();
|
||||
let b = DeviceKey::load_or_create_file(dir.path()).unwrap();
|
||||
assert_eq!(a.device_id(), b.device_id());
|
||||
assert_eq!(a.public_key(), b.public_key());
|
||||
assert_eq!(a.public_key().len(), 65);
|
||||
assert!(uuid::Uuid::parse_str(&a.device_id()).is_ok());
|
||||
|
||||
let sig = a.sign(b"hello".to_vec()).unwrap();
|
||||
assert_eq!(sig.len(), 64);
|
||||
let vk = VerifyingKey::from_sec1_bytes(&a.public_key()).unwrap();
|
||||
vk.verify(b"hello", &Signature::from_slice(&sig).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_key_means_new_device_id() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let a = DeviceKey::load_or_create_file(dir.path()).unwrap();
|
||||
std::fs::remove_file(key_file(dir.path())).unwrap();
|
||||
let b = DeviceKey::load_or_create_file(dir.path()).unwrap();
|
||||
assert_ne!(a.device_id(), b.device_id());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn key_file_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
DeviceKey::load_or_create_file(dir.path()).unwrap();
|
||||
let mode = std::fs::metadata(key_file(dir.path())).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,10 @@
|
||||
pub mod autolock;
|
||||
pub mod 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
@@ -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");
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! QR codes for onboarding (docs/10): rendered locally (no network), and read back from an Emergency Kit
|
||||
//! image (PNG/JPEG) or PDF.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use qrcode::{EcLevel, QrCode};
|
||||
|
||||
/// Largest file we try to decode (an Emergency Kit PDF or a phone photo).
|
||||
pub const MAX_INPUT: usize = 25 * 1024 * 1024;
|
||||
/// Rendering a page above this many pixels per side is pointless for a QR and slow.
|
||||
const MAX_SIDE: u32 = 4000;
|
||||
|
||||
/// An SVG `data:` URI for `text` (level M, as docs/10 sizes the codes). Dark modules on white with a quiet zone,
|
||||
/// so it scans in dark mode too.
|
||||
pub fn svg_data_uri(text: &str) -> Result<String, String> {
|
||||
let code = QrCode::with_error_correction_level(text.as_bytes(), EcLevel::M).map_err(|e| e.to_string())?;
|
||||
let svg = code
|
||||
.render::<qrcode::render::svg::Color>()
|
||||
.quiet_zone(true)
|
||||
.min_dimensions(240, 240)
|
||||
.dark_color(qrcode::render::svg::Color("#000000"))
|
||||
.light_color(qrcode::render::svg::Color("#ffffff"))
|
||||
.build();
|
||||
let mut uri = String::from("data:image/svg+xml;charset=utf-8,");
|
||||
for b in svg.bytes() {
|
||||
// Percent-encode everything outside a conservative safe set.
|
||||
if b.is_ascii_alphanumeric() || b" -_.!~*'()/:=,;".contains(&b) {
|
||||
uri.push(b as char);
|
||||
} else {
|
||||
let _ = write!(uri, "%{b:02X}");
|
||||
}
|
||||
}
|
||||
Ok(uri)
|
||||
}
|
||||
|
||||
/// Every QR text found in an image or PDF, in reading order.
|
||||
pub fn decode_file(bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
if bytes.len() > MAX_INPUT {
|
||||
return Err("The file is too large.".into());
|
||||
}
|
||||
if bytes.starts_with(b"%PDF") {
|
||||
decode_pdf(bytes)
|
||||
} else {
|
||||
let img = image::load_from_memory(bytes).map_err(|_| "This is not a PNG, JPEG or PDF file.".to_string())?;
|
||||
Ok(decode_luma(&img.to_luma8()))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_luma(img: &image::GrayImage) -> Vec<String> {
|
||||
let (w, h) = img.dimensions();
|
||||
let mut prepared = rqrr::PreparedImage::prepare_from_greyscale(w as usize, h as usize, |x, y| {
|
||||
img.get_pixel(x as u32, y as u32).0[0]
|
||||
});
|
||||
prepared.detect_grids().into_iter().filter_map(|g| g.decode().ok().map(|(_, text)| text)).collect()
|
||||
}
|
||||
|
||||
fn decode_pdf(bytes: &[u8]) -> Result<Vec<String>, String> {
|
||||
use hayro::hayro_interpret::InterpreterSettings;
|
||||
use hayro::hayro_syntax::Pdf;
|
||||
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||
|
||||
let pdf = Pdf::new(bytes.to_vec()).map_err(|_| "This PDF could not be read (is it password-protected?).")?;
|
||||
let cache = hayro::RenderCache::new();
|
||||
let settings = InterpreterSettings::default();
|
||||
let mut found = Vec::new();
|
||||
for page in pdf.pages().iter().take(10) {
|
||||
let (w, h) = page.render_dimensions();
|
||||
// ~216 dpi: a 35 mm code becomes ~300 px, plenty for the detector.
|
||||
let scale = (3.0_f32).min(MAX_SIDE as f32 / w.max(h).max(1.0));
|
||||
let pixmap = hayro::render(
|
||||
page,
|
||||
&cache,
|
||||
&settings,
|
||||
&hayro::RenderSettings::default(),
|
||||
&hayro::PixmapSettings { x_scale: scale, y_scale: scale, bg_color: WHITE },
|
||||
);
|
||||
let (pw, ph) = (u32::from(pixmap.width()), u32::from(pixmap.height()));
|
||||
let rgba = pixmap.data_as_u8_slice();
|
||||
let gray = image::GrayImage::from_fn(pw, ph, |x, y| {
|
||||
let i = ((y * pw + x) * 4) as usize;
|
||||
// Premultiplied over white: the background is opaque white, so treat the channels as plain RGB.
|
||||
let (r, g, b) = (u32::from(rgba[i]), u32::from(rgba[i + 1]), u32::from(rgba[i + 2]));
|
||||
image::Luma([((r * 299 + g * 587 + b * 114) / 1000) as u8])
|
||||
});
|
||||
found.extend(decode_luma(&gray));
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const PAYLOAD: &str = "kestrove://setup/v1?s=https%3A%2F%2Fkestrove.samialabri.com&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&k=K12TB9D5MPJTB9D5MPJTB9D5MPJTW";
|
||||
|
||||
fn png_of(text: &str) -> Vec<u8> {
|
||||
let code = QrCode::with_error_correction_level(text.as_bytes(), EcLevel::M).unwrap();
|
||||
let w = code.width();
|
||||
let scale = 6u32;
|
||||
let side = (w as u32 + 8) * scale;
|
||||
let colors = code.to_colors();
|
||||
let img = image::GrayImage::from_fn(side, side, |x, y| {
|
||||
let (mx, my) = ((x / scale) as i64 - 4, (y / scale) as i64 - 4);
|
||||
let dark = mx >= 0
|
||||
&& my >= 0
|
||||
&& (mx as usize) < w
|
||||
&& (my as usize) < w
|
||||
&& colors[my as usize * w + mx as usize] == qrcode::Color::Dark;
|
||||
image::Luma([if dark { 0 } else { 255 }])
|
||||
});
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_round_trip() {
|
||||
assert_eq!(decode_file(&png_of(PAYLOAD)).unwrap(), vec![PAYLOAD.to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_is_a_data_uri() {
|
||||
let uri = svg_data_uri(PAYLOAD).unwrap();
|
||||
assert!(uri.starts_with("data:image/svg+xml;charset=utf-8,%3C"));
|
||||
assert!(!uri.contains('<') && !uri.contains('#') && !uri.contains('"'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_other_files() {
|
||||
assert!(decode_file(b"hello").is_err());
|
||||
assert!(decode_file(b"%PDF-1.4 garbage").is_err());
|
||||
}
|
||||
|
||||
/// A minimal one-page PDF that draws the QR modules as filled rectangles (vector, like a generated Kit).
|
||||
#[test]
|
||||
fn pdf_with_vector_qr() {
|
||||
let code = QrCode::with_error_correction_level(PAYLOAD.as_bytes(), EcLevel::M).unwrap();
|
||||
let w = code.width();
|
||||
let colors = code.to_colors();
|
||||
let m = 4.0; // points per module
|
||||
let mut content = String::from("0 g\n");
|
||||
for y in 0..w {
|
||||
for x in 0..w {
|
||||
if colors[y * w + x] == qrcode::Color::Dark {
|
||||
let px = 100.0 + x as f32 * m;
|
||||
let py = 600.0 - y as f32 * m;
|
||||
content.push_str(&format!("{px} {py} {m} {m} re f\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
let objs = [
|
||||
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << >> >>".to_string(),
|
||||
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
|
||||
];
|
||||
let mut pdf = String::from("%PDF-1.4\n");
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.push_str(&format!("{} 0 obj\n{o}\nendobj\n", i + 1));
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.push_str(&format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1));
|
||||
for off in offsets {
|
||||
pdf.push_str(&format!("{off:010} 00000 n \n"));
|
||||
}
|
||||
pdf.push_str(&format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1));
|
||||
assert_eq!(decode_file(pdf.as_bytes()).unwrap(), vec![PAYLOAD.to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
//! Sync server connection and onboarding (docs/03, docs/10) on top of kv-ffi, without Tauri types so the
|
||||
//! integration tests can drive it. The UI layer (main.rs) wraps each method in a command.
|
||||
//!
|
||||
//! Secrets stay here: setup payloads (Secret Key, ticket) never go to the web view except as the QR image the
|
||||
//! user asked to see.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use kv_ffi::{CoreError, DeviceDto, LoginResult, PlatformDto, SetupInfo, VaultCore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::device_key::DeviceKey;
|
||||
use crate::{private_file, qr};
|
||||
|
||||
/// Background sync while unlocked (docs/03 "Triggers").
|
||||
pub const SYNC_PERIOD: Duration = Duration::from_secs(15 * 60);
|
||||
/// Saves in quick succession (or an unlock right after a save) become one sync.
|
||||
const COALESCE: Duration = Duration::from_millis(800);
|
||||
|
||||
/// What the UI shows about sync. Sent with every change (event `sync-status`).
|
||||
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
|
||||
pub struct SyncStatus {
|
||||
pub server_url: Option<String>,
|
||||
pub pins: Vec<String>,
|
||||
pub logged_in: bool,
|
||||
pub syncing: bool,
|
||||
/// Unix seconds of the last successful sync.
|
||||
pub last_sync_at: Option<u64>,
|
||||
pub last_error: Option<String>,
|
||||
/// This device waits for approval on another device.
|
||||
pub pending_approval: bool,
|
||||
/// Other devices waiting for this user's approval.
|
||||
pub pending_devices: u32,
|
||||
/// The server failed verification; syncing that vault has stopped (docs/03 "Integrity").
|
||||
pub integrity_alarm: Option<String>,
|
||||
pub device_fingerprint: Option<String>,
|
||||
pub key_storage: Option<String>,
|
||||
pub key_warning: Option<String>,
|
||||
/// Items an edit here brought back after a delete elsewhere, from the last sync.
|
||||
pub restored_items: u32,
|
||||
/// Bumped whenever a sync changed local items, so the UI knows to refresh the list.
|
||||
pub changes: u64,
|
||||
}
|
||||
|
||||
/// What we persist besides the core's own records (`sync.json`, 0600).
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct Saved {
|
||||
server_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pins: Vec<String>,
|
||||
last_sync_at: Option<u64>,
|
||||
integrity_alarm: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DeviceView {
|
||||
pub device_id: String,
|
||||
pub name: String,
|
||||
pub platform: String,
|
||||
pub approved: bool,
|
||||
pub revoked: bool,
|
||||
pub current: bool,
|
||||
pub created_at: String,
|
||||
pub key_fingerprint: String,
|
||||
}
|
||||
|
||||
/// A setup payload waiting for the master password. The Secret Key stays in Rust.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SetupView {
|
||||
pub server_url: String,
|
||||
pub server_host: String,
|
||||
pub account_id: String,
|
||||
/// A ticket means no manual approval will be needed.
|
||||
pub preapproved: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JoinView {
|
||||
/// QR image (`data:` URI) to scan with a signed-in phone.
|
||||
pub qr_svg: String,
|
||||
/// The same as text, for "Paste a computer's join code" on another computer.
|
||||
pub qr_text: String,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JoinPollView {
|
||||
/// `"123 456"` once a phone scanned the QR.
|
||||
pub code: Option<String>,
|
||||
/// Set when the bundle arrived: ask for the master password and call `finish_setup`.
|
||||
pub setup: Option<SetupView>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct AddDeviceView {
|
||||
pub qr_svg: String,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct JoinConfirmView {
|
||||
pub code: String,
|
||||
pub device_name: String,
|
||||
pub platform: String,
|
||||
pub key_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SignIn {
|
||||
Ok,
|
||||
PendingApproval,
|
||||
}
|
||||
|
||||
type Notify = Box<dyn Fn(&SyncStatus) + Send + Sync>;
|
||||
|
||||
pub struct SyncService {
|
||||
core: Arc<VaultCore>,
|
||||
data_dir: PathBuf,
|
||||
key: Result<Arc<DeviceKey>, String>,
|
||||
status: Mutex<SyncStatus>,
|
||||
/// From "Use my phone", the Emergency Kit or a pasted code, until `finish_setup` succeeds.
|
||||
pending_setup: Mutex<Option<SetupInfo>>,
|
||||
/// The "Add a device" ticket on screen, revoked on cancel and on lock.
|
||||
add_device_ticket: Mutex<Option<String>>,
|
||||
wake: tokio::sync::Notify,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |d| d.as_secs())
|
||||
}
|
||||
|
||||
/// User-facing text for a core error.
|
||||
pub fn describe(e: &CoreError) -> String {
|
||||
match e {
|
||||
CoreError::Network { msg } => format!("Can't reach the server: {msg}"),
|
||||
CoreError::PinMismatch => {
|
||||
"The server's certificate doesn't match the pinned keys. Someone may be intercepting \
|
||||
the connection, so Kestrove won't talk to it."
|
||||
.into()
|
||||
}
|
||||
CoreError::Server { status: 429, .. } => "Too many attempts. Wait a minute and try again.".into(),
|
||||
CoreError::BadCredentials => "Wrong master password or Secret Key.".into(),
|
||||
CoreError::PendingApproval => "This computer is waiting for approval on one of your other devices.".into(),
|
||||
CoreError::NotConfigured => "No sync server is set up yet.".into(),
|
||||
CoreError::NotAuthenticated => "Sign in to the sync server again (lock and unlock with your password).".into(),
|
||||
CoreError::IntegrityAlarm { vault_id, reason } => format!(
|
||||
"The server sent vault data that failed verification (vault {vault_id}: {reason}). Syncing stopped; \
|
||||
your local data is unchanged."
|
||||
),
|
||||
CoreError::Invalid { msg } => msg.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn platform_name(p: PlatformDto) -> &'static str {
|
||||
match p {
|
||||
PlatformDto::Ios => "iOS",
|
||||
PlatformDto::Macos => "macOS",
|
||||
PlatformDto::Android => "Android",
|
||||
PlatformDto::Windows => "Windows",
|
||||
PlatformDto::Linux => "Linux",
|
||||
}
|
||||
}
|
||||
|
||||
fn this_platform() -> PlatformDto {
|
||||
if cfg!(windows) {
|
||||
PlatformDto::Windows
|
||||
} else if cfg!(target_os = "macos") {
|
||||
PlatformDto::Macos
|
||||
} else {
|
||||
PlatformDto::Linux
|
||||
}
|
||||
}
|
||||
|
||||
/// The computer's name for the device list ("Sami's PC"); `KESTROVE_DEVICE_NAME` overrides it.
|
||||
pub fn device_name() -> String {
|
||||
if let Ok(n) = std::env::var("KESTROVE_DEVICE_NAME") {
|
||||
return n;
|
||||
}
|
||||
#[cfg(windows)]
|
||||
let host = std::env::var("COMPUTERNAME").ok();
|
||||
#[cfg(unix)]
|
||||
let host = {
|
||||
let mut buf = [0u8; 256];
|
||||
// SAFETY: the buffer is valid for its length; gethostname NUL-terminates on success (we also cap it).
|
||||
let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len() - 1) };
|
||||
(rc == 0).then(|| {
|
||||
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
|
||||
String::from_utf8_lossy(&buf[..end]).trim_end_matches(".local").to_string()
|
||||
})
|
||||
};
|
||||
host.filter(|h| !h.is_empty()).unwrap_or_else(|| format!("{} computer", platform_name(this_platform())))
|
||||
}
|
||||
|
||||
fn host_of(url: &str) -> String {
|
||||
url::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string)).unwrap_or_else(|| url.to_string())
|
||||
}
|
||||
|
||||
fn setup_view(s: &SetupInfo) -> SetupView {
|
||||
SetupView {
|
||||
server_url: s.server_url.clone(),
|
||||
server_host: host_of(&s.server_url),
|
||||
account_id: s.account_id.clone(),
|
||||
preapproved: s.ticket.is_some(),
|
||||
}
|
||||
}
|
||||
|
||||
fn device_view(d: DeviceDto) -> DeviceView {
|
||||
DeviceView {
|
||||
device_id: d.device_id,
|
||||
name: d.name,
|
||||
platform: platform_name(d.platform).into(),
|
||||
approved: d.approved,
|
||||
revoked: d.revoked,
|
||||
current: d.current,
|
||||
created_at: d.created_at,
|
||||
key_fingerprint: d.key_fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits "a, b c" pin lists typed by the user.
|
||||
pub fn parse_pins(text: &str) -> Vec<String> {
|
||||
text.split(|c: char| c == ',' || c.is_whitespace()).filter(|s| !s.is_empty()).map(str::to_string).collect()
|
||||
}
|
||||
|
||||
impl SyncService {
|
||||
/// `key` is this device's signing key (or why it could not be loaded; sync is then unavailable).
|
||||
pub fn new(core: Arc<VaultCore>, data_dir: &Path, key: Result<DeviceKey, String>, notify: Notify) -> Arc<Self> {
|
||||
let saved: Saved = private_file::read(&data_dir.join("sync.json"))
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||
.unwrap_or_default();
|
||||
let key = key.map(Arc::new);
|
||||
let mut status = SyncStatus {
|
||||
server_url: saved.server_url,
|
||||
pins: saved.pins,
|
||||
last_sync_at: saved.last_sync_at,
|
||||
integrity_alarm: saved.integrity_alarm,
|
||||
..Default::default()
|
||||
};
|
||||
match &key {
|
||||
Ok(k) => {
|
||||
status.key_storage = Some(k.storage().describe().into());
|
||||
status.key_warning = k.warning().map(str::to_string);
|
||||
let signer: Arc<dyn kv_ffi::DeviceSigner> = k.clone();
|
||||
if let Err(e) = core.set_device_signer(signer, device_name(), this_platform()) {
|
||||
status.key_warning = Some(format!("Sync is unavailable: {}", describe(&e)));
|
||||
}
|
||||
status.device_fingerprint = core.device_key_fingerprint().ok();
|
||||
}
|
||||
Err(e) => {
|
||||
status.key_warning = Some(format!("Sync is unavailable: this device's key could not be loaded ({e})."))
|
||||
}
|
||||
}
|
||||
Arc::new(Self {
|
||||
core,
|
||||
data_dir: data_dir.to_path_buf(),
|
||||
key,
|
||||
status: Mutex::new(status),
|
||||
pending_setup: Mutex::new(None),
|
||||
add_device_ticket: Mutex::new(None),
|
||||
wake: tokio::sync::Notify::new(),
|
||||
notify,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn core(&self) -> &Arc<VaultCore> {
|
||||
&self.core
|
||||
}
|
||||
|
||||
pub fn status(&self) -> SyncStatus {
|
||||
let mut s = lock(&self.status).clone();
|
||||
s.logged_in = self.core.is_logged_in();
|
||||
s
|
||||
}
|
||||
|
||||
fn update(&self, f: impl FnOnce(&mut SyncStatus)) {
|
||||
let snapshot = {
|
||||
let mut s = lock(&self.status);
|
||||
f(&mut s);
|
||||
let saved = Saved {
|
||||
server_url: s.server_url.clone(),
|
||||
pins: s.pins.clone(),
|
||||
last_sync_at: s.last_sync_at,
|
||||
integrity_alarm: s.integrity_alarm.clone(),
|
||||
};
|
||||
if let Ok(bytes) = serde_json::to_vec(&saved) {
|
||||
if let Err(e) = private_file::write(&self.data_dir.join("sync.json"), &bytes) {
|
||||
eprintln!("kestrove: could not save sync settings: {e}");
|
||||
}
|
||||
}
|
||||
s.clone()
|
||||
};
|
||||
let mut snapshot = snapshot;
|
||||
snapshot.logged_in = self.core.is_logged_in();
|
||||
(self.notify)(&snapshot);
|
||||
}
|
||||
|
||||
fn require_key(&self) -> Result<(), String> {
|
||||
self.key.as_ref().map(|_| ()).map_err(|e| format!("Sync is unavailable: {e}"))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Secret Key on this device
|
||||
|
||||
fn secret_key_path(&self) -> PathBuf {
|
||||
self.data_dir.join("secret-key")
|
||||
}
|
||||
|
||||
pub fn remember_secret_key(&self, secret_key: &str) {
|
||||
if let Err(e) = private_file::write(&self.secret_key_path(), secret_key.trim().as_bytes()) {
|
||||
eprintln!("kestrove: could not store the Secret Key: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stored_secret_key(&self) -> Option<Zeroizing<String>> {
|
||||
let b = private_file::read(&self.secret_key_path()).ok()??;
|
||||
String::from_utf8(b).ok().map(Zeroizing::new)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ server settings and sign-in
|
||||
|
||||
/// Saves the server URL and optional SPKI pins (base64 SHA-256).
|
||||
pub fn configure(&self, url: &str, pins: Vec<String>) -> Result<(), String> {
|
||||
self.require_key()?;
|
||||
let url = url.trim().trim_end_matches('/').to_string();
|
||||
self.core.configure_server(url.clone(), pins.clone()).map_err(|e| describe(&e))?;
|
||||
self.update(|s| {
|
||||
s.server_url = Some(url);
|
||||
s.pins = pins;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// "Create a new account": creates the vault here and registers it with a fresh server using the setup code
|
||||
/// (`KV_SETUP_CODE`). Returns the Secret Key for the Emergency Kit.
|
||||
pub async fn create_account(
|
||||
&self,
|
||||
server_url: &str,
|
||||
pins: Vec<String>,
|
||||
setup_code: &str,
|
||||
master_password: Zeroizing<String>,
|
||||
) -> Result<Zeroizing<String>, String> {
|
||||
if self.core.has_account() {
|
||||
return Err("This computer already has a vault.".into());
|
||||
}
|
||||
if setup_code.trim().is_empty() {
|
||||
return Err("Enter the server's setup code.".into());
|
||||
}
|
||||
self.configure(server_url, pins)?;
|
||||
let info = self.core.server_info().await.map_err(|e| describe(&e))?;
|
||||
if !info.setup_open {
|
||||
return Err(
|
||||
"This server already has an account. Choose “Use my phone” or “Paste setup code” instead.".into()
|
||||
);
|
||||
}
|
||||
let sk = Zeroizing::new(self.core.create_account(master_password.to_string()).map_err(|e| describe(&e))?);
|
||||
self.remember_secret_key(&sk);
|
||||
if let Err(e) = self.core.register_account(setup_code.trim().to_string()).await {
|
||||
return Err(format!(
|
||||
"Your vault was created on this computer, but the server refused it: {}. Check the setup code in \
|
||||
Settings → Sync and try again.",
|
||||
describe(&e)
|
||||
));
|
||||
}
|
||||
let _ = self.sync_now().await;
|
||||
Ok(sk)
|
||||
}
|
||||
|
||||
/// For a vault created before sync was set up: registers it with a fresh server.
|
||||
pub async fn register(&self, setup_code: &str) -> Result<(), String> {
|
||||
self.core.register_account(setup_code.trim().to_string()).await.map_err(|e| describe(&e))?;
|
||||
self.sync_now().await.map(|_| ())
|
||||
}
|
||||
|
||||
/// Whether the configured server still accepts a setup code (no account yet).
|
||||
pub async fn setup_open(&self) -> Result<bool, String> {
|
||||
Ok(self.core.server_info().await.map_err(|e| describe(&e))?.setup_open)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ sync
|
||||
|
||||
/// Wakes the background loop (after unlock, after a save).
|
||||
pub fn request_sync(&self) {
|
||||
self.wake.notify_one();
|
||||
}
|
||||
|
||||
fn can_sync(&self) -> bool {
|
||||
self.key.is_ok() && self.core.has_account() && !self.core.is_locked() && lock(&self.status).server_url.is_some()
|
||||
}
|
||||
|
||||
/// Runs forever: syncs when woken and every 15 minutes while unlocked.
|
||||
pub async fn run(self: Arc<Self>) {
|
||||
loop {
|
||||
let _ = tokio::time::timeout(SYNC_PERIOD, self.wake.notified()).await;
|
||||
tokio::time::sleep(COALESCE).await;
|
||||
if self.can_sync() {
|
||||
let _ = self.sync_now().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn sync_now(&self) -> Result<SyncStatus, String> {
|
||||
self.require_key()?;
|
||||
if self.core.is_locked() {
|
||||
return Err("Unlock Kestrove first.".into());
|
||||
}
|
||||
self.update(|s| s.syncing = true);
|
||||
let result = self.core.sync_now().await;
|
||||
let mut pending_devices = None;
|
||||
if result.is_ok() {
|
||||
pending_devices = self
|
||||
.core
|
||||
.list_devices()
|
||||
.await
|
||||
.ok()
|
||||
.map(|ds| ds.iter().filter(|d| !d.approved && !d.revoked && !d.current).count() as u32);
|
||||
}
|
||||
let err = result.as_ref().err().map(describe);
|
||||
self.update(|s| {
|
||||
s.syncing = false;
|
||||
match &result {
|
||||
Ok(r) => {
|
||||
s.last_sync_at = Some(now_secs());
|
||||
s.last_error = None;
|
||||
s.pending_approval = false;
|
||||
s.integrity_alarm = None;
|
||||
s.restored_items = r.restored_item_ids.len() as u32;
|
||||
if r.pulled + r.merged > 0 || !r.restored_item_ids.is_empty() {
|
||||
s.changes += 1;
|
||||
}
|
||||
}
|
||||
Err(CoreError::PendingApproval) => {
|
||||
s.pending_approval = true;
|
||||
s.last_error = None;
|
||||
}
|
||||
Err(e @ CoreError::IntegrityAlarm { .. }) => {
|
||||
s.integrity_alarm = Some(describe(e));
|
||||
s.last_error = err.clone();
|
||||
// Other vaults may still have synced.
|
||||
s.changes += 1;
|
||||
}
|
||||
Err(_) => s.last_error = err.clone(),
|
||||
}
|
||||
if let Some(n) = pending_devices {
|
||||
s.pending_devices = n;
|
||||
}
|
||||
});
|
||||
match err {
|
||||
Some(e) if !matches!(result, Err(CoreError::PendingApproval)) => Err(e),
|
||||
_ => Ok(self.status()),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ devices
|
||||
|
||||
pub async fn devices(&self) -> Result<Vec<DeviceView>, String> {
|
||||
let ds = self.core.list_devices().await.map_err(|e| describe(&e))?;
|
||||
let pending = ds.iter().filter(|d| !d.approved && !d.revoked && !d.current).count() as u32;
|
||||
self.update(|s| s.pending_devices = pending);
|
||||
Ok(ds.into_iter().map(device_view).collect())
|
||||
}
|
||||
|
||||
/// After the user compared `key_fingerprint` with the one the other device shows.
|
||||
pub async fn approve_device(&self, device_id: &str) -> Result<(), String> {
|
||||
self.core.approve_device(device_id.to_string()).await.map_err(|e| describe(&e))?;
|
||||
self.devices().await.map(|_| ())
|
||||
}
|
||||
|
||||
pub async fn revoke_device(&self, device_id: &str) -> Result<(), String> {
|
||||
self.core.revoke_device(device_id.to_string()).await.map_err(|e| describe(&e))?;
|
||||
self.devices().await.map(|_| ())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ onboarding: a new computer
|
||||
|
||||
/// "Use my phone": opens a rendezvous and returns the QR. Then poll `join_poll` every 2 seconds.
|
||||
pub async fn join_start(&self, server_url: &str) -> Result<JoinView, String> {
|
||||
self.require_key()?;
|
||||
let url = server_url.trim().trim_end_matches('/').to_string();
|
||||
let session = self.core.join_start(url.clone()).await.map_err(|e| describe(&e))?;
|
||||
self.update(|s| {
|
||||
s.server_url = Some(url);
|
||||
s.pins = vec![];
|
||||
});
|
||||
Ok(JoinView { qr_svg: qr::svg_data_uri(&session.qr)?, qr_text: session.qr, expires_at: session.expires_at })
|
||||
}
|
||||
|
||||
pub async fn join_poll(&self) -> Result<JoinPollView, String> {
|
||||
match self.core.join_poll().await {
|
||||
Ok(p) => {
|
||||
let setup = p.setup.map(|s| {
|
||||
let view = setup_view(&s);
|
||||
*lock(&self.pending_setup) = Some(s);
|
||||
view
|
||||
});
|
||||
Ok(JoinPollView { code: p.code, setup })
|
||||
}
|
||||
Err(CoreError::NotFound) => Err("The code expired. Start again.".into()),
|
||||
// The server allows one poll per second per rendezvous; a 429 just means "not yet".
|
||||
Err(CoreError::Server { status: 429, .. }) => Ok(JoinPollView { code: None, setup: None }),
|
||||
Err(e) => Err(describe(&e)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn join_cancel(&self) {
|
||||
self.core.join_cancel();
|
||||
}
|
||||
|
||||
/// "Paste setup code".
|
||||
pub fn setup_from_text(&self, text: &str) -> Result<SetupView, String> {
|
||||
let info = kv_ffi::parse_setup_payload(text.trim().to_string()).map_err(|e| describe(&e))?;
|
||||
let view = setup_view(&info);
|
||||
*lock(&self.pending_setup) = Some(info);
|
||||
Ok(view)
|
||||
}
|
||||
|
||||
/// "Open Emergency Kit…": a PNG/JPEG photo or the Kit PDF.
|
||||
pub fn setup_from_file(&self, bytes: &[u8]) -> Result<SetupView, String> {
|
||||
let texts = qr::decode_file(bytes)?;
|
||||
let text = texts
|
||||
.iter()
|
||||
.find(|t| t.starts_with("kestrove://setup/"))
|
||||
.ok_or("No Kestrove setup code was found in this file. Try a sharper photo, or paste the code.")?;
|
||||
self.setup_from_text(text)
|
||||
}
|
||||
|
||||
pub fn clear_setup(&self) {
|
||||
*lock(&self.pending_setup) = None;
|
||||
}
|
||||
|
||||
/// Signs in with the pending setup payload and the master password, then downloads the vault.
|
||||
/// `PendingApproval` (a Kit code has no ticket): approve this computer elsewhere, then call again.
|
||||
pub async fn finish_setup(&self, master_password: Zeroizing<String>) -> Result<SignIn, String> {
|
||||
self.require_key()?;
|
||||
let setup = lock(&self.pending_setup).clone().ok_or("Scan or paste a setup code first.")?;
|
||||
let result =
|
||||
self.core.login_with_setup(setup.clone(), master_password.to_string()).await.map_err(|e| describe(&e))?;
|
||||
self.update(|s| {
|
||||
s.server_url = Some(setup.server_url.clone());
|
||||
s.pins = setup.spki_pins.clone();
|
||||
s.pending_approval = result == LoginResult::PendingApproval;
|
||||
});
|
||||
if result == LoginResult::PendingApproval {
|
||||
return Ok(SignIn::PendingApproval);
|
||||
}
|
||||
self.remember_secret_key(&setup.secret_key);
|
||||
self.clear_setup();
|
||||
let _ = self.sync_now().await;
|
||||
Ok(SignIn::Ok)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ onboarding: from this (signed-in) computer
|
||||
|
||||
/// "Add a device": a 10-minute, single-use QR. Cancel (or lock) revokes it.
|
||||
pub async fn add_device(&self) -> Result<AddDeviceView, String> {
|
||||
self.cancel_add_device().await;
|
||||
let p = self.core.add_device_payload().await.map_err(|e| describe(&e))?;
|
||||
*lock(&self.add_device_ticket) = Some(p.ticket_id);
|
||||
Ok(AddDeviceView { qr_svg: qr::svg_data_uri(&p.payload)?, expires_at: p.expires_at })
|
||||
}
|
||||
|
||||
/// Revokes the "Add a device" ticket if one is on screen. A ticket already used or expired is fine.
|
||||
pub async fn cancel_add_device(&self) {
|
||||
let id = lock(&self.add_device_ticket).take();
|
||||
if let Some(id) = id {
|
||||
if let Err(e) = self.core.revoke_enrollment_ticket(id).await {
|
||||
if !matches!(e, CoreError::NotFound) {
|
||||
eprintln!("kestrove: could not revoke the enrollment ticket: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "Approve a computer": the join code shown (as QR and text) on the new computer.
|
||||
pub async fn approve_join(&self, join_code: &str) -> Result<JoinConfirmView, String> {
|
||||
let c = self.core.join_approve(join_code.trim().to_string()).await.map_err(|e| match e {
|
||||
CoreError::NotFound => "That join code expired or was already used.".to_string(),
|
||||
e => describe(&e),
|
||||
})?;
|
||||
Ok(JoinConfirmView {
|
||||
code: c.code,
|
||||
device_name: c.device_name,
|
||||
platform: platform_name(c.platform).into(),
|
||||
key_fingerprint: c.key_fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
/// After the user saw the same code on both screens.
|
||||
pub async fn send_join(&self) -> Result<(), String> {
|
||||
self.core.join_send().await.map_err(|e| describe(&e))
|
||||
}
|
||||
|
||||
/// Emergency Kit setup code (server, pins, account, Secret Key; no ticket) as a QR.
|
||||
pub fn kit_qr(&self) -> Result<String, String> {
|
||||
qr::svg_data_uri(&self.core.setup_payload_for_kit().map_err(|e| describe(&e))?)
|
||||
}
|
||||
|
||||
/// Called whenever the vault locks: nothing secret stays live (the core drops its keys itself).
|
||||
pub async fn on_lock(&self) {
|
||||
self.core.join_cancel();
|
||||
self.cancel_add_device().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pins_split_on_commas_and_spaces() {
|
||||
assert_eq!(parse_pins(" a=, b=\nc= "), vec!["a=", "b=", "c="]);
|
||||
assert!(parse_pins(" ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_name_is_never_empty() {
|
||||
assert!(!device_name().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn settings_persist_and_secrets_stay_out_of_views() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let core = VaultCore::new_in_memory();
|
||||
let key = DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string());
|
||||
let svc = SyncService::new(core, dir.path(), key, Box::new(|_| {}));
|
||||
svc.configure("http://127.0.0.1:9/", vec![]).unwrap();
|
||||
assert_eq!(svc.status().server_url.as_deref(), Some("http://127.0.0.1:9"));
|
||||
assert!(svc.status().device_fingerprint.is_some());
|
||||
|
||||
let payload = "kestrove://setup/v1?s=https%3A%2F%2Fkestrove.samialabri.com&a=6f1c2b9a4e0d4c7f9b3a8e2d1c0b9a87&k=K12TB9D5MPJTB9D5MPJTB9D5MPJTW";
|
||||
let view = svc.setup_from_text(payload).unwrap();
|
||||
assert_eq!(view.server_host, "kestrove.samialabri.com");
|
||||
assert!(!view.preapproved);
|
||||
assert!(!serde_json::to_string(&view).unwrap().contains("K12TB9"));
|
||||
assert!(svc.setup_from_text("kestrove://setup/v9?s=x").is_err());
|
||||
|
||||
// A second service over the same folder sees the saved server.
|
||||
let again = SyncService::new(
|
||||
VaultCore::new_in_memory(),
|
||||
dir.path(),
|
||||
DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()),
|
||||
Box::new(|_| {}),
|
||||
);
|
||||
assert_eq!(again.status().server_url.as_deref(), Some("http://127.0.0.1:9"));
|
||||
assert_eq!(again.status().device_fingerprint, svc.status().device_fingerprint);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_needs_unlock() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let svc = SyncService::new(
|
||||
VaultCore::new_in_memory(),
|
||||
dir.path(),
|
||||
DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string()),
|
||||
Box::new(|_| {}),
|
||||
);
|
||||
assert!(svc.sync_now().await.is_err());
|
||||
assert!(!svc.can_sync());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! End to end against a real kestrove-server (ignored by default):
|
||||
//!
|
||||
//! ```sh
|
||||
//! KV_SETUP_CODE=… KV_BIND=127.0.0.1:18090 KV_PUBLIC_URL=http://127.0.0.1:18090 kestrove-server &
|
||||
//! KV_TEST_SERVER=http://127.0.0.1:18090 KV_TEST_SETUP_CODE=… cargo test --test live_sync -- --ignored --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Three "app instances" with separate data folders go through the same `SyncService` calls the UI makes:
|
||||
//! A creates the account; B joins with "Use my phone", approved from A with the pasted join code; items sync both
|
||||
//! ways; C uses the Emergency Kit code (no ticket), waits for approval, A approves it by fingerprint.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use kestrove_desktop::device_key::DeviceKey;
|
||||
use kestrove_desktop::sync::{SignIn, SyncService};
|
||||
use kv_ffi::{ItemDto, ItemKindDto, VaultCore};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
const PASSWORD: &str = "correct horse battery staple";
|
||||
|
||||
fn instance(dir: &Path) -> Arc<SyncService> {
|
||||
let core = VaultCore::open(dir.join("vault.sqlite").to_string_lossy().into_owned()).unwrap();
|
||||
let key = DeviceKey::load_or_create_file(dir).map_err(|e| e.to_string());
|
||||
SyncService::new(core, dir, key, Box::new(|_| {}))
|
||||
}
|
||||
|
||||
fn login(title: &str) -> ItemDto {
|
||||
ItemDto {
|
||||
id: String::new(),
|
||||
kind: ItemKindDto::Login,
|
||||
title: title.into(),
|
||||
username: Some(format!("{title}-user")),
|
||||
password: Some("hunter2".into()),
|
||||
urls: vec!["https://example.com".into()],
|
||||
totp: None,
|
||||
notes: None,
|
||||
favorite: false,
|
||||
tags: vec![],
|
||||
modified_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn titles(svc: &SyncService) -> Vec<String> {
|
||||
svc.core().list_items().unwrap().into_iter().map(|i| i.title).collect()
|
||||
}
|
||||
|
||||
/// The server allows 5 auth calls a minute per IP; three devices from one machine hit that.
|
||||
async fn patiently<T, F, Fut>(mut f: F) -> Result<T, String>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: std::future::Future<Output = Result<T, String>>,
|
||||
{
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match f().await {
|
||||
Err(e) if e.contains("Too many attempts") && start.elapsed() < Duration::from_secs(150) => {
|
||||
eprintln!("rate limited; waiting");
|
||||
tokio::time::sleep(Duration::from_secs(15)).await;
|
||||
}
|
||||
other => return other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[ignore = "needs a running kestrove-server (KV_TEST_SERVER, KV_TEST_SETUP_CODE)"]
|
||||
async fn create_join_approve_and_sync() {
|
||||
let server = std::env::var("KV_TEST_SERVER").expect("KV_TEST_SERVER");
|
||||
let code = std::env::var("KV_TEST_SETUP_CODE").expect("KV_TEST_SETUP_CODE");
|
||||
let (da, db, dc) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
|
||||
|
||||
// A: "Create a new account".
|
||||
let a = instance(da.path());
|
||||
let sk = a.create_account(&server, vec![], &code, Zeroizing::new(PASSWORD.into())).await.unwrap();
|
||||
assert!(sk.starts_with("K1"));
|
||||
assert!(a.status().logged_in);
|
||||
assert!(a.status().last_sync_at.is_some(), "first sync after registration: {:?}", a.status().last_error);
|
||||
a.core().save_item(login("from-a")).unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
|
||||
// A: "Add a device" QR, then Cancel revokes the ticket.
|
||||
let add = a.add_device().await.unwrap();
|
||||
assert!(add.qr_svg.starts_with("data:image/svg+xml"));
|
||||
a.cancel_add_device().await;
|
||||
|
||||
// B: "Use my phone" → the QR (and its text for the paste fallback).
|
||||
let b = instance(db.path());
|
||||
let join = b.join_start(&server).await.unwrap();
|
||||
assert!(join.qr_text.starts_with("kestrove://join/v1?"));
|
||||
let first = b.join_poll().await.unwrap();
|
||||
assert!(first.code.is_none() && first.setup.is_none());
|
||||
|
||||
// A: "Approve a computer" with the pasted join code; both show the same code.
|
||||
let confirm = a.approve_join(&join.qr_text).await.unwrap();
|
||||
assert_eq!(Some(confirm.key_fingerprint.clone()), b.status().device_fingerprint);
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
let seen = b.join_poll().await.unwrap();
|
||||
assert_eq!(seen.code.as_deref(), Some(confirm.code.as_str()));
|
||||
a.send_join().await.unwrap();
|
||||
|
||||
// B: the bundle arrives; then only the master password.
|
||||
let setup = loop {
|
||||
tokio::time::sleep(Duration::from_millis(1100)).await;
|
||||
if let Some(s) = b.join_poll().await.unwrap().setup {
|
||||
break s;
|
||||
}
|
||||
};
|
||||
assert!(setup.preapproved);
|
||||
assert_eq!(setup.server_url, server.trim_end_matches('/'));
|
||||
let r = patiently(|| b.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap();
|
||||
assert_eq!(r, SignIn::Ok);
|
||||
assert_eq!(titles(&b), vec!["from-a"]);
|
||||
assert!(b.stored_secret_key().is_some(), "B remembers the Secret Key, so unlock needs only the password");
|
||||
|
||||
// An item from B reaches A.
|
||||
b.core().save_item(login("from-b")).unwrap();
|
||||
b.sync_now().await.unwrap();
|
||||
a.sync_now().await.unwrap();
|
||||
let mut got = titles(&a);
|
||||
got.sort();
|
||||
assert_eq!(got, vec!["from-a", "from-b"]);
|
||||
|
||||
// Lock clears keys: no sync while locked, and unlocking with the password works again.
|
||||
b.core().lock();
|
||||
b.on_lock().await;
|
||||
assert!(b.sync_now().await.is_err());
|
||||
b.core().unlock(PASSWORD.into(), b.stored_secret_key().unwrap().to_string()).unwrap();
|
||||
b.sync_now().await.unwrap();
|
||||
|
||||
// C: Emergency Kit code (no ticket) → pending → A approves after comparing fingerprints.
|
||||
let kit = a.core().setup_payload_for_kit().unwrap();
|
||||
let c = instance(dc.path());
|
||||
let view = c.setup_from_text(&kit).unwrap();
|
||||
assert!(!view.preapproved);
|
||||
let r = patiently(|| c.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap();
|
||||
assert_eq!(r, SignIn::PendingApproval);
|
||||
let devices = a.devices().await.unwrap();
|
||||
let pending: Vec<_> = devices.iter().filter(|d| !d.approved && !d.revoked).collect();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(Some(pending[0].key_fingerprint.clone()), c.status().device_fingerprint);
|
||||
assert_eq!(a.status().pending_devices, 1);
|
||||
a.approve_device(&pending[0].device_id).await.unwrap();
|
||||
let r = patiently(|| c.finish_setup(Zeroizing::new(PASSWORD.into()))).await.unwrap();
|
||||
assert_eq!(r, SignIn::Ok);
|
||||
let mut got = titles(&c);
|
||||
got.sort();
|
||||
assert_eq!(got, vec!["from-a", "from-b"]);
|
||||
|
||||
// Revoke C from A; C can no longer sync.
|
||||
let c_id = pending[0].device_id.clone();
|
||||
a.revoke_device(&c_id).await.unwrap();
|
||||
assert!(a.devices().await.unwrap().iter().any(|d| d.device_id == c_id && d.revoked));
|
||||
assert!(c.sync_now().await.is_err());
|
||||
}
|
||||
Reference in New Issue
Block a user