diff --git a/README.md b/README.md index 98564b1..48574d5 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,8 @@ npm run deploy ## πŸ“– Documentation - [Technical Specification](docs/SPEC.md) - Detailed architecture and implementation details +- [Encryption Architecture](docs/ENCRYPTION.md) - In-depth encryption documentation +- [Security Best Practices](docs/SECURITY.md) - Security guidelines for developers and users - [AI Development Guide](CLAUDE.md) - Guidelines for AI-assisted development - [Implementation TODO](docs/TODO.md) - Development roadmap and progress tracking - [Contributing Guide](CONTRIBUTING.md) - How to contribute to the project diff --git a/docs/ENCRYPTION.md b/docs/ENCRYPTION.md new file mode 100644 index 0000000..e5e5b93 --- /dev/null +++ b/docs/ENCRYPTION.md @@ -0,0 +1,659 @@ +# πŸ” GhostPaste Encryption Architecture + +## Table of Contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Security Model](#security-model) +- [Implementation Details](#implementation-details) +- [Usage Guide](#usage-guide) +- [Security Best Practices](#security-best-practices) +- [Threat Model](#threat-model) +- [Cryptographic Specifications](#cryptographic-specifications) +- [Key Management](#key-management) +- [Testing](#testing) + +## Overview + +GhostPaste implements zero-knowledge client-side encryption to ensure that gists are encrypted before leaving the user's browser and can only be decrypted by someone with the encryption key. The server (and Cloudflare) never has access to the plaintext content or encryption keys. + +### Key Principles + +1. **Zero-Knowledge**: The server never sees unencrypted content or encryption keys +2. **Client-Side Only**: All encryption/decryption happens in the browser +3. **URL Fragment Keys**: Encryption keys are shared via URL fragments (never sent to server) +4. **Forward Secrecy**: Each gist uses a unique encryption key +5. **Standard Algorithms**: Uses Web Crypto API with AES-256-GCM + +## Architecture + +### High-Level Flow + +```mermaid +sequenceDiagram + participant User + participant Browser + participant Server + participant R2 Storage + + Note over User,Browser: Creation Flow + User->>Browser: Create gist with files + Browser->>Browser: Generate encryption key + Browser->>Browser: Encode files to binary + Browser->>Browser: Encrypt with AES-GCM + Browser->>Server: Upload encrypted blob + Server->>R2 Storage: Store encrypted data + Server-->>Browser: Return gist ID + Browser->>User: Share URL with #key=... + + Note over User,Browser: Retrieval Flow + User->>Browser: Open gist URL + Browser->>Browser: Extract key from fragment + Browser->>Server: Request encrypted blob + Server->>R2 Storage: Fetch encrypted data + R2 Storage-->>Server: Return encrypted blob + Server-->>Browser: Return encrypted data + Browser->>Browser: Decrypt with key + Browser->>Browser: Decode binary to files + Browser->>User: Display decrypted content +``` + +### Component Architecture + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Frontend UI │────▢│ Crypto Utilities │────▢│ Web Crypto API β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β”‚ β–Ό + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ Binary Format β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ API Routes │────▢│ R2 Storage β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Security Model + +### Trust Boundaries + +1. **Client (Trusted)** + + - Browser environment + - User's device + - JavaScript execution context + +2. **Network (Untrusted)** + + - HTTPS connection + - Cloudflare edge + +3. **Server (Untrusted)** + - Cloudflare Workers + - R2 storage + - Server logs + +### Security Properties + +1. **Confidentiality**: Content is encrypted with AES-256-GCM +2. **Integrity**: GCM mode provides authenticated encryption +3. **Authenticity**: Optional PIN protection for edit access +4. **Forward Secrecy**: Each gist has a unique key +5. **Key Independence**: Compromising one key doesn't affect others + +## Implementation Details + +### File Structure + +``` +lib/ +β”œβ”€β”€ crypto.ts # Core encryption/decryption functions +β”œβ”€β”€ crypto-utils.ts # High-level encryption utilities +β”œβ”€β”€ binary.ts # Binary encoding/decoding +β”œβ”€β”€ auth.ts # PIN authentication (PBKDF2) +β”œβ”€β”€ base64.ts # Base64 encoding utilities +└── id.ts # Secure ID generation +``` + +### Encryption Process + +1. **File Preparation** + + ```typescript + // Files are encoded to a binary format + const encodedData = encodeFiles(files); + ``` + +2. **Key Generation** + + ```typescript + // Generate a 256-bit AES key + const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, // extractable + ["encrypt", "decrypt"] + ); + ``` + +3. **Encryption** + + ```typescript + // Encrypt with a random IV + const iv = crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = await crypto.subtle.encrypt( + { name: "AES-GCM", iv, tagLength: 128 }, + key, + encodedData + ); + ``` + +4. **Storage Format** + ``` + [12 bytes IV][Encrypted Data with Auth Tag] + ``` + +### Decryption Process + +1. **Key Extraction** + + ```typescript + // Extract key from URL fragment + const key = await extractKeyFromUrl(window.location.href); + ``` + +2. **Data Retrieval** + + ```typescript + // Fetch encrypted blob from server + const encryptedBlob = await fetchFromR2(gistId); + ``` + +3. **Decryption** + + ```typescript + // Unpack IV and decrypt + const { iv, ciphertext } = unpackEncryptedBlob(encryptedBlob); + const plaintext = await crypto.subtle.decrypt( + { name: "AES-GCM", iv, tagLength: 128 }, + key, + ciphertext + ); + ``` + +4. **File Reconstruction** + ```typescript + // Decode binary format back to files + const files = decodeFiles(plaintext); + ``` + +### Binary Format + +The binary format efficiently encodes multiple files: + +``` +Header (12 bytes): +β”œβ”€ Magic bytes (4): "GHST" +β”œβ”€ Version (2): 0x0001 +β”œβ”€ File count (2): uint16 +└─ Reserved (4): 0x00000000 + +For each file: +β”œβ”€ Name length (2): uint16 +β”œβ”€ Name (variable): UTF-8 string +β”œβ”€ Content length (4): uint32 +β”œβ”€ Content (variable): UTF-8 string +└─ Language length + data (variable): Optional +``` + +### PIN Protection + +Edit PINs use PBKDF2-SHA256 for secure hashing: + +```typescript +const salt = crypto.getRandomValues(new Uint8Array(16)); +const keyMaterial = await crypto.subtle.importKey( + "raw", + encoder.encode(pin), + { name: "PBKDF2" }, + false, + ["deriveBits"] +); +const hash = await crypto.subtle.deriveBits( + { + name: "PBKDF2", + salt: salt, + iterations: 100000, + hash: "SHA-256", + }, + keyMaterial, + 256 +); +``` + +## Usage Guide + +### Creating an Encrypted Gist + +```typescript +import { createGist } from "@/lib/crypto-utils"; + +const files = [ + { name: "main.js", content: 'console.log("Hello");', language: "javascript" }, + { name: "README.md", content: "# My Project", language: "markdown" }, +]; + +const { gist, shareUrl } = await createGist(files, { + description: "My secure code", + editPin: "MySecurePin123", + oneTimeView: false, + expiresAt: new Date(Date.now() + 86400000), // 24 hours +}); + +// Share this URL - the key is in the fragment +console.log(shareUrl); // https://ghostpaste.dev/g/abc123#key=... +``` + +### Loading and Decrypting a Gist + +```typescript +import { loadGistFromUrl } from "@/lib/crypto-utils"; + +// The URL contains the encryption key in the fragment +const url = "https://ghostpaste.dev/g/abc123#key=..."; + +// Fetch encrypted data from server +const encryptedData = await fetchGistData("abc123"); +const metadata = await fetchGistMetadata("abc123"); + +// Decrypt the gist +const decryptedGist = await loadGistFromUrl(url, encryptedData, metadata); + +if (decryptedGist) { + console.log(decryptedGist.files); // Array of decrypted files +} +``` + +### Low-Level Encryption API + +```typescript +import { generateEncryptionKey, encrypt, decrypt } from "@/lib/crypto"; + +// Generate key +const key = await generateEncryptionKey(); + +// Encrypt data +const data = new TextEncoder().encode("Secret message"); +const encrypted = await encrypt(data, key); + +// Decrypt data +const decrypted = await decrypt(encrypted, key); +const message = new TextDecoder().decode(decrypted); +``` + +## Security Best Practices + +### For Developers + +1. **Never Log Keys**: Never log, store, or transmit encryption keys to the server + + ```typescript + // ❌ BAD + console.log("Encryption key:", key); + await fetch("/api/save-key", { body: key }); + + // βœ… GOOD + // Keys stay in browser memory only + ``` + +2. **Use URL Fragments**: Always use fragments for key sharing + + ```typescript + // ❌ BAD - Query parameters are sent to server + const url = `https://ghostpaste.dev/g/${id}?key=${key}`; + + // βœ… GOOD - Fragments are never sent to server + const url = `https://ghostpaste.dev/g/${id}#key=${key}`; + ``` + +3. **Validate Everything**: Always validate decrypted data + + ```typescript + try { + const decrypted = await decrypt(data, key); + const files = decodeFiles(decrypted); + // Validate file structure + if (!Array.isArray(files) || files.length === 0) { + throw new Error("Invalid decrypted data"); + } + } catch (error) { + // Handle gracefully + } + ``` + +4. **Use Secure Random**: Always use crypto.getRandomValues() + + ```typescript + // ❌ BAD + const iv = new Uint8Array(12); + for (let i = 0; i < 12; i++) { + iv[i] = Math.floor(Math.random() * 256); + } + + // βœ… GOOD + const iv = crypto.getRandomValues(new Uint8Array(12)); + ``` + +5. **Handle Errors Securely**: Don't leak information in errors + + ```typescript + // ❌ BAD + throw new Error(`Decryption failed: ${key} is invalid`); + + // βœ… GOOD + throw new Error("Invalid decryption key"); + ``` + +### For Users + +1. **Share URLs Securely**: Use secure channels for sharing URLs +2. **Use Strong PINs**: If using PIN protection, choose strong PINs +3. **Understand Expiration**: One-time view and expiring gists cannot be recovered +4. **Save Important Keys**: Keep backups of encryption keys for important gists +5. **Verify URLs**: Ensure you're on the correct domain before entering sensitive data + +## Threat Model + +### In Scope Threats + +1. **Passive Network Attackers** + + - Threat: Eavesdropping on network traffic + - Mitigation: HTTPS + client-side encryption + +2. **Compromised Server** + + - Threat: Malicious server trying to steal data + - Mitigation: Zero-knowledge architecture, keys never sent to server + +3. **CDN/Edge Compromise** + + - Threat: Cloudflare compromise + - Mitigation: End-to-end encryption, encrypted data only + +4. **Storage Breach** + + - Threat: R2 storage compromise + - Mitigation: Data encrypted at rest with user keys + +5. **URL Sharing Attacks** + - Threat: URLs intercepted during sharing + - Mitigation: Optional PIN protection, expiring links + +### Out of Scope Threats + +1. **Compromised Client** + + - If the user's browser is compromised, encryption cannot help + - Users should ensure their devices are secure + +2. **Malicious JavaScript** + + - Supply chain attacks on dependencies + - Mitigated by SRI, CSP, and regular audits + +3. **Browser Vulnerabilities** + + - Zero-days in browser crypto implementations + - Users should keep browsers updated + +4. **Physical Access** + + - Shoulder surfing, device theft + - Users should practice physical security + +5. **Social Engineering** + - Tricking users into revealing URLs/PINs + - User education is key + +### Attack Scenarios and Mitigations + +1. **Scenario**: Attacker gains access to R2 storage + + - **Impact**: Encrypted blobs exposed + - **Mitigation**: Without keys, data remains encrypted + +2. **Scenario**: Man-in-the-middle attack + + - **Impact**: Could see encrypted traffic + - **Mitigation**: HTTPS + encryption provides defense in depth + +3. **Scenario**: Server-side code injection + + - **Impact**: Could modify served JavaScript + - **Mitigation**: CSP, SRI, regular security audits + +4. **Scenario**: Brute force attack on gist IDs + - **Impact**: Could find valid gist IDs + - **Mitigation**: Rate limiting, longer IDs, expiring gists + +## Cryptographic Specifications + +### Algorithms + +- **Encryption**: AES-256-GCM + + - Key size: 256 bits + - IV size: 96 bits (12 bytes) + - Tag size: 128 bits + - Mode: Galois/Counter Mode (authenticated encryption) + +- **Key Derivation**: PBKDF2-SHA256 + + - Iterations: 100,000 + - Salt size: 128 bits (16 bytes) + - Output: 256 bits + +- **Hashing**: SHA-256 + + - Used for integrity checks + - Part of PBKDF2 for PIN hashing + +- **Random Generation**: Web Crypto getRandomValues() + - Cryptographically secure + - Used for IVs, salts, and IDs + +### Key Formats + +- **Storage**: Base64url encoding (no padding) +- **Transport**: URL fragment parameters +- **Internal**: CryptoKey objects (non-extractable in production) + +### Security Parameters + +```typescript +// Encryption parameters +const ALGORITHM = "AES-GCM"; +const KEY_LENGTH = 256; // bits +const IV_LENGTH = 12; // bytes +const TAG_LENGTH = 128; // bits + +// PBKDF2 parameters +const PBKDF2_ITERATIONS = 100_000; +const PBKDF2_SALT_LENGTH = 16; // bytes +const PBKDF2_KEY_LENGTH = 32; // bytes + +// ID generation +const GIST_ID_LENGTH = 8; // characters +const GIST_ID_ALPHABET = "0-9a-zA-Z"; // 62 chars = ~48 bits entropy +``` + +## Key Management + +### Key Generation + +```typescript +// Each gist gets a unique key +const key = await crypto.subtle.generateKey( + { name: "AES-GCM", length: 256 }, + true, // extractable for sharing + ["encrypt", "decrypt"] +); +``` + +### Key Distribution + +```typescript +// Keys are shared via URL fragments +const exportedKey = await crypto.subtle.exportKey("raw", key); +const keyString = base64UrlEncode(new Uint8Array(exportedKey)); +const shareUrl = `${baseUrl}/g/${gistId}#key=${keyString}`; +``` + +### Key Storage + +- **Browser**: Keys exist only in memory during encryption/decryption +- **Server**: Never sees or stores keys +- **User**: Responsible for saving/sharing URLs with keys + +### Key Rotation + +- Not applicable - each gist has its own key +- No key reuse between gists +- No master keys or key hierarchies + +## Testing + +### Unit Tests + +```typescript +// Test encryption/decryption cycle +it("should encrypt and decrypt data correctly", async () => { + const key = await generateEncryptionKey(); + const plaintext = new TextEncoder().encode("Hello, World!"); + + const encrypted = await encrypt(plaintext, key); + const decrypted = await decrypt(encrypted, key); + + expect(decrypted).toEqual(plaintext); +}); + +// Test key extraction from URLs +it("should extract key from URL fragment", async () => { + const originalKey = await generateEncryptionKey(); + const url = await generateShareableUrl( + "https://example.com", + "id", + originalKey + ); + + const extractedKey = await extractKeyFromUrl(url); + expect(extractedKey).toBeTruthy(); + + // Verify keys are equivalent by encrypting/decrypting + const data = new TextEncoder().encode("test"); + const encrypted = await encrypt(data, originalKey); + const decrypted = await decrypt(encrypted, extractedKey!); + expect(decrypted).toEqual(data); +}); +``` + +### Integration Tests + +```typescript +// Test full gist lifecycle +it("should handle complete gist lifecycle", async () => { + // Create + const files = [{ name: "test.js", content: "test", language: "javascript" }]; + const { gist, shareUrl } = await createGist(files, { editPin: "Pin123" }); + + // Validate + const isValid = await validateGistPin("Pin123", gist.metadata); + expect(isValid).toBe(true); + + // Load + const loaded = await loadGistFromUrl( + shareUrl, + gist.encryptedData, + gist.metadata + ); + expect(loaded?.files).toEqual(files); +}); +``` + +### Security Tests + +```typescript +// Test against known attacks +it("should fail with tampered ciphertext", async () => { + const key = await generateEncryptionKey(); + const encrypted = await encrypt(data, key); + + // Tamper with ciphertext + encrypted.ciphertext[0] ^= 0xff; + + await expect(decrypt(encrypted, key)).rejects.toThrow(); +}); + +// Test key independence +it("should not decrypt with different key", async () => { + const key1 = await generateEncryptionKey(); + const key2 = await generateEncryptionKey(); + + const encrypted = await encrypt(data, key1); + await expect(decrypt(encrypted, key2)).rejects.toThrow(); +}); +``` + +### Performance Tests + +```typescript +// Test encryption performance +it("should handle large files efficiently", async () => { + const largeFile = new Uint8Array(5 * 1024 * 1024); // 5MB + crypto.getRandomValues(largeFile); + + const start = performance.now(); + const key = await generateEncryptionKey(); + const encrypted = await encrypt(largeFile, key); + const end = performance.now(); + + expect(end - start).toBeLessThan(1000); // Under 1 second +}); +``` + +## Compliance and Standards + +### Standards Compliance + +- **NIST SP 800-38D**: AES-GCM implementation +- **NIST SP 800-132**: PBKDF2 for password-based derivation +- **RFC 4648**: Base64url encoding +- **Web Crypto API**: W3C standard for browser cryptography + +### Security Headers + +```typescript +// Recommended security headers +{ + 'Content-Security-Policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';", + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', + 'Referrer-Policy': 'no-referrer', + 'Permissions-Policy': 'geolocation=(), microphone=(), camera=()' +} +``` + +### Future Considerations + +1. **Post-Quantum**: Monitor NIST PQC standardization +2. **Key Escrow**: Intentionally not implemented (zero-knowledge) +3. **Compliance**: GDPR right-to-deletion via expiring gists +4. **Auditing**: Regular third-party security audits recommended + +--- + +_This document is part of the GhostPaste security documentation. For implementation details, see the source code in `/lib/crypto_`.\* diff --git a/docs/R2_DIRECT_ACCESS.md b/docs/R2_DIRECT_ACCESS.md new file mode 100644 index 0000000..4bc75e2 --- /dev/null +++ b/docs/R2_DIRECT_ACCESS.md @@ -0,0 +1,412 @@ +# πŸš€ R2 Direct Access Architecture + +## Overview + +Direct browser-to-R2 access using presigned URLs can significantly improve performance by: + +- Reducing latency (direct to Cloudflare edge) +- Lowering server costs (no Worker CPU time for transfers) +- Improving scalability (offload bandwidth to Cloudflare) +- Enabling larger file uploads (bypass Worker limits) + +## Architecture Options + +### Option 1: Presigned URLs (Recommended for GhostPaste) + +```mermaid +sequenceDiagram + participant Browser + participant Worker + participant R2 + + Note over Browser,R2: Upload Flow + Browser->>Worker: Request upload URL + Worker->>Worker: Validate request + Worker->>Worker: Generate presigned PUT URL + Worker-->>Browser: Return presigned URL + Browser->>R2: Direct upload (encrypted blob) + R2-->>Browser: Success + Browser->>Worker: Confirm upload + metadata + Worker->>R2: Store metadata + + Note over Browser,R2: Download Flow + Browser->>Worker: Request download URL + Worker->>Worker: Check permissions + Worker->>Worker: Generate presigned GET URL + Worker-->>Browser: Return presigned URL + Browser->>R2: Direct download + R2-->>Browser: Encrypted blob +``` + +### Option 2: Worker Proxy (Current Approach) + +```mermaid +sequenceDiagram + participant Browser + participant Worker + participant R2 + + Browser->>Worker: Upload encrypted blob + Worker->>R2: Store blob + R2-->>Worker: Success + Worker-->>Browser: Success +``` + +## Implementation Guide + +### 1. Worker Endpoint for Presigned URLs + +```typescript +// app/api/upload/route.ts +import { PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; +import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; + +export async function POST(request: Request) { + const { fileSize, gistId } = await request.json(); + + // Validate request + if (fileSize > MAX_FILE_SIZE) { + return Response.json({ error: "File too large" }, { status: 413 }); + } + + // Create S3 client for R2 + const s3Client = new S3Client({ + region: "auto", + endpoint: `https://${env.ACCOUNT_ID}.r2.cloudflarestorage.com`, + credentials: { + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_SECRET_ACCESS_KEY, + }, + }); + + // Generate presigned PUT URL + const command = new PutObjectCommand({ + Bucket: env.R2_BUCKET_NAME, + Key: `gists/${gistId}/encrypted.bin`, + ContentType: "application/octet-stream", + ContentLength: fileSize, + }); + + const uploadUrl = await getSignedUrl(s3Client, command, { + expiresIn: 3600, // 1 hour + }); + + return Response.json({ uploadUrl, gistId }); +} +``` + +### 2. Browser Upload Implementation + +```typescript +// lib/r2-direct.ts +export async function uploadDirectToR2( + encryptedBlob: Uint8Array, + gistId: string +): Promise { + // Step 1: Get presigned URL from Worker + const response = await fetch("/api/upload", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + fileSize: encryptedBlob.length, + gistId, + }), + }); + + if (!response.ok) { + throw new Error("Failed to get upload URL"); + } + + const { uploadUrl } = await response.json(); + + // Step 2: Upload directly to R2 + const uploadResponse = await fetch(uploadUrl, { + method: "PUT", + body: encryptedBlob, + headers: { + "Content-Type": "application/octet-stream", + "Content-Length": encryptedBlob.length.toString(), + }, + }); + + if (!uploadResponse.ok) { + throw new Error("Failed to upload to R2"); + } + + // Step 3: Confirm upload and save metadata + await fetch(`/api/gists/${gistId}/confirm`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ uploaded: true }), + }); +} +``` + +### 3. CORS Configuration + +```typescript +// Worker to configure CORS for R2 bucket +export async function configureCORS() { + const corsRules = [ + { + AllowedOrigins: [ + "https://ghostpaste.dev", + "http://localhost:3000", // Dev only + ], + AllowedMethods: ["GET", "PUT", "HEAD"], + AllowedHeaders: ["Content-Type", "Content-Length"], + ExposeHeaders: ["ETag"], + MaxAgeSeconds: 3600, + }, + ]; + + // Apply CORS configuration via R2 API + await r2.putBucketCors({ + Bucket: BUCKET_NAME, + CORSConfiguration: { CORSRules: corsRules }, + }); +} +``` + +## Security Considerations + +### 1. Presigned URL Security + +```typescript +// Secure presigned URL generation +export async function generateSecurePresignedUrl( + gistId: string, + operation: "PUT" | "GET" +): Promise { + // Add security headers + const metadata = { + "x-amz-server-side-encryption": "AES256", + "x-amz-meta-gist-id": gistId, + "x-amz-meta-timestamp": Date.now().toString(), + }; + + // Short expiration for security + const expiresIn = operation === "PUT" ? 300 : 3600; // 5 min for upload, 1 hour for download + + // Include IP restriction if needed + const conditions = [ + ["content-length-range", 0, MAX_FILE_SIZE], + ["starts-with", "$Content-Type", "application/octet-stream"], + ]; + + return getSignedUrl(s3Client, command, { + expiresIn, + signableHeaders: new Set(["host"]), + signingRegion: "auto", + }); +} +``` + +### 2. Access Control + +```typescript +// Validate access before generating URLs +export async function validateAccess( + request: Request, + gistId: string +): Promise { + // Check rate limits + const ip = request.headers.get("CF-Connecting-IP"); + if (await isRateLimited(ip)) { + return false; + } + + // Verify gist exists and check permissions + const metadata = await getGistMetadata(gistId); + if (!metadata) { + return false; + } + + // Check one-time view restrictions + if (metadata.one_time_view && metadata.viewed) { + return false; + } + + return true; +} +``` + +## Performance Optimization + +### 1. Multipart Upload for Large Files + +```typescript +export async function multipartUpload( + file: Uint8Array, + gistId: string +): Promise { + const PART_SIZE = 5 * 1024 * 1024; // 5MB parts + const parts = Math.ceil(file.length / PART_SIZE); + + // Initiate multipart upload + const { uploadId } = await initiateMultipartUpload(gistId); + + // Upload parts in parallel + const uploadPromises = []; + for (let i = 0; i < parts; i++) { + const start = i * PART_SIZE; + const end = Math.min(start + PART_SIZE, file.length); + const part = file.slice(start, end); + + uploadPromises.push(uploadPart(gistId, uploadId, i + 1, part)); + } + + const uploadedParts = await Promise.all(uploadPromises); + + // Complete multipart upload + await completeMultipartUpload(gistId, uploadId, uploadedParts); +} +``` + +### 2. Download Optimization + +```typescript +// Support range requests for large files +export async function generateRangeDownloadUrl( + gistId: string, + start?: number, + end?: number +): Promise { + const command = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: `gists/${gistId}/encrypted.bin`, + Range: start && end ? `bytes=${start}-${end}` : undefined, + }); + + return getSignedUrl(s3Client, command, { + expiresIn: 3600, + }); +} +``` + +## Error Handling + +```typescript +export class R2UploadError extends Error { + constructor( + message: string, + public statusCode: number, + public details?: any + ) { + super(message); + this.name = "R2UploadError"; + } +} + +export async function handleR2Upload( + blob: Uint8Array, + gistId: string +): Promise { + try { + await uploadDirectToR2(blob, gistId); + } catch (error) { + if (error instanceof Response) { + throw new R2UploadError( + "Upload failed", + error.status, + await error.text() + ); + } + + // Fallback to Worker proxy on direct upload failure + console.warn("Direct upload failed, falling back to proxy"); + await uploadViaWorker(blob, gistId); + } +} +``` + +## Migration Strategy + +### Phase 1: Dual Mode + +```typescript +// Feature flag for gradual rollout +const useDirectUpload = await isFeatureEnabled("direct-r2-upload"); + +if (useDirectUpload) { + await uploadDirectToR2(blob, gistId); +} else { + await uploadViaWorker(blob, gistId); +} +``` + +### Phase 2: Monitor & Optimize + +- Track upload success rates +- Monitor latency improvements +- Adjust timeout and retry logic + +### Phase 3: Full Migration + +- Enable for all users +- Remove Worker proxy code +- Update documentation + +## Cost Analysis + +### Current (Worker Proxy) + +- Worker requests: $0.15/million +- Worker CPU time: ~50ms per upload +- Bandwidth through Worker: Counted + +### Direct Upload + +- Worker requests: $0.15/million (only for URL generation) +- Worker CPU time: ~5ms (minimal) +- Bandwidth: Direct to R2 (not counted) +- **Estimated savings: 70-80% on Worker costs** + +## Limitations + +1. **CORS Issues**: Must be properly configured +2. **URL Exposure**: Presigned URLs expose account/bucket info +3. **No Middleware**: Can't process data during upload +4. **Browser Compatibility**: Requires modern browser with fetch API + +## Recommendations for GhostPaste + +1. **Implement Direct Upload** for better performance +2. **Keep Worker Validation** for security +3. **Use Short Expiration** (5 minutes for uploads) +4. **Monitor Usage** to prevent abuse +5. **Implement Fallback** to Worker proxy +6. **Add Progress Tracking** for better UX + +## Example Integration + +```typescript +// High-level API for GhostPaste +export async function createGist( + files: File[], + options: GistOptions +): Promise<{ gistId: string; shareUrl: string }> { + // 1. Encrypt files locally + const { encryptedBlob, key } = await encryptFiles(files); + + // 2. Generate gist ID + const gistId = generateGistId(); + + // 3. Upload directly to R2 + await uploadDirectToR2(encryptedBlob, gistId); + + // 4. Save metadata via Worker + await saveGistMetadata(gistId, { + ...options, + size: encryptedBlob.length, + created: new Date(), + }); + + // 5. Generate shareable URL + const shareUrl = await generateShareableUrl(gistId, key); + + return { gistId, shareUrl }; +} +``` + +This architecture provides the best balance of performance, security, and user experience for GhostPaste's encrypted file sharing needs. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..8abad38 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,247 @@ +# πŸ”’ Security Best Practices + +This guide outlines security best practices for developing and using GhostPaste. + +## For Developers + +### 1. Key Management + +#### βœ… DO + +```typescript +// Keep keys in memory only +const key = await generateEncryptionKey(); + +// Share keys via URL fragments +const url = `https://ghostpaste.dev/g/${id}#key=${key}`; + +// Use crypto.getRandomValues for all randomness +const iv = crypto.getRandomValues(new Uint8Array(12)); +``` + +#### ❌ DON'T + +```typescript +// Never log keys +console.log("Key:", key); // NEVER DO THIS + +// Never send keys to server +await fetch("/api/log", { body: JSON.stringify({ key }) }); // NEVER + +// Never use Math.random for crypto +const badIv = Array.from({ length: 12 }, () => Math.floor(Math.random() * 256)); // INSECURE +``` + +### 2. Error Handling + +#### βœ… DO + +```typescript +try { + await decrypt(data, key); +} catch (error) { + // Generic error message + throw new Error("Decryption failed"); +} +``` + +#### ❌ DON'T + +```typescript +try { + await decrypt(data, key); +} catch (error) { + // Don't leak sensitive info + throw new Error(`Failed with key ${key}: ${error.message}`); // LEAKS KEY +} +``` + +### 3. Input Validation + +#### βœ… DO + +```typescript +// Validate file sizes +if (file.content.length > MAX_FILE_SIZE) { + throw new FileTooLargeError("File exceeds size limit"); +} + +// Validate file counts +if (files.length > MAX_FILES) { + throw new TooManyFilesError("Too many files"); +} + +// Sanitize filenames +const safeName = filename.replace(/[^\w.-]/g, "_"); +``` + +### 4. Secure Defaults + +#### βœ… DO + +```typescript +// Use secure defaults +const DEFAULT_PBKDF2_ITERATIONS = 100_000; +const DEFAULT_KEY_LENGTH = 256; +const DEFAULT_IV_LENGTH = 12; + +// Enforce HTTPS in production +if (window.location.protocol !== "https:" && !isDevelopment) { + window.location.protocol = "https:"; +} +``` + +### 5. Side-Channel Protection + +#### βœ… DO + +```typescript +// Constant-time comparison +function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) { + diff |= a[i] ^ b[i]; + } + return diff === 0; +} +``` + +## For Users + +### 1. URL Sharing + +#### πŸ”’ Secure Channels + +- **Encrypted messaging**: Signal, WhatsApp, Telegram (secret chats) +- **Encrypted email**: ProtonMail, Tutanota +- **In-person**: QR codes, written notes +- **Password managers**: Store URLs in secure vaults + +#### ⚠️ Avoid + +- **Plain email**: Can be intercepted +- **SMS**: Not encrypted, stored by carriers +- **Public forums**: Anyone can access +- **Cloud storage**: Unless encrypted + +### 2. PIN Selection + +#### πŸ’ͺ Strong PINs + +- Mix letters and numbers: `Blue42Sky`, `Cat2024Moon` +- Use phrases: `MyDog$Spot123`, `Coffee@9AM` +- Avoid patterns: Not `1234abcd` or `Pass1234` +- Unique per gist: Don't reuse PINs + +#### 🚫 Weak PINs + +- Sequential: `1234`, `abcd` +- Repeated: `1111`, `aaaa` +- Common words: `password`, `admin` +- Personal info: Birthdays, names + +### 3. Operational Security + +#### Before Sharing + +- βœ… Verify the recipient +- βœ… Check the URL is complete (includes `#key=...`) +- βœ… Consider expiration time +- βœ… Use PIN for sensitive content + +#### After Sharing + +- βœ… Confirm receipt with recipient +- βœ… Delete sensitive URLs from chat history +- βœ… Use one-time view for extra security +- βœ… Monitor access if concerned + +### 4. Browser Security + +#### Keep Secure + +- πŸ”„ Update browser regularly +- πŸ›‘οΈ Use reputable browsers (Chrome, Firefox, Safari, Edge) +- πŸ”’ Check for HTTPS padlock +- 🚫 Avoid browser extensions on sensitive pages + +#### Privacy Mode + +- πŸ•΅οΈ Use incognito/private mode for sensitive gists +- 🧹 Clear browser data after viewing sensitive content +- πŸ“΅ Disable browser sync for GhostPaste + +## Incident Response + +### If a URL is Compromised + +1. **Immediate Actions** + + - URLs with expiration: Wait for expiry + - URLs without expiration: Cannot be revoked + - Change any exposed sensitive data + +2. **Prevention** + - Use one-time view for sensitive data + - Set short expiration times + - Use PIN protection + - Share URLs more carefully next time + +### If You Suspect Tampering + +1. **Signs of Tampering** + + - Decryption fails unexpectedly + - Content doesn't match expectations + - Unexpected error messages + +2. **Response** + - Don't enter PINs on suspicious pages + - Verify URL domain is correct + - Request sender to reshare + - Report suspicious activity + +## Security Checklist + +### For Each Release + +- [ ] Run security tests +- [ ] Check for dependency vulnerabilities (`npm audit`) +- [ ] Review error messages for information leaks +- [ ] Verify CSP headers are strict +- [ ] Test with various browsers +- [ ] Check for console.log statements with sensitive data + +### For Deployment + +- [ ] HTTPS enforced +- [ ] Security headers configured +- [ ] Rate limiting enabled +- [ ] Error logging doesn't include sensitive data +- [ ] Environment variables secured +- [ ] No debug mode in production + +## Reporting Security Issues + +If you discover a security vulnerability: + +1. **DO NOT** create a public issue +2. Email: security@ghostpaste.dev +3. Include: + - Description of vulnerability + - Steps to reproduce + - Potential impact + - Suggested fix (if any) + +We aim to respond within 48 hours and fix critical issues within 7 days. + +## Security Resources + +- [OWASP Cryptographic Storage Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html) +- [MDN Web Crypto API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API) +- [NIST Cryptographic Standards](https://csrc.nist.gov/publications/sp) + +--- + +_Last updated: 2024-06-06_ diff --git a/docs/TODO.md b/docs/TODO.md index ebc2039..f0e28fa 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -94,7 +94,7 @@ This document tracks the implementation progress of GhostPaste. Check off tasks - [x] Create integration tests for encryption workflow - [#39](https://github.com/nullcoder/ghostpaste/issues/39) - [x] Add encryption helper utilities - [#40](https://github.com/nullcoder/ghostpaste/issues/40) -- [ ] Document encryption architecture - [#41](https://github.com/nullcoder/ghostpaste/issues/41) +- [x] Document encryption architecture - [#41](https://github.com/nullcoder/ghostpaste/issues/41) ## 🎨 Phase 4: UI Components diff --git a/lib/auth.ts b/lib/auth.ts index 11fe856..1cff3bb 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -3,6 +3,16 @@ * * This module provides secure PIN hashing and validation for edit protection, * using industry-standard PBKDF2 with SHA-256 for key derivation. + * + * Security properties: + * - PBKDF2 with 100,000 iterations (NIST SP 800-132 recommendation) + * - SHA-256 hash function (collision resistant) + * - 128-bit random salts (prevents rainbow tables) + * - Constant-time comparison (prevents timing attacks) + * - PIN complexity requirements (letters + numbers) + * + * @module auth + * @see {@link https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-132.pdf} */ import { BadRequestError, UnauthorizedError } from "./errors"; diff --git a/lib/binary.ts b/lib/binary.ts index 547b95c..ba48279 100644 --- a/lib/binary.ts +++ b/lib/binary.ts @@ -3,6 +3,24 @@ * * This module provides efficient binary packing/unpacking for multiple files * in a single blob, reducing storage overhead and maintaining data integrity. + * + * Binary Format Structure: + * ``` + * Header (12 bytes): + * β”œβ”€ Magic bytes (4): "GHST" - identifies valid GhostPaste data + * β”œβ”€ Version (2): 0x0001 - format version for future compatibility + * β”œβ”€ File count (2): uint16 - number of files (max 65535) + * └─ Reserved (4): 0x00000000 - reserved for future use + * + * For each file: + * β”œβ”€ Name length (2): uint16 - length of filename in bytes + * β”œβ”€ Name (variable): UTF-8 encoded filename + * β”œβ”€ Content length (4): uint32 - length of file content in bytes + * β”œβ”€ Content (variable): UTF-8 encoded file content + * └─ Language (optional): 1 byte marker + variable length string + * ``` + * + * @module binary */ import { diff --git a/lib/crypto-utils.ts b/lib/crypto-utils.ts index 5dd931c..9ac1e27 100644 --- a/lib/crypto-utils.ts +++ b/lib/crypto-utils.ts @@ -3,6 +3,21 @@ * * This module provides simplified, high-level functions for common encryption * operations, making it easier to use encryption throughout the application. + * + * Key features: + * - Simplified API for encrypting/decrypting gists + * - Automatic key generation and management + * - URL generation with embedded encryption keys + * - PIN protection integration + * - Metadata handling (expiration, one-time view, etc.) + * + * Usage flow: + * 1. Create gist: `createGist(files, options)` β†’ returns encrypted data + URL + * 2. Share URL: Contains gist ID and encryption key in fragment + * 3. Load gist: `loadGistFromUrl(url)` β†’ returns decrypted files + * + * @module crypto-utils + * @see {@link /docs/ENCRYPTION.md} for detailed architecture */ import { diff --git a/lib/crypto.ts b/lib/crypto.ts index 5cd7b5e..d821583 100644 --- a/lib/crypto.ts +++ b/lib/crypto.ts @@ -3,6 +3,15 @@ * * This module provides AES-GCM encryption using the Web Crypto API, * ensuring compatibility with Cloudflare Workers edge runtime. + * + * Security properties: + * - AES-256-GCM provides authenticated encryption (confidentiality + integrity) + * - Each encryption uses a unique IV (initialization vector) + * - Keys are never sent to the server (shared via URL fragments) + * - All operations use constant-time algorithms where possible + * + * @module crypto + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Crypto_API} */ import { InvalidEncryptionKeyError, DecryptionFailedError } from "./errors"; @@ -11,11 +20,16 @@ import { base64UrlEncode, base64UrlDecode } from "./base64"; /** * Encryption algorithm configuration + * + * Using AES-GCM (Galois/Counter Mode) for authenticated encryption: + * - Provides both confidentiality and authenticity + * - Recommended by NIST SP 800-38D + * - Supported by all modern browsers via Web Crypto API */ const ALGORITHM = "AES-GCM"; -const KEY_LENGTH = 256; -const IV_LENGTH = 12; // 96 bits for AES-GCM -const TAG_LENGTH = 128; // 128 bits for AES-GCM auth tag +const KEY_LENGTH = 256; // 256-bit keys (32 bytes) for AES-256 +const IV_LENGTH = 12; // 96 bits (12 bytes) - recommended for AES-GCM +const TAG_LENGTH = 128; // 128 bits (16 bytes) - authentication tag length /** * Encrypted data structure with raw binary data @@ -36,13 +50,19 @@ export type EncryptedBlob = Uint8Array; /** * Generate a new AES-256 encryption key * + * Creates a cryptographically secure 256-bit key using the Web Crypto API. + * Each key is unique and should be used for a single gist only (forward secrecy). + * * @returns Promise A new 256-bit AES key for encryption/decryption + * @throws {InvalidEncryptionKeyError} If key generation fails * * @example * ```typescript * const key = await generateEncryptionKey(); * // Use key for encryption/decryption operations * ``` + * + * @security Keys are marked as extractable to allow sharing via URLs */ export async function generateEncryptionKey(): Promise { try {