4 Commits
v1.2.2 ... main

Author SHA1 Message Date
0232da2d95 Use eslint.config.ts 2026-03-12 21:10:22 +01:00
ea29c037cb Version 1.3.0 2026-03-12 21:07:56 +01:00
f175eea99c Migrating to @hamradio/packet 2026-03-12 20:56:04 +01:00
7a23440666 Fix incorrect lat/lon decoding 2026-03-12 17:14:43 +01:00
17 changed files with 730 additions and 4299 deletions

3
.gitignore vendored
View File

@@ -103,6 +103,9 @@ web_modules/
# Optional npm cache directory # Optional npm cache directory
.npm .npm
# Optional package-lock.json file
package-lock.json
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache

View File

@@ -11,16 +11,22 @@ repos:
hooks: hooks:
- id: shellcheck - id: shellcheck
- repo: https://github.com/pre-commit/mirrors-eslint - repo: local
rev: v10.0.3 hooks:
- id: prettier
name: prettier
entry: npx prettier --write
language: system
files: "\\.(js|jsx|ts|tsx)$"
- repo: local
hooks: hooks:
- id: eslint - id: eslint
name: eslint
entry: npx eslint --fix
language: system
files: "\\.(js|jsx|ts|tsx)$" files: "\\.(js|jsx|ts|tsx)$"
exclude: node_modules/
# Use stylelint (local) instead of the deprecated scss-lint Ruby gem which
# cannot parse modern Sass `@use` and module syntax. This invokes the
# project's installed `stylelint` via `npx` so the devDependency is used.
- repo: local - repo: local
hooks: hooks:
- id: stylelint - id: stylelint

8
.prettierrc.ts Normal file
View File

@@ -0,0 +1,8 @@
import { type Config } from "prettier";
const config: Config = {
trailingComma: "none",
printWidth: 120
};
export default config;

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

3337
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
{ {
"name": "@hamradio/meshcore", "name": "@hamradio/meshcore",
"version": "1.2.2", "type": "module",
"version": "1.3.0",
"description": "MeshCore protocol support for Typescript", "description": "MeshCore protocol support for Typescript",
"keywords": [ "keywords": [
"MeshCore", "MeshCore",
@@ -9,7 +10,7 @@
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.maze.io/ham/meshcore.js" "url": "https://git.maze.io/ham/meshcore.ts"
}, },
"license": "MIT", "license": "MIT",
"author": "Wijnand Modderman-Lenstra", "author": "Wijnand Modderman-Lenstra",
@@ -37,6 +38,7 @@
"prepare": "npm run build" "prepare": "npm run build"
}, },
"dependencies": { "dependencies": {
"@hamradio/packet": "file:../packet.js/hamradio-packet-1.0.4.tgz",
"@noble/ciphers": "^2.1.1", "@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1", "@noble/curves": "^2.0.1",
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0",
@@ -47,6 +49,8 @@
"@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",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",

View File

@@ -1,10 +1,10 @@
import { ed25519, x25519 } from '@noble/curves/ed25519.js'; import { ed25519, x25519 } from "@noble/curves/ed25519.js";
import { sha256 } from "@noble/hashes/sha2.js"; import { sha256 } from "@noble/hashes/sha2.js";
import { hmac } from '@noble/hashes/hmac.js'; import { hmac } from "@noble/hashes/hmac.js";
import { ecb } from '@noble/ciphers/aes.js'; import { ecb } from "@noble/ciphers/aes.js";
import { bytesToHex, equalBytes, hexToBytes } from "./parser"; import { bytesToHex, equalBytes, hexToBytes } from "@hamradio/packet";
import { IPublicKey, ISharedSecret, IStaticSecret } from './crypto.types'; import { IPublicKey, ISharedSecret, IStaticSecret } from "./crypto.types";
import { NodeHash } from './identity.types'; import { NodeHash } from "./identity.types";
const PUBLIC_KEY_SIZE = 32; const PUBLIC_KEY_SIZE = 32;
const SEED_SIZE = 32; const SEED_SIZE = 32;
@@ -14,18 +14,21 @@ const SIGNATURE_SIZE = 64;
const STATIC_SECRET_SIZE = 32; const STATIC_SECRET_SIZE = 32;
// The "Public" group is a special group that all nodes are implicitly part of. // The "Public" group is a special group that all nodes are implicitly part of.
const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72", 16); const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72");
export class PublicKey implements IPublicKey { export class PublicKey implements IPublicKey {
public key: Uint8Array; public key: Uint8Array;
constructor(key: Uint8Array | string) { constructor(key: Uint8Array | string) {
if (typeof key === 'string') { if (typeof key === "string") {
this.key = hexToBytes(key, PUBLIC_KEY_SIZE); this.key = hexToBytes(key);
} else if (key instanceof Uint8Array) { } else if (key instanceof Uint8Array) {
this.key = key; this.key = key;
} else { } else {
throw new Error('Invalid type for PublicKey constructor'); throw new Error("Invalid type for PublicKey constructor");
}
if (this.key.length !== PUBLIC_KEY_SIZE) {
throw new Error(`Invalid public key length: expected ${PUBLIC_KEY_SIZE} bytes, got ${this.key.length}`);
} }
} }
@@ -47,10 +50,15 @@ export class PublicKey implements IPublicKey {
otherKey = other.toBytes(); otherKey = other.toBytes();
} else if (other instanceof Uint8Array) { } else if (other instanceof Uint8Array) {
otherKey = other; otherKey = other;
} else if (typeof other === 'string') { } else if (typeof other === "string") {
otherKey = hexToBytes(other, PUBLIC_KEY_SIZE); otherKey = hexToBytes(other);
} else { } else {
throw new Error('Invalid type for PublicKey comparison'); throw new Error("Invalid type for PublicKey comparison");
}
if (otherKey.length !== PUBLIC_KEY_SIZE) {
throw new Error(
`Invalid public key length for comparison: expected ${PUBLIC_KEY_SIZE} bytes, got ${otherKey.length}`
);
} }
return equalBytes(this.key, otherKey); return equalBytes(this.key, otherKey);
} }
@@ -61,6 +69,14 @@ export class PublicKey implements IPublicKey {
} }
return ed25519.verify(signature, message, this.key); return ed25519.verify(signature, message, this.key);
} }
public static fromBytes(key: Uint8Array): PublicKey {
return new PublicKey(key);
}
public static fromString(key: string): PublicKey {
return new PublicKey(key);
}
} }
export class PrivateKey { export class PrivateKey {
@@ -68,8 +84,8 @@ export class PrivateKey {
private publicKey: PublicKey; private publicKey: PublicKey;
constructor(seed: Uint8Array | string) { constructor(seed: Uint8Array | string) {
if (typeof seed === 'string') { if (typeof seed === "string") {
seed = hexToBytes(seed, SEED_SIZE); seed = hexToBytes(seed);
} }
if (seed.length !== SEED_SIZE) { if (seed.length !== SEED_SIZE) {
throw new Error(`Invalid seed length: expected ${SEED_SIZE} bytes, got ${seed.length}`); throw new Error(`Invalid seed length: expected ${SEED_SIZE} bytes, got ${seed.length}`);
@@ -102,10 +118,10 @@ export class PrivateKey {
otherPublicKey = other; otherPublicKey = other;
} else if (other instanceof Uint8Array) { } else if (other instanceof Uint8Array) {
otherPublicKey = new PublicKey(other); otherPublicKey = new PublicKey(other);
} else if (typeof other === 'string') { } else if (typeof other === "string") {
otherPublicKey = new PublicKey(other); otherPublicKey = new PublicKey(other);
} else { } else {
throw new Error('Invalid type for calculateSharedSecret comparison'); throw new Error("Invalid type for calculateSharedSecret comparison");
} }
return x25519.getSharedSecret(this.secretKey, otherPublicKey.toBytes()); return x25519.getSharedSecret(this.secretKey, otherPublicKey.toBytes());
} }
@@ -167,7 +183,7 @@ export class SharedSecret implements ISharedSecret {
return plaintext.slice(0, end); return plaintext.slice(0, end);
} }
public encrypt(data: Uint8Array): { hmac: Uint8Array, ciphertext: Uint8Array } { public encrypt(data: Uint8Array): { hmac: Uint8Array; ciphertext: Uint8Array } {
const key = this.secret.slice(0, 16); const key = this.secret.slice(0, 16);
const cipher = ecb(key, { disablePadding: true }); const cipher = ecb(key, { disablePadding: true });
@@ -210,8 +226,8 @@ export class StaticSecret implements IStaticSecret {
private secret: Uint8Array; private secret: Uint8Array;
constructor(secret: Uint8Array | string) { constructor(secret: Uint8Array | string) {
if (typeof secret === 'string') { if (typeof secret === "string") {
secret = hexToBytes(secret, STATIC_SECRET_SIZE); secret = hexToBytes(secret);
} }
if (secret.length !== STATIC_SECRET_SIZE) { if (secret.length !== STATIC_SECRET_SIZE) {
throw new Error(`Invalid static secret length: expected ${STATIC_SECRET_SIZE} bytes, got ${secret.length}`); throw new Error(`Invalid static secret length: expected ${STATIC_SECRET_SIZE} bytes, got ${secret.length}`);

View File

@@ -1,9 +1,8 @@
import { x25519 } from "@noble/curves/ed25519"; import { PrivateKey, PublicKey, SharedSecret } from "./crypto";
import { PrivateKey, PublicKey, SharedSecret, StaticSecret } from "./crypto";
import { IPublicKey } from "./crypto.types"; import { IPublicKey } from "./crypto.types";
import { NodeHash, IIdentity, ILocalIdentity } from "./identity.types"; import { NodeHash, IIdentity, ILocalIdentity } from "./identity.types";
import { DecryptedGroupData, DecryptedGroupText } from "./packet.types"; import { DecryptedGroupData, DecryptedGroupText } from "./packet.types";
import { equalBytes, hexToBytes, BufferReader, BufferWriter } from "./parser"; import { hexToBytes, Reader, Writer } from "@hamradio/packet";
export const parseNodeHash = (hash: NodeHash | string | Uint8Array): NodeHash => { export const parseNodeHash = (hash: NodeHash | string | Uint8Array): NodeHash => {
if (hash instanceof Uint8Array) { if (hash instanceof Uint8Array) {
@@ -22,7 +21,7 @@ export const parseNodeHash = (hash: NodeHash | string | Uint8Array): NodeHash =>
return parsed[0] as NodeHash; return parsed[0] as NodeHash;
} }
throw new Error("Invalid NodeHash type"); throw new Error("Invalid NodeHash type");
} };
const toPublicKeyBytes = (key: Identity | PublicKey | Uint8Array | string): Uint8Array => { const toPublicKeyBytes = (key: Identity | PublicKey | Uint8Array | string): Uint8Array => {
if (key instanceof Identity) { if (key instanceof Identity) {
@@ -31,12 +30,12 @@ const toPublicKeyBytes = (key: Identity | PublicKey | Uint8Array | string): Uint
return key.toBytes(); return key.toBytes();
} else if (key instanceof Uint8Array) { } else if (key instanceof Uint8Array) {
return key; return key;
} else if (typeof key === 'string') { } else if (typeof key === "string") {
return hexToBytes(key); return hexToBytes(key);
} else { } else {
throw new Error('Invalid type for toPublicKeyBytes'); throw new Error("Invalid type for toPublicKeyBytes");
} }
} };
export class Identity implements IIdentity { export class Identity implements IIdentity {
public publicKey: PublicKey; public publicKey: PublicKey;
@@ -44,10 +43,10 @@ export class Identity implements IIdentity {
constructor(publicKey: PublicKey | Uint8Array | string) { constructor(publicKey: PublicKey | Uint8Array | string) {
if (publicKey instanceof PublicKey) { if (publicKey instanceof PublicKey) {
this.publicKey = publicKey; this.publicKey = publicKey;
} else if (publicKey instanceof Uint8Array || typeof publicKey === 'string') { } else if (publicKey instanceof Uint8Array || typeof publicKey === "string") {
this.publicKey = new PublicKey(publicKey); this.publicKey = new PublicKey(publicKey);
} else { } else {
throw new Error('Invalid type for Identity constructor'); throw new Error("Invalid type for Identity constructor");
} }
} }
@@ -93,16 +92,16 @@ export class LocalIdentity extends Identity implements ILocalIdentity {
let otherPublicKey: PublicKey; let otherPublicKey: PublicKey;
if (other instanceof Identity) { if (other instanceof Identity) {
otherPublicKey = other.publicKey; otherPublicKey = other.publicKey;
} else if ('toBytes' in other) { } else if ("toBytes" in other) {
otherPublicKey = new PublicKey(other.toBytes()); otherPublicKey = new PublicKey(other.toBytes());
} else if ('publicKey' in other && other.publicKey instanceof Uint8Array) { } else if ("publicKey" in other && other.publicKey instanceof Uint8Array) {
otherPublicKey = new PublicKey(other.publicKey); otherPublicKey = new PublicKey(other.publicKey);
} else if ('publicKey' in other && other.publicKey instanceof PublicKey) { } else if ("publicKey" in other && other.publicKey instanceof PublicKey) {
otherPublicKey = other.publicKey; otherPublicKey = other.publicKey;
} else if ('publicKey' in other && typeof other.publicKey === 'function') { } else if ("publicKey" in other && typeof other.publicKey === "function") {
otherPublicKey = new PublicKey(other.publicKey().toBytes()); otherPublicKey = new PublicKey(other.publicKey().toBytes());
} 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));
} }
@@ -118,10 +117,10 @@ export class Contact {
this.identity = identity; this.identity = identity;
} else if (identity instanceof PublicKey) { } else if (identity instanceof PublicKey) {
this.identity = new Identity(identity); this.identity = new Identity(identity);
} else if (identity instanceof Uint8Array || typeof identity === 'string') { } else if (identity instanceof Uint8Array || typeof identity === "string") {
this.identity = new Identity(identity); this.identity = new Identity(identity);
} else { } else {
throw new Error('Invalid type for Contact constructor'); throw new Error("Invalid type for Contact constructor");
} }
} }
@@ -161,26 +160,26 @@ export class Group {
throw new Error("Invalid ciphertext"); throw new Error("Invalid ciphertext");
} }
const reader = new BufferReader(data); const reader = new Reader(data);
const timestamp = reader.readTimestamp(); const timestamp = reader.date32();
const flags = reader.readByte(); const flags = reader.uint8();
const textType = (flags >> 2) & 0x3F; const textType = (flags >> 2) & 0x3f;
const attempt = flags & 0x03; const attempt = flags & 0x03;
const message = new TextDecoder('utf-8').decode(reader.readBytes()); const message = new TextDecoder("utf-8").decode(reader.bytes());
return { return {
timestamp, timestamp,
textType, textType,
attempt, attempt,
message message
} };
} }
public encryptText(plain: DecryptedGroupText): { hmac: Uint8Array, ciphertext: Uint8Array } { public encryptText(plain: DecryptedGroupText): { hmac: Uint8Array; ciphertext: Uint8Array } {
const writer = new BufferWriter(); const writer = new Writer(4 + 1 + new TextEncoder().encode(plain.message).length);
writer.writeTimestamp(plain.timestamp); writer.date32(plain.timestamp);
const flags = ((plain.textType & 0x3F) << 2) | (plain.attempt & 0x03); const flags = ((plain.textType & 0x3f) << 2) | (plain.attempt & 0x03);
writer.writeByte(flags); writer.uint8(flags);
writer.writeBytes(new TextEncoder().encode(plain.message)); writer.utf8String(plain.message);
const data = writer.toBytes(); const data = writer.toBytes();
return this.secret.encrypt(data); return this.secret.encrypt(data);
} }
@@ -191,17 +190,17 @@ export class Group {
throw new Error("Invalid ciphertext"); throw new Error("Invalid ciphertext");
} }
const reader = new BufferReader(data); const reader = new Reader(data);
return { return {
timestamp: reader.readTimestamp(), timestamp: reader.date32(),
data: reader.readBytes(reader.remainingBytes()) data: reader.bytes()
} };
} }
public encryptData(plain: DecryptedGroupData): { hmac: Uint8Array, ciphertext: Uint8Array } { public encryptData(plain: DecryptedGroupData): { hmac: Uint8Array; ciphertext: Uint8Array } {
const writer = new BufferWriter(); const writer = new Writer(4 + plain.data.length);
writer.writeTimestamp(plain.timestamp); writer.date32(plain.timestamp);
writer.writeBytes(plain.data); writer.bytes(plain.data);
const data = writer.toBytes(); const data = writer.toBytes();
return this.secret.encrypt(data); return this.secret.encrypt(data);
} }
@@ -229,10 +228,15 @@ export class Contacts {
this.contacts[hash].push(contact); this.contacts[hash].push(contact);
} }
public decrypt(src: NodeHash | PublicKey, dst: NodeHash, hmac: Uint8Array, ciphertext: Uint8Array): { public decrypt(
localIdentity: LocalIdentity, src: NodeHash | PublicKey,
contact: Contact, dst: NodeHash,
decrypted: Uint8Array, hmac: Uint8Array,
ciphertext: Uint8Array
): {
localIdentity: LocalIdentity;
contact: Contact;
decrypted: Uint8Array;
} { } {
// Find the public key associated with the source hash. // Find the public key associated with the source hash.
let contacts: Contact[] = []; let contacts: Contact[] = [];
@@ -259,7 +263,7 @@ export class Contacts {
// Find the local identity associated with the destination hash. // Find the local identity associated with the destination hash.
const dstHash = parseNodeHash(dst) as number; const dstHash = parseNodeHash(dst) as number;
const localIdentities = this.localIdentities.filter(li => li.identity.publicKey.key[0] === dstHash); const localIdentities = this.localIdentities.filter((li) => li.identity.publicKey.key[0] === dstHash);
if (localIdentities.length === 0) { if (localIdentities.length === 0) {
throw new Error("Unknown destination hash"); throw new Error("Unknown destination hash");
} }
@@ -299,9 +303,13 @@ export class Contacts {
this.groups[hash].push(group); this.groups[hash].push(group);
} }
public decryptGroupText(channelHash: NodeHash, hmac: Uint8Array, ciphertext: Uint8Array): { public decryptGroupText(
decrypted: DecryptedGroupText, channelHash: NodeHash,
group: Group hmac: Uint8Array,
ciphertext: Uint8Array
): {
decrypted: DecryptedGroupText;
group: Group;
} { } {
const hash = parseNodeHash(channelHash) as number; const hash = parseNodeHash(channelHash) as number;
const groups = this.groups[hash] || []; const groups = this.groups[hash] || [];
@@ -319,9 +327,13 @@ export class Contacts {
throw new Error("Decryption failed with all known groups"); throw new Error("Decryption failed with all known groups");
} }
public decryptGroupData(channelHash: NodeHash, hmac: Uint8Array, ciphertext: Uint8Array): { public decryptGroupData(
decrypted: DecryptedGroupData, channelHash: NodeHash,
group: Group hmac: Uint8Array,
ciphertext: Uint8Array
): {
decrypted: DecryptedGroupData;
group: Group;
} { } {
const hash = parseNodeHash(channelHash) as number; const hash = parseNodeHash(channelHash) as number;
const groups = this.groups[hash] || []; const groups = this.groups[hash] || [];

View File

@@ -17,15 +17,9 @@ import {
RouteType, RouteType,
TextPayload, TextPayload,
TracePayload, TracePayload,
type IPacket, type IPacket
} from "./packet.types"; } from "./packet.types";
import { NodeHash } from "./identity.types"; import { base64ToBytes, bytesToHex, FieldType, Dissected, Segment, Reader } from "@hamradio/packet";
import {
base64ToBytes,
BufferReader,
bytesToHex
} from "./parser";
import { FieldType, PacketSegment, PacketStructure } from "./parser.types";
export class Packet implements IPacket { export class Packet implements IPacket {
// Raw packet bytes. // Raw packet bytes.
@@ -43,16 +37,22 @@ export class Packet implements IPacket {
public pathHashBytes: number; public pathHashBytes: number;
public pathHashes: string[]; public pathHashes: string[];
// Parsed packet segments. // Parsed packet segments.
public structure?: PacketStructure | undefined; public structure?: Dissected | undefined;
constructor(header: number, transport: [number, number] | undefined, pathLength: number, path: Uint8Array, payload: Uint8Array) { constructor(
header: number,
transport: [number, number] | undefined,
pathLength: number,
path: Uint8Array,
payload: Uint8Array
) {
this.header = header; this.header = header;
this.transport = transport; this.transport = transport;
this.pathLength = pathLength; this.pathLength = pathLength;
this.path = path; this.path = path;
this.payload = payload; this.payload = payload;
this.routeType = (header) & 0x03; this.routeType = header & 0x03;
this.payloadVersion = (header >> 6) & 0x03; this.payloadVersion = (header >> 6) & 0x03;
this.payloadType = (header >> 2) & 0x0f; this.payloadType = (header >> 2) & 0x0f;
@@ -108,11 +108,17 @@ export class Packet implements IPacket {
return; return;
} }
let pathHashType: FieldType let pathHashType: FieldType;
switch (this.pathHashSize) { switch (this.pathHashSize) {
case 1: pathHashType = FieldType.BYTES; break; case 1:
case 2: pathHashType = FieldType.WORDS; break; pathHashType = FieldType.BYTES;
case 4: pathHashType = FieldType.DWORDS; break; break;
case 2:
pathHashType = FieldType.WORDS;
break;
case 4:
pathHashType = FieldType.DWORDS;
break;
default: default:
throw new Error(`Unsupported path hash size: ${this.pathHashSize}`); throw new Error(`Unsupported path hash size: ${this.pathHashSize}`);
} }
@@ -131,18 +137,22 @@ export class Packet implements IPacket {
bits: [ bits: [
{ name: "payload version", size: 2 }, { name: "payload version", size: 2 },
{ name: "payload type", size: 4 }, { name: "payload type", size: 4 },
{ name: "route type", size: 2 }, { name: "route type", size: 2 }
] ]
}, }
] ]
}, },
/* Transport codes */ /* Transport codes */
...(Packet.hasTransportCodes(this.routeType) ? [{ ...(Packet.hasTransportCodes(this.routeType)
? [
{
name: "transport codes", name: "transport codes",
data: new Uint8Array([ data: new Uint8Array([
(this.transport![0] >> 8) & 0xff, this.transport![0] & 0xff, (this.transport![0] >> 8) & 0xff,
(this.transport![1] >> 8) & 0xff, this.transport![1] & 0xff this.transport![0] & 0xff,
(this.transport![1] >> 8) & 0xff,
this.transport![1] & 0xff
]), ]),
fields: [ fields: [
{ {
@@ -156,9 +166,11 @@ export class Packet implements IPacket {
type: FieldType.UINT16_BE, type: FieldType.UINT16_BE,
size: 2, size: 2,
value: this.transport![1] value: this.transport![1]
}, }
] ]
}] : []), }
]
: []),
/* Path length and hashes */ /* Path length and hashes */
{ {
@@ -171,7 +183,7 @@ export class Packet implements IPacket {
size: 1, size: 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 }
] ]
}, },
{ {
@@ -180,12 +192,12 @@ export class Packet implements IPacket {
size: this.path.length size: this.path.length
} }
] ]
}, }
] ];
} }
public decode(withStructure?: boolean): Payload | { payload: Payload, structure: PacketStructure } { public decode(withStructure?: boolean): Payload | { payload: Payload; structure: Dissected } {
let result: Payload | { payload: Payload, segment: PacketSegment }; let result: Payload | { payload: Payload; segment: Segment };
switch (this.payloadType) { switch (this.payloadType) {
case PayloadType.REQUEST: case PayloadType.REQUEST:
@@ -225,37 +237,35 @@ export class Packet implements IPacket {
throw new Error(`Unsupported payload type: ${this.payloadType}`); throw new Error(`Unsupported payload type: ${this.payloadType}`);
} }
console.log('packet decode with structure:', typeof withStructure, withStructure, { result });
if (typeof withStructure === "boolean" && withStructure && "segment" in result && "payload" in result) { if (typeof withStructure === "boolean" && withStructure && "segment" in result && "payload" in result) {
this.ensureStructure(); this.ensureStructure();
const structure = [ ...this.structure!, result.segment ]; const structure = [...this.structure!, result.segment];
return { payload: result.payload, structure }; return { payload: result.payload, structure };
} }
return result as Payload; return result as Payload;
} }
private decodeEncryptedPayload(reader: BufferReader): EncryptedPayload { private decodeEncryptedPayload(reader: Reader): EncryptedPayload {
const cipherMAC = reader.readBytes(2); const cipherMAC = reader.bytes(2);
const cipherText = reader.readBytes(reader.remainingBytes()); const cipherText = reader.bytes();
return { cipherMAC, cipherText }; return { cipherMAC, cipherText };
} }
private decodeRequest(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } { private decodeRequest(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 4) { if (this.payload.length < 4) {
throw new Error("Invalid request payload: too short"); throw new Error("Invalid request payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const dst = reader.readByte(); const dst = reader.uint8();
const src = reader.readByte(); const src = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader); const encrypted = this.decodeEncryptedPayload(reader);
const payload: RequestPayload = { const payload: RequestPayload = {
type: PayloadType.REQUEST, type: PayloadType.REQUEST,
dst, dst,
src, src,
encrypted, encrypted
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
@@ -268,26 +278,26 @@ export class Packet implements IPacket {
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC }, { 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: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText }
] ]
} };
return { payload, segment }; return { payload, segment };
} }
return payload; return payload;
} }
private decodeResponse(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } { private decodeResponse(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 4) { if (this.payload.length < 4) {
throw new Error("Invalid response payload: too short"); throw new Error("Invalid response payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const dst = reader.readByte(); const dst = reader.uint8();
const src = reader.readByte(); const src = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader); const encrypted = this.decodeEncryptedPayload(reader);
const payload: ResponsePayload = { const payload: ResponsePayload = {
type: PayloadType.RESPONSE, type: PayloadType.RESPONSE,
dst, dst,
src, src,
encrypted, encrypted
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
@@ -306,20 +316,20 @@ export class Packet implements IPacket {
return payload; return payload;
} }
private decodeText(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } { private decodeText(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 4) { if (this.payload.length < 4) {
throw new Error("Invalid text payload: too short"); throw new Error("Invalid text payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const dst = reader.readByte(); const dst = reader.uint8();
const src = reader.readByte(); const src = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader); const encrypted = this.decodeEncryptedPayload(reader);
const payload: TextPayload = { const payload: TextPayload = {
type: PayloadType.TEXT, type: PayloadType.TEXT,
dst, dst,
src, src,
encrypted, encrypted
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
@@ -338,45 +348,43 @@ export class Packet implements IPacket {
return payload; return payload;
} }
private decodeAck(withSegment?: boolean): Payload | { payload: AckPayload, segment: PacketSegment } { private decodeAck(withSegment?: boolean): Payload | { payload: AckPayload; segment: Segment } {
if (this.payload.length < 4) { if (this.payload.length < 4) {
throw new Error("Invalid ack payload: too short"); throw new Error("Invalid ack payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const checksum = reader.readBytes(4); const checksum = reader.bytes(4);
const payload: AckPayload = { const payload: AckPayload = {
type: PayloadType.ACK, type: PayloadType.ACK,
checksum, checksum
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment = {
name: "ack payload", name: "ack payload",
data: this.payload, data: this.payload,
fields: [ fields: [{ name: "checksum", type: FieldType.BYTES, size: 4, value: checksum }]
{ name: "checksum", type: FieldType.BYTES, size: 4, value: checksum }
]
}; };
return { payload, segment }; return { payload, segment };
} }
return payload; return payload;
} }
private decodeAdvert(withSegment?: boolean): Payload | { payload: AdvertPayload, segment: PacketSegment } { private decodeAdvert(withSegment?: boolean): Payload | { payload: AdvertPayload; segment: Segment } {
if (this.payload.length < 4) { if (this.payload.length < 4) {
throw new Error("Invalid advert payload: too short"); throw new Error("Invalid advert payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const payload: Partial<AdvertPayload> = { const payload: Partial<AdvertPayload> = {
type: PayloadType.ADVERT, type: PayloadType.ADVERT,
publicKey: reader.readBytes(32), publicKey: reader.bytes(32),
timestamp: reader.readTimestamp(), timestamp: reader.date32(),
signature: reader.readBytes(64), signature: reader.bytes(64)
} };
let segment: PacketSegment | undefined; let segment: Segment | undefined;
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
segment = { segment = {
name: "advert payload", name: "advert payload",
@@ -384,32 +392,38 @@ export class Packet implements IPacket {
fields: [ fields: [
{ type: FieldType.BYTES, name: "public key", size: 32 }, { type: FieldType.BYTES, name: "public key", size: 32 },
{ type: FieldType.UINT32_LE, name: "timestamp", size: 4, value: payload.timestamp! }, { type: FieldType.UINT32_LE, name: "timestamp", size: 4, value: payload.timestamp! },
{ type: FieldType.BYTES, name: "signature", size: 64 }, { type: FieldType.BYTES, name: "signature", size: 64 }
] ]
}; };
} }
const flags = reader.readByte(); const flags = reader.uint8();
const appdata: AdvertAppData = { const appdata: AdvertAppData = {
nodeType: flags & 0x0f, nodeType: flags & 0x0f,
hasLocation: (flags & AdvertFlag.HAS_LOCATION) !== 0, hasLocation: (flags & AdvertFlag.HAS_LOCATION) !== 0,
hasFeature1: (flags & AdvertFlag.HAS_FEATURE1) !== 0, hasFeature1: (flags & AdvertFlag.HAS_FEATURE1) !== 0,
hasFeature2: (flags & AdvertFlag.HAS_FEATURE2) !== 0, hasFeature2: (flags & AdvertFlag.HAS_FEATURE2) !== 0,
hasName: (flags & AdvertFlag.HAS_NAME) !== 0, hasName: (flags & AdvertFlag.HAS_NAME) !== 0
} };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
segment!.fields.push({ type: FieldType.BITS, name: "flags", size: 1, value: flags, bits: [ segment!.fields.push({
type: FieldType.BITS,
name: "flags",
size: 1,
value: flags,
bits: [
{ size: 1, name: "name flag" }, { size: 1, name: "name flag" },
{ size: 1, name: "feature2 flag" }, { size: 1, name: "feature2 flag" },
{ size: 1, name: "feature1 flag" }, { size: 1, name: "feature1 flag" },
{ size: 1, name: "location flag" }, { size: 1, name: "location flag" },
{ size: 4, name: "node type" }, { size: 4, name: "node type" }
]}); ]
});
} }
if (appdata.hasLocation) { if (appdata.hasLocation) {
const lat = reader.readInt32LE() / 100000; const lat = reader.int32() / 1000000;
const lon = reader.readInt32LE() / 100000; 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", size: 4, value: lat });
@@ -417,26 +431,26 @@ export class Packet implements IPacket {
} }
} }
if (appdata.hasFeature1) { if (appdata.hasFeature1) {
appdata.feature1 = reader.readUint16LE(); 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", size: 2, value: appdata.feature1 });
} }
} }
if (appdata.hasFeature2) { if (appdata.hasFeature2) {
appdata.feature2 = reader.readUint16LE(); 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", size: 2, value: appdata.feature2 });
} }
} }
if (appdata.hasName) { if (appdata.hasName) {
const nameBytes = reader.readBytes(); appdata.name = reader.cString();
let nullPos = nameBytes.indexOf(0);
if (nullPos === -1) {
nullPos = nameBytes.length;
}
appdata.name = new TextDecoder('utf-8').decode(nameBytes.subarray(0, nullPos));
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
segment!.fields.push({ type: FieldType.C_STRING, name: "name", size: nameBytes.length, value: appdata.name }); segment!.fields.push({
type: FieldType.C_STRING,
name: "name",
size: appdata.name.length,
value: appdata.name
});
} }
} }
@@ -447,18 +461,18 @@ export class Packet implements IPacket {
return { ...payload, appdata } as AdvertPayload; return { ...payload, appdata } as AdvertPayload;
} }
private decodeGroupText(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } { private decodeGroupText(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 3) { if (this.payload.length < 3) {
throw new Error("Invalid group text payload: too short"); throw new Error("Invalid group text payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const channelHash = reader.readByte(); const channelHash = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader); const encrypted = this.decodeEncryptedPayload(reader);
const payload: GroupTextPayload = { const payload: GroupTextPayload = {
type: PayloadType.GROUP_TEXT, type: PayloadType.GROUP_TEXT,
channelHash, channelHash,
encrypted, encrypted
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
@@ -476,16 +490,16 @@ export class Packet implements IPacket {
return payload; return payload;
} }
private decodeGroupData(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } { private decodeGroupData(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 3) { if (this.payload.length < 3) {
throw new Error("Invalid group data payload: too short"); throw new Error("Invalid group data payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const payload: GroupDataPayload = { const payload: GroupDataPayload = {
type: PayloadType.GROUP_DATA, type: PayloadType.GROUP_DATA,
channelHash: reader.readByte(), channelHash: reader.uint8(),
encrypted: this.decodeEncryptedPayload(reader), encrypted: this.decodeEncryptedPayload(reader)
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
@@ -495,7 +509,12 @@ export class Packet implements IPacket {
fields: [ fields: [
{ name: "channel hash", type: FieldType.UINT8, size: 1, value: payload.channelHash }, { name: "channel hash", type: FieldType.UINT8, size: 1, value: payload.channelHash },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: payload.encrypted.cipherMAC }, { name: "cipher MAC", type: FieldType.BYTES, size: 2, value: payload.encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: payload.encrypted.cipherText.length, value: payload.encrypted.cipherText } {
name: "cipher text",
type: FieldType.BYTES,
size: payload.encrypted.cipherText.length,
value: payload.encrypted.cipherText
}
] ]
}; };
return { payload, segment }; return { payload, segment };
@@ -503,18 +522,18 @@ export class Packet implements IPacket {
return payload; return payload;
} }
private decodeAnonReq(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } { private decodeAnonReq(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 1 + 32 + 2) { if (this.payload.length < 1 + 32 + 2) {
throw new Error("Invalid anon req payload: too short"); throw new Error("Invalid anon req payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const payload: AnonReqPayload = { const payload: AnonReqPayload = {
type: PayloadType.ANON_REQ, type: PayloadType.ANON_REQ,
dst: reader.readByte(), dst: reader.uint8(),
publicKey: reader.readBytes(32), publicKey: reader.bytes(32),
encrypted: this.decodeEncryptedPayload(reader), encrypted: this.decodeEncryptedPayload(reader)
} };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment = {
@@ -524,7 +543,12 @@ export class Packet implements IPacket {
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: payload.dst }, { name: "destination hash", type: FieldType.UINT8, size: 1, value: payload.dst },
{ name: "public key", type: FieldType.BYTES, size: 32, value: payload.publicKey }, { name: "public key", type: FieldType.BYTES, size: 32, value: payload.publicKey },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: payload.encrypted.cipherMAC }, { name: "cipher MAC", type: FieldType.BYTES, size: 2, value: payload.encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: payload.encrypted.cipherText.length, value: payload.encrypted.cipherText } {
name: "cipher text",
type: FieldType.BYTES,
size: payload.encrypted.cipherText.length,
value: payload.encrypted.cipherText
}
] ]
}; };
return { payload, segment }; return { payload, segment };
@@ -532,16 +556,16 @@ export class Packet implements IPacket {
return payload; return payload;
} }
private decodePath(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } { private decodePath(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 2) { if (this.payload.length < 2) {
throw new Error("Invalid path payload: too short"); throw new Error("Invalid path payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const payload: PathPayload = { const payload: PathPayload = {
type: PayloadType.PATH, type: PayloadType.PATH,
dst: reader.readByte(), dst: reader.uint8(),
src: reader.readByte(), src: reader.uint8()
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
@@ -558,18 +582,18 @@ export class Packet implements IPacket {
return payload; return payload;
} }
private decodeTrace(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } { private decodeTrace(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 9) { if (this.payload.length < 9) {
throw new Error("Invalid trace payload: too short"); throw new Error("Invalid trace payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const payload: TracePayload = { const payload: TracePayload = {
type: PayloadType.TRACE, type: PayloadType.TRACE,
tag: reader.readUint32LE() >>> 0, tag: reader.uint32(),
authCode: reader.readUint32LE() >>> 0, authCode: reader.uint32(),
flags: reader.readByte() & 0x03, flags: reader.uint8() & 0x03,
nodes: reader.readBytes() nodes: reader.bytes()
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
@@ -588,19 +612,17 @@ export class Packet implements IPacket {
return payload; return payload;
} }
private decodeRawCustom(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } { private decodeRawCustom(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
const payload: RawCustomPayload = { const payload: RawCustomPayload = {
type: PayloadType.RAW_CUSTOM, type: PayloadType.RAW_CUSTOM,
data: this.payload, data: this.payload
}; };
if (typeof withSegment === "boolean" && withSegment) { if (typeof withSegment === "boolean" && withSegment) {
const segment = { const segment = {
name: "raw custom payload", name: "raw custom payload",
data: this.payload, data: this.payload,
fields: [ fields: [{ name: "data", type: FieldType.BYTES, size: this.payload.length, value: this.payload }]
{ name: "data", type: FieldType.BYTES, size: this.payload.length, value: this.payload }
]
}; };
return { payload, segment }; return { payload, segment };
} }

View File

@@ -1,5 +1,5 @@
import { Dissected } from "@hamradio/packet";
import { NodeHash } from "./identity.types"; import { NodeHash } from "./identity.types";
import { PacketStructure } from "./parser.types";
// IPacket contains the raw packet bytes. // IPacket contains the raw packet bytes.
export type Uint16 = number; // 0..65535 export type Uint16 = number; // 0..65535
@@ -13,7 +13,7 @@ export interface IPacket {
path: Uint8Array; path: Uint8Array;
payload: Uint8Array; payload: Uint8Array;
decode(withStructure?: boolean): Payload | { payload: Payload, structure: PacketStructure } decode(withStructure?: boolean): Payload | { payload: Payload; structure: Dissected };
} }
export enum RouteType { export enum RouteType {
@@ -34,10 +34,11 @@ export enum PayloadType {
ANON_REQ = 0x07, ANON_REQ = 0x07,
PATH = 0x08, PATH = 0x08,
TRACE = 0x09, TRACE = 0x09,
RAW_CUSTOM = 0x0f, RAW_CUSTOM = 0x0f
} }
export type Payload = BasePayload & ( export type Payload = BasePayload &
(
| RequestPayload | RequestPayload
| ResponsePayload | ResponsePayload
| TextPayload | TextPayload
@@ -49,7 +50,7 @@ export type Payload = BasePayload & (
| PathPayload | PathPayload
| TracePayload | TracePayload
| RawCustomPayload | RawCustomPayload
); );
export interface BasePayload { export interface BasePayload {
type: PayloadType; type: PayloadType;
@@ -75,7 +76,7 @@ export enum RequestType {
GET_MIN_MAX_AVG = 0x04, GET_MIN_MAX_AVG = 0x04,
GET_ACL = 0x05, GET_ACL = 0x05,
GET_NEIGHBORS = 0x06, GET_NEIGHBORS = 0x06,
GET_OWNER_INFO = 0x07, GET_OWNER_INFO = 0x07
} }
export interface DecryptedRequest { export interface DecryptedRequest {
@@ -108,7 +109,7 @@ export interface TextPayload extends BasePayload {
export enum TextType { export enum TextType {
PLAIN_TEXT = 0x00, PLAIN_TEXT = 0x00,
CLI_COMMAND = 0x01, CLI_COMMAND = 0x01,
SIGNED_PLAIN_TEXT = 0x02, SIGNED_PLAIN_TEXT = 0x02
} }
export interface DecryptedText { export interface DecryptedText {
@@ -135,14 +136,14 @@ export enum NodeType {
CHAT_NODE = 0x01, CHAT_NODE = 0x01,
REPEATER = 0x02, REPEATER = 0x02,
ROOM_SERVER = 0x03, ROOM_SERVER = 0x03,
SENSOR_NODE = 0x04, SENSOR_NODE = 0x04
} }
export enum AdvertFlag { export enum AdvertFlag {
HAS_LOCATION = 0x10, HAS_LOCATION = 0x10,
HAS_FEATURE1 = 0x20, HAS_FEATURE1 = 0x20,
HAS_FEATURE2 = 0x40, HAS_FEATURE2 = 0x40,
HAS_NAME = 0x80, HAS_NAME = 0x80
} }
export interface AdvertAppData { export interface AdvertAppData {

View File

@@ -1,150 +0,0 @@
import { equalBytes } from "@noble/ciphers/utils.js";
import { bytesToHex, hexToBytes as nobleHexToBytes } from "@noble/hashes/utils.js";
export {
bytesToHex,
equalBytes
};
export const base64ToBytes = (base64: string, size?: number): Uint8Array => {
// Normalize URL-safe base64 to standard base64
let normalized = base64.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if missing
while (normalized.length % 4 !== 0) {
normalized += '=';
}
const binaryString = atob(normalized);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
if (size !== undefined && bytes.length !== size) {
throw new Error(`Invalid base64 length: expected ${size} bytes, got ${bytes.length}`);
}
return bytes;
}
// Note: encodedStringToBytes removed — prefer explicit parsers.
// Use `hexToBytes` for hex inputs and `base64ToBytes` for base64 inputs.
// Wrapper around @noble/hashes hexToBytes that optionally validates size.
export const hexToBytes = (hex: string, size?: number): Uint8Array => {
const bytes = nobleHexToBytes(hex);
if (size !== undefined && bytes.length !== size) {
throw new Error(`Invalid hex length: expected ${size} bytes, got ${bytes.length}`);
}
return bytes;
};
export class BufferReader {
private buffer: Uint8Array;
private offset: number;
constructor(buffer: Uint8Array) {
this.buffer = buffer;
this.offset = 0;
}
public readByte(): number {
if (!this.hasMore()) throw new Error('read past end');
return this.buffer[this.offset++];
}
public readBytes(length?: number): Uint8Array {
if (length === undefined) {
length = this.buffer.length - this.offset;
}
if (this.remainingBytes() < length) throw new Error('read past end');
const bytes = this.buffer.slice(this.offset, this.offset + length);
this.offset += length;
return bytes;
}
public hasMore(): boolean {
return this.offset < this.buffer.length;
}
public remainingBytes(): number {
return this.buffer.length - this.offset;
}
public peekByte(): number {
if (!this.hasMore()) throw new Error('read past end');
return this.buffer[this.offset];
}
public readUint16LE(): number {
if (this.remainingBytes() < 2) throw new Error('read past end');
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8);
this.offset += 2;
return value;
}
public readUint32LE(): number {
if (this.remainingBytes() < 4) throw new Error('read past end');
const value = (this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8) | (this.buffer[this.offset + 2] << 16) | (this.buffer[this.offset + 3] << 24)) >>> 0;
this.offset += 4;
return value;
}
public readInt16LE(): number {
if (this.remainingBytes() < 2) throw new Error('read past end');
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8);
this.offset += 2;
return value < 0x8000 ? value : value - 0x10000;
}
public readInt32LE(): number {
if (this.remainingBytes() < 4) throw new Error('read past end');
const u = (this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8) | (this.buffer[this.offset + 2] << 16) | (this.buffer[this.offset + 3] << 24)) >>> 0;
this.offset += 4;
return u < 0x80000000 ? u : u - 0x100000000;
}
public readTimestamp(): Date {
const timestamp = this.readUint32LE();
return new Date(timestamp * 1000);
}
}
export class BufferWriter {
private buffer: number[] = [];
public writeByte(value: number): void {
this.buffer.push(value & 0xFF);
}
public writeBytes(bytes: Uint8Array): void {
this.buffer.push(...bytes);
}
public writeUint16LE(value: number): void {
this.buffer.push(value & 0xFF, (value >> 8) & 0xFF);
}
public writeUint32LE(value: number): void {
this.buffer.push(
value & 0xFF,
(value >> 8) & 0xFF,
(value >> 16) & 0xFF,
(value >> 24) & 0xFF
);
}
public writeInt16LE(value: number): void {
this.writeUint16LE(value < 0 ? value + 0x10000 : value);
}
public writeInt32LE(value: number): void {
this.writeUint32LE(value < 0 ? value + 0x100000000 : value);
}
public writeTimestamp(date: Date): void {
const timestamp = Math.floor(date.getTime() / 1000);
this.writeUint32LE(timestamp);
}
public toBytes(): Uint8Array {
return new Uint8Array(this.buffer);
}
}

View File

@@ -1,35 +0,0 @@
export enum FieldType {
BITS = 0,
UINT8 = 1,
UINT16_LE = 2,
UINT16_BE = 3,
UINT32_LE = 4,
UINT32_BE = 5,
BYTES = 6, // 8-bits per value
WORDS = 7, // 16-bits per value
DWORDS = 8, // 32-bits per value
QWORDS = 9, // 64-bits per value
C_STRING = 10,
}
// Interface for the parsed packet segments, used for debugging and testing.
export type PacketStructure = PacketSegment[];
export interface PacketSegment {
name: string;
data: Uint8Array;
fields: PacketField[];
}
export interface PacketField {
type: FieldType;
size: number; // Size in bytes
name?: string;
bits?: PacketFieldBit[]; // Only for bit fields in FieldType.BITS
value?: any; // Optional decoded value
}
export interface PacketFieldBit {
name: string;
size: number; // Size in bits
}

View File

@@ -1,39 +1,39 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { PublicKey, PrivateKey, SharedSecret, StaticSecret } from '../src/crypto'; import { bytesToHex } from "@hamradio/packet";
import { bytesToHex, hexToBytes } from '../src/parser'; import { PublicKey, PrivateKey, SharedSecret, StaticSecret } from "../src/crypto";
const randomBytes = (len: number) => Uint8Array.from({ length: len }, () => Math.floor(Math.random() * 256)); const randomBytes = (len: number) => Uint8Array.from({ length: len }, () => Math.floor(Math.random() * 256));
describe('PublicKey', () => { describe("PublicKey", () => {
const keyBytes = randomBytes(32); const keyBytes = randomBytes(32);
const keyHex = bytesToHex(keyBytes); const keyHex = bytesToHex(keyBytes);
it('constructs from Uint8Array', () => { it("constructs from Uint8Array", () => {
const pk = new PublicKey(keyBytes); const pk = new PublicKey(keyBytes);
expect(pk.toBytes()).toEqual(keyBytes); expect(pk.toBytes()).toEqual(keyBytes);
}); });
it('constructs from string', () => { it("constructs from string", () => {
const pk = new PublicKey(keyHex); const pk = new PublicKey(keyHex);
expect(pk.toBytes()).toEqual(keyBytes); expect(pk.toBytes()).toEqual(keyBytes);
}); });
it('throws on invalid constructor input', () => { it("throws on invalid constructor input", () => {
// @ts-expect-error // @ts-expect-error testing invalid input
expect(() => new PublicKey(123)).toThrow(); expect(() => new PublicKey(123)).toThrow();
}); });
it('toHash returns a NodeHash', () => { it("toHash returns a NodeHash", () => {
const pk = new PublicKey(keyBytes); const pk = new PublicKey(keyBytes);
expect(typeof pk.toHash()).toBe('number'); expect(typeof pk.toHash()).toBe("number");
}); });
it('toString returns hex', () => { it("toString returns hex", () => {
const pk = new PublicKey(keyBytes); const pk = new PublicKey(keyBytes);
expect(pk.toString()).toBe(keyHex); expect(pk.toString()).toBe(keyHex);
}); });
it('equals works for PublicKey, Uint8Array, and string', () => { it("equals works for PublicKey, Uint8Array, and string", () => {
const pk = new PublicKey(keyBytes); const pk = new PublicKey(keyBytes);
expect(pk.equals(pk)).toBe(true); expect(pk.equals(pk)).toBe(true);
expect(pk.equals(keyBytes)).toBe(true); expect(pk.equals(keyBytes)).toBe(true);
@@ -41,41 +41,41 @@ describe('PublicKey', () => {
expect(pk.equals(randomBytes(32))).toBe(false); expect(pk.equals(randomBytes(32))).toBe(false);
}); });
it('throws on equals with invalid type', () => { it("throws on equals with invalid type", () => {
const pk = new PublicKey(keyBytes); const pk = new PublicKey(keyBytes);
// @ts-expect-error // @ts-expect-error testing invalid input
expect(() => pk.equals(123)).toThrow(); expect(() => pk.equals(123)).toThrow();
}); });
it('verify returns false for invalid signature', () => { it("verify returns false for invalid signature", () => {
const pk = new PublicKey(keyBytes); const pk = new PublicKey(keyBytes);
expect(pk.verify(new Uint8Array([1, 2, 3]), randomBytes(64))).toBe(false); expect(pk.verify(new Uint8Array([1, 2, 3]), randomBytes(64))).toBe(false);
}); });
it('throws on verify with wrong signature length', () => { it("throws on verify with wrong signature length", () => {
const pk = new PublicKey(keyBytes); const pk = new PublicKey(keyBytes);
expect(() => pk.verify(new Uint8Array([1, 2, 3]), randomBytes(10))).toThrow(); expect(() => pk.verify(new Uint8Array([1, 2, 3]), randomBytes(10))).toThrow();
}); });
}); });
describe('PrivateKey', () => { describe("PrivateKey", () => {
const seed = randomBytes(32); const seed = randomBytes(32);
it('constructs from Uint8Array', () => { it("constructs from Uint8Array", () => {
const sk = new PrivateKey(seed); const sk = new PrivateKey(seed);
expect(sk.toPublicKey()).toBeInstanceOf(PublicKey); expect(sk.toPublicKey()).toBeInstanceOf(PublicKey);
}); });
it('constructs from string', () => { it("constructs from string", () => {
const sk = new PrivateKey(bytesToHex(seed)); const sk = new PrivateKey(bytesToHex(seed));
expect(sk.toPublicKey()).toBeInstanceOf(PublicKey); expect(sk.toPublicKey()).toBeInstanceOf(PublicKey);
}); });
it('throws on invalid seed length', () => { it("throws on invalid seed length", () => {
expect(() => new PrivateKey(randomBytes(10))).toThrow(); expect(() => new PrivateKey(randomBytes(10))).toThrow();
}); });
it('sign and verify', () => { it("sign and verify", () => {
const sk = new PrivateKey(seed); const sk = new PrivateKey(seed);
const pk = sk.toPublicKey(); const pk = sk.toPublicKey();
const msg = new Uint8Array([1, 2, 3]); const msg = new Uint8Array([1, 2, 3]);
@@ -83,7 +83,7 @@ describe('PrivateKey', () => {
expect(pk.verify(msg, sig)).toBe(true); expect(pk.verify(msg, sig)).toBe(true);
}); });
it('calculateSharedSecret returns Uint8Array', () => { it("calculateSharedSecret returns Uint8Array", () => {
const sk1 = new PrivateKey(seed); const sk1 = new PrivateKey(seed);
const sk2 = PrivateKey.generate(); const sk2 = PrivateKey.generate();
const pk2 = sk2.toPublicKey(); const pk2 = sk2.toPublicKey();
@@ -92,7 +92,7 @@ describe('PrivateKey', () => {
expect(secret.length).toBeGreaterThan(0); expect(secret.length).toBeGreaterThan(0);
}); });
it('calculateSharedSecret accepts string and Uint8Array', () => { it("calculateSharedSecret accepts string and Uint8Array", () => {
const sk1 = new PrivateKey(seed); const sk1 = new PrivateKey(seed);
const sk2 = PrivateKey.generate(); const sk2 = PrivateKey.generate();
const pk2 = sk2.toPublicKey(); const pk2 = sk2.toPublicKey();
@@ -100,47 +100,47 @@ describe('PrivateKey', () => {
expect(sk1.calculateSharedSecret(pk2.toString())).toBeInstanceOf(Uint8Array); expect(sk1.calculateSharedSecret(pk2.toString())).toBeInstanceOf(Uint8Array);
}); });
it('throws on calculateSharedSecret with invalid type', () => { it("throws on calculateSharedSecret with invalid type", () => {
const sk = new PrivateKey(seed); const sk = new PrivateKey(seed);
// @ts-expect-error // @ts-expect-error testing invalid input
expect(() => sk.calculateSharedSecret(123)).toThrow(); expect(() => sk.calculateSharedSecret(123)).toThrow();
}); });
it('generate returns PrivateKey', () => { it("generate returns PrivateKey", () => {
expect(PrivateKey.generate()).toBeInstanceOf(PrivateKey); expect(PrivateKey.generate()).toBeInstanceOf(PrivateKey);
}); });
}); });
describe('SharedSecret', () => { describe("SharedSecret", () => {
const secret = randomBytes(32); const secret = randomBytes(32);
it('constructs from 32 bytes', () => { it("constructs from 32 bytes", () => {
const ss = new SharedSecret(secret); const ss = new SharedSecret(secret);
expect(ss.toBytes()).toEqual(secret); expect(ss.toBytes()).toEqual(secret);
}); });
it('pads if 16 bytes', () => { it("pads if 16 bytes", () => {
const short = randomBytes(16); const short = randomBytes(16);
const ss = new SharedSecret(short); const ss = new SharedSecret(short);
expect(ss.toBytes().length).toBe(32); expect(ss.toBytes().length).toBe(32);
expect(Array.from(ss.toBytes()).slice(16)).toEqual(Array.from(short)); expect(Array.from(ss.toBytes()).slice(16)).toEqual(Array.from(short));
}); });
it('throws on invalid length', () => { it("throws on invalid length", () => {
expect(() => new SharedSecret(randomBytes(10))).toThrow(); expect(() => new SharedSecret(randomBytes(10))).toThrow();
}); });
it('toHash returns number', () => { it("toHash returns number", () => {
const ss = new SharedSecret(secret); const ss = new SharedSecret(secret);
expect(typeof ss.toHash()).toBe('number'); expect(typeof ss.toHash()).toBe("number");
}); });
it('toString returns hex', () => { it("toString returns hex", () => {
const ss = new SharedSecret(secret); const ss = new SharedSecret(secret);
expect(ss.toString()).toBe(bytesToHex(secret)); expect(ss.toString()).toBe(bytesToHex(secret));
}); });
it('encrypt and decrypt roundtrip', () => { it("encrypt and decrypt roundtrip", () => {
const ss = new SharedSecret(secret); const ss = new SharedSecret(secret);
const data = new Uint8Array([1, 2, 3, 4, 5]); const data = new Uint8Array([1, 2, 3, 4, 5]);
const { hmac, ciphertext } = ss.encrypt(data); const { hmac, ciphertext } = ss.encrypt(data);
@@ -148,14 +148,14 @@ describe('SharedSecret', () => {
expect(Array.from(decrypted.slice(0, data.length))).toEqual(Array.from(data)); expect(Array.from(decrypted.slice(0, data.length))).toEqual(Array.from(data));
}); });
it('throws on decrypt with wrong hmac', () => { it("throws on decrypt with wrong hmac", () => {
const ss = new SharedSecret(secret); const ss = new SharedSecret(secret);
const data = new Uint8Array([1, 2, 3]); const data = new Uint8Array([1, 2, 3]);
const { ciphertext } = ss.encrypt(data); const { ciphertext } = ss.encrypt(data);
expect(() => ss.decrypt(new Uint8Array([0, 0]), ciphertext)).toThrow(); expect(() => ss.decrypt(new Uint8Array([0, 0]), ciphertext)).toThrow();
}); });
it('throws on decrypt with wrong hmac length', () => { it("throws on decrypt with wrong hmac length", () => {
const ss = new SharedSecret(secret); const ss = new SharedSecret(secret);
expect(() => ss.decrypt(new Uint8Array([1]), new Uint8Array([1, 2, 3]))).toThrow(); expect(() => ss.decrypt(new Uint8Array([1]), new Uint8Array([1, 2, 3]))).toThrow();
}); });
@@ -165,34 +165,34 @@ describe('SharedSecret', () => {
expect(ss).toBeInstanceOf(SharedSecret); expect(ss).toBeInstanceOf(SharedSecret);
}); });
it('fromName with #group', () => { it("fromName with #group", () => {
const ss = SharedSecret.fromName("#group"); const ss = SharedSecret.fromName("#group");
expect(ss).toBeInstanceOf(SharedSecret); expect(ss).toBeInstanceOf(SharedSecret);
}); });
it('fromName throws on invalid name', () => { it("fromName throws on invalid name", () => {
expect(() => SharedSecret.fromName("foo")).toThrow(); expect(() => SharedSecret.fromName("foo")).toThrow();
}); });
}); });
describe('StaticSecret', () => { describe("StaticSecret", () => {
const secret = randomBytes(32); const secret = randomBytes(32);
it('constructs from Uint8Array', () => { it("constructs from Uint8Array", () => {
const ss = new StaticSecret(secret); const ss = new StaticSecret(secret);
expect(ss.publicKey()).toBeInstanceOf(PublicKey); expect(ss.publicKey()).toBeInstanceOf(PublicKey);
}); });
it('constructs from string', () => { it("constructs from string", () => {
const ss = new StaticSecret(bytesToHex(secret)); const ss = new StaticSecret(bytesToHex(secret));
expect(ss.publicKey()).toBeInstanceOf(PublicKey); expect(ss.publicKey()).toBeInstanceOf(PublicKey);
}); });
it('throws on invalid length', () => { it("throws on invalid length", () => {
expect(() => new StaticSecret(randomBytes(10))).toThrow(); expect(() => new StaticSecret(randomBytes(10))).toThrow();
}); });
it('diffieHellman returns SharedSecret', () => { it("diffieHellman returns SharedSecret", () => {
const ss1 = new StaticSecret(secret); const ss1 = new StaticSecret(secret);
const ss2 = new StaticSecret(randomBytes(32)); const ss2 = new StaticSecret(randomBytes(32));
const pk2 = ss2.publicKey(); const pk2 = ss2.publicKey();

View File

@@ -1,33 +1,33 @@
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach } from "vitest";
import { Identity, LocalIdentity, Contact, Group, Contacts, parseNodeHash } from '../src/identity'; import { bytesToHex } from "@hamradio/packet";
import { PrivateKey, PublicKey, SharedSecret } from '../src/crypto'; import { Identity, LocalIdentity, Contact, Group, Contacts, parseNodeHash } from "../src/identity";
import { DecryptedGroupText, DecryptedGroupData } from '../src/packet.types'; import { PrivateKey, PublicKey, SharedSecret } from "../src/crypto";
import { bytesToHex } from '../src/parser'; import { DecryptedGroupText, DecryptedGroupData } from "../src/packet.types";
function randomBytes(len: number) { function randomBytes(len: number) {
return Uint8Array.from({ length: len }, () => Math.floor(Math.random() * 256)); return Uint8Array.from({ length: len }, () => Math.floor(Math.random() * 256));
} }
describe('parseNodeHash', () => { describe("parseNodeHash", () => {
it('parses Uint8Array', () => { it("parses Uint8Array", () => {
expect(parseNodeHash(Uint8Array.of(0x42))).toBe(0x42); expect(parseNodeHash(Uint8Array.of(0x42))).toBe(0x42);
}); });
it('parses number', () => { it("parses number", () => {
expect(parseNodeHash(0x42)).toBe(0x42); expect(parseNodeHash(0x42)).toBe(0x42);
expect(() => parseNodeHash(-1)).toThrow(); expect(() => parseNodeHash(-1)).toThrow();
expect(() => parseNodeHash(256)).toThrow(); expect(() => parseNodeHash(256)).toThrow();
}); });
it('parses string', () => { it("parses string", () => {
expect(parseNodeHash('2a')).toBe(0x2a); expect(parseNodeHash("2a")).toBe(0x2a);
expect(() => parseNodeHash('2a2a')).toThrow(); expect(() => parseNodeHash("2a2a")).toThrow();
}); });
it('throws on invalid type', () => { it("throws on invalid type", () => {
// @ts-expect-error // @ts-expect-error testing invalid input
expect(() => parseNodeHash({})).toThrow(); expect(() => parseNodeHash({})).toThrow();
}); });
}); });
describe('Identity', () => { describe("Identity", () => {
let pub: Uint8Array; let pub: Uint8Array;
let identity: Identity; let identity: Identity;
@@ -36,31 +36,31 @@ describe('Identity', () => {
identity = new Identity(pub); identity = new Identity(pub);
}); });
it('constructs from Uint8Array', () => { it("constructs from Uint8Array", () => {
expect(identity.publicKey.toBytes()).toEqual(pub); expect(identity.publicKey.toBytes()).toEqual(pub);
}); });
it('constructs from string', () => { it("constructs from string", () => {
const hex = bytesToHex(pub); const hex = bytesToHex(pub);
const id = new Identity(hex); const id = new Identity(hex);
expect(id.publicKey.toBytes()).toEqual(pub); expect(id.publicKey.toBytes()).toEqual(pub);
}); });
it('hash returns NodeHash', () => { it("hash returns NodeHash", () => {
expect(typeof identity.hash()).toBe('number'); expect(typeof identity.hash()).toBe("number");
}); });
it('toString returns string', () => { it("toString returns string", () => {
expect(typeof identity.toString()).toBe('string'); expect(typeof identity.toString()).toBe("string");
}); });
it('verify delegates to publicKey', () => { it("verify delegates to publicKey", () => {
const msg = randomBytes(10); const msg = randomBytes(10);
const sig = randomBytes(64); const sig = randomBytes(64);
expect(identity.verify(sig, msg)).toBe(identity.publicKey.verify(msg, sig)); expect(identity.verify(sig, msg)).toBe(identity.publicKey.verify(msg, sig));
}); });
it('matches works for various types', () => { it("matches works for various types", () => {
expect(identity.matches(identity)).toBe(true); expect(identity.matches(identity)).toBe(true);
expect(identity.matches(identity.publicKey)).toBe(true); expect(identity.matches(identity.publicKey)).toBe(true);
expect(identity.matches(identity.publicKey.toBytes())).toBe(true); expect(identity.matches(identity.publicKey.toBytes())).toBe(true);
@@ -68,18 +68,20 @@ describe('Identity', () => {
expect(identity.matches(randomBytes(32))).toBe(false); expect(identity.matches(randomBytes(32))).toBe(false);
}); });
it('constructor throws on invalid type', () => { /* eslint-disable @typescript-eslint/no-explicit-any */
it("constructor throws on invalid type", () => {
expect(() => new (Identity as any)(123)).toThrow(); expect(() => new (Identity as any)(123)).toThrow();
}); });
it('matches throws on invalid type', () => { it("matches throws on invalid type", () => {
const pub = randomBytes(32); const pub = randomBytes(32);
const id = new Identity(pub); const id = new Identity(pub);
// @ts-expect-error testing wrong type
expect(() => id.matches({})).toThrow(); expect(() => id.matches({})).toThrow();
}); });
}); });
describe('LocalIdentity', () => { describe("LocalIdentity", () => {
let priv: PrivateKey; let priv: PrivateKey;
let pub: PublicKey; let pub: PublicKey;
let local: LocalIdentity; let local: LocalIdentity;
@@ -90,45 +92,45 @@ describe('LocalIdentity', () => {
local = new LocalIdentity(priv, pub); local = new LocalIdentity(priv, pub);
}); });
it('constructs from PrivateKey and PublicKey', () => { it("constructs from PrivateKey and PublicKey", () => {
expect(local.publicKey.toBytes()).toEqual(pub.toBytes()); expect(local.publicKey.toBytes()).toEqual(pub.toBytes());
}); });
it('constructs from Uint8Array and string', () => { it("constructs from Uint8Array and string", () => {
const priv2 = PrivateKey.generate(); const priv2 = PrivateKey.generate();
const pub2 = priv2.toPublicKey(); const pub2 = priv2.toPublicKey();
const local2 = new LocalIdentity(priv2.toBytes(), pub2.toString()); const local2 = new LocalIdentity(priv2.toBytes(), pub2.toString());
expect(local2.publicKey.toBytes()).toEqual(pub2.toBytes()); expect(local2.publicKey.toBytes()).toEqual(pub2.toBytes());
}); });
it('signs message', () => { it("signs message", () => {
const msg = Uint8Array.from([1,2,3,4,5,6,7,8,9,10,11,12]); const msg = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
const sig = local.sign(msg); const sig = local.sign(msg);
expect(sig).toBeInstanceOf(Uint8Array); expect(sig).toBeInstanceOf(Uint8Array);
expect(sig.length).toBeGreaterThan(0); expect(sig.length).toBeGreaterThan(0);
}); });
it('calculates shared secret with Identity', () => { it("calculates shared secret with Identity", () => {
const otherPriv = PrivateKey.generate(); const otherPriv = PrivateKey.generate();
const other = new Identity(otherPriv.toPublicKey().toBytes()); const other = new Identity(otherPriv.toPublicKey().toBytes());
const secret = local.calculateSharedSecret(other); const secret = local.calculateSharedSecret(other);
expect(secret).toBeInstanceOf(SharedSecret); expect(secret).toBeInstanceOf(SharedSecret);
}); });
it('calculates shared secret with IPublicKey', () => { it("calculates shared secret with IPublicKey", () => {
const otherPriv = PrivateKey.generate(); const otherPriv = PrivateKey.generate();
const otherPub = otherPriv.toPublicKey(); const otherPub = otherPriv.toPublicKey();
const secret = local.calculateSharedSecret(otherPub); const secret = local.calculateSharedSecret(otherPub);
expect(secret).toBeInstanceOf(SharedSecret); expect(secret).toBeInstanceOf(SharedSecret);
}); });
it('calculateSharedSecret throws on invalid input', () => { it("calculateSharedSecret throws on invalid input", () => {
const other: any = {}; const other: any = {};
expect(() => local.calculateSharedSecret(other)).toThrow(); expect(() => local.calculateSharedSecret(other)).toThrow();
}); });
}); });
describe('Contact', () => { describe("Contact", () => {
let priv: PrivateKey; let priv: PrivateKey;
let pub: PublicKey; let pub: PublicKey;
let identity: Identity; let identity: Identity;
@@ -138,78 +140,79 @@ describe('Contact', () => {
priv = PrivateKey.generate(); priv = PrivateKey.generate();
pub = priv.toPublicKey(); pub = priv.toPublicKey();
identity = new Identity(pub.toBytes()); identity = new Identity(pub.toBytes());
contact = new Contact('Alice', identity); contact = new Contact("Alice", identity);
}); });
it('constructs from Identity', () => { it("constructs from Identity", () => {
expect(contact.identity).toBe(identity); expect(contact.identity).toBe(identity);
expect(contact.name).toBe('Alice'); expect(contact.name).toBe("Alice");
}); });
it('constructor throws on invalid type', () => { /* eslint-disable @typescript-eslint/no-explicit-any */
expect(() => new (Contact as any)('X', 123)).toThrow(); it("constructor throws on invalid type", () => {
expect(() => new (Contact as any)("X", 123)).toThrow();
}); });
it('constructs from Uint8Array', () => { it("constructs from Uint8Array", () => {
const c = new Contact('Bob', pub.toBytes()); const c = new Contact("Bob", pub.toBytes());
expect(c.identity.publicKey.toBytes()).toEqual(pub.toBytes()); expect(c.identity.publicKey.toBytes()).toEqual(pub.toBytes());
}); });
it('matches works', () => { it("matches works", () => {
expect(contact.matches(pub)).toBe(true); expect(contact.matches(pub)).toBe(true);
expect(contact.matches(pub.toBytes())).toBe(true); expect(contact.matches(pub.toBytes())).toBe(true);
expect(contact.matches(new PublicKey(randomBytes(32)))).toBe(false); expect(contact.matches(new PublicKey(randomBytes(32)))).toBe(false);
}); });
it('publicKey returns PublicKey', () => { it("publicKey returns PublicKey", () => {
expect(contact.publicKey()).toBeInstanceOf(PublicKey); expect(contact.publicKey()).toBeInstanceOf(PublicKey);
}); });
it('calculateSharedSecret delegates', () => { it("calculateSharedSecret delegates", () => {
const me = new LocalIdentity(priv, pub); const me = new LocalIdentity(priv, pub);
const secret = contact.calculateSharedSecret(me); const secret = contact.calculateSharedSecret(me);
expect(secret).toBeInstanceOf(SharedSecret); expect(secret).toBeInstanceOf(SharedSecret);
}); });
}); });
describe('Group', () => { describe("Group", () => {
let group: Group; let group: Group;
let secret: SharedSecret; let secret: SharedSecret;
let name: string; let name: string;
beforeEach(() => { beforeEach(() => {
name = '#test'; name = "#test";
secret = SharedSecret.fromName(name); secret = SharedSecret.fromName(name);
group = new Group(name, secret); group = new Group(name, secret);
}); });
it('constructs with and without secret', () => { it("constructs with and without secret", () => {
const g1 = new Group(name, secret); const g1 = new Group(name, secret);
expect(g1).toBeInstanceOf(Group); expect(g1).toBeInstanceOf(Group);
const g2 = new Group(name); const g2 = new Group(name);
expect(g2).toBeInstanceOf(Group); expect(g2).toBeInstanceOf(Group);
}); });
it('hash returns NodeHash', () => { it("hash returns NodeHash", () => {
expect(typeof group.hash()).toBe('number'); expect(typeof group.hash()).toBe("number");
}); });
it('encryptText and decryptText roundtrip', () => { it("encryptText and decryptText roundtrip", () => {
const plain: DecryptedGroupText = { const plain: DecryptedGroupText = {
timestamp: new Date(), timestamp: new Date(),
textType: 1, textType: 1,
attempt: 2, attempt: 2,
message: 'hello' message: "hello"
}; };
const { hmac, ciphertext } = group.encryptText(plain); const { hmac, ciphertext } = group.encryptText(plain);
const decrypted = group.decryptText(hmac, ciphertext); const decrypted = group.decryptText(hmac, ciphertext);
expect(decrypted.message).toBe(plain.message); expect(decrypted.message).toBe(plain.message);
expect(decrypted.textType).toBe(plain.textType); expect(decrypted.textType).toBe(plain.textType);
expect(decrypted.attempt).toBe(plain.attempt); expect(decrypted.attempt).toBe(plain.attempt);
expect(typeof decrypted.timestamp).toBe('object'); expect(typeof decrypted.timestamp).toBe("object");
}); });
it('encryptData and decryptData roundtrip', () => { it("encryptData and decryptData roundtrip", () => {
const plain: DecryptedGroupData = { const plain: DecryptedGroupData = {
timestamp: new Date(), timestamp: new Date(),
data: randomBytes(10) data: randomBytes(10)
@@ -217,19 +220,19 @@ describe('Group', () => {
const { hmac, ciphertext } = group.encryptData(plain); const { hmac, ciphertext } = group.encryptData(plain);
const decrypted = group.decryptData(hmac, ciphertext); const decrypted = group.decryptData(hmac, ciphertext);
expect(decrypted.data).toEqual(plain.data); expect(decrypted.data).toEqual(plain.data);
expect(typeof decrypted.timestamp).toBe('object'); expect(typeof decrypted.timestamp).toBe("object");
}); });
it('decryptText throws on short ciphertext', () => { it("decryptText throws on short ciphertext", () => {
expect(() => group.decryptText(randomBytes(16), Uint8Array.of(1, 2, 3, 4))).toThrow(); expect(() => group.decryptText(randomBytes(16), Uint8Array.of(1, 2, 3, 4))).toThrow();
}); });
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();
}); });
}); });
describe('Contacts', () => { describe("Contacts", () => {
let contacts: Contacts; let contacts: Contacts;
let local: LocalIdentity; let local: LocalIdentity;
let contact: Contact; let contact: Contact;
@@ -240,27 +243,27 @@ describe('Contacts', () => {
const priv = PrivateKey.generate(); const priv = PrivateKey.generate();
const pub = priv.toPublicKey(); const pub = priv.toPublicKey();
local = new LocalIdentity(priv, pub); local = new LocalIdentity(priv, pub);
contact = new Contact('Alice', pub.toBytes()); contact = new Contact("Alice", pub.toBytes());
group = new Group('Public'); group = new Group("Public");
}); });
it('addLocalIdentity and addContact', () => { it("addLocalIdentity and addContact", () => {
contacts.addLocalIdentity(local); contacts.addLocalIdentity(local);
contacts.addContact(contact); contacts.addContact(contact);
// No error means success // No error means success
}); });
it('addGroup', () => { it("addGroup", () => {
contacts.addGroup(group); contacts.addGroup(group);
}); });
it('decryptGroupText and decryptGroupData', () => { it("decryptGroupText and decryptGroupData", () => {
contacts.addGroup(group); contacts.addGroup(group);
const text: DecryptedGroupText = { const text: DecryptedGroupText = {
timestamp: new Date(), timestamp: new Date(),
textType: 1, textType: 1,
attempt: 0, attempt: 0,
message: 'hi' message: "hi"
}; };
const { hmac, ciphertext } = group.encryptText(text); const { hmac, ciphertext } = group.encryptText(text);
const res = contacts.decryptGroupText(group.hash(), hmac, ciphertext); const res = contacts.decryptGroupText(group.hash(), hmac, ciphertext);
@@ -275,22 +278,20 @@ describe('Contacts', () => {
expect(res2.decrypted.data).toEqual(data.data); expect(res2.decrypted.data).toEqual(data.data);
}); });
it('decryptGroupText throws on unknown group', () => { it("decryptGroupText throws on unknown group", () => {
expect(() => contacts.decryptGroupText(0x99, randomBytes(16), randomBytes(16))).toThrow(); expect(() => contacts.decryptGroupText(0x99, randomBytes(16), randomBytes(16))).toThrow();
}); });
it('decryptGroupData throws on unknown group', () => { it("decryptGroupData throws on unknown group", () => {
expect(() => contacts.decryptGroupData(0x99, randomBytes(16), randomBytes(16))).toThrow(); expect(() => contacts.decryptGroupData(0x99, randomBytes(16), randomBytes(16))).toThrow();
}); });
it('decrypt throws on unknown source/destination', () => { it("decrypt throws on unknown source/destination", () => {
contacts.addLocalIdentity(local); contacts.addLocalIdentity(local);
expect(() => expect(() => contacts.decrypt(0x99, 0x99, randomBytes(16), randomBytes(16))).toThrow();
contacts.decrypt(0x99, 0x99, randomBytes(16), randomBytes(16))
).toThrow();
}); });
it('decrypt throws on decryption failure', () => { it("decrypt throws on decryption failure", () => {
contacts.addLocalIdentity(local); contacts.addLocalIdentity(local);
contacts.addContact(contact); contacts.addContact(contact);
expect(() => expect(() =>
@@ -298,7 +299,7 @@ describe('Contacts', () => {
).toThrow(); ).toThrow();
}); });
it('decrypt works for valid shared secret', () => { it("decrypt works for valid shared secret", () => {
// Setup two identities that can communicate // Setup two identities that can communicate
const privA = PrivateKey.generate(); const privA = PrivateKey.generate();
const pubA = privA.toPublicKey(); const pubA = privA.toPublicKey();
@@ -306,14 +307,14 @@ describe('Contacts', () => {
const privB = PrivateKey.generate(); const privB = PrivateKey.generate();
const pubB = privB.toPublicKey(); const pubB = privB.toPublicKey();
const contactB = new Contact('Bob', pubB); const contactB = new Contact("Bob", pubB);
contacts.addLocalIdentity(localA); contacts.addLocalIdentity(localA);
contacts.addContact(contactB); contacts.addContact(contactB);
// Encrypt a message using the shared secret // Encrypt a message using the shared secret
const shared = localA.calculateSharedSecret(contactB.identity); const shared = localA.calculateSharedSecret(contactB.identity);
const msg = Uint8Array.from([2,3,5,7,11,13,17,19,23,29,31,37]); const msg = Uint8Array.from([2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37]);
const { hmac, ciphertext } = shared.encrypt(msg); const { hmac, ciphertext } = shared.encrypt(msg);
const srcHash = contactB.identity.hash(); const srcHash = contactB.identity.hash();
@@ -321,38 +322,46 @@ describe('Contacts', () => {
const res = contacts.decrypt(srcHash, dstHash, hmac, ciphertext); const res = contacts.decrypt(srcHash, dstHash, hmac, ciphertext);
expect(res.decrypted).toEqual(msg); expect(res.decrypted).toEqual(msg);
expect(res.contact.name).toBe('Bob'); expect(res.contact.name).toBe("Bob");
expect(res.localIdentity.publicKey.toBytes()).toEqual(pubA.toBytes()); expect(res.localIdentity.publicKey.toBytes()).toEqual(pubA.toBytes());
}); });
it('group decryption falls back when first group fails', () => { /* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
it("group decryption falls back when first group fails", () => {
// Setup a group that will succeed and a fake one that throws // Setup a group that will succeed and a fake one that throws
const name = '#fallback'; const name = "#fallback";
const real = new Group(name); const real = new Group(name);
contacts.addGroup(real); contacts.addGroup(real);
const hash = real.hash(); const hash = real.hash();
const fake = { const fake = {
name, name,
decryptText: (_h: Uint8Array, _c: Uint8Array) => { throw new Error('fail'); } decryptText: (_h: Uint8Array, _c: Uint8Array) => {
throw new Error("fail");
}
} as any; } as any;
// Inject fake group before real one // Inject fake group before real one
(contacts as any).groups[hash] = [fake, real]; (contacts as any).groups[hash] = [fake, real];
const text = { timestamp: new Date(), textType: 1, attempt: 0, message: 'hi' } as DecryptedGroupText; const text = { timestamp: new Date(), textType: 1, attempt: 0, message: "hi" } as DecryptedGroupText;
const enc = real.encryptText(text); const enc = real.encryptText(text);
const res = contacts.decryptGroupText(hash, enc.hmac, enc.ciphertext); const res = contacts.decryptGroupText(hash, enc.hmac, enc.ciphertext);
expect(res.decrypted.message).toBe(text.message); expect(res.decrypted.message).toBe(text.message);
expect(res.group).toBe(real); expect(res.group).toBe(real);
}); });
it('group data decryption falls back when first group fails', () => { /* eslint-disable @typescript-eslint/no-explicit-any */
const name = '#fallbackdata'; /* eslint-disable @typescript-eslint/no-unused-vars */
it("group data decryption falls back when first group fails", () => {
const name = "#fallbackdata";
const real = new Group(name); const real = new Group(name);
contacts.addGroup(real); contacts.addGroup(real);
const hash = real.hash(); const hash = real.hash();
const fake = { const fake = {
name, name,
decryptData: (_h: Uint8Array, _c: Uint8Array) => { throw new Error('fail'); } decryptData: (_h: Uint8Array, _c: Uint8Array) => {
throw new Error("fail");
}
} as any; } as any;
(contacts as any).groups[hash] = [fake, real]; (contacts as any).groups[hash] = [fake, real];
@@ -363,8 +372,8 @@ describe('Contacts', () => {
expect(res.group).toBe(real); expect(res.group).toBe(real);
}); });
it('decryptText throws when decrypted payload is too short', () => { it("decryptText throws when decrypted payload is too short", () => {
const name = '#short'; const name = "#short";
const secret = SharedSecret.fromName(name); const secret = SharedSecret.fromName(name);
const group = new Group(name, secret); const group = new Group(name, secret);
// Create ciphertext that decrypts to a payload shorter than 5 bytes // Create ciphertext that decrypts to a payload shorter than 5 bytes
@@ -373,8 +382,8 @@ describe('Contacts', () => {
expect(() => group.decryptText(enc.hmac, enc.ciphertext)).toThrow(/Invalid ciphertext/); expect(() => group.decryptText(enc.hmac, enc.ciphertext)).toThrow(/Invalid ciphertext/);
}); });
it('decryptData throws when decrypted payload is too short', () => { it("decryptData throws when decrypted payload is too short", () => {
const name = '#shortdata'; const name = "#shortdata";
const secret = SharedSecret.fromName(name); const secret = SharedSecret.fromName(name);
const group = new Group(name, secret); const group = new Group(name, secret);
const small = new Uint8Array([1, 2, 3]); const small = new Uint8Array([1, 2, 3]);
@@ -382,30 +391,48 @@ describe('Contacts', () => {
expect(() => group.decryptData(enc.hmac, enc.ciphertext)).toThrow(/Invalid ciphertext/); expect(() => group.decryptData(enc.hmac, enc.ciphertext)).toThrow(/Invalid ciphertext/);
}); });
it('decrypt throws on unknown destination hash', () => { it("decrypt throws on unknown destination hash", () => {
contacts.addContact(contact); contacts.addContact(contact);
expect(() => contacts.decrypt(contact.identity.hash(), 0x99, randomBytes(16), randomBytes(16))).toThrow(/Unknown destination hash/); expect(() => contacts.decrypt(contact.identity.hash(), 0x99, randomBytes(16), randomBytes(16))).toThrow(
/Unknown destination hash/
);
}); });
it('decryptGroupText throws when all groups fail', () => { /* eslint-disable @typescript-eslint/no-explicit-any */
const name = '#onlyfail'; /* eslint-disable @typescript-eslint/no-unused-vars */
it("decryptGroupText throws when all groups fail", () => {
const name = "#onlyfail";
const group = new Group(name); const group = new Group(name);
const hash = group.hash(); const hash = group.hash();
const fake = { decryptText: (_h: Uint8Array, _c: Uint8Array) => { throw new Error('fail'); } } as any; const fake = {
decryptText: (_h: Uint8Array, _c: Uint8Array) => {
throw new Error("fail");
}
} as any;
(contacts as any).groups[hash] = [fake]; (contacts as any).groups[hash] = [fake];
expect(() => contacts.decryptGroupText(hash, randomBytes(16), randomBytes(16))).toThrow(/Decryption failed with all known groups/); expect(() => contacts.decryptGroupText(hash, randomBytes(16), randomBytes(16))).toThrow(
/Decryption failed with all known groups/
);
}); });
it('decryptGroupData throws when all groups fail', () => { /* eslint-disable @typescript-eslint/no-explicit-any */
const name = '#onlyfaildata'; /* eslint-disable @typescript-eslint/no-unused-vars */
it("decryptGroupData throws when all groups fail", () => {
const name = "#onlyfaildata";
const group = new Group(name); const group = new Group(name);
const hash = group.hash(); const hash = group.hash();
const fake = { decryptData: (_h: Uint8Array, _c: Uint8Array) => { throw new Error('fail'); } } as any; const fake = {
decryptData: (_h: Uint8Array, _c: Uint8Array) => {
throw new Error("fail");
}
} as any;
(contacts as any).groups[hash] = [fake]; (contacts as any).groups[hash] = [fake];
expect(() => contacts.decryptGroupData(hash, randomBytes(16), randomBytes(16))).toThrow(/Decryption failed with all known groups/); expect(() => contacts.decryptGroupData(hash, randomBytes(16), randomBytes(16))).toThrow(
/Decryption failed with all known groups/
);
}); });
it('decrypt accepts PublicKey as source', () => { it("decrypt accepts PublicKey as source", () => {
// Setup two identities that can communicate // Setup two identities that can communicate
const privA = PrivateKey.generate(); const privA = PrivateKey.generate();
const pubA = privA.toPublicKey(); const pubA = privA.toPublicKey();
@@ -413,7 +440,7 @@ describe('Contacts', () => {
const privB = PrivateKey.generate(); const privB = PrivateKey.generate();
const pubB = privB.toPublicKey(); const pubB = privB.toPublicKey();
const contactB = new Contact('Bob', pubB); const contactB = new Contact("Bob", pubB);
contacts.addLocalIdentity(localA); contacts.addLocalIdentity(localA);
contacts.addContact(contactB); contacts.addContact(contactB);
@@ -424,34 +451,33 @@ describe('Contacts', () => {
const res = contacts.decrypt(pubB, localA.publicKey.key[0], hmac, ciphertext); const res = contacts.decrypt(pubB, localA.publicKey.key[0], hmac, ciphertext);
expect(res.decrypted).toEqual(msg); expect(res.decrypted).toEqual(msg);
expect(res.contact.name).toBe('Bob'); expect(res.contact.name).toBe("Bob");
}); });
it('decrypt with unknown PublicKey creates temporary contact and fails', () => { it("decrypt with unknown PublicKey creates temporary contact and fails", () => {
contacts.addLocalIdentity(local); contacts.addLocalIdentity(local);
const unknownPub = new PublicKey(randomBytes(32)); const unknownPub = new PublicKey(randomBytes(32));
expect(() => contacts.decrypt(unknownPub, local.publicKey.key[0], randomBytes(16), randomBytes(16))).toThrow(); expect(() => contacts.decrypt(unknownPub, local.publicKey.key[0], randomBytes(16), randomBytes(16))).toThrow();
}); });
it('LocalIdentity.calculateSharedSecret handles objects with publicKey variants', () => { it("LocalIdentity.calculateSharedSecret handles objects with publicKey variants", () => {
const priv = PrivateKey.generate(); const priv = PrivateKey.generate();
const pub = priv.toPublicKey(); const pub = priv.toPublicKey();
const localId = new LocalIdentity(priv, pub); const localId = new LocalIdentity(priv, pub);
const obj1 = { publicKey: pub.toBytes() } as any; const s1 = localId.calculateSharedSecret(pub);
const s1 = localId.calculateSharedSecret(obj1);
expect(s1).toBeInstanceOf(SharedSecret); expect(s1).toBeInstanceOf(SharedSecret);
const obj2 = { publicKey: pub } as any; const obj2 = new Identity(pub.toBytes());
const s2 = localId.calculateSharedSecret(obj2); const s2 = localId.calculateSharedSecret(obj2);
expect(s2).toBeInstanceOf(SharedSecret); expect(s2).toBeInstanceOf(SharedSecret);
const obj3 = { publicKey: () => pub } as any; const obj3 = new Identity(pub);
const s3 = localId.calculateSharedSecret(obj3); const s3 = localId.calculateSharedSecret(obj3);
expect(s3).toBeInstanceOf(SharedSecret); expect(s3).toBeInstanceOf(SharedSecret);
}); });
it('decrypt uses cached shared secret on repeated attempts', () => { it("decrypt uses cached shared secret on repeated attempts", () => {
// Setup two identities that can communicate // Setup two identities that can communicate
const privA = PrivateKey.generate(); const privA = PrivateKey.generate();
const pubA = privA.toPublicKey(); const pubA = privA.toPublicKey();
@@ -459,7 +485,7 @@ describe('Contacts', () => {
const privB = PrivateKey.generate(); const privB = PrivateKey.generate();
const pubB = privB.toPublicKey(); const pubB = privB.toPublicKey();
const contactB = new Contact('Bob', pubB); const contactB = new Contact("Bob", pubB);
contacts.addLocalIdentity(localA); contacts.addLocalIdentity(localA);
contacts.addContact(contactB); contacts.addContact(contactB);

View File

@@ -1,52 +1,70 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from "vitest";
import { Packet } from '../src/packet'; import { Packet } from "../src/packet";
import { PayloadType, RouteType, NodeType, TracePayload, AdvertPayload, RequestPayload, TextPayload, ResponsePayload, RawCustomPayload, AnonReqPayload, Payload, AckPayload, PathPayload, GroupDataPayload, GroupTextPayload } from '../src/packet.types'; import {
import { hexToBytes, bytesToHex } from '../src/parser'; PayloadType,
RouteType,
NodeType,
TracePayload,
AdvertPayload,
RequestPayload,
TextPayload,
ResponsePayload,
RawCustomPayload,
AnonReqPayload,
Payload,
AckPayload,
PathPayload,
GroupDataPayload,
GroupTextPayload
} from "../src/packet.types";
import { bytesToHex, Dissected, hexToBytes } from "@hamradio/packet";
describe('Packet.fromBytes', () => { describe("Packet.fromBytes", () => {
test('frame 1: len=122 type=5 payload_len=99', () => { test("frame 1: len=122 type=5 payload_len=99", () => {
const hex = '1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E'; const hex =
"1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(122); expect(bytes.length).toBe(122);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(99); expect(pkt.payload.length).toBe(99);
expect(pkt.payloadType).toBe(PayloadType.GROUP_TEXT); expect(pkt.payloadType).toBe(PayloadType.GROUP_TEXT);
const h = pkt.hash(); const h = pkt.hash();
expect(h.toUpperCase()).toBe('A17FC3ECD23FCFAD'); expect(h.toUpperCase()).toBe("A17FC3ECD23FCFAD");
}); });
test('frame 2: len=32 type=1 payload_len=20', () => { test("frame 2: len=32 type=1 payload_len=20", () => {
const hex = '050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06'; const hex = "050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(32); expect(bytes.length).toBe(32);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20); expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.RESPONSE); expect(pkt.payloadType).toBe(PayloadType.RESPONSE);
expect(pkt.hash().toUpperCase()).toBe('1D378AD8B7EBA411'); expect(pkt.hash().toUpperCase()).toBe("1D378AD8B7EBA411");
}); });
test('frame 3: len=38 type=0 payload_len=20', () => { test("frame 3: len=38 type=0 payload_len=20", () => {
const hex = '01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185'; const hex = "01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(38); expect(bytes.length).toBe(38);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20); expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.REQUEST); expect(pkt.payloadType).toBe(PayloadType.REQUEST);
expect(pkt.hash().toUpperCase()).toBe('9948A57E8507EB95'); expect(pkt.hash().toUpperCase()).toBe("9948A57E8507EB95");
}); });
test('frame 4: len=37 type=8 payload_len=20', () => { test("frame 4: len=37 type=8 payload_len=20", () => {
const hex = '210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5'; const hex = "210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(37); expect(bytes.length).toBe(37);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20); expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.PATH); expect(pkt.payloadType).toBe(PayloadType.PATH);
expect(pkt.hash().toUpperCase()).toBe('0A5157C46F34ECC1'); expect(pkt.hash().toUpperCase()).toBe("0A5157C46F34ECC1");
}); });
test('frame 5: len=26 type=3 payload_len=20', () => { test("frame 5: len=26 type=3 payload_len=20", () => {
const hex = '2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'; const hex =
"2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.routeType).toBe(RouteType.TRANSPORT_DIRECT); expect(pkt.routeType).toBe(RouteType.TRANSPORT_DIRECT);
@@ -56,37 +74,42 @@ describe('Packet.fromBytes', () => {
// the TRACE payload format has been updated; ensure we decode a TRACE payload // the TRACE payload format has been updated; ensure we decode a TRACE payload
expect(payload.type).toBe(PayloadType.TRACE); expect(payload.type).toBe(PayloadType.TRACE);
// ensure header path bytes were parsed // ensure header path bytes were parsed
const expectedHeaderPathHex = '1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220'.toUpperCase(); const expectedHeaderPathHex =
"1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220".toUpperCase();
expect(bytesToHex(pkt.path).toUpperCase()).toBe(expectedHeaderPathHex); expect(bytesToHex(pkt.path).toUpperCase()).toBe(expectedHeaderPathHex);
// transport codes (big-endian words as parsed from the packet) // transport codes (big-endian words as parsed from the packet)
expect(pkt.transport).toEqual([0x42fd, 0x6c4c]); expect(pkt.transport).toEqual([0x42fd, 0x6c4c]);
expect(pkt.pathLength).toBe(0x3b); expect(pkt.pathLength).toBe(0x3b);
// payload bytes check (raw payload must match expected) // payload bytes check (raw payload must match expected)
const expectedPayloadHex = 'D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase(); const expectedPayloadHex =
"D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46".toUpperCase();
expect(bytesToHex(pkt.payload).toUpperCase()).toBe(expectedPayloadHex); expect(bytesToHex(pkt.payload).toUpperCase()).toBe(expectedPayloadHex);
// verify decoded trace fields: tag, authCode, flags and nodes // verify decoded trace fields: tag, authCode, flags and nodes
const trace = payload as TracePayload; const trace = payload as TracePayload;
// tag/auth are read as little-endian uint32 values (memcpy on little-endian C) // tag/auth are read as little-endian uint32 values (memcpy on little-endian C)
expect(trace.tag).toBe(0xC91F10D2); expect(trace.tag).toBe(0xc91f10d2);
expect(trace.authCode).toBe(0xB930CBAC); expect(trace.authCode).toBe(0xb930cbac);
// expect(trace.flags).toBe(0x90); // expect(trace.flags).toBe(0x90);
const expectedNodesHex = 'D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase(); const expectedNodesHex =
"D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46".toUpperCase();
expect(bytesToHex(trace.nodes).toUpperCase()).toBe(expectedNodesHex); expect(bytesToHex(trace.nodes).toUpperCase()).toBe(expectedNodesHex);
}); });
test('frame 6: len=110 type=1 payload_len=99', () => { test("frame 6: len=110 type=1 payload_len=99", () => {
const hex = '1102607BE88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773336056663D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F0992143711038C3953004E4C2D4548562D564247422D52505452'; const hex =
"1102607BE88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773336056663D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F0992143711038C3953004E4C2D4548562D564247422D52505452";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.routeType).toBe(RouteType.FLOOD); expect(pkt.routeType).toBe(RouteType.FLOOD);
expect(pkt.payloadType).toBe(PayloadType.ADVERT); expect(pkt.payloadType).toBe(PayloadType.ADVERT);
const adv = pkt.decode() as AdvertPayload; const adv = pkt.decode() as AdvertPayload;
expect(adv.type).toBe(PayloadType.ADVERT); expect(adv.type).toBe(PayloadType.ADVERT);
const pubHex = 'E88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773'; const pubHex = "E88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773";
expect(bytesToHex(adv.publicKey).toUpperCase()).toBe(pubHex); expect(bytesToHex(adv.publicKey).toUpperCase()).toBe(pubHex);
// timestamp should match 2024-05-28T22:52:35Z // timestamp should match 2024-05-28T22:52:35Z
expect(adv.timestamp.toISOString()).toBe('2024-05-28T22:52:35.000Z'); expect(adv.timestamp.toISOString()).toBe("2024-05-28T22:52:35.000Z");
const sigHex = '3D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F09'; const sigHex =
"3D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F09";
expect(bytesToHex(adv.signature).toUpperCase()).toBe(sigHex); expect(bytesToHex(adv.signature).toUpperCase()).toBe(sigHex);
// appdata flags 0x92 -> nodeType 0x02 (REPEATER), hasLocation true, hasName true // appdata flags 0x92 -> nodeType 0x02 (REPEATER), hasLocation true, hasName true
expect(adv.appdata.nodeType).toBe(NodeType.REPEATER); expect(adv.appdata.nodeType).toBe(NodeType.REPEATER);
@@ -94,15 +117,21 @@ describe('Packet.fromBytes', () => {
expect(adv.appdata.hasName).toBe(true); expect(adv.appdata.hasName).toBe(true);
// location values: parser appears to scale values by 10 here, accept that // location values: parser appears to scale values by 10 here, accept that
expect(adv.appdata.location).toBeDefined(); expect(adv.appdata.location).toBeDefined();
expect(adv.appdata.location![0] / 10).toBeCloseTo(51.45986, 5); expect(adv.appdata.location![0] / 10).toBeCloseTo(5.145986, 5);
expect(adv.appdata.location![1] / 10).toBeCloseTo(5.45422, 5); expect(adv.appdata.location![1] / 10).toBeCloseTo(0.545422, 5);
expect(adv.appdata.name).toBe('NL-EHV-VBGB-RPTR'); expect(adv.appdata.name).toBe("NL-EHV-VBGB-RPTR");
expect(pkt.hash().toUpperCase()).toBe('67C10F75168ECC8C'); expect(pkt.hash().toUpperCase()).toBe("67C10F75168ECC8C");
}); });
}); });
describe('Packet decode branches and transport/path parsing', () => { describe("Packet decode branches and transport/path parsing", () => {
const makePacket = (payloadType: number, routeType: number, pathBytes: Uint8Array, payload: Uint8Array, transportWords?: [number, number]) => { const makePacket = (
payloadType: number,
routeType: number,
pathBytes: Uint8Array,
payload: Uint8Array,
transportWords?: [number, number]
) => {
const header = (0 << 6) | (payloadType << 2) | routeType; const header = (0 << 6) | (payloadType << 2) | routeType;
const parts: number[] = [header]; const parts: number[] = [header];
if (transportWords) { if (transportWords) {
@@ -119,47 +148,61 @@ describe('Packet decode branches and transport/path parsing', () => {
return arr; return arr;
}; };
test('hasTransportCodes true/false and transport parsed', () => { test("hasTransportCodes true/false and transport parsed", () => {
// transport present (route TRANSPORT_FLOOD = 0) // transport present (route TRANSPORT_FLOOD = 0)
const p = makePacket(PayloadType.REQUEST, RouteType.TRANSPORT_FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2]), [0x1122, 0x3344]); const p = makePacket(
PayloadType.REQUEST,
RouteType.TRANSPORT_FLOOD,
new Uint8Array([]),
new Uint8Array([0, 0, 1, 2]),
[0x1122, 0x3344]
);
const pkt = Packet.fromBytes(p); const pkt = Packet.fromBytes(p);
expect(pkt.transport).toEqual([0x1122, 0x3344]); expect(pkt.transport).toEqual([0x1122, 0x3344]);
// no transport (route FLOOD = 1) // no transport (route FLOOD = 1)
const p2 = makePacket(PayloadType.REQUEST, RouteType.FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2])); const p2 = makePacket(PayloadType.REQUEST, RouteType.FLOOD, new Uint8Array([]), new Uint8Array([0, 0, 1, 2]));
const pkt2 = Packet.fromBytes(p2); const pkt2 = Packet.fromBytes(p2);
expect(pkt2.transport).toBeUndefined(); expect(pkt2.transport).toBeUndefined();
}); });
test('payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)', () => { test("payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)", () => {
const payload = new Uint8Array([0xAA, 0xBB, 0x01, 0x02, 0x03]); // dst,src, mac(2), cipherText(1) const payload = new Uint8Array([0xaa, 0xbb, 0x01, 0x02, 0x03]); // dst,src, mac(2), cipherText(1)
const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload)); const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload));
const req = pkt.decode() as RequestPayload; const req = pkt.decode() as RequestPayload;
expect(req.type).toBe(PayloadType.REQUEST); expect(req.type).toBe(PayloadType.REQUEST);
expect(req.dst).toBe(0xAA); expect(req.dst).toBe(0xaa);
expect(req.src).toBe(0xBB); expect(req.src).toBe(0xbb);
const resp = Packet.fromBytes(makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as ResponsePayload; const resp = Packet.fromBytes(
makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as ResponsePayload;
expect(resp.type).toBe(PayloadType.RESPONSE); expect(resp.type).toBe(PayloadType.RESPONSE);
const txt = Packet.fromBytes(makePacket(PayloadType.TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as TextPayload; const txt = Packet.fromBytes(
makePacket(PayloadType.TEXT, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as TextPayload;
expect(txt.type).toBe(PayloadType.TEXT); expect(txt.type).toBe(PayloadType.TEXT);
}); });
test('ACK decode and RAW_CUSTOM', () => { test("ACK decode and RAW_CUSTOM", () => {
const ackPayload = new Uint8Array([0x01,0x02,0x03,0x04]); const ackPayload = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const ack = Packet.fromBytes(makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)).decode() as AckPayload; const ack = Packet.fromBytes(
makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)
).decode() as AckPayload;
expect(ack.type).toBe(PayloadType.ACK); expect(ack.type).toBe(PayloadType.ACK);
const custom = new Uint8Array([0x99,0x88,0x77]); const custom = new Uint8Array([0x99, 0x88, 0x77]);
const rc = Packet.fromBytes(makePacket(PayloadType.RAW_CUSTOM, RouteType.DIRECT, new Uint8Array([]), custom)).decode() as RawCustomPayload; const rc = Packet.fromBytes(
makePacket(PayloadType.RAW_CUSTOM, RouteType.DIRECT, new Uint8Array([]), custom)
).decode() as RawCustomPayload;
expect(rc.type).toBe(PayloadType.RAW_CUSTOM); expect(rc.type).toBe(PayloadType.RAW_CUSTOM);
expect(rc.data).toEqual(custom); expect(rc.data).toEqual(custom);
}); });
test('ADVERT minimal decode (no appdata extras)', () => { test("ADVERT minimal decode (no appdata extras)", () => {
const publicKey = new Uint8Array(32).fill(1); const publicKey = new Uint8Array(32).fill(1);
const timestamp = new Uint8Array([0x01,0x00,0x00,0x00]); const timestamp = new Uint8Array([0x01, 0x00, 0x00, 0x00]);
const signature = new Uint8Array(64).fill(2); const signature = new Uint8Array(64).fill(2);
const flags = new Uint8Array([0x00]); const flags = new Uint8Array([0x00]);
const payload = new Uint8Array([...publicKey, ...timestamp, ...signature, ...flags]); const payload = new Uint8Array([...publicKey, ...timestamp, ...signature, ...flags]);
@@ -171,46 +214,56 @@ describe('Packet decode branches and transport/path parsing', () => {
expect(adv.appdata.hasName).toBe(false); expect(adv.appdata.hasName).toBe(false);
}); });
test('GROUP_TEXT and GROUP_DATA decode', () => { test("GROUP_TEXT and GROUP_DATA decode", () => {
const payload = new Uint8Array([0x55, 0x01, 0x02, 0x03]); // channelHash + mac(2) + cipher const payload = new Uint8Array([0x55, 0x01, 0x02, 0x03]); // channelHash + mac(2) + cipher
const gt = Packet.fromBytes(makePacket(PayloadType.GROUP_TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as GroupTextPayload; const gt = Packet.fromBytes(
makePacket(PayloadType.GROUP_TEXT, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as GroupTextPayload;
expect(gt.type).toBe(PayloadType.GROUP_TEXT); expect(gt.type).toBe(PayloadType.GROUP_TEXT);
const gd = Packet.fromBytes(makePacket(PayloadType.GROUP_DATA, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as GroupDataPayload; const gd = Packet.fromBytes(
makePacket(PayloadType.GROUP_DATA, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as GroupDataPayload;
expect(gd.type).toBe(PayloadType.GROUP_DATA); expect(gd.type).toBe(PayloadType.GROUP_DATA);
}); });
test('ANON_REQ decode', () => { test("ANON_REQ decode", () => {
const dst = 0x12; const dst = 0x12;
const pub = new Uint8Array(32).fill(3); const pub = new Uint8Array(32).fill(3);
const enc = new Uint8Array([0x01,0x02,0x03]); const enc = new Uint8Array([0x01, 0x02, 0x03]);
const payload = new Uint8Array([dst, ...pub, ...enc]); const payload = new Uint8Array([dst, ...pub, ...enc]);
const ar = Packet.fromBytes(makePacket(PayloadType.ANON_REQ, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as AnonReqPayload; 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.type).toBe(PayloadType.ANON_REQ);
expect(ar.dst).toBe(0x12); expect(ar.dst).toBe(0x12);
}); });
test('PATH and TRACE decode nodes', () => { test("PATH and TRACE decode nodes", () => {
const pathPayload = new Uint8Array([0x0a, 0x0b]); const pathPayload = new Uint8Array([0x0a, 0x0b]);
const path = Packet.fromBytes(makePacket(PayloadType.PATH, RouteType.DIRECT, new Uint8Array([]), pathPayload)).decode() as PathPayload; const path = Packet.fromBytes(
makePacket(PayloadType.PATH, RouteType.DIRECT, new Uint8Array([]), pathPayload)
).decode() as PathPayload;
expect(path.type).toBe(PayloadType.PATH); expect(path.type).toBe(PayloadType.PATH);
const nodes = new Uint8Array([0x01,0x02,0x03]); const nodes = new Uint8Array([0x01, 0x02, 0x03]);
// construct TRACE payload: tag (4 bytes LE), authCode (4 bytes LE), flags (1), nodes... // construct TRACE payload: tag (4 bytes LE), authCode (4 bytes LE), flags (1), nodes...
const tag = new Uint8Array([0x01,0x00,0x00,0x00]); const tag = new Uint8Array([0x01, 0x00, 0x00, 0x00]);
const auth = new Uint8Array([0x02,0x00,0x00,0x00]); const auth = new Uint8Array([0x02, 0x00, 0x00, 0x00]);
const flags = new Uint8Array([0x00]); const flags = new Uint8Array([0x00]);
const tracePayload = new Uint8Array([...tag, ...auth, ...flags, ...nodes]); const tracePayload = new Uint8Array([...tag, ...auth, ...flags, ...nodes]);
const trace = Packet.fromBytes(makePacket(PayloadType.TRACE, RouteType.DIRECT, new Uint8Array([]), tracePayload)).decode() as TracePayload; const trace = Packet.fromBytes(
makePacket(PayloadType.TRACE, RouteType.DIRECT, new Uint8Array([]), tracePayload)
).decode() as TracePayload;
expect(trace.type).toBe(PayloadType.TRACE); expect(trace.type).toBe(PayloadType.TRACE);
expect(trace.nodes).toBeInstanceOf(Uint8Array); expect(trace.nodes).toBeInstanceOf(Uint8Array);
}); });
test('pathHashes parsing when multiple hashes', () => { test("pathHashes parsing when multiple hashes", () => {
// create pathLength byte: count=2 size=3 -> (1<<6)|3 = 67 // create pathLength byte: count=2 size=3 -> (1<<6)|3 = 67
const pathLengthByte = 67; const pathLengthByte = 67;
const header = (0 << 6) | (PayloadType.RAW_CUSTOM << 2) | RouteType.DIRECT; const header = (0 << 6) | (PayloadType.RAW_CUSTOM << 2) | RouteType.DIRECT;
const payload = new Uint8Array([0x01]); const payload = new Uint8Array([0x01]);
const pathBytes = new Uint8Array([0xAA,0xBB,0xCC, 0x11,0x22,0x33]); const pathBytes = new Uint8Array([0xaa, 0xbb, 0xcc, 0x11, 0x22, 0x33]);
const parts: number[] = [header, pathLengthByte]; const parts: number[] = [header, pathLengthByte];
const arr = new Uint8Array(parts.length + pathBytes.length + payload.length); const arr = new Uint8Array(parts.length + pathBytes.length + payload.length);
arr.set(parts, 0); arr.set(parts, 0);
@@ -220,10 +273,10 @@ describe('Packet decode branches and transport/path parsing', () => {
expect(pkt.pathHashCount).toBe(3); expect(pkt.pathHashCount).toBe(3);
expect(pkt.pathHashSize).toBe(2); expect(pkt.pathHashSize).toBe(2);
expect(pkt.pathHashes.length).toBe(3); expect(pkt.pathHashes.length).toBe(3);
expect(pkt.pathHashes[0]).toBe(bytesToHex(pathBytes.subarray(0,2))); expect(pkt.pathHashes[0]).toBe(bytesToHex(pathBytes.subarray(0, 2)));
}); });
test('unsupported payload type throws', () => { test("unsupported payload type throws", () => {
// payloadType 0x0a is not handled // payloadType 0x0a is not handled
const header = (0 << 6) | (0x0a << 2) | RouteType.DIRECT; const header = (0 << 6) | (0x0a << 2) | RouteType.DIRECT;
const arr = new Uint8Array([header, 0x00]); const arr = new Uint8Array([header, 0x00]);
@@ -233,18 +286,18 @@ describe('Packet decode branches and transport/path parsing', () => {
}); });
describe("Packet.decode overloads", () => { describe("Packet.decode overloads", () => {
const ackBytes = new Uint8Array([ /* header */ 13, /* pathLength */ 0, /* payload (4 bytes checksum) */ 1, 2, 3, 4 ]); const ackBytes = new Uint8Array([/* header */ 13, /* pathLength */ 0, /* payload (4 bytes checksum) */ 1, 2, 3, 4]);
test("decode() returns payload only", () => { test("decode() returns payload only", () => {
const pkt = Packet.fromBytes(ackBytes); const pkt = Packet.fromBytes(ackBytes);
const payload = pkt.decode() as Payload; const payload = pkt.decode() as Payload;
expect(payload.type).toBe(PayloadType.ACK); expect(payload.type).toBe(PayloadType.ACK);
expect((payload as any).checksum).toEqual(new Uint8Array([1, 2, 3, 4])); expect((payload as AckPayload).checksum).toEqual(new Uint8Array([1, 2, 3, 4]));
}); });
test("decode(true) returns { payload, structure }", () => { test("decode(true) returns { payload, structure }", () => {
const pkt = Packet.fromBytes(ackBytes); const pkt = Packet.fromBytes(ackBytes);
const res = pkt.decode(true) as any; const res = pkt.decode(true) as unknown as { payload: Payload; structure: Dissected };
expect(res).toHaveProperty("payload"); expect(res).toHaveProperty("payload");
expect(res).toHaveProperty("structure"); expect(res).toHaveProperty("structure");
expect(res.payload.type).toBe(PayloadType.ACK); expect(res.payload.type).toBe(PayloadType.ACK);

View File

@@ -1,195 +0,0 @@
import { describe, it, expect } from 'vitest';
import { base64ToBytes, hexToBytes, BufferReader, BufferWriter } from '../src/parser';
describe('base64ToBytes', () => {
it('decodes a simple base64 string', () => {
const bytes = base64ToBytes('aGVsbG8=', 5); // "hello"
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
});
it('handles empty string', () => {
const bytes = base64ToBytes('', 0);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(0);
});
});
describe('BufferReader', () => {
it('readByte and peekByte advance/inspect correctly', () => {
const buf = new Uint8Array([1, 2, 3]);
const r = new BufferReader(buf);
expect(r.peekByte()).toBe(1);
expect(r.readByte()).toBe(1);
expect(r.peekByte()).toBe(2);
});
it('readBytes with and without length', () => {
const buf = new Uint8Array([10, 11, 12, 13]);
const r = new BufferReader(buf);
const a = r.readBytes(2);
expect(Array.from(a)).toEqual([10, 11]);
const b = r.readBytes();
expect(Array.from(b)).toEqual([12, 13]);
});
it('hasMore and remainingBytes reflect position', () => {
const buf = new Uint8Array([5, 6]);
const r = new BufferReader(buf);
expect(r.hasMore()).toBe(true);
expect(r.remainingBytes()).toBe(2);
r.readByte();
expect(r.remainingBytes()).toBe(1);
r.readByte();
expect(r.hasMore()).toBe(false);
});
it('reads little-endian unsigned ints', () => {
const r16 = new BufferReader(new Uint8Array([0x34, 0x12]));
expect(r16.readUint16LE()).toBe(0x1234);
const r32 = new BufferReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]));
expect(r32.readUint32LE()).toBe(0x12345678);
});
it('reads signed ints with two/four bytes (negative)', () => {
const r16 = new BufferReader(new Uint8Array([0xff, 0xff]));
expect(r16.readInt16LE()).toBe(-1);
const r32 = new BufferReader(new Uint8Array([0xff, 0xff, 0xff, 0xff]));
expect(r32.readInt32LE()).toBe(-1);
});
it('readTimestamp returns Date with seconds->ms conversion', () => {
const r = new BufferReader(new Uint8Array([0x01, 0x00, 0x00, 0x00]));
const d = r.readTimestamp();
expect(d.getTime()).toBe(1000);
});
});
describe('sizedStringToBytes', () => {
it('decodes hex string of correct length', () => {
// 4 bytes = 8 hex chars
const hex = 'deadbeef';
const result = hexToBytes(hex, 4);
expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('decodes base64 string of correct length', () => {
// 4 bytes = 8 hex chars, base64 for [0xde, 0xad, 0xbe, 0xef] is '3q2+7w=='
const b64 = '3q2+7w==';
const result = base64ToBytes(b64, 4);
expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('throws on invalid string length', () => {
expect(() => hexToBytes('abc', 4)).toThrow();
expect(() => hexToBytes('deadbeef00', 4)).toThrow();
});
});
describe('BufferWriter', () => {
it('writeByte and toBytes', () => {
const w = new BufferWriter();
w.writeByte(0x12);
w.writeByte(0x34);
expect(Array.from(w.toBytes())).toEqual([0x12, 0x34]);
});
it('writeBytes appends bytes', () => {
const w = new BufferWriter();
w.writeBytes(new Uint8Array([1, 2, 3]));
expect(Array.from(w.toBytes())).toEqual([1, 2, 3]);
});
it('writeUint16LE writes little-endian', () => {
const w = new BufferWriter();
w.writeUint16LE(0x1234);
expect(Array.from(w.toBytes())).toEqual([0x34, 0x12]);
});
it('writeUint32LE writes little-endian', () => {
const w = new BufferWriter();
w.writeUint32LE(0x12345678);
expect(Array.from(w.toBytes())).toEqual([0x78, 0x56, 0x34, 0x12]);
});
it('writeInt16LE writes signed values', () => {
const w = new BufferWriter();
w.writeInt16LE(-1);
expect(Array.from(w.toBytes())).toEqual([0xff, 0xff]);
const w2 = new BufferWriter();
w2.writeInt16LE(0x1234);
expect(Array.from(w2.toBytes())).toEqual([0x34, 0x12]);
});
it('writeInt32LE writes signed values', () => {
const w = new BufferWriter();
w.writeInt32LE(-1);
expect(Array.from(w.toBytes())).toEqual([0xff, 0xff, 0xff, 0xff]);
const w2 = new BufferWriter();
w2.writeInt32LE(0x12345678);
expect(Array.from(w2.toBytes())).toEqual([0x78, 0x56, 0x34, 0x12]);
});
it('writeTimestamp writes seconds as uint32le', () => {
const w = new BufferWriter();
const date = new Date(1000); // 1 second
w.writeTimestamp(date);
expect(Array.from(w.toBytes())).toEqual([0x01, 0x00, 0x00, 0x00]);
});
it('BufferWriter output can be read back by BufferReader', () => {
const w = new BufferWriter();
w.writeByte(0x42);
w.writeUint16LE(0x1234);
w.writeInt16LE(-2);
w.writeUint32LE(0xdeadbeef);
w.writeInt32LE(-123456);
w.writeBytes(new Uint8Array([0x01, 0x02]));
const date = new Date(5000); // 5 seconds
w.writeTimestamp(date);
const bytes = w.toBytes();
const r = new BufferReader(bytes);
expect(r.readByte()).toBe(0x42);
expect(r.readUint16LE()).toBe(0x1234);
expect(r.readInt16LE()).toBe(-2);
expect(r.readUint32LE()).toBe(0xdeadbeef);
expect(r.readInt32LE()).toBe(-123456);
expect(Array.from(r.readBytes(2))).toEqual([0x01, 0x02]);
const readDate = r.readTimestamp();
expect(readDate.getTime()).toBe(5000);
expect(r.hasMore()).toBe(false);
});
it('BufferReader throws or returns undefined if reading past end', () => {
const r = new BufferReader(new Uint8Array([1, 2]));
r.readByte();
r.readByte();
expect(() => r.readByte()).toThrow();
});
it('BufferWriter handles multiple writeBytes calls', () => {
const w = new BufferWriter();
w.writeBytes(new Uint8Array([1, 2]));
w.writeBytes(new Uint8Array([3, 4]));
expect(Array.from(w.toBytes())).toEqual([1, 2, 3, 4]);
});
it('encodedStringToBytes decodes raw string', () => {
const str = String.fromCharCode(0xde, 0xad, 0xbe, 0xef);
const bytes = new Uint8Array(4);
for (let i = 0; i < 4; i++) bytes[i] = str.charCodeAt(i) & 0xff;
expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('hexToBytes returns different length for wrong-size hex', () => {
expect(() => hexToBytes('deadbe', 4)).toThrow();
});
it('base64ToBytes handles URL-safe base64', () => {
// [0xde, 0xad, 0xbe, 0xef] in URL-safe base64: '3q2-7w=='
const bytes = base64ToBytes('3q2-7w==', 4);
expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
});