From 993f7b37abe3d6892e8097a34ea9afa3c45f03d0 Mon Sep 17 00:00:00 2001 From: vjrj Date: Fri, 10 Jul 2026 20:20:04 +0200 Subject: [PATCH] feat(identity): switch social identity, all from the one backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds pseudonymous, switchable social identities derived from the SAME root seed via an account index (NostrKeyDerivation.deriveFromSeed(seed, account)). HKDF is one-way so accounts are unlinkable to the Ğ1 key; account 0 is the original identity, byte-for-byte unchanged (no rotation for current users), and every account regenerates from the single seed — so switching adds nothing to back up. - SocialAccountStore: keystore-backed active account + max created. - Per-identity stores (chats, profile, name cache) namespaced by account scope (account 0 = legacy keys, no migration) so identities never mix. - switchSocialAccount() re-derives the identity, re-scopes the stores and restarts the inbox listener; RestartWidget rebuilds the tree to pick up the new social singletons. DB/inventory untouched. - Profile 'Your identities' switcher: list, create, switch (with a note that messages/contacts are kept separate per identity). i18n en/es/pt/ast. Tests: account-indexed derivation (legacy 0 unchanged, accounts distinct yet deterministic, negatives rejected); SocialAccountStore; per-identity store scope isolation. Resolves the flagged 'change identity' decision (open-decisions §B). Known: switching resets navigation to home (full tree rebuild). --- apps/app_seeds/lib/app.dart | 13 +- apps/app_seeds/lib/bootstrap.dart | 2 + apps/app_seeds/lib/di/injector.dart | 69 ++++++++- apps/app_seeds/lib/i18n/ast.i18n.json | 10 +- apps/app_seeds/lib/i18n/en.i18n.json | 10 +- apps/app_seeds/lib/i18n/es.i18n.json | 10 +- apps/app_seeds/lib/i18n/pt.i18n.json | 10 +- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 16 ++ apps/app_seeds/lib/i18n/strings_en.g.dart | 32 ++++ apps/app_seeds/lib/i18n/strings_es.g.dart | 16 ++ apps/app_seeds/lib/i18n/strings_pt.g.dart | 16 ++ apps/app_seeds/lib/main.dart | 11 +- .../app_seeds/lib/services/message_store.dart | 12 +- .../app_seeds/lib/services/profile_cache.dart | 9 +- .../app_seeds/lib/services/profile_store.dart | 15 +- .../lib/services/social_account_store.dart | 60 ++++++++ .../lib/services/social_service.dart | 43 +++++- apps/app_seeds/lib/ui/profile_screen.dart | 139 +++++++++++++++++- apps/app_seeds/lib/ui/restart_widget.dart | 30 ++++ .../test/services/message_store_test.dart | 15 ++ .../services/social_account_store_test.dart | 44 ++++++ docs/design/open-decisions.md | 2 +- .../src/identity/nostr_key_derivation.dart | 17 ++- .../identity/nostr_key_derivation_test.dart | 25 ++++ 25 files changed, 597 insertions(+), 33 deletions(-) create mode 100644 apps/app_seeds/lib/services/social_account_store.dart create mode 100644 apps/app_seeds/lib/ui/restart_widget.dart create mode 100644 apps/app_seeds/test/services/social_account_store_test.dart diff --git a/apps/app_seeds/lib/app.dart b/apps/app_seeds/lib/app.dart index 8593a74..9becb1e 100644 --- a/apps/app_seeds/lib/app.dart +++ b/apps/app_seeds/lib/app.dart @@ -16,6 +16,7 @@ import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; import 'services/profile_cache.dart'; import 'services/profile_store.dart'; +import 'services/social_account_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; import 'state/inventory_cubit.dart'; @@ -50,6 +51,7 @@ class TaneApp extends StatelessWidget { this.messageStore, this.profileStore, this.profileCache, + this.socialAccounts, this.inbox, this.showIntro = false, this.autoBackup, @@ -65,6 +67,7 @@ class TaneApp extends StatelessWidget { messageStore, profileStore, profileCache, + socialAccounts, inbox, ); @@ -92,6 +95,9 @@ class TaneApp extends StatelessWidget { /// Optional cache of peers' published display names. final ProfileCache? profileCache; + /// Optional store of the active social identity, for the profile switcher. + final SocialAccountStore? socialAccounts; + /// App-wide inbox listener; drives the messages list's live refresh. final InboxService? inbox; final bool showIntro; @@ -112,6 +118,7 @@ class TaneApp extends StatelessWidget { MessageStore? messageStore, ProfileStore? profileStore, ProfileCache? profileCache, + SocialAccountStore? socialAccounts, InboxService? inbox, ) { return GoRouter( @@ -157,13 +164,17 @@ class TaneApp extends StatelessWidget { inbox: inbox, ), ), - if (social != null && socialSettings != null && profileStore != null) + if (social != null && + socialSettings != null && + profileStore != null && + socialAccounts != null) GoRoute( path: '/profile', builder: (context, state) => ProfileScreen( social: social, settings: socialSettings, profileStore: profileStore, + accounts: socialAccounts, ), ), if (social != null && socialSettings != null) diff --git a/apps/app_seeds/lib/bootstrap.dart b/apps/app_seeds/lib/bootstrap.dart index 34b58db..2dfe373 100644 --- a/apps/app_seeds/lib/bootstrap.dart +++ b/apps/app_seeds/lib/bootstrap.dart @@ -17,6 +17,7 @@ import 'services/offer_outbox.dart'; import 'services/onboarding_store.dart'; import 'services/profile_cache.dart'; import 'services/profile_store.dart'; +import 'services/social_account_store.dart'; import 'services/social_service.dart'; import 'services/social_settings.dart'; import 'ui/theme.dart'; @@ -67,6 +68,7 @@ class _BootstrapState extends State { messageStore: getIt(), profileStore: getIt(), profileCache: getIt(), + socialAccounts: getIt(), inbox: inbox, showIntro: !await onboarding.introSeen(), autoBackup: getIt.isRegistered() diff --git a/apps/app_seeds/lib/di/injector.dart b/apps/app_seeds/lib/di/injector.dart index 92e6dd6..119d1f7 100644 --- a/apps/app_seeds/lib/di/injector.dart +++ b/apps/app_seeds/lib/di/injector.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:commons_core/commons_core.dart'; @@ -34,6 +35,7 @@ import '../services/message_store.dart'; import '../services/offer_outbox.dart'; import '../services/profile_cache.dart'; import '../services/profile_store.dart'; +import '../services/social_account_store.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; @@ -75,6 +77,12 @@ Future configureDependencies() async { final dbKeyHex = await keyStore.databaseKeyHex(); final rootSeedHex = await keyStore.rootSeedHex(); + // Which social identity ("account") of the root seed is active. Namespaces the + // per-identity stores (chats, profile, name cache) so identities never mix. + final accounts = SocialAccountStore(secretStore); + final activeAccount = await accounts.active(); + final scope = socialAccountScope(activeAccount); + final database = AppDatabase( openEncryptedExecutor(await _databaseFile(), dbKeyHex), ); @@ -91,7 +99,8 @@ Future configureDependencies() async { // profile stay hidden) rather than blanking the whole app. SocialService? socialService; try { - socialService = await SocialService.fromRootSeedHex(rootSeedHex); + socialService = + await SocialService.fromRootSeedHex(rootSeedHex, account: activeAccount); } catch (e, s) { debugPrint('Social identity derivation failed; inventory-only mode: $e\n$s'); } @@ -136,10 +145,15 @@ Future configureDependencies() async { ..registerSingleton(OnboardingStore(secretStore)) ..registerSingleton(LocaleStore(secretStore)) ..registerSingleton(SocialSettings(secretStore)) + ..registerSingleton(accounts) ..registerSingleton(OfferOutbox(secretStore)) - ..registerSingleton(MessageStore(secretStore)) - ..registerSingleton(ProfileStore(secretStore)) - ..registerSingleton(ProfileCache(secretStore)) + // Per-identity stores are namespaced by the active account's scope. + ..registerSingleton( + MessageStore(secretStore, accountScope: scope)) + ..registerSingleton( + ProfileStore(secretStore, accountScope: scope)) + ..registerSingleton( + ProfileCache(secretStore, accountScope: scope)) ..registerSingleton( ExportImportService( repository: varietyRepository, @@ -192,6 +206,53 @@ Future configureDependencies() async { getIt.registerSingleton<_DepsReady>(const _DepsReady()); } +/// Switches the active social identity to [account], re-deriving the Nostr key +/// from the SAME root seed and re-scoping the per-identity stores (chats, +/// profile, name cache) so nothing leaks between identities. Restarts the inbox +/// listener on the new identity. The caller must rebuild the widget tree +/// (`RestartWidget.restart`) so screens and router pick up the new singletons. +/// The DB, inventory and relay settings are untouched — only the social slice. +Future switchSocialAccount(int account) async { + final secretStore = FlutterSecretStore(); + await getIt().setActive(account); + final scope = socialAccountScope(account); + final rootSeedHex = await getIt().rootSeedHex(); + + // Tear down the old identity's live listener/sessions before replacing. + if (getIt.isRegistered()) { + await getIt().stop(); + await getIt.unregister(); + } + if (getIt.isRegistered()) { + await getIt.unregister(); + } + await getIt.unregister(); + await getIt.unregister(); + await getIt.unregister(); + + // Re-register the per-identity stores under the new scope, then the identity. + getIt + ..registerSingleton( + MessageStore(secretStore, accountScope: scope)) + ..registerSingleton( + ProfileStore(secretStore, accountScope: scope)) + ..registerSingleton( + ProfileCache(secretStore, accountScope: scope)); + + final social = + await SocialService.fromRootSeedHex(rootSeedHex, account: account); + final inbox = InboxService( + social: social, + settings: getIt(), + store: getIt(), + profileCache: getIt(), + ); + getIt + ..registerSingleton(social) + ..registerSingleton(inbox); + unawaited(inbox.start()); +} + Future _backupsDir() async { final dir = await getApplicationSupportDirectory(); return Directory(p.join(dir.path, 'backups')); diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 07cff98..50acddb 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -415,7 +415,15 @@ "copy": "Copiar", "copied": "Copiao", "save": "Guardar", - "saved": "Perfil guardáu" + "saved": "Perfil guardáu", + "identities": "Les tos identidaes", + "identitiesHelp": "Ten identidaes separaes — cada una coles sos mensaxes y contactos. Toes salen de la to única copia de seguridá, asina que camudar nun amiesta nada que recordar.", + "identityLabel": "Identidá {n}", + "current": "N'usu", + "newIdentity": "Identidá nueva", + "switchTitle": "¿Camudar d'identidá?", + "switchBody": "Los mensaxes y contactos guárdense per separao pa cada identidá. Pues volver cuando quieras.", + "switchAction": "Camudar" }, "chatList": { "title": "Mensaxes", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index ee69b30..e4c9657 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -418,7 +418,15 @@ "copy": "Copy", "copied": "Copied", "save": "Save", - "saved": "Profile saved" + "saved": "Profile saved", + "identities": "Your identities", + "identitiesHelp": "Keep separate identities — each with its own messages and contacts. They all come from your one backup, so switching adds nothing to remember.", + "identityLabel": "Identity {n}", + "current": "In use", + "newIdentity": "New identity", + "switchTitle": "Switch identity?", + "switchBody": "Messages and contacts are kept separate for each identity. You can switch back anytime.", + "switchAction": "Switch" }, "chatList": { "title": "Messages", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 7c0e44a..8761289 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -417,7 +417,15 @@ "copy": "Copiar", "copied": "Copiado", "save": "Guardar", - "saved": "Perfil guardado" + "saved": "Perfil guardado", + "identities": "Tus identidades", + "identitiesHelp": "Ten identidades separadas — cada una con sus mensajes y contactos. Todas salen de tu única copia de seguridad, así que cambiar no añade nada que recordar.", + "identityLabel": "Identidad {n}", + "current": "En uso", + "newIdentity": "Nueva identidad", + "switchTitle": "¿Cambiar de identidad?", + "switchBody": "Los mensajes y contactos se guardan por separado para cada identidad. Puedes volver cuando quieras.", + "switchAction": "Cambiar" }, "chatList": { "title": "Mensajes", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index 8df35d2..0919803 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -414,7 +414,15 @@ "copy": "Copiar", "copied": "Copiado", "save": "Guardar", - "saved": "Perfil guardado" + "saved": "Perfil guardado", + "identities": "As tuas identidades", + "identitiesHelp": "Mantém identidades separadas — cada uma com as suas mensagens e contactos. Todas vêm da tua única cópia de segurança, por isso mudar não acrescenta nada para lembrar.", + "identityLabel": "Identidade {n}", + "current": "Em uso", + "newIdentity": "Nova identidade", + "switchTitle": "Mudar de identidade?", + "switchBody": "As mensagens e contactos são guardados separadamente para cada identidade. Podes voltar quando quiseres.", + "switchAction": "Mudar" }, "chatList": { "title": "Mensagens", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index fb59798..95fab3a 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1548 (387 per locale) +/// Strings: 1580 (395 per locale) /// -/// Built on 2026-07-10 at 17:18 UTC +/// Built on 2026-07-10 at 18:22 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 2f83eb6..2becdc8 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -713,6 +713,14 @@ class _Translations$profile$ast extends Translations$profile$en { @override String get copied => 'Copiao'; @override String get save => 'Guardar'; @override String get saved => 'Perfil guardáu'; + @override String get identities => 'Les tos identidaes'; + @override String get identitiesHelp => 'Ten identidaes separaes — cada una coles sos mensaxes y contactos. Toes salen de la to única copia de seguridá, asina que camudar nun amiesta nada que recordar.'; + @override String identityLabel({required Object n}) => 'Identidá ${n}'; + @override String get current => 'N\'usu'; + @override String get newIdentity => 'Identidá nueva'; + @override String get switchTitle => '¿Camudar d\'identidá?'; + @override String get switchBody => 'Los mensaxes y contactos guárdense per separao pa cada identidá. Pues volver cuando quieras.'; + @override String get switchAction => 'Camudar'; } // Path: chatList @@ -1469,6 +1477,14 @@ extension on TranslationsAst { 'profile.copied' => 'Copiao', 'profile.save' => 'Guardar', 'profile.saved' => 'Perfil guardáu', + 'profile.identities' => 'Les tos identidaes', + 'profile.identitiesHelp' => 'Ten identidaes separaes — cada una coles sos mensaxes y contactos. Toes salen de la to única copia de seguridá, asina que camudar nun amiesta nada que recordar.', + 'profile.identityLabel' => ({required Object n}) => 'Identidá ${n}', + 'profile.current' => 'N\'usu', + 'profile.newIdentity' => 'Identidá nueva', + 'profile.switchTitle' => '¿Camudar d\'identidá?', + 'profile.switchBody' => 'Los mensaxes y contactos guárdense per separao pa cada identidá. Pues volver cuando quieras.', + 'profile.switchAction' => 'Camudar', 'chatList.title' => 'Mensaxes', 'chatList.empty' => 'Entá nun hai charres. Escríbi-y a alguien dende\'l mercáu.', 'chat.title' => 'Charra', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 7785576..b38bcd2 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1330,6 +1330,30 @@ class Translations$profile$en { /// en: 'Profile saved' String get saved => 'Profile saved'; + + /// en: 'Your identities' + String get identities => 'Your identities'; + + /// en: 'Keep separate identities — each with its own messages and contacts. They all come from your one backup, so switching adds nothing to remember.' + String get identitiesHelp => 'Keep separate identities — each with its own messages and contacts. They all come from your one backup, so switching adds nothing to remember.'; + + /// en: 'Identity {n}' + String identityLabel({required Object n}) => 'Identity ${n}'; + + /// en: 'In use' + String get current => 'In use'; + + /// en: 'New identity' + String get newIdentity => 'New identity'; + + /// en: 'Switch identity?' + String get switchTitle => 'Switch identity?'; + + /// en: 'Messages and contacts are kept separate for each identity. You can switch back anytime.' + String get switchBody => 'Messages and contacts are kept separate for each identity. You can switch back anytime.'; + + /// en: 'Switch' + String get switchAction => 'Switch'; } // Path: chatList @@ -2233,6 +2257,14 @@ extension on Translations { 'profile.copied' => 'Copied', 'profile.save' => 'Save', 'profile.saved' => 'Profile saved', + 'profile.identities' => 'Your identities', + 'profile.identitiesHelp' => 'Keep separate identities — each with its own messages and contacts. They all come from your one backup, so switching adds nothing to remember.', + 'profile.identityLabel' => ({required Object n}) => 'Identity ${n}', + 'profile.current' => 'In use', + 'profile.newIdentity' => 'New identity', + 'profile.switchTitle' => 'Switch identity?', + 'profile.switchBody' => 'Messages and contacts are kept separate for each identity. You can switch back anytime.', + 'profile.switchAction' => 'Switch', 'chatList.title' => 'Messages', 'chatList.empty' => 'No conversations yet. Message someone from the market.', 'chat.title' => 'Chat', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index 98d6f12..4cc4307 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -715,6 +715,14 @@ class _Translations$profile$es extends Translations$profile$en { @override String get copied => 'Copiado'; @override String get save => 'Guardar'; @override String get saved => 'Perfil guardado'; + @override String get identities => 'Tus identidades'; + @override String get identitiesHelp => 'Ten identidades separadas — cada una con sus mensajes y contactos. Todas salen de tu única copia de seguridad, así que cambiar no añade nada que recordar.'; + @override String identityLabel({required Object n}) => 'Identidad ${n}'; + @override String get current => 'En uso'; + @override String get newIdentity => 'Nueva identidad'; + @override String get switchTitle => '¿Cambiar de identidad?'; + @override String get switchBody => 'Los mensajes y contactos se guardan por separado para cada identidad. Puedes volver cuando quieras.'; + @override String get switchAction => 'Cambiar'; } // Path: chatList @@ -1473,6 +1481,14 @@ extension on TranslationsEs { 'profile.copied' => 'Copiado', 'profile.save' => 'Guardar', 'profile.saved' => 'Perfil guardado', + 'profile.identities' => 'Tus identidades', + 'profile.identitiesHelp' => 'Ten identidades separadas — cada una con sus mensajes y contactos. Todas salen de tu única copia de seguridad, así que cambiar no añade nada que recordar.', + 'profile.identityLabel' => ({required Object n}) => 'Identidad ${n}', + 'profile.current' => 'En uso', + 'profile.newIdentity' => 'Nueva identidad', + 'profile.switchTitle' => '¿Cambiar de identidad?', + 'profile.switchBody' => 'Los mensajes y contactos se guardan por separado para cada identidad. Puedes volver cuando quieras.', + 'profile.switchAction' => 'Cambiar', 'chatList.title' => 'Mensajes', 'chatList.empty' => 'Aún no hay conversaciones. Escribe a alguien desde el mercado.', 'chat.title' => 'Chat', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 22249a5..6a01663 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -712,6 +712,14 @@ class _Translations$profile$pt extends Translations$profile$en { @override String get copied => 'Copiado'; @override String get save => 'Guardar'; @override String get saved => 'Perfil guardado'; + @override String get identities => 'As tuas identidades'; + @override String get identitiesHelp => 'Mantém identidades separadas — cada uma com as suas mensagens e contactos. Todas vêm da tua única cópia de segurança, por isso mudar não acrescenta nada para lembrar.'; + @override String identityLabel({required Object n}) => 'Identidade ${n}'; + @override String get current => 'Em uso'; + @override String get newIdentity => 'Nova identidade'; + @override String get switchTitle => 'Mudar de identidade?'; + @override String get switchBody => 'As mensagens e contactos são guardados separadamente para cada identidade. Podes voltar quando quiseres.'; + @override String get switchAction => 'Mudar'; } // Path: chatList @@ -1467,6 +1475,14 @@ extension on TranslationsPt { 'profile.copied' => 'Copiado', 'profile.save' => 'Guardar', 'profile.saved' => 'Perfil guardado', + 'profile.identities' => 'As tuas identidades', + 'profile.identitiesHelp' => 'Mantém identidades separadas — cada uma com as suas mensagens e contactos. Todas vêm da tua única cópia de segurança, por isso mudar não acrescenta nada para lembrar.', + 'profile.identityLabel' => ({required Object n}) => 'Identidade ${n}', + 'profile.current' => 'Em uso', + 'profile.newIdentity' => 'Nova identidade', + 'profile.switchTitle' => 'Mudar de identidade?', + 'profile.switchBody' => 'As mensagens e contactos são guardados separadamente para cada identidade. Podes voltar quando quiseres.', + 'profile.switchAction' => 'Mudar', 'chatList.title' => 'Mensagens', 'chatList.empty' => 'Ainda não há conversas. Escreve a alguém a partir do mercado.', 'chat.title' => 'Conversa', diff --git a/apps/app_seeds/lib/main.dart b/apps/app_seeds/lib/main.dart index e74b392..0a4c2db 100644 --- a/apps/app_seeds/lib/main.dart +++ b/apps/app_seeds/lib/main.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import 'bootstrap.dart'; import 'i18n/strings.g.dart'; +import 'ui/restart_widget.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); @@ -10,6 +11,12 @@ void main() { // (e.g. Asturian). The saved choice is restored inside [Bootstrap], after DI. LocaleSettings.useDeviceLocaleSync(); // Paint immediately: [Bootstrap] shows a splash and runs the heavy init off - // the first frame, so the app window appears at once instead of after DI. - runApp(TranslationProvider(child: const Bootstrap())); + // the first frame. Wrapped in [RestartWidget] so switching the social identity + // (which re-registers the social singletons) can rebuild the whole tree — + // Bootstrap re-reads its dependencies from the locator on each rebuild. + runApp( + RestartWidget( + builder: () => TranslationProvider(child: const Bootstrap()), + ), + ); } diff --git a/apps/app_seeds/lib/services/message_store.dart b/apps/app_seeds/lib/services/message_store.dart index e798969..cf9cdaf 100644 --- a/apps/app_seeds/lib/services/message_store.dart +++ b/apps/app_seeds/lib/services/message_store.dart @@ -23,9 +23,13 @@ class ChatSummary { } class MessageStore { - MessageStore(this._store); + /// [accountScope] namespaces the keys per social identity (empty = the + /// original identity's legacy keys). See [socialAccountScope]. + MessageStore(this._store, {String accountScope = ''}) + : _base = accountScope.isEmpty ? 'tane.social.' : 'tane.social.$accountScope.'; final SecretStore _store; + final String _base; /// Serializes [append]'s read-modify-write. Several sources now write the same /// conversation concurrently (the app-wide inbox listener and an open chat's @@ -33,17 +37,15 @@ class MessageStore { /// read the same history and the second write would clobber the first. Future _writeTail = Future.value(); - static const _prefix = 'tane.social.chat.'; - /// Index of peers we have a conversation with (the keystore is key/value with /// no key enumeration, so we track the list ourselves). - static const _indexKey = 'tane.social.chats'; + String get _indexKey => '${_base}chats'; /// Keep only the most recent [_cap] messages per conversation, to bound the /// keystore entry size. static const _cap = 200; - String _key(String peerPubkey) => '$_prefix$peerPubkey'; + String _key(String peerPubkey) => '${_base}chat.$peerPubkey'; /// Messages exchanged with [peerPubkey], oldest first. Future> history(String peerPubkey) async { diff --git a/apps/app_seeds/lib/services/profile_cache.dart b/apps/app_seeds/lib/services/profile_cache.dart index c403139..a9977fc 100644 --- a/apps/app_seeds/lib/services/profile_cache.dart +++ b/apps/app_seeds/lib/services/profile_cache.dart @@ -4,10 +4,15 @@ import '../security/secret_store.dart'; /// `name`), so the inbox and chat show a human name instead of a raw key — /// even offline. Keystore-backed (no plaintext at rest). class ProfileCache { - ProfileCache(this._store); + /// [accountScope] namespaces the keys per social identity (empty = the + /// original identity's legacy keys). See [socialAccountScope]. + ProfileCache(this._store, {String accountScope = ''}) + : _prefix = accountScope.isEmpty + ? 'tane.social.name.' + : 'tane.social.$accountScope.name.'; final SecretStore _store; - static const _prefix = 'tane.social.name.'; + final String _prefix; /// The cached name for [pubkeyHex], or null if none is known. Future name(String pubkeyHex) async { diff --git a/apps/app_seeds/lib/services/profile_store.dart b/apps/app_seeds/lib/services/profile_store.dart index d99c4f4..970ba8c 100644 --- a/apps/app_seeds/lib/services/profile_store.dart +++ b/apps/app_seeds/lib/services/profile_store.dart @@ -4,12 +4,19 @@ import '../security/secret_store.dart'; /// no plaintext at rest). The network copy is a published NIP-01 kind:0 event; /// this local copy prefills the editor and works offline. class ProfileStore { - ProfileStore(this._store); + /// [accountScope] namespaces the keys per social identity (empty = the + /// original identity's legacy keys). See [socialAccountScope]. + ProfileStore(this._store, {String accountScope = ''}) + : _base = accountScope.isEmpty + ? 'tane.social.profile.' + : 'tane.social.$accountScope.profile.'; final SecretStore _store; - static const _nameKey = 'tane.social.profile.name'; - static const _aboutKey = 'tane.social.profile.about'; - static const _g1Key = 'tane.social.profile.g1'; + final String _base; + + String get _nameKey => '${_base}name'; + String get _aboutKey => '${_base}about'; + String get _g1Key => '${_base}g1'; Future name() async => (await _store.read(_nameKey)) ?? ''; Future about() async => (await _store.read(_aboutKey)) ?? ''; diff --git a/apps/app_seeds/lib/services/social_account_store.dart b/apps/app_seeds/lib/services/social_account_store.dart new file mode 100644 index 0000000..5c449c8 --- /dev/null +++ b/apps/app_seeds/lib/services/social_account_store.dart @@ -0,0 +1,60 @@ +import '../security/secret_store.dart'; + +/// The per-identity key scope for [account]: empty for the original identity +/// (account 0, so its data keeps the legacy un-namespaced keys), otherwise a +/// distinct namespace. Used by the per-identity stores (chats, profile, name +/// cache) so switching identity never mixes one identity's data into another. +String socialAccountScope(int account) => account == 0 ? '' : 'acct$account'; + +/// Remembers which social identity ("account") of the root seed is active, and +/// how many the user has created, so identities can be switched and listed. +/// +/// Each account is a pseudonymous Nostr identity deterministically derived from +/// the SAME root seed (see `NostrKeyDerivation.deriveFromSeed`), so switching +/// never adds anything to back up — the one seed still regenerates them all. +/// Keystore-backed: no plaintext at rest. +class SocialAccountStore { + SocialAccountStore(this._store); + + final SecretStore _store; + + static const _activeKey = 'tane.social.account.active'; + static const _maxKey = 'tane.social.account.max'; + + /// The active account index (0 = the original identity). + Future active() async => _readInt(_activeKey); + + /// The highest account index ever created (so the list is 0..max and a new + /// identity takes max + 1). At least the active one always exists. + Future maxCreated() async { + final max = await _readInt(_maxKey); + final activeIdx = await active(); + return max > activeIdx ? max : activeIdx; + } + + /// Switches the active identity to [account] (must already be within + /// 0..maxCreated, or be maxCreated + 1 for a freshly created one). + Future setActive(int account) async { + if (account < 0) { + throw ArgumentError.value(account, 'account', 'must be >= 0'); + } + await _store.write(_activeKey, '$account'); + if (account > await _readInt(_maxKey)) { + await _store.write(_maxKey, '$account'); + } + } + + /// Creates a brand-new identity (the next index) and makes it active. Returns + /// the new account index. + Future createNew() async { + final next = await maxCreated() + 1; + await setActive(next); + return next; + } + + Future _readInt(String key) async { + final raw = await _store.read(key); + if (raw == null || raw.isEmpty) return 0; + return int.tryParse(raw) ?? 0; + } +} diff --git a/apps/app_seeds/lib/services/social_service.dart b/apps/app_seeds/lib/services/social_service.dart index 87463de..db4cfab 100644 --- a/apps/app_seeds/lib/services/social_service.dart +++ b/apps/app_seeds/lib/services/social_service.dart @@ -10,13 +10,26 @@ import 'package:commons_core/commons_core.dart'; /// offline; nothing connects to a relay until [openSession] is called, so the /// app runs fully without network and the social layer only enriches. class SocialService { - SocialService({required this.identity, List relays = const []}) - : relays = List.unmodifiable(relays); + SocialService({ + required this.identity, + this.account = 0, + this.rootSeedHex = '', + List relays = const [], + }) : relays = List.unmodifiable(relays); /// The derived Nostr identity (secp256k1). Same key across reinstalls that - /// restore the same seed. + /// restore the same seed (for a given [account]). final NostrIdentity identity; + /// Which pseudonymous identity of the root seed this is (0 = the original). + /// Switching account changes the social identity while the backup stays the + /// single root seed. See [NostrKeyDerivation.deriveFromSeed]. + final int account; + + /// The root-seed hex, kept so the switcher can preview OTHER accounts' + /// identities without re-reading the keystore. Empty when unknown (tests). + final String rootSeedHex; + /// Community relay URLs (may be empty — discovery/publishing degrade to /// nothing when offline or unconfigured). final List relays; @@ -25,15 +38,35 @@ class SocialService { String get npub => identity.npub; String get publicKeyHex => identity.publicKeyHex; - /// Derives the identity from the stored root-seed hex (32-byte, 64 chars). + /// Derives the identity from the stored root-seed hex (32-byte, 64 chars) for + /// the given [account] (0 = the original identity). static Future fromRootSeedHex( String rootSeedHex, { + int account = 0, List relays = const [], }) async { final identity = await NostrKeyDerivation.deriveFromSeed( _hexToBytes(rootSeedHex), + account: account, ); - return SocialService(identity: identity, relays: relays); + return SocialService( + identity: identity, + account: account, + rootSeedHex: rootSeedHex, + relays: relays, + ); + } + + /// The `npub` for another [account] of the same root seed, so the switcher can + /// show identities before committing to one. Returns null if the seed is + /// unknown (e.g. in tests constructed without it). + Future npubForAccount(int account) async { + if (rootSeedHex.isEmpty) return null; + final id = await NostrKeyDerivation.deriveFromSeed( + _hexToBytes(rootSeedHex), + account: account, + ); + return id.npub; } /// Opens a session against [relayUrls]: one fault-tolerant pool carrying all diff --git a/apps/app_seeds/lib/ui/profile_screen.dart b/apps/app_seeds/lib/ui/profile_screen.dart index 815671d..552a5cb 100644 --- a/apps/app_seeds/lib/ui/profile_screen.dart +++ b/apps/app_seeds/lib/ui/profile_screen.dart @@ -1,11 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../di/injector.dart' show switchSocialAccount; import '../i18n/strings.g.dart'; +import '../services/profile_cache.dart' show shortPubkey; +import '../services/social_account_store.dart'; import '../services/profile_store.dart'; import '../services/social_service.dart'; import '../services/social_settings.dart'; import 'qr_view.dart'; +import 'restart_widget.dart'; import 'theme.dart'; /// Your own profile: the shareable identity (npub) plus a display name and short @@ -16,12 +20,14 @@ class ProfileScreen extends StatefulWidget { required this.social, required this.settings, required this.profileStore, + required this.accounts, super.key, }); final SocialService social; final SocialSettings settings; final ProfileStore profileStore; + final SocialAccountStore accounts; @override State createState() => _ProfileScreenState(); @@ -33,6 +39,11 @@ class _ProfileScreenState extends State { final _g1 = TextEditingController(); bool _loading = true; bool _saving = false; + bool _switching = false; + + /// The identities of this root seed (account index + its npub), for the + /// switcher. Always includes the active one. + List<({int account, String npub})> _identities = const []; @override void initState() { @@ -44,7 +55,50 @@ class _ProfileScreenState extends State { _name.text = await widget.profileStore.name(); _about.text = await widget.profileStore.about(); _g1.text = await widget.profileStore.g1(); - if (mounted) setState(() => _loading = false); + final max = await widget.accounts.maxCreated(); + final ids = <({int account, String npub})>[]; + for (var i = 0; i <= max; i++) { + ids.add((account: i, npub: await widget.social.npubForAccount(i) ?? '')); + } + if (mounted) { + setState(() { + _identities = ids; + _loading = false; + }); + } + } + + /// Switches to (or creates) an identity and rebuilds the app on the new one. + Future _switchTo(int account, {bool confirm = true}) async { + if (account == widget.social.account || _switching) return; + final t = context.t; + if (confirm) { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(t.profile.switchTitle), + content: Text(t.profile.switchBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(t.common.cancel)), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(t.profile.switchAction)), + ], + ), + ); + if (ok != true) return; + } + if (!mounted) return; + setState(() => _switching = true); + await switchSocialAccount(account); + if (mounted) RestartWidget.restart(context); + } + + Future _newIdentity() async { + final next = await widget.accounts.maxCreated() + 1; + await _switchTo(next, confirm: false); // creating one is already explicit } Future _copyId() async { @@ -143,12 +197,95 @@ class _ProfileScreenState extends State { ) : Text(t.profile.save), ), + const SizedBox(height: 28), + const Divider(), + const SizedBox(height: 12), + _IdentitiesSection( + identities: _identities, + activeAccount: widget.social.account, + busy: _switching, + onSwitch: (a) => _switchTo(a), + onNew: _newIdentity, + ), ], ), ); } } +/// The identity switcher: every pseudonymous identity of this root seed, with +/// the active one marked, plus "new identity". Switching keeps chats/contacts +/// separate per identity; all regenerate from the single backup. +class _IdentitiesSection extends StatelessWidget { + const _IdentitiesSection({ + required this.identities, + required this.activeAccount, + required this.busy, + required this.onSwitch, + required this.onNew, + }); + + final List<({int account, String npub})> identities; + final int activeAccount; + final bool busy; + final ValueChanged onSwitch; + final VoidCallback onNew; + + @override + Widget build(BuildContext context) { + final t = context.t; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.profile.identities, + style: const TextStyle( + fontWeight: FontWeight.w600, color: seedOnSurface), + ), + const SizedBox(height: 4), + Text( + t.profile.identitiesHelp, + style: const TextStyle(color: seedMuted, fontSize: 12), + ), + const SizedBox(height: 8), + for (final id in identities) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + id.account == activeAccount + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: id.account == activeAccount ? seedGreen : seedMuted, + ), + title: Text(t.profile.identityLabel(n: id.account + 1)), + subtitle: id.npub.isEmpty + ? null + : Text(shortPubkey(id.npub), + style: const TextStyle( + fontFamily: 'monospace', fontSize: 12)), + trailing: id.account == activeAccount + ? Text(t.profile.current, + style: const TextStyle(color: seedGreen, fontSize: 12)) + : null, + onTap: busy || id.account == activeAccount + ? null + : () => onSwitch(id.account), + ), + const SizedBox(height: 4), + Align( + alignment: AlignmentDirectional.centerStart, + child: TextButton.icon( + key: const Key('profile.newIdentity'), + onPressed: busy ? null : onNew, + icon: const Icon(Icons.add, size: 18), + label: Text(t.profile.newIdentity), + ), + ), + ], + ); + } +} + class _IdentityCard extends StatelessWidget { const _IdentityCard({required this.npub, required this.onCopy}); diff --git a/apps/app_seeds/lib/ui/restart_widget.dart b/apps/app_seeds/lib/ui/restart_widget.dart new file mode 100644 index 0000000..5ad7bee --- /dev/null +++ b/apps/app_seeds/lib/ui/restart_widget.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +/// Rebuilds its whole subtree from scratch on demand — used to apply a social +/// identity switch, which re-registers the social singletons in the locator and +/// needs the widget tree (router, screens, sessions) rebuilt to pick them up. +/// +/// [builder] is re-invoked on each restart, so it should read its dependencies +/// fresh (e.g. from the service locator) rather than close over stale ones. +class RestartWidget extends StatefulWidget { + const RestartWidget({required this.builder, super.key}); + + final Widget Function() builder; + + /// Tears down and rebuilds everything under the nearest [RestartWidget]. + static void restart(BuildContext context) => + context.findAncestorStateOfType<_RestartWidgetState>()?.restart(); + + @override + State createState() => _RestartWidgetState(); +} + +class _RestartWidgetState extends State { + Key _key = UniqueKey(); + + void restart() => setState(() => _key = UniqueKey()); + + @override + Widget build(BuildContext context) => + KeyedSubtree(key: _key, child: widget.builder()); +} diff --git a/apps/app_seeds/test/services/message_store_test.dart b/apps/app_seeds/test/services/message_store_test.dart index 138ee46..57cc22d 100644 --- a/apps/app_seeds/test/services/message_store_test.dart +++ b/apps/app_seeds/test/services/message_store_test.dart @@ -59,6 +59,21 @@ void main() { expect(await store.history('peer'), hasLength(2)); }); + test('per-identity scope isolates conversations (0 = legacy keys)', () async { + final secret = InMemorySecretStore(); + final acct0 = MessageStore(secret); // legacy / account 0 + final acct1 = MessageStore(secret, accountScope: 'acct1'); + + await acct0.append('peer', msg('peer', 'for identity 0', 1000)); + await acct1.append('peer', msg('peer', 'for identity 1', 2000)); + + // Same peer, but each identity sees only its own conversation. + expect((await acct0.history('peer')).single.text, 'for identity 0'); + expect((await acct1.history('peer')).single.text, 'for identity 1'); + expect((await acct0.conversations()).single.lastText, 'for identity 0'); + expect((await acct1.conversations()).single.lastText, 'for identity 1'); + }); + test('history is capped to the most recent 200', () async { for (var i = 0; i < 210; i++) { await store.append('peer', msg('me', 'm$i', i)); diff --git a/apps/app_seeds/test/services/social_account_store_test.dart b/apps/app_seeds/test/services/social_account_store_test.dart new file mode 100644 index 0000000..029034e --- /dev/null +++ b/apps/app_seeds/test/services/social_account_store_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/services/social_account_store.dart'; + +import '../support/test_support.dart'; + +void main() { + test('defaults to account 0 with no max created yet', () async { + final store = SocialAccountStore(InMemorySecretStore()); + expect(await store.active(), 0); + expect(await store.maxCreated(), 0); + }); + + test('createNew increments the max and makes it active', () async { + final store = SocialAccountStore(InMemorySecretStore()); + expect(await store.createNew(), 1); + expect(await store.active(), 1); + expect(await store.maxCreated(), 1); + + expect(await store.createNew(), 2); + expect(await store.active(), 2); + expect(await store.maxCreated(), 2); + }); + + test('setActive can switch back to an earlier identity, keeping the max', + () async { + final store = SocialAccountStore(InMemorySecretStore()); + await store.createNew(); // 1 + await store.createNew(); // 2 + await store.setActive(0); // back to the original + expect(await store.active(), 0); + expect(await store.maxCreated(), 2); // still remembers we made 3 identities + }); + + test('a negative account is rejected', () async { + final store = SocialAccountStore(InMemorySecretStore()); + expect(() => store.setActive(-1), throwsArgumentError); + }); + + test('scope: account 0 uses legacy (empty) keys, others are namespaced', () { + expect(socialAccountScope(0), ''); + expect(socialAccountScope(1), 'acct1'); + expect(socialAccountScope(2), 'acct2'); + }); +} diff --git a/docs/design/open-decisions.md b/docs/design/open-decisions.md index 7f86c28..162d07c 100644 --- a/docs/design/open-decisions.md +++ b/docs/design/open-decisions.md @@ -28,7 +28,7 @@ Estas fijan `schemaVersion = 1` y el arranque técnico: - **Integración Ğ1 (moneda libre):** niveles 1–2 (precio en Ğ1 + enlace a cartera Ğecko/Cesium²/Ğ1nkgo) en la capa social; nivel 3 (reusar la WoT de Ğ1 como fuente de confianza) como estudio aparte. → [g1-integration.md](g1-integration.md) - **Ofertas de banco colectivo** (publica la entidad, no la persona). → sharing-model §6 - **Caducidad/revocación** de ofertas ya replicadas en relays. → sharing-model §6 -- **Cambiar de identidad social — IMPORTANTE, pendiente (feedback en dispositivo 2026-07-10).** Hoy la identidad Nostr se deriva una sola vez de la semilla raíz al arrancar (DI) y no hay forma de cambiarla. Se pidió poder hacerlo. Dos vías: **(A) identidad seudónima aparte** (recomendada, encaja con g1-integration.md §"peor caso": una clave Nostr distinta solo para lo social, manteniendo la semilla raíz para inventario/backup/Ğ1 intactos) o **(B) reinicio total** (nueva semilla raíz; se pierde la identidad y su recuperación). Implica **re-derivar la identidad al vuelo** (hoy se cachea en `SocialService` al iniciar) — probablemente con reinicio de la app o recreando `SocialService` + sesiones. Decidir modelo (seudónima vs reset), persistir la "semilla social activa" en el keystore, y que `SocialService.fromRootSeedHex` lea de ahí. → [[project-block2-spike]] +- **Cambiar de identidad social — RESUELTO/IMPLEMENTADO (2026-07-10).** Se eligió una refinación de la vía (A) que **mantiene "backup UNA semilla"**: identidades seudónimas **derivadas de la misma semilla raíz con un índice de cuenta** (`NostrKeyDerivation.deriveFromSeed(seed, {account})`, HKDF one-way → no vinculables a Ğ1; **cuenta 0 = la identidad actual, byte-for-byte, sin rotar a nadie**). La cuenta activa se persiste en el keystore (`SocialAccountStore`), los stores por-identidad (chats/perfil/nombres) se **namespacian por cuenta** (cuenta 0 = claves legacy, sin migración), y `switchSocialAccount()` re-registra la porción social del DI y reinicia el inbox; la UI (perfil → "Tus identidades", crear/cambiar) aplica el cambio con un `RestartWidget` que reconstruye el árbol leyendo los singletons nuevos. La app NO toca la DB/inventario. Descartada la vía (B) reset total (perdía identidad+recuperación). → [[project-block2-spike]] ## C) Puede esperar (no bloquea nada ahora) diff --git a/packages/commons_core/lib/src/identity/nostr_key_derivation.dart b/packages/commons_core/lib/src/identity/nostr_key_derivation.dart index e6be6f5..6d3673c 100644 --- a/packages/commons_core/lib/src/identity/nostr_key_derivation.dart +++ b/packages/commons_core/lib/src/identity/nostr_key_derivation.dart @@ -51,12 +51,25 @@ class NostrKeyDerivation { radix: 16, ); - static Future deriveFromSeed(List seed) async { + /// Derives the identity for a given [account] (default 0). Different accounts + /// yield unlinkable pseudonymous identities from the SAME root seed — so the + /// user can switch social identity while still backing up only the one seed + /// (every account regenerates from it). Account 0 is the legacy identity and + /// its derivation is byte-for-byte unchanged, so existing users keep their key. + static Future deriveFromSeed( + List seed, { + int account = 0, + }) async { + if (account < 0) throw ArgumentError.value(account, 'account', 'must be >= 0'); + // Account 0 keeps the original info string (no rotation for current users); + // higher accounts branch into a distinct, domain-separated sub-path. + final base = + account == 0 ? derivationInfo : '$derivationInfo/account/$account'; var counter = 0; while (true) { final key = await _hkdf.deriveKey( secretKey: SecretKey(seed), - info: '$derivationInfo/$counter'.codeUnits, + info: '$base/$counter'.codeUnits, nonce: const [], ); final bytes = await key.extractBytes(); diff --git a/packages/commons_core/test/identity/nostr_key_derivation_test.dart b/packages/commons_core/test/identity/nostr_key_derivation_test.dart index d0b01cb..c8bd1ba 100644 --- a/packages/commons_core/test/identity/nostr_key_derivation_test.dart +++ b/packages/commons_core/test/identity/nostr_key_derivation_test.dart @@ -49,6 +49,31 @@ void main() { expect(id.publicKeyHex, isNot(contains(seedHex))); }); + test('account 0 is the legacy identity (byte-for-byte unchanged)', + () async { + final legacy = await NostrKeyDerivation.deriveFromSeed(seed); + final explicit = await NostrKeyDerivation.deriveFromSeed(seed, account: 0); + expect(explicit.privateKeyHex, legacy.privateKeyHex); + expect(explicit.publicKeyHex, legacy.publicKeyHex); + }); + + test('different accounts on one seed → unlinkable identities', () async { + final a0 = await NostrKeyDerivation.deriveFromSeed(seed, account: 0); + final a1 = await NostrKeyDerivation.deriveFromSeed(seed, account: 1); + final a2 = await NostrKeyDerivation.deriveFromSeed(seed, account: 2); + final keys = {a0.publicKeyHex, a1.publicKeyHex, a2.publicKeyHex}; + expect(keys, hasLength(3)); // all distinct + + // ...yet each account is itself deterministic (regenerates from the seed). + final a1again = await NostrKeyDerivation.deriveFromSeed(seed, account: 1); + expect(a1again.publicKeyHex, a1.publicKeyHex); + }); + + test('a negative account is rejected', () async { + expect(() => NostrKeyDerivation.deriveFromSeed(seed, account: -1), + throwsArgumentError); + }); + test('integrates with the real IdentityService root seed', () async { final rootSeed = IdentityService(random: Random(42)).generateRootSeed(); expect(rootSeed.length, IdentityService.rootSeedLengthBytes);