Skip to content

feat(mediorum): authenticate audio uploads and attest their cids on chain - #460

Open
rickyrombo wants to merge 1 commit into
feat/content-auth-txfrom
feat/content-auth-mediorum
Open

feat(mediorum): authenticate audio uploads and attest their cids on chain#460
rickyrombo wants to merge 1 commit into
feat/content-auth-txfrom
feat/content-auth-mediorum

Conversation

@rickyrombo

@rickyrombo rickyrombo commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Stacked on #459. Review that first — this branch contains only the commit on top of it.

#459 lets consensus gate a track's cid fields on a validator attesting that the claiming wallet uploaded those bytes. This produces that attestation.

Knowing who uploaded

Upload.UserWallet was read straight out of client-supplied tus metadata under a key the SDK never sent, so it was NULL on every SDK upload and forgeable on the rest. Harmless while nothing read it; not harmless now that it decides who may claim the resulting cids.

Audio uploads now verify an EIP-712 signature at tus creation and set UserWallet from the recovered address. Unsigned uploads are still accepted where verification is not required, but are never credited to a wallet — recording an unverified one would put a forgeable value in the field attestations are built from. A signature that is offered and fails to verify is always an error, so a bad one cannot pass itself off as absent.

Why EIP-712

The previous revision of this PR hashed canonical JSON and signed it with personal_sign. Typed data is better on two counts:

  • No canonicalization. The type definition is the encoding, so there is no key ordering or number formatting for client and server to agree on and later drift apart over. That whole class of bug disappears.
  • Domain separation. A bare hashed payload has none — a signature produced for any other purpose over the same two fields would have been replayable as an upload authorization.

It also matches how the SDK already signs entity-manager writes, so both sides use standard calls (signTypedData, go-ethereum's apitypes) rather than a hand-rolled scheme.

The payload covers the user id and a timestamp, not the cid: at tus-create time the bytes have not been sent. Both fields also travel unsigned in tus metadata so the verifier can rebuild the typed data, but they are reproduced inside it, so tampering only breaks recovery. Stale and future-dated signatures are rejected — a captured one would otherwise be a standing credential to upload as someone else.

Only audio is covered. Images are served unauthenticated, so there is no gating story to protect, and requiring signatures would break signup, which uploads a profile picture before the account has a user id.

Decoupled from the DDEX flag

Content authorization runs off ContentAuthEnabled rather than ProgrammableDistributionEnabled. The latter governs DDEX; tying them together meant this bypass could only be closed on networks that also enable an unrelated subsystem with no production consumers — backwards for a fix to the ordinary track-upload path.

An upload is not done until its cids are attested

A client creates its track the moment an upload reads done, and consensus rejects a track naming cids it holds no claim for. Publishing the status first would race the attestation and reject perfectly legitimate uploads, so attestation happens before the status flips and a failure fails the transcode instead. SendTransaction blocks until the transaction is committed, so once the status is done the claims are genuinely on chain.

Cases where no claim was ever possible — content auth off, or an upload with no verified signer — return cleanly rather than erroring, since there is nothing to wait for and no reason to hold the upload back. That decision is split into contentAttestationFor so the rules are testable without a chain.

Cids are attested as they come into being

The initial upload sends one attestation covering the original, the 320 transcode and the preview, since all three exist by the time transcoding finishes.

Previews are the reason #459's attestation carries a plain cid list rather than named orig/transcode/preview fields. POST /uploads/:id lets a client change previewStartSeconds after the fact, which regenerates the preview and yields a new cid every time. Under a single fixed-shape attestation sent at transcode completion, that cid would never be attested and saving the track would be rejected. The edit path now sends its own attestation for the new cid, before responding, so a caller never receives a preview cid it cannot yet use.

Attestation requires a verified signature

Not merely a wallet. The legacy POST /uploads and gRPC paths still populate UserWallet from an unverified X-User-Wallet-Addr header, so keying off the wallet alone would let a forged header mint a claim over someone else's content. Those paths are otherwise untouched; the header fallback is a pre-existing hole worth closing separately.

Testing

Unit coverage for signature recovery, staleness, future-dating, field tampering, the per-template rules, flag independence, which uploads warrant an attestation, and that the skip cases never block an upload from completing.

One is a cross-implementation fixture: a real EIP-712 signature produced by the SDK using viem, pinned so the two independently-written signing paths cannot drift apart silently while both suites still pass on their own. I confirmed it recovers to the expected address in Go before wiring it up.

There is no integration coverage of this path, and I could not add any in this PR. TestUploadStream — the only devnet test that uploads audio — has been skipped since fe3d95c ("flaky on resource-constrained CI runners"), and even un-skipped it would not exercise this change: it uploads through the legacy POST /uploads multipart endpoint with the old per-cid query-string signature, while upload authentication hooks the tus path (tusd.go). An earlier revision of this stack was reported here as devnet-verified; that was overstated, and I have removed the claim.

What that leaves untested end to end: signature verification against a real tus client, the attestation reaching consensus, and the done-blocks-on-attestation behaviour. The unit tests cover the decision logic and the signing format on both sides, but not the wiring. Worth deciding whether this stack should carry a tus-based integration test before enforcement is scheduled.

Two pre-existing test issues found along the way, neither caused by this change: TestGRPC panics on a nil response (fails identically on the base commit), and TestUploadFile / TestRepair / TestPollDelistStatuses are not idempotent — they only pass against a fresh database.

🤖 Generated with Claude Code

@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from 419c9ab to 93878ea Compare August 7, 2026 06:25
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from 93878ea to cd4cfd9 Compare August 7, 2026 06:37
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from cd4cfd9 to 58d7dfa Compare August 7, 2026 06:48
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from 58d7dfa to 0bb71fa Compare August 7, 2026 07:46
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch 2 times, most recently from 1073aab to b9127ac Compare August 7, 2026 08:31
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from b9127ac to 237fc08 Compare August 7, 2026 08:45
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from 237fc08 to 54855c3 Compare August 7, 2026 09:00
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch 2 times, most recently from e8fcacf to 1da4b79 Compare August 7, 2026 09:29
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from 1da4b79 to 43f2651 Compare August 7, 2026 09:32
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from 43f2651 to 3eebfd5 Compare August 7, 2026 09:47
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from 3eebfd5 to 24ce10c Compare August 7, 2026 09:56
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from 24ce10c to 759266a Compare August 7, 2026 10:05
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from 759266a to 0058e99 Compare August 7, 2026 10:33
…hain

Consensus can now gate a track's cid fields on a validator attesting that the
claiming wallet uploaded those bytes. This produces that attestation.

Verify an EIP-712 signature at tus upload creation for audio, and set
Upload.UserWallet from the recovered address. It was previously read straight
out of client-supplied tus metadata under a key the SDK never sent, so it was
NULL on every SDK upload and forgeable on the rest — harmless while nothing
read it, not harmless now that it decides who may claim the resulting cids.

Typed data rather than a hashed JSON payload. It removes JSON canonicalization
from the protocol entirely — the type definition is the encoding, so there is
no key ordering or number formatting for client and server to agree on and
later drift apart over — and it gives domain separation, which the previous
scheme lacked: a signature produced for any other purpose over the same two
fields would have been replayable as an upload authorization. It also matches
how the SDK already signs entity-manager writes, so both sides use standard
calls rather than a hand-rolled hashing scheme.

The payload covers the user id and a timestamp, not the cid: at tus-create time
the bytes have not been sent and nobody knows the cid yet. Both fields also
travel unsigned in tus metadata because the verifier needs them to rebuild the
typed data, but they are reproduced inside it, so tampering only breaks
recovery. Stale and future-dated signatures are rejected — a captured one would
otherwise be a standing credential to upload as someone else.

Only audio is covered. Images are served unauthenticated, so there is no gating
story to protect, and requiring signatures would break signup, which uploads a
profile picture before the account has a user id.

After transcoding, submit a ContentAttestation over all three cids — the
original, the 320 transcode that becomes track_cid, and the preview. The
original matters most: it is the download path, the lossless master. Both sides
derive the signed bytes from one shared constructor so they cannot drift on
field order or address casing.

Content authorization runs off its own flag rather than
ProgrammableDistributionEnabled. That flag governs DDEX; tying them together
meant this bypass could only be closed on networks that also enable an
unrelated subsystem with no production consumers, which is backwards for a fix
to the ordinary track-upload path.

Attestation requires a verified signature, not merely a wallet. The legacy
POST /uploads and gRPC paths still populate UserWallet from an unverified
X-User-Wallet-Addr header, so keying off the wallet alone would let a forged
header mint a claim over someone else's content. Those paths are otherwise left
alone; the header fallback is a pre-existing hole worth closing separately.

Attestation failures are logged, not fatal. A dropped attestation costs the
uploader the ability to claim those cids, which re-uploading fixes; failing the
transcode would throw away the transcode too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rickyrombo
rickyrombo force-pushed the feat/content-auth-mediorum branch from 77859b4 to 4d33128 Compare August 8, 2026 08:04
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