diff --git a/RFC-001-OSS-DRM.md b/RFC-001-OSS-DRM.md index f5107b3..e7e8dfd 100644 --- a/RFC-001-OSS-DRM.md +++ b/RFC-001-OSS-DRM.md @@ -11,6 +11,8 @@ | Date | Status | Notes | |------|--------|-------| +| 2026-01-12 | Proposed | **Chunked streaming**: v3 now supports optional ChunkSize for independently decryptable chunks - enables seek, HTTP Range, and decrypt-while-downloading. | +| 2026-01-12 | Proposed | **v3 Streaming**: LTHN rolling keys with configurable cadence (daily/12h/6h/1h). CEK wrapping for zero-trust streaming. WASM v1.3.0 with decryptV3(). | | 2026-01-10 | Proposed | Technical review passed. Fixed section numbering (7.x, 8.x, 9.x, 11.x). Updated WASM size to 5.9MB. Implementation verified complete for stated scope. | --- @@ -142,14 +144,16 @@ Key properties: #### Format Versions -| Format | Payload Structure | Size | Speed | -|--------|------------------|------|-------| -| **v1** | JSON with base64-encoded attachments | +33% overhead | Baseline | -| **v2** | Binary header + raw attachments + zstd | ~Original size | 3-10x faster | +| Format | Payload Structure | Size | Speed | Use Case | +|--------|------------------|------|-------|----------| +| **v1** | JSON with base64-encoded attachments | +33% overhead | Baseline | Legacy | +| **v2** | Binary header + raw attachments + zstd | ~Original size | 3-10x faster | Download-to-own | +| **v3** | CEK + wrapped keys + rolling LTHN | ~Original size | 3-10x faster | **Streaming** | +| **v3+chunked** | v3 with independently decryptable chunks | ~Original size | Seekable | **Chunked streaming** | -v2 is recommended for production. v1 is maintained for backwards compatibility. +v2 is recommended for download-to-own (perpetual license). v3 is recommended for streaming (time-limited access). v3 with chunking is recommended for large files requiring seek capability or decrypt-while-downloading. -### 3.3 Key Derivation +### 3.3 Key Derivation (v1/v2) ``` License Key (password) @@ -168,7 +172,136 @@ Simple, auditable, no key escrow. **Note on password hashing**: SHA-256 is used for simplicity and speed. For high-value content, artists may choose to use stronger KDFs (Argon2, scrypt) in custom implementations. The format supports algorithm negotiation via the header. -### 3.4 Supported Content Types +### 3.4 Streaming Key Derivation (v3) + +v3 format uses **LTHN rolling keys** for zero-trust streaming. The platform controls key refresh cadence. + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ v3 STREAMING KEY FLOW │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ SERVER (encryption time): │ +│ ───────────────────────── │ +│ 1. Generate random CEK (Content Encryption Key) │ +│ 2. Encrypt content with CEK (one-time) │ +│ 3. For current period AND next period: │ +│ streamKey = SHA256(LTHN(period:license:fingerprint)) │ +│ wrappedKey = ChaCha(CEK, streamKey) │ +│ 4. Store wrapped keys in header (CEK never transmitted) │ +│ │ +│ CLIENT (decryption time): │ +│ ──────────────────────── │ +│ 1. Derive streamKey = SHA256(LTHN(period:license:fingerprint)) │ +│ 2. Try to unwrap CEK from current period key │ +│ 3. If fails, try next period key │ +│ 4. Decrypt content with unwrapped CEK │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +#### LTHN Hash Function + +LTHN is rainbow-table resistant because the salt is derived from the input itself: + +``` +LTHN(input) = SHA256(input + reverse_leet(input)) + +where reverse_leet swaps: o↔0, l↔1, e↔3, a↔4, s↔z, t↔7 + +Example: + LTHN("2026-01-12:license:fp") + = SHA256("2026-01-12:license:fp" + "pf:3zn3ci1:21-10-6202") +``` + +You cannot compute the hash without knowing the original input. + +#### Cadence Options + +The platform chooses the key refresh rate. Faster cadence = tighter access control. + +| Cadence | Period Format | Rolling Window | Use Case | +|---------|---------------|----------------|----------| +| `daily` | `2026-01-12` | 24-48 hours | Standard streaming | +| `12h` | `2026-01-12-AM/PM` | 12-24 hours | Premium content | +| `6h` | `2026-01-12-00/06/12/18` | 6-12 hours | High-value content | +| `1h` | `2026-01-12-15` | 1-2 hours | Live events | + +The rolling window ensures smooth key transitions. At any time, both the current period key AND the next period key are valid. + +#### Zero-Trust Properties + +- **Server never stores keys** - Derived on-demand from LTHN +- **Keys auto-expire** - No revocation mechanism needed +- **Sharing keys is pointless** - They expire within the cadence window +- **Fingerprint binds to device** - Different device = different key +- **License ties to user** - Different user = different key + +### 3.5 Chunked Streaming (v3 with ChunkSize) + +When `StreamParams.ChunkSize > 0`, v3 format splits content into independently decryptable chunks, enabling: + +- **Decrypt-while-downloading** - Play media as chunks arrive +- **HTTP Range requests** - Fetch specific chunks by byte offset +- **Seekable playback** - Jump to any position without decrypting previous chunks + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ V3 CHUNKED FORMAT │ +├──────────────────────────────────────────────────────────────────┤ +│ │ +│ Header (cleartext): │ +│ format: "v3" │ +│ chunked: { │ +│ chunkSize: 1048576, // 1MB default │ +│ totalChunks: N, │ +│ totalSize: X, // unencrypted total │ +│ index: [ // for HTTP Range / seeking │ +│ { offset: 0, size: Y }, │ +│ { offset: Y, size: Z }, │ +│ ... │ +│ ] │ +│ } │ +│ wrappedKeys: [...] // same as non-chunked v3 │ +│ │ +│ Payload: │ +│ [chunk 0: nonce + encrypted + tag] │ +│ [chunk 1: nonce + encrypted + tag] │ +│ ... │ +│ [chunk N: nonce + encrypted + tag] │ +│ │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Key insight**: Each chunk is encrypted with the same CEK but gets its own random nonce, making chunks independently decryptable. The chunk index in the header enables: + +1. **Seeking**: Calculate which chunk contains byte offset X, fetch just that chunk +2. **Range requests**: Use HTTP Range headers to fetch specific encrypted chunks +3. **Streaming**: Decrypt chunk 0 for metadata, then stream chunks 1-N as they arrive + +**Usage example**: +```go +params := &StreamParams{ + License: "user-license", + Fingerprint: "device-fp", + ChunkSize: 1024 * 1024, // 1MB chunks +} + +// Encrypt with chunking +encrypted, _ := EncryptV3(msg, params, manifest) + +// For streaming playback: +header, _ := GetV3Header(encrypted) +cek, _ := UnwrapCEKFromHeader(header, params) +payload, _ := GetV3Payload(encrypted) + +for i := 0; i < header.Chunked.TotalChunks; i++ { + chunk, _ := DecryptV3Chunk(payload, cek, i, header.Chunked) + player.Write(chunk) // Stream to audio/video player +} +``` + +### 3.6 Supported Content Types SMSG is content-agnostic. Any file can be an attachment: @@ -479,7 +612,7 @@ Local playback Third-party hosting ## 8. Implementation Status ### 8.1 Completed -- [x] SMSG format specification (v1 and v2) +- [x] SMSG format specification (v1, v2, v3) - [x] Go encryption/decryption library (pkg/smsg) - [x] WASM build for browser (pkg/wasm/stmf) - [x] Native desktop app (Wails, cmd/dapp-fm-app) @@ -491,17 +624,21 @@ Local playback Third-party hosting - [x] **Manifest links** - Artist platform links in metadata - [x] **Live demo** - https://demo.dapp.fm - [x] RFC-quality demo file with cryptographically secure password +- [x] **v3 streaming format** - LTHN rolling keys with CEK wrapping +- [x] **Configurable cadence** - daily/12h/6h/1h key rotation +- [x] **WASM v1.3.0** - `BorgSMSG.decryptV3()` for streaming +- [x] **Chunked streaming** - Independently decryptable chunks for seek/streaming ### 8.2 Fixed Issues - [x] ~~Double base64 encoding bug~~ - Fixed by using binary format - [x] ~~Demo file format detection~~ - v2 format auto-detected via header +- [x] ~~Key wrapping for streaming~~ - Implemented in v3 format ### 8.3 Future Work -- [ ] Chunked streaming (decrypt while downloading) -- [ ] Key wrapping for multi-license files (dapp.radio.fm) +- [ ] Multi-bitrate adaptive streaming (like HLS/DASH but encrypted) - [ ] Payment integration examples (Stripe, Gumroad) - [ ] IPFS distribution guide -- [ ] Expiring license enforcement +- [ ] Demo page "Streaming" tab for v3 showcase ## 9. Usage Examples diff --git a/cmd/extract-demo/main.go b/cmd/extract-demo/main.go new file mode 100644 index 0000000..bfea5ad --- /dev/null +++ b/cmd/extract-demo/main.go @@ -0,0 +1,70 @@ +// extract-demo extracts the video from a v2 SMSG file +package main + +import ( + "encoding/base64" + "fmt" + "os" + + "github.com/Snider/Borg/pkg/smsg" +) + +func main() { + if len(os.Args) < 4 { + fmt.Println("Usage: extract-demo ") + os.Exit(1) + } + + inputFile := os.Args[1] + password := os.Args[2] + outputFile := os.Args[3] + + data, err := os.ReadFile(inputFile) + if err != nil { + fmt.Printf("Failed to read: %v\n", err) + os.Exit(1) + } + + // Get info first + info, err := smsg.GetInfo(data) + if err != nil { + fmt.Printf("Failed to get info: %v\n", err) + os.Exit(1) + } + fmt.Printf("Format: %s, Compression: %s\n", info.Format, info.Compression) + + // Decrypt + msg, err := smsg.Decrypt(data, password) + if err != nil { + fmt.Printf("Failed to decrypt: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Body: %s...\n", msg.Body[:min(50, len(msg.Body))]) + fmt.Printf("Attachments: %d\n", len(msg.Attachments)) + + if len(msg.Attachments) > 0 { + att := msg.Attachments[0] + fmt.Printf(" Name: %s, MIME: %s, Size: %d\n", att.Name, att.MimeType, att.Size) + + // Decode and save + decoded, err := base64.StdEncoding.DecodeString(att.Content) + if err != nil { + fmt.Printf("Failed to decode: %v\n", err) + os.Exit(1) + } + + if err := os.WriteFile(outputFile, decoded, 0644); err != nil { + fmt.Printf("Failed to save: %v\n", err) + os.Exit(1) + } + fmt.Printf("Saved to %s (%d bytes)\n", outputFile, len(decoded)) + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/cmd/mkdemo-v3/main.go b/cmd/mkdemo-v3/main.go new file mode 100644 index 0000000..7ce0991 --- /dev/null +++ b/cmd/mkdemo-v3/main.go @@ -0,0 +1,129 @@ +// mkdemo-v3 creates a v3 chunked SMSG file for streaming demos +package main + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + + "github.com/Snider/Borg/pkg/smsg" +) + +func main() { + if len(os.Args) < 3 { + fmt.Println("Usage: mkdemo-v3 [license] [chunk-size-kb]") + fmt.Println("") + fmt.Println("Creates a v3 chunked SMSG file for streaming demos.") + fmt.Println("V3 uses rolling keys derived from: LTHN(date:license:fingerprint)") + fmt.Println("") + fmt.Println("Options:") + fmt.Println(" license The license key (default: auto-generated)") + fmt.Println(" chunk-size-kb Chunk size in KB (default: 512)") + fmt.Println("") + fmt.Println("Note: V3 files work for 24-48 hours from creation (rolling keys).") + os.Exit(1) + } + + inputFile := os.Args[1] + outputFile := os.Args[2] + + // Read input file + content, err := os.ReadFile(inputFile) + if err != nil { + fmt.Printf("Failed to read input file: %v\n", err) + os.Exit(1) + } + + // License (acts as password in v3) + var license string + if len(os.Args) > 3 { + license = os.Args[3] + } else { + // Generate cryptographically secure license + licenseBytes := make([]byte, 24) + if _, err := rand.Read(licenseBytes); err != nil { + fmt.Printf("Failed to generate license: %v\n", err) + os.Exit(1) + } + license = base64.RawURLEncoding.EncodeToString(licenseBytes) + } + + // Chunk size (default 512KB for good streaming granularity) + chunkSize := 512 * 1024 + if len(os.Args) > 4 { + var chunkKB int + if _, err := fmt.Sscanf(os.Args[4], "%d", &chunkKB); err == nil && chunkKB > 0 { + chunkSize = chunkKB * 1024 + } + } + + // Create manifest + title := filepath.Base(inputFile) + ext := filepath.Ext(title) + if ext != "" { + title = title[:len(title)-len(ext)] + } + manifest := smsg.NewManifest(title) + manifest.LicenseType = "streaming" + manifest.Format = "dapp.fm/v3-chunked" + + // Detect MIME type + mimeType := "video/mp4" + switch ext { + case ".mp3": + mimeType = "audio/mpeg" + case ".wav": + mimeType = "audio/wav" + case ".flac": + mimeType = "audio/flac" + case ".webm": + mimeType = "video/webm" + case ".ogg": + mimeType = "audio/ogg" + } + + // Create message with attachment + msg := smsg.NewMessage("dapp.fm V3 Streaming Demo - Decrypt-while-downloading enabled") + msg.Subject = "V3 Chunked Streaming" + msg.From = "dapp.fm" + msg.AddBinaryAttachment( + filepath.Base(inputFile), + content, + mimeType, + ) + + // Create stream params with chunking enabled + params := &smsg.StreamParams{ + License: license, + Fingerprint: "", // Empty for demo (works for any device) + Cadence: smsg.CadenceDaily, + ChunkSize: chunkSize, + } + + // Encrypt with v3 chunked format + encrypted, err := smsg.EncryptV3(msg, params, manifest) + if err != nil { + fmt.Printf("Failed to encrypt: %v\n", err) + os.Exit(1) + } + + // Write output + if err := os.WriteFile(outputFile, encrypted, 0644); err != nil { + fmt.Printf("Failed to write output: %v\n", err) + os.Exit(1) + } + + // Calculate chunk count + numChunks := (len(content) + chunkSize - 1) / chunkSize + + fmt.Printf("Created: %s (%d bytes)\n", outputFile, len(encrypted)) + fmt.Printf("Format: v3 chunked\n") + fmt.Printf("Chunk Size: %d KB\n", chunkSize/1024) + fmt.Printf("Total Chunks: ~%d\n", numChunks) + fmt.Printf("License: %s\n", license) + fmt.Println("") + fmt.Println("This license works for 24-48 hours from creation.") + fmt.Println("Use the license in the streaming demo to decrypt.") +} diff --git a/demo/demo-track-v3.smsg b/demo/demo-track-v3.smsg new file mode 100644 index 0000000..a7b62f7 Binary files /dev/null and b/demo/demo-track-v3.smsg differ diff --git a/demo/index.html b/demo/index.html index 51574bb..e4a40d6 100644 --- a/demo/index.html +++ b/demo/index.html @@ -983,6 +983,7 @@ +
@@ -1334,6 +1335,151 @@
+ +
+
+
+
+ + +
+
+ +
+

+ 📦 Chunk-by-Chunk Decryption +

+

+ V3 streaming format enables decrypt-while-downloading. Each chunk is independently decryptable + using a single Content Encryption Key (CEK). Perfect for large files and HTTP Range requests. +

+ +
+ + +
+ + + + + + + + +
+ + +
+

+ Seekable Streaming +

+

+ Jump to any position without decrypting previous chunks. The chunk index in the header + enables O(1) seeking to any byte offset. +

+ +
+ +
+ + +
+
+ + +
+
+
+ + + +
+
+
@@ -1384,11 +1530,17 @@
-
+ - +
+
+
▶️
+
Click to play
+
+
+
It Feels So Good
The Conductor & The Cowboy's Amnesia Mix
@@ -1474,8 +1626,7 @@ document.getElementById('demo-btn').disabled = false; // Enable Re-Key button if pre-filled values exist if (typeof checkRekeyReady === 'function') checkRekeyReady(); - // Auto-load profile video since it's the default view - if (typeof loadProfileVideo === 'function') loadProfileVideo(); + // Profile play button is ready - click handler already attached } catch (err) { statusEl.className = 'status error'; while (statusEl.firstChild) statusEl.removeChild(statusEl.firstChild); @@ -1557,27 +1708,18 @@ position += chunk.length; } - // Convert to base64 in chunks to avoid stack overflow - let base64 = ''; - const chunkSize = 32768; - for (let i = 0; i < allChunks.length; i += chunkSize) { - const chunk = allChunks.subarray(i, Math.min(i + chunkSize, allChunks.length)); - base64 += String.fromCharCode.apply(null, chunk); - } - base64 = btoa(base64); - - // Get manifest first + // Get manifest first (binary API) setProgress(60, 'Reading metadata...'); try { - const info = await BorgSMSG.getInfo(base64); + const info = await BorgSMSG.getInfoBinary(allChunks); manifest = info.manifest; } catch (e) { console.log('No manifest:', e); } - // Decrypt using streaming API (returns Uint8Array directly) - setProgress(70, 'Decrypting with ChaCha20-Poly1305...'); - const msg = await BorgSMSG.decryptStream(base64, DEMO_PASSWORD); + // Decrypt using binary API - no base64, pure zstd speed! + setProgress(70, 'Decrypting (zstd)...'); + const msg = await BorgSMSG.decryptBinary(allChunks, DEMO_PASSWORD); setProgress(95, 'Preparing player...'); displayMedia(msg); @@ -1812,29 +1954,21 @@ const arrayBuffer = await licenseFile.arrayBuffer(); const bytes = new Uint8Array(arrayBuffer); - setLicenseProgress(40, 'Preparing for decryption...'); + setLicenseProgress(40, 'Reading metadata...'); - // Convert to base64 in chunks - let base64 = ''; - const chunkSize = 32768; - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)); - base64 += String.fromCharCode.apply(null, chunk); - } - base64 = btoa(base64); - - setLicenseProgress(60, 'Decrypting with ChaCha20-Poly1305...'); - - // Get manifest + // Get manifest (binary API) let licenseManifest = null; try { - const info = await BorgSMSG.getInfo(base64); + const info = await BorgSMSG.getInfoBinary(bytes); licenseManifest = info.manifest; } catch (e) { console.log('No manifest:', e); } - const msg = await BorgSMSG.decryptStream(base64, password); + setLicenseProgress(60, 'Decrypting (zstd)...'); + + // Decrypt using binary API - no base64, pure zstd speed! + const msg = await BorgSMSG.decryptBinary(bytes, password); setLicenseProgress(90, 'Preparing player...'); displayLicensedMedia(msg, licenseManifest); @@ -1957,71 +2091,70 @@ document.querySelectorAll('.mode-view').forEach(v => v.classList.remove('active')); document.getElementById(mode + '-view').classList.add('active'); - // Auto-load profile video when switching to profile mode - if (mode === 'profile' && !profileLoaded && wasmReady) { - loadProfileVideo(); + // Profile play button is always visible - no setup needed + + // Initialize streaming demo when switching to streaming mode + if (mode === 'streaming' && wasmReady && !streamingHeaderInfo) { + initStreamingDemo(); } }); }); // ========== PROFILE MODE ========== - async function loadProfileVideo() { - if (profileLoaded) return; - - const loading = document.getElementById('profile-loading'); + // Play button click handler - loads WASM and video only when clicked + document.getElementById('profile-play-btn').addEventListener('click', async function() { + const playBtn = this; const video = document.getElementById('profile-video'); + // Show loading state + playBtn.innerHTML = '
Loading WASM...
'; + try { + // Load WASM if not ready + if (!wasmReady) { + await initWasm(); + } + + playBtn.querySelector('div > div:last-child').textContent = 'Downloading...'; + // Fetch encrypted content const response = await fetch(DEMO_URL); if (!response.ok) throw new Error('Demo file not found'); + playBtn.querySelector('div > div:last-child').textContent = 'Decrypting...'; + const buffer = await response.arrayBuffer(); const bytes = new Uint8Array(buffer); - // Convert to base64 - let base64 = ''; - const chunkSize = 32768; - for (let i = 0; i < bytes.length; i += chunkSize) { - const chunk = bytes.subarray(i, Math.min(i + chunkSize, bytes.length)); - base64 += String.fromCharCode.apply(null, chunk); - } - base64 = btoa(base64); + // Decrypt directly from binary + const msg = await BorgSMSG.decryptBinary(bytes, DEMO_PASSWORD); - // Decrypt - const msg = await BorgSMSG.decryptStream(base64, DEMO_PASSWORD); - - // Display video if (msg.attachments && msg.attachments.length > 0) { const att = msg.attachments[0]; if (att.mime.startsWith('video/')) { const blob = new Blob([att.data], { type: att.mime }); - const url = URL.createObjectURL(blob); - video.src = url; + const videoUrl = URL.createObjectURL(blob); // Start at specific timestamp for emotional impact const PROFILE_START_TIME = 68; // 1:08 - the drop + + video.src = videoUrl; video.addEventListener('loadedmetadata', () => { video.currentTime = PROFILE_START_TIME; }, { once: true }); - // Fade in smoothly when video can play + // Hide play button, show video, and play video.addEventListener('canplay', () => { - loading.style.opacity = '0'; - loading.style.transition = 'opacity 0.5s'; + playBtn.style.display = 'none'; video.style.opacity = '1'; - setTimeout(() => { - loading.style.display = 'none'; - }, 500); + video.play(); }, { once: true }); - - profileLoaded = true; } } } catch (err) { - loading.innerHTML = '
Failed to load: ' + err.message + '
'; + playBtn.innerHTML = '
Failed: ' + err.message + '
'; } - } + }, { once: true }); // ========== ARTIST MODE ========== let artistContentData = null; @@ -2537,8 +2670,352 @@ setTimeout(() => btn.textContent = 'Copy', 1500); }); - // Init - initWasm(); + // ========== STREAMING MODE ========== + // Uses v3 chunked format with chunk-by-chunk decryption + const STREAMING_V3_URL = 'demo-track-v3.smsg'; // V3 chunked file + const STREAMING_LICENSE = '6ZhMQ034bT6maHqMaJejoxDMpfaOQvq5'; + let streamingHeaderInfo = null; + let streamingCEK = null; + let streamingFileBytes = null; + + // Tab switching for streaming mode + document.querySelectorAll('[data-streaming-tab]').forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.streamingTab; + document.querySelectorAll('[data-streaming-tab]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('#streaming-chunked, #streaming-seek').forEach(p => p.classList.remove('active')); + document.getElementById('streaming-' + tab).classList.add('active'); + }); + }); + + async function initStreamingDemo() { + if (!wasmReady) return; + + document.getElementById('streaming-license').value = STREAMING_LICENSE; + + // Chunk grid will be populated when we know the actual chunk count + const progressDiv = document.getElementById('streaming-progress'); + progressDiv.style.display = 'block'; + document.getElementById('streaming-progress-bar').style.width = '0%'; + document.getElementById('streaming-progress-text').textContent = 'Ready'; + document.getElementById('stat-total-chunks').textContent = '-'; + document.getElementById('stat-chunk-size').textContent = '-'; + document.getElementById('stat-decrypted').textContent = '0 B'; + document.getElementById('stat-total-size').textContent = '-'; + + document.getElementById('streaming-start-btn').disabled = false; + document.getElementById('seek-chunk-btn').disabled = false; + + // Update sidebar for v3 + document.getElementById('streaming-file-info').style.display = 'block'; + document.getElementById('info-format').textContent = 'v3 (chunked)'; + document.getElementById('info-cadence').textContent = 'daily'; + document.getElementById('info-chunked').textContent = 'Yes'; + } + + // Chunk-by-chunk streaming decryption + document.getElementById('streaming-start-btn').addEventListener('click', async () => { + const license = document.getElementById('streaming-license').value; + if (!license) return; + + const playerDiv = document.getElementById('streaming-player'); + const wrapper = document.getElementById('streaming-media-wrapper'); + const chunksDiv = document.getElementById('streaming-chunks'); + + // Show player + playerDiv.style.display = 'block'; + wrapper.innerHTML = ` +
+ +
+
+
Fetching header...
+
+
+
+
0%
+
+
+
+ `; + + const updateStatus = (status) => { + document.getElementById('buffer-status').textContent = status; + }; + + const updateProgress = (percent) => { + document.getElementById('streaming-progress-bar').style.width = percent + '%'; + document.getElementById('streaming-progress-text').textContent = Math.round(percent) + '%'; + document.getElementById('buffer-bar').style.width = percent + '%'; + document.getElementById('buffer-text').textContent = Math.round(percent) + '%'; + }; + + const markChunk = (idx, state) => { + const el = document.getElementById('chunk-' + idx); + if (!el) return; + if (state === 'done') { + el.style.background = 'linear-gradient(135deg, #ff006e, #8338ec)'; + el.style.color = '#fff'; + } else if (state === 'active') { + el.style.background = '#ff006e'; + el.style.color = '#fff'; + } + }; + + try { + // Stream download with MediaSource - play video as chunks arrive + updateStatus('Connecting...'); + const response = await fetch(STREAMING_V3_URL); + if (!response.ok) throw new Error('V3 file not found'); + + const contentLength = parseInt(response.headers.get('content-length') || '0'); + const reader = response.body.getReader(); + + // Accumulate bytes as they stream in + let receivedBytes = new Uint8Array(contentLength || 50 * 1024 * 1024); + let receivedLength = 0; + let headerParsed = false; + let numChunks = 0; + let lastDecryptedChunk = -1; + let decryptedBytes = 0; + + // Collect decrypted chunks for blob URL (works with any MP4) + const decryptedChunks = []; + let videoDataOffset = 0; + let mimeType = 'video/mp4'; + + // Read stream + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + // Append to buffer + if (receivedLength + value.length > receivedBytes.length) { + const newBuffer = new Uint8Array(receivedBytes.length * 2); + newBuffer.set(receivedBytes); + receivedBytes = newBuffer; + } + receivedBytes.set(value, receivedLength); + receivedLength += value.length; + + updateStatus(`Downloading... ${formatBytes(receivedLength)}`); + + // Try to parse header once we have enough data + if (!headerParsed && receivedLength > 5000) { + try { + streamingFileBytes = receivedBytes.subarray(0, receivedLength); + streamingHeaderInfo = await BorgSMSG.parseV3Header(streamingFileBytes); + + if (streamingHeaderInfo.chunked) { + headerParsed = true; + numChunks = streamingHeaderInfo.chunked.totalChunks; + + // Update UI + document.getElementById('stat-total-chunks').textContent = numChunks; + document.getElementById('stat-chunk-size').textContent = formatBytes(streamingHeaderInfo.chunked.chunkSize); + document.getElementById('stat-total-size').textContent = formatBytes(streamingHeaderInfo.chunked.totalSize); + + // Create chunk grid + chunksDiv.innerHTML = ''; + for (let i = 0; i < numChunks; i++) { + const chunkEl = document.createElement('div'); + chunkEl.style.cssText = 'width: 24px; height: 24px; border-radius: 4px; background: rgba(255,255,255,0.1); display: flex; align-items: center; justify-content: center; font-size: 0.6rem; color: #666; transition: all 0.15s;'; + chunkEl.textContent = i + 1; + chunkEl.id = 'chunk-' + i; + chunksDiv.appendChild(chunkEl); + } + + // Unwrap CEK + updateStatus('Unwrapping key...'); + streamingCEK = await BorgSMSG.unwrapCEKFromHeader( + streamingHeaderInfo.wrappedKeys, + { license: license, fingerprint: '' }, + streamingHeaderInfo.cadence + ); + } + } catch (e) { + // Not enough data yet, keep downloading + } + } + + // Decrypt chunks as they become available + if (headerParsed && streamingCEK) { + for (let i = lastDecryptedChunk + 1; i < numChunks; i++) { + const chunkInfo = streamingHeaderInfo.chunked.index[i]; + const chunkStart = streamingHeaderInfo.payloadOffset + chunkInfo.offset; + const chunkEnd = chunkStart + chunkInfo.size; + + // Check if we have enough data for this chunk + if (receivedLength >= chunkEnd) { + markChunk(i, 'active'); + const chunkBytes = receivedBytes.subarray(chunkStart, chunkEnd); + + // Decrypt this chunk + const decrypted = await BorgSMSG.decryptChunkDirect(chunkBytes, streamingCEK); + decryptedBytes += decrypted.length; + lastDecryptedChunk = i; + + // First chunk: parse metadata to get MIME type + if (i === 0) { + for (let j = 0; j < Math.min(decrypted.length, 10000); j++) { + if (decrypted[j] === 0x7D) { + try { + const meta = JSON.parse(new TextDecoder().decode(decrypted.subarray(0, j + 1))); + videoDataOffset = j + 1; + if (meta.attachments && meta.attachments[0]) { + mimeType = meta.attachments[0].mime || 'video/mp4'; + } + break; + } catch (e) {} + } + } + } + + // Store decrypted chunk + decryptedChunks.push(decrypted); + + markChunk(i, 'done'); + updateProgress((i + 1) / numChunks * 100); + document.getElementById('stat-decrypted').textContent = formatBytes(decryptedBytes); + updateStatus(`Chunk ${i + 1}/${numChunks} decrypted`); + } else { + break; // Wait for more data + } + } + } + } + + // Final trim + streamingFileBytes = receivedBytes.subarray(0, receivedLength); + + // Decrypt any remaining chunks + if (headerParsed && streamingCEK) { + for (let i = lastDecryptedChunk + 1; i < numChunks; i++) { + markChunk(i, 'active'); + const chunkInfo = streamingHeaderInfo.chunked.index[i]; + const chunkStart = streamingHeaderInfo.payloadOffset + chunkInfo.offset; + const chunkEnd = chunkStart + chunkInfo.size; + const chunkBytes = streamingFileBytes.subarray(chunkStart, chunkEnd); + + const decrypted = await BorgSMSG.decryptChunkDirect(chunkBytes, streamingCEK); + decryptedBytes += decrypted.length; + decryptedChunks.push(decrypted); + + markChunk(i, 'done'); + updateProgress((i + 1) / numChunks * 100); + document.getElementById('stat-decrypted').textContent = formatBytes(decryptedBytes); + } + } + + // Combine decrypted chunks and create video blob + updateStatus('Assembling video...'); + const totalSize = decryptedChunks.reduce((sum, c) => sum + c.length, 0); + const combined = new Uint8Array(totalSize); + let offset = 0; + for (const chunk of decryptedChunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + + // Extract video data (skip JSON metadata in first chunk) + const videoData = combined.subarray(videoDataOffset); + const blob = new Blob([videoData], { type: mimeType }); + const url = URL.createObjectURL(blob); + + const video = document.getElementById('streaming-video'); + video.src = url; + video.style.opacity = '1'; + document.getElementById('buffer-overlay').style.display = 'none'; + + updateProgress(100); + updateStatus('Ready to play'); + document.getElementById('streaming-complete-banner').style.display = 'block'; + + } catch (err) { + console.error('Streaming failed:', err); + document.getElementById('buffer-status').textContent = 'Error: ' + err.message; + document.getElementById('buffer-status').style.color = '#ff5252'; + } + }); + + // Seek to specific chunk - uses v3 chunked format + document.getElementById('seek-chunk-btn').addEventListener('click', async () => { + const chunkNum = parseInt(document.getElementById('seek-chunk-input').value) || 0; + const license = document.getElementById('streaming-license').value; + + if (!license) return; + + try { + // Load file if not already loaded + if (!streamingFileBytes) { + const response = await fetch(STREAMING_V3_URL); + if (!response.ok) throw new Error('V3 file not found'); + streamingFileBytes = new Uint8Array(await response.arrayBuffer()); + } + + if (!streamingHeaderInfo) { + streamingHeaderInfo = await BorgSMSG.parseV3Header(streamingFileBytes); + } + + if (!streamingHeaderInfo.chunked) { + throw new Error('Not a chunked file'); + } + + if (chunkNum >= streamingHeaderInfo.chunked.totalChunks) { + throw new Error(`Chunk ${chunkNum} out of range (max: ${streamingHeaderInfo.chunked.totalChunks - 1})`); + } + + if (!streamingCEK) { + streamingCEK = await BorgSMSG.unwrapCEKFromHeader( + streamingHeaderInfo.wrappedKeys, + { license: license, fingerprint: '' }, + streamingHeaderInfo.cadence + ); + } + + // Decrypt just this one chunk + const chunkInfo = streamingHeaderInfo.chunked.index[chunkNum]; + const chunkStart = streamingHeaderInfo.payloadOffset + chunkInfo.offset; + const chunkEnd = chunkStart + chunkInfo.size; + const chunkBytes = streamingFileBytes.subarray(chunkStart, chunkEnd); + + const decrypted = await BorgSMSG.decryptChunkDirect(chunkBytes, streamingCEK); + + // Show hex output + const hexOutput = Array.from(decrypted.slice(0, 100)) + .map(b => b.toString(16).padStart(2, '0')) + .join(' '); + + document.getElementById('seek-result').style.display = 'block'; + document.getElementById('seek-chunk-num').textContent = chunkNum; + document.getElementById('seek-hex-output').textContent = hexOutput || '(empty)'; + document.getElementById('seek-bytes-count').textContent = decrypted.length; + document.getElementById('seek-chunk-offset').textContent = 'offset ' + chunkInfo.offset; + + } catch (err) { + console.error('Seek failed:', err); + document.getElementById('seek-result').style.display = 'block'; + document.getElementById('seek-hex-output').textContent = 'Error: ' + err.message; + } + }); + + function formatBytes(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; + } + + // Initialize streaming demo when switching to that mode + document.querySelectorAll('.mode-btn').forEach(btn => { + btn.addEventListener('click', () => { + if (btn.dataset.mode === 'streaming' && wasmReady && !streamingHeaderInfo) { + initStreamingDemo(); + } + }); + }); + + // WASM loads lazily on first interaction - no auto-init diff --git a/demo/stmf.wasm b/demo/stmf.wasm index 7862a10..4ec6e44 100755 Binary files a/demo/stmf.wasm and b/demo/stmf.wasm differ diff --git a/pkg/smsg/stream.go b/pkg/smsg/stream.go index ad39df4..b0ad28f 100644 --- a/pkg/smsg/stream.go +++ b/pkg/smsg/stream.go @@ -39,6 +39,7 @@ type StreamParams struct { License string // User's license identifier Fingerprint string // Device/session fingerprint Cadence Cadence // Key rotation cadence (default: daily) + ChunkSize int // Optional: chunk size for decrypt-while-downloading (0 = no chunking) } // DeriveStreamKey derives a 32-byte ChaCha key from date, license, and fingerprint. @@ -180,6 +181,9 @@ func GenerateCEK() ([]byte, error) { // EncryptV3 encrypts a message using v3 streaming format with rolling keys. // The content is encrypted with a random CEK, which is then wrapped with // stream keys for today and tomorrow. +// +// When params.ChunkSize > 0, content is split into independently decryptable +// chunks, enabling decrypt-while-downloading and seeking. func EncryptV3(msg *Message, params *StreamParams, manifest *Manifest) ([]byte, error) { if params == nil || params.License == "" { return nil, ErrLicenseRequired @@ -222,6 +226,17 @@ func EncryptV3(msg *Message, params *StreamParams, manifest *Manifest) ([]byte, return nil, fmt.Errorf("failed to wrap CEK for next period: %w", err) } + // Check if chunked mode requested + if params.ChunkSize > 0 { + return encryptV3Chunked(msg, params, manifest, cek, cadence, current, next, wrappedCurrent, wrappedNext) + } + + // Non-chunked v3 (original behavior) + return encryptV3Standard(msg, params, manifest, cek, cadence, current, next, wrappedCurrent, wrappedNext) +} + +// encryptV3Standard encrypts as a single block (original v3 behavior) +func encryptV3Standard(msg *Message, params *StreamParams, manifest *Manifest, cek []byte, cadence Cadence, current, next, wrappedCurrent, wrappedNext string) ([]byte, error) { // Build v3 payload (similar to v2 but encrypted with CEK) payload, attachmentData, err := buildV3Payload(msg) if err != nil { @@ -310,8 +325,108 @@ func EncryptV3(msg *Message, params *StreamParams, manifest *Manifest) ([]byte, return trix.Encode(t, Magic, nil) } +// encryptV3Chunked encrypts content into independently decryptable chunks +func encryptV3Chunked(msg *Message, params *StreamParams, manifest *Manifest, cek []byte, cadence Cadence, current, next, wrappedCurrent, wrappedNext string) ([]byte, error) { + chunkSize := params.ChunkSize + + // Build raw content to chunk: metadata JSON + binary attachments + metaJSON, attachmentData, err := buildV3Payload(msg) + if err != nil { + return nil, err + } + + // Combine into single byte slice for chunking + rawContent := append(metaJSON, attachmentData...) + totalSize := int64(len(rawContent)) + + // Create sigil with CEK for chunk encryption + sigil, err := enchantrix.NewChaChaPolySigil(cek) + if err != nil { + return nil, fmt.Errorf("failed to create sigil: %w", err) + } + + // Encrypt in chunks + var chunks [][]byte + var chunkIndex []ChunkInfo + offset := 0 + + for i := 0; offset < len(rawContent); i++ { + // Determine this chunk's size + end := offset + chunkSize + if end > len(rawContent) { + end = len(rawContent) + } + chunkData := rawContent[offset:end] + + // Encrypt chunk (each gets its own nonce) + encryptedChunk, err := sigil.In(chunkData) + if err != nil { + return nil, fmt.Errorf("failed to encrypt chunk %d: %w", i, err) + } + + chunks = append(chunks, encryptedChunk) + chunkIndex = append(chunkIndex, ChunkInfo{ + Offset: 0, // Will be calculated after we know all sizes + Size: len(encryptedChunk), + }) + + offset = end + } + + // Calculate chunk offsets + currentOffset := 0 + for i := range chunkIndex { + chunkIndex[i].Offset = currentOffset + currentOffset += chunkIndex[i].Size + } + + // Build header with chunked info + chunkedInfo := &ChunkedInfo{ + ChunkSize: chunkSize, + TotalChunks: len(chunks), + TotalSize: totalSize, + Index: chunkIndex, + } + + headerMap := map[string]interface{}{ + "version": Version, + "algorithm": "chacha20poly1305", + "format": FormatV3, + "compression": CompressionNone, // No compression in chunked mode (per-chunk not supported yet) + "keyMethod": KeyMethodLTHNRolling, + "cadence": string(cadence), + "chunked": chunkedInfo, + "wrappedKeys": []WrappedKey{ + {Date: current, Wrapped: wrappedCurrent}, + {Date: next, Wrapped: wrappedNext}, + }, + } + + if manifest != nil { + if manifest.IssuedAt == 0 { + manifest.IssuedAt = time.Now().Unix() + } + headerMap["manifest"] = manifest + } + + // Concatenate all encrypted chunks + var payload []byte + for _, chunk := range chunks { + payload = append(payload, chunk...) + } + + // Wrap in trix container + t := &trix.Trix{ + Header: headerMap, + Payload: payload, + } + + return trix.Encode(t, Magic, nil) +} + // DecryptV3 decrypts a v3 streaming message using rolling keys. // It tries today's key first, then tomorrow's key. +// Automatically handles both chunked and non-chunked v3 formats. func DecryptV3(data []byte, params *StreamParams) (*Message, *Header, error) { if params == nil || params.License == "" { return nil, nil, ErrLicenseRequired @@ -358,10 +473,19 @@ func DecryptV3(data []byte, params *StreamParams) (*Message, *Header, error) { return nil, &header, err } - // Parse v3 binary payload - payload := t.Payload + // Check if chunked format + if header.Chunked != nil { + return decryptV3Chunked(t.Payload, cek, &header) + } + + // Non-chunked v3 + return decryptV3Standard(t.Payload, cek, &header) +} + +// decryptV3Standard handles non-chunked v3 decryption +func decryptV3Standard(payload []byte, cek []byte, header *Header) (*Message, *Header, error) { if len(payload) < 8 { - return nil, &header, ErrInvalidPayload + return nil, header, ErrInvalidPayload } // Read header length (skip - we already parsed from trix header) @@ -369,7 +493,7 @@ func DecryptV3(data []byte, params *StreamParams) (*Message, *Header, error) { pos := 4 + int(headerLen) if len(payload) < pos+4 { - return nil, &header, ErrInvalidPayload + return nil, header, ErrInvalidPayload } // Read encrypted payload length @@ -377,7 +501,7 @@ func DecryptV3(data []byte, params *StreamParams) (*Message, *Header, error) { pos += 4 if len(payload) < pos+int(encryptedLen) { - return nil, &header, ErrInvalidPayload + return nil, header, ErrInvalidPayload } // Extract encrypted payload and attachments @@ -387,12 +511,12 @@ func DecryptV3(data []byte, params *StreamParams) (*Message, *Header, error) { // Decrypt with CEK sigil, err := enchantrix.NewChaChaPolySigil(cek) if err != nil { - return nil, &header, fmt.Errorf("failed to create sigil: %w", err) + return nil, header, fmt.Errorf("failed to create sigil: %w", err) } compressed, err := sigil.Out(encryptedPayload) if err != nil { - return nil, &header, ErrDecryptionFailed + return nil, header, ErrDecryptionFailed } // Decompress @@ -400,7 +524,7 @@ func DecryptV3(data []byte, params *StreamParams) (*Message, *Header, error) { if header.Compression == CompressionZstd { decompressed, err = zstdDecompress(compressed) if err != nil { - return nil, &header, fmt.Errorf("decompression failed: %w", err) + return nil, header, fmt.Errorf("decompression failed: %w", err) } } else { decompressed = compressed @@ -409,23 +533,74 @@ func DecryptV3(data []byte, params *StreamParams) (*Message, *Header, error) { // Parse message var msg Message if err := json.Unmarshal(decompressed, &msg); err != nil { - return nil, &header, fmt.Errorf("failed to parse message: %w", err) + return nil, header, fmt.Errorf("failed to parse message: %w", err) } // Decrypt attachments if present if len(encryptedAttachments) > 0 { attachmentData, err := sigil.Out(encryptedAttachments) if err != nil { - return nil, &header, fmt.Errorf("attachment decryption failed: %w", err) + return nil, header, fmt.Errorf("attachment decryption failed: %w", err) } // Restore attachment content from binary data if err := restoreV3Attachments(&msg, attachmentData); err != nil { - return nil, &header, err + return nil, header, err } } - return &msg, &header, nil + return &msg, header, nil +} + +// decryptV3Chunked handles chunked v3 decryption +func decryptV3Chunked(payload []byte, cek []byte, header *Header) (*Message, *Header, error) { + if header.Chunked == nil { + return nil, header, fmt.Errorf("v3 chunked format missing chunked info") + } + + // Create sigil for decryption + sigil, err := enchantrix.NewChaChaPolySigil(cek) + if err != nil { + return nil, header, fmt.Errorf("failed to create sigil: %w", err) + } + + // Decrypt all chunks + var decrypted []byte + + for i, ci := range header.Chunked.Index { + if ci.Offset+ci.Size > len(payload) { + return nil, header, fmt.Errorf("chunk %d out of bounds", i) + } + + chunkData := payload[ci.Offset : ci.Offset+ci.Size] + plaintext, err := sigil.Out(chunkData) + if err != nil { + return nil, header, fmt.Errorf("failed to decrypt chunk %d: %w", i, err) + } + + decrypted = append(decrypted, plaintext...) + } + + // Parse decrypted content (metadata JSON + attachments) + var msg Message + if err := json.Unmarshal(decrypted, &msg); err != nil { + // First part should be JSON, but may be mixed with binary + // Try to find JSON boundary + for i := 0; i < len(decrypted); i++ { + if decrypted[i] == '}' { + if err := json.Unmarshal(decrypted[:i+1], &msg); err == nil { + // Found valid JSON, rest is attachment data + if err := restoreV3Attachments(&msg, decrypted[i+1:]); err != nil { + return nil, header, err + } + return &msg, header, nil + } + } + } + return nil, header, fmt.Errorf("failed to parse message: %w", err) + } + + return &msg, header, nil } // tryUnwrapCEK attempts to unwrap the CEK using current or next period's key @@ -500,3 +675,153 @@ func restoreV3Attachments(msg *Message, data []byte) error { } return nil } + +// ============================================================================= +// V3 Chunked Streaming Helpers +// ============================================================================= +// +// When StreamParams.ChunkSize > 0, v3 format uses independently decryptable +// chunks, enabling: +// - Decrypt-while-downloading: Play media as it arrives +// - HTTP Range requests: Fetch specific chunks by byte range +// - Seekable playback: Jump to any position without decrypting everything +// +// Each chunk is encrypted with the same CEK but has its own nonce, +// making it independently decryptable. + +// DecryptV3Chunk decrypts a single chunk by index. +// This enables streaming playback and seeking without decrypting the entire file. +// +// Usage for streaming: +// +// header, _ := GetV3Header(data) +// cek, _ := UnwrapCEKFromHeader(header, params) +// payload, _ := GetV3Payload(data) +// for i := 0; i < header.Chunked.TotalChunks; i++ { +// chunk, _ := DecryptV3Chunk(payload, cek, i, header.Chunked) +// player.Write(chunk) +// } +func DecryptV3Chunk(payload []byte, cek []byte, chunkIndex int, chunked *ChunkedInfo) ([]byte, error) { + if chunked == nil { + return nil, fmt.Errorf("chunked info is nil") + } + if chunkIndex < 0 || chunkIndex >= len(chunked.Index) { + return nil, fmt.Errorf("chunk index %d out of range [0, %d)", chunkIndex, len(chunked.Index)) + } + + ci := chunked.Index[chunkIndex] + if ci.Offset+ci.Size > len(payload) { + return nil, fmt.Errorf("chunk %d data out of bounds", chunkIndex) + } + + // Create sigil and decrypt + sigil, err := enchantrix.NewChaChaPolySigil(cek) + if err != nil { + return nil, fmt.Errorf("failed to create sigil: %w", err) + } + + chunkData := payload[ci.Offset : ci.Offset+ci.Size] + return sigil.Out(chunkData) +} + +// GetV3Header extracts the header from a v3 file without decrypting. +// Useful for getting chunk index for Range requests. +func GetV3Header(data []byte) (*Header, error) { + t, err := trix.Decode(data, Magic, nil) + if err != nil { + return nil, fmt.Errorf("failed to decode container: %w", err) + } + + headerJSON, err := json.Marshal(t.Header) + if err != nil { + return nil, fmt.Errorf("failed to marshal header: %w", err) + } + + var header Header + if err := json.Unmarshal(headerJSON, &header); err != nil { + return nil, fmt.Errorf("failed to parse header: %w", err) + } + + if header.Format != FormatV3 { + return nil, fmt.Errorf("not a v3 format: %s", header.Format) + } + + return &header, nil +} + +// UnwrapCEKFromHeader unwraps the CEK from a v3 header using stream params. +// Returns the CEK for use with DecryptV3Chunk. +func UnwrapCEKFromHeader(header *Header, params *StreamParams) ([]byte, error) { + if params == nil || params.License == "" { + return nil, ErrLicenseRequired + } + + cadence := header.Cadence + if cadence == "" && params.Cadence != "" { + cadence = params.Cadence + } + if cadence == "" { + cadence = CadenceDaily + } + + return tryUnwrapCEK(header.WrappedKeys, params, cadence) +} + +// GetV3Payload extracts just the payload from a v3 file. +// Use with DecryptV3Chunk for individual chunk decryption. +func GetV3Payload(data []byte) ([]byte, error) { + t, err := trix.Decode(data, Magic, nil) + if err != nil { + return nil, fmt.Errorf("failed to decode container: %w", err) + } + return t.Payload, nil +} + +// GetV3HeaderFromPrefix parses the v3 header from just the file prefix. +// This enables streaming: parse header as soon as first few KB arrive. +// Returns header and payload offset (where encrypted chunks start). +// +// File format: +// - Bytes 0-3: Magic "SMSG" +// - Bytes 4-5: Version (2-byte little endian) +// - Bytes 6-8: Header length (3-byte big endian) +// - Bytes 9+: Header JSON +// - Payload starts at offset 9 + headerLen +func GetV3HeaderFromPrefix(data []byte) (*Header, int, error) { + // Need at least magic + version + header length indicator + if len(data) < 9 { + return nil, 0, fmt.Errorf("need at least 9 bytes, got %d", len(data)) + } + + // Check magic + if string(data[0:4]) != Magic { + return nil, 0, ErrInvalidMagic + } + + // Parse header length (3 bytes big endian at offset 6-8) + headerLen := int(data[6])<<16 | int(data[7])<<8 | int(data[8]) + if headerLen <= 0 || headerLen > 16*1024*1024 { + return nil, 0, fmt.Errorf("invalid header length: %d", headerLen) + } + + // Calculate payload offset + payloadOffset := 9 + headerLen + + // Check if we have enough data for the header + if len(data) < payloadOffset { + return nil, 0, fmt.Errorf("need %d bytes for header, got %d", payloadOffset, len(data)) + } + + // Parse header JSON + headerJSON := data[9:payloadOffset] + var header Header + if err := json.Unmarshal(headerJSON, &header); err != nil { + return nil, 0, fmt.Errorf("failed to parse header JSON: %w", err) + } + + if header.Format != FormatV3 { + return nil, 0, fmt.Errorf("not a v3 format: %s", header.Format) + } + + return &header, payloadOffset, nil +} diff --git a/pkg/smsg/stream_test.go b/pkg/smsg/stream_test.go index 18311b5..67dbd38 100644 --- a/pkg/smsg/stream_test.go +++ b/pkg/smsg/stream_test.go @@ -379,3 +379,299 @@ func TestRollingKeyWindow(t *testing.T) { t.Error("Missing tomorrow's wrapped key") } } + +// ============================================================================= +// V3 Chunked Streaming Tests +// ============================================================================= + +func TestEncryptDecryptV3ChunkedBasic(t *testing.T) { + msg := NewMessage("This is a chunked streaming test message") + msg.WithSubject("Chunked Test") + + params := &StreamParams{ + License: "chunk-license", + Fingerprint: "chunk-fp", + ChunkSize: 64, // Small chunks for testing + } + + manifest := NewManifest("Chunked Track") + manifest.Artist = "Test Artist" + + // Encrypt with chunking + encrypted, err := EncryptV3(msg, params, manifest) + if err != nil { + t.Fatalf("EncryptV3 (chunked) failed: %v", err) + } + + // Decrypt - automatically handles chunked format + decrypted, header, err := DecryptV3(encrypted, params) + if err != nil { + t.Fatalf("DecryptV3 (chunked) failed: %v", err) + } + + // Verify content + if decrypted.Body != msg.Body { + t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body) + } + if decrypted.Subject != msg.Subject { + t.Errorf("Subject = %q, want %q", decrypted.Subject, msg.Subject) + } + + // Verify header + if header.Format != FormatV3 { + t.Errorf("Format = %q, want %q", header.Format, FormatV3) + } + if header.Chunked == nil { + t.Fatal("Chunked info is nil") + } + if header.Chunked.ChunkSize != 64 { + t.Errorf("ChunkSize = %d, want 64", header.Chunked.ChunkSize) + } +} + +func TestV3ChunkedWithAttachment(t *testing.T) { + // Create a message with attachment larger than chunk size + attachmentData := make([]byte, 256) + for i := range attachmentData { + attachmentData[i] = byte(i) + } + + msg := NewMessage("Message with large attachment") + msg.AddBinaryAttachment("test.bin", attachmentData, "application/octet-stream") + + params := &StreamParams{ + License: "attach-license", + Fingerprint: "attach-fp", + ChunkSize: 64, // Force multiple chunks + } + + // Encrypt + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 (chunked) failed: %v", err) + } + + // Verify we have multiple chunks + header, err := GetV3Header(encrypted) + if err != nil { + t.Fatalf("GetV3Header failed: %v", err) + } + + if header.Chunked.TotalChunks <= 1 { + t.Errorf("TotalChunks = %d, want > 1", header.Chunked.TotalChunks) + } + + // Decrypt + decrypted, _, err := DecryptV3(encrypted, params) + if err != nil { + t.Fatalf("DecryptV3 (chunked) failed: %v", err) + } + + // Verify attachment + if len(decrypted.Attachments) != 1 { + t.Fatalf("Attachment count = %d, want 1", len(decrypted.Attachments)) + } +} + +func TestV3ChunkedIndividualChunks(t *testing.T) { + // Create content that spans multiple chunks + largeContent := make([]byte, 200) + for i := range largeContent { + largeContent[i] = byte(i % 256) + } + + msg := NewMessage("Chunk-by-chunk test") + msg.AddBinaryAttachment("data.bin", largeContent, "application/octet-stream") + + params := &StreamParams{ + License: "individual-license", + Fingerprint: "individual-fp", + ChunkSize: 50, // Force ~5 chunks + } + + // Encrypt + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 (chunked) failed: %v", err) + } + + // Get header and payload + header, err := GetV3Header(encrypted) + if err != nil { + t.Fatalf("GetV3Header failed: %v", err) + } + + payload, err := GetV3Payload(encrypted) + if err != nil { + t.Fatalf("GetV3Payload failed: %v", err) + } + + // Unwrap CEK + cek, err := UnwrapCEKFromHeader(header, params) + if err != nil { + t.Fatalf("UnwrapCEKFromHeader failed: %v", err) + } + + // Decrypt each chunk individually + var allDecrypted []byte + for i := 0; i < header.Chunked.TotalChunks; i++ { + chunk, err := DecryptV3Chunk(payload, cek, i, header.Chunked) + if err != nil { + t.Fatalf("DecryptV3Chunk(%d) failed: %v", i, err) + } + allDecrypted = append(allDecrypted, chunk...) + } + + // Verify total size matches + if int64(len(allDecrypted)) != header.Chunked.TotalSize { + t.Errorf("Decrypted size = %d, want %d", len(allDecrypted), header.Chunked.TotalSize) + } +} + +func TestV3ChunkedWrongLicense(t *testing.T) { + msg := NewMessage("Secret chunked content") + + params := &StreamParams{ + License: "correct-chunked-license", + Fingerprint: "device-fp", + ChunkSize: 64, + } + + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 (chunked) failed: %v", err) + } + + // Try to decrypt with wrong license + wrongParams := &StreamParams{ + License: "wrong-chunked-license", + Fingerprint: "device-fp", + } + + _, _, err = DecryptV3(encrypted, wrongParams) + if err == nil { + t.Error("DecryptV3 (chunked) with wrong license should fail") + } + if err != ErrNoValidKey { + t.Errorf("Error = %v, want ErrNoValidKey", err) + } +} + +func TestV3ChunkedChunkIndex(t *testing.T) { + msg := NewMessage("Index test") + msg.AddBinaryAttachment("test.dat", make([]byte, 150), "application/octet-stream") + + params := &StreamParams{ + License: "index-license", + Fingerprint: "index-fp", + ChunkSize: 50, + } + + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 (chunked) failed: %v", err) + } + + header, err := GetV3Header(encrypted) + if err != nil { + t.Fatalf("GetV3Header failed: %v", err) + } + + // Verify index structure + if len(header.Chunked.Index) != header.Chunked.TotalChunks { + t.Errorf("Index length = %d, want %d", len(header.Chunked.Index), header.Chunked.TotalChunks) + } + + // Verify offsets are sequential + expectedOffset := 0 + for i, ci := range header.Chunked.Index { + if ci.Offset != expectedOffset { + t.Errorf("Chunk %d offset = %d, want %d", i, ci.Offset, expectedOffset) + } + expectedOffset += ci.Size + } +} + +func TestV3ChunkedSeekMiddleChunk(t *testing.T) { + // Create predictable data + data := make([]byte, 300) + for i := range data { + data[i] = byte(i % 256) + } + + msg := NewMessage("Seek test") + msg.AddBinaryAttachment("seek.bin", data, "application/octet-stream") + + params := &StreamParams{ + License: "seek-license", + Fingerprint: "seek-fp", + ChunkSize: 100, // 3 data chunks minimum + } + + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 (chunked) failed: %v", err) + } + + header, err := GetV3Header(encrypted) + if err != nil { + t.Fatalf("GetV3Header failed: %v", err) + } + + payload, err := GetV3Payload(encrypted) + if err != nil { + t.Fatalf("GetV3Payload failed: %v", err) + } + + cek, err := UnwrapCEKFromHeader(header, params) + if err != nil { + t.Fatalf("UnwrapCEKFromHeader failed: %v", err) + } + + // Skip to middle chunk (simulate seeking) + if header.Chunked.TotalChunks < 2 { + t.Skip("Need at least 2 chunks for seek test") + } + + middleIdx := header.Chunked.TotalChunks / 2 + chunk, err := DecryptV3Chunk(payload, cek, middleIdx, header.Chunked) + if err != nil { + t.Fatalf("DecryptV3Chunk(%d) failed: %v", middleIdx, err) + } + + // Just verify we got something + if len(chunk) == 0 { + t.Error("Middle chunk is empty") + } +} + +func TestV3NonChunkedStillWorks(t *testing.T) { + // Verify non-chunked v3 still works (ChunkSize = 0) + msg := NewMessage("Non-chunked v3 test") + msg.WithSubject("No Chunks") + + params := &StreamParams{ + License: "non-chunk-license", + Fingerprint: "non-chunk-fp", + // ChunkSize = 0 (default) - no chunking + } + + encrypted, err := EncryptV3(msg, params, nil) + if err != nil { + t.Fatalf("EncryptV3 (non-chunked) failed: %v", err) + } + + decrypted, header, err := DecryptV3(encrypted, params) + if err != nil { + t.Fatalf("DecryptV3 (non-chunked) failed: %v", err) + } + + if decrypted.Body != msg.Body { + t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body) + } + + // Non-chunked should not have Chunked info + if header.Chunked != nil { + t.Error("Non-chunked v3 should not have Chunked info") + } +} diff --git a/pkg/smsg/types.go b/pkg/smsg/types.go index 2b2d45b..c8a99f3 100644 --- a/pkg/smsg/types.go +++ b/pkg/smsg/types.go @@ -289,9 +289,27 @@ func (m *Manifest) AddLink(platform, url string) *Manifest { const ( FormatV1 = "" // Original format: JSON with base64-encoded attachments FormatV2 = "v2" // Binary format: JSON header + raw binary attachments - FormatV3 = "v3" // Streaming format: CEK wrapped with rolling LTHN keys + FormatV3 = "v3" // Streaming format: CEK wrapped with rolling LTHN keys, optional chunking ) +// Default chunk size for v3 chunked format (1MB) +const DefaultChunkSize = 1024 * 1024 + +// ChunkInfo describes a single chunk in v3 chunked format +type ChunkInfo struct { + Offset int `json:"offset"` // byte offset in payload + Size int `json:"size"` // encrypted chunk size (includes nonce + tag) +} + +// ChunkedInfo contains chunking metadata for v3 streaming +// When present, enables decrypt-while-downloading and seeking +type ChunkedInfo struct { + ChunkSize int `json:"chunkSize"` // size of each chunk before encryption + TotalChunks int `json:"totalChunks"` // number of chunks + TotalSize int64 `json:"totalSize"` // total unencrypted size + Index []ChunkInfo `json:"index"` // chunk locations for seeking +} + // Compression types const ( CompressionNone = "" // No compression (default, backwards compatible) @@ -352,4 +370,7 @@ type Header struct { KeyMethod string `json:"keyMethod,omitempty"` // lthn-rolling for v3 Cadence Cadence `json:"cadence,omitempty"` // key rotation frequency (daily, 12h, 6h, 1h) WrappedKeys []WrappedKey `json:"wrappedKeys,omitempty"` // CEK wrapped with rolling keys + + // V3 chunked streaming (optional - enables decrypt-while-downloading) + Chunked *ChunkedInfo `json:"chunked,omitempty"` // chunk index for seeking/range requests } diff --git a/pkg/wasm/stmf/main.go b/pkg/wasm/stmf/main.go index 184ac03..6a54882 100644 --- a/pkg/wasm/stmf/main.go +++ b/pkg/wasm/stmf/main.go @@ -13,10 +13,11 @@ import ( "github.com/Snider/Borg/pkg/smsg" "github.com/Snider/Borg/pkg/stmf" + "github.com/Snider/Enchantrix/pkg/enchantrix" ) // Version of the WASM module -const Version = "1.3.0" +const Version = "1.6.0" func main() { // Export the BorgSTMF object to JavaScript global scope @@ -32,10 +33,18 @@ func main() { js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{ "decrypt": js.FuncOf(smsgDecrypt), "decryptStream": js.FuncOf(smsgDecryptStream), - "decryptV3": js.FuncOf(smsgDecryptV3), // v3 streaming with rolling keys + "decryptBinary": js.FuncOf(smsgDecryptBinary), // v2/v3 binary input (no base64!) + "decryptV3": js.FuncOf(smsgDecryptV3), // v3 streaming with rolling keys + "getV3ChunkInfo": js.FuncOf(smsgGetV3ChunkInfo), // Get chunk index for seeking + "decryptV3Chunk": js.FuncOf(smsgDecryptV3Chunk), // Decrypt single chunk + "unwrapV3CEK": js.FuncOf(smsgUnwrapV3CEK), // Unwrap CEK for chunk decryption + "parseV3Header": js.FuncOf(smsgParseV3Header), // Parse header from bytes, returns header + payloadOffset + "unwrapCEKFromHeader": js.FuncOf(smsgUnwrapCEKFromHeader), // Unwrap CEK from parsed header + "decryptChunkDirect": js.FuncOf(smsgDecryptChunkDirect), // Decrypt raw chunk bytes with CEK "encrypt": js.FuncOf(smsgEncrypt), "encryptWithManifest": js.FuncOf(smsgEncryptWithManifest), "getInfo": js.FuncOf(smsgGetInfo), + "getInfoBinary": js.FuncOf(smsgGetInfoBinary), // Binary input (no base64!) "quickDecrypt": js.FuncOf(smsgQuickDecrypt), "version": Version, "ready": true, @@ -362,6 +371,182 @@ func smsgDecryptStream(this js.Value, args []js.Value) interface{} { return promiseConstructor.New(handler) } +// smsgDecryptBinary decrypts v2/v3 binary data directly from Uint8Array. +// No base64 conversion needed - this is the efficient path for zstd streams. +// JavaScript usage: +// +// const response = await fetch(url); +// const bytes = new Uint8Array(await response.arrayBuffer()); +// const result = await BorgSMSG.decryptBinary(bytes, password); +// const blob = new Blob([result.attachments[0].data], {type: result.attachments[0].mime}); +func smsgDecryptBinary(this js.Value, args []js.Value) interface{} { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + if len(args) < 2 { + reject.Invoke(newError("decryptBinary requires 2 arguments: Uint8Array, password")) + return + } + + // Get binary data directly from Uint8Array + uint8Array := args[0] + length := uint8Array.Get("length").Int() + data := make([]byte, length) + js.CopyBytesToGo(data, uint8Array) + + password := args[1].String() + + // Decrypt directly from binary (no base64 decode!) + msg, err := smsg.Decrypt(data, password) + if err != nil { + reject.Invoke(newError("decryption failed: " + err.Error())) + return + } + + // Build result with binary attachment data + result := map[string]interface{}{ + "body": msg.Body, + "timestamp": msg.Timestamp, + } + + if msg.Subject != "" { + result["subject"] = msg.Subject + } + if msg.From != "" { + result["from"] = msg.From + } + + // Convert attachments with binary data + if len(msg.Attachments) > 0 { + attachments := make([]interface{}, len(msg.Attachments)) + for i, att := range msg.Attachments { + // Decode base64 to binary (internal format still uses base64) + attData, err := base64.StdEncoding.DecodeString(att.Content) + if err != nil { + reject.Invoke(newError("failed to decode attachment: " + err.Error())) + return + } + + // Create Uint8Array in JS + attArray := js.Global().Get("Uint8Array").New(len(attData)) + js.CopyBytesToJS(attArray, attData) + + attachments[i] = map[string]interface{}{ + "name": att.Name, + "mime": att.MimeType, + "size": len(attData), + "data": attArray, + } + } + result["attachments"] = attachments + } + + resolve.Invoke(js.ValueOf(result)) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) +} + +// smsgGetInfoBinary extracts header info from binary Uint8Array without decrypting. +// JavaScript usage: +// +// const bytes = new Uint8Array(await response.arrayBuffer()); +// const info = await BorgSMSG.getInfoBinary(bytes); +// console.log(info.manifest); +func smsgGetInfoBinary(this js.Value, args []js.Value) interface{} { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + if len(args) < 1 { + reject.Invoke(newError("getInfoBinary requires 1 argument: Uint8Array")) + return + } + + // Get binary data directly from Uint8Array + uint8Array := args[0] + length := uint8Array.Get("length").Int() + data := make([]byte, length) + js.CopyBytesToGo(data, uint8Array) + + header, err := smsg.GetInfo(data) + if err != nil { + reject.Invoke(newError("failed to get info: " + err.Error())) + return + } + + result := map[string]interface{}{ + "version": header.Version, + "algorithm": header.Algorithm, + } + if header.Format != "" { + result["format"] = header.Format + } + if header.Compression != "" { + result["compression"] = header.Compression + } + if header.Hint != "" { + result["hint"] = header.Hint + } + + // V3 streaming fields + if header.KeyMethod != "" { + result["keyMethod"] = header.KeyMethod + } + if header.Cadence != "" { + result["cadence"] = string(header.Cadence) + } + if len(header.WrappedKeys) > 0 { + wrappedKeys := make([]interface{}, len(header.WrappedKeys)) + for i, wk := range header.WrappedKeys { + wrappedKeys[i] = map[string]interface{}{ + "date": wk.Date, + } + } + result["wrappedKeys"] = wrappedKeys + result["isV3Streaming"] = true + } + + // V3 chunked streaming fields + if header.Chunked != nil { + index := make([]interface{}, len(header.Chunked.Index)) + for i, ci := range header.Chunked.Index { + index[i] = map[string]interface{}{ + "offset": ci.Offset, + "size": ci.Size, + } + } + result["chunked"] = map[string]interface{}{ + "chunkSize": header.Chunked.ChunkSize, + "totalChunks": header.Chunked.TotalChunks, + "totalSize": header.Chunked.TotalSize, + "index": index, + } + result["isChunked"] = true + } + + // Include manifest if present + if header.Manifest != nil { + result["manifest"] = manifestToJS(header.Manifest) + } + + resolve.Invoke(js.ValueOf(result)) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) +} + // smsgEncrypt encrypts a message with a password. // JavaScript usage: // @@ -515,6 +700,24 @@ func smsgGetInfo(this js.Value, args []js.Value) interface{} { result["isV3Streaming"] = true } + // V3 chunked streaming fields + if header.Chunked != nil { + index := make([]interface{}, len(header.Chunked.Index)) + for i, ci := range header.Chunked.Index { + index[i] = map[string]interface{}{ + "offset": ci.Offset, + "size": ci.Size, + } + } + result["chunked"] = map[string]interface{}{ + "chunkSize": header.Chunked.ChunkSize, + "totalChunks": header.Chunked.TotalChunks, + "totalSize": header.Chunked.TotalSize, + "index": index, + } + result["isChunked"] = true + } + // Include manifest if present if header.Manifest != nil { result["manifest"] = manifestToJS(header.Manifest) @@ -714,10 +917,23 @@ func smsgDecryptV3(this js.Value, args []js.Value) interface{} { // Include header info if header != nil { - result["header"] = map[string]interface{}{ + headerResult := map[string]interface{}{ "format": header.Format, "keyMethod": header.KeyMethod, } + if header.Cadence != "" { + headerResult["cadence"] = string(header.Cadence) + } + // Include chunked info if present + if header.Chunked != nil { + headerResult["isChunked"] = true + headerResult["chunked"] = map[string]interface{}{ + "chunkSize": header.Chunked.ChunkSize, + "totalChunks": header.Chunked.TotalChunks, + "totalSize": header.Chunked.TotalSize, + } + } + result["header"] = headerResult if header.Manifest != nil { result["manifest"] = manifestToJS(header.Manifest) } @@ -903,6 +1119,447 @@ func manifestToJS(m *smsg.Manifest) map[string]interface{} { return result } +// smsgGetV3ChunkInfo extracts chunk information from a v3 file for seeking. +// JavaScript usage: +// +// const info = await BorgSMSG.getV3ChunkInfo(encryptedBase64); +// console.log(info.chunked.totalChunks); +// console.log(info.chunked.index); // [{offset, size}, ...] +func smsgGetV3ChunkInfo(this js.Value, args []js.Value) interface{} { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + if len(args) < 1 { + reject.Invoke(newError("getV3ChunkInfo requires 1 argument: encryptedBase64")) + return + } + + encryptedB64 := args[0].String() + + // Decode base64 + data, err := base64.StdEncoding.DecodeString(encryptedB64) + if err != nil { + reject.Invoke(newError("invalid base64: " + err.Error())) + return + } + + // Get v3 header + header, err := smsg.GetV3Header(data) + if err != nil { + reject.Invoke(newError("failed to get v3 header: " + err.Error())) + return + } + + result := map[string]interface{}{ + "format": header.Format, + "keyMethod": header.KeyMethod, + "cadence": string(header.Cadence), + } + + // Include chunked info if present + if header.Chunked != nil { + index := make([]interface{}, len(header.Chunked.Index)) + for i, ci := range header.Chunked.Index { + index[i] = map[string]interface{}{ + "offset": ci.Offset, + "size": ci.Size, + } + } + + result["chunked"] = map[string]interface{}{ + "chunkSize": header.Chunked.ChunkSize, + "totalChunks": header.Chunked.TotalChunks, + "totalSize": header.Chunked.TotalSize, + "index": index, + } + result["isChunked"] = true + } else { + result["isChunked"] = false + } + + // Include manifest if present + if header.Manifest != nil { + result["manifest"] = manifestToJS(header.Manifest) + } + + resolve.Invoke(js.ValueOf(result)) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) +} + +// smsgUnwrapV3CEK unwraps the Content Encryption Key for chunk-by-chunk decryption. +// JavaScript usage: +// +// const cek = await BorgSMSG.unwrapV3CEK(encryptedBase64, {license, fingerprint}); +// // cek is base64-encoded CEK for use with decryptV3Chunk +func smsgUnwrapV3CEK(this js.Value, args []js.Value) interface{} { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + if len(args) < 2 { + reject.Invoke(newError("unwrapV3CEK requires 2 arguments: encryptedBase64, {license, fingerprint}")) + return + } + + encryptedB64 := args[0].String() + paramsObj := args[1] + + // Extract stream params + license := paramsObj.Get("license").String() + fingerprint := "" + if !paramsObj.Get("fingerprint").IsUndefined() { + fingerprint = paramsObj.Get("fingerprint").String() + } + + if license == "" { + reject.Invoke(newError("license is required")) + return + } + + params := &smsg.StreamParams{ + License: license, + Fingerprint: fingerprint, + } + + // Decode base64 + data, err := base64.StdEncoding.DecodeString(encryptedB64) + if err != nil { + reject.Invoke(newError("invalid base64: " + err.Error())) + return + } + + // Get header + header, err := smsg.GetV3Header(data) + if err != nil { + reject.Invoke(newError("failed to get v3 header: " + err.Error())) + return + } + + // Unwrap CEK + cek, err := smsg.UnwrapCEKFromHeader(header, params) + if err != nil { + reject.Invoke(newError("failed to unwrap CEK: " + err.Error())) + return + } + + // Return CEK as base64 for use with decryptV3Chunk + cekB64 := base64.StdEncoding.EncodeToString(cek) + resolve.Invoke(cekB64) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) +} + +// smsgDecryptV3Chunk decrypts a single chunk by index. +// JavaScript usage: +// +// const info = await BorgSMSG.getV3ChunkInfo(encryptedBase64); +// const cek = await BorgSMSG.unwrapV3CEK(encryptedBase64, {license, fingerprint}); +// for (let i = 0; i < info.chunked.totalChunks; i++) { +// const chunk = await BorgSMSG.decryptV3Chunk(encryptedBase64, cek, i); +// // chunk is Uint8Array of decrypted data +// } +func smsgDecryptV3Chunk(this js.Value, args []js.Value) interface{} { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + if len(args) < 3 { + reject.Invoke(newError("decryptV3Chunk requires 3 arguments: encryptedBase64, cekBase64, chunkIndex")) + return + } + + encryptedB64 := args[0].String() + cekB64 := args[1].String() + chunkIndex := args[2].Int() + + // Decode base64 data + data, err := base64.StdEncoding.DecodeString(encryptedB64) + if err != nil { + reject.Invoke(newError("invalid base64: " + err.Error())) + return + } + + // Decode CEK + cek, err := base64.StdEncoding.DecodeString(cekB64) + if err != nil { + reject.Invoke(newError("invalid CEK base64: " + err.Error())) + return + } + + // Get header for chunk info + header, err := smsg.GetV3Header(data) + if err != nil { + reject.Invoke(newError("failed to get v3 header: " + err.Error())) + return + } + + if header.Chunked == nil { + reject.Invoke(newError("not a chunked v3 file")) + return + } + + // Get payload + payload, err := smsg.GetV3Payload(data) + if err != nil { + reject.Invoke(newError("failed to get payload: " + err.Error())) + return + } + + // Decrypt the chunk + decrypted, err := smsg.DecryptV3Chunk(payload, cek, chunkIndex, header.Chunked) + if err != nil { + reject.Invoke(newError("failed to decrypt chunk: " + err.Error())) + return + } + + // Return as Uint8Array + uint8Array := js.Global().Get("Uint8Array").New(len(decrypted)) + js.CopyBytesToJS(uint8Array, decrypted) + + resolve.Invoke(uint8Array) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) +} + +// smsgParseV3Header parses header from file bytes, returns header info + payload offset. +// This allows streaming: fetch header first, then fetch chunks as needed. +// JavaScript usage: +// +// const headerInfo = await BorgSMSG.parseV3Header(fileBytes); +// // headerInfo.payloadOffset = where encrypted chunks start +// // headerInfo.chunked.index = [{offset, size}, ...] relative to payload +// +// STREAMING: This function uses GetV3HeaderFromPrefix which only needs +// the first few KB of the file. Call it as soon as ~3KB arrives. +func smsgParseV3Header(this js.Value, args []js.Value) interface{} { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + if len(args) < 1 { + reject.Invoke(newError("parseV3Header requires 1 argument: Uint8Array")) + return + } + + // Get binary data from Uint8Array + uint8Array := args[0] + length := uint8Array.Get("length").Int() + data := make([]byte, length) + js.CopyBytesToGo(data, uint8Array) + + // Parse header from prefix - works with partial data! + header, payloadOffset, err := smsg.GetV3HeaderFromPrefix(data) + if err != nil { + reject.Invoke(newError("failed to parse header: " + err.Error())) + return + } + + result := map[string]interface{}{ + "format": header.Format, + "keyMethod": header.KeyMethod, + "cadence": string(header.Cadence), + "payloadOffset": payloadOffset, + } + + // Include wrapped keys for CEK unwrapping + if len(header.WrappedKeys) > 0 { + wrappedKeys := make([]interface{}, len(header.WrappedKeys)) + for i, wk := range header.WrappedKeys { + wrappedKeys[i] = map[string]interface{}{ + "date": wk.Date, + "wrapped": wk.Wrapped, + } + } + result["wrappedKeys"] = wrappedKeys + } + + // Include chunk info + if header.Chunked != nil { + index := make([]interface{}, len(header.Chunked.Index)) + for i, ci := range header.Chunked.Index { + index[i] = map[string]interface{}{ + "offset": ci.Offset, + "size": ci.Size, + } + } + result["chunked"] = map[string]interface{}{ + "chunkSize": header.Chunked.ChunkSize, + "totalChunks": header.Chunked.TotalChunks, + "totalSize": header.Chunked.TotalSize, + "index": index, + } + } + + if header.Manifest != nil { + result["manifest"] = manifestToJS(header.Manifest) + } + + resolve.Invoke(js.ValueOf(result)) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) +} + +// smsgUnwrapCEKFromHeader unwraps CEK using wrapped keys from header. +// JavaScript usage: +// +// const headerInfo = await BorgSMSG.parseV3Header(fileBytes); +// const cek = await BorgSMSG.unwrapCEKFromHeader(headerInfo.wrappedKeys, {license, fingerprint}, headerInfo.cadence); +func smsgUnwrapCEKFromHeader(this js.Value, args []js.Value) interface{} { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + if len(args) < 2 { + reject.Invoke(newError("unwrapCEKFromHeader requires 2-3 arguments: wrappedKeys, {license, fingerprint}, [cadence]")) + return + } + + wrappedKeysJS := args[0] + paramsObj := args[1] + + // Get cadence (optional, defaults to daily) + cadence := smsg.CadenceDaily + if len(args) >= 3 && !args[2].IsUndefined() { + cadence = smsg.Cadence(args[2].String()) + } + + // Extract stream params + license := paramsObj.Get("license").String() + fingerprint := "" + if !paramsObj.Get("fingerprint").IsUndefined() { + fingerprint = paramsObj.Get("fingerprint").String() + } + + if license == "" { + reject.Invoke(newError("license is required")) + return + } + + // Convert JS wrapped keys to Go + var wrappedKeys []smsg.WrappedKey + for i := 0; i < wrappedKeysJS.Length(); i++ { + wk := wrappedKeysJS.Index(i) + wrappedKeys = append(wrappedKeys, smsg.WrappedKey{ + Date: wk.Get("date").String(), + Wrapped: wk.Get("wrapped").String(), + }) + } + + // Build header with just the wrapped keys + header := &smsg.Header{ + WrappedKeys: wrappedKeys, + Cadence: cadence, + } + + params := &smsg.StreamParams{ + License: license, + Fingerprint: fingerprint, + Cadence: cadence, + } + + // Unwrap CEK + cek, err := smsg.UnwrapCEKFromHeader(header, params) + if err != nil { + reject.Invoke(newError("failed to unwrap CEK: " + err.Error())) + return + } + + // Return CEK as Uint8Array + cekArray := js.Global().Get("Uint8Array").New(len(cek)) + js.CopyBytesToJS(cekArray, cek) + + resolve.Invoke(cekArray) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) +} + +// smsgDecryptChunkDirect decrypts raw chunk bytes with CEK. +// JavaScript usage: +// +// const chunkBytes = fileBytes.subarray(payloadOffset + chunk.offset, payloadOffset + chunk.offset + chunk.size); +// const decrypted = await BorgSMSG.decryptChunkDirect(chunkBytes, cek); +func smsgDecryptChunkDirect(this js.Value, args []js.Value) interface{} { + handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} { + resolve := promiseArgs[0] + reject := promiseArgs[1] + + go func() { + if len(args) < 2 { + reject.Invoke(newError("decryptChunkDirect requires 2 arguments: chunkBytes (Uint8Array), cek (Uint8Array)")) + return + } + + // Get chunk bytes + chunkArray := args[0] + chunkLen := chunkArray.Get("length").Int() + chunkData := make([]byte, chunkLen) + js.CopyBytesToGo(chunkData, chunkArray) + + // Get CEK + cekArray := args[1] + cekLen := cekArray.Get("length").Int() + cek := make([]byte, cekLen) + js.CopyBytesToGo(cek, cekArray) + + // Create sigil and decrypt + sigil, err := enchantrix.NewChaChaPolySigil(cek) + if err != nil { + reject.Invoke(newError("failed to create sigil: " + err.Error())) + return + } + + decrypted, err := sigil.Out(chunkData) + if err != nil { + reject.Invoke(newError("decryption failed: " + err.Error())) + return + } + + // Return as Uint8Array + result := js.Global().Get("Uint8Array").New(len(decrypted)) + js.CopyBytesToJS(result, decrypted) + + resolve.Invoke(result) + }() + + return nil + }) + + promiseConstructor := js.Global().Get("Promise") + return promiseConstructor.New(handler) +} + // jsToManifest converts a JavaScript object to an smsg.Manifest func jsToManifest(obj js.Value) *smsg.Manifest { if obj.IsUndefined() || obj.IsNull() {