openpencil/desktop/src/credentials.rs
Danila Poyarkov 95a920197e feat(app): add cross-platform credential storage
- Store desktop secrets in native OS credential services through a narrow Tauri bridge

- Provide session-only and encrypted remembered browser stores with explicit switching

- Migrate legacy plaintext keys only after verified writes and cover failure recovery
2026-07-26 14:07:26 +03:00

323 lines
9.7 KiB
Rust

use keyring::{Entry, Error as KeyringError};
use serde::{Deserialize, Serialize};
const CREDENTIAL_SERVICE: &str = "net.dannote.open-pencil.credentials";
const AVAILABILITY_ACCOUNT: &str = "v1:system:default:availability";
const MAX_SEGMENT_LENGTH: usize = 64;
const MAX_CREDENTIAL_LENGTH: usize = 16 * 1024;
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialRef {
integration_id: String,
profile_id: String,
field: String,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CredentialError {
code: CredentialErrorCode,
message: &'static str,
}
#[derive(Clone, Copy, Debug, Serialize)]
#[serde(rename_all = "kebab-case")]
enum CredentialErrorCode {
InvalidReference,
InvalidValue,
Locked,
Unavailable,
Failed,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CredentialStatus {
Configured,
Missing,
Locked,
Unavailable,
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum CredentialStoreAvailability {
Available,
Locked,
Unavailable,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum BackendError {
Locked,
Unavailable,
Failed,
}
trait CredentialBackend {
fn read(&self, account: &str) -> Result<Option<String>, BackendError>;
fn write(&self, account: &str, value: &str) -> Result<(), BackendError>;
fn remove(&self, account: &str) -> Result<(), BackendError>;
}
struct NativeCredentialBackend;
impl NativeCredentialBackend {
fn entry(account: &str) -> Result<Entry, BackendError> {
Entry::new(CREDENTIAL_SERVICE, account).map_err(map_keyring_error)
}
}
impl CredentialBackend for NativeCredentialBackend {
fn read(&self, account: &str) -> Result<Option<String>, BackendError> {
match Self::entry(account)?.get_password() {
Ok(value) => Ok(Some(value)),
Err(KeyringError::NoEntry) => Ok(None),
Err(error) => Err(map_keyring_error(error)),
}
}
fn write(&self, account: &str, value: &str) -> Result<(), BackendError> {
Self::entry(account)?
.set_password(value)
.map_err(map_keyring_error)
}
fn remove(&self, account: &str) -> Result<(), BackendError> {
match Self::entry(account)?.delete_credential() {
Ok(()) | Err(KeyringError::NoEntry) => Ok(()),
Err(error) => Err(map_keyring_error(error)),
}
}
}
fn map_keyring_error(error: KeyringError) -> BackendError {
match error {
KeyringError::NoStorageAccess(_) => BackendError::Locked,
KeyringError::NoDefaultStore
| KeyringError::NotSupportedByStore(_)
| KeyringError::PlatformFailure(_) => BackendError::Unavailable,
_ => BackendError::Failed,
}
}
fn validate_segment(value: &str) -> bool {
!value.is_empty()
&& value.len() <= MAX_SEGMENT_LENGTH
&& value.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
})
}
fn account_for(reference: &CredentialRef) -> Result<String, CredentialError> {
if !validate_segment(&reference.integration_id)
|| !validate_segment(&reference.profile_id)
|| !validate_segment(&reference.field)
{
return Err(CredentialError {
code: CredentialErrorCode::InvalidReference,
message: "Credential reference is invalid",
});
}
Ok(format!(
"v1:{}:{}:{}",
reference.integration_id, reference.profile_id, reference.field
))
}
fn public_error(error: BackendError) -> CredentialError {
match error {
BackendError::Locked => CredentialError {
code: CredentialErrorCode::Locked,
message: "The system credential store is locked",
},
BackendError::Unavailable => CredentialError {
code: CredentialErrorCode::Unavailable,
message: "The system credential store is unavailable",
},
BackendError::Failed => CredentialError {
code: CredentialErrorCode::Failed,
message: "The credential operation failed",
},
}
}
fn read_with(
backend: &impl CredentialBackend,
reference: &CredentialRef,
) -> Result<Option<String>, CredentialError> {
let account = account_for(reference)?;
backend.read(&account).map_err(public_error)
}
fn write_with(
backend: &impl CredentialBackend,
reference: &CredentialRef,
value: &str,
) -> Result<(), CredentialError> {
if value.is_empty() || value.len() > MAX_CREDENTIAL_LENGTH {
return Err(CredentialError {
code: CredentialErrorCode::InvalidValue,
message: "Credential value is invalid",
});
}
let account = account_for(reference)?;
backend.write(&account, value).map_err(public_error)
}
fn remove_with(
backend: &impl CredentialBackend,
reference: &CredentialRef,
) -> Result<(), CredentialError> {
let account = account_for(reference)?;
backend.remove(&account).map_err(public_error)
}
#[tauri::command]
pub async fn credential_store_availability() -> Result<CredentialStoreAvailability, CredentialError>
{
tauri::async_runtime::spawn_blocking(|| {
match NativeCredentialBackend.read(AVAILABILITY_ACCOUNT) {
Ok(_) => Ok(CredentialStoreAvailability::Available),
Err(BackendError::Locked) => Ok(CredentialStoreAvailability::Locked),
Err(BackendError::Unavailable) => Ok(CredentialStoreAvailability::Unavailable),
Err(error) => Err(public_error(error)),
}
})
.await
.map_err(|_| public_error(BackendError::Failed))?
}
#[tauri::command]
pub async fn credential_status(
reference: CredentialRef,
) -> Result<CredentialStatus, CredentialError> {
tauri::async_runtime::spawn_blocking(move || {
match read_with(&NativeCredentialBackend, &reference) {
Ok(Some(_)) => Ok(CredentialStatus::Configured),
Ok(None) => Ok(CredentialStatus::Missing),
Err(CredentialError {
code: CredentialErrorCode::Locked,
..
}) => Ok(CredentialStatus::Locked),
Err(CredentialError {
code: CredentialErrorCode::Unavailable,
..
}) => Ok(CredentialStatus::Unavailable),
Err(error) => Err(error),
}
})
.await
.map_err(|_| public_error(BackendError::Failed))?
}
#[tauri::command]
pub async fn credential_read(reference: CredentialRef) -> Result<Option<String>, CredentialError> {
tauri::async_runtime::spawn_blocking(move || read_with(&NativeCredentialBackend, &reference))
.await
.map_err(|_| public_error(BackendError::Failed))?
}
#[tauri::command]
pub async fn credential_write(
reference: CredentialRef,
value: String,
) -> Result<(), CredentialError> {
tauri::async_runtime::spawn_blocking(move || {
write_with(&NativeCredentialBackend, &reference, &value)
})
.await
.map_err(|_| public_error(BackendError::Failed))?
}
#[tauri::command]
pub async fn credential_remove(reference: CredentialRef) -> Result<(), CredentialError> {
tauri::async_runtime::spawn_blocking(move || remove_with(&NativeCredentialBackend, &reference))
.await
.map_err(|_| public_error(BackendError::Failed))?
}
#[cfg(test)]
mod tests {
use super::*;
use std::{collections::HashMap, sync::Mutex};
#[derive(Default)]
struct MemoryBackend(Mutex<HashMap<String, String>>);
impl CredentialBackend for MemoryBackend {
fn read(&self, account: &str) -> Result<Option<String>, BackendError> {
Ok(self
.0
.lock()
.ok()
.and_then(|values| values.get(account).cloned()))
}
fn write(&self, account: &str, value: &str) -> Result<(), BackendError> {
self.0
.lock()
.map_err(|_| BackendError::Failed)?
.insert(account.to_owned(), value.to_owned());
Ok(())
}
fn remove(&self, account: &str) -> Result<(), BackendError> {
self.0
.lock()
.map_err(|_| BackendError::Failed)?
.remove(account);
Ok(())
}
}
fn reference() -> CredentialRef {
CredentialRef {
integration_id: "openai-compatible".to_owned(),
profile_id: "default".to_owned(),
field: "api-key".to_owned(),
}
}
#[test]
fn creates_stable_versioned_account_names() {
assert_eq!(
account_for(&reference()).expect("valid reference"),
"v1:openai-compatible:default:api-key"
);
}
#[test]
fn rejects_untrusted_account_segments() {
let invalid = CredentialRef {
integration_id: "../../other-app".to_owned(),
..reference()
};
assert!(account_for(&invalid).is_err());
}
#[test]
fn rejects_empty_credential_values() {
let backend = MemoryBackend::default();
assert!(write_with(&backend, &reference(), "").is_err());
}
#[test]
fn reads_writes_and_removes_through_backend_contract() {
let backend = MemoryBackend::default();
let reference = reference();
assert_eq!(read_with(&backend, &reference).expect("initial read"), None);
write_with(&backend, &reference, "secret").expect("write");
assert_eq!(
read_with(&backend, &reference).expect("configured read"),
Some("secret".to_owned())
);
remove_with(&backend, &reference).expect("remove");
assert_eq!(read_with(&backend, &reference).expect("removed read"), None);
}
}