Sensitive data¶
Some data in the estate grants nobody anything and must still never be readable by whoever holds a disk, a backup, a git clone or a database dump: what a person consented to, what they asked never to be played at the table, what a moderator wrote about them, who they are outside the fiction, and which character is theirs. This page is how Nerthus.Platform enforces that and where each piece lives. It is not the credential census: a leaked credential is answered by rotating it, and a leaked class here cannot be answered at all.
The six classes¶
The registry is packages/nerthus/common/sensitive.yaml, carried in the wheel. The list is closed:
nerthus.common.sensitive refuses a seventh class.
| Class | What it is | Capability | Subject reads own | Exports | Ships between sites |
|---|---|---|---|---|---|
consent |
a decision about tokenize, analyse, train or export, and its reason |
consent.read |
yes | subject only | no |
reserved-topics |
reserved topics («tematy zastrzeżone»): what a player asked never to appear | reserved-topics.read and cast membership |
yes | never | no |
moderation-ledger |
the case register | moderation.read |
— | never | no |
moderation-record |
one case and its evidence | moderation.read |
— | never | no |
identity |
a Discord id or handle, an email | identity.read |
yes | subject only | no |
ownership |
which player owns which character | ownership.read |
yes | subject only | no |
The two moderation classes share one capability: a holder who could read that a case exists and not
what it says is a shape nobody needs. reserved-topics is readable by a narrator of a session the
player is in, so nerthus.domain.sensitive refuses to decide without being told the cast.
In the database: envelopes and blind indexes¶
A sensitive column is declared once:
enrolled_by: Mapped[str | None] = mapped_column(
SensitiveText("identity", "authored.instances.enrolled_by"),
info=sensitive("identity"),
)
enrolled_by_bi: Mapped[str | None] = mapped_column(BlindIndex())
and stored as
AES-256-GCM under the site's data key. The class, the key version and the column's fully qualified name are the additional authenticated data, so an envelope moved to another column, or one whose header was edited, does not open.
The application encrypts, never pgcrypto. A column the server encrypts is a column whose key
reaches the server: in a parameter, in pg_stat_statements, in a log somebody turned on.
Lookups are blind indexes, equality only. enrolled_by_bi holds HMAC-SHA256 over the NFKC,
trimmed, case-folded plaintext, under a subkey derived from the data key. Ordering, prefixes and
ranges are impossible by design, because they would leak the plaintext's order. The index's field
name is the column's own qualified name, so every writer derives the same token for one value; two
columns share a token only by declaring info={"blind_index_field": ...}, which links them for
anybody holding the index. nerthus.db.sensitive.assert_no_plaintext_indexes refuses a plaintext
index on an envelope column.
The read rule arrives in a context variable, and the default is deny. A type decorator is handed
a value and nothing else, so a query run by code that never asked the read rule returns [ukryte]
rather than the plaintext. domain.sensitive.reading_as is the one thing that opens it.
The key¶
nerthus secrets keygen # the instance identity, first
nerthus secrets datakey init # mint the site's data key into the sops store
nerthus secrets datakey rotate # mint the next version; the old one stays
The key lives in the instance's sops store as SITE_DATA_KEY_V<n>, never in the database it
protects. It is a credential class like any other (site-data-key, data-is-out) and rotates at a
ceremony.
rotate does not retire the old version: until every row is re-sealed, dropping it would make
every row not yet reached unreadable. The re-seal pass is the sensitive.reseal job, run manually,
because a re-encryption nobody watches is one nobody can vouch for. It is idempotent, reports what it
saw as well as what it moved, and moves each row's blind index with its envelope. A query taken
during a rotation uses blind_indexes(), which returns one token per held key version; the singular
form would silently return half the rows.
In git: sops files inside the corpora¶
| Class | Where |
|---|---|
consent |
metadata:consent/*.sops.yaml |
identity, ownership, reserved-topics |
metadata:Osoby/<id>.sops.yaml |
moderation-ledger |
moderation:.nerthus/moderation/ledger.sops.yaml |
moderation-record |
moderation:.nerthus/moderation/records/<id>.age |
These files are encrypted to the site's instances and the operator's recovery identity only. A
client instance is never a recipient, whatever per_instance says: a client reads sensitive rows
through the API or not at all. nerthus secrets verify reports
sops-file-client-recipient-of-a-sensitive-class and
sops-file-external-recipient-of-a-sensitive-class.
nerthus check metadata --corpus metadata # report
nerthus check metadata --corpus metadata --strict # refuse
The check reads a directory, not a glob: every file where a class lives must be a sops document
or an age file, and the exceptions are declared by name in the registry
(allow_plaintext: [README.md]). It reports and exits zero while enforcement.git.blocking in the
registry is false, which it is until the metadata and moderation corpora carry their sops files;
the advisory arm prints exactly what the blocking one would.
Logs, audit rows and tracebacks¶
nerthus.common.redact carries two vocabularies:
- credentials, masked everywhere, stdout included;
- sensitive classes, masked in log records, audit rows and trace attributes, and not on stdout.
nerthus consent show prints a person their own reason through a command that has already asked
the read rule, so presentation is gated by the read rule and sinks by the filter. A class value is
masked to the end of the line, because these values are prose.
The lint¶
It refuses a column whose name claims a class and carries no annotation; an annotated column that
is not a SensitiveText; and a plaintext index or unique constraint over an annotated column. The
vocabulary is matched against a column's words, never as substrings: it catches discord_id and
misses an identity column named owner_ref. An exemption must name a column the vocabulary would
have caught; one that exempts nothing is refused.
What a withheld line shows¶
A withheld transcript line shows that it is withheld (speaker, time, line count, the marker
[ukryte]) and never the reason or who holds the policy:
There is no reason key at all, so a client cannot tell "withheld for a reason I may not see" from
"withheld". domain.sensitive.withheld_line() builds that object rather than deleting keys from a
full one, so a field added upstream cannot leak through it.
What is proved¶
tests/test_sensitive_sinks.py plants a plaintext of every class and greps every place it could
surface: a git clone of the fixture corpora, pg_dump, the encrypted backup file and the logs.
Each sink's own control is found in it first, and only then is the planted value asserted absent.
Sinks with no producer yet are asserted at the function their producer must call.