Compare commits
16 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a77024aad4 | ||
|
|
eae9de0cf6 | ||
|
|
6e38c4f3a6 | ||
|
|
c26d841b1b | ||
|
|
cf2af53ed3 | ||
|
|
63b8a3ecb6 | ||
|
|
8486242fd8 | ||
|
|
bd7e8b3040 | ||
|
|
2debed53f1 | ||
|
|
0ba0897c25 | ||
|
|
3d903c5a27 | ||
|
|
2da38ae462 | ||
|
|
22e42d721a | ||
|
|
ef3d6e9731 | ||
|
|
727072e2e5 | ||
|
|
b94ffbab5e |
61 changed files with 21477 additions and 122 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>
|
||||
|
|
|
|||
333
cmd/collect_local.go
Normal file
333
cmd/collect_local.go
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Snider/Borg/pkg/compress"
|
||||
"github.com/Snider/Borg/pkg/datanode"
|
||||
"github.com/Snider/Borg/pkg/tim"
|
||||
"github.com/Snider/Borg/pkg/trix"
|
||||
"github.com/Snider/Borg/pkg/ui"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type CollectLocalCmd struct {
|
||||
cobra.Command
|
||||
}
|
||||
|
||||
// NewCollectLocalCmd creates a new collect local command
|
||||
func NewCollectLocalCmd() *CollectLocalCmd {
|
||||
c := &CollectLocalCmd{}
|
||||
c.Command = cobra.Command{
|
||||
Use: "local [directory]",
|
||||
Short: "Collect files from a local directory",
|
||||
Long: `Collect files from a local directory and store them in a DataNode.
|
||||
|
||||
If no directory is specified, the current working directory is used.
|
||||
|
||||
Examples:
|
||||
borg collect local
|
||||
borg collect local ./src
|
||||
borg collect local /path/to/project --output project.tar
|
||||
borg collect local . --format stim --password secret
|
||||
borg collect local . --exclude "*.log" --exclude "node_modules"`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
directory := "."
|
||||
if len(args) > 0 {
|
||||
directory = args[0]
|
||||
}
|
||||
|
||||
outputFile, _ := cmd.Flags().GetString("output")
|
||||
format, _ := cmd.Flags().GetString("format")
|
||||
compression, _ := cmd.Flags().GetString("compression")
|
||||
password, _ := cmd.Flags().GetString("password")
|
||||
excludes, _ := cmd.Flags().GetStringSlice("exclude")
|
||||
includeHidden, _ := cmd.Flags().GetBool("hidden")
|
||||
respectGitignore, _ := cmd.Flags().GetBool("gitignore")
|
||||
|
||||
finalPath, err := CollectLocal(directory, outputFile, format, compression, password, excludes, includeHidden, respectGitignore)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Files saved to", finalPath)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().String("output", "", "Output file for the DataNode")
|
||||
c.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
|
||||
c.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
|
||||
c.Flags().String("password", "", "Password for encryption (required for stim/trix format)")
|
||||
c.Flags().StringSlice("exclude", nil, "Patterns to exclude (can be specified multiple times)")
|
||||
c.Flags().Bool("hidden", false, "Include hidden files and directories")
|
||||
c.Flags().Bool("gitignore", true, "Respect .gitignore files (default: true)")
|
||||
return c
|
||||
}
|
||||
|
||||
func init() {
|
||||
collectCmd.AddCommand(&NewCollectLocalCmd().Command)
|
||||
}
|
||||
|
||||
// CollectLocal collects files from a local directory into a DataNode
|
||||
func CollectLocal(directory string, outputFile string, format string, compression string, password string, excludes []string, includeHidden bool, respectGitignore bool) (string, error) {
|
||||
// Validate format
|
||||
if format != "datanode" && format != "tim" && format != "trix" && format != "stim" {
|
||||
return "", fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', 'trix', or 'stim')", format)
|
||||
}
|
||||
if (format == "stim" || format == "trix") && password == "" {
|
||||
return "", fmt.Errorf("password is required for %s format", format)
|
||||
}
|
||||
if compression != "none" && compression != "gz" && compression != "xz" {
|
||||
return "", fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
|
||||
}
|
||||
|
||||
// Resolve directory path
|
||||
absDir, err := filepath.Abs(directory)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error resolving directory path: %w", err)
|
||||
}
|
||||
|
||||
info, err := os.Stat(absDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error accessing directory: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("not a directory: %s", absDir)
|
||||
}
|
||||
|
||||
// Load gitignore patterns if enabled
|
||||
var gitignorePatterns []string
|
||||
if respectGitignore {
|
||||
gitignorePatterns = loadGitignore(absDir)
|
||||
}
|
||||
|
||||
// Create DataNode and collect files
|
||||
dn := datanode.New()
|
||||
var fileCount int
|
||||
|
||||
bar := ui.NewProgressBar(-1, "Scanning files")
|
||||
defer bar.Finish()
|
||||
|
||||
err = filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get relative path
|
||||
relPath, err := filepath.Rel(absDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip root
|
||||
if relPath == "." {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip hidden files/dirs unless explicitly included
|
||||
if !includeHidden && isHidden(relPath) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check gitignore patterns
|
||||
if respectGitignore && matchesGitignore(relPath, d.IsDir(), gitignorePatterns) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check exclude patterns
|
||||
if matchesExclude(relPath, excludes) {
|
||||
if d.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Skip directories (they're implicit in DataNode)
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read file content
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading %s: %w", relPath, err)
|
||||
}
|
||||
|
||||
// Add to DataNode with forward slashes (tar convention)
|
||||
dn.AddData(filepath.ToSlash(relPath), content)
|
||||
fileCount++
|
||||
bar.Describe(fmt.Sprintf("Collected %d files", fileCount))
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error walking directory: %w", err)
|
||||
}
|
||||
|
||||
if fileCount == 0 {
|
||||
return "", fmt.Errorf("no files found in %s", directory)
|
||||
}
|
||||
|
||||
bar.Describe(fmt.Sprintf("Packaging %d files", fileCount))
|
||||
|
||||
// Convert to output format
|
||||
var data []byte
|
||||
if format == "tim" {
|
||||
t, err := tim.FromDataNode(dn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating tim: %w", err)
|
||||
}
|
||||
data, err = t.ToTar()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error serializing tim: %w", err)
|
||||
}
|
||||
} else if format == "stim" {
|
||||
t, err := tim.FromDataNode(dn)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error creating tim: %w", err)
|
||||
}
|
||||
data, err = t.ToSigil(password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error encrypting stim: %w", err)
|
||||
}
|
||||
} else if format == "trix" {
|
||||
data, err = trix.ToTrix(dn, password)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error serializing trix: %w", err)
|
||||
}
|
||||
} else {
|
||||
data, err = dn.ToTar()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error serializing DataNode: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply compression
|
||||
compressedData, err := compress.Compress(data, compression)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error compressing data: %w", err)
|
||||
}
|
||||
|
||||
// Determine output filename
|
||||
if outputFile == "" {
|
||||
baseName := filepath.Base(absDir)
|
||||
if baseName == "." || baseName == "/" {
|
||||
baseName = "local"
|
||||
}
|
||||
outputFile = baseName + "." + format
|
||||
if compression != "none" {
|
||||
outputFile += "." + compression
|
||||
}
|
||||
}
|
||||
|
||||
err = os.WriteFile(outputFile, compressedData, 0644)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error writing output file: %w", err)
|
||||
}
|
||||
|
||||
return outputFile, nil
|
||||
}
|
||||
|
||||
// isHidden checks if a path component starts with a dot
|
||||
func isHidden(path string) bool {
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, ".") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loadGitignore loads patterns from .gitignore if it exists
|
||||
func loadGitignore(dir string) []string {
|
||||
var patterns []string
|
||||
|
||||
gitignorePath := filepath.Join(dir, ".gitignore")
|
||||
content, err := os.ReadFile(gitignorePath)
|
||||
if err != nil {
|
||||
return patterns
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
// Skip empty lines and comments
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
patterns = append(patterns, line)
|
||||
}
|
||||
|
||||
return patterns
|
||||
}
|
||||
|
||||
// matchesGitignore checks if a path matches any gitignore pattern
|
||||
func matchesGitignore(path string, isDir bool, patterns []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
// Handle directory-only patterns
|
||||
if strings.HasSuffix(pattern, "/") {
|
||||
if !isDir {
|
||||
continue
|
||||
}
|
||||
pattern = strings.TrimSuffix(pattern, "/")
|
||||
}
|
||||
|
||||
// Handle negation (simplified - just skip negated patterns)
|
||||
if strings.HasPrefix(pattern, "!") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Match against path components
|
||||
matched, _ := filepath.Match(pattern, filepath.Base(path))
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
|
||||
// Also try matching the full path
|
||||
matched, _ = filepath.Match(pattern, path)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
|
||||
// Handle ** patterns (simplified)
|
||||
if strings.Contains(pattern, "**") {
|
||||
simplePattern := strings.ReplaceAll(pattern, "**", "*")
|
||||
matched, _ = filepath.Match(simplePattern, path)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchesExclude checks if a path matches any exclude pattern
|
||||
func matchesExclude(path string, excludes []string) bool {
|
||||
for _, pattern := range excludes {
|
||||
// Match against basename
|
||||
matched, _ := filepath.Match(pattern, filepath.Base(path))
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
|
||||
// Match against full path
|
||||
matched, _ = filepath.Match(pattern, path)
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
3
cmd/dapp-fm-app/.gitignore
vendored
Normal file
3
cmd/dapp-fm-app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
build/
|
||||
*.exe
|
||||
dapp-fm-app
|
||||
987
cmd/dapp-fm-app/frontend/index.html
Normal file
987
cmd/dapp-fm-app/frontend/index.html
Normal file
|
|
@ -0,0 +1,987 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>dapp.fm - Decentralized Music Player</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #0f0f1a 0%, #1a0a2e 50%, #0f1a2e 100%);
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.logo {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.logo h1 {
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #ff006e 0%, #8338ec 50%, #3a86ff 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -2px;
|
||||
}
|
||||
|
||||
.logo .tagline {
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
padding: 1.5rem;
|
||||
background: rgba(255,255,255,0.03);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
|
||||
.hero-text p {
|
||||
color: #aaa;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-text strong {
|
||||
color: #ff006e;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 20px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.card h2 .icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #888;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
textarea, input[type="password"], input[type="text"], input[type="url"] {
|
||||
width: 100%;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 2px solid rgba(255,255,255,0.1);
|
||||
border-radius: 12px;
|
||||
background: rgba(0,0,0,0.4);
|
||||
color: #fff;
|
||||
font-family: 'Monaco', 'Menlo', 'Consolas', monospace;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
textarea:focus, input:focus {
|
||||
outline: none;
|
||||
border-color: #8338ec;
|
||||
box-shadow: 0 0 0 4px rgba(131, 56, 236, 0.2);
|
||||
}
|
||||
|
||||
textarea.encrypted {
|
||||
min-height: 100px;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.unlock-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.unlock-row .input-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 1rem 2.5rem;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-size: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: linear-gradient(135deg, #ff006e 0%, #8338ec 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4px 20px rgba(255, 0, 110, 0.3);
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 30px rgba(255, 0, 110, 0.4);
|
||||
}
|
||||
|
||||
button.primary:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.status-indicator .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-indicator.loading .dot {
|
||||
background: #ffc107;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.ready .dot {
|
||||
background: #00ff94;
|
||||
}
|
||||
|
||||
.status-indicator.error .dot {
|
||||
background: #ff5252;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: rgba(255, 82, 82, 0.15);
|
||||
border: 1px solid rgba(255, 82, 82, 0.4);
|
||||
border-radius: 12px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.error-banner.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Media Player Styles */
|
||||
.player-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.player-container.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.track-artwork {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 0 auto 1.5rem;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #2d1b4e 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 5rem;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.track-artwork img, .track-artwork video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.track-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.track-artist {
|
||||
color: #888;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.media-player-wrapper {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
width: 100%;
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 12px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
audio, video {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
video {
|
||||
max-height: 500px;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-player-wrapper {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.license-info {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: rgba(131, 56, 236, 0.1);
|
||||
border: 1px solid rgba(131, 56, 236, 0.3);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.license-info h4 {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
color: #8338ec;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.license-info p {
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.license-info .license-token {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.75rem;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
margin-top: 0.75rem;
|
||||
word-break: break-all;
|
||||
color: #00ff94;
|
||||
}
|
||||
|
||||
.download-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.download-section button {
|
||||
padding: 0.75rem 1.5rem;
|
||||
}
|
||||
|
||||
.track-list-section {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.track-list-section h3 {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.track-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.track-item:hover {
|
||||
background: rgba(131, 56, 236, 0.2);
|
||||
}
|
||||
|
||||
.track-item.active {
|
||||
background: rgba(255, 0, 110, 0.2);
|
||||
border: 1px solid rgba(255, 0, 110, 0.4);
|
||||
}
|
||||
|
||||
.track-number {
|
||||
font-weight: 700;
|
||||
color: #8338ec;
|
||||
min-width: 24px;
|
||||
}
|
||||
|
||||
.track-name {
|
||||
font-weight: 500;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.track-type {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.track-time {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.8rem;
|
||||
color: #00ff94;
|
||||
}
|
||||
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.file-input-wrapper input[type="file"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-input-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
border: 2px dashed rgba(255,255,255,0.2);
|
||||
border-radius: 12px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.file-input-label:hover {
|
||||
border-color: #8338ec;
|
||||
background: rgba(131, 56, 236, 0.1);
|
||||
}
|
||||
|
||||
.file-input-label .icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.or-divider {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin: 1rem 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.native-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: linear-gradient(135deg, #00ff94 0%, #00d4aa 100%);
|
||||
color: #000;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<h1>dapp.fm</h1>
|
||||
<p class="tagline">Decentralized Music Distribution <span class="native-badge">Native App</span></p>
|
||||
</div>
|
||||
|
||||
<div class="hero-text">
|
||||
<p>
|
||||
<strong>No middlemen. No platforms. No 70% cuts.</strong><br>
|
||||
Artists encrypt their music with ChaCha20-Poly1305. Fans unlock with a license token.
|
||||
Content lives on any CDN, IPFS, or artist's own server. The password IS the license.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="status" class="status-indicator ready">
|
||||
<span class="dot"></span>
|
||||
<span>Native decryption ready (memory speed)</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><span class="icon">🔐</span> Unlock Licensed Content</h2>
|
||||
|
||||
<div class="file-input-wrapper">
|
||||
<input type="file" id="file-input" accept=".smsg,.enc,.borg">
|
||||
<label class="file-input-label">
|
||||
<span class="icon">📁</span>
|
||||
<span>Drop encrypted file here or click to browse</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="or-divider">- or paste encrypted content -</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="encrypted-content">Encrypted Content (base64):</label>
|
||||
<textarea id="encrypted-content" class="encrypted" placeholder="Paste the encrypted content from the artist..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="demo-banner" style="background: rgba(255, 0, 110, 0.1); border: 1px solid rgba(255, 0, 110, 0.3); border-radius: 12px; padding: 1rem; margin-bottom: 1rem;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 1rem;">
|
||||
<div>
|
||||
<strong style="color: #ff006e;">Try the Demo!</strong>
|
||||
<span style="color: #888; font-size: 0.85rem; margin-left: 0.5rem;">Bundled sample video</span>
|
||||
</div>
|
||||
<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;">PMVXogAJNVe_DDABfTmLYztaJAzsD0R7</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="error-banner" class="error-banner"></div>
|
||||
|
||||
<!-- Manifest preview (shown without decryption) -->
|
||||
<div id="manifest-preview" style="display: none; background: rgba(131, 56, 236, 0.1); border: 1px solid rgba(131, 56, 236, 0.3); border-radius: 12px; padding: 1.25rem; margin-bottom: 1rem;"></div>
|
||||
|
||||
<div class="unlock-row">
|
||||
<div class="input-group">
|
||||
<label for="license-token">License Token (Password):</label>
|
||||
<input type="password" id="license-token" placeholder="Enter your license token from the artist">
|
||||
</div>
|
||||
<button id="unlock-btn" class="primary">Unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Player appears after unlock -->
|
||||
<div id="player-container" class="card player-container">
|
||||
<h2><span class="icon">🎵</span> Now Playing</h2>
|
||||
|
||||
<div class="track-info">
|
||||
<div class="track-artwork" id="track-artwork">🎶</div>
|
||||
<div class="track-title" id="track-title">Track Title</div>
|
||||
<div class="track-artist" id="track-artist">Artist Name</div>
|
||||
</div>
|
||||
|
||||
<div class="media-player-wrapper" id="media-player-wrapper">
|
||||
<!-- Audio/Video player inserted here -->
|
||||
</div>
|
||||
|
||||
<div id="track-list-section" class="track-list-section" style="display: none;">
|
||||
<h3><span>💿</span> Track List</h3>
|
||||
<div id="track-list" class="track-list">
|
||||
<!-- Tracks populated by JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="license-info">
|
||||
<h4>🔓 Licensed Content</h4>
|
||||
<p id="license-description">This content was unlocked with your personal license token.
|
||||
Decryption powered by native Go - no servers, memory speed.</p>
|
||||
<div class="license-token" id="license-display"></div>
|
||||
</div>
|
||||
|
||||
<div class="download-section">
|
||||
<button class="secondary" id="download-btn">Download Original</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Wails runtime - provides window.go bindings
|
||||
let currentMediaBlob = null;
|
||||
let currentMediaName = null;
|
||||
let currentMediaMime = null;
|
||||
let currentManifest = null;
|
||||
|
||||
// Check if Wails runtime is available
|
||||
function isWailsReady() {
|
||||
return typeof window.go !== 'undefined' &&
|
||||
typeof window.go.player !== 'undefined' &&
|
||||
typeof window.go.player.Player !== 'undefined';
|
||||
}
|
||||
|
||||
// Wait for Wails runtime
|
||||
function waitForWails() {
|
||||
return new Promise((resolve) => {
|
||||
if (isWailsReady()) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
// Poll for Wails runtime
|
||||
const interval = setInterval(() => {
|
||||
if (isWailsReady()) {
|
||||
clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const errorBanner = document.getElementById('error-banner');
|
||||
errorBanner.textContent = msg;
|
||||
errorBanner.classList.add('visible');
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('error-banner').classList.remove('visible');
|
||||
}
|
||||
|
||||
// Handle file input
|
||||
document.getElementById('file-input').addEventListener('change', async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const content = await file.arrayBuffer();
|
||||
const base64 = btoa(String.fromCharCode(...new Uint8Array(content)));
|
||||
document.getElementById('encrypted-content').value = base64;
|
||||
await showManifestPreview(base64);
|
||||
} catch (err) {
|
||||
showError('Failed to read file: ' + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for content paste/input
|
||||
let previewDebounce = null;
|
||||
document.getElementById('encrypted-content').addEventListener('input', async (e) => {
|
||||
const content = e.target.value.trim();
|
||||
clearTimeout(previewDebounce);
|
||||
previewDebounce = setTimeout(async () => {
|
||||
if (content && content.length > 100) {
|
||||
await showManifestPreview(content);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Show manifest preview using Go bindings (NO WASM!)
|
||||
async function showManifestPreview(encryptedB64) {
|
||||
await waitForWails();
|
||||
|
||||
try {
|
||||
// Direct Go call at memory speed!
|
||||
const manifest = await window.go.player.Player.GetManifest(encryptedB64);
|
||||
currentManifest = manifest;
|
||||
|
||||
const previewSection = document.getElementById('manifest-preview');
|
||||
while (previewSection.firstChild) {
|
||||
previewSection.removeChild(previewSection.firstChild);
|
||||
}
|
||||
|
||||
if (manifest && manifest.title) {
|
||||
previewSection.style.display = 'block';
|
||||
|
||||
// Header with icon
|
||||
const headerDiv = document.createElement('div');
|
||||
headerDiv.style.cssText = 'display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;';
|
||||
|
||||
const icon = document.createElement('span');
|
||||
icon.style.fontSize = '2.5rem';
|
||||
icon.textContent = manifest.release_type === 'djset' ? '🎧' :
|
||||
manifest.release_type === 'live' ? '🎤' : '💿';
|
||||
|
||||
const titleDiv = document.createElement('div');
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.style.cssText = 'font-size: 1.2rem; font-weight: 700; color: #fff;';
|
||||
titleEl.textContent = manifest.title || 'Untitled';
|
||||
|
||||
const artistEl = document.createElement('div');
|
||||
artistEl.style.cssText = 'font-size: 0.9rem; color: #888;';
|
||||
artistEl.textContent = manifest.artist || 'Unknown Artist';
|
||||
|
||||
titleDiv.appendChild(titleEl);
|
||||
titleDiv.appendChild(artistEl);
|
||||
headerDiv.appendChild(icon);
|
||||
headerDiv.appendChild(titleDiv);
|
||||
previewSection.appendChild(headerDiv);
|
||||
|
||||
// Track list
|
||||
if (manifest.tracks && manifest.tracks.length > 0) {
|
||||
const trackHeader = document.createElement('div');
|
||||
trackHeader.style.cssText = 'font-size: 0.85rem; color: #8338ec; margin-bottom: 0.5rem;';
|
||||
trackHeader.textContent = '💿 ' + manifest.tracks.length + ' track(s)';
|
||||
previewSection.appendChild(trackHeader);
|
||||
|
||||
const trackList = document.createElement('div');
|
||||
trackList.style.maxHeight = '150px';
|
||||
trackList.style.overflowY = 'auto';
|
||||
|
||||
manifest.tracks.forEach((track, i) => {
|
||||
const trackEl = document.createElement('div');
|
||||
trackEl.style.cssText = 'display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem; background: rgba(0,0,0,0.2); border-radius: 6px; margin-bottom: 0.25rem; font-size: 0.85rem;';
|
||||
|
||||
const numEl = document.createElement('span');
|
||||
numEl.style.cssText = 'color: #8338ec; font-weight: 600; min-width: 20px;';
|
||||
numEl.textContent = (track.track_num || (i + 1)) + '.';
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.style.cssText = 'flex: 1; color: #ccc;';
|
||||
nameEl.textContent = track.title || 'Track ' + (i + 1);
|
||||
|
||||
const timeEl = document.createElement('span');
|
||||
timeEl.style.cssText = 'color: #00ff94; font-family: monospace; font-size: 0.8rem;';
|
||||
timeEl.textContent = formatTime(track.start || 0);
|
||||
|
||||
trackEl.appendChild(numEl);
|
||||
trackEl.appendChild(nameEl);
|
||||
trackEl.appendChild(timeEl);
|
||||
trackList.appendChild(trackEl);
|
||||
});
|
||||
|
||||
previewSection.appendChild(trackList);
|
||||
}
|
||||
|
||||
// License status
|
||||
if (manifest.is_expired !== undefined) {
|
||||
const licenseDiv = document.createElement('div');
|
||||
licenseDiv.style.cssText = 'margin-top: 1rem; padding: 0.75rem; border-radius: 8px;';
|
||||
|
||||
if (manifest.is_expired) {
|
||||
licenseDiv.style.background = 'rgba(255, 82, 82, 0.2)';
|
||||
licenseDiv.style.border = '1px solid rgba(255, 82, 82, 0.4)';
|
||||
const label = document.createElement('div');
|
||||
label.style.cssText = 'color: #ff5252; font-weight: 600;';
|
||||
label.textContent = 'LICENSE EXPIRED';
|
||||
licenseDiv.appendChild(label);
|
||||
} else if (manifest.time_remaining) {
|
||||
licenseDiv.style.background = 'rgba(0, 255, 148, 0.1)';
|
||||
licenseDiv.style.border = '1px solid rgba(0, 255, 148, 0.3)';
|
||||
const label = document.createElement('span');
|
||||
label.style.cssText = 'color: #00ff94; font-weight: 600; font-size: 0.8rem;';
|
||||
label.textContent = (manifest.license_type || 'LICENSE').toUpperCase();
|
||||
const time = document.createElement('span');
|
||||
time.style.cssText = 'color: #888; font-size: 0.8rem; margin-left: 0.5rem;';
|
||||
time.textContent = manifest.time_remaining + ' remaining';
|
||||
licenseDiv.appendChild(label);
|
||||
licenseDiv.appendChild(time);
|
||||
} else {
|
||||
licenseDiv.style.background = 'rgba(0, 255, 148, 0.1)';
|
||||
licenseDiv.style.border = '1px solid rgba(0, 255, 148, 0.3)';
|
||||
const label = document.createElement('span');
|
||||
label.style.cssText = 'color: #00ff94; font-weight: 600; font-size: 0.8rem;';
|
||||
label.textContent = 'PERPETUAL LICENSE';
|
||||
licenseDiv.appendChild(label);
|
||||
}
|
||||
previewSection.appendChild(licenseDiv);
|
||||
}
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = 'margin-top: 1rem; font-size: 0.85rem; color: #888; text-align: center;';
|
||||
hint.textContent = manifest.is_expired ?
|
||||
'License expired. Contact artist for renewal.' :
|
||||
'Enter license token to unlock and play';
|
||||
previewSection.appendChild(hint);
|
||||
|
||||
} else {
|
||||
previewSection.style.display = 'none';
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Could not read manifest:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Unlock content using Go bindings (memory speed!)
|
||||
async function unlockContent() {
|
||||
hideError();
|
||||
await waitForWails();
|
||||
|
||||
const encryptedB64 = document.getElementById('encrypted-content').value.trim();
|
||||
const password = document.getElementById('license-token').value;
|
||||
|
||||
if (!encryptedB64) {
|
||||
showError('Please provide encrypted content');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
showError('Please enter your license token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check license validity (memory speed)
|
||||
const isValid = await window.go.player.Player.IsLicenseValid(encryptedB64);
|
||||
if (!isValid) {
|
||||
showError('License has expired. Contact the artist for renewal.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrypt using Go bindings (memory speed - no HTTP/TCP!)
|
||||
const result = await window.go.player.Player.Decrypt(encryptedB64, password);
|
||||
displayMedia(result, password);
|
||||
|
||||
} catch (err) {
|
||||
showError('Unlock failed: ' + err.message);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Display decrypted media
|
||||
function displayMedia(result, password) {
|
||||
const playerContainer = document.getElementById('player-container');
|
||||
const mediaWrapper = document.getElementById('media-player-wrapper');
|
||||
const artworkEl = document.getElementById('track-artwork');
|
||||
|
||||
// Set track info
|
||||
const title = (currentManifest && currentManifest.title) || result.subject || 'Untitled';
|
||||
const artist = (currentManifest && currentManifest.artist) || result.from || 'Unknown Artist';
|
||||
document.getElementById('track-title').textContent = title;
|
||||
document.getElementById('track-artist').textContent = artist;
|
||||
|
||||
// Show masked license token
|
||||
const masked = password.substring(0, 4) + '••••••••' + password.substring(password.length - 4);
|
||||
document.getElementById('license-display').textContent = masked;
|
||||
|
||||
// Clear previous media
|
||||
while (mediaWrapper.firstChild) mediaWrapper.removeChild(mediaWrapper.firstChild);
|
||||
while (artworkEl.firstChild) artworkEl.removeChild(artworkEl.firstChild);
|
||||
artworkEl.textContent = '🎶';
|
||||
|
||||
// Process attachments
|
||||
if (result.attachments && result.attachments.length > 0) {
|
||||
result.attachments.forEach((att) => {
|
||||
const mime = att.mime_type || 'application/octet-stream';
|
||||
|
||||
// URL from Go - served through Wails asset handler
|
||||
const url = att.url || att.file_path || att.stream_url || att.data_url;
|
||||
|
||||
// Store info for download
|
||||
currentMediaName = att.name;
|
||||
currentMediaMime = mime;
|
||||
|
||||
if (mime.startsWith('video/')) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'video-player-wrapper';
|
||||
const video = document.createElement('video');
|
||||
video.controls = true;
|
||||
video.src = url;
|
||||
video.style.width = '100%';
|
||||
wrapper.appendChild(video);
|
||||
mediaWrapper.appendChild(wrapper);
|
||||
artworkEl.textContent = '🎬';
|
||||
|
||||
} else if (mime.startsWith('audio/')) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'audio-player';
|
||||
const audio = document.createElement('audio');
|
||||
audio.controls = true;
|
||||
audio.src = url;
|
||||
audio.style.width = '100%';
|
||||
wrapper.appendChild(audio);
|
||||
mediaWrapper.appendChild(wrapper);
|
||||
artworkEl.textContent = '🎵';
|
||||
|
||||
} else if (mime.startsWith('image/')) {
|
||||
const img = document.createElement('img');
|
||||
img.src = url;
|
||||
artworkEl.textContent = '';
|
||||
artworkEl.appendChild(img);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Build track list from manifest
|
||||
const trackListSection = document.getElementById('track-list-section');
|
||||
const trackListEl = document.getElementById('track-list');
|
||||
while (trackListEl.firstChild) trackListEl.removeChild(trackListEl.firstChild);
|
||||
|
||||
if (currentManifest && currentManifest.tracks && currentManifest.tracks.length > 0) {
|
||||
trackListSection.style.display = 'block';
|
||||
|
||||
currentManifest.tracks.forEach((track, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'track-item';
|
||||
item.addEventListener('click', () => {
|
||||
const media = document.querySelector('audio, video');
|
||||
if (media) {
|
||||
media.currentTime = track.start || 0;
|
||||
media.play();
|
||||
document.querySelectorAll('.track-item').forEach(t => t.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
const num = document.createElement('span');
|
||||
num.className = 'track-number';
|
||||
num.textContent = track.track_num || (index + 1);
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.style.flex = '1';
|
||||
const name = document.createElement('div');
|
||||
name.className = 'track-name';
|
||||
name.textContent = track.title || 'Track ' + (index + 1);
|
||||
info.appendChild(name);
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'track-time';
|
||||
time.textContent = formatTime(track.start || 0);
|
||||
|
||||
item.appendChild(num);
|
||||
item.appendChild(info);
|
||||
item.appendChild(time);
|
||||
trackListEl.appendChild(item);
|
||||
});
|
||||
} else {
|
||||
trackListSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Update license description
|
||||
if (currentManifest && currentManifest.time_remaining) {
|
||||
document.getElementById('license-description').textContent =
|
||||
(currentManifest.license_type || 'Rental').toUpperCase() + ' license - ' +
|
||||
currentManifest.time_remaining + ' remaining. Native Go decryption at memory speed.';
|
||||
}
|
||||
|
||||
// Hide preview, show player
|
||||
document.getElementById('manifest-preview').style.display = 'none';
|
||||
playerContainer.classList.add('visible');
|
||||
playerContainer.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return mins + ':' + secs.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
// Download handler
|
||||
document.getElementById('download-btn').addEventListener('click', () => {
|
||||
if (!currentMediaBlob) {
|
||||
alert('No media to download');
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(currentMediaBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = currentMediaName || 'media';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// Load bundled demo - DIRECT GO CALL, no HTTP!
|
||||
async function loadDemo() {
|
||||
const btn = document.getElementById('load-demo-btn');
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Loading...';
|
||||
btn.disabled = true;
|
||||
|
||||
try {
|
||||
await waitForWails();
|
||||
|
||||
// Get manifest first (direct Go call)
|
||||
const manifest = await window.go.main.App.GetDemoManifest();
|
||||
currentManifest = manifest;
|
||||
|
||||
// Decrypt demo directly in Go - NO fetch, NO base64 encoding!
|
||||
// Go reads embedded bytes -> decrypts -> returns result
|
||||
const result = await window.go.main.App.LoadDemo();
|
||||
|
||||
// Display the decrypted media
|
||||
displayMedia(result, 'PMVXogAJNVe_DDABfTmLYztaJAzsD0R7');
|
||||
|
||||
btn.textContent = 'Loaded!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
showError('Failed to load demo: ' + err.message);
|
||||
btn.textContent = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('unlock-btn').addEventListener('click', unlockContent);
|
||||
document.getElementById('license-token').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') unlockContent();
|
||||
});
|
||||
document.getElementById('load-demo-btn').addEventListener('click', loadDemo);
|
||||
|
||||
// Ready check
|
||||
waitForWails().then(() => {
|
||||
console.log('Wails bindings ready - memory speed decryption enabled');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.d.ts
vendored
Executable file
14
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.d.ts
vendored
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {main} from '../models';
|
||||
import {player} from '../models';
|
||||
|
||||
export function DecryptAndServe(arg1:string,arg2:string):Promise<main.MediaResult>;
|
||||
|
||||
export function GetDemoManifest():Promise<player.ManifestInfo>;
|
||||
|
||||
export function GetManifest(arg1:string):Promise<player.ManifestInfo>;
|
||||
|
||||
export function IsLicenseValid(arg1:string):Promise<boolean>;
|
||||
|
||||
export function LoadDemo():Promise<main.MediaResult>;
|
||||
23
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.js
Executable file
23
cmd/dapp-fm-app/frontend/wailsjs/go/main/App.js
Executable file
|
|
@ -0,0 +1,23 @@
|
|||
// @ts-check
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
|
||||
export function DecryptAndServe(arg1, arg2) {
|
||||
return window['go']['main']['App']['DecryptAndServe'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetDemoManifest() {
|
||||
return window['go']['main']['App']['GetDemoManifest']();
|
||||
}
|
||||
|
||||
export function GetManifest(arg1) {
|
||||
return window['go']['main']['App']['GetManifest'](arg1);
|
||||
}
|
||||
|
||||
export function IsLicenseValid(arg1) {
|
||||
return window['go']['main']['App']['IsLicenseValid'](arg1);
|
||||
}
|
||||
|
||||
export function LoadDemo() {
|
||||
return window['go']['main']['App']['LoadDemo']();
|
||||
}
|
||||
140
cmd/dapp-fm-app/frontend/wailsjs/go/models.ts
Executable file
140
cmd/dapp-fm-app/frontend/wailsjs/go/models.ts
Executable file
|
|
@ -0,0 +1,140 @@
|
|||
export namespace main {
|
||||
|
||||
export class MediaAttachment {
|
||||
name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
url: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MediaAttachment(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.name = source["name"];
|
||||
this.mime_type = source["mime_type"];
|
||||
this.size = source["size"];
|
||||
this.url = source["url"];
|
||||
}
|
||||
}
|
||||
export class MediaResult {
|
||||
body: string;
|
||||
subject?: string;
|
||||
from?: string;
|
||||
attachments?: MediaAttachment[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new MediaResult(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.body = source["body"];
|
||||
this.subject = source["subject"];
|
||||
this.from = source["from"];
|
||||
this.attachments = this.convertValues(source["attachments"], MediaAttachment);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export namespace player {
|
||||
|
||||
export class TrackInfo {
|
||||
title: string;
|
||||
start: number;
|
||||
end?: number;
|
||||
type?: string;
|
||||
track_num?: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new TrackInfo(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.title = source["title"];
|
||||
this.start = source["start"];
|
||||
this.end = source["end"];
|
||||
this.type = source["type"];
|
||||
this.track_num = source["track_num"];
|
||||
}
|
||||
}
|
||||
export class ManifestInfo {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
genre?: string;
|
||||
year?: number;
|
||||
release_type?: string;
|
||||
duration?: number;
|
||||
format?: string;
|
||||
expires_at?: number;
|
||||
issued_at?: number;
|
||||
license_type?: string;
|
||||
tracks?: TrackInfo[];
|
||||
is_expired: boolean;
|
||||
time_remaining?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new ManifestInfo(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.title = source["title"];
|
||||
this.artist = source["artist"];
|
||||
this.album = source["album"];
|
||||
this.genre = source["genre"];
|
||||
this.year = source["year"];
|
||||
this.release_type = source["release_type"];
|
||||
this.duration = source["duration"];
|
||||
this.format = source["format"];
|
||||
this.expires_at = source["expires_at"];
|
||||
this.issued_at = source["issued_at"];
|
||||
this.license_type = source["license_type"];
|
||||
this.tracks = this.convertValues(source["tracks"], TrackInfo);
|
||||
this.is_expired = source["is_expired"];
|
||||
this.time_remaining = source["time_remaining"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
24
cmd/dapp-fm-app/frontend/wailsjs/runtime/package.json
Normal file
24
cmd/dapp-fm-app/frontend/wailsjs/runtime/package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@wailsapp/runtime",
|
||||
"version": "2.0.0",
|
||||
"description": "Wails Javascript runtime library",
|
||||
"main": "runtime.js",
|
||||
"types": "runtime.d.ts",
|
||||
"scripts": {
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/wailsapp/wails.git"
|
||||
},
|
||||
"keywords": [
|
||||
"Wails",
|
||||
"Javascript",
|
||||
"Go"
|
||||
],
|
||||
"author": "Lea Anthony <lea.anthony@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/wailsapp/wails/issues"
|
||||
},
|
||||
"homepage": "https://github.com/wailsapp/wails#readme"
|
||||
}
|
||||
249
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
249
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.d.ts
vendored
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export interface Position {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface Screen {
|
||||
isCurrent: boolean;
|
||||
isPrimary: boolean;
|
||||
width : number
|
||||
height : number
|
||||
}
|
||||
|
||||
// Environment information such as platform, buildtype, ...
|
||||
export interface EnvironmentInfo {
|
||||
buildType: string;
|
||||
platform: string;
|
||||
arch: string;
|
||||
}
|
||||
|
||||
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
|
||||
// emits the given event. Optional data may be passed with the event.
|
||||
// This will trigger any event listeners.
|
||||
export function EventsEmit(eventName: string, ...data: any): void;
|
||||
|
||||
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
|
||||
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
|
||||
|
||||
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
|
||||
// sets up a listener for the given event name, but will only trigger a given number times.
|
||||
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
|
||||
|
||||
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
|
||||
// sets up a listener for the given event name, but will only trigger once.
|
||||
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
|
||||
|
||||
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
|
||||
// unregisters the listener for the given event name.
|
||||
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
|
||||
|
||||
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
|
||||
// unregisters all listeners.
|
||||
export function EventsOffAll(): void;
|
||||
|
||||
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
|
||||
// logs the given message as a raw message
|
||||
export function LogPrint(message: string): void;
|
||||
|
||||
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
|
||||
// logs the given message at the `trace` log level.
|
||||
export function LogTrace(message: string): void;
|
||||
|
||||
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
|
||||
// logs the given message at the `debug` log level.
|
||||
export function LogDebug(message: string): void;
|
||||
|
||||
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
|
||||
// logs the given message at the `error` log level.
|
||||
export function LogError(message: string): void;
|
||||
|
||||
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
|
||||
// logs the given message at the `fatal` log level.
|
||||
// The application will quit after calling this method.
|
||||
export function LogFatal(message: string): void;
|
||||
|
||||
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
|
||||
// logs the given message at the `info` log level.
|
||||
export function LogInfo(message: string): void;
|
||||
|
||||
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
|
||||
// logs the given message at the `warning` log level.
|
||||
export function LogWarning(message: string): void;
|
||||
|
||||
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
|
||||
// Forces a reload by the main application as well as connected browsers.
|
||||
export function WindowReload(): void;
|
||||
|
||||
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
|
||||
// Reloads the application frontend.
|
||||
export function WindowReloadApp(): void;
|
||||
|
||||
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
|
||||
// Sets the window AlwaysOnTop or not on top.
|
||||
export function WindowSetAlwaysOnTop(b: boolean): void;
|
||||
|
||||
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
|
||||
// *Windows only*
|
||||
// Sets window theme to system default (dark/light).
|
||||
export function WindowSetSystemDefaultTheme(): void;
|
||||
|
||||
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
|
||||
// *Windows only*
|
||||
// Sets window to light theme.
|
||||
export function WindowSetLightTheme(): void;
|
||||
|
||||
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
|
||||
// *Windows only*
|
||||
// Sets window to dark theme.
|
||||
export function WindowSetDarkTheme(): void;
|
||||
|
||||
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
|
||||
// Centers the window on the monitor the window is currently on.
|
||||
export function WindowCenter(): void;
|
||||
|
||||
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
|
||||
// Sets the text in the window title bar.
|
||||
export function WindowSetTitle(title: string): void;
|
||||
|
||||
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
|
||||
// Makes the window full screen.
|
||||
export function WindowFullscreen(): void;
|
||||
|
||||
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
|
||||
// Restores the previous window dimensions and position prior to full screen.
|
||||
export function WindowUnfullscreen(): void;
|
||||
|
||||
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
|
||||
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
|
||||
export function WindowIsFullscreen(): Promise<boolean>;
|
||||
|
||||
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
|
||||
// Sets the width and height of the window.
|
||||
export function WindowSetSize(width: number, height: number): void;
|
||||
|
||||
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
|
||||
// Gets the width and height of the window.
|
||||
export function WindowGetSize(): Promise<Size>;
|
||||
|
||||
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
|
||||
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMaxSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
|
||||
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
|
||||
// Setting a size of 0,0 will disable this constraint.
|
||||
export function WindowSetMinSize(width: number, height: number): void;
|
||||
|
||||
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
|
||||
// Sets the window position relative to the monitor the window is currently on.
|
||||
export function WindowSetPosition(x: number, y: number): void;
|
||||
|
||||
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
|
||||
// Gets the window position relative to the monitor the window is currently on.
|
||||
export function WindowGetPosition(): Promise<Position>;
|
||||
|
||||
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
|
||||
// Hides the window.
|
||||
export function WindowHide(): void;
|
||||
|
||||
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
|
||||
// Shows the window, if it is currently hidden.
|
||||
export function WindowShow(): void;
|
||||
|
||||
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
|
||||
// Maximises the window to fill the screen.
|
||||
export function WindowMaximise(): void;
|
||||
|
||||
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
|
||||
// Toggles between Maximised and UnMaximised.
|
||||
export function WindowToggleMaximise(): void;
|
||||
|
||||
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
|
||||
// Restores the window to the dimensions and position prior to maximising.
|
||||
export function WindowUnmaximise(): void;
|
||||
|
||||
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
|
||||
// Returns the state of the window, i.e. whether the window is maximised or not.
|
||||
export function WindowIsMaximised(): Promise<boolean>;
|
||||
|
||||
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
|
||||
// Minimises the window.
|
||||
export function WindowMinimise(): void;
|
||||
|
||||
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
|
||||
// Restores the window to the dimensions and position prior to minimising.
|
||||
export function WindowUnminimise(): void;
|
||||
|
||||
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
|
||||
// Returns the state of the window, i.e. whether the window is minimised or not.
|
||||
export function WindowIsMinimised(): Promise<boolean>;
|
||||
|
||||
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
|
||||
// Returns the state of the window, i.e. whether the window is normal or not.
|
||||
export function WindowIsNormal(): Promise<boolean>;
|
||||
|
||||
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
|
||||
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
|
||||
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
|
||||
|
||||
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
|
||||
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
|
||||
export function ScreenGetAll(): Promise<Screen[]>;
|
||||
|
||||
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
|
||||
// Opens the given URL in the system browser.
|
||||
export function BrowserOpenURL(url: string): void;
|
||||
|
||||
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
|
||||
// Returns information about the environment
|
||||
export function Environment(): Promise<EnvironmentInfo>;
|
||||
|
||||
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
|
||||
// Quits the application.
|
||||
export function Quit(): void;
|
||||
|
||||
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
|
||||
// Hides the application.
|
||||
export function Hide(): void;
|
||||
|
||||
// [Show](https://wails.io/docs/reference/runtime/intro#show)
|
||||
// Shows the application.
|
||||
export function Show(): void;
|
||||
|
||||
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
|
||||
// Returns the current text stored on clipboard
|
||||
export function ClipboardGetText(): Promise<string>;
|
||||
|
||||
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
|
||||
// Sets a text on the clipboard
|
||||
export function ClipboardSetText(text: string): Promise<boolean>;
|
||||
|
||||
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
|
||||
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
|
||||
|
||||
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
|
||||
// OnFileDropOff removes the drag and drop listeners and handlers.
|
||||
export function OnFileDropOff() :void
|
||||
|
||||
// Check if the file path resolver is available
|
||||
export function CanResolveFilePaths(): boolean;
|
||||
|
||||
// Resolves file paths for an array of files
|
||||
export function ResolveFilePaths(files: File[]): void
|
||||
242
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.js
Normal file
242
cmd/dapp-fm-app/frontend/wailsjs/runtime/runtime.js
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
_ __ _ __
|
||||
| | / /___ _(_) /____
|
||||
| | /| / / __ `/ / / ___/
|
||||
| |/ |/ / /_/ / / (__ )
|
||||
|__/|__/\__,_/_/_/____/
|
||||
The electron alternative for Go
|
||||
(c) Lea Anthony 2019-present
|
||||
*/
|
||||
|
||||
export function LogPrint(message) {
|
||||
window.runtime.LogPrint(message);
|
||||
}
|
||||
|
||||
export function LogTrace(message) {
|
||||
window.runtime.LogTrace(message);
|
||||
}
|
||||
|
||||
export function LogDebug(message) {
|
||||
window.runtime.LogDebug(message);
|
||||
}
|
||||
|
||||
export function LogInfo(message) {
|
||||
window.runtime.LogInfo(message);
|
||||
}
|
||||
|
||||
export function LogWarning(message) {
|
||||
window.runtime.LogWarning(message);
|
||||
}
|
||||
|
||||
export function LogError(message) {
|
||||
window.runtime.LogError(message);
|
||||
}
|
||||
|
||||
export function LogFatal(message) {
|
||||
window.runtime.LogFatal(message);
|
||||
}
|
||||
|
||||
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
|
||||
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
|
||||
}
|
||||
|
||||
export function EventsOn(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, -1);
|
||||
}
|
||||
|
||||
export function EventsOff(eventName, ...additionalEventNames) {
|
||||
return window.runtime.EventsOff(eventName, ...additionalEventNames);
|
||||
}
|
||||
|
||||
export function EventsOffAll() {
|
||||
return window.runtime.EventsOffAll();
|
||||
}
|
||||
|
||||
export function EventsOnce(eventName, callback) {
|
||||
return EventsOnMultiple(eventName, callback, 1);
|
||||
}
|
||||
|
||||
export function EventsEmit(eventName) {
|
||||
let args = [eventName].slice.call(arguments);
|
||||
return window.runtime.EventsEmit.apply(null, args);
|
||||
}
|
||||
|
||||
export function WindowReload() {
|
||||
window.runtime.WindowReload();
|
||||
}
|
||||
|
||||
export function WindowReloadApp() {
|
||||
window.runtime.WindowReloadApp();
|
||||
}
|
||||
|
||||
export function WindowSetAlwaysOnTop(b) {
|
||||
window.runtime.WindowSetAlwaysOnTop(b);
|
||||
}
|
||||
|
||||
export function WindowSetSystemDefaultTheme() {
|
||||
window.runtime.WindowSetSystemDefaultTheme();
|
||||
}
|
||||
|
||||
export function WindowSetLightTheme() {
|
||||
window.runtime.WindowSetLightTheme();
|
||||
}
|
||||
|
||||
export function WindowSetDarkTheme() {
|
||||
window.runtime.WindowSetDarkTheme();
|
||||
}
|
||||
|
||||
export function WindowCenter() {
|
||||
window.runtime.WindowCenter();
|
||||
}
|
||||
|
||||
export function WindowSetTitle(title) {
|
||||
window.runtime.WindowSetTitle(title);
|
||||
}
|
||||
|
||||
export function WindowFullscreen() {
|
||||
window.runtime.WindowFullscreen();
|
||||
}
|
||||
|
||||
export function WindowUnfullscreen() {
|
||||
window.runtime.WindowUnfullscreen();
|
||||
}
|
||||
|
||||
export function WindowIsFullscreen() {
|
||||
return window.runtime.WindowIsFullscreen();
|
||||
}
|
||||
|
||||
export function WindowGetSize() {
|
||||
return window.runtime.WindowGetSize();
|
||||
}
|
||||
|
||||
export function WindowSetSize(width, height) {
|
||||
window.runtime.WindowSetSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMaxSize(width, height) {
|
||||
window.runtime.WindowSetMaxSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetMinSize(width, height) {
|
||||
window.runtime.WindowSetMinSize(width, height);
|
||||
}
|
||||
|
||||
export function WindowSetPosition(x, y) {
|
||||
window.runtime.WindowSetPosition(x, y);
|
||||
}
|
||||
|
||||
export function WindowGetPosition() {
|
||||
return window.runtime.WindowGetPosition();
|
||||
}
|
||||
|
||||
export function WindowHide() {
|
||||
window.runtime.WindowHide();
|
||||
}
|
||||
|
||||
export function WindowShow() {
|
||||
window.runtime.WindowShow();
|
||||
}
|
||||
|
||||
export function WindowMaximise() {
|
||||
window.runtime.WindowMaximise();
|
||||
}
|
||||
|
||||
export function WindowToggleMaximise() {
|
||||
window.runtime.WindowToggleMaximise();
|
||||
}
|
||||
|
||||
export function WindowUnmaximise() {
|
||||
window.runtime.WindowUnmaximise();
|
||||
}
|
||||
|
||||
export function WindowIsMaximised() {
|
||||
return window.runtime.WindowIsMaximised();
|
||||
}
|
||||
|
||||
export function WindowMinimise() {
|
||||
window.runtime.WindowMinimise();
|
||||
}
|
||||
|
||||
export function WindowUnminimise() {
|
||||
window.runtime.WindowUnminimise();
|
||||
}
|
||||
|
||||
export function WindowSetBackgroundColour(R, G, B, A) {
|
||||
window.runtime.WindowSetBackgroundColour(R, G, B, A);
|
||||
}
|
||||
|
||||
export function ScreenGetAll() {
|
||||
return window.runtime.ScreenGetAll();
|
||||
}
|
||||
|
||||
export function WindowIsMinimised() {
|
||||
return window.runtime.WindowIsMinimised();
|
||||
}
|
||||
|
||||
export function WindowIsNormal() {
|
||||
return window.runtime.WindowIsNormal();
|
||||
}
|
||||
|
||||
export function BrowserOpenURL(url) {
|
||||
window.runtime.BrowserOpenURL(url);
|
||||
}
|
||||
|
||||
export function Environment() {
|
||||
return window.runtime.Environment();
|
||||
}
|
||||
|
||||
export function Quit() {
|
||||
window.runtime.Quit();
|
||||
}
|
||||
|
||||
export function Hide() {
|
||||
window.runtime.Hide();
|
||||
}
|
||||
|
||||
export function Show() {
|
||||
window.runtime.Show();
|
||||
}
|
||||
|
||||
export function ClipboardGetText() {
|
||||
return window.runtime.ClipboardGetText();
|
||||
}
|
||||
|
||||
export function ClipboardSetText(text) {
|
||||
return window.runtime.ClipboardSetText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||
*
|
||||
* @export
|
||||
* @callback OnFileDropCallback
|
||||
* @param {number} x - x coordinate of the drop
|
||||
* @param {number} y - y coordinate of the drop
|
||||
* @param {string[]} paths - A list of file paths.
|
||||
*/
|
||||
|
||||
/**
|
||||
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
|
||||
*
|
||||
* @export
|
||||
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
|
||||
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
|
||||
*/
|
||||
export function OnFileDrop(callback, useDropTarget) {
|
||||
return window.runtime.OnFileDrop(callback, useDropTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* OnFileDropOff removes the drag and drop listeners and handlers.
|
||||
*/
|
||||
export function OnFileDropOff() {
|
||||
return window.runtime.OnFileDropOff();
|
||||
}
|
||||
|
||||
export function CanResolveFilePaths() {
|
||||
return window.runtime.CanResolveFilePaths();
|
||||
}
|
||||
|
||||
export function ResolveFilePaths(files) {
|
||||
return window.runtime.ResolveFilePaths(files);
|
||||
}
|
||||
322
cmd/dapp-fm-app/main.go
Normal file
322
cmd/dapp-fm-app/main.go
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
// dapp-fm-app is a native desktop media player for dapp.fm
|
||||
// Decryption in Go, media served via Wails asset handler (same origin, no CORS)
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Snider/Borg/pkg/player"
|
||||
"github.com/Snider/Borg/pkg/smsg"
|
||||
"github.com/wailsapp/wails/v2"
|
||||
"github.com/wailsapp/wails/v2/pkg/options"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
)
|
||||
|
||||
//go:embed frontend
|
||||
var frontendAssets embed.FS
|
||||
|
||||
// MediaStore holds decrypted media in memory
|
||||
type MediaStore struct {
|
||||
mu sync.RWMutex
|
||||
media map[string]*MediaItem
|
||||
}
|
||||
|
||||
type MediaItem struct {
|
||||
Data []byte
|
||||
MimeType string
|
||||
Name string
|
||||
}
|
||||
|
||||
var globalStore = &MediaStore{media: make(map[string]*MediaItem)}
|
||||
|
||||
func (s *MediaStore) Set(id string, item *MediaItem) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.media[id] = item
|
||||
}
|
||||
|
||||
func (s *MediaStore) Get(id string) *MediaItem {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.media[id]
|
||||
}
|
||||
|
||||
func (s *MediaStore) Clear() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.media = make(map[string]*MediaItem)
|
||||
}
|
||||
|
||||
// AssetHandler serves both static assets and decrypted media
|
||||
type AssetHandler struct {
|
||||
assets fs.FS
|
||||
}
|
||||
|
||||
func (h *AssetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := r.URL.Path
|
||||
if path == "/" {
|
||||
path = "/index.html"
|
||||
}
|
||||
path = strings.TrimPrefix(path, "/")
|
||||
|
||||
// Check if this is a media request
|
||||
if strings.HasPrefix(path, "media/") {
|
||||
id := strings.TrimPrefix(path, "media/")
|
||||
item := globalStore.Get(id)
|
||||
if item == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve with range support for seeking
|
||||
w.Header().Set("Content-Type", item.MimeType)
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(item.Data)))
|
||||
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader != "" && strings.HasPrefix(rangeHeader, "bytes=") {
|
||||
rangeHeader = strings.TrimPrefix(rangeHeader, "bytes=")
|
||||
parts := strings.Split(rangeHeader, "-")
|
||||
start, _ := strconv.Atoi(parts[0])
|
||||
end := len(item.Data) - 1
|
||||
if len(parts) > 1 && parts[1] != "" {
|
||||
end, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
if end >= len(item.Data) {
|
||||
end = len(item.Data) - 1
|
||||
}
|
||||
if start > end || start >= len(item.Data) {
|
||||
http.Error(w, "Invalid range", http.StatusRequestedRangeNotSatisfiable)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(item.Data)))
|
||||
w.Header().Set("Content-Length", strconv.Itoa(end-start+1))
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
w.Write(item.Data[start : end+1])
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeContent(w, r, item.Name, time.Time{}, bytes.NewReader(item.Data))
|
||||
return
|
||||
}
|
||||
|
||||
// Serve static assets
|
||||
data, err := fs.ReadFile(h.assets, "frontend/"+path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Set content type
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".html"):
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
case strings.HasSuffix(path, ".js"):
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
case strings.HasSuffix(path, ".css"):
|
||||
w.Header().Set("Content-Type", "text/css")
|
||||
case strings.HasSuffix(path, ".wasm"):
|
||||
w.Header().Set("Content-Type", "application/wasm")
|
||||
}
|
||||
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// App wraps player functionality
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
player *player.Player
|
||||
}
|
||||
|
||||
func NewApp() *App {
|
||||
return &App{
|
||||
player: player.NewPlayer(),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) Startup(ctx context.Context) {
|
||||
a.ctx = ctx
|
||||
a.player.Startup(ctx)
|
||||
}
|
||||
|
||||
// MediaResult holds URLs for playback
|
||||
type MediaResult struct {
|
||||
Body string `json:"body"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
Attachments []MediaAttachment `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
type MediaAttachment struct {
|
||||
Name string `json:"name"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int `json:"size"`
|
||||
URL string `json:"url"` // /media/0, /media/1, etc.
|
||||
}
|
||||
|
||||
// LoadDemo decrypts demo and stores in memory for streaming
|
||||
func (a *App) LoadDemo() (*MediaResult, error) {
|
||||
globalStore.Clear()
|
||||
|
||||
// Read demo from embedded filesystem
|
||||
demoBytes, err := fs.ReadFile(frontendAssets, "frontend/demo-track.smsg")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("demo not found: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
msg, err := smsg.Decrypt(demoBytes, "dapp-fm-2024")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt failed: %w", err)
|
||||
}
|
||||
|
||||
result := &MediaResult{
|
||||
Body: msg.Body,
|
||||
Subject: msg.Subject,
|
||||
From: msg.From,
|
||||
}
|
||||
|
||||
for i, att := range msg.Attachments {
|
||||
// Decode base64 to raw bytes
|
||||
data, err := base64.StdEncoding.DecodeString(att.Content)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Store in memory
|
||||
id := strconv.Itoa(i)
|
||||
globalStore.Set(id, &MediaItem{
|
||||
Data: data,
|
||||
MimeType: att.MimeType,
|
||||
Name: att.Name,
|
||||
})
|
||||
|
||||
result.Attachments = append(result.Attachments, MediaAttachment{
|
||||
Name: att.Name,
|
||||
MimeType: att.MimeType,
|
||||
Size: len(data),
|
||||
URL: "/media/" + id,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetDemoManifest returns manifest without decrypting
|
||||
func (a *App) GetDemoManifest() (*player.ManifestInfo, error) {
|
||||
demoBytes, err := fs.ReadFile(frontendAssets, "frontend/demo-track.smsg")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("demo not found: %w", err)
|
||||
}
|
||||
|
||||
info, err := smsg.GetInfo(demoBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &player.ManifestInfo{}
|
||||
if info.Manifest != nil {
|
||||
m := info.Manifest
|
||||
result.Title = m.Title
|
||||
result.Artist = m.Artist
|
||||
result.Album = m.Album
|
||||
result.ReleaseType = m.ReleaseType
|
||||
result.Format = m.Format
|
||||
result.LicenseType = m.LicenseType
|
||||
|
||||
for _, t := range m.Tracks {
|
||||
result.Tracks = append(result.Tracks, player.TrackInfo{
|
||||
Title: t.Title,
|
||||
Start: t.Start,
|
||||
End: t.End,
|
||||
TrackNum: t.TrackNum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecryptAndServe decrypts user-provided content and serves via asset handler
|
||||
func (a *App) DecryptAndServe(encrypted string, password string) (*MediaResult, error) {
|
||||
globalStore.Clear()
|
||||
|
||||
// Decrypt using player (handles base64 input)
|
||||
msg, err := smsg.DecryptBase64(encrypted, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt failed: %w", err)
|
||||
}
|
||||
|
||||
result := &MediaResult{
|
||||
Body: msg.Body,
|
||||
Subject: msg.Subject,
|
||||
From: msg.From,
|
||||
}
|
||||
|
||||
for i, att := range msg.Attachments {
|
||||
data, err := base64.StdEncoding.DecodeString(att.Content)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
id := strconv.Itoa(i)
|
||||
globalStore.Set(id, &MediaItem{
|
||||
Data: data,
|
||||
MimeType: att.MimeType,
|
||||
Name: att.Name,
|
||||
})
|
||||
|
||||
result.Attachments = append(result.Attachments, MediaAttachment{
|
||||
Name: att.Name,
|
||||
MimeType: att.MimeType,
|
||||
Size: len(data),
|
||||
URL: "/media/" + id,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Proxy methods
|
||||
func (a *App) GetManifest(encrypted string) (*player.ManifestInfo, error) {
|
||||
return a.player.GetManifest(encrypted)
|
||||
}
|
||||
|
||||
func (a *App) IsLicenseValid(encrypted string) (bool, error) {
|
||||
return a.player.IsLicenseValid(encrypted)
|
||||
}
|
||||
|
||||
func main() {
|
||||
app := NewApp()
|
||||
|
||||
err := wails.Run(&options.App{
|
||||
Title: "dapp.fm Player",
|
||||
Width: 1200,
|
||||
Height: 800,
|
||||
MinWidth: 800,
|
||||
MinHeight: 600,
|
||||
AssetServer: &assetserver.Options{
|
||||
Handler: &AssetHandler{assets: frontendAssets},
|
||||
},
|
||||
BackgroundColour: &options.RGBA{R: 18, G: 18, B: 18, A: 1},
|
||||
OnStartup: app.Startup,
|
||||
Bind: []interface{}{
|
||||
app,
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
println("Error:", err.Error())
|
||||
}
|
||||
}
|
||||
20
cmd/dapp-fm-app/wails.json
Normal file
20
cmd/dapp-fm-app/wails.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "dapp-fm",
|
||||
"outputfilename": "dapp-fm",
|
||||
"frontend:install": "",
|
||||
"frontend:build": "",
|
||||
"frontend:dev:watcher": "",
|
||||
"frontend:dev:serverUrl": "",
|
||||
"author": {
|
||||
"name": "dapp.fm",
|
||||
"email": "hello@dapp.fm"
|
||||
},
|
||||
"info": {
|
||||
"companyName": "dapp.fm",
|
||||
"productName": "dapp.fm Player",
|
||||
"productVersion": "1.0.0",
|
||||
"copyright": "Copyright (c) 2024 dapp.fm - EUPL-1.2",
|
||||
"comments": "Decentralized Music Distribution - Zero-Trust DRM"
|
||||
}
|
||||
}
|
||||
64
cmd/dapp-fm/main.go
Normal file
64
cmd/dapp-fm/main.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// dapp-fm CLI provides headless media player functionality
|
||||
// For native desktop app with WebView, use dapp-fm-app instead
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Snider/Borg/pkg/player"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func main() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "dapp-fm",
|
||||
Short: "dapp.fm - Decentralized Music Player CLI",
|
||||
Long: `dapp-fm is the CLI version of the dapp.fm player.
|
||||
|
||||
For the native desktop app with WebView, use dapp-fm-app instead.
|
||||
This CLI provides HTTP server mode for automation and fallback scenarios.`,
|
||||
}
|
||||
|
||||
serveCmd := &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start HTTP server for the media player",
|
||||
Long: `Starts an HTTP server serving the media player interface.
|
||||
This is the slower TCP path - for memory-speed decryption, use dapp-fm-app.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
port, _ := cmd.Flags().GetString("port")
|
||||
openBrowser, _ := cmd.Flags().GetBool("open")
|
||||
|
||||
p := player.NewPlayer()
|
||||
|
||||
addr := ":" + port
|
||||
if openBrowser {
|
||||
fmt.Printf("Opening browser at http://localhost%s\n", addr)
|
||||
// Would need browser opener here
|
||||
}
|
||||
|
||||
return p.Serve(addr)
|
||||
},
|
||||
}
|
||||
|
||||
serveCmd.Flags().StringP("port", "p", "8080", "Port to serve on")
|
||||
serveCmd.Flags().Bool("open", false, "Open browser automatically")
|
||||
|
||||
versionCmd := &cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print version information",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("dapp-fm v1.0.0")
|
||||
fmt.Println("Decentralized Music Distribution")
|
||||
fmt.Println("https://dapp.fm")
|
||||
},
|
||||
}
|
||||
|
||||
rootCmd.AddCommand(serveCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
70
cmd/extract-demo/main.go
Normal file
70
cmd/extract-demo/main.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// extract-demo extracts the video from a v2 SMSG file
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Snider/Borg/pkg/smsg"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 4 {
|
||||
fmt.Println("Usage: extract-demo <input.smsg> <password> <output.mp4>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
inputFile := os.Args[1]
|
||||
password := os.Args[2]
|
||||
outputFile := os.Args[3]
|
||||
|
||||
data, err := os.ReadFile(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get info first
|
||||
info, err := smsg.GetInfo(data)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to get info: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Format: %s, Compression: %s\n", info.Format, info.Compression)
|
||||
|
||||
// Decrypt
|
||||
msg, err := smsg.Decrypt(data, password)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to decrypt: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("Body: %s...\n", msg.Body[:min(50, len(msg.Body))])
|
||||
fmt.Printf("Attachments: %d\n", len(msg.Attachments))
|
||||
|
||||
if len(msg.Attachments) > 0 {
|
||||
att := msg.Attachments[0]
|
||||
fmt.Printf(" Name: %s, MIME: %s, Size: %d\n", att.Name, att.MimeType, att.Size)
|
||||
|
||||
// Decode and save
|
||||
decoded, err := base64.StdEncoding.DecodeString(att.Content)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to decode: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputFile, decoded, 0644); err != nil {
|
||||
fmt.Printf("Failed to save: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("Saved to %s (%d bytes)\n", outputFile, len(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
226
cmd/mkdemo-abr/main.go
Normal file
226
cmd/mkdemo-abr/main.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
// mkdemo-abr creates an ABR (Adaptive Bitrate) demo set from a source video.
|
||||
// It uses ffmpeg to transcode to multiple bitrates, then encrypts each as v3 chunked SMSG.
|
||||
//
|
||||
// Usage: mkdemo-abr <input-video> <output-dir> [password]
|
||||
//
|
||||
// Output:
|
||||
//
|
||||
// output-dir/manifest.json - ABR manifest listing all variants
|
||||
// output-dir/track-1080p.smsg - 1080p variant (5 Mbps)
|
||||
// output-dir/track-720p.smsg - 720p variant (2.5 Mbps)
|
||||
// output-dir/track-480p.smsg - 480p variant (1 Mbps)
|
||||
// output-dir/track-360p.smsg - 360p variant (500 Kbps)
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Snider/Borg/pkg/smsg"
|
||||
)
|
||||
|
||||
// Preset defines a quality level for transcoding
|
||||
type Preset struct {
|
||||
Name string
|
||||
Width int
|
||||
Height int
|
||||
Bitrate string // For ffmpeg (e.g., "5M")
|
||||
BPS int // Bits per second for manifest
|
||||
}
|
||||
|
||||
// Default presets matching ABRPresets in types.go
|
||||
var presets = []Preset{
|
||||
{"1080p", 1920, 1080, "5M", 5000000},
|
||||
{"720p", 1280, 720, "2.5M", 2500000},
|
||||
{"480p", 854, 480, "1M", 1000000},
|
||||
{"360p", 640, 360, "500K", 500000},
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: mkdemo-abr <input-video> <output-dir> [password]")
|
||||
fmt.Println()
|
||||
fmt.Println("Creates ABR variant set from source video using ffmpeg.")
|
||||
fmt.Println()
|
||||
fmt.Println("Output:")
|
||||
fmt.Println(" output-dir/manifest.json - ABR manifest")
|
||||
fmt.Println(" output-dir/track-1080p.smsg - 1080p (5 Mbps)")
|
||||
fmt.Println(" output-dir/track-720p.smsg - 720p (2.5 Mbps)")
|
||||
fmt.Println(" output-dir/track-480p.smsg - 480p (1 Mbps)")
|
||||
fmt.Println(" output-dir/track-360p.smsg - 360p (500 Kbps)")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
inputFile := os.Args[1]
|
||||
outputDir := os.Args[2]
|
||||
|
||||
// Check ffmpeg is available
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
fmt.Println("Error: ffmpeg not found in PATH")
|
||||
fmt.Println("Install ffmpeg: https://ffmpeg.org/download.html")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Generate or use provided password
|
||||
var password string
|
||||
if len(os.Args) > 3 {
|
||||
password = os.Args[3]
|
||||
} else {
|
||||
passwordBytes := make([]byte, 24)
|
||||
if _, err := rand.Read(passwordBytes); err != nil {
|
||||
fmt.Printf("Failed to generate password: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
password = base64.RawURLEncoding.EncodeToString(passwordBytes)
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
fmt.Printf("Failed to create output directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get title from input filename
|
||||
title := filepath.Base(inputFile)
|
||||
ext := filepath.Ext(title)
|
||||
if ext != "" {
|
||||
title = title[:len(title)-len(ext)]
|
||||
}
|
||||
|
||||
// Create ABR manifest
|
||||
manifest := smsg.NewABRManifest(title)
|
||||
|
||||
fmt.Printf("Creating ABR variants for: %s\n", inputFile)
|
||||
fmt.Printf("Output directory: %s\n", outputDir)
|
||||
fmt.Printf("Password: %s\n\n", password)
|
||||
|
||||
// Process each preset
|
||||
for _, preset := range presets {
|
||||
fmt.Printf("Processing %s (%dx%d @ %s)...\n", preset.Name, preset.Width, preset.Height, preset.Bitrate)
|
||||
|
||||
// Step 1: Transcode with ffmpeg
|
||||
tempFile := filepath.Join(outputDir, fmt.Sprintf("temp-%s.mp4", preset.Name))
|
||||
if err := transcode(inputFile, tempFile, preset); err != nil {
|
||||
fmt.Printf(" Warning: Transcode failed for %s: %v\n", preset.Name, err)
|
||||
fmt.Printf(" Skipping this variant...\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 2: Read transcoded file
|
||||
content, err := os.ReadFile(tempFile)
|
||||
if err != nil {
|
||||
fmt.Printf(" Error reading transcoded file: %v\n", err)
|
||||
os.Remove(tempFile)
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 3: Create SMSG message
|
||||
msg := smsg.NewMessage("dapp.fm ABR Demo")
|
||||
msg.Subject = fmt.Sprintf("%s - %s", title, preset.Name)
|
||||
msg.From = "dapp.fm"
|
||||
msg.AddBinaryAttachment(
|
||||
fmt.Sprintf("%s-%s.mp4", strings.ReplaceAll(title, " ", "_"), preset.Name),
|
||||
content,
|
||||
"video/mp4",
|
||||
)
|
||||
|
||||
// Step 4: Create manifest for this variant
|
||||
variantManifest := smsg.NewManifest(title)
|
||||
variantManifest.LicenseType = "perpetual"
|
||||
variantManifest.Format = "dapp.fm/abr-v1"
|
||||
|
||||
// Step 5: Encrypt with v3 chunked format
|
||||
params := &smsg.StreamParams{
|
||||
License: password,
|
||||
ChunkSize: smsg.DefaultChunkSize, // 1MB chunks
|
||||
}
|
||||
|
||||
encrypted, err := smsg.EncryptV3(msg, params, variantManifest)
|
||||
if err != nil {
|
||||
fmt.Printf(" Error encrypting: %v\n", err)
|
||||
os.Remove(tempFile)
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 6: Write SMSG file
|
||||
smsgFile := filepath.Join(outputDir, fmt.Sprintf("track-%s.smsg", preset.Name))
|
||||
if err := os.WriteFile(smsgFile, encrypted, 0644); err != nil {
|
||||
fmt.Printf(" Error writing SMSG: %v\n", err)
|
||||
os.Remove(tempFile)
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 7: Get chunk count from header
|
||||
header, err := smsg.GetV3Header(encrypted)
|
||||
if err != nil {
|
||||
fmt.Printf(" Warning: Could not read header: %v\n", err)
|
||||
}
|
||||
chunkCount := 0
|
||||
if header != nil && header.Chunked != nil {
|
||||
chunkCount = header.Chunked.TotalChunks
|
||||
}
|
||||
|
||||
// Step 8: Add variant to manifest
|
||||
variant := smsg.Variant{
|
||||
Name: preset.Name,
|
||||
Bandwidth: preset.BPS,
|
||||
Width: preset.Width,
|
||||
Height: preset.Height,
|
||||
Codecs: "avc1.640028,mp4a.40.2",
|
||||
URL: fmt.Sprintf("track-%s.smsg", preset.Name),
|
||||
ChunkCount: chunkCount,
|
||||
FileSize: int64(len(encrypted)),
|
||||
}
|
||||
manifest.AddVariant(variant)
|
||||
|
||||
// Clean up temp file
|
||||
os.Remove(tempFile)
|
||||
|
||||
fmt.Printf(" Created: %s (%d bytes, %d chunks)\n", smsgFile, len(encrypted), chunkCount)
|
||||
}
|
||||
|
||||
if len(manifest.Variants) == 0 {
|
||||
fmt.Println("\nError: No variants created. Check ffmpeg output.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Write ABR manifest
|
||||
manifestPath := filepath.Join(outputDir, "manifest.json")
|
||||
if err := smsg.WriteABRManifest(manifest, manifestPath); err != nil {
|
||||
fmt.Printf("Failed to write manifest: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("\n✓ Created ABR manifest: %s\n", manifestPath)
|
||||
fmt.Printf("✓ Variants: %d\n", len(manifest.Variants))
|
||||
fmt.Printf("✓ Default: %s\n", manifest.Variants[manifest.DefaultIdx].Name)
|
||||
fmt.Printf("\nMaster Password: %s\n", password)
|
||||
fmt.Println("\nStore this password securely - it decrypts ALL variants!")
|
||||
}
|
||||
|
||||
// transcode uses ffmpeg to transcode the input to the specified preset
|
||||
func transcode(input, output string, preset Preset) error {
|
||||
args := []string{
|
||||
"-i", input,
|
||||
"-vf", fmt.Sprintf("scale=%d:%d:force_original_aspect_ratio=decrease,pad=%d:%d:(ow-iw)/2:(oh-ih)/2",
|
||||
preset.Width, preset.Height, preset.Width, preset.Height),
|
||||
"-c:v", "libx264",
|
||||
"-preset", "medium",
|
||||
"-b:v", preset.Bitrate,
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-movflags", "+faststart",
|
||||
"-y", // Overwrite output
|
||||
output,
|
||||
}
|
||||
|
||||
cmd := exec.Command("ffmpeg", args...)
|
||||
cmd.Stderr = os.Stderr // Show ffmpeg output for debugging
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
129
cmd/mkdemo-v3/main.go
Normal file
129
cmd/mkdemo-v3/main.go
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
// mkdemo-v3 creates a v3 chunked SMSG file for streaming demos
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/Snider/Borg/pkg/smsg"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 3 {
|
||||
fmt.Println("Usage: mkdemo-v3 <input-media-file> <output-smsg-file> [license] [chunk-size-kb]")
|
||||
fmt.Println("")
|
||||
fmt.Println("Creates a v3 chunked SMSG file for streaming demos.")
|
||||
fmt.Println("V3 uses rolling keys derived from: LTHN(date:license:fingerprint)")
|
||||
fmt.Println("")
|
||||
fmt.Println("Options:")
|
||||
fmt.Println(" license The license key (default: auto-generated)")
|
||||
fmt.Println(" chunk-size-kb Chunk size in KB (default: 512)")
|
||||
fmt.Println("")
|
||||
fmt.Println("Note: V3 files work for 24-48 hours from creation (rolling keys).")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
inputFile := os.Args[1]
|
||||
outputFile := os.Args[2]
|
||||
|
||||
// Read input file
|
||||
content, err := os.ReadFile(inputFile)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to read input file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// License (acts as password in v3)
|
||||
var license string
|
||||
if len(os.Args) > 3 {
|
||||
license = os.Args[3]
|
||||
} else {
|
||||
// Generate cryptographically secure license
|
||||
licenseBytes := make([]byte, 24)
|
||||
if _, err := rand.Read(licenseBytes); err != nil {
|
||||
fmt.Printf("Failed to generate license: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
license = base64.RawURLEncoding.EncodeToString(licenseBytes)
|
||||
}
|
||||
|
||||
// Chunk size (default 512KB for good streaming granularity)
|
||||
chunkSize := 512 * 1024
|
||||
if len(os.Args) > 4 {
|
||||
var chunkKB int
|
||||
if _, err := fmt.Sscanf(os.Args[4], "%d", &chunkKB); err == nil && chunkKB > 0 {
|
||||
chunkSize = chunkKB * 1024
|
||||
}
|
||||
}
|
||||
|
||||
// Create manifest
|
||||
title := filepath.Base(inputFile)
|
||||
ext := filepath.Ext(title)
|
||||
if ext != "" {
|
||||
title = title[:len(title)-len(ext)]
|
||||
}
|
||||
manifest := smsg.NewManifest(title)
|
||||
manifest.LicenseType = "streaming"
|
||||
manifest.Format = "dapp.fm/v3-chunked"
|
||||
|
||||
// Detect MIME type
|
||||
mimeType := "video/mp4"
|
||||
switch ext {
|
||||
case ".mp3":
|
||||
mimeType = "audio/mpeg"
|
||||
case ".wav":
|
||||
mimeType = "audio/wav"
|
||||
case ".flac":
|
||||
mimeType = "audio/flac"
|
||||
case ".webm":
|
||||
mimeType = "video/webm"
|
||||
case ".ogg":
|
||||
mimeType = "audio/ogg"
|
||||
}
|
||||
|
||||
// Create message with attachment
|
||||
msg := smsg.NewMessage("dapp.fm V3 Streaming Demo - Decrypt-while-downloading enabled")
|
||||
msg.Subject = "V3 Chunked Streaming"
|
||||
msg.From = "dapp.fm"
|
||||
msg.AddBinaryAttachment(
|
||||
filepath.Base(inputFile),
|
||||
content,
|
||||
mimeType,
|
||||
)
|
||||
|
||||
// Create stream params with chunking enabled
|
||||
params := &smsg.StreamParams{
|
||||
License: license,
|
||||
Fingerprint: "", // Empty for demo (works for any device)
|
||||
Cadence: smsg.CadenceDaily,
|
||||
ChunkSize: chunkSize,
|
||||
}
|
||||
|
||||
// Encrypt with v3 chunked format
|
||||
encrypted, err := smsg.EncryptV3(msg, params, manifest)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to encrypt: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Write output
|
||||
if err := os.WriteFile(outputFile, encrypted, 0644); err != nil {
|
||||
fmt.Printf("Failed to write output: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Calculate chunk count
|
||||
numChunks := (len(content) + chunkSize - 1) / chunkSize
|
||||
|
||||
fmt.Printf("Created: %s (%d bytes)\n", outputFile, len(encrypted))
|
||||
fmt.Printf("Format: v3 chunked\n")
|
||||
fmt.Printf("Chunk Size: %d KB\n", chunkSize/1024)
|
||||
fmt.Printf("Total Chunks: ~%d\n", numChunks)
|
||||
fmt.Printf("License: %s\n", license)
|
||||
fmt.Println("")
|
||||
fmt.Println("This license works for 24-48 hours from creation.")
|
||||
fmt.Println("Use the license in the streaming demo to decrypt.")
|
||||
}
|
||||
81
cmd/mkdemo/main.go
Normal file
81
cmd/mkdemo/main.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// 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 with filename as title
|
||||
title := filepath.Base(inputFile)
|
||||
ext := filepath.Ext(title)
|
||||
if ext != "" {
|
||||
title = title[:len(title)-len(ext)]
|
||||
}
|
||||
manifest := smsg.NewManifest(title)
|
||||
manifest.LicenseType = "perpetual"
|
||||
manifest.Format = "dapp.fm/v1"
|
||||
|
||||
// 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!")
|
||||
}
|
||||
BIN
demo/demo-sample.smsg
Normal file
BIN
demo/demo-sample.smsg
Normal file
Binary file not shown.
BIN
demo/demo-track-v3.smsg
Normal file
BIN
demo/demo-track-v3.smsg
Normal file
Binary file not shown.
3596
demo/index.html
Normal file
3596
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
281
docs/ipfs-distribution.md
Normal file
281
docs/ipfs-distribution.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# IPFS Distribution Guide
|
||||
|
||||
This guide explains how to distribute your encrypted `.smsg` content via IPFS (InterPlanetary File System) for permanent, decentralized hosting.
|
||||
|
||||
## Why IPFS?
|
||||
|
||||
IPFS is ideal for dapp.fm content because:
|
||||
|
||||
- **Permanent links** - Content-addressed (CID) means the URL never changes
|
||||
- **No hosting costs** - Pin with free services or self-host
|
||||
- **Censorship resistant** - No single point of failure
|
||||
- **Global CDN** - Content served from nearest peer
|
||||
- **Perfect for archival** - Your content survives even if you disappear
|
||||
|
||||
Combined with password-as-license, IPFS creates truly permanent media distribution:
|
||||
|
||||
```
|
||||
Artist uploads to IPFS → Fan downloads from anywhere → Password unlocks forever
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Install IPFS
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install ipfs
|
||||
```
|
||||
|
||||
**Linux:**
|
||||
```bash
|
||||
wget https://dist.ipfs.tech/kubo/v0.24.0/kubo_v0.24.0_linux-amd64.tar.gz
|
||||
tar xvfz kubo_v0.24.0_linux-amd64.tar.gz
|
||||
sudo mv kubo/ipfs /usr/local/bin/
|
||||
```
|
||||
|
||||
**Windows:**
|
||||
Download from https://dist.ipfs.tech/#kubo
|
||||
|
||||
### 2. Initialize and Start
|
||||
|
||||
```bash
|
||||
ipfs init
|
||||
ipfs daemon
|
||||
```
|
||||
|
||||
### 3. Add Your Content
|
||||
|
||||
```bash
|
||||
# Create your encrypted content first
|
||||
go run ./cmd/mkdemo my-album.mp4 my-album.smsg
|
||||
|
||||
# Add to IPFS
|
||||
ipfs add my-album.smsg
|
||||
# Output: added QmX...abc my-album.smsg
|
||||
|
||||
# Your content is now available at:
|
||||
# - Local: http://localhost:8080/ipfs/QmX...abc
|
||||
# - Gateway: https://ipfs.io/ipfs/QmX...abc
|
||||
```
|
||||
|
||||
## Distribution Workflow
|
||||
|
||||
### For Artists
|
||||
|
||||
```bash
|
||||
# 1. Package your media
|
||||
go run ./cmd/mkdemo album.mp4 album.smsg
|
||||
# Save the password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||
|
||||
# 2. Add to IPFS
|
||||
ipfs add album.smsg
|
||||
# added QmYourContentCID album.smsg
|
||||
|
||||
# 3. Pin for persistence (choose one):
|
||||
|
||||
# Option A: Pin locally (requires running node)
|
||||
ipfs pin add QmYourContentCID
|
||||
|
||||
# Option B: Use Pinata (free tier: 1GB)
|
||||
curl -X POST "https://api.pinata.cloud/pinning/pinByHash" \
|
||||
-H "Authorization: Bearer YOUR_JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"hashToPin": "QmYourContentCID"}'
|
||||
|
||||
# Option C: Use web3.storage (free tier: 5GB)
|
||||
# Upload at https://web3.storage
|
||||
|
||||
# 4. Share with fans
|
||||
# CID: QmYourContentCID
|
||||
# Password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||
# Gateway URL: https://ipfs.io/ipfs/QmYourContentCID
|
||||
```
|
||||
|
||||
### For Fans
|
||||
|
||||
```bash
|
||||
# Download via any gateway
|
||||
curl -o album.smsg https://ipfs.io/ipfs/QmYourContentCID
|
||||
|
||||
# Or via local node (faster if running)
|
||||
ipfs get QmYourContentCID -o album.smsg
|
||||
|
||||
# Play with password in browser demo or native app
|
||||
```
|
||||
|
||||
## IPFS Gateways
|
||||
|
||||
Public gateways for sharing (no IPFS node required):
|
||||
|
||||
| Gateway | URL Pattern | Notes |
|
||||
|---------|-------------|-------|
|
||||
| ipfs.io | `https://ipfs.io/ipfs/{CID}` | Official, reliable |
|
||||
| dweb.link | `https://{CID}.ipfs.dweb.link` | Subdomain style |
|
||||
| cloudflare | `https://cloudflare-ipfs.com/ipfs/{CID}` | Fast, cached |
|
||||
| w3s.link | `https://{CID}.ipfs.w3s.link` | web3.storage |
|
||||
| nftstorage.link | `https://{CID}.ipfs.nftstorage.link` | NFT.storage |
|
||||
|
||||
**Example URLs for CID `QmX...abc`:**
|
||||
```
|
||||
https://ipfs.io/ipfs/QmX...abc
|
||||
https://QmX...abc.ipfs.dweb.link
|
||||
https://cloudflare-ipfs.com/ipfs/QmX...abc
|
||||
```
|
||||
|
||||
## Pinning Services
|
||||
|
||||
Content on IPFS is only available while someone is hosting it. Use pinning services for persistence:
|
||||
|
||||
### Free Options
|
||||
|
||||
| Service | Free Tier | Link |
|
||||
|---------|-----------|------|
|
||||
| Pinata | 1 GB | https://pinata.cloud |
|
||||
| web3.storage | 5 GB | https://web3.storage |
|
||||
| NFT.storage | Unlimited* | https://nft.storage |
|
||||
| Filebase | 5 GB | https://filebase.com |
|
||||
|
||||
*NFT.storage is designed for NFT data but works for any content.
|
||||
|
||||
### Pin via CLI
|
||||
|
||||
```bash
|
||||
# Pinata
|
||||
export PINATA_JWT="your-jwt-token"
|
||||
curl -X POST "https://api.pinata.cloud/pinning/pinByHash" \
|
||||
-H "Authorization: Bearer $PINATA_JWT" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"hashToPin": "QmYourCID", "pinataMetadata": {"name": "my-album.smsg"}}'
|
||||
|
||||
# web3.storage (using w3 CLI)
|
||||
npm install -g @web3-storage/w3cli
|
||||
w3 login your@email.com
|
||||
w3 up my-album.smsg
|
||||
```
|
||||
|
||||
## Integration with Demo Page
|
||||
|
||||
The demo page can load content directly from IPFS gateways:
|
||||
|
||||
```javascript
|
||||
// In the demo page, use gateway URL
|
||||
const ipfsCID = 'QmYourContentCID';
|
||||
const gatewayUrl = `https://ipfs.io/ipfs/${ipfsCID}`;
|
||||
|
||||
// Fetch and decrypt
|
||||
const response = await fetch(gatewayUrl);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const msg = await BorgSMSG.decryptBinary(bytes, password);
|
||||
```
|
||||
|
||||
Or use the Fan tab with the IPFS gateway URL directly.
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Pin Your Content
|
||||
|
||||
IPFS garbage-collects unpinned content. Always pin important files:
|
||||
|
||||
```bash
|
||||
ipfs pin add QmYourCID
|
||||
# Or use a pinning service
|
||||
```
|
||||
|
||||
### 2. Use Multiple Pins
|
||||
|
||||
Pin with 2-3 services for redundancy:
|
||||
|
||||
```bash
|
||||
# Pin locally
|
||||
ipfs pin add QmYourCID
|
||||
|
||||
# Also pin with Pinata
|
||||
curl -X POST "https://api.pinata.cloud/pinning/pinByHash" ...
|
||||
|
||||
# And web3.storage as backup
|
||||
w3 up my-album.smsg
|
||||
```
|
||||
|
||||
### 3. Share CID + Password Separately
|
||||
|
||||
```
|
||||
Download: https://ipfs.io/ipfs/QmYourCID
|
||||
License: [sent via email/DM after purchase]
|
||||
```
|
||||
|
||||
### 4. Use IPNS for Updates (Optional)
|
||||
|
||||
IPNS lets you update content while keeping the same URL:
|
||||
|
||||
```bash
|
||||
# Create IPNS name
|
||||
ipfs name publish QmYourCID
|
||||
# Published to k51...xyz
|
||||
|
||||
# Your content is now at:
|
||||
# https://ipfs.io/ipns/k51...xyz
|
||||
|
||||
# Update to new version later:
|
||||
ipfs name publish QmNewVersionCID
|
||||
```
|
||||
|
||||
## Example: Full Album Release
|
||||
|
||||
```bash
|
||||
# 1. Create encrypted album
|
||||
go run ./cmd/mkdemo my-album.mp4 my-album.smsg
|
||||
# Password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||
|
||||
# 2. Add to IPFS
|
||||
ipfs add my-album.smsg
|
||||
# added QmAlbumCID my-album.smsg
|
||||
|
||||
# 3. Pin with multiple services
|
||||
ipfs pin add QmAlbumCID
|
||||
w3 up my-album.smsg
|
||||
|
||||
# 4. Create release page
|
||||
cat > release.html << 'EOF'
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>My Album - Download</title></head>
|
||||
<body>
|
||||
<h1>My Album</h1>
|
||||
<p>Download: <a href="https://ipfs.io/ipfs/QmAlbumCID">IPFS</a></p>
|
||||
<p>After purchase, you'll receive your license key via email.</p>
|
||||
<p><a href="https://demo.dapp.fm">Play with license key</a></p>
|
||||
</body>
|
||||
</html>
|
||||
EOF
|
||||
|
||||
# 5. Host release page on IPFS too!
|
||||
ipfs add release.html
|
||||
# added QmReleaseCID release.html
|
||||
# Share: https://ipfs.io/ipfs/QmReleaseCID
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Content Not Loading
|
||||
|
||||
1. **Check if pinned**: `ipfs pin ls | grep QmYourCID`
|
||||
2. **Try different gateway**: Some gateways cache slowly
|
||||
3. **Check daemon running**: `ipfs swarm peers` should show peers
|
||||
|
||||
### Slow Downloads
|
||||
|
||||
1. Use a faster gateway (cloudflare-ipfs.com is often fastest)
|
||||
2. Run your own IPFS node for direct access
|
||||
3. Pre-warm gateways by accessing content once
|
||||
|
||||
### CID Changed After Re-adding
|
||||
|
||||
IPFS CIDs are content-addressed. If you modify the file, the CID changes. For the same content, the CID is always identical.
|
||||
|
||||
## Resources
|
||||
|
||||
- [IPFS Documentation](https://docs.ipfs.tech/)
|
||||
- [Pinata Docs](https://docs.pinata.cloud/)
|
||||
- [web3.storage Docs](https://web3.storage/docs/)
|
||||
- [IPFS Gateway Checker](https://ipfs.github.io/public-gateway-checker/)
|
||||
497
docs/payment-integration.md
Normal file
497
docs/payment-integration.md
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
# Payment Integration Guide
|
||||
|
||||
This guide shows how to sell your encrypted `.smsg` content and deliver license keys (passwords) to customers using popular payment processors.
|
||||
|
||||
## Overview
|
||||
|
||||
The dapp.fm model is simple:
|
||||
|
||||
```
|
||||
1. Customer pays via Stripe/Gumroad/PayPal
|
||||
2. Payment processor triggers webhook or delivers digital product
|
||||
3. Customer receives password (license key)
|
||||
4. Customer downloads .smsg from your CDN/IPFS
|
||||
5. Customer decrypts with password - done forever
|
||||
```
|
||||
|
||||
No license servers, no accounts, no ongoing infrastructure.
|
||||
|
||||
## Stripe Integration
|
||||
|
||||
### Option 1: Stripe Payment Links (Easiest)
|
||||
|
||||
No code required - use Stripe's hosted checkout:
|
||||
|
||||
1. Create a Payment Link in Stripe Dashboard
|
||||
2. Set up a webhook to email the password on successful payment
|
||||
3. Host your `.smsg` file anywhere (CDN, IPFS, S3)
|
||||
|
||||
**Webhook endpoint (Node.js/Express):**
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Your content passwords (store securely!)
|
||||
const PRODUCTS = {
|
||||
'prod_ABC123': {
|
||||
name: 'My Album',
|
||||
password: 'PMVXogAJNVe_DDABfTmLYztaJAzsD0R7',
|
||||
downloadUrl: 'https://ipfs.io/ipfs/QmYourCID'
|
||||
}
|
||||
};
|
||||
|
||||
app.post('/webhook', express.raw({type: 'application/json'}), async (req, res) => {
|
||||
const sig = req.headers['stripe-signature'];
|
||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
|
||||
} catch (err) {
|
||||
return res.status(400).send(`Webhook Error: ${err.message}`);
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object;
|
||||
const customerEmail = session.customer_details.email;
|
||||
const productId = session.metadata.product_id;
|
||||
const product = PRODUCTS[productId];
|
||||
|
||||
if (product) {
|
||||
await sendLicenseEmail(customerEmail, product);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({received: true});
|
||||
});
|
||||
|
||||
async function sendLicenseEmail(email, product) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
// Configure your email provider
|
||||
service: 'gmail',
|
||||
auth: {
|
||||
user: process.env.EMAIL_USER,
|
||||
pass: process.env.EMAIL_PASS
|
||||
}
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: 'artist@example.com',
|
||||
to: email,
|
||||
subject: `Your License Key for ${product.name}`,
|
||||
html: `
|
||||
<h1>Thank you for your purchase!</h1>
|
||||
<p><strong>Download:</strong> <a href="${product.downloadUrl}">${product.name}</a></p>
|
||||
<p><strong>License Key:</strong> <code>${product.password}</code></p>
|
||||
<p><strong>How to play:</strong></p>
|
||||
<ol>
|
||||
<li>Download the .smsg file from the link above</li>
|
||||
<li>Go to <a href="https://demo.dapp.fm">demo.dapp.fm</a></li>
|
||||
<li>Click "Fan" tab, then "Unlock Licensed Content"</li>
|
||||
<li>Paste the file and enter your license key</li>
|
||||
</ol>
|
||||
<p>This is your permanent license - save this email!</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(3000);
|
||||
```
|
||||
|
||||
### Option 2: Stripe Checkout Session (More Control)
|
||||
|
||||
```javascript
|
||||
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||
|
||||
// Create checkout session
|
||||
app.post('/create-checkout', async (req, res) => {
|
||||
const { productId } = req.body;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: [{
|
||||
price: 'price_ABC123', // Your Stripe price ID
|
||||
quantity: 1,
|
||||
}],
|
||||
mode: 'payment',
|
||||
success_url: 'https://yoursite.com/success?session_id={CHECKOUT_SESSION_ID}',
|
||||
cancel_url: 'https://yoursite.com/cancel',
|
||||
metadata: {
|
||||
product_id: productId
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ url: session.url });
|
||||
});
|
||||
|
||||
// Success page - show license after payment
|
||||
app.get('/success', async (req, res) => {
|
||||
const session = await stripe.checkout.sessions.retrieve(req.query.session_id);
|
||||
|
||||
if (session.payment_status === 'paid') {
|
||||
const product = PRODUCTS[session.metadata.product_id];
|
||||
res.send(`
|
||||
<h1>Thank you!</h1>
|
||||
<p>Download: <a href="${product.downloadUrl}">${product.name}</a></p>
|
||||
<p>License Key: <code>${product.password}</code></p>
|
||||
`);
|
||||
} else {
|
||||
res.send('Payment not completed');
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Gumroad Integration
|
||||
|
||||
Gumroad is perfect for artists - handles payments, delivery, and customer management.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Create a Digital Product on Gumroad
|
||||
2. Upload a text file or PDF containing the password
|
||||
3. Set your `.smsg` download URL in the product description
|
||||
4. Gumroad delivers the password file on purchase
|
||||
|
||||
### Product Setup
|
||||
|
||||
**Product Description:**
|
||||
```
|
||||
My Album - Encrypted Digital Download
|
||||
|
||||
After purchase, you'll receive:
|
||||
1. A license key (in the download)
|
||||
2. Download link for the .smsg file
|
||||
|
||||
How to play:
|
||||
1. Download the .smsg file: https://ipfs.io/ipfs/QmYourCID
|
||||
2. Go to https://demo.dapp.fm
|
||||
3. Click "Fan" → "Unlock Licensed Content"
|
||||
4. Enter your license key from the PDF
|
||||
```
|
||||
|
||||
**Delivered File (license.txt):**
|
||||
```
|
||||
Your License Key: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||
|
||||
Download your content: https://ipfs.io/ipfs/QmYourCID
|
||||
|
||||
This is your permanent license - keep this file safe!
|
||||
The content works offline forever with this key.
|
||||
|
||||
Need help? Visit https://demo.dapp.fm
|
||||
```
|
||||
|
||||
### Gumroad Ping (Webhook)
|
||||
|
||||
For automated delivery, use Gumroad's Ping feature:
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Gumroad sends POST to this endpoint on sale
|
||||
app.post('/gumroad-ping', (req, res) => {
|
||||
const {
|
||||
seller_id,
|
||||
product_id,
|
||||
email,
|
||||
full_name,
|
||||
purchaser_id
|
||||
} = req.body;
|
||||
|
||||
// Verify it's from Gumroad (check seller_id matches yours)
|
||||
if (seller_id !== process.env.GUMROAD_SELLER_ID) {
|
||||
return res.status(403).send('Invalid seller');
|
||||
}
|
||||
|
||||
const product = PRODUCTS[product_id];
|
||||
if (product) {
|
||||
// Send custom email with password
|
||||
sendLicenseEmail(email, product);
|
||||
}
|
||||
|
||||
res.send('OK');
|
||||
});
|
||||
```
|
||||
|
||||
## PayPal Integration
|
||||
|
||||
### PayPal Buttons + IPN
|
||||
|
||||
```html
|
||||
<!-- PayPal Buy Button -->
|
||||
<form action="https://www.paypal.com/cgi-bin/webscr" method="post">
|
||||
<input type="hidden" name="cmd" value="_xclick">
|
||||
<input type="hidden" name="business" value="artist@example.com">
|
||||
<input type="hidden" name="item_name" value="My Album - Digital Download">
|
||||
<input type="hidden" name="item_number" value="album-001">
|
||||
<input type="hidden" name="amount" value="9.99">
|
||||
<input type="hidden" name="currency_code" value="USD">
|
||||
<input type="hidden" name="notify_url" value="https://yoursite.com/paypal-ipn">
|
||||
<input type="hidden" name="return" value="https://yoursite.com/thank-you">
|
||||
<input type="submit" value="Buy Now - $9.99">
|
||||
</form>
|
||||
```
|
||||
|
||||
**IPN Handler:**
|
||||
|
||||
```javascript
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
|
||||
app.post('/paypal-ipn', express.urlencoded({ extended: true }), async (req, res) => {
|
||||
// Verify with PayPal
|
||||
const verifyUrl = 'https://ipnpb.paypal.com/cgi-bin/webscr';
|
||||
const verifyBody = 'cmd=_notify-validate&' + new URLSearchParams(req.body).toString();
|
||||
|
||||
const response = await axios.post(verifyUrl, verifyBody);
|
||||
|
||||
if (response.data === 'VERIFIED' && req.body.payment_status === 'Completed') {
|
||||
const email = req.body.payer_email;
|
||||
const itemNumber = req.body.item_number;
|
||||
const product = PRODUCTS[itemNumber];
|
||||
|
||||
if (product) {
|
||||
await sendLicenseEmail(email, product);
|
||||
}
|
||||
}
|
||||
|
||||
res.send('OK');
|
||||
});
|
||||
```
|
||||
|
||||
## Ko-fi Integration
|
||||
|
||||
Ko-fi is great for tips and single purchases.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Enable "Commissions" or "Shop" on Ko-fi
|
||||
2. Create a product with the license key in the thank-you message
|
||||
3. Link to your .smsg download
|
||||
|
||||
**Ko-fi Thank You Message:**
|
||||
```
|
||||
Thank you for your purchase!
|
||||
|
||||
Your License Key: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||
|
||||
Download: https://ipfs.io/ipfs/QmYourCID
|
||||
|
||||
Play at: https://demo.dapp.fm (Fan → Unlock Licensed Content)
|
||||
```
|
||||
|
||||
## Serverless Options
|
||||
|
||||
### Vercel/Netlify Functions
|
||||
|
||||
No server needed - use serverless functions:
|
||||
|
||||
```javascript
|
||||
// api/stripe-webhook.js (Vercel)
|
||||
import Stripe from 'stripe';
|
||||
import { Resend } from 'resend';
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
export default async function handler(req, res) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).end();
|
||||
}
|
||||
|
||||
const sig = req.headers['stripe-signature'];
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
req.body,
|
||||
sig,
|
||||
process.env.STRIPE_WEBHOOK_SECRET
|
||||
);
|
||||
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object;
|
||||
|
||||
await resend.emails.send({
|
||||
from: 'artist@yoursite.com',
|
||||
to: session.customer_details.email,
|
||||
subject: 'Your License Key',
|
||||
html: `
|
||||
<p>Download: <a href="https://ipfs.io/ipfs/QmYourCID">My Album</a></p>
|
||||
<p>License Key: <code>PMVXogAJNVe_DDABfTmLYztaJAzsD0R7</code></p>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ received: true });
|
||||
}
|
||||
|
||||
export const config = {
|
||||
api: { bodyParser: false }
|
||||
};
|
||||
```
|
||||
|
||||
## Manual Workflow (No Code)
|
||||
|
||||
For artists who don't want to set up webhooks:
|
||||
|
||||
### Using Email
|
||||
|
||||
1. **Gumroad/Ko-fi**: Set product to require email
|
||||
2. **Manual delivery**: Check sales daily, email passwords manually
|
||||
3. **Template**:
|
||||
|
||||
```
|
||||
Subject: Your License for [Album Name]
|
||||
|
||||
Hi [Name],
|
||||
|
||||
Thank you for your purchase!
|
||||
|
||||
Download: [IPFS/CDN link]
|
||||
License Key: [password]
|
||||
|
||||
How to play:
|
||||
1. Download the .smsg file
|
||||
2. Go to demo.dapp.fm
|
||||
3. Fan tab → Unlock Licensed Content
|
||||
4. Enter your license key
|
||||
|
||||
Enjoy! This license works forever.
|
||||
|
||||
[Artist Name]
|
||||
```
|
||||
|
||||
### Using Discord/Telegram
|
||||
|
||||
1. Sell via Gumroad (free tier)
|
||||
2. Require customers join your Discord/Telegram
|
||||
3. Bot or manual delivery of license keys
|
||||
4. Community building bonus!
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### 1. One Password Per Product
|
||||
|
||||
Don't reuse passwords across products:
|
||||
|
||||
```javascript
|
||||
const PRODUCTS = {
|
||||
'album-2024': { password: 'unique-key-1' },
|
||||
'album-2023': { password: 'unique-key-2' },
|
||||
'single-summer': { password: 'unique-key-3' }
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Environment Variables
|
||||
|
||||
Never hardcode passwords in source:
|
||||
|
||||
```bash
|
||||
# .env
|
||||
ALBUM_2024_PASSWORD=PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||
STRIPE_SECRET_KEY=sk_live_...
|
||||
```
|
||||
|
||||
### 3. Webhook Verification
|
||||
|
||||
Always verify webhooks are from the payment provider:
|
||||
|
||||
```javascript
|
||||
// Stripe
|
||||
stripe.webhooks.constructEvent(body, sig, secret);
|
||||
|
||||
// Gumroad
|
||||
if (seller_id !== MY_SELLER_ID) reject();
|
||||
|
||||
// PayPal
|
||||
verify with IPN endpoint
|
||||
```
|
||||
|
||||
### 4. HTTPS Only
|
||||
|
||||
All webhook endpoints must use HTTPS.
|
||||
|
||||
## Pricing Strategies
|
||||
|
||||
### Direct Sale (Perpetual License)
|
||||
|
||||
- Customer pays once, owns forever
|
||||
- Single password for all buyers
|
||||
- Best for: Albums, films, books
|
||||
|
||||
### Time-Limited (Streaming/Rental)
|
||||
|
||||
Use dapp.fm Re-Key feature:
|
||||
|
||||
1. Encrypt master copy with master password
|
||||
2. On purchase, re-key with customer-specific password + expiry
|
||||
3. Deliver unique password per customer
|
||||
|
||||
```javascript
|
||||
// On purchase webhook
|
||||
const customerPassword = generateUniquePassword();
|
||||
const expiry = Date.now() + (24 * 60 * 60 * 1000); // 24 hours
|
||||
|
||||
// Use WASM or Go to re-key
|
||||
const customerVersion = await rekeyContent(masterSmsg, masterPassword, customerPassword, expiry);
|
||||
|
||||
// Deliver customer-specific file + password
|
||||
```
|
||||
|
||||
### Tiered Access
|
||||
|
||||
Different passwords for different tiers:
|
||||
|
||||
```javascript
|
||||
const TIERS = {
|
||||
'preview': { password: 'preview-key', expiry: '30s' },
|
||||
'rental': { password: 'rental-key', expiry: '7d' },
|
||||
'own': { password: 'perpetual-key', expiry: null }
|
||||
};
|
||||
```
|
||||
|
||||
## Example: Complete Stripe Setup
|
||||
|
||||
```bash
|
||||
# 1. Create your content
|
||||
go run ./cmd/mkdemo album.mp4 album.smsg
|
||||
# Password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
|
||||
|
||||
# 2. Upload to IPFS
|
||||
ipfs add album.smsg
|
||||
# QmAlbumCID
|
||||
|
||||
# 3. Create Stripe product
|
||||
# Dashboard → Products → Add Product
|
||||
# Name: My Album
|
||||
# Price: $9.99
|
||||
|
||||
# 4. Create Payment Link
|
||||
# Dashboard → Payment Links → New
|
||||
# Select your product
|
||||
# Get link: https://buy.stripe.com/xxx
|
||||
|
||||
# 5. Set up webhook
|
||||
# Dashboard → Developers → Webhooks → Add endpoint
|
||||
# URL: https://yoursite.com/api/stripe-webhook
|
||||
# Events: checkout.session.completed
|
||||
|
||||
# 6. Deploy webhook handler (Vercel example)
|
||||
vercel deploy
|
||||
|
||||
# 7. Share payment link
|
||||
# Fans click → Pay → Get email with password → Download → Play forever
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Stripe Webhooks](https://stripe.com/docs/webhooks)
|
||||
- [Gumroad Ping](https://help.gumroad.com/article/149-ping)
|
||||
- [PayPal IPN](https://developer.paypal.com/docs/ipn/)
|
||||
- [Resend (Email API)](https://resend.com/)
|
||||
- [Vercel Functions](https://vercel.com/docs/functions)
|
||||
183
examples/encrypt_media/main.go
Normal file
183
examples/encrypt_media/main.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
// Package main demonstrates encrypting media files into SMSG format for dapp.fm
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run main.go -input video.mp4 -output video.smsg -password "license-token" -title "My Track" -artist "Artist Name"
|
||||
// go run main.go -input video.mp4 -password "token" -track "0:Intro" -track "67:Sonnata, It Feels So Good"
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Snider/Borg/pkg/smsg"
|
||||
)
|
||||
|
||||
// trackList allows multiple -track flags
|
||||
type trackList []string
|
||||
|
||||
func (t *trackList) String() string {
|
||||
return strings.Join(*t, ", ")
|
||||
}
|
||||
|
||||
func (t *trackList) Set(value string) error {
|
||||
*t = append(*t, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
inputFile := flag.String("input", "", "Input media file (mp4, mp3, etc)")
|
||||
outputFile := flag.String("output", "", "Output SMSG file (default: input.smsg)")
|
||||
password := flag.String("password", "", "License token / password for encryption")
|
||||
title := flag.String("title", "", "Track title (default: filename)")
|
||||
artist := flag.String("artist", "", "Artist name")
|
||||
releaseType := flag.String("type", "single", "Release type: single, ep, album, djset, live")
|
||||
hint := flag.String("hint", "", "Optional password hint")
|
||||
outputBase64 := flag.Bool("base64", false, "Output as base64 text file instead of binary")
|
||||
|
||||
var tracks trackList
|
||||
flag.Var(&tracks, "track", "Track marker as 'seconds:title' or 'mm:ss:title' (can be repeated)")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *inputFile == "" {
|
||||
log.Fatal("Input file is required. Use -input flag.")
|
||||
}
|
||||
|
||||
if *password == "" {
|
||||
log.Fatal("Password/license token is required. Use -password flag.")
|
||||
}
|
||||
|
||||
// Read input file
|
||||
data, err := os.ReadFile(*inputFile)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read input file: %v", err)
|
||||
}
|
||||
|
||||
// Determine MIME type
|
||||
ext := strings.ToLower(filepath.Ext(*inputFile))
|
||||
mimeType := mime.TypeByExtension(ext)
|
||||
if mimeType == "" {
|
||||
// Fallback for common types
|
||||
switch ext {
|
||||
case ".mp4":
|
||||
mimeType = "video/mp4"
|
||||
case ".mp3":
|
||||
mimeType = "audio/mpeg"
|
||||
case ".wav":
|
||||
mimeType = "audio/wav"
|
||||
case ".ogg":
|
||||
mimeType = "audio/ogg"
|
||||
case ".webm":
|
||||
mimeType = "video/webm"
|
||||
case ".m4a":
|
||||
mimeType = "audio/mp4"
|
||||
case ".flac":
|
||||
mimeType = "audio/flac"
|
||||
default:
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
// Set defaults
|
||||
trackTitle := *title
|
||||
if trackTitle == "" {
|
||||
trackTitle = strings.TrimSuffix(filepath.Base(*inputFile), ext)
|
||||
}
|
||||
|
||||
output := *outputFile
|
||||
if output == "" {
|
||||
output = *inputFile + ".smsg"
|
||||
if *outputBase64 {
|
||||
output = *inputFile + ".smsg.txt"
|
||||
}
|
||||
}
|
||||
|
||||
// Create SMSG message with media attachment
|
||||
msg := smsg.NewMessage("Licensed media content from dapp.fm")
|
||||
msg.WithSubject(trackTitle)
|
||||
|
||||
if *artist != "" {
|
||||
msg.WithFrom(*artist)
|
||||
}
|
||||
|
||||
// Add the media file as base64 attachment
|
||||
contentB64 := base64.StdEncoding.EncodeToString(data)
|
||||
msg.AddAttachment(filepath.Base(*inputFile), contentB64, mimeType)
|
||||
|
||||
// Build manifest with public metadata
|
||||
manifest := smsg.NewManifest(trackTitle)
|
||||
manifest.Artist = *artist
|
||||
manifest.ReleaseType = *releaseType
|
||||
manifest.Format = "dapp.fm/v1"
|
||||
|
||||
// Parse track markers
|
||||
for _, trackStr := range tracks {
|
||||
parts := strings.SplitN(trackStr, ":", 3)
|
||||
var startSec float64
|
||||
var trackName string
|
||||
|
||||
if len(parts) == 2 {
|
||||
// Format: "seconds:title"
|
||||
startSec, _ = strconv.ParseFloat(parts[0], 64)
|
||||
trackName = parts[1]
|
||||
} else if len(parts) == 3 {
|
||||
// Format: "mm:ss:title"
|
||||
mins, _ := strconv.ParseFloat(parts[0], 64)
|
||||
secs, _ := strconv.ParseFloat(parts[1], 64)
|
||||
startSec = mins*60 + secs
|
||||
trackName = parts[2]
|
||||
} else {
|
||||
log.Printf("Warning: Invalid track format '%s', expected 'seconds:title' or 'mm:ss:title'", trackStr)
|
||||
continue
|
||||
}
|
||||
|
||||
manifest.AddTrack(trackName, startSec)
|
||||
fmt.Printf(" Track: %s @ %.0fs\n", trackName, startSec)
|
||||
}
|
||||
|
||||
// Encrypt with manifest
|
||||
var encrypted []byte
|
||||
if *hint != "" {
|
||||
// For hint, we'd need to extend the API - for now just use manifest
|
||||
_ = hint
|
||||
}
|
||||
encrypted, err = smsg.EncryptWithManifest(msg, *password, manifest)
|
||||
if err != nil {
|
||||
log.Fatalf("Encryption failed: %v", err)
|
||||
}
|
||||
|
||||
// Write output
|
||||
if *outputBase64 {
|
||||
// Write as base64 text
|
||||
b64 := base64.StdEncoding.EncodeToString(encrypted)
|
||||
err = os.WriteFile(output, []byte(b64), 0644)
|
||||
} else {
|
||||
// Write as binary
|
||||
err = os.WriteFile(output, encrypted, 0644)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to write output file: %v", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Encrypted media created successfully!\n")
|
||||
fmt.Printf(" Input: %s (%s)\n", *inputFile, mimeType)
|
||||
fmt.Printf(" Output: %s\n", output)
|
||||
fmt.Printf(" Title: %s\n", trackTitle)
|
||||
if *artist != "" {
|
||||
fmt.Printf(" Artist: %s\n", *artist)
|
||||
}
|
||||
fmt.Printf(" Size: %.2f MB -> %.2f MB\n",
|
||||
float64(len(data))/1024/1024,
|
||||
float64(len(encrypted))/1024/1024)
|
||||
fmt.Printf("\nLicense token: %s\n", *password)
|
||||
fmt.Printf("\nShare the .smsg file publicly. Only users with the license token can play it.\n")
|
||||
}
|
||||
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 |
|
||||
25
go.mod
25
go.mod
|
|
@ -7,10 +7,12 @@ 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
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
golang.org/x/mod v0.30.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/oauth2 v0.33.0
|
||||
|
|
@ -20,26 +22,47 @@ require (
|
|||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.6.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
github.com/labstack/gommon v0.4.2 // indirect
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
|
||||
github.com/leaanthony/gosod v1.0.4 // indirect
|
||||
github.com/leaanthony/slicer v1.6.0 // indirect
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.44.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/term v0.37.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
|
|
|||
57
go.sum
57
go.sum
|
|
@ -5,14 +5,14 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
|||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600 h1:9jyEgos5SNTVp3aJkhPs/fb4eTZE5l73YqaT+vFmFu0=
|
||||
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600/go.mod h1:v9HATMgLJWycy/R5ho1SL0OHbggXgEhu/qRB9gbS0BM=
|
||||
github.com/Snider/Enchantrix v0.0.2 h1:ExZQiBhfS/p/AHFTKhY80TOd+BXZjK95EzByAEgwvjs=
|
||||
github.com/Snider/Enchantrix v0.0.2/go.mod h1:CtFcLAvnDT1KcuF1JBb/DJj0KplY8jHryO06KzQ1hsQ=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
|
||||
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
|
|
@ -39,6 +39,10 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
|
|||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.16.3 h1:Z8BtvxZ09bYm/yYNgPKCzgWtaRqDTgIKRgIRHBfU6Z8=
|
||||
github.com/go-git/go-git/v5 v5.16.3/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
|
|
@ -51,12 +55,20 @@ github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvt
|
|||
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
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=
|
||||
|
|
@ -64,6 +76,23 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
|
||||
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
|
||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
|
||||
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
|
||||
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
|
||||
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
|
||||
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
|
||||
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
|
||||
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
|
||||
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
|
||||
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
|
||||
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
|
||||
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
|
|
@ -77,15 +106,20 @@ github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
|||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
|
||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
|
||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
|
|
@ -102,21 +136,34 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
|
|||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
|
||||
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
|
||||
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
|
||||
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
|
||||
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
|
|
@ -125,12 +172,14 @@ golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
|
|||
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
|
|
|
|||
102
go.work.sum
102
go.work.sum
|
|
@ -1,24 +1,122 @@
|
|||
atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw=
|
||||
atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU=
|
||||
atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8=
|
||||
atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ=
|
||||
atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
|
||||
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww=
|
||||
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
|
||||
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bitfield/script v0.24.0 h1:ic0Tbx+2AgRtkGGIcUyr+Un60vu4WXvqFrCSumf+T7M=
|
||||
github.com/bitfield/script v0.24.0/go.mod h1:fv+6x4OzVsRs6qAlc7wiGq8fq1b5orhtQdtW0dwjUHI=
|
||||
github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw=
|
||||
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||
github.com/charmbracelet/glamour v0.8.0 h1:tPrjL3aRcQbn++7t18wOpgLyl8wrOHUEDS7IZ68QtZs=
|
||||
github.com/charmbracelet/glamour v0.8.0/go.mod h1:ViRgmKkf3u5S7uakt2czJ272WSg2ZenlYEZXT2x7Bjw=
|
||||
github.com/charmbracelet/lipgloss v0.12.1 h1:/gmzszl+pedQpjCOH+wFkZr/N90Snz40J/NR7A0zQcs=
|
||||
github.com/charmbracelet/lipgloss v0.12.1/go.mod h1:V2CiwIuhx9S1S1ZlADfOj9HmxeMAORuz5izHb0zGbB8=
|
||||
github.com/charmbracelet/x/ansi v0.1.4 h1:IEU3D6+dWwPSgZ6HBH+v6oUuZ/nVawMiWj5831KfiLM=
|
||||
github.com/charmbracelet/x/ansi v0.1.4/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
|
||||
github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw=
|
||||
github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
||||
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
|
||||
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/flytam/filenamify v1.2.0 h1:7RiSqXYR4cJftDQ5NuvljKMfd/ubKnW/j9C6iekChgI=
|
||||
github.com/flytam/filenamify v1.2.0/go.mod h1:Dzf9kVycwcsBlr2ATg6uxjqiFgKGH+5SKFuhdeP5zu8=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0=
|
||||
github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/itchyny/gojq v0.12.13 h1:IxyYlHYIlspQHHTE0f3cJF0NKDMfajxViuhBLnHd/QU=
|
||||
github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4=
|
||||
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
|
||||
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
|
||||
github.com/jackmordaunt/icns v1.0.0 h1:RYSxplerf/l/DUd09AHtITwckkv/mqjVv4DjYdPmAMQ=
|
||||
github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo=
|
||||
github.com/jaypipes/ghw v0.13.0 h1:log8MXuB8hzTNnSktqpXMHc0c/2k/WgjOMSUtnI1RV4=
|
||||
github.com/jaypipes/ghw v0.13.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8=
|
||||
github.com/jaypipes/pcidb v1.0.1 h1:WB2zh27T3nwg8AE8ei81sNRb9yWBii3JGNJtT7K9Oic=
|
||||
github.com/jaypipes/pcidb v1.0.1/go.mod h1:6xYUz/yYEyOkIkUt2t2J2folIuZ4Yg6uByCGFXMCeE4=
|
||||
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 h1:qGQQKEcAR99REcMpsXCp3lJ03zYT1PkRd3kQGPn9GVg=
|
||||
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw=
|
||||
github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw=
|
||||
github.com/leaanthony/clir v1.3.0 h1:L9nPDWrmc/qU9UWZZvRaFajWYuO0np9V5p+5gxyYno0=
|
||||
github.com/leaanthony/clir v1.3.0/go.mod h1:k/RBkdkFl18xkkACMCLt09bhiZnrGORoxmomeMvDpE0=
|
||||
github.com/leaanthony/winicon v1.0.0 h1:ZNt5U5dY71oEoKZ97UVwJRT4e+5xo5o/ieKuHuk8NqQ=
|
||||
github.com/leaanthony/winicon v1.0.0/go.mod h1:en5xhijl92aphrJdmRPlh4NI1L6wq3gEm0LpXAPghjU=
|
||||
github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4=
|
||||
github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
|
||||
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pterm/pterm v0.12.80 h1:mM55B+GnKUnLMUSqhdINe4s6tOuVQIetQ3my8JGyAIg=
|
||||
github.com/pterm/pterm v0.12.80/go.mod h1:c6DeF9bSnOSeFPZlfs4ZRAFcf5SCoTwvwQ5xaKGQlHo=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
|
||||
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/tc-hib/winres v0.3.1 h1:CwRjEGrKdbi5CvZ4ID+iyVhgyfatxFoizjPhzez9Io4=
|
||||
github.com/tc-hib/winres v0.3.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk=
|
||||
github.com/tidwall/gjson v1.14.2 h1:6BBkirS0rAHjumnjHF6qgy5d2YAJ1TLIaFE2lzfOLqo=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/wzshiming/ctc v1.2.3 h1:q+hW3IQNsjIlOFBTGZZZeIXTElFM4grF4spW/errh/c=
|
||||
github.com/wzshiming/ctc v1.2.3/go.mod h1:2tVAtIY7SUyraSk0JxvwmONNPFL4ARavPuEsg5+KA28=
|
||||
github.com/wzshiming/winseq v0.0.0-20200112104235-db357dc107ae h1:tpXvBXC3hpQBDCc9OojJZCQMVRAbT3TTdUMP8WguXkY=
|
||||
github.com/wzshiming/winseq v0.0.0-20200112104235-db357dc107ae/go.mod h1:VTAq37rkGeV+WOybvZwjXiJOicICdpLCN8ifpISjK20=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
|
||||
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
|
||||
golang.org/x/crypto v0.11.1-0.20230711161743-2e82bdd1719d/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
golang.org/x/image v0.12.0 h1:w13vZbU4o5rKOFFR8y7M+c4A5jXDC0uXTdHYRP8X2DQ=
|
||||
golang.org/x/image v0.12.0/go.mod h1:Lu90jvHG7GfemOIcldsh9A2hS01ocl6oNO7ype5mEnk=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
|
||||
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
|
|
@ -28,3 +126,7 @@ google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6
|
|||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
|
||||
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
|
||||
|
|
|
|||
1161
js/borg-stmf/artist-portal.html
Normal file
1161
js/borg-stmf/artist-portal.html
Normal file
File diff suppressed because it is too large
Load diff
1
js/borg-stmf/demo-track.smsg
Normal file
1
js/borg-stmf/demo-track.smsg
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -310,6 +310,8 @@
|
|||
<nav class="nav-links">
|
||||
<a href="index.html" class="active">Form Encryption</a>
|
||||
<a href="support-reply.html">Decrypt Messages</a>
|
||||
<a href="media-player.html">Media Player</a>
|
||||
<a href="artist-portal.html">Artist Portal</a>
|
||||
</nav>
|
||||
|
||||
<div id="wasm-status" class="status-indicator loading">
|
||||
|
|
|
|||
1290
js/borg-stmf/media-player.html
Normal file
1290
js/borg-stmf/media-player.html
Normal file
File diff suppressed because it is too large
Load diff
Binary file not shown.
|
|
@ -389,6 +389,8 @@
|
|||
<nav class="nav-links">
|
||||
<a href="index.html">Form Encryption</a>
|
||||
<a href="support-reply.html" class="active">Decrypt Messages</a>
|
||||
<a href="media-player.html">Media Player</a>
|
||||
<a href="artist-portal.html">Artist Portal</a>
|
||||
</nav>
|
||||
|
||||
<div id="wasm-status" class="status-indicator loading">
|
||||
|
|
|
|||
36
pkg/player/assets.go
Normal file
36
pkg/player/assets.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package player
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// Assets embeds all frontend files for the media player
|
||||
// These are served both by Wails (memory) and HTTP (fallback)
|
||||
//
|
||||
//go:embed frontend/index.html
|
||||
//go:embed frontend/wasm_exec.js
|
||||
//go:embed frontend/stmf.wasm
|
||||
//go:embed frontend/demo-track.smsg
|
||||
var assets embed.FS
|
||||
|
||||
// Assets returns the embedded filesystem with frontend/ prefix stripped
|
||||
var Assets fs.FS
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
Assets, err = fs.Sub(assets, "frontend")
|
||||
if err != nil {
|
||||
panic("failed to create sub filesystem: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// GetDemoTrack returns the embedded demo track content
|
||||
func GetDemoTrack() ([]byte, error) {
|
||||
return fs.ReadFile(Assets, "demo-track.smsg")
|
||||
}
|
||||
|
||||
// GetIndex returns the main HTML page
|
||||
func GetIndex() ([]byte, error) {
|
||||
return fs.ReadFile(Assets, "index.html")
|
||||
}
|
||||
1290
pkg/player/frontend/index.html
Normal file
1290
pkg/player/frontend/index.html
Normal file
File diff suppressed because it is too large
Load diff
BIN
pkg/player/frontend/stmf.wasm
Executable file
BIN
pkg/player/frontend/stmf.wasm
Executable file
Binary file not shown.
575
pkg/player/frontend/wasm_exec.js
Normal file
575
pkg/player/frontend/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;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
329
pkg/player/player.go
Normal file
329
pkg/player/player.go
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
// Package player provides the core media player functionality for dapp.fm
|
||||
// It can be used both as Wails bindings (memory speed) or HTTP server (fallback)
|
||||
package player
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Snider/Borg/pkg/smsg"
|
||||
)
|
||||
|
||||
// Player provides media decryption and playback services
|
||||
// Methods are exposed to JavaScript via Wails bindings
|
||||
type Player struct {
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
// NewPlayer creates a new Player instance
|
||||
func NewPlayer() *Player {
|
||||
return &Player{}
|
||||
}
|
||||
|
||||
// Startup is called when the Wails app starts
|
||||
func (p *Player) Startup(ctx context.Context) {
|
||||
p.ctx = ctx
|
||||
}
|
||||
|
||||
// DecryptResult holds the decrypted message data
|
||||
type DecryptResult struct {
|
||||
Body string `json:"body"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
Attachments []AttachmentInfo `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// AttachmentInfo describes a decrypted attachment
|
||||
type AttachmentInfo struct {
|
||||
Name string `json:"name"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int `json:"size"`
|
||||
DataURL string `json:"data_url"` // Base64 data URL for direct playback
|
||||
}
|
||||
|
||||
// ManifestInfo holds public metadata (readable without decryption)
|
||||
type ManifestInfo struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
ReleaseType string `json:"release_type,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
IssuedAt int64 `json:"issued_at,omitempty"`
|
||||
LicenseType string `json:"license_type,omitempty"`
|
||||
Tracks []TrackInfo `json:"tracks,omitempty"`
|
||||
IsExpired bool `json:"is_expired"`
|
||||
TimeRemaining string `json:"time_remaining,omitempty"`
|
||||
}
|
||||
|
||||
// TrackInfo describes a track marker
|
||||
type TrackInfo struct {
|
||||
Title string `json:"title"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
TrackNum int `json:"track_num,omitempty"`
|
||||
}
|
||||
|
||||
// GetManifest returns public metadata without decryption
|
||||
// This is memory-speed via Wails bindings
|
||||
func (p *Player) GetManifest(encrypted string) (*ManifestInfo, error) {
|
||||
info, err := smsg.GetInfoBase64(encrypted)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get manifest: %w", err)
|
||||
}
|
||||
|
||||
result := &ManifestInfo{}
|
||||
|
||||
if info.Manifest != nil {
|
||||
m := info.Manifest
|
||||
result.Title = m.Title
|
||||
result.Artist = m.Artist
|
||||
result.Album = m.Album
|
||||
result.Genre = m.Genre
|
||||
result.Year = m.Year
|
||||
result.ReleaseType = m.ReleaseType
|
||||
result.Duration = m.Duration
|
||||
result.Format = m.Format
|
||||
result.ExpiresAt = m.ExpiresAt
|
||||
result.IssuedAt = m.IssuedAt
|
||||
result.LicenseType = m.LicenseType
|
||||
result.IsExpired = m.IsExpired()
|
||||
|
||||
if !result.IsExpired && m.ExpiresAt > 0 {
|
||||
remaining := m.TimeRemaining()
|
||||
result.TimeRemaining = formatDurationSeconds(remaining)
|
||||
}
|
||||
|
||||
for _, t := range m.Tracks {
|
||||
result.Tracks = append(result.Tracks, TrackInfo{
|
||||
Title: t.Title,
|
||||
Start: t.Start,
|
||||
End: t.End,
|
||||
Type: t.Type,
|
||||
TrackNum: t.TrackNum,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsLicenseValid checks if the license has expired
|
||||
// This is memory-speed via Wails bindings
|
||||
func (p *Player) IsLicenseValid(encrypted string) (bool, error) {
|
||||
info, err := smsg.GetInfoBase64(encrypted)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to check license: %w", err)
|
||||
}
|
||||
|
||||
if info.Manifest != nil && info.Manifest.ExpiresAt > 0 {
|
||||
return !info.Manifest.IsExpired(), nil
|
||||
}
|
||||
|
||||
// No expiration set = perpetual license
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Decrypt decrypts the SMSG content and returns playable media
|
||||
// This is memory-speed via Wails bindings - no HTTP, no WASM
|
||||
func (p *Player) Decrypt(encrypted string, password string) (*DecryptResult, error) {
|
||||
// Check license first
|
||||
valid, err := p.IsLicenseValid(encrypted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !valid {
|
||||
return nil, fmt.Errorf("license has expired")
|
||||
}
|
||||
|
||||
// Decrypt using pkg/smsg (Base64 variant for string input)
|
||||
msg, err := smsg.DecryptBase64(encrypted, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decryption failed: %w", err)
|
||||
}
|
||||
|
||||
result := &DecryptResult{
|
||||
Body: msg.Body,
|
||||
Subject: msg.Subject,
|
||||
From: msg.From,
|
||||
}
|
||||
|
||||
// Convert attachments to data URLs for direct playback
|
||||
for _, att := range msg.Attachments {
|
||||
// Decode base64 content to get size
|
||||
data, err := base64.StdEncoding.DecodeString(att.Content)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Create data URL for the browser to play directly
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", att.MimeType, att.Content)
|
||||
|
||||
result.Attachments = append(result.Attachments, AttachmentInfo{
|
||||
Name: att.Name,
|
||||
MimeType: att.MimeType,
|
||||
Size: len(data),
|
||||
DataURL: dataURL,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// QuickDecrypt returns just the first attachment as a data URL
|
||||
// Optimized for single-track playback
|
||||
func (p *Player) QuickDecrypt(encrypted string, password string) (string, error) {
|
||||
result, err := p.Decrypt(encrypted, password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(result.Attachments) == 0 {
|
||||
return "", fmt.Errorf("no media attachments found")
|
||||
}
|
||||
|
||||
return result.Attachments[0].DataURL, nil
|
||||
}
|
||||
|
||||
// GetLicenseInfo returns detailed license information
|
||||
func (p *Player) GetLicenseInfo(encrypted string) (map[string]interface{}, error) {
|
||||
manifest, err := p.GetManifest(encrypted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := map[string]interface{}{
|
||||
"is_valid": !manifest.IsExpired,
|
||||
"license_type": manifest.LicenseType,
|
||||
"time_remaining": manifest.TimeRemaining,
|
||||
}
|
||||
|
||||
if manifest.ExpiresAt > 0 {
|
||||
info["expires_at"] = time.Unix(manifest.ExpiresAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
if manifest.IssuedAt > 0 {
|
||||
info["issued_at"] = time.Unix(manifest.IssuedAt, 0).Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// Serve starts an HTTP server for CLI/fallback mode
|
||||
// This is the slower TCP path - use Wails bindings when possible
|
||||
func (p *Player) Serve(addr string) error {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Serve embedded assets
|
||||
mux.Handle("/", http.FileServer(http.FS(Assets)))
|
||||
|
||||
// API endpoints for WASM fallback
|
||||
mux.HandleFunc("/api/manifest", p.handleManifest)
|
||||
mux.HandleFunc("/api/decrypt", p.handleDecrypt)
|
||||
mux.HandleFunc("/api/license", p.handleLicense)
|
||||
|
||||
fmt.Printf("dapp.fm player serving at http://localhost%s\n", addr)
|
||||
return http.ListenAndServe(addr, mux)
|
||||
}
|
||||
|
||||
func (p *Player) handleManifest(w http.ResponseWriter, r *http.Request) {
|
||||
encrypted := r.URL.Query().Get("data")
|
||||
if encrypted == "" {
|
||||
http.Error(w, "missing data parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
manifest, err := p.GetManifest(encrypted)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(manifest)
|
||||
}
|
||||
|
||||
func (p *Player) handleDecrypt(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Encrypted string `json:"encrypted"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := p.Decrypt(req.Encrypted, req.Password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func (p *Player) handleLicense(w http.ResponseWriter, r *http.Request) {
|
||||
encrypted := r.URL.Query().Get("data")
|
||||
if encrypted == "" {
|
||||
http.Error(w, "missing data parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := p.GetLicenseInfo(encrypted)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(info)
|
||||
}
|
||||
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
return "expired"
|
||||
}
|
||||
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
minutes := int(d.Minutes()) % 60
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
|
||||
func formatDurationSeconds(seconds int64) string {
|
||||
if seconds < 0 {
|
||||
return "expired"
|
||||
}
|
||||
|
||||
days := seconds / 86400
|
||||
hours := (seconds % 86400) / 3600
|
||||
minutes := (seconds % 3600) / 60
|
||||
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
}
|
||||
return fmt.Sprintf("%dm", minutes)
|
||||
}
|
||||
214
pkg/smsg/abr.go
Normal file
214
pkg/smsg/abr.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// Package smsg - Adaptive Bitrate Streaming (ABR) support
|
||||
//
|
||||
// ABR enables multi-bitrate streaming with automatic quality switching based on
|
||||
// network conditions. Similar to HLS/DASH but with ChaCha20-Poly1305 encryption.
|
||||
//
|
||||
// Architecture:
|
||||
// - Master manifest (.json) lists available quality variants
|
||||
// - Each variant is a standard v3 chunked .smsg file
|
||||
// - Same password decrypts all variants (CEK unwrapped once)
|
||||
// - Player switches variants at chunk boundaries based on bandwidth
|
||||
package smsg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
const ABRVersion = "abr-v1"
|
||||
|
||||
// ABRSafetyFactor is the bandwidth multiplier for variant selection.
|
||||
// Using 80% of available bandwidth prevents buffering on fluctuating networks.
|
||||
const ABRSafetyFactor = 0.8
|
||||
|
||||
// NewABRManifest creates a new ABR manifest with the given title.
|
||||
func NewABRManifest(title string) *ABRManifest {
|
||||
return &ABRManifest{
|
||||
Version: ABRVersion,
|
||||
Title: title,
|
||||
Variants: make([]Variant, 0),
|
||||
DefaultIdx: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// AddVariant adds a quality variant to the manifest.
|
||||
// Variants are automatically sorted by bandwidth (ascending) after adding.
|
||||
func (m *ABRManifest) AddVariant(v Variant) {
|
||||
m.Variants = append(m.Variants, v)
|
||||
// Sort by bandwidth ascending (lowest quality first)
|
||||
sort.Slice(m.Variants, func(i, j int) bool {
|
||||
return m.Variants[i].Bandwidth < m.Variants[j].Bandwidth
|
||||
})
|
||||
// Update default to 720p if available, otherwise middle variant
|
||||
m.DefaultIdx = m.findDefaultVariant()
|
||||
}
|
||||
|
||||
// findDefaultVariant finds the best default variant (prefers 720p).
|
||||
func (m *ABRManifest) findDefaultVariant() int {
|
||||
// Prefer 720p as default
|
||||
for i, v := range m.Variants {
|
||||
if v.Name == "720p" || v.Height == 720 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
// Otherwise use middle variant
|
||||
if len(m.Variants) > 0 {
|
||||
return len(m.Variants) / 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SelectVariant selects the best variant for the given bandwidth (bits per second).
|
||||
// Returns the index of the highest quality variant that fits within the bandwidth.
|
||||
func (m *ABRManifest) SelectVariant(bandwidthBPS int) int {
|
||||
safeBandwidth := float64(bandwidthBPS) * ABRSafetyFactor
|
||||
|
||||
// Find highest quality that fits
|
||||
selected := 0
|
||||
for i, v := range m.Variants {
|
||||
if float64(v.Bandwidth) <= safeBandwidth {
|
||||
selected = i
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
// GetVariant returns the variant at the given index, or nil if out of range.
|
||||
func (m *ABRManifest) GetVariant(idx int) *Variant {
|
||||
if idx < 0 || idx >= len(m.Variants) {
|
||||
return nil
|
||||
}
|
||||
return &m.Variants[idx]
|
||||
}
|
||||
|
||||
// WriteABRManifest writes the ABR manifest to a JSON file.
|
||||
func WriteABRManifest(manifest *ABRManifest, path string) error {
|
||||
data, err := json.MarshalIndent(manifest, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal ABR manifest: %w", err)
|
||||
}
|
||||
|
||||
// Ensure directory exists
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("write ABR manifest: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadABRManifest reads an ABR manifest from a JSON file.
|
||||
func ReadABRManifest(path string) (*ABRManifest, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ABR manifest: %w", err)
|
||||
}
|
||||
|
||||
return ParseABRManifest(data)
|
||||
}
|
||||
|
||||
// ParseABRManifest parses an ABR manifest from JSON bytes.
|
||||
func ParseABRManifest(data []byte) (*ABRManifest, error) {
|
||||
var manifest ABRManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("parse ABR manifest: %w", err)
|
||||
}
|
||||
|
||||
// Validate version
|
||||
if manifest.Version != ABRVersion {
|
||||
return nil, fmt.Errorf("unsupported ABR version: %s (expected %s)", manifest.Version, ABRVersion)
|
||||
}
|
||||
|
||||
return &manifest, nil
|
||||
}
|
||||
|
||||
// VariantFromSMSG creates a Variant from an existing .smsg file.
|
||||
// It reads the header to extract chunk count and file size.
|
||||
func VariantFromSMSG(name string, bandwidth, width, height int, smsgPath string) (*Variant, error) {
|
||||
// Read file to get size and chunk info
|
||||
data, err := os.ReadFile(smsgPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read smsg file: %w", err)
|
||||
}
|
||||
|
||||
// Get header to extract chunk count
|
||||
header, err := GetV3Header(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse smsg header: %w", err)
|
||||
}
|
||||
|
||||
chunkCount := 0
|
||||
if header.Chunked != nil {
|
||||
chunkCount = header.Chunked.TotalChunks
|
||||
}
|
||||
|
||||
return &Variant{
|
||||
Name: name,
|
||||
Bandwidth: bandwidth,
|
||||
Width: width,
|
||||
Height: height,
|
||||
Codecs: "avc1.640028,mp4a.40.2", // Default H.264 + AAC
|
||||
URL: filepath.Base(smsgPath),
|
||||
ChunkCount: chunkCount,
|
||||
FileSize: int64(len(data)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ABRBandwidthEstimator tracks download speeds for adaptive quality selection.
|
||||
type ABRBandwidthEstimator struct {
|
||||
samples []int // bandwidth samples in bps
|
||||
maxSamples int
|
||||
}
|
||||
|
||||
// NewABRBandwidthEstimator creates a new bandwidth estimator.
|
||||
func NewABRBandwidthEstimator(maxSamples int) *ABRBandwidthEstimator {
|
||||
if maxSamples <= 0 {
|
||||
maxSamples = 10
|
||||
}
|
||||
return &ABRBandwidthEstimator{
|
||||
samples: make([]int, 0, maxSamples),
|
||||
maxSamples: maxSamples,
|
||||
}
|
||||
}
|
||||
|
||||
// RecordSample records a bandwidth sample from a download.
|
||||
// bytes is the number of bytes downloaded, durationMs is the time in milliseconds.
|
||||
func (e *ABRBandwidthEstimator) RecordSample(bytes int, durationMs int) {
|
||||
if durationMs <= 0 {
|
||||
return
|
||||
}
|
||||
// Calculate bits per second: (bytes * 8 * 1000) / durationMs
|
||||
bps := (bytes * 8 * 1000) / durationMs
|
||||
e.samples = append(e.samples, bps)
|
||||
if len(e.samples) > e.maxSamples {
|
||||
e.samples = e.samples[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// Estimate returns the estimated bandwidth in bits per second.
|
||||
// Uses average of recent samples, or 1 Mbps default if no samples.
|
||||
func (e *ABRBandwidthEstimator) Estimate() int {
|
||||
if len(e.samples) == 0 {
|
||||
return 1000000 // 1 Mbps default
|
||||
}
|
||||
|
||||
// Use average of last 3 samples (or all if fewer)
|
||||
count := 3
|
||||
if len(e.samples) < count {
|
||||
count = len(e.samples)
|
||||
}
|
||||
recent := e.samples[len(e.samples)-count:]
|
||||
|
||||
sum := 0
|
||||
for _, s := range recent {
|
||||
sum += s
|
||||
}
|
||||
return sum / count
|
||||
}
|
||||
338
pkg/smsg/smsg.go
338
pkg/smsg/smsg.go
|
|
@ -1,14 +1,37 @@
|
|||
package smsg
|
||||
|
||||
// SMSG (Secure Message) provides ChaCha20-Poly1305 authenticated encryption.
|
||||
//
|
||||
// IMPORTANT: Nonce handling for developers
|
||||
// =========================================
|
||||
// Enchantrix embeds the nonce directly in the ciphertext:
|
||||
//
|
||||
// [24-byte nonce][encrypted data][16-byte auth tag]
|
||||
//
|
||||
// The nonce is NOT transmitted separately in headers. It is:
|
||||
// - Generated fresh (random) for each encryption
|
||||
// - Extracted automatically from ciphertext during decryption
|
||||
// - Safe to transmit (public) - only the KEY must remain secret
|
||||
//
|
||||
// This means wrapped keys, encrypted payloads, etc. are self-contained.
|
||||
// You only need the correct key to decrypt - no nonce management required.
|
||||
//
|
||||
// See: github.com/Snider/Enchantrix/pkg/enchantrix/crypto_sigil.go
|
||||
|
||||
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.
|
||||
|
|
@ -120,7 +143,64 @@ func EncryptWithHint(msg *Message, password, hint string) ([]byte, error) {
|
|||
return trix.Encode(t, Magic, nil)
|
||||
}
|
||||
|
||||
// EncryptWithManifest encrypts with public manifest metadata in the clear text header
|
||||
// The manifest is visible without decryption, enabling content discovery and indexing
|
||||
func EncryptWithManifest(msg *Message, password string, manifest *Manifest) ([]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()
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
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 with manifest
|
||||
headerMap := map[string]interface{}{
|
||||
"version": Version,
|
||||
"algorithm": "chacha20poly1305",
|
||||
}
|
||||
if manifest != nil {
|
||||
headerMap["manifest"] = manifest
|
||||
}
|
||||
|
||||
t := &trix.Trix{
|
||||
Header: headerMap,
|
||||
Payload: encrypted,
|
||||
}
|
||||
|
||||
return trix.Encode(t, Magic, nil)
|
||||
}
|
||||
|
||||
// EncryptWithManifestBase64 encrypts with manifest and returns base64
|
||||
func EncryptWithManifestBase64(msg *Message, password string, manifest *Manifest) (string, error) {
|
||||
encrypted, err := EncryptWithManifest(msg, password, manifest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
@ -132,6 +212,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)
|
||||
|
|
@ -145,7 +235,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)
|
||||
|
|
@ -177,10 +288,28 @@ 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
|
||||
}
|
||||
|
||||
// Extract manifest if present
|
||||
if manifestData, ok := t.Header["manifest"]; ok && manifestData != nil {
|
||||
// Re-marshal and unmarshal to properly convert the map to Manifest struct
|
||||
manifestBytes, err := json.Marshal(manifestData)
|
||||
if err == nil {
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(manifestBytes, &manifest); err == nil {
|
||||
header.Manifest = &manifest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return header, nil
|
||||
}
|
||||
|
||||
|
|
@ -216,3 +345,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -268,3 +268,443 @@ func TestEmptyMessageError(t *testing.T) {
|
|||
t.Errorf("Expected ErrEmptyMessage, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptWithManifest(t *testing.T) {
|
||||
msg := NewMessage("Licensed content")
|
||||
password := "license-token-123"
|
||||
|
||||
// Create manifest with tracks
|
||||
manifest := NewManifest("Summer EP 2024").
|
||||
AddTrackFull("Intro", 0, 30, "intro").
|
||||
AddTrackFull("Main Track", 30, 180, "full").
|
||||
AddTrack("Outro", 180)
|
||||
manifest.Artist = "Test Artist"
|
||||
manifest.ReleaseType = "ep"
|
||||
manifest.Format = "dapp.fm/v1"
|
||||
|
||||
encrypted, err := EncryptWithManifest(msg, password, manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithManifest failed: %v", err)
|
||||
}
|
||||
|
||||
// Get info without decryption - should have manifest
|
||||
header, err := GetInfo(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if header.Manifest == nil {
|
||||
t.Fatal("Expected manifest in header")
|
||||
}
|
||||
|
||||
if header.Manifest.Title != "Summer EP 2024" {
|
||||
t.Errorf("Title = %q, want %q", header.Manifest.Title, "Summer EP 2024")
|
||||
}
|
||||
|
||||
if header.Manifest.Artist != "Test Artist" {
|
||||
t.Errorf("Artist = %q, want %q", header.Manifest.Artist, "Test Artist")
|
||||
}
|
||||
|
||||
if header.Manifest.ReleaseType != "ep" {
|
||||
t.Errorf("ReleaseType = %q, want %q", header.Manifest.ReleaseType, "ep")
|
||||
}
|
||||
|
||||
if len(header.Manifest.Tracks) != 3 {
|
||||
t.Errorf("Tracks count = %d, want 3", len(header.Manifest.Tracks))
|
||||
}
|
||||
|
||||
// Verify tracks
|
||||
if header.Manifest.Tracks[0].Title != "Intro" {
|
||||
t.Errorf("Track 0 Title = %q, want %q", header.Manifest.Tracks[0].Title, "Intro")
|
||||
}
|
||||
if header.Manifest.Tracks[0].Start != 0 {
|
||||
t.Errorf("Track 0 Start = %v, want 0", header.Manifest.Tracks[0].Start)
|
||||
}
|
||||
if header.Manifest.Tracks[0].Type != "intro" {
|
||||
t.Errorf("Track 0 Type = %q, want %q", header.Manifest.Tracks[0].Type, "intro")
|
||||
}
|
||||
|
||||
// Can still decrypt normally
|
||||
decrypted, err := Decrypt(encrypted, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.Body != "Licensed content" {
|
||||
t.Errorf("Body = %q, want %q", decrypted.Body, "Licensed content")
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestBuilder(t *testing.T) {
|
||||
manifest := NewManifest("Test Album")
|
||||
manifest.Artist = "Artist Name"
|
||||
manifest.Album = "Album Name"
|
||||
manifest.Year = 2024
|
||||
manifest.Genre = "Electronic"
|
||||
manifest.ReleaseType = "album"
|
||||
manifest.Tags = []string{"electronic", "ambient"}
|
||||
manifest.Extra["custom_field"] = "custom_value"
|
||||
|
||||
// Add tracks
|
||||
manifest.AddTrack("Track 1", 0)
|
||||
manifest.AddTrack("Track 2", 120)
|
||||
manifest.AddTrackFull("Track 3", 240, 360, "outro")
|
||||
|
||||
if manifest.Title != "Test Album" {
|
||||
t.Errorf("Title = %q, want %q", manifest.Title, "Test Album")
|
||||
}
|
||||
|
||||
if len(manifest.Tracks) != 3 {
|
||||
t.Fatalf("Track count = %d, want 3", len(manifest.Tracks))
|
||||
}
|
||||
|
||||
// First track should have TrackNum 1
|
||||
if manifest.Tracks[0].TrackNum != 1 {
|
||||
t.Errorf("Track 1 TrackNum = %d, want 1", manifest.Tracks[0].TrackNum)
|
||||
}
|
||||
|
||||
// Third track should have end time
|
||||
if manifest.Tracks[2].End != 360 {
|
||||
t.Errorf("Track 3 End = %v, want 360", manifest.Tracks[2].End)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManifestExpiration(t *testing.T) {
|
||||
// Test perpetual license (no expiration)
|
||||
perpetual := NewManifest("Perpetual Album")
|
||||
if perpetual.IsExpired() {
|
||||
t.Error("Perpetual license should not be expired")
|
||||
}
|
||||
if perpetual.TimeRemaining() != 0 {
|
||||
t.Error("Perpetual license should have 0 time remaining (infinite)")
|
||||
}
|
||||
if perpetual.LicenseType != "perpetual" {
|
||||
t.Errorf("LicenseType = %q, want perpetual", perpetual.LicenseType)
|
||||
}
|
||||
|
||||
// Test streaming access (24 hours)
|
||||
stream := NewManifest("Stream Album").WithStreamingAccess(24)
|
||||
if stream.IsExpired() {
|
||||
t.Error("Streaming license should not be expired immediately")
|
||||
}
|
||||
if stream.LicenseType != "stream" {
|
||||
t.Errorf("LicenseType = %q, want stream", stream.LicenseType)
|
||||
}
|
||||
remaining := stream.TimeRemaining()
|
||||
if remaining < 86000 || remaining > 86400 {
|
||||
t.Errorf("TimeRemaining = %d, expected ~86400", remaining)
|
||||
}
|
||||
|
||||
// Test rental with duration
|
||||
rental := NewManifest("Rental Album").WithRentalDuration(3600) // 1 hour
|
||||
if rental.IsExpired() {
|
||||
t.Error("Rental license should not be expired immediately")
|
||||
}
|
||||
if rental.LicenseType != "rental" {
|
||||
t.Errorf("LicenseType = %q, want rental", rental.LicenseType)
|
||||
}
|
||||
|
||||
// Test preview (30 seconds)
|
||||
preview := NewManifest("Preview Track").WithPreviewAccess(30)
|
||||
if preview.IsExpired() {
|
||||
t.Error("Preview license should not be expired immediately")
|
||||
}
|
||||
if preview.LicenseType != "preview" {
|
||||
t.Errorf("LicenseType = %q, want preview", preview.LicenseType)
|
||||
}
|
||||
|
||||
// Test already expired license
|
||||
expired := NewManifest("Expired Album")
|
||||
expired.ExpiresAt = 1000 // Very old timestamp
|
||||
if !expired.IsExpired() {
|
||||
t.Error("License with old expiration should be expired")
|
||||
}
|
||||
if expired.TimeRemaining() >= 0 {
|
||||
t.Error("Expired license should have negative time remaining")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpirationInHeader(t *testing.T) {
|
||||
msg := NewMessage("Licensed content")
|
||||
password := "stream-token-123"
|
||||
|
||||
// Create streaming license (24 hours)
|
||||
manifest := NewManifest("Streaming EP").WithStreamingAccess(24)
|
||||
|
||||
encrypted, err := EncryptWithManifest(msg, password, manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithManifest failed: %v", err)
|
||||
}
|
||||
|
||||
// Get info should show expiration
|
||||
header, err := GetInfo(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if header.Manifest == nil {
|
||||
t.Fatal("Expected manifest in header")
|
||||
}
|
||||
|
||||
if header.Manifest.LicenseType != "stream" {
|
||||
t.Errorf("LicenseType = %q, want stream", header.Manifest.LicenseType)
|
||||
}
|
||||
|
||||
if header.Manifest.ExpiresAt == 0 {
|
||||
t.Error("ExpiresAt should not be 0 for streaming license")
|
||||
}
|
||||
|
||||
if header.Manifest.IssuedAt == 0 {
|
||||
t.Error("IssuedAt should not be 0")
|
||||
}
|
||||
|
||||
if header.Manifest.IsExpired() {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
827
pkg/smsg/stream.go
Normal file
827
pkg/smsg/stream.go
Normal file
|
|
@ -0,0 +1,827 @@
|
|||
package smsg
|
||||
|
||||
// V3 Streaming Support with LTHN Rolling Keys
|
||||
//
|
||||
// This file implements zero-trust streaming where:
|
||||
// - Content is encrypted once with a random CEK (Content Encryption Key)
|
||||
// - CEK is wrapped (encrypted) with time-bound stream keys
|
||||
// - Stream keys are derived using LTHN(date:license:fingerprint)
|
||||
// - Rolling window: today and tomorrow keys are valid (24-48hr window)
|
||||
// - Keys auto-expire - no revocation needed
|
||||
//
|
||||
// Server flow:
|
||||
// 1. Generate random CEK
|
||||
// 2. Encrypt content with CEK
|
||||
// 3. For today & tomorrow: wrap CEK with DeriveStreamKey(date, license, fingerprint)
|
||||
// 4. Store wrapped keys in header
|
||||
//
|
||||
// Client flow:
|
||||
// 1. Derive stream key for today (or tomorrow)
|
||||
// 2. Try to unwrap CEK from header
|
||||
// 3. Decrypt content with CEK
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Snider/Enchantrix/pkg/crypt"
|
||||
"github.com/Snider/Enchantrix/pkg/enchantrix"
|
||||
"github.com/Snider/Enchantrix/pkg/trix"
|
||||
)
|
||||
|
||||
// StreamParams contains the parameters needed for stream key derivation
|
||||
type StreamParams struct {
|
||||
License string // User's license identifier
|
||||
Fingerprint string // Device/session fingerprint
|
||||
Cadence Cadence // Key rotation cadence (default: daily)
|
||||
ChunkSize int // Optional: chunk size for decrypt-while-downloading (0 = no chunking)
|
||||
}
|
||||
|
||||
// DeriveStreamKey derives a 32-byte ChaCha key from date, license, and fingerprint.
|
||||
// Uses LTHN hash which is rainbow-table resistant (salt derived from input itself).
|
||||
//
|
||||
// The derived key is: SHA256(LTHN("YYYY-MM-DD:license:fingerprint"))
|
||||
func DeriveStreamKey(date, license, fingerprint string) []byte {
|
||||
// Build input string
|
||||
input := fmt.Sprintf("%s:%s:%s", date, license, fingerprint)
|
||||
|
||||
// Use Enchantrix crypt service for LTHN hash
|
||||
cryptService := crypt.NewService()
|
||||
lthnHash := cryptService.Hash(crypt.LTHN, input)
|
||||
|
||||
// LTHN returns hex string, hash it again to get 32 bytes for ChaCha
|
||||
key := sha256.Sum256([]byte(lthnHash))
|
||||
return key[:]
|
||||
}
|
||||
|
||||
// GetRollingDates returns today and tomorrow's date strings in YYYY-MM-DD format
|
||||
// This is the default daily cadence.
|
||||
func GetRollingDates() (current, next string) {
|
||||
return GetRollingPeriods(CadenceDaily, time.Now().UTC())
|
||||
}
|
||||
|
||||
// GetRollingDatesAt returns today and tomorrow relative to a specific time
|
||||
func GetRollingDatesAt(t time.Time) (current, next string) {
|
||||
return GetRollingPeriods(CadenceDaily, t.UTC())
|
||||
}
|
||||
|
||||
// GetRollingPeriods returns the current and next period strings based on cadence.
|
||||
// The period string format varies by cadence:
|
||||
// - daily: "2006-01-02"
|
||||
// - 12h: "2006-01-02-AM" or "2006-01-02-PM"
|
||||
// - 6h: "2006-01-02-00", "2006-01-02-06", "2006-01-02-12", "2006-01-02-18"
|
||||
// - 1h: "2006-01-02-15" (hour in 24h format)
|
||||
func GetRollingPeriods(cadence Cadence, t time.Time) (current, next string) {
|
||||
t = t.UTC()
|
||||
|
||||
switch cadence {
|
||||
case CadenceHalfDay:
|
||||
// 12-hour periods: AM (00:00-11:59) and PM (12:00-23:59)
|
||||
date := t.Format("2006-01-02")
|
||||
if t.Hour() < 12 {
|
||||
current = date + "-AM"
|
||||
next = date + "-PM"
|
||||
} else {
|
||||
current = date + "-PM"
|
||||
next = t.AddDate(0, 0, 1).Format("2006-01-02") + "-AM"
|
||||
}
|
||||
|
||||
case CadenceQuarter:
|
||||
// 6-hour periods: 00, 06, 12, 18
|
||||
date := t.Format("2006-01-02")
|
||||
hour := t.Hour()
|
||||
period := (hour / 6) * 6
|
||||
nextPeriod := period + 6
|
||||
|
||||
current = fmt.Sprintf("%s-%02d", date, period)
|
||||
if nextPeriod >= 24 {
|
||||
next = fmt.Sprintf("%s-%02d", t.AddDate(0, 0, 1).Format("2006-01-02"), 0)
|
||||
} else {
|
||||
next = fmt.Sprintf("%s-%02d", date, nextPeriod)
|
||||
}
|
||||
|
||||
case CadenceHourly:
|
||||
// Hourly periods
|
||||
current = t.Format("2006-01-02-15")
|
||||
next = t.Add(time.Hour).Format("2006-01-02-15")
|
||||
|
||||
default: // CadenceDaily or empty
|
||||
current = t.Format("2006-01-02")
|
||||
next = t.AddDate(0, 0, 1).Format("2006-01-02")
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// GetCadenceWindowDuration returns the duration of one period for a cadence
|
||||
func GetCadenceWindowDuration(cadence Cadence) time.Duration {
|
||||
switch cadence {
|
||||
case CadenceHourly:
|
||||
return time.Hour
|
||||
case CadenceQuarter:
|
||||
return 6 * time.Hour
|
||||
case CadenceHalfDay:
|
||||
return 12 * time.Hour
|
||||
default: // CadenceDaily
|
||||
return 24 * time.Hour
|
||||
}
|
||||
}
|
||||
|
||||
// WrapCEK wraps a Content Encryption Key with a stream key
|
||||
// Returns base64-encoded wrapped key (includes nonce)
|
||||
func WrapCEK(cek, streamKey []byte) (string, error) {
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(streamKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
wrapped, err := sigil.In(cek)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to wrap CEK: %w", err)
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(wrapped), nil
|
||||
}
|
||||
|
||||
// UnwrapCEK unwraps a Content Encryption Key using a stream key
|
||||
// Takes base64-encoded wrapped key, returns raw CEK bytes
|
||||
func UnwrapCEK(wrappedB64 string, streamKey []byte) ([]byte, error) {
|
||||
wrapped, err := base64.StdEncoding.DecodeString(wrappedB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode wrapped key: %w", err)
|
||||
}
|
||||
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(streamKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
cek, err := sigil.Out(wrapped)
|
||||
if err != nil {
|
||||
return nil, ErrDecryptionFailed
|
||||
}
|
||||
|
||||
return cek, nil
|
||||
}
|
||||
|
||||
// GenerateCEK generates a random 32-byte Content Encryption Key
|
||||
func GenerateCEK() ([]byte, error) {
|
||||
cek := make([]byte, 32)
|
||||
if _, err := rand.Read(cek); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate CEK: %w", err)
|
||||
}
|
||||
return cek, nil
|
||||
}
|
||||
|
||||
// EncryptV3 encrypts a message using v3 streaming format with rolling keys.
|
||||
// The content is encrypted with a random CEK, which is then wrapped with
|
||||
// stream keys for today and tomorrow.
|
||||
//
|
||||
// When params.ChunkSize > 0, content is split into independently decryptable
|
||||
// chunks, enabling decrypt-while-downloading and seeking.
|
||||
func EncryptV3(msg *Message, params *StreamParams, manifest *Manifest) ([]byte, error) {
|
||||
if params == nil || params.License == "" {
|
||||
return nil, ErrLicenseRequired
|
||||
}
|
||||
if msg.Body == "" && len(msg.Attachments) == 0 {
|
||||
return nil, ErrEmptyMessage
|
||||
}
|
||||
|
||||
// Set timestamp if not set
|
||||
if msg.Timestamp == 0 {
|
||||
msg.Timestamp = time.Now().Unix()
|
||||
}
|
||||
|
||||
// Generate random CEK
|
||||
cek, err := GenerateCEK()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Determine cadence (default to daily if not specified)
|
||||
cadence := params.Cadence
|
||||
if cadence == "" {
|
||||
cadence = CadenceDaily
|
||||
}
|
||||
|
||||
// Get rolling periods based on cadence
|
||||
current, next := GetRollingPeriods(cadence, time.Now().UTC())
|
||||
|
||||
// Wrap CEK with current period's stream key
|
||||
currentKey := DeriveStreamKey(current, params.License, params.Fingerprint)
|
||||
wrappedCurrent, err := WrapCEK(cek, currentKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to wrap CEK for current period: %w", err)
|
||||
}
|
||||
|
||||
// Wrap CEK with next period's stream key
|
||||
nextKey := DeriveStreamKey(next, params.License, params.Fingerprint)
|
||||
wrappedNext, err := WrapCEK(cek, nextKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to wrap CEK for next period: %w", err)
|
||||
}
|
||||
|
||||
// Check if chunked mode requested
|
||||
if params.ChunkSize > 0 {
|
||||
return encryptV3Chunked(msg, params, manifest, cek, cadence, current, next, wrappedCurrent, wrappedNext)
|
||||
}
|
||||
|
||||
// Non-chunked v3 (original behavior)
|
||||
return encryptV3Standard(msg, params, manifest, cek, cadence, current, next, wrappedCurrent, wrappedNext)
|
||||
}
|
||||
|
||||
// encryptV3Standard encrypts as a single block (original v3 behavior)
|
||||
func encryptV3Standard(msg *Message, params *StreamParams, manifest *Manifest, cek []byte, cadence Cadence, current, next, wrappedCurrent, wrappedNext string) ([]byte, error) {
|
||||
// Build v3 payload (similar to v2 but encrypted with CEK)
|
||||
payload, attachmentData, err := buildV3Payload(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Compress payload
|
||||
compressed, err := zstdCompress(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("compression failed: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt with CEK
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(cek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
encrypted, err := sigil.In(compressed)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encryption failed: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt attachment data with CEK
|
||||
encryptedAttachments, err := sigil.In(attachmentData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("attachment encryption failed: %w", err)
|
||||
}
|
||||
|
||||
// Create header with wrapped keys
|
||||
headerMap := map[string]interface{}{
|
||||
"version": Version,
|
||||
"algorithm": "chacha20poly1305",
|
||||
"format": FormatV3,
|
||||
"compression": CompressionZstd,
|
||||
"keyMethod": KeyMethodLTHNRolling,
|
||||
"cadence": string(cadence),
|
||||
"wrappedKeys": []WrappedKey{
|
||||
{Date: current, Wrapped: wrappedCurrent},
|
||||
{Date: next, Wrapped: wrappedNext},
|
||||
},
|
||||
}
|
||||
|
||||
if manifest != nil {
|
||||
if manifest.IssuedAt == 0 {
|
||||
manifest.IssuedAt = time.Now().Unix()
|
||||
}
|
||||
headerMap["manifest"] = manifest
|
||||
}
|
||||
|
||||
// Build v3 binary format: [4-byte json len][json header][encrypted payload][encrypted attachments]
|
||||
headerJSON, err := json.Marshal(headerMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal header: %w", err)
|
||||
}
|
||||
|
||||
// Calculate total size
|
||||
totalSize := 4 + len(headerJSON) + 4 + len(encrypted) + len(encryptedAttachments)
|
||||
output := make([]byte, 0, totalSize)
|
||||
|
||||
// Write header length (4 bytes, big-endian)
|
||||
headerLen := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(headerLen, uint32(len(headerJSON)))
|
||||
output = append(output, headerLen...)
|
||||
|
||||
// Write header JSON
|
||||
output = append(output, headerJSON...)
|
||||
|
||||
// Write encrypted payload length (4 bytes, big-endian)
|
||||
payloadLen := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(payloadLen, uint32(len(encrypted)))
|
||||
output = append(output, payloadLen...)
|
||||
|
||||
// Write encrypted payload
|
||||
output = append(output, encrypted...)
|
||||
|
||||
// Write encrypted attachments
|
||||
output = append(output, encryptedAttachments...)
|
||||
|
||||
// Wrap in trix container
|
||||
t := &trix.Trix{
|
||||
Header: headerMap,
|
||||
Payload: output,
|
||||
}
|
||||
|
||||
return trix.Encode(t, Magic, nil)
|
||||
}
|
||||
|
||||
// encryptV3Chunked encrypts content into independently decryptable chunks
|
||||
func encryptV3Chunked(msg *Message, params *StreamParams, manifest *Manifest, cek []byte, cadence Cadence, current, next, wrappedCurrent, wrappedNext string) ([]byte, error) {
|
||||
chunkSize := params.ChunkSize
|
||||
|
||||
// Build raw content to chunk: metadata JSON + binary attachments
|
||||
metaJSON, attachmentData, err := buildV3Payload(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Combine into single byte slice for chunking
|
||||
rawContent := append(metaJSON, attachmentData...)
|
||||
totalSize := int64(len(rawContent))
|
||||
|
||||
// Create sigil with CEK for chunk encryption
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(cek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt in chunks
|
||||
var chunks [][]byte
|
||||
var chunkIndex []ChunkInfo
|
||||
offset := 0
|
||||
|
||||
for i := 0; offset < len(rawContent); i++ {
|
||||
// Determine this chunk's size
|
||||
end := offset + chunkSize
|
||||
if end > len(rawContent) {
|
||||
end = len(rawContent)
|
||||
}
|
||||
chunkData := rawContent[offset:end]
|
||||
|
||||
// Encrypt chunk (each gets its own nonce)
|
||||
encryptedChunk, err := sigil.In(chunkData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt chunk %d: %w", i, err)
|
||||
}
|
||||
|
||||
chunks = append(chunks, encryptedChunk)
|
||||
chunkIndex = append(chunkIndex, ChunkInfo{
|
||||
Offset: 0, // Will be calculated after we know all sizes
|
||||
Size: len(encryptedChunk),
|
||||
})
|
||||
|
||||
offset = end
|
||||
}
|
||||
|
||||
// Calculate chunk offsets
|
||||
currentOffset := 0
|
||||
for i := range chunkIndex {
|
||||
chunkIndex[i].Offset = currentOffset
|
||||
currentOffset += chunkIndex[i].Size
|
||||
}
|
||||
|
||||
// Build header with chunked info
|
||||
chunkedInfo := &ChunkedInfo{
|
||||
ChunkSize: chunkSize,
|
||||
TotalChunks: len(chunks),
|
||||
TotalSize: totalSize,
|
||||
Index: chunkIndex,
|
||||
}
|
||||
|
||||
headerMap := map[string]interface{}{
|
||||
"version": Version,
|
||||
"algorithm": "chacha20poly1305",
|
||||
"format": FormatV3,
|
||||
"compression": CompressionNone, // No compression in chunked mode (per-chunk not supported yet)
|
||||
"keyMethod": KeyMethodLTHNRolling,
|
||||
"cadence": string(cadence),
|
||||
"chunked": chunkedInfo,
|
||||
"wrappedKeys": []WrappedKey{
|
||||
{Date: current, Wrapped: wrappedCurrent},
|
||||
{Date: next, Wrapped: wrappedNext},
|
||||
},
|
||||
}
|
||||
|
||||
if manifest != nil {
|
||||
if manifest.IssuedAt == 0 {
|
||||
manifest.IssuedAt = time.Now().Unix()
|
||||
}
|
||||
headerMap["manifest"] = manifest
|
||||
}
|
||||
|
||||
// Concatenate all encrypted chunks
|
||||
var payload []byte
|
||||
for _, chunk := range chunks {
|
||||
payload = append(payload, chunk...)
|
||||
}
|
||||
|
||||
// Wrap in trix container
|
||||
t := &trix.Trix{
|
||||
Header: headerMap,
|
||||
Payload: payload,
|
||||
}
|
||||
|
||||
return trix.Encode(t, Magic, nil)
|
||||
}
|
||||
|
||||
// DecryptV3 decrypts a v3 streaming message using rolling keys.
|
||||
// It tries today's key first, then tomorrow's key.
|
||||
// Automatically handles both chunked and non-chunked v3 formats.
|
||||
func DecryptV3(data []byte, params *StreamParams) (*Message, *Header, error) {
|
||||
if params == nil || params.License == "" {
|
||||
return nil, nil, ErrLicenseRequired
|
||||
}
|
||||
|
||||
// Decode trix container
|
||||
t, err := trix.Decode(data, Magic, nil)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to decode container: %w", err)
|
||||
}
|
||||
|
||||
// Parse header
|
||||
headerJSON, err := json.Marshal(t.Header)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal header: %w", err)
|
||||
}
|
||||
|
||||
var header Header
|
||||
if err := json.Unmarshal(headerJSON, &header); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse header: %w", err)
|
||||
}
|
||||
|
||||
// Verify v3 format
|
||||
if header.Format != FormatV3 {
|
||||
return nil, nil, fmt.Errorf("expected v3 format, got: %s", header.Format)
|
||||
}
|
||||
|
||||
if header.KeyMethod != KeyMethodLTHNRolling {
|
||||
return nil, nil, fmt.Errorf("unsupported key method: %s", header.KeyMethod)
|
||||
}
|
||||
|
||||
// Determine cadence from header (or use params, or default to daily)
|
||||
cadence := header.Cadence
|
||||
if cadence == "" && params.Cadence != "" {
|
||||
cadence = params.Cadence
|
||||
}
|
||||
if cadence == "" {
|
||||
cadence = CadenceDaily
|
||||
}
|
||||
|
||||
// Try to unwrap CEK with rolling keys
|
||||
cek, err := tryUnwrapCEK(header.WrappedKeys, params, cadence)
|
||||
if err != nil {
|
||||
return nil, &header, err
|
||||
}
|
||||
|
||||
// Check if chunked format
|
||||
if header.Chunked != nil {
|
||||
return decryptV3Chunked(t.Payload, cek, &header)
|
||||
}
|
||||
|
||||
// Non-chunked v3
|
||||
return decryptV3Standard(t.Payload, cek, &header)
|
||||
}
|
||||
|
||||
// decryptV3Standard handles non-chunked v3 decryption
|
||||
func decryptV3Standard(payload []byte, cek []byte, header *Header) (*Message, *Header, error) {
|
||||
if len(payload) < 8 {
|
||||
return nil, header, ErrInvalidPayload
|
||||
}
|
||||
|
||||
// Read header length (skip - we already parsed from trix header)
|
||||
headerLen := binary.BigEndian.Uint32(payload[:4])
|
||||
pos := 4 + int(headerLen)
|
||||
|
||||
if len(payload) < pos+4 {
|
||||
return nil, header, ErrInvalidPayload
|
||||
}
|
||||
|
||||
// Read encrypted payload length
|
||||
encryptedLen := binary.BigEndian.Uint32(payload[pos : pos+4])
|
||||
pos += 4
|
||||
|
||||
if len(payload) < pos+int(encryptedLen) {
|
||||
return nil, header, ErrInvalidPayload
|
||||
}
|
||||
|
||||
// Extract encrypted payload and attachments
|
||||
encryptedPayload := payload[pos : pos+int(encryptedLen)]
|
||||
encryptedAttachments := payload[pos+int(encryptedLen):]
|
||||
|
||||
// Decrypt with CEK
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(cek)
|
||||
if err != nil {
|
||||
return nil, header, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
compressed, err := sigil.Out(encryptedPayload)
|
||||
if err != nil {
|
||||
return nil, header, ErrDecryptionFailed
|
||||
}
|
||||
|
||||
// Decompress
|
||||
var decompressed []byte
|
||||
if header.Compression == CompressionZstd {
|
||||
decompressed, err = zstdDecompress(compressed)
|
||||
if err != nil {
|
||||
return nil, header, fmt.Errorf("decompression failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
decompressed = compressed
|
||||
}
|
||||
|
||||
// Parse message
|
||||
var msg Message
|
||||
if err := json.Unmarshal(decompressed, &msg); err != nil {
|
||||
return nil, header, fmt.Errorf("failed to parse message: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt attachments if present
|
||||
if len(encryptedAttachments) > 0 {
|
||||
attachmentData, err := sigil.Out(encryptedAttachments)
|
||||
if err != nil {
|
||||
return nil, header, fmt.Errorf("attachment decryption failed: %w", err)
|
||||
}
|
||||
|
||||
// Restore attachment content from binary data
|
||||
if err := restoreV3Attachments(&msg, attachmentData); err != nil {
|
||||
return nil, header, err
|
||||
}
|
||||
}
|
||||
|
||||
return &msg, header, nil
|
||||
}
|
||||
|
||||
// decryptV3Chunked handles chunked v3 decryption
|
||||
func decryptV3Chunked(payload []byte, cek []byte, header *Header) (*Message, *Header, error) {
|
||||
if header.Chunked == nil {
|
||||
return nil, header, fmt.Errorf("v3 chunked format missing chunked info")
|
||||
}
|
||||
|
||||
// Create sigil for decryption
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(cek)
|
||||
if err != nil {
|
||||
return nil, header, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt all chunks
|
||||
var decrypted []byte
|
||||
|
||||
for i, ci := range header.Chunked.Index {
|
||||
if ci.Offset+ci.Size > len(payload) {
|
||||
return nil, header, fmt.Errorf("chunk %d out of bounds", i)
|
||||
}
|
||||
|
||||
chunkData := payload[ci.Offset : ci.Offset+ci.Size]
|
||||
plaintext, err := sigil.Out(chunkData)
|
||||
if err != nil {
|
||||
return nil, header, fmt.Errorf("failed to decrypt chunk %d: %w", i, err)
|
||||
}
|
||||
|
||||
decrypted = append(decrypted, plaintext...)
|
||||
}
|
||||
|
||||
// Parse decrypted content (metadata JSON + attachments)
|
||||
var msg Message
|
||||
if err := json.Unmarshal(decrypted, &msg); err != nil {
|
||||
// First part should be JSON, but may be mixed with binary
|
||||
// Try to find JSON boundary
|
||||
for i := 0; i < len(decrypted); i++ {
|
||||
if decrypted[i] == '}' {
|
||||
if err := json.Unmarshal(decrypted[:i+1], &msg); err == nil {
|
||||
// Found valid JSON, rest is attachment data
|
||||
if err := restoreV3Attachments(&msg, decrypted[i+1:]); err != nil {
|
||||
return nil, header, err
|
||||
}
|
||||
return &msg, header, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, header, fmt.Errorf("failed to parse message: %w", err)
|
||||
}
|
||||
|
||||
return &msg, header, nil
|
||||
}
|
||||
|
||||
// tryUnwrapCEK attempts to unwrap the CEK using current or next period's key
|
||||
func tryUnwrapCEK(wrappedKeys []WrappedKey, params *StreamParams, cadence Cadence) ([]byte, error) {
|
||||
current, next := GetRollingPeriods(cadence, time.Now().UTC())
|
||||
|
||||
// Build map of available wrapped keys by period
|
||||
keysByPeriod := make(map[string]string)
|
||||
for _, wk := range wrappedKeys {
|
||||
keysByPeriod[wk.Date] = wk.Wrapped
|
||||
}
|
||||
|
||||
// Try current period's key first
|
||||
if wrapped, ok := keysByPeriod[current]; ok {
|
||||
streamKey := DeriveStreamKey(current, params.License, params.Fingerprint)
|
||||
if cek, err := UnwrapCEK(wrapped, streamKey); err == nil {
|
||||
return cek, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try next period's key
|
||||
if wrapped, ok := keysByPeriod[next]; ok {
|
||||
streamKey := DeriveStreamKey(next, params.License, params.Fingerprint)
|
||||
if cek, err := UnwrapCEK(wrapped, streamKey); err == nil {
|
||||
return cek, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrNoValidKey
|
||||
}
|
||||
|
||||
// buildV3Payload builds the message JSON and binary attachment data
|
||||
func buildV3Payload(msg *Message) ([]byte, []byte, error) {
|
||||
// Create a copy of the message without attachment content
|
||||
msgCopy := *msg
|
||||
var attachmentData []byte
|
||||
|
||||
for i := range msgCopy.Attachments {
|
||||
att := &msgCopy.Attachments[i]
|
||||
if att.Content != "" {
|
||||
// Decode base64 content to binary
|
||||
data, err := base64.StdEncoding.DecodeString(att.Content)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to decode attachment %s: %w", att.Name, err)
|
||||
}
|
||||
attachmentData = append(attachmentData, data...)
|
||||
att.Content = "" // Clear content, will be restored on decrypt
|
||||
}
|
||||
}
|
||||
|
||||
// Marshal message (without attachment content)
|
||||
payload, err := json.Marshal(&msgCopy)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
return payload, attachmentData, nil
|
||||
}
|
||||
|
||||
// restoreV3Attachments restores attachment content from decrypted binary data
|
||||
func restoreV3Attachments(msg *Message, data []byte) error {
|
||||
offset := 0
|
||||
for i := range msg.Attachments {
|
||||
att := &msg.Attachments[i]
|
||||
if att.Size > 0 {
|
||||
if offset+att.Size > len(data) {
|
||||
return fmt.Errorf("attachment data truncated for %s", att.Name)
|
||||
}
|
||||
att.Content = base64.StdEncoding.EncodeToString(data[offset : offset+att.Size])
|
||||
offset += att.Size
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// V3 Chunked Streaming Helpers
|
||||
// =============================================================================
|
||||
//
|
||||
// When StreamParams.ChunkSize > 0, v3 format uses independently decryptable
|
||||
// chunks, enabling:
|
||||
// - Decrypt-while-downloading: Play media as it arrives
|
||||
// - HTTP Range requests: Fetch specific chunks by byte range
|
||||
// - Seekable playback: Jump to any position without decrypting everything
|
||||
//
|
||||
// Each chunk is encrypted with the same CEK but has its own nonce,
|
||||
// making it independently decryptable.
|
||||
|
||||
// DecryptV3Chunk decrypts a single chunk by index.
|
||||
// This enables streaming playback and seeking without decrypting the entire file.
|
||||
//
|
||||
// Usage for streaming:
|
||||
//
|
||||
// header, _ := GetV3Header(data)
|
||||
// cek, _ := UnwrapCEKFromHeader(header, params)
|
||||
// payload, _ := GetV3Payload(data)
|
||||
// for i := 0; i < header.Chunked.TotalChunks; i++ {
|
||||
// chunk, _ := DecryptV3Chunk(payload, cek, i, header.Chunked)
|
||||
// player.Write(chunk)
|
||||
// }
|
||||
func DecryptV3Chunk(payload []byte, cek []byte, chunkIndex int, chunked *ChunkedInfo) ([]byte, error) {
|
||||
if chunked == nil {
|
||||
return nil, fmt.Errorf("chunked info is nil")
|
||||
}
|
||||
if chunkIndex < 0 || chunkIndex >= len(chunked.Index) {
|
||||
return nil, fmt.Errorf("chunk index %d out of range [0, %d)", chunkIndex, len(chunked.Index))
|
||||
}
|
||||
|
||||
ci := chunked.Index[chunkIndex]
|
||||
if ci.Offset+ci.Size > len(payload) {
|
||||
return nil, fmt.Errorf("chunk %d data out of bounds", chunkIndex)
|
||||
}
|
||||
|
||||
// Create sigil and decrypt
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(cek)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
chunkData := payload[ci.Offset : ci.Offset+ci.Size]
|
||||
return sigil.Out(chunkData)
|
||||
}
|
||||
|
||||
// GetV3Header extracts the header from a v3 file without decrypting.
|
||||
// Useful for getting chunk index for Range requests.
|
||||
func GetV3Header(data []byte) (*Header, error) {
|
||||
t, err := trix.Decode(data, Magic, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode container: %w", err)
|
||||
}
|
||||
|
||||
headerJSON, err := json.Marshal(t.Header)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal header: %w", err)
|
||||
}
|
||||
|
||||
var header Header
|
||||
if err := json.Unmarshal(headerJSON, &header); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse header: %w", err)
|
||||
}
|
||||
|
||||
if header.Format != FormatV3 {
|
||||
return nil, fmt.Errorf("not a v3 format: %s", header.Format)
|
||||
}
|
||||
|
||||
return &header, nil
|
||||
}
|
||||
|
||||
// UnwrapCEKFromHeader unwraps the CEK from a v3 header using stream params.
|
||||
// Returns the CEK for use with DecryptV3Chunk.
|
||||
func UnwrapCEKFromHeader(header *Header, params *StreamParams) ([]byte, error) {
|
||||
if params == nil || params.License == "" {
|
||||
return nil, ErrLicenseRequired
|
||||
}
|
||||
|
||||
cadence := header.Cadence
|
||||
if cadence == "" && params.Cadence != "" {
|
||||
cadence = params.Cadence
|
||||
}
|
||||
if cadence == "" {
|
||||
cadence = CadenceDaily
|
||||
}
|
||||
|
||||
return tryUnwrapCEK(header.WrappedKeys, params, cadence)
|
||||
}
|
||||
|
||||
// GetV3Payload extracts just the payload from a v3 file.
|
||||
// Use with DecryptV3Chunk for individual chunk decryption.
|
||||
func GetV3Payload(data []byte) ([]byte, error) {
|
||||
t, err := trix.Decode(data, Magic, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode container: %w", err)
|
||||
}
|
||||
return t.Payload, nil
|
||||
}
|
||||
|
||||
// GetV3HeaderFromPrefix parses the v3 header from just the file prefix.
|
||||
// This enables streaming: parse header as soon as first few KB arrive.
|
||||
// Returns header and payload offset (where encrypted chunks start).
|
||||
//
|
||||
// File format:
|
||||
// - Bytes 0-3: Magic "SMSG"
|
||||
// - Bytes 4-5: Version (2-byte little endian)
|
||||
// - Bytes 6-8: Header length (3-byte big endian)
|
||||
// - Bytes 9+: Header JSON
|
||||
// - Payload starts at offset 9 + headerLen
|
||||
func GetV3HeaderFromPrefix(data []byte) (*Header, int, error) {
|
||||
// Need at least magic + version + header length indicator
|
||||
if len(data) < 9 {
|
||||
return nil, 0, fmt.Errorf("need at least 9 bytes, got %d", len(data))
|
||||
}
|
||||
|
||||
// Check magic
|
||||
if string(data[0:4]) != Magic {
|
||||
return nil, 0, ErrInvalidMagic
|
||||
}
|
||||
|
||||
// Parse header length (3 bytes big endian at offset 6-8)
|
||||
headerLen := int(data[6])<<16 | int(data[7])<<8 | int(data[8])
|
||||
if headerLen <= 0 || headerLen > 16*1024*1024 {
|
||||
return nil, 0, fmt.Errorf("invalid header length: %d", headerLen)
|
||||
}
|
||||
|
||||
// Calculate payload offset
|
||||
payloadOffset := 9 + headerLen
|
||||
|
||||
// Check if we have enough data for the header
|
||||
if len(data) < payloadOffset {
|
||||
return nil, 0, fmt.Errorf("need %d bytes for header, got %d", payloadOffset, len(data))
|
||||
}
|
||||
|
||||
// Parse header JSON
|
||||
headerJSON := data[9:payloadOffset]
|
||||
var header Header
|
||||
if err := json.Unmarshal(headerJSON, &header); err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to parse header JSON: %w", err)
|
||||
}
|
||||
|
||||
if header.Format != FormatV3 {
|
||||
return nil, 0, fmt.Errorf("not a v3 format: %s", header.Format)
|
||||
}
|
||||
|
||||
return &header, payloadOffset, nil
|
||||
}
|
||||
677
pkg/smsg/stream_test.go
Normal file
677
pkg/smsg/stream_test.go
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
package smsg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDeriveStreamKey(t *testing.T) {
|
||||
// Test that same inputs produce same key
|
||||
key1 := DeriveStreamKey("2026-01-12", "license123", "fingerprint456")
|
||||
key2 := DeriveStreamKey("2026-01-12", "license123", "fingerprint456")
|
||||
|
||||
if len(key1) != 32 {
|
||||
t.Errorf("Key length = %d, want 32", len(key1))
|
||||
}
|
||||
|
||||
if string(key1) != string(key2) {
|
||||
t.Error("Same inputs should produce same key")
|
||||
}
|
||||
|
||||
// Test that different dates produce different keys
|
||||
key3 := DeriveStreamKey("2026-01-13", "license123", "fingerprint456")
|
||||
if string(key1) == string(key3) {
|
||||
t.Error("Different dates should produce different keys")
|
||||
}
|
||||
|
||||
// Test that different licenses produce different keys
|
||||
key4 := DeriveStreamKey("2026-01-12", "license789", "fingerprint456")
|
||||
if string(key1) == string(key4) {
|
||||
t.Error("Different licenses should produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetRollingDates(t *testing.T) {
|
||||
today, tomorrow := GetRollingDates()
|
||||
|
||||
// Parse dates to verify format
|
||||
todayTime, err := time.Parse("2006-01-02", today)
|
||||
if err != nil {
|
||||
t.Fatalf("Invalid today format: %v", err)
|
||||
}
|
||||
|
||||
tomorrowTime, err := time.Parse("2006-01-02", tomorrow)
|
||||
if err != nil {
|
||||
t.Fatalf("Invalid tomorrow format: %v", err)
|
||||
}
|
||||
|
||||
// Tomorrow should be 1 day after today
|
||||
diff := tomorrowTime.Sub(todayTime)
|
||||
if diff != 24*time.Hour {
|
||||
t.Errorf("Tomorrow should be 24h after today, got %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrapUnwrapCEK(t *testing.T) {
|
||||
// Generate a test CEK
|
||||
cek, err := GenerateCEK()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateCEK failed: %v", err)
|
||||
}
|
||||
|
||||
// Generate a stream key
|
||||
streamKey := DeriveStreamKey("2026-01-12", "test-license", "test-fp")
|
||||
|
||||
// Wrap CEK
|
||||
wrapped, err := WrapCEK(cek, streamKey)
|
||||
if err != nil {
|
||||
t.Fatalf("WrapCEK failed: %v", err)
|
||||
}
|
||||
|
||||
// Unwrap CEK
|
||||
unwrapped, err := UnwrapCEK(wrapped, streamKey)
|
||||
if err != nil {
|
||||
t.Fatalf("UnwrapCEK failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify CEK matches
|
||||
if string(cek) != string(unwrapped) {
|
||||
t.Error("Unwrapped CEK doesn't match original")
|
||||
}
|
||||
|
||||
// Wrong key should fail
|
||||
wrongKey := DeriveStreamKey("2026-01-12", "wrong-license", "test-fp")
|
||||
_, err = UnwrapCEK(wrapped, wrongKey)
|
||||
if err == nil {
|
||||
t.Error("UnwrapCEK with wrong key should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptV3RoundTrip(t *testing.T) {
|
||||
msg := NewMessage("Hello, this is a v3 streaming message!").
|
||||
WithSubject("V3 Test").
|
||||
WithFrom("stream@dapp.fm")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "test-license-123",
|
||||
Fingerprint: "device-fp-456",
|
||||
}
|
||||
|
||||
manifest := NewManifest("Test Track")
|
||||
manifest.Artist = "Test Artist"
|
||||
manifest.LicenseType = "stream"
|
||||
|
||||
// Encrypt
|
||||
encrypted, err := EncryptV3(msg, params, manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 failed: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt with same params
|
||||
decrypted, header, err := DecryptV3(encrypted, params)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptV3 failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify message content
|
||||
if decrypted.Body != msg.Body {
|
||||
t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body)
|
||||
}
|
||||
if decrypted.Subject != msg.Subject {
|
||||
t.Errorf("Subject = %q, want %q", decrypted.Subject, msg.Subject)
|
||||
}
|
||||
|
||||
// Verify header
|
||||
if header.Format != FormatV3 {
|
||||
t.Errorf("Format = %q, want %q", header.Format, FormatV3)
|
||||
}
|
||||
if header.KeyMethod != KeyMethodLTHNRolling {
|
||||
t.Errorf("KeyMethod = %q, want %q", header.KeyMethod, KeyMethodLTHNRolling)
|
||||
}
|
||||
if len(header.WrappedKeys) != 2 {
|
||||
t.Errorf("WrappedKeys count = %d, want 2", len(header.WrappedKeys))
|
||||
}
|
||||
|
||||
// Verify manifest
|
||||
if header.Manifest == nil {
|
||||
t.Fatal("Manifest is nil")
|
||||
}
|
||||
if header.Manifest.Title != "Test Track" {
|
||||
t.Errorf("Manifest.Title = %q, want %q", header.Manifest.Title, "Test Track")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptV3WrongLicense(t *testing.T) {
|
||||
msg := NewMessage("Secret content")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "correct-license",
|
||||
Fingerprint: "device-fp",
|
||||
}
|
||||
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 failed: %v", err)
|
||||
}
|
||||
|
||||
// Try to decrypt with wrong license
|
||||
wrongParams := &StreamParams{
|
||||
License: "wrong-license",
|
||||
Fingerprint: "device-fp",
|
||||
}
|
||||
|
||||
_, _, err = DecryptV3(encrypted, wrongParams)
|
||||
if err == nil {
|
||||
t.Error("DecryptV3 with wrong license should fail")
|
||||
}
|
||||
if err != ErrNoValidKey {
|
||||
t.Errorf("Error = %v, want ErrNoValidKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptV3WrongFingerprint(t *testing.T) {
|
||||
msg := NewMessage("Secret content")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "test-license",
|
||||
Fingerprint: "correct-fingerprint",
|
||||
}
|
||||
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 failed: %v", err)
|
||||
}
|
||||
|
||||
// Try to decrypt with wrong fingerprint
|
||||
wrongParams := &StreamParams{
|
||||
License: "test-license",
|
||||
Fingerprint: "wrong-fingerprint",
|
||||
}
|
||||
|
||||
_, _, err = DecryptV3(encrypted, wrongParams)
|
||||
if err == nil {
|
||||
t.Error("DecryptV3 with wrong fingerprint should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptV3WithAttachment(t *testing.T) {
|
||||
msg := NewMessage("Message with attachment")
|
||||
msg.AddBinaryAttachment("test.mp3", []byte("fake audio data here"), "audio/mpeg")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "test-license",
|
||||
Fingerprint: "test-fp",
|
||||
}
|
||||
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, _, err := DecryptV3(encrypted, params)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptV3 failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify attachment
|
||||
if len(decrypted.Attachments) != 1 {
|
||||
t.Fatalf("Attachment count = %d, want 1", len(decrypted.Attachments))
|
||||
}
|
||||
|
||||
att := decrypted.GetAttachment("test.mp3")
|
||||
if att == nil {
|
||||
t.Fatal("Attachment not found")
|
||||
}
|
||||
if att.MimeType != "audio/mpeg" {
|
||||
t.Errorf("MimeType = %q, want %q", att.MimeType, "audio/mpeg")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptV3RequiresLicense(t *testing.T) {
|
||||
msg := NewMessage("Test")
|
||||
|
||||
// Nil params
|
||||
_, err := EncryptV3(msg, nil, nil)
|
||||
if err != ErrLicenseRequired {
|
||||
t.Errorf("Error = %v, want ErrLicenseRequired", err)
|
||||
}
|
||||
|
||||
// Empty license
|
||||
_, err = EncryptV3(msg, &StreamParams{}, nil)
|
||||
if err != ErrLicenseRequired {
|
||||
t.Errorf("Error = %v, want ErrLicenseRequired", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCadencePeriods(t *testing.T) {
|
||||
// Test at a known time: 2026-01-12 15:30:00 UTC
|
||||
testTime := time.Date(2026, 1, 12, 15, 30, 0, 0, time.UTC)
|
||||
|
||||
tests := []struct {
|
||||
cadence Cadence
|
||||
expectedCurrent string
|
||||
expectedNext string
|
||||
}{
|
||||
{CadenceDaily, "2026-01-12", "2026-01-13"},
|
||||
{CadenceHalfDay, "2026-01-12-PM", "2026-01-13-AM"},
|
||||
{CadenceQuarter, "2026-01-12-12", "2026-01-12-18"},
|
||||
{CadenceHourly, "2026-01-12-15", "2026-01-12-16"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(string(tc.cadence), func(t *testing.T) {
|
||||
current, next := GetRollingPeriods(tc.cadence, testTime)
|
||||
if current != tc.expectedCurrent {
|
||||
t.Errorf("current = %q, want %q", current, tc.expectedCurrent)
|
||||
}
|
||||
if next != tc.expectedNext {
|
||||
t.Errorf("next = %q, want %q", next, tc.expectedNext)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCadenceHalfDayAM(t *testing.T) {
|
||||
// Test in the morning
|
||||
testTime := time.Date(2026, 1, 12, 9, 0, 0, 0, time.UTC)
|
||||
current, next := GetRollingPeriods(CadenceHalfDay, testTime)
|
||||
|
||||
if current != "2026-01-12-AM" {
|
||||
t.Errorf("current = %q, want %q", current, "2026-01-12-AM")
|
||||
}
|
||||
if next != "2026-01-12-PM" {
|
||||
t.Errorf("next = %q, want %q", next, "2026-01-12-PM")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCadenceQuarterBoundary(t *testing.T) {
|
||||
// Test at 23:00 - should wrap to next day
|
||||
testTime := time.Date(2026, 1, 12, 23, 0, 0, 0, time.UTC)
|
||||
current, next := GetRollingPeriods(CadenceQuarter, testTime)
|
||||
|
||||
if current != "2026-01-12-18" {
|
||||
t.Errorf("current = %q, want %q", current, "2026-01-12-18")
|
||||
}
|
||||
if next != "2026-01-13-00" {
|
||||
t.Errorf("next = %q, want %q", next, "2026-01-13-00")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptV3WithCadence(t *testing.T) {
|
||||
cadences := []Cadence{CadenceDaily, CadenceHalfDay, CadenceQuarter, CadenceHourly}
|
||||
|
||||
for _, cadence := range cadences {
|
||||
t.Run(string(cadence), func(t *testing.T) {
|
||||
msg := NewMessage("Testing " + string(cadence) + " cadence")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "cadence-test-license",
|
||||
Fingerprint: "cadence-test-fp",
|
||||
Cadence: cadence,
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 failed: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt with same params
|
||||
decrypted, header, err := DecryptV3(encrypted, params)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptV3 failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.Body != msg.Body {
|
||||
t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body)
|
||||
}
|
||||
|
||||
// Verify cadence in header
|
||||
if header.Cadence != cadence {
|
||||
t.Errorf("Cadence = %q, want %q", header.Cadence, cadence)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollingKeyWindow(t *testing.T) {
|
||||
// This test verifies that both today's and tomorrow's keys work
|
||||
msg := NewMessage("Rolling window test")
|
||||
|
||||
// Create params
|
||||
params := &StreamParams{
|
||||
License: "rolling-test-license",
|
||||
Fingerprint: "rolling-test-fp",
|
||||
}
|
||||
|
||||
// Encrypt with current time
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 failed: %v", err)
|
||||
}
|
||||
|
||||
// Should decrypt successfully (within rolling window)
|
||||
decrypted, header, err := DecryptV3(encrypted, params)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptV3 failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.Body != msg.Body {
|
||||
t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body)
|
||||
}
|
||||
|
||||
// Verify we have both today and tomorrow keys
|
||||
today, tomorrow := GetRollingDates()
|
||||
hasToday := false
|
||||
hasTomorrow := false
|
||||
for _, wk := range header.WrappedKeys {
|
||||
if wk.Date == today {
|
||||
hasToday = true
|
||||
}
|
||||
if wk.Date == tomorrow {
|
||||
hasTomorrow = true
|
||||
}
|
||||
}
|
||||
if !hasToday {
|
||||
t.Error("Missing today's wrapped key")
|
||||
}
|
||||
if !hasTomorrow {
|
||||
t.Error("Missing tomorrow's wrapped key")
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// V3 Chunked Streaming Tests
|
||||
// =============================================================================
|
||||
|
||||
func TestEncryptDecryptV3ChunkedBasic(t *testing.T) {
|
||||
msg := NewMessage("This is a chunked streaming test message")
|
||||
msg.WithSubject("Chunked Test")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "chunk-license",
|
||||
Fingerprint: "chunk-fp",
|
||||
ChunkSize: 64, // Small chunks for testing
|
||||
}
|
||||
|
||||
manifest := NewManifest("Chunked Track")
|
||||
manifest.Artist = "Test Artist"
|
||||
|
||||
// Encrypt with chunking
|
||||
encrypted, err := EncryptV3(msg, params, manifest)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 (chunked) failed: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt - automatically handles chunked format
|
||||
decrypted, header, err := DecryptV3(encrypted, params)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptV3 (chunked) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify content
|
||||
if decrypted.Body != msg.Body {
|
||||
t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body)
|
||||
}
|
||||
if decrypted.Subject != msg.Subject {
|
||||
t.Errorf("Subject = %q, want %q", decrypted.Subject, msg.Subject)
|
||||
}
|
||||
|
||||
// Verify header
|
||||
if header.Format != FormatV3 {
|
||||
t.Errorf("Format = %q, want %q", header.Format, FormatV3)
|
||||
}
|
||||
if header.Chunked == nil {
|
||||
t.Fatal("Chunked info is nil")
|
||||
}
|
||||
if header.Chunked.ChunkSize != 64 {
|
||||
t.Errorf("ChunkSize = %d, want 64", header.Chunked.ChunkSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV3ChunkedWithAttachment(t *testing.T) {
|
||||
// Create a message with attachment larger than chunk size
|
||||
attachmentData := make([]byte, 256)
|
||||
for i := range attachmentData {
|
||||
attachmentData[i] = byte(i)
|
||||
}
|
||||
|
||||
msg := NewMessage("Message with large attachment")
|
||||
msg.AddBinaryAttachment("test.bin", attachmentData, "application/octet-stream")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "attach-license",
|
||||
Fingerprint: "attach-fp",
|
||||
ChunkSize: 64, // Force multiple chunks
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 (chunked) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify we have multiple chunks
|
||||
header, err := GetV3Header(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("GetV3Header failed: %v", err)
|
||||
}
|
||||
|
||||
if header.Chunked.TotalChunks <= 1 {
|
||||
t.Errorf("TotalChunks = %d, want > 1", header.Chunked.TotalChunks)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, _, err := DecryptV3(encrypted, params)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptV3 (chunked) failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify attachment
|
||||
if len(decrypted.Attachments) != 1 {
|
||||
t.Fatalf("Attachment count = %d, want 1", len(decrypted.Attachments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestV3ChunkedIndividualChunks(t *testing.T) {
|
||||
// Create content that spans multiple chunks
|
||||
largeContent := make([]byte, 200)
|
||||
for i := range largeContent {
|
||||
largeContent[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
msg := NewMessage("Chunk-by-chunk test")
|
||||
msg.AddBinaryAttachment("data.bin", largeContent, "application/octet-stream")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "individual-license",
|
||||
Fingerprint: "individual-fp",
|
||||
ChunkSize: 50, // Force ~5 chunks
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 (chunked) failed: %v", err)
|
||||
}
|
||||
|
||||
// Get header and payload
|
||||
header, err := GetV3Header(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("GetV3Header failed: %v", err)
|
||||
}
|
||||
|
||||
payload, err := GetV3Payload(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("GetV3Payload failed: %v", err)
|
||||
}
|
||||
|
||||
// Unwrap CEK
|
||||
cek, err := UnwrapCEKFromHeader(header, params)
|
||||
if err != nil {
|
||||
t.Fatalf("UnwrapCEKFromHeader failed: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt each chunk individually
|
||||
var allDecrypted []byte
|
||||
for i := 0; i < header.Chunked.TotalChunks; i++ {
|
||||
chunk, err := DecryptV3Chunk(payload, cek, i, header.Chunked)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptV3Chunk(%d) failed: %v", i, err)
|
||||
}
|
||||
allDecrypted = append(allDecrypted, chunk...)
|
||||
}
|
||||
|
||||
// Verify total size matches
|
||||
if int64(len(allDecrypted)) != header.Chunked.TotalSize {
|
||||
t.Errorf("Decrypted size = %d, want %d", len(allDecrypted), header.Chunked.TotalSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV3ChunkedWrongLicense(t *testing.T) {
|
||||
msg := NewMessage("Secret chunked content")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "correct-chunked-license",
|
||||
Fingerprint: "device-fp",
|
||||
ChunkSize: 64,
|
||||
}
|
||||
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 (chunked) failed: %v", err)
|
||||
}
|
||||
|
||||
// Try to decrypt with wrong license
|
||||
wrongParams := &StreamParams{
|
||||
License: "wrong-chunked-license",
|
||||
Fingerprint: "device-fp",
|
||||
}
|
||||
|
||||
_, _, err = DecryptV3(encrypted, wrongParams)
|
||||
if err == nil {
|
||||
t.Error("DecryptV3 (chunked) with wrong license should fail")
|
||||
}
|
||||
if err != ErrNoValidKey {
|
||||
t.Errorf("Error = %v, want ErrNoValidKey", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestV3ChunkedChunkIndex(t *testing.T) {
|
||||
msg := NewMessage("Index test")
|
||||
msg.AddBinaryAttachment("test.dat", make([]byte, 150), "application/octet-stream")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "index-license",
|
||||
Fingerprint: "index-fp",
|
||||
ChunkSize: 50,
|
||||
}
|
||||
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 (chunked) failed: %v", err)
|
||||
}
|
||||
|
||||
header, err := GetV3Header(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("GetV3Header failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify index structure
|
||||
if len(header.Chunked.Index) != header.Chunked.TotalChunks {
|
||||
t.Errorf("Index length = %d, want %d", len(header.Chunked.Index), header.Chunked.TotalChunks)
|
||||
}
|
||||
|
||||
// Verify offsets are sequential
|
||||
expectedOffset := 0
|
||||
for i, ci := range header.Chunked.Index {
|
||||
if ci.Offset != expectedOffset {
|
||||
t.Errorf("Chunk %d offset = %d, want %d", i, ci.Offset, expectedOffset)
|
||||
}
|
||||
expectedOffset += ci.Size
|
||||
}
|
||||
}
|
||||
|
||||
func TestV3ChunkedSeekMiddleChunk(t *testing.T) {
|
||||
// Create predictable data
|
||||
data := make([]byte, 300)
|
||||
for i := range data {
|
||||
data[i] = byte(i % 256)
|
||||
}
|
||||
|
||||
msg := NewMessage("Seek test")
|
||||
msg.AddBinaryAttachment("seek.bin", data, "application/octet-stream")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "seek-license",
|
||||
Fingerprint: "seek-fp",
|
||||
ChunkSize: 100, // 3 data chunks minimum
|
||||
}
|
||||
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 (chunked) failed: %v", err)
|
||||
}
|
||||
|
||||
header, err := GetV3Header(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("GetV3Header failed: %v", err)
|
||||
}
|
||||
|
||||
payload, err := GetV3Payload(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("GetV3Payload failed: %v", err)
|
||||
}
|
||||
|
||||
cek, err := UnwrapCEKFromHeader(header, params)
|
||||
if err != nil {
|
||||
t.Fatalf("UnwrapCEKFromHeader failed: %v", err)
|
||||
}
|
||||
|
||||
// Skip to middle chunk (simulate seeking)
|
||||
if header.Chunked.TotalChunks < 2 {
|
||||
t.Skip("Need at least 2 chunks for seek test")
|
||||
}
|
||||
|
||||
middleIdx := header.Chunked.TotalChunks / 2
|
||||
chunk, err := DecryptV3Chunk(payload, cek, middleIdx, header.Chunked)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptV3Chunk(%d) failed: %v", middleIdx, err)
|
||||
}
|
||||
|
||||
// Just verify we got something
|
||||
if len(chunk) == 0 {
|
||||
t.Error("Middle chunk is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestV3NonChunkedStillWorks(t *testing.T) {
|
||||
// Verify non-chunked v3 still works (ChunkSize = 0)
|
||||
msg := NewMessage("Non-chunked v3 test")
|
||||
msg.WithSubject("No Chunks")
|
||||
|
||||
params := &StreamParams{
|
||||
License: "non-chunk-license",
|
||||
Fingerprint: "non-chunk-fp",
|
||||
// ChunkSize = 0 (default) - no chunking
|
||||
}
|
||||
|
||||
encrypted, err := EncryptV3(msg, params, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptV3 (non-chunked) failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, header, err := DecryptV3(encrypted, params)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptV3 (non-chunked) failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.Body != msg.Body {
|
||||
t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body)
|
||||
}
|
||||
|
||||
// Non-chunked should not have Chunked info
|
||||
if header.Chunked != nil {
|
||||
t.Error("Non-chunked v3 should not have Chunked info")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,20 @@
|
|||
// SMSG (Secure Message) enables encrypted message exchange where the recipient
|
||||
// decrypts using a pre-shared password. Useful for secure support replies,
|
||||
// confidential documents, and any scenario requiring password-protected content.
|
||||
//
|
||||
// Format versions:
|
||||
// - v1: JSON with base64-encoded attachments (legacy)
|
||||
// - v2: Binary format with zstd compression (current)
|
||||
// - v3: Streaming with LTHN rolling keys (planned)
|
||||
//
|
||||
// Encryption note: Nonces are embedded in ciphertext, not transmitted separately.
|
||||
// See smsg.go header comment for details.
|
||||
package smsg
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Magic bytes for SMSG format
|
||||
|
|
@ -16,19 +26,22 @@ const Version = "1.0"
|
|||
|
||||
// Errors
|
||||
var (
|
||||
ErrInvalidMagic = errors.New("invalid SMSG magic")
|
||||
ErrInvalidPayload = errors.New("invalid SMSG payload")
|
||||
ErrDecryptionFailed = errors.New("decryption failed (wrong password?)")
|
||||
ErrPasswordRequired = errors.New("password is required")
|
||||
ErrEmptyMessage = errors.New("message cannot be empty")
|
||||
ErrInvalidMagic = errors.New("invalid SMSG magic")
|
||||
ErrInvalidPayload = errors.New("invalid SMSG payload")
|
||||
ErrDecryptionFailed = errors.New("decryption failed (wrong password?)")
|
||||
ErrPasswordRequired = errors.New("password is required")
|
||||
ErrEmptyMessage = errors.New("message cannot be empty")
|
||||
ErrStreamKeyExpired = errors.New("stream key expired (outside rolling window)")
|
||||
ErrNoValidKey = errors.New("no valid wrapped key found for current date")
|
||||
ErrLicenseRequired = errors.New("license is required for stream decryption")
|
||||
)
|
||||
|
||||
// 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
|
||||
|
|
@ -83,13 +96,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
|
||||
}
|
||||
|
|
@ -128,9 +153,264 @@ func (m *Message) GetAttachment(name string) *Attachment {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Track represents a track marker in a release (like CD chapters)
|
||||
type Track struct {
|
||||
Title string `json:"title"`
|
||||
Start float64 `json:"start"` // start time in seconds
|
||||
End float64 `json:"end,omitempty"` // end time in seconds (0 = until next track)
|
||||
Type string `json:"type,omitempty"` // intro, verse, chorus, drop, outro, etc.
|
||||
TrackNum int `json:"track_num,omitempty"` // track number for multi-track releases
|
||||
}
|
||||
|
||||
// Manifest contains public metadata visible without decryption
|
||||
// This enables content discovery, indexing, and preview
|
||||
type Manifest struct {
|
||||
// Content identification
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
|
||||
// Release info
|
||||
ReleaseType string `json:"release_type,omitempty"` // single, album, ep, mix
|
||||
Duration int `json:"duration,omitempty"` // total duration in seconds
|
||||
Format string `json:"format,omitempty"` // dapp.fm/v1, etc.
|
||||
|
||||
// License expiration (for streaming/rental models)
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"` // Unix timestamp when license expires (0 = never)
|
||||
IssuedAt int64 `json:"issued_at,omitempty"` // Unix timestamp when license was issued
|
||||
LicenseType string `json:"license_type,omitempty"` // perpetual, rental, stream, preview
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// NewManifest creates a new manifest with title
|
||||
func NewManifest(title string) *Manifest {
|
||||
return &Manifest{
|
||||
Title: title,
|
||||
Links: make(map[string]string),
|
||||
Extra: make(map[string]string),
|
||||
LicenseType: "perpetual",
|
||||
}
|
||||
}
|
||||
|
||||
// WithExpiration sets the license expiration time
|
||||
func (m *Manifest) WithExpiration(expiresAt int64) *Manifest {
|
||||
m.ExpiresAt = expiresAt
|
||||
if m.LicenseType == "perpetual" {
|
||||
m.LicenseType = "rental"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// WithRentalDuration sets expiration relative to issue time
|
||||
func (m *Manifest) WithRentalDuration(durationSeconds int64) *Manifest {
|
||||
if m.IssuedAt == 0 {
|
||||
m.IssuedAt = time.Now().Unix()
|
||||
}
|
||||
m.ExpiresAt = m.IssuedAt + durationSeconds
|
||||
m.LicenseType = "rental"
|
||||
return m
|
||||
}
|
||||
|
||||
// WithStreamingAccess sets up for streaming (short expiration, e.g., 24 hours)
|
||||
func (m *Manifest) WithStreamingAccess(hours int) *Manifest {
|
||||
m.IssuedAt = time.Now().Unix()
|
||||
m.ExpiresAt = m.IssuedAt + int64(hours*3600)
|
||||
m.LicenseType = "stream"
|
||||
return m
|
||||
}
|
||||
|
||||
// WithPreviewAccess sets up for preview (very short, e.g., 30 seconds)
|
||||
func (m *Manifest) WithPreviewAccess(seconds int) *Manifest {
|
||||
m.IssuedAt = time.Now().Unix()
|
||||
m.ExpiresAt = m.IssuedAt + int64(seconds)
|
||||
m.LicenseType = "preview"
|
||||
return m
|
||||
}
|
||||
|
||||
// IsExpired checks if the license has expired
|
||||
func (m *Manifest) IsExpired() bool {
|
||||
if m.ExpiresAt == 0 {
|
||||
return false // No expiration = perpetual
|
||||
}
|
||||
return time.Now().Unix() > m.ExpiresAt
|
||||
}
|
||||
|
||||
// TimeRemaining returns seconds until expiration (0 if perpetual, negative if expired)
|
||||
func (m *Manifest) TimeRemaining() int64 {
|
||||
if m.ExpiresAt == 0 {
|
||||
return 0 // Perpetual
|
||||
}
|
||||
return m.ExpiresAt - time.Now().Unix()
|
||||
}
|
||||
|
||||
// AddTrack adds a track marker to the manifest
|
||||
func (m *Manifest) AddTrack(title string, start float64) *Manifest {
|
||||
m.Tracks = append(m.Tracks, Track{
|
||||
Title: title,
|
||||
Start: start,
|
||||
TrackNum: len(m.Tracks) + 1,
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
// AddTrackFull adds a track with all details
|
||||
func (m *Manifest) AddTrackFull(title string, start, end float64, trackType string) *Manifest {
|
||||
m.Tracks = append(m.Tracks, Track{
|
||||
Title: title,
|
||||
Start: start,
|
||||
End: end,
|
||||
Type: trackType,
|
||||
TrackNum: len(m.Tracks) + 1,
|
||||
})
|
||||
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
|
||||
FormatV3 = "v3" // Streaming format: CEK wrapped with rolling LTHN keys, optional chunking
|
||||
)
|
||||
|
||||
// Default chunk size for v3 chunked format (1MB)
|
||||
const DefaultChunkSize = 1024 * 1024
|
||||
|
||||
// ChunkInfo describes a single chunk in v3 chunked format
|
||||
type ChunkInfo struct {
|
||||
Offset int `json:"offset"` // byte offset in payload
|
||||
Size int `json:"size"` // encrypted chunk size (includes nonce + tag)
|
||||
}
|
||||
|
||||
// ChunkedInfo contains chunking metadata for v3 streaming
|
||||
// When present, enables decrypt-while-downloading and seeking
|
||||
type ChunkedInfo struct {
|
||||
ChunkSize int `json:"chunkSize"` // size of each chunk before encryption
|
||||
TotalChunks int `json:"totalChunks"` // number of chunks
|
||||
TotalSize int64 `json:"totalSize"` // total unencrypted size
|
||||
Index []ChunkInfo `json:"index"` // chunk locations for seeking
|
||||
}
|
||||
|
||||
// Compression types
|
||||
const (
|
||||
CompressionNone = "" // No compression (default, backwards compatible)
|
||||
CompressionGzip = "gzip" // Gzip compression (stdlib, WASM compatible)
|
||||
CompressionZstd = "zstd" // Zstandard compression (faster, better ratio)
|
||||
)
|
||||
|
||||
// Key derivation methods for v3 streaming
|
||||
const (
|
||||
// KeyMethodDirect uses password directly (v1/v2 behavior)
|
||||
KeyMethodDirect = ""
|
||||
|
||||
// KeyMethodLTHNRolling uses LTHN hash with rolling date windows
|
||||
// Key = SHA256(LTHN(date:license:fingerprint))
|
||||
// Valid keys: current period and next period (rolling window)
|
||||
KeyMethodLTHNRolling = "lthn-rolling"
|
||||
)
|
||||
|
||||
// Cadence defines how often stream keys rotate
|
||||
type Cadence string
|
||||
|
||||
const (
|
||||
// CadenceDaily rotates keys every 24 hours (default)
|
||||
// Date format: "2006-01-02"
|
||||
CadenceDaily Cadence = "daily"
|
||||
|
||||
// CadenceHalfDay rotates keys every 12 hours
|
||||
// Date format: "2006-01-02-AM" or "2006-01-02-PM"
|
||||
CadenceHalfDay Cadence = "12h"
|
||||
|
||||
// CadenceQuarter rotates keys every 6 hours
|
||||
// Date format: "2006-01-02-00", "2006-01-02-06", "2006-01-02-12", "2006-01-02-18"
|
||||
CadenceQuarter Cadence = "6h"
|
||||
|
||||
// CadenceHourly rotates keys every hour
|
||||
// Date format: "2006-01-02-15" (24-hour format)
|
||||
CadenceHourly Cadence = "1h"
|
||||
)
|
||||
|
||||
// WrappedKey represents a CEK (Content Encryption Key) wrapped with a time-bound stream key.
|
||||
// The stream key is derived from LTHN(date:license:fingerprint) and is never transmitted.
|
||||
// Only the wrapped CEK (which includes its own nonce) is stored in the header.
|
||||
type WrappedKey struct {
|
||||
Date string `json:"date"` // ISO date "YYYY-MM-DD" for key derivation
|
||||
Wrapped string `json:"wrapped"` // base64([nonce][ChaCha(CEK, streamKey)])
|
||||
}
|
||||
|
||||
// Header represents the SMSG container header
|
||||
type Header struct {
|
||||
Version string `json:"version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
Hint string `json:"hint,omitempty"` // optional password hint
|
||||
Version string `json:"version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
Format string `json:"format,omitempty"` // v2 for binary, v3 for streaming, empty for v1 (base64)
|
||||
Compression string `json:"compression,omitempty"` // gzip, zstd, or empty for none
|
||||
Hint string `json:"hint,omitempty"` // optional password hint
|
||||
Manifest *Manifest `json:"manifest,omitempty"` // public metadata for discovery
|
||||
|
||||
// V3 streaming fields
|
||||
KeyMethod string `json:"keyMethod,omitempty"` // lthn-rolling for v3
|
||||
Cadence Cadence `json:"cadence,omitempty"` // key rotation frequency (daily, 12h, 6h, 1h)
|
||||
WrappedKeys []WrappedKey `json:"wrappedKeys,omitempty"` // CEK wrapped with rolling keys
|
||||
|
||||
// V3 chunked streaming (optional - enables decrypt-while-downloading)
|
||||
Chunked *ChunkedInfo `json:"chunked,omitempty"` // chunk index for seeking/range requests
|
||||
}
|
||||
|
||||
// ========== ADAPTIVE BITRATE STREAMING (ABR) ==========
|
||||
|
||||
// ABRManifest represents a multi-bitrate variant playlist for adaptive streaming.
|
||||
// Similar to HLS master playlist but with encrypted SMSG variants.
|
||||
type ABRManifest struct {
|
||||
Version string `json:"version"` // "abr-v1"
|
||||
Title string `json:"title"` // Content title
|
||||
Duration int `json:"duration"` // Total duration in seconds
|
||||
Variants []Variant `json:"variants"` // Quality variants (sorted by bandwidth, ascending)
|
||||
DefaultIdx int `json:"defaultIdx"` // Default variant index (typically 720p)
|
||||
Password string `json:"-"` // Shared password for all variants (not serialized)
|
||||
}
|
||||
|
||||
// Variant represents a single quality level in an ABR stream.
|
||||
// Each variant is a standard v3 chunked .smsg file.
|
||||
type Variant struct {
|
||||
Name string `json:"name"` // Human-readable name: "1080p", "720p", etc.
|
||||
Bandwidth int `json:"bandwidth"` // Required bandwidth in bits per second
|
||||
Width int `json:"width"` // Video width in pixels
|
||||
Height int `json:"height"` // Video height in pixels
|
||||
Codecs string `json:"codecs"` // Codec string: "avc1.640028,mp4a.40.2"
|
||||
URL string `json:"url"` // Relative path to .smsg file
|
||||
ChunkCount int `json:"chunkCount"` // Number of chunks (for progress calculation)
|
||||
FileSize int64 `json:"fileSize"` // File size in bytes
|
||||
}
|
||||
|
||||
// Standard ABR quality presets
|
||||
var ABRPresets = []struct {
|
||||
Name string
|
||||
Width int
|
||||
Height int
|
||||
Bitrate string // For ffmpeg
|
||||
BPS int // Bits per second
|
||||
}{
|
||||
{"1080p", 1920, 1080, "5M", 5000000},
|
||||
{"720p", 1280, 720, "2.5M", 2500000},
|
||||
{"480p", 854, 480, "1M", 1000000},
|
||||
{"360p", 640, 360, "500K", 500000},
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
40
rfc/README.md
Normal file
40
rfc/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Borg RFC Specifications
|
||||
|
||||
This directory contains technical specifications (RFCs) for the Borg project.
|
||||
|
||||
## Index
|
||||
|
||||
| RFC | Title | Status | Description |
|
||||
|-----|-------|--------|-------------|
|
||||
| [001](RFC-001-OSS-DRM.md) | Open Source DRM | Proposed | Core DRM system for independent artists |
|
||||
| [002](RFC-002-SMSG-FORMAT.md) | SMSG Container Format | Draft | Encrypted container format (v1/v2/v3) |
|
||||
| [003](RFC-003-DATANODE.md) | DataNode | Draft | In-memory filesystem abstraction |
|
||||
| [004](RFC-004-TIM.md) | Terminal Isolation Matrix | Draft | OCI-compatible container bundle |
|
||||
| [005](RFC-005-STIM.md) | Encrypted TIM | Draft | ChaCha20-Poly1305 encrypted containers |
|
||||
| [006](RFC-006-TRIX.md) | TRIX PGP Format | Draft | PGP encryption for archives and accounts |
|
||||
| [007](RFC-007-LTHN.md) | LTHN Key Derivation | Draft | Rainbow-table resistant rolling keys |
|
||||
| [008](RFC-008-BORGFILE.md) | Borgfile | Draft | Container compilation syntax |
|
||||
| [009](RFC-009-STMF.md) | Secure To-Me Form | Draft | Asymmetric form encryption |
|
||||
| [010](RFC-010-WASM-API.md) | WASM Decryption API | Draft | Browser decryption interface |
|
||||
|
||||
## Status Definitions
|
||||
|
||||
| Status | Meaning |
|
||||
|--------|---------|
|
||||
| **Draft** | Initial specification, subject to change |
|
||||
| **Proposed** | Ready for review, implementation may begin |
|
||||
| **Accepted** | Approved, implementation complete |
|
||||
| **Deprecated** | Superseded by newer specification |
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Create a new RFC with the next available number
|
||||
2. Use the template format (see existing RFCs)
|
||||
3. Start with "Draft" status
|
||||
4. Update this README index
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [CLAUDE.md](../CLAUDE.md) - Developer quick reference
|
||||
- [docs/](../docs/) - User documentation
|
||||
- [examples/formats/](../examples/formats/) - Format examples
|
||||
873
rfc/RFC-001-OSS-DRM.md
Normal file
873
rfc/RFC-001-OSS-DRM.md
Normal file
|
|
@ -0,0 +1,873 @@
|
|||
# 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-13 | Proposed | **Adaptive Bitrate (ABR)**: HLS-style multi-quality streaming with encrypted variants. New Section 3.7. All Future Work items complete. |
|
||||
| 2026-01-12 | Proposed | **Chunked streaming**: v3 now supports optional ChunkSize for independently decryptable chunks - enables seek, HTTP Range, and decrypt-while-downloading. |
|
||||
| 2026-01-12 | Proposed | **v3 Streaming**: LTHN rolling keys with configurable cadence (daily/12h/6h/1h). CEK wrapping for zero-trust streaming. WASM v1.3.0 with decryptV3(). |
|
||||
| 2026-01-10 | Proposed | Technical review passed. Fixed section numbering (7.x, 8.x, 9.x, 11.x). Updated WASM size to 5.9MB. Implementation verified complete for stated scope. |
|
||||
|
||||
---
|
||||
|
||||
## 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 | Use Case |
|
||||
|--------|------------------|------|-------|----------|
|
||||
| **v1** | JSON with base64-encoded attachments | +33% overhead | Baseline | Legacy |
|
||||
| **v2** | Binary header + raw attachments + zstd | ~Original size | 3-10x faster | Download-to-own |
|
||||
| **v3** | CEK + wrapped keys + rolling LTHN | ~Original size | 3-10x faster | **Streaming** |
|
||||
| **v3+chunked** | v3 with independently decryptable chunks | ~Original size | Seekable | **Chunked streaming** |
|
||||
|
||||
v2 is recommended for download-to-own (perpetual license). v3 is recommended for streaming (time-limited access). v3 with chunking is recommended for large files requiring seek capability or decrypt-while-downloading.
|
||||
|
||||
### 3.3 Key Derivation (v1/v2)
|
||||
|
||||
```
|
||||
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 Streaming Key Derivation (v3)
|
||||
|
||||
v3 format uses **LTHN rolling keys** for zero-trust streaming. The platform controls key refresh cadence.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ v3 STREAMING KEY FLOW │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ SERVER (encryption time): │
|
||||
│ ───────────────────────── │
|
||||
│ 1. Generate random CEK (Content Encryption Key) │
|
||||
│ 2. Encrypt content with CEK (one-time) │
|
||||
│ 3. For current period AND next period: │
|
||||
│ streamKey = SHA256(LTHN(period:license:fingerprint)) │
|
||||
│ wrappedKey = ChaCha(CEK, streamKey) │
|
||||
│ 4. Store wrapped keys in header (CEK never transmitted) │
|
||||
│ │
|
||||
│ CLIENT (decryption time): │
|
||||
│ ──────────────────────── │
|
||||
│ 1. Derive streamKey = SHA256(LTHN(period:license:fingerprint)) │
|
||||
│ 2. Try to unwrap CEK from current period key │
|
||||
│ 3. If fails, try next period key │
|
||||
│ 4. Decrypt content with unwrapped CEK │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### LTHN Hash Function
|
||||
|
||||
LTHN is rainbow-table resistant because the salt is derived from the input itself:
|
||||
|
||||
```
|
||||
LTHN(input) = SHA256(input + reverse_leet(input))
|
||||
|
||||
where reverse_leet swaps: o↔0, l↔1, e↔3, a↔4, s↔z, t↔7
|
||||
|
||||
Example:
|
||||
LTHN("2026-01-12:license:fp")
|
||||
= SHA256("2026-01-12:license:fp" + "pf:3zn3ci1:21-10-6202")
|
||||
```
|
||||
|
||||
You cannot compute the hash without knowing the original input.
|
||||
|
||||
#### Cadence Options
|
||||
|
||||
The platform chooses the key refresh rate. Faster cadence = tighter access control.
|
||||
|
||||
| Cadence | Period Format | Rolling Window | Use Case |
|
||||
|---------|---------------|----------------|----------|
|
||||
| `daily` | `2026-01-12` | 24-48 hours | Standard streaming |
|
||||
| `12h` | `2026-01-12-AM/PM` | 12-24 hours | Premium content |
|
||||
| `6h` | `2026-01-12-00/06/12/18` | 6-12 hours | High-value content |
|
||||
| `1h` | `2026-01-12-15` | 1-2 hours | Live events |
|
||||
|
||||
The rolling window ensures smooth key transitions. At any time, both the current period key AND the next period key are valid.
|
||||
|
||||
#### Zero-Trust Properties
|
||||
|
||||
- **Server never stores keys** - Derived on-demand from LTHN
|
||||
- **Keys auto-expire** - No revocation mechanism needed
|
||||
- **Sharing keys is pointless** - They expire within the cadence window
|
||||
- **Fingerprint binds to device** - Different device = different key
|
||||
- **License ties to user** - Different user = different key
|
||||
|
||||
### 3.5 Chunked Streaming (v3 with ChunkSize)
|
||||
|
||||
When `StreamParams.ChunkSize > 0`, v3 format splits content into independently decryptable chunks, enabling:
|
||||
|
||||
- **Decrypt-while-downloading** - Play media as chunks arrive
|
||||
- **HTTP Range requests** - Fetch specific chunks by byte offset
|
||||
- **Seekable playback** - Jump to any position without decrypting previous chunks
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ V3 CHUNKED FORMAT │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Header (cleartext): │
|
||||
│ format: "v3" │
|
||||
│ chunked: { │
|
||||
│ chunkSize: 1048576, // 1MB default │
|
||||
│ totalChunks: N, │
|
||||
│ totalSize: X, // unencrypted total │
|
||||
│ index: [ // for HTTP Range / seeking │
|
||||
│ { offset: 0, size: Y }, │
|
||||
│ { offset: Y, size: Z }, │
|
||||
│ ... │
|
||||
│ ] │
|
||||
│ } │
|
||||
│ wrappedKeys: [...] // same as non-chunked v3 │
|
||||
│ │
|
||||
│ Payload: │
|
||||
│ [chunk 0: nonce + encrypted + tag] │
|
||||
│ [chunk 1: nonce + encrypted + tag] │
|
||||
│ ... │
|
||||
│ [chunk N: nonce + encrypted + tag] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Key insight**: Each chunk is encrypted with the same CEK but gets its own random nonce, making chunks independently decryptable. The chunk index in the header enables:
|
||||
|
||||
1. **Seeking**: Calculate which chunk contains byte offset X, fetch just that chunk
|
||||
2. **Range requests**: Use HTTP Range headers to fetch specific encrypted chunks
|
||||
3. **Streaming**: Decrypt chunk 0 for metadata, then stream chunks 1-N as they arrive
|
||||
|
||||
**Usage example**:
|
||||
```go
|
||||
params := &StreamParams{
|
||||
License: "user-license",
|
||||
Fingerprint: "device-fp",
|
||||
ChunkSize: 1024 * 1024, // 1MB chunks
|
||||
}
|
||||
|
||||
// Encrypt with chunking
|
||||
encrypted, _ := EncryptV3(msg, params, manifest)
|
||||
|
||||
// For streaming playback:
|
||||
header, _ := GetV3Header(encrypted)
|
||||
cek, _ := UnwrapCEKFromHeader(header, params)
|
||||
payload, _ := GetV3Payload(encrypted)
|
||||
|
||||
for i := 0; i < header.Chunked.TotalChunks; i++ {
|
||||
chunk, _ := DecryptV3Chunk(payload, cek, i, header.Chunked)
|
||||
player.Write(chunk) // Stream to audio/video player
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 Supported Content Types
|
||||
|
||||
SMSG is content-agnostic. Any file can be an attachment:
|
||||
|
||||
| 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).
|
||||
|
||||
### 3.7 Adaptive Bitrate Streaming (ABR)
|
||||
|
||||
For large video content, ABR enables automatic quality switching based on network conditions—like HLS/DASH but with ChaCha20-Poly1305 encryption.
|
||||
|
||||
**Architecture:**
|
||||
```
|
||||
ABR Manifest (manifest.json)
|
||||
├── Title: "My Video"
|
||||
├── Version: "abr-v1"
|
||||
├── Variants: [1080p, 720p, 480p, 360p]
|
||||
└── DefaultIdx: 1 (720p)
|
||||
|
||||
track-1080p.smsg ──┐
|
||||
track-720p.smsg ──┼── Each is standard v3 chunked SMSG
|
||||
track-480p.smsg ──┤ Same password decrypts ALL variants
|
||||
track-360p.smsg ──┘
|
||||
```
|
||||
|
||||
**ABR Manifest Format:**
|
||||
```json
|
||||
{
|
||||
"version": "abr-v1",
|
||||
"title": "Content Title",
|
||||
"duration": 300,
|
||||
"variants": [
|
||||
{
|
||||
"name": "360p",
|
||||
"bandwidth": 500000,
|
||||
"width": 640,
|
||||
"height": 360,
|
||||
"codecs": "avc1.640028,mp4a.40.2",
|
||||
"url": "track-360p.smsg",
|
||||
"chunkCount": 12,
|
||||
"fileSize": 18750000
|
||||
},
|
||||
{
|
||||
"name": "720p",
|
||||
"bandwidth": 2500000,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"codecs": "avc1.640028,mp4a.40.2",
|
||||
"url": "track-720p.smsg",
|
||||
"chunkCount": 48,
|
||||
"fileSize": 93750000
|
||||
}
|
||||
],
|
||||
"defaultIdx": 1
|
||||
}
|
||||
```
|
||||
|
||||
**Bandwidth Estimation Algorithm:**
|
||||
1. Measure download time for each chunk
|
||||
2. Calculate bits per second: `(bytes × 8 × 1000) / timeMs`
|
||||
3. Average last 3 samples for stability
|
||||
4. Apply 80% safety factor to prevent buffering
|
||||
|
||||
**Variant Selection:**
|
||||
```
|
||||
Selected = highest quality where (bandwidth × 0.8) >= variant.bandwidth
|
||||
```
|
||||
|
||||
**Key Properties:**
|
||||
- **Same password for all variants**: CEK unwrapped once, works everywhere
|
||||
- **Chunk-boundary switching**: Clean cuts, no partial chunk issues
|
||||
- **Independent variants**: No cross-file dependencies
|
||||
- **CDN-friendly**: Each variant is a standard file, cacheable separately
|
||||
|
||||
**Creating ABR Content:**
|
||||
```bash
|
||||
# Use mkdemo-abr to create variant set from source video
|
||||
go run ./cmd/mkdemo-abr input.mp4 output-dir/ [password]
|
||||
|
||||
# Output:
|
||||
# output-dir/manifest.json (ABR manifest)
|
||||
# output-dir/track-1080p.smsg (v3 chunked, 5 Mbps)
|
||||
# output-dir/track-720p.smsg (v3 chunked, 2.5 Mbps)
|
||||
# output-dir/track-480p.smsg (v3 chunked, 1 Mbps)
|
||||
# output-dir/track-360p.smsg (v3 chunked, 500 Kbps)
|
||||
```
|
||||
|
||||
**Standard Presets:**
|
||||
|
||||
| Name | Resolution | Bitrate | Use Case |
|
||||
|------|------------|---------|----------|
|
||||
| 1080p | 1920×1080 | 5 Mbps | High quality, fast connections |
|
||||
| 720p | 1280×720 | 2.5 Mbps | Default, most connections |
|
||||
| 480p | 854×480 | 1 Mbps | Mobile, medium connections |
|
||||
| 360p | 640×360 | 500 Kbps | Slow connections, previews |
|
||||
|
||||
## 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, v2, v3)
|
||||
- [x] Go encryption/decryption library (pkg/smsg)
|
||||
- [x] WASM build for browser (pkg/wasm/stmf)
|
||||
- [x] Native desktop app (Wails, cmd/dapp-fm-app)
|
||||
- [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
|
||||
- [x] **v3 streaming format** - LTHN rolling keys with CEK wrapping
|
||||
- [x] **Configurable cadence** - daily/12h/6h/1h key rotation
|
||||
- [x] **WASM v1.3.0** - `BorgSMSG.decryptV3()` for streaming
|
||||
- [x] **Chunked streaming** - Independently decryptable chunks for seek/streaming
|
||||
- [x] **Adaptive Bitrate (ABR)** - HLS-style multi-quality streaming with encrypted variants
|
||||
|
||||
### 8.2 Fixed Issues
|
||||
- [x] ~~Double base64 encoding bug~~ - Fixed by using binary format
|
||||
- [x] ~~Demo file format detection~~ - v2 format auto-detected via header
|
||||
- [x] ~~Key wrapping for streaming~~ - Implemented in v3 format
|
||||
|
||||
### 8.3 Future Work
|
||||
- [x] Multi-bitrate adaptive streaming (see Section 3.7 ABR)
|
||||
- [x] Payment integration examples (see `docs/payment-integration.md`)
|
||||
- [x] IPFS distribution guide (see `docs/ipfs-distribution.md`)
|
||||
- [x] Demo page "Streaming" tab for v3 showcase
|
||||
|
||||
## 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) |
|
||||
| 1.0 | **v3** | LTHN rolling keys, CEK wrapping, chunked streaming |
|
||||
| 1.0 | **v3+ABR** | Multi-quality variants with adaptive bitrate switching |
|
||||
| 2 (future) | - | Algorithm negotiation, multiple KDFs |
|
||||
|
||||
Decoders MUST reject versions they don't understand. Use v2 for download-to-own, v3 for streaming, v3+ABR for video.
|
||||
|
||||
### 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/`
|
||||
- ABR Creator Tool: `cmd/mkdemo-abr/`
|
||||
- ABR Package: `pkg/smsg/abr.go`
|
||||
|
||||
## 13. License
|
||||
|
||||
This specification and implementation are licensed under EUPL-1.2.
|
||||
|
||||
**Viva La OpenSource** 💜
|
||||
480
rfc/RFC-002-SMSG-FORMAT.md
Normal file
480
rfc/RFC-002-SMSG-FORMAT.md
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
# RFC-002: SMSG Container Format
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: [Snider](https://github.com/Snider/)
|
||||
**Created**: 2026-01-13
|
||||
**License**: EUPL-1.2
|
||||
**Depends On**: RFC-001, RFC-007
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
SMSG (Secure Message) is an encrypted container format using ChaCha20-Poly1305 authenticated encryption. This RFC specifies the binary wire format, versioning, and encoding rules for SMSG files.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
SMSG provides:
|
||||
- Authenticated encryption (ChaCha20-Poly1305)
|
||||
- Public metadata (manifest) readable without decryption
|
||||
- Multiple format versions (v1 legacy, v2 binary, v3 streaming)
|
||||
- Optional chunking for large files and seeking
|
||||
|
||||
## 2. File Structure
|
||||
|
||||
### 2.1 Binary Layout
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ----- ------------------------------------
|
||||
0 4 Magic: "SMSG" (ASCII)
|
||||
4 2 Version: uint16 little-endian
|
||||
6 3 Header Length: 3-byte big-endian
|
||||
9 N Header JSON (plaintext)
|
||||
9+N M Encrypted Payload
|
||||
```
|
||||
|
||||
### 2.2 Magic Number
|
||||
|
||||
| Format | Value |
|
||||
|--------|-------|
|
||||
| Binary | `0x53 0x4D 0x53 0x47` |
|
||||
| ASCII | `SMSG` |
|
||||
| Base64 (first 6 chars) | `U01TRw` |
|
||||
|
||||
### 2.3 Version Field
|
||||
|
||||
Current version: `0x0001` (1)
|
||||
|
||||
Decoders MUST reject versions they don't understand.
|
||||
|
||||
### 2.4 Header Length
|
||||
|
||||
3 bytes, big-endian unsigned integer. Supports headers up to 16 MB.
|
||||
|
||||
## 3. Header Format (JSON)
|
||||
|
||||
Header is always plaintext (never encrypted), enabling metadata inspection without decryption.
|
||||
|
||||
### 3.1 Base Header
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"algorithm": "chacha20poly1305",
|
||||
"format": "v2",
|
||||
"compression": "zstd",
|
||||
"manifest": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 V3 Header Extensions
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"algorithm": "chacha20poly1305",
|
||||
"format": "v3",
|
||||
"compression": "zstd",
|
||||
"keyMethod": "lthn-rolling",
|
||||
"cadence": "daily",
|
||||
"manifest": { ... },
|
||||
"wrappedKeys": [
|
||||
{"date": "2026-01-13", "wrapped": "<base64>"},
|
||||
{"date": "2026-01-14", "wrapped": "<base64>"}
|
||||
],
|
||||
"chunked": {
|
||||
"chunkSize": 1048576,
|
||||
"totalChunks": 42,
|
||||
"totalSize": 44040192,
|
||||
"index": [
|
||||
{"offset": 0, "size": 1048600},
|
||||
{"offset": 1048600, "size": 1048600}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 Header Field Reference
|
||||
|
||||
| Field | Type | Values | Description |
|
||||
|-------|------|--------|-------------|
|
||||
| version | string | "1.0" | Format version string |
|
||||
| algorithm | string | "chacha20poly1305" | Always ChaCha20-Poly1305 |
|
||||
| format | string | "", "v2", "v3" | Payload format version |
|
||||
| compression | string | "", "gzip", "zstd" | Compression algorithm |
|
||||
| keyMethod | string | "", "lthn-rolling" | Key derivation method |
|
||||
| cadence | string | "daily", "12h", "6h", "1h" | Rolling key period (v3) |
|
||||
| manifest | object | - | Content metadata |
|
||||
| wrappedKeys | array | - | CEK wrapped for each period (v3) |
|
||||
| chunked | object | - | Chunk index for seeking (v3) |
|
||||
|
||||
## 4. Manifest Structure
|
||||
|
||||
### 4.1 Complete Manifest
|
||||
|
||||
```go
|
||||
type Manifest struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
Artist string `json:"artist,omitempty"`
|
||||
Album string `json:"album,omitempty"`
|
||||
Genre string `json:"genre,omitempty"`
|
||||
Year int `json:"year,omitempty"`
|
||||
ReleaseType string `json:"release_type,omitempty"`
|
||||
Duration int `json:"duration,omitempty"`
|
||||
Format string `json:"format,omitempty"`
|
||||
ExpiresAt int64 `json:"expires_at,omitempty"`
|
||||
IssuedAt int64 `json:"issued_at,omitempty"`
|
||||
LicenseType string `json:"license_type,omitempty"`
|
||||
Tracks []Track `json:"tracks,omitempty"`
|
||||
Links map[string]string `json:"links,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
Extra map[string]string `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Title string `json:"title"`
|
||||
Start float64 `json:"start"`
|
||||
End float64 `json:"end,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
TrackNum int `json:"track_num,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Manifest Field Reference
|
||||
|
||||
| Field | Type | Range | Description |
|
||||
|-------|------|-------|-------------|
|
||||
| title | string | 0-255 chars | Display name (required for discovery) |
|
||||
| artist | string | 0-255 chars | Creator name |
|
||||
| album | string | 0-255 chars | Album/collection name |
|
||||
| genre | string | 0-255 chars | Genre classification |
|
||||
| year | int | 0-9999 | Release year (0 = unset) |
|
||||
| releaseType | string | enum | "single", "album", "ep", "mix" |
|
||||
| duration | int | 0+ | Total duration in seconds |
|
||||
| format | string | any | Platform format string (e.g., "dapp.fm/v1") |
|
||||
| expiresAt | int64 | 0+ | Unix timestamp (0 = never expires) |
|
||||
| issuedAt | int64 | 0+ | Unix timestamp of license issue |
|
||||
| licenseType | string | enum | "perpetual", "rental", "stream", "preview" |
|
||||
| tracks | []Track | - | Track boundaries for multi-track releases |
|
||||
| links | map | - | Platform name → URL (e.g., "bandcamp" → URL) |
|
||||
| tags | []string | - | Arbitrary string tags |
|
||||
| extra | map | - | Free-form key-value extension data |
|
||||
|
||||
## 5. Format Versions
|
||||
|
||||
### 5.1 Version Comparison
|
||||
|
||||
| Aspect | v1 (Legacy) | v2 (Binary) | v3 (Streaming) |
|
||||
|--------|-------------|-------------|----------------|
|
||||
| Payload Structure | JSON only | Length-prefixed JSON + binary | Same as v2 |
|
||||
| Attachment Encoding | Base64 in JSON | Size field + raw binary | Size field + raw binary |
|
||||
| Compression | None | zstd (default) | zstd (default) |
|
||||
| Key Derivation | SHA256(password) | SHA256(password) | LTHN rolling keys |
|
||||
| Chunked Support | No | No | Yes (optional) |
|
||||
| Size Overhead | ~33% | ~25% | ~15% |
|
||||
| Use Case | Legacy | General purpose | Time-limited streaming |
|
||||
|
||||
### 5.2 V1 Format (Legacy)
|
||||
|
||||
**Payload (after decryption):**
|
||||
|
||||
```json
|
||||
{
|
||||
"body": "Message content",
|
||||
"subject": "Optional subject",
|
||||
"from": "sender@example.com",
|
||||
"to": "recipient@example.com",
|
||||
"timestamp": 1673644800,
|
||||
"attachments": [
|
||||
{
|
||||
"name": "file.bin",
|
||||
"content": "base64encodeddata==",
|
||||
"mime": "application/octet-stream",
|
||||
"size": 1024
|
||||
}
|
||||
],
|
||||
"reply_key": {
|
||||
"public_key": "base64x25519key==",
|
||||
"algorithm": "x25519"
|
||||
},
|
||||
"meta": {
|
||||
"custom_field": "custom_value"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Attachments base64-encoded inline in JSON (~33% overhead)
|
||||
- Simple but inefficient for large files
|
||||
|
||||
### 5.3 V2 Format (Binary)
|
||||
|
||||
**Payload structure (after decryption and decompression):**
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ----- ------------------------------------
|
||||
0 4 Message JSON Length (big-endian uint32)
|
||||
4 N Message JSON (attachments have size only, no content)
|
||||
4+N B1 Attachment 1 raw binary
|
||||
4+N+B1 B2 Attachment 2 raw binary
|
||||
...
|
||||
```
|
||||
|
||||
**Message JSON (within payload):**
|
||||
|
||||
```json
|
||||
{
|
||||
"body": "Message text",
|
||||
"subject": "Subject",
|
||||
"from": "sender",
|
||||
"attachments": [
|
||||
{"name": "file1.bin", "mime": "application/octet-stream", "size": 4096},
|
||||
{"name": "file2.bin", "mime": "image/png", "size": 65536}
|
||||
],
|
||||
"timestamp": 1673644800
|
||||
}
|
||||
```
|
||||
|
||||
- Attachment `content` field omitted; binary data follows JSON
|
||||
- Compressed before encryption
|
||||
- 3-10x faster than v1, ~25% smaller
|
||||
|
||||
### 5.4 V3 Format (Streaming)
|
||||
|
||||
Same payload structure as v2, but with:
|
||||
- LTHN-derived rolling keys instead of password
|
||||
- CEK (Content Encryption Key) wrapped for each time period
|
||||
- Optional chunking for seek support
|
||||
|
||||
**CEK Wrapping:**
|
||||
|
||||
```
|
||||
For each rolling period:
|
||||
streamKey = SHA256(LTHN(period:license:fingerprint))
|
||||
wrappedKey = ChaCha20-Poly1305(CEK, streamKey)
|
||||
```
|
||||
|
||||
**Rolling Periods (cadence):**
|
||||
|
||||
| Cadence | Period Format | Example |
|
||||
|---------|---------------|---------|
|
||||
| daily | YYYY-MM-DD | "2026-01-13" |
|
||||
| 12h | YYYY-MM-DD-AM/PM | "2026-01-13-AM" |
|
||||
| 6h | YYYY-MM-DD-HH | "2026-01-13-00", "2026-01-13-06" |
|
||||
| 1h | YYYY-MM-DD-HH | "2026-01-13-15" |
|
||||
|
||||
### 5.5 V3 Chunked Format
|
||||
|
||||
**Payload (independently decryptable chunks):**
|
||||
|
||||
```
|
||||
Offset Size Content
|
||||
------ ----- ----------------------------------
|
||||
0 1048600 Chunk 0: [24-byte nonce][ciphertext][16-byte tag]
|
||||
1048600 1048600 Chunk 1: [24-byte nonce][ciphertext][16-byte tag]
|
||||
...
|
||||
```
|
||||
|
||||
- Each chunk encrypted separately with same CEK, unique nonce
|
||||
- Enables seeking, HTTP Range requests
|
||||
- Chunk size typically 1MB (configurable)
|
||||
|
||||
## 6. Encryption
|
||||
|
||||
### 6.1 Algorithm
|
||||
|
||||
XChaCha20-Poly1305 (extended nonce variant)
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Key size | 32 bytes |
|
||||
| Nonce size | 24 bytes (XChaCha) |
|
||||
| Tag size | 16 bytes |
|
||||
|
||||
### 6.2 Ciphertext Structure
|
||||
|
||||
```
|
||||
[24-byte XChaCha20 nonce][encrypted data][16-byte Poly1305 tag]
|
||||
```
|
||||
|
||||
**Critical**: Nonces are embedded IN the ciphertext by the Enchantrix library, NOT transmitted separately in headers.
|
||||
|
||||
### 6.3 Key Derivation
|
||||
|
||||
**V1/V2 (Password-based):**
|
||||
|
||||
```go
|
||||
key := sha256.Sum256([]byte(password)) // 32 bytes
|
||||
```
|
||||
|
||||
**V3 (LTHN Rolling):**
|
||||
|
||||
```go
|
||||
// For each period in rolling window:
|
||||
streamKey := sha256.Sum256([]byte(
|
||||
crypt.NewService().Hash(crypt.LTHN, period + ":" + license + ":" + fingerprint)
|
||||
))
|
||||
```
|
||||
|
||||
## 7. Compression
|
||||
|
||||
| Value | Algorithm | Notes |
|
||||
|-------|-----------|-------|
|
||||
| "" (empty) | None | Raw bytes, default for v1 |
|
||||
| "gzip" | RFC 1952 | Stdlib, WASM compatible |
|
||||
| "zstd" | Zstandard | Default for v2/v3, better ratio |
|
||||
|
||||
**Order**: Compress → Encrypt (on write), Decrypt → Decompress (on read)
|
||||
|
||||
## 8. Message Structure
|
||||
|
||||
### 8.1 Go Types
|
||||
|
||||
```go
|
||||
type Message struct {
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
ReplyKey *KeyInfo `json:"reply_key,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
Name string `json:"name"`
|
||||
Mime string `json:"mime"`
|
||||
Size int `json:"size"`
|
||||
Content string `json:"content,omitempty"` // Base64, v1 only
|
||||
Data []byte `json:"-"` // Binary, v2/v3
|
||||
}
|
||||
|
||||
type KeyInfo struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 Stream Parameters (V3)
|
||||
|
||||
```go
|
||||
type StreamParams struct {
|
||||
License string `json:"license"` // User's license identifier
|
||||
Fingerprint string `json:"fingerprint"` // Device fingerprint (optional)
|
||||
Cadence string `json:"cadence"` // Rolling period: daily, 12h, 6h, 1h
|
||||
ChunkSize int `json:"chunk_size"` // Bytes per chunk (default 1MB)
|
||||
}
|
||||
```
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
### 9.1 Error Types
|
||||
|
||||
```go
|
||||
var (
|
||||
ErrInvalidMagic = errors.New("invalid SMSG magic")
|
||||
ErrInvalidPayload = errors.New("invalid SMSG payload")
|
||||
ErrDecryptionFailed = errors.New("decryption failed (wrong password?)")
|
||||
ErrPasswordRequired = errors.New("password is required")
|
||||
ErrEmptyMessage = errors.New("message cannot be empty")
|
||||
ErrStreamKeyExpired = errors.New("stream key expired (outside rolling window)")
|
||||
ErrNoValidKey = errors.New("no valid wrapped key found for current date")
|
||||
ErrLicenseRequired = errors.New("license is required for stream decryption")
|
||||
)
|
||||
```
|
||||
|
||||
### 9.2 Error Conditions
|
||||
|
||||
| Error | Cause | Recovery |
|
||||
|-------|-------|----------|
|
||||
| ErrInvalidMagic | File magic is not "SMSG" | Verify file format |
|
||||
| ErrInvalidPayload | Corrupted payload structure | Re-download or restore |
|
||||
| ErrDecryptionFailed | Wrong password or corrupted | Try correct password |
|
||||
| ErrPasswordRequired | Empty password provided | Provide password |
|
||||
| ErrStreamKeyExpired | Time outside rolling window | Wait for valid period or update file |
|
||||
| ErrNoValidKey | No wrapped key for current period | License/fingerprint mismatch |
|
||||
| ErrLicenseRequired | Empty StreamParams.License | Provide license identifier |
|
||||
|
||||
## 10. Constants
|
||||
|
||||
```go
|
||||
const Magic = "SMSG" // 4 ASCII bytes
|
||||
const Version = "1.0" // String version identifier
|
||||
const DefaultChunkSize = 1024 * 1024 // 1 MB
|
||||
|
||||
const FormatV1 = "" // Legacy JSON format
|
||||
const FormatV2 = "v2" // Binary format
|
||||
const FormatV3 = "v3" // Streaming with rolling keys
|
||||
|
||||
const KeyMethodDirect = "" // Password-direct (v1/v2)
|
||||
const KeyMethodLTHNRolling = "lthn-rolling" // LTHN rolling (v3)
|
||||
|
||||
const CompressionNone = ""
|
||||
const CompressionGzip = "gzip"
|
||||
const CompressionZstd = "zstd"
|
||||
|
||||
const CadenceDaily = "daily"
|
||||
const CadenceHalfDay = "12h"
|
||||
const CadenceQuarter = "6h"
|
||||
const CadenceHourly = "1h"
|
||||
```
|
||||
|
||||
## 11. API Usage
|
||||
|
||||
### 11.1 V1 (Legacy)
|
||||
|
||||
```go
|
||||
msg := NewMessage("Hello").WithSubject("Test")
|
||||
encrypted, _ := Encrypt(msg, "password")
|
||||
decrypted, _ := Decrypt(encrypted, "password")
|
||||
```
|
||||
|
||||
### 11.2 V2 (Binary)
|
||||
|
||||
```go
|
||||
msg := NewMessage("Hello").AddBinaryAttachment("file.bin", data, "application/octet-stream")
|
||||
manifest := NewManifest("My Content")
|
||||
encrypted, _ := EncryptV2WithManifest(msg, "password", manifest)
|
||||
decrypted, _ := Decrypt(encrypted, "password")
|
||||
```
|
||||
|
||||
### 11.3 V3 (Streaming)
|
||||
|
||||
```go
|
||||
msg := NewMessage("Stream content")
|
||||
params := &StreamParams{
|
||||
License: "user-license",
|
||||
Fingerprint: "device-fingerprint",
|
||||
Cadence: CadenceDaily,
|
||||
ChunkSize: 1048576,
|
||||
}
|
||||
manifest := NewManifest("Stream Track")
|
||||
manifest.LicenseType = "stream"
|
||||
encrypted, _ := EncryptV3(msg, params, manifest)
|
||||
decrypted, header, _ := DecryptV3(encrypted, params)
|
||||
```
|
||||
|
||||
## 12. Implementation Reference
|
||||
|
||||
- Types: `pkg/smsg/types.go`
|
||||
- Encryption: `pkg/smsg/smsg.go`
|
||||
- Streaming: `pkg/smsg/stream.go`
|
||||
- WASM: `pkg/wasm/stmf/main.go`
|
||||
- Tests: `pkg/smsg/*_test.go`
|
||||
|
||||
## 13. Security Considerations
|
||||
|
||||
1. **Nonce uniqueness**: Enchantrix generates random 24-byte nonces automatically
|
||||
2. **Key entropy**: Passwords should have 64+ bits entropy (no key stretching)
|
||||
3. **Manifest exposure**: Manifest is public; never include sensitive data
|
||||
4. **Constant-time crypto**: Enchantrix uses constant-time comparison for auth tags
|
||||
5. **Rolling window**: V3 keys valid for current + next period only
|
||||
|
||||
## 14. Future Work
|
||||
|
||||
- [ ] Key stretching (Argon2 option)
|
||||
- [ ] Multi-recipient encryption
|
||||
- [ ] Streaming API with ReadableStream
|
||||
- [ ] Hardware key support (WebAuthn)
|
||||
326
rfc/RFC-003-DATANODE.md
Normal file
326
rfc/RFC-003-DATANODE.md
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
# RFC-003: DataNode In-Memory Filesystem
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: [Snider](https://github.com/Snider/)
|
||||
**Created**: 2026-01-13
|
||||
**License**: EUPL-1.2
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
DataNode is an in-memory filesystem abstraction implementing Go's `fs.FS` interface. It provides the foundation for collecting, manipulating, and serializing file trees without touching disk.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
DataNode serves as the core data structure for:
|
||||
- Collecting files from various sources (GitHub, websites, PWAs)
|
||||
- Building container filesystems (TIM rootfs)
|
||||
- Serializing to/from tar archives
|
||||
- Encrypting as TRIX format
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
### 2.1 Core Type
|
||||
|
||||
```go
|
||||
type DataNode struct {
|
||||
files map[string]*dataFile
|
||||
}
|
||||
|
||||
type dataFile struct {
|
||||
name string
|
||||
content []byte
|
||||
modTime time.Time
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight**: DataNode uses a **flat key-value map**, not a nested tree structure. Paths are stored as keys directly, and directories are implicit (derived from path prefixes).
|
||||
|
||||
### 2.2 fs.FS Implementation
|
||||
|
||||
DataNode implements these interfaces:
|
||||
|
||||
| Interface | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| `fs.FS` | `Open(name string)` | Returns fs.File for path |
|
||||
| `fs.StatFS` | `Stat(name string)` | Returns fs.FileInfo |
|
||||
| `fs.ReadDirFS` | `ReadDir(name string)` | Lists directory contents |
|
||||
|
||||
### 2.3 Internal Helper Types
|
||||
|
||||
```go
|
||||
// File metadata
|
||||
type dataFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
modTime time.Time
|
||||
}
|
||||
func (fi *dataFileInfo) Mode() fs.FileMode { return 0444 } // Read-only
|
||||
|
||||
// Directory metadata
|
||||
type dirInfo struct {
|
||||
name string
|
||||
}
|
||||
func (di *dirInfo) Mode() fs.FileMode { return fs.ModeDir | 0555 }
|
||||
|
||||
// File reader (implements fs.File)
|
||||
type dataFileReader struct {
|
||||
info *dataFileInfo
|
||||
reader *bytes.Reader
|
||||
}
|
||||
|
||||
// Directory reader (implements fs.File)
|
||||
type dirFile struct {
|
||||
info *dirInfo
|
||||
entries []fs.DirEntry
|
||||
offset int
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Operations
|
||||
|
||||
### 3.1 Construction
|
||||
|
||||
```go
|
||||
// Create empty DataNode
|
||||
node := datanode.New()
|
||||
|
||||
// Returns: &DataNode{files: make(map[string]*dataFile)}
|
||||
```
|
||||
|
||||
### 3.2 Adding Files
|
||||
|
||||
```go
|
||||
// Add file with content
|
||||
node.AddData("path/to/file.txt", []byte("content"))
|
||||
|
||||
// Trailing slashes are ignored (treated as directory indicator)
|
||||
node.AddData("path/to/dir/", []byte("")) // Stored as "path/to/dir"
|
||||
```
|
||||
|
||||
**Note**: Parent directories are NOT explicitly created. They are implicit based on path prefixes.
|
||||
|
||||
### 3.3 File Access
|
||||
|
||||
```go
|
||||
// Open file (fs.FS interface)
|
||||
f, err := node.Open("path/to/file.txt")
|
||||
if err != nil {
|
||||
// fs.ErrNotExist if not found
|
||||
}
|
||||
defer f.Close()
|
||||
content, _ := io.ReadAll(f)
|
||||
|
||||
// Stat file
|
||||
info, err := node.Stat("path/to/file.txt")
|
||||
// info.Name(), info.Size(), info.ModTime(), info.Mode()
|
||||
|
||||
// Read directory
|
||||
entries, err := node.ReadDir("path/to")
|
||||
for _, entry := range entries {
|
||||
// entry.Name(), entry.IsDir(), entry.Type()
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 Walking
|
||||
|
||||
```go
|
||||
err := fs.WalkDir(node, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
// Process file
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
## 4. Path Semantics
|
||||
|
||||
### 4.1 Path Handling
|
||||
|
||||
- **Leading slashes stripped**: `/path/file` → `path/file`
|
||||
- **Trailing slashes ignored**: `path/dir/` → `path/dir`
|
||||
- **Forward slashes only**: Uses `/` regardless of OS
|
||||
- **Case-sensitive**: `File.txt` ≠ `file.txt`
|
||||
- **Direct lookup**: Paths stored as flat keys
|
||||
|
||||
### 4.2 Valid Paths
|
||||
|
||||
```
|
||||
file.txt → stored as "file.txt"
|
||||
dir/file.txt → stored as "dir/file.txt"
|
||||
/absolute/path → stored as "absolute/path" (leading / stripped)
|
||||
path/to/dir/ → stored as "path/to/dir" (trailing / stripped)
|
||||
```
|
||||
|
||||
### 4.3 Directory Detection
|
||||
|
||||
Directories are **implicit**. A directory exists if:
|
||||
1. Any file path has it as a prefix
|
||||
2. Example: Adding `a/b/c.txt` implicitly creates directories `a` and `a/b`
|
||||
|
||||
```go
|
||||
// ReadDir finds directories by scanning all paths
|
||||
func (dn *DataNode) ReadDir(name string) ([]fs.DirEntry, error) {
|
||||
// Scans all keys for matching prefix
|
||||
// Returns unique immediate children
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Tar Serialization
|
||||
|
||||
### 5.1 ToTar
|
||||
|
||||
```go
|
||||
tarBytes, err := node.ToTar()
|
||||
```
|
||||
|
||||
**Format**:
|
||||
- All files written as `tar.TypeReg` (regular files)
|
||||
- Header Mode: **0600** (fixed, not original mode)
|
||||
- No explicit directory entries
|
||||
- ModTime preserved from dataFile
|
||||
|
||||
```go
|
||||
// Serialization logic
|
||||
for path, file := range dn.files {
|
||||
header := &tar.Header{
|
||||
Name: path,
|
||||
Mode: 0600, // Fixed mode
|
||||
Size: int64(len(file.content)),
|
||||
ModTime: file.modTime,
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
tw.WriteHeader(header)
|
||||
tw.Write(file.content)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 FromTar
|
||||
|
||||
```go
|
||||
node, err := datanode.FromTar(tarBytes)
|
||||
```
|
||||
|
||||
**Parsing**:
|
||||
- Only reads `tar.TypeReg` entries
|
||||
- Ignores directory entries (`tar.TypeDir`)
|
||||
- Stores path and content in flat map
|
||||
|
||||
```go
|
||||
// Deserialization logic
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if header.Typeflag == tar.TypeReg {
|
||||
content, _ := io.ReadAll(tr)
|
||||
dn.files[header.Name] = &dataFile{
|
||||
name: filepath.Base(header.Name),
|
||||
content: content,
|
||||
modTime: header.ModTime,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Compressed Variants
|
||||
|
||||
```go
|
||||
// gzip compressed
|
||||
tarGz, err := node.ToTarGz()
|
||||
node, err := datanode.FromTarGz(tarGzBytes)
|
||||
|
||||
// xz compressed
|
||||
tarXz, err := node.ToTarXz()
|
||||
node, err := datanode.FromTarXz(tarXzBytes)
|
||||
```
|
||||
|
||||
## 6. File Modes
|
||||
|
||||
| Context | Mode | Notes |
|
||||
|---------|------|-------|
|
||||
| File read (fs.FS) | 0444 | Read-only for all |
|
||||
| Directory (fs.FS) | 0555 | Read+execute for all |
|
||||
| Tar export | 0600 | Owner read/write only |
|
||||
|
||||
**Note**: Original file modes are NOT preserved. All files get fixed modes.
|
||||
|
||||
## 7. Memory Model
|
||||
|
||||
- All content held in memory as `[]byte`
|
||||
- No lazy loading
|
||||
- No memory mapping
|
||||
- Thread-safe for concurrent reads (map is not mutated after creation)
|
||||
|
||||
### 7.1 Size Calculation
|
||||
|
||||
```go
|
||||
func (dn *DataNode) Size() int64 {
|
||||
var total int64
|
||||
for _, f := range dn.files {
|
||||
total += int64(len(f.content))
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Integration Points
|
||||
|
||||
### 8.1 TIM RootFS
|
||||
|
||||
```go
|
||||
tim := &tim.TIM{
|
||||
Config: configJSON,
|
||||
RootFS: datanode, // DataNode as container filesystem
|
||||
}
|
||||
```
|
||||
|
||||
### 8.2 TRIX Encryption
|
||||
|
||||
```go
|
||||
// Encrypt DataNode to TRIX
|
||||
encrypted, err := trix.Encrypt(datanode.ToTar(), password)
|
||||
|
||||
// Decrypt TRIX to DataNode
|
||||
tarBytes, err := trix.Decrypt(encrypted, password)
|
||||
node, err := datanode.FromTar(tarBytes)
|
||||
```
|
||||
|
||||
### 8.3 Collectors
|
||||
|
||||
```go
|
||||
// GitHub collector returns DataNode
|
||||
node, err := github.CollectRepo(url)
|
||||
|
||||
// Website collector returns DataNode
|
||||
node, err := website.Collect(url, depth)
|
||||
```
|
||||
|
||||
## 9. Implementation Reference
|
||||
|
||||
- Source: `pkg/datanode/datanode.go`
|
||||
- Tests: `pkg/datanode/datanode_test.go`
|
||||
|
||||
## 10. Security Considerations
|
||||
|
||||
1. **Path traversal**: Leading slashes stripped; no `..` handling needed (flat map)
|
||||
2. **Memory exhaustion**: No built-in limits; caller must validate input size
|
||||
3. **Tar bombs**: FromTar reads all entries into memory
|
||||
4. **Symlinks**: Not supported (intentional - tar.TypeReg only)
|
||||
|
||||
## 11. Limitations
|
||||
|
||||
- No symlink support
|
||||
- No extended attributes
|
||||
- No sparse files
|
||||
- Fixed file modes (0600 on export)
|
||||
- No streaming (full content in memory)
|
||||
|
||||
## 12. Future Work
|
||||
|
||||
- [ ] Streaming tar generation for large files
|
||||
- [ ] Optional mode preservation
|
||||
- [ ] Size limits for untrusted input
|
||||
- [ ] Lazy loading for large datasets
|
||||
330
rfc/RFC-004-TIM.md
Normal file
330
rfc/RFC-004-TIM.md
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
# RFC-004: Terminal Isolation Matrix (TIM)
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: [Snider](https://github.com/Snider/)
|
||||
**Created**: 2026-01-13
|
||||
**License**: EUPL-1.2
|
||||
**Depends On**: RFC-003
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
TIM (Terminal Isolation Matrix) is an OCI-compatible container bundle format. It packages a runtime configuration with a root filesystem (DataNode) for execution via runc or compatible runtimes.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
TIM provides:
|
||||
- OCI runtime-spec compatible bundles
|
||||
- Portable container packaging
|
||||
- Integration with DataNode filesystem
|
||||
- Encryption via STIM (RFC-005)
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
### 2.1 Core Type
|
||||
|
||||
```go
|
||||
// pkg/tim/tim.go:28-32
|
||||
type TerminalIsolationMatrix struct {
|
||||
Config []byte // Raw OCI runtime specification (JSON)
|
||||
RootFS *datanode.DataNode // In-memory filesystem
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Error Variables
|
||||
|
||||
```go
|
||||
var (
|
||||
ErrDataNodeRequired = errors.New("datanode is required")
|
||||
ErrConfigIsNil = errors.New("config is nil")
|
||||
ErrPasswordRequired = errors.New("password is required for encryption")
|
||||
ErrInvalidStimPayload = errors.New("invalid stim payload")
|
||||
ErrDecryptionFailed = errors.New("decryption failed (wrong password?)")
|
||||
)
|
||||
```
|
||||
|
||||
## 3. Public API
|
||||
|
||||
### 3.1 Constructors
|
||||
|
||||
```go
|
||||
// Create empty TIM with default config
|
||||
func New() (*TerminalIsolationMatrix, error)
|
||||
|
||||
// Wrap existing DataNode into TIM
|
||||
func FromDataNode(dn *DataNode) (*TerminalIsolationMatrix, error)
|
||||
|
||||
// Deserialize from tar archive
|
||||
func FromTar(data []byte) (*TerminalIsolationMatrix, error)
|
||||
```
|
||||
|
||||
### 3.2 Serialization
|
||||
|
||||
```go
|
||||
// Serialize to tar archive
|
||||
func (m *TerminalIsolationMatrix) ToTar() ([]byte, error)
|
||||
|
||||
// Encrypt to STIM format (ChaCha20-Poly1305)
|
||||
func (m *TerminalIsolationMatrix) ToSigil(password string) ([]byte, error)
|
||||
```
|
||||
|
||||
### 3.3 Decryption
|
||||
|
||||
```go
|
||||
// Decrypt from STIM format
|
||||
func FromSigil(data []byte, password string) (*TerminalIsolationMatrix, error)
|
||||
```
|
||||
|
||||
### 3.4 Execution
|
||||
|
||||
```go
|
||||
// Run plain .tim file with runc
|
||||
func Run(timPath string) error
|
||||
|
||||
// Decrypt and run .stim file
|
||||
func RunEncrypted(stimPath, password string) error
|
||||
```
|
||||
|
||||
## 4. Tar Archive Structure
|
||||
|
||||
### 4.1 Layout
|
||||
|
||||
```
|
||||
config.json (root level, mode 0600)
|
||||
rootfs/ (directory, mode 0755)
|
||||
rootfs/bin/app (files within rootfs/)
|
||||
rootfs/etc/config
|
||||
...
|
||||
```
|
||||
|
||||
### 4.2 Serialization (ToTar)
|
||||
|
||||
```go
|
||||
// pkg/tim/tim.go:111-195
|
||||
func (m *TerminalIsolationMatrix) ToTar() ([]byte, error) {
|
||||
// 1. Write config.json header (size = len(m.Config), mode 0600)
|
||||
// 2. Write config.json content
|
||||
// 3. Write rootfs/ directory entry (TypeDir, mode 0755)
|
||||
// 4. Walk m.RootFS depth-first
|
||||
// 5. For each file: tar entry with name "rootfs/" + path, mode 0600
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Deserialization (FromTar)
|
||||
|
||||
```go
|
||||
func FromTar(data []byte) (*TerminalIsolationMatrix, error) {
|
||||
// 1. Parse tar entries
|
||||
// 2. "config.json" → stored as raw bytes in Config
|
||||
// 3. "rootfs/*" prefix → stripped and added to DataNode
|
||||
// 4. Error if config.json missing (ErrConfigIsNil)
|
||||
}
|
||||
```
|
||||
|
||||
## 5. OCI Config
|
||||
|
||||
### 5.1 Default Config
|
||||
|
||||
The `New()` function creates a TIM with a default config from `pkg/tim/config.go`:
|
||||
|
||||
```go
|
||||
func defaultConfig() (*trix.Trix, error) {
|
||||
return &trix.Trix{Header: make(map[string]interface{})}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: The default config is minimal. Applications should populate the Config field with a proper OCI runtime spec.
|
||||
|
||||
### 5.2 OCI Runtime Spec Example
|
||||
|
||||
```json
|
||||
{
|
||||
"ociVersion": "1.0.2",
|
||||
"process": {
|
||||
"terminal": false,
|
||||
"user": {"uid": 0, "gid": 0},
|
||||
"args": ["/bin/app"],
|
||||
"env": ["PATH=/usr/bin:/bin"],
|
||||
"cwd": "/"
|
||||
},
|
||||
"root": {
|
||||
"path": "rootfs",
|
||||
"readonly": true
|
||||
},
|
||||
"mounts": [],
|
||||
"linux": {
|
||||
"namespaces": [
|
||||
{"type": "pid"},
|
||||
{"type": "network"},
|
||||
{"type": "mount"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Execution Flow
|
||||
|
||||
### 6.1 Plain TIM (Run)
|
||||
|
||||
```go
|
||||
// pkg/tim/run.go:18-74
|
||||
func Run(timPath string) error {
|
||||
// 1. Create temporary directory (borg-run-*)
|
||||
// 2. Extract tar entry-by-entry
|
||||
// - Security: Path traversal check (prevents ../)
|
||||
// - Validates: target = Clean(target) within tempDir
|
||||
// 3. Create directories as needed (0755)
|
||||
// 4. Write files with 0600 permissions
|
||||
// 5. Execute: runc run -b <tempDir> borg-container
|
||||
// 6. Stream stdout/stderr directly
|
||||
// 7. Return exit code
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Encrypted TIM (RunEncrypted)
|
||||
|
||||
```go
|
||||
// pkg/tim/run.go:79-134
|
||||
func RunEncrypted(stimPath, password string) error {
|
||||
// 1. Read encrypted .stim file
|
||||
// 2. Decrypt using FromSigil() with password
|
||||
// 3. Create temporary directory (borg-run-*)
|
||||
// 4. Write config.json to tempDir
|
||||
// 5. Create rootfs/ subdirectory
|
||||
// 6. Walk DataNode and extract all files to rootfs/
|
||||
// - Uses CopyFile() with 0600 permissions
|
||||
// 7. Execute: runc run -b <tempDir> borg-container
|
||||
// 8. Stream stdout/stderr
|
||||
// 9. Clean up temp directory (defer os.RemoveAll)
|
||||
// 10. Return exit code
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Security Controls
|
||||
|
||||
| Control | Implementation |
|
||||
|---------|----------------|
|
||||
| Path traversal | `filepath.Clean()` + prefix validation |
|
||||
| Temp cleanup | `defer os.RemoveAll(tempDir)` |
|
||||
| File permissions | Hardcoded 0600 (files), 0755 (dirs) |
|
||||
| Test injection | `ExecCommand` variable for mocking runc |
|
||||
|
||||
## 7. Cache API
|
||||
|
||||
### 7.1 Cache Structure
|
||||
|
||||
```go
|
||||
// pkg/tim/cache.go
|
||||
type Cache struct {
|
||||
Dir string // Directory path for storage
|
||||
Password string // Shared password for all TIMs
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 Cache Operations
|
||||
|
||||
```go
|
||||
// Create cache with master password
|
||||
func NewCache(dir, password string) (*Cache, error)
|
||||
|
||||
// Store TIM (encrypted automatically as .stim)
|
||||
func (c *Cache) Store(name string, m *TerminalIsolationMatrix) error
|
||||
|
||||
// Load TIM (decrypted automatically)
|
||||
func (c *Cache) Load(name string) (*TerminalIsolationMatrix, error)
|
||||
|
||||
// Delete cached TIM
|
||||
func (c *Cache) Delete(name string) error
|
||||
|
||||
// Check if TIM exists
|
||||
func (c *Cache) Exists(name string) bool
|
||||
|
||||
// List all cached TIM names
|
||||
func (c *Cache) List() ([]string, error)
|
||||
|
||||
// Load and execute cached TIM
|
||||
func (c *Cache) Run(name string) error
|
||||
|
||||
// Get file size of cached .stim
|
||||
func (c *Cache) Size(name string) (int64, error)
|
||||
```
|
||||
|
||||
### 7.3 Cache Directory Structure
|
||||
|
||||
```
|
||||
cache/
|
||||
├── mycontainer.stim (encrypted)
|
||||
├── another.stim (encrypted)
|
||||
└── ...
|
||||
```
|
||||
|
||||
- All TIMs stored as `.stim` files (encrypted)
|
||||
- Single password protects entire cache
|
||||
- Directory created with 0700 permissions
|
||||
- Files stored with 0600 permissions
|
||||
|
||||
## 8. CLI Usage
|
||||
|
||||
```bash
|
||||
# Compile Borgfile to TIM
|
||||
borg compile -f Borgfile -o container.tim
|
||||
|
||||
# Compile with encryption
|
||||
borg compile -f Borgfile -e "password" -o container.stim
|
||||
|
||||
# Run plain TIM
|
||||
borg run container.tim
|
||||
|
||||
# Run encrypted TIM
|
||||
borg run container.stim -p "password"
|
||||
|
||||
# Decode (extract) to tar
|
||||
borg decode container.stim -p "password" --i-am-in-isolation -o container.tar
|
||||
|
||||
# Inspect metadata without decrypting
|
||||
borg inspect container.stim
|
||||
```
|
||||
|
||||
## 9. Implementation Reference
|
||||
|
||||
- TIM core: `pkg/tim/tim.go`
|
||||
- Execution: `pkg/tim/run.go`
|
||||
- Cache: `pkg/tim/cache.go`
|
||||
- Config: `pkg/tim/config.go`
|
||||
- Tests: `pkg/tim/tim_test.go`, `pkg/tim/run_test.go`, `pkg/tim/cache_test.go`
|
||||
|
||||
## 10. Security Considerations
|
||||
|
||||
1. **Path traversal prevention**: `filepath.Clean()` + prefix validation
|
||||
2. **Permission hardcoding**: 0600 files, 0755 directories
|
||||
3. **Secure cleanup**: `defer os.RemoveAll()` on temp directories
|
||||
4. **Command injection prevention**: `ExecCommand` variable (no shell)
|
||||
5. **Config validation**: Validate OCI spec before execution
|
||||
|
||||
## 11. OCI Compatibility
|
||||
|
||||
TIM bundles are compatible with:
|
||||
- runc
|
||||
- crun
|
||||
- youki
|
||||
- Any OCI runtime-spec 1.0.2 compliant runtime
|
||||
|
||||
## 12. Test Coverage
|
||||
|
||||
| Area | Tests |
|
||||
|------|-------|
|
||||
| TIM creation | DataNode wrapping, default config |
|
||||
| Serialization | Tar round-trips, large files (1MB+) |
|
||||
| Encryption | ToSigil/FromSigil, wrong password detection |
|
||||
| Caching | Store/Load/Delete, List, Size |
|
||||
| Execution | ZIP slip prevention, temp cleanup |
|
||||
| Error handling | Nil DataNode, nil config, invalid tar |
|
||||
|
||||
## 13. Future Work
|
||||
|
||||
- [ ] Image layer support
|
||||
- [ ] Registry push/pull
|
||||
- [ ] Multi-platform bundles
|
||||
- [ ] Signature verification
|
||||
- [ ] Full OCI config generation
|
||||
303
rfc/RFC-005-STIM.md
Normal file
303
rfc/RFC-005-STIM.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# RFC-005: STIM Encrypted Container Format
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: [Snider](https://github.com/Snider/)
|
||||
**Created**: 2026-01-13
|
||||
**License**: EUPL-1.2
|
||||
**Depends On**: RFC-003, RFC-004
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
STIM (Secure TIM) is an encrypted container format that wraps TIM bundles using ChaCha20-Poly1305 authenticated encryption. It enables secure distribution and execution of containers without exposing the contents.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
STIM provides:
|
||||
- Encrypted TIM containers
|
||||
- ChaCha20-Poly1305 authenticated encryption
|
||||
- Separate encryption of config and rootfs
|
||||
- Direct execution without persistent decryption
|
||||
|
||||
## 2. Format Name
|
||||
|
||||
**ChaChaPolySigil** - The internal name for the STIM format, using:
|
||||
- ChaCha20-Poly1305 algorithm (via Enchantrix library)
|
||||
- Trix container wrapper with "STIM" magic
|
||||
|
||||
## 3. File Structure
|
||||
|
||||
### 3.1 Container Format
|
||||
|
||||
STIM uses the **Trix container format** from Enchantrix library:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Magic: "STIM" (4 bytes ASCII) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Trix Header (Gob-encoded JSON) │
|
||||
│ - encryption_algorithm: "chacha20poly1305"
|
||||
│ - tim: true │
|
||||
│ - config_size: uint32 │
|
||||
│ - rootfs_size: uint32 │
|
||||
│ - version: "1.0" │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Trix Payload: │
|
||||
│ [config_size: 4 bytes BE uint32] │
|
||||
│ [encrypted config] │
|
||||
│ [encrypted rootfs tar] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 Payload Structure
|
||||
|
||||
```
|
||||
Offset Size Field
|
||||
------ ----- ------------------------------------
|
||||
0 4 Config size (big-endian uint32)
|
||||
4 N Encrypted config (includes nonce + tag)
|
||||
4+N M Encrypted rootfs tar (includes nonce + tag)
|
||||
```
|
||||
|
||||
### 3.3 Encrypted Component Format
|
||||
|
||||
Each encrypted component (config and rootfs) follows Enchantrix format:
|
||||
|
||||
```
|
||||
[24-byte XChaCha20 nonce][ciphertext][16-byte Poly1305 tag]
|
||||
```
|
||||
|
||||
**Critical**: Nonces are **embedded in the ciphertext**, not transmitted separately.
|
||||
|
||||
## 4. Encryption
|
||||
|
||||
### 4.1 Algorithm
|
||||
|
||||
XChaCha20-Poly1305 (extended nonce variant)
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Key size | 32 bytes |
|
||||
| Nonce size | 24 bytes (embedded) |
|
||||
| Tag size | 16 bytes |
|
||||
|
||||
### 4.2 Key Derivation
|
||||
|
||||
```go
|
||||
// pkg/trix/trix.go:64-67
|
||||
func DeriveKey(password string) []byte {
|
||||
hash := sha256.Sum256([]byte(password))
|
||||
return hash[:] // 32 bytes
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Dual Encryption
|
||||
|
||||
Config and RootFS are encrypted **separately** with independent nonces:
|
||||
|
||||
```go
|
||||
// pkg/tim/tim.go:217-232
|
||||
func (m *TerminalIsolationMatrix) ToSigil(password string) ([]byte, error) {
|
||||
// 1. Derive key
|
||||
key := trix.DeriveKey(password)
|
||||
|
||||
// 2. Create sigil
|
||||
sigil, _ := enchantrix.NewChaChaPolySigil(key)
|
||||
|
||||
// 3. Encrypt config (generates fresh nonce automatically)
|
||||
encConfig, _ := sigil.In(m.Config)
|
||||
|
||||
// 4. Serialize rootfs to tar
|
||||
rootfsTar, _ := m.RootFS.ToTar()
|
||||
|
||||
// 5. Encrypt rootfs (generates different fresh nonce)
|
||||
encRootFS, _ := sigil.In(rootfsTar)
|
||||
|
||||
// 6. Build payload
|
||||
payload := make([]byte, 4+len(encConfig)+len(encRootFS))
|
||||
binary.BigEndian.PutUint32(payload[:4], uint32(len(encConfig)))
|
||||
copy(payload[4:4+len(encConfig)], encConfig)
|
||||
copy(payload[4+len(encConfig):], encRootFS)
|
||||
|
||||
// 7. Create Trix container with STIM magic
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Rationale for dual encryption:**
|
||||
- Config can be decrypted separately for inspection
|
||||
- Allows streaming decryption of large rootfs
|
||||
- Independent nonces prevent any nonce reuse
|
||||
|
||||
## 5. Decryption Flow
|
||||
|
||||
```go
|
||||
// pkg/tim/tim.go:255-308
|
||||
func FromSigil(data []byte, password string) (*TerminalIsolationMatrix, error) {
|
||||
// 1. Decode Trix container with magic "STIM"
|
||||
t, _ := trix.Decode(data, "STIM", nil)
|
||||
|
||||
// 2. Derive key from password
|
||||
key := trix.DeriveKey(password)
|
||||
|
||||
// 3. Create sigil
|
||||
sigil, _ := enchantrix.NewChaChaPolySigil(key)
|
||||
|
||||
// 4. Parse payload: extract configSize from first 4 bytes
|
||||
configSize := binary.BigEndian.Uint32(t.Payload[:4])
|
||||
|
||||
// 5. Validate bounds
|
||||
if int(configSize) > len(t.Payload)-4 {
|
||||
return nil, ErrInvalidStimPayload
|
||||
}
|
||||
|
||||
// 6. Extract encrypted components
|
||||
encConfig := t.Payload[4 : 4+configSize]
|
||||
encRootFS := t.Payload[4+configSize:]
|
||||
|
||||
// 7. Decrypt config (nonce auto-extracted by Enchantrix)
|
||||
config, err := sigil.Out(encConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
|
||||
}
|
||||
|
||||
// 8. Decrypt rootfs
|
||||
rootfsTar, err := sigil.Out(encRootFS)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
|
||||
}
|
||||
|
||||
// 9. Reconstruct DataNode from tar
|
||||
rootfs, _ := datanode.FromTar(rootfsTar)
|
||||
|
||||
return &TerminalIsolationMatrix{Config: config, RootFS: rootfs}, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Trix Header
|
||||
|
||||
```go
|
||||
Header: map[string]interface{}{
|
||||
"encryption_algorithm": "chacha20poly1305",
|
||||
"tim": true,
|
||||
"config_size": len(encConfig),
|
||||
"rootfs_size": len(encRootFS),
|
||||
"version": "1.0",
|
||||
}
|
||||
```
|
||||
|
||||
## 7. CLI Usage
|
||||
|
||||
```bash
|
||||
# Create encrypted container
|
||||
borg compile -f Borgfile -e "password" -o container.stim
|
||||
|
||||
# Run encrypted container
|
||||
borg run container.stim -p "password"
|
||||
|
||||
# Decode (extract) encrypted container
|
||||
borg decode container.stim -p "password" --i-am-in-isolation -o container.tar
|
||||
|
||||
# Inspect without decrypting (shows header metadata only)
|
||||
borg inspect container.stim
|
||||
# Output:
|
||||
# Format: STIM
|
||||
# encryption_algorithm: chacha20poly1305
|
||||
# config_size: 1234
|
||||
# rootfs_size: 567890
|
||||
```
|
||||
|
||||
## 8. Cache API
|
||||
|
||||
```go
|
||||
// Create cache with master password
|
||||
cache, err := tim.NewCache("/path/to/cache", masterPassword)
|
||||
|
||||
// Store TIM (encrypted automatically as .stim)
|
||||
err := cache.Store("name", tim)
|
||||
|
||||
// Load TIM (decrypted automatically)
|
||||
tim, err := cache.Load("name")
|
||||
|
||||
// List cached containers
|
||||
names, err := cache.List()
|
||||
```
|
||||
|
||||
## 9. Execution Security
|
||||
|
||||
```go
|
||||
// Secure execution flow
|
||||
func RunEncrypted(path, password string) error {
|
||||
// 1. Create secure temp directory
|
||||
tmpDir, _ := os.MkdirTemp("", "borg-run-*")
|
||||
defer os.RemoveAll(tmpDir) // Secure cleanup
|
||||
|
||||
// 2. Read and decrypt
|
||||
data, _ := os.ReadFile(path)
|
||||
tim, _ := FromSigil(data, password)
|
||||
|
||||
// 3. Extract to temp
|
||||
tim.ExtractTo(tmpDir)
|
||||
|
||||
// 4. Execute with runc
|
||||
return runRunc(tmpDir)
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Security Properties
|
||||
|
||||
### 10.1 Confidentiality
|
||||
|
||||
- Contents encrypted with ChaCha20-Poly1305
|
||||
- Password-derived key never stored
|
||||
- Nonces are random, never reused
|
||||
|
||||
### 10.2 Integrity
|
||||
|
||||
- Poly1305 MAC prevents tampering
|
||||
- Decryption fails if modified
|
||||
- Separate MACs for config and rootfs
|
||||
|
||||
### 10.3 Error Detection
|
||||
|
||||
| Error | Cause |
|
||||
|-------|-------|
|
||||
| `ErrPasswordRequired` | Empty password provided |
|
||||
| `ErrInvalidStimPayload` | Payload < 4 bytes or invalid size |
|
||||
| `ErrDecryptionFailed` | Wrong password or corrupted data |
|
||||
|
||||
## 11. Comparison to TRIX
|
||||
|
||||
| Feature | STIM | TRIX |
|
||||
|---------|------|------|
|
||||
| Algorithm | ChaCha20-Poly1305 | PGP/AES or ChaCha |
|
||||
| Content | TIM bundles | DataNode (raw files) |
|
||||
| Structure | Dual encryption | Single blob |
|
||||
| Magic | "STIM" | "TRIX" |
|
||||
| Use case | Container execution | General encryption, accounts |
|
||||
|
||||
STIM is for containers. TRIX is for general file encryption and accounts.
|
||||
|
||||
## 12. Implementation Reference
|
||||
|
||||
- Encryption: `pkg/tim/tim.go` (ToSigil, FromSigil)
|
||||
- Key derivation: `pkg/trix/trix.go` (DeriveKey)
|
||||
- Cache: `pkg/tim/cache.go`
|
||||
- CLI: `cmd/run.go`, `cmd/decode.go`, `cmd/compile.go`
|
||||
- Enchantrix: `github.com/Snider/Enchantrix`
|
||||
|
||||
## 13. Security Considerations
|
||||
|
||||
1. **Password strength**: Recommend 64+ bits entropy (12+ chars)
|
||||
2. **Key derivation**: SHA-256 only (no stretching) - use strong passwords
|
||||
3. **Memory handling**: Keys should be wiped after use
|
||||
4. **Temp files**: Use tmpfs when available, secure wipe after
|
||||
5. **Side channels**: Enchantrix uses constant-time crypto operations
|
||||
|
||||
## 14. Future Work
|
||||
|
||||
- [ ] Hardware key support (YubiKey, TPM)
|
||||
- [ ] Key stretching (Argon2)
|
||||
- [ ] Multi-recipient encryption
|
||||
- [ ] Streaming decryption for large rootfs
|
||||
342
rfc/RFC-006-TRIX.md
Normal file
342
rfc/RFC-006-TRIX.md
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
# RFC-006: TRIX PGP Encryption Format
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: [Snider](https://github.com/Snider/)
|
||||
**Created**: 2026-01-13
|
||||
**License**: EUPL-1.2
|
||||
**Depends On**: RFC-003
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
TRIX is a PGP-based encryption format for DataNode archives and account credentials. It provides symmetric and asymmetric encryption using OpenPGP standards and ChaCha20-Poly1305, enabling secure data exchange and identity management.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
TRIX provides:
|
||||
- PGP symmetric encryption for DataNode archives
|
||||
- ChaCha20-Poly1305 modern encryption
|
||||
- PGP armored keys for account/identity management
|
||||
- Integration with Enchantrix library
|
||||
|
||||
## 2. Public API
|
||||
|
||||
### 2.1 Key Derivation
|
||||
|
||||
```go
|
||||
// pkg/trix/trix.go:64-67
|
||||
func DeriveKey(password string) []byte {
|
||||
hash := sha256.Sum256([]byte(password))
|
||||
return hash[:] // 32 bytes
|
||||
}
|
||||
```
|
||||
|
||||
- Input: password string (any length)
|
||||
- Output: 32-byte key (256 bits)
|
||||
- Algorithm: SHA-256 hash of UTF-8 bytes
|
||||
- Deterministic: identical passwords → identical keys
|
||||
|
||||
### 2.2 Legacy PGP Encryption
|
||||
|
||||
```go
|
||||
// Encrypt DataNode to TRIX (PGP symmetric)
|
||||
func ToTrix(dn *datanode.DataNode, password string) ([]byte, error)
|
||||
|
||||
// Decrypt TRIX to DataNode (DISABLED for encrypted payloads)
|
||||
func FromTrix(data []byte, password string) (*datanode.DataNode, error)
|
||||
```
|
||||
|
||||
**Note**: `FromTrix` with a non-empty password returns error `"decryption disabled: cannot accept encrypted payloads"`. This is intentional to prevent accidental password use.
|
||||
|
||||
### 2.3 Modern ChaCha20-Poly1305 Encryption
|
||||
|
||||
```go
|
||||
// Encrypt with ChaCha20-Poly1305
|
||||
func ToTrixChaCha(dn *datanode.DataNode, password string) ([]byte, error)
|
||||
|
||||
// Decrypt ChaCha20-Poly1305
|
||||
func FromTrixChaCha(data []byte, password string) (*datanode.DataNode, error)
|
||||
```
|
||||
|
||||
### 2.4 Error Variables
|
||||
|
||||
```go
|
||||
var (
|
||||
ErrPasswordRequired = errors.New("password is required for encryption")
|
||||
ErrDecryptionFailed = errors.New("decryption failed (wrong password?)")
|
||||
)
|
||||
```
|
||||
|
||||
## 3. File Format
|
||||
|
||||
### 3.1 Container Structure
|
||||
|
||||
```
|
||||
[4 bytes] Magic: "TRIX" (ASCII)
|
||||
[Variable] Gob-encoded Header (map[string]interface{})
|
||||
[Variable] Payload (encrypted or unencrypted tarball)
|
||||
```
|
||||
|
||||
### 3.2 Header Examples
|
||||
|
||||
**Unencrypted:**
|
||||
```go
|
||||
Header: map[string]interface{}{} // Empty map
|
||||
```
|
||||
|
||||
**ChaCha20-Poly1305:**
|
||||
```go
|
||||
Header: map[string]interface{}{
|
||||
"encryption_algorithm": "chacha20poly1305",
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 ChaCha20-Poly1305 Payload
|
||||
|
||||
```
|
||||
[24 bytes] XChaCha20 Nonce (embedded)
|
||||
[N bytes] Encrypted tar archive
|
||||
[16 bytes] Poly1305 authentication tag
|
||||
```
|
||||
|
||||
**Note**: Nonces are embedded in the ciphertext by Enchantrix, not stored separately.
|
||||
|
||||
## 4. Encryption Workflows
|
||||
|
||||
### 4.1 ChaCha20-Poly1305 (Recommended)
|
||||
|
||||
```go
|
||||
// Encryption
|
||||
func ToTrixChaCha(dn *datanode.DataNode, password string) ([]byte, error) {
|
||||
// 1. Validate password is non-empty
|
||||
if password == "" {
|
||||
return nil, ErrPasswordRequired
|
||||
}
|
||||
|
||||
// 2. Serialize DataNode to tar
|
||||
tarball, _ := dn.ToTar()
|
||||
|
||||
// 3. Derive 32-byte key
|
||||
key := DeriveKey(password)
|
||||
|
||||
// 4. Create sigil and encrypt
|
||||
sigil, _ := enchantrix.NewChaChaPolySigil(key)
|
||||
encrypted, _ := sigil.In(tarball) // Generates nonce automatically
|
||||
|
||||
// 5. Create Trix container
|
||||
t := &trix.Trix{
|
||||
Header: map[string]interface{}{"encryption_algorithm": "chacha20poly1305"},
|
||||
Payload: encrypted,
|
||||
}
|
||||
|
||||
// 6. Encode with TRIX magic
|
||||
return trix.Encode(t, "TRIX", nil)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Decryption
|
||||
|
||||
```go
|
||||
func FromTrixChaCha(data []byte, password string) (*datanode.DataNode, error) {
|
||||
// 1. Validate password
|
||||
if password == "" {
|
||||
return nil, ErrPasswordRequired
|
||||
}
|
||||
|
||||
// 2. Decode TRIX container
|
||||
t, _ := trix.Decode(data, "TRIX", nil)
|
||||
|
||||
// 3. Derive key and decrypt
|
||||
key := DeriveKey(password)
|
||||
sigil, _ := enchantrix.NewChaChaPolySigil(key)
|
||||
tarball, err := sigil.Out(t.Payload) // Extracts nonce, verifies MAC
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
|
||||
}
|
||||
|
||||
// 4. Deserialize DataNode
|
||||
return datanode.FromTar(tarball)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Legacy PGP (Disabled Decryption)
|
||||
|
||||
```go
|
||||
func ToTrix(dn *datanode.DataNode, password string) ([]byte, error) {
|
||||
tarball, _ := dn.ToTar()
|
||||
|
||||
var payload []byte
|
||||
if password != "" {
|
||||
// PGP symmetric encryption
|
||||
cryptService := crypt.NewService()
|
||||
payload, _ = cryptService.SymmetricallyEncryptPGP([]byte(password), tarball)
|
||||
} else {
|
||||
payload = tarball
|
||||
}
|
||||
|
||||
t := &trix.Trix{Header: map[string]interface{}{}, Payload: payload}
|
||||
return trix.Encode(t, "TRIX", nil)
|
||||
}
|
||||
|
||||
func FromTrix(data []byte, password string) (*datanode.DataNode, error) {
|
||||
// Security: Reject encrypted payloads
|
||||
if password != "" {
|
||||
return nil, errors.New("decryption disabled: cannot accept encrypted payloads")
|
||||
}
|
||||
|
||||
t, _ := trix.Decode(data, "TRIX", nil)
|
||||
return datanode.FromTar(t.Payload)
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Enchantrix Library
|
||||
|
||||
### 5.1 Dependencies
|
||||
|
||||
```go
|
||||
import (
|
||||
"github.com/Snider/Enchantrix/pkg/trix" // Container format
|
||||
"github.com/Snider/Enchantrix/pkg/crypt" // PGP operations
|
||||
"github.com/Snider/Enchantrix/pkg/enchantrix" // AEAD sigils
|
||||
)
|
||||
```
|
||||
|
||||
### 5.2 Trix Container
|
||||
|
||||
```go
|
||||
type Trix struct {
|
||||
Header map[string]interface{}
|
||||
Payload []byte
|
||||
}
|
||||
|
||||
func Encode(t *Trix, magic string, extra interface{}) ([]byte, error)
|
||||
func Decode(data []byte, magic string, extra interface{}) (*Trix, error)
|
||||
```
|
||||
|
||||
### 5.3 ChaCha20-Poly1305 Sigil
|
||||
|
||||
```go
|
||||
// Create sigil with 32-byte key
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(key)
|
||||
|
||||
// Encrypt (generates random 24-byte nonce)
|
||||
ciphertext, err := sigil.In(plaintext)
|
||||
|
||||
// Decrypt (extracts nonce, verifies MAC)
|
||||
plaintext, err := sigil.Out(ciphertext)
|
||||
```
|
||||
|
||||
## 6. Account System Integration
|
||||
|
||||
### 6.1 PGP Armored Keys
|
||||
|
||||
```
|
||||
-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQENBGX...base64...
|
||||
-----END PGP PUBLIC KEY BLOCK-----
|
||||
```
|
||||
|
||||
### 6.2 Key Storage
|
||||
|
||||
```
|
||||
~/.borg/
|
||||
├── identity.pub # PGP public key (armored)
|
||||
├── identity.key # PGP private key (armored, encrypted)
|
||||
└── keyring/ # Trusted public keys
|
||||
```
|
||||
|
||||
## 7. CLI Usage
|
||||
|
||||
```bash
|
||||
# Encrypt with TRIX (PGP symmetric)
|
||||
borg collect github repo https://github.com/user/repo \
|
||||
--format trix \
|
||||
--password "password"
|
||||
|
||||
# Decrypt unencrypted TRIX
|
||||
borg decode archive.trix -o decoded.tar
|
||||
|
||||
# Inspect without decrypting
|
||||
borg inspect archive.trix
|
||||
# Output:
|
||||
# Format: TRIX
|
||||
# encryption_algorithm: chacha20poly1305 (if present)
|
||||
# Payload Size: N bytes
|
||||
```
|
||||
|
||||
## 8. Format Comparison
|
||||
|
||||
| Format | Extension | Algorithm | Use Case |
|
||||
|--------|-----------|-----------|----------|
|
||||
| `datanode` | `.tar` | None | Uncompressed archive |
|
||||
| `tim` | `.tim` | None | Container bundle |
|
||||
| `trix` | `.trix` | PGP/AES or ChaCha | Encrypted archives, accounts |
|
||||
| `stim` | `.stim` | ChaCha20-Poly1305 | Encrypted containers |
|
||||
| `smsg` | `.smsg` | ChaCha20-Poly1305 | Encrypted media |
|
||||
|
||||
## 9. Security Analysis
|
||||
|
||||
### 9.1 Key Derivation Limitations
|
||||
|
||||
**Current implementation: SHA-256 (single round)**
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Algorithm | SHA-256 |
|
||||
| Iterations | 1 |
|
||||
| Salt | None |
|
||||
| Key stretching | None |
|
||||
|
||||
**Implications:**
|
||||
- GPU brute force: ~10 billion guesses/second
|
||||
- 8-character password: ~10 seconds to break
|
||||
- Recommendation: Use 15+ character passwords
|
||||
|
||||
### 9.2 ChaCha20-Poly1305 Properties
|
||||
|
||||
| Property | Status |
|
||||
|----------|--------|
|
||||
| Authentication | Poly1305 MAC (16 bytes) |
|
||||
| Key size | 256 bits |
|
||||
| Nonce size | 192 bits (XChaCha) |
|
||||
| Standard | RFC 7539 compliant |
|
||||
|
||||
## 10. Test Coverage
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| DeriveKey length | Output is exactly 32 bytes |
|
||||
| DeriveKey determinism | Same password → same key |
|
||||
| DeriveKey uniqueness | Different passwords → different keys |
|
||||
| ToTrix without password | Valid TRIX with "TRIX" magic |
|
||||
| ToTrix with password | PGP encryption applied |
|
||||
| FromTrix unencrypted | Round-trip preserves files |
|
||||
| FromTrix password rejection | Returns error |
|
||||
| ToTrixChaCha success | Valid TRIX created |
|
||||
| ToTrixChaCha empty password | Returns ErrPasswordRequired |
|
||||
| FromTrixChaCha round-trip | Preserves nested directories |
|
||||
| FromTrixChaCha wrong password | Returns ErrDecryptionFailed |
|
||||
| FromTrixChaCha large data | 1MB file processed |
|
||||
|
||||
## 11. Implementation Reference
|
||||
|
||||
- Source: `pkg/trix/trix.go`
|
||||
- Tests: `pkg/trix/trix_test.go`
|
||||
- Enchantrix: `github.com/Snider/Enchantrix v0.0.2`
|
||||
|
||||
## 12. Security Considerations
|
||||
|
||||
1. **Use strong passwords**: 15+ characters due to no key stretching
|
||||
2. **Prefer ChaCha**: Use `ToTrixChaCha` over legacy PGP
|
||||
3. **Key backup**: Securely backup private keys
|
||||
4. **Interoperability**: TRIX files with GPG require password
|
||||
|
||||
## 13. Future Work
|
||||
|
||||
- [ ] Key stretching (Argon2 option in DeriveKey)
|
||||
- [ ] Public key encryption support
|
||||
- [ ] Signature support
|
||||
- [ ] Key expiration metadata
|
||||
- [ ] Multi-recipient encryption
|
||||
355
rfc/RFC-007-LTHN.md
Normal file
355
rfc/RFC-007-LTHN.md
Normal file
|
|
@ -0,0 +1,355 @@
|
|||
# RFC-007: LTHN Key Derivation
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: [Snider](https://github.com/Snider/)
|
||||
**Created**: 2026-01-13
|
||||
**License**: EUPL-1.2
|
||||
**Depends On**: RFC-002
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
LTHN (Leet-Hash-Nonce) is a rainbow-table resistant key derivation function used for streaming DRM with time-limited access. It generates rolling keys that automatically expire without requiring revocation infrastructure.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
LTHN provides:
|
||||
- Rainbow-table resistant hashing
|
||||
- Time-based key rolling
|
||||
- Zero-trust key derivation (no key server)
|
||||
- Configurable cadence (daily to hourly)
|
||||
|
||||
## 2. Motivation
|
||||
|
||||
Traditional DRM requires:
|
||||
- Central key server
|
||||
- License validation
|
||||
- Revocation lists
|
||||
- Network connectivity
|
||||
|
||||
LTHN eliminates these by:
|
||||
- Deriving keys from public information + secret
|
||||
- Time-bounding keys automatically
|
||||
- Making rainbow tables impractical
|
||||
- Working completely offline
|
||||
|
||||
## 3. Algorithm
|
||||
|
||||
### 3.1 Core Function
|
||||
|
||||
The LTHN hash is implemented in the Enchantrix library:
|
||||
|
||||
```go
|
||||
import "github.com/Snider/Enchantrix/pkg/crypt"
|
||||
|
||||
cryptService := crypt.NewService()
|
||||
lthnHash := cryptService.Hash(crypt.LTHN, input)
|
||||
```
|
||||
|
||||
**LTHN formula**:
|
||||
```
|
||||
LTHN(input) = SHA256(input || reverse_leet(input))
|
||||
```
|
||||
|
||||
Where `reverse_leet` performs bidirectional character substitution.
|
||||
|
||||
### 3.2 Reverse Leet Mapping
|
||||
|
||||
| Original | Leet | Bidirectional |
|
||||
|----------|------|---------------|
|
||||
| o | 0 | o ↔ 0 |
|
||||
| l | 1 | l ↔ 1 |
|
||||
| e | 3 | e ↔ 3 |
|
||||
| a | 4 | a ↔ 4 |
|
||||
| s | z | s ↔ z |
|
||||
| t | 7 | t ↔ 7 |
|
||||
|
||||
### 3.3 Example
|
||||
|
||||
```
|
||||
Input: "2026-01-13:license:fp"
|
||||
reverse_leet: "pf:3zn3ci1:31-10-6202"
|
||||
Combined: "2026-01-13:license:fppf:3zn3ci1:31-10-6202"
|
||||
Result: SHA256(combined) → 32-byte hash
|
||||
```
|
||||
|
||||
## 4. Stream Key Derivation
|
||||
|
||||
### 4.1 Implementation
|
||||
|
||||
```go
|
||||
// pkg/smsg/stream.go:49-60
|
||||
func DeriveStreamKey(date, license, fingerprint string) []byte {
|
||||
input := fmt.Sprintf("%s:%s:%s", date, license, fingerprint)
|
||||
cryptService := crypt.NewService()
|
||||
lthnHash := cryptService.Hash(crypt.LTHN, input)
|
||||
key := sha256.Sum256([]byte(lthnHash))
|
||||
return key[:]
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Input Format
|
||||
|
||||
```
|
||||
period:license:fingerprint
|
||||
|
||||
Where:
|
||||
- period: Time period identifier (see Cadence)
|
||||
- license: User's license key (password)
|
||||
- fingerprint: Device/browser fingerprint
|
||||
```
|
||||
|
||||
### 4.3 Output
|
||||
|
||||
32-byte key suitable for ChaCha20-Poly1305.
|
||||
|
||||
## 5. Cadence
|
||||
|
||||
### 5.1 Options
|
||||
|
||||
| Cadence | Constant | Period Format | Example | Duration |
|
||||
|---------|----------|---------------|---------|----------|
|
||||
| Daily | `CadenceDaily` | `2006-01-02` | `2026-01-13` | 24h |
|
||||
| 12-hour | `CadenceHalfDay` | `2006-01-02-AM/PM` | `2026-01-13-PM` | 12h |
|
||||
| 6-hour | `CadenceQuarter` | `2006-01-02-HH` | `2026-01-13-12` | 6h |
|
||||
| Hourly | `CadenceHourly` | `2006-01-02-HH` | `2026-01-13-15` | 1h |
|
||||
|
||||
### 5.2 Period Calculation
|
||||
|
||||
```go
|
||||
// pkg/smsg/stream.go:73-119
|
||||
func GetCurrentPeriod(cadence Cadence) string {
|
||||
return GetPeriodAt(time.Now(), cadence)
|
||||
}
|
||||
|
||||
func GetPeriodAt(t time.Time, cadence Cadence) string {
|
||||
switch cadence {
|
||||
case CadenceDaily:
|
||||
return t.Format("2006-01-02")
|
||||
case CadenceHalfDay:
|
||||
suffix := "AM"
|
||||
if t.Hour() >= 12 {
|
||||
suffix = "PM"
|
||||
}
|
||||
return t.Format("2006-01-02") + "-" + suffix
|
||||
case CadenceQuarter:
|
||||
bucket := (t.Hour() / 6) * 6
|
||||
return fmt.Sprintf("%s-%02d", t.Format("2006-01-02"), bucket)
|
||||
case CadenceHourly:
|
||||
return fmt.Sprintf("%s-%02d", t.Format("2006-01-02"), t.Hour())
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func GetNextPeriod(cadence Cadence) string {
|
||||
return GetPeriodAt(time.Now().Add(GetCadenceDuration(cadence)), cadence)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Duration Mapping
|
||||
|
||||
```go
|
||||
func GetCadenceDuration(cadence Cadence) time.Duration {
|
||||
switch cadence {
|
||||
case CadenceDaily:
|
||||
return 24 * time.Hour
|
||||
case CadenceHalfDay:
|
||||
return 12 * time.Hour
|
||||
case CadenceQuarter:
|
||||
return 6 * time.Hour
|
||||
case CadenceHourly:
|
||||
return 1 * time.Hour
|
||||
}
|
||||
return 24 * time.Hour
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Rolling Windows
|
||||
|
||||
### 6.1 Dual-Key Strategy
|
||||
|
||||
At encryption time, CEK is wrapped with **two** keys:
|
||||
1. Current period key
|
||||
2. Next period key
|
||||
|
||||
This creates a rolling validity window:
|
||||
|
||||
```
|
||||
Time: 2026-01-13 23:30 (daily cadence)
|
||||
|
||||
Valid keys:
|
||||
- "2026-01-13:license:fp" (current period)
|
||||
- "2026-01-14:license:fp" (next period)
|
||||
|
||||
Window: 24-48 hours of validity
|
||||
```
|
||||
|
||||
### 6.2 Key Wrapping
|
||||
|
||||
```go
|
||||
// pkg/smsg/stream.go:135-155
|
||||
func WrapCEK(cek []byte, streamKey []byte) (string, error) {
|
||||
sigil := enchantrix.NewChaChaPolySigil()
|
||||
wrapped, err := sigil.Seal(cek, streamKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(wrapped), nil
|
||||
}
|
||||
```
|
||||
|
||||
**Wrapped format**:
|
||||
```
|
||||
[24-byte nonce][encrypted CEK][16-byte auth tag]
|
||||
→ base64 encoded for header storage
|
||||
```
|
||||
|
||||
### 6.3 Key Unwrapping
|
||||
|
||||
```go
|
||||
// pkg/smsg/stream.go:157-170
|
||||
func UnwrapCEK(wrapped string, streamKey []byte) ([]byte, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(wrapped)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sigil := enchantrix.NewChaChaPolySigil()
|
||||
return sigil.Open(data, streamKey)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 Decryption Flow
|
||||
|
||||
```go
|
||||
// pkg/smsg/stream.go:606-633
|
||||
func UnwrapCEKFromHeader(header *V3Header, params *StreamParams) ([]byte, error) {
|
||||
// Try current period first
|
||||
currentPeriod := GetCurrentPeriod(params.Cadence)
|
||||
currentKey := DeriveStreamKey(currentPeriod, params.License, params.Fingerprint)
|
||||
|
||||
for _, wk := range header.WrappedKeys {
|
||||
cek, err := UnwrapCEK(wk.Key, currentKey)
|
||||
if err == nil {
|
||||
return cek, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try next period (for clock skew)
|
||||
nextPeriod := GetNextPeriod(params.Cadence)
|
||||
nextKey := DeriveStreamKey(nextPeriod, params.License, params.Fingerprint)
|
||||
|
||||
for _, wk := range header.WrappedKeys {
|
||||
cek, err := UnwrapCEK(wk.Key, nextKey)
|
||||
if err == nil {
|
||||
return cek, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, ErrKeyExpired
|
||||
}
|
||||
```
|
||||
|
||||
## 7. V3 Header Format
|
||||
|
||||
```go
|
||||
type V3Header struct {
|
||||
Format string `json:"format"` // "v3"
|
||||
Manifest *Manifest `json:"manifest"`
|
||||
WrappedKeys []WrappedKey `json:"wrappedKeys"`
|
||||
Chunked *ChunkInfo `json:"chunked,omitempty"`
|
||||
}
|
||||
|
||||
type WrappedKey struct {
|
||||
Period string `json:"period"` // e.g., "2026-01-13"
|
||||
Key string `json:"key"` // base64-encoded wrapped CEK
|
||||
}
|
||||
```
|
||||
|
||||
## 8. Rainbow Table Resistance
|
||||
|
||||
### 8.1 Why It Works
|
||||
|
||||
Standard hash:
|
||||
```
|
||||
SHA256("2026-01-13:license:fp") → predictable, precomputable
|
||||
```
|
||||
|
||||
LTHN hash:
|
||||
```
|
||||
LTHN("2026-01-13:license:fp")
|
||||
= SHA256("2026-01-13:license:fp" + reverse_leet("2026-01-13:license:fp"))
|
||||
= SHA256("2026-01-13:license:fp" + "pf:3zn3ci1:31-10-6202")
|
||||
```
|
||||
|
||||
The salt is **derived from the input itself**, making precomputation impractical:
|
||||
- Each unique input has a unique salt
|
||||
- Cannot build rainbow tables without knowing all possible inputs
|
||||
- Input space includes license keys (high entropy)
|
||||
|
||||
### 8.2 Security Analysis
|
||||
|
||||
| Attack | Mitigation |
|
||||
|--------|------------|
|
||||
| Rainbow tables | Input-derived salt makes precomputation infeasible |
|
||||
| Brute force | License key entropy (64+ bits recommended) |
|
||||
| Time oracle | Rolling window prevents precise timing attacks |
|
||||
| Key sharing | Keys expire within cadence window |
|
||||
|
||||
## 9. Zero-Trust Properties
|
||||
|
||||
| Property | Implementation |
|
||||
|----------|----------------|
|
||||
| No key server | Keys derived locally from LTHN |
|
||||
| Auto-expiration | Rolling periods invalidate old keys |
|
||||
| No revocation | Keys naturally expire within cadence window |
|
||||
| Device binding | Fingerprint in derivation input |
|
||||
| User binding | License key in derivation input |
|
||||
|
||||
## 10. Test Vectors
|
||||
|
||||
From `pkg/smsg/stream_test.go`:
|
||||
|
||||
```go
|
||||
// Stream key generation
|
||||
date := "2026-01-12"
|
||||
license := "test-license"
|
||||
fingerprint := "test-fp"
|
||||
key := DeriveStreamKey(date, license, fingerprint)
|
||||
// key is 32 bytes, deterministic
|
||||
|
||||
// Period calculation at 2026-01-12 15:30:00 UTC
|
||||
t := time.Date(2026, 1, 12, 15, 30, 0, 0, time.UTC)
|
||||
|
||||
GetPeriodAt(t, CadenceDaily) // "2026-01-12"
|
||||
GetPeriodAt(t, CadenceHalfDay) // "2026-01-12-PM"
|
||||
GetPeriodAt(t, CadenceQuarter) // "2026-01-12-12"
|
||||
GetPeriodAt(t, CadenceHourly) // "2026-01-12-15"
|
||||
|
||||
// Next periods
|
||||
// Daily: "2026-01-12" → "2026-01-13"
|
||||
// 12h: "2026-01-12-PM" → "2026-01-13-AM"
|
||||
// 6h: "2026-01-12-12" → "2026-01-12-18"
|
||||
// 1h: "2026-01-12-15" → "2026-01-12-16"
|
||||
```
|
||||
|
||||
## 11. Implementation Reference
|
||||
|
||||
- Stream key derivation: `pkg/smsg/stream.go`
|
||||
- LTHN hash: `github.com/Snider/Enchantrix/pkg/crypt`
|
||||
- WASM bindings: `pkg/wasm/stmf/main.go` (decryptV3, unwrapCEK)
|
||||
- Tests: `pkg/smsg/stream_test.go`
|
||||
|
||||
## 12. Security Considerations
|
||||
|
||||
1. **License entropy**: Recommend 64+ bits (12+ alphanumeric chars)
|
||||
2. **Fingerprint stability**: Should be stable but not user-controllable
|
||||
3. **Clock skew**: Rolling windows handle ±1 period drift
|
||||
4. **Key exposure**: Derived keys valid only for one period
|
||||
|
||||
## 13. References
|
||||
|
||||
- RFC-002: SMSG Format (v3 streaming)
|
||||
- RFC-001: OSS DRM (Section 3.4)
|
||||
- RFC 8439: ChaCha20-Poly1305
|
||||
- Enchantrix: github.com/Snider/Enchantrix
|
||||
255
rfc/RFC-008-BORGFILE.md
Normal file
255
rfc/RFC-008-BORGFILE.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# RFC-008: Borgfile Compilation
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: [Snider](https://github.com/Snider/)
|
||||
**Created**: 2026-01-13
|
||||
**License**: EUPL-1.2
|
||||
**Depends On**: RFC-003, RFC-004
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
Borgfile is a declarative syntax for defining TIM container contents. It specifies how local files are mapped into the container filesystem, enabling reproducible container builds.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
Borgfile provides:
|
||||
- Dockerfile-like syntax for familiarity
|
||||
- File mapping into containers
|
||||
- Simple ADD directive
|
||||
- Integration with TIM encryption
|
||||
|
||||
## 2. File Format
|
||||
|
||||
### 2.1 Location
|
||||
|
||||
- Default: `Borgfile` in current directory
|
||||
- Override: `borg compile -f path/to/Borgfile`
|
||||
|
||||
### 2.2 Encoding
|
||||
|
||||
- UTF-8 text
|
||||
- Unix line endings (LF)
|
||||
- No BOM
|
||||
|
||||
## 3. Syntax
|
||||
|
||||
### 3.1 Parsing Implementation
|
||||
|
||||
```go
|
||||
// cmd/compile.go:33-54
|
||||
lines := strings.Split(content, "\n")
|
||||
for _, line := range lines {
|
||||
parts := strings.Fields(line) // Whitespace-separated tokens
|
||||
if len(parts) == 0 {
|
||||
continue // Skip empty lines
|
||||
}
|
||||
switch parts[0] {
|
||||
case "ADD":
|
||||
// Process ADD directive
|
||||
default:
|
||||
return fmt.Errorf("unknown instruction: %s", parts[0])
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 ADD Directive
|
||||
|
||||
```
|
||||
ADD <source> <destination>
|
||||
```
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| source | Local path (relative to current working directory) |
|
||||
| destination | Container path (leading slash stripped) |
|
||||
|
||||
### 3.3 Examples
|
||||
|
||||
```dockerfile
|
||||
# Add single file
|
||||
ADD ./app /usr/local/bin/app
|
||||
|
||||
# Add configuration
|
||||
ADD ./config.yaml /etc/myapp/config.yaml
|
||||
|
||||
# Multiple files
|
||||
ADD ./bin/server /app/server
|
||||
ADD ./static /app/static
|
||||
```
|
||||
|
||||
## 4. Path Resolution
|
||||
|
||||
### 4.1 Source Paths
|
||||
|
||||
- Resolved relative to **current working directory** (not Borgfile location)
|
||||
- Must exist at compile time
|
||||
- Read via `os.ReadFile(src)`
|
||||
|
||||
### 4.2 Destination Paths
|
||||
|
||||
- Leading slash stripped: `strings.TrimPrefix(dest, "/")`
|
||||
- Added to DataNode as-is
|
||||
|
||||
```go
|
||||
// cmd/compile.go:46-50
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid ADD instruction: %s", line)
|
||||
}
|
||||
name := strings.TrimPrefix(dest, "/")
|
||||
m.RootFS.AddData(name, data)
|
||||
```
|
||||
|
||||
## 5. File Handling
|
||||
|
||||
### 5.1 Permissions
|
||||
|
||||
**Current implementation**: Permissions are NOT preserved.
|
||||
|
||||
| Source | Container |
|
||||
|--------|-----------|
|
||||
| Any file | 0600 (hardcoded in DataNode.ToTar) |
|
||||
| Any directory | 0755 (implicit) |
|
||||
|
||||
### 5.2 Timestamps
|
||||
|
||||
- Set to `time.Now()` when added to DataNode
|
||||
- Original timestamps not preserved
|
||||
|
||||
### 5.3 File Types
|
||||
|
||||
- Regular files only
|
||||
- No directory recursion (each file must be added explicitly)
|
||||
- No symlink following
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
| Error | Cause |
|
||||
|-------|-------|
|
||||
| `invalid ADD instruction: {line}` | Wrong number of arguments |
|
||||
| `os.ReadFile` error | Source file not found |
|
||||
| `unknown instruction: {name}` | Unrecognized directive |
|
||||
| `ErrPasswordRequired` | Encryption requested without password |
|
||||
|
||||
## 7. CLI Flags
|
||||
|
||||
```go
|
||||
// cmd/compile.go:80-82
|
||||
-f, --file string Path to Borgfile (default: "Borgfile")
|
||||
-o, --output string Output path (default: "a.tim")
|
||||
-e, --encrypt string Password for .stim encryption (optional)
|
||||
```
|
||||
|
||||
## 8. Output Formats
|
||||
|
||||
### 8.1 Plain TIM
|
||||
|
||||
```bash
|
||||
borg compile -f Borgfile -o container.tim
|
||||
```
|
||||
|
||||
Output: Standard TIM tar archive with `config.json` + `rootfs/`
|
||||
|
||||
### 8.2 Encrypted STIM
|
||||
|
||||
```bash
|
||||
borg compile -f Borgfile -e "password" -o container.stim
|
||||
```
|
||||
|
||||
Output: ChaCha20-Poly1305 encrypted STIM container
|
||||
|
||||
**Auto-detection**: If `-e` flag provided, output automatically uses `.stim` format even if `-o` specifies `.tim`.
|
||||
|
||||
## 9. Default OCI Config
|
||||
|
||||
The current implementation creates a minimal config:
|
||||
|
||||
```go
|
||||
// pkg/tim/config.go:6-10
|
||||
func defaultConfig() (*trix.Trix, error) {
|
||||
return &trix.Trix{Header: make(map[string]interface{})}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Note**: This is a placeholder. For full OCI runtime execution, you'll need to provide a proper `config.json` in the container or modify the TIM after compilation.
|
||||
|
||||
## 10. Compilation Process
|
||||
|
||||
```
|
||||
1. Read Borgfile content
|
||||
2. Parse line-by-line
|
||||
3. For each ADD directive:
|
||||
a. Read source file from filesystem
|
||||
b. Strip leading slash from destination
|
||||
c. Add to DataNode
|
||||
4. Create TIM with default config + populated RootFS
|
||||
5. If password provided:
|
||||
a. Encrypt to STIM via ToSigil()
|
||||
b. Adjust output extension to .stim
|
||||
6. Write output file
|
||||
```
|
||||
|
||||
## 11. Implementation Reference
|
||||
|
||||
- Parser/Compiler: `cmd/compile.go`
|
||||
- TIM creation: `pkg/tim/tim.go`
|
||||
- DataNode: `pkg/datanode/datanode.go`
|
||||
- Tests: `cmd/compile_test.go`
|
||||
|
||||
## 12. Current Limitations
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Comment support (`#`) | Not implemented |
|
||||
| Quoted paths | Not implemented |
|
||||
| Directory recursion | Not implemented |
|
||||
| Permission preservation | Not implemented |
|
||||
| Path resolution relative to Borgfile | Not implemented (uses CWD) |
|
||||
| Full OCI config generation | Not implemented (empty header) |
|
||||
| Symlink following | Not implemented |
|
||||
|
||||
## 13. Examples
|
||||
|
||||
### 13.1 Simple Application
|
||||
|
||||
```dockerfile
|
||||
ADD ./myapp /usr/local/bin/myapp
|
||||
ADD ./config.yaml /etc/myapp/config.yaml
|
||||
```
|
||||
|
||||
### 13.2 Web Application
|
||||
|
||||
```dockerfile
|
||||
ADD ./server /app/server
|
||||
ADD ./index.html /app/static/index.html
|
||||
ADD ./style.css /app/static/style.css
|
||||
ADD ./app.js /app/static/app.js
|
||||
```
|
||||
|
||||
### 13.3 With Encryption
|
||||
|
||||
```bash
|
||||
# Create Borgfile
|
||||
cat > Borgfile << 'EOF'
|
||||
ADD ./secret-app /app/secret-app
|
||||
ADD ./credentials.json /etc/app/credentials.json
|
||||
EOF
|
||||
|
||||
# Compile with encryption
|
||||
borg compile -f Borgfile -e "MySecretPassword123" -o secret.stim
|
||||
```
|
||||
|
||||
## 14. Future Work
|
||||
|
||||
- [ ] Comment support (`#`)
|
||||
- [ ] Quoted path support for spaces
|
||||
- [ ] Directory recursion in ADD
|
||||
- [ ] Permission preservation
|
||||
- [ ] Path resolution relative to Borgfile location
|
||||
- [ ] Full OCI config generation
|
||||
- [ ] Variable substitution (`${VAR}`)
|
||||
- [ ] Include directive
|
||||
- [ ] Glob patterns in source
|
||||
- [ ] COPY directive (alias for ADD)
|
||||
365
rfc/RFC-009-STMF.md
Normal file
365
rfc/RFC-009-STMF.md
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
# RFC-009: STMF Secure To-Me Form
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: [Snider](https://github.com/Snider/)
|
||||
**Created**: 2026-01-13
|
||||
**License**: EUPL-1.2
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
STMF (Secure To-Me Form) provides asymmetric encryption for web form submissions. It enables end-to-end encrypted form data where only the recipient can decrypt submissions, protecting sensitive data from server compromise.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
STMF provides:
|
||||
- Asymmetric encryption for form data
|
||||
- X25519 key exchange
|
||||
- ChaCha20-Poly1305 for payload encryption
|
||||
- Browser-based encryption via WASM
|
||||
- HTTP middleware for server-side decryption
|
||||
|
||||
## 2. Cryptographic Primitives
|
||||
|
||||
### 2.1 Key Exchange
|
||||
|
||||
X25519 (Curve25519 Diffie-Hellman)
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Private key | 32 bytes |
|
||||
| Public key | 32 bytes |
|
||||
| Shared secret | 32 bytes |
|
||||
|
||||
### 2.2 Encryption
|
||||
|
||||
ChaCha20-Poly1305
|
||||
|
||||
| Parameter | Value |
|
||||
|-----------|-------|
|
||||
| Key | 32 bytes (SHA-256 of shared secret) |
|
||||
| Nonce | 24 bytes (XChaCha variant) |
|
||||
| Tag | 16 bytes |
|
||||
|
||||
## 3. Protocol
|
||||
|
||||
### 3.1 Setup (One-time)
|
||||
|
||||
```
|
||||
Recipient (Server):
|
||||
1. Generate X25519 keypair
|
||||
2. Publish public key (embed in page or API)
|
||||
3. Store private key securely
|
||||
```
|
||||
|
||||
### 3.2 Encryption Flow (Browser)
|
||||
|
||||
```
|
||||
1. Fetch recipient's public key
|
||||
2. Generate ephemeral X25519 keypair
|
||||
3. Compute shared secret: X25519(ephemeral_private, recipient_public)
|
||||
4. Derive encryption key: SHA256(shared_secret)
|
||||
5. Encrypt form data: ChaCha20-Poly1305(data, key, random_nonce)
|
||||
6. Send: {ephemeral_public, nonce, ciphertext}
|
||||
```
|
||||
|
||||
### 3.3 Decryption Flow (Server)
|
||||
|
||||
```
|
||||
1. Receive {ephemeral_public, nonce, ciphertext}
|
||||
2. Compute shared secret: X25519(recipient_private, ephemeral_public)
|
||||
3. Derive encryption key: SHA256(shared_secret)
|
||||
4. Decrypt: ChaCha20-Poly1305_Open(ciphertext, key, nonce)
|
||||
```
|
||||
|
||||
## 4. Wire Format
|
||||
|
||||
### 4.1 Container (Trix-based)
|
||||
|
||||
```
|
||||
[Magic: "STMF" (4 bytes)]
|
||||
[Header: Gob-encoded JSON]
|
||||
[Payload: ChaCha20-Poly1305 ciphertext]
|
||||
```
|
||||
|
||||
### 4.2 Header Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"algorithm": "x25519-chacha20poly1305",
|
||||
"ephemeral_pk": "<base64 32-byte ephemeral public key>"
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Transmission
|
||||
|
||||
- Default form field: `_stmf_payload`
|
||||
- Encoding: Base64 string
|
||||
- Content-Type: `application/x-www-form-urlencoded` or `multipart/form-data`
|
||||
|
||||
## 5. Data Structures
|
||||
|
||||
### 5.1 FormField
|
||||
|
||||
```go
|
||||
type FormField struct {
|
||||
Name string // Field name
|
||||
Value string // Base64 for files, plaintext otherwise
|
||||
Type string // "text", "password", "file"
|
||||
Filename string // For file uploads
|
||||
MimeType string // For file uploads
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 FormData
|
||||
|
||||
```go
|
||||
type FormData struct {
|
||||
Fields []FormField // Array of form fields
|
||||
Metadata map[string]string // Arbitrary key-value metadata
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Builder Pattern
|
||||
|
||||
```go
|
||||
formData := NewFormData().
|
||||
AddField("email", "user@example.com").
|
||||
AddFieldWithType("password", "secret", "password").
|
||||
AddFile("document", base64Content, "report.pdf", "application/pdf").
|
||||
SetMetadata("timestamp", time.Now().String())
|
||||
```
|
||||
|
||||
## 6. Key Management API
|
||||
|
||||
### 6.1 Key Generation
|
||||
|
||||
```go
|
||||
// pkg/stmf/keypair.go
|
||||
func GenerateKeyPair() (*KeyPair, error)
|
||||
|
||||
type KeyPair struct {
|
||||
privateKey *ecdh.PrivateKey
|
||||
publicKey *ecdh.PublicKey
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Key Loading
|
||||
|
||||
```go
|
||||
// From raw bytes
|
||||
func LoadPublicKey(data []byte) (*ecdh.PublicKey, error)
|
||||
func LoadPrivateKey(data []byte) (*ecdh.PrivateKey, error)
|
||||
|
||||
// From base64
|
||||
func LoadPublicKeyBase64(encoded string) (*ecdh.PublicKey, error)
|
||||
func LoadPrivateKeyBase64(encoded string) (*ecdh.PrivateKey, error)
|
||||
|
||||
// Reconstruct keypair from private key
|
||||
func LoadKeyPair(privateKeyBytes []byte) (*KeyPair, error)
|
||||
```
|
||||
|
||||
### 6.3 Key Export
|
||||
|
||||
```go
|
||||
func (kp *KeyPair) PublicKey() []byte // Raw 32 bytes
|
||||
func (kp *KeyPair) PrivateKey() []byte // Raw 32 bytes
|
||||
func (kp *KeyPair) PublicKeyBase64() string // Base64 encoded
|
||||
func (kp *KeyPair) PrivateKeyBase64() string // Base64 encoded
|
||||
```
|
||||
|
||||
## 7. WASM API
|
||||
|
||||
### 7.1 BorgSTMF Namespace
|
||||
|
||||
```javascript
|
||||
// Generate X25519 keypair
|
||||
const keypair = await BorgSTMF.generateKeyPair();
|
||||
// keypair.publicKey: base64 string
|
||||
// keypair.privateKey: base64 string
|
||||
|
||||
// Encrypt form data
|
||||
const encrypted = await BorgSTMF.encrypt(
|
||||
JSON.stringify(formData),
|
||||
serverPublicKeyBase64
|
||||
);
|
||||
|
||||
// Encrypt with field-level control
|
||||
const encrypted = await BorgSTMF.encryptFields(
|
||||
{email: "user@example.com", password: "secret"},
|
||||
serverPublicKeyBase64,
|
||||
{timestamp: Date.now().toString()} // Optional metadata
|
||||
);
|
||||
```
|
||||
|
||||
## 8. HTTP Middleware
|
||||
|
||||
### 8.1 Simple Usage
|
||||
|
||||
```go
|
||||
import "github.com/Snider/Borg/pkg/stmf/middleware"
|
||||
|
||||
// Create middleware with private key
|
||||
mw := middleware.Simple(privateKeyBytes)
|
||||
|
||||
// Or from base64
|
||||
mw, err := middleware.SimpleBase64(privateKeyB64)
|
||||
|
||||
// Apply to handler
|
||||
http.Handle("/submit", mw(myHandler))
|
||||
```
|
||||
|
||||
### 8.2 Advanced Configuration
|
||||
|
||||
```go
|
||||
cfg := middleware.DefaultConfig(privateKeyBytes)
|
||||
cfg.FieldName = "_custom_field" // Custom field name (default: _stmf_payload)
|
||||
cfg.PopulateForm = &true // Auto-populate r.Form
|
||||
cfg.OnError = customErrorHandler // Custom error handling
|
||||
cfg.OnMissingPayload = customHandler // When field is absent
|
||||
|
||||
mw := middleware.Middleware(cfg)
|
||||
```
|
||||
|
||||
### 8.3 Context Access
|
||||
|
||||
```go
|
||||
func myHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Get decrypted form data
|
||||
formData := middleware.GetFormData(r)
|
||||
|
||||
// Get metadata
|
||||
metadata := middleware.GetMetadata(r)
|
||||
|
||||
// Access fields
|
||||
email := formData.Get("email")
|
||||
password := formData.Get("password")
|
||||
}
|
||||
```
|
||||
|
||||
### 8.4 Middleware Behavior
|
||||
|
||||
- Handles POST, PUT, PATCH requests only
|
||||
- Parses multipart/form-data (32 MB limit) or application/x-www-form-urlencoded
|
||||
- Looks for field `_stmf_payload` (configurable)
|
||||
- Base64 decodes, then decrypts
|
||||
- Populates `r.Form` and `r.PostForm` with decrypted fields
|
||||
- Returns 400 Bad Request on decryption failure
|
||||
|
||||
## 9. Integration Example
|
||||
|
||||
### 9.1 HTML Form
|
||||
|
||||
```html
|
||||
<form id="secure-form" data-stmf-pubkey="<base64-public-key>">
|
||||
<input name="name" type="text">
|
||||
<input name="email" type="email">
|
||||
<input name="ssn" type="password">
|
||||
<button type="submit">Send Securely</button>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
document.getElementById('secure-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target;
|
||||
const pubkey = form.dataset.stmfPubkey;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData);
|
||||
|
||||
const encrypted = await BorgSTMF.encrypt(JSON.stringify(data), pubkey);
|
||||
|
||||
await fetch('/api/submit', {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({_stmf_payload: encrypted}),
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 9.2 Server Handler
|
||||
|
||||
```go
|
||||
func main() {
|
||||
privateKey, _ := os.ReadFile("private.key")
|
||||
mw := middleware.Simple(privateKey)
|
||||
|
||||
http.Handle("/api/submit", mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
formData := middleware.GetFormData(r)
|
||||
|
||||
name := formData.Get("name")
|
||||
email := formData.Get("email")
|
||||
ssn := formData.Get("ssn")
|
||||
|
||||
// Process securely...
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})))
|
||||
|
||||
http.ListenAndServeTLS(":443", "cert.pem", "key.pem", nil)
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Security Properties
|
||||
|
||||
### 10.1 Forward Secrecy
|
||||
|
||||
- Fresh ephemeral keypair per encryption
|
||||
- Compromised private key doesn't decrypt past messages
|
||||
- Each ciphertext has unique shared secret
|
||||
|
||||
### 10.2 Authenticity
|
||||
|
||||
- Poly1305 MAC prevents tampering
|
||||
- Decryption fails if ciphertext modified
|
||||
|
||||
### 10.3 Confidentiality
|
||||
|
||||
- ChaCha20 provides 256-bit security
|
||||
- Nonces are random (24 bytes), collision unlikely
|
||||
- Data encrypted before leaving browser
|
||||
|
||||
### 10.4 Key Isolation
|
||||
|
||||
- Private key never exposed to browser/JavaScript
|
||||
- Public key can be safely distributed
|
||||
- Ephemeral keys discarded after encryption
|
||||
|
||||
## 11. Error Handling
|
||||
|
||||
```go
|
||||
var (
|
||||
ErrInvalidMagic = errors.New("invalid STMF magic")
|
||||
ErrInvalidPayload = errors.New("invalid STMF payload")
|
||||
ErrDecryptionFailed = errors.New("decryption failed")
|
||||
ErrInvalidPublicKey = errors.New("invalid public key")
|
||||
ErrInvalidPrivateKey = errors.New("invalid private key")
|
||||
ErrKeyGenerationFailed = errors.New("key generation failed")
|
||||
)
|
||||
```
|
||||
|
||||
## 12. Implementation Reference
|
||||
|
||||
- Types: `pkg/stmf/types.go`
|
||||
- Key management: `pkg/stmf/keypair.go`
|
||||
- Encryption: `pkg/stmf/encrypt.go`
|
||||
- Decryption: `pkg/stmf/decrypt.go`
|
||||
- Middleware: `pkg/stmf/middleware/http.go`
|
||||
- WASM: `pkg/wasm/stmf/main.go`
|
||||
|
||||
## 13. Security Considerations
|
||||
|
||||
1. **Public key authenticity**: Verify public key source (HTTPS, pinning)
|
||||
2. **Private key protection**: Never expose to browser, store securely
|
||||
3. **Nonce uniqueness**: Random generation ensures uniqueness
|
||||
4. **HTTPS required**: Transport layer must be encrypted
|
||||
|
||||
## 14. Future Work
|
||||
|
||||
- [ ] Multiple recipients
|
||||
- [ ] Key attestation
|
||||
- [ ] Offline decryption app
|
||||
- [ ] Hardware key support (WebAuthn)
|
||||
- [ ] Key rotation support
|
||||
458
rfc/RFC-010-WASM-API.md
Normal file
458
rfc/RFC-010-WASM-API.md
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
# RFC-010: WASM Decryption API
|
||||
|
||||
**Status**: Draft
|
||||
**Author**: [Snider](https://github.com/Snider/)
|
||||
**Created**: 2026-01-13
|
||||
**License**: EUPL-1.2
|
||||
**Depends On**: RFC-002, RFC-007, RFC-009
|
||||
|
||||
---
|
||||
|
||||
## Abstract
|
||||
|
||||
This RFC specifies the WebAssembly (WASM) API for browser-based decryption of SMSG content and STMF form encryption. The API is exposed through two JavaScript namespaces: `BorgSMSG` for content decryption and `BorgSTMF` for form encryption.
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The WASM module provides:
|
||||
- SMSG decryption (v1, v2, v3, chunked, ABR)
|
||||
- SMSG encryption
|
||||
- STMF form encryption/decryption
|
||||
- Metadata extraction without decryption
|
||||
|
||||
## 2. Module Loading
|
||||
|
||||
### 2.1 Files Required
|
||||
|
||||
```
|
||||
stmf.wasm (~5.9MB) Compiled Go WASM module
|
||||
wasm_exec.js (~20KB) Go WASM runtime
|
||||
```
|
||||
|
||||
### 2.2 Initialization
|
||||
|
||||
```html
|
||||
<script src="wasm_exec.js"></script>
|
||||
<script>
|
||||
const go = new Go();
|
||||
WebAssembly.instantiateStreaming(fetch('stmf.wasm'), go.importObject)
|
||||
.then(result => {
|
||||
go.run(result.instance);
|
||||
// BorgSMSG and BorgSTMF now available globally
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### 2.3 Ready Event
|
||||
|
||||
```javascript
|
||||
document.addEventListener('borgstmf:ready', (event) => {
|
||||
console.log('WASM ready, version:', event.detail.version);
|
||||
});
|
||||
```
|
||||
|
||||
## 3. BorgSMSG Namespace
|
||||
|
||||
### 3.1 Version
|
||||
|
||||
```javascript
|
||||
BorgSMSG.version // "1.6.0"
|
||||
BorgSMSG.ready // true when loaded
|
||||
```
|
||||
|
||||
### 3.2 Metadata Functions
|
||||
|
||||
#### getInfo(base64) → Promise<ManifestInfo>
|
||||
|
||||
Get manifest without decryption.
|
||||
|
||||
```javascript
|
||||
const info = await BorgSMSG.getInfo(base64Content);
|
||||
// info.version, info.algorithm, info.format
|
||||
// info.manifest.title, info.manifest.artist
|
||||
// info.isV3Streaming, info.isChunked
|
||||
// info.wrappedKeys (for v3)
|
||||
```
|
||||
|
||||
#### getInfoBinary(uint8Array) → Promise<ManifestInfo>
|
||||
|
||||
Binary input variant (no base64 decode needed).
|
||||
|
||||
```javascript
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const info = await BorgSMSG.getInfoBinary(bytes);
|
||||
```
|
||||
|
||||
### 3.3 Decryption Functions
|
||||
|
||||
#### decrypt(base64, password) → Promise<Message>
|
||||
|
||||
Full decryption (v1 format, base64 attachments).
|
||||
|
||||
```javascript
|
||||
const msg = await BorgSMSG.decrypt(base64Content, password);
|
||||
// msg.body, msg.subject, msg.from
|
||||
// msg.attachments[0].name, .content (base64), .mime
|
||||
```
|
||||
|
||||
#### decryptStream(base64, password) → Promise<StreamMessage>
|
||||
|
||||
Streaming decryption (v2 format, binary attachments).
|
||||
|
||||
```javascript
|
||||
const msg = await BorgSMSG.decryptStream(base64Content, password);
|
||||
// msg.attachments[0].data (Uint8Array)
|
||||
// msg.attachments[0].mime
|
||||
```
|
||||
|
||||
#### decryptBinary(uint8Array, password) → Promise<StreamMessage>
|
||||
|
||||
Binary input, binary output.
|
||||
|
||||
```javascript
|
||||
const bytes = new Uint8Array(await fetch(url).then(r => r.arrayBuffer()));
|
||||
const msg = await BorgSMSG.decryptBinary(bytes, password);
|
||||
```
|
||||
|
||||
#### quickDecrypt(base64, password) → Promise<string>
|
||||
|
||||
Returns body text only (fast path).
|
||||
|
||||
```javascript
|
||||
const body = await BorgSMSG.quickDecrypt(base64Content, password);
|
||||
```
|
||||
|
||||
### 3.4 V3 Streaming Functions
|
||||
|
||||
#### decryptV3(base64, params) → Promise<StreamMessage>
|
||||
|
||||
Decrypt v3 streaming content with LTHN rolling keys.
|
||||
|
||||
```javascript
|
||||
const msg = await BorgSMSG.decryptV3(base64Content, {
|
||||
license: "user-license-key",
|
||||
fingerprint: "device-fingerprint" // optional
|
||||
});
|
||||
```
|
||||
|
||||
#### getV3ChunkInfo(base64) → Promise<ChunkInfo>
|
||||
|
||||
Get chunk index for seeking without full decrypt.
|
||||
|
||||
```javascript
|
||||
const chunkInfo = await BorgSMSG.getV3ChunkInfo(base64Content);
|
||||
// chunkInfo.chunkSize (default 1MB)
|
||||
// chunkInfo.totalChunks
|
||||
// chunkInfo.totalSize
|
||||
// chunkInfo.index[i].offset, .size
|
||||
```
|
||||
|
||||
#### unwrapV3CEK(base64, params) → Promise<string>
|
||||
|
||||
Unwrap CEK for manual chunk decryption. Returns base64 CEK.
|
||||
|
||||
```javascript
|
||||
const cekBase64 = await BorgSMSG.unwrapV3CEK(base64Content, {
|
||||
license: "license",
|
||||
fingerprint: "fp"
|
||||
});
|
||||
```
|
||||
|
||||
#### decryptV3Chunk(base64, cekBase64, chunkIndex) → Promise<Uint8Array>
|
||||
|
||||
Decrypt single chunk by index.
|
||||
|
||||
```javascript
|
||||
const chunk = await BorgSMSG.decryptV3Chunk(base64Content, cekBase64, 5);
|
||||
```
|
||||
|
||||
#### parseV3Header(uint8Array) → Promise<V3HeaderInfo>
|
||||
|
||||
Parse header from partial data (for streaming).
|
||||
|
||||
```javascript
|
||||
const header = await BorgSMSG.parseV3Header(bytes);
|
||||
// header.format, header.keyMethod, header.cadence
|
||||
// header.payloadOffset (where chunks start)
|
||||
// header.wrappedKeys, header.chunked, header.manifest
|
||||
```
|
||||
|
||||
#### unwrapCEKFromHeader(wrappedKeys, params, cadence) → Promise<Uint8Array>
|
||||
|
||||
Unwrap CEK from parsed header.
|
||||
|
||||
```javascript
|
||||
const cek = await BorgSMSG.unwrapCEKFromHeader(
|
||||
header.wrappedKeys,
|
||||
{license: "lic", fingerprint: "fp"},
|
||||
"daily"
|
||||
);
|
||||
```
|
||||
|
||||
#### decryptChunkDirect(chunkBytes, cek) → Promise<Uint8Array>
|
||||
|
||||
Low-level chunk decryption with pre-unwrapped CEK.
|
||||
|
||||
```javascript
|
||||
const plaintext = await BorgSMSG.decryptChunkDirect(chunkBytes, cek);
|
||||
```
|
||||
|
||||
### 3.5 Encryption Functions
|
||||
|
||||
#### encrypt(message, password, hint?) → Promise<string>
|
||||
|
||||
Encrypt message (v1 format). Returns base64.
|
||||
|
||||
```javascript
|
||||
const encrypted = await BorgSMSG.encrypt({
|
||||
body: "Hello",
|
||||
attachments: [{
|
||||
name: "file.txt",
|
||||
content: btoa("data"),
|
||||
mime: "text/plain"
|
||||
}]
|
||||
}, password, "optional hint");
|
||||
```
|
||||
|
||||
#### encryptWithManifest(message, password, manifest) → Promise<string>
|
||||
|
||||
Encrypt with manifest (v2 format). Returns base64.
|
||||
|
||||
```javascript
|
||||
const encrypted = await BorgSMSG.encryptWithManifest(message, password, {
|
||||
title: "My Track",
|
||||
artist: "Artist Name",
|
||||
licenseType: "perpetual"
|
||||
});
|
||||
```
|
||||
|
||||
### 3.6 ABR Functions
|
||||
|
||||
#### parseABRManifest(jsonString) → Promise<ABRManifest>
|
||||
|
||||
Parse HLS-style ABR manifest.
|
||||
|
||||
```javascript
|
||||
const manifest = await BorgSMSG.parseABRManifest(manifestJson);
|
||||
// manifest.version, manifest.title, manifest.duration
|
||||
// manifest.variants[i].name, .bandwidth, .url
|
||||
// manifest.defaultIdx
|
||||
```
|
||||
|
||||
#### selectVariant(manifest, bandwidthBps) → Promise<number>
|
||||
|
||||
Select best variant for bandwidth (returns index).
|
||||
|
||||
```javascript
|
||||
const idx = await BorgSMSG.selectVariant(manifest, measuredBandwidth);
|
||||
// Uses 80% safety threshold
|
||||
```
|
||||
|
||||
## 4. BorgSTMF Namespace
|
||||
|
||||
### 4.1 Key Generation
|
||||
|
||||
```javascript
|
||||
const keypair = await BorgSTMF.generateKeyPair();
|
||||
// keypair.publicKey (base64 X25519)
|
||||
// keypair.privateKey (base64 X25519) - KEEP SECRET
|
||||
```
|
||||
|
||||
### 4.2 Encryption
|
||||
|
||||
```javascript
|
||||
// Encrypt JSON string
|
||||
const encrypted = await BorgSTMF.encrypt(
|
||||
JSON.stringify(formData),
|
||||
serverPublicKeyBase64
|
||||
);
|
||||
|
||||
// Encrypt with metadata
|
||||
const encrypted = await BorgSTMF.encryptFields(
|
||||
{email: "user@example.com", password: "secret"},
|
||||
serverPublicKeyBase64,
|
||||
{timestamp: Date.now().toString()} // optional metadata
|
||||
);
|
||||
```
|
||||
|
||||
## 5. Type Definitions
|
||||
|
||||
### 5.1 ManifestInfo
|
||||
|
||||
```typescript
|
||||
interface ManifestInfo {
|
||||
version: string;
|
||||
algorithm: string;
|
||||
format?: string;
|
||||
compression?: string;
|
||||
hint?: string;
|
||||
keyMethod?: string; // "LTHN" for v3
|
||||
cadence?: string; // "daily", "12h", "6h", "1h"
|
||||
wrappedKeys?: WrappedKey[];
|
||||
isV3Streaming: boolean;
|
||||
chunked?: ChunkInfo;
|
||||
isChunked: boolean;
|
||||
manifest?: Manifest;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 Message / StreamMessage
|
||||
|
||||
```typescript
|
||||
interface Message {
|
||||
from?: string;
|
||||
to?: string;
|
||||
subject?: string;
|
||||
body: string;
|
||||
timestamp?: number;
|
||||
attachments: Attachment[];
|
||||
replyKey?: KeyInfo;
|
||||
meta?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Attachment {
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
content?: string; // base64 (v1)
|
||||
data?: Uint8Array; // binary (v2/v3)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 ChunkInfo
|
||||
|
||||
```typescript
|
||||
interface ChunkInfo {
|
||||
chunkSize: number; // default 1048576 (1MB)
|
||||
totalChunks: number;
|
||||
totalSize: number;
|
||||
index: ChunkEntry[];
|
||||
}
|
||||
|
||||
interface ChunkEntry {
|
||||
offset: number;
|
||||
size: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Manifest
|
||||
|
||||
```typescript
|
||||
interface Manifest {
|
||||
title: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
genre?: string;
|
||||
year?: number;
|
||||
releaseType?: string; // "single", "album", "ep", "mix"
|
||||
duration?: number; // seconds
|
||||
format?: string;
|
||||
expiresAt?: number; // Unix timestamp
|
||||
issuedAt?: number; // Unix timestamp
|
||||
licenseType?: string; // "perpetual", "rental", "stream", "preview"
|
||||
tracks?: Track[];
|
||||
tags?: string[];
|
||||
links?: Record<string, string>;
|
||||
extra?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Error Handling
|
||||
|
||||
### 6.1 Pattern
|
||||
|
||||
All functions throw on error:
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const msg = await BorgSMSG.decrypt(content, password);
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Common Errors
|
||||
|
||||
| Error | Cause |
|
||||
|-------|-------|
|
||||
| `decrypt requires 2 arguments` | Wrong argument count |
|
||||
| `decryption failed: {reason}` | Wrong password or corrupted |
|
||||
| `invalid format` | Not a valid SMSG file |
|
||||
| `unsupported version` | Unknown format version |
|
||||
| `key expired` | v3 rolling key outside window |
|
||||
| `invalid base64: {reason}` | Base64 decode failed |
|
||||
| `chunk out of range` | Invalid chunk index |
|
||||
|
||||
## 7. Performance
|
||||
|
||||
### 7.1 Binary vs Base64
|
||||
|
||||
- Binary functions (`*Binary`, `decryptStream`) are ~30% faster
|
||||
- Avoid double base64 encoding
|
||||
|
||||
### 7.2 Large Files (>50MB)
|
||||
|
||||
Use chunked streaming:
|
||||
|
||||
```javascript
|
||||
// Efficient: Cache CEK, stream chunks
|
||||
const header = await BorgSMSG.parseV3Header(bytes);
|
||||
const cek = await BorgSMSG.unwrapCEKFromHeader(header.wrappedKeys, params);
|
||||
|
||||
for (let i = 0; i < header.chunked.totalChunks; i++) {
|
||||
const chunk = await BorgSMSG.decryptChunkDirect(payload, cek);
|
||||
player.write(chunk);
|
||||
// chunk is GC'd after each iteration
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 Typical Execution Times
|
||||
|
||||
| Operation | Size | Time |
|
||||
|-----------|------|------|
|
||||
| getInfo | any | ~50-100ms |
|
||||
| decrypt (small) | <1MB | ~200-500ms |
|
||||
| decrypt (large) | 100MB | 2-5s |
|
||||
| decryptV3Chunk | 1MB | ~200-400ms |
|
||||
| generateKeyPair | - | ~50-200ms |
|
||||
|
||||
## 8. Browser Compatibility
|
||||
|
||||
| Browser | Support |
|
||||
|---------|---------|
|
||||
| Chrome 57+ | Full |
|
||||
| Firefox 52+ | Full |
|
||||
| Safari 11+ | Full |
|
||||
| Edge 16+ | Full |
|
||||
| IE | Not supported |
|
||||
|
||||
Requirements:
|
||||
- WebAssembly support
|
||||
- Async/await (ES2017)
|
||||
- Uint8Array
|
||||
|
||||
## 9. Memory Management
|
||||
|
||||
- WASM module: ~5.9MB static
|
||||
- Per-operation: Peak ~2-3x file size during decryption
|
||||
- Go GC reclaims after Promise resolution
|
||||
- Keys never leave WASM memory
|
||||
|
||||
## 10. Implementation Reference
|
||||
|
||||
- Source: `pkg/wasm/stmf/main.go` (1758 lines)
|
||||
- Build: `GOOS=js GOARCH=wasm go build -o stmf.wasm ./pkg/wasm/stmf/`
|
||||
|
||||
## 11. Security Considerations
|
||||
|
||||
1. **Password handling**: Clear from memory after use
|
||||
2. **Memory isolation**: WASM sandbox prevents JS access
|
||||
3. **Constant-time crypto**: Go crypto uses safe operations
|
||||
4. **Key protection**: Keys never exposed to JavaScript
|
||||
|
||||
## 12. Future Work
|
||||
|
||||
- [ ] WebWorker support for background decryption
|
||||
- [ ] Streaming API with ReadableStream
|
||||
- [ ] Smaller WASM size via TinyGo
|
||||
- [ ] Native Web Crypto fallback for simple operations
|
||||
Loading…
Add table
Reference in a new issue