Textrun is a new personal archiving and research app,
built during Shipaton and available soon.

Subscribe to the newsletter for updates and discounts.

Its component TextrunSQL is already on GitHub…

github.com/textrun/TextrunSQL

Prepare your encrypted SQLite databases for the post-quantum future

TextrunSQL helps defend against “harvest now, decrypt later” attacks by adding post-quantum protection to SQLCipher database keys.

by Maximilian Götzfriedmax@text.run
last updated on #BuildInPublic#Shipaton

Attackers are stealing encrypted databases today and storing them for later. Their hope is that future quantum computers will let them read what is protected now.

TextrunSQL adds post-quantum protection around the key that opens the database. SQLCipher handles the encryption, while SQLite stores and queries the data.

TextrunSQL adds a post-quantum key layer. It does NOT replace SQLCipher or SQLite.

Why post-quantum matters now

Nobody knows when a quantum computer powerful enough to break the public-key protection used today will arrive. Updating products and stored data takes time, so waiting for that day is risky.

Encrypted files and traffic can be copied today and stored for years. If the public-key layer that protected those keys later falls, old archives can open. Someone can steal encrypted databases now and wait.

That attack is called harvest-now, decrypt-later.

Governments and product teams are not waiting for a dramatic “quantum day”:

The standards TextrunSQL uses

TextrunSQL wraps a database key with ML-KEM-768 so a stolen envelope is harder to open later. This is a standard from NIST FIPS 203, implemented in OpenSSL.

The envelope also uses SHA-256, HKDF-SHA-256, and AES-256-GCM. This strong symmetric crypto can stay because it is much less exposed to known quantum attacks than public-key crypto, according to NIST.

Other standards and recommendations are not implemented, because they do not apply to TextrunSQL:

TextrunSQL only defines the envelope format. Audit claims, formal certification, storage, backups, key custody, sharing, rotation, and migration are left to the application.

How TextrunSQL works

TextrunSQL protects the 32-byte key that opens the database. SQLCipher still encrypts and checks the database pages. Your app still decides where the database, envelope, private key, and recovery information live.

Seal protects the database key inside a 1236-byte Envelope. Bytes 0..1187 are authenticated but not encrypted, while the final 48 bytes contain the wrapped key and its tag.

Open checks the envelope, refuses changes or a different recipient or context, and returns the key only when all checks succeed. A separate Handoff then passes the key to SQLCipher.

Seal and envelope flow textrunsql_pq_seal_dek() starts with a context of 1 to 1024 bytes, a 1184-byte recipient public key, and a 32-byte database encryption key. SHA-256 hashes the public key and context to bind the envelope to that recipient and context. ML-KEM-768 uses the public key to create a 1088-byte KEM ciphertext and a shared secret. HKDF turns the shared secret into a wrapping key. AES-GCM uses that key to wrap the database key. The 1236-byte envelope contains a 24-byte header, 32-byte recipient ID, 32-byte context hash, 1088-byte KEM ciphertext, 12-byte nonce, 32-byte wrapped database key, and 16-byte authentication tag. Envelope bytes 0 through 1187 are authenticated but not encrypted. Bytes 1188 through 1235 hold the wrapped database key and tag. Seal textrunsql_pq_seal_dek() context 1–1024 B pk 1184 B DEK 32 B Bind SHA-256 Encapsulate ML-KEM-768 Wrap AES-256-GCM Derive HKDF Envelope 1236 B hdr 24 B rid 32 B chash 32 B KEM ciphertext 1088 B nonce 12 B wDEK 32 B tag 16 B AAD 0..1187 GCM output 1188..1235
Open and handoff flow textrunsql_pq_open_dek() starts with the envelope, the matching private key, and the same context. It first validates the envelope format and checks that its recipient and context bindings match. ML-KEM-768 uses the private key and KEM ciphertext to recover the shared secret. HKDF derives the same wrapping key. AES-GCM checks the envelope and unwraps the 32-byte database key. The separate textrunsql_pq_key_sqlcipher() handoff takes an open database, a schema name, and the recovered key. It formats the key in SQLCipher raw-key syntax and calls sqlite3_key_v2() to set it. SQLCipher then uses the key to encrypt database pages. Open textrunsql_pq_open_dek() Input envelope + key + context Validate format + bindings Decaps ML-KEM-768 Derive HKDF Unwrap AES-GCM DEK 32 B Handoff textrunsql_pq_key_sqlcipher() Input db + schema + DEK Format raw-key syntax Set key sqlite3_key_v2() SQLCipher Encrypts pages

What the labels mean:

  • AAD (Additional Authenticated Data): Envelope bytes 0..1187, which AES-GCM authenticates but does not encrypt.
  • AES-GCM / GCM: The diagram’s short labels for AES-256-GCM, which TextrunSQL uses to wrap the DEK and authenticate the envelope prefix.
  • Bind: Computes rid = SHA-256(pk) and chash = SHA-256(context) so Open can reject a different recipient or context.
  • chash (context hash): The 32-byte SHA-256 digest of the caller context, stored in the envelope and used as the HKDF salt.
  • context: A nonempty, app-defined binary value from 1 through 1024 bytes, supplied unchanged to Seal & Open as a stable database-context identifier.
  • Decaps: Uses the matching ML-KEM decapsulation key and KEM ciphertext to produce the shared secret.
  • DEK (database encryption key): The random 32-byte database encryption key supplied to SQLCipher in raw-key form.
  • Derive: Uses HKDF-SHA-256 with the shared secret, context hash, and fixed protocol label to produce the AES-GCM wrapping key.
  • Encapsulate: Uses the recipient’s ML-KEM encapsulation key to produce a 1088-byte KEM ciphertext and 32-byte shared secret.
  • Envelope: The fixed 1236-byte record containing the header, recipient and context bindings, KEM ciphertext, nonce, wrapped DEK, and tag.
  • Format: Converts the recovered DEK into SQLCipher’s lowercase raw-key syntax. That syntax uses the prefix x', exactly 64 hex digits, and a closing '.
  • GCM output: Envelope bytes 1188..1235 contain the 32-byte wrapped DEK followed by its 16-byte authentication tag.
  • Handoff: The separate caller-controlled step that formats the recovered DEK and applies it to an already-open SQLCipher database.
  • hdr (header): The fixed 24-byte envelope header containing the magic, version, suite, flags, reserved bytes, and declared lengths.
  • HKDF (HMAC-based Key Derivation Function): The key-derivation function that combines the shared secret, context hash, and fixed protocol label to produce the wrapping key.
  • Input: Open receives the envelope, matching recipient key, and unchanged context. Handoff receives the open db, selected schema, and recovered DEK.
  • KEM ciphertext: The 1088-byte ML-KEM output that the matching decapsulation key uses to produce the same shared secret.
  • ML-KEM-768 (Module-Lattice-Based Key-Encapsulation Mechanism): The FIPS 203 parameter set used by TextrunSQL. NIST classifies it in security category 3. 768 is a parameter-set name, not a key size.
  • nonce: A fresh random 12-byte AES-GCM input stored in the envelope. It must not repeat with the same wrapping key.
  • Open: Authenticates an envelope for the matching recipient and context and returns the DEK. It does not itself apply the DEK to SQLCipher. The tests below check both its success and failure paths.
  • pk (public key): The recipient’s 1184-byte ML-KEM encapsulation key, also called the public key in this API.
  • rid (recipient identifier): The 32-byte SHA-256 digest of pk, stored in the envelope and checked during Open.
  • Seal: Creates the canonical 1236-byte envelope that protects one 32-byte DEK for one recipient and context.
  • Set key: Calls sqlite3_key_v2() with the formatted raw key for the selected schema.
  • SHA-256: The hash function used to compute rid from pk and chash from the context.
  • SQLCipher: The SQLite-compatible library that encrypts and authenticates database pages after the DEK is applied.
  • sqlite3_key_v2(): The SQLCipher API that applies the formatted key to the selected schema. Success means the key was accepted, not that existing database content was authenticated.
  • tag: The 16-byte AES-GCM authentication tag covering the wrapped DEK and the AAD.
  • textrunsql_pq_key_sqlcipher(): Formats the 32-byte DEK as a SQLCipher raw key and calls sqlite3_key_v2(). Success sets the key but does not authenticate existing database content. The caller must read protected content next.
  • textrunsql_pq_open_dek(): Validates and authenticates the envelope and returns the 32-byte DEK only on success. The output remains zero on failure.
  • textrunsql_pq_seal_dek(): Seals a supplied 32-byte DEK into a 1236-byte envelope for one recipient and context.
  • Unwrap: Uses AES-GCM to authenticate the AAD and wrapped DEK, then releases the recovered DEK only if authentication succeeds.
  • Validate: Checks the exact envelope format, version, suite, recipient binding, and context binding before decapsulation.
  • wDEK (wrapped DEK): The 32-byte AES-GCM ciphertext of the DEK stored in the envelope.
  • Wrap: Uses the derived key and random nonce to AES-GCM-encrypt the DEK and authenticate the AAD, producing wDEK and the tag.

How to test this

The tests follow the Seal & Open paths in the diagrams above. They check that Seal creates an envelope that Open can read, that the recovered DEK opens the SQLCipher database, and that Open refuses changed data, the wrong recipient, or the wrong context.

Start with the regular checks. After you have configured the repo with OpenSSL 3.5 or newer, run these commands from its root:

make libsqlite3.a testfixture
./testfixture test/sqlcipher.test
make -C textrunsql check

The first two commands build SQLCipher and run its own checks. The last command tests TextrunSQL, including NIST examples, the Seal & Open behavior above, and the handoff of the recovered key to SQLCipher. The full setup and exact coverage are in textrunsql/TESTING.md.

For deeper checks, run two more commands that look for memory errors or parser crashes:

make -C textrunsql asan
make -C textrunsql fuzz-smoke

asan reruns the focused C tests with checks for memory errors and undefined behavior. fuzz-smoke sends 5,000 generated inputs through the envelope parser and looks for crashes, memory errors, or undefined behavior.

Passing these tests shows that the code worked in the setup you used: this commit, compiler, operating system, OpenSSL provider, architecture, and build settings. It is not a security review or certification, and it cannot cover every way an app or its environment might expose the database or its keys.

Why we don't encrypt the entire envelope

Open needs the envelope’s header, recipient and context bindings, ML-KEM ciphertext, and nonce before it can derive the key that unwraps the database key. Encrypting those fields with that same key would create a circle: Open would need the key to read the data required to derive it.

Those fields are not secret. They remain readable, but AES-GCM authenticates them as AAD, so Open rejects the envelope if any of them change. The database key is the part that needs to stay hidden, so TextrunSQL encrypts it and uses the authentication tag to protect both it and the visible fields.

Why we don't use something that already exists

SQLCipher currently does not provide post-quantum protection for the key. There is also no fork that does. TextrunSQL adds that missing layer and relies on existing cryptographic code:

How to learn more

These talks explain the ideas behind post-quantum cryptography, why the change matters now, and how ML-KEM is being used:

About the dev who built this

Maximilian Götzfried is a freelance developer from Germany. For more than two decades, he has shipped apps that use encrypted databases.

Apple recognized his work early with a student scholarship for WWDC in 2006.

This was about six months before Apple introduced the iPhone, and Steve Jobs was already dealing with severe health issues. At the beginning of the keynote, he told us that he was feeling better. That remark was later cut from the video recording. At the time, Apple still held full sessions in person, with long Q&As before live audiences at San Francisco’s Moscone Center.

His expertise in high-performance concurrency goes back to 2008, when he shipped iPhoneOS 2.0 apps with manually managed pthreads because NSOperationQueue had too much overhead.

The German Mac Developer magazine published his 15-page Snow Leopard special in 2010. Featured on the issue’s cover, it covered 64-bit development, Clang LLVM, Apple’s then-new Dispatch framework, and other multithreading topics.

Opening spread of the Snow Leopard article “Von 64 Bit bis GCD,” with Maximilian Götzfried’s byline
Mac Developer page with the MacDev Multithreading header, Objective-C code, article text, and a portrait of Maximilian Götzfried
Cover of Mac Developer 1/2010, featuring iPhone app development and a Snow Leopard overview
Bottom of Mac Developer page 67, centered on Grand Central Dispatch listing 31 with parts of listings 30 and 32 and the page footer
Xcode compiler menu with Clang LLVM selected
Maximilian Götzfried in Mac Developer, 2010 — with a lot more hair than today.

He started programming in Swift with version 1.0 in 2014 and released MXStatusMenu as an early project with manual memory management.

In recent years, he has migrated several high-profile projects and parsers to strict concurrency and optimized native app performance with contiguous memory, Metal, SIMD, and other advanced technologies.

His current focus is Textrun, a personal archiving and research app that will use TextrunSQL to ship post-quantum protection to its customers.

How AI helped build this

Maximilian Götzfried sees AI as a useful tool for developers, but does not believe that coding is “solved.”

I have always enjoyed writing code, and I will never give that up. I use AI as a supporting tool, similar to code generators and automations in the past. To write code and explore new ideas. I also use AI to improve my English because it is not my first language, and to learn new things by having it explain them to me.

I use AI to ship the best product I can and to become a better developer. But I am not a vibe coder. I write a lot of code every day, thoroughly review every AI contribution and try to get to the bottom of every issue – because I care about quality.