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:
parent
2debed53f1
commit
bd7e8b3040
10 changed files with 2206 additions and 94 deletions
|
|
@ -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
70
cmd/extract-demo/main.go
Normal 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
129
cmd/mkdemo-v3/main.go
Normal 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
BIN
demo/demo-track-v3.smsg
Normal file
Binary file not shown.
611
demo/index.html
611
demo/index.html
|
|
@ -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>
|
||||
|
|
|
|||
BIN
demo/stmf.wasm
BIN
demo/stmf.wasm
Binary file not shown.
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue