From 0feb4868e4f3dc635f1d56fbd971f316a96533ed Mon Sep 17 00:00:00 2001 From: maze Date: Sat, 14 Mar 2026 19:26:53 +0100 Subject: [PATCH] Fixed bug in channel hash calculation and decryption --- src/crypto.ts | 18 +++-- src/identity.ts | 10 ++- src/packet.ts | 156 ++++++++++++++++++++++++------------------ test/crypto.test.ts | 17 ++--- test/identity.test.ts | 33 ++++++++- 5 files changed, 146 insertions(+), 88 deletions(-) diff --git a/src/crypto.ts b/src/crypto.ts index 222d0e8..d5c3a9c 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -9,7 +9,7 @@ import { NodeHash } from "./identity.types"; const PUBLIC_KEY_SIZE = 32; const SEED_SIZE = 32; const HMAC_SIZE = 2; -const SHARED_SECRET_SIZE = 32; +const SHARED_SECRET_SIZE = 16; const SIGNATURE_SIZE = 64; const STATIC_SECRET_SIZE = 32; @@ -136,28 +136,32 @@ 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); + padded.set(secret, 0); 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; + this.secret = new Uint8Array(SHARED_SECRET_SIZE * 2); // Pad to 32 bytes for hashing and encryption + this.secret.set(secret, 0); } public toHash(): NodeHash { - return this.secret[0] as NodeHash; + const hash = sha256.create().update(this.secret.slice(0, 16)).digest(); + return hash[0] as NodeHash; } public toBytes(): Uint8Array { - return this.secret; + return this.secret.slice(0, 16); } public toString(): string { - return bytesToHex(this.secret); + return bytesToHex(this.secret.slice(0, 16)); } public decrypt(hmac: Uint8Array, ciphertext: Uint8Array): Uint8Array { @@ -242,6 +246,6 @@ export class StaticSecret implements IStaticSecret { public diffieHellman(otherPublicKey: IPublicKey): SharedSecret { const sharedSecret = x25519.getSharedSecret(this.secret, otherPublicKey.toBytes()); - return new SharedSecret(sharedSecret); + return new SharedSecret(sharedSecret.slice(0, 16)); } } diff --git a/src/identity.ts b/src/identity.ts index 294d24d..55234a1 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -103,7 +103,7 @@ export class LocalIdentity extends Identity implements ILocalIdentity { } else { throw new Error("Invalid type for calculateSharedSecret comparison"); } - return new SharedSecret(this.privateKey.calculateSharedSecret(otherPublicKey)); + return new SharedSecret(this.privateKey.calculateSharedSecret(otherPublicKey).slice(0, 16)); } } @@ -216,6 +216,12 @@ export class Contacts { private contacts: Record = {}; private groups: Record = {}; + constructor() { + // These groups are omnipresent: + this.addGroup(new Group("Public")); + this.addGroup(new Group("#test")); + } + public addLocalIdentity(identity: LocalIdentity) { this.localIdentities.push({ identity, sharedSecrets: {} }); } @@ -296,7 +302,7 @@ export class Contacts { } public addGroup(group: Group) { - const hash = parseNodeHash(group.hash()) as number; + const hash = group.hash() as number; if (!this.groups[hash]) { this.groups[hash] = []; } diff --git a/src/packet.ts b/src/packet.ts index d3bab4c..f3022b8 100644 --- a/src/packet.ts +++ b/src/packet.ts @@ -127,13 +127,13 @@ export class Packet implements IPacket { /* Header segment */ { name: "header", - data: new Uint8Array([this.header]), + data: new Uint8Array([this.header]).buffer, fields: [ /* Header flags */ { name: "flags", type: FieldType.BITS, - size: 1, + length: 1, bits: [ { name: "payload version", size: 2 }, { name: "payload type", size: 4 }, @@ -153,18 +153,18 @@ export class Packet implements IPacket { this.transport![0] & 0xff, (this.transport![1] >> 8) & 0xff, this.transport![1] & 0xff - ]), + ]).buffer, fields: [ { name: "transport code 1", type: FieldType.UINT16_BE, - size: 2, + length: 2, value: this.transport![0] }, { name: "transport code 2", type: FieldType.UINT16_BE, - size: 2, + length: 2, value: this.transport![1] } ] @@ -175,12 +175,12 @@ export class Packet implements IPacket { /* Path length and hashes */ { name: "path", - data: new Uint8Array([this.pathLength, ...this.path]), + data: new Uint8Array([this.pathLength, ...this.path]).buffer, fields: [ { name: "path length", type: FieldType.UINT8, - size: 1, + length: 1, bits: [ { name: "path hash size", size: 2 }, { name: "path hash count", size: 6 } @@ -189,7 +189,7 @@ export class Packet implements IPacket { { name: "path hashes", type: pathHashType, - size: this.path.length + length: this.path.length } ] } @@ -269,14 +269,19 @@ export class Packet implements IPacket { }; if (typeof withSegment === "boolean" && withSegment) { - const segment = { + const segment: Segment = { name: "request payload", - data: this.payload, + data: new Uint8Array(this.payload).buffer, fields: [ - { name: "destination hash", type: FieldType.UINT8, size: 1, value: dst }, - { name: "source hash", type: FieldType.UINT8, size: 1, value: src }, - { name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC }, - { name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText } + { name: "destination hash", type: FieldType.UINT8, length: 1, value: dst }, + { name: "source hash", type: FieldType.UINT8, length: 1, value: src }, + { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: encrypted.cipherMAC }, + { + name: "cipher text", + type: FieldType.BYTES, + length: encrypted.cipherText.length, + value: encrypted.cipherText + } ] }; return { payload, segment }; @@ -301,14 +306,19 @@ export class Packet implements IPacket { }; if (typeof withSegment === "boolean" && withSegment) { - const segment = { + const segment: Segment = { name: "response payload", - data: this.payload, + data: new Uint8Array(this.payload).buffer, fields: [ - { name: "destination hash", type: FieldType.UINT8, size: 1, value: dst }, - { name: "source hash", type: FieldType.UINT8, size: 1, value: src }, - { name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC }, - { name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText } + { name: "destination hash", type: FieldType.UINT8, length: 1, value: dst }, + { name: "source hash", type: FieldType.UINT8, length: 1, value: src }, + { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: encrypted.cipherMAC }, + { + name: "cipher text", + type: FieldType.BYTES, + length: encrypted.cipherText.length, + value: encrypted.cipherText + } ] }; return { payload, segment }; @@ -333,14 +343,19 @@ export class Packet implements IPacket { }; if (typeof withSegment === "boolean" && withSegment) { - const segment = { + const segment: Segment = { name: "text payload", - data: this.payload, + data: new Uint8Array(this.payload).buffer, fields: [ - { name: "destination hash", type: FieldType.UINT8, size: 1, value: dst }, - { name: "source hash", type: FieldType.UINT8, size: 1, value: src }, - { name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC }, - { name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText } + { name: "destination hash", type: FieldType.UINT8, length: 1, value: dst }, + { name: "source hash", type: FieldType.UINT8, length: 1, value: src }, + { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: encrypted.cipherMAC }, + { + name: "cipher text", + type: FieldType.BYTES, + length: encrypted.cipherText.length, + value: encrypted.cipherText + } ] }; return { payload, segment }; @@ -361,10 +376,10 @@ export class Packet implements IPacket { }; if (typeof withSegment === "boolean" && withSegment) { - const segment = { + const segment: Segment = { name: "ack payload", - data: this.payload, - fields: [{ name: "checksum", type: FieldType.BYTES, size: 4, value: checksum }] + data: new Uint8Array(this.payload).buffer, + fields: [{ name: "checksum", type: FieldType.BYTES, length: 4, value: checksum }] }; return { payload, segment }; } @@ -388,11 +403,11 @@ export class Packet implements IPacket { if (typeof withSegment === "boolean" && withSegment) { segment = { name: "advert payload", - data: this.payload, + data: new Uint8Array(this.payload).buffer, fields: [ - { type: FieldType.BYTES, name: "public key", size: 32 }, - { type: FieldType.UINT32_LE, name: "timestamp", size: 4, value: payload.timestamp! }, - { type: FieldType.BYTES, name: "signature", size: 64 } + { type: FieldType.BYTES, name: "public key", length: 32 }, + { type: FieldType.UINT32_LE, name: "timestamp", length: 4, value: payload.timestamp! }, + { type: FieldType.BYTES, name: "signature", length: 64 } ] }; } @@ -409,7 +424,7 @@ export class Packet implements IPacket { segment!.fields.push({ type: FieldType.BITS, name: "flags", - size: 1, + length: 1, value: flags, bits: [ { size: 1, name: "name flag" }, @@ -426,20 +441,20 @@ export class Packet implements IPacket { const lon = reader.int32() / 1000000; appdata.location = [lat, lon]; if (typeof withSegment === "boolean" && withSegment) { - segment!.fields.push({ type: FieldType.UINT32_LE, name: "latitude", size: 4, value: lat }); - segment!.fields.push({ type: FieldType.UINT32_LE, name: "longitude", size: 4, value: lon }); + segment!.fields.push({ type: FieldType.UINT32_LE, name: "latitude", length: 4, value: lat }); + segment!.fields.push({ type: FieldType.UINT32_LE, name: "longitude", length: 4, value: lon }); } } if (appdata.hasFeature1) { appdata.feature1 = reader.uint16(); if (typeof withSegment === "boolean" && withSegment) { - segment!.fields.push({ type: FieldType.UINT16_LE, name: "feature1", size: 2, value: appdata.feature1 }); + segment!.fields.push({ type: FieldType.UINT16_LE, name: "feature1", length: 2, value: appdata.feature1 }); } } if (appdata.hasFeature2) { appdata.feature2 = reader.uint16(); if (typeof withSegment === "boolean" && withSegment) { - segment!.fields.push({ type: FieldType.UINT16_LE, name: "feature2", size: 2, value: appdata.feature2 }); + segment!.fields.push({ type: FieldType.UINT16_LE, name: "feature2", length: 2, value: appdata.feature2 }); } } if (appdata.hasName) { @@ -448,7 +463,7 @@ export class Packet implements IPacket { segment!.fields.push({ type: FieldType.C_STRING, name: "name", - size: appdata.name.length, + length: appdata.name.length, value: appdata.name }); } @@ -476,13 +491,18 @@ export class Packet implements IPacket { }; if (typeof withSegment === "boolean" && withSegment) { - const segment = { + const segment: Segment = { name: "group text payload", - data: this.payload, + data: new Uint8Array(this.payload).buffer, fields: [ - { name: "channel hash", type: FieldType.UINT8, size: 1, value: channelHash }, - { name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC }, - { name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText } + { name: "channel hash", type: FieldType.UINT8, length: 1, value: channelHash }, + { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: encrypted.cipherMAC }, + { + name: "cipher text", + type: FieldType.BYTES, + length: encrypted.cipherText.length, + value: encrypted.cipherText + } ] }; return { payload, segment }; @@ -503,16 +523,16 @@ export class Packet implements IPacket { }; if (typeof withSegment === "boolean" && withSegment) { - const segment = { + const segment: Segment = { name: "group data payload", - data: this.payload, + data: new Uint8Array(this.payload).buffer, fields: [ - { name: "channel hash", type: FieldType.UINT8, size: 1, value: payload.channelHash }, - { name: "cipher MAC", type: FieldType.BYTES, size: 2, value: payload.encrypted.cipherMAC }, + { name: "channel hash", type: FieldType.UINT8, length: 1, value: payload.channelHash }, + { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: payload.encrypted.cipherMAC }, { name: "cipher text", type: FieldType.BYTES, - size: payload.encrypted.cipherText.length, + length: payload.encrypted.cipherText.length, value: payload.encrypted.cipherText } ] @@ -536,17 +556,17 @@ export class Packet implements IPacket { }; if (typeof withSegment === "boolean" && withSegment) { - const segment = { + const segment: Segment = { name: "anon req payload", - data: this.payload, + data: new Uint8Array(this.payload).buffer, fields: [ - { name: "destination hash", type: FieldType.UINT8, size: 1, value: payload.dst }, - { name: "public key", type: FieldType.BYTES, size: 32, value: payload.publicKey }, - { name: "cipher MAC", type: FieldType.BYTES, size: 2, value: payload.encrypted.cipherMAC }, + { name: "destination hash", type: FieldType.UINT8, length: 1, value: payload.dst }, + { name: "public key", type: FieldType.BYTES, length: 32, value: payload.publicKey }, + { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: payload.encrypted.cipherMAC }, { name: "cipher text", type: FieldType.BYTES, - size: payload.encrypted.cipherText.length, + length: payload.encrypted.cipherText.length, value: payload.encrypted.cipherText } ] @@ -569,12 +589,12 @@ export class Packet implements IPacket { }; if (typeof withSegment === "boolean" && withSegment) { - const segment = { + const segment: Segment = { name: "path payload", - data: this.payload, + data: new Uint8Array(this.payload).buffer, fields: [ - { name: "destination hash", type: FieldType.UINT8, size: 1, value: payload.dst }, - { name: "source hash", type: FieldType.UINT8, size: 1, value: payload.src } + { name: "destination hash", type: FieldType.UINT8, length: 1, value: payload.dst }, + { name: "source hash", type: FieldType.UINT8, length: 1, value: payload.src } ] }; return { payload, segment }; @@ -597,14 +617,14 @@ export class Packet implements IPacket { }; if (typeof withSegment === "boolean" && withSegment) { - const segment = { + const segment: Segment = { name: "trace payload", - data: this.payload, + data: new Uint8Array(this.payload).buffer, fields: [ - { name: "tag", type: FieldType.DWORDS, size: 4, value: payload.tag }, - { name: "auth code", type: FieldType.DWORDS, size: 4, value: payload.authCode }, - { name: "flags", type: FieldType.UINT8, size: 1, value: payload.flags }, - { name: "nodes", type: FieldType.BYTES, size: payload.nodes.length, value: payload.nodes } + { name: "tag", type: FieldType.DWORDS, length: 4, value: payload.tag }, + { name: "auth code", type: FieldType.DWORDS, length: 4, value: payload.authCode }, + { name: "flags", type: FieldType.UINT8, length: 1, value: payload.flags }, + { name: "nodes", type: FieldType.BYTES, length: payload.nodes.length, value: payload.nodes } ] }; return { payload, segment }; @@ -619,10 +639,10 @@ export class Packet implements IPacket { }; if (typeof withSegment === "boolean" && withSegment) { - const segment = { + const segment: Segment = { name: "raw custom payload", - data: this.payload, - fields: [{ name: "data", type: FieldType.BYTES, size: this.payload.length, value: this.payload }] + data: new Uint8Array(this.payload).buffer, + fields: [{ name: "data", type: FieldType.BYTES, length: this.payload.length, value: this.payload }] }; return { payload, segment }; } diff --git a/test/crypto.test.ts b/test/crypto.test.ts index 01ccb1e..990cc4e 100644 --- a/test/crypto.test.ts +++ b/test/crypto.test.ts @@ -112,18 +112,12 @@ describe("PrivateKey", () => { }); describe("SharedSecret", () => { - const secret = randomBytes(32); + const secret = randomBytes(16); - it("constructs from 32 bytes", () => { + it("constructs from 16 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)); + expect(ss.toBytes().length).toBe(16); + expect(bytesToHex(ss.toBytes())).toBe(bytesToHex(secret)); }); it("throws on invalid length", () => { @@ -163,6 +157,9 @@ describe("SharedSecret", () => { it('fromName "Public"', () => { const ss = SharedSecret.fromName("Public"); expect(ss).toBeInstanceOf(SharedSecret); + const hash = ss.toHash(); + expect(typeof hash).toBe("number"); + expect(hash).toBe(0x11); }); it("fromName with #group", () => { diff --git a/test/identity.test.ts b/test/identity.test.ts index 1f94133..730ea2b 100644 --- a/test/identity.test.ts +++ b/test/identity.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { bytesToHex } from "@hamradio/packet"; +import { bytesToHex, hexToBytes } 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"; @@ -230,6 +230,18 @@ describe("Group", () => { it("decryptData throws on short ciphertext", () => { expect(() => group.decryptData(randomBytes(16), Uint8Array.of(1, 2, 3))).toThrow(); }); + + it("hash is consistent with test vectors", () => { + const testVectors = [ + { name: "Public", hash: 0x11 }, + { name: "#test", hash: 0xd9 } + ]; + + testVectors.forEach(({ name, hash }) => { + const g = new Group(name); + expect(`${name}:${g.hash()}`).toBe(`${name}:${hash}`); + }); + }); }); describe("Contacts", () => { @@ -501,4 +513,23 @@ describe("Contacts", () => { const res2 = contacts.decrypt(contactB.identity.hash(), localA.publicKey.key[0], hmac, ciphertext); expect(res2.decrypted).toEqual(msg); }); + + it("decryptGroupText on well known channels with test vectors", () => { + const testVectors = [ + { + name: "#test", + channelHash: 0xd9, + cipherMAC: hexToBytes("570D"), + cipherText: hexToBytes("E397F0560B2B61396F7E236811FC70B70038E956045347D7F6B9976A46727427"), + message: "corrauder 🏕️: Testing " + } + ]; + + testVectors.forEach(({ name, channelHash, cipherMAC, cipherText, message }) => { + const group = new Group(name); + expect(group.hash()).toBe(channelHash); + const decrypted = group.decryptText(cipherMAC, cipherText); + expect(decrypted.message).toBe(message); + }); + }); });