Skip to content

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

NRTHENV1.<class>.<key version>.<base64url nonce>.<base64url ciphertext and tag>

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

nerthus.db.sensitive.lint(AuthoredBase.metadata, DerivedBase.metadata)

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:

{"redacted": true, "speaker": "Halszka", "at": "01:12", "lines": 5}

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.