AspansQulyp/src/storage.rs
deni.reyton 45de8f9f51 Qulyp: локальный менеджер паролей на Rust + egui
- Шифрованный файл .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>
2026-09-30 14:32:11 +05:00

305 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Открытое хранилище: чтение/запись файла и синхронизация.
//!
//! Синхронизация рассчитана на «папку в облаке» (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>());
}
}