4 Commits

Author SHA1 Message Date
7eca26a2b2 Export everything and all types 2026-03-10 18:04:56 +01:00
218042f552 NodeHash is a number 2026-03-10 17:55:37 +01:00
a30448c130 We can not sensibly parse both hex and base64, assume all input is hex 2026-03-10 17:48:51 +01:00
7a2522cf32 Refactoring 2026-03-10 17:35:15 +01:00
15 changed files with 1522 additions and 481 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "meshcore", "name": "meshcore",
"version": "1.0.0", "version": "1.1.0",
"description": "MeshCore protocol support for Typescript", "description": "MeshCore protocol support for Typescript",
"keywords": [ "keywords": [
"MeshCore", "MeshCore",
@@ -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": {

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

@@ -0,0 +1,202 @@
import { describe, it, expect } from 'vitest';
import { PublicKey, PrivateKey, SharedSecret, StaticSecret } from './crypto';
import { bytesToHex, hexToBytes } from './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);
});
});

231
src/crypto.ts Normal file
View File

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

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

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

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

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

View File

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

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

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

View File

@@ -1,11 +1,9 @@
import { Packet } from "./packet"; export * from './identity';
import { export * from './identity.types';
RouteType, export type * from './identity.types';
PayloadType,
} from "./types";
export default { export * from './crypto';
Packet, export type * from './crypto.types';
RouteType,
PayloadType, export * from './packet';
}; export type * from './packet.types';

View File

@@ -1,7 +1,7 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { Packet } from '../src/packet'; import { Packet } from './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 } from './packet.types';
import { hexToBytes, bytesToHex } from '../src/parser'; import { hexToBytes, bytesToHex } from './parser';
describe('Packet.fromBytes', () => { describe('Packet.fromBytes', () => {
test('frame 1: len=122 type=5 payload_len=99', () => { test('frame 1: len=122 type=5 payload_len=99', () => {
@@ -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);
@@ -185,7 +186,7 @@ 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', () => {

View File

@@ -17,8 +17,8 @@ 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,
@@ -39,7 +39,7 @@ 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[];
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;
@@ -142,8 +142,8 @@ export class Packet implements IPacket {
const reader = new BufferReader(this.payload); const reader = new BufferReader(this.payload);
return { return {
type: PayloadType.REQUEST, type: PayloadType.REQUEST,
dst: bytesToHex(reader.readBytes(1)), dst: reader.readByte(),
src: bytesToHex(reader.readBytes(1)), src: reader.readByte(),
encrypted: this.decodeEncryptedPayload(reader), encrypted: this.decodeEncryptedPayload(reader),
} }
} }
@@ -156,8 +156,8 @@ export class Packet implements IPacket {
const reader = new BufferReader(this.payload); const reader = new BufferReader(this.payload);
return { return {
type: PayloadType.RESPONSE, type: PayloadType.RESPONSE,
dst: bytesToHex(reader.readBytes(1)), dst: reader.readByte(),
src: bytesToHex(reader.readBytes(1)), src: reader.readByte(),
encrypted: this.decodeEncryptedPayload(reader), encrypted: this.decodeEncryptedPayload(reader),
} }
} }
@@ -170,8 +170,8 @@ export class Packet implements IPacket {
const reader = new BufferReader(this.payload); const reader = new BufferReader(this.payload);
return { return {
type: PayloadType.TEXT, type: PayloadType.TEXT,
dst: bytesToHex(reader.readBytes(1)), dst: reader.readByte(),
src: bytesToHex(reader.readBytes(1)), src: reader.readByte(),
encrypted: this.decodeEncryptedPayload(reader), encrypted: this.decodeEncryptedPayload(reader),
} }
} }
@@ -244,7 +244,7 @@ export class Packet implements IPacket {
const reader = new BufferReader(this.payload); const reader = new BufferReader(this.payload);
return { return {
type: PayloadType.GROUP_TEXT, type: PayloadType.GROUP_TEXT,
channelHash: bytesToHex(reader.readBytes(1)), channelHash: reader.readByte(),
encrypted: this.decodeEncryptedPayload(reader), encrypted: this.decodeEncryptedPayload(reader),
} }
} }
@@ -257,7 +257,7 @@ export class Packet implements IPacket {
const reader = new BufferReader(this.payload); const reader = new BufferReader(this.payload);
return { return {
type: PayloadType.GROUP_DATA, type: PayloadType.GROUP_DATA,
channelHash: bytesToHex(reader.readBytes(1)), channelHash: reader.readByte(),
encrypted: this.decodeEncryptedPayload(reader), encrypted: this.decodeEncryptedPayload(reader),
} }
} }
@@ -270,7 +270,7 @@ export class Packet implements IPacket {
const reader = new BufferReader(this.payload); const reader = new BufferReader(this.payload);
return { return {
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),
} }
@@ -284,8 +284,8 @@ export class Packet implements IPacket {
const reader = new BufferReader(this.payload); const reader = new BufferReader(this.payload);
return { return {
type: PayloadType.PATH, type: PayloadType.PATH,
dst: bytesToHex(reader.readBytes(1)), dst: reader.readByte(),
src: bytesToHex(reader.readBytes(1)), src: reader.readByte(),
} }
} }

View File

@@ -1,4 +1,4 @@
import { equalBytes, hexToBytes } from "./parser"; import { NodeHash } from "./identity.types";
// IPacket contains the raw packet bytes. // IPacket contains the raw packet bytes.
export type Uint16 = number; // 0..65535 export type Uint16 = number; // 0..65535
@@ -201,96 +201,3 @@ export interface RawCustomPayload {
type: PayloadType.RAW_CUSTOM; type: PayloadType.RAW_CUSTOM;
data: Uint8Array; 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 };
}

195
src/parser.test.ts Normal file
View File

@@ -0,0 +1,195 @@
import { describe, it, expect } from 'vitest';
import { base64ToBytes, hexToBytes, BufferReader, BufferWriter } from './parser';
describe('base64ToBytes', () => {
it('decodes a simple base64 string', () => {
const bytes = base64ToBytes('aGVsbG8=', 5); // "hello"
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
});
it('handles empty string', () => {
const bytes = base64ToBytes('', 0);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(0);
});
});
describe('BufferReader', () => {
it('readByte and peekByte advance/inspect correctly', () => {
const buf = new Uint8Array([1, 2, 3]);
const r = new BufferReader(buf);
expect(r.peekByte()).toBe(1);
expect(r.readByte()).toBe(1);
expect(r.peekByte()).toBe(2);
});
it('readBytes with and without length', () => {
const buf = new Uint8Array([10, 11, 12, 13]);
const r = new BufferReader(buf);
const a = r.readBytes(2);
expect(Array.from(a)).toEqual([10, 11]);
const b = r.readBytes();
expect(Array.from(b)).toEqual([12, 13]);
});
it('hasMore and remainingBytes reflect position', () => {
const buf = new Uint8Array([5, 6]);
const r = new BufferReader(buf);
expect(r.hasMore()).toBe(true);
expect(r.remainingBytes()).toBe(2);
r.readByte();
expect(r.remainingBytes()).toBe(1);
r.readByte();
expect(r.hasMore()).toBe(false);
});
it('reads little-endian unsigned ints', () => {
const r16 = new BufferReader(new Uint8Array([0x34, 0x12]));
expect(r16.readUint16LE()).toBe(0x1234);
const r32 = new BufferReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]));
expect(r32.readUint32LE()).toBe(0x12345678);
});
it('reads signed ints with two/four bytes (negative)', () => {
const r16 = new BufferReader(new Uint8Array([0xff, 0xff]));
expect(r16.readInt16LE()).toBe(-1);
const r32 = new BufferReader(new Uint8Array([0xff, 0xff, 0xff, 0xff]));
expect(r32.readInt32LE()).toBe(-1);
});
it('readTimestamp returns Date with seconds->ms conversion', () => {
const r = new BufferReader(new Uint8Array([0x01, 0x00, 0x00, 0x00]));
const d = r.readTimestamp();
expect(d.getTime()).toBe(1000);
});
});
describe('sizedStringToBytes', () => {
it('decodes hex string of correct length', () => {
// 4 bytes = 8 hex chars
const hex = 'deadbeef';
const result = hexToBytes(hex, 4);
expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('decodes base64 string of correct length', () => {
// 4 bytes = 8 hex chars, base64 for [0xde, 0xad, 0xbe, 0xef] is '3q2+7w=='
const b64 = '3q2+7w==';
const result = base64ToBytes(b64, 4);
expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('throws on invalid string length', () => {
expect(() => hexToBytes('abc', 4)).toThrow();
expect(() => hexToBytes('deadbeef00', 4)).toThrow();
});
});
describe('BufferWriter', () => {
it('writeByte and toBytes', () => {
const w = new BufferWriter();
w.writeByte(0x12);
w.writeByte(0x34);
expect(Array.from(w.toBytes())).toEqual([0x12, 0x34]);
});
it('writeBytes appends bytes', () => {
const w = new BufferWriter();
w.writeBytes(new Uint8Array([1, 2, 3]));
expect(Array.from(w.toBytes())).toEqual([1, 2, 3]);
});
it('writeUint16LE writes little-endian', () => {
const w = new BufferWriter();
w.writeUint16LE(0x1234);
expect(Array.from(w.toBytes())).toEqual([0x34, 0x12]);
});
it('writeUint32LE writes little-endian', () => {
const w = new BufferWriter();
w.writeUint32LE(0x12345678);
expect(Array.from(w.toBytes())).toEqual([0x78, 0x56, 0x34, 0x12]);
});
it('writeInt16LE writes signed values', () => {
const w = new BufferWriter();
w.writeInt16LE(-1);
expect(Array.from(w.toBytes())).toEqual([0xff, 0xff]);
const w2 = new BufferWriter();
w2.writeInt16LE(0x1234);
expect(Array.from(w2.toBytes())).toEqual([0x34, 0x12]);
});
it('writeInt32LE writes signed values', () => {
const w = new BufferWriter();
w.writeInt32LE(-1);
expect(Array.from(w.toBytes())).toEqual([0xff, 0xff, 0xff, 0xff]);
const w2 = new BufferWriter();
w2.writeInt32LE(0x12345678);
expect(Array.from(w2.toBytes())).toEqual([0x78, 0x56, 0x34, 0x12]);
});
it('writeTimestamp writes seconds as uint32le', () => {
const w = new BufferWriter();
const date = new Date(1000); // 1 second
w.writeTimestamp(date);
expect(Array.from(w.toBytes())).toEqual([0x01, 0x00, 0x00, 0x00]);
});
it('BufferWriter output can be read back by BufferReader', () => {
const w = new BufferWriter();
w.writeByte(0x42);
w.writeUint16LE(0x1234);
w.writeInt16LE(-2);
w.writeUint32LE(0xdeadbeef);
w.writeInt32LE(-123456);
w.writeBytes(new Uint8Array([0x01, 0x02]));
const date = new Date(5000); // 5 seconds
w.writeTimestamp(date);
const bytes = w.toBytes();
const r = new BufferReader(bytes);
expect(r.readByte()).toBe(0x42);
expect(r.readUint16LE()).toBe(0x1234);
expect(r.readInt16LE()).toBe(-2);
expect(r.readUint32LE()).toBe(0xdeadbeef);
expect(r.readInt32LE()).toBe(-123456);
expect(Array.from(r.readBytes(2))).toEqual([0x01, 0x02]);
const readDate = r.readTimestamp();
expect(readDate.getTime()).toBe(5000);
expect(r.hasMore()).toBe(false);
});
it('BufferReader throws or returns undefined if reading past end', () => {
const r = new BufferReader(new Uint8Array([1, 2]));
r.readByte();
r.readByte();
expect(() => r.readByte()).toThrow();
});
it('BufferWriter handles multiple writeBytes calls', () => {
const w = new BufferWriter();
w.writeBytes(new Uint8Array([1, 2]));
w.writeBytes(new Uint8Array([3, 4]));
expect(Array.from(w.toBytes())).toEqual([1, 2, 3, 4]);
});
it('encodedStringToBytes decodes raw string', () => {
const str = String.fromCharCode(0xde, 0xad, 0xbe, 0xef);
const bytes = new Uint8Array(4);
for (let i = 0; i < 4; i++) bytes[i] = str.charCodeAt(i) & 0xff;
expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
it('hexToBytes returns different length for wrong-size hex', () => {
expect(() => hexToBytes('deadbe', 4)).toThrow();
});
it('base64ToBytes handles URL-safe base64', () => {
// [0xde, 0xad, 0xbe, 0xef] in URL-safe base64: '3q2-7w=='
const bytes = base64ToBytes('3q2-7w==', 4);
expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
});

View File

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

View File

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

View File

@@ -1,67 +0,0 @@
import { describe, it, expect } from 'vitest';
import { base64ToBytes, BufferReader } from '../src/parser';
describe('base64ToBytes', () => {
it('decodes a simple base64 string', () => {
const bytes = base64ToBytes('aGVsbG8='); // "hello"
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
});
it('handles empty string', () => {
const bytes = base64ToBytes('');
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(0);
});
});
describe('BufferReader', () => {
it('readByte and peekByte advance/inspect correctly', () => {
const buf = new Uint8Array([1, 2, 3]);
const r = new BufferReader(buf);
expect(r.peekByte()).toBe(1);
expect(r.readByte()).toBe(1);
expect(r.peekByte()).toBe(2);
});
it('readBytes with and without length', () => {
const buf = new Uint8Array([10, 11, 12, 13]);
const r = new BufferReader(buf);
const a = r.readBytes(2);
expect(Array.from(a)).toEqual([10, 11]);
const b = r.readBytes();
expect(Array.from(b)).toEqual([12, 13]);
});
it('hasMore and remainingBytes reflect position', () => {
const buf = new Uint8Array([5, 6]);
const r = new BufferReader(buf);
expect(r.hasMore()).toBe(true);
expect(r.remainingBytes()).toBe(2);
r.readByte();
expect(r.remainingBytes()).toBe(1);
r.readByte();
expect(r.hasMore()).toBe(false);
});
it('reads little-endian unsigned ints', () => {
const r16 = new BufferReader(new Uint8Array([0x34, 0x12]));
expect(r16.readUint16LE()).toBe(0x1234);
const r32 = new BufferReader(new Uint8Array([0x78, 0x56, 0x34, 0x12]));
expect(r32.readUint32LE()).toBe(0x12345678);
});
it('reads signed ints with two/four bytes (negative)', () => {
const r16 = new BufferReader(new Uint8Array([0xff, 0xff]));
expect(r16.readInt16LE()).toBe(-1);
const r32 = new BufferReader(new Uint8Array([0xff, 0xff, 0xff, 0xff]));
expect(r32.readInt32LE()).toBe(-1);
});
it('readTimestamp returns Date with seconds->ms conversion', () => {
const r = new BufferReader(new Uint8Array([0x01, 0x00, 0x00, 0x00]));
const d = r.readTimestamp();
expect(d.getTime()).toBe(1000);
});
});