import { describe, it, expect, beforeEach } from "vitest"; import { bytesToHex } from "@hamradio/packet"; import { Identity, LocalIdentity, Contact, Group, Contacts, parseNodeHash } from "../src/identity"; import { PrivateKey, PublicKey, SharedSecret } from "../src/crypto"; import { DecryptedGroupText, DecryptedGroupData } from "../src/packet.types"; function randomBytes(len: number) { return Uint8Array.from({ length: len }, () => Math.floor(Math.random() * 256)); } describe("parseNodeHash", () => { it("parses Uint8Array", () => { expect(parseNodeHash(Uint8Array.of(0x42))).toBe(0x42); }); it("parses number", () => { expect(parseNodeHash(0x42)).toBe(0x42); expect(() => parseNodeHash(-1)).toThrow(); expect(() => parseNodeHash(256)).toThrow(); }); it("parses string", () => { expect(parseNodeHash("2a")).toBe(0x2a); expect(() => parseNodeHash("2a2a")).toThrow(); }); it("throws on invalid type", () => { // @ts-expect-error testing invalid input expect(() => parseNodeHash({})).toThrow(); }); }); describe("Identity", () => { let pub: Uint8Array; let identity: Identity; beforeEach(() => { pub = randomBytes(32); identity = new Identity(pub); }); it("constructs from Uint8Array", () => { expect(identity.publicKey.toBytes()).toEqual(pub); }); it("constructs from string", () => { const hex = bytesToHex(pub); const id = new Identity(hex); expect(id.publicKey.toBytes()).toEqual(pub); }); it("hash returns NodeHash", () => { expect(typeof identity.hash()).toBe("number"); }); it("toString returns string", () => { expect(typeof identity.toString()).toBe("string"); }); it("verify delegates to publicKey", () => { const msg = randomBytes(10); const sig = randomBytes(64); expect(identity.verify(sig, msg)).toBe(identity.publicKey.verify(msg, sig)); }); it("matches works for various types", () => { expect(identity.matches(identity)).toBe(true); expect(identity.matches(identity.publicKey)).toBe(true); expect(identity.matches(identity.publicKey.toBytes())).toBe(true); expect(identity.matches(identity.publicKey.toString())).toBe(true); expect(identity.matches(randomBytes(32))).toBe(false); }); /* eslint-disable @typescript-eslint/no-explicit-any */ it("constructor throws on invalid type", () => { expect(() => new (Identity as any)(123)).toThrow(); }); it("matches throws on invalid type", () => { const pub = randomBytes(32); const id = new Identity(pub); // @ts-expect-error testing wrong type expect(() => id.matches({})).toThrow(); }); }); describe("LocalIdentity", () => { let priv: PrivateKey; let pub: PublicKey; let local: LocalIdentity; beforeEach(() => { priv = PrivateKey.generate(); pub = priv.toPublicKey(); local = new LocalIdentity(priv, pub); }); it("constructs from PrivateKey and PublicKey", () => { expect(local.publicKey.toBytes()).toEqual(pub.toBytes()); }); it("constructs from Uint8Array and string", () => { const priv2 = PrivateKey.generate(); const pub2 = priv2.toPublicKey(); const local2 = new LocalIdentity(priv2.toBytes(), pub2.toString()); expect(local2.publicKey.toBytes()).toEqual(pub2.toBytes()); }); it("signs message", () => { const msg = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]); const sig = local.sign(msg); expect(sig).toBeInstanceOf(Uint8Array); expect(sig.length).toBeGreaterThan(0); }); it("calculates shared secret with Identity", () => { const otherPriv = PrivateKey.generate(); const other = new Identity(otherPriv.toPublicKey().toBytes()); const secret = local.calculateSharedSecret(other); expect(secret).toBeInstanceOf(SharedSecret); }); it("calculates shared secret with IPublicKey", () => { const otherPriv = PrivateKey.generate(); const otherPub = otherPriv.toPublicKey(); const secret = local.calculateSharedSecret(otherPub); expect(secret).toBeInstanceOf(SharedSecret); }); it("calculateSharedSecret throws on invalid input", () => { const other: any = {}; expect(() => local.calculateSharedSecret(other)).toThrow(); }); }); describe("Contact", () => { let priv: PrivateKey; let pub: PublicKey; let identity: Identity; let contact: Contact; beforeEach(() => { priv = PrivateKey.generate(); pub = priv.toPublicKey(); identity = new Identity(pub.toBytes()); contact = new Contact("Alice", identity); }); it("constructs from Identity", () => { expect(contact.identity).toBe(identity); expect(contact.name).toBe("Alice"); }); /* eslint-disable @typescript-eslint/no-explicit-any */ it("constructor throws on invalid type", () => { expect(() => new (Contact as any)("X", 123)).toThrow(); }); it("constructs from Uint8Array", () => { const c = new Contact("Bob", pub.toBytes()); expect(c.identity.publicKey.toBytes()).toEqual(pub.toBytes()); }); it("matches works", () => { expect(contact.matches(pub)).toBe(true); expect(contact.matches(pub.toBytes())).toBe(true); expect(contact.matches(new PublicKey(randomBytes(32)))).toBe(false); }); it("publicKey returns PublicKey", () => { expect(contact.publicKey()).toBeInstanceOf(PublicKey); }); it("calculateSharedSecret delegates", () => { const me = new LocalIdentity(priv, pub); const secret = contact.calculateSharedSecret(me); expect(secret).toBeInstanceOf(SharedSecret); }); }); describe("Group", () => { let group: Group; let secret: SharedSecret; let name: string; beforeEach(() => { name = "#test"; secret = SharedSecret.fromName(name); group = new Group(name, secret); }); it("constructs with and without secret", () => { const g1 = new Group(name, secret); expect(g1).toBeInstanceOf(Group); const g2 = new Group(name); expect(g2).toBeInstanceOf(Group); }); it("hash returns NodeHash", () => { expect(typeof group.hash()).toBe("number"); }); it("encryptText and decryptText roundtrip", () => { const plain: DecryptedGroupText = { timestamp: new Date(), textType: 1, attempt: 2, message: "hello" }; const { hmac, ciphertext } = group.encryptText(plain); const decrypted = group.decryptText(hmac, ciphertext); expect(decrypted.message).toBe(plain.message); expect(decrypted.textType).toBe(plain.textType); expect(decrypted.attempt).toBe(plain.attempt); expect(typeof decrypted.timestamp).toBe("object"); }); it("encryptData and decryptData roundtrip", () => { const plain: DecryptedGroupData = { timestamp: new Date(), data: randomBytes(10) }; const { hmac, ciphertext } = group.encryptData(plain); const decrypted = group.decryptData(hmac, ciphertext); expect(decrypted.data).toEqual(plain.data); expect(typeof decrypted.timestamp).toBe("object"); }); it("decryptText throws on short ciphertext", () => { expect(() => group.decryptText(randomBytes(16), Uint8Array.of(1, 2, 3, 4))).toThrow(); }); it("decryptData throws on short ciphertext", () => { expect(() => group.decryptData(randomBytes(16), Uint8Array.of(1, 2, 3))).toThrow(); }); }); describe("Contacts", () => { let contacts: Contacts; let local: LocalIdentity; let contact: Contact; let group: Group; beforeEach(() => { contacts = new Contacts(); const priv = PrivateKey.generate(); const pub = priv.toPublicKey(); local = new LocalIdentity(priv, pub); contact = new Contact("Alice", pub.toBytes()); group = new Group("Public"); }); it("addLocalIdentity and addContact", () => { contacts.addLocalIdentity(local); contacts.addContact(contact); // No error means success }); it("addGroup", () => { contacts.addGroup(group); }); it("decryptGroupText and decryptGroupData", () => { contacts.addGroup(group); const text: DecryptedGroupText = { timestamp: new Date(), textType: 1, attempt: 0, message: "hi" }; const { hmac, ciphertext } = group.encryptText(text); const res = contacts.decryptGroupText(group.hash(), hmac, ciphertext); expect(res.decrypted.message).toBe(text.message); const data: DecryptedGroupData = { timestamp: new Date(), data: randomBytes(8) }; const enc = group.encryptData(data); const res2 = contacts.decryptGroupData(group.hash(), enc.hmac, enc.ciphertext); expect(res2.decrypted.data).toEqual(data.data); }); it("decryptGroupText throws on unknown group", () => { expect(() => contacts.decryptGroupText(0x99, randomBytes(16), randomBytes(16))).toThrow(); }); it("decryptGroupData throws on unknown group", () => { expect(() => contacts.decryptGroupData(0x99, randomBytes(16), randomBytes(16))).toThrow(); }); it("decrypt throws on unknown source/destination", () => { contacts.addLocalIdentity(local); expect(() => contacts.decrypt(0x99, 0x99, randomBytes(16), randomBytes(16))).toThrow(); }); it("decrypt throws on decryption failure", () => { contacts.addLocalIdentity(local); contacts.addContact(contact); expect(() => contacts.decrypt(contact.identity.hash(), local.publicKey.key[0], randomBytes(16), randomBytes(16)) ).toThrow(); }); it("decrypt works for valid shared secret", () => { // Setup two identities that can communicate const privA = PrivateKey.generate(); const pubA = privA.toPublicKey(); const localA = new LocalIdentity(privA, pubA); const privB = PrivateKey.generate(); const pubB = privB.toPublicKey(); const contactB = new Contact("Bob", pubB); contacts.addLocalIdentity(localA); contacts.addContact(contactB); // Encrypt a message using the shared secret const shared = localA.calculateSharedSecret(contactB.identity); const msg = Uint8Array.from([2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37]); const { hmac, ciphertext } = shared.encrypt(msg); const srcHash = contactB.identity.hash(); const dstHash = localA.publicKey.key[0]; const res = contacts.decrypt(srcHash, dstHash, hmac, ciphertext); expect(res.decrypted).toEqual(msg); expect(res.contact.name).toBe("Bob"); expect(res.localIdentity.publicKey.toBytes()).toEqual(pubA.toBytes()); }); /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ it("group decryption falls back when first group fails", () => { // Setup a group that will succeed and a fake one that throws const name = "#fallback"; const real = new Group(name); contacts.addGroup(real); const hash = real.hash(); const fake = { name, decryptText: (_h: Uint8Array, _c: Uint8Array) => { throw new Error("fail"); } } as any; // Inject fake group before real one (contacts as any).groups[hash] = [fake, real]; const text = { timestamp: new Date(), textType: 1, attempt: 0, message: "hi" } as DecryptedGroupText; const enc = real.encryptText(text); const res = contacts.decryptGroupText(hash, enc.hmac, enc.ciphertext); expect(res.decrypted.message).toBe(text.message); expect(res.group).toBe(real); }); /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ it("group data decryption falls back when first group fails", () => { const name = "#fallbackdata"; const real = new Group(name); contacts.addGroup(real); const hash = real.hash(); const fake = { name, decryptData: (_h: Uint8Array, _c: Uint8Array) => { throw new Error("fail"); } } as any; (contacts as any).groups[hash] = [fake, real]; const data = { timestamp: new Date(), data: randomBytes(8) } as DecryptedGroupData; const enc = real.encryptData(data); const res = contacts.decryptGroupData(hash, enc.hmac, enc.ciphertext); expect(res.decrypted.data).toEqual(data.data); expect(res.group).toBe(real); }); it("decryptText throws when decrypted payload is too short", () => { const name = "#short"; const secret = SharedSecret.fromName(name); const group = new Group(name, secret); // Create ciphertext that decrypts to a payload shorter than 5 bytes const small = new Uint8Array([1, 2, 3, 4]); const enc = secret.encrypt(small); expect(() => group.decryptText(enc.hmac, enc.ciphertext)).toThrow(/Invalid ciphertext/); }); it("decryptData throws when decrypted payload is too short", () => { const name = "#shortdata"; const secret = SharedSecret.fromName(name); const group = new Group(name, secret); const small = new Uint8Array([1, 2, 3]); const enc = secret.encrypt(small); expect(() => group.decryptData(enc.hmac, enc.ciphertext)).toThrow(/Invalid ciphertext/); }); it("decrypt throws on unknown destination hash", () => { contacts.addContact(contact); expect(() => contacts.decrypt(contact.identity.hash(), 0x99, randomBytes(16), randomBytes(16))).toThrow( /Unknown destination hash/ ); }); /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ it("decryptGroupText throws when all groups fail", () => { const name = "#onlyfail"; const group = new Group(name); const hash = group.hash(); const fake = { decryptText: (_h: Uint8Array, _c: Uint8Array) => { throw new Error("fail"); } } as any; (contacts as any).groups[hash] = [fake]; expect(() => contacts.decryptGroupText(hash, randomBytes(16), randomBytes(16))).toThrow( /Decryption failed with all known groups/ ); }); /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ it("decryptGroupData throws when all groups fail", () => { const name = "#onlyfaildata"; const group = new Group(name); const hash = group.hash(); const fake = { decryptData: (_h: Uint8Array, _c: Uint8Array) => { throw new Error("fail"); } } as any; (contacts as any).groups[hash] = [fake]; expect(() => contacts.decryptGroupData(hash, randomBytes(16), randomBytes(16))).toThrow( /Decryption failed with all known groups/ ); }); it("decrypt accepts PublicKey as source", () => { // Setup two identities that can communicate const privA = PrivateKey.generate(); const pubA = privA.toPublicKey(); const localA = new LocalIdentity(privA, pubA); const privB = PrivateKey.generate(); const pubB = privB.toPublicKey(); const contactB = new Contact("Bob", pubB); contacts.addLocalIdentity(localA); contacts.addContact(contactB); const shared = localA.calculateSharedSecret(contactB.identity); const msg = randomBytes(12); const { hmac, ciphertext } = shared.encrypt(msg); const res = contacts.decrypt(pubB, localA.publicKey.key[0], hmac, ciphertext); expect(res.decrypted).toEqual(msg); expect(res.contact.name).toBe("Bob"); }); it("decrypt with unknown PublicKey creates temporary contact and fails", () => { contacts.addLocalIdentity(local); const unknownPub = new PublicKey(randomBytes(32)); expect(() => contacts.decrypt(unknownPub, local.publicKey.key[0], randomBytes(16), randomBytes(16))).toThrow(); }); it("LocalIdentity.calculateSharedSecret handles objects with publicKey variants", () => { const priv = PrivateKey.generate(); const pub = priv.toPublicKey(); const localId = new LocalIdentity(priv, pub); const s1 = localId.calculateSharedSecret(pub); expect(s1).toBeInstanceOf(SharedSecret); const obj2 = new Identity(pub.toBytes()); const s2 = localId.calculateSharedSecret(obj2); expect(s2).toBeInstanceOf(SharedSecret); const obj3 = new Identity(pub); const s3 = localId.calculateSharedSecret(obj3); expect(s3).toBeInstanceOf(SharedSecret); }); it("decrypt uses cached shared secret on repeated attempts", () => { // Setup two identities that can communicate const privA = PrivateKey.generate(); const pubA = privA.toPublicKey(); const localA = new LocalIdentity(privA, pubA); const privB = PrivateKey.generate(); const pubB = privB.toPublicKey(); const contactB = new Contact("Bob", pubB); contacts.addLocalIdentity(localA); contacts.addContact(contactB); const shared = localA.calculateSharedSecret(contactB.identity); const msg = randomBytes(12); const { hmac, ciphertext } = shared.encrypt(msg); const res1 = contacts.decrypt(contactB.identity.hash(), localA.publicKey.key[0], hmac, ciphertext); expect(res1.decrypted).toEqual(msg); // Second call should hit cached shared secret path const res2 = contacts.decrypt(contactB.identity.hash(), localA.publicKey.key[0], hmac, ciphertext); expect(res2.decrypted).toEqual(msg); }); });