kenqtade's picture
Use ken-q org namespace for project Spaces
0865492
Raw
History Blame Contribute Delete
14.9 kB
/**
* BLAKE3 Hasher - Incremental hashing with support for all modes
*
* Supports:
* - Regular hashing
* - Keyed hashing (MAC)
* - Key derivation (derive_key)
* - XOF (eXtendable Output Function) mode
*/
import { compress } from "./compress.js";
import {
IV,
CHUNK_START,
CHUNK_END,
PARENT,
ROOT,
KEYED_HASH,
DERIVE_KEY_CONTEXT,
DERIVE_KEY_MATERIAL,
BLOCK_LEN,
CHUNK_LEN,
OUT_LEN,
KEY_LEN,
MAX_DEPTH,
} from "./constants.js";
import {
IS_LITTLE_ENDIAN,
readLittleEndianWordsFull,
writeLittleEndianBytesPartial,
encodeUTF8,
} from "./utils.js";
/**
* Output state for XOF (eXtendable Output Function) mode.
* Allows reading arbitrary amounts of output.
*/
export class XofReader {
private inputCv: Uint32Array;
private blockWords: Uint32Array;
private counter: number;
private blockLen: number;
private flags: number;
private outputBlock: Uint32Array;
private outputBlockOffset: number;
constructor(
inputCv: Uint32Array,
blockWords: Uint32Array,
counter: number,
blockLen: number,
flags: number,
) {
this.inputCv = inputCv;
this.blockWords = blockWords;
this.counter = counter;
this.blockLen = blockLen;
this.flags = flags | ROOT;
this.outputBlock = new Uint32Array(16);
this.outputBlockOffset = 64; // Forces generation on first read
}
/**
* Read the next `length` bytes of output.
*/
read(length: number): Uint8Array {
const output = new Uint8Array(length);
let outputOffset = 0;
while (outputOffset < length) {
// Generate new output block if needed
if (this.outputBlockOffset >= 64) {
compress(
this.inputCv,
0,
this.blockWords,
0,
this.outputBlock,
0,
true, // full 64-byte output
this.counter++,
this.blockLen,
this.flags,
);
this.outputBlockOffset = 0;
}
// Copy bytes from output block
const available = 64 - this.outputBlockOffset;
const toCopy = Math.min(available, length - outputOffset);
// Optimized copy using writeLittleEndianBytesPartial
const wordOffset = this.outputBlockOffset >>> 2;
const byteWithinWord = this.outputBlockOffset & 3;
if (byteWithinWord === 0 && toCopy >= 4) {
// Aligned copy - can use word-at-a-time
const fullWords = toCopy >>> 2;
writeLittleEndianBytesPartial(
this.outputBlock,
wordOffset,
output,
outputOffset,
fullWords << 2,
);
const bytesCopied = fullWords << 2;
outputOffset += bytesCopied;
this.outputBlockOffset += bytesCopied;
} else {
// Byte-by-byte for unaligned access
for (let i = 0; i < toCopy; i++) {
const wordIdx = (this.outputBlockOffset + i) >>> 2;
const byteIdx = (this.outputBlockOffset + i) & 3;
output[outputOffset + i] = (this.outputBlock[wordIdx] >>> (byteIdx << 3)) & 0xff;
}
outputOffset += toCopy;
this.outputBlockOffset += toCopy;
}
}
return output;
}
}
/**
* Chunk state for processing input data.
* Each chunk is 1024 bytes and produces an 8-word chaining value.
*/
class ChunkState {
chainingValue: Uint32Array;
chunkCounter: number;
blockWords: Uint32Array;
blockLen: number;
blocksCompressed: number;
flags: number;
constructor(keyWords: Uint32Array, chunkCounter: number, flags: number) {
this.chainingValue = new Uint32Array(keyWords);
this.chunkCounter = chunkCounter;
this.blockWords = new Uint32Array(16);
this.blockLen = 0;
this.blocksCompressed = 0;
this.flags = flags;
}
resetTo(keyWords: Uint32Array, chunkCounter: number, flags: number): void {
this.chainingValue.set(keyWords);
this.chunkCounter = chunkCounter;
this.blockLen = 0;
this.blocksCompressed = 0;
this.flags = flags;
}
/**
* Get the flags for the current block.
*/
private startFlag(): number {
return this.blocksCompressed === 0 ? CHUNK_START : 0;
}
/**
* Update the chunk state with input data.
* Returns the number of bytes consumed.
*/
update(input: Uint8Array, inputOffset: number, inputLen: number): number {
let consumed = 0;
while (inputLen > 0) {
// If we have a full block, compress it
if (this.blockLen === BLOCK_LEN) {
compress(
this.chainingValue,
0,
this.blockWords,
0,
this.chainingValue,
0,
false,
this.chunkCounter,
BLOCK_LEN,
this.flags | this.startFlag(),
);
this.blocksCompressed++;
this.blockLen = 0;
}
// Fill the block buffer
const want = BLOCK_LEN - this.blockLen;
const take = Math.min(want, inputLen);
if (this.blockLen === 0 && take === BLOCK_LEN) {
readLittleEndianWordsFull(input, inputOffset, this.blockWords);
} else {
// Partial block - byte-by-byte into correct position
for (let i = 0; i < take; i++) {
const pos = this.blockLen + i;
const wordIdx = pos >>> 2;
const byteIdx = pos & 3;
if (byteIdx === 0) {
this.blockWords[wordIdx] = input[inputOffset + i];
} else {
this.blockWords[wordIdx] |= input[inputOffset + i] << (byteIdx << 3);
}
}
}
this.blockLen += take;
inputOffset += take;
inputLen -= take;
consumed += take;
}
return consumed;
}
/**
* Finalize this chunk and return its output.
* Returns 8 words (chaining value) or 16 words (if root).
*/
output(): {
inputCv: Uint32Array;
blockWords: Uint32Array;
blockLen: number;
counter: number;
flags: number;
} {
// Zero-pad unused words in blockWords to avoid stale data from previous blocks
// This is necessary when a partial block follows a full block within the same chunk
const usedWords = (this.blockLen + 3) >>> 2; // ceil(blockLen / 4)
for (let i = usedWords; i < 16; i++) {
this.blockWords[i] = 0;
}
return {
inputCv: this.chainingValue,
blockWords: this.blockWords,
blockLen: this.blockLen,
counter: this.chunkCounter,
flags: this.flags | this.startFlag() | CHUNK_END,
};
}
/**
* Get the number of bytes in this chunk.
*/
len(): number {
return this.blocksCompressed * BLOCK_LEN + this.blockLen;
}
}
/**
* Main BLAKE3 Hasher class.
*
* Usage:
* const hasher = new Hasher();
* hasher.update(data);
* const hash = hasher.finalize();
*
* Or with chaining:
* const hash = new Hasher().update(data).finalize();
*/
export class Hasher {
private chunkState: ChunkState;
private keyWords: Uint32Array;
private cvStack: Uint32Array;
private cvStackLen: number;
private flags: number;
private parentBlock: Uint32Array;
private parentCv: Uint32Array;
private chunkCv: Uint32Array;
private outWords: Uint32Array;
private finalizeCv: Uint32Array;
/**
* Create a new Hasher.
*
* @param keyWords - Initial key words (IV for regular hashing)
* @param flags - Domain separation flags
*/
constructor(keyWords?: Uint32Array, flags?: number) {
this.keyWords = keyWords ? new Uint32Array(keyWords) : new Uint32Array(IV);
this.flags = flags ?? 0;
this.chunkState = new ChunkState(this.keyWords, 0, this.flags);
this.cvStack = new Uint32Array(MAX_DEPTH * 8);
this.cvStackLen = 0;
this.parentBlock = new Uint32Array(16);
this.parentCv = new Uint32Array(8);
this.chunkCv = new Uint32Array(8);
this.outWords = new Uint32Array(16);
this.finalizeCv = new Uint32Array(8);
}
/**
* Reset the hasher to process a new message with the same key/flags.
* Reuses all internal buffers — zero allocations.
*/
reset(): this {
this.chunkState.resetTo(this.keyWords, 0, this.flags);
this.cvStackLen = 0;
return this;
}
/**
* Create a new keyed hasher (MAC).
*
* @param key - 32-byte key
*/
static newKeyed(key: Uint8Array): Hasher {
if (key.length !== KEY_LEN) {
throw new Error(`Key must be ${KEY_LEN} bytes, got ${key.length}`);
}
const keyWords = new Uint32Array(8);
if (IS_LITTLE_ENDIAN) {
const view = new Uint32Array(key.buffer, key.byteOffset, 8);
keyWords.set(view);
} else {
for (let i = 0; i < 8; i++) {
const off = i * 4;
keyWords[i] = key[off] | (key[off + 1] << 8) | (key[off + 2] << 16) | (key[off + 3] << 24);
}
}
return new Hasher(keyWords, KEYED_HASH);
}
/**
* Create a new key derivation hasher.
*
* @param context - Context string for domain separation
*/
static newDeriveKey(context: string): Hasher {
// First, hash the context string with DERIVE_KEY_CONTEXT flag
const contextBytes = encodeUTF8(context);
const contextHasher = new Hasher(new Uint32Array(IV), DERIVE_KEY_CONTEXT);
contextHasher.update(contextBytes);
// Get the context key
const contextKey = new Uint32Array(8);
const output = contextHasher.finalizeOutput();
compress(
output.inputCv,
0,
output.blockWords,
0,
contextKey,
0,
false,
output.counter,
output.blockLen,
output.flags | ROOT,
);
// Return a hasher initialized with the context key
return new Hasher(contextKey, DERIVE_KEY_MATERIAL);
}
/**
* Push a chaining value onto the stack.
*/
private pushCv(cv: Uint32Array, cvOffset: number): void {
this.cvStack.set(cv.subarray(cvOffset, cvOffset + 8), this.cvStackLen * 8);
this.cvStackLen++;
}
/**
* Pop a chaining value from the stack.
*/
private popCv(out: Uint32Array, outOffset: number): void {
this.cvStackLen--;
out.set(this.cvStack.subarray(this.cvStackLen * 8, (this.cvStackLen + 1) * 8), outOffset);
}
/**
* Add a chunk's chaining value and merge completed subtrees.
*/
private addChunkCv(newCv: Uint32Array, newCvOffset: number, totalChunks: number): void {
const parentBlock = this.parentBlock;
const parentCv = this.parentCv;
while ((totalChunks & 1) === 0) {
// Pop left child, new CV is right child
this.popCv(parentBlock, 0);
parentBlock.set(newCv.subarray(newCvOffset, newCvOffset + 8), 8);
compress(
this.keyWords,
0,
parentBlock,
0,
parentCv,
0,
false,
0,
BLOCK_LEN,
this.flags | PARENT,
);
newCv = parentCv;
newCvOffset = 0;
totalChunks >>>= 1;
}
this.pushCv(newCv, newCvOffset);
}
/**
* Update the hasher with input data.
*
* @param input - Data to hash
* @returns this (for chaining)
*/
update(input: Uint8Array): this {
let inputOffset = 0;
let inputLen = input.length;
// Fill the current chunk
while (inputLen > 0) {
// If current chunk is full, finalize it and start a new one
if (this.chunkState.len() === CHUNK_LEN) {
const output = this.chunkState.output();
const chunkCv = this.chunkCv;
compress(
output.inputCv,
0,
output.blockWords,
0,
chunkCv,
0,
false,
output.counter,
output.blockLen,
output.flags,
);
const totalChunks = this.chunkState.chunkCounter + 1;
this.addChunkCv(chunkCv, 0, totalChunks);
this.chunkState.resetTo(this.keyWords, totalChunks, this.flags);
}
// Fill the current chunk
const want = CHUNK_LEN - this.chunkState.len();
const take = Math.min(want, inputLen);
this.chunkState.update(input, inputOffset, take);
inputOffset += take;
inputLen -= take;
}
return this;
}
/**
* Get the output parameters (for XOF mode or finalization).
*/
private finalizeOutput(): {
inputCv: Uint32Array;
blockWords: Uint32Array;
blockLen: number;
counter: number;
flags: number;
} {
let output = this.chunkState.output();
let parentBlock = this.parentBlock;
let cv = this.finalizeCv;
// If there are chunks on the stack, merge them
if (this.cvStackLen > 0) {
// First compress the current chunk
compress(
output.inputCv,
0,
output.blockWords,
0,
cv,
0,
false,
output.counter,
output.blockLen,
output.flags,
);
// Merge with parent nodes from stack
while (this.cvStackLen > 0) {
this.cvStackLen--;
parentBlock.set(this.cvStack.subarray(this.cvStackLen * 8, (this.cvStackLen + 1) * 8), 0);
parentBlock.set(cv, 8);
if (this.cvStackLen > 0) {
compress(
this.keyWords,
0,
parentBlock,
0,
cv,
0,
false,
0,
BLOCK_LEN,
this.flags | PARENT,
);
} else {
// This is the root - return output params
return {
inputCv: this.keyWords,
blockWords: parentBlock,
blockLen: BLOCK_LEN,
counter: 0,
flags: this.flags | PARENT,
};
}
}
}
// Single chunk case
return output;
}
/**
* Finalize the hash and return the result.
*
* @param outputLength - Number of bytes to output (default: 32)
* @returns The hash output
*/
finalize(outputLength: number = OUT_LEN): Uint8Array {
const output = this.finalizeOutput();
const result = new Uint8Array(outputLength);
if (outputLength <= 64) {
const outWords = this.outWords;
compress(
output.inputCv,
0,
output.blockWords,
0,
outWords,
0,
outputLength > 32, // full output if > 32 bytes
output.counter,
output.blockLen,
output.flags | ROOT,
);
if (IS_LITTLE_ENDIAN) {
const outBytes = new Uint8Array(outWords.buffer);
result.set(outBytes.subarray(0, outputLength));
} else {
writeLittleEndianBytesPartial(outWords, 0, result, 0, outputLength);
}
} else {
// Multiple blocks - use XOF
const xof = this.finalizeXof();
const full = xof.read(outputLength);
result.set(full);
}
return result;
}
/**
* Finalize and return an XOF reader for arbitrary-length output.
*/
finalizeXof(): XofReader {
const output = this.finalizeOutput();
return new XofReader(
new Uint32Array(output.inputCv),
new Uint32Array(output.blockWords),
output.counter,
output.blockLen,
output.flags,
);
}
}