Refactoring
This commit is contained in:
67
src/contact.ts
Normal file
67
src/contact.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ecb } from '@noble/ciphers/aes.js';
|
||||
import { hmac } from '@noble/hashes/hmac.js';
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { BaseGroup, BaseGroupSecret, DecryptedGroupData, DecryptedGroupText, EncryptedPayload } from "./packet.types";
|
||||
import { BufferReader, equalBytes, hexToBytes } from "./parser";
|
||||
|
||||
// The "Public" group is a special group that all nodes are implicitly part of. It uses a fixed secret derived from the string "Public".
|
||||
const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72");
|
||||
|
||||
export class 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 decryptText(encrypted: EncryptedPayload): DecryptedGroupText {
|
||||
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText);
|
||||
if (data.length < 8) {
|
||||
throw new Error("Invalid ciphertext");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(data);
|
||||
const timestamp = reader.readTimestamp();
|
||||
const flags = reader.readByte();
|
||||
const textType = (flags >> 2) & 0x3F;
|
||||
const attempt = flags & 0x03;
|
||||
const message = new TextDecoder('utf-8').decode(reader.readBytes());
|
||||
return {
|
||||
timestamp,
|
||||
textType,
|
||||
attempt,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
public decryptData(encrypted: EncryptedPayload): DecryptedGroupData {
|
||||
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText);
|
||||
if (data.length < 8) {
|
||||
throw new Error("Invalid ciphertext");
|
||||
}
|
||||
|
||||
const reader = new BufferReader(data);
|
||||
return {
|
||||
timestamp: reader.readTimestamp(),
|
||||
data: reader.readBytes(reader.remainingBytes())
|
||||
};
|
||||
}
|
||||
|
||||
private macThenDecrypt(cipherMAC: Uint8Array, cipherText: Uint8Array): Uint8Array {
|
||||
const mac = hmac(sha256, this.secret, cipherText);
|
||||
if (!equalBytes(mac, cipherMAC)) {
|
||||
throw new Error("Invalid MAC");
|
||||
}
|
||||
|
||||
const block = ecb(this.secret.slice(0, 16), { disablePadding: true });
|
||||
const plain = block.decrypt(cipherText);
|
||||
|
||||
return plain;
|
||||
}
|
||||
}
|
||||
202
src/crypto.test.ts
Normal file
202
src/crypto.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
238
src/crypto.ts
Normal file
238
src/crypto.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { ed25519, x25519 } from '@noble/curves/ed25519.js';
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { hmac } from '@noble/hashes/hmac.js';
|
||||
import { ecb, ecb, encrypt } from '@noble/ciphers/aes.js';
|
||||
import { bytesToHex, equalBytes, hexToBytes, encodedStringToBytes } from "./parser";
|
||||
import { IPublicKey, ISharedSecret, IStaticSecret } from './crypto.types';
|
||||
import { NodeHash } from './identity.types';
|
||||
|
||||
const PUBLIC_KEY_SIZE = 32;
|
||||
const SEED_SIZE = 32;
|
||||
const PRIVATE_KEY_SIZE = 32;
|
||||
const HMAC_SIZE = 2;
|
||||
const SHARED_SECRET_SIZE = 32;
|
||||
const SIGNATURE_SIZE = 64;
|
||||
const STATIC_SECRET_SIZE = 32;
|
||||
|
||||
export class PublicKey implements IPublicKey {
|
||||
public key: Uint8Array;
|
||||
|
||||
constructor(key: Uint8Array | string) {
|
||||
if (typeof key === 'string') {
|
||||
this.key = encodedStringToBytes(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 = encodedStringToBytes(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 = encodedStringToBytes(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);
|
||||
}
|
||||
|
||||
private zeroPad(data: Uint8Array): Uint8Array {
|
||||
if (data.length % 16 === 0) {
|
||||
return data;
|
||||
}
|
||||
const padded = new Uint8Array(Math.ceil(data.length / 16) * 16);
|
||||
padded.set(data);
|
||||
return padded;
|
||||
}
|
||||
|
||||
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(hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72"));
|
||||
} 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 = encodedStringToBytes(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 "./packet.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;
|
||||
}
|
||||
308
src/identity.test.ts
Normal file
308
src/identity.test.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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 = randomBytes(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);
|
||||
});
|
||||
});
|
||||
|
||||
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('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 = randomBytes(12);
|
||||
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());
|
||||
});
|
||||
});
|
||||
494
src/identity.ts
494
src/identity.ts
@@ -1,98 +1,163 @@
|
||||
import { ecb } from '@noble/ciphers/aes.js';
|
||||
import { hmac } from '@noble/hashes/hmac.js';
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { ed25519, x25519 } from '@noble/curves/ed25519.js';
|
||||
import {
|
||||
BaseGroup,
|
||||
BaseGroupSecret,
|
||||
BaseIdentity,
|
||||
BaseKeyManager,
|
||||
BaseLocalIdentity,
|
||||
Contact,
|
||||
DecryptedAnonReq,
|
||||
DecryptedGroupData,
|
||||
DecryptedGroupText,
|
||||
DecryptedRequest,
|
||||
DecryptedResponse,
|
||||
DecryptedText,
|
||||
EncryptedPayload,
|
||||
NodeHash,
|
||||
Secret
|
||||
} from "./types";
|
||||
import { BufferReader, bytesToHex, equalBytes, hexToBytes } from "./parser";
|
||||
import { x25519 } from "@noble/curves/ed25519";
|
||||
import { PrivateKey, PublicKey, SharedSecret, StaticSecret } from "./crypto";
|
||||
import { IPublicKey } from "./crypto.types";
|
||||
import { NodeHash, IIdentity, ILocalIdentity } from "./identity.types";
|
||||
import { DecryptedGroupData, DecryptedGroupText } from "./packet.types";
|
||||
import { equalBytes, hexToBytes, BufferReader, BufferWriter } from "./parser";
|
||||
|
||||
// The "Public" group is a special group that all nodes are implicitly part of. It uses a fixed secret derived from the string "Public".
|
||||
const publicSecret = hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72");
|
||||
|
||||
export class Identity extends BaseIdentity {
|
||||
constructor(publicKey: Uint8Array | string) {
|
||||
if (typeof publicKey === "string") {
|
||||
publicKey = hexToBytes(publicKey);
|
||||
export const parseNodeHash = (hash: NodeHash | string | Uint8Array): NodeHash => {
|
||||
if (hash instanceof Uint8Array) {
|
||||
return hash[0] as NodeHash;
|
||||
}
|
||||
if (typeof hash === "number") {
|
||||
if (hash < 0 || hash > 255) {
|
||||
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 {
|
||||
return bytesToHex(this.publicKey.slice(0, 1));
|
||||
}
|
||||
|
||||
public verify(message: Uint8Array, signature: Uint8Array): boolean {
|
||||
return ed25519.verify(message, signature, this.publicKey);
|
||||
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 LocalIdentity extends Identity implements BaseLocalIdentity {
|
||||
public privateKey: Uint8Array;
|
||||
export class Identity implements IIdentity {
|
||||
public publicKey: PublicKey;
|
||||
|
||||
constructor(seed: Uint8Array | string) {
|
||||
if (typeof seed === "string") {
|
||||
seed = hexToBytes(seed);
|
||||
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');
|
||||
}
|
||||
const { secretKey, publicKey } = ed25519.keygen(seed);
|
||||
super(publicKey);
|
||||
this.privateKey = secretKey;
|
||||
}
|
||||
|
||||
public sign(message: Uint8Array): Uint8Array {
|
||||
return ed25519.sign(message, this.privateKey);
|
||||
hash(): NodeHash {
|
||||
return this.publicKey.toHash();
|
||||
}
|
||||
|
||||
public calculateSharedSecret(other: BaseIdentity | Uint8Array): Uint8Array {
|
||||
if (other instanceof Uint8Array) {
|
||||
return x25519.getSharedSecret(this.privateKey, other);
|
||||
}
|
||||
return x25519.getSharedSecret(this.privateKey, other.publicKey);
|
||||
toString(): string {
|
||||
return this.publicKey.toString();
|
||||
}
|
||||
|
||||
public hash(): NodeHash {
|
||||
return super.hash();
|
||||
verify(signature: Uint8Array, message: Uint8Array): boolean {
|
||||
return this.publicKey.verify(message, signature);
|
||||
}
|
||||
|
||||
public verify(message: Uint8Array, signature: Uint8Array): boolean {
|
||||
return super.verify(message, signature);
|
||||
matches(other: Identity | PublicKey | Uint8Array | string): boolean {
|
||||
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 {
|
||||
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");
|
||||
constructor(privateKey: PrivateKey | Uint8Array | string, publicKey: PublicKey | Uint8Array | string) {
|
||||
if (publicKey instanceof PublicKey) {
|
||||
super(publicKey.toBytes());
|
||||
} else {
|
||||
super(publicKey);
|
||||
}
|
||||
|
||||
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 {
|
||||
return bytesToHex(this.secret.slice(0, 1));
|
||||
return this.secret.toHash();
|
||||
}
|
||||
|
||||
public decryptText(encrypted: EncryptedPayload): DecryptedGroupText {
|
||||
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText);
|
||||
if (data.length < 8) {
|
||||
public decryptText(hmac: Uint8Array, ciphertext: Uint8Array): DecryptedGroupText {
|
||||
const data = this.secret.decrypt(hmac, ciphertext);
|
||||
if (data.length < 5) {
|
||||
throw new Error("Invalid ciphertext");
|
||||
}
|
||||
|
||||
@@ -110,9 +175,19 @@ export class GroupSecret extends BaseGroupSecret {
|
||||
}
|
||||
}
|
||||
|
||||
public decryptData(encrypted: EncryptedPayload): DecryptedGroupData {
|
||||
const data = this.macThenDecrypt(encrypted.cipherMAC, encrypted.cipherText);
|
||||
if (data.length < 8) {
|
||||
public encryptText(plain: DecryptedGroupText): { hmac: Uint8Array, ciphertext: Uint8Array } {
|
||||
const writer = new BufferWriter();
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -120,212 +195,147 @@ export class GroupSecret extends BaseGroupSecret {
|
||||
return {
|
||||
timestamp: reader.readTimestamp(),
|
||||
data: reader.readBytes(reader.remainingBytes())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private macThenDecrypt(cipherMAC: Uint8Array, cipherText: Uint8Array): Uint8Array {
|
||||
const mac = hmac(sha256, this.secret, cipherText);
|
||||
if (!equalBytes(mac, cipherMAC)) {
|
||||
throw new Error("Invalid MAC");
|
||||
}
|
||||
|
||||
const block = ecb(this.secret.slice(0, 16), { disablePadding: true });
|
||||
const plain = block.decrypt(cipherText);
|
||||
|
||||
return plain;
|
||||
public encryptData(plain: DecryptedGroupData): { hmac: Uint8Array, ciphertext: Uint8Array } {
|
||||
const writer = new BufferWriter();
|
||||
writer.writeTimestamp(plain.timestamp);
|
||||
writer.writeBytes(plain.data);
|
||||
const data = writer.toBytes();
|
||||
return this.secret.encrypt(data);
|
||||
}
|
||||
}
|
||||
|
||||
export class KeyManager extends BaseKeyManager {
|
||||
private groups: Map<NodeHash, Group[]> = new Map();
|
||||
private contacts: Map<NodeHash, Contact[]> = new Map();
|
||||
private localIdentities: Map<NodeHash, LocalIdentity[]> = new Map();
|
||||
interface CachedLocalIdentity {
|
||||
identity: LocalIdentity;
|
||||
sharedSecrets: Record<string, SharedSecret>;
|
||||
}
|
||||
|
||||
public addGroup(group: Group): void {
|
||||
const hash = group.secret.hash();
|
||||
if (!this.groups.has(hash)) {
|
||||
this.groups.set(hash, [group]);
|
||||
} else {
|
||||
this.groups.get(hash)!.push(group);
|
||||
}
|
||||
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 addGroupSecret(name: string, secret?: Secret): void {
|
||||
if (typeof secret === "undefined") {
|
||||
secret = GroupSecret.fromName(name).secret;
|
||||
} else if (typeof secret === "string") {
|
||||
secret = hexToBytes(secret);
|
||||
public addContact(contact: Contact) {
|
||||
const hash = parseNodeHash(contact.identity.hash()) as number;
|
||||
if (!this.contacts[hash]) {
|
||||
this.contacts[hash] = [];
|
||||
}
|
||||
this.addGroup(new Group(name, new GroupSecret(secret)));
|
||||
this.contacts[hash].push(contact);
|
||||
}
|
||||
|
||||
public decryptGroupText(channelHash: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedGroupText, group: Group } {
|
||||
const groupSecrets = this.groups.get(channelHash);
|
||||
if (!groupSecrets) {
|
||||
throw new Error("No group secrets for channel");
|
||||
}
|
||||
|
||||
for (const group of groupSecrets) {
|
||||
try {
|
||||
const decrypted = group.decryptText(encrypted);
|
||||
return { decrypted, group: group };
|
||||
} catch {
|
||||
// Ignore and try next secret
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
// 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())));
|
||||
}
|
||||
}
|
||||
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);
|
||||
const srcHash = parseNodeHash(src) as number;
|
||||
contacts = this.contacts[srcHash] || [];
|
||||
}
|
||||
}
|
||||
|
||||
public addIdentity(name: string, publicKey: Uint8Array | string): void {
|
||||
if (typeof publicKey === "string") {
|
||||
publicKey = hexToBytes(publicKey);
|
||||
if (contacts.length === 0) {
|
||||
throw new Error("Unknown source hash");
|
||||
}
|
||||
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);
|
||||
// Find the local identity associated with the destination hash.
|
||||
const dstHash = parseNodeHash(dst) as number;
|
||||
const localIdentities = this.localIdentities.filter(li => li.identity.publicKey.key[0] === dstHash);
|
||||
if (localIdentities.length === 0) {
|
||||
throw new Error("Unknown destination hash");
|
||||
}
|
||||
}
|
||||
|
||||
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 contact of contacts) {
|
||||
const sharedSecret = localIdentity.calculateSharedSecret(contact.publicKey);
|
||||
const mac = hmac(sha256, sharedSecret, encrypted.cipherText);
|
||||
if (!equalBytes(mac, encrypted.cipherMAC)) {
|
||||
continue; // Invalid MAC, try next combination
|
||||
const sharedSecret: SharedSecret = this.calculateSharedSecret(localIdentity, contact);
|
||||
try {
|
||||
const decrypted = sharedSecret.decrypt(hmac, ciphertext);
|
||||
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 } {
|
||||
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
|
||||
// Caches the calculated shared secret for a given local identity and contact to avoid redundant calculations.
|
||||
private calculateSharedSecret(localIdentity: CachedLocalIdentity, contact: Contact): SharedSecret {
|
||||
const cacheKey = contact.identity.toString();
|
||||
if (localIdentity.sharedSecrets[cacheKey]) {
|
||||
return localIdentity.sharedSecrets[cacheKey];
|
||||
}
|
||||
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 } {
|
||||
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 addGroup(group: Group) {
|
||||
const hash = parseNodeHash(group.hash()) as number;
|
||||
if (!this.groups[hash]) {
|
||||
this.groups[hash] = [];
|
||||
}
|
||||
this.groups[hash].push(group);
|
||||
}
|
||||
|
||||
public decryptText(dst: NodeHash, src: NodeHash, encrypted: EncryptedPayload): { decrypted: DecryptedText, contact: Contact, identity: BaseIdentity } {
|
||||
const { decrypted, contact, identity } = this.tryDecrypt(dst, src, encrypted);
|
||||
const reader = new BufferReader(decrypted);
|
||||
const timestamp = reader.readTimestamp();
|
||||
const flags = reader.readByte();
|
||||
const textType = (flags >> 2) & 0x3F;
|
||||
const attempt = flags & 0x03;
|
||||
const message = new TextDecoder('utf-8').decode(reader.readBytes());
|
||||
return {
|
||||
decrypted: {
|
||||
timestamp,
|
||||
textType,
|
||||
attempt,
|
||||
message
|
||||
},
|
||||
contact,
|
||||
identity
|
||||
public decryptGroupText(channelHash: NodeHash, hmac: Uint8Array, ciphertext: Uint8Array): {
|
||||
decrypted: DecryptedGroupText,
|
||||
group: Group
|
||||
} {
|
||||
const hash = parseNodeHash(channelHash) as number;
|
||||
const groups = this.groups[hash] || [];
|
||||
if (groups.length === 0) {
|
||||
throw new Error("Unknown group hash");
|
||||
}
|
||||
}
|
||||
|
||||
public decryptAnonReq(dst: NodeHash, publicKey: Uint8Array, encrypted: EncryptedPayload): { decrypted: DecryptedAnonReq, contact: Contact, identity: BaseIdentity } {
|
||||
if (!this.localIdentities.has(dst)) {
|
||||
throw new Error(`No local identities for destination ${dst}`);
|
||||
}
|
||||
const localIdentities = this.localIdentities.get(dst)!;
|
||||
|
||||
const contact = { publicKey } as Contact; // Create a temporary contact object for MAC verification
|
||||
|
||||
for (const localIdentity of localIdentities) {
|
||||
const sharedSecret = localIdentity.calculateSharedSecret(publicKey);
|
||||
const mac = hmac(sha256, sharedSecret, encrypted.cipherText);
|
||||
if (!equalBytes(mac, encrypted.cipherMAC)) {
|
||||
continue; // Invalid MAC, try next identity
|
||||
}
|
||||
|
||||
const block = ecb(sharedSecret.slice(0, 16), { disablePadding: true });
|
||||
const plain = block.decrypt(encrypted.cipherText);
|
||||
if (plain.length < 8) {
|
||||
continue; // Invalid plaintext, try next identity
|
||||
}
|
||||
const reader = new BufferReader(plain);
|
||||
return {
|
||||
decrypted: {
|
||||
timestamp: reader.readTimestamp(),
|
||||
data: reader.readBytes(),
|
||||
},
|
||||
contact,
|
||||
identity: localIdentity
|
||||
for (const group of groups) {
|
||||
try {
|
||||
const decrypted = group.decryptText(hmac, ciphertext);
|
||||
return { decrypted, group };
|
||||
} catch {
|
||||
// Ignore decryption errors and try the next group.
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
14
src/index.ts
14
src/index.ts
@@ -1,11 +1,13 @@
|
||||
import { Packet } from "./packet";
|
||||
import {
|
||||
RouteType,
|
||||
PayloadType,
|
||||
} from "./types";
|
||||
export * as types from './packet.types';
|
||||
export * from './parser';
|
||||
export * from './identity';
|
||||
export { Packet } from './packet';
|
||||
|
||||
import { Packet as _Packet } from './packet';
|
||||
import { RouteType, PayloadType } from './packet.types';
|
||||
|
||||
export default {
|
||||
Packet,
|
||||
Packet: _Packet,
|
||||
RouteType,
|
||||
PayloadType,
|
||||
};
|
||||
|
||||
233
src/packet.test.ts
Normal file
233
src/packet.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { Packet } from './packet';
|
||||
import { PayloadType, RouteType, NodeType, TracePayload, AdvertPayload, RequestPayload, TextPayload, ResponsePayload, RawCustomPayload, AnonReqPayload } from './packet.types';
|
||||
import { hexToBytes, bytesToHex } from './parser';
|
||||
|
||||
describe('Packet.fromBytes', () => {
|
||||
test('frame 1: len=122 type=5 payload_len=99', () => {
|
||||
const hex = '1515747207E0B28A52BE12186BCCBCABFC88A0417BBF78D951FF9FEC725F90F032C0DC9B7FD27890228B926A90E317E089F948EC66D9EF01F0C8683B6B28EC1E2D053741A75E7EEF51047BB4C9A1FB6766B379024DBA80B8FEFE804FF9696209039C2388E461AA6138D1DF9FDD3E333E5DFC18660F3E05F3364E';
|
||||
const bytes = hexToBytes(hex);
|
||||
expect(bytes.length).toBe(122);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.payload.length).toBe(99);
|
||||
expect(pkt.payloadType).toBe(PayloadType.GROUP_TEXT);
|
||||
const h = pkt.hash();
|
||||
expect(h.toUpperCase()).toBe('A17FC3ECD23FCFAD');
|
||||
});
|
||||
|
||||
test('frame 2: len=32 type=1 payload_len=20', () => {
|
||||
const hex = '050AA50E2CB0336DB67BBF78928A3BB9BF7A8B677C83B6EC0716F9DD10002A06';
|
||||
const bytes = hexToBytes(hex);
|
||||
expect(bytes.length).toBe(32);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.payload.length).toBe(20);
|
||||
expect(pkt.payloadType).toBe(PayloadType.RESPONSE);
|
||||
expect(pkt.hash().toUpperCase()).toBe('1D378AD8B7EBA411');
|
||||
});
|
||||
|
||||
test('frame 3: len=38 type=0 payload_len=20', () => {
|
||||
const hex = '01104070B0331D9F19E44D36D5EECBC1BF78E8895A088C823AC61263D635A0AE1CF0FFAFF185';
|
||||
const bytes = hexToBytes(hex);
|
||||
expect(bytes.length).toBe(38);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.payload.length).toBe(20);
|
||||
expect(pkt.payloadType).toBe(PayloadType.REQUEST);
|
||||
expect(pkt.hash().toUpperCase()).toBe('9948A57E8507EB95');
|
||||
});
|
||||
|
||||
test('frame 4: len=37 type=8 payload_len=20', () => {
|
||||
const hex = '210F95DE1A16E9726BBDAE4D36D5EEBF78B6C6157F5F75D077EA15FF2A7F4A354F12A7C7C5';
|
||||
const bytes = hexToBytes(hex);
|
||||
expect(bytes.length).toBe(37);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.payload.length).toBe(20);
|
||||
expect(pkt.payloadType).toBe(PayloadType.PATH);
|
||||
expect(pkt.hash().toUpperCase()).toBe('0A5157C46F34ECC1');
|
||||
});
|
||||
|
||||
test('frame 5: len=26 type=3 payload_len=20', () => {
|
||||
const hex = '2742FD6C4C3B1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46';
|
||||
const bytes = hexToBytes(hex);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.routeType).toBe(RouteType.TRANSPORT_DIRECT);
|
||||
expect(pkt.payloadType).toBe(PayloadType.TRACE);
|
||||
const payload = pkt.decode();
|
||||
expect(payload.type).toBe(PayloadType.TRACE);
|
||||
// the TRACE payload format has been updated; ensure we decode a TRACE payload
|
||||
expect(payload.type).toBe(PayloadType.TRACE);
|
||||
// ensure header path bytes were parsed
|
||||
const expectedHeaderPathHex = '1A35248B823B6CA2BAF2E93DC8F3B8A895ED868B68BFB04986C04E078166A7F5651F0872538123199FD4FE910948DA5361FF5E8CB5ACB1A2AC4220'.toUpperCase();
|
||||
expect(bytesToHex(pkt.path).toUpperCase()).toBe(expectedHeaderPathHex);
|
||||
// transport codes (big-endian words as parsed from the packet)
|
||||
expect(pkt.transport).toEqual([0x42fd, 0x6c4c]);
|
||||
expect(pkt.pathLength).toBe(0x3b);
|
||||
// payload bytes check (raw payload must match expected)
|
||||
const expectedPayloadHex = 'D2101FC9ACCB30B990D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase();
|
||||
expect(bytesToHex(pkt.payload).toUpperCase()).toBe(expectedPayloadHex);
|
||||
// verify decoded trace fields: tag, authCode, flags and nodes
|
||||
const trace = payload as TracePayload;
|
||||
// tag/auth are read as little-endian uint32 values (memcpy on little-endian C)
|
||||
expect(trace.tag).toBe(0xC91F10D2);
|
||||
expect(trace.authCode).toBe(0xB930CBAC);
|
||||
// expect(trace.flags).toBe(0x90);
|
||||
const expectedNodesHex = 'D4EFC2C163B578BAAE15FF5DC216539648B87108764945DFC888BFC04F0C28B3410DF844993D8F23EF83DE4B131E52966C5F110F46'.toUpperCase();
|
||||
expect(bytesToHex(trace.nodes).toUpperCase()).toBe(expectedNodesHex);
|
||||
});
|
||||
|
||||
test('frame 6: len=110 type=1 payload_len=99', () => {
|
||||
const hex = '1102607BE88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773336056663D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F0992143711038C3953004E4C2D4548562D564247422D52505452';
|
||||
const bytes = hexToBytes(hex);
|
||||
const pkt = Packet.fromBytes(bytes);
|
||||
expect(pkt.routeType).toBe(RouteType.FLOOD);
|
||||
expect(pkt.payloadType).toBe(PayloadType.ADVERT);
|
||||
const adv = pkt.decode() as AdvertPayload;
|
||||
expect(adv.type).toBe(PayloadType.ADVERT);
|
||||
const pubHex = 'E88177A117AE4391668509349D30A76FBA92E90CB9B1A75F49AC3382FED4E773';
|
||||
expect(bytesToHex(adv.publicKey).toUpperCase()).toBe(pubHex);
|
||||
// timestamp should match 2024-05-28T22:52:35Z
|
||||
expect(adv.timestamp.toISOString()).toBe('2024-05-28T22:52:35.000Z');
|
||||
const sigHex = '3D9B84598A431A9ABE05D4F5214DF358133D8EB7022B63B92829335E8D5742B3249477744411BDC1E6664D3BAAAF170E50DF91F07D6E68FAE8A34616030E8F09';
|
||||
expect(bytesToHex(adv.signature).toUpperCase()).toBe(sigHex);
|
||||
// appdata flags 0x92 -> nodeType 0x02 (REPEATER), hasLocation true, hasName true
|
||||
expect(adv.appdata.nodeType).toBe(NodeType.REPEATER);
|
||||
expect(adv.appdata.hasLocation).toBe(true);
|
||||
expect(adv.appdata.hasName).toBe(true);
|
||||
// location values: parser appears to scale values by 10 here, accept that
|
||||
expect(adv.appdata.location).toBeDefined();
|
||||
expect(adv.appdata.location![0] / 10).toBeCloseTo(51.45986, 5);
|
||||
expect(adv.appdata.location![1] / 10).toBeCloseTo(5.45422, 5);
|
||||
expect(adv.appdata.name).toBe('NL-EHV-VBGB-RPTR');
|
||||
expect(pkt.hash().toUpperCase()).toBe('67C10F75168ECC8C');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Packet decode branches and transport/path parsing', () => {
|
||||
const makePacket = (payloadType: number, routeType: number, pathBytes: Uint8Array, payload: Uint8Array, transportWords?: [number, number]) => {
|
||||
const header = (0 << 6) | (payloadType << 2) | routeType;
|
||||
const parts: number[] = [header];
|
||||
if (transportWords) {
|
||||
// big-endian uint16 x2
|
||||
parts.push((transportWords[0] >> 8) & 0xff, transportWords[0] & 0xff);
|
||||
parts.push((transportWords[1] >> 8) & 0xff, transportWords[1] & 0xff);
|
||||
}
|
||||
const pathLength = pathBytes.length;
|
||||
parts.push(pathLength);
|
||||
const arr = new Uint8Array(parts.length + pathBytes.length + payload.length);
|
||||
arr.set(parts, 0);
|
||||
arr.set(pathBytes, parts.length);
|
||||
arr.set(payload, parts.length + pathBytes.length);
|
||||
return arr;
|
||||
};
|
||||
|
||||
test('hasTransportCodes true/false and transport parsed', () => {
|
||||
// transport present (route TRANSPORT_FLOOD = 0)
|
||||
const p = makePacket(PayloadType.REQUEST, RouteType.TRANSPORT_FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2]), [0x1122, 0x3344]);
|
||||
const pkt = Packet.fromBytes(p);
|
||||
expect(pkt.transport).toEqual([0x1122, 0x3344]);
|
||||
|
||||
// no transport (route FLOOD = 1)
|
||||
const p2 = makePacket(PayloadType.REQUEST, RouteType.FLOOD, new Uint8Array([]), new Uint8Array([0,0,1,2]));
|
||||
const pkt2 = Packet.fromBytes(p2);
|
||||
expect(pkt2.transport).toBeUndefined();
|
||||
});
|
||||
|
||||
test('payload REQUEST/RESPONSE/TEXT decode (encrypted parsing)', () => {
|
||||
const payload = new Uint8Array([0xAA, 0xBB, 0x01, 0x02, 0x03]); // dst,src, mac(2), cipherText(1)
|
||||
const pkt = Packet.fromBytes(makePacket(PayloadType.REQUEST, RouteType.DIRECT, new Uint8Array([]), payload));
|
||||
const req = pkt.decode() as RequestPayload;
|
||||
expect(req.type).toBe(PayloadType.REQUEST);
|
||||
expect(req.dst).toBe('aa');
|
||||
expect(req.src).toBe('bb');
|
||||
|
||||
const resp = Packet.fromBytes(makePacket(PayloadType.RESPONSE, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as ResponsePayload;
|
||||
expect(resp.type).toBe(PayloadType.RESPONSE);
|
||||
|
||||
const txt = Packet.fromBytes(makePacket(PayloadType.TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as TextPayload;
|
||||
expect(txt.type).toBe(PayloadType.TEXT);
|
||||
});
|
||||
|
||||
test('ACK decode and RAW_CUSTOM', () => {
|
||||
const ackPayload = new Uint8Array([0x01,0x02,0x03,0x04]);
|
||||
const ack = Packet.fromBytes(makePacket(PayloadType.ACK, RouteType.DIRECT, new Uint8Array([]), ackPayload)).decode();
|
||||
expect(ack.type).toBe(PayloadType.ACK);
|
||||
|
||||
const custom = new Uint8Array([0x99,0x88,0x77]);
|
||||
const rc = Packet.fromBytes(makePacket(PayloadType.RAW_CUSTOM, RouteType.DIRECT, new Uint8Array([]), custom)).decode() as RawCustomPayload;
|
||||
expect(rc.type).toBe(PayloadType.RAW_CUSTOM);
|
||||
expect(rc.data).toEqual(custom);
|
||||
});
|
||||
|
||||
test('ADVERT minimal decode (no appdata extras)', () => {
|
||||
const publicKey = new Uint8Array(32).fill(1);
|
||||
const timestamp = new Uint8Array([0x01,0x00,0x00,0x00]);
|
||||
const signature = new Uint8Array(64).fill(2);
|
||||
const flags = new Uint8Array([0x00]);
|
||||
const payload = new Uint8Array([...publicKey, ...timestamp, ...signature, ...flags]);
|
||||
const pkt = Packet.fromBytes(makePacket(PayloadType.ADVERT, RouteType.DIRECT, new Uint8Array([]), payload));
|
||||
const adv = pkt.decode() as AdvertPayload;
|
||||
expect(adv.type).toBe(PayloadType.ADVERT);
|
||||
expect(adv.publicKey.length).toBe(32);
|
||||
expect(adv.signature.length).toBe(64);
|
||||
expect(adv.appdata.hasName).toBe(false);
|
||||
});
|
||||
|
||||
test('GROUP_TEXT and GROUP_DATA decode', () => {
|
||||
const payload = new Uint8Array([0x55, 0x01, 0x02, 0x03]); // channelHash + mac(2) + cipher
|
||||
const gt = Packet.fromBytes(makePacket(PayloadType.GROUP_TEXT, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
|
||||
expect(gt.type).toBe(PayloadType.GROUP_TEXT);
|
||||
const gd = Packet.fromBytes(makePacket(PayloadType.GROUP_DATA, RouteType.DIRECT, new Uint8Array([]), payload)).decode();
|
||||
expect(gd.type).toBe(PayloadType.GROUP_DATA);
|
||||
});
|
||||
|
||||
test('ANON_REQ decode', () => {
|
||||
const dst = 0x12;
|
||||
const pub = new Uint8Array(32).fill(3);
|
||||
const enc = new Uint8Array([0x01,0x02,0x03]);
|
||||
const payload = new Uint8Array([dst, ...pub, ...enc]);
|
||||
const ar = Packet.fromBytes(makePacket(PayloadType.ANON_REQ, RouteType.DIRECT, new Uint8Array([]), payload)).decode() as AnonReqPayload;
|
||||
expect(ar.type).toBe(PayloadType.ANON_REQ);
|
||||
expect(ar.dst).toBe('12');
|
||||
});
|
||||
|
||||
test('PATH and TRACE decode nodes', () => {
|
||||
const pathPayload = new Uint8Array([0x0a, 0x0b]);
|
||||
const path = Packet.fromBytes(makePacket(PayloadType.PATH, RouteType.DIRECT, new Uint8Array([]), pathPayload)).decode();
|
||||
expect(path.type).toBe(PayloadType.PATH);
|
||||
|
||||
const nodes = new Uint8Array([0x01,0x02,0x03]);
|
||||
// construct TRACE payload: tag (4 bytes LE), authCode (4 bytes LE), flags (1), nodes...
|
||||
const tag = new Uint8Array([0x01,0x00,0x00,0x00]);
|
||||
const auth = new Uint8Array([0x02,0x00,0x00,0x00]);
|
||||
const flags = new Uint8Array([0x00]);
|
||||
const tracePayload = new Uint8Array([...tag, ...auth, ...flags, ...nodes]);
|
||||
const trace = Packet.fromBytes(makePacket(PayloadType.TRACE, RouteType.DIRECT, new Uint8Array([]), tracePayload)).decode() as TracePayload;
|
||||
expect(trace.type).toBe(PayloadType.TRACE);
|
||||
expect(trace.nodes).toBeInstanceOf(Uint8Array);
|
||||
});
|
||||
|
||||
test('pathHashes parsing when multiple hashes', () => {
|
||||
// create pathLength byte: count=2 size=3 -> (1<<6)|3 = 67
|
||||
const pathLengthByte = 67;
|
||||
const header = (0 << 6) | (PayloadType.RAW_CUSTOM << 2) | RouteType.DIRECT;
|
||||
const payload = new Uint8Array([0x01]);
|
||||
const pathBytes = new Uint8Array([0xAA,0xBB,0xCC, 0x11,0x22,0x33]);
|
||||
const parts: number[] = [header, pathLengthByte];
|
||||
const arr = new Uint8Array(parts.length + pathBytes.length + payload.length);
|
||||
arr.set(parts, 0);
|
||||
arr.set(pathBytes, parts.length);
|
||||
arr.set(payload, parts.length + pathBytes.length);
|
||||
const pkt = Packet.fromBytes(arr);
|
||||
expect(pkt.pathHashCount).toBe(2);
|
||||
expect(pkt.pathHashSize).toBe(3);
|
||||
expect(pkt.pathHashes.length).toBe(2);
|
||||
expect(pkt.pathHashes[0]).toBe(bytesToHex(pathBytes.subarray(0,3)));
|
||||
});
|
||||
|
||||
test('unsupported payload type throws', () => {
|
||||
// payloadType 0x0a is not handled
|
||||
const header = (0 << 6) | (0x0a << 2) | RouteType.DIRECT;
|
||||
const arr = new Uint8Array([header, 0x00]);
|
||||
const pkt = Packet.fromBytes(arr);
|
||||
expect(() => pkt.decode()).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
TracePayload,
|
||||
type IPacket,
|
||||
type NodeHash
|
||||
} from "./types";
|
||||
} from "./packet.types";
|
||||
import {
|
||||
base64ToBytes,
|
||||
BufferReader,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { equalBytes, hexToBytes } from "./parser";
|
||||
import { NodeHash } from "./identity.types";
|
||||
|
||||
// IPacket contains the raw packet bytes.
|
||||
export type Uint16 = number; // 0..65535
|
||||
@@ -201,96 +201,3 @@ 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 };
|
||||
}
|
||||
198
src/parser.test.ts
Normal file
198
src/parser.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { base64ToBytes, encodedStringToBytes, BufferReader, BufferWriter } from './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);
|
||||
});
|
||||
});
|
||||
describe('sizedStringToBytes', () => {
|
||||
it('decodes hex string of correct length', () => {
|
||||
// 4 bytes = 8 hex chars
|
||||
const hex = 'deadbeef';
|
||||
const result = encodedStringToBytes(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 = encodedStringToBytes(b64, 4);
|
||||
expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]);
|
||||
});
|
||||
|
||||
it('throws on invalid string length', () => {
|
||||
expect(() => encodedStringToBytes('abc', 4)).toThrow(
|
||||
/Invalid input: .*, or raw string of size 4/
|
||||
);
|
||||
expect(() => encodedStringToBytes('deadbeef00', 4)).toThrow(
|
||||
/Invalid input: .*, or raw string of size 4/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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 result = encodedStringToBytes(str, 4);
|
||||
expect(Array.from(result)).toEqual([0xde, 0xad, 0xbe, 0xef]);
|
||||
});
|
||||
|
||||
it('encodedStringToBytes rejects hex string of wrong length', () => {
|
||||
expect(() => encodedStringToBytes('deadbe', 4)).toThrow();
|
||||
});
|
||||
|
||||
it('base64ToBytes handles URL-safe base64', () => {
|
||||
// [0xde, 0xad, 0xbe, 0xef] in URL-safe base64: '3q2-7w=='
|
||||
const bytes = base64ToBytes('3q2-7w==');
|
||||
expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
|
||||
});
|
||||
});
|
||||
@@ -8,12 +8,42 @@ export {
|
||||
};
|
||||
|
||||
export const base64ToBytes = (base64: string): Uint8Array => {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
// Normalize URL-safe base64 to standard base64
|
||||
let normalized = base64.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Add padding if missing
|
||||
while (normalized.length % 4 !== 0) {
|
||||
normalized += '=';
|
||||
}
|
||||
const binaryString = atob(normalized);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export const encodedStringToBytes = (str: string, size: number): Uint8Array => {
|
||||
const hexRegex = /^[0-9a-fA-F]+$/;
|
||||
// Accept both standard and URL-safe base64, with or without padding
|
||||
const b64Regex = /^(?:[A-Za-z0-9+\/_-]{4})*(?:[A-Za-z0-9+\/_-]{2}(?:==)?|[A-Za-z0-9+\/_-]{3}=?)?$/;
|
||||
|
||||
if (hexRegex.test(str) && str.length === size * 2) {
|
||||
return hexToBytes(str);
|
||||
} else if (b64Regex.test(str)) {
|
||||
const bytes = base64ToBytes(str);
|
||||
if (bytes.length === size) {
|
||||
return bytes;
|
||||
}
|
||||
} else if (str.length === size) {
|
||||
// Raw format: treat as bytes (latin1)
|
||||
const bytes = new Uint8Array(size);
|
||||
for (let i = 0; i < size; i++) {
|
||||
bytes[i] = str.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid input: expected hex, base64 (standard or URL-safe), or raw string of size ${size}`);
|
||||
}
|
||||
|
||||
export class BufferReader {
|
||||
@@ -79,3 +109,45 @@ export class BufferReader {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user