Skip to content

Add NIP-44 v3 encryption/decryption support#448

Merged
greenart7c3 merged 11 commits into
masterfrom
claude/implement-nip44-v3-0G3I9
Jun 1, 2026
Merged

Add NIP-44 v3 encryption/decryption support#448
greenart7c3 merged 11 commits into
masterfrom
claude/implement-nip44-v3-0G3I9

Conversation

@greenart7c3
Copy link
Copy Markdown
Owner

Implements NIP-44 v3 asymmetric encryption as specified in the nostr-land/nip44v3 draft. This adds a new encryption scheme alongside the existing NIP-44 v2/v4 support, with authenticated context (event kind + scope) to prevent cross-context replay attacks.

Key Changes

  • New Nip44v3 cipher implementation (service/nip44v3/Nip44v3.kt):

    • ECDH(secp256k1) key agreement with HKDF-SHA256 key derivation
    • ChaCha20 encryption with HMAC-SHA256 authentication
    • Canonical padding scheme with configurable chunk sizes
    • Context authentication via kind + scope parameters
    • Comprehensive test vector validation (461 test cases from official spec)
  • New signer types (SignerType.kt):

    • NIP44_V3_ENCRYPT and NIP44_V3_DECRYPT for v3-specific requests
  • Request handling:

    • Extended IntentUtils to parse v3 kind and scope parameters from intent URIs
    • Updated BunkerRequestUtils to extract v3 context from NIP-46 relay events
    • Added validation in EventNotificationConsumer to reject malformed v3 requests before approval UI
  • Approval UI (Nip44v3ApprovalData.kt):

    • Dedicated approval screen displaying authenticated context (kind + scope)
    • Scope toggle grants either single-kind or all-kinds access
    • Plaintext preview for encrypt/decrypt operations
  • Integration:

    • Updated SignerProvider to route v3 requests through the new approval flow
    • Extended IntentSingleEventHomeScreen and BunkerSingleEventHomeScreen to handle v3 approvals
    • Added v3 encryption/decryption methods to Account model
    • Updated AmberUtils to handle Base64-encoded plaintext wire format per NIP-46 v3 spec
  • Testing:

    • Comprehensive unit tests (Nip44v3Test.kt) validating all 461 official test vectors
    • Tests cover key derivation, encryption/decryption round-trips, padding, and error cases
    • Added secp256k1-jni dependency for JVM-side ECDH in tests

Implementation Details

  • V3 requests carry context (kind + scope) authenticated alongside the ciphertext, preventing cross-context replay
  • Permissions are scoped by (app, type, kind) with fallback to kind-null "all kinds" grants
  • Invalid v3 requests (bad MAC, padding, base64, or context mismatch) are rejected before approval UI
  • Plaintext travels Base64-encoded on the wire per NIP-46 v3 draft; readable plaintext is decoded for display/logs

https://claude.ai/code/session_019eHkJwms5RWyyJVDAXiNuc

claude added 4 commits June 1, 2026 12:39
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.
@greenart7c3 greenart7c3 force-pushed the claude/implement-nip44-v3-0G3I9 branch from fcdb187 to 83cd582 Compare June 1, 2026 12:46
claude added 6 commits June 1, 2026 12:48
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.
@greenart7c3 greenart7c3 force-pushed the claude/implement-nip44-v3-0G3I9 branch from 83cd582 to 96c7868 Compare June 1, 2026 12:48
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.
@greenart7c3 greenart7c3 merged commit edf6bb9 into master Jun 1, 2026
2 checks passed
@greenart7c3 greenart7c3 deleted the claude/implement-nip44-v3-0G3I9 branch June 1, 2026 13:25
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.

2 participants