Files
amethyst/.claude/skills/auth-signers/references/nip55-android-signer.md
T
Claude 60edd473c7 Refreshes .claude/ skill library: fixes stale refs, adds 4 new skills
- Updates CLAUDE.md tech stack to current versions (Compose 1.10.3, Kotlin 2.3.20).
- Reframes kotlin-multiplatform iOS as mature; adds secp256k1-kmp 0.23.0 references.
- Updates desktop-expert Main.kt references (code grew from ~270 to 1341 lines and
  NavigationRail moved to ui/deck/SinglePaneLayout.kt); replaces obsolete
  "hardcoded ctrl = true" anti-pattern note with accurate isMacOS branching.
- Removes compose-desktop.md (superseded by desktop-expert/).
- Adds nostr-expert references: nip19-bech32, event-factory, crypto-and-encryption,
  large-cache. Adds kotlin-expert/common-utilities, compose-expert/rich-text-parsing,
  android-expert/image-loading.
- New skills: account-state (Account + LocalCache), relay-client (subscriptions,
  filter assemblers, preloaders), feed-patterns (FeedFilter + FeedViewModel family),
  auth-signers (NostrSigner across internal / NIP-46 / NIP-55).
2026-04-21 21:00:45 +00:00

5.0 KiB

NIP-55 Android External Signer

NIP-55 lets the user delegate signing to another Android app (Amber, nos2x-fox, etc.) that holds the private key. Communication is via Intents and a ContentProvider, not Nostr itself.

Layout

Android-only, under quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/:

nip55AndroidSigner/
├── JsonMapperNip55.kt     ── JSON layer for intent extras
├── SignString.kt           ── canonical string to sign for login challenges
├── api/
│   ├── CommandType.kt      ── sign_event / nip04_encrypt / nip44_encrypt / …
│   ├── SignerResult.kt     ── sealed result sent back by launcher callback
│   ├── background/         ── "background" signer path via ContentProvider (no UI)
│   ├── foreground/         ── "foreground" signer path via Activity + Intent
│   └── permission/         ── permission grant / revoke helpers
└── client/
    ├── ExternalSignerLogin.kt       ── one-shot login / bootstrap intent
    ├── IActivityLauncher.kt         ── abstraction over Activity + ActivityResultLauncher
    ├── IsExternalSignerInstalled.kt ── query PM for compatible signers
    ├── NostrSignerExternal.kt       ── the NostrSigner impl
    └── handlers/                    ── per-command result handlers

Amethyst's Android app uses ExternalSignerButton (amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/ExternalSignerButton.kt) as the sign-up entry point.

Two Transport Modes

Foreground (Activity + Intent)

  • Launches an Intent with ACTION_VIEW and data URI nostrsigner:<payload>.
  • The signer app opens, shows a UI prompt, returns via onActivityResult.
  • Always works, but requires user interaction each call unless the user has "always allow" granted.
  • Lives in api/foreground/ and client/handlers/.

Background (ContentProvider)

  • Queries the signer's ContentProvider with content://<signer-auth>/sign_event?....
  • No UI interaction — signer either silently approves (if pre-authorized) or denies.
  • Requires the user to have granted "always allow" permission beforehand via the foreground flow.
  • Lives in api/background/ and api/permission/.
  • Falls back to foreground when background denies.

NostrSignerExternal picks the path automatically: try background if pre-authorized, else foreground. See client/handlers/ for the per-command dispatch.

Command Types

api/CommandType.kt enumerates what the external signer supports:

  • sign_event — sign a Nostr event.
  • nip04_encrypt / nip04_decrypt — legacy DMs.
  • nip44_encrypt / nip44_decrypt — NIP-44 payloads (gift-wrap).
  • get_public_key — identity check.
  • decrypt_zap_event — LN zap request decoding.
  • connect — bootstrap / permissions.

Commands map 1:1 to NostrSigner abstract methods.

Installation Check

Before showing the "Use external signer" button, IsExternalSignerInstalled.kt queries the Android PackageManager for intent filters matching nostrsigner: URIs. If no compatible app is installed, hide the button (the UI already does this).

Permission Flow

  1. User taps "Use external signer" → ExternalSignerLogin.launch(activityLauncher).
  2. Amethyst fires an intent asking the signer for the user's pubkey.
  3. Signer app opens, user approves, returns via onActivityResult.
  4. NostrSignerExternal is created with that pubkey and the package name of the approved signer.
  5. On subsequent sign requests, Amethyst tries background (via ContentProvider); if not granted, falls back to foreground intent.

api/permission/ has helpers to pre-grant / revoke permissions through the signer's dedicated permission URI.

Gotchas

  • Activity context required. All launch paths need an Activity, not just a Context. Design flows so the launcher is available when sign is called — if a background service needs to sign, it must defer until the app is foregrounded, or show a notification.
  • Foreground loop. Without "always allow", every single event sign = one Activity round-trip. That's bad UX for reactions / zaps. Push users toward granting always-allow.
  • Multiple signer apps installed. IActivityLauncher honors the one the user first approved, persisted in AccountSyncedSettings. Changing signers requires explicit re-login.
  • KMP boundary. NostrSignerExternal is strictly Android; Desktop uses NostrSignerInternal or NostrSignerRemote (NIP-46 bunker). Don't pretend there's a portable external-signer layer.
  • Test coverage. Signer Android flows have instrumented tests in quartz/src/androidDeviceTest/kotlin/.../nip55AndroidSigner/ — device or emulator only.
  • nip46-remote-signer.md — the other delegated-signing path (works on all platforms).
  • android-expert/references/android-permissions.md — Android permission mechanics.
  • android-expert/SKILL.md — intent / activity-result launcher patterns.