From 3e6e89e16f1d0dc40c78aaa1befc03e1623bbd76 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Fri, 22 May 2026 13:48:59 -0400 Subject: [PATCH] =?UTF-8?q?Add=20private=20(encrypted)=20publishing=20?= =?UTF-8?q?=E2=80=94=20Phases=201=20&=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/PRIVATE_PUBLISHING_FEATURE.md | 33 ++++ src/index.html | 285 ++++++++++++++++++++++++++++- 2 files changed, 317 insertions(+), 1 deletion(-) diff --git a/docs/PRIVATE_PUBLISHING_FEATURE.md b/docs/PRIVATE_PUBLISHING_FEATURE.md index 38bad3a..7c50421 100644 --- a/docs/PRIVATE_PUBLISHING_FEATURE.md +++ b/docs/PRIVATE_PUBLISHING_FEATURE.md @@ -38,6 +38,39 @@ plaintext JSON. 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 diff --git a/src/index.html b/src/index.html index e063b02..49be5e1 100644 --- a/src/index.html +++ b/src/index.html @@ -1828,6 +1828,7 @@ .feedCard.feedCardBroken{opacity:.55} .feedCard.feedCardBroken .feedCardThumb{background:repeating-linear-gradient(45deg,#f1f5f9,#f1f5f9 10px,#e2e8f0 10px,#e2e8f0 20px)} .feedCardBrokenBadge{position:absolute;top:6px;right:6px;background:rgba(220,38,38,.92);color:#fff;font-size:.62rem;font-weight:700;border-radius:4px;padding:2px 6px;z-index:6;letter-spacing:.03em} + .feedCardPrivateBadge{position:absolute;top:6px;left:6px;background:rgba(15,23,42,.92);color:#fff;font-size:.62rem;font-weight:700;border-radius:4px;padding:2px 6px;z-index:6;letter-spacing:.03em} .feedCardSoftModOverlay{position:absolute;inset:0;z-index:5;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8px;background:rgba(15,23,42,.55);color:#fff;padding:12px;text-align:center;font-size:.78rem;line-height:1.3} .feedCardSoftModOverlay .softModLabel{font-weight:600;max-width:90%} .feedCardSoftModOverlay button{font-size:.74rem;padding:5px 12px;border-radius:6px;border:1px solid rgba(255,255,255,.5);background:rgba(255,255,255,.12);color:#fff;cursor:pointer} @@ -3593,6 +3594,18 @@

Double-check the details below before posting to Nostr. You can edit or delete later, but the original event will exist on relays.

+
+
Visibility
+ + + +
@@ -4001,6 +4014,11 @@ editingKind: 0, editingEventId: '', editingTitle: '', + // Private publishing: 'public' (feed-listed) or 'private' (encrypted, + // only the author can open it). privateDTag is the persistent random + // d-tag a private design keeps so edits replace it in place. + visibility: 'public', + privateDTag: '', preset: 'dvd', panelW: presets.dvd.panelW, trimH: presets.dvd.trimH, @@ -9191,6 +9209,173 @@ return signed; } + // =================================================================== + // Private publishing — encrypt a design so only the author can read + // it. AES-256-GCM over gzip(JSON), with the AES key NIP-44/NIP-04 + // key-wrapped to the author. See docs/PRIVATE_PUBLISHING_FEATURE.md. + // =================================================================== + function nostrEncryptCapability() { + const n = window.nostr; + if (n && n.nip44 && typeof n.nip44.encrypt === 'function') return 'nip44'; + if (n && n.nip04 && typeof n.nip04.encrypt === 'function') return 'nip04'; + return null; + } + + function _bytesToB64(bytes) { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s); + } + function _b64ToBytes(str) { + const s = atob(str); + const b = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i); + return b; + } + + async function _gzipBytes(bytes) { + if (typeof CompressionStream === 'undefined') return { data: bytes, compression: 'none' }; + const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip')); + return { data: new Uint8Array(await new Response(stream).arrayBuffer()), compression: 'gzip' }; + } + async function _gunzipBytes(bytes, compression) { + if (compression !== 'gzip' || typeof DecompressionStream === 'undefined') return bytes; + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } + + async function _wrapAesKey(rawKey, selfPubkey, method) { + const keyB64 = _bytesToB64(rawKey); + return method === 'nip44' + ? await window.nostr.nip44.encrypt(selfPubkey, keyB64) + : await window.nostr.nip04.encrypt(selfPubkey, keyB64); + } + async function _unwrapAesKey(wrapped, selfPubkey, method) { + const keyB64 = method === 'nip44' + ? await window.nostr.nip44.decrypt(selfPubkey, wrapped) + : await window.nostr.nip04.decrypt(selfPubkey, wrapped); + return _b64ToBytes(keyB64); + } + + // Encrypt a design payload into the publishable envelope. + async function encryptProjectEnvelope(payload) { + const method = nostrEncryptCapability(); + if (!method) throw new Error('Your Nostr extension does not support encryption (NIP-44 / NIP-04).'); + const selfPubkey = normalizePubkey(state.community?.myPubkey || ''); + if (!selfPubkey) throw new Error('Sign in with Nostr before publishing privately.'); + const rawBytes = new TextEncoder().encode(JSON.stringify(payload)); + const { data: compressed, compression } = await _gzipBytes(rawBytes); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); + const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, compressed)); + const rawKey = new Uint8Array(await crypto.subtle.exportKey('raw', key)); + const wrappedKey = await _wrapAesKey(rawKey, selfPubkey, method); + return { + v: 1, + visibility: 'encrypted', + enc: { alg: 'aes-256-gcm', keywrap: method, compression }, + iv: _bytesToB64(iv), + ciphertext: _bytesToB64(ct), + recipients: [{ pubkey: selfPubkey, wrappedKey }] + }; + } + + // Decrypt an envelope back to the design payload. + async function decryptProjectEnvelope(envelope) { + const method = envelope?.enc?.keywrap || nostrEncryptCapability(); + if (!method) throw new Error('Your Nostr extension does not support decryption.'); + const selfPubkey = normalizePubkey(state.community?.myPubkey || ''); + const mine = (envelope.recipients || []).find(r => normalizePubkey(r.pubkey) === selfPubkey); + if (!mine) throw new Error('This private design is not addressed to you.'); + const rawKey = await _unwrapAesKey(mine.wrappedKey, selfPubkey, method); + const key = await crypto.subtle.importKey('raw', rawKey, { name: 'AES-GCM' }, false, ['decrypt']); + const iv = _b64ToBytes(envelope.iv); + const ct = _b64ToBytes(envelope.ciphertext); + const compressed = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct)); + const rawBytes = await _gunzipBytes(compressed, envelope?.enc?.compression); + return JSON.parse(new TextDecoder().decode(rawBytes)); + } + + function isEncryptedEnvelope(obj) { + return !!obj && obj.visibility === 'encrypted' && typeof obj.ciphertext === 'string'; + } + + // The persistent random d-tag a private design keeps across edits. + function ensurePrivateDTag() { + if (!state.privateDTag) { + state.privateDTag = `artstr-private-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`; + } + return state.privateDTag; + } + + // Publish a design payload as an encrypted, feed-invisible event. + async function publishEncryptedDesign(payload) { + const envelope = await encryptProjectEnvelope(payload); + ensurePrivateDTag(); + const dTag = isEditing() ? state.editingDTag : state.privateDTag; + const tags = [ + ['d', dTag], + ['alt', 'Encrypted Artstr design'], + ['encrypted', envelope.enc.keywrap] + ]; + const evt = { kind: 30078, created_at: Math.floor(Date.now() / 1000), tags, content: JSON.stringify(envelope) }; + return await publishCommunityEvent(evt); + } + + function typeTagForMode(mode) { + if (mode === 'disc') return 'casewrap-disc'; + if (mode === 'jewel') return 'casewrap-jewel'; + if (mode === 'deck') return 'casewrap-deck'; + if (mode === 'slide') return 'casewrap-slide'; + if (mode === 'customart') return 'casewrap-customart'; + if (mode === 'disc-design') return 'casewrap-disc-design'; + return 'casewrap-cover'; + } + + // Flip a published design between public and private in place — a fresh + // event under the same d-tag with the opposite wrapper (NIP-33 replace). + async function republishDesignVisibility(row, makePrivate) { + if (!state.community?.myPubkey) { toastError('Sign in with Nostr first.'); return; } + const dTag = rowDTag(row); + if (!dTag) { toastError('This design has no address to update.'); return; } + if (makePrivate) { + if (!confirm('Make this design private?\n\nIt will be encrypted and removed from the community feed. Anyone who already saw or cached the public version may still have a copy — encryption cannot erase the past.')) return; + } else { + if (!confirm('Make this design public?\n\nIt will be decrypted and listed in the community feed for anyone to see.')) return; + } + try { + const payload = JSON.parse(JSON.stringify(row.payload || {})); + const created_at = Math.floor(Date.now() / 1000); + let evt; + if (makePrivate) { + payload.visibility = 'private'; + payload.privateDTag = dTag; + const envelope = await encryptProjectEnvelope(payload); + evt = { + kind: 30078, created_at, + tags: [['d', dTag], ['alt', 'Encrypted Artstr design'], ['encrypted', envelope.enc.keywrap]], + content: JSON.stringify(envelope) + }; + } else { + delete payload.visibility; + delete payload.privateDTag; + const mode = normalizeTemplateMode(payload); + evt = { + kind: 30078, created_at, + tags: [['d', dTag], ...getTemplateTags(payload, typeTagForMode(mode))], + content: JSON.stringify(payload) + }; + } + const signed = await publishCommunityEvent(evt); + cachePublishedTemplateEvent(signed); + toastSuccess(makePrivate ? 'Design is now private.' : 'Design is now public.'); + closeNostrPreview(); + await loadMyDesigns(); + } catch (err) { + toastError(err?.message || 'Could not change the design’s visibility.'); + } + } + function buildTemplateTombstonePayload(payload, { reason = '', deletedAt = Math.floor(Date.now() / 1000), deletedBy = '' } = {}) { const next = JSON.parse(JSON.stringify(payload || {})); const meta = { ...(next.meta || {}) }; @@ -9414,6 +9599,17 @@ throw new Error('Add cover artwork or at least one layer before publishing.'); } } + // Private: encrypt and publish a feed-invisible event instead of the + // normal public, tagged one. + if (state.visibility === 'private') { + payload.visibility = 'private'; + payload.privateDTag = ensurePrivateDTag(); + const signed = await publishEncryptedDesign(payload); + const label = isEditing() ? `Updated "${state.meta.title}" (private)` : `Private "${state.meta.title}"`; + await finalizePublishedTemplateEvent(signed, { label }); + if (isEditing()) exitEditMode(); + return signed; + } const mode = normalizeTemplateMode(payload); const dBase = state.meta.imdbId || state.meta.title @@ -9446,6 +9642,15 @@ if (!designName?.trim()) { throw new Error('Add a title in Project Metadata or a custom design name before publishing.'); } + if (state.visibility === 'private') { + payload.visibility = 'private'; + payload.privateDTag = ensurePrivateDTag(); + const signed = await publishEncryptedDesign(payload); + const label = isEditing() ? `Updated "${designName}" (private)` : `Private "${designName}"`; + await finalizePublishedTemplateEvent(signed, { label }); + if (isEditing()) exitEditMode(); + return signed; + } const dTag = isEditing() ? state.editingDTag : `disc-design:${state.meta.imdbId || designName}`; const tags = [ ['d', dTag], @@ -11346,6 +11551,8 @@ state.jewelSpineLabels = { left: '', right: '' }; state.customArt = { width: 1920, height: 1080, background: '#ffffff', preset: '1920x1080' }; state.deck = { theme: { fontFamily: '', background: '' }, slides: [], editingIndex: null }; + state.visibility = 'public'; + state.privateDTag = ''; state.meta = blankMeta(); state.layers = (state.layers || []).filter(l => l.target === 'designer-disc'); state.selectedLayerId = ''; @@ -11523,11 +11730,33 @@ metaEl.innerHTML = rows.map(([k, v]) => `
${escapeHtml(k)}
${escapeHtml(String(v))}
`).join(''); } + // Visibility control — Public / Private (encrypted). + const cap = nostrEncryptCapability(); + const privLabel = $('publishConfirmPrivateLabel'); + const privInput = privLabel ? privLabel.querySelector('input') : null; + const pubInput = document.querySelector('input[name="pubVisibility"][value="public"]'); + let vis = state.visibility === 'private' ? 'private' : 'public'; + if (!cap) vis = 'public'; + state.visibility = vis; + if (privInput) privInput.disabled = !cap; + if (privLabel) { + privLabel.style.opacity = cap ? '1' : '.5'; + privLabel.title = cap ? '' : 'Your Nostr extension does not support encryption (NIP-44 / NIP-04).'; + } + if (pubInput) pubInput.checked = vis === 'public'; + if (privInput) privInput.checked = vis === 'private'; + updatePublishConfirmVisibilityNote(); + modal.style.display = 'flex'; modal.setAttribute('aria-hidden', 'false'); bringModalToFront(modal); } + function updatePublishConfirmVisibilityNote() { + const note = $('publishConfirmPrivateNote'); + if (note) note.style.display = state.visibility === 'private' ? '' : 'none'; + } + function closePublishConfirmModal() { const modal = $('publishConfirmModal'); if (modal) { modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); } @@ -11641,6 +11870,10 @@ // Loading a project file is always a fresh-start operation — never an // in-place edit of a published event. Drop any prior editing context. exitEditMode(); + // Restore publish visibility — a decrypted private design carries + // `visibility`/`privateDTag` so re-publishing replaces it in place. + state.visibility = parsed.visibility === 'private' ? 'private' : 'public'; + state.privateDTag = parsed.privateDTag || ''; if (parsed.version && parsed.templateMode === 'disc-design' && parsed.discDesign) { importDiscDesignFromPayload(parsed, 'a', { openDesigner: true }); return; @@ -13662,6 +13895,9 @@ if (authorPubkey && _blockedAuthors.has(authorPubkey)) continue; let payload = null; try { payload = JSON.parse(e.content || '{}'); } catch { continue; } + // Encrypted designs never render here — they're feed-invisible and + // are pre-decrypted by loadMyDesigns before reaching this builder. + if (isEncryptedEnvelope(payload)) continue; if (!skipVersionFilter && (payload.version ?? 0) < MIN_SUPPORTED_SCHEMA_VERSION) continue; const address = templateEventAddress(e, payload); const eventTime = e.created_at || 0; @@ -13846,6 +14082,12 @@ badge.textContent = 'MISSING IMAGE'; thumb.appendChild(badge); } + if (row.private) { + const lockBadge = document.createElement('span'); + lockBadge.className = 'feedCardPrivateBadge'; + lockBadge.textContent = '🔒 Private'; + thumb.appendChild(lockBadge); + } if (showBlur) { const overlay = document.createElement('div'); overlay.className = 'feedCardSoftModOverlay'; @@ -14039,7 +14281,23 @@ } } const ownEvents = mergeCachedPublishedEvents(events).filter(event => event.pubkey === state.community.myPubkey); - const rows = buildRowsFromEvents(ownEvents, typeFilter, '', false, categoryFilter, languageFilter); + // Decrypt the user's own private events so they render in My Designs. + // (A failed decrypt — extension locked, etc. — is skipped silently.) + const prepared = []; + for (const e of ownEvents) { + let parsed = null; + if (e.kind === 30078) { try { parsed = JSON.parse(e.content || '{}'); } catch {} } + if (isEncryptedEnvelope(parsed)) { + try { + const decoded = await decryptProjectEnvelope(parsed); + prepared.push({ ...e, content: JSON.stringify(decoded), _private: true }); + } catch (err) { /* can't decrypt — omit */ } + } else { + prepared.push(e); + } + } + const rows = buildRowsFromEvents(prepared, typeFilter, '', false, categoryFilter, languageFilter); + rows.forEach((r) => { if (r.e && r.e._private) r.private = true; }); rows.sort((a, b) => b.createdAt - a.createdAt); wall.innerHTML = ''; if (!rows.length) { @@ -14550,6 +14808,23 @@ editBtn.title = 'Edit this design and publish an update (replaces the original)'; editBtn.addEventListener('click', () => startEditFromRow(row)); actionsDiv.insertBefore(editBtn, saveJsonBtn); + + // Owner-only Visibility toggle — flip a published design between + // public (feed-listed) and private (encrypted) in place. + const makePrivate = !row.private; + const visBtn = document.createElement('button'); + visBtn.type = 'button'; + visBtn.textContent = makePrivate ? 'Make Private' : 'Make Public'; + if (makePrivate && !nostrEncryptCapability()) { + visBtn.disabled = true; + visBtn.title = 'Your Nostr extension does not support encryption (NIP-44 / NIP-04).'; + } else { + visBtn.title = makePrivate + ? 'Re-publish this design encrypted, so only you can open it' + : 'Re-publish this private design publicly, listed in the community feed'; + } + visBtn.addEventListener('click', () => republishDesignVisibility(row, makePrivate)); + actionsDiv.insertBefore(visBtn, saveJsonBtn); } // Lightning tip button — only shown when the author has a resolvable LN address. @@ -17499,6 +17774,14 @@ on('publishConfirmCloseBtn', 'click', closePublishConfirmModal); on('publishConfirmCancelBtn', 'click', closePublishConfirmModal); on('publishConfirmPublishBtn', 'click', handlePublishConfirmPublish); + document.querySelectorAll('input[name="pubVisibility"]').forEach((radio) => { + radio.addEventListener('change', (e) => { + if (e.target.checked) { + state.visibility = e.target.value === 'private' ? 'private' : 'public'; + updatePublishConfirmVisibilityNote(); + } + }); + }); on('supportDevCloseBtn', 'click', closeSupportDevModal); on('supportDevNoBtn', 'click', closeSupportDevModal); on('supportDevLaterBtn', 'click', closeSupportDevModal);