1.12.0
Chronicle v1.12.0 — GDPR Erasure via Crypto-Shredding
This release answers the question every compliance team asks about an immutable audit log: how do you honour a GDPR Article 17 erasure request against a ledger designed never to change?
Chronicle 1.12 resolves the tension with crypto-shredding. PII-bearing payload fields are encrypted under a per-subject key before hashing, so the hash chain is computed over ciphertext. To erase a subject, you destroy their key: the ciphertext stays in place — the ledger still verifies, byte-for-byte — but the content becomes permanently unreadable. What remains is the pseudonymised fact that an event happened: the evidence, not the personal data.
Encryption is opt-in. With it disabled, behaviour is identical to 1.11.
✨ Highlights
- 🔐 Crypto-shredding — per-subject payload encryption (XChaCha20-Poly1305-IETF) with the entry envelope bound in as associated data. Encrypting happens before hashing, so erasure never breaks the chain.
- 🗑️ GDPR erasure —
chronicle:subject:erasedestroys a subject's key and records a verifiable, PII-freesubject.erasedproof you can show a regulator. - ✅ Erase-and-still-verify — after erasure,
chronicle:verifystill passes; reads of erased fields return a tombstone, while the cleartext envelope (actor, action, subject, timestamp, tags) stays queryable. - 🔑 Pluggable key custody — per-subject DEKs are wrapped by a KEK. The default KEK is local; keep it in a KMS via
laravel-chronicle/kms-awsso it never lives in the app. - ⚖️ Legal hold — block erasure and pruning of subjects under litigation hold.
- ♻️ KEK rotation — re-wrap every DEK under a new KEK without touching ciphertext or hashes.
- ↩️ Backward compatible — encryption is off by default; mixed cleartext/encrypted ledgers verify and export normally.
🔐 How it works
// config/chronicle.php
'encryption' => [
'enabled' => env('CHRONICLE_ENCRYPTION_ENABLED', false),
'fields' => ['metadata', 'context', 'diff'], // PII-bearing fields, encrypted per-subject DEK
'kek' => [
'provider' => Chronicle\Encryption\LocalKeyEncryptionProvider::class,
'key' => env('CHRONICLE_ENCRYPTION_KEY'), // dedicated base64 32-byte key — NOT the app key
'id' => env('CHRONICLE_ENCRYPTION_KEK_ID', 'local'),
],
],Each data subject gets a random data key (DEK), wrapped by the key-encryption key (KEK) and stored alongside the ledger. Configured fields are encrypted with the subject's DEK between canonicalisation and hashing, so payload_hash and chain_hash cover the ciphertext. Erasure destroys the DEK; the ciphertext that remains can never be decrypted again.
🗑️ Erasing a subject
php artisan chronicle:subject:erase patient 01H... # destroy the DEK; records a PII-free proof
php artisan chronicle:subject:keys --status=erased # inspect key state (never prints key material)Erasure is idempotent, is itself recorded as a verifiable subject.erased entry (containing no PII), and leaves the ledger fully verifiable. Reads of an erased subject's encrypted fields return a tombstone; the event fact remains.
🆕 New Artisan commands
| Command | Purpose |
|---|---|
chronicle:subject:erase {type} {id} |
Destroy a subject's encryption key (GDPR erasure); records a PII-free proof (--reason) |
chronicle:subject:keys |
Inspect subject key state — never key material (--subject, --status, --json) |
chronicle:legal-hold {action} {type} {id} |
Place / release a litigation hold that blocks erasure and pruning |
chronicle:encryption:rotate-kek |
Re-wrap all subject DEKs under a new KEK (--old-key, --old-kek-id, --chunk) |
chronicle:encrypt-backfill |
Re-baseline migration: encrypt historical entries' PII (--from, --chunk, --dry-run, --force) |
⚖️ Legal hold & key rotation
chronicle:legal-hold place {type} {id}prevents both erasure and pruning of a held subject;releaselifts it.chronicle:encryption:rotate-kekre-wraps every DEK under a new KEK. It changes no ciphertext, hashes, or signatures, so the ledger is unaffected and remains decryptable.
⬆️ Upgrade guide
1. No changes required to keep current behaviour. Encryption defaults to off; existing ledgers verify and export exactly as on 1.11. The new migrations add the subject-key and legal-hold tables only.
2. Run the new migrations.
php artisan migrate3. To enable encryption (forward-only). Generate a dedicated 32-byte base64 key, set CHRONICLE_ENCRYPTION_KEY (do not reuse the app key), and turn on CHRONICLE_ENCRYPTION_ENABLED. New entries are encrypted from then on; existing entries stay cleartext.
4. (Optional) Encrypt historical entries. chronicle:encrypt-backfill re-encrypts existing entries' PII. This is a deliberate re-baselining migration — it recomputes payload_hash, re-links chain_hash to the head, and writes a fresh signed checkpoint. Take a backup first; it is gated behind --dry-run and --force and is not a routine operation.
⚠️ Important notes
- Erasure scope. Crypto-shredding guarantees erasure in the live store. Backups taken before an erasure still contain recoverable data and are governed by your backup-retention policy.
- Not legal advice. Whether retaining a pseudonymised event record satisfies a particular erasure request is a determination for your DPO / legal counsel.
- Keep
CHRONICLE_ENCRYPTION_KEYsafe and separate. Losing the KEK makes all wrapped DEKs (and therefore all encrypted content) unrecoverable; that is by design.
📚 Documentation
Requirements
- PHP
^8.2 - Laravel
^12.0or^13.0 ext-sodium,ext-openssl
Full Changelog: 1.11.0...1.12.0