Skip to content

feat: share Pubky identities with Bitkit - #342

Closed
Jasonvdb wants to merge 52 commits into
mainfrom
feat/android-shared-pubky-relaunch
Closed

Jasonvdb wants to merge 52 commits into
mainfrom
feat/android-shared-pubky-relaunch

Conversation

@Jasonvdb

@Jasonvdb Jasonvdb commented Jul 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Relaunch Android Ring as app.pubkyring 2.0 and allow Ring and Bitkit to explicitly use each other's Pubky identities while the source app retains ownership.

  • Android uses read-only providers, signature permissions, exact caller checks, and matching certificates.
  • iOS uses the non-synchronizing pubky.shared Keychain group with WhenUnlockedThisDeviceOnly protection.
  • Borrowed keys are validated and read only when needed. They are never copied into the consumer's private identity store, backups, or exports.
  • Confirmed source loss disconnects borrowed identities. Temporary discovery/credential failures preserve them, and failed local session deletion remains pending for retry before reconnection.
  • iOS private Keychain enumeration covers both synchronization modes. Validated private identities retained after reinstall become visible in Ring before sharing; malformed shared payloads are rejected safely.

Release order and gates

The legacy Android sunset app v1.17 has rolled out. Bitkit iOS #636 and Android #1084/#1097 are merged. Release Ring before the companion Bitkit PRs.

  • Verify the iOS Keychain fix preserves existing private identities during an in-place simulator upgrade and restores their shared mirrors.
  • Apple: provision pubky.shared for both App IDs, regenerate profiles, and pass signed two-app physical-device tests.
  • Play: confirm installed app-signing certificates and pass final Play-generated Ring/Bitkit interoperability.

Migration notes and limitations

The app-private Ring identity remains canonical. Shared mirrors are verified before stale mirrors are pruned or private identities are deleted. Borrowed identities are never promoted to owned identities. On iOS, uninstalling the app does not remove Keychain identities.

On iOS, canOpenURL(bitkit://) is an availability hint, not proof that Bitkit is installed or a security boundary. Another app registering that scheme can prevent uninstall detection while a retained shared record exists. Keychain access remains restricted by the shared access group.

The existing lifecycle lock still spans connection network calls. Shortening that lock is deferred because it needs separate race handling.

Validation

Current head 97f83fb2 contains main f14243688. The five follow-up commits are signed.

  • Full Jest: 29 suites / 245 tests passed, including transient failures, persisted cleanup/reconnect, retained private identities, and navigation.
  • Native iOS Keychain/payload tests, TypeScript, and git diff --check passed.
  • ESLint: zero errors and nine unchanged warnings. Changed-file Prettier passed; the whole-repository check still flags three unchanged files.
  • Android bundled debug build and iOS simulator build passed. Focused manual QA passed on both platforms.
  • Hosted lint and unit/type checks passed. Android and iOS E2E jobs are running.

Manual QA

Tests used disposable zero-balance wallets and staging or generated local identities. The dated rows cover this follow-up; other checked scenarios are previously completed coverage, not claims that everything was rerun. Simulator results do not replace the Apple physical-device gate. The iOS uninstall scenario is subject to the URL-scheme limitation above.

  • Sep 22, iOS generated-fixture reinstall: both retained private formats restore visible owned cards, preserve exact private bytes and metadata, open backup entry, and delete normally without returning after restart.

  • Sep 22, both platforms: adopt/reconnect a Bitkit key, immediately reoffer after Disconnect, close a disappeared offer's sheet, and preserve Settings while removing the disconnected detail beneath it.

  • Both platforms: owned-card body/action, backup entry, and drag behave independently.

  • Both platforms: adopt the exact Bitkit-owned key in Ring; show borrowed ownership and no Backup action.

  • Both platforms: restart both apps with the Bitkit source retained; the same borrowed Ring profile restores.

  • Both platforms: delete/uninstall/reset the Bitkit source; foregrounding Ring disconnects the borrowed profile while preserving owned keys.

  • Android: reject discovery from a nonempty wrong-certificate source in both directions, with trusted-signer controls.

  • Both platforms: authorize a borrowed Bitkit identity through Ring and independently receive/decrypt the exact grant.

  • Android Ring → Bitkit: exact-key adoption among two choices, empty-contact route, restart, ownership preservation, and Ring-uninstall disconnection.

  • iOS Ring → Bitkit: exact-key selection among four choices, Pay Contacts, restart persistence, and Ring-uninstall disconnection.

  • iOS upgrade: old private keys remain readable; new keys use sync=false; deletion removes private/shared entries without harming old keys.

  • Android rebuilt Ring: create, restart, delete, restart empty; Bitkit discovery omits the deleted identity.

  • Both platforms: published Ring profile displays in Bitkit and explicitly selected contacts import with exact public keys.

  • Both platforms: an owned encrypted Ring backup decrypts to the exact owned identity while a borrowed identity coexists.

  • Both platforms: full watch-only approval using a borrowed Ring identity; requester verifies the signed xpub claim and exact two-path grant.

  • Both platforms: Ring authorizes a borrowed Bitkit identity; requester verifies the exact key and capabilities.

  • Automated fault injection: shared-removal failure blocks private deletion and retry preserves shared-before-private ordering.

  • Wipe recovery: failed final shared-store verification removes only proven-keyless owned cards, preserves surviving/borrowed identities, reports the error, and remains retryable.

  • Source-loss navigation: matching detail and identity-specific sheets close while unrelated and nested routes are preserved.

  • iOS pre-sharing update: private record, shared mirror, encrypted backup, and ownership survive update and cold restart.

  • Android legacy migration: v1.17 side-by-side recovery and v1.16 → v1.17 in-place update preserve exact identities.

  • iOS isolated synthetic wipe: tested legacy/copied-style private keys, sessions, and Ring mirrors are removed; another source's mirror remains unchanged.

@Jasonvdb
Jasonvdb force-pushed the release/legacy-android-sunset branch from d9617a3 to 369b704 Compare July 21, 2026 13:44
Base automatically changed from release/legacy-android-sunset to main July 22, 2026 13:19
@Jasonvdb
Jasonvdb force-pushed the feat/android-shared-pubky-relaunch branch from fa2f0ae to 5b6eac8 Compare July 24, 2026 13:26
@Jasonvdb Jasonvdb changed the title feat(android): relaunch Ring with Bitkit sharing feat: share Pubky identities with Bitkit Jul 24, 2026
@pwltr
pwltr force-pushed the feat/android-shared-pubky-relaunch branch 2 times, most recently from 6383e32 to 2462854 Compare August 3, 2026 09:27
@Jasonvdb
Jasonvdb force-pushed the feat/android-shared-pubky-relaunch branch from c480f2f to f5ca889 Compare August 4, 2026 08:14
Jasonvdb and others added 19 commits September 17, 2026 10:16
pubky.ts now imports uuid for session ids, and deletePubky clears the
per-session keychain secrets, so the suite mocks both.
pubky.ts imports the singleton store to drop a borrowed identity whose
shared credential disappeared, which pulled the real store into the suite.
Session secrets moved from persisted Redux into the Keychain, so removing
the Redux entry no longer clears the sessions Ring created for a borrowed
identity. Route every borrowed disconnect through one helper that clears
them first.
Publishing a signed pkarr record is an ownership action, so it belongs to
the app that owns the key. The batch republish now skips identities
sourced from Bitkit, and the Settings republish section is gated on owned
identities so a borrowed-only user is never offered it.

This also removes the path where a transient shared-credential failure
during a maintenance action would drop the borrowed reference.
performAuth republished the selected identity's homeserver record after
authorising a third-party app. For a borrowed identity that signs an
ownership statement with the source app's key, using a homeserver value
cached at connect time that the owner may since have changed.
…gone

Deleting an owned identity cleared its homeserver session secrets before
the abortable private-key step. A failure there returned with the Redux
entry and its session list intact but the matching secrets already
destroyed, so those grants could never be revoked. The secrets now go
after the private records are confirmed deleted and before the Redux
entry that lists them, so every abort leaves the identity intact and
still revocable.
Dropping a borrowed reference mutated identity state outside the
lifecycle gate every other change takes, so it could interleave with a
connect that had already written a session secret to the Keychain but
not yet dispatched the session, leaving that grant secret unreachable
and unrevocable. The disconnect now runs under the gate and re-checks
that the identity is still present and still borrowed before touching
anything.
@Jasonvdb
Jasonvdb force-pushed the feat/android-shared-pubky-relaunch branch from f5ca889 to 09095c4 Compare September 17, 2026 11:17
@Jasonvdb

Copy link
Copy Markdown
Contributor Author

@codex review

@Jasonvdb
Jasonvdb marked this pull request as ready for review September 18, 2026 13:47

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 254a858d0a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/useSharedPubkyDiscovery.ts Outdated

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 987e504ea9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/utils/pubky.ts

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2e8a6fff70

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/hooks/useSharedPubkyDiscovery.ts Outdated

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 66abc213c6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/utils/pubky.ts
@Jasonvdb

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

Reviewed commit: 3f64fd49dd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Jasonvdb

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b8d672642b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/sheets/sheetNavigation.tsx Outdated
Comment on lines +194 to +196
const displayedContent = rootState.routes[displayedContentIndex];
const hasMatchingDetail =
displayedContent?.name === 'PubkyDetail' && getActiveRoutePubky(displayedContent) === pubky;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove matching detail routes anywhere in the stack

When the disconnected identity's detail screen is below another non-sheet screen, this checks only the currently displayed content route and returns without removing the stale detail. For example, a user can double-tap the logo on PubkyDetail to open Settings; if Bitkit revokes sharing while Settings is active, the foreground refresh disconnects the identity, but going back then reveals the blank detail screen. Fresh evidence in the current tree is the PubkyDetail -> Settings route created by AppHeader.handleDoubleTap, which this displayed-route-only check does not cover. Search all root routes for the matching PubkyDetail, rather than only displayedContent.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 04a0a73. removeDisconnectedPubkyDetail now removes matching detail routes anywhere in the root stack, including beneath Settings, while preserving unrelated routes and the active screen. Regression tests cover this, and manual Android/iOS checks confirmed Settings stays open and Back returns Home after source removal.

@coreyphillips

Copy link
Copy Markdown
Collaborator

Two independent reviews.

needs changing before merge

  • Any discovery error disconnects every borrowed identity (src/hooks/useSharedPubkyDiscovery.ts:62). A transient failure to reach Bitkit is handled as if Bitkit had removed the identities, so every borrowed identity is dropped and its Ring session secrets are deleted. In SharedPubkyModule.list (SharedPubkyModule.kt:120), every exception from resolveContentProvider or query is swallowed and the method reports available=false. On iOS, list rejects on any Keychain error, and discoverSharedPubkys turns that rejection into available:false. The hook then runs the "source gone" branch (useSharedPubkyDiscovery.ts:62) and calls disconnectBorrowedPubky for every borrowed key. A foreground refresh while Bitkit is being updated by Play, or while Bitkit's provider throws for any reason, drops every borrowed card and its sessions. The user then has to reconnect each one. discoverSharedPubkys has a comment saying unavailable is "not an empty source", but the hook treats the two the same way. The unit test explains the disconnect when the source app is gone entirely shows this is intended for the uninstall case, but that branch is also reached on errors. Suggestion: disconnect only on an authoritative absence (the package or provider cannot be resolved, or iOS canOpenURL returns false). On a query or Keychain error, keep the identities. Confirmed by reading the code paths. I did not reproduce on a device.
  • Failed source-loss cleanup is never retried (src/utils/pubky.ts:674). A transient Keychain deletion failure removes the borrowed identity from the only automatic cleanup retry set. disconnectBorrowedPubky logs an error from clearPubkySessionSecrets but still dispatches removePubky. Later discovery passes enumerate borrowed identities from Redux, so they never retry that pubky. I confirmed the failed services remain in the Keychain and only a full wipe or reconnection can target them again.

worth doing, does not block

  • Disconnected Bitkit identity does not reappear until the next foreground (src/hooks/useSharedPubkyDiscovery.ts:89). When the user disconnects a Bitkit identity that was already connected at the last refresh, it disappears from Home entirely. It comes back only after the app is backgrounded and foregrounded again. At useSharedPubkyDiscovery.ts:88-89, runRefresh filters already-connected keys out of identities. refresh runs only on mount and on AppState 'active', and nothing calls it after deletePubky, so after a disconnect Home has no dashed card for X. I confirmed this with a scratch test against the hook with the same mocks as useSharedPubkyDiscovery.test.tsx: when discovery returned X and the store already held X, result.current.identities was []. HomeScreen already filters by pubkyArray, so dropping the filter in the hook (profile fetches can still skip connected keys) or calling refresh() after a disconnect would fix it.
  • iOS reinstall re-exports private keys that Ring no longer lists (src/utils/pubky.ts:956). Ring's mirrors come from the private Keychain rather than from what Ring shows, so on iOS identities that are no longer on Ring's Home are still offered to Bitkit. reconcileOwnedSharedPubkysUnlocked (pubky.ts:956) mirrors every pubky-shaped private Keychain service and never consults Redux. On iOS the Keychain survives uninstall but the MMKV-backed Redux state does not, and restorePubkys has no callers. After a reinstall, Ring's Home is empty while the first foreground refresh mirrors all the old identities into pubky.shared, where Bitkit lists them as Ring-owned. The user cannot see or delete them in Ring short of a full wipe. If Bitkit also shares one of them, Ring shows it as an unconnected card, and connectSharedPubkyUnlocked refuses it through hasPrivatePubky (pubky.ts:891). The error says "This pubky is already connected", even though nothing is shown as connected. The PR does note that uninstall keeps Keychain identities, but that manual QA did not cover this case. Either reconcile against the owned Redux identities, or restore those identities into Redux on launch. Confirmed by reading the code.
  • Malformed shared payload can crash the iOS bridge (ios/pubkyring/SharedPubky.m:624). A malformed shared record can raise an uncaught Objective-C exception instead of being rejected. isValidPayload sends isEqualToString: to sourceApp without checking its type. Calling the production validator with a valid JSON payload containing "sourceApp": 123 reproduces an unrecognized-selector exception.
  • A disappeared offer leaves its reuse sheet open (src/hooks/useSharedPubkyDiscovery.ts:76). A disappeared unconnected identity leaves its open reuse sheet on screen. Refresh removes the identity from context, but route cleanup only runs for identities already persisted as borrowed. Pressing the sheet action then retries its stale route snapshot, fails credential lookup, and deliberately leaves the unusable sheet open.
  • Manual disconnect hides a still-shared identity (src/hooks/useSharedPubkyDiscovery.ts:88). A still-shared identity does not reappear after manual disconnect until another foreground refresh. Refresh stores only identities absent from Redux, so after a restart the connected identity is omitted from context. Manual deletion removes it from Redux without refreshing discovery, leaving no Use in Ring card despite the source still sharing it.

nits

  • Identity lifecycle lock is held across network calls when connecting (src/utils/pubky.ts:944). connectSharedPubkyUnlocked (pubky.ts:886) runs getHomeserver, signInToHomeserver, getProfileInfo and getProfileAvatar while holding the global withPubkyIdentityLifecycle gate. On a slow network, create, import, delete, foreground reconciliation and legacy-key migration in getPubkySecretKey all queue behind it. The migration wait also counts against performAuth's 20s timeout. At minimum, the purely cosmetic profile and avatar fetches (pubky.ts:944) could run after the gate is released.
  • pubkyAlreadyExists copy gives the wrong advice on the connect path (src/i18n/locales/en.json:305). The string says "Disconnect it before importing it as a Ring-owned identity". It fits the import path. It is also returned by connectSharedPubkyUnlocked when Ring already owns the key, where the user is not importing anything and there is nothing to disconnect.

the reviewers disagree, your call

  • Any bitkit URL handler preserves stale iOS credentials (ios/pubkyring/SharedPubky.m:636). Objection: IOS has no API that proves another app's identity, and the comment at line 632 already says canOpenURL is only an availability hint. A squatting app gains nothing because it can't read the team-scoped Keychain group. The only effect is that Ring keeps using the user's own, still-valid key that the user connected, so this is a documented platform limit with no fix, not a blocking defect. Answer: With a valid to.bitkit:<pubky> record retained after Bitkit is uninstalled, any unrelated app handling bitkit:// makes line 636 return true. list then returns that record at lines 163-191 and credential returns its key at lines 205-216, so the cleanup at src/hooks/useSharedPubkyDiscovery.ts:76-85 does not disconnect it. The other app does not need Keychain access because Ring performs the read.

@Jasonvdb

Copy link
Copy Markdown
Contributor Author

@coreyphillips Addressed both blockers and the smaller fixes in five signed commits:

  • e0b9623: sharedPubky.ts and the native bridges distinguish read errors from source loss. Errors preserve connected identities. Android also rejects incomplete provider listings.
  • 97f83fb: pubky.ts persists failed session cleanup for startup/foreground retry. Reconnect/import wait for cleanup and verified persistence before creating new sessions.
  • 75c6433: SharedPubky.m checks payload field types before Objective-C string calls, with native malformed-payload regression tests.
  • 3c0ae14: reconcileOwnedSharedPubkys restores missing cards from validated private Keychain records before sharing them. Existing metadata and borrowed ownership are preserved. Pending cleanup blocks restoration.
  • 04a0a73: discovery retains connected offers so Disconnect immediately reveals "Use in Ring." Disappeared offers close their sheets. sheetNavigation removes matching details beneath Settings without closing Settings. Connect now has its own duplicate message.

The lifecycle-lock/network refactor is deferred because releasing the gate needs separate race handling. The PR documents the iOS URL-scheme limitation: another app registering bitkit:// can keep retained credentials available after Bitkit is uninstalled, without gaining Keychain access itself.

Validation: 29 Jest suites / 245 tests, native iOS tests, TypeScript, and changed-file formatting passed. ESLint has no errors and nine unchanged warnings.

Manual Android/iOS tests passed for adoption, restart, immediate reoffer/reconnect, stale-sheet closure, and source removal beneath Settings. iOS generated fixtures also passed actual reinstall recovery, private-data preservation, backup entry, deletion, and restart without ghost cards. The checklist is updated. CI is running.

@Jasonvdb
Jasonvdb marked this pull request as draft September 22, 2026 09:57
@Jasonvdb Jasonvdb closed this Sep 23, 2026
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.

3 participants