fix(auth): store bearer tokens in native credentials

This commit is contained in:
2026-07-10 07:57:44 -04:00
parent 34ac842078
commit 94afa23a69
26 changed files with 2316 additions and 286 deletions
+18 -1
View File
@@ -7,6 +7,8 @@ rust-version.workspace = true
[dependencies]
base64.workspace = true
cap-fs-ext.workspace = true
cap-std.workspace = true
chacha20poly1305.workspace = true
ed25519-dalek.workspace = true
ely_domain = { path = "../ely_domain" }
@@ -15,7 +17,7 @@ getrandom.workspace = true
hkdf.workspace = true
hmac.workspace = true
hpke.workspace = true
keyring.workspace = true
keyring-core.workspace = true
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
@@ -24,5 +26,20 @@ ureq = { workspace = true, features = ["json"] }
uuid = { workspace = true }
zeroize.workspace = true
[dev-dependencies]
tempfile.workspace = true
[target.'cfg(unix)'.dependencies]
rustix = { workspace = true, features = ["process"] }
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows-native-keyring-store = "1.1.0"
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))'.dependencies]
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
[lints]
workspace = true
+253 -90
View File
@@ -1,31 +1,56 @@
use std::{
fs,
io::{self, ErrorKind},
fmt,
path::{Path, PathBuf},
};
use crate::error::SyncClientError;
use ely_domain::ProfileId;
use fs2::FileExt;
use zeroize::Zeroizing;
/// Better Auth bearer token issued by `https://<base>/api/auth/*`. The
/// token grants access to the per-user `withAuthenticatedApiControls`
/// routes and, once the device is bound to the session, to the
/// `withApprovedDeviceApiControls` routes used by sync.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BearerToken(String);
use crate::{
auth_files::{
ensure_migration_marker, migration_marker_exists, open_lock_file, read_legacy_bytes,
remove_file_if_present,
},
credential_store::{clear_secret, load_secret, save_secret},
error::SyncClientError,
};
const KEYCHAIN_SERVICE: &str = "com.elydora.ely-browser.auth.bearer.v1";
const MIN_BEARER_TOKEN_BYTES: usize = 32;
const MAX_BEARER_TOKEN_BYTES: usize = 2560;
/// Better Auth bearer token issued by `https://<base>/api/auth/*`.
pub struct BearerToken(Zeroizing<String>);
impl Clone for BearerToken {
fn clone(&self) -> Self {
Self(Zeroizing::new(self.as_str().to_string()))
}
}
impl fmt::Debug for BearerToken {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("BearerToken([REDACTED])")
}
}
impl PartialEq for BearerToken {
fn eq(&self, other: &Self) -> bool {
self.as_str() == other.as_str()
}
}
impl Eq for BearerToken {}
impl BearerToken {
/// Construct a token from an existing string. Trims whitespace and
/// enforces the same character envelope the worker validates so
/// obviously-malformed tokens fail before we hit the network.
pub fn new(value: impl Into<String>) -> Result<Self, SyncClientError> {
let value = value.into();
let value = Zeroizing::new(value.into());
let trimmed = value.trim();
if trimmed.is_empty() || !is_better_auth_bearer(trimmed) {
return Err(SyncClientError::TokenStorage(
"bearer token is not a Better Auth session token".to_string(),
));
return Err(storage_error("bearer token is not a Better Auth session token"));
}
Ok(Self(trimmed.to_string()))
Ok(Self(Zeroizing::new(trimmed.to_string())))
}
pub fn as_str(&self) -> &str {
@@ -34,7 +59,7 @@ impl BearerToken {
}
fn is_better_auth_bearer(token: &str) -> bool {
let length_ok = (32..=4096).contains(&token.len());
let length_ok = (MIN_BEARER_TOKEN_BYTES..=MAX_BEARER_TOKEN_BYTES).contains(&token.len());
let charset_ok = token
.as_bytes()
.iter()
@@ -42,100 +67,238 @@ fn is_better_auth_bearer(token: &str) -> bool {
length_ok && charset_ok
}
/// File-backed bearer token store. Lives in the per-profile data
/// directory so a private window never inherits the standard
/// profile's session — same isolation the rest of the runtime
/// enforces. Writes go through a temp-rename so a partial write
/// can't corrupt the persisted token.
trait CredentialBackend {
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String>;
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String>;
fn clear(&self, service: &str, account: &str) -> Result<(), String>;
}
struct NativeCredentialBackend;
impl CredentialBackend for NativeCredentialBackend {
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
load_secret(service, account)
}
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
save_secret(service, account, secret)
}
fn clear(&self, service: &str, account: &str) -> Result<(), String> {
clear_secret(service, account)
}
}
/// Profile-scoped native credential store with one-time plaintext-file migration.
#[derive(Clone, Debug)]
pub struct BearerTokenStore {
path: PathBuf,
account: String,
lock_path: PathBuf,
migration_marker_path: PathBuf,
legacy_paths: Vec<PathBuf>,
}
impl BearerTokenStore {
pub fn new(path: PathBuf) -> Self {
Self { path }
pub fn new(profile_id: &ProfileId, profile_data_dir: &Path) -> Self {
let sync_dir = profile_data_dir.join("sync");
Self {
account: profile_id.as_str().to_string(),
lock_path: sync_dir.join("bearer.lock"),
migration_marker_path: sync_dir.join("bearer.migrated"),
legacy_paths: vec![sync_dir.join("bearer.token")],
}
}
pub fn path(&self) -> &Path {
&self.path
pub fn with_legacy_path(mut self, path: PathBuf) -> Self {
if !self.legacy_paths.contains(&path) {
self.legacy_paths.push(path);
}
self
}
pub fn load(&self) -> Result<Option<BearerToken>, SyncClientError> {
match fs::read_to_string(&self.path) {
Ok(contents) => {
if contents.trim().is_empty() {
return Ok(None);
}
BearerToken::new(contents).map(Some)
}
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
}
self.load_with(&NativeCredentialBackend)
}
pub fn save(&self, token: &BearerToken) -> Result<(), SyncClientError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent).map_err(io_err)?;
}
let tmp = self.path.with_extension("tmp");
fs::write(&tmp, token.as_str()).map_err(io_err)?;
fs::rename(&tmp, &self.path).map_err(io_err)?;
Ok(())
self.save_with(&NativeCredentialBackend, token)
}
pub fn clear(&self) -> Result<(), SyncClientError> {
remove_file_if_present(&self.path)?;
remove_file_if_present(&self.path.with_extension("tmp"))
self.clear_with(&NativeCredentialBackend)
}
pub fn clear_if_matches(&self, token: &BearerToken) -> Result<bool, SyncClientError> {
self.clear_if_matches_with(&NativeCredentialBackend, token)
}
fn clear_if_matches_with<B: CredentialBackend>(
&self,
backend: &B,
token: &BearerToken,
) -> Result<bool, SyncClientError> {
self.with_lock(|| {
let current = load_backend_token(backend, &self.account)?;
ensure_migration_marker(&self.migration_marker_path)?;
self.cleanup_legacy_paths()?;
if current.as_ref().is_some_and(|current| current != token) {
return Ok(false);
}
backend.clear(KEYCHAIN_SERVICE, &self.account).map_err(storage_error)?;
Ok(true)
})
}
pub fn clear_legacy_files(&self) -> Result<(), SyncClientError> {
self.with_lock(|| self.cleanup_legacy_paths())
}
fn load_with<B: CredentialBackend>(
&self,
backend: &B,
) -> Result<Option<BearerToken>, SyncClientError> {
self.with_lock(|| self.load_locked(backend))
}
fn save_with<B: CredentialBackend>(
&self,
backend: &B,
token: &BearerToken,
) -> Result<(), SyncClientError> {
self.with_lock(|| {
ensure_migration_marker(&self.migration_marker_path)?;
self.cleanup_legacy_paths()?;
backend
.save(KEYCHAIN_SERVICE, &self.account, token.as_str().as_bytes())
.map_err(storage_error)
})
}
fn clear_with<B: CredentialBackend>(&self, backend: &B) -> Result<(), SyncClientError> {
self.with_lock(|| {
ensure_migration_marker(&self.migration_marker_path)?;
self.cleanup_legacy_paths()?;
backend.clear(KEYCHAIN_SERVICE, &self.account).map_err(storage_error)
})
}
fn load_locked<B: CredentialBackend>(
&self,
backend: &B,
) -> Result<Option<BearerToken>, SyncClientError> {
if let Some(token) = load_backend_token(backend, &self.account)? {
ensure_migration_marker(&self.migration_marker_path)?;
self.cleanup_legacy_paths()?;
return Ok(Some(token));
}
if migration_marker_exists(&self.migration_marker_path)? {
self.cleanup_legacy_paths()?;
return Ok(None);
}
let mut candidate = None;
for path in &self.legacy_paths {
if let Some(token) = read_legacy_token(path)? {
candidate = Some(token);
break;
}
}
let Some(token) = candidate else {
self.cleanup_legacy_tmp_files()?;
return Ok(None);
};
backend
.save(KEYCHAIN_SERVICE, &self.account, token.as_str().as_bytes())
.map_err(storage_error)?;
if let Err(error) = verify_backend_token(backend, &self.account, &token) {
return Err(rollback_migration(backend, &self.account, error));
}
if let Err(error) = ensure_migration_marker(&self.migration_marker_path) {
if matches!(migration_marker_exists(&self.migration_marker_path), Ok(false)) {
return Err(rollback_migration(backend, &self.account, error));
}
return Err(error);
}
self.cleanup_legacy_paths()?;
Ok(Some(token))
}
fn cleanup_legacy_paths(&self) -> Result<(), SyncClientError> {
for path in &self.legacy_paths {
remove_file_if_present(path)?;
remove_file_if_present(&path.with_extension("tmp"))?;
}
Ok(())
}
fn cleanup_legacy_tmp_files(&self) -> Result<(), SyncClientError> {
for path in &self.legacy_paths {
remove_file_if_present(&path.with_extension("tmp"))?;
}
Ok(())
}
fn with_lock<T>(
&self,
operation: impl FnOnce() -> Result<T, SyncClientError>,
) -> Result<T, SyncClientError> {
let lock = open_lock_file(&self.lock_path)?;
lock.lock_exclusive().map_err(|error| storage_error(error.to_string()))?;
operation()
}
}
fn remove_file_if_present(path: &Path) -> Result<(), SyncClientError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
fn load_backend_token<B: CredentialBackend>(
backend: &B,
account: &str,
) -> Result<Option<BearerToken>, SyncClientError> {
let Some(secret) = backend.load(KEYCHAIN_SERVICE, account).map_err(storage_error)? else {
return Ok(None);
};
let value =
std::str::from_utf8(&secret).map_err(|error| storage_error(error.to_string()))?.to_string();
BearerToken::new(value).map(Some)
}
fn verify_backend_token<B: CredentialBackend>(
backend: &B,
account: &str,
token: &BearerToken,
) -> Result<(), SyncClientError> {
let stored = load_backend_token(backend, account)?;
if stored.as_ref() != Some(token) {
return Err(storage_error("native credential read-back did not match"));
}
Ok(())
}
fn rollback_migration<B: CredentialBackend>(
backend: &B,
account: &str,
cause: SyncClientError,
) -> SyncClientError {
match backend.clear(KEYCHAIN_SERVICE, account) {
Ok(()) => cause,
Err(rollback_error) => {
storage_error(format!("{cause}; native credential rollback failed: {rollback_error}"))
}
}
}
fn io_err(error: io::Error) -> SyncClientError {
SyncClientError::TokenStorage(error.to_string())
fn read_legacy_token(path: &Path) -> Result<Option<BearerToken>, SyncClientError> {
let Some(bytes) = read_legacy_bytes(path, MAX_BEARER_TOKEN_BYTES + 1)? else {
return Ok(None);
};
let value =
std::str::from_utf8(&bytes).map_err(|error| storage_error(error.to_string()))?.to_string();
BearerToken::new(value).map(Some)
}
fn storage_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::BearerCredentialStorage(message.into())
}
#[cfg(test)]
mod tests {
use super::*;
use std::env::temp_dir;
#[test]
fn rejects_obviously_broken_tokens() {
assert!(BearerToken::new("").is_err());
assert!(BearerToken::new(" ").is_err());
assert!(BearerToken::new("short").is_err());
// Spaces are not in the Better Auth bearer charset.
assert!(BearerToken::new(format!("{}aaa bb", "a".repeat(40))).is_err());
}
#[test]
fn accepts_better_auth_shape() -> Result<(), SyncClientError> {
let token = format!("{}-{}_{}", "a".repeat(20), "b".repeat(20), "c".repeat(20));
BearerToken::new(token).map(|_| ())
}
#[test]
fn token_store_round_trips() -> Result<(), SyncClientError> {
let dir = temp_dir().join(format!("ely-token-{}", uuid::Uuid::now_v7().simple()));
let store = BearerTokenStore::new(dir.join("token"));
let token = BearerToken::new("a".repeat(64))?;
assert_eq!(store.load()?, None);
store.save(&token)?;
assert_eq!(store.load()?, Some(token.clone()));
fs::write(store.path().with_extension("tmp"), token.as_str()).map_err(io_err)?;
store.clear()?;
assert_eq!(store.load()?, None);
assert!(!store.path().with_extension("tmp").exists());
Ok(())
}
}
#[path = "auth_tests.rs"]
mod tests;
+296
View File
@@ -0,0 +1,296 @@
use std::{
fs,
io::{self, ErrorKind, Read, Write},
path::{Component, Path, PathBuf},
};
use cap_fs_ext::{
DirExt, FollowSymlinks, MetadataExt as CrossPlatformMetadataExt, OpenOptionsFollowExt,
};
use cap_std::{
ambient_authority,
fs::{Dir, File, OpenOptions, Permissions},
};
use zeroize::Zeroizing;
use crate::error::SyncClientError;
const MARKER_CONTENT: &[u8] = b"ely-bearer-migration-v1\n";
pub(super) fn read_legacy_bytes(
path: &Path,
maximum_bytes: usize,
) -> Result<Option<Zeroizing<Vec<u8>>>, SyncClientError> {
let Some((directory, name)) = open_parent(path, false)? else {
return Ok(None);
};
let Some(file) = open_existing(&directory, name)? else {
return Ok(None);
};
validate_private_file(&file)?;
let mut bytes = Zeroizing::new(Vec::new());
file.into_std().take((maximum_bytes + 1) as u64).read_to_end(&mut bytes).map_err(storage_io)?;
if bytes.len() > maximum_bytes {
return Err(storage_error("legacy bearer token is too large"));
}
Ok(Some(bytes))
}
pub(super) fn ensure_migration_marker(path: &Path) -> Result<(), SyncClientError> {
let (directory, name) = open_parent(path, true)?
.ok_or_else(|| storage_error("bearer migration marker parent is missing"))?;
if let Some(file) = open_existing(&directory, name)? {
return validate_migration_marker(file);
}
let temporary_path = marker_temporary_path(path)?;
remove_file_if_present(&temporary_path)?;
let temporary_name = temporary_path
.file_name()
.ok_or_else(|| storage_error("bearer migration marker temp name is missing"))?;
let mut options = private_open_options();
options.write(true).create_new(true);
let mut file = directory.open_with(temporary_name, &options).map_err(storage_io)?;
validate_private_file(&file)?;
set_private_file_permissions(&file)?;
file.write_all(MARKER_CONTENT).map_err(storage_io)?;
file.sync_all().map_err(storage_io)?;
drop(file);
directory.rename(temporary_name, &directory, name).map_err(storage_io)?;
sync_directory(&directory)
}
pub(super) fn migration_marker_exists(path: &Path) -> Result<bool, SyncClientError> {
let Some((directory, name)) = open_parent(path, false)? else {
return Ok(false);
};
let Some(file) = open_existing(&directory, name)? else {
return Ok(false);
};
validate_migration_marker(file)?;
Ok(true)
}
fn validate_migration_marker(file: File) -> Result<(), SyncClientError> {
validate_private_file(&file)?;
let mut bytes = Vec::new();
file.into_std()
.take((MARKER_CONTENT.len() + 1) as u64)
.read_to_end(&mut bytes)
.map_err(storage_io)?;
if bytes != MARKER_CONTENT {
return Err(storage_error("bearer migration marker content is invalid"));
}
Ok(())
}
fn marker_temporary_path(path: &Path) -> Result<PathBuf, SyncClientError> {
let mut name = path
.file_name()
.ok_or_else(|| storage_error("bearer migration marker name is missing"))?
.to_os_string();
name.push(".tmp");
Ok(path.with_file_name(name))
}
pub(super) fn remove_file_if_present(path: &Path) -> Result<(), SyncClientError> {
let Some((directory, name)) = open_parent(path, false)? else {
return Ok(());
};
let metadata = match directory.symlink_metadata(name) {
Ok(metadata) => metadata,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(storage_io(error)),
};
if metadata.is_symlink() {
return directory.remove_file_or_symlink(name).map_err(storage_io);
}
let file = open_existing(&directory, name)?.ok_or_else(|| {
storage_error("legacy bearer token disappeared during cleanup validation")
})?;
validate_private_file(&file)?;
drop(file);
directory.remove_file_or_symlink(name).map_err(storage_io)
}
pub(super) fn open_lock_file(path: &Path) -> Result<fs::File, SyncClientError> {
let (directory, name) =
open_parent(path, true)?.ok_or_else(|| storage_error("bearer lock parent is missing"))?;
let mut options = private_open_options();
options.read(true).write(true).create(true);
let file = directory.open_with(name, &options).map_err(storage_io)?;
validate_private_file(&file)?;
set_private_file_permissions(&file)?;
Ok(file.into_std())
}
fn open_parent(
path: &Path,
create: bool,
) -> Result<Option<(Dir, &std::ffi::OsStr)>, SyncClientError> {
let parent = path.parent().ok_or_else(|| storage_error("credential file parent is missing"))?;
let name = path.file_name().ok_or_else(|| storage_error("credential file name is missing"))?;
let Some(directory) = open_directory_nofollow(parent, create)? else {
return Ok(None);
};
validate_private_directory(&directory)?;
set_private_directory_permissions(&directory)?;
Ok(Some((directory, name)))
}
fn open_directory_nofollow(path: &Path, create: bool) -> Result<Option<Dir>, SyncClientError> {
let mut root = PathBuf::new();
let mut names = Vec::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => root.push(prefix.as_os_str()),
Component::RootDir => root.push(std::path::MAIN_SEPARATOR_STR),
Component::Normal(name) => names.push(name.to_os_string()),
Component::CurDir => {}
Component::ParentDir => {
return Err(storage_error("credential path traversal is invalid"));
}
}
}
if root.as_os_str().is_empty() {
return Err(storage_error("credential paths must be absolute"));
}
let mut directory = Dir::open_ambient_dir(root, ambient_authority()).map_err(storage_io)?;
#[cfg(windows)]
let strict_component = names.len().saturating_sub(4);
#[cfg(windows)]
let mut require_nofollow = false;
#[cfg(windows)]
let mut component_index = 0;
#[cfg(not(windows))]
let mut require_nofollow = directory_requires_nofollow(&directory)?;
for name in names {
#[cfg(windows)]
{
require_nofollow |= component_index >= strict_component;
component_index += 1;
}
if create {
match directory.create_dir(&name) {
Ok(()) => {}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
Err(error) => return Err(storage_io(error)),
}
}
let opened = match directory.open_dir_nofollow(&name) {
Ok(directory) => directory,
Err(_) if !require_nofollow => match directory.open_dir(&name) {
Ok(directory) => directory,
Err(error) if !create && error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(storage_io(error)),
},
Err(error) if !create && error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(storage_io(error)),
};
#[cfg(not(windows))]
{
require_nofollow |= directory_requires_nofollow(&opened)?;
}
directory = opened;
}
Ok(Some(directory))
}
#[cfg(unix)]
fn directory_requires_nofollow(directory: &Dir) -> Result<bool, SyncClientError> {
use cap_std::fs::MetadataExt;
let metadata = directory.dir_metadata().map_err(storage_io)?;
Ok(metadata.uid() == rustix::process::geteuid().as_raw() || metadata.mode() & 0o022 != 0)
}
#[cfg(not(any(unix, windows)))]
fn directory_requires_nofollow(_directory: &Dir) -> Result<bool, SyncClientError> {
Ok(true)
}
fn open_existing(directory: &Dir, name: &std::ffi::OsStr) -> Result<Option<File>, SyncClientError> {
let mut options = private_open_options();
options.read(true);
match directory.open_with(name, &options) {
Ok(file) => Ok(Some(file)),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
Err(error) => Err(storage_io(error)),
}
}
fn private_open_options() -> OpenOptions {
let mut options = OpenOptions::new();
options.follow(FollowSymlinks::No);
#[cfg(unix)]
{
use cap_std::fs::OpenOptionsExt;
options.mode(0o600);
}
options
}
fn validate_private_directory(directory: &Dir) -> Result<(), SyncClientError> {
let metadata = directory.dir_metadata().map_err(storage_io)?;
if !metadata.is_dir() {
return Err(storage_error("credential file parent is not a directory"));
}
#[cfg(unix)]
{
use cap_std::fs::MetadataExt;
if metadata.uid() != rustix::process::geteuid().as_raw() {
return Err(storage_error("credential file parent ownership is invalid"));
}
}
Ok(())
}
fn validate_private_file(file: &File) -> Result<(), SyncClientError> {
let metadata = file.metadata().map_err(storage_io)?;
if !metadata.is_file() || CrossPlatformMetadataExt::nlink(&metadata) != 1 {
return Err(storage_error("credential file link state is invalid"));
}
#[cfg(unix)]
{
use cap_std::fs::MetadataExt;
if metadata.uid() != rustix::process::geteuid().as_raw() {
return Err(storage_error("credential file ownership is invalid"));
}
}
Ok(())
}
fn set_private_directory_permissions(directory: &Dir) -> Result<(), SyncClientError> {
#[cfg(unix)]
directory
.set_permissions(".", Permissions::from_std(fs::Permissions::from_mode(0o700)))
.map_err(storage_io)?;
Ok(())
}
fn set_private_file_permissions(file: &File) -> Result<(), SyncClientError> {
#[cfg(unix)]
file.set_permissions(Permissions::from_std(fs::Permissions::from_mode(0o600)))
.map_err(storage_io)?;
Ok(())
}
#[cfg(unix)]
fn sync_directory(directory: &Dir) -> Result<(), SyncClientError> {
directory.try_clone().map_err(storage_io)?.into_std_file().sync_all().map_err(storage_io)
}
#[cfg(not(unix))]
fn sync_directory(_directory: &Dir) -> Result<(), SyncClientError> {
Ok(())
}
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
fn storage_io(error: io::Error) -> SyncClientError {
storage_error(error.to_string())
}
fn storage_error(message: impl Into<String>) -> SyncClientError {
SyncClientError::BearerCredentialStorage(message.into())
}
+483
View File
@@ -0,0 +1,483 @@
use std::{
collections::BTreeMap,
fs,
sync::{Mutex, MutexGuard},
};
use ely_domain::ProfileId;
use zeroize::Zeroizing;
use super::*;
#[derive(Default)]
struct MemoryCredentialBackend {
values: Mutex<BTreeMap<(String, String), Vec<u8>>>,
}
impl CredentialBackend for MemoryCredentialBackend {
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
Ok(self
.values()
.get(&(service.to_string(), account.to_string()))
.cloned()
.map(Zeroizing::new))
}
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
self.values().insert((service.to_string(), account.to_string()), secret.to_vec());
Ok(())
}
fn clear(&self, service: &str, account: &str) -> Result<(), String> {
self.values().remove(&(service.to_string(), account.to_string()));
Ok(())
}
}
impl MemoryCredentialBackend {
fn values(&self) -> MutexGuard<'_, BTreeMap<(String, String), Vec<u8>>> {
self.values.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
struct DroppingCredentialBackend;
impl CredentialBackend for DroppingCredentialBackend {
fn load(&self, _service: &str, _account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
Ok(None)
}
fn save(&self, _service: &str, _account: &str, _secret: &[u8]) -> Result<(), String> {
Ok(())
}
fn clear(&self, _service: &str, _account: &str) -> Result<(), String> {
Ok(())
}
}
struct SaveFailureBackend;
impl CredentialBackend for SaveFailureBackend {
fn load(&self, _service: &str, _account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
Ok(None)
}
fn save(&self, _service: &str, _account: &str, _secret: &[u8]) -> Result<(), String> {
Err("credential backend is locked".to_string())
}
fn clear(&self, _service: &str, _account: &str) -> Result<(), String> {
Ok(())
}
}
#[derive(Default)]
struct ClearFailureBackend {
values: Mutex<BTreeMap<(String, String), Vec<u8>>>,
}
impl CredentialBackend for ClearFailureBackend {
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
Ok(self
.values
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&(service.to_string(), account.to_string()))
.cloned()
.map(Zeroizing::new))
}
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
self.values
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert((service.to_string(), account.to_string()), secret.to_vec());
Ok(())
}
fn clear(&self, _service: &str, _account: &str) -> Result<(), String> {
Err("credential backend is locked".to_string())
}
}
#[test]
fn validates_token_shape_boundaries() -> Result<(), SyncClientError> {
assert!(BearerToken::new("").is_err());
assert!(BearerToken::new("short").is_err());
assert!(BearerToken::new(format!("{}aaa bb", "a".repeat(40))).is_err());
BearerToken::new("a".repeat(32))?;
BearerToken::new("a".repeat(MAX_BEARER_TOKEN_BYTES))?;
assert!(BearerToken::new("a".repeat(MAX_BEARER_TOKEN_BYTES + 1)).is_err());
Ok(())
}
#[test]
fn debug_output_redacts_the_token() -> Result<(), SyncClientError> {
let token = BearerToken::new("secret-session-token".repeat(3))?;
let output = format!("{token:?}");
assert_eq!(output, "BearerToken([REDACTED])");
assert!(!output.contains(token.as_str()));
Ok(())
}
#[test]
fn keychain_entry_wins_and_cleans_every_legacy_path() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
let old = directory.path().join("old/bearer.token");
write_token(&stable, 'a')?;
write_token(&old, 'b')?;
write_token(&stable.with_extension("tmp"), 'c')?;
write_token(&old.with_extension("tmp"), 'd')?;
let backend = MemoryCredentialBackend::default();
let expected = BearerToken::new("e".repeat(64))?;
backend.save(KEYCHAIN_SERVICE, profile_id.as_str(), expected.as_str().as_bytes())?;
let store = BearerTokenStore::new(&profile_id, directory.path()).with_legacy_path(old.clone());
assert_eq!(store.load_with(&backend)?, Some(expected));
for path in [stable, old] {
assert!(!path.exists());
assert!(!path.with_extension("tmp").exists());
}
assert!(migration_marker_path(directory.path()).exists());
Ok(())
}
#[test]
fn stable_legacy_token_precedes_old_default_source() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
let old = directory.path().join("old/bearer.token");
write_token(&stable, 'a')?;
write_token(&old, 'b')?;
let backend = MemoryCredentialBackend::default();
let store = BearerTokenStore::new(&profile_id, directory.path()).with_legacy_path(old.clone());
assert_eq!(
store.load_with(&backend)?.as_ref().map(BearerToken::as_str),
Some("a".repeat(64).as_str())
);
assert!(!stable.exists());
assert!(!old.exists());
Ok(())
}
#[test]
fn failed_keychain_readback_preserves_the_legacy_source() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
write_token(&stable, 'a')?;
let store = BearerTokenStore::new(&profile_id, directory.path());
assert!(store.load_with(&DroppingCredentialBackend).is_err());
assert!(stable.exists());
Ok(())
}
#[test]
fn existing_keychain_entry_finishes_a_crashed_migration() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
write_token(&stable, 'a')?;
let backend = MemoryCredentialBackend::default();
backend.save(KEYCHAIN_SERVICE, profile_id.as_str(), "a".repeat(64).as_bytes())?;
let store = BearerTokenStore::new(&profile_id, directory.path());
assert!(store.load_with(&backend)?.is_some());
assert!(!stable.exists());
Ok(())
}
#[test]
fn profile_accounts_are_isolated_and_clear_is_idempotent() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let first_id = ProfileId::new();
let second_id = ProfileId::new();
let first = BearerTokenStore::new(&first_id, &directory.path().join("first"));
let second = BearerTokenStore::new(&second_id, &directory.path().join("second"));
let first_token = BearerToken::new("a".repeat(64))?;
let second_token = BearerToken::new("b".repeat(64))?;
let backend = MemoryCredentialBackend::default();
first.save_with(&backend, &first_token)?;
second.save_with(&backend, &second_token)?;
assert_eq!(first.load_with(&backend)?, Some(first_token));
assert_eq!(second.load_with(&backend)?, Some(second_token.clone()));
first.clear_with(&backend)?;
first.clear_with(&backend)?;
assert_eq!(first.load_with(&backend)?, None);
assert_eq!(second.load_with(&backend)?, Some(second_token));
Ok(())
}
#[test]
fn conditional_clear_preserves_a_newer_profile_token() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let store = BearerTokenStore::new(&profile_id, directory.path());
let old = BearerToken::new("a".repeat(64))?;
let current = BearerToken::new("b".repeat(64))?;
let backend = MemoryCredentialBackend::default();
store.save_with(&backend, &old)?;
store.save_with(&backend, &current)?;
assert!(!store.clear_if_matches_with(&backend, &old)?);
assert_eq!(store.load_with(&backend)?, Some(current.clone()));
assert!(store.clear_if_matches_with(&backend, &current)?);
assert_eq!(store.load_with(&backend)?, None);
Ok(())
}
#[test]
fn clear_failure_preserves_the_credential_for_retry() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let store = BearerTokenStore::new(&profile_id, directory.path());
let token = BearerToken::new("a".repeat(64))?;
let backend = ClearFailureBackend::default();
store.save_with(&backend, &token)?;
assert!(store.clear_with(&backend).is_err());
assert_eq!(store.load_with(&backend)?, Some(token));
Ok(())
}
#[test]
fn migration_marker_blocks_recreated_legacy_tokens() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let old = directory.path().join("old/bearer.token");
let store = BearerTokenStore::new(&profile_id, directory.path()).with_legacy_path(old.clone());
let backend = MemoryCredentialBackend::default();
let token = BearerToken::new("a".repeat(64))?;
store.save_with(&backend, &token)?;
store.clear_with(&backend)?;
write_token(&old, 'b')?;
assert_eq!(store.load_with(&backend)?, None);
assert!(!old.exists());
Ok(())
}
#[cfg(unix)]
#[test]
fn cleanup_failure_prevents_a_direct_keychain_commit() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
let target = directory.path().join("target.tmp");
write_token(&target, 'b')?;
fs::create_dir_all(stable.parent().ok_or("missing stable parent")?)?;
fs::hard_link(&target, stable.with_extension("tmp"))?;
let store = BearerTokenStore::new(&profile_id, directory.path());
let backend = MemoryCredentialBackend::default();
let token = BearerToken::new("a".repeat(64))?;
assert!(store.save_with(&backend, &token).is_err());
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_none());
Ok(())
}
#[test]
fn direct_save_failure_retires_stale_legacy_credentials() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
write_token(&stable, 'a')?;
let store = BearerTokenStore::new(&profile_id, directory.path());
let token = BearerToken::new("b".repeat(64))?;
assert!(store.save_with(&SaveFailureBackend, &token).is_err());
assert!(!stable.exists());
assert!(migration_marker_path(directory.path()).exists());
assert_eq!(store.load_with(&MemoryCredentialBackend::default())?, None);
Ok(())
}
#[cfg(unix)]
#[test]
fn migration_cleanup_failure_keeps_the_committed_keychain() -> Result<(), Box<dyn std::error::Error>>
{
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
let target = directory.path().join("target.tmp");
write_token(&stable, 'a')?;
write_token(&target, 'b')?;
fs::hard_link(&target, stable.with_extension("tmp"))?;
let store = BearerTokenStore::new(&profile_id, directory.path());
let backend = MemoryCredentialBackend::default();
assert!(store.load_with(&backend).is_err());
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_some());
assert!(migration_marker_path(directory.path()).exists());
fs::remove_file(stable.with_extension("tmp"))?;
assert_eq!(
store.load_with(&backend)?.as_ref().map(BearerToken::as_str),
Some("a".repeat(64).as_str())
);
Ok(())
}
#[test]
fn invalid_migration_marker_never_retires_a_legacy_token() -> Result<(), Box<dyn std::error::Error>>
{
for marker in [b"".as_slice(), b"partial".as_slice()] {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
let marker_path = migration_marker_path(directory.path());
write_token(&stable, 'a')?;
fs::write(&marker_path, marker)?;
let store = BearerTokenStore::new(&profile_id, directory.path());
let backend = MemoryCredentialBackend::default();
assert!(store.load_with(&backend).is_err());
assert!(stable.exists());
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_none());
}
Ok(())
}
#[test]
fn crashed_marker_temp_is_replaced_atomically() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let marker = migration_marker_path(directory.path());
let marker_tmp = marker.with_file_name("bearer.migrated.tmp");
fs::create_dir_all(marker.parent().ok_or("missing marker parent")?)?;
fs::write(&marker_tmp, b"partial")?;
let store = BearerTokenStore::new(&profile_id, directory.path());
store.clear_with(&MemoryCredentialBackend::default())?;
assert_eq!(fs::read(marker)?, b"ely-bearer-migration-v1\n");
assert!(!marker_tmp.exists());
Ok(())
}
#[test]
fn stale_tmp_is_removed_without_becoming_a_token() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let stable = stable_legacy_path(directory.path());
write_token(&stable.with_extension("tmp"), 'a')?;
let store = BearerTokenStore::new(&profile_id, directory.path());
assert_eq!(store.load_with(&MemoryCredentialBackend::default())?, None);
assert!(!stable.with_extension("tmp").exists());
Ok(())
}
#[cfg(unix)]
#[test]
fn symlink_and_hardlink_legacy_sources_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::symlink;
for hard_link in [false, true] {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let target = directory.path().join("target.token");
let stable = stable_legacy_path(directory.path());
write_token(&target, 'a')?;
fs::create_dir_all(stable.parent().ok_or("missing stable parent")?)?;
if hard_link {
fs::hard_link(&target, &stable)?;
} else {
symlink(&target, &stable)?;
}
let backend = MemoryCredentialBackend::default();
let store = BearerTokenStore::new(&profile_id, directory.path());
assert!(store.load_with(&backend).is_err());
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_none());
}
Ok(())
}
#[cfg(unix)]
#[test]
fn symlink_and_hardlink_lock_files_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::{PermissionsExt, symlink};
for hard_link in [false, true] {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let target = directory.path().join("target.lock");
let lock = directory.path().join("sync/bearer.lock");
fs::write(&target, b"lock-target")?;
fs::set_permissions(&target, fs::Permissions::from_mode(0o644))?;
fs::create_dir_all(lock.parent().ok_or("missing lock parent")?)?;
if hard_link {
fs::hard_link(&target, &lock)?;
} else {
symlink(&target, &lock)?;
}
let store = BearerTokenStore::new(&profile_id, directory.path());
assert!(store.load_with(&MemoryCredentialBackend::default()).is_err());
assert_eq!(fs::metadata(&target)?.permissions().mode() & 0o777, 0o644);
}
Ok(())
}
#[cfg(unix)]
#[test]
fn symlinked_profile_directories_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::symlink;
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let target = directory.path().join("target-profile");
let linked_profile = directory.path().join("linked-profile");
fs::create_dir_all(&target)?;
symlink(&target, &linked_profile)?;
let store = BearerTokenStore::new(&profile_id, &linked_profile);
assert!(store.load_with(&MemoryCredentialBackend::default()).is_err());
assert!(!target.join("sync").exists());
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn native_keychain_round_trip() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let store = BearerTokenStore::new(&profile_id, directory.path());
let token = BearerToken::new("n".repeat(64))?;
let result = (|| {
store.save(&token)?;
assert_eq!(store.load()?, Some(token));
Ok::<(), SyncClientError>(())
})();
store.clear()?;
result?;
Ok(())
}
fn stable_legacy_path(profile_dir: &std::path::Path) -> std::path::PathBuf {
profile_dir.join("sync/bearer.token")
}
fn migration_marker_path(profile_dir: &std::path::Path) -> std::path::PathBuf {
profile_dir.join("sync/bearer.migrated")
}
fn write_token(path: &std::path::Path, character: char) -> Result<(), std::io::Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, character.to_string().repeat(64))
}
+155 -9
View File
@@ -1,28 +1,174 @@
use keyring::{Entry, Error as KeyringError};
use keyring_core::{CredentialStore, Entry, Error as KeyringError};
use std::sync::{Arc, Mutex};
use zeroize::Zeroizing;
static NATIVE_STORE: Mutex<Option<Arc<CredentialStore>>> = Mutex::new(None);
pub(crate) fn load_secret(
service: &str,
account: &str,
) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
match entry(service, account)?.get_secret() {
native_operation(service, account, |entry| match entry.get_secret() {
Ok(secret) => Ok(Some(Zeroizing::new(secret))),
Err(KeyringError::NoEntry) => Ok(None),
Err(error) => Err(error.to_string()),
}
Err(error) => Err(error),
})
}
pub(crate) fn save_secret(service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
entry(service, account)?.set_secret(secret).map_err(|error| error.to_string())
native_operation(service, account, |entry| entry.set_secret(secret))
}
pub(crate) fn clear_secret(service: &str, account: &str) -> Result<(), String> {
match entry(service, account)?.delete_credential() {
native_operation(service, account, |entry| match entry.delete_credential() {
Ok(()) | Err(KeyringError::NoEntry) => Ok(()),
Err(error) => Err(error.to_string()),
Err(error) => Err(error),
})
}
fn native_operation<T>(
service: &str,
account: &str,
operation: impl Fn(&Entry) -> keyring_core::Result<T>,
) -> Result<T, String> {
#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
{
return retry_once(
|| entry(service, account).and_then(|entry| operation(&entry)),
retryable_store_error,
clear_cached_store,
)
.map_err(|error| error.to_string());
}
#[cfg(not(all(
unix,
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
)))]
{
entry(service, account)
.and_then(|entry| operation(&entry))
.map_err(|error| error.to_string())
}
}
fn entry(service: &str, account: &str) -> Result<Entry, String> {
Entry::new(service, account).map_err(|error| error.to_string())
#[cfg(any(
test,
all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android")))
))]
fn retry_once<T, E>(
mut attempt: impl FnMut() -> Result<T, E>,
should_retry: impl Fn(&E) -> bool,
before_retry: impl FnOnce(),
) -> Result<T, E> {
let first = attempt();
if first.as_ref().is_err_and(should_retry) {
before_retry();
attempt()
} else {
first
}
}
fn entry(service: &str, account: &str) -> keyring_core::Result<Entry> {
let store = {
let mut guard = NATIVE_STORE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if guard.is_none() {
*guard = Some(platform_store()?);
}
guard.as_ref().cloned().ok_or(KeyringError::NoDefaultStore)?
};
build_entry(store.as_ref(), service, account)
}
#[cfg(any(
test,
all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android")))
))]
fn retryable_store_error(error: &KeyringError) -> bool {
matches!(
error,
KeyringError::PlatformFailure(_)
| KeyringError::NoStorageAccess(_)
| KeyringError::NoDefaultStore
)
}
#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
fn clear_cached_store() {
let mut guard = NATIVE_STORE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
*guard = None;
}
#[cfg(target_os = "windows")]
fn build_entry(
store: &CredentialStore,
service: &str,
account: &str,
) -> keyring_core::Result<Entry> {
let modifiers = std::collections::HashMap::from([("persistence", "Local")]);
store.build(service, account, Some(&modifiers))
}
#[cfg(not(target_os = "windows"))]
fn build_entry(
store: &CredentialStore,
service: &str,
account: &str,
) -> keyring_core::Result<Entry> {
store.build(service, account, None)
}
#[cfg(target_os = "macos")]
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
apple_native_keyring_store::keychain::Store::new().map(|store| store as Arc<CredentialStore>)
}
#[cfg(target_os = "windows")]
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
windows_native_keyring_store::Store::new().map(|store| store as Arc<CredentialStore>)
}
#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
zbus_secret_service_keyring_store::Store::new().map(|store| store as Arc<CredentialStore>)
}
#[cfg(not(any(
target_os = "macos",
target_os = "windows",
all(unix, not(any(target_os = "ios", target_os = "android")))
)))]
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
Err(KeyringError::NoDefaultStore)
}
#[cfg(test)]
mod tests {
use super::{KeyringError, retry_once, retryable_store_error};
use std::cell::Cell;
#[test]
fn retry_policy_targets_store_connectivity_errors() {
assert!(retryable_store_error(&KeyringError::NoDefaultStore));
assert!(!retryable_store_error(&KeyringError::NoEntry));
}
#[test]
fn retry_evicts_once_and_recreates_the_store_once() {
let attempts = Cell::new(0);
let evictions = Cell::new(0);
let result = retry_once(
|| {
let attempt = attempts.get() + 1;
attempts.set(attempt);
Err::<(), _>(attempt)
},
|_| true,
|| evictions.set(evictions.get() + 1),
);
assert_eq!(result, Err(2));
assert_eq!(attempts.get(), 2);
assert_eq!(evictions.get(), 1);
}
}
+38
View File
@@ -17,6 +17,7 @@ use crate::{
const PUBLIC_KEY_BYTES: usize = 32;
const MAX_DEVICE_TEXT_CHARS: usize = 128;
const MAX_STORED_IDENTITY_BYTES: usize = 16 * 1024;
/// Public device identity persisted in the profile directory. Both private
/// keys live in the macOS data-protection Keychain under `device_id`.
@@ -33,6 +34,18 @@ pub struct DeviceIdentity {
}
impl DeviceIdentity {
pub fn validate_stored_bytes(bytes: &[u8], path: &Path) -> Result<(), SyncClientError> {
if bytes.len() > MAX_STORED_IDENTITY_BYTES {
return Err(key_error("stored device identity is too large"));
}
let contents = std::str::from_utf8(bytes)
.map_err(|error| SyncClientError::TokenStorage(error.to_string()))?;
match decode_stored_identity(contents, path)? {
StoredIdentity::V2(identity) => identity.validate(),
StoredIdentity::Legacy(identity) => identity.validate(),
}
}
/// Loads a v2 identity and its Keychain secrets. A legacy public-only
/// identity is rotated to a fresh device ID because its private key was
/// never persisted and cannot prove device continuity.
@@ -413,6 +426,31 @@ mod tests {
Ok(())
}
#[test]
fn stored_identity_validation_is_bounded_and_schema_strict() -> Result<(), SyncClientError> {
let (identity, _) = generate_key_material("Test".to_string(), "macos".to_string())?;
let bytes = serde_json::to_vec(&identity).map_err(|error| {
SyncClientError::TokenStorage(format!("device identity serialize: {error}"))
})?;
DeviceIdentity::validate_stored_bytes(&bytes, Path::new("device.json"))?;
assert!(
DeviceIdentity::validate_stored_bytes(
&vec![b'a'; MAX_STORED_IDENTITY_BYTES + 1],
Path::new("device.json"),
)
.is_err()
);
assert!(
DeviceIdentity::validate_stored_bytes(
br#"{"device_id":"ely-test","unknown":true}"#,
Path::new("device.json"),
)
.is_err()
);
Ok(())
}
#[cfg(target_os = "macos")]
#[test]
fn legacy_identity_rotates_to_new_device_id() -> Result<(), SyncClientError> {
+15 -10
View File
@@ -16,6 +16,7 @@
use serde::{Deserialize, Serialize};
use std::time::Duration;
use ureq::{Agent, AgentBuilder};
use zeroize::Zeroizing;
use crate::{auth::BearerToken, client::ApiClientConfig, error::SyncClientError};
@@ -86,20 +87,21 @@ pub fn verify_email_otp(
match response {
Ok(ok) => {
let cookie_token = better_auth_cookie_token(ok.header("set-cookie"));
let body = ok.into_string().map_err(|error| SyncClientError::HttpStatus {
endpoint: endpoint.clone(),
status: 200,
body: error.to_string(),
})?;
let body =
Zeroizing::new(ok.into_string().map_err(|error| SyncClientError::HttpStatus {
endpoint: endpoint.clone(),
status: 200,
body: error.to_string(),
})?);
let json = serde_json::from_str::<VerifyOtpResponse>(&body).map_err(|error| {
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
})?;
let token = json.token.or(cookie_token).ok_or_else(|| {
let token = json.token.map(Zeroizing::new).or(cookie_token).ok_or_else(|| {
SyncClientError::TokenStorage(
"sign-in response did not include a session token".to_string(),
)
})?;
BearerToken::new(token)
BearerToken::new(token.as_str())
}
Err(ureq::Error::Status(status, raw)) => {
let body = raw.into_string().unwrap_or_default();
@@ -117,14 +119,14 @@ fn build_agent() -> Agent {
/// better-auth.session_token=<token>; …` header. Strip the cookie's
/// attributes and return just the value. Multi-cookie responses are
/// concatenated by `ureq` into a single header line per spec.
fn better_auth_cookie_token(set_cookie: Option<&str>) -> Option<String> {
fn better_auth_cookie_token(set_cookie: Option<&str>) -> Option<Zeroizing<String>> {
let header = set_cookie?;
for cookie in header.split(',') {
let trimmed = cookie.trim();
if let Some(rest) = trimmed.strip_prefix("better-auth.session_token=") {
let token = rest.split(';').next()?.trim();
if !token.is_empty() {
return Some(token.to_string());
return Some(Zeroizing::new(token.to_string()));
}
}
}
@@ -139,7 +141,10 @@ mod tests {
fn picks_session_token_out_of_set_cookie_header() {
let header =
"better-auth.session_token=abc.def.ghi; Path=/; HttpOnly; Secure; SameSite=Lax";
assert_eq!(better_auth_cookie_token(Some(header)), Some("abc.def.ghi".to_string()),);
assert_eq!(
better_auth_cookie_token(Some(header)).as_deref().map(String::as_str),
Some("abc.def.ghi"),
);
}
#[test]
+3
View File
@@ -8,6 +8,9 @@ pub enum SyncClientError {
#[error("Bearer token storage is unavailable: {0}")]
TokenStorage(String),
#[error("Bearer credential storage is unavailable: {0}")]
BearerCredentialStorage(String),
#[error("HTTP request failed for {endpoint}: {source}")]
Http {
endpoint: String,
+1
View File
@@ -17,6 +17,7 @@
//! the bearer token out-of-band and hand it to the client.
pub mod auth;
mod auth_files;
pub mod client;
mod credential_store;
pub mod device;