keyring works

This commit is contained in:
otsmr 2026-05-09 14:58:59 +02:00
parent f323bc03eb
commit 5fa253ec32
29 changed files with 579 additions and 211 deletions

View file

@ -6,6 +6,8 @@ import dev.darttools.flutter_android_volume_keydown.FlutterAndroidVolumeKeydownP
import android.view.KeyEvent.KEYCODE_VOLUME_DOWN import android.view.KeyEvent.KEYCODE_VOLUME_DOWN
import android.view.KeyEvent.KEYCODE_VOLUME_UP import android.view.KeyEvent.KEYCODE_VOLUME_UP
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import android.content.Context
import io.crates.keyring.Keyring
class MainActivity : FlutterFragmentActivity() { class MainActivity : FlutterFragmentActivity() {
@ -24,6 +26,8 @@ class MainActivity : FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
Keyring.initializeNdkContext(applicationContext)
MediaStoreChannel.configure(flutterEngine, applicationContext) MediaStoreChannel.configure(flutterEngine, applicationContext)
VideoCompressionChannel.configure(flutterEngine, applicationContext) VideoCompressionChannel.configure(flutterEngine, applicationContext)
} }

View file

@ -0,0 +1,14 @@
package io.crates.keyring
import android.content.Context
class Keyring {
companion object {
init {
// Replace with the name of your compiled Rust library
System.loadLibrary("rust_lib_twonly")
}
// The underlying Rust crate provides the implementation for this
external fun initializeNdkContext(context: Context)
}
}

View file

@ -38,9 +38,9 @@ final _initMutex = Mutex();
/// This function is used to initialized the absolute minimum so it /// This function is used to initialized the absolute minimum so it
/// can also be used by the backend without the UI was loaded. /// can also be used by the backend without the UI was loaded.
Future<void> twonlyMinimumInitialization() async { Future<bool> twonlyMinimumInitialization() async {
Log.info('twonlyMinimumInitialization: called'); Log.info('twonlyMinimumInitialization: called');
await exclusiveAccess( final hasStorageError = await exclusiveAccess(
lockName: 'init', lockName: 'init',
mutex: _initMutex, mutex: _initMutex,
action: () async { action: () async {
@ -54,15 +54,22 @@ Future<void> twonlyMinimumInitialization() async {
await initFlutterCallbacksForRust(); await initFlutterCallbacksForRust();
Log.info('twonlyMinimumInitialization: bridge.initializeTwonlyFlutter()'); Log.info('twonlyMinimumInitialization: bridge.initializeTwonlyFlutter()');
try {
await bridge.initializeTwonlyFlutter( await bridge.initializeTwonlyFlutter(
config: bridge.InitConfig( config: bridge.InitConfig(
databaseDir: AppEnvironment.supportDir, databaseDir: AppEnvironment.supportDir,
dataDir: AppEnvironment.supportDir, dataDir: AppEnvironment.supportDir,
), ),
); );
} catch (e) {
Log.error(e);
return true;
}
Log.info('twonlyMinimumInitialization: finished'); Log.info('twonlyMinimumInitialization: finished');
return false;
}, },
); );
return hasStorageError;
} }
void main() async { void main() async {
@ -72,19 +79,19 @@ void main() async {
unawaited(StartupGuard.markAppStartup()); unawaited(StartupGuard.markAppStartup());
await twonlyMinimumInitialization(); var storageError = await twonlyMinimumInitialization();
unawaited(initFCMService()); unawaited(initFCMService());
var userExists = false; var userExists = false;
var storageError = false;
if (!storageError) {
try { try {
userExists = await userService.tryInit(); userExists = await userService.tryInit();
} catch (e) { } catch (e) {
Log.error('Failed to initialize user session due to storage error: $e'); Log.error('Failed to initialize user session due to storage error: $e');
storageError = true; storageError = true;
} }
}
if (Platform.isIOS && userExists) { if (Platform.isIOS && userExists) {
final dbFile = File('${AppEnvironment.supportDir}/twonly.sqlite'); final dbFile = File('${AppEnvironment.supportDir}/twonly.sqlite');

View file

@ -1511,7 +1511,7 @@ abstract class AppLocalizations {
/// No description provided for @backupPasswordRequirement. /// No description provided for @backupPasswordRequirement.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'Password must be at least 8 characters long.'** /// **'Password must be at least 10 characters long.'**
String get backupPasswordRequirement; String get backupPasswordRequirement;
/// No description provided for @backupExpertSettings. /// No description provided for @backupExpertSettings.

View file

@ -777,7 +777,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get backupPasswordRequirement => String get backupPasswordRequirement =>
'Das Passwort muss mindestens 8 Zeichen lang sein.'; 'Das Passwort muss mindestens 10 Zeichen lang sein.';
@override @override
String get backupExpertSettings => 'Experteneinstellungen'; String get backupExpertSettings => 'Experteneinstellungen';

View file

@ -771,7 +771,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get backupPasswordRequirement => String get backupPasswordRequirement =>
'Password must be at least 8 characters long.'; 'Password must be at least 10 characters long.';
@override @override
String get backupExpertSettings => 'Expert settings'; String get backupExpertSettings => 'Expert settings';

View file

@ -108,10 +108,9 @@ Future<void> handleBackupData(
key: SecureStorageKeys.signalSignedPreKey, key: SecureStorageKeys.signalSignedPreKey,
value: secureStorage[SecureStorageKeys.signalSignedPreKey] as String, value: secureStorage[SecureStorageKeys.signalSignedPreKey] as String,
); );
await storage.write( final userDataMap = jsonDecode(secureStorage[SecureStorageKeys.userData] as String) as Map<String, dynamic>;
key: SecureStorageKeys.userData, final userData = UserData.fromJson(userDataMap);
value: secureStorage[SecureStorageKeys.userData] as String, await UserService.save(userData);
);
await UserService.update((u) { await UserService.update((u) {
u.deviceId += 1; u.deviceId += 1;
}); });

View file

@ -7,6 +7,7 @@ import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/model/json/userdata.model.dart'; import 'package:twonly/src/model/json/userdata.model.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/secure_storage.dart'; import 'package:twonly/src/utils/secure_storage.dart';
import 'package:twonly/src/utils/keyvalue.dart';
class UserService { class UserService {
late UserData currentUser; late UserData currentUser;
@ -26,21 +27,42 @@ class UserService {
static Future<UserData?> getUser() async { static Future<UserData?> getUser() async {
try { try {
// 1. Try to load from KeyValueStore (user.json)
final userDataMap = await KeyValueStore.get('user');
if (userDataMap != null) {
return UserData.fromJson(userDataMap);
}
// 2. If not found, try to load from SecureStorage (Migration path)
final userDataJson = await SecureStorage.instance.read( final userDataJson = await SecureStorage.instance.read(
key: SecureStorageKeys.userData, key: SecureStorageKeys.userData,
); );
if (userDataJson == null) {
return null; if (userDataJson != null) {
} final userData = UserData.fromJson(
return UserData.fromJson(
jsonDecode(userDataJson) as Map<String, dynamic>, jsonDecode(userDataJson) as Map<String, dynamic>,
); );
// 3. Run migration
await _migrateFromSecureStorage(userData);
return userData;
}
return null;
} catch (e) { } catch (e) {
Log.error('could not load user: $e'); Log.error('could not load user: $e');
rethrow; // Rethrow instead of returning null to distinguish error from missing user rethrow;
} }
} }
static Future<void> _migrateFromSecureStorage(UserData userData) async {
// Currently empty migration logic as requested, but we MUST store the data
await KeyValueStore.put('user', userData.toJson());
// Optional: Log migration
Log.info('Migrated user data from SecureStorage to KeyValueStore');
}
static Future<void> update( static Future<void> update(
void Function(UserData userData) updateUser, void Function(UserData userData) updateUser,
) async { ) async {
@ -53,10 +75,7 @@ class UserService {
user.defaultShowTime = null; user.defaultShowTime = null;
} }
updateUser(user); updateUser(user);
await SecureStorage.instance.write( await KeyValueStore.put('user', user.toJson());
key: SecureStorageKeys.userData,
value: jsonEncode(user),
);
userService.currentUser = user; userService.currentUser = user;
} catch (e) { } catch (e) {
Log.error('Could not update the user: $e'); Log.error('Could not update the user: $e');
@ -66,6 +85,11 @@ class UserService {
userService.triggerUserUpdate(); userService.triggerUserUpdate();
} }
static Future<void> save(UserData user) async {
await KeyValueStore.put('user', user.toJson());
await userService.tryInit();
}
void triggerUserUpdate() { void triggerUserUpdate() {
_userDataUpdateController.add(null); _userDataUpdateController.add(null);
} }

View file

@ -1,22 +1,19 @@
// ignore_for_file: avoid_dynamic_calls // ignore_for_file: avoid_dynamic_calls
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart'; import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/model/json/userdata.model.dart'; import 'package:twonly/src/model/json/userdata.model.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart'; import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
import 'package:twonly/src/services/signal/identity.signal.dart'; import 'package:twonly/src/services/signal/identity.signal.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/pow.dart'; import 'package:twonly/src/utils/pow.dart';
import 'package:twonly/src/utils/secure_storage.dart';
import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/views/groups/group.view.dart'; import 'package:twonly/src/visual/views/groups/group.view.dart';
@ -141,12 +138,7 @@ class _RegisterViewState extends State<RegisterView> {
currentSetupPage: SetupPages.profile.name, currentSetupPage: SetupPages.profile.name,
)..appVersion = AppState.latestAppVersionId; )..appVersion = AppState.latestAppVersionId;
await SecureStorage.instance.write( await UserService.save(userData);
key: SecureStorageKeys.userData,
value: jsonEncode(userData),
);
await userService.tryInit();
await apiService.authenticate(); await apiService.authenticate();
widget.callbackOnSuccess(); widget.callbackOnSuccess();

98
rust/Cargo.lock generated
View file

@ -34,7 +34,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher", "cipher 0.4.4",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
] ]
@ -46,7 +46,7 @@ checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [ dependencies = [
"aead", "aead",
"aes", "aes",
"cipher", "cipher 0.4.4",
"ctr", "ctr",
"ghash", "ghash",
"subtle", "subtle",
@ -357,7 +357,7 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [ dependencies = [
"cipher", "cipher 0.4.4",
] ]
[[package]] [[package]]
@ -389,7 +389,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cipher", "cipher 0.4.4",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
] ]
@ -412,7 +412,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [ dependencies = [
"aead", "aead",
"chacha20 0.9.1", "chacha20 0.9.1",
"cipher", "cipher 0.4.4",
"poly1305", "poly1305",
"zeroize", "zeroize",
] ]
@ -438,10 +438,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [ dependencies = [
"crypto-common 0.1.7", "crypto-common 0.1.7",
"inout", "inout 0.1.4",
"zeroize", "zeroize",
] ]
[[package]]
name = "cipher"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e34d8227fe1ba289043aeb13792056ff80fd6de1a9f49137a5f499de8e8c78ea"
dependencies = [
"block-buffer 0.12.0",
"crypto-common 0.2.1",
"inout 0.2.2",
]
[[package]] [[package]]
name = "cmov" name = "cmov"
version = "0.5.3" version = "0.5.3"
@ -660,7 +671,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [ dependencies = [
"cipher", "cipher 0.4.4",
] ]
[[package]] [[package]]
@ -1711,6 +1722,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "inout"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "instant" name = "instant"
version = "0.1.13" version = "0.1.13"
@ -2095,6 +2115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f"
dependencies = [ dependencies = [
"cc", "cc",
"openssl-sys",
"pkg-config", "pkg-config",
"vcpkg", "vcpkg",
] ]
@ -2287,7 +2308,7 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
"hex", "hex",
"instant", "instant",
"scrypt", "scrypt 0.11.0",
"secp256k1", "secp256k1",
"serde", "serde",
"serde_json", "serde_json",
@ -2470,6 +2491,28 @@ dependencies = [
"tls_codec", "tls_codec",
] ]
[[package]]
name = "openssl-src"
version = "300.5.0+3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f"
dependencies = [
"cc",
]
[[package]]
name = "openssl-sys"
version = "0.9.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781"
dependencies = [
"cc",
"libc",
"openssl-src",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "oslog" name = "oslog"
version = "0.2.0" version = "0.2.0"
@ -2565,6 +2608,16 @@ dependencies = [
"hmac 0.12.1", "hmac 0.12.1",
] ]
[[package]]
name = "pbkdf2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
dependencies = [
"digest 0.11.2",
"hmac 0.13.0",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@ -3134,6 +3187,7 @@ dependencies = [
"prost-build", "prost-build",
"protocols", "protocols",
"rand 0.10.1", "rand 0.10.1",
"scrypt 0.12.0",
"serde", "serde",
"sha2 0.10.9", "sha2 0.10.9",
"sqlx", "sqlx",
@ -3194,7 +3248,17 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213"
dependencies = [ dependencies = [
"cipher", "cipher 0.4.4",
]
[[package]]
name = "salsa20"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f874456e72520ff1375a06c588eaf074b0f01f9e9e1aada45bd9b7954a6e42c"
dependencies = [
"cfg-if",
"cipher 0.5.1",
] ]
[[package]] [[package]]
@ -3219,11 +3283,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [ dependencies = [
"password-hash", "password-hash",
"pbkdf2", "pbkdf2 0.12.2",
"salsa20", "salsa20 0.10.2",
"sha2 0.10.9", "sha2 0.10.9",
] ]
[[package]]
name = "scrypt"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0"
dependencies = [
"cfg-if",
"pbkdf2 0.13.0",
"salsa20 0.11.0",
"sha2 0.11.0",
]
[[package]] [[package]]
name = "sec1" name = "sec1"
version = "0.7.3" version = "0.7.3"

View file

@ -24,7 +24,9 @@ mdk-core = { version = "0.8.0", git = "https://github.com/marmot-protocol/mdk",
] } ] }
mdk-sqlite-storage = { version = "0.8.0", git = "https://github.com/marmot-protocol/mdk", rev = "7f809f8549458a0d7f7d885bcdd694023abf299c" } 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" } 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"] } libsqlite3-sys = { version = "0.35.0", features = [
"bundled-sqlcipher-vendored-openssl",
] }
tokio = { version = "1.44", features = ["full"] } tokio = { version = "1.44", features = ["full"] }
tracing = "0.1.44" tracing = "0.1.44"
rand = "0.10.1" rand = "0.10.1"
@ -42,6 +44,7 @@ keyring-core = "1"
postcard = { version = "1.0", features = ["alloc"] } postcard = { version = "1.0", features = ["alloc"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
zip = { version = "2.2.2", default-features = false, features = ["deflate"] } zip = { version = "2.2.2", default-features = false, features = ["deflate"] }
scrypt = { version = "0.12", default-features = false }
walkdir = "2.5.0" walkdir = "2.5.0"
[target.'cfg(target_os = "ios")'.dependencies] [target.'cfg(target_os = "ios")'.dependencies]
# iOS backend: Requires the 'protected' feature for Data Protection Keychain # iOS backend: Requires the 'protected' feature for Data Protection Keychain

View file

@ -1,7 +1,7 @@
use crate::context::Context; use crate::context::Context;
use crate::database::Database; use crate::database::Database;
use crate::error::Result; use crate::error::{Result, TwonlyError};
use crate::keys::DatabaseKey; use crate::keys::{DatabaseKey, MainKey};
use std::fs::{remove_file, File}; use std::fs::{remove_file, File};
use std::io::{copy, Cursor}; use std::io::{copy, Cursor};
use std::path::PathBuf; use std::path::PathBuf;
@ -23,7 +23,8 @@ impl BackupArchive {
Ok(vec![ Ok(vec![
("twonly.sqlite", database_dir.clone(), true, None), ("twonly.sqlite", database_dir.clone(), true, None),
("rust_db.sqlite", database_dir, true, Some(rust_db_key)), ("rust_db.sqlite", database_dir, true, Some(rust_db_key)),
("user_discovery_config.json", data_dir, false, None), ("user_discovery_config.json", data_dir.clone(), false, None),
("user.json", data_dir.join("keyvalue"), false, None),
]) ])
} }
@ -38,7 +39,7 @@ impl BackupArchive {
std::fs::create_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)? { for (file_name, source_dir, is_db, mut encryption_key) in Self::get_backup_files(ctx)? {
let file_path = source_dir.join(&file_name); let file_path = source_dir.join(file_name);
if !file_path.exists() { if !file_path.exists() {
tracing::warn!( tracing::warn!(
"Could not backup {} as it does not exist.", "Could not backup {} as it does not exist.",
@ -54,16 +55,20 @@ impl BackupArchive {
encryption_key.is_none(), encryption_key.is_none(),
) )
.await?; .await?;
let backup_database_file = backup_data_dir.join(&file_name).display().to_string(); 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()) db.create_backup(backup_database_file.as_str(), encryption_key.as_deref())
.await?; .await?;
} else { } else {
let file_backup = backup_data_dir.join(&file_name); let file_backup = backup_data_dir.join(file_name);
std::fs::copy(file_path, file_backup)?; std::fs::copy(file_path, file_backup)?;
} }
encryption_key.zeroize(); encryption_key.zeroize();
} }
let mut keys = ctx.get_key_manager()?;
let keys_serialized = postcard::to_allocvec(&keys)?;
let mut zip_data = Vec::new(); let mut zip_data = Vec::new();
{ {
@ -71,6 +76,9 @@ impl BackupArchive {
let options = let options =
SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); SimpleFileOptions::default().compression_method(CompressionMethod::Deflated);
zip.start_file(".key_manager.bin", options)?;
copy(&mut keys_serialized.as_slice(), &mut zip)?;
for entry in WalkDir::new(&backup_data_dir) { for entry in WalkDir::new(&backup_data_dir) {
let entry = entry?; let entry = entry?;
let path = entry.path(); let path = entry.path();
@ -87,8 +95,6 @@ impl BackupArchive {
zip.finish()?; zip.finish()?;
} }
let mut keys = ctx.get_key_manager()?;
let zip_path = data_dir.join("temp_backup.zip"); let zip_path = data_dir.join("temp_backup.zip");
std::fs::write(&zip_path, keys.main_key.encrypt_backup(&zip_data))?; std::fs::write(&zip_path, keys.main_key.encrypt_backup(&zip_data))?;
@ -98,13 +104,21 @@ impl BackupArchive {
Ok(zip_path) Ok(zip_path)
} }
pub(crate) async fn restore_from_backup(ctx: &Context, file_path: &PathBuf) -> Result<()> { pub(crate) async fn restore_from_backup(
ctx: &Context,
main_key_bytes: &[u8],
file_path: &PathBuf,
) -> Result<()> {
let data_dir = PathBuf::from(&ctx.get_config()?.data_dir); let data_dir = PathBuf::from(&ctx.get_config()?.data_dir);
let mut keys = ctx.get_key_manager()?; let main_key_arr: [u8; 32] = main_key_bytes
.try_into()
.map_err(|_| TwonlyError::Generic("Invalid main key length".to_string()))?;
let mut main_key = MainKey::from_main_key(main_key_arr);
let encrypted_zip = std::fs::read(file_path)?; let encrypted_zip = std::fs::read(file_path)?;
let zip_content = keys.main_key.decrypt_backup(&encrypted_zip)?; let zip_content = main_key.decrypt_backup(&encrypted_zip)?;
let restore_temp_dir = data_dir.join("restore_temp"); let restore_temp_dir = data_dir.join("restore_temp");
@ -120,6 +134,15 @@ impl BackupArchive {
let mut file = archive.by_index(i)?; let mut file = archive.by_index(i)?;
if file.is_file() { if file.is_file() {
let name = file.name().to_string();
if name == ".key_manager.bin" {
let mut data = Vec::new();
copy(&mut file, &mut data)?;
let key_manager: crate::keys::KeyManager = postcard::from_bytes(&data)?;
key_manager.store_to_keychain(ctx.get_secure_storage()?)?;
continue;
}
let enclosed_name = file.enclosed_name(); let enclosed_name = file.enclosed_name();
if let Some(name) = enclosed_name.as_ref().and_then(|p| p.file_name()) { if let Some(name) = enclosed_name.as_ref().and_then(|p| p.file_name()) {
let restored_file = restore_temp_dir.join(name); let restored_file = restore_temp_dir.join(name);
@ -129,9 +152,9 @@ impl BackupArchive {
} }
for (file_name, target_dir, is_db, _) in Self::get_backup_files(ctx)? { for (file_name, target_dir, is_db, _) in Self::get_backup_files(ctx)? {
let src = restore_temp_dir.join(&file_name); let src = restore_temp_dir.join(file_name);
if src.exists() { if src.exists() {
let dst = target_dir.join(&file_name); let dst = target_dir.join(file_name);
if is_db { if is_db {
// Remove existing database and its temporary files (WAL, SHM) // Remove existing database and its temporary files (WAL, SHM)
let _ = remove_file(&dst); let _ = remove_file(&dst);
@ -143,7 +166,7 @@ impl BackupArchive {
} }
} }
keys.zeroize(); main_key.zeroize();
std::fs::remove_dir_all(&restore_temp_dir)?; std::fs::remove_dir_all(&restore_temp_dir)?;
Ok(()) Ok(())
@ -152,6 +175,8 @@ impl BackupArchive {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{database::tables::received_messages::ReceivedMessage, keys::KeyManager};
use super::*; use super::*;
use tempfile::tempdir; use tempfile::tempdir;
@ -172,7 +197,14 @@ mod tests {
{ {
let config = ctx.get_config().unwrap(); let config = ctx.get_config().unwrap();
let rust_db_path = PathBuf::from(&config.database_dir).join("rust_db.sqlite"); let rust_db_path = PathBuf::from(&config.database_dir).join("rust_db.sqlite");
let key_manager = ctx.get_key_manager().unwrap(); let mut key_manager = ctx.get_key_manager().unwrap();
key_manager
.identity_keys
.push(crate::keys::IdentityKey::Nost());
key_manager
.store_to_keychain(ctx.get_secure_storage().unwrap())
.unwrap();
let db = Database::new( let db = Database::new(
&rust_db_path.display().to_string(), &rust_db_path.display().to_string(),
Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)), Some(&key_manager.main_key.get_database_key(DatabaseKey::RustDb)),
@ -181,11 +213,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
crate::database::tables::received_messages::ReceivedMessage::insert( ReceivedMessage::insert(&db.pool, 1, b"original message")
&db.pool,
"sender1",
b"original message",
)
.await .await
.unwrap(); .unwrap();
@ -198,6 +226,9 @@ mod tests {
let backup_path = BackupArchive::create_backup(&ctx).await.unwrap(); let backup_path = BackupArchive::create_backup(&ctx).await.unwrap();
assert!(backup_path.exists()); assert!(backup_path.exists());
// Save the original main key bytes
let original_main_key = *ctx.get_key_manager().unwrap().main_key.as_bytes();
// 3. Modify data (to simulate state before restore) // 3. Modify data (to simulate state before restore)
{ {
let config = ctx.get_config().unwrap(); let config = ctx.get_config().unwrap();
@ -211,20 +242,23 @@ mod tests {
.await .await
.unwrap(); .unwrap();
crate::database::tables::received_messages::ReceivedMessage::insert( ReceivedMessage::insert(&db.pool, 2, b"new message")
&db.pool,
"sender2",
b"new message",
)
.await .await
.unwrap(); .unwrap();
let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json"); let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json");
std::fs::write(config_file, "new config").unwrap(); std::fs::write(config_file, "new config").unwrap();
// Delete old keys to ensure they will be actually restored
let key_manager = KeyManager::generate().unwrap();
key_manager
.store_to_keychain(&ctx.get_secure_storage().unwrap())
.unwrap();
} }
// 4. Restore backup // 4. Restore backup
BackupArchive::restore_from_backup(&ctx, &backup_path) BackupArchive::restore_from_backup(&ctx, &original_main_key, &backup_path)
.await .await
.unwrap(); .unwrap();
@ -241,18 +275,22 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let messages = let messages = ReceivedMessage::get_all(&db.pool).await.unwrap();
crate::database::tables::received_messages::ReceivedMessage::get_all(&db.pool)
.await
.unwrap();
// Should only have the original message because restore overwrites // Should only have the original message because restore overwrites
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, "sender1"); assert_eq!(messages[0].sender_id, 1);
assert_eq!(messages[0].content, b"original message"); assert_eq!(messages[0].content, b"original message");
let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json"); let config_file = PathBuf::from(&config.data_dir).join("user_discovery_config.json");
let config_content = std::fs::read_to_string(config_file).unwrap(); let config_content = std::fs::read_to_string(config_file).unwrap();
assert_eq!(config_content, "original config"); assert_eq!(config_content, "original config");
let key_manager = ctx.get_key_manager().unwrap();
assert_eq!(key_manager.identity_keys.len(), 1);
match &key_manager.identity_keys[0] {
crate::keys::IdentityKey::Nost() => {}
_ => panic!("Wrong identity key!"),
}
} }
} }
} }

View file

@ -0,0 +1,115 @@
use crate::error::{Result, TwonlyError};
use crate::keys::KeyManager;
use aes_gcm::aead::rand_core::RngCore;
use aes_gcm::aead::{Aead, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Nonce};
use scrypt::{scrypt, Params};
use serde::{Deserialize, Serialize};
use zeroize::{Zeroize, ZeroizeOnDrop};
#[derive(Debug, Clone, PartialEq, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)]
pub(crate) struct BackupPasswordKeys {
backup_id: [u8; 32],
encryption_key: [u8; 32],
}
impl BackupPasswordKeys {
pub(crate) fn new(backup_id: [u8; 32], encryption_key: [u8; 32]) -> Self {
Self {
backup_id,
encryption_key,
}
}
pub(crate) fn from_password(password: &str, salt: &str) -> Result<Self> {
let params = Params::new(17, 8, 1)?;
let mut output = [0u8; 64];
scrypt(password.as_bytes(), salt.as_bytes(), &params, &mut output)?;
let mut backup_id = [0u8; 32];
let mut encryption_key = [0u8; 32];
backup_id.copy_from_slice(&output[0..32]);
encryption_key.copy_from_slice(&output[32..64]);
Ok(Self::new(backup_id, encryption_key))
}
}
#[derive(Debug, PartialEq, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)]
pub(crate) struct BackupPlainTextContent {
pub(crate) user_id: i64,
pub(crate) key_manager: KeyManager,
}
impl BackupPlainTextContent {
fn get_encrypted_backup(&self) -> Result<Vec<u8>> {
let Some(keys) = &self.key_manager.backup_password else {
return Err(TwonlyError::Generic("No backup password".into()));
};
let serialized_bytes = postcard::to_allocvec(&self)?;
let key = aes_gcm::Key::<Aes256Gcm>::from_slice(&keys.encryption_key);
let cipher = Aes256Gcm::new(key);
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher.encrypt(nonce, serialized_bytes.as_slice())?;
let mut encrypted_bytes = vec![];
encrypted_bytes.extend_from_slice(&nonce_bytes);
encrypted_bytes.extend_from_slice(&ciphertext);
Ok(encrypted_bytes)
}
pub(crate) fn from_encrypted_backup(
encrypted_bytes: &[u8],
keys: &BackupPasswordKeys,
) -> Result<Self> {
if encrypted_bytes.len() < 12 {
return Err(TwonlyError::Generic(
"Invalid encrypted backup length".into(),
));
}
let (nonce_bytes, ciphertext) = encrypted_bytes.split_at(12);
let nonce = Nonce::from_slice(nonce_bytes);
let key = aes_gcm::Key::<Aes256Gcm>::from_slice(&keys.encryption_key);
let cipher = Aes256Gcm::new(key);
let decrypted_bytes = cipher.decrypt(nonce, ciphertext)?;
Ok(postcard::from_bytes(&decrypted_bytes)?)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backup_encryption_decryption() {
let mut key_manager = KeyManager::generate().unwrap();
let password = "my_secure_password";
let salt = "my_random_salt";
let keys = BackupPasswordKeys::from_password(password, salt).unwrap();
key_manager.backup_password = Some(keys.clone());
let content = BackupPlainTextContent {
user_id: 12345,
key_manager,
};
let encrypted = content.get_encrypted_backup().unwrap();
let decrypted = BackupPlainTextContent::from_encrypted_backup(&encrypted, &keys).unwrap();
assert_eq!(content.user_id, decrypted.user_id);
assert_eq!(content.key_manager.main_key, decrypted.key_manager.main_key);
}
}

View file

@ -1 +1,3 @@
mod backup_archive; mod backup_archive;
pub(crate) mod backup_password;
mod backup_passwordless;

View file

@ -2,7 +2,6 @@
pub mod callbacks; pub mod callbacks;
pub mod wrapper; pub mod wrapper;
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use crate::bridge::callbacks::user_discovery::{ use crate::bridge::callbacks::user_discovery::{

View file

@ -99,7 +99,7 @@ impl Context {
Err(err) => { Err(err) => {
tracing::error!("{err}"); tracing::error!("{err}");
if rust_db_path.exists() { if rust_db_path.exists() {
tracing::error!("Rust Database exsist, while the key manager not"); tracing::error!("Rust Database exsist, while the key manager not. This must be a secure storage error.");
return Err(TwonlyError::SecureStorageError); return Err(TwonlyError::SecureStorageError);
} }
tracing::info!("Generating a new key manager."); tracing::info!("Generating a new key manager.");

View file

@ -2,15 +2,7 @@
CREATE TABLE IF NOT EXISTS received_messages ( CREATE TABLE IF NOT EXISTS received_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
sender_id TEXT NOT NULL, sender_id BIGINT NOT NULL,
content BLOB NOT NULL, content BLOB NOT NULL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP, 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

@ -96,6 +96,7 @@ impl Database {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::database::tables::received_messages::ReceivedMessage; use crate::database::tables::received_messages::ReceivedMessage;
use chrono::Utc;
use super::*; use super::*;
use tempfile::tempdir; use tempfile::tempdir;
@ -109,7 +110,7 @@ mod tests {
// 1. Create and initialize database with key // 1. Create and initialize database with key
let db = Database::new(&db_path, Some(key), false).await.unwrap(); let db = Database::new(&db_path, Some(key), false).await.unwrap();
ReceivedMessage::insert(&db.pool, "sender1", b"hello world") ReceivedMessage::insert(&db.pool, 1, b"hello world")
.await .await
.unwrap(); .unwrap();
@ -124,7 +125,7 @@ mod tests {
let db = Database::new(&db_path, Some(key), false).await.unwrap(); let db = Database::new(&db_path, Some(key), false).await.unwrap();
let messages = ReceivedMessage::get_all(&db.pool).await.unwrap(); let messages = ReceivedMessage::get_all(&db.pool).await.unwrap();
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, "sender1"); assert_eq!(messages[0].sender_id, 1);
assert_eq!(messages[0].content, b"hello world"); assert_eq!(messages[0].content, b"hello world");
} }
@ -137,7 +138,7 @@ mod tests {
let key = "secure_password"; let key = "secure_password";
let db = Database::new(&db_path, Some(key), false).await.unwrap(); let db = Database::new(&db_path, Some(key), false).await.unwrap();
ReceivedMessage::insert(&db.pool, "sender1", b"hello world") ReceivedMessage::insert(&db.pool, 1, b"hello world")
.await .await
.unwrap(); .unwrap();
@ -154,7 +155,7 @@ mod tests {
let backup_db = Database::new(&backup_path, Some(key), false).await.unwrap(); let backup_db = Database::new(&backup_path, Some(key), false).await.unwrap();
let messages = ReceivedMessage::get_all(&backup_db.pool).await.unwrap(); let messages = ReceivedMessage::get_all(&backup_db.pool).await.unwrap();
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, "sender1"); assert_eq!(messages[0].sender_id, 1);
} }
#[tokio::test] #[tokio::test]
@ -165,7 +166,7 @@ mod tests {
let backup_path = dir.path().join("backup_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(); let db = Database::new(&db_path, None, false).await.unwrap();
ReceivedMessage::insert(&db.pool, "sender1", b"hello world") ReceivedMessage::insert(&db.pool, 1, b"hello world")
.await .await
.unwrap(); .unwrap();
@ -175,6 +176,6 @@ mod tests {
let backup_db = Database::new(&backup_path, None, false).await.unwrap(); let backup_db = Database::new(&backup_path, None, false).await.unwrap();
let messages = ReceivedMessage::get_all(&backup_db.pool).await.unwrap(); let messages = ReceivedMessage::get_all(&backup_db.pool).await.unwrap();
assert_eq!(messages.len(), 1); assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender_id, "sender1"); assert_eq!(messages[0].sender_id, 1);
} }
} }

View file

@ -1,2 +1,102 @@
pub mod received_messages; pub mod received_messages;
pub mod sending_messages;
#[macro_export]
macro_rules! generate_insert {
($table:literal, $fn_name:ident, $($field:ident : $ty:ty),+) => {
pub async fn $fn_name(
pool: &sqlx::SqlitePool,
$($field: $ty),+
) -> crate::error::Result<i64> {
let sql = format!(
"INSERT INTO {} ({}) VALUES ({}) RETURNING id",
$table,
vec![$(stringify!($field)),+].join(", "),
vec!["?"; [$({stringify!($field); 1}),+].len()].join(", ")
);
let row: (i64,) = sqlx::query_as(sqlx::AssertSqlSafe(sql))
$(.bind($field))+
.fetch_one(pool)
.await?;
Ok(row.0)
}
};
}
#[macro_export]
macro_rules! generate_select {
($table:literal, $fn_name:ident) => {
pub async fn $fn_name(pool: &sqlx::SqlitePool) -> crate::error::Result<Vec<Self>> {
let sql = format!("SELECT * FROM {}", $table);
let results = sqlx::query_as::<_, Self>(sqlx::AssertSqlSafe(sql))
.fetch_all(pool)
.await?;
Ok(results)
}
};
($table:literal, $fn_name:ident, $($field:ident : $ty:ty),+) => {
pub async fn $fn_name(pool: &sqlx::SqlitePool, $($field: $ty),+) -> crate::error::Result<Vec<Self>> {
let mut sql = format!("SELECT * FROM {} WHERE ", $table);
let mut filters = Vec::new();
$(
filters.push(format!("{} = ?", stringify!($field)));
)+
sql.push_str(&filters.join(" AND "));
let results = sqlx::query_as::<_, Self>(sqlx::AssertSqlSafe(sql))
$(.bind($field))+
.fetch_all(pool)
.await?;
Ok(results)
}
};
}
#[macro_export]
macro_rules! generate_table_tests {
(
$struct:ident,
$insert_fn:ident ($($arg:expr),+),
$select_all_fn:ident
) => {
#[cfg(test)]
mod tests {
use super::*;
use crate::database::Database;
use tempfile::tempdir;
#[tokio::test]
async fn test_generated_basic() {
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.sqlite").display().to_string();
let db = Database::new(&db_path, None, false).await.unwrap();
let _id = $struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();
let all = $struct::$select_all_fn(&db.pool).await.unwrap();
assert_eq!(all.len(), 1);
}
}
};
}
#[macro_export]
macro_rules! generate_test_select {
($struct:ident, $insert_fn:ident ($($arg:expr),+), $select_fn:ident ($($sel_arg:expr),+)) => {
paste::paste! {
#[cfg(test)]
#[tokio::test]
async fn [<test_ $select_fn>]() {
use crate::database::Database;
use tempfile::tempdir;
let dir = tempdir().unwrap();
let db_path = dir.path().join("test.sqlite").display().to_string();
let db = Database::new(&db_path, None, false).await.unwrap();
$struct::$insert_fn(&db.pool, $($arg),+).await.unwrap();
let results = $struct::$select_fn(&db.pool, $($sel_arg),+).await.unwrap();
assert_eq!(results.len(), 1);
}
}
};
}

View file

@ -1,34 +1,25 @@
use crate::error::Result;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use sqlx::{FromRow, SqlitePool}; use sqlx::FromRow;
#[derive(Debug, FromRow)] #[derive(Debug, FromRow, PartialEq, Clone)]
pub struct ReceivedMessage { pub struct ReceivedMessage {
pub id: i64, pub id: i64,
pub sender_id: String, pub sender_id: i64,
pub content: Vec<u8>, pub content: Vec<u8>,
pub timestamp: DateTime<Utc>, pub timestamp: DateTime<Utc>,
} }
impl ReceivedMessage { impl ReceivedMessage {
pub async fn insert(pool: &SqlitePool, sender_id: &str, content: &[u8]) -> Result<i64> { crate::generate_insert!(
let result = "received_messages",
sqlx::query("INSERT INTO received_messages (sender_id, content) VALUES (?, ?)") insert,
.bind(sender_id) sender_id: i64,
.bind(content) content: &[u8]
.execute(pool) );
.await?; crate::generate_select!("received_messages", get_all);
crate::generate_select!("received_messages", get_by_sender, sender_id: i64);
Ok(result.last_insert_rowid())
} }
pub async fn get_all(pool: &SqlitePool) -> Result<Vec<Self>> { crate::generate_table_tests!(ReceivedMessage, insert(1, b"hello world"), get_all);
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) crate::generate_test_select!(ReceivedMessage, insert(1, b"hello world"), get_by_sender(1));
}
}

View file

@ -1,35 +0,0 @@
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,4 +1,6 @@
use hex::FromHexError;
use protocols::user_discovery::error::UserDiscoveryError; use protocols::user_discovery::error::UserDiscoveryError;
use scrypt::errors::{InvalidOutputLen, InvalidParams};
use thiserror::Error; use thiserror::Error;
use zip::result::ZipError; use zip::result::ZipError;
@ -8,24 +10,36 @@ pub type Result<T> = core::result::Result<T, TwonlyError>;
pub enum TwonlyError { pub enum TwonlyError {
#[error("global twonly is not initialized")] #[error("global twonly is not initialized")]
Initialization, Initialization,
#[error("Tried to access the wrong context")] #[error("Tried to access the wrong context")]
WrongContext, WrongContext,
#[error("init_flutter_callbacks was not called")] #[error("init_flutter_callbacks was not called")]
MissingCallbackInitialization, MissingCallbackInitialization,
#[error("Could not find the given database")] #[error("Could not find the given database")]
DatabaseNotFound, DatabaseNotFound,
#[error("main_key could not be loaded from the key_chain")]
MissingMainKey,
#[error("{0}")] #[error("{0}")]
UserDiscoveryError(#[from] UserDiscoveryError), UserDiscoveryError(#[from] UserDiscoveryError),
#[error("Error in dart callback")] #[error("Error in dart callback")]
DartError, DartError,
#[error( #[error(
"Storage error: database exists but master key could not be loaded from secure storage" "Storage error: database exists but master key could not be loaded from secure storage"
)] )]
SecureStorageError, SecureStorageError,
#[error("{0}")] #[error("{0}")]
SqliteError(#[from] sqlx::Error), SqliteError(#[from] sqlx::Error),
#[error("{0}")] #[error("{0}")]
Generic(String), Generic(String),
#[error("{0}")] #[error("{0}")]
IoError(#[from] std::io::Error), IoError(#[from] std::io::Error),
@ -34,6 +48,19 @@ pub enum TwonlyError {
#[error("{0}")] #[error("{0}")]
Walkdir(#[from] walkdir::Error), Walkdir(#[from] walkdir::Error),
#[error("{0}")]
Postcard(#[from] postcard::Error),
#[error("{0}")]
HexError(#[from] FromHexError),
#[error("{0}")]
InvalidParams(#[from] InvalidParams),
#[error("{0}")]
InvalidOutputLen(#[from] InvalidOutputLen),
#[error("AES-GCM error")]
AesGcm,
} }
impl From<String> for TwonlyError { impl From<String> for TwonlyError {
@ -47,3 +74,9 @@ impl From<TwonlyError> for UserDiscoveryError {
UserDiscoveryError::Store(error.to_string()) UserDiscoveryError::Store(error.to_string())
} }
} }
impl From<aes_gcm::Error> for TwonlyError {
fn from(_: aes_gcm::Error) -> Self {
TwonlyError::AesGcm
}
}

View file

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

View file

@ -9,7 +9,7 @@ use zeroize::{Zeroize, ZeroizeOnDrop};
/// `MainKey` is responsible for handling the cryptographically secure, immutable master key. /// `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). /// It uses HKDF to derive subordinate keys (Authentication Token, Backup Key, Media Main Key).
#[derive(Zeroize, ZeroizeOnDrop, Serialize, Deserialize)] #[derive(Debug, PartialEq, Zeroize, ZeroizeOnDrop, Serialize, Deserialize)]
pub struct MainKey { pub struct MainKey {
/// The 32-byte main master key /// The 32-byte main master key
main_key: [u8; 32], main_key: [u8; 32],
@ -33,6 +33,22 @@ impl MainKey {
Self { main_key } Self { main_key }
} }
pub fn as_bytes(&self) -> &[u8; 32] {
&self.main_key
}
/// Download token required to download a backup.
/// This ensures that the user who tries to download the backup must have knowledge over the
/// main key
pub fn backup_download_token(&self) -> [u8; 32] {
self.derive_key(b"backup_download_token")
}
/// Uses as a password to authenitcate agains the server
pub fn server_auth_token(&self) -> [u8; 32] {
self.derive_key(b"server_auth_token")
}
/// Derives the database encryption key. /// Derives the database encryption key.
pub(crate) fn get_database_key(&self, db: DatabaseKey) -> String { pub(crate) fn get_database_key(&self, db: DatabaseKey) -> String {
let db_name = match db { let db_name = match db {
@ -43,11 +59,6 @@ impl MainKey {
hex::encode(key) 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. /// Encrypts a backup payload.
/// The backup key is derived using HKDF from the main key. /// The backup key is derived using HKDF from the main key.
pub fn encrypt_backup(&self, backup_payload: &[u8]) -> Vec<u8> { pub fn encrypt_backup(&self, backup_payload: &[u8]) -> Vec<u8> {
@ -126,24 +137,6 @@ mod tests {
assert_eq!(km.main_key, km2.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] #[test]
fn test_backup_encryption_decryption_success() { fn test_backup_encryption_decryption_success() {
let km = MainKey::generate(); let km = MainKey::generate();

View file

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

View file

@ -32,8 +32,12 @@ impl SecureStorage {
#[cfg(target_os = "ios")] #[cfg(target_os = "ios")]
{ {
use std::collections::HashMap;
let group = "CN332ZUGRP.eu.twonly.shared"; let group = "CN332ZUGRP.eu.twonly.shared";
let store = apple_native_keyring_store::protected::Store::with_application_group(group) let mut config = HashMap::new();
config.insert("access-group", group);
let store =
apple_native_keyring_store::protected::Store::new_with_configuration(&config)
.map_err(|e| format!("Failed to init iOS Protected Store: {}", e))?; .map_err(|e| format!("Failed to init iOS Protected Store: {}", e))?;
keyring_core::set_default_store(store); keyring_core::set_default_store(store);
} }
@ -62,8 +66,7 @@ impl SecureStorage {
/// * `key` - The identifier (account name) for the secret. /// * `key` - The identifier (account name) for the secret.
/// * `value` - The secret string to store. /// * `value` - The secret string to store.
pub fn write(&self, key: &str, value: &str) -> Result<(), String> { pub fn write(&self, key: &str, value: &str) -> Result<(), String> {
let entry = Entry::new(&self.service_name, key) let entry = self.get_entry(key)?;
.map_err(|e| format!("Failed to create keyring entry: {}", e))?;
entry entry
.set_password(value) .set_password(value)
@ -77,8 +80,7 @@ impl SecureStorage {
/// Returns `Ok(Some(String))` if the key exists, `Ok(None)` if it doesn't, /// Returns `Ok(Some(String))` if the key exists, `Ok(None)` if it doesn't,
/// or an `Err` if a system error occurs. /// or an `Err` if a system error occurs.
pub fn read(&self, key: &str) -> Result<Option<String>, String> { pub fn read(&self, key: &str) -> Result<Option<String>, String> {
let entry = Entry::new(&self.service_name, key) let entry = self.get_entry(key)?;
.map_err(|e| format!("Failed to create keyring entry: {}", e))?;
match entry.get_password() { match entry.get_password() {
Ok(password) => Ok(Some(password)), Ok(password) => Ok(Some(password)),
@ -91,8 +93,7 @@ impl SecureStorage {
/// ///
/// If the key does not exist, this function returns `Ok(())` (idempotent). /// If the key does not exist, this function returns `Ok(())` (idempotent).
pub fn delete(&self, key: &str) -> Result<(), String> { pub fn delete(&self, key: &str) -> Result<(), String> {
let entry = Entry::new(&self.service_name, key) let entry = self.get_entry(key)?;
.map_err(|e| format!("Failed to create keyring entry: {}", e))?;
match entry.delete_credential() { match entry.delete_credential() {
Ok(()) => Ok(()), Ok(()) => Ok(()),
@ -100,6 +101,24 @@ impl SecureStorage {
Err(e) => Err(format!("Failed to delete secret from keyring: {}", e)), Err(e) => Err(format!("Failed to delete secret from keyring: {}", e)),
} }
} }
/// Helper to create a keyring entry with the appropriate platform modifiers.
fn get_entry(&self, key: &str) -> Result<Entry, String> {
#[cfg(target_os = "ios")]
{
use std::collections::HashMap;
let mut modifiers = HashMap::new();
modifiers.insert("access-policy", "AfterFirstUnlock");
Entry::new_with_modifiers(&self.service_name, key, &modifiers)
.map_err(|e| format!("Failed to create keyring entry with modifiers: {}", e))
}
#[cfg(not(target_os = "ios"))]
{
Entry::new(&self.service_name, key)
.map_err(|e| format!("Failed to create keyring entry: {}", e))
}
}
} }
#[cfg(test)] #[cfg(test)]