From 24db71303ef4d41d8e920c5a3ed90ac44bc5131a Mon Sep 17 00:00:00 2001 From: kongesque Date: Sun, 18 Oct 2026 13:35:03 +0800 Subject: [PATCH 01/13] fix: keep encrypted file keys and filenames inside sealed payloads --- docs/protocol/endpoints/media-download.md | 11 +++ internal/messaging/file_security_test.go | 89 +++++++++++++++++++++++ internal/messaging/files.go | 5 +- internal/messaging/files_test.go | 6 +- 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 internal/messaging/file_security_test.go diff --git a/docs/protocol/endpoints/media-download.md b/docs/protocol/endpoints/media-download.md index 5f8c548..4551dca 100644 --- a/docs/protocol/endpoints/media-download.md +++ b/docs/protocol/endpoints/media-download.md @@ -89,3 +89,14 @@ 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. diff --git a/internal/messaging/file_security_test.go b/internal/messaging/file_security_test.go new file mode 100644 index 0000000..15b3bc2 --- /dev/null +++ b/internal/messaging/file_security_test.go @@ -0,0 +1,89 @@ +package messaging + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/kongesque/line-cli/internal/session" + "github.com/kongesque/line-cli/pkg/line" +) + +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") + } + } + }) + } +} 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..a784035 100644 --- a/internal/messaging/files_test.go +++ b/internal/messaging/files_test.go @@ -49,15 +49,15 @@ func TestFileSendEnvelopeAndUploadOrdering(t *testing.T) { 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 From e3441384837dca64959c883170fe5d9c22f7b1b5 Mon Sep 17 00:00:00 2001 From: kongesque Date: Sun, 18 Oct 2026 13:35:17 +0800 Subject: [PATCH 02/13] fix: disable encrypted image sends that reuse a CTR stream --- docs/CLI.md | 3 ++ docs/protocol/endpoints/image-send.md | 11 +++++ internal/messaging/client.go | 3 ++ internal/messaging/images.go | 41 ++-------------- internal/messaging/images_test.go | 67 +++++++-------------------- 5 files changed, 38 insertions(+), 87 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index b20282f..6f956ec 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -375,6 +375,9 @@ audio/video sends are unsupported. Image preparation is local and precedes sessi 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 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/internal/messaging/client.go b/internal/messaging/client.go index 1c43866..821b160 100644 --- a/internal/messaging/client.go +++ b/internal/messaging/client.go @@ -363,6 +363,9 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att } plain = flow.FlowMap["1"] == 1 } + if image != nil && !plain { + return nil, encryptedImageUnavailable() + } groupKeyRegistered := false metadata := make(map[string]string) if mentionMetadata != "" { diff --git a/internal/messaging/images.go b/internal/messaging/images.go index 43eb9e4..29aba27 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. @@ -108,38 +104,11 @@ func (c *Client) uploadAttachment(ctx context.Context, file *Attachment, img *Pr 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 is disabled because the full image and preview reuse an encryption stream; nothing was sent", 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..64aee18 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.SendImageContext(context.Background(), chat, *img, "") + 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") @@ -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") } }) From 4154d84ae8cd411de0c52c78f1e330806eb77efa Mon Sep 17 00:00:00 2001 From: kongesque Date: Sun, 18 Oct 2026 13:38:53 +0800 Subject: [PATCH 03/13] feat: require explicit per-send consent for plaintext fallback --- AGENTS.md | 4 +- cmd/line/signals_test.go | 6 +- docs/CLI.md | 7 ++ docs/ERRORS.md | 5 + docs/SECURITY-CHANGES.md | 21 ++++ internal/cli/chats_test.go | 4 +- internal/cli/diagnostics_test.go | 2 +- internal/cli/errors_test.go | 6 +- internal/cli/images_test.go | 4 +- internal/cli/messages.go | 9 +- internal/cli/messages_test.go | 8 +- internal/cli/prompt_test.go | 8 +- internal/cli/send_policy_test.go | 50 +++++++++ internal/messaging/actions_test.go | 4 +- internal/messaging/cancellation_test.go | 13 ++- internal/messaging/client.go | 48 +++++++-- internal/messaging/client_test.go | 5 +- internal/messaging/files_test.go | 4 +- internal/messaging/history_test.go | 2 +- internal/messaging/images.go | 6 +- internal/messaging/images_test.go | 4 +- internal/messaging/mentions_test.go | 2 +- internal/messaging/outcome_test.go | 12 +-- internal/messaging/send_policy_test.go | 131 ++++++++++++++++++++++++ 24 files changed, 314 insertions(+), 51 deletions(-) create mode 100644 docs/SECURITY-CHANGES.md create mode 100644 internal/cli/send_policy_test.go create mode 100644 internal/messaging/send_policy_test.go diff --git a/AGENTS.md b/AGENTS.md index 40526ab..1e8f4bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,9 @@ 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` +consent plus explicit account/server capability evidence. 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/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 6f956ec..3a526b5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -386,6 +386,13 @@ 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, the command returns `encryption_required` before sending. +Use `--allow-plaintext` on that 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. + 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..5a6fea2 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -154,3 +154,8 @@ 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. `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..e0d21a3 --- /dev/null +++ b/docs/SECURITY-CHANGES.md @@ -0,0 +1,21 @@ +# 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` remains available for original bytes. +- All sends require encryption by default. `--allow-plaintext` permits plaintext + only after an explicit account/server capability signal. 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. + +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. +Validation uses synthetic data; no live LINE sends or interoperability checks +were performed for these changes. 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..4290f03 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, "permit content visible to LINE only when Letter Sealing is explicitly unavailable") 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") @@ -265,13 +267,14 @@ func (a *App) messageCommand(command string, args []string) error { return err } if command == "send" { + options := messaging.SendOptions{AllowPlaintext: allowPlaintext} var result *messaging.SendResult if preparedImage != nil { - result, err = client.SendImageContext(a.loginContext(), chat, *preparedImage, replyTo) + result, err = client.SendImageWithOptionsContext(a.loginContext(), chat, *preparedImage, replyTo, options) } else if attachment != nil { - result, err = client.SendFileContext(a.loginContext(), chat, *attachment, replyTo) + result, err = client.SendFileWithOptionsContext(a.loginContext(), chat, *attachment, replyTo, options) } else { - result, err = client.SendMentionsContext(a.loginContext(), chat, text, replyTo, mentions) + result, err = client.SendMentionsWithOptionsContext(a.loginContext(), chat, text, replyTo, mentions, options) } if err != nil { return err 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_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..6f26940 --- /dev/null +++ b/internal/cli/send_policy_test.go @@ -0,0 +1,50 @@ +package cli + +import ( + "encoding/json" + "github.com/kongesque/line-cli/internal/session" + "strings" + "testing" +) + +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(err.Error(), "nothing was sent") { + t.Fatal("missing safe consent error") + } + }) + } + } +} +func TestGuidedSendAlsoRequiresPlaintextConsent(t *testing.T) { + a, api, _, _, _ := guidedApp(t, "/New\n1\nsynthetic\n") + err := a.Run([]string{"send"}) + if err == nil || !strings.Contains(err.Error(), "--allow-plaintext") || api.sends != 0 { + t.Fatal("interactive send bypassed consent", err) + } +} 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 821b160..bf07548 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) error { + if plain && !o.AllowPlaintext { + return fault.New("encryption_required", "preparation", "Letter Sealing is unavailable; nothing was sent; use --allow-plaintext to explicitly permit sending content visible to LINE", 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" @@ -363,6 +387,12 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att } plain = flow.FlowMap["1"] == 1 } + if err := ctx.Err(); err != nil { + return nil, err + } + if err := options.checkPlaintext(plain); err != nil { + return nil, err + } if image != nil && !plain { return nil, encryptedImageUnavailable() } @@ -390,6 +420,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); err != nil { + return nil, err + } plain = true } else if keyErr != nil { return nil, keyErr @@ -419,6 +452,9 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att } } if errors.Is(err, errNoLetterSealing) { + if err := options.checkPlaintext(true); 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/files_test.go b/internal/messaging/files_test.go index a784035..1520eb5 100644 --- a/internal/messaging/files_test.go +++ b/internal/messaging/files_test.go @@ -45,7 +45,7 @@ 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) } @@ -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 29aba27..7491bbf 100644 --- a/internal/messaging/images.go +++ b/internal/messaging/images.go @@ -91,13 +91,17 @@ 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) { diff --git a/internal/messaging/images_test.go b/internal/messaging/images_test.go index 64aee18..5e8f354 100644 --- a/internal/messaging/images_test.go +++ b/internal/messaging/images_test.go @@ -103,7 +103,7 @@ func TestImageSendFlows(t *testing.T) { c.crypto = &fileCrypto{fakeCrypto: crypto} c.Session.NewClient = func(string) session.API { return api } for range 2 { - result, err := c.SendImageContext(context.Background(), chat, *img, "") + 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 { @@ -138,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")) { 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..3c69d6b --- /dev/null +++ b/internal/messaging/send_policy_test.go @@ -0,0 +1,131 @@ +package messaging + +import ( + "context" + "errors" + "fmt" + "github.com/kongesque/line-cli/internal/fault" + "github.com/kongesque/line-cli/internal/session" + "github.com/kongesque/line-cli/pkg/line" + "net/http" + "strings" + "testing" +) + +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) + } + } +} From a08308bb5358ff5019656a4332f391107f08494d Mon Sep 17 00:00:00 2001 From: kongesque Date: Sun, 18 Oct 2026 13:38:55 +0800 Subject: [PATCH 04/13] fix: reject LINE HTTP redirects before credential or body replay --- pkg/line/client.go | 24 +++---- pkg/line/http_policy.go | 29 ++++++++ pkg/line/http_policy_test.go | 128 +++++++++++++++++++++++++++++++++++ pkg/line/qr_login.go | 2 +- pkg/line/sse.go | 2 +- 5 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 pkg/line/http_policy.go create mode 100644 pkg/line/http_policy_test.go diff --git a/pkg/line/client.go b/pkg/line/client.go index fa0e884..86d0de4 100644 --- a/pkg/line/client.go +++ b/pkg/line/client.go @@ -209,7 +209,7 @@ 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 } @@ -273,7 +273,7 @@ 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 } @@ -405,7 +405,7 @@ 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) } @@ -498,7 +498,7 @@ 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) } @@ -609,7 +609,7 @@ 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) } @@ -678,7 +678,7 @@ 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) } @@ -743,7 +743,7 @@ 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) } @@ -796,7 +796,7 @@ 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) } @@ -859,7 +859,7 @@ 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) } @@ -953,7 +953,7 @@ 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) } @@ -991,7 +991,7 @@ 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) } @@ -1094,7 +1094,7 @@ 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 } 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..f7e2c5c 100644 --- a/pkg/line/qr_login.go +++ b/pkg/line/qr_login.go @@ -400,7 +400,7 @@ 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) } diff --git a/pkg/line/sse.go b/pkg/line/sse.go index 68d567e..f872569 100644 --- a/pkg/line/sse.go +++ b/pkg/line/sse.go @@ -52,7 +52,7 @@ 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 } From d755bd518fc3667e756e8d9eb8af47256ac889f7 Mon Sep 17 00:00:00 2001 From: kongesque Date: Sun, 18 Oct 2026 13:42:46 +0800 Subject: [PATCH 05/13] fix: bound LINE response bodies and SSE event accumulation --- docs/SECURITY-CHANGES.md | 13 ++ pkg/line/client.go | 72 +++++----- pkg/line/qr_login.go | 7 +- pkg/line/response_limits.go | 74 +++++++++++ pkg/line/response_limits_test.go | 219 +++++++++++++++++++++++++++++++ pkg/line/sse.go | 20 ++- 6 files changed, 360 insertions(+), 45 deletions(-) create mode 100644 pkg/line/response_limits.go create mode 100644 pkg/line/response_limits_test.go diff --git a/docs/SECURITY-CHANGES.md b/docs/SECURITY-CHANGES.md index e0d21a3..bcc9b3e 100644 --- a/docs/SECURITY-CHANGES.md +++ b/docs/SECURITY-CHANGES.md @@ -19,3 +19,16 @@ stream for each object using the same key material. No secure independent preview representation was established. Source remains copyright LY Corporation. Validation uses synthetic data; no live LINE sends or interoperability checks were performed for these changes. + +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. diff --git a/pkg/line/client.go b/pkg/line/client.go index 86d0de4..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 @@ -213,9 +212,11 @@ func (c *Client) waitForLoginJQ(verifier string) (*LoginResult, error) { 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 { @@ -277,9 +278,11 @@ func (c *Client) waitForLoginLF1(verifier string) (*LoginResult, error) { 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"` @@ -409,9 +412,8 @@ func (c *Client) callRPCWithBaseURLContext(ctx context.Context, baseURL, service 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) } @@ -502,9 +504,8 @@ func (c *Client) postWithHMACContext(ctx context.Context, fullURL string, body [ 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) } @@ -613,12 +614,14 @@ func (c *Client) UploadOBSPlainContext(ctx context.Context, data []byte, oid str 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 @@ -682,12 +685,14 @@ func (c *Client) UploadOBSWithSIDContext(ctx context.Context, data []byte, sid s 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 @@ -747,12 +752,14 @@ func (c *Client) UploadOBSWithOIDAndSIDContext(ctx context.Context, data []byte, 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 @@ -800,8 +807,7 @@ func (c *Client) DownloadOBSPublicResource(ctx context.Context, resourcePath str 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) } @@ -863,8 +869,7 @@ func (c *Client) DownloadAlbumPreview(ctx context.Context, oid, chatID, albumID 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) } @@ -957,8 +962,7 @@ func (c *Client) checkOBSObjectReady(ctx context.Context, obsURL, obsToken, mess 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) } @@ -995,16 +999,13 @@ func (c *Client) downloadOBSObject(ctx context.Context, obsURL, obsToken, messag 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) } @@ -1098,14 +1099,17 @@ func (c *Client) GetPageInfo(url string) (*PageInfoResult, error) { 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/qr_login.go b/pkg/line/qr_login.go index f7e2c5c..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" @@ -404,14 +403,10 @@ func (c *Client) qrRequest(ctx context.Context, serviceRoot, method string, arg 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 f872569..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. @@ -59,7 +58,10 @@ func (c *Client) ListenSSE(ctx context.Context, localRev int64, callback func(ev 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 From 8e2f04d14e81103151497a318ec65020a089a105 Mon Sep 17 00:00:00 2001 From: kongesque Date: Sun, 18 Oct 2026 18:29:17 +0800 Subject: [PATCH 06/13] build: pin patched Go toolchain and scan release artifacts --- .github/workflows/cli-release.yml | 14 ++++++++++++-- .github/workflows/cli.yml | 22 +++++++++++++++++++--- AGENTS.md | 2 +- README.md | 2 +- build.sh | 3 ++- docs/SECURITY-CHANGES.md | 26 ++++++++++++++++++++++++++ docs/TESTING.md | 22 +++++++++++++++++++++- go.mod | 2 +- scripts/check-toolchain.sh | 9 +++++++++ scripts/security-check.sh | 10 ++++++++++ 10 files changed, 102 insertions(+), 10 deletions(-) create mode 100755 scripts/check-toolchain.sh create mode 100755 scripts/security-check.sh 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..bb54c4f 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +env: + LINE_CLI_LIVE_READ: "0" + LINE_CLI_LIVE_WATCH: "0" + jobs: crypto: name: Runner and LTSM regressions @@ -19,8 +23,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 +41,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 +87,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 1e8f4bf..7d17bf6 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 diff --git a/README.md b/README.md index 2718bc0..941248b 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,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/docs/SECURITY-CHANGES.md b/docs/SECURITY-CHANGES.md index bcc9b3e..3f0201a 100644 --- a/docs/SECURITY-CHANGES.md +++ b/docs/SECURITY-CHANGES.md @@ -32,3 +32,29 @@ 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. diff --git a/docs/TESTING.md b/docs/TESTING.md index 0bf6292..6cc4f47 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 @@ -198,3 +198,23 @@ 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, redirect rejection, and response/SSE budgets. Cross builds do +not substitute for native Linux keyring or Windows DPAPI integration tests. 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/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 From d8df371003f9957d802be0a939da361c46185560 Mon Sep 17 00:00:00 2001 From: kongesque Date: Sun, 18 Oct 2026 18:34:09 +0800 Subject: [PATCH 07/13] test: verify encrypted file delivery with real recipient crypto --- docs/CLI.md | 7 +- internal/cli/send_policy_test.go | 3 +- internal/messaging/file_security_test.go | 102 +++++++++++++++++++++++ internal/messaging/send_policy_test.go | 7 +- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index 3a526b5..3902b61 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -369,7 +369,6 @@ 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 @@ -379,9 +378,9 @@ 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 `--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. diff --git a/internal/cli/send_policy_test.go b/internal/cli/send_policy_test.go index 6f26940..187011b 100644 --- a/internal/cli/send_policy_test.go +++ b/internal/cli/send_policy_test.go @@ -2,9 +2,10 @@ package cli import ( "encoding/json" - "github.com/kongesque/line-cli/internal/session" "strings" "testing" + + "github.com/kongesque/line-cli/internal/session" ) func TestCLIRequiresPlaintextConsent(t *testing.T) { diff --git a/internal/messaging/file_security_test.go b/internal/messaging/file_security_test.go index 15b3bc2..25e9cb2 100644 --- a/internal/messaging/file_security_test.go +++ b/internal/messaging/file_security_test.go @@ -3,6 +3,9 @@ package messaging import ( "bytes" "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" "encoding/json" "io" "net/http" @@ -10,7 +13,10 @@ import ( "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) @@ -87,3 +93,99 @@ func TestFileWireEnvelopeProtectsKeyAndName(t *testing.T) { }) } } + +// 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/send_policy_test.go b/internal/messaging/send_policy_test.go index 3c69d6b..c590521 100644 --- a/internal/messaging/send_policy_test.go +++ b/internal/messaging/send_policy_test.go @@ -4,12 +4,13 @@ import ( "context" "errors" "fmt" - "github.com/kongesque/line-cli/internal/fault" - "github.com/kongesque/line-cli/internal/session" - "github.com/kongesque/line-cli/pkg/line" "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 { From f4c155b3a3a6bba738a3ddcd18b0c033de68dc4c Mon Sep 17 00:00:00 2001 From: kongesque Date: Sun, 18 Oct 2026 18:43:18 +0800 Subject: [PATCH 08/13] docs: record security remediation validation and release gates --- docs/SECURITY-CHANGES.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/SECURITY-CHANGES.md b/docs/SECURITY-CHANGES.md index 3f0201a..3736d44 100644 --- a/docs/SECURITY-CHANGES.md +++ b/docs/SECURITY-CHANGES.md @@ -58,3 +58,21 @@ 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. No live +LINE interoperability test, upload, send, or group-key registration was run. From 09f7b07599637bff6368bf7e8c3dce227c4f6606 Mon Sep 17 00:00:00 2001 From: kongesque Date: Mon, 19 Oct 2026 00:21:22 +0800 Subject: [PATCH 09/13] fix: validate file capability and add guarded live security checks --- .github/workflows/cli.yml | 2 + docs/CLI.md | 6 + docs/SECURITY-CHANGES.md | 35 ++- docs/TESTING.md | 20 +- docs/protocol/endpoints/media-download.md | 18 ++ internal/messaging/client.go | 12 +- internal/messaging/live_security_test.go | 296 ++++++++++++++++++++++ internal/messaging/send_policy_test.go | 54 ++++ internal/messaging/test_helpers_test.go | 4 + 9 files changed, 435 insertions(+), 12 deletions(-) create mode 100644 internal/messaging/live_security_test.go diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index bb54c4f..5481a44 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -11,6 +11,8 @@ permissions: 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: diff --git a/docs/CLI.md b/docs/CLI.md index 3902b61..c520240 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -392,6 +392,12 @@ 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; +`--allow-plaintext` is required to send the file without Letter Sealing. +Missing, unknown, or unreadable media capabilities never authorize plaintext. + 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/SECURITY-CHANGES.md b/docs/SECURITY-CHANGES.md index 3736d44..13b932d 100644 --- a/docs/SECURITY-CHANGES.md +++ b/docs/SECURITY-CHANGES.md @@ -5,7 +5,8 @@ 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` remains available for original bytes. + 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. `--allow-plaintext` permits plaintext only after an explicit account/server capability signal. HTTPS still protects the connection, but LINE can read this content. The option does not suppress @@ -17,8 +18,8 @@ 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. -Validation uses synthetic data; no live LINE sends or interoperability checks -were performed for these changes. +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 @@ -74,5 +75,29 @@ 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. No live -LINE interoperability test, upload, send, or group-key registration was run. +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. Successful live +encrypted file delivery and authenticated download remain unverified on flow 2; +the earlier synthetic recipient roundtrip does not establish that live result. diff --git a/docs/TESTING.md b/docs/TESTING.md index 6cc4f47..a3cd93b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -145,9 +145,23 @@ 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 the 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 diff --git a/docs/protocol/endpoints/media-download.md b/docs/protocol/endpoints/media-download.md index 4551dca..b564c3f 100644 --- a/docs/protocol/endpoints/media-download.md +++ b/docs/protocol/endpoints/media-download.md @@ -100,3 +100,21 @@ Its shared `gL` packages `ENC_KM` and `FILE_NAME` as `keyMaterial` and `fileName 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. diff --git a/internal/messaging/client.go b/internal/messaging/client.go index bf07548..e22fe12 100644 --- a/internal/messaging/client.go +++ b/internal/messaging/client.go @@ -377,15 +377,19 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att } } 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 diff --git a/internal/messaging/live_security_test.go b/internal/messaging/live_security_test.go new file mode 100644 index 0000000..3797a7a --- /dev/null +++ b/internal/messaging/live_security_test.go @@ -0,0 +1,296 @@ +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() + 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/send_policy_test.go b/internal/messaging/send_policy_test.go index c590521..e961d58 100644 --- a/internal/messaging/send_policy_test.go +++ b/internal/messaging/send_policy_test.go @@ -130,3 +130,57 @@ func TestLegacySendMethodsRequireEncryption(t *testing.T) { } } } + +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 +} From 149c2e626a87754404bafcf9569f258b7d7d74f8 Mon Sep 17 00:00:00 2001 From: kongesque Date: Mon, 19 Oct 2026 00:49:49 +0800 Subject: [PATCH 10/13] test: verify live group media negotiation and plaintext delivery --- docs/SECURITY-CHANGES.md | 17 ++++++++++++++++- docs/TESTING.md | 3 ++- docs/protocol/endpoints/media-download.md | 8 ++++++++ internal/messaging/live_security_test.go | 18 ++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/SECURITY-CHANGES.md b/docs/SECURITY-CHANGES.md index 13b932d..d1010ed 100644 --- a/docs/SECURITY-CHANGES.md +++ b/docs/SECURITY-CHANGES.md @@ -98,6 +98,21 @@ A read-only capability check showed file flow 1 (plaintext-only), despite workin 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. Successful live +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. diff --git a/docs/TESTING.md b/docs/TESTING.md index a3cd93b..acc04ec 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -147,7 +147,8 @@ The current opt-ins are `LINE_CLI_LIVE_READ=1` for `TestLiveHistory` and watch checks read real events and update the saved watch cursor. Neither test 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 the media capability and, for plaintext-only files, verifies default +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 diff --git a/docs/protocol/endpoints/media-download.md b/docs/protocol/endpoints/media-download.md index b564c3f..4faef03 100644 --- a/docs/protocol/endpoints/media-download.md +++ b/docs/protocol/endpoints/media-download.md @@ -118,3 +118,11 @@ 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. diff --git a/internal/messaging/live_security_test.go b/internal/messaging/live_security_test.go index 3797a7a..6e81a66 100644 --- a/internal/messaging/live_security_test.go +++ b/internal/messaging/live_security_test.go @@ -248,6 +248,24 @@ func TestLiveSecurityMediaFlow(t *testing.T) { 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 { From ce373fdd5f63f293b9c93a43a59d85fd5389ad96 Mon Sep 17 00:00:00 2001 From: kongesque Date: Mon, 19 Oct 2026 00:52:44 +0800 Subject: [PATCH 11/13] ux: make send guidance clearer and less technical --- docs/CLI.md | 9 +++++++++ internal/cli/messages.go | 6 +++--- internal/cli/send_policy_test.go | 2 +- internal/messaging/client.go | 16 +++++++++++----- internal/messaging/images.go | 2 +- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index c520240..2cbb8e6 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -397,6 +397,15 @@ 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; `--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: + +```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 diff --git a/internal/cli/messages.go b/internal/cli/messages.go index 4290f03..6444f2c 100644 --- a/internal/cli/messages.go +++ b/internal/cli/messages.go @@ -52,7 +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, "permit content visible to LINE only when Letter Sealing is explicitly unavailable") + fs.BoolVar(&allowPlaintext, "allow-plaintext", false, "send without end-to-end encryption when needed (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") @@ -283,9 +283,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/send_policy_test.go b/internal/cli/send_policy_test.go index 187011b..cba6319 100644 --- a/internal/cli/send_policy_test.go +++ b/internal/cli/send_policy_test.go @@ -35,7 +35,7 @@ func TestCLIRequiresPlaintextConsent(t *testing.T) { t.Fatal("send without consent changed state") } encoded, _ := json.Marshal(machineDetail(t, err)) - if !strings.Contains(string(encoded), `"code":"encryption_required"`) || !strings.Contains(err.Error(), "nothing was sent") { + if !strings.Contains(string(encoded), `"code":"encryption_required"`) || !strings.Contains(strings.ToLower(err.Error()), "nothing was sent") { t.Fatal("missing safe consent error") } }) diff --git a/internal/messaging/client.go b/internal/messaging/client.go index e22fe12..f1158b1 100644 --- a/internal/messaging/client.go +++ b/internal/messaging/client.go @@ -275,9 +275,9 @@ type SendOptions struct { AllowPlaintext bool } -func (o SendOptions) checkPlaintext(plain bool) error { +func (o SendOptions) checkPlaintext(plain bool, item string) error { if plain && !o.AllowPlaintext { - return fault.New("encryption_required", "preparation", "Letter Sealing is unavailable; nothing was sent; use --allow-plaintext to explicitly permit sending content visible to LINE", nil) + 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 } @@ -376,6 +376,12 @@ 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 file != nil && !plain { flowType, mediaName := "14", "file" @@ -394,7 +400,7 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att if err := ctx.Err(); err != nil { return nil, err } - if err := options.checkPlaintext(plain); err != nil { + if err := options.checkPlaintext(plain, item); err != nil { return nil, err } if image != nil && !plain { @@ -424,7 +430,7 @@ 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); err != nil { + if err := options.checkPlaintext(true, item); err != nil { return nil, err } plain = true @@ -456,7 +462,7 @@ func (c *Client) send(ctx context.Context, chat, text, replyTo string, file *Att } } if errors.Is(err, errNoLetterSealing) { - if err := options.checkPlaintext(true); err != nil { + if err := options.checkPlaintext(true, item); err != nil { return nil, err } plain, err = true, nil diff --git a/internal/messaging/images.go b/internal/messaging/images.go index 7491bbf..6865759 100644 --- a/internal/messaging/images.go +++ b/internal/messaging/images.go @@ -112,7 +112,7 @@ func (c *Client) uploadAttachment(ctx context.Context, file *Attachment, img *Pr } func encryptedImageUnavailable() error { - return fault.New("encrypted_image_unavailable", "preparation", "encrypted image sending is disabled because the full image and preview reuse an encryption stream; nothing was sent", nil) + 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. From 83c36decf4ad4f604622d82f616c118aee68aeb8 Mon Sep 17 00:00:00 2001 From: kongesque Date: Mon, 19 Oct 2026 00:57:13 +0800 Subject: [PATCH 12/13] docs: record direct-chat file delivery verification --- docs/SECURITY-CHANGES.md | 9 +++++++++ docs/protocol/endpoints/media-download.md | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/docs/SECURITY-CHANGES.md b/docs/SECURITY-CHANGES.md index d1010ed..7fbf66a 100644 --- a/docs/SECURITY-CHANGES.md +++ b/docs/SECURITY-CHANGES.md @@ -116,3 +116,12 @@ 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/protocol/endpoints/media-download.md b/docs/protocol/endpoints/media-download.md index 4faef03..fb3f911 100644 --- a/docs/protocol/endpoints/media-download.md +++ b/docs/protocol/endpoints/media-download.md @@ -126,3 +126,10 @@ spec version 1, allowed text, excluded file type 14, and still returned file flo 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. From 4e0ed55a424e06273c9a4d5e5503766bd74b75aa Mon Sep 17 00:00:00 2001 From: kongesque Date: Mon, 19 Oct 2026 01:06:34 +0800 Subject: [PATCH 13/13] feat(cli): confirm plaintext sends interactively --- AGENTS.md | 3 +- README.md | 4 +- docs/CLI.md | 25 +++- docs/ERRORS.md | 5 +- docs/SECURITY-CHANGES.md | 16 ++- docs/TESTING.md | 4 +- internal/cli/messages.go | 51 ++++++-- internal/cli/prompt.go | 28 +++++ internal/cli/send_policy_test.go | 195 ++++++++++++++++++++++++++++++- 9 files changed, 306 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7d17bf6..ac43272 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,7 +44,8 @@ integration requirements, and CI coverage. ## Behavior and validation Require encryption by default; plaintext needs per-command `--allow-plaintext` -consent plus explicit account/server capability evidence. Preserve explicit +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. diff --git a/README.md b/README.md index 941248b..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 diff --git a/docs/CLI.md b/docs/CLI.md index 2cbb8e6..a3ca0f0 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -379,15 +379,29 @@ 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. Plaintext-capable -images require `--allow-plaintext`; message creation and upload each happen once. +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, the command returns `encryption_required` before sending. -Use `--allow-plaintext` on that command to permit content visible to LINE after +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. @@ -395,10 +409,11 @@ 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; -`--allow-plaintext` is required to send the file without Letter Sealing. +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: +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. diff --git a/docs/ERRORS.md b/docs/ERRORS.md index 5a6fea2..67c1cf2 100644 --- a/docs/ERRORS.md +++ b/docs/ERRORS.md @@ -156,6 +156,9 @@ 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. `encrypted_image_unavailable` means +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 index 7fbf66a..8ce7ecd 100644 --- a/docs/SECURITY-CHANGES.md +++ b/docs/SECURITY-CHANGES.md @@ -7,11 +7,23 @@ 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. `--allow-plaintext` permits plaintext - only after an explicit account/server capability signal. HTTPS still protects +- 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; diff --git a/docs/TESTING.md b/docs/TESTING.md index acc04ec..2ef29f4 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -231,5 +231,7 @@ 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, redirect rejection, and response/SSE budgets. Cross builds do +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/internal/cli/messages.go b/internal/cli/messages.go index 6444f2c..df79275 100644 --- a/internal/cli/messages.go +++ b/internal/cli/messages.go @@ -52,7 +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, "send without end-to-end encryption when needed (LINE can read it)") + 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") @@ -254,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 @@ -268,13 +272,42 @@ func (a *App) messageCommand(command string, args []string) error { } if command == "send" { options := messaging.SendOptions{AllowPlaintext: allowPlaintext} - var result *messaging.SendResult - if preparedImage != nil { - result, err = client.SendImageWithOptionsContext(a.loginContext(), chat, *preparedImage, replyTo, options) - } else if attachment != nil { - result, err = client.SendFileWithOptionsContext(a.loginContext(), chat, *attachment, replyTo, options) - } else { - result, err = client.SendMentionsWithOptionsContext(a.loginContext(), chat, text, replyTo, mentions, options) + 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 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/send_policy_test.go b/internal/cli/send_policy_test.go index cba6319..21631ac 100644 --- a/internal/cli/send_policy_test.go +++ b/internal/cli/send_policy_test.go @@ -1,11 +1,19 @@ 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) { @@ -42,10 +50,187 @@ func TestCLIRequiresPlaintextConsent(t *testing.T) { } } } -func TestGuidedSendAlsoRequiresPlaintextConsent(t *testing.T) { - a, api, _, _, _ := guidedApp(t, "/New\n1\nsynthetic\n") - err := a.Run([]string{"send"}) - if err == nil || !strings.Contains(err.Error(), "--allow-plaintext") || api.sends != 0 { - t.Fatal("interactive send bypassed consent", err) + +// 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") + } + }) } }