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 PUBLIC_KEY_SIZE = 32;
const SEED_SIZE = 32; const SEED_SIZE = 32;
const HMAC_SIZE = 2; const HMAC_SIZE = 2;
const SHARED_SECRET_SIZE = 32; const SHARED_SECRET_SIZE = 16;
const SIGNATURE_SIZE = 64; const SIGNATURE_SIZE = 64;
const STATIC_SECRET_SIZE = 32; const STATIC_SECRET_SIZE = 32;
@@ -136,28 +136,32 @@ export class SharedSecret implements ISharedSecret {
private secret: Uint8Array; private secret: Uint8Array;
constructor(secret: Uint8Array) { constructor(secret: Uint8Array) {
/*
if (secret.length === SHARED_SECRET_SIZE / 2) { if (secret.length === SHARED_SECRET_SIZE / 2) {
// Zero pad to the left if the secret is too short (e.g. from x25519) // Zero pad to the left if the secret is too short (e.g. from x25519)
const padded = new Uint8Array(SHARED_SECRET_SIZE); const padded = new Uint8Array(SHARED_SECRET_SIZE);
padded.set(secret, SHARED_SECRET_SIZE - secret.length); padded.set(secret, 0);
secret = padded; secret = padded;
} }
*/
if (secret.length !== SHARED_SECRET_SIZE) { if (secret.length !== SHARED_SECRET_SIZE) {
throw new Error(`Invalid shared secret length: expected ${SHARED_SECRET_SIZE} bytes, got ${secret.length}`); 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 { 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 { public toBytes(): Uint8Array {
return this.secret; return this.secret.slice(0, 16);
} }
public toString(): string { public toString(): string {
return bytesToHex(this.secret); return bytesToHex(this.secret.slice(0, 16));
} }
public decrypt(hmac: Uint8Array, ciphertext: Uint8Array): Uint8Array { public decrypt(hmac: Uint8Array, ciphertext: Uint8Array): Uint8Array {
@@ -242,6 +246,6 @@ export class StaticSecret implements IStaticSecret {
public diffieHellman(otherPublicKey: IPublicKey): SharedSecret { public diffieHellman(otherPublicKey: IPublicKey): SharedSecret {
const sharedSecret = x25519.getSharedSecret(this.secret, otherPublicKey.toBytes()); 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 { } else {
throw new Error("Invalid type for calculateSharedSecret comparison"); 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 contacts: Record<number, Contact[]> = {};
private groups: Record<number, Group[]> = {}; private groups: Record<number, Group[]> = {};
constructor() {
// These groups are omnipresent:
this.addGroup(new Group("Public"));
this.addGroup(new Group("#test"));
}
public addLocalIdentity(identity: LocalIdentity) { public addLocalIdentity(identity: LocalIdentity) {
this.localIdentities.push({ identity, sharedSecrets: {} }); this.localIdentities.push({ identity, sharedSecrets: {} });
} }
@@ -296,7 +302,7 @@ export class Contacts {
} }
public addGroup(group: Group) { public addGroup(group: Group) {
const hash = parseNodeHash(group.hash()) as number; const hash = group.hash() as number;
if (!this.groups[hash]) { if (!this.groups[hash]) {
this.groups[hash] = []; this.groups[hash] = [];
} }

View File

@@ -127,13 +127,13 @@ export class Packet implements IPacket {
/* Header segment */ /* Header segment */
{ {
name: "header", name: "header",
data: new Uint8Array([this.header]), data: new Uint8Array([this.header]).buffer,
fields: [ fields: [
/* Header flags */ /* Header flags */
{ {
name: "flags", name: "flags",
type: FieldType.BITS, type: FieldType.BITS,
size: 1, length: 1,
bits: [ bits: [
{ name: "payload version", size: 2 }, { name: "payload version", size: 2 },
{ name: "payload type", size: 4 }, { name: "payload type", size: 4 },
@@ -153,18 +153,18 @@ export class Packet implements IPacket {
this.transport![0] & 0xff, this.transport![0] & 0xff,
(this.transport![1] >> 8) & 0xff, (this.transport![1] >> 8) & 0xff,
this.transport![1] & 0xff this.transport![1] & 0xff
]), ]).buffer,
fields: [ fields: [
{ {
name: "transport code 1", name: "transport code 1",
type: FieldType.UINT16_BE, type: FieldType.UINT16_BE,
size: 2, length: 2,
value: this.transport![0] value: this.transport![0]
}, },
{ {
name: "transport code 2", name: "transport code 2",
type: FieldType.UINT16_BE, type: FieldType.UINT16_BE,
size: 2, length: 2,
value: this.transport![1] value: this.transport![1]
} }
] ]
@@ -175,12 +175,12 @@ export class Packet implements IPacket {
/* Path length and hashes */ /* Path length and hashes */
{ {
name: "path", name: "path",
data: new Uint8Array([this.pathLength, ...this.path]), data: new Uint8Array([this.pathLength, ...this.path]).buffer,
fields: [ fields: [
{ {
name: "path length", name: "path length",
type: FieldType.UINT8, type: FieldType.UINT8,
size: 1, length: 1,
bits: [ bits: [
{ name: "path hash size", size: 2 }, { name: "path hash size", size: 2 },
{ name: "path hash count", size: 6 } { name: "path hash count", size: 6 }
@@ -189,7 +189,7 @@ export class Packet implements IPacket {
{ {
name: "path hashes", name: "path hashes",
type: pathHashType, type: pathHashType,
size: this.path.length length: this.path.length
} }
] ]
} }
@@ -269,14 +269,19 @@ export class Packet implements IPacket {
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment: Segment = {
name: "request payload", name: "request payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [ fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: dst }, { name: "destination hash", type: FieldType.UINT8, length: 1, value: dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: src }, { name: "source hash", type: FieldType.UINT8, length: 1, value: src },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC }, { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText } {
name: "cipher text",
type: FieldType.BYTES,
length: encrypted.cipherText.length,
value: encrypted.cipherText
}
] ]
}; };
return { payload, segment }; return { payload, segment };
@@ -301,14 +306,19 @@ export class Packet implements IPacket {
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment: Segment = {
name: "response payload", name: "response payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [ fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: dst }, { name: "destination hash", type: FieldType.UINT8, length: 1, value: dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: src }, { name: "source hash", type: FieldType.UINT8, length: 1, value: src },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC }, { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText } {
name: "cipher text",
type: FieldType.BYTES,
length: encrypted.cipherText.length,
value: encrypted.cipherText
}
] ]
}; };
return { payload, segment }; return { payload, segment };
@@ -333,14 +343,19 @@ export class Packet implements IPacket {
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment: Segment = {
name: "text payload", name: "text payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [ fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: dst }, { name: "destination hash", type: FieldType.UINT8, length: 1, value: dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: src }, { name: "source hash", type: FieldType.UINT8, length: 1, value: src },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC }, { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText } {
name: "cipher text",
type: FieldType.BYTES,
length: encrypted.cipherText.length,
value: encrypted.cipherText
}
] ]
}; };
return { payload, segment }; return { payload, segment };
@@ -361,10 +376,10 @@ export class Packet implements IPacket {
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment: Segment = {
name: "ack payload", name: "ack payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [{ name: "checksum", type: FieldType.BYTES, size: 4, value: checksum }] fields: [{ name: "checksum", type: FieldType.BYTES, length: 4, value: checksum }]
}; };
return { payload, segment }; return { payload, segment };
} }
@@ -388,11 +403,11 @@ export class Packet implements IPacket {
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
segment = { segment = {
name: "advert payload", name: "advert payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [ fields: [
{ type: FieldType.BYTES, name: "public key", size: 32 }, { type: FieldType.BYTES, name: "public key", length: 32 },
{ type: FieldType.UINT32_LE, name: "timestamp", size: 4, value: payload.timestamp! }, { type: FieldType.UINT32_LE, name: "timestamp", length: 4, value: payload.timestamp! },
{ type: FieldType.BYTES, name: "signature", size: 64 } { type: FieldType.BYTES, name: "signature", length: 64 }
] ]
}; };
} }
@@ -409,7 +424,7 @@ export class Packet implements IPacket {
segment!.fields.push({ segment!.fields.push({
type: FieldType.BITS, type: FieldType.BITS,
name: "flags", name: "flags",
size: 1, length: 1,
value: flags, value: flags,
bits: [ bits: [
{ size: 1, name: "name flag" }, { size: 1, name: "name flag" },
@@ -426,20 +441,20 @@ export class Packet implements IPacket {
const lon = reader.int32() / 1000000; const lon = reader.int32() / 1000000;
appdata.location = [lat, lon]; appdata.location = [lat, lon];
if (typeof withSegment === "boolean" && withSegment) { 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: "latitude", length: 4, value: lat });
segment!.fields.push({ type: FieldType.UINT32_LE, name: "longitude", size: 4, value: lon }); segment!.fields.push({ type: FieldType.UINT32_LE, name: "longitude", length: 4, value: lon });
} }
} }
if (appdata.hasFeature1) { if (appdata.hasFeature1) {
appdata.feature1 = reader.uint16(); appdata.feature1 = reader.uint16();
if (typeof withSegment === "boolean" && withSegment) { 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) { if (appdata.hasFeature2) {
appdata.feature2 = reader.uint16(); appdata.feature2 = reader.uint16();
if (typeof withSegment === "boolean" && withSegment) { 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) { if (appdata.hasName) {
@@ -448,7 +463,7 @@ export class Packet implements IPacket {
segment!.fields.push({ segment!.fields.push({
type: FieldType.C_STRING, type: FieldType.C_STRING,
name: "name", name: "name",
size: appdata.name.length, length: appdata.name.length,
value: appdata.name value: appdata.name
}); });
} }
@@ -476,13 +491,18 @@ export class Packet implements IPacket {
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment: Segment = {
name: "group text payload", name: "group text payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [ fields: [
{ name: "channel hash", type: FieldType.UINT8, size: 1, value: channelHash }, { name: "channel hash", type: FieldType.UINT8, length: 1, value: channelHash },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC }, { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText } {
name: "cipher text",
type: FieldType.BYTES,
length: encrypted.cipherText.length,
value: encrypted.cipherText
}
] ]
}; };
return { payload, segment }; return { payload, segment };
@@ -503,16 +523,16 @@ export class Packet implements IPacket {
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment: Segment = {
name: "group data payload", name: "group data payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [ fields: [
{ name: "channel hash", type: FieldType.UINT8, size: 1, value: payload.channelHash }, { name: "channel hash", type: FieldType.UINT8, length: 1, value: payload.channelHash },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: payload.encrypted.cipherMAC }, { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: payload.encrypted.cipherMAC },
{ {
name: "cipher text", name: "cipher text",
type: FieldType.BYTES, type: FieldType.BYTES,
size: payload.encrypted.cipherText.length, length: payload.encrypted.cipherText.length,
value: payload.encrypted.cipherText value: payload.encrypted.cipherText
} }
] ]
@@ -536,17 +556,17 @@ export class Packet implements IPacket {
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment: Segment = {
name: "anon req payload", name: "anon req payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [ fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: payload.dst }, { name: "destination hash", type: FieldType.UINT8, length: 1, value: payload.dst },
{ name: "public key", type: FieldType.BYTES, size: 32, value: payload.publicKey }, { name: "public key", type: FieldType.BYTES, length: 32, value: payload.publicKey },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: payload.encrypted.cipherMAC }, { name: "cipher MAC", type: FieldType.BYTES, length: 2, value: payload.encrypted.cipherMAC },
{ {
name: "cipher text", name: "cipher text",
type: FieldType.BYTES, type: FieldType.BYTES,
size: payload.encrypted.cipherText.length, length: payload.encrypted.cipherText.length,
value: payload.encrypted.cipherText value: payload.encrypted.cipherText
} }
] ]
@@ -569,12 +589,12 @@ export class Packet implements IPacket {
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment: Segment = {
name: "path payload", name: "path payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [ fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: payload.dst }, { name: "destination hash", type: FieldType.UINT8, length: 1, value: payload.dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: payload.src } { name: "source hash", type: FieldType.UINT8, length: 1, value: payload.src }
] ]
}; };
return { payload, segment }; return { payload, segment };
@@ -597,14 +617,14 @@ export class Packet implements IPacket {
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment: Segment = {
name: "trace payload", name: "trace payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [ fields: [
{ name: "tag", type: FieldType.DWORDS, size: 4, value: payload.tag }, { name: "tag", type: FieldType.DWORDS, length: 4, value: payload.tag },
{ name: "auth code", type: FieldType.DWORDS, size: 4, value: payload.authCode }, { name: "auth code", type: FieldType.DWORDS, length: 4, value: payload.authCode },
{ name: "flags", type: FieldType.UINT8, size: 1, value: payload.flags }, { name: "flags", type: FieldType.UINT8, length: 1, value: payload.flags },
{ name: "nodes", type: FieldType.BYTES, size: payload.nodes.length, value: payload.nodes } { name: "nodes", type: FieldType.BYTES, length: payload.nodes.length, value: payload.nodes }
] ]
}; };
return { payload, segment }; return { payload, segment };
@@ -619,10 +639,10 @@ export class Packet implements IPacket {
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment: Segment = {
name: "raw custom payload", name: "raw custom payload",
data: this.payload, data: new Uint8Array(this.payload).buffer,
fields: [{ name: "data", type: FieldType.BYTES, size: this.payload.length, value: this.payload }] fields: [{ name: "data", type: FieldType.BYTES, length: this.payload.length, value: this.payload }]
}; };
return { payload, segment }; return { payload, segment };
} }

View File

@@ -112,18 +112,12 @@ describe("PrivateKey", () => {
}); });
describe("SharedSecret", () => { 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); const ss = new SharedSecret(secret);
expect(ss.toBytes()).toEqual(secret); expect(ss.toBytes().length).toBe(16);
}); expect(bytesToHex(ss.toBytes())).toBe(bytesToHex(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", () => { it("throws on invalid length", () => {
@@ -163,6 +157,9 @@ describe("SharedSecret", () => {
it('fromName "Public"', () => { it('fromName "Public"', () => {
const ss = SharedSecret.fromName("Public"); const ss = SharedSecret.fromName("Public");
expect(ss).toBeInstanceOf(SharedSecret); expect(ss).toBeInstanceOf(SharedSecret);
const hash = ss.toHash();
expect(typeof hash).toBe("number");
expect(hash).toBe(0x11);
}); });
it("fromName with #group", () => { it("fromName with #group", () => {

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from "vitest"; 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 { Identity, LocalIdentity, Contact, Group, Contacts, parseNodeHash } from "../src/identity";
import { PrivateKey, PublicKey, SharedSecret } from "../src/crypto"; import { PrivateKey, PublicKey, SharedSecret } from "../src/crypto";
import { DecryptedGroupText, DecryptedGroupData } from "../src/packet.types"; import { DecryptedGroupText, DecryptedGroupData } from "../src/packet.types";
@@ -230,6 +230,18 @@ describe("Group", () => {
it("decryptData throws on short ciphertext", () => { it("decryptData throws on short ciphertext", () => {
expect(() => group.decryptData(randomBytes(16), Uint8Array.of(1, 2, 3))).toThrow(); 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", () => { describe("Contacts", () => {
@@ -501,4 +513,23 @@ describe("Contacts", () => {
const res2 = contacts.decrypt(contactB.identity.hash(), localA.publicKey.key[0], hmac, ciphertext); const res2 = contacts.decrypt(contactB.identity.hash(), localA.publicKey.key[0], hmac, ciphertext);
expect(res2.decrypted).toEqual(msg); 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);
});
});
}); });