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!
+
+
+
+
+
+
π Encrypted Output
+
This base64 payload can be safely transmitted. Only the server with the private key can decrypt it.
+
+
+
+
+
+
-
+
Payload Size
+
+
+
-
+
Fields Encrypted
+
+
+
X25519
+
Key Exchange
+
+
+
ChaCha20
+
Cipher
+
+
+
+
+
+
βΉοΈ 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
+
+
+
+
+
+
+
+
+
Attachments
+
+
+
+
+
π 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)
+}