Spaces:
Sleeping
Sleeping
| /** | |
| * 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, | |
| ); | |
| } | |
| } | |