diff --git a/src/contact.ts b/src/contact.ts deleted file mode 100644 index a5baa93..0000000 --- a/src/contact.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ecb } from '@noble/ciphers/aes.js'; -import { hmac } from '@noble/hashes/hmac.js'; -import { sha256 } from "@noble/hashes/sha2.js"; -import { BaseGroup, BaseGroupSecret, DecryptedGroupData, DecryptedGroupText, EncryptedPayload } from "./packet.types"; -import { BufferReader, equalBytes, hexToBytes } from "./parser"; - -// The "Public" group is a special group that all nodes are implicitly part of. It uses a fixed secret derived from the string "Public". -const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72"); - -export class Group extends BaseGroup {} - -export class GroupSecret extends BaseGroupSecret { - public static fromName(name: string): GroupSecret { - if (name === "Public") { - return new GroupSecret(publicSecret); - } else if (!/^#/.test(name)) { - throw new Error("Only the 'Public' group or groups starting with '#' are supported"); - } - const hash = sha256.create().update(new TextEncoder().encode(name)).digest(); - return new GroupSecret(hash.slice(0, 16)); - } - - public decryptText(encrypted: EncryptedPayload): DecryptedGroupText { - const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText); - if (data.length < 8) { - throw new Error("Invalid ciphertext"); - } - - const reader = new BufferReader(data); - const timestamp = reader.readTimestamp(); - const flags = reader.readByte(); - const textType = (flags >> 2) & 0x3F; - const attempt = flags & 0x03; - const message = new TextDecoder('utf-8').decode(reader.readBytes()); - return { - timestamp, - textType, - attempt, - message - } - } - - public decryptData(encrypted: EncryptedPayload): DecryptedGroupData { - const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText); - if (data.length < 8) { - throw new Error("Invalid ciphertext"); - } - - const reader = new BufferReader(data); - return { - timestamp: reader.readTimestamp(), - data: reader.readBytes(reader.remainingBytes()) - }; - } - - private macThenDecrypt(cipherMAC: Uint8Array, cipherText: Uint8Array): Uint8Array { - const mac = hmac(sha256, this.secret, cipherText); - if (!equalBytes(mac, cipherMAC)) { - throw new Error("Invalid MAC"); - } - - const block = ecb(this.secret.slice(0, 16), { disablePadding: true }); - const plain = block.decrypt(cipherText); - - return plain; - } -} diff --git a/src/crypto.ts b/src/crypto.ts index 6181377..f105de4 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -13,6 +13,9 @@ const SHARED_SECRET_SIZE = 32; const SIGNATURE_SIZE = 64; const STATIC_SECRET_SIZE = 32; +// The "Public" group is a special group that all nodes are implicitly part of. +const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72", 16); + export class PublicKey implements IPublicKey { public key: Uint8Array; @@ -194,7 +197,7 @@ export class SharedSecret implements ISharedSecret { static fromName(name: string): SharedSecret { if (name === "Public") { - return new SharedSecret(hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72", 16)); + return new SharedSecret(publicSecret); } else if (!/^#/.test(name)) { throw new Error("Only the 'Public' group or groups starting with '#' are supported"); } diff --git a/src/crypto.types.ts b/src/crypto.types.ts index 231d0c1..7490d75 100644 --- a/src/crypto.types.ts +++ b/src/crypto.types.ts @@ -1,4 +1,4 @@ -import { NodeHash } from "./packet.types"; +import { NodeHash } from "./identity.types"; export interface IPublicKey { toHash(): NodeHash; diff --git a/src/identity.test.ts b/src/identity.test.ts index ab3742d..e07a28b 100644 --- a/src/identity.test.ts +++ b/src/identity.test.ts @@ -67,6 +67,16 @@ describe('Identity', () => { 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', () => { @@ -92,7 +102,7 @@ describe('LocalIdentity', () => { }); it('signs message', () => { - const msg = randomBytes(12); + 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); @@ -111,6 +121,11 @@ describe('LocalIdentity', () => { 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', () => { @@ -131,6 +146,10 @@ describe('Contact', () => { 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()); @@ -294,7 +313,7 @@ describe('Contacts', () => { // Encrypt a message using the shared secret const shared = localA.calculateSharedSecret(contactB.identity); - const msg = randomBytes(12); + 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(); @@ -305,4 +324,155 @@ describe('Contacts', () => { 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); + }); }); diff --git a/src/packet.test.ts b/src/packet.test.ts index 3540ce1..56dd728 100644 --- a/src/packet.test.ts +++ b/src/packet.test.ts @@ -136,8 +136,8 @@ describe('Packet decode branches and transport/path parsing', () => { const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload)); const req = pkt.decode() as RequestPayload; expect(req.type).toBe(PayloadType.REQUEST); - expect(req.dst).toBe('aa'); - expect(req.src).toBe('bb'); + expect(req.dst).toBe(0xAA); + expect(req.src).toBe(0xBB); const resp = Packet.fromBytes(makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as ResponsePayload; expect(resp.type).toBe(PayloadType.RESPONSE); @@ -186,7 +186,7 @@ describe('Packet decode branches and transport/path parsing', () => { const payload = new Uint8Array([dst, ...pub, ...enc]); const ar = Packet.fromBytes(makePacket(PayloadType.ANON_REQ, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as AnonReqPayload; expect(ar.type).toBe(PayloadType.ANON_REQ); - expect(ar.dst).toBe('12'); + expect(ar.dst).toBe(0x12); }); test('PATH and TRACE decode nodes', () => { diff --git a/src/packet.ts b/src/packet.ts index 1ff3e6f..b8978fd 100644 --- a/src/packet.ts +++ b/src/packet.ts @@ -39,7 +39,7 @@ export class Packet implements IPacket { public pathHashCount: number; public pathHashSize: number; public pathHashBytes: number; - public pathHashes: NodeHash[]; + public pathHashes: string[]; constructor(header: number, transport: [number, number] | undefined, pathLength: number, path: Uint8Array, payload: Uint8Array) { this.header = header; @@ -142,8 +142,8 @@ export class Packet implements IPacket { const reader = new BufferReader(this.payload); return { type: PayloadType.REQUEST, - dst: bytesToHex(reader.readBytes(1)), - src: bytesToHex(reader.readBytes(1)), + dst: reader.readByte(), + src: reader.readByte(), encrypted: this.decodeEncryptedPayload(reader), } } @@ -156,8 +156,8 @@ export class Packet implements IPacket { const reader = new BufferReader(this.payload); return { type: PayloadType.RESPONSE, - dst: bytesToHex(reader.readBytes(1)), - src: bytesToHex(reader.readBytes(1)), + dst: reader.readByte(), + src: reader.readByte(), encrypted: this.decodeEncryptedPayload(reader), } } @@ -170,8 +170,8 @@ export class Packet implements IPacket { const reader = new BufferReader(this.payload); return { type: PayloadType.TEXT, - dst: bytesToHex(reader.readBytes(1)), - src: bytesToHex(reader.readBytes(1)), + dst: reader.readByte(), + src: reader.readByte(), encrypted: this.decodeEncryptedPayload(reader), } } @@ -244,7 +244,7 @@ export class Packet implements IPacket { const reader = new BufferReader(this.payload); return { type: PayloadType.GROUP_TEXT, - channelHash: bytesToHex(reader.readBytes(1)), + channelHash: reader.readByte(), encrypted: this.decodeEncryptedPayload(reader), } } @@ -257,7 +257,7 @@ export class Packet implements IPacket { const reader = new BufferReader(this.payload); return { type: PayloadType.GROUP_DATA, - channelHash: bytesToHex(reader.readBytes(1)), + channelHash: reader.readByte(), encrypted: this.decodeEncryptedPayload(reader), } } @@ -270,7 +270,7 @@ export class Packet implements IPacket { const reader = new BufferReader(this.payload); return { type: PayloadType.ANON_REQ, - dst: bytesToHex(reader.readBytes(1)), + dst: reader.readByte(), publicKey: reader.readBytes(32), encrypted: this.decodeEncryptedPayload(reader), } @@ -284,8 +284,8 @@ export class Packet implements IPacket { const reader = new BufferReader(this.payload); return { type: PayloadType.PATH, - dst: bytesToHex(reader.readBytes(1)), - src: bytesToHex(reader.readBytes(1)), + dst: reader.readByte(), + src: reader.readByte(), } }