Decouple core Yaak logic from Tauri (#354)

This commit is contained in:
Gregory Schier
2026-01-08 20:44:25 -08:00
committed by GitHub
parent 3bcc0b8356
commit ef80216ca1
465 changed files with 3052 additions and 6234 deletions

View File

@@ -0,0 +1,98 @@
use crate::error::Error::{DecryptionError, EncryptionError, InvalidEncryptedData};
use crate::error::Result;
use chacha20poly1305::aead::generic_array::typenum::Unsigned;
use chacha20poly1305::aead::{Aead, AeadCore, Key, KeyInit, OsRng};
use chacha20poly1305::XChaCha20Poly1305;
const ENCRYPTION_TAG: &str = "yA4k3nC";
const ENCRYPTION_VERSION: u8 = 1;
pub(crate) fn encrypt_data(data: &[u8], key: &Key<XChaCha20Poly1305>) -> Result<Vec<u8>> {
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let cipher = XChaCha20Poly1305::new(&key);
let ciphered_data = cipher.encrypt(&nonce, data).map_err(|_| EncryptionError)?;
let mut data: Vec<u8> = Vec::new();
data.extend_from_slice(ENCRYPTION_TAG.as_bytes()); // Tag
data.push(ENCRYPTION_VERSION); // Version
data.extend_from_slice(&nonce.as_slice()); // Nonce
data.extend_from_slice(&ciphered_data); // Ciphertext
Ok(data)
}
pub(crate) fn decrypt_data(cipher_data: &[u8], key: &Key<XChaCha20Poly1305>) -> Result<Vec<u8>> {
// Yaak Tag + ID + Version + Nonce + ... ciphertext ...
let (tag, rest) =
cipher_data.split_at_checked(ENCRYPTION_TAG.len()).ok_or(InvalidEncryptedData)?;
if tag != ENCRYPTION_TAG.as_bytes() {
return Err(InvalidEncryptedData);
}
let (version, rest) = rest.split_at_checked(1).ok_or(InvalidEncryptedData)?;
if version[0] != ENCRYPTION_VERSION {
return Err(InvalidEncryptedData);
}
let nonce_bytes = <XChaCha20Poly1305 as AeadCore>::NonceSize::to_usize();
let (nonce, ciphered_data) = rest.split_at_checked(nonce_bytes).ok_or(InvalidEncryptedData)?;
let cipher = XChaCha20Poly1305::new(&key);
cipher.decrypt(nonce.into(), ciphered_data).map_err(|_e| DecryptionError)
}
#[cfg(test)]
mod test {
use crate::encryption::{decrypt_data, encrypt_data};
use crate::error::Error::InvalidEncryptedData;
use crate::error::Result;
use chacha20poly1305::aead::OsRng;
use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
#[test]
fn test_encrypt_decrypt() -> Result<()> {
let key = XChaCha20Poly1305::generate_key(OsRng);
let encrypted = encrypt_data("hello world".as_bytes(), &key)?;
let decrypted = decrypt_data(encrypted.as_slice(), &key)?;
assert_eq!(String::from_utf8(decrypted).unwrap(), "hello world");
Ok(())
}
#[test]
fn test_decrypt_empty() -> Result<()> {
let key = XChaCha20Poly1305::generate_key(OsRng);
let encrypted = encrypt_data(&[], &key)?;
assert_eq!(encrypted.len(), 48);
let decrypted = decrypt_data(encrypted.as_slice(), &key)?;
assert_eq!(String::from_utf8(decrypted).unwrap(), "");
Ok(())
}
#[test]
fn test_decrypt_bad_version() -> Result<()> {
let key = XChaCha20Poly1305::generate_key(OsRng);
let mut encrypted = encrypt_data("hello world".as_bytes(), &key)?;
encrypted[7] = 0;
let decrypted = decrypt_data(encrypted.as_slice(), &key);
assert!(matches!(decrypted, Err(InvalidEncryptedData)));
Ok(())
}
#[test]
fn test_decrypt_bad_tag() -> Result<()> {
let key = XChaCha20Poly1305::generate_key(OsRng);
let mut encrypted = encrypt_data("hello world".as_bytes(), &key)?;
encrypted[0] = 2;
let decrypted = decrypt_data(encrypted.as_slice(), &key);
assert!(matches!(decrypted, Err(InvalidEncryptedData)));
Ok(())
}
#[test]
fn test_decrypt_unencrypted_data() -> Result<()> {
let key = XChaCha20Poly1305::generate_key(OsRng);
let decrypted = decrypt_data("123".as_bytes(), &key);
assert!(matches!(decrypted, Err(InvalidEncryptedData)));
Ok(())
}
}

View File

@@ -0,0 +1,50 @@
use serde::{Serialize, Serializer};
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error(transparent)]
DbError(#[from] yaak_models::error::Error),
#[error("Keyring error: {0}")]
KeyringError(#[from] keyring::Error),
#[error("Missing workspace encryption key")]
MissingWorkspaceKey,
#[error("Incorrect workspace key")]
IncorrectWorkspaceKey,
#[error("Failed to decrypt workspace key: {0}")]
WorkspaceKeyDecryptionError(String),
#[error("Crypto IO error: {0}")]
IoError(#[from] io::Error),
#[error("Failed to encrypt data")]
EncryptionError,
#[error("Failed to decrypt data")]
DecryptionError,
#[error("Invalid encrypted data")]
InvalidEncryptedData,
#[error("Invalid key provided")]
InvalidHumanKey,
#[error("Encryption error: {0}")]
GenericError(String),
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -0,0 +1,7 @@
extern crate core;
pub mod encryption;
pub mod error;
pub mod manager;
mod master_key;
mod workspace_key;

View File

@@ -0,0 +1,158 @@
use crate::error::Error::{
GenericError, IncorrectWorkspaceKey, MissingWorkspaceKey, WorkspaceKeyDecryptionError,
};
use crate::error::{Error, Result};
use crate::master_key::MasterKey;
use crate::workspace_key::WorkspaceKey;
use base64::prelude::BASE64_STANDARD;
use base64::Engine;
use log::{info, warn};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use yaak_models::models::{EncryptedKey, Workspace, WorkspaceMeta};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{generate_id_of_length, UpdateSource};
const KEY_USER: &str = "encryption-key";
#[derive(Debug, Clone)]
pub struct EncryptionManager {
cached_master_key: Arc<Mutex<Option<MasterKey>>>,
cached_workspace_keys: Arc<Mutex<HashMap<String, WorkspaceKey>>>,
query_manager: QueryManager,
app_id: String,
}
impl EncryptionManager {
pub fn new(query_manager: QueryManager, app_id: impl Into<String>) -> Self {
Self {
cached_master_key: Default::default(),
cached_workspace_keys: Default::default(),
query_manager,
app_id: app_id.into(),
}
}
pub fn encrypt(&self, workspace_id: &str, data: &[u8]) -> Result<Vec<u8>> {
let workspace_secret = self.get_workspace_key(workspace_id)?;
workspace_secret.encrypt(data)
}
pub fn decrypt(&self, workspace_id: &str, data: &[u8]) -> Result<Vec<u8>> {
let workspace_secret = self.get_workspace_key(workspace_id)?;
workspace_secret.decrypt(data)
}
pub fn reveal_workspace_key(&self, workspace_id: &str) -> Result<String> {
let key = self.get_workspace_key(workspace_id)?;
key.to_human()
}
pub fn set_human_key(&self, workspace_id: &str, human_key: &str) -> Result<WorkspaceMeta> {
let wkey = WorkspaceKey::from_human(human_key)?;
let workspace = self.query_manager.connect().get_workspace(workspace_id)?;
let encryption_key_challenge = match workspace.encryption_key_challenge {
None => return self.set_workspace_key(workspace_id, &wkey),
Some(c) => c,
};
let encryption_key_challenge = match BASE64_STANDARD.decode(encryption_key_challenge) {
Ok(c) => c,
Err(_) => return Err(GenericError("Failed to decode workspace challenge".to_string())),
};
if let Err(_) = wkey.decrypt(encryption_key_challenge.as_slice()) {
return Err(IncorrectWorkspaceKey);
};
self.set_workspace_key(workspace_id, &wkey)
}
pub(crate) fn set_workspace_key(
&self,
workspace_id: &str,
wkey: &WorkspaceKey,
) -> Result<WorkspaceMeta> {
info!("Created workspace key for {workspace_id}");
let encrypted_key = BASE64_STANDARD.encode(self.get_master_key()?.encrypt(wkey.raw_key())?);
let encrypted_key = EncryptedKey { encrypted_key };
let encryption_key_challenge = wkey.encrypt(generate_id_of_length(50).as_bytes())?;
let encryption_key_challenge = Some(BASE64_STANDARD.encode(encryption_key_challenge));
let workspace_meta = self.query_manager.with_tx::<WorkspaceMeta, Error>(|tx| {
let workspace = tx.get_workspace(workspace_id)?;
let workspace_meta = tx.get_or_create_workspace_meta(workspace_id)?;
tx.upsert_workspace(
&Workspace { encryption_key_challenge, ..workspace },
&UpdateSource::Background,
)?;
Ok(tx.upsert_workspace_meta(
&WorkspaceMeta { encryption_key: Some(encrypted_key.clone()), ..workspace_meta },
&UpdateSource::Background,
)?)
})?;
let mut cache = self.cached_workspace_keys.lock().unwrap();
cache.insert(workspace_id.to_string(), wkey.clone());
Ok(workspace_meta)
}
pub fn ensure_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceMeta> {
let workspace_meta =
self.query_manager.connect().get_or_create_workspace_meta(workspace_id)?;
// Already exists
if let Some(_) = workspace_meta.encryption_key {
warn!("Tried to create workspace key when one already exists for {workspace_id}");
return Ok(workspace_meta);
}
let wkey = WorkspaceKey::create()?;
self.set_workspace_key(workspace_id, &wkey)
}
fn get_workspace_key(&self, workspace_id: &str) -> Result<WorkspaceKey> {
{
let cache = self.cached_workspace_keys.lock().unwrap();
if let Some(k) = cache.get(workspace_id) {
return Ok(k.clone());
}
};
let db = self.query_manager.connect();
let workspace_meta = db.get_or_create_workspace_meta(workspace_id)?;
let key = match workspace_meta.encryption_key {
None => return Err(MissingWorkspaceKey),
Some(k) => k,
};
let mkey = self.get_master_key()?;
let decoded_key = BASE64_STANDARD
.decode(key.encrypted_key)
.map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?;
let raw_key = mkey
.decrypt(decoded_key.as_slice())
.map_err(|e| WorkspaceKeyDecryptionError(e.to_string()))?;
let wkey = WorkspaceKey::from_raw_key(raw_key.as_slice());
Ok(wkey)
}
fn get_master_key(&self) -> Result<MasterKey> {
// NOTE: This locks the key for the entire function which seems wrong, but this prevents
// concurrent access from prompting the user for a keychain password multiple times.
let mut master_secret = self.cached_master_key.lock().unwrap();
if let Some(k) = master_secret.as_ref() {
return Ok(k.to_owned());
}
let mkey = MasterKey::get_or_create(&self.app_id, KEY_USER)?;
*master_secret = Some(mkey.clone());
Ok(mkey)
}
}

View File

@@ -0,0 +1,79 @@
use crate::encryption::{decrypt_data, encrypt_data};
use crate::error::Error::GenericError;
use crate::error::Result;
use base32::Alphabet;
use chacha20poly1305::aead::{Key, KeyInit, OsRng};
use chacha20poly1305::XChaCha20Poly1305;
use keyring::{Entry, Error};
use log::info;
const HUMAN_PREFIX: &str = "YKM_";
#[derive(Debug, Clone)]
pub(crate) struct MasterKey {
key: Key<XChaCha20Poly1305>,
}
impl MasterKey {
pub(crate) fn get_or_create(app_id: &str, user: &str) -> Result<Self> {
let id = format!("{app_id}.EncryptionKey");
let entry = Entry::new(&id, user)?;
let key = match entry.get_password() {
Ok(encoded) => {
let without_prefix = encoded.strip_prefix(HUMAN_PREFIX).unwrap_or(&encoded);
let key_bytes = base32::decode(Alphabet::Crockford {}, &without_prefix)
.ok_or(GenericError("Failed to decode master key".to_string()))?;
Key::<XChaCha20Poly1305>::clone_from_slice(key_bytes.as_slice())
}
Err(Error::NoEntry) => {
info!("Creating new master key");
let key = XChaCha20Poly1305::generate_key(OsRng);
let encoded = base32::encode(Alphabet::Crockford {}, key.as_slice());
let with_prefix = format!("{HUMAN_PREFIX}{encoded}");
entry.set_password(&with_prefix)?;
key
}
Err(e) => return Err(GenericError(e.to_string())),
};
Ok(Self { key })
}
pub(crate) fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
encrypt_data(data, &self.key)
}
pub(crate) fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
decrypt_data(data, &self.key)
}
#[cfg(test)]
pub(crate) fn test_key() -> Self {
let key: Key<XChaCha20Poly1305> = Key::<XChaCha20Poly1305>::clone_from_slice(
"00000000000000000000000000000000".as_bytes(),
);
Self { key }
}
}
#[cfg(test)]
mod tests {
use crate::error::Result;
use crate::master_key::MasterKey;
#[test]
fn test_master_key() -> Result<()> {
// Test out the master key
let mkey = MasterKey::test_key();
let encrypted = mkey.encrypt("hello".as_bytes())?;
let decrypted = mkey.decrypt(encrypted.as_slice()).unwrap();
assert_eq!(decrypted, "hello".as_bytes().to_vec());
let mkey = MasterKey::test_key();
let decrypted = mkey.decrypt(encrypted.as_slice()).unwrap();
assert_eq!(decrypted, "hello".as_bytes().to_vec());
Ok(())
}
}

View File

@@ -0,0 +1,114 @@
use crate::encryption::{decrypt_data, encrypt_data};
use crate::error::Error::InvalidHumanKey;
use crate::error::Result;
use base32::Alphabet;
use chacha20poly1305::aead::{Key, KeyInit, OsRng};
use chacha20poly1305::{KeySizeUser, XChaCha20Poly1305};
#[derive(Debug, Clone)]
pub struct WorkspaceKey {
key: Key<XChaCha20Poly1305>,
}
const HUMAN_PREFIX: &str = "YK";
impl WorkspaceKey {
pub(crate) fn to_human(&self) -> Result<String> {
let encoded = base32::encode(Alphabet::Crockford {}, self.key.as_slice());
let with_prefix = format!("{HUMAN_PREFIX}{encoded}");
let with_separators = with_prefix
.chars()
.collect::<Vec<_>>()
.chunks(6)
.map(|chunk| chunk.iter().collect::<String>())
.collect::<Vec<_>>()
.join("-");
Ok(with_separators)
}
#[allow(dead_code)]
pub(crate) fn from_human(human_key: &str) -> Result<Self> {
let without_prefix = human_key.strip_prefix(HUMAN_PREFIX).unwrap_or(human_key);
let without_separators = without_prefix.replace("-", "");
let key =
base32::decode(Alphabet::Crockford {}, &without_separators).ok_or(InvalidHumanKey)?;
if key.len() != XChaCha20Poly1305::key_size() {
return Err(InvalidHumanKey);
}
Ok(Self::from_raw_key(key.as_slice()))
}
pub(crate) fn from_raw_key(key: &[u8]) -> Self {
Self { key: Key::<XChaCha20Poly1305>::clone_from_slice(key) }
}
pub(crate) fn raw_key(&self) -> &[u8] {
self.key.as_slice()
}
pub(crate) fn create() -> Result<Self> {
let key = XChaCha20Poly1305::generate_key(OsRng);
Ok(Self::from_raw_key(key.as_slice()))
}
pub(crate) fn encrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
encrypt_data(data, &self.key)
}
pub(crate) fn decrypt(&self, data: &[u8]) -> Result<Vec<u8>> {
decrypt_data(data, &self.key)
}
#[cfg(test)]
pub(crate) fn test_key() -> Self {
Self::from_raw_key("f1a2d4b3c8e799af1456be3478a4c3f2".as_bytes())
}
}
#[cfg(test)]
mod tests {
use crate::error::Error::InvalidHumanKey;
use crate::error::Result;
use crate::workspace_key::WorkspaceKey;
#[test]
fn test_persisted_key() -> Result<()> {
let key = WorkspaceKey::test_key();
let encrypted = key.encrypt("hello".as_bytes())?;
assert_eq!(key.decrypt(encrypted.as_slice())?, "hello".as_bytes());
Ok(())
}
#[test]
fn test_human_format() -> Result<()> {
let key = WorkspaceKey::test_key();
let encrypted = key.encrypt("hello".as_bytes())?;
assert_eq!(key.decrypt(encrypted.as_slice())?, "hello".as_bytes());
let human = key.to_human()?;
assert_eq!(human, "YKCRRP-2CK46H-H36RSR-CMVKJE-B1CRRK-8D9PC9-JK6D1Q-71GK8R-SKCRS0");
assert_eq!(
WorkspaceKey::from_human(&human)?.decrypt(encrypted.as_slice())?,
"hello".as_bytes()
);
Ok(())
}
#[test]
fn test_from_human_invalid() -> Result<()> {
assert!(matches!(
WorkspaceKey::from_human(
"YKCRRP-2CK46H-H36RSR-CMVKJE-B1CRRK-8D9PC9-JK6D1Q-71GK8R-SKCRS0-H3X38D",
),
Err(InvalidHumanKey)
));
assert!(matches!(WorkspaceKey::from_human("bad-key",), Err(InvalidHumanKey)));
assert!(matches!(WorkspaceKey::from_human("",), Err(InvalidHumanKey)));
Ok(())
}
}