Auth & Signers
Any time Amethyst produces a signed Nostr event, it goes through a NostrSigner. There are three kinds; all three implement the same abstract contract so feature code doesn't care which one the user has configured.
When to Use This Skill
- Adding a new flow that publishes an event (follow, post, react, zap, profile edit).
- Reviewing whether a feature works when the user has a remote bunker signer or an external Android signer.
- Debugging "Sign request approved but nothing happens" / timeouts on sign operations.
- Onboarding a new signer kind (hardware signer, browser extension, etc.).
- Understanding the NIP-46 bunker request/response taxonomy.
The Abstract Contract
quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt:
abstract class NostrSigner(val pubKey: HexKey) {
abstract fun <T : Event> sign(
template: EventTemplate<T>,
onReady: (T) -> Unit,
)
abstract fun nip04Encrypt(plaintext: String, toPubKey: HexKey, onReady: (String) -> Unit)
abstract fun nip04Decrypt(ciphertext: String, fromPubKey: HexKey, onReady: (String) -> Unit)
abstract fun nip44Encrypt(...)
abstract fun nip44Decrypt(...)
abstract fun decryptZapEvent(event: LnZapRequestEvent, onReady: (LnZapRequestEvent) -> Unit)
}
Sibling files in the same folder:
NostrSignerInternal.kt — in-process signer with the user's seckey in memory. Fastest; used for locally-stored accounts.
NostrSignerSync.kt — blocking wrapper for scripts / migrations / tests where callbacks are inconvenient.
EventTemplate.kt — the unsigned holder passed to sign().
SignerExceptions.kt — the error taxonomy (user denied, timeout, unsupported method, etc.).
caches/ — request cache so duplicate sign/encrypt requests coalesce.
Concrete implementations
- Local (in-process):
NostrSignerInternal — direct Secp256k1Instance.signSchnorr + NIP-44 inline. Used by accounts created/imported into Amethyst.
- Remote (NIP-46 bunker):
quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt. Talks to a bunker service over Nostr DMs using the BunkerRequest* / BunkerResponse* event taxonomy (BunkerRequestConnect, BunkerRequestSign, BunkerRequestNip44Encrypt, …).
- Android external (NIP-55):
quartz/src/androidMain/.../nip55AndroidSigner/client/NostrSignerExternal.kt. Uses Android intents + content provider to delegate to another app on the same device. Launcher: ExternalSignerLogin.kt, IActivityLauncher.kt. Install-check: IsExternalSignerInstalled.kt.
The SignerResult Contract
Signers return via callback (and internally track via SignerResult sealed types in nip46RemoteSigner/signer/SignerResult.kt and nip55AndroidSigner/api/SignerResult.kt). Result variants cover success, user-denied, timeout, remote-disconnected, unsupported. Feature code should:
- Pass a callback that handles success.
- Trust the cache/timeout behavior — don't roll your own retry.
- Surface
SignerExceptions to the user with actionable messaging (e.g. "Bunker disconnected — reconnect?").
Typical Flow (Feature Code)
// High-level: Account methods already do this internally.
val signer: NostrSigner = account.signer // whichever kind the user configured
val template = reactionEventTemplate(noteId, authorPubKey, "+")
signer.sign(template) { signed ->
account.sendToRelays(signed) // or similar pipeline
}
Most feature code should go through Account's mutation methods (account.sendReaction, account.follow) rather than touching the signer directly — the account layer handles signing + publishing + local state update atomically. Reach for the signer directly only when Account doesn't have a helper.
Choosing a Signer at Sign-Up
Entry points:
- Existing private key (
nsec, 32-byte hex, file) → NostrSignerInternal.
- Bunker URL (
bunker://...) → NostrSignerRemote.fromBunkerUri(bunkerUri, localSigner, client) in nip46RemoteSigner/signer/NostrSignerRemote.kt parses the URI and returns a NostrSignerRemote; then call its suspend fun connect() to perform the NIP-46 handshake.
- Installed external signer app (Amber, nos2x, etc. on Android) →
ExternalSignerLogin.launch(...) opens the signer app; approval yields a NostrSignerExternal.
The UI hosts both flows via amethyst/.../ui/screen/loggedOff/login/ — look there for ExternalSignerButton.kt and the bunker-URL paste screen.
Trade-offs
| Signer |
Latency |
Offline OK? |
Security |
UX |
| Internal |
µs |
Yes |
Key in app memory |
No confirmation prompts |
| Remote (NIP-46) |
100ms–seconds |
No (needs bunker reachable) |
Key never touches Amethyst |
Occasional approval prompts |
| External (NIP-55) |
100–500ms |
Yes |
Key in separate app |
Prompt on every sign by default (configurable) |
Gotchas
- Callbacks may never fire. External signers can be dismissed without result; remote signers can time out. Use
SignerExceptions / timeout handling at every call site or rely on the Account layer's wrapping.
nip04Encrypt is legacy for NIP-04 DMs. New DM code should use NIP-17 gift-wrap → nip44Encrypt path.
- Don't cache signer output beyond the
caches/ that quartz already maintains. Stale cache entries lead to duplicate publishes.
- Remote signer disconnects need explicit reconnection UX —
RemoteSignerManager exposes state; hook into it for an account-switching warning.
- External signer launch requires an Activity context — it can't happen from a background service. Structure flows so signing is on the main dispatcher through an activity-scoped launcher.
NostrSignerSync is rare. If you reach for it, you're probably in a test or migration — production code uses the async API.
References
references/nip46-remote-signer.md — the NIP-46 bunker message taxonomy and connection lifecycle.
references/nip55-android-signer.md — Android intent-based external signer flow.
- Complements:
nostr-expert/references/crypto-and-encryption.md (the crypto under all signers), account-state (which wraps signer calls), android-expert (intent launcher patterns).
1---2name: auth-signers3description: Signer abstraction patterns in Amethyst. Use when working with event signing, choosing between a local keypair (`NostrSignerInternal`), a remote NIP-46 bunker signer (`NostrSignerRemote`), or a NIP-55 Android external-app signer (`NostrSignerExternal`). Covers the abstract `NostrSigner` base class, `SignerResult` contract, how to wire a new flow that needs to sign events, and the security/UX trade-offs between signer kinds.4---5
6# Auth & Signers
7
8Any time Amethyst produces a signed Nostr event, it goes through a `NostrSigner`. There are three kinds; all three implement the same abstract contract so feature code doesn't care which one the user has configured.
9
10## When to Use This Skill
11
12- Adding a new flow that publishes an event (follow, post, react, zap, profile edit).
13- Reviewing whether a feature works when the user has a remote bunker signer or an external Android signer.
14- Debugging "Sign request approved but nothing happens" / timeouts on sign operations.
15- Onboarding a new signer kind (hardware signer, browser extension, etc.).
16- Understanding the NIP-46 bunker request/response taxonomy.
17
18## The Abstract Contract
19
20`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/signers/NostrSigner.kt`:
21
22```kotlin
23abstract class NostrSigner(val pubKey: HexKey) {
24 abstract fun <T : Event> sign(
25 template: EventTemplate<T>,
26 onReady: (T) -> Unit,
27 )
28 abstract fun nip04Encrypt(plaintext: String, toPubKey: HexKey, onReady: (String) -> Unit)
29 abstract fun nip04Decrypt(ciphertext: String, fromPubKey: HexKey, onReady: (String) -> Unit)
30 abstract fun nip44Encrypt(...)
31 abstract fun nip44Decrypt(...)
32 abstract fun decryptZapEvent(event: LnZapRequestEvent, onReady: (LnZapRequestEvent) -> Unit)
33}
34```
35
36Sibling files in the same folder:
37
38- **`NostrSignerInternal.kt`** — in-process signer with the user's seckey in memory. Fastest; used for locally-stored accounts.
39- **`NostrSignerSync.kt`** — blocking wrapper for scripts / migrations / tests where callbacks are inconvenient.
40- **`EventTemplate.kt`** — the unsigned holder passed to `sign()`.
41- **`SignerExceptions.kt`** — the error taxonomy (user denied, timeout, unsupported method, etc.).
42- **`caches/`** — request cache so duplicate sign/encrypt requests coalesce.
43
44### Concrete implementations
45
46- **Local (in-process)**: `NostrSignerInternal` — direct `Secp256k1Instance.signSchnorr` + NIP-44 inline. Used by accounts created/imported into Amethyst.
47- **Remote (NIP-46 bunker)**: `quartz/.../nip46RemoteSigner/signer/NostrSignerRemote.kt`. Talks to a bunker service over Nostr DMs using the `BunkerRequest*` / `BunkerResponse*` event taxonomy (`BunkerRequestConnect`, `BunkerRequestSign`, `BunkerRequestNip44Encrypt`, …).
48- **Android external (NIP-55)**: `quartz/src/androidMain/.../nip55AndroidSigner/client/NostrSignerExternal.kt`. Uses Android intents + content provider to delegate to another app on the same device. Launcher: `ExternalSignerLogin.kt`, `IActivityLauncher.kt`. Install-check: `IsExternalSignerInstalled.kt`.
49
50## The `SignerResult` Contract
51
52Signers return via callback (and internally track via `SignerResult` sealed types in `nip46RemoteSigner/signer/SignerResult.kt` and `nip55AndroidSigner/api/SignerResult.kt`). Result variants cover success, user-denied, timeout, remote-disconnected, unsupported. Feature code should:
53
541. Pass a callback that handles success.
552. Trust the cache/timeout behavior — don't roll your own retry.
563. Surface `SignerExceptions` to the user with actionable messaging (e.g. "Bunker disconnected — reconnect?").
57
58## Typical Flow (Feature Code)
59
60```kotlin
61// High-level: Account methods already do this internally.
62val signer: NostrSigner = account.signer // whichever kind the user configured
63
64val template = reactionEventTemplate(noteId, authorPubKey, "+")
65
66signer.sign(template) { signed ->
67 account.sendToRelays(signed) // or similar pipeline
68}
69```
70
71Most feature code should go through `Account`'s mutation methods (`account.sendReaction`, `account.follow`) rather than touching the signer directly — the account layer handles signing + publishing + local state update atomically. Reach for the signer directly only when `Account` doesn't have a helper.
72
73## Choosing a Signer at Sign-Up
74
75Entry points:
76
77- **Existing private key** (`nsec`, 32-byte hex, file) → `NostrSignerInternal`.
78- **Bunker URL** (`bunker://...`) → `NostrSignerRemote.fromBunkerUri(bunkerUri, localSigner, client)` in `nip46RemoteSigner/signer/NostrSignerRemote.kt` parses the URI and returns a `NostrSignerRemote`; then call its `suspend fun connect()` to perform the NIP-46 handshake.
79- **Installed external signer app** (Amber, nos2x, etc. on Android) → `ExternalSignerLogin.launch(...)` opens the signer app; approval yields a `NostrSignerExternal`.
80
81The UI hosts both flows via `amethyst/.../ui/screen/loggedOff/login/` — look there for `ExternalSignerButton.kt` and the bunker-URL paste screen.
82
83## Trade-offs
84
85| Signer | Latency | Offline OK? | Security | UX |
86|--------|---------|-------------|----------|-----|
87| Internal | µs | Yes | Key in app memory | No confirmation prompts |
88| Remote (NIP-46) | 100ms–seconds | No (needs bunker reachable) | Key never touches Amethyst | Occasional approval prompts |
89| External (NIP-55) | 100–500ms | Yes | Key in separate app | Prompt on every sign by default (configurable) |
90
91## Gotchas
92
93- **Callbacks may never fire.** External signers can be dismissed without result; remote signers can time out. Use `SignerExceptions` / timeout handling at every call site or rely on the `Account` layer's wrapping.
94- **`nip04Encrypt` is legacy** for NIP-04 DMs. New DM code should use NIP-17 gift-wrap → `nip44Encrypt` path.
95- **Don't cache signer output** beyond the `caches/` that quartz already maintains. Stale cache entries lead to duplicate publishes.
96- **Remote signer disconnects** need explicit reconnection UX — `RemoteSignerManager` exposes state; hook into it for an account-switching warning.
97- **External signer launch requires an Activity context** — it can't happen from a background service. Structure flows so signing is on the main dispatcher through an activity-scoped launcher.
98- **`NostrSignerSync`** is rare. If you reach for it, you're probably in a test or migration — production code uses the async API.
99
100## References
101
102- `references/nip46-remote-signer.md` — the NIP-46 bunker message taxonomy and connection lifecycle.
103- `references/nip55-android-signer.md` — Android intent-based external signer flow.
104- Complements: `nostr-expert/references/crypto-and-encryption.md` (the crypto under all signers), `account-state` (which wraps signer calls), `android-expert` (intent launcher patterns).