diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index fbd9bbc..f71bf07 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -7,6 +7,7 @@ on: paths: - 'apps/marketing/**' - 'apps/portal/**' + - 'apps/paste-service/**' - 'packages/**' workflow_dispatch: inputs: @@ -19,6 +20,7 @@ on: - all - marketing - portal + - paste permissions: id-token: write @@ -30,6 +32,7 @@ jobs: outputs: marketing: ${{ steps.changes.outputs.marketing }} portal: ${{ steps.changes.outputs.portal }} + paste: ${{ steps.changes.outputs.paste }} steps: - uses: actions/checkout@v4 @@ -47,12 +50,18 @@ jobs: else echo "portal=false" >> $GITHUB_OUTPUT fi + if [[ "${{ inputs.target }}" == "all" || "${{ inputs.target }}" == "paste" ]]; then + echo "paste=true" >> $GITHUB_OUTPUT + else + echo "paste=false" >> $GITHUB_OUTPUT + fi else # For push events, check what changed git fetch origin ${{ github.event.before }} --depth=1 2>/dev/null || true MARKETING_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^(apps/marketing/|packages/)' || true) PORTAL_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^(apps/portal/|packages/)' || true) + PASTE_CHANGED=$(git diff --name-only ${{ github.event.before }} ${{ github.sha }} 2>/dev/null | grep -E '^apps/paste-service/' || true) if [[ -n "$MARKETING_CHANGED" ]]; then echo "marketing=true" >> $GITHUB_OUTPUT @@ -65,6 +74,12 @@ jobs: else echo "portal=false" >> $GITHUB_OUTPUT fi + + if [[ -n "$PASTE_CHANGED" ]]; then + echo "paste=true" >> $GITHUB_OUTPUT + else + echo "paste=false" >> $GITHUB_OUTPUT + fi fi deploy-marketing: @@ -132,3 +147,25 @@ jobs: aws cloudfront create-invalidation \ --distribution-id EP0KB9EFUWYXR \ --paths "/*" + + deploy-paste: + needs: detect-changes + if: needs.detect-changes.outputs.paste == 'true' + runs-on: ubuntu-latest + environment: production + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Deploy to Cloudflare + working-directory: apps/paste-service + run: npx wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/CLAUDE.md b/CLAUDE.md index 721524b..1a03fac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. | | `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. | | `PLANNOTATOR_SHARE_URL` | Custom base URL for share links (self-hosted portal). Default: `https://share.plannotator.ai`. | -| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://paste.plannotator.ai`. | +| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. | **Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected. Prefer `PLANNOTATOR_REMOTE=1` for explicit control. diff --git a/README.md b/README.md index 4e95012..6066917 100644 --- a/README.md +++ b/README.md @@ -39,14 +39,16 @@ Interactive Plan Review for AI Coding Agents. Mark up and refine your plans usin Plannotator lets you privately share plans, annotations, and feedback with colleagues. For example, a colleague can annotate a shared plan, and you can import their feedback to send directly back to the coding agent. -Plans are shared via compressed URL through a static site: **share.plannotator.ai** +**Small plans** are encoded entirely in the URL hash — no server involved, nothing stored anywhere. The data never leaves your browser. -- No backend or database; nothing is stored -- The site's deployment is open source -- You can self-host your own share site and point Plannotator to it via an environment variable ([see docs](https://plannotator.ai/docs/guides/sharing-and-collaboration/)) +**Large plans** use a short link service with **end-to-end encryption**. Your plan is encrypted with AES-256-GCM in your browser before it's uploaded — the server stores only ciphertext it cannot read. The decryption key lives only in the URL you share and is never sent to the server. Pastes auto-delete after 7 days. + +- Zero-knowledge storage — not even the service operator can read stored plans (similar to [PrivateBin](https://privatebin.info/)) +- No accounts, no tracking, no cookies on the share portal +- Fully open source and self-hostable ([see docs](https://plannotator.ai/docs/guides/sharing-and-collaboration/)) > [!NOTE] -> [share.plannotator.ai](https://share.plannotator.ai) uses a default fallback (demo) plan that is hard-coded into the site. This isn't a leaked plan—the site has no storage layer. +> Short links are end-to-end encrypted. A single-use AES-256-GCM key is generated in your browser via the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey), used to encrypt the plan, and embedded in the URL fragment (`#key=...`). The key never leaves the browser — it is never sent to the paste service or any server. Only someone with the exact URL can decrypt the plan. ## Install diff --git a/apps/marketing/src/content/blog/sharing-plans-with-your-team.md b/apps/marketing/src/content/blog/sharing-plans-with-your-team.md index 9b211a6..35bf39a 100644 --- a/apps/marketing/src/content/blog/sharing-plans-with-your-team.md +++ b/apps/marketing/src/content/blog/sharing-plans-with-your-team.md @@ -108,7 +108,8 @@ The hash fragment of a URL is never sent to a server in HTTP requests — that's This means: - **No accounts.** No sign-ups, no OAuth, no tokens. -- **No storage.** Nothing is persisted anywhere. Close the tab and the data exists only in the URL you copied. +- **No storage (small plans).** Nothing is persisted anywhere. Close the tab and the data exists only in the URL you copied. +- **End-to-end encrypted (large plans).** When a plan is too large for a URL, short links encrypt your plan with AES-256-GCM in your browser before uploading. The paste service stores only ciphertext it cannot read — the decryption key lives only in the URL you share. Pastes auto-delete after 7 days. - **No tracking.** The share portal has no analytics, no cookies, no telemetry. - **Self-hostable.** If even a static page hosted by someone else isn't acceptable, you can [self-host the portal](/docs/guides/self-hosting/) and point Plannotator at it with `PLANNOTATOR_SHARE_URL`. diff --git a/apps/marketing/src/content/docs/guides/self-hosting.md b/apps/marketing/src/content/docs/guides/self-hosting.md index d6a1dc2..246c4e5 100644 --- a/apps/marketing/src/content/docs/guides/self-hosting.md +++ b/apps/marketing/src/content/docs/guides/self-hosting.md @@ -20,11 +20,11 @@ Plannotator has three components. Only the hook is required. Small plans are encoded entirely in the URL hash — the share portal reads the hash and renders the plan. No backend involved. The data remains private — it never leaves the URL. -Large plans don't fit in a URL. **When a user explicitly confirms** short link creation, the compressed plan is sent to the paste service, which stores it and returns a short ID. A compressed plan goes in, a link to retrieve it comes out. The share URL becomes `share.plannotator.ai/p/aBcDeFgH` (or `your-portal.example.com/p/aBcDeFgH` if self-hosting). When someone opens that link, the portal fetches the compressed data from the paste service, decompresses it, and renders the plan. +Large plans don't fit in a URL. **When a user explicitly confirms** short link creation, the plan is **encrypted in the browser** (AES-256-GCM) before being sent to the paste service, which stores only the ciphertext and returns a short ID. The decryption key is embedded in the URL fragment (`#key=...`) and never sent to the server — not even the paste service operator can read stored plans. When someone opens that link, the portal fetches the ciphertext, decrypts it client-side using the key from the URL, and renders the plan. **Without paste service:** Sharing still works for plans that fit in a URL. Those plans stay completely private — the data lives only in the URL hash and never touches a server. Large plans show a warning that the URL may be truncated by messaging apps. -**With paste service:** Large plans get short, reliable URLs that work everywhere. Plannotator temporarily stores the compressed plan data — it auto-deletes after the configured TTL. +**With paste service:** Large plans get short, reliable URLs that work everywhere. Data is end-to-end encrypted and auto-deletes after the configured TTL. ## 1. Install the Hook diff --git a/apps/marketing/src/content/docs/guides/sharing-and-collaboration.md b/apps/marketing/src/content/docs/guides/sharing-and-collaboration.md index 418145c..1442331 100644 --- a/apps/marketing/src/content/docs/guides/sharing-and-collaboration.md +++ b/apps/marketing/src/content/docs/guides/sharing-and-collaboration.md @@ -70,10 +70,12 @@ When a plan is too large for a URL (~2KB+ compressed), messaging apps like Slack 5. A short URL like `share.plannotator.ai/p/aBcDeFgH` is generated 6. Both the short URL and the full hash URL are shown — the short URL is safe for messaging apps -### Privacy +### Privacy & encryption +- Plans are **end-to-end encrypted** (AES-256-GCM) in your browser before upload — the paste service stores only ciphertext it cannot read +- A single-use encryption key is generated in your browser via the [Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/generateKey). The key **never leaves the browser** — it is never sent to the paste service or any server. It exists only in the URL fragment (`#key=...`), which browsers never include in HTTP requests per the HTTP specification. Not even the service operator can decrypt stored plans. - Plans are only uploaded when you explicitly click "Create short link" — no data leaves your machine until you confirm -- Pastes auto-expire and are permanently deleted (hosted: a few days, self-hosted: configurable via `PASTE_TTL_DAYS`) +- Pastes auto-expire and are permanently deleted (hosted: 7 days, self-hosted: configurable via `PASTE_TTL_DAYS`) - The paste service is fully open source — you can audit exactly what it does - Self-hosters can run their own paste service for complete control — see the [self-hosting guide](/docs/guides/self-hosting/) - If the paste service is unavailable, the full hash URL is always available as fallback @@ -88,3 +90,4 @@ By default, share URLs point to `https://share.plannotator.ai`. You can self-hos - The share portal is a static page — it only reads the hash and renders client-side - No analytics, no tracking, no cookies on the share portal - Short URLs are opt-in — data is only uploaded when you explicitly click "Create short link" (see [Short URLs for large plans](#short-urls-for-large-plans) for details) +- Short URLs use end-to-end encryption (AES-256-GCM) — the decryption key is embedded in the URL fragment and never sent to the server. The paste service stores only opaque ciphertext, similar to [PrivateBin](https://privatebin.info/) diff --git a/apps/marketing/src/content/docs/reference/api-endpoints.md b/apps/marketing/src/content/docs/reference/api-endpoints.md index ce34a9a..0b05662 100644 --- a/apps/marketing/src/content/docs/reference/api-endpoints.md +++ b/apps/marketing/src/content/docs/reference/api-endpoints.md @@ -138,7 +138,7 @@ Body: Stores compressed plan data for short URL sharing. Runs as a separate service from the plan/review/annotate servers. -Default: `https://paste.plannotator.ai` (or self-hosted) +Default: `https://plannotator-paste.plannotator.workers.dev` (or self-hosted) | Endpoint | Method | Purpose | |----------|--------|---------| diff --git a/apps/marketing/src/content/docs/reference/environment-variables.md b/apps/marketing/src/content/docs/reference/environment-variables.md index a6976d9..4fc284e 100644 --- a/apps/marketing/src/content/docs/reference/environment-variables.md +++ b/apps/marketing/src/content/docs/reference/environment-variables.md @@ -23,7 +23,7 @@ All Plannotator environment variables and their defaults. | Variable | Default | Description | |----------|---------|-------------| -| `PLANNOTATOR_PASTE_URL` | `https://paste.plannotator.ai` | Base URL of the paste service API. Set this when self-hosting the paste service. | +| `PLANNOTATOR_PASTE_URL` | `https://plannotator-paste.plannotator.workers.dev` | Base URL of the paste service API. Set this when self-hosting the paste service. | ### Self-hosted paste service diff --git a/apps/paste-service/core/handler.ts b/apps/paste-service/core/handler.ts index d88d973..716316b 100644 --- a/apps/paste-service/core/handler.ts +++ b/apps/paste-service/core/handler.ts @@ -131,7 +131,7 @@ export async function handleRequest( { headers: { ...cors, - "Cache-Control": "public, max-age=3600", + "Cache-Control": "private, no-store", }, } ); diff --git a/apps/paste-service/wrangler.toml b/apps/paste-service/wrangler.toml index 32bc52a..7acdf56 100644 --- a/apps/paste-service/wrangler.toml +++ b/apps/paste-service/wrangler.toml @@ -5,8 +5,8 @@ compatibility_date = "2024-12-01" [[kv_namespaces]] binding = "PASTE_KV" # Run `wrangler kv:namespace create PASTE_KV` to get your IDs and fill them in. -id = "REPLACE_WITH_KV_NAMESPACE_ID" -preview_id = "REPLACE_WITH_PREVIEW_KV_NAMESPACE_ID" +id = "9bc2647f6f5244499c26c90d87a743a0" +preview_id = "6efae5ac33c4443ba8f0a0b83a2eb111" [vars] ALLOWED_ORIGINS = "https://share.plannotator.ai,http://localhost:3001" diff --git a/packages/shared/crypto.ts b/packages/shared/crypto.ts new file mode 100644 index 0000000..27c7671 --- /dev/null +++ b/packages/shared/crypto.ts @@ -0,0 +1,97 @@ +/** + * AES-256-GCM encryption for zero-knowledge paste storage. + * + * Uses Web Crypto API — works in browsers, Bun, and edge runtimes. + * The key never leaves the client; it lives in the URL fragment. + */ + +/** + * Encrypt a compressed base64url string with a fresh AES-256-GCM key. + * + * Returns { ciphertext, key } where: + * - ciphertext: base64url-encoded (12-byte IV prepended to GCM output) + * - key: base64url-encoded 256-bit key for the URL fragment + */ +export async function encrypt( + compressedData: string +): Promise<{ ciphertext: string; key: string }> { + const cryptoKey = await crypto.subtle.generateKey( + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt'] + ); + + const iv = crypto.getRandomValues(new Uint8Array(12)); + const plaintext = new TextEncoder().encode(compressedData); + + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + plaintext + ); + + // Prepend IV to ciphertext (IV || ciphertext+tag) + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv, 0); + combined.set(new Uint8Array(encrypted), iv.length); + + const rawKey = await crypto.subtle.exportKey('raw', cryptoKey); + + return { + ciphertext: bytesToBase64url(combined), + key: bytesToBase64url(new Uint8Array(rawKey)), + }; +} + +/** + * Decrypt a ciphertext string using a base64url-encoded AES-256-GCM key. + * + * Expects ciphertext format: base64url(IV || encrypted+tag) + * Returns the original compressed base64url string. + */ +export async function decrypt( + ciphertext: string, + key: string +): Promise { + const combined = base64urlToBytes(ciphertext); + const rawKey = base64urlToBytes(key); + + const iv = combined.slice(0, 12); + const encrypted = combined.slice(12); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + rawKey, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + cryptoKey, + encrypted + ); + + return new TextDecoder().decode(decrypted); +} + +// --- Helpers --- + +function bytesToBase64url(bytes: Uint8Array): string { + // Loop to avoid RangeError on large payloads (same approach as compress.ts) + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); +} + +function base64urlToBytes(b64: string): Uint8Array { + const base64 = b64.replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(base64); + return Uint8Array.from(binary, c => c.charCodeAt(0)); +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 4a21378..8101468 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,7 @@ "version": "0.0.1", "private": true, "exports": { - "./compress": "./compress.ts" + "./compress": "./compress.ts", + "./crypto": "./crypto.ts" } } diff --git a/packages/ui/components/ExportModal.tsx b/packages/ui/components/ExportModal.tsx index 7d1ade4..ad9082f 100644 --- a/packages/ui/components/ExportModal.tsx +++ b/packages/ui/components/ExportModal.tsx @@ -269,7 +269,7 @@ export const ExportModal: React.FC = ({

- Short link — safe for Slack, email, and messaging apps. + Encrypted short link. Your plan is end-to-end encrypted before it leaves your browser — not even the server can read it.

) : isGeneratingShortUrl ? ( @@ -334,13 +334,13 @@ export const ExportModal: React.FC = ({ {!shortShareUrl && !isGeneratingShortUrl && !urlIsLarge && (

- This URL contains the full plan and annotations. + Your plan is encoded entirely in the URL — it never touches a server.

)}

- Anyone with this link can view and add to your annotations. + Only someone with this exact link can view your plan. Short links are end-to-end encrypted — the decryption key is in the URL and never sent to the server.

) : activeTab === 'notes' && showNotesTab ? ( diff --git a/packages/ui/hooks/useSharing.ts b/packages/ui/hooks/useSharing.ts index 2605974..2a3c103 100644 --- a/packages/ui/hooks/useSharing.ts +++ b/packages/ui/hooks/useSharing.ts @@ -104,7 +104,12 @@ export function useSharing( const pathMatch = window.location.pathname.match(/^\/p\/([A-Za-z0-9]{6,16})$/); if (pathMatch) { const pasteId = pathMatch[1]; - const payload = await loadFromPasteId(pasteId, pasteApiUrl); + + // Extract encryption key from URL fragment: #key= + const fragment = window.location.hash.slice(1); + const encryptionKey = fragment.startsWith('key=') ? fragment.slice(4) : undefined; + + const payload = await loadFromPasteId(pasteId, pasteApiUrl, encryptionKey); if (payload) { setMarkdown(payload.p); @@ -128,7 +133,9 @@ export function useSharing( return true; } - // Paste fetch failed — fall through to try the hash fragment instead. + // Paste fetch failed — short URL path can't fall back to hash parsing + // (the hash contains #key=, not plan data). + return false; } const payload = await parseShareHash(); @@ -254,11 +261,12 @@ export function useSharing( try { let payload: SharePayload | undefined; - // Check for short URL pattern: /p/ - const shortMatch = url.match(/\/p\/([A-Za-z0-9]{6,16})(?:\?|$)/); + // Check for short URL pattern: /p/ with optional #key= fragment + const shortMatch = url.match(/\/p\/([A-Za-z0-9]{6,16})(?:#key=([A-Za-z0-9_-]+))?(?:\?|#|$)/); if (shortMatch) { const pasteId = shortMatch[1]; - const loaded = await loadFromPasteId(pasteId, pasteApiUrl); + const encryptionKey = shortMatch[2]; // undefined if no key fragment + const loaded = await loadFromPasteId(pasteId, pasteApiUrl, encryptionKey); if (!loaded) { return { success: false, count: 0, planTitle: '', error: 'Failed to load from short URL — paste may have expired' }; } diff --git a/packages/ui/utils/sharing.ts b/packages/ui/utils/sharing.ts index ff0b9f3..9799ffd 100644 --- a/packages/ui/utils/sharing.ts +++ b/packages/ui/utils/sharing.ts @@ -10,6 +10,7 @@ import { Annotation, AnnotationType, type ImageAttachment } from '../types'; import { compress, decompress } from '@plannotator/shared/compress'; +import { encrypt, decrypt } from '@plannotator/shared/crypto'; // Image in shareable format: plain string (old) or [path, name] tuple (new) type ShareableImage = string | [string, string]; @@ -191,7 +192,7 @@ export function formatUrlSize(url: string): string { // Short URL support (paste-service backed) // --------------------------------------------------------------------------- -const DEFAULT_PASTE_API = 'https://paste.plannotator.ai'; +const DEFAULT_PASTE_API = 'https://plannotator-paste.plannotator.workers.dev'; const DEFAULT_SHARE_BASE = 'https://share.plannotator.ai'; /** @@ -208,7 +209,7 @@ export async function createShortShareUrl( annotations: Annotation[], globalAttachments?: ImageAttachment[], options?: { - /** Override the paste API base URL (default: https://paste.plannotator.ai) */ + /** Override the paste API base URL (default: https://plannotator-paste.plannotator.workers.dev) */ pasteApiUrl?: string; /** Override the share site base URL used in the returned short link */ shareBaseUrl?: string; @@ -226,10 +227,13 @@ export async function createShortShareUrl( const compressed = await compress(payload); + // Encrypt before uploading — server only sees ciphertext + const { ciphertext, key } = await encrypt(compressed); + const response = await fetch(`${pasteApi}/api/paste`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ data: compressed }), + body: JSON.stringify({ data: ciphertext }), signal: AbortSignal.timeout(5_000), }); @@ -239,7 +243,8 @@ export async function createShortShareUrl( } const result = (await response.json()) as { id: string }; - const shortUrl = `${shareBase}/p/${result.id}`; + // Key in fragment — never sent to server per HTTP spec + const shortUrl = `${shareBase}/p/${result.id}#key=${key}`; return { shortUrl, id: result.id }; } catch (e) { @@ -258,7 +263,8 @@ export async function createShortShareUrl( */ export async function loadFromPasteId( pasteId: string, - pasteApiUrl: string = DEFAULT_PASTE_API + pasteApiUrl: string = DEFAULT_PASTE_API, + encryptionKey?: string ): Promise { try { const response = await fetch(`${pasteApiUrl}/api/paste/${pasteId}`, { @@ -271,7 +277,15 @@ export async function loadFromPasteId( } const result = (await response.json()) as { data: string }; - return await decompress(result.data); + + if (encryptionKey) { + // Encrypted path: decrypt ciphertext, then decompress + const compressed = await decrypt(result.data, encryptionKey); + return await decompress(compressed) as SharePayload; + } + + // Legacy unencrypted path: decompress directly + return await decompress(result.data) as SharePayload; } catch (e) { console.warn('[sharing] Failed to load from paste ID:', e); return null;