Skip to content

Commit 9ed649f

Browse files
authored
feat: add xurl chat — an end-to-end encrypted XChat client (#89)
* feat: add xurl chat — an end-to-end encrypted XChat client 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. * fix: don't auto-login unknown explicit usernames; honor -v in chat read 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. * feat(chat): add key rotation and adopt server-confirmed conversation ids - 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. * feat(chat): media, replies, add-members, mark-read, typing 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. * feat(chat): mark read and typing automatically 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. * fix(auth): don't silently downgrade an explicit -u user to app-only 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. * docs: complete and tidy chat command coverage - 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.
1 parent 49ea3b8 commit 9ed649f

21 files changed

Lines changed: 3481 additions & 67 deletions

.goreleaser.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ before:
1616
- go generate ./...
1717

1818
builds:
19+
# Release binaries are built with CGO disabled so a single Linux runner can
20+
# cross-compile every platform. The `xurl chat` XChat client requires cgo
21+
# (the chat-xdk crypto binding links prebuilt static libraries), so release
22+
# binaries ship a stub for it; chat needs a source build with CGO_ENABLED=1
23+
# on macOS (amd64/arm64) or Linux (amd64). See README "Encrypted chat".
1924
- env:
2025
- CGO_ENABLED=0
2126
goos:

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ All user-visible bugs and enhancements should be recorded here.
44

55
## Unreleased
66

7+
### Added
8+
9+
- `xurl chat` — an end-to-end encrypted XChat client: `keys status|restore|import`, `rotate`, `download`, `add-members`, `mark-read`, `typing`, (keys must already be registered by another XChat client; xurl never generates or registers keys), `conversations`, `read`, `send`, and `listen`. Encryption and decryption happen locally via the chat-xdk library; private keys live in `~/.xurl/keys.yml` (mode 600). Requires a cgo build on macOS (amd64/arm64) or Linux (amd64) — prebuilt release binaries ship a stub explaining how to build with chat enabled.
10+
11+
### Changed
12+
13+
- `~/.xurl` is now a directory: tokens and app credentials live in `~/.xurl/auth.yml`, and XChat private keys live in `~/.xurl/keys.yml`. An existing single-file `~/.xurl` migrates automatically (rename-based and non-destructive) on first use, and the existing legacy migrations still apply on top of the new layout: pre-v1.0 JSON-format token files are converted to YAML, and `.twurlrc` import is unchanged. Older xurl binaries cannot read the new layout.
14+
715
## v1.2.3 - 2026-07-16
816

917
### Added

README.md

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,10 @@ A command-line tool for interacting with the X (formerly Twitter) API, supportin
99
- OAuth 1.0a authentication
1010
- Multiple OAuth 2.0 account support per app
1111
- Default app and default user selection (interactive Bubble Tea picker or single command)
12-
- Persistent token storage in YAML (`~/.xurl`), auto-migrates from legacy JSON
12+
- Persistent token storage in YAML (`~/.xurl/auth.yml`), auto-migrates from the legacy single-file layout
1313
- HTTP request customization (headers, methods, body)
1414
- Per-request app override with `--app`
15+
- End-to-end encrypted XChat client (`xurl chat`) built on the official chat-xdk crypto library
1516

1617
## Installation
1718

@@ -45,13 +46,13 @@ You must have a developer account and app to use this tool.
4546

4647
#### Register an app
4748

48-
Register your X API app credentials so they're stored in `~/.xurl` (no env vars needed after this):
49+
Register your X API app credentials so they're stored in `~/.xurl/auth.yml` (no env vars needed after this):
4950

5051
```bash
5152
xurl auth apps add my-app --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET
5253
```
5354

54-
If you want the app to keep its own callback configuration in `~/.xurl`, you can store the redirect URI there too:
55+
If you want the app to keep its own callback configuration in `~/.xurl/auth.yml`, you can store the redirect URI there too:
5556

5657
```bash
5758
xurl auth apps add my-app --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET --redirect-uri http://localhost:8080/callback
@@ -65,7 +66,7 @@ xurl auth apps add dev-app --client-id DEV_ID --client-secret DEV_SECRET
6566

6667
> **Legacy / env-var flow:** You can also set `CLIENT_ID` and `CLIENT_SECRET` as environment variables. They'll be auto-saved into the active app on first use.
6768
>
68-
> `REDIRECT_URI` now resolves in this order: `REDIRECT_URI` environment variable, then the app's stored `redirect_uri` in `~/.xurl`, then the built-in default `http://localhost:8080/callback`.
69+
> `REDIRECT_URI` now resolves in this order: `REDIRECT_URI` environment variable, then the app's stored `redirect_uri` in `~/.xurl/auth.yml`, then the built-in default `http://localhost:8080/callback`.
6970
7071
#### OAuth 2.0 User-Context
7172
**Note:** For OAuth 2.0 authentication, you must specify the redirect URI in the [X API developer portal](https://developer.x.com/en/portal/dashboard).
@@ -383,9 +384,54 @@ xurl -X POST /2/media/upload/MEDIA_ID/finalize
383384
xurl '/2/media/upload?command=STATUS&media_id=MEDIA_ID'
384385
```
385386
387+
### Encrypted Chat (`xurl chat`)
388+
389+
`xurl chat` is a full end-to-end encrypted [XChat](https://docs.x.com/) client. Encryption and
390+
decryption happen locally using the official [chat-xdk](https://github.com/xdevplatform/chat-xdk)
391+
crypto library; the server only ever sees ciphertext.
392+
393+
**Keys must already exist.** xurl never generates or registers encryption keys — the
394+
account needs XChat keys from another client (e.g. the X app). Bring them to this
395+
machine once (needs an OAuth2 login with the `dm.read` + `dm.write` scopes):
396+
```bash
397+
xurl chat keys restore # recover from Juicebox with your PIN, or
398+
xurl chat keys import # paste a private-key blob exported elsewhere
399+
xurl chat keys status # local + registered key state and fingerprint
400+
```
401+
402+
Conversations are addressed by `@username`, user id, or conversation id (`123-456`, `g123`):
403+
```bash
404+
xurl chat conversations # list your inbox
405+
xurl chat read @bob [-n 50] [--json] # decrypted history (auto marks read)
406+
xurl chat listen @bob # live tail (Ctrl-C to stop)
407+
xurl chat send @bob "hello" # send (new 1:1 keys itself)
408+
xurl chat send @bob "look" --file photo.png # attach an encrypted file
409+
xurl chat send @bob "ok" --reply-to SEQUENCE_ID # threaded reply (id from read --json)
410+
xurl chat download @bob MEDIA_HASH_KEY -o out.png # download + decrypt an attachment
411+
xurl chat rotate @bob # rotate the conversation key
412+
xurl chat add-members g123 @carol # add a group member (rotates the key)
413+
xurl chat mark-read @bob # (also automatic on read/listen/send)
414+
xurl chat typing @bob # (also automatic before send)
415+
```
416+
417+
`read`, `listen`, and `send` mark the conversation read automatically, and `send` sends a
418+
typing indicator first; suppress with `--no-mark-read` / `--no-typing`. `rotate` and
419+
`add-members` change the key for every participant and protect future messages only —
420+
old history stays readable only to holders of the earlier key versions.
421+
422+
Notes:
423+
424+
- Keys not already registered on the account are rejected on restore/import; xurl never
425+
writes to Juicebox or the key-registration endpoint.
426+
- Private keys live in `~/.xurl/keys.yml` (mode 600). Losing it is safe as long as the
427+
Juicebox backup (made by the original client) still exists.
428+
- `chat` requires a cgo build on macOS (Intel/Apple Silicon) or Linux (amd64). Prebuilt
429+
release binaries ship a stub; build from source to enable it:
430+
`CGO_ENABLED=1 go install github.com/xdevplatform/xurl@latest`.
431+
386432
## Token Storage
387433
388-
Tokens and app credentials are stored in `~/.xurl` in YAML format. Each registered app has its own isolated set of tokens. Example:
434+
`~/.xurl` is a directory: tokens and app credentials live in `~/.xurl/auth.yml`, and XChat private keys live in `~/.xurl/keys.yml`. Each registered app has its own isolated set of tokens. Example `auth.yml`:
389435
390436
```yaml
391437
apps:
@@ -407,7 +453,7 @@ apps:
407453
default_app: my-app
408454
```
409455
410-
> **Migration:** If you have an existing JSON-format `~/.xurl` file from a previous version, it will be automatically migrated to the new YAML multi-app format on first use. Your tokens are preserved in a `default` app.
456+
> **Migration:** A single-file `~/.xurl` from a previous version migrates automatically to `~/.xurl/auth.yml` on first use (pre-v1.0 JSON-format files are also converted to the YAML multi-app format, preserving tokens in a `default` app).
411457
412458
## Contributing
413459
Contributions are welcome!

0 commit comments

Comments
 (0)