feat: lazy loading profile page + v3 streaming polish

Profile page:
   - No WASM or video download until play button clicked
   - Play button visible immediately, loading on-demand
   - Removed auto-play behavior completely

   Streaming:
   - GetV3HeaderFromPrefix for parsing from partial data
   - v3 demo file with 128KB chunks for streaming tests
This commit is contained in:
snider 2026-01-12 17:48:32 +00:00
parent 2debed53f1
commit bd7e8b3040
10 changed files with 2206 additions and 94 deletions

View file

@ -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

70
cmd/extract-demo/main.go Normal file
View file

@ -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 <input.smsg> <password> <output.mp4>")
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
}

129
cmd/mkdemo-v3/main.go Normal file
View file

@ -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 <input-media-file> <output-smsg-file> [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.")
}

BIN
demo/demo-track-v3.smsg Normal file

Binary file not shown.

View file

@ -983,6 +983,7 @@
<button class="mode-btn active" data-mode="profile">👤 Profile</button>
<button class="mode-btn" data-mode="fan">🎧 Fan</button>
<button class="mode-btn" data-mode="artist">🎨 Artist</button>
<button class="mode-btn" data-mode="streaming">📡 Streaming</button>
</div>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; gap: 1rem;">
@ -1334,6 +1335,151 @@
</div>
</div><!-- /artist-view -->
<!-- Streaming Mode -->
<div id="streaming-view" class="mode-view">
<div class="two-col">
<div class="tabs">
<div class="tab-buttons">
<button class="tab-btn active" data-streaming-tab="chunked">📦 Chunked Decrypt</button>
<button class="tab-btn" data-streaming-tab="seek">⏩ Seek Demo</button>
</div>
<div class="tab-content">
<!-- Chunked Decryption Tab -->
<div id="streaming-chunked" class="tab-panel active">
<h3 style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<span>📦</span> Chunk-by-Chunk Decryption
</h3>
<p style="color: #888; font-size: 0.85rem; margin-bottom: 1.5rem;">
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.
</p>
<div class="input-group" style="margin-bottom: 1rem;">
<label style="color: #888; font-size: 0.85rem; margin-bottom: 0.5rem; display: block;">License Token:</label>
<input type="text" id="streaming-license" placeholder="Enter license token..."
style="width: 100%; padding: 0.75rem 1rem; border: 2px solid rgba(255,255,255,0.1); border-radius: 8px; background: rgba(0,0,0,0.3); color: #fff; font-family: monospace;">
</div>
<button id="streaming-start-btn" class="demo-btn" disabled style="margin-bottom: 1.5rem;">
Start Chunked Decryption
</button>
<!-- Progress visualization -->
<div id="streaming-progress" style="display: none;">
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="color: #888; font-size: 0.85rem;">Decryption Progress</span>
<span id="streaming-progress-text" style="color: #00ff94; font-size: 0.85rem;">0%</span>
</div>
<div style="background: rgba(0,0,0,0.3); border-radius: 8px; height: 12px; overflow: hidden; margin-bottom: 1rem;">
<div id="streaming-progress-bar" style="background: linear-gradient(90deg, #ff006e, #8338ec); height: 100%; width: 0%; transition: width 0.3s;"></div>
</div>
<!-- Chunk grid visualization -->
<div id="streaming-chunks" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(24px, 1fr)); gap: 4px; margin-bottom: 1rem;"></div>
<div id="streaming-stats" style="background: rgba(0,0,0,0.2); border-radius: 8px; padding: 1rem; font-size: 0.8rem;">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.5rem;">
<div><span style="color: #888;">Total Chunks:</span> <span id="stat-total-chunks" style="color: #fff;">-</span></div>
<div><span style="color: #888;">Chunk Size:</span> <span id="stat-chunk-size" style="color: #fff;">-</span></div>
<div><span style="color: #888;">Decrypted:</span> <span id="stat-decrypted" style="color: #00ff94;">-</span></div>
<div><span style="color: #888;">Total Size:</span> <span id="stat-total-size" style="color: #fff;">-</span></div>
</div>
</div>
</div>
<!-- Result player -->
<div id="streaming-player" style="display: none; margin-top: 1.5rem;">
<div id="streaming-complete-banner" style="display: none; background: rgba(0,255,148,0.1); border: 1px solid rgba(0,255,148,0.3); border-radius: 12px; padding: 1rem; margin-bottom: 1rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; color: #00ff94; font-weight: 600;">
<span></span> Decryption Complete - Ready to Play
</div>
</div>
<div id="streaming-media-wrapper"></div>
</div>
</div>
<!-- Seek Demo Tab -->
<div id="streaming-seek" class="tab-panel">
<h3 style="margin-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<span></span> Seekable Streaming
</h3>
<p style="color: #888; font-size: 0.85rem; margin-bottom: 1.5rem;">
Jump to any position without decrypting previous chunks. The chunk index in the header
enables O(1) seeking to any byte offset.
</p>
<div class="input-group" style="margin-bottom: 1rem;">
<label style="color: #888; font-size: 0.85rem; margin-bottom: 0.5rem; display: block;">Jump to Chunk:</label>
<div style="display: flex; gap: 0.5rem;">
<input type="number" id="seek-chunk-input" min="0" value="0"
style="flex: 1; padding: 0.75rem 1rem; border: 2px solid rgba(255,255,255,0.1); border-radius: 8px; background: rgba(0,0,0,0.3); color: #fff;">
<button id="seek-chunk-btn" class="demo-btn" disabled style="padding: 0.75rem 1.5rem; width: auto;">
Decrypt Chunk
</button>
</div>
</div>
<div id="seek-result" style="display: none; background: rgba(0,0,0,0.2); border-radius: 8px; padding: 1rem; margin-top: 1rem;">
<div style="color: #888; font-size: 0.8rem; margin-bottom: 0.5rem;">Chunk <span id="seek-chunk-num">0</span> Data (first 100 bytes hex):</div>
<div id="seek-hex-output" style="font-family: monospace; font-size: 0.75rem; color: #00ff94; word-break: break-all; background: rgba(0,0,0,0.3); padding: 0.75rem; border-radius: 6px;"></div>
<div style="color: #888; font-size: 0.75rem; margin-top: 0.5rem;">
Decrypted <span id="seek-bytes-count">0</span> bytes from chunk <span id="seek-chunk-offset">0</span>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar-card">
<h3>📡 V3 Streaming Format</h3>
<p class="sidebar-desc">
Chunked encryption for large media files. Decrypt as you download, seek without decrypting everything.
</p>
<div class="sidebar-divider" style="margin: 1rem 0; border-top: 1px solid rgba(255,255,255,0.1);"></div>
<h4 style="font-size: 0.85rem; color: #8338ec; margin-bottom: 0.75rem;">How It Works</h4>
<div style="font-size: 0.8rem; color: #888; line-height: 1.6;">
<p style="margin-bottom: 0.5rem;"><strong style="color: #fff;">1.</strong> Content split into fixed-size chunks (default 1MB)</p>
<p style="margin-bottom: 0.5rem;"><strong style="color: #fff;">2.</strong> Single CEK encrypts all chunks</p>
<p style="margin-bottom: 0.5rem;"><strong style="color: #fff;">3.</strong> Each chunk gets unique nonce</p>
<p style="margin-bottom: 0.5rem;"><strong style="color: #fff;">4.</strong> Header contains chunk index with offsets</p>
<p><strong style="color: #fff;">5.</strong> CEK wrapped with LTHN rolling keys</p>
</div>
<div class="sidebar-divider" style="margin: 1rem 0; border-top: 1px solid rgba(255,255,255,0.1);"></div>
<h4 style="font-size: 0.85rem; color: #ff006e; margin-bottom: 0.75rem;">Use Cases</h4>
<div style="font-size: 0.8rem; color: #888;">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span>🎬</span> Large video files
</div>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span>📻</span> Live streaming
</div>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<span>📱</span> Mobile with limited RAM
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<span>🌐</span> CDN with Range requests
</div>
</div>
<div class="sidebar-divider" style="margin: 1rem 0; border-top: 1px solid rgba(255,255,255,0.1);"></div>
<div id="streaming-file-info" style="display: none;">
<h4 style="font-size: 0.85rem; color: #00ff94; margin-bottom: 0.75rem;">Loaded File</h4>
<div style="font-size: 0.8rem; color: #888;">
<div><span style="color: #fff;">Format:</span> <span id="info-format">-</span></div>
<div><span style="color: #fff;">Cadence:</span> <span id="info-cadence">-</span></div>
<div><span style="color: #fff;">Chunked:</span> <span id="info-chunked">-</span></div>
</div>
</div>
</div>
</div>
</div><!-- /streaming-view -->
<!-- Profile Mode -->
<div id="profile-view" class="mode-view active">
<div class="profile-layout">
@ -1384,11 +1530,17 @@
<!-- Hero Video -->
<div class="profile-hero" style="position: relative; min-height: 300px;">
<div id="profile-loading" class="profile-loading" style="position: absolute; inset: 0; background: rgba(15, 15, 26, 0.95); z-index: 10; border-radius: 20px;">
<div id="profile-loading" class="profile-loading" style="display: none; position: absolute; inset: 0; background: rgba(15, 15, 26, 0.95); z-index: 10; border-radius: 20px;">
<div class="spinner"></div>
<div>Decrypting content...</div>
<div>Loading...</div>
</div>
<video id="profile-video" style="opacity: 0; transition: opacity 0.5s;" autoplay loop controls playsinline></video>
<div id="profile-play-btn" style="position: absolute; inset: 0; z-index: 10; cursor: pointer; background: rgba(15, 15, 26, 0.7); border-radius: 20px;">
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center;">
<div style="font-size: 64px; margin-bottom: 10px;">▶️</div>
<div style="color: #fff; font-size: 18px;">Click to play</div>
</div>
</div>
<video id="profile-video" style="opacity: 0; transition: opacity 0.5s;" loop controls playsinline></video>
<div class="profile-hero-overlay">
<div class="profile-hero-title">It Feels So Good</div>
<div class="profile-hero-artist">The Conductor & The Cowboy's Amnesia Mix</div>
@ -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 = '<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center;"><div class="spinner"></div><div style="color: #fff; font-size: 14px; margin-top: 10px;">Loading WASM...</div></div>';
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 = '<div style="color: #ff5252;">Failed to load: ' + err.message + '</div>';
playBtn.innerHTML = '<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: #ff5252;">Failed: ' + err.message + '</div>';
}
}
}, { 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 = `
<div style="position: relative; border-radius: 12px; overflow: hidden; background: #000;">
<video id="streaming-video" style="width: 100%; max-height: 300px; display: block; opacity: 0.3;" controls></video>
<div id="buffer-overlay" style="position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.8); padding: 0.5rem 1rem;">
<div style="display: flex; align-items: center; gap: 0.75rem;">
<div style="color: #ff006e; font-size: 0.8rem;" id="buffer-status">Fetching header...</div>
<div style="flex: 1; background: rgba(255,255,255,0.2); height: 4px; border-radius: 2px; overflow: hidden;">
<div id="buffer-bar" style="background: linear-gradient(90deg, #ff006e, #8338ec); height: 100%; width: 0%; transition: width 0.1s;"></div>
</div>
<div id="buffer-text" style="color: #888; font-size: 0.75rem; font-family: monospace;">0%</div>
</div>
</div>
</div>
`;
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
</script>
</body>
</html>

Binary file not shown.

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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() {