Skip to content

feat: use pubky ring keys via shared keychain - #774

Open
Jasonvdb wants to merge 23 commits into
masterfrom
feat/shared-pubky-keys
Open

Jasonvdb wants to merge 23 commits into
masterfrom
feat/shared-pubky-keys

Conversation

@Jasonvdb

@Jasonvdb Jasonvdb commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

This PR:

  1. Shares Bitkit's pubky with Pubky Ring through the pubky.shared keychain group
  2. Lets the user pick one of their Pubky Ring pubkys on Pubky Choice
  3. Clears a picked pubky when Pubky Ring deletes it
  4. Removes the old deep-link flow that asked Pubky Ring to sign for Bitkit

Description

  • Adds the pubky.shared keychain group so Pubky Ring can use the pubky Bitkit created, including one created from a signup link, which is shared as soon as signup completes.
  • Lists the user's Pubky Ring pubkys on Pubky Choice; "Create profile with Bitkit" only shows when there are none.
  • Uses a picked Pubky Ring pubky by reading its key only when signing, so Bitkit never stores or backs up a key it does not own.
  • Only ever writes or deletes Bitkit's own entries in the shared group, so it cannot touch Pubky Ring's keys. The entries are named per network with the Android app IDs (to.bitkit, to.bitkit.tnet, to.bitkit.dev), so a test build can't touch the mainnet entry.
  • Clears the picked pubky with a toast when Pubky Ring deletes it, and returns to Pubky Choice from any profile, Pay Contacts or contacts screen.
  • Refuses signup links while a pubky is picked, even after a failed session restore, because the next launch signs in with it again.
  • Asks the user to use Pubky Ring, before the approval sheet opens, when Bitkit can't read a picked pubky's key.
  • Removes the deep-link relay flow, which is no longer needed now that the apps read each other's keys directly; old backups from that flow still decode, but restore without a Pubky identity (see Reviewer Notes).

Release gate

Out of Scope

  • Remote cleanup: when Pubky Ring deletes a picked pubky, Bitkit only clears local state.
  • Uninstalled apps: iOS keeps their keychain items, so a shared entry can outlive its app.
  • PubkyProfileManager: the BIP39 passphrase difference with Android is pre-existing and unchanged.

Reviewer Notes

  • Who can reach this: the profile, Pubky Choice and Paykit screens are all behind the experimental Paykit UI toggle, which is off by default and only reachable from the hidden Dev Settings. Bitkit only has a pubky key after going through those screens, so for regular users of released builds nothing changes: Bitkit publishes no key and Pubky Ring shows no Bitkit pubky. The notes below only affect people who turned that toggle on.
  • Paykit migration: Paykit is still under active development in Bitkit, so this PR doesn't handle Paykit-specific migration. Existing Paykit sessions and links are left as they are, and links made with a picked Pubky Ring pubky stop working if Pubky Ring deletes it. This will be handled once Paykit settles.
  • Old relay backups: backups from the removed relay flow (session kind externalSession, written since v2.3.0 behind the same toggle) still decode, but restore without a Pubky identity. They only held a homeserver session, never the key: the key stays in Pubky Ring and can be picked again on Pubky Choice with a Pubky Ring build that includes feat: share pubky keys with Bitkit pubky/pubky-ring#377. Covered by testRestoreSessionBackupStateForLegacyExternalSessionClearsCredentials.
  • Signing up a picked pubky: sign-up rewrites a pubky's homeserver record, so after a failed sign-in Bitkit signs a picked Pubky Ring pubky up on Homegate only when the network confirms it has no PKARR record. Any other failure shows the sign-in error. Known limit: pkarr gives up on a relay after 2 s, but relays take about 3–4 s to answer 404 for a missing key, so confirming "no record" relies on the DHT. Where the DHT is unreachable, a never-signed-up pubky can't be picked until it is signed up in Pubky Ring. A longer relay timeout in the SDK would close this; that is a follow-up.
  • Existing profiles: when the profile lookup fails for a signed-in pubky, or for one whose key Bitkit already stores, Create Profile doesn't publish until the lookup succeeds, so a network error can't replace an existing profile with an empty one. A brand-new key stays lenient: with no PKARR record yet, its lookup fails the same way a network error does.
  • Locked device: shared entries use WhenUnlockedThisDeviceOnly, like Pubky Ring's, while Bitkit's own keychain uses AfterFirstUnlock. Nothing reads a picked pubky's key in the background, because Bitkit has no background modes, and a failed read keeps the saved session.

Design

Pubky Choice follows the "Profile use existing" and "Profile Create" mockups; the matching Figma frames aren't confirmed, so no link is added.

Preview

Pubky Choice listing Pubky Ring pubkys

Pubky Choice with no Ring pubkys (Create only)

Create Profile pre-filled with the adopted Ring pubky

Profile using the adopted Ring pubky

Adopted pubky cleared after Ring removed it

QA Notes

Journeys

N/A — no journeys yet; see Manual Tests. Most flows need Pubky Ring running alongside Bitkit, which the Capabilities table doesn't list; the few that don't are marked "no journey yet".

Manual Tests

  • 1. Pubky Ring: create a signed-up pubky A and a never-signed-up pubky B → Bitkit Profile → Pubky Choice → both are listed with an uppercase truncated-key caption, and no create card is shown — Pubky Ring alongside Bitkit not in Capabilities
  • 2. Pubky Choice → tap A → nav to Create Profile pre-filled with A's pubky, Profile shows A, and no pubky_secret_key is written to the keychain — Pubky Ring alongside Bitkit not in Capabilities
  • 3. Pubky Choice → tap B (never signed up) → sign-in fails, Bitkit confirms B has no record, signs it up through Homegate and continues to Create Profile — Pubky Ring alongside Bitkit not in Capabilities
  • 4. Pubky Choice → tap a Ring pubky that has a PKARR record but no account, so sign-in fails → the error toast shows, Bitkit doesn't sign up, and the record is unchanged — Pubky Ring alongside Bitkit not in Capabilities
  • 5. Relaunch both apps → Profile → paste a pubkyauth:// request into the scanner → the session is restored and the request is authorized with the Ring key read just in time — Pubky Ring alongside Bitkit not in Capabilities
  • 6. Pick a Ring pubky → scanner → paste a pubkyauth://direct_signup link → "Already signed in" shows and no new identity is created — Pubky Ring alongside Bitkit not in Capabilities
  • 7. Pubky Ring: delete A → relaunch Bitkit → the picked identity is cleared, the stored session is deleted, and Profile lands on Pubky Choice still listing B — Pubky Ring alongside Bitkit not in Capabilities
  • 8. Pubky Ring: delete the last pubky → foreground Bitkit → the "Profile Disconnected" toast shows, nav to Pubky Choice, which offers only "Create profile with Bitkit" — Pubky Ring alongside Bitkit not in Capabilities
  • 9. Pubky Ring: delete the picked pubky while Bitkit is on Create Profile, Pay Contacts or Contacts → foreground Bitkit → the "Profile Disconnected" toast shows and Bitkit resets to Pubky Choice without the deleted pubky — Pubky Ring alongside Bitkit not in Capabilities
  • 10. Pubky Ring: delete the picked pubky while Bitkit is on Settings → foreground Bitkit → the toast shows and Bitkit stays on Settings — Pubky Ring alongside Bitkit not in Capabilities
  • 11. Pubky Ring with 9 pubkys → Bitkit Pubky Choice → the list scrolls under the pinned nav bar and the last pubky can be picked; with 1–2 pubkys the cards still sit right under the title — Pubky Ring alongside Bitkit not in Capabilities
  • 12. Pick a Ring pubky → network off → Create Profile → Save → nothing is published until the profile lookup succeeds — Pubky Ring alongside Bitkit and a network cut not in Capabilities
  • 13. Bitkit: create a Bitkit-owned profile → Pubky Ring Home → the pubky appears as a dashed card with the "Bitkit pubky" pill and a "Use in Pubky Ring" button — Pubky Ring alongside Bitkit not in Capabilities
  • 14. Bitkit with a profile created before this PR → update to this build and launch → Pubky Ring shows the Bitkit pubky as a dashed card — Pubky Ring alongside Bitkit not in Capabilities
  • 15. Scanner → paste a pubkyauth://direct_signup link → Authorize → Pubky Ring shows the Bitkit pubky as a dashed card without relaunching Bitkit — Pubky Ring alongside Bitkit not in Capabilities
  • 16. Pubky Ring: adopt the Bitkit pubky → detail → the pill, Share and Delete are shown, Backup is hidden, and renaming persists — Pubky Ring alongside Bitkit not in Capabilities
  • 17. Pubky Ring: authorize a pubkyauth:// request with the adopted Bitkit pubky → the grant succeeds, so the foreign key is read just in time — Pubky Ring alongside Bitkit not in Capabilities
  • 18. Pubky Ring: delete the adopted Bitkit pubky → the confirmation says it remains in Bitkit, the card returns to the dashed offer, and Bitkit keeps its identity and shared record — Pubky Ring alongside Bitkit not in Capabilities
  • 19. Bitkit: Profile → Edit → Delete Profile → foreground Pubky Ring → a toast shows, the adopted card disappears, and the Ring-owned pubky is untouched — Pubky Ring alongside Bitkit not in Capabilities
  • 20. Pubky Ring: Settings → wipe → Ring's own shared records are removed while Bitkit's record and keychain items survive and Bitkit keeps working — Pubky Ring alongside Bitkit not in Capabilities
  • 21. regression: Pubky Ring: deep-link authorize and encrypted backup export for an owned pubky → both still work after the relay removal — Pubky Ring alongside Bitkit not in Capabilities
  • 22. No Pubky Ring pubkys → Pubky Choice → Create profile with Bitkit → the profile publishes, then Pay Contacts, then Profile — no journey yet
  • 23. regression: Bitkit: scanner → paste a pubkyauth:// request → the approval sheet opens and completes, and Bitkit no longer registers the pubkyauth scheme — no journey yet

Automated Checks

  • added SharedPubkyKeychainTests.swift — a shared record's secret must be 64 lowercase hex characters that derive the requested pubky
  • added PubkyChoiceViewTests.swift — Pubky Choice picks its description, and shows the Create card only, when Pubky Ring lists no pubkys
  • updated PubkyProfileManagerTests.swift — the signing key comes from Bitkit's own secret or, just in time, the picked pubky's; a picked pubky counts as a stored identity and has no backup snapshot; legacy relay backups restore without an identity; restore tests inject the shared-record cleanup, and unit tests use their own unit-tests.pubky.shared.v1 keychain service, so they never touch Pubky Ring's records
  • updated ShopPaymentRequestTests.swift — the signup scanner test clears any picked pubky, so it doesn't depend on simulator state
  • removed PubkyProfileManagerTests.swift — relay auth URL, callback, nonce and completion cases, and the external-session backup cases, because the relay flow and that session kind no longer exist
  • removed PaykitSdkClientConfigTests.swift — relay auth activation and cleanup cases, because that flow no longer exists
  • removed PubkyAuthURLSchemeTests.swift — the Pubky Ring outbound scheme query, because Bitkit no longer opens Pubky Ring
  • ran swiftformat --lint on the changed files — no formatting changes

@jvsena42 jvsena42 mentioned this pull request Sep 23, 2026
33 tasks
# Conflicts:
#	Bitkit/ViewModels/AppViewModel.swift
@Jasonvdb
Jasonvdb marked this pull request as ready for review September 23, 2026 19:14
@greptile-apps

greptile-apps Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 3/5

This PR is not yet safe to merge because recoverable profile-lookup and backup-decoding failures can overwrite existing profile metadata or erase current Pubky credentials.

Findings

  1. P1 Lookup Failure Overwrites Profile ▶
  2. P1 Invalid Backup Erases Credentials ▶

Summary

This PR replaces Pubky Ring’s relay/deep-link authentication with shared-keychain identity adoption and just-in-time signing. It also publishes Bitkit-owned Pubky keys into the shared group, detects deleted adopted identities, adjusts backup compatibility, and updates the profile-choice UI.

  • Adds a shared keychain protocol for listing, validating, publishing, and removing Pubky identities.
  • Allows selecting a Ring-owned identity without persisting its secret in Bitkit.
  • Removes the former callback-based Ring authentication flow.
  • Adds source-loss cleanup and changes Pubky session backup behavior.
  • Two restore/profile-lookup paths can currently cause existing identity data or credentials to be overwritten or cleared after recoverable input failures.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Open Pubky Choice] --> B[List Ring records from shared keychain]
    B -->|Select Ring pubky| C[Read and validate secret just in time]
    C --> D[Sign in or sign up]
    D --> E[Store adopted pubky reference]
    E --> F[Load or create profile]
    B -->|No Ring identities| G[Create wallet-derived Bitkit identity]
    G --> H[Publish Bitkit-owned record to shared keychain]
    I[App initialization or foreground] --> J{Adopted Ring record still exists?}
    J -->|Yes or unknown| K[Keep adopted identity]
    J -->|Definitely missing| L[Clear session and identity state]
    M[Restore backup] --> N{Pubky session kind}
    N -->|localSeed| O[Derive key and sign in]
    N -->|nil or unknown| P[Delete current Pubky credentials]
Loading

Reviews (1) · Last reviewed commit: "Merge remote-tracking branch 'origin/mas..."

Comment thread Bitkit/Views/Profile/CreateProfileView.swift Outdated
Comment thread Bitkit/Models/BackupPayloads.swift

@ovi-reviewer ovi-reviewer 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.

Advice: ✅ Approve


Review: diff 24 files.
Counterpart synonymdev/bitkit-android#1329: equivalent.

Findings:
2 inline (non-blocking)

Audit:
Audited - no findings.

Coverage:
QA: journeys and manual tests await all reviewers to approve, author can run it now via comment: @ovi-reviewer test


Reviewed by gpt-6-sol-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest · audit (author or owner)

Comment thread Bitkit/AppScene.swift Outdated
Comment thread Bitkit/Views/Profile/PubkyChoiceView.swift
Comment thread Bitkit/Managers/PubkyProfileManager.swift
Comment thread Bitkit/Managers/PubkyProfileManager.swift Outdated
}
) async throws {
if isProfileSetupPending, let publicKey {
if let publicKey, isProfileSetupPending || AdoptedPubkyReference.current != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Identities created through a signup link take this pending-profile branch and return before publishOwnSharedRecord(). Completing the profile therefore leaves it unavailable in Ring until Bitkit initializes again. Could we publish the owned key after successful signup activation or when this branch completes?

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 337ea2f: approveSignupAuth calls publishOwnSharedRecord() once signup activation succeeds, so Ring sees the key right away, even before the profile is finished. Android needed no change because its provider serves the stored key live.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Three LOWs inline, all gated behind the Paykit opt-in, all checked by a second pass.

Checked and clean:

  • The access group is team-prefixed with no wildcard, and the record is WhenUnlockedThisDeviceOnly and non-synchronizable, so nothing reaches iCloud or a device-to-device restore. One thing worth a thought: the shared record is WhenUnlocked while the private store is AfterFirstUnlock, so an adopted identity's secret is unreadable from a locked background context — I did not trace whether any background path needs it.
  • What is written is the seed-derived pubky secret as hex, never the mnemonic, and it is the point of the feature.
  • Identity binding. loadSecret requires 64 lowercase hex that derives back to the requested pubky, and adoption re-derives the public key from the secret it actually read, so a stale or hostile record cannot supply a key for another identity.
  • Wipe. getAllKeyChainStorageKeys now filters on Bitkit's own group, so Ring's items are never enumerated or deleted, and both wipe entry points remove Bitkit's shared records first.
  • Scheme surface. The bitkit://pubky-auth callback parsing is gone with the relay flow.
  • Migration from the v2.5.0 relay flow: those users keep their session until expiry, legacy externalSession backups decode to no identity, and the same pubky can be re-adopted from an updated Ring.

Parity with synonymdev/bitkit-android#1329: android's boundary is an exported provider behind a signature permission plus a per-call UID signature check, and it copies nothing at rest; here the secret sits in the shared group by design. Both reduce to "same signing identity".

try sessionProvider.persistReceiverNoiseSecretKey(access.exportReceiverNoiseSecretKey())

guard shouldStoreLocalSecret, let localSecret = access.exportLocalSecretKey() else {
guard AdoptedPubkyReference.current == nil, let localSecret = access.exportLocalSecretKey() else {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A signup link approved while an adopted Ring reference is still set leaves the identity self-inconsistent. LOW as shipped, because it is behind the Paykit opt-in.

approveSignupAuth gates only on publicKey == nil and hasStoredIdentity(), neither of which looks at AdoptedPubkyReference. Reaching that state is possible: initializePersistedSession now feeds the adopted Ring secret into the re-sign-in, and when both importSession and signIn(R) fail the saved session is deleted and publicKey goes nil, while the reference survives — checkAdoptedSource clears it only on errSecItemNotFound, and the Ring record is still there.

A signup link then registers the wallet identity W, and this guard refuses to persist W's secret because the reference exists. The signup itself is signed with W correctly, but afterwards activeSecretKeyHex() returns R: ordinary approvals in PubkyAuthApprovalSheet display W and sign with R, the SDK is handed R as the local secret for W's session, and snapshotSessionBackupState returns nil so W is never backed up. Worse, the next launch can flip identity — if importSession fails, signIn(R) restores R and W is orphaned on the homeserver.

Fix, one line: clear AdoptedPubkyReference.current just before registerIdentity (a signup link means "use my wallet identity", and this guard then persists W), or throw already-signed-in while a reference exists so the user re-picks the Ring key on Pubky Choice.

Parity note: synonymdev/bitkit-android#1329 avoids the precondition structurally — its resolveSignedInSession never re-signs in with the Ring credential and does not delete the session on failure, so hasIdentity() stays true and a signup link throws. Its SDK provider has the same Ring fallback, so the divergence is in these two iOS behaviours, not in the fallback itself.

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 6451020: hasStoredIdentity() in PubkyProfileManager.swift now counts an AdoptedPubkyReference, because the next launch signs in with that Ring key again. After a failed restore a signup link now gets "Already signed in", the same as Android. I went with your second option: clearing the reference at signup would swap the picked Ring identity for the wallet key after one offline launch.

}

private static func clearLocalAppState() async {
SharedPubkyKeychain.removeAllOwn()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A failed forgetSessionAccess() leaves the homeserver session behind, so a deleted Ring identity comes back with no key. LOW.

The catch only logs, and clearLocalAppState never deletes .paykitSession. The realistic throw is SDK construction failing before the provider's clear callback runs. On the next launch the saved session is still there, activeSecretKeyHex() is nil now that the reference is gone, and importSession with no local secret restores R as authenticated — an identity the user was just told was disconnected, which can still do session-only writes but cannot sign. If the import fails instead, it is the "session expired" toast on every launch until sign-out.

Fix: delete .paykitSession (and .pubkySecretKey) on this path regardless of the SDK call's outcome, as restoreSessionBackupState already does.

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 ed779b1: when forgetSessionAccess() fails, clearLocalState() in PubkyProfileManager.swift now deletes .paykitSession and .pubkySecretKey through PubkySessionAccessTeardown.clear, the same as Android's clearLocalState.

/// Under test, records live under their own service so a suite never touches the records Pubky Ring reads.
static let service = Env.isUnitTest ? "unit-tests.pubky.shared.v1" : "pubky.shared.v1"
static let ringSourceApp = "app.pubkyring"
static let ownSourceApp = "to.bitkit"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: ownSourceApp has no network component while Env.keychainGroup does, so all flavors share one to.bitkit: namespace in the shared group. The bundle id is to.bitkit in every configuration, so this needs a Debug or E2E build installed over the mainnet one rather than a co-install: that build's removeAllOwn() then deletes the mainnet record, and it publishes a regtest key that Ring shows as a Bitkit key. The mainnet record heals on its next launch. Worth noting that the prefix is a cross-app contract with Ring and the android twin, so it cannot be suffixed unilaterally.

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 7208b47: ownSourceApp in SharedPubkyKeychain.swift now follows the network with the Android app IDs: to.bitkit on mainnet, to.bitkit.tnet on testnet and signet, to.bitkit.dev on regtest. Pubky Ring already lists all three, so it needs no change, and mainnet keeps to.bitkit.

@Jasonvdb

Copy link
Copy Markdown
Contributor Author

On the WhenUnlocked point from the review: nothing reads a picked pubky's key while the device is locked. Bitkit declares no background modes, and the notification and widget extensions aren't in the pubky.shared group. A failed read is also safe: restore keeps the saved session, and checkAdoptedSource only clears on errSecItemNotFound. I kept WhenUnlockedThisDeviceOnly to match the records Pubky Ring writes, and added a Reviewer Note.

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Delta since b9a8d8045 (64510209, ed779b11, 7208b470, 55abbe3a): no findings. All three are addressed.

  • Signup while a Ring key is adopted. hasStoredIdentity() now counts an adopted reference as an identity, so the signup link is refused as already signed in and the user re-picks the key on Pubky Choice. That closes the path where W was registered while activeSecretKeyHex() still returned R.
  • Source loss. The catch now clears the session access keychain entries directly when forgetSessionAccess() throws, so the deleted Ring identity is not resurrected on the next launch.
  • Shared record name. ownSourceApp is per network and matches android's applicationIds, so a test build installed over the mainnet one no longer removes or shadows its record.
  • The new activeSecretKeyHex() != nil guard gives an unreadable key its own toast instead of "no identity", matching synonymdev/bitkit-android#1329.

@ovitrif ovitrif added this to the 2.6.0 milestone Sep 24, 2026
@jvsena42

Copy link
Copy Markdown
Member

@Jasonvdb conflict

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.

4 participants