12 Commits
v1.1.4 ... 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
19 changed files with 1225 additions and 4237 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

@@ -45,6 +45,53 @@ _Packet {
*/ */
``` ```
## Packet structure parsing
The parser can also be instructed to generate a packet structure, useful for debugging or
printing packet details:
```ts
import { Packet } from '@hamradio/meshcore';
const raw = new Uint8Array(Buffer.from("050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06", "hex"));
const packet = Packet.fromBytes(raw);
const { structure } = packet.decode(true);
console.log(structure);
/*
[
{
name: 'header',
data: Uint8Array(12) [
5, 10, 165, 14, 44,
176, 51, 109, 182, 123,
191, 120
],
fields: [
{ name: 'flags', type: 0, size: 1, bits: [Array] },
{ name: 'path length', type: 1, size: 1, bits: [Array] },
{ name: 'path hashes', type: 6, size: 10 }
]
},
{
name: 'response payload',
data: Uint8Array(20) [
146, 138, 59, 185, 191, 122,
139, 103, 124, 131, 182, 236,
7, 22, 249, 221, 16, 0,
42, 6
],
fields: [
{ name: 'destination hash', type: 1, size: 1, value: 146 },
{ name: 'source hash', type: 1, size: 1, value: 138 },
{ name: 'cipher MAC', type: 6, size: 2, value: [Uint8Array] },
{ name: 'cipher text', type: 6, size: 16, value: [Uint8Array] }
]
}
]
*/
```
## Identities ## Identities
The package supports: The package supports:

View File

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

16
eslint.config.ts Normal file
View File

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

3337
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

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

View File

@@ -1,20 +1,50 @@
export * from './identity'; export {
import * as identityTypes from './identity.types'; type IPacket,
import type * as identityTypesTypes from './identity.types'; type Payload,
type EncryptedPayload,
type RequestPayload,
type ResponsePayload,
type TextPayload,
type AckPayload,
type AdvertPayload,
type GroupTextPayload,
type GroupDataPayload,
type AnonReqPayload,
type PathPayload,
type TracePayload,
type RawCustomPayload,
RouteType,
PayloadType,
RequestType,
TextType,
NodeType,
} from "./packet.types";
export { Packet } from "./packet";
export * from './crypto'; export {
import * as cryptoTypes from './crypto.types'; type NodeHash,
import type * as cryptoTypesTypes from './crypto.types'; type IIdentity,
type ILocalIdentity,
type IContact
} from "./identity.types";
export {
parseNodeHash,
Identity,
LocalIdentity,
Contact,
Group,
Contacts
} from "./identity";
export * from './packet'; export {
import * as packetTypes from './packet.types'; type IPublicKey,
import type * as packetTypesTypes from './packet.types'; type IPrivateKey,
type ISharedSecret,
export type { type IStaticSecret
identityTypes, } from "./crypto.types";
identityTypesTypes, export {
cryptoTypes, PublicKey,
cryptoTypesTypes, PrivateKey,
packetTypes, SharedSecret,
packetTypesTypes 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
} from "./packet.types"; } from "./packet.types";
import { NodeHash } from "./identity.types"; import { base64ToBytes, bytesToHex, FieldType, Dissected, Segment, Reader } from "@hamradio/packet";
import {
base64ToBytes,
BufferReader,
bytesToHex
} from "./parser";
export class Packet implements IPacket { export class Packet implements IPacket {
// Raw packet bytes. // Raw packet bytes.
@@ -40,15 +36,23 @@ export class Packet implements IPacket {
public pathHashSize: number; public pathHashSize: number;
public pathHashBytes: number; public pathHashBytes: number;
public pathHashes: string[]; 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;
@@ -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: reader.readByte(), dst,
src: reader.readByte(), 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: reader.readByte(), dst,
src: reader.readByte(), 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: reader.readByte(), dst,
src: reader.readByte(), 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: reader.readByte(), 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: reader.readByte(), 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: reader.readByte(), dst: reader.uint8(),
publicKey: reader.readBytes(32), publicKey: reader.bytes(32),
encrypted: this.decodeEncryptedPayload(reader), encrypted: this.decodeEncryptedPayload(reader)
};
if (typeof withSegment === "boolean" && withSegment) {
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: reader.readByte(), dst: reader.uint8(),
src: reader.readByte(), 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;
} }
} }

View File

@@ -1,3 +1,4 @@
import { Dissected } from "@hamradio/packet";
import { NodeHash } from "./identity.types"; import { NodeHash } from "./identity.types";
// IPacket contains the raw packet bytes. // IPacket contains the raw packet bytes.
@@ -12,7 +13,7 @@ export interface IPacket {
path: Uint8Array; path: Uint8Array;
payload: Uint8Array; payload: Uint8Array;
decode(): Payload; decode(withStructure?: boolean): Payload | { payload: Payload; structure: Dissected };
} }
export enum RouteType { export enum RouteType {
@@ -33,10 +34,11 @@ export enum PayloadType {
ANON_REQ = 0x07, ANON_REQ = 0x07,
PATH = 0x08, PATH = 0x08,
TRACE = 0x09, TRACE = 0x09,
RAW_CUSTOM = 0x0f, RAW_CUSTOM = 0x0f
} }
export type Payload = export type Payload = BasePayload &
(
| RequestPayload | RequestPayload
| ResponsePayload | ResponsePayload
| TextPayload | TextPayload
@@ -47,14 +49,19 @@ export type Payload =
| AnonReqPayload | AnonReqPayload
| PathPayload | PathPayload
| TracePayload | TracePayload
| RawCustomPayload; | RawCustomPayload
);
export interface BasePayload {
type: PayloadType;
}
export interface EncryptedPayload { export interface EncryptedPayload {
cipherMAC: Uint8Array; cipherMAC: Uint8Array;
cipherText: Uint8Array; cipherText: Uint8Array;
} }
export interface RequestPayload { export interface RequestPayload extends BasePayload {
type: PayloadType.REQUEST; type: PayloadType.REQUEST;
dst: NodeHash; dst: NodeHash;
src: NodeHash; src: NodeHash;
@@ -69,7 +76,7 @@ export enum RequestType {
GET_MIN_MAX_AVG = 0x04, GET_MIN_MAX_AVG = 0x04,
GET_ACL = 0x05, GET_ACL = 0x05,
GET_NEIGHBORS = 0x06, GET_NEIGHBORS = 0x06,
GET_OWNER_INFO = 0x07, GET_OWNER_INFO = 0x07
} }
export interface DecryptedRequest { export interface DecryptedRequest {
@@ -78,7 +85,7 @@ export interface DecryptedRequest {
requestData: Uint8Array; requestData: Uint8Array;
} }
export interface ResponsePayload { export interface ResponsePayload extends BasePayload {
type: PayloadType.RESPONSE; type: PayloadType.RESPONSE;
dst: NodeHash; dst: NodeHash;
src: NodeHash; src: NodeHash;
@@ -91,7 +98,7 @@ export interface DecryptedResponse {
responseData: Uint8Array; responseData: Uint8Array;
} }
export interface TextPayload { export interface TextPayload extends BasePayload {
type: PayloadType.TEXT; type: PayloadType.TEXT;
dst: NodeHash; dst: NodeHash;
src: NodeHash; src: NodeHash;
@@ -102,7 +109,7 @@ export interface TextPayload {
export enum TextType { export enum TextType {
PLAIN_TEXT = 0x00, PLAIN_TEXT = 0x00,
CLI_COMMAND = 0x01, CLI_COMMAND = 0x01,
SIGNED_PLAIN_TEXT = 0x02, SIGNED_PLAIN_TEXT = 0x02
} }
export interface DecryptedText { export interface DecryptedText {
@@ -112,12 +119,12 @@ export interface DecryptedText {
message: string; message: string;
} }
export interface AckPayload { export interface AckPayload extends BasePayload {
type: PayloadType.ACK; type: PayloadType.ACK;
checksum: Uint8Array; checksum: Uint8Array;
} }
export interface AdvertPayload { export interface AdvertPayload extends BasePayload {
type: PayloadType.ADVERT; type: PayloadType.ADVERT;
publicKey: Uint8Array; publicKey: Uint8Array;
timestamp: Date; timestamp: Date;
@@ -129,7 +136,14 @@ export enum NodeType {
CHAT_NODE = 0x01, CHAT_NODE = 0x01,
REPEATER = 0x02, REPEATER = 0x02,
ROOM_SERVER = 0x03, ROOM_SERVER = 0x03,
SENSOR_NODE = 0x04, SENSOR_NODE = 0x04
}
export enum AdvertFlag {
HAS_LOCATION = 0x10,
HAS_FEATURE1 = 0x20,
HAS_FEATURE2 = 0x40,
HAS_NAME = 0x80
} }
export interface AdvertAppData { export interface AdvertAppData {
@@ -144,7 +158,7 @@ export interface AdvertAppData {
name?: string; name?: string;
} }
export interface GroupTextPayload { export interface GroupTextPayload extends BasePayload {
type: PayloadType.GROUP_TEXT; type: PayloadType.GROUP_TEXT;
channelHash: NodeHash; channelHash: NodeHash;
encrypted: EncryptedPayload; encrypted: EncryptedPayload;
@@ -158,7 +172,7 @@ export interface DecryptedGroupText {
message: string; message: string;
} }
export interface GroupDataPayload { export interface GroupDataPayload extends BasePayload {
type: PayloadType.GROUP_DATA; type: PayloadType.GROUP_DATA;
channelHash: NodeHash; channelHash: NodeHash;
encrypted: EncryptedPayload; encrypted: EncryptedPayload;
@@ -170,7 +184,7 @@ export interface DecryptedGroupData {
data: Uint8Array; data: Uint8Array;
} }
export interface AnonReqPayload { export interface AnonReqPayload extends BasePayload {
type: PayloadType.ANON_REQ; type: PayloadType.ANON_REQ;
dst: NodeHash; dst: NodeHash;
publicKey: Uint8Array; publicKey: Uint8Array;
@@ -183,13 +197,13 @@ export interface DecryptedAnonReq {
data: Uint8Array; data: Uint8Array;
} }
export interface PathPayload { export interface PathPayload extends BasePayload {
type: PayloadType.PATH; type: PayloadType.PATH;
dst: NodeHash; dst: NodeHash;
src: NodeHash; src: NodeHash;
} }
export interface TracePayload { export interface TracePayload extends BasePayload {
type: PayloadType.TRACE; type: PayloadType.TRACE;
tag: number; tag: number;
authCode: number; authCode: number;
@@ -197,7 +211,7 @@ export interface TracePayload {
nodes: Uint8Array; nodes: Uint8Array;
} }
export interface RawCustomPayload { export interface RawCustomPayload extends BasePayload {
type: PayloadType.RAW_CUSTOM; type: PayloadType.RAW_CUSTOM;
data: Uint8Array; data: Uint8Array;
} }

View File

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

View File

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

View File

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

View File

@@ -1,92 +1,115 @@
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/packet.types'; import {
import { hexToBytes, bytesToHex } from '../src/parser'; PayloadType,
RouteType,
NodeType,
TracePayload,
AdvertPayload,
RequestPayload,
TextPayload,
ResponsePayload,
RawCustomPayload,
AnonReqPayload,
Payload,
AckPayload,
PathPayload,
GroupDataPayload,
GroupTextPayload
} from "../src/packet.types";
import { bytesToHex, Dissected, hexToBytes } from "@hamradio/packet";
describe('Packet.fromBytes', () => { describe("Packet.fromBytes", () => {
test('frame 1: len=122 type=5 payload_len=99', () => { test("frame 1: len=122 type=5 payload_len=99", () => {
const hex = '1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E'; const hex =
"1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(122); expect(bytes.length).toBe(122);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(99); expect(pkt.payload.length).toBe(99);
expect(pkt.payloadType).toBe(PayloadType.GROUP_TEXT); expect(pkt.payloadType).toBe(PayloadType.GROUP_TEXT);
const h = pkt.hash(); const h = pkt.hash();
expect(h.toUpperCase()).toBe('A17FC3ECD23FCFAD'); expect(h.toUpperCase()).toBe("A17FC3ECD23FCFAD");
}); });
test('frame 2: len=32 type=1 payload_len=20', () => { test("frame 2: len=32 type=1 payload_len=20", () => {
const hex = '050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06'; const hex = "050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(32); expect(bytes.length).toBe(32);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20); expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.RESPONSE); expect(pkt.payloadType).toBe(PayloadType.RESPONSE);
expect(pkt.hash().toUpperCase()).toBe('1D378AD8B7EBA411'); expect(pkt.hash().toUpperCase()).toBe("1D378AD8B7EBA411");
}); });
test('frame 3: len=38 type=0 payload_len=20', () => { test("frame 3: len=38 type=0 payload_len=20", () => {
const hex = '01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185'; const hex = "01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(38); expect(bytes.length).toBe(38);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20); expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.REQUEST); expect(pkt.payloadType).toBe(PayloadType.REQUEST);
expect(pkt.hash().toUpperCase()).toBe('9948A57E8507EB95'); expect(pkt.hash().toUpperCase()).toBe("9948A57E8507EB95");
}); });
test('frame 4: len=37 type=8 payload_len=20', () => { test("frame 4: len=37 type=8 payload_len=20", () => {
const hex = '210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5'; const hex = "210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
expect(bytes.length).toBe(37); expect(bytes.length).toBe(37);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.payload.length).toBe(20); expect(pkt.payload.length).toBe(20);
expect(pkt.payloadType).toBe(PayloadType.PATH); expect(pkt.payloadType).toBe(PayloadType.PATH);
expect(pkt.hash().toUpperCase()).toBe('0A5157C46F34ECC1'); expect(pkt.hash().toUpperCase()).toBe("0A5157C46F34ECC1");
}); });
test('frame 5: len=26 type=3 payload_len=20', () => { test("frame 5: len=26 type=3 payload_len=20", () => {
const hex = '2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'; const hex =
"2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46";
const bytes = hexToBytes(hex); const bytes = hexToBytes(hex);
const pkt = Packet.fromBytes(bytes); const pkt = Packet.fromBytes(bytes);
expect(pkt.routeType).toBe(RouteType.TRANSPORT_DIRECT); expect(pkt.routeType).toBe(RouteType.TRANSPORT_DIRECT);
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);
@@ -94,15 +117,21 @@ describe('Packet.fromBytes', () => {
expect(adv.appdata.hasName).toBe(true); expect(adv.appdata.hasName).toBe(true);
// location values: parser appears to scale values by 10 here, accept that // location values: parser appears to scale values by 10 here, accept that
expect(adv.appdata.location).toBeDefined(); expect(adv.appdata.location).toBeDefined();
expect(adv.appdata.location![0] / 10).toBeCloseTo(51.45986, 5); expect(adv.appdata.location![0] / 10).toBeCloseTo(5.145986, 5);
expect(adv.appdata.location![1] / 10).toBeCloseTo(5.45422, 5); expect(adv.appdata.location![1] / 10).toBeCloseTo(0.545422, 5);
expect(adv.appdata.name).toBe('NL-EHV-VBGB-RPTR'); expect(adv.appdata.name).toBe("NL-EHV-VBGB-RPTR");
expect(pkt.hash().toUpperCase()).toBe('67C10F75168ECC8C'); expect(pkt.hash().toUpperCase()).toBe("67C10F75168ECC8C");
}); });
}); });
describe('Packet decode branches and transport/path parsing', () => { describe("Packet decode branches and transport/path parsing", () => {
const makePacket = (payloadType: number, routeType: number, pathBytes: Uint8Array, payload: Uint8Array, transportWords?: [number, number]) => { const makePacket = (
payloadType: number,
routeType: number,
pathBytes: Uint8Array,
payload: Uint8Array,
transportWords?: [number, number]
) => {
const header = (0 << 6) | (payloadType << 2) | routeType; const header = (0 << 6) | (payloadType << 2) | routeType;
const parts: number[] = [header]; const parts: number[] = [header];
if (transportWords) { if (transportWords) {
@@ -119,47 +148,61 @@ describe('Packet decode branches and transport/path parsing', () => {
return arr; return arr;
}; };
test('hasTransportCodes true/false and transport parsed', () => { test("hasTransportCodes true/false and transport parsed", () => {
// transport present (route TRANSPORT_FLOOD = 0) // transport present (route TRANSPORT_FLOOD = 0)
const p = makePacket(PayloadType.REQUEST, RouteType.TRANSPORT_FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2]), [0x1122, 0x3344]); const p = makePacket(
PayloadType.REQUEST,
RouteType.TRANSPORT_FLOOD,
new Uint8Array([]),
new Uint8Array([0, 0, 1, 2]),
[0x1122, 0x3344]
);
const pkt = Packet.fromBytes(p); const pkt = Packet.fromBytes(p);
expect(pkt.transport).toEqual([0x1122, 0x3344]); expect(pkt.transport).toEqual([0x1122, 0x3344]);
// no transport (route FLOOD = 1) // no transport (route FLOOD = 1)
const p2 = makePacket(PayloadType.REQUEST, RouteType.FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2])); const p2 = makePacket(PayloadType.REQUEST, RouteType.FLOOD, new Uint8Array([]), new Uint8Array([0, 0, 1, 2]));
const pkt2 = Packet.fromBytes(p2); const pkt2 = Packet.fromBytes(p2);
expect(pkt2.transport).toBeUndefined(); expect(pkt2.transport).toBeUndefined();
}); });
test('payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)', () => { test("payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)", () => {
const payload = new Uint8Array([0xAA, 0xBB, 0x01, 0x02, 0x03]); // dst,src, mac(2), cipherText(1) const payload = new Uint8Array([0xaa, 0xbb, 0x01, 0x02, 0x03]); // dst,src, mac(2), cipherText(1)
const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload)); const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload));
const req = pkt.decode() as RequestPayload; const req = pkt.decode() as RequestPayload;
expect(req.type).toBe(PayloadType.REQUEST); expect(req.type).toBe(PayloadType.REQUEST);
expect(req.dst).toBe(0xAA); expect(req.dst).toBe(0xaa);
expect(req.src).toBe(0xBB); expect(req.src).toBe(0xbb);
const resp = Packet.fromBytes(makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as ResponsePayload; const resp = Packet.fromBytes(
makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as ResponsePayload;
expect(resp.type).toBe(PayloadType.RESPONSE); expect(resp.type).toBe(PayloadType.RESPONSE);
const txt = Packet.fromBytes(makePacket(PayloadType.TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as TextPayload; const txt = Packet.fromBytes(
makePacket(PayloadType.TEXT, RouteType.DIRECT, new Uint8Array([]), payload)
).decode() as TextPayload;
expect(txt.type).toBe(PayloadType.TEXT); expect(txt.type).toBe(PayloadType.TEXT);
}); });
test('ACK decode and RAW_CUSTOM', () => { test("ACK decode and RAW_CUSTOM", () => {
const ackPayload = new Uint8Array([0x01,0x02,0x03,0x04]); const ackPayload = new Uint8Array([0x01, 0x02, 0x03, 0x04]);
const ack = Packet.fromBytes(makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)).decode(); const ack = Packet.fromBytes(
makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)
).decode() as AckPayload;
expect(ack.type).toBe(PayloadType.ACK); expect(ack.type).toBe(PayloadType.ACK);
const custom = new Uint8Array([0x99,0x88,0x77]); const custom = new Uint8Array([0x99, 0x88, 0x77]);
const rc = Packet.fromBytes(makePacket(PayloadType.RAW_CUSTOM, RouteType.DIRECT, new Uint8Array([]), custom)).decode() as RawCustomPayload; const rc = Packet.fromBytes(
makePacket(PayloadType.RAW_CUSTOM, RouteType.DIRECT, new Uint8Array([]), custom)
).decode() as RawCustomPayload;
expect(rc.type).toBe(PayloadType.RAW_CUSTOM); expect(rc.type).toBe(PayloadType.RAW_CUSTOM);
expect(rc.data).toEqual(custom); expect(rc.data).toEqual(custom);
}); });
test('ADVERT minimal decode (no appdata extras)', () => { test("ADVERT minimal decode (no appdata extras)", () => {
const publicKey = new Uint8Array(32).fill(1); const publicKey = new Uint8Array(32).fill(1);
const timestamp = new Uint8Array([0x01,0x00,0x00,0x00]); const timestamp = new Uint8Array([0x01, 0x00, 0x00, 0x00]);
const signature = new Uint8Array(64).fill(2); const signature = new Uint8Array(64).fill(2);
const flags = new Uint8Array([0x00]); const flags = new Uint8Array([0x00]);
const payload = new Uint8Array([...publicKey, ...timestamp, ...signature, ...flags]); const payload = new Uint8Array([...publicKey, ...timestamp, ...signature, ...flags]);
@@ -171,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(0x12); expect(ar.dst).toBe(0x12);
}); });
test('PATH and TRACE decode nodes', () => { test("PATH and TRACE decode nodes", () => {
const pathPayload = new Uint8Array([0x0a, 0x0b]); const pathPayload = new Uint8Array([0x0a, 0x0b]);
const path = Packet.fromBytes(makePacket(PayloadType.PATH, RouteType.DIRECT, new Uint8Array([]), pathPayload)).decode(); 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]);
@@ -231,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,195 +0,0 @@
import { describe, it, expect } from 'vitest';
import { base64ToBytes, hexToBytes, BufferReader, BufferWriter } from '../src/parser';
describe('base64ToBytes', () => {
it('decodes a simple base64 string', () => {
const bytes = base64ToBytes('aGVsbG8=', 5); // "hello"
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
});
it('handles empty string', () => {
const bytes = base64ToBytes('', 0);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(0);
});
});
describe('BufferReader', () => {
it('readByte and peekByte advance/inspect correctly', () => {
const buf = new Uint8Array([1, 2, 3]);
const r = new BufferReader(buf);
expect(r.peekByte()).toBe(1);
expect(r.readByte()).toBe(1);
expect(r.peekByte()).toBe(2);
});
it('readBytes with and without length', () => {
const buf = new Uint8Array([10, 11, 12, 13]);
const r = new BufferReader(buf);
const a = r.readBytes(2);
expect(Array.from(a)).toEqual([10, 11]);
const b = r.readBytes();
expect(Array.from(b)).toEqual([12, 13]);
});
it('hasMore and remainingBytes reflect position', () => {
const buf = new Uint8Array([5, 6]);
const r = new BufferReader(buf);
expect(r.hasMore()).toBe(true);
expect(r.remainingBytes()).toBe(2);
r.readByte();
expect(r.remainingBytes()).toBe(1);
r.readByte();
expect(r.hasMore()).toBe(false);
});
it('reads little-endian unsigned ints', () => {
const r16 = new BufferReader(new Uint8Array([0x34, 0x12]));
expect(r16.readUint16LE()).toBe(0x1234);
const r32 = new BufferReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]));
expect(r32.readUint32LE()).toBe(0x12345678);
});
it('reads signed ints with two/four bytes (negative)', () => {
const r16 = new BufferReader(new Uint8Array([0xff, 0xff]));
expect(r16.readInt16LE()).toBe(-1);
const r32 = new BufferReader(new Uint8Array([0xff, 0xff, 0xff, 0xff]));
expect(r32.readInt32LE()).toBe(-1);
});
it('readTimestamp returns Date with seconds->ms conversion', () => {
const r = new BufferReader(new Uint8Array([0x01, 0x00, 0x00, 0x00]));
const d = r.readTimestamp();
expect(d.getTime()).toBe(1000);
});
});
describe('sizedStringToBytes', () => {
it('decodes hex string of correct length', () => {
// 4 bytes = 8 hex chars
const hex = 'deadbeef';
const result = hexToBytes(hex, 4);
expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('decodes base64 string of correct length', () => {
// 4 bytes = 8 hex chars, base64 for [0xde, 0xad, 0xbe, 0xef] is '3q2+7w=='
const b64 = '3q2+7w==';
const result = base64ToBytes(b64, 4);
expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('throws on invalid string length', () => {
expect(() => hexToBytes('abc', 4)).toThrow();
expect(() => hexToBytes('deadbeef00', 4)).toThrow();
});
});
describe('BufferWriter', () => {
it('writeByte and toBytes', () => {
const w = new BufferWriter();
w.writeByte(0x12);
w.writeByte(0x34);
expect(Array.from(w.toBytes())).toEqual([0x12, 0x34]);
});
it('writeBytes appends bytes', () => {
const w = new BufferWriter();
w.writeBytes(new Uint8Array([1, 2, 3]));
expect(Array.from(w.toBytes())).toEqual([1, 2, 3]);
});
it('writeUint16LE writes little-endian', () => {
const w = new BufferWriter();
w.writeUint16LE(0x1234);
expect(Array.from(w.toBytes())).toEqual([0x34, 0x12]);
});
it('writeUint32LE writes little-endian', () => {
const w = new BufferWriter();
w.writeUint32LE(0x12345678);
expect(Array.from(w.toBytes())).toEqual([0x78, 0x56, 0x34, 0x12]);
});
it('writeInt16LE writes signed values', () => {
const w = new BufferWriter();
w.writeInt16LE(-1);
expect(Array.from(w.toBytes())).toEqual([0xff, 0xff]);
const w2 = new BufferWriter();
w2.writeInt16LE(0x1234);
expect(Array.from(w2.toBytes())).toEqual([0x34, 0x12]);
});
it('writeInt32LE writes signed values', () => {
const w = new BufferWriter();
w.writeInt32LE(-1);
expect(Array.from(w.toBytes())).toEqual([0xff, 0xff, 0xff, 0xff]);
const w2 = new BufferWriter();
w2.writeInt32LE(0x12345678);
expect(Array.from(w2.toBytes())).toEqual([0x78, 0x56, 0x34, 0x12]);
});
it('writeTimestamp writes seconds as uint32le', () => {
const w = new BufferWriter();
const date = new Date(1000); // 1 second
w.writeTimestamp(date);
expect(Array.from(w.toBytes())).toEqual([0x01, 0x00, 0x00, 0x00]);
});
it('BufferWriter output can be read back by BufferReader', () => {
const w = new BufferWriter();
w.writeByte(0x42);
w.writeUint16LE(0x1234);
w.writeInt16LE(-2);
w.writeUint32LE(0xdeadbeef);
w.writeInt32LE(-123456);
w.writeBytes(new Uint8Array([0x01, 0x02]));
const date = new Date(5000); // 5 seconds
w.writeTimestamp(date);
const bytes = w.toBytes();
const r = new BufferReader(bytes);
expect(r.readByte()).toBe(0x42);
expect(r.readUint16LE()).toBe(0x1234);
expect(r.readInt16LE()).toBe(-2);
expect(r.readUint32LE()).toBe(0xdeadbeef);
expect(r.readInt32LE()).toBe(-123456);
expect(Array.from(r.readBytes(2))).toEqual([0x01, 0x02]);
const readDate = r.readTimestamp();
expect(readDate.getTime()).toBe(5000);
expect(r.hasMore()).toBe(false);
});
it('BufferReader throws or returns undefined if reading past end', () => {
const r = new BufferReader(new Uint8Array([1, 2]));
r.readByte();
r.readByte();
expect(() => r.readByte()).toThrow();
});
it('BufferWriter handles multiple writeBytes calls', () => {
const w = new BufferWriter();
w.writeBytes(new Uint8Array([1, 2]));
w.writeBytes(new Uint8Array([3, 4]));
expect(Array.from(w.toBytes())).toEqual([1, 2, 3, 4]);
});
it('encodedStringToBytes decodes raw string', () => {
const str = String.fromCharCode(0xde, 0xad, 0xbe, 0xef);
const bytes = new Uint8Array(4);
for (let i = 0; i < 4; i++) bytes[i] = str.charCodeAt(i) & 0xff;
expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('hexToBytes returns different length for wrong-size hex', () => {
expect(() => hexToBytes('deadbe', 4)).toThrow();
});
it('base64ToBytes handles URL-safe base64', () => {
// [0xde, 0xad, 0xbe, 0xef] in URL-safe base64: '3q2-7w=='
const bytes = base64ToBytes('3q2-7w==', 4);
expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
});