Add NIP-44 v3 encryption/decryption support#448
Merged
Conversation
Implements the draft NIP-44 v3 cipher (nostr-land/nip44v3) alongside
the existing v2 path, with kind+scope context binding for cross-context
replay protection. The cipher is a self-contained class that reuses
Quartz's existing primitives (secp256k1 ECDH, HKDF-SHA256, ChaCha20,
HMAC-SHA256), so no new runtime dependency is needed.
Wires v3 through all three ingestion paths (NIP-46 bunker via
`nip44v3_encrypt` / `nip44v3_decrypt` methods, `nostrsigner://` intents,
and the ContentProvider IPC at `content://*.NIP44_V3_{EN,DE}CRYPT`),
threading kind and scope from the request through the approval UI to
the cipher. Permissions for v3 are stored per (app, type, kind) so
users can grant a peer access to a single event kind rather than the
whole NIP. V3 grants are kept distinct from v2 grants.
Validated against the official spec test vectors (encrypt/decrypt
round-trips, key derivation, long messages, padding boundaries,
context-rebinding rejection, MAC tamper detection, invalid-version
handling). The JVM secp256k1 native library is added as a test-only
dependency so the vectors run in `./gradlew test`.
A v3 request that can never succeed — missing/invalid kind, a decrypt whose context (kind/scope) does not match the ciphertext, a bad MAC, corrupt padding, or a non-base64 encrypt payload — is now rejected outright instead of being surfaced to the user on the approval screen. Covers all three ingestion paths: the ContentProvider/bunker path returns a rejected cursor (so the bunker replies without notifying), and the intent path drops the request via the invalid-intent channel.
The SignerProvider's authorities are enumerated explicitly in the manifest, and the new NIP44_V3_ENCRYPT / NIP44_V3_DECRYPT authorities were missing. As a result contentResolver.query() for a v3 URI resolved to no provider and returned null, which the bunker path treats as "needs approval" — so invalid v3 requests (e.g. a decrypt whose kind does not match the ciphertext) were surfaced to the user instead of being rejected, and valid v3 requests could never auto-accept from a remembered permission. Also validate v3 bunker requests at the relay entry point and reject malformed ones (missing kind, context mismatch, bad MAC/padding, non-base64 payload) with a generic error response before any approval screen is shown.
The encrypt/decrypt approval screen reached via a nostrsigner:// intent looked identical for v3 and v2 — it never surfaced the v3 context. It now labels the algorithm "NIP-44 v3", shows the requested event kind and scope, and relabels the scope toggle as "this kind only" / "all kinds" to match v3's per-kind permission model.
fcdb187 to
83cd582
Compare
Instead of overloading the v2/v4 EncryptDecryptData screen with v3 conditionals, v3 encrypt/decrypt requests now render their own Nip44v3ApprovalData screen (used by both the intent and bunker paths). It surfaces the authenticated context (event kind + scope), decodes the Base64 wire payload for display (with a binary-data fallback), and its scope toggle grants a single kind or all kinds. The shared screen is reverted to its v2/v4-only form.
Drop the horizontal padding so the box aligns with the rest of the screen, and show the event kind's human-readable name alongside the number when a translation exists (e.g. "4 (Encrypted direct messages)").
The dedicated v3 approval screen is already used for NIP-46 bunker requests, but its content area was empty: getEncryptedDataKind only handled the typed v2/v4 BunkerRequest subclasses and returned null for v3 (which arrives as a generic BunkerRequest). Build the preview for v3 too — decrypting on decrypt, passing through the Base64 plaintext on encrypt — so the screen shows what is being encrypted/decrypted.
V3 wire payloads are Base64-encoded bytes, so history entries recorded the opaque Base64 string instead of the readable content. Decode it when storing history across all three paths (ContentProvider, bunker approval, intent), falling back to the original string when the bytes aren't valid UTF-8 (e.g. binary data).
Following the v2 pattern: encrypt/decrypt operate on plaintext strings end to end, so the decrypted content flows into history naturally. This removes the Base64 wrapping introduced earlier along with the per-type decode special-casing in the history-saving switches, the encrypt payload base64 validation, and the screen's base64 decode. The bunker approval now reuses the precomputed encryptedData.result (as v2 does) instead of recomputing on accept.
Per the nip44v3 NIP-46 draft, v3 plaintext travels Base64-encoded on the wire: AmberUtils now base64-decodes on encrypt and encodes on decrypt. The EncryptedDataKind stores the readable plaintext in `text` and the Base64 wire value in `result`, so history/display read the decoded plaintext (via nip44v3Plaintext) while responses keep the wire value. The ContentProvider auto-accept path has no EncryptedDataKind, so it decodes the wire value once for its history entry.
83cd582 to
96c7868
Compare
The ContentProvider auto-handle path looked the v3 permission up by (key, type, kind) and, on miss, fell back to (key, type) — whose SQL matches any kind. A v3 reject saved with kind=A therefore leaked to a request with kind=B, auto-rejecting it. Add a dedicated DAO query for the explicit "all kinds" (kind IS NULL) grant and use that as the fallback instead, mirroring how the manual bunker and intent screens already resolve v3 permissions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements NIP-44 v3 asymmetric encryption as specified in the
nostr-land/nip44v3draft. This adds a new encryption scheme alongside the existing NIP-44 v2/v4 support, with authenticated context (eventkind+scope) to prevent cross-context replay attacks.Key Changes
New
Nip44v3cipher implementation (service/nip44v3/Nip44v3.kt):kind+scopeparametersNew signer types (
SignerType.kt):NIP44_V3_ENCRYPTandNIP44_V3_DECRYPTfor v3-specific requestsRequest handling:
IntentUtilsto parse v3kindandscopeparameters from intent URIsBunkerRequestUtilsto extract v3 context from NIP-46 relay eventsEventNotificationConsumerto reject malformed v3 requests before approval UIApproval UI (
Nip44v3ApprovalData.kt):Integration:
SignerProviderto route v3 requests through the new approval flowIntentSingleEventHomeScreenandBunkerSingleEventHomeScreento handle v3 approvalsAccountmodelAmberUtilsto handle Base64-encoded plaintext wire format per NIP-46 v3 specTesting:
Nip44v3Test.kt) validating all 461 official test vectorsImplementation Details
kind+scope) authenticated alongside the ciphertext, preventing cross-context replay(app, type, kind)with fallback to kind-null "all kinds" grantshttps://claude.ai/code/session_019eHkJwms5RWyyJVDAXiNuc