Articles · Compliance & Migration

GDPR & crypto-shredding

Try out the running example

GDPR hands identity systems two demands that fight each other: erase this person (Art. 17) and keep your security records trustworthy. Delete the audit rows and your tamper-evident chain snaps; keep them and the personal data lingers. Sentinel’s answer is to make erasure a layered operation — crypto-shredding for what’s encrypted, anonymization for the row, digest-preserving redaction for the ledgers — so the chain still verifies when the person is gone.

The registration

services.AddSentinelPrivacy();
app.MapSentinelPrivacy();       // /sentinel-admin/privacy/export/{id}, /erase/{id}

One port is yours to provide: IPersonalDataSource — where your host keeps authenticators and federated links, so erasure can delete them wherever they live. AddSentinelEfCoreStores registers the EF implementation (plus a persistent key store and retention store); the sample keeps it in memory. Both endpoints are fenced to sentinel:global:manage, decided by the evaluator — a non-admin gets a 403, never a partial bundle.

Export: the Art. 20 bundle

POST /sentinel-admin/privacy/export/{userId}

returns everything Sentinel holds about one person: the user record (identity fields only — never credential hashes), org memberships, active sessions with device/IP, up to a thousand security events, linked identities, and an exportedAt stamp. The export itself lands on the audit chain as user.data_exported — recording that it happened, never the bundle contents (an audit entry containing the export would defeat the later erasure).

Erase: shred → anonymize → redact

POST /sentinel-admin/privacy/erase/{userId}
{ "confirm": "erase" }

The literal confirmation string is mandatory — an irreversible operation should never be a stray POST. Then six things happen, in an order chosen so that a failure partway leaves the most sensitive data already gone:

  1. Crypto-shred. The subject’s AES-256 key is destroyed first. Anything the host encrypted under it is unrecoverable from this instant, even if a later step fails.
  2. Sessions end. Every active session is revoked, its refresh family killed, and back-channel notifications go out.
  3. Authenticators and links die. Passkeys, TOTP, recovery codes, federated identities — deleted through IPersonalDataSource.
  4. The row is anonymized. The id survives (audit entries reference it); the identity does not: email becomes erased+<id>@invalid, display name, attributes and SCIM id are cleared, status becomes Deactivated. Login is now structurally impossible — the old email resolves to nobody, with the same anti-enumeration 401 as any unknown user.
  5. The ledgers are redacted, chain-aware. Security events keep their kinds but lose payloads, IPs and device strings. Admin-chain entries referencing the user lose their raw before/after JSON — but keep the digests those payloads were hashed into.
  6. The erasure itself is recorded — with snapshots that deliberately carry no identifying fields.

The response is the receipt: { userId, sessionsRevoked, securityEventsRedacted, auditEntriesRedacted }.

Why the chain still verifies

Each admin-chain entry commits to sha256(beforeJson) and sha256(afterJson) as separate digest columns, and the entry hash is computed over the digests, not the raw payloads. Redaction nulls only the payload columns — sequence, digests and hashes are untouched — so walking the chain still passes:

(await audit.VerifyChainAsync(realmId)).Should().BeNull();   // null = intact

And redaction is not a tampering loophole: forge a payload into a redacted entry and it fails its committed digest — the verifier returns that entry’s sequence number. The sample’s Erasure_shreds_the_key_redacts_history_and_the_chain_still_verifies test asserts all of it over HTTP.

The shredder is yours too

Erasure can only shred what was encrypted under the subject’s key — so Sentinel exposes the primitive to your application code:

// POST /notes — store PII encrypted under the caller's key
vault.Add(principal.SubjectId, await shredder.EncryptAsync(principal.SubjectId, request.Text));

// read side: null after the key is destroyed
await shredder.TryDecryptAsync(userId, ciphertext) ?? "[unrecoverable]"

ISentinelCryptoShredder is AES-256-GCM under a per-subject key created on first use (EfSubjectKeyStore persists it; the in-memory store is for samples and tests). Store medical notes, support transcripts, uploaded documents this way and your erasure story inherits Sentinel’s: destroy one key row, and every copy of that ciphertext — including the ones in your backups — is noise. That is the entire point of crypto-shredding: deleting the key is the deletion.

Retention: erasure for data nobody asked about

The same redaction machinery runs on a clock:

services.AddSentinelPrivacy(o =>
{
    o.SecurityEventRetention = TimeSpan.FromDays(365);  // default: one year, then deleted
    o.AdminAuditRetention = TimeSpan.FromDays(730);     // default: null — keep forever
});
services.AddSentinelRetentionService();                  // daily background sweep

Security events past their window are deleted outright. Admin-chain entries are never deleted — the chain is forever — but their payloads age out the same digest-preserving way, so a ten-year-old entry proves that something happened without still saying what the user’s email was. Each sweep that changes anything emits a retention.swept event with the counts. The sample runs a sweep against a mutable clock and verifies the chain afterwards (Retention_sweep_deletes_old_events_and_keeps_the_chain_intact).

Between export, layered erasure and scheduled retention, the compliance posture stops being a runbook of manual SQL and becomes three calls you can test — which is exactly what the sample does.