Skip to content

feat(worker): seal a stored protocol under a key the retention sweep cannot take - #155

Open
TheMeinerLP wants to merge 1 commit into
mainfrom
feat/worker-export-encryption
Open

feat(worker): seal a stored protocol under a key the retention sweep cannot take#155
TheMeinerLP wants to merge 1 commit into
mainfrom
feat/worker-export-encryption

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Export targets (#144) let a guild have its protocol rendered as Markdown or HTML and written to the object store instead of Outline. Those artefacts were stored in clear, and #144 said so itself. Every other object in that bucket is ciphertext — the recordings, and since #146 the spectrograms beside them. This one was the exception, and it is the worst possible exception: a recording is one speaker, a Markdown export is every word every participant said, in one object, at a predictable key.

This seals them.

What it does

An object-store artefact is now written through SealedArtefacts (sturnus.infrastructure.documents.artefacts), which both processes hold: the worker writes one through it, sturnus-api reads it back through it. The port the sink writes to has exactly one method and it is called put_sealed — there is no unsealed spelling of the act left to choose, which is the difference between a rule and a habit.

The envelope is small and self-describing, in the same family as the recording format: STRN\x02, then the wrapped data key, a nonce, and the sealed body. One seal rather than the chunking encrypt_file does, because a protocol is tens of kilobytes and a recording is hundreds of megabytes — the chunking exists for a size problem this object does not have.

The stored object no longer carries the format's media type either. text/markdown is true of the document and false of an envelope, and a bucket listing that says otherwise invites the next reader to treat the bytes as text. The real media type still reaches the browser from sturnus.application.export_formats, which is where it always came from.

Why the key is the artefact's own, and never the recording's

This is the decision the change is actually about, and getting it wrong would have looked exactly like getting it right.

A recording is sealed under a per-session data key wrapped into transcription_job, and the retention sweep ends that recording's life after audio_retention_days — thirty days by default. An export artefact is not audio. It belongs to the record of the meeting, which deliberately outlives the recording: #142 established that a transcript answers 200 with audio_available: false rather than 404 precisely because the retention window governs the recording and not the record of it.

So sealing an export under the session data key would have tied a document meant to last to a key whose entire purpose is to stop lasting. Every stored Markdown and HTML export unreadable on day thirty-one — silently, no error anywhere, noticed by whoever next opened an old document and got a 404. That is a data-loss bug wearing the costume of a security fix, and it would have passed review as the tidy reuse of an existing primitive.

Two further facts point the same way even setting the lifetime aside. A data key is per job, which is per speaker; a session's protocol merges every speaker, so there is no one session key to choose and picking one would bind a whole meeting's document to one participant's row. And the obvious future hardening of the retention sweep — clearing wrapped_data_key when the audio goes, so a row keeps no key material for a recording that no longer exists — would take every protocol with it.

Each artefact therefore carries a data key of its own, generated when the artefact is written and wrapped under the master key with secret_context("export-artefact", guild_id). It opens with the master key and the guild alone: no row has to have survived, and nothing the sweep destroys is involved.

Two consequences of that shape are worth stating:

  • The wrapped key lives in the object, not in a column. session_document has no place for one and does not gain one here, so there is no migration. Against somebody holding the bucket that is the same key wrapped under the same master key as everything else; against somebody holding only the database it is strictly less. And it makes an artefact complete on its own — restore the bucket and the master key and every protocol in it still opens.
  • The associated data comes off the row, never out of the object. An envelope that carried the guild it was filed under would authenticate just as happily after being moved onto another guild's key. So SessionDocument now carries guild_id, joined from session rather than stored — a document's guild is its session's and cannot be anything else — and the console route supplies it on every read. An artefact copied from one guild's prefix to another's fails to authenticate instead of handing that guild somebody else's meeting.

tests/application/test_retention.py pins the lifetime from both sides: the sweep deletes the audio and the picture and nothing else in the bucket, and a protocol still opens after its recording has been swept and its row's key material cleared. Re-binding these objects to the audio's lifetime fails a test rather than a link.

What the retention sweep does with them today, and what reads them back

Both answers were worth checking and neither had been written down.

Nothing deletes an export artefact. Not the sweep, which deletes what the job row names — s3_key and, since #146, spectrogram_key. Not the console. Not a re-export, which replaces the object at the same address rather than adding one. That is the intended rule and this PR states it in docs/operations.md §6 rather than leaving it as an omission a future reader could mistake for a bug: a protocol swept because its recording expired would be a meeting's minutes deleted on the strength of a window that was never about them.

Two consequences an operator has to plan for, now also written down: these objects accumulate, one per session per object-store destination, so the bucket lifecycle rule that backstops the recordings must not be written broadly enough to catch them; and their retention is a policy question this system does not answer — if a deployment needs stored protocols to expire, somebody has to state that rule, and there is no setting for it.

Exactly one thing reads them back: GET /api/sessions/{id}/documents/{target_id} in sturnus.console.routes_documents, under the session's own participant rule. That route is unchanged except for the guild_id it now passes and a second way for the read to fail.

What it deliberately does not do

  • It does not refuse the plaintext artefacts already in the bucket. feat(worker): publish a protocol to more than one place, in more than one shape #144 shipped in v0.16.0 and wrote them in clear; nothing sweeps them, so they are still there and their links are already in Discord messages. Refusing them would turn a link somebody holds into a 404 over bytes that are already written — and the object is the only way to reach that document. They are served, and each read logs session.export_unsealed at WARNING so the corpus is visible while it drains. A re-export replaces one with a sealed object at the same address. There is no bulk migration, deliberately: re-sealing would mean re-rendering every protocol ever published, from transcripts whose recordings may be gone.
  • It does not add a migration. The wrapped key is in the object, which is what makes that unnecessary rather than deferred.
  • It does not move a credential, and it does not need to. api already holds STURNUS_MASTER_KEY — it decrypts audio on the way to the browser — and worker already holds it. charts/sturnus/templates/_helpers.tpl is untouched: link still holds no master key, api still holds no Discord token, console still holds nothing.
  • It does not change the object key. session_document.document_id is the key, so a new naming scheme would orphan every row already written. The self-description is the magic in the body, not the suffix.
  • It does not bind an artefact to its session or its target, only to its guild and purpose. Moving one artefact onto another artefact's key within the same guild requires writing both the object and the row that resolves to it; the value of narrowing further did not look worth a binding that a legitimate re-key could not reproduce.

Checks

uv run pytest -q -m "not slow" 2764 passed, 4 deselected (2742 before, 22 new) · uv run ruff check . clean · uv run mypy src clean, 141 source files · uv run mypy (src, tests, scripts) clean, 293 source files.

…cannot take

A guild publishing to a `markdown` or `html` export target got an artefact
in the audio bucket, in clear. Every other object in that bucket is
ciphertext, and this was the most sensitive of them: a recording is one
speaker, a protocol is every word every participant said. The gap was
flagged when export targets landed and it is closed here.

The artefact is sealed in a small self-describing envelope, `STRN\x02`,
which carries the wrapped key that opens it: magic, the wrapped data key,
a nonce, the sealed body. One seal rather than the recording format's
chunking, because a protocol is tens of kilobytes and a recording is
hundreds of megabytes.

**The key is the artefact's own and never the recording's.** A recording
is sealed under a per-session data key that the retention sweep ends the
life of after `audio_retention_days`. A protocol is not audio: it belongs
to the record of the meeting, which deliberately outlives the recording --
a transcript answers `200` with `audio_available: false` rather than `404`
for exactly that reason. Sealing an export under the session data key
would have made every stored Markdown and HTML export unreadable on day
thirty-one, silently, noticed by whoever next opened an old document. Two
further facts say the same thing: a data key is per job and therefore per
speaker, while a protocol merges every speaker, so there is no one session
key to pick; and the obvious hardening of that sweep -- clearing
`wrapped_data_key` when the audio goes -- would take the protocols with
it.

So each artefact carries a fresh data key wrapped under the master key
with `secret_context("export-artefact", guild_id)`. An object relocated
onto another guild's key fails to authenticate instead of handing that
guild somebody else's meeting; the binding comes off the `session_document`
row rather than out of the object, because an envelope carrying the guild
it was filed under would authenticate just as happily after being moved.
The lifetime is pinned by a test that sweeps a job and then opens its
protocol with the master key alone.

Artefacts written before this are plaintext and are still served, logged
at WARNING on every read: nothing sweeps them, and refusing them would
turn a link somebody already holds into a 404 over bytes that are already
in the bucket. A re-export replaces one, sealed, at the same address.

No credential moves. `api` already holds the master key -- it decrypts
audio on the way to the browser -- and `worker` already holds it too.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant