libsoliton/soliton_wasm/bin/soliton.js
Kamal Tufekcic 1d99048c95
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
initial commit
Signed-off-by: Kamal Tufekcic <kamal@lo.sh>
2026-04-02 23:48:10 +03:00

438 lines
13 KiB
JavaScript

#!/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);
}