11 KiB
RFC-010: WASM Decryption API
Status: Draft Author: Snider Created: 2026-01-13 License: EUPL-1.2 Depends On: RFC-002, RFC-007, RFC-009
Abstract
This RFC specifies the WebAssembly (WASM) API for browser-based decryption of SMSG content and STMF form encryption. The API is exposed through two JavaScript namespaces: BorgSMSG for content decryption and BorgSTMF for form encryption.
1. Overview
The WASM module provides:
- SMSG decryption (v1, v2, v3, chunked, ABR)
- SMSG encryption
- STMF form encryption/decryption
- Metadata extraction without decryption
2. Module Loading
2.1 Files Required
stmf.wasm (~5.9MB) Compiled Go WASM module
wasm_exec.js (~20KB) Go WASM runtime
2.2 Initialization
<script src="wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch('stmf.wasm'), go.importObject)
.then(result => {
go.run(result.instance);
// BorgSMSG and BorgSTMF now available globally
});
</script>
2.3 Ready Event
document.addEventListener('borgstmf:ready', (event) => {
console.log('WASM ready, version:', event.detail.version);
});
3. BorgSMSG Namespace
3.1 Version
BorgSMSG.version // "1.6.0"
BorgSMSG.ready // true when loaded
3.2 Metadata Functions
getInfo(base64) → Promise
Get manifest without decryption.
const info = await BorgSMSG.getInfo(base64Content);
// info.version, info.algorithm, info.format
// info.manifest.title, info.manifest.artist
// info.isV3Streaming, info.isChunked
// info.wrappedKeys (for v3)
getInfoBinary(uint8Array) → Promise
Binary input variant (no base64 decode needed).
const bytes = new Uint8Array(await response.arrayBuffer());
const info = await BorgSMSG.getInfoBinary(bytes);
3.3 Decryption Functions
decrypt(base64, password) → Promise
Full decryption (v1 format, base64 attachments).
const msg = await BorgSMSG.decrypt(base64Content, password);
// msg.body, msg.subject, msg.from
// msg.attachments[0].name, .content (base64), .mime
decryptStream(base64, password) → Promise
Streaming decryption (v2 format, binary attachments).
const msg = await BorgSMSG.decryptStream(base64Content, password);
// msg.attachments[0].data (Uint8Array)
// msg.attachments[0].mime
decryptBinary(uint8Array, password) → Promise
Binary input, binary output.
const bytes = new Uint8Array(await fetch(url).then(r => r.arrayBuffer()));
const msg = await BorgSMSG.decryptBinary(bytes, password);
quickDecrypt(base64, password) → Promise
Returns body text only (fast path).
const body = await BorgSMSG.quickDecrypt(base64Content, password);
3.4 V3 Streaming Functions
decryptV3(base64, params) → Promise
Decrypt v3 streaming content with LTHN rolling keys.
const msg = await BorgSMSG.decryptV3(base64Content, {
license: "user-license-key",
fingerprint: "device-fingerprint" // optional
});
getV3ChunkInfo(base64) → Promise
Get chunk index for seeking without full decrypt.
const chunkInfo = await BorgSMSG.getV3ChunkInfo(base64Content);
// chunkInfo.chunkSize (default 1MB)
// chunkInfo.totalChunks
// chunkInfo.totalSize
// chunkInfo.index[i].offset, .size
unwrapV3CEK(base64, params) → Promise
Unwrap CEK for manual chunk decryption. Returns base64 CEK.
const cekBase64 = await BorgSMSG.unwrapV3CEK(base64Content, {
license: "license",
fingerprint: "fp"
});
decryptV3Chunk(base64, cekBase64, chunkIndex) → Promise
Decrypt single chunk by index.
const chunk = await BorgSMSG.decryptV3Chunk(base64Content, cekBase64, 5);
parseV3Header(uint8Array) → Promise
Parse header from partial data (for streaming).
const header = await BorgSMSG.parseV3Header(bytes);
// header.format, header.keyMethod, header.cadence
// header.payloadOffset (where chunks start)
// header.wrappedKeys, header.chunked, header.manifest
unwrapCEKFromHeader(wrappedKeys, params, cadence) → Promise
Unwrap CEK from parsed header.
const cek = await BorgSMSG.unwrapCEKFromHeader(
header.wrappedKeys,
{license: "lic", fingerprint: "fp"},
"daily"
);
decryptChunkDirect(chunkBytes, cek) → Promise
Low-level chunk decryption with pre-unwrapped CEK.
const plaintext = await BorgSMSG.decryptChunkDirect(chunkBytes, cek);
3.5 Encryption Functions
encrypt(message, password, hint?) → Promise
Encrypt message (v1 format). Returns base64.
const encrypted = await BorgSMSG.encrypt({
body: "Hello",
attachments: [{
name: "file.txt",
content: btoa("data"),
mime: "text/plain"
}]
}, password, "optional hint");
encryptWithManifest(message, password, manifest) → Promise
Encrypt with manifest (v2 format). Returns base64.
const encrypted = await BorgSMSG.encryptWithManifest(message, password, {
title: "My Track",
artist: "Artist Name",
licenseType: "perpetual"
});
3.6 ABR Functions
parseABRManifest(jsonString) → Promise
Parse HLS-style ABR manifest.
const manifest = await BorgSMSG.parseABRManifest(manifestJson);
// manifest.version, manifest.title, manifest.duration
// manifest.variants[i].name, .bandwidth, .url
// manifest.defaultIdx
selectVariant(manifest, bandwidthBps) → Promise
Select best variant for bandwidth (returns index).
const idx = await BorgSMSG.selectVariant(manifest, measuredBandwidth);
// Uses 80% safety threshold
4. BorgSTMF Namespace
4.1 Key Generation
const keypair = await BorgSTMF.generateKeyPair();
// keypair.publicKey (base64 X25519)
// keypair.privateKey (base64 X25519) - KEEP SECRET
4.2 Encryption
// Encrypt JSON string
const encrypted = await BorgSTMF.encrypt(
JSON.stringify(formData),
serverPublicKeyBase64
);
// Encrypt with metadata
const encrypted = await BorgSTMF.encryptFields(
{email: "user@example.com", password: "secret"},
serverPublicKeyBase64,
{timestamp: Date.now().toString()} // optional metadata
);
5. Type Definitions
5.1 ManifestInfo
interface ManifestInfo {
version: string;
algorithm: string;
format?: string;
compression?: string;
hint?: string;
keyMethod?: string; // "LTHN" for v3
cadence?: string; // "daily", "12h", "6h", "1h"
wrappedKeys?: WrappedKey[];
isV3Streaming: boolean;
chunked?: ChunkInfo;
isChunked: boolean;
manifest?: Manifest;
}
5.2 Message / StreamMessage
interface Message {
from?: string;
to?: string;
subject?: string;
body: string;
timestamp?: number;
attachments: Attachment[];
replyKey?: KeyInfo;
meta?: Record<string, string>;
}
interface Attachment {
name: string;
mime: string;
size: number;
content?: string; // base64 (v1)
data?: Uint8Array; // binary (v2/v3)
}
5.3 ChunkInfo
interface ChunkInfo {
chunkSize: number; // default 1048576 (1MB)
totalChunks: number;
totalSize: number;
index: ChunkEntry[];
}
interface ChunkEntry {
offset: number;
size: number;
}
5.4 Manifest
interface Manifest {
title: string;
artist?: string;
album?: string;
genre?: string;
year?: number;
releaseType?: string; // "single", "album", "ep", "mix"
duration?: number; // seconds
format?: string;
expiresAt?: number; // Unix timestamp
issuedAt?: number; // Unix timestamp
licenseType?: string; // "perpetual", "rental", "stream", "preview"
tracks?: Track[];
tags?: string[];
links?: Record<string, string>;
extra?: Record<string, string>;
}
6. Error Handling
6.1 Pattern
All functions throw on error:
try {
const msg = await BorgSMSG.decrypt(content, password);
} catch (e) {
console.error(e.message);
}
6.2 Common Errors
| Error | Cause |
|---|---|
decrypt requires 2 arguments |
Wrong argument count |
decryption failed: {reason} |
Wrong password or corrupted |
invalid format |
Not a valid SMSG file |
unsupported version |
Unknown format version |
key expired |
v3 rolling key outside window |
invalid base64: {reason} |
Base64 decode failed |
chunk out of range |
Invalid chunk index |
7. Performance
7.1 Binary vs Base64
- Binary functions (
*Binary,decryptStream) are ~30% faster - Avoid double base64 encoding
7.2 Large Files (>50MB)
Use chunked streaming:
// Efficient: Cache CEK, stream chunks
const header = await BorgSMSG.parseV3Header(bytes);
const cek = await BorgSMSG.unwrapCEKFromHeader(header.wrappedKeys, params);
for (let i = 0; i < header.chunked.totalChunks; i++) {
const chunk = await BorgSMSG.decryptChunkDirect(payload, cek);
player.write(chunk);
// chunk is GC'd after each iteration
}
7.3 Typical Execution Times
| Operation | Size | Time |
|---|---|---|
| getInfo | any | ~50-100ms |
| decrypt (small) | <1MB | ~200-500ms |
| decrypt (large) | 100MB | 2-5s |
| decryptV3Chunk | 1MB | ~200-400ms |
| generateKeyPair | - | ~50-200ms |
8. Browser Compatibility
| Browser | Support |
|---|---|
| Chrome 57+ | Full |
| Firefox 52+ | Full |
| Safari 11+ | Full |
| Edge 16+ | Full |
| IE | Not supported |
Requirements:
- WebAssembly support
- Async/await (ES2017)
- Uint8Array
9. Memory Management
- WASM module: ~5.9MB static
- Per-operation: Peak ~2-3x file size during decryption
- Go GC reclaims after Promise resolution
- Keys never leave WASM memory
10. Implementation Reference
- Source:
pkg/wasm/stmf/main.go(1758 lines) - Build:
GOOS=js GOARCH=wasm go build -o stmf.wasm ./pkg/wasm/stmf/
11. Security Considerations
- Password handling: Clear from memory after use
- Memory isolation: WASM sandbox prevents JS access
- Constant-time crypto: Go crypto uses safe operations
- Key protection: Keys never exposed to JavaScript
12. Future Work
- WebWorker support for background decryption
- Streaming API with ReadableStream
- Smaller WASM size via TinyGo
- Native Web Crypto fallback for simple operations