start with rust backup

This commit is contained in:
otsmr 2026-05-08 02:50:31 +02:00
parent e6a468c065
commit f323bc03eb
35 changed files with 3436 additions and 317 deletions

View file

@ -9,7 +9,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
// These functions are ignored because they are not marked as `pub`: `get_twonly_flutter`
// These types are ignored because they are neither used by any `pub` functions nor (for structs and enums) marked `#[frb(unignore)]`: `TwonlyFlutter`
Future<void> initializeTwonlyFlutter({required TwonlyConfig config}) =>
Future<void> initializeTwonlyFlutter({required InitConfig config}) =>
RustLib.instance.api.crateBridgeInitializeTwonlyFlutter(config: config);
class AnnouncedUser {
@ -36,6 +36,27 @@ class AnnouncedUser {
publicId == other.publicId;
}
class InitConfig {
final String databaseDir;
final String dataDir;
const InitConfig({
required this.databaseDir,
required this.dataDir,
});
@override
int get hashCode => databaseDir.hashCode ^ dataDir.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is InitConfig &&
runtimeType == other.runtimeType &&
databaseDir == other.databaseDir &&
dataDir == other.dataDir;
}
class OtherPromotion {
final int promotionId;
final PlatformInt64 publicId;
@ -74,24 +95,3 @@ class OtherPromotion {
announcementShare == other.announcementShare &&
publicKeyVerifiedTimestamp == other.publicKeyVerifiedTimestamp;
}
class TwonlyConfig {
final String databasePath;
final String dataDirectory;
const TwonlyConfig({
required this.databasePath,
required this.dataDirectory,
});
@override
int get hashCode => databasePath.hashCode ^ dataDirectory.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TwonlyConfig &&
runtimeType == other.runtimeType &&
databasePath == other.databasePath &&
dataDirectory == other.dataDirectory;
}

28
lib/core/context.dart Normal file
View file

@ -0,0 +1,28 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.12.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
class InitConfig {
final String databasePath;
final String dataDirectory;
const InitConfig({
required this.databasePath,
required this.dataDirectory,
});
@override
int get hashCode => databasePath.hashCode ^ dataDirectory.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is InitConfig &&
runtimeType == other.runtimeType &&
databasePath == other.databasePath &&
dataDirectory == other.dataDirectory;
}

View file

@ -152,9 +152,7 @@ abstract class RustLibApi extends BaseApi {
userDiscoveryGetContactPromotion,
});
Future<void> crateBridgeInitializeTwonlyFlutter({
required TwonlyConfig config,
});
Future<void> crateBridgeInitializeTwonlyFlutter({required InitConfig config});
}
class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
@ -556,13 +554,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
@override
Future<void> crateBridgeInitializeTwonlyFlutter({
required TwonlyConfig config,
required InitConfig config,
}) {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_box_autoadd_twonly_config(config, serializer);
sse_encode_box_autoadd_init_config(config, serializer);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
@ -1180,9 +1178,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
@protected
TwonlyConfig dco_decode_box_autoadd_twonly_config(dynamic raw) {
InitConfig dco_decode_box_autoadd_init_config(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
return dco_decode_twonly_config(raw);
return dco_decode_init_config(raw);
}
@protected
@ -1200,6 +1198,18 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return dcoDecodeI64(raw);
}
@protected
InitConfig dco_decode_init_config(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 2)
throw Exception('unexpected arr length: expect 2 but see ${arr.length}');
return InitConfig(
databaseDir: dco_decode_String(arr[0]),
dataDir: dco_decode_String(arr[1]),
);
}
@protected
PlatformInt64 dco_decode_isize(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@ -1276,18 +1286,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
TwonlyConfig dco_decode_twonly_config(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>;
if (arr.length != 2)
throw Exception('unexpected arr length: expect 2 but see ${arr.length}');
return TwonlyConfig(
databasePath: dco_decode_String(arr[0]),
dataDirectory: dco_decode_String(arr[1]),
);
}
@protected
int dco_decode_u_32(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs
@ -1375,11 +1373,9 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
@protected
TwonlyConfig sse_decode_box_autoadd_twonly_config(
SseDeserializer deserializer,
) {
InitConfig sse_decode_box_autoadd_init_config(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
return (sse_decode_twonly_config(deserializer));
return (sse_decode_init_config(deserializer));
}
@protected
@ -1396,6 +1392,14 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
return deserializer.buffer.getPlatformInt64();
}
@protected
InitConfig sse_decode_init_config(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_databaseDir = sse_decode_String(deserializer);
var var_dataDir = sse_decode_String(deserializer);
return InitConfig(databaseDir: var_databaseDir, dataDir: var_dataDir);
}
@protected
PlatformInt64 sse_decode_isize(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -1526,17 +1530,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
TwonlyConfig sse_decode_twonly_config(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
var var_databasePath = sse_decode_String(deserializer);
var var_dataDirectory = sse_decode_String(deserializer);
return TwonlyConfig(
databasePath: var_databasePath,
dataDirectory: var_dataDirectory,
);
}
@protected
int sse_decode_u_32(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -1820,12 +1813,12 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
}
@protected
void sse_encode_box_autoadd_twonly_config(
TwonlyConfig self,
void sse_encode_box_autoadd_init_config(
InitConfig self,
SseSerializer serializer,
) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_twonly_config(self, serializer);
sse_encode_init_config(self, serializer);
}
@protected
@ -1842,6 +1835,13 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
serializer.buffer.putPlatformInt64(self);
}
@protected
void sse_encode_init_config(InitConfig self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(self.databaseDir, serializer);
sse_encode_String(self.dataDir, serializer);
}
@protected
void sse_encode_isize(PlatformInt64 self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -1976,13 +1976,6 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
);
}
@protected
void sse_encode_twonly_config(TwonlyConfig self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs
sse_encode_String(self.databasePath, serializer);
sse_encode_String(self.dataDirectory, serializer);
}
@protected
void sse_encode_u_32(int self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs

View file

@ -117,7 +117,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw);
@protected
TwonlyConfig dco_decode_box_autoadd_twonly_config(dynamic raw);
InitConfig dco_decode_box_autoadd_init_config(dynamic raw);
@protected
FlutterUserDiscovery dco_decode_flutter_user_discovery(dynamic raw);
@ -125,6 +125,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
InitConfig dco_decode_init_config(dynamic raw);
@protected
PlatformInt64 dco_decode_isize(dynamic raw);
@ -158,9 +161,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
OtherPromotion dco_decode_other_promotion(dynamic raw);
@protected
TwonlyConfig dco_decode_twonly_config(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@ -202,9 +202,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer);
@protected
TwonlyConfig sse_decode_box_autoadd_twonly_config(
SseDeserializer deserializer,
);
InitConfig sse_decode_box_autoadd_init_config(SseDeserializer deserializer);
@protected
FlutterUserDiscovery sse_decode_flutter_user_discovery(
@ -214,6 +212,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
InitConfig sse_decode_init_config(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_isize(SseDeserializer deserializer);
@ -257,9 +258,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
OtherPromotion sse_decode_other_promotion(SseDeserializer deserializer);
@protected
TwonlyConfig sse_decode_twonly_config(SseDeserializer deserializer);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@ -394,8 +392,8 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
);
@protected
void sse_encode_box_autoadd_twonly_config(
TwonlyConfig self,
void sse_encode_box_autoadd_init_config(
InitConfig self,
SseSerializer serializer,
);
@ -408,6 +406,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_init_config(InitConfig self, SseSerializer serializer);
@protected
void sse_encode_isize(PlatformInt64 self, SseSerializer serializer);
@ -468,9 +469,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_twonly_config(TwonlyConfig self, SseSerializer serializer);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);

View file

@ -119,7 +119,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
PlatformInt64 dco_decode_box_autoadd_i_64(dynamic raw);
@protected
TwonlyConfig dco_decode_box_autoadd_twonly_config(dynamic raw);
InitConfig dco_decode_box_autoadd_init_config(dynamic raw);
@protected
FlutterUserDiscovery dco_decode_flutter_user_discovery(dynamic raw);
@ -127,6 +127,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 dco_decode_i_64(dynamic raw);
@protected
InitConfig dco_decode_init_config(dynamic raw);
@protected
PlatformInt64 dco_decode_isize(dynamic raw);
@ -160,9 +163,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
OtherPromotion dco_decode_other_promotion(dynamic raw);
@protected
TwonlyConfig dco_decode_twonly_config(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@ -204,9 +204,7 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
PlatformInt64 sse_decode_box_autoadd_i_64(SseDeserializer deserializer);
@protected
TwonlyConfig sse_decode_box_autoadd_twonly_config(
SseDeserializer deserializer,
);
InitConfig sse_decode_box_autoadd_init_config(SseDeserializer deserializer);
@protected
FlutterUserDiscovery sse_decode_flutter_user_discovery(
@ -216,6 +214,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
@protected
InitConfig sse_decode_init_config(SseDeserializer deserializer);
@protected
PlatformInt64 sse_decode_isize(SseDeserializer deserializer);
@ -259,9 +260,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
OtherPromotion sse_decode_other_promotion(SseDeserializer deserializer);
@protected
TwonlyConfig sse_decode_twonly_config(SseDeserializer deserializer);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@ -396,8 +394,8 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
);
@protected
void sse_encode_box_autoadd_twonly_config(
TwonlyConfig self,
void sse_encode_box_autoadd_init_config(
InitConfig self,
SseSerializer serializer,
);
@ -410,6 +408,9 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
@protected
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
@protected
void sse_encode_init_config(InitConfig self, SseSerializer serializer);
@protected
void sse_encode_isize(PlatformInt64 self, SseSerializer serializer);
@ -470,9 +471,6 @@ abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
SseSerializer serializer,
);
@protected
void sse_encode_twonly_config(TwonlyConfig self, SseSerializer serializer);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);

View file

@ -55,9 +55,9 @@ Future<void> twonlyMinimumInitialization() async {
Log.info('twonlyMinimumInitialization: bridge.initializeTwonlyFlutter()');
await bridge.initializeTwonlyFlutter(
config: bridge.TwonlyConfig(
databasePath: '${AppEnvironment.supportDir}/twonly.sqlite',
dataDirectory: AppEnvironment.supportDir,
config: bridge.InitConfig(
databaseDir: AppEnvironment.supportDir,
dataDir: AppEnvironment.supportDir,
),
);
Log.info('twonlyMinimumInitialization: finished');

1921
rust/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -18,13 +18,37 @@ sqlx = { version = "0.9.0-alpha.1", default-features = false, features = [
"derive",
"json",
] }
mdk-core = { version = "0.8.0", git = "https://github.com/marmot-protocol/mdk", rev = "7f809f8549458a0d7f7d885bcdd694023abf299c", features = [
"mip04",
"mip05",
] }
mdk-sqlite-storage = { version = "0.8.0", git = "https://github.com/marmot-protocol/mdk", rev = "7f809f8549458a0d7f7d885bcdd694023abf299c" }
mdk-storage-traits = { version = "0.8.0", git = "https://github.com/marmot-protocol/mdk", rev = "7f809f8549458a0d7f7d885bcdd694023abf299c" }
libsqlite3-sys = { version = "0.35.0", features = ["bundled", "sqlcipher"] }
tokio = { version = "1.44", features = ["full"] }
tracing = "0.1.44"
rand = "0.10.1"
protocols = { path = "../rust_dependencies/protocols" }
hkdf = "0.12.4"
sha2 = "0.10.8"
aes-gcm = "0.10.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2.5"
paste = "1.0.15"
serde = { version = "1.0", features = ["derive"] }
zeroize = { version = "1.8", features = ["derive"] }
hex = "0.4.3"
keyring-core = "1"
postcard = { version = "1.0", features = ["alloc"] }
chrono = { version = "0.4", features = ["serde"] }
zip = { version = "2.2.2", default-features = false, features = ["deflate"] }
walkdir = "2.5.0"
[target.'cfg(target_os = "ios")'.dependencies]
# iOS backend: Requires the 'protected' feature for Data Protection Keychain
apple-native-keyring-store = { version = "1", features = ["protected"] }
[target.'cfg(target_os = "android")'.dependencies]
# Android backend: Interfaces with the Android Keystore
android-native-keyring-store = "1"
[dev-dependencies]
pretty_env_logger = "0.5.0"

View file

@ -0,0 +1,258 @@
use crate::context::Context;
use crate::database::Database;
use crate::error::Result;
use crate::keys::DatabaseKey;
use std::fs::{remove_file, File};
use std::io::{copy, Cursor};
use std::path::PathBuf;
use walkdir::WalkDir;
use zeroize::Zeroize;
use zip::write::SimpleFileOptions;
use zip::{CompressionMethod, ZipArchive, ZipWriter};
struct BackupArchive {}
impl BackupArchive {
fn get_backup_files(ctx: &Context) -> Result<Vec<(&str, PathBuf, bool, Option<String>)>> {
let config = ctx.get_config()?;
let database_dir = PathBuf::from(&config.database_dir);
let data_dir = PathBuf::from(&config.data_dir);
let keys = ctx.get_key_manager()?;
let rust_db_key = keys.main_key.get_database_key(DatabaseKey::RustDb);
Ok(vec![
("twonly.sqlite", database_dir.clone(), true, None),
("rust_db.sqlite", database_dir, true, Some(rust_db_key)),
("user_discovery_config.json", data_dir, false, None),
])
}
pub(crate) async fn create_backup(ctx: &Context) -> Result<PathBuf> {
let config = ctx.get_config()?;
let data_dir = PathBuf::from(&config.data_dir);
let backup_data_dir = data_dir.join("temp_backup_dir");
if backup_data_dir.is_dir() {
std::fs::remove_dir_all(&backup_data_dir)?;
}
std::fs::create_dir_all(&backup_data_dir)?;
for (file_name, source_dir, is_db, mut encryption_key) in Self::get_backup_files(ctx)? {
let file_path = source_dir.join(&file_name);
if !file_path.exists() {
tracing::warn!(
"Could not backup {} as it does not exist.",
file_path.display()
);
continue;
}
if is_db {
let db = Database::new(
&file_path.display().to_string(),
encryption_key.as_deref(),
encryption_key.is_none(),
)
.await?;
let backup_database_file = backup_data_dir.join(&file_name).display().to_string();
db.create_backup(backup_database_file.as_str(), encryption_key.as_deref())
.await?;
} else {
let file_backup = backup_data_dir.join(&file_name);
std::fs::copy(file_path, file_backup)?;
}
encryption_key.zeroize();
}
let mut zip_data = Vec::new();
{
let mut zip = ZipWriter::new(Cursor::new(&mut zip_data));
let options =
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
for entry in WalkDir::new(&backup_data_dir) {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
if let Ok(name) = path.strip_prefix(&backup_data_dir) {
zip.start_file(name.to_string_lossy(), options)?;
copy(&mut File::open(path)?, &mut zip)?;
}
}
zip.finish()?;
}
let mut keys = ctx.get_key_manager()?;
let zip_path = data_dir.join("temp_backup.zip");
std::fs::write(&zip_path, keys.main_key.encrypt_backup(&zip_data))?;
std::fs::remove_dir_all(&backup_data_dir)?;
keys.zeroize();
Ok(zip_path)
}
pub(crate) async fn restore_from_backup(ctx: &Context, file_path: &PathBuf) -> Result<()> {
let data_dir = PathBuf::from(&ctx.get_config()?.data_dir);
let mut keys = ctx.get_key_manager()?;
let encrypted_zip = std::fs::read(file_path)?;
let zip_content = keys.main_key.decrypt_backup(&encrypted_zip)?;
let restore_temp_dir = data_dir.join("restore_temp");
if restore_temp_dir.exists() {
std::fs::remove_dir_all(&restore_temp_dir)?;
}
std::fs::create_dir_all(&restore_temp_dir)?;
let mut archive = ZipArchive::new(Cursor::new(zip_content))?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
if file.is_file() {
let enclosed_name = file.enclosed_name();
if let Some(name) = enclosed_name.as_ref().and_then(|p| p.file_name()) {
let restored_file = restore_temp_dir.join(name);
copy(&mut file, &mut File::create(&restored_file)?)?;
};
}
}
for (file_name, target_dir, is_db, _) in Self::get_backup_files(ctx)? {
let src = restore_temp_dir.join(&file_name);
if src.exists() {
let dst = target_dir.join(&file_name);
if is_db {
// Remove existing database and its temporary files (WAL, SHM)
let _ = remove_file(&dst);
let _ = remove_file(target_dir.join(format!("{}-wal", file_name)));
let _ = remove_file(target_dir.join(format!("{}-shm", file_name)));
}
std::fs::copy(src, dst)?;
}
}
keys.zeroize();
std::fs::remove_dir_all(&restore_temp_dir)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_backup_and_restore() {
let _ = pretty_env_logger::try_init();
let temp_dir = tempdir().unwrap();
let ctx = Context::init_for_testing(
temp_dir.path().join("database"),
temp_dir.path().join("data"),
)
.await
.unwrap();
// 1. Add some data
{
let config = ctx.get_config().unwrap();
let rust_db_path = PathBuf::from(&config.database_dir).join("rust_db.sqlite");
let key_manager = ctx.get_key_manager().unwrap();
let db = Database::new(
&rust_db_path.display().to_string(),
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
false,
)
.await
.unwrap();
crate::database::tables::received_messages::ReceivedMessage::insert(
&db.pool,
"sender1",
b"original message",
)
.await
.unwrap();
// Add a file
let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json");
std::fs::write(config_file, "original config").unwrap();
}
// 2. Create backup
let backup_path = BackupArchive::create_backup(&ctx).await.unwrap();
assert!(backup_path.exists());
// 3. Modify data (to simulate state before restore)
{
let config = ctx.get_config().unwrap();
let rust_db_path = PathBuf::from(&config.database_dir).join("rust_db.sqlite");
let key_manager = ctx.get_key_manager().unwrap();
let db = Database::new(
&rust_db_path.display().to_string(),
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
false,
)
.await
.unwrap();
crate::database::tables::received_messages::ReceivedMessage::insert(
&db.pool,
"sender2",
b"new message",
)
.await
.unwrap();
let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json");
std::fs::write(config_file, "new config").unwrap();
}
// 4. Restore backup
BackupArchive::restore_from_backup(&ctx, &backup_path)
.await
.unwrap();
// 5. Verify restored data
{
let config = ctx.get_config().unwrap();
let rust_db_path = PathBuf::from(&config.database_dir).join("rust_db.sqlite");
let key_manager = ctx.get_key_manager().unwrap();
let db = Database::new(
&rust_db_path.display().to_string(),
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
false,
)
.await
.unwrap();
let messages =
crate::database::tables::received_messages::ReceivedMessage::get_all(&db.pool)
.await
.unwrap();
// Should only have the original message because restore overwrites
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, "sender1");
assert_eq!(messages[0].content, b"original message");
let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json");
let config_content = std::fs::read_to_string(config_file).unwrap();
assert_eq!(config_content, "original config");
}
}
}

1
rust/src/backup/mod.rs Normal file
View file

@ -0,0 +1 @@
mod backup_archive;

View file

@ -5,12 +5,10 @@ pub(crate) mod user_discovery;
use flutter_rust_bridge::DartFnFuture;
use protocols::user_discovery::traits::{AnnouncedUser, OtherPromotion};
use super::error::Result;
use crate::error::{Result, TwonlyError};
use crate::{callback_generator, frb_generated::StreamSink};
use std::sync::{Arc, OnceLock};
use crate::bridge::error::TwonlyError;
static FLUTTER_CALLBACKS: OnceLock<FlutterCallbacks> = OnceLock::new();
// This will also generate the function init_flutter_callbacks which MUST be called from Flutter to initialize the callbacks

View file

@ -1,6 +1,6 @@
use crate::bridge::callbacks::get_callbacks;
use crate::bridge::error::TwonlyError;
use crate::bridge::get_twonly_flutter;
use crate::error::TwonlyError;
use protocols::user_discovery::error::{Result, UserDiscoveryError};
use protocols::user_discovery::traits::UserDiscoveryUtils;
use protocols::user_discovery::traits::{AnnouncedUser, OtherPromotion, UserDiscoveryStore};
@ -47,8 +47,7 @@ impl UserDiscoveryUtils for UserDiscoveryUtilsFlutter {
impl UserDiscoveryStore for UserDiscoveryStoreFlutter {
async fn get_config(&self) -> Result<String> {
let ws = get_twonly_flutter()?;
let config_path =
PathBuf::from(&ws.config.data_directory).join("user_discovery_config.json");
let config_path = PathBuf::from(&ws.config.data_dir).join("user_discovery_config.json");
if !config_path.is_file() {
return Err(UserDiscoveryError::NotInitialized);
@ -60,8 +59,7 @@ impl UserDiscoveryStore for UserDiscoveryStoreFlutter {
async fn update_config(&self, update: String) -> Result<()> {
tracing::debug!("Updating configuration file.");
let ws = get_twonly_flutter()?;
let config_path =
PathBuf::from(&ws.config.data_directory).join("user_discovery_config.json");
let config_path = PathBuf::from(&ws.config.data_dir).join("user_discovery_config.json");
std::fs::write(config_path, &update)?;
Ok(())
}

View file

@ -1,24 +1,30 @@
#![allow(unexpected_cfgs)]
pub mod callbacks;
pub mod error;
pub mod log;
pub mod wrapper;
use std::path::Path;
use std::sync::Arc;
use crate::bridge::callbacks::user_discovery::{
UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter,
};
use crate::bridge::log::init_tracing;
use crate::context::Context;
use crate::database::Database;
use crate::error::Result;
use crate::error::TwonlyError;
use crate::secure_storage::SecureStorage;
use crate::utils::Shared;
use error::Result;
use error::TwonlyError;
use flutter_rust_bridge::frb;
use protocols::user_discovery::UserDiscovery;
use std::path::PathBuf;
use tokio::sync::OnceCell;
pub use protocols::user_discovery::traits::AnnouncedUser;
pub use protocols::user_discovery::traits::OtherPromotion;
pub struct InitConfig {
pub database_dir: String,
pub data_dir: String,
}
#[frb(mirror(OtherPromotion))]
pub struct _OtherPromotion {
pub promotion_id: u32,
@ -36,58 +42,25 @@ pub struct _AnnouncedUser {
pub public_id: i64,
}
pub struct TwonlyConfig {
pub database_path: String,
pub data_directory: String,
}
pub(crate) struct TwonlyFlutter {
#[allow(dead_code)]
pub(crate) config: TwonlyConfig,
// /// Rust runs in the same process as drift, the database can only be opened in readonly mode
// pub(crate) twonly_db_readonly: Arc<Database>,
pub(crate) config: InitConfig,
pub(crate) user_discovery:
Shared<UserDiscovery<UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter>>,
pub(crate) rust_db: Arc<Database>,
pub(crate) secure_storage: SecureStorage,
}
static GLOBAL_TWONLY: OnceCell<TwonlyFlutter> = OnceCell::const_new();
pub(super) fn get_twonly_flutter() -> Result<&'static TwonlyFlutter> {
GLOBAL_TWONLY.get().ok_or(TwonlyError::Initialization)
let ctx = Context::get_static()?;
if let Context::Flutter(twonly) = ctx {
return Ok(twonly);
} else {
return Err(TwonlyError::Initialization);
}
}
pub async fn initialize_twonly_flutter(config: TwonlyConfig) -> Result<()> {
if GLOBAL_TWONLY.initialized() {
tracing::info!("twonly already initialized.");
return Ok(());
}
let log_dir = PathBuf::from(&config.data_directory).join("log");
init_tracing(&log_dir, true).await;
tracing::info!("Initialized twonly workspace.");
let twonly_res: Result<&'static TwonlyFlutter> = GLOBAL_TWONLY
.get_or_try_init(|| async {
// let database_dir = PathBuf::from(&config.database_path.clone());
// let Some(rust_db_path) = database_dir.parent() else {
// return Err(TwonlyError::DatabaseNotFound);
// };
// let rust_db_path = rust_db_path.join("rust_db.sqlite").display().to_string();
// let twonly_db_readonly = Arc::new(Database::new(&config.database_path, true).await?);
// let rust_db = Arc::new(Database::new(&rust_db_path, false).await?);
Ok(TwonlyFlutter {
config,
// twonly_db_readonly,
// rust_db,
user_discovery: Shared::new(UserDiscovery::new(
UserDiscoveryStoreFlutter {},
UserDiscoveryUtilsFlutter {},
)?),
})
})
.await;
twonly_res?;
pub async fn initialize_twonly_flutter(config: InitConfig) -> Result<()> {
Context::init_flutter(config).await?;
Ok(())
}

View file

@ -1,5 +1,5 @@
use crate::bridge::error::Result;
use crate::bridge::get_twonly_flutter;
use crate::error::Result;
pub struct FlutterUserDiscovery {}

169
rust/src/context.rs Normal file
View file

@ -0,0 +1,169 @@
use crate::{
bridge::{
callbacks::user_discovery::{UserDiscoveryStoreFlutter, UserDiscoveryUtilsFlutter},
InitConfig,
},
database::Database,
error::{Result, TwonlyError},
keys::{DatabaseKey, KeyManager},
log::init_tracing,
utils::Shared,
};
use protocols::user_discovery::UserDiscovery;
use std::{path::PathBuf, sync::Arc};
use tokio::sync::OnceCell;
use zeroize::Zeroize;
use crate::{bridge::TwonlyFlutter, secure_storage::SecureStorage, standalone::TwonlyStandalone};
pub(crate) enum Context {
Flutter(TwonlyFlutter),
Standalone(TwonlyStandalone),
}
impl Context {
#[cfg(test)]
pub(crate) fn from_standalone(standalone: TwonlyStandalone) -> Self {
Self::Standalone(standalone)
}
}
static GLOBAL_CONTEXT: OnceCell<Context> = OnceCell::const_new();
impl Context {
pub(crate) async fn init_flutter(config: InitConfig) -> Result<()> {
Self::init_common(config, true).await
}
pub(crate) async fn init_standalone(config: InitConfig) -> Result<()> {
Self::init_common(config, false).await
}
#[cfg(test)]
pub(crate) async fn init_for_testing(
database_dir: PathBuf,
data_dir: PathBuf,
) -> Result<Context> {
std::fs::create_dir_all(&database_dir)?;
std::fs::create_dir_all(&data_dir)?;
let config = InitConfig {
database_dir: database_dir.display().to_string(),
data_dir: data_dir.display().to_string(),
};
// Initialize tracing and secure storage if not already done
let _ = SecureStorage::init();
let secure_storage = SecureStorage::new("eu.twonly.testing");
let key_manager = KeyManager::generate()?;
key_manager.store_to_keychain(&secure_storage)?;
let rust_db_path = database_dir.join("rust_db.sqlite");
let rust_db = Arc::new(
Database::new(
&rust_db_path.display().to_string(),
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
false,
)
.await?,
);
Ok(Context::from_standalone(TwonlyStandalone {
config,
rust_db,
secure_storage,
}))
}
async fn init_common(config: InitConfig, is_flutter: bool) -> Result<()> {
if GLOBAL_CONTEXT.initialized() {
tracing::info!("twonly already initialized.");
return Ok(());
}
let log_dir = PathBuf::from(&config.data_dir).join("log");
init_tracing(&log_dir, is_flutter).await;
SecureStorage::init()?;
let secure_storage = SecureStorage::new("eu.twonly");
let database_dir = PathBuf::from(&config.database_dir.clone());
let rust_db_path = database_dir.join("rust_db.sqlite");
tracing::info!("Initialized twonly workspace.");
let _: Result<&'static Context> = GLOBAL_CONTEXT
.get_or_try_init(|| async {
let key_manager = match KeyManager::try_from_keychain(&secure_storage) {
Ok(key) => key,
Err(err) => {
tracing::error!("{err}");
if rust_db_path.exists() {
tracing::error!("Rust Database exsist, while the key manager not");
return Err(TwonlyError::SecureStorageError);
}
tracing::info!("Generating a new key manager.");
let new = KeyManager::generate()?;
new.store_to_keychain(&secure_storage)?;
new
}
};
let mut rust_db_key = key_manager.main_key.get_database_key(DatabaseKey::RustDb);
let rust_db = Arc::new(
Database::new(
&rust_db_path.display().to_string(),
Some(rust_db_key.as_str()),
false,
)
.await?,
);
rust_db_key.zeroize();
if is_flutter {
Ok(Context::Flutter(TwonlyFlutter {
config,
secure_storage,
rust_db,
user_discovery: Shared::new(UserDiscovery::new(
UserDiscoveryStoreFlutter {},
UserDiscoveryUtilsFlutter {},
)?),
}))
} else {
Ok(Context::Standalone(TwonlyStandalone {
config,
rust_db,
secure_storage,
}))
}
})
.await;
Ok(())
}
pub(super) fn get_static() -> Result<&'static Context> {
GLOBAL_CONTEXT.get().ok_or(TwonlyError::Initialization)
}
pub(crate) fn get_secure_storage(&self) -> Result<&SecureStorage> {
match self {
Self::Flutter(twonly) => Ok(&twonly.secure_storage),
Self::Standalone(twonly) => Ok(&twonly.secure_storage),
}
}
pub(crate) fn get_config(&self) -> Result<&InitConfig> {
match self {
Self::Flutter(twonly) => Ok(&twonly.config),
Self::Standalone(twonly) => Ok(&twonly.config),
}
}
pub(crate) fn get_key_manager(&self) -> Result<KeyManager> {
KeyManager::try_from_keychain(self.get_secure_storage()?)
}
}

View file

@ -0,0 +1,16 @@
-- Initial migration: Create received_messages and sending_messages tables
CREATE TABLE IF NOT EXISTS received_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id TEXT NOT NULL,
content BLOB NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
);
CREATE TABLE IF NOT EXISTS sending_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipient_id TEXT NOT NULL,
content BLOB NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
status TEXT DEFAULT 'pending'
);

View file

@ -1,65 +1,180 @@
// use crate::bridge::error::{Result, TwonlyError};
// use sqlx::migrate::MigrateDatabase;
// use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
// use sqlx::{ConnectOptions, Sqlite, SqlitePool};
// use std::time::Duration;
use crate::error::{Result, TwonlyError};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use sqlx::{ConnectOptions, SqlitePool};
use std::time::Duration;
// pub(crate) struct Database {
// pub(crate) pool: SqlitePool,
// }
pub(crate) mod tables;
// impl Database {
// pub(crate) async fn new(db_path: &String, read_only: bool) -> Result<Self> {
// let db_url = format!("sqlite://{}", db_path);
pub(crate) struct Database {
pub(crate) pool: SqlitePool,
}
// match Sqlite::database_exists(&db_url).await {
// Ok(true) => {
// tracing::debug!("database exists");
// }
// Ok(false) => {
// tracing::error!("could not open the sqlite3 database");
// return Err(TwonlyError::DatabaseNotFound);
// }
// Err(e) => {
// tracing::error!(
// "Could not check if database exists: {:?}, attempting to create",
// e
// );
// return Err(TwonlyError::DatabaseNotFound);
// }
// }
impl Database {
pub(crate) async fn new(
db_path: &String,
encryption_key: Option<&str>,
read_only: bool,
) -> Result<Self> {
let db_url = format!("sqlite://{}", db_path);
// tracing::debug!("Creating database connection pool");
let log_statements_level = if std::env::var("SQLX_LOG_STATEMENTS").is_ok() {
tracing::log::LevelFilter::Info
} else {
tracing::log::LevelFilter::Off
};
// let log_statements_level = if std::env::var("SQLX_LOG_STATEMENTS").is_ok() {
// tracing::log::LevelFilter::Info
// } else {
// tracing::log::LevelFilter::Off
// };
let mut connect_options = format!("{db_url}?mode=rwc")
.parse::<SqliteConnectOptions>()?
.log_statements(log_statements_level)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.foreign_keys(true)
.read_only(read_only)
.busy_timeout(Duration::from_millis(5000))
.pragma("recursive_triggers", "ON")
.log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500));
// let connect_options = format!("{db_url}?mode=rwc")
// .parse::<SqliteConnectOptions>()?
// .log_statements(log_statements_level)
// .read_only(read_only)
// .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
// .foreign_keys(true)
// .busy_timeout(Duration::from_millis(5000))
// .pragma("recursive_triggers", "ON")
// .log_slow_statements(tracing::log::LevelFilter::Warn, Duration::from_millis(500));
if let Some(encryption_key) = encryption_key {
connect_options = connect_options.pragma("key", format!("'{}'", encryption_key));
}
// let pool = SqlitePoolOptions::new()
// .acquire_timeout(Duration::from_secs(5))
// .max_connections(10)
// .connect_with(connect_options)
// .await?;
let pool = SqlitePoolOptions::new()
.acquire_timeout(Duration::from_secs(5))
.max_connections(10)
.connect_with(connect_options)
.await?;
// let row: (String, String) = sqlx::query_as("SELECT sqlite_version(), sqlite_source_id()")
// .fetch_one(&pool)
// .await?;
sqlx::migrate!("./src/database/migrations")
.run(&pool)
.await
.map_err(|e| {
tracing::error!("migration error: {:?}", e);
TwonlyError::Generic(format!("Migration error: {}", e))
})?;
// tracing::info!("Rust SQLite Version: {}", row.0);
// tracing::info!("Rust SQLite Source ID: {}", row.1);
Ok(Self { pool })
}
// Ok(Self { pool: pool })
// }
// }
pub(crate) async fn create_backup(
&self,
output_path: &str,
encryption_key: Option<&str>,
) -> Result<()> {
if let Some(key) = encryption_key {
let mut conn = self
.pool
.acquire()
.await
.map_err(|e| TwonlyError::Generic(e.to_string()))?;
sqlx::query("ATTACH DATABASE ? AS backup KEY ?")
.bind(output_path)
.bind(key)
.execute(&mut *conn)
.await
.map_err(|e| TwonlyError::Generic(format!("Attach failed: {}", e)))?;
sqlx::query("SELECT sqlcipher_export('backup')")
.execute(&mut *conn)
.await
.map_err(|e| TwonlyError::Generic(format!("Export failed: {}", e)))?;
sqlx::query("DETACH DATABASE backup")
.execute(&mut *conn)
.await
.map_err(|e| TwonlyError::Generic(format!("Detach failed: {}", e)))?;
} else {
sqlx::query("VACUUM INTO ?")
.bind(output_path)
.execute(&self.pool)
.await
.map_err(|e| TwonlyError::Generic(format!("Backup failed: {}", e)))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::database::tables::received_messages::ReceivedMessage;
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn test_database_encryption_and_migrations() {
let _ = pretty_env_logger::try_init();
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.sqlite").display().to_string();
let key = "secure_password";
// 1. Create and initialize database with key
let db = Database::new(&db_path, Some(key), false).await.unwrap();
ReceivedMessage::insert(&db.pool, "sender1", b"hello world")
.await
.unwrap();
// 2. Try to open with WRONG key
let result = Database::new(&db_path, Some("wrong_password"), false).await;
assert!(
result.is_err(),
"Opening with wrong key should fail. If this passes, the database might not be encrypted!"
);
// 3. Open with CORRECT key again
let db = Database::new(&db_path, Some(key), false).await.unwrap();
let messages = ReceivedMessage::get_all(&db.pool).await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, "sender1");
assert_eq!(messages[0].content, b"hello world");
}
#[tokio::test]
async fn test_database_backup_encrypted() {
let _ = pretty_env_logger::try_init();
let dir = tempdir().unwrap();
let db_path = dir.path().join("test_enc.sqlite").display().to_string();
let backup_path = dir.path().join("backup_enc.sqlite").display().to_string();
let key = "secure_password";
let db = Database::new(&db_path, Some(key), false).await.unwrap();
ReceivedMessage::insert(&db.pool, "sender1", b"hello world")
.await
.unwrap();
db.create_backup(&backup_path, Some(key)).await.unwrap();
// 1. Verify it cannot be opened with wrong key
let result = Database::new(&backup_path, Some("wrong_password"), false).await;
assert!(
result.is_err(),
"Encrypted backup should fail with wrong key"
);
// 2. Open backup with correct key and verify data
let backup_db = Database::new(&backup_path, Some(key), false).await.unwrap();
let messages = ReceivedMessage::get_all(&backup_db.pool).await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, "sender1");
}
#[tokio::test]
async fn test_database_backup_plaintext() {
let _ = pretty_env_logger::try_init();
let dir = tempdir().unwrap();
let db_path = dir.path().join("test_plain.sqlite").display().to_string();
let backup_path = dir.path().join("backup_plain.sqlite").display().to_string();
let db = Database::new(&db_path, None, false).await.unwrap();
ReceivedMessage::insert(&db.pool, "sender1", b"hello world")
.await
.unwrap();
db.create_backup(&backup_path, None).await.unwrap();
// Open backup and verify
let backup_db = Database::new(&backup_path, None, false).await.unwrap();
let messages = ReceivedMessage::get_all(&backup_db.pool).await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, "sender1");
}
}

View file

@ -0,0 +1,2 @@
pub mod received_messages;
pub mod sending_messages;

View file

@ -0,0 +1,34 @@
use crate::error::Result;
use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool};
#[derive(Debug, FromRow)]
pub struct ReceivedMessage {
pub id: i64,
pub sender_id: String,
pub content: Vec<u8>,
pub timestamp: DateTime<Utc>,
}
impl ReceivedMessage {
pub async fn insert(pool: &SqlitePool, sender_id: &str, content: &[u8]) -> Result<i64> {
let result =
sqlx::query("INSERT INTO received_messages (sender_id, content) VALUES (?, ?)")
.bind(sender_id)
.bind(content)
.execute(pool)
.await?;
Ok(result.last_insert_rowid())
}
pub async fn get_all(pool: &SqlitePool) -> Result<Vec<Self>> {
let messages = sqlx::query_as::<_, Self>(
"SELECT id, sender_id, content, timestamp FROM received_messages ORDER BY timestamp DESC",
)
.fetch_all(pool)
.await?;
Ok(messages)
}
}

View file

@ -0,0 +1,35 @@
use crate::error::Result;
use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool};
#[derive(Debug, FromRow)]
pub struct SendingMessage {
pub id: i64,
pub recipient_id: String,
pub content: Vec<u8>,
pub timestamp: DateTime<Utc>,
pub status: String,
}
impl SendingMessage {
pub async fn insert(pool: &SqlitePool, recipient_id: &str, content: &[u8]) -> Result<i64> {
let result =
sqlx::query("INSERT INTO sending_messages (recipient_id, content) VALUES (?, ?)")
.bind(recipient_id)
.bind(content)
.execute(pool)
.await?;
Ok(result.last_insert_rowid())
}
pub async fn get_all(pool: &SqlitePool) -> Result<Vec<Self>> {
let messages = sqlx::query_as::<_, Self>(
"SELECT id, recipient_id, content, timestamp, status FROM sending_messages ORDER BY timestamp DESC",
)
.fetch_all(pool)
.await?;
Ok(messages)
}
}

View file

@ -1,5 +1,6 @@
use protocols::user_discovery::error::UserDiscoveryError;
use thiserror::Error;
use zip::result::ZipError;
pub type Result<T> = core::result::Result<T, TwonlyError>;
@ -7,6 +8,8 @@ pub type Result<T> = core::result::Result<T, TwonlyError>;
pub enum TwonlyError {
#[error("global twonly is not initialized")]
Initialization,
#[error("Tried to access the wrong context")]
WrongContext,
#[error("init_flutter_callbacks was not called")]
MissingCallbackInitialization,
#[error("Could not find the given database")]
@ -15,8 +18,28 @@ pub enum TwonlyError {
UserDiscoveryError(#[from] UserDiscoveryError),
#[error("Error in dart callback")]
DartError,
#[error(
"Storage error: database exists but master key could not be loaded from secure storage"
)]
SecureStorageError,
#[error("{0}")]
SqliteError(#[from] sqlx::Error),
#[error("{0}")]
Generic(String),
#[error("{0}")]
IoError(#[from] std::io::Error),
#[error("{0}")]
ZipError(#[from] ZipError),
#[error("{0}")]
Walkdir(#[from] walkdir::Error),
}
impl From<String> for TwonlyError {
fn from(error: String) -> Self {
TwonlyError::Generic(error)
}
}
impl From<TwonlyError> for UserDiscoveryError {

View file

@ -195,7 +195,7 @@ fn wire__crate__bridge__initialize_twonly_flutter_impl(
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_config = <crate::bridge::TwonlyConfig>::sse_decode(&mut deserializer);
let api_config = <crate::bridge::InitConfig>::sse_decode(&mut deserializer);
deserializer.end();
move |context| async move {
transform_result_sse::<_, flutter_rust_bridge::for_generated::anyhow::Error>(
@ -743,6 +743,18 @@ impl SseDecode for i64 {
}
}
impl SseDecode for crate::bridge::InitConfig {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_databaseDir = <String>::sse_decode(deserializer);
let mut var_dataDir = <String>::sse_decode(deserializer);
return crate::bridge::InitConfig {
database_dir: var_databaseDir,
data_dir: var_dataDir,
};
}
}
impl SseDecode for isize {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@ -863,18 +875,6 @@ impl SseDecode for crate::bridge::OtherPromotion {
}
}
impl SseDecode for crate::bridge::TwonlyConfig {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
let mut var_databasePath = <String>::sse_decode(deserializer);
let mut var_dataDirectory = <String>::sse_decode(deserializer);
return crate::bridge::TwonlyConfig {
database_path: var_databasePath,
data_directory: var_dataDirectory,
};
}
}
impl SseDecode for u32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self {
@ -985,6 +985,22 @@ impl flutter_rust_bridge::IntoIntoDart<crate::bridge::wrapper::user_discovery::F
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::bridge::InitConfig {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.database_dir.into_into_dart().into_dart(),
self.data_dir.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::bridge::InitConfig {}
impl flutter_rust_bridge::IntoIntoDart<crate::bridge::InitConfig> for crate::bridge::InitConfig {
fn into_into_dart(self) -> crate::bridge::InitConfig {
self
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for FrbWrapper<crate::bridge::OtherPromotion> {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
@ -1012,24 +1028,6 @@ impl flutter_rust_bridge::IntoIntoDart<FrbWrapper<crate::bridge::OtherPromotion>
self.into()
}
}
// Codec=Dco (DartCObject based), see doc to use other codecs
impl flutter_rust_bridge::IntoDart for crate::bridge::TwonlyConfig {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
[
self.database_path.into_into_dart().into_dart(),
self.data_directory.into_into_dart().into_dart(),
]
.into_dart()
}
}
impl flutter_rust_bridge::for_generated::IntoDartExceptPrimitive for crate::bridge::TwonlyConfig {}
impl flutter_rust_bridge::IntoIntoDart<crate::bridge::TwonlyConfig>
for crate::bridge::TwonlyConfig
{
fn into_into_dart(self) -> crate::bridge::TwonlyConfig {
self
}
}
impl SseEncode for flutter_rust_bridge::for_generated::anyhow::Error {
// Codec=Sse (Serialization based), see doc to use other codecs
@ -1087,6 +1085,14 @@ impl SseEncode for i64 {
}
}
impl SseEncode for crate::bridge::InitConfig {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.database_dir, serializer);
<String>::sse_encode(self.data_dir, serializer);
}
}
impl SseEncode for isize {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
@ -1189,14 +1195,6 @@ impl SseEncode for crate::bridge::OtherPromotion {
}
}
impl SseEncode for crate::bridge::TwonlyConfig {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
<String>::sse_encode(self.database_path, serializer);
<String>::sse_encode(self.data_directory, serializer);
}
}
impl SseEncode for u32 {
// Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {

View file

@ -1,32 +0,0 @@
struct TwonlyIdentity {}
struct NostrIdentity {}
struct KeyManager {
main_key: [u8; 32],
}
impl KeyManager {
fn try_from_keychain() -> KeyManager {
todo!();
}
fn create_new() {
// generates main_key
// generates signal identity
// generates nostr identity
}
fn get_signal_identity() {}
fn recover_from_trusted_friends() {
//
}
fn generate_backup_key() {}
fn recover_from_backup() {
//
}
}

73
rust/src/keys/README.md Normal file
View file

@ -0,0 +1,73 @@
# Cryptographic Architecture
## 1. Main Key
A cryptographically secure, immutable master key. Loss of this key, in the absence of valid backups, results in permanent loss of account access.
*Key Derivation*: Utilizes HKDF to derive subordinate keys.
- Authentication Token: Uploaded to the server for session authentication.
- Backup Key: Used to encrypt a backup
- Backup Content: The encrypted backup encompasses
- Main Key
- Identity keys
- Local database (including contacts, public identities, memories (only references), and messages).
- Lifecycle: Backups are refreshed daily and deleted after one year.
- Media Main Key: Used to wrap media-specific keys.
- A new, cryptographically secure key is generated for every media file.
- The media key is wrapped using AES-GCM and the Main Media Key and stored in the online database along side to the uploaded media file database entry.
- The original media file is encrypted using AES-GCM and uploaded to the designated storage bucket.
## 3. Identity Keys
- Signal Identity
- Generates a private and public key pair for secure communication.
- Nostr Identity
- Generates a private and public key pair for Nostr network interactions.
## 1. Backup Keys
Independent, securely generated keys used to wrap the primary backup key.
### 1.1. Password-Based Backup
1. Derivation
- Utilizes scrypt with the username as the salt (cost 65536) to derive a 64-byte sequence.
2. Allocation:
- 32 bytes: Backup ID, used as the identifier to locate the backup on the server.
- 32 bytes: Backup wrapper key.
3. Content
- The payload contains the main key required to generate the auth token and the backup key.
4. Operation
- The backup wrapper key encrypts the main key. The ciphertext is uploaded anonymously to the server, indexed by the Backup ID.
5. Security Measures
- The server enforces strict rate limiting per IP address to prevent brute-force attacks.
6. Lifecycle
- These backup keys require a monthly refresh; otherwise, they are scheduled for deletion after two years.
### 1.2. Trusted Friends Keys (Passwordless Recovery)
1. Initiation
- The recovering user generates a temporary ID (TempID) and a new ephemeral asymmetric key pair.
2. Request
- A recovery request containing the TempID and the public key is transmitted to a trusted contact via a secure link.
3. Verification
- The contact manually verifies the requestor's identity within their application to mitigate phishing risks.
4. Share Transmission
- The contact encrypts a trusted friend share using the provided public key. This share includes the user IDs, the minimum threshold required for decryption, and the cryptographic share (utilizing Shamir's Secret Sharing).
5. Reconstruction
- Upon receiving the required threshold of shares, the user reconstructs the shared secret data.
6. Second Factor (Optional)
- The shared secret data may mandate an additional factor (PIN or Email). For a PIN factor, an unlock token and a PIN seed are used to securely retrieve the remaining share from the server without exposing the raw PIN.
7. Final Recovery
- The decrypted recovery data provides the User ID, private key, and the backup master key necessary to restore the account and its backups.
## 4. Web Portal Upload Protocol
1. Initialization
- The web portal generates a cryptographically secure symmetric key for end-to-end encrypted (E2EE) communication with the mobile application, alongside a newly registered session token.
2. Handshake
- The mobile application scans the QR code containing the session token and the symmetric key.
3. Authorization
- The application signals readiness via the server using the session token and securely provisions a temporary authentication token for media uploads over the established symmetric E2EE channel.
4. Key Exchange
- The web portal encrypts the media file using a newly generated media key. It transmits this media key to the application (encrypted via the E2EE symmetric key) and receives the wrapped media key in return.
5. Upload
- The web portal uploads the encrypted media file to the server, assigning it a device ID of 0.
6. Synchronization
- Finally, the application requests all memories with a device ID lower than its current one (the device ID increments after a backup restoration).

View file

View file

@ -0,0 +1 @@
mod types;

View file

@ -0,0 +1,83 @@
use serde::{Deserialize, Serialize};
/// Send from the person who tries to recover their account.
/// This can be done via a link, which will then be opened in the app of the contact.
/// The contact then has to manually select from which user he got the request.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RecoveryRequest {
pub temp_id: i64,
pub public_key: Vec<u8>,
}
/// Used as envelope for TrustedFriendShare and RecoveryData
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct EncryptedEnvelope {
pub encrypted_data: Vec<u8>,
pub iv: Vec<u8>,
pub mac: Vec<u8>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct User {
pub user_id: i64,
pub display_name: String,
pub avatar: Vec<u8>,
}
/// Send from the trusted friend.
/// This is encrypted with the received public key.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TrustedFriendShare {
/// This allows to display the user which user has send him his recovery data.
pub trusted_friend: User,
/// This allows to display the userdata, showing that he is recovering the correct person.
pub share_user: User,
/// The minimum threshold required to decrypt the shares.
pub threshold: i32,
/// The actual share which will become: SecretSharedData
pub share: Vec<u8>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SecondFactorPin {
/// Required to try the PIN to get the share from the server.
/// This prevents that someone else can lock the pin, as the server only
/// allows 3 tries then after 1 day again 3 tries until the key is deleted.
pub unlock_token: Vec<u8>,
/// This never is send to the server but used to hash the pin before sending it to the server.
/// This prevents that the server every knows the short 4-digit PIN.
pub pin_seed: Vec<u8>,
/// The recovery data in case a second factor was used
/// The decryption key is loaded from the server either using the PIN or the MAIL
pub recovery_data_encrypted: Vec<u8>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SecondFactorMail {
/// The users selected mail which will be send to the server
/// To this mail the encryption key for the recovery_data is send
pub mail: String,
/// Required to try the PIN to get the share from the server.
/// This prevents that someone else can lock the pin, as the server only
/// allows 3 tries then after 1 day again 3 tries until the key is deleted.
pub unlock_token: Vec<u8>,
/// The recovery data in case a second factor was used
/// The decryption key is loaded from the server either using the PIN or the MAIL
pub recovery_data_encrypted: Vec<u8>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub enum SecretSharedData {
None(RecoveryData),
Mail(SecondFactorMail),
Pin(SecondFactorPin),
}
/// The data which is recovered at the end.
/// The backup_master_key allows to recover the actual backup uploaded in the background to the server.
/// In case the backup is not available any more the user can use its user_id and his private_key to register as a new user.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct RecoveryData {
pub user_id: i64,
pub master_key: Vec<u8>,
}

View file

@ -0,0 +1,8 @@
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Zeroize, ZeroizeOnDrop, Serialize, Deserialize)]
pub(crate) enum IdentityKey {
Nost(),
Signal(),
}

256
rust/src/keys/main_key.rs Normal file
View file

@ -0,0 +1,256 @@
use crate::error::Result;
use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use hkdf::Hkdf;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use zeroize::{Zeroize, ZeroizeOnDrop};
/// `MainKey` is responsible for handling the cryptographically secure, immutable master key.
/// It uses HKDF to derive subordinate keys (Authentication Token, Backup Key, Media Main Key).
#[derive(Zeroize, ZeroizeOnDrop, Serialize, Deserialize)]
pub struct MainKey {
/// The 32-byte main master key
main_key: [u8; 32],
}
#[derive(Debug)]
pub(crate) enum DatabaseKey {
RustDb,
}
impl MainKey {
/// Generates a new cryptographically secure MainKey.
pub fn generate() -> Self {
let mut main_key = [0u8; 32];
OsRng.fill_bytes(&mut main_key);
Self { main_key }
}
/// Initializes a MainKey from an existing main key.
pub fn from_main_key(main_key: [u8; 32]) -> Self {
Self { main_key }
}
/// Derives the database encryption key.
pub(crate) fn get_database_key(&self, db: DatabaseKey) -> String {
let db_name = match db {
DatabaseKey::RustDb => b"rust_db",
};
let info = [b"database_key_", db_name as &[u8]].concat();
let key = self.derive_key(&info);
hex::encode(key)
}
/// Derives the authentication token uploaded to the server for session authentication.
pub fn get_authentication_token(&self) -> [u8; 32] {
self.derive_key(b"auth_token")
}
/// Encrypts a backup payload.
/// The backup key is derived using HKDF from the main key.
pub fn encrypt_backup(&self, backup_payload: &[u8]) -> Vec<u8> {
self.encrypt_with_info(b"backup_key", backup_payload)
}
/// Decrypts a backup payload.
pub fn decrypt_backup(&self, encrypted_backup: &[u8]) -> Result<Vec<u8>> {
self.decrypt_with_info(b"backup_key", encrypted_backup)
}
/// Encrypts a newly generated media key using the derived Media Main Key.
pub fn encrypt_media_key(&self, media_key: &[u8; 32]) -> Vec<u8> {
self.encrypt_with_info(b"media_main_key", media_key)
}
/// Decrypts a wrapped media key using the derived Media Main Key.
pub fn decrypt_media_key(&self, wrapped_media_key: &[u8]) -> Result<[u8; 32]> {
let decrypted = self.decrypt_with_info(b"media_main_key", wrapped_media_key)?;
if decrypted.len() != 32 {
return Err("Invalid decrypted key length".to_string())?;
}
let mut result = [0u8; 32];
result.copy_from_slice(&decrypted);
Ok(result)
}
fn derive_key(&self, info: &[u8]) -> [u8; 32] {
let hk = Hkdf::<Sha256>::new(None, &self.main_key);
let mut okm = [0u8; 32];
hk.expand(info, &mut okm).expect("HKDF expand failed");
okm
}
fn encrypt_with_info(&self, info: &[u8], payload: &[u8]) -> Vec<u8> {
let derived_key = self.derive_key(info);
let key = Key::<Aes256Gcm>::from_slice(&derived_key);
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ciphertext = cipher
.encrypt(&nonce, payload)
.expect("encryption failure!");
let mut result = nonce.to_vec();
result.extend_from_slice(&ciphertext);
result
}
fn decrypt_with_info(&self, info: &[u8], encrypted_data: &[u8]) -> Result<Vec<u8>> {
if encrypted_data.len() < 12 {
return Err("Invalid encrypted data length".to_string())?;
}
let derived_key = self.derive_key(info);
let key = Key::<Aes256Gcm>::from_slice(&derived_key);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(&encrypted_data[..12]);
let ciphertext = &encrypted_data[12..];
Ok(cipher
.decrypt(nonce, ciphertext)
.map_err(|_| "Decryption failure".to_string())?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_and_from_main_key() {
let km = MainKey::generate();
let km2 = MainKey::from_main_key(km.main_key);
assert_eq!(km.main_key, km2.main_key);
}
#[test]
fn test_get_authentication_token() {
let km1 = MainKey::generate();
let token1 = km1.get_authentication_token();
let km2 = MainKey::from_main_key(km1.main_key);
let token2 = km2.get_authentication_token();
// Tokens derived from the same main key should match
assert_eq!(token1, token2);
let km3 = MainKey::generate();
let token3 = km3.get_authentication_token();
// Different main keys should produce different tokens
assert_ne!(token1, token3);
}
#[test]
fn test_backup_encryption_decryption_success() {
let km = MainKey::generate();
let payload = b"this is a secret backup payload";
let encrypted = km.encrypt_backup(payload);
let decrypted = km.decrypt_backup(&encrypted).unwrap();
assert_eq!(payload.as_slice(), decrypted.as_slice());
}
#[test]
fn test_backup_decryption_tampered_payload_fails() {
let km = MainKey::generate();
let payload = b"this is a secret backup payload";
let mut encrypted = km.encrypt_backup(payload);
// Tamper with the ciphertext (assuming length > 12)
let last_idx = encrypted.len() - 1;
encrypted[last_idx] ^= 1; // Flip a bit
let result = km.decrypt_backup(&encrypted);
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "Decryption failure");
}
#[test]
fn test_backup_decryption_too_short_fails() {
let km = MainKey::generate();
let short_payload = vec![0u8; 10]; // Less than 12 bytes nonce
let result = km.decrypt_backup(&short_payload);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Invalid encrypted data length"
);
}
#[test]
fn test_media_key_encryption_decryption_success() {
let km = MainKey::generate();
let mut media_key = [0u8; 32];
OsRng.fill_bytes(&mut media_key);
let encrypted = km.encrypt_media_key(&media_key);
let decrypted = km.decrypt_media_key(&encrypted).unwrap();
assert_eq!(media_key, decrypted);
}
#[test]
fn test_media_key_decryption_tampered_payload_fails() {
let km = MainKey::generate();
let mut media_key = [0u8; 32];
OsRng.fill_bytes(&mut media_key);
let mut encrypted = km.encrypt_media_key(&media_key);
// Tamper with the ciphertext
let last_idx = encrypted.len() - 1;
encrypted[last_idx] ^= 1;
let result = km.decrypt_media_key(&encrypted);
assert!(result.is_err());
assert_eq!(result.unwrap_err().to_string(), "Decryption failure");
}
#[test]
fn test_media_key_decryption_too_short_fails() {
let km = MainKey::generate();
let short_payload = vec![0u8; 10]; // Less than 12 bytes nonce
let result = km.decrypt_media_key(&short_payload);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Invalid encrypted data length"
);
}
#[test]
fn test_media_key_decryption_wrong_decrypted_length_fails() {
let km = MainKey::generate();
// Manually encrypt a 31 byte payload
let hk = Hkdf::<Sha256>::new(None, &km.main_key);
let mut media_main_key = [0u8; 32];
hk.expand(b"media_main_key", &mut media_main_key)
.expect("HKDF expand failed");
let key = Key::<Aes256Gcm>::from_slice(&media_main_key);
let cipher = Aes256Gcm::new(key);
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let payload = vec![0u8; 31];
let ciphertext = cipher
.encrypt(&nonce, payload.as_ref())
.expect("encryption failure");
let mut encrypted = nonce.to_vec();
encrypted.extend_from_slice(&ciphertext);
let result = km.decrypt_media_key(&encrypted);
assert!(result.is_err());
assert_eq!(
result.unwrap_err().to_string(),
"Invalid decrypted key length"
);
}
}

52
rust/src/keys/mod.rs Normal file
View file

@ -0,0 +1,52 @@
mod backup_password;
mod backup_passwordless;
mod identity_key;
mod main_key;
pub(crate) use crate::keys::main_key::{DatabaseKey, MainKey};
use crate::secure_storage::SecureStorage;
use crate::{error::Result, keys::identity_key::IdentityKey};
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop};
const KEY_MANAGER_ID: &str = "twonly_key_manager";
#[derive(Zeroize, ZeroizeOnDrop, Serialize, Deserialize)]
pub(crate) struct KeyManager {
pub(crate) main_key: MainKey,
pub(crate) identity_keys: Vec<IdentityKey>,
}
impl KeyManager {
pub fn generate() -> Result<Self> {
Ok(KeyManager {
main_key: MainKey::generate(),
identity_keys: vec![],
})
}
/// Tries to load the KeyManager from the secure keychain/local storage.
pub fn try_from_keychain(storage: &SecureStorage) -> Result<Self> {
let hex_key = storage
.read(KEY_MANAGER_ID)?
.ok_or_else(|| "Main key not found in keychain".to_string())?;
let bytes = hex::decode(hex_key).map_err(|e| format!("Failed to decode hex key: {}", e))?;
let main_key: KeyManager = postcard::from_bytes(&bytes)
.map_err(|e| format!("Failed to deserialize KeyManager: {}", e))?;
Ok(main_key)
}
/// Stores the main key into the secure keychain/local storage.
pub fn store_to_keychain(&self, storage: &SecureStorage) -> Result<()> {
let serialized = postcard::to_allocvec(self)
.map_err(|e| format!("Failed to serialize KeyManager: {}", e))?;
let hex_key = hex::encode(serialized);
storage.write(KEY_MANAGER_ID, &hex_key)?;
Ok(())
}
}

View file

@ -1,5 +1,11 @@
mod backup;
pub mod bridge;
mod context;
mod database;
mod error;
mod frb_generated;
mod keys;
mod log;
mod secure_storage;
mod standalone;
mod utils;

View file

@ -68,7 +68,7 @@ fn build_writers(logs_dir: &std::path::Path) -> (NonBlocking, NonBlocking) {
}
Err(e) => {
eprintln!("Failed to create file appender: {}", e);
let (nb, guard) = tracing_appender::non_blocking(std::io::sink());
let (nb, _guard) = tracing_appender::non_blocking(std::io::sink());
(nb, None)
}
};

132
rust/src/secure_storage.rs Normal file
View file

@ -0,0 +1,132 @@
use keyring_core::{Entry, Error as KeyringError};
/// A simple wrapper around `keyring-core` for secure storage on iOS, Android, and other platforms.
///
/// IMPORTANT: This struct assumes that a `keyring-core` default store has been initialized
/// (e.g., via `keyring_core::set_default_store`). In the White Noise project, this is handled
/// during application startup in `Whitenoise::initialize_keyring_store`.
pub struct SecureStorage {
service_name: String,
}
impl SecureStorage {
/// Creates a new `SecureStorage` instance with the specified service name.
/// The service name is used as a namespace in the system keyring.
pub fn new(service_name: &str) -> Self {
Self {
service_name: service_name.to_string(),
}
}
/// Initializes the platform-native secure storage backend for iOS and Android.
///
/// # Arguments
/// * `group_id` - (iOS only) Optional App Group ID to allow cross-process keychain access.
///
/// This function registers the appropriate credential store (Protected Store for iOS,
/// Keystore for Android) with `keyring-core`. It is safe to call multiple times.
pub fn init() -> Result<(), String> {
if keyring_core::get_default_store().is_some() {
return Ok(());
}
#[cfg(target_os = "ios")]
{
let group = "CN332ZUGRP.eu.twonly.shared";
let store = apple_native_keyring_store::protected::Store::with_application_group(group)
.map_err(|e| format!("Failed to init iOS Protected Store: {}", e))?;
keyring_core::set_default_store(store);
}
#[cfg(target_os = "android")]
{
let store = android_native_keyring_store::Store::new()
.map_err(|e| format!("Failed to init Android Store: {}", e))?;
keyring_core::set_default_store(store);
}
#[cfg(not(any(target_os = "ios", target_os = "android")))]
{
let store = keyring_core::mock::Store::new()
.map_err(|e| format!("Failed to init Mock Store: {}", e))?;
keyring_core::set_default_store(store);
tracing::warn!("Using mock store as default keyring store!");
}
Ok(())
}
/// Writes a secret value to the secure keyring associated with the given key.
///
/// # Arguments
/// * `key` - The identifier (account name) for the secret.
/// * `value` - The secret string to store.
pub fn write(&self, key: &str, value: &str) -> Result<(), String> {
let entry = Entry::new(&self.service_name, key)
.map_err(|e| format!("Failed to create keyring entry: {}", e))?;
entry
.set_password(value)
.map_err(|e| format!("Failed to write secret to keyring: {}", e))?;
Ok(())
}
/// Reads a secret value from the secure keyring associated with the given key.
///
/// Returns `Ok(Some(String))` if the key exists, `Ok(None)` if it doesn't,
/// or an `Err` if a system error occurs.
pub fn read(&self, key: &str) -> Result<Option<String>, String> {
let entry = Entry::new(&self.service_name, key)
.map_err(|e| format!("Failed to create keyring entry: {}", e))?;
match entry.get_password() {
Ok(password) => Ok(Some(password)),
Err(KeyringError::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to read secret from keyring: {}", e)),
}
}
/// Deletes the secret associated with the given key from the secure keyring.
///
/// If the key does not exist, this function returns `Ok(())` (idempotent).
pub fn delete(&self, key: &str) -> Result<(), String> {
let entry = Entry::new(&self.service_name, key)
.map_err(|e| format!("Failed to create keyring entry: {}", e))?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(KeyringError::NoEntry) => Ok(()),
Err(e) => Err(format!("Failed to delete secret from keyring: {}", e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_secure_storage_flow() {
// Initialize the store (will use MockStore on non-mobile platforms)
SecureStorage::init().unwrap();
let storage = SecureStorage::new("eu.twonly.test");
let key = "test_secret_key";
let secret = "my_awesome_secret_123";
// 1. Write the secret
storage.write(key, secret).expect("Failed to write secret");
// 2. Read the secret and verify it matches
let read_val = storage.read(key).expect("Failed to read secret");
assert_eq!(read_val, Some(secret.to_string()));
// 3. Delete the secret
storage.delete(key).expect("Failed to delete secret");
// 4. Verify the secret is gone
let after_delete = storage.read(key).expect("Failed to read after delete");
assert_eq!(after_delete, None);
}
}

11
rust/src/standalone.rs Normal file
View file

@ -0,0 +1,11 @@
use crate::bridge::InitConfig;
use crate::database::Database;
use crate::secure_storage::SecureStorage;
use std::sync::Arc;
pub(crate) struct TwonlyStandalone {
#[allow(dead_code)]
pub(crate) config: InitConfig,
pub(crate) rust_db: Arc<Database>,
pub(crate) secure_storage: SecureStorage,
}

View file

@ -1,9 +0,0 @@
// use crate::{bridge::TwonlyConfig, database::Database};
// use std::sync::Arc;
// pub(crate) struct TwonlyStandalone {
// #[allow(dead_code)]
// pub(crate) config: TwonlyConfig,
// /// Because Rust is called from a different process it is safe to write to the twonly_db.
// pub(crate) twonly_db: Arc<Database>,
// }