Phase 1 — publish a design encrypted, readable only by the author: - Crypto helpers: encryptProjectEnvelope / decryptProjectEnvelope — AES-256-GCM over gzipped JSON, AES key NIP-44 key-wrapped to the author (NIP-04 fallback), via a nostrEncryptCapability() probe. All in-browser, no new dependencies. - publishEncryptedDesign: a feed-invisible kind:30078 event — random persistent d-tag, only d / alt / encrypted tags, no casewrap tag. - Publish-confirm modal gains a Public / Private control with the privacy warning and a capability gate; wired into the template and disc-design publish paths. - My Designs decrypts the author's own private events and shows a lock badge; buildRowsFromEvents skips any stray encrypted event so it can never surface in the public feed. - state.visibility / state.privateDTag — restored on load, cleared on Close project, autosaved. Phase 2 — flip a published design's visibility in place: - Owner-only Make Public / Make Private button on the preview page; republishDesignVisibility re-publishes under the same d-tag with the opposite wrapper (NIP-33 replace), behind a confirm that states the public-to-private caveat. Spec status documented in docs/PRIVATE_PUBLISHING_FEATURE.md. Co-Authored-By: Claude Opus 4.7 <[email protected]>
18 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.
Implementation status
- Phase 0 — codebase audit: done. The app uses only
getPublicKey/signEvent; no NIP-04/44 anywhere — thekind:10000mute list is plainptags, so there is no crypto codepath to reuse.CompressionStreamis assumed (with acompression:"none"fallback). Extension-runtime checks (encrypt-to-self on Alby/nos2x, relay size) are validated by the user in-browser; the build is defensive (capability probe + NIP-04 fallback) so it works regardless. - Phase 1 — built (pending in-browser testing). Done:
- Crypto helpers —
encryptProjectEnvelope/decryptProjectEnvelope(AES-256-GCM over gzip, NIP-44 key-wrap with NIP-04 fallback,nostrEncryptCapabilityprobe). publishEncryptedDesign— feed-invisible event: random persistentd-tag, onlyd/alt/encryptedtags, nocasewraptag.- Publish-confirm modal Public/Private control + private warning +
capability gate; private branch wired into
publishCurrentTemplateandpublishDiscDesign. - My Designs decrypts the author's own private events and shows a
🔒 Private badge;
buildRowsFromEventsskips stray encrypted events. state.visibility/state.privateDTag; restored on load, cleared on close.- Known v1 gaps: file-save writes plaintext without a
visibilityflag (re-pick Private on publish); a failed decrypt in My Designs is skipped silently rather than shown as a locked card.
- Crypto helpers —
- Phase 2 — built (pending in-browser testing). An owner-only
Make Public / Make Private button on a design's preview page
(
republishDesignVisibility) re-publishes the design under the samed-tag with the opposite wrapper — a NIP-33 in-place replace — behind a confirm that states the public→private caveat. The button is gated on encryption capability for the →private direction. - Phases 3–4 — not started.
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.