diff --git a/src/contact.ts b/src/contact.ts new file mode 100644 index 0000000..a5baa93 --- /dev/null +++ b/src/contact.ts @@ -0,0 +1,67 @@ +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.test.ts b/src/crypto.test.ts new file mode 100644 index 0000000..d4e4d3c --- /dev/null +++ b/src/crypto.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect } from 'vitest'; +import { PublicKey, PrivateKey, SharedSecret, StaticSecret } from './crypto'; +import { bytesToHex, hexToBytes } from './parser'; + +const randomBytes = (len: number) => Uint8Array.from({ length: len }, () => Math.floor(Math.random() * 256)); + +describe('PublicKey', () => { + const keyBytes = randomBytes(32); + const keyHex = bytesToHex(keyBytes); + + it('constructs from Uint8Array', () => { + const pk = new PublicKey(keyBytes); + expect(pk.toBytes()).toEqual(keyBytes); + }); + + it('constructs from string', () => { + const pk = new PublicKey(keyHex); + expect(pk.toBytes()).toEqual(keyBytes); + }); + + it('throws on invalid constructor input', () => { + // @ts-expect-error + expect(() => new PublicKey(123)).toThrow(); + }); + + it('toHash returns a NodeHash', () => { + const pk = new PublicKey(keyBytes); + expect(typeof pk.toHash()).toBe('number'); + }); + + it('toString returns hex', () => { + const pk = new PublicKey(keyBytes); + expect(pk.toString()).toBe(keyHex); + }); + + it('equals works for PublicKey, Uint8Array, and string', () => { + const pk = new PublicKey(keyBytes); + expect(pk.equals(pk)).toBe(true); + expect(pk.equals(keyBytes)).toBe(true); + expect(pk.equals(keyHex)).toBe(true); + expect(pk.equals(randomBytes(32))).toBe(false); + }); + + it('throws on equals with invalid type', () => { + const pk = new PublicKey(keyBytes); + // @ts-expect-error + expect(() => pk.equals(123)).toThrow(); + }); + + it('verify returns false for invalid signature', () => { + const pk = new PublicKey(keyBytes); + expect(pk.verify(new Uint8Array([1, 2, 3]), randomBytes(64))).toBe(false); + }); + + it('throws on verify with wrong signature length', () => { + const pk = new PublicKey(keyBytes); + expect(() => pk.verify(new Uint8Array([1, 2, 3]), randomBytes(10))).toThrow(); + }); +}); + +describe('PrivateKey', () => { + const seed = randomBytes(32); + + it('constructs from Uint8Array', () => { + const sk = new PrivateKey(seed); + expect(sk.toPublicKey()).toBeInstanceOf(PublicKey); + }); + + it('constructs from string', () => { + const sk = new PrivateKey(bytesToHex(seed)); + expect(sk.toPublicKey()).toBeInstanceOf(PublicKey); + }); + + it('throws on invalid seed length', () => { + expect(() => new PrivateKey(randomBytes(10))).toThrow(); + }); + + it('sign and verify', () => { + const sk = new PrivateKey(seed); + const pk = sk.toPublicKey(); + const msg = new Uint8Array([1, 2, 3]); + const sig = sk.sign(msg); + expect(pk.verify(msg, sig)).toBe(true); + }); + + it('calculateSharedSecret returns Uint8Array', () => { + const sk1 = new PrivateKey(seed); + const sk2 = PrivateKey.generate(); + const pk2 = sk2.toPublicKey(); + const secret = sk1.calculateSharedSecret(pk2); + expect(secret).toBeInstanceOf(Uint8Array); + expect(secret.length).toBeGreaterThan(0); + }); + + it('calculateSharedSecret accepts string and Uint8Array', () => { + const sk1 = new PrivateKey(seed); + const sk2 = PrivateKey.generate(); + const pk2 = sk2.toPublicKey(); + expect(sk1.calculateSharedSecret(pk2.toBytes())).toBeInstanceOf(Uint8Array); + expect(sk1.calculateSharedSecret(pk2.toString())).toBeInstanceOf(Uint8Array); + }); + + it('throws on calculateSharedSecret with invalid type', () => { + const sk = new PrivateKey(seed); + // @ts-expect-error + expect(() => sk.calculateSharedSecret(123)).toThrow(); + }); + + it('generate returns PrivateKey', () => { + expect(PrivateKey.generate()).toBeInstanceOf(PrivateKey); + }); +}); + +describe('SharedSecret', () => { + const secret = randomBytes(32); + + it('constructs from 32 bytes', () => { + const ss = new SharedSecret(secret); + expect(ss.toBytes()).toEqual(secret); + }); + + it('pads if 16 bytes', () => { + const short = randomBytes(16); + const ss = new SharedSecret(short); + expect(ss.toBytes().length).toBe(32); + expect(Array.from(ss.toBytes()).slice(16)).toEqual(Array.from(short)); + }); + + it('throws on invalid length', () => { + expect(() => new SharedSecret(randomBytes(10))).toThrow(); + }); + + it('toHash returns number', () => { + const ss = new SharedSecret(secret); + expect(typeof ss.toHash()).toBe('number'); + }); + + it('toString returns hex', () => { + const ss = new SharedSecret(secret); + expect(ss.toString()).toBe(bytesToHex(secret)); + }); + + it('encrypt and decrypt roundtrip', () => { + const ss = new SharedSecret(secret); + const data = new Uint8Array([1, 2, 3, 4, 5]); + const { hmac, ciphertext } = ss.encrypt(data); + const decrypted = ss.decrypt(hmac, ciphertext); + expect(Array.from(decrypted.slice(0, data.length))).toEqual(Array.from(data)); + }); + + it('throws on decrypt with wrong hmac', () => { + const ss = new SharedSecret(secret); + const data = new Uint8Array([1, 2, 3]); + const { ciphertext } = ss.encrypt(data); + expect(() => ss.decrypt(new Uint8Array([0, 0]), ciphertext)).toThrow(); + }); + + it('throws on decrypt with wrong hmac length', () => { + const ss = new SharedSecret(secret); + expect(() => ss.decrypt(new Uint8Array([1]), new Uint8Array([1, 2, 3]))).toThrow(); + }); + + it('fromName "Public"', () => { + const ss = SharedSecret.fromName("Public"); + expect(ss).toBeInstanceOf(SharedSecret); + }); + + it('fromName with #group', () => { + const ss = SharedSecret.fromName("#group"); + expect(ss).toBeInstanceOf(SharedSecret); + }); + + it('fromName throws on invalid name', () => { + expect(() => SharedSecret.fromName("foo")).toThrow(); + }); +}); + +describe('StaticSecret', () => { + const secret = randomBytes(32); + + it('constructs from Uint8Array', () => { + const ss = new StaticSecret(secret); + expect(ss.publicKey()).toBeInstanceOf(PublicKey); + }); + + it('constructs from string', () => { + const ss = new StaticSecret(bytesToHex(secret)); + expect(ss.publicKey()).toBeInstanceOf(PublicKey); + }); + + it('throws on invalid length', () => { + expect(() => new StaticSecret(randomBytes(10))).toThrow(); + }); + + it('diffieHellman returns SharedSecret', () => { + const ss1 = new StaticSecret(secret); + const ss2 = new StaticSecret(randomBytes(32)); + const pk2 = ss2.publicKey(); + const shared = ss1.diffieHellman(pk2); + expect(shared).toBeInstanceOf(SharedSecret); + }); +}); diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..fe4a569 --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,238 @@ +import { ed25519, x25519 } from '@noble/curves/ed25519.js'; +import { sha256 } from "@noble/hashes/sha2.js"; +import { hmac } from '@noble/hashes/hmac.js'; +import { ecb, ecb, encrypt } from '@noble/ciphers/aes.js'; +import { bytesToHex, equalBytes, hexToBytes, encodedStringToBytes } from "./parser"; +import { IPublicKey, ISharedSecret, IStaticSecret } from './crypto.types'; +import { NodeHash } from './identity.types'; + +const PUBLIC_KEY_SIZE = 32; +const SEED_SIZE = 32; +const PRIVATE_KEY_SIZE = 32; +const HMAC_SIZE = 2; +const SHARED_SECRET_SIZE = 32; +const SIGNATURE_SIZE = 64; +const STATIC_SECRET_SIZE = 32; + +export class PublicKey implements IPublicKey { + public key: Uint8Array; + + constructor(key: Uint8Array | string) { + if (typeof key === 'string') { + this.key = encodedStringToBytes(key, PUBLIC_KEY_SIZE); + } else if (key instanceof Uint8Array) { + this.key = key; + } else { + throw new Error('Invalid type for PublicKey constructor'); + } + } + + public toHash(): NodeHash { + return sha256.create().update(this.key).digest()[0] as NodeHash; + } + + public toBytes(): Uint8Array { + return this.key; + } + + public toString(): string { + return bytesToHex(this.key); + } + + public equals(other: PublicKey | Uint8Array | string): boolean { + let otherKey: Uint8Array; + if (other instanceof PublicKey) { + otherKey = other.toBytes(); + } else if (other instanceof Uint8Array) { + otherKey = other; + } else if (typeof other === 'string') { + otherKey = encodedStringToBytes(other, PUBLIC_KEY_SIZE); + } else { + throw new Error('Invalid type for PublicKey comparison'); + } + return equalBytes(this.key, otherKey); + } + + public verify(message: Uint8Array, signature: Uint8Array): boolean { + if (signature.length !== SIGNATURE_SIZE) { + throw new Error(`Invalid signature length: expected ${SIGNATURE_SIZE} bytes, got ${signature.length}`); + } + return ed25519.verify(signature, message, this.key); + } +} + +export class PrivateKey { + private secretKey: Uint8Array; + private publicKey: PublicKey; + + constructor(seed: Uint8Array | string) { + if (typeof seed === 'string') { + seed = encodedStringToBytes(seed, SEED_SIZE); + } + if (seed.length !== SEED_SIZE) { + throw new Error(`Invalid seed length: expected ${SEED_SIZE} bytes, got ${seed.length}`); + } + + const { secretKey, publicKey } = ed25519.keygen(seed); // Validate seed by generating keys + this.secretKey = secretKey; + this.publicKey = new PublicKey(publicKey); + } + + public toPublicKey(): PublicKey { + return this.publicKey; + } + + public toBytes(): Uint8Array { + return this.secretKey; + } + + public toString(): string { + return bytesToHex(this.secretKey); + } + + public sign(message: Uint8Array): Uint8Array { + return ed25519.sign(message, this.secretKey); + } + + public calculateSharedSecret(other: PublicKey | Uint8Array | string): Uint8Array { + let otherPublicKey: PublicKey; + if (other instanceof PublicKey) { + otherPublicKey = other; + } else if (other instanceof Uint8Array) { + otherPublicKey = new PublicKey(other); + } else if (typeof other === 'string') { + otherPublicKey = new PublicKey(other); + } else { + throw new Error('Invalid type for calculateSharedSecret comparison'); + } + return x25519.getSharedSecret(this.secretKey, otherPublicKey.toBytes()); + } + + static generate(): PrivateKey { + const { secretKey } = ed25519.keygen(); // Ensure ed25519 is initialized + return new PrivateKey(secretKey); + } +} + +export class SharedSecret implements ISharedSecret { + private secret: Uint8Array; + + constructor(secret: Uint8Array) { + if (secret.length === SHARED_SECRET_SIZE / 2) { + // Zero pad to the left if the secret is too short (e.g. from x25519) + const padded = new Uint8Array(SHARED_SECRET_SIZE); + padded.set(secret, SHARED_SECRET_SIZE - secret.length); + secret = padded; + } + if (secret.length !== SHARED_SECRET_SIZE) { + throw new Error(`Invalid shared secret length: expected ${SHARED_SECRET_SIZE} bytes, got ${secret.length}`); + } + this.secret = secret; + } + + public toHash(): NodeHash { + return this.secret[0] as NodeHash; + } + + public toBytes(): Uint8Array { + return this.secret; + } + + public toString(): string { + return bytesToHex(this.secret); + } + + public decrypt(hmac: Uint8Array, ciphertext: Uint8Array): Uint8Array { + if (hmac.length !== HMAC_SIZE) { + throw new Error(`Invalid HMAC length: expected ${HMAC_SIZE} bytes, got ${hmac.length}`); + } + const expectedHmac = this.calculateHmac(ciphertext); + if (!equalBytes(hmac, expectedHmac)) { + throw new Error(`Invalid HMAC: decryption failed: expected ${bytesToHex(expectedHmac)}, got ${bytesToHex(hmac)}`); + } + const cipher = ecb(this.secret.slice(0, 16), { disablePadding: true }); + const plaintext = new Uint8Array(ciphertext.length); + for (let i = 0; i < ciphertext.length; i += 16) { + const block = ciphertext.slice(i, i + 16); + const dec = cipher.decrypt(block); + plaintext.set(dec, i); + } + // Remove trailing null bytes (0x00) due to padding + let end = plaintext.length; + while (end > 0 && plaintext[end - 1] === 0) { + end--; + } + return plaintext.slice(0, end); + } + + private zeroPad(data: Uint8Array): Uint8Array { + if (data.length % 16 === 0) { + return data; + } + const padded = new Uint8Array(Math.ceil(data.length / 16) * 16); + padded.set(data); + return padded; + } + + public encrypt(data: Uint8Array): { hmac: Uint8Array, ciphertext: Uint8Array } { + const key = this.secret.slice(0, 16); + const cipher = ecb(key, { disablePadding: true }); + + const fullBlocks = Math.floor(data.length / 16); + const remaining = data.length % 16; + const ciphertext = new Uint8Array((fullBlocks + (remaining > 0 ? 1 : 0)) * 16); + for (let i = 0; i < fullBlocks; i++) { + const block = data.slice(i * 16, (i + 1) * 16); + const enc = cipher.encrypt(block); + ciphertext.set(enc, i * 16); + } + + if (remaining > 0) { + const lastBlock = new Uint8Array(16); + lastBlock.set(data.slice(fullBlocks * 16)); + const enc = cipher.encrypt(lastBlock); + ciphertext.set(enc, fullBlocks * 16); + } + + const hmac = this.calculateHmac(ciphertext); + return { hmac, ciphertext }; + } + + private calculateHmac(data: Uint8Array): Uint8Array { + return hmac(sha256, this.secret, data).slice(0, HMAC_SIZE); + } + + static fromName(name: string): SharedSecret { + if (name === "Public") { + return new SharedSecret(hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72")); + } 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 SharedSecret(hash.slice(0, SHARED_SECRET_SIZE)); + } +} + +export class StaticSecret implements IStaticSecret { + private secret: Uint8Array; + + constructor(secret: Uint8Array | string) { + if (typeof secret === 'string') { + secret = encodedStringToBytes(secret, STATIC_SECRET_SIZE); + } + if (secret.length !== STATIC_SECRET_SIZE) { + throw new Error(`Invalid static secret length: expected ${STATIC_SECRET_SIZE} bytes, got ${secret.length}`); + } + this.secret = secret; + } + + public publicKey(): IPublicKey { + const publicKey = x25519.getPublicKey(this.secret); + return new PublicKey(publicKey); + } + + public diffieHellman(otherPublicKey: IPublicKey): SharedSecret { + const sharedSecret = x25519.getSharedSecret(this.secret, otherPublicKey.toBytes()); + return new SharedSecret(sharedSecret); + } +} diff --git a/src/crypto.types.ts b/src/crypto.types.ts new file mode 100644 index 0000000..231d0c1 --- /dev/null +++ b/src/crypto.types.ts @@ -0,0 +1,27 @@ +import { NodeHash } from "./packet.types"; + +export interface IPublicKey { + toHash(): NodeHash; + toBytes(): Uint8Array; + toString(): string; + equals(other: IPublicKey | Uint8Array | string): boolean; + verify(message: Uint8Array, signature: Uint8Array): boolean; +} + +export interface IPrivateKey extends IPublicKey { + toPublicKey(): IPublicKey; + sign(message: Uint8Array): Uint8Array; +} + +export interface ISharedSecret { + toHash(): NodeHash; + toBytes(): Uint8Array; + toString(): string; + decrypt(hmac: Uint8Array, ciphertext: Uint8Array): Uint8Array; + encrypt(data: Uint8Array): { hmac: Uint8Array, ciphertext: Uint8Array }; +} + +export interface IStaticSecret { + publicKey(): IPublicKey; + diffieHellman(otherPublicKey: IPublicKey): ISharedSecret; +} diff --git a/src/identity.test.ts b/src/identity.test.ts new file mode 100644 index 0000000..ab3742d --- /dev/null +++ b/src/identity.test.ts @@ -0,0 +1,308 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { Identity, LocalIdentity, Contact, Group, Contacts, parseNodeHash } from './identity'; +import { PrivateKey, PublicKey, SharedSecret } from './crypto'; +import { DecryptedGroupText, DecryptedGroupData } from './packet.types'; +import { bytesToHex } from './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); + }); +}); + +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 = randomBytes(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); + }); +}); + +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('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 = randomBytes(12); + 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()); + }); +}); diff --git a/src/identity.ts b/src/identity.ts index d9ab09e..8cea026 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -1,98 +1,163 @@ -import { ecb } from '@noble/ciphers/aes.js'; -import { hmac } from '@noble/hashes/hmac.js'; -import { sha256 } from "@noble/hashes/sha2.js"; -import { ed25519, x25519 } from '@noble/curves/ed25519.js'; -import { - BaseGroup, - BaseGroupSecret, - BaseIdentity, - BaseKeyManager, - BaseLocalIdentity, - Contact, - DecryptedAnonReq, - DecryptedGroupData, - DecryptedGroupText, - DecryptedRequest, - DecryptedResponse, - DecryptedText, - EncryptedPayload, - NodeHash, - Secret -} from "./types"; -import { BufferReader, bytesToHex, equalBytes, hexToBytes } from "./parser"; +import { x25519 } from "@noble/curves/ed25519"; +import { PrivateKey, PublicKey, SharedSecret, StaticSecret } from "./crypto"; +import { IPublicKey } from "./crypto.types"; +import { NodeHash, IIdentity, ILocalIdentity } from "./identity.types"; +import { DecryptedGroupData, DecryptedGroupText } from "./packet.types"; +import { equalBytes, hexToBytes, BufferReader, BufferWriter } 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 Identity extends BaseIdentity { - constructor(publicKey: Uint8Array | string) { - if (typeof publicKey === "string") { - publicKey = hexToBytes(publicKey); +export const parseNodeHash = (hash: NodeHash | string | Uint8Array): NodeHash => { + if (hash instanceof Uint8Array) { + return hash[0] as NodeHash; + } + if (typeof hash === "number") { + if (hash < 0 || hash > 255) { + throw new Error("NodeHash number must be between 0x00 and 0xFF"); } - super(publicKey); + return hash as NodeHash; + } else if (typeof hash === "string") { + const parsed = hexToBytes(hash); + if (parsed.length !== 1) { + throw new Error("NodeHash string must represent a single byte"); + } + return parsed[0] as NodeHash; } + throw new Error("Invalid NodeHash type"); +} - public hash(): NodeHash { - return bytesToHex(this.publicKey.slice(0, 1)); - } - - public verify(message: Uint8Array, signature: Uint8Array): boolean { - return ed25519.verify(message, signature, this.publicKey); +const toPublicKeyBytes = (key: Identity | PublicKey | Uint8Array | string): Uint8Array => { + if (key instanceof Identity) { + return key.publicKey.toBytes(); + } else if (key instanceof PublicKey) { + return key.toBytes(); + } else if (key instanceof Uint8Array) { + return key; + } else if (typeof key === 'string') { + return hexToBytes(key); + } else { + throw new Error('Invalid type for toPublicKeyBytes'); } } -export class LocalIdentity extends Identity implements BaseLocalIdentity { - public privateKey: Uint8Array; +export class Identity implements IIdentity { + public publicKey: PublicKey; - constructor(seed: Uint8Array | string) { - if (typeof seed === "string") { - seed = hexToBytes(seed); + constructor(publicKey: PublicKey | Uint8Array | string) { + if (publicKey instanceof PublicKey) { + this.publicKey = publicKey; + } else if (publicKey instanceof Uint8Array || typeof publicKey === 'string') { + this.publicKey = new PublicKey(publicKey); + } else { + throw new Error('Invalid type for Identity constructor'); } - const { secretKey, publicKey } = ed25519.keygen(seed); - super(publicKey); - this.privateKey = secretKey; } - public sign(message: Uint8Array): Uint8Array { - return ed25519.sign(message, this.privateKey); + hash(): NodeHash { + return this.publicKey.toHash(); } - public calculateSharedSecret(other: BaseIdentity | Uint8Array): Uint8Array { - if (other instanceof Uint8Array) { - return x25519.getSharedSecret(this.privateKey, other); - } - return x25519.getSharedSecret(this.privateKey, other.publicKey); + toString(): string { + return this.publicKey.toString(); } - public hash(): NodeHash { - return super.hash(); + verify(signature: Uint8Array, message: Uint8Array): boolean { + return this.publicKey.verify(message, signature); } - public verify(message: Uint8Array, signature: Uint8Array): boolean { - return super.verify(message, signature); + matches(other: Identity | PublicKey | Uint8Array | string): boolean { + return this.publicKey.equals(toPublicKeyBytes(other)); } } -export class Group extends BaseGroup {} +export class LocalIdentity extends Identity implements ILocalIdentity { + private privateKey: PrivateKey; -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"); + constructor(privateKey: PrivateKey | Uint8Array | string, publicKey: PublicKey | Uint8Array | string) { + if (publicKey instanceof PublicKey) { + super(publicKey.toBytes()); + } else { + super(publicKey); + } + + if (privateKey instanceof PrivateKey) { + this.privateKey = privateKey; + } else { + this.privateKey = new PrivateKey(privateKey); + } + } + + sign(message: Uint8Array): Uint8Array { + return this.privateKey.sign(message); + } + + calculateSharedSecret(other: IIdentity | IPublicKey): SharedSecret { + let otherPublicKey: PublicKey; + if (other instanceof Identity) { + otherPublicKey = other.publicKey; + } else if ('toBytes' in other) { + otherPublicKey = new PublicKey(other.toBytes()); + } else if ('publicKey' in other && other.publicKey instanceof Uint8Array) { + otherPublicKey = new PublicKey(other.publicKey); + } else if ('publicKey' in other && other.publicKey instanceof PublicKey) { + otherPublicKey = other.publicKey; + } else if ('publicKey' in other && typeof other.publicKey === 'function') { + otherPublicKey = new PublicKey(other.publicKey().toBytes()); + } else { + throw new Error('Invalid type for calculateSharedSecret comparison'); + } + return new SharedSecret(this.privateKey.calculateSharedSecret(otherPublicKey)); + } +} + +export class Contact { + public name: string = ""; + public identity: Identity; + + constructor(name: string, identity: Identity | PublicKey | Uint8Array | string) { + this.name = name; + if (identity instanceof Identity) { + this.identity = identity; + } else if (identity instanceof PublicKey) { + this.identity = new Identity(identity); + } else if (identity instanceof Uint8Array || typeof identity === 'string') { + this.identity = new Identity(identity); + } else { + throw new Error('Invalid type for Contact constructor'); + } + } + + public matches(hash: Uint8Array | PublicKey): boolean { + return this.identity.publicKey.equals(hash); + } + + public publicKey(): PublicKey { + return this.identity.publicKey; + } + + public calculateSharedSecret(me: LocalIdentity): SharedSecret { + return me.calculateSharedSecret(this.identity); + } +} + +export class Group { + public name: string; + private secret: SharedSecret; + + constructor(name: string, secret?: SharedSecret) { + this.name = name; + if (secret) { + this.secret = secret; + } else { + this.secret = SharedSecret.fromName(name); } - const hash = sha256.create().update(new TextEncoder().encode(name)).digest(); - return new GroupSecret(hash.slice(0, 16)); } public hash(): NodeHash { - return bytesToHex(this.secret.slice(0, 1)); + return this.secret.toHash(); } - public decryptText(encrypted: EncryptedPayload): DecryptedGroupText { - const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText); - if (data.length < 8) { + public decryptText(hmac: Uint8Array, ciphertext: Uint8Array): DecryptedGroupText { + const data = this.secret.decrypt(hmac, ciphertext); + if (data.length < 5) { throw new Error("Invalid ciphertext"); } @@ -110,9 +175,19 @@ export class GroupSecret extends BaseGroupSecret { } } - public decryptData(encrypted: EncryptedPayload): DecryptedGroupData { - const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText); - if (data.length < 8) { + public encryptText(plain: DecryptedGroupText): { hmac: Uint8Array, ciphertext: Uint8Array } { + const writer = new BufferWriter(); + writer.writeTimestamp(plain.timestamp); + const flags = ((plain.textType & 0x3F) << 2) | (plain.attempt & 0x03); + writer.writeByte(flags); + writer.writeBytes(new TextEncoder().encode(plain.message)); + const data = writer.toBytes(); + return this.secret.encrypt(data); + } + + public decryptData(hmac: Uint8Array, ciphertext: Uint8Array): DecryptedGroupData { + const data = this.secret.decrypt(hmac, ciphertext); + if (data.length < 4) { throw new Error("Invalid ciphertext"); } @@ -120,212 +195,147 @@ export class GroupSecret extends BaseGroupSecret { 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; + public encryptData(plain: DecryptedGroupData): { hmac: Uint8Array, ciphertext: Uint8Array } { + const writer = new BufferWriter(); + writer.writeTimestamp(plain.timestamp); + writer.writeBytes(plain.data); + const data = writer.toBytes(); + return this.secret.encrypt(data); } } -export class KeyManager extends BaseKeyManager { - private groups: Map = new Map(); - private contacts: Map = new Map(); - private localIdentities: Map = new Map(); +interface CachedLocalIdentity { + identity: LocalIdentity; + sharedSecrets: Record; +} - public addGroup(group: Group): void { - const hash = group.secret.hash(); - if (!this.groups.has(hash)) { - this.groups.set(hash, [group]); - } else { - this.groups.get(hash)!.push(group); - } +export class Contacts { + private localIdentities: CachedLocalIdentity[] = []; + private contacts: Record = {}; + private groups: Record = {}; + + public addLocalIdentity(identity: LocalIdentity) { + this.localIdentities.push({ identity, sharedSecrets: {} }); } - public addGroupSecret(name: string, secret?: Secret): void { - if (typeof secret === "undefined") { - secret = GroupSecret.fromName(name).secret; - } else if (typeof secret === "string") { - secret = hexToBytes(secret); + public addContact(contact: Contact) { + const hash = parseNodeHash(contact.identity.hash()) as number; + if (!this.contacts[hash]) { + this.contacts[hash] = []; } - this.addGroup(new Group(name, new GroupSecret(secret))); + this.contacts[hash].push(contact); } - public decryptGroupText(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupText, group: Group } { - const groupSecrets = this.groups.get(channelHash); - if (!groupSecrets) { - throw new Error("No group secrets for channel"); - } - - for (const group of groupSecrets) { - try { - const decrypted = group.decryptText(encrypted); - return { decrypted, group: group }; - } catch { - // Ignore and try next secret + public decrypt(src: NodeHash | PublicKey, dst: NodeHash, hmac: Uint8Array, ciphertext: Uint8Array): { + localIdentity: LocalIdentity, + contact: Contact, + decrypted: Uint8Array, + } { + // Find the public key associated with the source hash. + let contacts: Contact[] = []; + if (src instanceof PublicKey) { + // Check if we have a contact with this exact public key (for direct messages). + const srcHash = parseNodeHash(src.toHash()) as number; + for (const contact of this.contacts[srcHash] || []) { + if (contact.identity.matches(src)) { + contacts.push(contact); + } } - } - throw new Error("Failed to decrypt group text with any known secret"); - } - public decryptGroupData(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupData, group: Group } { - const groupSecrets = this.groups.get(channelHash); - if (!groupSecrets) { - throw new Error("No group secrets for channel"); - } - - for (const group of groupSecrets) { - try { - const decrypted = group.decryptData(encrypted); - return { decrypted, group }; - } catch { - // Ignore and try next secret + // If no contact matches the public key, add a temporary contact with the hash and no name. + if (contacts.length === 0) { + contacts.push(new Contact("", new Identity(src.toBytes()))); } - } - throw new Error("Failed to decrypt group data with any known secret"); - } - - public addContact(contact: Contact): void { - const hash = bytesToHex(contact.publicKey.slice(0, 1)); - if (!this.contacts.has(hash)) { - this.contacts.set(hash, [contact]); } else { - this.contacts.get(hash)!.push(contact); + const srcHash = parseNodeHash(src) as number; + contacts = this.contacts[srcHash] || []; } - } - - public addIdentity(name: string, publicKey: Uint8Array | string): void { - if (typeof publicKey === "string") { - publicKey = hexToBytes(publicKey); + if (contacts.length === 0) { + throw new Error("Unknown source hash"); } - this.addContact({ name, publicKey }); - } - public addLocalIdentity(seed: Secret): void { - const localIdentity = new LocalIdentity(seed); - const hash = localIdentity.hash(); - if (!this.localIdentities.has(hash)) { - this.localIdentities.set(hash, [localIdentity]); - } else { - this.localIdentities.get(hash)!.push(localIdentity); + // Find the local identity associated with the destination hash. + const dstHash = parseNodeHash(dst) as number; + const localIdentities = this.localIdentities.filter(li => li.identity.publicKey.key[0] === dstHash); + if (localIdentities.length === 0) { + throw new Error("Unknown destination hash"); } - } - - private tryDecrypt(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: Uint8Array, contact: Contact, identity: BaseIdentity } { - if (!this.localIdentities.has(dst)) { - throw new Error(`No local identities for destination ${dst}`); - } - const localIdentities = this.localIdentities.get(dst)!; - - if (!this.contacts.has(src)) { - throw new Error(`No contacts for source ${src}`); - } - const contacts = this.contacts.get(src)!; + // Try to decrypt with each combination of local identity and their public key. for (const localIdentity of localIdentities) { for (const contact of contacts) { - const sharedSecret = localIdentity.calculateSharedSecret(contact.publicKey); - const mac = hmac(sha256, sharedSecret, encrypted.cipherText); - if (!equalBytes(mac, encrypted.cipherMAC)) { - continue; // Invalid MAC, try next combination + const sharedSecret: SharedSecret = this.calculateSharedSecret(localIdentity, contact); + try { + const decrypted = sharedSecret.decrypt(hmac, ciphertext); + return { localIdentity: localIdentity.identity, contact, decrypted }; + } catch { + // Ignore decryption errors and try the next combination. } - - const block = ecb(sharedSecret.slice(0, 16), { disablePadding: true }); - const plain = block.decrypt(encrypted.cipherText); - if (plain.length < 8) { - continue; // Invalid plaintext, try next combination - } - return { decrypted: plain, contact, identity: localIdentity }; } } - throw new Error("Failed to decrypt with any known identity/contact combination"); + + throw new Error("Decryption failed with all known identities and contacts"); } - public decryptRequest(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedRequest, contact: Contact, identity: BaseIdentity } { - const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted); - const reader = new BufferReader(decrypted); - return { - decrypted: { - timestamp: reader.readTimestamp(), - requestType: reader.readByte(), - requestData: reader.readBytes(), - }, - contact, - identity + // Caches the calculated shared secret for a given local identity and contact to avoid redundant calculations. + private calculateSharedSecret(localIdentity: CachedLocalIdentity, contact: Contact): SharedSecret { + const cacheKey = contact.identity.toString(); + if (localIdentity.sharedSecrets[cacheKey]) { + return localIdentity.sharedSecrets[cacheKey]; } + const sharedSecret = localIdentity.identity.calculateSharedSecret(contact.identity); + localIdentity.sharedSecrets[cacheKey] = sharedSecret; + return sharedSecret; } - public decryptResponse(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedResponse, contact: Contact, identity: BaseIdentity } { - const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted); - const reader = new BufferReader(decrypted); - return { - decrypted: { - timestamp: reader.readTimestamp(), - responseData: reader.readBytes(), - }, - contact, - identity + public addGroup(group: Group) { + const hash = parseNodeHash(group.hash()) as number; + if (!this.groups[hash]) { + this.groups[hash] = []; } + this.groups[hash].push(group); } - public decryptText(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedText, contact: Contact, identity: BaseIdentity } { - const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted); - const reader = new BufferReader(decrypted); - 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 { - decrypted: { - timestamp, - textType, - attempt, - message - }, - contact, - identity + public decryptGroupText(channelHash: NodeHash, hmac: Uint8Array, ciphertext: Uint8Array): { + decrypted: DecryptedGroupText, + group: Group + } { + const hash = parseNodeHash(channelHash) as number; + const groups = this.groups[hash] || []; + if (groups.length === 0) { + throw new Error("Unknown group hash"); } - } - - public decryptAnonReq(dst: NodeHash, publicKey: Uint8Array, encrypted: EncryptedPayload): { decrypted: DecryptedAnonReq, contact: Contact, identity: BaseIdentity } { - if (!this.localIdentities.has(dst)) { - throw new Error(`No local identities for destination ${dst}`); - } - const localIdentities = this.localIdentities.get(dst)!; - - const contact = { publicKey } as Contact; // Create a temporary contact object for MAC verification - - for (const localIdentity of localIdentities) { - const sharedSecret = localIdentity.calculateSharedSecret(publicKey); - const mac = hmac(sha256, sharedSecret, encrypted.cipherText); - if (!equalBytes(mac, encrypted.cipherMAC)) { - continue; // Invalid MAC, try next identity - } - - const block = ecb(sharedSecret.slice(0, 16), { disablePadding: true }); - const plain = block.decrypt(encrypted.cipherText); - if (plain.length < 8) { - continue; // Invalid plaintext, try next identity - } - const reader = new BufferReader(plain); - return { - decrypted: { - timestamp: reader.readTimestamp(), - data: reader.readBytes(), - }, - contact, - identity: localIdentity + for (const group of groups) { + try { + const decrypted = group.decryptText(hmac, ciphertext); + return { decrypted, group }; + } catch { + // Ignore decryption errors and try the next group. } } - throw new Error("Failed to decrypt anonymous request with any known identity"); + throw new Error("Decryption failed with all known groups"); + } + + public decryptGroupData(channelHash: NodeHash, hmac: Uint8Array, ciphertext: Uint8Array): { + decrypted: DecryptedGroupData, + group: Group + } { + const hash = parseNodeHash(channelHash) as number; + const groups = this.groups[hash] || []; + if (groups.length === 0) { + throw new Error("Unknown group hash"); + } + for (const group of groups) { + try { + const decrypted = group.decryptData(hmac, ciphertext); + return { decrypted, group }; + } catch { + // Ignore decryption errors and try the next group. + } + } + throw new Error("Decryption failed with all known groups"); } } diff --git a/src/identity.types.ts b/src/identity.types.ts new file mode 100644 index 0000000..fb73ac4 --- /dev/null +++ b/src/identity.types.ts @@ -0,0 +1,21 @@ +import { IPublicKey, ISharedSecret } from "./crypto.types"; + +export type NodeHash = number; // 1 byte hash represented as hex string + +export interface IIdentity { + hash(): NodeHash; + toString(): string; + verify(signature: Uint8Array, message: Uint8Array): boolean; + matches(other: IIdentity | IPublicKey | Uint8Array | string): boolean; +} + +export interface ILocalIdentity extends IIdentity { + sign(message: Uint8Array): Uint8Array; + calculateSharedSecret(other: IIdentity | IPublicKey): ISharedSecret; +} + +export interface IContact { + name: string; + identity: IIdentity; + calculateSharedSecret(me: ILocalIdentity): ISharedSecret; +} diff --git a/src/index.ts b/src/index.ts index 38d04af..e90bbaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ -import { Packet } from "./packet"; -import { - RouteType, - PayloadType, -} from "./types"; +export * as types from './packet.types'; +export * from './parser'; +export * from './identity'; +export { Packet } from './packet'; + +import { Packet as _Packet } from './packet'; +import { RouteType, PayloadType } from './packet.types'; export default { - Packet, + Packet: _Packet, RouteType, PayloadType, }; diff --git a/test/packet.test.ts b/src/packet.test.ts similarity index 97% rename from test/packet.test.ts rename to src/packet.test.ts index 383b635..3540ce1 100644 --- a/test/packet.test.ts +++ b/src/packet.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; -import { Packet } from '../src/packet'; -import { PayloadType, RouteType, NodeType, TracePayload, AdvertPayload, RequestPayload, TextPayload, ResponsePayload, RawCustomPayload, AnonReqPayload } from '../src/types'; -import { hexToBytes, bytesToHex } from '../src/parser'; +import { Packet } from './packet'; +import { PayloadType, RouteType, NodeType, TracePayload, AdvertPayload, RequestPayload, TextPayload, ResponsePayload, RawCustomPayload, AnonReqPayload } from './packet.types'; +import { hexToBytes, bytesToHex } from './parser'; describe('Packet.fromBytes', () => { test('frame 1: len=122 type=5 payload_len=99', () => { @@ -93,8 +93,9 @@ describe('Packet.fromBytes', () => { expect(adv.appdata.hasLocation).toBe(true); expect(adv.appdata.hasName).toBe(true); // location values: parser appears to scale values by 10 here, accept that - expect(adv.appdata.location[0] / 10).toBeCloseTo(51.45986, 5); - expect(adv.appdata.location[1] / 10).toBeCloseTo(5.45422, 5); + expect(adv.appdata.location).toBeDefined(); + expect(adv.appdata.location![0] / 10).toBeCloseTo(51.45986, 5); + expect(adv.appdata.location![1] / 10).toBeCloseTo(5.45422, 5); expect(adv.appdata.name).toBe('NL-EHV-VBGB-RPTR'); expect(pkt.hash().toUpperCase()).toBe('67C10F75168ECC8C'); }); diff --git a/src/packet.ts b/src/packet.ts index 6c420d1..b004812 100644 --- a/src/packet.ts +++ b/src/packet.ts @@ -18,7 +18,7 @@ import { TracePayload, type IPacket, type NodeHash -} from "./types"; +} from "./packet.types"; import { base64ToBytes, BufferReader, diff --git a/src/types.ts b/src/packet.types.ts similarity index 55% rename from src/types.ts rename to src/packet.types.ts index b0f1fd2..2790689 100644 --- a/src/types.ts +++ b/src/packet.types.ts @@ -1,4 +1,4 @@ -import { equalBytes, hexToBytes } from "./parser"; +import { NodeHash } from "./identity.types"; // IPacket contains the raw packet bytes. export type Uint16 = number; // 0..65535 @@ -201,96 +201,3 @@ export interface RawCustomPayload { type: PayloadType.RAW_CUSTOM; data: Uint8Array; } - -// NodeHash is a hex string of the hash of a node's (partial) public key. -export type NodeHash = string; - -/* Contact types and structures */ - -export interface Group { - name: string; - secret: BaseGroupSecret; -} - -export interface Contact { - name: string; - publicKey: Uint8Array; -} - -/* Identity and group management. */ - -export type Secret = Uint8Array | string; - -export abstract class BaseIdentity { - publicKey: Uint8Array; - - constructor(publicKey: Uint8Array) { - this.publicKey = publicKey; - } - - public abstract hash(): NodeHash; - public abstract verify(message: Uint8Array, signature: Uint8Array): boolean; - - public matches(other: BaseIdentity | BaseLocalIdentity): boolean { - return equalBytes(this.publicKey, other.publicKey); - } -} - -export abstract class BaseLocalIdentity extends BaseIdentity { - privateKey: Uint8Array; - - constructor(publicKey: Uint8Array, privateKey: Uint8Array) { - super(publicKey); - this.privateKey = privateKey; - } - - public abstract sign(message: Uint8Array): Uint8Array; - public abstract calculateSharedSecret(other: BaseIdentity | Uint8Array): Uint8Array; -} - -export abstract class BaseGroup { - name: string; - secret: BaseGroupSecret; - - constructor(name: string, secret: BaseGroupSecret) { - this.name = name; - this.secret = secret; - } - - decryptText(encrypted: EncryptedPayload): DecryptedGroupText { - return this.secret.decryptText(encrypted); - } - - decryptData(encrypted: EncryptedPayload): DecryptedGroupData { - return this.secret.decryptData(encrypted); - } -} - -export abstract class BaseGroupSecret { - secret: Uint8Array; - - constructor(secret: Secret) { - if (typeof secret === "string") { - secret = hexToBytes(secret); - } - this.secret = secret; - } - - public abstract hash(): NodeHash; - public abstract decryptText(encrypted: EncryptedPayload): DecryptedGroupText; - public abstract decryptData(encrypted: EncryptedPayload): DecryptedGroupData; -} - -export abstract class BaseKeyManager { - abstract addGroup(group: Group): void; - abstract addGroupSecret(name: string, secret?: Secret): void; - abstract decryptGroupText(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupText, group: Group }; - abstract decryptGroupData(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupData, group: Group }; - abstract addLocalIdentity(seed: Secret): void; - abstract addContact(contact: Contact): void; - abstract addIdentity(name: string, publicKey: Uint8Array | string): void; - abstract decryptRequest(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedRequest, contact: Contact, identity: BaseIdentity }; - abstract decryptResponse(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedResponse, contact: Contact, identity: BaseIdentity }; - abstract decryptText(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedText, contact: Contact, identity: BaseIdentity }; - abstract decryptAnonReq(dst: NodeHash, publicKey: Uint8Array, encrypted: EncryptedPayload): { decrypted: DecryptedAnonReq, contact: Contact, identity: BaseIdentity }; -} diff --git a/src/parser.test.ts b/src/parser.test.ts new file mode 100644 index 0000000..19f8319 --- /dev/null +++ b/src/parser.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from 'vitest'; +import { base64ToBytes, encodedStringToBytes, BufferReader, BufferWriter } from './parser'; + +describe('base64ToBytes', () => { + it('decodes a simple base64 string', () => { + const bytes = base64ToBytes('aGVsbG8='); // "hello" + expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]); + }); + + it('handles empty string', () => { + const bytes = base64ToBytes(''); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(bytes.length).toBe(0); + }); +}); + +describe('BufferReader', () => { + it('readByte and peekByte advance/inspect correctly', () => { + const buf = new Uint8Array([1, 2, 3]); + const r = new BufferReader(buf); + expect(r.peekByte()).toBe(1); + expect(r.readByte()).toBe(1); + expect(r.peekByte()).toBe(2); + }); + + it('readBytes with and without length', () => { + const buf = new Uint8Array([10, 11, 12, 13]); + const r = new BufferReader(buf); + const a = r.readBytes(2); + expect(Array.from(a)).toEqual([10, 11]); + const b = r.readBytes(); + expect(Array.from(b)).toEqual([12, 13]); + }); + + it('hasMore and remainingBytes reflect position', () => { + const buf = new Uint8Array([5, 6]); + const r = new BufferReader(buf); + expect(r.hasMore()).toBe(true); + expect(r.remainingBytes()).toBe(2); + r.readByte(); + expect(r.remainingBytes()).toBe(1); + r.readByte(); + expect(r.hasMore()).toBe(false); + }); + + it('reads little-endian unsigned ints', () => { + const r16 = new BufferReader(new Uint8Array([0x34, 0x12])); + expect(r16.readUint16LE()).toBe(0x1234); + + const r32 = new BufferReader(new Uint8Array([0x78, 0x56, 0x34, 0x12])); + expect(r32.readUint32LE()).toBe(0x12345678); + }); + + it('reads signed ints with two/four bytes (negative)', () => { + const r16 = new BufferReader(new Uint8Array([0xff, 0xff])); + expect(r16.readInt16LE()).toBe(-1); + + const r32 = new BufferReader(new Uint8Array([0xff, 0xff, 0xff, 0xff])); + expect(r32.readInt32LE()).toBe(-1); + }); + + it('readTimestamp returns Date with seconds->ms conversion', () => { + const r = new BufferReader(new Uint8Array([0x01, 0x00, 0x00, 0x00])); + const d = r.readTimestamp(); + expect(d.getTime()).toBe(1000); + }); +}); +describe('sizedStringToBytes', () => { + it('decodes hex string of correct length', () => { + // 4 bytes = 8 hex chars + const hex = 'deadbeef'; + const result = encodedStringToBytes(hex, 4); + expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]); + }); + + it('decodes base64 string of correct length', () => { + // 4 bytes = 8 hex chars, base64 for [0xde, 0xad, 0xbe, 0xef] is '3q2+7w==' + const b64 = '3q2+7w=='; + const result = encodedStringToBytes(b64, 4); + expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]); + }); + + it('throws on invalid string length', () => { + expect(() => encodedStringToBytes('abc', 4)).toThrow( + /Invalid input: .*, or raw string of size 4/ + ); + expect(() => encodedStringToBytes('deadbeef00', 4)).toThrow( + /Invalid input: .*, or raw string of size 4/ + ); + }); +}); + +describe('BufferWriter', () => { + it('writeByte and toBytes', () => { + const w = new BufferWriter(); + w.writeByte(0x12); + w.writeByte(0x34); + expect(Array.from(w.toBytes())).toEqual([0x12, 0x34]); + }); + + it('writeBytes appends bytes', () => { + const w = new BufferWriter(); + w.writeBytes(new Uint8Array([1, 2, 3])); + expect(Array.from(w.toBytes())).toEqual([1, 2, 3]); + }); + + it('writeUint16LE writes little-endian', () => { + const w = new BufferWriter(); + w.writeUint16LE(0x1234); + expect(Array.from(w.toBytes())).toEqual([0x34, 0x12]); + }); + + it('writeUint32LE writes little-endian', () => { + const w = new BufferWriter(); + w.writeUint32LE(0x12345678); + expect(Array.from(w.toBytes())).toEqual([0x78, 0x56, 0x34, 0x12]); + }); + + it('writeInt16LE writes signed values', () => { + const w = new BufferWriter(); + w.writeInt16LE(-1); + expect(Array.from(w.toBytes())).toEqual([0xff, 0xff]); + const w2 = new BufferWriter(); + w2.writeInt16LE(0x1234); + expect(Array.from(w2.toBytes())).toEqual([0x34, 0x12]); + }); + + it('writeInt32LE writes signed values', () => { + const w = new BufferWriter(); + w.writeInt32LE(-1); + expect(Array.from(w.toBytes())).toEqual([0xff, 0xff, 0xff, 0xff]); + const w2 = new BufferWriter(); + w2.writeInt32LE(0x12345678); + expect(Array.from(w2.toBytes())).toEqual([0x78, 0x56, 0x34, 0x12]); + }); + + it('writeTimestamp writes seconds as uint32le', () => { + const w = new BufferWriter(); + const date = new Date(1000); // 1 second + w.writeTimestamp(date); + expect(Array.from(w.toBytes())).toEqual([0x01, 0x00, 0x00, 0x00]); + }); + + it('BufferWriter output can be read back by BufferReader', () => { + const w = new BufferWriter(); + w.writeByte(0x42); + w.writeUint16LE(0x1234); + w.writeInt16LE(-2); + w.writeUint32LE(0xdeadbeef); + w.writeInt32LE(-123456); + w.writeBytes(new Uint8Array([0x01, 0x02])); + const date = new Date(5000); // 5 seconds + w.writeTimestamp(date); + + const bytes = w.toBytes(); + const r = new BufferReader(bytes); + + expect(r.readByte()).toBe(0x42); + expect(r.readUint16LE()).toBe(0x1234); + expect(r.readInt16LE()).toBe(-2); + expect(r.readUint32LE()).toBe(0xdeadbeef); + expect(r.readInt32LE()).toBe(-123456); + expect(Array.from(r.readBytes(2))).toEqual([0x01, 0x02]); + const readDate = r.readTimestamp(); + expect(readDate.getTime()).toBe(5000); + expect(r.hasMore()).toBe(false); + }); + + it('BufferReader throws or returns undefined if reading past end', () => { + const r = new BufferReader(new Uint8Array([1, 2])); + r.readByte(); + r.readByte(); + expect(() => r.readByte()).toThrow(); + }); + + it('BufferWriter handles multiple writeBytes calls', () => { + const w = new BufferWriter(); + w.writeBytes(new Uint8Array([1, 2])); + w.writeBytes(new Uint8Array([3, 4])); + expect(Array.from(w.toBytes())).toEqual([1, 2, 3, 4]); + }); + + it('encodedStringToBytes decodes raw string', () => { + const str = String.fromCharCode(0xde, 0xad, 0xbe, 0xef); + const result = encodedStringToBytes(str, 4); + expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]); + }); + + it('encodedStringToBytes rejects hex string of wrong length', () => { + expect(() => encodedStringToBytes('deadbe', 4)).toThrow(); + }); + + it('base64ToBytes handles URL-safe base64', () => { + // [0xde, 0xad, 0xbe, 0xef] in URL-safe base64: '3q2-7w==' + const bytes = base64ToBytes('3q2-7w=='); + expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]); + }); +}); diff --git a/src/parser.ts b/src/parser.ts index 7fed635..bd212b8 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -8,12 +8,42 @@ export { }; export const base64ToBytes = (base64: string): Uint8Array => { - const binaryString = atob(base64); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); + // Normalize URL-safe base64 to standard base64 + let normalized = base64.replace(/-/g, '+').replace(/_/g, '/'); + // Add padding if missing + while (normalized.length % 4 !== 0) { + normalized += '='; + } + const binaryString = atob(normalized); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +} + +export const encodedStringToBytes = (str: string, size: number): Uint8Array => { + const hexRegex = /^[0-9a-fA-F]+$/; + // Accept both standard and URL-safe base64, with or without padding + const b64Regex = /^(?:[A-Za-z0-9+\/_-]{4})*(?:[A-Za-z0-9+\/_-]{2}(?:==)?|[A-Za-z0-9+\/_-]{3}=?)?$/; + + if (hexRegex.test(str) && str.length === size * 2) { + return hexToBytes(str); + } else if (b64Regex.test(str)) { + const bytes = base64ToBytes(str); + if (bytes.length === size) { + return bytes; + } + } else if (str.length === size) { + // Raw format: treat as bytes (latin1) + const bytes = new Uint8Array(size); + for (let i = 0; i < size; i++) { + bytes[i] = str.charCodeAt(i) & 0xFF; } return bytes; + } + + throw new Error(`Invalid input: expected hex, base64 (standard or URL-safe), or raw string of size ${size}`); } export class BufferReader { @@ -79,3 +109,45 @@ export class BufferReader { return new Date(timestamp * 1000); } } + +export class BufferWriter { + private buffer: number[] = []; + + public writeByte(value: number): void { + this.buffer.push(value & 0xFF); + } + + public writeBytes(bytes: Uint8Array): void { + this.buffer.push(...bytes); + } + + public writeUint16LE(value: number): void { + this.buffer.push(value & 0xFF, (value >> 8) & 0xFF); + } + + public writeUint32LE(value: number): void { + this.buffer.push( + value & 0xFF, + (value >> 8) & 0xFF, + (value >> 16) & 0xFF, + (value >> 24) & 0xFF + ); + } + + public writeInt16LE(value: number): void { + this.writeUint16LE(value < 0 ? value + 0x10000 : value); + } + + public writeInt32LE(value: number): void { + this.writeUint32LE(value < 0 ? value + 0x100000000 : value); + } + + public writeTimestamp(date: Date): void { + const timestamp = Math.floor(date.getTime() / 1000); + this.writeUint32LE(timestamp); + } + + public toBytes(): Uint8Array { + return new Uint8Array(this.buffer); + } +} diff --git a/test/identity.ts b/test/identity.ts deleted file mode 100644 index 18ef175..0000000 --- a/test/identity.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { GroupSecret } from '../src/identity'; -import { bytesToHex } from '../src/parser'; - -describe('GroupSecret.fromName', () => { - it('computes Public secret correctly', () => { - const g = GroupSecret.fromName('Public'); - expect(bytesToHex(g.secret)).toBe('8b3387e9c5cdea6ac9e5edbaa115cd72'); - }); - - it('computes #test secret correctly', () => { - const g = GroupSecret.fromName('#test'); - expect(bytesToHex(g.secret)).toBe('9cd8fcf22a47333b591d96a2b848b73f'); - }); - - it('throws for invalid names', () => { - expect(() => GroupSecret.fromName('foo')).toThrow(); - }); - - it('accepts single # and returns 16 bytes', () => { - const g = GroupSecret.fromName('#'); - expect(g.secret).toBeInstanceOf(Uint8Array); - expect(g.secret.length).toBe(16); - }); - - it('returns GroupSecret instances consistently', () => { - const a = GroupSecret.fromName('#abc'); - const b = GroupSecret.fromName('#abc'); - expect(bytesToHex(a.secret)).toBe(bytesToHex(b.secret)); - }); -}); diff --git a/test/parser.test.ts b/test/parser.test.ts deleted file mode 100644 index 59124db..0000000 --- a/test/parser.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { base64ToBytes, BufferReader } from '../src/parser'; - -describe('base64ToBytes', () => { - it('decodes a simple base64 string', () => { - const bytes = base64ToBytes('aGVsbG8='); // "hello" - expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]); - }); - - it('handles empty string', () => { - const bytes = base64ToBytes(''); - expect(bytes).toBeInstanceOf(Uint8Array); - expect(bytes.length).toBe(0); - }); -}); - -describe('BufferReader', () => { - it('readByte and peekByte advance/inspect correctly', () => { - const buf = new Uint8Array([1, 2, 3]); - const r = new BufferReader(buf); - expect(r.peekByte()).toBe(1); - expect(r.readByte()).toBe(1); - expect(r.peekByte()).toBe(2); - }); - - it('readBytes with and without length', () => { - const buf = new Uint8Array([10, 11, 12, 13]); - const r = new BufferReader(buf); - const a = r.readBytes(2); - expect(Array.from(a)).toEqual([10, 11]); - const b = r.readBytes(); - expect(Array.from(b)).toEqual([12, 13]); - }); - - it('hasMore and remainingBytes reflect position', () => { - const buf = new Uint8Array([5, 6]); - const r = new BufferReader(buf); - expect(r.hasMore()).toBe(true); - expect(r.remainingBytes()).toBe(2); - r.readByte(); - expect(r.remainingBytes()).toBe(1); - r.readByte(); - expect(r.hasMore()).toBe(false); - }); - - it('reads little-endian unsigned ints', () => { - const r16 = new BufferReader(new Uint8Array([0x34, 0x12])); - expect(r16.readUint16LE()).toBe(0x1234); - - const r32 = new BufferReader(new Uint8Array([0x78, 0x56, 0x34, 0x12])); - expect(r32.readUint32LE()).toBe(0x12345678); - }); - - it('reads signed ints with two/four bytes (negative)', () => { - const r16 = new BufferReader(new Uint8Array([0xff, 0xff])); - expect(r16.readInt16LE()).toBe(-1); - - const r32 = new BufferReader(new Uint8Array([0xff, 0xff, 0xff, 0xff])); - expect(r32.readInt32LE()).toBe(-1); - }); - - it('readTimestamp returns Date with seconds->ms conversion', () => { - const r = new BufferReader(new Uint8Array([0x01, 0x00, 0x00, 0x00])); - const d = r.readTimestamp(); - expect(d.getTime()).toBe(1000); - }); -});