DEV Community

Yusuf İhsan Görgel
Yusuf İhsan Görgel

Posted on

BLAKE3's Hash Output Does Not Stop at 32 Bytes

A hash function normally gives you one fixed-size thing. SHA-256 always returns 32 bytes. SHA-512 always returns 64 bytes. Feed in a byte or a gigabyte, the output size never moves.

BLAKE3 does not work that way. The 32 bytes you get back from a default call are the first 32 bytes of an output stream that has no fixed end. You can ask for 64 bytes, 1000 bytes, or a specific 32-byte slice starting a million bytes into the stream, and each of those is a well-defined, deterministic answer. This is called an extendable-output function, or XOF.

I use blake3_ffi, a Dart FFI wrapper around the reference BLAKE3 C implementation, and its docs state this directly: "you may request any output length (BLAKE3 is an extendable-output function)." The part worth spending time on is the seek parameter that comes with it: it gives you random access into that stream, not just a way to ask for more bytes.

Contrast between a fixed-digest hash, one box of 32 bytes, and BLAKE3's unbounded output stream, where any 32-byte window can be read directly by its offset.

What seek does

SHA-256 finalizes by running one more compression round and stopping. BLAKE3 finalizes differently: its output is generated from a root chaining value and a block counter, the same way a stream cipher generates a keystream from a key and a counter. Reading bytes [0, 32) and reading bytes [1,000,000, 1,000,032) cost the same, because both are just picking a counter value and computing that block directly.

seek is the parameter that picks the counter. In blake3_ffi it lives on Blake3Hasher.finalize:

Uint8List finalize({int outputLength = blake3OutLength, int seek = 0})
Enter fullscreen mode Exit fullscreen mode

and its hex counterpart, finalizeHex. Calling finalize(seek: 64, outputLength: 32) returns output bytes 64 through 95 of the stream, computed directly, without first computing bytes 0 through 63. The doc comment on the native binding underneath states the mechanism plainly: it writes outLength bytes starting at extended-output offset seek, because BLAKE3 is an unbounded XOF and seek selects the window.

One thing to flag up front: seek is not on the convenience functions. blake3(), blake3Keyed(), blake3DeriveKey(), and the streaming helpers blake3Stream()/blake3HexStream() only take outputLength. To use seek you drop down to Blake3Hasher.

import 'package:blake3_ffi/blake3_ffi.dart';

final hasher = Blake3Hasher();
hasher.update(data);

final first32 = hasher.finalize(outputLength: 32);           // bytes 0..31
final next32 = hasher.finalize(seek: 32, outputLength: 32);   // bytes 32..63

hasher.dispose();
Enter fullscreen mode Exit fullscreen mode

first32 here is the same as calling blake3(data), since seek defaults to 0. next32 is a different 32 bytes read further along the same stream, for the same input. Extending the output length never changes the bytes you already read. It is the same stream, you are just reading more of it.

A use case: independent sub-slices without sequential chaining

The standard tool for turning one secret into many derived keys is HKDF-expand, from RFC 5869. It is sequential by construction: T(i) = HMAC(PRK, T(i-1) || info || i). Each output block depends on the block before it, so producing sub-key #10,000 means chaining through, or at minimum accounting for, the 9,999 blocks before it.

seek has no such chain: the block counter maps directly to the offset you ask for, so finalize(seek: 10000 * 32, outputLength: 32) computes block #10,000 with the same cost as block #0. That gives you two things HKDF-expand cannot cheaply give you: jumping straight to a specific slice deep in a large output, and generating many slices in parallel across workers with zero coordination between them, since each worker just needs the shared hasher state and its own offset.

Worth being precise about what this is and is not. This is BLAKE3's XOF, sliced by offset, not BLAKE3's dedicated KDF mode. The package has a separate, purpose-built API for that: Blake3Hasher.deriveKey(context) and the one-shot blake3DeriveKey(context, keyMaterial), which mix in a domain-separation context string per BLAKE3's actual keyed-derivation construction. Slicing a plain hasher's output by seek is a legitimate, different technique, useful when what you want is independent windows of one deterministic stream rather than a formal key-derivation function.

Reading offsets 0, N, and 2N from one BLAKE3 output stream to get three independent slices, each computed directly from its own offset rather than chained from the one before it.

final hasher = Blake3Hasher()..update(masterSecret);

List<Uint8List> subKeysBySeek(int count, int keyLength) => [
  for (var i = 0; i < count; i++)
    hasher.finalize(seek: i * keyLength, outputLength: keyLength),
];

final keys = subKeysBySeek(50000, 32);
hasher.dispose();
Enter fullscreen mode Exit fullscreen mode

finalize does not consume the hasher, so calling it 50,000 times with 50,000 different seek values against the same fed input is fine, and each call is independent of the others. If you only ever needed keys.last, the 50,000th slice at index 49999, you would call hasher.finalize(seek: 49999 * 32, outputLength: 32) on its own and skip the loop entirely. Compare that to producing 50,000 unrelated digests by hashing 50,000 different inputs: that works, but it is a different operation, hashing N distinct messages instead of reading N windows out of one output stream tied to one input.

Caveats

seek reads whatever has been fed to the hasher through update() so far. It is not a cursor that remembers your last read position. Each finalize(seek:, outputLength:) call is stateless with respect to earlier finalize calls on the same hasher.

The output stream is fixed only for a fixed input. Call update() again after reading part of the output at some seek, and the entire stream changes, because BLAKE3's output depends on all input fed so far. A seek offset indexes into the output of whatever has been hashed up to that point, and it stops meaning the same thing once you add more input.

The C call underneath computes each window in constant time relative to seek, but every Dart-side call still allocates a native buffer and copies the result back. That's fine for chunk-sized reads, not for pulling output one byte at a time.

Negative outputLength or seek throws ArgumentError. Calling finalize on a disposed hasher throws StateError.

When to reach for seek

Most code that uses BLAKE3 never touches seek, and the default 32-byte digest is all it needs. But when the actual problem is deriving a large number of independent slices from one input, seek is a primitive built for exactly that, and reaching for HKDF on top of a fixed-digest hash is doing the same job with an extra sequential dependency BLAKE3 does not have.

blake3_ffi wraps the official BLAKE3 C implementation over FFI, compiled at build time with no binaries to ship.

Top comments (0)