Skip to content

feat(core): authorize track cids against validator upload attestations - #459

Open
rickyrombo wants to merge 2 commits into
mainfrom
feat/content-auth-tx
Open

feat(core): authorize track cids against validator upload attestations#459
rickyrombo wants to merge 2 commits into
mainfrom
feat/content-auth-tx

Conversation

@rickyrombo

@rickyrombo rickyrombo commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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

The hole

A track's cid fields are plain client metadata, and nothing ever checked them against the bytes they name. Anyone can read a gated track's track_cid off the public API — it is returned even to callers with no access — create their own ungated track asserting that same cid, and stream it. The access check passes because they genuinely own the decoy track, and the storage node only ever verifies "this signature is for this cid", never "this cid belongs to that track".

Signer authorization does not close this: the attacker is acting for themselves, on a track they own, so every check #450 added passes.

The fix

Make possession the entitlement. core_auth_cids records which wallet may assert a cid, populated from a validator attesting that the wallet uploaded those bytes. Under Rules.ContentAuthEnforced a Track create/update may only name cids whose claim authorizes the acting user, resolved through the same signer predicate the rest of the auth state uses — so managed accounts and grant-holders keep working.

Forging a claim then requires possessing the audio, at which point the paywall was already moot. All three of a track's cid fields are covered; orig_file_cid is the download path, the lossless master, and the more valuable leak.

Why a new transaction type

ContentAttestation rather than reusing FileUpload. That message serves DDEX, where the uploader signs the original cid; here they authenticate before any cid exists, so the same field would mean two different things and every rule would branch on which mode produced it.

Reusing it would also inherit its programmable-distribution flag, which would make fixing this bypass conditional on enabling an unrelated subsystem that has no production consumers. FileUpload is left exactly as it was — this PR touches no DDEX surface.

The attestation says nothing about what each cid is

It carries a repeated cids and no roles. The original, the transcode and the preview are all just bytes the uploader possessed; whether a cid ends up as a track's audio or its preview is track metadata, and consensus never asks — the projection loops over the cids identically and enforcement loops over the three metadata keys identically, so roles would be information no reader uses.

Keeping them out is also what makes attestation incremental. A fixed orig/transcode/preview shape can only be sent once, at a moment when all three exist — but changing a track's preview start regenerates the preview, and each change yields a new cid. Under a single-shot attestation those cids would never be claimable and the edit would be rejected. Callers now attest each cid as it comes into being.

Signature design

The validator's signature covers every field the attestation speaks about, so one lifted from an on-chain transaction cannot be re-paired with a different uploader or cid set.

The uploader's own signature rides along for dispute forensics and is deliberately not verified. It is produced before any cid exists, so it names no content and cannot connect a wallet to these bytes — only the validator, which saw them arrive over an authenticated connection, can do that. Its freshness window is also unenforceable at consensus time, since transcoding takes an unbounded amount of time. Verifying it would prove nothing while turning any future change to the client's signing format into a replay-breaking consensus change.

Design notes

Claims are keyed on the user the upload was made for, not the uploading wallet. (cid, uploader_user_id).

The wallet is the wrong key because a developer app is a single wallet shared by every user who granted it. Authorizing "whoever can act for the uploading wallet" would let any of a popular app's users assert any other's uploads — reopening the bypass for that app's whole population, with no race needed. The user id comes from the uploader's own signed request, which already carries it.

A cid may be claimed by several users. Two uploaders who each genuinely hold the same bytes both get a claim and neither blocks the other — possession is the entitlement, and it should not be first-come. Restricting to one claimant would buy nothing: attestations already require a registered validator, and a validator mirrors the network's blobs, so anyone able to write a claim can already read the content and could launder it into a legitimate claim by re-uploading. It would also lock legacy owners out — production data has ~115k track rows whose cid is shared across owners.

Migration seeding. The genesis replay records a claim per track from its owner, with no attestation — the legacy data is the authority and there is no upload event left to attest to. This is what makes enforcement activatable at all; without it the first edit to any of the ~1.4M existing tracks would be rejected, and it makes the genesis rollover the natural activation moment.

Pre-gate, attestations are accepted at block validation but error in finalize, matching a binary that predates the type on both block validity and result code (see #463). No claims are written, so nobody can seed core_auth_cids ahead of enforcement.

Safety on the existing chain

upgradeSchedules is keyed by chain ID and prod maps to {}, so every rule resolves inactive there at any height. Nothing in this PR changes accept/reject behaviour for any transaction type that exists on the current chain.

Testing

Unit coverage for the reported bypass directly, plus cid substitution, manager-under-grant uploads, audio replacement, migration seeding, claims accumulating across separate attestations, and the attestation-binding forgeries (substituted, appended and reordered cids all break the signature).

No devnet coverage — the only audio-upload integration test is skipped and exercises a different endpoint anyway. See #460 for the details.

🤖 Generated with Claude Code

A track's cid fields are plain client metadata that nothing ever checked
against the bytes they name. Anyone can read a gated track's track_cid off the
public API — it is returned even to callers with no access — create their own
ungated track asserting that same cid, and stream it. The access check passes
because they genuinely own the decoy track, and the storage node only verifies
"this signature is for this cid", never "this cid belongs to that track".

Signer authorization does not close this: the attacker acts for themselves, on
a track they own.

Make possession the entitlement. core_auth_cids records which wallet may assert
a cid, populated from a validator attesting that the wallet uploaded those
bytes to it. Under Rules.ContentAuthEnforced a Track create/update may only
name cids whose claim authorizes the acting user, through the same signer
predicate the rest of the auth state uses, so managed accounts and
grant-holders keep working. All three cid fields are covered — orig_file_cid is
the download path, i.e. the lossless master, and the more valuable leak.

ContentAttestation is a new transaction type rather than a reuse of FileUpload.
FileUpload serves DDEX, where the uploader signs the original cid; here they
authenticate before any cid exists, so the same field would mean two different
things and every rule would branch on which mode produced it. Sharing it would
also inherit its programmable-distribution flag, and content authorization has
to be able to activate on a network where DDEX is switched off — that coupling
would make fixing this bypass conditional on enabling an unrelated subsystem
with no production consumers. FileUpload is left exactly as it was.

The validator's signature covers every field the attestation speaks about, so
a signature lifted from an on-chain transaction cannot be re-paired with a
different uploader, upload, or cid set. The uploader's own signature rides
along for dispute forensics and is deliberately not verified: it is produced
before any cid exists, so it names no content and cannot connect a wallet to
these bytes, and its freshness window is unenforceable at consensus time
because transcoding takes an unbounded amount of time. Verifying it would
prove nothing while turning any future change to the client's signing format
into a replay-breaking consensus change.

Claims are first-attestation-wins. A validator mirrors other nodes' blobs, so
allowing a later attestation to overwrite would let one malicious registered
node claim most of the network's content. Re-attesting to the wallet that
already holds the cid stays a no-op, because honest re-uploads are routine.

Cids are deliberately not globally unique: two uploaders who each hold the same
bytes get their own claim and neither blocks the other. Uniqueness would break
honest re-uploads and hand attackers a squatting vector.

The genesis migration seeds a claim per replayed track from the owner, with no
attestation — the legacy data is the authority and there is no upload event
left to attest to. That is what makes enforcement activatable at all; without
it the first edit to any existing track would be rejected.

Attestations are rejected outright before the gate height, so unenforced claims
cannot accumulate on chain and let an attacker squat cids ahead of enforcement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rickyrombo
rickyrombo force-pushed the feat/content-auth-tx branch 3 times, most recently from 22d950b to 408cbfa Compare August 7, 2026 08:58
@rickyrombo
rickyrombo force-pushed the feat/content-auth-tx branch 7 times, most recently from a73a74a to de628d9 Compare August 7, 2026 23:33
@rickyrombo
rickyrombo force-pushed the feat/content-auth-tx branch 4 times, most recently from 40f67f8 to a0473b8 Compare August 8, 2026 07:15
Content attestations carry no weight before Rules.ContentAuthEnforced, and the
first cut rejected them at block validation until then. That is a fork.

validateBlockTx has no default case, so a binary predating this transaction
type falls through the switch and votes the block valid. An upgraded node
rejecting it would vote down proposals that un-upgraded nodes accept, and any
peer could trigger that during a rolling deploy by submitting one attestation
to a node that has not restarted — un-upgraded mempools admit them without
validation, so it reaches a proposal.

Accepting alone is not sufficient either. CometBFT folds each transaction's
result Code into the block's LastResultsHash — abci.DeterministicExecTxResult
keeps Code, Data, GasWanted and GasUsed — and that hash is in the header. An
un-upgraded node reaches finalizeTransaction's default case and errors,
yielding Code 2. Had this returned success pre-gate, the two would compute
different headers and stall, which is a failure mode this network has hit
before.

So pre-gate behaviour has to be indistinguishable from a node that does not
know the type at all: accept the block, and error the transaction. Erroring
also means no claims are written, which is what keeps an attacker from seeding
core_auth_cids ahead of enforcement — the property the original rejection was
reaching for, obtained without the fork.

Mempool admission still refuses them. That is local policy rather than block
validity, so upgraded and un-upgraded nodes may differ there safely: an
upgraded node declines to propagate or propose one, but never disagrees about a
block that contains one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rickyrombo
rickyrombo force-pushed the feat/content-auth-tx branch from a0473b8 to 221b3d4 Compare August 8, 2026 08:02
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