NodeHash is a number

This commit is contained in:
2026-03-10 17:55:37 +01:00
parent a30448c130
commit 218042f552
6 changed files with 192 additions and 86 deletions

View File

@@ -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;
}
}

View File

@@ -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");
}

View File

@@ -1,4 +1,4 @@
import { NodeHash } from "./packet.types";
import { NodeHash } from "./identity.types";
export interface IPublicKey {
toHash(): NodeHash;

View File

@@ -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);
});
});

View File

@@ -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', () => {

View File

@@ -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(),
}
}