diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 96a50c2..7f733f3 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -25,8 +25,14 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: go.mod + go-version: '1.27.2' cache: true + - name: Verify security toolchain + shell: bash + run: ./scripts/check-toolchain.sh + - name: Install vulnerability scanner + shell: bash + run: go install golang.org/x/vuln/cmd/govulncheck@v1.9.0 - name: Build release shell: bash env: @@ -34,10 +40,14 @@ jobs: GOARCH: ${{ matrix.arch }} CGO_ENABLED: ${{ matrix.cgo }} run: | + ./scripts/check-toolchain.sh + govulncheck ./cmd/line ./pkg/line/... ./pkg/e2ee ./pkg mkdir -p dist extension="" if [ "$GOOS" = "windows" ]; then extension=".exe"; fi - go build -trimpath -ldflags "-s -w -X main.version=$GITHUB_REF_NAME -X main.distribution=standalone" -o "dist/line$extension" ./cmd/line + go build -trimpath -ldflags "-w -X main.version=$GITHUB_REF_NAME -X main.distribution=standalone" -o "dist/line$extension" ./cmd/line + go version -m "dist/line$extension" + govulncheck -mode=binary "dist/line$extension" cp LICENSE README.md CLI.md dist/ cd dist if command -v sha256sum >/dev/null; then diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 30afd9c..5481a44 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -8,6 +8,12 @@ on: permissions: contents: read +env: + LINE_CLI_LIVE_READ: "0" + LINE_CLI_LIVE_WATCH: "0" + LINE_CLI_LIVE_SECURITY_READ: "0" + LINE_CLI_LIVE_SECURITY_SEND: "0" + jobs: crypto: name: Runner and LTSM regressions @@ -19,8 +25,11 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: go.mod + go-version: '1.27.2' cache: true + - name: Verify security toolchain + shell: bash + run: ./scripts/check-toolchain.sh - name: Test signing and encryption runtime run: go test -count=1 -timeout=20m ./pkg ./pkg/ltsm @@ -34,8 +43,11 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: - go-version-file: go.mod + go-version: '1.27.2' cache: true + - name: Verify security toolchain + shell: bash + run: ./scripts/check-toolchain.sh - name: Test CLI and protocol packages shell: bash run: | @@ -77,7 +89,13 @@ jobs: run: go test -v -count=1 ./internal/session -run '^TestDPAPI' - name: Build CLI shell: bash - run: go build -trimpath -o bin/ ./cmd/line + run: ./build.sh + - name: Scan source and built binary + shell: bash + run: | + binary=./bin/line + if [ "$RUNNER_OS" = "Windows" ]; then binary=./bin/line.exe; fi + ./scripts/security-check.sh "$binary" - name: Validate POSIX installer shell: bash run: sh -n scripts/install-release.sh diff --git a/AGENTS.md b/AGENTS.md index 40526ab..ac43272 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ This repository contains no Matrix connector or homeserver integration. ## Build and checks -Requirements: Go 1.26+. macOS needs CGO and Xcode Command Line Tools for Keychain. +Requirements: Go 1.27.2. macOS needs CGO and Xcode Command Line Tools for Keychain. Linux and Windows support `CGO_ENABLED=0`. No libolm or ffmpeg is required. ```sh @@ -43,7 +43,10 @@ integration requirements, and CI coverage. ## Behavior and validation -Preserve explicit capability checks before plaintext fallback. Missing or malformed +Require encryption by default; plaintext needs per-command `--allow-plaintext` +or an affirmative interactive confirmation plus explicit account/server capability +evidence. Scripts, JSON output/errors, and `--stdin` never prompt. Preserve explicit +capability checks before plaintext fallback. Missing or malformed keys and transport failures must not silently downgrade encryption. Only an explicit send may register a group key, and it needs complete membership. Remote mutations are attempted once with a persisted request sequence; reads can diff --git a/README.md b/README.md index 2718bc0..df73dba 100644 --- a/README.md +++ b/README.md @@ -193,7 +193,9 @@ See [JSON fields and exit codes](docs/CLI.md#json-output-and-exit-codes) and - **Encryption:** Letter Sealing is used when supported. Missing or malformed keys and transport failures never silently downgrade an encrypted send to plaintext. - JSON send results report encryption status. + When LINE explicitly reports encryption unavailable, terminal sends ask for + confirmation; scripts require `--allow-plaintext`. JSON send results report + encryption status. - **Storage:** macOS Keychain, Linux AES-GCM session files with keys in Secret Service, or Windows current-user DPAPI. Headless Linux uses host-key storage. - **Session recovery:** access tokens refresh automatically when saved refresh @@ -240,7 +242,7 @@ error. Omit passwords, tokens, QR login values, and private messages. ## Build and contribute -Source builds require Git and **Go 1.26+**. macOS also needs Xcode Command Line +Source builds require Git and **Go 1.27.2**. macOS also needs Xcode Command Line Tools and CGO for Keychain support. On macOS or Linux: ```sh diff --git a/build.sh b/build.sh index 29332cf..e973bd9 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,7 @@ #!/bin/sh set -eu cd "$(dirname "$0")" +./scripts/check-toolchain.sh version=${VERSION:-$(git describe --tags --match 'v*' --match 'cli-v*' --always --dirty 2>/dev/null || printf dev)} mkdir -p bin -"${GO:-go}" build -trimpath -ldflags "-s -w -X main.version=$version" -o bin/ "$@" ./cmd/line +"${GO:-go}" build -trimpath -ldflags "-w -X main.version=$version" -o bin/ "$@" ./cmd/line diff --git a/cmd/line/signals_test.go b/cmd/line/signals_test.go index d57c818..260ee89 100644 --- a/cmd/line/signals_test.go +++ b/cmd/line/signals_test.go @@ -54,7 +54,7 @@ func TestSignalInputHelper(t *testing.T) { manager := session.NewManager(store) manager.NewClient = func(string) session.API { return &cancelledSendAPI{cancel: func() {}, confirmed: true} } a := &cli.App{Context: s.ctx, Out: os.Stdout, Err: os.Stderr, Manager: manager, Lock: func() (func(), error) { return func() {}, nil }} - err = a.Run([]string{"--error-format=json", "send", "id:c-group", "--text", "synthetic", "--json"}) + err = a.Run([]string{"--error-format=json", "send", "id:c-group", "--allow-plaintext", "--text", "synthetic", "--json"}) case "poll": io.WriteString(os.Stderr, "Waiting for scan\n") <-s.ctx.Done() @@ -179,10 +179,10 @@ func TestCommandExitPreservesCancelledMutationDetails(t *testing.T) { } var want string if confirmed { - _, err = client.SendFileContext(ctx, "c-group", messaging.Attachment{Name: "test.txt"}, "") + _, err = client.SendFileWithOptionsContext(ctx, "c-group", messaging.Attachment{Name: "test.txt"}, "", messaging.SendOptions{AllowPlaintext: true}) want = "message 123 was created" } else { - _, err = client.SendContext(ctx, "c-group", "synthetic") + _, err = client.SendMentionsWithOptionsContext(ctx, "c-group", "synthetic", "", nil, messaging.SendOptions{AllowPlaintext: true}) want = "delivery may have occurred" } var out bytes.Buffer diff --git a/docs/CLI.md b/docs/CLI.md index b20282f..a3ca0f0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -369,20 +369,59 @@ video, and audio sent with `--file` appear as ordinary files. 20 MiB, 40 million decoded pixels, and 16384 pixels on either axis. It applies EXIF orientation, removes metadata, flattens transparency onto white, and converts to JPEG at quality 60 with at most 1280ร—1280 total pixels (aspect ratio preserved). -Encrypted sends also upload a JPEG preview fitting 384ร—384 at quality 30. Use `--file` to preserve original bytes. GIF, animated PNG, BMP, WebP, and native audio/video sends are unsupported. Image preparation is local and precedes session access. `--image` cannot be combined with text, stdin, file, mentions, or `--reply-to`: Chrome's image path does not establish reply support. +Encrypted `--image` sends are disabled because the current full-image/preview +format reuses an encryption stream. They fail before uploads or sends. Generic +`--file` sends preserve original bytes and remain available with Letter Sealing. Image sends use the server's explicit encrypted/plaintext media capability. -A missing or invalid capability fails locally before mutation. Full encrypted -upload, preview upload, and message send each happen once. A failed upload after -plaintext message creation retains that confirmed message ID in structured errors; +A missing or invalid capability fails locally before mutation. Plaintext-capable +images require interactive confirmation or `--allow-plaintext`; message creation +and upload each happen once. +A failed upload after message creation retains that confirmed ID in structured errors; unknown outcomes require checking LINE before retrying. See [image protocol evidence](protocol/endpoints/image-send.md) for details. Live interoperability has not been verified. +Sending requires Letter Sealing by default. If the account or server explicitly +reports it unavailable, a terminal send asks for consent before any remote action: + +```text +This chat sends files without end-to-end encryption. LINE can read the file. +Send this file? [y/N]: +``` + +Enter `y` or `yes` to permit this send; Enter, `n`, or any other answer cancels. +The confirmation applies only to the current command. The session lock is +released while waiting; the account and capabilities are checked again afterward. +Encrypted sends do not ask. The prompt names the message, file, or image. + +Scripts, `--json`, `--error-format=json`, and `--stdin` never prompt. They return +`encryption_required` without sending unless `--allow-plaintext` was supplied. +Use that flag on a single command to permit content visible to LINE after +HTTPS termination. This flag applies to text, stdin, mentions, replies, files, +and plaintext-capable images; it still uses encryption when available. It does +not override the encrypted-image restriction or authorize fallback on errors. + +For `--file`, the server's file-specific media capability must also permit +encryption. A chat can support encrypted text while allowing only plaintext +files. In that case, the default file send stops before upload or message creation; +Interactive confirmation or `--allow-plaintext` is required to send the file +without Letter Sealing. +Missing, unknown, or unreadable media capabilities never authorize plaintext. +The CLI names the affected message, file, or image and explains the next step +without protocol details. For example, a script without consent reports: + +```text +This chat doesn't support end-to-end encryption for this file. Nothing was sent. +To send it anyway, add --allow-plaintext. LINE can read the file. +``` + +Send confirmations use `end-to-end encrypted` or `not end-to-end encrypted`. + The CLI uses Letter Sealing when the conversation supports it. Missing keys, incomplete group membership, or a transport failure stop an encrypted send instead of silently sending plaintext. An explicit group send can register a diff --git a/docs/ERRORS.md b/docs/ERRORS.md index 248b1b2..67c1cf2 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -154,3 +154,11 @@ preview (`preview_upload`), and `send` separately. An upload may leave an object without creating a message. Plain image upload follows message creation, so its failure retains the confirmed message receipt just like a generic-file upload. Never infer that an upload or send can safely be retried from a transport error. + +Send policy errors: `encryption_required` means Letter Sealing is unavailable +and the command did not permit plaintext. Interactive terminal sends offer +per-command confirmation before remote actions; declining returns `cancelled`. +Scripts, JSON output/errors, and `--stdin` require `--allow-plaintext` and never +prompt. `encrypted_image_unavailable` means +native encrypted image sending is disabled until a secure compatible preview +format is established. Neither error authorizes an automatic resend or downgrade. diff --git a/docs/SECURITY-CHANGES.md b/docs/SECURITY-CHANGES.md new file mode 100644 index 0000000..8ce7ecd --- /dev/null +++ b/docs/SECURITY-CHANGES.md @@ -0,0 +1,139 @@ +# Security changes (unreleased) + +- Generic encrypted files keep attachment key material and filenames exclusively + in the authenticated Letter Sealing payload. Historical downloads still accept + compatible old envelopes with conflict checks. +- Encrypted native image sending is disabled before uploads or key registration: + the existing image/preview representation reuses an AES-CTR encryption stream. + Generic encrypted `--file` is available only with an explicit encrypted-file + media capability. Plaintext-only file capability requires per-send consent. +- All sends require encryption by default. An affirmative terminal confirmation + or per-command `--allow-plaintext` permits plaintext only after an explicit + account/server capability signal. Scripts, JSON output/errors, and `--stdin` + never prompt. HTTPS still protects + the connection, but LINE can read this content. The option does not suppress + key validation, transport errors, or the encrypted-image restriction. + +Interactive confirmation occurs only after preparation stops with +`encryption_required`, before any sequence reservation or remote mutation step. +The credential lock is released during the prompt. On acceptance, the account +identity is checked and a fresh messaging client repeats capability validation. +Declining, incomplete input, and cancellation do not send anything. Sends, +uploads, and group-key registrations are never replayed to obtain consent. +Validation on 2026-10-19: synthetic terminal/script consent tests, the complete +CLI/protocol race suite, go vet, Staticcheck, and the native build/help check +passed. This UX change required no live sends. + +Protocol evidence: official LINE Chrome extension 3.7.2, package SHA-256 +`3722ab02edb0aba49c229399ee8df5b4b5a38c60b76efadadfbae395015eb859`. +Shared helpers `gL`/`EL` move media key/name fields into encrypted payloads; +`yL` restores them after decryption. `_L`/`CL` derive and restart the same CTR +stream for each object using the same key material. No secure independent +preview representation was established. Source remains copyright LY Corporation. +Initial validation used synthetic data; the authorized live follow-up and its +limitations are recorded below. + +Authenticated LINE requests now reject redirects at dispatch, even when a caller +supplies an HTTP client with a permissive redirect callback. A redirect leaves a +mutation outcome unknown and never causes a replay. + +Network response budgets apply after transport decompression: RPC/login/page +metadata 16 MiB (QR retains its stricter 1 MiB), OBS metadata/upload replies +64 KiB, HTTP error bodies 4 KiB, default media 20 MiB plus the authentication tag. +SSE allows 1 MiB per line, 4 MiB and 4096 lines per event, including comments and +event names. Oversized events never reach delivery/checkpoint callbacks. +Synthetic maximum-count history pages fit the RPC budget; live server maximum +keychain/history sizes have not been measured. Legitimate oversized responses +fail explicitly and may require a reviewed budget adjustment. + +Builds select Go 1.27.2, including CI/release jobs. `build.sh` checks the actual +compiler selected by `GO`/`GOTOOLCHAIN`; older compilers and unreviewed versions +fail before building. The minimum module directive is also Go 1.27.2. This +replaces the earlier Go 1.26+ requirement. To select the reviewed compiler: +`GOTOOLCHAIN=go1.27.2 ./build.sh`. Future toolchain updates should change the +module, CI pins, and compiler guard together after advisory review. + +Run `./scripts/security-check.sh ./bin/line` for source and binary scans using +pinned govulncheck v1.9.0. Release jobs scan each target before packaging. +[GO-2026-6611](https://pkg.go.dev/vuln/GO-2026-6611) affects HTTP/2 clients; +[Go 1.27.2](https://go.dev/doc/devel/release#go1.27.0) contains its fix. Scanner +findings require applicability review; there is no standard-library exemption. + +Audit triage on 2026-10-18 with govulncheck v1.9.0 and Go 1.27.2: source scan +reported no reachable or imported-package vulnerabilities. Module-only findings +were GO-2026-6629 (`x/text/secure/precis`), GO-2026-6355, GO-2026-6354 and +GO-2026-6303 (`x/crypto/ssh`), and GO-2026-5932 (`x/crypto/openpgp`). The CLI and +scanned public libraries do not import those packages. These are recorded as +inapplicable to the current dependency graph, not suppressed scanner failures. + +Release executables retain their symbol tables (`-w`, without `-s`) so binary +scans include inlined-symbol analysis. govulncheck v1.9.0 conservatively treats +all vulnerable module symbols as present in stripped executables. Retaining +symbols made the rebuilt macOS binary scan pass without advisory exemptions; +DWARF debug data remains omitted. + +Validation on 2026-10-18 at source revision `d8df371`: the complete CLI/protocol +race suite, runner/LTSM suites, go vet, and Staticcheck v0.8.2 passed. The real +synthetic recipient decrypted the sealed file key/name, authenticated the +attachment, and rejected a tampered payload. Bounded-response and SSE-line +fuzzing passed 15 seconds each (291,209 and 292,307 cases respectively). + +All six release targets were rebuilt with Go 1.27.2: Linux and Windows +amd64/arm64 without CGO, and macOS amd64/arm64 with CGO. Compiler identity was +checked in each executable; target-specific source and binary scans using +govulncheck v1.9.0 reported zero reachable or imported-package vulnerabilities. +The module-only advisory triage above still applies. The native macOS arm64 +CLI also passed its help smoke test. The selected-compiler guard passed +synthetic checks for accepted/rejected versions and a custom `GO` executable. + +Cross-builds do not replace native CI. Linux Secret Service and Windows DPAPI +integration, and the complete native CI matrix, remain release gates. The +initial validation above did not include live LINE actions. + + +Live follow-up (completed 2026-10-19): one explicitly authorized synthetic text +was sent encrypted and read back successfully in the designated test group. +The session was readable from macOS Keychain with native protection verified; +saved Letter Sealing keys loaded and the text send persisted its request sequence. +This checks the active session, not protection against a compromised OS account +or availability after reboot. + +The file test failed, including one separately authorized file-only retry. On +that retry the encrypted upload returned HTTP 201 with an object ID; the file +message returned HTTP 400 / TalkException, and no new file message appeared in +recent history. The observed outgoing envelope contained neither outer key +material nor filename, and no plaintext fallback occurred. An uploaded synthetic +object may remain. No raw traffic, tokens, encrypted chunks, or private message +contents were saved as capture artifacts. + +A read-only capability check showed file flow 1 (plaintext-only), despite working +text encryption. Static Chrome 3.7.2 evidence confirms type-specific media checks +also apply to files. The CLI now checks file type 14 before uploading. The live +refusal check passed with `encryption_required`, zero uploads/sends/registrations, +and an unchanged request sequence. No plaintext file was sent in that phase. Successful live +encrypted file delivery and authenticated download remain unverified on flow 2; +the earlier synthetic recipient roundtrip does not establish that live result. + + +Authorized plaintext follow-up (2026-10-19): after separate explicit consent, +one `line-security-test.txt` was sent with `--allow-plaintext`. The receipt and +history both reported `encrypted=false`, no group key was registered, the +filename matched, and the downloaded 28 bytes exactly matched the synthetic +fixture. This verifies the plaintext path only; it is not an encrypted-file +interoperability result. + +The additional read-only negotiation probe followed Chrome's order: retrieve +the latest group-key capability, then determine media flow. The selected group +returned spec version 1, included text in `allowedTypes`, excluded file type 14, +and continued to report file flow 1. No missing client-side negotiation step +was established by this check. Do not force encrypted-file sending or change +group keys to bypass those reported capabilities. + +Direct-chat follow-up (2026-10-19): a separately authorized test contact also +advertised file flow 1. The guarded default send refused with zero uploads, +sends, or registrations and an unchanged request sequence. After explicit +per-send plaintext consent, one synthetic `line-security-test.txt` was sent +successfully. Its receipt/history reported `encrypted=false`, the filename +matched, and the downloaded 28 bytes matched the fixture exactly. This verifies +generic text-file delivery in that direct chat; direct chats do not necessarily +advertise encrypted-file support. No additional text message was sent. diff --git a/docs/TESTING.md b/docs/TESTING.md index 0bf6292..2ef29f4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -2,7 +2,7 @@ Tests live beside the packages they exercise. Ordinary tests use synthetic data, fake APIs, and temporary storage. They do not require a LINE login. Requirements -are Go 1.26+; macOS also needs CGO and Xcode Command Line Tools for Keychain. +are Go 1.27.2; macOS also needs CGO and Xcode Command Line Tools for Keychain. Linux and Windows support `CGO_ENABLED=0`. ## Test suites @@ -145,9 +145,24 @@ actions, plus a saved LINE session. The current opt-ins are `LINE_CLI_LIVE_READ=1` for `TestLiveHistory` and `LINE_CLI_LIVE_WATCH=1` for `TestLiveWatch`. History checks read real conversations; watch checks read real events and update the saved watch cursor. Neither test -sends messages, registers group keys, or marks messages read. Any future live -mutation test must also have explicit authorization for its recipient, content, -and actions. Do not print credentials, private message data, encrypted chunks, +sends messages, registers group keys, or marks messages read. `LINE_CLI_LIVE_SECURITY_READ=1` enables +`TestLiveSecurityMediaFlow` for the explicit `LINE_CLI_LIVE_SECURITY_CHAT` ID. It +reads group negotiation capability (without registration), then media capability, +and for plaintext-only files verifies default +refusal with a transport guard that prevents mutation requests. + +`LINE_CLI_LIVE_SECURITY_SEND=1` enables `TestLiveSecuritySend`. This requires +separate explicit authorization for the selected chat, text `Security test from +LINE CLI`, and `line-security-test.txt` containing that text plus a newline, +including upload, possible group-key registration, history reads, and download. +It checks encrypted-file capability before sending. Run only that named test +with `-count=1`; never enable it for ordinary suites. After an uncertain failure, +inspect history and obtain retry authorization. `LINE_CLI_LIVE_SECURITY_TEXT_ID` +can reference a confirmed text receipt for an authorized file-only retry. +The test checks envelopes in memory and emits only status/allowlisted diagnostics. + +Any future live mutation test must likewise have explicit authorization for its +recipient, content, and actions. Do not print credentials, private message data, encrypted chunks, or raw server bodies. ## Shared helpers and fixtures @@ -198,3 +213,25 @@ The original eight audit probes now have permanent corrected-behavior coverage: `internal/cli/history_test.go`/`details_test.go` (CLI JSON order/details), `history_test.go` (reply identity and timestamp cursors), `content_test.go` (authenticated filenames, mentions and locations), and protocol `history_test.go` (delivered timestamps). No `/tmp` audit overlay is needed. + +## Security regression and release checks + +Use `GOTOOLCHAIN=go1.27.2` when the host Go version differs. The build guard +checks the compiler selected by `GO`, and rejects an unreviewed toolchain. + +```sh +./scripts/check-toolchain.sh +./build.sh +./scripts/security-check.sh ./bin/line +go test ./pkg/line -run '^$' -fuzz '^FuzzBoundedResponse$' -fuzztime=15s +go test ./pkg/line -run '^$' -fuzz '^FuzzSSELine$' -fuzztime=15s +``` + +Transport-limit tests include a loopback-only HTTP server to verify gzip and +chunked transfer decoding; they need permission to bind a local ephemeral port. +All request payloads and credentials are synthetic. Security tests cover outer +file envelopes, recipient attachment authentication, pre-upload image refusal, +plaintext consent (terminal accept/decline/EOF, released prompt locks, changed +accounts, fresh preparation, script suppression, and no mutation replay), redirect +rejection, and response/SSE budgets. Cross builds do +not substitute for native Linux keyring or Windows DPAPI integration tests. diff --git a/docs/protocol/endpoints/image-send.md b/docs/protocol/endpoints/image-send.md index fef6ae9..e67c387 100644 --- a/docs/protocol/endpoints/image-send.md +++ b/docs/protocol/endpoints/image-send.md @@ -95,3 +95,14 @@ server rejection or acceptance of image reply fields. The CLI therefore rejects `--image --reply-to` before session access instead of claiming unverified support. Text replies and existing generic-file behavior are unchanged. Text/stdin/file, mentions, and image inputs cannot be combined. + +## Security restriction (2026-10-18) + +The encrypted native-image send path is disabled before uploads, group-key +registration, or sequence reservation. Reinspection of the version-matched +package above confirmed `_L` deterministically derives a counter and `CL` +restarts AES-CTR with it for each object. The full image and preview therefore +share a keystream. No independent preview nonce/key or compatible secure +representation was established. Receiving historical images is unchanged. +Do not restore encrypted sends until both sender and recipient semantics of a +secure representation are verified. Plaintext capability handling is separate. diff --git a/docs/protocol/endpoints/media-download.md b/docs/protocol/endpoints/media-download.md index 5f8c548..fb3f911 100644 --- a/docs/protocol/endpoints/media-download.md +++ b/docs/protocol/endpoints/media-download.md @@ -89,3 +89,47 @@ returned records, and 30 seconds; see [history](history.md). Plaintext downloads remain limited to 20 MiB. Full verified bytes are buffered before publication to a file or stdout. No live capture was needed to resolve these code paths; live interoperability has not been claimed. + +## Outgoing generic-file confidentiality + +On 2026-10-18, rechecked the same official Chrome 3.7.2 package documented in +[image send](image-send.md) (SHA-256 +`3722ab02edb0aba49c229399ee8df5b4b5a38c60b76efadadfbae395015eb859`). +Its shared `gL` packages `ENC_KM` and `FILE_NAME` as `keyMaterial` and `fileName`; +`EL` removes both outer fields when constructing the encrypted message. `yL` +restores them only after decryption. These helpers apply to encrypted media, +not just images. The CLI now follows this envelope for generic files while +retaining historical incoming metadata conflict checks. No live send was used. + + +## File capability validation after live inspection + +The same Chrome 3.7.2 source uses `sP` and `checkAndGetMediaMessageFlow` for +all media types, including file type 14. `encryptMessage` encrypts media only +when its type-specific flow is 2. The CLI had applied this check only to images; +it now checks `flowMap["14"]` before generic-file uploads too. An explicit 1 +requires per-send plaintext consent; missing/unknown/error responses stop sends. + +In an authorized test group, LINE advertised file flow 1 even though encrypted +text worked. An encrypted object upload succeeded (HTTP 201), but the subsequent +file message returned HTTP 400 / TalkException. No file message appeared in the +inspected recent history. This exposed the missing capability check; the exact +server exception code was not retained, so the HTTP rejection alone does not +prove which field LINE rejected. The corrected default path is verified to +refuse before upload, registration, message send, or sequence reservation. +Encrypted-file delivery/download remains unverified on a live flow-2 chat. + +A subsequent read-only probe fetched the latest group-key capability before the +media-flow query, matching Chrome's negotiation order. The tested group returned +spec version 1, allowed text, excluded file type 14, and still returned file flow +1. With separate explicit plaintext consent, one synthetic file then completed +message creation, upload, history lookup, and byte-identical download. Its +receipt/history reported `encrypted=false`; no group key was registered. This +establishes plaintext interoperability for that group, not encrypted-file support. + +A separately authorized direct-chat test on 2026-10-19 also returned file flow +1. Default sending stopped before any upload/send or sequence change. With +explicit plaintext consent, one synthetic `.txt` file completed message creation, +upload, history lookup, and a byte-identical 28-byte download. Its receipt and +history reported `encrypted=false`. Direct-chat type alone therefore does not +establish an encrypted-file capability. diff --git a/go.mod b/go.mod index 0e92564..983885e 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/kongesque/line-cli -go 1.26.0 +go 1.27.2 require golang.org/x/crypto v0.54.0 diff --git a/internal/cli/chats_test.go b/internal/cli/chats_test.go index 62f2a28..4c8be21 100644 --- a/internal/cli/chats_test.go +++ b/internal/cli/chats_test.go @@ -94,11 +94,11 @@ func TestNameResolutionExactUniqueAndNoSendOnAmbiguity(t *testing.T) { t.Fatalf("got %q, %v", got, err) } if tc.want == "" { - if err := a.Run([]string{"send", tc.selector, "--text", "test"}); err == nil || f.sent != nil { + if err := a.Run([]string{"send", tc.selector, "--allow-plaintext", "--text", "test"}); err == nil || f.sent != nil { t.Fatal("unresolved name sent") } } else if tc.want == "u-one" { - if err := a.Run([]string{"send", tc.selector, "--text", "test"}); err != nil { + if err := a.Run([]string{"send", tc.selector, "--allow-plaintext", "--text", "test"}); err != nil { t.Fatal(err) } if f.sent == nil || f.sent.To != tc.want { diff --git a/internal/cli/diagnostics_test.go b/internal/cli/diagnostics_test.go index 1a72706..2813155 100644 --- a/internal/cli/diagnostics_test.go +++ b/internal/cli/diagnostics_test.go @@ -44,7 +44,7 @@ func TestSendDiagnosticSurvivesSessionAndMachineWrapping(t *testing.T) { store := &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} a.Manager.Store = store a.Manager.NewClient = func(string) session.API { return f } - err := a.Run([]string{"--error-format=json", "send", "id:c-group", "--text", "synthetic", "--json"}) + err := a.Run([]string{"--error-format=json", "send", "id:c-group", "--allow-plaintext", "--text", "synthetic", "--json"}) d := machineDetail(t, err) if d.Code != "remote_failed" || d.Stage != "send" || d.Diagnostic == nil || d.Diagnostic.Kind != tc.kind || d.Mutation == nil || d.Mutation.Outcome != fault.Unknown || !d.Mutation.Attempted || calls != 1 || out.Len() != 0 { t.Fatalf("incorrect send result: %+v; calls %d", d, calls) diff --git a/internal/cli/errors_test.go b/internal/cli/errors_test.go index b168932..4d17544 100644 --- a/internal/cli/errors_test.go +++ b/internal/cli/errors_test.go @@ -48,7 +48,7 @@ func TestJSONErrorFlagDoesNotConsumeMessageText(t *testing.T) { f := &messageAPI{} a.Manager.Store = &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} a.Manager.NewClient = func(string) session.API { return f } - if err := a.Run([]string{"--error-format", "json", "send", outcomeChat, "--text", "--error-format=json", "--json"}); err != nil { + if err := a.Run([]string{"--error-format", "json", "send", outcomeChat, "--allow-plaintext", "--text", "--error-format=json", "--json"}); err != nil { t.Fatal(err) } if f.sent.Text != "--error-format=json" { @@ -77,7 +77,7 @@ func TestConfirmedSendOutputFailureRetainsReceipt(t *testing.T) { a.Manager.Store = &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} a.Manager.NewClient = func(string) session.API { return f } a.Out = failedWriter{errors.New("secret writer details")} - args := []string{"--error-format=json", "send", outcomeChat, "--text", "synthetic"} + args := []string{"--error-format=json", "send", outcomeChat, "--allow-plaintext", "--text", "synthetic"} if jsonOutput { args = append(args, "--json") } @@ -145,7 +145,7 @@ func TestSignalAfterSuccessRetainsConfirmedReceipt(t *testing.T) { f := &messageAPI{} a.Manager.Store = &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} a.Manager.NewClient = func(string) session.API { return f } - if err := a.Run([]string{"--error-format=json", "send", outcomeChat, "--text", "synthetic", "--json"}); err != nil { + if err := a.Run([]string{"--error-format=json", "send", outcomeChat, "--allow-plaintext", "--text", "synthetic", "--json"}); err != nil { t.Fatal(err) } detail := machineDetail(t, a.SignalError()) diff --git a/internal/cli/images_test.go b/internal/cli/images_test.go index 2dedde6..984b468 100644 --- a/internal/cli/images_test.go +++ b/internal/cli/images_test.go @@ -72,7 +72,7 @@ func TestImageAndGenericFileRemainDistinct(t *testing.T) { api := &imageSendAPI{} a.Manager.Store = &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} a.Manager.NewClient = func(string) session.API { return api } - if err := a.Run([]string{"send", "id:u-peer", flag, path, "--json"}); err != nil { + if err := a.Run([]string{"send", "id:u-peer", "--allow-plaintext", flag, path, "--json"}); err != nil { t.Fatal(err) } var result messaging.SendResult @@ -98,7 +98,7 @@ func TestPlainImageUploadFailurePreservesJSONReceipt(t *testing.T) { api := &imageSendAPI{err: errors.New("private raw body")} a.Manager.Store = &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} a.Manager.NewClient = func(string) session.API { return api } - err := a.Run([]string{"--error-format=json", "send", "id:u-peer", "--image", path, "--json"}) + err := a.Run([]string{"--error-format=json", "send", "id:u-peer", "--allow-plaintext", "--image", path, "--json"}) if err == nil || out.Len() != 0 || api.uploads != 1 { t.Fatal("incorrect failed-upload result") } diff --git a/internal/cli/messages.go b/internal/cli/messages.go index 4b00148..df79275 100644 --- a/internal/cli/messages.go +++ b/internal/cli/messages.go @@ -35,6 +35,7 @@ func (a *App) messageCommand(command string, args []string) error { var cursor string var text string var stdin bool + var allowPlaintext bool var replyTo string var filePath, imagePath string var preparedImage *messaging.PreparedImage @@ -51,6 +52,7 @@ func (a *App) messageCommand(command string, args []string) error { fs.BoolVar(&pageMode, "page", false, "return a JSON history page with continuation cursor (requires --json)") fs.StringVar(&cursor, "cursor", "", "continue a history page (requires --page --json)") } else { + fs.BoolVar(&allowPlaintext, "allow-plaintext", false, "allow sending without end-to-end encryption (otherwise ask in a terminal; LINE can read it)") fs.StringVar(&mentionsJSON, "mentions", "", "explicit JSON mention array: [{\"start\":0,\"end\":5,\"mid\":\"USER_MID\"}] (UTF-16 offsets; text/stdin only)") fs.StringVar(&text, "text", "", "message text") fs.BoolVar(&stdin, "stdin", false, "read UTF-8 message text from stdin") @@ -252,7 +254,11 @@ func (a *App) messageCommand(command string, args []string) error { if err != nil { return err } - defer unlock() + defer func() { + if unlock != nil { + unlock() + } + }() chat, err = a.resolveChat(chat) if err != nil { return err @@ -265,13 +271,43 @@ func (a *App) messageCommand(command string, args []string) error { return err } if command == "send" { - var result *messaging.SendResult - if preparedImage != nil { - result, err = client.SendImageContext(a.loginContext(), chat, *preparedImage, replyTo) - } else if attachment != nil { - result, err = client.SendFileContext(a.loginContext(), chat, *attachment, replyTo) - } else { - result, err = client.SendMentionsContext(a.loginContext(), chat, text, replyTo, mentions) + options := messaging.SendOptions{AllowPlaintext: allowPlaintext} + send := func() (*messaging.SendResult, error) { + if preparedImage != nil { + return client.SendImageWithOptionsContext(a.loginContext(), chat, *preparedImage, replyTo, options) + } else if attachment != nil { + return client.SendFileWithOptionsContext(a.loginContext(), chat, *attachment, replyTo, options) + } + return client.SendMentionsWithOptionsContext(a.loginContext(), chat, text, replyTo, mentions, options) + } + result, err := send() + if guided && !allowPlaintext && canConfirmPlaintext(err) { + // Preparation stopped before any remote mutation. Release the lock + // for consent, then reload the client and repeat capability checks. + if err := a.rememberAccount(); err != nil { + return err + } + unlock() + unlock = nil + item := "message" + if preparedImage != nil { + item = "image" + } else if attachment != nil { + item = "file" + } + if err := a.confirmPlaintext(item); err != nil { + return err + } + unlock, err = a.lock() + if err != nil { + return err + } + client, err = messaging.New(a.Manager) + if err != nil { + return err + } + options.AllowPlaintext = true + result, err = send() } if err != nil { return err @@ -280,9 +316,9 @@ func (a *App) messageCommand(command string, args []string) error { if *jsonOutput { return a.confirmedOutput(op, a.json(result)) } - mode := "encrypted" + mode := "end-to-end encrypted" if !result.Encrypted { - mode = "not encrypted (Letter Sealing unavailable)" + mode = "not end-to-end encrypted" } if *showIDs && result.GroupKeyRegistered { mode += "; new group key registered" diff --git a/internal/cli/messages_test.go b/internal/cli/messages_test.go index c0f8b73..8f5ed4e 100644 --- a/internal/cli/messages_test.go +++ b/internal/cli/messages_test.go @@ -33,7 +33,7 @@ func TestSendStdinPreservesMultilineTextAndJSONOutput(t *testing.T) { a.Manager.Store = &testStore{state: &session.State{MID: "u-self", AccessToken: "token", NoE2EE: true}} a.Manager.NewClient = func(string) session.API { return f } a.In = strings.NewReader("hello\nworld\n") - if err := a.Run([]string{"send", "Uaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "--stdin", "--json"}); err != nil { + if err := a.Run([]string{"send", "Uaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "--allow-plaintext", "--stdin", "--json"}); err != nil { t.Fatal(err) } var result messaging.SendResult @@ -99,12 +99,12 @@ func TestMentionTextAndStdinReplies(t *testing.T) { f := &messageAPI{history: []*line.Message{{ID: "9007199254740993", From: "u-self", To: "c-group", ToType: 2}}} a.Manager.Store = &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} a.Manager.NewClient = func(string) session.API { return f } - args := []string{"send", "id:c-group", "--reply-to", "9007199254740993", "--json", "--mentions", `[{"start":3,"end":8,"mid":"Uaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"start":9,"end":13,"all":true}]`} + args := []string{"send", "id:c-group", "--allow-plaintext", "--reply-to", "9007199254740993", "--json", "--mentions", `[{"start":3,"end":8,"mid":"Uaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},{"start":9,"end":13,"all":true}]`} if stdin { args = append(args, "--stdin") a.In = strings.NewReader("๐Ÿ˜€ @Peer @All") } else { - args = append(args, "--text", "๐Ÿ˜€ @Peer @All") + args = append(args, "--allow-plaintext", "--text", "๐Ÿ˜€ @Peer @All") } if err := a.Run(args); err != nil { t.Fatal(err) @@ -120,7 +120,7 @@ func TestLiteralMentionDoesNotNotify(t *testing.T) { f := &messageAPI{} a.Manager.Store = &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} a.Manager.NewClient = func(string) session.API { return f } - if err := a.Run([]string{"send", "id:c-group", "--text", "@All @Peer"}); err != nil { + if err := a.Run([]string{"send", "id:c-group", "--allow-plaintext", "--text", "@All @Peer"}); err != nil { t.Fatal(err) } if f.sent.ContentMetadata["MENTION"] != "" { diff --git a/internal/cli/prompt.go b/internal/cli/prompt.go index f5cb20f..0095d60 100644 --- a/internal/cli/prompt.go +++ b/internal/cli/prompt.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/kongesque/line-cli/internal/fault" "github.com/kongesque/line-cli/internal/input" "github.com/kongesque/line-cli/internal/messaging" "github.com/kongesque/line-cli/internal/session" @@ -17,6 +18,33 @@ import ( var ErrCancelled = errors.New("cancelled") +// Only a refusal with no sequence or remote preparation steps may resume after +// consent. Never replay a send, upload, or key registration to show a prompt. +func canConfirmPlaintext(err error) bool { + var policy *fault.Error + var mutation *messaging.MutationError + if !errors.As(err, &policy) || policy.Code != "encryption_required" || + !errors.As(err, &mutation) || mutation.Stage != "preparation" { + return false + } + op := mutation.Mutation + return op.Action == "send" && !op.Attempted && op.Outcome == fault.NotAttempted && + op.RequestSequence == nil && len(op.Steps) == 0 +} + +func (a *App) confirmPlaintext(item string) error { + answer, err := a.ask(fmt.Sprintf("This chat sends %ss without end-to-end encryption. LINE can read the %s.\nSend this %s? [y/N]: ", item, item, item)) + if err != nil { + return err + } + switch strings.ToLower(strings.TrimSpace(answer)) { + case "y", "yes": + return nil + default: + return ErrCancelled + } +} + type promptAccount struct{ mid, generation string } // Prompts never hold a credential lock. Recheck account identity when work resumes. diff --git a/internal/cli/prompt_test.go b/internal/cli/prompt_test.go index f7e1094..5a0566c 100644 --- a/internal/cli/prompt_test.go +++ b/internal/cli/prompt_test.go @@ -13,7 +13,7 @@ import ( func TestGuidedSendSelectsNewContactAndSendsOnce(t *testing.T) { a, f, out, diagnostics, _ := guidedApp(t, "/New\n1\n\nHello เธ„เธธเธ“เนเธŸเธ™\n") - if err := a.Run([]string{"send"}); err != nil { + if err := a.Run([]string{"send", "--allow-plaintext"}); err != nil { t.Fatal(err) } if f.sends != 1 || f.sent.To != "u-new" || f.sent.Text != "Hello เธ„เธธเธ“เนเธŸเธ™" { @@ -29,7 +29,7 @@ func TestGuidedSendSelectsNewContactAndSendsOnce(t *testing.T) { func TestExplicitNameIncludesContactsWithoutChats(t *testing.T) { a, f, _, _, _ := guidedApp(t, "unused") - if err := a.Run([]string{"send", "New contact", "--text", "hello", "--json"}); err != nil { + if err := a.Run([]string{"send", "New contact", "--allow-plaintext", "--text", "hello", "--json"}); err != nil { t.Fatal(err) } if f.sends != 1 || f.sent.To != "u-new" { @@ -37,7 +37,7 @@ func TestExplicitNameIncludesContactsWithoutChats(t *testing.T) { } a, f, _, _, _ = guidedApp(t, "unused") f.names["u-new"] = "Alice" - if err := a.Run([]string{"send", "Alice", "--text", "hello"}); err == nil { + if err := a.Run([]string{"send", "Alice", "--allow-plaintext", "--text", "hello"}); err == nil { t.Fatal("ambiguous name was accepted") } if f.sends != 0 { @@ -114,7 +114,7 @@ func TestChooserDuplicateNamesPagingAndSearch(t *testing.T) { a, f, _, diagnostics, _ := guidedApp(t, "/Alice\n2\nHello\n") f.boxes = append(f.boxes, line.MessageBox{ID: "u-other"}) f.names["u-other"] = "Alice" - if err := a.Run([]string{"send"}); err != nil { + if err := a.Run([]string{"send", "--allow-plaintext"}); err != nil { t.Fatal(err) } if f.sent.To != "u-other" || !strings.Contains(diagnostics.String(), "u-alice") || !strings.Contains(diagnostics.String(), "u-other") { diff --git a/internal/cli/send_policy_test.go b/internal/cli/send_policy_test.go new file mode 100644 index 0000000..21631ac --- /dev/null +++ b/internal/cli/send_policy_test.go @@ -0,0 +1,236 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/kongesque/line-cli/internal/fault" + "github.com/kongesque/line-cli/internal/messaging" + "github.com/kongesque/line-cli/internal/session" + "github.com/kongesque/line-cli/pkg/line" +) + +func TestCLIRequiresPlaintextConsent(t *testing.T) { + for _, jsonMode := range []bool{false, true} { + for _, input := range []string{"text", "stdin", "file", "image"} { + t.Run(input+map[bool]string{false: "/human", true: "/json"}[jsonMode], func(t *testing.T) { + a, out, _ := testApp(nil) + api := &imageSendAPI{} + store := &testStore{state: &session.State{MID: "u-self", AccessToken: "synthetic", NoE2EE: true}} + a.Manager.Store = store + a.Manager.NewClient = func(string) session.API { return api } + a.In = strings.NewReader("synthetic") + args := []string{"--error-format=json", "send", "id:u-peer"} + switch input { + case "text": + args = append(args, "--text", "synthetic") + case "stdin": + args = append(args, "--stdin") + default: + args = append(args, "--"+input, cliImageFixture(t)) + } + if jsonMode { + args = append(args, "--json") + } + err := a.Run(args) + if err == nil || api.sent != nil || api.uploads != 0 || store.state.LastReqSeq != 0 || out.Len() != 0 { + t.Fatal("send without consent changed state") + } + encoded, _ := json.Marshal(machineDetail(t, err)) + if !strings.Contains(string(encoded), `"code":"encryption_required"`) || !strings.Contains(strings.ToLower(err.Error()), "nothing was sent") { + t.Fatal("missing safe consent error") + } + }) + } + } +} + +// All fields and attachment data in these tests are synthetic. +type consentAPI struct { + imageSendAPI + sends int + sendErr error +} + +func (f *consentAPI) SendMessageContext(_ context.Context, _ int64, m *line.Message) (*line.Message, error) { + f.sends++ + f.sent = m + return &line.Message{ID: "message-id"}, f.sendErr +} + +func consentFixture(t *testing.T, answer string) (*App, *consentAPI, *testStore, *bytes.Buffer, *bytes.Buffer, *bool) { + t.Helper() + a, _, out, diagnostics, locked := guidedApp(t, answer) + api := &consentAPI{} + a.Manager.NewClient = func(string) session.API { return api } + store := a.Manager.Store.(*testStore) + return a, api, store, out, diagnostics, locked +} + +func consentFile(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "test.txt") + if err := os.WriteFile(path, []byte("synthetic file\n"), 0600); err != nil { + t.Fatal(err) + } + return path +} + +func TestInteractiveFileConsentBeforeMutations(t *testing.T) { + for _, tc := range []struct { + name, answer string + accepted bool + }{ + {"yes", "y\n", true}, {"uppercase", "YES\n", true}, {"no", "n\n", false}, + {"default", "\n", false}, {"invalid", "maybe\n", false}, {"eof", "", false}, {"unfinished", "y", false}, + } { + t.Run(tc.name, func(t *testing.T) { + answer := tc.answer + a, api, store, out, diagnostics, locked := consentFixture(t, answer) + a.In = checkedInput{t: t, locked: locked, Reader: strings.NewReader(answer), onRead: func() { + if api.sends != 0 || api.uploads != 0 || store.state.LastReqSeq != 0 { + t.Fatal("consent did not precede mutations") + } + }} + err := a.Run([]string{"send", "id:u-peer", "--file", consentFile(t)}) + if !tc.accepted { + if !errors.Is(err, ErrCancelled) || api.sends != 0 || api.uploads != 0 || store.state.LastReqSeq != 0 || out.Len() != 0 { + t.Fatal("declined/unfinished consent changed remote state", err) + } + } else if err != nil || api.sends != 1 || api.uploads != 1 || store.state.LastReqSeq == 0 || string(api.data) != "synthetic file\n" || !strings.Contains(out.String(), "not end-to-end encrypted") { + t.Fatal("confirmed file did not send once", err) + } + if !strings.Contains(diagnostics.String(), "Send this file? [y/N]") || strings.Count(diagnostics.String(), "Send this file?") != 1 || *locked { + t.Fatal("incorrect prompt or lock retained") + } + }) + } +} + +func TestGuidedMessageConsent(t *testing.T) { + a, api, _, diagnostics, _ := guidedApp(t, "/New\n1\nsynthetic\ny\n") + if err := a.Run([]string{"send"}); err != nil || api.sends != 1 || api.sent.Text != "synthetic" || !strings.Contains(diagnostics.String(), "Send this message? [y/N]") { + t.Fatal("guided consent failed", err) + } +} + +func TestSendScriptsNeverPromptForPlaintext(t *testing.T) { + for _, mode := range []string{"noninteractive", "json", "json-errors", "stdin", "flag"} { + t.Run(mode, func(t *testing.T) { + a, api, store, _, diagnostics, _ := consentFixture(t, "unused") + store.state.NoE2EE = true + args := []string{"send", "id:u-peer", "--text", "synthetic"} + switch mode { + case "noninteractive": + a.Interactive = false + case "json": + args = append(args, "--json") + case "json-errors": + args = append([]string{"--error-format=json"}, args...) + case "stdin": + args = []string{"send", "id:u-peer", "--stdin"} + a.In = strings.NewReader("synthetic") + case "flag": + args = append(args, "--allow-plaintext") + } + err := a.Run(args) + if mode == "flag" { + if err != nil || api.sends != 1 { + t.Fatal("explicit flag failed", err) + } + } else if err == nil || api.sends != 0 || api.uploads != 0 || store.state.LastReqSeq != 0 { + t.Fatal("script bypassed consent", err) + } + if diagnostics.Len() != 0 { + t.Fatal("script or flag displayed prompt") + } + }) + } +} + +func TestConsentRechecksAccountAndPreparation(t *testing.T) { + for _, change := range []string{"account", "invalidated", "missing-keys", "cancelled"} { + t.Run(change, func(t *testing.T) { + a, api, store, out, _, locked := consentFixture(t, "y\n") + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + a.Context = ctx + a.In = checkedInput{t: t, locked: locked, Reader: strings.NewReader("y\n"), onRead: func() { + switch change { + case "account": + store.state.Generation = "replacement" + case "invalidated": + store.state.Invalidated = true + case "missing-keys": + store.state.NoE2EE = false + store.state.ExportedKeys = nil + case "cancelled": + cancel() + } + }} + if err := a.Run([]string{"send", "id:u-peer", "--file", consentFile(t)}); err == nil { + t.Fatal("changed preparation allowed send") + } + if api.sends != 0 || api.uploads != 0 || store.state.LastReqSeq != 0 || out.Len() != 0 || *locked { + t.Fatal("changed preparation caused mutation or retained lock") + } + }) + } +} + +func TestInteractiveSendDoesNotPromptOrReplayAfterFailures(t *testing.T) { + for _, failure := range []string{"missing-keys", "send", "upload"} { + t.Run(failure, func(t *testing.T) { + a, api, store, _, diagnostics, _ := consentFixture(t, "y\n") + switch failure { + case "missing-keys": + store.state.NoE2EE = false + store.state.ExportedKeys = nil + case "send": + api.sendErr = errors.New("synthetic connection lost") + case "upload": + api.err = errors.New("synthetic upload failure") + } + if err := a.Run([]string{"send", "id:u-peer", "--file", consentFile(t)}); err == nil { + t.Fatal("expected failure") + } + if failure == "send" || failure == "upload" { + wantUploads := 0 + if failure == "upload" { + wantUploads = 1 + } + if api.sends != 1 || api.uploads != wantUploads || strings.Count(diagnostics.String(), "Send this file?") != 1 { + t.Fatal("mutation was replayed or re-prompted") + } + } else if api.sends != 0 || api.uploads != 0 || diagnostics.Len() != 0 || store.state.LastReqSeq != 0 { + t.Fatal("error prompted for a downgrade") + } + }) + } +} + +func TestPlaintextConsentCannotResumeRemotePreparation(t *testing.T) { + seq := int64(1) + for _, tc := range []struct { + name string + op fault.Mutation + }{ + {"upload", fault.Mutation{Action: "send", Outcome: fault.NotAttempted, Steps: []fault.Step{{Stage: "attachment_upload", Attempted: true, Outcome: fault.Confirmed}}}}, + {"registration", fault.Mutation{Action: "send", Outcome: fault.NotAttempted, Steps: []fault.Step{{Stage: "group_key_registration", Attempted: true, Outcome: fault.Unknown}}}}, + {"sequence", fault.Mutation{Action: "send", Outcome: fault.NotAttempted, RequestSequence: &seq}}, + {"send", fault.Mutation{Action: "send", Attempted: true, Outcome: fault.Unknown}}, + } { + t.Run(tc.name, func(t *testing.T) { + err := fault.New("encryption_required", "preparation", "synthetic refusal", &messaging.MutationError{Stage: "preparation", Mutation: tc.op}) + if canConfirmPlaintext(err) { + t.Fatal("consent could replay remote work") + } + }) + } +} diff --git a/internal/messaging/actions_test.go b/internal/messaging/actions_test.go index 79a0364..1bd9863 100644 --- a/internal/messaging/actions_test.go +++ b/internal/messaging/actions_test.go @@ -64,7 +64,7 @@ func TestReplyPreservesEncryptionAndRelationFields(t *testing.T) { for _, plain := range []bool{true, false} { c, f, _, _ := setup(t, plain) f.history = []*line.Message{{ID: "123", From: "u-peer", To: "u-self"}} - result, err := c.SendReply("u-peer", "reply", "123") + result, err := c.SendMentionsWithOptionsContext(context.Background(), "u-peer", "reply", "123", nil, SendOptions{AllowPlaintext: true}) if err != nil { t.Fatal(err) } @@ -72,7 +72,7 @@ func TestReplyPreservesEncryptionAndRelationFields(t *testing.T) { t.Fatal("invalid reply envelope") } f.sendErr = errors.New("NOT_FOUND") - _, err = c.SendReply("u-peer", "reply", "123") + _, err = c.SendMentionsWithOptionsContext(context.Background(), "u-peer", "reply", "123", nil, SendOptions{AllowPlaintext: true}) if err == nil || f.sends != 2 || f.sent.RelatedMessageID != "123" { t.Fatal("reply retried without relation") } diff --git a/internal/messaging/cancellation_test.go b/internal/messaging/cancellation_test.go index 75008d2..b51a1d6 100644 --- a/internal/messaging/cancellation_test.go +++ b/internal/messaging/cancellation_test.go @@ -55,7 +55,10 @@ func TestAlreadyCancelledOperationsMakeNoRemoteCalls(t *testing.T) { cancel() calls := []func() error{ func() error { _, e := c.HistoryContext(ctx, "u-peer", 10); return e }, - func() error { _, e := c.SendContext(ctx, "u-peer", "hello"); return e }, + func() error { + _, e := c.SendMentionsWithOptionsContext(ctx, "u-peer", "hello", "", nil, SendOptions{AllowPlaintext: true}) + return e + }, func() error { _, e := c.SendFileContext(ctx, "c-group", Attachment{Name: "test.txt", Data: []byte("test")}, "") return e @@ -87,7 +90,7 @@ func TestCancellationBetweenPreparationAndMutation(t *testing.T) { if phase == "lookup" { _, err = c.UnsendContext(ctx, "u-peer", "123") } else { - _, err = c.SendContext(ctx, "u-peer", "hello") + _, err = c.SendMentionsWithOptionsContext(ctx, "u-peer", "hello", "", nil, SendOptions{AllowPlaintext: true}) } if !errors.Is(err, context.Canceled) || api.sends != 0 || store.state.LastReqSeq != 0 { t.Fatalf("err=%v sends=%d seq=%d", err, api.sends, store.state.LastReqSeq) @@ -110,9 +113,9 @@ func TestCancellationAfterDispatchPreservesOutcome(t *testing.T) { var result *SendResult var err error if phase == "plain-file" { - result, err = c.SendFileContext(ctx, "u-peer", Attachment{Name: "test.txt"}, "") + result, err = c.SendFileWithOptionsContext(ctx, "u-peer", Attachment{Name: "test.txt"}, "", SendOptions{AllowPlaintext: true}) } else { - result, err = c.SendContext(ctx, "u-peer", "hello") + result, err = c.SendMentionsWithOptionsContext(ctx, "u-peer", "hello", "", nil, SendOptions{AllowPlaintext: true}) } if api.sends != 1 { t.Fatalf("sends=%d", api.sends) @@ -168,7 +171,7 @@ func TestCancelledEncryptedUploadRetainsUncertainOutcome(t *testing.T) { defer cancel() f := &cancellationAPI{fakeAPI: api, cancel: cancel} c.Session.NewClient = func(string) session.API { return f } - _, err := c.SendFileContext(ctx, "u-peer", Attachment{Name: "test.txt"}, "") + _, err := c.SendFileWithOptionsContext(ctx, "u-peer", Attachment{Name: "test.txt"}, "", SendOptions{AllowPlaintext: true}) var mutation *MutationError if !errors.Is(err, context.Canceled) || !errors.As(err, &mutation) || !strings.Contains(err.Error(), "uploaded file may remain") || api.sends != 0 { t.Fatalf("err=%v sends=%d", err, api.sends) diff --git a/internal/messaging/client.go b/internal/messaging/client.go index 1c43866..f1158b1 100644 --- a/internal/messaging/client.go +++ b/internal/messaging/client.go @@ -269,8 +269,20 @@ type SendResult struct { RequestSequence int64 `json:"request_sequence"` } -// Send sends exactly once after read-only preparation, encrypting unless the -// account or peer explicitly lacks Letter Sealing support. Caller holds Lock. +// SendOptions controls the per-send plaintext exception. Its zero value requires +// Letter Sealing. AllowPlaintext never permits fallback after key/transport errors. +type SendOptions struct { + AllowPlaintext bool +} + +func (o SendOptions) checkPlaintext(plain bool, item string) error { + if plain && !o.AllowPlaintext { + return fault.New("encryption_required", "preparation", fmt.Sprintf("This chat doesn't support end-to-end encryption for this %s. Nothing was sent.\nTo send it anyway, add --allow-plaintext. LINE can read the %s.", item, item), nil) + } + return nil +} + +// Send sends exactly once and requires encryption. Caller holds Lock. func (c *Client) Send(chat, text string) (*SendResult, error) { return c.SendContext(context.Background(), chat, text) } @@ -284,13 +296,18 @@ func (c *Client) SendReply(chat, text, replyTo string) (*SendResult, error) { } func (c *Client) SendReplyContext(ctx context.Context, chat, text, replyTo string) (*SendResult, error) { - return c.send(ctx, chat, text, replyTo, nil, nil, nil) + return c.SendMentionsWithOptionsContext(ctx, chat, text, replyTo, nil, SendOptions{}) } // SendMentionsContext sends explicit mentions with the ordinary single-attempt // text/reply flow. It does not derive mentions from literal @name text. func (c *Client) SendMentionsContext(ctx context.Context, chat, text, replyTo string, mentions []Mention) (*SendResult, error) { - return c.send(ctx, chat, text, replyTo, nil, mentions, nil) + return c.SendMentionsWithOptionsContext(ctx, chat, text, replyTo, mentions, SendOptions{}) +} + +// SendMentionsWithOptionsContext applies an explicit policy to text, mentions, and replies. +func (c *Client) SendMentionsWithOptionsContext(ctx context.Context, chat, text, replyTo string, mentions []Mention, options SendOptions) (*SendResult, error) { + return c.send(ctx, chat, text, replyTo, nil, mentions, nil, options) } func (c *Client) SendFile(chat string, file Attachment, replyTo string) (*SendResult, error) { @@ -298,10 +315,14 @@ func (c *Client) SendFile(chat string, file Attachment, replyTo string) (*SendRe } func (c *Client) SendFileContext(ctx context.Context, chat string, file Attachment, replyTo string) (*SendResult, error) { - return c.send(ctx, chat, "", replyTo, &file, nil, nil) + return c.SendFileWithOptionsContext(ctx, chat, file, replyTo, SendOptions{}) +} + +func (c *Client) SendFileWithOptionsContext(ctx context.Context, chat string, file Attachment, replyTo string, options SendOptions) (*SendResult, error) { + return c.send(ctx, chat, "", replyTo, &file, nil, nil, options) } -func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Attachment, mentions []Mention, image *PreparedImage) (result *SendResult, resultErr error) { +func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Attachment, mentions []Mention, image *PreparedImage, options SendOptions) (result *SendResult, resultErr error) { op := &fault.Mutation{Action: "send", Outcome: fault.NotAttempted} stage := "validation" defer func() { resultErr = mutationFailure(op, stage, resultErr) }() @@ -329,6 +350,9 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att return nil, errors.New("mention-all requires a group or room") } } + if err := ctx.Err(); err != nil { + return nil, err + } op.ChatID = chat if replyTo != "" { stage = "lookup" @@ -352,16 +376,35 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att } } } + item := "message" + if image != nil { + item = "image" + } else if file != nil { + item = "file" + } plain := c.state.NoE2EE - if image != nil && !plain { + if file != nil && !plain { + flowType, mediaName := "14", "file" + if image != nil { + flowType, mediaName = "1", "image" + } var flow *line.MediaMessageFlowResponse if err := c.Session.DoContext(ctx, func(api session.API) (err error) { flow, err = api.DetermineMediaMessageFlowContext(ctx, chat); return }); err != nil { return nil, err } - if flow == nil || (flow.FlowMap["1"] != 1 && flow.FlowMap["1"] != 2) { - return nil, errors.New("LINE returned no supported image upload capability") + if flow == nil || (flow.FlowMap[flowType] != 1 && flow.FlowMap[flowType] != 2) { + return nil, fmt.Errorf("LINE returned no supported %s upload capability", mediaName) } - plain = flow.FlowMap["1"] == 1 + plain = flow.FlowMap[flowType] == 1 + } + if err := ctx.Err(); err != nil { + return nil, err + } + if err := options.checkPlaintext(plain, item); err != nil { + return nil, err + } + if image != nil && !plain { + return nil, encryptedImageUnavailable() } groupKeyRegistered := false metadata := make(map[string]string) @@ -387,6 +430,9 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att if !plain && kind == 0 { key, keyErr := c.negotiate(ctx, chat) if errors.Is(keyErr, errNoLetterSealing) { + if err := options.checkPlaintext(true, item); err != nil { + return nil, err + } plain = true } else if keyErr != nil { return nil, keyErr @@ -416,6 +462,9 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att } } if errors.Is(err, errNoLetterSealing) { + if err := options.checkPlaintext(true, item); err != nil { + return nil, err + } plain, err = true, nil } else if err == nil { encryptionVersion = c.groupKeys[chat+":0"] diff --git a/internal/messaging/client_test.go b/internal/messaging/client_test.go index c69aa07..4124bbc 100644 --- a/internal/messaging/client_test.go +++ b/internal/messaging/client_test.go @@ -1,6 +1,7 @@ package messaging import ( + "context" "encoding/json" "errors" "strings" @@ -120,7 +121,7 @@ func TestSendOnlyAllowsExplicitPlaintextFallback(t *testing.T) { t.Run(tc.name, func(t *testing.T) { c, f, _, _ := setup(t, tc.noE2EE) f.negotiateErr = tc.err - result, err := c.Send("u-peer", "hello") + result, err := c.SendMentionsWithOptionsContext(context.Background(), "u-peer", "hello", "", nil, SendOptions{AllowPlaintext: true}) if f.sends != tc.sends { t.Fatal("unsafe fallback") } @@ -199,7 +200,7 @@ func TestSendJSONReportsGroupKeyReuseAndNonGroupSends(t *testing.T) { } { c, f, _, _ := setup(t, tc.plain) f.group = &line.E2EEGroupSharedKey{GroupKeyID: 33, Creator: "u-self", CreatorKeyID: 11, ReceiverKeyID: 11, EncryptedSharedKey: "wrapped"} - result, err := c.Send(tc.chat, "test") + result, err := c.SendMentionsWithOptionsContext(context.Background(), tc.chat, "test", "", nil, SendOptions{AllowPlaintext: tc.plain}) if err != nil { t.Fatal(err) } diff --git a/internal/messaging/file_security_test.go b/internal/messaging/file_security_test.go new file mode 100644 index 0000000..25e9cb2 --- /dev/null +++ b/internal/messaging/file_security_test.go @@ -0,0 +1,191 @@ +package messaging + +import ( + "bytes" + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/kongesque/line-cli/internal/session" + gen "github.com/kongesque/line-cli/pkg" + "github.com/kongesque/line-cli/pkg/e2ee" + "github.com/kongesque/line-cli/pkg/line" + "github.com/kongesque/line-cli/pkg/ltsm" +) + +type claimTransport func(*http.Request) (*http.Response, error) + +func (f claimTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func claimResponse(r *http.Request, status int, body string) *http.Response { + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: r} +} + +type claimFileAPI struct { + *fileAPI + wire *line.Client +} + +func (a *claimFileAPI) SendMessageContext(ctx context.Context, seq int64, m *line.Message) (*line.Message, error) { + return a.wire.SendMessageContext(ctx, seq, m) +} + +func TestFileWireEnvelopeProtectsKeyAndName(t *testing.T) { + for _, chat := range []string{"u-peer", "c-group", "r-room"} { + t.Run(chat, func(t *testing.T) { + c, f, crypto, _ := setup(t, false) + f.group = &line.E2EEGroupSharedKey{GroupKeyID: 33, Creator: "u-self", CreatorKeyID: 11, ReceiverKeyID: 11, EncryptedSharedKey: "synthetic"} + files := &fileAPI{fakeAPI: f} + wire := line.NewClient("synthetic-token") + var outer line.Message + requests := 0 + wire.HTTPClient.Transport = claimTransport(func(r *http.Request) (*http.Response, error) { + if !strings.HasSuffix(r.URL.Path, "/sendMessage") { + t.Fatal("unexpected API request") + } + requests++ + var args []json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&args); err != nil || len(args) != 2 { + t.Fatal("invalid send argument envelope") + } + if err := json.Unmarshal(args[1], &outer); err != nil { + t.Fatal("invalid serialized message") + } + return claimResponse(r, 200, `{"code":0,"data":{"id":"synthetic-receipt"}}`), nil + }) + api := &claimFileAPI{fileAPI: files, wire: wire} + crypt := &fileCrypto{fakeCrypto: crypto} + c.crypto = crypt + c.Session.NewClient = func(string) session.API { return api } + file := Attachment{Name: "synthetic-private.txt", Data: []byte("synthetic confidential attachment")} + f.history = []*line.Message{{ID: "123", From: "u-self", To: chat, ToType: toType(chat)}} + result, err := c.SendFile(chat, file, "123") + if err != nil || result == nil || !result.Encrypted || requests != 1 || len(outer.Chunks) != 5 { + t.Fatal("encrypted file send did not complete through real HTTP serialization") + } + if outer.ContentMetadata["ENC_KM"] != "" || outer.ContentMetadata["FILE_NAME"] != "" || outer.RelatedMessageID != "123" { + t.Fatal("outer envelope exposed attachment secrets or lost reply relation") + } + if _, err := decryptFile(files.uploaded, outer.ContentMetadata["ENC_KM"]); err == nil { + t.Fatal("metadata-only decryption succeeded") + } + var payload map[string]string + if json.Unmarshal(crypt.payloadSent, &payload) != nil || payload["fileName"] != file.Name { + t.Fatal("authenticated payload lost filename") + } + plain, err := decryptFile(files.uploaded, payload["keyMaterial"]) + if err != nil || !bytes.Equal(plain, file.Data) { + t.Fatal("recipient payload did not decrypt attachment") + } + wireBody, _ := json.Marshal(outer) + receipt, _ := json.Marshal(result) + for _, secret := range []string{payload["keyMaterial"], file.Name, string(file.Data)} { + if bytes.Contains(wireBody, []byte(secret)) || bytes.Contains(receipt, []byte(secret)) { + t.Fatal("wire envelope or receipt exposed attachment secret") + } + } + }) + } +} + +// Keep the envelope probe above independent of Letter Sealing; this separate +// roundtrip exercises the real manager/runner and recipient crypto as well. +type realFileCrypto struct { + *fakeCrypto + manager *e2ee.Manager + senderKey int +} + +func (r *realFileCrypto) MyKeyIDs() (int, int, error) { return 11, r.senderKey, nil } +func (r *realFileCrypto) EncryptMessageV2Raw(chat, from string, own int, pub string, sender, receiver, kind int, payload []byte) ([]string, error) { + return r.manager.EncryptMessageV2Raw(chat, from, own, pub, sender, receiver, kind, payload) +} +func TestEncryptedFileRealRecipientRoundTrip(t *testing.T) { + runner, err := gen.GetRunner() + if err != nil { + t.Fatal("runner initialization failed") + } + senderKey, err := runner.KeyGenerate() + if err != nil { + t.Fatal("synthetic sender key failed") + } + public, err := runner.KeyGetPublic(senderKey) + if err != nil { + t.Fatal("synthetic sender public key failed") + } + receiver, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal("synthetic receiver key failed") + } + manager, err := e2ee.NewManager() + if err != nil { + t.Fatal("manager initialization failed") + } + manager.RegisterUnwrappedKey(11, senderKey) + c, f, crypto, _ := setup(t, false) + c.state.MID = "u11111111111111111111111111111111" + const chat = "u22222222222222222222222222222222" + f.key.PublicKey = base64.StdEncoding.EncodeToString(receiver.PublicKey().Bytes()) + c.crypto = &realFileCrypto{fakeCrypto: crypto, manager: manager, senderKey: senderKey} + api := &fileAPI{fakeAPI: f} + c.Session.NewClient = func(string) session.API { return api } + attachment := Attachment{Name: "synthetic-private.txt", Data: []byte("synthetic confidential bytes")} + result, err := c.SendFile(chat, attachment, "") + if err != nil || result == nil || !result.Encrypted || len(f.sent.Chunks) != 5 { + t.Fatalf("real encrypted file send failed: %v", err) + } + if f.sent.ContentMetadata["ENC_KM"] != "" || f.sent.ContentMetadata["FILE_NAME"] != "" { + t.Fatal("secrets outside sealed payload") + } + senderPublic, err := base64.StdEncoding.DecodeString(public) + if err != nil { + t.Fatal("invalid synthetic public key") + } + recipient, err := ltsm.NewChannel(receiver.Bytes(), senderPublic) + if err != nil { + t.Fatal("recipient channel failed") + } + parts := make([][]byte, 3) + for i := range parts { + parts[i], err = base64.StdEncoding.DecodeString(f.sent.Chunks[i]) + if err != nil { + t.Fatal("invalid chunk encoding") + } + } + // The generated-key runner can use the manager's existing V1 compatibility + // path. Decode the actual wire format with the production recipient crypto. + order := []int{0, 2, 1} + decrypt := func(cipher []byte) ([]byte, error) { + return recipient.DecryptV2(chat, c.state.MID, 11, 22, 14, cipher) + } + if len(parts[0]) == 8 { + order = []int{0, 1, 2} + decrypt = recipient.DecryptV1 + } + var cipher []byte + for _, i := range order { + cipher = append(cipher, parts[i]...) + } + payload, err := decrypt(cipher) + if err != nil { + t.Fatal("recipient could not authenticate sealed payload") + } + var decoded map[string]string + if json.Unmarshal(payload, &decoded) != nil || decoded["fileName"] != attachment.Name { + t.Fatal("recipient payload lost filename") + } + plain, err := decryptFile(api.uploaded, decoded["keyMaterial"]) + if err != nil || !bytes.Equal(plain, attachment.Data) { + t.Fatal("recipient could not authenticate attachment") + } + cipher[len(cipher)-1] ^= 1 + if _, err := decrypt(cipher); err == nil { + t.Fatal("tampered sealed payload accepted") + } +} diff --git a/internal/messaging/files.go b/internal/messaging/files.go index 5e6998f..282d9cc 100644 --- a/internal/messaging/files.go +++ b/internal/messaging/files.go @@ -145,7 +145,10 @@ func (c *Client) uploadFile(ctx context.Context, file *Attachment, metadata map[ op.Steps[len(op.Steps)-1].Outcome = fault.Unknown return nil, errors.New("file upload returned no object ID") } - metadata["OID"], metadata["SID"], metadata["ENC_KM"] = oid, "emf", key + metadata["OID"], metadata["SID"] = oid, "emf" + // Chrome gL/EL put secrets only in the authenticated Letter Sealing payload. + delete(metadata, "ENC_KM") + delete(metadata, "FILE_NAME") return json.Marshal(map[string]string{"keyMaterial": key, "fileName": file.Name}) } diff --git a/internal/messaging/files_test.go b/internal/messaging/files_test.go index 445461a..1520eb5 100644 --- a/internal/messaging/files_test.go +++ b/internal/messaging/files_test.go @@ -45,19 +45,19 @@ func TestFileSendEnvelopeAndUploadOrdering(t *testing.T) { f.group = &line.E2EEGroupSharedKey{GroupKeyID: 33, Creator: "u-self", CreatorKeyID: 11, ReceiverKeyID: 11, EncryptedSharedKey: "wrapped"} f.history = []*line.Message{{ID: "123", From: "u-self", To: chat, ToType: toType(chat)}} attachment := Attachment{Name: "notes.pdf", Data: []byte("private attachment bytes")} - result, err := c.SendFile(chat, attachment, "123") + result, err := c.SendFileWithOptionsContext(context.Background(), chat, attachment, "123", SendOptions{AllowPlaintext: plain}) if err != nil { t.Fatal(err) } - if result.Encrypted == plain || f.sent.ContentType != 14 || !f.sent.HasContent || f.sent.ContentMetadata["FILE_NAME"] != "notes.pdf" || f.sent.RelatedMessageID != "123" { + if result.Encrypted == plain || f.sent.ContentType != 14 || !f.sent.HasContent || f.sent.RelatedMessageID != "123" { t.Fatal("incorrect attachment envelope") } if plain { - if strings.Join(api.order, ",") != "send,plain_upload" || !bytes.Equal(api.uploaded, attachment.Data) { + if f.sent.ContentMetadata["FILE_NAME"] != "notes.pdf" || strings.Join(api.order, ",") != "send,plain_upload" || !bytes.Equal(api.uploaded, attachment.Data) { t.Fatal("plain upload must follow message creation") } } else { - if strings.Join(api.order, ",") != "upload,send" || bytes.Contains(api.uploaded, attachment.Data) || crypt.contentType != 14 { + if f.sent.ContentMetadata["ENC_KM"] != "" || f.sent.ContentMetadata["FILE_NAME"] != "" || strings.Join(api.order, ",") != "upload,send" || bytes.Contains(api.uploaded, attachment.Data) || crypt.contentType != 14 { t.Fatal("encrypted upload order/body incorrect") } var payload map[string]string @@ -83,7 +83,7 @@ func TestFileUploadFailureNeverRetriesOrSendsEmptyEncryptedMessage(t *testing.T) api := &fileAPI{fakeAPI: f, uploadErr: errors.New("private body"), plainErr: errors.New("private body")} c.crypto = &fileCrypto{fakeCrypto: crypto} c.Session.NewClient = func(string) session.API { return api } - _, err := c.SendFile("u-peer", Attachment{Name: "test.bin", Data: []byte("test")}, "") + _, err := c.SendFileWithOptionsContext(context.Background(), "u-peer", Attachment{Name: "test.bin", Data: []byte("test")}, "", SendOptions{AllowPlaintext: plain}) if err == nil || strings.Contains(err.Error(), "private") { t.Fatal("unsafe upload error", err) } diff --git a/internal/messaging/history_test.go b/internal/messaging/history_test.go index 58f7e83..2a6b95d 100644 --- a/internal/messaging/history_test.go +++ b/internal/messaging/history_test.go @@ -271,7 +271,7 @@ func TestAllActionsFindTargetsBeyondLatest100(t *testing.T) { var err error switch action { case "reply": - _, err = c.SendReply("u-peer", "hello", "101") + _, err = c.SendMentionsWithOptionsContext(context.Background(), "u-peer", "hello", "101", nil, SendOptions{AllowPlaintext: true}) case "react": _, err = c.React("u-peer", "101", "like", false) case "unsend": diff --git a/internal/messaging/images.go b/internal/messaging/images.go index 43eb9e4..6865759 100644 --- a/internal/messaging/images.go +++ b/internal/messaging/images.go @@ -3,9 +3,7 @@ package messaging import ( "bytes" "context" - "encoding/base64" "encoding/binary" - "encoding/json" "errors" "image" "image/color" @@ -13,11 +11,9 @@ import ( _ "image/png" "math" "path/filepath" - "strconv" "strings" "github.com/kongesque/line-cli/internal/fault" - "github.com/kongesque/line-cli/internal/session" ) // MaxImagePixels bounds decoding memory independently of the compressed file size. @@ -95,51 +91,28 @@ func PrepareImage(ctx context.Context, file Attachment) (*PreparedImage, error) } func (c *Client) SendImageContext(ctx context.Context, chat string, img PreparedImage, replyTo string) (*SendResult, error) { + return c.SendImageWithOptionsContext(ctx, chat, img, replyTo, SendOptions{}) +} + +func (c *Client) SendImageWithOptionsContext(ctx context.Context, chat string, img PreparedImage, replyTo string, options SendOptions) (*SendResult, error) { if replyTo != "" { return nil, fault.New("invalid_arguments", "validation", "--image cannot be combined with --reply-to; image reply support is unverified", nil) } if len(img.file.Data) == 0 || len(img.preview) == 0 { return nil, fault.New("invalid_arguments", "validation", "image must be prepared before sending", nil) } - return c.send(ctx, chat, "", replyTo, &img.file, nil, &img) + return c.send(ctx, chat, "", replyTo, &img.file, nil, &img, options) } func (c *Client) uploadAttachment(ctx context.Context, file *Attachment, img *PreparedImage, metadata map[string]string, op *fault.Mutation) ([]byte, error) { if img == nil { return c.uploadFile(ctx, file, metadata, op) } - full, key, err := encryptFile(file.Data) - if err != nil { - return nil, err - } - material, err := base64.StdEncoding.DecodeString(key) - if err != nil { - return nil, err - } - defer clear(material) - preview, err := encryptFileWithMaterial(img.preview, material) - if err != nil { - return nil, err - } - var oid string - if err := c.attempt(ctx, op, "attachment_upload", false, func(api session.API) (err error) { oid, err = api.UploadOBSWithSIDContext(ctx, full, "emi"); return }); err != nil { - return nil, err - } - if oid == "" { - op.Steps[len(op.Steps)-1].Outcome = fault.Unknown - return nil, fault.New("remote_failed", "attachment_upload", "image upload returned no object ID; inspect LINE before retrying", nil) - } - if err := c.attempt(ctx, op, "preview_upload", false, func(api session.API) error { - return api.UploadOBSWithOIDAndSIDContext(ctx, preview, oid+"__ud-preview", "emi") - }); err != nil { - return nil, err - } - metadata["OID"], metadata["SID"], metadata["FILE_SIZE"] = oid, "emi", strconv.Itoa(len(file.Data)) - thumb, _ := json.Marshal(map[string]int{"width": img.width, "height": img.height}) - metadata["MEDIA_THUMB_INFO"] = string(thumb) - // Chrome EL moves key material and filename exclusively into authenticated - // chunks. Never place them in the outgoing image's outer metadata. - return json.Marshal(map[string]string{"keyMaterial": key, "fileName": file.Name}) + return nil, encryptedImageUnavailable() +} + +func encryptedImageUnavailable() error { + return fault.New("encrypted_image_unavailable", "preparation", "Encrypted image sending isn't available yet. Nothing was sent.\nTry --file to send the original image instead.", nil) } // Read EXIF orientation without copying metadata (including GPS) into output. diff --git a/internal/messaging/images_test.go b/internal/messaging/images_test.go index fe13645..5e8f354 100644 --- a/internal/messaging/images_test.go +++ b/internal/messaging/images_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "encoding/binary" - "encoding/json" "errors" "hash/crc32" "image" @@ -98,43 +97,22 @@ func TestImageSendFlows(t *testing.T) { for _, flow := range []int{1, 2} { t.Run(chat+string(rune('0'+flow)), func(t *testing.T) { c, f, crypto, store := setup(t, false) - f.group = &line.E2EEGroupSharedKey{GroupKeyID: 33, Creator: "u-self", CreatorKeyID: 11, ReceiverKeyID: 11, EncryptedSharedKey: "wrapped"} + // Missing group keys must not cause registration before refusing images. + f.groupErr = line.ErrGroupKeyNotFound api := &imageAPI{fakeAPI: f, flow: flow} - crypt := &fileCrypto{fakeCrypto: crypto} - c.crypto = crypt + c.crypto = &fileCrypto{fakeCrypto: crypto} c.Session.NewClient = func(string) session.API { return api } - result, err := c.SendImageContext(context.Background(), chat, *img, "") - if err != nil { - t.Fatal(err) - } - if result.ID != "server-id" || result.Encrypted != (flow == 2) || result.ChatID != chat || store.state.LastReqSeq != result.RequestSequence || f.sent.ContentType != 1 || !f.sent.HasContent { - t.Fatal("incorrect image receipt/envelope") - } - if flow == 1 { - if strings.Join(api.order, ",") != "send,attachment_upload" || !bytes.Equal(api.plain, img.file.Data) || len(f.sent.Chunks) != 0 || len(f.sent.ContentMetadata) != 0 { - t.Fatal("incorrect plaintext image flow") - } - } else { - if strings.Join(api.order, ",") != "attachment_upload,preview_upload,send" || crypt.contentType != 1 { - t.Fatal("incorrect encrypted image flow") - } - var payload map[string]string - if err := json.Unmarshal(crypt.payloadSent, &payload); err != nil { - t.Fatal(err) - } - for _, pair := range [][2][]byte{{api.full, img.file.Data}, {api.preview, img.preview}} { - data, err := decryptFile(pair[0], payload["keyMaterial"]) - if err != nil || !bytes.Equal(data, pair[1]) { - t.Fatal("encrypted image or preview failed authentication") + for range 2 { + result, err := c.SendImageWithOptionsContext(context.Background(), chat, *img, "", SendOptions{AllowPlaintext: true}) + if flow == 2 { + var safe *fault.Error + if !errors.As(err, &safe) || safe.Code != "encrypted_image_unavailable" || result != nil || len(api.order) != 0 || f.sends != 0 || f.registrations != 0 || store.state.LastReqSeq != 0 || len(api.full) != 0 || len(api.preview) != 0 { + t.Fatal("encrypted image did not fail closed before side effects") } + continue } - meta := f.sent.ContentMetadata - if payload["fileName"] != "screenshot.jpg" || meta["OID"] != "image-object" || meta["SID"] != "emi" || meta["MEDIA_THUMB_INFO"] != `{"height":4,"width":8}` || meta["ENC_KM"] != "" || meta["FILE_NAME"] != "" { - t.Fatal("incorrect image metadata") - } - encoded, _ := json.Marshal(result) - if bytes.Contains(encoded, []byte(payload["keyMaterial"])) { - t.Fatal("receipt leaked image key") + if err != nil || result.Encrypted || result.ID != "server-id" || f.sent.ContentType != 1 || !bytes.Equal(api.plain, img.file.Data) || len(f.sent.ContentMetadata) != 0 { + t.Fatal("incorrect plaintext image flow") } } }) @@ -143,7 +121,7 @@ func TestImageSendFlows(t *testing.T) { } func TestImageFailuresAndCancellationNeverReplay(t *testing.T) { img := imageFixture(t) - for _, flow := range []int{1, 2} { + for _, flow := range []int{1} { stages := []string{"send", "attachment_upload"} if flow == 2 { stages = append(stages, "preview_upload") @@ -160,7 +138,7 @@ func TestImageFailuresAndCancellationNeverReplay(t *testing.T) { } c.crypto = &fileCrypto{fakeCrypto: crypto} c.Session.NewClient = func(string) session.API { return api } - result, err := c.SendImageContext(ctx, "u-peer", *img, "") + result, err := c.SendImageWithOptionsContext(ctx, "u-peer", *img, "", SendOptions{AllowPlaintext: true}) stop() // Cancellation after the final confirmed stage preserves success. if cancel && ((flow == 1 && stage == "attachment_upload") || (flow == 2 && stage == "send")) { @@ -197,7 +175,7 @@ func TestImageFailuresAndCancellationNeverReplay(t *testing.T) { } func TestImageCapabilitiesAndValidationFailClosed(t *testing.T) { img := imageFixture(t) - for _, mode := range []string{"missing-flow", "invalid-flow", "flow-error", "missing-key", "bad-key", "incomplete-members", "reply", "already-cancelled", "missing-oid", "missing-receipt"} { + for _, mode := range []string{"missing-flow", "invalid-flow", "flow-error", "missing-key", "bad-key", "incomplete-members", "reply", "already-cancelled"} { t.Run(mode, func(t *testing.T) { c, f, crypto, store := setup(t, false) api := &imageAPI{fakeAPI: f, flow: 2} @@ -233,24 +211,11 @@ func TestImageCapabilitiesAndValidationFailClosed(t *testing.T) { if err == nil { t.Fatal("unsafe image send accepted") } - if mode == "missing-receipt" { - var m *MutationError - if !errors.As(err, &m) || m.Mutation.Outcome != fault.Unknown || f.sends != 1 { - t.Fatal("missing receipt outcome") - } - return - } + if f.sends != 0 || f.registrations != 0 || store.state.LastReqSeq != 0 { t.Fatal("mutation after invalid preparation") } - if mode == "missing-oid" { - var safe *fault.Error - var mutation *MutationError - if !errors.As(err, &safe) || safe.Code != "remote_failed" || safe.Stage != "attachment_upload" || !errors.As(err, &mutation) || mutation.Mutation.Steps[0].Outcome != fault.Unknown { - t.Fatal("missing upload receipt classification", err) - } - } - if mode != "missing-oid" && len(api.order) != 0 { + if len(api.order) != 0 { t.Fatal("upload after invalid preparation") } }) diff --git a/internal/messaging/live_security_test.go b/internal/messaging/live_security_test.go new file mode 100644 index 0000000..6e81a66 --- /dev/null +++ b/internal/messaging/live_security_test.go @@ -0,0 +1,314 @@ +package messaging + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "strings" + "testing" + "time" + + "github.com/kongesque/line-cli/internal/fault" + "github.com/kongesque/line-cli/internal/session" + "github.com/kongesque/line-cli/pkg/line" +) + +// TestLiveSecuritySend sends a synthetic text and file (or only the file when +// a previously confirmed text receipt is explicitly supplied). It requires explicit +// human authorization for the target, fixed content, upload, and possible group +// key registration. Never enable it in ordinary CI or retry after an uncertain +// mutation outcome. Request bodies and credentials remain in memory only. +func TestLiveSecuritySend(t *testing.T) { + if os.Getenv("LINE_CLI_LIVE_SECURITY_SEND") != "1" { + t.Skip("explicit live send authorization required") + } + chat := os.Getenv("LINE_CLI_LIVE_SECURITY_CHAT") + if ValidateChatID(chat) != nil { + t.Fatal("explicit valid test chat ID required") + } + const content = "Security test from LINE CLI" + const filename = "line-security-test.txt" + file := Attachment{Name: filename, Data: []byte(content + "\n")} + unlock, err := session.Lock() + if err != nil { + t.Fatal("session lock unavailable") + } + defer unlock() + manager := session.NewManager(session.KeychainStore{}) + state, err := manager.Store.Load() + if err != nil { + t.Fatal("saved session unavailable") + } + if state.NoE2EE || state.Invalidated || len(state.ExportedKeys) == 0 { + t.Fatal("session is not ready for encrypted sends") + } + before := state.LastReqSeq + t.Log("session: saved encryption keys present; plaintext mode disabled") + sends, uploads, registrations, requests := 0, 0, 0, 0 + defer func() { + t.Logf("dispatch counts: sends=%d uploads=%d group_registrations=%d HTTPS_requests=%d", sends, uploads, registrations, requests) + }() + fail := func(stage string, err error) { + if d := line.RequestDiagnostics(err); d != nil { + encoded, _ := json.Marshal(d) + t.Logf("safe diagnostic: %s", encoded) + } + var mutation *MutationError + if errors.As(err, &mutation) { + t.Logf("mutation stage=%s outcome=%s steps=%+v", mutation.Stage, mutation.Mutation.Outcome, mutation.Mutation.Steps) + } + for cause := err; cause != nil; cause = errors.Unwrap(cause) { + t.Log("cause type:", fmt.Sprintf("%T", cause)) + } + t.Fatalf("%s failed; do not retry automatically", stage) + } + factory := manager.NewClient + manager.NewClient = func(token string) session.API { + api := factory(token).(*line.Client) + wrap := func(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return liveSecurityTransport(func(r *http.Request) (*http.Response, error) { + if r.URL.Scheme != "https" { + return nil, errors.New("live inspection rejected non-HTTPS request") + } + requests++ + isSend := strings.HasSuffix(r.URL.Path, "/sendMessage") + isUpload := r.URL.Host == "obs.line-apps.com" && r.Method == http.MethodPost + if isSend || isUpload { + if r.GetBody == nil { + return nil, errors.New("live inspection requires replayable local request body") + } + body, err := r.GetBody() + if err != nil { + return nil, errors.New("cannot inspect request") + } + raw, err := io.ReadAll(io.LimitReader(body, (1<<20)+1)) + body.Close() + if err != nil || len(raw) > 1<<20 { + return nil, errors.New("cannot inspect complete request") + } + defer clear(raw) + if bytes.Contains(raw, []byte(content)) || bytes.Contains(raw, []byte(filename)) { + return nil, errors.New("live inspection blocked plaintext content or filename") + } + if isSend { + var args []json.RawMessage + var message line.Message + if json.Unmarshal(raw, &args) != nil || len(args) != 2 || json.Unmarshal(args[1], &message) != nil { + return nil, errors.New("unexpected send envelope") + } + if message.To != chat || len(message.Chunks) != 5 || message.Text != "" || message.ContentMetadata["ENC_KM"] != "" || message.ContentMetadata["FILE_NAME"] != "" { + return nil, errors.New("live inspection blocked unsealed envelope") + } + sends++ + if sends > 2 { + return nil, errors.New("live inspection blocked additional send") + } + } else { + if !strings.Contains(r.URL.Path, "/emf") || bytes.Equal(raw, file.Data) { + return nil, errors.New("live inspection blocked plaintext attachment") + } + uploads++ + if uploads > 1 { + return nil, errors.New("live inspection blocked additional upload") + } + } + } + if strings.HasSuffix(r.URL.Path, "/registerE2EEGroupKey") { + registrations++ + } + resp, err := base.RoundTrip(r) + if isSend || isUpload { + if resp != nil { + t.Logf("transport send=%v upload=%v status=%d object_id_header=%v", isSend, isUpload, resp.StatusCode, resp.Header.Get("x-obs-oid") != "") + } + if err != nil { + t.Logf("transport error type=%T", err) + } + } + return resp, err + }) + } + api.HTTPClient.Transport = wrap(api.HTTPClient.Transport) + api.OBSClient.Transport = wrap(api.OBSClient.Transport) + return api + } + client, err := New(manager) + if err != nil || client.crypto == nil || client.keyError != nil { + t.Fatal("could not restore saved encryption keys") + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + var capability *line.MediaMessageFlowResponse + err = manager.DoContext(ctx, func(api session.API) (err error) { + capability, err = api.DetermineMediaMessageFlowContext(ctx, chat) + return + }) + if err != nil || capability == nil || capability.FlowMap["14"] != 2 { + t.Fatal("encrypted file capability is unavailable; no test messages were sent") + } + textResult := &SendResult{ID: os.Getenv("LINE_CLI_LIVE_SECURITY_TEXT_ID"), Encrypted: true} + expectedSends := 1 + if textResult.ID == "" { + expectedSends = 2 + textResult, err = client.SendContext(ctx, chat, content) + if err != nil { + fail("text send", err) + } + if textResult == nil || !textResult.Encrypted { + t.Fatal("text receipt was not encrypted") + } + t.Logf("text confirmed: id=%s encrypted=true", textResult.ID) + } else { + t.Log("read-only verification of previously confirmed text; text will not be resent") + } + fileResult, err := client.SendFileContext(ctx, chat, file, "") + if err != nil { + fail("file send", err) + } + if fileResult == nil || !fileResult.Encrypted { + t.Fatal("file receipt was not encrypted") + } + t.Logf("file confirmed: id=%s encrypted=true", fileResult.ID) + raw, err := client.recentMessages(ctx, chat, 20) + if err != nil { + t.Fatal("history verification failed; messages were sent; do not resend") + } + found := map[string]bool{} + for _, m := range raw { + if m == nil || (m.ID != textResult.ID && m.ID != fileResult.ID) { + continue + } + if len(m.Chunks) != 5 || m.Text != "" || m.ContentMetadata["ENC_KM"] != "" || m.ContentMetadata["FILE_NAME"] != "" { + t.Fatal("server history exposed unsealed content or attachment metadata") + } + found[m.ID] = true + } + if !found[textResult.ID] || !found[fileResult.ID] { + t.Fatal("new messages absent from recent history; do not resend") + } + var decoded []Message + for _, m := range raw { + if m != nil && (m.ID == textResult.ID || m.ID == fileResult.ID) { + decoded = append(decoded, client.DecodeContext(ctx, chat, m)) + } + } + verified := map[string]bool{} + for _, m := range decoded { + if m.ID == textResult.ID { + verified[m.ID] = m.Encrypted && m.Status == "decrypted" && m.Text == content + } + if m.ID == fileResult.ID { + verified[m.ID] = m.Encrypted && m.Content.Status == "ok" && m.Content.Attachment != nil && m.Content.Attachment.Name != nil && *m.Content.Attachment.Name == filename + } + } + if !verified[textResult.ID] || !verified[fileResult.ID] { + t.Fatal("decrypted history did not match test content; do not resend") + } + data, err := client.DownloadFile(ctx, chat, fileResult.ID) + if err != nil || !bytes.Equal(data, file.Data) { + t.Fatal("attachment authentication or byte comparison failed; do not resend") + } + clear(data) + after, err := manager.Store.Load() + if err != nil || after.LastReqSeq <= before { + t.Fatal("request sequence persistence not verified") + } + if sends != expectedSends || uploads != 1 { + t.Fatal("unexpected mutation dispatch count") + } + t.Logf("verified: sealed wire envelopes; encrypted upload; authenticated download matches; sends=%d uploads=%d group_registrations=%d HTTPS_requests=%d sequence_advanced=true", sends, uploads, registrations, requests) +} + +type liveSecurityTransport func(*http.Request) (*http.Response, error) + +func (f liveSecurityTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// TestLiveSecurityMediaFlow reads only the selected chat's declared media policy. +func TestLiveSecurityMediaFlow(t *testing.T) { + if os.Getenv("LINE_CLI_LIVE_SECURITY_READ") != "1" { + t.Skip("explicit live read authorization required") + } + chat := os.Getenv("LINE_CLI_LIVE_SECURITY_CHAT") + if ValidateChatID(chat) != nil { + t.Fatal("explicit valid test chat required") + } + unlock, err := session.Lock() + if err != nil { + t.Fatal("session lock unavailable") + } + defer unlock() + manager := session.NewManager(session.KeychainStore{}) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // Match Chrome's read-only negotiation order before requesting media flow. + // Inspect only capability fields; never print group keys or member identities. + if toType(chat) != 0 { + var key *line.E2EEGroupSharedKey + err = manager.DoContext(ctx, func(api session.API) (err error) { + key, err = api.GetLastE2EEGroupSharedKeyContext(ctx, chat) + return + }) + if err != nil || key == nil { + t.Fatal("group capability read failed; no key registration attempted") + } + textAllowed, fileAllowed := false, false + for _, kind := range key.AllowedTypes { + textAllowed = textAllowed || kind == 0 + fileAllowed = fileAllowed || kind == 14 + } + t.Logf("group negotiation: spec_version=%d allowed_types_present=%v text_allowed=%v file_allowed=%v", key.SpecVersion, key.AllowedTypes != nil, textAllowed, fileAllowed) + } + var flow *line.MediaMessageFlowResponse + err = manager.DoContext(ctx, func(api session.API) (err error) { flow, err = api.DetermineMediaMessageFlowContext(ctx, chat); return }) + if err != nil || flow == nil { + t.Fatal("media capability read failed") + } + t.Logf("declared media flows: image=%d file=%d", flow.FlowMap["1"], flow.FlowMap["14"]) + if flow.FlowMap["14"] != 1 { + return + } + state, err := manager.Store.Load() + if err != nil { + t.Fatal("saved session unavailable") + } + before := state.LastReqSeq + factory := manager.NewClient + blocked := 0 + manager.NewClient = func(token string) session.API { + api := factory(token).(*line.Client) + guard := liveSecurityTransport(func(r *http.Request) (*http.Response, error) { + // This phase is explicitly read-only. Only capability and blocked-contact + // reads may reach the network, even if a regression attempts a mutation. + if r.URL.Scheme != "https" || (!strings.HasSuffix(r.URL.Path, "/determineMediaMessageFlow") && !strings.HasSuffix(r.URL.Path, "/getBlockedContactIds")) { + blocked++ + return nil, errors.New("read-only inspection blocked unexpected request") + } + return http.DefaultTransport.RoundTrip(r) + }) + api.HTTPClient.Transport = guard + api.OBSClient.Transport = guard + return api + } + client, err := New(manager) + if err != nil { + t.Fatal("session initialization failed") + } + _, err = client.SendFileContext(ctx, chat, Attachment{Name: "line-security-test.txt", Data: []byte("Security test from LINE CLI\n")}, "") + var refusal *fault.Error + if !errors.As(err, &refusal) || refusal.Code != "encryption_required" || blocked != 0 { + t.Fatal("default file policy did not refuse before any mutation attempt") + } + after, err := manager.Store.Load() + if err != nil || after.LastReqSeq != before { + t.Fatal("read-only refusal changed request sequence") + } + t.Log("verified live default file refusal: encryption_required; uploads=0 sends=0 registrations=0 sequence_unchanged=true") +} diff --git a/internal/messaging/mentions_test.go b/internal/messaging/mentions_test.go index e50546a..831a087 100644 --- a/internal/messaging/mentions_test.go +++ b/internal/messaging/mentions_test.go @@ -53,7 +53,7 @@ func TestSendMentionsPreservesEncryptionReplyAndSequence(t *testing.T) { c, f, crypto, store := setup(t, plain) f.group = &line.E2EEGroupSharedKey{GroupKeyID: 33, CreatorKeyID: 22, ReceiverKeyID: 11, Creator: "u-peer", EncryptedSharedKey: "wrapped"} f.history = []*line.Message{{ID: "9007199254740993", From: "u-self", To: chat, ToType: toType(chat)}} - result, err := c.SendMentionsContext(context.Background(), chat, "๐Ÿ˜€ @Peer", "9007199254740993", []Mention{{Start: 3, End: 8, MID: mentionPeer}}) + result, err := c.SendMentionsWithOptionsContext(context.Background(), chat, "๐Ÿ˜€ @Peer", "9007199254740993", []Mention{{Start: 3, End: 8, MID: mentionPeer}}, SendOptions{AllowPlaintext: true}) if err != nil { t.Fatal(err) } diff --git a/internal/messaging/outcome_test.go b/internal/messaging/outcome_test.go index ecdf590..1fd582c 100644 --- a/internal/messaging/outcome_test.go +++ b/internal/messaging/outcome_test.go @@ -34,7 +34,7 @@ func TestSendOutcomeAndSequence(t *testing.T) { t.Run(tc.name, func(t *testing.T) { c, api, _, store := setup(t, true) api.sendErr = tc.failure - _, err := c.Send("u-peer", "synthetic") + _, err := c.SendMentionsWithOptionsContext(context.Background(), "u-peer", "synthetic", "", nil, SendOptions{AllowPlaintext: true}) e := mutationDetails(t, err) if e.Mutation.Outcome != tc.outcome || !e.Mutation.Attempted || e.Mutation.ChatID != "u-peer" || e.Mutation.RequestSequence == nil || *e.Mutation.RequestSequence != store.state.LastReqSeq || api.sends != 1 { t.Fatalf("bad outcome: %+v", e.Mutation) @@ -49,7 +49,7 @@ func TestSendOutcomeAndSequence(t *testing.T) { func TestSequenceFailureNeverAttemptsSend(t *testing.T) { c, api, _, store := setup(t, true) store.saveErr = session.ErrDurabilityUncertain - _, err := c.Send("u-peer", "synthetic") + _, err := c.SendMentionsWithOptionsContext(context.Background(), "u-peer", "synthetic", "", nil, SendOptions{AllowPlaintext: true}) e := mutationDetails(t, err) if e.Stage != "persist_sequence" || e.Mutation.Outcome != fault.NotAttempted || e.Mutation.Attempted || api.sends != 0 || session.StorageExitCode(err) != 74 { t.Fatalf("bad outcome: %+v", e) @@ -60,7 +60,7 @@ func TestPlainFileRetainsConfirmedMessageAndFailedUpload(t *testing.T) { c, api, _, _ := setup(t, true) f := &fileAPI{fakeAPI: api, plainErr: errors.New("secret-token raw-body ciphertext")} c.Session.NewClient = func(string) session.API { return f } - _, err := c.SendFile("u-peer", Attachment{Name: "file.txt"}, "") + _, err := c.SendFileWithOptionsContext(context.Background(), "u-peer", Attachment{Name: "file.txt"}, "", SendOptions{AllowPlaintext: true}) e := mutationDetails(t, err) op := e.Mutation if op.Outcome != fault.Confirmed || op.MessageID != "server-id" || len(op.Steps) != 2 || op.Steps[1].Outcome != fault.Unknown || e.Stage != "attachment_upload" { @@ -78,7 +78,7 @@ func TestSuccessfulPreparationSurvivesLaterFailure(t *testing.T) { c.Session.NewClient = func(string) session.API { return f } crypto.encryptErr = errors.New("secret crypto material") c.crypto = &fileCrypto{fakeCrypto: crypto} - _, err := c.SendFile("u-peer", Attachment{Name: "file.txt"}, "") + _, err := c.SendFileWithOptionsContext(context.Background(), "u-peer", Attachment{Name: "file.txt"}, "", SendOptions{AllowPlaintext: true}) e := mutationDetails(t, err) if e.Mutation.Outcome != fault.NotAttempted || len(e.Mutation.Steps) != 1 || e.Mutation.Steps[0].Outcome != fault.Confirmed || api.sends != 0 { t.Fatalf("lost upload: %+v", e) @@ -93,7 +93,7 @@ func TestCancelledUploadRetainsSeparateOutcomes(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() c.Session.NewClient = func(string) session.API { return &cancellationAPI{fakeAPI: api, cancel: cancel, phase: "confirmed"} } - _, err := c.SendFileContext(ctx, "u-peer", Attachment{Name: "file.txt"}, "") + _, err := c.SendFileWithOptionsContext(ctx, "u-peer", Attachment{Name: "file.txt"}, "", SendOptions{AllowPlaintext: true}) e := mutationDetails(t, err) if e.Mutation.Outcome != fault.Confirmed || len(e.Mutation.Steps) != 2 || e.Mutation.Steps[1].Outcome != fault.NotAttempted || e.Mutation.Steps[1].Attempted { t.Fatalf("bad upload outcome: %+v", e.Mutation) @@ -109,7 +109,7 @@ func (f missingReceiptAPI) SendMessageContext(_ context.Context, seq int64, msg func TestMissingSendReceiptIsUnknown(t *testing.T) { c, api, _, _ := setup(t, true) c.Session.NewClient = func(string) session.API { return missingReceiptAPI{api} } - _, err := c.Send("u-peer", "synthetic") + _, err := c.SendMentionsWithOptionsContext(context.Background(), "u-peer", "synthetic", "", nil, SendOptions{AllowPlaintext: true}) e := mutationDetails(t, err) if e.Mutation.Outcome != fault.Unknown || e.Mutation.Steps[0].Outcome != fault.Unknown || api.sends != 1 { t.Fatalf("false confirmation: %+v", e) diff --git a/internal/messaging/send_policy_test.go b/internal/messaging/send_policy_test.go new file mode 100644 index 0000000..e961d58 --- /dev/null +++ b/internal/messaging/send_policy_test.go @@ -0,0 +1,186 @@ +package messaging + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/kongesque/line-cli/internal/fault" + "github.com/kongesque/line-cli/internal/session" + "github.com/kongesque/line-cli/pkg/line" +) + +type claimGroupAPI struct { + *fakeAPI + wire *line.Client +} + +func (a *claimGroupAPI) GetLastE2EEGroupSharedKeyContext(ctx context.Context, chat string) (*line.E2EEGroupSharedKey, error) { + return a.wire.GetLastE2EEGroupSharedKeyContext(ctx, chat) +} + +func TestPlaintextConsentCapabilityAndFailureBoundaries(t *testing.T) { + for _, allow := range []bool{false, true} { + for _, tc := range []struct { + name, body string + network, sends bool + }{ + {name: "exceed_max_member", sends: true, body: `{"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","code":100,"reason":"exceed max member"}}`}, + {name: "member_settings_off", sends: true, body: `{"code":10051,"message":"RESPONSE_ERROR","data":{"name":"TalkException","code":98,"reason":"member settings off"}}`}, + {name: "missing_key", body: `{"code":0,"data":{}}`}, + {name: "malformed_key", body: `{"code":0,"data":{"groupKeyId":33,"creatorKeyId":11,"receiverKeyId":11,"encryptedSharedKey":""}}`}, + {name: "network_failure", network: true}, + } { + t.Run(fmt.Sprintf("%s/allow=%t", tc.name, allow), func(t *testing.T) { + c, f, _, store := setup(t, false) + wire := line.NewClient("synthetic-token") + wire.HTTPClient.Transport = claimTransport(func(r *http.Request) (*http.Response, error) { + if tc.network { + return nil, errors.New("synthetic network failure") + } + status := 200 + if tc.sends { + status = 400 + } + return claimResponse(r, status, tc.body), nil + }) + api := &claimGroupAPI{fakeAPI: f, wire: wire} + c.Session.NewClient = func(string) session.API { return api } + before := store.state.LastReqSeq + result, err := c.SendMentionsWithOptionsContext(context.Background(), "c-group", "synthetic private text", "", nil, SendOptions{AllowPlaintext: allow}) + if tc.sends && allow { + if err != nil || result == nil || result.Encrypted || f.sends != 1 || f.sent.Text != "synthetic private text" || len(f.sent.Chunks) != 0 || store.state.LastReqSeq <= before { + t.Fatal("expected one persisted-sequence plaintext send after explicit unavailability") + } + t.Log("CONFIRMED: explicit capability rejection causes one plaintext send and Encrypted=false") + } else if err == nil || f.sends != 0 || f.registrations != 0 || store.state.LastReqSeq != before { + t.Fatal("missing/malformed/network failure incorrectly allowed mutation") + } else { + t.Log("CONFIRMED: failure stops before any send, group registration, or sequence reservation") + } + }) + } + } + +} + +func TestSendConsentAcrossContentAndDestinations(t *testing.T) { + img := imageFixture(t) + for _, chat := range []string{"u-peer", "c-group", "r-room"} { + for _, mode := range []string{"account-text", "account-reply", "account-file", "account-image", "image-flow", "peer-off", "group-off"} { + for _, allow := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/%t", chat, mode, allow), func(t *testing.T) { + c, f, crypt, store := setup(t, strings.HasPrefix(mode, "account")) + api := &imageAPI{fakeAPI: f, flow: 1} + c.crypto = &fileCrypto{fakeCrypto: crypt} + c.Session.NewClient = func(string) session.API { return api } + f.history = []*line.Message{{ID: "123", From: "u-self", To: chat, ToType: toType(chat)}} + f.negotiateErr = line.ErrE2EEDisabled + f.groupErr = line.ErrE2EEDisabled + opts := SendOptions{AllowPlaintext: allow} + var result *SendResult + var err error + switch mode { + case "account-file": + files := &fileAPI{fakeAPI: f} + c.Session.NewClient = func(string) session.API { return files } + result, err = c.SendFileWithOptionsContext(context.Background(), chat, Attachment{Name: "test.txt", Data: []byte("synthetic")}, "123", opts) + case "account-image", "image-flow": + result, err = c.SendImageWithOptionsContext(context.Background(), chat, *img, "", opts) + default: + result, err = c.SendMentionsWithOptionsContext(context.Background(), chat, "@Peer", "123", []Mention{{Start: 0, End: 5, MID: mentionPeer}}, opts) + } + if allow { + if err != nil || result == nil || result.Encrypted || f.sends != 1 { + t.Fatal("explicit plaintext consent not honored", err) + } + } else { + var safe *fault.Error + if !errors.As(err, &safe) || safe.Code != "encryption_required" || f.sends != 0 || f.registrations != 0 || len(api.order) != 0 || store.state.LastReqSeq != 0 { + t.Fatal("plaintext rejection had side effects or wrong error", err) + } + } + }) + } + } + } +} + +func TestLegacySendMethodsRequireEncryption(t *testing.T) { + img := imageFixture(t) + c, f, _, store := setup(t, true) + f.history = []*line.Message{{ID: "123", From: "u-self", To: "u-peer"}} + calls := []func() (*SendResult, error){ + func() (*SendResult, error) { return c.Send("u-peer", "text") }, + func() (*SendResult, error) { return c.SendReply("u-peer", "text", "123") }, + func() (*SendResult, error) { return c.SendFile("u-peer", Attachment{Name: "test.txt"}, "") }, + func() (*SendResult, error) { + return c.SendMentionsContext(context.Background(), "u-peer", "text", "", nil) + }, + func() (*SendResult, error) { return c.SendImageContext(context.Background(), "u-peer", *img, "") }, + } + for _, call := range calls { + _, err := call() + var safe *fault.Error + if !errors.As(err, &safe) || safe.Code != "encryption_required" || f.sends != 0 || store.state.LastReqSeq != 0 { + t.Fatal("legacy method permits plaintext", err) + } + } +} + +type fileFlowAPI struct { + *fileAPI + flow *line.MediaMessageFlowResponse + err error +} + +func (a *fileFlowAPI) DetermineMediaMessageFlowContext(context.Context, string) (*line.MediaMessageFlowResponse, error) { + return a.flow, a.err +} + +func TestFileMediaCapabilityBeforeMutation(t *testing.T) { + for _, chat := range []string{"u-peer", "c-group", "r-room"} { + for _, allow := range []bool{false, true} { + for _, tc := range []struct { + name string + flow *line.MediaMessageFlowResponse + err error + plain bool + }{ + {name: "plaintext-only", flow: &line.MediaMessageFlowResponse{FlowMap: map[string]int{"1": 2, "14": 1}}, plain: true}, + {name: "missing-response"}, + {name: "missing-file-type", flow: &line.MediaMessageFlowResponse{FlowMap: map[string]int{"1": 2}}}, + {name: "unknown-file-flow", flow: &line.MediaMessageFlowResponse{FlowMap: map[string]int{"14": 3}}}, + {name: "transport-failure", err: errors.New("synthetic unavailable")}, + } { + t.Run(fmt.Sprintf("%s/%s/%t", chat, tc.name, allow), func(t *testing.T) { + c, f, crypt, store := setup(t, false) + files := &fileAPI{fakeAPI: f} + api := &fileFlowAPI{fileAPI: files, flow: tc.flow, err: tc.err} + c.crypto = &fileCrypto{fakeCrypto: crypt} + c.Session.NewClient = func(string) session.API { return api } + file := Attachment{Name: "synthetic.txt", Data: []byte("synthetic private file")} + result, err := c.SendFileWithOptionsContext(context.Background(), chat, file, "", SendOptions{AllowPlaintext: allow}) + if tc.plain && allow { + if err != nil || result == nil || result.Encrypted || f.sends != 1 || strings.Join(files.order, ",") != "send,plain_upload" { + t.Fatal("explicit plaintext file policy failed", err) + } + } else { + if err == nil || result != nil || f.sends != 0 || f.registrations != 0 || len(files.order) != 0 || store.state.LastReqSeq != 0 { + t.Fatal("file capability failure had mutation side effects") + } + if tc.plain { + var safe *fault.Error + if !errors.As(err, &safe) || safe.Code != "encryption_required" { + t.Fatal("wrong default file refusal") + } + } + } + }) + } + } + } +} diff --git a/internal/messaging/test_helpers_test.go b/internal/messaging/test_helpers_test.go index e0a9130..1b43b2f 100644 --- a/internal/messaging/test_helpers_test.go +++ b/internal/messaging/test_helpers_test.go @@ -277,3 +277,7 @@ func (f *fileAPI) SendMessageContext(_ context.Context, seq int64, msg *line.Mes func (f *fakeAPI) GetPreviousMessagesV2Context(context.Context, string, line.MessageIDWrapper, int) ([]*line.Message, error) { return nil, nil } + +func (f *fakeAPI) DetermineMediaMessageFlowContext(context.Context, string) (*line.MediaMessageFlowResponse, error) { + return &line.MediaMessageFlowResponse{FlowMap: map[string]int{"1": 2, "14": 2}}, nil +} diff --git a/pkg/line/client.go b/pkg/line/client.go index fa0e884..1151a45 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -9,7 +9,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "log" "net/http" "net/url" @@ -53,7 +52,7 @@ type Client struct { } type OBSDownloadOptions struct { - // MaxBytes bounds the downloaded object when positive; zero leaves the object size unbounded. + // MaxBytes bounds the downloaded object when positive; zero uses 20 MiB plus the authentication tag. MaxBytes int64 TID string OBSPop string @@ -209,13 +208,15 @@ func (c *Client) waitForLoginJQ(verifier string) (*LoginResult, error) { req.Header.Set("x-hmac", signature) pollClient := &http.Client{Timeout: 200 * time.Second} - resp, err := pollClient.Do(req) + resp, err := doLINERequest(pollClient, req) if err != nil { return nil, err } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := readResponseBody(resp, maxRPCResponseBytes) + if err != nil { + return nil, err + } // JQ response: {"data":{"result":{"verifier":"...","authPhase":"QRCODE_VERIFIED",...}}} var wrapper struct { @@ -273,13 +274,15 @@ func (c *Client) waitForLoginLF1(verifier string) (*LoginResult, error) { req.Header.Set("x-hmac", signature) pollClient := &http.Client{Timeout: 120 * time.Second} - resp, err := pollClient.Do(req) + resp, err := doLINERequest(pollClient, req) if err != nil { return nil, err } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + body, err := readResponseBody(resp, maxRPCResponseBytes) + if err != nil { + return nil, err + } var wrapper struct { Code int `json:"code"` @@ -405,13 +408,12 @@ func (c *Client) callRPCWithBaseURLContext(ctx context.Context, baseURL, service if err := ctx.Err(); err != nil { return nil, err } - resp, err := c.HTTPClient.Do(req) + resp, err := doLINERequest(c.HTTPClient, req) if err != nil { return nil, requestFailure("transport", err) } - defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + respBody, err := readResponseBody(resp, maxRPCResponseBytes) if err != nil { return nil, requestFailure("response_read", err) } @@ -498,13 +500,12 @@ func (c *Client) postWithHMACContext(ctx context.Context, fullURL string, body [ if err := ctx.Err(); err != nil { return nil, err } - resp, err := c.HTTPClient.Do(req) + resp, err := doLINERequest(c.HTTPClient, req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() - respBody, err := io.ReadAll(resp.Body) + respBody, err := readResponseBody(resp, maxRPCResponseBytes) if err != nil { return nil, fmt.Errorf("read LINE response: %w", err) } @@ -609,16 +610,18 @@ func (c *Client) UploadOBSPlainContext(ctx context.Context, data []byte, oid str if err := ctx.Err(); err != nil { return err } - resp, err := c.obsHTTPClient().Do(req) + resp, err := doLINERequest(c.obsHTTPClient(), req) if err != nil { return fmt.Errorf("OBS upload request failed: %w", err) } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + _, err = readResponseBody(resp, maxOBSMetadataBytes) + if err != nil { + return err + } if resp.StatusCode != 200 && resp.StatusCode != 201 { - return fmt.Errorf("OBS upload failed (%d): %s", resp.StatusCode, string(body)) + return fmt.Errorf("OBS upload failed (%d)", resp.StatusCode) } return nil @@ -678,16 +681,18 @@ func (c *Client) UploadOBSWithSIDContext(ctx context.Context, data []byte, sid s if err := ctx.Err(); err != nil { return "", err } - resp, err := c.obsHTTPClient().Do(req) + resp, err := doLINERequest(c.obsHTTPClient(), req) if err != nil { return "", fmt.Errorf("OBS upload request failed: %w", err) } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + _, err = readResponseBody(resp, maxOBSMetadataBytes) + if err != nil { + return "", err + } if resp.StatusCode != 200 && resp.StatusCode != 201 { - return "", fmt.Errorf("OBS upload failed (%d): %s", resp.StatusCode, string(body)) + return "", fmt.Errorf("OBS upload failed (%d)", resp.StatusCode) } // The OID is typically returned in the x-obs-oid header @@ -743,16 +748,18 @@ func (c *Client) UploadOBSWithOIDAndSIDContext(ctx context.Context, data []byte, if err := ctx.Err(); err != nil { return err } - resp, err := c.obsHTTPClient().Do(req) + resp, err := doLINERequest(c.obsHTTPClient(), req) if err != nil { return fmt.Errorf("OBS upload request failed: %w", err) } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) + _, err = readResponseBody(resp, maxOBSMetadataBytes) + if err != nil { + return err + } if resp.StatusCode != 200 && resp.StatusCode != 201 { - return fmt.Errorf("OBS upload failed (%d): %s", resp.StatusCode, string(body)) + return fmt.Errorf("OBS upload failed (%d)", resp.StatusCode) } return nil @@ -796,12 +803,11 @@ func (c *Client) DownloadOBSPublicResource(ctx context.Context, resourcePath str if err := ctx.Err(); err != nil { return nil, err } - resp, err := c.obsHTTPClient().Do(req) + resp, err := doLINERequest(c.obsHTTPClient(), req) if err != nil { return nil, fmt.Errorf("public OBS resource request failed: %w", err) } - body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() + body, readErr := readResponseBody(resp, maxMediaResponseBytes) if readErr != nil { return nil, fmt.Errorf("failed to read public OBS resource response: %w", readErr) } @@ -859,12 +865,11 @@ func (c *Client) DownloadAlbumPreview(ctx context.Context, oid, chatID, albumID if err := ctx.Err(); err != nil { return nil, err } - resp, err := c.obsHTTPClient().Do(req) + resp, err := doLINERequest(c.obsHTTPClient(), req) if err != nil { return nil, fmt.Errorf("OBS download request failed: %w", err) } - body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() + body, readErr := readResponseBody(resp, maxMediaResponseBytes) if readErr != nil { return nil, fmt.Errorf("failed to read OBS response body: %w", readErr) } @@ -953,12 +958,11 @@ func (c *Client) checkOBSObjectReady(ctx context.Context, obsURL, obsToken, mess if err := ctx.Err(); err != nil { return err } - resp, err := c.obsHTTPClient().Do(req) + resp, err := doLINERequest(c.obsHTTPClient(), req) if err != nil { return fmt.Errorf("OBS object info request failed: %w", err) } - body, readErr := io.ReadAll(resp.Body) - resp.Body.Close() + body, readErr := readResponseBody(resp, maxOBSMetadataBytes) if readErr != nil { return fmt.Errorf("failed to read OBS object info response: %w", readErr) } @@ -991,20 +995,17 @@ func (c *Client) downloadOBSObject(ctx context.Context, obsURL, obsToken, messag if err := ctx.Err(); err != nil { return nil, err } - resp, err := c.obsHTTPClient().Do(req) + resp, err := doLINERequest(c.obsHTTPClient(), req) if err != nil { return nil, fmt.Errorf("OBS download request failed: %w", err) } - var reader io.Reader = resp.Body - if maxBytes > 0 { - reader = io.LimitReader(resp.Body, maxBytes+1) + if maxBytes == 0 { + maxBytes = maxMediaResponseBytes } - body, readErr := io.ReadAll(reader) - if maxBytes > 0 && int64(len(body)) > maxBytes { - resp.Body.Close() + body, readErr := readResponseBody(resp, maxBytes) + if errors.Is(readErr, ErrResponseTooLarge) { return nil, ErrOBSSizeLimit } - resp.Body.Close() if readErr != nil { return nil, fmt.Errorf("failed to read OBS response body: %w", readErr) } @@ -1094,18 +1095,21 @@ func (c *Client) GetPageInfo(url string) (*PageInfoResult, error) { req.Header.Set("Cookie", fmt.Sprintf("lct=%s", c.AccessToken)) } - resp, err := c.HTTPClient.Do(req) + resp, err := doLINERequest(c.HTTPClient, req) if err != nil { return nil, err } - defer resp.Body.Close() + body, err := readResponseBody(resp, maxRPCResponseBytes) + if err != nil { + return nil, err + } if resp.StatusCode != 200 { return nil, fmt.Errorf("pageinfo request failed: %d", resp.StatusCode) } var wrapper PageInfoResponse - if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil { + if err := json.Unmarshal(body, &wrapper); err != nil { return nil, err } diff --git a/pkg/line/http_policy.go b/pkg/line/http_policy.go new file mode 100644 index 0000000..56bcba1 --- /dev/null +++ b/pkg/line/http_policy.go @@ -0,0 +1,29 @@ +package line + +import ( + "errors" + "net/http" +) + +// ErrRedirectRejected indicates an unknown outcome, never an authoritative +// mutation rejection. The original endpoint may have processed the request. +var ErrRedirectRejected = errors.New("LINE HTTP redirect rejected") + +// doLINERequest enforces the policy at dispatch, including caller-supplied +// clients. Copy settings without mutating a client shared by other users. +func doLINERequest(client *http.Client, req *http.Request) (*http.Response, error) { + if client == nil { + client = http.DefaultClient + } + restricted := *client + restricted.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + resp, err := restricted.Do(req) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + resp.Body.Close() + return nil, ErrRedirectRejected + } + return resp, nil +} diff --git a/pkg/line/http_policy_test.go b/pkg/line/http_policy_test.go new file mode 100644 index 0000000..6d4b038 --- /dev/null +++ b/pkg/line/http_policy_test.go @@ -0,0 +1,128 @@ +package line + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "strings" + "testing" + "time" +) + +// Every request is intercepted in memory. No DNS, sockets, or real credentials. +func auditResponse(r *http.Request, status int, body io.Reader) *http.Response { + return &http.Response{StatusCode: status, Header: make(http.Header), Body: io.NopCloser(body), Request: r} +} + +func TestAuthenticatedRequestsRejectRedirects(t *testing.T) { + for _, path := range []string{"rpc", "obs", "sse"} { + for _, target := range []string{"https://audit.invalid/collect", "http://audit.invalid/collect"} { + t.Run(path+"/"+strings.Split(target, ":")[0], func(t *testing.T) { + c := NewClient("synthetic-audit-token") + var redirected, credential, bodyReplayed bool + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.URL.Host == "audit.invalid" { + redirected = true + credential = r.Header.Get("x-line-access") != "" + if r.Body != nil { + b, _ := io.ReadAll(r.Body) + bodyReplayed = len(b) > 0 + } + return auditResponse(r, 200, strings.NewReader(`{"code":0,"data":{}}`)), nil + } + res := auditResponse(r, 307, strings.NewReader("")) + res.Header.Set("Location", target) + return res, nil + }) + c.HTTPClient.Transport = transport + c.OBSClient.Transport = transport + switch path { + case "rpc": + _, _ = c.callRPCContext(context.Background(), "TalkService", "sendMessage", 1, map[string]string{"text": "synthetic"}) + case "obs": + _, _ = c.downloadOBSObject(context.Background(), OBSBaseURL+"/r/talk/m/synthetic", "synthetic-obs-token", "123", 1024) + case "sse": + old := sseHTTPClient + t.Cleanup(func() { sseHTTPClient = old }) + copy := *sseHTTPClient + copy.Transport = transport + sseHTTPClient = © + _ = c.ListenSSE(context.Background(), 0, func(string, string) {}) + } + if redirected { + t.Fatalf("unsafe redirect followed: credential_forwarded=%t request_body_replayed=%t", credential, bodyReplayed) + } + }) + } + } +} + +func TestMutationRedirectDoesNotReplay(t *testing.T) { + c := NewClient("synthetic-audit-token") + dispatches := 0 + c.HTTPClient.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) { + if r.Method == http.MethodPost && r.Body != nil { + b, _ := io.ReadAll(r.Body) + if len(b) > 0 { + dispatches++ + } + } + if dispatches == 1 { + res := auditResponse(r, 307, strings.NewReader("")) + res.Header.Set("Location", r.URL.String()+"?redirected=1") + return res, nil + } + return auditResponse(r, 200, strings.NewReader(`{"code":0,"data":{}}`)), nil + }) + _, _ = c.callRPCContext(context.Background(), "TalkService", "sendMessage", 1, map[string]string{"text": "synthetic"}) + if dispatches != 1 { + t.Fatalf("one mutation call dispatched its POST body %d times", dispatches) + } +} + +func TestRedirectPolicyOverridesCallerAndPreservesClient(t *testing.T) { + for _, status := range []int{301, 302, 303, 307, 308} { + for _, target := range []string{"https://source.invalid/next", "https://other.invalid/collect", "http://other.invalid/collect", "https://other.invalid/chain"} { + t.Run(fmt.Sprintf("%d/%s", status, target), func(t *testing.T) { + dispatches, callbacks, closed := 0, 0, false + jar, _ := cookiejar.New(nil) + transport := roundTripFunc(func(r *http.Request) (*http.Response, error) { + dispatches++ + if dispatches != 1 { + t.Fatal("redirect dispatched private headers/body") + } + resp := auditResponse(r, status, strings.NewReader("private raw response")) + resp.Body = &policyBody{Reader: strings.NewReader("private raw response"), closed: &closed} + resp.Header.Set("Location", target) + return resp, nil + }) + client := &http.Client{Transport: transport, Jar: jar, Timeout: time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { callbacks++; return nil }} + req, _ := http.NewRequest("POST", "https://source.invalid/original", strings.NewReader("private mutation")) + for _, key := range []string{"x-line-access", "Cookie", "x-hmac", "x-talk-meta"} { + req.Header.Set(key, "synthetic") + } + _, err := doLINERequest(client, req) + if !errors.Is(err, ErrRedirectRejected) || IsAuthoritativeRejection(err) || dispatches != 1 || callbacks != 0 || !closed { + t.Fatal("redirect was not safely rejected") + } + if client.Jar != jar || client.Timeout != time.Second || client.Transport == nil { + t.Fatal("caller client settings changed") + } + _ = client.CheckRedirect(nil, nil) + if callbacks != 1 { + t.Fatal("caller redirect callback mutated") + } + }) + } + } +} + +type policyBody struct { + io.Reader + closed *bool +} + +func (b *policyBody) Close() error { *b.closed = true; return nil } diff --git a/pkg/line/qr_login.go b/pkg/line/qr_login.go index 088c33e..834e8d7 100644 --- a/pkg/line/qr_login.go +++ b/pkg/line/qr_login.go @@ -55,7 +55,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "net/url" "strconv" @@ -400,18 +399,14 @@ func (c *Client) qrRequest(ctx context.Context, serviceRoot, method string, arg if err := ctx.Err(); err != nil { return nil, false, err } - resp, err := c.qrHTTPClient(wait).Do(req) + resp, err := doLINERequest(c.qrHTTPClient(wait), req) if err != nil { return nil, true, qrIOError(ctx, err) } - defer resp.Body.Close() - body, err := io.ReadAll(io.LimitReader(resp.Body, qrMaxBodyBytes+1)) + body, err := readResponseBody(resp, qrMaxBodyBytes) if err != nil { return nil, true, qrIOError(ctx, err) } - if len(body) > qrMaxBodyBytes { - return nil, true, errors.New("QR response exceeds size limit") - } if resp.StatusCode != http.StatusOK { // Gateway failures may use non-200 outer statuses. Only the validated // gateway envelope carries a protocol classification, never status alone. diff --git a/pkg/line/response_limits.go b/pkg/line/response_limits.go new file mode 100644 index 0000000..6678d4f --- /dev/null +++ b/pkg/line/response_limits.go @@ -0,0 +1,74 @@ +package line + +import ( + "bufio" + "errors" + "fmt" + "io" + "math" + "net/http" +) + +const ( + maxRPCResponseBytes = 16 << 20 + maxOBSMetadataBytes = 64 << 10 + maxErrorResponseBytes = 4 << 10 + maxMediaResponseBytes = (20 << 20) + 32 // plaintext cap plus attachment HMAC + maxSSELineBytes = 1 << 20 + maxSSEEventBytes = 4 << 20 + maxSSEEventLines = 4096 +) + +var ErrResponseTooLarge = errors.New("LINE response exceeds size limit") +var ErrInvalidResponseLimit = errors.New("invalid LINE response size limit") + +type ResponseLimitError struct{ Limit int64 } + +func (e *ResponseLimitError) Error() string { + return fmt.Sprintf("LINE response exceeds size limit (%d bytes)", e.Limit) +} +func (e *ResponseLimitError) Unwrap() error { return ErrResponseTooLarge } + +// readBounded never returns a partial body on error. Limits apply after HTTP +// transfer decoding and automatic decompression, not to Content-Length. +func readBounded(r io.Reader, limit int64) ([]byte, error) { + if limit < 0 || limit == math.MaxInt64 { + return nil, ErrInvalidResponseLimit + } + data, err := io.ReadAll(io.LimitReader(r, limit+1)) + if int64(len(data)) > limit { + return nil, &ResponseLimitError{Limit: limit} + } + if err != nil { + return nil, err + } + return data, nil +} + +func readResponseBody(resp *http.Response, limit int64) ([]byte, error) { + defer resp.Body.Close() + if limit < 0 || limit == math.MaxInt64 { + return nil, ErrInvalidResponseLimit + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + limit = min(limit, maxErrorResponseBytes) + } + return readBounded(resp.Body, limit) +} + +// A bounded line reader avoids ReadBytes' unbounded allocation for a single +// unterminated line. The terminator counts toward the line and event budgets. +func readSSELine(r *bufio.Reader) ([]byte, error) { + var line []byte + for { + fragment, err := r.ReadSlice('\n') + if len(fragment) > maxSSELineBytes-len(line) { + return nil, &ResponseLimitError{Limit: maxSSELineBytes} + } + line = append(line, fragment...) + if err == bufio.ErrBufferFull { + continue + } + return line, err + } +} diff --git a/pkg/line/response_limits_test.go b/pkg/line/response_limits_test.go new file mode 100644 index 0000000..60f70d2 --- /dev/null +++ b/pkg/line/response_limits_test.go @@ -0,0 +1,219 @@ +package line + +import ( + "bufio" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "errors" + "io" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestResponseBoundariesAndClose(t *testing.T) { + for _, n := range []int{31, 32, 33} { + for _, advertised := range []int64{-1, 0, 1, 1 << 30} { + closed := false + resp := &http.Response{StatusCode: 200, ContentLength: advertised, Body: &policyBody{Reader: strings.NewReader(strings.Repeat("x", n)), closed: &closed}} + data, err := readResponseBody(resp, 32) + if !closed || (n <= 32 && (err != nil || len(data) != n)) || (n > 32 && (!errors.Is(err, ErrResponseTooLarge) || data != nil)) { + t.Fatal("response budget or close failed") + } + } + } + for _, limit := range []int64{-1, math.MaxInt64} { + for _, status := range []int{200, 500} { + closed := false + _, err := readResponseBody(&http.Response{StatusCode: status, Body: &policyBody{Reader: strings.NewReader(""), closed: &closed}}, limit) + if !errors.Is(err, ErrInvalidResponseLimit) || !closed { + t.Fatal("invalid limit accepted") + } + } + } +} + +func TestResponseLimitsAfterHTTPDecompression(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Encoding", "gzip") + w.WriteHeader(200) + w.(http.Flusher).Flush() // chunked framing, no Content-Length + writer := gzip.NewWriter(w) + _, _ = io.WriteString(writer, strings.Repeat("x", 4097)) + _ = writer.Close() + })) + defer server.Close() + req, _ := http.NewRequest("GET", server.URL, nil) + resp, err := doLINERequest(server.Client(), req) + if err != nil { + t.Fatal(err) + } + if !resp.Uncompressed { + t.Fatal("fixture did not exercise HTTP decompression") + } + data, err := readResponseBody(resp, 4096) + if data != nil || !errors.Is(err, ErrResponseTooLarge) { + t.Fatal("compressed response bypassed budget") + } +} + +func TestOBSPreflightRejectsOversizeBeforeDownload(t *testing.T) { + c := NewClient("synthetic") + consumed, closed := 0, false + c.OBSClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + body := io.MultiReader(strings.NewReader(`{"status":"exist"}`), io.LimitReader(zeroSpaces{}, 21<<20)) + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: &countedBody{Reader: body, count: &consumed, closed: &closed}}, nil + }) + err := c.checkOBSObjectReady(context.Background(), OBSBaseURL+"/r/talk/m/synthetic", "synthetic", "123") + if !errors.Is(err, ErrResponseTooLarge) || !closed || consumed != maxOBSMetadataBytes+1 { + t.Fatal("unbounded OBS metadata read") + } +} + +type zeroSpaces struct{} + +func (zeroSpaces) Read(p []byte) (int, error) { + for i := range p { + p[i] = ' ' + } + return len(p), nil +} + +type countedBody struct { + io.Reader + count *int + closed *bool +} + +func (b *countedBody) Read(p []byte) (int, error) { + n, err := b.Reader.Read(p) + *b.count += n + return n, err +} +func (b *countedBody) Close() error { *b.closed = true; return nil } + +func TestRPCAndDownloadBudgets(t *testing.T) { + c := NewClient("synthetic") + consumed, closed := 0, false + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + consumed, closed = 0, false + return &http.Response{StatusCode: 200, Header: make(http.Header), Body: &countedBody{Reader: zeroSpaces{}, count: &consumed, closed: &closed}}, nil + }) + c.HTTPClient.Transport = transport + c.OBSClient.Transport = transport + _, err := c.callRPCContext(context.Background(), "TalkService", "getProfile") + if !errors.Is(err, ErrResponseTooLarge) || consumed != maxRPCResponseBytes+1 || !closed { + t.Fatal("unbounded RPC") + } + _, err = c.downloadOBSObject(context.Background(), OBSBaseURL+"/r/talk/m/synthetic", "synthetic", "123", 0) + if !errors.Is(err, ErrOBSSizeLimit) || consumed != maxMediaResponseBytes+1 || !closed { + t.Fatal("zero download limit was unbounded") + } +} + +func TestSSELineAndAggregateBudgets(t *testing.T) { + old := sseHTTPClient + t.Cleanup(func() { sseHTTPClient = old }) + for _, tc := range []struct { + name, body string + want int + oversize bool + }{ + {"exact-line", "data: " + strings.Repeat("x", maxSSELineBytes-7) + "\n\n", 1, false}, + {"exact-event", strings.Repeat("data: "+strings.Repeat("x", maxSSELineBytes-7)+"\n", 4) + "\n", 1, false}, + {"long-event", strings.Repeat("data: "+strings.Repeat("x", maxSSELineBytes-7)+"\n", 4) + "data: x\n\n", 0, true}, + {"long-line", "data: " + strings.Repeat("x", maxSSELineBytes) + "\n\n", 0, true}, + {"unterminated", strings.Repeat("x", maxSSELineBytes+1), 0, true}, + {"multiline", strings.Repeat("data: "+strings.Repeat("x", 1024)+"\n", 4096) + "\n", 0, true}, + {"comments", strings.Repeat(": comment\n", maxSSEEventLines+1) + "\n", 0, true}, + {"names", strings.Repeat("event: name\n", maxSSEEventLines+1) + "\n", 0, true}, + {"exact-count", strings.Repeat("data: x\n", maxSSEEventLines) + "\n", 1, false}, + {"reset", strings.Repeat("data: x\n\n", maxSSEEventLines+1), maxSSEEventLines + 1, false}, + } { + t.Run(tc.name, func(t *testing.T) { + closed := false + sseHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Body: &policyBody{Reader: strings.NewReader(tc.body), closed: &closed}}, nil + })} + emitted := 0 + err := NewClient("synthetic").ListenSSE(context.Background(), 0, func(string, string) { emitted++ }) + if errors.Is(err, ErrResponseTooLarge) != tc.oversize || emitted != tc.want || !closed { + t.Fatal("SSE exceeded budget or emitted partial event", err, emitted) + } + }) + } + // A fresh connection after a failed oversized stream still delivers normally. + sseHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return auditResponse(req, 200, strings.NewReader("data: recovered\n\n")), nil + })} + delivered := "" + _ = NewClient("synthetic").ListenSSE(context.Background(), 0, func(_, data string) { delivered = data }) + if delivered != "recovered" { + t.Fatal("reconnect lost valid event") + } +} + +func FuzzBoundedResponse(f *testing.F) { + f.Add([]byte("synthetic"), uint16(4)) + f.Add([]byte{}, uint16(0)) + f.Fuzz(func(t *testing.T, data []byte, limit uint16) { + result, err := readBounded(bytes.NewReader(data), int64(limit)) + if len(data) > int(limit) { + if !errors.Is(err, ErrResponseTooLarge) || result != nil { + t.Fatal("oversize accepted") + } + } else if err != nil || !bytes.Equal(data, result) { + t.Fatal("valid body changed") + } + }) +} +func FuzzSSELine(f *testing.F) { + f.Add("data: synthetic\n") + f.Add(strings.Repeat("x", 4097)) + f.Fuzz(func(t *testing.T, data string) { + line, err := readSSELine(bufio.NewReader(strings.NewReader(data))) + if len(line) > maxSSELineBytes { + t.Fatal("line exceeded limit") + } + n := strings.IndexByte(data, '\n') + if n < 0 { + n = len(data) + } else { + n++ + } + if n > maxSSELineBytes && !errors.Is(err, ErrResponseTooLarge) { + t.Fatal("oversize line accepted") + } + }) +} + +func TestRPCBudgetAcceptsLargeSyntheticHistory(t *testing.T) { + // 100 messages, each with 10,000 four-byte runes, exceed normal CLI UTF-16 + // text limits but still fit comfortably alongside envelopes and key metadata. + messages := make([]map[string]string, 100) + for i := range messages { + messages[i] = map[string]string{"id": "123", "text": strings.Repeat("๐Ÿ˜€", 10000)} + } + body, _ := json.Marshal(map[string]any{"code": 0, "data": messages}) + c := NewClient("synthetic") + c.HTTPClient.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) { + return auditResponse(req, 200, bytes.NewReader(body)), nil + }) + got, err := c.callRPCContext(context.Background(), "TalkService", "getRecentMessagesV2") + if err != nil || !bytes.Equal(got, body) { + t.Fatal("valid history rejected by response budget") + } +} +func TestErrorBodyBudget(t *testing.T) { + closed := false + consumed := 0 + resp := &http.Response{StatusCode: 500, Body: &countedBody{Reader: zeroSpaces{}, count: &consumed, closed: &closed}} + _, err := readResponseBody(resp, maxRPCResponseBytes) + if !errors.Is(err, ErrResponseTooLarge) || consumed != maxErrorResponseBytes+1 || !closed { + t.Fatal("error body budget not enforced") + } +} diff --git a/pkg/line/sse.go b/pkg/line/sse.go index 68d567e..480e6fe 100644 --- a/pkg/line/sse.go +++ b/pkg/line/sse.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "io" "net/http" "net/url" "strconv" @@ -20,7 +19,7 @@ var sseHTTPClient = &http.Client{} var ErrSSEIdleTimeout = errors.New("SSE idle timeout") -const maxSSEErrorBodyBytes = 4096 +const maxSSEErrorBodyBytes = maxErrorResponseBytes // LINE advertises a 140s polling timeout. The extra 10s lets a normal // server-driven close arrive before the client declares the stream idle. @@ -52,14 +51,17 @@ func (c *Client) ListenSSE(ctx context.Context, localRev int64, callback func(ev req.Header.Set("Cookie", fmt.Sprintf("lct=%s", c.AccessToken)) } - resp, err := sseHTTPClient.Do(req) + resp, err := doLINERequest(sseHTTPClient, req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != 200 { - body, _ := io.ReadAll(io.LimitReader(resp.Body, maxSSEErrorBodyBytes)) + body, readErr := readBounded(resp.Body, maxSSEErrorBodyBytes) + if readErr != nil { + return readErr + } if bodyText := strings.TrimSpace(string(body)); bodyText != "" { return fmt.Errorf("SSE error: %d: %s", resp.StatusCode, bodyText) } @@ -84,6 +86,7 @@ func (c *Client) ListenSSE(ctx context.Context, localRev int64, callback func(ev var currentEvent string var dataLines []string + eventBytes, eventLines := 0, 0 flush := func() { if len(dataLines) == 0 && currentEvent == "" { @@ -98,11 +101,11 @@ func (c *Client) ListenSSE(ctx context.Context, localRev int64, callback func(ev callback(eventType, data) } currentEvent = "" - dataLines = dataLines[:0] + dataLines = nil } for { - lineBytes, err := reader.ReadBytes('\n') + lineBytes, err := readSSELine(reader) if err != nil { if idleTimedOut.Load() { return fmt.Errorf("%w after %s", ErrSSEIdleTimeout, idleTimeout) @@ -119,9 +122,16 @@ func (c *Client) ListenSSE(ctx context.Context, localRev int64, callback func(ev line := strings.TrimRight(string(lineBytes), "\r\n") if line == "" { flush() + eventBytes, eventLines = 0, 0 continue } + eventBytes += len(lineBytes) + eventLines++ + if eventBytes > maxSSEEventBytes || eventLines > maxSSEEventLines { + return &ResponseLimitError{Limit: maxSSEEventBytes} + } + parts := strings.SplitN(line, ":", 2) if len(parts) != 2 { continue diff --git a/scripts/check-toolchain.sh b/scripts/check-toolchain.sh new file mode 100755 index 0000000..fe73f52 --- /dev/null +++ b/scripts/check-toolchain.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Keep release builds on the explicitly reviewed security toolchain. Inspect the +# compiler chosen by GO/GOTOOLCHAIN, not an unrelated executable on PATH. +set -eu +compiler=$("${GO:-go}" env GOVERSION) +if [ "$compiler" != "go1.27.2" ]; then + printf '%s\n' "LINE CLI requires the reviewed Go 1.27.2 toolchain (selected: $compiler). Set GOTOOLCHAIN=go1.27.2." >&2 + exit 1 +fi diff --git a/scripts/security-check.sh b/scripts/security-check.sh new file mode 100755 index 0000000..face49f --- /dev/null +++ b/scripts/security-check.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Source scan on the selected platform; optionally scan one built binary. +set -eu +cd "$(dirname "$0")/.." +./scripts/check-toolchain.sh +"${GO:-go}" run golang.org/x/vuln/cmd/govulncheck@v1.9.0 ./cmd/line ./pkg/line/... ./pkg/e2ee ./pkg +if [ "$#" -gt 0 ]; then + "${GO:-go}" version -m "$1" + "${GO:-go}" run golang.org/x/vuln/cmd/govulncheck@v1.9.0 -mode=binary "$1" +fi