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]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.7 committed 2026-05-21 22:31:21 -04:00
1 parent 9bf99f1abb
commit 0870ccfa08
1 file changed
+391
+391
View File
@@ -0,0 +1,391 @@
# 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:
```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.