feat(worker): seal a stored protocol under a key the retention sweep cannot take - #155
Open
TheMeinerLP wants to merge 1 commit into
Open
feat(worker): seal a stored protocol under a key the retention sweep cannot take#155TheMeinerLP wants to merge 1 commit into
TheMeinerLP wants to merge 1 commit into
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-apireads it back through it. The port the sink writes to has exactly one method and it is calledput_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 chunkingencrypt_filedoes, 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/markdownis 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 fromsturnus.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 afteraudio_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 answers200withaudio_available: falserather than404precisely 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_keywhen 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:
session_documenthas 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.SessionDocumentnow carriesguild_id, joined fromsessionrather 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.pypins 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_keyand, 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 indocs/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}insturnus.console.routes_documents, under the session's own participant rule. That route is unchanged except for theguild_idit now passes and a second way for the read to fail.What it deliberately does not do
session.export_unsealedat 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.apialready holdsSTURNUS_MASTER_KEY— it decrypts audio on the way to the browser — andworkeralready holds it.charts/sturnus/templates/_helpers.tplis untouched:linkstill holds no master key,apistill holds no Discord token,consolestill holds nothing.session_document.document_idis 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.Checks
uv run pytest -q -m "not slow"2764 passed, 4 deselected (2742 before, 22 new) ·uv run ruff check .clean ·uv run mypy srcclean, 141 source files ·uv run mypy(src, tests, scripts) clean, 293 source files.