Skip to main content

End-to-End Encryption

JSS pods can store end-to-end encrypted content today, with zero server-side changes. The same secp256k1 keypair that authenticates a client via did:nostr provides the ECDH primitive needed for NIP-44 (recommended) or NIP-04 (legacy) encryption. The pod stores ciphertext as ordinary RDF or blob content; JSS never sees plaintext.

The architectural shape: the server is not in the trust boundary. Encrypt before PUT, decrypt after GET. JSS's role is content-opaque storage — exactly what it does for unencrypted content.

Architecture

End-to-end encryption flow: clients derive a shared key via ECDH between their did keypairs, encrypt with NIP-44, PUT ciphertext to the pod, and GET ciphertext to decrypt locally; the pod never sees plaintext.

JSS does not implement encryption, decryption, or key management. The encryption boundary is between clients.

Protocols

ProtocolCipherAuthKey derivationWhen to use
NIP-44 (v2)ChaCha20HMAC-SHA256HKDF-SHA256 over ECDHNew applications
NIP-04AES-256-CBCNoneRaw ECDH outputBackwards compatibility with older Nostr clients

Both produce opaque bytes from the server's perspective; JSS stores them identically.

NIP-44 has length-hiding power-of-two padding and a versioned wire format — prefer it unless you need to interoperate with legacy clients.

Recipe: encrypt-then-PUT

import { nip44, getPublicKey } from 'nostr-tools'

// privKey: your secp256k1 secret (hex), peerPubKey: recipient's hex pubkey
const conversationKey = nip44.v2.utils.getConversationKey(privKey, peerPubKey)
const ciphertext = nip44.v2.encrypt('the secret message', conversationKey)

await fetch('https://alice.example.org/private/note', {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'Authorization': `Nostr ${nip98AuthHeader}` // see Authentication
},
body: ciphertext
})

Recipe: GET-then-decrypt

const res = await fetch('https://alice.example.org/private/note', {
headers: { 'Authorization': `Nostr ${nip98AuthHeader}` }
})
const ciphertext = await res.text()

const conversationKey = nip44.v2.utils.getConversationKey(myPrivKey, peerPubKey)
const plaintext = nip44.v2.decrypt(ciphertext, conversationKey)

The pod sees only ciphertext on PUT and serves only ciphertext on GET. Access control (via WAC) gates who can fetch the bytes; encryption gates who can read them.

Browser flow with a NIP-07 signer

NIP-07 signer extensions (xlogin, nos2x, Alby) expose nip44.encrypt / nip44.decrypt directly, so applications never touch the user's private key:

const ct = await window.nostr.nip44.encrypt(peerPubKey, 'the secret message')
await fetch(podUrl, { method: 'PUT', body: ct, headers: { Authorization: nostrAuth } })

const res = await fetch(podUrl, { headers: { Authorization: nostrAuth } })
const pt = await window.nostr.nip44.decrypt(peerPubKey, await res.text())

NIP-04's window.nostr.nip04.encrypt / .decrypt work the same way for legacy paths.

Threat model

  • JSS sees: ciphertext, request metadata (URL, size, timing, requesting WebID).
  • JSS does not see: plaintext, conversation keys, peer relationships beyond what the URL or ACL exposes.
  • Forward secrecy: NIP-44 derives a conversation key from a long-term ECDH; rotating peer keys is the unit of compartmentalisation.
  • Authenticity: NIP-44 includes HMAC; NIP-04 does not — a tampered NIP-04 ciphertext decrypts to garbage rather than failing cleanly. New code should use NIP-44.

Why reuse Nostr primitives instead of designing a Solid-native E2EE protocol

The Nostr E2EE stack is already deployed at scale across funded teams with cross-implementation interoperability — White Noise (NIP-44 messenger), Damus, Amethyst, Iris, Coracle, and many others. NIP-44's design has had external cryptographic review, and NIP-04 ciphertext flows daily across the public relay network at scale.

Pointing JSS pods at these primitives reuses that maturity. The bridge from did:nostr identity to NIP-44/NIP-04 already exists architecturally — only the documentation step is missing.

Tracked upstream in JSS#365 and discussed in solid/specification#788.

See also