Skip to content

feat(content-auth): attribute uploads by asserted user id, not signature - #476

Open
rickyrombo wants to merge 3 commits into
feat/content-auth-txfrom
feat/content-auth-unsigned
Open

feat(content-auth): attribute uploads by asserted user id, not signature#476
rickyrombo wants to merge 3 commits into
feat/content-auth-txfrom
feat/content-auth-unsigned

Conversation

@rickyrombo

Copy link
Copy Markdown
Contributor

Stacked on #459 (base branch feat/content-auth-tx). Replaces the mediorum half of the signature design — #460 and the unpushed preview branch — and includes their commits; #460 can be closed in favor of this. Client counterpart: AudiusProject/apps#14555.

What changed vs. the signed design

The EIP-712 request-signature scheme is removed from both tus uploads and generate_preview; attribution is an asserted userId (tus metadata key / query parameter). upload_request_eip712.go, the freshness window, and the TS-fixture interop test are deleted.

Why the assertion is sufficient (full reasoning at the top of upload_auth.go):

  • Asserting someone else's id only ever credits them; using a claim requires a signed, grant-checked entity-manager write — that write is where the user authenticates.
  • Upload claims derive from bytes the node received; producing a gated track's cid requires the gated bytes.
  • generate_preview refuses users that do not already claim the source cid, and a minted preview cid is worthless to the requester: serveBlob never serves audio by bare cid and discovery only signs track-resolved cidstream URLs. That serving invariant is load-bearing for gated content generally — if audio ever becomes fetchable by bare cid, gated tracks leak directly, previews or not.

The signed design also had two defects this replaces outright: the recovered wallet was never bound to the asserted userId (making the signature decorative — any keypair could sign for any user, which on the preview endpoint reopened the exact bypass it exists to close), and preview attestations were built without an uploader address while consensus rejected empty ones.

Behavior

  • Audio tus creates must carry userId when ContentAuthEnabled (fail at create, not at publish); malformed ids are rejected even when it is off, so a bad assertion cannot pass as no assertion. Images unchanged.
  • generate_preview?userId= — required under content auth, claim-checked against the source cid before any transcode work.
  • ContentAttestation now carries an empty UploaderAddress/UploaderSignature; consensus accepts that (fields stay in the payload derivation so populated values remain signature-covered). Claims were already keyed on (cid, userId) — the projection never read the wallet.
  • Standing constraint documented in upload_auth.go and pinned by tests: claims are authorization material, not user activity. Nothing may treat "user X claims cid C" as evidence X did anything without adding real authentication.

Testing

  • pkg/core/server: all pass, including a new test pinning that an empty uploader address is not a rejection reason
  • pkg/mediorum/server: auth/preview/attestation tests rewritten for the assertion model and pass; TestUploadFile/TestRepair/TestPollDelistStatuses fail identically with and without this change (pre-existing, environment-dependent multi-node tests)
  • go build ./... clean; proto regen is comment-only

Follow-ups worth separate issues: a regression test pinning the audio-never-by-bare-cid serving invariant, and per-IP rate limits on generate_preview (it triggers network blob pulls, ffmpeg, and a blocking chain write).

🤖 Generated with Claude Code

rickyrombo and others added 3 commits August 8, 2026 01:03
…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>
POST /generate_preview takes any cid on the network and produces new bytes
with no upload behind them, so there is no uploader to credit — but the
result still needs a claim. Previews stream publicly, so a preview cid
anyone could name on their own track would let an attacker tile a gated
track at 30-second offsets and reassemble it.

The caller now signs the request with the same EIP-712 payload tus uploads
use, and the node refuses unless that user already claims the source cid.
The preview is then attested to them. Crediting the caller without the
source check is the attack; crediting them with it grants nothing they did
not already hold.

Authorization runs before the blob pull and the ffmpeg run, so the endpoint
also stops doing unbounded work for unauthenticated callers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces the EIP-712 request-signature scheme with unauthenticated user-id
assertion, for both tus uploads and generate_preview.

The signature was not what made claims safe, and the assertion is not what
makes them unsafe:

- A claim cannot be stolen by asserting someone else's id: asserting user X
  only ever credits X, and exercising a claim — naming the cid on a track —
  happens in an entity-manager write, which is signed and grant-checked.
- A claim cannot cover content the caller does not hold: upload claims derive
  from bytes the node received, and preview claims require the asserted user
  to already claim the source cid.
- A minted preview cid is worthless to the requester: audio is never served
  by bare cid (serveBlob blocks it), and discovery only signs cidstream URLs
  it resolved from a track record. That serving invariant already guards
  gated tracks directly, so previews sit behind the same door as the masters
  they are cut from.

What the signature bought — spam pricing and non-forgeable attribution — is
priced by years of running these endpoints unguarded without abuse, and its
cost was real: an OAuth client-side signing path that does not exist, a
wallet-binding step that is easy to get wrong, and a freshness window to
maintain. The EIP-712 scheme remains in history as the v2 contingency if
attestation spam materializes.

Consequences carried through here:

- tus creates require a userId metadata key on audio when content auth is
  enabled (fail at create, not at publish); malformed ids are rejected even
  when it is not, so a bad assertion cannot masquerade as no assertion.
- generate_preview takes ?userId= and refuses users that do not claim the
  source cid. Requests are refused before transcode work is done.
- ContentAttestation carries an empty uploader address and signature, and
  consensus no longer requires them; the fields stay in the payload
  derivation so populated values remain covered by the validator signature.
  This also fixes preview attestations, which were built without an uploader
  and would have been rejected by the old required-field check.
- The standing constraint is documented at the top of upload_auth.go:
  claims are authorization material, not user activity. Nothing may treat
  them as evidence a user did something without adding real authentication.

Pre-existing environment-dependent failures (TestUploadFile, TestRepair,
TestPollDelistStatuses) fail identically with and without this change; all
other mediorum and core server tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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