23 Commits
v1.0.0 ... 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
614b1858cb chore(release): v1.2.2 - bump from v1.2.1 2026-03-11 15:51:40 +01:00
45aae46925 Do not overwrite tags 2026-03-11 15:50:47 +01:00
d47007d905 Release script 2026-03-11 15:48:45 +01:00
6daadc97fc Treat transport codes and path as their own segments 2026-03-11 15:42:00 +01:00
10c7092313 Version 1.2.1 2026-03-11 15:06:48 +01:00
4973e1e52c Fixed bug with bit field order 2026-03-11 15:06:28 +01:00
f5fa45d11c Version 1.2.0 2026-03-11 13:24:09 +01:00
7c2cc0e0f6 Implemented Packet structure decoding 2026-03-11 13:23:52 +01:00
dee5e1cb9e Fixed incorrect path hash parsing 2026-03-10 18:54:53 +01:00
e388a55575 Updated README 2026-03-10 18:44:56 +01:00
c52ec1dc43 Updated package name to include org 2026-03-10 18:39:32 +01:00
9b2d4d1096 Version 1.1.1 2026-03-10 18:22:04 +01:00
fae58c223b Export everything from types 2026-03-10 18:21:32 +01:00
7e5a8c74a5 Move tests to their own folder 2026-03-10 18:13:01 +01:00
df09c952de Move tests to their own folder 2026-03-10 18:12:42 +01:00
7eca26a2b2 Export everything and all types 2026-03-10 18:04:56 +01:00
218042f552 NodeHash is a number 2026-03-10 17:55:37 +01:00
a30448c130 We can not sensibly parse both hex and base64, assume all input is hex 2026-03-10 17:48:51 +01:00
7a2522cf32 Refactoring 2026-03-10 17:35:15 +01:00
24 changed files with 2384 additions and 4308 deletions

3
.gitignore vendored
View File

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

View File

@@ -11,16 +11,22 @@ repos:
hooks:
- id: shellcheck
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v10.0.3
- repo: local
hooks:
- id: prettier
name: prettier
entry: npx prettier --write
language: system
files: "\\.(js|jsx|ts|tsx)$"
- repo: local
hooks:
- id: eslint
name: eslint
entry: npx eslint --fix
language: system
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
hooks:
- 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

@@ -2,18 +2,101 @@
TypeScript library for MeshCore protocol utilities.
Quick start
## Packet parsing
1. Install dev dependencies:
Using the library to decode MeshCore packets:
```bash
npm install --save-dev typescript tsup
```ts
import { Packet } from '@hamradio/meshcore';
const raw = new Uint8Array(Buffer.from("050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06", "hex"));
const packet = Packet.fromBytes(raw);
console.log(packet);
/*
_Packet {
header: 5,
transport: undefined,
pathLength: 10,
path: Uint8Array(10) [
165, 14, 44, 176,
51, 109, 182, 123,
191, 120
],
payload: Uint8Array(20) [
146, 138, 59, 185, 191, 122,
139, 103, 124, 131, 182, 236,
7, 22, 249, 221, 16, 0,
42, 6
],
routeType: 1,
payloadVersion: 0,
payloadType: 1,
pathHashCount: 10,
pathHashSize: 1,
pathHashBytes: 10,
pathHashes: [
'a5', '0e', '2c',
'b0', '33', '6d',
'b6', '7b', 'bf',
'78'
]
}
*/
```
2. Build the library:
## Packet structure parsing
```bash
npm run build
The parser can also be instructed to generate a packet structure, useful for debugging or
printing packet details:
```ts
import { Packet } from '@hamradio/meshcore';
const raw = new Uint8Array(Buffer.from("050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06", "hex"));
const packet = Packet.fromBytes(raw);
const { structure } = packet.decode(true);
console.log(structure);
/*
[
{
name: 'header',
data: Uint8Array(12) [
5, 10, 165, 14, 44,
176, 51, 109, 182, 123,
191, 120
],
fields: [
{ name: 'flags', type: 0, size: 1, bits: [Array] },
{ name: 'path length', type: 1, size: 1, bits: [Array] },
{ name: 'path hashes', type: 6, size: 10 }
]
},
{
name: 'response payload',
data: Uint8Array(20) [
146, 138, 59, 185, 191, 122,
139, 103, 124, 131, 182, 236,
7, 22, 249, 221, 16, 0,
42, 6
],
fields: [
{ name: 'destination hash', type: 1, size: 1, value: 146 },
{ name: 'source hash', type: 1, size: 1, value: 138 },
{ name: 'cipher MAC', type: 6, size: 2, value: [Uint8Array] },
{ name: 'cipher text', type: 6, size: 16, value: [Uint8Array] }
]
}
]
*/
```
3. Use the build output from the `dist/` folder or publish to npm.
## Identities
The package supports:
- `Identity` for public key management.
- `LocalIdentity` for private key management.
- `Contact` for managing named identities.
- `Group` for managing groups.
- `KeyManager` for managing all of the above and handling decryption.

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": "meshcore",
"version": "1.0.0",
"name": "@hamradio/meshcore",
"type": "module",
"version": "1.3.0",
"description": "MeshCore protocol support for Typescript",
"keywords": [
"MeshCore",
@@ -9,7 +10,7 @@
],
"repository": {
"type": "git",
"url": "git+https://git.maze.io/ham/meshcore.js"
"url": "https://git.maze.io/ham/meshcore.ts"
},
"license": "MIT",
"author": "Wijnand Modderman-Lenstra",
@@ -21,9 +22,9 @@
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
"require": "./dist/index.js"
}
},
"scripts": {
@@ -37,6 +38,7 @@
"prepare": "npm run build"
},
"dependencies": {
"@hamradio/packet": "file:../packet.js/hamradio-packet-1.0.4.tgz",
"@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1",
"@noble/ed25519": "^3.0.0",
@@ -47,6 +49,8 @@
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^10.0.3",
"globals": "^17.4.0",
"jiti": "^2.6.1",
"prettier": "3.8.1",
"tsup": "^8.5.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.0",

115
scripts/release.js Executable file
View File

@@ -0,0 +1,115 @@
#!/usr/bin/env node
// Minimal safe release script.
// Usage: node scripts/release.js [major|minor|patch|<version>]
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const root = path.resolve(__dirname, "..");
const pkgPath = path.join(root, "package.json");
function run(cmd, opts = {}) {
return execSync(cmd, { stdio: "inherit", cwd: root, ...opts });
}
function runOutput(cmd) {
return execSync(cmd, { cwd: root }).toString().trim();
}
function bumpSemver(current, spec) {
if (["major","minor","patch"].includes(spec)) {
const [maj, min, patch] = current.split(".").map(n=>parseInt(n,10));
if (spec==="major") return `${maj+1}.0.0`;
if (spec==="minor") return `${maj}.${min+1}.0`;
return `${maj}.${min}.${patch+1}`;
}
if (!/^\d+\.\d+\.\d+$/.test(spec)) throw new Error("Invalid version spec");
return spec;
}
(async () => {
const arg = process.argv[2] || "patch";
const pkgRaw = fs.readFileSync(pkgPath, "utf8");
const pkg = JSON.parse(pkgRaw);
const oldVersion = pkg.version;
const newVersion = bumpSemver(oldVersion, arg);
let committed = false;
let tagged = false;
let pushedTags = false;
try {
// refuse to run if there are unstaged/uncommitted changes
const status = runOutput("git status --porcelain");
if (status) throw new Error("Repository has uncommitted changes; please commit or stash before releasing.");
console.log("Running tests...");
run("npm run test:ci");
console.log("Building...");
run("npm run build");
// write new version
pkg.version = newVersion;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
console.log(`Bumped version: ${oldVersion} -> ${newVersion}`);
// commit
run(`git add ${pkgPath}`);
run(`git commit -m "chore(release): v${newVersion} - bump from v${oldVersion}"`);
committed = true;
// ensure tag doesn't already exist locally
let localTagExists = false;
try {
runOutput(`git rev-parse --verify refs/tags/v${newVersion}`);
localTagExists = true;
} catch (_) {
localTagExists = false;
}
if (localTagExists) throw new Error(`Tag v${newVersion} already exists locally — aborting to avoid overwrite.`);
// ensure tag doesn't exist on remote
const remoteTagInfo = (() => {
try { return runOutput(`git ls-remote --tags origin v${newVersion}`); } catch (_) { return ""; }
})();
if (remoteTagInfo) throw new Error(`Tag v${newVersion} already exists on remote — aborting to avoid overwrite.`);
// tag
run(`git tag -a v${newVersion} -m "Release v${newVersion}"`);
tagged = true;
// push commit and tags
run("git push");
run("git push --tags");
pushedTags = true;
// publish
console.log("Publishing to npm...");
const publishCmd = pkg.name && pkg.name.startsWith("@") ? "npm publish --access public" : "npm publish";
run(publishCmd);
console.log(`Release v${newVersion} succeeded.`);
process.exit(0);
} catch (err) {
console.error("Release failed:", err.message || err);
try {
// delete local tag
if (tagged) {
try { run(`git tag -d v${newVersion}`); } catch {}
if (pushedTags) {
try { run(`git push origin :refs/tags/v${newVersion}`); } catch {}
}
}
// undo commit if made
if (committed) {
try { run("git reset --hard HEAD~1"); } catch {
// fallback: restore package.json content
fs.writeFileSync(pkgPath, pkgRaw, "utf8");
}
} else {
// restore package.json
fs.writeFileSync(pkgPath, pkgRaw, "utf8");
}
} catch (rbErr) {
console.error("Rollback error:", rbErr.message || rbErr);
}
process.exit(1);
}
})();

247
src/crypto.ts Normal file
View File

@@ -0,0 +1,247 @@
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { hmac } from "@noble/hashes/hmac.js";
import { ecb } from "@noble/ciphers/aes.js";
import { bytesToHex, equalBytes, hexToBytes } from "@hamradio/packet";
import { IPublicKey, ISharedSecret, IStaticSecret } from "./crypto.types";
import { NodeHash } from "./identity.types";
const PUBLIC_KEY_SIZE = 32;
const SEED_SIZE = 32;
const HMAC_SIZE = 2;
const SHARED_SECRET_SIZE = 32;
const SIGNATURE_SIZE = 64;
const STATIC_SECRET_SIZE = 32;
// The "Public" group is a special group that all nodes are implicitly part of.
const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72");
export class PublicKey implements IPublicKey {
public key: Uint8Array;
constructor(key: Uint8Array | string) {
if (typeof key === "string") {
this.key = hexToBytes(key);
} else if (key instanceof Uint8Array) {
this.key = key;
} else {
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}`);
}
}
public toHash(): NodeHash {
return sha256.create().update(this.key).digest()[0] as NodeHash;
}
public toBytes(): Uint8Array {
return this.key;
}
public toString(): string {
return bytesToHex(this.key);
}
public equals(other: PublicKey | Uint8Array | string): boolean {
let otherKey: Uint8Array;
if (other instanceof PublicKey) {
otherKey = other.toBytes();
} else if (other instanceof Uint8Array) {
otherKey = other;
} else if (typeof other === "string") {
otherKey = hexToBytes(other);
} else {
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);
}
public verify(message: Uint8Array, signature: Uint8Array): boolean {
if (signature.length !== SIGNATURE_SIZE) {
throw new Error(`Invalid signature length: expected ${SIGNATURE_SIZE} bytes, got ${signature.length}`);
}
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 {
private secretKey: Uint8Array;
private publicKey: PublicKey;
constructor(seed: Uint8Array | string) {
if (typeof seed === "string") {
seed = hexToBytes(seed);
}
if (seed.length !== SEED_SIZE) {
throw new Error(`Invalid seed length: expected ${SEED_SIZE} bytes, got ${seed.length}`);
}
const { secretKey, publicKey } = ed25519.keygen(seed); // Validate seed by generating keys
this.secretKey = secretKey;
this.publicKey = new PublicKey(publicKey);
}
public toPublicKey(): PublicKey {
return this.publicKey;
}
public toBytes(): Uint8Array {
return this.secretKey;
}
public toString(): string {
return bytesToHex(this.secretKey);
}
public sign(message: Uint8Array): Uint8Array {
return ed25519.sign(message, this.secretKey);
}
public calculateSharedSecret(other: PublicKey | Uint8Array | string): Uint8Array {
let otherPublicKey: PublicKey;
if (other instanceof PublicKey) {
otherPublicKey = other;
} else if (other instanceof Uint8Array) {
otherPublicKey = new PublicKey(other);
} else if (typeof other === "string") {
otherPublicKey = new PublicKey(other);
} else {
throw new Error("Invalid type for calculateSharedSecret comparison");
}
return x25519.getSharedSecret(this.secretKey, otherPublicKey.toBytes());
}
static generate(): PrivateKey {
const { secretKey } = ed25519.keygen(); // Ensure ed25519 is initialized
return new PrivateKey(secretKey);
}
}
export class SharedSecret implements ISharedSecret {
private secret: Uint8Array;
constructor(secret: Uint8Array) {
if (secret.length === SHARED_SECRET_SIZE / 2) {
// Zero pad to the left if the secret is too short (e.g. from x25519)
const padded = new Uint8Array(SHARED_SECRET_SIZE);
padded.set(secret, SHARED_SECRET_SIZE - secret.length);
secret = padded;
}
if (secret.length !== SHARED_SECRET_SIZE) {
throw new Error(`Invalid shared secret length: expected ${SHARED_SECRET_SIZE} bytes, got ${secret.length}`);
}
this.secret = secret;
}
public toHash(): NodeHash {
return this.secret[0] as NodeHash;
}
public toBytes(): Uint8Array {
return this.secret;
}
public toString(): string {
return bytesToHex(this.secret);
}
public decrypt(hmac: Uint8Array, ciphertext: Uint8Array): Uint8Array {
if (hmac.length !== HMAC_SIZE) {
throw new Error(`Invalid HMAC length: expected ${HMAC_SIZE} bytes, got ${hmac.length}`);
}
const expectedHmac = this.calculateHmac(ciphertext);
if (!equalBytes(hmac, expectedHmac)) {
throw new Error(`Invalid HMAC: decryption failed: expected ${bytesToHex(expectedHmac)}, got ${bytesToHex(hmac)}`);
}
const cipher = ecb(this.secret.slice(0, 16), { disablePadding: true });
const plaintext = new Uint8Array(ciphertext.length);
for (let i = 0; i < ciphertext.length; i += 16) {
const block = ciphertext.slice(i, i + 16);
const dec = cipher.decrypt(block);
plaintext.set(dec, i);
}
// Remove trailing null bytes (0x00) due to padding
let end = plaintext.length;
while (end > 0 && plaintext[end - 1] === 0) {
end--;
}
return plaintext.slice(0, end);
}
public encrypt(data: Uint8Array): { hmac: Uint8Array; ciphertext: Uint8Array } {
const key = this.secret.slice(0, 16);
const cipher = ecb(key, { disablePadding: true });
const fullBlocks = Math.floor(data.length / 16);
const remaining = data.length % 16;
const ciphertext = new Uint8Array((fullBlocks + (remaining > 0 ? 1 : 0)) * 16);
for (let i = 0; i < fullBlocks; i++) {
const block = data.slice(i * 16, (i + 1) * 16);
const enc = cipher.encrypt(block);
ciphertext.set(enc, i * 16);
}
if (remaining > 0) {
const lastBlock = new Uint8Array(16);
lastBlock.set(data.slice(fullBlocks * 16));
const enc = cipher.encrypt(lastBlock);
ciphertext.set(enc, fullBlocks * 16);
}
const hmac = this.calculateHmac(ciphertext);
return { hmac, ciphertext };
}
private calculateHmac(data: Uint8Array): Uint8Array {
return hmac(sha256, this.secret, data).slice(0, HMAC_SIZE);
}
static fromName(name: string): SharedSecret {
if (name === "Public") {
return new SharedSecret(publicSecret);
} else if (!/^#/.test(name)) {
throw new Error("Only the 'Public' group or groups starting with '#' are supported");
}
const hash = sha256.create().update(new TextEncoder().encode(name)).digest();
return new SharedSecret(hash.slice(0, SHARED_SECRET_SIZE));
}
}
export class StaticSecret implements IStaticSecret {
private secret: Uint8Array;
constructor(secret: Uint8Array | string) {
if (typeof secret === "string") {
secret = hexToBytes(secret);
}
if (secret.length !== STATIC_SECRET_SIZE) {
throw new Error(`Invalid static secret length: expected ${STATIC_SECRET_SIZE} bytes, got ${secret.length}`);
}
this.secret = secret;
}
public publicKey(): IPublicKey {
const publicKey = x25519.getPublicKey(this.secret);
return new PublicKey(publicKey);
}
public diffieHellman(otherPublicKey: IPublicKey): SharedSecret {
const sharedSecret = x25519.getSharedSecret(this.secret, otherPublicKey.toBytes());
return new SharedSecret(sharedSecret);
}
}

27
src/crypto.types.ts Normal file
View File

@@ -0,0 +1,27 @@
import { NodeHash } from "./identity.types";
export interface IPublicKey {
toHash(): NodeHash;
toBytes(): Uint8Array;
toString(): string;
equals(other: IPublicKey | Uint8Array | string): boolean;
verify(message: Uint8Array, signature: Uint8Array): boolean;
}
export interface IPrivateKey extends IPublicKey {
toPublicKey(): IPublicKey;
sign(message: Uint8Array): Uint8Array;
}
export interface ISharedSecret {
toHash(): NodeHash;
toBytes(): Uint8Array;
toString(): string;
decrypt(hmac: Uint8Array, ciphertext: Uint8Array): Uint8Array;
encrypt(data: Uint8Array): { hmac: Uint8Array, ciphertext: Uint8Array };
}
export interface IStaticSecret {
publicKey(): IPublicKey;
diffieHellman(otherPublicKey: IPublicKey): ISharedSecret;
}

View File

@@ -1,331 +1,353 @@
import { ecb } from '@noble/ciphers/aes.js';
import { hmac } from '@noble/hashes/hmac.js';
import { sha256 } from "@noble/hashes/sha2.js";
import { ed25519, x25519 } from '@noble/curves/ed25519.js';
import {
BaseGroup,
BaseGroupSecret,
BaseIdentity,
BaseKeyManager,
BaseLocalIdentity,
Contact,
DecryptedAnonReq,
DecryptedGroupData,
DecryptedGroupText,
DecryptedRequest,
DecryptedResponse,
DecryptedText,
EncryptedPayload,
NodeHash,
Secret
} from "./types";
import { BufferReader, bytesToHex, equalBytes, hexToBytes } from "./parser";
import { PrivateKey, PublicKey, SharedSecret } from "./crypto";
import { IPublicKey } from "./crypto.types";
import { NodeHash, IIdentity, ILocalIdentity } from "./identity.types";
import { DecryptedGroupData, DecryptedGroupText } from "./packet.types";
import { hexToBytes, Reader, Writer } from "@hamradio/packet";
// The "Public" group is a special group that all nodes are implicitly part of. It uses a fixed secret derived from the string "Public".
const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72");
export class Identity extends BaseIdentity {
constructor(publicKey: Uint8Array | string) {
if (typeof publicKey === "string") {
publicKey = hexToBytes(publicKey);
export const parseNodeHash = (hash: NodeHash | string | Uint8Array): NodeHash => {
if (hash instanceof Uint8Array) {
return hash[0] as NodeHash;
}
if (typeof hash === "number") {
if (hash < 0 || hash > 255) {
throw new Error("NodeHash number must be between 0x00 and 0xFF");
}
return hash as NodeHash;
} else if (typeof hash === "string") {
const parsed = hexToBytes(hash);
if (parsed.length !== 1) {
throw new Error("NodeHash string must represent a single byte");
}
return parsed[0] as NodeHash;
}
throw new Error("Invalid NodeHash type");
};
const toPublicKeyBytes = (key: Identity | PublicKey | Uint8Array | string): Uint8Array => {
if (key instanceof Identity) {
return key.publicKey.toBytes();
} else if (key instanceof PublicKey) {
return key.toBytes();
} else if (key instanceof Uint8Array) {
return key;
} else if (typeof key === "string") {
return hexToBytes(key);
} else {
throw new Error("Invalid type for toPublicKeyBytes");
}
};
export class Identity implements IIdentity {
public publicKey: PublicKey;
constructor(publicKey: PublicKey | Uint8Array | string) {
if (publicKey instanceof PublicKey) {
this.publicKey = publicKey;
} else if (publicKey instanceof Uint8Array || typeof publicKey === "string") {
this.publicKey = new PublicKey(publicKey);
} else {
throw new Error("Invalid type for Identity constructor");
}
super(publicKey);
}
public hash(): NodeHash {
return bytesToHex(this.publicKey.slice(0, 1));
hash(): NodeHash {
return this.publicKey.toHash();
}
public verify(message: Uint8Array, signature: Uint8Array): boolean {
return ed25519.verify(message, signature, this.publicKey);
toString(): string {
return this.publicKey.toString();
}
verify(signature: Uint8Array, message: Uint8Array): boolean {
return this.publicKey.verify(message, signature);
}
matches(other: Identity | PublicKey | Uint8Array | string): boolean {
return this.publicKey.equals(toPublicKeyBytes(other));
}
}
export class LocalIdentity extends Identity implements BaseLocalIdentity {
public privateKey: Uint8Array;
export class LocalIdentity extends Identity implements ILocalIdentity {
private privateKey: PrivateKey;
constructor(seed: Uint8Array | string) {
if (typeof seed === "string") {
seed = hexToBytes(seed);
}
const { secretKey, publicKey } = ed25519.keygen(seed);
constructor(privateKey: PrivateKey | Uint8Array | string, publicKey: PublicKey | Uint8Array | string) {
if (publicKey instanceof PublicKey) {
super(publicKey.toBytes());
} else {
super(publicKey);
this.privateKey = secretKey;
}
public sign(message: Uint8Array): Uint8Array {
return ed25519.sign(message, this.privateKey);
if (privateKey instanceof PrivateKey) {
this.privateKey = privateKey;
} else {
this.privateKey = new PrivateKey(privateKey);
}
}
public calculateSharedSecret(other: BaseIdentity | Uint8Array): Uint8Array {
if (other instanceof Uint8Array) {
return x25519.getSharedSecret(this.privateKey, other);
}
return x25519.getSharedSecret(this.privateKey, other.publicKey);
sign(message: Uint8Array): Uint8Array {
return this.privateKey.sign(message);
}
public hash(): NodeHash {
return super.hash();
calculateSharedSecret(other: IIdentity | IPublicKey): SharedSecret {
let otherPublicKey: PublicKey;
if (other instanceof Identity) {
otherPublicKey = other.publicKey;
} else if ("toBytes" in other) {
otherPublicKey = new PublicKey(other.toBytes());
} else if ("publicKey" in other && other.publicKey instanceof Uint8Array) {
otherPublicKey = new PublicKey(other.publicKey);
} else if ("publicKey" in other && other.publicKey instanceof PublicKey) {
otherPublicKey = other.publicKey;
} else if ("publicKey" in other && typeof other.publicKey === "function") {
otherPublicKey = new PublicKey(other.publicKey().toBytes());
} else {
throw new Error("Invalid type for calculateSharedSecret comparison");
}
public verify(message: Uint8Array, signature: Uint8Array): boolean {
return super.verify(message, signature);
return new SharedSecret(this.privateKey.calculateSharedSecret(otherPublicKey));
}
}
export class Group extends BaseGroup {}
export class Contact {
public name: string = "";
public identity: Identity;
export class GroupSecret extends BaseGroupSecret {
public static fromName(name: string): GroupSecret {
if (name === "Public") {
return new GroupSecret(publicSecret);
} else if (!/^#/.test(name)) {
throw new Error("Only the 'Public' group or groups starting with '#' are supported");
constructor(name: string, identity: Identity | PublicKey | Uint8Array | string) {
this.name = name;
if (identity instanceof Identity) {
this.identity = identity;
} else if (identity instanceof PublicKey) {
this.identity = new Identity(identity);
} else if (identity instanceof Uint8Array || typeof identity === "string") {
this.identity = new Identity(identity);
} else {
throw new Error("Invalid type for Contact constructor");
}
}
public matches(hash: Uint8Array | PublicKey): boolean {
return this.identity.publicKey.equals(hash);
}
public publicKey(): PublicKey {
return this.identity.publicKey;
}
public calculateSharedSecret(me: LocalIdentity): SharedSecret {
return me.calculateSharedSecret(this.identity);
}
}
export class Group {
public name: string;
private secret: SharedSecret;
constructor(name: string, secret?: SharedSecret) {
this.name = name;
if (secret) {
this.secret = secret;
} else {
this.secret = SharedSecret.fromName(name);
}
const hash = sha256.create().update(new TextEncoder().encode(name)).digest();
return new GroupSecret(hash.slice(0, 16));
}
public hash(): NodeHash {
return bytesToHex(this.secret.slice(0, 1));
return this.secret.toHash();
}
public decryptText(encrypted: EncryptedPayload): DecryptedGroupText {
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText);
if (data.length < 8) {
public decryptText(hmac: Uint8Array, ciphertext: Uint8Array): DecryptedGroupText {
const data = this.secret.decrypt(hmac, ciphertext);
if (data.length < 5) {
throw new Error("Invalid ciphertext");
}
const reader = new BufferReader(data);
const timestamp = reader.readTimestamp();
const flags = reader.readByte();
const textType = (flags >> 2) & 0x3F;
const reader = new Reader(data);
const timestamp = reader.date32();
const flags = reader.uint8();
const textType = (flags >> 2) & 0x3f;
const attempt = flags & 0x03;
const message = new TextDecoder('utf-8').decode(reader.readBytes());
const message = new TextDecoder("utf-8").decode(reader.bytes());
return {
timestamp,
textType,
attempt,
message
}
}
public decryptData(encrypted: EncryptedPayload): DecryptedGroupData {
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText);
if (data.length < 8) {
throw new Error("Invalid ciphertext");
}
const reader = new BufferReader(data);
return {
timestamp: reader.readTimestamp(),
data: reader.readBytes(reader.remainingBytes())
};
}
private macThenDecrypt(cipherMAC: Uint8Array, cipherText: Uint8Array): Uint8Array {
const mac = hmac(sha256, this.secret, cipherText);
if (!equalBytes(mac, cipherMAC)) {
throw new Error("Invalid MAC");
public encryptText(plain: DecryptedGroupText): { hmac: Uint8Array; ciphertext: Uint8Array } {
const writer = new Writer(4 + 1 + new TextEncoder().encode(plain.message).length);
writer.date32(plain.timestamp);
const flags = ((plain.textType & 0x3f) << 2) | (plain.attempt & 0x03);
writer.uint8(flags);
writer.utf8String(plain.message);
const data = writer.toBytes();
return this.secret.encrypt(data);
}
const block = ecb(this.secret.slice(0, 16), { disablePadding: true });
const plain = block.decrypt(cipherText);
public decryptData(hmac: Uint8Array, ciphertext: Uint8Array): DecryptedGroupData {
const data = this.secret.decrypt(hmac, ciphertext);
if (data.length < 4) {
throw new Error("Invalid ciphertext");
}
return plain;
const reader = new Reader(data);
return {
timestamp: reader.date32(),
data: reader.bytes()
};
}
public encryptData(plain: DecryptedGroupData): { hmac: Uint8Array; ciphertext: Uint8Array } {
const writer = new Writer(4 + plain.data.length);
writer.date32(plain.timestamp);
writer.bytes(plain.data);
const data = writer.toBytes();
return this.secret.encrypt(data);
}
}
export class KeyManager extends BaseKeyManager {
private groups: Map<NodeHash, Group[]> = new Map();
private contacts: Map<NodeHash, Contact[]> = new Map();
private localIdentities: Map<NodeHash, LocalIdentity[]> = new Map();
interface CachedLocalIdentity {
identity: LocalIdentity;
sharedSecrets: Record<string, SharedSecret>;
}
public addGroup(group: Group): void {
const hash = group.secret.hash();
if (!this.groups.has(hash)) {
this.groups.set(hash, [group]);
export class Contacts {
private localIdentities: CachedLocalIdentity[] = [];
private contacts: Record<number, Contact[]> = {};
private groups: Record<number, Group[]> = {};
public addLocalIdentity(identity: LocalIdentity) {
this.localIdentities.push({ identity, sharedSecrets: {} });
}
public addContact(contact: Contact) {
const hash = parseNodeHash(contact.identity.hash()) as number;
if (!this.contacts[hash]) {
this.contacts[hash] = [];
}
this.contacts[hash].push(contact);
}
public decrypt(
src: NodeHash | PublicKey,
dst: NodeHash,
hmac: Uint8Array,
ciphertext: Uint8Array
): {
localIdentity: LocalIdentity;
contact: Contact;
decrypted: Uint8Array;
} {
// Find the public key associated with the source hash.
let contacts: Contact[] = [];
if (src instanceof PublicKey) {
// Check if we have a contact with this exact public key (for direct messages).
const srcHash = parseNodeHash(src.toHash()) as number;
for (const contact of this.contacts[srcHash] || []) {
if (contact.identity.matches(src)) {
contacts.push(contact);
}
}
// If no contact matches the public key, add a temporary contact with the hash and no name.
if (contacts.length === 0) {
contacts.push(new Contact("", new Identity(src.toBytes())));
}
} else {
this.groups.get(hash)!.push(group);
const srcHash = parseNodeHash(src) as number;
contacts = this.contacts[srcHash] || [];
}
if (contacts.length === 0) {
throw new Error("Unknown source hash");
}
public addGroupSecret(name: string, secret?: Secret): void {
if (typeof secret === "undefined") {
secret = GroupSecret.fromName(name).secret;
} else if (typeof secret === "string") {
secret = hexToBytes(secret);
}
this.addGroup(new Group(name, new GroupSecret(secret)));
// Find the local identity associated with the destination hash.
const dstHash = parseNodeHash(dst) as number;
const localIdentities = this.localIdentities.filter((li) => li.identity.publicKey.key[0] === dstHash);
if (localIdentities.length === 0) {
throw new Error("Unknown destination hash");
}
public decryptGroupText(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupText, group: Group } {
const groupSecrets = this.groups.get(channelHash);
if (!groupSecrets) {
throw new Error("No group secrets for channel");
}
for (const group of groupSecrets) {
try {
const decrypted = group.decryptText(encrypted);
return { decrypted, group: group };
} catch {
// Ignore and try next secret
}
}
throw new Error("Failed to decrypt group text with any known secret");
}
public decryptGroupData(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupData, group: Group } {
const groupSecrets = this.groups.get(channelHash);
if (!groupSecrets) {
throw new Error("No group secrets for channel");
}
for (const group of groupSecrets) {
try {
const decrypted = group.decryptData(encrypted);
return { decrypted, group };
} catch {
// Ignore and try next secret
}
}
throw new Error("Failed to decrypt group data with any known secret");
}
public addContact(contact: Contact): void {
const hash = bytesToHex(contact.publicKey.slice(0, 1));
if (!this.contacts.has(hash)) {
this.contacts.set(hash, [contact]);
} else {
this.contacts.get(hash)!.push(contact);
}
}
public addIdentity(name: string, publicKey: Uint8Array | string): void {
if (typeof publicKey === "string") {
publicKey = hexToBytes(publicKey);
}
this.addContact({ name, publicKey });
}
public addLocalIdentity(seed: Secret): void {
const localIdentity = new LocalIdentity(seed);
const hash = localIdentity.hash();
if (!this.localIdentities.has(hash)) {
this.localIdentities.set(hash, [localIdentity]);
} else {
this.localIdentities.get(hash)!.push(localIdentity);
}
}
private tryDecrypt(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: Uint8Array, contact: Contact, identity: BaseIdentity } {
if (!this.localIdentities.has(dst)) {
throw new Error(`No local identities for destination ${dst}`);
}
const localIdentities = this.localIdentities.get(dst)!;
if (!this.contacts.has(src)) {
throw new Error(`No contacts for source ${src}`);
}
const contacts = this.contacts.get(src)!;
// Try to decrypt with each combination of local identity and their public key.
for (const localIdentity of localIdentities) {
for (const contact of contacts) {
const sharedSecret = localIdentity.calculateSharedSecret(contact.publicKey);
const mac = hmac(sha256, sharedSecret, encrypted.cipherText);
if (!equalBytes(mac, encrypted.cipherMAC)) {
continue; // Invalid MAC, try next combination
const sharedSecret: SharedSecret = this.calculateSharedSecret(localIdentity, contact);
try {
const decrypted = sharedSecret.decrypt(hmac, ciphertext);
return { localIdentity: localIdentity.identity, contact, decrypted };
} catch {
// Ignore decryption errors and try the next combination.
}
const block = ecb(sharedSecret.slice(0, 16), { disablePadding: true });
const plain = block.decrypt(encrypted.cipherText);
if (plain.length < 8) {
continue; // Invalid plaintext, try next combination
}
return { decrypted: plain, contact, identity: localIdentity };
}
}
throw new Error("Failed to decrypt with any known identity/contact combination");
}
public decryptRequest(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedRequest, contact: Contact, identity: BaseIdentity } {
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
const reader = new BufferReader(decrypted);
return {
decrypted: {
timestamp: reader.readTimestamp(),
requestType: reader.readByte(),
requestData: reader.readBytes(),
},
contact,
identity
}
}
public decryptResponse(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedResponse, contact: Contact, identity: BaseIdentity } {
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
const reader = new BufferReader(decrypted);
return {
decrypted: {
timestamp: reader.readTimestamp(),
responseData: reader.readBytes(),
},
contact,
identity
}
throw new Error("Decryption failed with all known identities and contacts");
}
public decryptText(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedText, contact: Contact, identity: BaseIdentity } {
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
const reader = new BufferReader(decrypted);
const timestamp = reader.readTimestamp();
const flags = reader.readByte();
const textType = (flags >> 2) & 0x3F;
const attempt = flags & 0x03;
const message = new TextDecoder('utf-8').decode(reader.readBytes());
return {
decrypted: {
timestamp,
textType,
attempt,
message
},
contact,
identity
// Caches the calculated shared secret for a given local identity and contact to avoid redundant calculations.
private calculateSharedSecret(localIdentity: CachedLocalIdentity, contact: Contact): SharedSecret {
const cacheKey = contact.identity.toString();
if (localIdentity.sharedSecrets[cacheKey]) {
return localIdentity.sharedSecrets[cacheKey];
}
const sharedSecret = localIdentity.identity.calculateSharedSecret(contact.identity);
localIdentity.sharedSecrets[cacheKey] = sharedSecret;
return sharedSecret;
}
public decryptAnonReq(dst: NodeHash, publicKey: Uint8Array, encrypted: EncryptedPayload): { decrypted: DecryptedAnonReq, contact: Contact, identity: BaseIdentity } {
if (!this.localIdentities.has(dst)) {
throw new Error(`No local identities for destination ${dst}`);
public addGroup(group: Group) {
const hash = parseNodeHash(group.hash()) as number;
if (!this.groups[hash]) {
this.groups[hash] = [];
}
const localIdentities = this.localIdentities.get(dst)!;
const contact = { publicKey } as Contact; // Create a temporary contact object for MAC verification
for (const localIdentity of localIdentities) {
const sharedSecret = localIdentity.calculateSharedSecret(publicKey);
const mac = hmac(sha256, sharedSecret, encrypted.cipherText);
if (!equalBytes(mac, encrypted.cipherMAC)) {
continue; // Invalid MAC, try next identity
this.groups[hash].push(group);
}
const block = ecb(sharedSecret.slice(0, 16), { disablePadding: true });
const plain = block.decrypt(encrypted.cipherText);
if (plain.length < 8) {
continue; // Invalid plaintext, try next identity
public decryptGroupText(
channelHash: NodeHash,
hmac: Uint8Array,
ciphertext: Uint8Array
): {
decrypted: DecryptedGroupText;
group: Group;
} {
const hash = parseNodeHash(channelHash) as number;
const groups = this.groups[hash] || [];
if (groups.length === 0) {
throw new Error("Unknown group hash");
}
const reader = new BufferReader(plain);
return {
decrypted: {
timestamp: reader.readTimestamp(),
data: reader.readBytes(),
},
contact,
identity: localIdentity
for (const group of groups) {
try {
const decrypted = group.decryptText(hmac, ciphertext);
return { decrypted, group };
} catch {
// Ignore decryption errors and try the next group.
}
}
throw new Error("Failed to decrypt anonymous request with any known identity");
throw new Error("Decryption failed with all known groups");
}
public decryptGroupData(
channelHash: NodeHash,
hmac: Uint8Array,
ciphertext: Uint8Array
): {
decrypted: DecryptedGroupData;
group: Group;
} {
const hash = parseNodeHash(channelHash) as number;
const groups = this.groups[hash] || [];
if (groups.length === 0) {
throw new Error("Unknown group hash");
}
for (const group of groups) {
try {
const decrypted = group.decryptData(hmac, ciphertext);
return { decrypted, group };
} catch {
// Ignore decryption errors and try the next group.
}
}
throw new Error("Decryption failed with all known groups");
}
}

21
src/identity.types.ts Normal file
View File

@@ -0,0 +1,21 @@
import { IPublicKey, ISharedSecret } from "./crypto.types";
export type NodeHash = number; // 1 byte hash represented as hex string
export interface IIdentity {
hash(): NodeHash;
toString(): string;
verify(signature: Uint8Array, message: Uint8Array): boolean;
matches(other: IIdentity | IPublicKey | Uint8Array | string): boolean;
}
export interface ILocalIdentity extends IIdentity {
sign(message: Uint8Array): Uint8Array;
calculateSharedSecret(other: IIdentity | IPublicKey): ISharedSecret;
}
export interface IContact {
name: string;
identity: IIdentity;
calculateSharedSecret(me: ILocalIdentity): ISharedSecret;
}

View File

@@ -1,11 +1,50 @@
import { Packet } from "./packet";
import {
export {
type IPacket,
type Payload,
type EncryptedPayload,
type RequestPayload,
type ResponsePayload,
type TextPayload,
type AckPayload,
type AdvertPayload,
type GroupTextPayload,
type GroupDataPayload,
type AnonReqPayload,
type PathPayload,
type TracePayload,
type RawCustomPayload,
RouteType,
PayloadType,
} from "./types";
RequestType,
TextType,
NodeType,
} from "./packet.types";
export { Packet } from "./packet";
export default {
Packet,
RouteType,
PayloadType,
};
export {
type NodeHash,
type IIdentity,
type ILocalIdentity,
type IContact
} from "./identity.types";
export {
parseNodeHash,
Identity,
LocalIdentity,
Contact,
Group,
Contacts
} from "./identity";
export {
type IPublicKey,
type IPrivateKey,
type ISharedSecret,
type IStaticSecret
} from "./crypto.types";
export {
PublicKey,
PrivateKey,
SharedSecret,
StaticSecret,
} from "./crypto";

View File

@@ -2,6 +2,7 @@ import { sha256 } from "@noble/hashes/sha2.js";
import {
AckPayload,
AdvertAppData,
AdvertFlag,
AdvertPayload,
AnonReqPayload,
EncryptedPayload,
@@ -16,14 +17,9 @@ import {
RouteType,
TextPayload,
TracePayload,
type IPacket,
type NodeHash
} from "./types";
import {
base64ToBytes,
BufferReader,
bytesToHex
} from "./parser";
type IPacket
} from "./packet.types";
import { base64ToBytes, bytesToHex, FieldType, Dissected, Segment, Reader } from "@hamradio/packet";
export class Packet implements IPacket {
// Raw packet bytes.
@@ -39,26 +35,34 @@ export class Packet implements IPacket {
public pathHashCount: number;
public pathHashSize: number;
public pathHashBytes: number;
public pathHashes: NodeHash[];
public pathHashes: string[];
// Parsed packet segments.
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.transport = transport;
this.pathLength = pathLength;
this.path = path;
this.payload = payload;
this.routeType = (header) & 0x03;
this.routeType = header & 0x03;
this.payloadVersion = (header >> 6) & 0x03;
this.payloadType = (header >> 2) & 0x0f;
this.pathHashCount = (pathLength >> 6) + 1;
this.pathHashSize = pathLength & 0x3f;
this.pathHashSize = (pathLength >> 6) + 1;
this.pathHashCount = pathLength & 0x3f;
this.pathHashBytes = this.pathHashCount * this.pathHashSize;
this.pathHashes = [];
for (let i = 0; i < this.pathHashCount; i++) {
const hashBytes = this.path.slice(i * this.pathHashSize, (i + 1) * this.pathHashSize);
for (let i = 0; i < this.pathHashBytes; i += this.pathHashSize) {
const hashBytes = this.path.slice(i, i + this.pathHashSize);
const hashHex = bytesToHex(hashBytes);
this.pathHashes.push(hashHex);
}
@@ -99,215 +103,529 @@ export class Packet implements IPacket {
return bytesToHex(digest.slice(0, 8));
}
public decode(): Payload {
private ensureStructure(): void {
if (typeof this.structure !== "undefined") {
return;
}
let pathHashType: FieldType;
switch (this.pathHashSize) {
case 1:
pathHashType = FieldType.BYTES;
break;
case 2:
pathHashType = FieldType.WORDS;
break;
case 4:
pathHashType = FieldType.DWORDS;
break;
default:
throw new Error(`Unsupported path hash size: ${this.pathHashSize}`);
}
this.structure = [
/* Header segment */
{
name: "header",
data: new Uint8Array([this.header]),
fields: [
/* Header flags */
{
name: "flags",
type: FieldType.BITS,
size: 1,
bits: [
{ name: "payload version", size: 2 },
{ name: "payload type", size: 4 },
{ name: "route type", size: 2 }
]
}
]
},
/* Transport codes */
...(Packet.hasTransportCodes(this.routeType)
? [
{
name: "transport codes",
data: new Uint8Array([
(this.transport![0] >> 8) & 0xff,
this.transport![0] & 0xff,
(this.transport![1] >> 8) & 0xff,
this.transport![1] & 0xff
]),
fields: [
{
name: "transport code 1",
type: FieldType.UINT16_BE,
size: 2,
value: this.transport![0]
},
{
name: "transport code 2",
type: FieldType.UINT16_BE,
size: 2,
value: this.transport![1]
}
]
}
]
: []),
/* Path length and hashes */
{
name: "path",
data: new Uint8Array([this.pathLength, ...this.path]),
fields: [
{
name: "path length",
type: FieldType.UINT8,
size: 1,
bits: [
{ name: "path hash size", size: 2 },
{ name: "path hash count", size: 6 }
]
},
{
name: "path hashes",
type: pathHashType,
size: this.path.length
}
]
}
];
}
public decode(withStructure?: boolean): Payload | { payload: Payload; structure: Dissected } {
let result: Payload | { payload: Payload; segment: Segment };
switch (this.payloadType) {
case PayloadType.REQUEST:
return this.decodeRequest();
result = this.decodeRequest(withStructure);
break;
case PayloadType.RESPONSE:
return this.decodeResponse();
result = this.decodeResponse(withStructure);
break;
case PayloadType.TEXT:
return this.decodeText();
result = this.decodeText(withStructure);
break;
case PayloadType.ACK:
return this.decodeAck();
result = this.decodeAck(withStructure);
break;
case PayloadType.ADVERT:
return this.decodeAdvert();
result = this.decodeAdvert(withStructure);
break;
case PayloadType.GROUP_TEXT:
return this.decodeGroupText();
result = this.decodeGroupText(withStructure);
break;
case PayloadType.GROUP_DATA:
return this.decodeGroupData();
result = this.decodeGroupData(withStructure);
break;
case PayloadType.ANON_REQ:
return this.decodeAnonReq();
result = this.decodeAnonReq(withStructure);
break;
case PayloadType.PATH:
return this.decodePath();
result = this.decodePath(withStructure);
break;
case PayloadType.TRACE:
return this.decodeTrace();
result = this.decodeTrace(withStructure);
break;
case PayloadType.RAW_CUSTOM:
return this.decodeRawCustom();
result = this.decodeRawCustom(withStructure);
break;
default:
throw new Error(`Unsupported payload type: ${this.payloadType}`);
}
if (typeof withStructure === "boolean" && withStructure && "segment" in result && "payload" in result) {
this.ensureStructure();
const structure = [...this.structure!, result.segment];
return { payload: result.payload, structure };
}
private decodeEncryptedPayload(reader: BufferReader): EncryptedPayload {
const cipherMAC = reader.readBytes(2);
const cipherText = reader.readBytes(reader.remainingBytes());
return result as Payload;
}
private decodeEncryptedPayload(reader: Reader): EncryptedPayload {
const cipherMAC = reader.bytes(2);
const cipherText = reader.bytes();
return { cipherMAC, cipherText };
}
private decodeRequest(): RequestPayload {
private decodeRequest(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 4) {
throw new Error("Invalid request payload: too short");
}
const reader = new BufferReader(this.payload);
return {
const reader = Reader.fromBytes(this.payload);
const dst = reader.uint8();
const src = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: RequestPayload = {
type: PayloadType.REQUEST,
dst: bytesToHex(reader.readBytes(1)),
src: bytesToHex(reader.readBytes(1)),
encrypted: this.decodeEncryptedPayload(reader),
dst,
src,
encrypted
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "request payload",
data: this.payload,
fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: src },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText }
]
};
return { payload, segment };
}
return payload;
}
private decodeResponse(): ResponsePayload {
private decodeResponse(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 4) {
throw new Error("Invalid response payload: too short");
}
const reader = new BufferReader(this.payload);
return {
const reader = Reader.fromBytes(this.payload);
const dst = reader.uint8();
const src = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: ResponsePayload = {
type: PayloadType.RESPONSE,
dst: bytesToHex(reader.readBytes(1)),
src: bytesToHex(reader.readBytes(1)),
encrypted: this.decodeEncryptedPayload(reader),
dst,
src,
encrypted
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "response payload",
data: this.payload,
fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: src },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText }
]
};
return { payload, segment };
}
return payload;
}
private decodeText(): TextPayload {
private decodeText(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 4) {
throw new Error("Invalid text payload: too short");
}
const reader = new BufferReader(this.payload);
return {
const reader = Reader.fromBytes(this.payload);
const dst = reader.uint8();
const src = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: TextPayload = {
type: PayloadType.TEXT,
dst: bytesToHex(reader.readBytes(1)),
src: bytesToHex(reader.readBytes(1)),
encrypted: this.decodeEncryptedPayload(reader),
dst,
src,
encrypted
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "text payload",
data: this.payload,
fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: src },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText }
]
};
return { payload, segment };
}
return payload;
}
private decodeAck(): AckPayload {
private decodeAck(withSegment?: boolean): Payload | { payload: AckPayload; segment: Segment } {
if (this.payload.length < 4) {
throw new Error("Invalid ack payload: too short");
}
const reader = new BufferReader(this.payload);
return {
const reader = Reader.fromBytes(this.payload);
const checksum = reader.bytes(4);
const payload: AckPayload = {
type: PayloadType.ACK,
checksum: reader.readBytes(4),
checksum
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "ack payload",
data: this.payload,
fields: [{ name: "checksum", type: FieldType.BYTES, size: 4, value: checksum }]
};
return { payload, segment };
}
return payload;
}
private decodeAdvert(): AdvertPayload {
private decodeAdvert(withSegment?: boolean): Payload | { payload: AdvertPayload; segment: Segment } {
if (this.payload.length < 4) {
throw new Error("Invalid advert payload: too short");
}
const reader = new BufferReader(this.payload);
const reader = Reader.fromBytes(this.payload);
const payload: Partial<AdvertPayload> = {
type: PayloadType.ADVERT,
publicKey: reader.readBytes(32),
timestamp: reader.readTimestamp(),
signature: reader.readBytes(64),
publicKey: reader.bytes(32),
timestamp: reader.date32(),
signature: reader.bytes(64)
};
let segment: Segment | undefined;
if (typeof withSegment === "boolean" && withSegment) {
segment = {
name: "advert payload",
data: this.payload,
fields: [
{ type: FieldType.BYTES, name: "public key", size: 32 },
{ type: FieldType.UINT32_LE, name: "timestamp", size: 4, value: payload.timestamp! },
{ type: FieldType.BYTES, name: "signature", size: 64 }
]
};
}
const flags = reader.readByte();
const flags = reader.uint8();
const appdata: AdvertAppData = {
nodeType: flags & 0x0f,
hasLocation: (flags & 0x10) !== 0,
hasFeature1: (flags & 0x20) !== 0,
hasFeature2: (flags & 0x40) !== 0,
hasName: (flags & 0x80) !== 0,
hasLocation: (flags & AdvertFlag.HAS_LOCATION) !== 0,
hasFeature1: (flags & AdvertFlag.HAS_FEATURE1) !== 0,
hasFeature2: (flags & AdvertFlag.HAS_FEATURE2) !== 0,
hasName: (flags & AdvertFlag.HAS_NAME) !== 0
};
if (typeof withSegment === "boolean" && withSegment) {
segment!.fields.push({
type: FieldType.BITS,
name: "flags",
size: 1,
value: flags,
bits: [
{ size: 1, name: "name flag" },
{ size: 1, name: "feature2 flag" },
{ size: 1, name: "feature1 flag" },
{ size: 1, name: "location flag" },
{ size: 4, name: "node type" }
]
});
}
if (appdata.hasLocation) {
const lat = reader.readInt32LE() / 100000;
const lon = reader.readInt32LE() / 100000;
const lat = reader.int32() / 1000000;
const lon = reader.int32() / 1000000;
appdata.location = [lat, lon];
if (typeof withSegment === "boolean" && withSegment) {
segment!.fields.push({ type: FieldType.UINT32_LE, name: "latitude", size: 4, value: lat });
segment!.fields.push({ type: FieldType.UINT32_LE, name: "longitude", size: 4, value: lon });
}
}
if (appdata.hasFeature1) {
appdata.feature1 = reader.readUint16LE();
appdata.feature1 = reader.uint16();
if (typeof withSegment === "boolean" && withSegment) {
segment!.fields.push({ type: FieldType.UINT16_LE, name: "feature1", size: 2, value: appdata.feature1 });
}
}
if (appdata.hasFeature2) {
appdata.feature2 = reader.readUint16LE();
appdata.feature2 = reader.uint16();
if (typeof withSegment === "boolean" && withSegment) {
segment!.fields.push({ type: FieldType.UINT16_LE, name: "feature2", size: 2, value: appdata.feature2 });
}
}
if (appdata.hasName) {
const nameBytes = reader.readBytes();
let nullPos = nameBytes.indexOf(0);
if (nullPos === -1) {
nullPos = nameBytes.length;
appdata.name = reader.cString();
if (typeof withSegment === "boolean" && withSegment) {
segment!.fields.push({
type: FieldType.C_STRING,
name: "name",
size: appdata.name.length,
value: appdata.name
});
}
appdata.name = new TextDecoder('utf-8').decode(nameBytes.subarray(0, nullPos));
}
return {
...payload,
appdata
} as AdvertPayload;
if (typeof withSegment === "boolean" && withSegment && typeof segment !== "undefined") {
return { payload: { ...payload, appdata } as AdvertPayload, segment };
}
private decodeGroupText(): GroupTextPayload {
return { ...payload, appdata } as AdvertPayload;
}
private decodeGroupText(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 3) {
throw new Error("Invalid group text payload: too short");
}
const reader = new BufferReader(this.payload);
return {
const reader = Reader.fromBytes(this.payload);
const channelHash = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: GroupTextPayload = {
type: PayloadType.GROUP_TEXT,
channelHash: bytesToHex(reader.readBytes(1)),
encrypted: this.decodeEncryptedPayload(reader),
channelHash,
encrypted
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "group text payload",
data: this.payload,
fields: [
{ name: "channel hash", type: FieldType.UINT8, size: 1, value: channelHash },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText }
]
};
return { payload, segment };
}
return payload;
}
private decodeGroupData(): GroupDataPayload {
private decodeGroupData(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 3) {
throw new Error("Invalid group data payload: too short");
}
const reader = new BufferReader(this.payload);
return {
const reader = Reader.fromBytes(this.payload);
const payload: GroupDataPayload = {
type: PayloadType.GROUP_DATA,
channelHash: bytesToHex(reader.readBytes(1)),
encrypted: this.decodeEncryptedPayload(reader),
channelHash: reader.uint8(),
encrypted: this.decodeEncryptedPayload(reader)
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "group data payload",
data: this.payload,
fields: [
{ name: "channel hash", type: FieldType.UINT8, size: 1, value: payload.channelHash },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: payload.encrypted.cipherMAC },
{
name: "cipher text",
type: FieldType.BYTES,
size: payload.encrypted.cipherText.length,
value: payload.encrypted.cipherText
}
]
};
return { payload, segment };
}
return payload;
}
private decodeAnonReq(): AnonReqPayload {
private decodeAnonReq(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 1 + 32 + 2) {
throw new Error("Invalid anon req payload: too short");
}
const reader = new BufferReader(this.payload);
return {
const reader = Reader.fromBytes(this.payload);
const payload: AnonReqPayload = {
type: PayloadType.ANON_REQ,
dst: bytesToHex(reader.readBytes(1)),
publicKey: reader.readBytes(32),
encrypted: this.decodeEncryptedPayload(reader),
dst: reader.uint8(),
publicKey: reader.bytes(32),
encrypted: this.decodeEncryptedPayload(reader)
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "anon req payload",
data: this.payload,
fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: payload.dst },
{ name: "public key", type: FieldType.BYTES, size: 32, value: payload.publicKey },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: payload.encrypted.cipherMAC },
{
name: "cipher text",
type: FieldType.BYTES,
size: payload.encrypted.cipherText.length,
value: payload.encrypted.cipherText
}
]
};
return { payload, segment };
}
return payload;
}
private decodePath(): PathPayload {
private decodePath(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 2) {
throw new Error("Invalid path payload: too short");
}
const reader = new BufferReader(this.payload);
return {
const reader = Reader.fromBytes(this.payload);
const payload: PathPayload = {
type: PayloadType.PATH,
dst: bytesToHex(reader.readBytes(1)),
src: bytesToHex(reader.readBytes(1)),
dst: reader.uint8(),
src: reader.uint8()
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "path payload",
data: this.payload,
fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: payload.dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: payload.src }
]
};
return { payload, segment };
}
return payload;
}
private decodeTrace(): TracePayload {
private decodeTrace(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 9) {
throw new Error("Invalid trace payload: too short");
}
const reader = new BufferReader(this.payload);
return {
const reader = Reader.fromBytes(this.payload);
const payload: TracePayload = {
type: PayloadType.TRACE,
tag: reader.readUint32LE() >>> 0,
authCode: reader.readUint32LE() >>> 0,
flags: reader.readByte() & 0x03,
nodes: reader.readBytes()
tag: reader.uint32(),
authCode: reader.uint32(),
flags: reader.uint8() & 0x03,
nodes: reader.bytes()
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "trace payload",
data: this.payload,
fields: [
{ name: "tag", type: FieldType.DWORDS, size: 4, value: payload.tag },
{ name: "auth code", type: FieldType.DWORDS, size: 4, value: payload.authCode },
{ name: "flags", type: FieldType.UINT8, size: 1, value: payload.flags },
{ name: "nodes", type: FieldType.BYTES, size: payload.nodes.length, value: payload.nodes }
]
};
return { payload, segment };
}
return payload;
}
private decodeRawCustom(): RawCustomPayload {
return {
private decodeRawCustom(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
const payload: RawCustomPayload = {
type: PayloadType.RAW_CUSTOM,
data: this.payload
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "raw custom payload",
data: this.payload,
fields: [{ name: "data", type: FieldType.BYTES, size: this.payload.length, value: this.payload }]
};
return { payload, segment };
}
return payload;
}
}

217
src/packet.types.ts Normal file
View File

@@ -0,0 +1,217 @@
import { Dissected } from "@hamradio/packet";
import { NodeHash } from "./identity.types";
// IPacket contains the raw packet bytes.
export type Uint16 = number; // 0..65535
/* Packet types and structures. */
export interface IPacket {
header: number;
transport?: [Uint16, Uint16];
pathLength: number;
path: Uint8Array;
payload: Uint8Array;
decode(withStructure?: boolean): Payload | { payload: Payload; structure: Dissected };
}
export enum RouteType {
TRANSPORT_FLOOD = 0,
FLOOD = 1,
DIRECT = 2,
TRANSPORT_DIRECT = 3
}
export enum PayloadType {
REQUEST = 0x00,
RESPONSE = 0x01,
TEXT = 0x02,
ACK = 0x03,
ADVERT = 0x04,
GROUP_TEXT = 0x05,
GROUP_DATA = 0x06,
ANON_REQ = 0x07,
PATH = 0x08,
TRACE = 0x09,
RAW_CUSTOM = 0x0f
}
export type Payload = BasePayload &
(
| RequestPayload
| ResponsePayload
| TextPayload
| AckPayload
| AdvertPayload
| GroupTextPayload
| GroupDataPayload
| AnonReqPayload
| PathPayload
| TracePayload
| RawCustomPayload
);
export interface BasePayload {
type: PayloadType;
}
export interface EncryptedPayload {
cipherMAC: Uint8Array;
cipherText: Uint8Array;
}
export interface RequestPayload extends BasePayload {
type: PayloadType.REQUEST;
dst: NodeHash;
src: NodeHash;
encrypted: EncryptedPayload;
decrypted?: DecryptedRequest;
}
export enum RequestType {
GET_STATS = 0x01,
KEEP_ALIVE = 0x02,
GET_TELEMETRY = 0x03,
GET_MIN_MAX_AVG = 0x04,
GET_ACL = 0x05,
GET_NEIGHBORS = 0x06,
GET_OWNER_INFO = 0x07
}
export interface DecryptedRequest {
timestamp: Date;
requestType: RequestType;
requestData: Uint8Array;
}
export interface ResponsePayload extends BasePayload {
type: PayloadType.RESPONSE;
dst: NodeHash;
src: NodeHash;
encrypted: EncryptedPayload;
decrypted?: DecryptedResponse;
}
export interface DecryptedResponse {
timestamp: Date;
responseData: Uint8Array;
}
export interface TextPayload extends BasePayload {
type: PayloadType.TEXT;
dst: NodeHash;
src: NodeHash;
encrypted: EncryptedPayload;
decrypted?: DecryptedText;
}
export enum TextType {
PLAIN_TEXT = 0x00,
CLI_COMMAND = 0x01,
SIGNED_PLAIN_TEXT = 0x02
}
export interface DecryptedText {
timestamp: Date;
textType: TextType;
attempt: number;
message: string;
}
export interface AckPayload extends BasePayload {
type: PayloadType.ACK;
checksum: Uint8Array;
}
export interface AdvertPayload extends BasePayload {
type: PayloadType.ADVERT;
publicKey: Uint8Array;
timestamp: Date;
signature: Uint8Array;
appdata: AdvertAppData;
}
export enum NodeType {
CHAT_NODE = 0x01,
REPEATER = 0x02,
ROOM_SERVER = 0x03,
SENSOR_NODE = 0x04
}
export enum AdvertFlag {
HAS_LOCATION = 0x10,
HAS_FEATURE1 = 0x20,
HAS_FEATURE2 = 0x40,
HAS_NAME = 0x80
}
export interface AdvertAppData {
nodeType: NodeType;
hasLocation: boolean;
location?: [number, number];
hasFeature1: boolean;
feature1?: Uint16;
hasFeature2: boolean;
feature2?: Uint16;
hasName: boolean;
name?: string;
}
export interface GroupTextPayload extends BasePayload {
type: PayloadType.GROUP_TEXT;
channelHash: NodeHash;
encrypted: EncryptedPayload;
decrypted?: DecryptedGroupText;
}
export interface DecryptedGroupText {
timestamp: Date;
textType: TextType;
attempt: number;
message: string;
}
export interface GroupDataPayload extends BasePayload {
type: PayloadType.GROUP_DATA;
channelHash: NodeHash;
encrypted: EncryptedPayload;
decrypted?: DecryptedGroupData;
}
export interface DecryptedGroupData {
timestamp: Date;
data: Uint8Array;
}
export interface AnonReqPayload extends BasePayload {
type: PayloadType.ANON_REQ;
dst: NodeHash;
publicKey: Uint8Array;
encrypted: EncryptedPayload;
decrypted?: DecryptedAnonReq;
}
export interface DecryptedAnonReq {
timestamp: Date;
data: Uint8Array;
}
export interface PathPayload extends BasePayload {
type: PayloadType.PATH;
dst: NodeHash;
src: NodeHash;
}
export interface TracePayload extends BasePayload {
type: PayloadType.TRACE;
tag: number;
authCode: number;
flags: number;
nodes: Uint8Array;
}
export interface RawCustomPayload extends BasePayload {
type: PayloadType.RAW_CUSTOM;
data: Uint8Array;
}

View File

@@ -1,81 +0,0 @@
import { equalBytes } from "@noble/ciphers/utils.js";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
export {
bytesToHex,
hexToBytes,
equalBytes
};
export const base64ToBytes = (base64: string): Uint8Array => {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
export class BufferReader {
private buffer: Uint8Array;
private offset: number;
constructor(buffer: Uint8Array) {
this.buffer = buffer;
this.offset = 0;
}
public readByte(): number {
return this.buffer[this.offset++];
}
public readBytes(length?: number): Uint8Array {
if (length === undefined) {
length = this.buffer.length - this.offset;
}
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 {
return this.buffer[this.offset];
}
public readUint16LE(): number {
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8);
this.offset += 2;
return value;
}
public readUint32LE(): number {
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8) | (this.buffer[this.offset + 2] << 16) | (this.buffer[this.offset + 3] << 24);
this.offset += 4;
return value;
}
public readInt16LE(): number {
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8);
this.offset += 2;
return value < 0x8000 ? value : value - 0x10000;
}
public readInt32LE(): number {
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8) | (this.buffer[this.offset + 2] << 16) | (this.buffer[this.offset + 3] << 24);
this.offset += 4;
return value < 0x80000000 ? value : value - 0x100000000;
}
public readTimestamp(): Date {
const timestamp = this.readUint32LE();
return new Date(timestamp * 1000);
}
}

View File

@@ -1,296 +0,0 @@
import { equalBytes, hexToBytes } from "./parser";
// IPacket contains the raw packet bytes.
export type Uint16 = number; // 0..65535
/* Packet types and structures. */
export interface IPacket {
header: number;
transport?: [Uint16, Uint16];
pathLength: number;
path: Uint8Array;
payload: Uint8Array;
decode(): Payload;
}
export enum RouteType {
TRANSPORT_FLOOD = 0,
FLOOD = 1,
DIRECT = 2,
TRANSPORT_DIRECT = 3
}
export enum PayloadType {
REQUEST = 0x00,
RESPONSE = 0x01,
TEXT = 0x02,
ACK = 0x03,
ADVERT = 0x04,
GROUP_TEXT = 0x05,
GROUP_DATA = 0x06,
ANON_REQ = 0x07,
PATH = 0x08,
TRACE = 0x09,
RAW_CUSTOM = 0x0f,
}
export type Payload =
| RequestPayload
| ResponsePayload
| TextPayload
| AckPayload
| AdvertPayload
| GroupTextPayload
| GroupDataPayload
| AnonReqPayload
| PathPayload
| TracePayload
| RawCustomPayload;
export interface EncryptedPayload {
cipherMAC: Uint8Array;
cipherText: Uint8Array;
}
export interface RequestPayload {
type: PayloadType.REQUEST;
dst: NodeHash;
src: NodeHash;
encrypted: EncryptedPayload;
decrypted?: DecryptedRequest;
}
export enum RequestType {
GET_STATS = 0x01,
KEEP_ALIVE = 0x02,
GET_TELEMETRY = 0x03,
GET_MIN_MAX_AVG = 0x04,
GET_ACL = 0x05,
GET_NEIGHBORS = 0x06,
GET_OWNER_INFO = 0x07,
}
export interface DecryptedRequest {
timestamp: Date;
requestType: RequestType;
requestData: Uint8Array;
}
export interface ResponsePayload {
type: PayloadType.RESPONSE;
dst: NodeHash;
src: NodeHash;
encrypted: EncryptedPayload;
decrypted?: DecryptedResponse;
}
export interface DecryptedResponse {
timestamp: Date;
responseData: Uint8Array;
}
export interface TextPayload {
type: PayloadType.TEXT;
dst: NodeHash;
src: NodeHash;
encrypted: EncryptedPayload;
decrypted?: DecryptedText;
}
export enum TextType {
PLAIN_TEXT = 0x00,
CLI_COMMAND = 0x01,
SIGNED_PLAIN_TEXT = 0x02,
}
export interface DecryptedText {
timestamp: Date;
textType: TextType;
attempt: number;
message: string;
}
export interface AckPayload {
type: PayloadType.ACK;
checksum: Uint8Array;
}
export interface AdvertPayload {
type: PayloadType.ADVERT;
publicKey: Uint8Array;
timestamp: Date;
signature: Uint8Array;
appdata: AdvertAppData;
}
export enum NodeType {
CHAT_NODE = 0x01,
REPEATER = 0x02,
ROOM_SERVER = 0x03,
SENSOR_NODE = 0x04,
}
export interface AdvertAppData {
nodeType: NodeType;
hasLocation: boolean;
location?: [number, number];
hasFeature1: boolean;
feature1?: Uint16;
hasFeature2: boolean;
feature2?: Uint16;
hasName: boolean;
name?: string;
}
export interface GroupTextPayload {
type: PayloadType.GROUP_TEXT;
channelHash: NodeHash;
encrypted: EncryptedPayload;
decrypted?: DecryptedGroupText;
}
export interface DecryptedGroupText {
timestamp: Date;
textType: TextType;
attempt: number;
message: string;
}
export interface GroupDataPayload {
type: PayloadType.GROUP_DATA;
channelHash: NodeHash;
encrypted: EncryptedPayload;
decrypted?: DecryptedGroupData;
}
export interface DecryptedGroupData {
timestamp: Date;
data: Uint8Array;
}
export interface AnonReqPayload {
type: PayloadType.ANON_REQ;
dst: NodeHash;
publicKey: Uint8Array;
encrypted: EncryptedPayload;
decrypted?: DecryptedAnonReq;
}
export interface DecryptedAnonReq {
timestamp: Date;
data: Uint8Array;
}
export interface PathPayload {
type: PayloadType.PATH;
dst: NodeHash;
src: NodeHash;
}
export interface TracePayload {
type: PayloadType.TRACE;
tag: number;
authCode: number;
flags: number;
nodes: Uint8Array;
}
export interface RawCustomPayload {
type: PayloadType.RAW_CUSTOM;
data: Uint8Array;
}
// NodeHash is a hex string of the hash of a node's (partial) public key.
export type NodeHash = string;
/* Contact types and structures */
export interface Group {
name: string;
secret: BaseGroupSecret;
}
export interface Contact {
name: string;
publicKey: Uint8Array;
}
/* Identity and group management. */
export type Secret = Uint8Array | string;
export abstract class BaseIdentity {
publicKey: Uint8Array;
constructor(publicKey: Uint8Array) {
this.publicKey = publicKey;
}
public abstract hash(): NodeHash;
public abstract verify(message: Uint8Array, signature: Uint8Array): boolean;
public matches(other: BaseIdentity | BaseLocalIdentity): boolean {
return equalBytes(this.publicKey, other.publicKey);
}
}
export abstract class BaseLocalIdentity extends BaseIdentity {
privateKey: Uint8Array;
constructor(publicKey: Uint8Array, privateKey: Uint8Array) {
super(publicKey);
this.privateKey = privateKey;
}
public abstract sign(message: Uint8Array): Uint8Array;
public abstract calculateSharedSecret(other: BaseIdentity | Uint8Array): Uint8Array;
}
export abstract class BaseGroup {
name: string;
secret: BaseGroupSecret;
constructor(name: string, secret: BaseGroupSecret) {
this.name = name;
this.secret = secret;
}
decryptText(encrypted: EncryptedPayload): DecryptedGroupText {
return this.secret.decryptText(encrypted);
}
decryptData(encrypted: EncryptedPayload): DecryptedGroupData {
return this.secret.decryptData(encrypted);
}
}
export abstract class BaseGroupSecret {
secret: Uint8Array;
constructor(secret: Secret) {
if (typeof secret === "string") {
secret = hexToBytes(secret);
}
this.secret = secret;
}
public abstract hash(): NodeHash;
public abstract decryptText(encrypted: EncryptedPayload): DecryptedGroupText;
public abstract decryptData(encrypted: EncryptedPayload): DecryptedGroupData;
}
export abstract class BaseKeyManager {
abstract addGroup(group: Group): void;
abstract addGroupSecret(name: string, secret?: Secret): void;
abstract decryptGroupText(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupText, group: Group };
abstract decryptGroupData(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupData, group: Group };
abstract addLocalIdentity(seed: Secret): void;
abstract addContact(contact: Contact): void;
abstract addIdentity(name: string, publicKey: Uint8Array | string): void;
abstract decryptRequest(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedRequest, contact: Contact, identity: BaseIdentity };
abstract decryptResponse(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedResponse, contact: Contact, identity: BaseIdentity };
abstract decryptText(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedText, contact: Contact, identity: BaseIdentity };
abstract decryptAnonReq(dst: NodeHash, publicKey: Uint8Array, encrypted: EncryptedPayload): { decrypted: DecryptedAnonReq, contact: Contact, identity: BaseIdentity };
}

202
test/crypto.test.ts Normal file
View File

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

504
test/identity.test.ts Normal file
View File

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

View File

@@ -1,31 +0,0 @@
import { describe, it, expect } from 'vitest';
import { GroupSecret } from '../src/identity';
import { bytesToHex } from '../src/parser';
describe('GroupSecret.fromName', () => {
it('computes Public secret correctly', () => {
const g = GroupSecret.fromName('Public');
expect(bytesToHex(g.secret)).toBe('8b3387e9c5cdea6ac9e5edbaa115cd72');
});
it('computes #test secret correctly', () => {
const g = GroupSecret.fromName('#test');
expect(bytesToHex(g.secret)).toBe('9cd8fcf22a47333b591d96a2b848b73f');
});
it('throws for invalid names', () => {
expect(() => GroupSecret.fromName('foo')).toThrow();
});
it('accepts single # and returns 16 bytes', () => {
const g = GroupSecret.fromName('#');
expect(g.secret).toBeInstanceOf(Uint8Array);
expect(g.secret.length).toBe(16);
});
it('returns GroupSecret instances consistently', () => {
const a = GroupSecret.fromName('#abc');
const b = GroupSecret.fromName('#abc');
expect(bytesToHex(a.secret)).toBe(bytesToHex(b.secret));
});
});

View File

@@ -1,107 +1,137 @@
import { describe, expect, test } from 'vitest';
import { Packet } from '../src/packet';
import { PayloadType, RouteType, NodeType, TracePayload, AdvertPayload, RequestPayload, TextPayload, ResponsePayload, RawCustomPayload, AnonReqPayload } from '../src/types';
import { hexToBytes, bytesToHex } from '../src/parser';
import { describe, expect, test } from "vitest";
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 { bytesToHex, Dissected, hexToBytes } from "@hamradio/packet";
describe('Packet.fromBytes', () => {
test('frame 1: len=122 type=5 payload_len=99', () => {
const hex = '1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E';
describe("Packet.fromBytes", () => {
test("frame 1: len=122 type=5 payload_len=99", () => {
const hex =
"1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E";
const bytes = hexToBytes(hex);
expect(bytes.length).toBe(122);
const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(99);
expect(pkt.payloadType).toBe(PayloadType.GROUP_TEXT);
const h = pkt.hash();
expect(h.toUpperCase()).toBe('A17FC3ECD23FCFAD');
expect(h.toUpperCase()).toBe("A17FC3ECD23FCFAD");
});
test('frame 2: len=32 type=1 payload_len=20', () => {
const hex = '050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06';
test("frame 2: len=32 type=1 payload_len=20", () => {
const hex = "050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06";
const bytes = hexToBytes(hex);
expect(bytes.length).toBe(32);
const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20);
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', () => {
const hex = '01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185';
test("frame 3: len=38 type=0 payload_len=20", () => {
const hex = "01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185";
const bytes = hexToBytes(hex);
expect(bytes.length).toBe(38);
const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20);
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', () => {
const hex = '210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5';
test("frame 4: len=37 type=8 payload_len=20", () => {
const hex = "210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5";
const bytes = hexToBytes(hex);
expect(bytes.length).toBe(37);
const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20);
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', () => {
const hex = '2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46';
test("frame 5: len=26 type=3 payload_len=20", () => {
const hex =
"2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46";
const bytes = hexToBytes(hex);
const pkt = Packet.fromBytes(bytes);
expect(pkt.routeType).toBe(RouteType.TRANSPORT_DIRECT);
expect(pkt.payloadType).toBe(PayloadType.TRACE);
const payload = pkt.decode();
const payload = pkt.decode() as TracePayload;
expect(payload.type).toBe(PayloadType.TRACE);
// the TRACE payload format has been updated; ensure we decode a TRACE payload
expect(payload.type).toBe(PayloadType.TRACE);
// ensure header path bytes were parsed
const expectedHeaderPathHex = '1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220'.toUpperCase();
const expectedHeaderPathHex =
"1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220".toUpperCase();
expect(bytesToHex(pkt.path).toUpperCase()).toBe(expectedHeaderPathHex);
// transport codes (big-endian words as parsed from the packet)
expect(pkt.transport).toEqual([0x42fd, 0x6c4c]);
expect(pkt.pathLength).toBe(0x3b);
// payload bytes check (raw payload must match expected)
const expectedPayloadHex = 'D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase();
const expectedPayloadHex =
"D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46".toUpperCase();
expect(bytesToHex(pkt.payload).toUpperCase()).toBe(expectedPayloadHex);
// verify decoded trace fields: tag, authCode, flags and nodes
const trace = payload as TracePayload;
// tag/auth are read as little-endian uint32 values (memcpy on little-endian C)
expect(trace.tag).toBe(0xC91F10D2);
expect(trace.authCode).toBe(0xB930CBAC);
expect(trace.tag).toBe(0xc91f10d2);
expect(trace.authCode).toBe(0xb930cbac);
// expect(trace.flags).toBe(0x90);
const expectedNodesHex = 'D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase();
const expectedNodesHex =
"D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46".toUpperCase();
expect(bytesToHex(trace.nodes).toUpperCase()).toBe(expectedNodesHex);
});
test('frame 6: len=110 type=1 payload_len=99', () => {
const hex = '1102607BE88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773336056663D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F0992143711038C3953004E4C2D4548562D564247422D52505452';
test("frame 6: len=110 type=1 payload_len=99", () => {
const hex =
"1102607BE88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773336056663D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F0992143711038C3953004E4C2D4548562D564247422D52505452";
const bytes = hexToBytes(hex);
const pkt = Packet.fromBytes(bytes);
expect(pkt.routeType).toBe(RouteType.FLOOD);
expect(pkt.payloadType).toBe(PayloadType.ADVERT);
const adv = pkt.decode() as AdvertPayload;
expect(adv.type).toBe(PayloadType.ADVERT);
const pubHex = 'E88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773';
const pubHex = "E88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773";
expect(bytesToHex(adv.publicKey).toUpperCase()).toBe(pubHex);
// timestamp should match 2024-05-28T22:52:35Z
expect(adv.timestamp.toISOString()).toBe('2024-05-28T22:52:35.000Z');
const sigHex = '3D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F09';
expect(adv.timestamp.toISOString()).toBe("2024-05-28T22:52:35.000Z");
const sigHex =
"3D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F09";
expect(bytesToHex(adv.signature).toUpperCase()).toBe(sigHex);
// appdata flags 0x92 -> nodeType 0x02 (REPEATER), hasLocation true, hasName true
expect(adv.appdata.nodeType).toBe(NodeType.REPEATER);
expect(adv.appdata.hasLocation).toBe(true);
expect(adv.appdata.hasName).toBe(true);
// location values: parser appears to scale values by 10 here, accept that
expect(adv.appdata.location[0] / 10).toBeCloseTo(51.45986, 5);
expect(adv.appdata.location[1] / 10).toBeCloseTo(5.45422, 5);
expect(adv.appdata.name).toBe('NL-EHV-VBGB-RPTR');
expect(pkt.hash().toUpperCase()).toBe('67C10F75168ECC8C');
expect(adv.appdata.location).toBeDefined();
expect(adv.appdata.location![0] / 10).toBeCloseTo(5.145986, 5);
expect(adv.appdata.location![1] / 10).toBeCloseTo(0.545422, 5);
expect(adv.appdata.name).toBe("NL-EHV-VBGB-RPTR");
expect(pkt.hash().toUpperCase()).toBe("67C10F75168ECC8C");
});
});
describe('Packet decode branches and transport/path parsing', () => {
const makePacket = (payloadType: number, routeType: number, pathBytes: Uint8Array, payload: Uint8Array, transportWords?: [number, number]) => {
describe("Packet decode branches and transport/path parsing", () => {
const makePacket = (
payloadType: number,
routeType: number,
pathBytes: Uint8Array,
payload: Uint8Array,
transportWords?: [number, number]
) => {
const header = (0 << 6) | (payloadType << 2) | routeType;
const parts: number[] = [header];
if (transportWords) {
@@ -118,47 +148,61 @@ describe('Packet decode branches and transport/path parsing', () => {
return arr;
};
test('hasTransportCodes true/false and transport parsed', () => {
test("hasTransportCodes true/false and transport parsed", () => {
// 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);
expect(pkt.transport).toEqual([0x1122, 0x3344]);
// 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);
expect(pkt2.transport).toBeUndefined();
});
test('payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)', () => {
const payload = new Uint8Array([0xAA, 0xBB, 0x01, 0x02, 0x03]); // dst,src, mac(2), cipherText(1)
test("payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)", () => {
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 req = pkt.decode() as RequestPayload;
expect(req.type).toBe(PayloadType.REQUEST);
expect(req.dst).toBe('aa');
expect(req.src).toBe('bb');
expect(req.dst).toBe(0xaa);
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);
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);
});
test('ACK decode and RAW_CUSTOM', () => {
const ackPayload = new Uint8Array([0x01,0x02,0x03,0x04]);
const ack = Packet.fromBytes(makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)).decode();
test("ACK decode and RAW_CUSTOM", () => {
const ackPayload = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const ack = Packet.fromBytes(
makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)
).decode() as AckPayload;
expect(ack.type).toBe(PayloadType.ACK);
const custom = new Uint8Array([0x99,0x88,0x77]);
const rc = Packet.fromBytes(makePacket(PayloadType.RAW_CUSTOM, RouteType.DIRECT, new Uint8Array([]), custom)).decode() as RawCustomPayload;
const custom = new Uint8Array([0x99, 0x88, 0x77]);
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.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 timestamp = new Uint8Array([0x01,0x00,0x00,0x00]);
const timestamp = new Uint8Array([0x01, 0x00, 0x00, 0x00]);
const signature = new Uint8Array(64).fill(2);
const flags = new Uint8Array([0x00]);
const payload = new Uint8Array([...publicKey, ...timestamp, ...signature, ...flags]);
@@ -170,59 +214,69 @@ describe('Packet decode branches and transport/path parsing', () => {
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 gt = Packet.fromBytes(makePacket(PayloadType.GROUP_TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
const gt = Packet.fromBytes(
makePacket(PayloadType.GROUP_TEXT, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as GroupTextPayload;
expect(gt.type).toBe(PayloadType.GROUP_TEXT);
const gd = Packet.fromBytes(makePacket(PayloadType.GROUP_DATA, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
const gd = Packet.fromBytes(
makePacket(PayloadType.GROUP_DATA, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as GroupDataPayload;
expect(gd.type).toBe(PayloadType.GROUP_DATA);
});
test('ANON_REQ decode', () => {
test("ANON_REQ decode", () => {
const dst = 0x12;
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 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.dst).toBe('12');
expect(ar.dst).toBe(0x12);
});
test('PATH and TRACE decode nodes', () => {
test("PATH and TRACE decode nodes", () => {
const pathPayload = new Uint8Array([0x0a, 0x0b]);
const path = Packet.fromBytes(makePacket(PayloadType.PATH, RouteType.DIRECT, new Uint8Array([]), pathPayload)).decode();
const path = Packet.fromBytes(
makePacket(PayloadType.PATH, RouteType.DIRECT, new Uint8Array([]), pathPayload)
).decode() as PathPayload;
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...
const tag = new Uint8Array([0x01,0x00,0x00,0x00]);
const auth = new Uint8Array([0x02,0x00,0x00,0x00]);
const tag = new Uint8Array([0x01, 0x00, 0x00, 0x00]);
const auth = new Uint8Array([0x02, 0x00, 0x00, 0x00]);
const flags = new Uint8Array([0x00]);
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.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
const pathLengthByte = 67;
const header = (0 << 6) | (PayloadType.RAW_CUSTOM << 2) | RouteType.DIRECT;
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 arr = new Uint8Array(parts.length + pathBytes.length + payload.length);
arr.set(parts, 0);
arr.set(pathBytes, parts.length);
arr.set(payload, parts.length + pathBytes.length);
const pkt = Packet.fromBytes(arr);
expect(pkt.pathHashCount).toBe(2);
expect(pkt.pathHashSize).toBe(3);
expect(pkt.pathHashes.length).toBe(2);
expect(pkt.pathHashes[0]).toBe(bytesToHex(pathBytes.subarray(0,3)));
expect(pkt.pathHashCount).toBe(3);
expect(pkt.pathHashSize).toBe(2);
expect(pkt.pathHashes.length).toBe(3);
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
const header = (0 << 6) | (0x0a << 2) | RouteType.DIRECT;
const arr = new Uint8Array([header, 0x00]);
@@ -230,3 +284,24 @@ describe('Packet decode branches and transport/path parsing', () => {
expect(() => pkt.decode()).toThrow();
});
});
describe("Packet.decode overloads", () => {
const ackBytes = new Uint8Array([/* header */ 13, /* pathLength */ 0, /* payload (4 bytes checksum) */ 1, 2, 3, 4]);
test("decode() returns payload only", () => {
const pkt = Packet.fromBytes(ackBytes);
const payload = pkt.decode() as Payload;
expect(payload.type).toBe(PayloadType.ACK);
expect((payload as AckPayload).checksum).toEqual(new Uint8Array([1, 2, 3, 4]));
});
test("decode(true) returns { payload, structure }", () => {
const pkt = Packet.fromBytes(ackBytes);
const res = pkt.decode(true) as unknown as { payload: Payload; structure: Dissected };
expect(res).toHaveProperty("payload");
expect(res).toHaveProperty("structure");
expect(res.payload.type).toBe(PayloadType.ACK);
expect(Array.isArray(res.structure)).toBe(true);
expect(res.structure[res.structure.length - 1].name).toBe("ack payload");
});
});

View File

@@ -1,67 +0,0 @@
import { describe, it, expect } from 'vitest';
import { base64ToBytes, BufferReader } from '../src/parser';
describe('base64ToBytes', () => {
it('decodes a simple base64 string', () => {
const bytes = base64ToBytes('aGVsbG8='); // "hello"
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
});
it('handles empty string', () => {
const bytes = base64ToBytes('');
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);
});
});

View File

@@ -14,6 +14,6 @@
"isolatedModules": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"include": ["src", "test/crypto.test.ts", "test/identity.test.ts", "test/packet.test.ts", "test/parser.test.ts"],
"exclude": ["node_modules", "dist", "test"]
}