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:
vjrj 2026-07-10 20:20:04 +02:00
parent 4b71859416
commit a109a84714
25 changed files with 597 additions and 33 deletions

View file

@ -16,6 +16,7 @@ import 'services/offer_outbox.dart';
import 'services/onboarding_store.dart'; import 'services/onboarding_store.dart';
import 'services/profile_cache.dart'; import 'services/profile_cache.dart';
import 'services/profile_store.dart'; import 'services/profile_store.dart';
import 'services/social_account_store.dart';
import 'services/social_service.dart'; import 'services/social_service.dart';
import 'services/social_settings.dart'; import 'services/social_settings.dart';
import 'state/inventory_cubit.dart'; import 'state/inventory_cubit.dart';
@ -50,6 +51,7 @@ class TaneApp extends StatelessWidget {
this.messageStore, this.messageStore,
this.profileStore, this.profileStore,
this.profileCache, this.profileCache,
this.socialAccounts,
this.inbox, this.inbox,
this.showIntro = false, this.showIntro = false,
this.autoBackup, this.autoBackup,
@ -65,6 +67,7 @@ class TaneApp extends StatelessWidget {
messageStore, messageStore,
profileStore, profileStore,
profileCache, profileCache,
socialAccounts,
inbox, inbox,
); );
@ -92,6 +95,9 @@ class TaneApp extends StatelessWidget {
/// Optional cache of peers' published display names. /// Optional cache of peers' published display names.
final ProfileCache? profileCache; 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. /// App-wide inbox listener; drives the messages list's live refresh.
final InboxService? inbox; final InboxService? inbox;
final bool showIntro; final bool showIntro;
@ -112,6 +118,7 @@ class TaneApp extends StatelessWidget {
MessageStore? messageStore, MessageStore? messageStore,
ProfileStore? profileStore, ProfileStore? profileStore,
ProfileCache? profileCache, ProfileCache? profileCache,
SocialAccountStore? socialAccounts,
InboxService? inbox, InboxService? inbox,
) { ) {
return GoRouter( return GoRouter(
@ -157,13 +164,17 @@ class TaneApp extends StatelessWidget {
inbox: inbox, inbox: inbox,
), ),
), ),
if (social != null && socialSettings != null && profileStore != null) if (social != null &&
socialSettings != null &&
profileStore != null &&
socialAccounts != null)
GoRoute( GoRoute(
path: '/profile', path: '/profile',
builder: (context, state) => ProfileScreen( builder: (context, state) => ProfileScreen(
social: social, social: social,
settings: socialSettings, settings: socialSettings,
profileStore: profileStore, profileStore: profileStore,
accounts: socialAccounts,
), ),
), ),
if (social != null && socialSettings != null) if (social != null && socialSettings != null)

View file

@ -17,6 +17,7 @@ import 'services/offer_outbox.dart';
import 'services/onboarding_store.dart'; import 'services/onboarding_store.dart';
import 'services/profile_cache.dart'; import 'services/profile_cache.dart';
import 'services/profile_store.dart'; import 'services/profile_store.dart';
import 'services/social_account_store.dart';
import 'services/social_service.dart'; import 'services/social_service.dart';
import 'services/social_settings.dart'; import 'services/social_settings.dart';
import 'ui/theme.dart'; import 'ui/theme.dart';
@ -67,6 +68,7 @@ class _BootstrapState extends State<Bootstrap> {
messageStore: getIt<MessageStore>(), messageStore: getIt<MessageStore>(),
profileStore: getIt<ProfileStore>(), profileStore: getIt<ProfileStore>(),
profileCache: getIt<ProfileCache>(), profileCache: getIt<ProfileCache>(),
socialAccounts: getIt<SocialAccountStore>(),
inbox: inbox, inbox: inbox,
showIntro: !await onboarding.introSeen(), showIntro: !await onboarding.introSeen(),
autoBackup: getIt.isRegistered<AutoBackupService>() autoBackup: getIt.isRegistered<AutoBackupService>()

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:commons_core/commons_core.dart'; import 'package:commons_core/commons_core.dart';
@ -34,6 +35,7 @@ import '../services/message_store.dart';
import '../services/offer_outbox.dart'; import '../services/offer_outbox.dart';
import '../services/profile_cache.dart'; import '../services/profile_cache.dart';
import '../services/profile_store.dart'; import '../services/profile_store.dart';
import '../services/social_account_store.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/social_settings.dart'; import '../services/social_settings.dart';
@ -75,6 +77,12 @@ Future<void> configureDependencies() async {
final dbKeyHex = await keyStore.databaseKeyHex(); final dbKeyHex = await keyStore.databaseKeyHex();
final rootSeedHex = await keyStore.rootSeedHex(); 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( final database = AppDatabase(
openEncryptedExecutor(await _databaseFile(), dbKeyHex), openEncryptedExecutor(await _databaseFile(), dbKeyHex),
); );
@ -91,7 +99,8 @@ Future<void> configureDependencies() async {
// profile stay hidden) rather than blanking the whole app. // profile stay hidden) rather than blanking the whole app.
SocialService? socialService; SocialService? socialService;
try { try {
socialService = await SocialService.fromRootSeedHex(rootSeedHex); socialService =
await SocialService.fromRootSeedHex(rootSeedHex, account: activeAccount);
} catch (e, s) { } catch (e, s) {
debugPrint('Social identity derivation failed; inventory-only mode: $e\n$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<OnboardingStore>(OnboardingStore(secretStore))
..registerSingleton<LocaleStore>(LocaleStore(secretStore)) ..registerSingleton<LocaleStore>(LocaleStore(secretStore))
..registerSingleton<SocialSettings>(SocialSettings(secretStore)) ..registerSingleton<SocialSettings>(SocialSettings(secretStore))
..registerSingleton<SocialAccountStore>(accounts)
..registerSingleton<OfferOutbox>(OfferOutbox(secretStore)) ..registerSingleton<OfferOutbox>(OfferOutbox(secretStore))
..registerSingleton<MessageStore>(MessageStore(secretStore)) // Per-identity stores are namespaced by the active account's scope.
..registerSingleton<ProfileStore>(ProfileStore(secretStore)) ..registerSingleton<MessageStore>(
..registerSingleton<ProfileCache>(ProfileCache(secretStore)) MessageStore(secretStore, accountScope: scope))
..registerSingleton<ProfileStore>(
ProfileStore(secretStore, accountScope: scope))
..registerSingleton<ProfileCache>(
ProfileCache(secretStore, accountScope: scope))
..registerSingleton<ExportImportService>( ..registerSingleton<ExportImportService>(
ExportImportService( ExportImportService(
repository: varietyRepository, repository: varietyRepository,
@ -192,6 +206,53 @@ Future<void> configureDependencies() async {
getIt.registerSingleton<_DepsReady>(const _DepsReady()); 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 { Future<Directory> _backupsDir() async {
final dir = await getApplicationSupportDirectory(); final dir = await getApplicationSupportDirectory();
return Directory(p.join(dir.path, 'backups')); return Directory(p.join(dir.path, 'backups'));

View file

@ -415,7 +415,15 @@
"copy": "Copiar", "copy": "Copiar",
"copied": "Copiao", "copied": "Copiao",
"save": "Guardar", "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": { "chatList": {
"title": "Mensaxes", "title": "Mensaxes",

View file

@ -418,7 +418,15 @@
"copy": "Copy", "copy": "Copy",
"copied": "Copied", "copied": "Copied",
"save": "Save", "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": { "chatList": {
"title": "Messages", "title": "Messages",

View file

@ -417,7 +417,15 @@
"copy": "Copiar", "copy": "Copiar",
"copied": "Copiado", "copied": "Copiado",
"save": "Guardar", "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": { "chatList": {
"title": "Mensajes", "title": "Mensajes",

View file

@ -414,7 +414,15 @@
"copy": "Copiar", "copy": "Copiar",
"copied": "Copiado", "copied": "Copiado",
"save": "Guardar", "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": { "chatList": {
"title": "Mensagens", "title": "Mensagens",

View file

@ -4,9 +4,9 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 4 /// 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 // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import

View file

@ -713,6 +713,14 @@ class _Translations$profile$ast extends Translations$profile$en {
@override String get copied => 'Copiao'; @override String get copied => 'Copiao';
@override String get save => 'Guardar'; @override String get save => 'Guardar';
@override String get saved => 'Perfil guardáu'; @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 // Path: chatList
@ -1469,6 +1477,14 @@ extension on TranslationsAst {
'profile.copied' => 'Copiao', 'profile.copied' => 'Copiao',
'profile.save' => 'Guardar', 'profile.save' => 'Guardar',
'profile.saved' => 'Perfil guardáu', '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.title' => 'Mensaxes',
'chatList.empty' => 'Entá nun hai charres. Escríbi-y a alguien dende\'l mercáu.', 'chatList.empty' => 'Entá nun hai charres. Escríbi-y a alguien dende\'l mercáu.',
'chat.title' => 'Charra', 'chat.title' => 'Charra',

View file

@ -1330,6 +1330,30 @@ class Translations$profile$en {
/// en: 'Profile saved' /// en: 'Profile saved'
String get saved => '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 // Path: chatList
@ -2233,6 +2257,14 @@ extension on Translations {
'profile.copied' => 'Copied', 'profile.copied' => 'Copied',
'profile.save' => 'Save', 'profile.save' => 'Save',
'profile.saved' => 'Profile saved', '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.title' => 'Messages',
'chatList.empty' => 'No conversations yet. Message someone from the market.', 'chatList.empty' => 'No conversations yet. Message someone from the market.',
'chat.title' => 'Chat', 'chat.title' => 'Chat',

View file

@ -715,6 +715,14 @@ class _Translations$profile$es extends Translations$profile$en {
@override String get copied => 'Copiado'; @override String get copied => 'Copiado';
@override String get save => 'Guardar'; @override String get save => 'Guardar';
@override String get saved => 'Perfil guardado'; @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 // Path: chatList
@ -1473,6 +1481,14 @@ extension on TranslationsEs {
'profile.copied' => 'Copiado', 'profile.copied' => 'Copiado',
'profile.save' => 'Guardar', 'profile.save' => 'Guardar',
'profile.saved' => 'Perfil guardado', '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.title' => 'Mensajes',
'chatList.empty' => 'Aún no hay conversaciones. Escribe a alguien desde el mercado.', 'chatList.empty' => 'Aún no hay conversaciones. Escribe a alguien desde el mercado.',
'chat.title' => 'Chat', 'chat.title' => 'Chat',

View file

@ -712,6 +712,14 @@ class _Translations$profile$pt extends Translations$profile$en {
@override String get copied => 'Copiado'; @override String get copied => 'Copiado';
@override String get save => 'Guardar'; @override String get save => 'Guardar';
@override String get saved => 'Perfil guardado'; @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 // Path: chatList
@ -1467,6 +1475,14 @@ extension on TranslationsPt {
'profile.copied' => 'Copiado', 'profile.copied' => 'Copiado',
'profile.save' => 'Guardar', 'profile.save' => 'Guardar',
'profile.saved' => 'Perfil guardado', '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.title' => 'Mensagens',
'chatList.empty' => 'Ainda não há conversas. Escreve a alguém a partir do mercado.', 'chatList.empty' => 'Ainda não há conversas. Escreve a alguém a partir do mercado.',
'chat.title' => 'Conversa', 'chat.title' => 'Conversa',

View file

@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart';
import 'bootstrap.dart'; import 'bootstrap.dart';
import 'i18n/strings.g.dart'; import 'i18n/strings.g.dart';
import 'ui/restart_widget.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@ -10,6 +11,12 @@ void main() {
// (e.g. Asturian). The saved choice is restored inside [Bootstrap], after DI. // (e.g. Asturian). The saved choice is restored inside [Bootstrap], after DI.
LocaleSettings.useDeviceLocaleSync(); LocaleSettings.useDeviceLocaleSync();
// Paint immediately: [Bootstrap] shows a splash and runs the heavy init off // 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. // the first frame. Wrapped in [RestartWidget] so switching the social identity
runApp(TranslationProvider(child: const Bootstrap())); // (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()),
),
);
} }

View file

@ -23,9 +23,13 @@ class ChatSummary {
} }
class MessageStore { 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 SecretStore _store;
final String _base;
/// Serializes [append]'s read-modify-write. Several sources now write the same /// Serializes [append]'s read-modify-write. Several sources now write the same
/// conversation concurrently (the app-wide inbox listener and an open chat's /// 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. /// read the same history and the second write would clobber the first.
Future<void> _writeTail = Future.value(); 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 /// Index of peers we have a conversation with (the keystore is key/value with
/// no key enumeration, so we track the list ourselves). /// 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 /// Keep only the most recent [_cap] messages per conversation, to bound the
/// keystore entry size. /// keystore entry size.
static const _cap = 200; static const _cap = 200;
String _key(String peerPubkey) => '$_prefix$peerPubkey'; String _key(String peerPubkey) => '${_base}chat.$peerPubkey';
/// Messages exchanged with [peerPubkey], oldest first. /// Messages exchanged with [peerPubkey], oldest first.
Future<List<PrivateMessage>> history(String peerPubkey) async { Future<List<PrivateMessage>> history(String peerPubkey) async {

View file

@ -4,10 +4,15 @@ import '../security/secret_store.dart';
/// `name`), so the inbox and chat show a human name instead of a raw key /// `name`), so the inbox and chat show a human name instead of a raw key
/// even offline. Keystore-backed (no plaintext at rest). /// even offline. Keystore-backed (no plaintext at rest).
class ProfileCache { 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; final SecretStore _store;
static const _prefix = 'tane.social.name.'; final String _prefix;
/// The cached name for [pubkeyHex], or null if none is known. /// The cached name for [pubkeyHex], or null if none is known.
Future<String?> name(String pubkeyHex) async { Future<String?> name(String pubkeyHex) async {

View file

@ -4,12 +4,19 @@ import '../security/secret_store.dart';
/// no plaintext at rest). The network copy is a published NIP-01 kind:0 event; /// no plaintext at rest). The network copy is a published NIP-01 kind:0 event;
/// this local copy prefills the editor and works offline. /// this local copy prefills the editor and works offline.
class ProfileStore { 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; final SecretStore _store;
static const _nameKey = 'tane.social.profile.name'; final String _base;
static const _aboutKey = 'tane.social.profile.about';
static const _g1Key = 'tane.social.profile.g1'; 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> name() async => (await _store.read(_nameKey)) ?? '';
Future<String> about() async => (await _store.read(_aboutKey)) ?? ''; Future<String> about() async => (await _store.read(_aboutKey)) ?? '';

View 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;
}
}

View file

@ -10,13 +10,26 @@ import 'package:commons_core/commons_core.dart';
/// offline; nothing connects to a relay until [openSession] is called, so the /// offline; nothing connects to a relay until [openSession] is called, so the
/// app runs fully without network and the social layer only enriches. /// app runs fully without network and the social layer only enriches.
class SocialService { class SocialService {
SocialService({required this.identity, List<String> relays = const []}) SocialService({
: relays = List.unmodifiable(relays); 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 /// 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; 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 /// Community relay URLs (may be empty discovery/publishing degrade to
/// nothing when offline or unconfigured). /// nothing when offline or unconfigured).
final List<String> relays; final List<String> relays;
@ -25,15 +38,35 @@ class SocialService {
String get npub => identity.npub; String get npub => identity.npub;
String get publicKeyHex => identity.publicKeyHex; 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( static Future<SocialService> fromRootSeedHex(
String rootSeedHex, { String rootSeedHex, {
int account = 0,
List<String> relays = const [], List<String> relays = const [],
}) async { }) async {
final identity = await NostrKeyDerivation.deriveFromSeed( final identity = await NostrKeyDerivation.deriveFromSeed(
_hexToBytes(rootSeedHex), _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 /// Opens a session against [relayUrls]: one fault-tolerant pool carrying all

View file

@ -1,11 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../di/injector.dart' show switchSocialAccount;
import '../i18n/strings.g.dart'; 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/profile_store.dart';
import '../services/social_service.dart'; import '../services/social_service.dart';
import '../services/social_settings.dart'; import '../services/social_settings.dart';
import 'qr_view.dart'; import 'qr_view.dart';
import 'restart_widget.dart';
import 'theme.dart'; import 'theme.dart';
/// Your own profile: the shareable identity (npub) plus a display name and short /// 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.social,
required this.settings, required this.settings,
required this.profileStore, required this.profileStore,
required this.accounts,
super.key, super.key,
}); });
final SocialService social; final SocialService social;
final SocialSettings settings; final SocialSettings settings;
final ProfileStore profileStore; final ProfileStore profileStore;
final SocialAccountStore accounts;
@override @override
State<ProfileScreen> createState() => _ProfileScreenState(); State<ProfileScreen> createState() => _ProfileScreenState();
@ -33,6 +39,11 @@ class _ProfileScreenState extends State<ProfileScreen> {
final _g1 = TextEditingController(); final _g1 = TextEditingController();
bool _loading = true; bool _loading = true;
bool _saving = false; 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 @override
void initState() { void initState() {
@ -44,7 +55,50 @@ class _ProfileScreenState extends State<ProfileScreen> {
_name.text = await widget.profileStore.name(); _name.text = await widget.profileStore.name();
_about.text = await widget.profileStore.about(); _about.text = await widget.profileStore.about();
_g1.text = await widget.profileStore.g1(); _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 { Future<void> _copyId() async {
@ -143,12 +197,95 @@ class _ProfileScreenState extends State<ProfileScreen> {
) )
: Text(t.profile.save), : 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 { class _IdentityCard extends StatelessWidget {
const _IdentityCard({required this.npub, required this.onCopy}); const _IdentityCard({required this.npub, required this.onCopy});

View 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());
}

View file

@ -59,6 +59,21 @@ void main() {
expect(await store.history('peer'), hasLength(2)); 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 { test('history is capped to the most recent 200', () async {
for (var i = 0; i < 210; i++) { for (var i = 0; i < 210; i++) {
await store.append('peer', msg('me', 'm$i', i)); await store.append('peer', msg('me', 'm$i', i));

View 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');
});
}

View file

@ -28,7 +28,7 @@ Estas fijan `schemaVersion = 1` y el arranque técnico:
- **Integración Ğ1 (moneda libre):** niveles 12 (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) - **Integración Ğ1 (moneda libre):** niveles 12 (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 - **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 - **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) ## C) Puede esperar (no bloquea nada ahora)

View file

@ -51,12 +51,25 @@ class NostrKeyDerivation {
radix: 16, 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; var counter = 0;
while (true) { while (true) {
final key = await _hkdf.deriveKey( final key = await _hkdf.deriveKey(
secretKey: SecretKey(seed), secretKey: SecretKey(seed),
info: '$derivationInfo/$counter'.codeUnits, info: '$base/$counter'.codeUnits,
nonce: const [], nonce: const [],
); );
final bytes = await key.extractBytes(); final bytes = await key.extractBytes();

View file

@ -49,6 +49,31 @@ void main() {
expect(id.publicKeyHex, isNot(contains(seedHex))); 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 { test('integrates with the real IdentityService root seed', () async {
final rootSeed = IdentityService(random: Random(42)).generateRootSeed(); final rootSeed = IdentityService(random: Random(42)).generateRootSeed();
expect(rootSeed.length, IdentityService.rootSeedLengthBytes); expect(rootSeed.length, IdentityService.rootSeedLengthBytes);