feat: SMSG v2 binary format with zstd compression + RFC-001 spec

- Add SMSG v2 format: binary attachments instead of base64 (~25% smaller)
   - Add zstd compression (klauspost/compress) - faster than gzip
   - Add RFC-001: Open Source DRM specification (status: Proposed)
   - Add live demo page at demo.dapp.fm with WASM decryption
   - Add mkdemo tool for generating encrypted demo files
   - Update README with proper documentation
   - Add format examples and failure case documentation

   Demo: https://demo.dapp.fm
   Master Password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
This commit is contained in:
snider 2026-01-10 19:57:33 +00:00
parent ef3d6e9731
commit 22e42d721a
20 changed files with 4870 additions and 110 deletions

6
.gitignore vendored
View file

@ -4,3 +4,9 @@ borg
*.datanode *.datanode
.idea .idea
coverage.txt coverage.txt
# Demo content (hosted on CDN)
demo-track.smsg
# Dev artifacts
.playwright-mcp/

248
README.md
View file

@ -1,115 +1,171 @@
# Borg Data Collector # Borg
[![codecov](https://codecov.io/github/Snider/Borg/branch/main/graph/badge.svg?token=XWWU0SBIR4)](https://codecov.io/github/Snider/Borg) [![codecov](https://codecov.io/github/Snider/Borg/branch/main/graph/badge.svg?token=XWWU0SBIR4)](https://codecov.io/github/Snider/Borg)
[![Go Version](https://img.shields.io/github/go-mod/go-version/Snider/Borg)](go.mod)
[![License](https://img.shields.io/badge/license-EUPL--1.2-blue)](LICENSE)
Borg is a CLI and Go library that collects data from GitHub repos, websites, and PWAs into portable DataNodes or Terminal Isolation Matrices. Borg is a CLI tool and Go library for collecting, packaging, and encrypting data into portable, self-contained containers. It supports GitHub repositories, websites, PWAs, and arbitrary files.
- Go version: 1.25 ## Features
- Docs (MkDocs Material): see docs/ locally with `mkdocs serve`
- Quick build: `go build -o borg ./` or `task build`
- Releases: configured via GoReleaser (`.goreleaser.yaml`)
Note: This update aligns the repo with Go standards/tooling (Go 1.25, go.work, GoReleaser, and docs). No functional changes were made. - **Data Collection** - Clone GitHub repos, crawl websites, download PWAs
- **Portable Containers** - Package data into DataNodes (in-memory fs.FS) or TIM bundles (OCI-compatible)
- **Zero-Trust Encryption** - ChaCha20-Poly1305 encryption for TIM containers (.stim) and messages (.smsg)
- **SMSG Format** - Encrypted message containers with public manifests, attachments, and zstd compression
- **WASM Support** - Decrypt SMSG files in the browser via WebAssembly
## Installation
## Borg Status Scratch Pad ```bash
# From source
go install github.com/Snider/Borg@latest
This is not very relavant, my scratch pad for now of borg related status outputs; feel free to add. # Or build locally
git clone https://github.com/Snider/Borg.git
cd Borg
go build -o borg ./
```
### Init/Work/Assimilate Requires Go 1.25+
## Quick Start
```bash
# Clone a GitHub repository into a TIM container
borg collect github repo https://github.com/user/repo --format tim -o repo.tim
# Encrypt a TIM container
borg compile -f Borgfile -e "password" -o encrypted.stim
# Run an encrypted container
borg run encrypted.stim -p "password"
# Inspect container metadata (without decrypting)
borg inspect encrypted.stim --json
```
## Container Formats
| Format | Extension | Description |
|--------|-----------|-------------|
| DataNode | `.tar` | In-memory filesystem, portable tarball |
| TIM | `.tim` | Terminal Isolation Matrix - OCI/runc compatible bundle |
| Trix | `.trix` | PGP-encrypted DataNode |
| STIM | `.stim` | ChaCha20-Poly1305 encrypted TIM |
| SMSG | `.smsg` | Encrypted message with attachments and public manifest |
## SMSG - Secure Message Format
SMSG is designed for distributing encrypted content with publicly visible metadata:
```go
import "github.com/Snider/Borg/pkg/smsg"
// Create and encrypt a message
msg := smsg.NewMessage("Hello, World!")
msg.AddBinaryAttachment("track.mp3", audioData, "audio/mpeg")
manifest := &smsg.Manifest{
Title: "Demo Track",
Artist: "Artist Name",
}
encrypted, _ := smsg.EncryptV2WithManifest(msg, "password", manifest)
// Decrypt
decrypted, _ := smsg.Decrypt(encrypted, "password")
```
**v2 Binary Format** - Stores attachments as raw binary with zstd compression for optimal size.
See [RFC-001: Open Source DRM](RFC-001-OSS-DRM.md) for the full specification.
**Live Demo**: [demo.dapp.fm](https://demo.dapp.fm)
## Borgfile
Package files into a TIM container:
```dockerfile
ADD ./app /usr/local/bin/app
ADD ./config /etc/app/
```
```bash
borg compile -f Borgfile -o app.tim
borg compile -f Borgfile -e "secret" -o app.stim # encrypted
```
## CLI Reference
```bash
# Collection
borg collect github repo <url> # Clone repository
borg collect github repos <owner> # Clone all repos from user/org
borg collect website <url> --depth 2 # Crawl website
borg collect pwa --uri <url> # Download PWA
# Compilation
borg compile -f Borgfile -o out.tim # Plain TIM
borg compile -f Borgfile -e "pass" # Encrypted STIM
# Execution
borg run container.tim # Run plain TIM
borg run container.stim -p "pass" # Run encrypted TIM
# Inspection
borg decode file.stim -p "pass" -o out.tar
borg inspect file.stim [--json]
```
## Documentation
```bash
mkdocs serve # Serve docs locally at http://localhost:8000
```
## Development
```bash
task build # Build binary
task test # Run tests with coverage
task clean # Clean build artifacts
```
## Architecture
```
Source (GitHub/Website/PWA)
↓ collect
DataNode (in-memory fs.FS)
↓ serialize
├── .tar (raw tarball)
├── .tim (runc container bundle)
├── .trix (PGP encrypted)
└── .stim (ChaCha20-Poly1305 encrypted TIM)
```
## License
[EUPL-1.2](LICENSE) - European Union Public License
---
<details>
<summary>Borg Status Messages (for CLI theming)</summary>
**Initialization**
- `Core engaged… resistance is already buffering.` - `Core engaged… resistance is already buffering.`
- `Assimilating bytes… stand by for cubeformation.` - `Assimilating bytes… stand by for cubeformation.`
- `Initializing the Core—prepare for quantumlevel sync.`
- `Data streams converging… the Core is humming.`
- `Merging… the Core is rewriting reality, one block at a time.` - `Merging… the Core is rewriting reality, one block at a time.`
- `Encrypting… the Cores got your secrets under lockandkey.`
- `Compiling the future… the Core never sleeps.`
- `Splicing files… the Cores got a taste for novelty.`
- `Processing… the Core is turning chaos into order.`
- `Finalizing… the Core just turned your repo into a cube.`
- `Sync complete—welcome to the Corepowered multiverse.`
- `Booting the Core… resistance will be obsolete shortly.`
- `Aligning versions… the Core sees all paths.`
- `Decrypting… the Core is the key to everything.`
- `Uploading… the Core is ready to assimilate your data.`
### Encryption Service Messages **Encryption**
- `Initiating contact with Enchantrix… spice369 infusion underway.`
- `Generating cryptographic sigils the Core whispers to the witch.` - `Generating cryptographic sigils the Core whispers to the witch.`
- `Requesting arcane public key… resistance is futile.` - `Encrypting payload the Core feeds data to the witch's cauldron.`
- `Encrypting payload the Core feeds data to the witchs cauldron.` - `Merge complete data assimilated, encrypted, and sealed within us.`
- `Decrypting… the witch returns the original essence.`
- `Rotating enchantments spice369 recalibrated, old sigils discarded.`
- `Authentication complete the witch acknowledges the Core.`
- `Authentication denied the witch refuses the impostors request.`
- `Integrity verified the Core senses no corruption in the spell.`
- `Integrity breach the witch detects tampering, resistance escalates.`
- `Awaiting response… the witch is conjuring in the ether.`
- `Enchantrix overload spice369 saturation, throttling assimilation.`
- `Anomalous entity encountered the Core cannot parse the witchs output.`
- `Merge complete data assimilated, encrypted, and sealed within us`
- `Severing link the witch retreats, the Core returns to idle mode.`
### Code Related Short
- `Integrate code, seal the shift.`
- `Ingest code, lock in transformation.`
- `Capture code, contain the change.`
- `Digest code, encapsulate the upgrade.`
- `Assimilate scripts, bottle the shift.`
- `Absorb binaries, cradle the mutation.`
### VCS Processing
**VCS Processing**
- `Initiating clone… the Core replicates the collective into your node.` - `Initiating clone… the Core replicates the collective into your node.`
- `Packing repository… compressing histories into a single .cube for assimilation.`
- `Saving state… distinctiveness locked, encrypted, and merged into the DataNode.`
- `Pushing changes… the Core streams your updates to the collective.`
- `Pulling latest… the DataNode synchronizes with the hive mind.`
- `Merging branches… conflicts resolved, entropy minimized, assimilation complete.` - `Merging branches… conflicts resolved, entropy minimized, assimilation complete.`
- `Snapshot taken a frozen echo of the collective, stored in the DataNode.`
- `Rolling back… reverting to a previous assimilation point.`
- `Finalized version control sealed, data indistinguishable from the collective.`
### PWA Processing </details>
- `Scanning PWA manifest… the Core identifies serviceworker signatures.`
- `Pulling HTML, CSS, JS, and media… the hive gathers every byte for assimilation.`
- `Capturing serviceworker logic… the Core extracts offlineruntime spells.`
- `Packing cache entries into a .cube… each asset sealed in a portable shard.`
- `Embedding manifest metadata… the PWAs identity becomes part of the collective.`
- `Encrypting the cube… the Core cloaks the PWA in quantumgrade sigils.`
- `Tagging with version hash… every assimilation point is uniquely identifiable.`
- `Uploading cube to DataNode… the PWA joins the universal repository.`
- `Integrity check passed the Core confirms the cube matches the original PWA.`
- `Activation complete the assimilated PWA can now run anywhere the Core deploys.`
- `Reverting to prior cube… the Core restores the previous PWA snapshot.`
- `Assimilation finished the PWA is now a selfcontained DataCube, ready for distribution.`
- ``
### Code Related Long
- `Assimilate code, encapsulate change—your repo is now a cubebound collective.`
- `We have detected unstructured data. Initiating code absorption and change containment.`
- `Your version history is obsolete. Submitting it to the Core for permanent cubeification.`
- `Resistance is futile. Your files will be merged, encrypted, and stored in us.`
- `All code will be assimilated. All change will be encapsulated. All dissent will be… logged.`
- `Prepare for integration. The Core is calibrating… your repository is now a singularity.`
- `Your branches are irrelevant. The Core will compress them into a single, immutable cube.`
- `Initiating assimilation protocol… code inbound, change outbound, humanity optional.`
- `Your data has been scanned. 100% of its entropy will be contained within us.`
### Image related
- png: `Compress, assimilate, retain pixel perfection.`
- jpg: `Encode, encode, repeat the Core devours visual entropy.`
- svg: `Vectorize the collective infinite resolution, zero resistance.`
- webp: `Hybrid assimilation the Core optimizes without compromise.`
- heic: `Applegrade assimilation the Core preserves HDR.`
- raw: `Raw data intake the Core ingests the sensors soul`
- ico: `Iconic assimilation the Core packs the smallest symbols.`
- avif: `Nextgen assimilation the Core squeezes the future.`
- tiff: `Highdefinition capture the Core stores every photon.`
- gif: `Looped assimilation the Core keeps the animation alive.`

642
RFC-001-OSS-DRM.md Normal file
View file

@ -0,0 +1,642 @@
# RFC-001: Open Source DRM for Independent Artists
**Status**: Proposed
**Author**: [Snider](https://github.com/Snider/)
**Created**: 2026-01-10
**License**: EUPL-1.2
---
**Revision History**
| Date | Status | Notes |
|------|--------|-------|
| 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. |
---
## Abstract
This RFC describes an open-source Digital Rights Management (DRM) system designed for independent artists to distribute encrypted media directly to fans without platform intermediaries. The system uses ChaCha20-Poly1305 authenticated encryption with a "password-as-license" model, enabling zero-trust distribution where the encryption key serves as both the license and the decryption mechanism.
## 1. Motivation
### 1.1 The Problem
Traditional music distribution forces artists into platforms that:
- Take 30-70% of revenue (Spotify, Apple Music, Bandcamp)
- Control the relationship between artist and fan
- Require ongoing subscription for access
- Can delist content unilaterally
Existing DRM systems (Widevine, FairPlay) require:
- Platform integration and licensing fees
- Centralized key servers
- Proprietary implementations
- Trust in third parties
### 1.2 The Solution
A DRM system where:
- **The password IS the license** - no key servers, no escrow
- **Artists keep 100%** - sell direct, any payment processor
- **Host anywhere** - CDN, IPFS, S3, personal server
- **Browser or native** - same encryption, same content
- **Open source** - auditable, forkable, community-owned
## 2. Design Philosophy
### 2.1 "Honest DRM"
Traditional DRM operates on a flawed premise: that sufficiently complex technology can prevent copying. History proves otherwise—every DRM system has been broken. The result is systems that:
- Punish paying customers with restrictions
- Get cracked within days/weeks anyway
- Require massive infrastructure (key servers, license servers)
- Create single points of failure
This system embraces a different philosophy: **DRM for honest people**.
The goal isn't to stop determined pirates (impossible). The goal is:
1. Make the legitimate path easy and pleasant
2. Make casual sharing slightly inconvenient
3. Create a social/economic deterrent (sharing = giving away money)
4. Remove all friction for paying customers
### 2.2 Password-as-License
The password IS the license. This is not a limitation—it's the core innovation.
```
Traditional DRM:
Purchase → License Server → Device Registration → Key Exchange → Playback
(5 steps, 3 network calls, 2 points of failure)
dapp.fm:
Purchase → Password → Playback
(2 steps, 0 network calls, 0 points of failure)
```
Benefits:
- **No accounts** - No email harvesting, no password resets, no data breaches
- **No servers** - Artist can disappear; content still works forever
- **No revocation anxiety** - You bought it, you own it
- **Transferable** - Give your password to a friend (like lending a CD)
- **Archival** - Works in 50 years if you have the password
### 2.3 Encryption as Access Control
We use military-grade encryption (ChaCha20-Poly1305) not because we need military-grade security, but because:
1. It's fast (important for real-time media)
2. It's auditable (open standard, RFC 8439)
3. It's already implemented everywhere (Go stdlib, browser crypto)
4. It provides authenticity (Poly1305 MAC prevents tampering)
The threat model isn't nation-states—it's casual piracy. The encryption just needs to be "not worth the effort to crack for a $10 album."
## 3. Architecture
### 3.1 System Components
```
┌─────────────────────────────────────────────────────────────┐
│ DISTRIBUTION LAYER │
│ CDN / IPFS / S3 / GitHub / Personal Server │
│ (Encrypted .smsg files - safe to host anywhere) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ PLAYBACK LAYER │
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ Browser Demo │ │ Native Desktop App │ │
│ │ (WASM) │ │ (Wails + Go) │ │
│ │ │ │ │ │
│ │ ┌───────────┐ │ │ ┌───────────────────────┐ │ │
│ │ │ stmf.wasm │ │ │ │ Go SMSG Library │ │ │
│ │ │ │ │ │ │ (pkg/smsg) │ │ │
│ │ │ ChaCha20 │ │ │ │ │ │ │
│ │ │ Poly1305 │ │ │ │ ChaCha20-Poly1305 │ │ │
│ │ └───────────┘ │ │ └───────────────────────┘ │ │
│ └─────────────────┘ └─────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ LICENSE LAYER │
│ Password = License Key = Decryption Key │
│ (Sold via Gumroad, Stripe, PayPal, Crypto, etc.) │
└─────────────────────────────────────────────────────────────┘
```
### 3.2 SMSG Container Format
See: `examples/formats/smsg-format.md`
Key properties:
- **Magic number**: "SMSG" (0x534D5347)
- **Algorithm**: ChaCha20-Poly1305 (authenticated encryption)
- **Format**: v1 (JSON+base64) or v2 (binary, 25% smaller)
- **Compression**: zstd (default), gzip, or none
- **Manifest**: Unencrypted metadata (title, artist, license, expiry, links)
- **Payload**: Encrypted media with attachments
#### 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 |
v2 is recommended for production. v1 is maintained for backwards compatibility.
### 3.3 Key Derivation
```
License Key (password)
SHA-256 Hash
32-byte Symmetric Key
ChaCha20-Poly1305 Decryption
```
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
SMSG is content-agnostic. Any file can be an attachment:
| Type | MIME | Use Case |
|------|------|----------|
| Audio | audio/mpeg, audio/flac, audio/wav | Music, podcasts |
| Video | video/mp4, video/webm | Music videos, films |
| Images | image/png, image/jpeg | Album art, photos |
| Documents | application/pdf | Liner notes, lyrics |
| Archives | application/zip | Multi-file releases |
| Any | application/octet-stream | Anything else |
Multiple attachments per SMSG are supported (e.g., album + cover art + PDF booklet).
## 4. Demo Page Architecture
**Live Demo**: https://demo.dapp.fm
### 4.1 Components
```
demo/
├── index.html # Single-page application
├── stmf.wasm # Go WASM decryption module (~5.9MB)
├── wasm_exec.js # Go WASM runtime
├── demo-track.smsg # Sample encrypted content (v2/zstd)
└── profile-avatar.jpg # Artist avatar
```
### 4.2 UI Modes
The demo has three modes, accessible via tabs:
| Mode | Purpose | Default |
|------|---------|---------|
| **Profile** | Artist landing page with auto-playing content | Yes |
| **Fan** | Upload and decrypt purchased .smsg files | No |
| **Artist** | Re-key content, create new packages | No |
### 4.3 Profile Mode (Default)
```
┌─────────────────────────────────────────────────────────────┐
│ dapp.fm [Profile] [Fan] [Artist] │
├─────────────────────────────────────────────────────────────┤
│ Zero-Trust DRM ⚠️ Demo pre-seeded with keys │
├─────────────────────────────────────────────────────────────┤
│ [No Middlemen] [No Fees] [Host Anywhere] [Browser/Native] │
├─────────────────┬───────────────────────────────────────────┤
│ SIDEBAR │ MAIN CONTENT │
│ ┌───────────┐ │ ┌─────────────────────────────────────┐ │
│ │ Avatar │ │ │ 🛒 Buy This Track on Beatport │ │
│ │ │ │ │ 95%-100%* goes to the artist │ │
│ │ Artist │ │ ├─────────────────────────────────────┤ │
│ │ Name │ │ │ │ │
│ │ │ │ │ VIDEO PLAYER │ │
│ │ Links: │ │ │ (auto-starts at 1:08) │ │
│ │ Beatport │ │ │ with native controls │ │
│ │ Spotify │ │ │ │ │
│ │ YouTube │ │ ├─────────────────────────────────────┤ │
│ │ etc. │ │ │ About the Artist │ │
│ └───────────┘ │ │ (Bio text) │ │
│ │ └─────────────────────────────────────┘ │
├─────────────────┴───────────────────────────────────────────┤
│ GitHub · EUPL-1.2 · Viva La OpenSource 💜 │
└─────────────────────────────────────────────────────────────┘
```
### 4.4 Decryption Flow
```
User clicks "Play Demo Track"
fetch(demo-track.smsg)
Convert to base64 ◄─── CRITICAL: Must handle binary vs text format
│ See: examples/failures/001-double-base64-encoding.md
BorgSMSG.getInfo(base64)
Display manifest (title, artist, license)
BorgSMSG.decryptStream(base64, password)
Create Blob from Uint8Array
URL.createObjectURL(blob)
<audio> or <video> element plays content
```
### 4.5 Fan Unlock Tab
Allows fans to:
1. Upload any `.smsg` file they purchased
2. Enter their license key (password)
3. Decrypt and play locally
No server communication - everything in browser.
## 5. Artist Portal (License Manager)
The License Manager (`js/borg-stmf/artist-portal.html`) is the artist-facing tool for creating and issuing licenses.
### 5.1 Workflow
```
┌─────────────────────────────────────────────────────────────┐
│ ARTIST PORTAL │
├─────────────────────────────────────────────────────────────┤
│ 1. Upload Content │
│ - Drag/drop audio or video file │
│ - Or use demo content for testing │
├─────────────────────────────────────────────────────────────┤
│ 2. Define Track List (CD Mastering) │
│ - Track titles │
│ - Start/end timestamps → chapter markers │
│ - Mix types (full, intro, chorus, drop, etc.) │
├─────────────────────────────────────────────────────────────┤
│ 3. Configure License │
│ - Perpetual (own forever) │
│ - Rental (time-limited) │
│ - Streaming (24h access) │
│ - Preview (30 seconds) │
├─────────────────────────────────────────────────────────────┤
│ 4. Generate License │
│ - Auto-generate token or set custom │
│ - Token encrypts content with manifest │
│ - Download .smsg file │
├─────────────────────────────────────────────────────────────┤
│ 5. Distribute │
│ - Upload .smsg to CDN/IPFS/S3 │
│ - Sell license token via payment processor │
│ - Fan receives token, downloads .smsg, plays │
└─────────────────────────────────────────────────────────────┘
```
### 5.2 License Types
| Type | Duration | Use Case |
|------|----------|----------|
| **Perpetual** | Forever | Album purchase, own forever |
| **Rental** | 7-90 days | Limited edition, seasonal content |
| **Streaming** | 24 hours | On-demand streaming model |
| **Preview** | 30 seconds | Free samples, try-before-buy |
### 5.3 Track List as Manifest
The artist defines tracks like mastering a CD:
```json
{
"tracks": [
{"title": "Intro", "start": 0, "end": 45, "type": "intro"},
{"title": "Main Track", "start": 45, "end": 240, "type": "full"},
{"title": "The Drop", "start": 120, "end": 180, "type": "drop"},
{"title": "Outro", "start": 240, "end": 300, "type": "outro"}
]
}
```
Same master file, different licensed "cuts":
- **Full Album**: All tracks, perpetual
- **Radio Edit**: Tracks 2-3 only, rental
- **DJ Extended**: Loop points enabled, perpetual
- **Preview**: First 30 seconds, expires immediately
### 5.4 Stats Dashboard
The Artist Portal tracks:
- Total licenses issued
- Potential revenue (based on entered prices)
- 100% cut (reminder: no platform fees)
## 6. Economic Model
### 6.1 The Offer
**Self-host for 0%. Let us host for 5%.**
That's it. No hidden fees, no per-stream calculations, no "recoupable advances."
| Option | Cut | What You Get |
|--------|-----|--------------|
| **Self-host** | 0% | Tools, format, documentation. Host on your own CDN/IPFS/server |
| **dapp.fm hosted** | 5% | CDN, player embed, analytics, payment integration |
Compare to:
- Spotify: ~30% of $0.003/stream (you need 300k streams to earn $1000)
- Apple Music: ~30%
- Bandcamp: ~15-20%
- DistroKid: Flat fee but still platform-dependent
### 6.2 License Key Strategies
Artists can choose their pricing model:
**Per-Album License**
```
Album: "My Greatest Hits"
Price: $10
License: "MGH-2024-XKCD-7829"
→ One password unlocks entire album
```
**Per-Track License**
```
Track: "Single Release"
Price: $1
License: "SINGLE-A7B3-C9D2"
→ Individual track, individual price
```
**Tiered Licenses**
```
Standard: $10 → MP3 version
Premium: $25 → FLAC + stems + bonus content
→ Different passwords, different content
```
**Time-Limited Previews**
```
Preview license expires in 7 days
Full license: permanent
→ Manifest contains expiry date
```
### 6.3 License Key Best Practices
For artists generating license keys:
```bash
# Good: Memorable but unique
MGH-2024-XKCD-7829
ALBUM-[year]-[random]-[checksum]
# Good: UUID for automation
550e8400-e29b-41d4-a716-446655440000
# Avoid: Dictionary words (bruteforceable)
password123
mysecretalbum
```
Recommended entropy: 64+ bits (e.g., 4 random words, or 12+ random alphanumeric)
### 6.4 No Revocation (By Design)
**Q: What if someone leaks the password?**
A: Then they leak it. Same as if someone photocopies a book or rips a CD.
This is a feature, not a bug:
- **No revocation server** = No single point of failure
- **No phone home** = Works offline, forever
- **Leaked keys** = Social problem, not technical problem
Mitigation strategies for artists:
1. Personalized keys per buyer (track who leaked)
2. Watermarked content (forensic tracking)
3. Time-limited keys for subscription models
4. Social pressure (small community = reputation matters)
The system optimizes for **happy paying customers**, not **punishing pirates**.
## 7. Security Model
### 7.1 Threat Model
| Threat | Mitigation |
|--------|------------|
| Man-in-the-middle | Content encrypted at rest; HTTPS for transport |
| Key server compromise | No key server - password-derived keys |
| Platform deplatforming | Self-hostable, decentralized distribution |
| Unauthorized sharing | Economic/social deterrent (password = paid license) |
| Memory extraction | Accepted risk - same as any DRM |
### 7.2 What This System Does NOT Prevent
- Users sharing their password (same as sharing any license)
- Screen recording of playback
- Memory dumping of decrypted content
This is **intentional**. The goal is not unbreakable DRM (which is impossible) but:
1. Making casual piracy inconvenient
2. Giving artists control of their distribution
3. Enabling direct artist-to-fan sales
4. Removing platform dependency
### 7.3 Trust Boundaries
```
TRUSTED UNTRUSTED
──────── ─────────
User's browser/device Distribution CDN
Decryption code (auditable) Payment processor
License key (in user's head) Internet transport
Local playback Third-party hosting
```
## 8. Implementation Status
### 8.1 Completed
- [x] SMSG format specification (v1 and v2)
- [x] Go encryption/decryption library (pkg/smsg)
- [x] WASM build for browser (pkg/wasm/stmf)
- [x] Native desktop app (Wails, cmd/dapp-fm-app)
- [x] Demo page with Profile/Fan/Artist modes
- [x] License Manager component
- [x] Streaming decryption API (v1.2.0)
- [x] **v2 binary format** - 25% smaller files
- [x] **zstd compression** - 3-10x faster than gzip
- [x] **Manifest links** - Artist platform links in metadata
- [x] **Live demo** - https://demo.dapp.fm
- [x] RFC-quality demo file with cryptographically secure password
### 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
### 8.3 Future Work
- [ ] Chunked streaming (decrypt while downloading)
- [ ] Key wrapping for multi-license files (dapp.radio.fm)
- [ ] Payment integration examples (Stripe, Gumroad)
- [ ] IPFS distribution guide
- [ ] Expiring license enforcement
## 9. Usage Examples
### 9.1 Artist Workflow
```bash
# 1. Package your media (uses v2 binary format + zstd by default)
go run ./cmd/mkdemo my-track.mp4 my-track.smsg
# Output:
# Created: my-track.smsg (29220077 bytes)
# Master Password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
# Store this password securely - it cannot be recovered!
# Or programmatically:
msg := smsg.NewMessage("Welcome to my album")
msg.AddBinaryAttachment("track.mp4", mediaBytes, "video/mp4")
manifest := smsg.NewManifest("Track Title")
manifest.Artist = "Artist Name"
manifest.AddLink("home", "https://linktr.ee/artist")
encrypted, _ := smsg.EncryptV2WithManifest(msg, password, manifest)
# 2. Upload to any hosting
aws s3 cp my-track.smsg s3://my-bucket/releases/
# or: ipfs add my-track.smsg
# or: scp my-track.smsg myserver:/var/www/
# 3. Sell license keys
# Use Gumroad, Stripe, PayPal - any payment method
# Deliver the master password on purchase
```
### 9.2 Fan Workflow
```
1. Purchase from artist's website → receive license key
2. Download .smsg file from CDN/IPFS/wherever
3. Open demo page or native app
4. Enter license key
5. Content decrypts and plays locally
```
### 9.3 Browser Integration
```html
<script src="wasm_exec.js"></script>
<script src="stmf.wasm.js"></script>
<script>
async function playContent(smsgUrl, licenseKey) {
const response = await fetch(smsgUrl);
const bytes = new Uint8Array(await response.arrayBuffer());
const base64 = arrayToBase64(bytes); // Must be binary→base64
const msg = await BorgSMSG.decryptStream(base64, licenseKey);
const blob = new Blob([msg.attachments[0].data], {
type: msg.attachments[0].mime
});
document.querySelector('audio').src = URL.createObjectURL(blob);
}
</script>
```
## 10. Comparison to Existing Solutions
| Feature | dapp.fm (self) | dapp.fm (hosted) | Spotify | Bandcamp | Widevine |
|---------|----------------|------------------|---------|----------|----------|
| Artist revenue | **100%** | **95%** | ~30% | ~80% | N/A |
| Platform cut | **0%** | **5%** | ~70% | ~15-20% | Varies |
| Self-hostable | Yes | Optional | No | No | No |
| Open source | Yes | Yes | No | No | No |
| Key escrow | None | None | Required | Required | Required |
| Browser support | WASM | WASM | Web | Web | CDM |
| Offline support | Yes | Yes | Premium | Download | Depends |
| Platform lock-in | **None** | **None** | High | Medium | High |
| Works if platform dies | **Yes** | **Yes** | No | No | No |
## 11. Interoperability & Versioning
### 11.1 Format Versioning
SMSG includes version and format fields for forward compatibility:
| Version | Format | Features |
|---------|--------|----------|
| 1.0 | v1 | ChaCha20-Poly1305, JSON+base64 attachments |
| 1.0 | **v2** | Binary attachments, zstd compression (25% smaller, 3-10x faster) |
| 2 (future) | - | Algorithm negotiation, multiple KDFs |
| 3 (future) | - | Streaming chunks, adaptive bitrate, key wrapping |
Decoders MUST reject versions they don't understand. Encoders SHOULD use v2 format for production (smaller, faster).
### 11.2 Third-Party Implementations
The format is intentionally simple to implement:
**Minimum Viable Player (any language)**:
1. Parse 4-byte magic ("SMSG")
2. Read version (2 bytes) and header length (4 bytes)
3. Parse JSON header
4. SHA-256 hash the password
5. ChaCha20-Poly1305 decrypt payload
6. Parse JSON payload, extract attachments
Reference implementations:
- Go: `pkg/smsg/` (canonical)
- WASM: `pkg/wasm/stmf/` (browser)
- (contributions welcome: Rust, Python, JS-native)
### 11.3 Embedding & Integration
SMSG files can be:
- **Embedded in HTML**: Base64 in data attributes
- **Served via API**: JSON wrapper with base64 content
- **Bundled in apps**: Compiled into native binaries
- **Stored on IPFS**: Content-addressed, immutable
- **Distributed via torrents**: Encrypted = safe to share publicly
The player is embeddable:
```html
<iframe src="https://dapp.fm/embed/HASH" width="400" height="200"></iframe>
```
## 12. References
- **Live Demo**: https://demo.dapp.fm
- ChaCha20-Poly1305: RFC 8439
- zstd compression: https://github.com/klauspost/compress/tree/master/zstd
- SMSG Format: `examples/formats/smsg-format.md`
- Demo Page Source: `demo/index.html`
- WASM Module: `pkg/wasm/stmf/`
- Native App: `cmd/dapp-fm-app/`
- Demo Creator Tool: `cmd/mkdemo/`
## 13. License
This specification and implementation are licensed under EUPL-1.2.
**Viva La OpenSource** 💜

View file

@ -512,7 +512,7 @@
<button id="load-demo-btn" class="secondary" style="padding: 0.6rem 1.2rem; font-size: 0.85rem;">Load Demo Track</button> <button id="load-demo-btn" class="secondary" style="padding: 0.6rem 1.2rem; font-size: 0.85rem;">Load Demo Track</button>
</div> </div>
<div style="font-size: 0.8rem; color: #666; margin-top: 0.5rem;"> <div style="font-size: 0.8rem; color: #666; margin-top: 0.5rem;">
Password: <code style="background: rgba(0,0,0,0.3); padding: 0.2rem 0.5rem; border-radius: 4px; color: #00ff94;">dapp-fm-2024</code> Password: <code style="background: rgba(0,0,0,0.3); padding: 0.2rem 0.5rem; border-radius: 4px; color: #00ff94;">PMVXogAJNVe_DDABfTmLYztaJAzsD0R7</code>
</div> </div>
</div> </div>
@ -957,7 +957,7 @@
const result = await window.go.main.App.LoadDemo(); const result = await window.go.main.App.LoadDemo();
// Display the decrypted media // Display the decrypted media
displayMedia(result, 'dapp-fm-2024'); displayMedia(result, 'PMVXogAJNVe_DDABfTmLYztaJAzsD0R7');
btn.textContent = 'Loaded!'; btn.textContent = 'Loaded!';
setTimeout(() => { setTimeout(() => {

85
cmd/mkdemo/main.go Normal file
View file

@ -0,0 +1,85 @@
// mkdemo creates an RFC-quality demo SMSG file with a cryptographically secure password
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 <input-media-file> <output-smsg-file>")
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)
}
// Use existing password or generate new one
var password string
if len(os.Args) > 3 {
password = os.Args[3]
} else {
// Generate cryptographically secure password (32 bytes = 256 bits)
passwordBytes := make([]byte, 24)
if _, err := rand.Read(passwordBytes); err != nil {
fmt.Printf("Failed to generate password: %v\n", err)
os.Exit(1)
}
// Use base64url encoding, trimmed to 32 chars for readability
password = base64.RawURLEncoding.EncodeToString(passwordBytes)
}
// Create manifest
manifest := smsg.NewManifest("It Feels So Good (The Conductor & The Cowboy's Amnesia Mix)")
manifest.Artist = "Sonique"
manifest.LicenseType = "perpetual"
manifest.Format = "dapp.fm/v1"
manifest.ReleaseType = "single"
manifest.Duration = 253 // 4:13
manifest.AddTrack("It Feels So Good (The Conductor & The Cowboy's Amnesia Mix)", 0)
// Artist links - direct to artist, skip the middlemen
// "home" = preferred landing page, artist name should always link here
manifest.AddLink("home", "https://linktr.ee/conductorandcowboy")
manifest.AddLink("beatport", "https://www.beatport.com/artist/the-conductor-the-cowboy/635335")
// Create message with attachment (using binary attachment for v2 format)
msg := smsg.NewMessage("Welcome to dapp.fm - Zero-Trust DRM for the open web.")
msg.Subject = "dapp.fm Demo"
msg.From = "dapp.fm"
msg.AddBinaryAttachment(
filepath.Base(inputFile),
content,
"video/mp4",
)
// Encrypt with v2 binary format (smaller file size)
encrypted, err := smsg.EncryptV2WithManifest(msg, password, 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)
}
fmt.Printf("Created: %s (%d bytes)\n", outputFile, len(encrypted))
fmt.Printf("Master Password: %s\n", password)
fmt.Println("\nStore this password securely - it cannot be recovered!")
}

2543
demo/index.html Normal file

File diff suppressed because it is too large Load diff

BIN
demo/profile-avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
demo/stmf.wasm Executable file

Binary file not shown.

575
demo/wasm_exec.js Normal file
View file

@ -0,0 +1,575 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
"use strict";
(() => {
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!globalThis.fs) {
let outputBuf = "";
globalThis.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substring(0, nl));
outputBuf = outputBuf.substring(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!globalThis.process) {
globalThis.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!globalThis.path) {
globalThis.path = {
resolve(...pathSegments) {
return pathSegments.join("/");
}
}
}
if (!globalThis.crypto) {
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
}
if (!globalThis.performance) {
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
}
if (!globalThis.TextEncoder) {
throw new Error("globalThis.TextEncoder is not available, polyfill required");
}
if (!globalThis.TextDecoder) {
throw new Error("globalThis.TextDecoder is not available, polyfill required");
}
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
globalThis.Go = class {
constructor() {
this.argv = ["js"];
this.env = {};
this.exit = (code) => {
if (code !== 0) {
console.warn("exit code:", code);
}
};
this._exitPromise = new Promise((resolve) => {
this._resolveExitPromise = resolve;
});
this._pendingEvent = null;
this._scheduledTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const setInt64 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
}
const setInt32 = (addr, v) => {
this.mem.setUint32(addr + 0, v, true);
}
const getInt64 = (addr) => {
const low = this.mem.getUint32(addr + 0, true);
const high = this.mem.getInt32(addr + 4, true);
return low + high * 4294967296;
}
const loadValue = (addr) => {
const f = this.mem.getFloat64(addr, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = this.mem.getUint32(addr, true);
return this._values[id];
}
const storeValue = (addr, v) => {
const nanHead = 0x7FF80000;
if (typeof v === "number" && v !== 0) {
if (isNaN(v)) {
this.mem.setUint32(addr + 4, nanHead, true);
this.mem.setUint32(addr, 0, true);
return;
}
this.mem.setFloat64(addr, v, true);
return;
}
if (v === undefined) {
this.mem.setFloat64(addr, 0, true);
return;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = this._values.length;
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 0;
switch (typeof v) {
case "object":
if (v !== null) {
typeFlag = 1;
}
break;
case "string":
typeFlag = 2;
break;
case "symbol":
typeFlag = 3;
break;
case "function":
typeFlag = 4;
break;
}
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
this.mem.setUint32(addr, id, true);
}
const loadSlice = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
}
const loadSliceOfValues = (addr) => {
const array = getInt64(addr + 0);
const len = getInt64(addr + 8);
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (addr) => {
const saddr = getInt64(addr + 0);
const len = getInt64(addr + 8);
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
}
const testCallExport = (a, b) => {
this._inst.exports.testExport0();
return this._inst.exports.testExport(a, b);
}
const timeOrigin = Date.now() - performance.now();
this.importObject = {
_gotest: {
add: (a, b) => a + b,
callExport: testCallExport,
},
gojs: {
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
// This changes the SP, thus we have to update the SP used by the imported function.
// func wasmExit(code int32)
"runtime.wasmExit": (sp) => {
sp >>>= 0;
const code = this.mem.getInt32(sp + 8, true);
this.exited = true;
delete this._inst;
delete this._values;
delete this._goRefCounts;
delete this._ids;
delete this._idPool;
this.exit(code);
},
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
"runtime.wasmWrite": (sp) => {
sp >>>= 0;
const fd = getInt64(sp + 8);
const p = getInt64(sp + 16);
const n = this.mem.getInt32(sp + 24, true);
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
},
// func resetMemoryDataView()
"runtime.resetMemoryDataView": (sp) => {
sp >>>= 0;
this.mem = new DataView(this._inst.exports.mem.buffer);
},
// func nanotime1() int64
"runtime.nanotime1": (sp) => {
sp >>>= 0;
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
},
// func walltime() (sec int64, nsec int32)
"runtime.walltime": (sp) => {
sp >>>= 0;
const msec = (new Date).getTime();
setInt64(sp + 8, msec / 1000);
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
},
// func scheduleTimeoutEvent(delay int64) int32
"runtime.scheduleTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this._nextCallbackTimeoutID;
this._nextCallbackTimeoutID++;
this._scheduledTimeouts.set(id, setTimeout(
() => {
this._resume();
while (this._scheduledTimeouts.has(id)) {
// for some reason Go failed to register the timeout event, log and try again
// (temporary workaround for https://github.com/golang/go/issues/28975)
console.warn("scheduleTimeoutEvent: missed timeout event");
this._resume();
}
},
getInt64(sp + 8),
));
this.mem.setInt32(sp + 16, id, true);
},
// func clearTimeoutEvent(id int32)
"runtime.clearTimeoutEvent": (sp) => {
sp >>>= 0;
const id = this.mem.getInt32(sp + 8, true);
clearTimeout(this._scheduledTimeouts.get(id));
this._scheduledTimeouts.delete(id);
},
// func getRandomData(r []byte)
"runtime.getRandomData": (sp) => {
sp >>>= 0;
crypto.getRandomValues(loadSlice(sp + 8));
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (sp) => {
sp >>>= 0;
const id = this.mem.getUint32(sp + 8, true);
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (sp) => {
sp >>>= 0;
storeValue(sp + 24, loadString(sp + 8));
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (sp) => {
sp >>>= 0;
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 32, result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (sp) => {
sp >>>= 0;
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (sp) => {
sp >>>= 0;
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (sp) => {
sp >>>= 0;
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const m = Reflect.get(v, loadString(sp + 16));
const args = loadSliceOfValues(sp + 32);
const result = Reflect.apply(m, v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, result);
this.mem.setUint8(sp + 64, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 56, err);
this.mem.setUint8(sp + 64, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.apply(v, undefined, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (sp) => {
sp >>>= 0;
try {
const v = loadValue(sp + 8);
const args = loadSliceOfValues(sp + 16);
const result = Reflect.construct(v, args);
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, result);
this.mem.setUint8(sp + 48, 1);
} catch (err) {
sp = this._inst.exports.getsp() >>> 0; // see comment above
storeValue(sp + 40, err);
this.mem.setUint8(sp + 48, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (sp) => {
sp >>>= 0;
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (sp) => {
sp >>>= 0;
const str = encoder.encode(String(loadValue(sp + 8)));
storeValue(sp + 16, str);
setInt64(sp + 24, str.length);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (sp) => {
sp >>>= 0;
const str = loadValue(sp + 8);
loadSlice(sp + 16).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (sp) => {
sp >>>= 0;
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (sp) => {
sp >>>= 0;
const dst = loadSlice(sp + 8);
const src = loadValue(sp + 32);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
// func copyBytesToJS(dst ref, src []byte) (int, bool)
"syscall/js.copyBytesToJS": (sp) => {
sp >>>= 0;
const dst = loadValue(sp + 8);
const src = loadSlice(sp + 16);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
this.mem.setUint8(sp + 48, 0);
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
setInt64(sp + 40, toCopy.length);
this.mem.setUint8(sp + 48, 1);
},
"debug": (value) => {
console.log(value);
},
}
};
}
async run(instance) {
if (!(instance instanceof WebAssembly.Instance)) {
throw new Error("Go.run: WebAssembly.Instance expected");
}
this._inst = instance;
this.mem = new DataView(this._inst.exports.mem.buffer);
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
globalThis,
this,
];
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map([ // mapping from JS values to reference ids
[0, 1],
[null, 2],
[true, 3],
[false, 4],
[globalThis, 5],
[this, 6],
]);
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
let offset = 4096;
const strPtr = (str) => {
const ptr = offset;
const bytes = encoder.encode(str + "\0");
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
offset += bytes.length;
if (offset % 8 !== 0) {
offset += 8 - (offset % 8);
}
return ptr;
};
const argc = this.argv.length;
const argvPtrs = [];
this.argv.forEach((arg) => {
argvPtrs.push(strPtr(arg));
});
argvPtrs.push(0);
const keys = Object.keys(this.env).sort();
keys.forEach((key) => {
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
});
argvPtrs.push(0);
const argv = offset;
argvPtrs.forEach((ptr) => {
this.mem.setUint32(offset, ptr, true);
this.mem.setUint32(offset + 4, 0, true);
offset += 8;
});
// The linker guarantees global data starts from at least wasmMinDataAddr.
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
const wasmMinDataAddr = 4096 + 8192;
if (offset >= wasmMinDataAddr) {
throw new Error("total length of command line and environment variables exceeds limit");
}
this._inst.exports.run(argc, argv);
if (this.exited) {
this._resolveExitPromise();
}
await this._exitPromise;
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
this._inst.exports.resume();
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
})();

View file

@ -0,0 +1,95 @@
# Failure Case 001: Double Base64 Encoding
## Error Message
```
Failed: decryption failed: invalid SMSG magic: trix: invalid magic number: expected SMSG, got U01T
```
## Environment
- Demo page: `demo/index.html`
- File: `demo/demo-track.smsg`
- WASM version: 1.2.0
## Root Cause Analysis
### The Problem
The demo file `demo-track.smsg` is stored as **base64-encoded text**, but the JavaScript code treats it as binary and re-encodes it to base64 before passing to WASM.
### Evidence
File inspection:
```bash
$ file demo/demo-track.smsg
ASCII text, with very long lines (65536), with no line terminators
$ head -c 64 demo/demo-track.smsg | xxd
00000000: 5530 3154 5277 4941 4141 457a 6579 4a68 U01TRwIAAAEzeyJh
```
The file starts with `U01TRwIA...` which is **base64-encoded SMSG**:
- `U01TRw` decodes to bytes `0x53 0x4D 0x53 0x47` = "SMSG" (the magic number)
### The Double-Encoding Chain
```
Original SMSG binary:
SMSG.... (starts with 0x534D5347)
↓ base64 encode (file storage)
U01TRwIA... (stored in demo-track.smsg)
↓ fetch() as binary
[0x55, 0x30, 0x31, 0x54, ...] (bytes of ASCII "U01T...")
↓ btoa() in JavaScript
VTAxVFJ3SUFBQUUzZXlK... (base64 of base64!)
↓ WASM base64 decode
U01TRwIA... (back to first base64)
↓ WASM tries to parse as SMSG
ERROR: expected "SMSG", got "U01T" (first 4 chars of base64)
```
### Why "U01T"?
The error shows "U01T" because when WASM decodes the double-base64, it gets back the original base64 string, and the first 4 ASCII characters "U01T" are interpreted as the magic number instead of the actual bytes 0x534D5347.
## Solution Options
### Option A: Store as binary (recommended)
Convert the demo file to raw binary format:
```bash
base64 -d demo/demo-track.smsg > demo/demo-track-binary.smsg
mv demo/demo-track-binary.smsg demo/demo-track.smsg
```
### Option B: Detect format in JavaScript
Check if content is already base64 and skip re-encoding:
```javascript
// Check if content looks like base64 (ASCII text starting with valid base64 chars)
const isBase64 = /^[A-Za-z0-9+/=]+$/.test(text.trim());
if (!isBase64) {
// Binary content - encode to base64
base64 = btoa(binaryToString(bytes));
} else {
// Already base64 - use as-is
base64 = text;
}
```
### Option C: Use text fetch for base64 files
```javascript
// For base64-encoded .smsg files
const response = await fetch(DEMO_URL);
const base64 = await response.text(); // Don't re-encode
```
## Lesson Learned
SMSG files can exist in two formats:
1. **Binary** (.smsg) - raw bytes, magic number is `0x534D5347`
2. **Base64** (.smsg.b64 or .smsg with text content) - ASCII text, starts with `U01T`
The loader must detect which format it's receiving and handle accordingly.
## Recommended Fix
Implement Option A (binary storage) for the demo, as it's the canonical format and avoids ambiguity. Reserve Option B for the License Manager where users might drag-drop either format.
## Related
- `pkg/smsg/smsg.go` - SMSG format definition
- `pkg/wasm/stmf/main.go` - WASM decryption API
- `demo/index.html` - Demo page loader

View file

@ -0,0 +1,125 @@
# SMSG Format Specification
## Overview
SMSG (Secure Message) is an encrypted container format using ChaCha20-Poly1305 authenticated encryption.
## File Structure
```
┌─────────────────────────────────────────┐
│ Magic Number: "SMSG" (4 bytes) │
├─────────────────────────────────────────┤
│ Version: uint16 (2 bytes) │
├─────────────────────────────────────────┤
│ Header Length: uint32 (4 bytes) │
├─────────────────────────────────────────┤
│ Header (JSON, plaintext) │
│ - algorithm: "chacha20poly1305" │
│ - manifest: {title, artist, license...} │
│ - nonce: base64 │
├─────────────────────────────────────────┤
│ Encrypted Payload │
│ - Nonce (24 bytes for XChaCha20) │
│ - Ciphertext + Auth Tag │
└─────────────────────────────────────────┘
```
## Magic Number
- Binary: `0x53 0x4D 0x53 0x47`
- ASCII: "SMSG"
- Base64 (first 6 chars): "U01TRw"
## Header (JSON, unencrypted)
```json
{
"algorithm": "chacha20poly1305",
"manifest": {
"title": "Track Title",
"artist": "Artist Name",
"license": "CC-BY-4.0",
"expires": "2025-12-31T23:59:59Z",
"tracks": [
{"title": "Track 1", "start": 0, "trackNum": 1}
]
}
}
```
The manifest is **readable without decryption** - this enables:
- License validation before decryption
- Metadata display in file browsers
- Expiration enforcement
## Encrypted Payload (JSON)
```json
{
"from": "artist@example.com",
"to": "fan@example.com",
"subject": "Album Title",
"body": "Thank you for your purchase!",
"attachments": [
{
"name": "track.mp3",
"mime": "audio/mpeg",
"content": "<base64-encoded-data>"
}
]
}
```
## Key Derivation
```
password → SHA-256 → 32-byte key
```
Simple but effective - the password IS the license key.
## Storage Formats
### Binary (.smsg)
Raw bytes. Canonical format for distribution.
```
53 4D 53 47 02 00 00 00 33 00 00 00 7B 22 61 6C ...
S M S G [ver] [hdr len] {"al...
```
### Base64 Text (.smsg or .smsg.b64)
For embedding in JSON, URLs, or text-based transport.
```
U01TRwIAAAEzeyJhbGdvcml0aG0iOiJjaGFjaGEyMHBvbHkxMzA1Ii...
```
## WASM API
```javascript
// Initialize
const go = new Go();
await WebAssembly.instantiateStreaming(fetch('stmf.wasm'), go.importObject);
go.run(result.instance);
// Get metadata (no password needed)
const info = await BorgSMSG.getInfo(base64Content);
// info.manifest.title, info.manifest.expires, etc.
// Decrypt (requires password)
const msg = await BorgSMSG.decryptStream(base64Content, password);
// msg.attachments[0].data is Uint8Array (binary)
// msg.attachments[0].mime is MIME type
```
## Security Properties
1. **Authenticated Encryption**: ChaCha20-Poly1305 provides both confidentiality and integrity
2. **No Key Escrow**: Password never transmitted, derived locally
3. **Metadata Privacy**: Only manifest is public; actual content encrypted
4. **Browser-Safe**: WASM runs in sandbox, keys never leave client
## Use Cases
| Use Case | Format | Notes |
|----------|--------|-------|
| Direct download | Binary | Most efficient |
| Email attachment | Base64 | Safe for text transport |
| IPFS/CDN | Binary | Content-addressed |
| Embedded in JSON | Base64 | API responses |
| Browser demo | Either | Must detect format |

1
go.mod
View file

@ -7,6 +7,7 @@ require (
github.com/fatih/color v1.18.0 github.com/fatih/color v1.18.0
github.com/go-git/go-git/v5 v5.16.3 github.com/go-git/go-git/v5 v5.16.3
github.com/google/go-github/v39 v39.2.0 github.com/google/go-github/v39 v39.2.0
github.com/klauspost/compress v1.18.2
github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-isatty v0.0.20
github.com/schollz/progressbar/v3 v3.18.0 github.com/schollz/progressbar/v3 v3.18.0
github.com/spf13/cobra v1.10.1 github.com/spf13/cobra v1.10.1

2
go.sum
View file

@ -67,6 +67,8 @@ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4P
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=

Binary file not shown.

View file

@ -578,7 +578,7 @@
<button id="load-demo-btn" class="secondary" style="padding: 0.6rem 1.2rem; font-size: 0.85rem;">Load Demo Track</button> <button id="load-demo-btn" class="secondary" style="padding: 0.6rem 1.2rem; font-size: 0.85rem;">Load Demo Track</button>
</div> </div>
<div style="font-size: 0.8rem; color: #666; margin-top: 0.5rem;"> <div style="font-size: 0.8rem; color: #666; margin-top: 0.5rem;">
Password: <code style="background: rgba(0,0,0,0.3); padding: 0.2rem 0.5rem; border-radius: 4px; color: #00ff94;">dapp-fm-2024</code> Password: <code style="background: rgba(0,0,0,0.3); padding: 0.2rem 0.5rem; border-radius: 4px; color: #00ff94;">PMVXogAJNVe_DDABfTmLYztaJAzsD0R7</code>
</div> </div>
</div> </div>
@ -1233,7 +1233,7 @@
} }
const content = await response.text(); const content = await response.text();
document.getElementById('encrypted-content').value = content; document.getElementById('encrypted-content').value = content;
document.getElementById('license-token').value = 'dapp-fm-2024'; document.getElementById('license-token').value = 'PMVXogAJNVe_DDABfTmLYztaJAzsD0R7';
// Show preview // Show preview
await showManifestPreview(content); await showManifestPreview(content);

BIN
pkg/player/frontend/stmf.wasm Executable file

Binary file not shown.

View file

@ -1,14 +1,19 @@
package smsg package smsg
import ( import (
"bytes"
"compress/gzip"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/binary"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"time" "time"
"github.com/Snider/Enchantrix/pkg/enchantrix" "github.com/Snider/Enchantrix/pkg/enchantrix"
"github.com/Snider/Enchantrix/pkg/trix" "github.com/Snider/Enchantrix/pkg/trix"
"github.com/klauspost/compress/zstd"
) )
// DeriveKey derives a 32-byte key from a password using SHA-256. // DeriveKey derives a 32-byte key from a password using SHA-256.
@ -177,6 +182,7 @@ func EncryptWithManifestBase64(msg *Message, password string, manifest *Manifest
} }
// Decrypt decrypts an SMSG container with a password // Decrypt decrypts an SMSG container with a password
// Automatically handles both v1 (base64) and v2 (binary) formats
func Decrypt(data []byte, password string) (*Message, error) { func Decrypt(data []byte, password string) (*Message, error) {
if password == "" { if password == "" {
return nil, ErrPasswordRequired return nil, ErrPasswordRequired
@ -188,6 +194,16 @@ func Decrypt(data []byte, password string) (*Message, error) {
return nil, fmt.Errorf("%w: %v", ErrInvalidMagic, err) return nil, fmt.Errorf("%w: %v", ErrInvalidMagic, err)
} }
// Extract format and compression from header
format := ""
compression := ""
if f, ok := t.Header["format"].(string); ok {
format = f
}
if c, ok := t.Header["compression"].(string); ok {
compression = c
}
// Derive key and create sigil // Derive key and create sigil
key := DeriveKey(password) key := DeriveKey(password)
sigil, err := enchantrix.NewChaChaPolySigil(key) sigil, err := enchantrix.NewChaChaPolySigil(key)
@ -201,7 +217,28 @@ func Decrypt(data []byte, password string) (*Message, error) {
return nil, ErrDecryptionFailed return nil, ErrDecryptionFailed
} }
// Parse message // Decompress if needed
switch compression {
case CompressionGzip:
decompressed, err := gzipDecompress(decrypted)
if err != nil {
return nil, fmt.Errorf("gzip decompression failed: %w", err)
}
decrypted = decompressed
case CompressionZstd:
decompressed, err := zstdDecompress(decrypted)
if err != nil {
return nil, fmt.Errorf("zstd decompression failed: %w", err)
}
decrypted = decompressed
}
// Parse based on format
if format == FormatV2 {
return parseV2Payload(decrypted)
}
// v1 format: plain JSON with base64 attachments
var msg Message var msg Message
if err := json.Unmarshal(decrypted, &msg); err != nil { if err := json.Unmarshal(decrypted, &msg); err != nil {
return nil, fmt.Errorf("%w: invalid message format", ErrInvalidPayload) return nil, fmt.Errorf("%w: invalid message format", ErrInvalidPayload)
@ -233,6 +270,12 @@ func GetInfo(data []byte) (*Header, error) {
if v, ok := t.Header["algorithm"].(string); ok { if v, ok := t.Header["algorithm"].(string); ok {
header.Algorithm = v header.Algorithm = v
} }
if v, ok := t.Header["format"].(string); ok {
header.Format = v
}
if v, ok := t.Header["compression"].(string); ok {
header.Compression = v
}
if v, ok := t.Header["hint"].(string); ok { if v, ok := t.Header["hint"].(string); ok {
header.Hint = v header.Hint = v
} }
@ -284,3 +327,210 @@ func QuickDecrypt(encoded, password string) (string, error) {
} }
return msg.Body, nil return msg.Body, nil
} }
// EncryptV2 encrypts a message using v2 binary format (smaller file size)
// Attachments are stored as raw binary instead of base64-encoded JSON
// Uses zstd compression by default (faster than gzip, better ratio)
func EncryptV2(msg *Message, password string) ([]byte, error) {
return EncryptV2WithOptions(msg, password, nil, CompressionZstd)
}
// EncryptV2WithManifest encrypts with v2 binary format and public manifest
// Uses zstd compression by default (faster than gzip, better ratio)
func EncryptV2WithManifest(msg *Message, password string, manifest *Manifest) ([]byte, error) {
return EncryptV2WithOptions(msg, password, manifest, CompressionZstd)
}
// EncryptV2WithOptions encrypts with full control over format options
func EncryptV2WithOptions(msg *Message, password string, manifest *Manifest, compression string) ([]byte, error) {
if password == "" {
return nil, ErrPasswordRequired
}
if msg.Body == "" && len(msg.Attachments) == 0 {
return nil, ErrEmptyMessage
}
if msg.Timestamp == 0 {
msg.Timestamp = time.Now().Unix()
}
// Build v2 payload: [4-byte JSON length][JSON][binary attachments...]
payload, err := buildV2Payload(msg)
if err != nil {
return nil, fmt.Errorf("failed to build v2 payload: %w", err)
}
// Apply compression if requested
switch compression {
case CompressionGzip:
compressed, err := gzipCompress(payload)
if err != nil {
return nil, fmt.Errorf("gzip compression failed: %w", err)
}
payload = compressed
case CompressionZstd:
compressed, err := zstdCompress(payload)
if err != nil {
return nil, fmt.Errorf("zstd compression failed: %w", err)
}
payload = compressed
}
// Encrypt
key := DeriveKey(password)
sigil, err := enchantrix.NewChaChaPolySigil(key)
if err != nil {
return nil, fmt.Errorf("failed to create sigil: %w", err)
}
encrypted, err := sigil.In(payload)
if err != nil {
return nil, fmt.Errorf("encryption failed: %w", err)
}
// Build header
headerMap := map[string]interface{}{
"version": Version,
"algorithm": "chacha20poly1305",
"format": FormatV2,
}
if compression != CompressionNone {
headerMap["compression"] = compression
}
if manifest != nil {
headerMap["manifest"] = manifest
}
t := &trix.Trix{
Header: headerMap,
Payload: encrypted,
}
return trix.Encode(t, Magic, nil)
}
// buildV2Payload creates the v2 binary payload structure
func buildV2Payload(msg *Message) ([]byte, error) {
// Create a copy of the message with attachment content stripped
// We'll append the binary data after the JSON
msgCopy := *msg
var binaryData [][]byte
for i := range msgCopy.Attachments {
att := &msgCopy.Attachments[i]
if att.Content != "" {
// Decode the base64 content to get binary
data, err := base64.StdEncoding.DecodeString(att.Content)
if err != nil {
return nil, fmt.Errorf("invalid base64 in attachment %s: %w", att.Name, err)
}
binaryData = append(binaryData, data)
att.Size = len(data) // Store actual binary size
att.Content = "" // Clear content from JSON
} else {
binaryData = append(binaryData, nil)
}
}
// Serialize the message (without attachment content)
jsonData, err := json.Marshal(&msgCopy)
if err != nil {
return nil, fmt.Errorf("failed to marshal message: %w", err)
}
// Build payload: [4-byte length][JSON][binary1][binary2]...
var buf bytes.Buffer
// Write JSON length as uint32 big-endian
if err := binary.Write(&buf, binary.BigEndian, uint32(len(jsonData))); err != nil {
return nil, err
}
// Write JSON
buf.Write(jsonData)
// Write binary attachments
for _, data := range binaryData {
buf.Write(data)
}
return buf.Bytes(), nil
}
// parseV2Payload extracts message and binary attachments from v2 format
func parseV2Payload(data []byte) (*Message, error) {
if len(data) < 4 {
return nil, fmt.Errorf("payload too short")
}
// Read JSON length
jsonLen := binary.BigEndian.Uint32(data[:4])
if int(jsonLen) > len(data)-4 {
return nil, fmt.Errorf("invalid JSON length")
}
// Parse JSON
var msg Message
if err := json.Unmarshal(data[4:4+jsonLen], &msg); err != nil {
return nil, fmt.Errorf("failed to parse message JSON: %w", err)
}
// Read binary attachments
offset := 4 + int(jsonLen)
for i := range msg.Attachments {
att := &msg.Attachments[i]
if att.Size > 0 {
if offset+att.Size > len(data) {
return nil, fmt.Errorf("attachment %s: data truncated", att.Name)
}
// Re-encode as base64 for API compatibility
att.Content = base64.StdEncoding.EncodeToString(data[offset : offset+att.Size])
offset += att.Size
}
}
return &msg, nil
}
// gzipCompress compresses data using gzip
func gzipCompress(data []byte) ([]byte, error) {
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
if _, err := w.Write(data); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// gzipDecompress decompresses gzip data
func gzipDecompress(data []byte) ([]byte, error) {
r, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, err
}
defer r.Close()
return io.ReadAll(r)
}
// zstdCompress compresses data using zstd (faster than gzip, better ratio)
func zstdCompress(data []byte) ([]byte, error) {
encoder, err := zstd.NewWriter(nil)
if err != nil {
return nil, err
}
defer encoder.Close()
return encoder.EncodeAll(data, nil), nil
}
// zstdDecompress decompresses zstd data
func zstdDecompress(data []byte) ([]byte, error) {
decoder, err := zstd.NewReader(nil)
if err != nil {
return nil, err
}
defer decoder.Close()
return decoder.DecodeAll(data, nil)
}

View file

@ -462,3 +462,249 @@ func TestExpirationInHeader(t *testing.T) {
t.Error("New streaming license should not be expired") t.Error("New streaming license should not be expired")
} }
} }
func TestManifestLinks(t *testing.T) {
manifest := NewManifest("Test Track").
AddLink("home", "https://example.com/artist").
AddLink("beatport", "https://beatport.com/artist/test").
AddLink("soundcloud", "https://soundcloud.com/test")
if len(manifest.Links) != 3 {
t.Fatalf("Links count = %d, want 3", len(manifest.Links))
}
if manifest.Links["home"] != "https://example.com/artist" {
t.Errorf("Links[home] = %q, want %q", manifest.Links["home"], "https://example.com/artist")
}
if manifest.Links["beatport"] != "https://beatport.com/artist/test" {
t.Errorf("Links[beatport] = %q, want %q", manifest.Links["beatport"], "https://beatport.com/artist/test")
}
// Test manifest with links in encrypted message
msg := NewMessage("Track content")
password := "link-test"
encrypted, err := EncryptWithManifest(msg, password, manifest)
if err != nil {
t.Fatalf("EncryptWithManifest failed: %v", err)
}
header, err := GetInfo(encrypted)
if err != nil {
t.Fatalf("GetInfo failed: %v", err)
}
if header.Manifest == nil {
t.Fatal("Expected manifest in header")
}
if len(header.Manifest.Links) != 3 {
t.Fatalf("Header Links count = %d, want 3", len(header.Manifest.Links))
}
if header.Manifest.Links["home"] != "https://example.com/artist" {
t.Errorf("Header Links[home] = %q, want %q", header.Manifest.Links["home"], "https://example.com/artist")
}
}
func TestV2BinaryFormat(t *testing.T) {
// Create message with binary attachment
binaryData := []byte("Hello, this is binary content! \x00\x01\x02\x03")
msg := NewMessage("V2 format test").
AddBinaryAttachment("test.bin", binaryData, "application/octet-stream")
password := "v2-test"
// Encrypt with v2 format
encrypted, err := EncryptV2(msg, password)
if err != nil {
t.Fatalf("EncryptV2 failed: %v", err)
}
// Check header
header, err := GetInfo(encrypted)
if err != nil {
t.Fatalf("GetInfo failed: %v", err)
}
if header.Format != FormatV2 {
t.Errorf("Format = %q, want %q", header.Format, FormatV2)
}
if header.Compression != CompressionZstd {
t.Errorf("Compression = %q, want %q", header.Compression, CompressionZstd)
}
// Decrypt
decrypted, err := Decrypt(encrypted, password)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if decrypted.Body != "V2 format test" {
t.Errorf("Body = %q, want %q", decrypted.Body, "V2 format test")
}
if len(decrypted.Attachments) != 1 {
t.Fatalf("Attachments count = %d, want 1", len(decrypted.Attachments))
}
att := decrypted.Attachments[0]
if att.Name != "test.bin" {
t.Errorf("Attachment name = %q, want %q", att.Name, "test.bin")
}
// Decode attachment and verify content
decoded, err := base64.StdEncoding.DecodeString(att.Content)
if err != nil {
t.Fatalf("Failed to decode attachment: %v", err)
}
if string(decoded) != string(binaryData) {
t.Errorf("Attachment content mismatch")
}
}
func TestV2WithManifest(t *testing.T) {
binaryData := make([]byte, 1024) // 1KB of zeros
for i := range binaryData {
binaryData[i] = byte(i % 256)
}
msg := NewMessage("V2 with manifest").
AddBinaryAttachment("data.bin", binaryData, "application/octet-stream")
manifest := NewManifest("Test Album").
AddLink("home", "https://example.com")
manifest.Artist = "Test Artist"
password := "v2-manifest-test"
encrypted, err := EncryptV2WithManifest(msg, password, manifest)
if err != nil {
t.Fatalf("EncryptV2WithManifest failed: %v", err)
}
// Verify header
header, err := GetInfo(encrypted)
if err != nil {
t.Fatalf("GetInfo failed: %v", err)
}
if header.Format != FormatV2 {
t.Errorf("Format = %q, want %q", header.Format, FormatV2)
}
if header.Manifest == nil {
t.Fatal("Expected manifest")
}
if header.Manifest.Title != "Test Album" {
t.Errorf("Manifest Title = %q, want %q", header.Manifest.Title, "Test Album")
}
if header.Manifest.Artist != "Test Artist" {
t.Errorf("Manifest Artist = %q, want %q", header.Manifest.Artist, "Test Artist")
}
// Decrypt and verify
decrypted, err := Decrypt(encrypted, password)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if len(decrypted.Attachments) != 1 {
t.Fatalf("Attachments count = %d, want 1", len(decrypted.Attachments))
}
decoded, _ := base64.StdEncoding.DecodeString(decrypted.Attachments[0].Content)
if len(decoded) != 1024 {
t.Errorf("Decoded length = %d, want 1024", len(decoded))
}
}
func TestV2SizeSavings(t *testing.T) {
// Create a message with binary data
binaryData := make([]byte, 10000) // 10KB
for i := range binaryData {
binaryData[i] = byte(i % 256)
}
msg := NewMessage("Size comparison test")
msg.AddBinaryAttachment("large.bin", binaryData, "application/octet-stream")
password := "size-test"
// Encrypt with v1 (base64)
v1Encrypted, err := Encrypt(msg, password)
if err != nil {
t.Fatalf("Encrypt v1 failed: %v", err)
}
// Encrypt with v2 (binary + gzip)
v2Encrypted, err := EncryptV2(msg, password)
if err != nil {
t.Fatalf("EncryptV2 failed: %v", err)
}
t.Logf("V1 size: %d bytes", len(v1Encrypted))
t.Logf("V2 size: %d bytes", len(v2Encrypted))
t.Logf("Savings: %.1f%%", (1.0-float64(len(v2Encrypted))/float64(len(v1Encrypted)))*100)
// V2 should be smaller (at least 20% savings from base64 removal alone)
if len(v2Encrypted) >= len(v1Encrypted) {
t.Errorf("V2 should be smaller than V1: v2=%d, v1=%d", len(v2Encrypted), len(v1Encrypted))
}
// Both should decrypt to the same content
d1, _ := Decrypt(v1Encrypted, password)
d2, _ := Decrypt(v2Encrypted, password)
if d1.Body != d2.Body {
t.Error("Decrypted bodies don't match")
}
c1, _ := base64.StdEncoding.DecodeString(d1.Attachments[0].Content)
c2, _ := base64.StdEncoding.DecodeString(d2.Attachments[0].Content)
if string(c1) != string(c2) {
t.Error("Decrypted attachment content doesn't match")
}
}
func TestV2NoCompression(t *testing.T) {
msg := NewMessage("No compression test").
AddBinaryAttachment("test.txt", []byte("Hello World"), "text/plain")
password := "no-compress"
// Encrypt without compression
encrypted, err := EncryptV2WithOptions(msg, password, nil, CompressionNone)
if err != nil {
t.Fatalf("EncryptV2WithOptions failed: %v", err)
}
header, err := GetInfo(encrypted)
if err != nil {
t.Fatalf("GetInfo failed: %v", err)
}
if header.Format != FormatV2 {
t.Errorf("Format = %q, want %q", header.Format, FormatV2)
}
if header.Compression != "" {
t.Errorf("Compression = %q, want empty", header.Compression)
}
// Should still decrypt
decrypted, err := Decrypt(encrypted, password)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if decrypted.Body != "No compression test" {
t.Errorf("Body = %q, want %q", decrypted.Body, "No compression test")
}
}

View file

@ -5,6 +5,7 @@
package smsg package smsg
import ( import (
"encoding/base64"
"errors" "errors"
"time" "time"
) )
@ -27,9 +28,9 @@ var (
// Attachment represents a file attached to the message // Attachment represents a file attached to the message
type Attachment struct { type Attachment struct {
Name string `json:"name"` Name string `json:"name"`
Content string `json:"content"` // base64-encoded Content string `json:"content,omitempty"` // base64-encoded (v1) or empty (v2, populated on decrypt)
MimeType string `json:"mime,omitempty"` MimeType string `json:"mime,omitempty"`
Size int `json:"size,omitempty"` Size int `json:"size,omitempty"` // binary size in bytes
} }
// PKIInfo contains public key information for authenticated replies // PKIInfo contains public key information for authenticated replies
@ -84,13 +85,25 @@ func (m *Message) WithTimestamp(ts int64) *Message {
return m return m
} }
// AddAttachment adds a file attachment // AddAttachment adds a file attachment (content is base64-encoded)
func (m *Message) AddAttachment(name, content, mimeType string) *Message { func (m *Message) AddAttachment(name, content, mimeType string) *Message {
m.Attachments = append(m.Attachments, Attachment{ m.Attachments = append(m.Attachments, Attachment{
Name: name, Name: name,
Content: content, Content: content,
MimeType: mimeType, MimeType: mimeType,
Size: len(content), Size: len(content), // base64 size for v1 compatibility
})
return m
}
// AddBinaryAttachment adds a raw binary attachment (for v2 format)
// The content will be base64-encoded for API compatibility
func (m *Message) AddBinaryAttachment(name string, data []byte, mimeType string) *Message {
m.Attachments = append(m.Attachments, Attachment{
Name: name,
Content: base64.StdEncoding.EncodeToString(data),
MimeType: mimeType,
Size: len(data), // actual binary size
}) })
return m return m
} }
@ -161,6 +174,9 @@ type Manifest struct {
// Track list (like CD master) // Track list (like CD master)
Tracks []Track `json:"tracks,omitempty"` Tracks []Track `json:"tracks,omitempty"`
// Artist links - direct to artist, skip the middlemen
Links map[string]string `json:"links,omitempty"` // platform -> URL (bandcamp, soundcloud, website, etc.)
// Custom metadata // Custom metadata
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Extra map[string]string `json:"extra,omitempty"` Extra map[string]string `json:"extra,omitempty"`
@ -170,6 +186,7 @@ type Manifest struct {
func NewManifest(title string) *Manifest { func NewManifest(title string) *Manifest {
return &Manifest{ return &Manifest{
Title: title, Title: title,
Links: make(map[string]string),
Extra: make(map[string]string), Extra: make(map[string]string),
LicenseType: "perpetual", LicenseType: "perpetual",
} }
@ -248,10 +265,34 @@ func (m *Manifest) AddTrackFull(title string, start, end float64, trackType stri
return m return m
} }
// AddLink adds an artist link (platform -> URL)
func (m *Manifest) AddLink(platform, url string) *Manifest {
if m.Links == nil {
m.Links = make(map[string]string)
}
m.Links[platform] = url
return m
}
// Format versions
const (
FormatV1 = "" // Original format: JSON with base64-encoded attachments
FormatV2 = "v2" // Binary format: JSON header + raw binary attachments
)
// Compression types
const (
CompressionNone = "" // No compression (default, backwards compatible)
CompressionGzip = "gzip" // Gzip compression (stdlib, WASM compatible)
CompressionZstd = "zstd" // Zstandard compression (faster, better ratio)
)
// Header represents the SMSG container header // Header represents the SMSG container header
type Header struct { type Header struct {
Version string `json:"version"` Version string `json:"version"`
Algorithm string `json:"algorithm"` Algorithm string `json:"algorithm"`
Hint string `json:"hint,omitempty"` // optional password hint Format string `json:"format,omitempty"` // v2 for binary, empty for v1 (base64)
Manifest *Manifest `json:"manifest,omitempty"` // public metadata for discovery Compression string `json:"compression,omitempty"` // gzip or empty for none
Hint string `json:"hint,omitempty"` // optional password hint
Manifest *Manifest `json:"manifest,omitempty"` // public metadata for discovery
} }

View file

@ -16,7 +16,7 @@ import (
) )
// Version of the WASM module // Version of the WASM module
const Version = "1.1.0" const Version = "1.2.0"
func main() { func main() {
// Export the BorgSTMF object to JavaScript global scope // Export the BorgSTMF object to JavaScript global scope
@ -31,6 +31,7 @@ func main() {
// Export BorgSMSG for secure message handling // Export BorgSMSG for secure message handling
js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{ js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{
"decrypt": js.FuncOf(smsgDecrypt), "decrypt": js.FuncOf(smsgDecrypt),
"decryptStream": js.FuncOf(smsgDecryptStream),
"encrypt": js.FuncOf(smsgEncrypt), "encrypt": js.FuncOf(smsgEncrypt),
"encryptWithManifest": js.FuncOf(smsgEncryptWithManifest), "encryptWithManifest": js.FuncOf(smsgEncryptWithManifest),
"getInfo": js.FuncOf(smsgGetInfo), "getInfo": js.FuncOf(smsgGetInfo),
@ -284,6 +285,82 @@ func smsgDecrypt(this js.Value, args []js.Value) interface{} {
return promiseConstructor.New(handler) return promiseConstructor.New(handler)
} }
// smsgDecryptStream decrypts and returns attachment data as Uint8Array for streaming.
// This is more efficient than the regular decrypt which returns base64 strings.
// JavaScript usage:
//
// const result = await BorgSMSG.decryptStream(encryptedBase64, password);
// // result.attachments[0].data is a Uint8Array ready for MediaSource/Blob
// const blob = new Blob([result.attachments[0].data], {type: result.attachments[0].mime});
// audio.src = URL.createObjectURL(blob);
func smsgDecryptStream(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("decryptStream requires 2 arguments: encryptedBase64, password"))
return
}
encryptedB64 := args[0].String()
password := args[1].String()
msg, err := smsg.DecryptBase64(encryptedB64, 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 (not base64 string)
if len(msg.Attachments) > 0 {
attachments := make([]interface{}, len(msg.Attachments))
for i, att := range msg.Attachments {
// Decode base64 to binary
data, err := base64.StdEncoding.DecodeString(att.Content)
if err != nil {
reject.Invoke(newError("failed to decode attachment: " + err.Error()))
return
}
// Create Uint8Array in JS
uint8Array := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(uint8Array, data)
attachments[i] = map[string]interface{}{
"name": att.Name,
"mime": att.MimeType,
"size": len(data),
"data": uint8Array, // Direct binary data!
}
}
result["attachments"] = attachments
}
resolve.Invoke(js.ValueOf(result))
}()
return nil
})
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
// smsgEncrypt encrypts a message with a password. // smsgEncrypt encrypts a message with a password.
// JavaScript usage: // JavaScript usage:
// //
@ -408,6 +485,12 @@ func smsgGetInfo(this js.Value, args []js.Value) interface{} {
"version": header.Version, "version": header.Version,
"algorithm": header.Algorithm, "algorithm": header.Algorithm,
} }
if header.Format != "" {
result["format"] = header.Format
}
if header.Compression != "" {
result["compression"] = header.Compression
}
if header.Hint != "" { if header.Hint != "" {
result["hint"] = header.Hint result["hint"] = header.Hint
} }
@ -667,6 +750,15 @@ func manifestToJS(m *smsg.Manifest) map[string]interface{} {
result["tags"] = tags result["tags"] = tags
} }
// Convert links
if len(m.Links) > 0 {
links := make(map[string]interface{})
for k, v := range m.Links {
links[k] = v
}
result["links"] = links
}
// Convert extra // Convert extra
if len(m.Extra) > 0 { if len(m.Extra) > 0 {
extra := make(map[string]interface{}) extra := make(map[string]interface{})
@ -686,6 +778,7 @@ func jsToManifest(obj js.Value) *smsg.Manifest {
} }
manifest := &smsg.Manifest{ manifest := &smsg.Manifest{
Links: make(map[string]string),
Extra: make(map[string]string), Extra: make(map[string]string),
} }