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>
438 lines
13 KiB
JavaScript
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);
|
|
}
|