mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-10-27 08:40:39 +00:00
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:
1 parent
9bf99f1abb
commit
0870ccfa08
1 file changed
+391
@@ -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.
|
||||
Reference in new issue
Block a user