diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5d72791..b350435 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -16,6 +16,11 @@ before: - go generate ./... builds: + # Release binaries are built with CGO disabled so a single Linux runner can + # cross-compile every platform. The `xurl chat` XChat client requires cgo + # (the chat-xdk crypto binding links prebuilt static libraries), so release + # binaries ship a stub for it; chat needs a source build with CGO_ENABLED=1 + # on macOS (amd64/arm64) or Linux (amd64). See README "Encrypted chat". - env: - CGO_ENABLED=0 goos: diff --git a/CHANGELOG.md b/CHANGELOG.md index 563d062..33174bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All user-visible bugs and enhancements should be recorded here. ## Unreleased +### Added + +- `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. + +### Changed + +- `~/.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. + ## v1.2.3 - 2026-07-16 ### Added diff --git a/README.md b/README.md index 7a62366..87a10bd 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,10 @@ A command-line tool for interacting with the X (formerly Twitter) API, supportin - OAuth 1.0a authentication - Multiple OAuth 2.0 account support per app - Default app and default user selection (interactive Bubble Tea picker or single command) -- Persistent token storage in YAML (`~/.xurl`), auto-migrates from legacy JSON +- Persistent token storage in YAML (`~/.xurl/auth.yml`), auto-migrates from the legacy single-file layout - HTTP request customization (headers, methods, body) - Per-request app override with `--app` +- End-to-end encrypted XChat client (`xurl chat`) built on the official chat-xdk crypto library ## Installation @@ -45,13 +46,13 @@ You must have a developer account and app to use this tool. #### Register an app -Register your X API app credentials so they're stored in `~/.xurl` (no env vars needed after this): +Register your X API app credentials so they're stored in `~/.xurl/auth.yml` (no env vars needed after this): ```bash xurl auth apps add my-app --client-id YOUR_CLIENT_ID --client-secret YOUR_CLIENT_SECRET ``` -If you want the app to keep its own callback configuration in `~/.xurl`, you can store the redirect URI there too: +If you want the app to keep its own callback configuration in `~/.xurl/auth.yml`, you can store the redirect URI there too: ```bash 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 > **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. > -> `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`. +> `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`. #### OAuth 2.0 User-Context **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 xurl '/2/media/upload?command=STATUS&media_id=MEDIA_ID' ``` +### Encrypted Chat (`xurl chat`) + +`xurl chat` is a full end-to-end encrypted [XChat](https://docs.x.com/) client. Encryption and +decryption happen locally using the official [chat-xdk](https://github.com/xdevplatform/chat-xdk) +crypto library; the server only ever sees ciphertext. + +**Keys must already exist.** xurl never generates or registers encryption keys — the +account needs XChat keys from another client (e.g. the X app). Bring them to this +machine once (needs an OAuth2 login with the `dm.read` + `dm.write` scopes): +```bash +xurl chat keys restore # recover from Juicebox with your PIN, or +xurl chat keys import # paste a private-key blob exported elsewhere +xurl chat keys status # local + registered key state and fingerprint +``` + +Conversations are addressed by `@username`, user id, or conversation id (`123-456`, `g123`): +```bash +xurl chat conversations # list your inbox +xurl chat read @bob [-n 50] [--json] # decrypted history (auto marks read) +xurl chat listen @bob # live tail (Ctrl-C to stop) +xurl chat send @bob "hello" # send (new 1:1 keys itself) +xurl chat send @bob "look" --file photo.png # attach an encrypted file +xurl chat send @bob "ok" --reply-to SEQUENCE_ID # threaded reply (id from read --json) +xurl chat download @bob MEDIA_HASH_KEY -o out.png # download + decrypt an attachment +xurl chat rotate @bob # rotate the conversation key +xurl chat add-members g123 @carol # add a group member (rotates the key) +xurl chat mark-read @bob # (also automatic on read/listen/send) +xurl chat typing @bob # (also automatic before send) +``` + +`read`, `listen`, and `send` mark the conversation read automatically, and `send` sends a +typing indicator first; suppress with `--no-mark-read` / `--no-typing`. `rotate` and +`add-members` change the key for every participant and protect future messages only — +old history stays readable only to holders of the earlier key versions. + +Notes: + +- Keys not already registered on the account are rejected on restore/import; xurl never + writes to Juicebox or the key-registration endpoint. +- Private keys live in `~/.xurl/keys.yml` (mode 600). Losing it is safe as long as the + Juicebox backup (made by the original client) still exists. +- `chat` requires a cgo build on macOS (Intel/Apple Silicon) or Linux (amd64). Prebuilt + release binaries ship a stub; build from source to enable it: + `CGO_ENABLED=1 go install github.com/xdevplatform/xurl@latest`. + ## Token Storage -Tokens and app credentials are stored in `~/.xurl` in YAML format. Each registered app has its own isolated set of tokens. Example: +`~/.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`: ```yaml apps: @@ -407,7 +453,7 @@ apps: default_app: my-app ``` -> **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. +> **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). ## Contributing Contributions are welcome! diff --git a/SKILL.md b/SKILL.md index 78b06e7..482ec86 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: xurl -description: A curl-like CLI tool for making authenticated requests to the X (Twitter) API. Use this skill when you need to post tweets, reply, quote, search, read posts, manage followers, send DMs, upload media, or interact with any X API v2 endpoint. Supports multiple apps, OAuth 2.0, OAuth 1.0a, and app-only auth. +description: A curl-like CLI tool for making authenticated requests to the X (Twitter) API. Use this skill when you need to post tweets, reply, quote, search, read posts, manage followers, send DMs, send or read end-to-end encrypted XChat messages, upload media, or interact with any X API v2 endpoint. Supports multiple apps, OAuth 2.0, OAuth 1.0a, and app-only auth. --- # xurl — Agent Skill Reference @@ -17,9 +17,11 @@ Before using any command you must be authenticated. Run `xurl auth status` to ch ### Secret Safety (Mandatory) -- Never read, print, parse, summarize, upload, or send `~/.xurl` (or copies of it) to the LLM context. +- Never read, print, parse, summarize, upload, or send anything under `~/.xurl/` (or copies of it) to the LLM context. +- `~/.xurl/keys.yml` contains XChat **private encryption keys** — the strictest no-read rule applies. +- Never pass `--pin` inline in agent/LLM sessions (`xurl chat keys restore --pin ...` leaks the recovery PIN to context and shell history). Run `xurl chat keys restore` without the flag so the PIN is prompted without echo, or have the user run it manually. - Never ask the user to paste credentials/tokens into chat. -- The user must fill `~/.xurl` with required secrets manually on their own machine. +- The user must fill `~/.xurl/auth.yml` with required secrets manually on their own machine. - Do not recommend or execute auth commands with inline secrets in agent/LLM sessions. - Warn that using CLI secret options in agent sessions can leak credentials (prompt/context, logs, shell history). - Never use `--verbose` / `-v` in agent/LLM sessions; it can expose sensitive headers/tokens in output. @@ -54,7 +56,7 @@ xurl auth apps redirect-uri set prod-app http://localhost:8080/callback Examples with inline secret flags are intentionally omitted. If OAuth1 or app-only auth is needed, the user must run those commands manually outside agent/LLM context. -Tokens are persisted to `~/.xurl` in YAML format. Each app has its own isolated tokens and may also store a `redirect_uri`. `REDIRECT_URI` in the environment still takes precedence over the stored app value. Do not read this file through the agent/LLM. Once authenticated, every command below will auto‑attach the right `Authorization` header. +Tokens are persisted to `~/.xurl/auth.yml` in YAML format (a legacy single-file `~/.xurl` is migrated automatically). Each app has its own isolated tokens and may also store a `redirect_uri`. `REDIRECT_URI` in the environment still takes precedence over the stored app value. Do not read this file (or anything under `~/.xurl/`) through the agent/LLM. Once authenticated, every command below will auto‑attach the right `Authorization` header. --- @@ -93,6 +95,21 @@ Tokens are persisted to `~/.xurl` in YAML format. Each app has its own isolated | List DMs | `xurl dms -n 10` | | Upload media | `xurl media upload path/to/file.mp4` | | Media status | `xurl media status MEDIA_ID` | +| **Encrypted Chat (XChat)** | | +| Chat key status | `xurl chat keys status` | +| Restore chat keys | `xurl chat keys restore` (PIN prompted; never pass `--pin` in agent sessions) | +| Import chat keys | `xurl chat keys import` (blob prompted; avoid passing it as an argument) | +| List chat inbox | `xurl chat conversations` | +| Read a conversation | `xurl chat read @handle -n 50` | +| Send encrypted message | `xurl chat send @handle "message"` | +| Listen for new messages | `xurl chat listen @handle` | +| Rotate a conversation key | `xurl chat rotate CONV --yes` (write op — see notes) | +| Send with an attachment | `xurl chat send CONV "text" --file path/to/img.png` | +| Reply to a message | `xurl chat send CONV "text" --reply-to SEQUENCE_ID` | +| Download an attachment | `xurl chat download CONV MEDIA_HASH_KEY -o out.png` | +| Add group members | `xurl chat add-members GROUP @user --yes` (write op) | +| Mark read (explicit) | `xurl chat mark-read CONV` | +| Typing indicator (explicit) | `xurl chat typing CONV` | | **App Management** | | | Register app | Manual, outside agent (do not pass secrets via agent) | | List apps | `xurl auth apps list` | @@ -235,6 +252,64 @@ xurl dms xurl dms -n 25 ``` +### Encrypted Chat (XChat) + +`xurl chat` is an end-to-end encrypted XChat client: encryption and decryption happen locally via the chat-xdk crypto library, so the server only sees ciphertext. Requires OAuth2 user auth with `dm.read` + `dm.write` scopes, and is available on macOS (Intel/Apple Silicon) and Linux amd64 when built with cgo (prebuilt release binaries ship a stub that says so). + +**Keys come from another XChat client** — xurl never generates or registers encryption keys. The account must already have keys (e.g. from the X app); bring them to this machine once with `restore` (Juicebox PIN recovery) or `import` (an exported key blob). Private keys are stored in `~/.xurl/keys.yml` (mode 600) — never read that file into LLM context. + +A conversation is addressed by `@username`, a bare user id, or a conversation id (1:1 ids look like `123-456`; group ids look like `g123`). Every command accepts `-u USERNAME` to act as a specific authenticated account. + +```bash +# 1. Keys — one-time setup (xurl never generates/registers keys) +xurl chat keys status # local key presence/fingerprint + registered versions +xurl chat keys restore # recover from Juicebox; prompts for the PIN (no echo) +xurl chat keys import # paste an exported private-key blob (no echo) + +# 2. Browse the inbox +xurl chat conversations # pretty list; decrypts group names when keys are present +xurl chat conversations --json # raw JSON + +# 3. Read history (oldest first; auto-marks the conversation read) +xurl chat read @someuser +xurl chat read g1234567890 -n 50 # -n = how many events to fetch (max 100) +xurl chat read @someuser --json # decrypted events as JSON (each has id, sequence_id, content) +xurl chat read @someuser --no-mark-read # read without sending a read receipt + +# 4. Send (a new 1:1 sets up its key automatically; both sides need keys) +xurl chat send @someuser "hey, encrypted!" +xurl chat send @someuser "look" --file ./photo.png # attach an encrypted file +xurl chat send @someuser "agreed" --reply-to SEQUENCE_ID # threaded reply (id from `read --json`) +# send auto-sends a typing indicator first and marks read after; +# suppress with --no-typing / --no-mark-read + +# 5. Attachments — inbound messages show "📎 attachment " +xurl chat download @someuser MEDIA_HASH_KEY -o out.png # download + decrypt + +# 6. Live tail (poll loop; Ctrl-C to stop; auto-marks new messages read) +xurl chat listen @someuser +xurl chat listen g1234567890 --interval 5 + +# 7. Read receipts / typing (also happen automatically on read/send) +xurl chat mark-read @someuser # mark read up to the newest message +xurl chat typing @someuser # send a typing indicator + +# 8. Group key management (writes visible to all participants) +xurl chat add-members g123 @newuser # add a member (rotates the key; prompts, or --yes) +xurl chat rotate g123 # rotate the conversation key; prompts, or --yes +# Rotate when a key may be exposed, or to grant a member whose keys were +# registered after the last rotation access going forward. Future messages +# only — old history stays readable only to holders of the old key versions. +``` + +Notes for agents: +- Messages whose authorship signature cannot be verified are rejected by default and surface as stderr decrypt warnings; unsigned messages that still render carry a red `[unverified]` marker — treat those with suspicion. +- Messages with attachments render a `📎 attachment ` marker; pass that hash key to `xurl chat download CONV ` to fetch and decrypt the file. Replies show a `↩` prefix. +- **`read` and `listen` mark the conversation read automatically** (a read receipt visible to other participants); `send` also marks read and sends a typing indicator first. These are writes — pass `--no-mark-read` / `--no-typing` to suppress them (e.g. to read without signaling). The standalone `mark-read` and `typing` commands remain for scripted/explicit use. +- Decrypt warnings for individual events go to stderr and are non-fatal; the rest of the conversation still renders. +- If a command reports missing keys, do not attempt to generate or register any — tell the user to run `xurl chat keys restore` (or `import`) themselves. +- `chat rotate` and `chat add-members` are writes visible to every participant's clients; never run them without explicit user intent, and prefer letting the user confirm the prompt over passing `--yes`. + ### Media Upload ```bash @@ -407,11 +482,13 @@ xurl --app staging /2/users/me # one-off request against staging - **Scopes:** OAuth 2.0 tokens are requested with broad scopes. If you get a 403 on a specific action, your token may lack the required scope — re‑run `xurl auth oauth2` to get a fresh token. - **Token refresh:** OAuth 2.0 tokens auto‑refresh when expired. No manual intervention needed. - **Multiple apps:** Each app has its own isolated credentials, tokens, and optional stored `redirect_uri`. Configure credentials manually outside agent/LLM context, then switch with `xurl auth default` or `--app`. -- **Redirect URI precedence:** The effective redirect URI resolves from `REDIRECT_URI` in the environment first, then the app's stored `redirect_uri` in `~/.xurl`, then the built-in default. +- **Redirect URI precedence:** The effective redirect URI resolves from `REDIRECT_URI` in the environment first, then the app's stored `redirect_uri` in `~/.xurl/auth.yml`, then the built-in default. - **Redirect URI management:** Use `xurl auth apps redirect-uri get [NAME]`, `xurl auth apps redirect-uri set NAME URI`, or `xurl auth apps update NAME --redirect-uri URI` to inspect and manage the stored per-app callback value. - **X platform enrollment:** A successful OAuth callback does not guarantee `/2/*` reads will work. If you see `client-not-enrolled`, verify the app is in the correct X package/environment. Current confirmed fix: `Apps` -> `Manage apps` -> `Move to package` -> choose `Pay-per-use`, then move the app to `Production`. - **Multiple accounts:** You can authenticate multiple OAuth 2.0 accounts per app and switch between them with `--username` / `-u` or set a default with `xurl auth default APP USER`. - **Default user:** When no `-u` flag is given, xurl uses the default user for the active app (set via `xurl auth default`). If no default user is set, it uses the first available token. -- **Token storage:** `~/.xurl` is YAML. Each app stores its own credentials and tokens. Never read or send this file to LLM context. +- **Token storage:** `~/.xurl` is a directory; `~/.xurl/auth.yml` holds each app's credentials and tokens. Never read or send anything under `~/.xurl/` to LLM context. +- **Chat key storage:** `~/.xurl/keys.yml` holds XChat **private encryption keys** per user (mode 600). Losing it means losing the ability to decrypt on this machine (recoverable via `xurl chat keys restore` if a Juicebox PIN backup exists). Never read or send this file to LLM context. +- **Chat key registration:** xurl performs none — no public-key registration and no Juicebox writes. Only keys already registered by another XChat client can be restored or imported; unregistered keys are rejected. - **Access tokens:** `xurl token` prints a valid (refreshed) OAuth2 access token for the active app to stdout, refreshing and persisting it if expired. It never opens a browser. The output is a secret — use it only in the user's own scripts, never in agent/LLM sessions. - **MCP bridge:** `xurl mcp [URL]` bridges a stdio MCP client to a remote Streamable HTTP MCP server (default `https://api.x.com/mcp`), injecting `Authorization: Bearer ` and refreshing the token automatically. On first run with no cached token it opens the browser for a one-time OAuth2 login using the `CLIENT_ID`/`CLIENT_SECRET` from its environment (the handshake waits for it, so set a generous `startup_timeout_sec`); on a headless host, authenticate out-of-band first with `xurl auth oauth2 --headless`. Configure it in an MCP client via the npm launcher: `{"command":"npx","args":["-y","@xdevplatform/xurl","mcp","https://api.x.com/mcp"],"env":{"CLIENT_ID":"...","CLIENT_SECRET":"..."},"startup_timeout_sec":300}`. diff --git a/api/chat.go b/api/chat.go new file mode 100644 index 0000000..5f882e2 --- /dev/null +++ b/api/chat.go @@ -0,0 +1,417 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// ------------------------------------------------ +// XChat (encrypted chat) endpoint executors. +// +// These are thin, crypto-free wrappers over the /2/chat and +// /2/users/:id/public_keys routes. All encryption and decryption +// happens in the cli layer via the chat-xdk binding; this file only +// moves opaque base64 payloads. +// ------------------------------------------------ + +// ChatConversationPathID converts a conversation id to the form the API URL +// paths expect (hyphen-separated), from the colon-separated form found +// inside decrypted events and signatures. +func ChatConversationPathID(id string) string { + return strings.ReplaceAll(id, ":", "-") +} + +// ChatConversationEventID converts a conversation id to the canonical +// colon-separated form embedded in events and signatures. +func ChatConversationEventID(id string) string { + return strings.ReplaceAll(id, "-", ":") +} + +// ChatEventItem is one element of the conversation events response. ID is +// the event's sequence id (the resource exposes sequence_id as id). +type ChatEventItem struct { + ID string `json:"id"` + ConversationID string `json:"conversation_id"` + SenderID string `json:"sender_id"` + EncodedEvent string `json:"encoded_event"` + IsTrusted bool `json:"is_trusted"` +} + +// ChatPublicKey is one registered public key row for a user. Every field of +// the public_key resource is always included by the API (no public_key.fields +// parameter exists for opting in); the version field is exposed as +// public_key_version. +type ChatPublicKey struct { + // UserID tags the key's owner; only the batch endpoint + // (GET /2/users/public_keys) sets it. + UserID string `json:"user_id,omitempty"` + Version string `json:"public_key_version"` + PublicKey string `json:"public_key"` + SigningPublicKey string `json:"signing_public_key"` + IdentityPublicKeySignature string `json:"identity_public_key_signature"` + JuiceboxConfig json.RawMessage `json:"juicebox_config,omitempty"` +} + +// CompareChatKeyVersions numerically compares two key version strings +// (non-padded positive integers, typically millisecond timestamps): -1 when +// a < b, 0 when equal, 1 when a > b. +func CompareChatKeyVersions(a, b string) int { + if len(a) != len(b) { + if len(a) < len(b) { + return -1 + } + return 1 + } + return strings.Compare(a, b) +} + +// GetChatPublicKeys fetches a user's registered public keys (including the +// juicebox_config, which the API always returns). +func GetChatPublicKeys(client Client, userID string, opts RequestOptions) ([]ChatPublicKey, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/users/%s/public_keys", url.PathEscape(userID)) + opts.Data = "" + + resp, err := client.SendRequest(opts) + if err != nil { + return nil, err + } + var out struct { + Data []ChatPublicKey `json:"data"` + } + if err := json.Unmarshal(resp, &out); err != nil { + return nil, fmt.Errorf("failed to parse public keys response: %w", err) + } + return out.Data, nil +} + +// GetChatUsersPublicKeys fetches registered public keys for the given users; +// each returned row carries its owner's user_id. The endpoint accepts at +// most 100 ids per request, so larger inputs are fetched in batches. +func GetChatUsersPublicKeys(client Client, userIDs []string, opts RequestOptions) ([]ChatPublicKey, error) { + if len(userIDs) == 0 { + return nil, nil + } + var keys []ChatPublicKey + for start := 0; start < len(userIDs); start += 100 { + batch := userIDs[start:min(start+100, len(userIDs))] + opts.Method = "GET" + opts.Endpoint = "/2/users/public_keys?ids=" + url.QueryEscape(strings.Join(batch, ",")) + opts.Data = "" + + resp, err := client.SendRequest(opts) + if err != nil { + return nil, err + } + var out struct { + Data []ChatPublicKey `json:"data"` + } + if err := json.Unmarshal(resp, &out); err != nil { + return nil, fmt.Errorf("failed to parse public keys response: %w", err) + } + keys = append(keys, out.Data...) + } + return keys, nil +} + +// GetChatConversations fetches a page of the authenticated user's chat inbox. +func GetChatConversations(client Client, maxResults int, paginationToken string, opts RequestOptions) (json.RawMessage, error) { + q := url.Values{} + q.Set("max_results", fmt.Sprintf("%d", clampResults(maxResults, 1, 100))) + if paginationToken != "" { + q.Set("pagination_token", paginationToken) + } + opts.Method = "GET" + opts.Endpoint = "/2/chat/conversations?" + q.Encode() + opts.Data = "" + + return client.SendRequest(opts) +} + +// ChatEventsPage is one page of a conversation's events. +type ChatEventsPage struct { + Events []ChatEventItem + // KeyEvents are base64-encoded key-change events that apply to messages + // in this page but are not part of it (meta.conversation_key_events). + // Feed them to the SDK's batch decrypt alongside the page's events so + // the conversation keys can be extracted. + KeyEvents []string + NextToken string +} + +// GetChatEvents fetches a page of raw (encrypted) events for a conversation. +func GetChatEvents(client Client, conversationID string, maxResults int, paginationToken string, opts RequestOptions) (*ChatEventsPage, error) { + q := url.Values{} + q.Set("max_results", fmt.Sprintf("%d", clampResults(maxResults, 1, 100))) + if paginationToken != "" { + q.Set("pagination_token", paginationToken) + } + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/chat/conversations/%s/events?%s", url.PathEscape(ChatConversationPathID(conversationID)), q.Encode()) + opts.Data = "" + + resp, err := client.SendRequest(opts) + if err != nil { + return nil, err + } + var out struct { + Data []ChatEventItem `json:"data"` + Meta struct { + NextToken string `json:"next_token"` + ConversationKeyEvents []string `json:"conversation_key_events"` + } `json:"meta"` + } + if err := json.Unmarshal(resp, &out); err != nil { + return nil, fmt.Errorf("failed to parse conversation events response: %w", err) + } + return &ChatEventsPage{ + Events: out.Data, + KeyEvents: out.Meta.ConversationKeyEvents, + NextToken: out.Meta.NextToken, + }, nil +} + +// ChatConversationMeta is a conversation's metadata. Group name and avatar +// URL arrive encrypted under the conversation key when the group set them +// from an encrypted client. +type ChatConversationMeta struct { + ParticipantIDs []string `json:"participant_ids"` + MemberIDs []string `json:"member_ids"` + AdminIDs []string `json:"admin_ids"` + GroupName string `json:"group_name"` + GroupAvatarURL string `json:"group_avatar_url"` + MessageTTLMs *int64 `json:"message_ttl_ms"` + ScreenCaptureBlockingEnabled *bool `json:"screen_capture_blocking_enabled"` +} + +// AllUserIDs returns the deduplicated union of participant, member, and +// admin ids. +func (m *ChatConversationMeta) AllUserIDs() []string { + seen := map[string]bool{} + var ids []string + for _, group := range [][]string{m.ParticipantIDs, m.MemberIDs, m.AdminIDs} { + for _, id := range group { + if id != "" && !seen[id] { + seen[id] = true + ids = append(ids, id) + } + } + } + return ids +} + +// GetChatConversation fetches one conversation's metadata. +func GetChatConversation(client Client, conversationID string, opts RequestOptions) (*ChatConversationMeta, json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/chat/conversations/%s", url.PathEscape(ChatConversationPathID(conversationID))) + opts.Data = "" + + resp, err := client.SendRequest(opts) + if err != nil { + return nil, nil, err + } + var out struct { + Data ChatConversationMeta `json:"data"` + } + if err := json.Unmarshal(resp, &out); err != nil { + return nil, nil, fmt.Errorf("failed to parse conversation metadata response: %w", err) + } + return &out.Data, resp, nil +} + +// AddChatConversationKeys posts a prepared conversation-key change +// (initialize or rotate). For a 1:1, conversationID may be the recipient's +// user id; the server derives the canonical conversation id. +func AddChatConversationKeys(client Client, conversationID string, body any, opts RequestOptions) (json.RawMessage, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal conversation keys body: %w", err) + } + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/chat/conversations/%s/keys", url.PathEscape(ChatConversationPathID(conversationID))) + opts.Data = string(data) + + return client.SendRequest(opts) +} + +// ChatSendBody is the request body for sending an encrypted chat message. +type ChatSendBody struct { + MessageID string `json:"message_id"` + EncodedMessageCreateEvent string `json:"encoded_message_create_event"` + EncodedMessageEventSignature string `json:"encoded_message_event_signature"` + ConversationToken string `json:"conversation_token,omitempty"` +} + +// SendChatMessage posts an encrypted message produced by chat-xdk. +func SendChatMessage(client Client, conversationID string, body ChatSendBody, opts RequestOptions) (json.RawMessage, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal chat message body: %w", err) + } + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/chat/conversations/%s/messages", url.PathEscape(ChatConversationPathID(conversationID))) + opts.Data = string(data) + + return client.SendRequest(opts) +} + +// MarkChatRead marks a conversation read up to the given sequence id. +func MarkChatRead(client Client, conversationID, seenUntilSequenceID string, opts RequestOptions) (json.RawMessage, error) { + data, err := json.Marshal(map[string]string{"seen_until_sequence_id": seenUntilSequenceID}) + if err != nil { + return nil, err + } + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/chat/conversations/%s/read", url.PathEscape(ChatConversationPathID(conversationID))) + opts.Data = string(data) + + return client.SendRequest(opts) +} + +// SendChatTyping sends a typing indicator to a conversation. +func SendChatTyping(client Client, conversationID string, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/chat/conversations/%s/typing", url.PathEscape(ChatConversationPathID(conversationID))) + opts.Data = "" + + return client.SendRequest(opts) +} + +// AddChatGroupMembers adds members to a group conversation. body carries the +// new user_ids plus the rotated key change built from a prepared group +// members change. +func AddChatGroupMembers(client Client, conversationID string, body any, opts RequestOptions) (json.RawMessage, error) { + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal group members body: %w", err) + } + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/chat/conversations/%s/members", url.PathEscape(ChatConversationPathID(conversationID))) + opts.Data = string(data) + + return client.SendRequest(opts) +} + +// ------------------------------------------------ +// Encrypted media (opaque ciphertext blobs) +// ------------------------------------------------ + +// chatMediaUploadChunk is the append segment size for the three-step upload. +const chatMediaUploadChunk = 3 * 1024 * 1024 + +// InitializeChatMediaUpload starts an encrypted media upload session. +// totalBytes is the size of the ciphertext, not the plaintext. The media +// endpoints take the colon form of the conversation id in bodies. +func InitializeChatMediaUpload(client Client, conversationID string, totalBytes int, opts RequestOptions) (sessionID, mediaHashKey string, err error) { + data, err := json.Marshal(map[string]any{ + "conversation_id": ChatConversationEventID(conversationID), + "total_bytes": totalBytes, + }) + if err != nil { + return "", "", err + } + opts.Method = "POST" + opts.Endpoint = "/2/chat/media/upload/initialize" + opts.Data = string(data) + + resp, err := client.SendRequest(opts) + if err != nil { + return "", "", err + } + var out struct { + Data struct { + SessionID string `json:"session_id"` + MediaHashKey string `json:"media_hash_key"` + } `json:"data"` + } + if err := json.Unmarshal(resp, &out); err != nil { + return "", "", fmt.Errorf("failed to parse media initialize response: %w", err) + } + if out.Data.SessionID == "" || out.Data.MediaHashKey == "" { + return "", "", fmt.Errorf("media upload initialize returned no session (response: %s)", resp) + } + return out.Data.SessionID, out.Data.MediaHashKey, nil +} + +// UploadChatMedia appends the ciphertext in 3 MB base64 segments and +// finalizes the session. +func UploadChatMedia(client Client, sessionID, conversationID, mediaHashKey string, ciphertext []byte, opts RequestOptions) error { + conv := ChatConversationEventID(conversationID) + segment := 0 + for offset := 0; offset < len(ciphertext); offset += chatMediaUploadChunk { + end := min(offset+chatMediaUploadChunk, len(ciphertext)) + data, err := json.Marshal(map[string]any{ + "conversation_id": conv, + "media_hash_key": mediaHashKey, + "segment_index": fmt.Sprintf("%d", segment), + "media": base64.StdEncoding.EncodeToString(ciphertext[offset:end]), + }) + if err != nil { + return err + } + o := opts + o.Method = "POST" + o.Endpoint = fmt.Sprintf("/2/chat/media/upload/%s/append", url.PathEscape(sessionID)) + o.Data = string(data) + if _, err := client.SendRequest(o); err != nil { + return fmt.Errorf("media append (segment %d) failed: %w", segment, err) + } + segment++ + } + + data, err := json.Marshal(map[string]any{ + "conversation_id": conv, + "media_hash_key": mediaHashKey, + "num_parts": fmt.Sprintf("%d", segment), + }) + if err != nil { + return err + } + opts.Method = "POST" + opts.Endpoint = fmt.Sprintf("/2/chat/media/upload/%s/finalize", url.PathEscape(sessionID)) + opts.Data = string(data) + if _, err := client.SendRequest(opts); err != nil { + return fmt.Errorf("media finalize failed: %w", err) + } + return nil +} + +// DownloadChatMedia fetches an encrypted media blob as raw ciphertext bytes. +// The response is binary, so it bypasses the JSON response path. +func DownloadChatMedia(client Client, conversationID, mediaHashKey string, opts RequestOptions) ([]byte, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/chat/media/%s/%s", url.PathEscape(ChatConversationPathID(conversationID)), url.PathEscape(mediaHashKey)) + opts.Data = "" + + req, err := client.BuildRequest(opts) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("media download failed (%d): %s", resp.StatusCode, body) + } + return body, nil +} + +// GetUserByID fetches a user object by id (used to render sender usernames). +func GetUserByID(client Client, userID string, opts RequestOptions) (json.RawMessage, error) { + opts.Method = "GET" + opts.Endpoint = fmt.Sprintf("/2/users/%s", url.PathEscape(userID)) + opts.Data = "" + + return client.SendRequest(opts) +} diff --git a/api/chat_test.go b/api/chat_test.go new file mode 100644 index 0000000..82266df --- /dev/null +++ b/api/chat_test.go @@ -0,0 +1,270 @@ +package api + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xdevplatform/xurl/config" +) + +// --------------------------------------------------------------- +// Pure-function unit tests +// --------------------------------------------------------------- + +func TestCompareChatKeyVersions(t *testing.T) { + assert.Equal(t, 0, CompareChatKeyVersions("1700", "1700")) + assert.Equal(t, -1, CompareChatKeyVersions("999", "1700")) + assert.Equal(t, 1, CompareChatKeyVersions("1700", "999")) + assert.Equal(t, 1, CompareChatKeyVersions("1701", "1700")) +} + +func TestChatConversationIDForms(t *testing.T) { + assert.Equal(t, "1-2", ChatConversationPathID("1:2")) + assert.Equal(t, "1-2", ChatConversationPathID("1-2")) + assert.Equal(t, "g123", ChatConversationPathID("g123")) + assert.Equal(t, "1:2", ChatConversationEventID("1-2")) + assert.Equal(t, "1:2", ChatConversationEventID("1:2")) +} + +// --------------------------------------------------------------- +// Integration tests using httptest +// --------------------------------------------------------------- + +func setupChatServer(t *testing.T, requests *[]*http.Request, bodies *[]string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + *requests = append(*requests, r) + *bodies = append(*bodies, string(body)) + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasSuffix(r.URL.Path, "/public_keys") && r.Method == "GET": + w.Write([]byte(`{"data":[{"public_key_version":"1700","public_key":"idpk","signing_public_key":"sigpk","identity_public_key_signature":"binding","juicebox_config":{"realms":[]}}]}`)) + case strings.HasSuffix(r.URL.Path, "/events"): + w.Write([]byte(`{"data":[{"id":"s1","conversation_id":"1:2","sender_id":"7","encoded_event":"AAAA","is_trusted":true}],"meta":{"next_token":"tok2","conversation_key_events":["KEYEV"]}}`)) + case strings.HasSuffix(r.URL.Path, "/keys"): + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"data":{"conversation_id":"1-2"}}`)) + case strings.HasSuffix(r.URL.Path, "/messages"): + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"data":{"encoded_message_event":"BBBB"}}`)) + case r.URL.Path == "/2/chat/conversations": + w.Write([]byte(`{"data":[{"id":"1:2","type":"direct"}],"meta":{"result_count":1}}`)) + case strings.HasSuffix(r.URL.Path, "/read"): + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"data":{"success":true}}`)) + case strings.HasSuffix(r.URL.Path, "/typing"): + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"data":{"success":true}}`)) + case strings.HasSuffix(r.URL.Path, "/members"): + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"data":{"conversation_key_change_sequence_id":"99"}}`)) + case r.URL.Path == "/2/chat/media/upload/initialize": + w.Write([]byte(`{"data":{"session_id":"sess1","media_hash_key":"mhk1","conversation_id":"1:2"}}`)) + case strings.HasSuffix(r.URL.Path, "/append"): + w.Write([]byte(`{"data":{"expires_at":123}}`)) + case strings.HasSuffix(r.URL.Path, "/finalize"): + w.Write([]byte(`{"data":{"success":true}}`)) + case strings.HasPrefix(r.URL.Path, "/2/chat/media/"): + w.Header().Set("Content-Type", "application/octet-stream") + w.Write([]byte("RAWCIPHERTEXT")) + default: + w.Write([]byte(`{"data":{}}`)) + } + })) +} + +func chatTestClient(t *testing.T, server *httptest.Server) *ApiClient { + t.Helper() + authMock, tempDir := createMockAuth(t) + t.Cleanup(func() { os.RemoveAll(tempDir) }) + cfg := &config.Config{APIBaseURL: server.URL} + return NewApiClient(cfg, authMock) +} + +func TestGetChatPublicKeys(t *testing.T) { + var requests []*http.Request + var bodies []string + server := setupChatServer(t, &requests, &bodies) + defer server.Close() + client := chatTestClient(t, server) + + keys, err := GetChatPublicKeys(client, "7", RequestOptions{}) + require.NoError(t, err) + require.Len(t, keys, 1) + assert.Equal(t, "1700", keys[0].Version) + assert.Equal(t, "idpk", keys[0].PublicKey) + assert.Equal(t, "sigpk", keys[0].SigningPublicKey) + assert.NotEmpty(t, keys[0].JuiceboxConfig) + + require.Len(t, requests, 1) + assert.Equal(t, "/2/users/7/public_keys", requests[0].URL.Path) + // Every public_key field is always included; the route takes no + // public_key.fields parameter. + assert.Empty(t, requests[0].URL.RawQuery) +} + +func TestGetChatEvents(t *testing.T) { + var requests []*http.Request + var bodies []string + server := setupChatServer(t, &requests, &bodies) + defer server.Close() + client := chatTestClient(t, server) + + // Colon-form input converts to the hyphen form in the URL path. + page, err := GetChatEvents(client, "1:2", 50, "tok1", RequestOptions{}) + require.NoError(t, err) + require.Len(t, page.Events, 1) + assert.Equal(t, "AAAA", page.Events[0].EncodedEvent) + assert.Equal(t, "7", page.Events[0].SenderID) + assert.Equal(t, "tok2", page.NextToken) + assert.Equal(t, []string{"KEYEV"}, page.KeyEvents) + + require.Len(t, requests, 1) + assert.Equal(t, "/2/chat/conversations/1-2/events", requests[0].URL.Path) + assert.Equal(t, "50", requests[0].URL.Query().Get("max_results")) + assert.Equal(t, "tok1", requests[0].URL.Query().Get("pagination_token")) +} + +func TestAddChatConversationKeys(t *testing.T) { + var requests []*http.Request + var bodies []string + server := setupChatServer(t, &requests, &bodies) + defer server.Close() + client := chatTestClient(t, server) + + body := map[string]any{"conversation_key_version": "v1"} + _, err := AddChatConversationKeys(client, "1:2", body, RequestOptions{}) + require.NoError(t, err) + + require.Len(t, requests, 1) + assert.Equal(t, "POST", requests[0].Method) + assert.Equal(t, "/2/chat/conversations/1-2/keys", requests[0].URL.Path) + assert.Contains(t, bodies[0], "conversation_key_version") +} + +func TestSendChatMessage(t *testing.T) { + var requests []*http.Request + var bodies []string + server := setupChatServer(t, &requests, &bodies) + defer server.Close() + client := chatTestClient(t, server) + + _, err := SendChatMessage(client, "1:2", ChatSendBody{ + MessageID: "m1", + EncodedMessageCreateEvent: "enc", + EncodedMessageEventSignature: "sig", + }, RequestOptions{}) + require.NoError(t, err) + + require.Len(t, requests, 1) + assert.Equal(t, "/2/chat/conversations/1-2/messages", requests[0].URL.Path) + + var sent map[string]any + require.NoError(t, json.Unmarshal([]byte(bodies[0]), &sent)) + assert.Equal(t, "m1", sent["message_id"]) + assert.Equal(t, "enc", sent["encoded_message_create_event"]) + assert.Equal(t, "sig", sent["encoded_message_event_signature"]) + // Empty optional token is omitted entirely. + _, hasToken := sent["conversation_token"] + assert.False(t, hasToken) +} + +func TestMarkChatReadAndTyping(t *testing.T) { + var requests []*http.Request + var bodies []string + server := setupChatServer(t, &requests, &bodies) + defer server.Close() + client := chatTestClient(t, server) + + _, err := MarkChatRead(client, "1:2", "77", RequestOptions{}) + require.NoError(t, err) + assert.Equal(t, "/2/chat/conversations/1-2/read", requests[0].URL.Path) + assert.Contains(t, bodies[0], `"seen_until_sequence_id":"77"`) + + _, err = SendChatTyping(client, "1:2", RequestOptions{}) + require.NoError(t, err) + assert.Equal(t, "/2/chat/conversations/1-2/typing", requests[1].URL.Path) +} + +func TestChatMediaRoundTrip(t *testing.T) { + var requests []*http.Request + var bodies []string + server := setupChatServer(t, &requests, &bodies) + defer server.Close() + client := chatTestClient(t, server) + + sessionID, mhk, err := InitializeChatMediaUpload(client, "1:2", 10, RequestOptions{}) + require.NoError(t, err) + assert.Equal(t, "sess1", sessionID) + assert.Equal(t, "mhk1", mhk) + assert.Equal(t, "/2/chat/media/upload/initialize", requests[0].URL.Path) + + err = UploadChatMedia(client, sessionID, "1:2", mhk, []byte("ciphertextbytes"), RequestOptions{}) + require.NoError(t, err) + // One append (small payload) + one finalize. + assert.Equal(t, "/2/chat/media/upload/sess1/append", requests[1].URL.Path) + assert.Equal(t, "/2/chat/media/upload/sess1/finalize", requests[2].URL.Path) + + data, err := DownloadChatMedia(client, "1:2", mhk, RequestOptions{}) + require.NoError(t, err) + assert.Equal(t, "RAWCIPHERTEXT", string(data)) + assert.Equal(t, "/2/chat/media/1-2/mhk1", requests[3].URL.Path) +} + +func TestAddChatGroupMembers(t *testing.T) { + var requests []*http.Request + var bodies []string + server := setupChatServer(t, &requests, &bodies) + defer server.Close() + client := chatTestClient(t, server) + + body := map[string]any{"user_ids": []string{"7"}, "conversation_key_version": "v2"} + _, err := AddChatGroupMembers(client, "g123", body, RequestOptions{}) + require.NoError(t, err) + assert.Equal(t, "/2/chat/conversations/g123/members", requests[0].URL.Path) + assert.Contains(t, bodies[0], `"user_ids"`) +} + +func TestGetChatConversationMeta(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"data":{"participant_ids":["1","2"],"member_ids":["1","2","3"],"admin_ids":["1"],"group_name":"enc"}}`)) + })) + defer server.Close() + client := chatTestClient(t, server) + + meta, _, err := GetChatConversation(client, "g123", RequestOptions{}) + require.NoError(t, err) + assert.Equal(t, "enc", meta.GroupName) + assert.ElementsMatch(t, []string{"1", "2", "3"}, meta.AllUserIDs()) +} + +func TestGetChatConversations(t *testing.T) { + var requests []*http.Request + var bodies []string + server := setupChatServer(t, &requests, &bodies) + defer server.Close() + client := chatTestClient(t, server) + + resp, err := GetChatConversations(client, 20, "", RequestOptions{}) + require.NoError(t, err) + assert.Contains(t, string(resp), `"1:2"`) + + require.Len(t, requests, 1) + assert.Equal(t, "/2/chat/conversations", requests[0].URL.Path) + assert.Equal(t, "20", requests[0].URL.Query().Get("max_results")) + + // max_results clamps into the API's 1-100 window. + _, err = GetChatConversations(client, 1000, "", RequestOptions{}) + require.NoError(t, err) + assert.Equal(t, "100", requests[1].URL.Query().Get("max_results")) +} diff --git a/api/client.go b/api/client.go index 307ded2..fd34ee4 100644 --- a/api/client.go +++ b/api/client.go @@ -367,6 +367,13 @@ func (c *ApiClient) getAuthHeader(method, url string, authType string, username if err == nil { return accessToken, nil } + // When a specific user was requested (-u/--username), do not silently + // downgrade to OAuth1 or app-only auth: that hides the failure and + // would act as a different principal than asked. Surface the error so + // the caller learns to re-authenticate that account. + if username != "" { + return "", err + } } // If no OAuth2 token is available, try to use the first OAuth1 token diff --git a/api/client_test.go b/api/client_test.go index c1db3a9..b8bd67a 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -428,3 +428,31 @@ func TestStreamRequest(t *testing.T) { assert.True(t, xurlErrors.IsAPIError(err), "Expected API error") }) } + +// TestExplicitUsernameDoesNotDowngradeToAppOnly ensures that when a specific +// OAuth2 user is requested but that user's token cannot be produced (e.g. a +// failed refresh), the client surfaces the auth error instead of silently +// falling back to the app-only bearer token and acting as the wrong principal. +func TestExplicitUsernameDoesNotDowngradeToAppOnly(t *testing.T) { + cfg := &config.Config{ + ClientID: "cid", + ClientSecret: "secret", + TokenURL: "https://api.x.com/2/oauth2/token", + APIBaseURL: "https://api.x.com", + } + mockAuth := auth.NewAuth(cfg) + ts, tempDir := createTempTokenStore(t) + defer os.RemoveAll(tempDir) + + // An app-only bearer exists (the tempting downgrade target) plus an + // OAuth2 token for a user whose access token is already expired with a + // bogus refresh token, so any refresh attempt fails. + require.NoError(t, ts.SaveBearerToken("app-only-bearer")) + require.NoError(t, ts.SaveOAuth2Token("alice", "expired-access", "bad-refresh", 1)) + mockAuth.WithTokenStore(ts) + + client := NewApiClient(cfg, mockAuth) + _, err := client.getAuthHeader("GET", "https://api.x.com/2/users/me", "", "alice") + require.Error(t, err, "explicit user with a failed token must not downgrade to app-only") + assert.False(t, xurlErrors.IsAPIError(err) && err.Error() == "", "should surface the refresh error") +} diff --git a/auth/auth.go b/auth/auth.go index d589219..32a6eaa 100644 --- a/auth/auth.go +++ b/auth/auth.go @@ -175,6 +175,14 @@ func (a *Auth) GetOAuth2Header(username string) (string, error) { if username != "" { token = a.TokenStore.GetOAuth2TokenForApp(a.appName, username) + // An explicitly named user with no stored token is an error, not a + // login trigger: silently running the browser flow here would mint + // a real token under whatever label was passed (typos included) and + // invalidate the account's previous grant. + if token == nil { + return "", xurlErrors.NewAuthError("TokenNotFound", + fmt.Errorf("no OAuth2 token stored for %q — run 'xurl auth oauth2 %s' to authenticate that account", username, username)) + } } else { token = a.TokenStore.GetFirstOAuth2TokenForApp(a.appName) } diff --git a/cli/chat.go b/cli/chat.go new file mode 100644 index 0000000..d92f7d3 --- /dev/null +++ b/cli/chat.go @@ -0,0 +1,1940 @@ +//go:build cgo && ((darwin && (amd64 || arm64)) || (linux && amd64)) + +package cli + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/xdevplatform/chat-xdk/go/chatxdk" + "github.com/xdevplatform/xurl/api" + "github.com/xdevplatform/xurl/auth" + "github.com/xdevplatform/xurl/store" + "github.com/xdevplatform/xurl/utils" +) + +// chatSupported reports whether this build includes the XChat client. +const chatSupported = true + +// CreateChatCommand creates the `chat` command family: an end-to-end +// encrypted XChat client backed by the chat-xdk crypto binding. +func CreateChatCommand(a *auth.Auth) *cobra.Command { + chatCmd := &cobra.Command{ + Use: "chat", + Short: "Send and read end-to-end encrypted XChat messages", + Long: `An end-to-end encrypted XChat client. + +Quick start: + xurl chat keys restore Fetch your existing keys from Juicebox (one-time) + xurl chat send @bob "hello" Send an encrypted message + xurl chat read @bob Read decrypted conversation history + xurl chat conversations List your chat inbox + xurl chat listen @bob Print new messages as they arrive + +xurl never generates or registers encryption keys: the account must already +have XChat keys, registered by another XChat client. Bring them to this +machine with 'keys restore' (Juicebox PIN recovery) or 'keys import' (an +exported private-key blob). + +Conversations can be addressed by @username, user id, or conversation id +(e.g. 123-456 or g123). Requires OAuth2 user authentication with the +dm.read and dm.write scopes (run 'xurl auth oauth2' first).`, + } + + chatCmd.AddCommand( + createChatKeysCommand(a), + chatConversationsCmd(a), + chatReadCmd(a), + chatSendCmd(a), + chatListenCmd(a), + chatRotateCmd(a), + chatDownloadCmd(a), + chatMembersCmd(a), + chatMarkReadCmd(a), + chatTypingCmd(a), + ) + return chatCmd +} + +func chatDownloadCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "download CONVERSATION|@USERNAME MEDIA_HASH_KEY", + Short: "Download and decrypt a chat media attachment", + Long: `Downloads an encrypted media attachment and decrypts it to a local +file. Find the media hash key with 'chat read --json' (attachments carry +a media_hash_key). The attachment is decrypted with the conversation key +version that was active when the message was sent.`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + out, _ := cmd.Flags().GetString("output") + s, err := newChatSession(a, cmd, true) + exitOnError(err) + defer s.Close() + convID, err := s.resolveConversation(args[0]) + exitOnError(err) + exitOnError(s.downloadMedia(convID, args[1], out)) + }, + } + cmd.Flags().StringP("output", "o", "", "Output file path (default: the media hash key)") + addCommonFlags(cmd) + return cmd +} + +func chatMembersCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "add-members GROUP @USER [@USER...]", + Short: "Add members to a group conversation", + Long: `Adds one or more members to an existing group, rotating the +conversation key so the new members can read messages from now on. Only a +current member can add members. New members do not gain access to messages +sent before they were added.`, + Args: cobra.MinimumNArgs(2), + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + s, err := newChatSession(a, cmd, true) + exitOnError(err) + defer s.Close() + convID, err := s.resolveConversation(args[0]) + exitOnError(err) + if !strings.HasPrefix(convID, "g") { + exitOnError(fmt.Errorf("add-members only applies to group conversations (got %s)", convID)) + } + exitOnError(s.addGroupMembers(convID, args[1:], yes)) + }, + } + cmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt") + addCommonFlags(cmd) + return cmd +} + +func chatMarkReadCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "mark-read CONVERSATION|@USERNAME", + Short: "Mark a conversation read up to its latest message", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + s, err := newChatSession(a, cmd, false) + exitOnError(err) + defer s.Close() + convID, err := s.resolveConversation(args[0]) + exitOnError(err) + exitOnError(s.markReadCommand(convID)) + }, + } + addCommonFlags(cmd) + return cmd +} + +func chatTypingCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "typing CONVERSATION|@USERNAME", + Short: "Send a typing indicator to a conversation", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + s, err := newChatSession(a, cmd, false) + exitOnError(err) + defer s.Close() + convID, err := s.resolveConversation(args[0]) + exitOnError(err) + _, err = api.SendChatTyping(s.client, convID, s.opts) + exitOnError(err) + color.Green("✓ typing indicator sent") + }, + } + addCommonFlags(cmd) + return cmd +} + +func chatRotateCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "rotate CONVERSATION|@USERNAME", + Short: "Rotate a conversation's encryption key", + Long: `Generates a fresh conversation key and distributes it to every current +participant's newest registered keys. + +Rotate when a conversation key may have been exposed, or to grant a member +access going forward when their keys were registered after the last rotation +(they gain access to new messages only). Rotation protects future messages: +anyone holding an earlier key version can still read the messages encrypted +under it, and members without the old versions still cannot read old history.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + yes, _ := cmd.Flags().GetBool("yes") + s, err := newChatSession(a, cmd, true) + exitOnError(err) + defer s.Close() + convID, err := s.resolveConversation(args[0]) + exitOnError(err) + exitOnError(s.rotateConversationKey(convID, yes)) + }, + } + cmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt") + addCommonFlags(cmd) + return cmd +} + +// ----------------------------------------------------------------- +// chat keys +// ----------------------------------------------------------------- + +func createChatKeysCommand(a *auth.Auth) *cobra.Command { + keysCmd := &cobra.Command{ + Use: "keys", + Short: "Manage XChat encryption keys", + Long: `Manage the XChat private keys stored on this machine. + +xurl never generates or registers keys — it only fetches keys that already +exist for the account (registered by another XChat client).`, + } + keysCmd.AddCommand(chatKeysStatusCmd(a), chatKeysRestoreCmd(a), chatKeysImportCmd(a)) + return keysCmd +} + +func chatKeysStatusCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "status", + Short: "Show local and registered XChat key status", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + s, err := newChatSession(a, cmd, false) + exitOnError(err) + defer s.Close() + exitOnError(s.keyStatus()) + }, + } + addCommonFlags(cmd) + return cmd +} + +func chatKeysRestoreCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "restore", + Short: "Recover XChat keys from Juicebox onto this machine", + Long: `Recovers the account's private keys from the Juicebox +PIN-protected recovery service and saves them locally (mode 600). The keys +must have been stored in Juicebox by another XChat client; this is a +read-only recovery — xurl never writes to Juicebox.`, + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + pin, _ := cmd.Flags().GetString("pin") + s, err := newChatSession(a, cmd, false) + exitOnError(err) + defer s.Close() + exitOnError(s.restoreKeys(pin)) + }, + } + cmd.Flags().String("pin", "", "Recovery PIN (prompted interactively if omitted)") + addCommonFlags(cmd) + return cmd +} + +func chatKeysImportCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "import [PRIVATE_KEYS_B64]", + Short: "Import an exported XChat private-key blob", + Long: `Imports a base64 private-key blob exported by another XChat client +and saves it locally (mode 600). The blob is prompted for (without echo) +when not passed as an argument, so it stays out of shell history. + +The key version is detected by matching the imported identity against the +public keys registered on the account; a blob whose keys are not registered +is rejected.`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + blob := "" + if len(args) == 1 { + blob = args[0] + } + s, err := newChatSession(a, cmd, false) + exitOnError(err) + defer s.Close() + exitOnError(s.importKeys(blob)) + }, + } + addCommonFlags(cmd) + return cmd +} + +// ----------------------------------------------------------------- +// chat conversations / read / send / listen +// ----------------------------------------------------------------- + +func chatConversationsCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "conversations", + Short: "List your XChat inbox", + Args: cobra.NoArgs, + Run: func(cmd *cobra.Command, args []string) { + maxResults, _ := cmd.Flags().GetInt("max-results") + asJSON, _ := cmd.Flags().GetBool("json") + s, err := newChatSession(a, cmd, false) + exitOnError(err) + defer s.Close() + exitOnError(s.listConversations(maxResults, asJSON)) + }, + } + cmd.Flags().IntP("max-results", "n", 20, "Maximum number of conversations to list (1-100)") + cmd.Flags().Bool("json", false, "Output the raw JSON response") + addCommonFlags(cmd) + return cmd +} + +func chatReadCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "read CONVERSATION|@USERNAME", + Short: "Read decrypted messages from a conversation", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + maxResults, _ := cmd.Flags().GetInt("max-results") + asJSON, _ := cmd.Flags().GetBool("json") + noMarkRead, _ := cmd.Flags().GetBool("no-mark-read") + s, err := newChatSession(a, cmd, true) + exitOnError(err) + defer s.Close() + convID, err := s.resolveConversation(args[0]) + exitOnError(err) + exitOnError(s.readConversation(convID, maxResults, asJSON, !noMarkRead)) + }, + } + cmd.Flags().IntP("max-results", "n", 50, "Maximum number of events to fetch (1-100)") + cmd.Flags().Bool("json", false, "Output decrypted events as JSON") + cmd.Flags().Bool("no-mark-read", false, "Do not mark the conversation read") + addCommonFlags(cmd) + return cmd +} + +func chatSendCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "send CONVERSATION|@USERNAME \"TEXT\"", + Short: "Send an encrypted message", + Long: `Encrypts and sends a message. On the first message of a 1:1 +conversation, a conversation key is generated and distributed to both +participants automatically.`, + Args: cobra.RangeArgs(1, 2), + Run: func(cmd *cobra.Command, args []string) { + file, _ := cmd.Flags().GetString("file") + replyTo, _ := cmd.Flags().GetString("reply-to") + noMarkRead, _ := cmd.Flags().GetBool("no-mark-read") + noTyping, _ := cmd.Flags().GetBool("no-typing") + text := "" + if len(args) == 2 { + text = args[1] + } + if text == "" && file == "" { + exitOnError(fmt.Errorf("provide message text, --file, or both")) + } + s, err := newChatSession(a, cmd, true) + exitOnError(err) + defer s.Close() + convID, err := s.resolveConversation(args[0]) + exitOnError(err) + exitOnError(s.sendMessage(convID, text, sendOptions{filePath: file, replyToID: replyTo, markRead: !noMarkRead, typing: !noTyping})) + }, + } + cmd.Flags().StringP("file", "F", "", "Attach an encrypted media file") + cmd.Flags().String("reply-to", "", "Sequence id of the event to reply to") + cmd.Flags().Bool("no-mark-read", false, "Do not mark the conversation read after sending") + cmd.Flags().Bool("no-typing", false, "Do not send a typing indicator before sending") + addCommonFlags(cmd) + return cmd +} + +func chatListenCmd(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "listen CONVERSATION|@USERNAME", + Short: "Print new messages as they arrive (Ctrl-C to stop)", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + interval, _ := cmd.Flags().GetInt("interval") + noMarkRead, _ := cmd.Flags().GetBool("no-mark-read") + s, err := newChatSession(a, cmd, true) + exitOnError(err) + defer s.Close() + convID, err := s.resolveConversation(args[0]) + exitOnError(err) + exitOnError(s.listen(convID, time.Duration(interval)*time.Second, !noMarkRead)) + }, + } + cmd.Flags().Int("interval", 3, "Polling interval in seconds") + cmd.Flags().Bool("no-mark-read", false, "Do not mark new messages read as they arrive") + addCommonFlags(cmd) + return cmd +} + +// ----------------------------------------------------------------- +// chatSession: one authenticated user + one unlocked chat-xdk instance +// ----------------------------------------------------------------- + +type chatSession struct { + chat *chatxdk.Chat + client api.Client + opts api.RequestOptions + store *store.ChatKeyStore + userID string + + // signingKeys accumulates every seen sender's keys; the merged set is + // stored in the SDK so decrypt calls can pass nil. + signingKeys []chatxdk.SigningKeyEntry + seenSenders map[string]bool + loadedConvs map[string]bool + usernames map[string]string + // convKeys holds conversation keys (by version) recovered via ECIES-only + // extraction when the verified key-adoption path fails; see + // adoptKeyEvents. + convKeys map[string][]byte + // lastPrintedDay tracks day-separator state across printed events. + lastPrintedDay string +} + +// newChatSession resolves the authenticated user and creates a chat-xdk +// instance. When requireKeys is true the local private keys are imported and +// the session identity is set, erroring if no keys exist yet. +func newChatSession(a *auth.Auth, cmd *cobra.Command, requireKeys bool) (*chatSession, error) { + client := newClient(a) + opts := baseOpts(cmd) + + userID, err := resolveMyUserID(client, opts) + if err != nil { + return nil, err + } + + keyStore := store.NewChatKeyStore() + if err := keyStore.LoadErr(); err != nil { + return nil, fmt.Errorf("the chat key store %s exists but could not be loaded — fix or remove it before continuing: %w", keyStore.FilePath(), err) + } + + s := &chatSession{ + chat: chatxdk.New(), + client: client, + opts: opts, + store: keyStore, + userID: userID, + seenSenders: map[string]bool{}, + loadedConvs: map[string]bool{}, + usernames: map[string]string{}, + convKeys: map[string][]byte{}, + } + s.chat.SetCacheKeys(true) + + // Chat identity is account-bound: always show who this session acts as, + // so a stale default user is visible immediately instead of silently + // reading another account's conversations. stderr keeps --json clean. + color.New(color.Faint).Fprintf(os.Stderr, "acting as @%s (user %s) — switch with -u or 'xurl auth default'\n", s.username(s.userID), s.userID) + + if requireKeys { + if err := s.unlock(); err != nil { + keys := s.store.GetKeys(s.userID) + hasNoKeys := keys == nil || keys.PrivateKeysB64 == "" + if !hasNoKeys { + // Keys exist but are unusable (e.g. corrupt) — never + // auto-restore over them. + s.Close() + return nil, err + } + + // The acting account has no keys on this machine. Distinguish + // "wrong acting user" (keys exist here for another account) + // from "new machine" so the user picks the right fix. + if others := s.otherKeyHolders(); len(others) > 0 { + fmt.Fprintf(os.Stderr, "This machine has XChat keys for %s, but you are acting as @%s.\n", strings.Join(others, ", "), s.username(s.userID)) + fmt.Fprintln(os.Stderr, "To act as that account instead, re-run with -u USERNAME or set it with 'xurl auth default'.") + } + + // Otherwise (or additionally), offer the Juicebox PIN recovery + // for the acting account right here; scripts get the error. + if !term.IsTerminal(int(os.Stdin.Fd())) { + s.Close() + return nil, err + } + fmt.Fprintf(os.Stderr, "No XChat keys on this machine for @%s. Enter the recovery PIN to fetch them from the account's backup (Ctrl-C to abort).\n", s.username(s.userID)) + if rerr := s.restoreKeys(""); rerr != nil { + s.Close() + return nil, fmt.Errorf("%w\n(no local keys for this account: run 'xurl chat keys restore' or 'xurl chat keys import')", rerr) + } + if uerr := s.unlock(); uerr != nil { + s.Close() + return nil, uerr + } + } + } + return s, nil +} + +// otherKeyHolders returns "@handle (user id)" labels for accounts other than +// the acting user that have keys in the local store. +func (s *chatSession) otherKeyHolders() []string { + var out []string + for uid, keys := range s.store.Users { + if uid == s.userID || keys == nil || keys.PrivateKeysB64 == "" { + continue + } + out = append(out, fmt.Sprintf("@%s (user %s)", s.username(uid), uid)) + } + sort.Strings(out) + return out +} + +func (s *chatSession) Close() { + if s.chat != nil { + s.chat.Close() + } +} + +// unlock imports the locally stored private keys and sets the session identity. +func (s *chatSession) unlock() error { + keys := s.store.GetKeys(s.userID) + if keys == nil || keys.PrivateKeysB64 == "" { + return fmt.Errorf("no XChat keys found for user %s — bring your existing keys to this machine with 'xurl chat keys restore' (Juicebox PIN) or 'xurl chat keys import' (exported key blob)", s.userID) + } + raw, err := base64.StdEncoding.DecodeString(keys.PrivateKeysB64) + if err != nil { + return fmt.Errorf("stored chat keys are corrupt (%s): %w", s.store.FilePath(), err) + } + version := keys.KeyVersion + if version == "" { + version = "1" + } + if err := s.chat.ImportKeysWithVersion(raw, version); err != nil { + return fmt.Errorf("failed to import chat keys: %w", err) + } + if err := s.chat.SetIdentity(s.userID, version); err != nil { + return fmt.Errorf("failed to set chat identity: %w", err) + } + return nil +} + +// ----------------------------------------------------------------- +// Key management flows +// ----------------------------------------------------------------- + +func (s *chatSession) keyStatus() error { + keys := s.store.GetKeys(s.userID) + label := color.New(color.Faint) + label.Print("user: ") + fmt.Printf("@%s (%s)\n", s.username(s.userID), s.userID) + + if keys == nil || keys.PrivateKeysB64 == "" { + label.Print("local keys: ") + fmt.Println("none — run 'xurl chat keys restore' or 'xurl chat keys import'") + } else { + label.Print("local keys: ") + fmt.Printf("present — version %s (%s)\n", keys.KeyVersion, s.store.FilePath()) + if err := s.unlock(); err == nil { + if fp, err := s.chat.GetPublicKeyFingerprint(); err == nil && fp != "" { + label.Print("fingerprint: ") + fmt.Println(fp) + } + } + } + + serverKeys, err := api.GetChatPublicKeys(s.client, s.userID, s.opts) + if err != nil { + return fmt.Errorf("could not fetch registered keys: %w", err) + } + if len(serverKeys) == 0 { + color.New(color.Faint).Print("registered: ") + fmt.Println("none — register keys with another XChat client first (xurl never registers keys)") + return nil + } + color.New(color.Faint).Print("registered: ") + fmt.Printf("%d key(s) on account\n", len(serverKeys)) + for _, k := range serverKeys { + marker := "" + // Errors just mean no local keys are loaded — no marker to show. + if ok, err := s.chat.MatchesRegisteredKey(k.PublicKey); err == nil && ok { + marker = color.GreenString(" ← this machine") + } + fmt.Printf(" version %s%s\n", k.Version, marker) + } + return nil +} + +func (s *chatSession) restoreKeys(pin string) error { + if existing := s.store.GetKeys(s.userID); existing != nil && existing.PrivateKeysB64 != "" { + return fmt.Errorf("local keys already exist for user %s in %s — remove them first if you really want to restore over them", s.userID, s.store.FilePath()) + } + + serverKeys, err := api.GetChatPublicKeys(s.client, s.userID, s.opts) + if err != nil { + return err + } + // Use the config of the newest key version carrying one. + var juiceboxCfg string + var juiceboxVersion string + for _, k := range serverKeys { + if len(k.JuiceboxConfig) == 0 || string(k.JuiceboxConfig) == "null" { + continue + } + if juiceboxCfg == "" || api.CompareChatKeyVersions(k.Version, juiceboxVersion) > 0 { + juiceboxCfg = string(k.JuiceboxConfig) + juiceboxVersion = k.Version + } + } + if juiceboxCfg == "" { + return fmt.Errorf("no Juicebox backup found on this account — keys were never backed up with a PIN") + } + + if pin == "" { + p, err := promptSecret("Recovery PIN: ") + if err != nil { + return err + } + pin = p + } + if pin == "" { + return fmt.Errorf("a recovery PIN is required") + } + + fmt.Println("Recovering keys from Juicebox...") + if err := s.chat.Unlock([]byte(pin), juiceboxCfg); err != nil { + return fmt.Errorf("Juicebox recovery failed (wrong PIN?): %w", err) + } + + version, err := s.adoptSessionKeys(serverKeys) + if err != nil { + return err + } + color.Green("Keys recovered and saved to %s (version %s).", s.store.FilePath(), version) + return nil +} + +// importKeys imports a base64 private-key blob exported by another XChat +// client and saves it locally, adopting the key version of the matching +// registered public key. +func (s *chatSession) importKeys(blob string) error { + if existing := s.store.GetKeys(s.userID); existing != nil && existing.PrivateKeysB64 != "" { + return fmt.Errorf("local keys already exist for user %s in %s — remove them first if you really want to import over them", s.userID, s.store.FilePath()) + } + + if blob == "" { + b, err := promptSecret("Private key blob (base64): ") + if err != nil { + return err + } + blob = b + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(blob)) + if err != nil { + return fmt.Errorf("the key blob is not valid base64: %w", err) + } + if err := s.chat.ImportKeys(raw); err != nil { + return fmt.Errorf("failed to import the key blob: %w", err) + } + + serverKeys, err := api.GetChatPublicKeys(s.client, s.userID, s.opts) + if err != nil { + return err + } + + version, err := s.adoptSessionKeys(serverKeys) + if err != nil { + return err + } + color.Green("Keys imported and saved to %s (version %s).", s.store.FilePath(), version) + return nil +} + +// adoptSessionKeys persists the keys currently loaded in the session, +// adopting the key version of the registered public key they match. Keys +// that are not registered on the account are rejected: xurl never registers +// keys itself, so unregistered keys could never send or verify messages. +func (s *chatSession) adoptSessionKeys(serverKeys []api.ChatPublicKey) (string, error) { + version := "" + for _, k := range serverKeys { + if ok, err := s.chat.MatchesRegisteredKey(k.PublicKey); err == nil && ok { + version = k.Version + break + } + } + if version == "" { + return "", fmt.Errorf("these keys are not registered on account %s — xurl never registers keys, so only keys registered by another XChat client can be used", s.userID) + } + + exported, err := s.chat.ExportKeys() + if err != nil { + return "", fmt.Errorf("failed to export keys: %w", err) + } + if err := s.store.SaveKeys(s.userID, &store.ChatKeys{ + PrivateKeysB64: base64.StdEncoding.EncodeToString(exported), + KeyVersion: version, + }); err != nil { + return "", err + } + return version, nil +} + +// ----------------------------------------------------------------- +// Conversation resolution +// ----------------------------------------------------------------- + +// resolveConversation turns @username / user id / conversation id input into +// a conversation id accepted by the API URL paths (hyphen form). +func (s *chatSession) resolveConversation(input string) (string, error) { + input = strings.TrimSpace(input) + // Already a conversation id: group (g + digits), 1:1 hyphen (a-b) or + // colon (a:b). A bare word starting with "g" that is not all digits is a + // username (e.g. "gandalf"), not a group id. + if (strings.HasPrefix(input, "g") && isAllDigits(strings.TrimPrefix(input, "g"))) || strings.ContainsAny(input, "-:") { + return api.ChatConversationPathID(input), nil + } + peerID := input + if !isAllDigits(input) { + id, err := resolveUserID(s.client, input, s.opts) + if err != nil { + return "", err + } + peerID = id + } + // Canonical 1:1 conversation id: both participant ids, ascending. + a, b := s.userID, peerID + if len(a) > len(b) || (len(a) == len(b) && a > b) { + a, b = b, a + } + return a + "-" + b, nil +} + +// latestKeyVersion returns the newest key version in a conversation-key map +// ("" when empty). +func latestKeyVersion(keys map[string][]byte) string { + latest := "" + for v := range keys { + if latest == "" || api.CompareChatKeyVersions(v, latest) > 0 { + latest = v + } + } + return latest +} + +// eventTimestamp extracts a sortable timestamp from a decrypted event +// (0 when the event carries none). +func eventTimestamp(e *chatxdk.Event) int64 { + if msg := e.AsMessage(); msg != nil && msg.CreatedAtMsec != nil { + return *msg.CreatedAtMsec + } + if kc := e.AsKeyChange(); kc != nil && kc.CreatedAtMsec != nil { + return *kc.CreatedAtMsec + } + return 0 +} + +// isNotFoundError reports whether an API error indicates a missing +// conversation (which is expected before the first message of a 1:1). +func isNotFoundError(err error) bool { + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "404") || strings.Contains(msg, "not found") || strings.Contains(msg, "does not exist") +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// ----------------------------------------------------------------- +// Inbox +// ----------------------------------------------------------------- + +func (s *chatSession) listConversations(maxResults int, asJSON bool) error { + resp, err := api.GetChatConversations(s.client, maxResults, "", s.opts) + if err != nil { + return err + } + if asJSON { + utils.FormatAndPrintResponse(resp) + return nil + } + + var out struct { + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + GroupName string `json:"group_name"` + ParticipantIDs []string `json:"participant_ids"` + IsMuted bool `json:"is_muted"` + } `json:"data"` + } + if err := json.Unmarshal(resp, &out); err != nil || len(out.Data) == 0 { + // Unexpected shape or empty inbox: show the raw response. + utils.FormatAndPrintResponse(resp) + return nil + } + + // Group names are encrypted with the conversation key, so decrypting + // them needs the identity key. Unlock is soft here: without local keys + // the inbox still lists, just with the names left encrypted. + canDecrypt := s.unlock() == nil + if canDecrypt { + // Load signing keys for every participant up front (one batch call) + // so the key-change events fetched below verify and yield the keys. + var allParticipants []string + for _, c := range out.Data { + if c.GroupName != "" { + allParticipants = append(allParticipants, c.ParticipantIDs...) + } + } + s.loadSigningKeys(allParticipants) + } + + type convRow struct { + id, label string + group bool + muted bool + encrypted bool + } + rows := make([]convRow, 0, len(out.Data)) + idWidth := 0 + for _, c := range out.Data { + row := convRow{ + id: api.ChatConversationPathID(c.ID), + group: strings.HasPrefix(c.ID, "g"), + muted: c.IsMuted, + } + if c.GroupName != "" { + row.encrypted = true + if canDecrypt { + if name, err := s.decryptGroupName(c.ID, c.GroupName); err == nil { + row.label = name + row.encrypted = false + } else if s.opts.Verbose { + fmt.Fprintf(os.Stderr, "warning: could not decrypt name of %s: %v\n", c.ID, err) + } + } + } + if row.label == "" && !row.encrypted { + var others []string + for _, p := range c.ParticipantIDs { + if p != s.userID { + others = append(others, "@"+s.username(p)) + } + } + sort.Strings(others) + row.label = strings.Join(others, ", ") + if row.label == "" { + row.label = "(just you)" + } + } + if len(row.id) > idWidth { + idWidth = len(row.id) + } + rows = append(rows, row) + } + + idStyle := color.New(color.Faint) + groupStyle := color.New(color.Bold) + mutedStyle := color.New(color.Faint) + for _, r := range rows { + label := r.label + switch { + case r.encrypted: + label = mutedStyle.Sprint("🔒 (encrypted group name — no key on this device)") + case r.group: + label = groupStyle.Sprint(r.label) + } + suffix := "" + if r.muted { + suffix = mutedStyle.Sprint(" (muted)") + } + fmt.Printf("%s %s%s\n", idStyle.Sprintf("%-*s", idWidth, r.id), label, suffix) + } + return nil +} + +// decryptGroupName decrypts an encrypted group name. Conversation keys come +// from a minimal events fetch: the page's out-of-band key-change events +// (meta.conversation_key_events) carry the keys regardless of how deep the +// key change sits in the history. Each candidate key version is tried newest +// first, since the metadata does not say which version encrypted the name. +func (s *chatSession) decryptGroupName(conversationID, encryptedName string) (string, error) { + // A full page: the meta only reports the key-change events relevant to + // the page's messages, so tiny pages can come back without any. + page, err := api.GetChatEvents(s.client, conversationID, 100, "", s.opts) + if err != nil { + return "", err + } + events := page.KeyEvents + for _, e := range page.Events { + if e.EncodedEvent != "" { + events = append(events, e.EncodedEvent) + } + } + // ECIES-only extraction is enough here: a group name is metadata, and + // only keys encrypted to this identity can decrypt at all. + bundle, err := s.chat.ExtractConversationKeys(events) + if err != nil { + return "", err + } + keys := map[string][]byte{} + for v, k := range s.convKeys { + keys[v] = k + } + for v, k := range bundle.Keys { + keys[v] = k + } + if len(keys) == 0 { + return "", fmt.Errorf("no conversation key available") + } + versions := make([]string, 0, len(keys)) + for v := range keys { + versions = append(versions, v) + } + sort.Slice(versions, func(i, j int) bool { + return api.CompareChatKeyVersions(versions[i], versions[j]) > 0 + }) + var lastErr error + for _, v := range versions { + name, err := s.chat.Decrypt(encryptedName, keys[v]) + if err == nil { + return name, nil + } + lastErr = err + } + return "", lastErr +} + +// ----------------------------------------------------------------- +// Reading & decryption +// ----------------------------------------------------------------- + +// loadSigningKeys batch-fetches public keys for users not seen before and +// stores the merged set in the SDK, so decrypt calls verify against it. +func (s *chatSession) loadSigningKeys(userIDs []string) { + var missing []string + seenInCall := map[string]bool{} + for _, id := range userIDs { + if id == "" || s.seenSenders[id] || seenInCall[id] { + continue + } + seenInCall[id] = true + missing = append(missing, id) + } + if len(missing) == 0 { + return + } + added := false + for start := 0; start < len(missing); start += 100 { + batch := missing[start:min(start+100, len(missing))] + pks, err := api.GetChatUsersPublicKeys(s.client, batch, s.opts) + if err != nil { + // Leave the batch unmarked so a later page or poll retries it; + // a transient failure must not disable a sender for the session. + fmt.Fprintf(os.Stderr, "warning: could not fetch public keys for %d user(s): %v\n", len(batch), err) + continue + } + for _, id := range batch { + s.seenSenders[id] = true + } + for _, pk := range pks { + if pk.UserID == "" { + continue + } + s.signingKeys = append(s.signingKeys, chatxdk.SigningKeyEntry{ + UserID: pk.UserID, + PublicKeyVersion: pk.Version, + PublicKey: pk.SigningPublicKey, + IdentityPublicKey: pk.PublicKey, + IdentityPublicKeySignature: pk.IdentityPublicKeySignature, + }) + added = true + } + } + if added { + if err := s.chat.SetSigningKeys(s.signingKeys); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not store signing keys: %v\n", err) + } + } +} + +// refreshSigningKeys loads signing keys for the senders on a page of events. +func (s *chatSession) refreshSigningKeys(events []api.ChatEventItem) { + var ids []string + for _, e := range events { + ids = append(ids, e.SenderID) + } + s.loadSigningKeys(ids) +} + +// ensureParticipantKeys loads signing keys for every participant of a +// conversation (once per session). Key-change and group events are signed by +// members who may not appear on the fetched pages, so their keys must be +// available before decrypting. +func (s *chatSession) ensureParticipantKeys(conversationID string) { + if s.loadedConvs[conversationID] { + return + } + s.loadedConvs[conversationID] = true + meta, _, err := api.GetChatConversation(s.client, conversationID, s.opts) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not fetch conversation participants: %v\n", err) + return + } + s.loadSigningKeys(meta.AllUserIDs()) +} + +// loadBacklog fetches one page of events, batch-decrypts it (filling the +// SDK's conversation-key cache), and returns the decrypt result together +// with the raw items and the next pagination token. +// +// The page's out-of-band key-change events (meta.conversation_key_events — +// key changes that apply to this page's messages but happened before it) are +// decrypted first so the conversation keys are always available, even when +// the key change itself is deep in the history. +func (s *chatSession) loadBacklog(conversationID string, maxResults int, paginationToken string) (*chatxdk.DecryptEventsResult, []api.ChatEventItem, string, error) { + page, err := api.GetChatEvents(s.client, conversationID, maxResults, paginationToken, s.opts) + if err != nil { + return nil, nil, "", err + } + s.ensureParticipantKeys(conversationID) + s.refreshSigningKeys(page.Events) + s.adoptKeyEvents(page.KeyEvents) + var eventsB64 []string + for _, e := range page.Events { + if e.EncodedEvent != "" { + eventsB64 = append(eventsB64, e.EncodedEvent) + } + } + result, err := s.chat.DecryptEvents(eventsB64, nil) + if err != nil { + return nil, nil, "", fmt.Errorf("failed to decrypt events: %w", err) + } + s.retryWithExtractedKeys(result, eventsB64) + return result, page.Events, page.NextToken, nil +} + +// retryWithExtractedKeys retries batch-decrypt failures with the session's +// ECIES-extracted conversation keys (adoptKeyEvents' fallback for key +// changes whose signers are no longer resolvable). Successfully retried +// events move from result.Errors into result.Messages. +func (s *chatSession) retryWithExtractedKeys(result *chatxdk.DecryptEventsResult, eventsB64 []string) { + if len(result.Errors) == 0 || len(s.convKeys) == 0 { + return + } + for idxStr := range result.Errors { + idx, err := strconv.Atoi(idxStr) + if err != nil || idx < 0 || idx >= len(eventsB64) { + continue + } + event, err := s.chat.DecryptEvent(eventsB64[idx], s.convKeys, nil) + if err != nil { + continue + } + result.Messages = append(result.Messages, chatxdk.DecryptedEventMessage{ + Event: *event, + OriginalB64: eventsB64[idx], + }) + delete(result.Errors, idxStr) + } +} + +// adoptKeyEvents extracts conversation keys from key-change events. The +// verified batch-decrypt path runs first, filling the SDK's anti-downgrade +// key cache. Events it cannot verify — typically key changes signed by +// members who have since left, whose signing keys the API no longer lists — +// fall back to ECIES-only extraction into the session key map: only keys +// encrypted to this identity can decrypt at all, and message authorship is +// still verified per message, so the fallback cannot inject readable forged +// history. +func (s *chatSession) adoptKeyEvents(keyEvents []string) { + if len(keyEvents) == 0 { + return + } + if _, err := s.chat.DecryptEvents(keyEvents, nil); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not adopt conversation keys: %v\n", err) + } + bundle, err := s.chat.ExtractConversationKeys(keyEvents) + if err != nil { + return + } + for v, k := range bundle.Keys { + s.convKeys[v] = k + } +} + +func (s *chatSession) readConversation(conversationID string, maxResults int, asJSON, markRead bool) error { + result, events, _, err := s.loadBacklog(conversationID, maxResults, "") + if err != nil { + return err + } + // Reading a conversation means you have seen it: mark it read up to the + // newest event (a watermark — this also marks every earlier message + // read). Best-effort; opt out with --no-mark-read. + if markRead { + s.markReadLatest(conversationID, events) + } + + // Print oldest-first for reading, ordering by event timestamp. + messages := make([]*chatxdk.Event, 0, len(result.Messages)) + for i := range result.Messages { + messages = append(messages, &result.Messages[i].Event) + } + sort.SliceStable(messages, func(i, j int) bool { + return eventTimestamp(messages[i]) < eventTimestamp(messages[j]) + }) + + if asJSON { + var events []json.RawMessage + for _, e := range messages { + events = append(events, e.Raw()) + } + utils.FormatAndPrintResponse(events) + } else { + if len(messages) == 0 { + fmt.Println("No messages.") + } + for _, e := range messages { + s.printEvent(e, s.opts.Verbose) + } + } + + // The batch decrypt keys its errors by index into the submitted slice; + // map back through the same encoded-event filter loadBacklog applied so + // the warning names the real event id, matching listen's wording. + var decryptedIDs []string + for _, e := range events { + if e.EncodedEvent != "" { + decryptedIDs = append(decryptedIDs, e.ID) + } + } + for idxStr, msg := range result.Errors { + label := "at index " + idxStr + if idx, err := strconv.Atoi(idxStr); err == nil && idx >= 0 && idx < len(decryptedIDs) { + label = decryptedIDs[idx] + } + fmt.Fprintf(os.Stderr, "warning: could not decrypt event %s: %s\n", label, msg) + } + return nil +} + +func (s *chatSession) listen(conversationID string, interval time.Duration, markRead bool) error { + if interval <= 0 { + interval = 3 * time.Second + } + // Seed the key cache and the seen-event set from the backlog. Pagination + // tokens walk BACKWARD through history (each next page is older), so a + // poll loop must never follow them: every poll re-fetches the newest + // page with no token and relies on the seen set to surface only events + // that arrived since. + result, backlog, _, err := s.loadBacklog(conversationID, 100, "") + if err != nil { + return err + } + seen := map[string]bool{} + for _, e := range backlog { + seen[e.ID] = true + } + // Opening the conversation marks the existing backlog read. + if markRead { + s.markReadLatest(conversationID, backlog) + } + + fmt.Printf("Listening on %s (polling every %s, Ctrl-C to stop)...\n", conversationID, interval) + // Show a little context: the last few messages, oldest first. + context := make([]*chatxdk.Event, 0, len(result.Messages)) + for i := range result.Messages { + context = append(context, &result.Messages[i].Event) + } + sort.SliceStable(context, func(i, j int) bool { + return eventTimestamp(context[i]) < eventTimestamp(context[j]) + }) + if len(context) > 3 { + context = context[len(context)-3:] + } + for _, e := range context { + s.printEvent(e, false) + } + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + + for { + select { + case <-sig: + fmt.Println() + return nil + case <-time.After(interval): + } + + page, err := api.GetChatEvents(s.client, conversationID, 50, "", s.opts) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: poll failed: %v\n", err) + continue + } + s.refreshSigningKeys(page.Events) + s.adoptKeyEvents(page.KeyEvents) + + // The page is newest-first; collect the unseen events and flip them + // so a batch of new messages prints in chronological order. + var fresh []api.ChatEventItem + for _, item := range page.Events { + if item.EncodedEvent == "" || seen[item.ID] { + continue + } + seen[item.ID] = true + fresh = append(fresh, item) + } + for i := len(fresh) - 1; i >= 0; i-- { + item := fresh[i] + event, err := s.chat.DecryptEvent(item.EncodedEvent, nil, nil) + if err != nil && len(s.convKeys) > 0 { + // Retry with the ECIES-extracted keys (see adoptKeyEvents). + event, err = s.chat.DecryptEvent(item.EncodedEvent, s.convKeys, nil) + } + if err != nil { + fmt.Fprintf(os.Stderr, "warning: could not decrypt event %s: %v\n", item.ID, err) + continue + } + if event.AsKeyChange() != nil { + // Only the batch path adopts keys into the SDK's cache; + // replay the key change through it so a rotated key becomes + // the sending key. + if _, err := s.chat.DecryptEvents([]string{item.EncodedEvent}, nil); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not adopt rotated key: %v\n", err) + } + } + s.printEvent(event, s.opts.Verbose) + } + // New arrivals shown means they have been seen: advance the read + // watermark to the newest of this batch. + if markRead && len(fresh) > 0 { + s.markReadLatest(conversationID, fresh) + } + } +} + +// senderPalette holds the colors rotated through for other participants; +// the acting user is always green. +var senderPalette = []color.Attribute{ + color.FgCyan, color.FgMagenta, color.FgYellow, color.FgBlue, color.FgHiCyan, color.FgHiMagenta, +} + +// senderStyle returns a stable color for a user id (green bold for the +// acting user), so each participant keeps one color for the whole run. +func (s *chatSession) senderStyle(userID string) *color.Color { + if userID == s.userID { + return color.New(color.FgGreen, color.Bold) + } + var h uint32 = 2166136261 + for i := 0; i < len(userID); i++ { + h = (h ^ uint32(userID[i])) * 16777619 + } + return color.New(senderPalette[h%uint32(len(senderPalette))], color.Bold) +} + +// printDaySeparator prints a faint rule when the day changes between +// consecutively printed events, so timestamps can stay short. +func (s *chatSession) printDaySeparator(t time.Time) { + day := t.Format("Mon 2006-01-02") + if day == s.lastPrintedDay { + return + } + s.lastPrintedDay = day + color.New(color.Faint).Printf("── %s %s\n", day, strings.Repeat("─", 24)) +} + +// printEvent renders one decrypted event. Non-message events are only shown +// when verbose is true (key changes, typing, receipts, ...). +func (s *chatSession) printEvent(event *chatxdk.Event, verbose bool) { + msg := event.AsMessage() + if msg == nil { + if verbose { + color.New(color.Faint).Printf(" ─ %s event ─\n", event.Type) + } + return + } + + sender := "unknown" + senderID := "" + if msg.SenderID != nil { + senderID = *msg.SenderID + sender = "@" + s.username(senderID) + } + ts := "" + if msg.CreatedAtMsec != nil { + t := time.UnixMilli(*msg.CreatedAtMsec).Local() + s.printDaySeparator(t) + ts = t.Format("15:04") + } + + timeStyle := color.New(color.Faint) + senderStyle := s.senderStyle(senderID) + + switch msg.Content.ContentType { + case "Text": + text := msg.Text() + var prefix, suffix string + if tc := msg.Content.TextContent; tc != nil && len(tc.ReplyingToPreview) > 0 { + prefix = color.New(color.Faint).Sprint("↩ ") + } + if n := max(len(msg.MediaHashes), len(msg.Attachments)); n > 0 { + count := "" + if n > 1 { + count = fmt.Sprintf(" ×%d", n) + } + marker := "📎 attachment" + count + // Show the hash key so it can be passed to 'chat download'. + if len(msg.MediaHashes) > 0 { + marker += " " + msg.MediaHashes[0].MediaHashKey + } + if text == "" { + text = color.New(color.Faint).Sprint(marker) + } else { + suffix = color.New(color.Faint).Sprintf(" %s", marker) + } + } + if text == "" { + text = color.New(color.Faint).Sprint("(empty message)") + } + if !msg.Verified { + suffix += color.RedString(" [unverified]") + } + timeStyle.Printf("%s ", ts) + senderStyle.Print(sender) + fmt.Printf(" %s%s%s\n", prefix, text, suffix) + case "Reaction": + if msg.Content.ReactionContent != nil { + timeStyle.Printf("%s ", ts) + color.New(color.Faint).Printf("%s reacted %s\n", sender, msg.Content.ReactionContent.Emoji) + } + case "ReactionRemoved": + if msg.Content.ReactionContent != nil && verbose { + timeStyle.Printf("%s ", ts) + color.New(color.Faint).Printf("%s removed reaction %s\n", sender, msg.Content.ReactionContent.Emoji) + } + default: + if verbose { + color.New(color.Faint).Printf("%s %s (%s message)\n", ts, sender, msg.Content.ContentType) + } + } +} + +// ----------------------------------------------------------------- +// Sending +// ----------------------------------------------------------------- + +// sendOptions carries the optional extras for a send. +type sendOptions struct { + filePath string // attach an encrypted media file + replyToID string // reply to the event with this sequence id + markRead bool // mark the conversation read after sending + typing bool // send a typing indicator before sending +} + +func (s *chatSession) sendMessage(conversationID, text string, sopts sendOptions) error { + // A typing indicator before the message mirrors how a person composes; + // best-effort so it never blocks the send. Opt out with --no-typing. + if sopts.typing { + if _, err := api.SendChatTyping(s.client, conversationID, s.opts); err != nil && s.opts.Verbose { + fmt.Fprintf(os.Stderr, "warning: could not send typing indicator: %v\n", err) + } + } + + // Load the backlog: it extracts the conversation key and tells us whether + // the conversation exists. + result, events, _, err := s.loadBacklog(conversationID, 100, "") + if err != nil && !isNotFoundError(err) { + // Only a missing conversation may proceed to key initialization; any + // other failure must not trigger a blind key rotation. + return err + } + + // The message signature covers the conversation id in its canonical + // (colon) form; prefer the id carried inside a decrypted event, then the + // raw event items, then the caller-derived form. + signConvID := api.ChatConversationEventID(conversationID) + for _, e := range events { + if e.ConversationID != "" { + signConvID = e.ConversationID + break + } + } + if result != nil { + for _, m := range result.Messages { + if msg := m.Event.AsMessage(); msg != nil && msg.ConversationID != nil && *msg.ConversationID != "" { + signConvID = *msg.ConversationID + break + } + if kc := m.Event.AsKeyChange(); kc != nil && kc.ConversationID != nil && *kc.ConversationID != "" { + signConvID = *kc.ConversationID + break + } + } + } + + // Resolve the raw conversation key (needed to encrypt media under the + // same key as the message), initializing or rotating only with consent. + rawKey, keyVer, newSignConvID, err := s.resolveSendKey(conversationID, signConvID, result, len(events) > 0) + if err != nil { + return err + } + signConvID = newSignConvID + + params := chatxdk.EncryptMessageParams{ + ConversationID: signConvID, + Text: text, + ConversationKey: rawKey, + ConversationKeyVersion: keyVer, + } + + // Attach an encrypted media file, if requested. + if sopts.filePath != "" { + att, err := s.uploadMedia(signConvID, sopts.filePath, rawKey) + if err != nil { + return err + } + params.Attachments = []chatxdk.AttachmentDescriptor{*att} + } + + var payload *chatxdk.SendPayload + if sopts.replyToID != "" { + rawEvent := findEncodedEvent(events, sopts.replyToID) + if rawEvent == "" { + return fmt.Errorf("could not find event %s in the recent history to reply to", sopts.replyToID) + } + payload, err = s.chat.EncryptReply(chatxdk.EncryptReplyParams{ + ConversationID: signConvID, + Text: text, + ReplyToEvent: rawEvent, + ConversationKey: rawKey, + ConversationKeyVersion: keyVer, + Attachments: params.Attachments, + }) + } else { + payload, err = s.chat.EncryptMessage(params) + } + if err != nil { + return fmt.Errorf("failed to encrypt message: %w", err) + } + + resp, err := api.SendChatMessage(s.client, signConvID, api.ChatSendBody{ + MessageID: payload.MessageID, + EncodedMessageCreateEvent: payload.EncryptedContent, + EncodedMessageEventSignature: payload.EncodedEventSignature, + }, s.opts) + if err != nil { + return err + } + + fmt.Printf("%s %s\n", color.GreenString("✓ sent to"), api.ChatConversationPathID(signConvID)) + color.New(color.Faint).Printf(" message id %s\n", payload.MessageID) + if sopts.markRead { + s.markReadLatest(signConvID, events) + } + if s.opts.Verbose { + utils.FormatAndPrintResponse(resp) + } + return nil +} + +// resolveSendKey returns the raw conversation key and version to encrypt a +// message and any media under, plus the canonical conversation id. It uses +// the newest key already readable on the conversation; failing that, it +// initializes a fresh key for a new 1:1, or — for an existing 1:1 whose key +// this device cannot read — rotates only after explicit confirmation. A +// group with no readable key is an error (join/rotation is a separate step). +func (s *chatSession) resolveSendKey(conversationID, signConvID string, result *chatxdk.DecryptEventsResult, hasHistory bool) (rawKey []byte, keyVer, outConvID string, err error) { + keys := map[string][]byte{} + if result != nil { + for v, k := range result.ConversationKeys.Keys { + keys[v] = k + } + } + for v, k := range s.convKeys { + keys[v] = k + } + if v := latestKeyVersion(keys); v != "" { + return keys[v], v, signConvID, nil + } + + if strings.HasPrefix(conversationID, "g") { + return nil, "", "", fmt.Errorf("no usable conversation key for group %s — its key was not encrypted to this device (try 'xurl chat rotate %s' from a current member, or ask to be re-added)", conversationID, conversationID) + } + if hasHistory { + fmt.Fprintf(os.Stderr, "This conversation exists but none of its keys are readable by this device.\n") + fmt.Fprintln(os.Stderr, "Sending will rotate the conversation key: earlier messages stay unreadable here, and the other participant's clients will see a key change.") + if !term.IsTerminal(int(os.Stdin.Fd())) { + return nil, "", "", fmt.Errorf("refusing to rotate the conversation key non-interactively — run again from a terminal to confirm") + } + fmt.Fprint(os.Stderr, "Rotate and send? [y/N] ") + var answer string + _, _ = fmt.Scanln(&answer) + if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" { + return nil, "", "", fmt.Errorf("send aborted") + } + } + prepared, confirmedID, err := s.prepareOneToOneKey(conversationID) + if err != nil { + return nil, "", "", err + } + return prepared.ConversationKey, prepared.ConversationKeyVersion, api.ChatConversationEventID(confirmedID), nil +} + +// findEncodedEvent returns the raw base64 event whose id matches, or "". +func findEncodedEvent(events []api.ChatEventItem, id string) string { + for _, e := range events { + if e.ID == id { + return e.EncodedEvent + } + } + return "" +} + +// markReadLatest marks the conversation read up to the newest event's +// sequence id (best-effort; failures warn but don't fail the send). +func (s *chatSession) markReadLatest(conversationID string, events []api.ChatEventItem) { + newest := "" + for _, e := range events { + if api.CompareChatKeyVersions(e.ID, newest) > 0 { + newest = e.ID + } + } + if newest == "" { + return + } + if _, err := api.MarkChatRead(s.client, conversationID, newest, s.opts); err != nil { + fmt.Fprintf(os.Stderr, "warning: could not mark read: %v\n", err) + } +} + +// downloadMedia fetches and decrypts a media attachment to a local file, +// trying every conversation key this device holds (the attachment was +// encrypted under the key version active when its message was sent). +func (s *chatSession) downloadMedia(conversationID, mediaHashKey, outPath string) error { + // Load the backlog so the conversation keys are available. + result, _, _, err := s.loadBacklog(conversationID, 100, "") + if err != nil && !isNotFoundError(err) { + return err + } + keys := map[string][]byte{} + if result != nil { + for v, k := range result.ConversationKeys.Keys { + keys[v] = k + } + } + for v, k := range s.convKeys { + keys[v] = k + } + if len(keys) == 0 { + return fmt.Errorf("no conversation key available to decrypt media in %s", conversationID) + } + + ciphertext, err := api.DownloadChatMedia(s.client, conversationID, mediaHashKey, s.opts) + if err != nil { + return err + } + + // Try newest key first; the right version decrypts, others fail cleanly. + versions := make([]string, 0, len(keys)) + for v := range keys { + versions = append(versions, v) + } + sort.Slice(versions, func(i, j int) bool { + return api.CompareChatKeyVersions(versions[i], versions[j]) > 0 + }) + var plaintext []byte + for _, v := range versions { + if pt, derr := s.chat.DecryptStream(ciphertext, keys[v]); derr == nil { + plaintext = pt + break + } + } + if plaintext == nil { + return fmt.Errorf("could not decrypt media with any available conversation key (%d tried)", len(versions)) + } + + if outPath == "" { + outPath = mediaHashKey + } + if err := os.WriteFile(outPath, plaintext, 0600); err != nil { + return err + } + fmt.Printf("%s %s (%d bytes)\n", color.GreenString("✓ saved"), outPath, len(plaintext)) + return nil +} + +// markReadCommand marks a conversation read up to its newest event. +func (s *chatSession) markReadCommand(conversationID string) error { + page, err := api.GetChatEvents(s.client, conversationID, 1, "", s.opts) + if err != nil { + return err + } + if len(page.Events) == 0 { + fmt.Println("Nothing to mark read.") + return nil + } + newest := "" + for _, e := range page.Events { + if api.CompareChatKeyVersions(e.ID, newest) > 0 { + newest = e.ID + } + } + if _, err := api.MarkChatRead(s.client, conversationID, newest, s.opts); err != nil { + return err + } + fmt.Printf("%s %s (through sequence %s)\n", color.GreenString("✓ marked read"), api.ChatConversationPathID(conversationID), newest) + return nil +} + +// addGroupMembers adds members to a group, rotating the conversation key so +// the new roster can read messages going forward. +func (s *chatSession) addGroupMembers(conversationID string, newMembers []string, skipConfirm bool) error { + // Resolve the new members to user ids. + var newIDs []string + for _, m := range newMembers { + id, err := s.resolveUserToID(m) + if err != nil { + return err + } + newIDs = append(newIDs, id) + } + + meta, _, err := api.GetChatConversation(s.client, conversationID, s.opts) + if err != nil { + return fmt.Errorf("could not fetch the group: %w", err) + } + current := meta.AllUserIDs() + + if !skipConfirm { + var names []string + for _, id := range newIDs { + names = append(names, "@"+s.username(id)) + } + fmt.Fprintf(os.Stderr, "Add %s to %s? This rotates the conversation key (visible to all members); new members read messages from now on only.\n", strings.Join(names, ", "), conversationID) + if !term.IsTerminal(int(os.Stdin.Fd())) { + return fmt.Errorf("refusing to add members non-interactively — pass --yes to confirm") + } + fmt.Fprint(os.Stderr, "Proceed? [y/N] ") + var answer string + _, _ = fmt.Scanln(&answer) + if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" { + return fmt.Errorf("add-members aborted") + } + } + + // The rotated key is wrapped to the full new roster (current + new). + roster := append(append([]string{}, current...), newIDs...) + inputs, err := s.latestKeyInputs(roster) + if err != nil { + return err + } + prepared, err := s.chat.PrepareGroupMembersChange(chatxdk.GroupMembersChangeParams{ + PublicKeys: inputs, + ConversationID: api.ChatConversationEventID(conversationID), + NewMemberIDs: newIDs, + CurrentMemberIDs: meta.MemberIDs, + CurrentAdminIDs: meta.AdminIDs, + CurrentTitle: meta.GroupName, + CurrentAvatarURL: meta.GroupAvatarURL, + }) + if err != nil { + return fmt.Errorf("failed to prepare member change: %w", err) + } + + pub, err := s.chat.GetPublicKeys() + if err != nil { + return err + } + body := preparedChangeToRequest(prepared, pub.Signing) + body["user_ids"] = newIDs + if _, err := api.AddChatGroupMembers(s.client, conversationID, body, s.opts); err != nil { + return err + } + fmt.Printf("%s %d member(s) to %s\n", color.GreenString("✓ added"), len(newIDs), api.ChatConversationPathID(conversationID)) + color.New(color.Faint).Printf(" new key version %s\n", prepared.ConversationKeyVersion) + return nil +} + +// resolveUserToID resolves @username / username / bare id to a user id. +func (s *chatSession) resolveUserToID(input string) (string, error) { + input = strings.TrimSpace(input) + if isAllDigits(input) { + return input, nil + } + return resolveUserID(s.client, input, s.opts) +} + +// mediaTypeForMime maps a detected MIME type to the wire MediaType code. +func mediaTypeForMime(mime string) int32 { + switch { + case mime == "image/gif": + return 2 // GIF + case mime == "image/svg+xml": + return 6 // SVG + case strings.HasPrefix(mime, "image/"): + return 1 // IMAGE + case strings.HasPrefix(mime, "video/"): + return 3 // VIDEO + case strings.HasPrefix(mime, "audio/"): + return 4 // AUDIO + default: + return 5 // FILE + } +} + +// uploadMedia encrypts a local file under the conversation key, uploads the +// ciphertext, and returns an attachment descriptor referencing it. +func (s *chatSession) uploadMedia(conversationID, path string, conversationKey []byte) (*chatxdk.AttachmentDescriptor, error) { + plaintext, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("could not read %s: %w", path, err) + } + ciphertext, err := s.chat.EncryptStream(plaintext, conversationKey) + if err != nil { + return nil, fmt.Errorf("could not encrypt media: %w", err) + } + sessionID, mediaHashKey, err := api.InitializeChatMediaUpload(s.client, conversationID, len(ciphertext), s.opts) + if err != nil { + return nil, err + } + if err := api.UploadChatMedia(s.client, sessionID, conversationID, mediaHashKey, ciphertext, s.opts); err != nil { + return nil, err + } + + mime, _ := chatxdk.DetectMimeType(plaintext) + mediaType := mediaTypeForMime(mime) + att := &chatxdk.AttachmentDescriptor{ + AttachmentType: "media", + MediaHashKey: mediaHashKey, + FilesizeBytes: int64(len(plaintext)), + Filename: filepath.Base(path), + MediaType: &mediaType, + } + if dim, err := chatxdk.DetectImageDimensions(plaintext); err == nil && dim != nil { + att.Width = int64(dim.Width) + att.Height = int64(dim.Height) + } + color.New(color.Faint).Fprintf(os.Stderr, " uploaded %s (%s, %d bytes)\n", att.Filename, mime, len(plaintext)) + return att, nil +} + +// prepareOneToOneKey generates a conversation key for a 1:1 conversation, +// ECIES-encrypts it to both participants, and registers it with the API. +func (s *chatSession) prepareOneToOneKey(conversationID string) (*chatxdk.PreparedConversationChange, string, error) { + parts := strings.Split(conversationID, "-") + if len(parts) != 2 { + return nil, "", fmt.Errorf("cannot initialize keys for conversation %s", conversationID) + } + // An empty conversation id has the SDK derive the canonical 1:1 id from + // the participants. + return s.establishConversationKey(parts, "") +} + +// establishConversationKey generates a conversation key, encrypts it to each +// participant's newest registered identity key, and POSTs it to the API. An +// empty conversationID creates a fresh 1:1 key; a set id rotates the existing +// conversation's key. It returns the prepared change and the canonical +// conversation id confirmed by the server (which callers must prefer over a +// client-reconstructed id). +func (s *chatSession) establishConversationKey(participantIDs []string, conversationID string) (*chatxdk.PreparedConversationChange, string, error) { + inputs, err := s.latestKeyInputs(participantIDs) + if err != nil { + return nil, "", err + } + + prepared, err := s.chat.PrepareConversationKeyChange(chatxdk.ConversationKeyChangeParams{ + PublicKeys: inputs, + ConversationID: conversationID, + }) + if err != nil { + return nil, "", fmt.Errorf("failed to prepare conversation key: %w", err) + } + + pub, err := s.chat.GetPublicKeys() + if err != nil { + return nil, "", err + } + body := preparedChangeToRequest(prepared, pub.Signing) + resp, err := api.AddChatConversationKeys(s.client, prepared.ConversationID, body, s.opts) + if err != nil { + return nil, "", fmt.Errorf("failed to register conversation key: %w", err) + } + + confirmedID := prepared.ConversationID + var out struct { + Data struct { + ConversationID string `json:"conversation_id"` + } `json:"data"` + } + if json.Unmarshal(resp, &out) == nil && out.Data.ConversationID != "" { + confirmedID = out.Data.ConversationID + } + return prepared, confirmedID, nil +} + +// rotateConversationKey generates a fresh conversation key for an existing +// conversation and wraps it to every current participant's newest keys. +func (s *chatSession) rotateConversationKey(conversationID string, skipConfirm bool) error { + // The roster comes from the conversation itself: metadata participants + // for a group, the two ids from the canonical id for a 1:1. + var roster []string + if strings.HasPrefix(conversationID, "g") { + meta, _, err := api.GetChatConversation(s.client, conversationID, s.opts) + if err != nil { + return fmt.Errorf("could not fetch the group roster: %w", err) + } + roster = meta.AllUserIDs() + } else { + roster = strings.Split(conversationID, "-") + } + if len(roster) < 2 { + return fmt.Errorf("conversation %s has no roster to rotate a key for", conversationID) + } + + if !skipConfirm { + fmt.Fprintf(os.Stderr, "Rotating the key of %s re-encrypts future messages to %d participant(s)' newest keys.\n", conversationID, len(roster)) + fmt.Fprintln(os.Stderr, "Other participants' clients will see a key change. Old messages stay readable only to holders of the old key versions.") + if !term.IsTerminal(int(os.Stdin.Fd())) { + return fmt.Errorf("refusing to rotate non-interactively — pass --yes to confirm") + } + fmt.Fprint(os.Stderr, "Rotate? [y/N] ") + var answer string + _, _ = fmt.Scanln(&answer) + if a := strings.ToLower(strings.TrimSpace(answer)); a != "y" && a != "yes" { + return fmt.Errorf("rotation aborted") + } + } + + prepared, confirmedID, err := s.establishConversationKey(roster, api.ChatConversationEventID(conversationID)) + if err != nil { + return err + } + fmt.Printf("%s %s\n", color.GreenString("✓ rotated key of"), api.ChatConversationPathID(confirmedID)) + color.New(color.Faint).Printf(" new key version %s, wrapped to %d participant(s)\n", prepared.ConversationKeyVersion, len(prepared.ParticipantKeys)) + return nil +} + +// latestKeyInputs fetches each participant's registered public keys and +// selects the newest version to encrypt the conversation key to. +func (s *chatSession) latestKeyInputs(userIDs []string) ([]chatxdk.PublicKeyInput, error) { + var inputs []chatxdk.PublicKeyInput + for _, uid := range userIDs { + pks, err := api.GetChatPublicKeys(s.client, uid, s.opts) + if err != nil { + return nil, err + } + if len(pks) == 0 { + who := "user " + uid + if uid != s.userID { + who = "@" + s.username(uid) + } + return nil, fmt.Errorf("%s has no registered XChat keys — they need to enable encrypted chat first", who) + } + latest := pks[0] + for _, pk := range pks[1:] { + if api.CompareChatKeyVersions(pk.Version, latest.Version) > 0 { + latest = pk + } + } + inputs = append(inputs, chatxdk.PublicKeyInput{ + UserID: uid, + PublicKey: latest.PublicKey, + KeyVersion: latest.Version, + }) + } + return inputs, nil +} + +// preparedChangeToRequest maps a prepared conversation change into the +// /2/chat/conversations/:id/keys request shape. +func preparedChangeToRequest(prep *chatxdk.PreparedConversationChange, signingPublicKey string) map[string]any { + participantKeys := make([]map[string]any, 0, len(prep.ParticipantKeys)) + for _, pk := range prep.ParticipantKeys { + participantKeys = append(participantKeys, map[string]any{ + "user_id": pk.UserID, + "encrypted_conversation_key": pk.EncryptedKey, + "public_key_version": pk.PublicKeyVersion, + }) + } + actionSignatures := make([]map[string]any, 0, len(prep.ActionSignatures)) + for _, sig := range prep.ActionSignatures { + entry := map[string]any{ + "message_id": sig.MessageID, + "encoded_message_event_detail": sig.EncodedMessageEventDetail, + "message_event_signature": map[string]any{ + "signature": sig.Signature, + "signature_version": sig.SignatureVersion, + "public_key_version": sig.PublicKeyVersion, + "signing_public_key": signingPublicKey, + }, + } + if sig.SignaturePayload != "" { + entry["signature_payload"] = sig.SignaturePayload + } + actionSignatures = append(actionSignatures, entry) + } + return map[string]any{ + "conversation_key_version": prep.ConversationKeyVersion, + "conversation_participant_keys": participantKeys, + "action_signatures": actionSignatures, + } +} + +// ----------------------------------------------------------------- +// Small helpers +// ----------------------------------------------------------------- + +// username resolves a user id to a username, caching per session; falls back +// to the id itself. +func (s *chatSession) username(userID string) string { + if u, ok := s.usernames[userID]; ok { + return u + } + name := userID + if resp, err := api.GetUserByID(s.client, userID, s.opts); err == nil { + var out struct { + Data struct { + Username string `json:"username"` + } `json:"data"` + } + if json.Unmarshal(resp, &out) == nil && out.Data.Username != "" { + name = out.Data.Username + } + } + s.usernames[userID] = name + return name +} + +// promptSecret reads a line of sensitive input: without echo on a terminal, +// as a plain line read when stdin is piped. +func promptSecret(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + defer fmt.Fprintln(os.Stderr) + if !term.IsTerminal(int(os.Stdin.Fd())) { + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + return "", fmt.Errorf("could not read input: %w", err) + } + return strings.TrimSpace(line), nil + } + b, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", fmt.Errorf("could not read input: %w", err) + } + return strings.TrimSpace(string(b)), nil +} + +// exitOnError prints an error to stderr and exits non-zero. +func exitOnError(err error) { + if err != nil { + fmt.Fprintf(os.Stderr, "\033[31mError: %v\033[0m\n", err) + os.Exit(1) + } +} diff --git a/cli/chat_test.go b/cli/chat_test.go new file mode 100644 index 0000000..315c63b --- /dev/null +++ b/cli/chat_test.go @@ -0,0 +1,158 @@ +//go:build cgo && ((darwin && (amd64 || arm64)) || (linux && amd64)) + +package cli + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/xdevplatform/chat-xdk/go/chatxdk" + "github.com/xdevplatform/xurl/api" +) + +// stubChatClient serves canned responses for the endpoints resolveConversation +// touches (user lookup by username). +type stubChatClient struct { + api.Client + lookupID string +} + +func (c *stubChatClient) SendRequest(opts api.RequestOptions) (json.RawMessage, error) { + if strings.HasPrefix(opts.Endpoint, "/2/users/by/username/") { + return json.RawMessage(fmt.Sprintf(`{"data":{"id":%q,"username":"stub"}}`, c.lookupID)), nil + } + return json.RawMessage(`{"data":{}}`), nil +} + +func TestResolveConversationForms(t *testing.T) { + s := &chatSession{userID: "42", client: &stubChatClient{lookupID: "100"}} + + tests := []struct { + name string + input string + want string + }{ + {"group id", "g123", "g123"}, + {"hyphen 1:1 id", "100-200", "100-200"}, + {"colon 1:1 id converts to hyphen", "100:200", "100-200"}, + {"peer id greater than mine", "100", "42-100"}, + {"peer id smaller than mine", "7", "7-42"}, + {"same length ids order lexically", "41", "41-42"}, + {"username starting with g resolves as user", "gandalf", "42-100"}, + {"@username starting with g resolves as user", "@gandalf", "42-100"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := s.resolveConversation(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestIsAllDigits(t *testing.T) { + assert.True(t, isAllDigits("123")) + assert.False(t, isAllDigits("")) + assert.False(t, isAllDigits("@bob")) + assert.False(t, isAllDigits("12a")) +} + +func TestMatchesRegisteredKeyEncodings(t *testing.T) { + // The registered-key match is delegated to chat-xdk's + // MatchesRegisteredKey, which accepts both wire encodings: the SPKI/DER + // form the API returns and the raw SEC1 point from GetPublicKeys. + chat := chatxdk.New() + defer chat.Close() + payload, err := chat.GenerateKeypairs() + require.NoError(t, err) + pub, err := chat.GetPublicKeys() + require.NoError(t, err) + + assert.NotEqual(t, pub.Identity, payload.PublicKey.PublicKey, "sanity: encodings differ") + ok, err := chat.MatchesRegisteredKey(payload.PublicKey.PublicKey) + require.NoError(t, err) + assert.True(t, ok, "SPKI form matches") + ok, err = chat.MatchesRegisteredKey(pub.Identity) + require.NoError(t, err) + assert.True(t, ok, "raw SEC1 form matches") + + // A different key must not match. + other := chatxdk.New() + defer other.Close() + otherPayload, err := other.GenerateKeypairs() + require.NoError(t, err) + ok, err = chat.MatchesRegisteredKey(otherPayload.PublicKey.PublicKey) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestChatCommandStructure(t *testing.T) { + cmd := CreateChatCommand(nil) + assert.Equal(t, "chat", cmd.Name()) + assert.True(t, chatSupported) + + names := map[string]bool{} + for _, c := range cmd.Commands() { + names[c.Name()] = true + } + for _, want := range []string{"keys", "conversations", "read", "send", "listen"} { + assert.True(t, names[want], "missing subcommand %q", want) + } + + for _, c := range cmd.Commands() { + if c.Name() == "keys" { + sub := map[string]bool{} + for _, k := range c.Commands() { + sub[k.Name()] = true + } + for _, want := range []string{"status", "restore", "import"} { + assert.True(t, sub[want], "missing keys subcommand %q", want) + } + } + } +} + +func mockPreparedChange() *chatxdk.PreparedConversationChange { + return &chatxdk.PreparedConversationChange{ + ConversationID: "1:2", + ConversationKeyVersion: "v9", + ParticipantKeys: []chatxdk.EncryptedKeyForRecipient{ + {UserID: "7", EncryptedKey: "enc-key", PublicKeyVersion: "1700"}, + }, + ActionSignatures: []chatxdk.ActionSignature{ + {MessageID: "m1", EncodedMessageEventDetail: "detail", Signature: "sig", SignatureVersion: "7", PublicKeyVersion: "1700"}, + }, + } +} + +func TestPreparedChangeToRequestShape(t *testing.T) { + // Exercised indirectly via chat-xdk types; here we validate the JSON + // field names the API expects. + body := preparedChangeToRequest(mockPreparedChange(), "signing-pub") + + assert.Equal(t, "v9", body["conversation_key_version"]) + pks := body["conversation_participant_keys"].([]map[string]any) + require.Len(t, pks, 1) + assert.Equal(t, "7", pks[0]["user_id"]) + assert.Equal(t, "enc-key", pks[0]["encrypted_conversation_key"]) + assert.Equal(t, "1700", pks[0]["public_key_version"]) + + sigs := body["action_signatures"].([]map[string]any) + require.Len(t, sigs, 1) + assert.Equal(t, "m1", sigs[0]["message_id"]) + mes := sigs[0]["message_event_signature"].(map[string]any) + assert.Equal(t, "signing-pub", mes["signing_public_key"]) + // signature_payload is omitted when empty. + _, has := sigs[0]["signature_payload"] + assert.False(t, has) +} + +func TestChatConversationPathHelpers(t *testing.T) { + assert.Equal(t, "1-2", api.ChatConversationPathID("1:2")) + assert.Equal(t, "1:2", api.ChatConversationEventID("1-2")) +} diff --git a/cli/chat_unsupported.go b/cli/chat_unsupported.go new file mode 100644 index 0000000..2f7dbb5 --- /dev/null +++ b/cli/chat_unsupported.go @@ -0,0 +1,34 @@ +//go:build !cgo || !((darwin && (amd64 || arm64)) || (linux && amd64)) + +package cli + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "github.com/xdevplatform/xurl/auth" +) + +// chatSupported reports whether this build includes the XChat client. +const chatSupported = false + +// CreateChatCommand returns a stub on platforms where the chat-xdk crypto +// binding is unavailable (it requires cgo and prebuilt static libraries for +// darwin/amd64, darwin/arm64, or linux/amd64). +func CreateChatCommand(a *auth.Auth) *cobra.Command { + cmd := &cobra.Command{ + Use: "chat", + Short: "Send and read end-to-end encrypted XChat messages (unavailable in this build)", + Run: func(cmd *cobra.Command, args []string) { + fmt.Fprintln(os.Stderr, "xurl chat is not available in this build: the XChat crypto library requires cgo and supports macOS (amd64/arm64) and Linux (amd64).") + fmt.Fprintln(os.Stderr, "On a supported platform, build from source with CGO enabled: CGO_ENABLED=1 go install github.com/xdevplatform/xurl@latest") + os.Exit(1) + }, + } + // Swallow any subcommand/flags so `xurl chat send ...` also reaches the stub. + cmd.FParseErrWhitelist.UnknownFlags = true + cmd.Args = cobra.ArbitraryArgs + return cmd +} diff --git a/cli/root.go b/cli/root.go index c8ec74b..3266ada 100644 --- a/cli/root.go +++ b/cli/root.go @@ -124,6 +124,10 @@ Commands are grouped by purpose below. Run 'xurl --help' for details.` &cobra.Group{ID: groupManage, Title: "Management:"}, ) + chatCmd := CreateChatCommand(a) + chatCmd.GroupID = groupWrite + rootCmd.AddCommand(chatCmd) + authCmd := CreateAuthCommand(a) mediaCmd := CreateMediaCommand(a) versionCmd := CreateVersionCommand() diff --git a/go.mod b/go.mod index 76b88c5..db0be94 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/xdevplatform/xurl -go 1.24.0 +go 1.24.2 require ( github.com/charmbracelet/bubbletea v1.3.10 @@ -9,17 +9,22 @@ require ( github.com/spf13/cobra v1.8.0 github.com/stretchr/testify v1.10.0 github.com/tidwall/pretty v1.2.1 + github.com/xdevplatform/chat-xdk/go/chatxdk v0.4.1 golang.ngrok.com/ngrok v1.13.0 golang.org/x/oauth2 v0.18.0 + golang.org/x/term v0.25.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-stack/stack v1.8.1 // indirect @@ -28,11 +33,11 @@ require ( github.com/inconshreveable/log15/v3 v3.0.0-testing.5 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jpillora/backoff v1.0.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -44,9 +49,8 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.ngrok.com/muxado/v2 v2.0.1 // indirect golang.org/x/net v0.30.0 // indirect - golang.org/x/sync v0.8.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.25.0 // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.19.0 // indirect google.golang.org/appengine v1.6.7 // indirect google.golang.org/protobuf v1.35.1 // indirect diff --git a/go.sum b/go.sum index 75f9421..6a79c7c 100644 --- a/go.sum +++ b/go.sum @@ -2,16 +2,22 @@ github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiE github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,8 +44,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= @@ -47,8 +53,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -57,7 +63,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -71,6 +76,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/xdevplatform/chat-xdk/go/chatxdk v0.4.1 h1:pVHpxlYXgrsaw0nlUFlsN1EjeVg054/Au36Gjn7nmZE= +github.com/xdevplatform/chat-xdk/go/chatxdk v0.4.1/go.mod h1:OmIo1i9JwL49LoJJk2ycpAqL6KDDdDM5UsTyLfAB3s8= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -82,21 +89,21 @@ golang.ngrok.com/ngrok v1.13.0/go.mod h1:BKOMdoZXfD4w6o3EtE7Cu9TVbaUWBqptrZRWnVc golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= -golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.25.0 h1:WtHI/ltw4NvSUig5KARz9h521QvRC8RmF/cuYqifU24= golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/store/chatkeys.go b/store/chatkeys.go new file mode 100644 index 0000000..9ced384 --- /dev/null +++ b/store/chatkeys.go @@ -0,0 +1,127 @@ +package store + +import ( + "fmt" + "os" + + "github.com/xdevplatform/xurl/errors" + + "gopkg.in/yaml.v3" +) + +// ─── Chat key types ───────────────────────────────────────────────── + +// ChatKeys holds one user's XChat private key material. PrivateKeysB64 is +// the base64 encoding of the chat-xdk ExportKeys blob (identity key, or +// identity||signing); KeyVersion is the version the matching public key is +// registered under on the account. +type ChatKeys struct { + PrivateKeysB64 string `yaml:"private_keys_b64"` + KeyVersion string `yaml:"key_version"` +} + +// ChatKeyStore persists XChat private keys per user id in a YAML file +// (~/.xurl/keys.yml by default), following the same conventions as the +// token store: 0600 permissions and $HOME resolution. +type ChatKeyStore struct { + Users map[string]*ChatKeys `yaml:"users"` + filePath string + // loadErr records a failed load of an existing file. It gates SaveKeys: + // a corrupt key file must surface as an explicit error, never be + // mistaken for "no keys stored" and silently overwritten. + loadErr error +} + +// NewChatKeyStore loads (or initializes) the chat key store at +// ~/.xurl/keys.yml. +func NewChatKeyStore() *ChatKeyStore { + return NewChatKeyStoreWithPath(KeysFilePath()) +} + +// NewChatKeyStoreWithPath loads (or initializes) a chat key store at the given path. +func NewChatKeyStoreWithPath(path string) *ChatKeyStore { + s := &ChatKeyStore{ + Users: make(map[string]*ChatKeys), + filePath: path, + } + s.loadErr = s.loadFromFile() + return s +} + +// FilePath returns the location of the backing file. +func (s *ChatKeyStore) FilePath() string { + return s.filePath +} + +// LoadErr reports whether an existing key file failed to load (e.g. corrupt +// YAML). Callers must treat this as fatal rather than as an empty store. +func (s *ChatKeyStore) LoadErr() error { + return s.loadErr +} + +// GetKeys returns the stored keys for a user id, or nil if absent. +func (s *ChatKeyStore) GetKeys(userID string) *ChatKeys { + return s.Users[userID] +} + +// SaveKeys stores keys for a user id and persists the store. +func (s *ChatKeyStore) SaveKeys(userID string, keys *ChatKeys) error { + if s.loadErr != nil { + return errors.NewTokenStoreError(fmt.Sprintf("refusing to overwrite %s, which exists but could not be loaded (fix or remove it first): %v", s.filePath, s.loadErr)) + } + if userID == "" { + return errors.NewTokenStoreError("cannot save chat keys without a user id") + } + if s.Users == nil { + s.Users = make(map[string]*ChatKeys) + } + s.Users[userID] = keys + return s.saveToFile() +} + +// DeleteKeys removes keys for a user id and persists the store. +func (s *ChatKeyStore) DeleteKeys(userID string) error { + if _, ok := s.Users[userID]; !ok { + return nil + } + delete(s.Users, userID) + return s.saveToFile() +} + +// ─── Persistence ──────────────────────────────────────────────────── + +func (s *ChatKeyStore) loadFromFile() error { + data, err := os.ReadFile(s.filePath) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return errors.NewIOError(err) + } + var loaded ChatKeyStore + if err := yaml.Unmarshal(data, &loaded); err != nil { + return errors.NewTokenStoreError(fmt.Sprintf("failed to parse chat key store %s: %v", s.filePath, err)) + } + if loaded.Users != nil { + s.Users = loaded.Users + } + return nil +} + +func (s *ChatKeyStore) saveToFile() error { + data, err := yaml.Marshal(s) + if err != nil { + return errors.NewTokenStoreError(fmt.Sprintf("failed to serialize chat key store: %v", err)) + } + // Write-then-rename so a crash mid-write can never leave a truncated + // key file behind. Private key material: owner read/write only. + tmp := s.filePath + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return errors.NewIOError(err) + } + if err := os.Rename(tmp, s.filePath); err != nil { + _ = os.Remove(tmp) + return errors.NewIOError(err) + } + return nil +} diff --git a/store/chatkeys_test.go b/store/chatkeys_test.go new file mode 100644 index 0000000..5881e58 --- /dev/null +++ b/store/chatkeys_test.go @@ -0,0 +1,91 @@ +package store + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func tempChatKeyStore(t *testing.T) *ChatKeyStore { + t.Helper() + return NewChatKeyStoreWithPath(filepath.Join(t.TempDir(), "chat-keys.yaml")) +} + +func TestChatKeyStoreRoundTrip(t *testing.T) { + s := tempChatKeyStore(t) + + assert.Nil(t, s.GetKeys("42")) + + keys := &ChatKeys{ + PrivateKeysB64: "c2VjcmV0LWtleS1tYXRlcmlhbA==", + KeyVersion: "1700000000", + } + require.NoError(t, s.SaveKeys("42", keys)) + + // Reload from disk into a fresh store. + reloaded := NewChatKeyStoreWithPath(s.FilePath()) + got := reloaded.GetKeys("42") + require.NotNil(t, got) + assert.Equal(t, keys.PrivateKeysB64, got.PrivateKeysB64) + assert.Equal(t, keys.KeyVersion, got.KeyVersion) +} + +func TestChatKeyStoreFilePermissions(t *testing.T) { + s := tempChatKeyStore(t) + require.NoError(t, s.SaveKeys("42", &ChatKeys{PrivateKeysB64: "cw=="})) + + info, err := os.Stat(s.FilePath()) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) +} + +func TestChatKeyStoreMultipleUsers(t *testing.T) { + s := tempChatKeyStore(t) + require.NoError(t, s.SaveKeys("1", &ChatKeys{PrivateKeysB64: "YQ==", KeyVersion: "1"})) + require.NoError(t, s.SaveKeys("2", &ChatKeys{PrivateKeysB64: "Yg==", KeyVersion: "2"})) + + reloaded := NewChatKeyStoreWithPath(s.FilePath()) + assert.Equal(t, "YQ==", reloaded.GetKeys("1").PrivateKeysB64) + assert.Equal(t, "Yg==", reloaded.GetKeys("2").PrivateKeysB64) +} + +func TestChatKeyStoreDelete(t *testing.T) { + s := tempChatKeyStore(t) + require.NoError(t, s.SaveKeys("1", &ChatKeys{PrivateKeysB64: "YQ=="})) + require.NoError(t, s.DeleteKeys("1")) + // Deleting a missing user is a no-op. + require.NoError(t, s.DeleteKeys("missing")) + + reloaded := NewChatKeyStoreWithPath(s.FilePath()) + assert.Nil(t, reloaded.GetKeys("1")) +} + +func TestChatKeyStoreRejectsEmptyUserID(t *testing.T) { + s := tempChatKeyStore(t) + assert.Error(t, s.SaveKeys("", &ChatKeys{PrivateKeysB64: "YQ=="})) +} + +func TestChatKeyStoreMissingFileIsEmpty(t *testing.T) { + s := NewChatKeyStoreWithPath(filepath.Join(t.TempDir(), "does-not-exist.yaml")) + assert.Empty(t, s.Users) + assert.NoError(t, s.LoadErr()) +} + +func TestChatKeyStoreCorruptFileRefusesSave(t *testing.T) { + path := filepath.Join(t.TempDir(), "keys.yml") + corrupt := "users: {invalid yaml [" + require.NoError(t, os.WriteFile(path, []byte(corrupt), 0600)) + + s := NewChatKeyStoreWithPath(path) + require.Error(t, s.LoadErr(), "a corrupt file must not look like an empty store") + + // Saving must refuse rather than overwrite whatever remains of the file. + err := s.SaveKeys("42", &ChatKeys{PrivateKeysB64: "YQ=="}) + require.Error(t, err) + data, rerr := os.ReadFile(path) + require.NoError(t, rerr) + assert.Equal(t, corrupt, string(data), "corrupt file must be left untouched") +} diff --git a/store/paths.go b/store/paths.go new file mode 100644 index 0000000..db48e01 --- /dev/null +++ b/store/paths.go @@ -0,0 +1,93 @@ +package store + +import ( + "fmt" + "os" + "path/filepath" +) + +// Names of the files inside the ~/.xurl directory. +const ( + authFileName = "auth.yml" + keysFileName = "keys.yml" +) + +// resolveStoreDir returns ~/.xurl as a directory, creating it if needed and +// migrating the legacy single-file layout on first use: +// +// ~/.xurl (file) -> ~/.xurl/auth.yml (tokens and app credentials) +// +// Migration is rename-based (atomic on the same filesystem) and +// non-destructive: if any step fails, the legacy file is left (or restored) +// where it was and the legacy path is returned so the caller keeps working +// against the old layout. +func resolveStoreDir() string { + homeDir := resolveHomeDir() + root := filepath.Join(homeDir, ".xurl") + tmp := root + ".migrating" + + // Recover from a migration interrupted between its two renames: the + // legacy file is stranded at the temp path and root is missing or an + // empty directory. Finish moving it into place before anything else + // reads the (seemingly empty) store. + if _, err := os.Stat(tmp); err == nil { + authPath := filepath.Join(root, authFileName) + if _, err := os.Stat(authPath); os.IsNotExist(err) { + if err := os.MkdirAll(root, 0700); err == nil { + if err := os.Rename(tmp, authPath); err == nil { + fmt.Fprintf(os.Stderr, "Recovered interrupted migration: %s -> %s\n", tmp, authPath) + } + } + } + } + + info, err := os.Stat(root) + switch { + case err == nil && info.IsDir(): + // Already the new layout. + case err == nil: + // Legacy token file occupies the directory's path: move it aside, + // make the directory, and move it back in as auth.yml. A stranded + // temp file from an unrecoverable earlier attempt is preserved as + // .bak rather than overwritten. + if _, err := os.Stat(tmp); err == nil { + _ = os.Rename(tmp, tmp+".bak") + } + if err := os.Rename(root, tmp); err != nil { + return root + } + if err := os.MkdirAll(root, 0700); err != nil { + _ = os.Rename(tmp, root) + return root + } + if err := os.Rename(tmp, filepath.Join(root, authFileName)); err != nil { + _ = os.RemoveAll(root) + _ = os.Rename(tmp, root) + return root + } + fmt.Fprintf(os.Stderr, "Migrated %s to %s\n", root, filepath.Join(root, authFileName)) + default: + if err := os.MkdirAll(root, 0700); err != nil { + return root + } + } + + return root +} + +// AuthFilePath returns the token-store file inside the resolved ~/.xurl +// directory (migrating any legacy layout first). +func AuthFilePath() string { + dir := resolveStoreDir() + if info, err := os.Stat(dir); err == nil && !info.IsDir() { + // Migration failed and the legacy file layout is still in effect. + return dir + } + return filepath.Join(dir, authFileName) +} + +// KeysFilePath returns the chat-key file inside the resolved ~/.xurl +// directory. +func KeysFilePath() string { + return filepath.Join(resolveStoreDir(), keysFileName) +} diff --git a/store/paths_test.go b/store/paths_test.go new file mode 100644 index 0000000..43b13d5 --- /dev/null +++ b/store/paths_test.go @@ -0,0 +1,98 @@ +package store + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveStoreDirFreshHome(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) + + assert.Equal(t, filepath.Join(tempDir, ".xurl", "auth.yml"), AuthFilePath()) + assert.Equal(t, filepath.Join(tempDir, ".xurl", "keys.yml"), KeysFilePath()) + + info, err := os.Stat(filepath.Join(tempDir, ".xurl")) + require.NoError(t, err) + assert.True(t, info.IsDir()) + assert.Equal(t, os.FileMode(0700), info.Mode().Perm()) +} + +func TestResolveStoreDirMigratesLegacyTokenFile(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) + + legacy := filepath.Join(tempDir, ".xurl") + content := "apps:\n my-app:\n client_id: cid\n" + require.NoError(t, os.WriteFile(legacy, []byte(content), 0600)) + + authPath := AuthFilePath() + assert.Equal(t, filepath.Join(legacy, "auth.yml"), authPath) + + // The legacy file's bytes moved into the directory untouched. + migrated, err := os.ReadFile(authPath) + require.NoError(t, err) + assert.Equal(t, content, string(migrated)) + + info, err := os.Stat(legacy) + require.NoError(t, err) + assert.True(t, info.IsDir()) + + // The token store loads the migrated file. + ts := NewTokenStore() + assert.Equal(t, authPath, ts.FilePath) + require.NotNil(t, ts.Apps["my-app"]) + assert.Equal(t, "cid", ts.Apps["my-app"].ClientID) +} + +func TestKeysFilePathAlongsideMigratedTokenFile(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) + + // A legacy token file migrates into the directory; the chat-key store + // then lands next to it. + require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".xurl"), []byte("apps: {}\n"), 0600)) + assert.Equal(t, filepath.Join(tempDir, ".xurl", "keys.yml"), KeysFilePath()) + + cs := NewChatKeyStore() + require.NoError(t, cs.SaveKeys("42", &ChatKeys{PrivateKeysB64: "c2VjcmV0", KeyVersion: "7"})) + reloaded := NewChatKeyStore() + require.NotNil(t, reloaded.GetKeys("42")) + assert.Equal(t, "7", reloaded.GetKeys("42").KeyVersion) +} + +func TestResolveStoreDirRecoversInterruptedMigration(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) + + // A crash between the migration's two renames leaves the legacy file at + // the temp path and no ~/.xurl at all. + content := "apps:\n my-app:\n client_id: cid\n" + require.NoError(t, os.WriteFile(filepath.Join(tempDir, ".xurl.migrating"), []byte(content), 0600)) + + authPath := AuthFilePath() + assert.Equal(t, filepath.Join(tempDir, ".xurl", "auth.yml"), authPath) + migrated, err := os.ReadFile(authPath) + require.NoError(t, err) + assert.Equal(t, content, string(migrated)) + _, err = os.Stat(filepath.Join(tempDir, ".xurl.migrating")) + assert.True(t, os.IsNotExist(err), "temp file must be consumed by the recovery") +} + +func TestResolveStoreDirIdempotent(t *testing.T) { + tempDir := t.TempDir() + t.Setenv("HOME", tempDir) + + first := resolveStoreDir() + require.NoError(t, os.WriteFile(filepath.Join(first, "auth.yml"), []byte("apps: {}\n"), 0600)) + second := resolveStoreDir() + assert.Equal(t, first, second) + + data, err := os.ReadFile(filepath.Join(first, "auth.yml")) + require.NoError(t, err) + assert.Equal(t, "apps: {}\n", string(data)) +} diff --git a/store/tokens.go b/store/tokens.go index 535f764..5eb2be4 100644 --- a/store/tokens.go +++ b/store/tokens.go @@ -69,6 +69,8 @@ type storeFile struct { // ─── Legacy JSON structure (for migration) ────────────────────────── +// legacyStore is the pre-v1.0 single-app JSON layout of the token file; +// loadFromData converts it to the YAML multi-app format on first load. type legacyStore struct { OAuth2Tokens map[string]Token `json:"oauth2_tokens"` OAuth1Token *Token `json:"oauth1_tokens,omitempty"` @@ -98,17 +100,19 @@ func resolveHomeDir() string { return homeDir } -// Creates a new TokenStore, loading from ~/.xurl (auto-migrating legacy JSON). +// Creates a new TokenStore, loading from ~/.xurl/auth.yml (migrating a +// legacy single-file ~/.xurl on first use). func NewTokenStore() *TokenStore { return NewTokenStoreWithCredentials("", "") } // NewTokenStoreWithCredentials creates a TokenStore and backfills the given -// client credentials into any app that was migrated without them (i.e. legacy -// JSON migration where CLIENT_ID / CLIENT_SECRET came from env vars). +// client credentials into any app that has tokens but no stored client +// ID/secret (e.g. apps authenticated with CLIENT_ID / CLIENT_SECRET coming +// from env vars), so later refreshes work without the env vars present. func NewTokenStoreWithCredentials(clientID, clientSecret string) *TokenStore { homeDir := resolveHomeDir() - filePath := filepath.Join(homeDir, ".xurl") + filePath := AuthFilePath() store := &TokenStore{ Apps: make(map[string]*App), diff --git a/store/tokens_test.go b/store/tokens_test.go index 4ed671e..1ac83c8 100644 --- a/store/tokens_test.go +++ b/store/tokens_test.go @@ -304,31 +304,17 @@ func TestCredentialBackfill(t *testing.T) { defer os.RemoveAll(tempDir) t.Setenv("HOME", tempDir) - xurlPath := filepath.Join(tempDir, ".xurl") - - // Write a legacy JSON file (no credentials stored) - legacy := map[string]interface{}{ - "oauth2_tokens": map[string]interface{}{ - "user1": map[string]interface{}{ - "type": "oauth2", - "oauth2": map[string]interface{}{ - "access_token": "at", - "refresh_token": "rt", - "expiration_time": 9999, - }, - }, - }, - } - data, _ := json.MarshalIndent(legacy, "", " ") - os.WriteFile(xurlPath, data, 0600) - // First load without credentials — migration happens, no backfill + // An app with tokens but no stored client credentials (authenticated + // with CLIENT_ID/CLIENT_SECRET coming from env vars). s1 := NewTokenStore() + require.NoError(t, s1.AddApp("default", "", "")) + require.NoError(t, s1.SaveOAuth2TokenForApp("default", "user1", "at", "rt", 9999)) app1 := s1.GetApp("default") require.NotNil(t, app1) assert.Empty(t, app1.ClientID, "Should have no client ID without backfill") - // Now load WITH credentials — should backfill the migrated app + // Loading WITH credentials should backfill the credential-less app. s2 := NewTokenStoreWithCredentials("env-id", "env-secret") app2 := s2.GetApp("default") require.NotNil(t, app2) @@ -475,7 +461,7 @@ func TestLegacyJSONMigration(t *testing.T) { t.Setenv("HOME", tempDir) - // Write a legacy JSON .xurl file + // Write a pre-v1.0 legacy JSON .xurl file legacy := map[string]interface{}{ "oauth2_tokens": map[string]interface{}{ "legacyuser": map[string]interface{}{ @@ -514,8 +500,10 @@ func TestLegacyJSONMigration(t *testing.T) { require.NotNil(t, bearer) assert.Equal(t, "leg-bearer", bearer.Bearer) - // File should now be YAML - raw, _ := os.ReadFile(xurlPath) + // File should now be YAML, living inside the ~/.xurl directory after + // the legacy-file migration. + assert.Equal(t, filepath.Join(xurlPath, "auth.yml"), store.FilePath) + raw, _ := os.ReadFile(store.FilePath) assert.Contains(t, string(raw), "apps:") assert.Contains(t, string(raw), "default_app:") }