Skip to content

1.12.0

Choose a tag to compare

@ntoufoudis ntoufoudis released this 18 Jun 14:19
0f005c0

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 erasurechronicle:subject:erase destroys a subject's key and records a verifiable, PII-free subject.erased proof you can show a regulator.
  • Erase-and-still-verify — after erasure, chronicle:verify still 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-aws so 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; release lifts it.
  • chronicle:encryption:rotate-kek re-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 migrate

3. 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_KEY safe 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.0 or ^13.0
  • ext-sodium, ext-openssl

Full Changelog: 1.11.0...1.12.0