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
d36fd05741
commit
993f7b37ab
25 changed files with 597 additions and 33 deletions
|
|
@ -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