Verification picks the record down to signature.signedPropertyNames before checking anything. A field present in the bytes but absent from that list is dropped and never inspected, while remaining in what gets stored and rendered. signedPropertyNames lives inside signature, which is not part of the signed bytes, so the list is chosen by whoever signs the record.
So what gets verified is a strict subset of what clients display, and the record chooses the subset.
The concrete exploit today: crosspost
Leave crosspost out of signedPropertyNames on a record that has one. pick() drops it, so _verifyCrosspost is never called for that level. The nested embedded record then gets no cid check, no signature check and no reserved-field check, while still being present in the bytes, persisted to the db, and available for a client to render by walking comment.crosspost.comment.crosspost.comment.
The outer record's own cid stays consistent, since it hashes the whole record either way, so check 1 of tier 1 passes.
This is the sharp case because crosspost is the only field whose presence in the picked object triggers verification of a subtree signed by a different key. Every other field is one the attacker already controls.
Two ways such a record reaches a client:
Why the other fields are not exploits
Worth writing down, since it is the first place this investigation went wrong.
Shrinking signedPropertyNames on someone else's genuine record changes the encoded bytes, so their signature stops verifying. The attacker therefore always signs with a key they control, which means they can set every field value anyway. Leaving a field unsigned changes accountability, not capability.
content / title / link — the attacker authored them either way. The only delta is repudiation.
author — impersonation via author.name is not gated by the signature even when author is signed. It is gated by name resolution, which runs in the background and sets nameResolved (src/clients/base-client-manager.ts, src/publications/comment/comment.ts), outside verification entirely. Note author.address is runtime-only, derived as name || publicKey, and is in AuthorReservedFields, so it is never on the wire. See docs/protocol/wire-vs-runtime.md.
communityName / communityPublicKey — still just an author claim, resolved at tier 2 either way.
The general hazard is forward-looking: any field added later whose verification is gated on it being present will inherit the crosspost bypass unless this is fixed.
Mechanism
_verifyJsonSignature (src/signer/signatures.ts) rebuilds the signed object by reading signedPropertyNames off the record itself. JsonSignatureSchema types it as z.string().array() with no refinement pinning it to CommentSignedPropertyNames.
The check that closes this is _allFieldsOfRecordInSignedPropertyNames, and it only helps when it runs on the un-picked record:
- Community acceptance: covered.
respondWithErrorIfSignatureOfPublicationIsInvalid passes request.comment un-picked, so the guard fires (ERR_COMMENT_PUBSUB_RECORD_INCLUDES_FIELD_NOT_IN_SIGNED_PROPERTY_NAMES). An honest community never mints a cid for such a record.
- Client verification: not covered.
verifyCommentIpfs and _verifyCrosspost both pick(record, ["signature", ...signedPropertyNames]) before delegating, because a legitimate CommentIpfs carries community-generated fields (depth, thumbnailUrl*, previousCid, pseudonymityMode) that no author signs. After the pick the guard can never fail.
On the normal load path the cid's provenance substitutes for the guard, since the cid came from a community-signed page or CommentUpdate and the community ran the guard. For an embedded crosspost record there is no substitute, because tier 1 is offline by design.
Candidate fix
A targeted guard on both picking paths, over the signable set only:
for (const name of CommentSignedPropertyNames)
if (record[name] != null && !record.signature.signedPropertyNames.includes(name))
return { valid: false, reason: messages.ERR_... };
Targeted rather than a blanket allowlist: every read site uses CommentIpfsSchema.loose() and backward.compatibility.comment.test.ts pins that unknown extra props survive a load, so a future protocol version can add fields without old clients rejecting records. Restricting the rule to CommentSignedPropertyNames keeps that intact.
Safe for old records, since it only fires when a signable field is present and unsigned. A record predating a field does not carry it. Author-signed extra props already land in signedPropertyNames.
A UI warning is not a substitute for the crosspost case specifically: the library never recursed, so there is no signal for a client to surface without reimplementing _verifyCrosspost.
Open questions
- Fix just the
crosspost bypass in _verifyCrosspost, or the general subset problem on every picking path?
- Reject, or expose a signal? Rejecting is a client-visible behavior change.
- Apply to
Vote / CommentEdit / CommunityEdit too?
- Constrain
signedPropertyNames at the schema level instead, so a list that is not a subset of the canonical set fails parsing? Broader wire-format decision.
Refs
Verification picks the record down to
signature.signedPropertyNamesbefore checking anything. A field present in the bytes but absent from that list is dropped and never inspected, while remaining in what gets stored and rendered.signedPropertyNameslives insidesignature, which is not part of the signed bytes, so the list is chosen by whoever signs the record.So what gets verified is a strict subset of what clients display, and the record chooses the subset.
The concrete exploit today:
crosspostLeave
crosspostout ofsignedPropertyNameson a record that has one.pick()drops it, so_verifyCrosspostis never called for that level. The nested embedded record then gets no cid check, no signature check and no reserved-field check, while still being present in the bytes, persisted to the db, and available for a client to render by walkingcomment.crosspost.comment.crosspost.comment.The outer record's own cid stays consistent, since it hashes the whole record either way, so check 1 of tier 1 passes.
This is the sharp case because
crosspostis the only field whose presence in the picked object triggers verification of a subtree signed by a different key. Every other field is one the attacker already controls.Two ways such a record reaches a client:
crosspost.comment(feat(crosspost): embed the reposted comment in comment.crosspost (#32) #248), verified inline by_verifyCrosspostCommentUpdate, passed tocreateComment({ cid })/getComment({ cid })Why the other fields are not exploits
Worth writing down, since it is the first place this investigation went wrong.
Shrinking
signedPropertyNameson someone else's genuine record changes the encoded bytes, so their signature stops verifying. The attacker therefore always signs with a key they control, which means they can set every field value anyway. Leaving a field unsigned changes accountability, not capability.content/title/link— the attacker authored them either way. The only delta is repudiation.author— impersonation viaauthor.nameis not gated by the signature even whenauthoris signed. It is gated by name resolution, which runs in the background and setsnameResolved(src/clients/base-client-manager.ts,src/publications/comment/comment.ts), outside verification entirely. Noteauthor.addressis runtime-only, derived asname || publicKey, and is inAuthorReservedFields, so it is never on the wire. Seedocs/protocol/wire-vs-runtime.md.communityName/communityPublicKey— still just an author claim, resolved at tier 2 either way.The general hazard is forward-looking: any field added later whose verification is gated on it being present will inherit the
crosspostbypass unless this is fixed.Mechanism
_verifyJsonSignature(src/signer/signatures.ts) rebuilds the signed object by readingsignedPropertyNamesoff the record itself.JsonSignatureSchematypes it asz.string().array()with no refinement pinning it toCommentSignedPropertyNames.The check that closes this is
_allFieldsOfRecordInSignedPropertyNames, and it only helps when it runs on the un-picked record:respondWithErrorIfSignatureOfPublicationIsInvalidpassesrequest.commentun-picked, so the guard fires (ERR_COMMENT_PUBSUB_RECORD_INCLUDES_FIELD_NOT_IN_SIGNED_PROPERTY_NAMES). An honest community never mints a cid for such a record.verifyCommentIpfsand_verifyCrosspostbothpick(record, ["signature", ...signedPropertyNames])before delegating, because a legitimateCommentIpfscarries community-generated fields (depth,thumbnailUrl*,previousCid,pseudonymityMode) that no author signs. After the pick the guard can never fail.On the normal load path the cid's provenance substitutes for the guard, since the cid came from a community-signed page or
CommentUpdateand the community ran the guard. For an embedded crosspost record there is no substitute, because tier 1 is offline by design.Candidate fix
A targeted guard on both picking paths, over the signable set only:
Targeted rather than a blanket allowlist: every read site uses
CommentIpfsSchema.loose()andbackward.compatibility.comment.test.tspins that unknown extra props survive a load, so a future protocol version can add fields without old clients rejecting records. Restricting the rule toCommentSignedPropertyNameskeeps that intact.Safe for old records, since it only fires when a signable field is present and unsigned. A record predating a field does not carry it. Author-signed extra props already land in
signedPropertyNames.A UI warning is not a substitute for the
crosspostcase specifically: the library never recursed, so there is no signal for a client to surface without reimplementing_verifyCrosspost.Open questions
crosspostbypass in_verifyCrosspost, or the general subset problem on every picking path?Vote/CommentEdit/CommunityEdittoo?signedPropertyNamesat the schema level instead, so a list that is not a subset of the canonical set fails parsing? Broader wire-format decision.Refs
src/signer/signatures.tsthat started thissignedPropertyNamesonly covers top-level keys