Engineering ·
One key per student, and why that's the whole point
Envelope encryption with a per-record data key turns "we deleted it" from a policy statement into a fact you can verify — and turns key loss into a permanent, irreversible cost.
Most software that claims to "encrypt data at rest" means one thing: somewhere below the application, a disk or a database volume is encrypted, transparently, with one key. It's a real protection against one threat — a stolen drive — and it protects against exactly that threat and nothing else. Once the database process is running, every row is plaintext to anything that can query it. A compromised database credential, a misconfigured backup, a bug in an ORM that logs query parameters: none of those care that the disk underneath was encrypted.
Classline's approach is different and more specific: sensitive fields are ciphertext before they reach Postgres, encrypted by the application, with a separate key per student. This post is about what that buys, in plain terms, and about the trade-off that comes with it — because there is always a trade-off, and the honest version of this story includes it.
Envelope encryption, briefly
The pattern is called envelope encryption and it isn't unique to Classline — it's the standard shape used by every major cloud KMS. Two kinds of key, two jobs:
- A master key (a key-encrypting key, or KEK) — one per deployment, supplied at process startup from the environment, never written to disk, never committed to the repo, never baked into a container image.
- A data key (a data-encrypting key, or DEK) — one per record. The DEK encrypts the actual plaintext (a student's name, say). The KEK then encrypts the DEK itself — "wraps" it — and it's the wrapped DEK that gets stored alongside the ciphertext, in the same row.
To read a field back, the application unwraps the DEK using the KEK, then uses the now-plaintext DEK to decrypt the field. The KEK itself never touches a student record directly; it only ever handles other keys.
Classline's implementation (internal/crypto) uses AES-256-GCM for both
layers, and it binds every sealed value to exactly the row it belongs to:
the additional authenticated data passed to GCM is the table name, column
name, and record UUID, concatenated. That means a ciphertext copied from
one row into another — by a bug, a bad restore, a copy-paste in a
migration — fails to decrypt instead of silently decrypting as if it
belonged where it landed. A decryption fault surfaces as a fault on the
card, never as a silently blank or silently wrong name.
Why per-record, not one key for everything
The obvious simpler design is one DEK for the whole students table, or
no DEK at all — just encrypt directly with the KEK. Both would pass a "is
it encrypted" checkbox. Neither gets you the property that actually
matters, which shows up the moment someone asks you to delete a record.
Deleting a row from a live database is the easy 5% of "delete this child's data." The other 95% is every place a copy of that row might have gone: last night's backup, the backup from last term, a disk snapshot, a replica, a debug export someone ran eight months ago and forgot about. Reaching all of those and actually removing the plaintext from each one is, for almost any real system, not something you can do with confidence. So "we deleted it" is usually a policy statement — we believe we got everywhere — rather than a fact anyone can verify.
Per-record keys change what "delete" means. If a student's name is encrypted under a DEK that exists nowhere except wrapped on that student's own row, then destroying the wrapped DEK — one small value — makes every copy of the ciphertext, everywhere it has ever been copied, permanently unreadable. You don't need to find the backups. The backups still exist, the bytes are still sitting there, and they are just as useless as if they didn't. Erasure stops being "we searched everywhere and believe we got it all" and becomes "the only thing that could ever have read this no longer exists," which is a claim you can actually stand behind.
To be precise about where Classline is today: this is a property of the
key architecture, not a shipped feature. There is no "erase this
student" button in the product yet. internal/crypto has the mechanism —
wrap, unwrap, and a keyring that never derives a DEK from anything
recoverable (not the KEK, not the record ID, not a counter — see the guard
comment on NewDEK for exactly why that matters) — and it's exercised by
tests. But a mechanism sitting in a crypto package is not the same claim
as a button a school can click, and we're not going to blur that line in
what we tell people.
The honest trade-off
The same property that makes erasure real makes losing the master key absolute. If the KEK is gone — not backed up, not recoverable — every wrapped DEK in the database becomes permanently unopenable, which means every field it protects becomes permanently unreadable. Not "difficult to recover." Unrecoverable, by the same mechanism and for the same reason that a deliberately destroyed student key is unrecoverable. There is no back door, no support ticket that fixes it, no "we still have a copy somewhere" — building one would mean the KEK was recoverable after all, which would quietly undo the entire guarantee.
That is the design working as intended, not a bug to route around, and it has one concrete operational consequence: backing up the master key, somewhere separate from the server it protects, is not a nice-to-have. It is the precondition for every other claim in this post being true instead of catastrophic. A team adopting this pattern needs to treat KEK backup and rotation with at least the seriousness they'd give the primary database's own backups — arguably more, since a lost database backup costs you data, and a lost KEK costs you all of it, at once, forever.
What this doesn't protect against
Field-level encryption at the application layer protects the data from anyone or anything with access to the database but not the running application and its key: a stolen disk, a leaked backup, a compromised database credential used outside the app. It does not, and isn't meant to, protect the data from the operator running the application — a school with legitimate safeguarding duties needs to be able to read a student's record, and the application itself decrypts on their behalf when they do. That's a deliberate scope, not an oversight: the threat model here is "who can read this data without going through the application," not "make the data unreadable to everyone, including the people responsible for the child."