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 # Optional npm cache directory
.npm .npm
# Optional package-lock.json file
package-lock.json
# Optional eslint cache # Optional eslint cache
.eslintcache .eslintcache

View File

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

8
.prettierrc.ts Normal file
View File

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

View File

@@ -2,18 +2,101 @@
TypeScript library for MeshCore protocol utilities. TypeScript library for MeshCore protocol utilities.
Quick start ## Packet parsing
1. Install dev dependencies: Using the library to decode MeshCore packets:
```bash ```ts
npm install --save-dev typescript tsup 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 The parser can also be instructed to generate a packet structure, useful for debugging or
npm run build 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", "name": "@hamradio/meshcore",
"version": "1.0.0", "type": "module",
"version": "1.3.0",
"description": "MeshCore protocol support for Typescript", "description": "MeshCore protocol support for Typescript",
"keywords": [ "keywords": [
"MeshCore", "MeshCore",
@@ -9,7 +10,7 @@
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://git.maze.io/ham/meshcore.js" "url": "https://git.maze.io/ham/meshcore.ts"
}, },
"license": "MIT", "license": "MIT",
"author": "Wijnand Modderman-Lenstra", "author": "Wijnand Modderman-Lenstra",
@@ -21,9 +22,9 @@
], ],
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs", "import": "./dist/index.mjs",
"require": "./dist/index.js", "require": "./dist/index.js"
"types": "./dist/index.d.ts"
} }
}, },
"scripts": { "scripts": {
@@ -37,6 +38,7 @@
"prepare": "npm run build" "prepare": "npm run build"
}, },
"dependencies": { "dependencies": {
"@hamradio/packet": "file:../packet.js/hamradio-packet-1.0.4.tgz",
"@noble/ciphers": "^2.1.1", "@noble/ciphers": "^2.1.1",
"@noble/curves": "^2.0.1", "@noble/curves": "^2.0.1",
"@noble/ed25519": "^3.0.0", "@noble/ed25519": "^3.0.0",
@@ -47,6 +49,8 @@
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",
"eslint": "^10.0.3", "eslint": "^10.0.3",
"globals": "^17.4.0", "globals": "^17.4.0",
"jiti": "^2.6.1",
"prettier": "3.8.1",
"tsup": "^8.5.1", "tsup": "^8.5.1",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.57.0", "typescript-eslint": "^8.57.0",

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 { PrivateKey, PublicKey, SharedSecret } from "./crypto";
import { hmac } from '@noble/hashes/hmac.js'; import { IPublicKey } from "./crypto.types";
import { sha256 } from "@noble/hashes/sha2.js"; import { NodeHash, IIdentity, ILocalIdentity } from "./identity.types";
import { ed25519, x25519 } from '@noble/curves/ed25519.js'; import { DecryptedGroupData, DecryptedGroupText } from "./packet.types";
import { import { hexToBytes, Reader, Writer } from "@hamradio/packet";
BaseGroup,
BaseGroupSecret,
BaseIdentity,
BaseKeyManager,
BaseLocalIdentity,
Contact,
DecryptedAnonReq,
DecryptedGroupData,
DecryptedGroupText,
DecryptedRequest,
DecryptedResponse,
DecryptedText,
EncryptedPayload,
NodeHash,
Secret
} from "./types";
import { BufferReader, bytesToHex, equalBytes, hexToBytes } from "./parser";
// The "Public" group is a special group that all nodes are implicitly part of. It uses a fixed secret derived from the string "Public". export const parseNodeHash = (hash: NodeHash | string | Uint8Array): NodeHash => {
const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72"); if (hash instanceof Uint8Array) {
return hash[0] as NodeHash;
export class Identity extends BaseIdentity { }
constructor(publicKey: Uint8Array | string) { if (typeof hash === "number") {
if (typeof publicKey === "string") { if (hash < 0 || hash > 255) {
publicKey = hexToBytes(publicKey); 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 { hash(): NodeHash {
return bytesToHex(this.publicKey.slice(0, 1)); return this.publicKey.toHash();
} }
public verify(message: Uint8Array, signature: Uint8Array): boolean { toString(): string {
return ed25519.verify(message, signature, this.publicKey); 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 { export class LocalIdentity extends Identity implements ILocalIdentity {
public privateKey: Uint8Array; private privateKey: PrivateKey;
constructor(seed: Uint8Array | string) { constructor(privateKey: PrivateKey | Uint8Array | string, publicKey: PublicKey | Uint8Array | string) {
if (typeof seed === "string") { if (publicKey instanceof PublicKey) {
seed = hexToBytes(seed); super(publicKey.toBytes());
} } else {
const { secretKey, publicKey } = ed25519.keygen(seed);
super(publicKey); super(publicKey);
this.privateKey = secretKey;
} }
public sign(message: Uint8Array): Uint8Array { if (privateKey instanceof PrivateKey) {
return ed25519.sign(message, this.privateKey); this.privateKey = privateKey;
} else {
this.privateKey = new PrivateKey(privateKey);
}
} }
public calculateSharedSecret(other: BaseIdentity | Uint8Array): Uint8Array { sign(message: Uint8Array): Uint8Array {
if (other instanceof Uint8Array) { return this.privateKey.sign(message);
return x25519.getSharedSecret(this.privateKey, other);
}
return x25519.getSharedSecret(this.privateKey, other.publicKey);
} }
public hash(): NodeHash { calculateSharedSecret(other: IIdentity | IPublicKey): SharedSecret {
return super.hash(); 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");
} }
return new SharedSecret(this.privateKey.calculateSharedSecret(otherPublicKey));
public verify(message: Uint8Array, signature: Uint8Array): boolean {
return super.verify(message, signature);
} }
} }
export class Group extends BaseGroup {} export class Contact {
public name: string = "";
public identity: Identity;
export class GroupSecret extends BaseGroupSecret { constructor(name: string, identity: Identity | PublicKey | Uint8Array | string) {
public static fromName(name: string): GroupSecret { this.name = name;
if (name === "Public") { if (identity instanceof Identity) {
return new GroupSecret(publicSecret); this.identity = identity;
} else if (!/^#/.test(name)) { } else if (identity instanceof PublicKey) {
throw new Error("Only the 'Public' group or groups starting with '#' are supported"); 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 { public hash(): NodeHash {
return bytesToHex(this.secret.slice(0, 1)); return this.secret.toHash();
} }
public decryptText(encrypted: EncryptedPayload): DecryptedGroupText { public decryptText(hmac: Uint8Array, ciphertext: Uint8Array): DecryptedGroupText {
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText); const data = this.secret.decrypt(hmac, ciphertext);
if (data.length < 8) { if (data.length < 5) {
throw new Error("Invalid ciphertext"); throw new Error("Invalid ciphertext");
} }
const reader = new BufferReader(data); const reader = new Reader(data);
const timestamp = reader.readTimestamp(); const timestamp = reader.date32();
const flags = reader.readByte(); const flags = reader.uint8();
const textType = (flags >> 2) & 0x3F; const textType = (flags >> 2) & 0x3f;
const attempt = flags & 0x03; const attempt = flags & 0x03;
const message = new TextDecoder('utf-8').decode(reader.readBytes()); const message = new TextDecoder("utf-8").decode(reader.bytes());
return { return {
timestamp, timestamp,
textType, textType,
attempt, attempt,
message message
}
}
public 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 { public encryptText(plain: DecryptedGroupText): { hmac: Uint8Array; ciphertext: Uint8Array } {
const mac = hmac(sha256, this.secret, cipherText); const writer = new Writer(4 + 1 + new TextEncoder().encode(plain.message).length);
if (!equalBytes(mac, cipherMAC)) { writer.date32(plain.timestamp);
throw new Error("Invalid MAC"); 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 }); public decryptData(hmac: Uint8Array, ciphertext: Uint8Array): DecryptedGroupData {
const plain = block.decrypt(cipherText); 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 { interface CachedLocalIdentity {
private groups: Map<NodeHash, Group[]> = new Map(); identity: LocalIdentity;
private contacts: Map<NodeHash, Contact[]> = new Map(); sharedSecrets: Record<string, SharedSecret>;
private localIdentities: Map<NodeHash, LocalIdentity[]> = new Map(); }
public addGroup(group: Group): void { export class Contacts {
const hash = group.secret.hash(); private localIdentities: CachedLocalIdentity[] = [];
if (!this.groups.has(hash)) { private contacts: Record<number, Contact[]> = {};
this.groups.set(hash, [group]); 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 { } 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 { // Find the local identity associated with the destination hash.
if (typeof secret === "undefined") { const dstHash = parseNodeHash(dst) as number;
secret = GroupSecret.fromName(name).secret; const localIdentities = this.localIdentities.filter((li) => li.identity.publicKey.key[0] === dstHash);
} else if (typeof secret === "string") { if (localIdentities.length === 0) {
secret = hexToBytes(secret); throw new Error("Unknown destination hash");
}
this.addGroup(new Group(name, new GroupSecret(secret)));
} }
public decryptGroupText(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupText, group: Group } { // Try to decrypt with each combination of local identity and their public key.
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)!;
for (const localIdentity of localIdentities) { for (const localIdentity of localIdentities) {
for (const contact of contacts) { for (const contact of contacts) {
const sharedSecret = localIdentity.calculateSharedSecret(contact.publicKey); const sharedSecret: SharedSecret = this.calculateSharedSecret(localIdentity, contact);
const mac = hmac(sha256, sharedSecret, encrypted.cipherText); try {
if (!equalBytes(mac, encrypted.cipherMAC)) { const decrypted = sharedSecret.decrypt(hmac, ciphertext);
continue; // Invalid MAC, try next combination 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 } { throw new Error("Decryption failed with all known identities and contacts");
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
const reader = new BufferReader(decrypted);
return {
decrypted: {
timestamp: reader.readTimestamp(),
responseData: reader.readBytes(),
},
contact,
identity
}
} }
public decryptText(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedText, contact: Contact, identity: BaseIdentity } { // Caches the calculated shared secret for a given local identity and contact to avoid redundant calculations.
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted); private calculateSharedSecret(localIdentity: CachedLocalIdentity, contact: Contact): SharedSecret {
const reader = new BufferReader(decrypted); const cacheKey = contact.identity.toString();
const timestamp = reader.readTimestamp(); if (localIdentity.sharedSecrets[cacheKey]) {
const flags = reader.readByte(); return localIdentity.sharedSecrets[cacheKey];
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
} }
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 } { public addGroup(group: Group) {
if (!this.localIdentities.has(dst)) { const hash = parseNodeHash(group.hash()) as number;
throw new Error(`No local identities for destination ${dst}`); if (!this.groups[hash]) {
this.groups[hash] = [];
} }
const localIdentities = this.localIdentities.get(dst)!; this.groups[hash].push(group);
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
} }
const block = ecb(sharedSecret.slice(0, 16), { disablePadding: true }); public decryptGroupText(
const plain = block.decrypt(encrypted.cipherText); channelHash: NodeHash,
if (plain.length < 8) { hmac: Uint8Array,
continue; // Invalid plaintext, try next identity 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); for (const group of groups) {
return { try {
decrypted: { const decrypted = group.decryptText(hmac, ciphertext);
timestamp: reader.readTimestamp(), return { decrypted, group };
data: reader.readBytes(), } catch {
}, // Ignore decryption errors and try the next group.
contact,
identity: localIdentity
} }
} }
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"; export {
import { 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, RouteType,
PayloadType, PayloadType,
} from "./types"; RequestType,
TextType,
NodeType,
} from "./packet.types";
export { Packet } from "./packet";
export default { export {
Packet, type NodeHash,
RouteType, type IIdentity,
PayloadType, 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 { import {
AckPayload, AckPayload,
AdvertAppData, AdvertAppData,
AdvertFlag,
AdvertPayload, AdvertPayload,
AnonReqPayload, AnonReqPayload,
EncryptedPayload, EncryptedPayload,
@@ -16,14 +17,9 @@ import {
RouteType, RouteType,
TextPayload, TextPayload,
TracePayload, TracePayload,
type IPacket, type IPacket
type NodeHash } from "./packet.types";
} from "./types"; import { base64ToBytes, bytesToHex, FieldType, Dissected, Segment, Reader } from "@hamradio/packet";
import {
base64ToBytes,
BufferReader,
bytesToHex
} from "./parser";
export class Packet implements IPacket { export class Packet implements IPacket {
// Raw packet bytes. // Raw packet bytes.
@@ -39,26 +35,34 @@ export class Packet implements IPacket {
public pathHashCount: number; public pathHashCount: number;
public pathHashSize: number; public pathHashSize: number;
public pathHashBytes: 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.header = header;
this.transport = transport; this.transport = transport;
this.pathLength = pathLength; this.pathLength = pathLength;
this.path = path; this.path = path;
this.payload = payload; this.payload = payload;
this.routeType = (header) & 0x03; this.routeType = header & 0x03;
this.payloadVersion = (header >> 6) & 0x03; this.payloadVersion = (header >> 6) & 0x03;
this.payloadType = (header >> 2) & 0x0f; this.payloadType = (header >> 2) & 0x0f;
this.pathHashCount = (pathLength >> 6) + 1; this.pathHashSize = (pathLength >> 6) + 1;
this.pathHashSize = pathLength & 0x3f; this.pathHashCount = pathLength & 0x3f;
this.pathHashBytes = this.pathHashCount * this.pathHashSize; this.pathHashBytes = this.pathHashCount * this.pathHashSize;
this.pathHashes = []; this.pathHashes = [];
for (let i = 0; i < this.pathHashCount; i++) { for (let i = 0; i < this.pathHashBytes; i += this.pathHashSize) {
const hashBytes = this.path.slice(i * this.pathHashSize, (i + 1) * this.pathHashSize); const hashBytes = this.path.slice(i, i + this.pathHashSize);
const hashHex = bytesToHex(hashBytes); const hashHex = bytesToHex(hashBytes);
this.pathHashes.push(hashHex); this.pathHashes.push(hashHex);
} }
@@ -99,215 +103,529 @@ export class Packet implements IPacket {
return bytesToHex(digest.slice(0, 8)); 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) { switch (this.payloadType) {
case PayloadType.REQUEST: case PayloadType.REQUEST:
return this.decodeRequest(); result = this.decodeRequest(withStructure);
break;
case PayloadType.RESPONSE: case PayloadType.RESPONSE:
return this.decodeResponse(); result = this.decodeResponse(withStructure);
break;
case PayloadType.TEXT: case PayloadType.TEXT:
return this.decodeText(); result = this.decodeText(withStructure);
break;
case PayloadType.ACK: case PayloadType.ACK:
return this.decodeAck(); result = this.decodeAck(withStructure);
break;
case PayloadType.ADVERT: case PayloadType.ADVERT:
return this.decodeAdvert(); result = this.decodeAdvert(withStructure);
break;
case PayloadType.GROUP_TEXT: case PayloadType.GROUP_TEXT:
return this.decodeGroupText(); result = this.decodeGroupText(withStructure);
break;
case PayloadType.GROUP_DATA: case PayloadType.GROUP_DATA:
return this.decodeGroupData(); result = this.decodeGroupData(withStructure);
break;
case PayloadType.ANON_REQ: case PayloadType.ANON_REQ:
return this.decodeAnonReq(); result = this.decodeAnonReq(withStructure);
break;
case PayloadType.PATH: case PayloadType.PATH:
return this.decodePath(); result = this.decodePath(withStructure);
break;
case PayloadType.TRACE: case PayloadType.TRACE:
return this.decodeTrace(); result = this.decodeTrace(withStructure);
break;
case PayloadType.RAW_CUSTOM: case PayloadType.RAW_CUSTOM:
return this.decodeRawCustom(); result = this.decodeRawCustom(withStructure);
break;
default: default:
throw new Error(`Unsupported payload type: ${this.payloadType}`); 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 { return result as Payload;
const cipherMAC = reader.readBytes(2); }
const cipherText = reader.readBytes(reader.remainingBytes());
private decodeEncryptedPayload(reader: Reader): EncryptedPayload {
const cipherMAC = reader.bytes(2);
const cipherText = reader.bytes();
return { cipherMAC, cipherText }; return { cipherMAC, cipherText };
} }
private decodeRequest(): RequestPayload { private decodeRequest(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 4) { if (this.payload.length < 4) {
throw new Error("Invalid request payload: too short"); throw new Error("Invalid request payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
return { const dst = reader.uint8();
const src = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: RequestPayload = {
type: PayloadType.REQUEST, type: PayloadType.REQUEST,
dst: bytesToHex(reader.readBytes(1)), dst,
src: bytesToHex(reader.readBytes(1)), src,
encrypted: this.decodeEncryptedPayload(reader), 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) { if (this.payload.length < 4) {
throw new Error("Invalid response payload: too short"); throw new Error("Invalid response payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
return { const dst = reader.uint8();
const src = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: ResponsePayload = {
type: PayloadType.RESPONSE, type: PayloadType.RESPONSE,
dst: bytesToHex(reader.readBytes(1)), dst,
src: bytesToHex(reader.readBytes(1)), src,
encrypted: this.decodeEncryptedPayload(reader), 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) { if (this.payload.length < 4) {
throw new Error("Invalid text payload: too short"); throw new Error("Invalid text payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
return { const dst = reader.uint8();
const src = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: TextPayload = {
type: PayloadType.TEXT, type: PayloadType.TEXT,
dst: bytesToHex(reader.readBytes(1)), dst,
src: bytesToHex(reader.readBytes(1)), src,
encrypted: this.decodeEncryptedPayload(reader), 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) { if (this.payload.length < 4) {
throw new Error("Invalid ack payload: too short"); throw new Error("Invalid ack payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
return { const checksum = reader.bytes(4);
const payload: AckPayload = {
type: PayloadType.ACK, 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) { if (this.payload.length < 4) {
throw new Error("Invalid advert payload: too short"); throw new Error("Invalid advert payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
const payload: Partial<AdvertPayload> = { const payload: Partial<AdvertPayload> = {
type: PayloadType.ADVERT, type: PayloadType.ADVERT,
publicKey: reader.readBytes(32), publicKey: reader.bytes(32),
timestamp: reader.readTimestamp(), timestamp: reader.date32(),
signature: reader.readBytes(64), signature: reader.bytes(64)
};
let segment: 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 = { const appdata: AdvertAppData = {
nodeType: flags & 0x0f, nodeType: flags & 0x0f,
hasLocation: (flags & 0x10) !== 0, hasLocation: (flags & AdvertFlag.HAS_LOCATION) !== 0,
hasFeature1: (flags & 0x20) !== 0, hasFeature1: (flags & AdvertFlag.HAS_FEATURE1) !== 0,
hasFeature2: (flags & 0x40) !== 0, hasFeature2: (flags & AdvertFlag.HAS_FEATURE2) !== 0,
hasName: (flags & 0x80) !== 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) { if (appdata.hasLocation) {
const lat = reader.readInt32LE() / 100000; const lat = reader.int32() / 1000000;
const lon = reader.readInt32LE() / 100000; const lon = reader.int32() / 1000000;
appdata.location = [lat, lon]; appdata.location = [lat, lon];
if (typeof withSegment === "boolean" && withSegment) {
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) { 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) { 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) { if (appdata.hasName) {
const nameBytes = reader.readBytes(); appdata.name = reader.cString();
let nullPos = nameBytes.indexOf(0); if (typeof withSegment === "boolean" && withSegment) {
if (nullPos === -1) { segment!.fields.push({
nullPos = nameBytes.length; 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 { if (typeof withSegment === "boolean" && withSegment && typeof segment !== "undefined") {
...payload, return { payload: { ...payload, appdata } as AdvertPayload, segment };
appdata
} as AdvertPayload;
} }
private decodeGroupText(): GroupTextPayload { return { ...payload, appdata } as AdvertPayload;
}
private decodeGroupText(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
if (this.payload.length < 3) { if (this.payload.length < 3) {
throw new Error("Invalid group text payload: too short"); throw new Error("Invalid group text payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
return { const channelHash = reader.uint8();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: GroupTextPayload = {
type: PayloadType.GROUP_TEXT, type: PayloadType.GROUP_TEXT,
channelHash: bytesToHex(reader.readBytes(1)), channelHash,
encrypted: this.decodeEncryptedPayload(reader), 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) { if (this.payload.length < 3) {
throw new Error("Invalid group data payload: too short"); throw new Error("Invalid group data payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
return { const payload: GroupDataPayload = {
type: PayloadType.GROUP_DATA, type: PayloadType.GROUP_DATA,
channelHash: bytesToHex(reader.readBytes(1)), channelHash: reader.uint8(),
encrypted: this.decodeEncryptedPayload(reader), 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) { if (this.payload.length < 1 + 32 + 2) {
throw new Error("Invalid anon req payload: too short"); throw new Error("Invalid anon req payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
return { const payload: AnonReqPayload = {
type: PayloadType.ANON_REQ, type: PayloadType.ANON_REQ,
dst: bytesToHex(reader.readBytes(1)), dst: reader.uint8(),
publicKey: reader.readBytes(32), publicKey: reader.bytes(32),
encrypted: this.decodeEncryptedPayload(reader), 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) { if (this.payload.length < 2) {
throw new Error("Invalid path payload: too short"); throw new Error("Invalid path payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
return { const payload: PathPayload = {
type: PayloadType.PATH, type: PayloadType.PATH,
dst: bytesToHex(reader.readBytes(1)), dst: reader.uint8(),
src: bytesToHex(reader.readBytes(1)), 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) { if (this.payload.length < 9) {
throw new Error("Invalid trace payload: too short"); throw new Error("Invalid trace payload: too short");
} }
const reader = new BufferReader(this.payload); const reader = Reader.fromBytes(this.payload);
return { const payload: TracePayload = {
type: PayloadType.TRACE, type: PayloadType.TRACE,
tag: reader.readUint32LE() >>> 0, tag: reader.uint32(),
authCode: reader.readUint32LE() >>> 0, authCode: reader.uint32(),
flags: reader.readByte() & 0x03, flags: reader.uint8() & 0x03,
nodes: reader.readBytes() nodes: reader.bytes()
};
if (typeof withSegment === "boolean" && withSegment) {
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 { private decodeRawCustom(withSegment?: boolean): Payload | { payload: Payload; segment: Segment } {
return { const payload: RawCustomPayload = {
type: PayloadType.RAW_CUSTOM, type: PayloadType.RAW_CUSTOM,
data: this.payload
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "raw custom payload",
data: this.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 { describe, expect, test } from "vitest";
import { Packet } from '../src/packet'; import { Packet } from "../src/packet";
import { PayloadType, RouteType, NodeType, TracePayload, AdvertPayload, RequestPayload, TextPayload, ResponsePayload, RawCustomPayload, AnonReqPayload } from '../src/types'; import {
import { hexToBytes, bytesToHex } from '../src/parser'; PayloadType,
RouteType,
NodeType,
TracePayload,
AdvertPayload,
RequestPayload,
TextPayload,
ResponsePayload,
RawCustomPayload,
AnonReqPayload,
Payload,
AckPayload,
PathPayload,
GroupDataPayload,
GroupTextPayload
} from "../src/packet.types";
import { bytesToHex, Dissected, hexToBytes } from "@hamradio/packet";
describe('Packet.fromBytes', () => { describe("Packet.fromBytes", () => {
test('frame 1: len=122 type=5 payload_len=99', () => { test("frame 1: len=122 type=5 payload_len=99", () => {
const hex = '1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E'; const hex =
"1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(122); expect(bytes.length).toBe(122);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(99); expect(pkt.payload.length).toBe(99);
expect(pkt.payloadType).toBe(PayloadType.GROUP_TEXT); expect(pkt.payloadType).toBe(PayloadType.GROUP_TEXT);
const h = pkt.hash(); const h = pkt.hash();
expect(h.toUpperCase()).toBe('A17FC3ECD23FCFAD'); expect(h.toUpperCase()).toBe("A17FC3ECD23FCFAD");
}); });
test('frame 2: len=32 type=1 payload_len=20', () => { test("frame 2: len=32 type=1 payload_len=20", () => {
const hex = '050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06'; const hex = "050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(32); expect(bytes.length).toBe(32);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20); expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.RESPONSE); expect(pkt.payloadType).toBe(PayloadType.RESPONSE);
expect(pkt.hash().toUpperCase()).toBe('1D378AD8B7EBA411'); expect(pkt.hash().toUpperCase()).toBe("1D378AD8B7EBA411");
}); });
test('frame 3: len=38 type=0 payload_len=20', () => { test("frame 3: len=38 type=0 payload_len=20", () => {
const hex = '01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185'; const hex = "01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(38); expect(bytes.length).toBe(38);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20); expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.REQUEST); expect(pkt.payloadType).toBe(PayloadType.REQUEST);
expect(pkt.hash().toUpperCase()).toBe('9948A57E8507EB95'); expect(pkt.hash().toUpperCase()).toBe("9948A57E8507EB95");
}); });
test('frame 4: len=37 type=8 payload_len=20', () => { test("frame 4: len=37 type=8 payload_len=20", () => {
const hex = '210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5'; const hex = "210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(37); expect(bytes.length).toBe(37);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20); expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.PATH); expect(pkt.payloadType).toBe(PayloadType.PATH);
expect(pkt.hash().toUpperCase()).toBe('0A5157C46F34ECC1'); expect(pkt.hash().toUpperCase()).toBe("0A5157C46F34ECC1");
}); });
test('frame 5: len=26 type=3 payload_len=20', () => { test("frame 5: len=26 type=3 payload_len=20", () => {
const hex = '2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'; const hex =
"2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.routeType).toBe(RouteType.TRANSPORT_DIRECT); expect(pkt.routeType).toBe(RouteType.TRANSPORT_DIRECT);
expect(pkt.payloadType).toBe(PayloadType.TRACE); expect(pkt.payloadType).toBe(PayloadType.TRACE);
const payload = pkt.decode(); const payload = pkt.decode() as TracePayload;
expect(payload.type).toBe(PayloadType.TRACE); expect(payload.type).toBe(PayloadType.TRACE);
// the TRACE payload format has been updated; ensure we decode a TRACE payload // the TRACE payload format has been updated; ensure we decode a TRACE payload
expect(payload.type).toBe(PayloadType.TRACE); expect(payload.type).toBe(PayloadType.TRACE);
// ensure header path bytes were parsed // ensure header path bytes were parsed
const expectedHeaderPathHex = '1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220'.toUpperCase(); const expectedHeaderPathHex =
"1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220".toUpperCase();
expect(bytesToHex(pkt.path).toUpperCase()).toBe(expectedHeaderPathHex); expect(bytesToHex(pkt.path).toUpperCase()).toBe(expectedHeaderPathHex);
// transport codes (big-endian words as parsed from the packet) // transport codes (big-endian words as parsed from the packet)
expect(pkt.transport).toEqual([0x42fd, 0x6c4c]); expect(pkt.transport).toEqual([0x42fd, 0x6c4c]);
expect(pkt.pathLength).toBe(0x3b); expect(pkt.pathLength).toBe(0x3b);
// payload bytes check (raw payload must match expected) // payload bytes check (raw payload must match expected)
const expectedPayloadHex = 'D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase(); const expectedPayloadHex =
"D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46".toUpperCase();
expect(bytesToHex(pkt.payload).toUpperCase()).toBe(expectedPayloadHex); expect(bytesToHex(pkt.payload).toUpperCase()).toBe(expectedPayloadHex);
// verify decoded trace fields: tag, authCode, flags and nodes // verify decoded trace fields: tag, authCode, flags and nodes
const trace = payload as TracePayload; const trace = payload as TracePayload;
// tag/auth are read as little-endian uint32 values (memcpy on little-endian C) // tag/auth are read as little-endian uint32 values (memcpy on little-endian C)
expect(trace.tag).toBe(0xC91F10D2); expect(trace.tag).toBe(0xc91f10d2);
expect(trace.authCode).toBe(0xB930CBAC); expect(trace.authCode).toBe(0xb930cbac);
// expect(trace.flags).toBe(0x90); // expect(trace.flags).toBe(0x90);
const expectedNodesHex = 'D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase(); const expectedNodesHex =
"D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46".toUpperCase();
expect(bytesToHex(trace.nodes).toUpperCase()).toBe(expectedNodesHex); expect(bytesToHex(trace.nodes).toUpperCase()).toBe(expectedNodesHex);
}); });
test('frame 6: len=110 type=1 payload_len=99', () => { test("frame 6: len=110 type=1 payload_len=99", () => {
const hex = '1102607BE88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773336056663D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F0992143711038C3953004E4C2D4548562D564247422D52505452'; const hex =
"1102607BE88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773336056663D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F0992143711038C3953004E4C2D4548562D564247422D52505452";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.routeType).toBe(RouteType.FLOOD); expect(pkt.routeType).toBe(RouteType.FLOOD);
expect(pkt.payloadType).toBe(PayloadType.ADVERT); expect(pkt.payloadType).toBe(PayloadType.ADVERT);
const adv = pkt.decode() as AdvertPayload; const adv = pkt.decode() as AdvertPayload;
expect(adv.type).toBe(PayloadType.ADVERT); expect(adv.type).toBe(PayloadType.ADVERT);
const pubHex = 'E88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773'; const pubHex = "E88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773";
expect(bytesToHex(adv.publicKey).toUpperCase()).toBe(pubHex); expect(bytesToHex(adv.publicKey).toUpperCase()).toBe(pubHex);
// timestamp should match 2024-05-28T22:52:35Z // timestamp should match 2024-05-28T22:52:35Z
expect(adv.timestamp.toISOString()).toBe('2024-05-28T22:52:35.000Z'); expect(adv.timestamp.toISOString()).toBe("2024-05-28T22:52:35.000Z");
const sigHex = '3D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F09'; const sigHex =
"3D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F09";
expect(bytesToHex(adv.signature).toUpperCase()).toBe(sigHex); expect(bytesToHex(adv.signature).toUpperCase()).toBe(sigHex);
// appdata flags 0x92 -> nodeType 0x02 (REPEATER), hasLocation true, hasName true // appdata flags 0x92 -> nodeType 0x02 (REPEATER), hasLocation true, hasName true
expect(adv.appdata.nodeType).toBe(NodeType.REPEATER); expect(adv.appdata.nodeType).toBe(NodeType.REPEATER);
expect(adv.appdata.hasLocation).toBe(true); expect(adv.appdata.hasLocation).toBe(true);
expect(adv.appdata.hasName).toBe(true); expect(adv.appdata.hasName).toBe(true);
// location values: parser appears to scale values by 10 here, accept that // location values: parser appears to scale values by 10 here, accept that
expect(adv.appdata.location[0] / 10).toBeCloseTo(51.45986, 5); expect(adv.appdata.location).toBeDefined();
expect(adv.appdata.location[1] / 10).toBeCloseTo(5.45422, 5); expect(adv.appdata.location![0] / 10).toBeCloseTo(5.145986, 5);
expect(adv.appdata.name).toBe('NL-EHV-VBGB-RPTR'); expect(adv.appdata.location![1] / 10).toBeCloseTo(0.545422, 5);
expect(pkt.hash().toUpperCase()).toBe('67C10F75168ECC8C'); expect(adv.appdata.name).toBe("NL-EHV-VBGB-RPTR");
expect(pkt.hash().toUpperCase()).toBe("67C10F75168ECC8C");
}); });
}); });
describe('Packet decode branches and transport/path parsing', () => { describe("Packet decode branches and transport/path parsing", () => {
const makePacket = (payloadType: number, routeType: number, pathBytes: Uint8Array, payload: Uint8Array, transportWords?: [number, number]) => { const makePacket = (
payloadType: number,
routeType: number,
pathBytes: Uint8Array,
payload: Uint8Array,
transportWords?: [number, number]
) => {
const header = (0 << 6) | (payloadType << 2) | routeType; const header = (0 << 6) | (payloadType << 2) | routeType;
const parts: number[] = [header]; const parts: number[] = [header];
if (transportWords) { if (transportWords) {
@@ -118,47 +148,61 @@ describe('Packet decode branches and transport/path parsing', () => {
return arr; return arr;
}; };
test('hasTransportCodes true/false and transport parsed', () => { test("hasTransportCodes true/false and transport parsed", () => {
// transport present (route TRANSPORT_FLOOD = 0) // transport present (route TRANSPORT_FLOOD = 0)
const p = makePacket(PayloadType.REQUEST, RouteType.TRANSPORT_FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2]), [0x1122, 0x3344]); const p = makePacket(
PayloadType.REQUEST,
RouteType.TRANSPORT_FLOOD,
new Uint8Array([]),
new Uint8Array([0, 0, 1, 2]),
[0x1122, 0x3344]
);
const pkt = Packet.fromBytes(p); const pkt = Packet.fromBytes(p);
expect(pkt.transport).toEqual([0x1122, 0x3344]); expect(pkt.transport).toEqual([0x1122, 0x3344]);
// no transport (route FLOOD = 1) // no transport (route FLOOD = 1)
const p2 = makePacket(PayloadType.REQUEST, RouteType.FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2])); const p2 = makePacket(PayloadType.REQUEST, RouteType.FLOOD, new Uint8Array([]), new Uint8Array([0, 0, 1, 2]));
const pkt2 = Packet.fromBytes(p2); const pkt2 = Packet.fromBytes(p2);
expect(pkt2.transport).toBeUndefined(); expect(pkt2.transport).toBeUndefined();
}); });
test('payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)', () => { test("payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)", () => {
const payload = new Uint8Array([0xAA, 0xBB, 0x01, 0x02, 0x03]); // dst,src, mac(2), cipherText(1) const payload = new Uint8Array([0xaa, 0xbb, 0x01, 0x02, 0x03]); // dst,src, mac(2), cipherText(1)
const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload)); const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload));
const req = pkt.decode() as RequestPayload; const req = pkt.decode() as RequestPayload;
expect(req.type).toBe(PayloadType.REQUEST); expect(req.type).toBe(PayloadType.REQUEST);
expect(req.dst).toBe('aa'); expect(req.dst).toBe(0xaa);
expect(req.src).toBe('bb'); expect(req.src).toBe(0xbb);
const resp = Packet.fromBytes(makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as ResponsePayload; const resp = Packet.fromBytes(
makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as ResponsePayload;
expect(resp.type).toBe(PayloadType.RESPONSE); expect(resp.type).toBe(PayloadType.RESPONSE);
const txt = Packet.fromBytes(makePacket(PayloadType.TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as TextPayload; const txt = Packet.fromBytes(
makePacket(PayloadType.TEXT, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as TextPayload;
expect(txt.type).toBe(PayloadType.TEXT); expect(txt.type).toBe(PayloadType.TEXT);
}); });
test('ACK decode and RAW_CUSTOM', () => { test("ACK decode and RAW_CUSTOM", () => {
const ackPayload = new Uint8Array([0x01,0x02,0x03,0x04]); const ackPayload = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const ack = Packet.fromBytes(makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)).decode(); const ack = Packet.fromBytes(
makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)
).decode() as AckPayload;
expect(ack.type).toBe(PayloadType.ACK); expect(ack.type).toBe(PayloadType.ACK);
const custom = new Uint8Array([0x99,0x88,0x77]); const custom = new Uint8Array([0x99, 0x88, 0x77]);
const rc = Packet.fromBytes(makePacket(PayloadType.RAW_CUSTOM, RouteType.DIRECT, new Uint8Array([]), custom)).decode() as RawCustomPayload; const rc = Packet.fromBytes(
makePacket(PayloadType.RAW_CUSTOM, RouteType.DIRECT, new Uint8Array([]), custom)
).decode() as RawCustomPayload;
expect(rc.type).toBe(PayloadType.RAW_CUSTOM); expect(rc.type).toBe(PayloadType.RAW_CUSTOM);
expect(rc.data).toEqual(custom); expect(rc.data).toEqual(custom);
}); });
test('ADVERT minimal decode (no appdata extras)', () => { test("ADVERT minimal decode (no appdata extras)", () => {
const publicKey = new Uint8Array(32).fill(1); const publicKey = new Uint8Array(32).fill(1);
const timestamp = new Uint8Array([0x01,0x00,0x00,0x00]); const timestamp = new Uint8Array([0x01, 0x00, 0x00, 0x00]);
const signature = new Uint8Array(64).fill(2); const signature = new Uint8Array(64).fill(2);
const flags = new Uint8Array([0x00]); const flags = new Uint8Array([0x00]);
const payload = new Uint8Array([...publicKey, ...timestamp, ...signature, ...flags]); const payload = new Uint8Array([...publicKey, ...timestamp, ...signature, ...flags]);
@@ -170,59 +214,69 @@ describe('Packet decode branches and transport/path parsing', () => {
expect(adv.appdata.hasName).toBe(false); expect(adv.appdata.hasName).toBe(false);
}); });
test('GROUP_TEXT and GROUP_DATA decode', () => { test("GROUP_TEXT and GROUP_DATA decode", () => {
const payload = new Uint8Array([0x55, 0x01, 0x02, 0x03]); // channelHash + mac(2) + cipher const payload = new Uint8Array([0x55, 0x01, 0x02, 0x03]); // channelHash + mac(2) + cipher
const gt = Packet.fromBytes(makePacket(PayloadType.GROUP_TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode(); const gt = Packet.fromBytes(
makePacket(PayloadType.GROUP_TEXT, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as GroupTextPayload;
expect(gt.type).toBe(PayloadType.GROUP_TEXT); expect(gt.type).toBe(PayloadType.GROUP_TEXT);
const gd = Packet.fromBytes(makePacket(PayloadType.GROUP_DATA, RouteType.DIRECT, new Uint8Array([]), payload)).decode(); const gd = Packet.fromBytes(
makePacket(PayloadType.GROUP_DATA, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as GroupDataPayload;
expect(gd.type).toBe(PayloadType.GROUP_DATA); expect(gd.type).toBe(PayloadType.GROUP_DATA);
}); });
test('ANON_REQ decode', () => { test("ANON_REQ decode", () => {
const dst = 0x12; const dst = 0x12;
const pub = new Uint8Array(32).fill(3); const pub = new Uint8Array(32).fill(3);
const enc = new Uint8Array([0x01,0x02,0x03]); const enc = new Uint8Array([0x01, 0x02, 0x03]);
const payload = new Uint8Array([dst, ...pub, ...enc]); const payload = new Uint8Array([dst, ...pub, ...enc]);
const ar = Packet.fromBytes(makePacket(PayloadType.ANON_REQ, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as AnonReqPayload; const ar = Packet.fromBytes(
makePacket(PayloadType.ANON_REQ, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as AnonReqPayload;
expect(ar.type).toBe(PayloadType.ANON_REQ); expect(ar.type).toBe(PayloadType.ANON_REQ);
expect(ar.dst).toBe('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 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); expect(path.type).toBe(PayloadType.PATH);
const nodes = new Uint8Array([0x01,0x02,0x03]); const nodes = new Uint8Array([0x01, 0x02, 0x03]);
// construct TRACE payload: tag (4 bytes LE), authCode (4 bytes LE), flags (1), nodes... // construct TRACE payload: tag (4 bytes LE), authCode (4 bytes LE), flags (1), nodes...
const tag = new Uint8Array([0x01,0x00,0x00,0x00]); const tag = new Uint8Array([0x01, 0x00, 0x00, 0x00]);
const auth = new Uint8Array([0x02,0x00,0x00,0x00]); const auth = new Uint8Array([0x02, 0x00, 0x00, 0x00]);
const flags = new Uint8Array([0x00]); const flags = new Uint8Array([0x00]);
const tracePayload = new Uint8Array([...tag, ...auth, ...flags, ...nodes]); const tracePayload = new Uint8Array([...tag, ...auth, ...flags, ...nodes]);
const trace = Packet.fromBytes(makePacket(PayloadType.TRACE, RouteType.DIRECT, new Uint8Array([]), tracePayload)).decode() as TracePayload; const trace = Packet.fromBytes(
makePacket(PayloadType.TRACE, RouteType.DIRECT, new Uint8Array([]), tracePayload)
).decode() as TracePayload;
expect(trace.type).toBe(PayloadType.TRACE); expect(trace.type).toBe(PayloadType.TRACE);
expect(trace.nodes).toBeInstanceOf(Uint8Array); expect(trace.nodes).toBeInstanceOf(Uint8Array);
}); });
test('pathHashes parsing when multiple hashes', () => { test("pathHashes parsing when multiple hashes", () => {
// create pathLength byte: count=2 size=3 -> (1<<6)|3 = 67 // create pathLength byte: count=2 size=3 -> (1<<6)|3 = 67
const pathLengthByte = 67; const pathLengthByte = 67;
const header = (0 << 6) | (PayloadType.RAW_CUSTOM << 2) | RouteType.DIRECT; const header = (0 << 6) | (PayloadType.RAW_CUSTOM << 2) | RouteType.DIRECT;
const payload = new Uint8Array([0x01]); const payload = new Uint8Array([0x01]);
const pathBytes = new Uint8Array([0xAA,0xBB,0xCC, 0x11,0x22,0x33]); const pathBytes = new Uint8Array([0xaa, 0xbb, 0xcc, 0x11, 0x22, 0x33]);
const parts: number[] = [header, pathLengthByte]; const parts: number[] = [header, pathLengthByte];
const arr = new Uint8Array(parts.length + pathBytes.length + payload.length); const arr = new Uint8Array(parts.length + pathBytes.length + payload.length);
arr.set(parts, 0); arr.set(parts, 0);
arr.set(pathBytes, parts.length); arr.set(pathBytes, parts.length);
arr.set(payload, parts.length + pathBytes.length); arr.set(payload, parts.length + pathBytes.length);
const pkt = Packet.fromBytes(arr); const pkt = Packet.fromBytes(arr);
expect(pkt.pathHashCount).toBe(2); expect(pkt.pathHashCount).toBe(3);
expect(pkt.pathHashSize).toBe(3); expect(pkt.pathHashSize).toBe(2);
expect(pkt.pathHashes.length).toBe(2); expect(pkt.pathHashes.length).toBe(3);
expect(pkt.pathHashes[0]).toBe(bytesToHex(pathBytes.subarray(0,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 // payloadType 0x0a is not handled
const header = (0 << 6) | (0x0a << 2) | RouteType.DIRECT; const header = (0 << 6) | (0x0a << 2) | RouteType.DIRECT;
const arr = new Uint8Array([header, 0x00]); const arr = new Uint8Array([header, 0x00]);
@@ -230,3 +284,24 @@ describe('Packet decode branches and transport/path parsing', () => {
expect(() => pkt.decode()).toThrow(); 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, "isolatedModules": true,
"forceConsistentCasingInFileNames": 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"] "exclude": ["node_modules", "dist", "test"]
} }