Skip to content

fix: verify a creator signature against the bytes the creator signed - #596

Open
Zyrtnin wants to merge 1 commit into
mainfrom
fix/verify-creator-signature-against-signed-bytes
Open

fix: verify a creator signature against the bytes the creator signed#596
Zyrtnin wants to merge 1 commit into
mainfrom
fix/verify-creator-signature-against-signed-bytes

Conversation

@Zyrtnin

@Zyrtnin Zyrtnin commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

verify_creator_signature re-derived the signed form by calling to_cbor_dict() on the decoded metadata. Decoding is deliberately lossy — _cbor_str drops a wrong-typed field to "" rather than raising, because Photonic mints loc as an integer on mainnet and refusing those tokens surfaced as "metadata: NONE". So the verifier compared the signature against bytes the creator never signed, and reported an honest, correctly-signed token as a forgery.

Measured, with a verifying baseline and a matched control:

case verdict
in-memory, pyrxd-signed (True, '')
TEXT loc, honestly signed (True, '')
INTEGER loc, honestly signed (False, 'signature mismatch')

Worth noting my first attempt at this proof had a harness whose baseline also failed — which would have attributed the mismatch to the wrong cause. A finding isn't isolated until the control passes.

The fix is at the class, not the instance

loc is one field. Any field the decoder normalises — now, or the next time it learns a leniency — silently becomes a forgery verdict. The failure gets worse as the decoder gets more forgiving, which is the opposite of how leniency should behave.

So the decoded object now carries the exact bytes it was decoded from, and verification rebuilds the unsigned form from those, leaving every field with the type and value it had on chain. The re-encoded path remains for in-memory metadata, where the object is the original.

What not to trade it for

A signature valid over on-chain bytes does not mean the creator signed what is being displayed. The old behaviour had that property by construction, at the cost of the false negative above — dropping it silently would swap a visible defect for an invisible one.

So when the decode normalised anything, the verdict says so explicitly rather than returning a bare "valid". Detected by comparing the two forms, not by tracking each field as it is normalised: a hand-kept list of lossy fields goes stale the first time the decoder learns a new leniency, which is how this class of bug arrives in the first place.

Reachability

verify_creator_signature has no caller inside pyrxd — it is exported public API, so its tests are its production entry point. Every case goes through decode_payload rather than building metadata by hand, which is the only way the defect is expressible at all.

Verification

Both halves planted: restoring the decoded-object derivation fails 8 of the new tests; removing the lossy-decode disclosure fails 1. Every rescued case is paired with a forgery that must still be refused — including a forgery on the lossy path, since the fix must not open a bypass for exactly the records it saved.

Full suite: 11,007 passed, 192 skipped, 1 xfailed.

🤖 Generated with Claude Code

verify_creator_signature re-derived the signed form by calling to_cbor_dict() on
the DECODED metadata. Decoding is deliberately lossy: _cbor_str drops a
wrong-typed field to "" rather than raising, because Photonic mints `loc` as an
INTEGER on mainnet and refusing those tokens surfaced as "metadata: NONE". So
the verifier compared the signature against bytes the creator never signed, and
reported an HONEST, correctly-signed token as a forgery.

Measured, with a verifying baseline and a matched control - the first attempt at
this proof had a harness whose baseline ALSO failed, which would have attributed
the mismatch to the wrong cause:

  in-memory, pyrxd-signed          -> (True, '')
  TEXT loc, honestly signed        -> (True, '')
  INTEGER loc, honestly signed     -> (False, 'signature mismatch')   <-- the bug

`loc` is one instance; the defect is the class. ANY field the decoder normalises,
now or the next time it learns a leniency, silently becomes a forgery verdict -
so the failure gets WORSE as the decoder gets more forgiving, which is the
opposite of how leniency should behave. Fixed at that level: the decoded object
carries the exact bytes it was decoded from, and verification rebuilds the
unsigned form from those, leaving every field with the type and value it had on
chain. The re-encoded path remains for in-memory metadata, where the object IS
the original.

WHAT NOT TO TRADE IT FOR. A signature valid over on-chain bytes does not mean
the creator signed what is being DISPLAYED. The old behaviour had that property
by construction, at the cost of the false negative above, and dropping it
silently would swap a visible defect for an invisible one. So when the decode
normalised anything, the verdict says so explicitly rather than returning a bare
"valid". Detected by comparing the two forms, not by tracking each field as it is
normalised - a hand-kept list of lossy fields goes stale the first time the
decoder learns a new leniency, which is how this class of bug arrives.

source_cbor is compare=False: two tokens with identical fields are the same token
regardless of which arrived over a wire, so existing equality assertions hold.

REACHABILITY. verify_creator_signature has no caller inside pyrxd - it is
exported public API, so its tests ARE its production entry point. Every case goes
through decode_payload rather than building metadata by hand, which is the only
way the defect is expressible at all.

Verified by planting both halves: restoring the decoded-object derivation fails 8
of the new tests, and removing the lossy-decode disclosure fails 1. Every rescued
case is paired with a forgery that must still be refused, including a forgery on
the lossy path - the fix must not open a bypass for exactly the records it saved.

Co-Authored-By: Claude Opus 5 (1M context) <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