Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 58 additions & 14 deletions lib/features/account/providers/backup_reminder_provider.dart
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'package:mostro/src/rust/api/identity.dart' as identity_api;

const kBackupReminderDismissedKey = 'backupReminderDismissed';
const kBackupReminderActiveKey = 'backupReminderActive';

Expand Down Expand Up @@ -112,41 +114,83 @@ class BackupReminderNotifier extends StateNotifier<bool> {
}

class BackupCompletedNotifier extends StateNotifier<bool> {
BackupCompletedNotifier({bool? initialValue}) : super(initialValue ?? false) {
/// The three bridge calls are injectable so the notifier is testable without
/// a live Rust runtime; they default to the real identity-bridge functions
/// (issue #141).
BackupCompletedNotifier({
bool? initialValue,
Future<bool> Function()? getConfirmed,
Future<void> Function(bool confirmed)? setConfirmed,
Future<void> Function()? resetConfirmed,
}) : _getConfirmed = getConfirmed ?? identity_api.getBackupConfirmed,
_setConfirmed = setConfirmed ??
((confirmed) =>
identity_api.setBackupConfirmed(confirmed: confirmed)),
_resetConfirmed =
resetConfirmed ?? identity_api.resetBackupConfirmation,
super(initialValue ?? false) {
if (initialValue == null) {
load();
} else {
_loaded = true;
}
}

final Future<bool> Function() _getConfirmed;
final Future<void> Function(bool confirmed) _setConfirmed;
final Future<void> Function() _resetConfirmed;

bool _loaded = false;

/// Marks that the one-time SharedPreferences -> Rust migration has run, so
/// the legacy key is only ever read once (issue #141).
static const _kMigratedKey = 'backupCompletedMigratedToRust';

Future<void> load() async {
if (_loaded) return;
final prefs = await SharedPreferences.getInstance();
// Legacy installs only have the dismissed flag, which was set exclusively
// by the explicit "I have written down my secret words" confirmation —
// treat it as a completed backup.
state = prefs.getBool(kBackupCompletedKey) ??
prefs.getBool(kBackupReminderDismissedKey) ??
false;
// The backup-confirmed flag now lives in the Rust identity record. On the
// first run after upgrading, copy the legacy SharedPreferences value into
// Rust once, then read from Rust exclusively.
try {
final prefs = await SharedPreferences.getInstance();
final migrated = prefs.getBool(_kMigratedKey) ?? false;
if (!migrated) {
// Legacy installs only have the dismissed flag, which was set
// exclusively by the explicit "I have written down my secret words"
// confirmation — treat it as a completed backup.
final legacy = prefs.getBool(kBackupCompletedKey) ??
prefs.getBool(kBackupReminderDismissedKey) ??
false;
if (legacy) {
// Best-effort: if no identity is loaded yet, the bridge throws and we
// simply leave Rust at its default (false); the reminder stays armed,
// which is safe. The migration flag is only set once the copy sticks.
await _setConfirmed(true);
}
await prefs.setBool(_kMigratedKey, true);
}
state = await _getConfirmed();
} catch (_) {
// Rust unavailable (e.g. no identity yet, or tests without the bridge):
// fall back to unconfirmed so the reminder stays armed.
state = false;
}
_loaded = true;
}

/// Persist that the current identity has been backed up.
/// Persist that the current identity has been backed up (Rust identity
/// record, #141).
Future<void> markCompleted() async {
await load();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(kBackupCompletedKey, true);
await _setConfirmed(true);
state = true;
}

/// Clear the backed-up flag (new identity generated or imported).
/// Clear the backed-up flag (new identity generated or imported). The Rust
/// side is also reset in `create_identity`; this keeps the UI in sync (#141).
Future<void> reset() async {
await load();
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(kBackupCompletedKey, false);
await _resetConfirmed();
state = false;
}
}
110 changes: 110 additions & 0 deletions rust/src/api/identity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ pub async fn create_identity() -> Result<IdentityCreationResult> {
privacy_mode: false,
trade_key_index: 0,
created_at: now,
// A freshly generated mnemonic has not been backed up yet — this is
// what re-arms the backup reminder for a new identity (issue #141).
backup_confirmed: false,
};

*guard = Some(IdentityState {
Expand Down Expand Up @@ -203,12 +206,15 @@ pub async fn load_identity_from_mnemonic(
Some(ts) if ts > 0 => ts,
_ => unix_now(),
};
// Restore the backup-confirmed flag from the persisted identity record.
let backup_confirmed = restore_backup_confirmed(stored.as_ref(), &public_key);
let identity_info = IdentityInfo {
public_key: public_key.clone(),
display_name: None,
privacy_mode,
trade_key_index,
created_at,
backup_confirmed,
};

let mut guard = identity_lock().write().await;
Expand Down Expand Up @@ -264,6 +270,8 @@ pub async fn import_from_nsec(nsec: String) -> Result<IdentityInfo> {
privacy_mode: false,
trade_key_index: 0,
created_at: now,
// nsec imports have no BIP-39 mnemonic to back up; leave unconfirmed.
backup_confirmed: false,
};

let mut guard = identity_lock().write().await;
Expand All @@ -282,6 +290,60 @@ pub async fn get_identity() -> Result<Option<IdentityInfo>> {
Ok(guard.as_ref().map(|s| s.identity_info.clone()))
}

/// Whether the current identity's secret words have been confirmed backed up.
///
/// Returns `false` when no identity is loaded — nothing has been backed up
/// yet, which correctly leaves the reminder armed (issue #141).
pub async fn get_backup_confirmed() -> Result<bool> {
let guard = identity_lock().read().await;
Ok(guard
.as_ref()
.map(|s| s.identity_info.backup_confirmed)
.unwrap_or(false))
}

/// Set the backup-confirmed flag and persist it to the identity record.
///
/// Mirrors the `trade_key_index` persist path: mutate under the identity lock,
/// then `save_identity`, so the flag survives a restart. Unlike a trade-key
/// index, persistence is best-effort rather than required — the flag only
/// drives a reminder, so if the store is unavailable (e.g. the web IndexedDB
/// backend, which does not implement `save_identity`) the worst case is the
/// reminder re-appears next launch, which fails safe. A redundant write is
/// skipped so confirming twice does not touch storage.
pub async fn set_backup_confirmed(confirmed: bool) -> Result<()> {
let mut guard = identity_lock().write().await;
let state = guard.as_mut().ok_or_else(|| anyhow!("NoIdentity"))?;
if state.identity_info.backup_confirmed == confirmed {
return Ok(());
}
// Persist before committing in memory: build the updated record, save it,
// and only then assign to state. Mutating first would leave the session
// reporting a confirmed backup that never reached disk if the save failed —
// and the no-op short-circuit above would stop a retry from re-saving, so
// the flag would silently vanish on the next restart. Same persist-then-
// commit discipline as the trade_key_index path (#217).
let mut updated = state.identity_info.clone();
updated.backup_confirmed = confirmed;
if let Some(db) = crate::db::app_db::db() {
db.save_identity(&updated).await.map_err(|e| {
anyhow!("StorageError: failed to persist backup_confirmed={confirmed}: {e}")
})?;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
state.identity_info = updated;
Ok(())
}

/// Re-arm the backup reminder by marking the current identity as not-yet
/// backed up. Called when a new identity is generated so the security-relevant
/// reminder re-appears (issue #141). A no-op when no identity is loaded.
pub async fn reset_backup_confirmation() -> Result<()> {
if get_identity().await?.is_none() {
return Ok(());
}
set_backup_confirmed(false).await
}

/// Delete the in-memory identity state. Flutter must also clear
/// `flutter_secure_storage` after calling this.
pub async fn delete_identity() -> Result<()> {
Expand Down Expand Up @@ -502,6 +564,19 @@ fn reconcile_trade_key_index(
}
}

/// Restore the backup-confirmed flag from the persisted identity record on
/// load. Only trusts a stored value that belongs to the same identity (guards
/// against a leftover blob from a previous mnemonic), and defaults to
/// `false` — importing a mnemonic is not itself the in-app backup ritual, so
/// an identity with no persisted flag stays unconfirmed and keeps the reminder
/// armed (issue #141).
fn restore_backup_confirmed(stored: Option<&IdentityInfo>, public_key: &str) -> bool {
match stored {
Some(info) if info.public_key == public_key => info.backup_confirmed,
_ => false,
}
}

/// [`reconcile_trade_key_index`], publishing the result when the database knew
/// a higher counter than the value Flutter passed in. That is exactly the case
/// where secure storage is behind — an installation from before it was kept in
Expand Down Expand Up @@ -587,6 +662,7 @@ mod tests {
privacy_mode: false,
trade_key_index,
created_at: 1,
backup_confirmed: false,
}
}

Expand Down Expand Up @@ -666,6 +742,40 @@ mod tests {
assert_eq!(reconcile_trade_key_index(3, Some(&stored), "abc"), 3);
}

// ── backup_confirmed restore (#141) ───────────────────────────────────────
#[test]
fn restore_reads_the_persisted_backup_flag_for_the_same_identity() {
let mut stored = stored_identity("abc", 4);
stored.backup_confirmed = true;
assert!(restore_backup_confirmed(Some(&stored), "abc"));
}

#[test]
fn restore_defaults_to_unconfirmed_when_nothing_is_persisted() {
// No stored record: a fresh import has not completed the backup ritual,
// so the reminder must stay armed.
assert!(!restore_backup_confirmed(None, "abc"));
}

#[test]
fn restore_ignores_a_backup_flag_from_another_identity() {
// A leftover blob from a previous mnemonic must not mark the new
// identity as backed up.
let mut stored = stored_identity("other-pubkey", 0);
stored.backup_confirmed = true;
assert!(!restore_backup_confirmed(Some(&stored), "abc"));
}

#[test]
fn an_identity_persisted_before_the_field_deserializes_as_unconfirmed() {
// Serde default: an identity JSON blob written before backup_confirmed
// existed has no such key, and must load as `false` (reminder armed),
// not error.
let legacy = r#"{"public_key":"abc","display_name":null,"privacy_mode":false,"trade_key_index":3,"created_at":1}"#;
let info: IdentityInfo = serde_json::from_str(legacy).unwrap();
assert!(!info.backup_confirmed);
}

#[test]
fn reconcile_without_stored_identity_keeps_passed_index() {
assert_eq!(reconcile_trade_key_index(7, None, "abc"), 7);
Expand Down
7 changes: 7 additions & 0 deletions rust/src/api/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,13 @@ pub struct IdentityInfo {
pub privacy_mode: bool,
pub trade_key_index: u32,
pub created_at: i64,
/// Whether the user has confirmed a backup of the current identity's
/// secret words (issue #141 — migrated out of Dart SharedPreferences into
/// the Rust identity record per Principle I). `#[serde(default)]` so
/// identities persisted before this field deserialize as `false` — an
/// unconfirmed backup, which correctly keeps the reminder armed.
#[serde(default)]
pub backup_confirmed: bool,
}

/// Deterministic pseudonymous identity derived from a public key.
Expand Down
6 changes: 6 additions & 0 deletions rust/src/db/sqlite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -940,6 +940,7 @@ mod tests {
privacy_mode: false,
trade_key_index: 21,
created_at: 1_700_000_000,
backup_confirmed: false,
};
storage.save_identity(&identity).await.unwrap();
let loaded = storage.get_identity().await.unwrap().unwrap();
Expand All @@ -948,9 +949,13 @@ mod tests {

// INSERT OR REPLACE keeps a single row with the latest counter.
identity.trade_key_index = 22;
// The backup-confirmed flag rides in the same JSON blob (#141) and must
// survive the round-trip alongside the counter.
identity.backup_confirmed = true;
storage.save_identity(&identity).await.unwrap();
let loaded = storage.get_identity().await.unwrap().unwrap();
assert_eq!(loaded.trade_key_index, 22);
assert!(loaded.backup_confirmed, "backup_confirmed must persist");

drop(storage);
let _ = std::fs::remove_file(&path);
Expand All @@ -968,6 +973,7 @@ mod tests {
privacy_mode: false,
trade_key_index: 7,
created_at: 1_700_000_000,
backup_confirmed: false,
};
storage.save_identity(&identity).await.unwrap();
storage.save_trade_key("order-1", 5).await.unwrap();
Expand Down
Loading
Loading