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>
529 lines
18 KiB
JavaScript
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");
|
|
});
|
|
});
|