Files
Artstr-Studio/docs/PRIVATE_PUBLISHING_FEATURE.md
T
thePR0M3TH3ANandClaude Opus 4.7 0870ccfa08 Draft private (encrypted) publishing feature plan
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]>
2026-05-21 22:31:21 -04:00

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)

  1. 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.
  2. Phase 1 only for now — "private to me", no recipients.
  3. Key-wrap uses NIP-44 when available, NIP-04 as fallback — so the feature works with as many NIP-07 extensions as possible.
  4. No SCHEMA_VERSION bump — 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_at timestamp, and the event size — all public on every relay it touches.
  • Anything on the author's own device — the localStorage autosave 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:30078 addressable events with a mode-prefixed d-tag; owners already edit in place by republishing the same d-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() builds payload per mode, a typeTag, a dTag (${mode}:${title|imdb|dims}), tags via getTemplateTags(), then { kind:30078, tags, content: JSON.stringify(payload) }, signed/sent by publishCommunityEvent().
  • Feed: queryRelays(relays, { kinds:[30078], '#t':['casewrap'] }, …), then buildRowsFromEvents() does JSON.parse(e.content) and normalizeTemplateMode(payload) on every event.
  • My Designs (loadMyDesigns) fetches events authored by state.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 of gzip(JSON.stringify(projectPayload)).
  • projectPayload is the ordinary, current design JSON — the exact thing customArtPayload() / deckPayload() / coverTemplatePayload() / etc. already produce. Decryption yields a payload loadProjectFromText already understands.
  • recipients[] carries the AES key wrapped per recipient. In Phase 1 there is exactly one entry: the author (encrypt-to-self). wrappedKey is nostr.nip44.encrypt(authorPubkey, base64(rawAesKey)), or the NIP-04 equivalent when keywrap === "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 to window.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 payload as 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 via publishCommunityEvent().

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:

  1. Build rows immediately. An encrypted event (content parses to visibility:"encrypted", or the encrypted tag is present) first renders a placeholder 🔒 locked card with no contents.
  2. Decrypt each locked card asynchronously; as each resolves, swap the placeholder for the real card built from the decrypted payload, flagged row.private = true with a 🔒 badge. Preview / open / fork then work on the decrypted payload as usual.
  3. 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.
  4. 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.

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 random d-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 visibility so 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:

  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.
  2. CompressionStream('gzip') — confirm availability on target browsers; if absent, the compression:"none" path must work.
  3. 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 / privateDTag in 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.
  • CompressionStream availability 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 casewrap tag).
  • 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_VERSION change.