Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ if err := builder.Finish(); err != nil {
rank, err := idx.QueryRank(streamhash.PreHash(originalKey))
```

> **Security note:** pre-hashing defends against *accidental* skew, not a deliberate adversary. `PreHash` is unkeyed, so an attacker can compute it too, and `WithGlobalSeed` does not affect block routing. If keys may be adversary-influenced, apply a secret-keyed transform (e.g. SipHash/HMAC) upstream and index that. See [`streamhash-spec.md`](streamhash-spec.md) §7.7.
> **Security note:** pre-hashing defends against *accidental* skew, not a deliberate adversary. `PreHash` is unkeyed, so an attacker can compute it too, and `WithGlobalSeed` does not affect block routing or fingerprints. If an attacker can influence the keys you build with or look up, hash every key with a secret first, using a keyed hash with at least 16 bytes of output (e.g. 128-bit SipHash or HMAC-SHA256), and use the result both to build and to query. See [`streamhash-spec.md`](streamhash-spec.md) §7.7.

## Build Options

Expand Down
3 changes: 2 additions & 1 deletion builder_options.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ func WithPayload(sizeBytes int) BuildOption {

// WithFingerprint enables fingerprint verification. Size must be 0-4 bytes.
// 0 means no fingerprints (the default). With fingerprints enabled,
// QueryRank and QueryPayload return ErrNotFound for non-member keys.
// QueryRank and QueryPayload return ErrNotFound for most non-member keys;
// streamhash-spec.md §7.2 says how many get through.
func WithFingerprint(sizeBytes int) BuildOption {
return func(c *buildConfig) {
c.fingerprintSize = sizeBytes
Expand Down
6 changes: 6 additions & 0 deletions key.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ func extractFingerprint(k0, k1 uint64, fpSize int) uint32 {
// store the fingerprint with it and check it on lookup. Pass the same bytes you
// build and query the index with: PreHash(key), if you pre-hash.
//
// The fingerprint uses no secret, so it is not a security check: someone who
// knows one key in the index can make up other keys that pass it. If attackers
// can choose the keys you look up, hash every key with a secret first, both to
// build and to query, using a keyed hash with at least 16 bytes of output such
// as 128-bit SipHash (streamhash-spec.md §7.7).
//
// Returns ErrKeyTooShort if key is shorter than MinKeySize.
func Fingerprint(key []byte) (uint32, error) {
if len(key) < MinKeySize {
Expand Down
11 changes: 6 additions & 5 deletions streamhash-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -803,7 +803,7 @@ Keys must be at least 16 bytes; the first 16 become `k0` and `k1`. There are two
| >0 | 0 | Payload | payload (no verification) |
| >0 | >0 | Payload + verification | payload, or `ErrNotFound` |

A non-member passes the fingerprint check with probability `2^(−8 × FingerprintSize)` — its expected false-positive rate, not a flaw.
A random non-member passes the fingerprint check with probability `2^(−8 × FingerprintSize)`. This rate does not hold for keys made up on purpose to pass the check (§7.7).

### 7.3. Dataset size limits

Expand Down Expand Up @@ -885,17 +885,18 @@ For end-to-end integrity, the footer's two unseeded-xxHash64 region hashes (§3.
StreamHash assumes uniformly random input. Consequences of violating that:

- **Non-uniform keys** collect in a small number of blocks. Each block has a maximum number of keys, sized for uniform input. Exceed it and the build stops with `ErrBlockOverflow`. If your keys are structured or correlated, hash them first with xxHash3-128 (`PreHash`); this restores uniformity. `PreHash` protects against *accidental* skew. It does not protect against an attacker — see the next item.
- **Adversarial keys.** A key's block is a fixed, public function of the key — no secret is involved:
- **Attacks on the build.** A key's block is a fixed, public function of the key, with no secret involved:

```
blockIdx = FastRange32(ReverseBytes64(key[0:8]), numBlocks)
```

The only inputs are the first 8 bytes of the key and the block count, both public, so anyone can compute which block any key lands in. `globalSeed` does not change this: it applies only to hashing inside a block, so a random `globalSeed` gives no protection here. `PreHash` gives none either, because xxHash3-128 uses no secret and the attacker can compute it too.
The only inputs are the first 8 bytes of the key and the block count, both public, so anyone can compute which block any key lands in. `globalSeed` is not an input, so a random `globalSeed` gives no protection here. `PreHash` gives none either, because xxHash3-128 uses no secret and the attacker can compute it too.

An attacker can therefore make many keys land in one block — either by choosing keys directly, or, when the keys are values they cannot choose (like transaction hashes), by generating many candidates and keeping only those that land in the target block. Enough of them exceeds the block's maximum, and the build stops with `ErrBlockOverflow`. This is a denial of service against index construction, and nothing worse: the build simply aborts and writes no index file (see §7.8), so the attack can only prevent an index from being built — it cannot corrupt one or cause wrong lookups.
An attacker can therefore make many keys land in one block. They can choose keys directly, or, when the keys are values they cannot choose (like transaction hashes), generate many candidates and keep only those that land in the target block. Enough of them exceeds the block's maximum, and the build stops with `ErrBlockOverflow`. The index is then never built: the build aborts and writes no index file (see §7.8).
- **Attacks on lookups.** The fingerprint check catches random non-members, but it is not a security check. Someone who knows one key in the index can make up other keys that pass the check. Looking one up returns the real key's rank and payload instead of `ErrNotFound`. On PTRHash, every key made this way passes. On Bijection, only some do, but all of them do when the real key is alone in its bucket. This works because the fingerprint uses no secret, and neither do the block and bucket a key goes to. A random `globalSeed` does not stop it.

**Mitigation.** If an untrusted source can influence your keys, transform each key with a secret before indexing it — for example `key' = SipHash(secret, key)`, or any equivalent such as `HMAC(secret, key)`. Build the index over `key'` instead of `key`. Keep `secret` unknown to the untrusted source; if that source can read the index file, keep `secret` outside it (see §7.6). Without the secret an attacker cannot predict a key's block, and so cannot target one.
**Mitigation.** If an attacker can influence the keys you build with or look up, hash every key with a secret first: `key' = H(secret, key)`, where `H` is a keyed hash with at least 16 bytes of output, such as 128-bit SipHash or HMAC-SHA256. Use `key'` instead of `key` both when you build the index and when you look keys up. Keep `secret` away from the attacker. If the attacker can read the index file, do not store `secret` in it (see §7.6). Without the secret, the attacker cannot tell which block a key will land in, and cannot make up keys that pass the fingerprint check any more often than random keys do.

### 7.8. Durability and compatibility

Expand Down
Loading