#!/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 [options] Commands: keygen [--output ] Generate identity keypair fingerprint Print SHA3-256 fingerprint sign [file] Hybrid sign (Ed25519 + ML-DSA-65) verify [--sig ] Verify a hybrid signature xwing-keygen [--output ] Generate X-Wing keypair (SPK/OPK) sign-prekey Sign a pre-key phrase Verification phrase encrypt --key [file] [-o out] Streaming AEAD encrypt encrypt --derive [--salt ] [file] [-o out] decrypt --key [file] [-o out] Streaming AEAD decrypt decrypt --derive --salt [file] [-o out] argon2id [--m ] [--t ] [--p ] 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 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 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 or --derive --salt "); 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); }