Writing

Event Signatures in GenesisDB

Nov 04, 20266 min read

A hash chain tells you that your event log is consistent. It does not tell you who wrote it. Event signatures are planned for an upcoming GenesisDB release to close that gap. Here is the idea and how it is designed to work.

Event signatures are one of the next features on the GenesisDB roadmap. They are in development right now on a feature branch and are planned for an upcoming release. This post explains the idea, what signatures will be able to do and what they are for. Details may still change before the release.

What a hash chain cannot do

Every event in GenesisDB has a hash, and every hash includes the hash of the event before it. That chain is what GET /api/v2/admin/audit checks today. Change a single byte in an old event and the audit fails.

That is a good guarantee, but it is a narrower one than it looks.

A hash chain proves that the log is consistent. It does not prove that the log is authentic.

Hashes are public math. Anyone who can write to the data directory can change an event, recompute its hash, recompute every hash after it, and end up with a chain that is perfectly valid. The audit passes. Nothing looks wrong.

For a lot of systems that is fine, because whoever can write the data directory already owns the database. But event sourcing is often chosen precisely because the history matters: orders, payments, approvals, medical records, compliance trails. In those systems the question is not only "is this log intact?" but also "did this database actually write this event?"

That is what signatures are for.

The idea

With a signing key configured, GenesisDB will sign every new event with Ed25519 at the moment it is committed, and store the signature next to the event.

{
  "subject": "/customer/3a132ce2-bf14-4a40-b0b4-62639df86eea",
  "type": "io.genesisdb.app.customer-added",
  "data": { "firstName": "Bruce", "lastName": "Wayne" },
  "hash": "aa4e9be29d6ee1ccf422bc2f96277c632d4db5d51658282c103c722f3806adc6",
  "signature": "genesisdb:signature:v1:5a8883b93a6e58cf:9f3c..."
}

The signature covers the event hash. The hash already binds the metadata, the payload and the predecessor, so signing the hash signs all of it.

The important part is the private key. It is not stored in the data directory. Someone who rewrites events can still produce a valid hash chain, but without the private key they cannot produce valid signatures. The audit catches them.

Signed at commit, stored for good

There are two ways to build signatures into an event store. You can sign each event on the way out, when a client reads it, or you can sign it once, when it is written, and keep the signature.

I chose the second, for three reasons.

The signature means something about the history. A signature created at read time only says "this server sent you this, just now". If the stored events were rewritten an hour ago, the server happily signs the rewritten version. A signature created at commit says "this is what was committed", and it keeps saying that.

Signatures survive key rotation. GenesisDB will record the public key of every key that ever signed. When you rotate, old events keep the signature of the key that committed them and stay verifiable. Nothing needs to be re-signed, and nothing you exported earlier becomes invalid.

Signatures can leave the database. You will be able to export an event with its signature, put it in an archive, hand it to an auditor or a partner system, and have it verified years later. No running server needed, not even GenesisDB.

Verifiable with standard tools

The planned format is deliberately boring:

genesisdb:signature:v1:<key id>:<signature>

The key ID is the first 16 hex digits of SHA-256 over the public key. The signature is a plain Ed25519 signature over genesisdb:signature:v1: followed by the event hash. That is all it takes to check it with OpenSSL:

printf 'genesisdb:signature:v1:%s' "$HASH" > message
echo "$SIGNATURE_HEX" | xxd -r -p > signature
openssl pkeyutl -verify -pubin -inkey signing.pub.pem \
  -rawin -in message -sigfile signature

Check the hash first, then the signature. A valid signature over a hash that does not match the event proves nothing.

Trust lives outside the database

The list of recorded public keys will sit in the data directory. That makes it convenient, and it also makes it something an attacker with write access could edit: add their own key, sign a rewritten history with it, done.

So the recorded keys identify keys, they do not establish trust. Trust comes from outside: you pin the public keys you trust in the configuration. Signatures of any other key show up as "signed by an untrusted key" instead of quietly passing. The audit will report signatures next to the hash check:

Audit passed. All 456341 events integrity checks succeeded.
Signatures: 456341 valid, 0 unsigned, 0 signed by an untrusted key.

When a key leaks

A private key can leak. If it does, whoever has it can sign events that were never committed, and since the old key stays recorded, those signatures would still look valid.

That is why revocation is part of the design. You revoke the leaked key and name the hash of the last event you still trust. Signatures up to that event stay valid, so the history before the leak is not lost. Everything the key signed afterwards is counted as revoked. And the revocation point doubles as a checkpoint: if no event in the log has that hash anymore, the history was rewritten and the audit fails.

Revocations are configuration, not data. They are never written to the data directory or to backups, so whoever can write the directory cannot lift them. And a revoked key cannot be used to sign again. GenesisDB refuses to start with it.

Offline verification

The same check will also run on a stopped data directory, without starting a server and without changing a single byte. Unsigned events and events signed by an untrusted or revoked key can optionally fail the check as well. That fits nicely into a nightly job or a CI step that checks a backup.

Erasure still works

GenesisDB keeps personal data in erasable reference payloads, which I wrote about in GDPR and Event Sourcing. Those payloads are outside the event hash, so they are outside the signature too. Erasing a payload leaves both the hash and the signature valid. You do not have to choose between verifiable history and the right to be forgotten.

What signatures will not do

I want to be precise here, because "signed" sounds stronger than it is.

  • They do not detect truncation. If someone removes the newest events, the remaining ones are still validly signed. Keep external checkpoints (the ID and hash of the latest event) if that matters to you.
  • They do not help if the private key is stolen and not revoked. Keep it in a secret store, off the data volume, readable only by GenesisDB.
  • They do not sign the past. Events committed before you enable signing stay unsigned. The audit counts them separately.
  • They are not a compliance certificate. They are one strong building block for an audit trail, not the whole thing.

Compatibility

Signing will be off by default. Without a key, events and responses look exactly as they do today. The 1.x API under /api/v1 will never return signatures, so existing clients and SDKs keep working unchanged. Signatures will only appear on /api/v2.

What is next

The feature is being built and tested on the feat/signatures branch. You can already look at the code and the documentation there. Once it is reviewed and merged, it will ship in an upcoming GenesisDB release, and the release notes will have the configuration and all details.

If you have a use case for signed events, or opinions on the design, now is the best time to share them. Open an issue on GitHub or get in touch.