feat(identity): switch social identity, all from the one backup
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).
This commit is contained in:
parent
4b71859416
commit
a109a84714
25 changed files with 597 additions and 33 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<Bootstrap> {
|
|||
messageStore: getIt<MessageStore>(),
|
||||
profileStore: getIt<ProfileStore>(),
|
||||
profileCache: getIt<ProfileCache>(),
|
||||
socialAccounts: getIt<SocialAccountStore>(),
|
||||
inbox: inbox,
|
||||
showIntro: !await onboarding.introSeen(),
|
||||
autoBackup: getIt.isRegistered<AutoBackupService>()
|
||||
|
|
|
|||
|
|
@ -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<void> 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<void> 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<void> configureDependencies() async {
|
|||
..registerSingleton<OnboardingStore>(OnboardingStore(secretStore))
|
||||
..registerSingleton<LocaleStore>(LocaleStore(secretStore))
|
||||
..registerSingleton<SocialSettings>(SocialSettings(secretStore))
|
||||
..registerSingleton<SocialAccountStore>(accounts)
|
||||
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
|
||||
..registerSingleton<MessageStore>(MessageStore(secretStore))
|
||||
..registerSingleton<ProfileStore>(ProfileStore(secretStore))
|
||||
..registerSingleton<ProfileCache>(ProfileCache(secretStore))
|
||||
// Per-identity stores are namespaced by the active account's scope.
|
||||
..registerSingleton<MessageStore>(
|
||||
MessageStore(secretStore, accountScope: scope))
|
||||
..registerSingleton<ProfileStore>(
|
||||
ProfileStore(secretStore, accountScope: scope))
|
||||
..registerSingleton<ProfileCache>(
|
||||
ProfileCache(secretStore, accountScope: scope))
|
||||
..registerSingleton<ExportImportService>(
|
||||
ExportImportService(
|
||||
repository: varietyRepository,
|
||||
|
|
@ -192,6 +206,53 @@ Future<void> 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<void> switchSocialAccount(int account) async {
|
||||
final secretStore = FlutterSecretStore();
|
||||
await getIt<SocialAccountStore>().setActive(account);
|
||||
final scope = socialAccountScope(account);
|
||||
final rootSeedHex = await getIt<SecureKeyStore>().rootSeedHex();
|
||||
|
||||
// Tear down the old identity's live listener/sessions before replacing.
|
||||
if (getIt.isRegistered<InboxService>()) {
|
||||
await getIt<InboxService>().stop();
|
||||
await getIt.unregister<InboxService>();
|
||||
}
|
||||
if (getIt.isRegistered<SocialService>()) {
|
||||
await getIt.unregister<SocialService>();
|
||||
}
|
||||
await getIt.unregister<MessageStore>();
|
||||
await getIt.unregister<ProfileStore>();
|
||||
await getIt.unregister<ProfileCache>();
|
||||
|
||||
// Re-register the per-identity stores under the new scope, then the identity.
|
||||
getIt
|
||||
..registerSingleton<MessageStore>(
|
||||
MessageStore(secretStore, accountScope: scope))
|
||||
..registerSingleton<ProfileStore>(
|
||||
ProfileStore(secretStore, accountScope: scope))
|
||||
..registerSingleton<ProfileCache>(
|
||||
ProfileCache(secretStore, accountScope: scope));
|
||||
|
||||
final social =
|
||||
await SocialService.fromRootSeedHex(rootSeedHex, account: account);
|
||||
final inbox = InboxService(
|
||||
social: social,
|
||||
settings: getIt<SocialSettings>(),
|
||||
store: getIt<MessageStore>(),
|
||||
profileCache: getIt<ProfileCache>(),
|
||||
);
|
||||
getIt
|
||||
..registerSingleton<SocialService>(social)
|
||||
..registerSingleton<InboxService>(inbox);
|
||||
unawaited(inbox.start());
|
||||
}
|
||||
|
||||
Future<Directory> _backupsDir() async {
|
||||
final dir = await getApplicationSupportDirectory();
|
||||
return Directory(p.join(dir.path, 'backups'));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> _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<List<PrivateMessage>> history(String peerPubkey) async {
|
||||
|
|
|
|||
|
|
@ -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<String?> name(String pubkeyHex) async {
|
||||
|
|
|
|||
|
|
@ -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<String> name() async => (await _store.read(_nameKey)) ?? '';
|
||||
Future<String> about() async => (await _store.read(_aboutKey)) ?? '';
|
||||
|
|
|
|||
60
apps/app_seeds/lib/services/social_account_store.dart
Normal file
60
apps/app_seeds/lib/services/social_account_store.dart
Normal file
|
|
@ -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<int> 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<int> 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<void> 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<int> createNew() async {
|
||||
final next = await maxCreated() + 1;
|
||||
await setActive(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
Future<int> _readInt(String key) async {
|
||||
final raw = await _store.read(key);
|
||||
if (raw == null || raw.isEmpty) return 0;
|
||||
return int.tryParse(raw) ?? 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<String> relays = const []})
|
||||
: relays = List.unmodifiable(relays);
|
||||
SocialService({
|
||||
required this.identity,
|
||||
this.account = 0,
|
||||
this.rootSeedHex = '',
|
||||
List<String> 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<String> 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<SocialService> fromRootSeedHex(
|
||||
String rootSeedHex, {
|
||||
int account = 0,
|
||||
List<String> 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<String?> 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
|
||||
|
|
|
|||
|
|
@ -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<ProfileScreen> createState() => _ProfileScreenState();
|
||||
|
|
@ -33,6 +39,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||
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<ProfileScreen> {
|
|||
_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<void> _switchTo(int account, {bool confirm = true}) async {
|
||||
if (account == widget.social.account || _switching) return;
|
||||
final t = context.t;
|
||||
if (confirm) {
|
||||
final ok = await showDialog<bool>(
|
||||
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<void> _newIdentity() async {
|
||||
final next = await widget.accounts.maxCreated() + 1;
|
||||
await _switchTo(next, confirm: false); // creating one is already explicit
|
||||
}
|
||||
|
||||
Future<void> _copyId() async {
|
||||
|
|
@ -143,12 +197,95 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||
)
|
||||
: 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<int> 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});
|
||||
|
||||
|
|
|
|||
30
apps/app_seeds/lib/ui/restart_widget.dart
Normal file
30
apps/app_seeds/lib/ui/restart_widget.dart
Normal file
|
|
@ -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<RestartWidget> createState() => _RestartWidgetState();
|
||||
}
|
||||
|
||||
class _RestartWidgetState extends State<RestartWidget> {
|
||||
Key _key = UniqueKey();
|
||||
|
||||
void restart() => setState(() => _key = UniqueKey());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
KeyedSubtree(key: _key, child: widget.builder());
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
44
apps/app_seeds/test/services/social_account_store_test.dart
Normal file
44
apps/app_seeds/test/services/social_account_store_test.dart
Normal file
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -51,12 +51,25 @@ class NostrKeyDerivation {
|
|||
radix: 16,
|
||||
);
|
||||
|
||||
static Future<NostrIdentity> deriveFromSeed(List<int> 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<NostrIdentity> deriveFromSeed(
|
||||
List<int> 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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue