libsoliton/soliton_wasm/tests/soliton.test.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

529 lines
18 KiB
JavaScript

import { describe, it, expect } from "vitest";
import * as soliton from "../pkg/soliton_wasm.js";
const utf8 = (s) => new TextEncoder().encode(s);
const fromUtf8 = (buf) => new TextDecoder().decode(buf);
const randomBytes = (n) => crypto.getRandomValues(new Uint8Array(n));
const bytesEqual = (a, b) => {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
return true;
};
// ── Version ─────────────────────────────────────────────────────────────
describe("version", () => {
it("returns non-empty string", () => {
const v = soliton.version();
expect(v.length).toBeGreaterThan(0);
});
});
// ── Primitives ──────────────────────────────────────────────────────────
describe("primitives", () => {
it("sha3_256 known vector — empty", () => {
const hash = soliton.sha3_256(new Uint8Array(0));
expect(hash.length).toBe(32);
expect(hash[0]).toBe(0xa7);
expect(hash[1]).toBe(0xff);
});
it("sha3_256 known vector — abc", () => {
const hash = soliton.sha3_256(utf8("abc"));
expect(hash.length).toBe(32);
expect(hash[0]).toBe(0x3a);
expect(hash[1]).toBe(0x98);
});
it("fingerprintHex", () => {
const hex = soliton.fingerprintHex(utf8("abc"));
expect(hex.length).toBe(64);
expect(typeof hex).toBe("string");
});
it("hmacSha3_256 and verify", () => {
const key = new Uint8Array(32).fill(0x0b);
const tag = soliton.hmacSha3_256(key, utf8("Hi There"));
expect(tag.length).toBe(32);
expect(soliton.hmacSha3_256Verify(tag, tag)).toBe(true);
const bad = new Uint8Array(tag);
bad[0] ^= 0xff;
expect(soliton.hmacSha3_256Verify(tag, bad)).toBe(false);
});
it("hkdfSha3_256", () => {
const salt = new Uint8Array(32);
const ikm = new Uint8Array(32).fill(0x0b);
const okm = soliton.hkdfSha3_256(salt, ikm, utf8("test"), 64);
expect(okm.length).toBe(64);
});
it("xwingKeygen", () => {
const kp = soliton.xwingKeygen();
expect(kp.publicKey.length).toBe(1216);
expect(kp.secretKey.length).toBe(2432);
});
it("argon2id", () => {
const salt = randomBytes(16);
const key1 = soliton.argon2id(utf8("password"), salt, 19456, 2, 1, 32);
const key2 = soliton.argon2id(utf8("password"), salt, 19456, 2, 1, 32);
expect(key1.length).toBe(32);
expect(bytesEqual(key1, key2)).toBe(true);
const key3 = soliton.argon2id(utf8("wrong"), salt, 19456, 2, 1, 32);
expect(bytesEqual(key1, key3)).toBe(false);
});
});
// ── Identity ────────────────────────────────────────────────────────────
describe("identity", () => {
it("keygen", () => {
const id = new soliton.Identity();
expect(id.publicKey().length).toBe(3200);
expect(id.secretKey().length).toBe(2496);
id.free();
});
it("fingerprint", () => {
const id = new soliton.Identity();
const fp = id.fingerprint();
expect(fp.length).toBe(32);
// Verify fingerprint matches sha3_256(pk).
const expected = soliton.sha3_256(id.publicKey());
expect(bytesEqual(fp, expected)).toBe(true);
id.free();
});
it("sign and verify", () => {
const id = new soliton.Identity();
const msg = utf8("test message");
const sig = id.sign(msg);
expect(sig.length).toBe(3373);
id.verify(msg, sig); // throws on failure
id.free();
});
it("verify wrong message throws", () => {
const id = new soliton.Identity();
const sig = id.sign(utf8("correct"));
expect(() => id.verify(utf8("wrong"), sig)).toThrow();
id.free();
});
it("verify wrong key throws", () => {
const alice = new soliton.Identity();
const bob = new soliton.Identity();
const sig = alice.sign(utf8("hello"));
expect(() => bob.verify(utf8("hello"), sig)).toThrow();
alice.free();
bob.free();
});
it("fromBytes round trip", () => {
const id = new soliton.Identity();
const pk = id.publicKey();
const sk = id.secretKey();
id.free();
const id2 = soliton.Identity.fromBytes(pk, sk);
const sig = id2.sign(utf8("roundtrip"));
id2.verify(utf8("roundtrip"), sig);
id2.free();
});
it("fromPublicBytes cannot sign", () => {
const id = new soliton.Identity();
const pk = id.publicKey();
id.free();
const pub_only = soliton.Identity.fromPublicBytes(pk);
expect(() => pub_only.sign(utf8("test"))).toThrow();
pub_only.free();
});
});
// ── Auth ────────────────────────────────────────────────────────────────
describe("auth", () => {
it("challenge-respond-verify round trip", () => {
const id = new soliton.Identity();
const pk = id.publicKey();
const sk = id.secretKey();
const { ciphertext, token } = soliton.authChallenge(pk);
const proof = soliton.authRespond(sk, ciphertext);
expect(soliton.authVerify(token, proof)).toBe(true);
id.free();
});
it("wrong proof fails", () => {
const id = new soliton.Identity();
const { token } = soliton.authChallenge(id.publicKey());
expect(soliton.authVerify(token, new Uint8Array(32))).toBe(false);
id.free();
});
});
// ── Verification ────────────────────────────────────────────────────────
describe("verification", () => {
it("phrase is symmetric", () => {
const alice = new soliton.Identity();
const bob = new soliton.Identity();
const p1 = soliton.verificationPhrase(alice.publicKey(), bob.publicKey());
const p2 = soliton.verificationPhrase(bob.publicKey(), alice.publicKey());
expect(p1.length).toBeGreaterThan(0);
expect(p1).toBe(p2);
alice.free();
bob.free();
});
});
// ── Storage ─────────────────────────────────────────────────────────────
describe("storage", () => {
it("encrypt/decrypt round trip", () => {
const key = randomBytes(32);
const ring = new soliton.StorageKeyRing(1, key);
const pt = utf8("encrypted storage data");
const blob = ring.encryptBlob("channel-1", "segment-0", pt);
const result = ring.decryptBlob("channel-1", "segment-0", blob);
expect(bytesEqual(result, pt)).toBe(true);
ring.free();
});
it("wrong channel fails", () => {
const ring = new soliton.StorageKeyRing(1, randomBytes(32));
const blob = ring.encryptBlob("ch-1", "seg", utf8("data"));
expect(() => ring.decryptBlob("ch-2", "seg", blob)).toThrow();
ring.free();
});
it("key rotation", () => {
const ring = new soliton.StorageKeyRing(1, randomBytes(32));
const blob_v1 = ring.encryptBlob("ch", "seg", utf8("v1"));
ring.addKey(2, randomBytes(32), true);
const blob_v2 = ring.encryptBlob("ch", "seg", utf8("v2"));
expect(fromUtf8(ring.decryptBlob("ch", "seg", blob_v1))).toBe("v1");
expect(fromUtf8(ring.decryptBlob("ch", "seg", blob_v2))).toBe("v2");
ring.free();
});
it("dm queue round trip", () => {
const ring = new soliton.StorageKeyRing(1, randomBytes(32));
const fp = randomBytes(32);
const pt = utf8("queued DM");
const blob = ring.encryptDmQueue(fp, "batch-1", pt);
const result = ring.decryptDmQueue(fp, "batch-1", blob);
expect(bytesEqual(result, pt)).toBe(true);
ring.free();
});
it("compress round trip", () => {
const ring = new soliton.StorageKeyRing(1, randomBytes(32));
const data = utf8("compressible ".repeat(100));
const blob = ring.encryptBlob("ch", "seg", data, true);
const result = ring.decryptBlob("ch", "seg", blob);
expect(bytesEqual(result, data)).toBe(true);
ring.free();
});
});
// ── Streaming AEAD ──────────────────────────────────────────────────────
describe("streaming", () => {
it("single chunk round trip", () => {
const key = randomBytes(32);
const enc = new soliton.StreamEncryptor(key);
const header = enc.header();
expect(header.length).toBe(26);
const ct = enc.encryptChunk(utf8("hello stream"), true);
enc.free();
const dec = new soliton.StreamDecryptor(key, header);
const { plaintext, isLast } = dec.decryptChunk(ct);
expect(fromUtf8(plaintext)).toBe("hello stream");
expect(isLast).toBe(true);
dec.free();
});
it("encrypt_at/decrypt_at", () => {
const key = randomBytes(32);
const enc = new soliton.StreamEncryptor(key);
const header = enc.header();
const ct = enc.encryptChunkAt(5n, utf8("at index five"), true);
enc.free();
const dec = new soliton.StreamDecryptor(key, header);
const { plaintext } = dec.decryptChunkAt(5n, ct);
expect(fromUtf8(plaintext)).toBe("at index five");
dec.free();
});
it("wrong key fails", () => {
const enc = new soliton.StreamEncryptor(randomBytes(32));
const header = enc.header();
const ct = enc.encryptChunk(utf8("data"), true);
enc.free();
const dec = new soliton.StreamDecryptor(randomBytes(32), header);
expect(() => dec.decryptChunk(ct)).toThrow();
dec.free();
});
});
// ── KEX + Ratchet ───────────────────────────────────────────────────────
describe("kex", () => {
it("sign and verify bundle", () => {
const bob = new soliton.Identity();
const { publicKey: spkPub } = soliton.xwingKeygen();
const sig = soliton.kexSignPrekey(bob.secretKey(), spkPub);
expect(sig.length).toBe(3373);
soliton.kexVerifyBundle(
bob.publicKey(), bob.publicKey(),
spkPub, 1, sig, "lo-crypto-v1",
);
bob.free();
});
it("verify bundle wrong key throws", () => {
const bob = new soliton.Identity();
const eve = new soliton.Identity();
const { publicKey: spkPub } = soliton.xwingKeygen();
const sig = soliton.kexSignPrekey(bob.secretKey(), spkPub);
expect(() => soliton.kexVerifyBundle(
bob.publicKey(), eve.publicKey(),
spkPub, 1, sig, "lo-crypto-v1",
)).toThrow();
bob.free();
eve.free();
});
});
describe("first message", () => {
it("encrypt/decrypt round trip", () => {
const ck = randomBytes(32);
const aad = utf8("test-aad");
const pt = utf8("first application message");
const { encryptedPayload, ratchetInitKey: rikA } =
soliton.Ratchet.encryptFirstMessage(ck, pt, aad);
const { plaintext, ratchetInitKey: rikB } =
soliton.Ratchet.decryptFirstMessage(ck, encryptedPayload, aad);
expect(fromUtf8(plaintext)).toBe("first application message");
expect(bytesEqual(rikA, rikB)).toBe(true);
});
it("wrong key throws", () => {
const ck = randomBytes(32);
const { encryptedPayload } = soliton.Ratchet.encryptFirstMessage(
ck, utf8("hello"), utf8("aad"),
);
expect(() =>
soliton.Ratchet.decryptFirstMessage(randomBytes(32), encryptedPayload, utf8("aad")),
).toThrow();
});
});
describe("full kex + ratchet", () => {
it("complete session lifecycle", () => {
const alice = new soliton.Identity();
const bob = new soliton.Identity();
const { publicKey: spkPub, secretKey: spkSk } = soliton.xwingKeygen();
const spkSig = soliton.kexSignPrekey(bob.secretKey(), spkPub);
// Alice initiates.
const initiated = soliton.kexInitiate(
alice.publicKey(), alice.secretKey(),
bob.publicKey(), spkPub, 1, spkSig, "lo-crypto-v1",
);
// Bob receives.
const siEncoded = initiated.sessionInitEncoded();
const received = soliton.kexReceive(
bob.publicKey(), bob.secretKey(), alice.publicKey(),
siEncoded, initiated.senderSig(), spkSk,
);
// First message.
const aad = soliton.kexBuildFirstMessageAad(
initiated.senderFingerprint(),
initiated.recipientFingerprint(),
siEncoded,
);
const { encryptedPayload, ratchetInitKey: rikA } =
soliton.Ratchet.encryptFirstMessage(initiated.takeInitialChainKey(), utf8("hello bob"), aad);
const { plaintext: firstPt, ratchetInitKey: rikB } =
soliton.Ratchet.decryptFirstMessage(received.takeInitialChainKey(), encryptedPayload, aad);
expect(fromUtf8(firstPt)).toBe("hello bob");
// Init ratchets.
const aliceR = soliton.Ratchet.initAlice(
initiated.takeRootKey(), rikA,
alice.fingerprint(), bob.fingerprint(),
received.peerEk(), initiated.ekSk(),
);
const bobR = soliton.Ratchet.initBob(
received.takeRootKey(), rikB,
bob.fingerprint(), alice.fingerprint(),
received.peerEk(),
);
// Alice sends to Bob.
const { header, ciphertext } = aliceR.encrypt(utf8("message 1"));
const pt1 = bobR.decrypt(header, ciphertext);
expect(fromUtf8(pt1)).toBe("message 1");
// Bob replies (direction change).
const { header: hdr2, ciphertext: ct2 } = bobR.encrypt(utf8("reply 1"));
const pt2 = aliceR.decrypt(hdr2, ct2);
expect(fromUtf8(pt2)).toBe("reply 1");
// Cleanup.
aliceR.free();
bobR.free();
initiated.free();
received.free();
alice.free();
bob.free();
});
it("ratchet serialize/deserialize", () => {
const alice = new soliton.Identity();
const bob = new soliton.Identity();
const { publicKey: spkPub, secretKey: spkSk } = soliton.xwingKeygen();
const spkSig = soliton.kexSignPrekey(bob.secretKey(), spkPub);
const initiated = soliton.kexInitiate(
alice.publicKey(), alice.secretKey(),
bob.publicKey(), spkPub, 1, spkSig, "lo-crypto-v1",
);
const siEncoded = initiated.sessionInitEncoded();
const received = soliton.kexReceive(
bob.publicKey(), bob.secretKey(), alice.publicKey(),
siEncoded, initiated.senderSig(), spkSk,
);
const aad = soliton.kexBuildFirstMessageAad(
initiated.senderFingerprint(), initiated.recipientFingerprint(), siEncoded,
);
const { ratchetInitKey: rik } =
soliton.Ratchet.encryptFirstMessage(initiated.takeInitialChainKey(), utf8("x"), aad);
const aliceR = soliton.Ratchet.initAlice(
initiated.takeRootKey(), rik,
alice.fingerprint(), bob.fingerprint(),
received.peerEk(), initiated.ekSk(),
);
aliceR.encrypt(utf8("advance state"));
const { blob, epoch } = aliceR.toBytes();
expect(blob.length).toBeGreaterThan(0);
expect(epoch).toBeGreaterThanOrEqual(1n);
const restored = soliton.Ratchet.fromBytes(blob, 0n);
const { ciphertext } = restored.encrypt(utf8("after restore"));
expect(ciphertext.length).toBeGreaterThan(0);
restored.free();
initiated.free();
received.free();
alice.free();
bob.free();
});
it("derive call keys", () => {
const alice = new soliton.Identity();
const bob = new soliton.Identity();
const { publicKey: spkPub, secretKey: spkSk } = soliton.xwingKeygen();
const spkSig = soliton.kexSignPrekey(bob.secretKey(), spkPub);
const initiated = soliton.kexInitiate(
alice.publicKey(), alice.secretKey(),
bob.publicKey(), spkPub, 1, spkSig, "lo-crypto-v1",
);
const siEncoded = initiated.sessionInitEncoded();
const received = soliton.kexReceive(
bob.publicKey(), bob.secretKey(), alice.publicKey(),
siEncoded, initiated.senderSig(), spkSk,
);
const aad = soliton.kexBuildFirstMessageAad(
initiated.senderFingerprint(), initiated.recipientFingerprint(), siEncoded,
);
const { ratchetInitKey: rik } =
soliton.Ratchet.encryptFirstMessage(initiated.takeInitialChainKey(), utf8("x"), aad);
const aliceR = soliton.Ratchet.initAlice(
initiated.takeRootKey(), rik,
alice.fingerprint(), bob.fingerprint(),
received.peerEk(), initiated.ekSk(),
);
const kemSs = randomBytes(32);
const callId = randomBytes(16);
const keys = aliceR.deriveCallKeys(kemSs, callId);
const send = keys.sendKey();
const recv = keys.recvKey();
expect(send.length).toBe(32);
expect(recv.length).toBe(32);
expect(bytesEqual(send, recv)).toBe(false);
keys.advance();
const send2 = keys.sendKey();
expect(bytesEqual(send, send2)).toBe(false);
keys.free();
aliceR.free();
initiated.free();
received.free();
alice.free();
bob.free();
});
});
// ── Additional Coverage ─────────────────────────────────────────────────
describe("additional", () => {
it("hybridVerify standalone", () => {
const id = new soliton.Identity();
const msg = utf8("test");
const sig = id.sign(msg);
soliton.hybridVerify(id.publicKey(), msg, sig);
expect(() => soliton.hybridVerify(id.publicKey(), utf8("wrong"), sig)).toThrow();
id.free();
});
it("storage removeKey", () => {
const ring = new soliton.StorageKeyRing(1, randomBytes(32));
ring.addKey(2, randomBytes(32), true);
ring.removeKey(1);
const blob = ring.encryptBlob("ch", "seg", utf8("data"));
expect(fromUtf8(ring.decryptBlob("ch", "seg", blob))).toBe("data");
ring.free();
});
it("errors are Error instances", () => {
try {
soliton.authVerify(new Uint8Array(10), new Uint8Array(32));
} catch (e) {
expect(e instanceof Error).toBe(true);
expect(typeof e.message).toBe("string");
return;
}
expect.unreachable("should have thrown");
});
});