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>
This commit is contained in:
Snider 2025-12-27 00:49:07 +00:00
parent 376517d7a2
commit b3755da69d
41 changed files with 7894 additions and 2 deletions

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

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

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

2
go.mod
View file

@ -3,7 +3,7 @@ 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

2
go.sum
View file

@ -7,6 +7,8 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi
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=

View file

@ -2,5 +2,4 @@ go 1.25.0
use (
.
../Enchantrix
)

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

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

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

@ -0,0 +1,554 @@
<!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>
</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>

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,797 @@
<!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>
</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
}
]

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

@ -0,0 +1,218 @@
package smsg
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"time"
"github.com/Snider/Enchantrix/pkg/enchantrix"
"github.com/Snider/Enchantrix/pkg/trix"
)
// 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)
}
// Decrypt decrypts an SMSG container with a password
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)
}
// 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
}
// Parse message
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["hint"].(string); ok {
header.Hint = v
}
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
}

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

@ -0,0 +1,270 @@
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)
}
}

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

@ -0,0 +1,136 @@
// 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.
package smsg
import (
"errors"
)
// 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")
)
// Attachment represents a file attached to the message
type Attachment struct {
Name string `json:"name"`
Content string `json:"content"` // base64-encoded
MimeType string `json:"mime,omitempty"`
Size int `json:"size,omitempty"`
}
// 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
func (m *Message) AddAttachment(name, content, mimeType string) *Message {
m.Attachments = append(m.Attachments, Attachment{
Name: name,
Content: content,
MimeType: mimeType,
Size: len(content),
})
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
}
// Header represents the SMSG container header
type Header struct {
Version string `json:"version"`
Algorithm string `json:"algorithm"`
Hint string `json:"hint,omitempty"` // optional password hint
}

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) != ""
}

View file

@ -0,0 +1,298 @@
package middleware
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/Snider/Borg/pkg/stmf"
)
func TestMiddleware(t *testing.T) {
// Generate server keypair
serverKP, err := stmf.GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
// Create form data and encrypt it
formData := stmf.NewFormData().
AddField("email", "test@example.com").
AddFieldWithType("password", "secret123", "password")
encryptedB64, err := stmf.EncryptBase64(formData, serverKP.PublicKey())
if err != nil {
t.Fatalf("EncryptBase64 failed: %v", err)
}
// Create middleware
mw := Simple(serverKP.PrivateKey())
// Create test handler
var capturedEmail, capturedPassword string
var capturedFormData *stmf.FormData
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedEmail = r.FormValue("email")
capturedPassword = r.FormValue("password")
capturedFormData = GetFormData(r)
w.WriteHeader(http.StatusOK)
}))
// Create request with encrypted payload
form := url.Values{}
form.Set(stmf.DefaultFieldName, encryptedB64)
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// Verify response
if rec.Code != http.StatusOK {
t.Errorf("Status code = %d, want %d", rec.Code, http.StatusOK)
}
// Verify decrypted fields are in r.FormValue
if capturedEmail != "test@example.com" {
t.Errorf("email = %q, want %q", capturedEmail, "test@example.com")
}
if capturedPassword != "secret123" {
t.Errorf("password = %q, want %q", capturedPassword, "secret123")
}
// Verify context FormData
if capturedFormData == nil {
t.Error("FormData not in context")
} else if capturedFormData.Get("email") != "test@example.com" {
t.Error("FormData email incorrect")
}
}
func TestMiddlewarePassThrough(t *testing.T) {
serverKP, err := stmf.GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
mw := Simple(serverKP.PrivateKey())
var called bool
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
// Request without STMF payload should pass through
form := url.Values{}
form.Set("regular_field", "value")
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if !called {
t.Error("Handler was not called for request without STMF payload")
}
}
func TestMiddlewareGetRequest(t *testing.T) {
serverKP, err := stmf.GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
mw := Simple(serverKP.PrivateKey())
var called bool
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
w.WriteHeader(http.StatusOK)
}))
// GET request should pass through without processing
req := httptest.NewRequest(http.MethodGet, "/page", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if !called {
t.Error("Handler was not called for GET request")
}
}
func TestMiddlewareInvalidPayload(t *testing.T) {
serverKP, err := stmf.GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
mw := Simple(serverKP.PrivateKey())
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Handler should not be called for invalid payload")
}))
// Request with invalid STMF payload
form := url.Values{}
form.Set(stmf.DefaultFieldName, "invalid-not-base64!!!!")
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("Status code = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestMiddlewareWrongKey(t *testing.T) {
serverKP, err := stmf.GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
wrongKP, err := stmf.GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
// Encrypt with server's public key
formData := stmf.NewFormData().AddField("test", "value")
encryptedB64, _ := stmf.EncryptBase64(formData, serverKP.PublicKey())
// But use wrong private key in middleware
mw := Simple(wrongKP.PrivateKey())
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Handler should not be called for wrong key")
}))
form := url.Values{}
form.Set(stmf.DefaultFieldName, encryptedB64)
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Errorf("Status code = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
func TestMiddlewareCustomErrorHandler(t *testing.T) {
serverKP, err := stmf.GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
var errorHandlerCalled bool
cfg := DefaultConfig(serverKP.PrivateKey())
cfg.OnError = func(w http.ResponseWriter, r *http.Request, err error) {
errorHandlerCalled = true
http.Error(w, "Custom error", http.StatusUnprocessableEntity)
}
mw := Middleware(cfg)
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Handler should not be called")
}))
form := url.Values{}
form.Set(stmf.DefaultFieldName, "invalid")
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if !errorHandlerCalled {
t.Error("Custom error handler was not called")
}
if rec.Code != http.StatusUnprocessableEntity {
t.Errorf("Status code = %d, want %d", rec.Code, http.StatusUnprocessableEntity)
}
}
func TestMiddlewareWithMetadata(t *testing.T) {
serverKP, err := stmf.GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
formData := stmf.NewFormData().
AddField("email", "test@example.com").
SetMetadata("origin", "https://example.com").
SetMetadata("timestamp", "1234567890")
encryptedB64, _ := stmf.EncryptBase64(formData, serverKP.PublicKey())
mw := Simple(serverKP.PrivateKey())
var capturedMetadata map[string]string
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedMetadata = GetMetadata(r)
w.WriteHeader(http.StatusOK)
}))
form := url.Values{}
form.Set(stmf.DefaultFieldName, encryptedB64)
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if capturedMetadata == nil {
t.Fatal("Metadata not in context")
}
if capturedMetadata["origin"] != "https://example.com" {
t.Errorf("origin = %q, want %q", capturedMetadata["origin"], "https://example.com")
}
}
func TestSimpleBase64(t *testing.T) {
serverKP, err := stmf.GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
mw, err := SimpleBase64(serverKP.PrivateKeyBase64())
if err != nil {
t.Fatalf("SimpleBase64 failed: %v", err)
}
// Create and encrypt form data
formData := stmf.NewFormData().AddField("test", "value")
encryptedB64, _ := stmf.EncryptBase64(formData, serverKP.PublicKey())
var capturedValue string
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedValue = r.FormValue("test")
w.WriteHeader(http.StatusOK)
}))
form := url.Values{}
form.Set(stmf.DefaultFieldName, encryptedB64)
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if capturedValue != "value" {
t.Errorf("test = %q, want %q", capturedValue, "value")
}
}

382
pkg/stmf/stmf_test.go Normal file
View file

@ -0,0 +1,382 @@
package stmf
import (
"encoding/base64"
"testing"
)
func TestKeyPairGeneration(t *testing.T) {
kp, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
// X25519 keys are 32 bytes
if len(kp.PublicKey()) != 32 {
t.Errorf("Public key length = %d, want 32", len(kp.PublicKey()))
}
if len(kp.PrivateKey()) != 32 {
t.Errorf("Private key length = %d, want 32", len(kp.PrivateKey()))
}
// Base64 encoding should work
pubB64 := kp.PublicKeyBase64()
privB64 := kp.PrivateKeyBase64()
if pubB64 == "" || privB64 == "" {
t.Error("Base64 encoding returned empty string")
}
// Should be able to decode back
pubBytes, err := base64.StdEncoding.DecodeString(pubB64)
if err != nil {
t.Errorf("Failed to decode public key base64: %v", err)
}
if len(pubBytes) != 32 {
t.Errorf("Decoded public key length = %d, want 32", len(pubBytes))
}
}
func TestLoadKeyPair(t *testing.T) {
// Generate a keypair
original, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
// Load it back from bytes
loaded, err := LoadKeyPair(original.PrivateKey())
if err != nil {
t.Fatalf("LoadKeyPair failed: %v", err)
}
// Public keys should match
if string(loaded.PublicKey()) != string(original.PublicKey()) {
t.Error("Loaded public key doesn't match original")
}
}
func TestLoadKeyPairBase64(t *testing.T) {
original, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
loaded, err := LoadKeyPairBase64(original.PrivateKeyBase64())
if err != nil {
t.Fatalf("LoadKeyPairBase64 failed: %v", err)
}
if loaded.PublicKeyBase64() != original.PublicKeyBase64() {
t.Error("Loaded public key doesn't match original")
}
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
// Generate server keypair
serverKP, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
// Create form data
formData := NewFormData().
AddField("email", "test@example.com").
AddFieldWithType("password", "secret123", "password").
SetMetadata("origin", "https://example.com")
// Encrypt with server's public key
encrypted, err := Encrypt(formData, serverKP.PublicKey())
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
// Decrypt with server's private key
decrypted, err := Decrypt(encrypted, serverKP.PrivateKey())
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
// Verify fields
if decrypted.Get("email") != "test@example.com" {
t.Errorf("email = %q, want %q", decrypted.Get("email"), "test@example.com")
}
if decrypted.Get("password") != "secret123" {
t.Errorf("password = %q, want %q", decrypted.Get("password"), "secret123")
}
// Verify metadata
if decrypted.Metadata["origin"] != "https://example.com" {
t.Errorf("origin = %q, want %q", decrypted.Metadata["origin"], "https://example.com")
}
// Verify field type preserved
pwField := decrypted.GetField("password")
if pwField == nil {
t.Error("password field not found")
} else if pwField.Type != "password" {
t.Errorf("password type = %q, want %q", pwField.Type, "password")
}
}
func TestEncryptDecryptBase64RoundTrip(t *testing.T) {
serverKP, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
formData := NewFormData().
AddField("username", "johndoe").
AddField("action", "login")
// Encrypt to base64
encryptedB64, err := EncryptBase64(formData, serverKP.PublicKey())
if err != nil {
t.Fatalf("EncryptBase64 failed: %v", err)
}
// Should be valid base64
if _, err := base64.StdEncoding.DecodeString(encryptedB64); err != nil {
t.Fatalf("Encrypted output is not valid base64: %v", err)
}
// Decrypt from base64
decrypted, err := DecryptBase64(encryptedB64, serverKP.PrivateKey())
if err != nil {
t.Fatalf("DecryptBase64 failed: %v", err)
}
if decrypted.Get("username") != "johndoe" {
t.Errorf("username = %q, want %q", decrypted.Get("username"), "johndoe")
}
}
func TestEncryptMapRoundTrip(t *testing.T) {
serverKP, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
input := map[string]string{
"name": "John Doe",
"email": "john@example.com",
"phone": "+1234567890",
}
encrypted, err := EncryptMap(input, serverKP.PublicKey())
if err != nil {
t.Fatalf("EncryptMap failed: %v", err)
}
output, err := DecryptToMap(encrypted, serverKP.PrivateKey())
if err != nil {
t.Fatalf("DecryptToMap failed: %v", err)
}
for key, want := range input {
if got := output[key]; got != want {
t.Errorf("%s = %q, want %q", key, got, want)
}
}
}
func TestMultipleEncryptionsAreDifferent(t *testing.T) {
// Each encryption should use a different ephemeral key
serverKP, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
formData := NewFormData().AddField("test", "value")
enc1, err := Encrypt(formData, serverKP.PublicKey())
if err != nil {
t.Fatalf("First Encrypt failed: %v", err)
}
enc2, err := Encrypt(formData, serverKP.PublicKey())
if err != nil {
t.Fatalf("Second Encrypt failed: %v", err)
}
// Encryptions should be different (different ephemeral keys)
if string(enc1) == string(enc2) {
t.Error("Two encryptions of same data produced identical output (should use different ephemeral keys)")
}
// But both should decrypt to the same value
dec1, _ := Decrypt(enc1, serverKP.PrivateKey())
dec2, _ := Decrypt(enc2, serverKP.PrivateKey())
if dec1.Get("test") != dec2.Get("test") {
t.Error("Decrypted values don't match")
}
}
func TestDecryptWithWrongKey(t *testing.T) {
serverKP, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
wrongKP, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair for wrong key failed: %v", err)
}
formData := NewFormData().AddField("secret", "data")
encrypted, err := Encrypt(formData, serverKP.PublicKey())
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
// Decrypting with wrong key should fail
_, err = Decrypt(encrypted, wrongKP.PrivateKey())
if err == nil {
t.Error("Decrypt with wrong key should have failed")
}
}
func TestValidatePayload(t *testing.T) {
serverKP, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
formData := NewFormData().AddField("test", "value")
encrypted, err := Encrypt(formData, serverKP.PublicKey())
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
// Valid payload should pass validation
if err := ValidatePayload(encrypted); err != nil {
t.Errorf("ValidatePayload failed for valid payload: %v", err)
}
// Invalid data should fail
if err := ValidatePayload([]byte("not a valid payload")); err == nil {
t.Error("ValidatePayload should fail for invalid data")
}
}
func TestGetPayloadInfo(t *testing.T) {
serverKP, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
formData := NewFormData().AddField("test", "value")
encrypted, err := Encrypt(formData, serverKP.PublicKey())
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
info, err := GetPayloadInfo(encrypted)
if err != nil {
t.Fatalf("GetPayloadInfo failed: %v", err)
}
if info.Version != Version {
t.Errorf("Version = %q, want %q", info.Version, Version)
}
if info.Algorithm != "x25519-chacha20poly1305" {
t.Errorf("Algorithm = %q, want %q", info.Algorithm, "x25519-chacha20poly1305")
}
if info.EphemeralPK == "" {
t.Error("EphemeralPK is empty")
}
}
func TestFormDataMethods(t *testing.T) {
fd := NewFormData()
// Test AddField
fd.AddField("name", "John")
if fd.Get("name") != "John" {
t.Error("AddField/Get failed")
}
// Test AddFieldWithType
fd.AddFieldWithType("password", "secret", "password")
field := fd.GetField("password")
if field == nil || field.Type != "password" {
t.Error("AddFieldWithType failed to preserve type")
}
// Test AddFile
fd.AddFile("doc", "base64data", "document.pdf", "application/pdf")
fileField := fd.GetField("doc")
if fileField == nil {
t.Error("AddFile failed")
} else {
if fileField.Filename != "document.pdf" {
t.Error("Filename not preserved")
}
if fileField.MimeType != "application/pdf" {
t.Error("MimeType not preserved")
}
}
// Test SetMetadata
fd.SetMetadata("origin", "https://test.com")
if fd.Metadata["origin"] != "https://test.com" {
t.Error("SetMetadata failed")
}
// Test GetAll with multiple values
fd.AddField("tag", "one")
fd.AddField("tag", "two")
tags := fd.GetAll("tag")
if len(tags) != 2 {
t.Errorf("GetAll returned %d values, want 2", len(tags))
}
// Test ToMap
m := fd.ToMap()
if m["name"] != "John" {
t.Error("ToMap failed")
}
}
func TestFileFieldRoundTrip(t *testing.T) {
serverKP, err := GenerateKeyPair()
if err != nil {
t.Fatalf("GenerateKeyPair failed: %v", err)
}
// Simulate file upload with base64 content
fileContent := base64.StdEncoding.EncodeToString([]byte("Hello, World!"))
formData := NewFormData().
AddField("description", "My document").
AddFile("upload", fileContent, "hello.txt", "text/plain")
encrypted, err := Encrypt(formData, serverKP.PublicKey())
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
decrypted, err := Decrypt(encrypted, serverKP.PrivateKey())
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
uploadField := decrypted.GetField("upload")
if uploadField == nil {
t.Fatal("upload field not found")
}
if uploadField.Type != "file" {
t.Errorf("Type = %q, want %q", uploadField.Type, "file")
}
if uploadField.Filename != "hello.txt" {
t.Errorf("Filename = %q, want %q", uploadField.Filename, "hello.txt")
}
if uploadField.MimeType != "text/plain" {
t.Errorf("MimeType = %q, want %q", uploadField.MimeType, "text/plain")
}
if uploadField.Value != fileContent {
t.Error("File content not preserved")
}
}

139
pkg/stmf/types.go Normal file
View file

@ -0,0 +1,139 @@
// Package stmf implements Sovereign Form Encryption using X25519 ECDH + ChaCha20-Poly1305.
// STMF (STIM Form) enables client-side encryption of HTML form data using the server's
// public key, providing end-to-end encryption even against MITM proxies.
package stmf
import (
"errors"
)
// Magic bytes for STMF format
const Magic = "STMF"
// Version of the STMF format
const Version = "1.0"
// DefaultFieldName is the form field name used for the encrypted payload
const DefaultFieldName = "_stmf_payload"
// Errors
var (
ErrInvalidMagic = errors.New("invalid STMF magic")
ErrInvalidPayload = errors.New("invalid STMF payload")
ErrDecryptionFailed = errors.New("decryption failed")
ErrInvalidPublicKey = errors.New("invalid public key")
ErrInvalidPrivateKey = errors.New("invalid private key")
ErrKeyGenerationFailed = errors.New("key generation failed")
)
// FormField represents a single form field
type FormField struct {
Name string `json:"name"`
Value string `json:"value"`
Type string `json:"type,omitempty"` // text, password, file, etc.
Filename string `json:"filename,omitempty"` // for file uploads
MimeType string `json:"mime,omitempty"` // for file uploads
}
// FormData represents the encrypted form payload
type FormData struct {
Fields []FormField `json:"fields"`
Metadata map[string]string `json:"meta,omitempty"`
}
// NewFormData creates a new empty FormData
func NewFormData() *FormData {
return &FormData{
Fields: make([]FormField, 0),
Metadata: make(map[string]string),
}
}
// AddField adds a field to the form data
func (f *FormData) AddField(name, value string) *FormData {
f.Fields = append(f.Fields, FormField{
Name: name,
Value: value,
Type: "text",
})
return f
}
// AddFieldWithType adds a typed field to the form data
func (f *FormData) AddFieldWithType(name, value, fieldType string) *FormData {
f.Fields = append(f.Fields, FormField{
Name: name,
Value: value,
Type: fieldType,
})
return f
}
// AddFile adds a file field to the form data
func (f *FormData) AddFile(name, value, filename, mimeType string) *FormData {
f.Fields = append(f.Fields, FormField{
Name: name,
Value: value,
Type: "file",
Filename: filename,
MimeType: mimeType,
})
return f
}
// SetMetadata sets a metadata value
func (f *FormData) SetMetadata(key, value string) *FormData {
if f.Metadata == nil {
f.Metadata = make(map[string]string)
}
f.Metadata[key] = value
return f
}
// Get retrieves a field value by name
func (f *FormData) Get(name string) string {
for _, field := range f.Fields {
if field.Name == name {
return field.Value
}
}
return ""
}
// GetField retrieves a full field by name
func (f *FormData) GetField(name string) *FormField {
for i := range f.Fields {
if f.Fields[i].Name == name {
return &f.Fields[i]
}
}
return nil
}
// GetAll retrieves all values for a field name (for multi-select)
func (f *FormData) GetAll(name string) []string {
var values []string
for _, field := range f.Fields {
if field.Name == name {
values = append(values, field.Value)
}
}
return values
}
// ToMap converts fields to a simple key-value map (last value wins for duplicates)
func (f *FormData) ToMap() map[string]string {
result := make(map[string]string)
for _, field := range f.Fields {
result[field.Name] = field.Value
}
return result
}
// Header represents the STMF container header
type Header struct {
Version string `json:"version"`
Algorithm string `json:"algorithm"`
EphemeralPK string `json:"ephemeral_pk"` // base64-encoded ephemeral public key
Nonce string `json:"nonce"` // base64-encoded nonce
}

505
pkg/wasm/stmf/main.go Normal file
View file

@ -0,0 +1,505 @@
//go:build js && wasm
// Package main provides the WASM entry point for Borg encryption.
// This module exposes encryption/decryption functions to JavaScript for:
// - STMF: Client-side form encryption using server's public key
// - SMSG: Password-based secure message decryption
package main
import (
"encoding/base64"
"encoding/json"
"syscall/js"
"github.com/Snider/Borg/pkg/smsg"
"github.com/Snider/Borg/pkg/stmf"
)
// Version of the WASM module
const Version = "1.1.0"
func main() {
// Export the BorgSTMF object to JavaScript global scope
js.Global().Set("BorgSTMF", js.ValueOf(map[string]interface{}{
"encrypt": js.FuncOf(encrypt),
"encryptFields": js.FuncOf(encryptFields),
"generateKeyPair": js.FuncOf(generateKeyPair),
"version": Version,
"ready": true,
}))
// Export BorgSMSG for secure message handling
js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{
"decrypt": js.FuncOf(smsgDecrypt),
"encrypt": js.FuncOf(smsgEncrypt),
"getInfo": js.FuncOf(smsgGetInfo),
"quickDecrypt": js.FuncOf(smsgQuickDecrypt),
"version": Version,
"ready": true,
}))
// Dispatch a ready event
dispatchReadyEvent()
// Keep the WASM module alive
select {}
}
// dispatchReadyEvent fires a custom event to notify JS that WASM is loaded
func dispatchReadyEvent() {
event := js.Global().Get("CustomEvent").New("borgstmf:ready", map[string]interface{}{
"detail": map[string]interface{}{
"version": Version,
},
})
js.Global().Get("document").Call("dispatchEvent", event)
}
// encrypt encrypts form data using the server's public key.
// JavaScript usage:
//
// const result = await BorgSTMF.encrypt(formDataJSON, serverPublicKeyBase64);
// // result is a base64-encoded STMF payload
func encrypt(this js.Value, args []js.Value) interface{} {
// Return a Promise
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
if len(args) < 2 {
reject.Invoke(newError("encrypt requires 2 arguments: formDataJSON, serverPublicKeyBase64"))
return
}
formDataJSON := args[0].String()
serverPubKeyB64 := args[1].String()
// Parse form data
var formData stmf.FormData
if err := json.Unmarshal([]byte(formDataJSON), &formData); err != nil {
reject.Invoke(newError("invalid form data JSON: " + err.Error()))
return
}
// Decode server public key
serverPubKey, err := base64.StdEncoding.DecodeString(serverPubKeyB64)
if err != nil {
reject.Invoke(newError("invalid server public key base64: " + err.Error()))
return
}
// Encrypt
encryptedB64, err := stmf.EncryptBase64(&formData, serverPubKey)
if err != nil {
reject.Invoke(newError("encryption failed: " + err.Error()))
return
}
resolve.Invoke(encryptedB64)
}()
return nil
})
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
// encryptFields encrypts a simple key-value object of form fields.
// JavaScript usage:
//
// const result = await BorgSTMF.encryptFields({
// email: 'user@example.com',
// password: 'secret'
// }, serverPublicKeyBase64, metadata);
func encryptFields(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
if len(args) < 2 {
reject.Invoke(newError("encryptFields requires at least 2 arguments: fields, serverPublicKeyBase64"))
return
}
fieldsObj := args[0]
serverPubKeyB64 := args[1].String()
// Build FormData from JavaScript object
formData := stmf.NewFormData()
// Get field names
keys := js.Global().Get("Object").Call("keys", fieldsObj)
keysLen := keys.Length()
for i := 0; i < keysLen; i++ {
key := keys.Index(i).String()
value := fieldsObj.Get(key)
// Handle different value types
if value.Type() == js.TypeString {
formData.AddField(key, value.String())
} else if value.Type() == js.TypeObject {
// Check if it's a file-like object
if !value.Get("name").IsUndefined() && !value.Get("value").IsUndefined() {
field := stmf.FormField{
Name: key,
Value: value.Get("value").String(),
}
if !value.Get("type").IsUndefined() {
field.Type = value.Get("type").String()
}
if !value.Get("filename").IsUndefined() {
field.Filename = value.Get("filename").String()
}
if !value.Get("mime").IsUndefined() {
field.MimeType = value.Get("mime").String()
}
formData.Fields = append(formData.Fields, field)
} else {
// Convert to JSON string
jsonStr := js.Global().Get("JSON").Call("stringify", value).String()
formData.AddField(key, jsonStr)
}
} else {
// Convert to string
formData.AddField(key, value.String())
}
}
// Handle optional metadata argument
if len(args) >= 3 && args[2].Type() == js.TypeObject {
metaObj := args[2]
metaKeys := js.Global().Get("Object").Call("keys", metaObj)
metaLen := metaKeys.Length()
for i := 0; i < metaLen; i++ {
key := metaKeys.Index(i).String()
value := metaObj.Get(key).String()
formData.SetMetadata(key, value)
}
}
// Decode server public key
serverPubKey, err := base64.StdEncoding.DecodeString(serverPubKeyB64)
if err != nil {
reject.Invoke(newError("invalid server public key base64: " + err.Error()))
return
}
// Encrypt
encryptedB64, err := stmf.EncryptBase64(formData, serverPubKey)
if err != nil {
reject.Invoke(newError("encryption failed: " + err.Error()))
return
}
resolve.Invoke(encryptedB64)
}()
return nil
})
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
// generateKeyPair generates a new X25519 keypair for testing/development.
// JavaScript usage:
//
// const keypair = await BorgSTMF.generateKeyPair();
// console.log(keypair.publicKey); // base64 public key
// console.log(keypair.privateKey); // base64 private key (keep secret!)
func generateKeyPair(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
kp, err := stmf.GenerateKeyPair()
if err != nil {
reject.Invoke(newError("key generation failed: " + err.Error()))
return
}
result := map[string]interface{}{
"publicKey": kp.PublicKeyBase64(),
"privateKey": kp.PrivateKeyBase64(),
}
resolve.Invoke(js.ValueOf(result))
}()
return nil
})
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
// newError creates a JavaScript Error object
func newError(message string) js.Value {
return js.Global().Get("Error").New(message)
}
// smsgDecrypt decrypts a base64-encoded SMSG with a password.
// JavaScript usage:
//
// const message = await BorgSMSG.decrypt(encryptedBase64, password);
// console.log(message.body);
// console.log(message.subject);
// console.log(message.attachments);
func smsgDecrypt(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
if len(args) < 2 {
reject.Invoke(newError("decrypt requires 2 arguments: encryptedBase64, password"))
return
}
encryptedB64 := args[0].String()
password := args[1].String()
msg, err := smsg.DecryptBase64(encryptedB64, password)
if err != nil {
reject.Invoke(newError("decryption failed: " + err.Error()))
return
}
// Convert message to JS object
result := messageToJS(msg)
resolve.Invoke(result)
}()
return nil
})
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
// smsgEncrypt encrypts a message with a password.
// JavaScript usage:
//
// const encrypted = await BorgSMSG.encrypt({
// body: 'Hello!',
// subject: 'Test',
// from: 'support@example.com'
// }, password);
func smsgEncrypt(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
if len(args) < 2 {
reject.Invoke(newError("encrypt requires 2 arguments: messageObject, password"))
return
}
msgObj := args[0]
password := args[1].String()
// Build message from JS object
msg := smsg.NewMessage(msgObj.Get("body").String())
if !msgObj.Get("subject").IsUndefined() {
msg.WithSubject(msgObj.Get("subject").String())
}
if !msgObj.Get("from").IsUndefined() {
msg.WithFrom(msgObj.Get("from").String())
}
// Handle attachments
attachments := msgObj.Get("attachments")
if !attachments.IsUndefined() && attachments.Length() > 0 {
for i := 0; i < attachments.Length(); i++ {
att := attachments.Index(i)
name := att.Get("name").String()
content := att.Get("content").String()
mimeType := ""
if !att.Get("mime").IsUndefined() {
mimeType = att.Get("mime").String()
}
msg.AddAttachment(name, content, mimeType)
}
}
// Handle reply key
replyKey := msgObj.Get("replyKey")
if !replyKey.IsUndefined() {
msg.WithReplyKey(replyKey.Get("publicKey").String())
}
// Handle metadata
meta := msgObj.Get("meta")
if !meta.IsUndefined() && meta.Type() == js.TypeObject {
keys := js.Global().Get("Object").Call("keys", meta)
for i := 0; i < keys.Length(); i++ {
key := keys.Index(i).String()
value := meta.Get(key).String()
msg.SetMeta(key, value)
}
}
// Get optional hint
hint := ""
if len(args) >= 3 && args[2].Type() == js.TypeString {
hint = args[2].String()
}
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 {
reject.Invoke(newError("encryption failed: " + err.Error()))
return
}
encryptedB64 := base64.StdEncoding.EncodeToString(encrypted)
resolve.Invoke(encryptedB64)
}()
return nil
})
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
// smsgGetInfo extracts header info from an SMSG without decrypting.
// JavaScript usage:
//
// const info = await BorgSMSG.getInfo(encryptedBase64);
// console.log(info.hint); // password hint if set
// console.log(info.version);
func smsgGetInfo(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
if len(args) < 1 {
reject.Invoke(newError("getInfo requires 1 argument: encryptedBase64"))
return
}
encryptedB64 := args[0].String()
header, err := smsg.GetInfoBase64(encryptedB64)
if err != nil {
reject.Invoke(newError("failed to get info: " + err.Error()))
return
}
result := map[string]interface{}{
"version": header.Version,
"algorithm": header.Algorithm,
}
if header.Hint != "" {
result["hint"] = header.Hint
}
resolve.Invoke(js.ValueOf(result))
}()
return nil
})
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
// smsgQuickDecrypt is a convenience function that just returns the body text.
// JavaScript usage:
//
// const body = await BorgSMSG.quickDecrypt(encryptedBase64, password);
func smsgQuickDecrypt(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
resolve := promiseArgs[0]
reject := promiseArgs[1]
go func() {
if len(args) < 2 {
reject.Invoke(newError("quickDecrypt requires 2 arguments: encryptedBase64, password"))
return
}
encryptedB64 := args[0].String()
password := args[1].String()
body, err := smsg.QuickDecrypt(encryptedB64, password)
if err != nil {
reject.Invoke(newError("decryption failed: " + err.Error()))
return
}
resolve.Invoke(body)
}()
return nil
})
promiseConstructor := js.Global().Get("Promise")
return promiseConstructor.New(handler)
}
// messageToJS converts an smsg.Message to a JavaScript object
func messageToJS(msg *smsg.Message) js.Value {
result := map[string]interface{}{
"body": msg.Body,
"timestamp": msg.Timestamp,
}
if msg.Subject != "" {
result["subject"] = msg.Subject
}
if msg.From != "" {
result["from"] = msg.From
}
// Convert attachments
if len(msg.Attachments) > 0 {
attachments := make([]interface{}, len(msg.Attachments))
for i, att := range msg.Attachments {
attachments[i] = map[string]interface{}{
"name": att.Name,
"content": att.Content,
"mime": att.MimeType,
"size": att.Size,
}
}
result["attachments"] = attachments
}
// Convert reply key
if msg.ReplyKey != nil {
result["replyKey"] = map[string]interface{}{
"publicKey": msg.ReplyKey.PublicKey,
"keyId": msg.ReplyKey.KeyID,
"algorithm": msg.ReplyKey.Algorithm,
"fingerprint": msg.ReplyKey.Fingerprint,
}
}
// Convert metadata
if len(msg.Meta) > 0 {
meta := make(map[string]interface{})
for k, v := range msg.Meta {
meta[k] = v
}
result["meta"] = meta
}
return js.ValueOf(result)
}