import { describe, it, expect, beforeEach } from 'vitest'; import { Identity, LocalIdentity, Contact, Group, Contacts, parseNodeHash } from '../src/identity'; import { PrivateKey, PublicKey, SharedSecret } from '../src/crypto'; import { DecryptedGroupText, DecryptedGroupData } from '../src/packet.types'; import { bytesToHex } from '../src/parser'; 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 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); }); 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); 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'); }); 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()); }); 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); }); 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/); }); 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/); }); 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 obj1 = { publicKey: pub.toBytes() } as any; const s1 = localId.calculateSharedSecret(obj1); expect(s1).toBeInstanceOf(SharedSecret); const obj2 = { publicKey: pub } as any; const s2 = localId.calculateSharedSecret(obj2); expect(s2).toBeInstanceOf(SharedSecret); const obj3 = { publicKey: () => pub } as any; 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); }); });