feat(encryptor): :aes-gcm authenticated encryption; seal/unseal for raw bytes - #150
Open
whilo wants to merge 3 commits into
Open
feat(encryptor): :aes-gcm authenticated encryption; seal/unseal for raw bytes#150whilo wants to merge 3 commits into
whilo wants to merge 3 commits into
Conversation
…aw bytes
The :aes encryptor is unauthenticated AES-256-CBC (geheimnis v1, which its own
README now marks deprecated and unsafe for new use). A tampered blob is
undetectable: it either decrypts to garbage that fressian then parses, or throws a
padding error whose path is padding-oracle-shaped. For a content-addressed store
like datahike that means a modified blob silently becomes a tree node.
Add :aes-gcm (header byte 2), AES-256-GCM via geheimnis v2. Per blob part the
layout is [salt 32B][nonce 12B][ct || tag 16B]: a fresh CSPRNG salt derives a
per-blob key by HKDF, so no key ever encrypts more than one message and GCM's one
catastrophic failure mode -- nonce reuse -- cannot arise whatever the write volume.
The AAD binds each ciphertext to (layout version, store-key, :meta|:value), so a
blob cannot be relocated to another key, or swapped between slots of the same blob,
and still verify. Bytes are identical on JVM and CLJS.
Encryption becomes its own protocol (PEncryptor: -encrypt/-decrypt over whole byte
arrays) rather than a PStoreSerializer decorator. An AEAD cipher must verify its
tag over the complete ciphertext before any plaintext reaches the deserializer, so
there was nothing to stream -- and Web Crypto is Promise-only, so the sync
decorator shape could not survive anyway.
:aes keeps working, byte-for-byte, pinned by a golden vector generated from the
pre-refactor code.
Binary values were never encrypted -- bassoc/bget pass bytes to storage untouched,
bypassing the serializer path -- so an encrypted store wrote them in the clear,
silently. They now require an explicit choice: seal/unseal them under the store's
key, or pass {:raw? true} and own the format yourself.
Fixes :lz4 + encryption, which was simply unreadable: writes did
compress-then-encrypt while reads did decompress-then-decrypt.
whilo
force-pushed
the
feat/aes-gcm-encryptor
branch
from
July 12, 2026 10:55
0ae87b7 to
57384a8
Compare
connect-idb-store did (dissoc params :config) and then always used its own
hardcoded :config map, so {:encryptor ...} and {:compressor ...} passed by the
caller were silently discarded -- a browser store asked for encryption got none.
Both filestores already merge the caller's config over their defaults; IndexedDB
now does the same.
Surfaced by the new :aes-gcm browser tests: reading a "secret" back under a
different key returned the plaintext value rather than failing to authenticate,
because nothing had ever been encrypted. It also means the existing IndexedDB
:aes encryptor test was passing vacuously against an unencrypted store.
…d of skipping it
The first pass added a {:binary? false} option to compliance-test and used it to
skip the bassoc/bget section for encrypted stores. That was the wrong trade twice
over: it changed a public API that backend authors call, and it bought that by
deleting coverage exactly where binary is most delicate.
:raw? already expresses what those stores need. compliance-test now passes it on
its binary ops when (konserve.core/encrypted? store), so binary is exercised on
every store, encrypted or not, and the signature does not move. The diff against
the pre-PR file drops from 163 changed lines to 7, and the JVM suite gains 16
assertions rather than losing any.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The
:aesencryptor is unauthenticated AES-256-CBC (geheimnis v1, which its own README now marks deprecated and not safe for new use). A tampered blob is undetectable: it either decrypts to garbage that fressian then parses, or throws a padding error whose path is padding-oracle-shaped. For a content-addressed store like datahike that means a modified blob silently becomes a tree node.Important
Blocked on replikativ/geheimnis#8.
deps.ednpoints atgeheimnis 0.2.36, which is that PR. CI will fail until it is merged and released to Clojars.What
:aes-gcm(header byte 2)AES-256-GCM via geheimnis v2. Per blob part:
[salt 32B][nonce 12B][ct ‖ tag 16B].(layout version, store-key, :meta|:value). A blob cannot be relocated under another key, and a meta blob cannot be swapped into the value slot — both things an attacker with write access to the storage medium can otherwise do without touching a single ciphertext byte.konserve.encryptor/generate-keymakes one. konserve deliberately does not stretch passphrases: a guessable one would be brute-forced offline against the stored blobs, and geheimnis has no password KDF yet.{:sync? true}is an explicit error rather than a hang. The JVM supports both tiers.Overhead is ~12 µs per 1 KiB blob on the JVM (CSPRNG salt+nonce → HKDF → GCM), and 60 bytes on disk — less than the current 64-byte salt plus CBC padding.
PEncryptorEncryption becomes its own protocol (
-encrypt/-decryptover whole byte arrays) rather than aPStoreSerializerdecorator. An AEAD cipher must verify its tag over the complete ciphertext before any plaintext reaches the deserializer, so there was nothing to stream — and Web Crypto is Promise-only, so the sync decorator shape could not have survived regardless. Ops return a channel, or the byte array directly under{:sync? true}.This is the breaking change for anyone with a custom encryptor: implement
PEncryptor, notPStoreSerializer.Binary values
bassoc/bgethand your bytes to storage untouched, bypassing the serializer path — so they were never encrypted, and a store configured with an encryptor wrote them in the clear, silently. That is not what a user of an encrypted store expects. They now require an explicit choice:New
konserve.core/seal/unsealencrypt raw bytes under the store's own key and bind them to a konserve key, so a sealed binary blob is as tamper-evident and as un-relocatable as an EDN value. Encrypting binary by default needs chunked AEAD framing to keepbassocstreaming;:raw?keeps its meaning when that lands.:aes— deprecated, still worksExisting blobs stay readable and writable, byte-for-byte, pinned by a golden vector generated from the pre-refactor code.
Bugs found along the way
:lz4+ any encryptor was unreadable. Writes nested the encryptor outside the compressor (compress → encrypt) while reads nested them the other way (defaults.cljc:70vs:150), so a read tried to LZ4-decompress the ciphertext. Nobody hit it because the default compressor is the null one. The protocol split makes the order symmetric by construction.:aessalt is not from a CSPRNG. It is(edn-hash (uuid)), and hasch'srandom-uuidisMath.randomon CLJS — so the per-blob IV and derived key are predictable in the browser today.:aesis not actually cross-platform. The JVM and JS salt encodings disagree (incon one side, signed/unsigned reads on the other), so a blob written on one platform fails to decrypt on the other whenever a salt byte lands on 128 — roughly one blob in five. Preserved rather than fixed, since fixing it would break the stores it currently works for.:aes-gcmhas no such problem.Not addressed
-read-binaryalready materializes the whole payload on every backend (JVM allocates aByteBufferof the full value size and handslocked-cbaByteArrayInputStreamover it — there is a;; TODO use FileInputStream to not load the file in memorysitting right there; Node reads a fullBuffer; IndexedDB hands over the whole blob). Only-write-binarygenuinely streams. So framed AEAD and that TODO are the same piece of work, and belong together in a follow-up.Also worth recording: do not wrap GCM in
javax.crypto.CipherInputStreamwhen that follow-up happens. It has historically swallowedAEADBadTagExceptionand returned EOF (JDK-8154523), converting a detected forgery into a silent truncation — strictly worse than no authentication, because the caller believes it verified.Testing
:aes-gcmcompliance (sync + async), wrong-key rejection, byte-flip tamper rejection against a real filestore blob, a scan of every blob on disk confirming a marker string never lands there in the clear,seal/unsealround-trip and cross-key rejection,{:raw? true}pass-through, and the legacy:aesgolden vector.:aes-gcmover Web Crypto.cljfmtandclj-kondoclean (no new warnings).