13 Commits

Author SHA1 Message Date
f5fa45d11c Version 1.2.0 2026-03-11 13:24:09 +01:00
7c2cc0e0f6 Implemented Packet structure decoding 2026-03-11 13:23:52 +01:00
dee5e1cb9e Fixed incorrect path hash parsing 2026-03-10 18:54:53 +01:00
e388a55575 Updated README 2026-03-10 18:44:56 +01:00
c52ec1dc43 Updated package name to include org 2026-03-10 18:39:32 +01:00
9b2d4d1096 Version 1.1.1 2026-03-10 18:22:04 +01:00
fae58c223b Export everything from types 2026-03-10 18:21:32 +01:00
7e5a8c74a5 Move tests to their own folder 2026-03-10 18:13:01 +01:00
df09c952de Move tests to their own folder 2026-03-10 18:12:42 +01:00
7eca26a2b2 Export everything and all types 2026-03-10 18:04:56 +01:00
218042f552 NodeHash is a number 2026-03-10 17:55:37 +01:00
a30448c130 We can not sensibly parse both hex and base64, assume all input is hex 2026-03-10 17:48:51 +01:00
7a2522cf32 Refactoring 2026-03-10 17:35:15 +01:00
18 changed files with 2196 additions and 686 deletions

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "meshcore", "name": "@hamradio/meshcore",
"version": "1.0.0", "version": "1.2.0",
"description": "MeshCore protocol support for Typescript", "description": "MeshCore protocol support for Typescript",
"keywords": [ "keywords": [
"MeshCore", "MeshCore",
@@ -9,7 +9,7 @@
], ],
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git+https://git.maze.io/ham/meshcore.js" "url": "https://git.maze.io/ham/meshcore.js"
}, },
"license": "MIT", "license": "MIT",
"author": "Wijnand Modderman-Lenstra", "author": "Wijnand Modderman-Lenstra",
@@ -21,9 +21,9 @@
], ],
"exports": { "exports": {
".": { ".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs", "import": "./dist/index.mjs",
"require": "./dist/index.js", "require": "./dist/index.js"
"types": "./dist/index.d.ts"
} }
}, },
"scripts": { "scripts": {

231
src/crypto.ts Normal file
View File

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

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

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

View File

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

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

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

View File

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

View File

@@ -2,6 +2,7 @@ import { sha256 } from "@noble/hashes/sha2.js";
import { import {
AckPayload, AckPayload,
AdvertAppData, AdvertAppData,
AdvertFlag,
AdvertPayload, AdvertPayload,
AnonReqPayload, AnonReqPayload,
EncryptedPayload, EncryptedPayload,
@@ -17,13 +18,14 @@ import {
TextPayload, TextPayload,
TracePayload, TracePayload,
type IPacket, type IPacket,
type NodeHash } from "./packet.types";
} from "./types"; import { NodeHash } from "./identity.types";
import { import {
base64ToBytes, base64ToBytes,
BufferReader, BufferReader,
bytesToHex bytesToHex
} from "./parser"; } from "./parser";
import { FieldType, PacketSegment, PacketStructure } from "./parser.types";
export class Packet implements IPacket { export class Packet implements IPacket {
// Raw packet bytes. // Raw packet bytes.
@@ -39,7 +41,9 @@ export class Packet implements IPacket {
public pathHashCount: number; public pathHashCount: number;
public pathHashSize: number; public pathHashSize: number;
public pathHashBytes: number; public pathHashBytes: number;
public pathHashes: NodeHash[]; public pathHashes: string[];
// Parsed packet segments.
public structure?: PacketStructure | 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;
@@ -52,13 +56,13 @@ export class Packet implements IPacket {
this.payloadVersion = (header >> 6) & 0x03; this.payloadVersion = (header >> 6) & 0x03;
this.payloadType = (header >> 2) & 0x0f; this.payloadType = (header >> 2) & 0x0f;
this.pathHashCount = (pathLength >> 6) + 1; this.pathHashSize = (pathLength >> 6) + 1;
this.pathHashSize = pathLength & 0x3f; this.pathHashCount = pathLength & 0x3f;
this.pathHashBytes = this.pathHashCount * this.pathHashSize; this.pathHashBytes = this.pathHashCount * this.pathHashSize;
this.pathHashes = []; this.pathHashes = [];
for (let i = 0; i < this.pathHashCount; i++) { for (let i = 0; i < this.pathHashBytes; i += this.pathHashSize) {
const hashBytes = this.path.slice(i * this.pathHashSize, (i + 1) * this.pathHashSize); const hashBytes = this.path.slice(i, i + this.pathHashSize);
const hashHex = bytesToHex(hashBytes); const hashHex = bytesToHex(hashBytes);
this.pathHashes.push(hashHex); this.pathHashes.push(hashHex);
} }
@@ -99,33 +103,117 @@ 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, this.pathLength, ...this.path]), fields: [
/* Header flags */
{
name: "flags",
type: FieldType.BITS,
size: 1,
bits: [
{ name: "route type", size: 2 },
{ name: "payload version", size: 2 },
{ name: "payload type", size: 4 },
]
},
/* Transport codes */
...(Packet.hasTransportCodes(this.routeType) ? [
{
name: "transport code 1",
type: FieldType.UINT16_BE,
size: 2
},
{
name: "transport code 2",
type: FieldType.UINT16_BE,
size: 2
},
] : []),
/* Path length and hashes */
{
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: PacketStructure } {
let result: Payload | { payload: Payload, segment: PacketSegment };
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}`);
} }
console.log('packet decode with structure:', typeof withStructure, withStructure, { result });
if (typeof withStructure === "boolean" && withStructure && "segment" in result && "payload" in result) {
this.ensureStructure();
const structure = [ ...this.structure!, result.segment ];
return { payload: result.payload, structure };
}
return result as Payload;
} }
private decodeEncryptedPayload(reader: BufferReader): EncryptedPayload { private decodeEncryptedPayload(reader: BufferReader): EncryptedPayload {
@@ -134,61 +222,128 @@ export class Packet implements IPacket {
return { cipherMAC, cipherText }; return { cipherMAC, cipherText };
} }
private decodeRequest(): RequestPayload { private decodeRequest(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } {
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 = new BufferReader(this.payload);
return { const dst = reader.readByte();
const src = reader.readByte();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: RequestPayload = {
type: PayloadType.REQUEST, type: PayloadType.REQUEST,
dst: bytesToHex(reader.readBytes(1)), dst,
src: bytesToHex(reader.readBytes(1)), src,
encrypted: this.decodeEncryptedPayload(reader), encrypted,
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "request payload",
data: this.payload,
fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: src },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText }
]
} }
return { payload, segment };
}
return payload;
} }
private decodeResponse(): ResponsePayload { private decodeResponse(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } {
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 = new BufferReader(this.payload);
return { const dst = reader.readByte();
const src = reader.readByte();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: ResponsePayload = {
type: PayloadType.RESPONSE, type: PayloadType.RESPONSE,
dst: bytesToHex(reader.readBytes(1)), dst,
src: bytesToHex(reader.readBytes(1)), src,
encrypted: this.decodeEncryptedPayload(reader), encrypted,
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "response payload",
data: this.payload,
fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: src },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText }
]
};
return { payload, segment };
} }
return payload;
} }
private decodeText(): TextPayload { private decodeText(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } {
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 = new BufferReader(this.payload);
return { const dst = reader.readByte();
const src = reader.readByte();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: TextPayload = {
type: PayloadType.TEXT, type: PayloadType.TEXT,
dst: bytesToHex(reader.readBytes(1)), dst,
src: bytesToHex(reader.readBytes(1)), src,
encrypted: this.decodeEncryptedPayload(reader), encrypted,
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "text payload",
data: this.payload,
fields: [
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: dst },
{ name: "source hash", type: FieldType.UINT8, size: 1, value: src },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText }
]
};
return { payload, segment };
} }
return payload;
} }
private decodeAck(): AckPayload { private decodeAck(withSegment?: boolean): Payload | { payload: AckPayload, segment: PacketSegment } {
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 = new BufferReader(this.payload);
return { const checksum = reader.readBytes(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: PacketSegment } {
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");
} }
@@ -201,25 +356,57 @@ export class Packet implements IPacket {
signature: reader.readBytes(64), signature: reader.readBytes(64),
} }
let segment: PacketSegment | 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.readByte();
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: 4, name: "node type" },
{ size: 1, name: "location flag" },
{ size: 1, name: "feature1 flag" },
{ size: 1, name: "feature2 flag" },
{ size: 1, name: "name flag" },
]});
} }
if (appdata.hasLocation) { if (appdata.hasLocation) {
const lat = reader.readInt32LE() / 100000; const lat = reader.readInt32LE() / 100000;
const lon = reader.readInt32LE() / 100000; const lon = reader.readInt32LE() / 100000;
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.readUint16LE();
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.readUint16LE();
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(); const nameBytes = reader.readBytes();
@@ -228,86 +415,175 @@ export class Packet implements IPacket {
nullPos = nameBytes.length; nullPos = nameBytes.length;
} }
appdata.name = new TextDecoder('utf-8').decode(nameBytes.subarray(0, nullPos)); appdata.name = new TextDecoder('utf-8').decode(nameBytes.subarray(0, nullPos));
if (typeof withSegment === "boolean" && withSegment) {
segment!.fields.push({ type: FieldType.C_STRING, name: "name", size: nameBytes.length, value: appdata.name });
}
} }
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: PacketSegment } {
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 = new BufferReader(this.payload);
return { const channelHash = reader.readByte();
const encrypted = this.decodeEncryptedPayload(reader);
const payload: GroupTextPayload = {
type: PayloadType.GROUP_TEXT, type: PayloadType.GROUP_TEXT,
channelHash: bytesToHex(reader.readBytes(1)), channelHash,
encrypted: this.decodeEncryptedPayload(reader), encrypted,
};
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "group text payload",
data: this.payload,
fields: [
{ name: "channel hash", type: FieldType.UINT8, size: 1, value: channelHash },
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC },
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText }
]
};
return { payload, segment };
} }
return payload;
} }
private decodeGroupData(): GroupDataPayload { private decodeGroupData(withSegment?: boolean): Payload | { payload: Payload, segment: PacketSegment } {
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 = new BufferReader(this.payload);
return { const payload: GroupDataPayload = {
type: PayloadType.GROUP_DATA, type: PayloadType.GROUP_DATA,
channelHash: bytesToHex(reader.readBytes(1)), channelHash: reader.readByte(),
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: PacketSegment } {
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 = new BufferReader(this.payload);
return { const payload: AnonReqPayload = {
type: PayloadType.ANON_REQ, type: PayloadType.ANON_REQ,
dst: bytesToHex(reader.readBytes(1)), dst: reader.readByte(),
publicKey: reader.readBytes(32), publicKey: reader.readBytes(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: PacketSegment } {
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 = new BufferReader(this.payload);
return { const payload: PathPayload = {
type: PayloadType.PATH, type: PayloadType.PATH,
dst: bytesToHex(reader.readBytes(1)), dst: reader.readByte(),
src: bytesToHex(reader.readBytes(1)), src: reader.readByte(),
};
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: PacketSegment } {
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 = new BufferReader(this.payload);
return { const payload: TracePayload = {
type: PayloadType.TRACE, type: PayloadType.TRACE,
tag: reader.readUint32LE() >>> 0, tag: reader.readUint32LE() >>> 0,
authCode: reader.readUint32LE() >>> 0, authCode: reader.readUint32LE() >>> 0,
flags: reader.readByte() & 0x03, flags: reader.readByte() & 0x03,
nodes: reader.readBytes() nodes: reader.readBytes()
};
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: PacketSegment } {
return { const payload: RawCustomPayload = {
type: PayloadType.RAW_CUSTOM, type: PayloadType.RAW_CUSTOM,
data: this.payload, data: this.payload,
} };
if (typeof withSegment === "boolean" && withSegment) {
const segment = {
name: "raw custom payload",
data: this.payload,
fields: [
{ name: "data", type: FieldType.BYTES, size: this.payload.length, value: this.payload }
]
};
return { payload, segment };
}
return payload;
} }
} }

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

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

View File

@@ -1,21 +1,41 @@
import { equalBytes } from "@noble/ciphers/utils.js"; import { equalBytes } from "@noble/ciphers/utils.js";
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { bytesToHex, hexToBytes as nobleHexToBytes } from "@noble/hashes/utils.js";
export { export {
bytesToHex, bytesToHex,
hexToBytes,
equalBytes equalBytes
}; };
export const base64ToBytes = (base64: string): Uint8Array => { export const base64ToBytes = (base64: string, size?: number): Uint8Array => {
const binaryString = atob(base64); // 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); const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) { for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(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; 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 { export class BufferReader {
private buffer: Uint8Array; private buffer: Uint8Array;
private offset: number; private offset: number;
@@ -26,6 +46,7 @@ export class BufferReader {
} }
public readByte(): number { public readByte(): number {
if (!this.hasMore()) throw new Error('read past end');
return this.buffer[this.offset++]; return this.buffer[this.offset++];
} }
@@ -33,6 +54,7 @@ export class BufferReader {
if (length === undefined) { if (length === undefined) {
length = this.buffer.length - this.offset; 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); const bytes = this.buffer.slice(this.offset, this.offset + length);
this.offset += length; this.offset += length;
return bytes; return bytes;
@@ -47,31 +69,36 @@ export class BufferReader {
} }
public peekByte(): number { public peekByte(): number {
if (!this.hasMore()) throw new Error('read past end');
return this.buffer[this.offset]; return this.buffer[this.offset];
} }
public readUint16LE(): number { 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); const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8);
this.offset += 2; this.offset += 2;
return value; return value;
} }
public readUint32LE(): number { public readUint32LE(): number {
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8) | (this.buffer[this.offset + 2] << 16) | (this.buffer[this.offset + 3] << 24); 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; this.offset += 4;
return value; return value;
} }
public readInt16LE(): number { 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); const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8);
this.offset += 2; this.offset += 2;
return value < 0x8000 ? value : value - 0x10000; return value < 0x8000 ? value : value - 0x10000;
} }
public readInt32LE(): number { public readInt32LE(): number {
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8) | (this.buffer[this.offset + 2] << 16) | (this.buffer[this.offset + 3] << 24); 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; this.offset += 4;
return value < 0x80000000 ? value : value - 0x100000000; return u < 0x80000000 ? u : u - 0x100000000;
} }
public readTimestamp(): Date { public readTimestamp(): Date {
@@ -79,3 +106,45 @@ export class BufferReader {
return new Date(timestamp * 1000); 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);
}
}

35
src/parser.types.ts Normal file
View File

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

View File

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

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

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

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

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

View File

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

View File

@@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { Packet } from '../src/packet'; import { Packet } from '../src/packet';
import { PayloadType, RouteType, NodeType, TracePayload, AdvertPayload, RequestPayload, TextPayload, ResponsePayload, RawCustomPayload, AnonReqPayload } from '../src/types'; import { PayloadType, RouteType, NodeType, TracePayload, AdvertPayload, RequestPayload, TextPayload, ResponsePayload, RawCustomPayload, AnonReqPayload, Payload, AckPayload, PathPayload, GroupDataPayload, GroupTextPayload } from '../src/packet.types';
import { hexToBytes, bytesToHex } from '../src/parser'; import { hexToBytes, bytesToHex } from '../src/parser';
describe('Packet.fromBytes', () => { describe('Packet.fromBytes', () => {
@@ -51,7 +51,7 @@ describe('Packet.fromBytes', () => {
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);
@@ -93,8 +93,9 @@ describe('Packet.fromBytes', () => {
expect(adv.appdata.hasLocation).toBe(true); expect(adv.appdata.hasLocation).toBe(true);
expect(adv.appdata.hasName).toBe(true); expect(adv.appdata.hasName).toBe(true);
// location values: parser appears to scale values by 10 here, accept that // location values: parser appears to scale values by 10 here, accept that
expect(adv.appdata.location[0] / 10).toBeCloseTo(51.45986, 5); expect(adv.appdata.location).toBeDefined();
expect(adv.appdata.location[1] / 10).toBeCloseTo(5.45422, 5); expect(adv.appdata.location![0] / 10).toBeCloseTo(51.45986, 5);
expect(adv.appdata.location![1] / 10).toBeCloseTo(5.45422, 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');
}); });
@@ -135,8 +136,8 @@ describe('Packet decode branches and transport/path parsing', () => {
const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload)); const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload));
const req = pkt.decode() as RequestPayload; const req = pkt.decode() as RequestPayload;
expect(req.type).toBe(PayloadType.REQUEST); expect(req.type).toBe(PayloadType.REQUEST);
expect(req.dst).toBe('aa'); expect(req.dst).toBe(0xAA);
expect(req.src).toBe('bb'); expect(req.src).toBe(0xBB);
const resp = Packet.fromBytes(makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as ResponsePayload; const resp = Packet.fromBytes(makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as ResponsePayload;
expect(resp.type).toBe(PayloadType.RESPONSE); expect(resp.type).toBe(PayloadType.RESPONSE);
@@ -147,7 +148,7 @@ describe('Packet decode branches and transport/path parsing', () => {
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]);
@@ -172,9 +173,9 @@ describe('Packet decode branches and transport/path parsing', () => {
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);
}); });
@@ -185,12 +186,12 @@ describe('Packet decode branches and transport/path parsing', () => {
const payload = new Uint8Array([dst, ...pub, ...enc]); const payload = new Uint8Array([dst, ...pub, ...enc]);
const ar = Packet.fromBytes(makePacket(PayloadType.ANON_REQ, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as AnonReqPayload; const ar = Packet.fromBytes(makePacket(PayloadType.ANON_REQ, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as AnonReqPayload;
expect(ar.type).toBe(PayloadType.ANON_REQ); expect(ar.type).toBe(PayloadType.ANON_REQ);
expect(ar.dst).toBe('12'); expect(ar.dst).toBe(0x12);
}); });
test('PATH and TRACE decode nodes', () => { test('PATH and TRACE decode nodes', () => {
const pathPayload = new Uint8Array([0x0a, 0x0b]); const pathPayload = new Uint8Array([0x0a, 0x0b]);
const path = Packet.fromBytes(makePacket(PayloadType.PATH, RouteType.DIRECT, new Uint8Array([]), pathPayload)).decode(); const path = Packet.fromBytes(makePacket(PayloadType.PATH, RouteType.DIRECT, new Uint8Array([]), pathPayload)).decode() as PathPayload;
expect(path.type).toBe(PayloadType.PATH); expect(path.type).toBe(PayloadType.PATH);
const nodes = new Uint8Array([0x01,0x02,0x03]); const nodes = new Uint8Array([0x01,0x02,0x03]);
@@ -216,10 +217,10 @@ describe('Packet decode branches and transport/path parsing', () => {
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', () => {
@@ -230,3 +231,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 any).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 any;
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,14 +1,14 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { base64ToBytes, BufferReader } from '../src/parser'; import { base64ToBytes, hexToBytes, BufferReader, BufferWriter } from '../src/parser';
describe('base64ToBytes', () => { describe('base64ToBytes', () => {
it('decodes a simple base64 string', () => { it('decodes a simple base64 string', () => {
const bytes = base64ToBytes('aGVsbG8='); // "hello" const bytes = base64ToBytes('aGVsbG8=', 5); // "hello"
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]); expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
}); });
it('handles empty string', () => { it('handles empty string', () => {
const bytes = base64ToBytes(''); const bytes = base64ToBytes('', 0);
expect(bytes).toBeInstanceOf(Uint8Array); expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(0); expect(bytes.length).toBe(0);
}); });
@@ -65,3 +65,131 @@ describe('BufferReader', () => {
expect(d.getTime()).toBe(1000); 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]);
});
});

View File

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