We can not sensibly parse both hex and base64, assume all input is hex

This commit is contained in:
2026-03-10 17:48:51 +01:00
parent 7a2522cf32
commit a30448c130
4 changed files with 45 additions and 61 deletions

View File

@@ -1,14 +1,13 @@
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 { 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 PRIVATE_KEY_SIZE = 32;
const HMAC_SIZE = 2;
const SHARED_SECRET_SIZE = 32;
const SIGNATURE_SIZE = 64;
@@ -19,7 +18,7 @@ export class PublicKey implements IPublicKey {
constructor(key: Uint8Array | string) {
if (typeof key === 'string') {
this.key = encodedStringToBytes(key, PUBLIC_KEY_SIZE);
this.key = hexToBytes(key, PUBLIC_KEY_SIZE);
} else if (key instanceof Uint8Array) {
this.key = key;
} else {
@@ -46,7 +45,7 @@ export class PublicKey implements IPublicKey {
} else if (other instanceof Uint8Array) {
otherKey = other;
} else if (typeof other === 'string') {
otherKey = encodedStringToBytes(other, PUBLIC_KEY_SIZE);
otherKey = hexToBytes(other, PUBLIC_KEY_SIZE);
} else {
throw new Error('Invalid type for PublicKey comparison');
}
@@ -67,7 +66,7 @@ export class PrivateKey {
constructor(seed: Uint8Array | string) {
if (typeof seed === 'string') {
seed = encodedStringToBytes(seed, SEED_SIZE);
seed = hexToBytes(seed, SEED_SIZE);
}
if (seed.length !== SEED_SIZE) {
throw new Error(`Invalid seed length: expected ${SEED_SIZE} bytes, got ${seed.length}`);
@@ -165,15 +164,6 @@ export class SharedSecret implements ISharedSecret {
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 });
@@ -204,7 +194,7 @@ export class SharedSecret implements ISharedSecret {
static fromName(name: string): SharedSecret {
if (name === "Public") {
return new SharedSecret(hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72"));
return new SharedSecret(hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72", 16));
} else if (!/^#/.test(name)) {
throw new Error("Only the 'Public' group or groups starting with '#' are supported");
}
@@ -218,7 +208,7 @@ export class StaticSecret implements IStaticSecret {
constructor(secret: Uint8Array | string) {
if (typeof secret === 'string') {
secret = encodedStringToBytes(secret, STATIC_SECRET_SIZE);
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}`);

View File

@@ -17,8 +17,8 @@ import {
TextPayload,
TracePayload,
type IPacket,
type NodeHash
} from "./packet.types";
import { NodeHash } from "./identity.types";
import {
base64ToBytes,
BufferReader,

View File

@@ -1,14 +1,14 @@
import { describe, it, expect } from 'vitest';
import { base64ToBytes, encodedStringToBytes, BufferReader, BufferWriter } from './parser';
import { base64ToBytes, hexToBytes, BufferReader, BufferWriter } from './parser';
describe('base64ToBytes', () => {
it('decodes a simple base64 string', () => {
const bytes = base64ToBytes('aGVsbG8='); // "hello"
const bytes = base64ToBytes('aGVsbG8=', 5); // "hello"
expect(Array.from(bytes)).toEqual([104, 101, 108, 108, 111]);
});
it('handles empty string', () => {
const bytes = base64ToBytes('');
const bytes = base64ToBytes('', 0);
expect(bytes).toBeInstanceOf(Uint8Array);
expect(bytes.length).toBe(0);
});
@@ -69,24 +69,20 @@ describe('sizedStringToBytes', () => {
it('decodes hex string of correct length', () => {
// 4 bytes = 8 hex chars
const hex = 'deadbeef';
const result = encodedStringToBytes(hex, 4);
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 = encodedStringToBytes(b64, 4);
const result = base64ToBytes(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/
);
expect(() => hexToBytes('abc', 4)).toThrow();
expect(() => hexToBytes('deadbeef00', 4)).toThrow();
});
});
@@ -182,17 +178,18 @@ describe('BufferWriter', () => {
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]);
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('encodedStringToBytes rejects hex string of wrong length', () => {
expect(() => encodedStringToBytes('deadbe', 4)).toThrow();
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==');
const bytes = base64ToBytes('3q2-7w==', 4);
expect(Array.from(bytes)).toEqual([0xde, 0xad, 0xbe, 0xef]);
});
});

View File

@@ -1,13 +1,12 @@
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 {
bytesToHex,
hexToBytes,
equalBytes
};
export const base64ToBytes = (base64: string): Uint8Array => {
export const base64ToBytes = (base64: string, size?: number): Uint8Array => {
// Normalize URL-safe base64 to standard base64
let normalized = base64.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if missing
@@ -19,32 +18,23 @@ export const base64ToBytes = (base64: string): Uint8Array => {
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
if (size !== undefined && bytes.length !== size) {
throw new Error(`Invalid base64 length: expected ${size} bytes, got ${bytes.length}`);
}
return bytes;
}
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}=?)?$/;
// Note: encodedStringToBytes removed — prefer explicit parsers.
// Use `hexToBytes` for hex inputs and `base64ToBytes` for base64 inputs.
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;
// 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;
}
throw new Error(`Invalid input: expected hex, base64 (standard or URL-safe), or raw string of size ${size}`);
}
};
export class BufferReader {
private buffer: Uint8Array;
@@ -56,6 +46,7 @@ export class BufferReader {
}
public readByte(): number {
if (!this.hasMore()) throw new Error('read past end');
return this.buffer[this.offset++];
}
@@ -63,6 +54,7 @@ export class BufferReader {
if (length === undefined) {
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);
this.offset += length;
return bytes;
@@ -77,31 +69,36 @@ export class BufferReader {
}
public peekByte(): number {
if (!this.hasMore()) throw new Error('read past end');
return this.buffer[this.offset];
}
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);
this.offset += 2;
return value;
}
public readUint32LE(): number {
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8) | (this.buffer[this.offset + 2] << 16) | (this.buffer[this.offset + 3] << 24);
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;
return value;
}
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);
this.offset += 2;
return value < 0x8000 ? value : value - 0x10000;
}
public readInt32LE(): number {
const value = this.buffer[this.offset] | (this.buffer[this.offset + 1] << 8) | (this.buffer[this.offset + 2] << 16) | (this.buffer[this.offset + 3] << 24);
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;
return value < 0x80000000 ? value : value - 0x100000000;
return u < 0x80000000 ? u : u - 0x100000000;
}
public readTimestamp(): Date {