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