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).
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
---
|
||||
name: auth-signers
|
||||
description: 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.
|
||||
---
|
||||
|
||||
# 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`:
|
||||
|
||||
```kotlin
|
||||
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:
|
||||
|
||||
1. Pass a callback that handles success.
|
||||
2. Trust the cache/timeout behavior — don't roll your own retry.
|
||||
3. Surface `SignerExceptions` to the user with actionable messaging (e.g. "Bunker disconnected — reconnect?").
|
||||
|
||||
## Typical Flow (Feature Code)
|
||||
|
||||
```kotlin
|
||||
// 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://...`) → `RemoteSignerManager.connect(url)` in `nip46RemoteSigner/signer/RemoteSignerManager.kt` returns a `NostrSignerRemote`.
|
||||
- **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).
|
||||
@@ -0,0 +1,109 @@
|
||||
# NIP-46 Remote Signer (Bunker)
|
||||
|
||||
Remote signers are a different Nostr client (the "bunker") that holds the private key and signs on request over Nostr DMs. Amethyst connects to a bunker via a `bunker://` URL and proxies every signing / encryption operation as a request/response over kind 24133 events.
|
||||
|
||||
## Layout
|
||||
|
||||
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/`:
|
||||
|
||||
```
|
||||
nip46RemoteSigner/
|
||||
├── BunkerMessage.kt ── common wrapper
|
||||
├── BunkerRequest.kt ── sealed base for requests
|
||||
├── BunkerRequestConnect.kt
|
||||
├── BunkerRequestGetPublicKey.kt
|
||||
├── BunkerRequestGetRelays.kt
|
||||
├── BunkerRequestNip04Decrypt.kt
|
||||
├── BunkerRequestNip04Encrypt.kt
|
||||
├── BunkerRequestNip44Decrypt.kt
|
||||
├── BunkerRequestNip44Encrypt.kt
|
||||
├── BunkerRequestPing.kt
|
||||
├── BunkerRequestSign.kt
|
||||
├── BunkerResponse.kt ── sealed base for responses
|
||||
├── BunkerResponseAck.kt
|
||||
├── BunkerResponseDecrypt.kt
|
||||
├── BunkerResponseEncrypt.kt
|
||||
├── BunkerResponseError.kt
|
||||
├── BunkerResponseEvent.kt
|
||||
├── BunkerResponseGetRelays.kt
|
||||
├── BunkerResponsePong.kt
|
||||
├── BunkerResponsePublicKey.kt
|
||||
├── NostrConnectEvent.kt ── kind 24133 payload
|
||||
├── kotlinSerialization/ ── JSON codecs for each request/response
|
||||
└── signer/
|
||||
├── NostrSignerRemote.kt ── the NostrSigner impl
|
||||
├── RemoteSignerManager.kt ── connection lifecycle
|
||||
├── ConnectResponse.kt
|
||||
├── Nip04DecryptResponse.kt
|
||||
├── Nip04EncryptResponse.kt
|
||||
├── Nip44DecryptResponse.kt
|
||||
├── Nip44EncryptResponse.kt
|
||||
├── PingResponse.kt
|
||||
├── PubKeyResponse.kt
|
||||
├── SignerResult.kt
|
||||
└── SignResponse.kt
|
||||
```
|
||||
|
||||
## Connection Flow
|
||||
|
||||
```
|
||||
User pastes bunker://<bunker-pubkey>?relay=wss://…&secret=…
|
||||
│
|
||||
▼
|
||||
RemoteSignerManager.connect(url) generates a client keypair
|
||||
│
|
||||
▼
|
||||
Publish BunkerRequestConnect (kind 24133, encrypted) to relay
|
||||
│
|
||||
▼
|
||||
Wait for BunkerResponseAck or BunkerResponseError
|
||||
│
|
||||
▼ on ack
|
||||
Return a NostrSignerRemote(clientKeys, bunkerPubKey, relays)
|
||||
```
|
||||
|
||||
The client keypair is **not** the user's Nostr identity — it's a session key used to correspond with the bunker. The bunker controls the real signing key. `RemoteSignerManager` persists session state so reconnecting skips the handshake.
|
||||
|
||||
## Request / Response Pattern
|
||||
|
||||
Every signing or encryption call is an async round-trip:
|
||||
|
||||
```
|
||||
Amethyst Bunker
|
||||
│ │
|
||||
│── BunkerRequestSign(template) ──►│
|
||||
│ │ user approves (sometimes)
|
||||
│◄── BunkerResponseEvent(signed) ──│
|
||||
│ │
|
||||
```
|
||||
|
||||
`NostrSignerRemote.sign(template, onReady)` serializes the `BunkerRequestSign`, encrypts it to the bunker's pubkey, publishes it, and waits (with a timeout) for a `BunkerResponseEvent` carrying the signed event. The response goes through a request-id correlation map so concurrent signs don't interleave.
|
||||
|
||||
## Supported Requests
|
||||
|
||||
- `BunkerRequestConnect` / `BunkerRequestPing` — lifecycle.
|
||||
- `BunkerRequestGetPublicKey` — verify what pubkey this session controls.
|
||||
- `BunkerRequestGetRelays` — discover the bunker's preferred inbox relays.
|
||||
- `BunkerRequestSign` — the main path.
|
||||
- `BunkerRequestNip04Encrypt`/`Decrypt` — legacy DMs.
|
||||
- `BunkerRequestNip44Encrypt`/`Decrypt` — NIP-44 payloads (gift-wrap, modern DMs).
|
||||
|
||||
Each has a matching response with the same correlation id.
|
||||
|
||||
## Timeouts & Disconnects
|
||||
|
||||
- **Timeout**: default in `NostrSignerRemote`. Expired requests surface as `SignerExceptions.TimedOut` (or equivalent). Treat as "maybe the user will approve later but UI has given up" — don't auto-retry.
|
||||
- **Relay disconnect**: `RemoteSignerManager` reconnects transparently when the bunker's relay reappears; in-flight requests may still time out.
|
||||
- **Bunker revoke**: if the bunker closes the session, next sign attempt returns `BunkerResponseError`; prompt the user to reconnect.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **The session key is not the user key.** Logs and UI should never surface the session pubkey as "your pubkey".
|
||||
- **Don't assume a sign is fast** — UX should show a spinner and allow cancellation for 10+ seconds.
|
||||
- **Relay hints from `BunkerRequestGetRelays`** can change the relay list mid-session; the session manager handles it, but re-reads of `relays` in feature code may be stale.
|
||||
- **NIP-46 over Nostr is the only transport here** — there's no HTTP/WS shortcut. Any feature gating on "can this sign" must check relay reachability.
|
||||
|
||||
## Related
|
||||
|
||||
- `nip55-android-signer.md` — the other remote-ish signer (but local to the device).
|
||||
- `nostr-expert/references/crypto-and-encryption.md` — NIP-44 details (used by request/response encryption).
|
||||
@@ -0,0 +1,87 @@
|
||||
# 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 `Intent`s 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.
|
||||
|
||||
## Related
|
||||
|
||||
- `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.
|
||||
Reference in New Issue
Block a user