Files
ElyOffice/crates/ely-files/src/key.rs
T
ZacharyZhang-NY b7fca0146d Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git),
ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
2026-10-08 21:20:08 -04:00

208 lines
6.5 KiB
Rust

use std::{
io,
path::Path,
time::{Duration, Instant},
};
#[cfg(test)]
mod tests;
/// A key store backend, or a test double.
pub trait Backend: Send + Sync {
/// The stored key bytes, or None when absent.
fn get(&self) -> io::Result<Option<Vec<u8>>>;
/// Stores the key bytes.
fn set(&self, key: &[u8]) -> io::Result<()>;
}
/// The system key store through `keyring` (§18.2).
pub struct KeyringBackend;
impl Backend for KeyringBackend {
fn get(&self) -> io::Result<Option<Vec<u8>>> {
let entry = entry()?;
match entry.get_secret() {
Ok(bytes) => Ok(Some(bytes)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!("the key store keeps the recovery key: {error}"),
)),
}
}
fn set(&self, key: &[u8]) -> io::Result<()> {
entry()?
.set_secret(key)
.map_err(|error| io::Error::other(format!("the key store refused the key: {error}")))
}
}
/// The credential entry for the recovery key.
fn entry() -> io::Result<keyring::Entry> {
keyring::Entry::new("ElyOffice Recovery", "recovery-key")
.map_err(|error| io::Error::other(format!("no credential entry: {error}")))
}
/// The recovery key, and whether it outlives this session (§18.2).
#[derive(Clone, Copy, Debug)]
pub struct RecoveryKey {
pub key: [u8; 32],
/// False when the system store refused: this run only.
pub lasting: bool,
}
impl RecoveryKey {
/// The stored key, else this session's; never a file.
pub fn load() -> Self {
let dir = account_home().map(|home| shared_dir(&home));
Self::from(dir.and_then(|dir| load_with(&KeyringBackend, &dir)))
}
/// The loaded key, or this session's when loading failed.
pub fn from(loaded: io::Result<[u8; 32]>) -> Self {
match loaded {
Ok(key) => Self { key, lasting: true },
Err(error) => {
log::warn!("recovery: no system key store, this session's key only: {error}");
Self { key: *session_key(), lasting: false }
}
}
}
}
/// One random key per run, kept only in memory.
fn session_key() -> &'static [u8; 32] {
static KEY: std::sync::OnceLock<[u8; 32]> = std::sync::OnceLock::new();
KEY.get_or_init(|| {
let mut key = [0u8; 32];
getrandom::fill(&mut key).expect("system randomness");
key
})
}
/// The OS account's home, deaf to environment overrides.
#[cfg(unix)]
fn account_home() -> io::Result<std::path::PathBuf> {
use std::os::unix::ffi::OsStringExt;
let mut passwd: libc::passwd = unsafe { std::mem::zeroed() };
let mut buffer = vec![0u8; 1024];
let mut result = std::ptr::null_mut();
loop {
let status = unsafe {
libc::getpwuid_r(
libc::getuid(),
&mut passwd,
buffer.as_mut_ptr().cast(),
buffer.len(),
&mut result,
)
};
match status {
0 => break,
libc::ERANGE => buffer.resize(buffer.len() * 2, 0),
error => return Err(io::Error::from_raw_os_error(error)),
}
}
if result.is_null() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"no account home for this uid",
));
}
let dir = unsafe { std::ffi::CStr::from_ptr(passwd.pw_dir) };
Ok(std::path::PathBuf::from(std::ffi::OsString::from_vec(
dir.to_bytes().to_vec(),
)))
}
/// The OS account's profile, deaf to environment overrides.
#[cfg(windows)]
fn account_home() -> io::Result<std::path::PathBuf> {
use windows_sys::Win32::System::Com::CoTaskMemFree;
use windows_sys::Win32::UI::Shell::{FOLDERID_Profile, SHGetKnownFolderPath};
let mut path = std::ptr::null_mut();
let result =
unsafe { SHGetKnownFolderPath(&FOLDERID_Profile, 0, std::ptr::null_mut(), &mut path) };
if result != 0 || path.is_null() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"no account profile directory",
));
}
let text = unsafe {
let len = (0..).take_while(|i| *path.add(*i) != 0).count();
let text = String::from_utf16_lossy(std::slice::from_raw_parts(path, len));
CoTaskMemFree(path.cast());
text
};
Ok(std::path::PathBuf::from(text))
}
/// The lock's home: per user, deaf to data-dir env overrides.
fn shared_dir(home: &Path) -> std::path::PathBuf {
#[cfg(target_os = "macos")]
{
home.join("Library/Application Support/ElyOffice")
}
#[cfg(target_os = "windows")]
{
home.join("AppData").join("Local").join("ElyOffice")
}
#[cfg(all(unix, not(target_os = "macos")))]
{
home.join(".local").join("share").join("ElyOffice")
}
#[cfg(not(any(unix, windows)))]
{
home.join(".elyoffice")
}
}
/// Reads or initializes the key; one winner across processes.
pub fn load_with(backend: &dyn Backend, dir: &Path) -> io::Result<[u8; 32]> {
std::fs::create_dir_all(dir)?;
let lock = dir.join(".recovery-key.lock");
let deadline = Instant::now() + Duration::from_secs(1);
let guard = loop {
if let Some(guard) = ely_platform::lock::FileLock::try_take(&lock)? {
break guard;
}
if Instant::now() >= deadline {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"another process still initializes the recovery key",
));
}
std::thread::sleep(Duration::from_millis(20));
};
let outcome = match backend.get()? {
Some(bytes) => key_of(bytes),
None => init(backend),
};
drop(guard);
outcome
}
/// Generates and stores the key, then re-reads the persisted one.
fn init(backend: &dyn Backend) -> io::Result<[u8; 32]> {
let mut key = [0u8; 32];
getrandom::fill(&mut key).map_err(|_| io::Error::other("system randomness failed"))?;
backend.set(&key)?;
log::info!("recovery: a new recovery key in the system store");
let Some(bytes) = backend.get()? else {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"the key store did not keep the new key",
));
};
key_of(bytes)
}
/// The key's exact length, or a loud error.
fn key_of(bytes: Vec<u8>) -> io::Result<[u8; 32]> {
bytes
.try_into()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "recovery key length"))
}