feat(core): authorize track cids against validator upload attestations - #459
Open
rickyrombo wants to merge 2 commits into
Open
feat(core): authorize track cids against validator upload attestations#459rickyrombo wants to merge 2 commits into
rickyrombo wants to merge 2 commits into
Conversation
This was referenced Aug 6, 2026
rickyrombo
force-pushed
the
feat/content-auth-tx
branch
3 times, most recently
from
August 7, 2026 06:48
ea5e3f1 to
1637f26
Compare
rickyrombo
force-pushed
the
feat/content-auth-tx
branch
from
August 7, 2026 07:46
10b272d to
9b59607
Compare
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
force-pushed
the
feat/content-auth-tx
branch
3 times, most recently
from
August 7, 2026 08:58
22d950b to
408cbfa
Compare
rickyrombo
force-pushed
the
feat/content-auth-tx
branch
7 times, most recently
from
August 7, 2026 23:33
a73a74a to
de628d9
Compare
rickyrombo
force-pushed
the
feat/content-auth-tx
branch
4 times, most recently
from
August 8, 2026 07:15
40f67f8 to
a0473b8
Compare
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
force-pushed
the
feat/content-auth-tx
branch
from
August 8, 2026 08:02
a0473b8 to
221b3d4
Compare
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.
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_cidoff 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_cidsrecords which wallet may assert a cid, populated from a validator attesting that the wallet uploaded those bytes. UnderRules.ContentAuthEnforceda 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_cidis the download path, the lossless master, and the more valuable leak.Why a new transaction type
ContentAttestationrather than reusingFileUpload. 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.
FileUploadis left exactly as it was — this PR touches no DDEX surface.The attestation says nothing about what each cid is
It carries a
repeated cidsand 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_cidsahead of enforcement.Safety on the existing chain
upgradeSchedulesis 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