diff --git a/Taskfile.yml b/Taskfile.yml index 85400db..23c8914 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -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 diff --git a/dist/stmf.wasm b/dist/stmf.wasm new file mode 100755 index 0000000..2a6c45d Binary files /dev/null and b/dist/stmf.wasm differ diff --git a/dist/wasm_exec.js b/dist/wasm_exec.js new file mode 100644 index 0000000..d71af9e --- /dev/null +++ b/dist/wasm_exec.js @@ -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; + }; + } + } +})(); diff --git a/examples/smsg-reply/main.go b/examples/smsg-reply/main.go new file mode 100644 index 0000000..ec9f59b --- /dev/null +++ b/examples/smsg-reply/main.go @@ -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" + } +} diff --git a/go.mod b/go.mod index 6511b7d..84a1797 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 6dcbffd..b982ddb 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/go.work b/go.work index 395a2e0..855a0b3 100644 --- a/go.work +++ b/go.work @@ -2,5 +2,4 @@ go 1.25.0 use ( . - ../Enchantrix ) diff --git a/js/borg-stmf/README.md b/js/borg-stmf/README.md new file mode 100644 index 0000000..f7e0f84 --- /dev/null +++ b/js/borg-stmf/README.md @@ -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 + + + + +
+ + + +
+ + +``` + +## 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 diff --git a/js/borg-stmf/demo.html b/js/borg-stmf/demo.html new file mode 100644 index 0000000..939e37b --- /dev/null +++ b/js/borg-stmf/demo.html @@ -0,0 +1,155 @@ + + + + + + STMF Demo - Sovereign Form Encryption + + + +

STMF Demo

+

Sovereign Form Encryption using X25519 + ChaCha20-Poly1305

+ +
Loading WASM module...
+ +
+

1. Generate Server Keypair

+

In production, this is done server-side and the private key is kept secret.

+ + + + + +
+ +
+

2. Encrypt Form Data

+
+ + + + + + + + + + +
+ + +
Submit the form to see encrypted output...
+
+ +
+

3. Payload Info

+

This information can be read without decrypting (metadata is in the header):

+
Submit the form to see payload info...
+
+ + + + + diff --git a/js/borg-stmf/dist/stmf.wasm b/js/borg-stmf/dist/stmf.wasm new file mode 100755 index 0000000..00297c3 Binary files /dev/null and b/js/borg-stmf/dist/stmf.wasm differ diff --git a/js/borg-stmf/dist/wasm_exec.js b/js/borg-stmf/dist/wasm_exec.js new file mode 100644 index 0000000..d71af9e --- /dev/null +++ b/js/borg-stmf/dist/wasm_exec.js @@ -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; + }; + } + } +})(); diff --git a/js/borg-stmf/index.html b/js/borg-stmf/index.html new file mode 100644 index 0000000..4412d30 --- /dev/null +++ b/js/borg-stmf/index.html @@ -0,0 +1,554 @@ + + + + + + STMF - Sovereign Form Encryption + + + +
+

Sovereign Form Encryption

+

X25519 ECDH + ChaCha20-Poly1305 client-side encryption

+ + + +
+ + Loading encryption module... +
+ +
+

πŸ”‘ Server Keypair

+

In production, generate this server-side and keep the private key secret. Only the public key is shared with clients.

+ + + +
+
+ + +
+
+ + +
+
+
+ +
+

πŸ“ Encrypt Form Data

+

Enter form fields to encrypt. Data is encrypted client-side before transmission.

+ +
+ ⚠️ + Generate a keypair first to enable encryption +
+ +
+
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+ +
+ βœ… + Form encrypted successfully! + +
+
+ + + +
+

ℹ️ How It Works

+

+ 1. Key Exchange: An ephemeral X25519 keypair is generated for each encryption.
+ 2. Shared Secret: ECDH derives a shared secret using the ephemeral private key and server's public key.
+ 3. Encryption: Form data is encrypted with ChaCha20-Poly1305 using the derived key.
+ 4. Payload: The ephemeral public key is included in the header so the server can decrypt.

+ Each encryption produces a unique output even for the same data, ensuring forward secrecy. +

+
+
+ + + + + diff --git a/js/borg-stmf/package.json b/js/borg-stmf/package.json new file mode 100644 index 0000000..820ddd9 --- /dev/null +++ b/js/borg-stmf/package.json @@ -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" + } +} diff --git a/js/borg-stmf/src/index.ts b/js/borg-stmf/src/index.ts new file mode 100644 index 0000000..8c4078e --- /dev/null +++ b/js/borg-stmf/src/index.ts @@ -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; + 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 { + 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 { + this.ensureInitialized(); + + const formData = new window.FormData(form); + return this.encryptFormData(formData); + } + + /** + * Encrypt a FormData object + */ + async encryptFormData(formData: globalThis.FormData): Promise { + this.ensureInitialized(); + + const fields: Record = {}; + + 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, + metadata?: Record + ): Promise { + 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 { + 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 { + 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 = {}; + + 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 { + 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 { + 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; +} diff --git a/js/borg-stmf/src/types.ts b/js/borg-stmf/src/types.ts new file mode 100644 index 0000000..0653002 --- /dev/null +++ b/js/borg-stmf/src/types.ts @@ -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; +} + +/** + * 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; + + /** + * 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; + encryptFields: ( + fields: Record, + serverPublicKey: string, + metadata?: Record + ) => Promise; + generateKeyPair: () => Promise; + version: string; + ready: boolean; +} + +declare global { + interface Window { + BorgSTMF?: BorgSTMFWasm; + } +} diff --git a/js/borg-stmf/stmf.wasm b/js/borg-stmf/stmf.wasm new file mode 100755 index 0000000..2a6c45d Binary files /dev/null and b/js/borg-stmf/stmf.wasm differ diff --git a/js/borg-stmf/support-reply.html b/js/borg-stmf/support-reply.html new file mode 100644 index 0000000..edf8a95 --- /dev/null +++ b/js/borg-stmf/support-reply.html @@ -0,0 +1,797 @@ + + + + + + Decrypt Secure Support Reply + + + +
+

Secure Support Reply

+

Decrypt password-protected messages from support

+ + + +
+ + Loading encryption module... +
+ +
+

πŸ“¨ Encrypted Message

+ +
+ + +
+ +
+ πŸ’‘ + Password hint: +
+ +
+ +
+
+ + +
+ +
+
+ +
+

πŸ“¬ Decrypted Message

+ +
+
+
Support Team
+
+
+
+
+ +
+ + + +
+

πŸ” Authenticated Reply Key

+

This message includes a public key for secure replies. Use this to encrypt your response:

+
+
+
+ +
+
+

Demo: Try with sample messages

+

+ Click a button to load a pre-encrypted sample message. All use password: demo123 +

+
+
+
+
+ + + + + diff --git a/js/borg-stmf/tsconfig.json b/js/borg-stmf/tsconfig.json new file mode 100644 index 0000000..5d88f52 --- /dev/null +++ b/js/borg-stmf/tsconfig.json @@ -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"] +} diff --git a/js/borg-stmf/wasm_exec.js b/js/borg-stmf/wasm_exec.js new file mode 100644 index 0000000..d71af9e --- /dev/null +++ b/js/borg-stmf/wasm_exec.js @@ -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; + }; + } + } +})(); diff --git a/php/borg-stmf/README.md b/php/borg-stmf/README.md new file mode 100644 index 0000000..1ba1bbc --- /dev/null +++ b/php/borg-stmf/README.md @@ -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 +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 diff --git a/php/borg-stmf/composer.json b/php/borg-stmf/composer.json new file mode 100644 index 0000000..647a439 --- /dev/null +++ b/php/borg-stmf/composer.json @@ -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" +} diff --git a/php/borg-stmf/src/DecryptionException.php b/php/borg-stmf/src/DecryptionException.php new file mode 100644 index 0000000..37f0d8c --- /dev/null +++ b/php/borg-stmf/src/DecryptionException.php @@ -0,0 +1,12 @@ + */ + private array $metadata; + + /** + * @param FormField[] $fields + * @param array $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 + */ + public function toArray(): array + { + $result = []; + foreach ($this->fields as $field) { + $result[$field->name] = $field->value; + } + return $result; + } + + /** + * Get metadata + * + * @return array + */ + 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'] ?? []); + } +} diff --git a/php/borg-stmf/src/FormField.php b/php/borg-stmf/src/FormField.php new file mode 100644 index 0000000..9606e96 --- /dev/null +++ b/php/borg-stmf/src/FormField.php @@ -0,0 +1,64 @@ +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 + ); + } +} diff --git a/php/borg-stmf/src/InvalidPayloadException.php b/php/borg-stmf/src/InvalidPayloadException.php new file mode 100644 index 0000000..607d932 --- /dev/null +++ b/php/borg-stmf/src/InvalidPayloadException.php @@ -0,0 +1,12 @@ +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); + } +} diff --git a/php/borg-stmf/src/STMF.php b/php/borg-stmf/src/STMF.php new file mode 100644 index 0000000..3d2550f --- /dev/null +++ b/php/borg-stmf/src/STMF.php @@ -0,0 +1,312 @@ +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()); + } +} diff --git a/php/borg-stmf/tests/InteropTest.php b/php/borg-stmf/tests/InteropTest.php new file mode 100644 index 0000000..a6160e2 --- /dev/null +++ b/php/borg-stmf/tests/InteropTest.php @@ -0,0 +1,238 @@ +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); +} diff --git a/php/borg-stmf/tests/generate_test_vectors.go b/php/borg-stmf/tests/generate_test_vectors.go new file mode 100644 index 0000000..d833131 --- /dev/null +++ b/php/borg-stmf/tests/generate_test_vectors.go @@ -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", ""). + 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": "", + "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)) +} diff --git a/php/borg-stmf/tests/test_vectors.json b/php/borg-stmf/tests/test_vectors.json new file mode 100644 index 0000000..6292125 --- /dev/null +++ b/php/borg-stmf/tests/test_vectors.json @@ -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 + } +] diff --git a/pkg/smsg/smsg.go b/pkg/smsg/smsg.go new file mode 100644 index 0000000..a442d5c --- /dev/null +++ b/pkg/smsg/smsg.go @@ -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 +} diff --git a/pkg/smsg/smsg_test.go b/pkg/smsg/smsg_test.go new file mode 100644 index 0000000..a2d688e --- /dev/null +++ b/pkg/smsg/smsg_test.go @@ -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) + } +} diff --git a/pkg/smsg/types.go b/pkg/smsg/types.go new file mode 100644 index 0000000..402a860 --- /dev/null +++ b/pkg/smsg/types.go @@ -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 +} diff --git a/pkg/stmf/decrypt.go b/pkg/stmf/decrypt.go new file mode 100644 index 0000000..4e86be0 --- /dev/null +++ b/pkg/stmf/decrypt.go @@ -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 +} diff --git a/pkg/stmf/encrypt.go b/pkg/stmf/encrypt.go new file mode 100644 index 0000000..504b5c8 --- /dev/null +++ b/pkg/stmf/encrypt.go @@ -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 +} diff --git a/pkg/stmf/keypair.go b/pkg/stmf/keypair.go new file mode 100644 index 0000000..b836480 --- /dev/null +++ b/pkg/stmf/keypair.go @@ -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) +} diff --git a/pkg/stmf/middleware/http.go b/pkg/stmf/middleware/http.go new file mode 100644 index 0000000..15e83a0 --- /dev/null +++ b/pkg/stmf/middleware/http.go @@ -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) != "" +} diff --git a/pkg/stmf/middleware/http_test.go b/pkg/stmf/middleware/http_test.go new file mode 100644 index 0000000..be7792f --- /dev/null +++ b/pkg/stmf/middleware/http_test.go @@ -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") + } +} diff --git a/pkg/stmf/stmf_test.go b/pkg/stmf/stmf_test.go new file mode 100644 index 0000000..12ed25b --- /dev/null +++ b/pkg/stmf/stmf_test.go @@ -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") + } +} diff --git a/pkg/stmf/types.go b/pkg/stmf/types.go new file mode 100644 index 0000000..0aa9f19 --- /dev/null +++ b/pkg/stmf/types.go @@ -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 +} diff --git a/pkg/wasm/stmf/main.go b/pkg/wasm/stmf/main.go new file mode 100644 index 0000000..8bd615e --- /dev/null +++ b/pkg/wasm/stmf/main.go @@ -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) +}