Fixed bug in channel hash calculation and decryption

This commit is contained in:
2026-03-14 19:26:53 +01:00
parent 38b7ea7517
commit 0feb4868e4
5 changed files with 146 additions and 88 deletions

View File

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

View File

@@ -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<number, Contact[]> = {};
private groups: Record<number, Group[]> = {};
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] = [];
}

View File

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

View File

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

View File

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