Skip to content

feat: add xurl chat — an end-to-end encrypted XChat client#89

Merged
santiagomed merged 7 commits into
mainfrom
feat/xchat-client
Jul 21, 2026
Merged

feat: add xurl chat — an end-to-end encrypted XChat client#89
santiagomed merged 7 commits into
mainfrom
feat/xchat-client

Conversation

@santiagomed

Copy link
Copy Markdown
Collaborator

Summary

Adds a chat command family: a full XChat (encrypted DM) client where encryption, decryption, and signature verification happen locally via chat-xdk (go/chatxdk v0.4.1) — the server only ever sees ciphertext.

xurl chat keys status|restore|import   # bring existing registered keys to this machine
xurl chat conversations                # inbox, with decrypted group names
xurl chat read  <conv|@user>           # decrypted history (day separators, per-sender colors)
xurl chat send  <conv|@user> "text"    # encrypt + send
xurl chat listen <conv|@user>          # live poll loop

Design decisions

  • Zero key registration. xurl never generates keys, never calls the public-key registration endpoint, and never writes to Juicebox. Keys must already exist on the account (registered by another XChat client) and are fetched via Juicebox PIN recovery (read-only) or an exported blob. Unregistered keys are rejected; the registered public_key_version is adopted by matching the local identity key with the SDK's MatchesRegisteredKey.
  • Key discovery. Conversation keys come from the events endpoint's out-of-band meta.conversation_key_events. Key changes signed by members who have since left the group (whose keys the API no longer serves) fall back to ECIES-only extraction — safe because only keys encrypted to this identity can decrypt, and message authorship is still verified per message.
  • Pagination. Events pagination tokens walk backward through history, so listen never follows them: each poll re-fetches the newest page and dedups via a seen set seeded from the backlog.
  • No silent key rotation. A fresh 1:1 gets a conversation key automatically; sending into an existing conversation with no readable key requires explicit TTY confirmation (rotation is visible to the other participant).
  • Acting-user safety. Every chat command prints the acting account; a missing-keys session distinguishes "wrong acting user" (keys exist here for another account) from "new machine" (inline PIN recovery on a TTY, plain error in scripts).
  • Platforms. The crypto binding is cgo (macOS amd64/arm64, Linux amd64). Chat code is build-tagged; other platforms and the CGO_ENABLED=0 goreleaser binaries get a stub that explains how to build with chat enabled.

Storage change

~/.xurl becomes a directory: tokens in auth.yml, private chat keys in keys.yml (0600, write-then-rename; a corrupt key file refuses overwrite instead of qualifying for restore). A legacy single-file ~/.xurl migrates automatically — rename-based, non-destructive, with crash recovery for an interrupted migration — and the pre-v1.0 JSON conversion and .twurlrc import still work on top of the new layout. Note: older binaries cannot read the new layout once a machine migrates.

Testing

  • Unit tests across api (httptest-backed route executors), cli (conversation resolution incl. usernames starting with g, key-encoding matching against real generated keypairs, request-shape mapping), and store (key-store round-trip/permissions/corruption, migration incl. interrupted-migration recovery) — full suite green, plus gofmt/go vet and a GOOS=windows CGO_ENABLED=0 stub build.
  • Live-tested end to end against api.x.com: Juicebox key restore, inbox with decrypted group names, 1:1 and group reads, group send + read-back, live listen, and multi-account switching.
  • A full self-review pass (1 bug, 5 suggestions, 5 nits) was run and all findings are fixed in this diff.

Docs

README ("Encrypted Chat" section), SKILL.md (agent guidance incl. secret-safety rules for ~/.xurl/ and PIN handling), CHANGELOG (Added + Changed entries).

Adds a chat command family built on the chat-xdk crypto library
(github.com/xdevplatform/chat-xdk/go/chatxdk): encryption, decryption,
and signature verification happen locally, so the server only ever sees
ciphertext.

Commands:

- chat keys status|restore|import — key management. xurl never generates
  or registers encryption keys: the account must already have keys from
  another XChat client, brought to this machine via Juicebox PIN
  recovery (read-only; xurl never writes to Juicebox) or an exported
  key blob. Restore/import reject keys that are not registered on the
  account, and adopt the registered key version by matching the local
  identity key with chat-xdk's MatchesRegisteredKey.
- chat conversations — inbox list with decrypted group names (encrypted
  names render as a lock marker when no key is available).
- chat read / chat listen — decrypted history and a live poll loop.
  Conversation keys come from the events endpoint's out-of-band
  meta.conversation_key_events; key changes whose signers have left the
  group fall back to ECIES-only extraction (message authorship is still
  verified per message). Poll loops never follow pagination tokens,
  which walk backward through history; they re-fetch the newest page
  and dedup via a seen set.
- chat send — sends with the SDK's verified key cache, falling back to
  extracted keys; a fresh 1:1 gets a conversation key automatically,
  and rotating an existing conversation's key always requires explicit
  confirmation.

Every chat command prints the acting account, and a missing-keys
session distinguishes 'wrong acting user' from 'new machine', offering
the PIN recovery inline on a TTY.

The crypto binding is cgo (darwin amd64/arm64, linux amd64), so chat
code is build-tagged and other platforms get a stub; goreleaser
binaries stay CGO_ENABLED=0 and ship the stub.

Storage: ~/.xurl becomes a directory — tokens in auth.yml, private
chat keys in keys.yml (0600, write-then-rename, corrupt files refuse
overwrite). A legacy single-file ~/.xurl migrates automatically with
crash recovery; the pre-v1.0 JSON conversion and .twurlrc import are
retained on top of the new layout.
Two fixes from read-only exercise of the chat commands:

- GetOAuth2Header with an explicitly named user that has no stored
  token now errors with re-auth instructions instead of silently
  launching the interactive browser flow. The old behavior minted a
  real token under whatever label was passed — typos included — and
  invalidated the account's previous grant as a side effect.
- chat read now honors --verbose for non-message events (key changes,
  read receipts, typing); the printer was hardcoded to non-verbose.
- New 'xurl chat rotate CONVERSATION|@USERNAME': generates a fresh
  conversation key and wraps it to every current participant's newest
  registered keys (group rosters come from conversation metadata, 1:1
  rosters from the canonical id). Rotation is confirmed interactively
  and refused non-interactively without --yes, since other
  participants' clients see the key change. It protects future
  messages only, and also grants forward access to members whose keys
  were registered after the last rotation.
- Sending to someone new and rotating now share one primitive
  (establishConversationKey): an empty conversation id derives a fresh
  1:1, a set id rotates in place. Both adopt the canonical
  conversation id returned by the keys endpoint instead of
  reconstructing it client-side.
Round out the chat client with the remaining documented routes and SDK
capabilities:

- Media: 'chat send --file' encrypts a local file under the conversation
  key (EncryptStream), uploads it via the three-step media routes, and
  attaches the media_hash_key; 'chat download CONV HASH -o out' fetches
  and decrypts an attachment, trying each held key version. Inbound
  attachments now render the media_hash_key so it can be downloaded.
- Replies: 'chat send --reply-to SEQ' builds a threaded reply
  (EncryptReply) from the referenced event; replies render with a ↩.
- Group membership: 'chat add-members GROUP @user...' rotates the
  conversation key to the new roster (PrepareGroupMembersChange) so
  added members read messages going forward. Confirmed interactively,
  refused non-interactively without --yes.
- Read receipts: 'chat mark-read', plus a --mark-read flag on send.
- Typing: 'chat typing'.

Media/text share one key resolver (resolveSendKey) so an attachment is
always encrypted under the same key as its message. Send now accepts
text, --file, or both.
Reading or replying to a conversation implies you have seen it, so:

- 'chat read' and 'chat listen' now mark the conversation read
  automatically — up to the newest event, which the backend treats as a
  watermark that also marks every earlier message read. 'listen'
  advances the watermark as new messages arrive.
- 'chat send' marks the conversation read after sending and sends a
  typing indicator before, mirroring how a person composes.

All are best-effort writes (a failure warns, never aborts the read or
send) and opt-out via --no-mark-read / --no-typing for lurking or
scripting. The standalone 'mark-read' and 'typing' commands remain for
explicit/scripted use.
When a specific OAuth2 user is requested with -u/--username and that
user's token cannot be produced (e.g. an expired access token whose
refresh fails), getAuthHeader fell through to OAuth1 and then the
app-only bearer token. The request then went out as the wrong principal
and the server rejected it with a confusing 'OAuth 2.0 Application-Only
is forbidden' 403 instead of the real cause.

Now, when a username was explicitly requested, a failure to obtain that
user's OAuth2 header is returned to the caller (surfacing e.g. 'refresh
token invalid — re-authenticate'). The unspecified-user fallback is
unchanged, so raw passthrough behavior is preserved.
- SKILL.md: restore the '### Media Upload' heading that was clobbered
  when the chat section was inserted, and rewrite the chat walkthrough as
  a numbered flow covering every action — read, send (+ --file,
  --reply-to, --no-mark-read/--no-typing), download, listen, mark-read,
  typing, rotate, add-members — so an agent can see how each is invoked.
- README.md: condense the chat section (72 to 46 lines) to a compact
  command list plus the key-policy/storage/platform notes, leaving
  SKILL.md as the detailed reference.
@santiagomed
santiagomed merged commit 9ed649f into main Jul 21, 2026
2 checks passed
@santiagomed
santiagomed deleted the feat/xchat-client branch July 21, 2026 05:23
@santiagomed
santiagomed restored the feat/xchat-client branch July 21, 2026 05:23
@santiagomed
santiagomed deleted the feat/xchat-client branch July 21, 2026 05:23
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