initial commit
Some checks failed
CI / lint (push) Successful in 1m37s
CI / test-python (push) Successful in 1m49s
CI / test-zig (push) Successful in 1m39s
CI / test-wasm (push) Successful in 1m54s
CI / test (push) Successful in 14m44s
CI / miri (push) Successful in 14m18s
CI / build (push) Successful in 1m9s
CI / fuzz-regression (push) Successful in 9m9s
CI / publish (push) Failing after 1m10s
CI / publish-python (push) Failing after 1m46s
CI / publish-wasm (push) Has been cancelled
Some checks failed
CI / lint (push) Successful in 1m37s
CI / test-python (push) Successful in 1m49s
CI / test-zig (push) Successful in 1m39s
CI / test-wasm (push) Successful in 1m54s
CI / test (push) Successful in 14m44s
CI / miri (push) Successful in 14m18s
CI / build (push) Successful in 1m9s
CI / fuzz-regression (push) Successful in 9m9s
CI / publish (push) Failing after 1m10s
CI / publish-python (push) Failing after 1m46s
CI / publish-wasm (push) Has been cancelled
Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
This commit is contained in:
commit
1d99048c95
165830 changed files with 79062 additions and 0 deletions
438
soliton_wasm/bin/soliton.js
Normal file
438
soliton_wasm/bin/soliton.js
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* soliton — CLI for post-quantum cryptographic operations (WASM).
|
||||
*
|
||||
* Standalone CLI that requires no Rust toolchain — runs via WASM in Node.
|
||||
* For the native CLI (faster, no Node dependency), install soliton-cli via cargo.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readSync, openSync, closeSync } from "node:fs";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import process from "node:process";
|
||||
|
||||
// Initialize WASM manually — the bundler target's entry point imports .wasm
|
||||
// as an ES module which Node doesn't support natively.
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const wasmBytes = readFileSync(join(__dirname, "..", "soliton_wasm_bg.wasm"));
|
||||
const bgModule = await import("../soliton_wasm_bg.js");
|
||||
const wasmModule = await WebAssembly.instantiate(wasmBytes, {
|
||||
"./soliton_wasm_bg.js": bgModule,
|
||||
});
|
||||
bgModule.__wbg_set_wasm(wasmModule.instance.exports);
|
||||
wasmModule.instance.exports.__wbindgen_start();
|
||||
const soliton = bgModule;
|
||||
|
||||
const STREAM_CHUNK_SIZE = 1_048_576; // 1 MiB
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0];
|
||||
|
||||
function usage() {
|
||||
console.error(`soliton — post-quantum cryptographic toolkit (WASM)
|
||||
|
||||
Usage: soliton <command> [options]
|
||||
|
||||
Commands:
|
||||
keygen [--output <dir>] Generate identity keypair
|
||||
fingerprint <pk-file> Print SHA3-256 fingerprint
|
||||
sign <sk-file> [file] Hybrid sign (Ed25519 + ML-DSA-65)
|
||||
verify <pk-file> <file> [--sig <file>] Verify a hybrid signature
|
||||
xwing-keygen [--output <dir>] Generate X-Wing keypair (SPK/OPK)
|
||||
sign-prekey <sk-file> <spk-pub-file> Sign a pre-key
|
||||
phrase <pk-a-file> <pk-b-file> Verification phrase
|
||||
encrypt --key <key-file> [file] [-o out] Streaming AEAD encrypt
|
||||
encrypt --derive [--salt <hex>] [file] [-o out]
|
||||
decrypt --key <key-file> [file] [-o out] Streaming AEAD decrypt
|
||||
decrypt --derive --salt <hex> [file] [-o out]
|
||||
argon2id [--m <KiB>] [--t <passes>] [--p <lanes>]
|
||||
version Print library version`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function readFile(path) {
|
||||
return new Uint8Array(readFileSync(resolve(path)));
|
||||
}
|
||||
|
||||
function writeFileSafe(path, data, mode) {
|
||||
writeFileSync(resolve(path), Buffer.from(data), { mode: mode || 0o644 });
|
||||
}
|
||||
|
||||
function writeSecretFile(path, data) {
|
||||
writeFileSafe(path, data, 0o600);
|
||||
}
|
||||
|
||||
function readStdin() {
|
||||
const chunks = [];
|
||||
const fd = openSync("/dev/stdin", "r");
|
||||
const buf = Buffer.alloc(65536);
|
||||
let n;
|
||||
while (true) {
|
||||
try {
|
||||
n = readSync(fd, buf, 0, buf.length, null);
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
if (n === 0) break;
|
||||
chunks.push(Uint8Array.prototype.slice.call(buf, 0, n)); // copy — prevents overwrite
|
||||
}
|
||||
closeSync(fd);
|
||||
return new Uint8Array(Buffer.concat(chunks));
|
||||
}
|
||||
|
||||
function readInput(filePath) {
|
||||
return filePath ? readFile(filePath) : readStdin();
|
||||
}
|
||||
|
||||
function hexEncode(bytes) {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
|
||||
function hexDecode(hex) {
|
||||
if (hex.length % 2 !== 0) throw new Error("invalid hex length");
|
||||
if (!/^[0-9a-fA-F]*$/.test(hex)) throw new Error("invalid hex character");
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
bytes[i] = Number.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function getArg(flag) {
|
||||
const idx = args.indexOf(flag);
|
||||
if (idx === -1 || idx + 1 >= args.length) return null;
|
||||
return args[idx + 1];
|
||||
}
|
||||
|
||||
function hasFlag(flag) {
|
||||
return args.includes(flag);
|
||||
}
|
||||
|
||||
/** Get a positional arg, skipping known flag+value pairs. */
|
||||
function getPositionalAfter(startIdx) {
|
||||
const flags = new Set([
|
||||
"--output",
|
||||
"-o",
|
||||
"--key",
|
||||
"-k",
|
||||
"--sig",
|
||||
"--salt",
|
||||
"--m",
|
||||
"--t",
|
||||
"--p",
|
||||
"--length",
|
||||
]);
|
||||
for (let i = startIdx; i < args.length; i++) {
|
||||
if (flags.has(args[i])) {
|
||||
i++; // skip flag value
|
||||
continue;
|
||||
}
|
||||
if (args[i].startsWith("-")) continue; // skip boolean flags
|
||||
return args[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readPassphrase(prompt) {
|
||||
// Suppress echo if possible.
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: process.stderr.isTTY || false,
|
||||
});
|
||||
if (rl.terminal) {
|
||||
// Manually handle echo suppression via raw mode.
|
||||
process.stderr.write(prompt);
|
||||
return new Promise((res) => {
|
||||
let input = "";
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
const onData = (ch) => {
|
||||
if (ch === "\r" || ch === "\n") {
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.pause();
|
||||
process.stdin.removeListener("data", onData);
|
||||
process.stderr.write("\n");
|
||||
rl.close();
|
||||
res(input);
|
||||
} else if (ch === "\u007F" || ch === "\b") {
|
||||
input = input.slice(0, -1);
|
||||
} else if (ch === "\u0003") {
|
||||
process.exit(130);
|
||||
} else {
|
||||
input += ch;
|
||||
}
|
||||
};
|
||||
process.stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
return new Promise((res) => {
|
||||
rl.question(prompt, (answer) => {
|
||||
rl.close();
|
||||
res(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (!cmd) usage();
|
||||
|
||||
try {
|
||||
switch (cmd) {
|
||||
case "keygen": {
|
||||
const dir = getArg("--output") || getArg("-o") || ".";
|
||||
const id = new soliton.Identity();
|
||||
const pk = id.publicKey();
|
||||
const sk = id.secretKey();
|
||||
const fp = id.fingerprintHex();
|
||||
writeFileSafe(join(dir, "identity.pk"), pk);
|
||||
writeSecretFile(join(dir, "identity.sk"), sk);
|
||||
console.error(`Public key: ${join(dir, "identity.pk")}`);
|
||||
console.error(`Secret key: ${join(dir, "identity.sk")}`);
|
||||
console.error(`Fingerprint: ${fp}`);
|
||||
id.free();
|
||||
break;
|
||||
}
|
||||
|
||||
case "fingerprint": {
|
||||
const pkBytes = readFile(args[1]);
|
||||
console.log(soliton.fingerprintHex(pkBytes));
|
||||
break;
|
||||
}
|
||||
|
||||
case "sign": {
|
||||
const skBytes = readFile(args[1]);
|
||||
const pkPath = args[1].replace(/\.sk$/, ".pk");
|
||||
const pkBytes = readFile(pkPath);
|
||||
const inputFile = getPositionalAfter(2);
|
||||
const message = readInput(inputFile);
|
||||
const id = soliton.Identity.fromBytes(pkBytes, skBytes);
|
||||
const sig = id.sign(message);
|
||||
const outPath = getArg("--output") || getArg("-o");
|
||||
if (outPath) {
|
||||
writeFileSafe(outPath, sig);
|
||||
} else if (inputFile) {
|
||||
writeFileSafe(`${inputFile}.sig`, sig);
|
||||
console.error(`Signature: ${inputFile}.sig`);
|
||||
} else {
|
||||
process.stdout.write(Buffer.from(sig));
|
||||
}
|
||||
id.free();
|
||||
break;
|
||||
}
|
||||
|
||||
case "verify": {
|
||||
const pkBytes = readFile(args[1]);
|
||||
const message = readFile(args[2]);
|
||||
const sigPath = getArg("--sig") || `${args[2]}.sig`;
|
||||
const sigBytes = readFile(sigPath);
|
||||
soliton.hybridVerify(pkBytes, message, sigBytes);
|
||||
console.error("Signature OK");
|
||||
break;
|
||||
}
|
||||
|
||||
case "xwing-keygen": {
|
||||
const dir = getArg("--output") || getArg("-o") || ".";
|
||||
const kp = soliton.xwingKeygen();
|
||||
writeFileSafe(join(dir, "xwing.pk"), kp.publicKey);
|
||||
writeSecretFile(join(dir, "xwing.sk"), kp.secretKey);
|
||||
console.error(`Public key: ${join(dir, "xwing.pk")}`);
|
||||
console.error(`Secret key: ${join(dir, "xwing.sk")}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "sign-prekey": {
|
||||
const skBytes = readFile(args[1]);
|
||||
const spkBytes = readFile(args[2]);
|
||||
const sig = soliton.kexSignPrekey(skBytes, spkBytes);
|
||||
const out = getArg("--output") || getArg("-o") || "spk.sig";
|
||||
writeFileSafe(out, sig);
|
||||
console.error(`Pre-key signature: ${out}`);
|
||||
break;
|
||||
}
|
||||
|
||||
case "phrase": {
|
||||
const pkA = readFile(args[1]);
|
||||
const pkB = readFile(args[2]);
|
||||
console.log(soliton.verificationPhrase(pkA, pkB));
|
||||
break;
|
||||
}
|
||||
|
||||
case "encrypt": {
|
||||
const keyPath = getArg("--key") || getArg("-k");
|
||||
const derive = hasFlag("--derive");
|
||||
const saltHex = getArg("--salt");
|
||||
let key;
|
||||
|
||||
if (derive) {
|
||||
const password = await readPassphrase("Passphrase: ");
|
||||
let salt;
|
||||
if (saltHex) {
|
||||
salt = hexDecode(saltHex);
|
||||
} else {
|
||||
salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
console.error(`Salt: ${hexEncode(salt)} (save this to decrypt later)`);
|
||||
}
|
||||
key = soliton.argon2id(new TextEncoder().encode(password), salt, 65536, 3, 4, 32);
|
||||
} else if (keyPath) {
|
||||
key = readFile(keyPath);
|
||||
} else {
|
||||
console.error("error: provide --key <file> or --derive");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputFile = getPositionalAfter(1);
|
||||
const plaintext = readInput(inputFile);
|
||||
const enc = new soliton.StreamEncryptor(key);
|
||||
const header = enc.header();
|
||||
|
||||
const chunks = [];
|
||||
chunks.push(header);
|
||||
|
||||
if (plaintext.length === 0) {
|
||||
chunks.push(enc.encryptChunk(new Uint8Array(0), true));
|
||||
} else {
|
||||
const totalChunks = Math.ceil(plaintext.length / STREAM_CHUNK_SIZE);
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const start = i * STREAM_CHUNK_SIZE;
|
||||
const end = Math.min(start + STREAM_CHUNK_SIZE, plaintext.length);
|
||||
const isLast = i === totalChunks - 1;
|
||||
chunks.push(enc.encryptChunk(plaintext.subarray(start, end), isLast));
|
||||
}
|
||||
}
|
||||
enc.free();
|
||||
|
||||
const totalLen = chunks.reduce((s, c) => s + c.length, 0);
|
||||
const combined = new Uint8Array(totalLen);
|
||||
let offset = 0;
|
||||
for (const c of chunks) {
|
||||
combined.set(c, offset);
|
||||
offset += c.length;
|
||||
}
|
||||
|
||||
const outPath = getArg("--output") || getArg("-o");
|
||||
if (outPath) {
|
||||
writeFileSafe(outPath, combined);
|
||||
} else {
|
||||
process.stdout.write(Buffer.from(combined));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "decrypt": {
|
||||
const keyPath = getArg("--key") || getArg("-k");
|
||||
const derive = hasFlag("--derive");
|
||||
const saltHex = getArg("--salt");
|
||||
let key;
|
||||
|
||||
if (derive) {
|
||||
if (!saltHex) {
|
||||
console.error("error: --derive requires --salt <hex> for decryption");
|
||||
process.exit(1);
|
||||
}
|
||||
const password = await readPassphrase("Passphrase: ");
|
||||
key = soliton.argon2id(
|
||||
new TextEncoder().encode(password),
|
||||
hexDecode(saltHex),
|
||||
65536,
|
||||
3,
|
||||
4,
|
||||
32,
|
||||
);
|
||||
} else if (keyPath) {
|
||||
key = readFile(keyPath);
|
||||
} else {
|
||||
console.error("error: provide --key <file> or --derive --salt <hex>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const inputFile = getPositionalAfter(1);
|
||||
const data = readInput(inputFile);
|
||||
if (data.length < 26) {
|
||||
console.error("error: input too short (missing stream header)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const header = data.subarray(0, 26);
|
||||
const ciphertext = data.subarray(26);
|
||||
|
||||
const dec = new soliton.StreamDecryptor(key, header);
|
||||
const ptChunks = [];
|
||||
|
||||
// Each chunk: tag_byte (1) + ciphertext (variable) + AEAD tag (16).
|
||||
// Non-final uncompressed: 1 + 1,048,576 + 16 = 1,048,593 bytes.
|
||||
// Final: everything remaining.
|
||||
let pos = 0;
|
||||
while (pos < ciphertext.length) {
|
||||
const tagByte = ciphertext[pos];
|
||||
const isFinalChunk = tagByte === 0x01;
|
||||
const chunkSize = isFinalChunk
|
||||
? ciphertext.length - pos
|
||||
: 1 + STREAM_CHUNK_SIZE + 16;
|
||||
if (pos + chunkSize > ciphertext.length) {
|
||||
throw new Error("truncated chunk in stream");
|
||||
}
|
||||
const { plaintext: pt, isLast } = dec.decryptChunk(
|
||||
ciphertext.subarray(pos, pos + chunkSize),
|
||||
);
|
||||
ptChunks.push(pt);
|
||||
pos += chunkSize;
|
||||
if (isLast) break;
|
||||
}
|
||||
|
||||
// Truncation detection: an attacker who strips the final chunk leaves
|
||||
// the decryptor non-finalized with all prior chunks authentic.
|
||||
if (!dec.isFinalized()) {
|
||||
dec.free();
|
||||
throw new Error("stream truncated: final chunk missing");
|
||||
}
|
||||
dec.free();
|
||||
|
||||
const totalLen = ptChunks.reduce((s, c) => s + c.length, 0);
|
||||
const plaintext = new Uint8Array(totalLen);
|
||||
let off = 0;
|
||||
for (const c of ptChunks) {
|
||||
plaintext.set(c, off);
|
||||
off += c.length;
|
||||
}
|
||||
|
||||
const outPath = getArg("--output") || getArg("-o");
|
||||
if (outPath) {
|
||||
writeFileSafe(outPath, plaintext);
|
||||
} else {
|
||||
process.stdout.write(Buffer.from(plaintext));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "argon2id": {
|
||||
const m = Number.parseInt(getArg("--m") || "65536");
|
||||
const t = Number.parseInt(getArg("--t") || "3");
|
||||
const p = Number.parseInt(getArg("--p") || "4");
|
||||
const len = Number.parseInt(getArg("--length") || "32");
|
||||
|
||||
const password = await readPassphrase("Passphrase: ");
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const key = soliton.argon2id(new TextEncoder().encode(password), salt, m, t, p, len);
|
||||
console.error(`Salt: ${hexEncode(salt)}`);
|
||||
console.log(hexEncode(key));
|
||||
break;
|
||||
}
|
||||
|
||||
case "version":
|
||||
console.log(`soliton ${soliton.version()} (wasm)`);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}`);
|
||||
usage();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`error: ${e.message || e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue