- Шифрованный файл .qulyp: Argon2id + XChaCha20-Poly1305, атомарная запись и .bak - Записи с TOTP, папками, избранным и историей паролей; генератор паролей - Синхронизация через облачную папку с послезаписным слиянием и ручное слияние файлов - Автоблокировка, автоочистка буфера обмена, тёмная/светлая тема - Сборка под Windows и Linux, CI для Gitea Actions - Заметки проекта в notes/ с лимитами строк (tests/notes.rs) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
305 lines
12 KiB
Rust
305 lines
12 KiB
Rust
//! Открытое хранилище: чтение/запись файла и синхронизация.
|
||
//!
|
||
//! Синхронизация рассчитана на «папку в облаке» (Dropbox, OneDrive, Google Drive,
|
||
//! Syncthing, Nextcloud…): если файл на диске изменился извне, перед записью
|
||
//! он расшифровывается тем же ключом и сливается с текущей версией.
|
||
|
||
use std::fs;
|
||
use std::io::Write;
|
||
use std::path::{Path, PathBuf};
|
||
use std::time::SystemTime;
|
||
|
||
use anyhow::{Context, Result, anyhow};
|
||
use sha2::{Digest, Sha256};
|
||
use zeroize::Zeroizing;
|
||
|
||
use crate::crypto::{self, VaultKey};
|
||
use crate::vault::{MergeReport, Vault};
|
||
|
||
/// Файл на диске зашифрован другим мастер-паролем (сменили на другом устройстве).
|
||
#[derive(Debug)]
|
||
pub struct ForeignKey;
|
||
|
||
impl std::fmt::Display for ForeignKey {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str("Файл хранилища изменён с другим мастер-паролем")
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for ForeignKey {}
|
||
|
||
#[derive(Clone, Copy, PartialEq)]
|
||
struct Stamp {
|
||
len: u64,
|
||
mtime: Option<SystemTime>,
|
||
}
|
||
|
||
fn stamp(path: &Path) -> Option<Stamp> {
|
||
let m = fs::metadata(path).ok()?;
|
||
Some(Stamp { len: m.len(), mtime: m.modified().ok() })
|
||
}
|
||
|
||
fn hash(bytes: &[u8]) -> [u8; 32] {
|
||
Sha256::digest(bytes).into()
|
||
}
|
||
|
||
fn parse_vault(plain: &[u8]) -> Result<Vault> {
|
||
serde_json::from_slice(plain).context("Не удалось прочитать содержимое хранилища")
|
||
}
|
||
|
||
fn serialize(vault: &Vault, key: &VaultKey) -> Result<Vec<u8>> {
|
||
let plain = Zeroizing::new(serde_json::to_vec(vault)?);
|
||
crypto::encrypt(&plain, key)
|
||
}
|
||
|
||
/// Атомарная запись: старая версия → `.bak`, новая пишется во временный файл
|
||
/// и переименовывается поверх. Сбой посреди записи не портит хранилище.
|
||
pub fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
|
||
if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
|
||
fs::create_dir_all(dir)?;
|
||
}
|
||
let tmp = path.with_extension("qulyp.tmp");
|
||
{
|
||
let mut f = fs::File::create(&tmp).with_context(|| format!("Не удалось записать {}", tmp.display()))?;
|
||
f.write_all(data)?;
|
||
f.sync_all()?;
|
||
}
|
||
if path.exists() {
|
||
let _ = fs::copy(path, path.with_extension("qulyp.bak"));
|
||
}
|
||
fs::rename(&tmp, path).with_context(|| format!("Не удалось сохранить {}", path.display()))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// Прочитать и расшифровать файл паролем. Медленно (Argon2) — вызывать не из UI-потока.
|
||
pub fn load(path: &Path, password: &str) -> Result<(Vault, VaultKey, Vec<u8>)> {
|
||
let bytes = fs::read(path).with_context(|| format!("Не удалось открыть {}", path.display()))?;
|
||
let header = crypto::read_header(&bytes)?;
|
||
let key = VaultKey::derive(password, header.salt, header.params)?;
|
||
let plain = crypto::decrypt(&bytes, &key)?;
|
||
Ok((parse_vault(&plain)?, key, bytes))
|
||
}
|
||
|
||
pub fn same_file(a: &Path, b: &Path) -> bool {
|
||
match (a.canonicalize(), b.canonicalize()) {
|
||
(Ok(a), Ok(b)) => a == b,
|
||
_ => a == b,
|
||
}
|
||
}
|
||
|
||
pub struct Session {
|
||
pub path: PathBuf,
|
||
pub key: VaultKey,
|
||
pub vault: Vault,
|
||
disk_stamp: Option<Stamp>,
|
||
disk_hash: [u8; 32],
|
||
}
|
||
|
||
impl Session {
|
||
pub fn open(path: PathBuf, password: &str) -> Result<Self> {
|
||
let (vault, key, bytes) = load(&path, password)?;
|
||
Ok(Self { disk_stamp: stamp(&path), disk_hash: hash(&bytes), path, key, vault })
|
||
}
|
||
|
||
pub fn create(path: PathBuf, name: &str, password: &str) -> Result<Self> {
|
||
let key = VaultKey::generate(password)?;
|
||
let mut s = Self { path, key, vault: Vault::new(name), disk_stamp: None, disk_hash: [0; 32] };
|
||
s.write()?;
|
||
Ok(s)
|
||
}
|
||
|
||
pub fn file_name(&self) -> String {
|
||
self.path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||
}
|
||
|
||
fn write(&mut self) -> Result<()> {
|
||
self.vault.normalize();
|
||
let data = serialize(&self.vault, &self.key)?;
|
||
write_atomic(&self.path, &data)?;
|
||
self.disk_stamp = stamp(&self.path);
|
||
self.disk_hash = hash(&data);
|
||
Ok(())
|
||
}
|
||
|
||
/// Байты файла, если он изменился с момента последнего чтения/записи.
|
||
fn changed_on_disk(&mut self) -> Result<Option<Vec<u8>>> {
|
||
let now = stamp(&self.path);
|
||
if now.is_none() || now == self.disk_stamp {
|
||
return Ok(None);
|
||
}
|
||
let bytes = fs::read(&self.path)?;
|
||
if hash(&bytes) == self.disk_hash {
|
||
self.disk_stamp = now; // тронули mtime, но содержимое то же
|
||
return Ok(None);
|
||
}
|
||
Ok(Some(bytes))
|
||
}
|
||
|
||
fn decrypt_foreign(&self, bytes: &[u8]) -> Result<Vault> {
|
||
let header = crypto::read_header(bytes)?;
|
||
if !self.key.matches(&header) {
|
||
return Err(ForeignKey.into());
|
||
}
|
||
parse_vault(&crypto::decrypt(bytes, &self.key)?)
|
||
}
|
||
|
||
/// Сохранить, предварительно влив изменения, сделанные в файле извне.
|
||
pub fn save(&mut self) -> Result<MergeReport> {
|
||
let mut report = MergeReport::default();
|
||
if let Some(bytes) = self.changed_on_disk()? {
|
||
let other = self.decrypt_foreign(&bytes)?;
|
||
report = self.vault.merge(&other);
|
||
}
|
||
self.write()?;
|
||
Ok(report)
|
||
}
|
||
|
||
/// Перезаписать файл текущей версией без слияния.
|
||
pub fn force_save(&mut self) -> Result<()> {
|
||
self.write()
|
||
}
|
||
|
||
/// Проверить файл на внешние изменения и подтянуть их.
|
||
/// `Ok(None)` — ничего не менялось.
|
||
pub fn poll(&mut self) -> Result<Option<MergeReport>> {
|
||
let Some(bytes) = self.changed_on_disk()? else { return Ok(None) };
|
||
let mut other = self.decrypt_foreign(&bytes)?;
|
||
let report = self.vault.merge(&other);
|
||
other.normalize();
|
||
if self.vault == other {
|
||
self.disk_stamp = stamp(&self.path);
|
||
self.disk_hash = hash(&bytes);
|
||
} else {
|
||
// У нас есть что-то, чего нет в файле, — записываем объединённую версию.
|
||
self.write()?;
|
||
}
|
||
Ok(Some(report))
|
||
}
|
||
|
||
/// Слить с другим файлом, уже расшифрованным его ключом.
|
||
/// Если это наш же файл (сменили пароль на другом устройстве) — переходим на его ключ.
|
||
pub fn merge_other(
|
||
&mut self,
|
||
other: &Vault,
|
||
other_path: &Path,
|
||
other_key: &VaultKey,
|
||
write_back: bool,
|
||
) -> Result<MergeReport> {
|
||
let report = self.vault.merge(other);
|
||
if same_file(other_path, &self.path) {
|
||
self.key = other_key.clone();
|
||
self.write()?;
|
||
} else {
|
||
self.save()?;
|
||
if write_back {
|
||
write_atomic(other_path, &serialize(&self.vault, other_key)?)?;
|
||
}
|
||
}
|
||
Ok(report)
|
||
}
|
||
|
||
/// Прочитать другой файл нашим ключом (когда у копии тот же пароль и соль).
|
||
pub fn load_with_own_key(&self, path: &Path) -> Result<Vault> {
|
||
let bytes = fs::read(path).with_context(|| format!("Не удалось открыть {}", path.display()))?;
|
||
self.decrypt_foreign(&bytes).map_err(|e| {
|
||
if e.is::<ForeignKey>() {
|
||
anyhow!("У этого файла другой пароль — введите его")
|
||
} else {
|
||
e
|
||
}
|
||
})
|
||
}
|
||
|
||
pub fn change_key(&mut self, key: VaultKey) -> Result<()> {
|
||
self.save()?;
|
||
self.key = key;
|
||
self.write()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::vault::Entry;
|
||
|
||
fn tmp(name: &str) -> PathBuf {
|
||
let d = std::env::temp_dir().join(format!("qulyp-test-{}-{name}", std::process::id()));
|
||
let _ = fs::remove_dir_all(&d);
|
||
fs::create_dir_all(&d).unwrap();
|
||
d.join("v.qulyp")
|
||
}
|
||
|
||
#[test]
|
||
fn two_devices_sync_through_one_file() {
|
||
let path = tmp("sync");
|
||
let mut a = Session::create(path.clone(), "t", "pw").unwrap();
|
||
let mut b = Session::open(path.clone(), "pw").unwrap();
|
||
|
||
let mut e = Entry::default();
|
||
e.title = "from A".into();
|
||
a.vault.upsert(e);
|
||
a.save().unwrap();
|
||
|
||
let mut e = Entry::default();
|
||
e.title = "from B".into();
|
||
b.vault.upsert(e);
|
||
// B не видел изменений A — при сохранении должен их влить, а не затереть.
|
||
let r = b.save().unwrap();
|
||
assert_eq!(r.added, 1);
|
||
assert_eq!(b.vault.entries.len(), 2);
|
||
|
||
let r = a.poll().unwrap().unwrap();
|
||
assert_eq!(r.added, 1);
|
||
assert_eq!(a.vault.entries.len(), 2);
|
||
|
||
let reopened = Session::open(path, "pw").unwrap();
|
||
assert_eq!(reopened.vault.entries.len(), 2);
|
||
}
|
||
|
||
/// Демо-хранилище для ручной проверки UI (пароль `demo-password`):
|
||
/// `QULYP_DEMO=путь.qulyp cargo test make_demo -- --ignored`
|
||
#[test]
|
||
#[ignore]
|
||
fn make_demo() {
|
||
let path = PathBuf::from(std::env::var("QULYP_DEMO").expect("укажите QULYP_DEMO"));
|
||
let _ = fs::remove_file(&path);
|
||
let mut s = Session::create(path, "Личное", "demo-password").unwrap();
|
||
let work = s.vault.add_group("Работа");
|
||
let social = s.vault.add_group("Соцсети");
|
||
let items = [
|
||
("GitHub", "dev@example.com", "gh_Tq8#vLz2!mPx", "github.com", Some(work), true, "JBSWY3DPEHPK3PXP"),
|
||
("Почта Gmail", "me@gmail.com", "Kx9$wQ2mZr7!", "mail.google.com", None, true, ""),
|
||
("Jira", "ivanov", "qwerty123", "company.atlassian.net", Some(work), false, ""),
|
||
("Telegram", "+7 700 000 00 00", "Tg-2026-secure!", "web.telegram.org", Some(social), false, ""),
|
||
("Instagram", "my.photos", "InstaPass!42", "instagram.com", Some(social), false, ""),
|
||
("Wi-Fi дома", "", "HomeNet_5G_2026", "", None, false, ""),
|
||
("Kaspi", "+7 701 111 22 33", "K@spi!Bank#77", "kaspi.kz", None, true, ""),
|
||
];
|
||
for (t, u, p, url, g, fav, totp) in items {
|
||
let mut e = Entry::default();
|
||
e.title = t.into();
|
||
e.username = u.into();
|
||
e.password = p.into();
|
||
e.url = url.into();
|
||
e.group = g;
|
||
e.favorite = fav;
|
||
e.totp = totp.into();
|
||
if t == "Wi-Fi дома" {
|
||
e.notes = "Роутер в прихожей, админка 192.168.1.1".into();
|
||
}
|
||
s.vault.upsert(e);
|
||
}
|
||
s.save().unwrap();
|
||
}
|
||
|
||
#[test]
|
||
fn password_change_elsewhere_is_detected() {
|
||
let path = tmp("foreign");
|
||
let mut a = Session::create(path.clone(), "t", "old").unwrap();
|
||
let mut b = Session::open(path.clone(), "old").unwrap();
|
||
b.change_key(VaultKey::generate("new").unwrap()).unwrap();
|
||
let err = a.poll().unwrap_err();
|
||
assert!(err.is::<ForeignKey>());
|
||
}
|
||
}
|