feat: Add STMF form encryption and SMSG secure message packages
STMF (Sovereign Form Encryption): - X25519 ECDH + ChaCha20-Poly1305 hybrid encryption - Go library (pkg/stmf/) with encrypt/decrypt and HTTP middleware - WASM module for client-side browser encryption - JavaScript wrapper with TypeScript types (js/borg-stmf/) - PHP library for server-side decryption (php/borg-stmf/) - Full cross-platform interoperability (Go <-> PHP) SMSG (Secure Message): - Password-based ChaCha20-Poly1305 message encryption - Support for attachments, metadata, and PKI reply keys - WASM bindings for browser-based decryption Demos: - index.html: Form encryption demo with modern dark UI - support-reply.html: Decrypt password-protected messages - examples/smsg-reply/: CLI tool for creating encrypted replies 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
376517d7a2
commit
b3755da69d
41 changed files with 7894 additions and 2 deletions
20
Taskfile.yml
20
Taskfile.yml
|
|
@ -28,3 +28,23 @@ tasks:
|
|||
- task: build
|
||||
- chmod +x borg
|
||||
- ./borg --help
|
||||
wasm:
|
||||
desc: Build STMF WASM module for browser
|
||||
cmds:
|
||||
- mkdir -p dist
|
||||
- GOOS=js GOARCH=wasm go build -o dist/stmf.wasm ./pkg/wasm/stmf/
|
||||
- cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/
|
||||
sources:
|
||||
- ./pkg/stmf/**/*.go
|
||||
- ./pkg/wasm/stmf/*.go
|
||||
generates:
|
||||
- dist/stmf.wasm
|
||||
- dist/wasm_exec.js
|
||||
wasm-js:
|
||||
desc: Build STMF WASM and JS wrapper
|
||||
cmds:
|
||||
- task: wasm
|
||||
- cp dist/stmf.wasm js/borg-stmf/dist/
|
||||
- cp dist/wasm_exec.js js/borg-stmf/dist/
|
||||
deps:
|
||||
- wasm
|
||||
|
|
|
|||
BIN
dist/stmf.wasm
vendored
Executable file
BIN
dist/stmf.wasm
vendored
Executable file
Binary file not shown.
575
dist/wasm_exec.js
vendored
Normal file
575
dist/wasm_exec.js
vendored
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
191
examples/smsg-reply/main.go
Normal file
191
examples/smsg-reply/main.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
// Example: Creating encrypted support reply messages
|
||||
//
|
||||
// This example demonstrates how to create password-protected secure messages
|
||||
// that can be decrypted client-side using the BorgSMSG WASM module.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run main.go
|
||||
// go run main.go -password "secret123" -body "Your message here"
|
||||
// go run main.go -password "secret123" -body "Message" -hint "Your hint"
|
||||
// go run main.go -password "secret123" -body "Message" -attach file.txt
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/Snider/Borg/pkg/smsg"
|
||||
"github.com/Snider/Borg/pkg/stmf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Command line flags
|
||||
password := flag.String("password", "demo123", "Password for encryption")
|
||||
hint := flag.String("hint", "", "Optional password hint")
|
||||
body := flag.String("body", "", "Message body (if empty, uses demo content)")
|
||||
subject := flag.String("subject", "", "Message subject")
|
||||
from := flag.String("from", "support@example.com", "Sender address")
|
||||
attachFile := flag.String("attach", "", "File to attach (optional)")
|
||||
withReplyKey := flag.Bool("reply-key", false, "Include a reply public key")
|
||||
outputFile := flag.String("out", "", "Output file (if empty, prints to stdout)")
|
||||
rawBytes := flag.Bool("raw", false, "Output raw bytes instead of base64")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
// Create the message
|
||||
var msg *smsg.Message
|
||||
if *body == "" {
|
||||
msg = createDemoMessage()
|
||||
} else {
|
||||
msg = smsg.NewMessage(*body)
|
||||
}
|
||||
|
||||
// Set optional fields
|
||||
if *subject != "" {
|
||||
msg.WithSubject(*subject)
|
||||
}
|
||||
if *from != "" {
|
||||
msg.WithFrom(*from)
|
||||
}
|
||||
|
||||
// Add attachment if specified
|
||||
if *attachFile != "" {
|
||||
if err := addAttachment(msg, *attachFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error adding attachment: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Add reply key if requested
|
||||
if *withReplyKey {
|
||||
kp, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error generating reply key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
msg.WithReplyKey(kp.PublicKeyBase64())
|
||||
fmt.Fprintf(os.Stderr, "Reply private key (keep secret): %s\n", kp.PrivateKeyBase64())
|
||||
}
|
||||
|
||||
// Encrypt the message
|
||||
var encrypted []byte
|
||||
var err error
|
||||
if *hint != "" {
|
||||
encrypted, err = smsg.EncryptWithHint(msg, *password, *hint)
|
||||
} else {
|
||||
encrypted, err = smsg.Encrypt(msg, *password)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Encryption failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Output the result
|
||||
var output []byte
|
||||
if *rawBytes {
|
||||
output = encrypted
|
||||
} else {
|
||||
output = []byte(base64.StdEncoding.EncodeToString(encrypted))
|
||||
}
|
||||
|
||||
if *outputFile != "" {
|
||||
if err := os.WriteFile(*outputFile, output, 0644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing file: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Encrypted message written to: %s\n", *outputFile)
|
||||
} else {
|
||||
fmt.Println(string(output))
|
||||
}
|
||||
|
||||
// Print info
|
||||
fmt.Fprintln(os.Stderr)
|
||||
fmt.Fprintln(os.Stderr, "--- Message Info ---")
|
||||
fmt.Fprintf(os.Stderr, "Password: %s\n", *password)
|
||||
if *hint != "" {
|
||||
fmt.Fprintf(os.Stderr, "Hint: %s\n", *hint)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "From: %s\n", msg.From)
|
||||
if msg.Subject != "" {
|
||||
fmt.Fprintf(os.Stderr, "Subject: %s\n", msg.Subject)
|
||||
}
|
||||
if len(msg.Attachments) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Attachments: %d\n", len(msg.Attachments))
|
||||
}
|
||||
if msg.ReplyKey != nil {
|
||||
fmt.Fprintln(os.Stderr, "Reply Key: included")
|
||||
}
|
||||
}
|
||||
|
||||
// createDemoMessage creates a sample support reply message
|
||||
func createDemoMessage() *smsg.Message {
|
||||
return smsg.NewMessage(`Hello,
|
||||
|
||||
Thank you for contacting our support team. We have reviewed your request and are pleased to provide the following update.
|
||||
|
||||
Your account has been verified and all services are now active. If you have any further questions, please don't hesitate to reach out.
|
||||
|
||||
Best regards,
|
||||
The Support Team`).
|
||||
WithSubject("Re: Your Support Request #" + fmt.Sprintf("%d", time.Now().Unix()%100000)).
|
||||
WithFrom("support@example.com").
|
||||
SetMeta("ticket_id", fmt.Sprintf("%d", time.Now().Unix()%100000)).
|
||||
SetMeta("priority", "normal")
|
||||
}
|
||||
|
||||
// addAttachment reads a file and adds it as an attachment
|
||||
func addAttachment(msg *smsg.Message, filePath string) error {
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading file: %w", err)
|
||||
}
|
||||
|
||||
name := filepath.Base(filePath)
|
||||
content := base64.StdEncoding.EncodeToString(data)
|
||||
mimeType := detectMimeType(filePath)
|
||||
|
||||
msg.AddAttachment(name, content, mimeType)
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectMimeType returns a basic mime type based on file extension
|
||||
func detectMimeType(path string) string {
|
||||
ext := filepath.Ext(path)
|
||||
switch ext {
|
||||
case ".txt":
|
||||
return "text/plain"
|
||||
case ".html", ".htm":
|
||||
return "text/html"
|
||||
case ".css":
|
||||
return "text/css"
|
||||
case ".js":
|
||||
return "application/javascript"
|
||||
case ".json":
|
||||
return "application/json"
|
||||
case ".xml":
|
||||
return "application/xml"
|
||||
case ".pdf":
|
||||
return "application/pdf"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".svg":
|
||||
return "image/svg+xml"
|
||||
case ".zip":
|
||||
return "application/zip"
|
||||
case ".tar":
|
||||
return "application/x-tar"
|
||||
case ".gz":
|
||||
return "application/gzip"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
2
go.mod
2
go.mod
|
|
@ -3,7 +3,7 @@ module github.com/Snider/Borg
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600
|
||||
github.com/Snider/Enchantrix v0.0.2
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/go-git/go-git/v5 v5.16.3
|
||||
github.com/google/go-github/v39 v39.2.0
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -7,6 +7,8 @@ github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBi
|
|||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600 h1:9jyEgos5SNTVp3aJkhPs/fb4eTZE5l73YqaT+vFmFu0=
|
||||
github.com/Snider/Enchantrix v0.0.0-20251113213145-deff3a80c600/go.mod h1:v9HATMgLJWycy/R5ho1SL0OHbggXgEhu/qRB9gbS0BM=
|
||||
github.com/Snider/Enchantrix v0.0.2 h1:ExZQiBhfS/p/AHFTKhY80TOd+BXZjK95EzByAEgwvjs=
|
||||
github.com/Snider/Enchantrix v0.0.2/go.mod h1:CtFcLAvnDT1KcuF1JBb/DJj0KplY8jHryO06KzQ1hsQ=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
|
|
|
|||
1
go.work
1
go.work
|
|
@ -2,5 +2,4 @@ go 1.25.0
|
|||
|
||||
use (
|
||||
.
|
||||
../Enchantrix
|
||||
)
|
||||
|
|
|
|||
106
js/borg-stmf/README.md
Normal file
106
js/borg-stmf/README.md
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# @borg/stmf
|
||||
|
||||
Sovereign Form Encryption - Client-side form encryption using X25519 + ChaCha20-Poly1305.
|
||||
|
||||
## Overview
|
||||
|
||||
BorgSTMF encrypts HTML form data in the browser before submission, using the server's public key. Even if a MITM proxy intercepts the request, they only see encrypted data.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @borg/stmf
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```html
|
||||
<!-- Load the WASM support -->
|
||||
<script src="wasm_exec.js"></script>
|
||||
|
||||
<!-- Your form -->
|
||||
<form id="login" action="/api/login" method="POST" data-stmf="YOUR_PUBLIC_KEY_BASE64">
|
||||
<input name="email" type="email" required>
|
||||
<input name="password" type="password" required>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
|
||||
<script type="module">
|
||||
import { BorgSTMF } from '@borg/stmf';
|
||||
|
||||
const borg = new BorgSTMF({
|
||||
serverPublicKey: 'YOUR_PUBLIC_KEY_BASE64',
|
||||
wasmPath: '/wasm/stmf.wasm'
|
||||
});
|
||||
|
||||
await borg.init();
|
||||
borg.enableInterceptor();
|
||||
</script>
|
||||
```
|
||||
|
||||
## Manual Encryption
|
||||
|
||||
```typescript
|
||||
import { BorgSTMF } from '@borg/stmf';
|
||||
|
||||
const borg = new BorgSTMF({
|
||||
serverPublicKey: 'YOUR_PUBLIC_KEY_BASE64'
|
||||
});
|
||||
|
||||
await borg.init();
|
||||
|
||||
// Encrypt form element
|
||||
const form = document.querySelector('form');
|
||||
const result = await borg.encryptForm(form);
|
||||
console.log(result.payload); // Base64 encrypted STMF
|
||||
|
||||
// Or encrypt key-value pairs directly
|
||||
const result = await borg.encryptFields({
|
||||
email: 'user@example.com',
|
||||
password: 'secret'
|
||||
});
|
||||
```
|
||||
|
||||
## Server-Side Decryption
|
||||
|
||||
### Go Middleware
|
||||
|
||||
```go
|
||||
import "github.com/Snider/Borg/pkg/stmf/middleware"
|
||||
|
||||
privateKey := os.Getenv("STMF_PRIVATE_KEY")
|
||||
handler := middleware.Simple(privateKeyBytes)(yourHandler)
|
||||
|
||||
// In your handler, form values are automatically decrypted:
|
||||
email := r.FormValue("email")
|
||||
```
|
||||
|
||||
### PHP
|
||||
|
||||
```php
|
||||
use Borg\STMF\STMF;
|
||||
|
||||
$stmf = new STMF($privateKeyBase64);
|
||||
$formData = $stmf->decrypt($_POST['_stmf_payload']);
|
||||
|
||||
$email = $formData->get('email');
|
||||
```
|
||||
|
||||
## Key Generation
|
||||
|
||||
Generate a keypair for your server:
|
||||
|
||||
```go
|
||||
import "github.com/Snider/Borg/pkg/stmf"
|
||||
|
||||
kp, _ := stmf.GenerateKeyPair()
|
||||
fmt.Println("Public key:", kp.PublicKeyBase64()) // Share this
|
||||
fmt.Println("Private key:", kp.PrivateKeyBase64()) // Keep secret!
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- **Hybrid encryption**: X25519 ECDH key exchange + ChaCha20-Poly1305
|
||||
- **Forward secrecy**: Each form submission uses a new ephemeral keypair
|
||||
- **Authenticated encryption**: Data integrity is verified on decryption
|
||||
- **No passwords transmitted**: Only the public key is in the HTML
|
||||
155
js/borg-stmf/demo.html
Normal file
155
js/borg-stmf/demo.html
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>STMF Demo - Sovereign Form Encryption</title>
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
|
||||
.card { border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; margin: 1rem 0; }
|
||||
input, button { padding: 0.5rem; margin: 0.25rem 0; }
|
||||
input { width: 100%; box-sizing: border-box; }
|
||||
button { background: #4CAF50; color: white; border: none; cursor: pointer; border-radius: 4px; }
|
||||
button:hover { background: #45a049; }
|
||||
pre { background: #f5f5f5; padding: 1rem; overflow-x: auto; border-radius: 4px; font-size: 12px; }
|
||||
.status { padding: 0.5rem; border-radius: 4px; margin: 0.5rem 0; }
|
||||
.status.loading { background: #fff3cd; }
|
||||
.status.ready { background: #d4edda; }
|
||||
.status.error { background: #f8d7da; }
|
||||
label { display: block; margin-top: 1rem; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>STMF Demo</h1>
|
||||
<p>Sovereign Form Encryption using X25519 + ChaCha20-Poly1305</p>
|
||||
|
||||
<div id="status" class="status loading">Loading WASM module...</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>1. Generate Server Keypair</h2>
|
||||
<p>In production, this is done server-side and the private key is kept secret.</p>
|
||||
<button onclick="generateKeys()">Generate Keypair</button>
|
||||
<label>Public Key (share with clients):</label>
|
||||
<input type="text" id="publicKey" readonly placeholder="Click generate...">
|
||||
<label>Private Key (keep secret!):</label>
|
||||
<input type="text" id="privateKey" readonly placeholder="Click generate...">
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>2. Encrypt Form Data</h2>
|
||||
<form id="demoForm">
|
||||
<label>Email:</label>
|
||||
<input type="email" name="email" value="user@example.com" required>
|
||||
|
||||
<label>Password:</label>
|
||||
<input type="password" name="password" value="supersecret123" required>
|
||||
|
||||
<label>Message:</label>
|
||||
<input type="text" name="message" value="Hello, encrypted world!">
|
||||
|
||||
<button type="submit" style="margin-top: 1rem; width: 100%;">Encrypt Form</button>
|
||||
</form>
|
||||
|
||||
<label>Encrypted Payload (base64):</label>
|
||||
<pre id="encrypted" style="word-break: break-all;">Submit the form to see encrypted output...</pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>3. Payload Info</h2>
|
||||
<p>This information can be read without decrypting (metadata is in the header):</p>
|
||||
<pre id="info">Submit the form to see payload info...</pre>
|
||||
</div>
|
||||
|
||||
<script src="dist/wasm_exec.js"></script>
|
||||
<script>
|
||||
let wasmReady = false;
|
||||
|
||||
// Load WASM
|
||||
async function loadWasm() {
|
||||
const go = new Go();
|
||||
const result = await WebAssembly.instantiateStreaming(
|
||||
fetch('dist/stmf.wasm'),
|
||||
go.importObject
|
||||
);
|
||||
go.run(result.instance);
|
||||
|
||||
// Wait for BorgSTMF to be ready
|
||||
while (!window.BorgSTMF?.ready) {
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
wasmReady = true;
|
||||
document.getElementById('status').className = 'status ready';
|
||||
document.getElementById('status').textContent =
|
||||
`WASM loaded! Version: ${window.BorgSTMF.version}`;
|
||||
}
|
||||
|
||||
loadWasm().catch(err => {
|
||||
document.getElementById('status').className = 'status error';
|
||||
document.getElementById('status').textContent = `Error: ${err.message}`;
|
||||
});
|
||||
|
||||
// Generate keypair
|
||||
async function generateKeys() {
|
||||
if (!wasmReady) {
|
||||
alert('WASM not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const keypair = await BorgSTMF.generateKeyPair();
|
||||
document.getElementById('publicKey').value = keypair.publicKey;
|
||||
document.getElementById('privateKey').value = keypair.privateKey;
|
||||
} catch (err) {
|
||||
alert('Error: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('demoForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!wasmReady) {
|
||||
alert('WASM not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey = document.getElementById('publicKey').value;
|
||||
if (!publicKey) {
|
||||
alert('Generate a keypair first!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get form data
|
||||
const formData = new FormData(e.target);
|
||||
const fields = {};
|
||||
formData.forEach((value, key) => {
|
||||
fields[key] = value;
|
||||
});
|
||||
|
||||
// Encrypt
|
||||
const encrypted = await BorgSTMF.encryptFields(
|
||||
fields,
|
||||
publicKey,
|
||||
{ origin: window.location.origin, timestamp: Date.now().toString() }
|
||||
);
|
||||
|
||||
document.getElementById('encrypted').textContent = encrypted;
|
||||
|
||||
// Show info
|
||||
document.getElementById('info').textContent = JSON.stringify({
|
||||
payloadLength: encrypted.length,
|
||||
payloadSizeKB: (encrypted.length * 0.75 / 1024).toFixed(2) + ' KB',
|
||||
fieldsEncrypted: Object.keys(fields),
|
||||
note: 'Each encryption produces different output (ephemeral keys)'
|
||||
}, null, 2);
|
||||
|
||||
} catch (err) {
|
||||
alert('Encryption error: ' + err.message);
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
js/borg-stmf/dist/stmf.wasm
vendored
Executable file
BIN
js/borg-stmf/dist/stmf.wasm
vendored
Executable file
Binary file not shown.
575
js/borg-stmf/dist/wasm_exec.js
vendored
Normal file
575
js/borg-stmf/dist/wasm_exec.js
vendored
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
554
js/borg-stmf/index.html
Normal file
554
js/borg-stmf/index.html
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>STMF - Sovereign Form Encryption</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.8rem;
|
||||
background: linear-gradient(90deg, #00d9ff, #00ff94);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card h2 .icon {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.card p.description {
|
||||
font-size: 0.85rem;
|
||||
color: #888;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #aaa;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
textarea, input[type="text"], input[type="email"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
color: #fff;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
textarea:focus, input:focus {
|
||||
outline: none;
|
||||
border-color: #00d9ff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
input[readonly] {
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: #00ff94;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.8rem 2rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff94 100%);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 217, 255, 0.4);
|
||||
}
|
||||
|
||||
button.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
button.full-width {
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.key-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.key-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.status-indicator .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-indicator.loading .dot {
|
||||
background: #ffc107;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.ready .dot {
|
||||
background: #00ff94;
|
||||
}
|
||||
|
||||
.status-indicator.error .dot {
|
||||
background: #ff5252;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
pre {
|
||||
background: rgba(0,0,0,0.4);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
color: #00ff94;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
background: rgba(0,0,0,0.2);
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #00d9ff;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: #00d9ff;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 217, 255, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
background: rgba(0, 217, 255, 0.2);
|
||||
}
|
||||
|
||||
.nav-links a.active {
|
||||
background: rgba(0, 217, 255, 0.3);
|
||||
}
|
||||
|
||||
.warning-banner {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
.success-banner {
|
||||
background: rgba(0, 255, 148, 0.1);
|
||||
border: 1px solid rgba(0, 255, 148, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin-top: 1rem;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #00ff94;
|
||||
}
|
||||
|
||||
.success-banner.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Sovereign Form Encryption</h1>
|
||||
<p class="subtitle">X25519 ECDH + ChaCha20-Poly1305 client-side encryption</p>
|
||||
|
||||
<nav class="nav-links">
|
||||
<a href="index.html" class="active">Form Encryption</a>
|
||||
<a href="support-reply.html">Decrypt Messages</a>
|
||||
</nav>
|
||||
|
||||
<div id="wasm-status" class="status-indicator loading">
|
||||
<span class="dot"></span>
|
||||
<span>Loading encryption module...</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><span class="icon">🔑</span> Server Keypair</h2>
|
||||
<p class="description">In production, generate this server-side and keep the private key secret. Only the public key is shared with clients.</p>
|
||||
|
||||
<button id="generate-btn" class="secondary" disabled>Generate New Keypair</button>
|
||||
|
||||
<div class="key-row" style="margin-top: 1rem;">
|
||||
<div class="input-group">
|
||||
<label>Public Key (share with clients)</label>
|
||||
<input type="text" id="publicKey" readonly placeholder="Click generate...">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Private Key (keep secret!)</label>
|
||||
<input type="text" id="privateKey" readonly placeholder="Click generate...">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><span class="icon">📝</span> Encrypt Form Data</h2>
|
||||
<p class="description">Enter form fields to encrypt. Data is encrypted client-side before transmission.</p>
|
||||
|
||||
<div id="no-key-warning" class="warning-banner">
|
||||
<span>⚠️</span>
|
||||
<span>Generate a keypair first to enable encryption</span>
|
||||
</div>
|
||||
|
||||
<form id="demoForm">
|
||||
<div class="form-row">
|
||||
<div class="input-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" id="email" name="email" value="user@example.com" required>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="form-password" name="password" value="supersecret123" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="message">Message</label>
|
||||
<input type="text" id="message" name="message" value="Hello, encrypted world!">
|
||||
</div>
|
||||
|
||||
<button type="submit" id="encrypt-btn" class="primary full-width" disabled>Encrypt Form Data</button>
|
||||
</form>
|
||||
|
||||
<div id="success-banner" class="success-banner">
|
||||
<span>✅</span>
|
||||
<span>Form encrypted successfully!</span>
|
||||
<button class="secondary copy-btn" id="copy-btn">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" id="output-card" style="display: none;">
|
||||
<h2><span class="icon">🔒</span> Encrypted Output</h2>
|
||||
<p class="description">This base64 payload can be safely transmitted. Only the server with the private key can decrypt it.</p>
|
||||
|
||||
<pre id="encrypted"></pre>
|
||||
|
||||
<div class="info-grid">
|
||||
<div class="info-item">
|
||||
<div class="value" id="payload-size">-</div>
|
||||
<div class="label">Payload Size</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="value" id="fields-count">-</div>
|
||||
<div class="label">Fields Encrypted</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="value" id="algo-type">X25519</div>
|
||||
<div class="label">Key Exchange</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="value" id="cipher-type">ChaCha20</div>
|
||||
<div class="label">Cipher</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><span class="icon">ℹ️</span> How It Works</h2>
|
||||
<p class="description" style="margin-bottom: 0; line-height: 1.7;">
|
||||
<strong>1. Key Exchange:</strong> An ephemeral X25519 keypair is generated for each encryption.<br>
|
||||
<strong>2. Shared Secret:</strong> ECDH derives a shared secret using the ephemeral private key and server's public key.<br>
|
||||
<strong>3. Encryption:</strong> Form data is encrypted with ChaCha20-Poly1305 using the derived key.<br>
|
||||
<strong>4. Payload:</strong> The ephemeral public key is included in the header so the server can decrypt.<br><br>
|
||||
Each encryption produces a unique output even for the same data, ensuring forward secrecy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="wasm_exec.js"></script>
|
||||
<script>
|
||||
let wasmReady = false;
|
||||
|
||||
// Update status indicator safely
|
||||
function updateStatus(el, status, message) {
|
||||
el.className = 'status-indicator ' + status;
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'dot';
|
||||
const text = document.createElement('span');
|
||||
text.textContent = message;
|
||||
el.appendChild(dot);
|
||||
el.appendChild(text);
|
||||
}
|
||||
|
||||
// Initialize WASM
|
||||
async function initWasm() {
|
||||
const statusEl = document.getElementById('wasm-status');
|
||||
|
||||
try {
|
||||
const go = new Go();
|
||||
const result = await WebAssembly.instantiateStreaming(
|
||||
fetch('stmf.wasm'),
|
||||
go.importObject
|
||||
);
|
||||
go.run(result.instance);
|
||||
|
||||
// Wait for BorgSTMF to be ready
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('WASM init timeout')), 5000);
|
||||
|
||||
if (typeof BorgSTMF !== 'undefined' && BorgSTMF.ready) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('borgstmf:ready', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
wasmReady = true;
|
||||
updateStatus(statusEl, 'ready', 'Encryption module ready (v' + BorgSTMF.version + ')');
|
||||
document.getElementById('generate-btn').disabled = false;
|
||||
|
||||
} catch (err) {
|
||||
updateStatus(statusEl, 'error', 'Failed to load: ' + err.message);
|
||||
console.error('WASM init error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate keypair
|
||||
async function generateKeys() {
|
||||
if (!wasmReady) return;
|
||||
|
||||
try {
|
||||
const keypair = await BorgSTMF.generateKeyPair();
|
||||
document.getElementById('publicKey').value = keypair.publicKey;
|
||||
document.getElementById('privateKey').value = keypair.privateKey;
|
||||
|
||||
// Enable encryption
|
||||
document.getElementById('encrypt-btn').disabled = false;
|
||||
document.getElementById('no-key-warning').style.display = 'none';
|
||||
} catch (err) {
|
||||
alert('Error generating keys: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission
|
||||
async function handleFormSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!wasmReady) {
|
||||
alert('WASM not loaded yet');
|
||||
return;
|
||||
}
|
||||
|
||||
const publicKey = document.getElementById('publicKey').value;
|
||||
if (!publicKey) {
|
||||
alert('Generate a keypair first!');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get form data
|
||||
const formData = new FormData(e.target);
|
||||
const fields = {};
|
||||
formData.forEach((value, key) => {
|
||||
fields[key] = value;
|
||||
});
|
||||
|
||||
// Encrypt
|
||||
const encrypted = await BorgSTMF.encryptFields(
|
||||
fields,
|
||||
publicKey,
|
||||
{ origin: window.location.origin, timestamp: Date.now().toString() }
|
||||
);
|
||||
|
||||
// Show output
|
||||
document.getElementById('encrypted').textContent = encrypted;
|
||||
document.getElementById('output-card').style.display = 'block';
|
||||
document.getElementById('success-banner').classList.add('visible');
|
||||
|
||||
// Update stats
|
||||
const sizeKB = (encrypted.length * 0.75 / 1024).toFixed(2);
|
||||
document.getElementById('payload-size').textContent = sizeKB + ' KB';
|
||||
document.getElementById('fields-count').textContent = Object.keys(fields).length;
|
||||
|
||||
// Scroll to output
|
||||
document.getElementById('output-card').scrollIntoView({ behavior: 'smooth' });
|
||||
|
||||
} catch (err) {
|
||||
alert('Encryption error: ' + err.message);
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy to clipboard
|
||||
async function copyToClipboard() {
|
||||
const encrypted = document.getElementById('encrypted').textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(encrypted);
|
||||
const btn = document.getElementById('copy-btn');
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => btn.textContent = 'Copy', 2000);
|
||||
} catch (err) {
|
||||
alert('Failed to copy: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('generate-btn').addEventListener('click', generateKeys);
|
||||
document.getElementById('demoForm').addEventListener('submit', handleFormSubmit);
|
||||
document.getElementById('copy-btn').addEventListener('click', copyToClipboard);
|
||||
|
||||
// Initialize
|
||||
initWasm();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
36
js/borg-stmf/package.json
Normal file
36
js/borg-stmf/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "@borg/stmf",
|
||||
"version": "1.0.0",
|
||||
"description": "Sovereign Form Encryption - Client-side form encryption using X25519 + ChaCha20-Poly1305",
|
||||
"main": "dist/borg-stmf.js",
|
||||
"module": "dist/borg-stmf.esm.js",
|
||||
"types": "dist/borg-stmf.d.ts",
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "rollup -c",
|
||||
"dev": "rollup -c -w",
|
||||
"prepublishOnly": "npm run build"
|
||||
},
|
||||
"keywords": [
|
||||
"encryption",
|
||||
"form",
|
||||
"security",
|
||||
"chacha20",
|
||||
"x25519",
|
||||
"wasm",
|
||||
"privacy"
|
||||
],
|
||||
"author": "Snider",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Snider/Borg"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-typescript": "^11.1.0",
|
||||
"rollup": "^4.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
345
js/borg-stmf/src/index.ts
Normal file
345
js/borg-stmf/src/index.ts
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
import type {
|
||||
BorgSTMFConfig,
|
||||
FormData,
|
||||
FormField,
|
||||
EncryptResult,
|
||||
KeyPair,
|
||||
InterceptorOptions,
|
||||
BorgSTMFWasm,
|
||||
} from './types';
|
||||
|
||||
export * from './types';
|
||||
|
||||
const DEFAULT_FIELD_NAME = '_stmf_payload';
|
||||
const DEFAULT_WASM_PATH = './stmf.wasm';
|
||||
|
||||
/**
|
||||
* BorgSTMF - Sovereign Form Encryption
|
||||
*
|
||||
* Encrypts HTML form data client-side using the server's public key.
|
||||
* Data is encrypted with X25519 ECDH + ChaCha20-Poly1305, providing
|
||||
* end-to-end encryption even against MITM proxies.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const borg = new BorgSTMF({
|
||||
* serverPublicKey: 'base64PublicKeyHere',
|
||||
* wasmPath: '/wasm/stmf.wasm'
|
||||
* });
|
||||
*
|
||||
* await borg.init();
|
||||
*
|
||||
* // Manual encryption
|
||||
* const result = await borg.encryptForm(document.querySelector('form'));
|
||||
*
|
||||
* // Or use interceptor
|
||||
* borg.enableInterceptor();
|
||||
* ```
|
||||
*/
|
||||
export class BorgSTMF {
|
||||
private config: Required<BorgSTMFConfig>;
|
||||
private wasm: BorgSTMFWasm | null = null;
|
||||
private initialized = false;
|
||||
private interceptorActive = false;
|
||||
private interceptorHandler: ((e: Event) => void) | null = null;
|
||||
|
||||
constructor(config: BorgSTMFConfig) {
|
||||
this.config = {
|
||||
serverPublicKey: config.serverPublicKey,
|
||||
wasmPath: config.wasmPath || DEFAULT_WASM_PATH,
|
||||
fieldName: config.fieldName || DEFAULT_FIELD_NAME,
|
||||
debug: config.debug || false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the WASM module. Must be called before encryption.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
// Check if WASM is already loaded (e.g., from a script tag)
|
||||
if (window.BorgSTMF?.ready) {
|
||||
this.wasm = window.BorgSTMF;
|
||||
this.initialized = true;
|
||||
this.log('Using pre-loaded WASM module');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load wasm_exec.js if not already loaded
|
||||
if (typeof Go === 'undefined') {
|
||||
await this.loadScript(this.config.wasmPath.replace('stmf.wasm', 'wasm_exec.js'));
|
||||
}
|
||||
|
||||
// Load and instantiate the WASM module
|
||||
const go = new Go();
|
||||
const result = await WebAssembly.instantiateStreaming(
|
||||
fetch(this.config.wasmPath),
|
||||
go.importObject
|
||||
);
|
||||
|
||||
// Run the Go main function
|
||||
go.run(result.instance);
|
||||
|
||||
// Wait for WASM to be ready
|
||||
await this.waitForWasm();
|
||||
|
||||
this.wasm = window.BorgSTMF!;
|
||||
this.initialized = true;
|
||||
this.log('WASM module initialized, version:', this.wasm.version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an HTML form element
|
||||
*/
|
||||
async encryptForm(form: HTMLFormElement): Promise<EncryptResult> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const formData = new window.FormData(form);
|
||||
return this.encryptFormData(formData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a FormData object
|
||||
*/
|
||||
async encryptFormData(formData: globalThis.FormData): Promise<EncryptResult> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const fields: Record<string, string | FormField> = {};
|
||||
|
||||
formData.forEach((value, key) => {
|
||||
if (value instanceof File) {
|
||||
// Handle file uploads - read as base64
|
||||
// Note: For large files, consider chunking or streaming
|
||||
this.log('File field detected:', key, value.name);
|
||||
// For now, skip files - they need async reading
|
||||
// TODO: Add file support with FileReader
|
||||
} else {
|
||||
fields[key] = value.toString();
|
||||
}
|
||||
});
|
||||
|
||||
const payload = await this.wasm!.encryptFields(
|
||||
fields,
|
||||
this.config.serverPublicKey,
|
||||
{
|
||||
origin: window.location.origin,
|
||||
timestamp: Date.now().toString(),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
payload,
|
||||
fieldName: this.config.fieldName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a simple key-value object
|
||||
*/
|
||||
async encryptFields(
|
||||
fields: Record<string, string>,
|
||||
metadata?: Record<string, string>
|
||||
): Promise<EncryptResult> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const meta = {
|
||||
origin: window.location.origin,
|
||||
timestamp: Date.now().toString(),
|
||||
...metadata,
|
||||
};
|
||||
|
||||
const payload = await this.wasm!.encryptFields(
|
||||
fields,
|
||||
this.config.serverPublicKey,
|
||||
meta
|
||||
);
|
||||
|
||||
return {
|
||||
payload,
|
||||
fieldName: this.config.fieldName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a full FormData structure
|
||||
*/
|
||||
async encryptFormDataStruct(data: FormData): Promise<EncryptResult> {
|
||||
this.ensureInitialized();
|
||||
|
||||
const payload = await this.wasm!.encrypt(
|
||||
JSON.stringify(data),
|
||||
this.config.serverPublicKey
|
||||
);
|
||||
|
||||
return {
|
||||
payload,
|
||||
fieldName: this.config.fieldName,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new keypair (for testing/development only)
|
||||
*/
|
||||
async generateKeyPair(): Promise<KeyPair> {
|
||||
this.ensureInitialized();
|
||||
return this.wasm!.generateKeyPair();
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable automatic form interception.
|
||||
* Intercepts submit events on forms with the data-stmf attribute.
|
||||
*/
|
||||
enableInterceptor(options: InterceptorOptions = {}): void {
|
||||
if (this.interceptorActive) return;
|
||||
|
||||
const { autoSubmit = true } = options;
|
||||
|
||||
this.interceptorHandler = async (e: Event) => {
|
||||
const form = e.target as HTMLFormElement;
|
||||
|
||||
// Check if this form should be intercepted
|
||||
const publicKey = form.dataset.stmf;
|
||||
if (!publicKey && !options.selector) return;
|
||||
if (options.selector && !form.matches(options.selector)) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
try {
|
||||
// Use form's public key or default config
|
||||
const serverKey = publicKey || this.config.serverPublicKey;
|
||||
|
||||
// Callback before encryption
|
||||
if (options.onBeforeEncrypt) {
|
||||
const proceed = await options.onBeforeEncrypt(form);
|
||||
if (proceed === false) return;
|
||||
}
|
||||
|
||||
// Encrypt the form
|
||||
const originalFormData = new window.FormData(form);
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
originalFormData.forEach((value, key) => {
|
||||
if (!(value instanceof File)) {
|
||||
fields[key] = value.toString();
|
||||
}
|
||||
});
|
||||
|
||||
const payload = await this.wasm!.encryptFields(
|
||||
fields,
|
||||
serverKey,
|
||||
{
|
||||
origin: window.location.origin,
|
||||
timestamp: Date.now().toString(),
|
||||
formId: form.id || undefined,
|
||||
}
|
||||
);
|
||||
|
||||
// Callback after encryption
|
||||
if (options.onAfterEncrypt) {
|
||||
options.onAfterEncrypt(form, payload);
|
||||
}
|
||||
|
||||
if (autoSubmit) {
|
||||
// Create new form data with only the encrypted payload
|
||||
const encryptedFormData = new window.FormData();
|
||||
encryptedFormData.append(this.config.fieldName, payload);
|
||||
|
||||
// Submit via fetch
|
||||
const response = await fetch(form.action || window.location.href, {
|
||||
method: form.method || 'POST',
|
||||
body: encryptedFormData,
|
||||
});
|
||||
|
||||
// Handle response - trigger custom event
|
||||
const event = new CustomEvent('borgstmf:submitted', {
|
||||
detail: { form, response, payload },
|
||||
});
|
||||
form.dispatchEvent(event);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log('Encryption error:', error);
|
||||
if (options.onError) {
|
||||
options.onError(form, error as Error);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('submit', this.interceptorHandler, true);
|
||||
this.interceptorActive = true;
|
||||
this.log('Form interceptor enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable automatic form interception
|
||||
*/
|
||||
disableInterceptor(): void {
|
||||
if (!this.interceptorActive || !this.interceptorHandler) return;
|
||||
|
||||
document.removeEventListener('submit', this.interceptorHandler, true);
|
||||
this.interceptorHandler = null;
|
||||
this.interceptorActive = false;
|
||||
this.log('Form interceptor disabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the module is initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WASM module version
|
||||
*/
|
||||
getVersion(): string {
|
||||
return this.wasm?.version || 'not loaded';
|
||||
}
|
||||
|
||||
private ensureInitialized(): void {
|
||||
if (!this.initialized || !this.wasm) {
|
||||
throw new Error('BorgSTMF not initialized. Call init() first.');
|
||||
}
|
||||
}
|
||||
|
||||
private async waitForWasm(timeout = 5000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!window.BorgSTMF?.ready) {
|
||||
if (Date.now() - start > timeout) {
|
||||
throw new Error('Timeout waiting for WASM module to initialize');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
|
||||
private async loadScript(src: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement('script');
|
||||
script.src = src;
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
private log(...args: unknown[]): void {
|
||||
if (this.config.debug) {
|
||||
console.log('[BorgSTMF]', ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export a factory function for convenience
|
||||
export function createBorgSTMF(config: BorgSTMFConfig): BorgSTMF {
|
||||
return new BorgSTMF(config);
|
||||
}
|
||||
|
||||
// Export types for the Go interface
|
||||
declare class Go {
|
||||
constructor();
|
||||
importObject: WebAssembly.Imports;
|
||||
run(instance: WebAssembly.Instance): Promise<void>;
|
||||
}
|
||||
121
js/borg-stmf/src/types.ts
Normal file
121
js/borg-stmf/src/types.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
/**
|
||||
* Configuration options for BorgSTMF
|
||||
*/
|
||||
export interface BorgSTMFConfig {
|
||||
/**
|
||||
* Base64-encoded X25519 public key of the server.
|
||||
* Form data will be encrypted using this key.
|
||||
*/
|
||||
serverPublicKey: string;
|
||||
|
||||
/**
|
||||
* Path to the WASM file.
|
||||
* @default './stmf.wasm'
|
||||
*/
|
||||
wasmPath?: string;
|
||||
|
||||
/**
|
||||
* Name of the form field that will contain the encrypted payload.
|
||||
* @default '_stmf_payload'
|
||||
*/
|
||||
fieldName?: string;
|
||||
|
||||
/**
|
||||
* Enable debug logging.
|
||||
* @default false
|
||||
*/
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form field definition
|
||||
*/
|
||||
export interface FormField {
|
||||
name: string;
|
||||
value: string;
|
||||
type?: string;
|
||||
filename?: string;
|
||||
mime?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Form data structure for encryption
|
||||
*/
|
||||
export interface FormData {
|
||||
fields: FormField[];
|
||||
meta?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of encrypting form data
|
||||
*/
|
||||
export interface EncryptResult {
|
||||
/** Base64-encoded encrypted STMF payload */
|
||||
payload: string;
|
||||
/** Name of the form field for the payload */
|
||||
fieldName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* X25519 keypair (for testing/development)
|
||||
*/
|
||||
export interface KeyPair {
|
||||
/** Base64-encoded public key */
|
||||
publicKey: string;
|
||||
/** Base64-encoded private key (keep secret!) */
|
||||
privateKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for the form interceptor
|
||||
*/
|
||||
export interface InterceptorOptions {
|
||||
/**
|
||||
* CSS selector for forms to intercept.
|
||||
* If not specified, intercepts forms with data-stmf attribute.
|
||||
*/
|
||||
selector?: string;
|
||||
|
||||
/**
|
||||
* Callback before encryption.
|
||||
* Return false to cancel encryption.
|
||||
*/
|
||||
onBeforeEncrypt?: (form: HTMLFormElement) => boolean | Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Callback after encryption.
|
||||
*/
|
||||
onAfterEncrypt?: (form: HTMLFormElement, payload: string) => void;
|
||||
|
||||
/**
|
||||
* Callback on encryption error.
|
||||
*/
|
||||
onError?: (form: HTMLFormElement, error: Error) => void;
|
||||
|
||||
/**
|
||||
* Whether to submit the form automatically after encryption.
|
||||
* @default true
|
||||
*/
|
||||
autoSubmit?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* BorgSTMF WASM module interface
|
||||
*/
|
||||
export interface BorgSTMFWasm {
|
||||
encrypt: (formDataJSON: string, serverPublicKey: string) => Promise<string>;
|
||||
encryptFields: (
|
||||
fields: Record<string, string | FormField>,
|
||||
serverPublicKey: string,
|
||||
metadata?: Record<string, string>
|
||||
) => Promise<string>;
|
||||
generateKeyPair: () => Promise<KeyPair>;
|
||||
version: string;
|
||||
ready: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
BorgSTMF?: BorgSTMFWasm;
|
||||
}
|
||||
}
|
||||
BIN
js/borg-stmf/stmf.wasm
Executable file
BIN
js/borg-stmf/stmf.wasm
Executable file
Binary file not shown.
797
js/borg-stmf/support-reply.html
Normal file
797
js/borg-stmf/support-reply.html
Normal file
|
|
@ -0,0 +1,797 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Decrypt Secure Support Reply</title>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
min-height: 100vh;
|
||||
padding: 2rem;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.8rem;
|
||||
background: linear-gradient(90deg, #00d9ff, #00ff94);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
color: #888;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 16px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.card h2 .icon {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #aaa;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
textarea, input[type="password"], input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 0.8rem 1rem;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,0.3);
|
||||
color: #fff;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
textarea:focus, input:focus {
|
||||
outline: none;
|
||||
border-color: #00d9ff;
|
||||
box-shadow: 0 0 0 3px rgba(0, 217, 255, 0.1);
|
||||
}
|
||||
|
||||
textarea.encrypted {
|
||||
min-height: 120px;
|
||||
font-size: 0.75rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.password-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.password-row .input-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.8rem 2rem;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: linear-gradient(135deg, #00d9ff 0%, #00ff94 100%);
|
||||
color: #000;
|
||||
}
|
||||
|
||||
button.primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 217, 255, 0.4);
|
||||
}
|
||||
|
||||
button.primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
button.secondary:hover {
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.hint-banner {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 0.8rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hint-banner.visible {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hint-banner .hint-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.hint-banner .hint-text {
|
||||
color: #ffc107;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-container.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.message-from {
|
||||
font-weight: 600;
|
||||
color: #00d9ff;
|
||||
}
|
||||
|
||||
.message-date {
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.message-subject {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.attachments {
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.attachments h3 {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8rem;
|
||||
padding: 0.6rem 1rem;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.attachment-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.attachment-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.attachment-download {
|
||||
padding: 0.4rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.reply-key-banner {
|
||||
background: rgba(0, 217, 255, 0.1);
|
||||
border: 1px solid rgba(0, 217, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reply-key-banner.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reply-key-banner h4 {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.reply-key-banner p {
|
||||
font-size: 0.8rem;
|
||||
color: #aaa;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.reply-key-value {
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.7rem;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.error-banner {
|
||||
background: rgba(255, 82, 82, 0.1);
|
||||
border: 1px solid rgba(255, 82, 82, 0.3);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
display: none;
|
||||
color: #ff5252;
|
||||
}
|
||||
|
||||
.error-banner.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.status-indicator .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-indicator.loading .dot {
|
||||
background: #ffc107;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.ready .dot {
|
||||
background: #00ff94;
|
||||
}
|
||||
|
||||
.status-indicator.error .dot {
|
||||
background: #ff5252;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.demo-section {
|
||||
border-top: 1px dashed rgba(255,255,255,0.1);
|
||||
padding-top: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.demo-section h3 {
|
||||
font-size: 0.9rem;
|
||||
color: #888;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.example-messages {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.example-messages button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: #00d9ff;
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 20px;
|
||||
background: rgba(0, 217, 255, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
background: rgba(0, 217, 255, 0.2);
|
||||
}
|
||||
|
||||
.nav-links a.active {
|
||||
background: rgba(0, 217, 255, 0.3);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Secure Support Reply</h1>
|
||||
<p class="subtitle">Decrypt password-protected messages from support</p>
|
||||
|
||||
<nav class="nav-links">
|
||||
<a href="index.html">Form Encryption</a>
|
||||
<a href="support-reply.html" class="active">Decrypt Messages</a>
|
||||
</nav>
|
||||
|
||||
<div id="wasm-status" class="status-indicator loading">
|
||||
<span class="dot"></span>
|
||||
<span>Loading encryption module...</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2><span class="icon">📨</span> Encrypted Message</h2>
|
||||
|
||||
<div class="input-group">
|
||||
<label for="encrypted-message">Paste the encrypted message you received:</label>
|
||||
<textarea id="encrypted-message" class="encrypted" placeholder="U01TRy4uLg=="></textarea>
|
||||
</div>
|
||||
|
||||
<div id="hint-banner" class="hint-banner">
|
||||
<span class="hint-icon">💡</span>
|
||||
<span class="hint-text">Password hint: <strong id="hint-text"></strong></span>
|
||||
</div>
|
||||
|
||||
<div id="error-banner" class="error-banner"></div>
|
||||
|
||||
<div class="password-row">
|
||||
<div class="input-group">
|
||||
<label for="password">Password:</label>
|
||||
<input type="password" id="password" placeholder="Enter your password">
|
||||
</div>
|
||||
<button id="decrypt-btn" class="primary" disabled>Decrypt</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="message-container" class="card message-container">
|
||||
<h2><span class="icon">📬</span> Decrypted Message</h2>
|
||||
|
||||
<div class="message-header">
|
||||
<div>
|
||||
<div class="message-from" id="msg-from">Support Team</div>
|
||||
<div id="msg-subject" class="message-subject"></div>
|
||||
</div>
|
||||
<div class="message-date" id="msg-date"></div>
|
||||
</div>
|
||||
|
||||
<div class="message-body" id="msg-body"></div>
|
||||
|
||||
<div id="attachments-container" class="attachments" style="display: none;">
|
||||
<h3>Attachments</h3>
|
||||
<div id="attachments-list"></div>
|
||||
</div>
|
||||
|
||||
<div id="reply-key-banner" class="reply-key-banner">
|
||||
<h4><span>🔐</span> Authenticated Reply Key</h4>
|
||||
<p>This message includes a public key for secure replies. Use this to encrypt your response:</p>
|
||||
<div class="reply-key-value" id="reply-key"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="demo-section">
|
||||
<h3>Demo: Try with sample messages</h3>
|
||||
<p style="font-size: 0.85rem; color: #888; margin-bottom: 1rem;">
|
||||
Click a button to load a pre-encrypted sample message. All use password: <code style="background: rgba(0,0,0,0.3); padding: 0.2rem 0.4rem; border-radius: 4px;">demo123</code>
|
||||
</p>
|
||||
<div class="example-messages" id="example-buttons"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="wasm_exec.js"></script>
|
||||
<script>
|
||||
// Example encrypted messages (will be populated by WASM generation)
|
||||
const EXAMPLES = {
|
||||
'simple': '',
|
||||
'with-attachment': '',
|
||||
'with-hint': '',
|
||||
'with-reply-key': ''
|
||||
};
|
||||
|
||||
// Store attachment data for downloads
|
||||
const attachmentData = new Map();
|
||||
|
||||
let wasmReady = false;
|
||||
|
||||
// Initialize WASM
|
||||
async function initWasm() {
|
||||
const statusEl = document.getElementById('wasm-status');
|
||||
|
||||
try {
|
||||
const go = new Go();
|
||||
const result = await WebAssembly.instantiateStreaming(
|
||||
fetch('stmf.wasm'),
|
||||
go.importObject
|
||||
);
|
||||
go.run(result.instance);
|
||||
|
||||
// Wait for BorgSMSG to be ready
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('WASM init timeout')), 5000);
|
||||
|
||||
if (typeof BorgSMSG !== 'undefined' && BorgSMSG.ready) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('borgstmf:ready', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
wasmReady = true;
|
||||
updateStatus(statusEl, 'ready', 'Encryption module ready (v' + BorgSMSG.version + ')');
|
||||
document.getElementById('decrypt-btn').disabled = false;
|
||||
|
||||
// Generate example messages
|
||||
await generateExamples();
|
||||
setupExampleButtons();
|
||||
|
||||
} catch (err) {
|
||||
updateStatus(statusEl, 'error', 'Failed to load: ' + err.message);
|
||||
console.error('WASM init error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update status indicator safely
|
||||
function updateStatus(el, status, message) {
|
||||
el.className = 'status-indicator ' + status;
|
||||
// Clear and rebuild safely
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'dot';
|
||||
const text = document.createElement('span');
|
||||
text.textContent = message;
|
||||
el.appendChild(dot);
|
||||
el.appendChild(text);
|
||||
}
|
||||
|
||||
// Setup example buttons safely
|
||||
function setupExampleButtons() {
|
||||
const container = document.getElementById('example-buttons');
|
||||
const examples = [
|
||||
{ key: 'simple', label: 'Simple Message' },
|
||||
{ key: 'with-attachment', label: 'With Attachment' },
|
||||
{ key: 'with-hint', label: 'With Password Hint' },
|
||||
{ key: 'with-reply-key', label: 'With Reply Key' }
|
||||
];
|
||||
|
||||
examples.forEach(ex => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'secondary';
|
||||
btn.textContent = ex.label;
|
||||
btn.addEventListener('click', () => loadExample(ex.key));
|
||||
container.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
// Generate example encrypted messages
|
||||
async function generateExamples() {
|
||||
try {
|
||||
// Simple message
|
||||
EXAMPLES['simple'] = await BorgSMSG.encrypt({
|
||||
body: 'Hello! Thank you for contacting our support team.\n\nWe have reviewed your request and are happy to help. Please let us know if you have any other questions.\n\nBest regards,\nThe Support Team',
|
||||
subject: 'Re: Your Support Request #12345',
|
||||
from: 'support@example.com'
|
||||
}, 'demo123');
|
||||
|
||||
// With attachment
|
||||
const fileContent = btoa('This is the content of the attached file.\nIt contains important information.');
|
||||
EXAMPLES['with-attachment'] = await BorgSMSG.encrypt({
|
||||
body: 'Please find the requested document attached to this message.\n\nThe file contains the information you requested about your account.',
|
||||
subject: 'Document Attached',
|
||||
from: 'documents@example.com',
|
||||
attachments: [{
|
||||
name: 'account-details.txt',
|
||||
content: fileContent,
|
||||
mime: 'text/plain'
|
||||
}]
|
||||
}, 'demo123');
|
||||
|
||||
// With password hint
|
||||
EXAMPLES['with-hint'] = await BorgSMSG.encrypt({
|
||||
body: 'This is a confidential message that requires your password to view.\n\nYour account has been updated as requested.',
|
||||
subject: 'Account Update Confirmation',
|
||||
from: 'security@example.com'
|
||||
}, 'demo123', 'demo + 123');
|
||||
|
||||
// With reply key
|
||||
EXAMPLES['with-reply-key'] = await BorgSMSG.encrypt({
|
||||
body: 'This message includes a public key for secure replies.\n\nWhen you reply, use the attached public key to encrypt your response. This ensures only we can read your reply.',
|
||||
subject: 'Secure Communication Channel',
|
||||
from: 'secure@example.com',
|
||||
replyKey: {
|
||||
publicKey: 'dGVzdHB1YmxpY2tleWZvcmRlbW9wdXJwb3Nlcw=='
|
||||
}
|
||||
}, 'demo123');
|
||||
|
||||
console.log('Example messages generated');
|
||||
} catch (err) {
|
||||
console.error('Failed to generate examples:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Load example message
|
||||
function loadExample(type) {
|
||||
const textarea = document.getElementById('encrypted-message');
|
||||
textarea.value = EXAMPLES[type];
|
||||
checkForHint();
|
||||
}
|
||||
|
||||
// Check for password hint
|
||||
async function checkForHint() {
|
||||
const encryptedB64 = document.getElementById('encrypted-message').value.trim();
|
||||
const hintBanner = document.getElementById('hint-banner');
|
||||
const hintText = document.getElementById('hint-text');
|
||||
|
||||
hintBanner.classList.remove('visible');
|
||||
|
||||
if (!encryptedB64 || !wasmReady) return;
|
||||
|
||||
try {
|
||||
const info = await BorgSMSG.getInfo(encryptedB64);
|
||||
if (info.hint) {
|
||||
hintText.textContent = info.hint;
|
||||
hintBanner.classList.add('visible');
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently ignore - invalid format
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt message
|
||||
async function decryptMessage() {
|
||||
const encryptedB64 = document.getElementById('encrypted-message').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const errorBanner = document.getElementById('error-banner');
|
||||
const messageContainer = document.getElementById('message-container');
|
||||
|
||||
errorBanner.classList.remove('visible');
|
||||
messageContainer.classList.remove('visible');
|
||||
|
||||
if (!encryptedB64) {
|
||||
showError('Please paste an encrypted message');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!password) {
|
||||
showError('Please enter the password');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const message = await BorgSMSG.decrypt(encryptedB64, password);
|
||||
displayMessage(message);
|
||||
} catch (err) {
|
||||
showError('Decryption failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Show error
|
||||
function showError(msg) {
|
||||
const errorBanner = document.getElementById('error-banner');
|
||||
errorBanner.textContent = msg;
|
||||
errorBanner.classList.add('visible');
|
||||
}
|
||||
|
||||
// Display decrypted message
|
||||
function displayMessage(msg) {
|
||||
document.getElementById('msg-from').textContent = msg.from || 'Unknown Sender';
|
||||
document.getElementById('msg-subject').textContent = msg.subject || '(No Subject)';
|
||||
document.getElementById('msg-body').textContent = msg.body;
|
||||
|
||||
// Format date
|
||||
if (msg.timestamp) {
|
||||
const date = new Date(msg.timestamp * 1000);
|
||||
document.getElementById('msg-date').textContent = date.toLocaleString();
|
||||
} else {
|
||||
document.getElementById('msg-date').textContent = '';
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
const attachmentsContainer = document.getElementById('attachments-container');
|
||||
const attachmentsList = document.getElementById('attachments-list');
|
||||
|
||||
// Clear previous attachments
|
||||
while (attachmentsList.firstChild) {
|
||||
attachmentsList.removeChild(attachmentsList.firstChild);
|
||||
}
|
||||
attachmentData.clear();
|
||||
|
||||
if (msg.attachments && msg.attachments.length > 0) {
|
||||
attachmentsContainer.style.display = 'block';
|
||||
|
||||
msg.attachments.forEach((att, index) => {
|
||||
// Store attachment data
|
||||
const attId = 'att-' + index;
|
||||
attachmentData.set(attId, {
|
||||
name: att.name,
|
||||
content: att.content,
|
||||
mime: att.mime
|
||||
});
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'attachment-item';
|
||||
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'attachment-icon';
|
||||
iconSpan.textContent = getFileIcon(att.mime);
|
||||
|
||||
const infoDiv = document.createElement('div');
|
||||
infoDiv.className = 'attachment-info';
|
||||
|
||||
const nameDiv = document.createElement('div');
|
||||
nameDiv.className = 'attachment-name';
|
||||
nameDiv.textContent = att.name;
|
||||
|
||||
const metaDiv = document.createElement('div');
|
||||
metaDiv.className = 'attachment-meta';
|
||||
metaDiv.textContent = att.mime || 'unknown type';
|
||||
|
||||
infoDiv.appendChild(nameDiv);
|
||||
infoDiv.appendChild(metaDiv);
|
||||
|
||||
const downloadBtn = document.createElement('button');
|
||||
downloadBtn.className = 'secondary attachment-download';
|
||||
downloadBtn.textContent = 'Download';
|
||||
downloadBtn.dataset.attId = attId;
|
||||
downloadBtn.addEventListener('click', function() {
|
||||
downloadAttachment(this.dataset.attId);
|
||||
});
|
||||
|
||||
item.appendChild(iconSpan);
|
||||
item.appendChild(infoDiv);
|
||||
item.appendChild(downloadBtn);
|
||||
|
||||
attachmentsList.appendChild(item);
|
||||
});
|
||||
} else {
|
||||
attachmentsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
// Handle reply key
|
||||
const replyKeyBanner = document.getElementById('reply-key-banner');
|
||||
if (msg.replyKey && msg.replyKey.publicKey) {
|
||||
document.getElementById('reply-key').textContent = msg.replyKey.publicKey;
|
||||
replyKeyBanner.classList.add('visible');
|
||||
} else {
|
||||
replyKeyBanner.classList.remove('visible');
|
||||
}
|
||||
|
||||
document.getElementById('message-container').classList.add('visible');
|
||||
}
|
||||
|
||||
// Get file icon based on mime type
|
||||
function getFileIcon(mime) {
|
||||
if (!mime) return '📄';
|
||||
if (mime.startsWith('image/')) return '🖼️';
|
||||
if (mime.startsWith('video/')) return '🎬';
|
||||
if (mime.startsWith('audio/')) return '🎵';
|
||||
if (mime.includes('pdf')) return '📕';
|
||||
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gzip')) return '📦';
|
||||
if (mime.includes('json') || mime.includes('xml')) return '📋';
|
||||
return '📄';
|
||||
}
|
||||
|
||||
// Download attachment
|
||||
function downloadAttachment(attId) {
|
||||
const att = attachmentData.get(attId);
|
||||
if (!att) {
|
||||
alert('Attachment not found');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const binary = atob(att.content);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
|
||||
const blob = new Blob([bytes], { type: att.mime || 'application/octet-stream' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = att.name;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
alert('Failed to download: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('decrypt-btn').addEventListener('click', decryptMessage);
|
||||
document.getElementById('password').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') decryptMessage();
|
||||
});
|
||||
document.getElementById('encrypted-message').addEventListener('input', checkForHint);
|
||||
|
||||
// Initialize
|
||||
initWasm();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
17
js/borg-stmf/tsconfig.json
Normal file
17
js/borg-stmf/tsconfig.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"declaration": true,
|
||||
"declarationDir": "./dist",
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
575
js/borg-stmf/wasm_exec.js
Normal file
575
js/borg-stmf/wasm_exec.js
Normal file
|
|
@ -0,0 +1,575 @@
|
|||
// Copyright 2018 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
"use strict";
|
||||
|
||||
(() => {
|
||||
const enosys = () => {
|
||||
const err = new Error("not implemented");
|
||||
err.code = "ENOSYS";
|
||||
return err;
|
||||
};
|
||||
|
||||
if (!globalThis.fs) {
|
||||
let outputBuf = "";
|
||||
globalThis.fs = {
|
||||
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused
|
||||
writeSync(fd, buf) {
|
||||
outputBuf += decoder.decode(buf);
|
||||
const nl = outputBuf.lastIndexOf("\n");
|
||||
if (nl != -1) {
|
||||
console.log(outputBuf.substring(0, nl));
|
||||
outputBuf = outputBuf.substring(nl + 1);
|
||||
}
|
||||
return buf.length;
|
||||
},
|
||||
write(fd, buf, offset, length, position, callback) {
|
||||
if (offset !== 0 || length !== buf.length || position !== null) {
|
||||
callback(enosys());
|
||||
return;
|
||||
}
|
||||
const n = this.writeSync(fd, buf);
|
||||
callback(null, n);
|
||||
},
|
||||
chmod(path, mode, callback) { callback(enosys()); },
|
||||
chown(path, uid, gid, callback) { callback(enosys()); },
|
||||
close(fd, callback) { callback(enosys()); },
|
||||
fchmod(fd, mode, callback) { callback(enosys()); },
|
||||
fchown(fd, uid, gid, callback) { callback(enosys()); },
|
||||
fstat(fd, callback) { callback(enosys()); },
|
||||
fsync(fd, callback) { callback(null); },
|
||||
ftruncate(fd, length, callback) { callback(enosys()); },
|
||||
lchown(path, uid, gid, callback) { callback(enosys()); },
|
||||
link(path, link, callback) { callback(enosys()); },
|
||||
lstat(path, callback) { callback(enosys()); },
|
||||
mkdir(path, perm, callback) { callback(enosys()); },
|
||||
open(path, flags, mode, callback) { callback(enosys()); },
|
||||
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
|
||||
readdir(path, callback) { callback(enosys()); },
|
||||
readlink(path, callback) { callback(enosys()); },
|
||||
rename(from, to, callback) { callback(enosys()); },
|
||||
rmdir(path, callback) { callback(enosys()); },
|
||||
stat(path, callback) { callback(enosys()); },
|
||||
symlink(path, link, callback) { callback(enosys()); },
|
||||
truncate(path, length, callback) { callback(enosys()); },
|
||||
unlink(path, callback) { callback(enosys()); },
|
||||
utimes(path, atime, mtime, callback) { callback(enosys()); },
|
||||
};
|
||||
}
|
||||
|
||||
if (!globalThis.process) {
|
||||
globalThis.process = {
|
||||
getuid() { return -1; },
|
||||
getgid() { return -1; },
|
||||
geteuid() { return -1; },
|
||||
getegid() { return -1; },
|
||||
getgroups() { throw enosys(); },
|
||||
pid: -1,
|
||||
ppid: -1,
|
||||
umask() { throw enosys(); },
|
||||
cwd() { throw enosys(); },
|
||||
chdir() { throw enosys(); },
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.path) {
|
||||
globalThis.path = {
|
||||
resolve(...pathSegments) {
|
||||
return pathSegments.join("/");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!globalThis.crypto) {
|
||||
throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)");
|
||||
}
|
||||
|
||||
if (!globalThis.performance) {
|
||||
throw new Error("globalThis.performance is not available, polyfill required (performance.now only)");
|
||||
}
|
||||
|
||||
if (!globalThis.TextEncoder) {
|
||||
throw new Error("globalThis.TextEncoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
if (!globalThis.TextDecoder) {
|
||||
throw new Error("globalThis.TextDecoder is not available, polyfill required");
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder("utf-8");
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
globalThis.Go = class {
|
||||
constructor() {
|
||||
this.argv = ["js"];
|
||||
this.env = {};
|
||||
this.exit = (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn("exit code:", code);
|
||||
}
|
||||
};
|
||||
this._exitPromise = new Promise((resolve) => {
|
||||
this._resolveExitPromise = resolve;
|
||||
});
|
||||
this._pendingEvent = null;
|
||||
this._scheduledTimeouts = new Map();
|
||||
this._nextCallbackTimeoutID = 1;
|
||||
|
||||
const setInt64 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true);
|
||||
}
|
||||
|
||||
const setInt32 = (addr, v) => {
|
||||
this.mem.setUint32(addr + 0, v, true);
|
||||
}
|
||||
|
||||
const getInt64 = (addr) => {
|
||||
const low = this.mem.getUint32(addr + 0, true);
|
||||
const high = this.mem.getInt32(addr + 4, true);
|
||||
return low + high * 4294967296;
|
||||
}
|
||||
|
||||
const loadValue = (addr) => {
|
||||
const f = this.mem.getFloat64(addr, true);
|
||||
if (f === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isNaN(f)) {
|
||||
return f;
|
||||
}
|
||||
|
||||
const id = this.mem.getUint32(addr, true);
|
||||
return this._values[id];
|
||||
}
|
||||
|
||||
const storeValue = (addr, v) => {
|
||||
const nanHead = 0x7FF80000;
|
||||
|
||||
if (typeof v === "number" && v !== 0) {
|
||||
if (isNaN(v)) {
|
||||
this.mem.setUint32(addr + 4, nanHead, true);
|
||||
this.mem.setUint32(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
this.mem.setFloat64(addr, v, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (v === undefined) {
|
||||
this.mem.setFloat64(addr, 0, true);
|
||||
return;
|
||||
}
|
||||
|
||||
let id = this._ids.get(v);
|
||||
if (id === undefined) {
|
||||
id = this._idPool.pop();
|
||||
if (id === undefined) {
|
||||
id = this._values.length;
|
||||
}
|
||||
this._values[id] = v;
|
||||
this._goRefCounts[id] = 0;
|
||||
this._ids.set(v, id);
|
||||
}
|
||||
this._goRefCounts[id]++;
|
||||
let typeFlag = 0;
|
||||
switch (typeof v) {
|
||||
case "object":
|
||||
if (v !== null) {
|
||||
typeFlag = 1;
|
||||
}
|
||||
break;
|
||||
case "string":
|
||||
typeFlag = 2;
|
||||
break;
|
||||
case "symbol":
|
||||
typeFlag = 3;
|
||||
break;
|
||||
case "function":
|
||||
typeFlag = 4;
|
||||
break;
|
||||
}
|
||||
this.mem.setUint32(addr + 4, nanHead | typeFlag, true);
|
||||
this.mem.setUint32(addr, id, true);
|
||||
}
|
||||
|
||||
const loadSlice = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return new Uint8Array(this._inst.exports.mem.buffer, array, len);
|
||||
}
|
||||
|
||||
const loadSliceOfValues = (addr) => {
|
||||
const array = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
const a = new Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
a[i] = loadValue(array + i * 8);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
const loadString = (addr) => {
|
||||
const saddr = getInt64(addr + 0);
|
||||
const len = getInt64(addr + 8);
|
||||
return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len));
|
||||
}
|
||||
|
||||
const testCallExport = (a, b) => {
|
||||
this._inst.exports.testExport0();
|
||||
return this._inst.exports.testExport(a, b);
|
||||
}
|
||||
|
||||
const timeOrigin = Date.now() - performance.now();
|
||||
this.importObject = {
|
||||
_gotest: {
|
||||
add: (a, b) => a + b,
|
||||
callExport: testCallExport,
|
||||
},
|
||||
gojs: {
|
||||
// Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters)
|
||||
// may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported
|
||||
// function. A goroutine can switch to a new stack if the current stack is too small (see morestack function).
|
||||
// This changes the SP, thus we have to update the SP used by the imported function.
|
||||
|
||||
// func wasmExit(code int32)
|
||||
"runtime.wasmExit": (sp) => {
|
||||
sp >>>= 0;
|
||||
const code = this.mem.getInt32(sp + 8, true);
|
||||
this.exited = true;
|
||||
delete this._inst;
|
||||
delete this._values;
|
||||
delete this._goRefCounts;
|
||||
delete this._ids;
|
||||
delete this._idPool;
|
||||
this.exit(code);
|
||||
},
|
||||
|
||||
// func wasmWrite(fd uintptr, p unsafe.Pointer, n int32)
|
||||
"runtime.wasmWrite": (sp) => {
|
||||
sp >>>= 0;
|
||||
const fd = getInt64(sp + 8);
|
||||
const p = getInt64(sp + 16);
|
||||
const n = this.mem.getInt32(sp + 24, true);
|
||||
fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n));
|
||||
},
|
||||
|
||||
// func resetMemoryDataView()
|
||||
"runtime.resetMemoryDataView": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
},
|
||||
|
||||
// func nanotime1() int64
|
||||
"runtime.nanotime1": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000);
|
||||
},
|
||||
|
||||
// func walltime() (sec int64, nsec int32)
|
||||
"runtime.walltime": (sp) => {
|
||||
sp >>>= 0;
|
||||
const msec = (new Date).getTime();
|
||||
setInt64(sp + 8, msec / 1000);
|
||||
this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true);
|
||||
},
|
||||
|
||||
// func scheduleTimeoutEvent(delay int64) int32
|
||||
"runtime.scheduleTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this._nextCallbackTimeoutID;
|
||||
this._nextCallbackTimeoutID++;
|
||||
this._scheduledTimeouts.set(id, setTimeout(
|
||||
() => {
|
||||
this._resume();
|
||||
while (this._scheduledTimeouts.has(id)) {
|
||||
// for some reason Go failed to register the timeout event, log and try again
|
||||
// (temporary workaround for https://github.com/golang/go/issues/28975)
|
||||
console.warn("scheduleTimeoutEvent: missed timeout event");
|
||||
this._resume();
|
||||
}
|
||||
},
|
||||
getInt64(sp + 8),
|
||||
));
|
||||
this.mem.setInt32(sp + 16, id, true);
|
||||
},
|
||||
|
||||
// func clearTimeoutEvent(id int32)
|
||||
"runtime.clearTimeoutEvent": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getInt32(sp + 8, true);
|
||||
clearTimeout(this._scheduledTimeouts.get(id));
|
||||
this._scheduledTimeouts.delete(id);
|
||||
},
|
||||
|
||||
// func getRandomData(r []byte)
|
||||
"runtime.getRandomData": (sp) => {
|
||||
sp >>>= 0;
|
||||
crypto.getRandomValues(loadSlice(sp + 8));
|
||||
},
|
||||
|
||||
// func finalizeRef(v ref)
|
||||
"syscall/js.finalizeRef": (sp) => {
|
||||
sp >>>= 0;
|
||||
const id = this.mem.getUint32(sp + 8, true);
|
||||
this._goRefCounts[id]--;
|
||||
if (this._goRefCounts[id] === 0) {
|
||||
const v = this._values[id];
|
||||
this._values[id] = null;
|
||||
this._ids.delete(v);
|
||||
this._idPool.push(id);
|
||||
}
|
||||
},
|
||||
|
||||
// func stringVal(value string) ref
|
||||
"syscall/js.stringVal": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, loadString(sp + 8));
|
||||
},
|
||||
|
||||
// func valueGet(v ref, p string) ref
|
||||
"syscall/js.valueGet": (sp) => {
|
||||
sp >>>= 0;
|
||||
const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16));
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 32, result);
|
||||
},
|
||||
|
||||
// func valueSet(v ref, p string, x ref)
|
||||
"syscall/js.valueSet": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32));
|
||||
},
|
||||
|
||||
// func valueDelete(v ref, p string)
|
||||
"syscall/js.valueDelete": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16));
|
||||
},
|
||||
|
||||
// func valueIndex(v ref, i int) ref
|
||||
"syscall/js.valueIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16)));
|
||||
},
|
||||
|
||||
// valueSetIndex(v ref, i int, x ref)
|
||||
"syscall/js.valueSetIndex": (sp) => {
|
||||
sp >>>= 0;
|
||||
Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24));
|
||||
},
|
||||
|
||||
// func valueCall(v ref, m string, args []ref) (ref, bool)
|
||||
"syscall/js.valueCall": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const m = Reflect.get(v, loadString(sp + 16));
|
||||
const args = loadSliceOfValues(sp + 32);
|
||||
const result = Reflect.apply(m, v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, result);
|
||||
this.mem.setUint8(sp + 64, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 56, err);
|
||||
this.mem.setUint8(sp + 64, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueInvoke(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueInvoke": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.apply(v, undefined, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueNew(v ref, args []ref) (ref, bool)
|
||||
"syscall/js.valueNew": (sp) => {
|
||||
sp >>>= 0;
|
||||
try {
|
||||
const v = loadValue(sp + 8);
|
||||
const args = loadSliceOfValues(sp + 16);
|
||||
const result = Reflect.construct(v, args);
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, result);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
} catch (err) {
|
||||
sp = this._inst.exports.getsp() >>> 0; // see comment above
|
||||
storeValue(sp + 40, err);
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
}
|
||||
},
|
||||
|
||||
// func valueLength(v ref) int
|
||||
"syscall/js.valueLength": (sp) => {
|
||||
sp >>>= 0;
|
||||
setInt64(sp + 16, parseInt(loadValue(sp + 8).length));
|
||||
},
|
||||
|
||||
// valuePrepareString(v ref) (ref, int)
|
||||
"syscall/js.valuePrepareString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = encoder.encode(String(loadValue(sp + 8)));
|
||||
storeValue(sp + 16, str);
|
||||
setInt64(sp + 24, str.length);
|
||||
},
|
||||
|
||||
// valueLoadString(v ref, b []byte)
|
||||
"syscall/js.valueLoadString": (sp) => {
|
||||
sp >>>= 0;
|
||||
const str = loadValue(sp + 8);
|
||||
loadSlice(sp + 16).set(str);
|
||||
},
|
||||
|
||||
// func valueInstanceOf(v ref, t ref) bool
|
||||
"syscall/js.valueInstanceOf": (sp) => {
|
||||
sp >>>= 0;
|
||||
this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0);
|
||||
},
|
||||
|
||||
// func copyBytesToGo(dst []byte, src ref) (int, bool)
|
||||
"syscall/js.copyBytesToGo": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadSlice(sp + 8);
|
||||
const src = loadValue(sp + 32);
|
||||
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
// func copyBytesToJS(dst ref, src []byte) (int, bool)
|
||||
"syscall/js.copyBytesToJS": (sp) => {
|
||||
sp >>>= 0;
|
||||
const dst = loadValue(sp + 8);
|
||||
const src = loadSlice(sp + 16);
|
||||
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
|
||||
this.mem.setUint8(sp + 48, 0);
|
||||
return;
|
||||
}
|
||||
const toCopy = src.subarray(0, dst.length);
|
||||
dst.set(toCopy);
|
||||
setInt64(sp + 40, toCopy.length);
|
||||
this.mem.setUint8(sp + 48, 1);
|
||||
},
|
||||
|
||||
"debug": (value) => {
|
||||
console.log(value);
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async run(instance) {
|
||||
if (!(instance instanceof WebAssembly.Instance)) {
|
||||
throw new Error("Go.run: WebAssembly.Instance expected");
|
||||
}
|
||||
this._inst = instance;
|
||||
this.mem = new DataView(this._inst.exports.mem.buffer);
|
||||
this._values = [ // JS values that Go currently has references to, indexed by reference id
|
||||
NaN,
|
||||
0,
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
globalThis,
|
||||
this,
|
||||
];
|
||||
this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id
|
||||
this._ids = new Map([ // mapping from JS values to reference ids
|
||||
[0, 1],
|
||||
[null, 2],
|
||||
[true, 3],
|
||||
[false, 4],
|
||||
[globalThis, 5],
|
||||
[this, 6],
|
||||
]);
|
||||
this._idPool = []; // unused ids that have been garbage collected
|
||||
this.exited = false; // whether the Go program has exited
|
||||
|
||||
// Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory.
|
||||
let offset = 4096;
|
||||
|
||||
const strPtr = (str) => {
|
||||
const ptr = offset;
|
||||
const bytes = encoder.encode(str + "\0");
|
||||
new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes);
|
||||
offset += bytes.length;
|
||||
if (offset % 8 !== 0) {
|
||||
offset += 8 - (offset % 8);
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
const argc = this.argv.length;
|
||||
|
||||
const argvPtrs = [];
|
||||
this.argv.forEach((arg) => {
|
||||
argvPtrs.push(strPtr(arg));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const keys = Object.keys(this.env).sort();
|
||||
keys.forEach((key) => {
|
||||
argvPtrs.push(strPtr(`${key}=${this.env[key]}`));
|
||||
});
|
||||
argvPtrs.push(0);
|
||||
|
||||
const argv = offset;
|
||||
argvPtrs.forEach((ptr) => {
|
||||
this.mem.setUint32(offset, ptr, true);
|
||||
this.mem.setUint32(offset + 4, 0, true);
|
||||
offset += 8;
|
||||
});
|
||||
|
||||
// The linker guarantees global data starts from at least wasmMinDataAddr.
|
||||
// Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr.
|
||||
const wasmMinDataAddr = 4096 + 8192;
|
||||
if (offset >= wasmMinDataAddr) {
|
||||
throw new Error("total length of command line and environment variables exceeds limit");
|
||||
}
|
||||
|
||||
this._inst.exports.run(argc, argv);
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
await this._exitPromise;
|
||||
}
|
||||
|
||||
_resume() {
|
||||
if (this.exited) {
|
||||
throw new Error("Go program has already exited");
|
||||
}
|
||||
this._inst.exports.resume();
|
||||
if (this.exited) {
|
||||
this._resolveExitPromise();
|
||||
}
|
||||
}
|
||||
|
||||
_makeFuncWrapper(id) {
|
||||
const go = this;
|
||||
return function () {
|
||||
const event = { id: id, this: this, args: arguments };
|
||||
go._pendingEvent = event;
|
||||
go._resume();
|
||||
return event.result;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
147
php/borg-stmf/README.md
Normal file
147
php/borg-stmf/README.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# Borg STMF for PHP
|
||||
|
||||
Sovereign Form Encryption - Decrypt STMF payloads using X25519 + ChaCha20-Poly1305.
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 7.2 or later
|
||||
- `ext-sodium` (included in PHP 7.2+)
|
||||
- `ext-json`
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
composer require borg/stmf
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use Borg\STMF\STMF;
|
||||
|
||||
// Initialize with your private key
|
||||
$stmf = new STMF($privateKeyBase64);
|
||||
|
||||
// Decrypt the form payload from POST
|
||||
$formData = $stmf->decrypt($_POST['_stmf_payload']);
|
||||
|
||||
// Access form fields
|
||||
$email = $formData->get('email');
|
||||
$password = $formData->get('password');
|
||||
|
||||
// Access all fields as array
|
||||
$allFields = $formData->toArray();
|
||||
|
||||
// Access metadata
|
||||
$origin = $formData->getOrigin();
|
||||
$timestamp = $formData->getTimestamp();
|
||||
```
|
||||
|
||||
## Laravel Integration
|
||||
|
||||
```php
|
||||
// In a controller
|
||||
public function handleForm(Request $request)
|
||||
{
|
||||
$stmf = new STMF(config('app.stmf_private_key'));
|
||||
$formData = $stmf->decrypt($request->input('_stmf_payload'));
|
||||
|
||||
// Use decrypted data
|
||||
$user = User::create([
|
||||
'email' => $formData->get('email'),
|
||||
'password' => Hash::make($formData->get('password')),
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
## Key Generation
|
||||
|
||||
Generate a keypair in Go:
|
||||
|
||||
```go
|
||||
import "github.com/Snider/Borg/pkg/stmf"
|
||||
|
||||
kp, _ := stmf.GenerateKeyPair()
|
||||
fmt.Println("Public key:", kp.PublicKeyBase64()) // Put in HTML
|
||||
fmt.Println("Private key:", kp.PrivateKeyBase64()) // Put in PHP config
|
||||
```
|
||||
|
||||
Or generate in PHP (for testing):
|
||||
|
||||
```php
|
||||
use Borg\STMF\KeyPair;
|
||||
|
||||
$keypair = KeyPair::generate();
|
||||
echo "Public: " . $keypair->getPublicKeyBase64() . "\n";
|
||||
echo "Private: " . $keypair->getPrivateKeyBase64() . "\n";
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### STMF
|
||||
|
||||
```php
|
||||
// Constructor
|
||||
$stmf = new STMF(string $privateKeyBase64);
|
||||
|
||||
// Decrypt a base64-encoded payload
|
||||
$formData = $stmf->decrypt(string $payloadBase64): FormData;
|
||||
|
||||
// Decrypt raw bytes
|
||||
$formData = $stmf->decryptRaw(string $payload): FormData;
|
||||
|
||||
// Validate without decrypting
|
||||
$isValid = $stmf->validate(string $payloadBase64): bool;
|
||||
|
||||
// Get payload info without decrypting
|
||||
$info = $stmf->getInfo(string $payloadBase64): array;
|
||||
```
|
||||
|
||||
### FormData
|
||||
|
||||
```php
|
||||
// Get a single field value
|
||||
$value = $formData->get(string $name): ?string;
|
||||
|
||||
// Get a field object (includes type, filename, mime)
|
||||
$field = $formData->getField(string $name): ?FormField;
|
||||
|
||||
// Get all values for a field name
|
||||
$values = $formData->getAll(string $name): array;
|
||||
|
||||
// Check if field exists
|
||||
$exists = $formData->has(string $name): bool;
|
||||
|
||||
// Convert to associative array
|
||||
$array = $formData->toArray(): array;
|
||||
|
||||
// Get all fields
|
||||
$fields = $formData->fields(): array;
|
||||
|
||||
// Get metadata
|
||||
$meta = $formData->getMetadata(): array;
|
||||
$origin = $formData->getOrigin(): ?string;
|
||||
$timestamp = $formData->getTimestamp(): ?int;
|
||||
```
|
||||
|
||||
### FormField
|
||||
|
||||
```php
|
||||
$field->name; // Field name
|
||||
$field->value; // Field value
|
||||
$field->type; // Field type (text, password, file, etc.)
|
||||
$field->filename; // Filename for file uploads
|
||||
$field->mimeType; // MIME type for file uploads
|
||||
|
||||
$field->isFile(): bool; // Check if this is a file field
|
||||
$field->getFileContent(): ?string; // Get decoded file content
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
- **Hybrid encryption**: X25519 ECDH key exchange + ChaCha20-Poly1305
|
||||
- **Forward secrecy**: Each form submission uses a new ephemeral keypair
|
||||
- **Authenticated encryption**: Decryption fails if data was tampered with
|
||||
- **Libsodium**: Uses PHP's built-in sodium extension
|
||||
34
php/borg-stmf/composer.json
Normal file
34
php/borg-stmf/composer.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "borg/stmf",
|
||||
"description": "Sovereign Form Encryption - Decrypt STMF payloads using X25519 + ChaCha20-Poly1305",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Snider",
|
||||
"email": "snider@example.com"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": ">=7.2",
|
||||
"ext-sodium": "*",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^9.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Borg\\STMF\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Borg\\STMF\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "phpunit"
|
||||
},
|
||||
"minimum-stability": "stable"
|
||||
}
|
||||
12
php/borg-stmf/src/DecryptionException.php
Normal file
12
php/borg-stmf/src/DecryptionException.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Borg\STMF;
|
||||
|
||||
/**
|
||||
* Exception thrown when decryption fails
|
||||
*/
|
||||
class DecryptionException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
154
php/borg-stmf/src/FormData.php
Normal file
154
php/borg-stmf/src/FormData.php
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Borg\STMF;
|
||||
|
||||
/**
|
||||
* Represents decrypted form data
|
||||
*/
|
||||
class FormData
|
||||
{
|
||||
/** @var FormField[] */
|
||||
private array $fields;
|
||||
|
||||
/** @var array<string, string> */
|
||||
private array $metadata;
|
||||
|
||||
/**
|
||||
* @param FormField[] $fields
|
||||
* @param array<string, string> $metadata
|
||||
*/
|
||||
public function __construct(array $fields, array $metadata = [])
|
||||
{
|
||||
$this->fields = $fields;
|
||||
$this->metadata = $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a field value by name
|
||||
*/
|
||||
public function get(string $name): ?string
|
||||
{
|
||||
foreach ($this->fields as $field) {
|
||||
if ($field->name === $name) {
|
||||
return $field->value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a field object by name
|
||||
*/
|
||||
public function getField(string $name): ?FormField
|
||||
{
|
||||
foreach ($this->fields as $field) {
|
||||
if ($field->name === $name) {
|
||||
return $field;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all values for a field name (for multi-select)
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public function getAll(string $name): array
|
||||
{
|
||||
$values = [];
|
||||
foreach ($this->fields as $field) {
|
||||
if ($field->name === $name) {
|
||||
$values[] = $field->value;
|
||||
}
|
||||
}
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all fields
|
||||
*
|
||||
* @return FormField[]
|
||||
*/
|
||||
public function fields(): array
|
||||
{
|
||||
return $this->fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a field exists
|
||||
*/
|
||||
public function has(string $name): bool
|
||||
{
|
||||
foreach ($this->fields as $field) {
|
||||
if ($field->name === $name) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to associative array (last value wins for duplicates)
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function toArray(): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($this->fields as $field) {
|
||||
$result[$field->name] = $field->value;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getMetadata(): array
|
||||
{
|
||||
return $this->metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific metadata value
|
||||
*/
|
||||
public function getMeta(string $key): ?string
|
||||
{
|
||||
return $this->metadata[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the origin (if set in metadata)
|
||||
*/
|
||||
public function getOrigin(): ?string
|
||||
{
|
||||
return $this->metadata['origin'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the timestamp (if set in metadata)
|
||||
*/
|
||||
public function getTimestamp(): ?int
|
||||
{
|
||||
$ts = $this->metadata['timestamp'] ?? null;
|
||||
return $ts !== null ? (int) $ts : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from decoded JSON array
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$fields = [];
|
||||
foreach ($data['fields'] ?? [] as $fieldData) {
|
||||
$fields[] = FormField::fromArray($fieldData);
|
||||
}
|
||||
|
||||
return new self($fields, $data['meta'] ?? []);
|
||||
}
|
||||
}
|
||||
64
php/borg-stmf/src/FormField.php
Normal file
64
php/borg-stmf/src/FormField.php
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Borg\STMF;
|
||||
|
||||
/**
|
||||
* Represents a single form field
|
||||
*/
|
||||
class FormField
|
||||
{
|
||||
public string $name;
|
||||
public string $value;
|
||||
public ?string $type;
|
||||
public ?string $filename;
|
||||
public ?string $mimeType;
|
||||
|
||||
public function __construct(
|
||||
string $name,
|
||||
string $value,
|
||||
?string $type = null,
|
||||
?string $filename = null,
|
||||
?string $mimeType = null
|
||||
) {
|
||||
$this->name = $name;
|
||||
$this->value = $value;
|
||||
$this->type = $type;
|
||||
$this->filename = $filename;
|
||||
$this->mimeType = $mimeType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a file field
|
||||
*/
|
||||
public function isFile(): bool
|
||||
{
|
||||
return $this->type === 'file';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file content decoded from base64
|
||||
*/
|
||||
public function getFileContent(): ?string
|
||||
{
|
||||
if (!$this->isFile()) {
|
||||
return null;
|
||||
}
|
||||
return base64_decode($this->value, true) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from array
|
||||
*/
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
return new self(
|
||||
$data['name'] ?? '',
|
||||
$data['value'] ?? '',
|
||||
$data['type'] ?? null,
|
||||
$data['filename'] ?? null,
|
||||
$data['mime'] ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
12
php/borg-stmf/src/InvalidPayloadException.php
Normal file
12
php/borg-stmf/src/InvalidPayloadException.php
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Borg\STMF;
|
||||
|
||||
/**
|
||||
* Exception thrown when the STMF payload is invalid
|
||||
*/
|
||||
class InvalidPayloadException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
95
php/borg-stmf/src/KeyPair.php
Normal file
95
php/borg-stmf/src/KeyPair.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Borg\STMF;
|
||||
|
||||
/**
|
||||
* X25519 keypair for STMF encryption/decryption
|
||||
*/
|
||||
class KeyPair
|
||||
{
|
||||
private string $publicKey;
|
||||
private string $privateKey;
|
||||
|
||||
/**
|
||||
* @param string $publicKey Raw public key bytes (32 bytes)
|
||||
* @param string $privateKey Raw private key bytes (32 bytes)
|
||||
*/
|
||||
public function __construct(string $publicKey, string $privateKey)
|
||||
{
|
||||
if (strlen($publicKey) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Public key must be ' . SODIUM_CRYPTO_BOX_PUBLICKEYBYTES . ' bytes'
|
||||
);
|
||||
}
|
||||
if (strlen($privateKey) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Private key must be ' . SODIUM_CRYPTO_BOX_SECRETKEYBYTES . ' bytes'
|
||||
);
|
||||
}
|
||||
|
||||
$this->publicKey = $publicKey;
|
||||
$this->privateKey = $privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new X25519 keypair
|
||||
*/
|
||||
public static function generate(): self
|
||||
{
|
||||
$keypair = sodium_crypto_box_keypair();
|
||||
return new self(
|
||||
sodium_crypto_box_publickey($keypair),
|
||||
sodium_crypto_box_secretkey($keypair)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load keypair from base64-encoded private key
|
||||
*/
|
||||
public static function fromPrivateKeyBase64(string $privateKeyBase64): self
|
||||
{
|
||||
$privateKey = base64_decode($privateKeyBase64, true);
|
||||
if ($privateKey === false) {
|
||||
throw new \InvalidArgumentException('Invalid base64 private key');
|
||||
}
|
||||
|
||||
// Derive public key from private key
|
||||
$publicKey = sodium_crypto_scalarmult_base($privateKey);
|
||||
|
||||
return new self($publicKey, $privateKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw public key bytes
|
||||
*/
|
||||
public function getPublicKey(): string
|
||||
{
|
||||
return $this->publicKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw private key bytes
|
||||
*/
|
||||
public function getPrivateKey(): string
|
||||
{
|
||||
return $this->privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public key as base64
|
||||
*/
|
||||
public function getPublicKeyBase64(): string
|
||||
{
|
||||
return base64_encode($this->publicKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the private key as base64
|
||||
*/
|
||||
public function getPrivateKeyBase64(): string
|
||||
{
|
||||
return base64_encode($this->privateKey);
|
||||
}
|
||||
}
|
||||
312
php/borg-stmf/src/STMF.php
Normal file
312
php/borg-stmf/src/STMF.php
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Borg\STMF;
|
||||
|
||||
/**
|
||||
* STMF - Sovereign Form Encryption
|
||||
*
|
||||
* Decrypts STMF payloads that were encrypted client-side using the server's public key.
|
||||
* Uses X25519 ECDH key exchange + ChaCha20-Poly1305 authenticated encryption.
|
||||
*
|
||||
* @example
|
||||
* ```php
|
||||
* $stmf = new STMF($privateKeyBase64);
|
||||
* $formData = $stmf->decrypt($_POST['_stmf_payload']);
|
||||
*
|
||||
* $email = $formData->get('email');
|
||||
* $password = $formData->get('password');
|
||||
* ```
|
||||
*/
|
||||
class STMF
|
||||
{
|
||||
private const MAGIC = 'STMF';
|
||||
|
||||
private string $privateKey;
|
||||
|
||||
/**
|
||||
* @param string $privateKeyBase64 Base64-encoded X25519 private key
|
||||
*/
|
||||
public function __construct(string $privateKeyBase64)
|
||||
{
|
||||
$privateKey = base64_decode($privateKeyBase64, true);
|
||||
if ($privateKey === false || strlen($privateKey) !== SODIUM_CRYPTO_BOX_SECRETKEYBYTES) {
|
||||
throw new \InvalidArgumentException('Invalid private key');
|
||||
}
|
||||
$this->privateKey = $privateKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt an STMF payload
|
||||
*
|
||||
* @param string $payloadBase64 Base64-encoded STMF payload
|
||||
* @return FormData Decrypted form data
|
||||
* @throws InvalidPayloadException If the payload format is invalid
|
||||
* @throws DecryptionException If decryption fails
|
||||
*/
|
||||
public function decrypt(string $payloadBase64): FormData
|
||||
{
|
||||
// Decode base64
|
||||
$payload = base64_decode($payloadBase64, true);
|
||||
if ($payload === false) {
|
||||
throw new InvalidPayloadException('Invalid base64 payload');
|
||||
}
|
||||
|
||||
return $this->decryptRaw($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt raw STMF bytes
|
||||
*
|
||||
* @param string $payload Raw STMF bytes
|
||||
* @return FormData Decrypted form data
|
||||
*/
|
||||
public function decryptRaw(string $payload): FormData
|
||||
{
|
||||
// Verify magic
|
||||
if (strlen($payload) < 4 || substr($payload, 0, 4) !== self::MAGIC) {
|
||||
throw new InvalidPayloadException('Invalid STMF magic');
|
||||
}
|
||||
|
||||
// Parse trix container
|
||||
$trix = $this->parseTrixContainer($payload);
|
||||
|
||||
// Extract ephemeral public key from header
|
||||
if (!isset($trix['header']['ephemeral_pk'])) {
|
||||
throw new InvalidPayloadException('Missing ephemeral_pk in header');
|
||||
}
|
||||
|
||||
$ephemeralPKBase64 = $trix['header']['ephemeral_pk'];
|
||||
$ephemeralPK = base64_decode($ephemeralPKBase64, true);
|
||||
if ($ephemeralPK === false || strlen($ephemeralPK) !== SODIUM_CRYPTO_BOX_PUBLICKEYBYTES) {
|
||||
throw new InvalidPayloadException('Invalid ephemeral public key');
|
||||
}
|
||||
|
||||
// Perform X25519 ECDH key exchange
|
||||
$sharedSecret = sodium_crypto_scalarmult($this->privateKey, $ephemeralPK);
|
||||
|
||||
// Derive symmetric key using SHA-256 (same as Go implementation)
|
||||
$symmetricKey = hash('sha256', $sharedSecret, true);
|
||||
|
||||
// Decrypt the payload with ChaCha20-Poly1305
|
||||
$decrypted = $this->chachaDecrypt($trix['payload'], $symmetricKey);
|
||||
if ($decrypted === null) {
|
||||
throw new DecryptionException('Decryption failed (wrong key?)');
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
$data = json_decode($decrypted, true);
|
||||
if ($data === null) {
|
||||
throw new InvalidPayloadException('Invalid JSON in decrypted payload');
|
||||
}
|
||||
|
||||
return FormData::fromArray($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an STMF payload without decrypting
|
||||
*
|
||||
* @param string $payloadBase64 Base64-encoded STMF payload
|
||||
* @return bool True if the payload appears valid
|
||||
*/
|
||||
public function validate(string $payloadBase64): bool
|
||||
{
|
||||
try {
|
||||
$payload = base64_decode($payloadBase64, true);
|
||||
if ($payload === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strlen($payload) < 4 || substr($payload, 0, 4) !== self::MAGIC) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$trix = $this->parseTrixContainer($payload);
|
||||
return isset($trix['header']['ephemeral_pk']);
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payload info without decrypting
|
||||
*
|
||||
* @param string $payloadBase64 Base64-encoded STMF payload
|
||||
* @return array{version: ?string, algorithm: ?string, ephemeral_pk: ?string}
|
||||
*/
|
||||
public function getInfo(string $payloadBase64): array
|
||||
{
|
||||
$payload = base64_decode($payloadBase64, true);
|
||||
if ($payload === false) {
|
||||
throw new InvalidPayloadException('Invalid base64 payload');
|
||||
}
|
||||
|
||||
$trix = $this->parseTrixContainer($payload);
|
||||
|
||||
return [
|
||||
'version' => $trix['header']['version'] ?? null,
|
||||
'algorithm' => $trix['header']['algorithm'] ?? null,
|
||||
'ephemeral_pk' => $trix['header']['ephemeral_pk'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Trix container
|
||||
*
|
||||
* Enchantrix Trix format:
|
||||
* - Magic (4 bytes): "STMF"
|
||||
* - Version (4 bytes, little-endian): 2
|
||||
* - Header length (1 byte or varint)
|
||||
* - Header (JSON)
|
||||
* - Payload
|
||||
*
|
||||
* @return array{header: array, payload: string}
|
||||
*/
|
||||
private function parseTrixContainer(string $data): array
|
||||
{
|
||||
$offset = 4; // Skip magic
|
||||
|
||||
// Skip version (4 bytes)
|
||||
if (strlen($data) < $offset + 4) {
|
||||
throw new InvalidPayloadException('Payload too short for version');
|
||||
}
|
||||
$offset += 4;
|
||||
|
||||
// Read header length (varint - for now just handle 1-2 byte cases)
|
||||
if (strlen($data) < $offset + 1) {
|
||||
throw new InvalidPayloadException('Payload too short for header length');
|
||||
}
|
||||
|
||||
$firstByte = ord($data[$offset]);
|
||||
$headerLen = 0;
|
||||
|
||||
if ($firstByte < 128) {
|
||||
// Single byte length
|
||||
$headerLen = $firstByte;
|
||||
$offset += 1;
|
||||
} else {
|
||||
// Two byte length (varint continuation)
|
||||
if (strlen($data) < $offset + 2) {
|
||||
throw new InvalidPayloadException('Payload too short for header length');
|
||||
}
|
||||
$secondByte = ord($data[$offset + 1]);
|
||||
$headerLen = ($firstByte & 0x7F) | ($secondByte << 7);
|
||||
$offset += 2;
|
||||
}
|
||||
|
||||
// Read header
|
||||
if (strlen($data) < $offset + $headerLen) {
|
||||
throw new InvalidPayloadException('Payload too short for header');
|
||||
}
|
||||
|
||||
$headerJson = substr($data, $offset, $headerLen);
|
||||
$header = json_decode($headerJson, true);
|
||||
if ($header === null) {
|
||||
throw new InvalidPayloadException('Invalid header JSON: ' . json_last_error_msg());
|
||||
}
|
||||
|
||||
$offset += $headerLen;
|
||||
|
||||
// Rest is payload
|
||||
$payload = substr($data, $offset);
|
||||
|
||||
return [
|
||||
'header' => $header,
|
||||
'payload' => $payload,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data encrypted by Go's Enchantrix ChaChaPolySigil
|
||||
*
|
||||
* Enchantrix format:
|
||||
* - Nonce (24 bytes for XChaCha20-Poly1305)
|
||||
* - Ciphertext + Auth tag (16 bytes)
|
||||
*
|
||||
* Enchantrix also applies XOR pre-obfuscation before encryption.
|
||||
* After decryption, we must deobfuscate using the nonce as entropy.
|
||||
*/
|
||||
private function chachaDecrypt(string $ciphertext, string $key): ?string
|
||||
{
|
||||
$nonceLen = SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_NPUBBYTES; // 24
|
||||
|
||||
if (strlen($ciphertext) < $nonceLen + SODIUM_CRYPTO_AEAD_XCHACHA20POLY1305_IETF_ABYTES) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nonce = substr($ciphertext, 0, $nonceLen);
|
||||
$encrypted = substr($ciphertext, $nonceLen);
|
||||
|
||||
try {
|
||||
$obfuscated = sodium_crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
$encrypted,
|
||||
'', // Additional data
|
||||
$nonce,
|
||||
$key
|
||||
);
|
||||
|
||||
if ($obfuscated === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deobfuscate using XOR with nonce-derived key stream (Enchantrix pattern)
|
||||
return $this->xorDeobfuscate($obfuscated, $nonce);
|
||||
} catch (\SodiumException $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deobfuscate data using XOR with entropy-derived key stream.
|
||||
* This matches Enchantrix's XORObfuscator.
|
||||
*
|
||||
* The key stream is derived by hashing: SHA256(entropy || blockNumber)
|
||||
* for each 32-byte block needed.
|
||||
*/
|
||||
private function xorDeobfuscate(string $data, string $entropy): string
|
||||
{
|
||||
if (strlen($data) === 0) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$keyStream = $this->deriveKeyStream($entropy, strlen($data));
|
||||
$result = '';
|
||||
|
||||
for ($i = 0; $i < strlen($data); $i++) {
|
||||
$result .= chr(ord($data[$i]) ^ ord($keyStream[$i]));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a key stream from entropy using SHA-256.
|
||||
* Matches Enchantrix's XORObfuscator.deriveKeyStream.
|
||||
*/
|
||||
private function deriveKeyStream(string $entropy, int $length): string
|
||||
{
|
||||
$stream = '';
|
||||
$blockNum = 0;
|
||||
|
||||
while (strlen($stream) < $length) {
|
||||
// SHA256(entropy || blockNumber as big-endian uint64)
|
||||
$blockBytes = pack('J', $blockNum); // J = unsigned 64-bit big-endian
|
||||
$block = hash('sha256', $entropy . $blockBytes, true);
|
||||
|
||||
$copyLen = min(32, $length - strlen($stream));
|
||||
$stream .= substr($block, 0, $copyLen);
|
||||
$blockNum++;
|
||||
}
|
||||
|
||||
return $stream;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create STMF instance from a KeyPair
|
||||
*/
|
||||
public static function fromKeyPair(KeyPair $keyPair): self
|
||||
{
|
||||
return new self($keyPair->getPrivateKeyBase64());
|
||||
}
|
||||
}
|
||||
238
php/borg-stmf/tests/InteropTest.php
Normal file
238
php/borg-stmf/tests/InteropTest.php
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Borg\STMF\Tests;
|
||||
|
||||
require_once __DIR__ . '/../src/FormField.php';
|
||||
require_once __DIR__ . '/../src/FormData.php';
|
||||
require_once __DIR__ . '/../src/KeyPair.php';
|
||||
require_once __DIR__ . '/../src/DecryptionException.php';
|
||||
require_once __DIR__ . '/../src/InvalidPayloadException.php';
|
||||
require_once __DIR__ . '/../src/STMF.php';
|
||||
|
||||
use Borg\STMF\STMF;
|
||||
use Borg\STMF\KeyPair;
|
||||
|
||||
/**
|
||||
* Interoperability test - decrypts payloads encrypted by Go
|
||||
*/
|
||||
class InteropTest
|
||||
{
|
||||
private array $vectors;
|
||||
private int $passed = 0;
|
||||
private int $failed = 0;
|
||||
|
||||
public function __construct(string $vectorsFile)
|
||||
{
|
||||
$json = file_get_contents($vectorsFile);
|
||||
$this->vectors = json_decode($json, true);
|
||||
if ($this->vectors === null) {
|
||||
throw new \RuntimeException("Failed to parse test vectors: " . json_last_error_msg());
|
||||
}
|
||||
}
|
||||
|
||||
public function run(): bool
|
||||
{
|
||||
echo "Running STMF Interoperability Tests\n";
|
||||
echo "===================================\n\n";
|
||||
|
||||
foreach ($this->vectors as $vector) {
|
||||
$this->runVector($vector);
|
||||
}
|
||||
|
||||
echo "\n===================================\n";
|
||||
echo "Results: {$this->passed} passed, {$this->failed} failed\n";
|
||||
|
||||
return $this->failed === 0;
|
||||
}
|
||||
|
||||
private function runVector(array $vector): void
|
||||
{
|
||||
$name = $vector['name'];
|
||||
echo "Testing: {$name}... ";
|
||||
|
||||
try {
|
||||
// Create STMF instance with private key
|
||||
$stmf = new STMF($vector['private_key']);
|
||||
|
||||
// Decrypt the payload
|
||||
$formData = $stmf->decrypt($vector['encrypted_b64']);
|
||||
|
||||
// Verify fields
|
||||
$expectedFields = $vector['expected_fields'] ?? [];
|
||||
foreach ($expectedFields as $key => $expectedValue) {
|
||||
$actualValue = $formData->get($key);
|
||||
if ($actualValue !== $expectedValue) {
|
||||
throw new \RuntimeException(
|
||||
"Field '{$key}': expected " . json_encode($expectedValue) .
|
||||
", got " . json_encode($actualValue)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify metadata if present
|
||||
$expectedMeta = $vector['expected_meta'] ?? [];
|
||||
if ($expectedMeta) {
|
||||
$actualMeta = $formData->getMetadata();
|
||||
foreach ($expectedMeta as $key => $expectedValue) {
|
||||
$actualValue = $actualMeta[$key] ?? null;
|
||||
if ($actualValue !== $expectedValue) {
|
||||
throw new \RuntimeException(
|
||||
"Metadata '{$key}': expected " . json_encode($expectedValue) .
|
||||
", got " . json_encode($actualValue)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify field count
|
||||
$expectedCount = count($expectedFields);
|
||||
$actualCount = count($formData->fields());
|
||||
if ($actualCount !== $expectedCount) {
|
||||
throw new \RuntimeException(
|
||||
"Field count: expected {$expectedCount}, got {$actualCount}"
|
||||
);
|
||||
}
|
||||
|
||||
echo "PASS\n";
|
||||
$this->passed++;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
echo "FAIL\n";
|
||||
echo " Error: " . $e->getMessage() . "\n";
|
||||
$this->failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Additional standalone tests
|
||||
class StandaloneTests
|
||||
{
|
||||
public static function runAll(): bool
|
||||
{
|
||||
echo "\nRunning Standalone PHP Tests\n";
|
||||
echo "============================\n\n";
|
||||
|
||||
$passed = 0;
|
||||
$failed = 0;
|
||||
|
||||
// Test 1: KeyPair generation
|
||||
echo "Testing: KeyPair generation... ";
|
||||
try {
|
||||
$kp = KeyPair::generate();
|
||||
if (strlen($kp->getPublicKey()) !== 32) {
|
||||
throw new \RuntimeException("Public key wrong length");
|
||||
}
|
||||
if (strlen($kp->getPrivateKey()) !== 32) {
|
||||
throw new \RuntimeException("Private key wrong length");
|
||||
}
|
||||
echo "PASS\n";
|
||||
$passed++;
|
||||
} catch (\Exception $e) {
|
||||
echo "FAIL: " . $e->getMessage() . "\n";
|
||||
$failed++;
|
||||
}
|
||||
|
||||
// Test 2: KeyPair from private key
|
||||
echo "Testing: KeyPair from private key... ";
|
||||
try {
|
||||
$kp1 = KeyPair::generate();
|
||||
$kp2 = KeyPair::fromPrivateKeyBase64($kp1->getPrivateKeyBase64());
|
||||
if ($kp1->getPublicKeyBase64() !== $kp2->getPublicKeyBase64()) {
|
||||
throw new \RuntimeException("Public keys don't match");
|
||||
}
|
||||
echo "PASS\n";
|
||||
$passed++;
|
||||
} catch (\Exception $e) {
|
||||
echo "FAIL: " . $e->getMessage() . "\n";
|
||||
$failed++;
|
||||
}
|
||||
|
||||
// Test 3: Invalid payload validation
|
||||
echo "Testing: Invalid payload detection... ";
|
||||
try {
|
||||
$kp = KeyPair::generate();
|
||||
$stmf = STMF::fromKeyPair($kp);
|
||||
$isValid = $stmf->validate("not-valid-base64!!!");
|
||||
if ($isValid) {
|
||||
throw new \RuntimeException("Should have rejected invalid payload");
|
||||
}
|
||||
$isValid2 = $stmf->validate(base64_encode("FAKE" . str_repeat("\x00", 100)));
|
||||
if ($isValid2) {
|
||||
throw new \RuntimeException("Should have rejected fake STMF");
|
||||
}
|
||||
echo "PASS\n";
|
||||
$passed++;
|
||||
} catch (\Exception $e) {
|
||||
echo "FAIL: " . $e->getMessage() . "\n";
|
||||
$failed++;
|
||||
}
|
||||
|
||||
// Test 4: FormData methods
|
||||
echo "Testing: FormData methods... ";
|
||||
try {
|
||||
$fields = [
|
||||
\Borg\STMF\FormField::fromArray(['name' => 'email', 'value' => 'test@test.com']),
|
||||
\Borg\STMF\FormField::fromArray(['name' => 'tag', 'value' => 'one']),
|
||||
\Borg\STMF\FormField::fromArray(['name' => 'tag', 'value' => 'two']),
|
||||
];
|
||||
$fd = new \Borg\STMF\FormData($fields, ['origin' => 'https://example.com']);
|
||||
|
||||
if ($fd->get('email') !== 'test@test.com') {
|
||||
throw new \RuntimeException("get() failed");
|
||||
}
|
||||
if (!$fd->has('email')) {
|
||||
throw new \RuntimeException("has() failed");
|
||||
}
|
||||
if ($fd->has('nonexistent')) {
|
||||
throw new \RuntimeException("has() false positive");
|
||||
}
|
||||
|
||||
$tags = $fd->getAll('tag');
|
||||
if (count($tags) !== 2 || $tags[0] !== 'one' || $tags[1] !== 'two') {
|
||||
throw new \RuntimeException("getAll() failed");
|
||||
}
|
||||
|
||||
if ($fd->getOrigin() !== 'https://example.com') {
|
||||
throw new \RuntimeException("getOrigin() failed");
|
||||
}
|
||||
|
||||
echo "PASS\n";
|
||||
$passed++;
|
||||
} catch (\Exception $e) {
|
||||
echo "FAIL: " . $e->getMessage() . "\n";
|
||||
$failed++;
|
||||
}
|
||||
|
||||
echo "\n============================\n";
|
||||
echo "Standalone: {$passed} passed, {$failed} failed\n";
|
||||
|
||||
return $failed === 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Run tests
|
||||
if (php_sapi_name() === 'cli') {
|
||||
$vectorsFile = __DIR__ . '/test_vectors.json';
|
||||
|
||||
if (!file_exists($vectorsFile)) {
|
||||
echo "Error: test_vectors.json not found.\n";
|
||||
echo "Generate it with: go run tests/generate_test_vectors.go > tests/test_vectors.json\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// Check sodium extension
|
||||
if (!extension_loaded('sodium')) {
|
||||
echo "Error: sodium extension not loaded.\n";
|
||||
echo "Enable it in php.ini or install php-sodium.\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$interop = new InteropTest($vectorsFile);
|
||||
$interopPassed = $interop->run();
|
||||
|
||||
$standalonePassed = StandaloneTests::runAll();
|
||||
|
||||
exit(($interopPassed && $standalonePassed) ? 0 : 1);
|
||||
}
|
||||
159
php/borg-stmf/tests/generate_test_vectors.go
Normal file
159
php/borg-stmf/tests/generate_test_vectors.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
// +build ignore
|
||||
|
||||
// This program generates test vectors for PHP interoperability testing.
|
||||
// Run with: go run generate_test_vectors.go > test_vectors.json
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Snider/Borg/pkg/stmf"
|
||||
)
|
||||
|
||||
type TestVector struct {
|
||||
Name string `json:"name"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
PublicKey string `json:"public_key"`
|
||||
EncryptedB64 string `json:"encrypted_b64"`
|
||||
ExpectedFields map[string]string `json:"expected_fields"`
|
||||
ExpectedMeta map[string]string `json:"expected_meta"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var vectors []TestVector
|
||||
|
||||
// Test 1: Simple form with two fields
|
||||
{
|
||||
kp, _ := stmf.GenerateKeyPair()
|
||||
formData := stmf.NewFormData().
|
||||
AddField("email", "test@example.com").
|
||||
AddFieldWithType("password", "secret123", "password")
|
||||
|
||||
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
|
||||
|
||||
vectors = append(vectors, TestVector{
|
||||
Name: "simple_form",
|
||||
PrivateKey: kp.PrivateKeyBase64(),
|
||||
PublicKey: kp.PublicKeyBase64(),
|
||||
EncryptedB64: encrypted,
|
||||
ExpectedFields: map[string]string{
|
||||
"email": "test@example.com",
|
||||
"password": "secret123",
|
||||
},
|
||||
ExpectedMeta: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// Test 2: Form with metadata
|
||||
{
|
||||
kp, _ := stmf.GenerateKeyPair()
|
||||
formData := stmf.NewFormData().
|
||||
AddField("username", "johndoe").
|
||||
AddField("action", "login").
|
||||
SetMetadata("origin", "https://example.com").
|
||||
SetMetadata("timestamp", "1735265000")
|
||||
|
||||
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
|
||||
|
||||
vectors = append(vectors, TestVector{
|
||||
Name: "form_with_metadata",
|
||||
PrivateKey: kp.PrivateKeyBase64(),
|
||||
PublicKey: kp.PublicKeyBase64(),
|
||||
EncryptedB64: encrypted,
|
||||
ExpectedFields: map[string]string{
|
||||
"username": "johndoe",
|
||||
"action": "login",
|
||||
},
|
||||
ExpectedMeta: map[string]string{
|
||||
"origin": "https://example.com",
|
||||
"timestamp": "1735265000",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Test 3: Unicode content
|
||||
{
|
||||
kp, _ := stmf.GenerateKeyPair()
|
||||
formData := stmf.NewFormData().
|
||||
AddField("name", "日本語テスト").
|
||||
AddField("emoji", "🔐🛡️✅").
|
||||
AddField("mixed", "Hello 世界 مرحبا")
|
||||
|
||||
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
|
||||
|
||||
vectors = append(vectors, TestVector{
|
||||
Name: "unicode_content",
|
||||
PrivateKey: kp.PrivateKeyBase64(),
|
||||
PublicKey: kp.PublicKeyBase64(),
|
||||
EncryptedB64: encrypted,
|
||||
ExpectedFields: map[string]string{
|
||||
"name": "日本語テスト",
|
||||
"emoji": "🔐🛡️✅",
|
||||
"mixed": "Hello 世界 مرحبا",
|
||||
},
|
||||
ExpectedMeta: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// Test 4: Large form with many fields
|
||||
{
|
||||
kp, _ := stmf.GenerateKeyPair()
|
||||
formData := stmf.NewFormData()
|
||||
expectedFields := make(map[string]string)
|
||||
|
||||
for i := 0; i < 20; i++ {
|
||||
key := fmt.Sprintf("field_%d", i)
|
||||
value := fmt.Sprintf("value_%d_with_some_content", i)
|
||||
formData.AddField(key, value)
|
||||
expectedFields[key] = value
|
||||
}
|
||||
|
||||
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
|
||||
|
||||
vectors = append(vectors, TestVector{
|
||||
Name: "large_form",
|
||||
PrivateKey: kp.PrivateKeyBase64(),
|
||||
PublicKey: kp.PublicKeyBase64(),
|
||||
EncryptedB64: encrypted,
|
||||
ExpectedFields: expectedFields,
|
||||
ExpectedMeta: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// Test 5: Special characters
|
||||
{
|
||||
kp, _ := stmf.GenerateKeyPair()
|
||||
formData := stmf.NewFormData().
|
||||
AddField("sql", "'; DROP TABLE users; --").
|
||||
AddField("html", "<script>alert('xss')</script>").
|
||||
AddField("json", `{"key": "value", "nested": {"a": 1}}`).
|
||||
AddField("newlines", "line1\nline2\nline3")
|
||||
|
||||
encrypted, _ := stmf.EncryptBase64(formData, kp.PublicKey())
|
||||
|
||||
vectors = append(vectors, TestVector{
|
||||
Name: "special_characters",
|
||||
PrivateKey: kp.PrivateKeyBase64(),
|
||||
PublicKey: kp.PublicKeyBase64(),
|
||||
EncryptedB64: encrypted,
|
||||
ExpectedFields: map[string]string{
|
||||
"sql": "'; DROP TABLE users; --",
|
||||
"html": "<script>alert('xss')</script>",
|
||||
"json": `{"key": "value", "nested": {"a": 1}}`,
|
||||
"newlines": "line1\nline2\nline3",
|
||||
},
|
||||
ExpectedMeta: nil,
|
||||
})
|
||||
}
|
||||
|
||||
// Output as JSON
|
||||
output, err := json.MarshalIndent(vectors, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println(string(output))
|
||||
}
|
||||
81
php/borg-stmf/tests/test_vectors.json
Normal file
81
php/borg-stmf/tests/test_vectors.json
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
[
|
||||
{
|
||||
"name": "simple_form",
|
||||
"private_key": "cHSFC/ZN/whRWfQSHMHvQcEQgNm8VLPqr3FGW9pIUWw=",
|
||||
"public_key": "9N840/Td0GTeGrmCip+4o/iftrh11l5IsxeUr4M3vzU=",
|
||||
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6IjdYeTlGdWZSUkdJYW1WdTBEbE5VM2dGQWEveFdFZVVjWU9TVWRVN1NLSHc9IiwidmVyc2lvbiI6IjEuMCJ9WfRt459u9b3sGFhx5JaxQ3Nr1sVVy7Mebr4NnqfzX6GhQzs8iLZuF7EbeyY0auSBrgHIH3WBrvPj2H0rr7gnmIMesIRRs6HWR76vkvAb1FfbC6MOArduGfBK6edKaejtdC7rD9NtgpHaEEruNTE1e7SRQFF41ufu97+OqwfuyIMVyICmlvgW7ln+T6/PwMnhHf8dZ+rksc7SFnhwt5akBBxXOUbVgEvz",
|
||||
"expected_fields": {
|
||||
"email": "test@example.com",
|
||||
"password": "secret123"
|
||||
},
|
||||
"expected_meta": null
|
||||
},
|
||||
{
|
||||
"name": "form_with_metadata",
|
||||
"private_key": "NVYNU8Ruc0aG0Yh8YHZfLASlH0xeCZXRJ3rP4WiQ5t4=",
|
||||
"public_key": "0nJS0TpZPk/oaEAwpbPKbRboTIBa7qkeYRIGmE1A61Q=",
|
||||
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6ImM3WCtDVDBOeDRDWFVjOWJHVEhwWFNZQ0dWckswbWZMRmhLWFhWT1pSbTg9IiwidmVyc2lvbiI6IjEuMCJ9y+1OD5VnQmBqck8tM3vs1fBznR6ZhK/nGFXVoxlC5jZjpBgAbExrb4AKgBmkvM8t+oVWIH2lyRfKArEJFalqm8H+Gv/OoebUSR0qwoHlYUaIGU6JAbNb5gEmrsJBTz7E2/FCdILokVaWNicu6p9eCye2OH4lEWNCGI4WTZJZMO9N45Kqj3UUqNm9dAar3hpYKcezvbSWpM1OjUBO9F5ye0tnIiqinJvlFmkzfKpBz0mseWE1QL8BcnbUcaSgaTSGxs1jDq4JrUDMtPODeoxgTCFju0GTJfmo9g==",
|
||||
"expected_fields": {
|
||||
"action": "login",
|
||||
"username": "johndoe"
|
||||
},
|
||||
"expected_meta": {
|
||||
"origin": "https://example.com",
|
||||
"timestamp": "1735265000"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "unicode_content",
|
||||
"private_key": "p9JUNyRQYhQef7tNse3rUAIxyntRjnv3DPCqiKobg0Y=",
|
||||
"public_key": "V2V27btYoVYui/6O3U/v24xu6g0Rsz3x8NHyOre2sFc=",
|
||||
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6IlhxZnZSQlBpbGt4dWc3S0k3ZWFGWGNMQkJQaHZOb2dZa0JOM2xra1E2REk9IiwidmVyc2lvbiI6IjEuMCJ97lEfIzlKEs3MxXPb+taqw1QFPoEp8U/+WjOY8PFNYUXBFstbfybSFzXakthOARFcRU1RzoiHG+mWlGdwcBdcVWhodZDZj0C6NSMqLVx3bZLIoGlzN7v5N+b+xs+ApVQpl3x4LqbML6Jj5zhisTlaoEmMld+FeH8zRmp7a+FbNfMBM9V+IIRY6p3nPeo8czmyrwyrGscDnUkFaThdv7D2v1kFOFc5EOlfzaFPsDLq+ewa5OkGpEYnEu8UM+B122fzhHEr7sOUjHOk0RSkC33hNOWzmcs5SYsc2GEp86f/aeYp6SyiKUNHFg==",
|
||||
"expected_fields": {
|
||||
"emoji": "🔐🛡️✅",
|
||||
"mixed": "Hello 世界 مرحبا",
|
||||
"name": "日本語テスト"
|
||||
},
|
||||
"expected_meta": null
|
||||
},
|
||||
{
|
||||
"name": "large_form",
|
||||
"private_key": "6ZEQmKTUiojvQumqRdJsFcm91tz21QYgfvetxXO/VGs=",
|
||||
"public_key": "iMk1wvMZc3fr8WqAqiPPZj/9x0pZuGh8kTECGqAJKQI=",
|
||||
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6ImJad2w1ZjI1SjBJdmFBb0lXWUZlWjhIVkFFU1lhYktWdXVSeDFXTW53RU09IiwidmVyc2lvbiI6IjEuMCJ9QbTXLryMbYSnNJV0TPUpFkgArf1PkA0vx/R0Ml5VIXxuZuMayx8mE2GVSvPlYDp5UgIndl5uQ/oA9Mx0rqOpIQozRdPUHFKA98c3XRYQqhiWguBRpLpFtV3HKgtPdfPtS+rq2/jPICH+DKyLOCYVVHK+/E2b/39wjBHWTP1XuSHbU0bdS7+YkXyjh2xzrsS3HvVL3YbPN11Q1thizT9C2WxiKfetmwig480a8lJV8ej5ew9303iEGqhtRArxgHAI19syFQHkOVJBYQh83/B3HcPoF5TKuVelZ6FvB+/HETpbfaL3rsB0zZLR+PZn2X+MM4wsvPkWf+VcRTQjEXJKlQQAuy5rrgLg0LAOQbko7zKjfCL4MPyrYx/Mc4Ht4OyIxssQUYh5FV6+kCRlh/abu5XVuyv4lswG0SCzA5eYPmDGlZHJuRP3gcpKkwS92/X0NYZjxPPhZStKcPf5NOBrXmAtQoWdwV0i7aZBvNipBvJ3EUhkSJoKhYV4g4TSOcHS69RNCL8z7qg/7MFfihJbklDuSyEZCV7DW+6SJggdyEfs5e0e9Hf8fXGBsGophfNorh8vypV4KA0spnpKHLwuCWxVaYUX5vpFupPWo546M8JunvTJf80bPiL8neEDqkWkxXDda1K+c7JHsIrz2iOdDN/6qYTYMr2F+vXF+GERBHc2qMfH7EdzgCB6DqgxDWEXAGCbrnUJTIaew3CPaeU49ECDy9UlRLh4cFuRNEXsDYm7ALjThwulONU8BUoYhIcKuXowV0OEr6bSogF5CmrdwwUCrHNxDBJA3lP7/oBSSoj1FOVf3e2GGSmLJml1OiunTbW5voiFmiQdwhWkYcrmBr0ZMj6BPOSirPhsjNCqxML65VnOpsengPr0MUHL/bXUPUp0sqGHgRTViS7OvN4WxH1otOjzCAV1EdyAKIE4pcPSP6x6SZV4AIEjcSiBy08UaryL5UpO8Q8aLbu0hQ7ftMGF4SK0hqZxncTV46s67yQI5EE09qqefLHtV4N5FwD3t28FFmF9Bkmm6H2lChTFBenLE4nBDzHjHME9z/9xe1ryZzbX9DpSpH8LngdeE+5pZi72KhMHGy6r8tFJJBNMsOPnoYAhYFiAK817yVWgYK/W4SDksL4MyN3pYmX9hmug0b18TV6QwoDIvApio3zcGICUPD804xYuE/7rUjJQLlp/bj9049lu8AZaaYNPF39jvJMQqU0+dZ1OfBhCkkbaUza0Vv+YIEnJ+ZicKdpf5X4Vbltpgw4CDhNXBVNo9GmQDHzHhgapnWmkQQHn9eOlTgjUB+M2AvCwzpx3aHO+6QfiRVj/Xf9U6UMWoSp2ui+C8u2YWvRgvs+BTJyeq5fNbxvLgvT7l24I6RKMl4JzPcpgo1ysaXtPsBbO1Lz6wa3vIazVsX4bax0b2GVRyfW1g/SL2PggP8R0ob5zj7K2PSakVwOIMHw9NiUcVgrY2o8LYml+VeKZJFA3SUkseFN1gH9UYeLsnj+rmAs/n5V8XVPbuklBX8eIIt0E1ZiDz6eQuWnKJzKiwvGhALtG9vgTyIpFNgdlg7dWBcw/GBZNvXDXgwKDe+ypPU2XObErVTF29NEV0vE7gH5j727ZfgHsiiQWq3Qt2GGuMMsPA+m3xJ6NtAvoe9td6hpP6CMdSv++5N3tXKjauf3fpaeJetxS+AfW+8PT5gBFN5U6cblUQ3igTZs2dRQt4gCK1toBKo8k51ikL3zAxNeGdhS9j+z/0eavkEs8df2cWZ2FZ5OF5VaMOH+XzL/JwpHep5yJfO9IuHp+tejGLs9H9e+x69rXHqB9TkZtcrYSm9ChO3L9olZGaZWWgRnYdyT1cwv76Rdvv11K0kiPmmuBQSRQe1ri/G2+mh+WxAny3gBc4ElWQOhUF+J4A9F31gSKhcUIOB1ZaApuXMswBpr8RYHr",
|
||||
"expected_fields": {
|
||||
"field_0": "value_0_with_some_content",
|
||||
"field_1": "value_1_with_some_content",
|
||||
"field_10": "value_10_with_some_content",
|
||||
"field_11": "value_11_with_some_content",
|
||||
"field_12": "value_12_with_some_content",
|
||||
"field_13": "value_13_with_some_content",
|
||||
"field_14": "value_14_with_some_content",
|
||||
"field_15": "value_15_with_some_content",
|
||||
"field_16": "value_16_with_some_content",
|
||||
"field_17": "value_17_with_some_content",
|
||||
"field_18": "value_18_with_some_content",
|
||||
"field_19": "value_19_with_some_content",
|
||||
"field_2": "value_2_with_some_content",
|
||||
"field_3": "value_3_with_some_content",
|
||||
"field_4": "value_4_with_some_content",
|
||||
"field_5": "value_5_with_some_content",
|
||||
"field_6": "value_6_with_some_content",
|
||||
"field_7": "value_7_with_some_content",
|
||||
"field_8": "value_8_with_some_content",
|
||||
"field_9": "value_9_with_some_content"
|
||||
},
|
||||
"expected_meta": null
|
||||
},
|
||||
{
|
||||
"name": "special_characters",
|
||||
"private_key": "bET5cDIamjtYKBGJSSAekIrH2mw54YamHCtyrWOSMlw=",
|
||||
"public_key": "umOQqu/3cBBs3PeWs4vQiG3DxNARWlOJfrtG65RQdkk=",
|
||||
"encrypted_b64": "U1RNRgIAAAB1eyJhbGdvcml0aG0iOiJ4MjU1MTktY2hhY2hhMjBwb2x5MTMwNSIsImVwaGVtZXJhbF9wayI6Ik9nVnF4ZERJcnhsd2t2REdlQ3BtYVNUS0RhNkJQZFBMTm9Bek4yV2RJMDQ9IiwidmVyc2lvbiI6IjEuMCJ9fMvVueLCedw67bt7iLXJvZE66NOaPfwu2TsmN3sseXGu5M2csgYN+ngT6/XcMzAl7MHc3sHsvUE7H2MHTlhLih46WiyTzpXvfmzZHv3/t6oBxDwcm8O++ksCuuOnhHAfh65340/EuEMl8Da71zF00cFyuIvAmJFaBtn3Tlj6wd+9jfORuxv1TeRKLtMIojbLl9hZv25UHJSgjrlGL3kYdpg6WLqVKwz81P5RP10LbCQzHmH+4/lIAaFZAzf9mHcIHGj3ytKuDXDe1GhEhkUCK6Pe7PGuojIglhXMj9mijvlh2TCyxGQDw0VhCBCjVyWkWSPnTTb0bQOs3WLpGHN+XLgewVdSRTLbGy1t0PEkhjNftca8mLd4qEcSHzlFphGpN0u4R+Am0VSV44cqYDjeDKZnYyuBGu+ti6h+33AHfJU8EWzSMJXJEhP9Uqb0OuFyzfc7tVgGVAeEOBxGo/nyzPzi",
|
||||
"expected_fields": {
|
||||
"html": "\u003cscript\u003ealert('xss')\u003c/script\u003e",
|
||||
"json": "{\"key\": \"value\", \"nested\": {\"a\": 1}}",
|
||||
"newlines": "line1\nline2\nline3",
|
||||
"sql": "'; DROP TABLE users; --"
|
||||
},
|
||||
"expected_meta": null
|
||||
}
|
||||
]
|
||||
218
pkg/smsg/smsg.go
Normal file
218
pkg/smsg/smsg.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
package smsg
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/Snider/Enchantrix/pkg/enchantrix"
|
||||
"github.com/Snider/Enchantrix/pkg/trix"
|
||||
)
|
||||
|
||||
// DeriveKey derives a 32-byte key from a password using SHA-256.
|
||||
func DeriveKey(password string) []byte {
|
||||
hash := sha256.Sum256([]byte(password))
|
||||
return hash[:]
|
||||
}
|
||||
|
||||
// Encrypt encrypts a message with a password.
|
||||
// Returns the encrypted SMSG container bytes.
|
||||
func Encrypt(msg *Message, password string) ([]byte, error) {
|
||||
if password == "" {
|
||||
return nil, ErrPasswordRequired
|
||||
}
|
||||
if msg.Body == "" && len(msg.Attachments) == 0 {
|
||||
return nil, ErrEmptyMessage
|
||||
}
|
||||
|
||||
// Set timestamp if not set
|
||||
if msg.Timestamp == 0 {
|
||||
msg.Timestamp = time.Now().Unix()
|
||||
}
|
||||
|
||||
// Serialize message to JSON
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
// Derive key and create sigil
|
||||
key := DeriveKey(password)
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
encrypted, err := sigil.In(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encryption failed: %w", err)
|
||||
}
|
||||
|
||||
// Create container header
|
||||
headerMap := map[string]interface{}{
|
||||
"version": Version,
|
||||
"algorithm": "chacha20poly1305",
|
||||
}
|
||||
|
||||
// Create trix container
|
||||
t := &trix.Trix{
|
||||
Header: headerMap,
|
||||
Payload: encrypted,
|
||||
}
|
||||
|
||||
return trix.Encode(t, Magic, nil)
|
||||
}
|
||||
|
||||
// EncryptBase64 encrypts and returns base64-encoded result
|
||||
func EncryptBase64(msg *Message, password string) (string, error) {
|
||||
encrypted, err := Encrypt(msg, password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
// EncryptWithHint encrypts with an optional password hint in the header
|
||||
func EncryptWithHint(msg *Message, password, hint string) ([]byte, error) {
|
||||
if password == "" {
|
||||
return nil, ErrPasswordRequired
|
||||
}
|
||||
if msg.Body == "" && len(msg.Attachments) == 0 {
|
||||
return nil, ErrEmptyMessage
|
||||
}
|
||||
|
||||
if msg.Timestamp == 0 {
|
||||
msg.Timestamp = time.Now().Unix()
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
key := DeriveKey(password)
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
encrypted, err := sigil.In(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encryption failed: %w", err)
|
||||
}
|
||||
|
||||
headerMap := map[string]interface{}{
|
||||
"version": Version,
|
||||
"algorithm": "chacha20poly1305",
|
||||
}
|
||||
if hint != "" {
|
||||
headerMap["hint"] = hint
|
||||
}
|
||||
|
||||
t := &trix.Trix{
|
||||
Header: headerMap,
|
||||
Payload: encrypted,
|
||||
}
|
||||
|
||||
return trix.Encode(t, Magic, nil)
|
||||
}
|
||||
|
||||
// Decrypt decrypts an SMSG container with a password
|
||||
func Decrypt(data []byte, password string) (*Message, error) {
|
||||
if password == "" {
|
||||
return nil, ErrPasswordRequired
|
||||
}
|
||||
|
||||
// Decode trix container
|
||||
t, err := trix.Decode(data, Magic, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidMagic, err)
|
||||
}
|
||||
|
||||
// Derive key and create sigil
|
||||
key := DeriveKey(password)
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
decrypted, err := sigil.Out(t.Payload)
|
||||
if err != nil {
|
||||
return nil, ErrDecryptionFailed
|
||||
}
|
||||
|
||||
// Parse message
|
||||
var msg Message
|
||||
if err := json.Unmarshal(decrypted, &msg); err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid message format", ErrInvalidPayload)
|
||||
}
|
||||
|
||||
return &msg, nil
|
||||
}
|
||||
|
||||
// DecryptBase64 decrypts a base64-encoded SMSG
|
||||
func DecryptBase64(encoded, password string) (*Message, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid base64", ErrInvalidPayload)
|
||||
}
|
||||
return Decrypt(data, password)
|
||||
}
|
||||
|
||||
// GetInfo extracts header info without decrypting
|
||||
func GetInfo(data []byte) (*Header, error) {
|
||||
t, err := trix.Decode(data, Magic, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidMagic, err)
|
||||
}
|
||||
|
||||
header := &Header{}
|
||||
if v, ok := t.Header["version"].(string); ok {
|
||||
header.Version = v
|
||||
}
|
||||
if v, ok := t.Header["algorithm"].(string); ok {
|
||||
header.Algorithm = v
|
||||
}
|
||||
if v, ok := t.Header["hint"].(string); ok {
|
||||
header.Hint = v
|
||||
}
|
||||
|
||||
return header, nil
|
||||
}
|
||||
|
||||
// GetInfoBase64 extracts header info from base64-encoded SMSG
|
||||
func GetInfoBase64(encoded string) (*Header, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid base64", ErrInvalidPayload)
|
||||
}
|
||||
return GetInfo(data)
|
||||
}
|
||||
|
||||
// Validate checks if data is a valid SMSG container (without decrypting)
|
||||
func Validate(data []byte) error {
|
||||
_, err := trix.Decode(data, Magic, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidMagic, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuickEncrypt is a convenience function for simple message encryption
|
||||
func QuickEncrypt(body, password string) (string, error) {
|
||||
msg := NewMessage(body)
|
||||
return EncryptBase64(msg, password)
|
||||
}
|
||||
|
||||
// QuickDecrypt is a convenience function for simple message decryption
|
||||
func QuickDecrypt(encoded, password string) (string, error) {
|
||||
msg, err := DecryptBase64(encoded, password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return msg.Body, nil
|
||||
}
|
||||
270
pkg/smsg/smsg_test.go
Normal file
270
pkg/smsg/smsg_test.go
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
package smsg
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
msg := NewMessage("Hello, this is a secure message!").
|
||||
WithSubject("Test Subject").
|
||||
WithFrom("support@example.com")
|
||||
|
||||
password := "supersecret123"
|
||||
|
||||
encrypted, err := Encrypt(msg, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(encrypted, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.Body != msg.Body {
|
||||
t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body)
|
||||
}
|
||||
if decrypted.Subject != msg.Subject {
|
||||
t.Errorf("Subject = %q, want %q", decrypted.Subject, msg.Subject)
|
||||
}
|
||||
if decrypted.From != msg.From {
|
||||
t.Errorf("From = %q, want %q", decrypted.From, msg.From)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBase64RoundTrip(t *testing.T) {
|
||||
msg := NewMessage("Base64 test message")
|
||||
password := "testpass"
|
||||
|
||||
encryptedB64, err := EncryptBase64(msg, password)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptBase64 failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be valid base64
|
||||
if _, err := base64.StdEncoding.DecodeString(encryptedB64); err != nil {
|
||||
t.Fatalf("Invalid base64: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := DecryptBase64(encryptedB64, password)
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptBase64 failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.Body != msg.Body {
|
||||
t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithAttachments(t *testing.T) {
|
||||
fileContent := base64.StdEncoding.EncodeToString([]byte("Hello, World!"))
|
||||
|
||||
msg := NewMessage("Please see the attached file.").
|
||||
AddAttachment("hello.txt", fileContent, "text/plain").
|
||||
AddAttachment("data.json", base64.StdEncoding.EncodeToString([]byte(`{"key":"value"}`)), "application/json")
|
||||
|
||||
password := "attachtest"
|
||||
|
||||
encrypted, err := Encrypt(msg, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(encrypted, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if len(decrypted.Attachments) != 2 {
|
||||
t.Fatalf("Attachments count = %d, want 2", len(decrypted.Attachments))
|
||||
}
|
||||
|
||||
att := decrypted.GetAttachment("hello.txt")
|
||||
if att == nil {
|
||||
t.Fatal("Attachment hello.txt not found")
|
||||
}
|
||||
if att.Content != fileContent {
|
||||
t.Error("Attachment content mismatch")
|
||||
}
|
||||
if att.MimeType != "text/plain" {
|
||||
t.Errorf("MimeType = %q, want %q", att.MimeType, "text/plain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithReplyKey(t *testing.T) {
|
||||
msg := NewMessage("Here's a public key for your reply.").
|
||||
WithReplyKey("dGVzdHB1YmxpY2tleWJhc2U2NA==")
|
||||
|
||||
password := "pki-test"
|
||||
|
||||
encrypted, err := Encrypt(msg, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(encrypted, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.ReplyKey == nil {
|
||||
t.Fatal("ReplyKey is nil")
|
||||
}
|
||||
if decrypted.ReplyKey.PublicKey != "dGVzdHB1YmxpY2tleWJhc2U2NA==" {
|
||||
t.Error("ReplyKey.PublicKey mismatch")
|
||||
}
|
||||
if decrypted.ReplyKey.Algorithm != "x25519" {
|
||||
t.Errorf("Algorithm = %q, want %q", decrypted.ReplyKey.Algorithm, "x25519")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithHint(t *testing.T) {
|
||||
msg := NewMessage("Password hint test")
|
||||
password := "birthday1990"
|
||||
hint := "Your birthday year"
|
||||
|
||||
encrypted, err := EncryptWithHint(msg, password, hint)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptWithHint failed: %v", err)
|
||||
}
|
||||
|
||||
// Get info should include hint
|
||||
info, err := GetInfo(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("GetInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if info.Hint != hint {
|
||||
t.Errorf("Hint = %q, want %q", info.Hint, hint)
|
||||
}
|
||||
|
||||
// Should still decrypt
|
||||
decrypted, err := Decrypt(encrypted, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.Body != msg.Body {
|
||||
t.Error("Body mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongPassword(t *testing.T) {
|
||||
msg := NewMessage("Secret message")
|
||||
password := "correct-password"
|
||||
|
||||
encrypted, err := Encrypt(msg, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = Decrypt(encrypted, "wrong-password")
|
||||
if err == nil {
|
||||
t.Error("Decrypt with wrong password should have failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickFunctions(t *testing.T) {
|
||||
body := "Quick test message"
|
||||
password := "quickpass"
|
||||
|
||||
encrypted, err := QuickEncrypt(body, password)
|
||||
if err != nil {
|
||||
t.Fatalf("QuickEncrypt failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := QuickDecrypt(encrypted, password)
|
||||
if err != nil {
|
||||
t.Fatalf("QuickDecrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted != body {
|
||||
t.Errorf("Decrypted = %q, want %q", decrypted, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnicodeContent(t *testing.T) {
|
||||
msg := NewMessage("日本語メッセージ 🔐 مرحبا").
|
||||
WithSubject("Unicode テスト").
|
||||
WithFrom("サポート")
|
||||
|
||||
password := "unicode-test"
|
||||
|
||||
encrypted, err := Encrypt(msg, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(encrypted, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.Body != msg.Body {
|
||||
t.Errorf("Body = %q, want %q", decrypted.Body, msg.Body)
|
||||
}
|
||||
if decrypted.Subject != msg.Subject {
|
||||
t.Errorf("Subject = %q, want %q", decrypted.Subject, msg.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetadata(t *testing.T) {
|
||||
msg := NewMessage("Message with metadata").
|
||||
SetMeta("ticket_id", "12345").
|
||||
SetMeta("priority", "high")
|
||||
|
||||
password := "meta-test"
|
||||
|
||||
encrypted, err := Encrypt(msg, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(encrypted, password)
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.Meta["ticket_id"] != "12345" {
|
||||
t.Error("ticket_id metadata mismatch")
|
||||
}
|
||||
if decrypted.Meta["priority"] != "high" {
|
||||
t.Error("priority metadata mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
msg := NewMessage("Test")
|
||||
password := "test"
|
||||
|
||||
encrypted, _ := Encrypt(msg, password)
|
||||
|
||||
// Valid SMSG should pass
|
||||
if err := Validate(encrypted); err != nil {
|
||||
t.Errorf("Validate failed for valid SMSG: %v", err)
|
||||
}
|
||||
|
||||
// Invalid data should fail
|
||||
if err := Validate([]byte("not an smsg")); err == nil {
|
||||
t.Error("Validate should fail for invalid data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyPasswordError(t *testing.T) {
|
||||
msg := NewMessage("Test")
|
||||
|
||||
_, err := Encrypt(msg, "")
|
||||
if err != ErrPasswordRequired {
|
||||
t.Errorf("Expected ErrPasswordRequired, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyMessageError(t *testing.T) {
|
||||
msg := &Message{}
|
||||
|
||||
_, err := Encrypt(msg, "password")
|
||||
if err != ErrEmptyMessage {
|
||||
t.Errorf("Expected ErrEmptyMessage, got %v", err)
|
||||
}
|
||||
}
|
||||
136
pkg/smsg/types.go
Normal file
136
pkg/smsg/types.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// Package smsg implements Secure Message encryption using password-based ChaCha20-Poly1305.
|
||||
// SMSG (Secure Message) enables encrypted message exchange where the recipient
|
||||
// decrypts using a pre-shared password. Useful for secure support replies,
|
||||
// confidential documents, and any scenario requiring password-protected content.
|
||||
package smsg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Magic bytes for SMSG format
|
||||
const Magic = "SMSG"
|
||||
|
||||
// Version of the SMSG format
|
||||
const Version = "1.0"
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrInvalidMagic = errors.New("invalid SMSG magic")
|
||||
ErrInvalidPayload = errors.New("invalid SMSG payload")
|
||||
ErrDecryptionFailed = errors.New("decryption failed (wrong password?)")
|
||||
ErrPasswordRequired = errors.New("password is required")
|
||||
ErrEmptyMessage = errors.New("message cannot be empty")
|
||||
)
|
||||
|
||||
// Attachment represents a file attached to the message
|
||||
type Attachment struct {
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"` // base64-encoded
|
||||
MimeType string `json:"mime,omitempty"`
|
||||
Size int `json:"size,omitempty"`
|
||||
}
|
||||
|
||||
// PKIInfo contains public key information for authenticated replies
|
||||
type PKIInfo struct {
|
||||
PublicKey string `json:"public_key"` // base64-encoded X25519 public key
|
||||
KeyID string `json:"key_id,omitempty"` // optional key identifier
|
||||
Algorithm string `json:"algorithm,omitempty"` // e.g., "x25519"
|
||||
Fingerprint string `json:"fingerprint,omitempty"` // SHA256 fingerprint of public key
|
||||
}
|
||||
|
||||
// Message represents the decrypted message content
|
||||
type Message struct {
|
||||
// Core message content
|
||||
Subject string `json:"subject,omitempty"`
|
||||
Body string `json:"body"`
|
||||
|
||||
// Optional attachments
|
||||
Attachments []Attachment `json:"attachments,omitempty"`
|
||||
|
||||
// PKI for authenticated replies
|
||||
ReplyKey *PKIInfo `json:"reply_key,omitempty"`
|
||||
|
||||
// Metadata
|
||||
From string `json:"from,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// NewMessage creates a new message with the given body
|
||||
func NewMessage(body string) *Message {
|
||||
return &Message{
|
||||
Body: body,
|
||||
Meta: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// WithSubject sets the message subject
|
||||
func (m *Message) WithSubject(subject string) *Message {
|
||||
m.Subject = subject
|
||||
return m
|
||||
}
|
||||
|
||||
// WithFrom sets the sender
|
||||
func (m *Message) WithFrom(from string) *Message {
|
||||
m.From = from
|
||||
return m
|
||||
}
|
||||
|
||||
// WithTimestamp sets the timestamp
|
||||
func (m *Message) WithTimestamp(ts int64) *Message {
|
||||
m.Timestamp = ts
|
||||
return m
|
||||
}
|
||||
|
||||
// AddAttachment adds a file attachment
|
||||
func (m *Message) AddAttachment(name, content, mimeType string) *Message {
|
||||
m.Attachments = append(m.Attachments, Attachment{
|
||||
Name: name,
|
||||
Content: content,
|
||||
MimeType: mimeType,
|
||||
Size: len(content),
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
// WithReplyKey sets the PKI public key for authenticated replies
|
||||
func (m *Message) WithReplyKey(publicKeyB64 string) *Message {
|
||||
m.ReplyKey = &PKIInfo{
|
||||
PublicKey: publicKeyB64,
|
||||
Algorithm: "x25519",
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// WithReplyKeyInfo sets full PKI information
|
||||
func (m *Message) WithReplyKeyInfo(pki *PKIInfo) *Message {
|
||||
m.ReplyKey = pki
|
||||
return m
|
||||
}
|
||||
|
||||
// SetMeta sets a metadata value
|
||||
func (m *Message) SetMeta(key, value string) *Message {
|
||||
if m.Meta == nil {
|
||||
m.Meta = make(map[string]string)
|
||||
}
|
||||
m.Meta[key] = value
|
||||
return m
|
||||
}
|
||||
|
||||
// GetAttachment finds an attachment by name
|
||||
func (m *Message) GetAttachment(name string) *Attachment {
|
||||
for i := range m.Attachments {
|
||||
if m.Attachments[i].Name == name {
|
||||
return &m.Attachments[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Header represents the SMSG container header
|
||||
type Header struct {
|
||||
Version string `json:"version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
Hint string `json:"hint,omitempty"` // optional password hint
|
||||
}
|
||||
151
pkg/stmf/decrypt.go
Normal file
151
pkg/stmf/decrypt.go
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
package stmf
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/Snider/Enchantrix/pkg/enchantrix"
|
||||
"github.com/Snider/Enchantrix/pkg/trix"
|
||||
)
|
||||
|
||||
// Decrypt decrypts a STMF payload using the server's private key.
|
||||
// It extracts the ephemeral public key from the header, performs ECDH,
|
||||
// and decrypts with ChaCha20-Poly1305.
|
||||
func Decrypt(stmfData []byte, serverPrivateKey []byte) (*FormData, error) {
|
||||
// Load server's private key
|
||||
serverPriv, err := LoadPrivateKey(serverPrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return DecryptWithKey(stmfData, serverPriv)
|
||||
}
|
||||
|
||||
// DecryptBase64 decrypts a base64-encoded STMF payload
|
||||
func DecryptBase64(encoded string, serverPrivateKey []byte) (*FormData, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid base64: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
return Decrypt(data, serverPrivateKey)
|
||||
}
|
||||
|
||||
// DecryptWithKey decrypts a STMF payload using a pre-loaded private key
|
||||
func DecryptWithKey(stmfData []byte, serverPrivateKey *ecdh.PrivateKey) (*FormData, error) {
|
||||
// Decode the trix container
|
||||
t, err := trix.Decode(stmfData, Magic, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidMagic, err)
|
||||
}
|
||||
|
||||
// Extract ephemeral public key from header
|
||||
ephemeralPKBase64, ok := t.Header["ephemeral_pk"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: missing ephemeral_pk in header", ErrInvalidPayload)
|
||||
}
|
||||
|
||||
ephemeralPKBytes, err := base64.StdEncoding.DecodeString(ephemeralPKBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid ephemeral_pk base64: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
|
||||
// Load ephemeral public key
|
||||
ephemeralPub, err := LoadPublicKey(ephemeralPKBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid ephemeral public key: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
|
||||
// Perform ECDH key exchange (server private * ephemeral public = shared secret)
|
||||
sharedSecret, err := serverPrivateKey.ECDH(ephemeralPub)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ECDH failed: %w", err)
|
||||
}
|
||||
|
||||
// Derive symmetric key using SHA-256 (same as encryption)
|
||||
symmetricKey := sha256.Sum256(sharedSecret)
|
||||
|
||||
// Create ChaCha20-Poly1305 sigil
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(symmetricKey[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the payload
|
||||
decrypted, err := sigil.Out(t.Payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrDecryptionFailed, err)
|
||||
}
|
||||
|
||||
// Unmarshal form data
|
||||
var formData FormData
|
||||
if err := json.Unmarshal(decrypted, &formData); err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid JSON payload: %v", ErrInvalidPayload, err)
|
||||
}
|
||||
|
||||
return &formData, nil
|
||||
}
|
||||
|
||||
// DecryptToMap is a convenience function that returns the form data as a simple map
|
||||
func DecryptToMap(stmfData []byte, serverPrivateKey []byte) (map[string]string, error) {
|
||||
formData, err := Decrypt(stmfData, serverPrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return formData.ToMap(), nil
|
||||
}
|
||||
|
||||
// DecryptBase64ToMap decrypts base64 and returns a map
|
||||
func DecryptBase64ToMap(encoded string, serverPrivateKey []byte) (map[string]string, error) {
|
||||
formData, err := DecryptBase64(encoded, serverPrivateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return formData.ToMap(), nil
|
||||
}
|
||||
|
||||
// ValidatePayload checks if the data is a valid STMF container without decrypting
|
||||
func ValidatePayload(stmfData []byte) error {
|
||||
t, err := trix.Decode(stmfData, Magic, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrInvalidMagic, err)
|
||||
}
|
||||
|
||||
// Check required header fields
|
||||
if _, ok := t.Header["ephemeral_pk"].(string); !ok {
|
||||
return fmt.Errorf("%w: missing ephemeral_pk", ErrInvalidPayload)
|
||||
}
|
||||
|
||||
if _, ok := t.Header["algorithm"].(string); !ok {
|
||||
return fmt.Errorf("%w: missing algorithm", ErrInvalidPayload)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPayloadInfo extracts metadata from a STMF payload without decrypting
|
||||
func GetPayloadInfo(stmfData []byte) (*Header, error) {
|
||||
t, err := trix.Decode(stmfData, Magic, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidMagic, err)
|
||||
}
|
||||
|
||||
header := &Header{}
|
||||
|
||||
if v, ok := t.Header["version"].(string); ok {
|
||||
header.Version = v
|
||||
}
|
||||
if v, ok := t.Header["algorithm"].(string); ok {
|
||||
header.Algorithm = v
|
||||
}
|
||||
if v, ok := t.Header["ephemeral_pk"].(string); ok {
|
||||
header.EphemeralPK = v
|
||||
}
|
||||
if v, ok := t.Header["nonce"].(string); ok {
|
||||
header.Nonce = v
|
||||
}
|
||||
|
||||
return header, nil
|
||||
}
|
||||
118
pkg/stmf/encrypt.go
Normal file
118
pkg/stmf/encrypt.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package stmf
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/Snider/Enchantrix/pkg/enchantrix"
|
||||
"github.com/Snider/Enchantrix/pkg/trix"
|
||||
)
|
||||
|
||||
// Encrypt encrypts form data using the server's public key.
|
||||
// It performs X25519 ECDH key exchange with an ephemeral keypair,
|
||||
// derives a symmetric key, and encrypts with ChaCha20-Poly1305.
|
||||
//
|
||||
// The result is a STMF container that can be base64-encoded for transmission.
|
||||
func Encrypt(data *FormData, serverPublicKey []byte) ([]byte, error) {
|
||||
// Load server's public key
|
||||
serverPub, err := LoadPublicKey(serverPublicKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return EncryptWithKey(data, serverPub)
|
||||
}
|
||||
|
||||
// EncryptBase64 encrypts form data and returns a base64-encoded string
|
||||
func EncryptBase64(data *FormData, serverPublicKey []byte) (string, error) {
|
||||
encrypted, err := Encrypt(data, serverPublicKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
|
||||
// EncryptWithKey encrypts form data using a pre-loaded public key
|
||||
func EncryptWithKey(data *FormData, serverPublicKey *ecdh.PublicKey) ([]byte, error) {
|
||||
// Generate ephemeral keypair for this encryption
|
||||
ephemeralPrivate, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate ephemeral key: %w", err)
|
||||
}
|
||||
ephemeralPublic := ephemeralPrivate.PublicKey()
|
||||
|
||||
// Perform ECDH key exchange
|
||||
sharedSecret, err := ephemeralPrivate.ECDH(serverPublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ECDH failed: %w", err)
|
||||
}
|
||||
|
||||
// Derive symmetric key using SHA-256 (same pattern as pkg/trix)
|
||||
symmetricKey := sha256.Sum256(sharedSecret)
|
||||
|
||||
// Create ChaCha20-Poly1305 sigil
|
||||
sigil, err := enchantrix.NewChaChaPolySigil(symmetricKey[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create sigil: %w", err)
|
||||
}
|
||||
|
||||
// Serialize form data to JSON
|
||||
payload, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal form data: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt the payload
|
||||
encrypted, err := sigil.In(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encryption failed: %w", err)
|
||||
}
|
||||
|
||||
// Build STMF container
|
||||
// The nonce is included in the encrypted data by ChaChaPolySigil,
|
||||
// but we include the ephemeral public key in the header
|
||||
header := Header{
|
||||
Version: Version,
|
||||
Algorithm: "x25519-chacha20poly1305",
|
||||
EphemeralPK: base64.StdEncoding.EncodeToString(ephemeralPublic.Bytes()),
|
||||
Nonce: "", // Nonce is embedded in ciphertext by Enchantrix
|
||||
}
|
||||
|
||||
// Convert header to map for trix
|
||||
headerMap := map[string]interface{}{
|
||||
"version": header.Version,
|
||||
"algorithm": header.Algorithm,
|
||||
"ephemeral_pk": header.EphemeralPK,
|
||||
}
|
||||
|
||||
// Create trix container
|
||||
t := &trix.Trix{
|
||||
Header: headerMap,
|
||||
Payload: encrypted,
|
||||
}
|
||||
|
||||
// Encode with STMF magic
|
||||
return trix.Encode(t, Magic, nil)
|
||||
}
|
||||
|
||||
// EncryptMap is a convenience function to encrypt a simple key-value map
|
||||
func EncryptMap(fields map[string]string, serverPublicKey []byte) ([]byte, error) {
|
||||
data := NewFormData()
|
||||
for name, value := range fields {
|
||||
data.AddField(name, value)
|
||||
}
|
||||
return Encrypt(data, serverPublicKey)
|
||||
}
|
||||
|
||||
// EncryptMapBase64 encrypts a map and returns base64
|
||||
func EncryptMapBase64(fields map[string]string, serverPublicKey []byte) (string, error) {
|
||||
encrypted, err := EncryptMap(fields, serverPublicKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(encrypted), nil
|
||||
}
|
||||
107
pkg/stmf/keypair.go
Normal file
107
pkg/stmf/keypair.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package stmf
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// KeyPair represents an X25519 keypair for STMF encryption
|
||||
type KeyPair struct {
|
||||
privateKey *ecdh.PrivateKey
|
||||
publicKey *ecdh.PublicKey
|
||||
}
|
||||
|
||||
// GenerateKeyPair generates a new X25519 keypair
|
||||
func GenerateKeyPair() (*KeyPair, error) {
|
||||
curve := ecdh.X25519()
|
||||
privateKey, err := curve.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrKeyGenerationFailed, err)
|
||||
}
|
||||
|
||||
return &KeyPair{
|
||||
privateKey: privateKey,
|
||||
publicKey: privateKey.PublicKey(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// PublicKey returns the raw public key bytes (32 bytes)
|
||||
func (k *KeyPair) PublicKey() []byte {
|
||||
return k.publicKey.Bytes()
|
||||
}
|
||||
|
||||
// PrivateKey returns the raw private key bytes (32 bytes)
|
||||
func (k *KeyPair) PrivateKey() []byte {
|
||||
return k.privateKey.Bytes()
|
||||
}
|
||||
|
||||
// PublicKeyBase64 returns the public key as a base64-encoded string
|
||||
func (k *KeyPair) PublicKeyBase64() string {
|
||||
return base64.StdEncoding.EncodeToString(k.publicKey.Bytes())
|
||||
}
|
||||
|
||||
// PrivateKeyBase64 returns the private key as a base64-encoded string
|
||||
func (k *KeyPair) PrivateKeyBase64() string {
|
||||
return base64.StdEncoding.EncodeToString(k.privateKey.Bytes())
|
||||
}
|
||||
|
||||
// LoadPublicKey loads a public key from raw bytes
|
||||
func LoadPublicKey(data []byte) (*ecdh.PublicKey, error) {
|
||||
curve := ecdh.X25519()
|
||||
pub, err := curve.NewPublicKey(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidPublicKey, err)
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// LoadPublicKeyBase64 loads a public key from a base64-encoded string
|
||||
func LoadPublicKeyBase64(encoded string) (*ecdh.PublicKey, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid base64: %v", ErrInvalidPublicKey, err)
|
||||
}
|
||||
return LoadPublicKey(data)
|
||||
}
|
||||
|
||||
// LoadPrivateKey loads a private key from raw bytes
|
||||
func LoadPrivateKey(data []byte) (*ecdh.PrivateKey, error) {
|
||||
curve := ecdh.X25519()
|
||||
priv, err := curve.NewPrivateKey(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidPrivateKey, err)
|
||||
}
|
||||
return priv, nil
|
||||
}
|
||||
|
||||
// LoadPrivateKeyBase64 loads a private key from a base64-encoded string
|
||||
func LoadPrivateKeyBase64(encoded string) (*ecdh.PrivateKey, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid base64: %v", ErrInvalidPrivateKey, err)
|
||||
}
|
||||
return LoadPrivateKey(data)
|
||||
}
|
||||
|
||||
// LoadKeyPair loads a keypair from raw private key bytes
|
||||
func LoadKeyPair(privateKeyBytes []byte) (*KeyPair, error) {
|
||||
priv, err := LoadPrivateKey(privateKeyBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &KeyPair{
|
||||
privateKey: priv,
|
||||
publicKey: priv.PublicKey(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LoadKeyPairBase64 loads a keypair from a base64-encoded private key
|
||||
func LoadKeyPairBase64(privateKeyBase64 string) (*KeyPair, error) {
|
||||
data, err := base64.StdEncoding.DecodeString(privateKeyBase64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid base64: %v", ErrInvalidPrivateKey, err)
|
||||
}
|
||||
return LoadKeyPair(data)
|
||||
}
|
||||
192
pkg/stmf/middleware/http.go
Normal file
192
pkg/stmf/middleware/http.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
// Package middleware provides HTTP middleware for automatic STMF decryption.
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/Snider/Borg/pkg/stmf"
|
||||
)
|
||||
|
||||
// contextKey is a custom type for context keys to avoid collisions
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
// FormDataKey is the context key for the decrypted FormData
|
||||
FormDataKey contextKey = "stmf_form_data"
|
||||
|
||||
// MetadataKey is the context key for the form metadata
|
||||
MetadataKey contextKey = "stmf_metadata"
|
||||
)
|
||||
|
||||
// Config holds the middleware configuration
|
||||
type Config struct {
|
||||
// PrivateKey is the server's X25519 private key (32 bytes)
|
||||
PrivateKey []byte
|
||||
|
||||
// FieldName is the form field name containing the STMF payload
|
||||
// Defaults to "_stmf_payload" if empty
|
||||
FieldName string
|
||||
|
||||
// OnError is called when decryption fails
|
||||
// If nil, returns 400 Bad Request
|
||||
OnError func(w http.ResponseWriter, r *http.Request, err error)
|
||||
|
||||
// OnMissingPayload is called when the STMF field is not present
|
||||
// If nil, the request passes through unchanged
|
||||
OnMissingPayload func(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// PopulateForm controls whether decrypted fields are added to r.Form
|
||||
// Defaults to true
|
||||
PopulateForm *bool
|
||||
}
|
||||
|
||||
// DefaultConfig returns a Config with default values
|
||||
func DefaultConfig(privateKey []byte) Config {
|
||||
populateForm := true
|
||||
return Config{
|
||||
PrivateKey: privateKey,
|
||||
FieldName: stmf.DefaultFieldName,
|
||||
PopulateForm: &populateForm,
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware creates an HTTP middleware that decrypts STMF payloads.
|
||||
// It looks for the STMF payload in the configured field name,
|
||||
// decrypts it, and populates r.Form with the decrypted fields.
|
||||
func Middleware(cfg Config) func(http.Handler) http.Handler {
|
||||
if cfg.FieldName == "" {
|
||||
cfg.FieldName = stmf.DefaultFieldName
|
||||
}
|
||||
if cfg.PopulateForm == nil {
|
||||
populateForm := true
|
||||
cfg.PopulateForm = &populateForm
|
||||
}
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Only process POST/PUT/PATCH requests
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodPut && r.Method != http.MethodPatch {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse the form
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
// Try regular form parsing
|
||||
if err := r.ParseForm(); err != nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Look for STMF payload
|
||||
payloadB64 := r.FormValue(cfg.FieldName)
|
||||
if payloadB64 == "" {
|
||||
if cfg.OnMissingPayload != nil {
|
||||
cfg.OnMissingPayload(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Decode base64
|
||||
payloadBytes, err := base64.StdEncoding.DecodeString(payloadB64)
|
||||
if err != nil {
|
||||
handleError(w, r, cfg, stmf.ErrInvalidPayload)
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
formData, err := stmf.Decrypt(payloadBytes, cfg.PrivateKey)
|
||||
if err != nil {
|
||||
handleError(w, r, cfg, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Store in context
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, FormDataKey, formData)
|
||||
if formData.Metadata != nil {
|
||||
ctx = context.WithValue(ctx, MetadataKey, formData.Metadata)
|
||||
}
|
||||
|
||||
// Populate r.Form with decrypted fields
|
||||
if *cfg.PopulateForm {
|
||||
if r.Form == nil {
|
||||
r.Form = make(url.Values)
|
||||
}
|
||||
for _, field := range formData.Fields {
|
||||
r.Form.Set(field.Name, field.Value)
|
||||
}
|
||||
// Also populate PostForm
|
||||
if r.PostForm == nil {
|
||||
r.PostForm = make(url.Values)
|
||||
}
|
||||
for _, field := range formData.Fields {
|
||||
r.PostForm.Set(field.Name, field.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the encrypted payload field
|
||||
if r.Form != nil {
|
||||
delete(r.Form, cfg.FieldName)
|
||||
}
|
||||
if r.PostForm != nil {
|
||||
delete(r.PostForm, cfg.FieldName)
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleError calls the error handler or returns 400
|
||||
func handleError(w http.ResponseWriter, r *http.Request, cfg Config, err error) {
|
||||
if cfg.OnError != nil {
|
||||
cfg.OnError(w, r, err)
|
||||
return
|
||||
}
|
||||
http.Error(w, "Invalid encrypted payload", http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// Simple creates a simple middleware with just a private key
|
||||
func Simple(privateKey []byte) func(http.Handler) http.Handler {
|
||||
return Middleware(DefaultConfig(privateKey))
|
||||
}
|
||||
|
||||
// SimpleBase64 creates a simple middleware with a base64-encoded private key
|
||||
func SimpleBase64(privateKeyB64 string) (func(http.Handler) http.Handler, error) {
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(privateKeyB64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return Simple(keyBytes), nil
|
||||
}
|
||||
|
||||
// GetFormData retrieves the decrypted FormData from the request context
|
||||
func GetFormData(r *http.Request) *stmf.FormData {
|
||||
if fd, ok := r.Context().Value(FormDataKey).(*stmf.FormData); ok {
|
||||
return fd
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadata retrieves the form metadata from the request context
|
||||
func GetMetadata(r *http.Request) map[string]string {
|
||||
if md, ok := r.Context().Value(MetadataKey).(map[string]string); ok {
|
||||
return md
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasSTMFPayload checks if the request contains a STMF payload
|
||||
func HasSTMFPayload(r *http.Request, fieldName string) bool {
|
||||
if fieldName == "" {
|
||||
fieldName = stmf.DefaultFieldName
|
||||
}
|
||||
return r.FormValue(fieldName) != ""
|
||||
}
|
||||
298
pkg/stmf/middleware/http_test.go
Normal file
298
pkg/stmf/middleware/http_test.go
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Snider/Borg/pkg/stmf"
|
||||
)
|
||||
|
||||
func TestMiddleware(t *testing.T) {
|
||||
// Generate server keypair
|
||||
serverKP, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
// Create form data and encrypt it
|
||||
formData := stmf.NewFormData().
|
||||
AddField("email", "test@example.com").
|
||||
AddFieldWithType("password", "secret123", "password")
|
||||
|
||||
encryptedB64, err := stmf.EncryptBase64(formData, serverKP.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptBase64 failed: %v", err)
|
||||
}
|
||||
|
||||
// Create middleware
|
||||
mw := Simple(serverKP.PrivateKey())
|
||||
|
||||
// Create test handler
|
||||
var capturedEmail, capturedPassword string
|
||||
var capturedFormData *stmf.FormData
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedEmail = r.FormValue("email")
|
||||
capturedPassword = r.FormValue("password")
|
||||
capturedFormData = GetFormData(r)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Create request with encrypted payload
|
||||
form := url.Values{}
|
||||
form.Set(stmf.DefaultFieldName, encryptedB64)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
// Verify response
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("Status code = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
// Verify decrypted fields are in r.FormValue
|
||||
if capturedEmail != "test@example.com" {
|
||||
t.Errorf("email = %q, want %q", capturedEmail, "test@example.com")
|
||||
}
|
||||
if capturedPassword != "secret123" {
|
||||
t.Errorf("password = %q, want %q", capturedPassword, "secret123")
|
||||
}
|
||||
|
||||
// Verify context FormData
|
||||
if capturedFormData == nil {
|
||||
t.Error("FormData not in context")
|
||||
} else if capturedFormData.Get("email") != "test@example.com" {
|
||||
t.Error("FormData email incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewarePassThrough(t *testing.T) {
|
||||
serverKP, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
mw := Simple(serverKP.PrivateKey())
|
||||
|
||||
var called bool
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// Request without STMF payload should pass through
|
||||
form := url.Values{}
|
||||
form.Set("regular_field", "value")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if !called {
|
||||
t.Error("Handler was not called for request without STMF payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareGetRequest(t *testing.T) {
|
||||
serverKP, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
mw := Simple(serverKP.PrivateKey())
|
||||
|
||||
var called bool
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
// GET request should pass through without processing
|
||||
req := httptest.NewRequest(http.MethodGet, "/page", nil)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if !called {
|
||||
t.Error("Handler was not called for GET request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareInvalidPayload(t *testing.T) {
|
||||
serverKP, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
mw := Simple(serverKP.PrivateKey())
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("Handler should not be called for invalid payload")
|
||||
}))
|
||||
|
||||
// Request with invalid STMF payload
|
||||
form := url.Values{}
|
||||
form.Set(stmf.DefaultFieldName, "invalid-not-base64!!!!")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("Status code = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareWrongKey(t *testing.T) {
|
||||
serverKP, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
wrongKP, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt with server's public key
|
||||
formData := stmf.NewFormData().AddField("test", "value")
|
||||
encryptedB64, _ := stmf.EncryptBase64(formData, serverKP.PublicKey())
|
||||
|
||||
// But use wrong private key in middleware
|
||||
mw := Simple(wrongKP.PrivateKey())
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("Handler should not be called for wrong key")
|
||||
}))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set(stmf.DefaultFieldName, encryptedB64)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("Status code = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareCustomErrorHandler(t *testing.T) {
|
||||
serverKP, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
var errorHandlerCalled bool
|
||||
cfg := DefaultConfig(serverKP.PrivateKey())
|
||||
cfg.OnError = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
errorHandlerCalled = true
|
||||
http.Error(w, "Custom error", http.StatusUnprocessableEntity)
|
||||
}
|
||||
|
||||
mw := Middleware(cfg)
|
||||
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("Handler should not be called")
|
||||
}))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set(stmf.DefaultFieldName, "invalid")
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if !errorHandlerCalled {
|
||||
t.Error("Custom error handler was not called")
|
||||
}
|
||||
if rec.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("Status code = %d, want %d", rec.Code, http.StatusUnprocessableEntity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMiddlewareWithMetadata(t *testing.T) {
|
||||
serverKP, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
formData := stmf.NewFormData().
|
||||
AddField("email", "test@example.com").
|
||||
SetMetadata("origin", "https://example.com").
|
||||
SetMetadata("timestamp", "1234567890")
|
||||
|
||||
encryptedB64, _ := stmf.EncryptBase64(formData, serverKP.PublicKey())
|
||||
|
||||
mw := Simple(serverKP.PrivateKey())
|
||||
|
||||
var capturedMetadata map[string]string
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedMetadata = GetMetadata(r)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set(stmf.DefaultFieldName, encryptedB64)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if capturedMetadata == nil {
|
||||
t.Fatal("Metadata not in context")
|
||||
}
|
||||
if capturedMetadata["origin"] != "https://example.com" {
|
||||
t.Errorf("origin = %q, want %q", capturedMetadata["origin"], "https://example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleBase64(t *testing.T) {
|
||||
serverKP, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
mw, err := SimpleBase64(serverKP.PrivateKeyBase64())
|
||||
if err != nil {
|
||||
t.Fatalf("SimpleBase64 failed: %v", err)
|
||||
}
|
||||
|
||||
// Create and encrypt form data
|
||||
formData := stmf.NewFormData().AddField("test", "value")
|
||||
encryptedB64, _ := stmf.EncryptBase64(formData, serverKP.PublicKey())
|
||||
|
||||
var capturedValue string
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedValue = r.FormValue("test")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
form := url.Values{}
|
||||
form.Set(stmf.DefaultFieldName, encryptedB64)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if capturedValue != "value" {
|
||||
t.Errorf("test = %q, want %q", capturedValue, "value")
|
||||
}
|
||||
}
|
||||
382
pkg/stmf/stmf_test.go
Normal file
382
pkg/stmf/stmf_test.go
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
package stmf
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestKeyPairGeneration(t *testing.T) {
|
||||
kp, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
// X25519 keys are 32 bytes
|
||||
if len(kp.PublicKey()) != 32 {
|
||||
t.Errorf("Public key length = %d, want 32", len(kp.PublicKey()))
|
||||
}
|
||||
if len(kp.PrivateKey()) != 32 {
|
||||
t.Errorf("Private key length = %d, want 32", len(kp.PrivateKey()))
|
||||
}
|
||||
|
||||
// Base64 encoding should work
|
||||
pubB64 := kp.PublicKeyBase64()
|
||||
privB64 := kp.PrivateKeyBase64()
|
||||
|
||||
if pubB64 == "" || privB64 == "" {
|
||||
t.Error("Base64 encoding returned empty string")
|
||||
}
|
||||
|
||||
// Should be able to decode back
|
||||
pubBytes, err := base64.StdEncoding.DecodeString(pubB64)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to decode public key base64: %v", err)
|
||||
}
|
||||
if len(pubBytes) != 32 {
|
||||
t.Errorf("Decoded public key length = %d, want 32", len(pubBytes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKeyPair(t *testing.T) {
|
||||
// Generate a keypair
|
||||
original, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
// Load it back from bytes
|
||||
loaded, err := LoadKeyPair(original.PrivateKey())
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
// Public keys should match
|
||||
if string(loaded.PublicKey()) != string(original.PublicKey()) {
|
||||
t.Error("Loaded public key doesn't match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadKeyPairBase64(t *testing.T) {
|
||||
original, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
loaded, err := LoadKeyPairBase64(original.PrivateKeyBase64())
|
||||
if err != nil {
|
||||
t.Fatalf("LoadKeyPairBase64 failed: %v", err)
|
||||
}
|
||||
|
||||
if loaded.PublicKeyBase64() != original.PublicKeyBase64() {
|
||||
t.Error("Loaded public key doesn't match original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptRoundTrip(t *testing.T) {
|
||||
// Generate server keypair
|
||||
serverKP, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
// Create form data
|
||||
formData := NewFormData().
|
||||
AddField("email", "test@example.com").
|
||||
AddFieldWithType("password", "secret123", "password").
|
||||
SetMetadata("origin", "https://example.com")
|
||||
|
||||
// Encrypt with server's public key
|
||||
encrypted, err := Encrypt(formData, serverKP.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt with server's private key
|
||||
decrypted, err := Decrypt(encrypted, serverKP.PrivateKey())
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify fields
|
||||
if decrypted.Get("email") != "test@example.com" {
|
||||
t.Errorf("email = %q, want %q", decrypted.Get("email"), "test@example.com")
|
||||
}
|
||||
if decrypted.Get("password") != "secret123" {
|
||||
t.Errorf("password = %q, want %q", decrypted.Get("password"), "secret123")
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
if decrypted.Metadata["origin"] != "https://example.com" {
|
||||
t.Errorf("origin = %q, want %q", decrypted.Metadata["origin"], "https://example.com")
|
||||
}
|
||||
|
||||
// Verify field type preserved
|
||||
pwField := decrypted.GetField("password")
|
||||
if pwField == nil {
|
||||
t.Error("password field not found")
|
||||
} else if pwField.Type != "password" {
|
||||
t.Errorf("password type = %q, want %q", pwField.Type, "password")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptBase64RoundTrip(t *testing.T) {
|
||||
serverKP, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
formData := NewFormData().
|
||||
AddField("username", "johndoe").
|
||||
AddField("action", "login")
|
||||
|
||||
// Encrypt to base64
|
||||
encryptedB64, err := EncryptBase64(formData, serverKP.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptBase64 failed: %v", err)
|
||||
}
|
||||
|
||||
// Should be valid base64
|
||||
if _, err := base64.StdEncoding.DecodeString(encryptedB64); err != nil {
|
||||
t.Fatalf("Encrypted output is not valid base64: %v", err)
|
||||
}
|
||||
|
||||
// Decrypt from base64
|
||||
decrypted, err := DecryptBase64(encryptedB64, serverKP.PrivateKey())
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptBase64 failed: %v", err)
|
||||
}
|
||||
|
||||
if decrypted.Get("username") != "johndoe" {
|
||||
t.Errorf("username = %q, want %q", decrypted.Get("username"), "johndoe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptMapRoundTrip(t *testing.T) {
|
||||
serverKP, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
input := map[string]string{
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
"phone": "+1234567890",
|
||||
}
|
||||
|
||||
encrypted, err := EncryptMap(input, serverKP.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptMap failed: %v", err)
|
||||
}
|
||||
|
||||
output, err := DecryptToMap(encrypted, serverKP.PrivateKey())
|
||||
if err != nil {
|
||||
t.Fatalf("DecryptToMap failed: %v", err)
|
||||
}
|
||||
|
||||
for key, want := range input {
|
||||
if got := output[key]; got != want {
|
||||
t.Errorf("%s = %q, want %q", key, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleEncryptionsAreDifferent(t *testing.T) {
|
||||
// Each encryption should use a different ephemeral key
|
||||
serverKP, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
formData := NewFormData().AddField("test", "value")
|
||||
|
||||
enc1, err := Encrypt(formData, serverKP.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("First Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
enc2, err := Encrypt(formData, serverKP.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("Second Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// Encryptions should be different (different ephemeral keys)
|
||||
if string(enc1) == string(enc2) {
|
||||
t.Error("Two encryptions of same data produced identical output (should use different ephemeral keys)")
|
||||
}
|
||||
|
||||
// But both should decrypt to the same value
|
||||
dec1, _ := Decrypt(enc1, serverKP.PrivateKey())
|
||||
dec2, _ := Decrypt(enc2, serverKP.PrivateKey())
|
||||
|
||||
if dec1.Get("test") != dec2.Get("test") {
|
||||
t.Error("Decrypted values don't match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecryptWithWrongKey(t *testing.T) {
|
||||
serverKP, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
wrongKP, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair for wrong key failed: %v", err)
|
||||
}
|
||||
|
||||
formData := NewFormData().AddField("secret", "data")
|
||||
encrypted, err := Encrypt(formData, serverKP.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// Decrypting with wrong key should fail
|
||||
_, err = Decrypt(encrypted, wrongKP.PrivateKey())
|
||||
if err == nil {
|
||||
t.Error("Decrypt with wrong key should have failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePayload(t *testing.T) {
|
||||
serverKP, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
formData := NewFormData().AddField("test", "value")
|
||||
encrypted, err := Encrypt(formData, serverKP.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
// Valid payload should pass validation
|
||||
if err := ValidatePayload(encrypted); err != nil {
|
||||
t.Errorf("ValidatePayload failed for valid payload: %v", err)
|
||||
}
|
||||
|
||||
// Invalid data should fail
|
||||
if err := ValidatePayload([]byte("not a valid payload")); err == nil {
|
||||
t.Error("ValidatePayload should fail for invalid data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetPayloadInfo(t *testing.T) {
|
||||
serverKP, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
formData := NewFormData().AddField("test", "value")
|
||||
encrypted, err := Encrypt(formData, serverKP.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
info, err := GetPayloadInfo(encrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPayloadInfo failed: %v", err)
|
||||
}
|
||||
|
||||
if info.Version != Version {
|
||||
t.Errorf("Version = %q, want %q", info.Version, Version)
|
||||
}
|
||||
if info.Algorithm != "x25519-chacha20poly1305" {
|
||||
t.Errorf("Algorithm = %q, want %q", info.Algorithm, "x25519-chacha20poly1305")
|
||||
}
|
||||
if info.EphemeralPK == "" {
|
||||
t.Error("EphemeralPK is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormDataMethods(t *testing.T) {
|
||||
fd := NewFormData()
|
||||
|
||||
// Test AddField
|
||||
fd.AddField("name", "John")
|
||||
if fd.Get("name") != "John" {
|
||||
t.Error("AddField/Get failed")
|
||||
}
|
||||
|
||||
// Test AddFieldWithType
|
||||
fd.AddFieldWithType("password", "secret", "password")
|
||||
field := fd.GetField("password")
|
||||
if field == nil || field.Type != "password" {
|
||||
t.Error("AddFieldWithType failed to preserve type")
|
||||
}
|
||||
|
||||
// Test AddFile
|
||||
fd.AddFile("doc", "base64data", "document.pdf", "application/pdf")
|
||||
fileField := fd.GetField("doc")
|
||||
if fileField == nil {
|
||||
t.Error("AddFile failed")
|
||||
} else {
|
||||
if fileField.Filename != "document.pdf" {
|
||||
t.Error("Filename not preserved")
|
||||
}
|
||||
if fileField.MimeType != "application/pdf" {
|
||||
t.Error("MimeType not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
// Test SetMetadata
|
||||
fd.SetMetadata("origin", "https://test.com")
|
||||
if fd.Metadata["origin"] != "https://test.com" {
|
||||
t.Error("SetMetadata failed")
|
||||
}
|
||||
|
||||
// Test GetAll with multiple values
|
||||
fd.AddField("tag", "one")
|
||||
fd.AddField("tag", "two")
|
||||
tags := fd.GetAll("tag")
|
||||
if len(tags) != 2 {
|
||||
t.Errorf("GetAll returned %d values, want 2", len(tags))
|
||||
}
|
||||
|
||||
// Test ToMap
|
||||
m := fd.ToMap()
|
||||
if m["name"] != "John" {
|
||||
t.Error("ToMap failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileFieldRoundTrip(t *testing.T) {
|
||||
serverKP, err := GenerateKeyPair()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateKeyPair failed: %v", err)
|
||||
}
|
||||
|
||||
// Simulate file upload with base64 content
|
||||
fileContent := base64.StdEncoding.EncodeToString([]byte("Hello, World!"))
|
||||
|
||||
formData := NewFormData().
|
||||
AddField("description", "My document").
|
||||
AddFile("upload", fileContent, "hello.txt", "text/plain")
|
||||
|
||||
encrypted, err := Encrypt(formData, serverKP.PublicKey())
|
||||
if err != nil {
|
||||
t.Fatalf("Encrypt failed: %v", err)
|
||||
}
|
||||
|
||||
decrypted, err := Decrypt(encrypted, serverKP.PrivateKey())
|
||||
if err != nil {
|
||||
t.Fatalf("Decrypt failed: %v", err)
|
||||
}
|
||||
|
||||
uploadField := decrypted.GetField("upload")
|
||||
if uploadField == nil {
|
||||
t.Fatal("upload field not found")
|
||||
}
|
||||
|
||||
if uploadField.Type != "file" {
|
||||
t.Errorf("Type = %q, want %q", uploadField.Type, "file")
|
||||
}
|
||||
if uploadField.Filename != "hello.txt" {
|
||||
t.Errorf("Filename = %q, want %q", uploadField.Filename, "hello.txt")
|
||||
}
|
||||
if uploadField.MimeType != "text/plain" {
|
||||
t.Errorf("MimeType = %q, want %q", uploadField.MimeType, "text/plain")
|
||||
}
|
||||
if uploadField.Value != fileContent {
|
||||
t.Error("File content not preserved")
|
||||
}
|
||||
}
|
||||
139
pkg/stmf/types.go
Normal file
139
pkg/stmf/types.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Package stmf implements Sovereign Form Encryption using X25519 ECDH + ChaCha20-Poly1305.
|
||||
// STMF (STIM Form) enables client-side encryption of HTML form data using the server's
|
||||
// public key, providing end-to-end encryption even against MITM proxies.
|
||||
package stmf
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Magic bytes for STMF format
|
||||
const Magic = "STMF"
|
||||
|
||||
// Version of the STMF format
|
||||
const Version = "1.0"
|
||||
|
||||
// DefaultFieldName is the form field name used for the encrypted payload
|
||||
const DefaultFieldName = "_stmf_payload"
|
||||
|
||||
// Errors
|
||||
var (
|
||||
ErrInvalidMagic = errors.New("invalid STMF magic")
|
||||
ErrInvalidPayload = errors.New("invalid STMF payload")
|
||||
ErrDecryptionFailed = errors.New("decryption failed")
|
||||
ErrInvalidPublicKey = errors.New("invalid public key")
|
||||
ErrInvalidPrivateKey = errors.New("invalid private key")
|
||||
ErrKeyGenerationFailed = errors.New("key generation failed")
|
||||
)
|
||||
|
||||
// FormField represents a single form field
|
||||
type FormField struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type,omitempty"` // text, password, file, etc.
|
||||
Filename string `json:"filename,omitempty"` // for file uploads
|
||||
MimeType string `json:"mime,omitempty"` // for file uploads
|
||||
}
|
||||
|
||||
// FormData represents the encrypted form payload
|
||||
type FormData struct {
|
||||
Fields []FormField `json:"fields"`
|
||||
Metadata map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
// NewFormData creates a new empty FormData
|
||||
func NewFormData() *FormData {
|
||||
return &FormData{
|
||||
Fields: make([]FormField, 0),
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// AddField adds a field to the form data
|
||||
func (f *FormData) AddField(name, value string) *FormData {
|
||||
f.Fields = append(f.Fields, FormField{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Type: "text",
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
// AddFieldWithType adds a typed field to the form data
|
||||
func (f *FormData) AddFieldWithType(name, value, fieldType string) *FormData {
|
||||
f.Fields = append(f.Fields, FormField{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Type: fieldType,
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
// AddFile adds a file field to the form data
|
||||
func (f *FormData) AddFile(name, value, filename, mimeType string) *FormData {
|
||||
f.Fields = append(f.Fields, FormField{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Type: "file",
|
||||
Filename: filename,
|
||||
MimeType: mimeType,
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
// SetMetadata sets a metadata value
|
||||
func (f *FormData) SetMetadata(key, value string) *FormData {
|
||||
if f.Metadata == nil {
|
||||
f.Metadata = make(map[string]string)
|
||||
}
|
||||
f.Metadata[key] = value
|
||||
return f
|
||||
}
|
||||
|
||||
// Get retrieves a field value by name
|
||||
func (f *FormData) Get(name string) string {
|
||||
for _, field := range f.Fields {
|
||||
if field.Name == name {
|
||||
return field.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetField retrieves a full field by name
|
||||
func (f *FormData) GetField(name string) *FormField {
|
||||
for i := range f.Fields {
|
||||
if f.Fields[i].Name == name {
|
||||
return &f.Fields[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAll retrieves all values for a field name (for multi-select)
|
||||
func (f *FormData) GetAll(name string) []string {
|
||||
var values []string
|
||||
for _, field := range f.Fields {
|
||||
if field.Name == name {
|
||||
values = append(values, field.Value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
// ToMap converts fields to a simple key-value map (last value wins for duplicates)
|
||||
func (f *FormData) ToMap() map[string]string {
|
||||
result := make(map[string]string)
|
||||
for _, field := range f.Fields {
|
||||
result[field.Name] = field.Value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Header represents the STMF container header
|
||||
type Header struct {
|
||||
Version string `json:"version"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
EphemeralPK string `json:"ephemeral_pk"` // base64-encoded ephemeral public key
|
||||
Nonce string `json:"nonce"` // base64-encoded nonce
|
||||
}
|
||||
505
pkg/wasm/stmf/main.go
Normal file
505
pkg/wasm/stmf/main.go
Normal file
|
|
@ -0,0 +1,505 @@
|
|||
//go:build js && wasm
|
||||
|
||||
// Package main provides the WASM entry point for Borg encryption.
|
||||
// This module exposes encryption/decryption functions to JavaScript for:
|
||||
// - STMF: Client-side form encryption using server's public key
|
||||
// - SMSG: Password-based secure message decryption
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"syscall/js"
|
||||
|
||||
"github.com/Snider/Borg/pkg/smsg"
|
||||
"github.com/Snider/Borg/pkg/stmf"
|
||||
)
|
||||
|
||||
// Version of the WASM module
|
||||
const Version = "1.1.0"
|
||||
|
||||
func main() {
|
||||
// Export the BorgSTMF object to JavaScript global scope
|
||||
js.Global().Set("BorgSTMF", js.ValueOf(map[string]interface{}{
|
||||
"encrypt": js.FuncOf(encrypt),
|
||||
"encryptFields": js.FuncOf(encryptFields),
|
||||
"generateKeyPair": js.FuncOf(generateKeyPair),
|
||||
"version": Version,
|
||||
"ready": true,
|
||||
}))
|
||||
|
||||
// Export BorgSMSG for secure message handling
|
||||
js.Global().Set("BorgSMSG", js.ValueOf(map[string]interface{}{
|
||||
"decrypt": js.FuncOf(smsgDecrypt),
|
||||
"encrypt": js.FuncOf(smsgEncrypt),
|
||||
"getInfo": js.FuncOf(smsgGetInfo),
|
||||
"quickDecrypt": js.FuncOf(smsgQuickDecrypt),
|
||||
"version": Version,
|
||||
"ready": true,
|
||||
}))
|
||||
|
||||
// Dispatch a ready event
|
||||
dispatchReadyEvent()
|
||||
|
||||
// Keep the WASM module alive
|
||||
select {}
|
||||
}
|
||||
|
||||
// dispatchReadyEvent fires a custom event to notify JS that WASM is loaded
|
||||
func dispatchReadyEvent() {
|
||||
event := js.Global().Get("CustomEvent").New("borgstmf:ready", map[string]interface{}{
|
||||
"detail": map[string]interface{}{
|
||||
"version": Version,
|
||||
},
|
||||
})
|
||||
js.Global().Get("document").Call("dispatchEvent", event)
|
||||
}
|
||||
|
||||
// encrypt encrypts form data using the server's public key.
|
||||
// JavaScript usage:
|
||||
//
|
||||
// const result = await BorgSTMF.encrypt(formDataJSON, serverPublicKeyBase64);
|
||||
// // result is a base64-encoded STMF payload
|
||||
func encrypt(this js.Value, args []js.Value) interface{} {
|
||||
// Return a Promise
|
||||
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
||||
resolve := promiseArgs[0]
|
||||
reject := promiseArgs[1]
|
||||
|
||||
go func() {
|
||||
if len(args) < 2 {
|
||||
reject.Invoke(newError("encrypt requires 2 arguments: formDataJSON, serverPublicKeyBase64"))
|
||||
return
|
||||
}
|
||||
|
||||
formDataJSON := args[0].String()
|
||||
serverPubKeyB64 := args[1].String()
|
||||
|
||||
// Parse form data
|
||||
var formData stmf.FormData
|
||||
if err := json.Unmarshal([]byte(formDataJSON), &formData); err != nil {
|
||||
reject.Invoke(newError("invalid form data JSON: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Decode server public key
|
||||
serverPubKey, err := base64.StdEncoding.DecodeString(serverPubKeyB64)
|
||||
if err != nil {
|
||||
reject.Invoke(newError("invalid server public key base64: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
encryptedB64, err := stmf.EncryptBase64(&formData, serverPubKey)
|
||||
if err != nil {
|
||||
reject.Invoke(newError("encryption failed: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
resolve.Invoke(encryptedB64)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
promiseConstructor := js.Global().Get("Promise")
|
||||
return promiseConstructor.New(handler)
|
||||
}
|
||||
|
||||
// encryptFields encrypts a simple key-value object of form fields.
|
||||
// JavaScript usage:
|
||||
//
|
||||
// const result = await BorgSTMF.encryptFields({
|
||||
// email: 'user@example.com',
|
||||
// password: 'secret'
|
||||
// }, serverPublicKeyBase64, metadata);
|
||||
func encryptFields(this js.Value, args []js.Value) interface{} {
|
||||
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
||||
resolve := promiseArgs[0]
|
||||
reject := promiseArgs[1]
|
||||
|
||||
go func() {
|
||||
if len(args) < 2 {
|
||||
reject.Invoke(newError("encryptFields requires at least 2 arguments: fields, serverPublicKeyBase64"))
|
||||
return
|
||||
}
|
||||
|
||||
fieldsObj := args[0]
|
||||
serverPubKeyB64 := args[1].String()
|
||||
|
||||
// Build FormData from JavaScript object
|
||||
formData := stmf.NewFormData()
|
||||
|
||||
// Get field names
|
||||
keys := js.Global().Get("Object").Call("keys", fieldsObj)
|
||||
keysLen := keys.Length()
|
||||
|
||||
for i := 0; i < keysLen; i++ {
|
||||
key := keys.Index(i).String()
|
||||
value := fieldsObj.Get(key)
|
||||
|
||||
// Handle different value types
|
||||
if value.Type() == js.TypeString {
|
||||
formData.AddField(key, value.String())
|
||||
} else if value.Type() == js.TypeObject {
|
||||
// Check if it's a file-like object
|
||||
if !value.Get("name").IsUndefined() && !value.Get("value").IsUndefined() {
|
||||
field := stmf.FormField{
|
||||
Name: key,
|
||||
Value: value.Get("value").String(),
|
||||
}
|
||||
if !value.Get("type").IsUndefined() {
|
||||
field.Type = value.Get("type").String()
|
||||
}
|
||||
if !value.Get("filename").IsUndefined() {
|
||||
field.Filename = value.Get("filename").String()
|
||||
}
|
||||
if !value.Get("mime").IsUndefined() {
|
||||
field.MimeType = value.Get("mime").String()
|
||||
}
|
||||
formData.Fields = append(formData.Fields, field)
|
||||
} else {
|
||||
// Convert to JSON string
|
||||
jsonStr := js.Global().Get("JSON").Call("stringify", value).String()
|
||||
formData.AddField(key, jsonStr)
|
||||
}
|
||||
} else {
|
||||
// Convert to string
|
||||
formData.AddField(key, value.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Handle optional metadata argument
|
||||
if len(args) >= 3 && args[2].Type() == js.TypeObject {
|
||||
metaObj := args[2]
|
||||
metaKeys := js.Global().Get("Object").Call("keys", metaObj)
|
||||
metaLen := metaKeys.Length()
|
||||
|
||||
for i := 0; i < metaLen; i++ {
|
||||
key := metaKeys.Index(i).String()
|
||||
value := metaObj.Get(key).String()
|
||||
formData.SetMetadata(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Decode server public key
|
||||
serverPubKey, err := base64.StdEncoding.DecodeString(serverPubKeyB64)
|
||||
if err != nil {
|
||||
reject.Invoke(newError("invalid server public key base64: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
encryptedB64, err := stmf.EncryptBase64(formData, serverPubKey)
|
||||
if err != nil {
|
||||
reject.Invoke(newError("encryption failed: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
resolve.Invoke(encryptedB64)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
promiseConstructor := js.Global().Get("Promise")
|
||||
return promiseConstructor.New(handler)
|
||||
}
|
||||
|
||||
// generateKeyPair generates a new X25519 keypair for testing/development.
|
||||
// JavaScript usage:
|
||||
//
|
||||
// const keypair = await BorgSTMF.generateKeyPair();
|
||||
// console.log(keypair.publicKey); // base64 public key
|
||||
// console.log(keypair.privateKey); // base64 private key (keep secret!)
|
||||
func generateKeyPair(this js.Value, args []js.Value) interface{} {
|
||||
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
||||
resolve := promiseArgs[0]
|
||||
reject := promiseArgs[1]
|
||||
|
||||
go func() {
|
||||
kp, err := stmf.GenerateKeyPair()
|
||||
if err != nil {
|
||||
reject.Invoke(newError("key generation failed: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"publicKey": kp.PublicKeyBase64(),
|
||||
"privateKey": kp.PrivateKeyBase64(),
|
||||
}
|
||||
|
||||
resolve.Invoke(js.ValueOf(result))
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
promiseConstructor := js.Global().Get("Promise")
|
||||
return promiseConstructor.New(handler)
|
||||
}
|
||||
|
||||
// newError creates a JavaScript Error object
|
||||
func newError(message string) js.Value {
|
||||
return js.Global().Get("Error").New(message)
|
||||
}
|
||||
|
||||
// smsgDecrypt decrypts a base64-encoded SMSG with a password.
|
||||
// JavaScript usage:
|
||||
//
|
||||
// const message = await BorgSMSG.decrypt(encryptedBase64, password);
|
||||
// console.log(message.body);
|
||||
// console.log(message.subject);
|
||||
// console.log(message.attachments);
|
||||
func smsgDecrypt(this js.Value, args []js.Value) interface{} {
|
||||
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
||||
resolve := promiseArgs[0]
|
||||
reject := promiseArgs[1]
|
||||
|
||||
go func() {
|
||||
if len(args) < 2 {
|
||||
reject.Invoke(newError("decrypt requires 2 arguments: encryptedBase64, password"))
|
||||
return
|
||||
}
|
||||
|
||||
encryptedB64 := args[0].String()
|
||||
password := args[1].String()
|
||||
|
||||
msg, err := smsg.DecryptBase64(encryptedB64, password)
|
||||
if err != nil {
|
||||
reject.Invoke(newError("decryption failed: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Convert message to JS object
|
||||
result := messageToJS(msg)
|
||||
resolve.Invoke(result)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
promiseConstructor := js.Global().Get("Promise")
|
||||
return promiseConstructor.New(handler)
|
||||
}
|
||||
|
||||
// smsgEncrypt encrypts a message with a password.
|
||||
// JavaScript usage:
|
||||
//
|
||||
// const encrypted = await BorgSMSG.encrypt({
|
||||
// body: 'Hello!',
|
||||
// subject: 'Test',
|
||||
// from: 'support@example.com'
|
||||
// }, password);
|
||||
func smsgEncrypt(this js.Value, args []js.Value) interface{} {
|
||||
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
||||
resolve := promiseArgs[0]
|
||||
reject := promiseArgs[1]
|
||||
|
||||
go func() {
|
||||
if len(args) < 2 {
|
||||
reject.Invoke(newError("encrypt requires 2 arguments: messageObject, password"))
|
||||
return
|
||||
}
|
||||
|
||||
msgObj := args[0]
|
||||
password := args[1].String()
|
||||
|
||||
// Build message from JS object
|
||||
msg := smsg.NewMessage(msgObj.Get("body").String())
|
||||
|
||||
if !msgObj.Get("subject").IsUndefined() {
|
||||
msg.WithSubject(msgObj.Get("subject").String())
|
||||
}
|
||||
if !msgObj.Get("from").IsUndefined() {
|
||||
msg.WithFrom(msgObj.Get("from").String())
|
||||
}
|
||||
|
||||
// Handle attachments
|
||||
attachments := msgObj.Get("attachments")
|
||||
if !attachments.IsUndefined() && attachments.Length() > 0 {
|
||||
for i := 0; i < attachments.Length(); i++ {
|
||||
att := attachments.Index(i)
|
||||
name := att.Get("name").String()
|
||||
content := att.Get("content").String()
|
||||
mimeType := ""
|
||||
if !att.Get("mime").IsUndefined() {
|
||||
mimeType = att.Get("mime").String()
|
||||
}
|
||||
msg.AddAttachment(name, content, mimeType)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle reply key
|
||||
replyKey := msgObj.Get("replyKey")
|
||||
if !replyKey.IsUndefined() {
|
||||
msg.WithReplyKey(replyKey.Get("publicKey").String())
|
||||
}
|
||||
|
||||
// Handle metadata
|
||||
meta := msgObj.Get("meta")
|
||||
if !meta.IsUndefined() && meta.Type() == js.TypeObject {
|
||||
keys := js.Global().Get("Object").Call("keys", meta)
|
||||
for i := 0; i < keys.Length(); i++ {
|
||||
key := keys.Index(i).String()
|
||||
value := meta.Get(key).String()
|
||||
msg.SetMeta(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Get optional hint
|
||||
hint := ""
|
||||
if len(args) >= 3 && args[2].Type() == js.TypeString {
|
||||
hint = args[2].String()
|
||||
}
|
||||
|
||||
var encrypted []byte
|
||||
var err error
|
||||
if hint != "" {
|
||||
encrypted, err = smsg.EncryptWithHint(msg, password, hint)
|
||||
} else {
|
||||
encrypted, err = smsg.Encrypt(msg, password)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
reject.Invoke(newError("encryption failed: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
encryptedB64 := base64.StdEncoding.EncodeToString(encrypted)
|
||||
resolve.Invoke(encryptedB64)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
promiseConstructor := js.Global().Get("Promise")
|
||||
return promiseConstructor.New(handler)
|
||||
}
|
||||
|
||||
// smsgGetInfo extracts header info from an SMSG without decrypting.
|
||||
// JavaScript usage:
|
||||
//
|
||||
// const info = await BorgSMSG.getInfo(encryptedBase64);
|
||||
// console.log(info.hint); // password hint if set
|
||||
// console.log(info.version);
|
||||
func smsgGetInfo(this js.Value, args []js.Value) interface{} {
|
||||
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
||||
resolve := promiseArgs[0]
|
||||
reject := promiseArgs[1]
|
||||
|
||||
go func() {
|
||||
if len(args) < 1 {
|
||||
reject.Invoke(newError("getInfo requires 1 argument: encryptedBase64"))
|
||||
return
|
||||
}
|
||||
|
||||
encryptedB64 := args[0].String()
|
||||
|
||||
header, err := smsg.GetInfoBase64(encryptedB64)
|
||||
if err != nil {
|
||||
reject.Invoke(newError("failed to get info: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"version": header.Version,
|
||||
"algorithm": header.Algorithm,
|
||||
}
|
||||
if header.Hint != "" {
|
||||
result["hint"] = header.Hint
|
||||
}
|
||||
|
||||
resolve.Invoke(js.ValueOf(result))
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
promiseConstructor := js.Global().Get("Promise")
|
||||
return promiseConstructor.New(handler)
|
||||
}
|
||||
|
||||
// smsgQuickDecrypt is a convenience function that just returns the body text.
|
||||
// JavaScript usage:
|
||||
//
|
||||
// const body = await BorgSMSG.quickDecrypt(encryptedBase64, password);
|
||||
func smsgQuickDecrypt(this js.Value, args []js.Value) interface{} {
|
||||
handler := js.FuncOf(func(this js.Value, promiseArgs []js.Value) interface{} {
|
||||
resolve := promiseArgs[0]
|
||||
reject := promiseArgs[1]
|
||||
|
||||
go func() {
|
||||
if len(args) < 2 {
|
||||
reject.Invoke(newError("quickDecrypt requires 2 arguments: encryptedBase64, password"))
|
||||
return
|
||||
}
|
||||
|
||||
encryptedB64 := args[0].String()
|
||||
password := args[1].String()
|
||||
|
||||
body, err := smsg.QuickDecrypt(encryptedB64, password)
|
||||
if err != nil {
|
||||
reject.Invoke(newError("decryption failed: " + err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
resolve.Invoke(body)
|
||||
}()
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
promiseConstructor := js.Global().Get("Promise")
|
||||
return promiseConstructor.New(handler)
|
||||
}
|
||||
|
||||
// messageToJS converts an smsg.Message to a JavaScript object
|
||||
func messageToJS(msg *smsg.Message) js.Value {
|
||||
result := map[string]interface{}{
|
||||
"body": msg.Body,
|
||||
"timestamp": msg.Timestamp,
|
||||
}
|
||||
|
||||
if msg.Subject != "" {
|
||||
result["subject"] = msg.Subject
|
||||
}
|
||||
if msg.From != "" {
|
||||
result["from"] = msg.From
|
||||
}
|
||||
|
||||
// Convert attachments
|
||||
if len(msg.Attachments) > 0 {
|
||||
attachments := make([]interface{}, len(msg.Attachments))
|
||||
for i, att := range msg.Attachments {
|
||||
attachments[i] = map[string]interface{}{
|
||||
"name": att.Name,
|
||||
"content": att.Content,
|
||||
"mime": att.MimeType,
|
||||
"size": att.Size,
|
||||
}
|
||||
}
|
||||
result["attachments"] = attachments
|
||||
}
|
||||
|
||||
// Convert reply key
|
||||
if msg.ReplyKey != nil {
|
||||
result["replyKey"] = map[string]interface{}{
|
||||
"publicKey": msg.ReplyKey.PublicKey,
|
||||
"keyId": msg.ReplyKey.KeyID,
|
||||
"algorithm": msg.ReplyKey.Algorithm,
|
||||
"fingerprint": msg.ReplyKey.Fingerprint,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert metadata
|
||||
if len(msg.Meta) > 0 {
|
||||
meta := make(map[string]interface{})
|
||||
for k, v := range msg.Meta {
|
||||
meta[k] = v
|
||||
}
|
||||
result["meta"] = meta
|
||||
}
|
||||
|
||||
return js.ValueOf(result)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue