Skip to content

Sign read receipts: add prepare_mark_conversation_read - #49

Open
santiagomed wants to merge 6 commits into
cursor/message-key-send-21f1from
cursor/signed-read-receipts-1119
Open

santiagomed wants to merge 6 commits into
cursor/message-key-send-21f1from
cursor/signed-read-receipts-1119

Conversation

@santiagomed

@santiagomed santiagomed commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The SDK verifies read receipts (a MarkConversationReadEvent decrypts to Event::ReadReceipt and, under the default policy, is rejected when unverified) but had no way to produce one. Callers that mark a conversation read had no means to sign the receipt, so recipients receive it unsigned.

Stacked on #47.

prepare_mark_conversation_read(MarkConversationReadParams)

A read receipt is a plaintext signed event, like a message delete. This builds the thrift MarkConversationReadEvent (seen_until_sequence_id + seen_at_millis), signs

MarkConversationReadEvent,{msg_id},{sender_id},{conv_id},{seen_until_sequence_id},{seen_at_msec}

and returns an ActionSignature carrying the encoded event detail. That is the payload the existing MarkConversationReadEvent arm of the verifier reconstructs, so the receipt round-trips through decrypt_event as a verified ReadReceipt.

  • seen_at_msec is optional and defaults to the current time. The WASM wrapper fills an omitted seenAtMsec from Date.now(), so the core never reads the system clock on wasm32-unknown-unknown.
  • sender_id / signing_key_version resolve from the session identity (set_identity) when unset, as for every other producer, so the receipt carries the registered signing key version.
  • One-to-one conversation ids are signed in canonical colon form.
  • An empty conversation_id or seen_until_sequence_id fails with an error naming the field.

Binding parity

Per AGENTS.md §2, every surface moves together:

  • Rust core: ChatCore::prepare_mark_conversation_read / Chat::prepare_mark_conversation_read, MarkConversationReadParams
  • JS/WASM: prepareMarkConversationRead + MarkConversationReadParams in index.d.ts
  • Python: prepare_mark_conversation_read(MarkConversationReadParams), with MarkConversationReadParams(conversation_id, seen_until_sequence_id, *, seen_at_msec=None, sender_id=None, signing_key_version=None) as a params class like the other Python params calls, + .pyi stub
  • Go: PrepareMarkConversationRead, SeenAtMsec *int64 with omitempty
  • .NET: PrepareMarkConversationRead, long? SeenAtMsec
  • JVM: prepareMarkConversationRead, Long seenAtMsec omitted when null

The Go header (go/chatxdk/include/chat_xdk.h) and .NET NativeMethods.g.cs are regenerated. docs/API.md gains row 32 (later rows renumbered) and a "Read receipts are signed, not encrypted" paragraph; docs/CRYPTO.md records the signed payload; CHANGELOG.md gains an Added entry.

Testing

  • Core: exact payload pin (canonical colon-form id from a hyphen-form input), action-signature round trip through the verifier, a full MessageEvent → decrypt_event round trip asserting a verified ReadReceipt with the read point and time, the current-time default, and empty-field rejection.
  • Every binding suite has matching tests: the payload pin plus a signature check through the binding's own verify, the session-identity and current-time defaults, and the invalid-input error.
  • On the stack, locally green: fmt, clippy -D warnings, cargo-deny, workspace tests, WASM build, Python, JS (Node 20 and 18), JVM, .NET, Go, and the example suites.

Notes

  • Additive, with no wire-format change: the payload is the form the verifier already accepts, at the current signature version. Independent of Drop version and generate_version from the key registration payload #48, which is stacked on this branch.
  • The SDK only produces the signature; the mark-read request has to carry the returned ActionSignature for recipients to see a signed receipt.
  • Go prebuilt archives (go/chatxdk/libs/) are not regenerated here; the prebuilt sync workflow rebuilds them on push.
Open in Web Open in Cursor 

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you all sign our Contributor License Agreement before we can accept your contribution.
1 out of 2 committers have signed the CLA.

✅ santiagomed
❌ github-actions[bot]
You have signed the CLA already but the status is still pending? Let us recheck it.

The SDK verified MarkConversationReadEvent signatures but had no way to
produce one, so callers could not sign a read receipt.

prepare_mark_conversation_read builds the encoded MarkConversationReadEvent
detail (the read point and its time) and signs the payload the verifier
already reconstructs, returning an ActionSignature. The read time defaults to
the current time; the sender and signing key version resolve from the
session identity, and a one-to-one id is signed in canonical colon form.
Python, JS/WASM, Go, .NET and JVM gain the method plus a
MarkConversationReadParams type that follows each binding's optional-field
style. The WASM wrapper takes an omitted seenAtMsec from the JavaScript clock,
since wasm32 has no system clock. The Go header and the .NET P/Invoke layer
are regenerated, and every binding suite covers the signed payload, the
session-identity and current-time defaults, and the empty-field error.
Add prepare_mark_conversation_read to the per-binding API tables, describe
read receipts next to message deletes, and record the signed
MarkConversationReadEvent payload in CRYPTO.md.
@santiagomed
santiagomed force-pushed the cursor/signed-read-receipts-1119 branch from 1b01613 to 59c197a Compare October 16, 2026 23:53
@cursor
cursor Bot changed the base branch from main to cursor/message-key-send-21f1 October 16, 2026 23:54
@santiagomed
santiagomed added this pull request to stack #42 October 17, 2026 01:31
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.

2 participants