sdk/src/secrets.rs
Rama main · réplica de lectura del repositorio original.
//! Storage for shares's key material and the client's own secrets.
use crate::ShareId;
use std::fs;
use std::io;
use std::path::PathBuf;
/// A secret's storage key.
#[derive(Clone, Debug, PartialEq, Eq, uniffi::Enum)]
pub enum SecretScope {
/// Key material of one share.
Share { id: ShareId },
/// The client's own secrets, like the browser pairing secret.
Client,
}
/// Generic secret storage. Apps implement this on the platform's secure store.
#[uniffi::export(with_foreign)]
pub trait SecretStore: Send + Sync {
fn load(&self, scope: SecretScope, key: String) -> Result<Option<Vec<u8>>, SecretStoreError>;
fn save(&self, scope: SecretScope, key: String, value: Vec<u8>)
-> Result<(), SecretStoreError>;
/// Deleting what is not there is not an error.
fn delete(&self, scope: SecretScope, key: String) -> Result<(), SecretStoreError>;
}
#[derive(Debug, thiserror::Error, uniffi::Error)]
#[uniffi(flat_error)]
pub enum SecretStoreError {
#[error("{0}")]
Failed(String),
}
impl From<io::Error> for SecretStoreError {
fn from(e: io::Error) -> Self {
SecretStoreError::Failed(e.to_string())
}
}
impl From<uniffi::UnexpectedUniFFICallbackError> for SecretStoreError {
fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self {
SecretStoreError::Failed(e.to_string())
}
}
/// The default store with one file per secret - `<dir>/<share id>/<key>` for a
/// share's and `<dir>/client/<key>` for the client's own, readable by the owner
/// only. For desktop, and for tests.
pub struct FileSecretStore {
dir: PathBuf,
}
impl FileSecretStore {
pub fn new(dir: PathBuf) -> Self {
Self { dir }
}
fn path(&self, scope: &SecretScope, key: &str) -> PathBuf {
debug_assert!(!key.contains(['/', '\\']), "secret keys are plain names");
self.scope_dir(scope).join(key)
}
fn scope_dir(&self, scope: &SecretScope) -> PathBuf {
match scope {
SecretScope::Share { id } => self.dir.join(id.as_str()),
SecretScope::Client => self.dir.join("client"),
}
}
}
impl SecretStore for FileSecretStore {
fn load(&self, scope: SecretScope, key: String) -> Result<Option<Vec<u8>>, SecretStoreError> {
match fs::read(self.path(&scope, &key)) {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e.into()),
}
}
fn save(
&self,
scope: SecretScope,
key: String,
value: Vec<u8>,
) -> Result<(), SecretStoreError> {
let path = self.path(&scope, &key);
let dir = path
.parent()
.expect("a secret's path has a scope directory");
fs::create_dir_all(dir)?;
// Write beside the target and rename, so a crash leaves the old
// secret or the new one, never a torn file.
let staging = path.with_extension("staging");
let mut options = fs::OpenOptions::new();
options.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
{
use io::Write;
let mut file = options.open(&staging)?;
file.write_all(&value)?;
file.sync_all()?;
}
fs::rename(&staging, &path)?;
Ok(())
}
fn delete(&self, scope: SecretScope, key: String) -> Result<(), SecretStoreError> {
let path = self.path(&scope, &key);
match fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(e.into()),
}
// The scope's directory goes with its last secret. Not being empty
// yet is fine.
let _ = fs::remove_dir(self.scope_dir(&scope));
Ok(())
}
}
/// A new secret for the SDK's tokens, cookies and credentials: 32 random bytes
/// from the OS, as lowercase hex.
pub(crate) fn mint_secret() -> String {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("the OS random source is available");
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
/// Whether two secrets are equal, compared in time that does not depend on
/// where they differ.
pub(crate) fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn secrets_round_trip_and_leave_nothing_behind() {
let dir =
std::env::temp_dir().join(format!("wispers-access-secrets-{}", uuid::Uuid::new_v4()));
let store = FileSecretStore::new(dir.clone());
let share_id = ShareId::mint();
let share = || SecretScope::Share {
id: share_id.clone(),
};
let key = || "root_key".to_owned();
assert_eq!(store.load(share(), key()).unwrap(), None);
store.save(share(), key(), b"one".to_vec()).unwrap();
store.save(share(), key(), b"two".to_vec()).unwrap();
assert_eq!(
store.load(share(), key()).unwrap().as_deref(),
Some(&b"two"[..])
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(dir.join(share_id.as_str()).join("root_key"))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
store.delete(share(), key()).unwrap();
store.delete(share(), key()).unwrap();
assert!(!dir.join(share_id.as_str()).exists());
// The client's own secrets live beside the shares', and are not a share's.
let pairing = || "browser_pairing_secret".to_owned();
store
.save(SecretScope::Client, pairing(), b"s3cret".to_vec())
.unwrap();
assert_eq!(
store
.load(SecretScope::Client, pairing())
.unwrap()
.as_deref(),
Some(&b"s3cret"[..])
);
assert_eq!(store.load(share(), pairing()).unwrap(), None);
assert!(dir.join("client").join("browser_pairing_secret").exists());
store.delete(SecretScope::Client, pairing()).unwrap();
assert!(!dir.join("client").exists());
fs::remove_dir_all(dir).unwrap();
}
}