7 Commits

9 changed files with 242 additions and 114 deletions

View File

@@ -1,19 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
eslint.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import js from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(["dist"]),
{
files: ["**/*.{ts,tsx}"],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser
}
}
]);

View File

@@ -1,7 +1,7 @@
{ {
"name": "@hamradio/meshcore", "name": "@hamradio/meshcore",
"type": "module", "type": "module",
"version": "1.3.0", "version": "1.3.1",
"description": "MeshCore protocol support for Typescript", "description": "MeshCore protocol support for Typescript",
"keywords": [ "keywords": [
"MeshCore", "MeshCore",
@@ -15,7 +15,7 @@
"license": "MIT", "license": "MIT",
"author": "Wijnand Modderman-Lenstra", "author": "Wijnand Modderman-Lenstra",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.mjs", "module": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"files": [ "files": [
"dist" "dist"
@@ -23,7 +23,7 @@
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.mjs", "import": "./dist/index.js",
"require": "./dist/index.js" "require": "./dist/index.js"
} }
}, },
@@ -49,6 +49,7 @@
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"globals": "^17.4.0", "globals": "^17.4.0",
"jiti": "^2.6.1",
"prettier": "3.8.1", "prettier": "3.8.1",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",

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: {} });
} }
@@ -228,6 +234,30 @@ export class Contacts {
this.contacts[hash].push(contact); this.contacts[hash].push(contact);
} }
public hasContact(nameOrHash: string | NodeHash): boolean {
if (typeof nameOrHash === "string") {
return Object.values(this.contacts)
.flat()
.some((contact) => contact.name.toLowerCase() === nameOrHash.toLowerCase());
} else {
const hash = parseNodeHash(nameOrHash) as number;
return (this.contacts[hash] || []).length > 0;
}
}
public getContactByName(name: string): Contact | null {
const contact = Object.values(this.contacts)
.flat()
.find((contact) => contact.name.toLowerCase() === name.toLowerCase());
return contact || null;
}
public getContacts(): Contact[] {
const contacts = Object.values(this.contacts).flat();
contacts.sort((a, b) => a.name.localeCompare(b.name));
return contacts;
}
public decrypt( public decrypt(
src: NodeHash | PublicKey, src: NodeHash | PublicKey,
dst: NodeHash, dst: NodeHash,
@@ -296,13 +326,54 @@ export class Contacts {
} }
public addGroup(group: Group) { public addGroup(group: Group) {
const hash = parseNodeHash(group.hash()) as number; // Remove any group with the same name (regardless of hash), the hash may
// have changed if the secret was changed, but the name is what identifies
// the group to the user.
for (const key of Object.keys(this.groups)) {
const hash = Number(key);
this.groups[hash] = this.groups[hash].filter((g) => g.name.toLowerCase() !== group.name.toLowerCase());
if (this.groups[hash].length === 0) {
delete this.groups[hash];
}
}
const hash = group.hash() as number;
if (!this.groups[hash]) { if (!this.groups[hash]) {
this.groups[hash] = []; this.groups[hash] = [];
} }
this.groups[hash].push(group); this.groups[hash].push(group);
} }
public hasGroup(nameOrHash: string | NodeHash): boolean {
if (typeof nameOrHash === "string") {
return Object.values(this.groups)
.flat()
.some((group) => group.name.toLowerCase() === nameOrHash.toLowerCase());
} else {
const hash = parseNodeHash(nameOrHash) as number;
return (this.groups[hash] || []).length > 0;
}
}
public getGroupByName(name: string): Group | null {
const group = Object.values(this.groups)
.flat()
.find((group) => group.name.toLowerCase() === name.toLowerCase());
return group || null;
}
public getGroups(): Group[] {
const groups = Object.values(this.groups).flat();
groups.sort((a, b) => {
if (a.name === "Public") return -1;
if (b.name === "Public") return 1;
return a.name.localeCompare(b.name);
});
return groups;
}
public decryptGroupText( public decryptGroupText(
channelHash: NodeHash, channelHash: NodeHash,
hmac: Uint8Array, hmac: Uint8Array,
@@ -314,7 +385,7 @@ export class Contacts {
const hash = parseNodeHash(channelHash) as number; const hash = parseNodeHash(channelHash) as number;
const groups = this.groups[hash] || []; const groups = this.groups[hash] || [];
if (groups.length === 0) { if (groups.length === 0) {
throw new Error("Unknown group hash"); throw new Error(`Unknown group hash ${hash.toString(16).padStart(2, "0")}`);
} }
for (const group of groups) { for (const group of groups) {
try { try {
@@ -324,7 +395,7 @@ export class Contacts {
// Ignore decryption errors and try the next group. // Ignore decryption errors and try the next group.
} }
} }
throw new Error("Decryption failed with all known groups"); throw new Error(`Decryption failed with all known groups with hash ${hash.toString(16).padStart(2, "0")}`);
} }
public decryptGroupData( public decryptGroupData(
@@ -338,7 +409,7 @@ export class Contacts {
const hash = parseNodeHash(channelHash) as number; const hash = parseNodeHash(channelHash) as number;
const groups = this.groups[hash] || []; const groups = this.groups[hash] || [];
if (groups.length === 0) { if (groups.length === 0) {
throw new Error("Unknown group hash"); throw new Error(`Unknown group hash ${hash.toString(16).padStart(2, "0")}`);
} }
for (const group of groups) { for (const group of groups) {
try { try {
@@ -348,6 +419,6 @@ export class Contacts {
// Ignore decryption errors and try the next group. // Ignore decryption errors and try the next group.
} }
} }
throw new Error("Decryption failed with all known groups"); throw new Error(`Decryption failed with all known groups with hash ${hash.toString(16).padStart(2, "0")}`);
} }
} }

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

@@ -1,5 +1,6 @@
import { Dissected } from "@hamradio/packet"; import { Dissected } from "@hamradio/packet";
import { NodeHash } from "./identity.types"; import { NodeHash } from "./identity.types";
import { Contact, Group } from "./identity";
// IPacket contains the raw packet bytes. // IPacket contains the raw packet bytes.
export type Uint16 = number; // 0..65535 export type Uint16 = number; // 0..65535
@@ -67,6 +68,7 @@ export interface RequestPayload extends BasePayload {
src: NodeHash; src: NodeHash;
encrypted: EncryptedPayload; encrypted: EncryptedPayload;
decrypted?: DecryptedRequest; decrypted?: DecryptedRequest;
contact?: Contact;
} }
export enum RequestType { export enum RequestType {
@@ -91,6 +93,7 @@ export interface ResponsePayload extends BasePayload {
src: NodeHash; src: NodeHash;
encrypted: EncryptedPayload; encrypted: EncryptedPayload;
decrypted?: DecryptedResponse; decrypted?: DecryptedResponse;
contact?: Contact;
} }
export interface DecryptedResponse { export interface DecryptedResponse {
@@ -104,6 +107,7 @@ export interface TextPayload extends BasePayload {
src: NodeHash; src: NodeHash;
encrypted: EncryptedPayload; encrypted: EncryptedPayload;
decrypted?: DecryptedText; decrypted?: DecryptedText;
contact?: Contact;
} }
export enum TextType { export enum TextType {
@@ -163,6 +167,7 @@ export interface GroupTextPayload extends BasePayload {
channelHash: NodeHash; channelHash: NodeHash;
encrypted: EncryptedPayload; encrypted: EncryptedPayload;
decrypted?: DecryptedGroupText; decrypted?: DecryptedGroupText;
group?: Group;
} }
export interface DecryptedGroupText { export interface DecryptedGroupText {
@@ -177,6 +182,7 @@ export interface GroupDataPayload extends BasePayload {
channelHash: NodeHash; channelHash: NodeHash;
encrypted: EncryptedPayload; encrypted: EncryptedPayload;
decrypted?: DecryptedGroupData; decrypted?: DecryptedGroupData;
group?: Group;
} }
export interface DecryptedGroupData { export interface DecryptedGroupData {
@@ -190,6 +196,7 @@ export interface AnonReqPayload extends BasePayload {
publicKey: Uint8Array; publicKey: Uint8Array;
encrypted: EncryptedPayload; encrypted: EncryptedPayload;
decrypted?: DecryptedAnonReq; decrypted?: DecryptedAnonReq;
contact?: Contact;
} }
export interface DecryptedAnonReq { export interface DecryptedAnonReq {

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