Spec for publishing any design to Nostr encrypted, readable only by the author (Phase 1 "private to me"). Covers the AES-256-GCM + NIP-44/NIP-04 key-wrap envelope, leak-free tagging (no casewrap feed tag, random d-tag), progressive decryption in My Designs, an explicit threat model, and a Phase 0 validation spike. Phases 2-4 (public/private toggle, recipients, NIP-59/Blossom) sketched as future work. Co-Authored-By: Claude Opus 4.7 <[email protected]>
16 KiB
Private (Encrypted) Publishing
Goals
Let any Artstr design — cover, disc, jewel, custom art, slide, deck — be
published to Nostr encrypted, so only the author can read it. An
encrypted design is a normal kind:30078 addressable event (still
editable in place), but its contents are an encrypted blob instead of
plaintext JSON.
Product goal (v1 = Phase 1)
"Private to me." A design published privately:
- is encrypted so only the author can decrypt it;
- never appears in the public community feed;
- shows in My Designs, where the author decrypts and opens it normally;
- stays editable in place — republishing the same
d-tag supersedes it.
Non-goals (v1)
- Sharing with other people (private-to-selected-pubkeys) — Phase 3.
- Public ⇄ private toggling of an already-published design — Phase 2.
- NIP-59 gift-wrap metadata privacy — Phase 4.
- Blossom / NIP-96 offload for oversized encrypted blobs — Phase 4.
- Hiding that an encrypted Artstr event exists — the event, the author pubkey, and the timestamp are always public. Encryption hides the design contents, nothing more.
Decisions (locked)
- Private designs are invisible in the public feed — they omit the
["t","casewrap"]discovery tag entirely, so the feed query never fetches them. No "locked card" in New/Popular/etc. - Phase 1 only for now — "private to me", no recipients.
- Key-wrap uses NIP-44 when available, NIP-04 as fallback — so the feature works with as many NIP-07 extensions as possible.
- No
SCHEMA_VERSIONbump — the decrypted payload is unchanged (still v5). The encryption envelope is versioned separately.
The honest caveat
Encryption protects the design contents. It does not hide that an encrypted event exists, who authored it, or when. And replacement is not deletion: if a design was ever public, old public copies may persist on relays regardless of what is published later. The publish UI must say so.
Threat model
Protected: design contents — layers, artwork URLs, text, title, category and identifiers — from relays, feed scrapers, and anyone browsing Artstr or any other Nostr client.
Not protected — and the UI must not imply otherwise:
- The existence of the event, the author pubkey, the
created_attimestamp, and the event size — all public on every relay it touches. - Anything on the author's own device — the
localStorageautosave and any "Save project (JSON)" file are plaintext. Encryption applies only to what is published to Nostr. - Against a malicious or compromised NIP-07 extension — it holds the signing key and performs the key-wrap.
- Forward secrecy is a non-goal; a fresh AES key per publish is a weak bonus, not a guarantee.
Why this fits the repo
- Designs already publish as
kind:30078addressable events with a mode-prefixedd-tag; owners already edit in place by republishing the samed-tag (publishCurrentTemplateToNostr,isEditing()). - The project JSON is already the portable, publishable artifact — we encrypt that, unchanged.
- The community feed already filters by
#t:['casewrap']; omitting that one tag is all it takes to keep a design out of the feed. - My Designs already queries by author pubkey, so the author's own encrypted events are fetched there without any query change.
- Image references are remote URLs, never embedded binary — so the plaintext to encrypt is bounded to layer JSON.
Current-state facts the plan is built on
- Publish path:
publishCurrentTemplateToNostr()buildspayloadper mode, atypeTag, adTag(${mode}:${title|imdb|dims}), tags viagetTemplateTags(), then{ kind:30078, tags, content: JSON.stringify(payload) }, signed/sent bypublishCommunityEvent(). - Feed:
queryRelays(relays, { kinds:[30078], '#t':['casewrap'] }, …), thenbuildRowsFromEvents()doesJSON.parse(e.content)andnormalizeTemplateMode(payload)on every event. - My Designs (
loadMyDesigns) fetches events authored bystate.community.myPubkey. - NIP-07 surface used today:
window.nostr.getPublicKey,signEvent. NIP-44 / NIP-04 are not used yet.
Data model
Public design — UNCHANGED
Public events stay exactly as today: content is the raw project JSON
({ version:5, templateMode, meta, … }). Old clients keep working. A
flat, additive "visibility":"public" field may be added to the
payload, but no wrapper — nothing that moves templateMode.
Encrypted design — new envelope
The event content is the encryption envelope, not a design payload:
{
"v": 1,
"visibility": "encrypted",
"enc": {
"alg": "aes-256-gcm",
"keywrap": "nip44", // or "nip04"
"compression": "gzip" // or "none"
},
"iv": "<base64>",
"ciphertext": "<base64>",
"recipients": [
{ "pubkey": "<author-pubkey>", "wrappedKey": "<wrapped>" }
]
}
ciphertext= AES-256-GCM ofgzip(JSON.stringify(projectPayload)).projectPayloadis the ordinary, current design JSON — the exact thingcustomArtPayload()/deckPayload()/coverTemplatePayload()/ etc. already produce. Decryption yields a payloadloadProjectFromTextalready understands.recipients[]carries the AES key wrapped per recipient. In Phase 1 there is exactly one entry: the author (encrypt-to-self).wrappedKeyisnostr.nip44.encrypt(authorPubkey, base64(rawAesKey)), or the NIP-04 equivalent whenkeywrap === "nip04".
Event tags — encrypted designs
Minimal, leak-free:
["d", "artstr-private-<random-id>"] // random, NOT title/imdb derived
["alt", "Encrypted Artstr design"] // NIP-31 generic
["encrypted", "nip44"] // or "nip04"
Deliberately absent: ["t","casewrap"], title, i (imdb/upc/…),
category, language, template_type, and the ["t","casewrap-*"]
tags. getTemplateTags() is not called for a private design.
The d-tag is random but persistent for that design — minted once on
first private publish, reused on every edit so replacement works.
Crypto
All in-browser, no new vendored libraries:
- AES-256-GCM via WebCrypto (
crypto.subtle). - gzip via
CompressionStream('gzip')(fallback:compression:"none"when unavailable). - Key-wrap via the NIP-07 extension:
window.nostr.nip44.encrypt, falling back towindow.nostr.nip04.encrypt. Encrypt-to-self is valid — ECDH(my privkey, my pubkey) is a well-defined shared secret.
Encrypt (publish)
rawKey = crypto.getRandomValues(32 bytes) // fresh per publish
iv = crypto.getRandomValues(12 bytes)
plain = gzip(utf8(JSON.stringify(projectPayload)))
cipher = AES-GCM.encrypt(rawKey, iv, plain)
wrapped = nostr.nip44.encrypt(authorPubkey, base64(rawKey)) // or nip04
envelope = { v:1, visibility:"encrypted", enc:{…}, iv, ciphertext:cipher,
recipients:[{ pubkey:authorPubkey, wrappedKey:wrapped }] }
Decrypt (My Designs / open)
mine = envelope.recipients.find(r => r.pubkey === myPubkey)
rawKey = base64decode(nostr.nip44.decrypt(mine.pubkey, mine.wrappedKey))
plain = AES-GCM.decrypt(rawKey, envelope.iv, envelope.ciphertext)
payload = JSON.parse(gunzip(plain)) // ordinary design JSON
keywrap in the envelope says which of nip44/nip04 to use for the unwrap.
Capability gate
On opening the publish modal, probe window.nostr?.nip44?.encrypt || window.nostr?.nip04?.encrypt. If neither exists, the Private option
is disabled with a tooltip ("Your Nostr extension doesn't support
encryption"). In practice NIP-04 is near-universal, so this rarely bites.
The surfaces
1. Publish modal — visibility control
A radio / segmented control:
Publish visibility:
( ) Public — listed in the community feed, anyone can open it
(•) Private — encrypted; only you can open it; not in the feed
When Private is selected the confirm modal swaps its warning text:
Private designs are encrypted — relays and other people can't read the contents. The event still reveals that something was published, by your pubkey, at this time. Encryption can't undo a past public publish.
Pre-validation: a private design does not require a category (it's never categorized in a feed). A title is still kept — it rides inside the encrypted payload and labels the design in My Designs.
2. Publish path
publishCurrentTemplateToNostr() branches on visibility:
- Public — unchanged.
- Private — build
payloadas today, then: encrypt → envelope →content; tags = the minimal leak-free set above;d-tag = the design's persistent random private id (minted if absent); sign + send viapublishCommunityEvent().
Editing an already-private design re-encrypts (fresh AES key + iv) and
reuses the same random d-tag.
3. My Designs — decrypt + render
loadMyDesigns() already fetches the author's own events. Decryption is
progressive, never a blocking wall:
- Build rows immediately. An encrypted event (content parses to
visibility:"encrypted", or theencryptedtag is present) first renders a placeholder 🔒 locked card with no contents. - Decrypt each locked card asynchronously; as each resolves, swap the
placeholder for the real card built from the decrypted payload,
flagged
row.private = truewith a 🔒 badge. Preview / open / fork then work on the decrypted payload as usual. - If the NIP-07 extension is absent or locked, every decrypt fails — show one clear "Unlock your Nostr extension to view private designs" state, not N broken cards.
- A genuine decrypt failure on a single design renders a disabled "🔒 Could not decrypt" card and never blocks the rest.
4. Feed / discovery
No change. Private events lack ["t","casewrap"], so queryRelays with
#t:['casewrap'] never returns them. They are invisible everywhere except
My Designs.
5. Load / open / fork
Opening a private design from My Designs decrypts first, then calls the
existing loadProjectFromText(decryptedJSON). The loaded project keeps
visibility:"private" and its random d-tag in state; opening it for
edit sets the existing edit context (editingDTag = that random
d-tag, editingEventId, editingKind) so a re-publish replaces it in
place and stays private.
Fork is a fresh start, so a fork of a private design defaults to
Private as well (and mints a new random d-tag — a fork is a new
design, not a replacement). The user can still switch it to Public in the
publish modal.
"Save project (JSON)" writes the decrypted payload — a local file is plaintext (see Threat model). Encryption is applied only at Nostr publish time, never to the on-disk JSON.
6. Share links
A /share/<naddr> for a private design resolves to the encrypted event,
which a non-author cannot decrypt. For Phase 1 the share page shows a
"This design is private" locked state; per-recipient sharing is Phase 3.
"Copy share link" on a private design warns that only you can open it.
State
state.visibility—"public" | "private", default"public".state.privateDTag— the persistent randomd-tag for this design once it's been published privately (so edits replace correctly).- Both ride in the saved/loaded project JSON (additive fields) and in
autosave. The decrypted payload carries
visibilityso a reloaded private design stays private.
Phased delivery
Phase 0 — Validation spike (do first; ~half a day)
Three unknowns can sink the design — probe them before building Phase 1:
- NIP-44 / NIP-04 encrypt-to-self — confirm the target extensions (Alby, nos2x, …) accept encrypting to the user's own pubkey, and that the NIP-04 fallback behaves the same. Self-addressed NIP-04 is the established pattern for encrypted NIP-51 lists, so this is likely fine — but the whole feature rests on it, so verify.
CompressionStream('gzip')— confirm availability on target browsers; if absent, thecompression:"none"path must work.- Relay event-size limits — publish a deliberately large encrypted blob (a ~20-slide deck) to the default relay set and read it back. This decides whether Blossom offload (Phase 4) is needed sooner.
If the app already self-encrypts its kind:10000 mute/block list with
NIP-04, reuse that codepath rather than writing a new one.
Phase 1 — Private to me (current target)
- Encryption helpers (
encryptProjectEnvelope,decryptProjectEnvelope), AES-GCM + gzip + NIP-44/NIP-04 key-wrap, capability gate. - Publish modal visibility control + private warning copy.
- Private publish path: envelope content, minimal tags, random persistent
d-tag, no feed tag. - My Designs async decrypt pass + 🔒 badge + open/fork on decrypted JSON.
visibility/privateDTagin state, save/load, autosave.
Ship gate: publish a design privately; confirm it does not appear in the public feed; see it in My Designs with a 🔒 badge; open it — it decrypts and edits normally; republish — it replaces in place.
Phase 2 — Public ⇄ Private toggle
Re-publish an existing design with the opposite wrapper under the same address. Going public→private keeps the old public event on relays (documented, unavoidable).
Phase 3 — Private to selected pubkeys
Add recipients: wrap the AES key once per recipient into recipients[]. A
recipient with a share link decrypts their own wrappedKey. Adds a
recipient-picker UI.
Phase 4 — Hardening
- NIP-59 gift-wrap to hide who the recipients are.
- Blossom / NIP-96 offload when an encrypted blob (esp. a large deck) exceeds relay event-size limits; chunking as a fallback.
Risks & tradeoffs
| Risk | Mitigation |
|---|---|
| Replacement ≠ deletion — public→private can't erase the past | Documented in UI; Phase 1 is encrypt-first so the design is never public to begin with. |
| NIP-44 plaintext cap (64 KB) | We encrypt with AES-GCM and only key-wrap with NIP-44/04 — the wrapped key is tiny, well under the cap. |
| Relay event-size caps — a large encrypted deck may not fit one event | Compress before encrypt; warn on oversized blobs; Blossom offload deferred to Phase 4. |
Extension lacks nip44 |
NIP-04 fallback; capability gate disables Private if neither exists. |
buildRowsFromEvents is synchronous; decryption is async |
Decrypt outside the sync parser — render 🔒 placeholders, then swap in decrypted cards progressively (surface 3). |
| Encrypted event still leaks existence / pubkey / timestamp | Stated plainly in the Threat model and the publish warning; NIP-59 (Phase 4) reduces metadata leakage for shared designs. |
| Fresh AES key per republish | Intentional and harmless — only the author needs it, and it's re-wrapped each publish. |
An extension might reject nip44/nip04 encrypt-to-self |
Validated up front in Phase 0; gate the Private option on a successful capability probe, not just method presence. |
| Many private designs → slow My Designs load | Progressive decrypt: the page is usable immediately; cards fill in as they resolve. |
A relay rejects an event with no t tags / unusual shape |
kind:30078 app-data with a d tag is standard; verified against the default relay set in Phase 0. |
Acceptance summary
Phase 0
- NIP-44 and NIP-04 encrypt-to-self confirmed working on the target extensions.
CompressionStreamavailability checked;compression:"none"fallback exercised.- A large encrypted deck publishes to and reads back from the default relay set (or the size ceiling is documented).
Phase 1
- Publish modal offers Public / Private; Private is gated on nip44-or-nip04 capability.
- A privately-published design is absent from New / Popular / Zapped /
Search (no
casewraptag). - The encrypted event carries no title / category / identifier tags
and a random
d-tag. - My Designs shows the private design with a 🔒 badge; opening it decrypts and loads it into the editor.
- Editing and re-publishing a private design replaces it in place
(same
d-tag) and stays private. - Decrypted payload is identical to the pre-encryption project JSON
(round-trip clean) — no
SCHEMA_VERSIONchange.