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:
parent
ef3d6e9731
commit
22e42d721a
20 changed files with 4870 additions and 110 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -4,3 +4,9 @@ borg
|
|||
*.datanode
|
||||
.idea
|
||||
coverage.txt
|
||||
|
||||
# Demo content (hosted on CDN)
|
||||
demo-track.smsg
|
||||
|
||||
# Dev artifacts
|
||||
.playwright-mcp/
|
||||
|
|
|
|||
248
README.md
248
README.md
|
|
@ -1,115 +1,171 @@
|
|||
# Borg Data Collector
|
||||
# Borg
|
||||
|
||||
[](https://codecov.io/github/Snider/Borg)
|
||||
[](go.mod)
|
||||
[](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
|
||||
- Docs (MkDocs Material): see docs/ locally with `mkdocs serve`
|
||||
- Quick build: `go build -o borg ./` or `task build`
|
||||
- Releases: configured via GoReleaser (`.goreleaser.yaml`)
|
||||
## Features
|
||||
|
||||
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.`
|
||||
- `Assimilating bytes… stand by for cube‑formation.`
|
||||
- `Initializing the Core—prepare for quantum‑level sync.`
|
||||
- `Data streams converging… the Core is humming.`
|
||||
- `Merging… the Core is rewriting reality, one block at a time.`
|
||||
- `Encrypting… the Core’s got your secrets under lock‑and‑key.`
|
||||
- `Compiling the future… the Core never sleeps.`
|
||||
- `Splicing files… the Core’s 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 Core‑powered 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
|
||||
|
||||
- `Initiating contact with Enchantrix… spice‑369 infusion underway.`
|
||||
**Encryption**
|
||||
- `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.`
|
||||
- `Decrypting… the witch returns the original essence.`
|
||||
- `Rotating enchantments – spice‑369 recalibrated, old sigils discarded.`
|
||||
- `Authentication complete – the witch acknowledges the Core.`
|
||||
- `Authentication denied – the witch refuses the impostor’s 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 – spice‑369 saturation, throttling assimilation.`
|
||||
- `Anomalous entity encountered – the Core cannot parse the witch’s 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
|
||||
- `Encrypting payload – the Core feeds data to the witch's cauldron.`
|
||||
- `Merge complete – data assimilated, encrypted, and sealed within us.`
|
||||
|
||||
**VCS Processing**
|
||||
- `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.`
|
||||
- `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
|
||||
|
||||
- `Scanning PWA manifest… the Core identifies service‑worker signatures.`
|
||||
- `Pulling HTML, CSS, JS, and media… the hive gathers every byte for assimilation.`
|
||||
- `Capturing service‑worker logic… the Core extracts offline‑runtime spells.`
|
||||
- `Packing cache entries into a .cube… each asset sealed in a portable shard.`
|
||||
- `Embedding manifest metadata… the PWA’s identity becomes part of the collective.`
|
||||
- `Encrypting the cube… the Core cloaks the PWA in quantum‑grade 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 self‑contained DataCube, ready for distribution.`
|
||||
- ``
|
||||
|
||||
### Code Related Long
|
||||
|
||||
- `Assimilate code, encapsulate change—your repo is now a cube‑bound collective.`
|
||||
- `We have detected unstructured data. Initiating code absorption and change containment.`
|
||||
- `Your version history is obsolete. Submitting it to the Core for permanent cube‑ification.`
|
||||
- `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: `Apple‑grade assimilation – the Core preserves HDR.`
|
||||
- raw: `Raw data intake – the Core ingests the sensor’s soul`
|
||||
- ico: `Iconic assimilation – the Core packs the smallest symbols.`
|
||||
- avif: `Next‑gen assimilation – the Core squeezes the future.`
|
||||
- tiff: `High‑definition capture – the Core stores every photon.`
|
||||
- gif: `Looped assimilation – the Core keeps the animation alive.`
|
||||
</details>
|
||||
|
|
|
|||
642
RFC-001-OSS-DRM.md
Normal file
642
RFC-001-OSS-DRM.md
Normal 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** 💜
|
||||
|
|
@ -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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
|
@ -957,7 +957,7 @@
|
|||
const result = await window.go.main.App.LoadDemo();
|
||||
|
||||
// Display the decrypted media
|
||||
displayMedia(result, 'dapp-fm-2024');
|
||||
displayMedia(result, 'PMVXogAJNVe_DDABfTmLYztaJAzsD0R7');
|
||||
|
||||
btn.textContent = 'Loaded!';
|
||||
setTimeout(() => {
|
||||
|
|
|
|||
85
cmd/mkdemo/main.go
Normal file
85
cmd/mkdemo/main.go
Normal 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
2543
demo/index.html
Normal file
File diff suppressed because it is too large
Load diff
BIN
demo/profile-avatar.jpg
Normal file
BIN
demo/profile-avatar.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
BIN
demo/stmf.wasm
Executable file
BIN
demo/stmf.wasm
Executable file
Binary file not shown.
575
demo/wasm_exec.js
Normal file
575
demo/wasm_exec.js
Normal 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
95
examples/failures/001-double-base64-encoding.md
Normal file
95
examples/failures/001-double-base64-encoding.md
Normal 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
|
||||
125
examples/formats/smsg-format.md
Normal file
125
examples/formats/smsg-format.md
Normal 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
1
go.mod
|
|
@ -7,6 +7,7 @@ require (
|
|||
github.com/fatih/color v1.18.0
|
||||
github.com/go-git/go-git/v5 v5.16.3
|
||||
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/schollz/progressbar/v3 v3.18.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -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/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/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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
|
|
@ -1233,7 +1233,7 @@
|
|||
}
|
||||
const content = await response.text();
|
||||
document.getElementById('encrypted-content').value = content;
|
||||
document.getElementById('license-token').value = 'dapp-fm-2024';
|
||||
document.getElementById('license-token').value = 'PMVXogAJNVe_DDABfTmLYztaJAzsD0R7';
|
||||
|
||||
// Show preview
|
||||
await showManifestPreview(content);
|
||||
|
|
|
|||
BIN
pkg/player/frontend/stmf.wasm
Executable file
BIN
pkg/player/frontend/stmf.wasm
Executable file
Binary file not shown.
252
pkg/smsg/smsg.go
252
pkg/smsg/smsg.go
|
|
@ -1,14 +1,19 @@
|
|||
package smsg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/Snider/Enchantrix/pkg/enchantrix"
|
||||
"github.com/Snider/Enchantrix/pkg/trix"
|
||||
"github.com/klauspost/compress/zstd"
|
||||
)
|
||||
|
||||
// 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
|
||||
// Automatically handles both v1 (base64) and v2 (binary) formats
|
||||
func Decrypt(data []byte, password string) (*Message, error) {
|
||||
if password == "" {
|
||||
return nil, ErrPasswordRequired
|
||||
|
|
@ -188,6 +194,16 @@ func Decrypt(data []byte, password string) (*Message, error) {
|
|||
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
|
||||
key := DeriveKey(password)
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(key)
|
||||
|
|
@ -201,7 +217,28 @@ func Decrypt(data []byte, password string) (*Message, error) {
|
|||
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
|
||||
if err := json.Unmarshal(decrypted, &msg); err != nil {
|
||||
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 {
|
||||
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 {
|
||||
header.Hint = v
|
||||
}
|
||||
|
|
@ -284,3 +327,210 @@ func QuickDecrypt(encoded, password string) (string, error) {
|
|||
}
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -462,3 +462,249 @@ func TestExpirationInHeader(t *testing.T) {
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
package smsg
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -27,9 +28,9 @@ var (
|
|||
// Attachment represents a file attached to the message
|
||||
type Attachment struct {
|
||||
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"`
|
||||
Size int `json:"size,omitempty"`
|
||||
Size int `json:"size,omitempty"` // binary size in bytes
|
||||
}
|
||||
|
||||
// PKIInfo contains public key information for authenticated replies
|
||||
|
|
@ -84,13 +85,25 @@ func (m *Message) WithTimestamp(ts int64) *Message {
|
|||
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 {
|
||||
m.Attachments = append(m.Attachments, Attachment{
|
||||
Name: name,
|
||||
Content: content,
|
||||
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
|
||||
}
|
||||
|
|
@ -161,6 +174,9 @@ type Manifest struct {
|
|||
// Track list (like CD master)
|
||||
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
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
|
|
@ -170,6 +186,7 @@ type Manifest struct {
|
|||
func NewManifest(title string) *Manifest {
|
||||
return &Manifest{
|
||||
Title: title,
|
||||
Links: make(map[string]string),
|
||||
Extra: make(map[string]string),
|
||||
LicenseType: "perpetual",
|
||||
}
|
||||
|
|
@ -248,10 +265,34 @@ func (m *Manifest) AddTrackFull(title string, start, end float64, trackType stri
|
|||
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
|
||||
type Header struct {
|
||||
Version string `json:"version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
Format string `json:"format,omitempty"` // v2 for binary, empty for v1 (base64)
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
)
|
||||
|
||||
// Version of the WASM module
|
||||
const Version = "1.1.0"
|
||||
const Version = "1.2.0"
|
||||
|
||||
func main() {
|
||||
// Export the BorgSTMF object to JavaScript global scope
|
||||
|
|
@ -31,6 +31,7 @@ func main() {
|
|||
// Export BorgSMSG for secure message handling
|
||||
js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{
|
||||
"decrypt": js.FuncOf(smsgDecrypt),
|
||||
"decryptStream": js.FuncOf(smsgDecryptStream),
|
||||
"encrypt": js.FuncOf(smsgEncrypt),
|
||||
"encryptWithManifest": js.FuncOf(smsgEncryptWithManifest),
|
||||
"getInfo": js.FuncOf(smsgGetInfo),
|
||||
|
|
@ -284,6 +285,82 @@ func smsgDecrypt(this js.Value, args []js.Value) interface{} {
|
|||
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.
|
||||
// JavaScript usage:
|
||||
//
|
||||
|
|
@ -408,6 +485,12 @@ func smsgGetInfo(this js.Value, args []js.Value) 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
|
||||
}
|
||||
|
|
@ -667,6 +750,15 @@ func manifestToJS(m *smsg.Manifest) map[string]interface{} {
|
|||
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
|
||||
if len(m.Extra) > 0 {
|
||||
extra := make(map[string]interface{})
|
||||
|
|
@ -686,6 +778,7 @@ func jsToManifest(obj js.Value) *smsg.Manifest {
|
|||
}
|
||||
|
||||
manifest := &smsg.Manifest{
|
||||
Links: make(map[string]string),
|
||||
Extra: make(map[string]string),
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue