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

1 participant