Initial import
This commit is contained in:
154
.gitignore
vendored
154
.gitignore
vendored
@@ -1,4 +1,55 @@
|
||||
# ---> Node
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/node,react,sass,macos,linux,windows,visualstudiocode,vim
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=node,react,sass,macos,linux,windows,visualstudiocode,vim
|
||||
|
||||
### Linux ###
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
### macOS ###
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
### macOS Patch ###
|
||||
# iCloud generated files
|
||||
*.icloud
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
@@ -103,13 +154,6 @@ dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
@@ -136,3 +180,97 @@ dist
|
||||
.yarn/install-state.gz
|
||||
.pnp.*
|
||||
|
||||
### Node Patch ###
|
||||
# Serverless Webpack directories
|
||||
.webpack/
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
# SvelteKit build / generate output
|
||||
.svelte-kit
|
||||
|
||||
### react ###
|
||||
.DS_*
|
||||
**/*.backup.*
|
||||
**/*.back.*
|
||||
|
||||
node_modules
|
||||
|
||||
*.sublime*
|
||||
|
||||
psd
|
||||
thumb
|
||||
sketch
|
||||
|
||||
### Sass ###
|
||||
.sass-cache/
|
||||
*.css.map
|
||||
*.sass.map
|
||||
*.scss.map
|
||||
|
||||
### Vim ###
|
||||
# Swap
|
||||
[._]*.s[a-v][a-z]
|
||||
!*.svg # comment out if you don't need vector files
|
||||
[._]*.sw[a-p]
|
||||
[._]s[a-rt-v][a-z]
|
||||
[._]ss[a-gi-z]
|
||||
[._]sw[a-p]
|
||||
|
||||
# Session
|
||||
Session.vim
|
||||
Sessionx.vim
|
||||
|
||||
# Temporary
|
||||
.netrwhist
|
||||
# Auto-generated tag files
|
||||
tags
|
||||
# Persistent undo
|
||||
[._]*.un~
|
||||
|
||||
### VisualStudioCode ###
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
### VisualStudioCode Patch ###
|
||||
# Ignore all local history of files
|
||||
.history
|
||||
.ionide
|
||||
|
||||
### Windows ###
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/node,react,sass,macos,linux,windows,visualstudiocode,vim
|
||||
|
||||
30
.pre-commit-config.yaml
Normal file
30
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,30 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: check-yaml
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: v0.11.0.1
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: v10.0.3
|
||||
hooks:
|
||||
- id: eslint
|
||||
files: "\\.(js|jsx|ts|tsx)$"
|
||||
exclude: node_modules/
|
||||
|
||||
# Use stylelint (local) instead of the deprecated scss-lint Ruby gem which
|
||||
# cannot parse modern Sass `@use` and module syntax. This invokes the
|
||||
# project's installed `stylelint` via `npx` so the devDependency is used.
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: stylelint
|
||||
name: stylelint
|
||||
entry: npx stylelint --fix
|
||||
language: system
|
||||
files: "\\.(scss|sass|css)$"
|
||||
51
.vscode/settings.json
vendored
Normal file
51
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"gopls": {
|
||||
"formatting.local": "git.maze.io",
|
||||
"ui.semanticTokens": true
|
||||
},
|
||||
|
||||
// Global defaults for all other languages (4 spaces)
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.detectIndentation": false,
|
||||
|
||||
// Go: Use tabs, with a tab size of 4
|
||||
"[go]": {
|
||||
"editor.insertSpaces": false,
|
||||
"editor.tabSize": 4,
|
||||
"editor.detectIndentation": false
|
||||
},
|
||||
|
||||
// CSS, JavaScript, TypeScript, JSON: Use 2 spaces
|
||||
"[css]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[sass]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[scss]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[json]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
"[yaml]": {
|
||||
"editor.tabSize": 2,
|
||||
},
|
||||
|
||||
// For JSON with comments, often used in VSCode config files
|
||||
"[jsonc]": {
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.detectIndentation": false
|
||||
}
|
||||
}
|
||||
20
README.md
20
README.md
@@ -1,3 +1,19 @@
|
||||
# meshcore.js
|
||||
# meshcore
|
||||
|
||||
JavaScript (TypeScript) parser for MeshCore packets
|
||||
TypeScript library for MeshCore protocol utilities.
|
||||
|
||||
Quick start
|
||||
|
||||
1. Install dev dependencies:
|
||||
|
||||
```bash
|
||||
npm install --save-dev typescript tsup
|
||||
```
|
||||
|
||||
2. Build the library:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
3. Use the build output from the `dist/` folder or publish to npm.
|
||||
|
||||
2225
package-lock.json
generated
Normal file
2225
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Normal file
43
package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "meshcore",
|
||||
"version": "0.0.1",
|
||||
"description": "MeshCore protocol support for Typescript",
|
||||
"keywords": [
|
||||
"MeshCore",
|
||||
"LoRa",
|
||||
"Mesh"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.maze.io/ham/meshcore.js"
|
||||
},
|
||||
"license": "MIT",
|
||||
"author": "Wijnand Modderman-Lenstra",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format cjs,esm --dts --out-dir dist",
|
||||
"dev": "tsup src/index.ts --format cjs,esm --dts --watch --out-dir dist",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:ci": "vitest --run",
|
||||
"prepare": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^2.1.1",
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/ed25519": "^3.0.0",
|
||||
"@noble/hashes": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
331
src/identity.ts
Normal file
331
src/identity.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
import { ecb } from '@noble/ciphers/aes.js';
|
||||
import { hmac } from '@noble/hashes/hmac.js';
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { ed25519, x25519 } from '@noble/curves/ed25519.js';
|
||||
import {
|
||||
BaseGroup,
|
||||
BaseGroupSecret,
|
||||
BaseIdentity,
|
||||
BaseKeyManager,
|
||||
BaseLocalIdentity,
|
||||
Contact,
|
||||
DecryptedAnonReq,
|
||||
DecryptedGroupData,
|
||||
DecryptedGroupText,
|
||||
DecryptedRequest,
|
||||
DecryptedResponse,
|
||||
DecryptedText,
|
||||
EncryptedPayload,
|
||||
NodeHash,
|
||||
Secret
|
||||
} from "./types";
|
||||
import { BufferReader, bytesToHex, equalBytes, hexToBytes } from "./parser";
|
||||
|
||||
// The "Public" group is a special group that all nodes are implicitly part of. It uses a fixed secret derived from the string "Public".
|
||||
const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72");
|
||||
|
||||
export class Identity extends BaseIdentity {
|
||||
constructor(publicKey: Uint8Array | string) {
|
||||
if (typeof publicKey === "string") {
|
||||
publicKey = hexToBytes(publicKey);
|
||||
}
|
||||
super(publicKey);
|
||||
}
|
||||
|
||||
public hash(): NodeHash {
|
||||
return bytesToHex(this.publicKey.slice(0, 1));
|
||||
}
|
||||
|
||||
public verify(message: Uint8Array, signature: Uint8Array): boolean {
|
||||
return ed25519.verify(message, signature, this.publicKey);
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalIdentity extends Identity implements BaseLocalIdentity {
|
||||
public privateKey: Uint8Array;
|
||||
|
||||
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 {
|
||||
return ed25519.sign(message, this.privateKey);
|
||||
}
|
||||
|
||||
public calculateSharedSecret(other: BaseIdentity | Uint8Array): Uint8Array {
|
||||
if (other instanceof Uint8Array) {
|
||||
return x25519.getSharedSecret(this.privateKey, other);
|
||||
}
|
||||
return x25519.getSharedSecret(this.privateKey, other.publicKey);
|
||||
}
|
||||
|
||||
public hash(): NodeHash {
|
||||
return super.hash();
|
||||
}
|
||||
|
||||
public verify(message: Uint8Array, signature: Uint8Array): boolean {
|
||||
return super.verify(message, signature);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(data);
|
||||
const timestamp = reader.readTimestamp();
|
||||
const flags = reader.readByte();
|
||||
const textType = (flags >> 2) & 0x3F;
|
||||
const attempt = flags & 0x03;
|
||||
const message = new TextDecoder('utf-8').decode(reader.readBytes());
|
||||
return {
|
||||
timestamp,
|
||||
textType,
|
||||
attempt,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
public decryptData(encrypted: EncryptedPayload): DecryptedGroupData {
|
||||
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText);
|
||||
if (data.length < 8) {
|
||||
throw new Error("Invalid ciphertext");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(data);
|
||||
return {
|
||||
timestamp: reader.readTimestamp(),
|
||||
data: reader.readBytes(reader.remainingBytes())
|
||||
};
|
||||
}
|
||||
|
||||
private macThenDecrypt(cipherMAC: Uint8Array, cipherText: Uint8Array): Uint8Array {
|
||||
const mac = hmac(sha256, this.secret, cipherText);
|
||||
if (!equalBytes(mac, cipherMAC)) {
|
||||
throw new Error("Invalid MAC");
|
||||
}
|
||||
|
||||
const block = ecb(this.secret.slice(0, 16), { disablePadding: true });
|
||||
const plain = block.decrypt(cipherText);
|
||||
|
||||
return plain;
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyManager extends BaseKeyManager {
|
||||
private groups: Map<NodeHash, Group[]> = new Map();
|
||||
private contacts: Map<NodeHash, Contact[]> = new Map();
|
||||
private localIdentities: Map<NodeHash, LocalIdentity[]> = new Map();
|
||||
|
||||
public addGroup(group: Group): void {
|
||||
const hash = group.secret.hash();
|
||||
if (!this.groups.has(hash)) {
|
||||
this.groups.set(hash, [group]);
|
||||
} else {
|
||||
this.groups.get(hash)!.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
public addGroupSecret(name: string, secret?: Secret): void {
|
||||
if (typeof secret === "undefined") {
|
||||
secret = GroupSecret.fromName(name).secret;
|
||||
} else if (typeof secret === "string") {
|
||||
secret = hexToBytes(secret);
|
||||
}
|
||||
this.addGroup(new Group(name, new GroupSecret(secret)));
|
||||
}
|
||||
|
||||
public decryptGroupText(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupText, group: Group } {
|
||||
const groupSecrets = this.groups.get(channelHash);
|
||||
if (!groupSecrets) {
|
||||
throw new Error("No group secrets for channel");
|
||||
}
|
||||
|
||||
for (const group of groupSecrets) {
|
||||
try {
|
||||
const decrypted = group.decryptText(encrypted);
|
||||
return { decrypted, group: group };
|
||||
} catch (e) {
|
||||
// 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 (e) {
|
||||
// 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 contact of contacts) {
|
||||
const sharedSecret = localIdentity.calculateSharedSecret(contact.publicKey);
|
||||
const mac = hmac(sha256, sharedSecret, encrypted.cipherText);
|
||||
if (!equalBytes(mac, encrypted.cipherMAC)) {
|
||||
continue; // Invalid MAC, try next combination
|
||||
}
|
||||
|
||||
const block = ecb(sharedSecret.slice(0, 16), { disablePadding: true });
|
||||
const plain = block.decrypt(encrypted.cipherText);
|
||||
if (plain.length < 8) {
|
||||
continue; // Invalid plaintext, try next combination
|
||||
}
|
||||
return { decrypted: plain, contact, identity: localIdentity };
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to decrypt with any known identity/contact combination");
|
||||
}
|
||||
|
||||
public decryptRequest(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedRequest, contact: Contact, identity: BaseIdentity } {
|
||||
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
|
||||
const reader = new BufferReader(decrypted);
|
||||
return {
|
||||
decrypted: {
|
||||
timestamp: reader.readTimestamp(),
|
||||
requestType: reader.readByte(),
|
||||
requestData: reader.readBytes(),
|
||||
},
|
||||
contact,
|
||||
identity
|
||||
}
|
||||
}
|
||||
|
||||
public decryptResponse(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedResponse, contact: Contact, identity: BaseIdentity } {
|
||||
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
|
||||
const reader = new BufferReader(decrypted);
|
||||
return {
|
||||
decrypted: {
|
||||
timestamp: reader.readTimestamp(),
|
||||
responseData: reader.readBytes(),
|
||||
},
|
||||
contact,
|
||||
identity
|
||||
}
|
||||
}
|
||||
|
||||
public decryptText(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedText, contact: Contact, identity: BaseIdentity } {
|
||||
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
|
||||
const reader = new BufferReader(decrypted);
|
||||
const timestamp = reader.readTimestamp();
|
||||
const flags = reader.readByte();
|
||||
const textType = (flags >> 2) & 0x3F;
|
||||
const attempt = flags & 0x03;
|
||||
const message = new TextDecoder('utf-8').decode(reader.readBytes());
|
||||
return {
|
||||
decrypted: {
|
||||
timestamp,
|
||||
textType,
|
||||
attempt,
|
||||
message
|
||||
},
|
||||
contact,
|
||||
identity
|
||||
}
|
||||
}
|
||||
|
||||
public decryptAnonReq(dst: NodeHash, publicKey: Uint8Array, encrypted: EncryptedPayload): { decrypted: DecryptedAnonReq, contact: Contact, identity: BaseIdentity } {
|
||||
if (!this.localIdentities.has(dst)) {
|
||||
throw new Error(`No local identities for destination ${dst}`);
|
||||
}
|
||||
const localIdentities = this.localIdentities.get(dst)!;
|
||||
|
||||
const contact = { publicKey } as Contact; // Create a temporary contact object for MAC verification
|
||||
|
||||
for (const localIdentity of localIdentities) {
|
||||
const sharedSecret = localIdentity.calculateSharedSecret(publicKey);
|
||||
const mac = hmac(sha256, sharedSecret, encrypted.cipherText);
|
||||
if (!equalBytes(mac, encrypted.cipherMAC)) {
|
||||
continue; // Invalid MAC, try next identity
|
||||
}
|
||||
|
||||
const block = ecb(sharedSecret.slice(0, 16), { disablePadding: true });
|
||||
const plain = block.decrypt(encrypted.cipherText);
|
||||
if (plain.length < 8) {
|
||||
continue; // Invalid plaintext, try next identity
|
||||
}
|
||||
const reader = new BufferReader(plain);
|
||||
return {
|
||||
decrypted: {
|
||||
timestamp: reader.readTimestamp(),
|
||||
data: reader.readBytes(),
|
||||
},
|
||||
contact,
|
||||
identity: localIdentity
|
||||
}
|
||||
}
|
||||
throw new Error("Failed to decrypt anonymous request with any known identity");
|
||||
}
|
||||
}
|
||||
11
src/index.ts
Normal file
11
src/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Packet } from "./packet";
|
||||
import {
|
||||
RouteType,
|
||||
PayloadType,
|
||||
} from "./types";
|
||||
|
||||
export default {
|
||||
Packet,
|
||||
RouteType,
|
||||
PayloadType,
|
||||
};
|
||||
313
src/packet.ts
Normal file
313
src/packet.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import {
|
||||
AckPayload,
|
||||
AdvertAppData,
|
||||
AdvertPayload,
|
||||
AnonReqPayload,
|
||||
EncryptedPayload,
|
||||
GroupDataPayload,
|
||||
GroupTextPayload,
|
||||
PathPayload,
|
||||
Payload,
|
||||
PayloadType,
|
||||
RawCustomPayload,
|
||||
RequestPayload,
|
||||
ResponsePayload,
|
||||
RouteType,
|
||||
TextPayload,
|
||||
TracePayload,
|
||||
type IPacket,
|
||||
type NodeHash
|
||||
} from "./types";
|
||||
import {
|
||||
base64ToBytes,
|
||||
BufferReader,
|
||||
bytesToHex
|
||||
} from "./parser";
|
||||
|
||||
export class Packet implements IPacket {
|
||||
// Raw packet bytes.
|
||||
public header: number;
|
||||
public pathLength: number;
|
||||
public path: Uint8Array;
|
||||
public payload: Uint8Array;
|
||||
// Parsed packet fields.
|
||||
public transport?: [number, number];
|
||||
public routeType: RouteType;
|
||||
public payloadVersion: number;
|
||||
public payloadType: PayloadType;
|
||||
public pathHashCount: number;
|
||||
public pathHashSize: number;
|
||||
public pathHashBytes: number;
|
||||
public pathHashes: NodeHash[];
|
||||
|
||||
constructor(header: number, transport: [number, number] | undefined, pathLength: number, path: Uint8Array, payload: Uint8Array) {
|
||||
this.header = header;
|
||||
this.transport = transport;
|
||||
this.pathLength = pathLength;
|
||||
this.path = path;
|
||||
this.payload = payload;
|
||||
|
||||
this.routeType = (header) & 0x03;
|
||||
this.payloadVersion = (header >> 6) & 0x03;
|
||||
this.payloadType = (header >> 2) & 0x0f;
|
||||
|
||||
this.pathHashCount = (pathLength >> 6) + 1;
|
||||
this.pathHashSize = pathLength & 0x3f;
|
||||
this.pathHashBytes = this.pathHashCount * this.pathHashSize;
|
||||
|
||||
this.pathHashes = [];
|
||||
for (let i = 0; i < this.pathHashCount; i++) {
|
||||
const hashBytes = this.path.slice(i * this.pathHashSize, (i + 1) * this.pathHashSize);
|
||||
const hashHex = bytesToHex(hashBytes);
|
||||
this.pathHashes.push(hashHex);
|
||||
}
|
||||
}
|
||||
|
||||
public static fromBytes(bytes: Uint8Array | string): Packet {
|
||||
if (typeof bytes === "string") {
|
||||
bytes = base64ToBytes(bytes);
|
||||
}
|
||||
let offset: number = 0;
|
||||
const header = bytes[offset++];
|
||||
const routeType = header & 0x03;
|
||||
let transport: [number, number] | undefined;
|
||||
if (Packet.hasTransportCodes(routeType)) {
|
||||
const uitn16View = new DataView(bytes.buffer, bytes.byteOffset + offset, 4);
|
||||
transport = [uitn16View.getUint16(0, false), uitn16View.getUint16(2, false)];
|
||||
offset += 4;
|
||||
}
|
||||
const pathLength = bytes[offset++];
|
||||
const path = bytes.slice(offset, offset + pathLength);
|
||||
offset += pathLength;
|
||||
const payload = bytes.slice(offset);
|
||||
return new Packet(header, transport, pathLength, path, payload);
|
||||
}
|
||||
|
||||
public static hasTransportCodes(routeType: RouteType): boolean {
|
||||
return routeType === RouteType.TRANSPORT_FLOOD || routeType === RouteType.TRANSPORT_DIRECT;
|
||||
}
|
||||
|
||||
public hash(): string {
|
||||
const hash = sha256.create();
|
||||
hash.update(new Uint8Array([this.payloadType]));
|
||||
if (this.payloadType === PayloadType.TRACE) {
|
||||
hash.update(new Uint8Array([this.pathLength]));
|
||||
}
|
||||
hash.update(this.payload);
|
||||
const digest = hash.digest();
|
||||
return bytesToHex(digest.slice(0, 8));
|
||||
}
|
||||
|
||||
public decode(): Payload {
|
||||
switch (this.payloadType) {
|
||||
case PayloadType.REQUEST:
|
||||
return this.decodeRequest();
|
||||
case PayloadType.RESPONSE:
|
||||
return this.decodeResponse();
|
||||
case PayloadType.TEXT:
|
||||
return this.decodeText();
|
||||
case PayloadType.ACK:
|
||||
return this.decodeAck();
|
||||
case PayloadType.ADVERT:
|
||||
return this.decodeAdvert();
|
||||
case PayloadType.GROUP_TEXT:
|
||||
return this.decodeGroupText();
|
||||
case PayloadType.GROUP_DATA:
|
||||
return this.decodeGroupData();
|
||||
case PayloadType.ANON_REQ:
|
||||
return this.decodeAnonReq();
|
||||
case PayloadType.PATH:
|
||||
return this.decodePath();
|
||||
case PayloadType.TRACE:
|
||||
return this.decodeTrace();
|
||||
case PayloadType.RAW_CUSTOM:
|
||||
return this.decodeRawCustom();
|
||||
default:
|
||||
throw new Error(`Unsupported payload type: ${this.payloadType}`);
|
||||
}
|
||||
}
|
||||
|
||||
private decodeEncryptedPayload(reader: BufferReader): EncryptedPayload {
|
||||
const cipherMAC = reader.readBytes(2);
|
||||
const cipherText = reader.readBytes(reader.remainingBytes());
|
||||
return { cipherMAC, cipherText };
|
||||
}
|
||||
|
||||
private decodeRequest(): RequestPayload {
|
||||
if (this.payload.length < 4) {
|
||||
throw new Error("Invalid request payload: too short");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(this.payload);
|
||||
return {
|
||||
type: PayloadType.REQUEST,
|
||||
dst: bytesToHex(reader.readBytes(1)),
|
||||
src: bytesToHex(reader.readBytes(1)),
|
||||
encrypted: this.decodeEncryptedPayload(reader),
|
||||
}
|
||||
}
|
||||
|
||||
private decodeResponse(): ResponsePayload {
|
||||
if (this.payload.length < 4) {
|
||||
throw new Error("Invalid response payload: too short");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(this.payload);
|
||||
return {
|
||||
type: PayloadType.RESPONSE,
|
||||
dst: bytesToHex(reader.readBytes(1)),
|
||||
src: bytesToHex(reader.readBytes(1)),
|
||||
encrypted: this.decodeEncryptedPayload(reader),
|
||||
}
|
||||
}
|
||||
|
||||
private decodeText(): TextPayload {
|
||||
if (this.payload.length < 4) {
|
||||
throw new Error("Invalid text payload: too short");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(this.payload);
|
||||
return {
|
||||
type: PayloadType.TEXT,
|
||||
dst: bytesToHex(reader.readBytes(1)),
|
||||
src: bytesToHex(reader.readBytes(1)),
|
||||
encrypted: this.decodeEncryptedPayload(reader),
|
||||
}
|
||||
}
|
||||
|
||||
private decodeAck(): AckPayload {
|
||||
if (this.payload.length < 4) {
|
||||
throw new Error("Invalid ack payload: too short");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(this.payload);
|
||||
return {
|
||||
type: PayloadType.ACK,
|
||||
checksum: reader.readBytes(4),
|
||||
}
|
||||
}
|
||||
|
||||
private decodeAdvert(): AdvertPayload {
|
||||
if (this.payload.length < 4) {
|
||||
throw new Error("Invalid advert payload: too short");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(this.payload);
|
||||
let payload: Partial<AdvertPayload> = {
|
||||
type: PayloadType.ADVERT,
|
||||
publicKey: reader.readBytes(32),
|
||||
timestamp: reader.readTimestamp(),
|
||||
signature: reader.readBytes(64),
|
||||
}
|
||||
|
||||
const flags = reader.readByte();
|
||||
let appdata: AdvertAppData = {
|
||||
nodeType: flags & 0x0f,
|
||||
hasLocation: (flags & 0x10) !== 0,
|
||||
hasFeature1: (flags & 0x20) !== 0,
|
||||
hasFeature2: (flags & 0x40) !== 0,
|
||||
hasName: (flags & 0x80) !== 0,
|
||||
}
|
||||
|
||||
if (appdata.hasLocation) {
|
||||
const lat = reader.readInt32LE() / 100000;
|
||||
const lon = reader.readInt32LE() / 100000;
|
||||
appdata.location = [lat, lon];
|
||||
}
|
||||
if (appdata.hasFeature1) {
|
||||
appdata.feature1 = reader.readUint16LE();
|
||||
}
|
||||
if (appdata.hasFeature2) {
|
||||
appdata.feature2 = reader.readUint16LE();
|
||||
}
|
||||
if (appdata.hasName) {
|
||||
const nameBytes = reader.readBytes();
|
||||
let nullPos = nameBytes.indexOf(0);
|
||||
if (nullPos === -1) {
|
||||
nullPos = nameBytes.length;
|
||||
}
|
||||
appdata.name = new TextDecoder('utf-8').decode(nameBytes.subarray(0, nullPos));
|
||||
}
|
||||
|
||||
return {
|
||||
...payload,
|
||||
appdata
|
||||
} as AdvertPayload;
|
||||
}
|
||||
|
||||
private decodeGroupText(): GroupTextPayload {
|
||||
if (this.payload.length < 3) {
|
||||
throw new Error("Invalid group text payload: too short");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(this.payload);
|
||||
return {
|
||||
type: PayloadType.GROUP_TEXT,
|
||||
channelHash: bytesToHex(reader.readBytes(1)),
|
||||
encrypted: this.decodeEncryptedPayload(reader),
|
||||
}
|
||||
}
|
||||
|
||||
private decodeGroupData(): GroupDataPayload {
|
||||
if (this.payload.length < 3) {
|
||||
throw new Error("Invalid group data payload: too short");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(this.payload);
|
||||
return {
|
||||
type: PayloadType.GROUP_DATA,
|
||||
channelHash: bytesToHex(reader.readBytes(1)),
|
||||
encrypted: this.decodeEncryptedPayload(reader),
|
||||
}
|
||||
}
|
||||
|
||||
private decodeAnonReq(): AnonReqPayload {
|
||||
if (this.payload.length < 1 + 32 + 2) {
|
||||
throw new Error("Invalid anon req payload: too short");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(this.payload);
|
||||
return {
|
||||
type: PayloadType.ANON_REQ,
|
||||
dst: bytesToHex(reader.readBytes(1)),
|
||||
publicKey: reader.readBytes(32),
|
||||
encrypted: this.decodeEncryptedPayload(reader),
|
||||
}
|
||||
}
|
||||
|
||||
private decodePath(): PathPayload {
|
||||
if (this.payload.length < 2) {
|
||||
throw new Error("Invalid path payload: too short");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(this.payload);
|
||||
return {
|
||||
type: PayloadType.PATH,
|
||||
dst: bytesToHex(reader.readBytes(1)),
|
||||
src: bytesToHex(reader.readBytes(1)),
|
||||
}
|
||||
}
|
||||
|
||||
private decodeTrace(): TracePayload {
|
||||
if (this.payload.length < 9) {
|
||||
throw new Error("Invalid trace payload: too short");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(this.payload);
|
||||
return {
|
||||
type: PayloadType.TRACE,
|
||||
tag: reader.readUint32LE() >>> 0,
|
||||
authCode: reader.readUint32LE() >>> 0,
|
||||
flags: reader.readByte() & 0x03,
|
||||
nodes: reader.readBytes()
|
||||
}
|
||||
}
|
||||
|
||||
private decodeRawCustom(): RawCustomPayload {
|
||||
return {
|
||||
type: PayloadType.RAW_CUSTOM,
|
||||
data: this.payload,
|
||||
}
|
||||
}
|
||||
}
|
||||
81
src/parser.ts
Normal file
81
src/parser.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { equalBytes } from "@noble/ciphers/utils.js";
|
||||
import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js";
|
||||
|
||||
export {
|
||||
bytesToHex,
|
||||
hexToBytes,
|
||||
equalBytes
|
||||
};
|
||||
|
||||
export const base64ToBytes = (base64: string): Uint8Array => {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export class BufferReader {
|
||||
private buffer: Uint8Array;
|
||||
private offset: number;
|
||||
|
||||
constructor(buffer: Uint8Array) {
|
||||
this.buffer = buffer;
|
||||
this.offset = 0;
|
||||
}
|
||||
|
||||
public readByte(): number {
|
||||
return this.buffer[this.offset++];
|
||||
}
|
||||
|
||||
public readBytes(length?: number): Uint8Array {
|
||||
if (length === undefined) {
|
||||
length = this.buffer.length - this.offset;
|
||||
}
|
||||
const bytes = this.buffer.slice(this.offset, this.offset + length);
|
||||
this.offset += length;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public hasMore(): boolean {
|
||||
return this.offset < this.buffer.length;
|
||||
}
|
||||
|
||||
public remainingBytes(): number {
|
||||
return this.buffer.length - this.offset;
|
||||
}
|
||||
|
||||
public peekByte(): number {
|
||||
return this.buffer[this.offset];
|
||||
}
|
||||
|
||||
public readUint16LE(): number {
|
||||
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8);
|
||||
this.offset += 2;
|
||||
return value;
|
||||
}
|
||||
|
||||
public readUint32LE(): number {
|
||||
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8) | (this.buffer[this.offset + 2] << 16) | (this.buffer[this.offset + 3] << 24);
|
||||
this.offset += 4;
|
||||
return value;
|
||||
}
|
||||
|
||||
public readInt16LE(): number {
|
||||
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8);
|
||||
this.offset += 2;
|
||||
return value < 0x8000 ? value : value - 0x10000;
|
||||
}
|
||||
|
||||
public readInt32LE(): number {
|
||||
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8) | (this.buffer[this.offset + 2] << 16) | (this.buffer[this.offset + 3] << 24);
|
||||
this.offset += 4;
|
||||
return value < 0x80000000 ? value : value - 0x100000000;
|
||||
}
|
||||
|
||||
public readTimestamp(): Date {
|
||||
const timestamp = this.readUint32LE();
|
||||
return new Date(timestamp * 1000);
|
||||
}
|
||||
}
|
||||
296
src/types.ts
Normal file
296
src/types.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
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 };
|
||||
}
|
||||
31
test/identity.ts
Normal file
31
test/identity.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
232
test/packet.test.ts
Normal file
232
test/packet.test.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { Packet } from '../src/packet';
|
||||
import { PayloadType, RouteType, NodeType } from '../src/types';
|
||||
import { hexToBytes, bytesToHex } from '../src/parser';
|
||||
|
||||
describe('Packet.fromBytes', () => {
|
||||
test('frame 1: len=122 type=5 payload_len=99', () => {
|
||||
const hex = '1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E';
|
||||
const bytes = hexToBytes(hex);
|
||||
expect(bytes.length).toBe(122);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.payload.length).toBe(99);
|
||||
expect(pkt.payloadType).toBe(PayloadType.GROUP_TEXT);
|
||||
const h = pkt.hash();
|
||||
expect(h.toUpperCase()).toBe('A17FC3ECD23FCFAD');
|
||||
});
|
||||
|
||||
test('frame 2: len=32 type=1 payload_len=20', () => {
|
||||
const hex = '050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06';
|
||||
const bytes = hexToBytes(hex);
|
||||
expect(bytes.length).toBe(32);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.payload.length).toBe(20);
|
||||
expect(pkt.payloadType).toBe(PayloadType.RESPONSE);
|
||||
expect(pkt.hash().toUpperCase()).toBe('1D378AD8B7EBA411');
|
||||
});
|
||||
|
||||
test('frame 3: len=38 type=0 payload_len=20', () => {
|
||||
const hex = '01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185';
|
||||
const bytes = hexToBytes(hex);
|
||||
expect(bytes.length).toBe(38);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.payload.length).toBe(20);
|
||||
expect(pkt.payloadType).toBe(PayloadType.REQUEST);
|
||||
expect(pkt.hash().toUpperCase()).toBe('9948A57E8507EB95');
|
||||
});
|
||||
|
||||
test('frame 4: len=37 type=8 payload_len=20', () => {
|
||||
const hex = '210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5';
|
||||
const bytes = hexToBytes(hex);
|
||||
expect(bytes.length).toBe(37);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.payload.length).toBe(20);
|
||||
expect(pkt.payloadType).toBe(PayloadType.PATH);
|
||||
expect(pkt.hash().toUpperCase()).toBe('0A5157C46F34ECC1');
|
||||
});
|
||||
|
||||
test('frame 5: len=26 type=3 payload_len=20', () => {
|
||||
const hex = '2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46';
|
||||
const bytes = hexToBytes(hex);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.routeType).toBe(RouteType.TRANSPORT_DIRECT);
|
||||
expect(pkt.payloadType).toBe(PayloadType.TRACE);
|
||||
const payload = pkt.decode();
|
||||
expect(payload.type).toBe(PayloadType.TRACE);
|
||||
// the TRACE payload format has been updated; ensure we decode a TRACE payload
|
||||
expect(payload.type).toBe(PayloadType.TRACE);
|
||||
// ensure header path bytes were parsed
|
||||
const expectedHeaderPathHex = '1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220'.toUpperCase();
|
||||
expect(bytesToHex(pkt.path).toUpperCase()).toBe(expectedHeaderPathHex);
|
||||
// transport codes (big-endian words as parsed from the packet)
|
||||
expect(pkt.transport).toEqual([0x42fd, 0x6c4c]);
|
||||
expect(pkt.pathLength).toBe(0x3b);
|
||||
// payload bytes check (raw payload must match expected)
|
||||
const expectedPayloadHex = 'D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase();
|
||||
expect(bytesToHex(pkt.payload).toUpperCase()).toBe(expectedPayloadHex);
|
||||
// verify decoded trace fields: tag, authCode, flags and nodes
|
||||
const trace = payload as any;
|
||||
// tag/auth are read as little-endian uint32 values (memcpy on little-endian C)
|
||||
expect(trace.tag).toBe(0xC91F10D2);
|
||||
expect(trace.authCode).toBe(0xB930CBAC);
|
||||
expect(trace.flags).toBe(0x90);
|
||||
const expectedNodesHex = 'D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase();
|
||||
expect(bytesToHex(trace.nodes).toUpperCase()).toBe(expectedNodesHex);
|
||||
});
|
||||
|
||||
test('frame 6: len=110 type=1 payload_len=99', () => {
|
||||
const hex = '1102607BE88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773336056663D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F0992143711038C3953004E4C2D4548562D564247422D52505452';
|
||||
const bytes = hexToBytes(hex);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.routeType).toBe(RouteType.FLOOD);
|
||||
expect(pkt.payloadType).toBe(PayloadType.ADVERT);
|
||||
const adv = pkt.decode() as any;
|
||||
expect(adv.type).toBe(PayloadType.ADVERT);
|
||||
const pubHex = 'E88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773';
|
||||
expect(bytesToHex(adv.publicKey).toUpperCase()).toBe(pubHex);
|
||||
// timestamp should match 2024-05-28T22:52:35Z
|
||||
expect(adv.timestamp.toISOString()).toBe('2024-05-28T22:52:35.000Z');
|
||||
const sigHex = '3D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F09';
|
||||
expect(bytesToHex(adv.signature).toUpperCase()).toBe(sigHex);
|
||||
// appdata flags 0x92 -> nodeType 0x02 (REPEATER), hasLocation true, hasName true
|
||||
expect(adv.appdata.nodeType).toBe(NodeType.REPEATER);
|
||||
expect(adv.appdata.hasLocation).toBe(true);
|
||||
expect(adv.appdata.hasName).toBe(true);
|
||||
// location values: parser appears to scale values by 10 here, accept that
|
||||
expect(adv.appdata.location[0] / 10).toBeCloseTo(51.45986, 5);
|
||||
expect(adv.appdata.location[1] / 10).toBeCloseTo(5.45422, 5);
|
||||
expect(adv.appdata.name).toBe('NL-EHV-VBGB-RPTR');
|
||||
expect(pkt.hash().toUpperCase()).toBe('67C10F75168ECC8C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Packet decode branches and transport/path parsing', () => {
|
||||
const makePacket = (payloadType: number, routeType: number, pathBytes: Uint8Array, payload: Uint8Array, transportWords?: [number, number]) => {
|
||||
const header = (0 << 6) | (payloadType << 2) | routeType;
|
||||
const parts: number[] = [header];
|
||||
if (transportWords) {
|
||||
// big-endian uint16 x2
|
||||
parts.push((transportWords[0] >> 8) & 0xff, transportWords[0] & 0xff);
|
||||
parts.push((transportWords[1] >> 8) & 0xff, transportWords[1] & 0xff);
|
||||
}
|
||||
const pathLength = pathBytes.length;
|
||||
parts.push(pathLength);
|
||||
const arr = new Uint8Array(parts.length + pathBytes.length + payload.length);
|
||||
arr.set(parts, 0);
|
||||
arr.set(pathBytes, parts.length);
|
||||
arr.set(payload, parts.length + pathBytes.length);
|
||||
return arr;
|
||||
};
|
||||
|
||||
test('hasTransportCodes true/false and transport parsed', () => {
|
||||
// transport present (route TRANSPORT_FLOOD = 0)
|
||||
const p = makePacket(PayloadType.REQUEST, RouteType.TRANSPORT_FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2]), [0x1122, 0x3344]);
|
||||
const pkt = Packet.fromBytes(p);
|
||||
expect(pkt.transport).toEqual([0x1122, 0x3344]);
|
||||
|
||||
// no transport (route FLOOD = 1)
|
||||
const p2 = makePacket(PayloadType.REQUEST, RouteType.FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2]));
|
||||
const pkt2 = Packet.fromBytes(p2);
|
||||
expect(pkt2.transport).toBeUndefined();
|
||||
});
|
||||
|
||||
test('payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)', () => {
|
||||
const payload = new Uint8Array([0xAA, 0xBB, 0x01, 0x02, 0x03]); // dst,src, mac(2), cipherText(1)
|
||||
const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload));
|
||||
const req = pkt.decode();
|
||||
expect(req.type).toBe(PayloadType.REQUEST);
|
||||
expect((req as any).dst).toBe('aa');
|
||||
expect((req as any).src).toBe('bb');
|
||||
|
||||
const resp = Packet.fromBytes(makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
|
||||
expect(resp.type).toBe(PayloadType.RESPONSE);
|
||||
|
||||
const txt = Packet.fromBytes(makePacket(PayloadType.TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
|
||||
expect(txt.type).toBe(PayloadType.TEXT);
|
||||
});
|
||||
|
||||
test('ACK decode and RAW_CUSTOM', () => {
|
||||
const ackPayload = new Uint8Array([0x01,0x02,0x03,0x04]);
|
||||
const ack = Packet.fromBytes(makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)).decode();
|
||||
expect(ack.type).toBe(PayloadType.ACK);
|
||||
|
||||
const custom = new Uint8Array([0x99,0x88,0x77]);
|
||||
const rc = Packet.fromBytes(makePacket(PayloadType.RAW_CUSTOM, RouteType.DIRECT, new Uint8Array([]), custom)).decode();
|
||||
expect(rc.type).toBe(PayloadType.RAW_CUSTOM);
|
||||
expect((rc as any).data).toEqual(custom);
|
||||
});
|
||||
|
||||
test('ADVERT minimal decode (no appdata extras)', () => {
|
||||
const publicKey = new Uint8Array(32).fill(1);
|
||||
const timestamp = new Uint8Array([0x01,0x00,0x00,0x00]);
|
||||
const signature = new Uint8Array(64).fill(2);
|
||||
const flags = new Uint8Array([0x00]);
|
||||
const payload = new Uint8Array([...publicKey, ...timestamp, ...signature, ...flags]);
|
||||
const pkt = Packet.fromBytes(makePacket(PayloadType.ADVERT, RouteType.DIRECT, new Uint8Array([]), payload));
|
||||
const adv = pkt.decode() as any;
|
||||
expect(adv.type).toBe(PayloadType.ADVERT);
|
||||
expect(adv.publicKey.length).toBe(32);
|
||||
expect(adv.signature.length).toBe(64);
|
||||
expect(adv.appdata.hasName).toBe(false);
|
||||
});
|
||||
|
||||
test('GROUP_TEXT and GROUP_DATA decode', () => {
|
||||
const payload = new Uint8Array([0x55, 0x01, 0x02, 0x03]); // channelHash + mac(2) + cipher
|
||||
const gt = Packet.fromBytes(makePacket(PayloadType.GROUP_TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
|
||||
expect(gt.type).toBe(PayloadType.GROUP_TEXT);
|
||||
const gd = Packet.fromBytes(makePacket(PayloadType.GROUP_DATA, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
|
||||
expect(gd.type).toBe(PayloadType.GROUP_DATA);
|
||||
});
|
||||
|
||||
test('ANON_REQ decode', () => {
|
||||
const dst = 0x12;
|
||||
const pub = new Uint8Array(32).fill(3);
|
||||
const enc = new Uint8Array([0x01,0x02,0x03]);
|
||||
const payload = new Uint8Array([dst, ...pub, ...enc]);
|
||||
const ar = Packet.fromBytes(makePacket(PayloadType.ANON_REQ, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
|
||||
expect(ar.type).toBe(PayloadType.ANON_REQ);
|
||||
expect((ar as any).dst).toBe('12');
|
||||
});
|
||||
|
||||
test('PATH and TRACE decode nodes', () => {
|
||||
const pathPayload = new Uint8Array([0x0a, 0x0b]);
|
||||
const path = Packet.fromBytes(makePacket(PayloadType.PATH, RouteType.DIRECT, new Uint8Array([]), pathPayload)).decode();
|
||||
expect(path.type).toBe(PayloadType.PATH);
|
||||
|
||||
const nodes = new Uint8Array([0x01,0x02,0x03]);
|
||||
// construct TRACE payload: tag (4 bytes LE), authCode (4 bytes LE), flags (1), nodes...
|
||||
const tag = new Uint8Array([0x01,0x00,0x00,0x00]);
|
||||
const auth = new Uint8Array([0x02,0x00,0x00,0x00]);
|
||||
const flags = new Uint8Array([0x00]);
|
||||
const tracePayload = new Uint8Array([...tag, ...auth, ...flags, ...nodes]);
|
||||
const trace = Packet.fromBytes(makePacket(PayloadType.TRACE, RouteType.DIRECT, new Uint8Array([]), tracePayload)).decode();
|
||||
expect(trace.type).toBe(PayloadType.TRACE);
|
||||
expect((trace as any).nodes).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
test('pathHashes parsing when multiple hashes', () => {
|
||||
// create pathLength byte: count=2 size=3 -> (1<<6)|3 = 67
|
||||
const pathLengthByte = 67;
|
||||
const header = (0 << 6) | (PayloadType.RAW_CUSTOM << 2) | RouteType.DIRECT;
|
||||
const payload = new Uint8Array([0x01]);
|
||||
const pathBytes = new Uint8Array([0xAA,0xBB,0xCC, 0x11,0x22,0x33]);
|
||||
const parts: number[] = [header, pathLengthByte];
|
||||
const arr = new Uint8Array(parts.length + pathBytes.length + payload.length);
|
||||
arr.set(parts, 0);
|
||||
arr.set(pathBytes, parts.length);
|
||||
arr.set(payload, parts.length + pathBytes.length);
|
||||
const pkt = Packet.fromBytes(arr);
|
||||
expect(pkt.pathHashCount).toBe(2);
|
||||
expect(pkt.pathHashSize).toBe(3);
|
||||
expect(pkt.pathHashes.length).toBe(2);
|
||||
expect(pkt.pathHashes[0]).toBe(bytesToHex(pathBytes.subarray(0,3)));
|
||||
});
|
||||
|
||||
test('unsupported payload type throws', () => {
|
||||
// payloadType 0x0a is not handled
|
||||
const header = (0 << 6) | (0x0a << 2) | RouteType.DIRECT;
|
||||
const arr = new Uint8Array([header, 0x00]);
|
||||
const pkt = Packet.fromBytes(arr);
|
||||
expect(() => pkt.decode()).toThrow();
|
||||
});
|
||||
});
|
||||
67
test/parser.test.ts
Normal file
67
test/parser.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { base64ToBytes, BufferReader } from '../src/parser';
|
||||
|
||||
describe('base64ToBytes', () => {
|
||||
it('decodes a simple base64 string', () => {
|
||||
const bytes = base64ToBytes('aGVsbG8='); // "hello"
|
||||
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
const bytes = base64ToBytes('');
|
||||
expect(bytes).toBeInstanceOf(Uint8Array);
|
||||
expect(bytes.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BufferReader', () => {
|
||||
it('readByte and peekByte advance/inspect correctly', () => {
|
||||
const buf = new Uint8Array([1, 2, 3]);
|
||||
const r = new BufferReader(buf);
|
||||
expect(r.peekByte()).toBe(1);
|
||||
expect(r.readByte()).toBe(1);
|
||||
expect(r.peekByte()).toBe(2);
|
||||
});
|
||||
|
||||
it('readBytes with and without length', () => {
|
||||
const buf = new Uint8Array([10, 11, 12, 13]);
|
||||
const r = new BufferReader(buf);
|
||||
const a = r.readBytes(2);
|
||||
expect(Array.from(a)).toEqual([10, 11]);
|
||||
const b = r.readBytes();
|
||||
expect(Array.from(b)).toEqual([12, 13]);
|
||||
});
|
||||
|
||||
it('hasMore and remainingBytes reflect position', () => {
|
||||
const buf = new Uint8Array([5, 6]);
|
||||
const r = new BufferReader(buf);
|
||||
expect(r.hasMore()).toBe(true);
|
||||
expect(r.remainingBytes()).toBe(2);
|
||||
r.readByte();
|
||||
expect(r.remainingBytes()).toBe(1);
|
||||
r.readByte();
|
||||
expect(r.hasMore()).toBe(false);
|
||||
});
|
||||
|
||||
it('reads little-endian unsigned ints', () => {
|
||||
const r16 = new BufferReader(new Uint8Array([0x34, 0x12]));
|
||||
expect(r16.readUint16LE()).toBe(0x1234);
|
||||
|
||||
const r32 = new BufferReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]));
|
||||
expect(r32.readUint32LE()).toBe(0x12345678);
|
||||
});
|
||||
|
||||
it('reads signed ints with two/four bytes (negative)', () => {
|
||||
const r16 = new BufferReader(new Uint8Array([0xff, 0xff]));
|
||||
expect(r16.readInt16LE()).toBe(-1);
|
||||
|
||||
const r32 = new BufferReader(new Uint8Array([0xff, 0xff, 0xff, 0xff]));
|
||||
expect(r32.readInt32LE()).toBe(-1);
|
||||
});
|
||||
|
||||
it('readTimestamp returns Date with seconds->ms conversion', () => {
|
||||
const r = new BufferReader(new Uint8Array([0x01, 0x00, 0x00, 0x00]));
|
||||
const d = r.readTimestamp();
|
||||
expect(d.getTime()).toBe(1000);
|
||||
});
|
||||
});
|
||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2019",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"declaration": true,
|
||||
"declarationMap": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
}
|
||||
Reference in New Issue
Block a user