Files
Artstr-Studio/docs/PRIVATE_PUBLISHING_FEATURE.md
T
thePR0M3TH3ANandClaude Opus 4.7 3e6e89e16f Add private (encrypted) publishing — Phases 1 & 2
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]>
2026-05-22 13:48:59 -04:00

425 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
### Implementation status
- **Phase 0 — codebase audit: done.** The app uses only `getPublicKey` /
`signEvent`; **no NIP-04/44 anywhere** — the `kind:10000` mute list is
plain `p` tags, so there is no crypto codepath to reuse. `CompressionStream`
is assumed (with a `compression:"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,
`nostrEncryptCapability` probe).
- `publishEncryptedDesign` — feed-invisible event: random persistent
`d`-tag, only `d` / `alt` / `encrypted` tags, no `casewrap` tag.
- Publish-confirm modal Public/Private control + private warning +
capability gate; private branch wired into `publishCurrentTemplate`
and `publishDiscDesign`.
- My Designs decrypts the author's own private events and shows a
🔒 Private badge; `buildRowsFromEvents` skips stray encrypted events.
- `state.visibility` / `state.privateDTag`; restored on load, cleared
on close.
- Known v1 gaps: file-save writes plaintext without a `visibility`
flag (re-pick Private on publish); a failed decrypt in My Designs is
skipped silently rather than shown as a locked card.
- **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 same
`d`-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_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:
```json
{
"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.
### 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 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.