Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
10c7092313
|
|||
|
4973e1e52c
|
|||
|
f5fa45d11c
|
|||
|
7c2cc0e0f6
|
|||
|
dee5e1cb9e
|
|||
|
e388a55575
|
|||
|
c52ec1dc43
|
|||
|
9b2d4d1096
|
|||
|
fae58c223b
|
|||
|
7e5a8c74a5
|
|||
|
df09c952de
|
|||
|
7eca26a2b2
|
|||
|
218042f552
|
|||
|
a30448c130
|
|||
|
7a2522cf32
|
99
README.md
99
README.md
@@ -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.
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "meshcore",
|
"name": "@hamradio/meshcore",
|
||||||
"version": "1.0.0",
|
"version": "1.2.1",
|
||||||
"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
231
src/crypto.ts
Normal 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
27
src/crypto.types.ts
Normal 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;
|
||||||
|
}
|
||||||
494
src/identity.ts
494
src/identity.ts
@@ -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 hash === "number") {
|
||||||
if (typeof publicKey === "string") {
|
if (hash < 0 || hash > 255) {
|
||||||
publicKey = hexToBytes(publicKey);
|
throw new Error("NodeHash number must be between 0x00 and 0xFF");
|
||||||
}
|
}
|
||||||
super(publicKey);
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
public hash(): NodeHash {
|
const toPublicKeyBytes = (key: Identity | PublicKey | Uint8Array | string): Uint8Array => {
|
||||||
return bytesToHex(this.publicKey.slice(0, 1));
|
if (key instanceof Identity) {
|
||||||
}
|
return key.publicKey.toBytes();
|
||||||
|
} else if (key instanceof PublicKey) {
|
||||||
public verify(message: Uint8Array, signature: Uint8Array): boolean {
|
return key.toBytes();
|
||||||
return ed25519.verify(message, signature, this.publicKey);
|
} 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 LocalIdentity extends Identity implements BaseLocalIdentity {
|
export class Identity implements IIdentity {
|
||||||
public privateKey: Uint8Array;
|
public publicKey: PublicKey;
|
||||||
|
|
||||||
constructor(seed: Uint8Array | string) {
|
constructor(publicKey: PublicKey | Uint8Array | string) {
|
||||||
if (typeof seed === "string") {
|
if (publicKey instanceof PublicKey) {
|
||||||
seed = hexToBytes(seed);
|
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');
|
||||||
}
|
}
|
||||||
const { secretKey, publicKey } = ed25519.keygen(seed);
|
|
||||||
super(publicKey);
|
|
||||||
this.privateKey = secretKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sign(message: Uint8Array): Uint8Array {
|
hash(): NodeHash {
|
||||||
return ed25519.sign(message, this.privateKey);
|
return this.publicKey.toHash();
|
||||||
}
|
}
|
||||||
|
|
||||||
public calculateSharedSecret(other: BaseIdentity | Uint8Array): Uint8Array {
|
toString(): string {
|
||||||
if (other instanceof Uint8Array) {
|
return this.publicKey.toString();
|
||||||
return x25519.getSharedSecret(this.privateKey, other);
|
|
||||||
}
|
|
||||||
return x25519.getSharedSecret(this.privateKey, other.publicKey);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public hash(): NodeHash {
|
verify(signature: Uint8Array, message: Uint8Array): boolean {
|
||||||
return super.hash();
|
return this.publicKey.verify(message, signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
public verify(message: Uint8Array, signature: Uint8Array): boolean {
|
matches(other: Identity | PublicKey | Uint8Array | string): boolean {
|
||||||
return super.verify(message, signature);
|
return this.publicKey.equals(toPublicKeyBytes(other));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Group extends BaseGroup {}
|
export class LocalIdentity extends Identity implements ILocalIdentity {
|
||||||
|
private privateKey: PrivateKey;
|
||||||
|
|
||||||
export class GroupSecret extends BaseGroupSecret {
|
constructor(privateKey: PrivateKey | Uint8Array | string, publicKey: PublicKey | Uint8Array | string) {
|
||||||
public static fromName(name: string): GroupSecret {
|
if (publicKey instanceof PublicKey) {
|
||||||
if (name === "Public") {
|
super(publicKey.toBytes());
|
||||||
return new GroupSecret(publicSecret);
|
} else {
|
||||||
} else if (!/^#/.test(name)) {
|
super(publicKey);
|
||||||
throw new Error("Only the 'Public' group or groups starting with '#' are supported");
|
}
|
||||||
|
|
||||||
|
if (privateKey instanceof PrivateKey) {
|
||||||
|
this.privateKey = privateKey;
|
||||||
|
} else {
|
||||||
|
this.privateKey = new PrivateKey(privateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sign(message: Uint8Array): Uint8Array {
|
||||||
|
return this.privateKey.sign(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateSharedSecret(other: IIdentity | IPublicKey): SharedSecret {
|
||||||
|
let otherPublicKey: PublicKey;
|
||||||
|
if (other instanceof Identity) {
|
||||||
|
otherPublicKey = other.publicKey;
|
||||||
|
} else if ('toBytes' in other) {
|
||||||
|
otherPublicKey = new PublicKey(other.toBytes());
|
||||||
|
} else if ('publicKey' in other && other.publicKey instanceof Uint8Array) {
|
||||||
|
otherPublicKey = new PublicKey(other.publicKey);
|
||||||
|
} else if ('publicKey' in other && other.publicKey instanceof PublicKey) {
|
||||||
|
otherPublicKey = other.publicKey;
|
||||||
|
} else if ('publicKey' in other && typeof other.publicKey === 'function') {
|
||||||
|
otherPublicKey = new PublicKey(other.publicKey().toBytes());
|
||||||
|
} else {
|
||||||
|
throw new Error('Invalid type for calculateSharedSecret comparison');
|
||||||
|
}
|
||||||
|
return new SharedSecret(this.privateKey.calculateSharedSecret(otherPublicKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Contact {
|
||||||
|
public name: string = "";
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
const hash = sha256.create().update(new TextEncoder().encode(name)).digest();
|
|
||||||
return new GroupSecret(hash.slice(0, 16));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public hash(): NodeHash {
|
public hash(): NodeHash {
|
||||||
return bytesToHex(this.secret.slice(0, 1));
|
return this.secret.toHash();
|
||||||
}
|
}
|
||||||
|
|
||||||
public decryptText(encrypted: EncryptedPayload): DecryptedGroupText {
|
public decryptText(hmac: Uint8Array, ciphertext: Uint8Array): DecryptedGroupText {
|
||||||
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText);
|
const data = this.secret.decrypt(hmac, ciphertext);
|
||||||
if (data.length < 8) {
|
if (data.length < 5) {
|
||||||
throw new Error("Invalid ciphertext");
|
throw new Error("Invalid ciphertext");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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 {
|
public encryptData(plain: DecryptedGroupData): { hmac: Uint8Array, ciphertext: Uint8Array } {
|
||||||
const mac = hmac(sha256, this.secret, cipherText);
|
const writer = new BufferWriter();
|
||||||
if (!equalBytes(mac, cipherMAC)) {
|
writer.writeTimestamp(plain.timestamp);
|
||||||
throw new Error("Invalid MAC");
|
writer.writeBytes(plain.data);
|
||||||
}
|
const data = writer.toBytes();
|
||||||
|
return this.secret.encrypt(data);
|
||||||
const block = ecb(this.secret.slice(0, 16), { disablePadding: true });
|
|
||||||
const plain = block.decrypt(cipherText);
|
|
||||||
|
|
||||||
return plain;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class KeyManager extends BaseKeyManager {
|
interface CachedLocalIdentity {
|
||||||
private groups: Map<NodeHash, Group[]> = new Map();
|
identity: LocalIdentity;
|
||||||
private contacts: Map<NodeHash, Contact[]> = new Map();
|
sharedSecrets: Record<string, SharedSecret>;
|
||||||
private localIdentities: Map<NodeHash, LocalIdentity[]> = new Map();
|
}
|
||||||
|
|
||||||
public addGroup(group: Group): void {
|
export class Contacts {
|
||||||
const hash = group.secret.hash();
|
private localIdentities: CachedLocalIdentity[] = [];
|
||||||
if (!this.groups.has(hash)) {
|
private contacts: Record<number, Contact[]> = {};
|
||||||
this.groups.set(hash, [group]);
|
private groups: Record<number, Group[]> = {};
|
||||||
} else {
|
|
||||||
this.groups.get(hash)!.push(group);
|
public addLocalIdentity(identity: LocalIdentity) {
|
||||||
}
|
this.localIdentities.push({ identity, sharedSecrets: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
public addGroupSecret(name: string, secret?: Secret): void {
|
public addContact(contact: Contact) {
|
||||||
if (typeof secret === "undefined") {
|
const hash = parseNodeHash(contact.identity.hash()) as number;
|
||||||
secret = GroupSecret.fromName(name).secret;
|
if (!this.contacts[hash]) {
|
||||||
} else if (typeof secret === "string") {
|
this.contacts[hash] = [];
|
||||||
secret = hexToBytes(secret);
|
|
||||||
}
|
}
|
||||||
this.addGroup(new Group(name, new GroupSecret(secret)));
|
this.contacts[hash].push(contact);
|
||||||
}
|
}
|
||||||
|
|
||||||
public decryptGroupText(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupText, group: Group } {
|
public decrypt(src: NodeHash | PublicKey, dst: NodeHash, hmac: Uint8Array, ciphertext: Uint8Array): {
|
||||||
const groupSecrets = this.groups.get(channelHash);
|
localIdentity: LocalIdentity,
|
||||||
if (!groupSecrets) {
|
contact: Contact,
|
||||||
throw new Error("No group secrets for channel");
|
decrypted: Uint8Array,
|
||||||
}
|
} {
|
||||||
|
// Find the public key associated with the source hash.
|
||||||
for (const group of groupSecrets) {
|
let contacts: Contact[] = [];
|
||||||
try {
|
if (src instanceof PublicKey) {
|
||||||
const decrypted = group.decryptText(encrypted);
|
// Check if we have a contact with this exact public key (for direct messages).
|
||||||
return { decrypted, group: group };
|
const srcHash = parseNodeHash(src.toHash()) as number;
|
||||||
} catch {
|
for (const contact of this.contacts[srcHash] || []) {
|
||||||
// Ignore and try next secret
|
if (contact.identity.matches(src)) {
|
||||||
|
contacts.push(contact);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
throw new Error("Failed to decrypt group text with any known secret");
|
|
||||||
}
|
|
||||||
|
|
||||||
public decryptGroupData(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupData, group: Group } {
|
// If no contact matches the public key, add a temporary contact with the hash and no name.
|
||||||
const groupSecrets = this.groups.get(channelHash);
|
if (contacts.length === 0) {
|
||||||
if (!groupSecrets) {
|
contacts.push(new Contact("", new Identity(src.toBytes())));
|
||||||
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 {
|
} else {
|
||||||
this.contacts.get(hash)!.push(contact);
|
const srcHash = parseNodeHash(src) as number;
|
||||||
|
contacts = this.contacts[srcHash] || [];
|
||||||
}
|
}
|
||||||
}
|
if (contacts.length === 0) {
|
||||||
|
throw new Error("Unknown source hash");
|
||||||
public addIdentity(name: string, publicKey: Uint8Array | string): void {
|
|
||||||
if (typeof publicKey === "string") {
|
|
||||||
publicKey = hexToBytes(publicKey);
|
|
||||||
}
|
}
|
||||||
this.addContact({ name, publicKey });
|
|
||||||
}
|
|
||||||
|
|
||||||
public addLocalIdentity(seed: Secret): void {
|
// Find the local identity associated with the destination hash.
|
||||||
const localIdentity = new LocalIdentity(seed);
|
const dstHash = parseNodeHash(dst) as number;
|
||||||
const hash = localIdentity.hash();
|
const localIdentities = this.localIdentities.filter(li => li.identity.publicKey.key[0] === dstHash);
|
||||||
if (!this.localIdentities.has(hash)) {
|
if (localIdentities.length === 0) {
|
||||||
this.localIdentities.set(hash, [localIdentity]);
|
throw new Error("Unknown destination hash");
|
||||||
} else {
|
|
||||||
this.localIdentities.get(hash)!.push(localIdentity);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private tryDecrypt(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: Uint8Array, contact: Contact, identity: BaseIdentity } {
|
|
||||||
if (!this.localIdentities.has(dst)) {
|
|
||||||
throw new Error(`No local identities for destination ${dst}`);
|
|
||||||
}
|
|
||||||
const localIdentities = this.localIdentities.get(dst)!;
|
|
||||||
|
|
||||||
if (!this.contacts.has(src)) {
|
|
||||||
throw new Error(`No contacts for source ${src}`);
|
|
||||||
}
|
|
||||||
const contacts = this.contacts.get(src)!;
|
|
||||||
|
|
||||||
|
// Try to decrypt with each combination of local identity and their public key.
|
||||||
for (const localIdentity of localIdentities) {
|
for (const 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");
|
|
||||||
|
throw new Error("Decryption failed with all known identities and contacts");
|
||||||
}
|
}
|
||||||
|
|
||||||
public decryptRequest(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedRequest, 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();
|
||||||
return {
|
if (localIdentity.sharedSecrets[cacheKey]) {
|
||||||
decrypted: {
|
return localIdentity.sharedSecrets[cacheKey];
|
||||||
timestamp: reader.readTimestamp(),
|
|
||||||
requestType: reader.readByte(),
|
|
||||||
requestData: reader.readBytes(),
|
|
||||||
},
|
|
||||||
contact,
|
|
||||||
identity
|
|
||||||
}
|
}
|
||||||
|
const sharedSecret = localIdentity.identity.calculateSharedSecret(contact.identity);
|
||||||
|
localIdentity.sharedSecrets[cacheKey] = sharedSecret;
|
||||||
|
return sharedSecret;
|
||||||
}
|
}
|
||||||
|
|
||||||
public decryptResponse(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedResponse, contact: Contact, identity: BaseIdentity } {
|
public addGroup(group: Group) {
|
||||||
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
|
const hash = parseNodeHash(group.hash()) as number;
|
||||||
const reader = new BufferReader(decrypted);
|
if (!this.groups[hash]) {
|
||||||
return {
|
this.groups[hash] = [];
|
||||||
decrypted: {
|
|
||||||
timestamp: reader.readTimestamp(),
|
|
||||||
responseData: reader.readBytes(),
|
|
||||||
},
|
|
||||||
contact,
|
|
||||||
identity
|
|
||||||
}
|
}
|
||||||
|
this.groups[hash].push(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
public decryptText(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedText, contact: Contact, identity: BaseIdentity } {
|
public decryptGroupText(channelHash: NodeHash, hmac: Uint8Array, ciphertext: Uint8Array): {
|
||||||
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
|
decrypted: DecryptedGroupText,
|
||||||
const reader = new BufferReader(decrypted);
|
group: Group
|
||||||
const timestamp = reader.readTimestamp();
|
} {
|
||||||
const flags = reader.readByte();
|
const hash = parseNodeHash(channelHash) as number;
|
||||||
const textType = (flags >> 2) & 0x3F;
|
const groups = this.groups[hash] || [];
|
||||||
const attempt = flags & 0x03;
|
if (groups.length === 0) {
|
||||||
const message = new TextDecoder('utf-8').decode(reader.readBytes());
|
throw new Error("Unknown group hash");
|
||||||
return {
|
|
||||||
decrypted: {
|
|
||||||
timestamp,
|
|
||||||
textType,
|
|
||||||
attempt,
|
|
||||||
message
|
|
||||||
},
|
|
||||||
contact,
|
|
||||||
identity
|
|
||||||
}
|
}
|
||||||
}
|
for (const group of groups) {
|
||||||
|
try {
|
||||||
public decryptAnonReq(dst: NodeHash, publicKey: Uint8Array, encrypted: EncryptedPayload): { decrypted: DecryptedAnonReq, contact: Contact, identity: BaseIdentity } {
|
const decrypted = group.decryptText(hmac, ciphertext);
|
||||||
if (!this.localIdentities.has(dst)) {
|
return { decrypted, group };
|
||||||
throw new Error(`No local identities for destination ${dst}`);
|
} catch {
|
||||||
}
|
// Ignore decryption errors and try the next group.
|
||||||
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");
|
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
21
src/identity.types.ts
Normal 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;
|
||||||
|
}
|
||||||
55
src/index.ts
55
src/index.ts
@@ -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";
|
||||||
|
|||||||
406
src/packet.ts
406
src/packet.ts
@@ -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: "payload version", size: 2 },
|
||||||
|
{ name: "payload type", size: 4 },
|
||||||
|
{ name: "route type", size: 2 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
/* 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();
|
||||||
type: PayloadType.REQUEST,
|
const src = reader.readByte();
|
||||||
dst: bytesToHex(reader.readBytes(1)),
|
const encrypted = this.decodeEncryptedPayload(reader);
|
||||||
src: bytesToHex(reader.readBytes(1)),
|
const payload: RequestPayload = {
|
||||||
encrypted: this.decodeEncryptedPayload(reader),
|
type: PayloadType.REQUEST,
|
||||||
|
dst,
|
||||||
|
src,
|
||||||
|
encrypted,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof withSegment === "boolean" && withSegment) {
|
||||||
|
const segment = {
|
||||||
|
name: "request payload",
|
||||||
|
data: this.payload,
|
||||||
|
fields: [
|
||||||
|
{ name: "destination hash", type: FieldType.UINT8, size: 1, value: dst },
|
||||||
|
{ name: "source hash", type: FieldType.UINT8, size: 1, value: src },
|
||||||
|
{ name: "cipher MAC", type: FieldType.BYTES, size: 2, value: encrypted.cipherMAC },
|
||||||
|
{ name: "cipher text", type: FieldType.BYTES, size: encrypted.cipherText.length, value: encrypted.cipherText }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return { payload, segment };
|
||||||
}
|
}
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeResponse(): ResponsePayload {
|
private decodeResponse(withSegment?: boolean): Payload | { payload: Payload, segment: 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: 1, name: "name flag" },
|
||||||
|
{ size: 1, name: "feature2 flag" },
|
||||||
|
{ size: 1, name: "feature1 flag" },
|
||||||
|
{ size: 1, name: "location flag" },
|
||||||
|
{ size: 4, name: "node type" },
|
||||||
|
]});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (appdata.hasLocation) {
|
if (appdata.hasLocation) {
|
||||||
const lat = reader.readInt32LE() / 100000;
|
const lat = reader.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;
|
|
||||||
|
return { ...payload, appdata } as AdvertPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
private decodeGroupText(): GroupTextPayload {
|
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
216
src/packet.types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
const bytes = new Uint8Array(binaryString.length);
|
let normalized = base64.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
for (let i = 0; i < binaryString.length; i++) {
|
// Add padding if missing
|
||||||
bytes[i] = binaryString.charCodeAt(i);
|
while (normalized.length % 4 !== 0) {
|
||||||
}
|
normalized += '=';
|
||||||
return bytes;
|
}
|
||||||
|
const binaryString = atob(normalized);
|
||||||
|
const bytes = new Uint8Array(binaryString.length);
|
||||||
|
for (let i = 0; i < binaryString.length; i++) {
|
||||||
|
bytes[i] = binaryString.charCodeAt(i);
|
||||||
|
}
|
||||||
|
if (size !== undefined && bytes.length !== size) {
|
||||||
|
throw new Error(`Invalid base64 length: expected ${size} bytes, got ${bytes.length}`);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Note: encodedStringToBytes removed — prefer explicit parsers.
|
||||||
|
// Use `hexToBytes` for hex inputs and `base64ToBytes` for base64 inputs.
|
||||||
|
|
||||||
|
// Wrapper around @noble/hashes hexToBytes that optionally validates size.
|
||||||
|
export const hexToBytes = (hex: string, size?: number): Uint8Array => {
|
||||||
|
const bytes = nobleHexToBytes(hex);
|
||||||
|
if (size !== undefined && bytes.length !== size) {
|
||||||
|
throw new Error(`Invalid hex length: expected ${size} bytes, got ${bytes.length}`);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
};
|
||||||
|
|
||||||
export class BufferReader {
|
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
35
src/parser.types.ts
Normal 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
|
||||||
|
}
|
||||||
296
src/types.ts
296
src/types.ts
@@ -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
202
test/crypto.test.ts
Normal 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
478
test/identity.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user