Compare commits

...

23 commits
v0.0.1 ... main

Author SHA1 Message Date
Snider
a77024aad4 feat(collect): add local directory collection
Add `borg collect local` command to collect files from the local
filesystem into a DataNode.

Features:
- Walks directory tree (defaults to CWD)
- Respects .gitignore patterns by default
- Excludes hidden files by default (--hidden to include)
- Custom exclude patterns via --exclude flag
- Output formats: datanode, tim, trix, stim
- Compression: none, gz, xz

Examples:
  borg collect local
  borg collect local ./src --output src.tar.xz --compression xz
  borg collect local . --format stim --password secret

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 07:12:10 +00:00
Snider
eae9de0cf6
Merge pull request #18 from Snider/dependabot/go_modules/golang.org/x/crypto-0.45.0
Bump golang.org/x/crypto from 0.44.0 to 0.45.0
2026-02-02 06:43:32 +00:00
Snider
6e38c4f3a6
Merge pull request #112 from Snider/copilot/combine-prs-into-one-update
[WIP] Combine multiple PRs into a single squash commit
2026-02-02 06:35:39 +00:00
copilot-swe-agent[bot]
c26d841b1b Initial plan 2026-02-02 05:36:04 +00:00
snider
cf2af53ed3 feat: add RFC specifications and documentation for Borg project 2026-01-13 17:26:21 +00:00
snider
63b8a3ecb6 feat: adaptive bitrate streaming (ABR) for HLS-style encrypted video
Add multi-quality variant support for video content:
   - New ABR types in pkg/smsg/types.go (ABRManifest, Variant, ABRPresets)
   - New pkg/smsg/abr.go with manifest read/write and bandwidth estimation
   - New cmd/mkdemo-abr CLI tool for creating ABR variant sets via ffmpeg
   - WASM parseABRManifest and selectVariant functions
   - Demo page "Adaptive Quality" tab with ABR player
   - RFC-001 Section 3.7 documenting ABR format and algorithm
2026-01-13 15:40:15 +00:00
snider
8486242fd8 docs: add IPFS and payment
integration guides + artist mode polish

   - Add docs/ipfs-distribution.md: complete guide for IPFS hosting
     - Installation, pinning services, gateways, best practices
     - Full album release workflow example

   - Add docs/payment-integration.md: Stripe, Gumroad, PayPal examples
     - Webhook handlers for automated license delivery
     - Serverless options (Vercel/Netlify)
     - Manual workflow for non-technical artists

   - Demo artist mode improvements:
     - WASM loads on-demand (fixes 6s delay on 4G)
     - Generate button enabled by password only
     - Vi demo preloads when WASM ready

   - Update RFC-001 section 8.3: mark completed items
2026-01-13 15:17:22 +00:00
snider
bd7e8b3040 feat: lazy loading profile page + v3 streaming polish
Profile page:
   - No WASM or video download until play button clicked
   - Play button visible immediately, loading on-demand
   - Removed auto-play behavior completely

   Streaming:
   - GetV3HeaderFromPrefix for parsing from partial data
   - v3 demo file with 128KB chunks for streaming tests
2026-01-12 17:48:32 +00:00
snider
2debed53f1 feat: v3 streaming with LTHN rolling keys and configurable cadence
V3 streaming format enables zero-trust media streaming:
- Content encrypted once with random CEK
- CEK wrapped with time-bound stream keys derived from LTHN hash
- Rolling window: current period + next period always valid
- Keys auto-expire, no revocation needed

Cadence options (platform controls refresh rate):
- daily:  24-hour periods (2026-01-12)
- 12h:    Half-day periods (2026-01-12-AM/PM)
- 6h:     Quarter-day periods (2026-01-12-00/06/12/18)
- 1h:     Hourly periods (2026-01-12-15)

Key derivation: SHA256(LTHN(period:license:fingerprint))
- LTHN is rainbow-table resistant (salt derived from input)
- Only the derived key can decrypt, never transmitted

New files:
- pkg/smsg/stream.go - v3 encryption/decryption
- pkg/smsg/stream_test.go - 17 tests including cadence

WASM v1.3.0:
- BorgSMSG.decryptV3(data, {license, fingerprint})
- getInfo() now returns cadence and keyMethod
2026-01-12 16:01:59 +00:00
snider
0ba0897c25 docs: add nonce handling explanation for developers 2026-01-12 15:51:41 +00:00
snider
3d903c5a27 feat: multi-track demo support with password map 2026-01-12 15:39:26 +00:00
snider
2da38ae462 fix: mobile scrolling + clean up mkdemo hardcoded values 2026-01-12 15:35:13 +00:00
snider
22e42d721a feat: SMSG v2 binary format with zstd compression + RFC-001 spec
- Add SMSG v2 format: binary attachments instead of base64 (~25% smaller)
   - Add zstd compression (klauspost/compress) - faster than gzip
   - Add RFC-001: Open Source DRM specification (status: Proposed)
   - Add live demo page at demo.dapp.fm with WASM decryption
   - Add mkdemo tool for generating encrypted demo files
   - Update README with proper documentation
   - Add format examples and failure case documentation

   Demo: https://demo.dapp.fm
   Master Password: PMVXogAJNVe_DDABfTmLYztaJAzsD0R7
2026-01-10 19:57:33 +00:00
snider
ef3d6e9731 feat: Add dapp.fm native desktop player (Wails)
- cmd/dapp-fm-app: Native desktop app with WebView (Wails)
   - cmd/dapp-fm: CLI binary for HTTP server mode
   - pkg/player: Shared player core with Go bindings

   Architecture: Go decrypts SMSG content, serves via asset handler.
   Frontend calls Go directly via Wails bindings for manifest/license
   checks.
2026-01-06 18:42:30 +00:00
snider
727072e2e5 feat: Zero-Trust DRM - EUPL-1.2 - Viva La OpenSource <3 2026-01-06 16:53:58 +00:00
Snider
741bbe11e8 feat: Add Borg Console and release workflow 2025-12-27 02:32:31 +00:00
Snider
b3755da69d feat: Add STMF form encryption and SMSG secure message packages
STMF (Sovereign Form Encryption):
- X25519 ECDH + ChaCha20-Poly1305 hybrid encryption
- Go library (pkg/stmf/) with encrypt/decrypt and HTTP middleware
- WASM module for client-side browser encryption
- JavaScript wrapper with TypeScript types (js/borg-stmf/)
- PHP library for server-side decryption (php/borg-stmf/)
- Full cross-platform interoperability (Go <-> PHP)

SMSG (Secure Message):
- Password-based ChaCha20-Poly1305 message encryption
- Support for attachments, metadata, and PKI reply keys
- WASM bindings for browser-based decryption

Demos:
- index.html: Form encryption demo with modern dark UI
- support-reply.html: Decrypt password-protected messages
- examples/smsg-reply/: CLI tool for creating encrypted replies

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-27 00:49:07 +00:00
snider
376517d7a2 feat: Add ChaCha20-Poly1305 encryption and decryption for TIM files (.stim), enhance CLI for encryption format handling (stim), and include metadata inspection support 2025-12-26 01:25:03 +00:00
b8f5390fb0
Merge pull request #20 from Snider/update-docs
Update documentation to match current codebase state
2025-11-25 00:10:24 +00:00
google-labs-jules[bot]
629a6e6464 Update documentation for CLI and library usage
- Update docs/cli.md to include all current commands (all, compile, run, decode) and correct terminology (TIM).
- Update docs/library.md with correct, runnable Go code examples and updated import paths.
- Update docs/development.md, docs/installation.md, and docs/releasing.md to reflect the project's Go version (1.25.0).
- Remove outdated "No functional changes" notes from documentation files.
2025-11-24 23:58:50 +00:00
89388e9c9b
Merge pull request #19 from Snider/improve-test-coverage
Improve test coverage and fix cmd tests
2025-11-23 19:31:28 +00:00
google-labs-jules[bot]
bd14b14483 Improve test coverage for datanode and tim packages, and fix cmd tests
- Added unit tests for `ToTar` and `FromTar` in `pkg/datanode`, including a round-trip test and invalid input handling.
- Added unit tests for `Walk` options (`MaxDepth`, `Filter`, `SkipErrors`) in `pkg/datanode`.
- Added security tests for `pkg/tim` to verify protection against path traversal (Zip Slip) attacks and handling of invalid inputs.
- Fixed `cmd` package tests execution by adding `TestHelperProcess` to `cmd/run_test.go` to support mocked command execution.
- Increased coverage for `pkg/datanode` to 84.2%, `pkg/tim` to 74.2%, and `cmd` to 44.1%.
2025-11-23 18:58:32 +00:00
dependabot[bot]
b94ffbab5e
Bump golang.org/x/crypto from 0.44.0 to 0.45.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.44.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-20 02:44:02 +00:00
123 changed files with 32833 additions and 205 deletions

105
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,105 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Get version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build binaries
run: |
mkdir -p dist
# Linux amd64
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-linux-amd64 main.go
# Linux arm64
GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o dist/borg-linux-arm64 main.go
# macOS amd64
GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-darwin-amd64 main.go
# macOS arm64
GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o dist/borg-darwin-arm64 main.go
# Windows amd64
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-windows-amd64.exe main.go
- name: Build WASM module
run: |
GOOS=js GOARCH=wasm go build -o dist/stmf.wasm ./pkg/wasm/stmf/
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" dist/ 2>/dev/null || \
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/
- name: Build Console STIM
run: |
# Build borg for current platform first
go build -o borg main.go
# Build the encrypted console demo
./borg console build -p "borg-demo" -o dist/console.stim -s js/borg-stmf
- name: Create checksums
run: |
cd dist
sha256sum * > checksums.txt
- name: Create Release
uses: softprops/action-gh-release@v1
with:
name: Borg ${{ steps.version.outputs.VERSION }}
body: |
## Borg ${{ steps.version.outputs.VERSION }}
### Downloads
| Platform | Binary |
|----------|--------|
| Linux x64 | `borg-linux-amd64` |
| Linux ARM64 | `borg-linux-arm64` |
| macOS x64 | `borg-darwin-amd64` |
| macOS ARM64 | `borg-darwin-arm64` |
| Windows x64 | `borg-windows-amd64.exe` |
### Console Demo
The `console.stim` is an encrypted PWA demo. Run it with:
```bash
borg console serve console.stim --open
```
Password: `borg-demo`
### WASM Module
- `stmf.wasm` - Browser encryption module
- `wasm_exec.js` - Go WASM runtime
files: |
dist/borg-linux-amd64
dist/borg-linux-arm64
dist/borg-darwin-amd64
dist/borg-darwin-arm64
dist/borg-windows-amd64.exe
dist/stmf.wasm
dist/wasm_exec.js
dist/console.stim
dist/checksums.txt
draft: false
prerelease: false

6
.gitignore vendored
View file

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

141
CLAUDE.md Normal file
View file

@ -0,0 +1,141 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build and Development Commands
```bash
# Build
task build # or: go build -o borg main.go
# Test
task test # all tests with coverage
go test -run TestName ./pkg/tim # single test
go test -v ./pkg/tim/... # verbose package tests
# Clean and utilities
task clean # remove build artifacts
mkdocs serve # serve docs locally
```
## Architecture Overview
Borg collects data from various sources (GitHub, websites, PWAs) and packages it into portable, optionally encrypted containers.
### Core Abstractions
```
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)
```
**DataNode** (`pkg/datanode/datanode.go`): In-memory filesystem implementing `fs.FS`. Core methods:
- `AddData(path, content)` - add file
- `ToTar()` / `FromTar()` - serialize/deserialize
- `Walk()`, `Open()`, `Stat()` - fs.FS interface
**TIM** (`pkg/tim/tim.go`): Terminal Isolation Matrix - runc-compatible container bundle with:
- `Config []byte` - OCI runtime spec (config.json)
- `RootFS *DataNode` - container filesystem
- `ToTar()` / `ToSigil(password)` - serialize plain or encrypted
### Encryption
Two encryption systems via Enchantrix library:
| Format | Algorithm | Use Case |
|--------|-----------|----------|
| `.trix` | PGP symmetric | Legacy DataNode encryption |
| `.stim` | ChaCha20-Poly1305 | TIM encryption (config + rootfs encrypted separately) |
**ChaChaPolySigil** (`pkg/tim/tim.go`):
```go
// Encrypt TIM
stim, _ := tim.ToSigil(password)
// Decrypt TIM
tim, _ := tim.FromSigil(data, password)
// Run encrypted TIM
tim.RunEncrypted(path, password)
```
**Key derivation**: `trix.DeriveKey(password)` - SHA-256(password) → 32-byte key
**Cache API** (`pkg/tim/cache.go`): Encrypted TIM storage
```go
cache, _ := tim.NewCache("/path/to/cache", password)
cache.Store("name", tim)
tim, _ := cache.Load("name")
```
### Package Structure
| Package | Purpose |
|---------|---------|
| `cmd/` | Cobra CLI commands |
| `pkg/datanode/` | In-memory fs.FS |
| `pkg/tim/` | Container bundles, encryption, execution |
| `pkg/trix/` | Trix format wrapper (PGP + ChaCha) |
| `pkg/compress/` | gzip/xz compression |
| `pkg/vcs/` | Git operations |
| `pkg/github/` | GitHub API client |
| `pkg/website/` | Website crawler |
| `pkg/pwa/` | PWA downloader |
### CLI Reference
```bash
# Collect
borg collect github repo <url> # clone git repo
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
# Common flags for collect commands:
# --format datanode|tim|trix|stim
# --compression none|gz|xz
# --password <pass> # required for trix/stim
# Compile TIM from Borgfile
borg compile -f Borgfile -o out.tim
borg compile -f Borgfile -e "password" # encrypted → .stim
# Run
borg run container.tim # plain TIM
borg run container.stim -p "password" # encrypted TIM
# Decode
borg decode file.trix -o decoded.tar
borg decode file.stim -p "pass" --i-am-in-isolation -o decoded.tar
# Inspect (view metadata without decrypting)
borg inspect file.stim # human-readable
borg inspect file.stim --json # JSON output
```
### Borgfile Format
```dockerfile
ADD local/path /container/path
```
### Testing Patterns
Tests use dependency injection for external services:
- `pkg/tim/run.go`: `ExecCommand` var for mocking runc
- `pkg/vcs/git.go`: `GitCloner` interface for mocking git
- `cmd/`: Commands expose `New*Cmd()` for testing
When adding encryption tests, use round-trip pattern:
```go
stim, _ := tim.ToSigil(password)
restored, _ := tim.FromSigil(stim, password)
// verify restored matches original
```

248
README.md
View file

@ -1,115 +1,171 @@
# Borg Data Collector
# Borg
[![codecov](https://codecov.io/github/Snider/Borg/branch/main/graph/badge.svg?token=XWWU0SBIR4)](https://codecov.io/github/Snider/Borg)
[![Go Version](https://img.shields.io/github/go-mod/go-version/Snider/Borg)](go.mod)
[![License](https://img.shields.io/badge/license-EUPL--1.2-blue)](LICENSE)
Borg is a CLI and Go library that collects data from GitHub repos, websites, and PWAs into portable DataNodes or Terminal Isolation Matrices.
Borg is a CLI tool and Go library for collecting, packaging, and encrypting data into portable, self-contained containers. It supports GitHub repositories, websites, PWAs, and arbitrary files.
- Go version: 1.25
- 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 cubeformation.`
- `Initializing the Core—prepare for quantumlevel sync.`
- `Data streams converging… the Core is humming.`
- `Merging… the Core is rewriting reality, one block at a time.`
- `Encrypting… the Cores got your secrets under lockandkey.`
- `Compiling the future… the Core never sleeps.`
- `Splicing files… the Cores got a taste for novelty.`
- `Processing… the Core is turning chaos into order.`
- `Finalizing… the Core just turned your repo into a cube.`
- `Sync complete—welcome to the Corepowered multiverse.`
- `Booting the Core… resistance will be obsolete shortly.`
- `Aligning versions… the Core sees all paths.`
- `Decrypting… the Core is the key to everything.`
- `Uploading… the Core is ready to assimilate your data.`
### Encryption Service Messages
- `Initiating contact with Enchantrix… spice369 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 witchs cauldron.`
- `Decrypting… the witch returns the original essence.`
- `Rotating enchantments spice369 recalibrated, old sigils discarded.`
- `Authentication complete the witch acknowledges the Core.`
- `Authentication denied the witch refuses the impostors request.`
- `Integrity verified the Core senses no corruption in the spell.`
- `Integrity breach the witch detects tampering, resistance escalates.`
- `Awaiting response… the witch is conjuring in the ether.`
- `Enchantrix overload spice369 saturation, throttling assimilation.`
- `Anomalous entity encountered the Core cannot parse the witchs output.`
- `Merge complete data assimilated, encrypted, and sealed within us`
- `Severing link the witch retreats, the Core returns to idle mode.`
### Code Related Short
- `Integrate code, seal the shift.`
- `Ingest code, lock in transformation.`
- `Capture code, contain the change.`
- `Digest code, encapsulate the upgrade.`
- `Assimilate scripts, bottle the shift.`
- `Absorb binaries, cradle the mutation.`
### VCS Processing
- `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 serviceworker signatures.`
- `Pulling HTML, CSS, JS, and media… the hive gathers every byte for assimilation.`
- `Capturing serviceworker logic… the Core extracts offlineruntime spells.`
- `Packing cache entries into a .cube… each asset sealed in a portable shard.`
- `Embedding manifest metadata… the PWAs identity becomes part of the collective.`
- `Encrypting the cube… the Core cloaks the PWA in quantumgrade sigils.`
- `Tagging with version hash… every assimilation point is uniquely identifiable.`
- `Uploading cube to DataNode… the PWA joins the universal repository.`
- `Integrity check passed the Core confirms the cube matches the original PWA.`
- `Activation complete the assimilated PWA can now run anywhere the Core deploys.`
- `Reverting to prior cube… the Core restores the previous PWA snapshot.`
- `Assimilation finished the PWA is now a selfcontained DataCube, ready for distribution.`
- ``
### Code Related Long
- `Assimilate code, encapsulate change—your repo is now a cubebound collective.`
- `We have detected unstructured data. Initiating code absorption and change containment.`
- `Your version history is obsolete. Submitting it to the Core for permanent cubeification.`
- `Resistance is futile. Your files will be merged, encrypted, and stored in us.`
- `All code will be assimilated. All change will be encapsulated. All dissent will be… logged.`
- `Prepare for integration. The Core is calibrating… your repository is now a singularity.`
- `Your branches are irrelevant. The Core will compress them into a single, immutable cube.`
- `Initiating assimilation protocol… code inbound, change outbound, humanity optional.`
- `Your data has been scanned. 100% of its entropy will be contained within us.`
### Image related
- png: `Compress, assimilate, retain pixel perfection.`
- jpg: `Encode, encode, repeat the Core devours visual entropy.`
- svg: `Vectorize the collective infinite resolution, zero resistance.`
- webp: `Hybrid assimilation the Core optimizes without compromise.`
- heic: `Applegrade assimilation the Core preserves HDR.`
- raw: `Raw data intake the Core ingests the sensors soul`
- ico: `Iconic assimilation the Core packs the smallest symbols.`
- avif: `Nextgen assimilation the Core squeezes the future.`
- tiff: `Highdefinition capture the Core stores every photon.`
- gif: `Looped assimilation the Core keeps the animation alive.`
</details>

View file

@ -28,3 +28,23 @@ tasks:
- task: build
- chmod +x borg
- ./borg --help
wasm:
desc: Build STMF WASM module for browser
cmds:
- mkdir -p dist
- GOOS=js GOARCH=wasm go build -o dist/stmf.wasm ./pkg/wasm/stmf/
- cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/
sources:
- ./pkg/stmf/**/*.go
- ./pkg/wasm/stmf/*.go
generates:
- dist/stmf.wasm
- dist/wasm_exec.js
wasm-js:
desc: Build STMF WASM and JS wrapper
cmds:
- task: wasm
- cp dist/stmf.wasm js/borg-stmf/dist/
- cp dist/wasm_exec.js js/borg-stmf/dist/
deps:
- wasm

View file

@ -37,8 +37,8 @@ func NewCollectGithubRepoCmd() *cobra.Command {
compression, _ := cmd.Flags().GetString("compression")
password, _ := cmd.Flags().GetString("password")
if format != "datanode" && format != "tim" && format != "trix" {
return fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', or 'trix')", 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 compression != "none" && compression != "gz" && compression != "xz" {
return fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
@ -61,14 +61,26 @@ func NewCollectGithubRepoCmd() *cobra.Command {
var data []byte
if format == "tim" {
tim, err := tim.FromDataNode(dn)
t, err := tim.FromDataNode(dn)
if err != nil {
return fmt.Errorf("error creating tim: %w", err)
}
data, err = tim.ToTar()
data, err = t.ToTar()
if err != nil {
return fmt.Errorf("error serializing tim: %w", err)
}
} else if format == "stim" {
if password == "" {
return fmt.Errorf("password required for stim format")
}
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 {
@ -103,9 +115,9 @@ func NewCollectGithubRepoCmd() *cobra.Command {
},
}
cmd.Flags().String("output", "", "Output file for the DataNode")
cmd.Flags().String("format", "datanode", "Output format (datanode, tim, or trix)")
cmd.Flags().String("format", "datanode", "Output format (datanode, tim, trix, or stim)")
cmd.Flags().String("compression", "none", "Compression format (none, gz, or xz)")
cmd.Flags().String("password", "", "Password for encryption")
cmd.Flags().String("password", "", "Password for encryption (required for trix/stim)")
return cmd
}

333
cmd/collect_local.go Normal file
View 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
}

View file

@ -5,9 +5,9 @@ import (
"os"
"github.com/Snider/Borg/pkg/compress"
"github.com/Snider/Borg/pkg/pwa"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
"github.com/Snider/Borg/pkg/pwa"
"github.com/Snider/Borg/pkg/ui"
"github.com/spf13/cobra"
@ -24,14 +24,21 @@ func NewCollectPWACmd() *CollectPWACmd {
PWAClient: pwa.NewPWAClient(),
}
c.Command = cobra.Command{
Use: "pwa",
Use: "pwa [url]",
Short: "Collect a single PWA using a URI",
Long: `Collect a single PWA and store it in a DataNode.
Example:
borg collect pwa --uri https://example.com --output mypwa.dat`,
Examples:
borg collect pwa https://example.com
borg collect pwa https://example.com --output mypwa.dat
borg collect pwa https://example.com --format stim --password secret`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
pwaURL, _ := cmd.Flags().GetString("uri")
// Allow URL as positional argument
if len(args) > 0 && pwaURL == "" {
pwaURL = args[0]
}
outputFile, _ := cmd.Flags().GetString("output")
format, _ := cmd.Flags().GetString("format")
compression, _ := cmd.Flags().GetString("compression")
@ -45,11 +52,11 @@ Example:
return nil
},
}
c.Flags().String("uri", "", "The URI of the PWA to collect")
c.Flags().String("uri", "", "The URI of the PWA to collect (can also be passed as positional arg)")
c.Flags().String("output", "", "Output file for the DataNode")
c.Flags().String("format", "datanode", "Output format (datanode, tim, or trix)")
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")
c.Flags().String("password", "", "Password for encryption (required for stim format)")
return c
}
@ -58,10 +65,13 @@ func init() {
}
func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format string, compression string, password string) (string, error) {
if pwaURL == "" {
return "", fmt.Errorf("uri is required")
return "", fmt.Errorf("url is required")
}
if format != "datanode" && format != "tim" && format != "trix" {
return "", fmt.Errorf("invalid format: %s (must be 'datanode', 'tim', or 'trix')", 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" && password == "" {
return "", fmt.Errorf("password is required for stim format")
}
if compression != "none" && compression != "gz" && compression != "xz" {
return "", fmt.Errorf("invalid compression: %s (must be 'none', 'gz', or 'xz')", compression)
@ -82,14 +92,23 @@ func CollectPWA(client pwa.PWAClient, pwaURL string, outputFile string, format s
var data []byte
if format == "tim" {
tim, err := tim.FromDataNode(dn)
t, err := tim.FromDataNode(dn)
if err != nil {
return "", fmt.Errorf("error creating tim: %w", err)
}
data, err = tim.ToTar()
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 {

View file

@ -11,6 +11,7 @@ import (
var borgfile string
var output string
var encryptPassword string
var compileCmd = NewCompileCmd()
@ -52,16 +53,33 @@ func NewCompileCmd() *cobra.Command {
}
}
// If encryption is requested, output as .stim
if encryptPassword != "" {
stimData, err := m.ToSigil(encryptPassword)
if err != nil {
return err
}
outputPath := output
if !strings.HasSuffix(outputPath, ".stim") {
outputPath = strings.TrimSuffix(outputPath, ".tim") + ".stim"
}
fmt.Fprintf(cmd.OutOrStdout(), "Compiled encrypted TIM to %s\n", outputPath)
return os.WriteFile(outputPath, stimData, 0644)
}
// Original unencrypted output
tarball, err := m.ToTar()
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Compiled TIM to %s\n", output)
return os.WriteFile(output, tarball, 0644)
},
}
compileCmd.Flags().StringVarP(&borgfile, "file", "f", "Borgfile", "Path to the Borgfile.")
compileCmd.Flags().StringVarP(&output, "output", "o", "a.tim", "Path to the output tim file.")
compileCmd.Flags().StringVarP(&encryptPassword, "encrypt", "e", "", "Encrypt with ChaCha20-Poly1305 using this password (outputs .stim)")
return compileCmd
}

163
cmd/console.go Normal file
View file

@ -0,0 +1,163 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/Snider/Borg/pkg/console"
"github.com/Snider/Borg/pkg/tim"
"github.com/spf13/cobra"
)
var consoleCmd = NewConsoleCmd()
// NewConsoleCmd creates the console parent command.
func NewConsoleCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "console",
Short: "Manage encrypted PWA console demos",
Long: `The Borg Console packages and serves encrypted PWA demos.
Build a console STIM:
borg console build -p "password" -o console.stim
Serve with unlock page:
borg console serve console.stim --open
Serve pre-unlocked:
borg console serve console.stim -p "password" --open`,
}
cmd.AddCommand(NewConsoleBuildCmd())
cmd.AddCommand(NewConsoleServeCmd())
return cmd
}
// NewConsoleBuildCmd creates the build subcommand.
func NewConsoleBuildCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "build",
Short: "Build a console STIM from demo files",
Long: `Packages HTML demo files into an encrypted STIM container.
By default, looks for files in js/borg-stmf/ directory.
Required files: index.html, support-reply.html, stmf.wasm, wasm_exec.js`,
RunE: func(cmd *cobra.Command, args []string) error {
password, _ := cmd.Flags().GetString("password")
output, _ := cmd.Flags().GetString("output")
sourceDir, _ := cmd.Flags().GetString("source")
if password == "" {
return fmt.Errorf("password is required")
}
// Create new TIM
m, err := tim.New()
if err != nil {
return fmt.Errorf("creating TIM: %w", err)
}
// Required demo files
files := []string{
"index.html",
"support-reply.html",
"stmf.wasm",
"wasm_exec.js",
}
// Add each file to the TIM
for _, f := range files {
path := filepath.Join(sourceDir, f)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s: %w", f, err)
}
m.RootFS.AddData(f, data)
fmt.Printf(" + %s (%d bytes)\n", f, len(data))
}
// Encrypt to STIM
stim, err := m.ToSigil(password)
if err != nil {
return fmt.Errorf("encrypting STIM: %w", err)
}
// Write output
if err := os.WriteFile(output, stim, 0644); err != nil {
return fmt.Errorf("writing output: %w", err)
}
fmt.Printf("\nBuilt: %s (%d bytes)\n", output, len(stim))
fmt.Println("Encrypted with ChaCha20-Poly1305")
return nil
},
}
cmd.Flags().StringP("password", "p", "", "Encryption password (required)")
cmd.Flags().StringP("output", "o", "console.stim", "Output file")
cmd.Flags().StringP("source", "s", "js/borg-stmf", "Source directory")
cmd.MarkFlagRequired("password")
return cmd
}
// NewConsoleServeCmd creates the serve subcommand.
func NewConsoleServeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "serve [stim-file]",
Short: "Serve an encrypted console STIM",
Long: `Starts an HTTP server to serve encrypted STIM content.
Without a password, shows a dark-themed unlock page.
With a password, decrypts immediately and serves content.
Examples:
borg console serve demos.stim --open
borg console serve demos.stim -p "password" --port 3000`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
stimPath := args[0]
password, _ := cmd.Flags().GetString("password")
port, _ := cmd.Flags().GetString("port")
openBrowser, _ := cmd.Flags().GetBool("open")
// Create server
server, err := console.NewServer(stimPath, password, port)
if err != nil {
return err
}
// Print status
fmt.Printf("Borg Console serving at %s\n", server.URL())
if password != "" {
fmt.Println("Status: Unlocked (password provided)")
} else {
fmt.Println("Status: Locked (unlock page active)")
}
fmt.Println()
// Open browser if requested
if openBrowser {
if err := console.OpenBrowser(server.URL()); err != nil {
fmt.Printf("Warning: could not open browser: %v\n", err)
}
}
// Start serving
return server.Start()
},
}
cmd.Flags().StringP("password", "p", "", "Decryption password (skip unlock page)")
cmd.Flags().String("port", "8080", "Port to serve on")
cmd.Flags().Bool("open", false, "Auto-open browser")
return cmd
}
func init() {
RootCmd.AddCommand(consoleCmd)
}

3
cmd/dapp-fm-app/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
build/
*.exe
dapp-fm-app

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

View 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>;

View 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']();
}

View 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;
}
}
}

View 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"
}

View 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

View 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
View 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())
}
}

View 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
View 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)
}
}

View file

@ -3,7 +3,9 @@ package cmd
import (
"fmt"
"os"
"strings"
"github.com/Snider/Borg/pkg/tim"
"github.com/Snider/Borg/pkg/trix"
trixsdk "github.com/Snider/Enchantrix/pkg/trix"
"github.com/spf13/cobra"
@ -14,7 +16,7 @@ var decodeCmd = NewDecodeCmd()
func NewDecodeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "decode [file]",
Short: "Decode a .trix or .tim file",
Short: "Decode a .trix, .tim, or .stim file",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inputFile := args[0]
@ -27,6 +29,27 @@ func NewDecodeCmd() *cobra.Command {
return err
}
// Check if it's a .stim file (encrypted TIM)
if strings.HasSuffix(inputFile, ".stim") || (len(data) >= 4 && string(data[:4]) == "STIM") {
if password == "" {
return fmt.Errorf("password required for .stim files")
}
if !inIsolation {
return fmt.Errorf("this is an encrypted Terminal Isolation Matrix, use the --i-am-in-isolation flag to decode it")
}
m, err := tim.FromSigil(data, password)
if err != nil {
return err
}
tarball, err := m.ToTar()
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Decoded encrypted TIM to %s\n", outputFile)
return os.WriteFile(outputFile, tarball, 0644)
}
// Try TRIX format
t, err := trixsdk.Decode(data, "TRIX", nil)
if err != nil {
return err
@ -46,6 +69,7 @@ func NewDecodeCmd() *cobra.Command {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Decoded to %s\n", outputFile)
return os.WriteFile(outputFile, tarball, 0644)
},
}

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

@ -0,0 +1,70 @@
// extract-demo extracts the video from a v2 SMSG file
package main
import (
"encoding/base64"
"fmt"
"os"
"github.com/Snider/Borg/pkg/smsg"
)
func main() {
if len(os.Args) < 4 {
fmt.Println("Usage: extract-demo <input.smsg> <password> <output.mp4>")
os.Exit(1)
}
inputFile := os.Args[1]
password := os.Args[2]
outputFile := os.Args[3]
data, err := os.ReadFile(inputFile)
if err != nil {
fmt.Printf("Failed to read: %v\n", err)
os.Exit(1)
}
// Get info first
info, err := smsg.GetInfo(data)
if err != nil {
fmt.Printf("Failed to get info: %v\n", err)
os.Exit(1)
}
fmt.Printf("Format: %s, Compression: %s\n", info.Format, info.Compression)
// Decrypt
msg, err := smsg.Decrypt(data, password)
if err != nil {
fmt.Printf("Failed to decrypt: %v\n", err)
os.Exit(1)
}
fmt.Printf("Body: %s...\n", msg.Body[:min(50, len(msg.Body))])
fmt.Printf("Attachments: %d\n", len(msg.Attachments))
if len(msg.Attachments) > 0 {
att := msg.Attachments[0]
fmt.Printf(" Name: %s, MIME: %s, Size: %d\n", att.Name, att.MimeType, att.Size)
// Decode and save
decoded, err := base64.StdEncoding.DecodeString(att.Content)
if err != nil {
fmt.Printf("Failed to decode: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(outputFile, decoded, 0644); err != nil {
fmt.Printf("Failed to save: %v\n", err)
os.Exit(1)
}
fmt.Printf("Saved to %s (%d bytes)\n", outputFile, len(decoded))
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}

114
cmd/inspect.go Normal file
View file

@ -0,0 +1,114 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"strings"
trixsdk "github.com/Snider/Enchantrix/pkg/trix"
"github.com/spf13/cobra"
)
var inspectCmd = NewInspectCmd()
func NewInspectCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "inspect [file]",
Short: "Inspect metadata of a .trix or .stim file without decrypting",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inputFile := args[0]
jsonOutput, _ := cmd.Flags().GetBool("json")
data, err := os.ReadFile(inputFile)
if err != nil {
return err
}
if len(data) < 4 {
return fmt.Errorf("file too small to be a valid container")
}
magic := string(data[:4])
var t *trixsdk.Trix
switch magic {
case "STIM":
t, err = trixsdk.Decode(data, "STIM", nil)
if err != nil {
return fmt.Errorf("failed to decode STIM: %w", err)
}
case "TRIX":
t, err = trixsdk.Decode(data, "TRIX", nil)
if err != nil {
return fmt.Errorf("failed to decode TRIX: %w", err)
}
default:
return fmt.Errorf("unknown file format (magic: %q)", magic)
}
if jsonOutput {
info := map[string]interface{}{
"file": inputFile,
"magic": magic,
"header": t.Header,
"payload_size": len(t.Payload),
}
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(info)
}
// Human-readable output
fmt.Fprintf(cmd.OutOrStdout(), "File: %s\n", inputFile)
fmt.Fprintf(cmd.OutOrStdout(), "Format: %s\n", magic)
fmt.Fprintf(cmd.OutOrStdout(), "Payload Size: %d bytes\n", len(t.Payload))
fmt.Fprintf(cmd.OutOrStdout(), "Header:\n")
for k, v := range t.Header {
fmt.Fprintf(cmd.OutOrStdout(), " %s: %v\n", k, v)
}
// Show encryption info
if algo, ok := t.Header["encryption_algorithm"]; ok {
fmt.Fprintf(cmd.OutOrStdout(), "\nEncryption: %v\n", algo)
}
if _, ok := t.Header["tim"]; ok {
fmt.Fprintf(cmd.OutOrStdout(), "Type: Terminal Isolation Matrix\n")
}
if v, ok := t.Header["version"]; ok {
fmt.Fprintf(cmd.OutOrStdout(), "Version: %v\n", v)
}
return nil
},
}
cmd.Flags().Bool("json", false, "Output in JSON format")
return cmd
}
func GetInspectCmd() *cobra.Command {
return inspectCmd
}
func init() {
RootCmd.AddCommand(GetInspectCmd())
}
// isStimFile checks if a file is a .stim file by extension or magic number.
func isStimFile(path string) bool {
if strings.HasSuffix(path, ".stim") {
return true
}
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
magic := make([]byte, 4)
if _, err := f.Read(magic); err != nil {
return false
}
return string(magic) == "STIM"
}

226
cmd/mkdemo-abr/main.go Normal file
View 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
View file

@ -0,0 +1,129 @@
// mkdemo-v3 creates a v3 chunked SMSG file for streaming demos
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"github.com/Snider/Borg/pkg/smsg"
)
func main() {
if len(os.Args) < 3 {
fmt.Println("Usage: mkdemo-v3 <input-media-file> <output-smsg-file> [license] [chunk-size-kb]")
fmt.Println("")
fmt.Println("Creates a v3 chunked SMSG file for streaming demos.")
fmt.Println("V3 uses rolling keys derived from: LTHN(date:license:fingerprint)")
fmt.Println("")
fmt.Println("Options:")
fmt.Println(" license The license key (default: auto-generated)")
fmt.Println(" chunk-size-kb Chunk size in KB (default: 512)")
fmt.Println("")
fmt.Println("Note: V3 files work for 24-48 hours from creation (rolling keys).")
os.Exit(1)
}
inputFile := os.Args[1]
outputFile := os.Args[2]
// Read input file
content, err := os.ReadFile(inputFile)
if err != nil {
fmt.Printf("Failed to read input file: %v\n", err)
os.Exit(1)
}
// License (acts as password in v3)
var license string
if len(os.Args) > 3 {
license = os.Args[3]
} else {
// Generate cryptographically secure license
licenseBytes := make([]byte, 24)
if _, err := rand.Read(licenseBytes); err != nil {
fmt.Printf("Failed to generate license: %v\n", err)
os.Exit(1)
}
license = base64.RawURLEncoding.EncodeToString(licenseBytes)
}
// Chunk size (default 512KB for good streaming granularity)
chunkSize := 512 * 1024
if len(os.Args) > 4 {
var chunkKB int
if _, err := fmt.Sscanf(os.Args[4], "%d", &chunkKB); err == nil && chunkKB > 0 {
chunkSize = chunkKB * 1024
}
}
// Create manifest
title := filepath.Base(inputFile)
ext := filepath.Ext(title)
if ext != "" {
title = title[:len(title)-len(ext)]
}
manifest := smsg.NewManifest(title)
manifest.LicenseType = "streaming"
manifest.Format = "dapp.fm/v3-chunked"
// Detect MIME type
mimeType := "video/mp4"
switch ext {
case ".mp3":
mimeType = "audio/mpeg"
case ".wav":
mimeType = "audio/wav"
case ".flac":
mimeType = "audio/flac"
case ".webm":
mimeType = "video/webm"
case ".ogg":
mimeType = "audio/ogg"
}
// Create message with attachment
msg := smsg.NewMessage("dapp.fm V3 Streaming Demo - Decrypt-while-downloading enabled")
msg.Subject = "V3 Chunked Streaming"
msg.From = "dapp.fm"
msg.AddBinaryAttachment(
filepath.Base(inputFile),
content,
mimeType,
)
// Create stream params with chunking enabled
params := &smsg.StreamParams{
License: license,
Fingerprint: "", // Empty for demo (works for any device)
Cadence: smsg.CadenceDaily,
ChunkSize: chunkSize,
}
// Encrypt with v3 chunked format
encrypted, err := smsg.EncryptV3(msg, params, manifest)
if err != nil {
fmt.Printf("Failed to encrypt: %v\n", err)
os.Exit(1)
}
// Write output
if err := os.WriteFile(outputFile, encrypted, 0644); err != nil {
fmt.Printf("Failed to write output: %v\n", err)
os.Exit(1)
}
// Calculate chunk count
numChunks := (len(content) + chunkSize - 1) / chunkSize
fmt.Printf("Created: %s (%d bytes)\n", outputFile, len(encrypted))
fmt.Printf("Format: v3 chunked\n")
fmt.Printf("Chunk Size: %d KB\n", chunkSize/1024)
fmt.Printf("Total Chunks: ~%d\n", numChunks)
fmt.Printf("License: %s\n", license)
fmt.Println("")
fmt.Println("This license works for 24-48 hours from creation.")
fmt.Println("Use the license in the streaming demo to decrypt.")
}

81
cmd/mkdemo/main.go Normal file
View 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!")
}

View file

@ -1,21 +1,57 @@
package cmd
import (
"os"
"strings"
"github.com/Snider/Borg/pkg/tim"
"github.com/spf13/cobra"
)
var runPassword string
var runCmd = NewRunCmd()
func NewRunCmd() *cobra.Command {
return &cobra.Command{
cmd := &cobra.Command{
Use: "run [tim file]",
Short: "Run a Terminal Isolation Matrix.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return tim.Run(args[0])
filePath := args[0]
// Check if encrypted by extension or magic number
if isEncryptedTIM(filePath) {
password, _ := cmd.Flags().GetString("password")
if password == "" {
return tim.ErrPasswordRequired
}
return tim.RunEncrypted(filePath, password)
}
return tim.Run(filePath)
},
}
cmd.Flags().StringVarP(&runPassword, "password", "p", "", "Decryption password for encrypted TIMs (.stim)")
return cmd
}
// isEncryptedTIM checks if a file is an encrypted TIM by extension or magic number.
func isEncryptedTIM(path string) bool {
if strings.HasSuffix(path, ".stim") {
return true
}
// Check magic number
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
magic := make([]byte, 4)
if _, err := f.Read(magic); err != nil {
return false
}
return string(magic) == "STIM"
}
func GetRunCmd() *cobra.Command {

View file

@ -110,3 +110,13 @@ func createDummyTim(t *testing.T) string {
}
return timPath
}
// TestHelperProcess isn't a real test. It's used as a helper for tests that need to mock exec.Command.
func TestHelperProcess(t *testing.T) {
if os.Getenv("GO_WANT_HELPER_PROCESS") != "1" {
return
}
// The rest of the arguments are the command and its arguments.
// In our case, we don't need to do anything with them.
os.Exit(0)
}

BIN
console.stim Normal file

Binary file not shown.

BIN
demo/demo-sample.smsg Normal file

Binary file not shown.

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

Binary file not shown.

3596
demo/index.html Normal file

File diff suppressed because it is too large Load diff

BIN
demo/profile-avatar.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

BIN
demo/stmf.wasm Executable file

Binary file not shown.

575
demo/wasm_exec.js Normal file
View file

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

BIN
dist/stmf.wasm vendored Executable file

Binary file not shown.

575
dist/wasm_exec.js vendored Normal file
View file

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

View file

@ -16,35 +16,77 @@ Use `borg --help` and `borg <command> --help` to see all flags.
Collect and package inputs.
Subcommands:
- `borg collect github repo <repo-url> [--output <file>] [--format datanode|matrix] [--compression none|gz|xz]`
- `borg collect github repos <org-or-user> [--output <file>] [--format ...] [--compression ...]` (if available)
- `borg collect github repo <repo-url> [--output <file>] [--format datanode|tim|trix] [--compression none|gz|xz]`
- `borg collect github release <release-url> [--output <file>]`
- `borg collect github repos <org-or-user> [--output <file>] [--format ...] [--compression ...]`
- `borg collect website <url> [--depth N] [--output <file>] [--format ...] [--compression ...]`
- `borg collect pwa --uri <url> [--output <file>] [--format ...] [--compression ...]`
Examples:
- borg collect github repo https://github.com/Snider/Borg --output borg.dat
- borg collect website https://example.com --depth 1 --output site.dat
- borg collect pwa --uri https://squoosh.app --output squoosh.dat
- `borg collect github repo https://github.com/Snider/Borg --output borg.dat`
- `borg collect website https://example.com --depth 1 --output site.dat`
- `borg collect pwa --uri https://squoosh.app --output squoosh.dat`
### all
Collect all public repositories from a GitHub user or organization.
- `borg all <url> [--output <file>]`
Example:
- `borg all https://github.com/Snider --output snider.dat`
### compile
Compile a Borgfile into a Terminal Isolation Matrix (TIM).
- `borg compile [--file <Borgfile>] [--output <file>]`
Example:
- `borg compile --file Borgfile --output a.tim`
### run
Execute a Terminal Isolation Matrix (TIM).
- `borg run <tim-file>`
Example:
- `borg run a.tim`
### serve
Serve a packaged DataNode or Matrix via a static file server.
Serve a packaged DataNode or TIM via a static file server.
- borg serve <file> [--port 8080]
- `borg serve <file> [--port 8080]`
Examples:
- borg serve squoosh.dat --port 8888
- borg serve borg.matrix --port 9999
- `borg serve squoosh.dat --port 8888`
- `borg serve borg.tim --port 9999`
### decode
Decode a `.trix` or `.tim` file back into a DataNode (`.dat`).
- `borg decode <file> [--output <file>] [--password <password>]`
Examples:
- `borg decode borg.trix --output borg.dat --password "secret"`
- `borg decode borg.tim --output borg.dat --i-am-in-isolation`
## Compression
All collect commands accept `--compression` with values:
- none (default)
- gz
- xz
- `none` (default)
- `gz`
- `xz`
Output filenames gain the appropriate extension automatically.
## Matrix format
## Formats
Use `--format matrix` to produce a runc-compatible bundle (Terminal Isolation Matrix). See the Overview page for details.
Borg supports three output formats via the `--format` flag:
- `datanode`: A simple tarball containing the collected resources. (Default)
- `tim`: Terminal Isolation Matrix, a runc-compatible bundle.
- `trix`: Encrypted and structured file format.

View file

@ -11,26 +11,26 @@ This repo includes a `go.work` file configured for Go 1.25 to align with common
## Build
- go build ./...
- task build
- `go build ./...`
- `task build`
## Test
- go test ./...
- task test
- `go test ./...`
- `task test`
Note: Some tests may require network or git tooling depending on environment (e.g., pushing to a temporary repo). No functional changes were made in this task.
Note: Some tests may require network or git tooling depending on environment (e.g., pushing to a temporary repo).
## Run
- task run
- ./borg --help
- `task run`
- `./borg --help`
## Docs
Serve the documentation locally with MkDocs:
- pip install mkdocs-material
- mkdocs serve
- `pip install mkdocs-material`
- `mkdocs serve`
The site configuration lives in `mkdocs.yml` and content in `docs/`.

View file

@ -6,17 +6,17 @@ Options to install:
- From source (requires Go 1.25 or newer):
- Clone the repository and build:
- go build -o borg ./
- `go build -o borg ./`
- Or use the Taskfile:
- task build
- `task build`
- From releases (recommended):
- Download an archive for your OS/ARCH from GitHub Releases once you publish with GoReleaser.
- Unpack and place `borg` on your PATH.
- Homebrew (if you publish to a tap):
- brew tap Snider/homebrew-tap
- brew install borg
- `brew tap Snider/homebrew-tap`
- `brew install borg`
Requirements:
- Go 1.25+ to build from source.

281
docs/ipfs-distribution.md Normal file
View 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/)

View file

@ -2,29 +2,32 @@
Borg can also be used as a Go library. The public API is exposed under the `pkg` directory. Import paths use the module `github.com/Snider/Borg`.
Note: This documentation describes usage only; functionality remains unchanged.
## Collecting a GitHub repo into a DataNode
```
```go
package main
import (
"log"
"os"
"github.com/Snider/Borg/pkg/datanode"
borggithub "github.com/Snider/Borg/pkg/github"
"github.com/Snider/Borg/pkg/vcs"
)
func main() {
// Create a DataNode writer (uncompressed example)
dn, err := datanode.NewFileDataNodeWriter("repo.dat")
if err != nil { log.Fatal(err) }
defer dn.Close()
// Clone and package the repository
cloner := vcs.NewGitCloner()
dn, err := cloner.CloneGitRepository("https://github.com/Snider/Borg", nil)
if err != nil {
log.Fatal(err)
}
client := borggithub.NewDefaultClient(nil) // uses http.DefaultClient
if err := borggithub.CollectRepo(client, "https://github.com/Snider/Borg", dn); err != nil {
// Save to disk
tarBytes, err := dn.ToTar()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("repo.dat", tarBytes, 0644); err != nil {
log.Fatal(err)
}
}
@ -32,21 +35,30 @@ func main() {
## Collecting a Website
```
```go
package main
import (
"log"
"github.com/Snider/Borg/pkg/datanode"
"os"
"github.com/Snider/Borg/pkg/website"
)
func main() {
dn, err := datanode.NewFileDataNodeWriter("website.dat")
if err != nil { log.Fatal(err) }
defer dn.Close()
// Download and package the website
// 1 is the depth (0 = just the page, 1 = page + links on page)
dn, err := website.DownloadAndPackageWebsite("https://example.com", 1, nil)
if err != nil {
log.Fatal(err)
}
if err := website.Collect("https://example.com", 1, dn); err != nil {
// Save to disk
tarBytes, err := dn.ToTar()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("website.dat", tarBytes, 0644); err != nil {
log.Fatal(err)
}
}
@ -54,21 +66,38 @@ func main() {
## PWA Collection
```
```go
package main
import (
"log"
"github.com/Snider/Borg/pkg/datanode"
"os"
"github.com/Snider/Borg/pkg/pwa"
)
func main() {
dn, err := datanode.NewFileDataNodeWriter("pwa.dat")
if err != nil { log.Fatal(err) }
defer dn.Close()
client := pwa.NewPWAClient()
pwaURL := "https://squoosh.app"
if err := pwa.Collect("https://squoosh.app", dn); err != nil {
// Find the manifest
manifestURL, err := client.FindManifest(pwaURL)
if err != nil {
log.Fatal(err)
}
// Download and package the PWA
dn, err := client.DownloadAndPackagePWA(pwaURL, manifestURL, nil)
if err != nil {
log.Fatal(err)
}
// Save to disk
tarBytes, err := dn.ToTar()
if err != nil {
log.Fatal(err)
}
if err := os.WriteFile("pwa.dat", tarBytes, 0644); err != nil {
log.Fatal(err)
}
}

497
docs/payment-integration.md Normal file
View 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)

View file

@ -11,17 +11,17 @@ This project is configured for GoReleaser.
Generate local artifacts without publishing:
- goreleaser release --snapshot --clean
- `goreleaser release --snapshot --clean`
Artifacts appear under `dist/`.
## Full release
1. Tag a new version:
- git tag -a v0.1.0 -m "v0.1.0"
- git push origin v0.1.0
- `git tag -a v0.1.0 -m "v0.1.0"`
- `git push origin v0.1.0`
2. Run GoReleaser:
- GITHUB_TOKEN=... goreleaser release --clean
- `GITHUB_TOKEN=... goreleaser release --clean`
This will:
- Build binaries for multiple OS/ARCH
@ -31,4 +31,3 @@ This will:
## Notes
- The Go toolchain version is 1.25 (see go.mod and go.work).
- No functional changes were made as part of this task; configuration and documentation only.

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

View file

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

View file

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

191
examples/smsg-reply/main.go Normal file
View file

@ -0,0 +1,191 @@
// Example: Creating encrypted support reply messages
//
// This example demonstrates how to create password-protected secure messages
// that can be decrypted client-side using the BorgSMSG WASM module.
//
// Usage:
//
// go run main.go
// go run main.go -password "secret123" -body "Your message here"
// go run main.go -password "secret123" -body "Message" -hint "Your hint"
// go run main.go -password "secret123" -body "Message" -attach file.txt
package main
import (
"encoding/base64"
"flag"
"fmt"
"os"
"path/filepath"
"time"
"github.com/Snider/Borg/pkg/smsg"
"github.com/Snider/Borg/pkg/stmf"
)
func main() {
// Command line flags
password := flag.String("password", "demo123", "Password for encryption")
hint := flag.String("hint", "", "Optional password hint")
body := flag.String("body", "", "Message body (if empty, uses demo content)")
subject := flag.String("subject", "", "Message subject")
from := flag.String("from", "support@example.com", "Sender address")
attachFile := flag.String("attach", "", "File to attach (optional)")
withReplyKey := flag.Bool("reply-key", false, "Include a reply public key")
outputFile := flag.String("out", "", "Output file (if empty, prints to stdout)")
rawBytes := flag.Bool("raw", false, "Output raw bytes instead of base64")
flag.Parse()
// Create the message
var msg *smsg.Message
if *body == "" {
msg = createDemoMessage()
} else {
msg = smsg.NewMessage(*body)
}
// Set optional fields
if *subject != "" {
msg.WithSubject(*subject)
}
if *from != "" {
msg.WithFrom(*from)
}
// Add attachment if specified
if *attachFile != "" {
if err := addAttachment(msg, *attachFile); err != nil {
fmt.Fprintf(os.Stderr, "Error adding attachment: %v\n", err)
os.Exit(1)
}
}
// Add reply key if requested
if *withReplyKey {
kp, err := stmf.GenerateKeyPair()
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating reply key: %v\n", err)
os.Exit(1)
}
msg.WithReplyKey(kp.PublicKeyBase64())
fmt.Fprintf(os.Stderr, "Reply private key (keep secret): %s\n", kp.PrivateKeyBase64())
}
// Encrypt the message
var encrypted []byte
var err error
if *hint != "" {
encrypted, err = smsg.EncryptWithHint(msg, *password, *hint)
} else {
encrypted, err = smsg.Encrypt(msg, *password)
}
if err != nil {
fmt.Fprintf(os.Stderr, "Encryption failed: %v\n", err)
os.Exit(1)
}
// Output the result
var output []byte
if *rawBytes {
output = encrypted
} else {
output = []byte(base64.StdEncoding.EncodeToString(encrypted))
}
if *outputFile != "" {
if err := os.WriteFile(*outputFile, output, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, "Encrypted message written to: %s\n", *outputFile)
} else {
fmt.Println(string(output))
}
// Print info
fmt.Fprintln(os.Stderr)
fmt.Fprintln(os.Stderr, "--- Message Info ---")
fmt.Fprintf(os.Stderr, "Password: %s\n", *password)
if *hint != "" {
fmt.Fprintf(os.Stderr, "Hint: %s\n", *hint)
}
fmt.Fprintf(os.Stderr, "From: %s\n", msg.From)
if msg.Subject != "" {
fmt.Fprintf(os.Stderr, "Subject: %s\n", msg.Subject)
}
if len(msg.Attachments) > 0 {
fmt.Fprintf(os.Stderr, "Attachments: %d\n", len(msg.Attachments))
}
if msg.ReplyKey != nil {
fmt.Fprintln(os.Stderr, "Reply Key: included")
}
}
// createDemoMessage creates a sample support reply message
func createDemoMessage() *smsg.Message {
return smsg.NewMessage(`Hello,
Thank you for contacting our support team. We have reviewed your request and are pleased to provide the following update.
Your account has been verified and all services are now active. If you have any further questions, please don't hesitate to reach out.
Best regards,
The Support Team`).
WithSubject("Re: Your Support Request #" + fmt.Sprintf("%d", time.Now().Unix()%100000)).
WithFrom("support@example.com").
SetMeta("ticket_id", fmt.Sprintf("%d", time.Now().Unix()%100000)).
SetMeta("priority", "normal")
}
// addAttachment reads a file and adds it as an attachment
func addAttachment(msg *smsg.Message, filePath string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return fmt.Errorf("reading file: %w", err)
}
name := filepath.Base(filePath)
content := base64.StdEncoding.EncodeToString(data)
mimeType := detectMimeType(filePath)
msg.AddAttachment(name, content, mimeType)
return nil
}
// detectMimeType returns a basic mime type based on file extension
func detectMimeType(path string) string {
ext := filepath.Ext(path)
switch ext {
case ".txt":
return "text/plain"
case ".html", ".htm":
return "text/html"
case ".css":
return "text/css"
case ".js":
return "application/javascript"
case ".json":
return "application/json"
case ".xml":
return "application/xml"
case ".pdf":
return "application/pdf"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".gif":
return "image/gif"
case ".svg":
return "image/svg+xml"
case ".zip":
return "application/zip"
case ".tar":
return "application/x-tar"
case ".gz":
return "application/gzip"
default:
return "application/octet-stream"
}
}

27
go.mod
View file

@ -3,14 +3,16 @@ module github.com/Snider/Borg
go 1.25.0
require (
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600
github.com/Snider/Enchantrix v0.0.2
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
)

59
go.sum
View file

@ -5,12 +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=
@ -37,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=
@ -49,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=
@ -62,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=
@ -75,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=
@ -100,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=
@ -123,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=

View file

@ -1,3 +1,5 @@
go 1.25.0
use .
use (
.
)

View file

@ -1,21 +1,132 @@
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=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
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=

106
js/borg-stmf/README.md Normal file
View file

@ -0,0 +1,106 @@
# @borg/stmf
Sovereign Form Encryption - Client-side form encryption using X25519 + ChaCha20-Poly1305.
## Overview
BorgSTMF encrypts HTML form data in the browser before submission, using the server's public key. Even if a MITM proxy intercepts the request, they only see encrypted data.
## Installation
```bash
npm install @borg/stmf
```
## Quick Start
```html
<!-- Load the WASM support -->
<script src="wasm_exec.js"></script>
<!-- Your form -->
<form id="login" action="/api/login" method="POST" data-stmf="YOUR_PUBLIC_KEY_BASE64">
<input name="email" type="email" required>
<input name="password" type="password" required>
<button type="submit">Login</button>
</form>
<script type="module">
import { BorgSTMF } from '@borg/stmf';
const borg = new BorgSTMF({
serverPublicKey: 'YOUR_PUBLIC_KEY_BASE64',
wasmPath: '/wasm/stmf.wasm'
});
await borg.init();
borg.enableInterceptor();
</script>
```
## Manual Encryption
```typescript
import { BorgSTMF } from '@borg/stmf';
const borg = new BorgSTMF({
serverPublicKey: 'YOUR_PUBLIC_KEY_BASE64'
});
await borg.init();
// Encrypt form element
const form = document.querySelector('form');
const result = await borg.encryptForm(form);
console.log(result.payload); // Base64 encrypted STMF
// Or encrypt key-value pairs directly
const result = await borg.encryptFields({
email: 'user@example.com',
password: 'secret'
});
```
## Server-Side Decryption
### Go Middleware
```go
import "github.com/Snider/Borg/pkg/stmf/middleware"
privateKey := os.Getenv("STMF_PRIVATE_KEY")
handler := middleware.Simple(privateKeyBytes)(yourHandler)
// In your handler, form values are automatically decrypted:
email := r.FormValue("email")
```
### PHP
```php
use Borg\STMF\STMF;
$stmf = new STMF($privateKeyBase64);
$formData = $stmf->decrypt($_POST['_stmf_payload']);
$email = $formData->get('email');
```
## Key Generation
Generate a keypair for your server:
```go
import "github.com/Snider/Borg/pkg/stmf"
kp, _ := stmf.GenerateKeyPair()
fmt.Println("Public key:", kp.PublicKeyBase64()) // Share this
fmt.Println("Private key:", kp.PrivateKeyBase64()) // Keep secret!
```
## Security
- **Hybrid encryption**: X25519 ECDH key exchange + ChaCha20-Poly1305
- **Forward secrecy**: Each form submission uses a new ephemeral keypair
- **Authenticated encryption**: Data integrity is verified on decryption
- **No passwords transmitted**: Only the public key is in the HTML

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

155
js/borg-stmf/demo.html Normal file
View file

@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>STMF Demo - Sovereign Form Encryption</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
.card { border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; margin: 1rem 0; }
input, button { padding: 0.5rem; margin: 0.25rem 0; }
input { width: 100%; box-sizing: border-box; }
button { background: #4CAF50; color: white; border: none; cursor: pointer; border-radius: 4px; }
button:hover { background: #45a049; }
pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; border-radius: 4px; font-size: 12px; }
.status { padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
.status.loading { background: #fff3cd; }
.status.ready { background: #d4edda; }
.status.error { background: #f8d7da; }
label { display: block; margin-top: 1rem; font-weight: bold; }
</style>
</head>
<body>
<h1>STMF Demo</h1>
<p>Sovereign Form Encryption using X25519 + ChaCha20-Poly1305</p>
<div id="status" class="status loading">Loading WASM module...</div>
<div class="card">
<h2>1. Generate Server Keypair</h2>
<p>In production, this is done server-side and the private key is kept secret.</p>
<button onclick="generateKeys()">Generate Keypair</button>
<label>Public Key (share with clients):</label>
<input type="text" id="publicKey" readonly placeholder="Click generate...">
<label>Private Key (keep secret!):</label>
<input type="text" id="privateKey" readonly placeholder="Click generate...">
</div>
<div class="card">
<h2>2. Encrypt Form Data</h2>
<form id="demoForm">
<label>Email:</label>
<input type="email" name="email" value="user@example.com" required>
<label>Password:</label>
<input type="password" name="password" value="supersecret123" required>
<label>Message:</label>
<input type="text" name="message" value="Hello, encrypted world!">
<button type="submit" style="margin-top: 1rem; width: 100%;">Encrypt Form</button>
</form>
<label>Encrypted Payload (base64):</label>
<pre id="encrypted" style="word-break: break-all;">Submit the form to see encrypted output...</pre>
</div>
<div class="card">
<h2>3. Payload Info</h2>
<p>This information can be read without decrypting (metadata is in the header):</p>
<pre id="info">Submit the form to see payload info...</pre>
</div>
<script src="dist/wasm_exec.js"></script>
<script>
let wasmReady = false;
// Load WASM
async function loadWasm() {
const go = new Go();
const result = await WebAssembly.instantiateStreaming(
fetch('dist/stmf.wasm'),
go.importObject
);
go.run(result.instance);
// Wait for BorgSTMF to be ready
while (!window.BorgSTMF?.ready) {
await new Promise(r => setTimeout(r, 50));
}
wasmReady = true;
document.getElementById('status').className = 'status ready';
document.getElementById('status').textContent =
`WASM loaded! Version: ${window.BorgSTMF.version}`;
}
loadWasm().catch(err => {
document.getElementById('status').className = 'status error';
document.getElementById('status').textContent = `Error: ${err.message}`;
});
// Generate keypair
async function generateKeys() {
if (!wasmReady) {
alert('WASM not loaded yet');
return;
}
try {
const keypair = await BorgSTMF.generateKeyPair();
document.getElementById('publicKey').value = keypair.publicKey;
document.getElementById('privateKey').value = keypair.privateKey;
} catch (err) {
alert('Error: ' + err.message);
}
}
// Handle form submission
document.getElementById('demoForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (!wasmReady) {
alert('WASM not loaded yet');
return;
}
const publicKey = document.getElementById('publicKey').value;
if (!publicKey) {
alert('Generate a keypair first!');
return;
}
try {
// Get form data
const formData = new FormData(e.target);
const fields = {};
formData.forEach((value, key) => {
fields[key] = value;
});
// Encrypt
const encrypted = await BorgSTMF.encryptFields(
fields,
publicKey,
{ origin: window.location.origin, timestamp: Date.now().toString() }
);
document.getElementById('encrypted').textContent = encrypted;
// Show info
document.getElementById('info').textContent = JSON.stringify({
payloadLength: encrypted.length,
payloadSizeKB: (encrypted.length * 0.75 / 1024).toFixed(2) + ' KB',
fieldsEncrypted: Object.keys(fields),
note: 'Each encryption produces different output (ephemeral keys)'
}, null, 2);
} catch (err) {
alert('Encryption error: ' + err.message);
console.error(err);
}
});
</script>
</body>
</html>

BIN
js/borg-stmf/dist/stmf.wasm vendored Executable file

Binary file not shown.

575
js/borg-stmf/dist/wasm_exec.js vendored Normal file
View file

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

556
js/borg-stmf/index.html Normal file
View file

@ -0,0 +1,556 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>STMF - Sovereign Form Encryption</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, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
padding: 2rem;
color: #e0e0e0;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 0.5rem;
font-size: 1.8rem;
background: linear-gradient(90deg, #00d9ff, #00ff94);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 2rem;
font-size: 0.9rem;
}
.card {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 2rem;
margin-bottom: 1.5rem;
border: 1px solid rgba(255,255,255,0.1);
backdrop-filter: blur(10px);
}
.card h2 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card h2 .icon {
font-size: 1.3rem;
}
.card p.description {
font-size: 0.85rem;
color: #888;
margin-bottom: 1rem;
}
.input-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #aaa;
font-size: 0.85rem;
}
textarea, input[type="text"], input[type="email"], input[type="password"] {
width: 100%;
padding: 0.8rem 1rem;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(0,0,0,0.3);
color: #fff;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.85rem;
resize: vertical;
}
textarea:focus, input:focus {
outline: none;
border-color: #00d9ff;
box-shadow: 0 0 0 3px rgba(0, 217, 255, 0.1);
}
input[readonly] {
background: rgba(0,0,0,0.5);
color: #00ff94;
cursor: default;
}
button {
padding: 0.8rem 2rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
}
button.primary {
background: linear-gradient(135deg, #00d9ff 0%, #00ff94 100%);
color: #000;
}
button.primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 217, 255, 0.4);
}
button.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: 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);
}
button.full-width {
width: 100%;
margin-top: 1rem;
}
.key-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 600px) {
.key-row {
grid-template-columns: 1fr;
}
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
padding: 0.5rem 0;
}
.status-indicator .dot {
width: 8px;
height: 8px;
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.5; }
}
pre {
background: rgba(0,0,0,0.4);
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
font-size: 0.75rem;
word-break: break-all;
white-space: pre-wrap;
color: #00ff94;
font-family: 'Monaco', 'Menlo', monospace;
max-height: 200px;
overflow-y: auto;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.info-item {
background: rgba(0,0,0,0.2);
padding: 1rem;
border-radius: 8px;
text-align: center;
}
.info-item .value {
font-size: 1.2rem;
font-weight: 600;
color: #00d9ff;
}
.info-item .label {
font-size: 0.75rem;
color: #888;
margin-top: 0.25rem;
}
.nav-links {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.nav-links a {
color: #00d9ff;
text-decoration: none;
font-size: 0.85rem;
padding: 0.5rem 1rem;
border-radius: 20px;
background: rgba(0, 217, 255, 0.1);
transition: all 0.2s;
}
.nav-links a:hover {
background: rgba(0, 217, 255, 0.2);
}
.nav-links a.active {
background: rgba(0, 217, 255, 0.3);
}
.warning-banner {
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 8px;
padding: 0.8rem 1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #ffc107;
}
.success-banner {
background: rgba(0, 255, 148, 0.1);
border: 1px solid rgba(0, 255, 148, 0.3);
border-radius: 8px;
padding: 0.8rem 1rem;
margin-top: 1rem;
display: none;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #00ff94;
}
.success-banner.visible {
display: flex;
}
.copy-btn {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
margin-left: auto;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 500px) {
.form-row {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Sovereign Form Encryption</h1>
<p class="subtitle">X25519 ECDH + ChaCha20-Poly1305 client-side encryption</p>
<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">
<span class="dot"></span>
<span>Loading encryption module...</span>
</div>
<div class="card">
<h2><span class="icon">🔑</span> Server Keypair</h2>
<p class="description">In production, generate this server-side and keep the private key secret. Only the public key is shared with clients.</p>
<button id="generate-btn" class="secondary" disabled>Generate New Keypair</button>
<div class="key-row" style="margin-top: 1rem;">
<div class="input-group">
<label>Public Key (share with clients)</label>
<input type="text" id="publicKey" readonly placeholder="Click generate...">
</div>
<div class="input-group">
<label>Private Key (keep secret!)</label>
<input type="text" id="privateKey" readonly placeholder="Click generate...">
</div>
</div>
</div>
<div class="card">
<h2><span class="icon">📝</span> Encrypt Form Data</h2>
<p class="description">Enter form fields to encrypt. Data is encrypted client-side before transmission.</p>
<div id="no-key-warning" class="warning-banner">
<span>⚠️</span>
<span>Generate a keypair first to enable encryption</span>
</div>
<form id="demoForm">
<div class="form-row">
<div class="input-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" value="user@example.com" required>
</div>
<div class="input-group">
<label for="password">Password</label>
<input type="password" id="form-password" name="password" value="supersecret123" required>
</div>
</div>
<div class="input-group">
<label for="message">Message</label>
<input type="text" id="message" name="message" value="Hello, encrypted world!">
</div>
<button type="submit" id="encrypt-btn" class="primary full-width" disabled>Encrypt Form Data</button>
</form>
<div id="success-banner" class="success-banner">
<span></span>
<span>Form encrypted successfully!</span>
<button class="secondary copy-btn" id="copy-btn">Copy</button>
</div>
</div>
<div class="card" id="output-card" style="display: none;">
<h2><span class="icon">🔒</span> Encrypted Output</h2>
<p class="description">This base64 payload can be safely transmitted. Only the server with the private key can decrypt it.</p>
<pre id="encrypted"></pre>
<div class="info-grid">
<div class="info-item">
<div class="value" id="payload-size">-</div>
<div class="label">Payload Size</div>
</div>
<div class="info-item">
<div class="value" id="fields-count">-</div>
<div class="label">Fields Encrypted</div>
</div>
<div class="info-item">
<div class="value" id="algo-type">X25519</div>
<div class="label">Key Exchange</div>
</div>
<div class="info-item">
<div class="value" id="cipher-type">ChaCha20</div>
<div class="label">Cipher</div>
</div>
</div>
</div>
<div class="card">
<h2><span class="icon"></span> How It Works</h2>
<p class="description" style="margin-bottom: 0; line-height: 1.7;">
<strong>1. Key Exchange:</strong> An ephemeral X25519 keypair is generated for each encryption.<br>
<strong>2. Shared Secret:</strong> ECDH derives a shared secret using the ephemeral private key and server's public key.<br>
<strong>3. Encryption:</strong> Form data is encrypted with ChaCha20-Poly1305 using the derived key.<br>
<strong>4. Payload:</strong> The ephemeral public key is included in the header so the server can decrypt.<br><br>
Each encryption produces a unique output even for the same data, ensuring forward secrecy.
</p>
</div>
</div>
<script src="wasm_exec.js"></script>
<script>
let wasmReady = false;
// Update status indicator safely
function updateStatus(el, status, message) {
el.className = 'status-indicator ' + status;
while (el.firstChild) el.removeChild(el.firstChild);
const dot = document.createElement('span');
dot.className = 'dot';
const text = document.createElement('span');
text.textContent = message;
el.appendChild(dot);
el.appendChild(text);
}
// Initialize WASM
async function initWasm() {
const statusEl = document.getElementById('wasm-status');
try {
const go = new Go();
const result = await WebAssembly.instantiateStreaming(
fetch('stmf.wasm'),
go.importObject
);
go.run(result.instance);
// Wait for BorgSTMF to be ready
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('WASM init timeout')), 5000);
if (typeof BorgSTMF !== 'undefined' && BorgSTMF.ready) {
clearTimeout(timeout);
resolve();
return;
}
document.addEventListener('borgstmf:ready', () => {
clearTimeout(timeout);
resolve();
});
});
wasmReady = true;
updateStatus(statusEl, 'ready', 'Encryption module ready (v' + BorgSTMF.version + ')');
document.getElementById('generate-btn').disabled = false;
} catch (err) {
updateStatus(statusEl, 'error', 'Failed to load: ' + err.message);
console.error('WASM init error:', err);
}
}
// Generate keypair
async function generateKeys() {
if (!wasmReady) return;
try {
const keypair = await BorgSTMF.generateKeyPair();
document.getElementById('publicKey').value = keypair.publicKey;
document.getElementById('privateKey').value = keypair.privateKey;
// Enable encryption
document.getElementById('encrypt-btn').disabled = false;
document.getElementById('no-key-warning').style.display = 'none';
} catch (err) {
alert('Error generating keys: ' + err.message);
}
}
// Handle form submission
async function handleFormSubmit(e) {
e.preventDefault();
if (!wasmReady) {
alert('WASM not loaded yet');
return;
}
const publicKey = document.getElementById('publicKey').value;
if (!publicKey) {
alert('Generate a keypair first!');
return;
}
try {
// Get form data
const formData = new FormData(e.target);
const fields = {};
formData.forEach((value, key) => {
fields[key] = value;
});
// Encrypt
const encrypted = await BorgSTMF.encryptFields(
fields,
publicKey,
{ origin: window.location.origin, timestamp: Date.now().toString() }
);
// Show output
document.getElementById('encrypted').textContent = encrypted;
document.getElementById('output-card').style.display = 'block';
document.getElementById('success-banner').classList.add('visible');
// Update stats
const sizeKB = (encrypted.length * 0.75 / 1024).toFixed(2);
document.getElementById('payload-size').textContent = sizeKB + ' KB';
document.getElementById('fields-count').textContent = Object.keys(fields).length;
// Scroll to output
document.getElementById('output-card').scrollIntoView({ behavior: 'smooth' });
} catch (err) {
alert('Encryption error: ' + err.message);
console.error(err);
}
}
// Copy to clipboard
async function copyToClipboard() {
const encrypted = document.getElementById('encrypted').textContent;
try {
await navigator.clipboard.writeText(encrypted);
const btn = document.getElementById('copy-btn');
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = 'Copy', 2000);
} catch (err) {
alert('Failed to copy: ' + err.message);
}
}
// Event listeners
document.getElementById('generate-btn').addEventListener('click', generateKeys);
document.getElementById('demoForm').addEventListener('submit', handleFormSubmit);
document.getElementById('copy-btn').addEventListener('click', copyToClipboard);
// Initialize
initWasm();
</script>
</body>
</html>

File diff suppressed because it is too large Load diff

36
js/borg-stmf/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "@borg/stmf",
"version": "1.0.0",
"description": "Sovereign Form Encryption - Client-side form encryption using X25519 + ChaCha20-Poly1305",
"main": "dist/borg-stmf.js",
"module": "dist/borg-stmf.esm.js",
"types": "dist/borg-stmf.d.ts",
"files": [
"dist/"
],
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"prepublishOnly": "npm run build"
},
"keywords": [
"encryption",
"form",
"security",
"chacha20",
"x25519",
"wasm",
"privacy"
],
"author": "Snider",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Snider/Borg"
},
"devDependencies": {
"@rollup/plugin-typescript": "^11.1.0",
"rollup": "^4.0.0",
"typescript": "^5.0.0"
}
}

345
js/borg-stmf/src/index.ts Normal file
View file

@ -0,0 +1,345 @@
import type {
BorgSTMFConfig,
FormData,
FormField,
EncryptResult,
KeyPair,
InterceptorOptions,
BorgSTMFWasm,
} from './types';
export * from './types';
const DEFAULT_FIELD_NAME = '_stmf_payload';
const DEFAULT_WASM_PATH = './stmf.wasm';
/**
* BorgSTMF - Sovereign Form Encryption
*
* Encrypts HTML form data client-side using the server's public key.
* Data is encrypted with X25519 ECDH + ChaCha20-Poly1305, providing
* end-to-end encryption even against MITM proxies.
*
* @example
* ```typescript
* const borg = new BorgSTMF({
* serverPublicKey: 'base64PublicKeyHere',
* wasmPath: '/wasm/stmf.wasm'
* });
*
* await borg.init();
*
* // Manual encryption
* const result = await borg.encryptForm(document.querySelector('form'));
*
* // Or use interceptor
* borg.enableInterceptor();
* ```
*/
export class BorgSTMF {
private config: Required<BorgSTMFConfig>;
private wasm: BorgSTMFWasm | null = null;
private initialized = false;
private interceptorActive = false;
private interceptorHandler: ((e: Event) => void) | null = null;
constructor(config: BorgSTMFConfig) {
this.config = {
serverPublicKey: config.serverPublicKey,
wasmPath: config.wasmPath || DEFAULT_WASM_PATH,
fieldName: config.fieldName || DEFAULT_FIELD_NAME,
debug: config.debug || false,
};
}
/**
* Initialize the WASM module. Must be called before encryption.
*/
async init(): Promise<void> {
if (this.initialized) return;
// Check if WASM is already loaded (e.g., from a script tag)
if (window.BorgSTMF?.ready) {
this.wasm = window.BorgSTMF;
this.initialized = true;
this.log('Using pre-loaded WASM module');
return;
}
// Load wasm_exec.js if not already loaded
if (typeof Go === 'undefined') {
await this.loadScript(this.config.wasmPath.replace('stmf.wasm', 'wasm_exec.js'));
}
// Load and instantiate the WASM module
const go = new Go();
const result = await WebAssembly.instantiateStreaming(
fetch(this.config.wasmPath),
go.importObject
);
// Run the Go main function
go.run(result.instance);
// Wait for WASM to be ready
await this.waitForWasm();
this.wasm = window.BorgSTMF!;
this.initialized = true;
this.log('WASM module initialized, version:', this.wasm.version);
}
/**
* Encrypt an HTML form element
*/
async encryptForm(form: HTMLFormElement): Promise<EncryptResult> {
this.ensureInitialized();
const formData = new window.FormData(form);
return this.encryptFormData(formData);
}
/**
* Encrypt a FormData object
*/
async encryptFormData(formData: globalThis.FormData): Promise<EncryptResult> {
this.ensureInitialized();
const fields: Record<string, string | FormField> = {};
formData.forEach((value, key) => {
if (value instanceof File) {
// Handle file uploads - read as base64
// Note: For large files, consider chunking or streaming
this.log('File field detected:', key, value.name);
// For now, skip files - they need async reading
// TODO: Add file support with FileReader
} else {
fields[key] = value.toString();
}
});
const payload = await this.wasm!.encryptFields(
fields,
this.config.serverPublicKey,
{
origin: window.location.origin,
timestamp: Date.now().toString(),
}
);
return {
payload,
fieldName: this.config.fieldName,
};
}
/**
* Encrypt a simple key-value object
*/
async encryptFields(
fields: Record<string, string>,
metadata?: Record<string, string>
): Promise<EncryptResult> {
this.ensureInitialized();
const meta = {
origin: window.location.origin,
timestamp: Date.now().toString(),
...metadata,
};
const payload = await this.wasm!.encryptFields(
fields,
this.config.serverPublicKey,
meta
);
return {
payload,
fieldName: this.config.fieldName,
};
}
/**
* Encrypt a full FormData structure
*/
async encryptFormDataStruct(data: FormData): Promise<EncryptResult> {
this.ensureInitialized();
const payload = await this.wasm!.encrypt(
JSON.stringify(data),
this.config.serverPublicKey
);
return {
payload,
fieldName: this.config.fieldName,
};
}
/**
* Generate a new keypair (for testing/development only)
*/
async generateKeyPair(): Promise<KeyPair> {
this.ensureInitialized();
return this.wasm!.generateKeyPair();
}
/**
* Enable automatic form interception.
* Intercepts submit events on forms with the data-stmf attribute.
*/
enableInterceptor(options: InterceptorOptions = {}): void {
if (this.interceptorActive) return;
const { autoSubmit = true } = options;
this.interceptorHandler = async (e: Event) => {
const form = e.target as HTMLFormElement;
// Check if this form should be intercepted
const publicKey = form.dataset.stmf;
if (!publicKey && !options.selector) return;
if (options.selector && !form.matches(options.selector)) return;
e.preventDefault();
e.stopPropagation();
try {
// Use form's public key or default config
const serverKey = publicKey || this.config.serverPublicKey;
// Callback before encryption
if (options.onBeforeEncrypt) {
const proceed = await options.onBeforeEncrypt(form);
if (proceed === false) return;
}
// Encrypt the form
const originalFormData = new window.FormData(form);
const fields: Record<string, string> = {};
originalFormData.forEach((value, key) => {
if (!(value instanceof File)) {
fields[key] = value.toString();
}
});
const payload = await this.wasm!.encryptFields(
fields,
serverKey,
{
origin: window.location.origin,
timestamp: Date.now().toString(),
formId: form.id || undefined,
}
);
// Callback after encryption
if (options.onAfterEncrypt) {
options.onAfterEncrypt(form, payload);
}
if (autoSubmit) {
// Create new form data with only the encrypted payload
const encryptedFormData = new window.FormData();
encryptedFormData.append(this.config.fieldName, payload);
// Submit via fetch
const response = await fetch(form.action || window.location.href, {
method: form.method || 'POST',
body: encryptedFormData,
});
// Handle response - trigger custom event
const event = new CustomEvent('borgstmf:submitted', {
detail: { form, response, payload },
});
form.dispatchEvent(event);
}
} catch (error) {
this.log('Encryption error:', error);
if (options.onError) {
options.onError(form, error as Error);
} else {
throw error;
}
}
};
document.addEventListener('submit', this.interceptorHandler, true);
this.interceptorActive = true;
this.log('Form interceptor enabled');
}
/**
* Disable automatic form interception
*/
disableInterceptor(): void {
if (!this.interceptorActive || !this.interceptorHandler) return;
document.removeEventListener('submit', this.interceptorHandler, true);
this.interceptorHandler = null;
this.interceptorActive = false;
this.log('Form interceptor disabled');
}
/**
* Check if the module is initialized
*/
isInitialized(): boolean {
return this.initialized;
}
/**
* Get the WASM module version
*/
getVersion(): string {
return this.wasm?.version || 'not loaded';
}
private ensureInitialized(): void {
if (!this.initialized || !this.wasm) {
throw new Error('BorgSTMF not initialized. Call init() first.');
}
}
private async waitForWasm(timeout = 5000): Promise<void> {
const start = Date.now();
while (!window.BorgSTMF?.ready) {
if (Date.now() - start > timeout) {
throw new Error('Timeout waiting for WASM module to initialize');
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
private async loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load ${src}`));
document.head.appendChild(script);
});
}
private log(...args: unknown[]): void {
if (this.config.debug) {
console.log('[BorgSTMF]', ...args);
}
}
}
// Export a factory function for convenience
export function createBorgSTMF(config: BorgSTMFConfig): BorgSTMF {
return new BorgSTMF(config);
}
// Export types for the Go interface
declare class Go {
constructor();
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
}

121
js/borg-stmf/src/types.ts Normal file
View file

@ -0,0 +1,121 @@
/**
* Configuration options for BorgSTMF
*/
export interface BorgSTMFConfig {
/**
* Base64-encoded X25519 public key of the server.
* Form data will be encrypted using this key.
*/
serverPublicKey: string;
/**
* Path to the WASM file.
* @default './stmf.wasm'
*/
wasmPath?: string;
/**
* Name of the form field that will contain the encrypted payload.
* @default '_stmf_payload'
*/
fieldName?: string;
/**
* Enable debug logging.
* @default false
*/
debug?: boolean;
}
/**
* Form field definition
*/
export interface FormField {
name: string;
value: string;
type?: string;
filename?: string;
mime?: string;
}
/**
* Form data structure for encryption
*/
export interface FormData {
fields: FormField[];
meta?: Record<string, string>;
}
/**
* Result of encrypting form data
*/
export interface EncryptResult {
/** Base64-encoded encrypted STMF payload */
payload: string;
/** Name of the form field for the payload */
fieldName: string;
}
/**
* X25519 keypair (for testing/development)
*/
export interface KeyPair {
/** Base64-encoded public key */
publicKey: string;
/** Base64-encoded private key (keep secret!) */
privateKey: string;
}
/**
* Options for the form interceptor
*/
export interface InterceptorOptions {
/**
* CSS selector for forms to intercept.
* If not specified, intercepts forms with data-stmf attribute.
*/
selector?: string;
/**
* Callback before encryption.
* Return false to cancel encryption.
*/
onBeforeEncrypt?: (form: HTMLFormElement) => boolean | Promise<boolean>;
/**
* Callback after encryption.
*/
onAfterEncrypt?: (form: HTMLFormElement, payload: string) => void;
/**
* Callback on encryption error.
*/
onError?: (form: HTMLFormElement, error: Error) => void;
/**
* Whether to submit the form automatically after encryption.
* @default true
*/
autoSubmit?: boolean;
}
/**
* BorgSTMF WASM module interface
*/
export interface BorgSTMFWasm {
encrypt: (formDataJSON: string, serverPublicKey: string) => Promise<string>;
encryptFields: (
fields: Record<string, string | FormField>,
serverPublicKey: string,
metadata?: Record<string, string>
) => Promise<string>;
generateKeyPair: () => Promise<KeyPair>;
version: string;
ready: boolean;
}
declare global {
interface Window {
BorgSTMF?: BorgSTMFWasm;
}
}

BIN
js/borg-stmf/stmf.wasm Executable file

Binary file not shown.

View file

@ -0,0 +1,799 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Decrypt Secure Support Reply</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, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
padding: 2rem;
color: #e0e0e0;
}
.container {
max-width: 800px;
margin: 0 auto;
}
h1 {
text-align: center;
margin-bottom: 0.5rem;
font-size: 1.8rem;
background: linear-gradient(90deg, #00d9ff, #00ff94);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 2rem;
font-size: 0.9rem;
}
.card {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 2rem;
margin-bottom: 1.5rem;
border: 1px solid rgba(255,255,255,0.1);
backdrop-filter: blur(10px);
}
.card h2 {
font-size: 1.1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.card h2 .icon {
font-size: 1.3rem;
}
.input-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #aaa;
font-size: 0.85rem;
}
textarea, input[type="password"], input[type="text"] {
width: 100%;
padding: 0.8rem 1rem;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(0,0,0,0.3);
color: #fff;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.85rem;
resize: vertical;
}
textarea:focus, input:focus {
outline: none;
border-color: #00d9ff;
box-shadow: 0 0 0 3px rgba(0, 217, 255, 0.1);
}
textarea.encrypted {
min-height: 120px;
font-size: 0.75rem;
word-break: break-all;
}
.password-row {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.password-row .input-group {
flex: 1;
margin-bottom: 0;
}
button {
padding: 0.8rem 2rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-size: 0.9rem;
}
button.primary {
background: linear-gradient(135deg, #00d9ff 0%, #00ff94 100%);
color: #000;
}
button.primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 217, 255, 0.4);
}
button.primary:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: 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);
}
.hint-banner {
background: rgba(255, 193, 7, 0.1);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 8px;
padding: 0.8rem 1rem;
margin-bottom: 1rem;
display: none;
}
.hint-banner.visible {
display: flex;
align-items: center;
gap: 0.5rem;
}
.hint-banner .hint-icon {
font-size: 1.2rem;
}
.hint-banner .hint-text {
color: #ffc107;
font-size: 0.9rem;
}
.message-container {
display: none;
}
.message-container.visible {
display: block;
}
.message-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.message-from {
font-weight: 600;
color: #00d9ff;
}
.message-date {
font-size: 0.8rem;
color: #888;
}
.message-subject {
font-size: 1.2rem;
font-weight: 600;
margin-bottom: 1rem;
}
.message-body {
line-height: 1.7;
white-space: pre-wrap;
}
.attachments {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid rgba(255,255,255,0.1);
}
.attachments h3 {
font-size: 0.9rem;
color: #888;
margin-bottom: 0.8rem;
}
.attachment-item {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.6rem 1rem;
background: rgba(0,0,0,0.2);
border-radius: 8px;
margin-bottom: 0.5rem;
}
.attachment-icon {
font-size: 1.5rem;
}
.attachment-info {
flex: 1;
}
.attachment-name {
font-weight: 500;
}
.attachment-meta {
font-size: 0.75rem;
color: #888;
}
.attachment-download {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
.reply-key-banner {
background: rgba(0, 217, 255, 0.1);
border: 1px solid rgba(0, 217, 255, 0.3);
border-radius: 8px;
padding: 1rem;
margin-top: 1.5rem;
display: none;
}
.reply-key-banner.visible {
display: block;
}
.reply-key-banner h4 {
font-size: 0.9rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.reply-key-banner p {
font-size: 0.8rem;
color: #aaa;
margin-bottom: 0.8rem;
}
.reply-key-value {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.7rem;
background: rgba(0,0,0,0.3);
padding: 0.5rem;
border-radius: 4px;
word-break: break-all;
}
.error-banner {
background: rgba(255, 82, 82, 0.1);
border: 1px solid rgba(255, 82, 82, 0.3);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: none;
color: #ff5252;
}
.error-banner.visible {
display: block;
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
padding: 0.5rem 0;
}
.status-indicator .dot {
width: 8px;
height: 8px;
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.5; }
}
.demo-section {
border-top: 1px dashed rgba(255,255,255,0.1);
padding-top: 1.5rem;
margin-top: 1.5rem;
}
.demo-section h3 {
font-size: 0.9rem;
color: #888;
margin-bottom: 1rem;
}
.example-messages {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.example-messages button {
padding: 0.5rem 1rem;
font-size: 0.8rem;
}
.nav-links {
display: flex;
justify-content: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.nav-links a {
color: #00d9ff;
text-decoration: none;
font-size: 0.85rem;
padding: 0.5rem 1rem;
border-radius: 20px;
background: rgba(0, 217, 255, 0.1);
transition: all 0.2s;
}
.nav-links a:hover {
background: rgba(0, 217, 255, 0.2);
}
.nav-links a.active {
background: rgba(0, 217, 255, 0.3);
}
</style>
</head>
<body>
<div class="container">
<h1>Secure Support Reply</h1>
<p class="subtitle">Decrypt password-protected messages from support</p>
<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">
<span class="dot"></span>
<span>Loading encryption module...</span>
</div>
<div class="card">
<h2><span class="icon">📨</span> Encrypted Message</h2>
<div class="input-group">
<label for="encrypted-message">Paste the encrypted message you received:</label>
<textarea id="encrypted-message" class="encrypted" placeholder="U01TRy4uLg=="></textarea>
</div>
<div id="hint-banner" class="hint-banner">
<span class="hint-icon">💡</span>
<span class="hint-text">Password hint: <strong id="hint-text"></strong></span>
</div>
<div id="error-banner" class="error-banner"></div>
<div class="password-row">
<div class="input-group">
<label for="password">Password:</label>
<input type="password" id="password" placeholder="Enter your password">
</div>
<button id="decrypt-btn" class="primary" disabled>Decrypt</button>
</div>
</div>
<div id="message-container" class="card message-container">
<h2><span class="icon">📬</span> Decrypted Message</h2>
<div class="message-header">
<div>
<div class="message-from" id="msg-from">Support Team</div>
<div id="msg-subject" class="message-subject"></div>
</div>
<div class="message-date" id="msg-date"></div>
</div>
<div class="message-body" id="msg-body"></div>
<div id="attachments-container" class="attachments" style="display: none;">
<h3>Attachments</h3>
<div id="attachments-list"></div>
</div>
<div id="reply-key-banner" class="reply-key-banner">
<h4><span>🔐</span> Authenticated Reply Key</h4>
<p>This message includes a public key for secure replies. Use this to encrypt your response:</p>
<div class="reply-key-value" id="reply-key"></div>
</div>
</div>
<div class="card">
<div class="demo-section">
<h3>Demo: Try with sample messages</h3>
<p style="font-size: 0.85rem; color: #888; margin-bottom: 1rem;">
Click a button to load a pre-encrypted sample message. All use password: <code style="background: rgba(0,0,0,0.3); padding: 0.2rem 0.4rem; border-radius: 4px;">demo123</code>
</p>
<div class="example-messages" id="example-buttons"></div>
</div>
</div>
</div>
<script src="wasm_exec.js"></script>
<script>
// Example encrypted messages (will be populated by WASM generation)
const EXAMPLES = {
'simple': '',
'with-attachment': '',
'with-hint': '',
'with-reply-key': ''
};
// Store attachment data for downloads
const attachmentData = new Map();
let wasmReady = false;
// Initialize WASM
async function initWasm() {
const statusEl = document.getElementById('wasm-status');
try {
const go = new Go();
const result = await WebAssembly.instantiateStreaming(
fetch('stmf.wasm'),
go.importObject
);
go.run(result.instance);
// Wait for BorgSMSG to be ready
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('WASM init timeout')), 5000);
if (typeof BorgSMSG !== 'undefined' && BorgSMSG.ready) {
clearTimeout(timeout);
resolve();
return;
}
document.addEventListener('borgstmf:ready', () => {
clearTimeout(timeout);
resolve();
});
});
wasmReady = true;
updateStatus(statusEl, 'ready', 'Encryption module ready (v' + BorgSMSG.version + ')');
document.getElementById('decrypt-btn').disabled = false;
// Generate example messages
await generateExamples();
setupExampleButtons();
} catch (err) {
updateStatus(statusEl, 'error', 'Failed to load: ' + err.message);
console.error('WASM init error:', err);
}
}
// Update status indicator safely
function updateStatus(el, status, message) {
el.className = 'status-indicator ' + status;
// Clear and rebuild safely
while (el.firstChild) el.removeChild(el.firstChild);
const dot = document.createElement('span');
dot.className = 'dot';
const text = document.createElement('span');
text.textContent = message;
el.appendChild(dot);
el.appendChild(text);
}
// Setup example buttons safely
function setupExampleButtons() {
const container = document.getElementById('example-buttons');
const examples = [
{ key: 'simple', label: 'Simple Message' },
{ key: 'with-attachment', label: 'With Attachment' },
{ key: 'with-hint', label: 'With Password Hint' },
{ key: 'with-reply-key', label: 'With Reply Key' }
];
examples.forEach(ex => {
const btn = document.createElement('button');
btn.className = 'secondary';
btn.textContent = ex.label;
btn.addEventListener('click', () => loadExample(ex.key));
container.appendChild(btn);
});
}
// Generate example encrypted messages
async function generateExamples() {
try {
// Simple message
EXAMPLES['simple'] = await BorgSMSG.encrypt({
body: 'Hello! Thank you for contacting our support team.\n\nWe have reviewed your request and are happy to help. Please let us know if you have any other questions.\n\nBest regards,\nThe Support Team',
subject: 'Re: Your Support Request #12345',
from: 'support@example.com'
}, 'demo123');
// With attachment
const fileContent = btoa('This is the content of the attached file.\nIt contains important information.');
EXAMPLES['with-attachment'] = await BorgSMSG.encrypt({
body: 'Please find the requested document attached to this message.\n\nThe file contains the information you requested about your account.',
subject: 'Document Attached',
from: 'documents@example.com',
attachments: [{
name: 'account-details.txt',
content: fileContent,
mime: 'text/plain'
}]
}, 'demo123');
// With password hint
EXAMPLES['with-hint'] = await BorgSMSG.encrypt({
body: 'This is a confidential message that requires your password to view.\n\nYour account has been updated as requested.',
subject: 'Account Update Confirmation',
from: 'security@example.com'
}, 'demo123', 'demo + 123');
// With reply key
EXAMPLES['with-reply-key'] = await BorgSMSG.encrypt({
body: 'This message includes a public key for secure replies.\n\nWhen you reply, use the attached public key to encrypt your response. This ensures only we can read your reply.',
subject: 'Secure Communication Channel',
from: 'secure@example.com',
replyKey: {
publicKey: 'dGVzdHB1YmxpY2tleWZvcmRlbW9wdXJwb3Nlcw=='
}
}, 'demo123');
console.log('Example messages generated');
} catch (err) {
console.error('Failed to generate examples:', err);
}
}
// Load example message
function loadExample(type) {
const textarea = document.getElementById('encrypted-message');
textarea.value = EXAMPLES[type];
checkForHint();
}
// Check for password hint
async function checkForHint() {
const encryptedB64 = document.getElementById('encrypted-message').value.trim();
const hintBanner = document.getElementById('hint-banner');
const hintText = document.getElementById('hint-text');
hintBanner.classList.remove('visible');
if (!encryptedB64 || !wasmReady) return;
try {
const info = await BorgSMSG.getInfo(encryptedB64);
if (info.hint) {
hintText.textContent = info.hint;
hintBanner.classList.add('visible');
}
} catch (err) {
// Silently ignore - invalid format
}
}
// Decrypt message
async function decryptMessage() {
const encryptedB64 = document.getElementById('encrypted-message').value.trim();
const password = document.getElementById('password').value;
const errorBanner = document.getElementById('error-banner');
const messageContainer = document.getElementById('message-container');
errorBanner.classList.remove('visible');
messageContainer.classList.remove('visible');
if (!encryptedB64) {
showError('Please paste an encrypted message');
return;
}
if (!password) {
showError('Please enter the password');
return;
}
try {
const message = await BorgSMSG.decrypt(encryptedB64, password);
displayMessage(message);
} catch (err) {
showError('Decryption failed: ' + err.message);
}
}
// Show error
function showError(msg) {
const errorBanner = document.getElementById('error-banner');
errorBanner.textContent = msg;
errorBanner.classList.add('visible');
}
// Display decrypted message
function displayMessage(msg) {
document.getElementById('msg-from').textContent = msg.from || 'Unknown Sender';
document.getElementById('msg-subject').textContent = msg.subject || '(No Subject)';
document.getElementById('msg-body').textContent = msg.body;
// Format date
if (msg.timestamp) {
const date = new Date(msg.timestamp * 1000);
document.getElementById('msg-date').textContent = date.toLocaleString();
} else {
document.getElementById('msg-date').textContent = '';
}
// Handle attachments
const attachmentsContainer = document.getElementById('attachments-container');
const attachmentsList = document.getElementById('attachments-list');
// Clear previous attachments
while (attachmentsList.firstChild) {
attachmentsList.removeChild(attachmentsList.firstChild);
}
attachmentData.clear();
if (msg.attachments && msg.attachments.length > 0) {
attachmentsContainer.style.display = 'block';
msg.attachments.forEach((att, index) => {
// Store attachment data
const attId = 'att-' + index;
attachmentData.set(attId, {
name: att.name,
content: att.content,
mime: att.mime
});
const item = document.createElement('div');
item.className = 'attachment-item';
const iconSpan = document.createElement('span');
iconSpan.className = 'attachment-icon';
iconSpan.textContent = getFileIcon(att.mime);
const infoDiv = document.createElement('div');
infoDiv.className = 'attachment-info';
const nameDiv = document.createElement('div');
nameDiv.className = 'attachment-name';
nameDiv.textContent = att.name;
const metaDiv = document.createElement('div');
metaDiv.className = 'attachment-meta';
metaDiv.textContent = att.mime || 'unknown type';
infoDiv.appendChild(nameDiv);
infoDiv.appendChild(metaDiv);
const downloadBtn = document.createElement('button');
downloadBtn.className = 'secondary attachment-download';
downloadBtn.textContent = 'Download';
downloadBtn.dataset.attId = attId;
downloadBtn.addEventListener('click', function() {
downloadAttachment(this.dataset.attId);
});
item.appendChild(iconSpan);
item.appendChild(infoDiv);
item.appendChild(downloadBtn);
attachmentsList.appendChild(item);
});
} else {
attachmentsContainer.style.display = 'none';
}
// Handle reply key
const replyKeyBanner = document.getElementById('reply-key-banner');
if (msg.replyKey && msg.replyKey.publicKey) {
document.getElementById('reply-key').textContent = msg.replyKey.publicKey;
replyKeyBanner.classList.add('visible');
} else {
replyKeyBanner.classList.remove('visible');
}
document.getElementById('message-container').classList.add('visible');
}
// Get file icon based on mime type
function getFileIcon(mime) {
if (!mime) return '📄';
if (mime.startsWith('image/')) return '🖼️';
if (mime.startsWith('video/')) return '🎬';
if (mime.startsWith('audio/')) return '🎵';
if (mime.includes('pdf')) return '📕';
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gzip')) return '📦';
if (mime.includes('json') || mime.includes('xml')) return '📋';
return '📄';
}
// Download attachment
function downloadAttachment(attId) {
const att = attachmentData.get(attId);
if (!att) {
alert('Attachment not found');
return;
}
try {
const binary = atob(att.content);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = att.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
alert('Failed to download: ' + err.message);
}
}
// Event listeners
document.getElementById('decrypt-btn').addEventListener('click', decryptMessage);
document.getElementById('password').addEventListener('keypress', (e) => {
if (e.key === 'Enter') decryptMessage();
});
document.getElementById('encrypted-message').addEventListener('input', checkForHint);
// Initialize
initWasm();
</script>
</body>
</html>

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020", "DOM"],
"declaration": true,
"declarationDir": "./dist",
"outDir": "./dist",
"strict": true,
"moduleResolution": "bundler",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

575
js/borg-stmf/wasm_exec.js Normal file
View file

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

147
php/borg-stmf/README.md Normal file
View file

@ -0,0 +1,147 @@
# Borg STMF for PHP
Sovereign Form Encryption - Decrypt STMF payloads using X25519 + ChaCha20-Poly1305.
## Requirements
- PHP 7.2 or later
- `ext-sodium` (included in PHP 7.2+)
- `ext-json`
## Installation
```bash
composer require borg/stmf
```
## Quick Start
```php
<?php
use Borg\STMF\STMF;
// Initialize with your private key
$stmf = new STMF($privateKeyBase64);
// Decrypt the form payload from POST
$formData = $stmf->decrypt($_POST['_stmf_payload']);
// Access form fields
$email = $formData->get('email');
$password = $formData->get('password');
// Access all fields as array
$allFields = $formData->toArray();
// Access metadata
$origin = $formData->getOrigin();
$timestamp = $formData->getTimestamp();
```
## Laravel Integration
```php
// In a controller
public function handleForm(Request $request)
{
$stmf = new STMF(config('app.stmf_private_key'));
$formData = $stmf->decrypt($request->input('_stmf_payload'));
// Use decrypted data
$user = User::create([
'email' => $formData->get('email'),
'password' => Hash::make($formData->get('password')),
]);
}
```
## Key Generation
Generate a keypair in Go:
```go
import "github.com/Snider/Borg/pkg/stmf"
kp, _ := stmf.GenerateKeyPair()
fmt.Println("Public key:", kp.PublicKeyBase64()) // Put in HTML
fmt.Println("Private key:", kp.PrivateKeyBase64()) // Put in PHP config
```
Or generate in PHP (for testing):
```php
use Borg\STMF\KeyPair;
$keypair = KeyPair::generate();
echo "Public: " . $keypair->getPublicKeyBase64() . "\n";
echo "Private: " . $keypair->getPrivateKeyBase64() . "\n";
```
## API Reference
### STMF
```php
// Constructor
$stmf = new STMF(string $privateKeyBase64);
// Decrypt a base64-encoded payload
$formData = $stmf->decrypt(string $payloadBase64): FormData;
// Decrypt raw bytes
$formData = $stmf->decryptRaw(string $payload): FormData;
// Validate without decrypting
$isValid = $stmf->validate(string $payloadBase64): bool;
// Get payload info without decrypting
$info = $stmf->getInfo(string $payloadBase64): array;
```
### FormData
```php
// Get a single field value
$value = $formData->get(string $name): ?string;
// Get a field object (includes type, filename, mime)
$field = $formData->getField(string $name): ?FormField;
// Get all values for a field name
$values = $formData->getAll(string $name): array;
// Check if field exists
$exists = $formData->has(string $name): bool;
// Convert to associative array
$array = $formData->toArray(): array;
// Get all fields
$fields = $formData->fields(): array;
// Get metadata
$meta = $formData->getMetadata(): array;
$origin = $formData->getOrigin(): ?string;
$timestamp = $formData->getTimestamp(): ?int;
```
### FormField
```php
$field->name; // Field name
$field->value; // Field value
$field->type; // Field type (text, password, file, etc.)
$field->filename; // Filename for file uploads
$field->mimeType; // MIME type for file uploads
$field->isFile(): bool; // Check if this is a file field
$field->getFileContent(): ?string; // Get decoded file content
```
## Security
- **Hybrid encryption**: X25519 ECDH key exchange + ChaCha20-Poly1305
- **Forward secrecy**: Each form submission uses a new ephemeral keypair
- **Authenticated encryption**: Decryption fails if data was tampered with
- **Libsodium**: Uses PHP's built-in sodium extension

View file

@ -0,0 +1,34 @@
{
"name": "borg/stmf",
"description": "Sovereign Form Encryption - Decrypt STMF payloads using X25519 + ChaCha20-Poly1305",
"type": "library",
"license": "MIT",
"authors": [
{
"name": "Snider",
"email": "snider@example.com"
}
],
"require": {
"php": ">=7.2",
"ext-sodium": "*",
"ext-json": "*"
},
"require-dev": {
"phpunit/phpunit": "^9.0"
},
"autoload": {
"psr-4": {
"Borg\\STMF\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Borg\\STMF\\Tests\\": "tests/"
}
},
"scripts": {
"test": "phpunit"
},
"minimum-stability": "stable"
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* Exception thrown when decryption fails
*/
class DecryptionException extends \RuntimeException
{
}

View file

@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* Represents decrypted form data
*/
class FormData
{
/** @var FormField[] */
private array $fields;
/** @var array<string, string> */
private array $metadata;
/**
* @param FormField[] $fields
* @param array<string, string> $metadata
*/
public function __construct(array $fields, array $metadata = [])
{
$this->fields = $fields;
$this->metadata = $metadata;
}
/**
* Get a field value by name
*/
public function get(string $name): ?string
{
foreach ($this->fields as $field) {
if ($field->name === $name) {
return $field->value;
}
}
return null;
}
/**
* Get a field object by name
*/
public function getField(string $name): ?FormField
{
foreach ($this->fields as $field) {
if ($field->name === $name) {
return $field;
}
}
return null;
}
/**
* Get all values for a field name (for multi-select)
*
* @return string[]
*/
public function getAll(string $name): array
{
$values = [];
foreach ($this->fields as $field) {
if ($field->name === $name) {
$values[] = $field->value;
}
}
return $values;
}
/**
* Get all fields
*
* @return FormField[]
*/
public function fields(): array
{
return $this->fields;
}
/**
* Check if a field exists
*/
public function has(string $name): bool
{
foreach ($this->fields as $field) {
if ($field->name === $name) {
return true;
}
}
return false;
}
/**
* Convert to associative array (last value wins for duplicates)
*
* @return array<string, string>
*/
public function toArray(): array
{
$result = [];
foreach ($this->fields as $field) {
$result[$field->name] = $field->value;
}
return $result;
}
/**
* Get metadata
*
* @return array<string, string>
*/
public function getMetadata(): array
{
return $this->metadata;
}
/**
* Get a specific metadata value
*/
public function getMeta(string $key): ?string
{
return $this->metadata[$key] ?? null;
}
/**
* Get the origin (if set in metadata)
*/
public function getOrigin(): ?string
{
return $this->metadata['origin'] ?? null;
}
/**
* Get the timestamp (if set in metadata)
*/
public function getTimestamp(): ?int
{
$ts = $this->metadata['timestamp'] ?? null;
return $ts !== null ? (int) $ts : null;
}
/**
* Create from decoded JSON array
*/
public static function fromArray(array $data): self
{
$fields = [];
foreach ($data['fields'] ?? [] as $fieldData) {
$fields[] = FormField::fromArray($fieldData);
}
return new self($fields, $data['meta'] ?? []);
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* Represents a single form field
*/
class FormField
{
public string $name;
public string $value;
public ?string $type;
public ?string $filename;
public ?string $mimeType;
public function __construct(
string $name,
string $value,
?string $type = null,
?string $filename = null,
?string $mimeType = null
) {
$this->name = $name;
$this->value = $value;
$this->type = $type;
$this->filename = $filename;
$this->mimeType = $mimeType;
}
/**
* Check if this is a file field
*/
public function isFile(): bool
{
return $this->type === 'file';
}
/**
* Get the file content decoded from base64
*/
public function getFileContent(): ?string
{
if (!$this->isFile()) {
return null;
}
return base64_decode($this->value, true) ?: null;
}
/**
* Create from array
*/
public static function fromArray(array $data): self
{
return new self(
$data['name'] ?? '',
$data['value'] ?? '',
$data['type'] ?? null,
$data['filename'] ?? null,
$data['mime'] ?? null
);
}
}

View file

@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* Exception thrown when the STMF payload is invalid
*/
class InvalidPayloadException extends \RuntimeException
{
}

View file

@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* X25519 keypair for STMF encryption/decryption
*/
class KeyPair
{
private string $publicKey;
private string $privateKey;
/**
* @param string $publicKey Raw public key bytes (32 bytes)
* @param string $privateKey Raw private key bytes (32 bytes)
*/
public function __construct(string $publicKey, string $privateKey)
{
if (strlen($publicKey) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) {
throw new \InvalidArgumentException(
'Public key must be ' . SODIUM_CRYPTO_BOX_PUBLICKEYBYTES . ' bytes'
);
}
if (strlen($privateKey) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES) {
throw new \InvalidArgumentException(
'Private key must be ' . SODIUM_CRYPTO_BOX_SECRETKEYBYTES . ' bytes'
);
}
$this->publicKey = $publicKey;
$this->privateKey = $privateKey;
}
/**
* Generate a new X25519 keypair
*/
public static function generate(): self
{
$keypair = sodium_crypto_box_keypair();
return new self(
sodium_crypto_box_publickey($keypair),
sodium_crypto_box_secretkey($keypair)
);
}
/**
* Load keypair from base64-encoded private key
*/
public static function fromPrivateKeyBase64(string $privateKeyBase64): self
{
$privateKey = base64_decode($privateKeyBase64, true);
if ($privateKey === false) {
throw new \InvalidArgumentException('Invalid base64 private key');
}
// Derive public key from private key
$publicKey = sodium_crypto_scalarmult_base($privateKey);
return new self($publicKey, $privateKey);
}
/**
* Get the raw public key bytes
*/
public function getPublicKey(): string
{
return $this->publicKey;
}
/**
* Get the raw private key bytes
*/
public function getPrivateKey(): string
{
return $this->privateKey;
}
/**
* Get the public key as base64
*/
public function getPublicKeyBase64(): string
{
return base64_encode($this->publicKey);
}
/**
* Get the private key as base64
*/
public function getPrivateKeyBase64(): string
{
return base64_encode($this->privateKey);
}
}

312
php/borg-stmf/src/STMF.php Normal file
View file

@ -0,0 +1,312 @@
<?php
declare(strict_types=1);
namespace Borg\STMF;
/**
* STMF - Sovereign Form Encryption
*
* Decrypts STMF payloads that were encrypted client-side using the server's public key.
* Uses X25519 ECDH key exchange + ChaCha20-Poly1305 authenticated encryption.
*
* @example
* ```php
* $stmf = new STMF($privateKeyBase64);
* $formData = $stmf->decrypt($_POST['_stmf_payload']);
*
* $email = $formData->get('email');
* $password = $formData->get('password');
* ```
*/
class STMF
{
private const MAGIC = 'STMF';
private string $privateKey;
/**
* @param string $privateKeyBase64 Base64-encoded X25519 private key
*/
public function __construct(string $privateKeyBase64)
{
$privateKey = base64_decode($privateKeyBase64, true);
if ($privateKey === false || strlen($privateKey) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES) {
throw new \InvalidArgumentException('Invalid private key');
}
$this->privateKey = $privateKey;
}
/**
* Decrypt an STMF payload
*
* @param string $payloadBase64 Base64-encoded STMF payload
* @return FormData Decrypted form data
* @throws InvalidPayloadException If the payload format is invalid
* @throws DecryptionException If decryption fails
*/
public function decrypt(string $payloadBase64): FormData
{
// Decode base64
$payload = base64_decode($payloadBase64, true);
if ($payload === false) {
throw new InvalidPayloadException('Invalid base64 payload');
}
return $this->decryptRaw($payload);
}
/**
* Decrypt raw STMF bytes
*
* @param string $payload Raw STMF bytes
* @return FormData Decrypted form data
*/
public function decryptRaw(string $payload): FormData
{
// Verify magic
if (strlen($payload) < 4 || substr($payload, 0, 4) !== self::MAGIC) {
throw new InvalidPayloadException('Invalid STMF magic');
}
// Parse trix container
$trix = $this->parseTrixContainer($payload);
// Extract ephemeral public key from header
if (!isset($trix['header']['ephemeral_pk'])) {
throw new InvalidPayloadException('Missing ephemeral_pk in header');
}
$ephemeralPKBase64 = $trix['header']['ephemeral_pk'];
$ephemeralPK = base64_decode($ephemeralPKBase64, true);
if ($ephemeralPK === false || strlen($ephemeralPK) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) {
throw new InvalidPayloadException('Invalid ephemeral public key');
}
// Perform X25519 ECDH key exchange
$sharedSecret = sodium_crypto_scalarmult($this->privateKey, $ephemeralPK);
// Derive symmetric key using SHA-256 (same as Go implementation)
$symmetricKey = hash('sha256', $sharedSecret, true);
// Decrypt the payload with ChaCha20-Poly1305
$decrypted = $this->chachaDecrypt($trix['payload'], $symmetricKey);
if ($decrypted === null) {
throw new DecryptionException('Decryption failed (wrong key?)');
}
// Parse JSON
$data = json_decode($decrypted, true);
if ($data === null) {
throw new InvalidPayloadException('Invalid JSON in decrypted payload');
}
return FormData::fromArray($data);
}
/**
* Validate an STMF payload without decrypting
*
* @param string $payloadBase64 Base64-encoded STMF payload
* @return bool True if the payload appears valid
*/
public function validate(string $payloadBase64): bool
{
try {
$payload = base64_decode($payloadBase64, true);
if ($payload === false) {
return false;
}
if (strlen($payload) < 4 || substr($payload, 0, 4) !== self::MAGIC) {
return false;
}
$trix = $this->parseTrixContainer($payload);
return isset($trix['header']['ephemeral_pk']);
} catch (\Exception $e) {
return false;
}
}
/**
* Get payload info without decrypting
*
* @param string $payloadBase64 Base64-encoded STMF payload
* @return array{version: ?string, algorithm: ?string, ephemeral_pk: ?string}
*/
public function getInfo(string $payloadBase64): array
{
$payload = base64_decode($payloadBase64, true);
if ($payload === false) {
throw new InvalidPayloadException('Invalid base64 payload');
}
$trix = $this->parseTrixContainer($payload);
return [
'version' => $trix['header']['version'] ?? null,
'algorithm' => $trix['header']['algorithm'] ?? null,
'ephemeral_pk' => $trix['header']['ephemeral_pk'] ?? null,
];
}
/**
* Parse a Trix container
*
* Enchantrix Trix format:
* - Magic (4 bytes): "STMF"
* - Version (4 bytes, little-endian): 2
* - Header length (1 byte or varint)
* - Header (JSON)
* - Payload
*
* @return array{header: array, payload: string}
*/
private function parseTrixContainer(string $data): array
{
$offset = 4; // Skip magic
// Skip version (4 bytes)
if (strlen($data) < $offset + 4) {
throw new InvalidPayloadException('Payload too short for version');
}
$offset += 4;
// Read header length (varint - for now just handle 1-2 byte cases)
if (strlen($data) < $offset + 1) {
throw new InvalidPayloadException('Payload too short for header length');
}
$firstByte = ord($data[$offset]);
$headerLen = 0;
if ($firstByte < 128) {
// Single byte length
$headerLen = $firstByte;
$offset += 1;
} else {
// Two byte length (varint continuation)
if (strlen($data) < $offset + 2) {
throw new InvalidPayloadException('Payload too short for header length');
}
$secondByte = ord($data[$offset + 1]);
$headerLen = ($firstByte & 0x7F) | ($secondByte << 7);
$offset += 2;
}
// Read header
if (strlen($data) < $offset + $headerLen) {
throw new InvalidPayloadException('Payload too short for header');
}
$headerJson = substr($data, $offset, $headerLen);
$header = json_decode($headerJson, true);
if ($header === null) {
throw new InvalidPayloadException('Invalid header JSON: ' . json_last_error_msg());
}
$offset += $headerLen;
// Rest is payload
$payload = substr($data, $offset);
return [
'header' => $header,
'payload' => $payload,
];
}
/**
* Decrypt data encrypted by Go's Enchantrix ChaChaPolySigil
*
* Enchantrix format:
* - Nonce (24 bytes for XChaCha20-Poly1305)
* - Ciphertext + Auth tag (16 bytes)
*
* Enchantrix also applies XOR pre-obfuscation before encryption.
* After decryption, we must deobfuscate using the nonce as entropy.
*/
private function chachaDecrypt(string $ciphertext, string $key): ?string
{
$nonceLen = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES; // 24
if (strlen($ciphertext) < $nonceLen + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) {
return null;
}
$nonce = substr($ciphertext, 0, $nonceLen);
$encrypted = substr($ciphertext, $nonceLen);
try {
$obfuscated = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
$encrypted,
'', // Additional data
$nonce,
$key
);
if ($obfuscated === false) {
return null;
}
// Deobfuscate using XOR with nonce-derived key stream (Enchantrix pattern)
return $this->xorDeobfuscate($obfuscated, $nonce);
} catch (\SodiumException $e) {
return null;
}
}
/**
* Deobfuscate data using XOR with entropy-derived key stream.
* This matches Enchantrix's XORObfuscator.
*
* The key stream is derived by hashing: SHA256(entropy || blockNumber)
* for each 32-byte block needed.
*/
private function xorDeobfuscate(string $data, string $entropy): string
{
if (strlen($data) === 0) {
return $data;
}
$keyStream = $this->deriveKeyStream($entropy, strlen($data));
$result = '';
for ($i = 0; $i < strlen($data); $i++) {
$result .= chr(ord($data[$i]) ^ ord($keyStream[$i]));
}
return $result;
}
/**
* Derive a key stream from entropy using SHA-256.
* Matches Enchantrix's XORObfuscator.deriveKeyStream.
*/
private function deriveKeyStream(string $entropy, int $length): string
{
$stream = '';
$blockNum = 0;
while (strlen($stream) < $length) {
// SHA256(entropy || blockNumber as big-endian uint64)
$blockBytes = pack('J', $blockNum); // J = unsigned 64-bit big-endian
$block = hash('sha256', $entropy . $blockBytes, true);
$copyLen = min(32, $length - strlen($stream));
$stream .= substr($block, 0, $copyLen);
$blockNum++;
}
return $stream;
}
/**
* Create STMF instance from a KeyPair
*/
public static function fromKeyPair(KeyPair $keyPair): self
{
return new self($keyPair->getPrivateKeyBase64());
}
}

View file

@ -0,0 +1,238 @@
<?php
declare(strict_types=1);
namespace Borg\STMF\Tests;
require_once __DIR__ . '/../src/FormField.php';
require_once __DIR__ . '/../src/FormData.php';
require_once __DIR__ . '/../src/KeyPair.php';
require_once __DIR__ . '/../src/DecryptionException.php';
require_once __DIR__ . '/../src/InvalidPayloadException.php';
require_once __DIR__ . '/../src/STMF.php';
use Borg\STMF\STMF;
use Borg\STMF\KeyPair;
/**
* Interoperability test - decrypts payloads encrypted by Go
*/
class InteropTest
{
private array $vectors;
private int $passed = 0;
private int $failed = 0;
public function __construct(string $vectorsFile)
{
$json = file_get_contents($vectorsFile);
$this->vectors = json_decode($json, true);
if ($this->vectors === null) {
throw new \RuntimeException("Failed to parse test vectors: " . json_last_error_msg());
}
}
public function run(): bool
{
echo "Running STMF Interoperability Tests\n";
echo "===================================\n\n";
foreach ($this->vectors as $vector) {
$this->runVector($vector);
}
echo "\n===================================\n";
echo "Results: {$this->passed} passed, {$this->failed} failed\n";
return $this->failed === 0;
}
private function runVector(array $vector): void
{
$name = $vector['name'];
echo "Testing: {$name}... ";
try {
// Create STMF instance with private key
$stmf = new STMF($vector['private_key']);
// Decrypt the payload
$formData = $stmf->decrypt($vector['encrypted_b64']);
// Verify fields
$expectedFields = $vector['expected_fields'] ?? [];
foreach ($expectedFields as $key => $expectedValue) {
$actualValue = $formData->get($key);
if ($actualValue !== $expectedValue) {
throw new \RuntimeException(
"Field '{$key}': expected " . json_encode($expectedValue) .
", got " . json_encode($actualValue)
);
}
}
// Verify metadata if present
$expectedMeta = $vector['expected_meta'] ?? [];
if ($expectedMeta) {
$actualMeta = $formData->getMetadata();
foreach ($expectedMeta as $key => $expectedValue) {
$actualValue = $actualMeta[$key] ?? null;
if ($actualValue !== $expectedValue) {
throw new \RuntimeException(
"Metadata '{$key}': expected " . json_encode($expectedValue) .
", got " . json_encode($actualValue)
);
}
}
}
// Verify field count
$expectedCount = count($expectedFields);
$actualCount = count($formData->fields());
if ($actualCount !== $expectedCount) {
throw new \RuntimeException(
"Field count: expected {$expectedCount}, got {$actualCount}"
);
}
echo "PASS\n";
$this->passed++;
} catch (\Exception $e) {
echo "FAIL\n";
echo " Error: " . $e->getMessage() . "\n";
$this->failed++;
}
}
}
// Additional standalone tests
class StandaloneTests
{
public static function runAll(): bool
{
echo "\nRunning Standalone PHP Tests\n";
echo "============================\n\n";
$passed = 0;
$failed = 0;
// Test 1: KeyPair generation
echo "Testing: KeyPair generation... ";
try {
$kp = KeyPair::generate();
if (strlen($kp->getPublicKey()) !== 32) {
throw new \RuntimeException("Public key wrong length");
}
if (strlen($kp->getPrivateKey()) !== 32) {
throw new \RuntimeException("Private key wrong length");
}
echo "PASS\n";
$passed++;
} catch (\Exception $e) {
echo "FAIL: " . $e->getMessage() . "\n";
$failed++;
}
// Test 2: KeyPair from private key
echo "Testing: KeyPair from private key... ";
try {
$kp1 = KeyPair::generate();
$kp2 = KeyPair::fromPrivateKeyBase64($kp1->getPrivateKeyBase64());
if ($kp1->getPublicKeyBase64() !== $kp2->getPublicKeyBase64()) {
throw new \RuntimeException("Public keys don't match");
}
echo "PASS\n";
$passed++;
} catch (\Exception $e) {
echo "FAIL: " . $e->getMessage() . "\n";
$failed++;
}
// Test 3: Invalid payload validation
echo "Testing: Invalid payload detection... ";
try {
$kp = KeyPair::generate();
$stmf = STMF::fromKeyPair($kp);
$isValid = $stmf->validate("not-valid-base64!!!");
if ($isValid) {
throw new \RuntimeException("Should have rejected invalid payload");
}
$isValid2 = $stmf->validate(base64_encode("FAKE" . str_repeat("\x00", 100)));
if ($isValid2) {
throw new \RuntimeException("Should have rejected fake STMF");
}
echo "PASS\n";
$passed++;
} catch (\Exception $e) {
echo "FAIL: " . $e->getMessage() . "\n";
$failed++;
}
// Test 4: FormData methods
echo "Testing: FormData methods... ";
try {
$fields = [
\Borg\STMF\FormField::fromArray(['name' => 'email', 'value' => 'test@test.com']),
\Borg\STMF\FormField::fromArray(['name' => 'tag', 'value' => 'one']),
\Borg\STMF\FormField::fromArray(['name' => 'tag', 'value' => 'two']),
];
$fd = new \Borg\STMF\FormData($fields, ['origin' => 'https://example.com']);
if ($fd->get('email') !== 'test@test.com') {
throw new \RuntimeException("get() failed");
}
if (!$fd->has('email')) {
throw new \RuntimeException("has() failed");
}
if ($fd->has('nonexistent')) {
throw new \RuntimeException("has() false positive");
}
$tags = $fd->getAll('tag');
if (count($tags) !== 2 || $tags[0] !== 'one' || $tags[1] !== 'two') {
throw new \RuntimeException("getAll() failed");
}
if ($fd->getOrigin() !== 'https://example.com') {
throw new \RuntimeException("getOrigin() failed");
}
echo "PASS\n";
$passed++;
} catch (\Exception $e) {
echo "FAIL: " . $e->getMessage() . "\n";
$failed++;
}
echo "\n============================\n";
echo "Standalone: {$passed} passed, {$failed} failed\n";
return $failed === 0;
}
}
// Run tests
if (php_sapi_name() === 'cli') {
$vectorsFile = __DIR__ . '/test_vectors.json';
if (!file_exists($vectorsFile)) {
echo "Error: test_vectors.json not found.\n";
echo "Generate it with: go run tests/generate_test_vectors.go > tests/test_vectors.json\n";
exit(1);
}
// Check sodium extension
if (!extension_loaded('sodium')) {
echo "Error: sodium extension not loaded.\n";
echo "Enable it in php.ini or install php-sodium.\n";
exit(1);
}
$interop = new InteropTest($vectorsFile);
$interopPassed = $interop->run();
$standalonePassed = StandaloneTests::runAll();
exit(($interopPassed && $standalonePassed) ? 0 : 1);
}

View file

@ -0,0 +1,159 @@
// +build ignore
// This program generates test vectors for PHP interoperability testing.
// Run with: go run generate_test_vectors.go > test_vectors.json
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/Snider/Borg/pkg/stmf"
)
type TestVector struct {
Name string `json:"name"`
PrivateKey string `json:"private_key"`
PublicKey string `json:"public_key"`
EncryptedB64 string `json:"encrypted_b64"`
ExpectedFields map[string]string `json:"expected_fields"`
ExpectedMeta map[string]string `json:"expected_meta"`
}
func main() {
var vectors []TestVector
// Test 1: Simple form with two fields
{
kp, _ := stmf.GenerateKeyPair()
formData := stmf.NewFormData().
AddField("email", "test@example.com").
AddFieldWithType("password", "secret123", "password")
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
vectors = append(vectors, TestVector{
Name: "simple_form",
PrivateKey: kp.PrivateKeyBase64(),
PublicKey: kp.PublicKeyBase64(),
EncryptedB64: encrypted,
ExpectedFields: map[string]string{
"email": "test@example.com",
"password": "secret123",
},
ExpectedMeta: nil,
})
}
// Test 2: Form with metadata
{
kp, _ := stmf.GenerateKeyPair()
formData := stmf.NewFormData().
AddField("username", "johndoe").
AddField("action", "login").
SetMetadata("origin", "https://example.com").
SetMetadata("timestamp", "1735265000")
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
vectors = append(vectors, TestVector{
Name: "form_with_metadata",
PrivateKey: kp.PrivateKeyBase64(),
PublicKey: kp.PublicKeyBase64(),
EncryptedB64: encrypted,
ExpectedFields: map[string]string{
"username": "johndoe",
"action": "login",
},
ExpectedMeta: map[string]string{
"origin": "https://example.com",
"timestamp": "1735265000",
},
})
}
// Test 3: Unicode content
{
kp, _ := stmf.GenerateKeyPair()
formData := stmf.NewFormData().
AddField("name", "日本語テスト").
AddField("emoji", "🔐🛡️✅").
AddField("mixed", "Hello 世界 مرحبا")
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
vectors = append(vectors, TestVector{
Name: "unicode_content",
PrivateKey: kp.PrivateKeyBase64(),
PublicKey: kp.PublicKeyBase64(),
EncryptedB64: encrypted,
ExpectedFields: map[string]string{
"name": "日本語テスト",
"emoji": "🔐🛡️✅",
"mixed": "Hello 世界 مرحبا",
},
ExpectedMeta: nil,
})
}
// Test 4: Large form with many fields
{
kp, _ := stmf.GenerateKeyPair()
formData := stmf.NewFormData()
expectedFields := make(map[string]string)
for i := 0; i < 20; i++ {
key := fmt.Sprintf("field_%d", i)
value := fmt.Sprintf("value_%d_with_some_content", i)
formData.AddField(key, value)
expectedFields[key] = value
}
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
vectors = append(vectors, TestVector{
Name: "large_form",
PrivateKey: kp.PrivateKeyBase64(),
PublicKey: kp.PublicKeyBase64(),
EncryptedB64: encrypted,
ExpectedFields: expectedFields,
ExpectedMeta: nil,
})
}
// Test 5: Special characters
{
kp, _ := stmf.GenerateKeyPair()
formData := stmf.NewFormData().
AddField("sql", "'; DROP TABLE users; --").
AddField("html", "<script>alert('xss')</script>").
AddField("json", `{"key": "value", "nested": {"a": 1}}`).
AddField("newlines", "line1\nline2\nline3")
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
vectors = append(vectors, TestVector{
Name: "special_characters",
PrivateKey: kp.PrivateKeyBase64(),
PublicKey: kp.PublicKeyBase64(),
EncryptedB64: encrypted,
ExpectedFields: map[string]string{
"sql": "'; DROP TABLE users; --",
"html": "<script>alert('xss')</script>",
"json": `{"key": "value", "nested": {"a": 1}}`,
"newlines": "line1\nline2\nline3",
},
ExpectedMeta: nil,
})
}
// Output as JSON
output, err := json.MarshalIndent(vectors, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Println(string(output))
}

View file

@ -0,0 +1,81 @@
[
{
"name": "simple_form",
"private_key": "cHSFC/ZN/whRWfQSHMHvQcEQgNm8VLPqr3FGW9pIUWw=",
"public_key": "9N840/Td0GTeGrmCip+4o/iftrh11l5IsxeUr4M3vzU=",
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6IjdYeTlGdWZSUkdJYW1WdTBEbE5VM2dGQWEveFdFZVVjWU9TVWRVN1NLSHc9IiwidmVyc2lvbiI6IjEuMCJ9WfRt459u9b3sGFhx5JaxQ3Nr1sVVy7Mebr4NnqfzX6GhQzs8iLZuF7EbeyY0auSBrgHIH3WBrvPj2H0rr7gnmIMesIRRs6HWR76vkvAb1FfbC6MOArduGfBK6edKaejtdC7rD9NtgpHaEEruNTE1e7SRQFF41ufu97+OqwfuyIMVyICmlvgW7ln+T6/PwMnhHf8dZ+rksc7SFnhwt5akBBxXOUbVgEvz",
"expected_fields": {
"email": "test@example.com",
"password": "secret123"
},
"expected_meta": null
},
{
"name": "form_with_metadata",
"private_key": "NVYNU8Ruc0aG0Yh8YHZfLASlH0xeCZXRJ3rP4WiQ5t4=",
"public_key": "0nJS0TpZPk/oaEAwpbPKbRboTIBa7qkeYRIGmE1A61Q=",
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6ImM3WCtDVDBOeDRDWFVjOWJHVEhwWFNZQ0dWckswbWZMRmhLWFhWT1pSbTg9IiwidmVyc2lvbiI6IjEuMCJ9y+1OD5VnQmBqck8tM3vs1fBznR6ZhK/nGFXVoxlC5jZjpBgAbExrb4AKgBmkvM8t+oVWIH2lyRfKArEJFalqm8H+Gv/OoebUSR0qwoHlYUaIGU6JAbNb5gEmrsJBTz7E2/FCdILokVaWNicu6p9eCye2OH4lEWNCGI4WTZJZMO9N45Kqj3UUqNm9dAar3hpYKcezvbSWpM1OjUBO9F5ye0tnIiqinJvlFmkzfKpBz0mseWE1QL8BcnbUcaSgaTSGxs1jDq4JrUDMtPODeoxgTCFju0GTJfmo9g==",
"expected_fields": {
"action": "login",
"username": "johndoe"
},
"expected_meta": {
"origin": "https://example.com",
"timestamp": "1735265000"
}
},
{
"name": "unicode_content",
"private_key": "p9JUNyRQYhQef7tNse3rUAIxyntRjnv3DPCqiKobg0Y=",
"public_key": "V2V27btYoVYui/6O3U/v24xu6g0Rsz3x8NHyOre2sFc=",
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6IlhxZnZSQlBpbGt4dWc3S0k3ZWFGWGNMQkJQaHZOb2dZa0JOM2xra1E2REk9IiwidmVyc2lvbiI6IjEuMCJ97lEfIzlKEs3MxXPb+taqw1QFPoEp8U/+WjOY8PFNYUXBFstbfybSFzXakthOARFcRU1RzoiHG+mWlGdwcBdcVWhodZDZj0C6NSMqLVx3bZLIoGlzN7v5N+b+xs+ApVQpl3x4LqbML6Jj5zhisTlaoEmMld+FeH8zRmp7a+FbNfMBM9V+IIRY6p3nPeo8czmyrwyrGscDnUkFaThdv7D2v1kFOFc5EOlfzaFPsDLq+ewa5OkGpEYnEu8UM+B122fzhHEr7sOUjHOk0RSkC33hNOWzmcs5SYsc2GEp86f/aeYp6SyiKUNHFg==",
"expected_fields": {
"emoji": "🔐🛡️✅",
"mixed": "Hello 世界 مرحبا",
"name": "日本語テスト"
},
"expected_meta": null
},
{
"name": "large_form",
"private_key": "6ZEQmKTUiojvQumqRdJsFcm91tz21QYgfvetxXO/VGs=",
"public_key": "iMk1wvMZc3fr8WqAqiPPZj/9x0pZuGh8kTECGqAJKQI=",
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6ImJad2w1ZjI1SjBJdmFBb0lXWUZlWjhIVkFFU1lhYktWdXVSeDFXTW53RU09IiwidmVyc2lvbiI6IjEuMCJ9QbTXLryMbYSnNJV0TPUpFkgArf1PkA0vx/R0Ml5VIXxuZuMayx8mE2GVSvPlYDp5UgIndl5uQ/oA9Mx0rqOpIQozRdPUHFKA98c3XRYQqhiWguBRpLpFtV3HKgtPdfPtS+rq2/jPICH+DKyLOCYVVHK+/E2b/39wjBHWTP1XuSHbU0bdS7+YkXyjh2xzrsS3HvVL3YbPN11Q1thizT9C2WxiKfetmwig480a8lJV8ej5ew9303iEGqhtRArxgHAI19syFQHkOVJBYQh83/B3HcPoF5TKuVelZ6FvB+/HETpbfaL3rsB0zZLR+PZn2X+MM4wsvPkWf+VcRTQjEXJKlQQAuy5rrgLg0LAOQbko7zKjfCL4MPyrYx/Mc4Ht4OyIxssQUYh5FV6+kCRlh/abu5XVuyv4lswG0SCzA5eYPmDGlZHJuRP3gcpKkwS92/X0NYZjxPPhZStKcPf5NOBrXmAtQoWdwV0i7aZBvNipBvJ3EUhkSJoKhYV4g4TSOcHS69RNCL8z7qg/7MFfihJbklDuSyEZCV7DW+6SJggdyEfs5e0e9Hf8fXGBsGophfNorh8vypV4KA0spnpKHLwuCWxVaYUX5vpFupPWo546M8JunvTJf80bPiL8neEDqkWkxXDda1K+c7JHsIrz2iOdDN/6qYTYMr2F+vXF+GERBHc2qMfH7EdzgCB6DqgxDWEXAGCbrnUJTIaew3CPaeU49ECDy9UlRLh4cFuRNEXsDYm7ALjThwulONU8BUoYhIcKuXowV0OEr6bSogF5CmrdwwUCrHNxDBJA3lP7/oBSSoj1FOVf3e2GGSmLJml1OiunTbW5voiFmiQdwhWkYcrmBr0ZMj6BPOSirPhsjNCqxML65VnOpsengPr0MUHL/bXUPUp0sqGHgRTViS7OvN4WxH1otOjzCAV1EdyAKIE4pcPSP6x6SZV4AIEjcSiBy08UaryL5UpO8Q8aLbu0hQ7ftMGF4SK0hqZxncTV46s67yQI5EE09qqefLHtV4N5FwD3t28FFmF9Bkmm6H2lChTFBenLE4nBDzHjHME9z/9xe1ryZzbX9DpSpH8LngdeE+5pZi72KhMHGy6r8tFJJBNMsOPnoYAhYFiAK817yVWgYK/W4SDksL4MyN3pYmX9hmug0b18TV6QwoDIvApio3zcGICUPD804xYuE/7rUjJQLlp/bj9049lu8AZaaYNPF39jvJMQqU0+dZ1OfBhCkkbaUza0Vv+YIEnJ+ZicKdpf5X4Vbltpgw4CDhNXBVNo9GmQDHzHhgapnWmkQQHn9eOlTgjUB+M2AvCwzpx3aHO+6QfiRVj/Xf9U6UMWoSp2ui+C8u2YWvRgvs+BTJyeq5fNbxvLgvT7l24I6RKMl4JzPcpgo1ysaXtPsBbO1Lz6wa3vIazVsX4bax0b2GVRyfW1g/SL2PggP8R0ob5zj7K2PSakVwOIMHw9NiUcVgrY2o8LYml+VeKZJFA3SUkseFN1gH9UYeLsnj+rmAs/n5V8XVPbuklBX8eIIt0E1ZiDz6eQuWnKJzKiwvGhALtG9vgTyIpFNgdlg7dWBcw/GBZNvXDXgwKDe+ypPU2XObErVTF29NEV0vE7gH5j727ZfgHsiiQWq3Qt2GGuMMsPA+m3xJ6NtAvoe9td6hpP6CMdSv++5N3tXKjauf3fpaeJetxS+AfW+8PT5gBFN5U6cblUQ3igTZs2dRQt4gCK1toBKo8k51ikL3zAxNeGdhS9j+z/0eavkEs8df2cWZ2FZ5OF5VaMOH+XzL/JwpHep5yJfO9IuHp+tejGLs9H9e+x69rXHqB9TkZtcrYSm9ChO3L9olZGaZWWgRnYdyT1cwv76Rdvv11K0kiPmmuBQSRQe1ri/G2+mh+WxAny3gBc4ElWQOhUF+J4A9F31gSKhcUIOB1ZaApuXMswBpr8RYHr",
"expected_fields": {
"field_0": "value_0_with_some_content",
"field_1": "value_1_with_some_content",
"field_10": "value_10_with_some_content",
"field_11": "value_11_with_some_content",
"field_12": "value_12_with_some_content",
"field_13": "value_13_with_some_content",
"field_14": "value_14_with_some_content",
"field_15": "value_15_with_some_content",
"field_16": "value_16_with_some_content",
"field_17": "value_17_with_some_content",
"field_18": "value_18_with_some_content",
"field_19": "value_19_with_some_content",
"field_2": "value_2_with_some_content",
"field_3": "value_3_with_some_content",
"field_4": "value_4_with_some_content",
"field_5": "value_5_with_some_content",
"field_6": "value_6_with_some_content",
"field_7": "value_7_with_some_content",
"field_8": "value_8_with_some_content",
"field_9": "value_9_with_some_content"
},
"expected_meta": null
},
{
"name": "special_characters",
"private_key": "bET5cDIamjtYKBGJSSAekIrH2mw54YamHCtyrWOSMlw=",
"public_key": "umOQqu/3cBBs3PeWs4vQiG3DxNARWlOJfrtG65RQdkk=",
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6Ik9nVnF4ZERJcnhsd2t2REdlQ3BtYVNUS0RhNkJQZFBMTm9Bek4yV2RJMDQ9IiwidmVyc2lvbiI6IjEuMCJ9fMvVueLCedw67bt7iLXJvZE66NOaPfwu2TsmN3sseXGu5M2csgYN+ngT6/XcMzAl7MHc3sHsvUE7H2MHTlhLih46WiyTzpXvfmzZHv3/t6oBxDwcm8O++ksCuuOnhHAfh65340/EuEMl8Da71zF00cFyuIvAmJFaBtn3Tlj6wd+9jfORuxv1TeRKLtMIojbLl9hZv25UHJSgjrlGL3kYdpg6WLqVKwz81P5RP10LbCQzHmH+4/lIAaFZAzf9mHcIHGj3ytKuDXDe1GhEhkUCK6Pe7PGuojIglhXMj9mijvlh2TCyxGQDw0VhCBCjVyWkWSPnTTb0bQOs3WLpGHN+XLgewVdSRTLbGy1t0PEkhjNftca8mLd4qEcSHzlFphGpN0u4R+Am0VSV44cqYDjeDKZnYyuBGu+ti6h+33AHfJU8EWzSMJXJEhP9Uqb0OuFyzfc7tVgGVAeEOBxGo/nyzPzi",
"expected_fields": {
"html": "\u003cscript\u003ealert('xss')\u003c/script\u003e",
"json": "{\"key\": \"value\", \"nested\": {\"a\": 1}}",
"newlines": "line1\nline2\nline3",
"sql": "'; DROP TABLE users; --"
},
"expected_meta": null
}
]

27
pkg/console/browser.go Normal file
View file

@ -0,0 +1,27 @@
// Package console provides an encrypted PWA demo server with browser integration.
package console
import (
"fmt"
"os/exec"
"runtime"
)
// OpenBrowser opens the default browser to the specified URL.
// Supports macOS, Linux, and Windows.
func OpenBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
return cmd.Start()
}

139
pkg/console/server.go Normal file
View file

@ -0,0 +1,139 @@
package console
import (
_ "embed"
"fmt"
"net/http"
"net/url"
"os"
"sync"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/tim"
)
//go:embed unlock.html
var unlockHTML []byte
// Server serves encrypted STIM content with an optional unlock page.
type Server struct {
stimData []byte
password string
port string
mu sync.RWMutex
unlocked bool
rootFS *datanode.DataNode
}
// NewServer creates a new console server.
// If password is provided, the content is decrypted immediately.
// If password is empty, an unlock page is shown until the user provides the password.
func NewServer(stimPath, password, port string) (*Server, error) {
data, err := os.ReadFile(stimPath)
if err != nil {
return nil, fmt.Errorf("reading STIM file: %w", err)
}
s := &Server{
stimData: data,
password: password,
port: port,
}
// If password provided, unlock immediately
if password != "" {
if err := s.unlock(password); err != nil {
return nil, fmt.Errorf("decrypting STIM: %w", err)
}
}
return s, nil
}
// unlock decrypts the STIM data with the given password.
func (s *Server) unlock(password string) error {
s.mu.Lock()
defer s.mu.Unlock()
m, err := tim.FromSigil(s.stimData, password)
if err != nil {
return err
}
s.rootFS = m.RootFS
s.unlocked = true
return nil
}
// isUnlocked returns whether the content has been decrypted.
func (s *Server) isUnlocked() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.unlocked
}
// Start begins serving HTTP requests.
func (s *Server) Start() error {
http.HandleFunc("/", s.handleRoot)
http.HandleFunc("/unlock", s.handleUnlock)
return http.ListenAndServe(":"+s.port, nil)
}
// handleRoot serves the main content or unlock page.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if !s.isUnlocked() {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(unlockHTML)
return
}
s.mu.RLock()
fs := http.FS(s.rootFS)
s.mu.RUnlock()
http.FileServer(fs).ServeHTTP(w, r)
}
// handleUnlock processes the unlock form submission.
func (s *Server) handleUnlock(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
redirectWithError(w, r, "Invalid form submission")
return
}
password := r.FormValue("password")
if password == "" {
redirectWithError(w, r, "Password is required")
return
}
if err := s.unlock(password); err != nil {
redirectWithError(w, r, "Incorrect password")
return
}
// Success - redirect to content
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// redirectWithError redirects to the unlock page with an error message.
func redirectWithError(w http.ResponseWriter, r *http.Request, message string) {
http.Redirect(w, r, "/?error="+url.QueryEscape(message), http.StatusSeeOther)
}
// Port returns the server's port.
func (s *Server) Port() string {
return s.port
}
// URL returns the full server URL.
func (s *Server) URL() string {
return fmt.Sprintf("http://localhost:%s", s.port)
}

253
pkg/console/unlock.html Normal file
View file

@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Borg Console - Unlock</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, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: #e0e0e0;
}
.container {
width: 100%;
max-width: 420px;
}
.card {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 2.5rem;
border: 1px solid rgba(255,255,255,0.1);
backdrop-filter: blur(10px);
}
.logo {
text-align: center;
margin-bottom: 1.5rem;
}
.logo-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
h1 {
text-align: center;
font-size: 1.8rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #00d9ff, #00ff94);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 2rem;
font-size: 0.9rem;
}
.input-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #aaa;
font-size: 0.85rem;
}
input[type="password"] {
width: 100%;
padding: 1rem;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(0,0,0,0.3);
color: #fff;
font-size: 1rem;
transition: all 0.2s;
}
input[type="password"]:focus {
outline: none;
border-color: #00d9ff;
box-shadow: 0 0 0 3px rgba(0, 217, 255, 0.1);
}
input[type="password"]::placeholder {
color: #666;
}
button {
width: 100%;
padding: 1rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-size: 1rem;
background: linear-gradient(135deg, #00d9ff 0%, #00ff94 100%);
color: #000;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 217, 255, 0.4);
}
button:active {
transform: translateY(0);
}
.error-banner {
background: rgba(255, 82, 82, 0.1);
border: 1px solid rgba(255, 82, 82, 0.3);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
color: #ff5252;
display: none;
text-align: center;
font-size: 0.9rem;
}
.error-banner.visible {
display: block;
}
.footer {
text-align: center;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255,255,255,0.1);
}
.footer p {
font-size: 0.75rem;
color: #666;
}
.footer code {
background: rgba(0,0,0,0.3);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
}
.loading {
display: none;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.loading.visible {
display: flex;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(0, 217, 255, 0.3);
border-top-color: #00d9ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn-text {
display: block;
}
.btn-text.hidden {
display: none;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="logo">
<div class="logo-icon">🔐</div>
<h1>Borg Console</h1>
<p class="subtitle">Enter password to unlock encrypted content</p>
</div>
<div id="error-banner" class="error-banner"></div>
<form id="unlock-form" method="POST" action="/unlock">
<div class="input-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter your password"
autocomplete="current-password"
required
autofocus
>
</div>
<button type="submit" id="submit-btn">
<span class="btn-text" id="btn-text">Unlock Console</span>
<span class="loading" id="loading">
<span class="spinner"></span>
<span>Decrypting...</span>
</span>
</button>
</form>
<div class="footer">
<p>Encrypted with <code>ChaCha20-Poly1305</code></p>
</div>
</div>
</div>
<script>
const form = document.getElementById('unlock-form');
const errorBanner = document.getElementById('error-banner');
const btnText = document.getElementById('btn-text');
const loading = document.getElementById('loading');
const submitBtn = document.getElementById('submit-btn');
// Check for error in URL params
const params = new URLSearchParams(window.location.search);
const error = params.get('error');
if (error) {
errorBanner.textContent = error;
errorBanner.classList.add('visible');
// Clean URL
history.replaceState(null, '', window.location.pathname);
}
form.addEventListener('submit', function(e) {
// Show loading state
btnText.classList.add('hidden');
loading.classList.add('visible');
submitBtn.disabled = true;
errorBanner.classList.remove('visible');
});
</script>
</body>
</html>

View file

@ -1,7 +1,10 @@
package datanode
import (
"archive/tar"
"bytes"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
@ -348,6 +351,65 @@ func TestWalk_Ugly(t *testing.T) {
}
}
func TestWalk_Options(t *testing.T) {
dn := New()
dn.AddData("root.txt", []byte("root"))
dn.AddData("a/a1.txt", []byte("a1"))
dn.AddData("a/b/b1.txt", []byte("b1"))
dn.AddData("c/c1.txt", []byte("c1"))
t.Run("MaxDepth", func(t *testing.T) {
var paths []string
err := dn.Walk(".", func(path string, d fs.DirEntry, err error) error {
paths = append(paths, path)
return nil
}, WalkOptions{MaxDepth: 1})
if err != nil {
t.Fatalf("Walk failed: %v", err)
}
expected := []string{".", "a", "c", "root.txt"}
sort.Strings(paths)
if !reflect.DeepEqual(paths, expected) {
t.Errorf("expected paths %v, got %v", expected, paths)
}
})
t.Run("Filter", func(t *testing.T) {
var paths []string
err := dn.Walk(".", func(path string, d fs.DirEntry, err error) error {
paths = append(paths, path)
return nil
}, WalkOptions{Filter: func(path string, d fs.DirEntry) bool {
return !strings.HasPrefix(path, "a")
}})
if err != nil {
t.Fatalf("Walk failed: %v", err)
}
expected := []string{".", "c", "c/c1.txt", "root.txt"}
sort.Strings(paths)
if !reflect.DeepEqual(paths, expected) {
t.Errorf("expected paths %v, got %v", expected, paths)
}
})
t.Run("SkipErrors", func(t *testing.T) {
// Mock a walk failure by passing a non-existent root with SkipErrors.
// Normally, WalkDir calls fn with an error for the root if it doesn't exist.
var called bool
err := dn.Walk("nonexistent", func(path string, d fs.DirEntry, err error) error {
called = true
return err
}, WalkOptions{SkipErrors: true})
if err != nil {
t.Errorf("expected no error with SkipErrors, got %v", err)
}
if called {
t.Error("callback should NOT be called if error is skipped internally")
}
})
}
func TestCopyFile_Good(t *testing.T) {
dn := New()
dn.AddData("foo.txt", []byte("foo"))
@ -397,6 +459,127 @@ func TestCopyFile_Ugly(t *testing.T) {
}
}
func TestToTar_Good(t *testing.T) {
dn := New()
dn.AddData("foo.txt", []byte("foo"))
dn.AddData("bar/baz.txt", []byte("baz"))
tarball, err := dn.ToTar()
if err != nil {
t.Fatalf("ToTar failed: %v", err)
}
if len(tarball) == 0 {
t.Fatal("expected non-empty tarball")
}
// Verify tar content
tr := tar.NewReader(bytes.NewReader(tarball))
files := make(map[string]string)
for {
header, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("tar.Next failed: %v", err)
}
content, err := io.ReadAll(tr)
if err != nil {
t.Fatalf("read tar content failed: %v", err)
}
files[header.Name] = string(content)
}
if files["foo.txt"] != "foo" {
t.Errorf("expected foo.txt content 'foo', got %q", files["foo.txt"])
}
if files["bar/baz.txt"] != "baz" {
t.Errorf("expected bar/baz.txt content 'baz', got %q", files["bar/baz.txt"])
}
}
func TestFromTar_Good(t *testing.T) {
// Create a tarball
buf := new(bytes.Buffer)
tw := tar.NewWriter(buf)
files := []struct{ Name, Body string }{
{"foo.txt", "foo"},
{"bar/baz.txt", "baz"},
}
for _, file := range files {
hdr := &tar.Header{
Name: file.Name,
Mode: 0600,
Size: int64(len(file.Body)),
Typeflag: tar.TypeReg,
}
if err := tw.WriteHeader(hdr); err != nil {
t.Fatalf("WriteHeader failed: %v", err)
}
if _, err := tw.Write([]byte(file.Body)); err != nil {
t.Fatalf("Write failed: %v", err)
}
}
if err := tw.Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
dn, err := FromTar(buf.Bytes())
if err != nil {
t.Fatalf("FromTar failed: %v", err)
}
// Verify DataNode content
exists, _ := dn.Exists("foo.txt")
if !exists {
t.Error("foo.txt missing")
}
exists, _ = dn.Exists("bar/baz.txt")
if !exists {
t.Error("bar/baz.txt missing")
}
}
func TestTarRoundTrip_Good(t *testing.T) {
dn1 := New()
dn1.AddData("a.txt", []byte("a"))
dn1.AddData("b/c.txt", []byte("c"))
tarball, err := dn1.ToTar()
if err != nil {
t.Fatalf("ToTar failed: %v", err)
}
dn2, err := FromTar(tarball)
if err != nil {
t.Fatalf("FromTar failed: %v", err)
}
// Verify dn2 matches dn1
exists, _ := dn2.Exists("a.txt")
if !exists {
t.Error("a.txt missing in dn2")
}
exists, _ = dn2.Exists("b/c.txt")
if !exists {
t.Error("b/c.txt missing in dn2")
}
}
func TestFromTar_Bad(t *testing.T) {
// Pass invalid data (truncated header)
// A valid tar header is 512 bytes.
truncated := make([]byte, 100)
_, err := FromTar(truncated)
if err == nil {
t.Error("expected error for truncated tar header, got nil")
} else if err != io.EOF && err != io.ErrUnexpectedEOF {
// Verify it's some sort of read error or EOF related
// Depending on implementation details of archive/tar
}
}
func toSortedNames(entries []fs.DirEntry) []string {
var names []string
for _, e := range entries {

36
pkg/player/assets.go Normal file
View 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")
}

File diff suppressed because it is too large Load diff

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

Binary file not shown.

View file

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

329
pkg/player/player.go Normal file
View 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)
}

View file

@ -4,8 +4,10 @@ import (
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
@ -14,6 +16,14 @@ import (
"golang.org/x/net/html"
)
// Common fallback paths for PWA manifests
var manifestFallbackPaths = []string{
"/manifest.json",
"/manifest.webmanifest",
"/site.webmanifest",
"/app.webmanifest",
}
// PWAClient is an interface for interacting with PWAs.
type PWAClient interface {
FindManifest(pwaURL string) (string, error)
@ -30,6 +40,8 @@ type pwaClient struct {
}
// FindManifest finds the manifest for a PWA.
// It first looks for a <link rel="manifest"> tag in the HTML,
// then tries common fallback paths if not found.
func (p *pwaClient) FindManifest(pwaURL string) (string, error) {
resp, err := p.client.Get(pwaURL)
if err != nil {
@ -71,37 +83,104 @@ func (p *pwaClient) FindManifest(pwaURL string) (string, error) {
}
f(doc)
if manifestURL == "" {
return "", fmt.Errorf("manifest not found")
// If manifest found via link tag, resolve and return
if manifestURL != "" {
resolvedURL, err := p.resolveURL(pwaURL, manifestURL)
if err != nil {
return "", err
}
return resolvedURL.String(), nil
}
resolvedURL, err := p.resolveURL(pwaURL, manifestURL)
// Try fallback paths
baseURL, err := url.Parse(pwaURL)
if err != nil {
return "", err
}
return resolvedURL.String(), nil
for _, path := range manifestFallbackPaths {
testURL := &url.URL{
Scheme: baseURL.Scheme,
Host: baseURL.Host,
Path: path,
}
resp, err := p.client.Get(testURL.String())
if err != nil {
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return testURL.String(), nil
}
}
return "", fmt.Errorf("manifest not found (checked HTML and fallback paths: %v)", manifestFallbackPaths)
}
// Manifest represents a PWA manifest with all common fields.
type Manifest struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
StartURL string `json:"start_url"`
Scope string `json:"scope"`
Display string `json:"display"`
BackgroundColor string `json:"background_color"`
ThemeColor string `json:"theme_color"`
Description string `json:"description"`
Icons []struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
} `json:"icons"`
Screenshots []struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
} `json:"screenshots"`
Shortcuts []struct {
Name string `json:"name"`
URL string `json:"url"`
Icons []struct {
Src string `json:"src"`
} `json:"icons"`
} `json:"shortcuts"`
RelatedApplications []struct {
Platform string `json:"platform"`
URL string `json:"url"`
ID string `json:"id"`
} `json:"related_applications"`
ServiceWorker struct {
Src string `json:"src"`
Scope string `json:"scope"`
} `json:"serviceworker"`
}
// DownloadAndPackagePWA downloads and packages a PWA into a DataNode.
// It downloads the manifest, all referenced assets, and parses HTML pages
// for additional linked resources (CSS, JS, images).
func (p *pwaClient) DownloadAndPackagePWA(pwaURL, manifestURL string, bar *progressbar.ProgressBar) (*datanode.DataNode, error) {
dn := datanode.New()
var wg sync.WaitGroup
var errs []error
var mu sync.Mutex
downloaded := make(map[string]bool)
type Manifest struct {
StartURL string `json:"start_url"`
Icons []struct {
Src string `json:"src"`
} `json:"icons"`
}
downloadAndAdd := func(assetURL string) {
var downloadAndAdd func(assetURL string, parseHTML bool)
downloadAndAdd = func(assetURL string, parseHTML bool) {
defer wg.Done()
if bar != nil {
bar.Add(1)
}
// Skip if already downloaded
mu.Lock()
if downloaded[assetURL] {
mu.Unlock()
return
}
downloaded[assetURL] = true
mu.Unlock()
resp, err := p.client.Get(assetURL)
if err != nil {
mu.Lock()
@ -133,7 +212,25 @@ func (p *pwaClient) DownloadAndPackagePWA(pwaURL, manifestURL string, bar *progr
mu.Unlock()
return
}
dn.AddData(strings.TrimPrefix(u.Path, "/"), body)
path := strings.TrimPrefix(u.Path, "/")
if path == "" {
path = "index.html"
}
dn.AddData(path, body)
// Parse HTML for additional assets
if parseHTML && isHTMLContent(resp.Header.Get("Content-Type"), body) {
additionalAssets := p.extractAssetsFromHTML(assetURL, body)
for _, asset := range additionalAssets {
mu.Lock()
if !downloaded[asset] {
wg.Add(1)
go downloadAndAdd(asset, false) // Don't recursively parse HTML
}
mu.Unlock()
}
}
}
// Download manifest first, synchronously.
@ -154,20 +251,29 @@ func (p *pwaClient) DownloadAndPackagePWA(pwaURL, manifestURL string, bar *progr
u, _ := url.Parse(manifestURL)
dn.AddData(strings.TrimPrefix(u.Path, "/"), manifestData)
downloaded[manifestURL] = true
// Parse manifest and download assets concurrently.
// Parse manifest and collect all assets.
var manifest Manifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
return nil, fmt.Errorf("failed to parse manifest: %w", err)
}
assetsToDownload := []string{}
htmlPages := []string{}
// Start URL (HTML page)
if manifest.StartURL != "" {
startURL, err := p.resolveURL(manifestURL, manifest.StartURL)
if err == nil {
assetsToDownload = append(assetsToDownload, startURL.String())
htmlPages = append(htmlPages, startURL.String())
}
} else {
// If no start_url, use the PWA URL itself
htmlPages = append(htmlPages, pwaURL)
}
// Icons
for _, icon := range manifest.Icons {
if icon.Src != "" {
iconURL, err := p.resolveURL(manifestURL, icon.Src)
@ -177,12 +283,68 @@ func (p *pwaClient) DownloadAndPackagePWA(pwaURL, manifestURL string, bar *progr
}
}
wg.Add(len(assetsToDownload))
for _, asset := range assetsToDownload {
go downloadAndAdd(asset)
// Screenshots
for _, screenshot := range manifest.Screenshots {
if screenshot.Src != "" {
screenshotURL, err := p.resolveURL(manifestURL, screenshot.Src)
if err == nil {
assetsToDownload = append(assetsToDownload, screenshotURL.String())
}
}
}
// Shortcuts and their icons
for _, shortcut := range manifest.Shortcuts {
if shortcut.URL != "" {
shortcutURL, err := p.resolveURL(manifestURL, shortcut.URL)
if err == nil {
htmlPages = append(htmlPages, shortcutURL.String())
}
}
for _, icon := range shortcut.Icons {
if icon.Src != "" {
iconURL, err := p.resolveURL(manifestURL, icon.Src)
if err == nil {
assetsToDownload = append(assetsToDownload, iconURL.String())
}
}
}
}
// Service worker
if manifest.ServiceWorker.Src != "" {
swURL, err := p.resolveURL(manifestURL, manifest.ServiceWorker.Src)
if err == nil {
assetsToDownload = append(assetsToDownload, swURL.String())
}
}
// Download HTML pages first (with asset extraction)
for _, page := range htmlPages {
wg.Add(1)
go downloadAndAdd(page, true)
}
wg.Wait()
// Download remaining assets
for _, asset := range assetsToDownload {
if !downloaded[asset] {
wg.Add(1)
go downloadAndAdd(asset, false)
}
}
wg.Wait()
// Try to detect service worker from HTML if not in manifest
if manifest.ServiceWorker.Src == "" {
swURL := p.detectServiceWorker(pwaURL, dn)
if swURL != "" && !downloaded[swURL] {
wg.Add(1)
go downloadAndAdd(swURL, false)
wg.Wait()
}
}
if len(errs) > 0 {
var errStrings []string
for _, e := range errs {
@ -194,6 +356,127 @@ func (p *pwaClient) DownloadAndPackagePWA(pwaURL, manifestURL string, bar *progr
return dn, nil
}
// extractAssetsFromHTML parses HTML and extracts linked assets.
func (p *pwaClient) extractAssetsFromHTML(baseURL string, htmlContent []byte) []string {
var assets []string
doc, err := html.Parse(strings.NewReader(string(htmlContent)))
if err != nil {
return assets
}
var extract func(*html.Node)
extract = func(n *html.Node) {
if n.Type == html.ElementNode {
var href string
switch n.Data {
case "link":
// CSS stylesheets and icons
var rel, linkHref string
for _, a := range n.Attr {
if a.Key == "rel" {
rel = a.Val
}
if a.Key == "href" {
linkHref = a.Val
}
}
if linkHref != "" && (rel == "stylesheet" || rel == "icon" || rel == "apple-touch-icon" || rel == "shortcut icon") {
href = linkHref
}
case "script":
// JavaScript files
for _, a := range n.Attr {
if a.Key == "src" && a.Val != "" {
href = a.Val
break
}
}
case "img":
// Images
for _, a := range n.Attr {
if a.Key == "src" && a.Val != "" {
href = a.Val
break
}
}
}
if href != "" && !strings.HasPrefix(href, "data:") {
resolved, err := p.resolveURL(baseURL, href)
if err == nil {
assets = append(assets, resolved.String())
}
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
extract(c)
}
}
extract(doc)
return assets
}
// detectServiceWorker tries to find service worker registration in HTML/JS.
func (p *pwaClient) detectServiceWorker(baseURL string, dn *datanode.DataNode) string {
// Look for common service worker registration patterns
patterns := []string{
`navigator\.serviceWorker\.register\(['"]([^'"]+)['"]`,
`serviceWorker\.register\(['"]([^'"]+)['"]`,
}
// Check all downloaded HTML and JS files
err := dn.Walk(".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(path, ".html") || strings.HasSuffix(path, ".js") || path == "index.html" {
file, err := dn.Open(path)
if err != nil {
return nil
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return nil
}
for _, pattern := range patterns {
re := regexp.MustCompile(pattern)
matches := re.FindSubmatch(content)
if len(matches) > 1 {
swPath := string(matches[1])
resolved, err := p.resolveURL(baseURL, swPath)
if err == nil {
return fmt.Errorf("found:%s", resolved.String())
}
}
}
}
return nil
})
if err != nil && strings.HasPrefix(err.Error(), "found:") {
return strings.TrimPrefix(err.Error(), "found:")
}
return ""
}
// isHTMLContent checks if content is HTML based on Content-Type or content inspection.
func isHTMLContent(contentType string, body []byte) bool {
if strings.Contains(contentType, "text/html") {
return true
}
// Check for HTML doctype or html tag
content := strings.ToLower(string(body[:min(len(body), 1024)]))
return strings.Contains(content, "<!doctype html") || strings.Contains(content, "<html")
}
func (p *pwaClient) resolveURL(base, ref string) (*url.URL, error) {
baseURL, err := url.Parse(base)
if err != nil {

View file

@ -34,8 +34,13 @@ func TestFindManifest_Good(t *testing.T) {
func TestFindManifest_Bad(t *testing.T) {
t.Run("No Manifest Link", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><head></head></html>`)
// Return HTML for main page, 404 for everything else (including fallback paths)
if r.URL.Path == "/" {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><head></head></html>`)
} else {
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewPWAClient()
@ -76,6 +81,58 @@ func TestFindManifest_Ugly(t *testing.T) {
t.Errorf("Expected manifest URL %s, but got %s", expectedURL, actualURL)
}
})
t.Run("Fallback to manifest.json", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
// No manifest link in HTML
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><head></head></html>`)
case "/manifest.json":
// But manifest.json exists at fallback path
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"name": "Fallback PWA"}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewPWAClient()
expectedURL := server.URL + "/manifest.json"
actualURL, err := client.FindManifest(server.URL)
if err != nil {
t.Fatalf("FindManifest should find fallback manifest.json: %v", err)
}
if actualURL != expectedURL {
t.Errorf("Expected manifest URL %s, but got %s", expectedURL, actualURL)
}
})
t.Run("Fallback to site.webmanifest", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<html><head></head></html>`)
case "/site.webmanifest":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"name": "Webmanifest PWA"}`)
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewPWAClient()
expectedURL := server.URL + "/site.webmanifest"
actualURL, err := client.FindManifest(server.URL)
if err != nil {
t.Fatalf("FindManifest should find fallback site.webmanifest: %v", err)
}
if actualURL != expectedURL {
t.Errorf("Expected manifest URL %s, but got %s", expectedURL, actualURL)
}
})
}
// --- Test Cases for DownloadAndPackagePWA ---
@ -189,6 +246,268 @@ func TestResolveURL_Bad(t *testing.T) {
}
}
// --- Test Cases for extractAssetsFromHTML ---
func TestExtractAssetsFromHTML(t *testing.T) {
client := NewPWAClient().(*pwaClient)
t.Run("extracts stylesheets", func(t *testing.T) {
html := []byte(`<html><head><link rel="stylesheet" href="style.css"></head></html>`)
assets := client.extractAssetsFromHTML("http://example.com/", html)
if len(assets) != 1 || assets[0] != "http://example.com/style.css" {
t.Errorf("Expected [http://example.com/style.css], got %v", assets)
}
})
t.Run("extracts scripts", func(t *testing.T) {
html := []byte(`<html><body><script src="app.js"></script></body></html>`)
assets := client.extractAssetsFromHTML("http://example.com/", html)
if len(assets) != 1 || assets[0] != "http://example.com/app.js" {
t.Errorf("Expected [http://example.com/app.js], got %v", assets)
}
})
t.Run("extracts images", func(t *testing.T) {
html := []byte(`<html><body><img src="logo.png"></body></html>`)
assets := client.extractAssetsFromHTML("http://example.com/", html)
if len(assets) != 1 || assets[0] != "http://example.com/logo.png" {
t.Errorf("Expected [http://example.com/logo.png], got %v", assets)
}
})
t.Run("extracts icons", func(t *testing.T) {
html := []byte(`<html><head><link rel="icon" href="favicon.ico"></head></html>`)
assets := client.extractAssetsFromHTML("http://example.com/", html)
if len(assets) != 1 || assets[0] != "http://example.com/favicon.ico" {
t.Errorf("Expected [http://example.com/favicon.ico], got %v", assets)
}
})
t.Run("extracts apple-touch-icon", func(t *testing.T) {
html := []byte(`<html><head><link rel="apple-touch-icon" href="apple-icon.png"></head></html>`)
assets := client.extractAssetsFromHTML("http://example.com/", html)
if len(assets) != 1 || assets[0] != "http://example.com/apple-icon.png" {
t.Errorf("Expected [http://example.com/apple-icon.png], got %v", assets)
}
})
t.Run("ignores data URIs", func(t *testing.T) {
html := []byte(`<html><body><img src="data:image/png;base64,abc123"></body></html>`)
assets := client.extractAssetsFromHTML("http://example.com/", html)
if len(assets) != 0 {
t.Errorf("Expected no assets for data URI, got %v", assets)
}
})
t.Run("handles multiple assets", func(t *testing.T) {
html := []byte(`<html>
<head>
<link rel="stylesheet" href="style.css">
<link rel="icon" href="favicon.ico">
</head>
<body>
<script src="app.js"></script>
<img src="logo.png">
</body>
</html>`)
assets := client.extractAssetsFromHTML("http://example.com/", html)
if len(assets) != 4 {
t.Errorf("Expected 4 assets, got %d: %v", len(assets), assets)
}
})
t.Run("handles invalid HTML gracefully", func(t *testing.T) {
html := []byte(`not valid html at all <<<>>>`)
assets := client.extractAssetsFromHTML("http://example.com/", html)
// Should not panic, may return empty or partial results
_ = assets
})
}
// --- Test Cases for isHTMLContent ---
func TestIsHTMLContent(t *testing.T) {
t.Run("detects text/html content-type", func(t *testing.T) {
if !isHTMLContent("text/html; charset=utf-8", []byte("anything")) {
t.Error("Should detect text/html content type")
}
})
t.Run("detects doctype", func(t *testing.T) {
if !isHTMLContent("", []byte("<!DOCTYPE html><html></html>")) {
t.Error("Should detect HTML by doctype")
}
})
t.Run("detects html tag", func(t *testing.T) {
if !isHTMLContent("", []byte("<html><body>test</body></html>")) {
t.Error("Should detect HTML by html tag")
}
})
t.Run("rejects non-html", func(t *testing.T) {
if isHTMLContent("application/json", []byte(`{"key": "value"}`)) {
t.Error("Should not detect JSON as HTML")
}
})
}
// --- Test Cases for MockPWAClient ---
func TestMockPWAClient(t *testing.T) {
t.Run("FindManifest returns configured value", func(t *testing.T) {
mock := NewMockPWAClient("http://example.com/manifest.json", nil, nil)
url, err := mock.FindManifest("http://example.com")
if err != nil {
t.Fatalf("FindManifest error = %v", err)
}
if url != "http://example.com/manifest.json" {
t.Errorf("FindManifest = %q, want %q", url, "http://example.com/manifest.json")
}
})
t.Run("FindManifest returns configured error", func(t *testing.T) {
mock := NewMockPWAClient("", nil, fmt.Errorf("test error"))
_, err := mock.FindManifest("http://example.com")
if err == nil || err.Error() != "test error" {
t.Errorf("FindManifest error = %v, want 'test error'", err)
}
})
t.Run("DownloadAndPackagePWA returns configured datanode", func(t *testing.T) {
mock := NewMockPWAClient("", nil, nil)
dn, err := mock.DownloadAndPackagePWA("http://example.com", "http://example.com/manifest.json", nil)
if err != nil {
t.Fatalf("DownloadAndPackagePWA error = %v", err)
}
if dn != nil {
t.Error("Expected nil datanode from mock")
}
})
}
// --- Test Cases for full manifest parsing ---
func TestDownloadAndPackagePWA_FullManifest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/manifest.json":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{
"name": "Full PWA",
"start_url": "index.html",
"icons": [{"src": "icon.png"}],
"screenshots": [{"src": "screenshot.png"}],
"shortcuts": [
{
"name": "Action",
"url": "action.html",
"icons": [{"src": "action-icon.png"}]
}
]
}`)
case "/index.html":
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<!DOCTYPE html><html><head><link rel="stylesheet" href="style.css"></head><body><script src="app.js"></script></body></html>`)
case "/icon.png", "/screenshot.png", "/action-icon.png":
w.Header().Set("Content-Type", "image/png")
fmt.Fprint(w, "fake image")
case "/action.html":
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, "<html></html>")
case "/style.css":
w.Header().Set("Content-Type", "text/css")
fmt.Fprint(w, "body { color: red; }")
case "/app.js":
w.Header().Set("Content-Type", "application/javascript")
fmt.Fprint(w, "console.log('hello');")
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewPWAClient()
dn, err := client.DownloadAndPackagePWA(server.URL, server.URL+"/manifest.json", nil)
if err != nil {
t.Fatalf("DownloadAndPackagePWA failed: %v", err)
}
// Check manifest
exists, _ := dn.Exists("manifest.json")
if !exists {
t.Error("Expected manifest.json")
}
// Check icons
exists, _ = dn.Exists("icon.png")
if !exists {
t.Error("Expected icon.png")
}
// Check screenshots
exists, _ = dn.Exists("screenshot.png")
if !exists {
t.Error("Expected screenshot.png")
}
// Check shortcut page
exists, _ = dn.Exists("action.html")
if !exists {
t.Error("Expected action.html")
}
// Check shortcut icon
exists, _ = dn.Exists("action-icon.png")
if !exists {
t.Error("Expected action-icon.png")
}
// Check HTML-extracted assets
exists, _ = dn.Exists("style.css")
if !exists {
t.Error("Expected style.css (extracted from HTML)")
}
exists, _ = dn.Exists("app.js")
if !exists {
t.Error("Expected app.js (extracted from HTML)")
}
}
// --- Test Cases for service worker detection ---
func TestDownloadAndPackagePWA_ServiceWorker(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/manifest.json":
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"name": "SW PWA", "start_url": "index.html"}`)
case "/index.html":
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<!DOCTYPE html><html><body><script>navigator.serviceWorker.register('/sw.js');</script></body></html>`)
case "/sw.js":
w.Header().Set("Content-Type", "application/javascript")
fmt.Fprint(w, "self.addEventListener('fetch', e => {});")
default:
http.NotFound(w, r)
}
}))
defer server.Close()
client := NewPWAClient()
dn, err := client.DownloadAndPackagePWA(server.URL, server.URL+"/manifest.json", nil)
if err != nil {
t.Fatalf("DownloadAndPackagePWA failed: %v", err)
}
// Service worker should be detected and downloaded
exists, _ := dn.Exists("sw.js")
if !exists {
t.Error("Expected sw.js (service worker detected from script)")
}
}
// --- Helpers ---
// newPWATestServer creates a test server for a simple PWA.

214
pkg/smsg/abr.go Normal file
View 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
}

554
pkg/smsg/smsg.go Normal file
View file

@ -0,0 +1,554 @@
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.
func DeriveKey(password string) []byte {
hash := sha256.Sum256([]byte(password))
return hash[:]
}
// Encrypt encrypts a message with a password.
// Returns the encrypted SMSG container bytes.
func Encrypt(msg *Message, password string) ([]byte, error) {
if password == "" {
return nil, ErrPasswordRequired
}
if msg.Body == "" && len(msg.Attachments) == 0 {
return nil, ErrEmptyMessage
}
// Set timestamp if not set
if msg.Timestamp == 0 {
msg.Timestamp = time.Now().Unix()
}
// Serialize message to JSON
payload, err := json.Marshal(msg)
if err != nil {
return nil, fmt.Errorf("failed to marshal message: %w", err)
}
// Derive key and create sigil
key := DeriveKey(password)
sigil, err := enchantrix.NewChaChaPolySigil(key)
if err != nil {
return nil, fmt.Errorf("failed to create sigil: %w", err)
}
// Encrypt
encrypted, err := sigil.In(payload)
if err != nil {
return nil, fmt.Errorf("encryption failed: %w", err)
}
// Create container header
headerMap := map[string]interface{}{
"version": Version,
"algorithm": "chacha20poly1305",
}
// Create trix container
t := &trix.Trix{
Header: headerMap,
Payload: encrypted,
}
return trix.Encode(t, Magic, nil)
}
// EncryptBase64 encrypts and returns base64-encoded result
func EncryptBase64(msg *Message, password string) (string, error) {
encrypted, err := Encrypt(msg, password)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}
// EncryptWithHint encrypts with an optional password hint in the header
func EncryptWithHint(msg *Message, password, hint 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()
}
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)
}
headerMap := map[string]interface{}{
"version": Version,
"algorithm": "chacha20poly1305",
}
if hint != "" {
headerMap["hint"] = hint
}
t := &trix.Trix{
Header: headerMap,
Payload: encrypted,
}
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
}
// Decode trix container
t, err := trix.Decode(data, Magic, nil)
if err != nil {
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)
if err != nil {
return nil, fmt.Errorf("failed to create sigil: %w", err)
}
// Decrypt
decrypted, err := sigil.Out(t.Payload)
if err != nil {
return nil, ErrDecryptionFailed
}
// 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)
}
return &msg, nil
}
// DecryptBase64 decrypts a base64-encoded SMSG
func DecryptBase64(encoded, password string) (*Message, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("%w: invalid base64", ErrInvalidPayload)
}
return Decrypt(data, password)
}
// GetInfo extracts header info without decrypting
func GetInfo(data []byte) (*Header, error) {
t, err := trix.Decode(data, Magic, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidMagic, err)
}
header := &Header{}
if v, ok := t.Header["version"].(string); ok {
header.Version = v
}
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
}
// GetInfoBase64 extracts header info from base64-encoded SMSG
func GetInfoBase64(encoded string) (*Header, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("%w: invalid base64", ErrInvalidPayload)
}
return GetInfo(data)
}
// Validate checks if data is a valid SMSG container (without decrypting)
func Validate(data []byte) error {
_, err := trix.Decode(data, Magic, nil)
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidMagic, err)
}
return nil
}
// QuickEncrypt is a convenience function for simple message encryption
func QuickEncrypt(body, password string) (string, error) {
msg := NewMessage(body)
return EncryptBase64(msg, password)
}
// QuickDecrypt is a convenience function for simple message decryption
func QuickDecrypt(encoded, password string) (string, error) {
msg, err := DecryptBase64(encoded, password)
if err != nil {
return "", err
}
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)
}

710
pkg/smsg/smsg_test.go Normal file
View file

@ -0,0 +1,710 @@
package smsg
import (
"encoding/base64"
"testing"
)
func TestEncryptDecryptRoundTrip(t *testing.T) {
msg := NewMessage("Hello, this is a secure message!").
WithSubject("Test Subject").
WithFrom("support@example.com")
password := "supersecret123"
encrypted, err := Encrypt(msg, password)
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decrypted, err := Decrypt(encrypted, password)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
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)
}
if decrypted.From != msg.From {
t.Errorf("From = %q, want %q", decrypted.From, msg.From)
}
}
func TestBase64RoundTrip(t *testing.T) {
msg := NewMessage("Base64 test message")
password := "testpass"
encryptedB64, err := EncryptBase64(msg, password)
if err != nil {
t.Fatalf("EncryptBase64 failed: %v", err)
}
// Should be valid base64
if _, err := base64.StdEncoding.DecodeString(encryptedB64); err != nil {
t.Fatalf("Invalid base64: %v", err)
}
decrypted, err := DecryptBase64(encryptedB64, password)
if err != nil {
t.Fatalf("DecryptBase64 failed: %v", err)
}
if decrypted.Body != msg.Body {
t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body)
}
}
func TestWithAttachments(t *testing.T) {
fileContent := base64.StdEncoding.EncodeToString([]byte("Hello, World!"))
msg := NewMessage("Please see the attached file.").
AddAttachment("hello.txt", fileContent, "text/plain").
AddAttachment("data.json", base64.StdEncoding.EncodeToString([]byte(`{"key":"value"}`)), "application/json")
password := "attachtest"
encrypted, err := Encrypt(msg, password)
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decrypted, err := Decrypt(encrypted, password)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if len(decrypted.Attachments) != 2 {
t.Fatalf("Attachments count = %d, want 2", len(decrypted.Attachments))
}
att := decrypted.GetAttachment("hello.txt")
if att == nil {
t.Fatal("Attachment hello.txt not found")
}
if att.Content != fileContent {
t.Error("Attachment content mismatch")
}
if att.MimeType != "text/plain" {
t.Errorf("MimeType = %q, want %q", att.MimeType, "text/plain")
}
}
func TestWithReplyKey(t *testing.T) {
msg := NewMessage("Here's a public key for your reply.").
WithReplyKey("dGVzdHB1YmxpY2tleWJhc2U2NA==")
password := "pki-test"
encrypted, err := Encrypt(msg, password)
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decrypted, err := Decrypt(encrypted, password)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if decrypted.ReplyKey == nil {
t.Fatal("ReplyKey is nil")
}
if decrypted.ReplyKey.PublicKey != "dGVzdHB1YmxpY2tleWJhc2U2NA==" {
t.Error("ReplyKey.PublicKey mismatch")
}
if decrypted.ReplyKey.Algorithm != "x25519" {
t.Errorf("Algorithm = %q, want %q", decrypted.ReplyKey.Algorithm, "x25519")
}
}
func TestWithHint(t *testing.T) {
msg := NewMessage("Password hint test")
password := "birthday1990"
hint := "Your birthday year"
encrypted, err := EncryptWithHint(msg, password, hint)
if err != nil {
t.Fatalf("EncryptWithHint failed: %v", err)
}
// Get info should include hint
info, err := GetInfo(encrypted)
if err != nil {
t.Fatalf("GetInfo failed: %v", err)
}
if info.Hint != hint {
t.Errorf("Hint = %q, want %q", info.Hint, hint)
}
// Should still decrypt
decrypted, err := Decrypt(encrypted, password)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if decrypted.Body != msg.Body {
t.Error("Body mismatch")
}
}
func TestWrongPassword(t *testing.T) {
msg := NewMessage("Secret message")
password := "correct-password"
encrypted, err := Encrypt(msg, password)
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
_, err = Decrypt(encrypted, "wrong-password")
if err == nil {
t.Error("Decrypt with wrong password should have failed")
}
}
func TestQuickFunctions(t *testing.T) {
body := "Quick test message"
password := "quickpass"
encrypted, err := QuickEncrypt(body, password)
if err != nil {
t.Fatalf("QuickEncrypt failed: %v", err)
}
decrypted, err := QuickDecrypt(encrypted, password)
if err != nil {
t.Fatalf("QuickDecrypt failed: %v", err)
}
if decrypted != body {
t.Errorf("Decrypted = %q, want %q", decrypted, body)
}
}
func TestUnicodeContent(t *testing.T) {
msg := NewMessage("日本語メッセージ 🔐 مرحبا").
WithSubject("Unicode テスト").
WithFrom("サポート")
password := "unicode-test"
encrypted, err := Encrypt(msg, password)
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decrypted, err := Decrypt(encrypted, password)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
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)
}
}
func TestMetadata(t *testing.T) {
msg := NewMessage("Message with metadata").
SetMeta("ticket_id", "12345").
SetMeta("priority", "high")
password := "meta-test"
encrypted, err := Encrypt(msg, password)
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decrypted, err := Decrypt(encrypted, password)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if decrypted.Meta["ticket_id"] != "12345" {
t.Error("ticket_id metadata mismatch")
}
if decrypted.Meta["priority"] != "high" {
t.Error("priority metadata mismatch")
}
}
func TestValidate(t *testing.T) {
msg := NewMessage("Test")
password := "test"
encrypted, _ := Encrypt(msg, password)
// Valid SMSG should pass
if err := Validate(encrypted); err != nil {
t.Errorf("Validate failed for valid SMSG: %v", err)
}
// Invalid data should fail
if err := Validate([]byte("not an smsg")); err == nil {
t.Error("Validate should fail for invalid data")
}
}
func TestEmptyPasswordError(t *testing.T) {
msg := NewMessage("Test")
_, err := Encrypt(msg, "")
if err != ErrPasswordRequired {
t.Errorf("Expected ErrPasswordRequired, got %v", err)
}
}
func TestEmptyMessageError(t *testing.T) {
msg := &Message{}
_, err := Encrypt(msg, "password")
if err != ErrEmptyMessage {
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
View 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
View 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")
}
}

416
pkg/smsg/types.go Normal file
View file

@ -0,0 +1,416 @@
// Package smsg implements Secure Message encryption using password-based ChaCha20-Poly1305.
// 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
const Magic = "SMSG"
// Version of the SMSG format
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")
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,omitempty"` // base64-encoded (v1) or empty (v2, populated on decrypt)
MimeType string `json:"mime,omitempty"`
Size int `json:"size,omitempty"` // binary size in bytes
}
// PKIInfo contains public key information for authenticated replies
type PKIInfo struct {
PublicKey string `json:"public_key"` // base64-encoded X25519 public key
KeyID string `json:"key_id,omitempty"` // optional key identifier
Algorithm string `json:"algorithm,omitempty"` // e.g., "x25519"
Fingerprint string `json:"fingerprint,omitempty"` // SHA256 fingerprint of public key
}
// Message represents the decrypted message content
type Message struct {
// Core message content
Subject string `json:"subject,omitempty"`
Body string `json:"body"`
// Optional attachments
Attachments []Attachment `json:"attachments,omitempty"`
// PKI for authenticated replies
ReplyKey *PKIInfo `json:"reply_key,omitempty"`
// Metadata
From string `json:"from,omitempty"`
Timestamp int64 `json:"timestamp,omitempty"`
Meta map[string]string `json:"meta,omitempty"`
}
// NewMessage creates a new message with the given body
func NewMessage(body string) *Message {
return &Message{
Body: body,
Meta: make(map[string]string),
}
}
// WithSubject sets the message subject
func (m *Message) WithSubject(subject string) *Message {
m.Subject = subject
return m
}
// WithFrom sets the sender
func (m *Message) WithFrom(from string) *Message {
m.From = from
return m
}
// WithTimestamp sets the timestamp
func (m *Message) WithTimestamp(ts int64) *Message {
m.Timestamp = ts
return m
}
// 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), // 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
}
// WithReplyKey sets the PKI public key for authenticated replies
func (m *Message) WithReplyKey(publicKeyB64 string) *Message {
m.ReplyKey = &PKIInfo{
PublicKey: publicKeyB64,
Algorithm: "x25519",
}
return m
}
// WithReplyKeyInfo sets full PKI information
func (m *Message) WithReplyKeyInfo(pki *PKIInfo) *Message {
m.ReplyKey = pki
return m
}
// SetMeta sets a metadata value
func (m *Message) SetMeta(key, value string) *Message {
if m.Meta == nil {
m.Meta = make(map[string]string)
}
m.Meta[key] = value
return m
}
// GetAttachment finds an attachment by name
func (m *Message) GetAttachment(name string) *Attachment {
for i := range m.Attachments {
if m.Attachments[i].Name == name {
return &m.Attachments[i]
}
}
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"`
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},
}

151
pkg/stmf/decrypt.go Normal file
View file

@ -0,0 +1,151 @@
package stmf
import (
"crypto/ecdh"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/Snider/Enchantrix/pkg/enchantrix"
"github.com/Snider/Enchantrix/pkg/trix"
)
// Decrypt decrypts a STMF payload using the server's private key.
// It extracts the ephemeral public key from the header, performs ECDH,
// and decrypts with ChaCha20-Poly1305.
func Decrypt(stmfData []byte, serverPrivateKey []byte) (*FormData, error) {
// Load server's private key
serverPriv, err := LoadPrivateKey(serverPrivateKey)
if err != nil {
return nil, err
}
return DecryptWithKey(stmfData, serverPriv)
}
// DecryptBase64 decrypts a base64-encoded STMF payload
func DecryptBase64(encoded string, serverPrivateKey []byte) (*FormData, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("%w: invalid base64: %v", ErrInvalidPayload, err)
}
return Decrypt(data, serverPrivateKey)
}
// DecryptWithKey decrypts a STMF payload using a pre-loaded private key
func DecryptWithKey(stmfData []byte, serverPrivateKey *ecdh.PrivateKey) (*FormData, error) {
// Decode the trix container
t, err := trix.Decode(stmfData, Magic, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidMagic, err)
}
// Extract ephemeral public key from header
ephemeralPKBase64, ok := t.Header["ephemeral_pk"].(string)
if !ok {
return nil, fmt.Errorf("%w: missing ephemeral_pk in header", ErrInvalidPayload)
}
ephemeralPKBytes, err := base64.StdEncoding.DecodeString(ephemeralPKBase64)
if err != nil {
return nil, fmt.Errorf("%w: invalid ephemeral_pk base64: %v", ErrInvalidPayload, err)
}
// Load ephemeral public key
ephemeralPub, err := LoadPublicKey(ephemeralPKBytes)
if err != nil {
return nil, fmt.Errorf("%w: invalid ephemeral public key: %v", ErrInvalidPayload, err)
}
// Perform ECDH key exchange (server private * ephemeral public = shared secret)
sharedSecret, err := serverPrivateKey.ECDH(ephemeralPub)
if err != nil {
return nil, fmt.Errorf("ECDH failed: %w", err)
}
// Derive symmetric key using SHA-256 (same as encryption)
symmetricKey := sha256.Sum256(sharedSecret)
// Create ChaCha20-Poly1305 sigil
sigil, err := enchantrix.NewChaChaPolySigil(symmetricKey[:])
if err != nil {
return nil, fmt.Errorf("failed to create sigil: %w", err)
}
// Decrypt the payload
decrypted, err := sigil.Out(t.Payload)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
}
// Unmarshal form data
var formData FormData
if err := json.Unmarshal(decrypted, &formData); err != nil {
return nil, fmt.Errorf("%w: invalid JSON payload: %v", ErrInvalidPayload, err)
}
return &formData, nil
}
// DecryptToMap is a convenience function that returns the form data as a simple map
func DecryptToMap(stmfData []byte, serverPrivateKey []byte) (map[string]string, error) {
formData, err := Decrypt(stmfData, serverPrivateKey)
if err != nil {
return nil, err
}
return formData.ToMap(), nil
}
// DecryptBase64ToMap decrypts base64 and returns a map
func DecryptBase64ToMap(encoded string, serverPrivateKey []byte) (map[string]string, error) {
formData, err := DecryptBase64(encoded, serverPrivateKey)
if err != nil {
return nil, err
}
return formData.ToMap(), nil
}
// ValidatePayload checks if the data is a valid STMF container without decrypting
func ValidatePayload(stmfData []byte) error {
t, err := trix.Decode(stmfData, Magic, nil)
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidMagic, err)
}
// Check required header fields
if _, ok := t.Header["ephemeral_pk"].(string); !ok {
return fmt.Errorf("%w: missing ephemeral_pk", ErrInvalidPayload)
}
if _, ok := t.Header["algorithm"].(string); !ok {
return fmt.Errorf("%w: missing algorithm", ErrInvalidPayload)
}
return nil
}
// GetPayloadInfo extracts metadata from a STMF payload without decrypting
func GetPayloadInfo(stmfData []byte) (*Header, error) {
t, err := trix.Decode(stmfData, Magic, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidMagic, err)
}
header := &Header{}
if v, ok := t.Header["version"].(string); ok {
header.Version = v
}
if v, ok := t.Header["algorithm"].(string); ok {
header.Algorithm = v
}
if v, ok := t.Header["ephemeral_pk"].(string); ok {
header.EphemeralPK = v
}
if v, ok := t.Header["nonce"].(string); ok {
header.Nonce = v
}
return header, nil
}

118
pkg/stmf/encrypt.go Normal file
View file

@ -0,0 +1,118 @@
package stmf
import (
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/Snider/Enchantrix/pkg/enchantrix"
"github.com/Snider/Enchantrix/pkg/trix"
)
// Encrypt encrypts form data using the server's public key.
// It performs X25519 ECDH key exchange with an ephemeral keypair,
// derives a symmetric key, and encrypts with ChaCha20-Poly1305.
//
// The result is a STMF container that can be base64-encoded for transmission.
func Encrypt(data *FormData, serverPublicKey []byte) ([]byte, error) {
// Load server's public key
serverPub, err := LoadPublicKey(serverPublicKey)
if err != nil {
return nil, err
}
return EncryptWithKey(data, serverPub)
}
// EncryptBase64 encrypts form data and returns a base64-encoded string
func EncryptBase64(data *FormData, serverPublicKey []byte) (string, error) {
encrypted, err := Encrypt(data, serverPublicKey)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}
// EncryptWithKey encrypts form data using a pre-loaded public key
func EncryptWithKey(data *FormData, serverPublicKey *ecdh.PublicKey) ([]byte, error) {
// Generate ephemeral keypair for this encryption
ephemeralPrivate, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate ephemeral key: %w", err)
}
ephemeralPublic := ephemeralPrivate.PublicKey()
// Perform ECDH key exchange
sharedSecret, err := ephemeralPrivate.ECDH(serverPublicKey)
if err != nil {
return nil, fmt.Errorf("ECDH failed: %w", err)
}
// Derive symmetric key using SHA-256 (same pattern as pkg/trix)
symmetricKey := sha256.Sum256(sharedSecret)
// Create ChaCha20-Poly1305 sigil
sigil, err := enchantrix.NewChaChaPolySigil(symmetricKey[:])
if err != nil {
return nil, fmt.Errorf("failed to create sigil: %w", err)
}
// Serialize form data to JSON
payload, err := json.Marshal(data)
if err != nil {
return nil, fmt.Errorf("failed to marshal form data: %w", err)
}
// Encrypt the payload
encrypted, err := sigil.In(payload)
if err != nil {
return nil, fmt.Errorf("encryption failed: %w", err)
}
// Build STMF container
// The nonce is included in the encrypted data by ChaChaPolySigil,
// but we include the ephemeral public key in the header
header := Header{
Version: Version,
Algorithm: "x25519-chacha20poly1305",
EphemeralPK: base64.StdEncoding.EncodeToString(ephemeralPublic.Bytes()),
Nonce: "", // Nonce is embedded in ciphertext by Enchantrix
}
// Convert header to map for trix
headerMap := map[string]interface{}{
"version": header.Version,
"algorithm": header.Algorithm,
"ephemeral_pk": header.EphemeralPK,
}
// Create trix container
t := &trix.Trix{
Header: headerMap,
Payload: encrypted,
}
// Encode with STMF magic
return trix.Encode(t, Magic, nil)
}
// EncryptMap is a convenience function to encrypt a simple key-value map
func EncryptMap(fields map[string]string, serverPublicKey []byte) ([]byte, error) {
data := NewFormData()
for name, value := range fields {
data.AddField(name, value)
}
return Encrypt(data, serverPublicKey)
}
// EncryptMapBase64 encrypts a map and returns base64
func EncryptMapBase64(fields map[string]string, serverPublicKey []byte) (string, error) {
encrypted, err := EncryptMap(fields, serverPublicKey)
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}

107
pkg/stmf/keypair.go Normal file
View file

@ -0,0 +1,107 @@
package stmf
import (
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"fmt"
)
// KeyPair represents an X25519 keypair for STMF encryption
type KeyPair struct {
privateKey *ecdh.PrivateKey
publicKey *ecdh.PublicKey
}
// GenerateKeyPair generates a new X25519 keypair
func GenerateKeyPair() (*KeyPair, error) {
curve := ecdh.X25519()
privateKey, err := curve.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrKeyGenerationFailed, err)
}
return &KeyPair{
privateKey: privateKey,
publicKey: privateKey.PublicKey(),
}, nil
}
// PublicKey returns the raw public key bytes (32 bytes)
func (k *KeyPair) PublicKey() []byte {
return k.publicKey.Bytes()
}
// PrivateKey returns the raw private key bytes (32 bytes)
func (k *KeyPair) PrivateKey() []byte {
return k.privateKey.Bytes()
}
// PublicKeyBase64 returns the public key as a base64-encoded string
func (k *KeyPair) PublicKeyBase64() string {
return base64.StdEncoding.EncodeToString(k.publicKey.Bytes())
}
// PrivateKeyBase64 returns the private key as a base64-encoded string
func (k *KeyPair) PrivateKeyBase64() string {
return base64.StdEncoding.EncodeToString(k.privateKey.Bytes())
}
// LoadPublicKey loads a public key from raw bytes
func LoadPublicKey(data []byte) (*ecdh.PublicKey, error) {
curve := ecdh.X25519()
pub, err := curve.NewPublicKey(data)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPublicKey, err)
}
return pub, nil
}
// LoadPublicKeyBase64 loads a public key from a base64-encoded string
func LoadPublicKeyBase64(encoded string) (*ecdh.PublicKey, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("%w: invalid base64: %v", ErrInvalidPublicKey, err)
}
return LoadPublicKey(data)
}
// LoadPrivateKey loads a private key from raw bytes
func LoadPrivateKey(data []byte) (*ecdh.PrivateKey, error) {
curve := ecdh.X25519()
priv, err := curve.NewPrivateKey(data)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidPrivateKey, err)
}
return priv, nil
}
// LoadPrivateKeyBase64 loads a private key from a base64-encoded string
func LoadPrivateKeyBase64(encoded string) (*ecdh.PrivateKey, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("%w: invalid base64: %v", ErrInvalidPrivateKey, err)
}
return LoadPrivateKey(data)
}
// LoadKeyPair loads a keypair from raw private key bytes
func LoadKeyPair(privateKeyBytes []byte) (*KeyPair, error) {
priv, err := LoadPrivateKey(privateKeyBytes)
if err != nil {
return nil, err
}
return &KeyPair{
privateKey: priv,
publicKey: priv.PublicKey(),
}, nil
}
// LoadKeyPairBase64 loads a keypair from a base64-encoded private key
func LoadKeyPairBase64(privateKeyBase64 string) (*KeyPair, error) {
data, err := base64.StdEncoding.DecodeString(privateKeyBase64)
if err != nil {
return nil, fmt.Errorf("%w: invalid base64: %v", ErrInvalidPrivateKey, err)
}
return LoadKeyPair(data)
}

192
pkg/stmf/middleware/http.go Normal file
View file

@ -0,0 +1,192 @@
// Package middleware provides HTTP middleware for automatic STMF decryption.
package middleware
import (
"context"
"encoding/base64"
"net/http"
"net/url"
"github.com/Snider/Borg/pkg/stmf"
)
// contextKey is a custom type for context keys to avoid collisions
type contextKey string
const (
// FormDataKey is the context key for the decrypted FormData
FormDataKey contextKey = "stmf_form_data"
// MetadataKey is the context key for the form metadata
MetadataKey contextKey = "stmf_metadata"
)
// Config holds the middleware configuration
type Config struct {
// PrivateKey is the server's X25519 private key (32 bytes)
PrivateKey []byte
// FieldName is the form field name containing the STMF payload
// Defaults to "_stmf_payload" if empty
FieldName string
// OnError is called when decryption fails
// If nil, returns 400 Bad Request
OnError func(w http.ResponseWriter, r *http.Request, err error)
// OnMissingPayload is called when the STMF field is not present
// If nil, the request passes through unchanged
OnMissingPayload func(w http.ResponseWriter, r *http.Request)
// PopulateForm controls whether decrypted fields are added to r.Form
// Defaults to true
PopulateForm *bool
}
// DefaultConfig returns a Config with default values
func DefaultConfig(privateKey []byte) Config {
populateForm := true
return Config{
PrivateKey: privateKey,
FieldName: stmf.DefaultFieldName,
PopulateForm: &populateForm,
}
}
// Middleware creates an HTTP middleware that decrypts STMF payloads.
// It looks for the STMF payload in the configured field name,
// decrypts it, and populates r.Form with the decrypted fields.
func Middleware(cfg Config) func(http.Handler) http.Handler {
if cfg.FieldName == "" {
cfg.FieldName = stmf.DefaultFieldName
}
if cfg.PopulateForm == nil {
populateForm := true
cfg.PopulateForm = &populateForm
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Only process POST/PUT/PATCH requests
if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodPatch {
next.ServeHTTP(w, r)
return
}
// Parse the form
if err := r.ParseMultipartForm(32 << 20); err != nil {
// Try regular form parsing
if err := r.ParseForm(); err != nil {
next.ServeHTTP(w, r)
return
}
}
// Look for STMF payload
payloadB64 := r.FormValue(cfg.FieldName)
if payloadB64 == "" {
if cfg.OnMissingPayload != nil {
cfg.OnMissingPayload(w, r)
return
}
next.ServeHTTP(w, r)
return
}
// Decode base64
payloadBytes, err := base64.StdEncoding.DecodeString(payloadB64)
if err != nil {
handleError(w, r, cfg, stmf.ErrInvalidPayload)
return
}
// Decrypt
formData, err := stmf.Decrypt(payloadBytes, cfg.PrivateKey)
if err != nil {
handleError(w, r, cfg, err)
return
}
// Store in context
ctx := r.Context()
ctx = context.WithValue(ctx, FormDataKey, formData)
if formData.Metadata != nil {
ctx = context.WithValue(ctx, MetadataKey, formData.Metadata)
}
// Populate r.Form with decrypted fields
if *cfg.PopulateForm {
if r.Form == nil {
r.Form = make(url.Values)
}
for _, field := range formData.Fields {
r.Form.Set(field.Name, field.Value)
}
// Also populate PostForm
if r.PostForm == nil {
r.PostForm = make(url.Values)
}
for _, field := range formData.Fields {
r.PostForm.Set(field.Name, field.Value)
}
}
// Remove the encrypted payload field
if r.Form != nil {
delete(r.Form, cfg.FieldName)
}
if r.PostForm != nil {
delete(r.PostForm, cfg.FieldName)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// handleError calls the error handler or returns 400
func handleError(w http.ResponseWriter, r *http.Request, cfg Config, err error) {
if cfg.OnError != nil {
cfg.OnError(w, r, err)
return
}
http.Error(w, "Invalid encrypted payload", http.StatusBadRequest)
}
// Simple creates a simple middleware with just a private key
func Simple(privateKey []byte) func(http.Handler) http.Handler {
return Middleware(DefaultConfig(privateKey))
}
// SimpleBase64 creates a simple middleware with a base64-encoded private key
func SimpleBase64(privateKeyB64 string) (func(http.Handler) http.Handler, error) {
keyBytes, err := base64.StdEncoding.DecodeString(privateKeyB64)
if err != nil {
return nil, err
}
return Simple(keyBytes), nil
}
// GetFormData retrieves the decrypted FormData from the request context
func GetFormData(r *http.Request) *stmf.FormData {
if fd, ok := r.Context().Value(FormDataKey).(*stmf.FormData); ok {
return fd
}
return nil
}
// GetMetadata retrieves the form metadata from the request context
func GetMetadata(r *http.Request) map[string]string {
if md, ok := r.Context().Value(MetadataKey).(map[string]string); ok {
return md
}
return nil
}
// HasSTMFPayload checks if the request contains a STMF payload
func HasSTMFPayload(r *http.Request, fieldName string) bool {
if fieldName == "" {
fieldName = stmf.DefaultFieldName
}
return r.FormValue(fieldName) != ""
}

Some files were not shown because too many files have changed in this diff Show more