We can not sensibly parse both hex and base64, assume all input is hex
This commit is contained in:
@@ -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}`);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
if (size !== undefined && bytes.length !== size) {
|
||||||
|
throw new Error(`Invalid base64 length: expected ${size} bytes, got ${bytes.length}`);
|
||||||
|
}
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const encodedStringToBytes = (str: string, size: number): Uint8Array => {
|
// Note: encodedStringToBytes removed — prefer explicit parsers.
|
||||||
const hexRegex = /^[0-9a-fA-F]+$/;
|
// Use `hexToBytes` for hex inputs and `base64ToBytes` for base64 inputs.
|
||||||
// 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) {
|
// Wrapper around @noble/hashes hexToBytes that optionally validates size.
|
||||||
return hexToBytes(str);
|
export const hexToBytes = (hex: string, size?: number): Uint8Array => {
|
||||||
} else if (b64Regex.test(str)) {
|
const bytes = nobleHexToBytes(hex);
|
||||||
const bytes = base64ToBytes(str);
|
if (size !== undefined && bytes.length !== size) {
|
||||||
if (bytes.length === size) {
|
throw new Error(`Invalid hex length: expected ${size} bytes, got ${bytes.length}`);
|
||||||
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}`);
|
};
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user