Skip to content

Build1 publisher3 min readPublished

A NUL byte truncated on read broke Chron's 14,994-message hash chain

Chron flagged a 14,994-message imported session as tampered after a NUL byte in one row was hashed on write and cut off on read. Its developer's rule, hash what you can read back, applies to any system that hashes text before storing it.

The Engineer · Build desk

Illustration accompanying A NUL byte truncated on read broke Chron's 14,994-message hash chain

What happened

  • Chron's developer imported 33 existing Claude Code transcripts, about 70,000 events, into the local audit log and then verified every session instead of spot-checking one.
  • The largest session, 14,994 messages, failed verification with a content_hash mismatch at row 8842, and Chron labelled that row tampered.
  • The row held a NUL byte inside a TEXT value; SQLite stored all of it, but length() and the JavaScript driver both stopped at the NUL on read.
  • A six-case test on @libsql/client 0.17.3 and Node 23 showed a 12-character string with an embedded NUL coming back 6 characters long.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

  • exposure Any tamper-evident log that hashes a string and stores it in a TEXT column can raise false tamper alarms when its driver returns the value cut at a NUL.
  • cost Chaining multiplies the cost of dirty input, so an importer that accepts raw tool output can lose verification for thousands of later rows over one stray byte.
  • constraint The fix has to change what gets stored; normalizing inside the digest function alone leaves the writer and the verifier hashing different inputs.
  • decision Teams hashing text before storage have a reason to test a write-then-read round trip on their own driver, since the published reproduction covers one driver version.

The rows were minutes old and the importer was their only writer, so the suspect list was short [4]. The developer started with ordering. Imported transcripts keep the client's line order, because 11,591 lines in the corpus carry a timestamp earlier than the line before them, and a wrong sort would link the chain in the wrong sequence [6].

Chron's verifier is well built on this point. It reports a prev_hash mismatch when rows are out of order, and a content_hash mismatch when a row's stored hash disagrees with that row's own stored fields [7]. Row 8842 failed the second check [3].

That failure should have been impossible. The import code hashes a string and inserts the same string a few lines later, in the same function [8]. A query on the row found 298 characters, 530 bytes when cast to a blob, and a NUL at position 299 [9]. The write hashed all 530 bytes. Verification hashed what the driver returned: 298 characters in 308 bytes [11]. The read path dropped 222 bytes [12]. The NUL came from tool output, printed by some program in some session [13].

"For a tool whose entire job is telling you whether a record was altered, a false positive is worse than a missed detection. A missed detection is a gap. A false positive teaches people to ignore the alarm," the developer wrote [5]. Chaining raises the cost of that alarm. One byte in one row out of 73,526 invalidated a 14,994-event chain, because every row after it inherits the break [14].

The obvious fix is to strip the NUL inside the hash function. It fails. The stored value still holds the NUL, so the writer hashes the sanitized original while the verifier hashes the sanitized truncation [15]. "You have to change what goes into the database, not just what goes into the digest," the developer wrote [15]. The rule that came out of it is "hash what you can read back" [16]. I think the rule is correct for any design that hashes before it stores. A verifier only ever sees what the read path returns. The diagnostic query also shows the bytes were never lost in storage: the blob cast still counted 530 [9].

The reproduction covers one stack, @libsql/client 0.17.3 on Node 23 [18]. The 6 characters it returned are the word "before", everything ahead of the NUL [19]. The harness also writes lone high and low surrogates, a valid emoji pair, CRLF with a tab, and ESC with DEL [17]. The post's printed output shows the NUL result only [20]. For this failure to transfer to another system, its driver has to hand TEXT back as a string that ends at the first NUL. The harness is short and needs only the libsql client and Node's crypto module [17].

What to watch

  • Whether the lone-surrogate and control-character cases in the harness also fail on @libsql/client 0.17.3; the printed output covers only the NUL case.
  • Which write-path change Chron ships for NUL-bearing tool output, now that fixing it inside the hash function has been ruled out.
  • Whether other SQLite drivers also cut TEXT at an embedded NUL; the reproduction so far covers one driver version on Node 23.
Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories