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 { ed25519, x25519 } from '@noble/curves/ed25519.js';
import { sha256 } from "@noble/hashes/sha2.js"; import { sha256 } from "@noble/hashes/sha2.js";
import { hmac } from '@noble/hashes/hmac.js'; import { hmac } from '@noble/hashes/hmac.js';
import { ecb, ecb, encrypt } from '@noble/ciphers/aes.js'; import { ecb } from '@noble/ciphers/aes.js';
import { bytesToHex, equalBytes, hexToBytes, encodedStringToBytes } from "./parser"; import { bytesToHex, equalBytes, hexToBytes } from "./parser";
import { IPublicKey, ISharedSecret, IStaticSecret } from './crypto.types'; import { IPublicKey, ISharedSecret, IStaticSecret } from './crypto.types';
import { NodeHash } from './identity.types'; import { NodeHash } from './identity.types';
const PUBLIC_KEY_SIZE = 32; const PUBLIC_KEY_SIZE = 32;
const SEED_SIZE = 32; const SEED_SIZE = 32;
const PRIVATE_KEY_SIZE = 32;
const HMAC_SIZE = 2; const HMAC_SIZE = 2;
const SHARED_SECRET_SIZE = 32; const SHARED_SECRET_SIZE = 32;
const SIGNATURE_SIZE = 64; const SIGNATURE_SIZE = 64;
@@ -19,7 +18,7 @@ export class PublicKey implements IPublicKey {
constructor(key: Uint8Array | string) { constructor(key: Uint8Array | string) {
if (typeof key === 'string') { if (typeof key === 'string') {
this.key = encodedStringToBytes(key, PUBLIC_KEY_SIZE); this.key = hexToBytes(key, PUBLIC_KEY_SIZE);
} else if (key instanceof Uint8Array) { } else if (key instanceof Uint8Array) {
this.key = key; this.key = key;
} else { } else {
@@ -46,7 +45,7 @@ export class PublicKey implements IPublicKey {
} else if (other instanceof Uint8Array) { } else if (other instanceof Uint8Array) {
otherKey = other; otherKey = other;
} else if (typeof other === 'string') { } else if (typeof other === 'string') {
otherKey = encodedStringToBytes(other, PUBLIC_KEY_SIZE); otherKey = hexToBytes(other, PUBLIC_KEY_SIZE);
} else { } else {
throw new Error('Invalid type for PublicKey comparison'); throw new Error('Invalid type for PublicKey comparison');
} }
@@ -67,7 +66,7 @@ export class PrivateKey {
constructor(seed: Uint8Array | string) { constructor(seed: Uint8Array | string) {
if (typeof seed === 'string') { if (typeof seed === 'string') {
seed = encodedStringToBytes(seed, SEED_SIZE); seed = hexToBytes(seed, SEED_SIZE);
} }
if (seed.length !== SEED_SIZE) { if (seed.length !== SEED_SIZE) {
throw new Error(`Invalid seed length: expected ${SEED_SIZE} bytes, got ${seed.length}`); 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); 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 } { public encrypt(data: Uint8Array): { hmac: Uint8Array, ciphertext: Uint8Array } {
const key = this.secret.slice(0, 16); const key = this.secret.slice(0, 16);
const cipher = ecb(key, { disablePadding: true }); const cipher = ecb(key, { disablePadding: true });
@@ -204,7 +194,7 @@ export class SharedSecret implements ISharedSecret {
static fromName(name: string): SharedSecret { static fromName(name: string): SharedSecret {
if (name === "Public") { if (name === "Public") {
return new SharedSecret(hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72")); return new SharedSecret(hexToBytes("8b3387e9c5cdea6ac9e5edbaa115cd72", 16));
} else if (!/^#/.test(name)) { } else if (!/^#/.test(name)) {
throw new Error("Only the 'Public' group or groups starting with '#' are supported"); 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) { constructor(secret: Uint8Array | string) {
if (typeof secret === 'string') { if (typeof secret === 'string') {
secret = encodedStringToBytes(secret, STATIC_SECRET_SIZE); secret = hexToBytes(secret, STATIC_SECRET_SIZE);
} }
if (secret.length !== STATIC_SECRET_SIZE) { if (secret.length !== STATIC_SECRET_SIZE) {
throw new Error(`Invalid static secret length: expected ${STATIC_SECRET_SIZE} bytes, got ${secret.length}`); throw new Error(`Invalid static secret length: expected ${STATIC_SECRET_SIZE} bytes, got ${secret.length}`);

View File

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

View File

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

View File

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