Skip to content

feat(nostr): track which account owns a local key on shared phones - #727

Draft
islandbitcoin wants to merge 8 commits into
fix/nostr-npub-auto-relinkfrom
feat/nostr-multi-account-keys
Draft

islandbitcoin wants to merge 8 commits into
fix/nostr-npub-auto-relinkfrom
feat/nostr-multi-account-keys

Conversation

@islandbitcoin

@islandbitcoin islandbitcoin commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Part of ENG-601 (multi-account support). Stacked on #720. Base is fix/nostr-npub-auto-relink; retarget to main once #720 merges. Draft until then.

Problem

The keychain entry holding the Nostr nsec survives logout (use-logout.ts never clears it). On a phone shared by two Flash accounts, account B starts with the key account A generated. #720 registers an unregistered local key silently, so B would claim A's key. The backend then refuses A (NPUB_NOT_AVAILABLE) and A's DMs go to a key B advertises.

This is the first piece of multi-account support: two or more Flash accounts sharing one phone.

Change

  • app/nostr/key-owner.ts: records the owning account next to each local key, keyed on the npub. generateAndStoreKey and saveNewNostrKey stamp it. Deleting keys clears it.
  • NostrKeyEnsurer:
    • unregistered with a key owned by another account → asks once per (account, key) instead of registering.
    • linked → adopts ownership, so keys from before this record and imported nsecs get an owner.
    • mismatch with a foreign-owned key → names the other account, and a refusal never advises deleting that account's key.
    • Runs once per account instead of once per mount. Nothing above it remounts on logout, so B was never checked after A in the same process.
  • keyForeignMessage / keyForeignRelinkRefused, translated in all locales.

Tests

  • __tests__/nostr/key-owner.spec.ts, __tests__/nostr/signer.spec.ts (owner stamp).
  • nostr-key-ensurer.spec.tsx: foreign-key prompt, take over on confirm, decline remembered, per-key marker, refusal copy, once-per-account across logout/login, account payload without id.
  • use-nostr-profile-relink.spec.ts: never registers a foreign key, owner stamp on fresh generation, delete clears owners.

62 tests pass on this branch.

Out of scope, still open for full multi-account

  • Keychain holds one nsec for the whole device, not one per account. Switching accounts still swaps which account can decrypt.
  • Chat caches (giftwraps, rumors, lastSeen_*) are device-global and survive logout, so B can see A's decrypted history.

🤖 Generated with Claude Code

https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX

The keychain entry holding the Nostr nsec survives logout, so on a phone
shared by two Flash accounts, account B can start with the key account A
generated. Silently registering it on B strands A: the backend refuses a
second owner (NPUB_NOT_AVAILABLE) and A's DMs go to a key B advertises.

- app/nostr/key-owner.ts records the owning account next to each local
  key; generateAndStoreKey and saveNewNostrKey stamp it, deleting keys
  clears it.
- NostrKeyEnsurer treats a key owned by another account as prompt-class:
  unregistered asks once per (account, key) instead of registering;
  mismatch names the other account and never answers a refusal with
  advice to delete that account's key.
- The ensurer runs once per account instead of once per mount, since
  nothing above it remounts on logout.
- keyForeignMessage / keyForeignRelinkRefused, translated in all locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX
@linear

linear Bot commented Sep 15, 2026

Copy link
Copy Markdown

ENG-601

Dread and others added 6 commits September 15, 2026 13:04
…copy in Reconnect and for unowned keys, stamp owner on nsec import

- NostrKeyEnsurer: a pending prompt carries the accountId that produced it
  and is dropped unless that account is still signed in; logout and account
  switches clear it and cancel an in-flight check before it writes with the
  next account's token.
- Refusals advise deleting the chat keys only when this account is the
  key's recorded owner; unowned (pre-record) and foreign keys get back-up
  advice. Applies to the ensurer and to Settings > Nostr > Reconnect.
- reconnectLocalNpub stamps the owner on success.
- importNsec records the importing account as owner once the backend
  accepts the key and drops the replaced key's owner record.
- Clear pre-existing lint errors on this PR's changed lines in
  use-nostr-profile.ts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX
…witch race test exercise the guards

- A refused registration of a key with no owner record (every install from
  before the record) no longer claims the key belongs to another account on
  this phone. New keyUnownedRelinkRefused gives back-up advice without that
  claim; keyForeignRelinkRefused is kept for a key another account owns.
  Translated in all locales.
- keyOwnerState (self | other | unknown) replaces the mayAdviseDeletingKey
  boolean; reconnectLocalNpub reports keyOwner, and a shared
  relinkRefusedMessage picks the copy for the ensurer and Reconnect.
- The "stops a check still running for A once B is signed in" test now keeps
  B's check from recording an owner (B has an already-answered mismatch), so
  a resumed A would reach the silent relink. Verified it fails with the
  isCurrent() guards removed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX
Both branches independently guarded against async check results that
outlive the session that started them: #720 with a render-time session
counter, this branch with a per-account run token. Kept this branch's
guard and folded the counter into it, so isCurrent() now also rejects a
continuation that resolves between the logout render and the logout
effect.

- Ensurer docstring: "once per app process" from #720 replaced with the
  per-account behaviour this branch implements.
- Reconnect: #720's report-before-refresh ordering with this branch's
  owner-aware refusal copy.
- #720's logout tests re-sign-in the same account. Here that account is
  re-checked on purpose, so each test now signs back in to a linked state
  and any alert or write can only come from the stale check. Disabling
  isCurrent() fails five of them.
- Reconnect screen spec: mock the account query and clear owner records
  between tests; refusal with no owner record expects the neutral copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX
…lines-per-function

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX
… feat/nostr-multi-account-keys

# Conflicts:
#	app/hooks/use-nostr-profile.ts
…erge

The previous merge resolved a one-comment conflict by taking #720's whole
copy of use-nostr-profile.ts, which dropped this branch's owner stamp,
foreign-key guard and clearNostrKeyOwners. Restores this branch's file
with #720's comment wording on the flash_username lint exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014etuRoG7S3DxsAmWMP9jAX
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant